Merge pull request #4779 from mhils/mitmweb-tcp

web: add support for viewing tcp flows
This commit is contained in:
Maximilian Hils 2021-08-25 13:35:37 +02:00 committed by GitHub
parent 550e1a4ab3
commit c0fd6cfc09
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
36 changed files with 721 additions and 457 deletions

View File

@ -96,31 +96,30 @@ 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()
else:
content_length = None
content_hash = None
f["request"] = {
"method": flow.request.method,
"scheme": flow.request.scheme,
"host": flow.request.host,
"port": flow.request.port,
"path": flow.request.path,
"http_version": flow.request.http_version,
"headers": tuple(flow.request.headers.items(True)),
"contentLength": content_length,
"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.request.raw_content is not None:
content_length = len(flow.request.raw_content)
content_hash = hashlib.sha256(flow.request.raw_content).hexdigest()
else:
content_length = None
content_hash = None
f["request"] = {
"method": flow.request.method,
"scheme": flow.request.scheme,
"host": flow.request.host,
"port": flow.request.port,
"path": flow.request.path,
"http_version": flow.request.http_version,
"headers": tuple(flow.request.headers.items(True)),
"contentLength": content_length,
"contentHash": content_hash,
"timestamp_start": flow.request.timestamp_start,
"timestamp_end": flow.request.timestamp_end,
"pretty_host": flow.request.pretty_host,
}
if flow.response:
if flow.response.raw_content is not None:
content_length = len(flow.response.raw_content)
@ -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

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

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

View File

@ -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",

View File

@ -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/"})

View File

@ -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);
}

View File

@ -48,3 +48,7 @@
.resource-icon-websocket {
background-image: url(images/resourceWebSocketIcon.png);
}
.resource-icon-tcp {
background-image: url(images/resourceTcpIcon.png);
}

Binary file not shown.

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

View File

@ -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,9 +19,14 @@ 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()
// not modified

View File

@ -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>

View File

@ -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>

View File

@ -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"));
});

View File

@ -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"

View File

@ -26,7 +26,7 @@ exports[`MainMenu 1`] = `
class="form-control"
placeholder="Search"
type="text"
value="~u /second"
value="~u /second | ~tcp"
/>
</div>
<div

View File

@ -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>
`;

View File

@ -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"

View File

@ -1,164 +1,224 @@
/** Auto-generated by test_app.py:test_generate_tflow_js */
import {HTTPFlow} from '../../flow';
export default function(): Required<HTTPFlow> {
return {
"client_conn": {
"alpn": "http/1.1",
"cert": undefined,
"cipher": "cipher",
"id": "4a18d1a0-50a1-48dd-9aa6-d45d74282939",
"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": "d91165be-ca1f-4612-88a9-c0f8696f3e29",
"intercepted": false,
"is_replay": undefined,
"marked": "",
"modified": false,
"request": {
"contentHash": "ed7002b439e9ac845f22357d822bac1444730fbdb6016d3ec9432297b9ec9f73",
"contentLength": 7,
"headers": [
[
"header",
"qvalue"
import {HTTPFlow, TCPFlow} from '../../flow';
export function THTTPFlow(): Required<HTTPFlow> {
return {
"client_conn": {
"alpn": "http/1.1",
"cert": undefined,
"cipher": "cipher",
"id": "4a18d1a0-50a1-48dd-9aa6-d45d74282939",
"peername": [
"127.0.0.1",
22
],
[
"content-length",
"7"
]
],
"host": "address",
"http_version": "HTTP/1.1",
//@ts-ignore
"is_replay": false,
"method": "GET",
"path": "/path",
"port": 22,
"pretty_host": "address",
"scheme": "http",
"timestamp_end": 946681201,
"timestamp_start": 946681200
},
"response": {
"contentHash": "ab530a13e45914982b79f9b7e3fba994cfd1f3fb22f71cea1afbf02b460c6d1d",
"contentLength": 7,
"headers": [
[
"header-response",
"svalue"
"sni": "address",
"sockname": [
"",
0
],
[
"content-length",
"7"
]
],
"http_version": "HTTP/1.1",
//@ts-ignore
"is_replay": false,
"reason": "OK",
"status_code": 200,
"timestamp_end": 946681203,
"timestamp_start": 946681202,
"trailers": [
[
"trailer",
"qvalue"
]
]
},
"server_conn": {
//@ts-ignore
"address": [
"address",
22
],
"alpn": undefined,
"cert": {
"altnames": [
"example.mitmproxy.org"
],
"issuer": [
"timestamp_end": 946681206,
"timestamp_start": 946681200,
"timestamp_tls_setup": 946681201,
"tls_established": true,
"tls_version": "TLSv1.2"
},
"error": {
"msg": "error",
"timestamp": 946681207.0
},
"id": "d91165be-ca1f-4612-88a9-c0f8696f3e29",
"intercepted": false,
"is_replay": undefined,
"marked": "",
"modified": false,
"request": {
"contentHash": "ed7002b439e9ac845f22357d822bac1444730fbdb6016d3ec9432297b9ec9f73",
"contentLength": 7,
"headers": [
[
"C",
"AU"
"header",
"qvalue"
],
[
"ST",
"Some-State"
],
[
"O",
"Internet Widgits Pty Ltd"
"content-length",
"7"
]
],
"keyinfo": [
"RSA",
2048
"host": "address",
"http_version": "HTTP/1.1",
"method": "GET",
"path": "/path",
"port": 22,
"pretty_host": "address",
"scheme": "http",
"timestamp_end": 946681201,
"timestamp_start": 946681200
},
"response": {
"contentHash": "ab530a13e45914982b79f9b7e3fba994cfd1f3fb22f71cea1afbf02b460c6d1d",
"contentLength": 7,
"headers": [
[
"header-response",
"svalue"
],
[
"content-length",
"7"
]
],
"notafter": 2235103407,
"notbefore": 1604383407,
"serial": "247170098335718583458667965517443538258472437317",
"sha256": "e5f62a1175031b6feb959bc8e6dd0f8e2546dbbf7c32da39534309d8aa92967c",
"subject": [
"http_version": "HTTP/1.1",
"reason": "OK",
"status_code": 200,
"timestamp_end": 946681203,
"timestamp_start": 946681202,
"trailers": [
[
"C",
"AU"
],
[
"ST",
"Some-State"
],
[
"O",
"Internet Widgits Pty Ltd"
"trailer",
"qvalue"
]
]
},
"cipher": undefined,
"id": "f087e7b2-6d0a-41a8-a8f0-e1a4761395f8",
"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": "http",
"websocket": {
"close_code": 1000,
"close_reason": "Close Reason",
"closed_by_client": false,
"messages_meta": {
"count": 3,
"timestamp_last": 946681205
"server_conn": {
"address": [
"address",
22
],
"alpn": undefined,
"cert": {
"altnames": [
"example.mitmproxy.org"
],
"issuer": [
[
"C",
"AU"
],
[
"ST",
"Some-State"
],
[
"O",
"Internet Widgits Pty Ltd"
]
],
"keyinfo": [
"RSA",
2048
],
"notafter": 2235103407,
"notbefore": 1604383407,
"serial": "247170098335718583458667965517443538258472437317",
"sha256": "e5f62a1175031b6feb959bc8e6dd0f8e2546dbbf7c32da39534309d8aa92967c",
"subject": [
[
"C",
"AU"
],
[
"ST",
"Some-State"
],
[
"O",
"Internet Widgits Pty Ltd"
]
]
},
"cipher": undefined,
"id": "f087e7b2-6d0a-41a8-a8f0-e1a4761395f8",
"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"
},
"timestamp_end": 946681205
"type": "http",
"websocket": {
"close_code": 1000,
"close_reason": "Close Reason",
"closed_by_client": false,
"messages_meta": {
"contentLength": 29,
"count": 3,
"timestamp_last": 946681205
},
"timestamp_end": 946681205
}
}
}
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"
}
}

View File

@ -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

View File

@ -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'
})
}

View File

@ -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>
)
}

View File

@ -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>
))}

View File

@ -10,22 +10,35 @@ 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
}
export const allTabs: { [name: string]: FunctionComponent<TabProps> & {displayName: string} } = {
export const allTabs: { [name: string]: FunctionComponent<TabProps> & { displayName: string } } = {
request: Request,
response: Response,
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;

View 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>
)
}

View 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"

View File

@ -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(...[

View File

@ -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>
)
@ -51,17 +16,17 @@ export default function WebSocket({flow}: { flow: HTTPFlow & { websocket: WebSoc
WebSocket.displayName = "WebSocket"
function CloseSummary({websocket}: {websocket: WebSocketData}){
if(!websocket.timestamp_end)
function CloseSummary({websocket}: { websocket: WebSocketData }) {
if (!websocket.timestamp_end)
return null;
const reason = websocket.close_reason ? `(${websocket.close_reason})` : ""
return <div>
<i className="fa fa-fw fa-window-close text-muted"/>
&nbsp;
Closed by {websocket.closed_by_client ? "client": "server"} with code {websocket.close_code} {reason}.
Closed by {websocket.closed_by_client ? "client" : "server"} with code {websocket.close_code} {reason}.
<small className="pull-right">
{formatTimeStamp(websocket.timestamp_end)}
{formatTimeStamp(websocket.timestamp_end)}
</small>
</div>
}

View File

@ -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>
}

View File

@ -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"

View File

@ -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
}

View File

@ -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

View File

@ -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);
}
}

View File

@ -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,33 +129,50 @@ export var isValidHttpVersion = function (httpVersion: string): boolean {
export function startTime(flow: Flow): number | undefined {
if (flow.type === "http") {
return flow.request.timestamp_start
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") {
if (flow.websocket) {
if (flow.websocket.timestamp_end)
return flow.websocket.timestamp_end
if (flow.websocket.messages_meta.timestamp_last)
return flow.websocket.messages_meta.timestamp_last
}
if (flow.response) {
return flow.response.timestamp_end
}
switch (flow.type) {
case "http":
if (flow.websocket) {
if (flow.websocket.timestamp_end)
return flow.websocket.timestamp_end
if (flow.websocket.messages_meta.timestamp_last)
return flow.websocket.messages_meta.timestamp_last
}
if (flow.response) {
return flow.response.timestamp_end
}
return undefined
case "tcp":
return flow.server_conn?.timestamp_end
}
return undefined
}
export const getTotalSize = (flow: Flow): number => {
if (flow.type !== "http")
return 0
let total = flow.request.contentLength || 0
if (flow.response) {
total += flow.response.contentLength || 0
switch (flow.type) {
case "http":
let total = flow.request.contentLength || 0
if (flow.response) {
total += flow.response.contentLength || 0
}
if (flow.websocket) {
total += flow.websocket.messages_meta.contentLength || 0
}
return total
case "tcp":
return flow.messages_meta.contentLength || 0
}
return total
}
export const canReplay = (flow: Flow): boolean => {
return (flow.type === "http" && !flow.websocket)
}