diff options
author | Emiliano Ciavatta | 2020-10-08 09:16:38 +0000 |
---|---|---|
committer | Emiliano Ciavatta | 2020-10-08 09:16:38 +0000 |
commit | 2b2b8e66e7244592672c283fe7bb5d9a1fd9da99 (patch) | |
tree | 9a1d35e7a16246ad22feb64e16018e2ed1b93948 | |
parent | 659833be506e86de277d23f4b48ecce422cfaa5d (diff) |
Minor changes
-rw-r--r-- | application_router.go | 6 | ||||
-rw-r--r-- | connections_controller.go | 4 | ||||
-rw-r--r-- | frontend/src/components/Connection.js | 33 | ||||
-rw-r--r-- | frontend/src/components/Connection.scss | 4 | ||||
-rw-r--r-- | frontend/src/components/Notifications.js | 14 | ||||
-rw-r--r-- | frontend/src/components/Notifications.scss | 1 | ||||
-rw-r--r-- | frontend/src/views/App.js | 4 | ||||
-rw-r--r-- | frontend/src/views/Connections.js | 44 | ||||
-rw-r--r-- | frontend/src/views/Footer.js | 176 | ||||
-rw-r--r-- | frontend/src/views/Header.js | 24 | ||||
-rw-r--r-- | frontend/src/views/Timeline.js | 215 | ||||
-rw-r--r-- | frontend/src/views/Timeline.scss (renamed from frontend/src/views/Footer.scss) | 0 |
12 files changed, 291 insertions, 234 deletions
diff --git a/application_router.go b/application_router.go index 6431e22..30ec7c6 100644 --- a/application_router.go +++ b/application_router.go @@ -269,9 +269,9 @@ func CreateApplicationRouter(applicationContext *ApplicationContext, } if result { - c.Status(http.StatusAccepted) - notificationController.Notify("connections.action", UpdateNotification, - gin.H{"connection_id": c.Param("id"), "action": c.Param("action")}) + response := gin.H{"connection_id": c.Param("id"), "action": c.Param("action")} + success(c, response) + notificationController.Notify("connections.action", UpdateNotification, response) } else { notFound(c, gin.H{"connection": id}) } diff --git a/connections_controller.go b/connections_controller.go index 2894193..e872c9f 100644 --- a/connections_controller.go +++ b/connections_controller.go @@ -68,11 +68,11 @@ func (cc ConnectionsController) GetConnections(c context.Context, filter Connect from, _ := RowIDFromHex(filter.From) if !from.IsZero() { - query = query.Filter(OrderedDocument{{"_id", UnorderedDocument{"$lt": from}}}) + query = query.Filter(OrderedDocument{{"_id", UnorderedDocument{"$lte": from}}}) } to, _ := RowIDFromHex(filter.To) if !to.IsZero() { - query = query.Filter(OrderedDocument{{"_id", UnorderedDocument{"$gt": to}}}) + query = query.Filter(OrderedDocument{{"_id", UnorderedDocument{"$gte": to}}}) } else { query = query.Sort("_id", false) } diff --git a/frontend/src/components/Connection.js b/frontend/src/components/Connection.js index 46a0cab..b7e2531 100644 --- a/frontend/src/components/Connection.js +++ b/frontend/src/components/Connection.js @@ -4,6 +4,7 @@ import {Form, OverlayTrigger, Popover} from "react-bootstrap"; import backend from "../backend"; import {dateTimeToTime, durationBetween, formatSize} from "../utils"; import ButtonField from "./fields/ButtonField"; +import LinkPopover from "./objects/LinkPopover"; const classNames = require('classnames'); @@ -54,9 +55,9 @@ class Connection extends Component { serviceName = service.name; serviceColor = service.color; } - let startedAt = new Date(conn.started_at); - let closedAt = new Date(conn.closed_at); - let processedAt = new Date(conn.processed_at); + let startedAt = new Date(conn["started_at"]); + let closedAt = new Date(conn["closed_at"]); + let processedAt = new Date(conn["processed_at"]); let timeInfo = <div> <span>Started at {startedAt.toLocaleDateString() + " " + startedAt.toLocaleTimeString()}</span><br/> <span>Processed at {processedAt.toLocaleDateString() + " " + processedAt.toLocaleTimeString()}</span><br/> @@ -88,28 +89,20 @@ class Connection extends Component { <td> <span className="connection-service"> <ButtonField small fullSpan color={serviceColor} name={serviceName} - onClick={() => this.props.addServicePortFilter(conn.port_dst)}/> + onClick={() => this.props.addServicePortFilter(conn["port_dst"])}/> </span> </td> - <td className="clickable" onClick={this.props.onSelected}>{conn.ip_src}</td> - <td className="clickable" onClick={this.props.onSelected}>{conn.port_src}</td> - <td className="clickable" onClick={this.props.onSelected}>{conn.ip_dst}</td> - <td className="clickable" onClick={this.props.onSelected}>{conn.port_dst}</td> - <td className="clickable" onClick={this.props.onSelected}>{dateTimeToTime(conn.started_at)}</td> + <td className="clickable" onClick={this.props.onSelected}>{conn["ip_src"]}</td> + <td className="clickable" onClick={this.props.onSelected}>{conn["port_src"]}</td> + <td className="clickable" onClick={this.props.onSelected}>{conn["ip_dst"]}</td> + <td className="clickable" onClick={this.props.onSelected}>{conn["port_dst"]}</td> <td className="clickable" onClick={this.props.onSelected}> - <OverlayTrigger trigger={["focus", "hover"]} placement="right" - overlay={popoverFor("duration", timeInfo)}> - <span className="test-tooltip">{durationBetween(startedAt, closedAt)}</span> - </OverlayTrigger> + <LinkPopover text={dateTimeToTime(conn["started_at"])} content={timeInfo} placement="right"/> </td> - <td className="clickable" onClick={this.props.onSelected}>{formatSize(conn.client_bytes)}</td> - <td className="clickable" onClick={this.props.onSelected}>{formatSize(conn.server_bytes)}</td> + <td className="clickable" onClick={this.props.onSelected}>{durationBetween(startedAt, closedAt)}</td> + <td className="clickable" onClick={this.props.onSelected}>{formatSize(conn["client_bytes"])}</td> + <td className="clickable" onClick={this.props.onSelected}>{formatSize(conn["server_bytes"])}</td> <td> - {/*<OverlayTrigger trigger={["focus", "hover"]} placement="right"*/} - {/* overlay={popoverFor("hide", <span>Hide this connection from the list</span>)}>*/} - {/* <span className={"connection-icon" + (conn.hidden ? " icon-enabled" : "")}*/} - {/* onClick={() => this.handleAction("hide")}>%</span>*/} - {/*</OverlayTrigger>*/} <OverlayTrigger trigger={["focus", "hover"]} placement="right" overlay={popoverFor("hide", <span>Mark this connection</span>)}> <span className={"connection-icon" + (conn.marked ? " icon-enabled" : "")} diff --git a/frontend/src/components/Connection.scss b/frontend/src/components/Connection.scss index cb7fa54..cc1ea96 100644 --- a/frontend/src/components/Connection.scss +++ b/frontend/src/components/Connection.scss @@ -42,6 +42,10 @@ &.has-matched-rules { border-bottom: 0; } + + .link-popover { + font-weight: 400; + } } .connection-popover { diff --git a/frontend/src/components/Notifications.js b/frontend/src/components/Notifications.js index 4d6dcd4..9ce2b58 100644 --- a/frontend/src/components/Notifications.js +++ b/frontend/src/components/Notifications.js @@ -17,24 +17,30 @@ class Notifications extends Component { const notifications = this.state.notifications; notifications.push(notification); this.setState({notifications}); - setTimeout(() => { const notifications = this.state.notifications; notification.open = true; this.setState({notifications}); }, 100); - setTimeout(() => { + const hideHandle = setTimeout(() => { const notifications = _.without(this.state.notifications, notification); const closedNotifications = this.state.closedNotifications.concat([notification]); notification.closed = true; this.setState({notifications, closedNotifications}); }, 5000); - setTimeout(() => { + const removeHandle = setTimeout(() => { const closedNotifications = _.without(this.state.closedNotifications, notification); this.setState({closedNotifications}); }, 6000); + + notification.onClick = () => { + clearTimeout(hideHandle); + clearTimeout(removeHandle); + const notifications = _.without(this.state.notifications, notification); + this.setState({notifications}); + }; }); } @@ -45,7 +51,7 @@ class Notifications extends Component { { this.state.closedNotifications.concat(this.state.notifications).map(n => <div className={classNames("notification", {"notification-closed": n.closed}, - {"notification-open": n.open})}> + {"notification-open": n.open})} onClick={n.onClick}> <h3 className="notification-title">{n.event}</h3> <span className="notification-description">{JSON.stringify(n.message)}</span> </div> diff --git a/frontend/src/components/Notifications.scss b/frontend/src/components/Notifications.scss index bec7734..324d0bb 100644 --- a/frontend/src/components/Notifications.scss +++ b/frontend/src/components/Notifications.scss @@ -18,6 +18,7 @@ color: $color-green-light; border-left: 5px solid $color-green-dark; background-color: $color-green; + cursor: pointer; .notification-title { font-size: 0.9em; diff --git a/frontend/src/views/App.js b/frontend/src/views/App.js index c14b7f5..4bb9f57 100644 --- a/frontend/src/views/App.js +++ b/frontend/src/views/App.js @@ -2,7 +2,7 @@ import React, {Component} from 'react'; import './App.scss'; import Header from "./Header"; import MainPane from "../components/panels/MainPane"; -import Footer from "./Footer"; +import Timeline from "./Timeline"; import {BrowserRouter as Router} from "react-router-dom"; import Filters from "./Filters"; import ConfigurationPane from "../components/panels/ConfigurationPane"; @@ -52,7 +52,7 @@ class App extends Component { {modal} </div> <div className="main-footer"> - {this.state.configured && <Footer/>} + {this.state.configured && <Timeline/>} </div> </Router> } diff --git a/frontend/src/views/Connections.js b/frontend/src/views/Connections.js index bd631a2..e835dcb 100644 --- a/frontend/src/views/Connections.js +++ b/frontend/src/views/Connections.js @@ -17,7 +17,6 @@ class Connections extends Component { connections: [], firstConnection: null, lastConnection: null, - queryString: null }; constructor(props) { @@ -29,14 +28,15 @@ class Connections extends Component { this.queryLimit = 50; this.connectionsListRef = React.createRef(); this.lastScrollPosition = 0; + this.doQueryStringRedirect = false; + this.doSelectedConnectionRedirect = false; } componentDidMount() { this.loadConnections({limit: this.queryLimit}) .then(() => this.setState({loaded: true})); - if (this.props.initialConnection != null) { + if (this.props.initialConnection) { this.setState({selected: this.props.initialConnection.id}); - // TODO: scroll to initial connection } dispatcher.register("timeline_updates", payload => { @@ -62,19 +62,24 @@ class Connections extends Component { } connectionSelected = (c) => { + this.doSelectedConnectionRedirect = true; this.setState({selected: c.id}); this.props.onSelected(c); }; componentDidUpdate(prevProps, prevState, snapshot) { if (this.state.loaded && prevProps.location.search !== this.props.location.search) { - this.setState({queryString: this.props.location.search}); this.loadConnections({limit: this.queryLimit}) .then(() => log.info("Connections reloaded after query string update")); } } handleScroll = (e) => { + if (this.disableScrollHandler) { + this.lastScrollPosition = e.currentTarget.scrollTop; + return; + } + let relativeScroll = e.currentTarget.scrollTop / (e.currentTarget.scrollHeight - e.currentTarget.clientHeight); if (!this.state.loading && relativeScroll > this.scrollBottomThreashold) { this.loadConnections({from: this.state.lastConnection.id, limit: this.queryLimit,}) @@ -103,7 +108,8 @@ class Connections extends Component { addServicePortFilter = (port) => { const urlParams = new URLSearchParams(this.props.location.search); urlParams.set("service_port", port); - this.setState({queryString: "?" + urlParams}); + this.doQueryStringRedirect = true; + this.setState({queryString: urlParams}); }; addMatchedRulesFilter = (matchedRule) => { @@ -112,7 +118,8 @@ class Connections extends Component { if (!oldMatchedRules.includes(matchedRule)) { urlParams.append("matched_rules", matchedRule); - this.setState({queryString: "?" + urlParams}); + this.doQueryStringRedirect = true; + this.setState({queryString: urlParams}); } }; @@ -139,7 +146,7 @@ class Connections extends Component { if (params !== undefined && params.from !== undefined && params.to === undefined) { if (res.length > 0) { - connections = this.state.connections.concat(res); + connections = this.state.connections.concat(res.slice(1)); lastConnection = connections[connections.length - 1]; if (connections.length > this.maxConnections) { connections = connections.slice(connections.length - this.maxConnections, @@ -149,7 +156,7 @@ class Connections extends Component { } } else if (params !== undefined && params.to !== undefined && params.from === undefined) { if (res.length > 0) { - connections = res.concat(this.state.connections); + connections = res.slice(0, res.length - 1).concat(this.state.connections); firstConnection = connections[0]; if (connections.length > this.maxConnections) { connections = connections.slice(0, this.maxConnections); @@ -157,7 +164,6 @@ class Connections extends Component { } } } else { - this.connectionsListRef.current.scrollTop = 0; if (res.length > 0) { connections = res; firstConnection = connections[0]; @@ -194,9 +200,12 @@ class Connections extends Component { render() { let redirect; - let queryString = this.state.queryString !== null ? this.state.queryString : ""; - if (this.state.selected) { - redirect = <Redirect push to={`/connections/${this.state.selected}${queryString}`}/>; + if (this.doSelectedConnectionRedirect) { + redirect = <Redirect push to={`/connections/${this.state.selected}${this.props.location.search}`}/>; + this.doSelectedConnectionRedirect = false; + } else if (this.doQueryStringRedirect) { + redirect = <Redirect push to={`${this.props.location.pathname}?${this.state.queryString}`}/>; + this.doQueryStringRedirect = false; } let loading = null; @@ -209,10 +218,15 @@ class Connections extends Component { return ( <div className="connections-container"> {this.state.showMoreRecentButton && <div className="most-recent-button"> - <ButtonField name="most_recent" variant="green" onClick={() => + <ButtonField name="most_recent" variant="green" onClick={() => { + this.disableScrollHandler = true; + this.connectionsListRef.current.scrollTop = 0; this.loadConnections({limit: this.queryLimit}) - .then(() => log.info("Most recent connections loaded")) - }/> + .then(() => { + this.disableScrollHandler = false; + log.info("Most recent connections loaded"); + }); + }}/> </div>} <div className="connections" onScroll={this.handleScroll} ref={this.connectionsListRef}> diff --git a/frontend/src/views/Footer.js b/frontend/src/views/Footer.js deleted file mode 100644 index dcf9cf8..0000000 --- a/frontend/src/views/Footer.js +++ /dev/null @@ -1,176 +0,0 @@ -import React, {Component} from 'react'; -import './Footer.scss'; -import { - ChartContainer, - ChartRow, - Charts, - LineChart, - MultiBrush, - Resizable, - styler, - YAxis -} from "react-timeseries-charts"; -import {TimeRange, TimeSeries} from "pondjs"; -import backend from "../backend"; -import ChoiceField from "../components/fields/ChoiceField"; -import {withRouter} from "react-router-dom"; -import log from "../log"; -import dispatcher from "../dispatcher"; - - -class Footer extends Component { - - state = { - metric: "connections_per_service" - }; - - constructor() { - super(); - - this.disableTimeSeriesChanges = false; - this.selectionTimeout = null; - } - - filteredPort = () => { - const urlParams = new URLSearchParams(this.props.location.search); - return urlParams.get("service_port"); - }; - - componentDidMount() { - const filteredPort = this.filteredPort(); - this.setState({filteredPort}); - this.loadStatistics(this.state.metric, filteredPort).then(() => log.debug("Statistics loaded after mount")); - - dispatcher.register("connection_updates", payload => { - this.setState({ - selection: new TimeRange(payload.from, payload.to), - }); - }); - } - - componentDidUpdate(prevProps, prevState, snapshot) { - const filteredPort = this.filteredPort(); - if (this.state.filteredPort !== filteredPort) { - this.setState({filteredPort}); - this.loadStatistics(this.state.metric, filteredPort).then(() => - log.debug("Statistics reloaded after filtered port changes")); - } - } - - loadStatistics = async (metric, filteredPort) => { - const urlParams = new URLSearchParams(); - urlParams.set("metric", metric); - - let services = (await backend.get("/api/services")).json; - if (filteredPort && services[filteredPort]) { - const service = services[filteredPort]; - services = {}; - services[filteredPort] = service; - } - - const ports = Object.keys(services); - ports.forEach(s => urlParams.append("ports", s)); - - const metrics = (await backend.get("/api/statistics?" + urlParams)).json; - const series = new TimeSeries({ - name: "statistics", - columns: ["time"].concat(ports), - points: metrics.map(m => [new Date(m["range_start"])].concat(ports.map(p => m[metric][p] || 0))) - }); - this.setState({ - metric, - series, - services, - timeRange: series.range(), - }); - log.debug(`Loaded statistics for metric "${metric}" for services [${ports}]`); - }; - - createStyler = () => { - return styler(Object.keys(this.state.services).map(port => { - return {key: port, color: this.state.services[port].color, width: 2}; - })); - }; - - handleTimeRangeChange = (timeRange) => { - if (!this.disableTimeSeriesChanges) { - this.setState({timeRange}); - } - }; - - handleSelectionChange = (timeRange) => { - this.disableTimeSeriesChanges = true; - - this.setState({selection: timeRange}); - if (this.selectionTimeout) { - clearTimeout(this.selectionTimeout); - } - this.selectionTimeout = setTimeout(() => { - dispatcher.dispatch("timeline_updates", { - from: timeRange.begin(), - to: timeRange.end() - }); - this.selectionTimeout = null; - this.disableTimeSeriesChanges = false; - }, 1000); - }; - - aggregateSeries = (func) => { - const values = this.state.series.columns().map(c => this.state.series[func](c)); - return Math[func](...values); - }; - - render() { - return ( - <footer className="footer"> - <div className="time-line"> - {this.state.series && - <> - <Resizable> - <ChartContainer timeRange={this.state.timeRange} enableDragZoom={false} - paddingTop={5} utc minDuration={60000} - maxTime={this.state.series.range().end()} - minTime={this.state.series.range().begin()} - paddingLeft={0} paddingRight={0} paddingBottom={0} - enablePanZoom={true} - onTimeRangeChanged={this.handleTimeRangeChange}> - - <ChartRow height="125"> - <YAxis id="axis1" hideAxisLine - min={this.aggregateSeries("min")} - max={this.aggregateSeries("max")} width="35" type="linear" transition={300}/> - <Charts> - <LineChart axis="axis1" series={this.state.series} - columns={Object.keys(this.state.services)} - style={this.createStyler()} interpolation="curveBasis"/> - - <MultiBrush - timeRanges={[this.state.selection]} - allowSelectionClear={false} - allowFreeDrawing={false} - onTimeRangeChanged={this.handleSelectionChange} - /> - </Charts> - </ChartRow> - </ChartContainer> - </Resizable> - - <div className="metric-selection"> - <ChoiceField inline small - keys={["connections_per_service", "client_bytes_per_service", - "server_bytes_per_service", "duration_per_service"]} - values={["connections_per_service", "client_bytes_per_service", - "server_bytes_per_service", "duration_per_service"]} - onChange={(metric) => this.loadStatistics(metric, this.state.filteredPort) - .then(() => log.debug("Statistics loaded after metric changes"))} - value={this.state.metric}/> - </div> - </> - } - </div> - </footer> - ); - } -} - -export default withRouter(Footer); diff --git a/frontend/src/views/Header.js b/frontend/src/views/Header.js index 944f1d5..f5eff17 100644 --- a/frontend/src/views/Header.js +++ b/frontend/src/views/Header.js @@ -2,7 +2,7 @@ import React, {Component} from 'react'; import Typed from 'typed.js'; import './Header.scss'; import {filtersDefinitions, filtersNames} from "../components/filters/FiltersDefinitions"; -import {Link} from "react-router-dom"; +import {Link, withRouter} from "react-router-dom"; import ButtonField from "../components/fields/ButtonField"; class Header extends Component { @@ -46,7 +46,7 @@ class Header extends Component { render() { let quickFilters = filtersNames.filter(name => this.state[`${name}_active`]) - .map(name => <React.Fragment key={name} >{filtersDefinitions[name]}</React.Fragment>) + .map(name => <React.Fragment key={name}>{filtersDefinitions[name]}</React.Fragment>) .slice(0, 5); return ( @@ -68,18 +68,18 @@ class Header extends Component { <div className="col"> <div className="header-buttons"> - <ButtonField variant="pink" onClick={this.props.onOpenFilters} name="filters" bordered /> - <Link to="/pcaps"> - <ButtonField variant="purple" name="pcaps" bordered /> + <ButtonField variant="pink" onClick={this.props.onOpenFilters} name="filters" bordered/> + <Link to={"/pcaps" + this.props.location.search}> + <ButtonField variant="purple" name="pcaps" bordered/> </Link> - <Link to="/rules"> - <ButtonField variant="deep-purple" name="rules" bordered /> + <Link to={"/rules" + this.props.location.search}> + <ButtonField variant="deep-purple" name="rules" bordered/> </Link> - <Link to="/services"> - <ButtonField variant="indigo" name="services" bordered /> + <Link to={"/services" + this.props.location.search}> + <ButtonField variant="indigo" name="services" bordered/> </Link> - <Link to="/config"> - <ButtonField variant="blue" name="config" bordered /> + <Link to={"/config" + this.props.location.search}> + <ButtonField variant="blue" name="config" bordered/> </Link> </div> </div> @@ -89,4 +89,4 @@ class Header extends Component { } } -export default Header; +export default withRouter(Header); diff --git a/frontend/src/views/Timeline.js b/frontend/src/views/Timeline.js new file mode 100644 index 0000000..3adbf88 --- /dev/null +++ b/frontend/src/views/Timeline.js @@ -0,0 +1,215 @@ +import React, {Component} from 'react'; +import './Timeline.scss'; +import { + ChartContainer, + ChartRow, + Charts, + LineChart, + MultiBrush, + Resizable, + styler, + YAxis +} from "react-timeseries-charts"; +import {TimeRange, TimeSeries} from "pondjs"; +import backend from "../backend"; +import ChoiceField from "../components/fields/ChoiceField"; +import {withRouter} from "react-router-dom"; +import log from "../log"; +import dispatcher from "../dispatcher"; + +const minutes = 60 * 1000; + +class Timeline extends Component { + + state = { + metric: "connections_per_service" + }; + + constructor() { + super(); + + this.disableTimeSeriesChanges = false; + this.selectionTimeout = null; + } + + filteredPort = () => { + const urlParams = new URLSearchParams(this.props.location.search); + return urlParams.get("service_port"); + }; + + componentDidMount() { + const filteredPort = this.filteredPort(); + this.setState({filteredPort}); + this.loadStatistics(this.state.metric, filteredPort).then(() => log.debug("Statistics loaded after mount")); + + dispatcher.register("connection_updates", payload => { + this.setState({ + selection: new TimeRange(payload.from, payload.to), + }); + }); + + dispatcher.register("notifications", payload => { + if (payload.event === "services.edit") { + this.loadServices().then(() => log.debug("Services reloaded after notification update")); + } + }); + } + + componentDidUpdate(prevProps, prevState, snapshot) { + const filteredPort = this.filteredPort(); + if (this.state.filteredPort !== filteredPort) { + this.setState({filteredPort}); + this.loadStatistics(this.state.metric, filteredPort).then(() => + log.debug("Statistics reloaded after filtered port changes")); + } + } + + loadStatistics = async (metric, filteredPort) => { + const urlParams = new URLSearchParams(); + urlParams.set("metric", metric); + + let services = await this.loadServices(); + if (filteredPort && services[filteredPort]) { + const service = services[filteredPort]; + services = {}; + services[filteredPort] = service; + } + + const ports = Object.keys(services); + ports.forEach(s => urlParams.append("ports", s)); + + const metrics = (await backend.get("/api/statistics?" + urlParams)).json; + const zeroFilledMetrics = []; + const toTime = m => new Date(m["range_start"]).getTime(); + + if (metrics.length > 0) { + let i = 0; + for (let interval = toTime(metrics[0]); interval <= toTime(metrics[metrics.length - 1]); interval += minutes) { + if (interval === toTime(metrics[i])) { + const m = metrics[i++]; + m["range_start"] = new Date(m["range_start"]); + zeroFilledMetrics.push(m); + } else { + const m = {}; + m["range_start"] = new Date(interval); + m[metric] = {}; + ports.forEach(p => m[metric][p] = 0); + zeroFilledMetrics.push(m); + } + } + } + + const series = new TimeSeries({ + name: "statistics", + columns: ["time"].concat(ports), + points: zeroFilledMetrics.map(m => [m["range_start"]].concat(ports.map(p => m[metric][p] || 0))) + }); + const start = series.range().begin(); + const end = series.range().end(); + start.setTime(start.getTime() - minutes); + end.setTime(end.getTime() + minutes); + + this.setState({ + metric, + series, + timeRange: new TimeRange(start, end), + start, + end + }); + log.debug(`Loaded statistics for metric "${metric}" for services [${ports}]`); + }; + + loadServices = async () => { + const services = (await backend.get("/api/services")).json; + this.setState({services}); + return services; + }; + + createStyler = () => { + return styler(Object.keys(this.state.services).map(port => { + return {key: port, color: this.state.services[port].color, width: 2}; + })); + }; + + handleTimeRangeChange = (timeRange) => { + if (!this.disableTimeSeriesChanges) { + this.setState({timeRange}); + } + }; + + handleSelectionChange = (timeRange) => { + this.disableTimeSeriesChanges = true; + + this.setState({selection: timeRange}); + if (this.selectionTimeout) { + clearTimeout(this.selectionTimeout); + } + this.selectionTimeout = setTimeout(() => { + dispatcher.dispatch("timeline_updates", { + from: timeRange.begin(), + to: timeRange.end() + }); + this.selectionTimeout = null; + this.disableTimeSeriesChanges = false; + }, 1000); + }; + + aggregateSeries = (func) => { + const values = this.state.series.columns().map(c => this.state.series[func](c)); + return Math[func](...values); + }; + + render() { + if (!this.state.series) { + return null; + } + + return ( + <footer className="footer"> + <div className="time-line"> + <Resizable> + <ChartContainer timeRange={this.state.timeRange} enableDragZoom={false} + paddingTop={5} minDuration={60000} + maxTime={this.state.end} + minTime={this.state.start} + paddingLeft={0} paddingRight={0} paddingBottom={0} + enablePanZoom={true} utc={false} + onTimeRangeChanged={this.handleTimeRangeChange}> + + <ChartRow height="125"> + <YAxis id="axis1" hideAxisLine + min={this.aggregateSeries("min")} + max={this.aggregateSeries("max")} width="35" type="linear" transition={300}/> + <Charts> + <LineChart axis="axis1" series={this.state.series} + columns={Object.keys(this.state.services)} + style={this.createStyler()} interpolation="curveBasis"/> + + <MultiBrush + timeRanges={[this.state.selection]} + allowSelectionClear={false} + allowFreeDrawing={false} + onTimeRangeChanged={this.handleSelectionChange} + /> + </Charts> + </ChartRow> + </ChartContainer> + </Resizable> + + <div className="metric-selection"> + <ChoiceField inline small + keys={["connections_per_service", "client_bytes_per_service", + "server_bytes_per_service", "duration_per_service"]} + values={["connections_per_service", "client_bytes_per_service", + "server_bytes_per_service", "duration_per_service"]} + onChange={(metric) => this.loadStatistics(metric, this.state.filteredPort) + .then(() => log.debug("Statistics loaded after metric changes"))} + value={this.state.metric}/> + </div> + </div> + </footer> + ); + } +} + +export default withRouter(Timeline); diff --git a/frontend/src/views/Footer.scss b/frontend/src/views/Timeline.scss index 14360d4..14360d4 100644 --- a/frontend/src/views/Footer.scss +++ b/frontend/src/views/Timeline.scss |