diff options
Diffstat (limited to 'web/src/js')
-rw-r--r-- | web/src/js/actions.js | 38 | ||||
-rw-r--r-- | web/src/js/app.js | 4 | ||||
-rw-r--r-- | web/src/js/components/eventlog.jsx.js | 119 | ||||
-rw-r--r-- | web/src/js/components/flowdetail.jsx.js | 299 | ||||
-rw-r--r-- | web/src/js/components/flowtable-columns.jsx.js | 153 | ||||
-rw-r--r-- | web/src/js/components/flowtable.jsx.js | 94 | ||||
-rw-r--r-- | web/src/js/components/footer.jsx.js | 12 | ||||
-rw-r--r-- | web/src/js/components/header.jsx.js | 95 | ||||
-rw-r--r-- | web/src/js/components/mainview.jsx.js | 126 | ||||
-rw-r--r-- | web/src/js/components/proxyapp.jsx.js | 54 | ||||
-rw-r--r-- | web/src/js/components/utils.jsx.js | 100 | ||||
-rw-r--r-- | web/src/js/connection.js | 33 | ||||
-rw-r--r-- | web/src/js/dispatcher.js | 35 | ||||
-rw-r--r-- | web/src/js/flow/utils.js | 47 | ||||
-rw-r--r-- | web/src/js/stores/base.js | 25 | ||||
-rw-r--r-- | web/src/js/stores/eventlogstore.js | 99 | ||||
-rw-r--r-- | web/src/js/stores/flowstore.js | 91 | ||||
-rw-r--r-- | web/src/js/stores/settingstore.js | 28 | ||||
-rw-r--r-- | web/src/js/tests.js | 3 | ||||
-rw-r--r-- | web/src/js/utils.js | 62 |
20 files changed, 1517 insertions, 0 deletions
diff --git a/web/src/js/actions.js b/web/src/js/actions.js new file mode 100644 index 00000000..9211403f --- /dev/null +++ b/web/src/js/actions.js @@ -0,0 +1,38 @@ +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++ + } + }); + } +};
\ No newline at end of file diff --git a/web/src/js/app.js b/web/src/js/app.js new file mode 100644 index 00000000..736072dc --- /dev/null +++ b/web/src/js/app.js @@ -0,0 +1,4 @@ +$(function () { + Connection.init(); + app = React.renderComponent(ProxyApp, document.body); +});
\ No newline at end of file diff --git a/web/src/js/components/eventlog.jsx.js b/web/src/js/components/eventlog.jsx.js new file mode 100644 index 00000000..08a6dfb4 --- /dev/null +++ b/web/src/js/components/eventlog.jsx.js @@ -0,0 +1,119 @@ +/** @jsx React.DOM */ + +var LogMessage = React.createClass({ + render: function(){ + var entry = this.props.entry; + var indicator; + switch(entry.level){ + case "web": + indicator = <i className="fa fa-fw fa-html5"></i>; + break; + case "debug": + indicator = <i className="fa fa-fw fa-bug"></i>; + break; + default: + indicator = <i className="fa fa-fw fa-info"></i>; + } + return ( + <div> + { indicator } {entry.message} + </div> + ); + }, + shouldComponentUpdate: function(){ + return false; // log entries are immutable. + } +}); + +var EventLogContents = React.createClass({ + 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 <pre>{messages}</pre>; + } +}); + +var ToggleFilter = React.createClass({ + 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 ( + <a + href="#" + className={className} + onClick={this.toggle}> + {this.props.name} + </a> + ); + } +}); + +var EventLog = React.createClass({ + 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 ( + <div className="eventlog"> + <div> + Eventlog + <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}/> + <i onClick={this.close} className="fa fa-close"></i> + </div> + + </div> + <EventLogContents filter={this.state.filter}/> + </div> + ); + } +});
\ No newline at end of file diff --git a/web/src/js/components/flowdetail.jsx.js b/web/src/js/components/flowdetail.jsx.js new file mode 100644 index 00000000..3ba025a9 --- /dev/null +++ b/web/src/js/components/flowdetail.jsx.js @@ -0,0 +1,299 @@ +/** @jsx React.DOM */ + +var FlowDetailNav = React.createClass({ + 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 <a key={e} + href="#" + className={className} + onClick={onClick}>{str}</a>; + }.bind(this)); + return ( + <nav ref="head" className="nav-tabs nav-tabs-sm"> + {items} + </nav> + ); + } +}); + +var Headers = React.createClass({ + render: function(){ + var rows = this.props.message.headers.map(function(header, i){ + return ( + <tr key={i}> + <td className="header-name">{header[0]+":"}</td> + <td className="header-value">{header[1]}</td> + </tr> + ); + }); + return ( + <table className="header-table"> + <tbody> + {rows} + </tbody> + </table> + ); + } +}); + +var FlowDetailRequest = React.createClass({ + 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 = <div className="alert alert-info">No Content</div>; + } + + //TODO: Styling + + return ( + <section> + <div className="first-line">{ first_line }</div> + <Headers message={flow.request}/> + <hr/> + {content} + </section> + ); + } +}); + +var FlowDetailResponse = React.createClass({ + 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 = <div className="alert alert-info">No Content</div>; + } + + //TODO: Styling + + return ( + <section> + <div className="first-line">{ first_line }</div> + <Headers message={flow.response}/> + <hr/> + {content} + </section> + ); + } +}); + +var TimeStamp = React.createClass({ + render: function() { + + if(!this.props.t){ + //should be return null, but that triggers a React bug. + return <tr></tr>; + } + + 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 = <span className="text-muted">{"(" + delta + ")"}</span>; + } else { + delta = null; + } + + return <tr><td>{this.props.title + ":"}</td><td>{ts} {delta}</td></tr>; + } +}); + +var ConnectionInfo = React.createClass({ + + render: function() { + var conn = this.props.conn; + var address = conn.address.address.join(":"); + + var sni = <tr key="sni"></tr>; //should be null, but that triggers a React bug. + if(conn.sni){ + sni = <tr key="sni"><td><abbr title="TLS Server Name Indication">TLS SNI:</abbr></td><td>{conn.sni}</td></tr>; + } + return ( + <table className="connection-table"> + <tbody> + <tr key="address"><td>Address:</td><td>{address}</td></tr> + {sni} + </tbody> + </table> + ); + } +}); + +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 ( + <div> + {client_conn.cert ? <h4>Client Certificate</h4> : null} + {client_conn.cert ? <pre style={preStyle}>{client_conn.cert}</pre> : null} + + {server_conn.cert ? <h4>Server Certificate</h4> : null} + {server_conn.cert ? <pre style={preStyle}>{server_conn.cert}</pre> : null} + </div> + ); + } +}); + +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 TimeStamp(e); + }); + + return ( + <div> + <h4>Timing</h4> + <table className="timing-table"> + <tbody> + {rows} + </tbody> + </table> + </div> + ); + } +}); + +var FlowDetailConnectionInfo = React.createClass({ + render: function(){ + var flow = this.props.flow; + var client_conn = flow.client_conn; + var server_conn = flow.server_conn; + return ( + <section> + + <h4>Client Connection</h4> + <ConnectionInfo conn={client_conn}/> + + <h4>Server Connection</h4> + <ConnectionInfo conn={server_conn}/> + + <CertificateInfo flow={flow}/> + + <Timing flow={flow}/> + + </section> + ); + } +}); + +var tabs = { + request: FlowDetailRequest, + response: FlowDetailResponse, + details: FlowDetailConnectionInfo +}; + +var FlowDetail = React.createClass({ + 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 ( + <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}/> + </div> + ); + } +});
\ No newline at end of file diff --git a/web/src/js/components/flowtable-columns.jsx.js b/web/src/js/components/flowtable-columns.jsx.js new file mode 100644 index 00000000..b7db71b7 --- /dev/null +++ b/web/src/js/components/flowtable-columns.jsx.js @@ -0,0 +1,153 @@ +/** @jsx React.DOM */ + + +var TLSColumn = React.createClass({ + statics: { + renderTitle: function(){ + return <th key="tls" className="col-tls"></th>; + } + }, + 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 <td className={classes}></td>; + } +}); + + +var IconColumn = React.createClass({ + statics: { + renderTitle: function(){ + return <th key="icon" className="col-icon"></th>; + } + }, + 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 <td className="col-icon"><div className={icon}></div></td>; + } +}); + +var PathColumn = React.createClass({ + statics: { + renderTitle: function(){ + return <th key="path" className="col-path">Path</th>; + } + }, + render: function(){ + var flow = this.props.flow; + return <td className="col-path">{flow.request.scheme + "://" + flow.request.host + flow.request.path}</td>; + } +}); + + +var MethodColumn = React.createClass({ + statics: { + renderTitle: function(){ + return <th key="method" className="col-method">Method</th>; + } + }, + render: function(){ + var flow = this.props.flow; + return <td className="col-method">{flow.request.method}</td>; + } +}); + + +var StatusColumn = React.createClass({ + statics: { + renderTitle: function(){ + return <th key="status" className="col-status">Status</th>; + } + }, + render: function(){ + var flow = this.props.flow; + var status; + if(flow.response){ + status = flow.response.code; + } else { + status = null; + } + return <td className="col-status">{status}</td>; + } +}); + + +var SizeColumn = React.createClass({ + statics: { + renderTitle: function(){ + return <th key="size" className="col-size">Size</th>; + } + }, + 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 <td className="col-size">{size}</td>; + } +}); + + +var TimeColumn = React.createClass({ + statics: { + renderTitle: function(){ + return <th key="time" className="col-time">Time</th>; + } + }, + 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 <td className="col-time">{time}</td>; + } +}); + + +var all_columns = [ + TLSColumn, + IconColumn, + PathColumn, + MethodColumn, + StatusColumn, + SizeColumn, + TimeColumn]; + diff --git a/web/src/js/components/flowtable.jsx.js b/web/src/js/components/flowtable.jsx.js new file mode 100644 index 00000000..fc4d8fbc --- /dev/null +++ b/web/src/js/components/flowtable.jsx.js @@ -0,0 +1,94 @@ +/** @jsx React.DOM */ + +var FlowRow = React.createClass({ + 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 ( + <tr className={className} onClick={this.props.selectFlow.bind(null, flow)}> + {columns} + </tr>); + }, + 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({ + render: function(){ + var columns = this.props.columns.map(function(column){ + return column.renderTitle(); + }.bind(this)); + return <thead><tr>{columns}</tr></thead>; + } +}); + +var FlowTableBody = React.createClass({ + 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 <tbody>{rows}</tbody>; + } +}); + + +var FlowTable = React.createClass({ + 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 ( + <div className="flow-table" onScroll={this.adjustHead}> + <table> + <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}/> + </table> + </div> + ); + } +}); diff --git a/web/src/js/components/footer.jsx.js b/web/src/js/components/footer.jsx.js new file mode 100644 index 00000000..9bcbbc2a --- /dev/null +++ b/web/src/js/components/footer.jsx.js @@ -0,0 +1,12 @@ +/** @jsx React.DOM */ + +var Footer = React.createClass({ + render: function () { + var mode = this.props.settings.mode; + return ( + <footer> + {mode != "regular" ? <span className="label label-success">{mode} mode</span> : null} + </footer> + ); + } +}); diff --git a/web/src/js/components/header.jsx.js b/web/src/js/components/header.jsx.js new file mode 100644 index 00000000..994bc759 --- /dev/null +++ b/web/src/js/components/header.jsx.js @@ -0,0 +1,95 @@ +/** @jsx React.DOM */ + +var MainMenu = React.createClass({ + statics: { + title: "Traffic", + route: "flows" + }, + toggleEventLog: function () { + SettingsActions.update({ + showEventLog: !this.props.settings.showEventLog + }); + }, + render: function () { + return ( + <div> + <button className={"btn " + (this.props.settings.showEventLog ? "btn-primary" : "btn-default")} onClick={this.toggleEventLog}> + <i className="fa fa-database"></i> Display Event Log + </button> + </div> + ); + } +}); + + +var ToolsMenu = React.createClass({ + statics: { + title: "Tools", + route: "flows" + }, + render: function () { + return <div>Tools Menu</div>; + } +}); + + +var ReportsMenu = React.createClass({ + statics: { + title: "Visualization", + route: "reports" + }, + render: function () { + return <div>Reports Menu</div>; + } +}); + + +var header_entries = [MainMenu, ToolsMenu, ReportsMenu]; + + +var Header = React.createClass({ + 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 ( + <a key={i} + href="#" + className={classes} + onClick={this.handleClick.bind(this, entry)} + > + { entry.title} + </a> + ); + }.bind(this)); + + return ( + <header> + <div className="title-bar"> + mitmproxy { this.props.settings.version } + </div> + <nav className="nav-tabs nav-tabs-lg"> + <a href="#" className="special" onClick={this.handleFileClick}> File </a> + {header} + </nav> + <div className="menu"> + <this.state.active settings={this.props.settings}/> + </div> + </header> + ); + } +}); diff --git a/web/src/js/components/mainview.jsx.js b/web/src/js/components/mainview.jsx.js new file mode 100644 index 00000000..795b8136 --- /dev/null +++ b/web/src/js/components/mainview.jsx.js @@ -0,0 +1,126 @@ +/** @jsx React.DOM */ + +var MainView = React.createClass({ + 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 ( + <div className="main-view" onKeyDown={this.onKeyDown} tabIndex="0"> + <FlowTable ref="flowTable" + flows={this.state.flows} + selectFlow={this.selectFlow} + selected={selected} /> + { details ? <Splitter/> : null } + {details} + </div> + ); + } +});
\ No newline at end of file diff --git a/web/src/js/components/proxyapp.jsx.js b/web/src/js/components/proxyapp.jsx.js new file mode 100644 index 00000000..ff6e8da1 --- /dev/null +++ b/web/src/js/components/proxyapp.jsx.js @@ -0,0 +1,54 @@ +/** @jsx React.DOM */ + +//TODO: Move out of here, just a stub. +var Reports = React.createClass({ + render: function () { + return <div>ReportEditor</div>; + } +}); + + +var ProxyAppMain = React.createClass({ + 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 ( + <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} + <Footer settings={this.state.settings}/> + </div> + ); + } +}); + + +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" /> + </Route> + </Routes> + );
\ No newline at end of file diff --git a/web/src/js/components/utils.jsx.js b/web/src/js/components/utils.jsx.js new file mode 100644 index 00000000..91cb8458 --- /dev/null +++ b/web/src/js/components/utils.jsx.js @@ -0,0 +1,100 @@ +/** @jsx React.DOM */ + +//React utils. For other utilities, see ../utils.js + +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 + }); + }, + 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 ( + <div className={className}> + <div onMouseDown={this.onMouseDown} draggable="true"></div> + </div> + ); + } +});
\ No newline at end of file diff --git a/web/src/js/connection.js b/web/src/js/connection.js new file mode 100644 index 00000000..3edbfc20 --- /dev/null +++ b/web/src/js/connection.js @@ -0,0 +1,33 @@ +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"); diff --git a/web/src/js/dispatcher.js b/web/src/js/dispatcher.js new file mode 100644 index 00000000..4fe23447 --- /dev/null +++ b/web/src/js/dispatcher.js @@ -0,0 +1,35 @@ +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); +}; diff --git a/web/src/js/flow/utils.js b/web/src/js/flow/utils.js new file mode 100644 index 00000000..e54f7c59 --- /dev/null +++ b/web/src/js/flow/utils.js @@ -0,0 +1,47 @@ +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, {});
\ No newline at end of file diff --git a/web/src/js/stores/base.js b/web/src/js/stores/base.js new file mode 100644 index 00000000..952fa847 --- /dev/null +++ b/web/src/js/stores/base.js @@ -0,0 +1,25 @@ +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); + } +}; diff --git a/web/src/js/stores/eventlogstore.js b/web/src/js/stores/eventlogstore.js new file mode 100644 index 00000000..e356959a --- /dev/null +++ b/web/src/js/stores/eventlogstore.js @@ -0,0 +1,99 @@ +// +// 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));
\ No newline at end of file diff --git a/web/src/js/stores/flowstore.js b/web/src/js/stores/flowstore.js new file mode 100644 index 00000000..7c0bddbd --- /dev/null +++ b/web/src/js/stores/flowstore.js @@ -0,0 +1,91 @@ +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)); diff --git a/web/src/js/stores/settingstore.js b/web/src/js/stores/settingstore.js new file mode 100644 index 00000000..7eef9b8f --- /dev/null +++ b/web/src/js/stores/settingstore.js @@ -0,0 +1,28 @@ +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)); diff --git a/web/src/js/tests.js b/web/src/js/tests.js new file mode 100644 index 00000000..ede323dc --- /dev/null +++ b/web/src/js/tests.js @@ -0,0 +1,3 @@ +QUnit.test("example test", function (assert) { + assert.ok(true); +});
\ No newline at end of file diff --git a/web/src/js/utils.js b/web/src/js/utils.js new file mode 100644 index 00000000..fa15db8c --- /dev/null +++ b/web/src/js/utils.js @@ -0,0 +1,62 @@ +// 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]; +};
\ No newline at end of file |