aboutsummaryrefslogtreecommitdiffstats
path: root/web/src/js/components/FlowView
diff options
context:
space:
mode:
Diffstat (limited to 'web/src/js/components/FlowView')
-rw-r--r--web/src/js/components/FlowView/Details.jsx133
-rw-r--r--web/src/js/components/FlowView/Headers.jsx130
-rw-r--r--web/src/js/components/FlowView/Messages.jsx168
-rw-r--r--web/src/js/components/FlowView/Nav.jsx57
4 files changed, 488 insertions, 0 deletions
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
+ />
+ &nbsp;
+ <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
+ />
+ &nbsp;
+ <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
+ />
+ &nbsp;
+ <ValueEditor
+ ref="code"
+ content={flow.response.status_code + ''}
+ onDone={code => FlowActions.update(flow, { response: { code: parseInt(code) } })}
+ isValid={code => /^\d+$/.test(code)}
+ inline
+ />
+ &nbsp;
+ <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>
+ )
+}