mirror of
https://github.com/Grasscutters/mitmproxy.git
synced 2024-11-21 22:58:24 +00:00
Merge pull request #4779 from mhils/mitmweb-tcp
web: add support for viewing tcp flows
This commit is contained in:
parent
550e1a4ab3
commit
c0fd6cfc09
@ -96,10 +96,10 @@ def flow_to_json(flow: mitmproxy.flow.Flow) -> dict:
|
||||
if flow.error:
|
||||
f["error"] = flow.error.get_state()
|
||||
|
||||
if isinstance(flow, http.HTTPFlow):
|
||||
if isinstance(flow, HTTPFlow):
|
||||
content_length: Optional[int]
|
||||
content_hash: Optional[str]
|
||||
if flow.request:
|
||||
|
||||
if flow.request.raw_content is not None:
|
||||
content_length = len(flow.request.raw_content)
|
||||
content_hash = hashlib.sha256(flow.request.raw_content).hexdigest()
|
||||
@ -118,7 +118,6 @@ def flow_to_json(flow: mitmproxy.flow.Flow) -> dict:
|
||||
"contentHash": content_hash,
|
||||
"timestamp_start": flow.request.timestamp_start,
|
||||
"timestamp_end": flow.request.timestamp_end,
|
||||
"is_replay": flow.is_replay == "request", # TODO: remove, use flow.is_replay instead.
|
||||
"pretty_host": flow.request.pretty_host,
|
||||
}
|
||||
if flow.response:
|
||||
@ -137,7 +136,6 @@ def flow_to_json(flow: mitmproxy.flow.Flow) -> dict:
|
||||
"contentHash": content_hash,
|
||||
"timestamp_start": flow.response.timestamp_start,
|
||||
"timestamp_end": flow.response.timestamp_end,
|
||||
"is_replay": flow.is_replay == "response", # TODO: remove, use flow.is_replay instead.
|
||||
}
|
||||
if flow.response.data.trailers:
|
||||
f["response"]["trailers"] = tuple(flow.response.data.trailers.items(True))
|
||||
@ -145,6 +143,7 @@ def flow_to_json(flow: mitmproxy.flow.Flow) -> dict:
|
||||
if flow.websocket:
|
||||
f["websocket"] = {
|
||||
"messages_meta": {
|
||||
"contentLength": sum(len(x.content) for x in flow.websocket.messages),
|
||||
"count": len(flow.websocket.messages),
|
||||
"timestamp_last": flow.websocket.messages[-1].timestamp if flow.websocket.messages else None,
|
||||
},
|
||||
@ -153,6 +152,12 @@ def flow_to_json(flow: mitmproxy.flow.Flow) -> dict:
|
||||
"close_reason": flow.websocket.close_reason,
|
||||
"timestamp_end": flow.websocket.timestamp_end,
|
||||
}
|
||||
elif isinstance(flow, TCPFlow):
|
||||
f["messages_meta"] = {
|
||||
"contentLength": sum(len(x.content) for x in flow.messages),
|
||||
"count": len(flow.messages),
|
||||
"timestamp_last": flow.messages[-1].timestamp if flow.messages else None,
|
||||
}
|
||||
|
||||
return f
|
||||
|
||||
|
2
mitmproxy/tools/web/static/app.css
vendored
2
mitmproxy/tools/web/static/app.css
vendored
File diff suppressed because one or more lines are too long
86
mitmproxy/tools/web/static/app.js
vendored
86
mitmproxy/tools/web/static/app.js
vendored
File diff suppressed because one or more lines are too long
BIN
mitmproxy/tools/web/static/images/resourceTcpIcon.png
vendored
Normal file
BIN
mitmproxy/tools/web/static/images/resourceTcpIcon.png
vendored
Normal file
Binary file not shown.
After Width: | Height: | Size: 1.2 KiB |
Binary file not shown.
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 1.4 KiB |
@ -1,9 +1,8 @@
|
||||
import asyncio
|
||||
import io
|
||||
import json
|
||||
import json as _json
|
||||
import logging
|
||||
import re
|
||||
import textwrap
|
||||
import typing
|
||||
from contextlib import redirect_stdout
|
||||
from pathlib import Path
|
||||
@ -33,39 +32,43 @@ def no_tornado_logging():
|
||||
|
||||
|
||||
def get_json(resp: httpclient.HTTPResponse):
|
||||
return _json.loads(resp.body.decode())
|
||||
return json.loads(resp.body.decode())
|
||||
|
||||
|
||||
def test_generate_tflow_js(tdata):
|
||||
tf = tflow.tflow(resp=True, err=True, ws=True)
|
||||
tf.server_conn.certificate_list = [
|
||||
tf_http = tflow.tflow(resp=True, err=True, ws=True)
|
||||
tf_http.id = "d91165be-ca1f-4612-88a9-c0f8696f3e29"
|
||||
tf_http.client_conn.id = "4a18d1a0-50a1-48dd-9aa6-d45d74282939"
|
||||
tf_http.server_conn.id = "f087e7b2-6d0a-41a8-a8f0-e1a4761395f8"
|
||||
tf_http.server_conn.certificate_list = [
|
||||
certs.Cert.from_pem(
|
||||
Path(tdata.path("mitmproxy/net/data/verificationcerts/self-signed.pem")).read_bytes()
|
||||
)
|
||||
]
|
||||
tf.request.trailers = Headers(trailer="qvalue")
|
||||
tf.response.trailers = Headers(trailer="qvalue")
|
||||
tf_http.request.trailers = Headers(trailer="qvalue")
|
||||
tf_http.response.trailers = Headers(trailer="qvalue")
|
||||
|
||||
_tflow = app.flow_to_json(tf)
|
||||
# Set some value as constant, so that _tflow.js would not change every time.
|
||||
_tflow['id'] = "d91165be-ca1f-4612-88a9-c0f8696f3e29"
|
||||
_tflow['client_conn']['id'] = "4a18d1a0-50a1-48dd-9aa6-d45d74282939"
|
||||
_tflow['server_conn']['id'] = "f087e7b2-6d0a-41a8-a8f0-e1a4761395f8"
|
||||
tflow_json = _json.dumps(_tflow, indent=4, sort_keys=True)
|
||||
|
||||
tflow_json = re.sub(
|
||||
r'( {8}"(address|is_replay|alpn_proto_negotiated)":)',
|
||||
r" //@ts-ignore\n\1",
|
||||
tflow_json
|
||||
).replace(": null", ": undefined")
|
||||
tf_tcp = tflow.ttcpflow(err=True)
|
||||
tf_tcp.id = "2ea7012b-21b5-4f8f-98cd-d49819954001"
|
||||
tf_tcp.client_conn.id = "8be32b99-a0b3-446e-93bc-b29982fe1322"
|
||||
tf_tcp.server_conn.id = "e33bb2cd-c07e-4214-9a8e-3a8f85f25200"
|
||||
|
||||
# language=TypeScript
|
||||
content = (
|
||||
"/** Auto-generated by test_app.py:test_generate_tflow_js */\n"
|
||||
"import {HTTPFlow} from '../../flow';\n"
|
||||
"export default function(): Required<HTTPFlow> {\n"
|
||||
f" return {tflow_json}\n"
|
||||
"}"
|
||||
"import {HTTPFlow, TCPFlow} from '../../flow';\n"
|
||||
"export function THTTPFlow(): Required<HTTPFlow> {\n"
|
||||
" return %s\n"
|
||||
"}\n"
|
||||
"export function TTCPFlow(): Required<TCPFlow> {\n"
|
||||
" return %s\n"
|
||||
"}" % (
|
||||
textwrap.indent(json.dumps(app.flow_to_json(tf_http), indent=4, sort_keys=True), " "),
|
||||
textwrap.indent(json.dumps(app.flow_to_json(tf_tcp), indent=4, sort_keys=True), " "),
|
||||
)
|
||||
)
|
||||
content = content.replace(": null", ": undefined")
|
||||
|
||||
(Path(__file__).parent / "../../../../web/src/js/__tests__/ducks/_tflow.ts").write_bytes(
|
||||
content.encode()
|
||||
)
|
||||
@ -145,7 +148,7 @@ class TestApp(tornado.testing.AsyncHTTPTestCase):
|
||||
return self.fetch(
|
||||
url,
|
||||
method="PUT",
|
||||
body=_json.dumps(data),
|
||||
body=json.dumps(data),
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
|
||||
@ -413,7 +416,7 @@ class TestApp(tornado.testing.AsyncHTTPTestCase):
|
||||
self.master.options.anticomp = True
|
||||
|
||||
r1 = yield ws_client.read_message()
|
||||
response = _json.loads(r1)
|
||||
response = json.loads(r1)
|
||||
assert response == {
|
||||
"resource": "options",
|
||||
"cmd": "update",
|
||||
|
@ -64,7 +64,7 @@ function scripts_prod() {
|
||||
return esbuild(false);
|
||||
}
|
||||
|
||||
const copy_src = ["src/images/**", "src/fonts/fontawesome-webfont.*"];
|
||||
const copy_src = ["src/images/**", "src/fonts/fontawesome-webfont.*", "!**/*.psd"];
|
||||
|
||||
function copy() {
|
||||
return gulp.src(copy_src, {base: "src/"})
|
||||
|
@ -184,16 +184,12 @@
|
||||
border-radius: 16px;
|
||||
text-align: center;
|
||||
|
||||
&:hover, &.open {
|
||||
&:hover {
|
||||
background-color: rgba(0, 0, 0, 5%);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.col-quickactions .fa-ellipsis-h {
|
||||
transform: translate(0, 3px);
|
||||
}
|
||||
|
||||
.col-quickactions .fa-play {
|
||||
transform: translate(1px, 2px);
|
||||
}
|
||||
|
@ -48,3 +48,7 @@
|
||||
.resource-icon-websocket {
|
||||
background-image: url(images/resourceWebSocketIcon.png);
|
||||
}
|
||||
|
||||
.resource-icon-tcp {
|
||||
background-image: url(images/resourceTcpIcon.png);
|
||||
}
|
||||
|
BIN
web/src/images/resourceIcon.psd
Normal file
BIN
web/src/images/resourceIcon.psd
Normal file
Binary file not shown.
BIN
web/src/images/resourceTcpIcon.png
Normal file
BIN
web/src/images/resourceTcpIcon.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 1.2 KiB |
Binary file not shown.
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 1.4 KiB |
@ -1,7 +1,7 @@
|
||||
import * as React from "react"
|
||||
import renderer from 'react-test-renderer'
|
||||
import FlowColumns from '../../../components/FlowTable/FlowColumns'
|
||||
import {TFlow} from '../../ducks/tutils'
|
||||
import {TFlow, TTCPFlow} from '../../ducks/tutils'
|
||||
import {render} from "../../test-utils";
|
||||
|
||||
test("should render columns", async () => {
|
||||
@ -19,8 +19,13 @@ test("should render columns", async () => {
|
||||
|
||||
describe('Flowcolumns Components', () => {
|
||||
it('should render IconColumn', () => {
|
||||
let tflow = {...TFlow(), websocket: undefined},
|
||||
iconColumn = renderer.create(<FlowColumns.icon flow={tflow}/>),
|
||||
let tcpflow = TTCPFlow(),
|
||||
iconColumn = renderer.create(<FlowColumns.icon flow={tcpflow}/>),
|
||||
tree = iconColumn.toJSON()
|
||||
expect(tree).toMatchSnapshot()
|
||||
|
||||
let tflow = {...TFlow(), websocket: undefined};
|
||||
iconColumn = renderer.create(<FlowColumns.icon flow={tflow}/>)
|
||||
tree = iconColumn.toJSON()
|
||||
// plain
|
||||
expect(tree).toMatchSnapshot()
|
||||
|
@ -5,7 +5,7 @@ exports[`Flowcolumns Components should render IconColumn 1`] = `
|
||||
className="col-icon"
|
||||
>
|
||||
<div
|
||||
className="resource-icon resource-icon-plain"
|
||||
className="resource-icon resource-icon-tcp"
|
||||
/>
|
||||
</td>
|
||||
`;
|
||||
@ -15,7 +15,7 @@ exports[`Flowcolumns Components should render IconColumn 2`] = `
|
||||
className="col-icon"
|
||||
>
|
||||
<div
|
||||
className="resource-icon resource-icon-not-modified"
|
||||
className="resource-icon resource-icon-plain"
|
||||
/>
|
||||
</td>
|
||||
`;
|
||||
@ -25,7 +25,7 @@ exports[`Flowcolumns Components should render IconColumn 3`] = `
|
||||
className="col-icon"
|
||||
>
|
||||
<div
|
||||
className="resource-icon resource-icon-redirect"
|
||||
className="resource-icon resource-icon-not-modified"
|
||||
/>
|
||||
</td>
|
||||
`;
|
||||
@ -35,7 +35,7 @@ exports[`Flowcolumns Components should render IconColumn 4`] = `
|
||||
className="col-icon"
|
||||
>
|
||||
<div
|
||||
className="resource-icon resource-icon-image"
|
||||
className="resource-icon resource-icon-redirect"
|
||||
/>
|
||||
</td>
|
||||
`;
|
||||
@ -45,7 +45,7 @@ exports[`Flowcolumns Components should render IconColumn 5`] = `
|
||||
className="col-icon"
|
||||
>
|
||||
<div
|
||||
className="resource-icon resource-icon-js"
|
||||
className="resource-icon resource-icon-image"
|
||||
/>
|
||||
</td>
|
||||
`;
|
||||
@ -55,7 +55,7 @@ exports[`Flowcolumns Components should render IconColumn 6`] = `
|
||||
className="col-icon"
|
||||
>
|
||||
<div
|
||||
className="resource-icon resource-icon-css"
|
||||
className="resource-icon resource-icon-js"
|
||||
/>
|
||||
</td>
|
||||
`;
|
||||
@ -65,7 +65,7 @@ exports[`Flowcolumns Components should render IconColumn 7`] = `
|
||||
className="col-icon"
|
||||
>
|
||||
<div
|
||||
className="resource-icon resource-icon-document"
|
||||
className="resource-icon resource-icon-css"
|
||||
/>
|
||||
</td>
|
||||
`;
|
||||
@ -75,7 +75,7 @@ exports[`Flowcolumns Components should render IconColumn 8`] = `
|
||||
className="col-icon"
|
||||
>
|
||||
<div
|
||||
className="resource-icon resource-icon-plain"
|
||||
className="resource-icon resource-icon-document"
|
||||
/>
|
||||
</td>
|
||||
`;
|
||||
@ -90,6 +90,16 @@ exports[`Flowcolumns Components should render IconColumn 9`] = `
|
||||
</td>
|
||||
`;
|
||||
|
||||
exports[`Flowcolumns Components should render IconColumn 10`] = `
|
||||
<td
|
||||
className="col-icon"
|
||||
>
|
||||
<div
|
||||
className="resource-icon resource-icon-websocket"
|
||||
/>
|
||||
</td>
|
||||
`;
|
||||
|
||||
exports[`Flowcolumns Components should render TimeColumn 1`] = `
|
||||
<td
|
||||
className="col-time"
|
||||
@ -205,24 +215,7 @@ exports[`should render columns: quickactions 1`] = `
|
||||
<td
|
||||
class="col-quickactions"
|
||||
>
|
||||
<div>
|
||||
<a
|
||||
class="quickaction"
|
||||
href="#"
|
||||
>
|
||||
<i
|
||||
class="fa fa-fw fa-repeat text-primary"
|
||||
/>
|
||||
</a>
|
||||
<a
|
||||
class="quickaction"
|
||||
href="#"
|
||||
>
|
||||
<i
|
||||
class="fa fa-fw fa-ellipsis-h text-muted"
|
||||
/>
|
||||
</a>
|
||||
</div>
|
||||
<div />
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
@ -238,7 +231,7 @@ exports[`should render columns: size 1`] = `
|
||||
<td
|
||||
class="col-size"
|
||||
>
|
||||
14b
|
||||
43b
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
|
@ -45,7 +45,7 @@ exports[`FlowRow 1`] = `
|
||||
<td
|
||||
class="col-size"
|
||||
>
|
||||
14b
|
||||
43b
|
||||
</td>
|
||||
<td
|
||||
class="col-time"
|
||||
@ -64,14 +64,6 @@ exports[`FlowRow 1`] = `
|
||||
class="fa fa-fw fa-play text-success"
|
||||
/>
|
||||
</a>
|
||||
<a
|
||||
class="quickaction"
|
||||
href="#"
|
||||
>
|
||||
<i
|
||||
class="fa fa-fw fa-ellipsis-h text-muted"
|
||||
/>
|
||||
</a>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
|
@ -1,6 +1,7 @@
|
||||
import * as React from "react"
|
||||
import {render, screen, waitFor} from "../test-utils";
|
||||
import {render, screen} from "../test-utils";
|
||||
import FlowView from "../../components/FlowView";
|
||||
import * as flowActions from "../../ducks/flows"
|
||||
import fetchMock, {enableFetchMocks} from "jest-fetch-mock";
|
||||
import {fireEvent} from "@testing-library/react";
|
||||
|
||||
@ -9,7 +10,7 @@ enableFetchMocks();
|
||||
test("FlowView", async () => {
|
||||
fetchMock.mockReject(new Error("backend missing"));
|
||||
|
||||
const {asFragment} = render(<FlowView/>);
|
||||
const {asFragment, store} = render(<FlowView/>);
|
||||
expect(asFragment()).toMatchSnapshot();
|
||||
|
||||
fireEvent.click(screen.getByText("Response"));
|
||||
@ -26,4 +27,11 @@ test("FlowView", async () => {
|
||||
|
||||
fireEvent.click(screen.getByText("Error"));
|
||||
expect(asFragment()).toMatchSnapshot();
|
||||
|
||||
store.dispatch(flowActions.select(store.getState().flows.list[2].id));
|
||||
|
||||
fireEvent.click(screen.getByText("TCP Messages"));
|
||||
expect(asFragment()).toMatchSnapshot();
|
||||
|
||||
fireEvent.click(screen.getByText("Error"));
|
||||
});
|
||||
|
@ -49,6 +49,20 @@ exports[`FlowMenu 1`] = `
|
||||
/>
|
||||
Delete
|
||||
</button>
|
||||
<a
|
||||
class=""
|
||||
href="#"
|
||||
>
|
||||
<button
|
||||
class="btn btn-default"
|
||||
title="mark flow"
|
||||
>
|
||||
<i
|
||||
class="fa fa-paint-brush text-success"
|
||||
/>
|
||||
Mark▾
|
||||
</button>
|
||||
</a>
|
||||
</div>
|
||||
<div
|
||||
class="menu-legend"
|
||||
|
@ -26,7 +26,7 @@ exports[`MainMenu 1`] = `
|
||||
class="form-control"
|
||||
placeholder="Search"
|
||||
type="text"
|
||||
value="~u /second"
|
||||
value="~u /second | ~tcp"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
|
@ -898,6 +898,32 @@ exports[`FlowView 5`] = `
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
Server conn. closed:
|
||||
</td>
|
||||
<td>
|
||||
1999-12-31 23:00:05.000
|
||||
<span
|
||||
class="text-muted"
|
||||
>
|
||||
(5s)
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
Client conn. closed:
|
||||
</td>
|
||||
<td>
|
||||
1999-12-31 23:00:06.000
|
||||
<span
|
||||
class="text-muted"
|
||||
>
|
||||
(6s)
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
@ -967,3 +993,76 @@ exports[`FlowView 6`] = `
|
||||
</div>
|
||||
</DocumentFragment>
|
||||
`;
|
||||
|
||||
exports[`FlowView 7`] = `
|
||||
<DocumentFragment>
|
||||
<div
|
||||
class="flow-detail"
|
||||
>
|
||||
<nav
|
||||
class="nav-tabs nav-tabs-sm"
|
||||
>
|
||||
<a
|
||||
class="active"
|
||||
href="#"
|
||||
>
|
||||
TCP Messages
|
||||
</a>
|
||||
<a
|
||||
class=""
|
||||
href="#"
|
||||
>
|
||||
Error
|
||||
</a>
|
||||
<a
|
||||
class=""
|
||||
href="#"
|
||||
>
|
||||
Connection
|
||||
</a>
|
||||
<a
|
||||
class=""
|
||||
href="#"
|
||||
>
|
||||
Timing
|
||||
</a>
|
||||
</nav>
|
||||
<section
|
||||
class="tcp"
|
||||
>
|
||||
<h4>
|
||||
TCP Data
|
||||
</h4>
|
||||
<div
|
||||
class="contentview"
|
||||
>
|
||||
<div
|
||||
class="controls"
|
||||
>
|
||||
<h5>
|
||||
2 Messages
|
||||
</h5>
|
||||
<a
|
||||
class="btn btn-default btn-xs"
|
||||
href="#"
|
||||
>
|
||||
<span>
|
||||
<i
|
||||
class="fa fa-fw fa-files-o"
|
||||
/>
|
||||
|
||||
<b>
|
||||
View:
|
||||
</b>
|
||||
auto
|
||||
<span
|
||||
class="caret"
|
||||
/>
|
||||
</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</DocumentFragment>
|
||||
`;
|
||||
|
@ -84,6 +84,20 @@ exports[`Header 1`] = `
|
||||
/>
|
||||
Delete
|
||||
</button>
|
||||
<a
|
||||
class=""
|
||||
href="#"
|
||||
>
|
||||
<button
|
||||
class="btn btn-default"
|
||||
title="mark flow"
|
||||
>
|
||||
<i
|
||||
class="fa fa-paint-brush text-success"
|
||||
/>
|
||||
Mark▾
|
||||
</button>
|
||||
</a>
|
||||
</div>
|
||||
<div
|
||||
class="menu-legend"
|
||||
|
@ -1,6 +1,6 @@
|
||||
/** Auto-generated by test_app.py:test_generate_tflow_js */
|
||||
import {HTTPFlow} from '../../flow';
|
||||
export default function(): Required<HTTPFlow> {
|
||||
import {HTTPFlow, TCPFlow} from '../../flow';
|
||||
export function THTTPFlow(): Required<HTTPFlow> {
|
||||
return {
|
||||
"client_conn": {
|
||||
"alpn": "http/1.1",
|
||||
@ -46,8 +46,6 @@ export default function(): Required<HTTPFlow> {
|
||||
],
|
||||
"host": "address",
|
||||
"http_version": "HTTP/1.1",
|
||||
//@ts-ignore
|
||||
"is_replay": false,
|
||||
"method": "GET",
|
||||
"path": "/path",
|
||||
"port": 22,
|
||||
@ -70,8 +68,6 @@ export default function(): Required<HTTPFlow> {
|
||||
]
|
||||
],
|
||||
"http_version": "HTTP/1.1",
|
||||
//@ts-ignore
|
||||
"is_replay": false,
|
||||
"reason": "OK",
|
||||
"status_code": 200,
|
||||
"timestamp_end": 946681203,
|
||||
@ -84,7 +80,6 @@ export default function(): Required<HTTPFlow> {
|
||||
]
|
||||
},
|
||||
"server_conn": {
|
||||
//@ts-ignore
|
||||
"address": [
|
||||
"address",
|
||||
22
|
||||
@ -155,6 +150,7 @@ export default function(): Required<HTTPFlow> {
|
||||
"close_reason": "Close Reason",
|
||||
"closed_by_client": false,
|
||||
"messages_meta": {
|
||||
"contentLength": 29,
|
||||
"count": 3,
|
||||
"timestamp_last": 946681205
|
||||
},
|
||||
@ -162,3 +158,67 @@ export default function(): Required<HTTPFlow> {
|
||||
}
|
||||
}
|
||||
}
|
||||
export function TTCPFlow(): Required<TCPFlow> {
|
||||
return {
|
||||
"client_conn": {
|
||||
"alpn": "http/1.1",
|
||||
"cert": undefined,
|
||||
"cipher": "cipher",
|
||||
"id": "8be32b99-a0b3-446e-93bc-b29982fe1322",
|
||||
"peername": [
|
||||
"127.0.0.1",
|
||||
22
|
||||
],
|
||||
"sni": "address",
|
||||
"sockname": [
|
||||
"",
|
||||
0
|
||||
],
|
||||
"timestamp_end": 946681206,
|
||||
"timestamp_start": 946681200,
|
||||
"timestamp_tls_setup": 946681201,
|
||||
"tls_established": true,
|
||||
"tls_version": "TLSv1.2"
|
||||
},
|
||||
"error": {
|
||||
"msg": "error",
|
||||
"timestamp": 946681207.0
|
||||
},
|
||||
"id": "2ea7012b-21b5-4f8f-98cd-d49819954001",
|
||||
"intercepted": false,
|
||||
"is_replay": undefined,
|
||||
"marked": "",
|
||||
"messages_meta": {
|
||||
"contentLength": 12,
|
||||
"count": 2,
|
||||
"timestamp_last": 1629806221.58518
|
||||
},
|
||||
"modified": false,
|
||||
"server_conn": {
|
||||
"address": [
|
||||
"address",
|
||||
22
|
||||
],
|
||||
"alpn": undefined,
|
||||
"cert": undefined,
|
||||
"cipher": undefined,
|
||||
"id": "e33bb2cd-c07e-4214-9a8e-3a8f85f25200",
|
||||
"peername": [
|
||||
"192.168.0.1",
|
||||
22
|
||||
],
|
||||
"sni": "address",
|
||||
"sockname": [
|
||||
"address",
|
||||
22
|
||||
],
|
||||
"timestamp_end": 946681205,
|
||||
"timestamp_start": 946681202,
|
||||
"timestamp_tcp_setup": 946681203,
|
||||
"timestamp_tls_setup": 946681204,
|
||||
"tls_established": true,
|
||||
"tls_version": "TLSv1.2"
|
||||
},
|
||||
"type": "tcp"
|
||||
}
|
||||
}
|
@ -1,18 +1,19 @@
|
||||
import thunk from 'redux-thunk'
|
||||
import configureStore, {MockStoreCreator, MockStoreEnhanced} from 'redux-mock-store'
|
||||
import {ConnectionState} from '../../ducks/connection'
|
||||
import TFlow from './_tflow'
|
||||
import {THTTPFlow, TTCPFlow} from './_tflow'
|
||||
import {AppDispatch, RootState} from "../../ducks";
|
||||
import {HTTPFlow} from "../../flow";
|
||||
import {HTTPFlow, TCPFlow} from "../../flow";
|
||||
import {defaultState as defaultConf} from "../../ducks/conf"
|
||||
import {defaultState as defaultOptions} from "../../ducks/options"
|
||||
|
||||
const mockStoreCreator: MockStoreCreator<RootState, AppDispatch> = configureStore([thunk])
|
||||
|
||||
export {TFlow}
|
||||
export {THTTPFlow as TFlow, TTCPFlow}
|
||||
|
||||
const tflow1: HTTPFlow = TFlow();
|
||||
const tflow2: HTTPFlow = TFlow();
|
||||
const tflow1: HTTPFlow = THTTPFlow();
|
||||
const tflow2: HTTPFlow = THTTPFlow();
|
||||
const tflow3: TCPFlow = TTCPFlow();
|
||||
tflow1.modified = true
|
||||
tflow1.intercepted = true
|
||||
tflow2.id = "flow2";
|
||||
@ -71,17 +72,17 @@ export const testState: RootState = {
|
||||
options: defaultOptions,
|
||||
flows: {
|
||||
selected: [tflow2.id],
|
||||
byId: {[tflow1.id]: tflow1, [tflow2.id]: tflow2},
|
||||
filter: '~u /second',
|
||||
byId: {[tflow1.id]: tflow1, [tflow2.id]: tflow2, [tflow3.id]: tflow3},
|
||||
filter: '~u /second | ~tcp',
|
||||
highlight: '~u /path',
|
||||
sort: {
|
||||
desc: true,
|
||||
column: "path"
|
||||
},
|
||||
view: [tflow2],
|
||||
list: [tflow1, tflow2],
|
||||
listIndex: {[tflow1.id]: 0, [tflow2.id]: 1},
|
||||
viewIndex: {[tflow2.id]: 0},
|
||||
view: [tflow2, tflow3],
|
||||
list: [tflow1, tflow2, tflow3],
|
||||
listIndex: {[tflow1.id]: 0, [tflow2.id]: 1, [tflow3.id]: 2},
|
||||
viewIndex: {[tflow2.id]: 0, [tflow3.id]: 1},
|
||||
},
|
||||
connection: {
|
||||
state: ConnectionState.ESTABLISHED
|
||||
|
@ -12,7 +12,7 @@ describe('onKeyDown', () => {
|
||||
for (let i = 1; i <= 12; i++) {
|
||||
flows = reduceFlows(flows, {
|
||||
type: flowsActions.ADD,
|
||||
data: {id: i + "", request: true, response: true},
|
||||
data: {id: i + "", request: true, response: true, type: "http"},
|
||||
cmd: 'add'
|
||||
})
|
||||
}
|
||||
|
@ -1,13 +1,10 @@
|
||||
import React, {useState} from 'react'
|
||||
import React, {ReactElement, useState} from 'react'
|
||||
import {useDispatch} from 'react-redux'
|
||||
import classnames from 'classnames'
|
||||
import {endTime, getTotalSize, RequestUtils, ResponseUtils, startTime} from '../../flow/utils'
|
||||
import {canReplay, endTime, getTotalSize, RequestUtils, ResponseUtils, startTime} from '../../flow/utils'
|
||||
import {formatSize, formatTimeDelta, formatTimeStamp} from '../../utils'
|
||||
import * as flowActions from "../../ducks/flows";
|
||||
import {addInterceptFilter} from "../../ducks/options"
|
||||
import Dropdown, {MenuItem, SubMenu} from "../common/Dropdown";
|
||||
import {Flow} from "../../flow";
|
||||
import {copy} from "../../flow/export";
|
||||
|
||||
|
||||
type FlowColumnProps = {
|
||||
@ -40,12 +37,15 @@ icon.headerName = ''
|
||||
icon.sortKey = flow => getIcon(flow)
|
||||
|
||||
const getIcon = (flow: Flow): string => {
|
||||
if (flow.type !== "http" || !flow.response) {
|
||||
return 'resource-icon-plain'
|
||||
if (flow.type === "tcp") {
|
||||
return "resource-icon-tcp"
|
||||
}
|
||||
if (flow.websocket) {
|
||||
return 'resource-icon-websocket'
|
||||
}
|
||||
if (!flow.response) {
|
||||
return 'resource-icon-plain'
|
||||
}
|
||||
|
||||
var contentType = ResponseUtils.getContentType(flow.response) || ''
|
||||
|
||||
@ -71,6 +71,15 @@ const getIcon = (flow: Flow): string => {
|
||||
return 'resource-icon-plain'
|
||||
}
|
||||
|
||||
const mainPath = (flow: Flow): string => {
|
||||
switch (flow.type) {
|
||||
case "http":
|
||||
return RequestUtils.pretty_url(flow.request)
|
||||
case "tcp":
|
||||
return `${flow.client_conn.peername.join(':')} ↔ ${flow.server_conn?.address?.join(':')}`
|
||||
}
|
||||
}
|
||||
|
||||
export const path: FlowColumn = ({flow}) => {
|
||||
let err;
|
||||
if (flow.error) {
|
||||
@ -90,20 +99,20 @@ export const path: FlowColumn = ({flow}) => {
|
||||
)}
|
||||
{err}
|
||||
<span className="marker pull-right">{flow.marked}</span>
|
||||
{flow.type === "http" ? RequestUtils.pretty_url(flow.request) : null}
|
||||
{mainPath(flow)}
|
||||
</td>
|
||||
)
|
||||
};
|
||||
path.headerName = 'Path'
|
||||
path.sortKey = flow => flow.type === "http" && RequestUtils.pretty_url(flow.request)
|
||||
path.sortKey = flow => mainPath(flow)
|
||||
|
||||
export const method: FlowColumn = ({flow}) => {
|
||||
return (
|
||||
<td className="col-method">{flow.type === "http" ? flow.request.method : flow.type.toLowerCase()}</td>
|
||||
<td className="col-method">{flow.type === "http" ? flow.request.method : flow.type.toUpperCase()}</td>
|
||||
)
|
||||
};
|
||||
method.headerName = 'Method'
|
||||
method.sortKey = flow => flow.type === "http" && flow.request.method
|
||||
method.sortKey = flow => flow.type === "http" ? flow.request.method : flow.type.toUpperCase()
|
||||
|
||||
export const status: FlowColumn = ({flow}) => {
|
||||
let color = 'darkred';
|
||||
@ -186,76 +195,22 @@ export const quickactions: FlowColumn = ({flow}) => {
|
||||
const dispatch = useDispatch()
|
||||
let [open, setOpen] = useState(false)
|
||||
|
||||
let resume_or_replay: React.ReactNode | null = null;
|
||||
let resume_or_replay: ReactElement | null = null;
|
||||
if (flow.intercepted) {
|
||||
resume_or_replay = <a href="#" className="quickaction" onClick={() => dispatch(flowActions.resume(flow))}>
|
||||
<i className="fa fa-fw fa-play text-success"/>
|
||||
</a>;
|
||||
} else {
|
||||
} else if (canReplay(flow)) {
|
||||
resume_or_replay = <a href="#" className="quickaction" onClick={() => dispatch(flowActions.replay(flow))}>
|
||||
<i className="fa fa-fw fa-repeat text-primary"/>
|
||||
</a>;
|
||||
}
|
||||
|
||||
if (flow.type !== "http")
|
||||
return <td className="col-quickactions"/>
|
||||
|
||||
const filt = (x) => dispatch(addInterceptFilter(x));
|
||||
const ct = flow.response && ResponseUtils.getContentType(flow.response);
|
||||
|
||||
return (
|
||||
<td className={classnames("col-quickactions", {hover: open})} onClick={() => 0}>
|
||||
<div>
|
||||
{resume_or_replay}
|
||||
<Dropdown text={<i className="fa fa-fw fa-ellipsis-h text-muted"/>} className="quickaction"
|
||||
onOpen={setOpen}
|
||||
options={{placement: "bottom-end"}}>
|
||||
<SubMenu title="Copy...">
|
||||
<MenuItem onClick={() => copy(flow, "raw_request")}>Copy raw request</MenuItem>
|
||||
<MenuItem onClick={() => copy(flow, "raw_response")}>Copy raw response</MenuItem>
|
||||
<MenuItem onClick={() => copy(flow, "raw")}>Copy raw request and response</MenuItem>
|
||||
<MenuItem onClick={() => copy(flow, "curl")}>Copy as cURL</MenuItem>
|
||||
<MenuItem onClick={() => copy(flow, "httpie")}>Copy as HTTPie</MenuItem>
|
||||
</SubMenu>
|
||||
<SubMenu title="Mark..." className="markers-menu">
|
||||
<MenuItem onClick={() => dispatch(flowActions.update(flow, {marked: ""}))}>⚪ (no marker)</MenuItem>
|
||||
{Object.entries(markers).map(([name, sym]) =>
|
||||
<MenuItem
|
||||
key={name}
|
||||
onClick={() => dispatch(flowActions.update(flow, {marked: name}))}>
|
||||
{sym} {name.replace(/[:_]/g, " ")}
|
||||
</MenuItem>
|
||||
)}
|
||||
</SubMenu>
|
||||
<SubMenu title="Intercept requests like this">
|
||||
<MenuItem onClick={() => filt(`~q ${flow.request.host}`)}>
|
||||
Requests to {flow.request.host}
|
||||
</MenuItem>
|
||||
{flow.request.path !== "/" &&
|
||||
<MenuItem onClick={() => filt(`~q ${flow.request.host}${flow.request.path}`)}>
|
||||
Requests to {flow.request.host + flow.request.path}
|
||||
</MenuItem>}
|
||||
{flow.request.method !== "GET" &&
|
||||
<MenuItem onClick={() => filt(`~q ~m ${flow.request.method} ${flow.request.host}`)}>
|
||||
{flow.request.method} requests to {flow.request.host}
|
||||
</MenuItem>}
|
||||
</SubMenu>
|
||||
<SubMenu title="Intercept responses like this">
|
||||
<MenuItem onClick={() => filt(`~s ${flow.request.host}`)}>
|
||||
Responses from {flow.request.host}
|
||||
</MenuItem>
|
||||
{flow.request.path !== "/" &&
|
||||
<MenuItem onClick={() => filt(`~s ${flow.request.host}${flow.request.path}`)}>
|
||||
Responses from {flow.request.host + flow.request.path}
|
||||
</MenuItem>}
|
||||
{!!ct &&
|
||||
<MenuItem onClick={() => filt(`~ts ${ct}`)}>
|
||||
Responses with a {ct} content type.
|
||||
</MenuItem>}
|
||||
</SubMenu>
|
||||
</Dropdown>
|
||||
</div>
|
||||
|
||||
</td>
|
||||
)
|
||||
}
|
||||
|
@ -19,7 +19,9 @@ export default React.memo(function FlowTableHead() {
|
||||
{displayColumns.map(Column => (
|
||||
<th className={classnames(`col-${Column.name}`, sortColumn === Column.name && sortType)}
|
||||
key={Column.name}
|
||||
onClick={() => dispatch(setSort(Column.name, Column.name !== sortColumn ? false : !sortDesc))}>
|
||||
onClick={() => dispatch(setSort(
|
||||
Column.name === sortColumn && sortDesc ? undefined : Column.name,
|
||||
Column.name !== sortColumn ? false : !sortDesc))}>
|
||||
{Column.headerName}
|
||||
</th>
|
||||
))}
|
||||
|
@ -10,6 +10,7 @@ import {selectTab} from '../ducks/ui/flow'
|
||||
import {useAppDispatch, useAppSelector} from "../ducks";
|
||||
import {Flow} from "../flow";
|
||||
import classnames from "classnames";
|
||||
import TcpMessages from "./FlowView/TcpMessages";
|
||||
|
||||
type TabProps = {
|
||||
flow: Flow
|
||||
@ -21,11 +22,23 @@ export const allTabs: { [name: string]: FunctionComponent<TabProps> & {displayNa
|
||||
error: Error,
|
||||
connection: Connection,
|
||||
timing: Timing,
|
||||
websocket: WebSocket
|
||||
websocket: WebSocket,
|
||||
messages: TcpMessages,
|
||||
}
|
||||
|
||||
export function tabsForFlow(flow: Flow): string[] {
|
||||
const tabs = ['request', 'response', 'websocket', 'error'].filter(k => flow[k])
|
||||
let tabs;
|
||||
switch (flow.type) {
|
||||
case "http":
|
||||
tabs = ['request', 'response', 'websocket'].filter(k => flow[k])
|
||||
break
|
||||
case "tcp":
|
||||
tabs = ["messages"]
|
||||
break
|
||||
}
|
||||
|
||||
if (flow.error)
|
||||
tabs.push("error")
|
||||
tabs.push("connection")
|
||||
tabs.push("timing")
|
||||
return tabs;
|
||||
|
50
web/src/js/components/FlowView/Messages.tsx
Normal file
50
web/src/js/components/FlowView/Messages.tsx
Normal file
@ -0,0 +1,50 @@
|
||||
import {Flow, MessagesMeta} from "../../flow";
|
||||
import {useAppDispatch, useAppSelector} from "../../ducks";
|
||||
import * as React from "react";
|
||||
import {useCallback, useMemo, useState} from "react";
|
||||
import {ContentViewData, SHOW_MAX_LINES, useContent} from "../contentviews/useContent";
|
||||
import {MessageUtils} from "../../flow/utils";
|
||||
import ViewSelector from "../contentviews/ViewSelector";
|
||||
import {setContentViewFor} from "../../ducks/ui/flow";
|
||||
import {formatTimeStamp} from "../../utils";
|
||||
import LineRenderer from "../contentviews/LineRenderer";
|
||||
|
||||
type MessagesPropTypes = {
|
||||
flow: Flow
|
||||
messages_meta: MessagesMeta
|
||||
}
|
||||
|
||||
export default function Messages({flow, messages_meta}: MessagesPropTypes) {
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const contentView = useAppSelector(state => state.ui.flow.contentViewFor[flow.id + "messages"] || "Auto");
|
||||
let [maxLines, setMaxLines] = useState<number>(SHOW_MAX_LINES);
|
||||
const showMore = useCallback(() => setMaxLines(Math.max(1024, maxLines * 2)), [maxLines]);
|
||||
const content = useContent(
|
||||
MessageUtils.getContentURL(flow, "messages", contentView, maxLines + 1),
|
||||
flow.id + messages_meta.count
|
||||
);
|
||||
const messages = useMemo<ContentViewData[] | undefined>(() => content && JSON.parse(content), [content]) || [];
|
||||
|
||||
return (
|
||||
<div className="contentview">
|
||||
<div className="controls">
|
||||
<h5>{messages_meta.count} Messages</h5>
|
||||
<ViewSelector value={contentView}
|
||||
onChange={cv => dispatch(setContentViewFor(flow.id + "messages", cv))}/>
|
||||
</div>
|
||||
{messages.map((d: ContentViewData, i) => {
|
||||
const className = `fa fa-fw fa-arrow-${d.from_client ? "right text-primary" : "left text-danger"}`;
|
||||
const renderer = <div key={i}>
|
||||
<small>
|
||||
<i className={className}/>
|
||||
<span className="pull-right">{d.timestamp && formatTimeStamp(d.timestamp)}</span>
|
||||
</small>
|
||||
<LineRenderer lines={d.lines} maxLines={maxLines} showMore={showMore}/>
|
||||
</div>;
|
||||
maxLines -= d.lines.length;
|
||||
return renderer;
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
14
web/src/js/components/FlowView/TcpMessages.tsx
Normal file
14
web/src/js/components/FlowView/TcpMessages.tsx
Normal file
@ -0,0 +1,14 @@
|
||||
import {TCPFlow} from "../../flow";
|
||||
import * as React from "react";
|
||||
import Messages from "./Messages";
|
||||
|
||||
|
||||
export default function TcpMessages({flow}: { flow: TCPFlow }) {
|
||||
return (
|
||||
<section className="tcp">
|
||||
<h4>TCP Data</h4>
|
||||
<Messages flow={flow} messages_meta={flow.messages_meta}/>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
TcpMessages.displayName = "TCP Messages"
|
@ -47,6 +47,10 @@ export default function Timing({flow}: { flow: Flow }) {
|
||||
title: "Server conn. TLS handshake",
|
||||
t: flow.server_conn?.timestamp_tls_setup,
|
||||
deltaTo: ref
|
||||
}, {
|
||||
title: "Server conn. closed",
|
||||
t: flow.server_conn?.timestamp_end,
|
||||
deltaTo: ref
|
||||
}, {
|
||||
title: "Client conn. established",
|
||||
t: flow.client_conn.timestamp_start,
|
||||
@ -55,7 +59,11 @@ export default function Timing({flow}: { flow: Flow }) {
|
||||
title: "Client conn. TLS handshake",
|
||||
t: flow.client_conn.timestamp_tls_setup,
|
||||
deltaTo: ref
|
||||
}
|
||||
}, {
|
||||
title: "Client conn. closed",
|
||||
t: flow.client_conn.timestamp_end,
|
||||
deltaTo: ref
|
||||
},
|
||||
]
|
||||
if (flow.type === "http") {
|
||||
timestamps.push(...[
|
||||
|
@ -1,49 +1,14 @@
|
||||
import {HTTPFlow, WebSocketData} from "../../flow";
|
||||
import * as React from "react";
|
||||
import {useCallback, useMemo, useState} from "react";
|
||||
import {ContentViewData, SHOW_MAX_LINES, useContent} from "../contentviews/useContent";
|
||||
import {MessageUtils} from "../../flow/utils";
|
||||
import ViewSelector from "../contentviews/ViewSelector";
|
||||
import {useAppDispatch, useAppSelector} from "../../ducks";
|
||||
import {setContentViewFor} from "../../ducks/ui/flow";
|
||||
import LineRenderer from "../contentviews/LineRenderer";
|
||||
import {formatTimeStamp} from "../../utils";
|
||||
import Messages from "./Messages";
|
||||
|
||||
|
||||
export default function WebSocket({flow}: { flow: HTTPFlow & { websocket: WebSocketData } }) {
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const contentView = useAppSelector(state => state.ui.flow.contentViewFor[flow.id + "ws"] || "Auto");
|
||||
let [maxLines, setMaxLines] = useState<number>(SHOW_MAX_LINES);
|
||||
const showMore = useCallback(() => setMaxLines(Math.max(1024, maxLines * 2)), [maxLines]);
|
||||
const content = useContent(
|
||||
MessageUtils.getContentURL(flow, "messages", contentView, maxLines + 1),
|
||||
flow.id + flow.websocket.messages_meta.count
|
||||
);
|
||||
const messages = useMemo<ContentViewData[] | undefined>(() => content && JSON.parse(content), [content]) || [];
|
||||
|
||||
return (
|
||||
<section className="websocket">
|
||||
<h4>WebSocket</h4>
|
||||
<div className="contentview">
|
||||
<div className="controls">
|
||||
<h5>{flow.websocket.messages_meta.count} Messages</h5>
|
||||
<ViewSelector value={contentView}
|
||||
onChange={cv => dispatch(setContentViewFor(flow.id + "ws", cv))}/>
|
||||
</div>
|
||||
{messages.map((d: ContentViewData, i) => {
|
||||
const className = `fa fa-fw fa-arrow-${d.from_client ? "right text-primary" : "left text-danger"}`;
|
||||
const renderer = <div key={i}>
|
||||
<small>
|
||||
<i className={className}/>
|
||||
<span className="pull-right">{d.timestamp && formatTimeStamp(d.timestamp)}</span>
|
||||
</small>
|
||||
<LineRenderer lines={d.lines} maxLines={maxLines} showMore={showMore}/>
|
||||
</div>;
|
||||
maxLines -= d.lines.length;
|
||||
return renderer;
|
||||
})}
|
||||
</div>
|
||||
<Messages flow={flow} messages_meta={flow.websocket.messages_meta}/>
|
||||
<CloseSummary websocket={flow.websocket}/>
|
||||
</section>
|
||||
)
|
||||
|
@ -1,8 +1,9 @@
|
||||
import * as React from "react";
|
||||
import Button from "../common/Button"
|
||||
import {MessageUtils} from "../../flow/utils"
|
||||
import {canReplay, MessageUtils} from "../../flow/utils"
|
||||
import HideInStatic from "../common/HideInStatic";
|
||||
import {useAppDispatch, useAppSelector} from "../../ducks";
|
||||
import * as flowActions from "../../ducks/flows"
|
||||
import {
|
||||
duplicate as duplicateFlow,
|
||||
kill as killFlow,
|
||||
@ -30,7 +31,7 @@ export default function FlowMenu(): JSX.Element {
|
||||
<div className="menu-content">
|
||||
<Button title="[r]eplay flow" icon="fa-repeat text-primary"
|
||||
onClick={() => dispatch(replayFlow(flow))}
|
||||
disabled={!(flow.type === "http" && !flow.websocket)}
|
||||
disabled={!canReplay(flow)}
|
||||
>
|
||||
Replay
|
||||
</Button>
|
||||
@ -46,6 +47,7 @@ export default function FlowMenu(): JSX.Element {
|
||||
onClick={() => dispatch(removeFlow(flow))}>
|
||||
Delete
|
||||
</Button>
|
||||
<MarkButton flow={flow}/>
|
||||
</div>
|
||||
<div className="menu-legend">Flow Modification</div>
|
||||
</div>
|
||||
@ -54,17 +56,7 @@ export default function FlowMenu(): JSX.Element {
|
||||
<div className="menu-group">
|
||||
<div className="menu-content">
|
||||
<DownloadButton flow={flow}/>
|
||||
<Dropdown className="" text={
|
||||
<Button title="Export flow." icon="fa-clone" onClick={() => 1}>Export▾</Button>
|
||||
} options={{"placement": "bottom-start"}}>
|
||||
<MenuItem onClick={() => copy(flow, "raw_request")}>Copy raw request</MenuItem>
|
||||
<MenuItem onClick={() => copy(flow, "raw_response")}>Copy raw response</MenuItem>
|
||||
<MenuItem onClick={() => copy(flow, "raw")}>Copy raw request and response</MenuItem>
|
||||
<MenuItem onClick={() => copy(flow, "curl")}>Copy as cURL</MenuItem>
|
||||
<MenuItem onClick={() => copy(flow, "httpie")}>Copy as HTTPie</MenuItem>
|
||||
</Dropdown>
|
||||
|
||||
|
||||
<ExportButton flow={flow}/>
|
||||
</div>
|
||||
<div className="menu-legend">Export</div>
|
||||
</div>
|
||||
@ -91,7 +83,7 @@ export default function FlowMenu(): JSX.Element {
|
||||
|
||||
function DownloadButton({flow}: { flow: Flow }) {
|
||||
if (flow.type !== "http")
|
||||
return null;
|
||||
return <Button icon="fa-download" onClick={() => 0} disabled>Download</Button>;
|
||||
|
||||
if (flow.request.contentLength && !flow.response?.contentLength) {
|
||||
return <Button icon="fa-download"
|
||||
@ -119,3 +111,44 @@ function DownloadButton({flow}: { flow: Flow }) {
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function ExportButton({flow}: { flow: Flow }) {
|
||||
return <Dropdown className="" text={
|
||||
<Button title="Export flow." icon="fa-clone" onClick={() => 1}
|
||||
disabled={flow.type === "tcp"}>Export▾</Button>
|
||||
} options={{"placement": "bottom-start"}}>
|
||||
<MenuItem onClick={() => copy(flow, "raw_request")}>Copy raw request</MenuItem>
|
||||
<MenuItem onClick={() => copy(flow, "raw_response")}>Copy raw response</MenuItem>
|
||||
<MenuItem onClick={() => copy(flow, "raw")}>Copy raw request and response</MenuItem>
|
||||
<MenuItem onClick={() => copy(flow, "curl")}>Copy as cURL</MenuItem>
|
||||
<MenuItem onClick={() => copy(flow, "httpie")}>Copy as HTTPie</MenuItem>
|
||||
</Dropdown>
|
||||
}
|
||||
|
||||
|
||||
const markers = {
|
||||
":red_circle:": "🔴",
|
||||
":orange_circle:": "🟠",
|
||||
":yellow_circle:": "🟡",
|
||||
":green_circle:": "🟢",
|
||||
":large_blue_circle:": "🔵",
|
||||
":purple_circle:": "🟣",
|
||||
":brown_circle:": "🟤",
|
||||
}
|
||||
|
||||
function MarkButton({flow}: { flow: Flow }) {
|
||||
const dispatch = useAppDispatch();
|
||||
return <Dropdown className="" text={
|
||||
<Button title="mark flow" icon="fa-paint-brush text-success" onClick={() => 1}>Mark▾</Button>
|
||||
} options={{"placement": "bottom-start"}}>
|
||||
<MenuItem onClick={() => dispatch(flowActions.update(flow, {marked: ""}))}>⚪ (no
|
||||
marker)</MenuItem>
|
||||
{Object.entries(markers).map(([name, sym]) =>
|
||||
<MenuItem
|
||||
key={name}
|
||||
onClick={() => dispatch(flowActions.update(flow, {marked: name}))}>
|
||||
{sym} {name.replace(/[:_]/g, " ")}
|
||||
</MenuItem>
|
||||
)}
|
||||
</Dropdown>
|
||||
}
|
||||
|
@ -1,3 +1,6 @@
|
||||
import * as flowActions from "../flows";
|
||||
import {tabsForFlow} from "../../components/FlowView";
|
||||
|
||||
export const
|
||||
SET_TAB = "UI_FLOWVIEW_SET_TAB",
|
||||
SET_CONTENT_VIEW_FOR = "SET_CONTENT_VIEW_FOR"
|
||||
|
@ -57,7 +57,7 @@ export function onKeyDown(e: KeyboardEvent) {
|
||||
if (!flow) break
|
||||
let tabs = tabsForFlow(flow),
|
||||
currentTab = getState().ui.flow.tab,
|
||||
nextTab = tabs[(tabs.indexOf(currentTab) - 1 + tabs.length) % tabs.length]
|
||||
nextTab = tabs[(Math.max(0, tabs.indexOf(currentTab)) - 1 + tabs.length) % tabs.length]
|
||||
dispatch(selectTab(nextTab))
|
||||
break
|
||||
}
|
||||
@ -67,7 +67,7 @@ export function onKeyDown(e: KeyboardEvent) {
|
||||
if (!flow) break
|
||||
let tabs = tabsForFlow(flow),
|
||||
currentTab = getState().ui.flow.tab,
|
||||
nextTab = tabs[(tabs.indexOf(currentTab) + 1) % tabs.length]
|
||||
nextTab = tabs[(Math.max(0, tabs.indexOf(currentTab)) + 1) % tabs.length]
|
||||
dispatch(selectTab(nextTab))
|
||||
break
|
||||
}
|
||||
|
@ -24,6 +24,7 @@ export interface HTTPFlow extends _Flow {
|
||||
|
||||
export interface TCPFlow extends _Flow {
|
||||
type: "tcp"
|
||||
messages_meta: MessagesMeta,
|
||||
}
|
||||
|
||||
export interface Error {
|
||||
@ -100,11 +101,14 @@ export interface HTTPResponse extends HTTPMessage {
|
||||
reason: string
|
||||
}
|
||||
|
||||
export interface WebSocketData {
|
||||
messages_meta: {
|
||||
export interface MessagesMeta {
|
||||
contentLength: number,
|
||||
count: number,
|
||||
timestamp_last?: number
|
||||
},
|
||||
}
|
||||
|
||||
export interface WebSocketData {
|
||||
messages_meta: MessagesMeta,
|
||||
closed_by_client?: boolean
|
||||
close_code?: number
|
||||
close_reason?: string
|
||||
|
@ -3,5 +3,11 @@ import {Flow} from "../flow";
|
||||
|
||||
export const copy = async (flow: Flow, format: string): Promise<void> => {
|
||||
let ret = await runCommand("export", format, `@${flow.id}`);
|
||||
await navigator.clipboard.writeText(ret);
|
||||
if(ret.value) {
|
||||
await navigator.clipboard.writeText(ret.value);
|
||||
} else if(ret.error) {
|
||||
alert(ret.error)
|
||||
} else {
|
||||
console.error(ret);
|
||||
}
|
||||
}
|
||||
|
@ -1,4 +1,4 @@
|
||||
import {Flow, HTTPFlow, HTTPMessage, HTTPRequest} from "../flow";
|
||||
import {Flow, HTTPMessage, HTTPRequest} from "../flow";
|
||||
|
||||
const defaultPorts = {
|
||||
"http": 80,
|
||||
@ -53,14 +53,14 @@ export class MessageUtils {
|
||||
}
|
||||
|
||||
static getContentURL(
|
||||
flow: HTTPFlow,
|
||||
flow: Flow,
|
||||
part: HTTPMessage | "request" | "response" | "messages",
|
||||
view?: string,
|
||||
lines?: number
|
||||
): string {
|
||||
if (part === flow.request) {
|
||||
if (flow.type === "http" && part === flow.request) {
|
||||
part = "request";
|
||||
} else if (part === flow.response) {
|
||||
} else if (flow.type === "http" && part === flow.response) {
|
||||
part = "response";
|
||||
}
|
||||
const lineStr = lines ? `?lines=${lines}` : "";
|
||||
@ -129,14 +129,17 @@ export var isValidHttpVersion = function (httpVersion: string): boolean {
|
||||
|
||||
|
||||
export function startTime(flow: Flow): number | undefined {
|
||||
if (flow.type === "http") {
|
||||
switch (flow.type) {
|
||||
case "http":
|
||||
return flow.request.timestamp_start
|
||||
case "tcp":
|
||||
return flow.client_conn.timestamp_start
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
export function endTime(flow: Flow): number | undefined {
|
||||
if (flow.type === "http") {
|
||||
switch (flow.type) {
|
||||
case "http":
|
||||
if (flow.websocket) {
|
||||
if (flow.websocket.timestamp_end)
|
||||
return flow.websocket.timestamp_end
|
||||
@ -146,16 +149,30 @@ export function endTime(flow: Flow): number | undefined {
|
||||
if (flow.response) {
|
||||
return flow.response.timestamp_end
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
case "tcp":
|
||||
return flow.server_conn?.timestamp_end
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export const getTotalSize = (flow: Flow): number => {
|
||||
if (flow.type !== "http")
|
||||
return 0
|
||||
switch (flow.type) {
|
||||
case "http":
|
||||
let total = flow.request.contentLength || 0
|
||||
if (flow.response) {
|
||||
total += flow.response.contentLength || 0
|
||||
}
|
||||
return total
|
||||
if (flow.websocket) {
|
||||
total += flow.websocket.messages_meta.contentLength || 0
|
||||
}
|
||||
return total
|
||||
case "tcp":
|
||||
return flow.messages_meta.contentLength || 0
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
export const canReplay = (flow: Flow): boolean => {
|
||||
return (flow.type === "http" && !flow.websocket)
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user