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

View File

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

View File

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

View File

@ -12,6 +12,15 @@ var AutoScrollMixin = {
}, },
}; };
var Key = {
UP: 38,
DOWN: 40,
LEFT: 37,
RIGHT: 39,
ENTER: 13,
ESC: 27
}
const PayloadSources = { const PayloadSources = {
VIEW: "view", VIEW: "view",
SERVER: "server" SERVER: "server"
@ -274,9 +283,8 @@ _.extend(FlowView.prototype, EventEmitter.prototype, {
this.emit("change"); this.emit("change");
}, },
_update: function(flow){ _update: function(flow){
console.debug("FIXME: Use UUID");
var idx = _.findIndex(this.flows, function(f){ var idx = _.findIndex(this.flows, function(f){
return flow.request.timestamp_start == f.request.timestamp_start; return flow.id === f.id;
}); });
if(idx < 0){ if(idx < 0){
@ -300,7 +308,13 @@ _.extend(_FlowStore.prototype, EventEmitter.prototype, {
var view = new FlowView(this, !since); var view = new FlowView(this, !since);
$.getJSON("/static/flows.json", function(flows){ $.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); view.add_bulk(flows);
}); });
return view; return view;
@ -447,38 +461,6 @@ var Header = React.createClass({displayName: 'Header',
/** @jsx React.DOM */ /** @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', var TLSColumn = React.createClass({displayName: 'TLSColumn',
statics: { statics: {
@ -573,6 +555,54 @@ var TimeColumn = React.createClass({displayName: 'TimeColumn',
var all_columns = [TLSColumn, IconColumn, PathColumn, MethodColumn, StatusColumn, 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', var FlowTable = React.createClass({displayName: 'FlowTable',
getInitialState: function () { getInitialState: function () {
return { return {
@ -593,17 +623,87 @@ var FlowTable = React.createClass({displayName: 'FlowTable',
flows: this.flowStore.getAll() flows: this.flowStore.getAll()
}); });
}, },
onClick: function(e){ selectFlow: function(flow){
console.log("rowclick", e); 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 () { render: function () {
var flows = this.state.flows.map(function(flow){ 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.div(null, flow.request.method, " ", flow.request.scheme, "://", flow.request.host, flow.request.path);
}); });
return ( return (
React.DOM.main({onScroll: this.onScroll},
React.DOM.table({className: "flow-table"}, React.DOM.table({className: "flow-table"},
FlowTableHead({columns: this.state.columns}), FlowTableHead({ref: "head",
FlowTableBody({onClick: this.onClick, columns: this.state.columns, flows: this.state.flows}) 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 ( return (
React.DOM.div({id: "container"}, React.DOM.div({id: "container"},
Header({settings: this.state.settings}), 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, this.state.settings.showEventLog ? EventLog(null) : null,
Footer({settings: this.state.settings}) Footer({settings: this.state.settings})
) )

View File

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

View File

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

View File

@ -13,7 +13,7 @@ html, body, #container {
} }
} }
#main { main {
flex: 1 1 auto; flex: 1 1 auto;
overflow: 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 flow: flow
}); });
}.bind(this)); }.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({ var FlowTableBody = React.createClass({
render: function(){ render: function(){
var rows = this.props.flows.map(function(flow){ var rows = this.props.flows.map(function(flow){
//TODO: Add UUID var selected = (flow == this.props.selected);
return <FlowRow onClick={this.props.onClick} flow={flow} columns={this.props.columns}/>; return <FlowRow key={flow.id}
ref={flow.id}
flow={flow}
columns={this.props.columns}
selected={selected}
selectFlow={this.props.selectFlow}
/>;
}.bind(this)); }.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({ var FlowTable = React.createClass({
getInitialState: function () { getInitialState: function () {
return { return {
@ -146,18 +66,88 @@ var FlowTable = React.createClass({
flows: this.flowStore.getAll() flows: this.flowStore.getAll()
}); });
}, },
onClick: function(e){ selectFlow: function(flow){
console.log("rowclick", e); 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 () { render: function () {
var flows = this.state.flows.map(function(flow){ var flows = this.state.flows.map(function(flow){
return <div>{flow.request.method} {flow.request.scheme}://{flow.request.host}{flow.request.path}</div>; return <div>{flow.request.method} {flow.request.scheme}://{flow.request.host}{flow.request.path}</div>;
}); });
return ( return (
<main onScroll={this.onScroll}>
<table className="flow-table"> <table className="flow-table">
<FlowTableHead columns={this.state.columns}/> <FlowTableHead ref="head"
<FlowTableBody onClick={this.onClick} columns={this.state.columns} flows={this.state.flows}/> 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> </table>
</main>
); );
} }
}); });

View File

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

View File

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

View File

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