mirror of
https://github.com/Grasscutters/mitmproxy.git
synced 2024-11-22 07:08:10 +00:00
[web] FlowView and ContentView
This commit is contained in:
parent
1fc2db85fa
commit
e5bf1e930a
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
78
web/src/js/components/ContentView.jsx
Normal file
78
web/src/js/components/ContentView.jsx
Normal file
@ -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>
|
||||
)
|
||||
}
|
||||
}
|
28
web/src/js/components/ContentView/ContentErrors.jsx
Normal file
28
web/src/js/components/ContentView/ContentErrors.jsx
Normal file
@ -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>
|
||||
)
|
||||
}
|
67
web/src/js/components/ContentView/ContentLoader.jsx
Normal file
67
web/src/js/components/ContentView/ContentLoader.jsx
Normal file
@ -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>
|
||||
)
|
||||
}
|
||||
}
|
70
web/src/js/components/ContentView/ContentViews.jsx
Normal file
70
web/src/js/components/ContentView/ContentViews.jsx
Normal file
@ -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
|
28
web/src/js/components/ContentView/ViewSelector.jsx
Normal file
28
web/src/js/components/ContentView/ViewSelector.jsx
Normal file
@ -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>
|
||||
)
|
||||
}
|
@ -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 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 {
|
||||
|
||||
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>
|
||||
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)
|
||||
}
|
||||
|
||||
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(
|
||||
|
@ -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(
|
||||
|
107
web/src/js/components/FlowView.jsx
Normal file
107
web/src/js/components/FlowView.jsx
Normal file
@ -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 flow={flow}/>
|
||||
{this.state.prompt && (
|
||||
<Prompt {...this.state.prompt}/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
133
web/src/js/components/FlowView/Details.jsx
Normal file
133
web/src/js/components/FlowView/Details.jsx
Normal file
@ -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>
|
||||
)
|
||||
}
|
130
web/src/js/components/FlowView/Headers.jsx
Normal file
130
web/src/js/components/FlowView/Headers.jsx
Normal file
@ -0,0 +1,130 @@
|
||||
import React, { Component, PropTypes } from 'react'
|
||||
import ReactDOM from 'react-dom'
|
||||
import { ValueEditor } from '../editor'
|
||||
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>
|
||||
)
|
||||
}
|
||||
}
|
168
web/src/js/components/FlowView/Messages.jsx
Normal file
168
web/src/js/components/FlowView/Messages.jsx
Normal file
@ -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 '../editor'
|
||||
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>
|
||||
)
|
||||
}
|
57
web/src/js/components/FlowView/Nav.jsx
Normal file
57
web/src/js/components/FlowView/Nav.jsx
Normal file
@ -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>
|
||||
)
|
||||
}
|
@ -5,7 +5,7 @@ import { Query } from '../actions.js'
|
||||
import { Key } from '../utils.js'
|
||||
import { Splitter } from './common.js'
|
||||
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 {
|
||||
|
@ -1,14 +1,13 @@
|
||||
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 { Splitter } from "./common.js"
|
||||
import Header from "./Header"
|
||||
import EventLog from "./EventLog"
|
||||
import Footer from "./Footer"
|
||||
import { SettingsStore } from "../store/store.js"
|
||||
import { Key } from "../utils.js"
|
||||
import Header from './Header'
|
||||
import EventLog from './EventLog'
|
||||
import Footer from './Footer'
|
||||
import { SettingsStore } from '../store/store.js'
|
||||
import { Key } from '../utils.js'
|
||||
|
||||
class ProxyAppMain extends Component {
|
||||
|
||||
@ -67,7 +66,7 @@ class ProxyAppMain extends Component {
|
||||
*/
|
||||
componentDidMount() {
|
||||
this.focus()
|
||||
this.settingsStore.addListener("recalculate", this.onSettingsChange)
|
||||
this.settingsStore.addListener('recalculate', this.onSettingsChange)
|
||||
}
|
||||
|
||||
/**
|
||||
@ -76,7 +75,7 @@ class ProxyAppMain extends Component {
|
||||
* @todo stop listening to window's key events
|
||||
*/
|
||||
componentWillUnmount() {
|
||||
this.settingsStore.removeListener("recalculate", this.onSettingsChange)
|
||||
this.settingsStore.removeListener('recalculate', this.onSettingsChange)
|
||||
}
|
||||
|
||||
/**
|
||||
@ -113,13 +112,13 @@ class ProxyAppMain extends Component {
|
||||
|
||||
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
|
||||
@ -134,7 +133,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()
|
||||
})
|
||||
}
|
||||
@ -151,12 +150,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>
|
||||
)
|
||||
|
@ -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;
|
@ -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;
|
@ -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;
|
@ -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>
|
||||
);
|
||||
}
|
||||
});
|
@ -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;
|
Loading…
Reference in New Issue
Block a user