aboutsummaryrefslogtreecommitdiffstats
path: root/web/src/js
diff options
context:
space:
mode:
Diffstat (limited to 'web/src/js')
-rw-r--r--web/src/js/actions.js38
-rw-r--r--web/src/js/app.js4
-rw-r--r--web/src/js/components/eventlog.jsx.js119
-rw-r--r--web/src/js/components/flowdetail.jsx.js299
-rw-r--r--web/src/js/components/flowtable-columns.jsx.js153
-rw-r--r--web/src/js/components/flowtable.jsx.js94
-rw-r--r--web/src/js/components/footer.jsx.js12
-rw-r--r--web/src/js/components/header.jsx.js95
-rw-r--r--web/src/js/components/mainview.jsx.js126
-rw-r--r--web/src/js/components/proxyapp.jsx.js54
-rw-r--r--web/src/js/components/utils.jsx.js100
-rw-r--r--web/src/js/connection.js33
-rw-r--r--web/src/js/dispatcher.js35
-rw-r--r--web/src/js/flow/utils.js47
-rw-r--r--web/src/js/stores/base.js25
-rw-r--r--web/src/js/stores/eventlogstore.js99
-rw-r--r--web/src/js/stores/flowstore.js91
-rw-r--r--web/src/js/stores/settingstore.js28
-rw-r--r--web/src/js/tests.js3
-rw-r--r--web/src/js/utils.js62
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