aboutsummaryrefslogtreecommitdiff
path: root/frontend/src
diff options
context:
space:
mode:
authorEmiliano Ciavatta2020-10-05 21:46:18 +0000
committerEmiliano Ciavatta2020-10-05 21:46:18 +0000
commite905618113309eaba7227ff1328a20f6846e4afd (patch)
treef6dd471683ac8ed7e630ce84956508ead28eab83 /frontend/src
parentf11e5d9e55c963109af8b8517c7790bf2eb7cac8 (diff)
Implement timeline
Diffstat (limited to 'frontend/src')
-rw-r--r--frontend/src/components/filters/FiltersDefinitions.js86
-rw-r--r--frontend/src/components/panels/MainPane.js34
-rw-r--r--frontend/src/globals.js5
-rw-r--r--frontend/src/log.js7
-rw-r--r--frontend/src/views/App.js18
-rw-r--r--frontend/src/views/Connections.js158
-rw-r--r--frontend/src/views/Connections.scss52
-rw-r--r--frontend/src/views/Footer.js173
-rw-r--r--frontend/src/views/Footer.scss17
9 files changed, 385 insertions, 165 deletions
diff --git a/frontend/src/components/filters/FiltersDefinitions.js b/frontend/src/components/filters/FiltersDefinitions.js
index 02ccb42..d4f2912 100644
--- a/frontend/src/components/filters/FiltersDefinitions.js
+++ b/frontend/src/components/filters/FiltersDefinitions.js
@@ -1,12 +1,4 @@
-import {
- cleanNumber,
- timestampToTime,
- timeToTimestamp,
- validate24HourTime,
- validateIpAddress,
- validateMin,
- validatePort
-} from "../../utils";
+import {cleanNumber, validateIpAddress, validateMin, validatePort} from "../../utils";
import StringConnectionsFilter from "./StringConnectionsFilter";
import React from "react";
import RulesConnectionsFilter from "./RulesConnectionsFilter";
@@ -22,69 +14,69 @@ export const filtersDefinitions = {
replaceFunc={cleanNumber}
validateFunc={validatePort}
key="service_port_filter"
- width={200} />,
- matched_rules: <RulesConnectionsFilter />,
+ width={200}/>,
+ matched_rules: <RulesConnectionsFilter/>,
client_address: <StringConnectionsFilter filterName="client_address"
defaultFilterValue="all_addresses"
validateFunc={validateIpAddress}
key="client_address_filter"
- width={320} />,
+ width={320}/>,
client_port: <StringConnectionsFilter filterName="client_port"
defaultFilterValue="all_ports"
replaceFunc={cleanNumber}
validateFunc={validatePort}
key="client_port_filter"
- width={200} />,
+ width={200}/>,
min_duration: <StringConnectionsFilter filterName="min_duration"
defaultFilterValue="0"
replaceFunc={cleanNumber}
validateFunc={validateMin(0)}
key="min_duration_filter"
- width={200} />,
+ width={200}/>,
max_duration: <StringConnectionsFilter filterName="max_duration"
defaultFilterValue="∞"
replaceFunc={cleanNumber}
key="max_duration_filter"
- width={200} />,
+ width={200}/>,
min_bytes: <StringConnectionsFilter filterName="min_bytes"
defaultFilterValue="0"
replaceFunc={cleanNumber}
validateFunc={validateMin(0)}
key="min_bytes_filter"
- width={200} />,
+ width={200}/>,
max_bytes: <StringConnectionsFilter filterName="max_bytes"
defaultFilterValue="∞"
replaceFunc={cleanNumber}
key="max_bytes_filter"
- width={200} />,
- started_after: <StringConnectionsFilter filterName="started_after"
- defaultFilterValue="00:00:00"
- validateFunc={validate24HourTime}
- encodeFunc={timeToTimestamp}
- decodeFunc={timestampToTime}
- key="started_after_filter"
- width={200} />,
- started_before: <StringConnectionsFilter filterName="started_before"
- defaultFilterValue="00:00:00"
- validateFunc={validate24HourTime}
- encodeFunc={timeToTimestamp}
- decodeFunc={timestampToTime}
- key="started_before_filter"
- width={200} />,
- closed_after: <StringConnectionsFilter filterName="closed_after"
- defaultFilterValue="00:00:00"
- validateFunc={validate24HourTime}
- encodeFunc={timeToTimestamp}
- decodeFunc={timestampToTime}
- key="closed_after_filter"
- width={200} />,
- closed_before: <StringConnectionsFilter filterName="closed_before"
- defaultFilterValue="00:00:00"
- validateFunc={validate24HourTime}
- encodeFunc={timeToTimestamp}
- decodeFunc={timestampToTime}
- key="closed_before_filter"
- width={200} />,
- marked: <BooleanConnectionsFilter filterName={"marked"} />,
- hidden: <BooleanConnectionsFilter filterName={"hidden"} />
+ width={200}/>,
+ // started_after: <StringConnectionsFilter filterName="started_after"
+ // defaultFilterValue="00:00:00"
+ // validateFunc={validate24HourTime}
+ // encodeFunc={timeToTimestamp}
+ // decodeFunc={timestampToTime}
+ // key="started_after_filter"
+ // width={230} />,
+ // started_before: <StringConnectionsFilter filterName="started_before"
+ // defaultFilterValue="00:00:00"
+ // validateFunc={validate24HourTime}
+ // encodeFunc={timeToTimestamp}
+ // decodeFunc={timestampToTime}
+ // key="started_before_filter"
+ // width={230} />,
+ // closed_after: <StringConnectionsFilter filterName="closed_after"
+ // defaultFilterValue="00:00:00"
+ // validateFunc={validate24HourTime}
+ // encodeFunc={timeToTimestamp}
+ // decodeFunc={timestampToTime}
+ // key="closed_after_filter"
+ // width={230} />,
+ // closed_before: <StringConnectionsFilter filterName="closed_before"
+ // defaultFilterValue="00:00:00"
+ // validateFunc={validate24HourTime}
+ // encodeFunc={timeToTimestamp}
+ // decodeFunc={timestampToTime}
+ // key="closed_before_filter"
+ // width={230} />,
+ marked: <BooleanConnectionsFilter filterName={"marked"}/>,
+ // hidden: <BooleanConnectionsFilter filterName={"hidden"} />
};
diff --git a/frontend/src/components/panels/MainPane.js b/frontend/src/components/panels/MainPane.js
index 3202d6d..bd25e0c 100644
--- a/frontend/src/components/panels/MainPane.js
+++ b/frontend/src/components/panels/MainPane.js
@@ -8,24 +8,23 @@ import PcapPane from "./PcapPane";
import backend from "../../backend";
import RulePane from "./RulePane";
import ServicePane from "./ServicePane";
+import log from "../../log";
class MainPane extends Component {
- constructor(props) {
- super(props);
- this.state = {
- selectedConnection: null,
- loading: false
- };
- }
+ state = {};
componentDidMount() {
const match = this.props.location.pathname.match(/^\/connections\/([a-f0-9]{24})$/);
if (match != null) {
- this.setState({loading: true});
+ this.loading = true;
backend.get(`/api/connections/${match[1]}`)
- .then(res => this.setState({selectedConnection: res.json, loading: false}))
- .catch(error => console.log(error));
+ .then(res => {
+ this.loading = false;
+ this.setState({selectedConnection: res.json});
+ log.debug(`Initial connection ${match[1]} loaded`);
+ })
+ .catch(error => log.error("Error loading initial connection", error));
}
}
@@ -34,18 +33,19 @@ class MainPane extends Component {
<div className="main-pane">
<div className="pane connections-pane">
{
- !this.state.loading &&
+ !this.loading &&
<Connections onSelected={(c) => this.setState({selectedConnection: c})}
- initialConnection={this.state.selectedConnection} />
+ initialConnection={this.state.selectedConnection}/>
}
</div>
<div className="pane details-pane">
<Switch>
- <Route path="/pcaps" children={<PcapPane />} />
- <Route path="/rules" children={<RulePane />} />
- <Route path="/services" children={<ServicePane />} />
- <Route exact path="/connections/:id" children={<ConnectionContent connection={this.state.selectedConnection} />} />
- <Route children={<ConnectionContent />} />
+ <Route path="/pcaps" children={<PcapPane/>}/>
+ <Route path="/rules" children={<RulePane/>}/>
+ <Route path="/services" children={<ServicePane/>}/>
+ <Route exact path="/connections/:id"
+ children={<ConnectionContent connection={this.state.selectedConnection}/>}/>
+ <Route children={<ConnectionContent/>}/>
</Switch>
</div>
</div>
diff --git a/frontend/src/globals.js b/frontend/src/globals.js
new file mode 100644
index 0000000..cd4dc64
--- /dev/null
+++ b/frontend/src/globals.js
@@ -0,0 +1,5 @@
+import {Dispatcher} from "flux";
+
+const dispatcher = new Dispatcher();
+
+export default dispatcher;
diff --git a/frontend/src/log.js b/frontend/src/log.js
new file mode 100644
index 0000000..0883962
--- /dev/null
+++ b/frontend/src/log.js
@@ -0,0 +1,7 @@
+const log = {
+ debug: (...obj) => console.info(...obj),
+ info: (...obj) => console.info(...obj),
+ error: (...obj) => console.error(obj)
+};
+
+export default log;
diff --git a/frontend/src/views/App.js b/frontend/src/views/App.js
index abd5209..00d9110 100644
--- a/frontend/src/views/App.js
+++ b/frontend/src/views/App.js
@@ -7,17 +7,17 @@ import {BrowserRouter as Router} from "react-router-dom";
import Filters from "./Filters";
import backend from "../backend";
import ConfigurationPane from "../components/panels/ConfigurationPane";
+import log from "../log";
class App extends Component {
- constructor(props) {
- super(props);
-
- this.state = {};
- }
+ state = {};
componentDidMount() {
- backend.get("/api/services").then(_ => this.setState({configured: true}));
+ backend.get("/api/services").then(_ => {
+ log.debug("Caronte is already configured. Loading main..");
+ this.setState({configured: true});
+ });
setInterval(() => {
if (document.title.endsWith("❚")) {
@@ -38,11 +38,11 @@ class App extends Component {
<div className="main">
<Router>
<div className="main-header">
- <Header onOpenFilters={() => this.setState({filterWindowOpen: true})} />
+ <Header onOpenFilters={() => this.setState({filterWindowOpen: true})}/>
</div>
<div className="main-content">
- {this.state.configured ? <MainPane /> :
- <ConfigurationPane onConfigured={() => this.setState({configured: true})} />}
+ {this.state.configured ? <MainPane/> :
+ <ConfigurationPane onConfigured={() => this.setState({configured: true})}/>}
{modal}
</div>
<div className="main-footer">
diff --git a/frontend/src/views/Connections.js b/frontend/src/views/Connections.js
index 73979c4..fe655b3 100644
--- a/frontend/src/views/Connections.js
+++ b/frontend/src/views/Connections.js
@@ -6,26 +6,31 @@ import {Redirect} from 'react-router';
import {withRouter} from "react-router-dom";
import backend from "../backend";
import ConnectionMatchedRules from "../components/ConnectionMatchedRules";
+import dispatcher from "../globals";
+import log from "../log";
+import ButtonField from "../components/fields/ButtonField";
class Connections extends Component {
+ state = {
+ loading: false,
+ connections: [],
+ firstConnection: null,
+ lastConnection: null,
+ flagRule: null,
+ rules: null,
+ queryString: null
+ };
+
constructor(props) {
super(props);
- this.state = {
- loading: false,
- connections: [],
- firstConnection: null,
- lastConnection: null,
- prevParams: null,
- flagRule: null,
- rules: null,
- queryString: null
- };
this.scrollTopThreashold = 0.00001;
this.scrollBottomThreashold = 0.99999;
- this.maxConnections = 500;
+ this.maxConnections = 200;
this.queryLimit = 50;
+ this.connectionsListRef = React.createRef();
+ this.lastScrollPosition = 0;
}
componentDidMount() {
@@ -35,6 +40,17 @@ class Connections extends Component {
this.setState({selected: this.props.initialConnection.id});
// TODO: scroll to initial connection
}
+
+ dispatcher.register((payload) => {
+ if (payload.actionType === "timeline-update") {
+ this.connectionsListRef.current.scrollTop = 0;
+ this.loadConnections({
+ started_after: Math.round(payload.from.getTime() / 1000),
+ started_before: Math.round(payload.to.getTime() / 1000),
+ limit: this.maxConnections
+ }).then(() => log.info(`Loading connections between ${payload.from} and ${payload.to}`));
+ }
+ });
}
connectionSelected = (c) => {
@@ -46,7 +62,7 @@ class Connections extends Component {
if (this.state.loaded && prevProps.location.search !== this.props.location.search) {
this.setState({queryString: this.props.location.search});
this.loadConnections({limit: this.queryLimit})
- .then(() => console.log("Connections reloaded after query string update"));
+ .then(() => log.info("Connections reloaded after query string update"));
}
}
@@ -54,12 +70,26 @@ class Connections extends Component {
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,})
- .then(() => console.log("Following connections loaded"));
+ .then(() => log.info("Following connections loaded"));
}
if (!this.state.loading && relativeScroll < this.scrollTopThreashold) {
this.loadConnections({to: this.state.firstConnection.id, limit: this.queryLimit,})
- .then(() => console.log("Previous connections loaded"));
+ .then(() => log.info("Previous connections loaded"));
+ if (this.state.showMoreRecentButton) {
+ this.setState({showMoreRecentButton: false});
+ }
+ } else {
+ if (this.lastScrollPosition > e.currentTarget.scrollTop) {
+ if (!this.state.showMoreRecentButton) {
+ this.setState({showMoreRecentButton: true});
+ }
+ } else {
+ if (this.state.showMoreRecentButton) {
+ this.setState({showMoreRecentButton: false});
+ }
+ }
}
+ this.lastScrollPosition = e.currentTarget.scrollTop;
};
addServicePortFilter = (port) => {
@@ -85,14 +115,14 @@ class Connections extends Component {
urlParams.set(name, value);
}
- this.setState({loading: true, prevParams: params});
+ this.setState({loading: true});
let res = (await backend.get(`${url}?${urlParams}`)).json;
let connections = this.state.connections;
let firstConnection = this.state.firstConnection;
let lastConnection = this.state.lastConnection;
- if (params !== undefined && params.from !== undefined) {
+ if (params !== undefined && params.from !== undefined && params.to === undefined) {
if (res.length > 0) {
connections = this.state.connections.concat(res);
lastConnection = connections[connections.length - 1];
@@ -102,7 +132,7 @@ class Connections extends Component {
firstConnection = connections[0];
}
}
- } else if (params !== undefined && params.to !== undefined) {
+ } else if (params !== undefined && params.to !== undefined && params.from === undefined) {
if (res.length > 0) {
connections = res.concat(this.state.connections);
firstConnection = connections[0];
@@ -112,6 +142,7 @@ class Connections extends Component {
}
}
} else {
+ this.connectionsListRef.current.scrollTop = 0;
if (res.length > 0) {
connections = res;
firstConnection = connections[0];
@@ -124,7 +155,7 @@ class Connections extends Component {
}
let rules = this.state.rules;
- if (rules === null) {
+ if (rules == null) {
rules = (await backend.get("/api/rules")).json;
}
@@ -135,15 +166,21 @@ class Connections extends Component {
firstConnection: firstConnection,
lastConnection: lastConnection
});
+
+ if (firstConnection != null && lastConnection != null) {
+ dispatcher.dispatch({
+ actionType: "connections-update",
+ from: new Date(lastConnection["started_at"]),
+ to: new Date(firstConnection["started_at"])
+ });
+ }
}
render() {
let redirect;
let queryString = this.state.queryString !== null ? this.state.queryString : "";
if (this.state.selected) {
- let format = this.props.match.params.format;
- format = format !== undefined ? "/" + format : "";
- redirect = <Redirect push to={`/connections/${this.state.selected}${format}${queryString}`} />;
+ redirect = <Redirect push to={`/connections/${this.state.selected}${queryString}`}/>;
}
let loading = null;
@@ -154,48 +191,55 @@ class Connections extends Component {
}
return (
- <div className="connections" onScroll={this.handleScroll}>
- <div className="connections-header-padding"/>
- <Table borderless size="sm">
- <thead>
- <tr>
- <th>service</th>
- <th>srcip</th>
- <th>srcport</th>
- <th>dstip</th>
- <th>dstport</th>
- <th>started_at</th>
- <th>duration</th>
- <th>up</th>
- <th>down</th>
- <th>actions</th>
- </tr>
- </thead>
- <tbody>
- {
- this.state.connections.flatMap(c => {
- return [<Connection key={c.id} data={c} onSelected={() => this.connectionSelected(c)}
- selected={this.state.selected === c.id}
- onMarked={marked => c.marked = marked}
- onEnabled={enabled => c.hidden = !enabled}
- addServicePortFilter={this.addServicePortFilter} />,
- c.matched_rules.length > 0 &&
+ <div className="connections-container">
+ {this.state.showMoreRecentButton && <div className="most-recent-button">
+ <ButtonField name="most_recent" variant="green" onClick={() =>
+ this.loadConnections({limit: this.queryLimit})
+ .then(() => log.info("Most recent connections loaded"))
+ }/>
+ </div>}
+
+ <div className="connections" onScroll={this.handleScroll} ref={this.connectionsListRef}>
+ <Table borderless size="sm">
+ <thead>
+ <tr>
+ <th>service</th>
+ <th>srcip</th>
+ <th>srcport</th>
+ <th>dstip</th>
+ <th>dstport</th>
+ <th>started_at</th>
+ <th>duration</th>
+ <th>up</th>
+ <th>down</th>
+ <th>actions</th>
+ </tr>
+ </thead>
+ <tbody>
+ {
+ this.state.connections.flatMap(c => {
+ return [<Connection key={c.id} data={c} onSelected={() => this.connectionSelected(c)}
+ selected={this.state.selected === c.id}
+ onMarked={marked => c.marked = marked}
+ onEnabled={enabled => c.hidden = !enabled}
+ addServicePortFilter={this.addServicePortFilter}/>,
+ c.matched_rules.length > 0 &&
<ConnectionMatchedRules key={c.id + "_m"} matchedRules={c.matched_rules}
rules={this.state.rules}
- addMatchedRulesFilter={this.addMatchedRulesFilter} />
- ];
- })
- }
- {loading}
- </tbody>
- </Table>
-
- {redirect}
+ addMatchedRulesFilter={this.addMatchedRulesFilter}/>
+ ];
+ })
+ }
+ {loading}
+ </tbody>
+ </Table>
+
+ {redirect}
+ </div>
</div>
);
}
}
-
export default withRouter(Connections);
diff --git a/frontend/src/views/Connections.scss b/frontend/src/views/Connections.scss
index c7bd1df..9e2b6ba 100644
--- a/frontend/src/views/Connections.scss
+++ b/frontend/src/views/Connections.scss
@@ -1,37 +1,39 @@
@import "../colors.scss";
-.connections {
+.connections-container {
position: relative;
- overflow: auto;
height: 100%;
- padding: 0 10px;
+ padding: 10px;
background-color: $color-primary-3;
- .table {
- margin-top: 10px;
- }
+ .connections {
+ position: relative;
+ overflow-y: scroll;
+ height: 100%;
- .connections-header-padding {
- position: sticky;
- top: 0;
- right: 0;
- left: 0;
- height: 10px;
- margin-bottom: -10px;
- background-color: $color-primary-3;
- }
+ .table {
+ margin-bottom: 0;
+ }
+
+ th {
+ font-size: 13.5px;
+ position: sticky;
+ top: 0;
+ padding: 5px;
+ border: none;
+ background-color: $color-primary-2;
+ }
- th {
- font-size: 13.5px;
- position: sticky;
- top: 10px;
- padding: 5px;
- border-top: 3px solid $color-primary-3;
- border-bottom: 3px solid $color-primary-3;
- background-color: $color-primary-2;
+ &:hover::-webkit-scrollbar-thumb {
+ background: $color-secondary-2;
+ }
}
- &:hover::-webkit-scrollbar-thumb {
- background: $color-secondary-2;
+ .most-recent-button {
+ position: absolute;
+ z-index: 20;
+ top: 45px;
+ left: calc(50% - 50px);
+ background-color: red;
}
}
diff --git a/frontend/src/views/Footer.js b/frontend/src/views/Footer.js
index 0a3c5a3..ad1e4f7 100644
--- a/frontend/src/views/Footer.js
+++ b/frontend/src/views/Footer.js
@@ -1,19 +1,180 @@
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 dispatcher from "../globals";
+import {withRouter} from "react-router-dom";
+import log from "../log";
+
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((payload) => {
+ if (payload.actionType === "connections-update") {
+ 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({
+ actionType: "timeline-update",
+ 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 container-fluid">
- <div className="row">
- <div className="col-12">
- <div className="footer-timeline">timeline - <a href="https://github.com/eciavatta/caronte/issues/12">to be implemented</a></div>
- </div>
+ <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 Footer;
+export default withRouter(Footer);
diff --git a/frontend/src/views/Footer.scss b/frontend/src/views/Footer.scss
index 6ec4a62..14360d4 100644
--- a/frontend/src/views/Footer.scss
+++ b/frontend/src/views/Footer.scss
@@ -1,13 +1,22 @@
@import "../colors.scss";
.footer {
- padding: 15px 30px;
+ padding: 15px;
- > .row {
+ .time-line {
+ position: relative;
background-color: $color-primary-0;
+
+ .metric-selection {
+ font-size: 0.8em;
+ position: absolute;
+ top: 5px;
+ right: 10px;
+ }
}
- .footer-timeline {
- height: 100px;
+ svg text {
+ font-family: "Fira Code", monospace !important;
+ fill: $color-primary-4 !important;
}
}