aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--frontend/src/components/App.jsx70
-rw-r--r--frontend/src/components/Header.jsx99
-rw-r--r--frontend/src/components/Notifications.jsx147
-rw-r--r--frontend/src/components/Timeline.jsx405
-rw-r--r--frontend/src/components/dialogs/CommentDialog.jsx70
-rw-r--r--frontend/src/components/dialogs/CopyDialog.jsx69
-rw-r--r--frontend/src/components/dialogs/Filters.jsx85
-rw-r--r--frontend/src/components/fields/ButtonField.jsx78
-rw-r--r--frontend/src/components/fields/CheckField.jsx67
-rw-r--r--frontend/src/components/fields/ChoiceField.jsx85
-rw-r--r--frontend/src/components/fields/InputField.jsx95
-rw-r--r--frontend/src/components/fields/TagField.jsx75
-rw-r--r--frontend/src/components/fields/TextField.jsx60
-rw-r--r--frontend/src/components/fields/extensions/ColorField.jsx98
-rw-r--r--frontend/src/components/fields/extensions/NumericField.jsx62
-rw-r--r--frontend/src/components/filters/AdvancedFilters.jsx54
-rw-r--r--frontend/src/components/filters/BooleanConnectionsFilter.jsx69
-rw-r--r--frontend/src/components/filters/ExitSearchFilter.jsx57
-rw-r--r--frontend/src/components/filters/RulesConnectionsFilter.jsx83
-rw-r--r--frontend/src/components/filters/StringConnectionsFilter.jsx127
-rw-r--r--frontend/src/components/objects/Connection.jsx116
-rw-r--r--frontend/src/components/objects/ConnectionMatchedRules.jsx51
-rw-r--r--frontend/src/components/objects/CopyLinkPopover.jsx54
-rw-r--r--frontend/src/components/objects/LinkPopover.jsx51
-rw-r--r--frontend/src/components/pages/ConfigurationPage.jsx183
-rw-r--r--frontend/src/components/pages/MainPage.jsx97
-rw-r--r--frontend/src/components/pages/ServiceUnavailablePage.jsx34
-rw-r--r--frontend/src/components/panels/ConnectionsPane.jsx310
-rw-r--r--frontend/src/components/panels/MainPane.jsx112
-rw-r--r--frontend/src/components/panels/PcapsPane.jsx287
-rw-r--r--frontend/src/components/panels/RulesPane.jsx469
-rw-r--r--frontend/src/components/panels/SearchPane.jsx309
-rw-r--r--frontend/src/components/panels/ServicesPane.jsx233
-rw-r--r--frontend/src/components/panels/StatsPane.jsx274
-rw-r--r--frontend/src/components/panels/StreamsPane.jsx453
-rw-r--r--frontend/src/index.jsx32
36 files changed, 5020 insertions, 0 deletions
diff --git a/frontend/src/components/App.jsx b/frontend/src/components/App.jsx
new file mode 100644
index 0000000..96083cd
--- /dev/null
+++ b/frontend/src/components/App.jsx
@@ -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/Header.jsx b/frontend/src/components/Header.jsx
new file mode 100644
index 0000000..4695bd9
--- /dev/null
+++ b/frontend/src/components/Header.jsx
@@ -0,0 +1,99 @@
+/*
+ * 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";
+
+import classNames from '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={classNames("header", {"configured": this.props.configured})}>
+ <div className="header-content">
+ <h1 className="header-title type-wrap">
+ <Link to="/">
+ <span style={{whiteSpace: "pre"}} ref={(el) => {
+ this.el = el;
+ }}/>
+ </Link>
+ </h1>
+
+ {this.props.configured &&
+ <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>
+ }
+
+ {this.props.configured &&
+ <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={"/stats" + this.props.location.search}>
+ <ButtonField variant="blue" name="stats" bordered/>
+ </Link>
+ </div>
+ }
+ </div>
+ </header>
+ );
+ }
+}
+
+export default withRouter(Header);
diff --git a/frontend/src/components/Notifications.jsx b/frontend/src/components/Notifications.jsx
new file mode 100644
index 0000000..5afdb7b
--- /dev/null
+++ b/frontend/src/components/Notifications.jsx
@@ -0,0 +1,147 @@
+/*
+ * 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";
+import _ from "lodash";
+import classNames from "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);
+ case "timeline.range.large":
+ n.title = "timeline cropped";
+ n.description = `the maximum range is 24h`;
+ n.variant = "red";
+ 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/Timeline.jsx b/frontend/src/components/Timeline.jsx
new file mode 100644
index 0000000..faaa8de
--- /dev/null
+++ b/frontend/src/components/Timeline.jsx
@@ -0,0 +1,405 @@
+/*
+ * 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 ChartContainer from "react-timeseries-charts/lib/components/ChartContainer";
+import ChartRow from "react-timeseries-charts/lib/components/ChartRow";
+import Charts from "react-timeseries-charts/lib/components/Charts";
+import LineChart from "react-timeseries-charts/lib/components/LineChart";
+import MultiBrush from "react-timeseries-charts/lib/components/MultiBrush";
+import Resizable from "react-timeseries-charts/lib/components/Resizable";
+import YAxis from "react-timeseries-charts/lib/components/YAxis";
+import { TimeRange, TimeSeries } from "pondjs";
+import styler from "react-timeseries-charts/lib/js/styler";
+
+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 maxTimelineRange = 24 * 60 * minutes;
+import classNames from "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;
+ let timeStart = toTime(metrics[0]) - minutes;
+ for (i = 0; timeStart < 0 && i < metrics.length; i++) {
+ // workaround to remove negative timestamps :(
+ timeStart = toTime(metrics[i]) - minutes;
+ }
+
+ let timeEnd = toTime(metrics[metrics.length - 1]) + minutes;
+ if (timeEnd - timeStart > maxTimelineRange) {
+ timeEnd = timeStart + maxTimelineRange;
+
+ const now = new Date().getTime();
+ if (
+ !this.lastDisplayNotificationTime ||
+ this.lastDisplayNotificationTime + minutes < now
+ ) {
+ this.lastDisplayNotificationTime = now;
+ dispatcher.dispatch("notifications", { event: "timeline.range.large" });
+ }
+ }
+
+ for (let interval = timeStart; interval <= timeEnd; 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) => {
+ if (
+ payload.from >= this.state.start &&
+ payload.from < payload.to &&
+ payload.to <= this.state.end
+ ) {
+ 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={this.props.height - 70}>
+ <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/dialogs/CommentDialog.jsx b/frontend/src/components/dialogs/CommentDialog.jsx
new file mode 100644
index 0000000..970aa83
--- /dev/null
+++ b/frontend/src/components/dialogs/CommentDialog.jsx
@@ -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 {Modal} from "react-bootstrap";
+import backend from "../../backend";
+import log from "../../log";
+import ButtonField from "../fields/ButtonField";
+import TextField from "../fields/TextField";
+
+class CommentDialog extends Component {
+
+ state = {};
+
+ componentDidMount() {
+ this.setState({comment: this.props.initialComment || ""});
+ }
+
+ setComment = () => {
+ if (this.state.comment === this.props.initialComment) {
+ return this.close();
+ }
+ const comment = this.state.comment || null;
+ backend.post(`/api/connections/${this.props.connectionId}/comment`, {comment})
+ .then((_) => {
+ this.close();
+ }).catch((e) => {
+ log.error(e);
+ this.setState({error: "failed to save comment"});
+ });
+ };
+
+ close = () => this.props.onSave(this.state.comment || null);
+
+ render() {
+ return (
+ <Modal show size="md" aria-labelledby="comment-dialog" centered>
+ <Modal.Header>
+ <Modal.Title id="comment-dialog">
+ ~/.comment
+ </Modal.Title>
+ </Modal.Header>
+ <Modal.Body>
+ <TextField value={this.state.comment} onChange={(comment) => this.setState({comment})}
+ rows={7} error={this.state.error}/>
+ </Modal.Body>
+ <Modal.Footer className="dialog-footer">
+ <ButtonField variant="red" bordered onClick={this.close} name="cancel"/>
+ <ButtonField variant="green" bordered onClick={this.setComment} name="save"/>
+ </Modal.Footer>
+ </Modal>
+ );
+ }
+}
+
+export default CommentDialog;
diff --git a/frontend/src/components/dialogs/CopyDialog.jsx b/frontend/src/components/dialogs/CopyDialog.jsx
new file mode 100644
index 0000000..069fd2e
--- /dev/null
+++ b/frontend/src/components/dialogs/CopyDialog.jsx
@@ -0,0 +1,69 @@
+/*
+ * 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 ButtonField from "../fields/ButtonField";
+import TextField from "../fields/TextField";
+
+class CopyDialog extends Component {
+
+ state = {
+ copyButtonText: "copy"
+ };
+
+ constructor(props) {
+ super(props);
+ this.textbox = React.createRef();
+ }
+
+ copyActionValue = () => {
+ this.textbox.current.select();
+ document.execCommand("copy");
+ this.setState({copyButtonText: "copied!"});
+ this.timeoutHandle = setTimeout(() => this.setState({copyButtonText: "copy"}), 3000);
+ };
+
+ componentWillUnmount() {
+ if (this.timeoutHandle) {
+ clearTimeout(this.timeoutHandle);
+ }
+ }
+
+ render() {
+ return (
+ <Modal show={true} size="lg" aria-labelledby="message-action-dialog" centered>
+ <Modal.Header>
+ <Modal.Title id="message-action-dialog">
+ {this.props.name}
+ </Modal.Title>
+ </Modal.Header>
+ <Modal.Body>
+ <TextField readonly={this.props.readonly} value={this.props.value} textRef={this.textbox}
+ rows={15}/>
+ </Modal.Body>
+ <Modal.Footer className="dialog-footer">
+ <ButtonField variant="red" bordered onClick={this.props.onHide} name="close"/>
+ <ButtonField variant="green" bordered onClick={this.copyActionValue}
+ name={this.state.copyButtonText}/>
+ </Modal.Footer>
+ </Modal>
+ );
+ }
+}
+
+export default CopyDialog;
diff --git a/frontend/src/components/dialogs/Filters.jsx b/frontend/src/components/dialogs/Filters.jsx
new file mode 100644
index 0000000..a2407df
--- /dev/null
+++ b/frontend/src/components/dialogs/Filters.jsx
@@ -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/fields/ButtonField.jsx b/frontend/src/components/fields/ButtonField.jsx
new file mode 100644
index 0000000..ae5b33a
--- /dev/null
+++ b/frontend/src/components/fields/ButtonField.jsx
@@ -0,0 +1,78 @@
+/*
+ * 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 "./ButtonField.scss";
+import "./common.scss";
+import classNames from "classnames";
+
+class ButtonField extends Component {
+ render() {
+ const handler = () => {
+ if (typeof this.props.onClick === "function") {
+ this.props.onClick();
+ }
+ };
+
+ let buttonClassnames = {
+ "button-bordered": this.props.bordered,
+ };
+ if (this.props.variant) {
+ buttonClassnames[`button-variant-${this.props.variant}`] = true;
+ }
+
+ let buttonStyle = {};
+ if (this.props.color) {
+ buttonStyle["backgroundColor"] = this.props.color;
+ }
+ if (this.props.border) {
+ buttonStyle["borderColor"] = this.props.border;
+ }
+ if (this.props.fullSpan) {
+ buttonStyle["width"] = "100%";
+ }
+ if (this.props.rounded) {
+ buttonStyle["borderRadius"] = "3px";
+ }
+ if (this.props.inline) {
+ buttonStyle["marginTop"] = "8px";
+ }
+
+ return (
+ <div
+ className={classNames(
+ "field",
+ "button-field",
+ { "field-small": this.props.small },
+ { "field-active": this.props.active }
+ )}
+ >
+ <button
+ type="button"
+ className={classNames(buttonClassnames)}
+ onClick={handler}
+ style={buttonStyle}
+ disabled={this.props.disabled}
+ >
+ {this.props.name}
+ </button>
+ </div>
+ );
+ }
+}
+
+export default ButtonField;
diff --git a/frontend/src/components/fields/CheckField.jsx b/frontend/src/components/fields/CheckField.jsx
new file mode 100644
index 0000000..1a04f18
--- /dev/null
+++ b/frontend/src/components/fields/CheckField.jsx
@@ -0,0 +1,67 @@
+/*
+ * 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";
+
+import classNames from "classnames";
+
+class CheckField extends Component {
+ constructor(props) {
+ super(props);
+
+ this.id = `field-${this.props.name || "noname"}-${randomClassName()}`;
+ }
+
+ render() {
+ const checked = this.props.checked || false;
+ const small = this.props.small || false;
+ const name = this.props.name || null;
+ const handler = () => {
+ if (!this.props.readonly && this.props.onChange) {
+ this.props.onChange(!checked);
+ }
+ };
+
+ return (
+ <div
+ className={classNames(
+ "field",
+ "check-field",
+ { "field-checked": checked },
+ { "field-small": small }
+ )}
+ >
+ <div className="field-input">
+ <input
+ type="checkbox"
+ id={this.id}
+ checked={checked}
+ onChange={handler}
+ />
+ <label htmlFor={this.id}>
+ {(checked ? "✓ " : "✗ ") + (name != null ? name : "")}
+ </label>
+ </div>
+ </div>
+ );
+ }
+}
+
+export default CheckField;
diff --git a/frontend/src/components/fields/ChoiceField.jsx b/frontend/src/components/fields/ChoiceField.jsx
new file mode 100644
index 0000000..c44d0a9
--- /dev/null
+++ b/frontend/src/components/fields/ChoiceField.jsx
@@ -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 {randomClassName} from "../../utils";
+import "./ChoiceField.scss";
+import "./common.scss";
+
+import classNames from 'classnames';
+
+class ChoiceField extends Component {
+
+ constructor(props) {
+ super(props);
+
+ this.state = {
+ expanded: false
+ };
+
+ this.id = `field-${this.props.name || "noname"}-${randomClassName()}`;
+ }
+
+ render() {
+ const name = this.props.name || null;
+ const inline = this.props.inline;
+
+ const collapse = () => this.setState({expanded: false});
+ const expand = () => this.setState({expanded: true});
+
+ const handler = (key) => {
+ collapse();
+ if (this.props.onChange) {
+ this.props.onChange(key);
+ }
+ };
+
+ const keys = this.props.keys || [];
+ const values = this.props.values || [];
+
+ const options = keys.map((key, i) =>
+ <span className="field-option" key={key} onClick={() => handler(key)}>{values[i]}</span>
+ );
+
+ let fieldValue = "";
+ if (inline && name) {
+ fieldValue = name;
+ }
+ if (!this.props.onlyName && inline && name) {
+ fieldValue += ": ";
+ }
+ if (!this.props.onlyName) {
+ fieldValue += this.props.value || "select a value";
+ }
+
+ return (
+ <div className={classNames("field", "choice-field", {"field-inline": inline},
+ {"field-small": this.props.small})}>
+ {!inline && name && <label className="field-name">{name}:</label>}
+ <div className={classNames("field-select", {"select-expanded": this.state.expanded})}
+ tabIndex={0} onBlur={collapse} onClick={() => this.state.expanded ? collapse() : expand()}>
+ <div className="field-value">{fieldValue}</div>
+ <div className="field-options">
+ {options}
+ </div>
+ </div>
+ </div>
+ );
+ }
+}
+
+export default ChoiceField;
diff --git a/frontend/src/components/fields/InputField.jsx b/frontend/src/components/fields/InputField.jsx
new file mode 100644
index 0000000..f9609df
--- /dev/null
+++ b/frontend/src/components/fields/InputField.jsx
@@ -0,0 +1,95 @@
+/*
+ * 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";
+
+import classNames from 'classnames';
+
+class InputField extends Component {
+
+ constructor(props) {
+ super(props);
+
+ this.id = `field-${this.props.name || "noname"}-${randomClassName()}`;
+ }
+
+ render() {
+ const active = this.props.active || false;
+ const invalid = this.props.invalid || false;
+ const small = this.props.small || false;
+ const inline = this.props.inline || false;
+ const name = this.props.name || null;
+ const value = this.props.value || "";
+ const defaultValue = this.props.defaultValue || "";
+ const type = this.props.type || "text";
+ const error = this.props.error || null;
+
+ const handler = (e) => {
+ if (typeof this.props.onChange === "function") {
+ if (type === "file") {
+ let file = e.target.files[0];
+ this.props.onChange(file);
+ } else if (e == null) {
+ this.props.onChange(defaultValue);
+ } else {
+ this.props.onChange(e.target.value);
+ }
+ }
+ };
+ let inputProps = {};
+ if (type !== "file") {
+ inputProps["value"] = value || defaultValue;
+ }
+
+ return (
+ <div className={classNames("field", "input-field", {"field-active": active},
+ {"field-invalid": invalid}, {"field-small": small}, {"field-inline": inline})}>
+ <div className="field-wrapper">
+ {name &&
+ <div className="field-name">
+ <label>{name}:</label>
+ </div>
+ }
+ <div className="field-input">
+ <div className="field-value">
+ {type === "file" && <label for={this.id} className={"file-label"}>
+ {value.name || this.props.placeholder}</label>}
+ <input type={type} placeholder={this.props.placeholder} id={this.id}
+ aria-describedby={this.id} onChange={handler} {...inputProps}
+ readOnly={this.props.readonly}/>
+ </div>
+ {type !== "file" && value !== "" && !this.props.readonly &&
+ <div className="field-clear">
+ <span onClick={() => handler(null)}>del</span>
+ </div>
+ }
+ </div>
+ </div>
+ {error &&
+ <div className="field-error">
+ error: {error}
+ </div>
+ }
+ </div>
+ );
+ }
+}
+
+export default InputField;
diff --git a/frontend/src/components/fields/TagField.jsx b/frontend/src/components/fields/TagField.jsx
new file mode 100644
index 0000000..79e2314
--- /dev/null
+++ b/frontend/src/components/fields/TagField.jsx
@@ -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";
+
+import classNames from 'classnames';
+import _ from '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/TextField.jsx b/frontend/src/components/fields/TextField.jsx
new file mode 100644
index 0000000..1138ffc
--- /dev/null
+++ b/frontend/src/components/fields/TextField.jsx
@@ -0,0 +1,60 @@
+/*
+ * 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";
+
+import classNames from 'classnames';
+
+class TextField extends Component {
+
+ constructor(props) {
+ super(props);
+
+ this.id = `field-${this.props.name || "noname"}-${randomClassName()}`;
+ }
+
+ render() {
+ const name = this.props.name || null;
+ const error = this.props.error || null;
+ const rows = this.props.rows || 3;
+
+ const handler = (e) => {
+ if (this.props.onChange) {
+ if (e == null) {
+ this.props.onChange("");
+ } else {
+ this.props.onChange(e.target.value);
+ }
+ }
+ };
+
+ return (
+ <div className={classNames("field", "text-field", {"field-active": this.props.active},
+ {"field-invalid": this.props.invalid}, {"field-small": this.props.small})}>
+ {name && <label htmlFor={this.id}>{name}:</label>}
+ <textarea id={this.id} placeholder={this.props.defaultValue} onChange={handler} rows={rows}
+ readOnly={this.props.readonly} value={this.props.value} ref={this.props.textRef}/>
+ {error && <div className="field-error">error: {error}</div>}
+ </div>
+ );
+ }
+}
+
+export default TextField;
diff --git a/frontend/src/components/fields/extensions/ColorField.jsx b/frontend/src/components/fields/extensions/ColorField.jsx
new file mode 100644
index 0000000..fd30988
--- /dev/null
+++ b/frontend/src/components/fields/extensions/ColorField.jsx
@@ -0,0 +1,98 @@
+/*
+ * 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 validation from "../../../validation";
+import InputField from "../InputField";
+import "./ColorField.scss";
+
+class ColorField extends Component {
+
+ constructor(props) {
+ super(props);
+
+ this.state = {};
+
+ this.colors = ["#e53935", "#d81b60", "#8e24aa", "#5e35b1", "#3949ab", "#1e88e5", "#039be5", "#00acc1",
+ "#00897b", "#43a047", "#7cb342", "#9e9d24", "#f9a825", "#fb8c00", "#f4511e", "#6d4c41"];
+ }
+
+ componentDidUpdate(prevProps, prevState, snapshot) {
+ if (prevProps.value !== this.props.value) {
+ this.onChange(this.props.value);
+ }
+ }
+
+ onChange = (value) => {
+ this.setState({invalid: value !== "" && !validation.isValidColor(value)});
+
+ if (typeof this.props.onChange === "function") {
+ this.props.onChange(value);
+ }
+ };
+
+ render() {
+ const colorButtons = this.colors.map((color) =>
+ <span key={"button" + color} className="color-input" style={{"backgroundColor": color}}
+ onClick={() => {
+ if (typeof this.props.onChange === "function") {
+ this.props.onChange(color);
+ }
+ document.body.click(); // magic to close popup
+ }}/>);
+
+ const popover = (
+ <Popover id="popover-basic">
+ <Popover.Title as="h3">choose a color</Popover.Title>
+ <Popover.Content>
+ <div className="colors-container">
+ <div className="colors-row">
+ {colorButtons.slice(0, 8)}
+ </div>
+ <div className="colors-row">
+ {colorButtons.slice(8, 18)}
+ </div>
+ </div>
+ </Popover.Content>
+ </Popover>
+ );
+
+ let buttonStyles = {};
+ if (this.props.value) {
+ buttonStyles["backgroundColor"] = this.props.value;
+ }
+
+ return (
+ <div className="field color-field">
+ <div className="color-input">
+ <InputField {...this.props} onChange={this.onChange} invalid={this.state.invalid} name="color"
+ error={null}/>
+ <div className="color-picker">
+ <OverlayTrigger trigger="click" placement="top" overlay={popover} rootClose>
+ <button type="button" className="picker-button" style={buttonStyles}>pick</button>
+ </OverlayTrigger>
+ </div>
+ </div>
+ {this.props.error && <div className="color-error">{this.props.error}</div>}
+ </div>
+ );
+ }
+
+}
+
+export default ColorField;
diff --git a/frontend/src/components/fields/extensions/NumericField.jsx b/frontend/src/components/fields/extensions/NumericField.jsx
new file mode 100644
index 0000000..a6cba26
--- /dev/null
+++ b/frontend/src/components/fields/extensions/NumericField.jsx
@@ -0,0 +1,62 @@
+/*
+ * 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 {
+
+ constructor(props) {
+ super(props);
+
+ this.state = {
+ invalid: false
+ };
+ }
+
+ componentDidUpdate(prevProps, prevState, snapshot) {
+ if (prevProps.value !== this.props.value) {
+ this.onChange(this.props.value);
+ }
+ }
+
+ onChange = (value) => {
+ value = value.toString().replace(/[^\d]/gi, "");
+ let intValue = 0;
+ if (value !== "") {
+ intValue = parseInt(value, 10);
+ }
+ const valid =
+ (!this.props.validate || (typeof this.props.validate === "function" && this.props.validate(intValue))) &&
+ (!this.props.min || (typeof this.props.min === "number" && intValue >= this.props.min)) &&
+ (!this.props.max || (typeof this.props.max === "number" && intValue <= this.props.max));
+ this.setState({invalid: !valid});
+ if (typeof this.props.onChange === "function") {
+ this.props.onChange(intValue);
+ }
+ };
+
+ render() {
+ return (
+ <InputField {...this.props} onChange={this.onChange} defaultValue={this.props.defaultValue || "0"}
+ invalid={this.state.invalid}/>
+ );
+ }
+
+}
+
+export default NumericField;
diff --git a/frontend/src/components/filters/AdvancedFilters.jsx b/frontend/src/components/filters/AdvancedFilters.jsx
new file mode 100644
index 0000000..8598185
--- /dev/null
+++ b/frontend/src/components/filters/AdvancedFilters.jsx
@@ -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.jsx b/frontend/src/components/filters/BooleanConnectionsFilter.jsx
new file mode 100644
index 0000000..0355167
--- /dev/null
+++ b/frontend/src/components/filters/BooleanConnectionsFilter.jsx
@@ -0,0 +1,69 @@
+/*
+ * 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 BooleanConnectionsFilter extends Component {
+
+ 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);
+ }
+
+ componentWillUnmount() {
+ dispatcher.unregister(this.connectionsFiltersCallback);
+ }
+
+ toBoolean = (value) => {
+ return value !== null && value.toLowerCase() === "true";
+ };
+
+ 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() {
+ return (
+ <div className="filter" style={{"width": `${this.props.width}px`}}>
+ <CheckField checked={this.toBoolean(this.state.filterActive)} name={this.props.filterName}
+ onChange={this.filterChanged} small/>
+ </div>
+ );
+ }
+
+}
+
+export default withRouter(BooleanConnectionsFilter);
diff --git a/frontend/src/components/filters/ExitSearchFilter.jsx b/frontend/src/components/filters/ExitSearchFilter.jsx
new file mode 100644
index 0000000..0aacfd6
--- /dev/null
+++ b/frontend/src/components/filters/ExitSearchFilter.jsx
@@ -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/RulesConnectionsFilter.jsx b/frontend/src/components/filters/RulesConnectionsFilter.jsx
new file mode 100644
index 0000000..4ae769a
--- /dev/null
+++ b/frontend/src/components/filters/RulesConnectionsFilter.jsx
@@ -0,0 +1,83 @@
+/*
+ * 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 backend from "../../backend";
+import dispatcher from "../../dispatcher";
+import TagField from "../fields/TagField";
+
+import classNames from 'classnames';
+import _ from 'lodash';
+
+class RulesConnectionsFilter extends Component {
+
+ state = {
+ rules: [],
+ activeRules: []
+ };
+
+ componentDidMount() {
+ 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});
+ });
+
+ 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);
+ }
+
+ componentWillUnmount() {
+ dispatcher.unregister(this.connectionsFiltersCallback);
+ }
+
+ 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() {
+ return (
+ <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>
+ </div>
+ );
+ }
+
+}
+
+export default withRouter(RulesConnectionsFilter);
diff --git a/frontend/src/components/filters/StringConnectionsFilter.jsx b/frontend/src/components/filters/StringConnectionsFilter.jsx
new file mode 100644
index 0000000..c5d7075
--- /dev/null
+++ b/frontend/src/components/filters/StringConnectionsFilter.jsx
@@ -0,0 +1,127 @@
+/*
+ * 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 InputField from "../fields/InputField";
+
+class StringConnectionsFilter extends Component {
+
+ 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);
+ }
+
+ componentWillUnmount() {
+ dispatcher.unregister(this.connectionsFiltersCallback);
+ }
+
+ updateStateFromFilterValue = (filterValue) => {
+ if (filterValue !== null) {
+ let fieldValue = filterValue;
+ if (typeof this.props.decodeFunc === "function") {
+ fieldValue = this.props.decodeFunc(filterValue);
+ }
+ if (typeof this.props.replaceFunc === "function") {
+ fieldValue = this.props.replaceFunc(fieldValue);
+ }
+ if (this.isValueValid(fieldValue)) {
+ this.setState({fieldValue, filterValue});
+ } else {
+ this.setState({fieldValue, invalidValue: true});
+ }
+ } else {
+ this.setState({fieldValue: "", filterValue: null});
+ }
+ };
+
+ isValueValid = (value) => {
+ return typeof this.props.validateFunc !== "function" ||
+ (typeof this.props.validateFunc === "function" && this.props.validateFunc(value));
+ };
+
+ changeFilterValue = (value) => {
+ const urlParams = {};
+ urlParams[this.props.filterName] = value;
+ dispatcher.dispatch("connections_filters", urlParams);
+ };
+
+ filterChanged = (fieldValue) => {
+ if (this.state.timeoutHandle) {
+ clearTimeout(this.state.timeoutHandle);
+ }
+
+ if (typeof this.props.replaceFunc === "function") {
+ fieldValue = this.props.replaceFunc(fieldValue);
+ }
+
+ if (fieldValue === "") {
+ this.setState({fieldValue: "", filterValue: null, invalidValue: false});
+ return this.changeFilterValue(null);
+ }
+
+
+ if (this.isValueValid(fieldValue)) {
+ let filterValue = fieldValue;
+ if (filterValue !== "" && typeof this.props.encodeFunc === "function") {
+ filterValue = this.props.encodeFunc(filterValue);
+ }
+
+ this.setState({
+ fieldValue,
+ timeoutHandle: setTimeout(() => {
+ this.setState({filterValue});
+ this.changeFilterValue(filterValue);
+ }, 500),
+ invalidValue: false
+ });
+ } else {
+ this.setState({fieldValue, invalidValue: true});
+ }
+ };
+
+ render() {
+ 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={this.props.inline} small={this.props.small}/>
+ </div>
+ );
+ }
+
+}
+
+export default withRouter(StringConnectionsFilter);
diff --git a/frontend/src/components/objects/Connection.jsx b/frontend/src/components/objects/Connection.jsx
new file mode 100644
index 0000000..113ed21
--- /dev/null
+++ b/frontend/src/components/objects/Connection.jsx
@@ -0,0 +1,116 @@
+/*
+ * 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 backend from "../../backend";
+import dispatcher from "../../dispatcher";
+import {dateTimeToTime, durationBetween, formatSize} from "../../utils";
+import CommentDialog from "../dialogs/CommentDialog";
+import ButtonField from "../fields/ButtonField";
+import TextField from "../fields/TextField";
+import "./Connection.scss";
+import CopyLinkPopover from "./CopyLinkPopover";
+import LinkPopover from "./LinkPopover";
+
+import classNames from 'classnames';
+
+class Connection extends Component {
+
+ state = {
+ update: false
+ };
+
+ handleAction = (name, comment) => {
+ 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});
+ });
+ } else if (name === "comment") {
+ this.props.onCommented(comment);
+ this.setState({showCommentDialog: false});
+ }
+ };
+
+ 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 style={{"width": "250px"}}>
+ <span>Click to <strong>{conn.comment ? "edit" : "add"}</strong> comment</span>
+ {conn.comment && <TextField rows={3} value={conn.comment} readonly/>}
+ </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.setState({showCommentDialog: true})}>@</span>}
+ content={commentPopoverContent} placement="right"/>
+ <CopyLinkPopover text="#" value={conn.id}
+ textClassName={classNames("connection-icon", {"icon-enabled": conn.hidden})}/>
+ {
+ this.state.showCommentDialog &&
+ <CommentDialog onSave={(comment) => this.handleAction("comment", comment)}
+ initialComment={conn.comment} connectionId={conn.id}/>
+ }
+
+ </td>
+ </tr>
+ );
+ }
+
+}
+
+export default Connection;
diff --git a/frontend/src/components/objects/ConnectionMatchedRules.jsx b/frontend/src/components/objects/ConnectionMatchedRules.jsx
new file mode 100644
index 0000000..a69cad8
--- /dev/null
+++ b/frontend/src/components/objects/ConnectionMatchedRules.jsx
@@ -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/objects/CopyLinkPopover.jsx b/frontend/src/components/objects/CopyLinkPopover.jsx
new file mode 100644
index 0000000..b951603
--- /dev/null
+++ b/frontend/src/components/objects/CopyLinkPopover.jsx
@@ -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.jsx b/frontend/src/components/objects/LinkPopover.jsx
new file mode 100644
index 0000000..551a819
--- /dev/null
+++ b/frontend/src/components/objects/LinkPopover.jsx
@@ -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 {OverlayTrigger, Popover} from "react-bootstrap";
+import {randomClassName} from "../../utils";
+import "./LinkPopover.scss";
+
+class LinkPopover extends Component {
+
+ constructor(props) {
+ super(props);
+
+ this.id = `link-overlay-${randomClassName()}`;
+ }
+
+ render() {
+ const popover = (
+ <Popover id={this.id}>
+ {this.props.title && <Popover.Title as="h3">{this.props.title}</Popover.Title>}
+ <Popover.Content>
+ {this.props.content}
+ </Popover.Content>
+ </Popover>
+ );
+
+ return (this.props.content ?
+ <OverlayTrigger trigger={["hover", "focus"]} placement={this.props.placement || "top"}
+ overlay={popover}>
+ <span className="link-popover">{this.props.text}</span>
+ </OverlayTrigger> :
+ <span className="link-popover-empty">{this.props.text}</span>
+ );
+ }
+}
+
+export default LinkPopover;
diff --git a/frontend/src/components/pages/ConfigurationPage.jsx b/frontend/src/components/pages/ConfigurationPage.jsx
new file mode 100644
index 0000000..c8646fb
--- /dev/null
+++ b/frontend/src/components/pages/ConfigurationPage.jsx
@@ -0,0 +1,183 @@
+/*
+ * 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 Table from "react-bootstrap/Table";
+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 "./ConfigurationPage.scss";
+
+class ConfigurationPage extends Component {
+
+ constructor(props) {
+ super(props);
+ this.state = {
+ settings: {
+ "config": {
+ "server_address": "",
+ "flag_regex": "",
+ "auth_required": false
+ },
+ "accounts": {}
+ },
+ newUsername: "",
+ newPassword: ""
+ };
+ }
+
+ saveSettings = () => {
+ if (this.validateSettings(this.state.settings)) {
+ backend.post("/setup", this.state.settings).then((_) => {
+ this.props.onConfigured();
+ }).catch((res) => {
+ this.setState({setupStatusCode: res.status, setupResponse: JSON.stringify(res.json)});
+ });
+ }
+ };
+
+ validateSettings = (settings) => {
+ let valid = true;
+ if (!validation.isValidAddress(settings.config["server_address"], true)) {
+ this.setState({serverAddressError: "invalid ip_address"});
+ valid = false;
+ }
+ if (settings.config["flag_regex"].length < 8) {
+ this.setState({flagRegexError: "flag_regex.length < 8"});
+ valid = false;
+ }
+
+ return valid;
+ };
+
+ updateParam = (callback) => {
+ callback(this.state.settings);
+ this.setState({settings: this.state.settings});
+ };
+
+ addAccount = () => {
+ if (this.state.newUsername.length !== 0 && this.state.newPassword.length !== 0) {
+ const settings = this.state.settings;
+ settings.accounts[this.state.newUsername] = this.state.newPassword;
+
+ this.setState({
+ newUsername: "",
+ newPassword: "",
+ settings
+ });
+ } else {
+ this.setState({
+ newUsernameActive: this.state.newUsername.length === 0,
+ newPasswordActive: this.state.newPassword.length === 0
+ });
+ }
+ };
+
+ render() {
+ const settings = this.state.settings;
+ const curlCommand = createCurlCommand("/setup", "POST", settings);
+
+ const accounts = Object.entries(settings.accounts).map(([username, password]) =>
+ <tr key={username}>
+ <td>{username}</td>
+ <td><LinkPopover text="******" content={password}/></td>
+ <td><ButtonField variant="red" small rounded name="delete"
+ onClick={() => this.updateParam((s) => delete s.accounts[username])}/></td>
+ </tr>).concat(<tr key={"new_account"}>
+ <td><InputField value={this.state.newUsername} small active={this.state.newUsernameActive}
+ onChange={(v) => this.setState({newUsername: v})}/></td>
+ <td><InputField value={this.state.newPassword} small active={this.state.newPasswordActive}
+ onChange={(v) => this.setState({newPassword: v})}/></td>
+ <td><ButtonField variant="green" small rounded name="add" onClick={this.addAccount}/></td>
+ </tr>);
+
+ return (
+ <div className="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>
+ </div>
+
+ <div className="section-content">
+ <Container className="p-0">
+ <Row>
+ <Col>
+ <InputField name="server_address" value={settings.config["server_address"]}
+ error={this.state.serverAddressError}
+ onChange={(v) => this.updateParam((s) => s.config["server_address"] = v)}/>
+ <InputField name="flag_regex" value={settings.config["flag_regex"]}
+ onChange={(v) => this.updateParam((s) => s.config["flag_regex"] = v)}
+ error={this.state.flagRegexError}/>
+ <div style={{"marginTop": "10px"}}>
+ <CheckField checked={settings.config["auth_required"]} name="auth_required"
+ onChange={(v) => this.updateParam((s) => s.config["auth_required"] = v)}/>
+ </div>
+
+ </Col>
+
+ <Col>
+ accounts:
+ <div className="section-table">
+ <Table borderless size="sm">
+ <thead>
+ <tr>
+ <th>username</th>
+ <th>password</th>
+ <th>actions</th>
+ </tr>
+ </thead>
+ <tbody>
+ {accounts}
+ </tbody>
+ </Table>
+ </div>
+ </Col>
+ </Row>
+ </Container>
+
+ <TextField value={curlCommand} rows={4} readonly small={true}/>
+ </div>
+
+ <div className="section-footer">
+ <ButtonField variant="green" name="save" bordered onClick={this.saveSettings}/>
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+ );
+ }
+}
+
+export default ConfigurationPage;
diff --git a/frontend/src/components/pages/MainPage.jsx b/frontend/src/components/pages/MainPage.jsx
new file mode 100644
index 0000000..b9a9e6e
--- /dev/null
+++ b/frontend/src/components/pages/MainPage.jsx
@@ -0,0 +1,97 @@
+/*
+ * 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 {ReflexContainer, ReflexElement, ReflexSplitter} from "react-reflex";
+import "react-reflex/styles.css"
+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 StatsPane from "../panels/StatsPane";
+import StreamsPane from "../panels/StreamsPane";
+import "./MainPage.scss";
+
+class MainPage extends Component {
+
+ state = {
+ timelineHeight: 210
+ };
+
+ handleTimelineResize = (e) => {
+ if (this.timelineTimeoutHandle) {
+ clearTimeout(this.timelineTimeoutHandle);
+ }
+
+ this.timelineTimeoutHandle = setTimeout(() =>
+ this.setState({timelineHeight: e.domElement.clientHeight}), 100);
+ };
+
+ render() {
+ let modal;
+ if (this.state.filterWindowOpen) {
+ modal = <Filters onHide={() => this.setState({filterWindowOpen: false})}/>;
+ }
+
+ return (
+ <ReflexContainer orientation="horizontal" className="page main-page">
+ <div className="fuck-css">
+ <ReflexElement className="page-header">
+ <Header onOpenFilters={() => this.setState({filterWindowOpen: true})} configured={true}/>
+ {modal}
+ </ReflexElement>
+ </div>
+
+ <ReflexElement className="page-content" flex={1}>
+ <ReflexContainer orientation="vertical" className="page-content">
+ <ReflexElement className="pane connections-pane">
+ <Connections onSelected={(c) => this.setState({selectedConnection: c})}/>
+ </ReflexElement>
+
+ <ReflexSplitter/>
+
+ <ReflexElement 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 path="/stats" children={<StatsPane/>}/>
+ <Route exact path="/connections/:id"
+ children={<StreamsPane connection={this.state.selectedConnection}/>}/>
+ <Route children={<MainPane version={this.props.version}/>}/>
+ </Switch>
+ </ReflexElement>
+ </ReflexContainer>
+ </ReflexElement>
+
+ <ReflexSplitter propagate={true}/>
+
+ <ReflexElement className="page-footer" onResize={this.handleTimelineResize}>
+
+ </ReflexElement>
+ </ReflexContainer>
+ );
+ }
+}
+
+export default MainPage;
diff --git a/frontend/src/components/pages/ServiceUnavailablePage.jsx b/frontend/src/components/pages/ServiceUnavailablePage.jsx
new file mode 100644
index 0000000..deb4cf8
--- /dev/null
+++ b/frontend/src/components/pages/ServiceUnavailablePage.jsx
@@ -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/panels/ConnectionsPane.jsx b/frontend/src/components/panels/ConnectionsPane.jsx
new file mode 100644
index 0000000..2c7fadd
--- /dev/null
+++ b/frontend/src/components/panels/ConnectionsPane.jsx
@@ -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";
+
+import classNames from '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}
+ onCommented={(comment) => c.comment = comment}
+ 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/MainPane.jsx b/frontend/src/components/panels/MainPane.jsx
new file mode 100644
index 0000000..ce72be5
--- /dev/null
+++ b/frontend/src/components/panels/MainPane.jsx
@@ -0,0 +1,112 @@
+/*
+ * 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 {
+
+ 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);
+ }
+
+ componentWillUnmount() {
+ this.typed.destroy();
+ }
+
+ render() {
+ return (
+ <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="background-pane">
+ {this.state.backgroundPane}
+ </div>
+ </div>
+ );
+ }
+
+}
+
+export default MainPane;
diff --git a/frontend/src/components/panels/PcapsPane.jsx b/frontend/src/components/panels/PcapsPane.jsx
new file mode 100644
index 0000000..b7d5ce9
--- /dev/null
+++ b/frontend/src/components/panels/PcapsPane.jsx
@@ -0,0 +1,287 @@
+/*
+ * 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 ButtonField from "../fields/ButtonField";
+import CheckField from "../fields/CheckField";
+import InputField from "../fields/InputField";
+import TextField from "../fields/TextField";
+import CopyLinkPopover from "../objects/CopyLinkPopover";
+import LinkPopover from "../objects/LinkPopover";
+import "./common.scss";
+import "./PcapsPane.scss";
+
+class PcapsPane extends Component {
+
+ 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({
+ sessions: res.json, sessionsStatusCode: res.status,
+ sessionsResponse: JSON.stringify(res.json)
+ }));
+ };
+
+ uploadPcap = () => {
+ if (this.state.uploadSelectedFile == null || !this.state.isUploadFileValid) {
+ this.setState({isUploadFileFocused: true});
+ return;
+ }
+
+ const formData = new FormData();
+ formData.append("file", this.state.uploadSelectedFile);
+ formData.append("flush_all", this.state.uploadFlushAll);
+ backend.postFile("/api/pcap/upload", formData).then((res) => {
+ this.setState({
+ uploadStatusCode: res.status,
+ uploadResponse: JSON.stringify(res.json)
+ });
+ this.resetUpload();
+ this.loadSessions();
+ }).catch((res) => this.setState({
+ uploadStatusCode: res.status,
+ uploadResponse: JSON.stringify(res.json)
+ })
+ );
+ };
+
+ processPcap = () => {
+ if (this.state.fileValue === "" || !this.state.isFileValid) {
+ this.setState({isFileFocused: true});
+ return;
+ }
+
+ backend.post("/api/pcap/file", {
+ "file": this.state.fileValue,
+ "flush_all": this.state.processFlushAll,
+ "delete_original_file": this.state.deleteOriginalFile
+ }).then((res) => {
+ this.setState({
+ processStatusCode: res.status,
+ processResponse: JSON.stringify(res.json)
+ });
+ this.resetProcess();
+ this.loadSessions();
+ }).catch((res) => this.setState({
+ processStatusCode: res.status,
+ processResponse: JSON.stringify(res.json)
+ })
+ );
+ };
+
+ resetUpload = () => {
+ this.setState({
+ isUploadFileValid: true,
+ isUploadFileFocused: false,
+ uploadFlushAll: false,
+ uploadSelectedFile: null
+ });
+ };
+
+ resetProcess = () => {
+ this.setState({
+ isFileValid: true,
+ isFileFocused: false,
+ fileValue: "",
+ processFlushAll: false,
+ deleteOriginalFile: false,
+ });
+ };
+
+ render() {
+ let sessions = this.state.sessions.map((s) => {
+ 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>
+ <td>{s["invalid_packets"]}</td>
+ <td><LinkPopover text={Object.keys(s["packets_per_service"]).length + " services"}
+ content={JSON.stringify(s["packets_per_service"])}
+ placement="left"/></td>
+ <td className="table-cell-action"><a href={"/api/pcap/sessions/" + s["id"] + "/download"}>download</a>
+ </td>
+ </tr>;
+ });
+
+ const handleUploadFileChange = (file) => {
+ this.setState({
+ isUploadFileValid: file == null || (file.type.endsWith("pcap") || file.type.endsWith("pcapng")),
+ isUploadFileFocused: false,
+ uploadSelectedFile: file,
+ uploadStatusCode: null,
+ uploadResponse: null
+ });
+ };
+
+ const handleFileChange = (file) => {
+ this.setState({
+ isFileValid: (file.endsWith("pcap") || file.endsWith("pcapng")),
+ isFileFocused: false,
+ fileValue: file,
+ processStatusCode: null,
+ processResponse: null
+ });
+ };
+
+ const uploadCurlCommand = createCurlCommand("/pcap/upload", "POST", null, {
+ "file": "@" + ((this.state.uploadSelectedFile != null && this.state.isUploadFileValid) ?
+ this.state.uploadSelectedFile.name : "invalid.pcap"),
+ "flush_all": this.state.uploadFlushAll
+ });
+
+ const fileCurlCommand = createCurlCommand("/pcap/file", "POST", {
+ "file": this.state.fileValue,
+ "flush_all": this.state.processFlushAll,
+ "delete_original_file": this.state.deleteOriginalFile
+ });
+
+ return (
+ <div className="pane-container pcap-pane">
+ <div className="pane-section pcap-list">
+ <div className="section-header">
+ <span className="api-request">GET /api/pcap/sessions</span>
+ <span className="api-response"><LinkPopover text={this.state.sessionsStatusCode}
+ content={this.state.sessionsResponse}
+ placement="left"/></span>
+ </div>
+
+ <div className="section-content">
+ <div className="section-table">
+ <Table borderless size="sm">
+ <thead>
+ <tr>
+ <th>id</th>
+ <th>started_at</th>
+ <th>duration</th>
+ <th>size</th>
+ <th>processed_packets</th>
+ <th>invalid_packets</th>
+ <th>packets_per_service</th>
+ <th>actions</th>
+ </tr>
+ </thead>
+ <tbody>
+ {sessions}
+ </tbody>
+ </Table>
+ </div>
+ </div>
+ </div>
+
+ <div className="double-pane-container">
+ <div className="pane-section">
+ <div className="section-header">
+ <span className="api-request">POST /api/pcap/upload</span>
+ <span className="api-response"><LinkPopover text={this.state.uploadStatusCode}
+ content={this.state.uploadResponse}
+ placement="left"/></span>
+ </div>
+
+ <div className="section-content">
+ <InputField type={"file"} name={"file"} invalid={!this.state.isUploadFileValid}
+ active={this.state.isUploadFileFocused}
+ onChange={handleUploadFileChange} value={this.state.uploadSelectedFile}
+ placeholder={"no .pcap[ng] selected"}/>
+ <div className="upload-actions">
+ <div className="upload-options">
+ <span>options:</span>
+ <CheckField name="flush_all" checked={this.state.uploadFlushAll}
+ onChange={(v) => this.setState({uploadFlushAll: v})}/>
+ </div>
+ <ButtonField variant="green" bordered onClick={this.uploadPcap} name="upload"/>
+ </div>
+
+ <TextField value={uploadCurlCommand} rows={4} readonly small={true}/>
+ </div>
+ </div>
+
+ <div className="pane-section">
+ <div className="section-header">
+ <span className="api-request">POST /api/pcap/file</span>
+ <span className="api-response"><LinkPopover text={this.state.processStatusCode}
+ content={this.state.processResponse}
+ placement="left"/></span>
+ </div>
+
+ <div className="section-content">
+ <InputField name="file" active={this.state.isFileFocused} invalid={!this.state.isFileValid}
+ onChange={handleFileChange} value={this.state.fileValue}
+ placeholder={"local .pcap[ng] path"} inline/>
+
+ <div className="upload-actions" style={{"marginTop": "11px"}}>
+ <div className="upload-options">
+ <CheckField name="flush_all" checked={this.state.processFlushAll}
+ onChange={(v) => this.setState({processFlushAll: v})}/>
+ <CheckField name="delete_original_file" checked={this.state.deleteOriginalFile}
+ onChange={(v) => this.setState({deleteOriginalFile: v})}/>
+ </div>
+ <ButtonField variant="blue" bordered onClick={this.processPcap} name="process"/>
+ </div>
+
+ <TextField value={fileCurlCommand} rows={4} readonly small={true}/>
+ </div>
+ </div>
+ </div>
+ </div>
+ );
+ }
+}
+
+export default PcapsPane;
diff --git a/frontend/src/components/panels/RulesPane.jsx b/frontend/src/components/panels/RulesPane.jsx
new file mode 100644
index 0000000..4cb5e41
--- /dev/null
+++ b/frontend/src/components/panels/RulesPane.jsx
@@ -0,0 +1,469 @@
+/*
+ * 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 Table from "react-bootstrap/Table";
+import backend from "../../backend";
+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 "./common.scss";
+import "./RulesPane.scss";
+
+import classNames from 'classnames';
+import _ from 'lodash';
+
+class RulesPane extends Component {
+
+ emptyRule = {
+ "name": "",
+ "color": "",
+ "notes": "",
+ "enabled": true,
+ "patterns": [],
+ "filter": {
+ "service_port": 0,
+ "client_address": "",
+ "client_port": 0,
+ "min_duration": 0,
+ "max_duration": 0,
+ "min_bytes": 0,
+ "max_bytes": 0
+ },
+ "version": 0
+ };
+ emptyPattern = {
+ "regex": "",
+ "flags": {
+ "caseless": false,
+ "dot_all": false,
+ "multi_line": false,
+ "utf_8_mode": false,
+ "unicode_property": false
+ },
+ "min_occurrences": 0,
+ "max_occurrences": 0,
+ "direction": 0
+ };
+ 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) }));
+ };
+
+ addRule = () => {
+ if (this.validateRule(this.state.newRule)) {
+ backend.post("/api/rules", this.state.newRule).then((res) => {
+ this.reset();
+ this.setState({ ruleStatusCode: res.status });
+ this.loadRules();
+ }).catch((res) => {
+ this.setState({ ruleStatusCode: res.status, ruleResponse: JSON.stringify(res.json) });
+ });
+ }
+ };
+
+ deleteRule = () => {
+ const rule = this.state.selectedRule;
+ backend.delete(`/api/rules/${rule.id}`).then((res) => {
+ this.reset();
+ this.setState({ ruleStatusCode: res.status });
+ this.loadRules();
+ }).catch((res) => {
+ this.setState({ ruleStatusCode: res.status, ruleResponse: JSON.stringify(res.json) });
+ });
+ }
+
+ updateRule = () => {
+ const rule = this.state.selectedRule;
+ if (this.validateRule(rule)) {
+ backend.put(`/api/rules/${rule.id}`, rule).then((res) => {
+ this.reset();
+ this.setState({ ruleStatusCode: res.status });
+ this.loadRules();
+ }).catch((res) => {
+ this.setState({ ruleStatusCode: res.status, ruleResponse: JSON.stringify(res.json) });
+ });
+ }
+ };
+
+ validateRule = (rule) => {
+ let valid = true;
+ if (rule.name.length < 3) {
+ this.setState({ ruleNameError: "name.length < 3" });
+ valid = false;
+ }
+ if (!validation.isValidColor(rule.color)) {
+ this.setState({ ruleColorError: "color is not hexcolor" });
+ valid = false;
+ }
+ if (!validation.isValidPort(rule.filter["service_port"])) {
+ this.setState({ ruleServicePortError: "service_port > 65565" });
+ valid = false;
+ }
+ if (!validation.isValidPort(rule.filter["client_port"])) {
+ this.setState({ ruleClientPortError: "client_port > 65565" });
+ valid = false;
+ }
+ if (!validation.isValidAddress(rule.filter["client_address"])) {
+ this.setState({ ruleClientAddressError: "client_address is not ip_address" });
+ valid = false;
+ }
+ if (rule.filter["min_duration"] > rule.filter["max_duration"]) {
+ this.setState({ ruleDurationError: "min_duration > max_dur." });
+ valid = false;
+ }
+ if (rule.filter["min_bytes"] > rule.filter["max_bytes"]) {
+ this.setState({ ruleBytesError: "min_bytes > max_bytes" });
+ valid = false;
+ }
+ if (rule.patterns.length < 1) {
+ this.setState({ rulePatternsError: "patterns.length < 1" });
+ valid = false;
+ }
+
+ return valid;
+ };
+
+ reset = () => {
+ const newRule = _.cloneDeep(this.emptyRule);
+ const newPattern = _.cloneDeep(this.emptyPattern);
+ this.setState({
+ selectedRule: null,
+ newRule,
+ selectedPattern: null,
+ newPattern,
+ patternRegexFocused: false,
+ patternOccurrencesFocused: false,
+ ruleNameError: null,
+ ruleColorError: null,
+ ruleServicePortError: null,
+ ruleClientPortError: null,
+ ruleClientAddressError: null,
+ ruleDurationError: null,
+ ruleBytesError: null,
+ rulePatternsError: null,
+ ruleStatusCode: null,
+ rulesStatusCode: null,
+ ruleResponse: null,
+ rulesResponse: null
+ });
+ };
+
+ updateParam = (callback) => {
+ const updatedRule = this.currentRule();
+ callback(updatedRule);
+ this.setState({ newRule: updatedRule });
+ };
+
+ currentRule = () => this.state.selectedRule != null ? this.state.selectedRule : this.state.newRule;
+
+ addPattern = (pattern) => {
+ if (!this.validatePattern(pattern)) {
+ return;
+ }
+
+ const newPattern = _.cloneDeep(this.emptyPattern);
+ this.currentRule().patterns.push(pattern);
+ this.setState({ newPattern });
+ };
+
+ editPattern = (pattern) => {
+ this.setState({
+ selectedPattern: pattern
+ });
+ };
+
+ updatePattern = (pattern) => {
+ if (!this.validatePattern(pattern)) {
+ return;
+ }
+
+ this.setState({
+ selectedPattern: null
+ });
+ };
+
+ validatePattern = (pattern) => {
+ let valid = true;
+ if (pattern.regex === "") {
+ valid = false;
+ this.setState({ patternRegexFocused: true });
+ }
+ if (pattern["min_occurrences"] > pattern["max_occurrences"]) {
+ valid = false;
+ this.setState({ patternOccurrencesFocused: true });
+ }
+ return valid;
+ };
+
+ render() {
+ const isUpdate = this.state.selectedRule != null;
+ const rule = this.currentRule();
+ const pattern = this.state.selectedPattern || this.state.newPattern;
+
+ let rules = this.state.rules.map((r) =>
+ <tr key={r.id} onClick={() => {
+ this.reset();
+ this.setState({ selectedRule: _.cloneDeep(r) });
+ }} className={classNames("row-small", "row-clickable", { "row-selected": rule.id === r.id })}>
+ <td><CopyLinkPopover text={r["id"].substring(0, 8)} value={r["id"]} /></td>
+ <td>{r["name"]}</td>
+ <td><ButtonField name={r["color"]} color={r["color"]} small /></td>
+ <td>{r["notes"]}</td>
+ </tr>
+ );
+
+ let patterns = (this.state.selectedPattern == null && !isUpdate ?
+ rule.patterns.concat(this.state.newPattern) :
+ rule.patterns
+ ).map((p) => p === pattern ?
+ <tr key={"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 style={{ "width": "70px" }}>
+ <NumericField small value={pattern["min_occurrences"]}
+ active={this.state.patternOccurrencesFocused}
+ onChange={(v) => this.updateParam(() => pattern["min_occurrences"] = v)} />
+ </td>
+ <td style={{ "width": "70px" }}>
+ <NumericField small value={pattern["max_occurrences"]}
+ active={this.state.patternOccurrencesFocused}
+ onChange={(v) => this.updateParam(() => pattern["max_occurrences"] = v)} />
+ </td>
+ <td><ChoiceField inline small keys={[0, 1, 2]} values={["both", "c->s", "s->c"]}
+ value={this.directions[pattern.direction]}
+ onChange={(v) => this.updateParam(() => pattern.direction = v)} /></td>
+ <td>{this.state.selectedPattern == null ?
+ <ButtonField variant="green" small name="add" inline rounded onClick={() => this.addPattern(p)} /> :
+ <ButtonField variant="green" small name="save" inline rounded
+ onClick={() => this.updatePattern(p)} />}
+ </td>
+ </tr>
+ :
+ <tr key={"new_pattern"} className="row-small">
+ <td>{p.regex}</td>
+ <td>{p.flags["caseless"] ? "yes" : "no"}</td>
+ <td>{p.flags["dot_all"] ? "yes" : "no"}</td>
+ <td>{p.flags["multi_line"] ? "yes" : "no"}</td>
+ <td>{p.flags["utf_8_mode"] ? "yes" : "no"}</td>
+ <td>{p.flags["unicode_property"] ? "yes" : "no"}</td>
+ <td>{p["min_occurrences"]}</td>
+ <td>{p["max_occurrences"]}</td>
+ <td>{this.directions[p.direction]}</td>
+ <td>
+ <ButtonField
+ variant="blue"
+ small
+ rounded
+ name="edit"
+ onClick={() => this.editPattern(p)}
+ />
+ </td>
+ </tr>
+ );
+
+ return (
+ <div className="pane-container rule-pane">
+ <div className="pane-section rules-list">
+ <div className="section-header">
+ <span className="api-request">GET /api/rules</span>
+ {this.state.rulesStatusCode &&
+ <span className="api-response"><LinkPopover text={this.state.rulesStatusCode}
+ content={this.state.rulesResponse}
+ placement="left" /></span>}
+ </div>
+
+ <div className="section-content">
+ <div className="section-table">
+ <Table borderless size="sm">
+ <thead>
+ <tr>
+ <th>id</th>
+ <th>name</th>
+ <th>color</th>
+ <th>notes</th>
+ </tr>
+ </thead>
+ <tbody>
+ {rules}
+ </tbody>
+ </Table>
+ </div>
+ </div>
+ </div>
+
+ <div className="pane-section rule-edit">
+ <div className="section-header">
+ <span className="api-request">
+ {isUpdate ? `PUT /api/rules/${this.state.selectedRule.id}` : "POST /api/rules"}
+ </span>
+ <span className="api-response"><LinkPopover text={this.state.ruleStatusCode}
+ content={this.state.ruleResponse}
+ placement="left" /></span>
+ </div>
+
+ <div className="section-content">
+ <Container className="p-0">
+ <Row>
+ <Col>
+ <InputField name="name" inline value={rule.name}
+ onChange={(v) => this.updateParam((r) => r.name = v)}
+ error={this.state.ruleNameError} />
+ <ColorField inline value={rule.color} error={this.state.ruleColorError}
+ onChange={(v) => this.updateParam((r) => r.color = v)} />
+ <TextField name="notes" rows={2} value={rule.notes}
+ onChange={(v) => this.updateParam((r) => r.notes = v)} />
+ </Col>
+
+ <Col style={{ "paddingTop": "6px" }}>
+ <span>filters:</span>
+ <NumericField name="service_port"
+ inline
+ value={rule.filter["service_port"]}
+ onChange={(v) => this.updateParam((r) => r.filter["service_port"] = v)}
+ min={0}
+ max={65565}
+ error={this.state.ruleServicePortError}
+ />
+ <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}
+ />
+ <InputField name="client_address"
+ value={rule.filter["client_address"]}
+ error={this.state.ruleClientAddressError}
+ onChange={(v) => this.updateParam((r) => r.filter["client_address"] = v)} />
+ </Col>
+
+ <Col style={{ "paddingTop": "11px" }}>
+ <NumericField name="min_duration" inline value={rule.filter["min_duration"]}
+ error={this.state.ruleDurationError} readonly={isUpdate}
+ onChange={(v) => this.updateParam((r) => r.filter["min_duration"] = v)} />
+ <NumericField name="max_duration" inline value={rule.filter["max_duration"]}
+ error={this.state.ruleDurationError} readonly={isUpdate}
+ onChange={(v) => this.updateParam((r) => r.filter["max_duration"] = v)} />
+ <NumericField name="min_bytes" inline value={rule.filter["min_bytes"]}
+ error={this.state.ruleBytesError} readonly={isUpdate}
+ onChange={(v) => this.updateParam((r) => r.filter["min_bytes"] = v)} />
+ <NumericField name="max_bytes" inline value={rule.filter["max_bytes"]}
+ error={this.state.ruleBytesError} readonly={isUpdate}
+ onChange={(v) => this.updateParam((r) => r.filter["max_bytes"] = v)} />
+ </Col>
+ </Row>
+ </Container>
+
+ <div className="section-table">
+ <Table borderless size="sm">
+ <thead>
+ <tr>
+ <th>regex</th>
+ <th>!Aa</th>
+ <th>.*</th>
+ <th>\n+</th>
+ <th>UTF8</th>
+ <th>Uni_</th>
+ <th>min</th>
+ <th>max</th>
+ <th>direction</th>
+ {!isUpdate && <th>actions</th>}
+ </tr>
+ </thead>
+ <tbody>
+ {patterns}
+ </tbody>
+ </Table>
+ {this.state.rulePatternsError != null &&
+ <span className="table-error">error: {this.state.rulePatternsError}</span>}
+ </div>
+ </div>
+
+ <div className="section-footer">
+ {<ButtonField variant="red" name="cancel" bordered onClick={this.reset} />}
+ <ButtonField variant={isUpdate ? "blue" : "green"} name={isUpdate ? "update_rule" : "add_rule"}
+ bordered onClick={isUpdate ? this.updateRule : this.addRule} />
+ <ButtonField variant="red" name="delete_rule" bordered onClick={this.deleteRule} />
+ </div>
+ </div>
+ </div>
+ );
+ }
+
+}
+
+export default RulesPane;
diff --git a/frontend/src/components/panels/SearchPane.jsx b/frontend/src/components/panels/SearchPane.jsx
new file mode 100644
index 0000000..6fe9dc7
--- /dev/null
+++ b/frontend/src/components/panels/SearchPane.jsx
@@ -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";
+
+import _ from '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/ServicesPane.jsx b/frontend/src/components/panels/ServicesPane.jsx
new file mode 100644
index 0000000..296b329
--- /dev/null
+++ b/frontend/src/components/panels/ServicesPane.jsx
@@ -0,0 +1,233 @@
+/*
+ * 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 Table from "react-bootstrap/Table";
+import backend from "../../backend";
+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 "./common.scss";
+import "./ServicesPane.scss";
+
+import classNames from 'classnames';
+import _ from 'lodash';
+
+class ServicesPane extends Component {
+
+ emptyService = {
+ "port": 0,
+ "name": "",
+ "color": "",
+ "notes": ""
+ };
+
+ state = {
+ services: [],
+ currentService: this.emptyService,
+ };
+
+ componentDidMount() {
+ this.reset();
+ this.loadServices();
+
+ dispatcher.register("notifications", this.handleNotifications);
+ document.title = "caronte:~/services$";
+ }
+
+ 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)}));
+ };
+
+ updateService = () => {
+ const service = this.state.currentService;
+ if (this.validateService(service)) {
+ backend.put("/api/services", service).then((res) => {
+ this.reset();
+ this.setState({serviceStatusCode: res.status});
+ this.loadServices();
+ }).catch((res) => {
+ this.setState({serviceStatusCode: res.status, serviceResponse: JSON.stringify(res.json)});
+ });
+ }
+ };
+
+ deleteService = () => {
+ const service = this.state.currentService;
+ if (this.validateService(service)) {
+ backend.delete("/api/services", service).then((res) => {
+ this.reset();
+ this.setState({serviceStatusCode: res.status});
+ this.loadServices();
+ }).catch((res) => {
+ this.setState({serviceStatusCode: res.status, serviceResponse: JSON.stringify(res.json)});
+ });
+ }
+ };
+
+ validateService = (service) => {
+ let valid = true;
+ if (!validation.isValidPort(service.port, true)) {
+ this.setState({servicePortError: "port < 0 || port > 65565"});
+ valid = false;
+ }
+ if (service.name.length < 3) {
+ this.setState({serviceNameError: "name.length < 3"});
+ valid = false;
+ }
+ if (!validation.isValidColor(service.color)) {
+ this.setState({serviceColorError: "color is not hexcolor"});
+ valid = false;
+ }
+
+ return valid;
+ };
+
+ reset = () => {
+ this.setState({
+ isUpdate: false,
+ currentService: _.cloneDeep(this.emptyService),
+ servicePortError: null,
+ serviceNameError: null,
+ serviceColorError: null,
+ serviceStatusCode: null,
+ servicesStatusCode: null,
+ serviceResponse: null,
+ servicesResponse: null
+ });
+ };
+
+ updateParam = (callback) => {
+ callback(this.state.currentService);
+ this.setState({currentService: this.state.currentService});
+ };
+
+ render() {
+ const isUpdate = this.state.isUpdate;
+ const service = this.state.currentService;
+
+ let services = this.state.services.map((s) =>
+ <tr key={s.port} onClick={() => {
+ this.reset();
+ this.setState({isUpdate: true, currentService: _.cloneDeep(s)});
+ }} className={classNames("row-small", "row-clickable", {"row-selected": service.port === s.port})}>
+ <td>{s["port"]}</td>
+ <td>{s["name"]}</td>
+ <td><ButtonField name={s["color"]} color={s["color"]} small/></td>
+ <td>{s["notes"]}</td>
+ </tr>
+ );
+
+ const curlCommand = createCurlCommand("/services", "PUT", service);
+
+ return (
+ <div className="pane-container service-pane">
+ <div className="pane-section services-list">
+ <div className="section-header">
+ <span className="api-request">GET /api/services</span>
+ {this.state.servicesStatusCode &&
+ <span className="api-response"><LinkPopover text={this.state.servicesStatusCode}
+ content={this.state.servicesResponse}
+ placement="left"/></span>}
+ </div>
+
+ <div className="section-content">
+ <div className="section-table">
+ <Table borderless size="sm">
+ <thead>
+ <tr>
+ <th>port</th>
+ <th>name</th>
+ <th>color</th>
+ <th>notes</th>
+ </tr>
+ </thead>
+ <tbody>
+ {services}
+ </tbody>
+ </Table>
+ </div>
+ </div>
+ </div>
+
+ <div className="pane-section service-edit">
+ <div className="section-header">
+ <span className="api-request">PUT /api/services</span>
+ <span className="api-response"><LinkPopover text={this.state.serviceStatusCode}
+ content={this.state.serviceResponse}
+ placement="left"/></span>
+ </div>
+
+ <div className="section-content">
+ <Container className="p-0">
+ <Row>
+ <Col>
+ <NumericField name="port" value={service.port}
+ onChange={(v) => this.updateParam((s) => s.port = v)}
+ min={0} max={65565} error={this.state.servicePortError}/>
+ <InputField name="name" value={service.name}
+ onChange={(v) => this.updateParam((s) => s.name = v)}
+ error={this.state.serviceNameError}/>
+ <ColorField value={service.color} error={this.state.serviceColorError}
+ onChange={(v) => this.updateParam((s) => s.color = v)}/>
+ </Col>
+
+ <Col>
+ <TextField name="notes" rows={7} value={service.notes}
+ onChange={(v) => this.updateParam((s) => s.notes = v)}/>
+ </Col>
+ </Row>
+ </Container>
+
+ <TextField value={curlCommand} rows={3} readonly small={true}/>
+ </div>
+
+ <div className="section-footer">
+ {<ButtonField variant="red" name="cancel" bordered onClick={this.reset}/>}
+ {isUpdate && <ButtonField variant="red" name= "delete_service"
+ bordered onClick={this.deleteService}/>}
+ <ButtonField variant={isUpdate ? "blue" : "green"}
+ name={isUpdate ? "update_service" : "add_service"}
+ bordered onClick={this.updateService}/>
+ </div>
+ </div>
+ </div>
+ );
+ }
+
+}
+
+export default ServicesPane;
diff --git a/frontend/src/components/panels/StatsPane.jsx b/frontend/src/components/panels/StatsPane.jsx
new file mode 100644
index 0000000..a35ef0c
--- /dev/null
+++ b/frontend/src/components/panels/StatsPane.jsx
@@ -0,0 +1,274 @@
+/*
+ * 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 {formatSize} from "../../utils";
+import ButtonField from "../fields/ButtonField";
+import CopyLinkPopover from "../objects/CopyLinkPopover";
+import LinkPopover from "../objects/LinkPopover";
+import "./common.scss";
+import "./StatsPane.scss";
+
+class StatsPane extends Component {
+
+ state = {
+ rules: []
+ };
+
+ componentDidMount() {
+ this.loadStats();
+ this.loadResourcesStats();
+ this.loadRules();
+ dispatcher.register("notifications", this.handleNotifications);
+ document.title = "caronte:~/stats$";
+ this.intervalToken = setInterval(() => this.loadResourcesStats(), 3000);
+ }
+
+ componentWillUnmount() {
+ dispatcher.unregister(this.handleNotifications);
+ clearInterval(this.intervalToken);
+ }
+
+ handleNotifications = (payload) => {
+ if (payload.event.startsWith("pcap")) {
+ this.loadStats();
+ } else if (payload.event.startsWith("rules")) {
+ this.loadRules();
+ }
+ };
+
+ loadStats = () => {
+ backend.get("/api/statistics/totals")
+ .then((res) => this.setState({stats: res.json, statsStatusCode: res.status}))
+ .catch((res) => this.setState({
+ stats: res.json, statsStatusCode: res.status,
+ statsResponse: JSON.stringify(res.json)
+ }));
+ };
+
+ loadResourcesStats = () => {
+ backend.get("/api/resources/system")
+ .then((res) => this.setState({resourcesStats: res.json, resourcesStatsStatusCode: res.status}))
+ .catch((res) => this.setState({
+ resourcesStats: res.json, resourcesStatsStatusCode: res.status,
+ resourcesStatsResponse: JSON.stringify(res.json)
+ }));
+ };
+
+ loadRules = () => {
+ backend.get("/api/rules").then((res) => this.setState({rules: res.json}));
+ };
+
+ render() {
+ const s = this.state.stats;
+ const rs = this.state.resourcesStats;
+
+ const ports = s && s["connections_per_service"] ? Object.keys(s["connections_per_service"]) : [];
+ let connections = 0, clientBytes = 0, serverBytes = 0, totalBytes = 0, duration = 0;
+ let servicesStats = ports.map((port) => {
+ connections += s["connections_per_service"][port];
+ clientBytes += s["client_bytes_per_service"][port];
+ serverBytes += s["server_bytes_per_service"][port];
+ totalBytes += s["total_bytes_per_service"][port];
+ duration += s["duration_per_service"][port];
+
+ return <tr key={port} className="row-small row-clickable">
+ <td>{port}</td>
+ <td>{formatSize(s["connections_per_service"][port])}</td>
+ <td>{formatSize(s["client_bytes_per_service"][port])}B</td>
+ <td>{formatSize(s["server_bytes_per_service"][port])}B</td>
+ <td>{formatSize(s["total_bytes_per_service"][port])}B</td>
+ <td>{formatSize(s["duration_per_service"][port] / 1000)}s</td>
+ </tr>;
+ });
+ servicesStats.push(<tr key="totals" className="row-small row-clickable font-weight-bold">
+ <td>totals</td>
+ <td>{formatSize(connections)}</td>
+ <td>{formatSize(clientBytes)}B</td>
+ <td>{formatSize(serverBytes)}B</td>
+ <td>{formatSize(totalBytes)}B</td>
+ <td>{formatSize(duration / 1000)}s</td>
+ </tr>);
+
+ const rulesStats = this.state.rules.map((r) =>
+ <tr key={r.id} className="row-small row-clickable">
+ <td><CopyLinkPopover text={r["id"].substring(0, 8)} value={r["id"]}/></td>
+ <td>{r["name"]}</td>
+ <td><ButtonField name={r["color"]} color={r["color"]} small/></td>
+ <td>{formatSize(s && s["matched_rules"] && s["matched_rules"][r.id] ? s["matched_rules"][r.id] : 0)}</td>
+ </tr>
+ );
+
+ const cpuStats = (rs ? rs["cpu_times"] : []).map((cpu, index) =>
+ <tr key={cpu["cpu"]} className="row-small row-clickable">
+ <td>{cpu["cpu"]}</td>
+ <td>{cpu["user"]}</td>
+ <td>{cpu["system"]}</td>
+ <td>{cpu["idle"]}</td>
+ <td>{cpu["nice"]}</td>
+ <td>{cpu["iowait"]}</td>
+ <td>{rs["cpu_percents"][index].toFixed(2)} %</td>
+ </tr>
+ );
+
+ return (
+ <div className="pane-container stats-pane">
+ <div className="pane-section stats-list">
+ <div className="section-header">
+ <span className="api-request">GET /api/statistics/totals</span>
+ <span className="api-response"><LinkPopover text={this.state.statsStatusCode}
+ content={this.state.statsResponse}
+ placement="left"/></span>
+ </div>
+
+ <div className="section-content">
+ <div className="section-table">
+ <Table borderless size="sm">
+ <thead>
+ <tr>
+ <th>service</th>
+ <th>connections</th>
+ <th>client_bytes</th>
+ <th>server_bytes</th>
+ <th>total_bytes</th>
+ <th>duration</th>
+ </tr>
+ </thead>
+ <tbody>
+ {servicesStats}
+ </tbody>
+ </Table>
+ </div>
+
+ <div className="section-table">
+ <Table borderless size="sm">
+ <thead>
+ <tr>
+ <th>rule_id</th>
+ <th>rule_name</th>
+ <th>rule_color</th>
+ <th>occurrences</th>
+ </tr>
+ </thead>
+ <tbody>
+ {rulesStats}
+ </tbody>
+ </Table>
+ </div>
+ </div>
+ </div>
+
+ <div className="pane-section stats-list" style={{"paddingTop": "10px"}}>
+ <div className="section-header">
+ <span className="api-request">GET /api/resources/system</span>
+ <span className="api-response"><LinkPopover text={this.state.resourcesStatsStatusCode}
+ content={this.state.resourcesStatsResponse}
+ placement="left"/></span>
+ </div>
+
+ <div className="section-content">
+ <div className="section-table">
+ <Table borderless size="sm">
+ <thead>
+ <tr>
+ <th>type</th>
+ <th>total</th>
+ <th>used</th>
+ <th>free</th>
+ <th>shared</th>
+ <th>buff/cache</th>
+ <th>available</th>
+ </tr>
+ </thead>
+ <tbody>
+ <tr className="row-small row-clickable">
+ <td>mem</td>
+ <td>{rs && formatSize(rs["virtual_memory"]["total"])}</td>
+ <td>{rs && formatSize(rs["virtual_memory"]["used"])}</td>
+ <td>{rs && formatSize(rs["virtual_memory"]["free"])}</td>
+ <td>{rs && formatSize(rs["virtual_memory"]["shared"])}</td>
+ <td>{rs && formatSize(rs["virtual_memory"]["cached"])}</td>
+ <td>{rs && formatSize(rs["virtual_memory"]["available"])}</td>
+ </tr>
+ <tr className="row-small row-clickable">
+ <td>swap</td>
+ <td>{rs && formatSize(rs["virtual_memory"]["swaptotal"])}</td>
+ <td>{rs && formatSize(rs["virtual_memory"]["swaptotal"])}</td>
+ <td>{rs && formatSize(rs["virtual_memory"]["swapfree"])}</td>
+ <td>-</td>
+ <td>-</td>
+ <td>-</td>
+ </tr>
+ </tbody>
+ </Table>
+ </div>
+
+ <div className="section-table">
+ <Table borderless size="sm">
+ <thead>
+ <tr>
+ <th>cpu</th>
+ <th>user</th>
+ <th>system</th>
+ <th>idle</th>
+ <th>nice</th>
+ <th>iowait</th>
+ <th>used_percent</th>
+ </tr>
+ </thead>
+ <tbody>
+ {cpuStats}
+ </tbody>
+ </Table>
+ </div>
+
+ <div className="section-table">
+ <Table borderless size="sm">
+ <thead>
+ <tr>
+ <th>disk_path</th>
+ <th>fs_type</th>
+ <th>total</th>
+ <th>free</th>
+ <th>used</th>
+ <th>used_percent</th>
+ </tr>
+ </thead>
+ <tbody>
+ <tr className="row-small row-clickable">
+ <td>{rs && rs["disk_usage"]["path"]}</td>
+ <td>{rs && rs["disk_usage"]["fstype"]}</td>
+ <td>{rs && formatSize(rs["disk_usage"]["total"])}</td>
+ <td>{rs && formatSize(rs["disk_usage"]["free"])}</td>
+ <td>{rs && formatSize(rs["disk_usage"]["used"])}</td>
+ <td>{rs && rs["disk_usage"]["usedPercent"].toFixed(2)} %</td>
+ </tr>
+ </tbody>
+ </Table>
+ </div>
+ </div>
+ </div>
+ </div>
+ );
+ }
+
+}
+
+export default StatsPane;
diff --git a/frontend/src/components/panels/StreamsPane.jsx b/frontend/src/components/panels/StreamsPane.jsx
new file mode 100644
index 0000000..9e88f55
--- /dev/null
+++ b/frontend/src/components/panels/StreamsPane.jsx
@@ -0,0 +1,453 @@
+/*
+ * 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 rules from "../../model/rules";
+import { downloadBlob, getHeaderValue } from "../../utils";
+import ButtonField from "../fields/ButtonField";
+import ChoiceField from "../fields/ChoiceField";
+import CopyDialog from "../dialogs/CopyDialog";
+import "./StreamsPane.scss";
+
+import reactStringReplace from "react-string-replace";
+import classNames from "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 });
+ }
+ };
+
+ viewAs = (mode) => {
+ if (mode === "decoded") {
+ this.setState({ tryParse: true });
+ } else if (mode === "raw") {
+ this.setState({ tryParse: false });
+ }
+ };
+
+ tryParseConnectionMessage = (connectionMessage) => {
+ const isClient = connectionMessage["from_client"];
+ if (connectionMessage.metadata == null) {
+ return this.highlightRules(connectionMessage.content, isClient);
+ }
+
+ 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" }}>
+ {this.highlightRules(m.body, isClient)}
+ </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);
+ if (typeof json === "object") {
+ 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" }}>
+ {this.highlightRules(body, isClient)}
+ </div>
+ {unrollMap(m.trailers)}
+ </span>
+ );
+ default:
+ return this.highlightRules(connectionMessage.content, isClient);
+ }
+ };
+
+ highlightRules = (content, isClient) => {
+ let streamContent = content;
+ this.props.connection["matched_rules"].forEach((ruleId) => {
+ const rule = rules.ruleById(ruleId);
+ rule.patterns.forEach((pattern) => {
+ if (
+ (!isClient && pattern.direction === 1) ||
+ (isClient && pattern.direction === 2)
+ ) {
+ return;
+ }
+ let flags = "";
+ pattern["caseless"] && (flags += "i");
+ pattern["dot_all"] && (flags += "s");
+ pattern["multi_line"] && (flags += "m");
+ pattern["unicode_property"] && (flags += "u");
+ const regex = new RegExp(
+ pattern.regex.replace(/^\//, "(").replace(/\/$/, ")"),
+ flags
+ );
+ streamContent = reactStringReplace(streamContent, regex, (match, i) => (
+ <span
+ key={i}
+ className="matched-occurrence"
+ style={{ backgroundColor: rule.color }}
+ >
+ {match}
+ </span>
+ ));
+ });
+ });
+
+ return streamContent;
+ };
+
+ 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(
+ ([name, value]) => (
+ <ButtonField
+ small
+ key={name + "_button"}
+ name={name}
+ onClick={() => {
+ this.setState({
+ messageActionDialog: (
+ <CopyDialog
+ actionName={name}
+ value={value}
+ 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
+ .filter(
+ (c) =>
+ !this.state.tryParse ||
+ (this.state.tryParse && !c["is_metadata_continuation"])
+ )
+ .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}
+ />
+
+ <ChoiceField
+ name="view_as"
+ inline
+ small
+ onlyName
+ onChange={this.viewAs}
+ keys={["decoded", "raw"]}
+ values={["decoded", "raw"]}
+ />
+
+ <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/index.jsx b/frontend/src/index.jsx
new file mode 100644
index 0000000..62cb974
--- /dev/null
+++ b/frontend/src/index.jsx
@@ -0,0 +1,32 @@
+/*
+ * 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 "bootstrap/dist/css/bootstrap.css";
+import React from "react";
+import ReactDOM from "react-dom";
+import App from "./components/App";
+import "./index.scss";
+import notifications from "./notifications";
+
+notifications.createWebsocket();
+
+ReactDOM.render(
+ // <React.StrictMode>
+ <App/>,
+ // </React.StrictMode>,
+ document.getElementById("root")
+);