aboutsummaryrefslogtreecommitdiff
path: root/frontend/src/components
diff options
context:
space:
mode:
Diffstat (limited to 'frontend/src/components')
-rw-r--r--frontend/src/components/App.js70
-rw-r--r--frontend/src/components/Connection.js134
-rw-r--r--frontend/src/components/ConnectionContent.js166
-rw-r--r--frontend/src/components/ConnectionMatchedRules.js29
-rw-r--r--frontend/src/components/Header.js101
-rw-r--r--frontend/src/components/Header.scss41
-rw-r--r--frontend/src/components/Notifications.js131
-rw-r--r--frontend/src/components/Notifications.scss49
-rw-r--r--frontend/src/components/Timeline.js295
-rw-r--r--frontend/src/components/Timeline.scss27
-rw-r--r--frontend/src/components/dialogs/Filters.js85
-rw-r--r--frontend/src/components/dialogs/Filters.scss5
-rw-r--r--frontend/src/components/fields/ButtonField.js32
-rw-r--r--frontend/src/components/fields/ButtonField.scss7
-rw-r--r--frontend/src/components/fields/CheckField.js31
-rw-r--r--frontend/src/components/fields/ChoiceField.js27
-rw-r--r--frontend/src/components/fields/ChoiceField.scss8
-rw-r--r--frontend/src/components/fields/InputField.js37
-rw-r--r--frontend/src/components/fields/InputField.scss21
-rw-r--r--frontend/src/components/fields/TagField.js75
-rw-r--r--frontend/src/components/fields/TagField.scss157
-rw-r--r--frontend/src/components/fields/TextField.js27
-rw-r--r--frontend/src/components/fields/TextField.scss4
-rw-r--r--frontend/src/components/fields/common.scss4
-rw-r--r--frontend/src/components/fields/extensions/ColorField.js34
-rw-r--r--frontend/src/components/fields/extensions/NumericField.js23
-rw-r--r--frontend/src/components/filters/AdvancedFilters.js54
-rw-r--r--frontend/src/components/filters/BooleanConnectionsFilter.js83
-rw-r--r--frontend/src/components/filters/ExitSearchFilter.js57
-rw-r--r--frontend/src/components/filters/FiltersDefinitions.js90
-rw-r--r--frontend/src/components/filters/RulesConnectionsFilter.js115
-rw-r--r--frontend/src/components/filters/RulesConnectionsFilter.scss118
-rw-r--r--frontend/src/components/filters/StringConnectionsFilter.js114
-rw-r--r--frontend/src/components/objects/Connection.js114
-rw-r--r--frontend/src/components/objects/Connection.scss (renamed from frontend/src/components/Connection.scss)10
-rw-r--r--frontend/src/components/objects/ConnectionMatchedRules.js51
-rw-r--r--frontend/src/components/objects/ConnectionMatchedRules.scss (renamed from frontend/src/components/ConnectionMatchedRules.scss)2
-rw-r--r--frontend/src/components/objects/CopyLinkPopover.js54
-rw-r--r--frontend/src/components/objects/LinkPopover.js32
-rw-r--r--frontend/src/components/objects/LinkPopover.scss5
-rw-r--r--frontend/src/components/objects/MessageAction.js (renamed from frontend/src/components/MessageAction.js)36
-rw-r--r--frontend/src/components/objects/MessageAction.scss (renamed from frontend/src/components/MessageAction.scss)2
-rw-r--r--frontend/src/components/pages/ConfigurationPage.js (renamed from frontend/src/components/panels/ConfigurationPane.js)92
-rw-r--r--frontend/src/components/pages/ConfigurationPage.scss30
-rw-r--r--frontend/src/components/pages/MainPage.js76
-rw-r--r--frontend/src/components/pages/MainPage.scss25
-rw-r--r--frontend/src/components/pages/ServiceUnavailablePage.js34
-rw-r--r--frontend/src/components/pages/common.scss16
-rw-r--r--frontend/src/components/panels/ConfigurationPane.scss18
-rw-r--r--frontend/src/components/panels/ConnectionsPane.js310
-rw-r--r--frontend/src/components/panels/ConnectionsPane.scss41
-rw-r--r--frontend/src/components/panels/MainPane.js134
-rw-r--r--frontend/src/components/panels/MainPane.scss36
-rw-r--r--frontend/src/components/panels/PcapsPane.js (renamed from frontend/src/components/panels/PcapPane.js)130
-rw-r--r--frontend/src/components/panels/PcapsPane.scss (renamed from frontend/src/components/panels/PcapPane.scss)0
-rw-r--r--frontend/src/components/panels/RulesPane.js (renamed from frontend/src/components/panels/RulePane.js)254
-rw-r--r--frontend/src/components/panels/RulesPane.scss (renamed from frontend/src/components/panels/RulePane.scss)0
-rw-r--r--frontend/src/components/panels/SearchPane.js309
-rw-r--r--frontend/src/components/panels/SearchPane.scss52
-rw-r--r--frontend/src/components/panels/ServicesPane.js (renamed from frontend/src/components/panels/ServicePane.js)114
-rw-r--r--frontend/src/components/panels/ServicesPane.scss (renamed from frontend/src/components/panels/ServicePane.scss)0
-rw-r--r--frontend/src/components/panels/StreamsPane.js241
-rw-r--r--frontend/src/components/panels/StreamsPane.scss (renamed from frontend/src/components/ConnectionContent.scss)24
-rw-r--r--frontend/src/components/panels/common.scss12
64 files changed, 3411 insertions, 1094 deletions
diff --git a/frontend/src/components/App.js b/frontend/src/components/App.js
new file mode 100644
index 0000000..96083cd
--- /dev/null
+++ b/frontend/src/components/App.js
@@ -0,0 +1,70 @@
+/*
+ * This file is part of caronte (https://github.com/eciavatta/caronte).
+ * Copyright (c) 2020 Emiliano Ciavatta.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, version 3.
+ *
+ * This program is distributed in the hope that it will be useful, but
+ * WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ */
+
+import React, {Component} from "react";
+import {BrowserRouter as Router} from "react-router-dom";
+import dispatcher from "../dispatcher";
+import Notifications from "./Notifications";
+import ConfigurationPage from "./pages/ConfigurationPage";
+import MainPage from "./pages/MainPage";
+import ServiceUnavailablePage from "./pages/ServiceUnavailablePage";
+
+class App extends Component {
+
+ state = {};
+
+ componentDidMount() {
+ dispatcher.register("notifications", this.handleNotifications);
+
+ setInterval(() => {
+ if (document.title.endsWith("❚")) {
+ document.title = document.title.slice(0, -1);
+ } else {
+ document.title += "❚";
+ }
+ }, 500);
+ }
+
+ componentWillUnmount() {
+ dispatcher.unregister(this.handleNotifications);
+ }
+
+ handleNotifications = (payload) => {
+ if (payload.event === "connected") {
+ this.setState({
+ connected: true,
+ configured: payload.message["is_configured"],
+ version: payload.message["version"]
+ });
+ }
+ };
+
+ render() {
+ return (
+ <Router>
+ <Notifications/>
+ {this.state.connected ?
+ (this.state.configured ? <MainPage version={this.state.version}/> :
+ <ConfigurationPage onConfigured={() => this.setState({configured: true})}/>) :
+ <ServiceUnavailablePage/>
+ }
+ </Router>
+ );
+ }
+}
+
+export default App;
diff --git a/frontend/src/components/Connection.js b/frontend/src/components/Connection.js
deleted file mode 100644
index 44f9f18..0000000
--- a/frontend/src/components/Connection.js
+++ /dev/null
@@ -1,134 +0,0 @@
-import React, {Component} from 'react';
-import './Connection.scss';
-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 {
-
- constructor(props) {
- super(props);
- this.state = {
- update: false,
- copiedMessage: false
- };
-
- this.copyTextarea = React.createRef();
- this.handleAction = this.handleAction.bind(this);
- }
-
- handleAction(name) {
- if (name === "hide") {
- const enabled = !this.props.data.hidden;
- 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;
- backend.post(`/api/connections/${this.props.data.id}/${marked ? "unmark" : "mark"}`)
- .then(_ => {
- this.props.onMarked(!marked);
- this.setState({update: true});
- });
- }
- if (name === "copy") {
- this.copyTextarea.current.select();
- document.execCommand('copy');
- this.setState({copiedMessage: true});
- setTimeout(() => this.setState({copiedMessage: false}), 3000);
- }
- }
-
- render() {
- let conn = this.props.data;
- let serviceName = "/dev/null";
- let serviceColor = "#0F192E";
- if (conn.service.port !== 0) {
- serviceName = conn.service.name;
- serviceColor = conn.service.color;
- }
- let startedAt = new Date(conn.started_at);
- let closedAt = new Date(conn.closed_at);
- let processedAt = new Date(conn.processed_at);
- 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>;
-
- const popoverFor = function (name, content) {
- return <Popover id={`popover-${name}-${conn.id}`} className="connection-popover">
- <Popover.Content>
- {content}
- </Popover.Content>
- </Popover>;
- };
-
- const commentPopoverContent = <div>
- <span>Click to <strong>{conn.comment.length > 0 ? "edit" : "add"}</strong> comment</span>
- {conn.comment && <Form.Control as="textarea" readOnly={true} rows={2} defaultValue={conn.comment}/>}
- </div>;
-
- const copyPopoverContent = <div>
- {this.state.copiedMessage ? <span><strong>Copied!</strong></span> :
- <span>Click to <strong>copy</strong> the connection id</span>}
- <Form.Control as="textarea" readOnly={true} rows={1} defaultValue={conn.id} ref={this.copyTextarea}/>
- </div>;
-
- return (
- <tr className={classNames("connection", {"connection-selected": this.props.selected},
- {"has-matched-rules": conn.matched_rules.length > 0})}>
- <td>
- <span className="connection-service">
- <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">{durationBetween(startedAt, closedAt)}</span>
- </OverlayTrigger>
- </td>
- <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" : "")}*/}
- {/* onClick={() => this.handleAction("hide")}>%</span>*/}
- {/*</OverlayTrigger>*/}
- <OverlayTrigger trigger={["focus", "hover"]} placement="right"
- overlay={popoverFor("hide", <span>Mark this connection</span>)}>
- <span className={"connection-icon" + (conn.marked ? " icon-enabled" : "")}
- onClick={() => this.handleAction("mark")}>!!</span>
- </OverlayTrigger>
- <OverlayTrigger trigger={["focus", "hover"]} placement="right"
- overlay={popoverFor("comment", commentPopoverContent)}>
- <span className={"connection-icon" + (conn.comment ? " icon-enabled" : "")}
- onClick={() => this.handleAction("comment")}>@</span>
- </OverlayTrigger>
- <OverlayTrigger trigger={["focus", "hover"]} placement="right"
- overlay={popoverFor("copy", copyPopoverContent)}>
- <span className="connection-icon"
- onClick={() => this.handleAction("copy")}>#</span>
- </OverlayTrigger>
- </td>
- </tr>
- );
- }
-
-}
-
-export default Connection;
diff --git a/frontend/src/components/ConnectionContent.js b/frontend/src/components/ConnectionContent.js
deleted file mode 100644
index ccaec0b..0000000
--- a/frontend/src/components/ConnectionContent.js
+++ /dev/null
@@ -1,166 +0,0 @@
-import React, {Component} from 'react';
-import './ConnectionContent.scss';
-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');
-
-class ConnectionContent extends Component {
-
- constructor(props) {
- super(props);
- this.state = {
- loading: false,
- connectionContent: null,
- format: "default",
- tryParse: true,
- messageActionDialog: null
- };
-
- this.validFormats = ["default", "hex", "hexdump", "base32", "base64", "ascii", "binary", "decimal", "octal"];
- this.setFormat = this.setFormat.bind(this);
- }
-
- componentDidMount() {
- if (this.props.connection != null) {
- this.loadStream();
- }
- }
-
- componentDidUpdate(prevProps, prevState, snapshot) {
- if (this.props.connection != null && (
- this.props.connection !== prevProps.connection || this.state.format !== prevState.format)) {
- 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});
- }
- }
-
- tryParseConnectionMessage(connectionMessage) {
- if (connectionMessage.metadata == null) {
- return connectionMessage.content;
- }
- if (connectionMessage["is_metadata_continuation"]) {
- return <span style={{"fontSize": "12px"}}>**already parsed in previous messages**</span>;
- }
-
- let unrollMap = (obj) => obj == null ? null : Object.entries(obj).map(([key, value]) =>
- <p key={key}><strong>{key}</strong>: {value}</p>
- );
-
- let m = connectionMessage.metadata;
- switch (m.type) {
- case "http-request":
- let url = <i><u><a href={"http://" + m.host + m.url} target="_blank"
- rel="noopener noreferrer">{m.host}{m.url}</a></u></i>;
- return <span className="type-http-request">
- <p style={{"marginBottom": "7px"}}><strong>{m.method}</strong> {url} {m.protocol}</p>
- {unrollMap(m.headers)}
- <div style={{"margin": "20px 0"}}>{m.body}</div>
- {unrollMap(m.trailers)}
- </span>;
- case "http-response":
- return <span className="type-http-response">
- <p style={{"marginBottom": "7px"}}>{m.protocol} <strong>{m.status}</strong></p>
- {unrollMap(m.headers)}
- <div style={{"margin": "20px 0"}}>{m.body}</div>
- {unrollMap(m.trailers)}
- </span>;
- default:
- return connectionMessage.content;
- }
- }
-
- connectionsActions(connectionMessage) {
- if (connectionMessage.metadata == null || connectionMessage.metadata["reproducers"] === undefined) {
- return null;
- }
-
- return Object.entries(connectionMessage.metadata["reproducers"]).map(([actionName, actionValue]) =>
- <ButtonField small key={actionName + "_button"} name={actionName} onClick={() => {
- this.setState({
- messageActionDialog: <MessageAction actionName={actionName} actionValue={actionValue}
- onHide={() => this.setState({messageActionDialog: null})}/>
- });
- }} />
- );
- }
-
- render() {
- let content = this.state.connectionContent;
-
- if (content == null) {
- return <div>select a connection to view</div>;
- }
-
- let payload = content.map((c, i) =>
- <div key={`content-${i}`}
- className={classNames("connection-message", c.from_client ? "from-client" : "from-server")}>
- <div className="connection-message-header container-fluid">
- <div className="row">
- <div className="connection-message-info col">
- <span><strong>offset</strong>: {c.index}</span> | <span><strong>timestamp</strong>: {c.timestamp}
- </span> | <span><strong>retransmitted</strong>: {c["is_retransmitted"] ? "yes" : "no"}</span>
- </div>
- <div className="connection-message-actions col-auto">{this.connectionsActions(c)}</div>
- </div>
- </div>
- <div className="connection-message-label">{c.from_client ? "client" : "server"}</div>
- <div
- className={classNames("message-content", this.state.decoded ? "message-parsed" : "message-original")}>
- {this.state.tryParse && this.state.format === "default" ? this.tryParseConnectionMessage(c) : c.content}
- </div>
- </div>
- );
-
- return (
- <div className="connection-content">
- <div className="connection-content-header container-fluid">
- <Row>
- <div className="header-info col">
- <span><strong>flow</strong>: {this.props.connection.ip_src}:{this.props.connection.port_src} -> {this.props.connection.ip_dst}:{this.props.connection.port_dst}</span>
- <span> | <strong>timestamp</strong>: {this.props.connection.started_at}</span>
- </div>
- <div className="header-actions col-auto">
- <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>
-
- <pre>{payload}</pre>
- {this.state.messageActionDialog}
- </div>
- );
- }
-
-}
-
-
-export default ConnectionContent;
diff --git a/frontend/src/components/ConnectionMatchedRules.js b/frontend/src/components/ConnectionMatchedRules.js
deleted file mode 100644
index 21f2a92..0000000
--- a/frontend/src/components/ConnectionMatchedRules.js
+++ /dev/null
@@ -1,29 +0,0 @@
-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/Header.js b/frontend/src/components/Header.js
new file mode 100644
index 0000000..c46d768
--- /dev/null
+++ b/frontend/src/components/Header.js
@@ -0,0 +1,101 @@
+/*
+ * This file is part of caronte (https://github.com/eciavatta/caronte).
+ * Copyright (c) 2020 Emiliano Ciavatta.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, version 3.
+ *
+ * This program is distributed in the hope that it will be useful, but
+ * WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ */
+
+import React, {Component} from "react";
+import {Link, withRouter} from "react-router-dom";
+import Typed from "typed.js";
+import {cleanNumber, validatePort} from "../utils";
+import ButtonField from "./fields/ButtonField";
+import AdvancedFilters from "./filters/AdvancedFilters";
+import BooleanConnectionsFilter from "./filters/BooleanConnectionsFilter";
+import ExitSearchFilter from "./filters/ExitSearchFilter";
+import RulesConnectionsFilter from "./filters/RulesConnectionsFilter";
+import StringConnectionsFilter from "./filters/StringConnectionsFilter";
+import "./Header.scss";
+
+const classNames = require("classnames");
+
+class Header extends Component {
+
+ componentDidMount() {
+ const options = {
+ strings: ["caronte$ "],
+ typeSpeed: 50,
+ cursorChar: "❚"
+ };
+ this.typed = new Typed(this.el, options);
+ }
+
+ componentWillUnmount() {
+ this.typed.destroy();
+ }
+
+ render() {
+ return (
+ <header className="header container-fluid">
+ <div className="row">
+ <div className={classNames({"col-auto": this.props.configured, "col": !this.props.configured})}>
+ <h1 className="header-title type-wrap">
+ <Link to="/">
+ <span style={{whiteSpace: "pre"}} ref={(el) => {
+ this.el = el;
+ }}/>
+ </Link>
+ </h1>
+ </div>
+
+ {this.props.configured && <div className="col-auto">
+ <div className="filters-bar">
+ <StringConnectionsFilter filterName="service_port"
+ defaultFilterValue="all_ports"
+ replaceFunc={cleanNumber}
+ validateFunc={validatePort}
+ key="service_port_filter"
+ width={200} small inline/>
+ <RulesConnectionsFilter/>
+ <BooleanConnectionsFilter filterName={"marked"}/>
+ <ExitSearchFilter/>
+ <AdvancedFilters onClick={this.props.onOpenFilters}/>
+ </div>
+ </div>}
+
+ {this.props.configured && <div className="col">
+ <div className="header-buttons">
+ <Link to={"/searches" + this.props.location.search}>
+ <ButtonField variant="pink" name="searches" bordered/>
+ </Link>
+ <Link to={"/pcaps" + this.props.location.search}>
+ <ButtonField variant="purple" name="pcaps" bordered/>
+ </Link>
+ <Link to={"/rules" + this.props.location.search}>
+ <ButtonField variant="deep-purple" name="rules" bordered/>
+ </Link>
+ <Link to={"/services" + this.props.location.search}>
+ <ButtonField variant="indigo" name="services" bordered/>
+ </Link>
+ <Link to={"/config" + this.props.location.search}>
+ <ButtonField variant="blue" name="config" bordered/>
+ </Link>
+ </div>
+ </div>}
+ </div>
+ </header>
+ );
+ }
+}
+
+export default withRouter(Header);
diff --git a/frontend/src/components/Header.scss b/frontend/src/components/Header.scss
new file mode 100644
index 0000000..fff28e6
--- /dev/null
+++ b/frontend/src/components/Header.scss
@@ -0,0 +1,41 @@
+@import "../colors";
+
+.header {
+ height: 80px;
+ padding: 15px 30px;
+
+ > .row {
+ background-color: $color-primary-0;
+ }
+
+ .header-title {
+ width: 200px;
+ margin: 5px 0 5px -5px;
+ }
+
+ .header-buttons {
+ display: flex;
+ justify-content: flex-end;
+ margin: 7px 0;
+
+ .button-field {
+ margin-left: 7px;
+ }
+ }
+
+ .filters-bar {
+ padding: 3px 0;
+
+ .filter,
+ .button-field {
+ display: inline-block;
+ margin-right: 10px;
+ }
+
+ .button-field button {
+ font-weight: 400;
+ padding: 7px 10px;
+ border-radius: 5px;
+ }
+ }
+}
diff --git a/frontend/src/components/Notifications.js b/frontend/src/components/Notifications.js
new file mode 100644
index 0000000..0b47b43
--- /dev/null
+++ b/frontend/src/components/Notifications.js
@@ -0,0 +1,131 @@
+/*
+ * This file is part of caronte (https://github.com/eciavatta/caronte).
+ * Copyright (c) 2020 Emiliano Ciavatta.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, version 3.
+ *
+ * This program is distributed in the hope that it will be useful, but
+ * WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ */
+
+import React, {Component} from "react";
+import dispatcher from "../dispatcher";
+import {randomClassName} from "../utils";
+import "./Notifications.scss";
+
+const _ = require("lodash");
+const classNames = require("classnames");
+
+class Notifications extends Component {
+
+ state = {
+ notifications: [],
+ closedNotifications: [],
+ };
+
+ componentDidMount() {
+ dispatcher.register("notifications", this.handleNotifications);
+ }
+
+ componentWillUnmount() {
+ dispatcher.unregister(this.handleNotifications);
+ }
+
+ handleNotifications = (n) => this.notificationHandler(n);
+
+ notificationHandler = (n) => {
+ switch (n.event) {
+ case "connected":
+ n.title = "connected";
+ n.description = `number of active clients: ${n.message["connected_clients"]}`;
+ return this.pushNotification(n);
+ case "services.edit":
+ n.title = "services updated";
+ n.description = `updated "${n.message["name"]}" on port ${n.message["port"]}`;
+ n.variant = "blue";
+ return this.pushNotification(n);
+ case "rules.new":
+ n.title = "rules updated";
+ n.description = `new rule added: ${n.message["name"]}`;
+ n.variant = "green";
+ return this.pushNotification(n);
+ case "rules.edit":
+ n.title = "rules updated";
+ n.description = `existing rule updated: ${n.message["name"]}`;
+ n.variant = "blue";
+ return this.pushNotification(n);
+ case "pcap.completed":
+ n.title = "new pcap analyzed";
+ n.description = `${n.message["processed_packets"]} packets processed`;
+ n.variant = "blue";
+ return this.pushNotification(n);
+ default:
+ return null;
+ }
+ };
+
+ pushNotification = (notification) => {
+ const notifications = this.state.notifications;
+ notification.id = randomClassName();
+ notifications.push(notification);
+ this.setState({notifications});
+ setTimeout(() => {
+ const notifications = this.state.notifications;
+ notification.open = true;
+ this.setState({notifications});
+ }, 100);
+
+ const hideHandle = setTimeout(() => {
+ const notifications = _.without(this.state.notifications, notification);
+ const closedNotifications = this.state.closedNotifications.concat([notification]);
+ notification.closed = true;
+ this.setState({notifications, closedNotifications});
+ }, 5000);
+
+ const removeHandle = setTimeout(() => {
+ const closedNotifications = _.without(this.state.closedNotifications, notification);
+ this.setState({closedNotifications});
+ }, 6000);
+
+ notification.onClick = () => {
+ clearTimeout(hideHandle);
+ clearTimeout(removeHandle);
+ const notifications = _.without(this.state.notifications, notification);
+ this.setState({notifications});
+ };
+ };
+
+ render() {
+ return (
+ <div className="notifications">
+ <div className="notifications-list">
+ {
+ this.state.closedNotifications.concat(this.state.notifications).map((n) => {
+ const notificationClassnames = {
+ "notification": true,
+ "notification-closed": n.closed,
+ "notification-open": n.open
+ };
+ if (n.variant) {
+ notificationClassnames[`notification-${n.variant}`] = true;
+ }
+ return <div key={n.id} className={classNames(notificationClassnames)} onClick={n.onClick}>
+ <h3 className="notification-title">{n.title}</h3>
+ <pre className="notification-description">{n.description}</pre>
+ </div>;
+ })
+ }
+ </div>
+ </div>
+ );
+ }
+}
+
+export default Notifications;
diff --git a/frontend/src/components/Notifications.scss b/frontend/src/components/Notifications.scss
new file mode 100644
index 0000000..5852c7d
--- /dev/null
+++ b/frontend/src/components/Notifications.scss
@@ -0,0 +1,49 @@
+@import "../colors.scss";
+
+.notifications {
+ position: absolute;
+ z-index: 50;
+ bottom: 50px;
+ left: 30px;
+
+ .notification {
+ width: 250px;
+ margin: 10px 0;
+ padding: 10px;
+ cursor: pointer;
+ transition: all 1s ease;
+ transform: translateX(-300px);
+ color: $color-green-light;
+ border-left: 5px solid $color-green-dark;
+ background-color: $color-green;
+
+ .notification-title {
+ font-size: 0.9em;
+ margin: 0;
+ }
+
+ .notification-description {
+ font-size: 0.8em;
+ overflow: hidden;
+ margin: 10px 0;
+ white-space: nowrap;
+ text-overflow: ellipsis;
+ color: $color-primary-4;
+ }
+
+ &.notification-open {
+ transform: translateX(0);
+ }
+
+ &.notification-closed {
+ transform: translateY(-50px);
+ opacity: 0;
+ }
+
+ &.notification-blue {
+ color: $color-blue-light;
+ border-left: 5px solid $color-blue-dark;
+ background-color: $color-blue;
+ }
+ }
+}
diff --git a/frontend/src/components/Timeline.js b/frontend/src/components/Timeline.js
new file mode 100644
index 0000000..9ecbd80
--- /dev/null
+++ b/frontend/src/components/Timeline.js
@@ -0,0 +1,295 @@
+/*
+ * This file is part of caronte (https://github.com/eciavatta/caronte).
+ * Copyright (c) 2020 Emiliano Ciavatta.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, version 3.
+ *
+ * This program is distributed in the hope that it will be useful, but
+ * WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ */
+
+import {TimeRange, TimeSeries} from "pondjs";
+import React, {Component} from "react";
+import {withRouter} from "react-router-dom";
+import {
+ ChartContainer,
+ ChartRow,
+ Charts,
+ LineChart,
+ MultiBrush,
+ Resizable,
+ styler,
+ YAxis
+} from "react-timeseries-charts";
+import backend from "../backend";
+import dispatcher from "../dispatcher";
+import log from "../log";
+import ChoiceField from "./fields/ChoiceField";
+import "./Timeline.scss";
+
+const minutes = 60 * 1000;
+const classNames = require("classnames");
+
+const leftSelectionPaddingMultiplier = 24;
+const rightSelectionPaddingMultiplier = 8;
+
+class Timeline extends Component {
+
+ state = {
+ metric: "connections_per_service"
+ };
+
+ constructor() {
+ super();
+
+ this.disableTimeSeriesChanges = false;
+ this.selectionTimeout = null;
+ }
+
+ componentDidMount() {
+ const urlParams = new URLSearchParams(this.props.location.search);
+ this.setState({
+ servicePortFilter: urlParams.get("service_port") || null,
+ matchedRulesFilter: urlParams.getAll("matched_rules") || null
+ });
+
+ this.loadStatistics(this.state.metric).then(() => log.debug("Statistics loaded after mount"));
+ dispatcher.register("connections_filters", this.handleConnectionsFiltersCallback);
+ dispatcher.register("connection_updates", this.handleConnectionUpdates);
+ dispatcher.register("notifications", this.handleNotifications);
+ dispatcher.register("pulse_timeline", this.handlePulseTimeline);
+ }
+
+ componentWillUnmount() {
+ dispatcher.unregister(this.handleConnectionsFiltersCallback);
+ dispatcher.unregister(this.handleConnectionUpdates);
+ dispatcher.unregister(this.handleNotifications);
+ dispatcher.unregister(this.handlePulseTimeline);
+ }
+
+ loadStatistics = async (metric) => {
+ const urlParams = new URLSearchParams();
+ urlParams.set("metric", metric);
+
+ let columns = [];
+ if (metric === "matched_rules") {
+ let rules = await this.loadRules();
+ if (this.state.matchedRulesFilter.length > 0) {
+ this.state.matchedRulesFilter.forEach((id) => {
+ urlParams.append("rules_ids", id);
+ });
+ columns = this.state.matchedRulesFilter;
+ } else {
+ columns = rules.map((r) => r.id);
+ }
+ } else {
+ let services = await this.loadServices();
+ const filteredPort = this.state.servicePortFilter;
+ if (filteredPort && services[filteredPort]) {
+ const service = services[filteredPort];
+ services = {};
+ services[filteredPort] = service;
+ }
+
+ columns = Object.keys(services);
+ columns.forEach((port) => urlParams.append("ports", port));
+ }
+
+ const metrics = (await backend.get("/api/statistics?" + urlParams)).json;
+ if (metrics.length === 0) {
+ return;
+ }
+
+ const zeroFilledMetrics = [];
+ const toTime = (m) => new Date(m["range_start"]).getTime();
+ let i = 0;
+ for (let interval = toTime(metrics[0]) - minutes; interval <= toTime(metrics[metrics.length - 1]) + minutes; interval += minutes) {
+ if (i < metrics.length && interval === toTime(metrics[i])) {
+ const m = metrics[i++];
+ m["range_start"] = new Date(m["range_start"]);
+ zeroFilledMetrics.push(m);
+ } else {
+ const m = {};
+ m["range_start"] = new Date(interval);
+ m[metric] = {};
+ columns.forEach((c) => m[metric][c] = 0);
+ zeroFilledMetrics.push(m);
+ }
+ }
+
+ const series = new TimeSeries({
+ name: "statistics",
+ columns: ["time"].concat(columns),
+ points: zeroFilledMetrics.map((m) => [m["range_start"]].concat(columns.map((c) =>
+ ((metric in m) && (m[metric] != null)) ? (m[metric][c] || 0) : 0
+ )))
+ });
+
+ const start = series.range().begin();
+ const end = series.range().end();
+
+ this.setState({
+ metric,
+ series,
+ timeRange: new TimeRange(start, end),
+ columns,
+ start,
+ end
+ });
+ };
+
+ loadServices = async () => {
+ const services = (await backend.get("/api/services")).json;
+ this.setState({services});
+ return services;
+ };
+
+ loadRules = async () => {
+ const rules = (await backend.get("/api/rules")).json;
+ this.setState({rules});
+ return rules;
+ };
+
+ createStyler = () => {
+ if (this.state.metric === "matched_rules") {
+ return styler(this.state.rules.map((rule) => {
+ return {key: rule.id, color: rule.color, width: 2};
+ }));
+ } else {
+ return styler(Object.keys(this.state.services).map((port) => {
+ return {key: port, color: this.state.services[port].color, width: 2};
+ }));
+ }
+ };
+
+ handleTimeRangeChange = (timeRange) => {
+ if (!this.disableTimeSeriesChanges) {
+ this.setState({timeRange});
+ }
+ };
+
+ handleSelectionChange = (timeRange) => {
+ this.disableTimeSeriesChanges = true;
+
+ this.setState({selection: timeRange});
+ if (this.selectionTimeout) {
+ clearTimeout(this.selectionTimeout);
+ }
+ this.selectionTimeout = setTimeout(() => {
+ dispatcher.dispatch("timeline_updates", {
+ from: timeRange.begin(),
+ to: timeRange.end()
+ });
+ this.selectionTimeout = null;
+ this.disableTimeSeriesChanges = false;
+ }, 1000);
+ };
+
+ handleConnectionsFiltersCallback = (payload) => {
+ if ("service_port" in payload && this.state.servicePortFilter !== payload["service_port"]) {
+ this.setState({servicePortFilter: payload["service_port"]});
+ this.loadStatistics(this.state.metric).then(() => log.debug("Statistics reloaded after service port changed"));
+ }
+ if ("matched_rules" in payload && this.state.matchedRulesFilter !== payload["matched_rules"]) {
+ this.setState({matchedRulesFilter: payload["matched_rules"]});
+ this.loadStatistics(this.state.metric).then(() => log.debug("Statistics reloaded after matched rules changed"));
+ }
+ };
+
+ handleConnectionUpdates = (payload) => {
+ this.setState({
+ selection: new TimeRange(payload.from, payload.to),
+ });
+ this.adjustSelection();
+ };
+
+ handleNotifications = (payload) => {
+ if (payload.event === "services.edit" && this.state.metric !== "matched_rules") {
+ this.loadStatistics(this.state.metric).then(() => log.debug("Statistics reloaded after services updates"));
+ } else if (payload.event.startsWith("rules") && this.state.metric === "matched_rules") {
+ this.loadStatistics(this.state.metric).then(() => log.debug("Statistics reloaded after rules updates"));
+ } else if (payload.event === "pcap.completed") {
+ this.loadStatistics(this.state.metric).then(() => log.debug("Statistics reloaded after pcap processed"));
+ }
+ };
+
+ handlePulseTimeline = (payload) => {
+ this.setState({pulseTimeline: true});
+ setTimeout(() => this.setState({pulseTimeline: false}), payload.duration);
+ };
+
+ adjustSelection = () => {
+ const seriesRange = this.state.series.range();
+ const selection = this.state.selection;
+ const delta = selection.end() - selection.begin();
+ const start = Math.max(selection.begin().getTime() - delta * leftSelectionPaddingMultiplier, seriesRange.begin().getTime());
+ const end = Math.min(selection.end().getTime() + delta * rightSelectionPaddingMultiplier, seriesRange.end().getTime());
+ this.setState({timeRange: new TimeRange(start, end)});
+ };
+
+ aggregateSeries = (func) => {
+ const values = this.state.series.columns().map((c) => this.state.series[func](c));
+ return Math[func](...values);
+ };
+
+ render() {
+ if (!this.state.series) {
+ return null;
+ }
+
+ return (
+ <footer className="footer">
+ <div className={classNames("time-line", {"pulse-timeline": this.state.pulseTimeline})}>
+ <Resizable>
+ <ChartContainer timeRange={this.state.timeRange} enableDragZoom={false}
+ paddingTop={5} minDuration={60000}
+ maxTime={this.state.end}
+ minTime={this.state.start}
+ paddingLeft={0} paddingRight={0} paddingBottom={0}
+ enablePanZoom={true} utc={false}
+ onTimeRangeChanged={this.handleTimeRangeChange}>
+
+ <ChartRow height="125">
+ <YAxis id="axis1" hideAxisLine
+ min={this.aggregateSeries("min")}
+ max={this.aggregateSeries("max")} width="35" type="linear" transition={300}/>
+ <Charts>
+ <LineChart axis="axis1" series={this.state.series}
+ columns={this.state.columns}
+ style={this.createStyler()} interpolation="curveBasis"/>
+
+ <MultiBrush
+ timeRanges={[this.state.selection]}
+ allowSelectionClear={false}
+ allowFreeDrawing={false}
+ onTimeRangeChanged={this.handleSelectionChange}
+ />
+ </Charts>
+ </ChartRow>
+ </ChartContainer>
+ </Resizable>
+
+ <div className="metric-selection">
+ <ChoiceField inline small
+ keys={["connections_per_service", "client_bytes_per_service",
+ "server_bytes_per_service", "duration_per_service", "matched_rules"]}
+ values={["connections_per_service", "client_bytes_per_service",
+ "server_bytes_per_service", "duration_per_service", "matched_rules"]}
+ onChange={(metric) => this.loadStatistics(metric)
+ .then(() => log.debug("Statistics loaded after metric changes"))}
+ value={this.state.metric}/>
+ </div>
+ </div>
+ </footer>
+ );
+ }
+}
+
+export default withRouter(Timeline);
diff --git a/frontend/src/components/Timeline.scss b/frontend/src/components/Timeline.scss
new file mode 100644
index 0000000..262da1e
--- /dev/null
+++ b/frontend/src/components/Timeline.scss
@@ -0,0 +1,27 @@
+@import "../colors";
+
+.footer {
+ padding: 15px;
+
+ .time-line {
+ position: relative;
+ background-color: $color-primary-0;
+
+ .metric-selection {
+ font-size: 0.8em;
+ position: absolute;
+ top: 5px;
+ right: 10px;
+ width: 180px;
+ }
+
+ &.pulse-timeline {
+ animation: pulse 2s infinite;
+ }
+ }
+
+ svg text {
+ font-family: "Fira Code", monospace !important;
+ fill: $color-primary-4 !important;
+ }
+}
diff --git a/frontend/src/components/dialogs/Filters.js b/frontend/src/components/dialogs/Filters.js
new file mode 100644
index 0000000..a2407df
--- /dev/null
+++ b/frontend/src/components/dialogs/Filters.js
@@ -0,0 +1,85 @@
+/*
+ * This file is part of caronte (https://github.com/eciavatta/caronte).
+ * Copyright (c) 2020 Emiliano Ciavatta.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, version 3.
+ *
+ * This program is distributed in the hope that it will be useful, but
+ * WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ */
+
+import React, {Component} from "react";
+import {Modal} from "react-bootstrap";
+import {cleanNumber, validateIpAddress, validateMin, validatePort} from "../../utils";
+import ButtonField from "../fields/ButtonField";
+import StringConnectionsFilter from "../filters/StringConnectionsFilter";
+import "./Filters.scss";
+
+class Filters extends Component {
+
+ render() {
+ return (
+ <Modal
+ {...this.props}
+ show={true}
+ size="lg"
+ aria-labelledby="filters-dialog"
+ centered
+ >
+ <Modal.Header>
+ <Modal.Title id="filters-dialog">
+ ~/advanced_filters
+ </Modal.Title>
+ </Modal.Header>
+ <Modal.Body>
+ <div className="advanced-filters d-flex">
+ <div className="flex-fill">
+ <StringConnectionsFilter filterName="client_address"
+ defaultFilterValue="all_addresses"
+ validateFunc={validateIpAddress}
+ key="client_address_filter"/>
+ <StringConnectionsFilter filterName="min_duration"
+ defaultFilterValue="0"
+ replaceFunc={cleanNumber}
+ validateFunc={validateMin(0)}
+ key="min_duration_filter"/>
+ <StringConnectionsFilter filterName="min_bytes"
+ defaultFilterValue="0"
+ replaceFunc={cleanNumber}
+ validateFunc={validateMin(0)}
+ key="min_bytes_filter"/>
+ </div>
+
+ <div className="flex-fill">
+ <StringConnectionsFilter filterName="client_port"
+ defaultFilterValue="all_ports"
+ replaceFunc={cleanNumber}
+ validateFunc={validatePort}
+ key="client_port_filter"/>
+ <StringConnectionsFilter filterName="max_duration"
+ defaultFilterValue="∞"
+ replaceFunc={cleanNumber}
+ key="max_duration_filter"/>
+ <StringConnectionsFilter filterName="max_bytes"
+ defaultFilterValue="∞"
+ replaceFunc={cleanNumber}
+ key="max_bytes_filter"/>
+ </div>
+ </div>
+ </Modal.Body>
+ <Modal.Footer className="dialog-footer">
+ <ButtonField variant="red" bordered onClick={this.props.onHide} name="close"/>
+ </Modal.Footer>
+ </Modal>
+ );
+ }
+}
+
+export default Filters;
diff --git a/frontend/src/components/dialogs/Filters.scss b/frontend/src/components/dialogs/Filters.scss
new file mode 100644
index 0000000..7d09380
--- /dev/null
+++ b/frontend/src/components/dialogs/Filters.scss
@@ -0,0 +1,5 @@
+.advanced-filters {
+ .filter {
+ margin: 10px;
+ }
+}
diff --git a/frontend/src/components/fields/ButtonField.js b/frontend/src/components/fields/ButtonField.js
index cc32b0f..15ef179 100644
--- a/frontend/src/components/fields/ButtonField.js
+++ b/frontend/src/components/fields/ButtonField.js
@@ -1,8 +1,25 @@
-import React, {Component} from 'react';
-import './ButtonField.scss';
-import './common.scss';
+/*
+ * This file is part of caronte (https://github.com/eciavatta/caronte).
+ * Copyright (c) 2020 Emiliano Ciavatta.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, version 3.
+ *
+ * This program is distributed in the hope that it will be useful, but
+ * WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ */
-const classNames = require('classnames');
+import React, {Component} from "react";
+import "./ButtonField.scss";
+import "./common.scss";
+
+const classNames = require("classnames");
class ButtonField extends Component {
@@ -38,9 +55,10 @@ class ButtonField extends Component {
}
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 className={classNames("field", "button-field", {"field-small": this.props.small},
+ {"field-active": this.props.active})}>
+ <button type="button" className={classNames(buttonClassnames)}
+ onClick={handler} style={buttonStyle} disabled={this.props.disabled}>{this.props.name}</button>
</div>
);
}
diff --git a/frontend/src/components/fields/ButtonField.scss b/frontend/src/components/fields/ButtonField.scss
index 9e46b9f..99afe08 100644
--- a/frontend/src/components/fields/ButtonField.scss
+++ b/frontend/src/components/fields/ButtonField.scss
@@ -15,6 +15,13 @@
}
}
+ &.field-active {
+ button {
+ color: $color-primary-1;
+ background-color: $color-primary-4;
+ }
+ }
+
.button-variant-red {
color: $color-red-light;
background-color: $color-red;
diff --git a/frontend/src/components/fields/CheckField.js b/frontend/src/components/fields/CheckField.js
index 33f4f83..bfa1c9d 100644
--- a/frontend/src/components/fields/CheckField.js
+++ b/frontend/src/components/fields/CheckField.js
@@ -1,9 +1,26 @@
-import React, {Component} from 'react';
-import './CheckField.scss';
-import './common.scss';
+/*
+ * This file is part of caronte (https://github.com/eciavatta/caronte).
+ * Copyright (c) 2020 Emiliano Ciavatta.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, version 3.
+ *
+ * This program is distributed in the hope that it will be useful, but
+ * WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ */
+
+import React, {Component} from "react";
import {randomClassName} from "../../utils";
+import "./CheckField.scss";
+import "./common.scss";
-const classNames = require('classnames');
+const classNames = require("classnames");
class CheckField extends Component {
@@ -18,15 +35,15 @@ class CheckField extends Component {
const small = this.props.small || false;
const name = this.props.name || null;
const handler = () => {
- if (this.props.onChange) {
+ if (!this.props.readonly && this.props.onChange) {
this.props.onChange(!checked);
}
};
return (
- <div className={classNames( "field", "check-field", {"field-checked" : checked}, {"field-small": small})}>
+ <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} />
+ <input type="checkbox" id={this.id} checked={checked} onChange={handler}/>
<label htmlFor={this.id}>{(checked ? "✓ " : "✗ ") + (name != null ? name : "")}</label>
</div>
</div>
diff --git a/frontend/src/components/fields/ChoiceField.js b/frontend/src/components/fields/ChoiceField.js
index 73e950d..7e97d89 100644
--- a/frontend/src/components/fields/ChoiceField.js
+++ b/frontend/src/components/fields/ChoiceField.js
@@ -1,9 +1,26 @@
-import React, {Component} from 'react';
-import './ChoiceField.scss';
-import './common.scss';
+/*
+ * This file is part of caronte (https://github.com/eciavatta/caronte).
+ * Copyright (c) 2020 Emiliano Ciavatta.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, version 3.
+ *
+ * This program is distributed in the hope that it will be useful, but
+ * WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ */
+
+import React, {Component} from "react";
import {randomClassName} from "../../utils";
+import "./ChoiceField.scss";
+import "./common.scss";
-const classNames = require('classnames');
+const classNames = require("classnames");
class ChoiceField extends Component {
@@ -50,7 +67,7 @@ class ChoiceField extends Component {
}
return (
- <div className={classNames( "field", "choice-field", {"field-inline" : inline},
+ <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})}
diff --git a/frontend/src/components/fields/ChoiceField.scss b/frontend/src/components/fields/ChoiceField.scss
index 0b5e510..85986af 100644
--- a/frontend/src/components/fields/ChoiceField.scss
+++ b/frontend/src/components/fields/ChoiceField.scss
@@ -19,7 +19,7 @@
border-radius: 5px;
background-color: $color-primary-2;
- &:after {
+ &::after {
position: absolute;
right: 10px;
content: "⋎";
@@ -27,8 +27,8 @@
}
.field-options {
- position: absolute;
- z-index: 20;
+ position: static;
+ z-index: 100;
top: 35px;
display: none;
width: 100%;
@@ -58,7 +58,7 @@
display: block;
}
- .field-value:after {
+ .field-value::after {
content: "⋏";
}
}
diff --git a/frontend/src/components/fields/InputField.js b/frontend/src/components/fields/InputField.js
index 84c981b..e2ea020 100644
--- a/frontend/src/components/fields/InputField.js
+++ b/frontend/src/components/fields/InputField.js
@@ -1,9 +1,26 @@
-import React, {Component} from 'react';
-import './InputField.scss';
-import './common.scss';
+/*
+ * This file is part of caronte (https://github.com/eciavatta/caronte).
+ * Copyright (c) 2020 Emiliano Ciavatta.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, version 3.
+ *
+ * This program is distributed in the hope that it will be useful, but
+ * WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ */
+
+import React, {Component} from "react";
import {randomClassName} from "../../utils";
+import "./common.scss";
+import "./InputField.scss";
-const classNames = require('classnames');
+const classNames = require("classnames");
class InputField extends Component {
@@ -42,23 +59,23 @@ class InputField extends Component {
}
return (
- <div className={classNames("field", "input-field", {"field-active" : active},
+ <div className={classNames("field", "input-field", {"field-active": active},
{"field-invalid": invalid}, {"field-small": small}, {"field-inline": inline})}>
<div className="field-wrapper">
- { name &&
+ {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> }
+ {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} />
+ readOnly={this.props.readonly}/>
</div>
- { type !== "file" && value !== "" &&
+ {type !== "file" && value !== "" && !this.props.readonly &&
<div className="field-clear">
<span onClick={() => handler(null)}>del</span>
</div>
diff --git a/frontend/src/components/fields/InputField.scss b/frontend/src/components/fields/InputField.scss
index 7cc34d9..eafb2ab 100644
--- a/frontend/src/components/fields/InputField.scss
+++ b/frontend/src/components/fields/InputField.scss
@@ -28,7 +28,7 @@
display: none;
}
- .file-label:after {
+ .file-label::after {
position: absolute;
top: 0;
right: 0;
@@ -47,13 +47,14 @@
background-color: $color-primary-4 !important;
}
- .field-value input, .field-value .file-label {
- color: $color-primary-3 !important;
- background-color: $color-primary-4 !important;
+ .file-label::after {
+ background-color: $color-secondary-4 !important;
}
- .file-label:after {
- background-color: $color-secondary-4 !important;
+ .field-value input,
+ .field-value .file-label {
+ color: $color-primary-3 !important;
+ background-color: $color-primary-4 !important;
}
}
@@ -63,12 +64,13 @@
background-color: $color-secondary-2 !important;
}
- .field-value input, .field-value .file-label {
+ .field-value input,
+ .field-value .file-label {
color: $color-primary-4 !important;
background-color: $color-secondary-2 !important;
}
- .file-label:after {
+ .file-label::after {
background-color: $color-secondary-1 !important;
}
}
@@ -90,7 +92,8 @@
.field-input {
width: 100%;
- input, .file-label {
+ input,
+ .file-label {
padding-left: 3px;
border-top-left-radius: 0;
border-bottom-left-radius: 0;
diff --git a/frontend/src/components/fields/TagField.js b/frontend/src/components/fields/TagField.js
new file mode 100644
index 0000000..9a36da4
--- /dev/null
+++ b/frontend/src/components/fields/TagField.js
@@ -0,0 +1,75 @@
+/*
+ * This file is part of caronte (https://github.com/eciavatta/caronte).
+ * Copyright (c) 2020 Emiliano Ciavatta.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, version 3.
+ *
+ * This program is distributed in the hope that it will be useful, but
+ * WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ */
+
+import React, {Component} from "react";
+import ReactTags from "react-tag-autocomplete";
+import {randomClassName} from "../../utils";
+import "./common.scss";
+import "./TagField.scss";
+
+const classNames = require("classnames");
+const _ = require("lodash");
+
+class TagField extends Component {
+
+ state = {};
+
+ constructor(props) {
+ super(props);
+
+ this.id = `field-${this.props.name || "noname"}-${randomClassName()}`;
+ }
+
+ onAddition = (tag) => {
+ if (typeof this.props.onChange === "function") {
+ this.props.onChange([].concat(this.props.tags, tag), true, tag); // true == addition
+ }
+ };
+
+ onDelete = (i) => {
+ if (typeof this.props.onChange === "function") {
+ const tags = _.clone(this.props.tags);
+ const tag = tags[i];
+ tags.splice(i, 1);
+ this.props.onChange(tags, true, tag); // false == delete
+ }
+ };
+
+
+ render() {
+ const small = this.props.small || false;
+ const name = this.props.name || null;
+
+ return (
+ <div className={classNames("field", "tag-field", {"field-small": small},
+ {"field-inline": this.props.inline})}>
+ {name &&
+ <div className="field-name">
+ <label>{name}:</label>
+ </div>
+ }
+ <div className="field-input">
+ <ReactTags {...this.props} tags={this.props.tags || []} autoresize={false}
+ onDelete={this.onDelete} onAddition={this.onAddition}
+ placeholderText={this.props.placeholder || ""}/>
+ </div>
+ </div>
+ );
+ }
+}
+
+export default TagField;
diff --git a/frontend/src/components/fields/TagField.scss b/frontend/src/components/fields/TagField.scss
new file mode 100644
index 0000000..723e71f
--- /dev/null
+++ b/frontend/src/components/fields/TagField.scss
@@ -0,0 +1,157 @@
+@import "../../colors.scss";
+
+.tag-field {
+ font-size: 0.9em;
+ margin: 5px 0;
+
+ .field-name {
+ label {
+ margin: 0;
+ }
+ }
+
+ .react-tags {
+ position: relative;
+ display: flex;
+ border-radius: 4px;
+ background-color: $color-primary-2;
+
+ &:focus-within,
+ &:focus-within .react-tags__search-input {
+ background-color: $color-primary-1;
+ }
+ }
+
+ &.field-small {
+ font-size: 0.8em;
+ }
+
+ &.field-inline {
+ display: flex;
+
+ .field-name {
+ padding: 6px 0 6px 7px;
+ border-top-left-radius: 5px;
+ border-bottom-left-radius: 5px;
+ background-color: $color-primary-2;
+ }
+
+ .field-input {
+ flex: 1;
+
+ .react-tags {
+ padding-left: 3px;
+ border-top-left-radius: 0;
+ border-bottom-left-radius: 0;
+ }
+ }
+
+ &:focus-within .field-name {
+ background-color: $color-primary-1;
+ }
+ }
+
+ .react-tags__selected {
+ display: inline-block;
+ flex: 0 1;
+ margin: 6px 0;
+ white-space: nowrap;
+ }
+
+ .react-tags__selected-tag {
+ font-size: 0.75em;
+ margin: 0 3px;
+ padding: 2px 4px;
+ color: $color-primary-3;
+ border-radius: 2px;
+ background: $color-primary-4;
+ }
+
+ .react-tags__selected-tag::after {
+ margin-left: 8px;
+ content: "\2715";
+ color: $color-primary-3;
+ }
+
+ .react-tags__selected-tag:hover,
+ .react-tags__selected-tag:focus {
+ border-color: #b1b1b1;
+ background-color: $color-primary-0;
+
+ &::after {
+ color: $color-primary-4;
+ }
+ }
+
+ .react-tags__search {
+ flex: 1 0;
+ }
+
+ @media screen and (min-width: 30em) {
+ .react-tags__search {
+ position: relative;
+ }
+ }
+
+ .react-tags__search-input {
+ color: $color-primary-4;
+ background-color: $color-primary-2;
+ }
+
+ .react-tags__search-input::-ms-clear {
+ display: none;
+ }
+
+ .react-tags__suggestions {
+ position: absolute;
+ z-index: 50;
+ top: 100%;
+ left: 0;
+ width: 100%;
+ }
+
+ @media screen and (min-width: 30em) {
+ .react-tags__suggestions {
+ width: 240px;
+ }
+ }
+
+ .react-tags__suggestions ul {
+ font-size: 12px;
+ margin: 4px -1px;
+ padding: 0;
+ list-style: none;
+ border-radius: 3px;
+ background: $color-primary-2;
+ }
+
+ .react-tags__suggestions li {
+ padding: 5px 10px;
+ }
+
+ .react-tags__suggestions li mark {
+ font-weight: 600;
+ padding: 0;
+ color: $color-primary-4;
+ background: none;
+ }
+
+ .react-tags__suggestions li:hover {
+ cursor: pointer;
+ border-radius: 3px;
+ background: $color-primary-1;
+
+ mark {
+ color: $color-primary-4;
+ }
+ }
+
+ .react-tags__suggestions li.is-active {
+ background: $color-primary-3;
+ }
+
+ .react-tags__suggestions li.is-disabled {
+ cursor: auto;
+ opacity: 0.5;
+ }
+}
diff --git a/frontend/src/components/fields/TextField.js b/frontend/src/components/fields/TextField.js
index de68c21..4dd77bd 100644
--- a/frontend/src/components/fields/TextField.js
+++ b/frontend/src/components/fields/TextField.js
@@ -1,9 +1,26 @@
-import React, {Component} from 'react';
-import './TextField.scss';
-import './common.scss';
+/*
+ * This file is part of caronte (https://github.com/eciavatta/caronte).
+ * Copyright (c) 2020 Emiliano Ciavatta.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, version 3.
+ *
+ * This program is distributed in the hope that it will be useful, but
+ * WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ */
+
+import React, {Component} from "react";
import {randomClassName} from "../../utils";
+import "./common.scss";
+import "./TextField.scss";
-const classNames = require('classnames');
+const classNames = require("classnames");
class TextField extends Component {
@@ -33,7 +50,7 @@ class TextField extends Component {
{"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} />
+ readOnly={this.props.readonly} value={this.props.value} ref={this.props.textRef}/>
{error && <div className="field-error">error: {error}</div>}
</div>
);
diff --git a/frontend/src/components/fields/TextField.scss b/frontend/src/components/fields/TextField.scss
index c2d6ef5..5fde9e6 100644
--- a/frontend/src/components/fields/TextField.scss
+++ b/frontend/src/components/fields/TextField.scss
@@ -51,4 +51,8 @@
padding: 5px 10px;
color: $color-secondary-0;
}
+
+ &:hover::-webkit-scrollbar-thumb {
+ background: $color-secondary-2 !important;
+ }
}
diff --git a/frontend/src/components/fields/common.scss b/frontend/src/components/fields/common.scss
index f37369e..e5dc65c 100644
--- a/frontend/src/components/fields/common.scss
+++ b/frontend/src/components/fields/common.scss
@@ -1,7 +1,9 @@
@import "../../colors.scss";
.field {
- input, textarea {
+ input,
+ textarea {
+ font-family: "Fira Code", monospace;
width: 100%;
padding: 7px 10px;
color: $color-primary-4;
diff --git a/frontend/src/components/fields/extensions/ColorField.js b/frontend/src/components/fields/extensions/ColorField.js
index 96ebc49..fd30988 100644
--- a/frontend/src/components/fields/extensions/ColorField.js
+++ b/frontend/src/components/fields/extensions/ColorField.js
@@ -1,19 +1,35 @@
-import React, {Component} from 'react';
+/*
+ * This file is part of caronte (https://github.com/eciavatta/caronte).
+ * Copyright (c) 2020 Emiliano Ciavatta.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, version 3.
+ *
+ * This program is distributed in the hope that it will be useful, but
+ * WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ */
+
+import React, {Component} from "react";
import {OverlayTrigger, Popover} from "react-bootstrap";
-import './ColorField.scss';
-import InputField from "../InputField";
import validation from "../../../validation";
+import InputField from "../InputField";
+import "./ColorField.scss";
class ColorField extends Component {
constructor(props) {
super(props);
- this.state = {
- };
+ this.state = {};
- this.colors = ["#E53935", "#D81B60", "#8E24AA", "#5E35B1", "#3949AB", "#1E88E5", "#039BE5", "#00ACC1",
- "#00897B", "#43A047", "#7CB342", "#9E9D24", "#F9A825", "#FB8C00", "#F4511E", "#6D4C41"];
+ this.colors = ["#e53935", "#d81b60", "#8e24aa", "#5e35b1", "#3949ab", "#1e88e5", "#039be5", "#00acc1",
+ "#00897b", "#43a047", "#7cb342", "#9e9d24", "#f9a825", "#fb8c00", "#f4511e", "#6d4c41"];
}
componentDidUpdate(prevProps, prevState, snapshot) {
@@ -38,7 +54,7 @@ class ColorField extends Component {
this.props.onChange(color);
}
document.body.click(); // magic to close popup
- }} />);
+ }}/>);
const popover = (
<Popover id="popover-basic">
@@ -65,7 +81,7 @@ class ColorField extends Component {
<div className="field color-field">
<div className="color-input">
<InputField {...this.props} onChange={this.onChange} invalid={this.state.invalid} name="color"
- error={null} />
+ error={null}/>
<div className="color-picker">
<OverlayTrigger trigger="click" placement="top" overlay={popover} rootClose>
<button type="button" className="picker-button" style={buttonStyles}>pick</button>
diff --git a/frontend/src/components/fields/extensions/NumericField.js b/frontend/src/components/fields/extensions/NumericField.js
index 19a9e46..a6cba26 100644
--- a/frontend/src/components/fields/extensions/NumericField.js
+++ b/frontend/src/components/fields/extensions/NumericField.js
@@ -1,4 +1,21 @@
-import React, {Component} from 'react';
+/*
+ * This file is part of caronte (https://github.com/eciavatta/caronte).
+ * Copyright (c) 2020 Emiliano Ciavatta.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, version 3.
+ *
+ * This program is distributed in the hope that it will be useful, but
+ * WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ */
+
+import React, {Component} from "react";
import InputField from "../InputField";
class NumericField extends Component {
@@ -18,7 +35,7 @@ class NumericField extends Component {
}
onChange = (value) => {
- value = value.toString().replace(/[^\d]/gi, '');
+ value = value.toString().replace(/[^\d]/gi, "");
let intValue = 0;
if (value !== "") {
intValue = parseInt(value, 10);
@@ -36,7 +53,7 @@ class NumericField extends Component {
render() {
return (
<InputField {...this.props} onChange={this.onChange} defaultValue={this.props.defaultValue || "0"}
- invalid={this.state.invalid} />
+ invalid={this.state.invalid}/>
);
}
diff --git a/frontend/src/components/filters/AdvancedFilters.js b/frontend/src/components/filters/AdvancedFilters.js
new file mode 100644
index 0000000..8598185
--- /dev/null
+++ b/frontend/src/components/filters/AdvancedFilters.js
@@ -0,0 +1,54 @@
+/*
+ * This file is part of caronte (https://github.com/eciavatta/caronte).
+ * Copyright (c) 2020 Emiliano Ciavatta.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, version 3.
+ *
+ * This program is distributed in the hope that it will be useful, but
+ * WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ */
+
+import React, {Component} from "react";
+import {withRouter} from "react-router-dom";
+import dispatcher from "../../dispatcher";
+import {updateParams} from "../../utils";
+import ButtonField from "../fields/ButtonField";
+
+class AdvancedFilters extends Component {
+
+ state = {};
+
+ componentDidMount() {
+ this.urlParams = new URLSearchParams(this.props.location.search);
+
+ this.connectionsFiltersCallback = (payload) => {
+ this.urlParams = updateParams(this.urlParams, payload);
+ const active = ["client_address", "client_port", "min_duration", "max_duration", "min_bytes", "max_bytes"]
+ .some((f) => this.urlParams.has(f));
+ if (this.state.active !== active) {
+ this.setState({active});
+ }
+ };
+ dispatcher.register("connections_filters", this.connectionsFiltersCallback);
+ }
+
+ componentWillUnmount() {
+ dispatcher.unregister(this.connectionsFiltersCallback);
+ }
+
+ render() {
+ return (
+ <ButtonField onClick={this.props.onClick} name="advanced_filters" small active={this.state.active}/>
+ );
+ }
+
+}
+
+export default withRouter(AdvancedFilters);
diff --git a/frontend/src/components/filters/BooleanConnectionsFilter.js b/frontend/src/components/filters/BooleanConnectionsFilter.js
index 4c5a78a..0355167 100644
--- a/frontend/src/components/filters/BooleanConnectionsFilter.js
+++ b/frontend/src/components/filters/BooleanConnectionsFilter.js
@@ -1,64 +1,65 @@
-import React, {Component} from 'react';
+/*
+ * This file is part of caronte (https://github.com/eciavatta/caronte).
+ * Copyright (c) 2020 Emiliano Ciavatta.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, version 3.
+ *
+ * This program is distributed in the hope that it will be useful, but
+ * WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ */
+
+import React, {Component} from "react";
import {withRouter} from "react-router-dom";
-import {Redirect} from "react-router";
+import dispatcher from "../../dispatcher";
import CheckField from "../fields/CheckField";
class BooleanConnectionsFilter extends Component {
- constructor(props) {
- super(props);
- this.state = {
- filterActive: "false"
- };
-
- this.filterChanged = this.filterChanged.bind(this);
- this.needRedirect = false;
- }
+ state = {
+ filterActive: "false"
+ };
componentDidMount() {
let params = new URLSearchParams(this.props.location.search);
this.setState({filterActive: this.toBoolean(params.get(this.props.filterName)).toString()});
+
+ this.connectionsFiltersCallback = (payload) => {
+ const name = this.props.filterName;
+ if (name in payload && this.state.filterActive !== payload[name]) {
+ this.setState({filterActive: payload[name]});
+ }
+ };
+ dispatcher.register("connections_filters", this.connectionsFiltersCallback);
}
- componentDidUpdate(prevProps, prevState, snapshot) {
- let urlParams = new URLSearchParams(this.props.location.search);
- let externalActive = this.toBoolean(urlParams.get(this.props.filterName));
- let filterActive = this.toBoolean(this.state.filterActive);
- // if the filterActive state is changed by another component (and not by filterChanged func) and
- // the query string is not equals at the filterActive state, update the state of the component
- if (this.toBoolean(prevState.filterActive) === filterActive && filterActive !== externalActive) {
- this.setState({filterActive: externalActive.toString()});
- }
+ componentWillUnmount() {
+ dispatcher.unregister(this.connectionsFiltersCallback);
}
- toBoolean(value) {
+ toBoolean = (value) => {
return value !== null && value.toLowerCase() === "true";
- }
+ };
- filterChanged() {
- this.needRedirect = true;
- this.setState({filterActive: (!this.toBoolean(this.state.filterActive)).toString()});
- }
+ filterChanged = () => {
+ const newValue = (!this.toBoolean(this.state.filterActive)).toString();
+ const urlParams = {};
+ urlParams[this.props.filterName] = newValue === "true" ? "true" : null;
+ dispatcher.dispatch("connections_filters", urlParams);
+ this.setState({filterActive: newValue});
+ };
render() {
- let redirect = null;
- if (this.needRedirect) {
- let urlParams = new URLSearchParams(this.props.location.search);
- if (this.toBoolean(this.state.filterActive)) {
- urlParams.set(this.props.filterName, "true");
- } else {
- urlParams.delete(this.props.filterName);
- }
- redirect = <Redirect push to={`${this.props.location.pathname}?${urlParams}`} />;
-
- this.needRedirect = false;
- }
-
return (
<div className="filter" style={{"width": `${this.props.width}px`}}>
<CheckField checked={this.toBoolean(this.state.filterActive)} name={this.props.filterName}
- onChange={this.filterChanged} />
- {redirect}
+ onChange={this.filterChanged} small/>
</div>
);
}
diff --git a/frontend/src/components/filters/ExitSearchFilter.js b/frontend/src/components/filters/ExitSearchFilter.js
new file mode 100644
index 0000000..0aacfd6
--- /dev/null
+++ b/frontend/src/components/filters/ExitSearchFilter.js
@@ -0,0 +1,57 @@
+/*
+ * This file is part of caronte (https://github.com/eciavatta/caronte).
+ * Copyright (c) 2020 Emiliano Ciavatta.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, version 3.
+ *
+ * This program is distributed in the hope that it will be useful, but
+ * WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ */
+
+import React, {Component} from "react";
+import {withRouter} from "react-router-dom";
+import dispatcher from "../../dispatcher";
+import CheckField from "../fields/CheckField";
+
+class ExitSearchFilter extends Component {
+
+ state = {};
+
+ componentDidMount() {
+ let params = new URLSearchParams(this.props.location.search);
+ this.setState({performedSearch: params.get("performed_search")});
+
+ this.connectionsFiltersCallback = (payload) => {
+ if (this.state.performedSearch !== payload["performed_search"]) {
+ this.setState({performedSearch: payload["performed_search"]});
+ }
+ };
+ dispatcher.register("connections_filters", this.connectionsFiltersCallback);
+ }
+
+ componentWillUnmount() {
+ dispatcher.unregister(this.connectionsFiltersCallback);
+ }
+
+ render() {
+ return (
+ <>
+ {this.state.performedSearch &&
+ <div className="filter" style={{"width": `${this.props.width}px`}}>
+ <CheckField checked={true} name="exit_search" onChange={() =>
+ dispatcher.dispatch("connections_filters", {"performed_search": null})} small/>
+ </div>}
+ </>
+ );
+ }
+
+}
+
+export default withRouter(ExitSearchFilter);
diff --git a/frontend/src/components/filters/FiltersDefinitions.js b/frontend/src/components/filters/FiltersDefinitions.js
deleted file mode 100644
index 02ccb42..0000000
--- a/frontend/src/components/filters/FiltersDefinitions.js
+++ /dev/null
@@ -1,90 +0,0 @@
-import {
- cleanNumber,
- timestampToTime,
- timeToTimestamp,
- validate24HourTime,
- validateIpAddress,
- validateMin,
- validatePort
-} from "../../utils";
-import StringConnectionsFilter from "./StringConnectionsFilter";
-import React from "react";
-import RulesConnectionsFilter from "./RulesConnectionsFilter";
-import BooleanConnectionsFilter from "./BooleanConnectionsFilter";
-
-export const filtersNames = ["service_port", "matched_rules", "client_address", "client_port",
- "min_duration", "max_duration", "min_bytes", "max_bytes", "started_after",
- "started_before", "closed_after", "closed_before", "marked", "hidden"];
-
-export const filtersDefinitions = {
- service_port: <StringConnectionsFilter filterName="service_port"
- defaultFilterValue="all_ports"
- replaceFunc={cleanNumber}
- validateFunc={validatePort}
- key="service_port_filter"
- width={200} />,
- matched_rules: <RulesConnectionsFilter />,
- client_address: <StringConnectionsFilter filterName="client_address"
- defaultFilterValue="all_addresses"
- validateFunc={validateIpAddress}
- key="client_address_filter"
- width={320} />,
- client_port: <StringConnectionsFilter filterName="client_port"
- defaultFilterValue="all_ports"
- replaceFunc={cleanNumber}
- validateFunc={validatePort}
- key="client_port_filter"
- width={200} />,
- min_duration: <StringConnectionsFilter filterName="min_duration"
- defaultFilterValue="0"
- replaceFunc={cleanNumber}
- validateFunc={validateMin(0)}
- key="min_duration_filter"
- width={200} />,
- max_duration: <StringConnectionsFilter filterName="max_duration"
- defaultFilterValue="∞"
- replaceFunc={cleanNumber}
- key="max_duration_filter"
- width={200} />,
- min_bytes: <StringConnectionsFilter filterName="min_bytes"
- defaultFilterValue="0"
- replaceFunc={cleanNumber}
- validateFunc={validateMin(0)}
- key="min_bytes_filter"
- width={200} />,
- max_bytes: <StringConnectionsFilter filterName="max_bytes"
- defaultFilterValue="∞"
- replaceFunc={cleanNumber}
- key="max_bytes_filter"
- width={200} />,
- started_after: <StringConnectionsFilter filterName="started_after"
- defaultFilterValue="00:00:00"
- validateFunc={validate24HourTime}
- encodeFunc={timeToTimestamp}
- decodeFunc={timestampToTime}
- key="started_after_filter"
- width={200} />,
- started_before: <StringConnectionsFilter filterName="started_before"
- defaultFilterValue="00:00:00"
- validateFunc={validate24HourTime}
- encodeFunc={timeToTimestamp}
- decodeFunc={timestampToTime}
- key="started_before_filter"
- width={200} />,
- closed_after: <StringConnectionsFilter filterName="closed_after"
- defaultFilterValue="00:00:00"
- validateFunc={validate24HourTime}
- encodeFunc={timeToTimestamp}
- decodeFunc={timestampToTime}
- key="closed_after_filter"
- width={200} />,
- closed_before: <StringConnectionsFilter filterName="closed_before"
- defaultFilterValue="00:00:00"
- validateFunc={validate24HourTime}
- encodeFunc={timeToTimestamp}
- decodeFunc={timestampToTime}
- key="closed_before_filter"
- width={200} />,
- marked: <BooleanConnectionsFilter filterName={"marked"} />,
- hidden: <BooleanConnectionsFilter filterName={"hidden"} />
-};
diff --git a/frontend/src/components/filters/RulesConnectionsFilter.js b/frontend/src/components/filters/RulesConnectionsFilter.js
index 8366189..210ee36 100644
--- a/frontend/src/components/filters/RulesConnectionsFilter.js
+++ b/frontend/src/components/filters/RulesConnectionsFilter.js
@@ -1,86 +1,79 @@
-import React, {Component} from 'react';
+/*
+ * This file is part of caronte (https://github.com/eciavatta/caronte).
+ * Copyright (c) 2020 Emiliano Ciavatta.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, version 3.
+ *
+ * This program is distributed in the hope that it will be useful, but
+ * WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ */
+
+import React, {Component} from "react";
import {withRouter} from "react-router-dom";
-import {Redirect} from "react-router";
-import './RulesConnectionsFilter.scss';
-import ReactTags from 'react-tag-autocomplete';
import backend from "../../backend";
+import dispatcher from "../../dispatcher";
+import TagField from "../fields/TagField";
-const classNames = require('classnames');
+const classNames = require("classnames");
+const _ = require("lodash");
class RulesConnectionsFilter extends Component {
- constructor(props) {
- super(props);
- this.state = {
- mounted: false,
- rules: [],
- activeRules: []
- };
-
- this.needRedirect = false;
- }
+ state = {
+ rules: [],
+ activeRules: []
+ };
componentDidMount() {
- let params = new URLSearchParams(this.props.location.search);
+ const params = new URLSearchParams(this.props.location.search);
let activeRules = params.getAll("matched_rules") || [];
- 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});
+ 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});
});
- }
- componentDidUpdate(prevProps, prevState, snapshot) {
- let urlParams = new URLSearchParams(this.props.location.search);
- let externalRules = urlParams.getAll("matched_rules") || [];
- let activeRules = this.state.activeRules.map(r => r.id);
- let compareRules = (first, second) => first.sort().join(",") === second.sort().join(",");
- if (this.state.mounted &&
- compareRules(prevState.activeRules.map(r => r.id), activeRules) &&
- !compareRules(externalRules, activeRules)) {
- this.setState({activeRules: externalRules.map(id => this.state.rules.find(r => r.id === id))});
- }
+ this.connectionsFiltersCallback = (payload) => {
+ if ("matched_rules" in payload && !_.isEqual(payload["matched_rules"].sort(), this.state.activeRules.sort())) {
+ const newRules = this.state.rules.filter((r) => payload["matched_rules"].includes(r.id));
+ this.setState({
+ activeRules: newRules.map((r) => {
+ return {id: r.id, name: r.name};
+ })
+ });
+ }
+ };
+ dispatcher.register("connections_filters", this.connectionsFiltersCallback);
}
- onDelete(i) {
- const activeRules = this.state.activeRules.slice(0);
- activeRules.splice(i, 1);
- this.needRedirect = true;
- this.setState({ activeRules });
+ componentWillUnmount() {
+ dispatcher.unregister(this.connectionsFiltersCallback);
}
- onAddition(rule) {
- if (!this.state.activeRules.includes(rule)) {
- const activeRules = [].concat(this.state.activeRules, rule);
- this.needRedirect = true;
+ onChange = (activeRules) => {
+ if (!_.isEqual(activeRules.sort(), this.state.activeRules.sort())) {
this.setState({activeRules});
+ dispatcher.dispatch("connections_filters", {"matched_rules": activeRules.map((r) => r.id)});
}
- }
+ };
render() {
- let redirect = null;
-
- if (this.needRedirect) {
- let urlParams = new URLSearchParams(this.props.location.search);
- urlParams.delete("matched_rules");
- this.state.activeRules.forEach(rule => urlParams.append("matched_rules", rule.id));
- redirect = <Redirect push to={`${this.props.location.pathname}?${urlParams}`} />;
-
- this.needRedirect = false;
- }
-
return (
- <div className={classNames("filter", "d-inline-block", {"filter-active" : this.state.filterActive === "true"})}>
- <div className="filter-booleanq">
- <ReactTags tags={this.state.activeRules} suggestions={this.state.rules}
- onDelete={this.onDelete.bind(this)} onAddition={this.onAddition.bind(this)}
- minQueryLength={0} placeholderText="rule_name"
- suggestionsFilter={(suggestion, query) =>
- suggestion.name.startsWith(query) && !this.state.activeRules.includes(suggestion)} />
+ <div
+ className={classNames("filter", "d-inline-block", {"filter-active": this.state.filterActive === "true"})}>
+ <div className="filter-rules">
+ <TagField tags={this.state.activeRules} onChange={this.onChange}
+ suggestions={_.differenceWith(this.state.rules, this.state.activeRules, _.isEqual)}
+ minQueryLength={0} name="matched_rules" inline small placeholder="rule_name"/>
</div>
-
- {redirect}
</div>
);
}
diff --git a/frontend/src/components/filters/RulesConnectionsFilter.scss b/frontend/src/components/filters/RulesConnectionsFilter.scss
deleted file mode 100644
index 71efd0d..0000000
--- a/frontend/src/components/filters/RulesConnectionsFilter.scss
+++ /dev/null
@@ -1,118 +0,0 @@
-@import "../../colors";
-
-.react-tags {
- font-size: 12px;
- position: relative;
- z-index: 10;
- padding: 0 6px;
- cursor: text;
- border-radius: 4px;
- background-color: $color-primary-2;
-}
-
-.react-tags.is-focused {
- border-color: #b1b1b1;
-}
-
-.react-tags__selected {
- display: inline;
-}
-
-.react-tags__selected-tag {
- font-size: 11px;
- display: inline-block;
- margin: 0 6px 6px 0;
- padding: 2px 4px;
- color: $color-primary-3;
- border: none;
- border-radius: 2px;
- background: $color-primary-4;
-}
-
-.react-tags__selected-tag::after {
- margin-left: 8px;
- content: "\2715";
- color: $color-primary-3;
-}
-
-.react-tags__selected-tag:hover,
-.react-tags__selected-tag:focus {
- border-color: #b1b1b1;
-}
-
-.react-tags__search {
- display: inline-block;
- max-width: 100%;
- padding: 7px 10px;
-}
-
-@media screen and (min-width: 30em) {
- .react-tags__search {
- position: relative;
- }
-}
-
-.react-tags__search-input {
- font-size: inherit;
- line-height: inherit;
- max-width: 100%;
- margin: 0;
- padding: 0;
- color: $color-primary-4;
- border: 0;
- outline: none;
- background-color: $color-primary-2;
-}
-
-.react-tags__search-input::-ms-clear {
- display: none;
-}
-
-.react-tags__suggestions {
- position: absolute;
- top: 100%;
- left: 0;
- width: 100%;
-}
-
-@media screen and (min-width: 30em) {
- .react-tags__suggestions {
- width: 240px;
- }
-}
-
-.react-tags__suggestions ul {
- font-size: 12px;
- margin: 4px -1px;
- padding: 0;
- list-style: none;
- color: $color-primary-1;
- border-radius: 2px;
- background: $color-primary-4;
-}
-
-.react-tags__suggestions li {
- padding: 3px 5px;
- border-bottom: 1px solid #ddd;
-}
-
-.react-tags__suggestions li mark {
- font-weight: 600;
- text-decoration: underline;
- background: none;
-}
-
-.react-tags__suggestions li:hover {
- cursor: pointer;
- color: $color-primary-4;
- background: $color-primary-0;
-}
-
-.react-tags__suggestions li.is-active {
- background: #b7cfe0;
-}
-
-.react-tags__suggestions li.is-disabled {
- cursor: auto;
- opacity: 0.5;
-}
diff --git a/frontend/src/components/filters/StringConnectionsFilter.js b/frontend/src/components/filters/StringConnectionsFilter.js
index f463593..c5d7075 100644
--- a/frontend/src/components/filters/StringConnectionsFilter.js
+++ b/frontend/src/components/filters/StringConnectionsFilter.js
@@ -1,36 +1,52 @@
-import React, {Component} from 'react';
+/*
+ * This file is part of caronte (https://github.com/eciavatta/caronte).
+ * Copyright (c) 2020 Emiliano Ciavatta.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, version 3.
+ *
+ * This program is distributed in the hope that it will be useful, but
+ * WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ */
+
+import React, {Component} from "react";
import {withRouter} from "react-router-dom";
-import {Redirect} from "react-router";
+import dispatcher from "../../dispatcher";
import InputField from "../fields/InputField";
class StringConnectionsFilter extends Component {
- constructor(props) {
- super(props);
- this.state = {
- fieldValue: "",
- filterValue: null,
- timeoutHandle: null,
- invalidValue: false
- };
- this.needRedirect = false;
- this.filterChanged = this.filterChanged.bind(this);
- }
+ state = {
+ fieldValue: "",
+ filterValue: null,
+ timeoutHandle: null,
+ invalidValue: false
+ };
componentDidMount() {
let params = new URLSearchParams(this.props.location.search);
this.updateStateFromFilterValue(params.get(this.props.filterName));
+
+ this.connectionsFiltersCallback = (payload) => {
+ const name = this.props.filterName;
+ if (name in payload && this.state.filterValue !== payload[name]) {
+ this.updateStateFromFilterValue(payload[name]);
+ }
+ };
+ dispatcher.register("connections_filters", this.connectionsFiltersCallback);
}
- componentDidUpdate(prevProps, prevState, snapshot) {
- let urlParams = new URLSearchParams(this.props.location.search);
- let filterValue = urlParams.get(this.props.filterName);
- if (prevState.filterValue === this.state.filterValue && this.state.filterValue !== filterValue) {
- this.updateStateFromFilterValue(filterValue);
- }
+ componentWillUnmount() {
+ dispatcher.unregister(this.connectionsFiltersCallback);
}
- updateStateFromFilterValue(filterValue) {
+ updateStateFromFilterValue = (filterValue) => {
if (filterValue !== null) {
let fieldValue = filterValue;
if (typeof this.props.decodeFunc === "function") {
@@ -40,28 +56,28 @@ class StringConnectionsFilter extends Component {
fieldValue = this.props.replaceFunc(fieldValue);
}
if (this.isValueValid(fieldValue)) {
- this.setState({
- fieldValue: fieldValue,
- filterValue: filterValue
- });
+ this.setState({fieldValue, filterValue});
} else {
- this.setState({
- fieldValue: fieldValue,
- invalidValue: true
- });
+ this.setState({fieldValue, invalidValue: true});
}
} else {
this.setState({fieldValue: "", filterValue: null});
}
- }
+ };
- isValueValid(value) {
+ isValueValid = (value) => {
return typeof this.props.validateFunc !== "function" ||
(typeof this.props.validateFunc === "function" && this.props.validateFunc(value));
- }
+ };
- filterChanged(fieldValue) {
- if (this.state.timeoutHandle !== null) {
+ changeFilterValue = (value) => {
+ const urlParams = {};
+ urlParams[this.props.filterName] = value;
+ dispatcher.dispatch("connections_filters", urlParams);
+ };
+
+ filterChanged = (fieldValue) => {
+ if (this.state.timeoutHandle) {
clearTimeout(this.state.timeoutHandle);
}
@@ -70,11 +86,11 @@ class StringConnectionsFilter extends Component {
}
if (fieldValue === "") {
- this.needRedirect = true;
this.setState({fieldValue: "", filterValue: null, invalidValue: false});
- return;
+ return this.changeFilterValue(null);
}
+
if (this.isValueValid(fieldValue)) {
let filterValue = fieldValue;
if (filterValue !== "" && typeof this.props.encodeFunc === "function") {
@@ -82,42 +98,26 @@ class StringConnectionsFilter extends Component {
}
this.setState({
- fieldValue: fieldValue,
+ fieldValue,
timeoutHandle: setTimeout(() => {
- this.needRedirect = true;
- this.setState({filterValue: filterValue});
+ this.setState({filterValue});
+ this.changeFilterValue(filterValue);
}, 500),
invalidValue: false
});
} else {
- this.needRedirect = true;
- this.setState({
- fieldValue: fieldValue,
- invalidValue: true
- });
+ this.setState({fieldValue, invalidValue: true});
}
- }
+ };
render() {
- let redirect = null;
- if (this.needRedirect) {
- let urlParams = new URLSearchParams(this.props.location.search);
- if (this.state.filterValue !== null) {
- urlParams.set(this.props.filterName, this.state.filterValue);
- } else {
- urlParams.delete(this.props.filterName);
- }
- redirect = <Redirect push to={`${this.props.location.pathname}?${urlParams}`} />;
- this.needRedirect = false;
- }
let active = this.state.filterValue !== null;
return (
<div className="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}
+ value={this.state.fieldValue} inline={this.props.inline} small={this.props.small}/>
</div>
);
}
diff --git a/frontend/src/components/objects/Connection.js b/frontend/src/components/objects/Connection.js
new file mode 100644
index 0000000..b70b7f7
--- /dev/null
+++ b/frontend/src/components/objects/Connection.js
@@ -0,0 +1,114 @@
+/*
+ * This file is part of caronte (https://github.com/eciavatta/caronte).
+ * Copyright (c) 2020 Emiliano Ciavatta.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, version 3.
+ *
+ * This program is distributed in the hope that it will be useful, but
+ * WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ */
+
+import React, {Component} from "react";
+import {Form} from "react-bootstrap";
+import backend from "../../backend";
+import dispatcher from "../../dispatcher";
+import {dateTimeToTime, durationBetween, formatSize} from "../../utils";
+import ButtonField from "../fields/ButtonField";
+import "./Connection.scss";
+import CopyLinkPopover from "./CopyLinkPopover";
+import LinkPopover from "./LinkPopover";
+
+const classNames = require("classnames");
+
+class Connection extends Component {
+
+ state = {
+ update: false
+ };
+
+ handleAction = (name) => {
+ if (name === "hide") {
+ const enabled = !this.props.data.hidden;
+ 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;
+ backend.post(`/api/connections/${this.props.data.id}/${marked ? "unmark" : "mark"}`)
+ .then((_) => {
+ this.props.onMarked(!marked);
+ this.setState({update: true});
+ });
+ }
+ };
+
+ render() {
+ let conn = this.props.data;
+ let serviceName = "/dev/null";
+ let serviceColor = "#0f192e";
+ if (this.props.services[conn["port_dst"]]) {
+ const service = this.props.services[conn["port_dst"]];
+ serviceName = service.name;
+ serviceColor = service.color;
+ }
+ let startedAt = new Date(conn["started_at"]);
+ let closedAt = new Date(conn["closed_at"]);
+ let processedAt = new Date(conn["processed_at"]);
+ 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>;
+
+ const commentPopoverContent = <div>
+ <span>Click to <strong>{conn.comment.length > 0 ? "edit" : "add"}</strong> comment</span>
+ {conn.comment && <Form.Control as="textarea" readOnly={true} rows={2} defaultValue={conn.comment}/>}
+ </div>;
+
+ return (
+ <tr className={classNames("connection", {"connection-selected": this.props.selected},
+ {"has-matched-rules": conn.matched_rules.length > 0})}>
+ <td>
+ <span className="connection-service">
+ <ButtonField small fullSpan color={serviceColor} name={serviceName}
+ onClick={() => dispatcher.dispatch("connections_filters",
+ {"service_port": conn["port_dst"].toString()})}/>
+ </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}>
+ <LinkPopover text={dateTimeToTime(conn["started_at"])} content={timeInfo} placement="right"/>
+ </td>
+ <td className="clickable" onClick={this.props.onSelected}>{durationBetween(startedAt, closedAt)}</td>
+ <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 className="connection-actions">
+ <LinkPopover text={<span className={classNames("connection-icon", {"icon-enabled": conn.marked})}
+ onClick={() => this.handleAction("mark")}>!!</span>}
+ content={<span>Mark this connection</span>} placement="right"/>
+ <LinkPopover text={<span className={classNames("connection-icon", {"icon-enabled": conn.comment})}
+ onClick={() => this.handleAction("comment")}>@</span>}
+ content={commentPopoverContent} placement="right"/>
+ <CopyLinkPopover text="#" value={conn.id}
+ textClassName={classNames("connection-icon", {"icon-enabled": conn.hidden})}/>
+ </td>
+ </tr>
+ );
+ }
+
+}
+
+export default Connection;
diff --git a/frontend/src/components/Connection.scss b/frontend/src/components/objects/Connection.scss
index cb7fa54..bf66272 100644
--- a/frontend/src/components/Connection.scss
+++ b/frontend/src/components/objects/Connection.scss
@@ -1,4 +1,4 @@
-@import "../colors.scss";
+@import "../../colors";
.connection {
border-top: 3px solid $color-primary-3;
@@ -42,6 +42,14 @@
&.has-matched-rules {
border-bottom: 0;
}
+
+ .link-popover {
+ font-weight: 400;
+ }
+
+ .connection-actions .link-popover {
+ text-decoration: none;
+ }
}
.connection-popover {
diff --git a/frontend/src/components/objects/ConnectionMatchedRules.js b/frontend/src/components/objects/ConnectionMatchedRules.js
new file mode 100644
index 0000000..a69cad8
--- /dev/null
+++ b/frontend/src/components/objects/ConnectionMatchedRules.js
@@ -0,0 +1,51 @@
+/*
+ * This file is part of caronte (https://github.com/eciavatta/caronte).
+ * Copyright (c) 2020 Emiliano Ciavatta.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, version 3.
+ *
+ * This program is distributed in the hope that it will be useful, but
+ * WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ */
+
+import React, {Component} from "react";
+import {withRouter} from "react-router-dom";
+import dispatcher from "../../dispatcher";
+import ButtonField from "../fields/ButtonField";
+import "./ConnectionMatchedRules.scss";
+
+class ConnectionMatchedRules extends Component {
+
+ onMatchedRulesSelected = (id) => {
+ const params = new URLSearchParams(this.props.location.search);
+ const rules = params.getAll("matched_rules");
+ if (!rules.includes(id)) {
+ rules.push(id);
+ dispatcher.dispatch("connections_filters", {"matched_rules": rules});
+ }
+ };
+
+ render() {
+ const matchedRules = this.props.matchedRules.map((mr) => {
+ const rule = this.props.rules.find((r) => r.id === mr);
+ return <ButtonField key={mr} onClick={() => this.onMatchedRulesSelected(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 withRouter(ConnectionMatchedRules);
diff --git a/frontend/src/components/ConnectionMatchedRules.scss b/frontend/src/components/objects/ConnectionMatchedRules.scss
index 65d9ac8..f46a914 100644
--- a/frontend/src/components/ConnectionMatchedRules.scss
+++ b/frontend/src/components/objects/ConnectionMatchedRules.scss
@@ -1,4 +1,4 @@
-@import "../colors.scss";
+@import "../../colors";
.connection-matches {
background-color: $color-primary-0;
diff --git a/frontend/src/components/objects/CopyLinkPopover.js b/frontend/src/components/objects/CopyLinkPopover.js
new file mode 100644
index 0000000..b951603
--- /dev/null
+++ b/frontend/src/components/objects/CopyLinkPopover.js
@@ -0,0 +1,54 @@
+/*
+ * This file is part of caronte (https://github.com/eciavatta/caronte).
+ * Copyright (c) 2020 Emiliano Ciavatta.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, version 3.
+ *
+ * This program is distributed in the hope that it will be useful, but
+ * WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ */
+
+import React, {Component} from "react";
+import TextField from "../fields/TextField";
+import LinkPopover from "./LinkPopover";
+
+class CopyLinkPopover extends Component {
+
+ state = {};
+
+ constructor(props) {
+ super(props);
+
+ this.copyTextarea = React.createRef();
+ }
+
+ handleClick = () => {
+ this.copyTextarea.current.select();
+ document.execCommand("copy");
+ this.setState({copiedMessage: true});
+ setTimeout(() => this.setState({copiedMessage: false}), 3000);
+ };
+
+ render() {
+ const copyPopoverContent = <div style={{"width": "250px"}}>
+ {this.state.copiedMessage ? <span><strong>Copied!</strong></span> :
+ <span>Click to <strong>copy</strong></span>}
+ <TextField readonly rows={2} value={this.props.value} textRef={this.copyTextarea}/>
+ </div>;
+
+ return (
+ <LinkPopover text={<span className={this.props.textClassName}
+ onClick={this.handleClick}>{this.props.text}</span>}
+ content={copyPopoverContent} placement="right"/>
+ );
+ }
+}
+
+export default CopyLinkPopover;
diff --git a/frontend/src/components/objects/LinkPopover.js b/frontend/src/components/objects/LinkPopover.js
index 8768caa..551a819 100644
--- a/frontend/src/components/objects/LinkPopover.js
+++ b/frontend/src/components/objects/LinkPopover.js
@@ -1,7 +1,24 @@
-import React, {Component} from 'react';
-import {randomClassName} from "../../utils";
+/*
+ * This file is part of caronte (https://github.com/eciavatta/caronte).
+ * Copyright (c) 2020 Emiliano Ciavatta.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, version 3.
+ *
+ * This program is distributed in the hope that it will be useful, but
+ * WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ */
+
+import React, {Component} from "react";
import {OverlayTrigger, Popover} from "react-bootstrap";
-import './LinkPopover.scss';
+import {randomClassName} from "../../utils";
+import "./LinkPopover.scss";
class LinkPopover extends Component {
@@ -22,10 +39,11 @@ class LinkPopover extends Component {
);
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>
+ <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>
);
}
}
diff --git a/frontend/src/components/objects/LinkPopover.scss b/frontend/src/components/objects/LinkPopover.scss
index 725224c..c81f8bb 100644
--- a/frontend/src/components/objects/LinkPopover.scss
+++ b/frontend/src/components/objects/LinkPopover.scss
@@ -5,3 +5,8 @@
cursor: pointer;
text-decoration: underline;
}
+
+.popover {
+ font-family: "Fira Code", monospace;
+ font-size: 0.75em;
+}
diff --git a/frontend/src/components/MessageAction.js b/frontend/src/components/objects/MessageAction.js
index 8f4b031..e0c96e8 100644
--- a/frontend/src/components/MessageAction.js
+++ b/frontend/src/components/objects/MessageAction.js
@@ -1,8 +1,25 @@
-import React, {Component} from 'react';
-import './MessageAction.scss';
+/*
+ * This file is part of caronte (https://github.com/eciavatta/caronte).
+ * Copyright (c) 2020 Emiliano Ciavatta.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, version 3.
+ *
+ * This program is distributed in the hope that it will be useful, but
+ * WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ */
+
+import React, {Component} from "react";
import {Modal} from "react-bootstrap";
-import TextField from "./fields/TextField";
-import ButtonField from "./fields/ButtonField";
+import ButtonField from "../fields/ButtonField";
+import TextField from "../fields/TextField";
+import "./MessageAction.scss";
class MessageAction extends Component {
@@ -17,7 +34,7 @@ class MessageAction extends Component {
copyActionValue() {
this.actionValue.current.select();
- document.execCommand('copy');
+ document.execCommand("copy");
this.setState({copyButtonText: "copied!"});
setTimeout(() => this.setState({copyButtonText: "copy"}), 3000);
}
@@ -26,7 +43,7 @@ class MessageAction extends Component {
return (
<Modal
{...this.props}
- show="true"
+ show={true}
size="lg"
aria-labelledby="message-action-dialog"
centered
@@ -37,11 +54,12 @@ class MessageAction extends Component {
</Modal.Title>
</Modal.Header>
<Modal.Body>
- <TextField readonly value={this.props.actionValue} textRef={this.actionValue} rows={15} />
+ <TextField readonly value={this.props.actionValue} textRef={this.actionValue} rows={15}/>
</Modal.Body>
<Modal.Footer className="dialog-footer">
- <ButtonField variant="green" bordered onClick={this.copyActionValue} name={this.state.copyButtonText} />
- <ButtonField variant="red" bordered onClick={this.props.onHide} name="close" />
+ <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/MessageAction.scss b/frontend/src/components/objects/MessageAction.scss
index faa23d3..996007b 100644
--- a/frontend/src/components/MessageAction.scss
+++ b/frontend/src/components/objects/MessageAction.scss
@@ -1,4 +1,4 @@
-@import "../colors.scss";
+@import "../../colors";
.message-action-value {
font-size: 13px;
diff --git a/frontend/src/components/panels/ConfigurationPane.js b/frontend/src/components/pages/ConfigurationPage.js
index 10309f6..8f9b68b 100644
--- a/frontend/src/components/panels/ConfigurationPane.js
+++ b/frontend/src/components/pages/ConfigurationPage.js
@@ -1,18 +1,37 @@
-import React, {Component} from 'react';
-import './common.scss';
-import './ConfigurationPane.scss';
-import LinkPopover from "../objects/LinkPopover";
+/*
+ * This file is part of caronte (https://github.com/eciavatta/caronte).
+ * Copyright (c) 2020 Emiliano Ciavatta.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, version 3.
+ *
+ * This program is distributed in the hope that it will be useful, but
+ * WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ */
+
+import React, {Component} from "react";
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";
+import {createCurlCommand} from "../../utils";
+import validation from "../../validation";
+import ButtonField from "../fields/ButtonField";
+import CheckField from "../fields/CheckField";
+import InputField from "../fields/InputField";
+import TextField from "../fields/TextField";
+import Header from "../Header";
+import LinkPopover from "../objects/LinkPopover";
+import "../panels/common.scss";
+import "./common.scss";
+import "./ConfigurationPage.scss";
-class ConfigurationPane extends Component {
+class ConfigurationPage extends Component {
constructor(props) {
super(props);
@@ -23,8 +42,7 @@ class ConfigurationPane extends Component {
"flag_regex": "",
"auth_required": false
},
- "accounts": {
- }
+ "accounts": {}
},
newUsername: "",
newPassword: ""
@@ -33,9 +51,9 @@ class ConfigurationPane extends Component {
saveSettings = () => {
if (this.validateSettings(this.state.settings)) {
- backend.post("/setup", this.state.settings).then(_ => {
+ backend.post("/setup", this.state.settings).then((_) => {
this.props.onConfigured();
- }).catch(res => {
+ }).catch((res) => {
this.setState({setupStatusCode: res.status, setupResponse: JSON.stringify(res.json)});
});
}
@@ -43,11 +61,11 @@ class ConfigurationPane extends Component {
validateSettings = (settings) => {
let valid = true;
- if (!validation.isValidAddress(settings.config.server_address, true)) {
+ if (!validation.isValidAddress(settings.config["server_address"], true)) {
this.setState({serverAddressError: "invalid ip_address"});
valid = false;
}
- if (settings.config.flag_regex.length < 8) {
+ if (settings.config["flag_regex"].length < 8) {
this.setState({flagRegexError: "flag_regex.length < 8"});
valid = false;
}
@@ -68,7 +86,7 @@ class ConfigurationPane extends Component {
this.setState({
newUsername: "",
newPassword: "",
- settings: settings
+ settings
});
} else {
this.setState({
@@ -85,42 +103,46 @@ class ConfigurationPane extends Component {
const accounts = Object.entries(settings.accounts).map(([username, password]) =>
<tr key={username}>
<td>{username}</td>
- <td><LinkPopover text="******" content={password} /></td>
+ <td><LinkPopover text="******" content={password}/></td>
<td><ButtonField variant="red" small rounded name="delete"
- onClick={() => this.updateParam((s) => delete s.accounts[username]) }/></td>
+ 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>
+ 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>
+ 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="page configuration-page">
+ <div className="page-header">
+ <Header />
+ </div>
+
+ <div className="page-content">
+ <div className="pane-container configuration-pane">
<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>
+ placement="left"/></span>
</div>
<div className="section-content">
<Container className="p-0">
<Row>
<Col>
- <InputField name="server_address" value={settings.config.server_address}
+ <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} />
+ 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)}/>
+ <CheckField checked={settings.config["auth_required"]} name="auth_required"
+ onChange={(v) => this.updateParam((s) => s.config["auth_required"] = v)}/>
</div>
</Col>
@@ -149,7 +171,7 @@ class ConfigurationPane extends Component {
</div>
<div className="section-footer">
- <ButtonField variant="green" name="save" bordered onClick={this.saveSettings} />
+ <ButtonField variant="green" name="save" bordered onClick={this.saveSettings}/>
</div>
</div>
</div>
@@ -159,4 +181,4 @@ class ConfigurationPane extends Component {
}
}
-export default ConfigurationPane;
+export default ConfigurationPage;
diff --git a/frontend/src/components/pages/ConfigurationPage.scss b/frontend/src/components/pages/ConfigurationPage.scss
new file mode 100644
index 0000000..4254547
--- /dev/null
+++ b/frontend/src/components/pages/ConfigurationPage.scss
@@ -0,0 +1,30 @@
+@import "../../colors";
+
+.configuration-page {
+ background-color: $color-primary-0;
+
+ .header-title {
+ margin: 50px auto;
+ }
+
+ .configuration-pane {
+ display: flex;
+ justify-content: center;
+ height: 100%;
+ padding-top: 100px;
+
+ .section-content {
+ background-color: $color-primary-3;
+ margin-top: 15px;
+ }
+
+ .section-table table {
+ background-color: red !important;
+ }
+
+ .section-footer {
+ background-color: $color-primary-3;
+ }
+ }
+}
+
diff --git a/frontend/src/components/pages/MainPage.js b/frontend/src/components/pages/MainPage.js
new file mode 100644
index 0000000..c4dcd20
--- /dev/null
+++ b/frontend/src/components/pages/MainPage.js
@@ -0,0 +1,76 @@
+/*
+ * This file is part of caronte (https://github.com/eciavatta/caronte).
+ * Copyright (c) 2020 Emiliano Ciavatta.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, version 3.
+ *
+ * This program is distributed in the hope that it will be useful, but
+ * WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ */
+
+import React, {Component} from "react";
+import {Route, Switch} from "react-router-dom";
+import Filters from "../dialogs/Filters";
+import Header from "../Header";
+import Connections from "../panels/ConnectionsPane";
+import MainPane from "../panels/MainPane";
+import PcapsPane from "../panels/PcapsPane";
+import RulesPane from "../panels/RulesPane";
+import SearchPane from "../panels/SearchPane";
+import ServicesPane from "../panels/ServicesPane";
+import StreamsPane from "../panels/StreamsPane";
+import Timeline from "../Timeline";
+import "./common.scss";
+import "./MainPage.scss";
+
+class MainPage extends Component {
+
+ state = {};
+
+ render() {
+ let modal;
+ if (this.state.filterWindowOpen) {
+ modal = <Filters onHide={() => this.setState({filterWindowOpen: false})}/>;
+ }
+
+ return (
+ <div className="page main-page">
+ <div className="page-header">
+ <Header onOpenFilters={() => this.setState({filterWindowOpen: true})} configured={true}/>
+ </div>
+
+ <div className="page-content">
+ <div className="pane connections-pane">
+ <Connections onSelected={(c) => this.setState({selectedConnection: c})}/>
+ </div>
+ <div className="pane details-pane">
+ <Switch>
+ <Route path="/searches" children={<SearchPane/>}/>
+ <Route path="/pcaps" children={<PcapsPane/>}/>
+ <Route path="/rules" children={<RulesPane/>}/>
+ <Route path="/services" children={<ServicesPane/>}/>
+ <Route exact path="/connections/:id"
+ children={<StreamsPane connection={this.state.selectedConnection}/>}/>
+ <Route children={<MainPane version={this.props.version}/>}/>
+ </Switch>
+ </div>
+
+ {modal}
+ </div>
+
+ <div className="page-footer">
+ <Timeline/>
+ </div>
+ </div>
+ );
+ }
+}
+
+export default MainPage;
diff --git a/frontend/src/components/pages/MainPage.scss b/frontend/src/components/pages/MainPage.scss
new file mode 100644
index 0000000..4ca54c0
--- /dev/null
+++ b/frontend/src/components/pages/MainPage.scss
@@ -0,0 +1,25 @@
+@import "../../colors";
+
+.main-page {
+ .page-content {
+ display: flex;
+ flex: 1;
+ padding: 0 15px;
+ background-color: $color-primary-2;
+
+ .connections-pane {
+ flex: 1 0;
+ margin-right: 7.5px;
+ }
+
+ .details-pane {
+ position: relative;
+ flex: 1 1;
+ margin-left: 7.5px;
+ }
+ }
+
+ .page-footer {
+ flex: 0;
+ }
+}
diff --git a/frontend/src/components/pages/ServiceUnavailablePage.js b/frontend/src/components/pages/ServiceUnavailablePage.js
new file mode 100644
index 0000000..deb4cf8
--- /dev/null
+++ b/frontend/src/components/pages/ServiceUnavailablePage.js
@@ -0,0 +1,34 @@
+/*
+ * This file is part of caronte (https://github.com/eciavatta/caronte).
+ * Copyright (c) 2020 Emiliano Ciavatta.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, version 3.
+ *
+ * This program is distributed in the hope that it will be useful, but
+ * WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ */
+
+import React, {Component} from "react";
+import "./MainPage.scss";
+
+class ServiceUnavailablePage extends Component {
+
+ state = {};
+
+ render() {
+ return (
+ <div className="main-page">
+
+ </div>
+ );
+ }
+}
+
+export default ServiceUnavailablePage;
diff --git a/frontend/src/components/pages/common.scss b/frontend/src/components/pages/common.scss
new file mode 100644
index 0000000..fcf5c20
--- /dev/null
+++ b/frontend/src/components/pages/common.scss
@@ -0,0 +1,16 @@
+.page {
+ position: relative;
+ display: flex;
+ flex-direction: column;
+ height: 100vh;
+
+ .page-header,
+ .page-footer {
+ flex: 0;
+ }
+
+ .page-content {
+ overflow: hidden;
+ flex: 1;
+ }
+}
diff --git a/frontend/src/components/panels/ConfigurationPane.scss b/frontend/src/components/panels/ConfigurationPane.scss
deleted file mode 100644
index ef48b34..0000000
--- a/frontend/src/components/panels/ConfigurationPane.scss
+++ /dev/null
@@ -1,18 +0,0 @@
-@import "../../colors";
-
-.configuration-pane {
- display: flex;
- align-items: center;
- justify-content: center;
- height: 100%;
- background-color: $color-primary-0;
-
- .pane {
- flex-basis: 900px;
- margin-bottom: 200px;
- }
-
- .pane-container {
- padding-bottom: 1px;
- }
-}
diff --git a/frontend/src/components/panels/ConnectionsPane.js b/frontend/src/components/panels/ConnectionsPane.js
new file mode 100644
index 0000000..6418b3e
--- /dev/null
+++ b/frontend/src/components/panels/ConnectionsPane.js
@@ -0,0 +1,310 @@
+/*
+ * This file is part of caronte (https://github.com/eciavatta/caronte).
+ * Copyright (c) 2020 Emiliano Ciavatta.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, version 3.
+ *
+ * This program is distributed in the hope that it will be useful, but
+ * WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ */
+
+import React, {Component} from "react";
+import Table from "react-bootstrap/Table";
+import {Redirect} from "react-router";
+import {withRouter} from "react-router-dom";
+import backend from "../../backend";
+import dispatcher from "../../dispatcher";
+import log from "../../log";
+import {updateParams} from "../../utils";
+import ButtonField from "../fields/ButtonField";
+import Connection from "../objects/Connection";
+import ConnectionMatchedRules from "../objects/ConnectionMatchedRules";
+import "./ConnectionsPane.scss";
+
+const classNames = require("classnames");
+
+class ConnectionsPane extends Component {
+
+ state = {
+ loading: false,
+ connections: [],
+ firstConnection: null,
+ lastConnection: null,
+ };
+
+ constructor(props) {
+ super(props);
+
+ this.scrollTopThreashold = 0.00001;
+ this.scrollBottomThreashold = 0.99999;
+ this.maxConnections = 200;
+ this.queryLimit = 50;
+ this.connectionsListRef = React.createRef();
+ this.lastScrollPosition = 0;
+ }
+
+ componentDidMount() {
+ let urlParams = new URLSearchParams(this.props.location.search);
+ this.setState({urlParams});
+
+ const additionalParams = {limit: this.queryLimit};
+
+ const match = this.props.location.pathname.match(/^\/connections\/([a-f0-9]{24})$/);
+ if (match != null) {
+ const id = match[1];
+ additionalParams.from = id;
+ backend.get(`/api/connections/${id}`)
+ .then((res) => this.connectionSelected(res.json))
+ .catch((error) => log.error("Error loading initial connection", error));
+ }
+
+ this.loadConnections(additionalParams, urlParams, true).then(() => log.debug("Connections loaded"));
+
+ dispatcher.register("connections_filters", this.handleConnectionsFilters);
+ dispatcher.register("timeline_updates", this.handleTimelineUpdates);
+ dispatcher.register("notifications", this.handleNotifications);
+ dispatcher.register("pulse_connections_view", this.handlePulseConnectionsView);
+ }
+
+ componentWillUnmount() {
+ dispatcher.unregister(this.handleConnectionsFilters);
+ dispatcher.unregister(this.handleTimelineUpdates);
+ dispatcher.unregister(this.handleNotifications);
+ dispatcher.unregister(this.handlePulseConnectionsView);
+ }
+
+ handleConnectionsFilters = (payload) => {
+ const newParams = updateParams(this.state.urlParams, payload);
+ if (this.state.urlParams.toString() === newParams.toString()) {
+ return;
+ }
+
+ log.debug("Update following url params:", payload);
+ this.queryStringRedirect = true;
+ this.setState({urlParams: newParams});
+
+ this.loadConnections({limit: this.queryLimit}, newParams)
+ .then(() => log.info("ConnectionsPane reloaded after query string update"));
+ };
+
+ handleTimelineUpdates = (payload) => {
+ this.connectionsListRef.current.scrollTop = 0;
+ this.loadConnections({
+ "started_after": Math.round(payload.from.getTime() / 1000),
+ "started_before": Math.round(payload.to.getTime() / 1000),
+ limit: this.maxConnections
+ }).then(() => log.info(`Loading connections between ${payload.from} and ${payload.to}`));
+ };
+
+ handleNotifications = (payload) => {
+ if (payload.event === "rules.new" || payload.event === "rules.edit") {
+ this.loadRules().then(() => log.debug("Loaded connection rules after notification update"));
+ }
+ if (payload.event === "services.edit") {
+ this.loadServices().then(() => log.debug("Services reloaded after notification update"));
+ }
+ };
+
+ handlePulseConnectionsView = (payload) => {
+ this.setState({pulseConnectionsView: true});
+ setTimeout(() => this.setState({pulseConnectionsView: false}), payload.duration);
+ };
+
+ connectionSelected = (c) => {
+ this.connectionSelectedRedirect = true;
+ this.setState({selected: c.id});
+ this.props.onSelected(c);
+ log.debug(`Connection ${c.id} selected`);
+ };
+
+ handleScroll = (e) => {
+ if (this.disableScrollHandler) {
+ this.lastScrollPosition = e.currentTarget.scrollTop;
+ return;
+ }
+
+ let relativeScroll = e.currentTarget.scrollTop / (e.currentTarget.scrollHeight - e.currentTarget.clientHeight);
+ if (!this.state.loading && relativeScroll > this.scrollBottomThreashold) {
+ this.loadConnections({from: this.state.lastConnection.id, limit: this.queryLimit,})
+ .then(() => log.info("Following connections loaded"));
+ }
+ if (!this.state.loading && relativeScroll < this.scrollTopThreashold) {
+ this.loadConnections({to: this.state.firstConnection.id, limit: this.queryLimit,})
+ .then(() => log.info("Previous connections loaded"));
+ if (this.state.showMoreRecentButton) {
+ this.setState({showMoreRecentButton: false});
+ }
+ } else {
+ if (this.lastScrollPosition > e.currentTarget.scrollTop) {
+ if (!this.state.showMoreRecentButton) {
+ this.setState({showMoreRecentButton: true});
+ }
+ } else {
+ if (this.state.showMoreRecentButton) {
+ this.setState({showMoreRecentButton: false});
+ }
+ }
+ }
+ this.lastScrollPosition = e.currentTarget.scrollTop;
+ };
+
+ async loadConnections(additionalParams, initialParams = null, isInitial = false) {
+ if (!initialParams) {
+ initialParams = this.state.urlParams;
+ }
+ const urlParams = new URLSearchParams(initialParams.toString());
+ for (const [name, value] of Object.entries(additionalParams)) {
+ urlParams.set(name, value);
+ }
+
+ this.setState({loading: true});
+ if (!this.state.rules) {
+ await this.loadRules();
+ }
+ if (!this.state.services) {
+ await this.loadServices();
+ }
+
+ let res = (await backend.get(`/api/connections?${urlParams}`)).json;
+
+ let connections = this.state.connections;
+ let firstConnection = this.state.firstConnection;
+ let lastConnection = this.state.lastConnection;
+
+ if (additionalParams && additionalParams.from && !additionalParams.to) {
+ if (res.length > 0) {
+ if (!isInitial) {
+ res = res.slice(1);
+ }
+ connections = this.state.connections.concat(res);
+ lastConnection = connections[connections.length - 1];
+ if (isInitial) {
+ firstConnection = connections[0];
+ }
+ if (connections.length > this.maxConnections) {
+ connections = connections.slice(connections.length - this.maxConnections,
+ connections.length - 1);
+ firstConnection = connections[0];
+ }
+ }
+ } else if (additionalParams && additionalParams.to && !additionalParams.from) {
+ if (res.length > 0) {
+ connections = res.slice(0, res.length - 1).concat(this.state.connections);
+ firstConnection = connections[0];
+ if (connections.length > this.maxConnections) {
+ connections = connections.slice(0, this.maxConnections);
+ lastConnection = connections[this.maxConnections - 1];
+ }
+ }
+ } else {
+ if (res.length > 0) {
+ connections = res;
+ firstConnection = connections[0];
+ lastConnection = connections[connections.length - 1];
+ } else {
+ connections = [];
+ firstConnection = null;
+ lastConnection = null;
+ }
+ }
+
+ this.setState({loading: false, connections, firstConnection, lastConnection});
+
+ if (firstConnection != null && lastConnection != null) {
+ dispatcher.dispatch("connection_updates", {
+ from: new Date(lastConnection["started_at"]),
+ to: new Date(firstConnection["started_at"])
+ });
+ }
+ }
+
+ loadRules = async () => {
+ return backend.get("/api/rules").then((res) => this.setState({rules: res.json}));
+ };
+
+ loadServices = async () => {
+ return backend.get("/api/services").then((res) => this.setState({services: res.json}));
+ };
+
+ render() {
+ let redirect;
+ if (this.connectionSelectedRedirect) {
+ redirect = <Redirect push to={`/connections/${this.state.selected}?${this.state.urlParams}`}/>;
+ this.connectionSelectedRedirect = false;
+ } else if (this.queryStringRedirect) {
+ redirect = <Redirect push to={`${this.props.location.pathname}?${this.state.urlParams}`}/>;
+ this.queryStringRedirect = false;
+ }
+
+ let loading = null;
+ if (this.state.loading) {
+ loading = <tr>
+ <td colSpan={10}>Loading...</td>
+ </tr>;
+ }
+
+ return (
+ <div className="connections-container">
+ {this.state.showMoreRecentButton && <div className="most-recent-button">
+ <ButtonField name="most_recent" variant="green" bordered onClick={() => {
+ this.disableScrollHandler = true;
+ this.connectionsListRef.current.scrollTop = 0;
+ this.loadConnections({limit: this.queryLimit})
+ .then(() => {
+ this.disableScrollHandler = false;
+ log.info("Most recent connections loaded");
+ });
+ }}/>
+ </div>}
+
+ <div className={classNames("connections", {"connections-pulse": this.state.pulseConnectionsView})}
+ onScroll={this.handleScroll} ref={this.connectionsListRef}>
+ <Table borderless size="sm">
+ <thead>
+ <tr>
+ <th>service</th>
+ <th>srcip</th>
+ <th>srcport</th>
+ <th>dstip</th>
+ <th>dstport</th>
+ <th>started_at</th>
+ <th>duration</th>
+ <th>up</th>
+ <th>down</th>
+ <th>actions</th>
+ </tr>
+ </thead>
+ <tbody>
+ {
+ this.state.connections.flatMap((c) => {
+ return [<Connection key={c.id} data={c} onSelected={() => this.connectionSelected(c)}
+ selected={this.state.selected === c.id}
+ onMarked={(marked) => c.marked = marked}
+ onEnabled={(enabled) => c.hidden = !enabled}
+ services={this.state.services}/>,
+ c.matched_rules.length > 0 &&
+ <ConnectionMatchedRules key={c.id + "_m"} matchedRules={c.matched_rules}
+ rules={this.state.rules}/>
+ ];
+ })
+ }
+ {loading}
+ </tbody>
+ </Table>
+
+ {redirect}
+ </div>
+ </div>
+ );
+ }
+
+}
+
+export default withRouter(ConnectionsPane);
diff --git a/frontend/src/components/panels/ConnectionsPane.scss b/frontend/src/components/panels/ConnectionsPane.scss
new file mode 100644
index 0000000..59fe372
--- /dev/null
+++ b/frontend/src/components/panels/ConnectionsPane.scss
@@ -0,0 +1,41 @@
+@import "../../colors";
+
+.connections-container {
+ position: relative;
+ height: 100%;
+ background-color: $color-primary-3;
+
+ .connections {
+ position: relative;
+ overflow-y: scroll;
+ height: 100%;
+
+ .table {
+ margin-bottom: 0;
+ }
+
+ th {
+ font-size: 13.5px;
+ position: sticky;
+ top: 0;
+ padding: 5px;
+ border: none;
+ background-color: $color-primary-3;
+ }
+
+ &:hover::-webkit-scrollbar-thumb {
+ background: $color-secondary-2;
+ }
+ }
+
+ .most-recent-button {
+ position: absolute;
+ z-index: 20;
+ top: 45px;
+ left: calc(50% - 50px);
+ }
+
+ .connections-pulse {
+ animation: pulse 2s infinite;
+ }
+}
diff --git a/frontend/src/components/panels/MainPane.js b/frontend/src/components/panels/MainPane.js
index 3202d6d..ce72be5 100644
--- a/frontend/src/components/panels/MainPane.js
+++ b/frontend/src/components/panels/MainPane.js
@@ -1,56 +1,112 @@
-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";
+/*
+ * This file is part of caronte (https://github.com/eciavatta/caronte).
+ * Copyright (c) 2020 Emiliano Ciavatta.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, version 3.
+ *
+ * This program is distributed in the hope that it will be useful, but
+ * WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ */
+
+import React, {Component} from "react";
+import Typed from "typed.js";
+import dispatcher from "../../dispatcher";
+import "./common.scss";
+import "./MainPane.scss";
+import PcapsPane from "./PcapsPane";
+import RulesPane from "./RulesPane";
+import ServicesPane from "./ServicesPane";
+import StreamsPane from "./StreamsPane";
class MainPane extends Component {
- constructor(props) {
- super(props);
- this.state = {
- selectedConnection: null,
- loading: false
+ state = {};
+
+ componentDidMount() {
+ const nl = "^600\n^400";
+ const options = {
+ strings: [
+ `welcome to caronte!^1000 the current version is ${this.props.version}` + nl +
+ "caronte is a network analyzer,^300 it is able to read pcaps and extract connections", // 0
+ "the left panel lists all connections that have already been closed" + nl +
+ "scrolling up the list will load the most recent connections,^300 downward the oldest ones", // 1
+ "by selecting a connection you can view its content,^300 which will be shown in the right panel" + nl +
+ "you can choose the display format,^300 or decide to download the connection content", // 2
+ "below there is the timeline,^300 which shows the number of connections per minute per service" + nl +
+ "you can use the sliding window to move the time range of the connections to be displayed", // 3
+ "there are also additional metrics,^300 selectable from the drop-down menu", // 4
+ "at the top are the filters,^300 which can be used to select only certain types of connections" + nl +
+ "you can choose which filters to display in the top bar from the filters window", // 5
+ "in the pcaps panel it is possible to analyze new pcaps,^300 or to see the pcaps already analyzed" + nl +
+ "you can load pcaps from your browser,^300 or process pcaps already present on the filesystem", // 6
+ "in the rules panel you can see the rules already created,^300 or create new ones" + nl +
+ "the rules inserted will be used only to label new connections, not those already analyzed" + nl +
+ "a connection is tagged if it meets all the requirements specified by the rule", // 7
+ "in the services panel you can assign new services or edit existing ones" + nl +
+ "each service is associated with a port number,^300 and will be shown in the connection list", // 8
+ "from the configuration panel you can change the settings of the frontend application", // 9
+ "that's all! and have fun!" + nl + "created by @eciavatta" // 10
+ ],
+ typeSpeed: 40,
+ cursorChar: "_",
+ backSpeed: 5,
+ smartBackspace: false,
+ backDelay: 1500,
+ preStringTyped: (arrayPos) => {
+ switch (arrayPos) {
+ case 1:
+ return dispatcher.dispatch("pulse_connections_view", {duration: 12000});
+ case 2:
+ return this.setState({backgroundPane: <StreamsPane/>});
+ case 3:
+ this.setState({backgroundPane: null});
+ return dispatcher.dispatch("pulse_timeline", {duration: 12000});
+ case 6:
+ return this.setState({backgroundPane: <PcapsPane/>});
+ case 7:
+ return this.setState({backgroundPane: <RulesPane/>});
+ case 8:
+ return this.setState({backgroundPane: <ServicesPane/>});
+ case 10:
+ return this.setState({backgroundPane: null});
+ default:
+ return;
+ }
+ },
};
+ this.typed = new Typed(this.el, options);
}
- 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));
- }
+ componentWillUnmount() {
+ this.typed.destroy();
}
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 className="pane-container">
+ <div className="main-pane">
+ <div className="pane-section">
+ <div className="tutorial">
+ <span style={{whiteSpace: "pre"}} ref={(el) => {
+ this.el = el;
+ }}/>
+ </div>
+ </div>
</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 className="background-pane">
+ {this.state.backgroundPane}
</div>
</div>
);
}
+
}
-export default withRouter(MainPane);
+export default MainPane;
diff --git a/frontend/src/components/panels/MainPane.scss b/frontend/src/components/panels/MainPane.scss
index 2973c00..8f99b3c 100644
--- a/frontend/src/components/panels/MainPane.scss
+++ b/frontend/src/components/panels/MainPane.scss
@@ -1,22 +1,30 @@
@import "../../colors";
-.main-pane {
- display: flex;
- height: 100%;
- padding: 0 15px;
- background-color: $color-primary-2;
+.pane-container {
+ background-color: $color-primary-0;
- .pane {
- flex: 1;
- }
+ .main-pane {
+ position: absolute;
+ z-index: 50;
+ top: 0;
+ left: 0;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 100%;
+ height: 100%;
+ background-color: transparent;
- .connections-pane {
- flex: 1 0;
- margin-right: 7.5px;
+ .tutorial {
+ flex-basis: 100%;
+ padding: 5px 10px;
+ text-align: center;
+ background-color: $color-primary-2;
+ }
}
- .details-pane {
- flex: 1 1;
- margin-left: 7.5px;
+ .background-pane {
+ height: 100%;
+ opacity: 0.4;
}
}
diff --git a/frontend/src/components/panels/PcapPane.js b/frontend/src/components/panels/PcapsPane.js
index 7b3fde6..b7d5ce9 100644
--- a/frontend/src/components/panels/PcapPane.js
+++ b/frontend/src/components/panels/PcapsPane.js
@@ -1,41 +1,68 @@
-import React, {Component} from 'react';
-import './PcapPane.scss';
-import './common.scss';
+/*
+ * This file is part of caronte (https://github.com/eciavatta/caronte).
+ * Copyright (c) 2020 Emiliano Ciavatta.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, version 3.
+ *
+ * This program is distributed in the hope that it will be useful, but
+ * WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ */
+
+import React, {Component} from "react";
import Table from "react-bootstrap/Table";
import backend from "../../backend";
+import dispatcher from "../../dispatcher";
import {createCurlCommand, dateTimeToTime, durationBetween, formatSize} from "../../utils";
-import InputField from "../fields/InputField";
+import ButtonField from "../fields/ButtonField";
import CheckField from "../fields/CheckField";
+import InputField from "../fields/InputField";
import TextField from "../fields/TextField";
-import ButtonField from "../fields/ButtonField";
+import CopyLinkPopover from "../objects/CopyLinkPopover";
import LinkPopover from "../objects/LinkPopover";
+import "./common.scss";
+import "./PcapsPane.scss";
-class PcapPane extends Component {
-
- constructor(props) {
- super(props);
+class PcapsPane extends Component {
- this.state = {
- sessions: [],
- isUploadFileValid: true,
- isUploadFileFocused: false,
- uploadFlushAll: false,
- isFileValid: true,
- isFileFocused: false,
- fileValue: "",
- processFlushAll: false,
- deleteOriginalFile: false
- };
- }
+ state = {
+ sessions: [],
+ isUploadFileValid: true,
+ isUploadFileFocused: false,
+ uploadFlushAll: false,
+ isFileValid: true,
+ isFileFocused: false,
+ fileValue: "",
+ processFlushAll: false,
+ deleteOriginalFile: false
+ };
componentDidMount() {
this.loadSessions();
+ dispatcher.register("notifications", this.handleNotifications);
+ document.title = "caronte:~/pcaps$";
}
+ componentWillUnmount() {
+ dispatcher.unregister(this.handleNotifications);
+ }
+
+ handleNotifications = (payload) => {
+ if (payload.event.startsWith("pcap")) {
+ this.loadSessions();
+ }
+ };
+
loadSessions = () => {
backend.get("/api/pcap/sessions")
- .then(res => this.setState({sessions: res.json, sessionsStatusCode: res.status}))
- .catch(res => this.setState({
+ .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)
}));
@@ -50,14 +77,14 @@ class PcapPane extends Component {
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 => {
+ 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({
+ }).catch((res) => this.setState({
uploadStatusCode: res.status,
uploadResponse: JSON.stringify(res.json)
})
@@ -71,17 +98,17 @@ class PcapPane extends Component {
}
backend.post("/api/pcap/file", {
- file: this.state.fileValue,
- flush_all: this.state.processFlushAll,
- delete_original_file: this.state.deleteOriginalFile
- }).then(res => {
+ "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({
+ }).catch((res) => this.setState({
processStatusCode: res.status,
processResponse: JSON.stringify(res.json)
})
@@ -108,10 +135,19 @@ class PcapPane extends Component {
};
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>
+ let sessions = this.state.sessions.map((s) => {
+ const startedAt = new Date(s["started_at"]);
+ const completedAt = new Date(s["completed_at"]);
+ let timeInfo = <div>
+ <span>Started at {startedAt.toLocaleDateString() + " " + startedAt.toLocaleTimeString()}</span><br/>
+ <span>Completed at {completedAt.toLocaleDateString() + " " + completedAt.toLocaleTimeString()}</span>
+ </div>;
+
+ return <tr key={s.id} className="row-small row-clickable">
+ <td><CopyLinkPopover text={s["id"].substring(0, 8)} value={s["id"]}/></td>
+ <td>
+ <LinkPopover text={dateTimeToTime(s["started_at"])} content={timeInfo} placement="right"/>
+ </td>
<td>{durationBetween(s["started_at"], s["completed_at"])}</td>
<td>{formatSize(s["size"])}</td>
<td>{s["processed_packets"]}</td>
@@ -121,8 +157,8 @@ class PcapPane extends Component {
placement="left"/></td>
<td className="table-cell-action"><a href={"/api/pcap/sessions/" + s["id"] + "/download"}>download</a>
</td>
- </tr>
- );
+ </tr>;
+ });
const handleUploadFileChange = (file) => {
this.setState({
@@ -144,16 +180,16 @@ class PcapPane extends Component {
});
};
- const uploadCurlCommand = createCurlCommand("pcap/upload", "POST", null, {
- file: "@" + ((this.state.uploadSelectedFile != null && this.state.isUploadFileValid) ?
+ 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
+ "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
+ const fileCurlCommand = createCurlCommand("/pcap/file", "POST", {
+ "file": this.state.fileValue,
+ "flush_all": this.state.processFlushAll,
+ "delete_original_file": this.state.deleteOriginalFile
});
return (
@@ -207,7 +243,7 @@ class PcapPane extends Component {
<div className="upload-options">
<span>options:</span>
<CheckField name="flush_all" checked={this.state.uploadFlushAll}
- onChange={v => this.setState({uploadFlushAll: v})}/>
+ onChange={(v) => this.setState({uploadFlushAll: v})}/>
</div>
<ButtonField variant="green" bordered onClick={this.uploadPcap} name="upload"/>
</div>
@@ -232,9 +268,9 @@ class PcapPane extends Component {
<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})}/>
+ onChange={(v) => this.setState({processFlushAll: v})}/>
<CheckField name="delete_original_file" checked={this.state.deleteOriginalFile}
- onChange={v => this.setState({deleteOriginalFile: v})}/>
+ onChange={(v) => this.setState({deleteOriginalFile: v})}/>
</div>
<ButtonField variant="blue" bordered onClick={this.processPcap} name="process"/>
</div>
@@ -248,4 +284,4 @@ class PcapPane extends Component {
}
}
-export default PcapPane;
+export default PcapsPane;
diff --git a/frontend/src/components/panels/PcapPane.scss b/frontend/src/components/panels/PcapsPane.scss
index 4dbc2b2..4dbc2b2 100644
--- a/frontend/src/components/panels/PcapPane.scss
+++ b/frontend/src/components/panels/PcapsPane.scss
diff --git a/frontend/src/components/panels/RulePane.js b/frontend/src/components/panels/RulesPane.js
index 49364d2..cdfe185 100644
--- a/frontend/src/components/panels/RulePane.js
+++ b/frontend/src/components/panels/RulesPane.js
@@ -1,45 +1,42 @@
-import React, {Component} from 'react';
-import './common.scss';
-import './RulePane.scss';
-import Table from "react-bootstrap/Table";
+/*
+ * This file is part of caronte (https://github.com/eciavatta/caronte).
+ * Copyright (c) 2020 Emiliano Ciavatta.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, version 3.
+ *
+ * This program is distributed in the hope that it will be useful, but
+ * WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ */
+
+import React, {Component} from "react";
import {Col, Container, Row} from "react-bootstrap";
-import InputField from "../fields/InputField";
-import CheckField from "../fields/CheckField";
-import TextField from "../fields/TextField";
+import Table from "react-bootstrap/Table";
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 dispatcher from "../../dispatcher";
import validation from "../../validation";
+import ButtonField from "../fields/ButtonField";
+import CheckField from "../fields/CheckField";
+import ChoiceField from "../fields/ChoiceField";
+import ColorField from "../fields/extensions/ColorField";
+import NumericField from "../fields/extensions/NumericField";
+import InputField from "../fields/InputField";
+import TextField from "../fields/TextField";
+import CopyLinkPopover from "../objects/CopyLinkPopover";
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
- };
+import "./common.scss";
+import "./RulesPane.scss";
- this.directions = {
- 0: "both",
- 1: "c->s",
- 2: "s->c"
- };
- }
+const classNames = require("classnames");
+const _ = require("lodash");
- componentDidMount() {
- this.reset();
- this.loadRules();
- }
+class RulesPane extends Component {
emptyRule = {
"name": "",
@@ -58,7 +55,6 @@ class RulePane extends Component {
},
"version": 0
};
-
emptyPattern = {
"regex": "",
"flags": {
@@ -72,19 +68,52 @@ class RulePane extends Component {
"max_occurrences": 0,
"direction": 0
};
+ state = {
+ rules: [],
+ newRule: this.emptyRule,
+ newPattern: this.emptyPattern
+ };
+
+ constructor(props) {
+ super(props);
+
+ this.directions = {
+ 0: "both",
+ 1: "c->s",
+ 2: "s->c"
+ };
+ }
+
+ componentDidMount() {
+ this.reset();
+ this.loadRules();
+
+ dispatcher.register("notifications", this.handleNotifications);
+ document.title = "caronte:~/rules$";
+ }
+
+ componentWillUnmount() {
+ dispatcher.unregister(this.handleNotifications);
+ }
+
+ handleNotifications = (payload) => {
+ if (payload.event === "rules.new" || payload.event === "rules.edit") {
+ this.loadRules();
+ }
+ };
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)}));
+ 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 => {
+ backend.post("/api/rules", this.state.newRule).then((res) => {
this.reset();
this.setState({ruleStatusCode: res.status});
this.loadRules();
- }).catch(res => {
+ }).catch((res) => {
this.setState({ruleStatusCode: res.status, ruleResponse: JSON.stringify(res.json)});
});
}
@@ -93,11 +122,11 @@ class RulePane extends Component {
updateRule = () => {
const rule = this.state.selectedRule;
if (this.validateRule(rule)) {
- backend.put(`/api/rules/${rule.id}`, rule).then(res => {
+ backend.put(`/api/rules/${rule.id}`, rule).then((res) => {
this.reset();
this.setState({ruleStatusCode: res.status});
this.loadRules();
- }).catch(res => {
+ }).catch((res) => {
this.setState({ruleStatusCode: res.status, ruleResponse: JSON.stringify(res.json)});
});
}
@@ -113,23 +142,23 @@ class RulePane extends Component {
this.setState({ruleColorError: "color is not hexcolor"});
valid = false;
}
- if (!validation.isValidPort(rule.filter.service_port)) {
+ if (!validation.isValidPort(rule.filter["service_port"])) {
this.setState({ruleServicePortError: "service_port > 65565"});
valid = false;
}
- if (!validation.isValidPort(rule.filter.client_port)) {
+ if (!validation.isValidPort(rule.filter["client_port"])) {
this.setState({ruleClientPortError: "client_port > 65565"});
valid = false;
}
- if (!validation.isValidAddress(rule.filter.client_address)) {
+ 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) {
+ 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) {
+ if (rule.filter["min_bytes"] > rule.filter["max_bytes"]) {
this.setState({ruleBytesError: "min_bytes > max_bytes"});
valid = false;
}
@@ -146,9 +175,9 @@ class RulePane extends Component {
const newPattern = _.cloneDeep(this.emptyPattern);
this.setState({
selectedRule: null,
- newRule: newRule,
+ newRule,
selectedPattern: null,
- newPattern: newPattern,
+ newPattern,
patternRegexFocused: false,
patternOccurrencesFocused: false,
ruleNameError: null,
@@ -181,9 +210,7 @@ class RulePane extends Component {
const newPattern = _.cloneDeep(this.emptyPattern);
this.currentRule().patterns.push(pattern);
- this.setState({
- newPattern: newPattern
- });
+ this.setState({newPattern});
};
editPattern = (pattern) => {
@@ -208,7 +235,7 @@ class RulePane extends Component {
valid = false;
this.setState({patternRegexFocused: true});
}
- if (pattern.min_occurrences > pattern.max_occurrences) {
+ if (pattern["min_occurrences"] > pattern["max_occurrences"]) {
valid = false;
this.setState({patternOccurrencesFocused: true});
}
@@ -220,71 +247,72 @@ class RulePane extends Component {
const rule = this.currentRule();
const pattern = this.state.selectedPattern || this.state.newPattern;
- let rules = this.state.rules.map(r =>
+ 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>
+ }} className={classNames("row-small", "row-clickable", {"row-selected": rule.id === r.id})}>
+ <CopyLinkPopover text={r["id"].substring(0, 8)} value={r["id"]}/>
<td>{r["name"]}</td>
- <td><ButtonField name={r["color"]} color={r["color"]} small /></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()}>
+ rule.patterns.concat(this.state.newPattern) :
+ rule.patterns
+ ).map((p) => p === pattern ?
+ <tr key={"new_pattern"}>
<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><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}
+ <NumericField small value={pattern["min_occurrences"]}
active={this.state.patternOccurrencesFocused}
- onChange={(v) => this.updateParam(() => pattern.min_occurrences = v)} />
+ onChange={(v) => this.updateParam(() => pattern["min_occurrences"] = v)}/>
</td>
<td style={{"width": "70px"}}>
- <NumericField small value={pattern.max_occurrences}
+ <NumericField small value={pattern["max_occurrences"]}
active={this.state.patternOccurrencesFocused}
- onChange={(v) => this.updateParam(() => pattern.max_occurrences = v)} />
+ 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>
+ 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)}/>}
+ <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>{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>}
+ onClick={() => this.editPattern(p)}/></td>}
</tr>
);
@@ -294,9 +322,9 @@ class RulePane extends Component {
<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>}
+ <span className="api-response"><LinkPopover text={this.state.rulesStatusCode}
+ content={this.state.rulesResponse}
+ placement="left"/></span>}
</div>
<div className="section-content">
@@ -325,7 +353,7 @@ class RulePane extends Component {
</span>
<span className="api-response"><LinkPopover text={this.state.ruleStatusCode}
content={this.state.ruleResponse}
- placement="left" /></span>
+ placement="left"/></span>
</div>
<div className="section-content">
@@ -334,41 +362,41 @@ class RulePane extends Component {
<Col>
<InputField name="name" inline value={rule.name}
onChange={(v) => this.updateParam((r) => r.name = v)}
- error={this.state.ruleNameError} />
+ error={this.state.ruleNameError}/>
<ColorField inline value={rule.color} error={this.state.ruleColorError}
- onChange={(v) => this.updateParam((r) => r.color = v)} />
+ onChange={(v) => this.updateParam((r) => r.color = v)}/>
<TextField name="notes" rows={2} value={rule.notes}
- onChange={(v) => this.updateParam((r) => r.notes = v)} />
+ 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)}
+ <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)}
+ 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}
+ 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)} />
+ 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}
+ <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}
+ 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}
+ 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}
+ 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)} />
+ onChange={(v) => this.updateParam((r) => r.filter["max_bytes"] = v)}/>
</Col>
</Row>
</Container>
@@ -386,7 +414,7 @@ class RulePane extends Component {
<th>min</th>
<th>max</th>
<th>direction</th>
- {!isUpdate && <th>actions</th> }
+ {!isUpdate && <th>actions</th>}
</tr>
</thead>
<tbody>
@@ -401,7 +429,7 @@ class RulePane extends Component {
<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} />
+ bordered onClick={isUpdate ? this.updateRule : this.addRule}/>
</div>
</div>
</div>
@@ -410,4 +438,4 @@ class RulePane extends Component {
}
-export default RulePane;
+export default RulesPane;
diff --git a/frontend/src/components/panels/RulePane.scss b/frontend/src/components/panels/RulesPane.scss
index 992445a..992445a 100644
--- a/frontend/src/components/panels/RulePane.scss
+++ b/frontend/src/components/panels/RulesPane.scss
diff --git a/frontend/src/components/panels/SearchPane.js b/frontend/src/components/panels/SearchPane.js
new file mode 100644
index 0000000..4ef5632
--- /dev/null
+++ b/frontend/src/components/panels/SearchPane.js
@@ -0,0 +1,309 @@
+/*
+ * This file is part of caronte (https://github.com/eciavatta/caronte).
+ * Copyright (c) 2020 Emiliano Ciavatta.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, version 3.
+ *
+ * This program is distributed in the hope that it will be useful, but
+ * WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ */
+
+import React, {Component} from "react";
+import Table from "react-bootstrap/Table";
+import backend from "../../backend";
+import dispatcher from "../../dispatcher";
+import {createCurlCommand, dateTimeToTime, durationBetween} from "../../utils";
+import ButtonField from "../fields/ButtonField";
+import CheckField from "../fields/CheckField";
+import InputField from "../fields/InputField";
+import TagField from "../fields/TagField";
+import TextField from "../fields/TextField";
+import LinkPopover from "../objects/LinkPopover";
+import "./common.scss";
+import "./SearchPane.scss";
+
+const _ = require("lodash");
+
+class SearchPane extends Component {
+
+ searchOptions = {
+ "text_search": {
+ "terms": null,
+ "excluded_terms": null,
+ "exact_phrase": "",
+ "case_sensitive": false
+ },
+ "regex_search": {
+ "pattern": "",
+ "not_pattern": "",
+ "case_insensitive": false,
+ "multi_line": false,
+ "ignore_whitespaces": false,
+ "dot_character": false
+ },
+ "timeout": 10
+ };
+
+ state = {
+ searches: [],
+ currentSearchOptions: this.searchOptions,
+ };
+
+ componentDidMount() {
+ this.reset();
+ this.loadSearches();
+
+ dispatcher.register("notifications", this.handleNotification);
+ document.title = "caronte:~/searches$";
+ }
+
+ componentWillUnmount() {
+ dispatcher.unregister(this.handleNotification);
+ }
+
+ loadSearches = () => {
+ backend.get("/api/searches")
+ .then((res) => this.setState({searches: res.json, searchesStatusCode: res.status}))
+ .catch((res) => this.setState({searchesStatusCode: res.status, searchesResponse: JSON.stringify(res.json)}));
+ };
+
+ performSearch = () => {
+ const options = this.state.currentSearchOptions;
+ this.setState({loading: true});
+ if (this.validateSearch(options)) {
+ backend.post("/api/searches/perform", options).then((res) => {
+ this.reset();
+ this.setState({searchStatusCode: res.status, loading: false});
+ this.loadSearches();
+ this.viewSearch(res.json.id);
+ }).catch((res) => {
+ this.setState({
+ searchStatusCode: res.status, searchResponse: JSON.stringify(res.json),
+ loading: false
+ });
+ });
+ }
+ };
+
+ reset = () => {
+ this.setState({
+ currentSearchOptions: _.cloneDeep(this.searchOptions),
+ exactPhraseError: null,
+ patternError: null,
+ notPatternError: null,
+ searchStatusCode: null,
+ searchesStatusCode: null,
+ searchResponse: null,
+ searchesResponse: null
+ });
+ };
+
+ validateSearch = (options) => {
+ let valid = true;
+ if (options["text_search"]["exact_phrase"] && options["text_search"]["exact_phrase"].length < 3) {
+ this.setState({exactPhraseError: "text_search.exact_phrase.length < 3"});
+ valid = false;
+ }
+ if (options["regex_search"].pattern && options["regex_search"].pattern.length < 3) {
+ this.setState({patternError: "regex_search.pattern.length < 3"});
+ valid = false;
+ }
+ if (options["regex_search"]["not_pattern"] && options["regex_search"]["not_pattern"].length < 3) {
+ this.setState({exactPhraseError: "regex_search.not_pattern.length < 3"});
+ valid = false;
+ }
+
+ return valid;
+ };
+
+ updateParam = (callback) => {
+ callback(this.state.currentSearchOptions);
+ this.setState({currentSearchOptions: this.state.currentSearchOptions});
+ };
+
+ extractPattern = (options) => {
+ let pattern = "";
+ if (_.isEqual(options.regex_search, this.searchOptions.regex_search)) { // is text search
+ if (options["text_search"]["exact_phrase"]) {
+ pattern += `"${options["text_search"]["exact_phrase"]}"`;
+ } else {
+ pattern += options["text_search"].terms.join(" ");
+ if (options["text_search"]["excluded_terms"]) {
+ pattern += " -" + options["text_search"]["excluded_terms"].join(" -");
+ }
+ }
+ options["text_search"]["case_sensitive"] && (pattern += "/s");
+ } else { // is regex search
+ if (options["regex_search"].pattern) {
+ pattern += "/" + options["regex_search"].pattern + "/";
+ } else {
+ pattern += "!/" + options["regex_search"]["not_pattern"] + "/";
+ }
+ options["regex_search"]["case_insensitive"] && (pattern += "i");
+ options["regex_search"]["multi_line"] && (pattern += "m");
+ options["regex_search"]["ignore_whitespaces"] && (pattern += "x");
+ options["regex_search"]["dot_character"] && (pattern += "s");
+ }
+
+ return pattern;
+ };
+
+ viewSearch = (searchId) => {
+ dispatcher.dispatch("connections_filters", {"performed_search": searchId});
+ };
+
+ handleNotification = (payload) => {
+ if (payload.event === "searches.new") {
+ this.loadSearches();
+ }
+ };
+
+ render() {
+ const options = this.state.currentSearchOptions;
+
+ let searches = this.state.searches.map((s) =>
+ <tr key={s.id} className="row-small row-clickable">
+ <td>{s.id.substring(0, 8)}</td>
+ <td>{this.extractPattern(s["search_options"])}</td>
+ <td>{s["affected_connections_count"]}</td>
+ <td>{dateTimeToTime(s["started_at"])}</td>
+ <td>{durationBetween(s["started_at"], s["finished_at"])}</td>
+ <td><ButtonField name="view" variant="green" small onClick={() => this.viewSearch(s.id)}/></td>
+ </tr>
+ );
+
+ const textOptionsModified = !_.isEqual(this.searchOptions.text_search, options.text_search);
+ const regexOptionsModified = !_.isEqual(this.searchOptions.regex_search, options.regex_search);
+
+ const curlCommand = createCurlCommand("/searches/perform", "POST", options);
+
+ return (
+ <div className="pane-container search-pane">
+ <div className="pane-section searches-list">
+ <div className="section-header">
+ <span className="api-request">GET /api/searches</span>
+ {this.state.searchesStatusCode &&
+ <span className="api-response"><LinkPopover text={this.state.searchesStatusCode}
+ content={this.state.searchesResponse}
+ placement="left"/></span>}
+ </div>
+
+ <div className="section-content">
+ <div className="section-table">
+ <Table borderless size="sm">
+ <thead>
+ <tr>
+ <th>id</th>
+ <th>pattern</th>
+ <th>occurrences</th>
+ <th>started_at</th>
+ <th>duration</th>
+ <th>actions</th>
+ </tr>
+ </thead>
+ <tbody>
+ {searches}
+ </tbody>
+ </Table>
+ </div>
+ </div>
+ </div>
+
+ <div className="pane-section search-new">
+ <div className="section-header">
+ <span className="api-request">POST /api/searches/perform</span>
+ <span className="api-response"><LinkPopover text={this.state.searchStatusCode}
+ content={this.state.searchResponse}
+ placement="left"/></span>
+ </div>
+
+ <div className="section-content">
+ <span className="notes">
+ NOTE: it is recommended to use the rules for recurring themes. Give preference to textual search over that with regex.
+ </span>
+
+ <div className="content-row">
+ <div className="text-search">
+ <TagField tags={(options["text_search"].terms || []).map((t) => {
+ return {name: t};
+ })}
+ name="terms" min={3} inline allowNew={true}
+ readonly={regexOptionsModified || options["text_search"]["exact_phrase"]}
+ onChange={(tags) => this.updateParam((s) => s["text_search"].terms = tags.map((t) => t.name))}/>
+ <TagField tags={(options["text_search"]["excluded_terms"] || []).map((t) => {
+ return {name: t};
+ })}
+ name="excluded_terms" min={3} inline allowNew={true}
+ readonly={regexOptionsModified || options["text_search"]["exact_phrase"]}
+ onChange={(tags) => this.updateParam((s) => s["text_search"]["excluded_terms"] = tags.map((t) => t.name))}/>
+
+ <span className="exclusive-separator">or</span>
+
+ <InputField name="exact_phrase" value={options["text_search"]["exact_phrase"]} inline
+ error={this.state.exactPhraseError}
+ onChange={(v) => this.updateParam((s) => s["text_search"]["exact_phrase"] = v)}
+ readonly={regexOptionsModified || (Array.isArray(options["text_search"].terms) && options["text_search"].terms.length > 0)}/>
+
+ <CheckField checked={options["text_search"]["case_sensitive"]} name="case_sensitive"
+ readonly={regexOptionsModified} small
+ onChange={(v) => this.updateParam((s) => s["text_search"]["case_sensitive"] = v)}/>
+ </div>
+
+ <div className="separator">
+ <span>or</span>
+ </div>
+
+ <div className="regex-search">
+ <InputField name="pattern" value={options["regex_search"].pattern} inline
+ error={this.state.patternError}
+ readonly={textOptionsModified || options["regex_search"]["not_pattern"]}
+ onChange={(v) => this.updateParam((s) => s["regex_search"].pattern = v)}/>
+ <span className="exclusive-separator">or</span>
+ <InputField name="not_pattern" value={options["regex_search"]["not_pattern"]} inline
+ error={this.state.notPatternError}
+ readonly={textOptionsModified || options["regex_search"].pattern}
+ onChange={(v) => this.updateParam((s) => s["regex_search"]["not_pattern"] = v)}/>
+
+ <div className="checkbox-line">
+ <CheckField checked={options["regex_search"]["case_insensitive"]}
+ name="case_insensitive"
+ readonly={textOptionsModified} small
+ onChange={(v) => this.updateParam((s) => s["regex_search"]["case_insensitive"] = v)}/>
+ <CheckField checked={options["regex_search"]["multi_line"]} name="multi_line"
+ readonly={textOptionsModified} small
+ onChange={(v) => this.updateParam((s) => s["regex_search"]["multi_line"] = v)}/>
+ <CheckField checked={options["regex_search"]["ignore_whitespaces"]}
+ name="ignore_whitespaces"
+ readonly={textOptionsModified} small
+ onChange={(v) => this.updateParam((s) => s["regex_search"]["ignore_whitespaces"] = v)}/>
+ <CheckField checked={options["regex_search"]["dot_character"]} name="dot_character"
+ readonly={textOptionsModified} small
+ onChange={(v) => this.updateParam((s) => s["regex_search"]["dot_character"] = v)}/>
+ </div>
+ </div>
+ </div>
+
+ <TextField value={curlCommand} rows={3} readonly small={true}/>
+ </div>
+
+ <div className="section-footer">
+ <ButtonField variant="red" name="cancel" bordered disabled={this.state.loading}
+ onClick={this.reset}/>
+ <ButtonField variant="green" name="perform_search" bordered
+ disabled={this.state.loading} onClick={this.performSearch}/>
+ </div>
+ </div>
+ </div>
+ );
+ }
+
+}
+
+export default SearchPane;
diff --git a/frontend/src/components/panels/SearchPane.scss b/frontend/src/components/panels/SearchPane.scss
new file mode 100644
index 0000000..63e11fb
--- /dev/null
+++ b/frontend/src/components/panels/SearchPane.scss
@@ -0,0 +1,52 @@
+.search-pane {
+ display: flex;
+ flex-direction: column;
+
+ .searches-list {
+ overflow: hidden;
+ flex: 2 1;
+
+ .section-content {
+ height: 100%;
+ }
+
+ .section-table {
+ height: calc(100% - 30px);
+ }
+ }
+
+ .search-new {
+ .content-row {
+ display: flex;
+
+ .text-search,
+ .regex-search {
+ flex: 1;
+ }
+
+ .exclusive-separator {
+ font-size: 0.8em;
+ display: block;
+ text-align: center;
+ }
+
+ .separator {
+ font-size: 0.9em;
+ flex: 0;
+ margin: auto 10px;
+ }
+ }
+
+ .notes {
+ font-size: 0.8em;
+ }
+
+ .checkbox-line {
+ .check-field {
+ display: inline-block;
+ margin-top: 0;
+ margin-right: 10px;
+ }
+ }
+ }
+}
diff --git a/frontend/src/components/panels/ServicePane.js b/frontend/src/components/panels/ServicesPane.js
index eaefa64..5986804 100644
--- a/frontend/src/components/panels/ServicePane.js
+++ b/frontend/src/components/panels/ServicesPane.js
@@ -1,58 +1,85 @@
-import React, {Component} from 'react';
-import './common.scss';
-import './ServicePane.scss';
-import Table from "react-bootstrap/Table";
+/*
+ * This file is part of caronte (https://github.com/eciavatta/caronte).
+ * Copyright (c) 2020 Emiliano Ciavatta.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, version 3.
+ *
+ * This program is distributed in the hope that it will be useful, but
+ * WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ */
+
+import React, {Component} from "react";
import {Col, Container, Row} from "react-bootstrap";
-import InputField from "../fields/InputField";
-import TextField from "../fields/TextField";
+import Table from "react-bootstrap/Table";
import backend from "../../backend";
-import NumericField from "../fields/extensions/NumericField";
-import ColorField from "../fields/extensions/ColorField";
-import ButtonField from "../fields/ButtonField";
+import dispatcher from "../../dispatcher";
+import {createCurlCommand} from "../../utils";
import validation from "../../validation";
+import ButtonField from "../fields/ButtonField";
+import ColorField from "../fields/extensions/ColorField";
+import NumericField from "../fields/extensions/NumericField";
+import InputField from "../fields/InputField";
+import TextField from "../fields/TextField";
import LinkPopover from "../objects/LinkPopover";
-import {createCurlCommand} from "../../utils";
+import "./common.scss";
+import "./ServicesPane.scss";
-const classNames = require('classnames');
-const _ = require('lodash');
+const classNames = require("classnames");
+const _ = require("lodash");
-class ServicePane extends Component {
+class ServicesPane extends Component {
- constructor(props) {
- super(props);
+ emptyService = {
+ "port": 0,
+ "name": "",
+ "color": "",
+ "notes": ""
+ };
- this.state = {
- services: [],
- currentService: this.emptyService,
- };
- }
+ state = {
+ services: [],
+ currentService: this.emptyService,
+ };
componentDidMount() {
this.reset();
this.loadServices();
+
+ dispatcher.register("notifications", this.handleNotifications);
+ document.title = "caronte:~/services$";
}
- emptyService = {
- "port": 0,
- "name": "",
- "color": "",
- "notes": ""
+ componentWillUnmount() {
+ dispatcher.unregister(this.handleNotifications);
+ }
+
+ handleNotifications = (payload) => {
+ if (payload.event === "services.edit") {
+ this.loadServices();
+ }
};
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)}));
+ .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 => {
+ backend.put("/api/services", service).then((res) => {
this.reset();
this.setState({serviceStatusCode: res.status});
this.loadServices();
- }).catch(res => {
+ }).catch((res) => {
this.setState({serviceStatusCode: res.status, serviceResponse: JSON.stringify(res.json)});
});
}
@@ -99,14 +126,14 @@ class ServicePane extends Component {
const isUpdate = this.state.isUpdate;
const service = this.state.currentService;
- let services = this.state.services.map(s =>
+ 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 })}>
+ }} 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><ButtonField name={s["color"]} color={s["color"]} small/></td>
<td>{s["notes"]}</td>
</tr>
);
@@ -119,9 +146,9 @@ class ServicePane extends Component {
<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>}
+ <span className="api-response"><LinkPopover text={this.state.servicesStatusCode}
+ content={this.state.servicesResponse}
+ placement="left"/></span>}
</div>
<div className="section-content">
@@ -148,7 +175,7 @@ class ServicePane extends Component {
<span className="api-request">PUT /api/services</span>
<span className="api-response"><LinkPopover text={this.state.serviceStatusCode}
content={this.state.serviceResponse}
- placement="left" /></span>
+ placement="left"/></span>
</div>
<div className="section-content">
@@ -157,17 +184,17 @@ class ServicePane extends Component {
<Col>
<NumericField name="port" value={service.port}
onChange={(v) => this.updateParam((s) => s.port = v)}
- min={0} max={65565} error={this.state.servicePortError} />
+ 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} />
+ error={this.state.serviceNameError}/>
<ColorField value={service.color} error={this.state.serviceColorError}
- onChange={(v) => this.updateParam((s) => s.color = v)} />
+ 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)} />
+ onChange={(v) => this.updateParam((s) => s.notes = v)}/>
</Col>
</Row>
</Container>
@@ -177,8 +204,9 @@ class ServicePane extends Component {
<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} />
+ <ButtonField variant={isUpdate ? "blue" : "green"}
+ name={isUpdate ? "update_service" : "add_service"}
+ bordered onClick={this.updateService}/>
</div>
</div>
</div>
@@ -187,4 +215,4 @@ class ServicePane extends Component {
}
-export default ServicePane;
+export default ServicesPane;
diff --git a/frontend/src/components/panels/ServicePane.scss b/frontend/src/components/panels/ServicesPane.scss
index daf7e79..daf7e79 100644
--- a/frontend/src/components/panels/ServicePane.scss
+++ b/frontend/src/components/panels/ServicesPane.scss
diff --git a/frontend/src/components/panels/StreamsPane.js b/frontend/src/components/panels/StreamsPane.js
new file mode 100644
index 0000000..9470d7d
--- /dev/null
+++ b/frontend/src/components/panels/StreamsPane.js
@@ -0,0 +1,241 @@
+/*
+ * This file is part of caronte (https://github.com/eciavatta/caronte).
+ * Copyright (c) 2020 Emiliano Ciavatta.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, version 3.
+ *
+ * This program is distributed in the hope that it will be useful, but
+ * WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ */
+
+import DOMPurify from "dompurify";
+import React, {Component} from "react";
+import {Row} from "react-bootstrap";
+import ReactJson from "react-json-view";
+import backend from "../../backend";
+import log from "../../log";
+import {downloadBlob, getHeaderValue} from "../../utils";
+import ButtonField from "../fields/ButtonField";
+import ChoiceField from "../fields/ChoiceField";
+import MessageAction from "../objects/MessageAction";
+import "./StreamsPane.scss";
+
+const classNames = require("classnames");
+
+class StreamsPane extends Component {
+
+ state = {
+ messages: [],
+ format: "default",
+ tryParse: true
+ };
+
+ constructor(props) {
+ super(props);
+
+ this.validFormats = ["default", "hex", "hexdump", "base32", "base64", "ascii", "binary", "decimal", "octal"];
+ }
+
+ componentDidMount() {
+ if (this.props.connection && this.state.currentId !== this.props.connection.id) {
+ this.setState({currentId: this.props.connection.id});
+ this.loadStream(this.props.connection.id);
+ }
+
+ document.title = "caronte:~/$";
+ }
+
+ componentDidUpdate(prevProps, prevState, snapshot) {
+ if (this.props.connection && (
+ this.props.connection !== prevProps.connection || this.state.format !== prevState.format)) {
+ this.closeRenderWindow();
+ this.loadStream(this.props.connection.id);
+ }
+ }
+
+ componentWillUnmount() {
+ this.closeRenderWindow();
+ }
+
+ loadStream = (connectionId) => {
+ this.setState({messages: [], currentId: connectionId});
+ backend.get(`/api/streams/${connectionId}?format=${this.state.format}`)
+ .then((res) => this.setState({messages: res.json}));
+ };
+
+ setFormat = (format) => {
+ if (this.validFormats.includes(format)) {
+ this.setState({format});
+ }
+ };
+
+ tryParseConnectionMessage = (connectionMessage) => {
+ if (connectionMessage.metadata == null) {
+ return connectionMessage.content;
+ }
+ if (connectionMessage["is_metadata_continuation"]) {
+ return <span style={{"fontSize": "12px"}}>**already parsed in previous messages**</span>;
+ }
+
+ let unrollMap = (obj) => obj == null ? null : Object.entries(obj).map(([key, value]) =>
+ <p key={key}><strong>{key}</strong>: {value}</p>
+ );
+
+ let m = connectionMessage.metadata;
+ switch (m.type) {
+ case "http-request":
+ let url = <i><u><a href={"http://" + m.host + m.url} target="_blank"
+ rel="noopener noreferrer">{m.host}{m.url}</a></u></i>;
+ return <span className="type-http-request">
+ <p style={{"marginBottom": "7px"}}><strong>{m.method}</strong> {url} {m.protocol}</p>
+ {unrollMap(m.headers)}
+ <div style={{"margin": "20px 0"}}>{m.body}</div>
+ {unrollMap(m.trailers)}
+ </span>;
+ case "http-response":
+ const contentType = getHeaderValue(m, "Content-Type");
+ let body = m.body;
+ if (contentType && contentType.includes("application/json")) {
+ try {
+ const json = JSON.parse(m.body);
+ body = <ReactJson src={json} theme="grayscale" collapsed={false} displayDataTypes={false}/>;
+ } catch (e) {
+ log.error(e);
+ }
+ }
+
+ return <span className="type-http-response">
+ <p style={{"marginBottom": "7px"}}>{m.protocol} <strong>{m.status}</strong></p>
+ {unrollMap(m.headers)}
+ <div style={{"margin": "20px 0"}}>{body}</div>
+ {unrollMap(m.trailers)}
+ </span>;
+ default:
+ return connectionMessage.content;
+ }
+ };
+
+ connectionsActions = (connectionMessage) => {
+ if (!connectionMessage.metadata) {
+ return null;
+ }
+
+ const m = connectionMessage.metadata;
+ switch (m.type) {
+ case "http-request" :
+ if (!connectionMessage.metadata["reproducers"]) {
+ return;
+ }
+ return Object.entries(connectionMessage.metadata["reproducers"]).map(([actionName, actionValue]) =>
+ <ButtonField small key={actionName + "_button"} name={actionName} onClick={() => {
+ this.setState({
+ messageActionDialog: <MessageAction actionName={actionName} actionValue={actionValue}
+ onHide={() => this.setState({messageActionDialog: null})}/>
+ });
+ }}/>
+ );
+ case "http-response":
+ const contentType = getHeaderValue(m, "Content-Type");
+
+ if (contentType && contentType.includes("text/html")) {
+ return <ButtonField small name="render_html" onClick={() => {
+ let w;
+ if (this.state.renderWindow && !this.state.renderWindow.closed) {
+ w = this.state.renderWindow;
+ } else {
+ w = window.open("", "", "width=900, height=600, scrollbars=yes");
+ this.setState({renderWindow: w});
+ }
+ w.document.body.innerHTML = DOMPurify.sanitize(m.body);
+ w.focus();
+ }}/>;
+ }
+ break;
+ default:
+ return null;
+ }
+ };
+
+ downloadStreamRaw = (value) => {
+ if (this.state.currentId) {
+ backend.download(`/api/streams/${this.props.connection.id}/download?format=${this.state.format}&type=${value}`)
+ .then((res) => downloadBlob(res.blob, `${this.state.currentId}-${value}-${this.state.format}.txt`))
+ .catch((_) => log.error("Failed to download stream messages"));
+ }
+ };
+
+ closeRenderWindow = () => {
+ if (this.state.renderWindow) {
+ this.state.renderWindow.close();
+ }
+ };
+
+ render() {
+ const conn = this.props.connection || {
+ "ip_src": "0.0.0.0",
+ "ip_dst": "0.0.0.0",
+ "port_src": "0",
+ "port_dst": "0",
+ "started_at": new Date().toISOString(),
+ };
+ const content = this.state.messages || [];
+
+ let payload = content.map((c, i) =>
+ <div key={`content-${i}`}
+ className={classNames("connection-message", c["from_client"] ? "from-client" : "from-server")}>
+ <div className="connection-message-header container-fluid">
+ <div className="row">
+ <div className="connection-message-info col">
+ <span><strong>offset</strong>: {c.index}</span> | <span><strong>timestamp</strong>: {c.timestamp}
+ </span> | <span><strong>retransmitted</strong>: {c["is_retransmitted"] ? "yes" : "no"}</span>
+ </div>
+ <div className="connection-message-actions col-auto">{this.connectionsActions(c)}</div>
+ </div>
+ </div>
+ <div className="connection-message-label">{c["from_client"] ? "client" : "server"}</div>
+ <div
+ className="message-content">
+ {this.state.tryParse && this.state.format === "default" ? this.tryParseConnectionMessage(c) : c.content}
+ </div>
+ </div>
+ );
+
+ return (
+ <div className="pane-container stream-pane">
+ <div className="stream-pane-header container-fluid">
+ <Row>
+ <div className="header-info col">
+ <span><strong>flow</strong>: {conn["ip_src"]}:{conn["port_src"]} -> {conn["ip_dst"]}:{conn["port_dst"]}</span>
+ <span> | <strong>timestamp</strong>: {conn["started_at"]}</span>
+ </div>
+ <div className="header-actions col-auto">
+ <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 onChange={this.downloadStreamRaw}
+ keys={["nl_separated", "only_client", "only_server", "pwntools"]}
+ values={["nl_separated", "only_client", "only_server", "pwntools"]}/>
+ </div>
+ </Row>
+ </div>
+
+ <pre>{payload}</pre>
+ {this.state.messageActionDialog}
+ </div>
+ );
+ }
+}
+
+
+export default StreamsPane;
diff --git a/frontend/src/components/ConnectionContent.scss b/frontend/src/components/panels/StreamsPane.scss
index c97a4b0..1f641f3 100644
--- a/frontend/src/components/ConnectionContent.scss
+++ b/frontend/src/components/panels/StreamsPane.scss
@@ -1,8 +1,6 @@
-@import "../colors.scss";
+@import "../../colors";
-.connection-content {
- height: 100%;
- padding: 10px 10px 0;
+.stream-pane {
background-color: $color-primary-0;
pre {
@@ -16,6 +14,10 @@
margin: 0;
padding: 0;
}
+
+ &:hover::-webkit-scrollbar-thumb {
+ background: $color-secondary-2;
+ }
}
.connection-message {
@@ -48,6 +50,10 @@
.message-content {
padding: 10px;
+
+ .react-json-view {
+ background-color: inherit !important;
+ }
}
&:hover .connection-message-actions {
@@ -84,15 +90,15 @@
}
}
- .connection-content-header {
+ .stream-pane-header {
height: 33px;
padding: 0;
- background-color: $color-primary-2;
+ background-color: $color-primary-3;
.header-info {
font-size: 12px;
padding-top: 7px;
- padding-left: 20px;
+ padding-left: 25px;
}
.header-actions {
@@ -100,6 +106,10 @@
.choice-field {
margin-top: -5px;
+
+ .field-value {
+ background-color: $color-primary-3;
+ }
}
}
}
diff --git a/frontend/src/components/panels/common.scss b/frontend/src/components/panels/common.scss
index 121a917..335e65b 100644
--- a/frontend/src/components/panels/common.scss
+++ b/frontend/src/components/panels/common.scss
@@ -2,11 +2,9 @@
.pane-container {
height: 100%;
- padding: 10px 10px 0;
background-color: $color-primary-3;
.pane-section {
- margin-bottom: 10px;
background-color: $color-primary-0;
.section-header {
@@ -14,7 +12,7 @@
font-weight: 500;
display: flex;
padding: 5px 10px;
- background-color: $color-primary-2;
+ background-color: $color-primary-3;
.api-request {
flex: 1;
@@ -34,6 +32,10 @@
margin-left: 10px;
color: $color-secondary-0;
}
+
+ &:hover::-webkit-scrollbar-thumb {
+ background: $color-secondary-2;
+ }
}
table {
@@ -98,4 +100,8 @@
margin-left: 5px;
}
}
+
+ &:hover::-webkit-scrollbar-thumb {
+ background: $color-secondary-2;
+ }
}