Merge pull request #414 from mitmproxy/flowviews2

Flowviews2
This commit is contained in:
Aldo Cortesi 2014-12-12 22:08:15 +13:00
commit 01fa5d3f07
49 changed files with 16997 additions and 30742 deletions

View File

@ -277,16 +277,16 @@ class ConsoleState(flow.State):
d = self.flowsettings.get(flow, {}) d = self.flowsettings.get(flow, {})
return d.get(key, default) return d.get(key, default)
def add_request(self, f): def add_flow(self, f):
flow.State.add_request(self, f) super(ConsoleState, self).add_flow(f)
if self.focus is None: if self.focus is None:
self.set_focus(0) self.set_focus(0)
elif self.follow_focus: elif self.follow_focus:
self.set_focus(len(self.view) - 1) self.set_focus(len(self.view) - 1)
return f return f
def add_response(self, resp): def update_flow(self, f):
f = flow.State.add_response(self, resp) super(ConsoleState, self).update_flow(f)
if self.focus is None: if self.focus is None:
self.set_focus(0) self.set_focus(0)
return f return f

View File

@ -2,6 +2,7 @@
This module provides more sophisticated flow tracking and provides filtering and interception facilities. This module provides more sophisticated flow tracking and provides filtering and interception facilities.
""" """
from __future__ import absolute_import from __future__ import absolute_import
from abc import abstractmethod, ABCMeta
import hashlib import hashlib
import Cookie import Cookie
import cookielib import cookielib
@ -338,80 +339,216 @@ class StickyAuthState:
f.request.headers["authorization"] = self.hosts[host] f.request.headers["authorization"] = self.hosts[host]
class State(object): class FlowList(object):
def __init__(self): __metaclass__ = ABCMeta
self._flow_list = []
self.view = []
# These are compiled filt expressions: def __iter__(self):
self._limit = None return iter(self._list)
self.intercept = None
@property def __contains__(self, item):
def limit_txt(self): return item in self._list
if self._limit:
return self._limit.pattern
else:
return None
def flow_count(self): def __getitem__(self, item):
return len(self._flow_list) return self._list[item]
def __nonzero__(self):
return bool(self._list)
def __len__(self):
return len(self._list)
def index(self, f): def index(self, f):
return self._flow_list.index(f) return self._list.index(f)
def active_flow_count(self): @abstractmethod
def _add(self, f):
return
@abstractmethod
def _update(self, f):
return
@abstractmethod
def _remove(self, f):
return
class FlowView(FlowList):
def __init__(self, store, filt=None):
self._list = []
if not filt:
filt = lambda flow: True
self._build(store, filt)
self.store = store
self.store.views.append(self)
def _close(self):
self.store.views.remove(self)
def _build(self, flows, filt=None):
if filt:
self.filt = filt
self._list = list(filter(self.filt, flows))
def _add(self, f):
if self.filt(f):
self._list.append(f)
def _update(self, f):
if f not in self._list:
self._add(f)
elif not self.filt(f):
self._remove(f)
def _remove(self, f):
if f in self._list:
self._list.remove(f)
def _recalculate(self, flows):
self._build(flows)
class FlowStore(FlowList):
"""
Responsible for handling flows in the state:
Keeps a list of all flows and provides views on them.
"""
def __init__(self):
self._list = []
self._set = set() # Used for O(1) lookups
self.views = []
self._recalculate_views()
def __contains__(self, f):
return f in self._set
def _add(self, f):
"""
Adds a flow to the state.
The flow to add must not be present in the state.
"""
self._list.append(f)
self._set.add(f)
for view in self.views:
view._add(f)
def _update(self, f):
"""
Notifies the state that a flow has been updated.
The flow must be present in the state.
"""
for view in self.views:
view._update(f)
def _remove(self, f):
"""
Deletes a flow from the state.
The flow must be present in the state.
"""
self._list.remove(f)
self._set.remove(f)
for view in self.views:
view._remove(f)
# Expensive bulk operations
def _extend(self, flows):
"""
Adds a list of flows to the state.
The list of flows to add must not contain flows that are already in the state.
"""
self._list.extend(flows)
self._set.update(flows)
self._recalculate_views()
def _clear(self):
self._list = []
self._set = set()
self._recalculate_views()
def _recalculate_views(self):
"""
Expensive operation: Recalculate all the views after a bulk change.
"""
for view in self.views:
view._recalculate(self)
# Utility functions.
# There are some common cases where we need to argue about all flows
# irrespective of filters on the view etc (i.e. on shutdown).
def active_count(self):
c = 0 c = 0
for i in self._flow_list: for i in self._list:
if not i.response and not i.error: if not i.response and not i.error:
c += 1 c += 1
return c return c
def add_request(self, flow): # TODO: Should accept_all operate on views or on all flows?
""" def accept_all(self):
Add a request to the state. Returns the matching flow. for f in self._list:
""" f.accept_intercept()
if flow in self._flow_list: # catch flow replay
return flow
self._flow_list.append(flow)
if flow.match(self._limit):
self.view.append(flow)
return flow
def add_response(self, f): def kill_all(self, master):
for f in self._list:
f.kill(master)
class State(object):
def __init__(self):
self.flows = FlowStore()
self.view = FlowView(self.flows, None)
# These are compiled filt expressions:
self.intercept = None
@property
def limit_txt(self):
return getattr(self.view.filt, "pattern", None)
def flow_count(self):
return len(self.flows)
# TODO: All functions regarding flows that don't cause side-effects should be moved into FlowStore.
def index(self, f):
return self.flows.index(f)
def active_flow_count(self):
return self.flows.active_count()
def add_flow(self, f):
""" """
Add a response to the state. Returns the matching flow. Add a request to the state.
""" """
if not f: self.flows._add(f)
return False
if f.match(self._limit) and not f in self.view:
self.view.append(f)
return f return f
def add_error(self, f): def update_flow(self, f):
""" """
Add an error response to the state. Returns the matching flow, or Add a response to the state.
None if there isn't one.
""" """
if not f: self.flows._update(f)
return None
if f.match(self._limit) and not f in self.view:
self.view.append(f)
return f return f
def delete_flow(self, f):
self.flows._remove(f)
def load_flows(self, flows): def load_flows(self, flows):
self._flow_list.extend(flows) self.flows._extend(flows)
self.recalculate_view()
def set_limit(self, txt): def set_limit(self, txt):
if txt == self.limit_txt:
return
if txt: if txt:
f = filt.parse(txt) f = filt.parse(txt)
if not f: if not f:
return "Invalid filter expression." return "Invalid filter expression."
self._limit = f self.view._close()
self.view = FlowView(self.flows, f)
else: else:
self._limit = None self.view._close()
self.recalculate_view() self.view = FlowView(self.flows, None)
def set_intercept(self, txt): def set_intercept(self, txt):
if txt: if txt:
@ -419,37 +556,24 @@ class State(object):
if not f: if not f:
return "Invalid filter expression." return "Invalid filter expression."
self.intercept = f self.intercept = f
self.intercept_txt = txt
else: else:
self.intercept = None self.intercept = None
self.intercept_txt = None
def recalculate_view(self): @property
if self._limit: def intercept_txt(self):
self.view = [i for i in self._flow_list if i.match(self._limit)] return getattr(self.intercept, "pattern", None)
else:
self.view = self._flow_list[:]
def delete_flow(self, f):
self._flow_list.remove(f)
if f in self.view:
self.view.remove(f)
return True
def clear(self): def clear(self):
for i in self._flow_list[:]: self.flows._clear()
self.delete_flow(i)
def accept_all(self): def accept_all(self):
for i in self._flow_list[:]: self.flows.accept_all()
i.accept_intercept()
def revert(self, f): def revert(self, f):
f.revert() f.revert()
def killall(self, master): def killall(self, master):
for i in self._flow_list: self.flows.kill_all(master)
i.kill(master)
class FlowMaster(controller.Master): class FlowMaster(controller.Master):
@ -716,7 +840,7 @@ class FlowMaster(controller.Master):
sc.reply() sc.reply()
def handle_error(self, f): def handle_error(self, f):
self.state.add_error(f) self.state.update_flow(f)
self.run_script_hook("error", f) self.run_script_hook("error", f)
if self.client_playback: if self.client_playback:
self.client_playback.clear(f) self.client_playback.clear(f)
@ -736,7 +860,8 @@ class FlowMaster(controller.Master):
self.add_event("Error in wsgi app. %s"%err, "error") self.add_event("Error in wsgi app. %s"%err, "error")
f.reply(protocol.KILL) f.reply(protocol.KILL)
return return
self.state.add_request(f) if f not in self.state.flows: # don't add again on replay
self.state.add_flow(f)
self.replacehooks.run(f) self.replacehooks.run(f)
self.setheaders.run(f) self.setheaders.run(f)
self.run_script_hook("request", f) self.run_script_hook("request", f)
@ -757,7 +882,7 @@ class FlowMaster(controller.Master):
return f return f
def handle_response(self, f): def handle_response(self, f):
self.state.add_response(f) self.state.update_flow(f)
self.replacehooks.run(f) self.replacehooks.run(f)
self.setheaders.run(f) self.setheaders.run(f)
self.run_script_hook("response", f) self.run_script_hook("response", f)
@ -772,7 +897,7 @@ class FlowMaster(controller.Master):
self.unload_scripts() self.unload_scripts()
controller.Master.shutdown(self) controller.Master.shutdown(self)
if self.stream: if self.stream:
for i in self.state._flow_list: for i in self.state.flows:
if not i.response: if not i.response:
self.stream.add(i) self.stream.add(i)
self.stop_stream() self.stop_stream()

View File

@ -117,7 +117,10 @@ class HTTPMessage(stateobject.StateObject):
def get_state(self, short=False): def get_state(self, short=False):
ret = super(HTTPMessage, self).get_state(short) ret = super(HTTPMessage, self).get_state(short)
if short: if short:
if self.content:
ret["contentLength"] = len(self.content) ret["contentLength"] = len(self.content)
else:
ret["contentLength"] = 0
return ret return ret
def get_decoded_content(self): def get_decoded_content(self):

View File

