diff options
author | Emiliano Ciavatta | 2020-09-30 20:58:05 +0000 |
---|---|---|
committer | Emiliano Ciavatta | 2020-09-30 20:58:05 +0000 |
commit | 55afd62a8cfe2cde6e627f1905ab8fe77965afd6 (patch) | |
tree | 57545a722a62d2279bfcd2e36f1cbd1da5a5736a /frontend/src | |
parent | 4cfdf6e2dfe9184e988a145495e072571d512cdc (diff) | |
parent | d6e2aaad41f916c2080c59cf7b4e42bf87a1a03f (diff) |
Merge branch 'feature/frontend' into develop
Diffstat (limited to 'frontend/src')
57 files changed, 2688 insertions, 855 deletions
diff --git a/frontend/src/backend.js b/frontend/src/backend.js new file mode 100644 index 0000000..72ee9dd --- /dev/null +++ b/frontend/src/backend.js @@ -0,0 +1,42 @@ + +async function json(method, url, data, json, headers) { + const options = { + method: method, + body: json != null ? JSON.stringify(json) : data, + mode: "cors", + cache: "no-cache", + credentials: "same-origin", + headers: headers || { + "Content-Type": "application/json" + }, + redirect: "follow", + referrerPolicy: "no-referrer", + }; + const response = await fetch(url, options); + const result = { + statusCode: response.status, + status: `${response.status} ${response.statusText}`, + json: await response.json() + }; + + if (response.status >= 200 && response.status < 300) { + return result; + } else { + return Promise.reject(result); + } +} + +const backend = { + get: (url = "", headers = null) => + json("GET", url, null,null, headers), + post: (url = "", data = null, headers = null) => + json("POST", url, null, data, headers), + put: (url = "", data = null, headers = null) => + json("PUT", url, null, data, headers), + delete: (url = "", data = null, headers = null) => + json("DELETE", url, null, data, headers), + postFile: (url = "", data = null, headers = {}) => + json("POST", url, data, null, headers) +}; + +export default backend; diff --git a/frontend/src/colors.scss b/frontend/src/colors.scss index 064f9f5..d982f03 100644 --- a/frontend/src/colors.scss +++ b/frontend/src/colors.scss @@ -10,10 +10,30 @@ $color-secondary-2: #df3030; $color-secondary-3: #ff9d9d; $color-secondary-4: #ffdfdf; -$color-blue: #247085; -$color-blue-light: #a5b8be; -$color-blue-dark: #013b4c; +$color-red: #E53935; +$color-red-light: #FFEBEE; +$color-red-dark: #B71C1C; -$color-green: #25965d; -$color-green-light: #cde4d8; -$color-green-dark: #004321; +$color-pink: #D81B60; +$color-pink-light: #FCE4EC; +$color-pink-dark: #880E4F; + +$color-purple: #8E24AA; +$color-purple-light: #F3E5F5; +$color-purple-dark: #4A148C; + +$color-deep-purple: #5E35B1; +$color-deep-purple-light: #EDE7F6; +$color-deep-purple-dark: #311B92; + +$color-indigo: #3949AB; +$color-indigo-light: #E8EAF6; +$color-indigo-dark: #1A237E; + +$color-green: #43A047; +$color-green-light: #E8F5E9; +$color-green-dark: #1B5E20; + +$color-blue: #1E88E5; +$color-blue-light: #E3F2FD; +$color-blue-dark: #0D47A1; diff --git a/frontend/src/components/Connection.js b/frontend/src/components/Connection.js index 93c6438..44f9f18 100644 --- a/frontend/src/components/Connection.js +++ b/frontend/src/components/Connection.js @@ -1,7 +1,11 @@ import React, {Component} from 'react'; import './Connection.scss'; -import axios from 'axios' -import {Button, Form, OverlayTrigger, Popover} from "react-bootstrap"; +import {Form, OverlayTrigger, Popover} from "react-bootstrap"; +import backend from "../backend"; +import {dateTimeToTime, durationBetween, formatSize} from "../utils"; +import ButtonField from "./fields/ButtonField"; + +const classNames = require('classnames'); class Connection extends Component { @@ -19,22 +23,18 @@ class Connection extends Component { handleAction(name) { if (name === "hide") { const enabled = !this.props.data.hidden; - axios.post(`/api/connections/${this.props.data.id}/${enabled ? "hide" : "show"}`) - .then(res => { - if (res.status === 202) { - this.props.onEnabled(!enabled); - this.setState({update: true}); - } + backend.post(`/api/connections/${this.props.data.id}/${enabled ? "hide" : "show"}`) + .then(_ => { + this.props.onEnabled(!enabled); + this.setState({update: true}); }); } if (name === "mark") { const marked = this.props.data.marked; - axios.post(`/api/connections/${this.props.data.id}/${marked ? "unmark" : "mark"}`) - .then(res => { - if (res.status === 202) { - this.props.onMarked(!marked); - this.setState({update: true}); - } + backend.post(`/api/connections/${this.props.data.id}/${marked ? "unmark" : "mark"}`) + .then(_ => { + this.props.onMarked(!marked); + this.setState({update: true}); }); } if (name === "copy") { @@ -56,26 +56,12 @@ class Connection extends Component { let startedAt = new Date(conn.started_at); let closedAt = new Date(conn.closed_at); let processedAt = new Date(conn.processed_at); - let duration = ((closedAt - startedAt) / 1000).toFixed(3); - if (duration > 1000 || duration < -1000) { - duration = "∞"; - } else { - duration += "s"; - } let timeInfo = <div> <span>Started at {startedAt.toLocaleDateString() + " " + startedAt.toLocaleTimeString()}</span><br/> <span>Processed at {processedAt.toLocaleDateString() + " " + processedAt.toLocaleTimeString()}</span><br/> <span>Closed at {closedAt.toLocaleDateString() + " " + closedAt.toLocaleTimeString()}</span> </div>; - let classes = "connection"; - if (this.props.selected) { - classes += " connection-selected"; - } - if (this.props.containsFlag) { - classes += " contains-flag"; - } - const popoverFor = function (name, content) { return <Popover id={`popover-${name}-${conn.id}`} className="connection-popover"> <Popover.Content> @@ -96,27 +82,28 @@ class Connection extends Component { </div>; return ( - <tr className={classes}> + <tr className={classNames("connection", {"connection-selected": this.props.selected}, + {"has-matched-rules": conn.matched_rules.length > 0})}> <td> <span className="connection-service"> - <Button size="sm" style={{ - "backgroundColor": serviceColor - }} onClick={() => this.props.addServicePortFilter(conn.port_dst)}>{serviceName}</Button> + <ButtonField small fullSpan color={serviceColor} name={serviceName} + onClick={() => this.props.addServicePortFilter(conn.port_dst)} /> </span> </td> <td className="clickable" onClick={this.props.onSelected}>{conn.ip_src}</td> <td className="clickable" onClick={this.props.onSelected}>{conn.port_src}</td> <td className="clickable" onClick={this.props.onSelected}>{conn.ip_dst}</td> <td className="clickable" onClick={this.props.onSelected}>{conn.port_dst}</td> + <td className="clickable" onClick={this.props.onSelected}>{dateTimeToTime(conn.started_at)}</td> <td className="clickable" onClick={this.props.onSelected}> <OverlayTrigger trigger={["focus", "hover"]} placement="right" overlay={popoverFor("duration", timeInfo)}> - <span className="test-tooltip">{duration}</span> + <span className="test-tooltip">{durationBetween(startedAt, closedAt)}</span> </OverlayTrigger> </td> - <td className="clickable" onClick={this.props.onSelected}>{conn.client_bytes}</td> - <td className="clickable" onClick={this.props.onSelected}>{conn.server_bytes}</td> - <td className="contains-flag"> + <td className="clickable" onClick={this.props.onSelected}>{formatSize(conn.client_bytes)}</td> + <td className="clickable" onClick={this.props.onSelected}>{formatSize(conn.server_bytes)}</td> + <td> {/*<OverlayTrigger trigger={["focus", "hover"]} placement="right"*/} {/* overlay={popoverFor("hide", <span>Hide this connection from the list</span>)}>*/} {/* <span className={"connection-icon" + (conn.hidden ? " icon-enabled" : "")}*/} diff --git a/frontend/src/components/Connection.scss b/frontend/src/components/Connection.scss index cb9eb5f..97ef0a4 100644 --- a/frontend/src/components/Connection.scss +++ b/frontend/src/components/Connection.scss @@ -39,8 +39,8 @@ background-color: $color-primary-2; } - &.contains-flag { - border-right: 3px solid $color-secondary-2; + &.has-matched-rules { + border-bottom: 0; } } diff --git a/frontend/src/components/ConnectionContent.js b/frontend/src/components/ConnectionContent.js index 20ec92b..ccaec0b 100644 --- a/frontend/src/components/ConnectionContent.js +++ b/frontend/src/components/ConnectionContent.js @@ -1,8 +1,10 @@ import React, {Component} from 'react'; import './ConnectionContent.scss'; -import {Button, Dropdown, Row} from 'react-bootstrap'; -import axios from 'axios'; +import {Row} from 'react-bootstrap'; import MessageAction from "./MessageAction"; +import backend from "../backend"; +import ButtonField from "./fields/ButtonField"; +import ChoiceField from "./fields/ChoiceField"; const classNames = require('classnames'); @@ -22,20 +24,30 @@ class ConnectionContent extends Component { this.setFormat = this.setFormat.bind(this); } + componentDidMount() { + if (this.props.connection != null) { + this.loadStream(); + } + } + componentDidUpdate(prevProps, prevState, snapshot) { - if (this.props.connection !== null && ( + if (this.props.connection != null && ( this.props.connection !== prevProps.connection || this.state.format !== prevState.format)) { - this.setState({loading: true}); - // TODO: limit workaround. - axios.get(`/api/streams/${this.props.connection.id}?format=${this.state.format}&limit=999999`).then(res => { - this.setState({ - connectionContent: res.data, - loading: false - }); - }); + this.loadStream(); } } + loadStream = () => { + this.setState({loading: true}); + // TODO: limit workaround. + backend.get(`/api/streams/${this.props.connection.id}?format=${this.state.format}&limit=999999`).then(res => { + this.setState({ + connectionContent: res.json, + loading: false + }); + }); + }; + setFormat(format) { if (this.validFormats.includes(format)) { this.setState({format: format}); @@ -51,7 +63,7 @@ class ConnectionContent extends Component { } let unrollMap = (obj) => obj == null ? null : Object.entries(obj).map(([key, value]) => - <p><strong>{key}</strong>: {value}</p> + <p key={key}><strong>{key}</strong>: {value}</p> ); let m = connectionMessage.metadata; @@ -83,12 +95,12 @@ class ConnectionContent extends Component { } return Object.entries(connectionMessage.metadata["reproducers"]).map(([actionName, actionValue]) => - <Button size="sm" key={actionName + "_button"} onClick={() => { + <ButtonField small key={actionName + "_button"} name={actionName} onClick={() => { this.setState({ messageActionDialog: <MessageAction actionName={actionName} actionValue={actionValue} onHide={() => this.setState({messageActionDialog: null})}/> }); - }}>{actionName}</Button> + }} /> ); } @@ -128,56 +140,16 @@ class ConnectionContent extends Component { <span> | <strong>timestamp</strong>: {this.props.connection.started_at}</span> </div> <div className="header-actions col-auto"> - <Dropdown onSelect={this.setFormat}> - <Dropdown.Toggle size="sm" id="connection-content-format"> - format - </Dropdown.Toggle> - - <Dropdown.Menu> - <Dropdown.Item eventKey="default" - active={this.state.format === "default"}>plain</Dropdown.Item> - <Dropdown.Item eventKey="hex" - active={this.state.format === "hex"}>hex</Dropdown.Item> - <Dropdown.Item eventKey="hexdump" - active={this.state.format === "hexdump"}>hexdump</Dropdown.Item> - <Dropdown.Item eventKey="base32" - active={this.state.format === "base32"}>base32</Dropdown.Item> - <Dropdown.Item eventKey="base64" - active={this.state.format === "base64"}>base64</Dropdown.Item> - <Dropdown.Item eventKey="ascii" - active={this.state.format === "ascii"}>ascii</Dropdown.Item> - <Dropdown.Item eventKey="binary" - active={this.state.format === "binary"}>binary</Dropdown.Item> - <Dropdown.Item eventKey="decimal" - active={this.state.format === "decimal"}>decimal</Dropdown.Item> - <Dropdown.Item eventKey="octal" - active={this.state.format === "octal"}>octal</Dropdown.Item> - </Dropdown.Menu> - </Dropdown> - - <Dropdown> - <Dropdown.Toggle size="sm" id="connection-content-view"> - view_as - </Dropdown.Toggle> - - <Dropdown.Menu> - <Dropdown.Item eventKey="default" active={true}>default</Dropdown.Item> - </Dropdown.Menu> - - </Dropdown> - - <Dropdown> - <Dropdown.Toggle size="sm" id="connection-content-download"> - download_as - </Dropdown.Toggle> - - <Dropdown.Menu> - <Dropdown.Item eventKey="nl_separated">nl_separated</Dropdown.Item> - <Dropdown.Item eventKey="only_client">only_client</Dropdown.Item> - <Dropdown.Item eventKey="only_server">only_server</Dropdown.Item> - </Dropdown.Menu> - - </Dropdown> + <ChoiceField name="format" inline small onlyName + keys={["default", "hex", "hexdump", "base32", "base64", "ascii", "binary", "decimal", "octal"]} + values={["plain", "hex", "hexdump", "base32", "base64", "ascii", "binary", "decimal", "octal"]} + onChange={this.setFormat} value={this.state.value} /> + + <ChoiceField name="view_as" inline small onlyName keys={["default"]} values={["default"]} /> + + <ChoiceField name="download_as" inline small onlyName + keys={["nl_separated", "only_client", "only_server"]} + values={["nl_separated", "only_client", "only_server"]} /> </div> </Row> </div> diff --git a/frontend/src/components/ConnectionContent.scss b/frontend/src/components/ConnectionContent.scss index 8ee31ec..fde54c0 100644 --- a/frontend/src/components/ConnectionContent.scss +++ b/frontend/src/components/ConnectionContent.scss @@ -51,7 +51,7 @@ } &:hover .connection-message-actions { - display: block; + display: flex; } .connection-message-label { @@ -89,17 +89,19 @@ .connection-content-header { background-color: $color-primary-2; padding: 0; - height: 31px; + height: 33px; .header-info { - padding-top: 5px; + padding-top: 7px; padding-left: 20px; font-size: 12px; } .header-actions { - .dropdown { - display: inline-block; + display: flex; + + .choice-field { + margin-top: -5px; } } } diff --git a/frontend/src/components/ConnectionMatchedRules.js b/frontend/src/components/ConnectionMatchedRules.js new file mode 100644 index 0000000..21f2a92 --- /dev/null +++ b/frontend/src/components/ConnectionMatchedRules.js @@ -0,0 +1,29 @@ +import React, {Component} from 'react'; +import './ConnectionMatchedRules.scss'; +import ButtonField from "./fields/ButtonField"; + +class ConnectionMatchedRules extends Component { + + constructor(props) { + super(props); + this.state = { + }; + } + + render() { + const matchedRules = this.props.matchedRules.map(mr => { + const rule = this.props.rules.find(r => r.id === mr); + return <ButtonField key={mr} onClick={() => this.props.addMatchedRulesFilter(rule.id)} name={rule.name} + color={rule.color} small />; + }); + + return ( + <tr className="connection-matches"> + <td className="row-label">matched_rules:</td> + <td className="rule-buttons" colSpan={9}>{matchedRules}</td> + </tr> + ); + } +} + +export default ConnectionMatchedRules; diff --git a/frontend/src/components/ConnectionMatchedRules.scss b/frontend/src/components/ConnectionMatchedRules.scss new file mode 100644 index 0000000..ed18f3c --- /dev/null +++ b/frontend/src/components/ConnectionMatchedRules.scss @@ -0,0 +1,23 @@ +@import '../colors.scss'; + +.connection-matches { + background-color: $color-primary-0; + + .rule-buttons { + padding: 0; + } + + .button-field { + display: inline-block; + margin-right: 5px; + + button { + font-size: 0.8em; + padding: 2px 10px; + } + } + + .row-label { + font-size: 0.8em; + } +} diff --git a/frontend/src/components/MessageAction.js b/frontend/src/components/MessageAction.js index 2c85d84..8f4b031 100644 --- a/frontend/src/components/MessageAction.js +++ b/frontend/src/components/MessageAction.js @@ -1,6 +1,8 @@ import React, {Component} from 'react'; import './MessageAction.scss'; -import {Button, FormControl, InputGroup, Modal} from "react-bootstrap"; +import {Modal} from "react-bootstrap"; +import TextField from "./fields/TextField"; +import ButtonField from "./fields/ButtonField"; class MessageAction extends Component { @@ -35,14 +37,11 @@ class MessageAction extends Component { </Modal.Title> </Modal.Header> <Modal.Body> - <InputGroup> - <FormControl as="textarea" className="message-action-value" readOnly={true} - style={{"height": "300px"}} value={this.props.actionValue} ref={this.actionValue} /> - </InputGroup> + <TextField readonly value={this.props.actionValue} textRef={this.actionValue} rows={15} /> </Modal.Body> <Modal.Footer className="dialog-footer"> - <Button variant="green" onClick={this.copyActionValue}>{this.state.copyButtonText}</Button> - <Button variant="red" onClick={this.props.onHide}>close</Button> + <ButtonField variant="green" bordered onClick={this.copyActionValue} name={this.state.copyButtonText} /> + <ButtonField variant="red" bordered onClick={this.props.onHide} name="close" /> </Modal.Footer> </Modal> ); diff --git a/frontend/src/components/fields/ButtonField.js b/frontend/src/components/fields/ButtonField.js new file mode 100644 index 0000000..cc32b0f --- /dev/null +++ b/frontend/src/components/fields/ButtonField.js @@ -0,0 +1,49 @@ +import React, {Component} from 'react'; +import './ButtonField.scss'; +import './common.scss'; + +const classNames = require('classnames'); + +class ButtonField extends Component { + + render() { + const handler = () => { + if (typeof this.props.onClick === "function") { + this.props.onClick(); + } + }; + + let buttonClassnames = { + "button-bordered": this.props.bordered, + }; + if (this.props.variant) { + buttonClassnames[`button-variant-${this.props.variant}`] = true; + } + + let buttonStyle = {}; + if (this.props.color) { + buttonStyle["backgroundColor"] = this.props.color; + } + if (this.props.border) { + buttonStyle["borderColor"] = this.props.border; + } + if (this.props.fullSpan) { + buttonStyle["width"] = "100%"; + } + if (this.props.rounded) { + buttonStyle["borderRadius"] = "3px"; + } + if (this.props.inline) { + buttonStyle["marginTop"] = "8px"; + } + + return ( + <div className={classNames( "field", "button-field", {"field-small": this.props.small})}> + <button type="button" className={classNames(classNames(buttonClassnames))} + onClick={handler} style={buttonStyle}>{this.props.name}</button> + </div> + ); + } +} + +export default ButtonField; diff --git a/frontend/src/components/fields/ButtonField.scss b/frontend/src/components/fields/ButtonField.scss new file mode 100644 index 0000000..cfd20ff --- /dev/null +++ b/frontend/src/components/fields/ButtonField.scss @@ -0,0 +1,123 @@ +@import '../../colors.scss'; + +.button-field { + font-size: 0.9em; + + .button-bordered { + border-bottom: 5px solid $color-primary-1; + } + + &.field-small { + font-size: 0.8em; + + button { + padding: 3px 12px; + } + } + + .button-variant-red { + color: $color-red-light; + background-color: $color-red; + + &.button-bordered { + border-bottom: 5px solid $color-red-dark; + } + + &:hover, + &:active { + color: $color-red-light; + background-color: $color-red-dark; + } + } + + .button-variant-pink { + color: $color-pink-light; + background-color: $color-pink; + + &.button-bordered { + border-bottom: 5px solid $color-pink-dark; + } + + &:hover, + &:active { + color: $color-pink-light; + background-color: $color-pink-dark; + } + } + + .button-variant-purple { + color: $color-purple-light; + background-color: $color-purple; + + &.button-bordered { + border-bottom: 5px solid $color-purple-dark; + } + + &:hover, + &:active { + color: $color-purple-light; + background-color: $color-purple-dark; + } + } + + .button-variant-deep-purple { + color: $color-deep-purple-light; + background-color: $color-deep-purple; + + &.button-bordered { + border-bottom: 5px solid $color-deep-purple-dark; + } + + &:hover, + &:active { + color: $color-deep-purple-light; + background-color: $color-deep-purple-dark; + } + } + + .button-variant-indigo { + color: $color-indigo-light; + background-color: $color-indigo; + + &.button-bordered { + border-bottom: 5px solid $color-indigo-dark; + } + + &:hover, + &:active { + color: $color-indigo-light; + background-color: $color-indigo-dark; + } + } + + .button-variant-blue { + color: $color-blue-light; + background-color: $color-blue; + + &.button-bordered { + border-bottom: 5px solid $color-blue-dark; + } + + &:hover, + &:active { + color: $color-blue-light; + background-color: $color-blue-dark; + } + } + + .button-variant-green { + color: $color-green-light; + background-color: $color-green; + + &.button-bordered { + border-bottom: 5px solid $color-green-dark; + } + + &:hover, + &:active { + color: $color-green-light; + background-color: $color-green-dark; + } + } + +} diff --git a/frontend/src/components/fields/CheckField.js b/frontend/src/components/fields/CheckField.js new file mode 100644 index 0000000..33f4f83 --- /dev/null +++ b/frontend/src/components/fields/CheckField.js @@ -0,0 +1,37 @@ +import React, {Component} from 'react'; +import './CheckField.scss'; +import './common.scss'; +import {randomClassName} from "../../utils"; + +const classNames = require('classnames'); + +class CheckField extends Component { + + constructor(props) { + super(props); + + this.id = `field-${this.props.name || "noname"}-${randomClassName()}`; + } + + render() { + const checked = this.props.checked || false; + const small = this.props.small || false; + const name = this.props.name || null; + const handler = () => { + if (this.props.onChange) { + this.props.onChange(!checked); + } + }; + + return ( + <div className={classNames( "field", "check-field", {"field-checked" : checked}, {"field-small": small})}> + <div className="field-input"> + <input type="checkbox" id={this.id} checked={checked} onChange={handler} /> + <label htmlFor={this.id}>{(checked ? "✓ " : "✗ ") + (name != null ? name : "")}</label> + </div> + </div> + ); + } +} + +export default CheckField; diff --git a/frontend/src/components/fields/CheckField.scss b/frontend/src/components/fields/CheckField.scss new file mode 100644 index 0000000..ab932b4 --- /dev/null +++ b/frontend/src/components/fields/CheckField.scss @@ -0,0 +1,39 @@ +@import '../../colors.scss'; + +.check-field { + font-size: 0.9em; + margin: 5px 0; + + .field-input { + border-radius: 5px; + width: fit-content; + background-color: $color-primary-2; + + input { + display: none; + } + + label { + margin: 0; + padding: 6px 15px; + cursor: pointer; + } + + &:hover { + background-color: $color-primary-1; + } + } + + &.field-checked .field-input { + background-color: $color-primary-4 !important; + color: $color-primary-3; + } + + &.field-small { + font-size: 0.8em; + + .field-input label { + padding: 7px 15px; + } + } +} diff --git a/frontend/src/components/fields/ChoiceField.js b/frontend/src/components/fields/ChoiceField.js new file mode 100644 index 0000000..73e950d --- /dev/null +++ b/frontend/src/components/fields/ChoiceField.js @@ -0,0 +1,68 @@ +import React, {Component} from 'react'; +import './ChoiceField.scss'; +import './common.scss'; +import {randomClassName} from "../../utils"; + +const classNames = require('classnames'); + +class ChoiceField extends Component { + + constructor(props) { + super(props); + + this.state = { + expanded: false + }; + + this.id = `field-${this.props.name || "noname"}-${randomClassName()}`; + } + + render() { + const name = this.props.name || null; + const inline = this.props.inline; + + const collapse = () => this.setState({expanded: false}); + const expand = () => this.setState({expanded: true}); + + const handler = (key) => { + collapse(); + if (this.props.onChange) { + this.props.onChange(key); + } + }; + + const keys = this.props.keys || []; + const values = this.props.values || []; + + const options = keys.map((key, i) => + <span className="field-option" key={key} onClick={() => handler(key)}>{values[i]}</span> + ); + + let fieldValue = ""; + if (inline && name) { + fieldValue = name; + } + if (!this.props.onlyName && inline && name) { + fieldValue += ": "; + } + if (!this.props.onlyName) { + fieldValue += this.props.value || "select a value"; + } + + return ( + <div className={classNames( "field", "choice-field", {"field-inline" : inline}, + {"field-small": this.props.small})}> + {!inline && name && <label className="field-name">{name}:</label>} + <div className={classNames("field-select", {"select-expanded": this.state.expanded})} + tabIndex={0} onBlur={collapse} onClick={() => this.state.expanded ? collapse() : expand()}> + <div className="field-value">{fieldValue}</div> + <div className="field-options"> + {options} + </div> + </div> + </div> + ); + } +} + +export default ChoiceField; diff --git a/frontend/src/components/fields/ChoiceField.scss b/frontend/src/components/fields/ChoiceField.scss new file mode 100644 index 0000000..e7683b7 --- /dev/null +++ b/frontend/src/components/fields/ChoiceField.scss @@ -0,0 +1,69 @@ +@import '../../colors.scss'; + +.choice-field { + font-size: 0.9em; + + .field-name { + margin: 0; + } + + .field-select { + position: relative; + margin-top: 5px; + + .field-value { + background-color: $color-primary-2; + border: none; + color: $color-primary-4; + border-radius: 5px; + padding: 7px 25px 7px 10px; + cursor: pointer; + + &:after { + content: "⋎"; + position: absolute; + right: 10px; + } + } + + .field-options { + position: absolute; + top: 35px; + width: 100%; + z-index: 20; + border-top: 3px solid $color-primary-0; + border-radius: 5px; + background-color: $color-primary-2; + display: none; + + .field-option { + display: block; + padding: 5px 10px; + cursor: pointer; + border-radius: 5px; + } + + .field-option:hover { + background-color: $color-primary-1; + } + } + + &:focus { + outline: none; + } + } + + .field-select.select-expanded { + .field-options { + display: block; + } + + .field-value:after { + content: "⋏"; + } + } + + &.field-small { + font-size: 0.8em; + } +} diff --git a/frontend/src/components/fields/InputField.js b/frontend/src/components/fields/InputField.js new file mode 100644 index 0000000..84c981b --- /dev/null +++ b/frontend/src/components/fields/InputField.js @@ -0,0 +1,78 @@ +import React, {Component} from 'react'; +import './InputField.scss'; +import './common.scss'; +import {randomClassName} from "../../utils"; + +const classNames = require('classnames'); + +class InputField extends Component { + + constructor(props) { + super(props); + + this.id = `field-${this.props.name || "noname"}-${randomClassName()}`; + } + + render() { + const active = this.props.active || false; + const invalid = this.props.invalid || false; + const small = this.props.small || false; + const inline = this.props.inline || false; + const name = this.props.name || null; + const value = this.props.value || ""; + const defaultValue = this.props.defaultValue || ""; + const type = this.props.type || "text"; + const error = this.props.error || null; + + const handler = (e) => { + if (typeof this.props.onChange === "function") { + if (type === "file") { + let file = e.target.files[0]; + this.props.onChange(file); + } else if (e == null) { + this.props.onChange(defaultValue); + } else { + this.props.onChange(e.target.value); + } + } + }; + let inputProps = {}; + if (type !== "file") { + inputProps["value"] = value || defaultValue; + } + + return ( + <div className={classNames("field", "input-field", {"field-active" : active}, + {"field-invalid": invalid}, {"field-small": small}, {"field-inline": inline})}> + <div className="field-wrapper"> + { name && + <div className="field-name"> + <label>{name}:</label> + </div> + } + <div className="field-input"> + <div className="field-value"> + { type === "file" && <label for={this.id} className={"file-label"}> + {value.name || this.props.placeholder}</label> } + <input type={type} placeholder={this.props.placeholder} id={this.id} + aria-describedby={this.id} onChange={handler} {...inputProps} + readOnly={this.props.readonly} /> + </div> + { type !== "file" && value !== "" && + <div className="field-clear"> + <span onClick={() => handler(null)}>del</span> + </div> + } + </div> + </div> + {error && + <div className="field-error"> + error: {error} + </div> + } + </div> + ); + } +} + +export default InputField; diff --git a/frontend/src/components/fields/InputField.scss b/frontend/src/components/fields/InputField.scss new file mode 100644 index 0000000..79e2b7e --- /dev/null +++ b/frontend/src/components/fields/InputField.scss @@ -0,0 +1,125 @@ +@import '../../colors.scss'; + +.input-field { + font-size: 0.9em; + margin: 5px 0; + + .field-name { + label { + margin: 0; + } + } + + .field-input { + position: relative; + + .field-value { + .file-label { + background-color: $color-primary-2; + margin: 0; + width: 100%; + color: $color-primary-4; + border-radius: 5px; + padding: 7px 10px; + cursor: pointer; + } + + input[type="file"] { + display: none; + } + + .file-label:after { + content: "Browse"; + position: absolute; + right: 0; + top: 0; + padding: 7px 10px 7px 12px; + background-color: $color-primary-1; + border-bottom-right-radius: 5px; + border-top-right-radius: 5px; + } + } + } + + &.field-active { + &.field-inline .field-name { + background-color: $color-primary-4 !important; + color: $color-primary-3 !important; + } + + .field-value input, .field-value .file-label { + background-color: $color-primary-4 !important; + color: $color-primary-3 !important; + } + + .file-label:after { + background-color: $color-secondary-4 !important; + } + } + + &.field-invalid { + &.field-inline .field-name { + background-color: $color-secondary-2 !important; + color: $color-primary-4 !important; + } + + .field-value input, .field-value .file-label { + background-color: $color-secondary-2 !important; + color: $color-primary-4 !important; + } + + .file-label:after { + background-color: $color-secondary-1 !important; + } + } + + &.field-small { + font-size: 0.8em; + } + + &.field-inline .field-wrapper { + display: flex; + + .field-name { + background-color: $color-primary-2; + padding: 6px 7px; + border-top-left-radius: 5px; + border-bottom-left-radius: 5px; + } + + .field-input { + width: 100%; + + input, .file-label { + border-bottom-left-radius: 0; + border-top-left-radius: 0; + padding-left: 3px; + } + } + + &:focus-within .field-name { + background-color: $color-primary-1; + } + } + + .field-clear { + position: absolute; + right: 8px; + top: 8px; + z-index: 10; + font-size: 0.9em; + font-weight: 600; + letter-spacing: -0.5px; + cursor: pointer; + } + + &.field-active .field-clear { + color: $color-primary-2; + } + + .field-error { + padding: 5px 10px; + font-size: 0.9em; + color: $color-secondary-0; + } +} diff --git a/frontend/src/components/fields/TextField.js b/frontend/src/components/fields/TextField.js new file mode 100644 index 0000000..de68c21 --- /dev/null +++ b/frontend/src/components/fields/TextField.js @@ -0,0 +1,43 @@ +import React, {Component} from 'react'; +import './TextField.scss'; +import './common.scss'; +import {randomClassName} from "../../utils"; + +const classNames = require('classnames'); + +class TextField extends Component { + + constructor(props) { + super(props); + + this.id = `field-${this.props.name || "noname"}-${randomClassName()}`; + } + + render() { + const name = this.props.name || null; + const error = this.props.error || null; + const rows = this.props.rows || 3; + + const handler = (e) => { + if (this.props.onChange) { + if (e == null) { + this.props.onChange(""); + } else { + this.props.onChange(e.target.value); + } + } + }; + + return ( + <div className={classNames("field", "text-field", {"field-active": this.props.active}, + {"field-invalid": this.props.invalid}, {"field-small": this.props.small})}> + {name && <label htmlFor={this.id}>{name}:</label>} + <textarea id={this.id} placeholder={this.props.defaultValue} onChange={handler} rows={rows} + readOnly={this.props.readonly} value={this.props.value} ref={this.props.textRef} /> + {error && <div className="field-error">error: {error}</div>} + </div> + ); + } +} + +export default TextField; diff --git a/frontend/src/components/fields/TextField.scss b/frontend/src/components/fields/TextField.scss new file mode 100644 index 0000000..de831fb --- /dev/null +++ b/frontend/src/components/fields/TextField.scss @@ -0,0 +1,54 @@ +@import '../../colors.scss'; + +.text-field { + font-size: 0.9em; + margin: 5px 0; + + label { + display: block; + margin: 0; + } + + textarea { + resize: none; + } + + &.field-active { + textarea { + background-color: $color-primary-4 !important; + color: $color-primary-3 !important; + } + } + + &.field-invalid { + textarea { + background-color: $color-secondary-2 !important; + color: $color-primary-4 !important; + } + } + + &.field-small { + font-size: 0.8em; + } + + .field-clear { + position: absolute; + right: 8px; + top: 8px; + z-index: 10; + font-size: 0.9em; + font-weight: 600; + letter-spacing: -0.5px; + cursor: pointer; + } + + &.field-active .field-clear { + color: $color-primary-2; + } + + .field-error { + padding: 5px 10px; + font-size: 0.9em; + color: $color-secondary-0; + } +} diff --git a/frontend/src/components/fields/common.scss b/frontend/src/components/fields/common.scss new file mode 100644 index 0000000..f83a988 --- /dev/null +++ b/frontend/src/components/fields/common.scss @@ -0,0 +1,54 @@ +@import '../../colors.scss'; + +.field { + + input, textarea { + background-color: $color-primary-2; + width: 100%; + border: none; + color: $color-primary-4; + border-radius: 5px; + padding: 7px 10px; + + &:focus { + background-color: $color-primary-1; + color: $color-primary-4; + box-shadow: none; + outline: none; + } + + &[readonly] { + background-color: $color-primary-2; + border: none; + color: $color-primary-4; + } + + &[readonly]:focus { + background-color: $color-primary-1; + color: $color-primary-4; + box-shadow: none; + } + } + + button { + border-radius: 0; + background-color: $color-primary-2; + border: none; + color: $color-primary-4; + outline: none; + padding: 5px 12px; + font-weight: 500; + + &:hover, + &:active { + background-color: $color-primary-1; + color: $color-primary-4; + } + + &:focus, + &:active { + outline: none !important; + box-shadow: none !important; + } + } +} diff --git a/frontend/src/components/fields/extensions/ColorField.js b/frontend/src/components/fields/extensions/ColorField.js new file mode 100644 index 0000000..96ebc49 --- /dev/null +++ b/frontend/src/components/fields/extensions/ColorField.js @@ -0,0 +1,82 @@ +import React, {Component} from 'react'; +import {OverlayTrigger, Popover} from "react-bootstrap"; +import './ColorField.scss'; +import InputField from "../InputField"; +import validation from "../../../validation"; + +class ColorField extends Component { + + constructor(props) { + super(props); + + this.state = { + }; + + this.colors = ["#E53935", "#D81B60", "#8E24AA", "#5E35B1", "#3949AB", "#1E88E5", "#039BE5", "#00ACC1", + "#00897B", "#43A047", "#7CB342", "#9E9D24", "#F9A825", "#FB8C00", "#F4511E", "#6D4C41"]; + } + + componentDidUpdate(prevProps, prevState, snapshot) { + if (prevProps.value !== this.props.value) { + this.onChange(this.props.value); + } + } + + onChange = (value) => { + this.setState({invalid: value !== "" && !validation.isValidColor(value)}); + + if (typeof this.props.onChange === "function") { + this.props.onChange(value); + } + }; + + render() { + const colorButtons = this.colors.map((color) => + <span key={"button" + color} className="color-input" style={{"backgroundColor": color}} + onClick={() => { + if (typeof this.props.onChange === "function") { + this.props.onChange(color); + } + document.body.click(); // magic to close popup + }} />); + + const popover = ( + <Popover id="popover-basic"> + <Popover.Title as="h3">choose a color</Popover.Title> + <Popover.Content> + <div className="colors-container"> + <div className="colors-row"> + {colorButtons.slice(0, 8)} + </div> + <div className="colors-row"> + {colorButtons.slice(8, 18)} + </div> + </div> + </Popover.Content> + </Popover> + ); + + let buttonStyles = {}; + if (this.props.value) { + buttonStyles["backgroundColor"] = this.props.value; + } + + return ( + <div className="field color-field"> + <div className="color-input"> + <InputField {...this.props} onChange={this.onChange} invalid={this.state.invalid} name="color" + error={null} /> + <div className="color-picker"> + <OverlayTrigger trigger="click" placement="top" overlay={popover} rootClose> + <button type="button" className="picker-button" style={buttonStyles}>pick</button> + </OverlayTrigger> + </div> + </div> + {this.props.error && <div className="color-error">{this.props.error}</div>} + </div> + ); + } + +} + +export default ColorField; diff --git a/frontend/src/components/fields/extensions/ColorField.scss b/frontend/src/components/fields/extensions/ColorField.scss new file mode 100644 index 0000000..6eabbda --- /dev/null +++ b/frontend/src/components/fields/extensions/ColorField.scss @@ -0,0 +1,46 @@ +@import '../../../colors.scss'; + +.color-field { + .color-input { + display: flex; + align-items: flex-end; + + .input-field { + flex: 1; + + input { + border-bottom-right-radius: 0 !important; + border-top-right-radius: 0 !important; + } + } + + .color-picker { + margin-bottom: 5px; + + .picker-button { + font-size: 0.8em; + padding: 8px 15px; + border-bottom-right-radius: 5px; + border-top-right-radius: 5px; + background-color: $color-primary-1; + } + } + } + + .color-error { + font-size: 0.8em; + color: $color-secondary-0; + margin-left: 10px; + } +} + +.colors-container { + width: 600px; + + .color-input { + display: inline-block; + width: 31px; + height: 31px; + cursor: pointer; + } +} diff --git a/frontend/src/components/fields/extensions/NumericField.js b/frontend/src/components/fields/extensions/NumericField.js new file mode 100644 index 0000000..ed81ed7 --- /dev/null +++ b/frontend/src/components/fields/extensions/NumericField.js @@ -0,0 +1,45 @@ +import React, {Component} from 'react'; +import InputField from "../InputField"; + +class NumericField extends Component { + + constructor(props) { + super(props); + + this.state = { + invalid: false + }; + } + + componentDidUpdate(prevProps, prevState, snapshot) { + if (prevProps.value !== this.props.value) { + this.onChange(this.props.value); + } + } + + onChange = (value) => { + value = value.toString().replace(/[^\d]/gi, ''); + let intValue = 0; + if (value !== "") { + intValue = parseInt(value); + } + const valid = + (!this.props.validate || (typeof this.props.validate === "function" && this.props.validate(intValue))) && + (!this.props.min || (typeof this.props.min === "number" && intValue >= this.props.min)) && + (!this.props.max || (typeof this.props.max === "number" && intValue <= this.props.max)); + this.setState({invalid: !valid}); + if (typeof this.props.onChange === "function") { + this.props.onChange(intValue); + } + }; + + render() { + return ( + <InputField {...this.props} onChange={this.onChange} defaultValue={this.props.defaultValue || "0"} + invalid={this.state.invalid} /> + ); + } + +} + +export default NumericField; diff --git a/frontend/src/components/filters/BooleanConnectionsFilter.js b/frontend/src/components/filters/BooleanConnectionsFilter.js index 7dea7cf..4c5a78a 100644 --- a/frontend/src/components/filters/BooleanConnectionsFilter.js +++ b/frontend/src/components/filters/BooleanConnectionsFilter.js @@ -1,9 +1,7 @@ import React, {Component} from 'react'; import {withRouter} from "react-router-dom"; import {Redirect} from "react-router"; -import './BooleanConnectionsFilter.scss'; - -const classNames = require('classnames'); +import CheckField from "../fields/CheckField"; class BooleanConnectionsFilter extends Component { @@ -57,11 +55,9 @@ class BooleanConnectionsFilter extends Component { } 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> - + <div className="filter" style={{"width": `${this.props.width}px`}}> + <CheckField checked={this.toBoolean(this.state.filterActive)} name={this.props.filterName} + onChange={this.filterChanged} /> {redirect} </div> ); diff --git a/frontend/src/components/filters/BooleanConnectionsFilter.scss b/frontend/src/components/filters/BooleanConnectionsFilter.scss deleted file mode 100644 index 941b967..0000000 --- a/frontend/src/components/filters/BooleanConnectionsFilter.scss +++ /dev/null @@ -1,24 +0,0 @@ -@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 index a582d02..02ccb42 100644 --- a/frontend/src/components/filters/FiltersDefinitions.js +++ b/frontend/src/components/filters/FiltersDefinitions.js @@ -12,7 +12,6 @@ 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"]; @@ -21,49 +20,71 @@ export const filtersDefinitions = { service_port: <StringConnectionsFilter filterName="service_port" defaultFilterValue="all_ports" replaceFunc={cleanNumber} - validateFunc={validatePort}/>, + validateFunc={validatePort} + key="service_port_filter" + width={200} />, matched_rules: <RulesConnectionsFilter />, client_address: <StringConnectionsFilter filterName="client_address" defaultFilterValue="all_addresses" - validateFunc={validateIpAddress} />, + validateFunc={validateIpAddress} + key="client_address_filter" + width={320} />, client_port: <StringConnectionsFilter filterName="client_port" defaultFilterValue="all_ports" replaceFunc={cleanNumber} - validateFunc={validatePort}/>, + validateFunc={validatePort} + key="client_port_filter" + width={200} />, min_duration: <StringConnectionsFilter filterName="min_duration" defaultFilterValue="0" replaceFunc={cleanNumber} - validateFunc={validateMin(0)}/>, + validateFunc={validateMin(0)} + key="min_duration_filter" + width={200} />, max_duration: <StringConnectionsFilter filterName="max_duration" defaultFilterValue="∞" - replaceFunc={cleanNumber} />, + replaceFunc={cleanNumber} + key="max_duration_filter" + width={200} />, min_bytes: <StringConnectionsFilter filterName="min_bytes" defaultFilterValue="0" replaceFunc={cleanNumber} - validateFunc={validateMin(0)} />, + validateFunc={validateMin(0)} + key="min_bytes_filter" + width={200} />, max_bytes: <StringConnectionsFilter filterName="max_bytes" defaultFilterValue="∞" - replaceFunc={cleanNumber} />, + replaceFunc={cleanNumber} + key="max_bytes_filter" + width={200} />, started_after: <StringConnectionsFilter filterName="started_after" defaultFilterValue="00:00:00" validateFunc={validate24HourTime} encodeFunc={timeToTimestamp} - decodeFunc={timestampToTime} />, + decodeFunc={timestampToTime} + key="started_after_filter" + width={200} />, started_before: <StringConnectionsFilter filterName="started_before" defaultFilterValue="00:00:00" validateFunc={validate24HourTime} encodeFunc={timeToTimestamp} - decodeFunc={timestampToTime} />, + decodeFunc={timestampToTime} + key="started_before_filter" + width={200} />, closed_after: <StringConnectionsFilter filterName="closed_after" defaultFilterValue="00:00:00" validateFunc={validate24HourTime} encodeFunc={timeToTimestamp} - decodeFunc={timestampToTime} />, + decodeFunc={timestampToTime} + key="closed_after_filter" + width={200} />, closed_before: <StringConnectionsFilter filterName="closed_before" defaultFilterValue="00:00:00" validateFunc={validate24HourTime} encodeFunc={timeToTimestamp} - decodeFunc={timestampToTime} />, + decodeFunc={timestampToTime} + key="closed_before_filter" + width={200} />, marked: <BooleanConnectionsFilter filterName={"marked"} />, hidden: <BooleanConnectionsFilter filterName={"hidden"} /> }; diff --git a/frontend/src/components/filters/RulesConnectionsFilter.js b/frontend/src/components/filters/RulesConnectionsFilter.js index 358085f..8366189 100644 --- a/frontend/src/components/filters/RulesConnectionsFilter.js +++ b/frontend/src/components/filters/RulesConnectionsFilter.js @@ -3,7 +3,7 @@ import {withRouter} from "react-router-dom"; import {Redirect} from "react-router"; import './RulesConnectionsFilter.scss'; import ReactTags from 'react-tag-autocomplete'; -import axios from 'axios'; +import backend from "../../backend"; const classNames = require('classnames'); @@ -24,8 +24,8 @@ class RulesConnectionsFilter extends Component { 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}] : []); + backend.get("/api/rules").then(res => { + let rules = res.json.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}); }); diff --git a/frontend/src/components/filters/StringConnectionsFilter.js b/frontend/src/components/filters/StringConnectionsFilter.js index 490a569..f463593 100644 --- a/frontend/src/components/filters/StringConnectionsFilter.js +++ b/frontend/src/components/filters/StringConnectionsFilter.js @@ -1,9 +1,7 @@ import React, {Component} from 'react'; import {withRouter} from "react-router-dom"; import {Redirect} from "react-router"; -import './StringConnectionsFilter.scss'; - -const classNames = require('classnames'); +import InputField from "../fields/InputField"; class StringConnectionsFilter extends Component { @@ -62,8 +60,7 @@ class StringConnectionsFilter extends Component { (typeof this.props.validateFunc === "function" && this.props.validateFunc(value)); } - filterChanged(event) { - let fieldValue = event.target.value; + filterChanged(fieldValue) { if (this.state.timeoutHandle !== null) { clearTimeout(this.state.timeoutHandle); } @@ -116,26 +113,10 @@ class StringConnectionsFilter extends Component { 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> - } - + <div className="filter" style={{"width": `${this.props.width}px`}}> + <InputField active={active} invalid={this.state.invalidValue} name={this.props.filterName} + placeholder={this.props.defaultFilterValue} onChange={this.filterChanged} + value={this.state.fieldValue} inline={true} small={true} /> {redirect} </div> ); diff --git a/frontend/src/components/filters/StringConnectionsFilter.scss b/frontend/src/components/filters/StringConnectionsFilter.scss deleted file mode 100644 index 1476616..0000000 --- a/frontend/src/components/filters/StringConnectionsFilter.scss +++ /dev/null @@ -1,66 +0,0 @@ -@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; - } - } -} diff --git a/frontend/src/components/objects/LinkPopover.js b/frontend/src/components/objects/LinkPopover.js new file mode 100644 index 0000000..8768caa --- /dev/null +++ b/frontend/src/components/objects/LinkPopover.js @@ -0,0 +1,33 @@ +import React, {Component} from 'react'; +import {randomClassName} from "../../utils"; +import {OverlayTrigger, Popover} from "react-bootstrap"; +import './LinkPopover.scss'; + +class LinkPopover extends Component { + + constructor(props) { + super(props); + + this.id = `link-overlay-${randomClassName()}`; + } + + render() { + const popover = ( + <Popover id={this.id}> + {this.props.title && <Popover.Title as="h3">{this.props.title}</Popover.Title>} + <Popover.Content> + {this.props.content} + </Popover.Content> + </Popover> + ); + + return (this.props.content ? + <OverlayTrigger trigger={["hover", "focus"]} placement={this.props.placement || "top"} overlay={popover}> + <span className="link-popover">{this.props.text}</span> + </OverlayTrigger> : + <span className="link-popover-empty">{this.props.text}</span> + ); + } +} + +export default LinkPopover; diff --git a/frontend/src/components/objects/LinkPopover.scss b/frontend/src/components/objects/LinkPopover.scss new file mode 100644 index 0000000..d5f4879 --- /dev/null +++ b/frontend/src/components/objects/LinkPopover.scss @@ -0,0 +1,7 @@ +@import '../../colors.scss'; + +.link-popover { + text-decoration: underline; + font-weight: 500; + cursor: pointer; +}
\ No newline at end of file diff --git a/frontend/src/components/panels/ConfigurationPane.js b/frontend/src/components/panels/ConfigurationPane.js new file mode 100644 index 0000000..10309f6 --- /dev/null +++ b/frontend/src/components/panels/ConfigurationPane.js @@ -0,0 +1,162 @@ +import React, {Component} from 'react'; +import './common.scss'; +import './ConfigurationPane.scss'; +import LinkPopover from "../objects/LinkPopover"; +import {Col, Container, Row} from "react-bootstrap"; +import InputField from "../fields/InputField"; +import TextField from "../fields/TextField"; +import ButtonField from "../fields/ButtonField"; +import CheckField from "../fields/CheckField"; +import {createCurlCommand} from "../../utils"; +import Table from "react-bootstrap/Table"; +import validation from "../../validation"; +import backend from "../../backend"; + +class ConfigurationPane extends Component { + + constructor(props) { + super(props); + this.state = { + settings: { + "config": { + "server_address": "", + "flag_regex": "", + "auth_required": false + }, + "accounts": { + } + }, + newUsername: "", + newPassword: "" + }; + } + + saveSettings = () => { + if (this.validateSettings(this.state.settings)) { + backend.post("/setup", this.state.settings).then(_ => { + this.props.onConfigured(); + }).catch(res => { + this.setState({setupStatusCode: res.status, setupResponse: JSON.stringify(res.json)}); + }); + } + }; + + validateSettings = (settings) => { + let valid = true; + if (!validation.isValidAddress(settings.config.server_address, true)) { + this.setState({serverAddressError: "invalid ip_address"}); + valid = false; + } + if (settings.config.flag_regex.length < 8) { + this.setState({flagRegexError: "flag_regex.length < 8"}); + valid = false; + } + + return valid; + }; + + updateParam = (callback) => { + callback(this.state.settings); + this.setState({settings: this.state.settings}); + }; + + addAccount = () => { + if (this.state.newUsername.length !== 0 && this.state.newPassword.length !== 0) { + const settings = this.state.settings; + settings.accounts[this.state.newUsername] = this.state.newPassword; + + this.setState({ + newUsername: "", + newPassword: "", + settings: settings + }); + } else { + this.setState({ + newUsernameActive: this.state.newUsername.length === 0, + newPasswordActive: this.state.newPassword.length === 0 + }); + } + }; + + render() { + const settings = this.state.settings; + const curlCommand = createCurlCommand("/setup", "POST", settings); + + const accounts = Object.entries(settings.accounts).map(([username, password]) => + <tr key={username}> + <td>{username}</td> + <td><LinkPopover text="******" content={password} /></td> + <td><ButtonField variant="red" small rounded name="delete" + onClick={() => this.updateParam((s) => delete s.accounts[username]) }/></td> + </tr>).concat(<tr key={"new_account"}> + <td><InputField value={this.state.newUsername} small active={this.state.newUsernameActive} + onChange={(v) => this.setState({newUsername: v})} /></td> + <td><InputField value={this.state.newPassword} small active={this.state.newPasswordActive} + onChange={(v) => this.setState({newPassword: v})} /></td> + <td><ButtonField variant="green" small rounded name="add" onClick={this.addAccount}/></td> + </tr>); + + return ( + <div className="configuration-pane"> + <div className="pane"> + <div className="pane-container"> + <div className="pane-section"> + <div className="section-header"> + <span className="api-request">POST /setup</span> + <span className="api-response"><LinkPopover text={this.state.setupStatusCode} + content={this.state.setupResponse} + placement="left" /></span> + </div> + + <div className="section-content"> + <Container className="p-0"> + <Row> + <Col> + <InputField name="server_address" value={settings.config.server_address} + error={this.state.serverAddressError} + onChange={(v) => this.updateParam((s) => s.config.server_address = v)} /> + <InputField name="flag_regex" value={settings.config.flag_regex} + onChange={(v) => this.updateParam((s) => s.config.flag_regex = v)} + error={this.state.flagRegexError} /> + <div style={{"marginTop": "10px"}}> + <CheckField checked={settings.config.auth_required} name="auth_required" + onChange={(v) => this.updateParam((s) => s.config.auth_required = v)}/> + </div> + + </Col> + + <Col> + accounts: + <div className="section-table"> + <Table borderless size="sm"> + <thead> + <tr> + <th>username</th> + <th>password</th> + <th>actions</th> + </tr> + </thead> + <tbody> + {accounts} + </tbody> + </Table> + </div> + </Col> + </Row> + </Container> + + <TextField value={curlCommand} rows={4} readonly small={true}/> + </div> + + <div className="section-footer"> + <ButtonField variant="green" name="save" bordered onClick={this.saveSettings} /> + </div> + </div> + </div> + </div> + </div> + ); + } +} + +export default ConfigurationPane; diff --git a/frontend/src/components/panels/ConfigurationPane.scss b/frontend/src/components/panels/ConfigurationPane.scss new file mode 100644 index 0000000..955d2bc --- /dev/null +++ b/frontend/src/components/panels/ConfigurationPane.scss @@ -0,0 +1,19 @@ +@import '../../colors'; + +.configuration-pane { + height: 100%; + display: flex; + align-items: center; + justify-content: center; + background-color: $color-primary-0; + + .pane { + flex-basis: 900px; + margin-bottom: 200px; + } + + .pane-container { + padding-bottom: 1px; + } + +} diff --git a/frontend/src/components/panels/MainPane.js b/frontend/src/components/panels/MainPane.js new file mode 100644 index 0000000..3202d6d --- /dev/null +++ b/frontend/src/components/panels/MainPane.js @@ -0,0 +1,56 @@ +import React, {Component} from 'react'; +import './common.scss'; +import './MainPane.scss'; +import Connections from "../../views/Connections"; +import ConnectionContent from "../ConnectionContent"; +import {Route, Switch, withRouter} from "react-router-dom"; +import PcapPane from "./PcapPane"; +import backend from "../../backend"; +import RulePane from "./RulePane"; +import ServicePane from "./ServicePane"; + +class MainPane extends Component { + + constructor(props) { + super(props); + this.state = { + selectedConnection: null, + loading: false + }; + } + + componentDidMount() { + const match = this.props.location.pathname.match(/^\/connections\/([a-f0-9]{24})$/); + if (match != null) { + this.setState({loading: true}); + backend.get(`/api/connections/${match[1]}`) + .then(res => this.setState({selectedConnection: res.json, loading: false})) + .catch(error => console.log(error)); + } + } + + render() { + return ( + <div className="main-pane"> + <div className="pane connections-pane"> + { + !this.state.loading && + <Connections onSelected={(c) => this.setState({selectedConnection: c})} + 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 />} /> + </Switch> + </div> + </div> + ); + } +} + +export default withRouter(MainPane); diff --git a/frontend/src/components/panels/MainPane.scss b/frontend/src/components/panels/MainPane.scss new file mode 100644 index 0000000..04be347 --- /dev/null +++ b/frontend/src/components/panels/MainPane.scss @@ -0,0 +1,23 @@ +@import '../../colors'; + +.main-pane { + height: 100%; + display: flex; + padding: 0 15px; + background-color: $color-primary-2; + + .pane { + flex: 1; + } + + .connections-pane { + flex: 1 0; + margin-right: 7.5px; + } + + .details-pane { + flex: 1 1; + margin-left: 7.5px; + } + +} diff --git a/frontend/src/components/panels/PcapPane.js b/frontend/src/components/panels/PcapPane.js new file mode 100644 index 0000000..7b3fde6 --- /dev/null +++ b/frontend/src/components/panels/PcapPane.js @@ -0,0 +1,251 @@ +import React, {Component} from 'react'; +import './PcapPane.scss'; +import './common.scss'; +import Table from "react-bootstrap/Table"; +import backend from "../../backend"; +import {createCurlCommand, dateTimeToTime, durationBetween, formatSize} from "../../utils"; +import InputField from "../fields/InputField"; +import CheckField from "../fields/CheckField"; +import TextField from "../fields/TextField"; +import ButtonField from "../fields/ButtonField"; +import LinkPopover from "../objects/LinkPopover"; + +class PcapPane extends Component { + + constructor(props) { + super(props); + + this.state = { + sessions: [], + isUploadFileValid: true, + isUploadFileFocused: false, + uploadFlushAll: false, + isFileValid: true, + isFileFocused: false, + fileValue: "", + processFlushAll: false, + deleteOriginalFile: false + }; + } + + componentDidMount() { + this.loadSessions(); + } + + loadSessions = () => { + backend.get("/api/pcap/sessions") + .then(res => this.setState({sessions: res.json, sessionsStatusCode: res.status})) + .catch(res => this.setState({ + sessions: res.json, sessionsStatusCode: res.status, + sessionsResponse: JSON.stringify(res.json) + })); + }; + + uploadPcap = () => { + if (this.state.uploadSelectedFile == null || !this.state.isUploadFileValid) { + this.setState({isUploadFileFocused: true}); + return; + } + + const formData = new FormData(); + formData.append("file", this.state.uploadSelectedFile); + formData.append("flush_all", this.state.uploadFlushAll); + backend.postFile("/api/pcap/upload", formData).then(res => { + this.setState({ + uploadStatusCode: res.status, + uploadResponse: JSON.stringify(res.json) + }); + this.resetUpload(); + this.loadSessions(); + }).catch(res => this.setState({ + uploadStatusCode: res.status, + uploadResponse: JSON.stringify(res.json) + }) + ); + }; + + processPcap = () => { + if (this.state.fileValue === "" || !this.state.isFileValid) { + this.setState({isFileFocused: true}); + return; + } + + backend.post("/api/pcap/file", { + file: this.state.fileValue, + flush_all: this.state.processFlushAll, + delete_original_file: this.state.deleteOriginalFile + }).then(res => { + this.setState({ + processStatusCode: res.status, + processResponse: JSON.stringify(res.json) + }); + this.resetProcess(); + this.loadSessions(); + }).catch(res => this.setState({ + processStatusCode: res.status, + processResponse: JSON.stringify(res.json) + }) + ); + }; + + resetUpload = () => { + this.setState({ + isUploadFileValid: true, + isUploadFileFocused: false, + uploadFlushAll: false, + uploadSelectedFile: null + }); + }; + + resetProcess = () => { + this.setState({ + isFileValid: true, + isFileFocused: false, + fileValue: "", + processFlushAll: false, + deleteOriginalFile: false, + }); + }; + + render() { + let sessions = this.state.sessions.map(s => + <tr key={s.id} className="table-row"> + <td>{s["id"].substring(0, 8)}</td> + <td>{dateTimeToTime(s["started_at"])}</td> + <td>{durationBetween(s["started_at"], s["completed_at"])}</td> + <td>{formatSize(s["size"])}</td> + <td>{s["processed_packets"]}</td> + <td>{s["invalid_packets"]}</td> + <td><LinkPopover text={Object.keys(s["packets_per_service"]).length + " services"} + content={JSON.stringify(s["packets_per_service"])} + placement="left"/></td> + <td className="table-cell-action"><a href={"/api/pcap/sessions/" + s["id"] + "/download"}>download</a> + </td> + </tr> + ); + + const handleUploadFileChange = (file) => { + this.setState({ + isUploadFileValid: file == null || (file.type.endsWith("pcap") || file.type.endsWith("pcapng")), + isUploadFileFocused: false, + uploadSelectedFile: file, + uploadStatusCode: null, + uploadResponse: null + }); + }; + + const handleFileChange = (file) => { + this.setState({ + isFileValid: (file.endsWith("pcap") || file.endsWith("pcapng")), + isFileFocused: false, + fileValue: file, + processStatusCode: null, + processResponse: null + }); + }; + + const uploadCurlCommand = createCurlCommand("pcap/upload", "POST", null, { + file: "@" + ((this.state.uploadSelectedFile != null && this.state.isUploadFileValid) ? + this.state.uploadSelectedFile.name : "invalid.pcap"), + flush_all: this.state.uploadFlushAll + }); + + const fileCurlCommand = createCurlCommand("pcap/file", "POST", { + file: this.state.fileValue, + flush_all: this.state.processFlushAll, + delete_original_file: this.state.deleteOriginalFile + }); + + return ( + <div className="pane-container pcap-pane"> + <div className="pane-section pcap-list"> + <div className="section-header"> + <span className="api-request">GET /api/pcap/sessions</span> + <span className="api-response"><LinkPopover text={this.state.sessionsStatusCode} + content={this.state.sessionsResponse} + placement="left"/></span> + </div> + + <div className="section-content"> + <div className="section-table"> + <Table borderless size="sm"> + <thead> + <tr> + <th>id</th> + <th>started_at</th> + <th>duration</th> + <th>size</th> + <th>processed_packets</th> + <th>invalid_packets</th> + <th>packets_per_service</th> + <th>actions</th> + </tr> + </thead> + <tbody> + {sessions} + </tbody> + </Table> + </div> + </div> + </div> + + <div className="double-pane-container"> + <div className="pane-section"> + <div className="section-header"> + <span className="api-request">POST /api/pcap/upload</span> + <span className="api-response"><LinkPopover text={this.state.uploadStatusCode} + content={this.state.uploadResponse} + placement="left"/></span> + </div> + + <div className="section-content"> + <InputField type={"file"} name={"file"} invalid={!this.state.isUploadFileValid} + active={this.state.isUploadFileFocused} + onChange={handleUploadFileChange} value={this.state.uploadSelectedFile} + placeholder={"no .pcap[ng] selected"}/> + <div className="upload-actions"> + <div className="upload-options"> + <span>options:</span> + <CheckField name="flush_all" checked={this.state.uploadFlushAll} + onChange={v => this.setState({uploadFlushAll: v})}/> + </div> + <ButtonField variant="green" bordered onClick={this.uploadPcap} name="upload"/> + </div> + + <TextField value={uploadCurlCommand} rows={4} readonly small={true}/> + </div> + </div> + + <div className="pane-section"> + <div className="section-header"> + <span className="api-request">POST /api/pcap/file</span> + <span className="api-response"><LinkPopover text={this.state.processStatusCode} + content={this.state.processResponse} + placement="left"/></span> + </div> + + <div className="section-content"> + <InputField name="file" active={this.state.isFileFocused} invalid={!this.state.isFileValid} + onChange={handleFileChange} value={this.state.fileValue} + placeholder={"local .pcap[ng] path"} inline/> + + <div className="upload-actions" style={{"marginTop": "11px"}}> + <div className="upload-options"> + <CheckField name="flush_all" checked={this.state.processFlushAll} + onChange={v => this.setState({processFlushAll: v})}/> + <CheckField name="delete_original_file" checked={this.state.deleteOriginalFile} + onChange={v => this.setState({deleteOriginalFile: v})}/> + </div> + <ButtonField variant="blue" bordered onClick={this.processPcap} name="process"/> + </div> + + <TextField value={fileCurlCommand} rows={4} readonly small={true}/> + </div> + </div> + </div> + </div> + ); + } +} + +export default PcapPane; diff --git a/frontend/src/components/panels/PcapPane.scss b/frontend/src/components/panels/PcapPane.scss new file mode 100644 index 0000000..721560a --- /dev/null +++ b/frontend/src/components/panels/PcapPane.scss @@ -0,0 +1,39 @@ +@import '../../colors.scss'; + +.pcap-pane { + display: flex; + flex-direction: column; + + .pcap-list { + flex: 1; + overflow: hidden; + + .section-content { + height: 100%; + } + + .section-table { + height: calc(100% - 30px); + } + + .table-cell-action { + font-size: 13px; + font-weight: 600; + } + } + + .upload-actions { + display: flex; + align-items: flex-end; + margin-bottom: 20px; + } + + .upload-options { + flex: 1; + + span { + font-size: 0.9em; + } + } + +} diff --git a/frontend/src/components/panels/RulePane.js b/frontend/src/components/panels/RulePane.js new file mode 100644 index 0000000..49364d2 --- /dev/null +++ b/frontend/src/components/panels/RulePane.js @@ -0,0 +1,413 @@ +import React, {Component} from 'react'; +import './common.scss'; +import './RulePane.scss'; +import Table from "react-bootstrap/Table"; +import {Col, Container, Row} from "react-bootstrap"; +import InputField from "../fields/InputField"; +import CheckField from "../fields/CheckField"; +import TextField from "../fields/TextField"; +import backend from "../../backend"; +import NumericField from "../fields/extensions/NumericField"; +import ColorField from "../fields/extensions/ColorField"; +import ChoiceField from "../fields/ChoiceField"; +import ButtonField from "../fields/ButtonField"; +import validation from "../../validation"; +import LinkPopover from "../objects/LinkPopover"; +import {randomClassName} from "../../utils"; + +const classNames = require('classnames'); +const _ = require('lodash'); + +class RulePane extends Component { + + constructor(props) { + super(props); + + this.state = { + rules: [], + newRule: this.emptyRule, + newPattern: this.emptyPattern + }; + + this.directions = { + 0: "both", + 1: "c->s", + 2: "s->c" + }; + } + + componentDidMount() { + this.reset(); + this.loadRules(); + } + + emptyRule = { + "name": "", + "color": "", + "notes": "", + "enabled": true, + "patterns": [], + "filter": { + "service_port": 0, + "client_address": "", + "client_port": 0, + "min_duration": 0, + "max_duration": 0, + "min_bytes": 0, + "max_bytes": 0 + }, + "version": 0 + }; + + emptyPattern = { + "regex": "", + "flags": { + "caseless": false, + "dot_all": false, + "multi_line": false, + "utf_8_mode": false, + "unicode_property": false + }, + "min_occurrences": 0, + "max_occurrences": 0, + "direction": 0 + }; + + loadRules = () => { + backend.get("/api/rules").then(res => this.setState({rules: res.json, rulesStatusCode: res.status})) + .catch(res => this.setState({rulesStatusCode: res.status, rulesResponse: JSON.stringify(res.json)})); + }; + + addRule = () => { + if (this.validateRule(this.state.newRule)) { + backend.post("/api/rules", this.state.newRule).then(res => { + this.reset(); + this.setState({ruleStatusCode: res.status}); + this.loadRules(); + }).catch(res => { + this.setState({ruleStatusCode: res.status, ruleResponse: JSON.stringify(res.json)}); + }); + } + }; + + updateRule = () => { + const rule = this.state.selectedRule; + if (this.validateRule(rule)) { + backend.put(`/api/rules/${rule.id}`, rule).then(res => { + this.reset(); + this.setState({ruleStatusCode: res.status}); + this.loadRules(); + }).catch(res => { + this.setState({ruleStatusCode: res.status, ruleResponse: JSON.stringify(res.json)}); + }); + } + }; + + validateRule = (rule) => { + let valid = true; + if (rule.name.length < 3) { + this.setState({ruleNameError: "name.length < 3"}); + valid = false; + } + if (!validation.isValidColor(rule.color)) { + this.setState({ruleColorError: "color is not hexcolor"}); + valid = false; + } + if (!validation.isValidPort(rule.filter.service_port)) { + this.setState({ruleServicePortError: "service_port > 65565"}); + valid = false; + } + if (!validation.isValidPort(rule.filter.client_port)) { + this.setState({ruleClientPortError: "client_port > 65565"}); + valid = false; + } + if (!validation.isValidAddress(rule.filter.client_address)) { + this.setState({ruleClientAddressError: "client_address is not ip_address"}); + valid = false; + } + if (rule.filter.min_duration > rule.filter.max_duration) { + this.setState({ruleDurationError: "min_duration > max_dur."}); + valid = false; + } + if (rule.filter.min_bytes > rule.filter.max_bytes) { + this.setState({ruleBytesError: "min_bytes > max_bytes"}); + valid = false; + } + if (rule.patterns.length < 1) { + this.setState({rulePatternsError: "patterns.length < 1"}); + valid = false; + } + + return valid; + }; + + reset = () => { + const newRule = _.cloneDeep(this.emptyRule); + const newPattern = _.cloneDeep(this.emptyPattern); + this.setState({ + selectedRule: null, + newRule: newRule, + selectedPattern: null, + newPattern: newPattern, + patternRegexFocused: false, + patternOccurrencesFocused: false, + ruleNameError: null, + ruleColorError: null, + ruleServicePortError: null, + ruleClientPortError: null, + ruleClientAddressError: null, + ruleDurationError: null, + ruleBytesError: null, + rulePatternsError: null, + ruleStatusCode: null, + rulesStatusCode: null, + ruleResponse: null, + rulesResponse: null + }); + }; + + updateParam = (callback) => { + const updatedRule = this.currentRule(); + callback(updatedRule); + this.setState({newRule: updatedRule}); + }; + + currentRule = () => this.state.selectedRule != null ? this.state.selectedRule : this.state.newRule; + + addPattern = (pattern) => { + if (!this.validatePattern(pattern)) { + return; + } + + const newPattern = _.cloneDeep(this.emptyPattern); + this.currentRule().patterns.push(pattern); + this.setState({ + newPattern: newPattern + }); + }; + + editPattern = (pattern) => { + this.setState({ + selectedPattern: pattern + }); + }; + + updatePattern = (pattern) => { + if (!this.validatePattern(pattern)) { + return; + } + + this.setState({ + selectedPattern: null + }); + }; + + validatePattern = (pattern) => { + let valid = true; + if (pattern.regex === "") { + valid = false; + this.setState({patternRegexFocused: true}); + } + if (pattern.min_occurrences > pattern.max_occurrences) { + valid = false; + this.setState({patternOccurrencesFocused: true}); + } + return valid; + }; + + render() { + const isUpdate = this.state.selectedRule != null; + const rule = this.currentRule(); + const pattern = this.state.selectedPattern || this.state.newPattern; + + let rules = this.state.rules.map(r => + <tr key={r.id} onClick={() => { + this.reset(); + this.setState({selectedRule: _.cloneDeep(r)}); + }} className={classNames("row-small", "row-clickable", {"row-selected": rule.id === r.id })}> + <td>{r["id"].substring(0, 8)}</td> + <td>{r["name"]}</td> + <td><ButtonField name={r["color"]} color={r["color"]} small /></td> + <td>{r["notes"]}</td> + </tr> + ); + + let patterns = (this.state.selectedPattern == null && !isUpdate ? + rule.patterns.concat(this.state.newPattern) : + rule.patterns + ).map(p => p === pattern ? + <tr key={randomClassName()}> + <td style={{"width": "500px"}}> + <InputField small active={this.state.patternRegexFocused} value={pattern.regex} + onChange={(v) => { + this.updateParam(() => pattern.regex = v); + this.setState({patternRegexFocused: pattern.regex === ""}); + }} /> + </td> + <td><CheckField small checked={pattern.flags.caseless} + onChange={(v) => this.updateParam(() => pattern.flags.caseless = v)}/></td> + <td><CheckField small checked={pattern.flags.dot_all} + onChange={(v) => this.updateParam(() => pattern.flags.dot_all = v)}/></td> + <td><CheckField small checked={pattern.flags.multi_line} + onChange={(v) => this.updateParam(() => pattern.flags.multi_line = v)}/></td> + <td><CheckField small checked={pattern.flags.utf_8_mode} + onChange={(v) => this.updateParam(() => pattern.flags.utf_8_mode = v)}/></td> + <td><CheckField small checked={pattern.flags.unicode_property} + onChange={(v) => this.updateParam(() => pattern.flags.unicode_property = v)}/></td> + <td style={{"width": "70px"}}> + <NumericField small value={pattern.min_occurrences} + active={this.state.patternOccurrencesFocused} + onChange={(v) => this.updateParam(() => pattern.min_occurrences = v)} /> + </td> + <td style={{"width": "70px"}}> + <NumericField small value={pattern.max_occurrences} + active={this.state.patternOccurrencesFocused} + onChange={(v) => this.updateParam(() => pattern.max_occurrences = v)} /> + </td> + <td><ChoiceField inline small keys={[0, 1, 2]} values={["both", "c->s", "s->c"]} + value={this.directions[pattern.direction]} + onChange={(v) => this.updateParam(() => pattern.direction = v)} /></td> + <td>{this.state.selectedPattern == null ? + <ButtonField variant="green" small name="add" inline rounded onClick={() => this.addPattern(p)}/> : + <ButtonField variant="green" small name="save" inline rounded onClick={() => this.updatePattern(p)}/>} + </td> + </tr> + : + <tr key={"new_pattern"} className="row-small"> + <td>{p.regex}</td> + <td>{p.flags.caseless ? "yes": "no"}</td> + <td>{p.flags.dot_all ? "yes": "no"}</td> + <td>{p.flags.multi_line ? "yes": "no"}</td> + <td>{p.flags.utf_8_mode ? "yes": "no"}</td> + <td>{p.flags.unicode_property ? "yes": "no"}</td> + <td>{p.min_occurrences}</td> + <td>{p.max_occurrences}</td> + <td>{this.directions[p.direction]}</td> + {!isUpdate && <td><ButtonField variant="blue" small rounded name="edit" + onClick={() => this.editPattern(p) }/></td>} + </tr> + ); + + return ( + <div className="pane-container rule-pane"> + <div className="pane-section rules-list"> + <div className="section-header"> + <span className="api-request">GET /api/rules</span> + {this.state.rulesStatusCode && + <span className="api-response"><LinkPopover text={this.state.rulesStatusCode} + content={this.state.rulesResponse} + placement="left" /></span>} + </div> + + <div className="section-content"> + <div className="section-table"> + <Table borderless size="sm"> + <thead> + <tr> + <th>id</th> + <th>name</th> + <th>color</th> + <th>notes</th> + </tr> + </thead> + <tbody> + {rules} + </tbody> + </Table> + </div> + </div> + </div> + + <div className="pane-section rule-edit"> + <div className="section-header"> + <span className="api-request"> + {isUpdate ? `PUT /api/rules/${this.state.selectedRule.id}` : "POST /api/rules"} + </span> + <span className="api-response"><LinkPopover text={this.state.ruleStatusCode} + content={this.state.ruleResponse} + placement="left" /></span> + </div> + + <div className="section-content"> + <Container className="p-0"> + <Row> + <Col> + <InputField name="name" inline value={rule.name} + onChange={(v) => this.updateParam((r) => r.name = v)} + error={this.state.ruleNameError} /> + <ColorField inline value={rule.color} error={this.state.ruleColorError} + onChange={(v) => this.updateParam((r) => r.color = v)} /> + <TextField name="notes" rows={2} value={rule.notes} + onChange={(v) => this.updateParam((r) => r.notes = v)} /> + </Col> + + <Col style={{"paddingTop": "6px"}}> + <span>filters:</span> + <NumericField name="service_port" inline value={rule.filter.service_port} + onChange={(v) => this.updateParam((r) => r.filter.service_port = v)} + min={0} max={65565} error={this.state.ruleServicePortError} + readonly={isUpdate} /> + <NumericField name="client_port" inline value={rule.filter.client_port} + onChange={(v) => this.updateParam((r) => r.filter.client_port = v)} + min={0} max={65565} error={this.state.ruleClientPortError} + readonly={isUpdate} /> + <InputField name="client_address" value={rule.filter.client_address} + error={this.state.ruleClientAddressError} readonly={isUpdate} + onChange={(v) => this.updateParam((r) => r.filter.client_address = v)} /> + </Col> + + <Col style={{"paddingTop": "11px"}}> + <NumericField name="min_duration" inline value={rule.filter.min_duration} + error={this.state.ruleDurationError} readonly={isUpdate} + onChange={(v) => this.updateParam((r) => r.filter.min_duration = v)} /> + <NumericField name="max_duration" inline value={rule.filter.max_duration} + error={this.state.ruleDurationError} readonly={isUpdate} + onChange={(v) => this.updateParam((r) => r.filter.max_duration = v)} /> + <NumericField name="min_bytes" inline value={rule.filter.min_bytes} + error={this.state.ruleBytesError} readonly={isUpdate} + onChange={(v) => this.updateParam((r) => r.filter.min_bytes = v)} /> + <NumericField name="max_bytes" inline value={rule.filter.max_bytes} + error={this.state.ruleBytesError} readonly={isUpdate} + onChange={(v) => this.updateParam((r) => r.filter.max_bytes = v)} /> + </Col> + </Row> + </Container> + + <div className="section-table"> + <Table borderless size="sm"> + <thead> + <tr> + <th>regex</th> + <th>!Aa</th> + <th>.*</th> + <th>\n+</th> + <th>UTF8</th> + <th>Uni_</th> + <th>min</th> + <th>max</th> + <th>direction</th> + {!isUpdate && <th>actions</th> } + </tr> + </thead> + <tbody> + {patterns} + </tbody> + </Table> + {this.state.rulePatternsError != null && + <span className="table-error">error: {this.state.rulePatternsError}</span>} + </div> + </div> + + <div className="section-footer"> + {<ButtonField variant="red" name="cancel" bordered onClick={this.reset}/>} + <ButtonField variant={isUpdate ? "blue" : "green"} name={isUpdate ? "update_rule" : "add_rule"} + bordered onClick={isUpdate ? this.updateRule : this.addRule} /> + </div> + </div> + </div> + ); + } + +} + +export default RulePane; diff --git a/frontend/src/components/panels/RulePane.scss b/frontend/src/components/panels/RulePane.scss new file mode 100644 index 0000000..d45c366 --- /dev/null +++ b/frontend/src/components/panels/RulePane.scss @@ -0,0 +1,32 @@ + +.rule-pane { + display: flex; + flex-direction: column; + + .rules-list { + flex: 2 1; + overflow: hidden; + + .section-content { + height: 100%; + } + + .section-table { + height: calc(100% - 30px); + } + } + + .rule-edit { + flex: 3 0; + display: flex; + flex-direction: column; + + .section-content { + flex: 1; + } + + .section-table { + max-height: 150px; + } + } +} diff --git a/frontend/src/components/panels/ServicePane.js b/frontend/src/components/panels/ServicePane.js new file mode 100644 index 0000000..eaefa64 --- /dev/null +++ b/frontend/src/components/panels/ServicePane.js @@ -0,0 +1,190 @@ +import React, {Component} from 'react'; +import './common.scss'; +import './ServicePane.scss'; +import Table from "react-bootstrap/Table"; +import {Col, Container, Row} from "react-bootstrap"; +import InputField from "../fields/InputField"; +import TextField from "../fields/TextField"; +import backend from "../../backend"; +import NumericField from "../fields/extensions/NumericField"; +import ColorField from "../fields/extensions/ColorField"; +import ButtonField from "../fields/ButtonField"; +import validation from "../../validation"; +import LinkPopover from "../objects/LinkPopover"; +import {createCurlCommand} from "../../utils"; + +const classNames = require('classnames'); +const _ = require('lodash'); + +class ServicePane extends Component { + + constructor(props) { + super(props); + + this.state = { + services: [], + currentService: this.emptyService, + }; + } + + componentDidMount() { + this.reset(); + this.loadServices(); + } + + emptyService = { + "port": 0, + "name": "", + "color": "", + "notes": "" + }; + + loadServices = () => { + backend.get("/api/services") + .then(res => this.setState({services: Object.values(res.json), servicesStatusCode: res.status})) + .catch(res => this.setState({servicesStatusCode: res.status, servicesResponse: JSON.stringify(res.json)})); + }; + + updateService = () => { + const service = this.state.currentService; + if (this.validateService(service)) { + backend.put("/api/services", service).then(res => { + this.reset(); + this.setState({serviceStatusCode: res.status}); + this.loadServices(); + }).catch(res => { + this.setState({serviceStatusCode: res.status, serviceResponse: JSON.stringify(res.json)}); + }); + } + }; + + validateService = (service) => { + let valid = true; + if (!validation.isValidPort(service.port, true)) { + this.setState({servicePortError: "port < 0 || port > 65565"}); + valid = false; + } + if (service.name.length < 3) { + this.setState({serviceNameError: "name.length < 3"}); + valid = false; + } + if (!validation.isValidColor(service.color)) { + this.setState({serviceColorError: "color is not hexcolor"}); + valid = false; + } + + return valid; + }; + + reset = () => { + this.setState({ + isUpdate: false, + currentService: _.cloneDeep(this.emptyService), + servicePortError: null, + serviceNameError: null, + serviceColorError: null, + serviceStatusCode: null, + servicesStatusCode: null, + serviceResponse: null, + servicesResponse: null + }); + }; + + updateParam = (callback) => { + callback(this.state.currentService); + this.setState({currentService: this.state.currentService}); + }; + + render() { + const isUpdate = this.state.isUpdate; + const service = this.state.currentService; + + let services = this.state.services.map(s => + <tr key={s.port} onClick={() => { + this.reset(); + this.setState({isUpdate: true, currentService: _.cloneDeep(s)}); + }} className={classNames("row-small", "row-clickable", {"row-selected": service.port === s.port })}> + <td>{s["port"]}</td> + <td>{s["name"]}</td> + <td><ButtonField name={s["color"]} color={s["color"]} small /></td> + <td>{s["notes"]}</td> + </tr> + ); + + const curlCommand = createCurlCommand("/services", "PUT", service); + + return ( + <div className="pane-container service-pane"> + <div className="pane-section services-list"> + <div className="section-header"> + <span className="api-request">GET /api/services</span> + {this.state.servicesStatusCode && + <span className="api-response"><LinkPopover text={this.state.servicesStatusCode} + content={this.state.servicesResponse} + placement="left" /></span>} + </div> + + <div className="section-content"> + <div className="section-table"> + <Table borderless size="sm"> + <thead> + <tr> + <th>port</th> + <th>name</th> + <th>color</th> + <th>notes</th> + </tr> + </thead> + <tbody> + {services} + </tbody> + </Table> + </div> + </div> + </div> + + <div className="pane-section service-edit"> + <div className="section-header"> + <span className="api-request">PUT /api/services</span> + <span className="api-response"><LinkPopover text={this.state.serviceStatusCode} + content={this.state.serviceResponse} + placement="left" /></span> + </div> + + <div className="section-content"> + <Container className="p-0"> + <Row> + <Col> + <NumericField name="port" value={service.port} + onChange={(v) => this.updateParam((s) => s.port = v)} + min={0} max={65565} error={this.state.servicePortError} /> + <InputField name="name" value={service.name} + onChange={(v) => this.updateParam((s) => s.name = v)} + error={this.state.serviceNameError} /> + <ColorField value={service.color} error={this.state.serviceColorError} + onChange={(v) => this.updateParam((s) => s.color = v)} /> + </Col> + + <Col> + <TextField name="notes" rows={7} value={service.notes} + onChange={(v) => this.updateParam((s) => s.notes = v)} /> + </Col> + </Row> + </Container> + + <TextField value={curlCommand} rows={3} readonly small={true}/> + </div> + + <div className="section-footer"> + {<ButtonField variant="red" name="cancel" bordered onClick={this.reset}/>} + <ButtonField variant={isUpdate ? "blue" : "green"} name={isUpdate ? "update_service" : "add_service"} + bordered onClick={this.updateService} /> + </div> + </div> + </div> + ); + } + +} + +export default ServicePane; diff --git a/frontend/src/components/panels/ServicePane.scss b/frontend/src/components/panels/ServicePane.scss new file mode 100644 index 0000000..0b154e6 --- /dev/null +++ b/frontend/src/components/panels/ServicePane.scss @@ -0,0 +1,22 @@ + +.service-pane { + display: flex; + flex-direction: column; + + .services-list { + flex: 1; + overflow: hidden; + + .section-content { + height: 100%; + } + + .section-table { + height: calc(100% - 30px); + } + } + + .service-edit { + flex: 0; + } +} diff --git a/frontend/src/components/panels/common.scss b/frontend/src/components/panels/common.scss new file mode 100644 index 0000000..ea8da94 --- /dev/null +++ b/frontend/src/components/panels/common.scss @@ -0,0 +1,101 @@ +@import '../../colors.scss'; + +.pane-container { + background-color: $color-primary-3; + padding: 10px 10px 0; + height: 100%; + + .pane-section { + margin-bottom: 10px; + background-color: $color-primary-0; + + .section-header { + background-color: $color-primary-2; + padding: 5px 10px; + font-weight: 500; + font-size: 0.9em; + display: flex; + + .api-request { + flex: 1; + } + } + + .section-content { + padding: 10px; + } + + .section-table { + position: relative; + overflow-y: scroll; + + .table-error { + font-size: 0.8em; + color: $color-secondary-0; + margin-left: 10px; + } + } + + table { + margin-bottom: 0; + + tbody tr { + background-color: $color-primary-3; + border-top: 3px solid $color-primary-0; + border-bottom: 3px solid $color-primary-0; + } + + th { + background-color: $color-primary-2; + font-size: 0.8em; + position: sticky; + top: 0; + padding: 2px 5px; + border: none; + } + + .row-small { + font-size: 0.9em; + } + + .row-clickable { + cursor: pointer; + + &:hover { + background-color: $color-primary-0; + } + } + + .row-selected { + background-color: $color-primary-0; + } + } + } + + .section-footer { + display: flex; + padding: 10px; + background-color: $color-primary-0; + justify-content: flex-end; + + .button-field { + margin-left: 10px; + } + } + + .double-pane-container { + display: flex; + + .pane-section { + flex: 1; + } + + .pane-section:nth-child(1) { + margin-right: 5px; + } + + .pane-section:nth-child(2) { + margin-left: 5px; + } + } +} diff --git a/frontend/src/index.scss b/frontend/src/index.scss index 53ce4dd..9ba23de 100644 --- a/frontend/src/index.scss +++ b/frontend/src/index.scss @@ -11,8 +11,8 @@ body { -moz-osx-font-smoothing: grayscale; background-color: $color-primary-2; color: $color-primary-4; - height: 100%; - max-height: 100%; + height: 100vh; + font-size: 100%; } pre { @@ -20,70 +20,6 @@ pre { font-size: 14px; } -.btn { - border-radius: 0; - background-color: $color-primary-2; - border: none; - border-bottom: 5px solid $color-primary-1; - color: $color-primary-4; - outline: none; - padding: 5px 12px; - font-weight: 500; - - &:hover, - &:active { - background-color: $color-primary-1; - color: $color-primary-4; - } - - &:focus, - &:active { - outline: none !important; - box-shadow: none !important; - } -} - -.btn-sm { - border: none; - font-size: 12px; -} - -.btn-red { - color: $color-secondary-4; - background-color: $color-secondary-2; - border-bottom: 5px solid $color-secondary-1; - - &:hover, - &:active { - color: $color-secondary-4; - background-color: $color-secondary-1; - } -} - -.btn-blue { - color: $color-blue-light; - background-color: $color-blue; - border-bottom: 5px solid $color-blue-dark; - - &:hover, - &:active { - color: $color-blue-light; - background-color: $color-blue-dark; - } -} - -.btn-green { - color: $color-green-light; - background-color: $color-green; - border-bottom: 5px solid $color-green-dark; - - &:hover, - &:active { - color: $color-green-light; - background-color: $color-green-dark; - } -} - a { color: $color-primary-4; @@ -130,36 +66,6 @@ a { } } -textarea.form-control { - resize: none; -} - -input.form-control, -textarea.form-control { - background-color: $color-primary-2; - border: none; - color: $color-primary-4; - font-family: 'Fira Code', monospace; - - &:focus { - background-color: $color-primary-1; - color: $color-primary-4; - box-shadow: none; - } - - &[readonly] { - background-color: $color-primary-2; - border: none; - color: $color-primary-4; - } - - &[readonly]:focus { - background-color: $color-primary-1; - color: $color-primary-4; - box-shadow: none; - } -} - .table { color: $color-primary-4; } @@ -167,3 +73,7 @@ textarea.form-control { .text-muted { color: $color-primary-4 !important; } + +.popover-header { + color: $color-primary-1; +}
\ No newline at end of file diff --git a/frontend/src/utils.js b/frontend/src/utils.js index 7381f69..e71067a 100644 --- a/frontend/src/utils.js +++ b/frontend/src/utils.js @@ -1,9 +1,19 @@ const timeRegex = /^(0[0-9]|1[0-9]|2[0-3]):([0-5][0-9]):([0-5][0-9])$/; -export function createCurlCommand(subCommand, data) { - let full = window.location.protocol + '//' + window.location.hostname + (window.location.port ? ':' + window.location.port : ''); - return `curl --request PUT \\\n --url ${full}/api${subCommand} \\\n ` + - `--header 'content-type: application/json' \\\n --data '${JSON.stringify(data)}'`; +export function createCurlCommand(subCommand, method = null, json = null, data = null) { + const full = window.location.protocol + '//' + window.location.hostname + (window.location.port ? ':' + window.location.port : ''); + + let contentType = null; + let content = null; + if (json != null) { + contentType = ' -H "Content-Type: application/json" \\\n'; + content = ` -d '${JSON.stringify(json)}'`; + } else if (data != null) { + contentType = ' -H "Content-Type: multipart/form-data" \\\n'; + content = " " + Object.entries(data).map(([key, value]) => `-F "${key}=${value}"`).join(" \\\n "); + } + + return `curl${method != null ? " -X " + method : ""} "${full}/api${subCommand}" \\\n` + contentType + "" + content; } export function validateIpAddress(ipAddress) { @@ -64,3 +74,40 @@ export function timestampToDateTime(timestamp) { let d = new Date(timestamp); return d.toLocaleDateString() + " " + d.toLocaleTimeString(); } + +export function dateTimeToTime(dateTime) { + if (typeof dateTime === "string") { + dateTime = new Date(dateTime); + } + + let hours = dateTime.getHours(); + let minutes = "0" + dateTime.getMinutes(); + let seconds = "0" + dateTime.getSeconds(); + return hours + ':' + minutes.substr(-2) + ':' + seconds.substr(-2); +} + +export function durationBetween(from, to) { + if (typeof from === "string") { + from = new Date(from); + } + if (typeof to === "string") { + to = new Date(to); + } + const duration = ((to - from) / 1000).toFixed(3); + + return (duration > 1000 || duration < -1000) ? "∞" : duration + "s"; +} + +export function formatSize(size) { + if (size < 1000) { + return `${size}`; + } else if (size < 1000000) { + return `${(size / 1000).toFixed(1)}K`; + } else { + return `${(size / 1000000).toFixed(1)}M`; + } +} + +export function randomClassName() { + return Math.random().toString(36).slice(2); +} diff --git a/frontend/src/validation.js b/frontend/src/validation.js new file mode 100644 index 0000000..8f3409f --- /dev/null +++ b/frontend/src/validation.js @@ -0,0 +1,8 @@ + +const validation = { + isValidColor: (color) => /^#(?:[0-9a-fA-F]{3}){1,2}$/.test(color), + isValidPort: (port, required) => parseInt(port) > (required ? 0 : -1) && parseInt(port) <= 65565, + isValidAddress: (address) => true // TODO +}; + +export default validation; diff --git a/frontend/src/views/App.js b/frontend/src/views/App.js index 6c101fa..fb4454c 100644 --- a/frontend/src/views/App.js +++ b/frontend/src/views/App.js @@ -1,49 +1,46 @@ import React, {Component} from 'react'; +import './App.scss'; import Header from "./Header"; -import MainPane from "./MainPane"; +import MainPane from "../components/panels/MainPane"; import Footer from "./Footer"; -import {BrowserRouter as Router, Route, Switch} from "react-router-dom"; -import Services from "./Services"; +import {BrowserRouter as Router} from "react-router-dom"; import Filters from "./Filters"; -import Rules from "./Rules"; +import backend from "../backend"; +import ConfigurationPane from "../components/panels/ConfigurationPane"; class App extends Component { constructor(props) { super(props); - this.state = { - servicesWindowOpen: false, - filterWindowOpen: false, - rulesWindowOpen: false - }; + + this.state = {}; + } + + componentDidMount() { + backend.get("/api/services").then(_ => this.setState({configured: true})); } render() { let modal; - if (this.state.servicesWindowOpen) { - modal = <Services onHide={() => this.setState({servicesWindowOpen: false})}/>; - } - if (this.state.filterWindowOpen) { + if (this.state.filterWindowOpen && this.state.configured) { modal = <Filters onHide={() => this.setState({filterWindowOpen: false})}/>; } - if (this.state.rulesWindowOpen) { - modal = <Rules onHide={() => this.setState({rulesWindowOpen: false})}/>; - } return ( - <div className="app"> + <div className="main"> <Router> - <Header onOpenServices={() => this.setState({servicesWindowOpen: true})} - onOpenFilters={() => this.setState({filterWindowOpen: true})} - onOpenRules={() => this.setState({rulesWindowOpen: true})} /> - <Switch> - <Route path="/connections/:id" children={<MainPane/>}/> - <Route path="/" children={<MainPane/>}/> - </Switch> - {modal} - <Footer/> + <div className="main-header"> + <Header onOpenFilters={() => this.setState({filterWindowOpen: true})} /> + </div> + <div className="main-content"> + {this.state.configured ? <MainPane /> : + <ConfigurationPane onConfigured={() => this.setState({configured: true})} />} + {modal} + </div> + <div className="main-footer"> + {this.state.configured && <Footer/>} + </div> </Router> - </div> ); } diff --git a/frontend/src/views/App.scss b/frontend/src/views/App.scss new file mode 100644 index 0000000..b25d4c9 --- /dev/null +++ b/frontend/src/views/App.scss @@ -0,0 +1,15 @@ + +.main { + display: flex; + flex-direction: column; + height: 100vh; + + .main-content { + flex: 1 1; + overflow: hidden; + } + + .main-header, .main-footer { + flex: 0 0; + } +} diff --git a/frontend/src/views/Connections.js b/frontend/src/views/Connections.js index 62733d7..73979c4 100644 --- a/frontend/src/views/Connections.js +++ b/frontend/src/views/Connections.js @@ -1,10 +1,11 @@ import React, {Component} from 'react'; import './Connections.scss'; -import axios from 'axios'; import Connection from "../components/Connection"; import Table from 'react-bootstrap/Table'; import {Redirect} from 'react-router'; import {withRouter} from "react-router-dom"; +import backend from "../backend"; +import ConnectionMatchedRules from "../components/ConnectionMatchedRules"; class Connections extends Component { @@ -25,21 +26,21 @@ class Connections extends Component { this.scrollBottomThreashold = 0.99999; this.maxConnections = 500; this.queryLimit = 50; - - this.handleScroll = this.handleScroll.bind(this); - this.connectionSelected = this.connectionSelected.bind(this); - this.addServicePortFilter = this.addServicePortFilter.bind(this); } componentDidMount() { this.loadConnections({limit: this.queryLimit}) .then(() => this.setState({loaded: true})); + if (this.props.initialConnection != null) { + this.setState({selected: this.props.initialConnection.id}); + // TODO: scroll to initial connection + } } - connectionSelected(c) { + connectionSelected = (c) => { this.setState({selected: c.id}); this.props.onSelected(c); - } + }; componentDidUpdate(prevProps, prevState, snapshot) { if (this.state.loaded && prevProps.location.search !== this.props.location.search) { @@ -49,7 +50,7 @@ class Connections extends Component { } } - handleScroll(e) { + handleScroll = (e) => { 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,}) @@ -59,13 +60,23 @@ class Connections extends Component { this.loadConnections({to: this.state.firstConnection.id, limit: this.queryLimit,}) .then(() => console.log("Previous connections loaded")); } - } + }; - addServicePortFilter(port) { - let urlParams = new URLSearchParams(this.props.location.search); + addServicePortFilter = (port) => { + const urlParams = new URLSearchParams(this.props.location.search); urlParams.set("service_port", port); this.setState({queryString: "?" + urlParams}); - } + }; + + addMatchedRulesFilter = (matchedRule) => { + const urlParams = new URLSearchParams(this.props.location.search); + const oldMatchedRules = urlParams.getAll("matched_rules") || []; + + if (!oldMatchedRules.includes(matchedRule)) { + urlParams.append("matched_rules", matchedRule); + this.setState({queryString: "?" + urlParams}); + } + }; async loadConnections(params) { let url = "/api/connections"; @@ -75,15 +86,15 @@ class Connections extends Component { } this.setState({loading: true, prevParams: params}); - let res = await axios.get(`${url}?${urlParams}`); + 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 (res.data.length > 0) { - connections = this.state.connections.concat(res.data); + if (res.length > 0) { + connections = this.state.connections.concat(res); lastConnection = connections[connections.length - 1]; if (connections.length > this.maxConnections) { connections = connections.slice(connections.length - this.maxConnections, @@ -92,8 +103,8 @@ class Connections extends Component { } } } else if (params !== undefined && params.to !== undefined) { - if (res.data.length > 0) { - connections = res.data.concat(this.state.connections); + if (res.length > 0) { + connections = res.concat(this.state.connections); firstConnection = connections[0]; if (connections.length > this.maxConnections) { connections = connections.slice(0, this.maxConnections); @@ -101,8 +112,8 @@ class Connections extends Component { } } } else { - if (res.data.length > 0) { - connections = res.data; + if (res.length > 0) { + connections = res; firstConnection = connections[0]; lastConnection = connections[connections.length - 1]; } else { @@ -112,21 +123,15 @@ class Connections extends Component { } } - let flagRule = this.state.flagRule; let rules = this.state.rules; - if (flagRule === null) { - let res = await axios.get("/api/rules"); - rules = res.data; - flagRule = rules.filter(rule => { - return rule.name === "flag"; - })[0]; + if (rules === null) { + rules = (await backend.get("/api/rules")).json; } this.setState({ loading: false, connections: connections, - rules: res.data, - flagRule: flagRule, + rules: rules, firstConnection: firstConnection, lastConnection: lastConnection }); @@ -134,7 +139,7 @@ class Connections extends Component { render() { let redirect; - let queryString = this.state.queryString !== null ? this.state.queryString : "" + let queryString = this.state.queryString !== null ? this.state.queryString : ""; if (this.state.selected) { let format = this.props.match.params.format; format = format !== undefined ? "/" + format : ""; @@ -159,6 +164,7 @@ class Connections extends Component { <th>srcport</th> <th>dstip</th> <th>dstport</th> + <th>started_at</th> <th>duration</th> <th>up</th> <th>down</th> @@ -167,13 +173,18 @@ class Connections extends Component { </thead> <tbody> { - this.state.connections.map(c => - <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} - containsFlag={c.matched_rules.includes(this.state.flagRule.id)} - addServicePortFilter={this.addServicePortFilter}/> - ) + 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> diff --git a/frontend/src/views/Filters.js b/frontend/src/views/Filters.js index 78f0342..ba7d467 100644 --- a/frontend/src/views/Filters.js +++ b/frontend/src/views/Filters.js @@ -1,7 +1,7 @@ import React, {Component} from 'react'; -import './Services.scss'; -import {Button, Col, Container, Modal, Row, Table} from "react-bootstrap"; +import {Col, Container, Modal, Row, Table} from "react-bootstrap"; import {filtersDefinitions, filtersNames} from "../components/filters/FiltersDefinitions"; +import ButtonField from "../components/fields/ButtonField"; class Filters extends Component { @@ -89,7 +89,7 @@ class Filters extends Component { </Container> </Modal.Body> <Modal.Footer className="dialog-footer"> - <Button variant="red" onClick={this.props.onHide}>close</Button> + <ButtonField variant="red" bordered onClick={this.props.onHide} name="close" /> </Modal.Footer> </Modal> ); diff --git a/frontend/src/views/Footer.js b/frontend/src/views/Footer.js index b6ffd9d..0a3c5a3 100644 --- a/frontend/src/views/Footer.js +++ b/frontend/src/views/Footer.js @@ -8,11 +8,11 @@ class Footer extends Component { <footer className="footer container-fluid"> <div className="row"> <div className="col-12"> - <div className="footer-timeline">timeline</div> + <div className="footer-timeline">timeline - <a href="https://github.com/eciavatta/caronte/issues/12">to be implemented</a></div> </div> </div> </footer> - ) + ); } } diff --git a/frontend/src/views/Header.js b/frontend/src/views/Header.js index 5d0f690..944f1d5 100644 --- a/frontend/src/views/Header.js +++ b/frontend/src/views/Header.js @@ -1,8 +1,9 @@ import React, {Component} from 'react'; import Typed from 'typed.js'; import './Header.scss'; -import {Button} from "react-bootstrap"; import {filtersDefinitions, filtersNames} from "../components/filters/FiltersDefinitions"; +import {Link} from "react-router-dom"; +import ButtonField from "../components/fields/ButtonField"; class Header extends Component { @@ -45,7 +46,7 @@ class Header extends Component { render() { let quickFilters = filtersNames.filter(name => this.state[`${name}_active`]) - .map(name => filtersDefinitions[name]) + .map(name => <React.Fragment key={name} >{filtersDefinitions[name]}</React.Fragment>) .slice(0, 5); return ( @@ -60,21 +61,26 @@ class Header extends Component { </div> <div className="col-auto"> - <div className="filters-bar-wrapper"> - <div className="filters-bar"> - {quickFilters} - </div> + <div className="filters-bar"> + {quickFilters} </div> </div> <div className="col"> <div className="header-buttons"> - <Button onClick={this.props.onOpenFilters}>filters</Button> - <Button variant="yellow" size="sm">pcaps</Button> - <Button variant="blue" onClick={this.props.onOpenRules}>rules</Button> - <Button variant="red" onClick={this.props.onOpenServices}> - services - </Button> + <ButtonField variant="pink" onClick={this.props.onOpenFilters} name="filters" bordered /> + <Link to="/pcaps"> + <ButtonField variant="purple" name="pcaps" bordered /> + </Link> + <Link to="/rules"> + <ButtonField variant="deep-purple" name="rules" bordered /> + </Link> + <Link to="/services"> + <ButtonField variant="indigo" name="services" bordered /> + </Link> + <Link to="/config"> + <ButtonField variant="blue" name="config" bordered /> + </Link> </div> </div> </div> diff --git a/frontend/src/views/Header.scss b/frontend/src/views/Header.scss index e84e758..15d1375 100644 --- a/frontend/src/views/Header.scss +++ b/frontend/src/views/Header.scss @@ -14,12 +14,21 @@ } .header-buttons { - margin: 5px 0; - text-align: right; + margin: 7px 0; + display: flex; + justify-content: flex-end; + + .button-field { + margin-left: 7px; + } } - .filters-bar-wrapper { - height: 50px; - padding: 8px 0; + .filters-bar { + padding: 3px 0; + + .filter { + display: inline-block; + margin-right: 10px; + } } } diff --git a/frontend/src/views/MainPane.js b/frontend/src/views/MainPane.js deleted file mode 100644 index 69de725..0000000 --- a/frontend/src/views/MainPane.js +++ /dev/null @@ -1,46 +0,0 @@ -import React, {Component} from 'react'; -import './MainPane.scss'; -import Connections from "./Connections"; -import ConnectionContent from "../components/ConnectionContent"; -import {withRouter} from "react-router-dom"; -import axios from 'axios'; - -class MainPane extends Component { - - constructor(props) { - super(props); - this.state = { - selectedConnection: null - }; - } - - componentDidMount() { - if ('id' in this.props.match.params) { - const id = this.props.match.params.id; - axios.get(`/api/connections/${id}`).then(res => { - if (res.status === 200) { - this.setState({selectedConnection: res.data}); - } - }); - } - } - - render() { - return ( - <div className="main-pane"> - <div className="container-fluid"> - <div className="row"> - <div className="col-md-6 pane"> - <Connections onSelected={(c) => this.setState({selectedConnection: c})} /> - </div> - <div className="col-md-6 pl-0 pane"> - <ConnectionContent connection={this.state.selectedConnection}/> - </div> - </div> - </div> - </div> - ); - } -} - -export default withRouter(MainPane); diff --git a/frontend/src/views/MainPane.scss b/frontend/src/views/MainPane.scss deleted file mode 100644 index 20720ba..0000000 --- a/frontend/src/views/MainPane.scss +++ /dev/null @@ -1,6 +0,0 @@ -.main-pane { - .pane { - height: calc(100vh - 210px); - position: relative; - } -} diff --git a/frontend/src/views/Rules.js b/frontend/src/views/Rules.js deleted file mode 100644 index 3424410..0000000 --- a/frontend/src/views/Rules.js +++ /dev/null @@ -1,118 +0,0 @@ -import React, {Component} from 'react'; -import './Services.scss'; -import {Button, ButtonGroup, Col, Container, Form, FormControl, InputGroup, Modal, Row, Table} from "react-bootstrap"; -import axios from "axios"; - -class Rules extends Component { - - constructor(props) { - super(props); - - this.state = { - rules: [] - }; - } - - componentDidMount() { - this.loadRules(); - } - - loadRules() { - axios.get("/api/rules").then(res => this.setState({rules: res.data})); - } - - render() { - let rulesRows = this.state.rules.map(rule => - <tr key={rule.id}> - <td><Button variant="btn-edit" size="sm" - style={{"backgroundColor": rule.color}}>edit</Button></td> - <td>{rule.name}</td> - </tr> - ); - - - return ( - <Modal - {...this.props} - show="true" - size="lg" - aria-labelledby="rules-dialog" - centered - > - <Modal.Header> - <Modal.Title id="rules-dialog"> - ~/rules - </Modal.Title> - </Modal.Header> - <Modal.Body> - <Container> - <Row> - <Col md={7}> - <Table borderless size="sm" className="rules-list"> - <thead> - <tr> - <th><Button size="sm" >new</Button></th> - <th>name</th> - </tr> - </thead> - <tbody> - {rulesRows} - - </tbody> - </Table> - </Col> - <Col md={5}> - <Form> - <Form.Group controlId="servicePort"> - <Form.Label>port:</Form.Label> - <Form.Control type="text" /> - </Form.Group> - - <Form.Group controlId="serviceName"> - <Form.Label>name:</Form.Label> - <Form.Control type="text" /> - </Form.Group> - - <Form.Group controlId="serviceColor"> - <Form.Label>color:</Form.Label> - <ButtonGroup aria-label="Basic example"> - - </ButtonGroup> - <ButtonGroup aria-label="Basic example"> - - </ButtonGroup> - </Form.Group> - - <Form.Group controlId="serviceNotes"> - <Form.Label>notes:</Form.Label> - <Form.Control as="textarea" rows={3} /> - </Form.Group> - </Form> - - - </Col> - - </Row> - - <Row> - <Col md={12}> - <InputGroup> - <FormControl as="textarea" rows={4} className="curl-output" readOnly={true} - /> - </InputGroup> - - </Col> - </Row> - - - </Container> - </Modal.Body> - <Modal.Footer className="dialog-footer"> - <Button variant="red" onClick={this.props.onHide}>close</Button> - </Modal.Footer> - </Modal> - ); - } -} - -export default Rules; diff --git a/frontend/src/views/Services.js b/frontend/src/views/Services.js deleted file mode 100644 index b95b01c..0000000 --- a/frontend/src/views/Services.js +++ /dev/null @@ -1,210 +0,0 @@ -import React, {Component} from 'react'; -import './Services.scss'; -import {Button, ButtonGroup, Col, Container, Form, FormControl, InputGroup, Modal, Row, Table} from "react-bootstrap"; -import axios from 'axios' -import {createCurlCommand} from '../utils'; - -class Services extends Component { - - constructor(props) { - super(props); - this.alphabet = 'abcdefghijklmnopqrstuvwxyz'; - this.colors = ["#E53935", "#D81B60", "#8E24AA", "#5E35B1", "#3949AB", "#1E88E5", "#039BE5", "#00ACC1", - "#00897B", "#43A047", "#7CB342", "#9E9D24", "#F9A825", "#FB8C00", "#F4511E", "#6D4C41"]; - - this.state = { - services: {}, - port: 0, - portValid: false, - name: "", - nameValid: false, - color: this.colors[0], - colorValid: false, - notes: "" - }; - - this.portChanged = this.portChanged.bind(this); - this.nameChanged = this.nameChanged.bind(this); - this.notesChanged = this.notesChanged.bind(this); - this.newService = this.newService.bind(this); - this.editService = this.editService.bind(this); - this.saveService = this.saveService.bind(this); - this.loadServices = this.loadServices.bind(this); - } - - componentDidMount() { - this.loadServices(); - } - - portChanged(event) { - let value = event.target.value.replace(/[^\d]/gi, ''); - let port = 0; - if (value !== "") { - port = parseInt(value); - } - this.setState({port: port}); - } - - nameChanged(event) { - let value = event.target.value.replace(/[\s]/gi, '_').replace(/[^\w]/gi, '').toLowerCase(); - this.setState({name: value}); - } - - notesChanged(event) { - this.setState({notes: event.target.value}); - } - - newService() { - this.setState({name: "", port: 0, notes: ""}); - } - - editService(service) { - this.setState({name: service.name, port: service.port, color: service.color, notes: service.notes}); - } - - saveService() { - if (this.state.portValid && this.state.nameValid) { - axios.put("/api/services", { - name: this.state.name, - port: this.state.port, - color: this.state.color, - notes: this.state.notes - }); - - this.newService(); - this.loadServices(); - } - } - - loadServices() { - axios.get("/api/services").then(res => this.setState({services: res.data})); - } - - componentDidUpdate(prevProps, prevState, snapshot) { - if (this.state.name != null && prevState.name !== this.state.name) { - this.setState({ - nameValid: this.state.name.length >= 3 - }); - } - if (prevState.port !== this.state.port) { - this.setState({ - portValid: this.state.port > 0 && this.state.port <= 65565 - }); - } - } - - render() { - let output = ""; - if (!this.state.portValid) { - output += "assert(1 <= port <= 65565)\n"; - } - if (!this.state.nameValid) { - output += "assert(len(name) >= 3)\n"; - } - if (output === "") { - output = createCurlCommand("/services", { - "port": this.state.port, - "name": this.state.name, - "color": this.state.color, - "notes": this.state.notes - }); - } - let rows = Object.values(this.state.services).map(s => - <tr> - <td><Button variant="btn-edit" size="sm" - onClick={() => this.editService(s)} style={{"backgroundColor": s.color}}>edit</Button></td> - <td>{s.port}</td> - <td>{s.name}</td> - </tr> - ); - - let colorButtons = this.colors.map((color, i) => - <Button size="sm" className="btn-color" key={"button" + this.alphabet[i]} - style={{"backgroundColor": color, "borderColor": this.state.color === color ? "#fff" : color}} - onClick={() => this.setState({color: color})}>{this.alphabet[i]}</Button>); - - return ( - <Modal - {...this.props} - show="true" - size="lg" - aria-labelledby="services-dialog" - centered - > - <Modal.Header> - <Modal.Title id="services-dialog"> - ~/services - </Modal.Title> - </Modal.Header> - <Modal.Body> - <Container> - <Row> - <Col md={7}> - <Table borderless size="sm" className="services-list"> - <thead> - <tr> - <th><Button size="sm" onClick={this.newService}>new</Button></th> - <th>port</th> - <th>name</th> - </tr> - </thead> - <tbody> - {rows} - </tbody> - </Table> - </Col> - <Col md={5}> - <Form> - <Form.Group controlId="servicePort"> - <Form.Label>port:</Form.Label> - <Form.Control type="text" onChange={this.portChanged} value={this.state.port}/> - </Form.Group> - - <Form.Group controlId="serviceName"> - <Form.Label>name:</Form.Label> - <Form.Control type="text" onChange={this.nameChanged} value={this.state.name}/> - </Form.Group> - - <Form.Group controlId="serviceColor"> - <Form.Label>color:</Form.Label> - <ButtonGroup aria-label="Basic example"> - {colorButtons.slice(0, 8)} - </ButtonGroup> - <ButtonGroup aria-label="Basic example"> - {colorButtons.slice(8, 18)} - </ButtonGroup> - </Form.Group> - - <Form.Group controlId="serviceNotes"> - <Form.Label>notes:</Form.Label> - <Form.Control as="textarea" rows={3} onChange={this.notesChanged} - value={this.state.notes}/> - </Form.Group> - </Form> - - - </Col> - - </Row> - - <Row> - <Col md={12}> - <InputGroup> - <FormControl as="textarea" rows={4} className="curl-output" readOnly={true} - value={output}/> - </InputGroup> - - </Col> - </Row> - </Container> - </Modal.Body> - <Modal.Footer className="dialog-footer"> - <Button variant="green" onClick={this.saveService}>save</Button> - <Button variant="red" onClick={this.props.onHide}>close</Button> - </Modal.Footer> - </Modal> - ); - } -} - -export default Services; diff --git a/frontend/src/views/Services.scss b/frontend/src/views/Services.scss deleted file mode 100644 index 2abb55e..0000000 --- a/frontend/src/views/Services.scss +++ /dev/null @@ -1,32 +0,0 @@ -@import '../colors.scss'; - -.curl-output { - width: 100%; - font-size: 13px; -} - -.services-list { - .btn { - width: 70px; - } - - td { - background-color: $color-primary-2; - border-top: 2px solid $color-primary-0; - vertical-align: middle; - } - - th { - background-color: $color-primary-1; - } -} - -.btn-color { - border: 3px solid #fff; -} - -.dialog-footer { - .btn { - width: 80px; - } -} |