diff options
Diffstat (limited to 'libmproxy/web/static/js/app.js')
-rw-r--r-- | libmproxy/web/static/js/app.js | 1524 |
1 files changed, 1524 insertions, 0 deletions
diff --git a/libmproxy/web/static/js/app.js b/libmproxy/web/static/js/app.js new file mode 100644 index 00000000..fe317d7f --- /dev/null +++ b/libmproxy/web/static/js/app.js @@ -0,0 +1,1524 @@ +// 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 + 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 Key = { + UP: 38, + DOWN: 40, + PAGE_UP: 33, + PAGE_DOWN: 34, + LEFT: 37, + RIGHT: 39, + ENTER: 13, + ESC: 27, + TAB: 9, + SPACE: 32, + J: 74, + K: 75, + H: 72, + L: 76 +}; + +var formatSize = function (bytes) { + var size = bytes; + var prefix = ["B", "KB", "MB", "GB", "TB"]; + var i=0; + while (Math.abs(size) >= 1024 && i < prefix.length-1) { + i++; + size = size / 1024; + } + return (Math.floor(size * 100) / 100.0).toFixed(2) + prefix[i]; +}; + +var formatTimeDelta = function (milliseconds) { + var time = milliseconds; + var prefix = ["ms", "s", "min", "h"]; + var div = [1000, 60, 60]; + var i = 0; + while (Math.abs(time) >= div[i] && i < div.length) { + time = time / div[i]; + i++; + } + return Math.round(time) + prefix[i]; +}; +const PayloadSources = { + VIEW: "view", + SERVER: "server" +}; + + +function Dispatcher() { + this.callbacks = []; +} +Dispatcher.prototype.register = function (callback) { + this.callbacks.push(callback); +}; +Dispatcher.prototype.unregister = function (callback) { + var index = this.callbacks.indexOf(f); + if (index >= 0) { + this.callbacks.splice(this.callbacks.indexOf(f), 1); + } +}; +Dispatcher.prototype.dispatch = function (payload) { + console.debug("dispatch", payload); + for(var i = 0; i < this.callbacks.length; i++){ + this.callbacks[i](payload); + } +}; + + +AppDispatcher = new Dispatcher(); +AppDispatcher.dispatchViewAction = function (action) { + action.source = PayloadSources.VIEW; + this.dispatch(action); +}; +AppDispatcher.dispatchServerAction = function (action) { + action.source = PayloadSources.SERVER; + this.dispatch(action); +}; + +var ActionTypes = { + //Settings + UPDATE_SETTINGS: "update_settings", + + //EventLog + ADD_EVENT: "add_event", + + //Flow + ADD_FLOW: "add_flow", + UPDATE_FLOW: "update_flow", +}; + +var SettingsActions = { + update: function (settings) { + settings = _.merge({}, SettingsStore.getAll(), settings); + //TODO: Update server. + + //Facebook Flux: We do an optimistic update on the client already. + AppDispatcher.dispatchViewAction({ + type: ActionTypes.UPDATE_SETTINGS, + settings: settings + }); + } +}; + +var event_id = 0; +var EventLogActions = { + add_event: function(message){ + AppDispatcher.dispatchViewAction({ + type: ActionTypes.ADD_EVENT, + data: { + message: message, + level: "web", + id: "viewAction-"+event_id++ + } + }); + } +}; +var _MessageUtils = { + getContentType: function (message) { + return this.get_first_header(message, /^Content-Type$/i); + }, + get_first_header: function (message, regex) { + //FIXME: Cache Invalidation. + if (!message._headerLookups) + Object.defineProperty(message, "_headerLookups", { + value: {}, + configurable: false, + enumerable: false, + writable: false + }); + if (!(regex in message._headerLookups)) { + var header; + for (var i = 0; i < message.headers.length; i++) { + if (!!message.headers[i][0].match(regex)) { + header = message.headers[i]; + break; + } + } + message._headerLookups[regex] = header ? header[1] : undefined; + } + return message._headerLookups[regex]; + } +}; + +var defaultPorts = { + "http": 80, + "https": 443 +}; + +var RequestUtils = _.extend(_MessageUtils, { + pretty_host: function (request) { + //FIXME: Add hostheader + return request.host; + }, + pretty_url: function (request) { + var port = ""; + if (defaultPorts[request.scheme] !== request.port) { + port = ":" + request.port; + } + return request.scheme + "://" + this.pretty_host(request) + port + request.path; + } +}); + +var ResponseUtils = _.extend(_MessageUtils, {}); +function EventEmitter() { + this.listeners = {}; +} +EventEmitter.prototype.emit = function (event) { + if (!(event in this.listeners)) { + return; + } + var args = Array.prototype.slice.call(arguments, 1); + this.listeners[event].forEach(function (listener) { + listener.apply(this, args); + }.bind(this)); +}; +EventEmitter.prototype.addListener = function (event, f) { + this.listeners[event] = this.listeners[event] || []; + this.listeners[event].push(f); +}; +EventEmitter.prototype.removeListener = function (event, f) { + if (!(event in this.listeners)) { + return false; + } + var index = this.listeners[event].indexOf(f); + if (index >= 0) { + this.listeners[event].splice(index, 1); + } +}; + +function _SettingsStore() { + EventEmitter.call(this); + + //FIXME: What do we do if we haven't requested anything from the server yet? + this.settings = { + version: "0.12", + showEventLog: true, + mode: "transparent", + }; +} +_.extend(_SettingsStore.prototype, EventEmitter.prototype, { + getAll: function () { + return this.settings; + }, + handle: function (action) { + switch (action.type) { + case ActionTypes.UPDATE_SETTINGS: + this.settings = action.settings; + this.emit("change"); + break; + default: + return; + } + } +}); + +var SettingsStore = new _SettingsStore(); +AppDispatcher.register(SettingsStore.handle.bind(SettingsStore)); + +// +// We have an EventLogView and an EventLogStore: +// The basic architecture is that one can request views on the event log +// from the store, which returns a view object and then deals with getting the data required for the view. +// The view object is accessed by React components and distributes updates etc. +// +// See also: components/EventLog.react.js +function EventLogView(store, live) { + EventEmitter.call(this); + this._store = store; + this.live = live; + this.log = []; + + this.add = this.add.bind(this); + + if (live) { + this._store.addListener(ActionTypes.ADD_EVENT, this.add); + } +} +_.extend(EventLogView.prototype, EventEmitter.prototype, { + close: function () { + this._store.removeListener(ActionTypes.ADD_EVENT, this.add); + }, + getAll: function () { + return this.log; + }, + add: function (entry) { + this.log.push(entry); + if(this.log.length > 200){ + this.log.shift(); + } + this.emit("change"); + }, + add_bulk: function (messages) { + var log = messages; + var last_id = log[log.length - 1].id; + var to_add = _.filter(this.log, function (entry) { + return entry.id > last_id; + }); + this.log = log.concat(to_add); + this.emit("change"); + } +}); + + +function _EventLogStore() { + EventEmitter.call(this); +} +_.extend(_EventLogStore.prototype, EventEmitter.prototype, { + getView: function (since) { + var view = new EventLogView(this, !since); + return view; + /* + //TODO: Really do bulk retrieval of last messages. + window.setTimeout(function () { + view.add_bulk([ + { + id: 1, + message: "Hello World" + }, + { + id: 2, + message: "I was already transmitted as an event." + } + ]); + }, 100); + + var id = 2; + view.add({ + id: id++, + message: "I was already transmitted as an event." + }); + view.add({ + id: id++, + message: "I was only transmitted as an event before the bulk was added.." + }); + window.setInterval(function () { + view.add({ + id: id++, + message: "." + }); + }, 1000); + return view; + */ + }, + handle: function (action) { + switch (action.type) { + case ActionTypes.ADD_EVENT: + this.emit(ActionTypes.ADD_EVENT, action.data); + break; + default: + return; + } + } +}); + + +var EventLogStore = new _EventLogStore(); +AppDispatcher.register(EventLogStore.handle.bind(EventLogStore)); +function FlowView(store, live) { + EventEmitter.call(this); + this._store = store; + this.live = live; + this.flows = []; + + this.add = this.add.bind(this); + this.update = this.update.bind(this); + + if (live) { + this._store.addListener(ActionTypes.ADD_FLOW, this.add); + this._store.addListener(ActionTypes.UPDATE_FLOW, this.update); + } +} + +_.extend(FlowView.prototype, EventEmitter.prototype, { + close: function () { + this._store.removeListener(ActionTypes.ADD_FLOW, this.add); + this._store.removeListener(ActionTypes.UPDATE_FLOW, this.update); + }, + getAll: function () { + return this.flows; + }, + add: function (flow) { + return this.update(flow); + }, + add_bulk: function (flows) { + //Treat all previously received updates as newer than the bulk update. + //If they weren't newer, we're about to receive an update for them very soon. + var updates = this.flows; + this.flows = flows; + updates.forEach(function(flow){ + this._update(flow); + }.bind(this)); + this.emit("change"); + }, + _update: function(flow){ + var idx = _.findIndex(this.flows, function(f){ + return flow.id === f.id; + }); + + if(idx < 0){ + this.flows.push(flow); + //if(this.flows.length > 100){ + // this.flows.shift(); + //} + } else { + this.flows[idx] = flow; + } + }, + update: function(flow){ + this._update(flow); + this.emit("change"); + }, +}); + + +function _FlowStore() { + EventEmitter.call(this); +} +_.extend(_FlowStore.prototype, EventEmitter.prototype, { + getView: function (since) { + var view = new FlowView(this, !since); + + $.getJSON("/static/flows.json", function(flows){ + flows = flows.concat(_.cloneDeep(flows)).concat(_.cloneDeep(flows)); + var id = 1; + flows.forEach(function(flow){ + flow.id = "uuid-" + id++; + }); + view.add_bulk(flows); + + }); + + return view; + }, + handle: function (action) { + switch (action.type) { + case ActionTypes.ADD_FLOW: + case ActionTypes.UPDATE_FLOW: + this.emit(action.type, action.data); + break; + default: + return; + } + } +}); + + +var FlowStore = new _FlowStore(); +AppDispatcher.register(FlowStore.handle.bind(FlowStore)); + +function _Connection(url) { + this.url = url; +} +_Connection.prototype.init = function () { + this.openWebSocketConnection(); +}; +_Connection.prototype.openWebSocketConnection = function () { + this.ws = new WebSocket(this.url.replace("http", "ws")); + var ws = this.ws; + + ws.onopen = this.onopen.bind(this); + ws.onmessage = this.onmessage.bind(this); + ws.onerror = this.onerror.bind(this); + ws.onclose = this.onclose.bind(this); +}; +_Connection.prototype.onopen = function (open) { + console.debug("onopen", this, arguments); +}; +_Connection.prototype.onmessage = function (message) { + //AppDispatcher.dispatchServerAction(...); + var m = JSON.parse(message.data); + AppDispatcher.dispatchServerAction(m); +}; +_Connection.prototype.onerror = function (error) { + EventLogActions.add_event("WebSocket Connection Error."); + console.debug("onerror", this, arguments); +}; +_Connection.prototype.onclose = function (close) { + EventLogActions.add_event("WebSocket Connection closed."); + console.debug("onclose", this, arguments); +}; + +var Connection = new _Connection(location.origin + "/updates"); + +/** @jsx React.DOM */ + +//React utils. For other utilities, see ../utils.js + +var Splitter = React.createClass({displayName: 'Splitter', + 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 + }); + }, + 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)"; + }, + 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 + }); + } + + }, + componentWillUnmount: function(){ + this.reset(true); + }, + render: function(){ + var className = "splitter"; + if(this.props.axis === "x"){ + className += " splitter-x"; + } else { + className += " splitter-y"; + } + return ( + React.DOM.div({className: className}, + React.DOM.div({onMouseDown: this.onMouseDown, draggable: "true"}) + ) + ); + } +}); +/** @jsx React.DOM */ + +var MainMenu = React.createClass({displayName: 'MainMenu', + statics: { + title: "Traffic", + route: "flows" + }, + toggleEventLog: function () { + SettingsActions.update({ + showEventLog: !this.props.settings.showEventLog + }); + }, + render: function () { + return ( + React.DOM.div(null, + React.DOM.button({className: "btn " + (this.props.settings.showEventLog ? "btn-primary" : "btn-default"), onClick: this.toggleEventLog}, + React.DOM.i({className: "fa fa-database"}), " Display Event Log" + ) + ) + ); + } +}); + + +var ToolsMenu = React.createClass({displayName: 'ToolsMenu', + statics: { + title: "Tools", + route: "flows" + }, + render: function () { + return React.DOM.div(null, "Tools Menu"); + } +}); + + +var ReportsMenu = React.createClass({displayName: 'ReportsMenu', + statics: { + title: "Visualization", + route: "reports" + }, + render: function () { + return React.DOM.div(null, "Reports Menu"); + } +}); + + +var header_entries = [MainMenu, ToolsMenu, ReportsMenu]; + + +var Header = React.createClass({displayName: 'Header', + getInitialState: function () { + return { + active: header_entries[0] + }; + }, + handleClick: function (active) { + ReactRouter.transitionTo(active.route); + this.setState({active: active}); + return false; + }, + handleFileClick: function () { + console.log("File click"); + }, + render: function () { + var header = header_entries.map(function(entry, i){ + var classes = React.addons.classSet({ + active: entry == this.state.active + }); + return ( + React.DOM.a({key: i, + href: "#", + className: classes, + onClick: this.handleClick.bind(this, entry) + }, + entry.title + ) + ); + }.bind(this)); + + return ( + React.DOM.header(null, + React.DOM.div({className: "title-bar"}, + "mitmproxy ", this.props.settings.version + ), + React.DOM.nav({className: "nav-tabs nav-tabs-lg"}, + React.DOM.a({href: "#", className: "special", onClick: this.handleFileClick}, " File "), + header + ), + React.DOM.div({className: "menu"}, + this.state.active({settings: this.props.settings}) + ) + ) + ); + } +}); + +/** @jsx React.DOM */ + + +var TLSColumn = React.createClass({displayName: 'TLSColumn', + statics: { + renderTitle: function(){ + return React.DOM.th({key: "tls", className: "col-tls"}); + } + }, + 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 React.DOM.td({className: classes}); + } +}); + + +var IconColumn = React.createClass({displayName: 'IconColumn', + statics: { + renderTitle: function(){ + return React.DOM.th({key: "icon", className: "col-icon"}); + } + }, + 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.code == 304) { + icon = "resource-icon-not-modified"; + } else if(300 <= flow.response.code && flow.response.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 React.DOM.td({className: "col-icon"}, React.DOM.div({className: icon})); + } +}); + +var PathColumn = React.createClass({displayName: 'PathColumn', + statics: { + renderTitle: function(){ + return React.DOM.th({key: "path", className: "col-path"}, "Path"); + } + }, + render: function(){ + var flow = this.props.flow; + return React.DOM.td({className: "col-path"}, flow.request.scheme + "://" + flow.request.host + flow.request.path); + } +}); + + +var MethodColumn = React.createClass({displayName: 'MethodColumn', + statics: { + renderTitle: function(){ + return React.DOM.th({key: "method", className: "col-method"}, "Method"); + } + }, + render: function(){ + var flow = this.props.flow; + return React.DOM.td({className: "col-method"}, flow.request.method); + } +}); + + +var StatusColumn = React.createClass({displayName: 'StatusColumn', + statics: { + renderTitle: function(){ + return React.DOM.th({key: "status", className: "col-status"}, "Status"); + } + }, + render: function(){ + var flow = this.props.flow; + var status; + if(flow.response){ + status = flow.response.code; + } else { + status = null; + } + return React.DOM.td({className: "col-status"}, status); + } +}); + + +var SizeColumn = React.createClass({displayName: 'SizeColumn', + statics: { + renderTitle: function(){ + return React.DOM.th({key: "size", className: "col-size"}, "Size"); + } + }, + render: function(){ + var flow = this.props.flow; + + var total = flow.request.contentLength; + if(flow.response){ + total += flow.response.contentLength || 0; + } + var size = formatSize(total); + return React.DOM.td({className: "col-size"}, size); + } +}); + + +var TimeColumn = React.createClass({displayName: 'TimeColumn', + statics: { + renderTitle: function(){ + return React.DOM.th({key: "time", className: "col-time"}, "Time"); + } + }, + render: function(){ + var flow = this.props.flow; + var time; + if(flow.response){ + time = formatTimeDelta(1000 * (flow.response.timestamp_end - flow.request.timestamp_start)); + } else { + time = "..."; + } + return React.DOM.td({className: "col-time"}, time); + } +}); + + +var all_columns = [ + TLSColumn, + IconColumn, + PathColumn, + MethodColumn, + StatusColumn, + SizeColumn, + TimeColumn]; + + +/** @jsx React.DOM */ + +var FlowRow = React.createClass({displayName: 'FlowRow', + render: function(){ + var flow = this.props.flow; + var columns = this.props.columns.map(function(column){ + return column({key: column.displayName, flow: flow}); + }.bind(this)); + var className = ""; + if(this.props.selected){ + className += "selected"; + } + return ( + React.DOM.tr({className: className, onClick: this.props.selectFlow.bind(null, flow)}, + columns + )); + }, + shouldComponentUpdate: function(nextProps){ + var isEqual = ( + this.props.columns.length === nextProps.columns.length && + this.props.selected === nextProps.selected && + this.props.flow.response === nextProps.flow.response); + return !isEqual; + } +}); + +var FlowTableHead = React.createClass({displayName: 'FlowTableHead', + render: function(){ + var columns = this.props.columns.map(function(column){ + return column.renderTitle(); + }.bind(this)); + return React.DOM.thead(null, React.DOM.tr(null, columns)); + } +}); + +var FlowTableBody = React.createClass({displayName: 'FlowTableBody', + render: function(){ + var rows = this.props.flows.map(function(flow){ + var selected = (flow == this.props.selected); + return FlowRow({key: flow.id, + ref: flow.id, + flow: flow, + columns: this.props.columns, + selected: selected, + selectFlow: this.props.selectFlow} + ); + }.bind(this)); + return React.DOM.tbody(null, rows); + } +}); + + +var FlowTable = React.createClass({displayName: 'FlowTable', + mixins: [StickyHeadMixin, AutoScrollMixin], + getInitialState: function () { + return { + columns: all_columns + }; + }, + scrollIntoView: function(flow){ + // Now comes the fun part: Scroll the flow into the view. + var viewport = this.getDOMNode(); + var flowNode = this.refs.body.refs[flow.id].getDOMNode(); + var viewport_top = viewport.scrollTop; + var viewport_bottom = viewport_top + viewport.offsetHeight; + var flowNode_top = flowNode.offsetTop; + var flowNode_bottom = flowNode_top + flowNode.offsetHeight; + + // Account for pinned thead by pretending that the flowNode starts + // -thead_height pixel earlier. + flowNode_top -= this.refs.body.getDOMNode().offsetTop; + + if(flowNode_top < viewport_top){ + viewport.scrollTop = flowNode_top; + } else if(flowNode_bottom > viewport_bottom) { + viewport.scrollTop = flowNode_bottom - viewport.offsetHeight; + } + }, + render: function () { + return ( + React.DOM.div({className: "flow-table", onScroll: this.adjustHead}, + React.DOM.table(null, + FlowTableHead({ref: "head", + columns: this.state.columns}), + FlowTableBody({ref: "body", + flows: this.props.flows, + selected: this.props.selected, + selectFlow: this.props.selectFlow, + columns: this.state.columns}) + ) + ) + ); + } +}); + +/** @jsx React.DOM */ + +var FlowDetailNav = React.createClass({displayName: 'FlowDetailNav', + render: function(){ + + var items = 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(){ + this.props.selectTab(e); + return false; + }.bind(this); + return React.DOM.a({key: e, + href: "#", + className: className, + onClick: onClick}, str); + }.bind(this)); + return ( + React.DOM.nav({ref: "head", className: "nav-tabs nav-tabs-sm"}, + items + ) + ); + } +}); + +var Headers = React.createClass({displayName: 'Headers', + render: function(){ + var rows = this.props.message.headers.map(function(header, i){ + return ( + React.DOM.tr({key: i}, + React.DOM.td({className: "header-name"}, header[0]+":"), + React.DOM.td({className: "header-value"}, header[1]) + ) + ); + }); + return ( + React.DOM.table({className: "header-table"}, + React.DOM.tbody(null, + rows + ) + ) + ); + } +}); + +var FlowDetailRequest = React.createClass({displayName: 'FlowDetailRequest', + render: function(){ + var flow = this.props.flow; + var first_line = [ + flow.request.method, + RequestUtils.pretty_url(flow.request), + "HTTP/"+ flow.response.httpversion.join(".") + ].join(" "); + var content = null; + if(flow.request.contentLength > 0){ + content = "Request Content Size: "+ formatSize(flow.request.contentLength); + } else { + content = React.DOM.div({className: "alert alert-info"}, "No Content"); + } + + //TODO: Styling + + return ( + React.DOM.section(null, + React.DOM.div({className: "first-line"}, first_line ), + Headers({message: flow.request}), + React.DOM.hr(null), + content + ) + ); + } +}); + +var FlowDetailResponse = React.createClass({displayName: 'FlowDetailResponse', + render: function(){ + var flow = this.props.flow; + var first_line = [ + "HTTP/"+ flow.response.httpversion.join("."), + flow.response.code, + flow.response.msg + ].join(" "); + var content = null; + if(flow.response.contentLength > 0){ + content = "Response Content Size: "+ formatSize(flow.response.contentLength); + } else { + content = React.DOM.div({className: "alert alert-info"}, "No Content"); + } + + //TODO: Styling + + return ( + React.DOM.section(null, + React.DOM.div({className: "first-line"}, first_line ), + Headers({message: flow.response}), + React.DOM.hr(null), + content + ) + ); + } +}); + +var TimeStamp = React.createClass({displayName: 'TimeStamp', + render: function() { + + if(!this.props.t){ + //should be return null, but that triggers a React bug. + return React.DOM.tr(null); + } + + var ts = (new Date(this.props.t * 1000)).toISOString(); + ts = ts.replace("T", " ").replace("Z",""); + + var delta; + if(this.props.deltaTo){ + delta = formatTimeDelta(1000 * (this.props.t-this.props.deltaTo)); + delta = React.DOM.span({className: "text-muted"}, "(" + delta + ")"); + } else { + delta = null; + } + + return React.DOM.tr(null, React.DOM.td(null, this.props.title + ":"), React.DOM.td(null, ts, " ", delta)); + } +}); + +var ConnectionInfo = React.createClass({displayName: 'ConnectionInfo', + + render: function() { + var conn = this.props.conn; + var address = conn.address.address.join(":"); + + var sni = React.DOM.tr({key: "sni"}); //should be null, but that triggers a React bug. + if(conn.sni){ + sni = React.DOM.tr({key: "sni"}, React.DOM.td(null, React.DOM.abbr({title: "TLS Server Name Indication"}, "TLS SNI:")), React.DOM.td(null, conn.sni)); + } + return ( + React.DOM.table({className: "connection-table"}, + React.DOM.tbody(null, + React.DOM.tr({key: "address"}, React.DOM.td(null, "Address:"), React.DOM.td(null, address)), + sni + ) + ) + ); + } +}); + +var CertificateInfo = React.createClass({displayName: 'CertificateInfo', + 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 ( + React.DOM.div(null, + client_conn.cert ? React.DOM.h4(null, "Client Certificate") : null, + client_conn.cert ? React.DOM.pre({style: preStyle}, client_conn.cert) : null, + + server_conn.cert ? React.DOM.h4(null, "Server Certificate") : null, + server_conn.cert ? React.DOM.pre({style: preStyle}, server_conn.cert) : null + ) + ); + } +}); + +var Timing = React.createClass({displayName: 'Timing', + 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 TimeStamp(e); + }); + + return ( + React.DOM.div(null, + React.DOM.h4(null, "Timing"), + React.DOM.table({className: "timing-table"}, + React.DOM.tbody(null, + rows + ) + ) + ) + ); + } +}); + +var FlowDetailConnectionInfo = React.createClass({displayName: 'FlowDetailConnectionInfo', + render: function(){ + var flow = this.props.flow; + var client_conn = flow.client_conn; + var server_conn = flow.server_conn; + return ( + React.DOM.section(null, + + React.DOM.h4(null, "Client Connection"), + ConnectionInfo({conn: client_conn}), + + React.DOM.h4(null, "Server Connection"), + ConnectionInfo({conn: server_conn}), + + CertificateInfo({flow: flow}), + + Timing({flow: flow}) + + ) + ); + } +}); + +var tabs = { + request: FlowDetailRequest, + response: FlowDetailResponse, + details: FlowDetailConnectionInfo +}; + +var FlowDetail = React.createClass({displayName: 'FlowDetail', + getDefaultProps: function(){ + return { + tabs: ["request","response", "details"] + }; + }, + mixins: [StickyHeadMixin], + nextTab: function(i) { + var currentIndex = this.props.tabs.indexOf(this.props.active); + // JS modulo operator doesn't correct negative numbers, make sure that we are positive. + var nextIndex = (currentIndex + i + this.props.tabs.length) % this.props.tabs.length; + this.props.selectTab(this.props.tabs[nextIndex]); + }, + render: function(){ + var flow = JSON.stringify(this.props.flow, null, 2); + var Tab = tabs[this.props.active]; + return ( + React.DOM.div({className: "flow-detail", onScroll: this.adjustHead}, + FlowDetailNav({ref: "head", + tabs: this.props.tabs, + active: this.props.active, + selectTab: this.props.selectTab}), + Tab({flow: this.props.flow}) + ) + ); + } +}); +/** @jsx React.DOM */ + +var MainView = React.createClass({displayName: 'MainView', + getInitialState: function() { + return { + flows: [], + }; + }, + componentDidMount: function () { + this.flowStore = FlowStore.getView(); + this.flowStore.addListener("change",this.onFlowChange); + }, + componentWillUnmount: function () { + this.flowStore.removeListener("change",this.onFlowChange); + this.flowStore.close(); + }, + onFlowChange: function () { + this.setState({ + flows: this.flowStore.getAll() + }); + }, + selectDetailTab: function(panel) { + ReactRouter.replaceWith( + "flow", + { + flowId: this.props.params.flowId, + detailTab: panel + } + ); + }, + selectFlow: function(flow) { + if(flow){ + ReactRouter.replaceWith( + "flow", + { + flowId: flow.id, + detailTab: this.props.params.detailTab || "request" + } + ); + this.refs.flowTable.scrollIntoView(flow); + } else { + ReactRouter.replaceWith("flows"); + } + }, + selectFlowRelative: function(i){ + var index; + if(!this.props.params.flowId){ + if(i > 0){ + index = this.state.flows.length-1; + } else { + index = 0; + } + } else { + index = _.findIndex(this.state.flows, function(f){ + return f.id === this.props.params.flowId; + }.bind(this)); + index = Math.min(Math.max(0, index+i), this.state.flows.length-1); + } + this.selectFlow(this.state.flows[index]); + }, + onKeyDown: function(e){ + 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.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; + default: + console.debug("keydown", e.keyCode); + return; + } + return false; + }, + render: function() { + var selected = _.find(this.state.flows, { id: this.props.params.flowId }); + + var details; + if(selected){ + details = ( + FlowDetail({ref: "flowDetails", + flow: selected, + selectTab: this.selectDetailTab, + active: this.props.params.detailTab}) + ); + } else { + details = null; + } + + return ( + React.DOM.div({className: "main-view", onKeyDown: this.onKeyDown, tabIndex: "0"}, + FlowTable({ref: "flowTable", + flows: this.state.flows, + selectFlow: this.selectFlow, + selected: selected}), + details ? Splitter(null) : null, + details + ) + ); + } +}); +/** @jsx React.DOM */ + +var LogMessage = React.createClass({displayName: 'LogMessage', + render: function(){ + var entry = this.props.entry; + var indicator; + switch(entry.level){ + case "web": + indicator = React.DOM.i({className: "fa fa-fw fa-html5"}); + break; + case "debug": + indicator = React.DOM.i({className: "fa fa-fw fa-bug"}); + break; + default: + indicator = React.DOM.i({className: "fa fa-fw fa-info"}); + } + return ( + React.DOM.div(null, + indicator, " ", entry.message + ) + ); + }, + shouldComponentUpdate: function(){ + return false; // log entries are immutable. + } +}); + +var EventLogContents = React.createClass({displayName: 'EventLogContents', + mixins:[AutoScrollMixin], + getInitialState: function () { + return { + log: [] + }; + }, + componentDidMount: function () { + this.log = EventLogStore.getView(); + this.log.addListener("change", this.onEventLogChange); + }, + componentWillUnmount: function () { + this.log.removeListener("change", this.onEventLogChange); + this.log.close(); + }, + onEventLogChange: function () { + this.setState({ + log: this.log.getAll() + }); + }, + render: function () { + var messages = this.state.log.map(function(row) { + if(!this.props.filter[row.level]){ + return null; + } + return LogMessage({key: row.id, entry: row}); + }.bind(this)); + return React.DOM.pre(null, messages); + } +}); + +var ToggleFilter = React.createClass({displayName: 'ToggleFilter', + toggle: function(){ + return this.props.toggleLevel(this.props.name); + }, + render: function(){ + var className = "label "; + if (this.props.active) { + className += "label-primary"; + } else { + className += "label-default"; + } + return ( + React.DOM.a({ + href: "#", + className: className, + onClick: this.toggle}, + this.props.name + ) + ); + } +}); + +var EventLog = React.createClass({displayName: 'EventLog', + getInitialState: function(){ + return { + filter: { + "debug": false, + "info": true, + "web": true + } + }; + }, + close: function () { + SettingsActions.update({ + showEventLog: false + }); + }, + toggleLevel: function(level){ + var filter = this.state.filter; + filter[level] = !filter[level]; + this.setState({filter: filter}); + return false; + }, + render: function () { + return ( + React.DOM.div({className: "eventlog"}, + React.DOM.div(null, + "Eventlog", + React.DOM.div({className: "pull-right"}, + ToggleFilter({name: "debug", active: this.state.filter.debug, toggleLevel: this.toggleLevel}), + ToggleFilter({name: "info", active: this.state.filter.info, toggleLevel: this.toggleLevel}), + ToggleFilter({name: "web", active: this.state.filter.web, toggleLevel: this.toggleLevel}), + React.DOM.i({onClick: this.close, className: "fa fa-close"}) + ) + + ), + EventLogContents({filter: this.state.filter}) + ) + ); + } +}); +/** @jsx React.DOM */ + +var Footer = React.createClass({displayName: 'Footer', + render: function () { + var mode = this.props.settings.mode; + return ( + React.DOM.footer(null, + mode != "regular" ? React.DOM.span({className: "label label-success"}, mode, " mode") : null + ) + ); + } +}); + +/** @jsx React.DOM */ + +//TODO: Move out of here, just a stub. +var Reports = React.createClass({displayName: 'Reports', + render: function () { + return React.DOM.div(null, "ReportEditor"); + } +}); + + +var ProxyAppMain = React.createClass({displayName: 'ProxyAppMain', + getInitialState: function () { + return { settings: SettingsStore.getAll() }; + }, + componentDidMount: function () { + SettingsStore.addListener("change", this.onSettingsChange); + }, + componentWillUnmount: function () { + SettingsStore.removeListener("change", this.onSettingsChange); + }, + onSettingsChange: function () { + this.setState({settings: SettingsStore.getAll()}); + }, + render: function () { + return ( + React.DOM.div({id: "container"}, + Header({settings: this.state.settings}), + this.props.activeRouteHandler({settings: this.state.settings}), + this.state.settings.showEventLog ? Splitter({axis: "y"}) : null, + this.state.settings.showEventLog ? EventLog(null) : null, + Footer({settings: this.state.settings}) + ) + ); + } +}); + + +var Routes = ReactRouter.Routes; +var Route = ReactRouter.Route; +var Redirect = ReactRouter.Redirect; +var DefaultRoute = ReactRouter.DefaultRoute; +var NotFoundRoute = ReactRouter.NotFoundRoute; + + +var ProxyApp = ( + Routes({location: "hash"}, + Route({path: "/", handler: ProxyAppMain}, + Route({name: "flows", path: "flows", handler: MainView}), + Route({name: "flow", path: "flows/:flowId/:detailTab", handler: MainView}), + Route({name: "reports", handler: Reports}), + Redirect({path: "/", to: "flows"}) + ) + ) + ); +$(function () { + Connection.init(); + app = React.renderComponent(ProxyApp, document.body); +}); +//# sourceMappingURL=app.js.map
\ No newline at end of file |