aboutsummaryrefslogtreecommitdiff
path: root/frontend
diff options
context:
space:
mode:
authorEmiliano Ciavatta2020-10-08 09:16:38 +0000
committerEmiliano Ciavatta2020-10-08 09:16:38 +0000
commit2b2b8e66e7244592672c283fe7bb5d9a1fd9da99 (patch)
tree9a1d35e7a16246ad22feb64e16018e2ed1b93948 /frontend
parent659833be506e86de277d23f4b48ecce422cfaa5d (diff)
Minor changes
Diffstat (limited to 'frontend')
-rw-r--r--frontend/src/components/Connection.js33
-rw-r--r--frontend/src/components/Connection.scss4
-rw-r--r--frontend/src/components/Notifications.js14
-rw-r--r--frontend/src/components/Notifications.scss1
-rw-r--r--frontend/src/views/App.js4
-rw-r--r--frontend/src/views/Connections.js44
-rw-r--r--frontend/src/views/Footer.js176
-rw-r--r--frontend/src/views/Header.js24
-rw-r--r--frontend/src/views/Timeline.js215
-rw-r--r--frontend/src/views/Timeline.scss (renamed from frontend/src/views/Footer.scss)0
10 files changed, 286 insertions, 229 deletions
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