aboutsummaryrefslogtreecommitdiff
path: root/frontend/src/components/Timeline.jsx
diff options
context:
space:
mode:
authorVaiTon2023-06-05 14:54:12 +0000
committerVaiTon2023-06-05 14:54:12 +0000
commit2e6af3b2623da3d002816a6de325133d626858c9 (patch)
tree69448ca3dc33f8754fa88ffa77ed3dea524848b5 /frontend/src/components/Timeline.jsx
parentf4c97974dac97f3254047fd17abed09e82cae797 (diff)
Frontend to jsx
Diffstat (limited to 'frontend/src/components/Timeline.jsx')
-rw-r--r--frontend/src/components/Timeline.jsx405
1 files changed, 405 insertions, 0 deletions
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);