@ -1,4 +1,5 @@
from __future__ import absolute_import, print_function from __future__ import absolute_import, print_function
import collections
import tornado.ioloop import tornado.ioloop
import tornado.httpserver import tornado.httpserver
from .. import controller, flow from .. import controller, flow
@ -9,10 +10,64 @@ class Stop(Exception):
pass pass
class WebFlowView(flow.FlowView):
def __init__(self, store):
super(WebFlowView, self).__init__(store, None)
def _add(self, f):
super(WebFlowView, self)._add(f)
app.ClientConnection.broadcast(
type="flows",
cmd="add",
data=f.get_state(short=True)
)
def _update(self, f):
super(WebFlowView, self)._update(f)
app.ClientConnection.broadcast(
type="flows",
cmd="update",
data=f.get_state(short=True)
)
def _remove(self, f):
super(WebFlowView, self)._remove(f)
app.ClientConnection.broadcast(
type="flows",
cmd="remove",
data=f.get_state(short=True)
)
def _recalculate(self, flows):
super(WebFlowView, self)._recalculate(flows)
app.ClientConnection.broadcast(
type="flows",
cmd="reset"
)
class WebState(flow.State): class WebState(flow.State):
def __init__(self): def __init__(self):
flow.State.__init__(self) super(WebState, self).__init__()
self.view._close()
self.view = WebFlowView(self.flows)
self._last_event_id = 0
self.events = collections.deque(maxlen=1000)
def add_event(self, e, level):
self._last_event_id += 1
entry = {
"id": self._last_event_id,
"message": e,
"level": level
}
self.events.append(entry)
app.ClientConnection.broadcast(
type="events",
cmd="add",
data=entry
)
class Options(object): class Options(object):
attributes = [ attributes = [
@ -58,10 +113,8 @@ class Options(object):
class WebMaster(flow.FlowMaster): class WebMaster(flow.FlowMaster):
def __init__(self, server, options): def __init__(self, server, options):
self.options = options self.options = options
self.app = app.Application(self.options.wdebug)
super(WebMaster, self).__init__(server, WebState()) super(WebMaster, self).__init__(server, WebState())
self.app = app.Application(self.state, self.options.wdebug)
self.last_log_id = 0
def tick(self): def tick(self):
flow.FlowMaster.tick(self, self.masterq, timeout=0) flow.FlowMaster.tick(self, self.masterq, timeout=0)
@ -83,33 +136,17 @@ class WebMaster(flow.FlowMaster):
self.shutdown() self.shutdown()
def handle_request(self, f): def handle_request(self, f):
app.ClientConnection.broadcast("add_flow", f.get_state(True)) super(WebMaster, self).handle_request(f)
flow.FlowMaster.handle_request(self, f)
if f: if f:
f.reply() f.reply()
return f return f
def handle_response(self, f): def handle_response(self, f):
app.ClientConnection.broadcast("update_flow", f.get_state(True)) super(WebMaster, self).handle_response(f)
flow.FlowMaster.handle_response(self, f)
if f: if f:
f.reply() f.reply()
return f return f
def handle_error(self, f): def add_event(self, e, level="info"):
app.ClientConnection.broadcast("update_flow", f.get_state(True)) super(WebMaster, self).add_event(e, level)
flow.FlowMaster.handle_error(self, f) self.state.add_event(e, level)
return f
def handle_log(self, l):
self.last_log_id += 1
app.ClientConnection.broadcast(
"add_event", {
"id": self.last_log_id,
"message": l.msg,
"level": l.level
}
)
self.add_event(l.msg, l.level)
l.reply()

View File

@ -1,51 +1,84 @@
import os.path import os.path
import sys
import tornado.web import tornado.web
import tornado.websocket import tornado.websocket
import logging import logging
import json import json
from .. import flow
class IndexHandler(tornado.web.RequestHandler): class IndexHandler(tornado.web.RequestHandler):
def get(self): def get(self):
_ = self.xsrf_token # https://github.com/tornadoweb/tornado/issues/645
self.render("index.html") self.render("index.html")
class ClientConnection(tornado.websocket.WebSocketHandler): class WebSocketEventBroadcaster(tornado.websocket.WebSocketHandler):
connections = set() connections = None # raise an error if inherited class doesn't specify its own instance.
def open(self): def open(self):
ClientConnection.connections.add(self) self.connections.add(self)
def on_close(self): def on_close(self):
ClientConnection.connections.remove(self) self.connections.remove(self)
@classmethod @classmethod
def broadcast(cls, type, data): def broadcast(cls, **kwargs):
message = json.dumps(kwargs)
for conn in cls.connections: for conn in cls.connections:
try: try:
conn.write_message( conn.write_message(message)
json.dumps(
{
"type": type,
"data": data
}
)
)
except: except:
logging.error("Error sending message", exc_info=True) logging.error("Error sending message", exc_info=True)
class Flows(tornado.web.RequestHandler):
def get(self):
self.write(dict(
data=[f.get_state(short=True) for f in self.application.state.flows]
))
class Events(tornado.web.RequestHandler):
def get(self):
self.write(dict(
data=list(self.application.state.events)
))
class Settings(tornado.web.RequestHandler):
def get(self):
self.write(dict(
data=dict(
showEventLog=True
)
))
class FlowClear(tornado.web.RequestHandler):
def post(self):
self.application.state.clear()
class ClientConnection(WebSocketEventBroadcaster):
connections = set()
class Application(tornado.web.Application): class Application(tornado.web.Application):
def __init__(self, debug): def __init__(self, state, debug):
self.state = state
handlers = [ handlers = [
(r"/", IndexHandler), (r"/", IndexHandler),
(r"/updates", ClientConnection), (r"/updates", ClientConnection),
(r"/events", Events),
(r"/flows", Flows),
(r"/settings", Settings),
(r"/flows/clear", FlowClear),
] ]
settings = dict( settings = dict(
template_path=os.path.join(os.path.dirname(__file__), "templates"), template_path=os.path.join(os.path.dirname(__file__), "templates"),
static_path=os.path.join(os.path.dirname(__file__), "static"), static_path=os.path.join(os.path.dirname(__file__), "static"),
xsrf_cookies=True, xsrf_cookies=True,
cookie_secret="__TODO:_GENERATE_YOUR_OWN_RANDOM_VALUE_HERE__", cookie_secret=os.urandom(256),
debug=debug, debug=debug,
) )
tornado.web.Application.__init__(self, handlers, **settings) tornado.web.Application.__init__(self, handlers, **settings)

View File

@ -270,7 +270,7 @@ header .menu {
margin-left: 3px; margin-left: 3px;
} }
footer { footer {
box-shadow: 0 -1px 3px #d3d3d3; box-shadow: 0 -1px 3px lightgray;
padding: 0px 10px 3px; padding: 0px 10px 3px;
} }

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -15,7 +15,7 @@ class TestConsoleState:
""" """
c = console.ConsoleState() c = console.ConsoleState()
f = self._add_request(c) f = self._add_request(c)
assert f in c._flow_list assert f in c.flows
assert c.get_focus() == (f, 0) assert c.get_focus() == (f, 0)
def test_focus(self): def test_focus(self):
@ -52,19 +52,19 @@ class TestConsoleState:
def _add_request(self, state): def _add_request(self, state):
f = tutils.tflow() f = tutils.tflow()
return state.add_request(f) return state.add_flow(f)
def _add_response(self, state): def _add_response(self, state):
f = self._add_request(state) f = self._add_request(state)
f.response = tutils.tresp() f.response = tutils.tresp()
state.add_response(f) state.update_flow(f)
def test_add_response(self): def test_add_response(self):
c = console.ConsoleState() c = console.ConsoleState()
f = self._add_request(c) f = self._add_request(c)
f.response = tutils.tresp() f.response = tutils.tresp()
c.focus = None c.focus = None
c.add_response(f) c.update_flow(f)
def test_focus_view(self): def test_focus_view(self):
c = console.ConsoleState() c = console.ConsoleState()

View File

@ -364,7 +364,7 @@ class TestState:
def test_backup(self): def test_backup(self):
c = flow.State() c = flow.State()
f = tutils.tflow() f = tutils.tflow()
c.add_request(f) c.add_flow(f)
f.backup() f.backup()
c.revert(f) c.revert(f)
@ -376,42 +376,42 @@ class TestState:
""" """
c = flow.State() c = flow.State()
f = tutils.tflow() f = tutils.tflow()
c.add_request(f) c.add_flow(f)
assert f assert f
assert c.flow_count() == 1 assert c.flow_count() == 1
assert c.active_flow_count() == 1 assert c.active_flow_count() == 1
newf = tutils.tflow() newf = tutils.tflow()
assert c.add_request(newf) assert c.add_flow(newf)
assert c.active_flow_count() == 2 assert c.active_flow_count() == 2
f.response = tutils.tresp() f.response = tutils.tresp()
assert c.add_response(f) assert c.update_flow(f)
assert c.flow_count() == 2 assert c.flow_count() == 2
assert c.active_flow_count() == 1 assert c.active_flow_count() == 1
_ = tutils.tresp() _ = tutils.tresp()
assert not c.add_response(None) assert not c.update_flow(None)
assert c.active_flow_count() == 1 assert c.active_flow_count() == 1
newf.response = tutils.tresp() newf.response = tutils.tresp()
assert c.add_response(newf) assert c.update_flow(newf)
assert c.active_flow_count() == 0 assert c.active_flow_count() == 0
def test_err(self): def test_err(self):
c = flow.State() c = flow.State()
f = tutils.tflow() f = tutils.tflow()
c.add_request(f) c.add_flow(f)
f.error = Error("message") f.error = Error("message")
assert c.add_error(f) assert c.update_flow(f)
c = flow.State() c = flow.State()
f = tutils.tflow() f = tutils.tflow()
c.add_request(f) c.add_flow(f)
c.set_limit("~e") c.set_limit("~e")
assert not c.view assert not c.view
f.error = tutils.terr() f.error = tutils.terr()
assert c.add_error(f) assert c.update_flow(f)
assert c.view assert c.view
def test_set_limit(self): def test_set_limit(self):
@ -420,20 +420,20 @@ class TestState:
f = tutils.tflow() f = tutils.tflow()
assert len(c.view) == 0 assert len(c.view) == 0
c.add_request(f) c.add_flow(f)
assert len(c.view) == 1 assert len(c.view) == 1
c.set_limit("~s") c.set_limit("~s")
assert c.limit_txt == "~s" assert c.limit_txt == "~s"
assert len(c.view) == 0 assert len(c.view) == 0
f.response = tutils.tresp() f.response = tutils.tresp()
c.add_response(f) c.update_flow(f)
assert len(c.view) == 1 assert len(c.view) == 1
c.set_limit(None) c.set_limit(None)
assert len(c.view) == 1 assert len(c.view) == 1
f = tutils.tflow() f = tutils.tflow()
c.add_request(f) c.add_flow(f)
assert len(c.view) == 2 assert len(c.view) == 2
c.set_limit("~q") c.set_limit("~q")
assert len(c.view) == 1 assert len(c.view) == 1
@ -452,18 +452,18 @@ class TestState:
def _add_request(self, state): def _add_request(self, state):
f = tutils.tflow() f = tutils.tflow()
state.add_request(f) state.add_flow(f)
return f return f
def _add_response(self, state): def _add_response(self, state):
f = tutils.tflow() f = tutils.tflow()
state.add_request(f) state.add_flow(f)
f.response = tutils.tresp() f.response = tutils.tresp()
state.add_response(f) state.update_flow(f)
def _add_error(self, state): def _add_error(self, state):
f = tutils.tflow(err=True) f = tutils.tflow(err=True)
state.add_request(f) state.add_flow(f)
def test_clear(self): def test_clear(self):
c = flow.State() c = flow.State()
@ -487,7 +487,7 @@ class TestState:
c.clear() c.clear()
c.load_flows(flows) c.load_flows(flows)
assert isinstance(c._flow_list[0], Flow) assert isinstance(c.flows[0], Flow)
def test_accept_all(self): def test_accept_all(self):
c = flow.State() c = flow.State()
@ -532,7 +532,7 @@ class TestSerialize:
s = flow.State() s = flow.State()
fm = flow.FlowMaster(None, s) fm = flow.FlowMaster(None, s)
fm.load_flows(r) fm.load_flows(r)
assert len(s._flow_list) == 6 assert len(s.flows) == 6
def test_load_flows_reverse(self): def test_load_flows_reverse(self):
r = self._treader() r = self._treader()
@ -540,7 +540,7 @@ class TestSerialize:
conf = ProxyConfig(mode="reverse", upstream_server=[True,True,"use-this-domain",80]) conf = ProxyConfig(mode="reverse", upstream_server=[True,True,"use-this-domain",80])
fm = flow.FlowMaster(DummyServer(conf), s) fm = flow.FlowMaster(DummyServer(conf), s)
fm.load_flows(r) fm.load_flows(r)
assert s._flow_list[0].request.host == "use-this-domain" assert s.flows[0].request.host == "use-this-domain"
def test_filter(self): def test_filter(self):
sio = StringIO() sio = StringIO()

View File

@ -747,19 +747,19 @@ class TestProxyChainingSSLReconnect(tservers.HTTPUpstreamProxTest):
assert req.content == "content" assert req.content == "content"
assert req.status_code == 418 assert req.status_code == 418
assert not self.chain[1].tmaster.state._flow_list[0].response # killed assert not self.chain[1].tmaster.state.flows[0].response # killed
assert self.chain[1].tmaster.state._flow_list[1].response assert self.chain[1].tmaster.state.flows[1].response
assert self.proxy.tmaster.state._flow_list[0].request.form_in == "authority" assert self.proxy.tmaster.state.flows[0].request.form_in == "authority"
assert self.proxy.tmaster.state._flow_list[1].request.form_in == "relative" assert self.proxy.tmaster.state.flows[1].request.form_in == "relative"
assert self.chain[0].tmaster.state._flow_list[0].request.form_in == "authority" assert self.chain[0].tmaster.state.flows[0].request.form_in == "authority"
assert self.chain[0].tmaster.state._flow_list[1].request.form_in == "relative" assert self.chain[0].tmaster.state.flows[1].request.form_in == "relative"
assert self.chain[0].tmaster.state._flow_list[2].request.form_in == "authority" assert self.chain[0].tmaster.state.flows[2].request.form_in == "authority"
assert self.chain[0].tmaster.state._flow_list[3].request.form_in == "relative" assert self.chain[0].tmaster.state.flows[3].request.form_in == "relative"
assert self.chain[1].tmaster.state._flow_list[0].request.form_in == "relative" assert self.chain[1].tmaster.state.flows[0].request.form_in == "relative"
assert self.chain[1].tmaster.state._flow_list[1].request.form_in == "relative" assert self.chain[1].tmaster.state.flows[1].request.form_in == "relative"
req = p.request("get:'/p/418:b\"content2\"'") req = p.request("get:'/p/418:b\"content2\"'")

View File

@ -10,8 +10,7 @@
"qunit": "", "qunit": "",
"benchmark": "", "benchmark": "",
"benchmarkjs-runner": "", "benchmarkjs-runner": "",
"bootstrap": "", "bootstrap": ""
"react-bootstrap": ""
}, },
"install": { "install": {
"path": "src/vendor", "path": "src/vendor",

View File

@ -30,19 +30,18 @@ var path = {
'vendor/lodash/lodash.js', 'vendor/lodash/lodash.js',
'vendor/react/react-with-addons.js', 'vendor/react/react-with-addons.js',
'vendor/react-router/react-router.js', 'vendor/react-router/react-router.js',
'vendor/react-bootstrap/react-bootstrap.js'
], ],
app: [ app: [
'js/utils.js', 'js/utils.js',
'js/dispatcher.js', 'js/dispatcher.js',
'js/actions.js', 'js/actions.js',
'js/flow/utils.js', 'js/flow/utils.js',
'js/stores/base.js', 'js/store/store.js',
'js/stores/settingstore.js', 'js/store/view.js',
'js/stores/eventlogstore.js', 'js/store/settingstore.js',
'js/stores/flowstore.js',
'js/connection.js', 'js/connection.js',
'js/components/utils.jsx.js', 'js/components/utils.jsx.js',
'js/components/virtualscroll.jsx.js',
'js/components/header.jsx.js', 'js/components/header.jsx.js',
'js/components/flowtable-columns.jsx.js', 'js/components/flowtable-columns.jsx.js',
'js/components/flowtable.jsx.js', 'js/components/flowtable.jsx.js',

View File

@ -3,24 +3,24 @@
"private": true, "private": true,
"devDependencies": { "devDependencies": {
"bower": "", "bower": "",
"bower-installer": "^0.8.4", "bower-installer": "",
"clean-css": "", "clean-css": "",
"gulp": "^3.8.8", "gulp": "",
"gulp-concat": "^2.4.0", "gulp-concat": "",
"gulp-jshint": "^1.8.4", "gulp-jshint": "",
"gulp-less": "^1.3.5", "gulp-less": "",
"gulp-livereload": "^2.1.1", "gulp-livereload": "",
"gulp-minify-css": "^0.3.8", "gulp-minify-css": "",
"gulp-notify": "^1.6.0", "gulp-notify": "",
"gulp-plumber": "^0.6.5", "gulp-plumber": "",
"gulp-qunit": "^0.3.3", "gulp-qunit": "",
"gulp-react": "^1.0.1", "gulp-react": "",
"gulp-rename": "^1.2.0", "gulp-rename": "",
"gulp-sourcemaps": "^1.1.5", "gulp-sourcemaps": "",
"gulp-uglify": "^1.0.1", "gulp-uglify": "",
"gulp-util": "^3.0.1", "gulp-util": "",
"jshint-stylish": "^0.4.0", "jshint-stylish": "",
"merge-stream": "^0.1.5", "merge-stream": "",
"react": "", "react": "",
"react-tools": "" "react-tools": ""
} }

File diff suppressed because it is too large Load Diff

View File

@ -1,37 +1,64 @@
var ActionTypes = { var ActionTypes = {
//Settings // Connection
UPDATE_SETTINGS: "update_settings", CONNECTION_OPEN: "connection_open",
CONNECTION_CLOSE: "connection_close",
CONNECTION_ERROR: "connection_error",
//EventLog // Stores
ADD_EVENT: "add_event", SETTINGS_STORE: "settings",
EVENT_STORE: "events",
FLOW_STORE: "flows",
};
//Flow var StoreCmds = {
ADD_FLOW: "add_flow", ADD: "add",
UPDATE_FLOW: "update_flow", UPDATE: "update",
REMOVE: "remove",
RESET: "reset"
};
var ConnectionActions = {
open: function () {
AppDispatcher.dispatchViewAction({
type: ActionTypes.CONNECTION_OPEN
});
},
close: function () {
AppDispatcher.dispatchViewAction({
type: ActionTypes.CONNECTION_CLOSE
});
},
error: function () {
AppDispatcher.dispatchViewAction({
type: ActionTypes.CONNECTION_ERROR
});
}
}; };
var SettingsActions = { var SettingsActions = {
update: function (settings) { update: function (settings) {
settings = _.merge({}, SettingsStore.getAll(), settings);
//TODO: Update server. //TODO: Update server.
//Facebook Flux: We do an optimistic update on the client already. //Facebook Flux: We do an optimistic update on the client already.
AppDispatcher.dispatchViewAction({ AppDispatcher.dispatchViewAction({
type: ActionTypes.UPDATE_SETTINGS, type: ActionTypes.SETTINGS_STORE,
settings: settings cmd: StoreCmds.UPDATE,
data: settings
}); });
} }
}; };
var event_id = 0; var EventLogActions_event_id = 0;
var EventLogActions = { var EventLogActions = {
add_event: function(message){ add_event: function (message) {
AppDispatcher.dispatchViewAction({ AppDispatcher.dispatchViewAction({
type: ActionTypes.ADD_EVENT, type: ActionTypes.EVENT_STORE,
cmd: StoreCmds.ADD,
data: { data: {
message: message, message: message,
level: "web", level: "web",
id: "viewAction-"+event_id++ id: "viewAction-" + EventLogActions_event_id++
} }
}); });
} }

View File

@ -1,4 +1,7 @@
$(function () { $(function () {
Connection.init(); window.ws = new Connection("/updates");
app = React.renderComponent(ProxyApp, document.body);
ReactRouter.run(routes, function (Handler) {
React.render(<Handler/>, document.body);
});
}); });

View File

@ -1,10 +1,8 @@
/** @jsx React.DOM */
var LogMessage = React.createClass({ var LogMessage = React.createClass({
render: function(){ render: function () {
var entry = this.props.entry; var entry = this.props.entry;
var indicator; var indicator;
switch(entry.level){ switch (entry.level) {
case "web": case "web":
indicator = <i className="fa fa-fw fa-html5"></i>; indicator = <i className="fa fa-fw fa-html5"></i>;
break; break;
@ -20,47 +18,79 @@ var LogMessage = React.createClass({
</div> </div>
); );
}, },
shouldComponentUpdate: function(){ shouldComponentUpdate: function () {
return false; // log entries are immutable. return false; // log entries are immutable.
} }
}); });
var EventLogContents = React.createClass({ var EventLogContents = React.createClass({
mixins:[AutoScrollMixin], mixins: [AutoScrollMixin, VirtualScrollMixin],
getInitialState: function () { getInitialState: function () {
return { return {
log: [] log: []
}; };
}, },
componentDidMount: function () { componentWillMount: function () {
this.log = EventLogStore.getView(); this.openView(this.props.eventStore);
this.log.addListener("change", this.onEventLogChange);
}, },
componentWillUnmount: function () { componentWillUnmount: function () {
this.log.removeListener("change", this.onEventLogChange); this.closeView();
this.log.close(); },
openView: function (store) {
var view = new StoreView(store, function (entry) {
return this.props.filter[entry.level];
}.bind(this));
this.setState({
view: view
});
view.addListener("add recalculate", this.onEventLogChange);
},
closeView: function () {
this.state.view.close();
}, },
onEventLogChange: function () { onEventLogChange: function () {
this.setState({ this.setState({
log: this.log.getAll() log: this.state.view.list
}); });
}, },
render: function () { componentWillReceiveProps: function (nextProps) {
var messages = this.state.log.map(function(row) { if (nextProps.filter !== this.props.filter) {
if(!this.props.filter[row.level]){ this.props.filter = nextProps.filter; // Dirty: Make sure that view filter sees the update.
return null; this.state.view.recalculate(this.props.eventStore.list);
} }
return <LogMessage key={row.id} entry={row}/>; if (nextProps.eventStore !== this.props.eventStore) {
}.bind(this)); this.closeView();
return <pre>{messages}</pre>; this.openView(nextProps.eventStore);
}
},
getDefaultProps: function () {
return {
rowHeight: 45,
rowHeightMin: 15,
placeholderTagName: "div"
};
},
renderRow: function (elem) {
return <LogMessage key={elem.id} entry={elem}/>;
},
render: function () {
var rows = this.renderRows(this.state.log);
return <pre onScroll={this.onScroll}>
{ this.getPlaceholderTop(this.state.log.length) }
{rows}
{ this.getPlaceholderBottom(this.state.log.length) }
</pre>;
} }
}); });
var ToggleFilter = React.createClass({ var ToggleFilter = React.createClass({
toggle: function(){ toggle: function (e) {
e.preventDefault();
return this.props.toggleLevel(this.props.name); return this.props.toggleLevel(this.props.name);
}, },
render: function(){ render: function () {
var className = "label "; var className = "label ";
if (this.props.active) { if (this.props.active) {
className += "label-primary"; className += "label-primary";
@ -79,7 +109,7 @@ var ToggleFilter = React.createClass({
}); });
var EventLog = React.createClass({ var EventLog = React.createClass({
getInitialState: function(){ getInitialState: function () {
return { return {
filter: { filter: {
"debug": false, "debug": false,
@ -93,11 +123,10 @@ var EventLog = React.createClass({
showEventLog: false showEventLog: false
}); });
}, },
toggleLevel: function(level){ toggleLevel: function (level) {
var filter = this.state.filter; var filter = _.extend({}, this.state.filter);
filter[level] = !filter[level]; filter[level] = !filter[level];
this.setState({filter: filter}); this.setState({filter: filter});
return false;
}, },
render: function () { render: function () {
return ( return (
@ -112,7 +141,7 @@ var EventLog = React.createClass({
</div> </div>
</div> </div>
<EventLogContents filter={this.state.filter}/> <EventLogContents filter={this.state.filter} eventStore={this.props.eventStore}/>
</div> </div>
); );
} }

View File

@ -1,14 +1,12 @@
/** @jsx React.DOM */
var FlowDetailNav = React.createClass({ var FlowDetailNav = React.createClass({
render: function(){ render: function () {
var items = this.props.tabs.map(function(e){ var items = this.props.tabs.map(function (e) {
var str = e.charAt(0).toUpperCase() + e.slice(1); var str = e.charAt(0).toUpperCase() + e.slice(1);
var className = this.props.active === e ? "active" : ""; var className = this.props.active === e ? "active" : "";
var onClick = function(){ var onClick = function (event) {
this.props.selectTab(e); this.props.selectTab(e);
return false; event.preventDefault();
}.bind(this); }.bind(this);
return <a key={e} return <a key={e}
href="#" href="#"
@ -24,11 +22,11 @@ var FlowDetailNav = React.createClass({
}); });
var Headers = React.createClass({ var Headers = React.createClass({
render: function(){ render: function () {
var rows = this.props.message.headers.map(function(header, i){ var rows = this.props.message.headers.map(function (header, i) {
return ( return (
<tr key={i}> <tr key={i}>
<td className="header-name">{header[0]+":"}</td> <td className="header-name">{header[0] + ":"}</td>
<td className="header-value">{header[1]}</td> <td className="header-value">{header[1]}</td>
</tr> </tr>
); );
@ -44,16 +42,16 @@ var Headers = React.createClass({
}); });
var FlowDetailRequest = React.createClass({ var FlowDetailRequest = React.createClass({
render: function(){ render: function () {
var flow = this.props.flow; var flow = this.props.flow;
var first_line = [ var first_line = [
flow.request.method, flow.request.method,
RequestUtils.pretty_url(flow.request), RequestUtils.pretty_url(flow.request),
"HTTP/"+ flow.response.httpversion.join(".") "HTTP/" + flow.request.httpversion.join(".")
].join(" "); ].join(" ");
var content = null; var content = null;
if(flow.request.contentLength > 0){ if (flow.request.contentLength > 0) {
content = "Request Content Size: "+ formatSize(flow.request.contentLength); content = "Request Content Size: " + formatSize(flow.request.contentLength);
} else { } else {
content = <div className="alert alert-info">No Content</div>; content = <div className="alert alert-info">No Content</div>;
} }
@ -72,16 +70,16 @@ var FlowDetailRequest = React.createClass({
}); });
var FlowDetailResponse = React.createClass({ var FlowDetailResponse = React.createClass({
render: function(){ render: function () {
var flow = this.props.flow; var flow = this.props.flow;
var first_line = [ var first_line = [
"HTTP/"+ flow.response.httpversion.join("."), "HTTP/" + flow.response.httpversion.join("."),
flow.response.code, flow.response.code,
flow.response.msg flow.response.msg
].join(" "); ].join(" ");
var content = null; var content = null;
if(flow.response.contentLength > 0){ if (flow.response.contentLength > 0) {
content = "Response Content Size: "+ formatSize(flow.response.contentLength); content = "Response Content Size: " + formatSize(flow.response.contentLength);
} else { } else {
content = <div className="alert alert-info">No Content</div>; content = <div className="alert alert-info">No Content</div>;
} }
@ -99,43 +97,67 @@ var FlowDetailResponse = React.createClass({
} }
}); });
var TimeStamp = React.createClass({ var FlowDetailError = React.createClass({
render: function() { render: function () {
var flow = this.props.flow;
return (
<section>
<div className="alert alert-warning">
{flow.error.msg}
<div><small>{ formatTimeStamp(flow.error.timestamp) }</small></div>
</div>
</section>
);
}
});
if(!this.props.t){ var TimeStamp = React.createClass({
render: function () {
if (!this.props.t) {
//should be return null, but that triggers a React bug. //should be return null, but that triggers a React bug.
return <tr></tr>; return <tr></tr>;
} }
var ts = (new Date(this.props.t * 1000)).toISOString(); var ts = formatTimeStamp(this.props.t);
ts = ts.replace("T", " ").replace("Z","");
var delta; var delta;
if(this.props.deltaTo){ if (this.props.deltaTo) {
delta = formatTimeDelta(1000 * (this.props.t-this.props.deltaTo)); delta = formatTimeDelta(1000 * (this.props.t - this.props.deltaTo));
delta = <span className="text-muted">{"(" + delta + ")"}</span>; delta = <span className="text-muted">{"(" + delta + ")"}</span>;
} else { } else {
delta = null; delta = null;
} }
return <tr><td>{this.props.title + ":"}</td><td>{ts} {delta}</td></tr>; return <tr>
<td>{this.props.title + ":"}</td>
<td>{ts} {delta}</td>
</tr>;
} }
}); });
var ConnectionInfo = React.createClass({ var ConnectionInfo = React.createClass({
render: function() { render: function () {
var conn = this.props.conn; var conn = this.props.conn;
var address = conn.address.address.join(":"); var address = conn.address.address.join(":");
var sni = <tr key="sni"></tr>; //should be null, but that triggers a React bug. var sni = <tr key="sni"></tr>; //should be null, but that triggers a React bug.
if(conn.sni){ if (conn.sni) {
sni = <tr key="sni"><td><abbr title="TLS Server Name Indication">TLS SNI:</abbr></td><td>{conn.sni}</td></tr>; sni = <tr key="sni">
<td>
<abbr title="TLS Server Name Indication">TLS SNI:</abbr>
</td>
<td>{conn.sni}</td>
</tr>;
} }
return ( return (
<table className="connection-table"> <table className="connection-table">
<tbody> <tbody>
<tr key="address"><td>Address:</td><td>{address}</td></tr> <tr key="address">
<td>Address:</td>
<td>{address}</td>
</tr>
{sni} {sni}
</tbody> </tbody>
</table> </table>
@ -144,7 +166,7 @@ var ConnectionInfo = React.createClass({
}); });
var CertificateInfo = React.createClass({ var CertificateInfo = React.createClass({
render: function(){ render: function () {
//TODO: We should fetch human-readable certificate representation //TODO: We should fetch human-readable certificate representation
// from the server // from the server
var flow = this.props.flow; var flow = this.props.flow;
@ -165,7 +187,7 @@ var CertificateInfo = React.createClass({
}); });
var Timing = React.createClass({ var Timing = React.createClass({
render: function(){ render: function () {
var flow = this.props.flow; var flow = this.props.flow;
var sc = flow.server_conn; var sc = flow.server_conn;
var cc = flow.client_conn; var cc = flow.client_conn;
@ -218,14 +240,14 @@ var Timing = React.createClass({
} }
//Add unique key for each row. //Add unique key for each row.
timestamps.forEach(function(e){ timestamps.forEach(function (e) {
e.key = e.title; e.key = e.title;
}); });
timestamps = _.sortBy(timestamps, 't'); timestamps = _.sortBy(timestamps, 't');
var rows = timestamps.map(function(e){ var rows = timestamps.map(function (e) {
return TimeStamp(e); return <TimeStamp {...e}/>;
}); });
return ( return (
@ -242,7 +264,7 @@ var Timing = React.createClass({
}); });
var FlowDetailConnectionInfo = React.createClass({ var FlowDetailConnectionInfo = React.createClass({
render: function(){ render: function () {
var flow = this.props.flow; var flow = this.props.flow;
var client_conn = flow.client_conn; var client_conn = flow.client_conn;
var server_conn = flow.server_conn; var server_conn = flow.server_conn;
@ -264,35 +286,65 @@ var FlowDetailConnectionInfo = React.createClass({
} }
}); });
var tabs = { var allTabs = {
request: FlowDetailRequest, request: FlowDetailRequest,
response: FlowDetailResponse, response: FlowDetailResponse,
error: FlowDetailError,
details: FlowDetailConnectionInfo details: FlowDetailConnectionInfo
}; };
var FlowDetail = React.createClass({ var FlowDetail = React.createClass({
getDefaultProps: function(){ mixins: [StickyHeadMixin, ReactRouter.Navigation, ReactRouter.State],
return { getTabs: function (flow) {
tabs: ["request","response", "details"] var tabs = [];
}; ["request", "response", "error"].forEach(function (e) {
if (flow[e]) {
tabs.push(e);
}
});
tabs.push("details");
return tabs;
}, },
mixins: [StickyHeadMixin], nextTab: function (i) {
nextTab: function(i) { var tabs = this.getTabs(this.props.flow);
var currentIndex = this.props.tabs.indexOf(this.props.active); var currentIndex = tabs.indexOf(this.getParams().detailTab);
// JS modulo operator doesn't correct negative numbers, make sure that we are positive. // JS modulo operator doesn't correct negative numbers, make sure that we are positive.
var nextIndex = (currentIndex + i + this.props.tabs.length) % this.props.tabs.length; var nextIndex = (currentIndex + i + tabs.length) % tabs.length;
this.props.selectTab(this.props.tabs[nextIndex]); this.selectTab(tabs[nextIndex]);
}, },
render: function(){ selectTab: function (panel) {
var flow = JSON.stringify(this.props.flow, null, 2); this.replaceWith(
var Tab = tabs[this.props.active]; "flow",
{
flowId: this.getParams().flowId,
detailTab: panel
}
);
},
render: function () {
var flow = this.props.flow;
var tabs = this.getTabs(flow);
var active = this.getParams().detailTab;
if (!_.contains(tabs, active)) {
if (active === "response" && flow.error) {
active = "error";
} else if (active === "error" && flow.response) {
active = "response";
} else {
active = tabs[0];
}
this.selectTab(active);
}
var Tab = allTabs[active];
return ( return (
<div className="flow-detail" onScroll={this.adjustHead}> <div className="flow-detail" onScroll={this.adjustHead}>
<FlowDetailNav ref="head" <FlowDetailNav ref="head"
tabs={this.props.tabs} tabs={tabs}
active={this.props.active} active={active}
selectTab={this.props.selectTab}/> selectTab={this.selectTab}/>
<Tab flow={this.props.flow}/> <Tab flow={flow}/>
</div> </div>
); );
} }

View File

@ -1,17 +1,14 @@
/** @jsx React.DOM */
var TLSColumn = React.createClass({ var TLSColumn = React.createClass({
statics: { statics: {
renderTitle: function(){ renderTitle: function () {
return <th key="tls" className="col-tls"></th>; return <th key="tls" className="col-tls"></th>;
} }
}, },
render: function(){ render: function () {
var flow = this.props.flow; var flow = this.props.flow;
var ssl = (flow.request.scheme == "https"); var ssl = (flow.request.scheme == "https");
var classes; var classes;
if(ssl){ if (ssl) {
classes = "col-tls col-tls-https"; classes = "col-tls col-tls-https";
} else { } else {
classes = "col-tls col-tls-http"; classes = "col-tls col-tls-http";
@ -23,23 +20,23 @@ var TLSColumn = React.createClass({
var IconColumn = React.createClass({ var IconColumn = React.createClass({
statics: { statics: {
renderTitle: function(){ renderTitle: function () {
return <th key="icon" className="col-icon"></th>; return <th key="icon" className="col-icon"></th>;
} }
}, },
render: function(){ render: function () {
var flow = this.props.flow; var flow = this.props.flow;
var icon; var icon;
if(flow.response){ if (flow.response) {
var contentType = ResponseUtils.getContentType(flow.response); var contentType = ResponseUtils.getContentType(flow.response);
//TODO: We should assign a type to the flow somewhere else. //TODO: We should assign a type to the flow somewhere else.
if(flow.response.code == 304) { if (flow.response.code == 304) {
icon = "resource-icon-not-modified"; icon = "resource-icon-not-modified";
} else if(300 <= flow.response.code && flow.response.code < 400) { } else if (300 <= flow.response.code && flow.response.code < 400) {
icon = "resource-icon-redirect"; icon = "resource-icon-redirect";
} else if(contentType && contentType.indexOf("image") >= 0) { } else if (contentType && contentType.indexOf("image") >= 0) {
icon = "resource-icon-image"; icon = "resource-icon-image";
} else if (contentType && contentType.indexOf("javascript") >= 0) { } else if (contentType && contentType.indexOf("javascript") >= 0) {
icon = "resource-icon-js"; icon = "resource-icon-js";
@ -49,23 +46,25 @@ var IconColumn = React.createClass({
icon = "resource-icon-document"; icon = "resource-icon-document";
} }
} }
if(!icon){ if (!icon) {
icon = "resource-icon-plain"; icon = "resource-icon-plain";
} }
icon += " resource-icon"; icon += " resource-icon";
return <td className="col-icon"><div className={icon}></div></td>; return <td className="col-icon">
<div className={icon}></div>
</td>;
} }
}); });
var PathColumn = React.createClass({ var PathColumn = React.createClass({
statics: { statics: {
renderTitle: function(){ renderTitle: function () {
return <th key="path" className="col-path">Path</th>; return <th key="path" className="col-path">Path</th>;
} }
}, },
render: function(){ render: function () {
var flow = this.props.flow; var flow = this.props.flow;
return <td className="col-path">{flow.request.scheme + "://" + flow.request.host + flow.request.path}</td>; return <td className="col-path">{flow.request.scheme + "://" + flow.request.host + flow.request.path}</td>;
} }
@ -74,11 +73,11 @@ var PathColumn = React.createClass({
var MethodColumn = React.createClass({ var MethodColumn = React.createClass({
statics: { statics: {
renderTitle: function(){ renderTitle: function () {
return <th key="method" className="col-method">Method</th>; return <th key="method" className="col-method">Method</th>;
} }
}, },
render: function(){ render: function () {
var flow = this.props.flow; var flow = this.props.flow;
return <td className="col-method">{flow.request.method}</td>; return <td className="col-method">{flow.request.method}</td>;
} }
@ -87,14 +86,14 @@ var MethodColumn = React.createClass({
var StatusColumn = React.createClass({ var StatusColumn = React.createClass({
statics: { statics: {
renderTitle: function(){ renderTitle: function () {
return <th key="status" className="col-status">Status</th>; return <th key="status" className="col-status">Status</th>;
} }
}, },
render: function(){ render: function () {
var flow = this.props.flow; var flow = this.props.flow;
var status; var status;
if(flow.response){ if (flow.response) {
status = flow.response.code; status = flow.response.code;
} else { } else {
status = null; status = null;
@ -106,15 +105,15 @@ var StatusColumn = React.createClass({
var SizeColumn = React.createClass({ var SizeColumn = React.createClass({
statics: { statics: {
renderTitle: function(){ renderTitle: function () {
return <th key="size" className="col-size">Size</th>; return <th key="size" className="col-size">Size</th>;
} }
}, },
render: function(){ render: function () {
var flow = this.props.flow; var flow = this.props.flow;
var total = flow.request.contentLength; var total = flow.request.contentLength;
if(flow.response){ if (flow.response) {
total += flow.response.contentLength || 0; total += flow.response.contentLength || 0;
} }
var size = formatSize(total); var size = formatSize(total);
@ -125,14 +124,14 @@ var SizeColumn = React.createClass({
var TimeColumn = React.createClass({ var TimeColumn = React.createClass({
statics: { statics: {
renderTitle: function(){ renderTitle: function () {
return <th key="time" className="col-time">Time</th>; return <th key="time" className="col-time">Time</th>;
} }
}, },
render: function(){ render: function () {
var flow = this.props.flow; var flow = this.props.flow;
var time; var time;
if(flow.response){ if (flow.response) {
time = formatTimeDelta(1000 * (flow.response.timestamp_end - flow.request.timestamp_start)); time = formatTimeDelta(1000 * (flow.response.timestamp_end - flow.request.timestamp_start));
} else { } else {
time = "..."; time = "...";

View File

@ -1,13 +1,11 @@
/** @jsx React.DOM */
var FlowRow = React.createClass({ var FlowRow = React.createClass({
render: function(){ render: function () {
var flow = this.props.flow; var flow = this.props.flow;
var columns = this.props.columns.map(function(column){ var columns = this.props.columns.map(function (Column) {
return <column key={column.displayName} flow={flow}/>; return <Column key={Column.displayName} flow={flow}/>;
}.bind(this)); }.bind(this));
var className = ""; var className = "";
if(this.props.selected){ if (this.props.selected) {
className += "selected"; className += "selected";
} }
return ( return (
@ -15,78 +13,95 @@ var FlowRow = React.createClass({
{columns} {columns}
</tr>); </tr>);
}, },
shouldComponentUpdate: function(nextProps){ shouldComponentUpdate: function (nextProps) {
var isEqual = ( return true;
this.props.columns.length === nextProps.columns.length && // Further optimization could be done here
this.props.selected === nextProps.selected && // by calling forceUpdate on flow updates, selection changes and column changes.
this.props.flow.response === nextProps.flow.response); //return (
return !isEqual; //(this.props.columns.length !== nextProps.columns.length) ||
//(this.props.selected !== nextProps.selected)
//);
} }
}); });
var FlowTableHead = React.createClass({ var FlowTableHead = React.createClass({
render: function(){ render: function () {
var columns = this.props.columns.map(function(column){ var columns = this.props.columns.map(function (column) {
return column.renderTitle(); return column.renderTitle();
}.bind(this)); }.bind(this));
return <thead><tr>{columns}</tr></thead>; return <thead>
<tr>{columns}</tr>
</thead>;
} }
}); });
var FlowTableBody = React.createClass({
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 <tbody>{rows}</tbody>;
}
});
var ROW_HEIGHT = 32;
var FlowTable = React.createClass({ var FlowTable = React.createClass({
mixins: [StickyHeadMixin, AutoScrollMixin], mixins: [StickyHeadMixin, AutoScrollMixin, VirtualScrollMixin],
getInitialState: function () { getInitialState: function () {
return { return {
columns: all_columns columns: all_columns
}; };
}, },
scrollIntoView: function(flow){ componentWillMount: function () {
// Now comes the fun part: Scroll the flow into the view. if (this.props.view) {
var viewport = this.getDOMNode(); this.props.view.addListener("add update remove recalculate", this.onChange);
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;
} }
}, },
componentWillReceiveProps: function (nextProps) {
if (nextProps.view !== this.props.view) {
if (this.props.view) {
this.props.view.removeListener("add update remove recalculate");
}
nextProps.view.addListener("add update remove recalculate", this.onChange);
}
},
getDefaultProps: function () {
return {
rowHeight: ROW_HEIGHT
};
},
onScrollFlowTable: function () {
this.adjustHead();
this.onScroll();
},
onChange: function () {
this.forceUpdate();
},
scrollIntoView: function (flow) {
this.scrollRowIntoView(
this.props.view.index(flow),
this.refs.body.getDOMNode().offsetTop
);
},
renderRow: function (flow) {
var selected = (flow === this.props.selected);
return <FlowRow key={flow.id}
ref={flow.id}
flow={flow}
columns={this.state.columns}
selected={selected}
selectFlow={this.props.selectFlow}
/>;
},
render: function () { render: function () {
//console.log("render flowtable", this.state.start, this.state.stop, this.props.selected);
var flows = this.props.view ? this.props.view.list : [];
var rows = this.renderRows(flows);
return ( return (
<div className="flow-table" onScroll={this.adjustHead}> <div className="flow-table" onScroll={this.onScrollFlowTable}>
<table> <table>
<FlowTableHead ref="head" <FlowTableHead ref="head"
columns={this.state.columns}/> columns={this.state.columns}/>
<FlowTableBody ref="body" <tbody ref="body">
flows={this.props.flows} { this.getPlaceholderTop(flows.length) }
selected={this.props.selected} {rows}
selectFlow={this.props.selectFlow} { this.getPlaceholderBottom(flows.length) }
columns={this.state.columns}/> </tbody>
</table> </table>
</div> </div>
); );

View File

@ -1,5 +1,3 @@
/** @jsx React.DOM */
var Footer = React.createClass({ var Footer = React.createClass({
render: function () { render: function () {
var mode = this.props.settings.mode; var mode = this.props.settings.mode;

View File

@ -1,5 +1,3 @@
/** @jsx React.DOM */
var MainMenu = React.createClass({ var MainMenu = React.createClass({
statics: { statics: {
title: "Traffic", title: "Traffic",
@ -10,11 +8,20 @@ var MainMenu = React.createClass({
showEventLog: !this.props.settings.showEventLog showEventLog: !this.props.settings.showEventLog
}); });
}, },
clearFlows: function () {
$.post("/flows/clear");
},
render: function () { render: function () {
return ( return (
<div> <div>
<button className={"btn " + (this.props.settings.showEventLog ? "btn-primary" : "btn-default")} onClick={this.toggleEventLog}> <button className={"btn " + (this.props.settings.showEventLog ? "btn-primary" : "btn-default")} onClick={this.toggleEventLog}>
<i className="fa fa-database"></i> Display Event Log <i className="fa fa-database"></i>
&nbsp;Display Event Log
</button>
&nbsp;
<button className="btn btn-default" onClick={this.clearFlows}>
<i className="fa fa-eraser"></i>
&nbsp;Clear Flows
</button> </button>
</div> </div>
); );
@ -43,26 +50,98 @@ var ReportsMenu = React.createClass({
} }
}); });
var FileMenu = React.createClass({
getInitialState: function () {
return {
showFileMenu: false
};
},
handleFileClick: function (e) {
e.preventDefault();
if (!this.state.showFileMenu) {
var close = function () {
this.setState({showFileMenu: false});
document.removeEventListener("click", close);
}.bind(this);
document.addEventListener("click", close);
this.setState({
showFileMenu: true
});
}
},
handleNewClick: function(e){
e.preventDefault();
console.error("unimplemented: handleNewClick");
},
handleOpenClick: function(e){
e.preventDefault();
console.error("unimplemented: handleOpenClick");
},
handleSaveClick: function(e){
e.preventDefault();
console.error("unimplemented: handleSaveClick");
},
handleShutdownClick: function(e){
e.preventDefault();
console.error("unimplemented: handleShutdownClick");
},
render: function () {
var fileMenuClass = "dropdown pull-left" + (this.state.showFileMenu ? " open" : "");
return (
<div className={fileMenuClass}>
<a href="#" className="special" onClick={this.handleFileClick}> File </a>
<ul className="dropdown-menu" role="menu">
<li>
<a href="#" onClick={this.handleNewClick}>
<i className="fa fa-fw fa-file"></i>
New
</a>
</li>
<li>
<a href="#" onClick={this.handleOpenClick}>
<i className="fa fa-fw fa-folder-open"></i>
Open
</a>
</li>
<li>
<a href="#" onClick={this.handleSaveClick}>
<i className="fa fa-fw fa-save"></i>
Save
</a>
</li>
<li role="presentation" className="divider"></li>
<li>
<a href="#" onClick={this.handleShutdownClick}>
<i className="fa fa-fw fa-plug"></i>
Shutdown
</a>
</li>
</ul>
</div>
);
}
});
var header_entries = [MainMenu, ToolsMenu, ReportsMenu]; var header_entries = [MainMenu, ToolsMenu, ReportsMenu];
var Header = React.createClass({ var Header = React.createClass({
mixins: [ReactRouter.Navigation],
getInitialState: function () { getInitialState: function () {
return { return {
active: header_entries[0] active: header_entries[0]
}; };
}, },
handleClick: function (active) { handleClick: function (active, e) {
ReactRouter.transitionTo(active.route); e.preventDefault();
this.transitionTo(active.route);
this.setState({active: active}); this.setState({active: active});
return false;
},
handleFileClick: function () {
console.log("File click");
}, },
render: function () { render: function () {
var header = header_entries.map(function(entry, i){ var header = header_entries.map(function (entry, i) {
var classes = React.addons.classSet({ var classes = React.addons.classSet({
active: entry == this.state.active active: entry == this.state.active
}); });
@ -83,7 +162,7 @@ var Header = React.createClass({
mitmproxy { this.props.settings.version } mitmproxy { this.props.settings.version }
</div> </div>
<nav className="nav-tabs nav-tabs-lg"> <nav className="nav-tabs nav-tabs-lg">
<a href="#" className="special" onClick={this.handleFileClick}> File </a> <FileMenu/>
{header} {header}
</nav> </nav>
<div className="menu"> <div className="menu">

View File

@ -1,65 +1,86 @@
/** @jsx React.DOM */
var MainView = React.createClass({ var MainView = React.createClass({
getInitialState: function() { mixins: [ReactRouter.Navigation, ReactRouter.State],
getInitialState: function () {
return { return {
flows: [], flows: []
}; };
}, },
componentDidMount: function () { componentWillReceiveProps: function (nextProps) {
this.flowStore = FlowStore.getView(); if (nextProps.flowStore !== this.props.flowStore) {
this.flowStore.addListener("change",this.onFlowChange); this.closeView();
this.openView(nextProps.flowStore);
}
},
openView: function (store) {
var view = new StoreView(store);
this.setState({
view: view
});
view.addListener("recalculate", this.onRecalculate);
view.addListener("add update remove", this.onUpdate);
},
onRecalculate: function(){
this.forceUpdate();
var selected = this.getSelected();
if(selected){
this.refs.flowTable.scrollIntoView(selected);
}
},
onUpdate: function (flow) {
if (flow.id === this.getParams().flowId) {
this.forceUpdate();
}
},
closeView: function () {
this.state.view.close();
},
componentWillMount: function () {
this.openView(this.props.flowStore);
}, },
componentWillUnmount: function () { componentWillUnmount: function () {
this.flowStore.removeListener("change",this.onFlowChange); this.closeView();
this.flowStore.close();
}, },
onFlowChange: function () { selectFlow: function (flow) {
this.setState({ if (flow) {
flows: this.flowStore.getAll() this.replaceWith(
});
},
selectDetailTab: function(panel) {
ReactRouter.replaceWith(
"flow",
{
flowId: this.props.params.flowId,
detailTab: panel
}
);
},
selectFlow: function(flow) {
if(flow){
ReactRouter.replaceWith(
"flow", "flow",
{ {
flowId: flow.id, flowId: flow.id,
detailTab: this.props.params.detailTab || "request" detailTab: this.getParams().detailTab || "request"
} }
); );
this.refs.flowTable.scrollIntoView(flow); this.refs.flowTable.scrollIntoView(flow);
} else { } else {
ReactRouter.replaceWith("flows"); this.replaceWith("flows");
} }
}, },
selectFlowRelative: function(i){ selectFlowRelative: function (shift) {
var flows = this.state.view.list;
var index; var index;
if(!this.props.params.flowId){ if (!this.getParams().flowId) {
if(i > 0){ if (shift > 0) {
index = this.state.flows.length-1; index = flows.length - 1;
} else { } else {
index = 0; index = 0;
} }
} else { } else {
index = _.findIndex(this.state.flows, function(f){ var currFlowId = this.getParams().flowId;
return f.id === this.props.params.flowId; var i = flows.length;
}.bind(this)); while (i--) {
index = Math.min(Math.max(0, index+i), this.state.flows.length-1); if (flows[i].id === currFlowId) {
index = i;
break;
} }
this.selectFlow(this.state.flows[index]); }
index = Math.min(
Math.max(0, index + shift),
flows.length - 1);
}
this.selectFlow(flows[index]);
}, },
onKeyDown: function(e){ onKeyDown: function (e) {
switch(e.keyCode){ switch (e.keyCode) {
case Key.K: case Key.K:
case Key.UP: case Key.UP:
this.selectFlowRelative(-1); this.selectFlowRelative(-1);
@ -80,14 +101,14 @@ var MainView = React.createClass({
break; break;
case Key.H: case Key.H:
case Key.LEFT: case Key.LEFT:
if(this.refs.flowDetails){ if (this.refs.flowDetails) {
this.refs.flowDetails.nextTab(-1); this.refs.flowDetails.nextTab(-1);
} }
break; break;
case Key.L: case Key.L:
case Key.TAB: case Key.TAB:
case Key.RIGHT: case Key.RIGHT:
if(this.refs.flowDetails){ if (this.refs.flowDetails) {
this.refs.flowDetails.nextTab(+1); this.refs.flowDetails.nextTab(+1);
} }
break; break;
@ -95,19 +116,20 @@ var MainView = React.createClass({
console.debug("keydown", e.keyCode); console.debug("keydown", e.keyCode);
return; return;
} }
return false; e.preventDefault();
}, },
render: function() { getSelected: function(){
var selected = _.find(this.state.flows, { id: this.props.params.flowId }); return this.props.flowStore.get(this.getParams().flowId);
},
render: function () {
var selected = this.getSelected();
var details; var details;
if(selected){ if (selected) {
details = ( details = [
<FlowDetail ref="flowDetails" <Splitter key="splitter"/>,
flow={selected} <FlowDetail key="flowDetails" ref="flowDetails" flow={selected}/>
selectTab={this.selectDetailTab} ];
active={this.props.params.detailTab}/>
);
} else { } else {
details = null; details = null;
} }
@ -115,10 +137,9 @@ var MainView = React.createClass({
return ( return (
<div className="main-view" onKeyDown={this.onKeyDown} tabIndex="0"> <div className="main-view" onKeyDown={this.onKeyDown} tabIndex="0">
<FlowTable ref="flowTable" <FlowTable ref="flowTable"
flows={this.state.flows} view={this.state.view}
selectFlow={this.selectFlow} selectFlow={this.selectFlow}
selected={selected} /> selected={selected} />
{ details ? <Splitter/> : null }
{details} {details}
</div> </div>
); );

View File

@ -1,5 +1,3 @@
/** @jsx React.DOM */
//TODO: Move out of here, just a stub. //TODO: Move out of here, just a stub.
var Reports = React.createClass({ var Reports = React.createClass({
render: function () { render: function () {
@ -10,45 +8,67 @@ var Reports = React.createClass({
var ProxyAppMain = React.createClass({ var ProxyAppMain = React.createClass({
getInitialState: function () { getInitialState: function () {
return { settings: SettingsStore.getAll() }; var eventStore = new EventLogStore();
var flowStore = new FlowStore();
var settings = new SettingsStore();
// Default Settings before fetch
_.extend(settings.dict,{
showEventLog: true
});
return {
settings: settings,
flowStore: flowStore,
eventStore: eventStore
};
}, },
componentDidMount: function () { componentDidMount: function () {
SettingsStore.addListener("change", this.onSettingsChange); this.state.settings.addListener("recalculate", this.onSettingsChange);
}, },
componentWillUnmount: function () { componentWillUnmount: function () {
SettingsStore.removeListener("change", this.onSettingsChange); this.state.settings.removeListener("recalculate", this.onSettingsChange);
}, },
onSettingsChange: function () { onSettingsChange: function(){
this.setState({settings: SettingsStore.getAll()}); this.setState({
settings: this.state.settings
});
}, },
render: function () { render: function () {
var eventlog;
if (this.state.settings.dict.showEventLog) {
eventlog = [
<Splitter key="splitter" axis="y"/>,
<EventLog key="eventlog" eventStore={this.state.eventStore}/>
];
} else {
eventlog = null;
}
return ( return (
<div id="container"> <div id="container">
<Header settings={this.state.settings}/> <Header settings={this.state.settings.dict}/>
<this.props.activeRouteHandler settings={this.state.settings}/> <RouteHandler settings={this.state.settings.dict} flowStore={this.state.flowStore}/>
{this.state.settings.showEventLog ? <Splitter axis="y"/> : null} {eventlog}
{this.state.settings.showEventLog ? <EventLog/> : null} <Footer settings={this.state.settings.dict}/>
<Footer settings={this.state.settings}/>
</div> </div>
); );
} }
}); });
var Routes = ReactRouter.Routes;
var Route = ReactRouter.Route; var Route = ReactRouter.Route;
var RouteHandler = ReactRouter.RouteHandler;
var Redirect = ReactRouter.Redirect; var Redirect = ReactRouter.Redirect;
var DefaultRoute = ReactRouter.DefaultRoute; var DefaultRoute = ReactRouter.DefaultRoute;
var NotFoundRoute = ReactRouter.NotFoundRoute; var NotFoundRoute = ReactRouter.NotFoundRoute;
var ProxyApp = ( var routes = (
<Routes location="hash">
<Route path="/" handler={ProxyAppMain}> <Route path="/" handler={ProxyAppMain}>
<Route name="flows" path="flows" handler={MainView}/> <Route name="flows" path="flows" handler={MainView}/>
<Route name="flow" path="flows/:flowId/:detailTab" handler={MainView}/> <Route name="flow" path="flows/:flowId/:detailTab" handler={MainView}/>
<Route name="reports" handler={Reports}/> <Route name="reports" handler={Reports}/>
<Redirect path="/" to="flows" /> <Redirect path="/" to="flows" />
</Route> </Route>
</Routes> );
);

View File

@ -1,5 +1,3 @@
/** @jsx React.DOM */
//React utils. For other utilities, see ../utils.js //React utils. For other utilities, see ../utils.js
var Splitter = React.createClass({ var Splitter = React.createClass({
@ -8,62 +6,70 @@ var Splitter = React.createClass({
axis: "x" axis: "x"
}; };
}, },
getInitialState: function(){ getInitialState: function () {
return { return {
applied: false, applied: false,
startX: false, startX: false,
startY: false startY: false
}; };
}, },
onMouseDown: function(e){ onMouseDown: function (e) {
this.setState({ this.setState({
startX: e.pageX, startX: e.pageX,
startY: e.pageY startY: e.pageY
}); });
window.addEventListener("mousemove",this.onMouseMove); window.addEventListener("mousemove", this.onMouseMove);
window.addEventListener("mouseup",this.onMouseUp); window.addEventListener("mouseup", this.onMouseUp);
// Occasionally, only a dragEnd event is triggered, but no mouseUp. // Occasionally, only a dragEnd event is triggered, but no mouseUp.
window.addEventListener("dragend",this.onDragEnd); window.addEventListener("dragend", this.onDragEnd);
}, },
onDragEnd: function(){ onDragEnd: function () {
this.getDOMNode().style.transform=""; this.getDOMNode().style.transform = "";
window.removeEventListener("dragend",this.onDragEnd); window.removeEventListener("dragend", this.onDragEnd);
window.removeEventListener("mouseup",this.onMouseUp); window.removeEventListener("mouseup", this.onMouseUp);
window.removeEventListener("mousemove",this.onMouseMove); window.removeEventListener("mousemove", this.onMouseMove);
}, },
onMouseUp: function(e){ onMouseUp: function (e) {
this.onDragEnd(); this.onDragEnd();
var node = this.getDOMNode(); var node = this.getDOMNode();
var prev = node.previousElementSibling; var prev = node.previousElementSibling;
var next = node.nextElementSibling; var next = node.nextElementSibling;
var dX = e.pageX-this.state.startX; var dX = e.pageX - this.state.startX;
var dY = e.pageY-this.state.startY; var dY = e.pageY - this.state.startY;
var flexBasis; var flexBasis;
if(this.props.axis === "x"){ if (this.props.axis === "x") {
flexBasis = prev.offsetWidth + dX; flexBasis = prev.offsetWidth + dX;
} else { } else {
flexBasis = prev.offsetHeight + dY; flexBasis = prev.offsetHeight + dY;
} }
prev.style.flex = "0 0 "+Math.max(0, flexBasis)+"px"; prev.style.flex = "0 0 " + Math.max(0, flexBasis) + "px";
next.style.flex = "1 1 auto"; next.style.flex = "1 1 auto";
this.setState({ this.setState({
applied: true applied: true
}); });
this.onResize();
}, },
onMouseMove: function(e){ onMouseMove: function (e) {
var dX = 0, dY = 0; var dX = 0, dY = 0;
if(this.props.axis === "x"){ if (this.props.axis === "x") {
dX = e.pageX-this.state.startX; dX = e.pageX - this.state.startX;
} else { } else {
dY = e.pageY-this.state.startY; dY = e.pageY - this.state.startY;
} }
this.getDOMNode().style.transform = "translate("+dX+"px,"+dY+"px)"; this.getDOMNode().style.transform = "translate(" + dX + "px," + dY + "px)";
}, },
reset: function(willUnmount) { onResize: function () {
// Trigger a global resize event. This notifies components that employ virtual scrolling
// that their viewport may have changed.
window.setTimeout(function () {
window.dispatchEvent(new CustomEvent("resize"));
}, 1);
},
reset: function (willUnmount) {
if (!this.state.applied) { if (!this.state.applied) {
return; return;
} }
@ -74,19 +80,19 @@ var Splitter = React.createClass({
prev.style.flex = ""; prev.style.flex = "";
next.style.flex = ""; next.style.flex = "";
if(!willUnmount){ if (!willUnmount) {
this.setState({ this.setState({
applied: false applied: false
}); });
} }
this.onResize();
}, },
componentWillUnmount: function(){ componentWillUnmount: function () {
this.reset(true); this.reset(true);
}, },
render: function(){ render: function () {
var className = "splitter"; var className = "splitter";
if(this.props.axis === "x"){ if (this.props.axis === "x") {
className += " splitter-x"; className += " splitter-x";
} else { } else {
className += " splitter-y"; className += " splitter-y";
@ -98,3 +104,20 @@ var Splitter = React.createClass({
); );
} }
}); });
function getCookie(name) {
var r = document.cookie.match("\\b" + name + "=([^;]*)\\b");
return r ? r[1] : undefined;
}
var xsrf = $.param({_xsrf: getCookie("_xsrf")});
//Tornado XSRF Protection.
$.ajaxPrefilter(function (options) {
if (options.type === "post" && options.url[0] === "/") {
if (options.data) {
options.data += ("&" + xsrf);
} else {
options.data = xsrf;
}
}
});

View File

@ -0,0 +1,81 @@
var VirtualScrollMixin = {
getInitialState: function () {
return {
start: 0,
stop: 0
};
},
componentWillMount: function () {
if (!this.props.rowHeight) {
console.warn("VirtualScrollMixin: No rowHeight specified", this);
}
},
getPlaceholderTop: function (total) {
var Tag = this.props.placeholderTagName || "tr";
// When a large trunk of elements is removed from the button, start may be far off the viewport.
// To make this issue less severe, limit the top placeholder to the total number of rows.
var style = {
height: Math.min(this.state.start, total) * this.props.rowHeight
};
var spacer = <Tag key="placeholder-top" style={style}></Tag>;
if (this.state.start % 2 === 1) {
// fix even/odd rows
return [spacer, <Tag key="placeholder-top-2"></Tag>];
} else {
return spacer;
}
},
getPlaceholderBottom: function (total) {
var Tag = this.props.placeholderTagName || "tr";
var style = {
height: Math.max(0, total - this.state.stop) * this.props.rowHeight
};
return <Tag key="placeholder-bottom" style={style}></Tag>;
},
componentDidMount: function () {
this.onScroll();
window.addEventListener('resize', this.onScroll);
},
componentWillUnmount: function(){
window.removeEventListener('resize', this.onScroll);
},
onScroll: function () {
var viewport = this.getDOMNode();
var top = viewport.scrollTop;
var height = viewport.offsetHeight;
var start = Math.floor(top / this.props.rowHeight);
var stop = start + Math.ceil(height / (this.props.rowHeightMin || this.props.rowHeight));
this.setState({
start: start,
stop: stop
});
},
renderRows: function (elems) {
var rows = [];
var max = Math.min(elems.length, this.state.stop);
for (var i = this.state.start; i < max; i++) {
var elem = elems[i];
rows.push(this.renderRow(elem));
}
return rows;
},
scrollRowIntoView: function (index, head_height) {
var row_top = (index * this.props.rowHeight) + head_height;
var row_bottom = row_top + this.props.rowHeight;
var viewport = this.getDOMNode();
var viewport_top = viewport.scrollTop;
var viewport_bottom = viewport_top + viewport.offsetHeight;
// Account for pinned thead
if (row_top - head_height < viewport_top) {
viewport.scrollTop = row_top - head_height;
} else if (row_bottom > viewport_bottom) {
viewport.scrollTop = row_bottom - viewport.offsetHeight;
}
},
};

View File

@ -1,33 +1,24 @@
function _Connection(url) { function Connection(url) {
this.url = url;
}
_Connection.prototype.init = function () {
this.openWebSocketConnection();
};
_Connection.prototype.openWebSocketConnection = function () {
this.ws = new WebSocket(this.url.replace("http", "ws"));
var ws = this.ws;
ws.onopen = this.onopen.bind(this); if (url[0] === "/") {
ws.onmessage = this.onmessage.bind(this); url = location.origin.replace("http", "ws") + url;
ws.onerror = this.onerror.bind(this); }
ws.onclose = this.onclose.bind(this);
}; var ws = new WebSocket(url);
_Connection.prototype.onopen = function (open) { ws.onopen = function () {
console.debug("onopen", this, arguments); ConnectionActions.open();
}; };
_Connection.prototype.onmessage = function (message) { ws.onmessage = function (message) {
//AppDispatcher.dispatchServerAction(...);
var m = JSON.parse(message.data); var m = JSON.parse(message.data);
AppDispatcher.dispatchServerAction(m); AppDispatcher.dispatchServerAction(m);
}; };
_Connection.prototype.onerror = function (error) { ws.onerror = function () {
EventLogActions.add_event("WebSocket Connection Error."); ConnectionActions.error();
console.debug("onerror", this, arguments); EventLogActions.add_event("WebSocket connection error.");
}; };
_Connection.prototype.onclose = function (close) { ws.onclose = function () {
EventLogActions.add_event("WebSocket Connection closed."); ConnectionActions.close();
console.debug("onclose", this, arguments); EventLogActions.add_event("WebSocket connection closed.");
}; };
return ws;
var Connection = new _Connection(location.origin + "/updates"); }

View File

@ -11,14 +11,14 @@ Dispatcher.prototype.register = function (callback) {
this.callbacks.push(callback); this.callbacks.push(callback);
}; };
Dispatcher.prototype.unregister = function (callback) { Dispatcher.prototype.unregister = function (callback) {
var index = this.callbacks.indexOf(f); var index = this.callbacks.indexOf(callback);
if (index >= 0) { if (index >= 0) {
this.callbacks.splice(this.callbacks.indexOf(f), 1); this.callbacks.splice(index, 1);
} }
}; };
Dispatcher.prototype.dispatch = function (payload) { Dispatcher.prototype.dispatch = function (payload) {
console.debug("dispatch", payload); console.debug("dispatch", payload);
for(var i = 0; i < this.callbacks.length; i++){ for (var i = 0; i < this.callbacks.length; i++) {
this.callbacks[i](payload); this.callbacks[i](payload);
} }
}; };

View File

@ -0,0 +1,93 @@
// PEG.js filter rules - see http://pegjs.majda.cz/online
/* Explain Filter */
{
var or = function(first, second) {
return first + " or " + second;
};
var and = function(first, second) {
return first + " and " + second;
};
var not = function(expr) {
return "not " + expr;
};
var binding = function(expr) {
return "(" + expr + ")";
}
var assetFilter = "is asset";
var trueFilter = true;
var falseFilter = false;
var bodyFilter = function(s) {
return "body ~= '" + s + "'";
}
var urlFilter = function(s) {
return "url ~= '" + s + "'";
}
}
start "filter expression"
= __ orExpr:OrExpr __ { return orExpr; }
ws "whitespace" = [ \t\n\r]
cc "control character" = [|&!()~"]
__ "optional whitespace" = ws*
OrExpr
= first:AndExpr __ "|" __ second:OrExpr
{ return or(first, second); }
/ AndExpr
AndExpr
= first:NotExpr __ "&" __ second:AndExpr
{ return and(first, second); }
/ first:NotExpr ws+ second:AndExpr
{ return and(first, second); }
/ NotExpr
NotExpr
= "!" __ expr:NotExpr
{ return not(expr); }
/ BindingExpr
BindingExpr
= "(" __ expr:OrExpr __ ")"
{ return binding(orExpr); }
/ Expr
Expr
= NullaryExpr
/ UnaryExpr
NullaryExpr
= BooleanLiteral
/ "~a" { return assetFilter; };
BooleanLiteral
= "true" { return trueFilter; }
/ "false" { return falseFilter; }
UnaryExpr
= "~b" ws+ s:StringLiteral { return bodyFilter(s); }
/ s:StringLiteral { return urlFilter(s); }
StringLiteral "string"
= '"' chars:DoubleStringChar* '"' { return chars.join(""); }
/ "'" chars:SingleStringChar* "'" { return chars.join(""); }
/ !cc chars:UnquotedStringChar+ { return chars.join(""); }
DoubleStringChar
= !["\\] char:. { return char; }
/ "\\" char:EscapeSequence { return char; }
SingleStringChar
= !['\\] char:. { return char; }
/ "\\" char:EscapeSequence { return char; }
UnquotedStringChar
= !ws char:. { return char; }
EscapeSequence
= ['"\\]
/ "n" { return "\n"; }
/ "r" { return "\r"; }
/ "t" { return "\t"; }

View File

164
web/src/js/store/store.js Normal file
View File

@ -0,0 +1,164 @@
function ListStore() {
EventEmitter.call(this);
this.reset();
}
_.extend(ListStore.prototype, EventEmitter.prototype, {
add: function (elem) {
if (elem.id in this._pos_map) {
return;
}
this._pos_map[elem.id] = this.list.length;
this.list.push(elem);
this.emit("add", elem);
},
update: function (elem) {
if (!(elem.id in this._pos_map)) {
return;
}
this.list[this._pos_map[elem.id]] = elem;
this.emit("update", elem);
},
remove: function (elem_id) {
if (!(elem.id in this._pos_map)) {
return;
}
this.list.splice(this._pos_map[elem_id], 1);
this._build_map();
this.emit("remove", elem_id);
},
reset: function (elems) {
this.list = elems || [];
this._build_map();
this.emit("recalculate", this.list);
},
_build_map: function () {
this._pos_map = {};
for (var i = 0; i < this.list.length; i++) {
var elem = this.list[i];
this._pos_map[elem.id] = i;
}
},
get: function (elem_id) {
return this.list[this._pos_map[elem_id]];
},
index: function (elem_id) {
return this._pos_map[elem_id];
}
});
function DictStore() {
EventEmitter.call(this);
this.reset();
}
_.extend(DictStore.prototype, EventEmitter.prototype, {
update: function (dict) {
_.merge(this.dict, dict);
this.emit("recalculate", this.dict);
},
reset: function (dict) {
this.dict = dict || {};
this.emit("recalculate", this.dict);
}
});
function LiveStoreMixin(type) {
this.type = type;
this._updates_before_fetch = undefined;
this._fetchxhr = false;
this.handle = this.handle.bind(this);
AppDispatcher.register(this.handle);
// Avoid double-fetch on startup.
if (!(window.ws && window.ws.readyState === WebSocket.CONNECTING)) {
this.fetch();
}
}
_.extend(LiveStoreMixin.prototype, {
handle: function (event) {
if (event.type === ActionTypes.CONNECTION_OPEN) {
return this.fetch();
}
if (event.type === this.type) {
if (event.cmd === StoreCmds.RESET) {
this.fetch();
} else if (this._updates_before_fetch) {
console.log("defer update", event);
this._updates_before_fetch.push(event);
} else {
this[event.cmd](event.data);
}
}
},
close: function () {
AppDispatcher.unregister(this.handle);
},
fetch: function (data) {
console.log("fetch " + this.type);
if (this._fetchxhr) {
this._fetchxhr.abort();
}
this._updates_before_fetch = []; // (JS: empty array is true)
if (data) {
this.handle_fetch(data);
} else {
this._fetchxhr = $.getJSON("/" + this.type)
.done(function (message) {
this.handle_fetch(message.data);
}.bind(this))
.fail(function () {
EventLogActions.add_event("Could not fetch " + this.type);
}.bind(this));
}
},
handle_fetch: function (data) {
this._fetchxhr = false;
console.log(this.type + " fetched.", this._updates_before_fetch);
this.reset(data);
var updates = this._updates_before_fetch;
this._updates_before_fetch = false;
for (var i = 0; i < updates.length; i++) {
this.handle(updates[i]);
}
},
});
function LiveListStore(type) {
ListStore.call(this);
LiveStoreMixin.call(this, type);
}
_.extend(LiveListStore.prototype, ListStore.prototype, LiveStoreMixin.prototype);
function LiveDictStore(type) {
DictStore.call(this);
LiveStoreMixin.call(this, type);
}
_.extend(LiveDictStore.prototype, DictStore.prototype, LiveStoreMixin.prototype);
function FlowStore() {
return new LiveListStore(ActionTypes.FLOW_STORE);
}
function SettingsStore() {
return new LiveDictStore(ActionTypes.SETTINGS_STORE);
}
function EventLogStore() {
LiveListStore.call(this, ActionTypes.EVENT_STORE);
}
_.extend(EventLogStore.prototype, LiveListStore.prototype, {
fetch: function(){
LiveListStore.prototype.fetch.apply(this, arguments);
// Make sure to display updates even if fetching all events failed.
// This way, we can send "fetch failed" log messages to the log.
if(this._fetchxhr){
this._fetchxhr.fail(function(){
this.handle_fetch(null);
}.bind(this));
}
}
});

99
web/src/js/store/view.js Normal file
View File

@ -0,0 +1,99 @@
function SortByStoreOrder(elem) {
return this.store.index(elem.id);
}
var default_sort = SortByStoreOrder;
var default_filt = function(elem){
return true;
};
function StoreView(store, filt, sortfun) {
EventEmitter.call(this);
filt = filt || default_filt;
sortfun = sortfun || default_sort;
this.store = store;
this.add = this.add.bind(this);
this.update = this.update.bind(this);
this.remove = this.remove.bind(this);
this.recalculate = this.recalculate.bind(this);
this.store.addListener("add", this.add);
this.store.addListener("update", this.update);
this.store.addListener("remove", this.remove);
this.store.addListener("recalculate", this.recalculate);
this.recalculate(this.store.list, filt, sortfun);
}
_.extend(StoreView.prototype, EventEmitter.prototype, {
close: function () {
this.store.removeListener("add", this.add);
this.store.removeListener("update", this.update);
this.store.removeListener("remove", this.remove);
this.store.removeListener("recalculate", this.recalculate);
},
recalculate: function (elems, filt, sortfun) {
if (filt) {
this.filt = filt;
}
if (sortfun) {
this.sortfun = sortfun.bind(this);
}
this.list = elems.filter(this.filt);
this.list.sort(function (a, b) {
return this.sortfun(a) - this.sortfun(b);
}.bind(this));
this.emit("recalculate");
},
index: function (elem) {
return _.sortedIndex(this.list, elem, this.sortfun);
},
add: function (elem) {
if (this.filt(elem)) {
var idx = this.index(elem);
if (idx === this.list.length) { //happens often, .push is way faster.
this.list.push(elem);
} else {
this.list.splice(idx, 0, elem);
}
this.emit("add", elem, idx);
}
},
update: function (elem) {
var idx;
var i = this.list.length;
// Search from the back, we usually update the latest entries.
while (i--) {
if (this.list[i].id === elem.id) {
idx = i;
break;
}
}
if (idx === -1) { //not contained in list
this.add(elem);
} else if (!this.filt(elem)) {
this.remove(elem.id);
} else {
if (this.sortfun(this.list[idx]) !== this.sortfun(elem)) { //sortpos has changed
this.remove(this.list[idx]);
this.add(elem);
} else {
this.list[idx] = elem;
this.emit("update", elem, idx);
}
}
},
remove: function (elem_id) {
var idx = this.list.length;
while (idx--) {
if (this.list[idx].id === elem_id) {
this.list.splice(idx, 1);
this.emit("remove", elem_id, idx);
break;
}
}
}
});

View File

@ -1,25 +0,0 @@
function EventEmitter() {
this.listeners = {};
}
EventEmitter.prototype.emit = function (event) {
if (!(event in this.listeners)) {
return;
}
var args = Array.prototype.slice.call(arguments, 1);
this.listeners[event].forEach(function (listener) {
listener.apply(this, args);
}.bind(this));
};
EventEmitter.prototype.addListener = function (event, f) {
this.listeners[event] = this.listeners[event] || [];
this.listeners[event].push(f);
};
EventEmitter.prototype.removeListener = function (event, f) {
if (!(event in this.listeners)) {
return false;
}
var index = this.listeners[event].indexOf(f);
if (index >= 0) {
this.listeners[event].splice(index, 1);
}
};

View File

@ -1,99 +0,0 @@
//
// We have an EventLogView and an EventLogStore:
// The basic architecture is that one can request views on the event log
// from the store, which returns a view object and then deals with getting the data required for the view.
// The view object is accessed by React components and distributes updates etc.
//
// See also: components/EventLog.react.js
function EventLogView(store, live) {
EventEmitter.call(this);
this._store = store;
this.live = live;
this.log = [];
this.add = this.add.bind(this);
if (live) {
this._store.addListener(ActionTypes.ADD_EVENT, this.add);
}
}
_.extend(EventLogView.prototype, EventEmitter.prototype, {
close: function () {
this._store.removeListener(ActionTypes.ADD_EVENT, this.add);
},
getAll: function () {
return this.log;
},
add: function (entry) {
this.log.push(entry);
if(this.log.length > 200){
this.log.shift();
}
this.emit("change");
},
add_bulk: function (messages) {
var log = messages;
var last_id = log[log.length - 1].id;
var to_add = _.filter(this.log, function (entry) {
return entry.id > last_id;
});
this.log = log.concat(to_add);
this.emit("change");
}
});
function _EventLogStore() {
EventEmitter.call(this);
}
_.extend(_EventLogStore.prototype, EventEmitter.prototype, {
getView: function (since) {
var view = new EventLogView(this, !since);
return view;
/*
//TODO: Really do bulk retrieval of last messages.
window.setTimeout(function () {
view.add_bulk([
{
id: 1,
message: "Hello World"
},
{
id: 2,
message: "I was already transmitted as an event."
}
]);
}, 100);
var id = 2;
view.add({
id: id++,
message: "I was already transmitted as an event."
});
view.add({
id: id++,
message: "I was only transmitted as an event before the bulk was added.."
});
window.setInterval(function () {
view.add({
id: id++,
message: "."
});
}, 1000);
return view;
*/
},
handle: function (action) {
switch (action.type) {
case ActionTypes.ADD_EVENT:
this.emit(ActionTypes.ADD_EVENT, action.data);
break;
default:
return;
}
}
});
var EventLogStore = new _EventLogStore();
AppDispatcher.register(EventLogStore.handle.bind(EventLogStore));

View File

@ -1,91 +0,0 @@
function FlowView(store, live) {
EventEmitter.call(this);
this._store = store;
this.live = live;
this.flows = [];
this.add = this.add.bind(this);
this.update = this.update.bind(this);
if (live) {
this._store.addListener(ActionTypes.ADD_FLOW, this.add);
this._store.addListener(ActionTypes.UPDATE_FLOW, this.update);
}
}
_.extend(FlowView.prototype, EventEmitter.prototype, {
close: function () {
this._store.removeListener(ActionTypes.ADD_FLOW, this.add);
this._store.removeListener(ActionTypes.UPDATE_FLOW, this.update);
},
getAll: function () {
return this.flows;
},
add: function (flow) {
return this.update(flow);
},
add_bulk: function (flows) {
//Treat all previously received updates as newer than the bulk update.
//If they weren't newer, we're about to receive an update for them very soon.
var updates = this.flows;
this.flows = flows;
updates.forEach(function(flow){
this._update(flow);
}.bind(this));
this.emit("change");
},
_update: function(flow){
var idx = _.findIndex(this.flows, function(f){
return flow.id === f.id;
});
if(idx < 0){
this.flows.push(flow);
//if(this.flows.length > 100){
// this.flows.shift();
//}
} else {
this.flows[idx] = flow;
}
},
update: function(flow){
this._update(flow);
this.emit("change");
},
});
function _FlowStore() {
EventEmitter.call(this);
}
_.extend(_FlowStore.prototype, EventEmitter.prototype, {
getView: function (since) {
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;
},
handle: function (action) {
switch (action.type) {
case ActionTypes.ADD_FLOW:
case ActionTypes.UPDATE_FLOW:
this.emit(action.type, action.data);
break;
default:
return;
}
}
});
var FlowStore = new _FlowStore();
AppDispatcher.register(FlowStore.handle.bind(FlowStore));

View File

@ -1,28 +0,0 @@
function _SettingsStore() {
EventEmitter.call(this);
//FIXME: What do we do if we haven't requested anything from the server yet?
this.settings = {
version: "0.12",
showEventLog: true,
mode: "transparent",
};
}
_.extend(_SettingsStore.prototype, EventEmitter.prototype, {
getAll: function () {
return this.settings;
},
handle: function (action) {
switch (action.type) {
case ActionTypes.UPDATE_SETTINGS:
this.settings = action.settings;
this.emit("change");
break;
default:
return;
}
}
});
var SettingsStore = new _SettingsStore();
AppDispatcher.register(SettingsStore.handle.bind(SettingsStore));

View File

@ -12,6 +12,7 @@ var AutoScrollMixin = {
}, },
}; };
var StickyHeadMixin = { var StickyHeadMixin = {
adjustHead: function () { adjustHead: function () {
// Abusing CSS transforms to set the element // Abusing CSS transforms to set the element
@ -21,6 +22,7 @@ var StickyHeadMixin = {
} }
}; };
var Key = { var Key = {
UP: 38, UP: 38,
DOWN: 40, DOWN: 40,
@ -38,17 +40,19 @@ var Key = {
L: 76 L: 76
}; };
var formatSize = function (bytes) { var formatSize = function (bytes) {
var size = bytes; var size = bytes;
var prefix = ["B", "KB", "MB", "GB", "TB"]; var prefix = ["B", "KB", "MB", "GB", "TB"];
var i=0; var i = 0;
while (Math.abs(size) >= 1024 && i < prefix.length-1) { while (Math.abs(size) >= 1024 && i < prefix.length - 1) {
i++; i++;
size = size / 1024; size = size / 1024;
} }
return (Math.floor(size * 100) / 100.0).toFixed(2) + prefix[i]; return (Math.floor(size * 100) / 100.0).toFixed(2) + prefix[i];
}; };
var formatTimeDelta = function (milliseconds) { var formatTimeDelta = function (milliseconds) {
var time = milliseconds; var time = milliseconds;
var prefix = ["ms", "s", "min", "h"]; var prefix = ["ms", "s", "min", "h"];
@ -60,3 +64,40 @@ var formatTimeDelta = function (milliseconds) {
} }
return Math.round(time) + prefix[i]; return Math.round(time) + prefix[i];
}; };
var formatTimeStamp = function (seconds) {
var ts = (new Date(seconds * 1000)).toISOString();
return ts.replace("T", " ").replace("Z", "");
};
function EventEmitter() {
this.listeners = {};
}
EventEmitter.prototype.emit = function (event) {
if (!(event in this.listeners)) {
return;
}
var args = Array.prototype.slice.call(arguments, 1);
this.listeners[event].forEach(function (listener) {
listener.apply(this, args);
}.bind(this));
};
EventEmitter.prototype.addListener = function (events, f) {
events.split(" ").forEach(function (event) {
this.listeners[event] = this.listeners[event] || [];
this.listeners[event].push(f);
}.bind(this));
};
EventEmitter.prototype.removeListener = function (events, f) {
if (!(events in this.listeners)) {
return false;
}
events.split(" ").forEach(function (event) {
var index = this.listeners[event].indexOf(f);
if (index >= 0) {
this.listeners[event].splice(index, 1);
}
}.bind(this));
};

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -1,12 +1,12 @@
/*! /*!
* QUnit 1.15.0 * QUnit 1.16.0
* http://qunitjs.com/ * http://qunitjs.com/
* *
* Copyright 2014 jQuery Foundation and other contributors * Copyright 2006, 2014 jQuery Foundation and other contributors
* Released under the MIT license * Released under the MIT license
* http://jquery.org/license * http://jquery.org/license
* *
* Date: 2014-08-08T16:00Z * Date: 2014-12-03T16:32Z
*/ */
/** Font Family and Sizes */ /** Font Family and Sizes */
@ -91,6 +91,14 @@
list-style-position: inside; list-style-position: inside;
} }
#qunit-tests > li {
display: none;
}
#qunit-tests li.pass, #qunit-tests li.running, #qunit-tests li.fail {
display: list-item;
}
#qunit-tests.hidepass li.pass, #qunit-tests.hidepass li.running { #qunit-tests.hidepass li.pass, #qunit-tests.hidepass li.running {
display: none; display: none;
} }
@ -99,6 +107,10 @@
cursor: pointer; cursor: pointer;
} }
#qunit-tests li.skipped strong {
cursor: default;
}
#qunit-tests li a { #qunit-tests li a {
padding: 0.5em; padding: 0.5em;
color: #C2CCD1; color: #C2CCD1;
@ -211,6 +223,21 @@
#qunit-banner.qunit-fail { background-color: #EE5757; } #qunit-banner.qunit-fail { background-color: #EE5757; }
/*** Skipped tests */
#qunit-tests .skipped {
background-color: #EBECE9;
}
#qunit-tests .qunit-skipped-label {
background-color: #F4FF77;
display: inline-block;
font-style: normal;
color: #366097;
line-height: 1.8em;
padding: 0 0.5em;
margin: -0.4em 0.4em -0.4em 0;
}
/** Result */ /** Result */

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff