Merge pull request #1725 from mhils/mitmweb

Mitmweb Improvements
This commit is contained in:
Maximilian Hils 2016-11-09 15:19:14 +01:00 committed by GitHub
commit f0783a0874
30 changed files with 1082 additions and 62133 deletions

View File

@ -1,19 +1,18 @@
import base64
import hashlib
import json
import logging
import os.path
import re
import hashlib
import tornado.websocket
import tornado.web
from io import BytesIO
from mitmproxy import flowfilter
from mitmproxy import flow
from mitmproxy import http
import tornado.web
import tornado.websocket
import tornado.escape
from mitmproxy import contentviews
from mitmproxy import flow
from mitmproxy import flowfilter
from mitmproxy import http
from mitmproxy import io
from mitmproxy import version
@ -96,6 +95,14 @@ class BasicAuth:
class RequestHandler(BasicAuth, tornado.web.RequestHandler):
def write(self, chunk):
# Writing arrays on the top level is ok nowadays.
# http://flask.pocoo.org/docs/0.11/security/#json-security
if isinstance(chunk, list):
chunk = tornado.escape.json_encode(chunk)
self.set_header("Content-Type", "application/json; charset=UTF-8")
super(RequestHandler, self).write(chunk)
def set_default_headers(self):
super().set_default_headers()
self.set_header("Server", version.MITMPROXY)
@ -184,9 +191,7 @@ class ClientConnection(WebSocketEventBroadcaster):
class Flows(RequestHandler):
def get(self):
self.write(dict(
data=[convert_flow_to_json_dict(f) for f in self.view]
))
self.write([convert_flow_to_json_dict(f) for f in self.view])
class DumpFlows(RequestHandler):
@ -248,7 +253,9 @@ class FlowHandler(RequestHandler):
elif k == "port":
request.port = int(v)
elif k == "headers":
request.headers.set_state(v)
request.headers.clear()
for header in v:
request.headers.add(*header)
elif k == "content":
request.text = v
else:
@ -264,7 +271,9 @@ class FlowHandler(RequestHandler):
elif k == "http_version":
response.http_version = str(v)
elif k == "headers":
response.headers.set_state(v)
response.headers.clear()
for header in v:
response.headers.add(*header)
elif k == "content":
response.text = v
else:
@ -341,7 +350,7 @@ class FlowContentView(RequestHandler):
message = getattr(self.flow, message)
description, lines, error = contentviews.get_message_content_view(
contentviews.get(content_view.replace('_', ' ')).name, message
content_view.replace('_', ' '), message
)
# if error:
# add event log
@ -355,31 +364,26 @@ class FlowContentView(RequestHandler):
class Events(RequestHandler):
def get(self):
self.write(dict(
data=list([])
))
self.write([]) # FIXME
class Settings(RequestHandler):
def get(self):
self.write(dict(
data=dict(
version=version.VERSION,
mode=str(self.master.options.mode),
intercept=self.master.options.intercept,
showhost=self.master.options.showhost,
no_upstream_cert=self.master.options.no_upstream_cert,
rawtcp=self.master.options.rawtcp,
http2=self.master.options.http2,
anticache=self.master.options.anticache,
anticomp=self.master.options.anticomp,
stickyauth=self.master.options.stickyauth,
stickycookie=self.master.options.stickycookie,
stream= self.master.options.stream_large_bodies,
contentViews= [v.name.replace(' ', '_') for v in contentviews.views]
)
version=version.VERSION,
mode=str(self.master.options.mode),
intercept=self.master.options.intercept,
showhost=self.master.options.showhost,
no_upstream_cert=self.master.options.no_upstream_cert,
rawtcp=self.master.options.rawtcp,
http2=self.master.options.http2,
anticache=self.master.options.anticache,
anticomp=self.master.options.anticomp,
stickyauth=self.master.options.stickyauth,
stickycookie=self.master.options.stickycookie,
stream=self.master.options.stream_large_bodies,
contentViews=[v.name.replace(' ', '_') for v in contentviews.views]
))
def put(self):
@ -419,7 +423,7 @@ class Settings(RequestHandler):
print("Warning: Unknown setting {}: {}".format(k, v))
ClientConnection.broadcast(
type="UPDATE_SETTINGS",
resource="settings",
cmd="update",
data=update
)

View File

@ -21,7 +21,8 @@ class Stop(Exception):
class _WebState():
def add_log(self, e, level):
self._last_event_id += 1
# server-side log ids are odd
self._last_event_id += 2
entry = {
"id": self._last_event_id,
"message": e,
@ -29,7 +30,7 @@ class _WebState():
}
self.events.append(entry)
app.ClientConnection.broadcast(
type="UPDATE_EVENTLOG",
resource="events",
cmd="add",
data=entry
)
@ -38,9 +39,8 @@ class _WebState():
super().clear()
self.events.clear()
app.ClientConnection.broadcast(
type="UPDATE_EVENTLOG",
cmd="reset",
data=[]
resource="events",
cmd="reset"
)
@ -113,28 +113,28 @@ class WebMaster(master.Master):
def _sig_add(self, view, flow):
app.ClientConnection.broadcast(
type="UPDATE_FLOWS",
resource="flows",
cmd="add",
data=app.convert_flow_to_json_dict(flow)
)
def _sig_update(self, view, flow):
app.ClientConnection.broadcast(
type="UPDATE_FLOWS",
resource="flows",
cmd="update",
data=app.convert_flow_to_json_dict(flow)
)
def _sig_remove(self, view, flow):
app.ClientConnection.broadcast(
type="UPDATE_FLOWS",
resource="flows",
cmd="remove",
data=dict(id=flow.id)
)
def _sig_refresh(self, view):
app.ClientConnection.broadcast(
type="UPDATE_FLOWS",
resource="flows",
cmd="reset"
)

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -1,8 +1,8 @@
var conf = {
src: "src/",
dist: "../mitmproxy/web",
static: "../mitmproxy/web/static",
dist: "../mitmproxy/tools/web",
static: "../mitmproxy/tools/web/static",
js: {
// Don't package these in the vendor distribution
vendor_excludes: [

View File

@ -19,7 +19,6 @@
"bootstrap": "^3.3.6",
"classnames": "^2.2.5",
"flux": "^2.1.1",
"history": "^3.0.0",
"lodash": "^4.11.2",
"react": "^15.1.0",
"react-codemirror": "^0.2.6",

View File

@ -1,44 +0,0 @@
import {AppDispatcher} from "./dispatcher.js";
export var ActionTypes = {
// Connection
CONNECTION_OPEN: "connection_open",
CONNECTION_CLOSE: "connection_close",
CONNECTION_ERROR: "connection_error",
// Stores
SETTINGS_STORE: "settings",
EVENT_STORE: "events",
FLOW_STORE: "flows"
};
export var StoreCmds = {
ADD: "add",
UPDATE: "update",
REMOVE: "remove",
RESET: "reset"
};
export 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
});
}
};
export var Query = {
SEARCH: "s",
HIGHLIGHT: "h",
SHOW_EVENTLOG: "e"
};

