From f306cfa8b6445dd04c5f7188d1a5022bcb747a62 Mon Sep 17 00:00:00 2001 From: Jason Date: Thu, 9 Jun 2016 17:46:14 +0800 Subject: [web] separate flowtable to multiple files --- web/src/js/app.js | 41 ----- web/src/js/app.jsx | 41 +++++ web/src/js/components/FlowTable.jsx | 120 +++++++++++++ web/src/js/components/FlowTable/FlowColumns.jsx | 137 +++++++++++++++ web/src/js/components/FlowTable/FlowRow.jsx | 28 +++ web/src/js/components/FlowTable/FlowTableHead.jsx | 43 +++++ web/src/js/components/MainView.js | 191 -------------------- web/src/js/components/MainView.jsx | 200 +++++++++++++++++++++ web/src/js/components/ProxyApp.js | 169 ------------------ web/src/js/components/ProxyApp.jsx | 170 ++++++++++++++++++ web/src/js/components/flowtable-columns.js | 131 -------------- web/src/js/components/flowtable.js | 201 ---------------------- web/src/js/ducks/flows.js | 6 +- 13 files changed, 742 insertions(+), 736 deletions(-) delete mode 100644 web/src/js/app.js create mode 100644 web/src/js/app.jsx create mode 100644 web/src/js/components/FlowTable.jsx create mode 100644 web/src/js/components/FlowTable/FlowColumns.jsx create mode 100644 web/src/js/components/FlowTable/FlowRow.jsx create mode 100644 web/src/js/components/FlowTable/FlowTableHead.jsx delete mode 100644 web/src/js/components/MainView.js create mode 100644 web/src/js/components/MainView.jsx delete mode 100644 web/src/js/components/ProxyApp.js create mode 100644 web/src/js/components/ProxyApp.jsx delete mode 100644 web/src/js/components/flowtable-columns.js delete mode 100644 web/src/js/components/flowtable.js (limited to 'web/src/js') diff --git a/web/src/js/app.js b/web/src/js/app.js deleted file mode 100644 index 8fa52a00..00000000 --- a/web/src/js/app.js +++ /dev/null @@ -1,41 +0,0 @@ -import React from "react" -import { render } from 'react-dom' -import { applyMiddleware, createStore } from 'redux' -import { Provider } from 'react-redux' -import createLogger from 'redux-logger' -import thunkMiddleware from 'redux-thunk' -import { Route, Router as ReactRouter, hashHistory, Redirect } from "react-router" - -import Connection from "./connection" -import ProxyApp from "./components/ProxyApp" -import MainView from './components/MainView' -import rootReducer from './ducks/index' -import { addLogEntry } from "./ducks/eventLog" - -// logger must be last -const store = createStore( - rootReducer, - applyMiddleware(thunkMiddleware, createLogger()) -) - -window.addEventListener('error', msg => { - store.dispatch(addLogEntry(msg)) -}) - -// @todo remove this -document.addEventListener('DOMContentLoaded', () => { - window.ws = new Connection("/updates", store.dispatch) - - render( - - - - - - - - - , - document.getElementById("mitmproxy") - ) -}) diff --git a/web/src/js/app.jsx b/web/src/js/app.jsx new file mode 100644 index 00000000..8fa52a00 --- /dev/null +++ b/web/src/js/app.jsx @@ -0,0 +1,41 @@ +import React from "react" +import { render } from 'react-dom' +import { applyMiddleware, createStore } from 'redux' +import { Provider } from 'react-redux' +import createLogger from 'redux-logger' +import thunkMiddleware from 'redux-thunk' +import { Route, Router as ReactRouter, hashHistory, Redirect } from "react-router" + +import Connection from "./connection" +import ProxyApp from "./components/ProxyApp" +import MainView from './components/MainView' +import rootReducer from './ducks/index' +import { addLogEntry } from "./ducks/eventLog" + +// logger must be last +const store = createStore( + rootReducer, + applyMiddleware(thunkMiddleware, createLogger()) +) + +window.addEventListener('error', msg => { + store.dispatch(addLogEntry(msg)) +}) + +// @todo remove this +document.addEventListener('DOMContentLoaded', () => { + window.ws = new Connection("/updates", store.dispatch) + + render( + + + + + + + + + , + document.getElementById("mitmproxy") + ) +}) diff --git a/web/src/js/components/FlowTable.jsx b/web/src/js/components/FlowTable.jsx new file mode 100644 index 00000000..eddeed62 --- /dev/null +++ b/web/src/js/components/FlowTable.jsx @@ -0,0 +1,120 @@ +import React, { PropTypes } from 'react' +import ReactDOM from 'react-dom' +import shallowEqual from 'shallowequal' +import AutoScroll from './helpers/AutoScroll' +import { calcVScroll } from './helpers/VirtualScroll' +import FlowTableHead from './FlowTable/FlowTableHead' +import FlowRow from './FlowTable/FlowRow' +import Filt from "../filt/filt" + +class FlowTable extends React.Component { + + static propTypes = { + onSelect: PropTypes.func.isRequired, + flows: PropTypes.array.isRequired, + rowHeight: PropTypes.number, + highlight: PropTypes.string, + selected: PropTypes.object, + } + + static defaultProps = { + rowHeight: 32, + } + + constructor(props, context) { + super(props, context) + + this.state = { vScroll: calcVScroll() } + this.onViewportUpdate = this.onViewportUpdate.bind(this) + } + + componentWillMount() { + window.addEventListener('resize', this.onViewportUpdate) + } + + componentWillUnmount() { + window.removeEventListener('resize', this.onViewportUpdate) + } + + componentDidUpdate() { + this.onViewportUpdate() + + if (!this.shouldScrollIntoView) { + return + } + + this.shouldScrollIntoView = false + + const { rowHeight, flows, selected } = this.props + const viewport = ReactDOM.findDOMNode(this) + const head = ReactDOM.findDOMNode(this.refs.head) + + const headHeight = head ? head.offsetHeight : 0 + + const rowTop = (flows.indexOf(selected) * rowHeight) + headHeight + const rowBottom = rowTop + rowHeight + + const viewportTop = viewport.scrollTop + const viewportHeight = viewport.offsetHeight + + // Account for pinned thead + if (rowTop - headHeight < viewportTop) { + viewport.scrollTop = rowTop - headHeight + } else if (rowBottom > viewportTop + viewportHeight) { + viewport.scrollTop = rowBottom - viewportHeight + } + } + + componentWillReceiveProps(nextProps) { + if (nextProps.selected && nextProps.selected !== this.props.selected) { + this.shouldScrollIntoView = true + } + } + + onViewportUpdate() { + const viewport = ReactDOM.findDOMNode(this) + const viewportTop = viewport.scrollTop + + const vScroll = calcVScroll({ + viewportTop, + viewportHeight: viewport.offsetHeight, + itemCount: this.props.flows.length, + rowHeight: this.props.rowHeight, + }) + + if (this.state.viewportTop !== viewportTop || !shallowEqual(this.state.vScroll, vScroll)) { + this.setState({ vScroll, viewportTop }) + } + } + + render() { + const { vScroll, viewportTop } = this.state + const { flows, selected, highlight } = this.props + const isHighlighted = highlight ? Filt.parse(highlight) : () => false + + return ( +
+ + + + + + + {flows.slice(vScroll.start, vScroll.end).map(flow => ( + + ))} + + +
+
+ ) + } +} + +export default AutoScroll(FlowTable) diff --git a/web/src/js/components/FlowTable/FlowColumns.jsx b/web/src/js/components/FlowTable/FlowColumns.jsx new file mode 100644 index 00000000..11c0796c --- /dev/null +++ b/web/src/js/components/FlowTable/FlowColumns.jsx @@ -0,0 +1,137 @@ +import React, { Component } from 'react' +import classnames from 'classnames' +import { RequestUtils, ResponseUtils } from '../../flow/utils.js' +import { formatSize, formatTimeDelta } from '../../utils.js' + +export function TLSColumn({ flow }) { + return ( + + ) +} + +TLSColumn.sortKeyFun = flow => flow.request.scheme +TLSColumn.headerClass = 'col-tls' +TLSColumn.headerName = '' + +export function IconColumn({ flow }) { + return ( + +
+ + ) +} + +IconColumn.headerClass = 'col-icon' +IconColumn.headerName = '' + +IconColumn.getIcon = flow => { + if (!flow.response) { + return 'resource-icon-plain' + } + + var contentType = ResponseUtils.getContentType(flow.response) || '' + + // @todo We should assign a type to the flow somewhere else. + if (flow.response.status_code === 304) { + return 'resource-icon-not-modified' + } + if (300 <= flow.response.status_code && flow.response.status_code < 400) { + return 'resource-icon-redirect' + } + if (contentType.indexOf('image') >= 0) { + return 'resource-icon-image' + } + if (contentType.indexOf('javascript') >= 0) { + return 'resource-icon-js' + } + if (contentType.indexOf('css') >= 0) { + return 'resource-icon-css' + } + if (contentType.indexOf('html') >= 0) { + return 'resource-icon-document' + } + + return 'resource-icon-plain' +} + +export function PathColumn({ flow }) { + return ( + + {flow.request.is_replay && ( + + )} + {flow.intercepted && ( + + )} + {RequestUtils.pretty_url(flow.request)} + + ) +} + +PathColumn.sortKeyFun = flow => RequestUtils.pretty_url(flow.request) +PathColumn.headerClass = 'col-path' +PathColumn.headerName = 'Path' + +export function MethodColumn({ flow }) { + return ( + {flow.request.method} + ) +} + +MethodColumn.sortKeyFun = flow => flow.request.method +MethodColumn.headerClass = 'col-method' +MethodColumn.headerName = 'Method' + +export function StatusColumn({ flow }) { + return ( + {flow.response && flow.response.status_code} + ) +} + +StatusColumn.sortKeyFun = flow => flow.response && flow.response.status_code +StatusColumn.headerClass = 'col-status' +StatusColumn.headerName = 'Status' + +export function SizeColumn({ flow }) { + return ( + {formatSize(SizeColumn.getTotalSize(flow))} + ) +} + +SizeColumn.sortKeyFun = flow => { + let total = flow.request.contentLength + if (flow.response) { + total += flow.response.contentLength || 0 + } + return total +} + +SizeColumn.getTotalSize = SizeColumn.sortKeyFun +SizeColumn.headerClass = 'col-size' +SizeColumn.headerName = 'Size' + +export function TimeColumn({ flow }) { + return ( + + {flow.response ? ( + formatTimeDelta(1000 * (flow.response.timestamp_end - flow.request.timestamp_start)) + ) : ( + '...' + )} + + ) +} + +TimeColumn.sortKeyFun = flow => flow.response && flow.response.timestamp_end - flow.request.timestamp_start +TimeColumn.headerClass = 'col-time' +TimeColumn.headerName = 'Time' + +export default [ + TLSColumn, + IconColumn, + PathColumn, + MethodColumn, + StatusColumn, + SizeColumn, + TimeColumn, +] diff --git a/web/src/js/components/FlowTable/FlowRow.jsx b/web/src/js/components/FlowTable/FlowRow.jsx new file mode 100644 index 00000000..749bc0ce --- /dev/null +++ b/web/src/js/components/FlowTable/FlowRow.jsx @@ -0,0 +1,28 @@ +import React, { PropTypes } from 'react' +import classnames from 'classnames' +import columns from './FlowColumns' + +FlowRow.propTypes = { + onSelect: PropTypes.func.isRequired, + flow: PropTypes.object.isRequired, + highlighted: PropTypes.bool, + selected: PropTypes.bool, +} + +export default function FlowRow({ flow, selected, highlighted, onSelect }) { + const className = classnames({ + 'selected': selected, + 'highlighted': highlighted, + 'intercepted': flow.intercepted, + 'has-request': flow.request, + 'has-response': flow.response, + }) + + return ( + onSelect(flow)}> + {columns.map(Column => ( + + ))} + + ) +} diff --git a/web/src/js/components/FlowTable/FlowTableHead.jsx b/web/src/js/components/FlowTable/FlowTableHead.jsx new file mode 100644 index 00000000..a46219d1 --- /dev/null +++ b/web/src/js/components/FlowTable/FlowTableHead.jsx @@ -0,0 +1,43 @@ +import React, { PropTypes } from 'react' +import { bindActionCreators } from 'redux' +import { connect } from 'react-redux' +import classnames from 'classnames' +import columns from './FlowColumns' + +import { setSort } from "../../ducks/flows" + +FlowTableHead.propTypes = { + onSort: PropTypes.func.isRequired, + sortDesc: React.PropTypes.bool.isRequired, + sortColumn: React.PropTypes.string, +} + +function FlowTableHead({ sortColumn, sortDesc, onSort }) { + const sortType = sortDesc ? 'sort-desc' : 'sort-asc' + + return ( + + {columns.map(Column => ( + onClick(Column)}> + {Column.headerName} + + ))} + + ) + + function onClick(Column) { + onSort({ sortColumn: Column.name, sortDesc: Column.name !== sortColumn ? false : !sortDesc }) + } +} + +export default connect( + state => ({ + sortDesc: state.flows.sort.sortDesc, + sortColumn: state.flows.sort.sortColumn, + }), + dispatch => bindActionCreators({ + onSort: setSort, + }, dispatch) +)(FlowTableHead) diff --git a/web/src/js/components/MainView.js b/web/src/js/components/MainView.js deleted file mode 100644 index 6172ce77..00000000 --- a/web/src/js/components/MainView.js +++ /dev/null @@ -1,191 +0,0 @@ -import React, { Component } from "react" - -import { FlowActions } from "../actions.js" -import { Query } from "../actions.js" -import { Key } from "../utils.js" -import { Splitter } from "./common.js" -import FlowTable from "./flowtable.js" -import FlowView from "./flowview/index.js" -import { connect } from 'react-redux' -import { selectFlow, setFilter, setHighlight } from "../ducks/flows" - -class MainView extends Component { - - /** - * @todo move to actions - * @todo replace with mapStateToProps - */ - componentWillReceiveProps(nextProps) { - // Update redux store with route changes - if (nextProps.routeParams.flowId !== (nextProps.selectedFlow || {}).id) { - this.props.selectFlow(nextProps.routeParams.flowId) - } - if (nextProps.location.query[Query.SEARCH] !== nextProps.filter) { - this.props.setFilter(nextProps.location.query[Query.SEARCH], false) - } - if (nextProps.location.query[Query.HIGHLIGHT] !== nextProps.highlight) { - this.props.setHighlight(nextProps.location.query[Query.HIGHLIGHT], false) - } - } - - /** - * @todo move to actions - */ - selectFlow(flow) { - if (flow) { - this.props.updateLocation(`/flows/${flow.id}/${this.props.routeParams.detailTab || "request"}`) - } else { - this.props.updateLocation("/flows") - } - } - - /** - * @todo move to actions - */ - selectFlowRelative(shift) { - const { flows, routeParams, selectedFlow } = this.props - let index = 0 - if (!routeParams.flowId) { - if (shift < 0) { - index = flows.length - 1 - } - } else { - index = Math.min( - Math.max(0, flows.indexOf(selectedFlow) + shift), - flows.length - 1 - ) - } - this.selectFlow(flows[index]) - } - - /** - * @todo move to actions - */ - onMainKeyDown(e) { - var flow = this.props.selectedFlow - if (e.ctrlKey) { - return - } - switch (e.keyCode) { - case Key.K: - case Key.UP: - this.selectFlowRelative(-1) - break - case Key.J: - case Key.DOWN: - this.selectFlowRelative(+1) - break - case Key.SPACE: - case Key.PAGE_DOWN: - this.selectFlowRelative(+10) - break - case Key.PAGE_UP: - this.selectFlowRelative(-10) - break - case Key.END: - this.selectFlowRelative(+1e10) - break - case Key.HOME: - this.selectFlowRelative(-1e10) - break - case Key.ESC: - this.selectFlow(null) - break - case Key.H: - case Key.LEFT: - if (this.refs.flowDetails) { - this.refs.flowDetails.nextTab(-1) - } - break - case Key.L: - case Key.TAB: - case Key.RIGHT: - if (this.refs.flowDetails) { - this.refs.flowDetails.nextTab(+1) - } - break - case Key.C: - if (e.shiftKey) { - FlowActions.clear() - } - break - case Key.D: - if (flow) { - if (e.shiftKey) { - FlowActions.duplicate(flow) - } else { - FlowActions.delete(flow) - } - } - break - case Key.A: - if (e.shiftKey) { - FlowActions.accept_all() - } else if (flow && flow.intercepted) { - FlowActions.accept(flow) - } - break - case Key.R: - if (!e.shiftKey && flow) { - FlowActions.replay(flow) - } - break - case Key.V: - if (e.shiftKey && flow && flow.modified) { - FlowActions.revert(flow) - } - break - case Key.E: - if (this.refs.flowDetails) { - this.refs.flowDetails.promptEdit() - } - break - case Key.SHIFT: - break - default: - console.debug("keydown", e.keyCode) - return - } - e.preventDefault() - } - - render() { - const { selectedFlow } = this.props - return ( -
- this.selectFlow(flow)} - selected={selectedFlow} - /> - {selectedFlow && [ - , - - ]} -
- ) - } -} - -export default connect( - state => ({ - flows: state.flows.view, - filter: state.flows.filter, - highlight: state.flows.highlight, - selectedFlow: state.flows.all.byId[state.flows.selected[0]] - }), - dispatch => ({ - selectFlow: flowId => dispatch(selectFlow(flowId)), - setFilter: filter => dispatch(setFilter(filter)), - setHighlight: highlight => dispatch(setHighlight(highlight)) - }), - undefined, - { withRef: true } -)(MainView) diff --git a/web/src/js/components/MainView.jsx b/web/src/js/components/MainView.jsx new file mode 100644 index 00000000..dbea76e5 --- /dev/null +++ b/web/src/js/components/MainView.jsx @@ -0,0 +1,200 @@ +import React, { Component, PropTypes } from 'react' +import { connect } from 'react-redux' +import { bindActionCreators } from 'redux' + +import { FlowActions } from '../actions.js' +import { Query } from '../actions.js' +import { Key } from '../utils.js' +import { Splitter } from './common.js' +import FlowTable from './FlowTable' +import FlowView from './flowview/index.js' +import { selectFlow, setFilter, setHighlight } from '../ducks/flows' + +class MainView extends Component { + + static propTypes = { + highlight: PropTypes.string, + sort: PropTypes.object, + } + + /** + * @todo move to actions + * @todo replace with mapStateToProps + */ + componentWillReceiveProps(nextProps) { + // Update redux store with route changes + if (nextProps.routeParams.flowId !== (nextProps.selectedFlow || {}).id) { + this.props.selectFlow(nextProps.routeParams.flowId) + } + if (nextProps.location.query[Query.SEARCH] !== nextProps.filter) { + this.props.setFilter(nextProps.location.query[Query.SEARCH], false) + } + if (nextProps.location.query[Query.HIGHLIGHT] !== nextProps.highlight) { + this.props.setHighlight(nextProps.location.query[Query.HIGHLIGHT], false) + } + } + + /** + * @todo move to actions + */ + selectFlow(flow) { + if (flow) { + this.props.updateLocation(`/flows/${flow.id}/${this.props.routeParams.detailTab || 'request'}`) + } else { + this.props.updateLocation('/flows') + } + } + + /** + * @todo move to actions + */ + selectFlowRelative(shift) { + const { flows, routeParams, selectedFlow } = this.props + let index = 0 + if (!routeParams.flowId) { + if (shift < 0) { + index = flows.length - 1 + } + } else { + index = Math.min( + Math.max(0, flows.indexOf(selectedFlow) + shift), + flows.length - 1 + ) + } + this.selectFlow(flows[index]) + } + + /** + * @todo move to actions + */ + onMainKeyDown(e) { + var flow = this.props.selectedFlow + if (e.ctrlKey) { + return + } + switch (e.keyCode) { + case Key.K: + case Key.UP: + this.selectFlowRelative(-1) + break + case Key.J: + case Key.DOWN: + this.selectFlowRelative(+1) + break + case Key.SPACE: + case Key.PAGE_DOWN: + this.selectFlowRelative(+10) + break + case Key.PAGE_UP: + this.selectFlowRelative(-10) + break + case Key.END: + this.selectFlowRelative(+1e10) + break + case Key.HOME: + this.selectFlowRelative(-1e10) + break + case Key.ESC: + this.selectFlow(null) + break + case Key.H: + case Key.LEFT: + if (this.refs.flowDetails) { + this.refs.flowDetails.nextTab(-1) + } + break + case Key.L: + case Key.TAB: + case Key.RIGHT: + if (this.refs.flowDetails) { + this.refs.flowDetails.nextTab(+1) + } + break + case Key.C: + if (e.shiftKey) { + FlowActions.clear() + } + break + case Key.D: + if (flow) { + if (e.shiftKey) { + FlowActions.duplicate(flow) + } else { + FlowActions.delete(flow) + } + } + break + case Key.A: + if (e.shiftKey) { + FlowActions.accept_all() + } else if (flow && flow.intercepted) { + FlowActions.accept(flow) + } + break + case Key.R: + if (!e.shiftKey && flow) { + FlowActions.replay(flow) + } + break + case Key.V: + if (e.shiftKey && flow && flow.modified) { + FlowActions.revert(flow) + } + break + case Key.E: + if (this.refs.flowDetails) { + this.refs.flowDetails.promptEdit() + } + break + case Key.SHIFT: + break + default: + console.debug('keydown', e.keyCode) + return + } + e.preventDefault() + } + + render() { + const { flows, selectedFlow, highlight, sort } = this.props + return ( +
+ this.selectFlow(flow)} + /> + {selectedFlow && [ + , + + ]} +
+ ) + } +} + +export default connect( + state => ({ + flows: state.flows.view, + filter: state.flows.filter, + sort: state.flows.sort, + highlight: state.flows.highlight, + selectedFlow: state.flows.all.byId[state.flows.selected[0]] + }), + dispatch => bindActionCreators({ + selectFlow, + setFilter, + setHighlight, + }, dispatch), + undefined, + { withRef: true } +)(MainView) diff --git a/web/src/js/components/ProxyApp.js b/web/src/js/components/ProxyApp.js deleted file mode 100644 index 71a7bf9b..00000000 --- a/web/src/js/components/ProxyApp.js +++ /dev/null @@ -1,169 +0,0 @@ -import React, { Component, PropTypes } from "react" -import ReactDOM from "react-dom" -import _ from "lodash" -import { connect } from 'react-redux' - -import { Splitter } from "./common.js" -import { Header, MainMenu } from "./header.js" -import EventLog from "./eventlog.js" -import Footer from "./footer.js" -import { SettingsStore } from "../store/store.js" -import { Key } from "../utils.js" - -class ProxyAppMain extends Component { - - static childContextTypes = { - returnFocus: PropTypes.func.isRequired, - location: PropTypes.object.isRequired, - } - - static contextTypes = { - router: PropTypes.object.isRequired, - } - - constructor(props, context) { - super(props, context) - - this.settingsStore = new SettingsStore() - - // Default Settings before fetch - _.extend(this.settingsStore.dict, {}) - - this.state = { settings: this.settingsStore.dict } - - this.onKeyDown = this.onKeyDown.bind(this) - this.updateLocation = this.updateLocation.bind(this) - this.onSettingsChange = this.onSettingsChange.bind(this) - } - - /** - * @todo move to actions - */ - updateLocation(pathname, queryUpdate) { - if (pathname === undefined) { - pathname = this.props.location.pathname - } - const query = this.props.location.query - for (const key of Object.keys(queryUpdate || {})) { - query[i] = queryUpdate[i] || undefined - } - this.context.router.replace({ pathname, query }) - } - - /** - * @todo pass in with props - */ - getQuery() { - // For whatever reason, react-router always returns the same object, which makes comparing - // the current props with nextProps impossible. As a workaround, we just clone the query object. - return _.clone(this.props.location.query) - } - - /** - * @todo remove settings store - * @todo connect websocket here - * @todo listen to window's key events - */ - componentDidMount() { - this.focus() - this.settingsStore.addListener("recalculate", this.onSettingsChange) - } - - /** - * @todo remove settings store - * @todo disconnect websocket here - * @todo stop listening to window's key events - */ - componentWillUnmount() { - this.settingsStore.removeListener("recalculate", this.onSettingsChange) - } - - /** - * @todo move to actions - */ - onSettingsChange() { - this.setState({ settings: this.settingsStore.dict }) - } - - /** - * @todo use props - */ - getChildContext() { - return { - returnFocus: this.focus, - location: this.props.location - } - } - - /** - * @todo remove it - */ - focus() { - document.activeElement.blur() - window.getSelection().removeAllRanges() - ReactDOM.findDOMNode(this).focus() - } - - /** - * @todo move to actions - */ - onKeyDown(e) { - let name = null - - switch (e.keyCode) { - case Key.I: - name = "intercept" - break - case Key.L: - name = "search" - break - case Key.H: - name = "highlight" - break - default: - let main = this.refs.view - if (this.refs.view.getWrappedInstance) { - main = this.refs.view.getWrappedInstance() - } - if (main.onMainKeyDown) { - main.onMainKeyDown(e) - } - return // don't prevent default then - } - - if (name) { - const headerComponent = this.refs.header - headerComponent.setState({active: MainMenu}, function () { - headerComponent.refs.active.refs[name].select() - }) - } - - e.preventDefault() - } - - render() { - const { showEventLog, location, children } = this.props - const { settings } = this.state - const query = this.getQuery() - return ( -
-
- {React.cloneElement( - children, - { ref: "view", location, query, updateLocation: this.updateLocation } - )} - {showEventLog && [ - , - - ]} -
-
- ) - } -} - -export default connect( - state => ({ - showEventLog: state.eventLog.visible - }) -)(ProxyAppMain) diff --git a/web/src/js/components/ProxyApp.jsx b/web/src/js/components/ProxyApp.jsx new file mode 100644 index 00000000..9f497a7d --- /dev/null +++ b/web/src/js/components/ProxyApp.jsx @@ -0,0 +1,170 @@ +import React, { Component, PropTypes } from "react" +import ReactDOM from "react-dom" +import _ from "lodash" +import { connect } from 'react-redux' + +import { Splitter } from "./common.js" +import { Header, MainMenu } from "./header.js" +import EventLog from "./eventlog.js" +import Footer from "./footer.js" +import { SettingsStore } from "../store/store.js" +import { Key } from "../utils.js" + +class ProxyAppMain extends Component { + + static childContextTypes = { + returnFocus: PropTypes.func.isRequired, + location: PropTypes.object.isRequired, + } + + static contextTypes = { + router: PropTypes.object.isRequired, + } + + constructor(props, context) { + super(props, context) + + this.settingsStore = new SettingsStore() + + // Default Settings before fetch + _.extend(this.settingsStore.dict, {}) + + this.state = { settings: this.settingsStore.dict } + + this.focus = this.focus.bind(this) + this.onKeyDown = this.onKeyDown.bind(this) + this.updateLocation = this.updateLocation.bind(this) + this.onSettingsChange = this.onSettingsChange.bind(this) + } + + /** + * @todo move to actions + */ + updateLocation(pathname, queryUpdate) { + if (pathname === undefined) { + pathname = this.props.location.pathname + } + const query = this.props.location.query + for (const key of Object.keys(queryUpdate || {})) { + query[key] = queryUpdate[key] || undefined + } + this.context.router.replace({ pathname, query }) + } + + /** + * @todo pass in with props + */ + getQuery() { + // For whatever reason, react-router always returns the same object, which makes comparing + // the current props with nextProps impossible. As a workaround, we just clone the query object. + return _.clone(this.props.location.query) + } + + /** + * @todo remove settings store + * @todo connect websocket here + * @todo listen to window's key events + */ + componentDidMount() { + this.focus() + this.settingsStore.addListener("recalculate", this.onSettingsChange) + } + + /** + * @todo remove settings store + * @todo disconnect websocket here + * @todo stop listening to window's key events + */ + componentWillUnmount() { + this.settingsStore.removeListener("recalculate", this.onSettingsChange) + } + + /** + * @todo move to actions + */ + onSettingsChange() { + this.setState({ settings: this.settingsStore.dict }) + } + + /** + * @todo use props + */ + getChildContext() { + return { + returnFocus: this.focus, + location: this.props.location + } + } + + /** + * @todo remove it + */ + focus() { + document.activeElement.blur() + window.getSelection().removeAllRanges() + ReactDOM.findDOMNode(this).focus() + } + + /** + * @todo move to actions + */ + onKeyDown(e) { + let name = null + + switch (e.keyCode) { + case Key.I: + name = "intercept" + break + case Key.L: + name = "search" + break + case Key.H: + name = "highlight" + break + default: + let main = this.refs.view + if (this.refs.view.getWrappedInstance) { + main = this.refs.view.getWrappedInstance() + } + if (main.onMainKeyDown) { + main.onMainKeyDown(e) + } + return // don't prevent default then + } + + if (name) { + const headerComponent = this.refs.header + headerComponent.setState({active: MainMenu}, function () { + headerComponent.refs.active.refs[name].select() + }) + } + + e.preventDefault() + } + + render() { + const { showEventLog, location, children } = this.props + const { settings } = this.state + const query = this.getQuery() + return ( +
+
+ {React.cloneElement( + children, + { ref: "view", location, query, updateLocation: this.updateLocation } + )} + {showEventLog && [ + , + + ]} +
+
+ ) + } +} + +export default connect( + state => ({ + showEventLog: state.eventLog.visible + }) +)(ProxyAppMain) diff --git a/web/src/js/components/flowtable-columns.js b/web/src/js/components/flowtable-columns.js deleted file mode 100644 index 799b3f9f..00000000 --- a/web/src/js/components/flowtable-columns.js +++ /dev/null @@ -1,131 +0,0 @@ -import React from "react" -import {RequestUtils, ResponseUtils} from "../flow/utils.js" -import {formatSize, formatTimeDelta} from "../utils.js" - - -export function TLSColumn({flow}) { - let ssl = (flow.request.scheme === "https") - let classes - if (ssl) { - classes = "col-tls col-tls-https" - } else { - classes = "col-tls col-tls-http" - } - return -} -TLSColumn.Title = ({className = "", ...props}) => -TLSColumn.sortKeyFun = flow => flow.request.scheme - - -export function IconColumn({flow}) { - let icon - if (flow.response) { - var contentType = ResponseUtils.getContentType(flow.response) - - //TODO: We should assign a type to the flow somewhere else. - if (flow.response.status_code === 304) { - icon = "resource-icon-not-modified" - } else if (300 <= flow.response.status_code && flow.response.status_code < 400) { - icon = "resource-icon-redirect" - } else if (contentType && contentType.indexOf("image") >= 0) { - icon = "resource-icon-image" - } else if (contentType && contentType.indexOf("javascript") >= 0) { - icon = "resource-icon-js" - } else if (contentType && contentType.indexOf("css") >= 0) { - icon = "resource-icon-css" - } else if (contentType && contentType.indexOf("html") >= 0) { - icon = "resource-icon-document" - } - } - if (!icon) { - icon = "resource-icon-plain" - } - - icon += " resource-icon" - return -
- -} -IconColumn.Title = ({className = "", ...props}) => - - -export function PathColumn({flow}) { - return - {flow.request.is_replay ? : null} - {flow.intercepted ? : null} - { RequestUtils.pretty_url(flow.request) } - -} -PathColumn.Title = ({className = "", ...props}) => - Path -PathColumn.sortKeyFun = flow => RequestUtils.pretty_url(flow.request) - - -export function MethodColumn({flow}) { - return {flow.request.method} -} -MethodColumn.Title = ({className = "", ...props}) => - Method -MethodColumn.sortKeyFun = flow => flow.request.method - - -export function StatusColumn({flow}) { - let status - if (flow.response) { - status = flow.response.status_code - } else { - status = null - } - return {status} - -} -StatusColumn.Title = ({className = "", ...props}) => - Status -StatusColumn.sortKeyFun = flow => flow.response ? flow.response.status_code : undefined - - -export function SizeColumn({flow}) { - let total = flow.request.contentLength - if (flow.response) { - total += flow.response.contentLength || 0 - } - let size = formatSize(total) - return {size} - -} -SizeColumn.Title = ({className = "", ...props}) => - Size -SizeColumn.sortKeyFun = flow => { - let total = flow.request.contentLength - if (flow.response) { - total += flow.response.contentLength || 0 - } - return total -} - - -export function TimeColumn({flow}) { - let time - if (flow.response) { - time = formatTimeDelta(1000 * (flow.response.timestamp_end - flow.request.timestamp_start)) - } else { - time = "..." - } - return {time} -} -TimeColumn.Title = ({className = "", ...props}) => - Time -TimeColumn.sortKeyFun = flow => flow.response.timestamp_end - flow.request.timestamp_start - - -var all_columns = [ - TLSColumn, - IconColumn, - PathColumn, - MethodColumn, - StatusColumn, - SizeColumn, - TimeColumn -] - -export default all_columns diff --git a/web/src/js/components/flowtable.js b/web/src/js/components/flowtable.js deleted file mode 100644 index 5a0793ba..00000000 --- a/web/src/js/components/flowtable.js +++ /dev/null @@ -1,201 +0,0 @@ -import React from "react"; -import ReactDOM from "react-dom"; -import {connect} from 'react-redux' -import classNames from "classnames"; -import _ from "lodash"; -import shallowEqual from "shallowequal"; -import AutoScroll from "./helpers/AutoScroll"; -import {calcVScroll} from "./helpers/VirtualScroll"; -import flowtable_columns from "./flowtable-columns.js"; -import Filt from "../filt/filt"; -import {setSort} from "../ducks/flows"; - - -FlowRow.propTypes = { - selectFlow: React.PropTypes.func.isRequired, - columns: React.PropTypes.array.isRequired, - flow: React.PropTypes.object.isRequired, - highlight: React.PropTypes.string, - selected: React.PropTypes.bool, -}; - -function FlowRow({flow, selected, highlight, columns, selectFlow}) { - - const className = classNames({ - "selected": selected, - "highlighted": highlight && parseFilter(highlight)(flow), - "intercepted": flow.intercepted, - "has-request": flow.request, - "has-response": flow.response, - }); - - return ( - selectFlow(flow)}> - {columns.map(Column => ( - - ))} - - ); -} - -const FlowRowContainer = connect( - (state, ownProps) => ({ - flow: state.flows.all.byId[ownProps.flowId], - highlight: state.flows.highlight, - selected: state.flows.selected.indexOf(ownProps.flowId) >= 0 - }) -)(FlowRow) - -function FlowTableHead({setSort, columns, sort}) { - const sortColumn = sort.sortColumn; - const sortType = sort.sortDesc ? "sort-desc" : "sort-asc"; - - return ( - - {columns.map(Column => ( - setSort({sortColumn: Column.name, sortDesc: Column.name != sort.sortColumn ? false : !sort.sortDesc})} - className={sortColumn === Column.name ? sortType : undefined} - /> - ))} - - ); -} - -FlowTableHead.propTypes = { - setSort: React.PropTypes.func.isRequired, - sort: React.PropTypes.object.isRequired, - columns: React.PropTypes.array.isRequired -}; - -const FlowTableHeadContainer = connect( - state => ({ - sort: state.flows.sort - }), - dispatch => ({ - setSort: (sort) => dispatch(setSort(sort)), - }) -)(FlowTableHead) - -class FlowTable extends React.Component { - - static propTypes = { - rowHeight: React.PropTypes.number, - }; - - static defaultProps = { - rowHeight: 32, - }; - - constructor(props, context) { - super(props, context); - - this.state = {vScroll: calcVScroll()}; - - this.onViewportUpdate = this.onViewportUpdate.bind(this); - } - - componentWillMount() { - window.addEventListener("resize", this.onViewportUpdate); - } - - componentWillUnmount() { - window.removeEventListener("resize", this.onViewportUpdate); - } - - componentWillReceiveProps(nextProps) { - if (nextProps.selected && nextProps.selected !== this.props.selected) { - window.setTimeout(() => this.scrollIntoView(nextProps.selected), 1) - } - } - - componentDidUpdate() { - this.onViewportUpdate(); - } - - onViewportUpdate() { - const viewport = ReactDOM.findDOMNode(this); - const viewportTop = viewport.scrollTop; - - const vScroll = calcVScroll({ - viewportTop, - viewportHeight: viewport.offsetHeight, - itemCount: this.props.flows.length, - rowHeight: this.props.rowHeight, - }); - - if (!shallowEqual(this.state.vScroll, vScroll) || - this.state.viewportTop !== viewportTop) { - this.setState({vScroll, viewportTop}); - } - } - - scrollIntoView(flow) { - const viewport = ReactDOM.findDOMNode(this); - const index = this.props.flows.indexOf(flow); - const rowHeight = this.props.rowHeight; - const head = ReactDOM.findDOMNode(this.refs.head); - - const headHeight = head ? head.offsetHeight : 0; - - const rowTop = (index * rowHeight) + headHeight; - const rowBottom = rowTop + rowHeight; - - const viewportTop = viewport.scrollTop; - const viewportHeight = viewport.offsetHeight; - - // Account for pinned thead - if (rowTop - headHeight < viewportTop) { - viewport.scrollTop = rowTop - headHeight; - } else if (rowBottom > viewportTop + viewportHeight) { - viewport.scrollTop = rowBottom - viewportHeight; - } - } - - render() { - const vScroll = this.state.vScroll; - const flows = this.props.flows.slice(vScroll.start, vScroll.end); - - const transform = `translate(0,${this.state.viewportTop}px)`; - - return ( -
- - - - - - - {flows.map(flow => ( - - ))} - - -
-
- ); - } -} - -FlowTable = AutoScroll(FlowTable) - - -const parseFilter = _.memoize(Filt.parse) - -const FlowTableContainer = connect( - state => ({ - flows: state.flows.view - }) -)(FlowTable) - -export default FlowTableContainer; diff --git a/web/src/js/ducks/flows.js b/web/src/js/ducks/flows.js index 6adde71c..f7a5538a 100644 --- a/web/src/js/ducks/flows.js +++ b/web/src/js/ducks/flows.js @@ -2,7 +2,7 @@ import makeList from "./utils/list" import Filt from "../filt/filt" import {updateViewFilter, updateViewList, updateViewSort} from "./utils/view" import {reverseString} from "../utils.js"; -import * as flow_table_columns from "../components/flowtable-columns.js"; +import * as columns from "../components/FlowTable/FlowColumns"; export const UPDATE_FLOWS = "UPDATE_FLOWS" export const SET_FILTER = "SET_FLOW_FILTER" @@ -32,7 +32,7 @@ function makeFilterFn(filter) { function makeSortFn(sort){ - let column = flow_table_columns[sort.sortColumn]; + let column = columns[sort.sortColumn]; if (!column) return; let sortKeyFun = column.sortKeyFun; @@ -108,4 +108,4 @@ export function selectFlow(flowId) { } -export {updateList as updateFlows, fetchList as fetchFlows} \ No newline at end of file +export {updateList as updateFlows, fetchList as fetchFlows} -- cgit v1.2.3 From e24bf8d73f744319eeed99e7cae0b7f2c3947b8f Mon Sep 17 00:00:00 2001 From: Jason Date: Thu, 9 Jun 2016 18:03:40 +0800 Subject: [web] fix shortcut for header fields --- web/src/js/components/ProxyApp.jsx | 2 +- web/src/js/components/header.js | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) (limited to 'web/src/js') diff --git a/web/src/js/components/ProxyApp.jsx b/web/src/js/components/ProxyApp.jsx index 9f497a7d..465cf272 100644 --- a/web/src/js/components/ProxyApp.jsx +++ b/web/src/js/components/ProxyApp.jsx @@ -134,7 +134,7 @@ class ProxyAppMain extends Component { if (name) { const headerComponent = this.refs.header - headerComponent.setState({active: MainMenu}, function () { + headerComponent.setState({ active: MainMenu }, () => { headerComponent.refs.active.refs[name].select() }) } diff --git a/web/src/js/components/header.js b/web/src/js/components/header.js index 4152e95c..afd295cf 100644 --- a/web/src/js/components/header.js +++ b/web/src/js/components/header.js @@ -442,6 +442,7 @@ export var Header = React.createClass({
Date: Thu, 9 Jun 2016 18:13:18 +0800 Subject: [web] footer.js -> Footer.jsx --- web/src/js/components/Footer.jsx | 47 +++++++++++++++++++++++++++++++++++ web/src/js/components/ProxyApp.jsx | 2 +- web/src/js/components/footer.js | 50 -------------------------------------- 3 files changed, 48 insertions(+), 51 deletions(-) create mode 100644 web/src/js/components/Footer.jsx delete mode 100644 web/src/js/components/footer.js (limited to 'web/src/js') diff --git a/web/src/js/components/Footer.jsx b/web/src/js/components/Footer.jsx new file mode 100644 index 00000000..903522f4 --- /dev/null +++ b/web/src/js/components/Footer.jsx @@ -0,0 +1,47 @@ +import React from 'react' +import { formatSize } from '../utils.js' +import { SettingsState } from './common.js' + +Footer.propTypes = { + settings: React.PropTypes.object.isRequired, +} + +export default function Footer({ settings }) { + return ( +
+ {settings.mode && settings.mode != "regular" && ( + {settings.mode} mode + )} + {settings.intercept && ( + Intercept: {settings.intercept} + )} + {settings.showhost && ( + showhost + )} + {settings.no_upstream_cert && ( + no-upstream-cert + )} + {settings.rawtcp && ( + raw-tcp + )} + {!settings.http2 && ( + no-http2 + )} + {settings.anticache && ( + anticache + )} + {settings.anticomp && ( + anticomp + )} + {settings.stickyauth && ( + stickyauth: {settings.stickyauth} + )} + {settings.stickycookie && ( + stickycookie: {settings.stickycookie} + )} + {settings.stream && ( + stream: {formatSize(settings.stream)} + )} +
+ ) +} diff --git a/web/src/js/components/ProxyApp.jsx b/web/src/js/components/ProxyApp.jsx index 465cf272..e273fc92 100644 --- a/web/src/js/components/ProxyApp.jsx +++ b/web/src/js/components/ProxyApp.jsx @@ -6,7 +6,7 @@ import { connect } from 'react-redux' import { Splitter } from "./common.js" import { Header, MainMenu } from "./header.js" import EventLog from "./eventlog.js" -import Footer from "./footer.js" +import Footer from "./Footer" import { SettingsStore } from "../store/store.js" import { Key } from "../utils.js" diff --git a/web/src/js/components/footer.js b/web/src/js/components/footer.js deleted file mode 100644 index 8fe1081b..00000000 --- a/web/src/js/components/footer.js +++ /dev/null @@ -1,50 +0,0 @@ -import React from "react"; -import {formatSize} from "../utils.js" -import {SettingsState} from "./common.js"; - -Footer.propTypes = { - settings: React.PropTypes.object.isRequired, -}; - -export default function Footer({ settings }) { - const {mode, intercept, showhost, no_upstream_cert, rawtcp, http2, anticache, anticomp, stickyauth, stickycookie, stream} = settings; - return ( -
- {mode && mode != "regular" && ( - {mode} mode - )} - {intercept && ( - Intercept: {intercept} - )} - {showhost && ( - showhost - )} - {no_upstream_cert && ( - no-upstream-cert - )} - {rawtcp && ( - raw-tcp - )} - {!http2 && ( - no-http2 - )} - {anticache && ( - anticache - )} - {anticomp && ( - anticomp - )} - {stickyauth && ( - stickyauth: {stickyauth} - )} - {stickycookie && ( - stickycookie: {stickycookie} - )} - {stream && ( - stream: {formatSize(stream)} - )} - - -
- ); -} -- cgit v1.2.3 From 6c95635cb809d9261acc317f223ef80ba9c25f20 Mon Sep 17 00:00:00 2001 From: Jason Date: Thu, 9 Jun 2016 18:40:59 +0800 Subject: [web] eventlog.js -> EventLog.jsx --- web/src/js/components/EventLog.jsx | 41 +++++++ web/src/js/components/EventLog/EventList.jsx | 90 ++++++++++++++++ web/src/js/components/ProxyApp.jsx | 2 +- web/src/js/components/eventlog.js | 153 --------------------------- web/src/js/components/header.js | 12 ++- 5 files changed, 143 insertions(+), 155 deletions(-) create mode 100644 web/src/js/components/EventLog.jsx create mode 100644 web/src/js/components/EventLog/EventList.jsx delete mode 100644 web/src/js/components/eventlog.js (limited to 'web/src/js') diff --git a/web/src/js/components/EventLog.jsx b/web/src/js/components/EventLog.jsx new file mode 100644 index 00000000..3de38954 --- /dev/null +++ b/web/src/js/components/EventLog.jsx @@ -0,0 +1,41 @@ +import React, { PropTypes } from 'react' +import { bindActionCreators } from 'redux' +import { connect } from 'react-redux' +import { toggleEventLogFilter, toggleEventLogVisibility } from '../ducks/eventLog' +import { ToggleButton } from './common' +import EventList from './EventLog/EventList' + +EventLog.propTypes = { + filters: PropTypes.object.isRequired, + events: PropTypes.array.isRequired, + onToggleFilter: PropTypes.func.isRequired, + onClose: PropTypes.func.isRequired +} + +function EventLog({ filters, events, onToggleFilter, onClose }) { + return ( +
+
+ Eventlog +
+ {['debug', 'info', 'web'].map(type => ( + onToggleFilter(type)}/> + ))} + +
+
+ +
+ ) +} + +export default connect( + state => ({ + filters: state.eventLog.filter, + events: state.eventLog.filteredEvents, + }), + dispatch => bindActionCreators({ + onClose: toggleEventLogVisibility, + onToggleFilter: toggleEventLogFilter, + }, dispatch) +)(EventLog) diff --git a/web/src/js/components/EventLog/EventList.jsx b/web/src/js/components/EventLog/EventList.jsx new file mode 100644 index 00000000..d0b036e7 --- /dev/null +++ b/web/src/js/components/EventLog/EventList.jsx @@ -0,0 +1,90 @@ +import React, { Component, PropTypes } from 'react' +import ReactDOM from 'react-dom' +import shallowEqual from 'shallowequal' +import AutoScroll from '../helpers/AutoScroll' +import { calcVScroll } from '../helpers/VirtualScroll' + +class EventLogList extends Component { + + static propTypes = { + events: PropTypes.array.isRequired, + rowHeight: PropTypes.number, + } + + static defaultProps = { + rowHeight: 18, + } + + constructor(props) { + super(props) + + this.heights = {} + this.state = { vScroll: calcVScroll() } + + this.onViewportUpdate = this.onViewportUpdate.bind(this) + } + + componentDidMount() { + window.addEventListener('resize', this.onViewportUpdate) + this.onViewportUpdate() + } + + componentWillUnmount() { + window.removeEventListener('resize', this.onViewportUpdate) + } + + componentDidUpdate() { + this.onViewportUpdate() + } + + onViewportUpdate() { + const viewport = ReactDOM.findDOMNode(this) + + const vScroll = calcVScroll({ + itemCount: this.props.events.length, + rowHeight: this.props.rowHeight, + viewportTop: viewport.scrollTop, + viewportHeight: viewport.offsetHeight, + itemHeights: this.props.events.map(entry => this.heights[entry.id]), + }) + + if (!shallowEqual(this.state.vScroll, vScroll)) { + this.setState({vScroll}) + } + } + + setHeight(id, node) { + if (node && !this.heights[id]) { + const height = node.offsetHeight + if (this.heights[id] !== height) { + this.heights[id] = height + this.onViewportUpdate() + } + } + } + + render() { + const { vScroll } = this.state + const { events } = this.props + + return ( +
+                
+ {events.slice(vScroll.start, vScroll.end).map(event => ( +
this.setHeight(event.id, node)}> + + {event.message} +
+ ))} +
+
+ ) + } +} + +function LogIcon({ event }) { + const icon = { web: 'html5', debug: 'bug' }[event.level] || 'info' + return +} + +export default AutoScroll(EventLogList) diff --git a/web/src/js/components/ProxyApp.jsx b/web/src/js/components/ProxyApp.jsx index e273fc92..bab8183d 100644 --- a/web/src/js/components/ProxyApp.jsx +++ b/web/src/js/components/ProxyApp.jsx @@ -5,7 +5,7 @@ import { connect } from 'react-redux' import { Splitter } from "./common.js" import { Header, MainMenu } from "./header.js" -import EventLog from "./eventlog.js" +import EventLog from "./EventLog" import Footer from "./Footer" import { SettingsStore } from "../store/store.js" import { Key } from "../utils.js" diff --git a/web/src/js/components/eventlog.js b/web/src/js/components/eventlog.js deleted file mode 100644 index 6ada58ff..00000000 --- a/web/src/js/components/eventlog.js +++ /dev/null @@ -1,153 +0,0 @@ -import React from "react" -import ReactDOM from "react-dom" -import {connect} from 'react-redux' -import shallowEqual from "shallowequal" -import {toggleEventLogFilter, toggleEventLogVisibility} from "../ducks/eventLog" -import AutoScroll from "./helpers/AutoScroll"; -import {calcVScroll} from "./helpers/VirtualScroll" -import {ToggleButton} from "./common"; - -function LogIcon({event}) { - let icon = {web: "html5", debug: "bug"}[event.level] || "info"; - return -} - -function LogEntry({event, registerHeight}) { - return
- - {event.message} -
; -} - -class EventLogContents extends React.Component { - - static defaultProps = { - rowHeight: 18, - }; - - constructor(props) { - super(props); - - this.heights = {}; - this.state = {vScroll: calcVScroll()}; - - this.onViewportUpdate = this.onViewportUpdate.bind(this); - } - - componentDidMount() { - window.addEventListener("resize", this.onViewportUpdate); - this.onViewportUpdate(); - } - - componentWillUnmount() { - window.removeEventListener("resize", this.onViewportUpdate); - } - - componentDidUpdate() { - this.onViewportUpdate(); - } - - onViewportUpdate() { - const viewport = ReactDOM.findDOMNode(this); - - const vScroll = calcVScroll({ - itemCount: this.props.events.length, - rowHeight: this.props.rowHeight, - viewportTop: viewport.scrollTop, - viewportHeight: viewport.offsetHeight, - itemHeights: this.props.events.map(entry => this.heights[entry.id]), - }); - - if (!shallowEqual(this.state.vScroll, vScroll)) { - this.setState({vScroll}); - } - } - - setHeight(id, node) { - if (node && !this.heights[id]) { - const height = node.offsetHeight; - if (this.heights[id] !== height) { - this.heights[id] = height; - this.onViewportUpdate(); - } - } - } - - render() { - const vScroll = this.state.vScroll; - const events = this.props.events - .slice(vScroll.start, vScroll.end) - .map(event => - this.setHeight(event.id, node)} - /> - ); - - return ( -
-                
- {events} -
-
- ); - } -} - -EventLogContents = AutoScroll(EventLogContents); - - -const EventLogContentsContainer = connect( - state => ({ - events: state.eventLog.filteredEvents - }) -)(EventLogContents); - - -export const ToggleEventLog = connect( - state => ({ - checked: state.eventLog.visible - }), - dispatch => ({ - onToggle: () => dispatch(toggleEventLogVisibility()) - }) -)(ToggleButton); - - -const ToggleFilter = connect( - (state, ownProps) => ({ - checked: state.eventLog.filter[ownProps.text] - }), - (dispatch, ownProps) => ({ - onToggle: () => dispatch(toggleEventLogFilter(ownProps.text)) - }) -)(ToggleButton); - - -const EventLog = ({close}) => -
-
- Eventlog -
- - - - -
-
- -
; - -EventLog.propTypes = { - close: React.PropTypes.func.isRequired -}; - -const EventLogContainer = connect( - undefined, - dispatch => ({ - close: () => dispatch(toggleEventLogVisibility()) - }) -)(EventLog); - -export default EventLogContainer; diff --git a/web/src/js/components/header.js b/web/src/js/components/header.js index afd295cf..ebd77f91 100644 --- a/web/src/js/components/header.js +++ b/web/src/js/components/header.js @@ -1,5 +1,6 @@ import React from "react"; import ReactDOM from 'react-dom'; +import { bindActionCreators } from 'redux' import $ from "jquery"; import {connect} from 'react-redux' @@ -9,7 +10,16 @@ import {ToggleInputButton, ToggleButton} from "./common.js"; import {SettingsActions, FlowActions} from "../actions.js"; import {Query} from "../actions.js"; import {SettingsState} from "./common.js"; -import {ToggleEventLog} from "./eventlog" +import { toggleEventLogVisibility } from '../ducks/eventLog' + +const ToggleEventLog = connect( + state => ({ + checked: state.eventLog.visible + }), + dispatch => bindActionCreators({ + onToggle: toggleEventLogVisibility, + }, dispatch) +)(ToggleButton) var FilterDocs = React.createClass({ statics: { -- cgit v1.2.3 From 81a0c45c89df2dc94f7d97c4367f0e549495e4d0 Mon Sep 17 00:00:00 2001 From: Jason Date: Thu, 9 Jun 2016 20:34:57 +0800 Subject: [web] header.js -> Header.js --- web/src/js/components/EventLog.jsx | 2 +- web/src/js/components/Header.js | 56 ++++ web/src/js/components/Header/FileMenu.jsx | 100 ++++++ web/src/js/components/Header/FilterDocs.jsx | 56 ++++ web/src/js/components/Header/FilterInput.jsx | 133 ++++++++ web/src/js/components/Header/MainMenu.jsx | 73 +++++ web/src/js/components/Header/OptionMenu.jsx | 60 ++++ web/src/js/components/Header/ViewMenu.jsx | 33 ++ web/src/js/components/ProxyApp.jsx | 4 +- web/src/js/components/header.js | 464 --------------------------- web/src/js/components/prompt.js | 4 +- 11 files changed, 516 insertions(+), 469 deletions(-) create mode 100644 web/src/js/components/Header.js create mode 100644 web/src/js/components/Header/FileMenu.jsx create mode 100644 web/src/js/components/Header/FilterDocs.jsx create mode 100644 web/src/js/components/Header/FilterInput.jsx create mode 100644 web/src/js/components/Header/MainMenu.jsx create mode 100644 web/src/js/components/Header/OptionMenu.jsx create mode 100644 web/src/js/components/Header/ViewMenu.jsx delete mode 100644 web/src/js/components/header.js (limited to 'web/src/js') diff --git a/web/src/js/components/EventLog.jsx b/web/src/js/components/EventLog.jsx index 3de38954..24b3c2bf 100644 --- a/web/src/js/components/EventLog.jsx +++ b/web/src/js/components/EventLog.jsx @@ -19,7 +19,7 @@ function EventLog({ filters, events, onToggleFilter, onClose }) { Eventlog
{['debug', 'info', 'web'].map(type => ( - onToggleFilter(type)}/> + onToggleFilter(type)}/> ))}
diff --git a/web/src/js/components/Header.js b/web/src/js/components/Header.js new file mode 100644 index 00000000..7134f7d9 --- /dev/null +++ b/web/src/js/components/Header.js @@ -0,0 +1,56 @@ +import React, { Component, PropTypes } from 'react' +import classnames from 'classnames' +import { toggleEventLogVisibility } from '../ducks/eventLog' +import MainMenu from './Header/MainMenu' +import ViewMenu from './Header/ViewMenu' +import OptionMenu from './Header/OptionMenu' +import FileMenu from './Header/FileMenu' + +export default class Header extends Component { + + static entries = [MainMenu, ViewMenu, OptionMenu] + + static propTypes = { + settings: PropTypes.object.isRequired, + } + + constructor(props, context) { + super(props, context) + this.state = { active: Header.entries[0] } + } + + handleClick(active, e) { + e.preventDefault() + this.props.updateLocation(active.route) + this.setState({ active }) + } + + render() { + const { active: Active } = this.state + const { settings, updateLocation, query } = this.props + + return ( +
+ +
+ +
+
+ ) + } +} diff --git a/web/src/js/components/Header/FileMenu.jsx b/web/src/js/components/Header/FileMenu.jsx new file mode 100644 index 00000000..b075b3c8 --- /dev/null +++ b/web/src/js/components/Header/FileMenu.jsx @@ -0,0 +1,100 @@ +import React, { Component } from 'react' +import classnames from 'classnames' +import { FlowActions } from '../../actions.js' + +export default class FileMenu extends Component { + + constructor(props, context) { + super(props, context) + this.state = { show: false } + + this.close = this.close.bind(this) + this.onFileClick = this.onFileClick.bind(this) + this.onNewClick = this.onNewClick.bind(this) + this.onOpenClick = this.onOpenClick.bind(this) + this.onOpenFile = this.onOpenFile.bind(this) + this.onSaveClick = this.onSaveClick.bind(this) + } + + close() { + this.setState({ show: false }) + document.removeEventListener('click', this.close) + } + + onFileClick(e) { + e.preventDefault() + + if (this.state.show) { + return + } + + document.addEventListener('click', this.close) + this.setState({ show: true }) + } + + onNewClick(e) { + e.preventDefault() + if (confirm('Delete all flows?')) { + FlowActions.clear() + } + } + + onOpenClick(e) { + e.preventDefault() + this.fileInput.click() + } + + onOpenFile(e) { + e.preventDefault() + if (e.target.files.length > 0) { + FlowActions.upload(e.target.files[0]) + this.fileInput.value = '' + } + } + + onSaveClick(e) { + e.preventDefault() + FlowActions.download() + } + + render() { + return ( +
+ mitmproxy + +
+ ) + } +} diff --git a/web/src/js/components/Header/FilterDocs.jsx b/web/src/js/components/Header/FilterDocs.jsx new file mode 100644 index 00000000..efb4818c --- /dev/null +++ b/web/src/js/components/Header/FilterDocs.jsx @@ -0,0 +1,56 @@ +import React, { Component } from 'react' +import $ from 'jquery' + +export default class FilterDocs extends Component { + + // @todo move to redux + + static xhr = null + static doc = null + + constructor(props, context) { + super(props, context) + this.state = { doc: FilterDocs.doc } + } + + componentWillMount() { + if (!FilterDocs.xhr) { + FilterDocs.xhr = $.getJSON('/filter-help') + FilterDocs.xhr.fail(() => { + FilterDocs.xhr = null + }) + } + if (!this.state.doc) { + FilterDocs.xhr.done(doc => { + FilterDocs.doc = doc + this.setState({ doc }) + }) + } + } + + render() { + const { doc } = this.state + return !doc ? ( + + ) : ( + + + {doc.commands.map(cmd => ( + + + + + ))} + + + + +
{cmd[0].replace(' ', '\u00a0')}{cmd[1]}
+ + +   mitmproxy docs +
+ ) + } +} diff --git a/web/src/js/components/Header/FilterInput.jsx b/web/src/js/components/Header/FilterInput.jsx new file mode 100644 index 00000000..5b49b788 --- /dev/null +++ b/web/src/js/components/Header/FilterInput.jsx @@ -0,0 +1,133 @@ +import React, { PropTypes, Component } from 'react' +import ReactDOM from 'react-dom' +import classnames from 'classnames' +import { Key } from '../../utils.js' +import Filt from '../../filt/filt' +import FilterDocs from './FilterDocs' + +export default class FilterInput extends Component { + + static contextTypes = { + returnFocus: React.PropTypes.func, + } + + constructor(props, context) { + super(props, context) + + // Consider both focus and mouseover for showing/hiding the tooltip, + // because onBlur of the input is triggered before the click on the tooltip + // finalized, hiding the tooltip just as the user clicks on it. + this.state = { value: this.props.value, focus: false, mousefocus: false } + + this.onChange = this.onChange.bind(this) + this.onFocus = this.onFocus.bind(this) + this.onBlur = this.onBlur.bind(this) + this.onKeyDown = this.onKeyDown.bind(this) + this.onMouseEnter = this.onMouseEnter.bind(this) + this.onMouseLeave = this.onMouseLeave.bind(this) + } + + componentWillReceiveProps(nextProps) { + this.setState({ value: nextProps.value }) + } + + isValid(filt) { + try { + const str = filt == null ? this.state.value : filt + if (str) { + Filt.parse(str) + } + return true + } catch (e) { + return false + } + } + + getDesc() { + if (!this.state.value) { + return + } + try { + return Filt.parse(this.state.value).desc + } catch (e) { + return '' + e + } + } + + onChange(e) { + const value = e.target.value + this.setState({ value }) + + // Only propagate valid filters upwards. + if (this.isValid(value)) { + this.props.onChange(value) + } + } + + onFocus() { + this.setState({ focus: true }) + } + + onBlur() { + this.setState({ focus: false }) + } + + onMouseEnter() { + this.setState({ mousefocus: true }) + } + + onMouseLeave() { + this.setState({ mousefocus: false }) + } + + onKeyDown(e) { + if (e.keyCode === Key.ESC || e.keyCode === Key.ENTER) { + this.blur() + // If closed using ESC/ENTER, hide the tooltip. + this.setState({mousefocus: false}) + } + e.stopPropagation() + } + + blur() { + ReactDOM.findDOMNode(this.refs.input).blur() + this.context.returnFocus() + } + + select() { + ReactDOM.findDOMNode(this.refs.input).select() + } + + render() { + const { type, color, placeholder } = this.props + const { value, focus, mousefocus } = this.state + return ( +
+ + + + + {(focus || mousefocus) && ( +
+
+
+ {this.getDesc()} +
+
+ )} +
+ ) + } +} diff --git a/web/src/js/components/Header/MainMenu.jsx b/web/src/js/components/Header/MainMenu.jsx new file mode 100644 index 00000000..86bf961a --- /dev/null +++ b/web/src/js/components/Header/MainMenu.jsx @@ -0,0 +1,73 @@ +import React, { Component, PropTypes } from 'react' +import { SettingsActions } from "../../actions.js" +import FilterInput from './FilterInput' +import { Query } from '../../actions.js' + +export default class MainMenu extends Component { + + static title = 'Start' + static route = 'flows' + + static propTypes = { + settings: React.PropTypes.object.isRequired, + } + + constructor(props, context) { + super(props, context) + this.onSearchChange = this.onSearchChange.bind(this) + this.onHighlightChange = this.onHighlightChange.bind(this) + this.onInterceptChange = this.onInterceptChange.bind(this) + } + + onSearchChange(val) { + this.props.updateLocation(undefined, { [Query.SEARCH]: val }) + } + + onHighlightChange(val) { + this.props.updateLocation(undefined, { [Query.HIGHLIGHT]: val }) + } + + onInterceptChange(val) { + SettingsActions.update({ intercept: val }) + } + + render() { + const { query, settings } = this.props + + const search = query[Query.SEARCH] || '' + const highlight = query[Query.HIGHLIGHT] || '' + const intercept = settings.intercept || '' + + return ( +
+
+ + + +
+
+
+ ) + } +} diff --git a/web/src/js/components/Header/OptionMenu.jsx b/web/src/js/components/Header/OptionMenu.jsx new file mode 100644 index 00000000..6bbf15d5 --- /dev/null +++ b/web/src/js/components/Header/OptionMenu.jsx @@ -0,0 +1,60 @@ +import React, { PropTypes } from 'react' +import { ToggleInputButton, ToggleButton } from '../common.js' +import { SettingsActions } from '../../actions.js' + +OptionMenu.title = "Options" + +OptionMenu.propTypes = { + settings: PropTypes.object.isRequired, +} + +export default function OptionMenu({ settings }) { + // @todo use settings.map + return ( +
+
+ SettingsActions.update({ showhost: !settings.showhost })} + /> + SettingsActions.update({ no_upstream_cert: !settings.no_upstream_cert })} + /> + SettingsActions.update({ rawtcp: !settings.rawtcp })} + /> + SettingsActions.update({ http2: !settings.http2 })} + /> + SettingsActions.update({ anticache: !settings.anticache })} + /> + SettingsActions.update({ anticomp: !settings.anticomp })} + /> + SettingsActions.update({ stickyauth: !settings.stickyauth ? txt : null })} + /> + SettingsActions.update({ stickycookie: !settings.stickycookie ? txt : null })} + /> + SettingsActions.update({ stream: !settings.stream ? txt : null })} + /> +
+
+
+ ) +} diff --git a/web/src/js/components/Header/ViewMenu.jsx b/web/src/js/components/Header/ViewMenu.jsx new file mode 100644 index 00000000..45359a83 --- /dev/null +++ b/web/src/js/components/Header/ViewMenu.jsx @@ -0,0 +1,33 @@ +import React, { PropTypes } from 'react' +import { bindActionCreators } from 'redux' +import { connect } from 'react-redux' +import { ToggleButton } from '../common.js' +import { toggleEventLogVisibility } from '../../ducks/eventLog' + +ViewMenu.title = 'View' +ViewMenu.route = 'flows' + +ViewMenu.propTypes = { + visible: PropTypes.bool.isRequired, + onToggle: PropTypes.func.isRequired, +} + +function ViewMenu({ visible, onToggle }) { + return ( +
+
+ +
+
+
+ ) +} + +export default connect( + state => ({ + visible: state.eventLog.visible, + }), + dispatch => bindActionCreators({ + onToggle: toggleEventLogVisibility, + }, dispatch) +)(ViewMenu) diff --git a/web/src/js/components/ProxyApp.jsx b/web/src/js/components/ProxyApp.jsx index bab8183d..81272268 100644 --- a/web/src/js/components/ProxyApp.jsx +++ b/web/src/js/components/ProxyApp.jsx @@ -4,7 +4,7 @@ import _ from "lodash" import { connect } from 'react-redux' import { Splitter } from "./common.js" -import { Header, MainMenu } from "./header.js" +import Header from "./Header" import EventLog from "./EventLog" import Footer from "./Footer" import { SettingsStore } from "../store/store.js" @@ -134,7 +134,7 @@ class ProxyAppMain extends Component { if (name) { const headerComponent = this.refs.header - headerComponent.setState({ active: MainMenu }, () => { + headerComponent.setState({ active: Header.entries.MainMenu }, () => { headerComponent.refs.active.refs[name].select() }) } diff --git a/web/src/js/components/header.js b/web/src/js/components/header.js deleted file mode 100644 index ebd77f91..00000000 --- a/web/src/js/components/header.js +++ /dev/null @@ -1,464 +0,0 @@ -import React from "react"; -import ReactDOM from 'react-dom'; -import { bindActionCreators } from 'redux' -import $ from "jquery"; -import {connect} from 'react-redux' - -import Filt from "../filt/filt.js"; -import {Key} from "../utils.js"; -import {ToggleInputButton, ToggleButton} from "./common.js"; -import {SettingsActions, FlowActions} from "../actions.js"; -import {Query} from "../actions.js"; -import {SettingsState} from "./common.js"; -import { toggleEventLogVisibility } from '../ducks/eventLog' - -const ToggleEventLog = connect( - state => ({ - checked: state.eventLog.visible - }), - dispatch => bindActionCreators({ - onToggle: toggleEventLogVisibility, - }, dispatch) -)(ToggleButton) - -var FilterDocs = React.createClass({ - statics: { - xhr: false, - doc: false - }, - componentWillMount: function () { - if (!FilterDocs.doc) { - FilterDocs.xhr = $.getJSON("/filter-help").done(function (doc) { - FilterDocs.doc = doc; - FilterDocs.xhr = false; - }); - } - if (FilterDocs.xhr) { - FilterDocs.xhr.done(function () { - this.forceUpdate(); - }.bind(this)); - } - }, - render: function () { - if (!FilterDocs.doc) { - return ; - } else { - var commands = FilterDocs.doc.commands.map(function (c) { - return - {c[0].replace(" ", '\u00a0')} - {c[1]} - ; - }); - commands.push( - - - -   mitmproxy docs - - ); - return - {commands} -
; - } - } -}); -var FilterInput = React.createClass({ - contextTypes: { - returnFocus: React.PropTypes.func - }, - getInitialState: function () { - // Consider both focus and mouseover for showing/hiding the tooltip, - // because onBlur of the input is triggered before the click on the tooltip - // finalized, hiding the tooltip just as the user clicks on it. - return { - value: this.props.value, - focus: false, - mousefocus: false - }; - }, - componentWillReceiveProps: function (nextProps) { - this.setState({value: nextProps.value}); - }, - onChange: function (e) { - var nextValue = e.target.value; - this.setState({ - value: nextValue - }); - // Only propagate valid filters upwards. - if (this.isValid(nextValue)) { - this.props.onChange(nextValue); - } - }, - isValid: function (filt) { - try { - var str = filt || this.state.value; - if(str){ - Filt.parse(filt || this.state.value); - } - return true; - } catch (e) { - return false; - } - }, - getDesc: function () { - if(this.state.value) { - try { - return Filt.parse(this.state.value).desc; - } catch (e) { - return "" + e; - } - } - return ; - }, - onFocus: function () { - this.setState({focus: true}); - }, - onBlur: function () { - this.setState({focus: false}); - }, - onMouseEnter: function () { - this.setState({mousefocus: true}); - }, - onMouseLeave: function () { - this.setState({mousefocus: false}); - }, - onKeyDown: function (e) { - if (e.keyCode === Key.ESC || e.keyCode === Key.ENTER) { - this.blur(); - // If closed using ESC/ENTER, hide the tooltip. - this.setState({mousefocus: false}); - } - e.stopPropagation(); - }, - blur: function () { - ReactDOM.findDOMNode(this.refs.input).blur(); - this.context.returnFocus(); - }, - select: function () { - ReactDOM.findDOMNode(this.refs.input).select(); - }, - render: function () { - var isValid = this.isValid(); - var icon = "fa fa-fw fa-" + this.props.type; - var groupClassName = "filter-input input-group" + (isValid ? "" : " has-error"); - - var popover; - if (this.state.focus || this.state.mousefocus) { - popover = ( -
-
-
- {this.getDesc()} -
-
- ); - } - - return ( -
- - - - - {popover} -
- ); - } -}); - -export var MainMenu = React.createClass({ - propTypes: { - settings: React.PropTypes.object.isRequired, - }, - statics: { - title: "Start", - route: "flows" - }, - onSearchChange: function (val) { - var d = {}; - d[Query.SEARCH] = val; - this.props.updateLocation(undefined, d); - }, - onHighlightChange: function (val) { - var d = {}; - d[Query.HIGHLIGHT] = val; - this.props.updateLocation(undefined, d); - }, - onInterceptChange: function (val) { - SettingsActions.update({intercept: val}); - }, - render: function () { - var search = this.props.query[Query.SEARCH] || ""; - var highlight = this.props.query[Query.HIGHLIGHT] || ""; - var intercept = this.props.settings.intercept || ""; - - return ( -
-
- - - -
-
-
- ); - } -}); - - -var ViewMenu = React.createClass({ - statics: { - title: "View", - route: "flows" - }, - render: function () { - return ( -
-
- -
-
-
- ); - } -}); - -export const OptionMenu = (props) => { - const {mode, intercept, showhost, no_upstream_cert, rawtcp, http2, anticache, anticomp, stickycookie, stickyauth, stream} = props.settings; - return ( -
-
- SettingsActions.update({showhost: !showhost})} - /> - SettingsActions.update({no_upstream_cert: !no_upstream_cert})} - /> - SettingsActions.update({rawtcp: !rawtcp})} - /> - SettingsActions.update({http2: !http2})} - /> - SettingsActions.update({anticache: !anticache})} - /> - SettingsActions.update({anticomp: !anticomp})} - /> - SettingsActions.update({stickyauth: (!stickyauth ? txt : null)})} - /> - SettingsActions.update({stickycookie: (!stickycookie ? txt : null)})} - /> - SettingsActions.update({stream: (!stream ? txt : null)})} - /> -
-
-
- ); -}; -OptionMenu.title = "Options"; - -OptionMenu.propTypes = { - settings: React.PropTypes.object.isRequired -}; - -var ReportsMenu = React.createClass({ - statics: { - title: "Visualization", - route: "reports" - }, - render: function () { - return
Reports Menu
; - } -}); - -var FileMenu = React.createClass({ - getInitialState: function () { - return { - showFileMenu: false - }; - }, - handleFileClick: function (e) { - e.preventDefault(); - if (!this.state.showFileMenu) { - var close = function () { - this.setState({showFileMenu: false}); - document.removeEventListener("click", close); - }.bind(this); - document.addEventListener("click", close); - - this.setState({ - showFileMenu: true - }); - } - }, - handleNewClick: function (e) { - e.preventDefault(); - if (confirm("Delete all flows?")) { - FlowActions.clear(); - } - }, - handleOpenClick: function (e) { - this.fileInput.click(); - e.preventDefault(); - }, - handleOpenFile: function (e) { - if (e.target.files.length > 0) { - FlowActions.upload(e.target.files[0]); - this.fileInput.value = ""; - } - e.preventDefault(); - }, - handleSaveClick: function (e) { - e.preventDefault(); - FlowActions.download(); - }, - handleShutdownClick: function (e) { - e.preventDefault(); - console.error("unimplemented: handleShutdownClick"); - }, - render: function () { - var fileMenuClass = "dropdown pull-left" + (this.state.showFileMenu ? " open" : ""); - - return ( -
- mitmproxy - -
- ); - } -}); - - -var header_entries = [MainMenu, ViewMenu, OptionMenu /*, ReportsMenu */]; - - -export var Header = React.createClass({ - propTypes: { - settings: React.PropTypes.object.isRequired, - }, - getInitialState: function () { - return { - active: header_entries[0] - }; - }, - handleClick: function (active, e) { - e.preventDefault(); - this.props.updateLocation(active.route); - this.setState({active: active}); - }, - render: function () { - var header = header_entries.map(function (entry, i) { - var className; - if (entry === this.state.active) { - className = "active"; - } else { - className = ""; - } - return ( - - {entry.title} - - ); - }.bind(this)); - - return ( -
- -
- -
-
- ); - } -}); diff --git a/web/src/js/components/prompt.js b/web/src/js/components/prompt.js index e324f7d4..5ab26b82 100644 --- a/web/src/js/components/prompt.js +++ b/web/src/js/components/prompt.js @@ -42,7 +42,7 @@ var Prompt = React.createClass({ var opts = []; var keyTaken = function (k) { - return _.includes(_.pluck(opts, "key"), k); + return _.includes(_.map(opts, "key"), k); }; for (var i = 0; i < this.props.options.length; i++) { @@ -99,4 +99,4 @@ var Prompt = React.createClass({ } }); -export default Prompt; \ No newline at end of file +export default Prompt; -- cgit v1.2.3