flowtable: add selection indicator, add keyboard navigation

This commit is contained in:
Maximilian Hils 2014-09-18 02:22:10 +02:00
parent 6a161be6b4
commit 0d64cc9327
12 changed files with 376 additions and 154 deletions

View File

@ -1,5 +1,6 @@
from __future__ import absolute_import
import copy
import uuid
import netlib.tcp
from .. import stateobject, utils, version
from ..proxy.connection import ClientConnection, ServerConnection
@ -60,6 +61,7 @@ class Flow(stateobject.StateObject):
"""
def __init__(self, conntype, client_conn, server_conn, live=None):
self.conntype = conntype
self.id = str(uuid.uuid4())
self.client_conn = client_conn
"""@type: ClientConnection"""
self.server_conn = server_conn
@ -72,6 +74,7 @@ class Flow(stateobject.StateObject):
self._backup = None
_stateobject_attributes = dict(
id=str,
error=Error,
client_conn=ClientConnection,
server_conn=ServerConnection,

View File

@ -56,7 +56,7 @@ body,
#container > .eventlog {
flex: 0 0 auto;
}
#main {
main {
flex: 1 1 auto;
overflow: auto;
}
@ -111,6 +111,9 @@ header .menu {
.flow-table tr {
cursor: pointer;
}
.flow-table tr.selected {
background-color: rgba(193, 215, 235, 0.5) !important;
}
.flow-table td {
overflow: hidden;
white-space: nowrap;
@ -119,7 +122,7 @@ header .menu {
.flow-table tr:nth-child(even) {
background-color: rgba(0, 0, 0, 0.05);
}
.flow-table .col-tls {
.flow-table .col-tls {
width: 10px;
}
.flow-table .col-tls-https {

View File

@ -1,4 +1,5 @@
[{
"id": "b5e5483c-e124-45bb-aa2e-360706e03ef4",
"request": {
"timestamp_end": 1410651311.107,
"timestamp_start": 1410651311.106,
@ -157,6 +158,7 @@
}
},
{
"id": "85e9781f-d81d-43ca-a694-2cd86c76d991",
"request": {
"timestamp_end": 1410651311.657,
"timestamp_start": 1410651311.653,
@ -319,6 +321,7 @@
}
},
{
"id": "1bf281fd-e02a-423c-a69c-aa65657bc3dd",
"request": {
"timestamp_end": 1410651312.362,
"timestamp_start": 1410651312.359,
@ -485,6 +488,7 @@
}
},
{
"id": "833253a0-f7dd-48c7-893c-1f13a38a71ce",
"request": {
"timestamp_end": 1410651312.389,
"timestamp_start": 1410651312.368,
@ -651,6 +655,7 @@
}
},
{
"id": "152d8e71-2469-4034-8d6d-11099bbb4248",
"request": {
"timestamp_end": 1410651312.386,
"timestamp_start": 1410651312.368,
@ -817,6 +822,7 @@
}
},
{
"id": "b3758e4d-7bae-4771-b154-e100c0722d00",
"request": {
"timestamp_end": 1410651373.965,
"timestamp_start": 1410651373.963,
@ -947,6 +953,7 @@
}
},
{
"id": "ea9e47ab-fd7b-4463-bfea-cfd64cc5f78d",
"request": {
"timestamp_end": 1410651374.391,
"timestamp_start": 1410651374.387,
@ -1081,6 +1088,7 @@
}
},
{
"id": "13ee4cd1-08e0-43ef-9bee-56fc0d9cbf3f",
"request": {
"timestamp_end": 1410651374.396,
"timestamp_start": 1410651374.394,
@ -1211,7 +1219,8 @@
}
},
{
"request": {
"id": "5c50e1fc-5ac4-4748-aed1-c969ede63e4e",
"request": {
"timestamp_end": 1410651374.795,
"timestamp_start": 1410651374.793,
"form_in": "absolute",
@ -1361,7 +1370,8 @@
}
},
{
"request": {
"id": "0285a0b2-380e-43eb-a7a9-a18893950216",
"request": {
"timestamp_end": 1410651375.084,
"timestamp_start": 1410651375.078,
"form_in": "absolute",
@ -1507,7 +1517,8 @@
}
},
{
"request": {
"id": "c9af9c71-dc68-462e-8446-f3a4b2782400",
"request": {
"timestamp_end": 1410651374.778,
"timestamp_start": 1410651374.766,
"form_in": "absolute",
@ -1637,7 +1648,8 @@
}
},
{
"request": {
"id": "310386ab-3ae1-4129-9a2e-8dd2ce60ecdb",
"request": {
"timestamp_end": 1410651374.778,
"timestamp_start": 1410651374.766,
"form_in": "absolute",
@ -1767,7 +1779,8 @@
}
},
{
"request": {
"id": "b92e5f6e-bb0f-4e47-a50c-ef4072ea40b3",
"request": {
"timestamp_end": 1410651376.078,
"timestamp_start": 1410651376.075,
"form_in": "absolute",
@ -1889,7 +1902,8 @@
}
},
{
"request": {
"id": "597d086f-d836-49e3-85bb-77a983bed87f",
"request": {
"timestamp_end": 1410651376.282,
"timestamp_start": 1410651376.279,
"form_in": "absolute",

View File

@ -12,6 +12,15 @@ var AutoScrollMixin = {
},
};
var Key = {
UP: 38,
DOWN: 40,
LEFT: 37,
RIGHT: 39,
ENTER: 13,
ESC: 27
}
const PayloadSources = {
VIEW: "view",
SERVER: "server"
@ -274,9 +283,8 @@ _.extend(FlowView.prototype, EventEmitter.prototype, {
this.emit("change");
},
_update: function(flow){
console.debug("FIXME: Use UUID");
var idx = _.findIndex(this.flows, function(f){
return flow.request.timestamp_start == f.request.timestamp_start;
return flow.id === f.id;
});
if(idx < 0){
@ -300,7 +308,13 @@ _.extend(_FlowStore.prototype, EventEmitter.prototype, {
var view = new FlowView(this, !since);
$.getJSON("/static/flows.json", function(flows){
flows = flows.concat(_.cloneDeep(flows)).concat(_.cloneDeep(flows));
var id = 1;
flows.forEach(function(flow){
flow.id = "uuid-"+id++;
})
view.add_bulk(flows);
});
return view;
@ -447,38 +461,6 @@ var Header = React.createClass({displayName: 'Header',
/** @jsx React.DOM */
var FlowRow = React.createClass({displayName: 'FlowRow',
render: function(){
var flow = this.props.flow;
var columns = this.props.columns.map(function(column){
return column({
key: column.displayName,
flow: flow
});
}.bind(this));
return React.DOM.tr({onClick: this.props.onClick}, columns);
}
});
var FlowTableHead = React.createClass({displayName: 'FlowTableHead',
render: function(){
var columns = this.props.columns.map(function(column){
return column.renderTitle();
}.bind(this));
return React.DOM.thead(null, columns);
}
});
var FlowTableBody = React.createClass({displayName: 'FlowTableBody',
render: function(){
var rows = this.props.flows.map(function(flow){
//TODO: Add UUID
return FlowRow({onClick: this.props.onClick, flow: flow, columns: this.props.columns});
}.bind(this));
return React.DOM.tbody(null, rows);
}
});
var TLSColumn = React.createClass({displayName: 'TLSColumn',
statics: {
@ -573,6 +555,54 @@ var TimeColumn = React.createClass({displayName: 'TimeColumn',
var all_columns = [TLSColumn, IconColumn, PathColumn, MethodColumn, StatusColumn, TimeColumn];
/** @jsx React.DOM */
var FlowRow = React.createClass({displayName: 'FlowRow',
render: function(){
var flow = this.props.flow;
var columns = this.props.columns.map(function(column){
return column({
key: column.displayName,
flow: flow
});
}.bind(this));
var className = "";
if(this.props.selected){
className += "selected";
}
return (
React.DOM.tr({className: className, onClick: this.props.selectFlow.bind(null, flow)},
columns
));
}
});
var FlowTableHead = React.createClass({displayName: 'FlowTableHead',
render: function(){
var columns = this.props.columns.map(function(column){
return column.renderTitle();
}.bind(this));
return React.DOM.thead(null, columns);
}
});
var FlowTableBody = React.createClass({displayName: 'FlowTableBody',
render: function(){
var rows = this.props.flows.map(function(flow){
var selected = (flow == this.props.selected);
return FlowRow({key: flow.id,
ref: flow.id,
flow: flow,
columns: this.props.columns,
selected: selected,
selectFlow: this.props.selectFlow}
);
}.bind(this));
return React.DOM.tbody({onKeyDown: this.props.onKeyDown, tabIndex: "0"}, rows);
}
});
var FlowTable = React.createClass({displayName: 'FlowTable',
getInitialState: function () {
return {
@ -593,18 +623,88 @@ var FlowTable = React.createClass({displayName: 'FlowTable',
flows: this.flowStore.getAll()
});
},
onClick: function(e){
console.log("rowclick", e);
selectFlow: function(flow){
this.setState({
selected: flow
});
// Now comes the fun part: Scroll the flow into the view.
var viewport = this.getDOMNode();
var flowNode = this.refs.body.refs[flow.id].getDOMNode();
var viewport_top = viewport.scrollTop;
var viewport_bottom = viewport_top + viewport.offsetHeight;
var flowNode_top = flowNode.offsetTop;
var flowNode_bottom = flowNode_top + flowNode.offsetHeight;
// Account for pinned thead by pretending that the flowNode starts
// -thead_height pixel earlier.
flowNode_top -= this.refs.body.getDOMNode().offsetTop;
if(flowNode_top < viewport_top){
viewport.scrollTop = flowNode_top;
} else if(flowNode_bottom > viewport_bottom) {
viewport.scrollTop = flowNode_bottom - viewport.offsetHeight;
}
},
selectRowRelative: function(i){
var index;
if(!this.state.selected){
if(i > 0){
index = this.flows.length-1;
} else {
index = 0;
}
} else {
index = _.findIndex(this.state.flows, function(f){
return f === this.state.selected;
}.bind(this));
index = Math.min(Math.max(0, index+i), this.state.flows.length-1);
}
this.selectFlow(this.state.flows[index]);
},
onKeyDown: function(e){
switch(e.keyCode){
case Key.DOWN:
this.selectRowRelative(+1);
return false;
break;
case Key.UP:
this.selectRowRelative(-1);
return false;
break;
case Key.ENTER:
console.log("Open details pane...", this.state.selected);
break;
case Key.ESC:
console.log("")
default:
console.debug("keydown", e.keyCode);
return;
}
return false;
},
onScroll: function(e){
//Abusing CSS transforms to set thead into position:fixed.
var head = this.refs.head.getDOMNode();
head.style.transform = "translate(0,"+this.getDOMNode().scrollTop+"px)";
},
render: function () {
var flows = this.state.flows.map(function(flow){
return React.DOM.div(null, flow.request.method, " ", flow.request.scheme, "://", flow.request.host, flow.request.path);
});
return (
React.DOM.main({onScroll: this.onScroll},
React.DOM.table({className: "flow-table"},
FlowTableHead({columns: this.state.columns}),
FlowTableBody({onClick: this.onClick, columns: this.state.columns, flows: this.state.flows})
FlowTableHead({ref: "head",
columns: this.state.columns}),
FlowTableBody({ref: "body",
selectFlow: this.selectFlow,
onKeyDown: this.onKeyDown,
selected: this.state.selected,
columns: this.state.columns,
flows: this.state.flows})
)
)
);
}
});
@ -691,7 +791,7 @@ var ProxyAppMain = React.createClass({displayName: 'ProxyAppMain',
return (
React.DOM.div({id: "container"},
Header({settings: this.state.settings}),
React.DOM.div({id: "main"}, this.props.activeRouteHandler(null)),
this.props.activeRouteHandler(null),
this.state.settings.showEventLog ? EventLog(null) : null,
Footer({settings: this.state.settings})
)

View File

@ -42,6 +42,7 @@ var path = {
'js/stores/flowstore.js',
'js/connection.js',
'js/components/header.jsx.js',
'js/components/flowtable-columns.jsx.js',
'js/components/flowtable.jsx.js',
'js/components/eventlog.jsx.js',
'js/components/footer.jsx.js',

View File

@ -8,6 +8,9 @@
tr {
cursor: pointer;
&.selected {
background-color: hsla(209, 52%, 84%, 0.5) !important;
}
}
td {
@ -19,8 +22,6 @@
//tr:nth-child(odd) { background-color : white; }
tr:nth-child(even) { background-color : rgba(0,0,0,0.05); }
//tr:hover { background-color : hsla(209, 52%, 84%, 0.5); }
.col-tls {
width: 10px;

View File

@ -13,7 +13,7 @@ html, body, #container {
}
}
#main {
main {
flex: 1 1 auto;
overflow: auto;
}

View File

@ -0,0 +1,95 @@
/** @jsx React.DOM */
var TLSColumn = React.createClass({
statics: {
renderTitle: function(){
return <th key="tls" className="col-tls"></th>;
}
},
render: function(){
var flow = this.props.flow;
var ssl = (flow.request.scheme == "https");
return <td className={ssl ? "col-tls-https" : "col-tls-http"}></td>;
}
});
var IconColumn = React.createClass({
statics: {
renderTitle: function(){
return <th key="icon" className="col-icon"></th>;
}
},
render: function(){
var flow = this.props.flow;
return <td className="resource-icon resource-icon-plain"></td>;
}
});
var PathColumn = React.createClass({
statics: {
renderTitle: function(){
return <th key="path" className="col-path">Path</th>;
}
},
render: function(){
var flow = this.props.flow;
return <td>{flow.request.scheme + "://" + flow.request.host + flow.request.path}</td>;
}
});
var MethodColumn = React.createClass({
statics: {
renderTitle: function(){
return <th key="method" className="col-method">Method</th>;
}
},
render: function(){
var flow = this.props.flow;
return <td>{flow.request.method}</td>;
}
});
var StatusColumn = React.createClass({
statics: {
renderTitle: function(){
return <th key="status" className="col-status">Status</th>;
}
},
render: function(){
var flow = this.props.flow;
var status;
if(flow.response){
status = flow.response.code;
} else {
status = null;
}
return <td>{status}</td>;
}
});
var TimeColumn = React.createClass({
statics: {
renderTitle: function(){
return <th key="time" className="col-time">Time</th>;
}
},
render: function(){
var flow = this.props.flow;
var time;
if(flow.response){
time = Math.round(1000 * (flow.response.timestamp_end - flow.request.timestamp_start))+"ms";
} else {
time = "...";
}
return <td>{time}</td>;
}
});
var all_columns = [TLSColumn, IconColumn, PathColumn, MethodColumn, StatusColumn, TimeColumn];

View File

@ -9,7 +9,14 @@ var FlowRow = React.createClass({
flow: flow
});
}.bind(this));
return <tr onClick={this.props.onClick} >{columns}</tr>;
var className = "";
if(this.props.selected){
className += "selected";
}
return (
<tr className={className} onClick={this.props.selectFlow.bind(null, flow)}>
{columns}
</tr>);
}
});
@ -25,107 +32,20 @@ var FlowTableHead = React.createClass({
var FlowTableBody = React.createClass({
render: function(){
var rows = this.props.flows.map(function(flow){
//TODO: Add UUID
return <FlowRow onClick={this.props.onClick} flow={flow} columns={this.props.columns}/>;
var selected = (flow == this.props.selected);
return <FlowRow key={flow.id}
ref={flow.id}
flow={flow}
columns={this.props.columns}
selected={selected}
selectFlow={this.props.selectFlow}
/>;
}.bind(this));
return <tbody>{rows}</tbody>;
return <tbody onKeyDown={this.props.onKeyDown} tabIndex="0">{rows}</tbody>;
}
});
var TLSColumn = React.createClass({
statics: {
renderTitle: function(){
return <th key="tls" className="col-tls"></th>;
}
},
render: function(){
var flow = this.props.flow;
var ssl = (flow.request.scheme == "https");
return <td className={ssl ? "col-tls-https" : "col-tls-http"}></td>;
}
});
var IconColumn = React.createClass({
statics: {
renderTitle: function(){
return <th key="icon" className="col-icon"></th>;
}
},
render: function(){
var flow = this.props.flow;
return <td className="resource-icon resource-icon-plain"></td>;
}
});
var PathColumn = React.createClass({
statics: {
renderTitle: function(){
return <th key="path" className="col-path">Path</th>;
}
},
render: function(){
var flow = this.props.flow;
return <td>{flow.request.scheme + "://" + flow.request.host + flow.request.path}</td>;
}
});
var MethodColumn = React.createClass({
statics: {
renderTitle: function(){
return <th key="method" className="col-method">Method</th>;
}
},
render: function(){
var flow = this.props.flow;
return <td>{flow.request.method}</td>;
}
});
var StatusColumn = React.createClass({
statics: {
renderTitle: function(){
return <th key="status" className="col-status">Status</th>;
}
},
render: function(){
var flow = this.props.flow;
var status;
if(flow.response){
status = flow.response.code;
} else {
status = null;
}
return <td>{status}</td>;
}
});
var TimeColumn = React.createClass({
statics: {
renderTitle: function(){
return <th key="time" className="col-time">Time</th>;
}
},
render: function(){
var flow = this.props.flow;
var time;
if(flow.response){
time = Math.round(1000 * (flow.response.timestamp_end - flow.request.timestamp_start))+"ms";
} else {
time = "...";
}
return <td>{time}</td>;
}
});
var all_columns = [TLSColumn, IconColumn, PathColumn, MethodColumn, StatusColumn, TimeColumn];
var FlowTable = React.createClass({
getInitialState: function () {
return {
@ -146,18 +66,88 @@ var FlowTable = React.createClass({
flows: this.flowStore.getAll()
});
},
onClick: function(e){
console.log("rowclick", e);
selectFlow: function(flow){
this.setState({
selected: flow
});
// Now comes the fun part: Scroll the flow into the view.
var viewport = this.getDOMNode();
var flowNode = this.refs.body.refs[flow.id].getDOMNode();
var viewport_top = viewport.scrollTop;
var viewport_bottom = viewport_top + viewport.offsetHeight;
var flowNode_top = flowNode.offsetTop;
var flowNode_bottom = flowNode_top + flowNode.offsetHeight;
// Account for pinned thead by pretending that the flowNode starts
// -thead_height pixel earlier.
flowNode_top -= this.refs.body.getDOMNode().offsetTop;
if(flowNode_top < viewport_top){
viewport.scrollTop = flowNode_top;
} else if(flowNode_bottom > viewport_bottom) {
viewport.scrollTop = flowNode_bottom - viewport.offsetHeight;
}
},
selectRowRelative: function(i){
var index;
if(!this.state.selected){
if(i > 0){
index = this.flows.length-1;
} else {
index = 0;
}
} else {
index = _.findIndex(this.state.flows, function(f){
return f === this.state.selected;
}.bind(this));
index = Math.min(Math.max(0, index+i), this.state.flows.length-1);
}
this.selectFlow(this.state.flows[index]);
},
onKeyDown: function(e){
switch(e.keyCode){
case Key.DOWN:
this.selectRowRelative(+1);
return false;
break;
case Key.UP:
this.selectRowRelative(-1);
return false;
break;
case Key.ENTER:
console.log("Open details pane...", this.state.selected);
break;
case Key.ESC:
console.log("")
default:
console.debug("keydown", e.keyCode);
return;
}
return false;
},
onScroll: function(e){
//Abusing CSS transforms to set thead into position:fixed.
var head = this.refs.head.getDOMNode();
head.style.transform = "translate(0,"+this.getDOMNode().scrollTop+"px)";
},
render: function () {
var flows = this.state.flows.map(function(flow){
return <div>{flow.request.method} {flow.request.scheme}://{flow.request.host}{flow.request.path}</div>;
});
return (
<main onScroll={this.onScroll}>
<table className="flow-table">
<FlowTableHead columns={this.state.columns}/>
<FlowTableBody onClick={this.onClick} columns={this.state.columns} flows={this.state.flows}/>
<FlowTableHead ref="head"
columns={this.state.columns}/>
<FlowTableBody ref="body"
selectFlow={this.selectFlow}
onKeyDown={this.onKeyDown}
selected={this.state.selected}
columns={this.state.columns}
flows={this.state.flows}/>
</table>
</main>
);
}
});

View File

@ -26,7 +26,7 @@ var ProxyAppMain = React.createClass({
return (
<div id="container">
<Header settings={this.state.settings}/>
<div id="main"><this.props.activeRouteHandler/></div>
<this.props.activeRouteHandler/>
{this.state.settings.showEventLog ? <EventLog/> : null}
<Footer settings={this.state.settings}/>
</div>

View File

@ -35,9 +35,8 @@ _.extend(FlowView.prototype, EventEmitter.prototype, {
this.emit("change");
},
_update: function(flow){
console.debug("FIXME: Use UUID");
var idx = _.findIndex(this.flows, function(f){
return flow.request.timestamp_start == f.request.timestamp_start;
return flow.id === f.id;
});
if(idx < 0){
@ -61,7 +60,13 @@ _.extend(_FlowStore.prototype, EventEmitter.prototype, {
var view = new FlowView(this, !since);
$.getJSON("/static/flows.json", function(flows){
flows = flows.concat(_.cloneDeep(flows)).concat(_.cloneDeep(flows));
var id = 1;
flows.forEach(function(flow){
flow.id = "uuid-"+id++;
})
view.add_bulk(flows);
});
return view;

View File

@ -11,3 +11,13 @@ var AutoScrollMixin = {
}
},
};
var Key = {
UP: 38,
DOWN: 40,
LEFT: 37,
RIGHT: 39,
ENTER: 13,
ESC: 27
}