View File

@ -7,6 +7,9 @@ import thunk from 'redux-thunk'
import ProxyApp from './components/ProxyApp'
import rootReducer from './ducks/index'
import { add as addLog } from './ducks/eventLog'
import useUrlState from './urlState'
import WebSocketBackend from './backends/websocket'
const middlewares = [thunk];
@ -21,12 +24,13 @@ const store = createStore(
applyMiddleware(...middlewares)
)
// @todo move to ProxyApp
useUrlState(store)
window.backend = new WebSocketBackend(store)
window.addEventListener('error', msg => {
store.dispatch(addLog(msg))
})
// @todo remove this
document.addEventListener('DOMContentLoaded', () => {
render(
<Provider store={store}>

View File

@ -0,0 +1,73 @@
/**
* The WebSocket backend is responsible for updating our knowledge of flows and events
* from the REST API and live updates delivered via a WebSocket connection.
* An alternative backend may use the REST API only to host static instances.
*/
import { fetchApi } from "../utils"
const CMD_RESET = 'reset'
export default class WebsocketBackend {
constructor(store) {
this.activeFetches = {}
this.store = store
this.connect()
}
connect() {
this.socket = new WebSocket(location.origin.replace('http', 'ws') + '/updates')
this.socket.addEventListener('open', () => this.onOpen())
this.socket.addEventListener('close', () => this.onClose())
this.socket.addEventListener('message', msg => this.onMessage(JSON.parse(msg.data)))
this.socket.addEventListener('error', error => this.onError(error))
}
onOpen() {
this.fetchData("settings")
this.fetchData("flows")
this.fetchData("events")
}
fetchData(resource) {
let queue = []
this.activeFetches[resource] = queue
fetchApi(`/${resource}`)
.then(res => res.json())
.then(json => {
// Make sure that we are not superseded yet by the server sending a RESET.
if (this.activeFetches[resource] === queue)
this.receive(resource, json)
})
}
onMessage(msg) {
if (msg.cmd === CMD_RESET) {
return this.fetchData(msg.resource)
}
if (msg.resource in this.activeFetches) {
this.activeFetches[msg.resource].push(msg)
} else {
let type = `${msg.resource}_${msg.cmd}`.toUpperCase()
this.store.dispatch({ type, ...msg })
}
}
receive(resource, data) {
let type = `${resource}_RECEIVE`.toUpperCase()
this.store.dispatch({ type, cmd: "receive", resource, data })
let queue = this.activeFetches[resource]
delete this.activeFetches[resource]
queue.forEach(msg => this.onMessage(msg))
}
onClose() {
// FIXME
console.error("onClose", arguments)
}
onError() {
// FIXME
console.error("onError", arguments)
}
}

View File

@ -3,15 +3,15 @@ import { connect } from 'react-redux'
import classnames from 'classnames'
import columns from './FlowColumns'
import { updateSort } from '../../ducks/flowView'
import { setSort } from '../../ducks/flows'
FlowTableHead.propTypes = {
updateSort: PropTypes.func.isRequired,
setSort: PropTypes.func.isRequired,
sortDesc: React.PropTypes.bool.isRequired,
sortColumn: React.PropTypes.string,
}
function FlowTableHead({ sortColumn, sortDesc, updateSort }) {
function FlowTableHead({ sortColumn, sortDesc, setSort }) {
const sortType = sortDesc ? 'sort-desc' : 'sort-asc'
return (
@ -19,7 +19,7 @@ function FlowTableHead({ sortColumn, sortDesc, updateSort }) {
{columns.map(Column => (
<th className={classnames(Column.headerClass, sortColumn === Column.name && sortType)}
key={Column.name}
onClick={() => updateSort(Column.name, Column.name !== sortColumn ? false : !sortDesc)}>
onClick={() => setSort(Column.name, Column.name !== sortColumn ? false : !sortDesc)}>
{Column.headerName}
</th>
))}
@ -29,10 +29,10 @@ function FlowTableHead({ sortColumn, sortDesc, updateSort }) {
export default connect(
state => ({
sortDesc: state.flowView.sort.desc,
sortColumn: state.flowView.sort.column,
sortDesc: state.flows.sort.desc,
sortColumn: state.flows.sort.column,
}),
{
updateSort
setSort
}
)(FlowTableHead)

View File

@ -2,7 +2,7 @@ import React, { Component, PropTypes } from 'react'
import { connect } from 'react-redux'
import FilterInput from './FilterInput'
import { update as updateSettings } from '../../ducks/settings'
import { updateFilter, updateHighlight } from '../../ducks/flowView'
import { setFilter, setHighlight } from '../../ducks/flows'
MainMenu.title = "Start"
@ -31,20 +31,20 @@ const InterceptInput = connect(
const FlowFilterInput = connect(
state => ({
value: state.flowView.filter || '',
value: state.flows.filter || '',
placeholder: 'Search',
type: 'search',
color: 'black'
}),
{ onChange: updateFilter }
{ onChange: setFilter }
)(FilterInput);
const HighlightInput = connect(
state => ({
value: state.flowView.highlight || '',
value: state.flows.highlight || '',
placeholder: 'Highlight',
type: 'tag',
color: 'hsl(48, 100%, 50%)'
}),
{ onChange: updateHighlight }
{ onChange: setHighlight }
)(FilterInput);

View File

@ -4,7 +4,6 @@ import Splitter from './common/Splitter'
import FlowTable from './FlowTable'
import FlowView from './FlowView'
import * as flowsActions from '../ducks/flows'
import { updateFilter, updateHighlight } from '../ducks/flowView'
class MainView extends Component {
@ -41,16 +40,14 @@ class MainView extends Component {
export default connect(
state => ({
flows: state.flowView.data,
filter: state.flowView.filter,
highlight: state.flowView.highlight,
flows: state.flows.view,
filter: state.flows.filter,
highlight: state.flows.highlight,
selectedFlow: state.flows.byId[state.flows.selected[0]],
tab: state.ui.flow.tab,
}),
{
selectFlow: flowsActions.select,
updateFilter,
updateHighlight,
updateFlow: flowsActions.update,
}
)(MainView)

View File

@ -1,13 +1,7 @@
import React, { Component, PropTypes } from 'react'
import { connect } from 'react-redux'
import { createHashHistory, useQueries } from 'history'
import { init as appInit, destruct as appDestruct } from '../ducks/app'
import { onKeyDown } from '../ducks/ui/keyboard'
import { updateFilter, updateHighlight } from '../ducks/flowView'
import { selectTab } from '../ducks/ui/flow'
import { select as selectFlow } from '../ducks/flows'
import { Query } from '../actions'
import MainView from './MainView'
import Header from './Header'
import EventLog from './EventLog'
@ -15,57 +9,14 @@ import Footer from './Footer'
class ProxyAppMain extends Component {
flushToStore(location) {
const components = location.pathname.split('/').filter(v => v)
const query = location.query || {}
if (components.length > 2) {
this.props.selectFlow(components[1])
this.props.selectTab(components[2])
} else {
this.props.selectFlow(null)
this.props.selectTab(null)
}
this.props.updateFilter(query[Query.SEARCH])
this.props.updateHighlight(query[Query.HIGHLIGHT])
}
flushToHistory(props) {
const query = { ...query }
if (props.filter) {
query[Query.SEARCH] = props.filter
}
if (props.highlight) {
query[Query.HIGHLIGHT] = props.highlight
}
if (props.selectedFlowId) {
this.history.push({ pathname: `/flows/${props.selectedFlowId}/${props.tab}`, query })
} else {
this.history.push({ pathname: '/flows', query })
}
}
componentWillMount() {
this.props.appInit()
this.history = useQueries(createHashHistory)()
this.unlisten = this.history.listen(location => this.flushToStore(location))
window.addEventListener('keydown', this.props.onKeyDown);
}
componentWillUnmount() {
this.props.appDestruct()
this.unlisten()
window.removeEventListener('keydown', this.props.onKeyDown);
}
componentWillReceiveProps(nextProps) {
this.flushToHistory(nextProps)
}
render() {
const { showEventLog, location, filter, highlight } = this.props
return (
@ -84,18 +35,8 @@ class ProxyAppMain extends Component {
export default connect(
state => ({
showEventLog: state.eventLog.visible,
filter: state.flowView.filter,
highlight: state.flowView.highlight,
tab: state.ui.flow.tab,
selectedFlowId: state.flows.selected[0]
}),
{
appInit,
appDestruct,
onKeyDown,
updateFilter,
updateHighlight,
selectTab,
selectFlow
}
)(ProxyAppMain)

View File

@ -1,18 +0,0 @@
import flux from "flux";
const PayloadSources = {
VIEW: "view",
SERVER: "server"
};
export var AppDispatcher = new flux.Dispatcher();
AppDispatcher.dispatchViewAction = function (action) {
action.source = PayloadSources.VIEW;
this.dispatch(action);
};
AppDispatcher.dispatchServerAction = function (action) {
action.source = PayloadSources.SERVER;
this.dispatch(action);
};

View File

@ -1,27 +0,0 @@
import { connect as wsConnect, disconnect as wsDisconnect } from './websocket'
export const INIT = 'APP_INIT'
const defaultState = {}
export function reduce(state = defaultState, action) {
switch (action.type) {
default:
return state
}
}
export function init() {
return dispatch => {
dispatch(wsConnect())
dispatch({ type: INIT })
}
}
export function destruct() {
return dispatch => {
dispatch(wsDisconnect())
dispatch({ type: DESTRUCT })
}
}

View File

@ -1,24 +1,15 @@
import reduceList, * as listActions from './utils/list'
import reduceView, * as viewActions from './utils/view'
import * as websocketActions from './websocket'
import * as msgQueueActions from './msgQueue'
import reduceStore from "./utils/store"
import * as storeActions from "./utils/store"
export const MSG_TYPE = 'UPDATE_EVENTLOG'
export const DATA_URL = '/events'
export const ADD = 'EVENTLOG_ADD'
export const RECEIVE = 'EVENTLOG_RECEIVE'
export const TOGGLE_VISIBILITY = 'EVENTLOG_TOGGLE_VISIBILITY'
export const TOGGLE_FILTER = 'EVENTLOG_TOGGLE_FILTER'
export const UNKNOWN_CMD = 'EVENTLOG_UNKNOWN_CMD'
export const FETCH_ERROR = 'EVENTLOG_FETCH_ERROR'
export const ADD = 'EVENTS_ADD'
export const RECEIVE = 'EVENTS_RECEIVE'
export const TOGGLE_VISIBILITY = 'EVENTS_TOGGLE_VISIBILITY'
export const TOGGLE_FILTER = 'EVENTS_TOGGLE_FILTER'
const defaultState = {
logId: 0,
visible: false,
filters: { debug: false, info: true, web: true },
list: reduceList(undefined, {}),
view: reduceView(undefined, {}),
...reduceStore(undefined, {}),
}
export default function reduce(state = defaultState, action) {
@ -35,27 +26,14 @@ export default function reduce(state = defaultState, action) {
return {
...state,
filters,
view: reduceView(state.view, viewActions.updateFilter(state.list.data, log => filters[log.level])),
...reduceStore(state, storeActions.setFilter(log => filters[log.level]))
}
case ADD:
const item = {
id: state.logId,
message: action.message,
level: action.level,
}
return {
...state,
logId: state.logId + 1,
list: reduceList(state.list, listActions.add(item)),
view: reduceView(state.view, viewActions.add(item, log => state.filters[log.level])),
}
case RECEIVE:
return {
...state,
list: reduceList(state.list, listActions.receive(action.list)),
view: reduceView(state.view, viewActions.receive(action.list, log => state.filters[log.level])),
...reduceStore(state, storeActions[action.cmd](action.data, log => state.filters[log.level]))
}
default:
@ -63,58 +41,25 @@ export default function reduce(state = defaultState, action) {
}
}
/**
* @public
*/
export function toggleFilter(filter) {
return { type: TOGGLE_FILTER, filter }
}
/**
* @public
*
* @todo move to ui?
*/
export function toggleVisibility() {
return { type: TOGGLE_VISIBILITY }
}
/**
* @public
*/
let logId = 1 // client-side log ids are odd
export function add(message, level = 'web') {
return { type: ADD, message, level }
}
/**
* This action creater takes all WebSocket events
*
* @public websocket
*/
export function handleWsMsg(msg) {
switch (msg.cmd) {
case websocketActions.CMD_ADD:
return add(msg.data.message, msg.data.level)
case websocketActions.CMD_RESET:
return fetchData()
default:
return { type: UNKNOWN_CMD, msg }
let data = {
id: logId,
message,
level,
}
logId += 2
return {
type: ADD,
cmd: "add",
data
}
}
/**
* @public websocket
*/
export function fetchData() {
return msgQueueActions.fetchData(MSG_TYPE)
}
/**
* @public msgQueue
*/
export function receiveData(list) {
return { type: RECEIVE, list }
}

View File

@ -1,195 +0,0 @@
import reduceView, * as viewActions from './utils/view'
import * as flowActions from './flows'
import Filt from '../filt/filt'
import { RequestUtils } from '../flow/utils'
export const UPDATE_FILTER = 'FLOWVIEW_UPDATE_FILTER'
export const UPDATE_SORT = 'FLOWVIEW_UPDATE_SORT'
export const UPDATE_HIGHLIGHT = 'FLOWVIEW_UPDATE_HIGHLIGHT'
const sortKeyFuns = {
TLSColumn: flow => flow.request.scheme,
PathColumn: flow => RequestUtils.pretty_url(flow.request),
MethodColumn: flow => flow.request.method,
StatusColumn: flow => flow.response && flow.response.status_code,
TimeColumn: flow => flow.response && flow.response.timestamp_end - flow.request.timestamp_start,
SizeColumn: flow => {
let total = flow.request.contentLength
if (flow.response) {
total += flow.response.contentLength || 0
}
return total
},
}
export function makeFilter(filter) {
if (!filter) {
return
}
return Filt.parse(filter)
}
export function makeSort({ column, desc }) {
const sortKeyFun = sortKeyFuns[column]
if (!sortKeyFun) {
return
}
return (a, b) => {
const ka = sortKeyFun(a)
const kb = sortKeyFun(b)
if (ka > kb) {
return desc ? -1 : 1
}
if (ka < kb) {
return desc ? 1 : -1
}
return 0
}
}
const defaultState = {
highlight: null,
filter: null,
sort: { column: null, desc: false },
...reduceView(undefined, {})
}
export default function reduce(state = defaultState, action) {
switch (action.type) {
case UPDATE_HIGHLIGHT:
return {
...state,
highlight: action.highlight,
}
case UPDATE_FILTER:
return {
...reduceView(
state,
viewActions.updateFilter(
action.flows,
makeFilter(action.filter),
makeSort(state.sort)
)
),
filter: action.filter,
}
case UPDATE_SORT:
const sort = { column: action.column, desc: action.desc }
return {
...reduceView(
state,
viewActions.updateSort(
makeSort(sort)
)
),
sort,
}
case flowActions.ADD:
return {
...reduceView(
state,
viewActions.add(
action.item,
makeFilter(state.filter),
makeSort(state.sort)
)
),
}
case flowActions.UPDATE:
return {
...reduceView(
state,
viewActions.update(
action.item,
makeFilter(state.filter),
makeSort(state.sort)
)
),
}
case flowActions.REMOVE:
return {
...reduceView(
state,
viewActions.remove(
action.id
)
),
}
case flowActions.RECEIVE:
return {
...reduceView(
state,
viewActions.receive(
action.list,
makeFilter(state.filter),
makeSort(state.sort)
)
),
}
default:
return {
...reduceView(state, action),
}
}
}
/**
* @public
*/
export function updateFilter(filter) {
return (dispatch, getState) => {
dispatch({ type: UPDATE_FILTER, filter, flows: getState().flows.data })
}
}
/**
* @public
*/
export function updateHighlight(highlight) {
return { type: UPDATE_HIGHLIGHT, highlight }
}
/**
* @public
*/
export function updateSort(column, desc) {
return { type: UPDATE_SORT, column, desc }
}
/**
* @public
*/
export function selectRelative(shift) {
return (dispatch, getState) => {
let currentSelectionIndex = getState().flowView.indexOf[getState().flows.selected[0]]
let minIndex = 0
let maxIndex = getState().flowView.data.length - 1
let newIndex
if (currentSelectionIndex === undefined) {
newIndex = (shift < 0) ? minIndex : maxIndex
} else {
newIndex = currentSelectionIndex + shift
newIndex = Math.max(newIndex, minIndex)
newIndex = Math.min(newIndex, maxIndex)
}
let flow = getState().flowView.data[newIndex]
dispatch(flowActions.select(flow ? flow.id : undefined))
}
}

View File

@ -1,53 +1,58 @@
import { fetchApi } from '../utils'
import reduceList, * as listActions from './utils/list'
import { selectRelative } from './flowView'
import { fetchApi } from "../utils"
import reduceStore, * as storeActions from "./utils/store"
import Filt from "../filt/filt"
import { RequestUtils } from "../flow/utils"
import * as msgQueueActions from './msgQueue'
import * as websocketActions from './websocket'
export const MSG_TYPE = 'UPDATE_FLOWS'
export const DATA_URL = '/flows'
export const ADD = 'FLOWS_ADD'
export const UPDATE = 'FLOWS_UPDATE'
export const REMOVE = 'FLOWS_REMOVE'
export const RECEIVE = 'FLOWS_RECEIVE'
export const ADD = 'FLOWS_ADD'
export const UPDATE = 'FLOWS_UPDATE'
export const REMOVE = 'FLOWS_REMOVE'
export const RECEIVE = 'FLOWS_RECEIVE'
export const SELECT = 'FLOWS_SELECT'
export const SET_FILTER = 'FLOWS_SET_FILTER'
export const SET_SORT = 'FLOWS_SET_SORT'
export const SET_HIGHLIGHT = 'FLOWS_SET_HIGHLIGHT'
export const REQUEST_ACTION = 'FLOWS_REQUEST_ACTION'
export const UNKNOWN_CMD = 'FLOWS_UNKNOWN_CMD'
export const FETCH_ERROR = 'FLOWS_FETCH_ERROR'
export const SELECT = 'FLOWS_SELECT'
const defaultState = {
highlight: null,
filter: null,
sort: { column: null, desc: false },
selected: [],
...reduceList(undefined, {}),
...reduceStore(undefined, {})
}
export default function reduce(state = defaultState, action) {
switch (action.type) {
case ADD:
return {
...state,
...reduceList(state, listActions.add(action.item)),
}
case UPDATE:
return {
...state,
...reduceList(state, listActions.update(action.item)),
}
case REMOVE:
case RECEIVE:
// FIXME: Update state.selected on REMOVE:
// The selected flow may have been removed, we need to select the next one in the view.
let storeAction = storeActions[action.cmd](
action.data,
makeFilter(state.filter),
makeSort(state.sort)
)
return {
...state,
...reduceList(state, listActions.remove(action.id)),
...reduceStore(state, storeAction)
}
case RECEIVE:
case SET_FILTER:
return {
...state,
...reduceList(state, listActions.receive(action.list)),
filter: action.filter,
...reduceStore(state, storeActions.setFilter(makeFilter(action.filter), makeSort(state.sort)))
}
case SET_SORT:
return {
...state,
sort: action.sort,
...reduceStore(state, storeActions.setSort(makeSort(action.sort)))
}
case SELECT:
@ -57,88 +62,133 @@ export default function reduce(state = defaultState, action) {
}
default:
return {
...state,
...reduceList(state, action),
}
return state
}
}
/**
* @public
*/
const sortKeyFuns = {
TLSColumn: flow => flow.request.scheme,
PathColumn: flow => RequestUtils.pretty_url(flow.request),
MethodColumn: flow => flow.request.method,
StatusColumn: flow => flow.response && flow.response.status_code,
TimeColumn: flow => flow.response && flow.response.timestamp_end - flow.request.timestamp_start,
SizeColumn: flow => {
let total = flow.request.contentLength
if (flow.response) {
total += flow.response.contentLength || 0
}
return total
},
}
export function makeFilter(filter) {
if (!filter) {
return
}
return Filt.parse(filter)
}
export function makeSort({ column, desc }) {
const sortKeyFun = sortKeyFuns[column]
if (!sortKeyFun) {
return
}
return (a, b) => {
const ka = sortKeyFun(a)
const kb = sortKeyFun(b)
if (ka > kb) {
return desc ? -1 : 1
}
if (ka < kb) {
return desc ? 1 : -1
}
return 0
}
}
export function setFilter(filter) {
return { type: SET_FILTER, filter }
}
export function setHighlight(highlight) {
return { type: SET_HIGHLIGHT, highlight }
}
export function setSort(column, desc) {
return { type: SET_SORT, sort: { column, desc } }
}
export function selectRelative(shift) {
return (dispatch, getState) => {
let currentSelectionIndex = getState().flows.viewIndex[getState().flows.selected[0]]
let minIndex = 0
let maxIndex = getState().flows.view.length - 1
let newIndex
if (currentSelectionIndex === undefined) {
newIndex = (shift < 0) ? minIndex : maxIndex
} else {
newIndex = currentSelectionIndex + shift
newIndex = window.Math.max(newIndex, minIndex)
newIndex = window.Math.min(newIndex, maxIndex)
}
let flow = getState().flows.view[newIndex]
dispatch(select(flow ? flow.id : undefined))
}
}
export function accept(flow) {
return dispatch => fetchApi(`/flows/${flow.id}/accept`, { method: 'POST' })
}
/**
* @public
*/
export function acceptAll() {
return dispatch => fetchApi('/flows/accept', { method: 'POST' })
}
/**
* @public
*/
export function remove(flow) {
return dispatch => fetchApi(`/flows/${flow.id}`, { method: 'DELETE' })
}
/**
* @public
*/
export function duplicate(flow) {
return dispatch => fetchApi(`/flows/${flow.id}/duplicate`, { method: 'POST' })
}
/**
* @public
*/
export function replay(flow) {
return dispatch => fetchApi(`/flows/${flow.id}/replay`, { method: 'POST' })
}
/**
* @public
*/
export function revert(flow) {
return dispatch => fetchApi(`/flows/${flow.id}/revert`, { method: 'POST' })
}
/**
* @public
*/
export function update(flow, data) {
return dispatch => fetchApi.put(`/flows/${flow.id}`, data)
}
export function uploadContent(flow, file, type) {
const body = new FormData()
file = new Blob([file], {type: 'plain/text'})
file = new window.Blob([file], { type: 'plain/text' })
body.append('file', file)
return dispatch => fetchApi(`/flows/${flow.id}/${type}/content`, {method: 'post', body} )
return dispatch => fetchApi(`/flows/${flow.id}/${type}/content`, { method: 'post', body })
}
/**
* @public
*/
export function clear() {
return dispatch => fetchApi('/clear', { method: 'POST' })
}
/**
* @public
*/
export function download() {
window.location = '/flows/dump'
return { type: REQUEST_ACTION }
}
/**
* @public
*/
export function upload(file) {
const body = new FormData()
body.append('file', file)
@ -152,73 +202,3 @@ export function select(id) {
flowIds: id ? [id] : []
}
}
/**
* This action creater takes all WebSocket events
*
* @public websocket
*/
export function handleWsMsg(msg) {
switch (msg.cmd) {
case websocketActions.CMD_ADD:
return addFlow(msg.data)
case websocketActions.CMD_UPDATE:
return updateFlow(msg.data)
case websocketActions.CMD_REMOVE:
return removeFlow(msg.data.id)
case websocketActions.CMD_RESET:
return fetchFlows()
default:
return { type: UNKNOWN_CMD, msg }
}
}
/**
* @public websocket
*/
export function fetchFlows() {
return msgQueueActions.fetchData(MSG_TYPE)
}
/**
* @public msgQueue
*/
export function receiveData(list) {
return { type: RECEIVE, list }
}
/**
* @private
*/
export function addFlow(item) {
return { type: ADD, item }
}
/**
* @private
*/
export function updateFlow(item) {
return { type: UPDATE, item }
}
/**
* @private
*/
export function removeFlow(id) {
return (dispatch, getState) => {
let currentIndex = getState().flowView.indexOf[getState().flows.selected[0]]
let maxIndex = getState().flowView.data.length - 1
let deleteLastEntry = maxIndex == 0
if (deleteLastEntry)
dispatch(select())
else
dispatch(selectRelative(currentIndex == maxIndex ? -1 : 1) )
dispatch({ type: REMOVE, id })
}
}

View File

@ -1,18 +1,12 @@
import { combineReducers } from 'redux'
import eventLog from './eventLog'
import websocket from './websocket'
import flows from './flows'
import flowView from './flowView'
import settings from './settings'
import ui from './ui/index'
import msgQueue from './msgQueue'
export default combineReducers({
eventLog,
websocket,
flows,
flowView,
settings,
ui,
msgQueue,
})

View File

@ -1,113 +0,0 @@
import { fetchApi } from '../utils'
import * as websocketActions from './websocket'
import * as eventLogActions from './eventLog'
import * as flowsActions from './flows'
import * as settingsActions from './settings'
export const INIT = 'MSG_QUEUE_INIT'
export const ENQUEUE = 'MSG_QUEUE_ENQUEUE'
export const CLEAR = 'MSG_QUEUE_CLEAR'
export const FETCH_ERROR = 'MSG_QUEUE_FETCH_ERROR'
const handlers = {
[eventLogActions.MSG_TYPE] : eventLogActions,
[flowsActions.MSG_TYPE] : flowsActions,
[settingsActions.MSG_TYPE] : settingsActions,
}
const defaultState = {}
export default function reduce(state = defaultState, action) {
switch (action.type) {
case INIT:
return {
...state,
[action.queue]: [],
}
case ENQUEUE:
return {
...state,
[action.queue]: [...state[action.queue], action.msg],
}
case CLEAR:
return {
...state,
[action.queue]: null,
}
default:
return state
}
}
/**
* @public websocket
*/
export function handleWsMsg(msg) {
return (dispatch, getState) => {
const handler = handlers[msg.type]
if (msg.cmd === websocketActions.CMD_RESET) {
return dispatch(fetchData(handler.MSG_TYPE))
}
if (getState().msgQueue[handler.MSG_TYPE]) {
return dispatch({ type: ENQUEUE, queue: handler.MSG_TYPE, msg })
}
return dispatch(handler.handleWsMsg(msg))
}
}
/**
* @public
*/
export function fetchData(type) {
return dispatch => {
const handler = handlers[type]
dispatch(init(handler.MSG_TYPE))
fetchApi(handler.DATA_URL)
.then(res => res.json())
.then(json => dispatch(receive(type, json)))
.catch(error => dispatch(fetchError(type, error)))
}
}
/**
* @private
*/
export function receive(type, res) {
return (dispatch, getState) => {
const handler = handlers[type]
const queue = getState().msgQueue[handler.MSG_TYPE] || []
dispatch(clear(handler.MSG_TYPE))
dispatch(handler.receiveData(res.data))
for (const msg of queue) {
dispatch(handler.handleWsMsg(msg))
}
}
}
/**
* @private
*/
export function init(queue) {
return { type: INIT, queue }
}
/**
* @private
*/
export function clear(queue) {
return { type: CLEAR, queue }
}
/**
* @private
*/
export function fetchError(type, error) {
return { type: FETCH_ERROR, type, error }
}

View File

@ -1,12 +1,7 @@
import { fetchApi } from '../utils'
import * as websocketActions from './websocket'
import * as msgQueueActions from './msgQueue'
export const MSG_TYPE = 'UPDATE_SETTINGS'
export const DATA_URL = '/settings'
export const RECEIVE = 'RECEIVE'
export const UPDATE = 'UPDATE'
export const RECEIVE = 'SETTINGS_RECEIVE'
export const UPDATE = 'SETTINGS_UPDATE'
export const REQUEST_UPDATE = 'REQUEST_UPDATE'
export const UNKNOWN_CMD = 'SETTINGS_UNKNOWN_CMD'
@ -18,12 +13,12 @@ export default function reducer(state = defaultState, action) {
switch (action.type) {
case RECEIVE:
return action.settings
return action.data
case UPDATE:
return {
...state,
...action.settings,
...action.data,
}
default:
@ -31,46 +26,7 @@ export default function reducer(state = defaultState, action) {
}
}
/**
* @public msgQueue
*/
export function handleWsMsg(msg) {
switch (msg.cmd) {
case websocketActions.CMD_UPDATE:
return updateSettings(msg.data)
default:
console.error('unknown settings update', msg)
return { type: UNKNOWN_CMD, msg }
}
}
/**
* @public
*/
export function update(settings) {
fetchApi.put('/settings', settings)
return { type: REQUEST_UPDATE }
}
/**
* @public websocket
*/
export function fetchData() {
return msgQueueActions.fetchData(MSG_TYPE)
}
/**
* @public msgQueue
*/
export function receiveData(settings) {
return { type: RECEIVE, settings }
}
/**
* @private
*/
export function updateSettings(settings) {
return { type: UPDATE, settings }
}

View File

@ -60,7 +60,7 @@ export default function reducer(state = defaultState, action) {
// There is no explicit "stop edit" event.
// We stop editing when we receive an update for
// the currently edited flow from the server
if (action.item.id === state.modifiedFlow.id) {
if (action.data.id === state.modifiedFlow.id) {
return {
...state,
modifiedFlow: false,

View File

@ -2,6 +2,7 @@ import { combineReducers } from 'redux'
import flow from './flow'
import header from './header'
// TODO: Just move ducks/ui/* into ducks/?
export default combineReducers({
flow,
header,

View File

@ -1,5 +1,4 @@
import { Key } from '../../utils'
import { selectRelative as selectFlowRelative } from '../flowView'
import { selectTab } from './flow'
import * as flowsActions from '../flows'
@ -20,29 +19,29 @@ export function onKeyDown(e) {
switch (key) {
case Key.K:
case Key.UP:
dispatch(selectFlowRelative(-1))
dispatch(flowsActions.selectRelative(-1))
break
case Key.J:
case Key.DOWN:
dispatch(selectFlowRelative(+1))
dispatch(flowsActions.selectRelative(+1))
break
case Key.SPACE:
case Key.PAGE_DOWN:
dispatch(selectFlowRelative(+10))
dispatch(flowsActions.selectRelative(+10))
break
case Key.PAGE_UP:
dispatch(selectFlowRelative(-10))
dispatch(flowsActions.selectRelative(-10))
break
case Key.END:
dispatch(selectFlowRelative(+1e10))
dispatch(flowsActions.selectRelative(+1e10))
break
case Key.HOME:
dispatch(selectFlowRelative(-1e10))
dispatch(flowsActions.selectRelative(-1e10))
break
case Key.ESC:

View File

@ -1,105 +0,0 @@
import _ from 'lodash'
export const ADD = 'LIST_ADD'
export const UPDATE = 'LIST_UPDATE'
export const REMOVE = 'LIST_REMOVE'
export const RECEIVE = 'LIST_RECEIVE'
const defaultState = {
data: [],
byId: {},
indexOf: {},
}
export default function reduce(state = defaultState, action) {
switch (action.type) {
case ADD:
return {
...state,
data: [...state.data, action.item],
byId: { ...state.byId, [action.item.id]: action.item },
indexOf: { ...state.indexOf, [action.item.id]: state.data.length },
}
case UPDATE: {
const index = state.indexOf[action.item.id]
if (index == null) {
return state
}
const data = [...state.data]
data[index] = action.item
return {
...state,
data,
byId: { ...state.byId, [action.item.id]: action.item }
}
}
case REMOVE: {
const index = state.indexOf[action.id]
if (index == null) {
return state
}
const data = [...state.data]
const indexOf = { ...state.indexOf, [action.id]: null }
data.splice(index, 1)
for (let i = data.length - 1; i >= index; i--) {
indexOf[data[i].id] = i
}
return {
...state,
data,
indexOf,
byId: { ...state.byId, [action.id]: null },
}
}
case RECEIVE:
return {
...state,
data: action.list,
byId: _.fromPairs(action.list.map(item => [item.id, item])),
indexOf: _.fromPairs(action.list.map((item, index) => [item.id, index])),
}
default:
return state
}
}
/**
* @public
*/
export function add(item) {
return { type: ADD, item }
}
/**
* @public
*/
export function update(item) {
return { type: UPDATE, item }
}
/**
* @public
*/
export function remove(id) {
return { type: REMOVE, id }
}
/**
* @public
*/
export function receive(list) {
return { type: RECEIVE, list }
}

View File

@ -0,0 +1,210 @@
export const SET_FILTER = 'LIST_SET_FILTER'
export const SET_SORT = 'LIST_SET_SORT'
export const ADD = 'LIST_ADD'
export const UPDATE = 'LIST_UPDATE'
export const REMOVE = 'LIST_REMOVE'
export const RECEIVE = 'LIST_RECEIVE'
const defaultState = {
byId: {},
list: [],
listIndex: {},
view: [],
viewIndex: {},
}
/**
* The store reducer can be used as a mixin to another reducer that always returns a
* new { byId, list, listIndex, view, viewIndex } object. The reducer using the store
* usually has to map its action to the matching store action and then call the mixin with that.
*
* Example Usage:
*
* import reduceStore, * as storeActions from "./utils/store"
*
* case EVENTLOG_ADD:
* return {
* ...state,
* ...reduceStore(state, storeActions.add(action.data))
* }
*
*/
export default function reduce(state = defaultState, action) {
let { byId, list, listIndex, view, viewIndex } = state
switch (action.type) {
case SET_FILTER:
view = list.filter(action.filter).sort(action.sort)
viewIndex = {}
view.forEach((item, index) => {
viewIndex[item.id] = index
})
break
case SET_SORT:
view = [...view].sort(action.sort)
viewIndex = {}
view.forEach((item, index) => {
viewIndex[item.id] = index
})
break
case ADD:
if (action.item.id in byId) {
// we already had that.
break
}
byId = { ...byId, [action.item.id]: action.item }
listIndex = { ...listIndex, [action.item.id]: list.length }
list = [...list, action.item]
if (action.filter(action.item)) {
({ view, viewIndex } = sortedInsert(state, action.item, action.sort))
}
break
case UPDATE:
byId = { ...byId, [action.item.id]: action.item }
list = [...list]
list[listIndex[action.item.id]] = action.item
let hasOldItem = action.item.id in viewIndex
let hasNewItem = action.filter(action.item)
if (hasNewItem && !hasOldItem) {
({view, viewIndex} = sortedInsert(state, action.item, action.sort))
}
else if (!hasNewItem && hasOldItem) {
({data: view, dataIndex: viewIndex} = removeData(view, viewIndex, action.item.id))
}
else if (hasNewItem && hasOldItem) {
({view, viewIndex} = sortedUpdate(state, action.item, action.sort))
}
break
case REMOVE:
if (!(action.id in byId)) {
break
}
delete byId[action.id];
({data: list, dataIndex: listIndex} = removeData(list, listIndex, action.id))
if (action.id in viewIndex) {
({data: view, dataIndex: viewIndex} = removeData(view, viewIndex, action.id))
}
break
case RECEIVE:
list = action.list
listIndex = {}
byId = {}
list.forEach((item, i) => {
byId[item.id] = item
listIndex[item.id] = i
})
view = list.filter(action.filter).sort(action.sort)
viewIndex = {}
view.forEach((item, index) => {
viewIndex[item.id] = index
})
break
}
return { byId, list, listIndex, view, viewIndex }
}
export function setFilter(filter = defaultFilter, sort = defaultSort) {
return { type: SET_FILTER, filter, sort }
}
export function setSort(sort = defaultSort) {
return { type: SET_SORT, sort }
}
export function add(item, filter = defaultFilter, sort = defaultSort) {
return { type: ADD, item, filter, sort }
}
export function update(item, filter = defaultFilter, sort = defaultSort) {
return { type: UPDATE, item, filter, sort }
}
export function remove(id) {
return { type: REMOVE, id }
}
export function receive(list, filter = defaultFilter, sort = defaultSort) {
return { type: RECEIVE, list, filter, sort }
}
function sortedInsert(state, item, sort) {
const index = sortedIndex(state.view, item, sort)
const view = [...state.view]
const viewIndex = { ...state.viewIndex }
view.splice(index, 0, item)
for (let i = view.length - 1; i >= index; i--) {
viewIndex[view[i].id] = i
}
return { view, viewIndex }
}
function removeData(currentData, currentDataIndex, id) {
const index = currentDataIndex[id]
const data = [...currentData]
const dataIndex = { ...currentDataIndex }
delete dataIndex[id];
data.splice(index, 1)
for (let i = data.length - 1; i >= index; i--) {
dataIndex[data[i].id] = i
}
return { data, dataIndex }
}
function sortedUpdate(state, item, sort) {
let view = [...state.view]
let viewIndex = { ...state.viewIndex }
let index = viewIndex[item.id]
view[index] = item
while (index + 1 < view.length && sort(view[index], view[index + 1]) > 0) {
view[index] = view[index + 1]
view[index + 1] = item
viewIndex[item.id] = index + 1
viewIndex[view[index].id] = index
++index
}
while (index > 0 && sort(view[index], view[index - 1]) < 0) {
view[index] = view[index - 1]
view[index - 1] = item
viewIndex[item.id] = index - 1
viewIndex[view[index].id] = index
--index
}
return { view, viewIndex }
}
function sortedIndex(list, item, sort) {
let low = 0
let high = list.length
while (low < high) {
const middle = (low + high) >>> 1
if (sort(item, list[middle]) >= 0) {
low = middle + 1
} else {
high = middle
}
}
return low
}
function defaultFilter() {
return true
}
function defaultSort(a, b) {
return 0
}

View File

@ -1,189 +0,0 @@
import _ from 'lodash'
export const UPDATE_FILTER = 'VIEW_UPDATE_FILTER'
export const UPDATE_SORT = 'VIEW_UPDATE_SORT'
export const ADD = 'VIEW_ADD'
export const UPDATE = 'VIEW_UPDATE'
export const REMOVE = 'VIEW_REMOVE'
export const RECEIVE = 'VIEW_RECEIVE'
const defaultState = {
data: [],
indexOf: {},
}
export default function reduce(state = defaultState, action) {
switch (action.type) {
case UPDATE_FILTER:
{
const data = action.list.filter(action.filter).sort(action.sort)
return {
...state,
data,
indexOf: _.fromPairs(data.map((item, index) => [item.id, index])),
}
}
case UPDATE_SORT:
{
const data = [...state.data].sort(action.sort)
return {
...state,
data,
indexOf: _.fromPairs(data.map((item, index) => [item.id, index])),
}
}
case ADD:
if (state.indexOf[action.item.id] != null || !action.filter(action.item)) {
return state
}
return {
...state,
...sortedInsert(state, action.item, action.sort),
}
case REMOVE:
if (state.indexOf[action.id] == null) {
return state
}
return {
...state,
...sortedRemove(state, action.id),
}
case UPDATE:
let hasOldItem = state.indexOf[action.item.id] !== null && state.indexOf[action.item.id] !== undefined
let hasNewItem = action.filter(action.item)
if (!hasNewItem && !hasOldItem) {
return state
}
if (hasNewItem && !hasOldItem) {
return {
...state,
...sortedInsert(state, action.item, action.sort)
}
}
if (!hasNewItem && hasOldItem) {
return {
...state,
...sortedRemove(state, action.item.id)
}
}
if (hasNewItem && hasOldItem) {
return {
...state,
...sortedUpdate(state, action.item, action.sort),
}
}
case RECEIVE:
{
const data = action.list.filter(action.filter).sort(action.sort)
return {
...state,
data,
indexOf: _.fromPairs(data.map((item, index) => [item.id, index])),
}
}
default:
return state
}
}
export function updateFilter(list, filter = defaultFilter, sort = defaultSort) {
return { type: UPDATE_FILTER, list, filter, sort }
}
export function updateSort(sort = defaultSort) {
return { type: UPDATE_SORT, sort }
}
export function add(item, filter = defaultFilter, sort = defaultSort) {
return { type: ADD, item, filter, sort }
}
export function update(item, filter = defaultFilter, sort = defaultSort) {
return { type: UPDATE, item, filter, sort }
}
export function remove(id) {
return { type: REMOVE, id }
}
export function receive(list, filter = defaultFilter, sort = defaultSort) {
return { type: RECEIVE, list, filter, sort }
}
function sortedInsert(state, item, sort) {
const index = sortedIndex(state.data, item, sort)
const data = [ ...state.data ]
const indexOf = { ...state.indexOf }
data.splice(index, 0, item)
for (let i = data.length - 1; i >= index; i--) {
indexOf[data[i].id] = i
}
return { data, indexOf }
}
function sortedRemove(state, id) {
const index = state.indexOf[id]
const data = [...state.data]
const indexOf = { ...state.indexOf, [id]: null }
data.splice(index, 1)
for (let i = data.length - 1; i >= index; i--) {
indexOf[data[i].id] = i
}
return { data, indexOf }
}
function sortedUpdate(state, item, sort) {
let data = [ ...state.data ]
let indexOf = { ...state.indexOf }
let index = indexOf[item.id]
data[index] = item
while (index + 1 < data.length && sort(data[index], data[index + 1]) > 0) {
data[index] = data[index + 1]
data[index + 1] = item
indexOf[item.id] = index + 1
indexOf[data[index].id] = index
++index
}
while (index > 0 && sort(data[index], data[index - 1]) < 0) {
data[index] = data[index - 1]
data[index - 1] = item
indexOf[item.id] = index - 1
indexOf[data[index].id] = index
--index
}
return { data, indexOf }
}
function sortedIndex(list, item, sort) {
let low = 0
let high = list.length
while (low < high) {
const middle = (low + high) >>> 1
if (sort(item, list[middle]) >= 0) {
low = middle + 1
} else {
high = middle
}
}
return low
}
function defaultFilter() {
return true
}
function defaultSort(a, b) {
return 0
}

View File

@ -1,93 +0,0 @@
import { ConnectionActions } from '../actions.js'
import { AppDispatcher } from '../dispatcher.js'
import * as msgQueueActions from './msgQueue'
import * as eventLogActions from './eventLog'
import * as flowsActions from './flows'
import * as settingsActions from './settings'
export const CMD_ADD = 'add'
export const CMD_UPDATE = 'update'
export const CMD_REMOVE = 'remove'
export const CMD_RESET = 'reset'
export const SYM_SOCKET = Symbol('WEBSOCKET_SYM_SOCKET')
export const CONNECT = 'WEBSOCKET_CONNECT'
export const CONNECTED = 'WEBSOCKET_CONNECTED'
export const DISCONNECT = 'WEBSOCKET_DISCONNECT'
export const DISCONNECTED = 'WEBSOCKET_DISCONNECTED'
export const ERROR = 'WEBSOCKET_ERROR'
export const MESSAGE = 'WEBSOCKET_MESSAGE'
/* we may want to have an error message attribute here at some point */
const defaultState = { connected: false, socket: null }
export default function reduce(state = defaultState, action) {
switch (action.type) {
case CONNECT:
return { ...state, [SYM_SOCKET]: action.socket }
case CONNECTED:
return { ...state, connected: true }
case DISCONNECT:
return { ...state, connected: false }
case DISCONNECTED:
return { ...state, [SYM_SOCKET]: null, connected: false }
default:
return state
}
}
export function connect() {
return dispatch => {
const socket = new WebSocket(location.origin.replace('http', 'ws') + '/updates')
socket.addEventListener('open', () => dispatch(onConnect()))
socket.addEventListener('close', () => dispatch(onDisconnect()))
socket.addEventListener('message', msg => dispatch(onMessage(JSON.parse(msg.data))))
socket.addEventListener('error', error => dispatch(onError(error)))
dispatch({ type: CONNECT, socket })
}
}
export function disconnect() {
return (dispatch, getState) => {
getState().settings[SYM_SOCKET].close()
dispatch({ type: DISCONNECT })
}
}
export function onConnect() {
// workaround to make sure that our state is already available.
return dispatch => {
dispatch({ type: CONNECTED })
dispatch(settingsActions.fetchData())
dispatch(flowsActions.fetchFlows())
dispatch(eventLogActions.fetchData())
}
}
export function onMessage(msg) {
return msgQueueActions.handleWsMsg(msg)
}
export function onDisconnect() {
return dispatch => {
dispatch(eventLogActions.add('WebSocket connection closed.'))
dispatch({ type: DISCONNECTED })
}
}
export function onError(error) {
// @todo let event log subscribe WebSocketActions.ERROR
return dispatch => {
dispatch(eventLogActions.add('WebSocket connection error.'))
dispatch({ type: ERROR, error })
}
}

83
web/src/js/urlState.js Normal file
View File

@ -0,0 +1,83 @@
/**
* Instead of dealing with react-router's ever-changing APIs,
* we use a simple url state manager where we only
*
* - read the initial URL state on page load
* - push updates to the URL later on.
*/
import { select, setFilter, setHighlight } from "./ducks/flows"
import { selectTab } from "./ducks/ui/flow"
import { toggleVisibility } from "./ducks/eventLog"
const Query = {
SEARCH: "s",
HIGHLIGHT: "h",
SHOW_EVENTLOG: "e"
};
function updateStoreFromUrl(store) {
const [path, query] = window.location.hash.substr(1).split("?", 2)
const path_components = path.substr(1).split("/")
if (path_components[0] === "flows") {
if (path_components.length == 3) {
const [flowId, tab] = path_components.slice(1)
store.dispatch(select(flowId))
store.dispatch(selectTab(tab))
}
}
if (query) {
query
.split("&")
.forEach((x) => {
const [key, value] = x.split("=", 2)
switch (key) {
case Query.SEARCH:
store.dispatch(setFilter(value))
break
case Query.HIGHLIGHT:
store.dispatch(setHighlight(value))
break
case Query.SHOW_EVENTLOG:
if (!store.getState().eventLog.visible)
store.dispatch(toggleVisibility())
break
default:
console.error(`unimplemented query arg: ${x}`)
}
})
}
}
function updateUrlFromStore(store) {
const state = store.getState()
let query = {
[Query.SEARCH]: state.flows.filter,
[Query.HIGHLIGHT]: state.flows.highlight,
[Query.SHOW_EVENTLOG]: state.eventLog.visible,
}
const queryStr = Object.keys(query)
.filter(k => query[k])
.map(k => `${k}=${query[k]}`)
.join("&")
let url
if (state.flows.selected.length > 0) {
url = `/flows/${state.flows.selected[0]}/${state.ui.flow.tab}`
} else {
url = "/flows"
}
if (queryStr) {
url += "?" + queryStr
}
if (window.location.hash.substr(1) !== url) {
history.replaceState(undefined, "", `/#${url}`)
}
}
export default function initialize(store) {
updateStoreFromUrl(store)
store.subscribe(() => updateUrlFromStore(store))
}