From 18b619e164ced91cf0ac8d3fd3c18be1f07df1cc Mon Sep 17 00:00:00 2001 From: Maximilian Hils Date: Thu, 18 Feb 2016 12:29:35 +0100 Subject: move mitmproxy/web to root --- web/src/js/components/common.js | 219 ++++++++++++++ web/src/js/components/editor.js | 240 ++++++++++++++++ web/src/js/components/eventlog.js | 150 ++++++++++ web/src/js/components/flowtable-columns.js | 201 +++++++++++++ web/src/js/components/flowtable.js | 187 ++++++++++++ web/src/js/components/flowview/contentview.js | 237 +++++++++++++++ web/src/js/components/flowview/details.js | 181 ++++++++++++ web/src/js/components/flowview/index.js | 127 ++++++++ web/src/js/components/flowview/messages.js | 326 +++++++++++++++++++++ web/src/js/components/flowview/nav.js | 61 ++++ web/src/js/components/footer.js | 19 ++ web/src/js/components/header.js | 399 ++++++++++++++++++++++++++ web/src/js/components/mainview.js | 244 ++++++++++++++++ web/src/js/components/prompt.js | 100 +++++++ web/src/js/components/proxyapp.js | 129 +++++++++ web/src/js/components/virtualscroll.js | 85 ++++++ 16 files changed, 2905 insertions(+) create mode 100644 web/src/js/components/common.js create mode 100644 web/src/js/components/editor.js create mode 100644 web/src/js/components/eventlog.js create mode 100644 web/src/js/components/flowtable-columns.js create mode 100644 web/src/js/components/flowtable.js create mode 100644 web/src/js/components/flowview/contentview.js create mode 100644 web/src/js/components/flowview/details.js create mode 100644 web/src/js/components/flowview/index.js create mode 100644 web/src/js/components/flowview/messages.js create mode 100644 web/src/js/components/flowview/nav.js create mode 100644 web/src/js/components/footer.js create mode 100644 web/src/js/components/header.js create mode 100644 web/src/js/components/mainview.js create mode 100644 web/src/js/components/prompt.js create mode 100644 web/src/js/components/proxyapp.js create mode 100644 web/src/js/components/virtualscroll.js (limited to 'web/src/js/components') diff --git a/web/src/js/components/common.js b/web/src/js/components/common.js new file mode 100644 index 00000000..965ae9a7 --- /dev/null +++ b/web/src/js/components/common.js @@ -0,0 +1,219 @@ +var React = require("react"); +var ReactRouter = require("react-router"); +var _ = require("lodash"); + +// http://blog.vjeux.com/2013/javascript/scroll-position-with-react.html (also contains inverse example) +var AutoScrollMixin = { + componentWillUpdate: function () { + var node = this.getDOMNode(); + this._shouldScrollBottom = ( + node.scrollTop !== 0 && + node.scrollTop + node.clientHeight === node.scrollHeight + ); + }, + componentDidUpdate: function () { + if (this._shouldScrollBottom) { + var node = this.getDOMNode(); + node.scrollTop = node.scrollHeight; + } + }, +}; + + +var StickyHeadMixin = { + adjustHead: function () { + // Abusing CSS transforms to set the element + // referenced as head into some kind of position:sticky. + var head = this.refs.head.getDOMNode(); + head.style.transform = "translate(0," + this.getDOMNode().scrollTop + "px)"; + } +}; + +var SettingsState = { + contextTypes: { + settingsStore: React.PropTypes.object.isRequired + }, + getInitialState: function () { + return { + settings: this.context.settingsStore.dict + }; + }, + componentDidMount: function () { + this.context.settingsStore.addListener("recalculate", this.onSettingsChange); + }, + componentWillUnmount: function () { + this.context.settingsStore.removeListener("recalculate", this.onSettingsChange); + }, + onSettingsChange: function () { + this.setState({ + settings: this.context.settingsStore.dict + }); + }, +}; + + +var ChildFocus = { + contextTypes: { + returnFocus: React.PropTypes.func + }, + returnFocus: function(){ + React.findDOMNode(this).blur(); + window.getSelection().removeAllRanges(); + this.context.returnFocus(); + } +}; + + +var Navigation = _.extend({}, ReactRouter.Navigation, { + setQuery: function (dict) { + var q = this.context.router.getCurrentQuery(); + for (var i in dict) { + if (dict.hasOwnProperty(i)) { + q[i] = dict[i] || undefined; //falsey values shall be removed. + } + } + this.replaceWith(this.context.router.getCurrentPath(), this.context.router.getCurrentParams(), q); + }, + replaceWith: function (routeNameOrPath, params, query) { + if (routeNameOrPath === undefined) { + routeNameOrPath = this.context.router.getCurrentPath(); + } + if (params === undefined) { + params = this.context.router.getCurrentParams(); + } + if (query === undefined) { + query = this.context.router.getCurrentQuery(); + } + + this.context.router.replaceWith(routeNameOrPath, params, query); + } +}); + +// react-router is fairly good at changing its API regularly. +// We keep the old method for now - if it should turn out that their changes are permanent, +// we may remove this mixin and access react-router directly again. +var RouterState = _.extend({}, ReactRouter.State, { + getQuery: function () { + // 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.context.router.getCurrentQuery()); + }, + getParams: function () { + return _.clone(this.context.router.getCurrentParams()); + } +}); + +var Splitter = React.createClass({ + getDefaultProps: function () { + return { + axis: "x" + }; + }, + getInitialState: function () { + return { + applied: false, + startX: false, + startY: false + }; + }, + onMouseDown: function (e) { + this.setState({ + startX: e.pageX, + startY: e.pageY + }); + window.addEventListener("mousemove", this.onMouseMove); + window.addEventListener("mouseup", this.onMouseUp); + // Occasionally, only a dragEnd event is triggered, but no mouseUp. + window.addEventListener("dragend", this.onDragEnd); + }, + onDragEnd: function () { + this.getDOMNode().style.transform = ""; + window.removeEventListener("dragend", this.onDragEnd); + window.removeEventListener("mouseup", this.onMouseUp); + window.removeEventListener("mousemove", this.onMouseMove); + }, + onMouseUp: function (e) { + this.onDragEnd(); + + var node = this.getDOMNode(); + var prev = node.previousElementSibling; + var next = node.nextElementSibling; + + var dX = e.pageX - this.state.startX; + var dY = e.pageY - this.state.startY; + var flexBasis; + if (this.props.axis === "x") { + flexBasis = prev.offsetWidth + dX; + } else { + flexBasis = prev.offsetHeight + dY; + } + + prev.style.flex = "0 0 " + Math.max(0, flexBasis) + "px"; + next.style.flex = "1 1 auto"; + + this.setState({ + applied: true + }); + this.onResize(); + }, + onMouseMove: function (e) { + var dX = 0, dY = 0; + if (this.props.axis === "x") { + dX = e.pageX - this.state.startX; + } else { + dY = e.pageY - this.state.startY; + } + this.getDOMNode().style.transform = "translate(" + dX + "px," + dY + "px)"; + }, + onResize: function () { + // Trigger a global resize event. This notifies components that employ virtual scrolling + // that their viewport may have changed. + window.setTimeout(function () { + window.dispatchEvent(new CustomEvent("resize")); + }, 1); + }, + reset: function (willUnmount) { + if (!this.state.applied) { + return; + } + var node = this.getDOMNode(); + var prev = node.previousElementSibling; + var next = node.nextElementSibling; + + prev.style.flex = ""; + next.style.flex = ""; + + if (!willUnmount) { + this.setState({ + applied: false + }); + } + this.onResize(); + }, + componentWillUnmount: function () { + this.reset(true); + }, + render: function () { + var className = "splitter"; + if (this.props.axis === "x") { + className += " splitter-x"; + } else { + className += " splitter-y"; + } + return ( +
+
+
+ ); + } +}); + +module.exports = { + ChildFocus: ChildFocus, + RouterState: RouterState, + Navigation: Navigation, + StickyHeadMixin: StickyHeadMixin, + AutoScrollMixin: AutoScrollMixin, + Splitter: Splitter, + SettingsState: SettingsState +}; \ No newline at end of file diff --git a/web/src/js/components/editor.js b/web/src/js/components/editor.js new file mode 100644 index 00000000..f2d44566 --- /dev/null +++ b/web/src/js/components/editor.js @@ -0,0 +1,240 @@ +var React = require("react"); +var common = require("./common.js"); +var utils = require("../utils.js"); + +var contentToHtml = function (content) { + return _.escape(content); +}; +var nodeToContent = function (node) { + return node.textContent; +}; + +/* + Basic Editor Functionality + */ +var EditorBase = React.createClass({ + propTypes: { + content: React.PropTypes.string.isRequired, + onDone: React.PropTypes.func.isRequired, + contentToHtml: React.PropTypes.func, + nodeToContent: React.PropTypes.func, // content === nodeToContent( Node ) + onStop: React.PropTypes.func, + submitOnEnter: React.PropTypes.bool, + className: React.PropTypes.string, + tag: React.PropTypes.string + }, + getDefaultProps: function () { + return { + contentToHtml: contentToHtml, + nodeToContent: nodeToContent, + submitOnEnter: true, + className: "", + tag: "div" + }; + }, + getInitialState: function () { + return { + editable: false + }; + }, + render: function () { + var className = "inline-input " + this.props.className; + var html = {__html: this.props.contentToHtml(this.props.content)}; + var Tag = this.props.tag; + return ; + }, + onPaste: function (e) { + e.preventDefault(); + var content = e.clipboardData.getData("text/plain"); + document.execCommand("insertHTML", false, content); + }, + onMouseDown: function (e) { + this._mouseDown = true; + window.addEventListener("mouseup", this.onMouseUp); + this.props.onMouseDown && this.props.onMouseDown(e); + }, + onMouseUp: function () { + if (this._mouseDown) { + this._mouseDown = false; + window.removeEventListener("mouseup", this.onMouseUp) + } + }, + onClick: function (e) { + this.onMouseUp(); + this.onFocus(e); + }, + onFocus: function (e) { + console.log("onFocus", this._mouseDown, this._ignore_events, this.state.editable); + if (this._mouseDown || this._ignore_events || this.state.editable) { + return; + } + + //contenteditable in FireFox is more or less broken. + // - we need to blur() and then focus(), otherwise the caret is not shown. + // - blur() + focus() == we need to save the caret position before + // Firefox sometimes just doesn't set a caret position => use caretPositionFromPoint + var sel = window.getSelection(); + var range; + if (sel.rangeCount > 0) { + range = sel.getRangeAt(0); + } else if (document.caretPositionFromPoint && e.clientX && e.clientY) { + var pos = document.caretPositionFromPoint(e.clientX, e.clientY); + range = document.createRange(); + range.setStart(pos.offsetNode, pos.offset); + } else if (document.caretRangeFromPoint && e.clientX && e.clientY) { + range = document.caretRangeFromPoint(e.clientX, e.clientY); + } else { + range = document.createRange(); + range.selectNodeContents(React.findDOMNode(this)); + } + + this._ignore_events = true; + this.setState({editable: true}, function () { + var node = React.findDOMNode(this); + node.blur(); + node.focus(); + this._ignore_events = false; + //sel.removeAllRanges(); + //sel.addRange(range); + + + }); + }, + stop: function () { + // a stop would cause a blur as a side-effect. + // but a blur event must trigger a stop as well. + // to fix this, make stop = blur and do the actual stop in the onBlur handler. + React.findDOMNode(this).blur(); + this.props.onStop && this.props.onStop(); + }, + _stop: function (e) { + if (this._ignore_events) { + return; + } + console.log("_stop", _.extend({}, e)); + window.getSelection().removeAllRanges(); //make sure that selection is cleared on blur + var node = React.findDOMNode(this); + var content = this.props.nodeToContent(node); + this.setState({editable: false}); + this.props.onDone(content); + this.props.onBlur && this.props.onBlur(e); + }, + reset: function () { + React.findDOMNode(this).innerHTML = this.props.contentToHtml(this.props.content); + }, + onKeyDown: function (e) { + e.stopPropagation(); + switch (e.keyCode) { + case utils.Key.ESC: + e.preventDefault(); + this.reset(); + this.stop(); + break; + case utils.Key.ENTER: + if (this.props.submitOnEnter && !e.shiftKey) { + e.preventDefault(); + this.stop(); + } + break; + default: + break; + } + }, + onInput: function () { + var node = React.findDOMNode(this); + var content = this.props.nodeToContent(node); + this.props.onInput && this.props.onInput(content); + } +}); + +/* + Add Validation to EditorBase + */ +var ValidateEditor = React.createClass({ + propTypes: { + content: React.PropTypes.string.isRequired, + onDone: React.PropTypes.func.isRequired, + onInput: React.PropTypes.func, + isValid: React.PropTypes.func, + className: React.PropTypes.string, + }, + getInitialState: function () { + return { + currentContent: this.props.content + }; + }, + componentWillReceiveProps: function () { + this.setState({currentContent: this.props.content}); + }, + onInput: function (content) { + this.setState({currentContent: content}); + this.props.onInput && this.props.onInput(content); + }, + render: function () { + var className = this.props.className || ""; + if (this.props.isValid) { + if (this.props.isValid(this.state.currentContent)) { + className += " has-success"; + } else { + className += " has-warning" + } + } + return ; + }, + onDone: function (content) { + if (this.props.isValid && !this.props.isValid(content)) { + this.refs.editor.reset(); + content = this.props.content; + } + this.props.onDone(content); + } +}); + +/* + Text Editor with mitmweb-specific convenience features + */ +var ValueEditor = React.createClass({ + mixins: [common.ChildFocus], + propTypes: { + content: React.PropTypes.string.isRequired, + onDone: React.PropTypes.func.isRequired, + inline: React.PropTypes.bool, + }, + render: function () { + var tag = this.props.inline ? "span" : "div"; + return ; + }, + focus: function () { + React.findDOMNode(this).focus(); + }, + onStop: function () { + this.returnFocus(); + } +}); + +module.exports = { + ValueEditor: ValueEditor +}; \ No newline at end of file diff --git a/web/src/js/components/eventlog.js b/web/src/js/components/eventlog.js new file mode 100644 index 00000000..fea7b247 --- /dev/null +++ b/web/src/js/components/eventlog.js @@ -0,0 +1,150 @@ +var React = require("react"); +var common = require("./common.js"); +var Query = require("../actions.js").Query; +var VirtualScrollMixin = require("./virtualscroll.js"); +var views = require("../store/view.js"); +var _ = require("lodash"); + +var LogMessage = React.createClass({ + render: function () { + var entry = this.props.entry; + var indicator; + switch (entry.level) { + case "web": + indicator = ; + break; + case "debug": + indicator = ; + break; + default: + indicator = ; + } + return ( +
+ { indicator } {entry.message} +
+ ); + }, + shouldComponentUpdate: function () { + return false; // log entries are immutable. + } +}); + +var EventLogContents = React.createClass({ + contextTypes: { + eventStore: React.PropTypes.object.isRequired + }, + mixins: [common.AutoScrollMixin, VirtualScrollMixin], + getInitialState: function () { + var filterFn = function (entry) { + return this.props.filter[entry.level]; + }; + var view = new views.StoreView(this.context.eventStore, filterFn.bind(this)); + view.addListener("add", this.onEventLogChange); + view.addListener("recalculate", this.onEventLogChange); + + return { + view: view + }; + }, + componentWillUnmount: function () { + this.state.view.close(); + }, + filter: function (entry) { + return this.props.filter[entry.level]; + }, + onEventLogChange: function () { + this.forceUpdate(); + }, + componentWillReceiveProps: function (nextProps) { + if (nextProps.filter !== this.props.filter) { + this.props.filter = nextProps.filter; // Dirty: Make sure that view filter sees the update. + this.state.view.recalculate(); + } + }, + getDefaultProps: function () { + return { + rowHeight: 45, + rowHeightMin: 15, + placeholderTagName: "div" + }; + }, + renderRow: function (elem) { + return ; + }, + render: function () { + var entries = this.state.view.list; + var rows = this.renderRows(entries); + + return
+            { this.getPlaceholderTop(entries.length) }
+            {rows}
+            { this.getPlaceholderBottom(entries.length) }
+        
; + } +}); + +var ToggleFilter = React.createClass({ + toggle: function (e) { + e.preventDefault(); + return this.props.toggleLevel(this.props.name); + }, + render: function () { + var className = "label "; + if (this.props.active) { + className += "label-primary"; + } else { + className += "label-default"; + } + return ( + + {this.props.name} + + ); + } +}); + +var EventLog = React.createClass({ + mixins: [common.Navigation], + getInitialState: function () { + return { + filter: { + "debug": false, + "info": true, + "web": true + } + }; + }, + close: function () { + var d = {}; + d[Query.SHOW_EVENTLOG] = undefined; + this.setQuery(d); + }, + toggleLevel: function (level) { + var filter = _.extend({}, this.state.filter); + filter[level] = !filter[level]; + this.setState({filter: filter}); + }, + render: function () { + return ( +
+
+ Eventlog +
+ + + + +
+ +
+ +
+ ); + } +}); + +module.exports = EventLog; \ No newline at end of file diff --git a/web/src/js/components/flowtable-columns.js b/web/src/js/components/flowtable-columns.js new file mode 100644 index 00000000..74d96216 --- /dev/null +++ b/web/src/js/components/flowtable-columns.js @@ -0,0 +1,201 @@ +var React = require("react"); +var RequestUtils = require("../flow/utils.js").RequestUtils; +var ResponseUtils = require("../flow/utils.js").ResponseUtils; +var utils = require("../utils.js"); + +var TLSColumn = React.createClass({ + statics: { + Title: React.createClass({ + render: function(){ + return ; + } + }), + sortKeyFun: function(flow){ + return flow.request.scheme; + } + }, + render: function () { + var flow = this.props.flow; + var ssl = (flow.request.scheme === "https"); + var classes; + if (ssl) { + classes = "col-tls col-tls-https"; + } else { + classes = "col-tls col-tls-http"; + } + return ; + } +}); + + +var IconColumn = React.createClass({ + statics: { + Title: React.createClass({ + render: function(){ + return ; + } + }) + }, + render: function () { + var flow = this.props.flow; + + var 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 +
+ ; + } +}); + +var PathColumn = React.createClass({ + statics: { + Title: React.createClass({ + render: function(){ + return Path; + } + }), + sortKeyFun: function(flow){ + return RequestUtils.pretty_url(flow.request); + } + }, + render: function () { + var flow = this.props.flow; + return + {flow.request.is_replay ? : null} + {flow.intercepted ? : null} + { RequestUtils.pretty_url(flow.request) } + ; + } +}); + + +var MethodColumn = React.createClass({ + statics: { + Title: React.createClass({ + render: function(){ + return Method; + } + }), + sortKeyFun: function(flow){ + return flow.request.method; + } + }, + render: function () { + var flow = this.props.flow; + return {flow.request.method}; + } +}); + + +var StatusColumn = React.createClass({ + statics: { + Title: React.createClass({ + render: function(){ + return Status; + } + }), + sortKeyFun: function(flow){ + return flow.response ? flow.response.status_code : undefined; + } + }, + render: function () { + var flow = this.props.flow; + var status; + if (flow.response) { + status = flow.response.status_code; + } else { + status = null; + } + return {status}; + } +}); + + +var SizeColumn = React.createClass({ + statics: { + Title: React.createClass({ + render: function(){ + return Size; + } + }), + sortKeyFun: function(flow){ + var total = flow.request.contentLength; + if (flow.response) { + total += flow.response.contentLength || 0; + } + return total; + } + }, + render: function () { + var flow = this.props.flow; + + var total = flow.request.contentLength; + if (flow.response) { + total += flow.response.contentLength || 0; + } + var size = utils.formatSize(total); + return {size}; + } +}); + + +var TimeColumn = React.createClass({ + statics: { + Title: React.createClass({ + render: function(){ + return Time; + } + }), + sortKeyFun: function(flow){ + if(flow.response) { + return flow.response.timestamp_end - flow.request.timestamp_start; + } + } + }, + render: function () { + var flow = this.props.flow; + var time; + if (flow.response) { + time = utils.formatTimeDelta(1000 * (flow.response.timestamp_end - flow.request.timestamp_start)); + } else { + time = "..."; + } + return {time}; + } +}); + + +var all_columns = [ + TLSColumn, + IconColumn, + PathColumn, + MethodColumn, + StatusColumn, + SizeColumn, + TimeColumn +]; + +module.exports = all_columns; diff --git a/web/src/js/components/flowtable.js b/web/src/js/components/flowtable.js new file mode 100644 index 00000000..609034f6 --- /dev/null +++ b/web/src/js/components/flowtable.js @@ -0,0 +1,187 @@ +var React = require("react"); +var common = require("./common.js"); +var utils = require("../utils.js"); +var _ = require("lodash"); + +var VirtualScrollMixin = require("./virtualscroll.js"); +var flowtable_columns = require("./flowtable-columns.js"); + +var FlowRow = React.createClass({ + render: function () { + var flow = this.props.flow; + var columns = this.props.columns.map(function (Column) { + return ; + }.bind(this)); + var className = ""; + if (this.props.selected) { + className += " selected"; + } + if (this.props.highlighted) { + className += " highlighted"; + } + if (flow.intercepted) { + className += " intercepted"; + } + if (flow.request) { + className += " has-request"; + } + if (flow.response) { + className += " has-response"; + } + + return ( + + {columns} + ); + }, + shouldComponentUpdate: function (nextProps) { + return true; + // Further optimization could be done here + // by calling forceUpdate on flow updates, selection changes and column changes. + //return ( + //(this.props.columns.length !== nextProps.columns.length) || + //(this.props.selected !== nextProps.selected) + //); + } +}); + +var FlowTableHead = React.createClass({ + getInitialState: function(){ + return { + sortColumn: undefined, + sortDesc: false + }; + }, + onClick: function(Column){ + var sortDesc = this.state.sortDesc; + var hasSort = Column.sortKeyFun; + if(Column === this.state.sortColumn){ + sortDesc = !sortDesc; + this.setState({ + sortDesc: sortDesc + }); + } else { + this.setState({ + sortColumn: hasSort && Column, + sortDesc: false + }) + } + var sortKeyFun; + if(!sortDesc){ + sortKeyFun = Column.sortKeyFun; + } else { + sortKeyFun = hasSort && function(){ + var k = Column.sortKeyFun.apply(this, arguments); + if(_.isString(k)){ + return utils.reverseString(""+k); + } else { + return -k; + } + } + } + this.props.setSortKeyFun(sortKeyFun); + }, + render: function () { + var columns = this.props.columns.map(function (Column) { + var onClick = this.onClick.bind(this, Column); + var className; + if(this.state.sortColumn === Column) { + if(this.state.sortDesc){ + className = "sort-desc"; + } else { + className = "sort-asc"; + } + } + return ; + }.bind(this)); + return + {columns} + ; + } +}); + + +var ROW_HEIGHT = 32; + +var FlowTable = React.createClass({ + mixins: [common.StickyHeadMixin, common.AutoScrollMixin, VirtualScrollMixin], + contextTypes: { + view: React.PropTypes.object.isRequired + }, + getInitialState: function () { + return { + columns: flowtable_columns + }; + }, + componentWillMount: function () { + this.context.view.addListener("add", this.onChange); + this.context.view.addListener("update", this.onChange); + this.context.view.addListener("remove", this.onChange); + this.context.view.addListener("recalculate", this.onChange); + }, + componentWillUnmount: function(){ + this.context.view.removeListener("add", this.onChange); + this.context.view.removeListener("update", this.onChange); + this.context.view.removeListener("remove", this.onChange); + this.context.view.removeListener("recalculate", this.onChange); + }, + getDefaultProps: function () { + return { + rowHeight: ROW_HEIGHT + }; + }, + onScrollFlowTable: function () { + this.adjustHead(); + this.onScroll(); + }, + onChange: function () { + this.forceUpdate(); + }, + scrollIntoView: function (flow) { + this.scrollRowIntoView( + this.context.view.index(flow), + this.refs.body.getDOMNode().offsetTop + ); + }, + renderRow: function (flow) { + var selected = (flow === this.props.selected); + var highlighted = + ( + this.context.view._highlight && + this.context.view._highlight[flow.id] + ); + + return ; + }, + render: function () { + var flows = this.context.view.list; + var rows = this.renderRows(flows); + + return ( +
+ + + + { this.getPlaceholderTop(flows.length) } + {rows} + { this.getPlaceholderBottom(flows.length) } + +
+
+ ); + } +}); + +module.exports = FlowTable; diff --git a/web/src/js/components/flowview/contentview.js b/web/src/js/components/flowview/contentview.js new file mode 100644 index 00000000..63d22c1c --- /dev/null +++ b/web/src/js/components/flowview/contentview.js @@ -0,0 +1,237 @@ +var React = require("react"); +var _ = require("lodash"); + +var MessageUtils = require("../../flow/utils.js").MessageUtils; +var utils = require("../../utils.js"); + +var image_regex = /^image\/(png|jpe?g|gif|vnc.microsoft.icon|x-icon)$/i; +var ViewImage = React.createClass({ + statics: { + matches: function (message) { + return image_regex.test(MessageUtils.getContentType(message)); + } + }, + render: function () { + var url = MessageUtils.getContentURL(this.props.flow, this.props.message); + return
+ preview +
; + } +}); + +var RawMixin = { + getInitialState: function () { + return { + content: undefined, + request: undefined + } + }, + requestContent: function (nextProps) { + if (this.state.request) { + this.state.request.abort(); + } + var request = MessageUtils.getContent(nextProps.flow, nextProps.message); + this.setState({ + content: undefined, + request: request + }); + request.done(function (data) { + this.setState({content: data}); + }.bind(this)).fail(function (jqXHR, textStatus, errorThrown) { + if (textStatus === "abort") { + return; + } + this.setState({content: "AJAX Error: " + textStatus + "\r\n" + errorThrown}); + }.bind(this)).always(function () { + this.setState({request: undefined}); + }.bind(this)); + + }, + componentWillMount: function () { + this.requestContent(this.props); + }, + componentWillReceiveProps: function (nextProps) { + if (nextProps.message !== this.props.message) { + this.requestContent(nextProps); + } + }, + componentWillUnmount: function () { + if (this.state.request) { + this.state.request.abort(); + } + }, + render: function () { + if (!this.state.content) { + return
+ +
; + } + return this.renderContent(); + } +}; + +var ViewRaw = React.createClass({ + mixins: [RawMixin], + statics: { + matches: function (message) { + return true; + } + }, + renderContent: function () { + return
{this.state.content}
; + } +}); + +var json_regex = /^application\/json$/i; +var ViewJSON = React.createClass({ + mixins: [RawMixin], + statics: { + matches: function (message) { + return json_regex.test(MessageUtils.getContentType(message)); + } + }, + renderContent: function () { + var json = this.state.content; + try { + json = JSON.stringify(JSON.parse(json), null, 2); + } catch (e) { + } + return
{json}
; + } +}); + +var ViewAuto = React.createClass({ + statics: { + matches: function () { + return false; // don't match itself + }, + findView: function (message) { + for (var i = 0; i < all.length; i++) { + if (all[i].matches(message)) { + return all[i]; + } + } + return all[all.length - 1]; + } + }, + render: function () { + var View = ViewAuto.findView(this.props.message); + return ; + } +}); + +var all = [ViewAuto, ViewImage, ViewJSON, ViewRaw]; + + +var ContentEmpty = React.createClass({ + render: function () { + var message_name = this.props.flow.request === this.props.message ? "request" : "response"; + return
No {message_name} content.
; + } +}); + +var ContentMissing = React.createClass({ + render: function () { + var message_name = this.props.flow.request === this.props.message ? "Request" : "Response"; + return
{message_name} content missing.
; + } +}); + +var TooLarge = React.createClass({ + statics: { + isTooLarge: function (message) { + var max_mb = ViewImage.matches(message) ? 10 : 0.2; + return message.contentLength > 1024 * 1024 * max_mb; + } + }, + render: function () { + var size = utils.formatSize(this.props.message.contentLength); + return
+ + {size} content size. +
; + } +}); + +var ViewSelector = React.createClass({ + render: function () { + var views = []; + for (var i = 0; i < all.length; i++) { + var view = all[i]; + var className = "btn btn-default"; + if (view === this.props.active) { + className += " active"; + } + var text; + if (view === ViewAuto) { + text = "auto: " + ViewAuto.findView(this.props.message).displayName.toLowerCase().replace("view", ""); + } else { + text = view.displayName.toLowerCase().replace("view", ""); + } + views.push( + + ); + } + + return
{views}
; + } +}); + +var ContentView = React.createClass({ + getInitialState: function () { + return { + displayLarge: false, + View: ViewAuto + }; + }, + propTypes: { + // It may seem a bit weird at the first glance: + // Every view takes the flow and the message as props, e.g. + // + flow: React.PropTypes.object.isRequired, + message: React.PropTypes.object.isRequired, + }, + selectView: function (view) { + this.setState({ + View: view + }); + }, + displayLarge: function () { + this.setState({displayLarge: true}); + }, + componentWillReceiveProps: function (nextProps) { + if (nextProps.message !== this.props.message) { + this.setState(this.getInitialState()); + } + }, + render: function () { + var message = this.props.message; + if (message.contentLength === 0) { + return ; + } else if (message.contentLength === null) { + return ; + } else if (!this.state.displayLarge && TooLarge.isTooLarge(message)) { + return ; + } + + var downloadUrl = MessageUtils.getContentURL(this.props.flow, message); + + return
+ +
+ +   + + + +
+
; + } +}); + +module.exports = ContentView; \ No newline at end of file diff --git a/web/src/js/components/flowview/details.js b/web/src/js/components/flowview/details.js new file mode 100644 index 00000000..00e0116c --- /dev/null +++ b/web/src/js/components/flowview/details.js @@ -0,0 +1,181 @@ +var React = require("react"); +var _ = require("lodash"); + +var utils = require("../../utils.js"); + +var TimeStamp = React.createClass({ + render: function () { + + if (!this.props.t) { + //should be return null, but that triggers a React bug. + return ; + } + + var ts = utils.formatTimeStamp(this.props.t); + + var delta; + if (this.props.deltaTo) { + delta = utils.formatTimeDelta(1000 * (this.props.t - this.props.deltaTo)); + delta = {"(" + delta + ")"}; + } else { + delta = null; + } + + return + {this.props.title + ":"} + {ts} {delta} + ; + } +}); + +var ConnectionInfo = React.createClass({ + + render: function () { + var conn = this.props.conn; + var address = conn.address.address.join(":"); + + var sni = ; //should be null, but that triggers a React bug. + if (conn.sni) { + sni = + + TLS SNI: + + {conn.sni} + ; + } + return ( + + + + + + + {sni} + +
Address:{address}
+ ); + } +}); + +var CertificateInfo = React.createClass({ + render: function () { + //TODO: We should fetch human-readable certificate representation + // from the server + var flow = this.props.flow; + var client_conn = flow.client_conn; + var server_conn = flow.server_conn; + + var preStyle = {maxHeight: 100}; + return ( +
+ {client_conn.cert ?

Client Certificate

: null} + {client_conn.cert ?
{client_conn.cert}
: null} + + {server_conn.cert ?

Server Certificate

: null} + {server_conn.cert ?
{server_conn.cert}
: null} +
+ ); + } +}); + +var Timing = React.createClass({ + render: function () { + var flow = this.props.flow; + var sc = flow.server_conn; + var cc = flow.client_conn; + var req = flow.request; + var resp = flow.response; + + var timestamps = [ + { + title: "Server conn. initiated", + t: sc.timestamp_start, + deltaTo: req.timestamp_start + }, { + title: "Server conn. TCP handshake", + t: sc.timestamp_tcp_setup, + deltaTo: req.timestamp_start + }, { + title: "Server conn. SSL handshake", + t: sc.timestamp_ssl_setup, + deltaTo: req.timestamp_start + }, { + title: "Client conn. established", + t: cc.timestamp_start, + deltaTo: req.timestamp_start + }, { + title: "Client conn. SSL handshake", + t: cc.timestamp_ssl_setup, + deltaTo: req.timestamp_start + }, { + title: "First request byte", + t: req.timestamp_start, + }, { + title: "Request complete", + t: req.timestamp_end, + deltaTo: req.timestamp_start + } + ]; + + if (flow.response) { + timestamps.push( + { + title: "First response byte", + t: resp.timestamp_start, + deltaTo: req.timestamp_start + }, { + title: "Response complete", + t: resp.timestamp_end, + deltaTo: req.timestamp_start + } + ); + } + + //Add unique key for each row. + timestamps.forEach(function (e) { + e.key = e.title; + }); + + timestamps = _.sortBy(timestamps, 't'); + + var rows = timestamps.map(function (e) { + return ; + }); + + return ( +
+

Timing

+ + + {rows} + +
+
+ ); + } +}); + +var Details = React.createClass({ + render: function () { + var flow = this.props.flow; + var client_conn = flow.client_conn; + var server_conn = flow.server_conn; + return ( +
+ +

Client Connection

+ + +

Server Connection

+ + + + + + +
+ ); + } +}); + +module.exports = Details; \ No newline at end of file diff --git a/web/src/js/components/flowview/index.js b/web/src/js/components/flowview/index.js new file mode 100644 index 00000000..739a46dc --- /dev/null +++ b/web/src/js/components/flowview/index.js @@ -0,0 +1,127 @@ +var React = require("react"); +var _ = require("lodash"); + +var common = require("../common.js"); +var Nav = require("./nav.js"); +var Messages = require("./messages.js"); +var Details = require("./details.js"); +var Prompt = require("../prompt.js"); + + +var allTabs = { + request: Messages.Request, + response: Messages.Response, + error: Messages.Error, + details: Details +}; + +var FlowView = React.createClass({ + mixins: [common.StickyHeadMixin, common.Navigation, common.RouterState], + getInitialState: function () { + return { + prompt: false + }; + }, + getTabs: function (flow) { + var tabs = []; + ["request", "response", "error"].forEach(function (e) { + if (flow[e]) { + tabs.push(e); + } + }); + tabs.push("details"); + return tabs; + }, + nextTab: function (i) { + var tabs = this.getTabs(this.props.flow); + var currentIndex = tabs.indexOf(this.getActive()); + // JS modulo operator doesn't correct negative numbers, make sure that we are positive. + var nextIndex = (currentIndex + i + tabs.length) % tabs.length; + this.selectTab(tabs[nextIndex]); + }, + selectTab: function (panel) { + this.replaceWith( + "flow", + { + flowId: this.getParams().flowId, + detailTab: panel + } + ); + }, + getActive: function(){ + return this.getParams().detailTab; + }, + promptEdit: function () { + var options; + switch(this.getActive()){ + case "request": + options = [ + "method", + "url", + {text:"http version", key:"v"}, + "header" + /*, "content"*/]; + break; + case "response": + options = [ + {text:"http version", key:"v"}, + "code", + "message", + "header" + /*, "content"*/]; + break; + case "details": + return; + default: + throw "Unknown tab for edit: " + this.getActive(); + } + + this.setState({ + prompt: { + done: function (k) { + this.setState({prompt: false}); + if(k){ + this.refs.tab.edit(k); + } + }.bind(this), + options: options + } + }); + }, + render: function () { + var flow = this.props.flow; + var tabs = this.getTabs(flow); + var active = this.getActive(); + + if (!_.contains(tabs, active)) { + if (active === "response" && flow.error) { + active = "error"; + } else if (active === "error" && flow.response) { + active = "response"; + } else { + active = tabs[0]; + } + this.selectTab(active); + } + + var prompt = null; + if (this.state.prompt) { + prompt = ; + } + + var Tab = allTabs[active]; + return ( +
+
+ ); + } +}); + +module.exports = FlowView; \ No newline at end of file diff --git a/web/src/js/components/flowview/messages.js b/web/src/js/components/flowview/messages.js new file mode 100644 index 00000000..7ac95d85 --- /dev/null +++ b/web/src/js/components/flowview/messages.js @@ -0,0 +1,326 @@ +var React = require("react"); +var _ = require("lodash"); + +var common = require("../common.js"); +var actions = require("../../actions.js"); +var flowutils = require("../../flow/utils.js"); +var utils = require("../../utils.js"); +var ContentView = require("./contentview.js"); +var ValueEditor = require("../editor.js").ValueEditor; + +var Headers = React.createClass({ + propTypes: { + onChange: React.PropTypes.func.isRequired, + message: React.PropTypes.object.isRequired + }, + onChange: function (row, col, val) { + var nextHeaders = _.cloneDeep(this.props.message.headers); + nextHeaders[row][col] = val; + if (!nextHeaders[row][0] && !nextHeaders[row][1]) { + // do not delete last row + if (nextHeaders.length === 1) { + nextHeaders[0][0] = "Name"; + nextHeaders[0][1] = "Value"; + } else { + nextHeaders.splice(row, 1); + // manually move selection target if this has been the last row. + if (row === nextHeaders.length) { + this._nextSel = (row - 1) + "-value"; + } + } + } + this.props.onChange(nextHeaders); + }, + edit: function () { + this.refs["0-key"].focus(); + }, + onTab: function (row, col, e) { + var headers = this.props.message.headers; + if (row === headers.length - 1 && col === 1) { + e.preventDefault(); + + var nextHeaders = _.cloneDeep(this.props.message.headers); + nextHeaders.push(["Name", "Value"]); + this.props.onChange(nextHeaders); + this._nextSel = (row + 1) + "-key"; + } + }, + componentDidUpdate: function () { + if (this._nextSel && this.refs[this._nextSel]) { + this.refs[this._nextSel].focus(); + this._nextSel = undefined; + } + }, + onRemove: function (row, col, e) { + if (col === 1) { + e.preventDefault(); + this.refs[row + "-key"].focus(); + } else if (row > 0) { + e.preventDefault(); + this.refs[(row - 1) + "-value"].focus(); + } + }, + render: function () { + + var rows = this.props.message.headers.map(function (header, i) { + + var kEdit = ; + var vEdit = ; + return ( + + {kEdit}: + {vEdit} + + ); + }.bind(this)); + return ( + + + {rows} + +
+ ); + } +}); + +var HeaderEditor = React.createClass({ + render: function () { + return ; + }, + focus: function () { + this.getDOMNode().focus(); + }, + onKeyDown: function (e) { + switch (e.keyCode) { + case utils.Key.BACKSPACE: + var s = window.getSelection().getRangeAt(0); + if (s.startOffset === 0 && s.endOffset === 0) { + this.props.onRemove(e); + } + break; + case utils.Key.TAB: + if (!e.shiftKey) { + this.props.onTab(e); + } + break; + } + } +}); + +var RequestLine = React.createClass({ + render: function () { + var flow = this.props.flow; + var url = flowutils.RequestUtils.pretty_url(flow.request); + var httpver = flow.request.http_version; + + return
+ +   + +   + +
+ }, + isValidUrl: function (url) { + var u = flowutils.parseUrl(url); + return !!u.host; + }, + onMethodChange: function (nextMethod) { + actions.FlowActions.update( + this.props.flow, + {request: {method: nextMethod}} + ); + }, + onUrlChange: function (nextUrl) { + var props = flowutils.parseUrl(nextUrl); + props.path = props.path || ""; + actions.FlowActions.update( + this.props.flow, + {request: props} + ); + }, + onHttpVersionChange: function (nextVer) { + var ver = flowutils.parseHttpVersion(nextVer); + actions.FlowActions.update( + this.props.flow, + {request: {http_version: ver}} + ); + } +}); + +var ResponseLine = React.createClass({ + render: function () { + var flow = this.props.flow; + var httpver = flow.response.http_version; + return
+ +   + +   + +
; + }, + isValidCode: function (code) { + return /^\d+$/.test(code); + }, + onHttpVersionChange: function (nextVer) { + var ver = flowutils.parseHttpVersion(nextVer); + actions.FlowActions.update( + this.props.flow, + {response: {http_version: ver}} + ); + }, + onMsgChange: function (nextMsg) { + actions.FlowActions.update( + this.props.flow, + {response: {msg: nextMsg}} + ); + }, + onCodeChange: function (nextCode) { + nextCode = parseInt(nextCode); + actions.FlowActions.update( + this.props.flow, + {response: {code: nextCode}} + ); + } +}); + +var Request = React.createClass({ + render: function () { + var flow = this.props.flow; + return ( +
+ + {/**/} + +
+ +
+ ); + }, + edit: function (k) { + switch (k) { + case "m": + this.refs.requestLine.refs.method.focus(); + break; + case "u": + this.refs.requestLine.refs.url.focus(); + break; + case "v": + this.refs.requestLine.refs.httpVersion.focus(); + break; + case "h": + this.refs.headers.edit(); + break; + default: + throw "Unimplemented: " + k; + } + }, + onHeaderChange: function (nextHeaders) { + actions.FlowActions.update(this.props.flow, { + request: { + headers: nextHeaders + } + }); + } +}); + +var Response = React.createClass({ + render: function () { + var flow = this.props.flow; + return ( +
+ {/**/} + + +
+ +
+ ); + }, + edit: function (k) { + switch (k) { + case "c": + this.refs.responseLine.refs.status_code.focus(); + break; + case "m": + this.refs.responseLine.refs.msg.focus(); + break; + case "v": + this.refs.responseLine.refs.httpVersion.focus(); + break; + case "h": + this.refs.headers.edit(); + break; + default: + throw "Unimplemented: " + k; + } + }, + onHeaderChange: function (nextHeaders) { + actions.FlowActions.update(this.props.flow, { + response: { + headers: nextHeaders + } + }); + } +}); + +var Error = React.createClass({ + render: function () { + var flow = this.props.flow; + return ( +
+
+ {flow.error.msg} +
+ { utils.formatTimeStamp(flow.error.timestamp) } +
+
+
+ ); + } +}); + +module.exports = { + Request: Request, + Response: Response, + Error: Error +}; diff --git a/web/src/js/components/flowview/nav.js b/web/src/js/components/flowview/nav.js new file mode 100644 index 00000000..46eda707 --- /dev/null +++ b/web/src/js/components/flowview/nav.js @@ -0,0 +1,61 @@ +var React = require("react"); + +var actions = require("../../actions.js"); + +var NavAction = React.createClass({ + onClick: function (e) { + e.preventDefault(); + this.props.onClick(); + }, + render: function () { + return ( + + + + ); + } +}); + +var Nav = React.createClass({ + render: function () { + var flow = this.props.flow; + + var tabs = this.props.tabs.map(function (e) { + var str = e.charAt(0).toUpperCase() + e.slice(1); + var className = this.props.active === e ? "active" : ""; + var onClick = function (event) { + this.props.selectTab(e); + event.preventDefault(); + }.bind(this); + return {str}; + }.bind(this)); + + var acceptButton = null; + if(flow.intercepted){ + acceptButton = ; + } + var revertButton = null; + if(flow.modified){ + revertButton = ; + } + + return ( + + ); + } +}); + +module.exports = Nav; \ No newline at end of file diff --git a/web/src/js/components/footer.js b/web/src/js/components/footer.js new file mode 100644 index 00000000..229d691b --- /dev/null +++ b/web/src/js/components/footer.js @@ -0,0 +1,19 @@ +var React = require("react"); +var common = require("./common.js"); + +var Footer = React.createClass({ + mixins: [common.SettingsState], + render: function () { + var mode = this.state.settings.mode; + var intercept = this.state.settings.intercept; + return ( +
+ {mode && mode != "regular" ? {mode} mode : null} +   + {intercept ? Intercept: {intercept} : null} +
+ ); + } +}); + +module.exports = Footer; \ No newline at end of file diff --git a/web/src/js/components/header.js b/web/src/js/components/header.js new file mode 100644 index 00000000..998a41df --- /dev/null +++ b/web/src/js/components/header.js @@ -0,0 +1,399 @@ +var React = require("react"); +var $ = require("jquery"); + +var Filt = require("../filt/filt.js"); +var utils = require("../utils.js"); +var common = require("./common.js"); +var actions = require("../actions.js"); +var Query = require("../actions.js").Query; + +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({ + mixins: [common.ChildFocus], + 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 { + Filt.parse(filt || this.state.value); + return true; + } catch (e) { + return false; + } + }, + getDesc: function () { + var desc; + try { + desc = Filt.parse(this.state.value).desc; + } catch (e) { + desc = "" + e; + } + if (desc !== "true") { + return desc; + } else { + 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 === utils.Key.ESC || e.keyCode === utils.Key.ENTER) { + this.blur(); + // If closed using ESC/ENTER, hide the tooltip. + this.setState({mousefocus: false}); + } + e.stopPropagation(); + }, + blur: function () { + this.refs.input.getDOMNode().blur(); + this.returnFocus(); + }, + select: function () { + this.refs.input.getDOMNode().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} +
+ ); + } +}); + +var MainMenu = React.createClass({ + mixins: [common.Navigation, common.RouterState, common.SettingsState], + statics: { + title: "Start", + route: "flows" + }, + onSearchChange: function (val) { + var d = {}; + d[Query.SEARCH] = val; + this.setQuery(d); + }, + onHighlightChange: function (val) { + var d = {}; + d[Query.HIGHLIGHT] = val; + this.setQuery(d); + }, + onInterceptChange: function (val) { + actions.SettingsActions.update({intercept: val}); + }, + render: function () { + var search = this.getQuery()[Query.SEARCH] || ""; + var highlight = this.getQuery()[Query.HIGHLIGHT] || ""; + var intercept = this.state.settings.intercept || ""; + + return ( +
+
+ + + +
+
+
+ ); + } +}); + + +var ViewMenu = React.createClass({ + statics: { + title: "View", + route: "flows" + }, + mixins: [common.Navigation, common.RouterState], + toggleEventLog: function () { + var d = {}; + + if (this.getQuery()[Query.SHOW_EVENTLOG]) { + d[Query.SHOW_EVENTLOG] = undefined; + } else { + d[Query.SHOW_EVENTLOG] = "t"; // any non-false value will do it, keep it short + } + + this.setQuery(d); + }, + render: function () { + var showEventLog = this.getQuery()[Query.SHOW_EVENTLOG]; + return ( +
+ + +
+ ); + } +}); + + +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?")) { + actions.FlowActions.clear(); + } + }, + handleOpenClick: function (e) { + e.preventDefault(); + console.error("unimplemented: handleOpenClick"); + }, + handleSaveClick: function (e) { + e.preventDefault(); + console.error("unimplemented: handleSaveClick"); + }, + handleShutdownClick: function (e) { + e.preventDefault(); + console.error("unimplemented: handleShutdownClick"); + }, + render: function () { + var fileMenuClass = "dropdown pull-left" + (this.state.showFileMenu ? " open" : ""); + + return ( + + ); + } +}); + + +var header_entries = [MainMenu, ViewMenu /*, ReportsMenu */]; + + +var Header = React.createClass({ + mixins: [common.Navigation], + getInitialState: function () { + return { + active: header_entries[0] + }; + }, + handleClick: function (active, e) { + e.preventDefault(); + this.replaceWith(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 ( +
+ +
+ +
+
+ ); + } +}); + + +module.exports = { + Header: Header, + MainMenu: MainMenu +}; \ No newline at end of file diff --git a/web/src/js/components/mainview.js b/web/src/js/components/mainview.js new file mode 100644 index 00000000..9ff51dfa --- /dev/null +++ b/web/src/js/components/mainview.js @@ -0,0 +1,244 @@ +var React = require("react"); + +var actions = require("../actions.js"); +var Query = require("../actions.js").Query; +var utils = require("../utils.js"); +var views = require("../store/view.js"); +var Filt = require("../filt/filt.js"); + +var common = require("./common.js"); +var FlowTable = require("./flowtable.js"); +var FlowView = require("./flowview/index.js"); + +var MainView = React.createClass({ + mixins: [common.Navigation, common.RouterState], + contextTypes: { + flowStore: React.PropTypes.object.isRequired, + }, + childContextTypes: { + view: React.PropTypes.object.isRequired, + }, + getChildContext: function () { + return { + view: this.state.view + }; + }, + getInitialState: function () { + var sortKeyFun = false; + var view = new views.StoreView(this.context.flowStore, this.getViewFilt(), sortKeyFun); + view.addListener("recalculate", this.onRecalculate); + view.addListener("add", this.onUpdate); + view.addListener("update", this.onUpdate); + view.addListener("remove", this.onUpdate); + view.addListener("remove", this.onRemove); + + return { + view: view, + sortKeyFun: sortKeyFun + }; + }, + componentWillUnmount: function () { + this.state.view.close(); + }, + getViewFilt: function () { + try { + var filt = Filt.parse(this.getQuery()[Query.SEARCH] || ""); + var highlightStr = this.getQuery()[Query.HIGHLIGHT]; + var highlight = highlightStr ? Filt.parse(highlightStr) : false; + } catch (e) { + console.error("Error when processing filter: " + e); + } + + return function filter_and_highlight(flow) { + if (!this._highlight) { + this._highlight = {}; + } + this._highlight[flow.id] = highlight && highlight(flow); + return filt(flow); + }; + }, + componentWillReceiveProps: function (nextProps) { + var filterChanged = (this.props.query[Query.SEARCH] !== nextProps.query[Query.SEARCH]); + var highlightChanged = (this.props.query[Query.HIGHLIGHT] !== nextProps.query[Query.HIGHLIGHT]); + if (filterChanged || highlightChanged) { + this.state.view.recalculate(this.getViewFilt(), this.state.sortKeyFun); + } + }, + onRecalculate: function () { + this.forceUpdate(); + var selected = this.getSelected(); + if (selected) { + this.refs.flowTable.scrollIntoView(selected); + } + }, + onUpdate: function (flow) { + if (flow.id === this.getParams().flowId) { + this.forceUpdate(); + } + }, + onRemove: function (flow_id, index) { + if (flow_id === this.getParams().flowId) { + var flow_to_select = this.state.view.list[Math.min(index, this.state.view.list.length - 1)]; + this.selectFlow(flow_to_select); + } + }, + setSortKeyFun: function (sortKeyFun) { + this.setState({ + sortKeyFun: sortKeyFun + }); + this.state.view.recalculate(this.getViewFilt(), sortKeyFun); + }, + selectFlow: function (flow) { + if (flow) { + this.replaceWith( + "flow", + { + flowId: flow.id, + detailTab: this.getParams().detailTab || "request" + } + ); + this.refs.flowTable.scrollIntoView(flow); + } else { + this.replaceWith("flows", {}); + } + }, + selectFlowRelative: function (shift) { + var flows = this.state.view.list; + var index; + if (!this.getParams().flowId) { + if (shift < 0) { + index = flows.length - 1; + } else { + index = 0; + } + } else { + var currFlowId = this.getParams().flowId; + var i = flows.length; + while (i--) { + if (flows[i].id === currFlowId) { + index = i; + break; + } + } + index = Math.min( + Math.max(0, index + shift), + flows.length - 1); + } + this.selectFlow(flows[index]); + }, + onMainKeyDown: function (e) { + var flow = this.getSelected(); + if (e.ctrlKey) { + return; + } + switch (e.keyCode) { + case utils.Key.K: + case utils.Key.UP: + this.selectFlowRelative(-1); + break; + case utils.Key.J: + case utils.Key.DOWN: + this.selectFlowRelative(+1); + break; + case utils.Key.SPACE: + case utils.Key.PAGE_DOWN: + this.selectFlowRelative(+10); + break; + case utils.Key.PAGE_UP: + this.selectFlowRelative(-10); + break; + case utils.Key.END: + this.selectFlowRelative(+1e10); + break; + case utils.Key.HOME: + this.selectFlowRelative(-1e10); + break; + case utils.Key.ESC: + this.selectFlow(null); + break; + case utils.Key.H: + case utils.Key.LEFT: + if (this.refs.flowDetails) { + this.refs.flowDetails.nextTab(-1); + } + break; + case utils.Key.L: + case utils.Key.TAB: + case utils.Key.RIGHT: + if (this.refs.flowDetails) { + this.refs.flowDetails.nextTab(+1); + } + break; + case utils.Key.C: + if (e.shiftKey) { + actions.FlowActions.clear(); + } + break; + case utils.Key.D: + if (flow) { + if (e.shiftKey) { + actions.FlowActions.duplicate(flow); + } else { + actions.FlowActions.delete(flow); + } + } + break; + case utils.Key.A: + if (e.shiftKey) { + actions.FlowActions.accept_all(); + } else if (flow && flow.intercepted) { + actions.FlowActions.accept(flow); + } + break; + case utils.Key.R: + if (!e.shiftKey && flow) { + actions.FlowActions.replay(flow); + } + break; + case utils.Key.V: + if (e.shiftKey && flow && flow.modified) { + actions.FlowActions.revert(flow); + } + break; + case utils.Key.E: + if (this.refs.flowDetails) { + this.refs.flowDetails.promptEdit(); + } + break; + case utils.Key.SHIFT: + break; + default: + console.debug("keydown", e.keyCode); + return; + } + e.preventDefault(); + }, + getSelected: function () { + return this.context.flowStore.get(this.getParams().flowId); + }, + render: function () { + var selected = this.getSelected(); + + var details; + if (selected) { + details = [ + , + + ]; + } else { + details = null; + } + + return ( +
+ + {details} +
+ ); + } +}); + +module.exports = MainView; diff --git a/web/src/js/components/prompt.js b/web/src/js/components/prompt.js new file mode 100644 index 00000000..121a1170 --- /dev/null +++ b/web/src/js/components/prompt.js @@ -0,0 +1,100 @@ +var React = require("react"); +var _ = require("lodash"); + +var utils = require("../utils.js"); +var common = require("./common.js"); + +var Prompt = React.createClass({ + mixins: [common.ChildFocus], + propTypes: { + options: React.PropTypes.array.isRequired, + done: React.PropTypes.func.isRequired, + prompt: React.PropTypes.string + }, + componentDidMount: function () { + React.findDOMNode(this).focus(); + }, + onKeyDown: function (e) { + e.stopPropagation(); + e.preventDefault(); + var opts = this.getOptions(); + for (var i = 0; i < opts.length; i++) { + var k = opts[i].key; + if (utils.Key[k.toUpperCase()] === e.keyCode) { + this.done(k); + return; + } + } + if (e.keyCode === utils.Key.ESC || e.keyCode === utils.Key.ENTER) { + this.done(false); + } + }, + onClick: function (e) { + this.done(false); + }, + done: function (ret) { + this.props.done(ret); + this.returnFocus(); + }, + getOptions: function () { + var opts = []; + + var keyTaken = function (k) { + return _.includes(_.pluck(opts, "key"), k); + }; + + for (var i = 0; i < this.props.options.length; i++) { + var opt = this.props.options[i]; + if (_.isString(opt)) { + var str = opt; + while (str.length > 0 && keyTaken(str[0])) { + str = str.substr(1); + } + opt = { + text: opt, + key: str[0] + }; + } + if (!opt.text || !opt.key || keyTaken(opt.key)) { + throw "invalid options"; + } else { + opts.push(opt); + } + } + return opts; + }, + render: function () { + var opts = this.getOptions(); + opts = _.map(opts, function (o) { + var prefix, suffix; + var idx = o.text.indexOf(o.key); + if (idx !== -1) { + prefix = o.text.substring(0, idx); + suffix = o.text.substring(idx + 1); + + } else { + prefix = o.text + " ("; + suffix = ")"; + } + var onClick = function (e) { + this.done(o.key); + e.stopPropagation(); + }.bind(this); + return + {prefix} + {o.key}{suffix} + ; + }.bind(this)); + return
+
+ {this.props.prompt || Select: } + {opts} +
+
; + } +}); + +module.exports = Prompt; \ No newline at end of file diff --git a/web/src/js/components/proxyapp.js b/web/src/js/components/proxyapp.js new file mode 100644 index 00000000..e766d6e6 --- /dev/null +++ b/web/src/js/components/proxyapp.js @@ -0,0 +1,129 @@ +var React = require("react"); +var ReactRouter = require("react-router"); +var _ = require("lodash"); + +var common = require("./common.js"); +var MainView = require("./mainview.js"); +var Footer = require("./footer.js"); +var header = require("./header.js"); +var EventLog = require("./eventlog.js"); +var store = require("../store/store.js"); +var Query = require("../actions.js").Query; +var Key = require("../utils.js").Key; + + +//TODO: Move out of here, just a stub. +var Reports = React.createClass({ + render: function () { + return
ReportEditor
; + } +}); + + +var ProxyAppMain = React.createClass({ + mixins: [common.RouterState], + childContextTypes: { + settingsStore: React.PropTypes.object.isRequired, + flowStore: React.PropTypes.object.isRequired, + eventStore: React.PropTypes.object.isRequired, + returnFocus: React.PropTypes.func.isRequired, + }, + componentDidMount: function () { + this.focus(); + }, + getChildContext: function () { + return { + settingsStore: this.state.settingsStore, + flowStore: this.state.flowStore, + eventStore: this.state.eventStore, + returnFocus: this.focus, + }; + }, + getInitialState: function () { + var eventStore = new store.EventLogStore(); + var flowStore = new store.FlowStore(); + var settingsStore = new store.SettingsStore(); + + // Default Settings before fetch + _.extend(settingsStore.dict, {}); + return { + settingsStore: settingsStore, + flowStore: flowStore, + eventStore: eventStore + }; + }, + focus: function () { + React.findDOMNode(this).focus(); + }, + getMainComponent: function () { + return this.refs.view.refs.__routeHandler__; + }, + onKeydown: function (e) { + + var selectFilterInput = function (name) { + var headerComponent = this.refs.header; + headerComponent.setState({active: header.MainMenu}, function () { + headerComponent.refs.active.refs[name].select(); + }); + }.bind(this); + + switch (e.keyCode) { + case Key.I: + selectFilterInput("intercept"); + break; + case Key.L: + selectFilterInput("search"); + break; + case Key.H: + selectFilterInput("highlight"); + break; + default: + var main = this.getMainComponent(); + if (main.onMainKeyDown) { + main.onMainKeyDown(e); + } + return; // don't prevent default then + } + e.preventDefault(); + }, + render: function () { + var eventlog; + if (this.getQuery()[Query.SHOW_EVENTLOG]) { + eventlog = [ + , + + ]; + } else { + eventlog = null; + } + return ( +
+ + + {eventlog} +
+
+ ); + } +}); + + +var Route = ReactRouter.Route; +var RouteHandler = ReactRouter.RouteHandler; +var Redirect = ReactRouter.Redirect; +var DefaultRoute = ReactRouter.DefaultRoute; +var NotFoundRoute = ReactRouter.NotFoundRoute; + + +var routes = ( + + + + + + +); + +module.exports = { + routes: routes +}; \ No newline at end of file diff --git a/web/src/js/components/virtualscroll.js b/web/src/js/components/virtualscroll.js new file mode 100644 index 00000000..956e1a0b --- /dev/null +++ b/web/src/js/components/virtualscroll.js @@ -0,0 +1,85 @@ +var React = require("react"); + +var VirtualScrollMixin = { + getInitialState: function () { + return { + start: 0, + stop: 0 + }; + }, + componentWillMount: function () { + if (!this.props.rowHeight) { + console.warn("VirtualScrollMixin: No rowHeight specified", this); + } + }, + getPlaceholderTop: function (total) { + var Tag = this.props.placeholderTagName || "tr"; + // When a large trunk of elements is removed from the button, start may be far off the viewport. + // To make this issue less severe, limit the top placeholder to the total number of rows. + var style = { + height: Math.min(this.state.start, total) * this.props.rowHeight + }; + var spacer = ; + + if (this.state.start % 2 === 1) { + // fix even/odd rows + return [spacer, ]; + } else { + return spacer; + } + }, + getPlaceholderBottom: function (total) { + var Tag = this.props.placeholderTagName || "tr"; + var style = { + height: Math.max(0, total - this.state.stop) * this.props.rowHeight + }; + return ; + }, + componentDidMount: function () { + this.onScroll(); + window.addEventListener('resize', this.onScroll); + }, + componentWillUnmount: function(){ + window.removeEventListener('resize', this.onScroll); + }, + onScroll: function () { + var viewport = this.getDOMNode(); + var top = viewport.scrollTop; + var height = viewport.offsetHeight; + var start = Math.floor(top / this.props.rowHeight); + var stop = start + Math.ceil(height / (this.props.rowHeightMin || this.props.rowHeight)); + + this.setState({ + start: start, + stop: stop + }); + }, + renderRows: function (elems) { + var rows = []; + var max = Math.min(elems.length, this.state.stop); + + for (var i = this.state.start; i < max; i++) { + var elem = elems[i]; + rows.push(this.renderRow(elem)); + } + return rows; + }, + scrollRowIntoView: function (index, head_height) { + + var row_top = (index * this.props.rowHeight) + head_height; + var row_bottom = row_top + this.props.rowHeight; + + var viewport = this.getDOMNode(); + var viewport_top = viewport.scrollTop; + var viewport_bottom = viewport_top + viewport.offsetHeight; + + // Account for pinned thead + if (row_top - head_height < viewport_top) { + viewport.scrollTop = row_top - head_height; + } else if (row_bottom > viewport_bottom) { + viewport.scrollTop = row_bottom - viewport.offsetHeight; + } + }, +}; + +module.exports = VirtualScrollMixin; \ No newline at end of file -- cgit v1.2.3