diff options
Diffstat (limited to 'frontend/src/views')
-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 |
6 files changed, 258 insertions, 205 deletions
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 |