aboutsummaryrefslogtreecommitdiff
path: root/frontend/src/components/panels
diff options
context:
space:
mode:
authorEmiliano Ciavatta2020-09-30 20:58:05 +0000
committerEmiliano Ciavatta2020-09-30 20:58:05 +0000
commit55afd62a8cfe2cde6e627f1905ab8fe77965afd6 (patch)
tree57545a722a62d2279bfcd2e36f1cbd1da5a5736a /frontend/src/components/panels
parent4cfdf6e2dfe9184e988a145495e072571d512cdc (diff)
parentd6e2aaad41f916c2080c59cf7b4e42bf87a1a03f (diff)
Merge branch 'feature/frontend' into develop
Diffstat (limited to 'frontend/src/components/panels')
-rw-r--r--frontend/src/components/panels/ConfigurationPane.js162
-rw-r--r--frontend/src/components/panels/ConfigurationPane.scss19
-rw-r--r--frontend/src/components/panels/MainPane.js56
-rw-r--r--frontend/src/components/panels/MainPane.scss23
-rw-r--r--frontend/src/components/panels/PcapPane.js251
-rw-r--r--frontend/src/components/panels/PcapPane.scss39
-rw-r--r--frontend/src/components/panels/RulePane.js413
-rw-r--r--frontend/src/components/panels/RulePane.scss32
-rw-r--r--frontend/src/components/panels/ServicePane.js190
-rw-r--r--frontend/src/components/panels/ServicePane.scss22
-rw-r--r--frontend/src/components/panels/common.scss101
11 files changed, 1308 insertions, 0 deletions
diff --git a/frontend/src/components/panels/ConfigurationPane.js b/frontend/src/components/panels/ConfigurationPane.js
new file mode 100644
index 0000000..10309f6
--- /dev/null
+++ b/frontend/src/components/panels/ConfigurationPane.js
@@ -0,0 +1,162 @@
+import React, {Component} from 'react';
+import './common.scss';
+import './ConfigurationPane.scss';
+import LinkPopover from "../objects/LinkPopover";
+import {Col, Container, Row} from "react-bootstrap";
+import InputField from "../fields/InputField";
+import TextField from "../fields/TextField";
+import ButtonField from "../fields/ButtonField";
+import CheckField from "../fields/CheckField";
+import {createCurlCommand} from "../../utils";
+import Table from "react-bootstrap/Table";
+import validation from "../../validation";
+import backend from "../../backend";
+
+class ConfigurationPane extends Component {
+
+ constructor(props) {
+ super(props);
+ this.state = {
+ settings: {
+ "config": {
+ "server_address": "",
+ "flag_regex": "",
+ "auth_required": false
+ },
+ "accounts": {
+ }
+ },
+ newUsername: "",
+ newPassword: ""
+ };
+ }
+
+ saveSettings = () => {
+ if (this.validateSettings(this.state.settings)) {
+ backend.post("/setup", this.state.settings).then(_ => {
+ this.props.onConfigured();
+ }).catch(res => {
+ this.setState({setupStatusCode: res.status, setupResponse: JSON.stringify(res.json)});
+ });
+ }
+ };
+
+ validateSettings = (settings) => {
+ let valid = true;
+ if (!validation.isValidAddress(settings.config.server_address, true)) {
+ this.setState({serverAddressError: "invalid ip_address"});
+ valid = false;
+ }
+ if (settings.config.flag_regex.length < 8) {
+ this.setState({flagRegexError: "flag_regex.length < 8"});
+ valid = false;
+ }
+
+ return valid;
+ };
+
+ updateParam = (callback) => {
+ callback(this.state.settings);
+ this.setState({settings: this.state.settings});
+ };
+
+ addAccount = () => {
+ if (this.state.newUsername.length !== 0 && this.state.newPassword.length !== 0) {
+ const settings = this.state.settings;
+ settings.accounts[this.state.newUsername] = this.state.newPassword;
+
+ this.setState({
+ newUsername: "",
+ newPassword: "",
+ settings: settings
+ });
+ } else {
+ this.setState({
+ newUsernameActive: this.state.newUsername.length === 0,
+ newPasswordActive: this.state.newPassword.length === 0
+ });
+ }
+ };
+
+ render() {
+ const settings = this.state.settings;
+ const curlCommand = createCurlCommand("/setup", "POST", settings);
+
+ const accounts = Object.entries(settings.accounts).map(([username, password]) =>
+ <tr key={username}>
+ <td>{username}</td>
+ <td><LinkPopover text="******" content={password} /></td>
+ <td><ButtonField variant="red" small rounded name="delete"
+ onClick={() => this.updateParam((s) => delete s.accounts[username]) }/></td>
+ </tr>).concat(<tr key={"new_account"}>
+ <td><InputField value={this.state.newUsername} small active={this.state.newUsernameActive}
+ onChange={(v) => this.setState({newUsername: v})} /></td>
+ <td><InputField value={this.state.newPassword} small active={this.state.newPasswordActive}
+ onChange={(v) => this.setState({newPassword: v})} /></td>
+ <td><ButtonField variant="green" small rounded name="add" onClick={this.addAccount}/></td>
+ </tr>);
+
+ return (
+ <div className="configuration-pane">
+ <div className="pane">
+ <div className="pane-container">
+ <div className="pane-section">
+ <div className="section-header">
+ <span className="api-request">POST /setup</span>
+ <span className="api-response"><LinkPopover text={this.state.setupStatusCode}
+ content={this.state.setupResponse}
+ placement="left" /></span>
+ </div>
+
+ <div className="section-content">
+ <Container className="p-0">
+ <Row>
+ <Col>
+ <InputField name="server_address" value={settings.config.server_address}
+ error={this.state.serverAddressError}
+ onChange={(v) => this.updateParam((s) => s.config.server_address = v)} />
+ <InputField name="flag_regex" value={settings.config.flag_regex}
+ onChange={(v) => this.updateParam((s) => s.config.flag_regex = v)}
+ error={this.state.flagRegexError} />
+ <div style={{"marginTop": "10px"}}>
+ <CheckField checked={settings.config.auth_required} name="auth_required"
+ onChange={(v) => this.updateParam((s) => s.config.auth_required = v)}/>
+ </div>
+
+ </Col>
+
+ <Col>
+ accounts:
+ <div className="section-table">
+ <Table borderless size="sm">
+ <thead>
+ <tr>
+ <th>username</th>
+ <th>password</th>
+ <th>actions</th>
+ </tr>
+ </thead>
+ <tbody>
+ {accounts}
+ </tbody>
+ </Table>
+ </div>
+ </Col>
+ </Row>
+ </Container>
+
+ <TextField value={curlCommand} rows={4} readonly small={true}/>
+ </div>
+
+ <div className="section-footer">
+ <ButtonField variant="green" name="save" bordered onClick={this.saveSettings} />
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+ );
+ }
+}
+
+export default ConfigurationPane;
diff --git a/frontend/src/components/panels/ConfigurationPane.scss b/frontend/src/components/panels/ConfigurationPane.scss
new file mode 100644
index 0000000..955d2bc
--- /dev/null
+++ b/frontend/src/components/panels/ConfigurationPane.scss
@@ -0,0 +1,19 @@
+@import '../../colors';
+
+.configuration-pane {
+ height: 100%;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ background-color: $color-primary-0;
+
+ .pane {
+ flex-basis: 900px;
+ margin-bottom: 200px;
+ }
+
+ .pane-container {
+ padding-bottom: 1px;
+ }
+
+}
diff --git a/frontend/src/components/panels/MainPane.js b/frontend/src/components/panels/MainPane.js
new file mode 100644
index 0000000..3202d6d
--- /dev/null
+++ b/frontend/src/components/panels/MainPane.js
@@ -0,0 +1,56 @@
+import React, {Component} from 'react';
+import './common.scss';
+import './MainPane.scss';
+import Connections from "../../views/Connections";
+import ConnectionContent from "../ConnectionContent";
+import {Route, Switch, withRouter} from "react-router-dom";
+import PcapPane from "./PcapPane";
+import backend from "../../backend";
+import RulePane from "./RulePane";
+import ServicePane from "./ServicePane";
+
+class MainPane extends Component {
+
+ constructor(props) {
+ super(props);
+ this.state = {
+ selectedConnection: null,
+ loading: false
+ };
+ }
+
+ componentDidMount() {
+ const match = this.props.location.pathname.match(/^\/connections\/([a-f0-9]{24})$/);
+ if (match != null) {
+ this.setState({loading: true});
+ backend.get(`/api/connections/${match[1]}`)
+ .then(res => this.setState({selectedConnection: res.json, loading: false}))
+ .catch(error => console.log(error));
+ }
+ }
+
+ render() {
+ return (
+ <div className="main-pane">
+ <div className="pane connections-pane">
+ {
+ !this.state.loading &&
+ <Connections onSelected={(c) => this.setState({selectedConnection: c})}
+ initialConnection={this.state.selectedConnection} />
+ }
+ </div>
+ <div className="pane details-pane">
+ <Switch>
+ <Route path="/pcaps" children={<PcapPane />} />
+ <Route path="/rules" children={<RulePane />} />
+ <Route path="/services" children={<ServicePane />} />
+ <Route exact path="/connections/:id" children={<ConnectionContent connection={this.state.selectedConnection} />} />
+ <Route children={<ConnectionContent />} />
+ </Switch>
+ </div>
+ </div>
+ );
+ }
+}
+
+export default withRouter(MainPane);
diff --git a/frontend/src/components/panels/MainPane.scss b/frontend/src/components/panels/MainPane.scss
new file mode 100644
index 0000000..04be347
--- /dev/null
+++ b/frontend/src/components/panels/MainPane.scss
@@ -0,0 +1,23 @@
+@import '../../colors';
+
+.main-pane {
+ height: 100%;
+ display: flex;
+ padding: 0 15px;
+ background-color: $color-primary-2;
+
+ .pane {
+ flex: 1;
+ }
+
+ .connections-pane {
+ flex: 1 0;
+ margin-right: 7.5px;
+ }
+
+ .details-pane {
+ flex: 1 1;
+ margin-left: 7.5px;
+ }
+
+}
diff --git a/frontend/src/components/panels/PcapPane.js b/frontend/src/components/panels/PcapPane.js
new file mode 100644
index 0000000..7b3fde6
--- /dev/null
+++ b/frontend/src/components/panels/PcapPane.js
@@ -0,0 +1,251 @@
+import React, {Component} from 'react';
+import './PcapPane.scss';
+import './common.scss';
+import Table from "react-bootstrap/Table";
+import backend from "../../backend";
+import {createCurlCommand, dateTimeToTime, durationBetween, formatSize} from "../../utils";
+import InputField from "../fields/InputField";
+import CheckField from "../fields/CheckField";
+import TextField from "../fields/TextField";
+import ButtonField from "../fields/ButtonField";
+import LinkPopover from "../objects/LinkPopover";
+
+class PcapPane extends Component {
+
+ constructor(props) {
+ super(props);
+
+ this.state = {
+ sessions: [],
+ isUploadFileValid: true,
+ isUploadFileFocused: false,
+ uploadFlushAll: false,
+ isFileValid: true,
+ isFileFocused: false,
+ fileValue: "",
+ processFlushAll: false,
+ deleteOriginalFile: false
+ };
+ }
+
+ componentDidMount() {
+ this.loadSessions();
+ }
+
+ loadSessions = () => {
+ backend.get("/api/pcap/sessions")
+ .then(res => this.setState({sessions: res.json, sessionsStatusCode: res.status}))
+ .catch(res => this.setState({
+ sessions: res.json, sessionsStatusCode: res.status,
+ sessionsResponse: JSON.stringify(res.json)
+ }));
+ };
+
+ uploadPcap = () => {
+ if (this.state.uploadSelectedFile == null || !this.state.isUploadFileValid) {
+ this.setState({isUploadFileFocused: true});
+ return;
+ }
+
+ const formData = new FormData();
+ formData.append("file", this.state.uploadSelectedFile);
+ formData.append("flush_all", this.state.uploadFlushAll);
+ backend.postFile("/api/pcap/upload", formData).then(res => {
+ this.setState({
+ uploadStatusCode: res.status,
+ uploadResponse: JSON.stringify(res.json)
+ });
+ this.resetUpload();
+ this.loadSessions();
+ }).catch(res => this.setState({
+ uploadStatusCode: res.status,
+ uploadResponse: JSON.stringify(res.json)
+ })
+ );
+ };
+
+ processPcap = () => {
+ if (this.state.fileValue === "" || !this.state.isFileValid) {
+ this.setState({isFileFocused: true});
+ return;
+ }
+
+ backend.post("/api/pcap/file", {
+ file: this.state.fileValue,
+ flush_all: this.state.processFlushAll,
+ delete_original_file: this.state.deleteOriginalFile
+ }).then(res => {
+ this.setState({
+ processStatusCode: res.status,
+ processResponse: JSON.stringify(res.json)
+ });
+ this.resetProcess();
+ this.loadSessions();
+ }).catch(res => this.setState({
+ processStatusCode: res.status,
+ processResponse: JSON.stringify(res.json)
+ })
+ );
+ };
+
+ resetUpload = () => {
+ this.setState({
+ isUploadFileValid: true,
+ isUploadFileFocused: false,
+ uploadFlushAll: false,
+ uploadSelectedFile: null
+ });
+ };
+
+ resetProcess = () => {
+ this.setState({
+ isFileValid: true,
+ isFileFocused: false,
+ fileValue: "",
+ processFlushAll: false,
+ deleteOriginalFile: false,
+ });
+ };
+
+ render() {
+ let sessions = this.state.sessions.map(s =>
+ <tr key={s.id} className="table-row">
+ <td>{s["id"].substring(0, 8)}</td>
+ <td>{dateTimeToTime(s["started_at"])}</td>
+ <td>{durationBetween(s["started_at"], s["completed_at"])}</td>
+ <td>{formatSize(s["size"])}</td>
+ <td>{s["processed_packets"]}</td>
+ <td>{s["invalid_packets"]}</td>
+ <td><LinkPopover text={Object.keys(s["packets_per_service"]).length + " services"}
+ content={JSON.stringify(s["packets_per_service"])}
+ placement="left"/></td>
+ <td className="table-cell-action"><a href={"/api/pcap/sessions/" + s["id"] + "/download"}>download</a>
+ </td>
+ </tr>
+ );
+
+ const handleUploadFileChange = (file) => {
+ this.setState({
+ isUploadFileValid: file == null || (file.type.endsWith("pcap") || file.type.endsWith("pcapng")),
+ isUploadFileFocused: false,
+ uploadSelectedFile: file,
+ uploadStatusCode: null,
+ uploadResponse: null
+ });
+ };
+
+ const handleFileChange = (file) => {
+ this.setState({
+ isFileValid: (file.endsWith("pcap") || file.endsWith("pcapng")),
+ isFileFocused: false,
+ fileValue: file,
+ processStatusCode: null,
+ processResponse: null
+ });
+ };
+
+ const uploadCurlCommand = createCurlCommand("pcap/upload", "POST", null, {
+ file: "@" + ((this.state.uploadSelectedFile != null && this.state.isUploadFileValid) ?
+ this.state.uploadSelectedFile.name : "invalid.pcap"),
+ flush_all: this.state.uploadFlushAll
+ });
+
+ const fileCurlCommand = createCurlCommand("pcap/file", "POST", {
+ file: this.state.fileValue,
+ flush_all: this.state.processFlushAll,
+ delete_original_file: this.state.deleteOriginalFile
+ });
+
+ return (
+ <div className="pane-container pcap-pane">
+ <div className="pane-section pcap-list">
+ <div className="section-header">
+ <span className="api-request">GET /api/pcap/sessions</span>
+ <span className="api-response"><LinkPopover text={this.state.sessionsStatusCode}
+ content={this.state.sessionsResponse}
+ placement="left"/></span>
+ </div>
+
+ <div className="section-content">
+ <div className="section-table">
+ <Table borderless size="sm">
+ <thead>
+ <tr>
+ <th>id</th>
+ <th>started_at</th>
+ <th>duration</th>
+ <th>size</th>
+ <th>processed_packets</th>
+ <th>invalid_packets</th>
+ <th>packets_per_service</th>
+ <th>actions</th>
+ </tr>
+ </thead>
+ <tbody>
+ {sessions}
+ </tbody>
+ </Table>
+ </div>
+ </div>
+ </div>
+
+ <div className="double-pane-container">
+ <div className="pane-section">
+ <div className="section-header">
+ <span className="api-request">POST /api/pcap/upload</span>
+ <span className="api-response"><LinkPopover text={this.state.uploadStatusCode}
+ content={this.state.uploadResponse}
+ placement="left"/></span>
+ </div>
+
+ <div className="section-content">
+ <InputField type={"file"} name={"file"} invalid={!this.state.isUploadFileValid}
+ active={this.state.isUploadFileFocused}
+ onChange={handleUploadFileChange} value={this.state.uploadSelectedFile}
+ placeholder={"no .pcap[ng] selected"}/>
+ <div className="upload-actions">
+ <div className="upload-options">
+ <span>options:</span>
+ <CheckField name="flush_all" checked={this.state.uploadFlushAll}
+ onChange={v => this.setState({uploadFlushAll: v})}/>
+ </div>
+ <ButtonField variant="green" bordered onClick={this.uploadPcap} name="upload"/>
+ </div>
+
+ <TextField value={uploadCurlCommand} rows={4} readonly small={true}/>
+ </div>
+ </div>
+
+ <div className="pane-section">
+ <div className="section-header">
+ <span className="api-request">POST /api/pcap/file</span>
+ <span className="api-response"><LinkPopover text={this.state.processStatusCode}
+ content={this.state.processResponse}
+ placement="left"/></span>
+ </div>
+
+ <div className="section-content">
+ <InputField name="file" active={this.state.isFileFocused} invalid={!this.state.isFileValid}
+ onChange={handleFileChange} value={this.state.fileValue}
+ placeholder={"local .pcap[ng] path"} inline/>
+
+ <div className="upload-actions" style={{"marginTop": "11px"}}>
+ <div className="upload-options">
+ <CheckField name="flush_all" checked={this.state.processFlushAll}
+ onChange={v => this.setState({processFlushAll: v})}/>
+ <CheckField name="delete_original_file" checked={this.state.deleteOriginalFile}
+ onChange={v => this.setState({deleteOriginalFile: v})}/>
+ </div>
+ <ButtonField variant="blue" bordered onClick={this.processPcap} name="process"/>
+ </div>
+
+ <TextField value={fileCurlCommand} rows={4} readonly small={true}/>
+ </div>
+ </div>
+ </div>
+ </div>
+ );
+ }
+}
+
+export default PcapPane;
diff --git a/frontend/src/components/panels/PcapPane.scss b/frontend/src/components/panels/PcapPane.scss
new file mode 100644
index 0000000..721560a
--- /dev/null
+++ b/frontend/src/components/panels/PcapPane.scss
@@ -0,0 +1,39 @@
+@import '../../colors.scss';
+
+.pcap-pane {
+ display: flex;
+ flex-direction: column;
+
+ .pcap-list {
+ flex: 1;
+ overflow: hidden;
+
+ .section-content {
+ height: 100%;
+ }
+
+ .section-table {
+ height: calc(100% - 30px);
+ }
+
+ .table-cell-action {
+ font-size: 13px;
+ font-weight: 600;
+ }
+ }
+
+ .upload-actions {
+ display: flex;
+ align-items: flex-end;
+ margin-bottom: 20px;
+ }
+
+ .upload-options {
+ flex: 1;
+
+ span {
+ font-size: 0.9em;
+ }
+ }
+
+}
diff --git a/frontend/src/components/panels/RulePane.js b/frontend/src/components/panels/RulePane.js
new file mode 100644
index 0000000..49364d2
--- /dev/null
+++ b/frontend/src/components/panels/RulePane.js
@@ -0,0 +1,413 @@
+import React, {Component} from 'react';
+import './common.scss';
+import './RulePane.scss';
+import Table from "react-bootstrap/Table";
+import {Col, Container, Row} from "react-bootstrap";
+import InputField from "../fields/InputField";
+import CheckField from "../fields/CheckField";
+import TextField from "../fields/TextField";
+import backend from "../../backend";
+import NumericField from "../fields/extensions/NumericField";
+import ColorField from "../fields/extensions/ColorField";
+import ChoiceField from "../fields/ChoiceField";
+import ButtonField from "../fields/ButtonField";
+import validation from "../../validation";
+import LinkPopover from "../objects/LinkPopover";
+import {randomClassName} from "../../utils";
+
+const classNames = require('classnames');
+const _ = require('lodash');
+
+class RulePane extends Component {
+
+ constructor(props) {
+ super(props);
+
+ this.state = {
+ rules: [],
+ newRule: this.emptyRule,
+ newPattern: this.emptyPattern
+ };
+
+ this.directions = {
+ 0: "both",
+ 1: "c->s",
+ 2: "s->c"
+ };
+ }
+
+ componentDidMount() {
+ this.reset();
+ this.loadRules();
+ }
+
+ emptyRule = {
+ "name": "",
+ "color": "",
+ "notes": "",
+ "enabled": true,
+ "patterns": [],
+ "filter": {
+ "service_port": 0,
+ "client_address": "",
+ "client_port": 0,
+ "min_duration": 0,
+ "max_duration": 0,
+ "min_bytes": 0,
+ "max_bytes": 0
+ },
+ "version": 0
+ };
+
+ emptyPattern = {
+ "regex": "",
+ "flags": {
+ "caseless": false,
+ "dot_all": false,
+ "multi_line": false,
+ "utf_8_mode": false,
+ "unicode_property": false
+ },
+ "min_occurrences": 0,
+ "max_occurrences": 0,
+ "direction": 0
+ };
+
+ loadRules = () => {
+ backend.get("/api/rules").then(res => this.setState({rules: res.json, rulesStatusCode: res.status}))
+ .catch(res => this.setState({rulesStatusCode: res.status, rulesResponse: JSON.stringify(res.json)}));
+ };
+
+ addRule = () => {
+ if (this.validateRule(this.state.newRule)) {
+ backend.post("/api/rules", this.state.newRule).then(res => {
+ this.reset();
+ this.setState({ruleStatusCode: res.status});
+ this.loadRules();
+ }).catch(res => {
+ this.setState({ruleStatusCode: res.status, ruleResponse: JSON.stringify(res.json)});
+ });
+ }
+ };
+
+ updateRule = () => {
+ const rule = this.state.selectedRule;
+ if (this.validateRule(rule)) {
+ backend.put(`/api/rules/${rule.id}`, rule).then(res => {
+ this.reset();
+ this.setState({ruleStatusCode: res.status});
+ this.loadRules();
+ }).catch(res => {
+ this.setState({ruleStatusCode: res.status, ruleResponse: JSON.stringify(res.json)});
+ });
+ }
+ };
+
+ validateRule = (rule) => {
+ let valid = true;
+ if (rule.name.length < 3) {
+ this.setState({ruleNameError: "name.length < 3"});
+ valid = false;
+ }
+ if (!validation.isValidColor(rule.color)) {
+ this.setState({ruleColorError: "color is not hexcolor"});
+ valid = false;
+ }
+ if (!validation.isValidPort(rule.filter.service_port)) {
+ this.setState({ruleServicePortError: "service_port > 65565"});
+ valid = false;
+ }
+ if (!validation.isValidPort(rule.filter.client_port)) {
+ this.setState({ruleClientPortError: "client_port > 65565"});
+ valid = false;
+ }
+ if (!validation.isValidAddress(rule.filter.client_address)) {
+ this.setState({ruleClientAddressError: "client_address is not ip_address"});
+ valid = false;
+ }
+ if (rule.filter.min_duration > rule.filter.max_duration) {
+ this.setState({ruleDurationError: "min_duration > max_dur."});
+ valid = false;
+ }
+ if (rule.filter.min_bytes > rule.filter.max_bytes) {
+ this.setState({ruleBytesError: "min_bytes > max_bytes"});
+ valid = false;
+ }
+ if (rule.patterns.length < 1) {
+ this.setState({rulePatternsError: "patterns.length < 1"});
+ valid = false;
+ }
+
+ return valid;
+ };
+
+ reset = () => {
+ const newRule = _.cloneDeep(this.emptyRule);
+ const newPattern = _.cloneDeep(this.emptyPattern);
+ this.setState({
+ selectedRule: null,
+ newRule: newRule,
+ selectedPattern: null,
+ newPattern: newPattern,
+ patternRegexFocused: false,
+ patternOccurrencesFocused: false,
+ ruleNameError: null,
+ ruleColorError: null,
+ ruleServicePortError: null,
+ ruleClientPortError: null,
+ ruleClientAddressError: null,
+ ruleDurationError: null,
+ ruleBytesError: null,
+ rulePatternsError: null,
+ ruleStatusCode: null,
+ rulesStatusCode: null,
+ ruleResponse: null,
+ rulesResponse: null
+ });
+ };
+
+ updateParam = (callback) => {
+ const updatedRule = this.currentRule();
+ callback(updatedRule);
+ this.setState({newRule: updatedRule});
+ };
+
+ currentRule = () => this.state.selectedRule != null ? this.state.selectedRule : this.state.newRule;
+
+ addPattern = (pattern) => {
+ if (!this.validatePattern(pattern)) {
+ return;
+ }
+
+ const newPattern = _.cloneDeep(this.emptyPattern);
+ this.currentRule().patterns.push(pattern);
+ this.setState({
+ newPattern: newPattern
+ });
+ };
+
+ editPattern = (pattern) => {
+ this.setState({
+ selectedPattern: pattern
+ });
+ };
+
+ updatePattern = (pattern) => {
+ if (!this.validatePattern(pattern)) {
+ return;
+ }
+
+ this.setState({
+ selectedPattern: null
+ });
+ };
+
+ validatePattern = (pattern) => {
+ let valid = true;
+ if (pattern.regex === "") {
+ valid = false;
+ this.setState({patternRegexFocused: true});
+ }
+ if (pattern.min_occurrences > pattern.max_occurrences) {
+ valid = false;
+ this.setState({patternOccurrencesFocused: true});
+ }
+ return valid;
+ };
+
+ render() {
+ const isUpdate = this.state.selectedRule != null;
+ const rule = this.currentRule();
+ const pattern = this.state.selectedPattern || this.state.newPattern;
+
+ let rules = this.state.rules.map(r =>
+ <tr key={r.id} onClick={() => {
+ this.reset();
+ this.setState({selectedRule: _.cloneDeep(r)});
+ }} className={classNames("row-small", "row-clickable", {"row-selected": rule.id === r.id })}>
+ <td>{r["id"].substring(0, 8)}</td>
+ <td>{r["name"]}</td>
+ <td><ButtonField name={r["color"]} color={r["color"]} small /></td>
+ <td>{r["notes"]}</td>
+ </tr>
+ );
+
+ let patterns = (this.state.selectedPattern == null && !isUpdate ?
+ rule.patterns.concat(this.state.newPattern) :
+ rule.patterns
+ ).map(p => p === pattern ?
+ <tr key={randomClassName()}>
+ <td style={{"width": "500px"}}>
+ <InputField small active={this.state.patternRegexFocused} value={pattern.regex}
+ onChange={(v) => {
+ this.updateParam(() => pattern.regex = v);
+ this.setState({patternRegexFocused: pattern.regex === ""});
+ }} />
+ </td>
+ <td><CheckField small checked={pattern.flags.caseless}
+ onChange={(v) => this.updateParam(() => pattern.flags.caseless = v)}/></td>
+ <td><CheckField small checked={pattern.flags.dot_all}
+ onChange={(v) => this.updateParam(() => pattern.flags.dot_all = v)}/></td>
+ <td><CheckField small checked={pattern.flags.multi_line}
+ onChange={(v) => this.updateParam(() => pattern.flags.multi_line = v)}/></td>
+ <td><CheckField small checked={pattern.flags.utf_8_mode}
+ onChange={(v) => this.updateParam(() => pattern.flags.utf_8_mode = v)}/></td>
+ <td><CheckField small checked={pattern.flags.unicode_property}
+ onChange={(v) => this.updateParam(() => pattern.flags.unicode_property = v)}/></td>
+ <td style={{"width": "70px"}}>
+ <NumericField small value={pattern.min_occurrences}
+ active={this.state.patternOccurrencesFocused}
+ onChange={(v) => this.updateParam(() => pattern.min_occurrences = v)} />
+ </td>
+ <td style={{"width": "70px"}}>
+ <NumericField small value={pattern.max_occurrences}
+ active={this.state.patternOccurrencesFocused}
+ onChange={(v) => this.updateParam(() => pattern.max_occurrences = v)} />
+ </td>
+ <td><ChoiceField inline small keys={[0, 1, 2]} values={["both", "c->s", "s->c"]}
+ value={this.directions[pattern.direction]}
+ onChange={(v) => this.updateParam(() => pattern.direction = v)} /></td>
+ <td>{this.state.selectedPattern == null ?
+ <ButtonField variant="green" small name="add" inline rounded onClick={() => this.addPattern(p)}/> :
+ <ButtonField variant="green" small name="save" inline rounded onClick={() => this.updatePattern(p)}/>}
+ </td>
+ </tr>
+ :
+ <tr key={"new_pattern"} className="row-small">
+ <td>{p.regex}</td>
+ <td>{p.flags.caseless ? "yes": "no"}</td>
+ <td>{p.flags.dot_all ? "yes": "no"}</td>
+ <td>{p.flags.multi_line ? "yes": "no"}</td>
+ <td>{p.flags.utf_8_mode ? "yes": "no"}</td>
+ <td>{p.flags.unicode_property ? "yes": "no"}</td>
+ <td>{p.min_occurrences}</td>
+ <td>{p.max_occurrences}</td>
+ <td>{this.directions[p.direction]}</td>
+ {!isUpdate && <td><ButtonField variant="blue" small rounded name="edit"
+ onClick={() => this.editPattern(p) }/></td>}
+ </tr>
+ );
+
+ return (
+ <div className="pane-container rule-pane">
+ <div className="pane-section rules-list">
+ <div className="section-header">
+ <span className="api-request">GET /api/rules</span>
+ {this.state.rulesStatusCode &&
+ <span className="api-response"><LinkPopover text={this.state.rulesStatusCode}
+ content={this.state.rulesResponse}
+ placement="left" /></span>}
+ </div>
+
+ <div className="section-content">
+ <div className="section-table">
+ <Table borderless size="sm">
+ <thead>
+ <tr>
+ <th>id</th>
+ <th>name</th>
+ <th>color</th>
+ <th>notes</th>
+ </tr>
+ </thead>
+ <tbody>
+ {rules}
+ </tbody>
+ </Table>
+ </div>
+ </div>
+ </div>
+
+ <div className="pane-section rule-edit">
+ <div className="section-header">
+ <span className="api-request">
+ {isUpdate ? `PUT /api/rules/${this.state.selectedRule.id}` : "POST /api/rules"}
+ </span>
+ <span className="api-response"><LinkPopover text={this.state.ruleStatusCode}
+ content={this.state.ruleResponse}
+ placement="left" /></span>
+ </div>
+
+ <div className="section-content">
+ <Container className="p-0">
+ <Row>
+ <Col>
+ <InputField name="name" inline value={rule.name}
+ onChange={(v) => this.updateParam((r) => r.name = v)}
+ error={this.state.ruleNameError} />
+ <ColorField inline value={rule.color} error={this.state.ruleColorError}
+ onChange={(v) => this.updateParam((r) => r.color = v)} />
+ <TextField name="notes" rows={2} value={rule.notes}
+ onChange={(v) => this.updateParam((r) => r.notes = v)} />
+ </Col>
+
+ <Col style={{"paddingTop": "6px"}}>
+ <span>filters:</span>
+ <NumericField name="service_port" inline value={rule.filter.service_port}
+ onChange={(v) => this.updateParam((r) => r.filter.service_port = v)}
+ min={0} max={65565} error={this.state.ruleServicePortError}
+ readonly={isUpdate} />
+ <NumericField name="client_port" inline value={rule.filter.client_port}
+ onChange={(v) => this.updateParam((r) => r.filter.client_port = v)}
+ min={0} max={65565} error={this.state.ruleClientPortError}
+ readonly={isUpdate} />
+ <InputField name="client_address" value={rule.filter.client_address}
+ error={this.state.ruleClientAddressError} readonly={isUpdate}
+ onChange={(v) => this.updateParam((r) => r.filter.client_address = v)} />
+ </Col>
+
+ <Col style={{"paddingTop": "11px"}}>
+ <NumericField name="min_duration" inline value={rule.filter.min_duration}
+ error={this.state.ruleDurationError} readonly={isUpdate}
+ onChange={(v) => this.updateParam((r) => r.filter.min_duration = v)} />
+ <NumericField name="max_duration" inline value={rule.filter.max_duration}
+ error={this.state.ruleDurationError} readonly={isUpdate}
+ onChange={(v) => this.updateParam((r) => r.filter.max_duration = v)} />
+ <NumericField name="min_bytes" inline value={rule.filter.min_bytes}
+ error={this.state.ruleBytesError} readonly={isUpdate}
+ onChange={(v) => this.updateParam((r) => r.filter.min_bytes = v)} />
+ <NumericField name="max_bytes" inline value={rule.filter.max_bytes}
+ error={this.state.ruleBytesError} readonly={isUpdate}
+ onChange={(v) => this.updateParam((r) => r.filter.max_bytes = v)} />
+ </Col>
+ </Row>
+ </Container>
+
+ <div className="section-table">
+ <Table borderless size="sm">
+ <thead>
+ <tr>
+ <th>regex</th>
+ <th>!Aa</th>
+ <th>.*</th>
+ <th>\n+</th>
+ <th>UTF8</th>
+ <th>Uni_</th>
+ <th>min</th>
+ <th>max</th>
+ <th>direction</th>
+ {!isUpdate && <th>actions</th> }
+ </tr>
+ </thead>
+ <tbody>
+ {patterns}
+ </tbody>
+ </Table>
+ {this.state.rulePatternsError != null &&
+ <span className="table-error">error: {this.state.rulePatternsError}</span>}
+ </div>
+ </div>
+
+ <div className="section-footer">
+ {<ButtonField variant="red" name="cancel" bordered onClick={this.reset}/>}
+ <ButtonField variant={isUpdate ? "blue" : "green"} name={isUpdate ? "update_rule" : "add_rule"}
+ bordered onClick={isUpdate ? this.updateRule : this.addRule} />
+ </div>
+ </div>
+ </div>
+ );
+ }
+
+}
+
+export default RulePane;
diff --git a/frontend/src/components/panels/RulePane.scss b/frontend/src/components/panels/RulePane.scss
new file mode 100644
index 0000000..d45c366
--- /dev/null
+++ b/frontend/src/components/panels/RulePane.scss
@@ -0,0 +1,32 @@
+
+.rule-pane {
+ display: flex;
+ flex-direction: column;
+
+ .rules-list {
+ flex: 2 1;
+ overflow: hidden;
+
+ .section-content {
+ height: 100%;
+ }
+
+ .section-table {
+ height: calc(100% - 30px);
+ }
+ }
+
+ .rule-edit {
+ flex: 3 0;
+ display: flex;
+ flex-direction: column;
+
+ .section-content {
+ flex: 1;
+ }
+
+ .section-table {
+ max-height: 150px;
+ }
+ }
+}
diff --git a/frontend/src/components/panels/ServicePane.js b/frontend/src/components/panels/ServicePane.js
new file mode 100644
index 0000000..eaefa64
--- /dev/null
+++ b/frontend/src/components/panels/ServicePane.js
@@ -0,0 +1,190 @@
+import React, {Component} from 'react';
+import './common.scss';
+import './ServicePane.scss';
+import Table from "react-bootstrap/Table";
+import {Col, Container, Row} from "react-bootstrap";
+import InputField from "../fields/InputField";
+import TextField from "../fields/TextField";
+import backend from "../../backend";
+import NumericField from "../fields/extensions/NumericField";
+import ColorField from "../fields/extensions/ColorField";
+import ButtonField from "../fields/ButtonField";
+import validation from "../../validation";
+import LinkPopover from "../objects/LinkPopover";
+import {createCurlCommand} from "../../utils";
+
+const classNames = require('classnames');
+const _ = require('lodash');
+
+class ServicePane extends Component {
+
+ constructor(props) {
+ super(props);
+
+ this.state = {
+ services: [],
+ currentService: this.emptyService,
+ };
+ }
+
+ componentDidMount() {
+ this.reset();
+ this.loadServices();
+ }
+
+ emptyService = {
+ "port": 0,
+ "name": "",
+ "color": "",
+ "notes": ""
+ };
+
+ loadServices = () => {
+ backend.get("/api/services")
+ .then(res => this.setState({services: Object.values(res.json), servicesStatusCode: res.status}))
+ .catch(res => this.setState({servicesStatusCode: res.status, servicesResponse: JSON.stringify(res.json)}));
+ };
+
+ updateService = () => {
+ const service = this.state.currentService;
+ if (this.validateService(service)) {
+ backend.put("/api/services", service).then(res => {
+ this.reset();
+ this.setState({serviceStatusCode: res.status});
+ this.loadServices();
+ }).catch(res => {
+ this.setState({serviceStatusCode: res.status, serviceResponse: JSON.stringify(res.json)});
+ });
+ }
+ };
+
+ validateService = (service) => {
+ let valid = true;
+ if (!validation.isValidPort(service.port, true)) {
+ this.setState({servicePortError: "port < 0 || port > 65565"});
+ valid = false;
+ }
+ if (service.name.length < 3) {
+ this.setState({serviceNameError: "name.length < 3"});
+ valid = false;
+ }
+ if (!validation.isValidColor(service.color)) {
+ this.setState({serviceColorError: "color is not hexcolor"});
+ valid = false;
+ }
+
+ return valid;
+ };
+
+ reset = () => {
+ this.setState({
+ isUpdate: false,
+ currentService: _.cloneDeep(this.emptyService),
+ servicePortError: null,
+ serviceNameError: null,
+ serviceColorError: null,
+ serviceStatusCode: null,
+ servicesStatusCode: null,
+ serviceResponse: null,
+ servicesResponse: null
+ });
+ };
+
+ updateParam = (callback) => {
+ callback(this.state.currentService);
+ this.setState({currentService: this.state.currentService});
+ };
+
+ render() {
+ const isUpdate = this.state.isUpdate;
+ const service = this.state.currentService;
+
+ let services = this.state.services.map(s =>
+ <tr key={s.port} onClick={() => {
+ this.reset();
+ this.setState({isUpdate: true, currentService: _.cloneDeep(s)});
+ }} className={classNames("row-small", "row-clickable", {"row-selected": service.port === s.port })}>
+ <td>{s["port"]}</td>
+ <td>{s["name"]}</td>
+ <td><ButtonField name={s["color"]} color={s["color"]} small /></td>
+ <td>{s["notes"]}</td>
+ </tr>
+ );
+
+ const curlCommand = createCurlCommand("/services", "PUT", service);
+
+ return (
+ <div className="pane-container service-pane">
+ <div className="pane-section services-list">
+ <div className="section-header">
+ <span className="api-request">GET /api/services</span>
+ {this.state.servicesStatusCode &&
+ <span className="api-response"><LinkPopover text={this.state.servicesStatusCode}
+ content={this.state.servicesResponse}
+ placement="left" /></span>}
+ </div>
+
+ <div className="section-content">
+ <div className="section-table">
+ <Table borderless size="sm">
+ <thead>
+ <tr>
+ <th>port</th>
+ <th>name</th>
+ <th>color</th>
+ <th>notes</th>
+ </tr>
+ </thead>
+ <tbody>
+ {services}
+ </tbody>
+ </Table>
+ </div>
+ </div>
+ </div>
+
+ <div className="pane-section service-edit">
+ <div className="section-header">
+ <span className="api-request">PUT /api/services</span>
+ <span className="api-response"><LinkPopover text={this.state.serviceStatusCode}
+ content={this.state.serviceResponse}
+ placement="left" /></span>
+ </div>
+
+ <div className="section-content">
+ <Container className="p-0">
+ <Row>
+ <Col>
+ <NumericField name="port" value={service.port}
+ onChange={(v) => this.updateParam((s) => s.port = v)}
+ min={0} max={65565} error={this.state.servicePortError} />
+ <InputField name="name" value={service.name}
+ onChange={(v) => this.updateParam((s) => s.name = v)}
+ error={this.state.serviceNameError} />
+ <ColorField value={service.color} error={this.state.serviceColorError}
+ onChange={(v) => this.updateParam((s) => s.color = v)} />
+ </Col>
+
+ <Col>
+ <TextField name="notes" rows={7} value={service.notes}
+ onChange={(v) => this.updateParam((s) => s.notes = v)} />
+ </Col>
+ </Row>
+ </Container>
+
+ <TextField value={curlCommand} rows={3} readonly small={true}/>
+ </div>
+
+ <div className="section-footer">
+ {<ButtonField variant="red" name="cancel" bordered onClick={this.reset}/>}
+ <ButtonField variant={isUpdate ? "blue" : "green"} name={isUpdate ? "update_service" : "add_service"}
+ bordered onClick={this.updateService} />
+ </div>
+ </div>
+ </div>
+ );
+ }
+
+}
+
+export default ServicePane;
diff --git a/frontend/src/components/panels/ServicePane.scss b/frontend/src/components/panels/ServicePane.scss
new file mode 100644
index 0000000..0b154e6
--- /dev/null
+++ b/frontend/src/components/panels/ServicePane.scss
@@ -0,0 +1,22 @@
+
+.service-pane {
+ display: flex;
+ flex-direction: column;
+
+ .services-list {
+ flex: 1;
+ overflow: hidden;
+
+ .section-content {
+ height: 100%;
+ }
+
+ .section-table {
+ height: calc(100% - 30px);
+ }
+ }
+
+ .service-edit {
+ flex: 0;
+ }
+}
diff --git a/frontend/src/components/panels/common.scss b/frontend/src/components/panels/common.scss
new file mode 100644
index 0000000..ea8da94
--- /dev/null
+++ b/frontend/src/components/panels/common.scss
@@ -0,0 +1,101 @@
+@import '../../colors.scss';
+
+.pane-container {
+ background-color: $color-primary-3;
+ padding: 10px 10px 0;
+ height: 100%;
+
+ .pane-section {
+ margin-bottom: 10px;
+ background-color: $color-primary-0;
+
+ .section-header {
+ background-color: $color-primary-2;
+ padding: 5px 10px;
+ font-weight: 500;
+ font-size: 0.9em;
+ display: flex;
+
+ .api-request {
+ flex: 1;
+ }
+ }
+
+ .section-content {
+ padding: 10px;
+ }
+
+ .section-table {
+ position: relative;
+ overflow-y: scroll;
+
+ .table-error {
+ font-size: 0.8em;
+ color: $color-secondary-0;
+ margin-left: 10px;
+ }
+ }
+
+ table {
+ margin-bottom: 0;
+
+ tbody tr {
+ background-color: $color-primary-3;
+ border-top: 3px solid $color-primary-0;
+ border-bottom: 3px solid $color-primary-0;
+ }
+
+ th {
+ background-color: $color-primary-2;
+ font-size: 0.8em;
+ position: sticky;
+ top: 0;
+ padding: 2px 5px;
+ border: none;
+ }
+
+ .row-small {
+ font-size: 0.9em;
+ }
+
+ .row-clickable {
+ cursor: pointer;
+
+ &:hover {
+ background-color: $color-primary-0;
+ }
+ }
+
+ .row-selected {
+ background-color: $color-primary-0;
+ }
+ }
+ }
+
+ .section-footer {
+ display: flex;
+ padding: 10px;
+ background-color: $color-primary-0;
+ justify-content: flex-end;
+
+ .button-field {
+ margin-left: 10px;
+ }
+ }
+
+ .double-pane-container {
+ display: flex;
+
+ .pane-section {
+ flex: 1;
+ }
+
+ .pane-section:nth-child(1) {
+ margin-right: 5px;
+ }
+
+ .pane-section:nth-child(2) {
+ margin-left: 5px;
+ }
+ }
+}