aboutsummaryrefslogtreecommitdiffstats
path: root/web/src/js
diff options
context:
space:
mode:
Diffstat (limited to 'web/src/js')
-rw-r--r--web/src/js/actions.js44
-rw-r--r--web/src/js/app.jsx8
-rw-r--r--web/src/js/backends/websocket.js73
-rw-r--r--web/src/js/components/FlowTable/FlowTableHead.jsx14
-rw-r--r--web/src/js/components/Header/MainMenu.jsx10
-rw-r--r--web/src/js/components/MainView.jsx9
-rw-r--r--web/src/js/components/ProxyApp.jsx59
-rw-r--r--web/src/js/dispatcher.js18
-rw-r--r--web/src/js/ducks/app.js27
-rw-r--r--web/src/js/ducks/eventLog.js95
-rw-r--r--web/src/js/ducks/flowView.js195
-rw-r--r--web/src/js/ducks/flows.js248
-rw-r--r--web/src/js/ducks/index.js6
-rw-r--r--web/src/js/ducks/msgQueue.js113
-rw-r--r--web/src/js/ducks/settings.js52
-rw-r--r--web/src/js/ducks/ui/flow.js2
-rw-r--r--web/src/js/ducks/ui/index.js1
-rw-r--r--web/src/js/ducks/ui/keyboard.js13
-rw-r--r--web/src/js/ducks/utils/list.js105
-rw-r--r--web/src/js/ducks/utils/store.js210
-rwxr-xr-xweb/src/js/ducks/utils/view.js189
-rw-r--r--web/src/js/ducks/websocket.js93
-rw-r--r--web/src/js/urlState.js83
23 files changed, 533 insertions, 1134 deletions
diff --git a/web/src/js/actions.js b/web/src/js/actions.js
deleted file mode 100644
index 51b180ce..00000000
--- a/web/src/js/actions.js
+++ /dev/null
@@ -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"
-};
diff --git a/web/src/js/app.jsx b/web/src/js/app.jsx
index f04baea0..a94d2ef6 100644
--- a/web/src/js/app.jsx
+++ b/web/src/js/app.jsx
@@ -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}>
diff --git a/web/src/js/backends/websocket.js b/web/src/js/backends/websocket.js
new file mode 100644
index 00000000..44b260c9
--- /dev/null
+++ b/web/src/js/backends/websocket.js
@@ -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)
+ }
+}
diff --git a/web/src/js/components/FlowTable/FlowTableHead.jsx b/web/src/js/components/FlowTable/FlowTableHead.jsx
index 50242737..b201285f 100644
--- a/web/src/js/components/FlowTable/FlowTableHead.jsx
+++ b/web/src/js/components/FlowTable/FlowTableHead.jsx
@@ -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)
diff --git a/web/src/js/components/Header/MainMenu.jsx b/web/src/js/components/Header/MainMenu.jsx
index 7236d31f..5ab3fa9d 100644
--- a/web/src/js/components/Header/MainMenu.jsx
+++ b/web/src/js/components/Header/MainMenu.jsx
@@ -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);
diff --git a/web/src/js/components/MainView.jsx b/web/src/js/components/MainView.jsx
index 8be6f21c..5c9a2d30 100644
--- a/web/src/js/components/MainView.jsx
+++ b/web/src/js/components/MainView.jsx
@@ -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)
diff --git a/web/src/js/components/ProxyApp.jsx b/web/src/js/components/ProxyApp.jsx
index d76816e5..18976de0 100644
--- a/web/src/js/components/ProxyApp.jsx
+++ b/web/src/js/components/ProxyApp.jsx
@@ -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)
diff --git a/web/src/js/dispatcher.js b/web/src/js/dispatcher.js
deleted file mode 100644
index b4e22ed9..00000000
--- a/web/src/js/dispatcher.js
+++ /dev/null
@@ -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);
-}; \ No newline at end of file
diff --git a/web/src/js/ducks/app.js b/web/src/js/ducks/app.js
deleted file mode 100644
index f1dcb490..00000000
--- a/web/src/js/ducks/app.js
+++ /dev/null
@@ -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 })
- }
-}
diff --git a/web/src/js/ducks/eventLog.js b/web/src/js/ducks/eventLog.js
index f72d7bd6..776e4b08 100644
--- a/web/src/js/ducks/eventLog.js
+++ b/web/src/js/ducks/eventLog.js
@@ -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 }
}
diff --git a/web/src/js/ducks/flowView.js b/web/src/js/ducks/flowView.js
deleted file mode 100644
index dd5bea41..00000000
--- a/web/src/js/ducks/flowView.js
+++ /dev/null
@@ -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))
- }
-}
diff --git a/web/src/js/ducks/flows.js b/web/src/js/ducks/flows.js
index 404db0d1..d3717533 100644
--- a/web/src/js/ducks/flows.js
+++ b/web/src/js/ducks/flows.js
@@ -1,53 +1,58 @@
-import { fetchApi } from '../utils'
-import reduceList, * as listActions from './utils/list'
-import { selectRelative } from './flowView'
-
-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'
+import { fetchApi } from "../utils"
+import reduceStore, * as storeActions from "./utils/store"
+import Filt from "../filt/filt"
+import { RequestUtils } from "../flow/utils"
+
+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:
+ 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.update(action.item)),
+ ...reduceStore(state, storeAction)
}
- case REMOVE:
+ case SET_FILTER:
return {
...state,
- ...reduceList(state, listActions.remove(action.id)),
+ filter: action.filter,
+ ...reduceStore(state, storeActions.setFilter(makeFilter(action.filter), makeSort(state.sort)))
}
- case RECEIVE:
+ case SET_SORT:
return {
...state,
- ...reduceList(state, listActions.receive(action.list)),
+ 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
+ }
+}
+
+
+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
}
}
-/**
- * @public
- */
+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 })
- }
-}
diff --git a/web/src/js/ducks/index.js b/web/src/js/ducks/index.js
index b90b24ff..753075fa 100644
--- a/web/src/js/ducks/index.js
+++ b/web/src/js/ducks/index.js
@@ -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,
})
diff --git a/web/src/js/ducks/msgQueue.js b/web/src/js/ducks/msgQueue.js
deleted file mode 100644
index 6d82f4c2..00000000
--- a/web/src/js/ducks/msgQueue.js
+++ /dev/null
@@ -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 }
-}
diff --git a/web/src/js/ducks/settings.js b/web/src/js/ducks/settings.js
index 6b21baec..a2e360de 100644
--- a/web/src/js/ducks/settings.js
+++ b/web/src/js/ducks/settings.js
@@ -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 }
-}
diff --git a/web/src/js/ducks/ui/flow.js b/web/src/js/ducks/ui/flow.js
index 4a6d64cd..b5f6f78b 100644
--- a/web/src/js/ducks/ui/flow.js
+++ b/web/src/js/ducks/ui/flow.js
@@ -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,
diff --git a/web/src/js/ducks/ui/index.js b/web/src/js/ducks/ui/index.js
index f3c5f59e..1d989eb1 100644
--- a/web/src/js/ducks/ui/index.js
+++ b/web/src/js/ducks/ui/index.js
@@ -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,
diff --git a/web/src/js/ducks/ui/keyboard.js b/web/src/js/ducks/ui/keyboard.js
index 10c69853..7418eca9 100644
--- a/web/src/js/ducks/ui/keyboard.js
+++ b/web/src/js/ducks/ui/keyboard.js
@@ -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:
diff --git a/web/src/js/ducks/utils/list.js b/web/src/js/ducks/utils/list.js
deleted file mode 100644
index fdeb5856..00000000
--- a/web/src/js/ducks/utils/list.js
+++ /dev/null
@@ -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 }
-}
diff --git a/web/src/js/ducks/utils/store.js b/web/src/js/ducks/utils/store.js
new file mode 100644
index 00000000..9ea4f02e
--- /dev/null
+++ b/web/src/js/ducks/utils/store.js
@@ -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
+}
diff --git a/web/src/js/ducks/utils/view.js b/web/src/js/ducks/utils/view.js
deleted file mode 100755
index 6bf0a63e..00000000
--- a/web/src/js/ducks/utils/view.js
+++ /dev/null
@@ -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
-}
diff --git a/web/src/js/ducks/websocket.js b/web/src/js/ducks/websocket.js
deleted file mode 100644
index 21400bb5..00000000
--- a/web/src/js/ducks/websocket.js
+++ /dev/null
@@ -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 })
- }
-}
diff --git a/web/src/js/urlState.js b/web/src/js/urlState.js
new file mode 100644
index 00000000..ca9187b2
--- /dev/null
+++ b/web/src/js/urlState.js
@@ -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))
+}