diff options
author | Emiliano Ciavatta | 2020-08-09 09:22:47 +0000 |
---|---|---|
committer | Emiliano Ciavatta | 2020-08-09 09:22:47 +0000 |
commit | 9dae0115592424929ffe4045069740365bc91d52 (patch) | |
tree | 98bfc74f13964102d98c2cbb20c48870e1211c55 /frontend/src/components/filters | |
parent | c17bbff1bb9cca355be1548448452f582e2d7db6 (diff) |
Update frontend filters
Diffstat (limited to 'frontend/src/components/filters')
7 files changed, 607 insertions, 0 deletions
diff --git a/frontend/src/components/filters/BooleanConnectionsFilter.js b/frontend/src/components/filters/BooleanConnectionsFilter.js new file mode 100644 index 0000000..7dea7cf --- /dev/null +++ b/frontend/src/components/filters/BooleanConnectionsFilter.js @@ -0,0 +1,72 @@ +import React, {Component} from 'react'; +import {withRouter} from "react-router-dom"; +import {Redirect} from "react-router"; +import './BooleanConnectionsFilter.scss'; + +const classNames = require('classnames'); + +class BooleanConnectionsFilter extends Component { + + constructor(props) { + super(props); + this.state = { + filterActive: "false" + }; + + this.filterChanged = this.filterChanged.bind(this); + this.needRedirect = false; + } + + componentDidMount() { + let params = new URLSearchParams(this.props.location.search); + this.setState({filterActive: this.toBoolean(params.get(this.props.filterName)).toString()}); + } + + componentDidUpdate(prevProps, prevState, snapshot) { + let urlParams = new URLSearchParams(this.props.location.search); + let externalActive = this.toBoolean(urlParams.get(this.props.filterName)); + let filterActive = this.toBoolean(this.state.filterActive); + // if the filterActive state is changed by another component (and not by filterChanged func) and + // the query string is not equals at the filterActive state, update the state of the component + if (this.toBoolean(prevState.filterActive) === filterActive && filterActive !== externalActive) { + this.setState({filterActive: externalActive.toString()}); + } + } + + toBoolean(value) { + return value !== null && value.toLowerCase() === "true"; + } + + filterChanged() { + this.needRedirect = true; + this.setState({filterActive: (!this.toBoolean(this.state.filterActive)).toString()}); + } + + render() { + let redirect = null; + if (this.needRedirect) { + let urlParams = new URLSearchParams(this.props.location.search); + if (this.toBoolean(this.state.filterActive)) { + urlParams.set(this.props.filterName, "true"); + } else { + urlParams.delete(this.props.filterName); + } + redirect = <Redirect push to={`${this.props.location.pathname}?${urlParams}`} />; + + this.needRedirect = false; + } + + return ( + <div className={classNames("filter", "d-inline-block", {"filter-active" : this.toBoolean(this.state.filterActive)})}> + <div className="filter-boolean" onClick={this.filterChanged}> + <span>{this.props.filterName}</span> + </div> + + {redirect} + </div> + ); + } + +} + +export default withRouter(BooleanConnectionsFilter); diff --git a/frontend/src/components/filters/BooleanConnectionsFilter.scss b/frontend/src/components/filters/BooleanConnectionsFilter.scss new file mode 100644 index 0000000..f3d8697 --- /dev/null +++ b/frontend/src/components/filters/BooleanConnectionsFilter.scss @@ -0,0 +1,26 @@ +@import '../../colors'; + +.filter { + + .filter-boolean { + padding: 0 10px; + background-color: $color-primary-2; + border-radius: 5px; + cursor: pointer; + height: 34px; + + span { + display: block; + font-size: 13px; + padding: 6px 5px; + } + } + + &.filter-active { + .filter-boolean { + background-color: $color-primary-4; + color: $color-primary-3; + } + } + +} diff --git a/frontend/src/components/filters/FiltersDefinitions.js b/frontend/src/components/filters/FiltersDefinitions.js new file mode 100644 index 0000000..a582d02 --- /dev/null +++ b/frontend/src/components/filters/FiltersDefinitions.js @@ -0,0 +1,69 @@ +import { + cleanNumber, + timestampToTime, + timeToTimestamp, + validate24HourTime, + validateIpAddress, + validateMin, + validatePort +} from "../../utils"; +import StringConnectionsFilter from "./StringConnectionsFilter"; +import React from "react"; +import RulesConnectionsFilter from "./RulesConnectionsFilter"; +import BooleanConnectionsFilter from "./BooleanConnectionsFilter"; + + +export const filtersNames = ["service_port", "matched_rules", "client_address", "client_port", + "min_duration", "max_duration", "min_bytes", "max_bytes", "started_after", + "started_before", "closed_after", "closed_before", "marked", "hidden"]; + +export const filtersDefinitions = { + service_port: <StringConnectionsFilter filterName="service_port" + defaultFilterValue="all_ports" + replaceFunc={cleanNumber} + validateFunc={validatePort}/>, + matched_rules: <RulesConnectionsFilter />, + client_address: <StringConnectionsFilter filterName="client_address" + defaultFilterValue="all_addresses" + validateFunc={validateIpAddress} />, + client_port: <StringConnectionsFilter filterName="client_port" + defaultFilterValue="all_ports" + replaceFunc={cleanNumber} + validateFunc={validatePort}/>, + min_duration: <StringConnectionsFilter filterName="min_duration" + defaultFilterValue="0" + replaceFunc={cleanNumber} + validateFunc={validateMin(0)}/>, + max_duration: <StringConnectionsFilter filterName="max_duration" + defaultFilterValue="∞" + replaceFunc={cleanNumber} />, + min_bytes: <StringConnectionsFilter filterName="min_bytes" + defaultFilterValue="0" + replaceFunc={cleanNumber} + validateFunc={validateMin(0)} />, + max_bytes: <StringConnectionsFilter filterName="max_bytes" + defaultFilterValue="∞" + replaceFunc={cleanNumber} />, + started_after: <StringConnectionsFilter filterName="started_after" + defaultFilterValue="00:00:00" + validateFunc={validate24HourTime} + encodeFunc={timeToTimestamp} + decodeFunc={timestampToTime} />, + started_before: <StringConnectionsFilter filterName="started_before" + defaultFilterValue="00:00:00" + validateFunc={validate24HourTime} + encodeFunc={timeToTimestamp} + decodeFunc={timestampToTime} />, + closed_after: <StringConnectionsFilter filterName="closed_after" + defaultFilterValue="00:00:00" + validateFunc={validate24HourTime} + encodeFunc={timeToTimestamp} + decodeFunc={timestampToTime} />, + closed_before: <StringConnectionsFilter filterName="closed_before" + defaultFilterValue="00:00:00" + validateFunc={validate24HourTime} + encodeFunc={timeToTimestamp} + decodeFunc={timestampToTime} />, + marked: <BooleanConnectionsFilter filterName={"marked"} />, + hidden: <BooleanConnectionsFilter filterName={"hidden"} /> +}; diff --git a/frontend/src/components/filters/RulesConnectionsFilter.js b/frontend/src/components/filters/RulesConnectionsFilter.js new file mode 100644 index 0000000..358085f --- /dev/null +++ b/frontend/src/components/filters/RulesConnectionsFilter.js @@ -0,0 +1,90 @@ +import React, {Component} from 'react'; +import {withRouter} from "react-router-dom"; +import {Redirect} from "react-router"; +import './RulesConnectionsFilter.scss'; +import ReactTags from 'react-tag-autocomplete'; +import axios from 'axios'; + +const classNames = require('classnames'); + +class RulesConnectionsFilter extends Component { + + constructor(props) { + super(props); + this.state = { + mounted: false, + rules: [], + activeRules: [] + }; + + this.needRedirect = false; + } + + componentDidMount() { + let params = new URLSearchParams(this.props.location.search); + let activeRules = params.getAll("matched_rules") || []; + + axios.get("/api/rules").then(res => { + let rules = res.data.flatMap(rule => rule.enabled ? [{id: rule.id, name: rule.name}] : []); + activeRules = rules.filter(rule => activeRules.some(id => rule.id === id)); + this.setState({rules, activeRules, mounted: true}); + }); + } + + componentDidUpdate(prevProps, prevState, snapshot) { + let urlParams = new URLSearchParams(this.props.location.search); + let externalRules = urlParams.getAll("matched_rules") || []; + let activeRules = this.state.activeRules.map(r => r.id); + let compareRules = (first, second) => first.sort().join(",") === second.sort().join(","); + if (this.state.mounted && + compareRules(prevState.activeRules.map(r => r.id), activeRules) && + !compareRules(externalRules, activeRules)) { + this.setState({activeRules: externalRules.map(id => this.state.rules.find(r => r.id === id))}); + } + } + + onDelete(i) { + const activeRules = this.state.activeRules.slice(0); + activeRules.splice(i, 1); + this.needRedirect = true; + this.setState({ activeRules }); + } + + onAddition(rule) { + if (!this.state.activeRules.includes(rule)) { + const activeRules = [].concat(this.state.activeRules, rule); + this.needRedirect = true; + this.setState({activeRules}); + } + } + + render() { + let redirect = null; + + if (this.needRedirect) { + let urlParams = new URLSearchParams(this.props.location.search); + urlParams.delete("matched_rules"); + this.state.activeRules.forEach(rule => urlParams.append("matched_rules", rule.id)); + redirect = <Redirect push to={`${this.props.location.pathname}?${urlParams}`} />; + + this.needRedirect = false; + } + + return ( + <div className={classNames("filter", "d-inline-block", {"filter-active" : this.state.filterActive === "true"})}> + <div className="filter-booleanq"> + <ReactTags tags={this.state.activeRules} suggestions={this.state.rules} + onDelete={this.onDelete.bind(this)} onAddition={this.onAddition.bind(this)} + minQueryLength={0} placeholderText="rule_name" + suggestionsFilter={(suggestion, query) => + suggestion.name.startsWith(query) && !this.state.activeRules.includes(suggestion)} /> + </div> + + {redirect} + </div> + ); + } + +} + +export default withRouter(RulesConnectionsFilter); diff --git a/frontend/src/components/filters/RulesConnectionsFilter.scss b/frontend/src/components/filters/RulesConnectionsFilter.scss new file mode 100644 index 0000000..d75066e --- /dev/null +++ b/frontend/src/components/filters/RulesConnectionsFilter.scss @@ -0,0 +1,136 @@ +@import '../../colors'; + +.react-tags { + position: relative; + padding: 0px 6px; + border-radius: 4px; + + background-color: $color-primary-2; + + + /* shared font styles */ + font-size: 12px; + + /* clicking anywhere will focus the input */ + cursor: text; + + z-index: 10; +} + +.react-tags.is-focused { + border-color: #B1B1B1; +} + +.react-tags__selected { + display: inline; +} + +.react-tags__selected-tag { + display: inline-block; + border: none; + margin: 0 6px 6px 0; + padding: 2px 4px; + border-radius: 2px; + background: $color-primary-4; + color: $color-primary-3; + + font-size: 11px; +} + +.react-tags__selected-tag:after { + content: '\2715'; + color: $color-primary-3; + margin-left: 8px; +} + +.react-tags__selected-tag:hover, +.react-tags__selected-tag:focus { + border-color: #B1B1B1; +} + +.react-tags__search { + display: inline-block; + padding: 7px 10px; + + /* prevent autoresize overflowing the container */ + max-width: 100%; +} + +@media screen and (min-width: 30em) { + + .react-tags__search { + /* this will become the offsetParent for suggestions */ + position: relative; + } + +} + +.react-tags__search-input { + max-width: 100%; + margin: 0; + padding: 0; + border: 0; + outline: none; + + background-color: $color-primary-2; + color: $color-primary-4; + + /* match the font styles */ + font-size: inherit; + line-height: inherit; +} + +.react-tags__search-input::-ms-clear { + display: none; +} + +.react-tags__suggestions { + position: absolute; + top: 100%; + left: 0; + width: 100%; +} + +@media screen and (min-width: 30em) { + + .react-tags__suggestions { + width: 240px; + } + +} + +.react-tags__suggestions ul { + margin: 4px -1px; + padding: 0; + list-style: none; + background: $color-primary-4; + border-radius: 2px; + color: $color-primary-1; + font-size: 12px; +} + +.react-tags__suggestions li { + border-bottom: 1px solid #ddd; + padding: 3px 5px; +} + +.react-tags__suggestions li mark { + text-decoration: underline; + background: none; + font-weight: 600; +} + +.react-tags__suggestions li:hover { + cursor: pointer; + background: $color-primary-0; + color: $color-primary-4; +} + +.react-tags__suggestions li.is-active { + background: #b7cfe0; +} + +.react-tags__suggestions li.is-disabled { + opacity: 0.5; + cursor: auto; +}
\ No newline at end of file diff --git a/frontend/src/components/filters/StringConnectionsFilter.js b/frontend/src/components/filters/StringConnectionsFilter.js new file mode 100644 index 0000000..490a569 --- /dev/null +++ b/frontend/src/components/filters/StringConnectionsFilter.js @@ -0,0 +1,146 @@ +import React, {Component} from 'react'; +import {withRouter} from "react-router-dom"; +import {Redirect} from "react-router"; +import './StringConnectionsFilter.scss'; + +const classNames = require('classnames'); + +class StringConnectionsFilter extends Component { + + constructor(props) { + super(props); + this.state = { + fieldValue: "", + filterValue: null, + timeoutHandle: null, + invalidValue: false + }; + this.needRedirect = false; + this.filterChanged = this.filterChanged.bind(this); + } + + componentDidMount() { + let params = new URLSearchParams(this.props.location.search); + this.updateStateFromFilterValue(params.get(this.props.filterName)); + } + + componentDidUpdate(prevProps, prevState, snapshot) { + let urlParams = new URLSearchParams(this.props.location.search); + let filterValue = urlParams.get(this.props.filterName); + if (prevState.filterValue === this.state.filterValue && this.state.filterValue !== filterValue) { + this.updateStateFromFilterValue(filterValue); + } + } + + updateStateFromFilterValue(filterValue) { + if (filterValue !== null) { + let fieldValue = filterValue; + if (typeof this.props.decodeFunc === "function") { + fieldValue = this.props.decodeFunc(filterValue); + } + if (typeof this.props.replaceFunc === "function") { + fieldValue = this.props.replaceFunc(fieldValue); + } + if (this.isValueValid(fieldValue)) { + this.setState({ + fieldValue: fieldValue, + filterValue: filterValue + }); + } else { + this.setState({ + fieldValue: fieldValue, + invalidValue: true + }); + } + } else { + this.setState({fieldValue: "", filterValue: null}); + } + } + + isValueValid(value) { + return typeof this.props.validateFunc !== "function" || + (typeof this.props.validateFunc === "function" && this.props.validateFunc(value)); + } + + filterChanged(event) { + let fieldValue = event.target.value; + if (this.state.timeoutHandle !== null) { + clearTimeout(this.state.timeoutHandle); + } + + if (typeof this.props.replaceFunc === "function") { + fieldValue = this.props.replaceFunc(fieldValue); + } + + if (fieldValue === "") { + this.needRedirect = true; + this.setState({fieldValue: "", filterValue: null, invalidValue: false}); + return; + } + + if (this.isValueValid(fieldValue)) { + let filterValue = fieldValue; + if (filterValue !== "" && typeof this.props.encodeFunc === "function") { + filterValue = this.props.encodeFunc(filterValue); + } + + this.setState({ + fieldValue: fieldValue, + timeoutHandle: setTimeout(() => { + this.needRedirect = true; + this.setState({filterValue: filterValue}); + }, 500), + invalidValue: false + }); + } else { + this.needRedirect = true; + this.setState({ + fieldValue: fieldValue, + invalidValue: true + }); + } + } + + render() { + let redirect = null; + if (this.needRedirect) { + let urlParams = new URLSearchParams(this.props.location.search); + if (this.state.filterValue !== null) { + urlParams.set(this.props.filterName, this.state.filterValue); + } else { + urlParams.delete(this.props.filterName); + } + redirect = <Redirect push to={`${this.props.location.pathname}?${urlParams}`} />; + this.needRedirect = false; + } + let active = this.state.filterValue !== null; + + return ( + <div className={classNames("filter", "d-inline-block", {"filter-active" : active}, + {"filter-invalid": this.state.invalidValue})} style={{"width": "200px"}}> + <div className="input-group"> + <div className="filter-name-wrapper"> + <span className="filter-name" id={`filter-${this.props.filterName}`}>{this.props.filterName}:</span> + </div> + <input placeholder={this.props.defaultFilterValue} aria-label={this.props.filterName} + aria-describedby={`filter-${this.props.filterName}`} className="form-control filter-value" + onChange={this.filterChanged} value={this.state.fieldValue} /> + </div> + + { active && + <div className="filter-delete"> + <span className="filter-delete-icon" onClick={() => { + this.needRedirect = true; + this.setState({fieldValue: "", filterValue: null}); + }}>del</span> + </div> + } + + {redirect} + </div> + ); + } + +} + +export default withRouter(StringConnectionsFilter); diff --git a/frontend/src/components/filters/StringConnectionsFilter.scss b/frontend/src/components/filters/StringConnectionsFilter.scss new file mode 100644 index 0000000..ecc8d0f --- /dev/null +++ b/frontend/src/components/filters/StringConnectionsFilter.scss @@ -0,0 +1,68 @@ +@import '../../colors'; + +.filter { + margin: 0 10px; + position: relative; + + .filter-name-wrapper { + background-color: $color-primary-2; + padding: 3px 7px; + border-top-left-radius: 5px; + border-bottom-left-radius: 5px; + } + + .filter-name { + font-size: 13px; + } + + .filter-value { + font-size: 13px; + padding-left: 0; + border-radius: 5px; + + &:focus { + background-color: $color-primary-2; + } + } + + &.filter-active { + .filter-name-wrapper { + background-color: $color-primary-4; + color: $color-primary-3; + } + + .filter-value { + background-color: $color-primary-4; + color: $color-primary-3; + } + } + + &.filter-invalid { + .filter-name-wrapper { + background-color: $color-secondary-2; + color: $color-primary-4; + } + + .filter-value { + background-color: $color-secondary-2; + color: $color-primary-4; + } + } + + .filter-delete { + position: absolute; + right: 10px; + top: 10px; + + z-index: 10; + font-size: 11px; + letter-spacing: -0.5px; + + color: $color-primary-2; + cursor: pointer; + + .filter-delete-icon { + font-weight: 800; + } + } +}
\ No newline at end of file |