diff options
author | Jason <jason.daurus@gmail.com> | 2016-06-17 21:58:24 +0800 |
---|---|---|
committer | Jason <jason.daurus@gmail.com> | 2016-06-17 21:58:24 +0800 |
commit | c82d27b2a215c075dba71254cafdc86e98e0f2d9 (patch) | |
tree | 84324135951cf67617e4887923bee0627c1d1085 /web | |
parent | 6ad2f13341208b8460eae0dd0105c3109e773bae (diff) | |
parent | 9c6199db9be34fad18eaedb86463333671ae190a (diff) | |
download | mitmproxy-c82d27b2a215c075dba71254cafdc86e98e0f2d9.tar.gz mitmproxy-c82d27b2a215c075dba71254cafdc86e98e0f2d9.tar.bz2 mitmproxy-c82d27b2a215c075dba71254cafdc86e98e0f2d9.zip |
Merge branch 'master' into settings
Conflicts:
mitmproxy/web/static/app.css
mitmproxy/web/static/app.js
web/src/js/components/ProxyApp.jsx
Diffstat (limited to 'web')
37 files changed, 1472 insertions, 1514 deletions
diff --git a/web/.editorconfig b/web/.editorconfig index 9acd1b0f..ddc78021 100644 --- a/web/.editorconfig +++ b/web/.editorconfig @@ -3,3 +3,4 @@ indent_style = space indent_size = 4 trim_trailing_whitespace = true insert_final_newline = true +end_of_line = lf diff --git a/web/src/css/eventlog.less b/web/src/css/eventlog.less index 908312cd..393f75db 100644 --- a/web/src/css/eventlog.less +++ b/web/src/css/eventlog.less @@ -10,6 +10,8 @@ background-color: #F2F2F2; padding: 0 5px; flex: 0 0 auto; + border-top: 1px solid #aaa; + cursor: row-resize; } > pre { @@ -48,4 +50,4 @@ margin-top: -2px; margin-left: 3px; } -}
\ No newline at end of file +} diff --git a/web/src/js/components/ContentView.jsx b/web/src/js/components/ContentView.jsx new file mode 100644 index 00000000..af3bffc1 --- /dev/null +++ b/web/src/js/components/ContentView.jsx @@ -0,0 +1,78 @@ +import React, { Component, PropTypes } from 'react' +import { MessageUtils } from '../flow/utils.js' +import { ViewAuto, ViewImage } from './ContentView/ContentViews' +import * as ContentErrors from './ContentView/ContentErrors' +import ContentLoader from './ContentView/ContentLoader' +import ViewSelector from './ContentView/ViewSelector' + +export default class ContentView extends Component { + + static propTypes = { + // It may seem a bit weird at the first glance: + // Every view takes the flow and the message as props, e.g. + // <Auto flow={flow} message={flow.request}/> + flow: React.PropTypes.object.isRequired, + message: React.PropTypes.object.isRequired, + } + + constructor(props, context) { + super(props, context) + + this.state = { displayLarge: false, View: ViewAuto } + this.selectView = this.selectView.bind(this) + } + + selectView(View) { + this.setState({ View }) + } + + displayLarge() { + this.setState({ displayLarge: true }) + } + + componentWillReceiveProps(nextProps) { + if (nextProps.message !== this.props.message) { + this.setState({ displayLarge: false, View: ViewAuto }) + } + } + + isContentTooLarge(msg) { + return msg.contentLength > 1024 * 1024 * (ViewImage.matches(msg) ? 10 : 0.2) + } + + render() { + const { flow, message } = this.props + const { displayLarge, View } = this.state + + if (message.contentLength === 0) { + return <ContentErrors.ContentEmpty {...this.props}/> + } + + if (message.contentLength === null) { + return <ContentErrors.ContentMissing {...this.props}/> + } + + if (!displayLarge && this.isContentTooLarge(message)) { + return <ContentErrors.ContentTooLarge {...this.props} onClick={this.displayLarge}/> + } + + return ( + <div> + {View.textView ? ( + <ContentLoader flow={flow} message={message}> + <this.state.View content="" /> + </ContentLoader> + ) : ( + <View flow={flow} message={message} /> + )} + <div className="view-options text-center"> + <ViewSelector onSelectView={this.selectView} active={View} message={message}/> + + <a className="btn btn-default btn-xs" href={MessageUtils.getContentURL(flow, message)}> + <i className="fa fa-download"/> + </a> + </div> + </div> + ) + } +} diff --git a/web/src/js/components/ContentView/ContentErrors.jsx b/web/src/js/components/ContentView/ContentErrors.jsx new file mode 100644 index 00000000..11594c7f --- /dev/null +++ b/web/src/js/components/ContentView/ContentErrors.jsx @@ -0,0 +1,28 @@ +import React from 'react' +import { ViewImage } from './ContentViews' +import {formatSize} from '../../utils.js' + +export function ContentEmpty({ flow, message }) { + return ( + <div className="alert alert-info"> + No {flow.request === message ? 'request' : 'response'} content. + </div> + ) +} + +export function ContentMissing({ flow, message }) { + return ( + <div className="alert alert-info"> + {flow.request === message ? 'Request' : 'Response'} content missing. + </div> + ) +} + +export function ContentTooLarge({ message, onClick }) { + return ( + <div className="alert alert-warning"> + <button onClick={onClick} className="btn btn-xs btn-warning pull-right">Display anyway</button> + {formatSize(message.contentLength)} content size. + </div> + ) +} diff --git a/web/src/js/components/ContentView/ContentLoader.jsx b/web/src/js/components/ContentView/ContentLoader.jsx new file mode 100644 index 00000000..f346dc01 --- /dev/null +++ b/web/src/js/components/ContentView/ContentLoader.jsx @@ -0,0 +1,67 @@ +import React, { Component, PropTypes } from 'react' +import { MessageUtils } from '../../flow/utils.js' + +export default class ContentLoader extends Component { + + static propTypes = { + flow: PropTypes.object.isRequired, + message: PropTypes.object.isRequired, + } + + constructor(props, context) { + super(props, context) + this.state = { content: null, request: null } + } + + requestContent(nextProps) { + if (this.state.request) { + this.state.request.abort() + } + + const request = MessageUtils.getContent(nextProps.flow, nextProps.message) + + this.setState({ content: null, request }) + + request + .done(content => { + this.setState({ content }) + }) + .fail((xhr, textStatus, errorThrown) => { + if (textStatus === 'abort') { + return + } + this.setState({ content: `AJAX Error: ${textStatus}\r\n${errorThrown}` }) + }) + .always(() => { + this.setState({ request: null }) + }) + } + + componentWillMount() { + this.requestContent(this.props) + } + + componentWillReceiveProps(nextProps) { + if (nextProps.message !== this.props.message) { + this.requestContent(nextProps) + } + } + + componentWillUnmount() { + if (this.state.request) { + this.state.request.abort() + } + } + + render() { + return this.state.content ? ( + React.cloneElement(this.props.children, { + content: this.state.content + }) + ) : ( + <div className="text-center"> + <i className="fa fa-spinner fa-spin"></i> + </div> + ) + } +} diff --git a/web/src/js/components/ContentView/ContentViews.jsx b/web/src/js/components/ContentView/ContentViews.jsx new file mode 100644 index 00000000..b0297dcc --- /dev/null +++ b/web/src/js/components/ContentView/ContentViews.jsx @@ -0,0 +1,70 @@ +import React, { PropTypes } from 'react' +import ContentLoader from './ContentLoader' +import { MessageUtils } from '../../flow/utils.js' + +const views = [ViewAuto, ViewImage, ViewJSON, ViewRaw] + +ViewImage.regex = /^image\/(png|jpe?g|gif|vnc.microsoft.icon|x-icon)$/i +ViewImage.matches = msg => ViewImage.regex.test(MessageUtils.getContentType(msg)) + +ViewImage.propTypes = { + flow: PropTypes.object.isRequired, + message: PropTypes.object.isRequired, +} + +export function ViewImage({ flow, message }) { + return ( + <div className="flowview-image"> + <img src={MessageUtils.getContentURL(flow, message)} alt="preview" className="img-thumbnail"/> + </div> + ) +} + +ViewRaw.textView = true +ViewRaw.matches = () => true + +ViewRaw.propTypes = { + content: React.PropTypes.string.isRequired, +} + +export function ViewRaw({ content }) { + return <pre>{content}</pre> +} + +ViewJSON.textView = true +ViewJSON.regex = /^application\/json$/i +ViewJSON.matches = msg => ViewJSON.regex.test(MessageUtils.getContentType(msg)) + +ViewJSON.propTypes = { + content: React.PropTypes.string.isRequired, +} + +export function ViewJSON({ content }) { + let json = content + try { + json = JSON.stringify(JSON.parse(content), null, 2); + } catch (e) { + // @noop + } + return <pre>{json}</pre> +} + + +ViewAuto.matches = () => false +ViewAuto.findView = msg => views.find(v => v.matches(msg)) || views[views.length - 1] + +ViewAuto.propTypes = { + message: React.PropTypes.object.isRequired, + flow: React.PropTypes.object.isRequired, +} + +export function ViewAuto({ message, flow }) { + const View = ViewAuto.findView(message) + if (View.textView) { + return <ContentLoader message={message} flow={flow}><View content="" /></ContentLoader> + } else { + return <View message={message} flow={flow} /> + } +} + +export default views diff --git a/web/src/js/components/ContentView/ViewSelector.jsx b/web/src/js/components/ContentView/ViewSelector.jsx new file mode 100644 index 00000000..df3a5b83 --- /dev/null +++ b/web/src/js/components/ContentView/ViewSelector.jsx @@ -0,0 +1,28 @@ +import React, { PropTypes } from 'react' +import classnames from 'classnames' +import views, { ViewAuto } from './ContentViews' + +ViewSelector.propTypes = { + active: PropTypes.func.isRequired, + message: PropTypes.object.isRequired, + onSelectView: PropTypes.func.isRequired, +} + +export default function ViewSelector({ active, message, onSelectView }) { + return ( + <div className="view-selector btn-group btn-group-xs"> + {views.map(View => ( + <button + key={View.name} + onClick={() => onSelectView(View)} + className={classnames('btn btn-default', { active: View === active })}> + {View === ViewAuto ? ( + `auto: ${ViewAuto.findView(message).name.toLowerCase().replace('view', '')}` + ) : ( + View.name.toLowerCase().replace('view', '') + )} + </button> + ))} + </div> + ) +} diff --git a/web/src/js/components/EventLog.jsx b/web/src/js/components/EventLog.jsx index 58cecd1a..169162ee 100644 --- a/web/src/js/components/EventLog.jsx +++ b/web/src/js/components/EventLog.jsx @@ -1,31 +1,70 @@ -import React, { PropTypes } from 'react' +import React, { Component, PropTypes } from 'react' import { connect } from 'react-redux' import { toggleEventLogFilter, toggleEventLogVisibility } from '../ducks/eventLog' -import { ToggleButton } from './common' +import ToggleButton from './common/ToggleButton' import EventList from './EventLog/EventList' -EventLog.propTypes = { - filters: PropTypes.object.isRequired, - events: PropTypes.array.isRequired, - onToggleFilter: PropTypes.func.isRequired, - onClose: PropTypes.func.isRequired -} +class EventLog extends Component { + + static propTypes = { + filters: PropTypes.object.isRequired, + events: PropTypes.array.isRequired, + onToggleFilter: PropTypes.func.isRequired, + onClose: PropTypes.func.isRequired, + defaultHeight: PropTypes.number, + } + + static defaultProps = { + defaultHeight: 200, + } + + constructor(props, context) { + super(props, context) + + this.state = { height: this.props.defaultHeight } + + this.onDragStart = this.onDragStart.bind(this) + this.onDragMove = this.onDragMove.bind(this) + this.onDragStop = this.onDragStop.bind(this) + } -function EventLog({ filters, events, onToggleFilter, onClose }) { - return ( - <div className="eventlog"> - <div> - Eventlog - <div className="pull-right"> - {['debug', 'info', 'web'].map(type => ( - <ToggleButton key={type} text={type} checked={filters[type]} onToggle={() => onToggleFilter(type)}/> - ))} - <i onClick={onClose} className="fa fa-close"></i> + onDragStart(event) { + event.preventDefault() + this.dragStart = this.state.height + event.pageY + window.addEventListener('mousemove', this.onDragMove) + window.addEventListener('mouseup', this.onDragStop) + window.addEventListener('dragend', this.onDragStop) + } + + onDragMove(event) { + event.preventDefault() + this.setState({ height: this.dragStart - event.pageY }) + } + + onDragStop(event) { + event.preventDefault() + window.removeEventListener('mousemove', this.onDragMove) + } + + render() { + const { height } = this.state + const { filters, events, onToggleFilter, onClose } = this.props + + return ( + <div className="eventlog" style={{ height }}> + <div onMouseDown={this.onDragStart}> + Eventlog + <div className="pull-right"> + {['debug', 'info', 'web'].map(type => ( + <ToggleButton key={type} text={type} checked={filters[type]} onToggle={() => onToggleFilter(type)}/> + ))} + <i onClick={onClose} className="fa fa-close"></i> + </div> </div> + <EventList events={events} /> </div> - <EventList events={events} /> - </div> - ) + ) + } } export default connect( diff --git a/web/src/js/components/FlowTable/FlowTableHead.jsx b/web/src/js/components/FlowTable/FlowTableHead.jsx index 1df38aba..840f6a34 100644 --- a/web/src/js/components/FlowTable/FlowTableHead.jsx +++ b/web/src/js/components/FlowTable/FlowTableHead.jsx @@ -19,16 +19,12 @@ function FlowTableHead({ sortColumn, sortDesc, onSort }) { {columns.map(Column => ( <th className={classnames(Column.headerClass, sortColumn === Column.name && sortType)} key={Column.name} - onClick={() => onClick(Column)}> + onClick={() => onSort({ sortColumn: Column.name, sortDesc: Column.name !== sortColumn ? false : !sortDesc })}> {Column.headerName} </th> ))} </tr> ) - - function onClick(Column) { - onSort({ sortColumn: Column.name, sortDesc: Column.name !== sortColumn ? false : !sortDesc }) - } } export default connect( diff --git a/web/src/js/components/FlowView.jsx b/web/src/js/components/FlowView.jsx new file mode 100644 index 00000000..23f8b3ea --- /dev/null +++ b/web/src/js/components/FlowView.jsx @@ -0,0 +1,107 @@ +import React, { Component } from 'react' +import _ from 'lodash' + +import Nav from './FlowView/Nav' +import { Request, Response, Error } from './FlowView/Messages' +import Details from './FlowView/Details' +import Prompt from './Prompt' + +export default class FlowView extends Component { + + static allTabs = { Request, Response, Error, Details } + + constructor(props, context) { + super(props, context) + + this.state = { prompt: false } + + this.closePrompt = this.closePrompt.bind(this) + this.selectTab = this.selectTab.bind(this) + } + + getTabs() { + return ['request', 'response', 'error'].filter(k => this.props.flow[k]).concat(['details']) + } + + nextTab(increment) { + const tabs = this.getTabs() + // JS modulo operator doesn't correct negative numbers, make sure that we are positive. + this.selectTab(tabs[(tabs.indexOf(this.props.tab) + increment + tabs.length) % tabs.length]) + } + + selectTab(panel) { + this.props.updateLocation(`/flows/${this.props.flow.id}/${panel}`) + } + + closePrompt(edit) { + this.setState({ prompt: false }) + if (edit) { + this.refs.tab.edit(edit) + } + } + + promptEdit() { + let options + + switch (this.props.tab) { + + case 'request': + options = [ + 'method', + 'url', + { text: 'http version', key: 'v' }, + 'header' + ] + break + + case 'response': + options = [ + { text: 'http version', key: 'v' }, + 'code', + 'message', + 'header' + ] + break + + case 'details': + return + + default: + throw 'Unknown tab for edit: ' + this.props.tab + } + + this.setState({ prompt: { options, done: this.closePrompt } }) + } + + render() { + const tabs = this.getTabs() + let { flow, tab: active } = this.props + + if (tabs.indexOf(active) < 0) { + if (active === 'response' && flow.error) { + active = 'error' + } else if (active === 'error' && flow.response) { + active = 'response' + } else { + active = tabs[0] + } + } + + const Tab = FlowView.allTabs[_.capitalize(active)] + + return ( + <div className="flow-detail" onScroll={this.adjustHead}> + <Nav + flow={flow} + tabs={tabs} + active={active} + onSelectTab={this.selectTab} + /> + <Tab ref="tab" flow={flow}/> + {this.state.prompt && ( + <Prompt {...this.state.prompt}/> + )} + </div> + ) + } +} diff --git a/web/src/js/components/FlowView/Details.jsx b/web/src/js/components/FlowView/Details.jsx new file mode 100644 index 00000000..78e68ecf --- /dev/null +++ b/web/src/js/components/FlowView/Details.jsx @@ -0,0 +1,133 @@ +import React from 'react' +import _ from 'lodash' +import { formatTimeStamp, formatTimeDelta } from '../../utils.js' + +export function TimeStamp({ t, deltaTo, title }) { + return t ? ( + <tr> + <td>{title}:</td> + <td> + {formatTimeStamp(t)} + {deltaTo && ( + <span className="text-muted"> + ({formatTimeDelta(1000 * (t - deltaTo))}) + </span> + )} + </td> + </tr> + ) : ( + <tr></tr> + ) +} + +export function ConnectionInfo({ conn }) { + return ( + <table className="connection-table"> + <tbody> + <tr key="address"> + <td>Address:</td> + <td>{conn.address.address.join(':')}</td> + </tr> + {conn.sni ? ( + <tr key="sni"></tr> + ) : ( + <tr key="sni"> + <td> + <abbr title="TLS Server Name Indication">TLS SNI:</abbr> + </td> + <td>{conn.sni}</td> + </tr> + )} + </tbody> + </table> + ) +} + +export function CertificateInfo({ flow }) { + // @todo We should fetch human-readable certificate representation from the server + return ( + <div> + {flow.client_conn.cert && [ + <h4 key="name">Client Certificate</h4>, + <pre key="value" style={{ maxHeight: 100 }}>{flow.client_conn.cert}</pre> + ]} + + {flow.server_conn.cert && [ + <h4 key="name">Server Certificate</h4>, + <pre key="value" style={{ maxHeight: 100 }}>{flow.server_conn.cert}</pre> + ]} + </div> + ) +} + +export function Timing({ flow }) { + const { server_conn: sc, client_conn: cc, request: req, response: res } = flow + + const 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 + }, res && { + title: "First response byte", + t: res.timestamp_start, + deltaTo: req.timestamp_start + }, res && { + title: "Response complete", + t: res.timestamp_end, + deltaTo: req.timestamp_start + } + ] + + return ( + <div> + <h4>Timing</h4> + <table className="timing-table"> + <tbody> + {timestamps.filter(v => v).sort((a, b) => a.t - b.t).map(item => ( + <TimeStamp key={item.title} {...item}/> + ))} + </tbody> + </table> + </div> + ) +} + +export default function Details({ flow }) { + return ( + <section> + <h4>Client Connection</h4> + <ConnectionInfo conn={flow.client_conn}/> + + <h4>Server Connection</h4> + <ConnectionInfo conn={flow.server_conn}/> + + <CertificateInfo flow={flow}/> + + <Timing flow={flow}/> + </section> + ) +} diff --git a/web/src/js/components/FlowView/Headers.jsx b/web/src/js/components/FlowView/Headers.jsx new file mode 100644 index 00000000..880eeda1 --- /dev/null +++ b/web/src/js/components/FlowView/Headers.jsx @@ -0,0 +1,130 @@ +import React, { Component, PropTypes } from 'react' +import ReactDOM from 'react-dom' +import ValueEditor from '../ValueEditor' +import { Key } from '../../utils.js' + +class HeaderEditor extends Component { + + render() { + return <ValueEditor ref="input" {...this.props} onKeyDown={this.onKeyDown} inline/> + } + + focus() { + ReactDOM.findDOMNode(this).focus() + } + + onKeyDown(e) { + switch (e.keyCode) { + case Key.BACKSPACE: + var s = window.getSelection().getRangeAt(0) + if (s.startOffset === 0 && s.endOffset === 0) { + this.props.onRemove(e) + } + break + case Key.TAB: + if (!e.shiftKey) { + this.props.onTab(e) + } + break + } + } +} + +export default class Headers extends Component { + + static propTypes = { + onChange: PropTypes.func.isRequired, + message: PropTypes.object.isRequired, + } + + onChange(row, col, val) { + const nextHeaders = _.cloneDeep(this.props.message.headers) + + nextHeaders[row][col] = val + + if (!nextHeaders[row][0] && !nextHeaders[row][1]) { + // do not delete last row + if (nextHeaders.length === 1) { + nextHeaders[0][0] = 'Name' + nextHeaders[0][1] = 'Value' + } else { + nextHeaders.splice(row, 1) + // manually move selection target if this has been the last row. + if (row === nextHeaders.length) { + this._nextSel = `${row - 1}-value` + } + } + } + + this.props.onChange(nextHeaders) + } + + edit() { + this.refs['0-key'].focus() + } + + onTab(row, col, e) { + const headers = this.props.message.headers + + if (row !== headers.length - 1 || col !== 1) { + return + } + + e.preventDefault() + + const nextHeaders = _.cloneDeep(this.props.message.headers) + nextHeaders.push(['Name', 'Value']) + this.props.onChange(nextHeaders) + this._nextSel = `${row + 1}-key` + } + + componentDidUpdate() { + if (this._nextSel && this.refs[this._nextSel]) { + this.refs[this._nextSel].focus() + this._nextSel = undefined + } + } + + onRemove(row, col, e) { + if (col === 1) { + e.preventDefault() + this.refs[`${row}-key`].focus() + } else if (row > 0) { + e.preventDefault() + this.refs[`${row - 1}-value`].focus() + } + } + + render() { + const { message } = this.props + + return ( + <table className="header-table"> + <tbody> + {message.headers.map((header, i) => ( + <tr key={i}> + <td className="header-name"> + <HeaderEditor + ref={`${i}-key`} + content={header[0]} + onDone={val => this.onChange(i, 0, val)} + onRemove={event => this.onRemove(i, 0, event)} + onTab={event => this.onTab(i, 0, event)} + />: + </td> + <td className="header-value"> + <HeaderEditor + ref={`${i}-value`} + content={header[1]} + onDone={val => this.onChange(i, 1, val)} + onRemove={event => this.onRemove(i, 1, event)} + onTab={event => this.onTab(i, 1, event)} + /> + </td> + </tr> + ))} + </tbody> + </table> + ) + } +} diff --git a/web/src/js/components/FlowView/Messages.jsx b/web/src/js/components/FlowView/Messages.jsx new file mode 100644 index 00000000..ba6a5f2b --- /dev/null +++ b/web/src/js/components/FlowView/Messages.jsx @@ -0,0 +1,168 @@ +import React, { Component } from 'react' +import _ from 'lodash' + +import { FlowActions } from '../../actions.js' +import { RequestUtils, isValidHttpVersion, parseUrl, parseHttpVersion } from '../../flow/utils.js' +import { Key, formatTimeStamp } from '../../utils.js' +import ContentView from '../ContentView' +import ValueEditor from '../ValueEditor' +import Headers from './Headers' + +class RequestLine extends Component { + + render() { + const { flow } = this.props + + return ( + <div className="first-line request-line"> + <ValueEditor + ref="method" + content={flow.request.method} + onDone={method => FlowActions.update(flow, { request: { method } })} + inline + /> + + <ValueEditor + ref="url" + content={RequestUtils.pretty_url(flow.request)} + onDone={url => FlowActions.update(flow, { request: Object.assign({ path: '' }, parseUrl(url)) })} + isValid={url => !!parseUrl(url).host} + inline + /> + + <ValueEditor + ref="httpVersion" + content={flow.request.http_version} + onDone={ver => FlowActions.update(flow, { request: { http_version: parseHttpVersion(ver) } })} + isValid={isValidHttpVersion} + inline + /> + </div> + ) + } +} + +class ResponseLine extends Component { + + render() { + const { flow } = this.props + + return ( + <div className="first-line response-line"> + <ValueEditor + ref="httpVersion" + content={flow.response.http_version} + onDone={nextVer => FlowActions.update(flow, { response: { http_version: parseHttpVersion(nextVer) } })} + isValid={isValidHttpVersion} + inline + /> + + <ValueEditor + ref="code" + content={flow.response.status_code + ''} + onDone={code => FlowActions.update(flow, { response: { code: parseInt(code) } })} + isValid={code => /^\d+$/.test(code)} + inline + /> + + <ValueEditor + ref="msg" + content={flow.response.reason} + onDone={msg => FlowActions.update(flow, { response: { msg } })} + inline + /> + </div> + ) + } +} + +export class Request extends Component { + + render() { + const { flow } = this.props + + return ( + <section className="request"> + <RequestLine ref="requestLine" flow={flow}/> + <Headers + ref="headers" + message={flow.request} + onChange={headers => FlowActions.update(flow, { request: { headers } })} + /> + <hr/> + <ContentView flow={flow} message={flow.request}/> + </section> + ) + } + + edit(k) { + switch (k) { + case 'm': + this.refs.requestLine.refs.method.focus() + break + case 'u': + this.refs.requestLine.refs.url.focus() + break + case 'v': + this.refs.requestLine.refs.httpVersion.focus() + break + case 'h': + this.refs.headers.edit() + break + default: + throw new Error(`Unimplemented: ${k}`) + } + } +} + +export class Response extends Component { + + render() { + const { flow } = this.props + + return ( + <section className="response"> + <ResponseLine ref="responseLine" flow={flow}/> + <Headers + ref="headers" + message={flow.response} + onChange={headers => FlowActions.update(flow, { response: { headers } })} + /> + <hr/> + <ContentView flow={flow} message={flow.response}/> + </section> + ) + } + + edit(k) { + switch (k) { + case 'c': + this.refs.responseLine.refs.status_code.focus() + break + case 'm': + this.refs.responseLine.refs.msg.focus() + break + case 'v': + this.refs.responseLine.refs.httpVersion.focus() + break + case 'h': + this.refs.headers.edit() + break + default: + throw new Error(`'Unimplemented: ${k}`) + } + } +} + +export function Error({ flow }) { + return ( + <section> + <div className="alert alert-warning"> + {flow.error.msg} + <div> + <small>{formatTimeStamp(flow.error.timestamp)}</small> + </div> + </div> + </section> + ) +} diff --git a/web/src/js/components/FlowView/Nav.jsx b/web/src/js/components/FlowView/Nav.jsx new file mode 100644 index 00000000..386c3a6c --- /dev/null +++ b/web/src/js/components/FlowView/Nav.jsx @@ -0,0 +1,57 @@ +import React, { PropTypes } from 'react' +import classnames from 'classnames' +import { FlowActions } from '../../actions.js' + +NavAction.propTypes = { + icon: PropTypes.string.isRequired, + title: PropTypes.string.isRequired, + onClick: PropTypes.func.isRequired, +} + +function NavAction({ icon, title, onClick }) { + return ( + <a title={title} + href="#" + className="nav-action" + onClick={event => { + event.preventDefault() + onClick(event) + }}> + <i className={`fa fa-fw ${icon}`}></i> + </a> + ) +} + +Nav.propTypes = { + flow: PropTypes.object.isRequired, + active: PropTypes.string.isRequired, + tabs: PropTypes.array.isRequired, + onSelectTab: PropTypes.func.isRequired, +} + +export default function Nav({ flow, active, tabs, onSelectTab }) { + return ( + <nav className="nav-tabs nav-tabs-sm"> + {tabs.map(tab => ( + <a key={tab} + href="#" + className={classnames({ active: active === tab })} + onClick={event => { + event.preventDefault() + onSelectTab(tab) + }}> + {_.capitalize(tab)} + </a> + ))} + <NavAction title="[d]elete flow" icon="fa-trash" onClick={() => FlowActions.delete(flow)} /> + <NavAction title="[D]uplicate flow" icon="fa-copy" onClick={() => FlowActions.duplicate(flow)} /> + <NavAction disabled title="[r]eplay flow" icon="fa-repeat" onClick={() => FlowActions.replay(flow)} /> + {flow.intercepted && ( + <NavAction title="[a]ccept intercepted flow" icon="fa-play" onClick={() => FlowActions.accept(flow)} /> + )} + {flow.modified && ( + <NavAction title="revert changes to flow [V]" icon="fa-history" onClick={() => FlowActions.revert(flow)} /> + )} + </nav> + ) +} diff --git a/web/src/js/components/Footer.jsx b/web/src/js/components/Footer.jsx index 903522f4..1f6de2d7 100644 --- a/web/src/js/components/Footer.jsx +++ b/web/src/js/components/Footer.jsx @@ -1,6 +1,5 @@ import React from 'react' import { formatSize } from '../utils.js' -import { SettingsState } from './common.js' Footer.propTypes = { settings: React.PropTypes.object.isRequired, diff --git a/web/src/js/components/Header.js b/web/src/js/components/Header.jsx index 93ca5154..93ca5154 100644 --- a/web/src/js/components/Header.js +++ b/web/src/js/components/Header.jsx diff --git a/web/src/js/components/Header/FlowMenu.jsx b/web/src/js/components/Header/FlowMenu.jsx index 4a43f40f..96f42652 100644 --- a/web/src/js/components/Header/FlowMenu.jsx +++ b/web/src/js/components/Header/FlowMenu.jsx @@ -1,10 +1,10 @@ import React, { PropTypes } from 'react' -import { Button } from '../common.js' -import {FlowActions} from "../../actions.js"; -import {MessageUtils} from "../../flow/utils.js"; +import Button from '../common/Button' +import { FlowActions } from '../../actions.js' +import { MessageUtils } from '../../flow/utils.js' import { connect } from 'react-redux' -FlowMenu.title = "Flow" +FlowMenu.title = 'Flow' FlowMenu.propTypes = { flow: PropTypes.object.isRequired, diff --git a/web/src/js/components/Header/OptionMenu.jsx b/web/src/js/components/Header/OptionMenu.jsx index 6bbf15d5..44f309fd 100644 --- a/web/src/js/components/Header/OptionMenu.jsx +++ b/web/src/js/components/Header/OptionMenu.jsx @@ -1,8 +1,9 @@ import React, { PropTypes } from 'react' -import { ToggleInputButton, ToggleButton } from '../common.js' +import ToggleButton from '../common/ToggleButton' +import ToggleInputButton from '../common/ToggleInputButton' import { SettingsActions } from '../../actions.js' -OptionMenu.title = "Options" +OptionMenu.title = 'Options' OptionMenu.propTypes = { settings: PropTypes.object.isRequired, diff --git a/web/src/js/components/Header/ViewMenu.jsx b/web/src/js/components/Header/ViewMenu.jsx index a58b0a16..8d662c28 100644 --- a/web/src/js/components/Header/ViewMenu.jsx +++ b/web/src/js/components/Header/ViewMenu.jsx @@ -1,6 +1,6 @@ import React, { PropTypes } from 'react' import { connect } from 'react-redux' -import { ToggleButton } from '../common.js' +import ToggleButton from '../common/ToggleButton' import { toggleEventLogVisibility } from '../../ducks/eventLog' ViewMenu.title = 'View' diff --git a/web/src/js/components/MainView.jsx b/web/src/js/components/MainView.jsx index 8c6ed6d0..7064d3bf 100644 --- a/web/src/js/components/MainView.jsx +++ b/web/src/js/components/MainView.jsx @@ -3,9 +3,9 @@ import { connect } from 'react-redux' import { FlowActions } from '../actions.js' import { Query } from '../actions.js' import { Key } from '../utils.js' -import { Splitter } from './common.js' +import Splitter from './common/Splitter' import FlowTable from './FlowTable' -import FlowView from './flowview/index.js' +import FlowView from './FlowView' import { selectFlow, setFilter, setHighlight } from '../ducks/flows' class MainView extends Component { diff --git a/web/src/js/components/Prompt.jsx b/web/src/js/components/Prompt.jsx new file mode 100755 index 00000000..6b19b3b3 --- /dev/null +++ b/web/src/js/components/Prompt.jsx @@ -0,0 +1,71 @@ +import React, { PropTypes } from 'react' +import ReactDOM from 'react-dom' +import _ from 'lodash' + +import {Key} from '../utils.js' + +Prompt.contextTypes = { + returnFocus: PropTypes.func +} + +Prompt.propTypes = { + options: PropTypes.array.isRequired, + done: PropTypes.func.isRequired, + prompt: PropTypes.string, +} + +export default function Prompt({ prompt, done, options }, context) { + const opts = [] + + function keyTaken(k) { + return _.map(opts, 'key').includes(k) + } + + for (let i = 0; i < options.length; i++) { + let opt = options[i] + if (_.isString(opt)) { + let str = opt + while (str.length > 0 && keyTaken(str[0])) { + str = str.substr(1) + } + opt = { text: opt, key: str[0] } + } + if (!opt.text || !opt.key || keyTaken(opt.key)) { + throw 'invalid options' + } + opts.push(opt) + } + + return ( + <div tabIndex="0" onKeyDown={onKeyDown} onClick={onClick} className="prompt-dialog"> + <div className="prompt-content"> + {prompt || <strong>Select: </strong> } + {opts.map(opt => { + const idx = opt.text.indexOf(opt.key) + function onClick(event) { + done(opt.key) + event.stopPropagation() + } + return ( + <span key={opt.key} className="option" onClick={onClick}> + {idx !== -1 ? opt.text.substring(0, idx) : opt.text + '('} + {prefix}<strong className="text-primary">{opt.key}</strong> + {idx !== -1 ? opt.text.substring(idx + 1) : ')'} + </span> + ) + })} + </div> + </div> + ) + + function onKeyDown(event) { + event.stopPropagation() + event.preventDefault() + const key = opts.find(opt => Key[opt.key.toUpperCase()] === event.keyCode) + if (!key && event.keyCode !== Key.ESC && event.keyCode !== Key.ENTER) { + return + } + done(k || false) + context.returnFocus() + } +} diff --git a/web/src/js/components/ProxyApp.jsx b/web/src/js/components/ProxyApp.jsx index 1d27614f..8129b0f0 100644 --- a/web/src/js/components/ProxyApp.jsx +++ b/web/src/js/components/ProxyApp.jsx @@ -1,20 +1,18 @@ -import React, { Component, PropTypes } from "react" -import ReactDOM from "react-dom" -import _ from "lodash" +import React, { Component, PropTypes } from 'react' +import ReactDOM from 'react-dom' +import _ from 'lodash' import { connect } from 'react-redux' import { fetch as fetchSettings } from '../ducks/settings' -import { Splitter } from "./common.js" -import Header from "./Header" -import EventLog from "./EventLog" -import Footer from "./Footer" -import { Key } from "../utils.js" +import Header from './Header' +import EventLog from './EventLog' +import Footer from './Footer' +import { Key } from '../utils.js' class ProxyAppMain extends Component { static childContextTypes = { returnFocus: PropTypes.func.isRequired, - location: PropTypes.object.isRequired, } static contextTypes = { @@ -68,10 +66,7 @@ class ProxyAppMain extends Component { * @todo use props */ getChildContext() { - return { - returnFocus: this.focus, - location: this.props.location - } + return { returnFocus: this.focus } } /** @@ -85,19 +80,20 @@ class ProxyAppMain extends Component { /** * @todo move to actions + * @todo bind on window */ onKeyDown(e) { let name = null switch (e.keyCode) { case Key.I: - name = "intercept" + name = 'intercept' break case Key.L: - name = "search" + name = 'search' break case Key.H: - name = "highlight" + name = 'highlight' break default: let main = this.refs.view @@ -112,7 +108,7 @@ class ProxyAppMain extends Component { if (name) { const headerComponent = this.refs.header - headerComponent.setState({ active: Header.entries.MainMenu }, () => { + headerComponent.setState({ active: Header.entries[0] }, () => { headerComponent.refs.active.refs[name].select() }) } @@ -128,12 +124,11 @@ class ProxyAppMain extends Component { <Header ref="header" settings={settings} updateLocation={this.updateLocation} query={query} /> {React.cloneElement( children, - { ref: "view", location, query, updateLocation: this.updateLocation } + { ref: 'view', location, query, updateLocation: this.updateLocation } )} - {showEventLog && [ - <Splitter key="splitter" axis="y"/>, + {showEventLog && ( <EventLog key="eventlog"/> - ]} + )} <Footer settings={settings}/> </div> ) diff --git a/web/src/js/components/ValueEditor.jsx b/web/src/js/components/ValueEditor.jsx new file mode 100755 index 00000000..0316924f --- /dev/null +++ b/web/src/js/components/ValueEditor.jsx @@ -0,0 +1,36 @@ +import React, { Component, PropTypes } from 'react' +import ReactDOM from 'react-dom' +import ValidateEditor from './ValueEditor/ValidateEditor' + +export default class ValueEditor extends Component { + + static contextTypes = { + returnFocus: PropTypes.func, + } + + static propTypes = { + content: PropTypes.string.isRequired, + onDone: PropTypes.func.isRequired, + inline: PropTypes.bool, + } + + constructor(props) { + super(props) + this.focus = this.focus.bind(this) + } + + render() { + var tag = this.props.inline ? "span" : 'div' + return ( + <ValidateEditor + {...this.props} + onStop={() => this.context.returnFocus()} + tag={tag} + /> + ) + } + + focus() { + ReactDOM.findDOMNode(this).focus(); + } +} diff --git a/web/src/js/components/ValueEditor/EditorBase.jsx b/web/src/js/components/ValueEditor/EditorBase.jsx new file mode 100755 index 00000000..e737d2af --- /dev/null +++ b/web/src/js/components/ValueEditor/EditorBase.jsx @@ -0,0 +1,166 @@ +import React, { Component, PropTypes } from 'react' +import ReactDOM from 'react-dom' +import {Key} from '../../utils.js' + +export default class EditorBase extends Component { + + static propTypes = { + content: PropTypes.string.isRequired, + onDone: PropTypes.func.isRequired, + contentToHtml: PropTypes.func, + nodeToContent: PropTypes.func, + onStop: PropTypes.func, + submitOnEnter: PropTypes.bool, + className: PropTypes.string, + tag: PropTypes.string, + } + + static defaultProps = { + contentToHtml: content => _.escape(content), + nodeToContent: node => node.textContent, + submitOnEnter: true, + className: '', + tag: 'div', + onStop: _.noop, + onMouseDown: _.noop, + onBlur: _.noop, + onInput: _.noop, + } + + constructor(props) { + super(props) + this.state = {editable: false} + + this.onPaste = this.onPaste.bind(this) + this.onMouseDown = this.onMouseDown.bind(this) + this.onMouseUp = this.onMouseUp.bind(this) + this.onFocus = this.onFocus.bind(this) + this.onClick = this.onClick.bind(this) + this.stop = this.stop.bind(this) + this.onBlur = this.onBlur.bind(this) + this.reset = this.reset.bind(this) + this.onKeyDown = this.onKeyDown.bind(this) + this.onInput = this.onInput.bind(this) + } + + stop() { + // a stop would cause a blur as a side-effect. + // but a blur event must trigger a stop as well. + // to fix this, make stop = blur and do the actual stop in the onBlur handler. + ReactDOM.findDOMNode(this).blur() + this.props.onStop() + } + + render() { + return ( + <this.props.tag + {...this.props} + tabIndex="0" + className={`inline-input ${this.props.className}`} + contentEditable={this.state.editable || undefined} + onFocus={this.onFocus} + onMouseDown={this.onMouseDown} + onClick={this.onClick} + onBlur={this.onBlur} + onKeyDown={this.onKeyDown} + onInput={this.onInput} + onPaste={this.onPaste} + dangerouslySetInnerHTML={{ __html: this.props.contentToHtml(this.props.content) }} + /> + ) + } + + onPaste(e) { + e.preventDefault() + var content = e.clipboardData.getData('text/plain') + document.execCommand('insertHTML', false, content) + } + + onMouseDown(e) { + this._mouseDown = true + window.addEventListener('mouseup', this.onMouseUp) + this.props.onMouseDown(e) + } + + onMouseUp() { + if (this._mouseDown) { + this._mouseDown = false + window.removeEventListener('mouseup', this.onMouseUp) + } + } + + onClick(e) { + this.onMouseUp() + this.onFocus(e) + } + + onFocus(e) { + if (this._mouseDown || this._ignore_events || this.state.editable) { + return + } + + // contenteditable in FireFox is more or less broken. + // - we need to blur() and then focus(), otherwise the caret is not shown. + // - blur() + focus() == we need to save the caret position before + // Firefox sometimes just doesn't set a caret position => use caretPositionFromPoint + const sel = window.getSelection() + let range + if (sel.rangeCount > 0) { + range = sel.getRangeAt(0) + } else if (document.caretPositionFromPoint && e.clientX && e.clientY) { + const pos = document.caretPositionFromPoint(e.clientX, e.clientY) + range = document.createRange() + range.setStart(pos.offsetNode, pos.offset) + } else if (document.caretRangeFromPoint && e.clientX && e.clientY) { + range = document.caretRangeFromPoint(e.clientX, e.clientY) + } else { + range = document.createRange() + range.selectNodeContents(ReactDOM.findDOMNode(this)) + } + + this._ignore_events = true + this.setState({ editable: true }, () => { + const node = ReactDOM.findDOMNode(this) + node.blur() + node.focus() + this._ignore_events = false + }) + } + + onBlur(e) { + if (this._ignore_events) { + return + } + window.getSelection().removeAllRanges() //make sure that selection is cleared on blur + this.setState({ editable: false }) + this.props.onDone(this.props.nodeToContent(ReactDOM.findDOMNode(this))) + this.props.onBlur(e) + } + + reset() { + ReactDOM.findDOMNode(this).innerHTML = this.props.contentToHtml(this.props.content) + } + + onKeyDown(e) { + e.stopPropagation() + switch (e.keyCode) { + case Key.ESC: + e.preventDefault() + this.reset() + this.stop() + break + case Key.ENTER: + if (this.props.submitOnEnter && !e.shiftKey) { + e.preventDefault() + this.stop() + } + break + default: + break + } + } + + onInput() { + this.props.onInput(this.props.nodeToContent(ReactDOM.findDOMNode(this))) + } +} diff --git a/web/src/js/components/ValueEditor/ValidateEditor.jsx b/web/src/js/components/ValueEditor/ValidateEditor.jsx new file mode 100755 index 00000000..2f362986 --- /dev/null +++ b/web/src/js/components/ValueEditor/ValidateEditor.jsx @@ -0,0 +1,58 @@ +import React, { Component, PropTypes } from 'react' +import ReactDOM from 'react-dom' +import EditorBase from './EditorBase' + +export default class ValidateEditor extends Component { + + static propTypes = { + content: PropTypes.string.isRequired, + onDone: PropTypes.func.isRequired, + onInput: PropTypes.func, + isValid: PropTypes.func, + className: PropTypes.string, + } + + constructor(props) { + super(props) + this.state = { currentContent: props.content } + this.onInput = this.onInput.bind(this) + this.onDone = this.onDone.bind(this) + } + + componentWillReceiveProps(nextProps) { + this.setState({ currentContent: nextProps.content }) + } + + onInput(currentContent) { + this.setState({ currentContent }) + this.props.onInput && this.props.onInput(currentContent) + } + + onDone(content) { + if (this.props.isValid && !this.props.isValid(content)) { + this.refs.editor.reset() + content = this.props.content + } + this.props.onDone(content) + } + + render() { + let className = this.props.className || '' + if (this.props.isValid) { + if (this.props.isValid(this.state.currentContent)) { + className += ' has-success' + } else { + className += ' has-warning' + } + } + return ( + <EditorBase + {...this.props} + ref="editor" + className={className} + onDone={this.onDone} + onInput={this.onInput} + /> + ) + } +} diff --git a/web/src/js/components/common.js b/web/src/js/components/common.js deleted file mode 100644 index 1497e15d..00000000 --- a/web/src/js/components/common.js +++ /dev/null @@ -1,173 +0,0 @@ -import React from "react" -import ReactDOM from "react-dom" -import {Key} from "../utils.js"; -import _ from "lodash" - -export 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 () { - ReactDOM.findDOMNode(this).style.transform = ""; - window.removeEventListener("dragend", this.onDragEnd); - window.removeEventListener("mouseup", this.onMouseUp); - window.removeEventListener("mousemove", this.onMouseMove); - }, - onMouseUp: function (e) { - this.onDragEnd(); - - var node = ReactDOM.findDOMNode(this); - var prev = node.previousElementSibling; - var next = node.nextElementSibling; - - var dX = e.pageX - this.state.startX; - var dY = e.pageY - this.state.startY; - var flexBasis; - if (this.props.axis === "x") { - flexBasis = prev.offsetWidth + dX; - } else { - flexBasis = prev.offsetHeight + dY; - } - - prev.style.flex = "0 0 " + Math.max(0, flexBasis) + "px"; - next.style.flex = "1 1 auto"; - - this.setState({ - applied: true - }); - this.onResize(); - }, - onMouseMove: function (e) { - var dX = 0, dY = 0; - if (this.props.axis === "x") { - dX = e.pageX - this.state.startX; - } else { - dY = e.pageY - this.state.startY; - } - ReactDOM.findDOMNode(this).style.transform = "translate(" + dX + "px," + dY + "px)"; - }, - onResize: function () { - // Trigger a global resize event. This notifies components that employ virtual scrolling - // that their viewport may have changed. - window.setTimeout(function () { - window.dispatchEvent(new CustomEvent("resize")); - }, 1); - }, - reset: function (willUnmount) { - if (!this.state.applied) { - return; - } - var node = ReactDOM.findDOMNode(this); - var prev = node.previousElementSibling; - var next = node.nextElementSibling; - - prev.style.flex = ""; - next.style.flex = ""; - - if (!willUnmount) { - this.setState({ - applied: false - }); - } - this.onResize(); - }, - componentWillUnmount: function () { - this.reset(true); - }, - render: function () { - var className = "splitter"; - if (this.props.axis === "x") { - className += " splitter-x"; - } else { - className += " splitter-y"; - } - return ( - <div className={className}> - <div onMouseDown={this.onMouseDown} draggable="true"></div> - </div> - ); - } -}); - -export const ToggleButton = ({checked, onToggle, text}) => - <div className={"btn btn-toggle " + (checked ? "btn-primary" : "btn-default")} onClick={onToggle}> - <i className={"fa fa-fw " + (checked ? "fa-check-square-o" : "fa-square-o")}/> - - {text} - </div>; - -ToggleButton.propTypes = { - checked: React.PropTypes.bool.isRequired, - onToggle: React.PropTypes.func.isRequired, - text: React.PropTypes.string.isRequired -}; - -export const Button = ({onClick, text, icon}) => - <div className={"btn btn-default"} onClick={onClick}> - <i className={"fa fa-fw " + icon}/> - - {text} - </div>; - -Button.propTypes = { - onClick: React.PropTypes.func.isRequired, - text: React.PropTypes.string.isRequired -}; - -export class ToggleInputButton extends React.Component { - constructor(props) { - super(props); - this.state = {txt: props.txt}; - } - - render() { - return ( - <div className="input-group toggle-input-btn"> - <span - className="input-group-btn" - onClick={() => this.props.onToggleChanged(this.state.txt)}> - <div className={"btn " + (this.props.checked ? "btn-primary" : "btn-default")}> - <span className={"fa " + (this.props.checked ? "fa-check-square-o" : "fa-square-o")}/> - {this.props.name} - </div> - </span> - <input - className="form-control" - placeholder={this.props.placeholder} - disabled={this.props.checked} - value={this.state.txt} - type={this.props.inputType} - onChange={e => this.setState({txt: e.target.value})} - onKeyDown={e => {if (e.keyCode === Key.ENTER) this.props.onToggleChanged(this.state.txt); e.stopPropagation()}}/> - </div> - ); - } -} - -ToggleInputButton.propTypes = { - name: React.PropTypes.string.isRequired, - txt: React.PropTypes.string.isRequired, - onToggleChanged: React.PropTypes.func.isRequired -}; - - - diff --git a/web/src/js/components/common/Button.jsx b/web/src/js/components/common/Button.jsx new file mode 100644 index 00000000..cc2fe9dd --- /dev/null +++ b/web/src/js/components/common/Button.jsx @@ -0,0 +1,16 @@ +import React, { PropTypes } from 'react' + +Button.propTypes = { + onClick: PropTypes.func.isRequired, + text: PropTypes.string.isRequired +} + +export default function Button({ onClick, text, icon }) { + return ( + <div className={"btn btn-default"} onClick={onClick}> + <i className={"fa fa-fw " + icon}/> + + {text} + </div> + ) +} diff --git a/web/src/js/components/common/Splitter.jsx b/web/src/js/components/common/Splitter.jsx new file mode 100644 index 00000000..9d22b6fd --- /dev/null +++ b/web/src/js/components/common/Splitter.jsx @@ -0,0 +1,99 @@ +import React, { Component } from 'react' +import ReactDOM from 'react-dom' +import classnames from 'classnames' + +export default class Splitter extends Component { + + static defaultProps = { axis: 'x' } + + constructor(props, context) { + super(props, context) + + this.state = { applied: false, startX: false, startY: false } + + this.onMouseMove = this.onMouseMove.bind(this) + this.onMouseUp = this.onMouseUp.bind(this) + this.onDragEnd = this.onDragEnd.bind(this) + } + + onMouseDown(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() { + ReactDOM.findDOMNode(this).style.transform = '' + + window.removeEventListener('dragend', this.onDragEnd) + window.removeEventListener('mouseup', this.onMouseUp) + window.removeEventListener('mousemove', this.onMouseMove) + } + + onMouseUp(e) { + this.onDragEnd() + + const node = ReactDOM.findDOMNode(this) + const prev = node.previousElementSibling + + let flexBasis = prev.offsetHeight + e.pageY - this.state.startY + + if (this.props.axis === 'x') { + flexBasis = prev.offsetWidth + e.pageX - this.state.startX + } + + prev.style.flex = `0 0 ${Math.max(0, flexBasis)}px` + node.nextElementSibling.style.flex = '1 1 auto' + + this.setState({ applied: true }) + this.onResize() + } + + onMouseMove(e) { + let dX = 0 + let dY = 0 + if (this.props.axis === 'x') { + dX = e.pageX - this.state.startX + } else { + dY = e.pageY - this.state.startY + } + ReactDOM.findDOMNode(this).style.transform = `translate(${dX}px, ${dY}px)` + } + + onResize() { + // Trigger a global resize event. This notifies components that employ virtual scrolling + // that their viewport may have changed. + window.setTimeout(() => window.dispatchEvent(new CustomEvent('resize')), 1) + } + + reset(willUnmount) { + if (!this.state.applied) { + return + } + + const node = ReactDOM.findDOMNode(this) + + node.previousElementSibling.style.flex = '' + node.nextElementSibling.style.flex = '' + + if (!willUnmount) { + this.setState({ applied: false }) + } + this.onResize() + } + + componentWillUnmount() { + this.reset(true) + } + + render() { + return ( + <div className={classnames('splitter', this.props.axis === 'x' ? 'splitter-x' : 'splitter-y')}> + <div onMouseDown={this.onMouseDown} draggable="true"></div> + </div> + ) + } +} diff --git a/web/src/js/components/common/ToggleButton.jsx b/web/src/js/components/common/ToggleButton.jsx new file mode 100644 index 00000000..6027728b --- /dev/null +++ b/web/src/js/components/common/ToggleButton.jsx @@ -0,0 +1,17 @@ +import React, { PropTypes } from 'react' + +ToggleButton.propTypes = { + checked: PropTypes.bool.isRequired, + onToggle: PropTypes.func.isRequired, + text: PropTypes.string.isRequired +} + +export default function ToggleButton({ checked, onToggle, text }) { + return ( + <div className={"btn btn-toggle " + (checked ? "btn-primary" : "btn-default")} onClick={onToggle}> + <i className={"fa fa-fw " + (checked ? "fa-check-square-o" : "fa-square-o")}/> + + {text} + </div> + ) +} diff --git a/web/src/js/components/common/ToggleInputButton.jsx b/web/src/js/components/common/ToggleInputButton.jsx new file mode 100644 index 00000000..25d620ae --- /dev/null +++ b/web/src/js/components/common/ToggleInputButton.jsx @@ -0,0 +1,52 @@ +import React, { Component, PropTypes } from 'react' +import classnames from 'classnames' +import { Key } from '../../utils' + +export default class ToggleInputButton extends Component { + + static propTypes = { + name: PropTypes.string.isRequired, + txt: PropTypes.string.isRequired, + onToggleChanged: PropTypes.func.isRequired + } + + constructor(props) { + super(props) + this.state = { txt: props.txt } + } + + onChange(e) { + this.setState({ txt: e.target.value }) + } + + onKeyDown(e) { + e.stopPropagation() + if (e.keyCode === Key.ENTER) { + this.props.onToggleChanged(this.state.txt) + } + } + + render() { + return ( + <div className="input-group toggle-input-btn"> + <span className="input-group-btn" + onClick={() => this.props.onToggleChanged(this.state.txt)}> + <div className={classnames('btn', this.props.checked ? 'btn-primary' : 'btn-default')}> + <span className={classnames('fa', this.props.checked ? 'fa-check-square-o' : 'fa-square-o')}/> + + {this.props.name} + </div> + </span> + <input + className="form-control" + placeholder={this.props.placeholder} + disabled={this.props.checked} + value={this.state.txt} + type={this.props.inputType} + onChange={e => this.onChange(e)} + onKeyDown={e => this.onKeyDown(e)} + /> + </div> + ) + } +} diff --git a/web/src/js/components/editor.js b/web/src/js/components/editor.js deleted file mode 100644 index eed2f7c6..00000000 --- a/web/src/js/components/editor.js +++ /dev/null @@ -1,238 +0,0 @@ -import React from "react"; -import ReactDOM from 'react-dom'; -import {Key} from "../utils.js"; - -var contentToHtml = function (content) { - return _.escape(content); -}; -var nodeToContent = function (node) { - return node.textContent; -}; - -/* - Basic Editor Functionality - */ -var EditorBase = React.createClass({ - propTypes: { - content: React.PropTypes.string.isRequired, - onDone: React.PropTypes.func.isRequired, - contentToHtml: React.PropTypes.func, - nodeToContent: React.PropTypes.func, // content === nodeToContent( Node<innerHTML=contentToHtml(content)> ) - onStop: React.PropTypes.func, - submitOnEnter: React.PropTypes.bool, - className: React.PropTypes.string, - tag: React.PropTypes.string - }, - getDefaultProps: function () { - return { - contentToHtml: contentToHtml, - nodeToContent: nodeToContent, - submitOnEnter: true, - className: "", - tag: "div" - }; - }, - getInitialState: function () { - return { - editable: false - }; - }, - render: function () { - var className = "inline-input " + this.props.className; - var html = {__html: this.props.contentToHtml(this.props.content)}; - var Tag = this.props.tag; - return <Tag - {...this.props} - tabIndex="0" - className={className} - contentEditable={this.state.editable || undefined } // workaround: use undef instead of false to remove attr - onFocus={this.onFocus} - onMouseDown={this.onMouseDown} - onClick={this.onClick} - onBlur={this._stop} - onKeyDown={this.onKeyDown} - onInput={this.onInput} - onPaste={this.onPaste} - dangerouslySetInnerHTML={html} - />; - }, - onPaste: function (e) { - e.preventDefault(); - var content = e.clipboardData.getData("text/plain"); - document.execCommand("insertHTML", false, content); - }, - onMouseDown: function (e) { - this._mouseDown = true; - window.addEventListener("mouseup", this.onMouseUp); - this.props.onMouseDown && this.props.onMouseDown(e); - }, - onMouseUp: function () { - if (this._mouseDown) { - this._mouseDown = false; - window.removeEventListener("mouseup", this.onMouseUp) - } - }, - onClick: function (e) { - this.onMouseUp(); - this.onFocus(e); - }, - onFocus: function (e) { - console.log("onFocus", this._mouseDown, this._ignore_events, this.state.editable); - if (this._mouseDown || this._ignore_events || this.state.editable) { - return; - } - - //contenteditable in FireFox is more or less broken. - // - we need to blur() and then focus(), otherwise the caret is not shown. - // - blur() + focus() == we need to save the caret position before - // Firefox sometimes just doesn't set a caret position => use caretPositionFromPoint - var sel = window.getSelection(); - var range; - if (sel.rangeCount > 0) { - range = sel.getRangeAt(0); - } else if (document.caretPositionFromPoint && e.clientX && e.clientY) { - var pos = document.caretPositionFromPoint(e.clientX, e.clientY); - range = document.createRange(); - range.setStart(pos.offsetNode, pos.offset); - } else if (document.caretRangeFromPoint && e.clientX && e.clientY) { - range = document.caretRangeFromPoint(e.clientX, e.clientY); - } else { - range = document.createRange(); - range.selectNodeContents(ReactDOM.findDOMNode(this)); - } - - this._ignore_events = true; - this.setState({editable: true}, function () { - var node = ReactDOM.findDOMNode(this); - node.blur(); - node.focus(); - this._ignore_events = false; - //sel.removeAllRanges(); - //sel.addRange(range); - - - }); - }, - stop: function () { - // a stop would cause a blur as a side-effect. - // but a blur event must trigger a stop as well. - // to fix this, make stop = blur and do the actual stop in the onBlur handler. - ReactDOM.findDOMNode(this).blur(); - this.props.onStop && this.props.onStop(); - }, - _stop: function (e) { - if (this._ignore_events) { - return; - } - console.log("_stop", _.extend({}, e)); - window.getSelection().removeAllRanges(); //make sure that selection is cleared on blur - var node = ReactDOM.findDOMNode(this); - var content = this.props.nodeToContent(node); - this.setState({editable: false}); - this.props.onDone(content); - this.props.onBlur && this.props.onBlur(e); - }, - reset: function () { - ReactDOM.findDOMNode(this).innerHTML = this.props.contentToHtml(this.props.content); - }, - onKeyDown: function (e) { - e.stopPropagation(); - switch (e.keyCode) { - case Key.ESC: - e.preventDefault(); - this.reset(); - this.stop(); - break; - case Key.ENTER: - if (this.props.submitOnEnter && !e.shiftKey) { - e.preventDefault(); - this.stop(); - } - break; - default: - break; - } - }, - onInput: function () { - var node = ReactDOM.findDOMNode(this); - var content = this.props.nodeToContent(node); - this.props.onInput && this.props.onInput(content); - } -}); - -/* - Add Validation to EditorBase - */ -var ValidateEditor = React.createClass({ - propTypes: { - content: React.PropTypes.string.isRequired, - onDone: React.PropTypes.func.isRequired, - onInput: React.PropTypes.func, - isValid: React.PropTypes.func, - className: React.PropTypes.string, - }, - getInitialState: function () { - return { - currentContent: this.props.content - }; - }, - componentWillReceiveProps: function () { - this.setState({currentContent: this.props.content}); - }, - onInput: function (content) { - this.setState({currentContent: content}); - this.props.onInput && this.props.onInput(content); - }, - render: function () { - var className = this.props.className || ""; - if (this.props.isValid) { - if (this.props.isValid(this.state.currentContent)) { - className += " has-success"; - } else { - className += " has-warning" - } - } - return <EditorBase - {...this.props} - ref="editor" - className={className} - onDone={this.onDone} - onInput={this.onInput} - />; - }, - onDone: function (content) { - if (this.props.isValid && !this.props.isValid(content)) { - this.refs.editor.reset(); - content = this.props.content; - } - this.props.onDone(content); - } -}); - -/* - Text Editor with mitmweb-specific convenience features - */ -export var ValueEditor = React.createClass({ - contextTypes: { - returnFocus: React.PropTypes.func - }, - propTypes: { - content: React.PropTypes.string.isRequired, - onDone: React.PropTypes.func.isRequired, - inline: React.PropTypes.bool, - }, - render: function () { - var tag = this.props.inline ? "span" : "div"; - return <ValidateEditor - {...this.props} - onStop={this.onStop} - tag={tag} - />; - }, - focus: function () { - ReactDOM.findDOMNode(this).focus(); - }, - onStop: function () { - this.context.returnFocus(); - } -});
\ No newline at end of file diff --git a/web/src/js/components/flowview/contentview.js b/web/src/js/components/flowview/contentview.js deleted file mode 100644 index cbac9a75..00000000 --- a/web/src/js/components/flowview/contentview.js +++ /dev/null @@ -1,267 +0,0 @@ -import React from "react"; -import _ from "lodash"; - -import {MessageUtils} from "../../flow/utils.js"; -import {formatSize} from "../../utils.js"; - -var ViewImage = React.createClass({ - propTypes: { - flow: React.PropTypes.object.isRequired, - message: React.PropTypes.object.isRequired, - }, - statics: { - regex: /^image\/(png|jpe?g|gif|vnc.microsoft.icon|x-icon)$/i, - matches: function (message) { - return ViewImage.regex.test(MessageUtils.getContentType(message)); - } - }, - render: function () { - var url = MessageUtils.getContentURL(this.props.flow, this.props.message); - return <div className="flowview-image"> - <img src={url} alt="preview" className="img-thumbnail"/> - </div>; - } -}); - -var ContentLoader = React.createClass({ - propTypes: { - flow: React.PropTypes.object.isRequired, - message: React.PropTypes.object.isRequired, - }, - getInitialState: function () { - return { - content: undefined, - request: undefined - } - }, - requestContent: function (nextProps) { - if (this.state.request) { - this.state.request.abort(); - } - var request = MessageUtils.getContent(nextProps.flow, nextProps.message); - this.setState({ - content: undefined, - request: request - }); - request.done(function (data) { - this.setState({content: data}); - }.bind(this)).fail(function (jqXHR, textStatus, errorThrown) { - if (textStatus === "abort") { - return; - } - this.setState({content: "AJAX Error: " + textStatus + "\r\n" + errorThrown}); - }.bind(this)).always(function () { - this.setState({request: undefined}); - }.bind(this)); - - }, - componentWillMount: function () { - this.requestContent(this.props); - }, - componentWillReceiveProps: function (nextProps) { - if (nextProps.message !== this.props.message) { - this.requestContent(nextProps); - } - }, - componentWillUnmount: function () { - if (this.state.request) { - this.state.request.abort(); - } - }, - render: function () { - if (!this.state.content) { - return <div className="text-center"> - <i className="fa fa-spinner fa-spin"></i> - </div>; - } - return React.cloneElement(this.props.children, { - content: this.state.content - }) - } -}); - -var ViewRaw = React.createClass({ - propTypes: { - content: React.PropTypes.string.isRequired, - }, - statics: { - textView: true, - matches: function (message) { - return true; - } - }, - render: function () { - return <pre>{this.props.content}</pre>; - } -}); - -var ViewJSON = React.createClass({ - propTypes: { - content: React.PropTypes.string.isRequired, - }, - statics: { - textView: true, - regex: /^application\/json$/i, - matches: function (message) { - return ViewJSON.regex.test(MessageUtils.getContentType(message)); - } - }, - render: function () { - var json = this.props.content; - try { - json = JSON.stringify(JSON.parse(json), null, 2); - } catch (e) { - // @noop - } - return <pre>{json}</pre>; - } -}); - -var ViewAuto = React.createClass({ - propTypes: { - message: React.PropTypes.object.isRequired, - flow: React.PropTypes.object.isRequired, - }, - statics: { - matches: function () { - return false; // don't match itself - }, - findView: function (message) { - for (var i = 0; i < all.length; i++) { - if (all[i].matches(message)) { - return all[i]; - } - } - return all[all.length - 1]; - } - }, - render: function () { - var { message, flow } = this.props - var View = ViewAuto.findView(this.props.message); - if (View.textView) { - return <ContentLoader message={message} flow={flow}><View content="" /></ContentLoader> - } else { - return <View message={message} flow={flow} /> - } - } -}); - -var all = [ViewAuto, ViewImage, ViewJSON, ViewRaw]; - -var ContentEmpty = React.createClass({ - render: function () { - var message_name = this.props.flow.request === this.props.message ? "request" : "response"; - return <div className="alert alert-info">No {message_name} content.</div>; - } -}); - -var ContentMissing = React.createClass({ - render: function () { - var message_name = this.props.flow.request === this.props.message ? "Request" : "Response"; - return <div className="alert alert-info">{message_name} content missing.</div>; - } -}); - -var TooLarge = React.createClass({ - statics: { - isTooLarge: function (message) { - var max_mb = ViewImage.matches(message) ? 10 : 0.2; - return message.contentLength > 1024 * 1024 * max_mb; - } - }, - render: function () { - var size = formatSize(this.props.message.contentLength); - return <div className="alert alert-warning"> - <button onClick={this.props.onClick} className="btn btn-xs btn-warning pull-right">Display anyway</button> - {size} content size. - </div>; - } -}); - -var ViewSelector = React.createClass({ - render: function () { - var views = []; - for (var i = 0; i < all.length; i++) { - var view = all[i]; - var className = "btn btn-default"; - if (view === this.props.active) { - className += " active"; - } - var text; - if (view === ViewAuto) { - text = "auto: " + ViewAuto.findView(this.props.message).displayName.toLowerCase().replace("view", ""); - } else { - text = view.displayName.toLowerCase().replace("view", ""); - } - views.push( - <button - key={view.displayName} - onClick={this.props.selectView.bind(null, view)} - className={className}> - {text} - </button> - ); - } - - return <div className="view-selector btn-group btn-group-xs">{views}</div>; - } -}); - -var ContentView = React.createClass({ - getInitialState: function () { - return { - displayLarge: false, - View: ViewAuto - }; - }, - propTypes: { - // It may seem a bit weird at the first glance: - // Every view takes the flow and the message as props, e.g. - // <Auto flow={flow} message={flow.request}/> - flow: React.PropTypes.object.isRequired, - message: React.PropTypes.object.isRequired, - }, - selectView: function (view) { - this.setState({ - View: view - }); - }, - displayLarge: function () { - this.setState({displayLarge: true}); - }, - componentWillReceiveProps: function (nextProps) { - if (nextProps.message !== this.props.message) { - this.setState(this.getInitialState()); - } - }, - render: function () { - var { flow, message } = this.props - var message = this.props.message; - if (message.contentLength === 0) { - return <ContentEmpty {...this.props}/>; - } else if (message.contentLength === null) { - return <ContentMissing {...this.props}/>; - } else if (!this.state.displayLarge && TooLarge.isTooLarge(message)) { - return <TooLarge {...this.props} onClick={this.displayLarge}/>; - } - - var downloadUrl = MessageUtils.getContentURL(this.props.flow, message); - - return <div> - {this.state.View.textView ? ( - <ContentLoader flow={flow} message={message}><this.state.View content="" /></ContentLoader> - ) : ( - <this.state.View flow={flow} message={message} /> - )} - <div className="view-options text-center"> - <ViewSelector selectView={this.selectView} active={this.state.View} message={message}/> - - <a className="btn btn-default btn-xs" href={downloadUrl}> - <i className="fa fa-download"/> - </a> - </div> - </div>; - } -}); - -export default ContentView; diff --git a/web/src/js/components/flowview/details.js b/web/src/js/components/flowview/details.js deleted file mode 100644 index 45fe1292..00000000 --- a/web/src/js/components/flowview/details.js +++ /dev/null @@ -1,181 +0,0 @@ -import React from "react"; -import _ from "lodash"; - -import {formatTimeStamp, formatTimeDelta} from "../../utils.js"; - -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 = formatTimeStamp(this.props.t); - - 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 Details = 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> - ); - } -}); - -export default Details;
\ No newline at end of file diff --git a/web/src/js/components/flowview/index.js b/web/src/js/components/flowview/index.js deleted file mode 100644 index 7f5e9768..00000000 --- a/web/src/js/components/flowview/index.js +++ /dev/null @@ -1,114 +0,0 @@ -import React from "react"; - -import Nav from "./nav.js"; -import {Request, Response, Error} from "./messages.js"; -import Details from "./details.js"; -import Prompt from "../prompt.js"; - - -var allTabs = { - request: Request, - response: Response, - error: Error, - details: Details -}; - -var FlowView = React.createClass({ - getInitialState: function () { - return { - prompt: false - }; - }, - getTabs: function (flow) { - var tabs = []; - ["request", "response", "error"].forEach(function (e) { - if (flow[e]) { - tabs.push(e); - } - }); - tabs.push("details"); - return tabs; - }, - nextTab: function (i) { - var tabs = this.getTabs(this.props.flow); - var currentIndex = tabs.indexOf(this.props.tab); - // JS modulo operator doesn't correct negative numbers, make sure that we are positive. - var nextIndex = (currentIndex + i + tabs.length) % tabs.length; - this.selectTab(tabs[nextIndex]); - }, - selectTab: function (panel) { - this.props.updateLocation(`/flows/${this.props.flow.id}/${panel}`); - }, - promptEdit: function () { - var options; - switch (this.props.tab) { - case "request": - options = [ - "method", - "url", - {text: "http version", key: "v"}, - "header" - /*, "content"*/]; - break; - case "response": - options = [ - {text: "http version", key: "v"}, - "code", - "message", - "header" - /*, "content"*/]; - break; - case "details": - return; - default: - throw "Unknown tab for edit: " + this.props.tab; - } - - this.setState({ - prompt: { - done: function (k) { - this.setState({prompt: false}); - if (k) { - this.refs.tab.edit(k); - } - }.bind(this), - options: options - } - }); - }, - render: function () { - var flow = this.props.flow; - var tabs = this.getTabs(flow); - var active = this.props.tab; - - if (tabs.indexOf(active) < 0) { - if (active === "response" && flow.error) { - active = "error"; - } else if (active === "error" && flow.response) { - active = "response"; - } else { - active = tabs[0]; - } - } - - var prompt = null; - if (this.state.prompt) { - prompt = <Prompt {...this.state.prompt}/>; - } - - var Tab = allTabs[active]; - return ( - <div className="flow-detail" onScroll={this.adjustHead}> - <Nav ref="head" - flow={flow} - tabs={tabs} - active={active} - selectTab={this.selectTab}/> - <Tab ref="tab" flow={flow}/> - {prompt} - </div> - ); - } -}); - -export default FlowView; diff --git a/web/src/js/components/flowview/messages.js b/web/src/js/components/flowview/messages.js deleted file mode 100644 index 2885b3b1..00000000 --- a/web/src/js/components/flowview/messages.js +++ /dev/null @@ -1,320 +0,0 @@ -import React from "react"; -import ReactDOM from 'react-dom'; -import _ from "lodash"; - -import {FlowActions} from "../../actions.js"; -import {RequestUtils, isValidHttpVersion, parseUrl, parseHttpVersion} from "../../flow/utils.js"; -import {Key, formatTimeStamp} from "../../utils.js"; -import ContentView from "./contentview.js"; -import {ValueEditor} from "../editor.js"; - -var Headers = React.createClass({ - propTypes: { - onChange: React.PropTypes.func.isRequired, - message: React.PropTypes.object.isRequired - }, - onChange: function (row, col, val) { - var nextHeaders = _.cloneDeep(this.props.message.headers); - nextHeaders[row][col] = val; - if (!nextHeaders[row][0] && !nextHeaders[row][1]) { - // do not delete last row - if (nextHeaders.length === 1) { - nextHeaders[0][0] = "Name"; - nextHeaders[0][1] = "Value"; - } else { - nextHeaders.splice(row, 1); - // manually move selection target if this has been the last row. - if (row === nextHeaders.length) { - this._nextSel = (row - 1) + "-value"; - } - } - } - this.props.onChange(nextHeaders); - }, - edit: function () { - this.refs["0-key"].focus(); - }, - onTab: function (row, col, e) { - var headers = this.props.message.headers; - if (row === headers.length - 1 && col === 1) { - e.preventDefault(); - - var nextHeaders = _.cloneDeep(this.props.message.headers); - nextHeaders.push(["Name", "Value"]); - this.props.onChange(nextHeaders); - this._nextSel = (row + 1) + "-key"; - } - }, - componentDidUpdate: function () { - if (this._nextSel && this.refs[this._nextSel]) { - this.refs[this._nextSel].focus(); - this._nextSel = undefined; - } - }, - onRemove: function (row, col, e) { - if (col === 1) { - e.preventDefault(); - this.refs[row + "-key"].focus(); - } else if (row > 0) { - e.preventDefault(); - this.refs[(row - 1) + "-value"].focus(); - } - }, - render: function () { - - var rows = this.props.message.headers.map(function (header, i) { - - var kEdit = <HeaderEditor - ref={i + "-key"} - content={header[0]} - onDone={this.onChange.bind(null, i, 0)} - onRemove={this.onRemove.bind(null, i, 0)} - onTab={this.onTab.bind(null, i, 0)}/>; - var vEdit = <HeaderEditor - ref={i + "-value"} - content={header[1]} - onDone={this.onChange.bind(null, i, 1)} - onRemove={this.onRemove.bind(null, i, 1)} - onTab={this.onTab.bind(null, i, 1)}/>; - return ( - <tr key={i}> - <td className="header-name">{kEdit}:</td> - <td className="header-value">{vEdit}</td> - </tr> - ); - }.bind(this)); - return ( - <table className="header-table"> - <tbody> - {rows} - </tbody> - </table> - ); - } -}); - -var HeaderEditor = React.createClass({ - render: function () { - return <ValueEditor ref="input" {...this.props} onKeyDown={this.onKeyDown} inline/>; - }, - focus: function () { - ReactDOM.findDOMNode(this).focus(); - }, - onKeyDown: function (e) { - switch (e.keyCode) { - case Key.BACKSPACE: - var s = window.getSelection().getRangeAt(0); - if (s.startOffset === 0 && s.endOffset === 0) { - this.props.onRemove(e); - } - break; - case Key.TAB: - if (!e.shiftKey) { - this.props.onTab(e); - } - break; - } - } -}); - -var RequestLine = React.createClass({ - render: function () { - var flow = this.props.flow; - var url = RequestUtils.pretty_url(flow.request); - var httpver = flow.request.http_version; - - return <div className="first-line request-line"> - <ValueEditor - ref="method" - content={flow.request.method} - onDone={this.onMethodChange} - inline/> - - <ValueEditor - ref="url" - content={url} - onDone={this.onUrlChange} - isValid={this.isValidUrl} - inline/> - - <ValueEditor - ref="httpVersion" - content={httpver} - onDone={this.onHttpVersionChange} - isValid={isValidHttpVersion} - inline/> - </div> - }, - isValidUrl: function (url) { - var u = parseUrl(url); - return !!u.host; - }, - onMethodChange: function (nextMethod) { - FlowActions.update( - this.props.flow, - {request: {method: nextMethod}} - ); - }, - onUrlChange: function (nextUrl) { - var props = parseUrl(nextUrl); - props.path = props.path || ""; - FlowActions.update( - this.props.flow, - {request: props} - ); - }, - onHttpVersionChange: function (nextVer) { - var ver = parseHttpVersion(nextVer); - FlowActions.update( - this.props.flow, - {request: {http_version: ver}} - ); - } -}); - -var ResponseLine = React.createClass({ - render: function () { - var flow = this.props.flow; - var httpver = flow.response.http_version; - return <div className="first-line response-line"> - <ValueEditor - ref="httpVersion" - content={httpver} - onDone={this.onHttpVersionChange} - isValid={isValidHttpVersion} - inline/> - - <ValueEditor - ref="code" - content={flow.response.status_code + ""} - onDone={this.onCodeChange} - isValid={this.isValidCode} - inline/> - - <ValueEditor - ref="msg" - content={flow.response.reason} - onDone={this.onMsgChange} - inline/> - </div>; - }, - isValidCode: function (code) { - return /^\d+$/.test(code); - }, - onHttpVersionChange: function (nextVer) { - var ver = parseHttpVersion(nextVer); - FlowActions.update( - this.props.flow, - {response: {http_version: ver}} - ); - }, - onMsgChange: function (nextMsg) { - FlowActions.update( - this.props.flow, - {response: {msg: nextMsg}} - ); - }, - onCodeChange: function (nextCode) { - nextCode = parseInt(nextCode); - FlowActions.update( - this.props.flow, - {response: {code: nextCode}} - ); - } -}); - -export var Request = React.createClass({ - render: function () { - var flow = this.props.flow; - return ( - <section className="request"> - <RequestLine ref="requestLine" flow={flow}/> - {/*<ResponseLine flow={flow}/>*/} - <Headers ref="headers" message={flow.request} onChange={this.onHeaderChange}/> - <hr/> - <ContentView flow={flow} message={flow.request}/> - </section> - ); - }, - edit: function (k) { - switch (k) { - case "m": - this.refs.requestLine.refs.method.focus(); - break; - case "u": - this.refs.requestLine.refs.url.focus(); - break; - case "v": - this.refs.requestLine.refs.httpVersion.focus(); - break; - case "h": - this.refs.headers.edit(); - break; - default: - throw "Unimplemented: " + k; - } - }, - onHeaderChange: function (nextHeaders) { - FlowActions.update(this.props.flow, { - request: { - headers: nextHeaders - } - }); - } -}); - -export var Response = React.createClass({ - render: function () { - var flow = this.props.flow; - return ( - <section className="response"> - {/*<RequestLine flow={flow}/>*/} - <ResponseLine ref="responseLine" flow={flow}/> - <Headers ref="headers" message={flow.response} onChange={this.onHeaderChange}/> - <hr/> - <ContentView flow={flow} message={flow.response}/> - </section> - ); - }, - edit: function (k) { - switch (k) { - case "c": - this.refs.responseLine.refs.status_code.focus(); - break; - case "m": - this.refs.responseLine.refs.msg.focus(); - break; - case "v": - this.refs.responseLine.refs.httpVersion.focus(); - break; - case "h": - this.refs.headers.edit(); - break; - default: - throw "Unimplemented: " + k; - } - }, - onHeaderChange: function (nextHeaders) { - FlowActions.update(this.props.flow, { - response: { - headers: nextHeaders - } - }); - } -}); - -export var Error = React.createClass({ - render: function () { - var flow = this.props.flow; - return ( - <section> - <div className="alert alert-warning"> - {flow.error.msg} - <div> - <small>{ formatTimeStamp(flow.error.timestamp) }</small> - </div> - </div> - </section> - ); - } -}); diff --git a/web/src/js/components/flowview/nav.js b/web/src/js/components/flowview/nav.js deleted file mode 100644 index a12fd1fd..00000000 --- a/web/src/js/components/flowview/nav.js +++ /dev/null @@ -1,61 +0,0 @@ -import React from "react"; - -import {FlowActions} from "../../actions.js"; - -var NavAction = React.createClass({ - onClick: function (e) { - e.preventDefault(); - this.props.onClick(); - }, - render: function () { - return ( - <a title={this.props.title} - href="#" - className="nav-action" - onClick={this.onClick}> - <i className={"fa fa-fw " + this.props.icon}></i> - </a> - ); - } -}); - -var Nav = React.createClass({ - render: function () { - var flow = this.props.flow; - - var tabs = this.props.tabs.map(function (e) { - var str = e.charAt(0).toUpperCase() + e.slice(1); - var className = this.props.active === e ? "active" : ""; - var onClick = function (event) { - this.props.selectTab(e); - event.preventDefault(); - }.bind(this); - return <a key={e} - href="#" - className={className} - onClick={onClick}>{str}</a>; - }.bind(this)); - - var acceptButton = null; - if(flow.intercepted){ - acceptButton = <NavAction title="[a]ccept intercepted flow" icon="fa-play" onClick={FlowActions.accept.bind(null, flow)} />; - } - var revertButton = null; - if(flow.modified){ - revertButton = <NavAction title="revert changes to flow [V]" icon="fa-history" onClick={FlowActions.revert.bind(null, flow)} />; - } - - return ( - <nav ref="head" className="nav-tabs nav-tabs-sm"> - {tabs} - <NavAction title="[d]elete flow" icon="fa-trash" onClick={FlowActions.delete.bind(null, flow)} /> - <NavAction title="[D]uplicate flow" icon="fa-copy" onClick={FlowActions.duplicate.bind(null, flow)} /> - <NavAction disabled title="[r]eplay flow" icon="fa-repeat" onClick={FlowActions.replay.bind(null, flow)} /> - {acceptButton} - {revertButton} - </nav> - ); - } -}); - -export default Nav;
\ No newline at end of file diff --git a/web/src/js/components/prompt.js b/web/src/js/components/prompt.js deleted file mode 100644 index 5ab26b82..00000000 --- a/web/src/js/components/prompt.js +++ /dev/null @@ -1,102 +0,0 @@ -import React from "react"; -import ReactDOM from 'react-dom'; -import _ from "lodash"; - -import {Key} from "../utils.js"; - -var Prompt = React.createClass({ - contextTypes: { - returnFocus: React.PropTypes.func - }, - propTypes: { - options: React.PropTypes.array.isRequired, - done: React.PropTypes.func.isRequired, - prompt: React.PropTypes.string - }, - componentDidMount: function () { - ReactDOM.findDOMNode(this).focus(); - }, - onKeyDown: function (e) { - e.stopPropagation(); - e.preventDefault(); - var opts = this.getOptions(); - for (var i = 0; i < opts.length; i++) { - var k = opts[i].key; - if (Key[k.toUpperCase()] === e.keyCode) { - this.done(k); - return; - } - } - if (e.keyCode === Key.ESC || e.keyCode === Key.ENTER) { - this.done(false); - } - }, - onClick: function (e) { - this.done(false); - }, - done: function (ret) { - this.props.done(ret); - this.context.returnFocus(); - }, - getOptions: function () { - var opts = []; - - var keyTaken = function (k) { - return _.includes(_.map(opts, "key"), k); - }; - - for (var i = 0; i < this.props.options.length; i++) { - var opt = this.props.options[i]; - if (_.isString(opt)) { - var str = opt; - while (str.length > 0 && keyTaken(str[0])) { - str = str.substr(1); - } - opt = { - text: opt, - key: str[0] - }; - } - if (!opt.text || !opt.key || keyTaken(opt.key)) { - throw "invalid options"; - } else { - opts.push(opt); - } - } - return opts; - }, - render: function () { - var opts = this.getOptions(); - opts = _.map(opts, function (o) { - var prefix, suffix; - var idx = o.text.indexOf(o.key); - if (idx !== -1) { - prefix = o.text.substring(0, idx); - suffix = o.text.substring(idx + 1); - - } else { - prefix = o.text + " ("; - suffix = ")"; - } - var onClick = function (e) { - this.done(o.key); - e.stopPropagation(); - }.bind(this); - return <span - key={o.key} - className="option" - onClick={onClick}> - {prefix} - <strong className="text-primary">{o.key}</strong>{suffix} - </span>; - }.bind(this)); - return <div tabIndex="0" onKeyDown={this.onKeyDown} onClick={this.onClick} className="prompt-dialog"> - <div className="prompt-content"> - {this.props.prompt || <strong>Select: </strong> } - {opts} - </div> - </div>; - } -}); - -export default Prompt; |