diff options
author | Emiliano Ciavatta | 2020-10-05 21:46:18 +0000 |
---|---|---|
committer | Emiliano Ciavatta | 2020-10-05 21:46:18 +0000 |
commit | e905618113309eaba7227ff1328a20f6846e4afd (patch) | |
tree | f6dd471683ac8ed7e630ce84956508ead28eab83 | |
parent | f11e5d9e55c963109af8b8517c7790bf2eb7cac8 (diff) |
Implement timeline
-rw-r--r-- | application_context.go | 2 | ||||
-rw-r--r-- | application_router.go | 22 | ||||
-rw-r--r-- | connection_handler.go | 26 | ||||
-rw-r--r-- | connections_controller.go | 52 | ||||
-rw-r--r-- | frontend/package.json | 3 | ||||
-rw-r--r-- | frontend/src/components/filters/FiltersDefinitions.js | 86 | ||||
-rw-r--r-- | frontend/src/components/panels/MainPane.js | 34 | ||||
-rw-r--r-- | frontend/src/globals.js | 5 | ||||
-rw-r--r-- | frontend/src/log.js | 7 | ||||
-rw-r--r-- | frontend/src/views/App.js | 18 | ||||
-rw-r--r-- | frontend/src/views/Connections.js | 158 | ||||
-rw-r--r-- | frontend/src/views/Connections.scss | 52 | ||||
-rw-r--r-- | frontend/src/views/Footer.js | 173 | ||||
-rw-r--r-- | frontend/src/views/Footer.scss | 17 | ||||
-rw-r--r-- | frontend/yarn.lock | 312 | ||||
-rw-r--r-- | statistics_controller.go | 71 | ||||
-rw-r--r-- | storage.go | 31 |
17 files changed, 850 insertions, 219 deletions
diff --git a/application_context.go b/application_context.go index e4be74d..6ea449d 100644 --- a/application_context.go +++ b/application_context.go @@ -20,6 +20,7 @@ type ApplicationContext struct { ConnectionsController ConnectionsController ServicesController *ServicesController ConnectionStreamsController ConnectionStreamsController + StatisticsController StatisticsController IsConfigured bool } @@ -93,5 +94,6 @@ func (sm *ApplicationContext) configure() { sm.ServicesController = NewServicesController(sm.Storage) sm.ConnectionsController = NewConnectionsController(sm.Storage, sm.ServicesController) sm.ConnectionStreamsController = NewConnectionStreamsController(sm.Storage) + sm.StatisticsController = NewStatisticsController(sm.Storage) sm.IsConfigured = true } diff --git a/application_router.go b/application_router.go index 501956b..8b5e32f 100644 --- a/application_router.go +++ b/application_router.go @@ -119,7 +119,7 @@ func CreateApplicationRouter(applicationContext *ApplicationContext) *gin.Engine flushAllValue, isPresent := c.GetPostForm("flush_all") flushAll := isPresent && strings.ToLower(flushAllValue) == "true" fileName := fmt.Sprintf("%v-%s", time.Now().UnixNano(), fileHeader.Filename) - if err := c.SaveUploadedFile(fileHeader, ProcessingPcapsBasePath + fileName); err != nil { + if err := c.SaveUploadedFile(fileHeader, ProcessingPcapsBasePath+fileName); err != nil { log.WithError(err).Panic("failed to save uploaded file") } @@ -147,7 +147,7 @@ func CreateApplicationRouter(applicationContext *ApplicationContext) *gin.Engine } fileName := fmt.Sprintf("%v-%s", time.Now().UnixNano(), filepath.Base(request.File)) - if err := CopyFile(ProcessingPcapsBasePath + fileName, request.File); err != nil { + if err := CopyFile(ProcessingPcapsBasePath+fileName, request.File); err != nil { log.WithError(err).Panic("failed to copy pcap file") } if sessionID, err := applicationContext.PcapImporter.ImportPcap(fileName, request.FlushAll); err != nil { @@ -179,9 +179,9 @@ func CreateApplicationRouter(applicationContext *ApplicationContext) *gin.Engine sessionID := c.Param("id") if _, isPresent := applicationContext.PcapImporter.GetSession(sessionID); isPresent { if FileExists(PcapsBasePath + sessionID + ".pcap") { - c.FileAttachment(PcapsBasePath + sessionID + ".pcap", sessionID[:16] + ".pcap") + c.FileAttachment(PcapsBasePath+sessionID+".pcap", sessionID[:16]+".pcap") } else if FileExists(PcapsBasePath + sessionID + ".pcapng") { - c.FileAttachment(PcapsBasePath + sessionID + ".pcapng", sessionID[:16] + ".pcapng") + c.FileAttachment(PcapsBasePath+sessionID+".pcapng", sessionID[:16]+".pcapng") } else { log.WithField("sessionID", sessionID).Panic("pcap file not exists") } @@ -289,9 +289,17 @@ func CreateApplicationRouter(applicationContext *ApplicationContext) *gin.Engine unprocessableEntity(c, err) } }) - } + api.GET("/statistics", func(c *gin.Context) { + var filter StatisticsFilter + if err := c.ShouldBindQuery(&filter); err != nil { + badRequest(c, err) + return + } + success(c, applicationContext.StatisticsController.GetStatistics(c, filter)) + }) + } return router } @@ -352,3 +360,7 @@ func unprocessableEntity(c *gin.Context, err error) { func notFound(c *gin.Context, obj interface{}) { c.JSON(http.StatusNotFound, obj) } + +func serverError(c *gin.Context, err error) { + c.JSON(http.StatusInternalServerError, UnorderedDocument{"result": "error", "error": err.Error()}) +} diff --git a/connection_handler.go b/connection_handler.go index ffe4fac..3d38531 100644 --- a/connection_handler.go +++ b/connection_handler.go @@ -2,6 +2,7 @@ package main import ( "encoding/binary" + "fmt" "github.com/flier/gohs/hyperscan" "github.com/google/gopacket" "github.com/google/gopacket/tcpassembly" @@ -225,6 +226,31 @@ func (ch *connectionHandlerImpl) Complete(handler *StreamHandler) { log.WithError(err).WithField("connection", connection).Error("failed to update all connections streams") } } + + ch.UpdateStatistics(connection) +} + +func (ch *connectionHandlerImpl) UpdateStatistics(connection Connection) { + rangeStart := connection.StartedAt.Unix() / 60 // group statistic records by minutes + duration := connection.ClosedAt.Sub(connection.StartedAt) + // if one of the two parts doesn't close connection, the duration is +infinity or -infinity + if duration.Hours() > 1 || duration.Hours() < -1 { + duration = 0 + } + servicePort := connection.DestinationPort + + var results interface{} + if _, err := ch.Storage().Update(Statistics).Upsert(&results). + Filter(OrderedDocument{{"_id", time.Unix(rangeStart*60, 0)}}).OneComplex(UnorderedDocument{ + "$inc": UnorderedDocument{ + fmt.Sprintf("connections_per_service.%d", servicePort): 1, + fmt.Sprintf("client_bytes_per_service.%d", servicePort): connection.ClientBytes, + fmt.Sprintf("server_bytes_per_service.%d", servicePort): connection.ServerBytes, + fmt.Sprintf("duration_per_service.%d", servicePort): duration.Milliseconds(), + }, + }); err != nil { + log.WithError(err).WithField("connection", connection).Error("failed to update connection statistics") + } } func (ch *connectionHandlerImpl) Storage() Storage { diff --git a/connections_controller.go b/connections_controller.go index 2773506..2894193 100644 --- a/connections_controller.go +++ b/connections_controller.go @@ -31,40 +31,40 @@ type Connection struct { } type ConnectionsFilter struct { - From string `form:"from" binding:"omitempty,hexadecimal,len=24"` - To string `form:"to" binding:"omitempty,hexadecimal,len=24"` - ServicePort uint16 `form:"service_port"` - ClientAddress string `form:"client_address" binding:"omitempty,ip"` - ClientPort uint16 `form:"client_port"` - MinDuration uint `form:"min_duration"` - MaxDuration uint `form:"max_duration" binding:"omitempty,gtefield=MinDuration"` - MinBytes uint `form:"min_bytes"` - MaxBytes uint `form:"max_bytes" binding:"omitempty,gtefield=MinBytes"` - StartedAfter int64 `form:"started_after" ` - StartedBefore int64 `form:"started_before" binding:"omitempty,gtefield=StartedAfter"` - ClosedAfter int64 `form:"closed_after" ` - ClosedBefore int64 `form:"closed_before" binding:"omitempty,gtefield=ClosedAfter"` - Hidden bool `form:"hidden"` - Marked bool `form:"marked"` + From string `form:"from" binding:"omitempty,hexadecimal,len=24"` + To string `form:"to" binding:"omitempty,hexadecimal,len=24"` + ServicePort uint16 `form:"service_port"` + ClientAddress string `form:"client_address" binding:"omitempty,ip"` + ClientPort uint16 `form:"client_port"` + MinDuration uint `form:"min_duration"` + MaxDuration uint `form:"max_duration" binding:"omitempty,gtefield=MinDuration"` + MinBytes uint `form:"min_bytes"` + MaxBytes uint `form:"max_bytes" binding:"omitempty,gtefield=MinBytes"` + StartedAfter int64 `form:"started_after" ` + StartedBefore int64 `form:"started_before" binding:"omitempty,gtefield=StartedAfter"` + ClosedAfter int64 `form:"closed_after" ` + ClosedBefore int64 `form:"closed_before" binding:"omitempty,gtefield=ClosedAfter"` + Hidden bool `form:"hidden"` + Marked bool `form:"marked"` MatchedRules []string `form:"matched_rules" binding:"dive,hexadecimal,len=24"` - Limit int64 `form:"limit"` + Limit int64 `form:"limit"` } type ConnectionsController struct { - storage Storage + storage Storage servicesController *ServicesController } func NewConnectionsController(storage Storage, servicesController *ServicesController) ConnectionsController { return ConnectionsController{ - storage: storage, + storage: storage, servicesController: servicesController, } } func (cc ConnectionsController) GetConnections(c context.Context, filter ConnectionsFilter) []Connection { var connections []Connection - query := cc.storage.Find(Connections).Context(c).Sort("_id", false) + query := cc.storage.Find(Connections).Context(c) from, _ := RowIDFromHex(filter.From) if !from.IsZero() { @@ -73,6 +73,8 @@ func (cc ConnectionsController) GetConnections(c context.Context, filter Connect to, _ := RowIDFromHex(filter.To) if !to.IsZero() { query = query.Filter(OrderedDocument{{"_id", UnorderedDocument{"$gt": to}}}) + } else { + query = query.Sort("_id", false) } if filter.ServicePort > 0 { query = query.Filter(OrderedDocument{{"port_dst", filter.ServicePort}}) @@ -146,6 +148,10 @@ func (cc ConnectionsController) GetConnections(c context.Context, filter Connect } } + if !to.IsZero() { + connections = reverseConnections(connections) + } + return connections } @@ -178,3 +184,11 @@ func (cc ConnectionsController) setProperty(c context.Context, id RowID, propert } return updated } + +func reverseConnections(connections []Connection) []Connection { + for i := 0; i < len(connections)/2; i++ { + j := len(connections) - i - 1 + connections[i], connections[j] = connections[j], connections[i] + } + return connections +} diff --git a/frontend/package.json b/frontend/package.json index f22fad5..5bc13f1 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -14,8 +14,10 @@ "classnames": "^2.2.6", "dompurify": "^2.1.1", "eslint-config-react-app": "^5.2.1", + "flux": "^3.1.3", "lodash": "^4.17.20", "node-sass": "^4.14.0", + "pondjs": "^0.9.0", "react": "^16.13.1", "react-bootstrap": "^1.0.1", "react-dom": "^16.13.1", @@ -25,6 +27,7 @@ "react-router-dom": "^5.1.2", "react-scripts": "3.4.1", "react-tag-autocomplete": "^6.0.0-beta.6", + "react-timeseries-charts": "^0.16.1", "typed.js": "^2.0.11" }, "scripts": { 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; } } diff --git a/frontend/yarn.lock b/frontend/yarn.lock index 84d6058..fa150ab 100644 --- a/frontend/yarn.lock +++ b/frontend/yarn.lock @@ -909,6 +909,14 @@ "@babel/helper-create-regexp-features-plugin" "^7.10.4" "@babel/helper-plugin-utils" "^7.10.4" +"@babel/polyfill@^7.7.0": + version "7.11.5" + resolved "https://registry.yarnpkg.com/@babel/polyfill/-/polyfill-7.11.5.tgz#df550b2ec53abbc2ed599367ec59e64c7a707bb5" + integrity sha512-FunXnE0Sgpd61pKSj2OSOs1D44rKTD3pGOfGilZ6LGrrIH0QEtJlTjqOqdF8Bs98JmjfGhni2BBkTfv9KcKJ9g== + dependencies: + core-js "^2.6.5" + regenerator-runtime "^0.13.4" + "@babel/preset-env@7.9.0": version "7.9.0" resolved "https://registry.yarnpkg.com/@babel/preset-env/-/preset-env-7.9.0.tgz#a5fc42480e950ae8f5d9f8f2bbc03f52722df3a8" @@ -1725,9 +1733,9 @@ "@types/react" "*" "@types/react@*", "@types/react@^16.9.11", "@types/react@^16.9.35": - version "16.9.49" - resolved "https://registry.yarnpkg.com/@types/react/-/react-16.9.49.tgz#09db021cf8089aba0cdb12a49f8021a69cce4872" - integrity sha512-DtLFjSj0OYAdVLBbyjhuV9CdGVHCkHn2R+xr3XkBvK2rS1Y1tkc14XSGjYgm5Fjjr90AxH9tiSzc1pCFMGO06g== + version "16.9.50" + resolved "https://registry.yarnpkg.com/@types/react/-/react-16.9.50.tgz#cb5f2c22d42de33ca1f5efc6a0959feb784a3a2d" + integrity sha512-kPx5YsNnKDJejTk1P+lqThwxN2PczrocwsvqXnjvVvKpFescoY62ZiM3TV7dH1T8lFhlHZF+PE5xUyimUwqEGA== dependencies: "@types/prop-types" "*" csstype "^3.0.2" @@ -2268,6 +2276,11 @@ array-unique@^0.3.2: resolved "https://registry.yarnpkg.com/array-unique/-/array-unique-0.3.2.tgz#a894b75d4bc4f6cd679ef3244a9fd8f46ae2d428" integrity sha1-qJS3XUvE9s1nnvMkSp/Y9Gri1Cg= +array.prototype.fill@^1.0.1: + version "1.0.2" + resolved "https://registry.yarnpkg.com/array.prototype.fill/-/array.prototype.fill-1.0.2.tgz#ab33207f21d57d1ab2f7f0d1cf122d3419c38ef5" + integrity sha1-qzMgfyHVfRqy9/DRzxItNBnDjvU= + array.prototype.flat@^1.2.1: version "1.2.3" resolved "https://registry.yarnpkg.com/array.prototype.flat/-/array.prototype.flat-1.2.3.tgz#0de82b426b0318dbfdb940089e38b043d37f6c7b" @@ -2535,7 +2548,7 @@ babel-preset-react-app@^9.1.2: babel-plugin-macros "2.8.0" babel-plugin-transform-react-remove-prop-types "0.4.24" -babel-runtime@^6.26.0: +babel-runtime@^6.23.0, babel-runtime@^6.26.0: version "6.26.0" resolved "https://registry.yarnpkg.com/babel-runtime/-/babel-runtime-6.26.0.tgz#965c7058668e82b55d7bfe04ff2337bc8b5647fe" integrity sha1-llxwWGaOgrVde/4E/yM3vItWR/4= @@ -2981,9 +2994,9 @@ caniuse-api@^3.0.0: lodash.uniq "^4.5.0" caniuse-lite@^1.0.0, caniuse-lite@^1.0.30000981, caniuse-lite@^1.0.30001035, caniuse-lite@^1.0.30001109, caniuse-lite@^1.0.30001135: - version "1.0.30001140" - resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001140.tgz#30dae27599f6ede2603a0962c82e468bca894232" - integrity sha512-xFtvBtfGrpjTOxTpjP5F2LmN04/ZGfYV8EQzUIC/RmKpdrmzJrjqlJ4ho7sGuAMPko2/Jl08h7x9uObCfBFaAA== + version "1.0.30001142" + resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001142.tgz#a8518fdb5fee03ad95ac9f32a9a1e5999469c250" + integrity sha512-pDPpn9ankEpBFZXyCv2I4lh1v/ju+bqb78QfKf+w9XgDAFWBwSYPswXqprRdrgQWK0wQnpIbfwRjNHO1HWqvoQ== capture-exit@^2.0.0: version "2.0.0" @@ -3251,6 +3264,11 @@ color@^3.0.0: color-convert "^1.9.1" color-string "^1.5.2" +colorbrewer@^1.0.0: + version "1.3.0" + resolved "https://registry.yarnpkg.com/colorbrewer/-/colorbrewer-1.3.0.tgz#1d7e92a6277e42dc56377911bbd867bdbcb2ff7d" + integrity sha512-AzVPpWa+fuO/qY8LxPQjej6F49Lb2Cl+7U9YhPn6y4/SOY6u/EZiXUc7qHzRb6i6fWPStCUdEaU2731QyQKWjg== + colorette@^1.2.1: version "1.2.1" resolved "https://registry.yarnpkg.com/colorette/-/colorette-1.2.1.tgz#4d0b921325c14faf92633086a536db6e89564b1b" @@ -3429,7 +3447,7 @@ core-js@^1.0.0: resolved "https://registry.yarnpkg.com/core-js/-/core-js-1.2.7.tgz#652294c14651db28fa93bd2d5ff2983a4f08c636" integrity sha1-ZSKUwUZR2yj6k70tX/KYOk8IxjY= -core-js@^2.4.0: +core-js@^2.4.0, core-js@^2.6.5: version "2.6.11" resolved "https://registry.yarnpkg.com/core-js/-/core-js-2.6.11.tgz#38831469f9922bded8ee21c9dc46985e0399308c" integrity sha512-5wjnpaT/3dV+XB4borEsnAYQchn00XSgTAWKDkEqv+K8KevjbzmofK6hfJ9TZIlpj2N0xQpazy7PiRQiWHqzWg== @@ -3641,9 +3659,9 @@ css-what@2.1: integrity sha512-a+EPoD+uZiNfh+5fxw2nO9QwFa6nJe2Or35fGY6Ipw1R3R4AGz1d1TEZrCegvw2YTmZ0jXirGYlzxxpYSHwpEg== css-what@^3.2.1: - version "3.3.0" - resolved "https://registry.yarnpkg.com/css-what/-/css-what-3.3.0.tgz#10fec696a9ece2e591ac772d759aacabac38cd39" - integrity sha512-pv9JPyatiPaQ6pf4OvD/dbfm0o5LviWmwxNWzblYf/1u9QZd0ihV+PMwy5jdQWQ3349kZmKEx9WXuSka2dM4cg== + version "3.4.1" + resolved "https://registry.yarnpkg.com/css-what/-/css-what-3.4.1.tgz#81cb70b609e4b1351b1e54cbc90fd9c54af86e2e" + integrity sha512-wHOppVDKl4vTAOWzJt5Ek37Sgd9qq1Bmj/T1OjvicWbU5W7ru7Pqbn0Jdqii3Drx/h+dixHKXNhZYx7blthL7g== css.escape@^1.5.1: version "1.5.1" @@ -3779,6 +3797,123 @@ cyclist@^1.0.1: resolved "https://registry.yarnpkg.com/cyclist/-/cyclist-1.0.1.tgz#596e9698fd0c80e12038c2b82d6eb1b35b6224d9" integrity sha1-WW6WmP0MgOEgOMK4LW6xs1tiJNk= +d3-array@^1.2.0: + version "1.2.4" + resolved "https://registry.yarnpkg.com/d3-array/-/d3-array-1.2.4.tgz#635ce4d5eea759f6f605863dbcfc30edc737f71f" + integrity sha512-KHW6M86R+FUPYGb3R5XiYjXPq7VzwxZ22buHhAEVG5ztoEcZZMLov530mmccaqA1GghZArjQV46fuc8kUqhhHw== + +d3-axis@^1.0.8: + version "1.0.12" + resolved "https://registry.yarnpkg.com/d3-axis/-/d3-axis-1.0.12.tgz#cdf20ba210cfbb43795af33756886fb3638daac9" + integrity sha512-ejINPfPSNdGFKEOAtnBtdkpr24c4d4jsei6Lg98mxf424ivoDP2956/5HDpIAtmHo85lqT4pruy+zEgvRUBqaQ== + +d3-collection@1: + version "1.0.7" + resolved "https://registry.yarnpkg.com/d3-collection/-/d3-collection-1.0.7.tgz#349bd2aa9977db071091c13144d5e4f16b5b310e" + integrity sha512-ii0/r5f4sjKNTfh84Di+DpztYwqKhEyUlKoPrzUFfeSkWxjW49xU2QzO9qrPrNkpdI0XJkfzvmTu8V2Zylln6A== + +d3-color@1: + version "1.4.1" + resolved "https://registry.yarnpkg.com/d3-color/-/d3-color-1.4.1.tgz#c52002bf8846ada4424d55d97982fef26eb3bc8a" + integrity sha512-p2sTHSLCJI2QKunbGb7ocOh7DgTAn8IrLx21QRc/BSnodXM4sv6aLQlnfpvehFMLZEfBc6g9pH9SWQccFYfJ9Q== + +d3-dispatch@1: + version "1.0.6" + resolved "https://registry.yarnpkg.com/d3-dispatch/-/d3-dispatch-1.0.6.tgz#00d37bcee4dd8cd97729dd893a0ac29caaba5d58" + integrity sha512-fVjoElzjhCEy+Hbn8KygnmMS7Or0a9sI2UzGwoB7cCtvI1XpVN9GpoYlnb3xt2YV66oXYb1fLJ8GMvP4hdU1RA== + +d3-ease@1, d3-ease@^1.0.3: + version "1.0.7" + resolved "https://registry.yarnpkg.com/d3-ease/-/d3-ease-1.0.7.tgz#9a834890ef8b8ae8c558b2fe55bd57f5993b85e2" + integrity sha512-lx14ZPYkhNx0s/2HX5sLFUI3mbasHjSSpwO/KaaNACweVwxUruKyWVcb293wMv1RqTPZyZ8kSZ2NogUZNcLOFQ== + +d3-format@1, d3-format@^1.2.0: + version "1.4.5" + resolved "https://registry.yarnpkg.com/d3-format/-/d3-format-1.4.5.tgz#374f2ba1320e3717eb74a9356c67daee17a7edb4" + integrity sha512-J0piedu6Z8iB6TbIGfZgDzfXxUFN3qQRMofy2oPdXzQibYGqPB/9iMcxr/TGalU+2RsyDO+U4f33id8tbnSRMQ== + +d3-interpolate@1, d3-interpolate@^1.1.5: + version "1.4.0" + resolved "https://registry.yarnpkg.com/d3-interpolate/-/d3-interpolate-1.4.0.tgz#526e79e2d80daa383f9e0c1c1c7dcc0f0583e987" + integrity sha512-V9znK0zc3jOPV4VD2zZn0sDhZU3WAE2bmlxdIwwQPPzPjvyLkd8B3JUVdS1IDUFDkWZ72c9qnv1GK2ZagTZ8EA== + dependencies: + d3-color "1" + +d3-path@1: + version "1.0.9" + resolved "https://registry.yarnpkg.com/d3-path/-/d3-path-1.0.9.tgz#48c050bb1fe8c262493a8caf5524e3e9591701cf" + integrity sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg== + +d3-scale-chromatic@^1.1.1: + version "1.5.0" + resolved "https://registry.yarnpkg.com/d3-scale-chromatic/-/d3-scale-chromatic-1.5.0.tgz#54e333fc78212f439b14641fb55801dd81135a98" + integrity sha512-ACcL46DYImpRFMBcpk9HhtIyC7bTBR4fNOPxwVSl0LfulDAwyiHyPOTqcDG1+t5d4P9W7t/2NAuWu59aKko/cg== + dependencies: + d3-color "1" + d3-interpolate "1" + +d3-scale@^1.0.6: + version "1.0.7" + resolved "https://registry.yarnpkg.com/d3-scale/-/d3-scale-1.0.7.tgz#fa90324b3ea8a776422bd0472afab0b252a0945d" + integrity sha512-KvU92czp2/qse5tUfGms6Kjig0AhHOwkzXG0+PqIJB3ke0WUv088AHMZI0OssO9NCkXt4RP8yju9rpH8aGB7Lw== + dependencies: + d3-array "^1.2.0" + d3-collection "1" + d3-color "1" + d3-format "1" + d3-interpolate "1" + d3-time "1" + d3-time-format "2" + +d3-selection-multi@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/d3-selection-multi/-/d3-selection-multi-1.0.1.tgz#cd6c25413d04a2cb97470e786f2cd877f3e34f58" + integrity sha1-zWwlQT0EosuXRw54byzYd/PjT1g= + dependencies: + d3-selection "1" + d3-transition "1" + +d3-selection@1, d3-selection@^1.1.0: + version "1.4.2" + resolved "https://registry.yarnpkg.com/d3-selection/-/d3-selection-1.4.2.tgz#dcaa49522c0dbf32d6c1858afc26b6094555bc5c" + integrity sha512-SJ0BqYihzOjDnnlfyeHT0e30k0K1+5sR3d5fNueCNeuhZTnGw4M4o8mqJchSwgKMXCNFo+e2VTChiSJ0vYtXkg== + +d3-shape@^1.2.0: + version "1.3.7" + resolved "https://registry.yarnpkg.com/d3-shape/-/d3-shape-1.3.7.tgz#df63801be07bc986bc54f63789b4fe502992b5d7" + integrity sha512-EUkvKjqPFUAZyOlhY5gzCxCeI0Aep04LwIRpsZ/mLFelJiUfnK56jo5JMDSE7yyP2kLSb6LtF+S5chMk7uqPqw== + dependencies: + d3-path "1" + +d3-time-format@2, d3-time-format@^2.0.5: + version "2.3.0" + resolved "https://registry.yarnpkg.com/d3-time-format/-/d3-time-format-2.3.0.tgz#107bdc028667788a8924ba040faf1fbccd5a7850" + integrity sha512-guv6b2H37s2Uq/GefleCDtbe0XZAuy7Wa49VGkPVPMfLL9qObgBST3lEHJBMUp8S7NdLQAGIvr2KXk8Hc98iKQ== + dependencies: + d3-time "1" + +d3-time@1, d3-time@^1.0.7: + version "1.1.0" + resolved "https://registry.yarnpkg.com/d3-time/-/d3-time-1.1.0.tgz#b1e19d307dae9c900b7e5b25ffc5dcc249a8a0f1" + integrity sha512-Xh0isrZ5rPYYdqhAVk8VLnMEidhz5aP7htAADH6MfzgmmicPkTo8LhkLxci61/lCB7n7UmE3bN0leRt+qvkLxA== + +d3-timer@1: + version "1.0.10" + resolved "https://registry.yarnpkg.com/d3-timer/-/d3-timer-1.0.10.tgz#dfe76b8a91748831b13b6d9c793ffbd508dd9de5" + integrity sha512-B1JDm0XDaQC+uvo4DT79H0XmBskgS3l6Ve+1SBCfxgmtIb1AVrPIoqd+nPSv+loMX8szQ0sVUhGngL7D5QPiXw== + +d3-transition@1, d3-transition@^1.1.0: + version "1.3.2" + resolved "https://registry.yarnpkg.com/d3-transition/-/d3-transition-1.3.2.tgz#a98ef2151be8d8600543434c1ca80140ae23b398" + integrity sha512-sc0gRU4PFqZ47lPVHloMn9tlPcv8jxgOQg+0zjhfZXMQuvppjG6YuwdMBE0TuqCZjeJkLecku/l9R0JPcRhaDA== + dependencies: + d3-color "1" + d3-dispatch "1" + d3-ease "1" + d3-interpolate "1" + d3-selection "^1.1.0" + d3-timer "1" + d@1, d@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/d/-/d-1.0.1.tgz#8698095372d58dbee346ffd0c7093f99f8f9eb5a" @@ -4041,6 +4176,11 @@ dom-helpers@^5.0.1, dom-helpers@^5.1.0, dom-helpers@^5.1.2: "@babel/runtime" "^7.8.7" csstype "^3.0.2" +dom-resize@^1.0.3: + version "1.0.3" + resolved "https://registry.yarnpkg.com/dom-resize/-/dom-resize-1.0.3.tgz#df9d71e808171fdb66ee88517b0d1c02cdb98876" + integrity sha512-lohasnGy9LABj1Sq7ZPUGIWSYf+4LFUwL0Aev+dAzxSzUiovc+lKnFyrc6M2TycMIoH7674kwKaucRIPZPgJXw== + dom-serializer@0: version "0.2.2" resolved "https://registry.yarnpkg.com/dom-serializer/-/dom-serializer-0.2.2.tgz#1afb81f533717175d478655debc5e332d9f9bb51" @@ -4049,6 +4189,11 @@ dom-serializer@0: domelementtype "^2.0.1" entities "^2.0.0" +dom-walk@^0.1.0: + version "0.1.2" + resolved "https://registry.yarnpkg.com/dom-walk/-/dom-walk-0.1.2.tgz#0c548bef048f4d1f2a97249002236060daa3fd84" + integrity sha512-6QvTW9mrGeIegrFXdtQi9pk7O/nSK6lSdXW2eqUspN5LWD7UTji2Fqw5V2YLjBpHEoU9Xl/eUWNpDeZvoyOv2w== + domain-browser@^1.1.1: version "1.2.0" resolved "https://registry.yarnpkg.com/domain-browser/-/domain-browser-1.2.0.tgz#3d31f50191a6749dd1375a7f522e823d42e54eda" @@ -4243,23 +4388,23 @@ error-ex@^1.2.0, error-ex@^1.3.1: is-arrayish "^0.2.1" es-abstract@^1.17.0, es-abstract@^1.17.0-next.1, es-abstract@^1.17.2, es-abstract@^1.17.5: - version "1.17.6" - resolved "https://registry.yarnpkg.com/es-abstract/-/es-abstract-1.17.6.tgz#9142071707857b2cacc7b89ecb670316c3e2d52a" - integrity sha512-Fr89bON3WFyUi5EvAeI48QTWX0AyekGgLA8H+c+7fbfCkJwRWRMLd8CQedNEyJuoYYhmtEqY92pgte1FAhBlhw== + version "1.17.7" + resolved "https://registry.yarnpkg.com/es-abstract/-/es-abstract-1.17.7.tgz#a4de61b2f66989fc7421676c1cb9787573ace54c" + integrity sha512-VBl/gnfcJ7OercKA9MVaegWsBHFjV492syMudcnQZvt/Dw8ezpcOHYZXa/J96O8vx+g4x65YKhxOwDUh63aS5g== dependencies: es-to-primitive "^1.2.1" function-bind "^1.1.1" has "^1.0.3" has-symbols "^1.0.1" - is-callable "^1.2.0" - is-regex "^1.1.0" - object-inspect "^1.7.0" + is-callable "^1.2.2" + is-regex "^1.1.1" + object-inspect "^1.8.0" object-keys "^1.1.1" - object.assign "^4.1.0" + object.assign "^4.1.1" string.prototype.trimend "^1.0.1" string.prototype.trimstart "^1.0.1" -es-abstract@^1.18.0-next.0: +es-abstract@^1.18.0-next.0, es-abstract@^1.18.0-next.1: version "1.18.0-next.1" resolved "https://registry.yarnpkg.com/es-abstract/-/es-abstract-1.18.0-next.1.tgz#6e3a0a4bda717e5023ab3b8e90bec36108d22c68" integrity sha512-I4UGspA0wpZXWENrdA0uHbnhte683t3qT/1VFH9aX2dA5PPSf6QW5HHXf5HImaqPmjXaVeVk4RGWnaylmV7uAA== @@ -4758,7 +4903,7 @@ fast-json-stable-stringify@^2.0.0: resolved "https://registry.yarnpkg.com/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz#874bf69c6f404c2b5d99c481341399fd55892633" integrity sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw== -fast-levenshtein@~2.0.6: +fast-levenshtein@^2.0.6, fast-levenshtein@~2.0.6: version "2.0.6" resolved "https://registry.yarnpkg.com/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz#3d8a5c66883a16a30ca8643e851f19baa7797917" integrity sha1-PYpcZog6FqMMqGQ+hR8Zuqd5eRc= @@ -5238,6 +5383,14 @@ global-prefix@^3.0.0: kind-of "^6.0.2" which "^1.3.1" +global@^4.3.0: + version "4.4.0" + resolved "https://registry.yarnpkg.com/global/-/global-4.4.0.tgz#3e7b105179006a323ed71aafca3e9c57a5cc6406" + integrity sha512-wv/LAoHdRE3BeTGz53FAamhGlPLhlssK45usmGFThIi4XqnBmjKQ16u+RNbP7WvigRZDxUsM0J3gcQ5yicaL0w== + dependencies: + min-document "^2.19.0" + process "^0.11.10" + globals@^11.1.0: version "11.12.0" resolved "https://registry.yarnpkg.com/globals/-/globals-11.12.0.tgz#ab8795338868a0babd8525758018c2a7eb95c42e" @@ -5437,6 +5590,11 @@ hmac-drbg@^1.0.0: minimalistic-assert "^1.0.0" minimalistic-crypto-utils "^1.0.1" +hoist-non-react-statics@^2.5.0: + version "2.5.5" + resolved "https://registry.yarnpkg.com/hoist-non-react-statics/-/hoist-non-react-statics-2.5.5.tgz#c5903cf409c0dfd908f388e619d86b9c1174cb47" + integrity sha512-rqcy4pJo55FTTLWt+bU8ukscqHeE/e9KWvsOW2b/a3afxQZhwkQdT1rPPCJ0rYXdj4vNcasY8zHTH+jF/qStxw== + hoist-non-react-statics@^3.1.0: version "3.3.2" resolved "https://registry.yarnpkg.com/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz#ece0acaf71d62c2969c2ec59feff42a4b1a85b45" @@ -5656,6 +5814,16 @@ immer@1.10.0: resolved "https://registry.yarnpkg.com/immer/-/immer-1.10.0.tgz#bad67605ba9c810275d91e1c2a47d4582e98286d" integrity sha512-O3sR1/opvCDGLEVcvrGTMtLac8GJ5IwZC4puPrLuRj3l7ICKvkmA0vGuU9OW8mV9WIBRnaxp5GJh9IEAaNOoYg== +immutable-devtools@0.0.4: + version "0.0.4" + resolved "https://registry.yarnpkg.com/immutable-devtools/-/immutable-devtools-0.0.4.tgz#1e7e87f2c7a4f0533955bc4c2922d124bf9129dd" + integrity sha1-Hn6H8sek8FM5VbxMKSLRJL+RKd0= + +immutable@^3.6.4: + version "3.8.2" + resolved "https://registry.yarnpkg.com/immutable/-/immutable-3.8.2.tgz#c2439951455bb39913daf281376f1530e104adf3" + integrity sha1-wkOZUUVbs5kT2vKBN28VMOEErfM= + import-cwd@^2.0.0: version "2.1.0" resolved "https://registry.yarnpkg.com/import-cwd/-/import-cwd-2.1.0.tgz#aa6cf36e722761285cb371ec6519f53e2435b0a9" @@ -5809,7 +5977,7 @@ internal-slot@^1.0.2: has "^1.0.3" side-channel "^1.0.2" -invariant@^2.2.2, invariant@^2.2.4: +invariant@^2.1.1, invariant@^2.2.2, invariant@^2.2.4: version "2.2.4" resolved "https://registry.yarnpkg.com/invariant/-/invariant-2.2.4.tgz#610f3c92c9359ce1db616e538008d23ff35158e6" integrity sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA== @@ -5894,7 +6062,7 @@ is-buffer@^1.0.2, is-buffer@^1.1.5: resolved "https://registry.yarnpkg.com/is-buffer/-/is-buffer-1.1.6.tgz#efaa2ea9daa0d7ab2ea13a97b2b8ad51fefbe8be" integrity sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w== -is-callable@^1.1.4, is-callable@^1.2.0, is-callable@^1.2.2: +is-callable@^1.1.4, is-callable@^1.2.2: version "1.2.2" resolved "https://registry.yarnpkg.com/is-callable/-/is-callable-1.2.2.tgz#c7c6715cd22d4ddb48d3e19970223aceabb080d9" integrity sha512-dnMqspv5nU3LoewK2N/y7KLtxtakvTuaCsU9FU50/QDmdbHNy/4/JuRtMHqRU22o3q+W89YQndQEeCVwK+3qrA== @@ -6081,7 +6249,7 @@ is-plain-object@^2.0.1, is-plain-object@^2.0.3, is-plain-object@^2.0.4: dependencies: isobject "^3.0.1" -is-regex@^1.0.4, is-regex@^1.1.0, is-regex@^1.1.1: +is-regex@^1.0.4, is-regex@^1.1.1: version "1.1.1" resolved "https://registry.yarnpkg.com/is-regex/-/is-regex-1.1.1.tgz#c6f98aacc546f6cec5468a07b7b153ab564a57b9" integrity sha512-1+QkEcxiLlB7VEyFtyBg94e08OAsvq7FUBgApTq/w2ymCLyKJgDPsybBENVtA7XCQEgEXxKPonG+mvYRxh/LIg== @@ -7232,6 +7400,11 @@ merge2@^1.2.3: resolved "https://registry.yarnpkg.com/merge2/-/merge2-1.4.1.tgz#4368892f885e907455a6fd7dc55c0c9d404990ae" integrity sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg== +merge@^1.2.0: + version "1.2.1" + resolved "https://registry.yarnpkg.com/merge/-/merge-1.2.1.tgz#38bebf80c3220a8a487b6fcfb3941bb11720c145" + integrity sha512-VjFo4P5Whtj4vsLzsYBu5ayHhoHJ0UqNm7ibvShmbmoz7tGi0vXaoJbGdB+GmDMLUdg8DpQXEIeVDAe8MaABvQ== + methods@~1.1.2: version "1.1.2" resolved "https://registry.yarnpkg.com/methods/-/methods-1.1.2.tgz#5529a4d67654134edcc5266656835b0f851afcee" @@ -7301,6 +7474,13 @@ mimic-fn@^2.0.0, mimic-fn@^2.1.0: resolved "https://registry.yarnpkg.com/mimic-fn/-/mimic-fn-2.1.0.tgz#7ed2c2ccccaf84d3ffcb7a69b57711fc2083401b" integrity sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg== +min-document@^2.19.0: + version "2.19.0" + resolved "https://registry.yarnpkg.com/min-document/-/min-document-2.19.0.tgz#7bd282e3f5842ed295bb748cdd9f1ffa2c824685" + integrity sha1-e9KC4/WELtKVu3SM3Z8f+iyCRoU= + dependencies: + dom-walk "^0.1.0" + min-indent@^1.0.0: version "1.0.1" resolved "https://registry.yarnpkg.com/min-indent/-/min-indent-1.0.1.tgz#a63f681673b30571fbe8bc25686ae746eefa9869" @@ -7413,6 +7593,16 @@ mixin-object@^2.0.1: dependencies: minimist "^1.2.5" +moment-duration-format@^1.3.0: + version "1.3.0" + resolved "https://registry.yarnpkg.com/moment-duration-format/-/moment-duration-format-1.3.0.tgz#541771b5f87a049cc65540475d3ad966737d6908" + integrity sha1-VBdxtfh6BJzGVUBHXTrZZnN9aQg= + +moment@^2.18.1, moment@^2.24.0: + version "2.29.0" + resolved "https://registry.yarnpkg.com/moment/-/moment-2.29.0.tgz#fcbef955844d91deb55438613ddcec56e86a3425" + integrity sha512-z6IJ5HXYiuxvFTI6eiQ9dm77uE0gyy1yXNApVHqTcnIKfY9tIwEjlzsZ6u1LQXvVgKeTnv9Xm7NDvJ7lso3MtA== + move-concurrently@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/move-concurrently/-/move-concurrently-1.0.1.tgz#be2c005fda32e0b29af1f05d7c4b33214c701f92" @@ -7734,18 +7924,18 @@ object-hash@^2.0.1: resolved "https://registry.yarnpkg.com/object-hash/-/object-hash-2.0.3.tgz#d12db044e03cd2ca3d77c0570d87225b02e1e6ea" integrity sha512-JPKn0GMu+Fa3zt3Bmr66JhokJU5BaNBIh4ZeTlaCBzrBsOeXzwcKKAK1tbLiPKgvwmPXsDvvLHoWh5Bm7ofIYg== -object-inspect@^1.7.0, object-inspect@^1.8.0: +object-inspect@^1.8.0: version "1.8.0" resolved "https://registry.yarnpkg.com/object-inspect/-/object-inspect-1.8.0.tgz#df807e5ecf53a609cc6bfe93eac3cc7be5b3a9d0" integrity sha512-jLdtEOB112fORuypAyl/50VRVIBIdVQOSUUGQHzJ4xBSbit81zRarz7GThkEFZy1RceYrWYcPcBFPQwHyAc1gA== object-is@^1.0.1: - version "1.1.2" - resolved "https://registry.yarnpkg.com/object-is/-/object-is-1.1.2.tgz#c5d2e87ff9e119f78b7a088441519e2eec1573b6" - integrity sha512-5lHCz+0uufF6wZ7CRFWJN3hp8Jqblpgve06U5CMQ3f//6iDjPr2PEo9MWCjEssDsa+UZEL4PkFpr+BMop6aKzQ== + version "1.1.3" + resolved "https://registry.yarnpkg.com/object-is/-/object-is-1.1.3.tgz#2e3b9e65560137455ee3bd62aec4d90a2ea1cc81" + integrity sha512-teyqLvFWzLkq5B9ki8FVWA902UER2qkxmdA4nLf+wjOLAWgxzCWZNCxpDq9MvE8MmhWNr+I8w3BN49Vx36Y6Xg== dependencies: define-properties "^1.1.3" - es-abstract "^1.17.5" + es-abstract "^1.18.0-next.1" object-keys@^1.0.12, object-keys@^1.1.1: version "1.1.1" @@ -8301,6 +8491,17 @@ pnp-webpack-plugin@1.6.4: dependencies: ts-pnp "^1.1.6" +pondjs@^0.9.0: + version "0.9.0" + resolved "https://registry.yarnpkg.com/pondjs/-/pondjs-0.9.0.tgz#5c931233f8ef56852914eb669506eaac62b0f13b" + integrity sha512-fXgEYrWhgC/N3CVuXarG+/q54jar35Mqf7QPdl7z+mX5RkMyyjLfj9fMgY3oHv2hzMo9zkNx2kK62DrZuG3LXg== + dependencies: + "@babel/polyfill" "^7.7.0" + immutable "^3.6.4" + immutable-devtools "0.0.4" + moment "^2.24.0" + underscore "^1.9.1" + portfinder@^1.0.25: version "1.0.28" resolved "https://registry.yarnpkg.com/portfinder/-/portfinder-1.0.28.tgz#67c4622852bd5374dd1dd900f779f53462fac778" @@ -9080,7 +9281,7 @@ prop-types-extra@^1.1.0: react-is "^16.3.2" warning "^4.0.0" -prop-types@^15.6.0, prop-types@^15.6.2, prop-types@^15.7.2: +prop-types@^15.5.10, prop-types@^15.6.0, prop-types@^15.6.1, prop-types@^15.6.2, prop-types@^15.7.2: version "15.7.2" resolved "https://registry.yarnpkg.com/prop-types/-/prop-types-15.7.2.tgz#52c41e75b8c87e72b9d9360e0206b99dcbffa6c5" integrity sha512-8QQikdH7//R2vurIJSutZ1smHYTcLpRWEOlHnzcWHmBYrOGUysKwSsrC89BCiFj3CbrfJ/nXFdJepOVrY1GCHQ== @@ -9335,6 +9536,18 @@ react-error-overlay@^6.0.7: resolved "https://registry.yarnpkg.com/react-error-overlay/-/react-error-overlay-6.0.7.tgz#1dcfb459ab671d53f660a991513cb2f0a0553108" integrity sha512-TAv1KJFh3RhqxNvhzxj6LeT5NWklP6rDr2a0jaTfsZ5wSZWHOGeqQyejUp3xxLfPt2UpyJEcVQB/zyPcmonNFA== +react-hot-loader@4.1.2: + version "4.1.2" + resolved "https://registry.yarnpkg.com/react-hot-loader/-/react-hot-loader-4.1.2.tgz#5e8025f5bc5605506586b46eb2c6cc4006fd54d7" + integrity sha512-7EFwgpJOx4AG4pwVifgr/ZNBPAxl2z424nGJPc/APB3F8YtCA3WdYuGlcerRK2C9vYAoqiiLw745IB3wjnzrRQ== + dependencies: + fast-levenshtein "^2.0.6" + global "^4.3.0" + hoist-non-react-statics "^2.5.0" + prop-types "^15.6.1" + react-lifecycles-compat "^3.0.2" + shallowequal "^1.0.2" + react-input-mask@^3.0.0-alpha.2: version "3.0.0-alpha.2" resolved "https://registry.yarnpkg.com/react-input-mask/-/react-input-mask-3.0.0-alpha.2.tgz#113102942a557edc7a192e66020b8ce1ba699a5c" @@ -9359,7 +9572,7 @@ react-json-view@^1.19.1: react-lifecycles-compat "^3.0.4" react-textarea-autosize "^6.1.0" -react-lifecycles-compat@^3.0.4: +react-lifecycles-compat@^3.0.2, react-lifecycles-compat@^3.0.4: version "3.0.4" resolved "https://registry.yarnpkg.com/react-lifecycles-compat/-/react-lifecycles-compat-3.0.4.tgz#4f1a273afdfc8f3488a8c516bfda78f872352362" integrity sha512-fBASbA6LnOU9dOU2eW7aQ8xmYBSXUIWr+UmF9b1efZBazGNO+rcXT/icdKnYm2pTwcRylVUYwW7H1PHfLekVzA== @@ -9479,6 +9692,35 @@ react-textarea-autosize@^6.1.0: dependencies: prop-types "^15.6.0" +react-timeseries-charts@^0.16.1: + version "0.16.1" + resolved "https://registry.yarnpkg.com/react-timeseries-charts/-/react-timeseries-charts-0.16.1.tgz#46675a41a7806155a0b5a1342e3b811172845a5c" + integrity sha512-WK2Pege5/FGrE5ifGX1XTVQOZkobRUV5YMwRQi4+TpYAALL5cBLp+XCuAX8x8agq4AmqChpClPIrANc8pRL5RA== + dependencies: + array.prototype.fill "^1.0.1" + babel-runtime "^6.23.0" + colorbrewer "^1.0.0" + d3-axis "^1.0.8" + d3-ease "^1.0.3" + d3-format "^1.2.0" + d3-interpolate "^1.1.5" + d3-scale "^1.0.6" + d3-scale-chromatic "^1.1.1" + d3-selection "^1.1.0" + d3-selection-multi "^1.0.1" + d3-shape "^1.2.0" + d3-time "^1.0.7" + d3-time-format "^2.0.5" + d3-transition "^1.1.0" + dom-resize "^1.0.3" + invariant "^2.1.1" + merge "^1.2.0" + moment "^2.18.1" + moment-duration-format "^1.3.0" + prop-types "^15.5.10" + react-hot-loader "4.1.2" + underscore "^1.8.3" + react-transition-group@^4.4.1: version "4.4.1" resolved "https://registry.yarnpkg.com/react-transition-group/-/react-transition-group-4.4.1.tgz#63868f9325a38ea5ee9535d828327f85773345c9" @@ -10205,6 +10447,11 @@ shallow-clone@^3.0.0: dependencies: kind-of "^6.0.2" +shallowequal@^1.0.2: + version "1.1.0" + resolved "https://registry.yarnpkg.com/shallowequal/-/shallowequal-1.1.0.tgz#188d521de95b9087404fd4dcb68b13df0ae4e7f8" + integrity sha512-y0m1JoUZSlPAjXVtPPW70aZWfIL/dSP7AFkRnniLCrK/8MDKog3TySTBmckD+RObVxH0v4Tox67+F14PdED2oQ== + shebang-command@^1.2.0: version "1.2.0" resolved "https://registry.yarnpkg.com/shebang-command/-/shebang-command-1.2.0.tgz#44aac65b695b03398968c39f363fee5deafdf1ea" @@ -11108,6 +11355,11 @@ uncontrollable@^7.0.0: invariant "^2.2.4" react-lifecycles-compat "^3.0.4" +underscore@^1.8.3, underscore@^1.9.1: + version "1.11.0" + resolved "https://registry.yarnpkg.com/underscore/-/underscore-1.11.0.tgz#dd7c23a195db34267186044649870ff1bab5929e" + integrity sha512-xY96SsN3NA461qIRKZ/+qox37YXPtSBswMGfiNptr+wrt6ds4HaMw23TP612fEyGekRE6LNRiLYr/aqbHXNedw== + unicode-canonical-property-names-ecmascript@^1.0.4: version "1.0.4" resolved "https://registry.yarnpkg.com/unicode-canonical-property-names-ecmascript/-/unicode-canonical-property-names-ecmascript-1.0.4.tgz#2619800c4c825800efdd8343af7dd9933cbe2818" diff --git a/statistics_controller.go b/statistics_controller.go new file mode 100644 index 0000000..65c7d58 --- /dev/null +++ b/statistics_controller.go @@ -0,0 +1,71 @@ +package main + +import ( + "context" + "fmt" + log "github.com/sirupsen/logrus" + "time" +) + +type StatisticRecord struct { + RangeStart time.Time `json:"range_start" bson:"_id"` + ConnectionsPerService map[uint16]int `json:"connections_per_service" bson:"connections_per_service"` + ClientBytesPerService map[uint16]int `json:"client_bytes_per_service" bson:"client_bytes_per_service"` + ServerBytesPerService map[uint16]int `json:"server_bytes_per_service" bson:"server_bytes_per_service"` + DurationPerService map[uint16]int64 `json:"duration_per_service" bson:"duration_per_service"` +} + +type StatisticsFilter struct { + RangeFrom time.Time `form:"range_from"` + RangeTo time.Time `form:"range_to"` + Ports []uint16 `form:"ports"` + Metric string `form:"metric"` +} + +type StatisticsController struct { + storage Storage + metrics []string +} + +func NewStatisticsController(storage Storage) StatisticsController { + return StatisticsController{ + storage: storage, + metrics: []string{"connections_per_service", "client_bytes_per_service", + "server_bytes_per_service", "duration_per_service"}, + } +} + +func (sc *StatisticsController) GetStatistics(context context.Context, filter StatisticsFilter) []StatisticRecord { + var statisticRecords []StatisticRecord + query := sc.storage.Find(Statistics).Context(context) + if !filter.RangeFrom.IsZero() { + query = query.Filter(OrderedDocument{{"_id", UnorderedDocument{"$lt": filter.RangeFrom}}}) + } + if !filter.RangeTo.IsZero() { + query = query.Filter(OrderedDocument{{"_id", UnorderedDocument{"$gt": filter.RangeTo}}}) + } + for _, port := range filter.Ports { + for _, metric := range sc.metrics { + if filter.Metric == "" || filter.Metric == metric { + query = query.Projection(OrderedDocument{{fmt.Sprintf("%s.%d", metric, port), 1}}) + } + } + } + if filter.Metric != "" && len(filter.Ports) == 0 { + for _, metric := range sc.metrics { + if filter.Metric == metric { + query = query.Projection(OrderedDocument{{metric, 1}}) + } + } + } + + if err := query.All(&statisticRecords); err != nil { + log.WithError(err).WithField("filter", filter).Error("failed to retrieve statistics") + return []StatisticRecord{} + } + if statisticRecords == nil { + return []StatisticRecord{} + } + + return statisticRecords +} @@ -17,6 +17,7 @@ const ImportingSessions = "importing_sessions" const Rules = "rules" const Settings = "settings" const Services = "services" +const Statistics = "statistics" var ZeroRowID [12]byte @@ -57,6 +58,7 @@ func NewMongoStorage(uri string, port int, database string) (*MongoStorage, erro Rules: db.Collection(Rules), Settings: db.Collection(Settings), Services: db.Collection(Services), + Statistics: db.Collection(Statistics), } if _, err := collections[Services].Indexes().CreateOne(ctx, mongo.IndexModel{ @@ -150,6 +152,7 @@ type UpdateOperation interface { Filter(filter OrderedDocument) UpdateOperation Upsert(upsertResults *interface{}) UpdateOperation One(update interface{}) (bool, error) + OneComplex(update interface{}) (bool, error) Many(update interface{}) (int64, error) } @@ -200,6 +203,22 @@ func (fo MongoUpdateOperation) One(update interface{}) (bool, error) { return result.ModifiedCount == 1, nil } +func (fo MongoUpdateOperation) OneComplex(update interface{}) (bool, error) { + if fo.err != nil { + return false, fo.err + } + + result, err := fo.collection.UpdateOne(fo.ctx, fo.filter, update, fo.opt) + if err != nil { + return false, err + } + + if fo.upsertResult != nil { + *(fo.upsertResult) = result.UpsertedID + } + return result.ModifiedCount == 1, nil +} + func (fo MongoUpdateOperation) Many(update interface{}) (int64, error) { if fo.err != nil { return 0, fo.err @@ -238,6 +257,7 @@ func (storage *MongoStorage) Update(collectionName string) UpdateOperation { type FindOperation interface { Context(ctx context.Context) FindOperation Filter(filter OrderedDocument) FindOperation + Projection(filter OrderedDocument) FindOperation Sort(field string, ascending bool) FindOperation Limit(n int64) FindOperation First(result interface{}) error @@ -247,6 +267,7 @@ type FindOperation interface { type MongoFindOperation struct { collection *mongo.Collection filter OrderedDocument + projection OrderedDocument ctx context.Context optFind *options.FindOptions optFindOne *options.FindOneOptions @@ -266,6 +287,15 @@ func (fo MongoFindOperation) Filter(filter OrderedDocument) FindOperation { return fo } +func (fo MongoFindOperation) Projection(projection OrderedDocument) FindOperation { + for _, elem := range projection { + fo.projection = append(fo.projection, primitive.E{Key: elem.Key, Value: elem.Value}) + } + fo.optFindOne.SetProjection(fo.projection) + fo.optFind.SetProjection(fo.projection) + return fo +} + func (fo MongoFindOperation) Limit(n int64) FindOperation { fo.optFind.SetLimit(n) return fo @@ -321,6 +351,7 @@ func (storage *MongoStorage) Find(collectionName string) FindOperation { op := MongoFindOperation{ collection: collection, filter: OrderedDocument{}, + projection: OrderedDocument{}, optFind: options.Find(), optFindOne: options.FindOne(), sorts: OrderedDocument{}, |