From d3d13f101a45dace9ca7eb5291d3d51dfb315d84 Mon Sep 17 00:00:00 2001 From: Emiliano Ciavatta Date: Fri, 2 Oct 2020 23:35:12 +0200 Subject: Add render_html feature --- frontend/package.json | 1 + frontend/src/components/ConnectionContent.js | 67 +++++++++++++++++++++------- frontend/yarn.lock | 5 +++ 3 files changed, 57 insertions(+), 16 deletions(-) diff --git a/frontend/package.json b/frontend/package.json index b13a7ea..a2d4166 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -12,6 +12,7 @@ "bootstrap": "^4.4.1", "bs-custom-file-input": "^1.3.4", "classnames": "^2.2.6", + "dompurify": "^2.1.1", "eslint-config-react-app": "^5.2.1", "lodash": "^4.17.20", "node-sass": "^4.14.0", diff --git a/frontend/src/components/ConnectionContent.js b/frontend/src/components/ConnectionContent.js index ccaec0b..8667886 100644 --- a/frontend/src/components/ConnectionContent.js +++ b/frontend/src/components/ConnectionContent.js @@ -5,6 +5,7 @@ import MessageAction from "./MessageAction"; import backend from "../backend"; import ButtonField from "./fields/ButtonField"; import ChoiceField from "./fields/ChoiceField"; +import DOMPurify from 'dompurify'; const classNames = require('classnames'); @@ -21,7 +22,6 @@ class ConnectionContent extends Component { }; this.validFormats = ["default", "hex", "hexdump", "base32", "base64", "ascii", "binary", "decimal", "octal"]; - this.setFormat = this.setFormat.bind(this); } componentDidMount() { @@ -33,10 +33,15 @@ class ConnectionContent extends Component { componentDidUpdate(prevProps, prevState, snapshot) { if (this.props.connection != null && ( this.props.connection !== prevProps.connection || this.state.format !== prevState.format)) { + this.closeRenderWindow(); this.loadStream(); } } + componentWillUnmount() { + this.closeRenderWindow(); + } + loadStream = () => { this.setState({loading: true}); // TODO: limit workaround. @@ -48,13 +53,13 @@ class ConnectionContent extends Component { }); }; - setFormat(format) { + setFormat = (format) => { if (this.validFormats.includes(format)) { this.setState({format: format}); } - } + }; - tryParseConnectionMessage(connectionMessage) { + tryParseConnectionMessage = (connectionMessage) => { if (connectionMessage.metadata == null) { return connectionMessage.content; } @@ -87,22 +92,52 @@ class ConnectionContent extends Component { default: return connectionMessage.content; } - } + }; - connectionsActions(connectionMessage) { - if (connectionMessage.metadata == null || connectionMessage.metadata["reproducers"] === undefined) { + connectionsActions = (connectionMessage) => { + if (connectionMessage.metadata == null) { //} || !connectionMessage.metadata["reproducers"]) { return null; } - return Object.entries(connectionMessage.metadata["reproducers"]).map(([actionName, actionValue]) => - { - this.setState({ - messageActionDialog: this.setState({messageActionDialog: null})}/> - }); - }} /> - ); - } + const m = connectionMessage.metadata; + switch (m.type) { + case "http-request" : + if (!connectionMessage.metadata["reproducers"]) { + return; + } + return Object.entries(connectionMessage.metadata["reproducers"]).map(([actionName, actionValue]) => + { + this.setState({ + messageActionDialog: this.setState({messageActionDialog: null})}/> + }); + }} /> + ); + case "http-response": + if (m.headers && m.headers["Content-Type"].includes("text/html")) { + return { + 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; + } + }; + + closeRenderWindow = () => { + if (this.state.renderWindow) { + this.state.renderWindow.close(); + } + }; render() { let content = this.state.connectionContent; diff --git a/frontend/yarn.lock b/frontend/yarn.lock index 6d111a3..0a754ad 100644 --- a/frontend/yarn.lock +++ b/frontend/yarn.lock @@ -4068,6 +4068,11 @@ domhandler@^2.3.0: dependencies: domelementtype "1" +dompurify@^2.1.1: + version "2.1.1" + resolved "https://registry.yarnpkg.com/dompurify/-/dompurify-2.1.1.tgz#b5aa988676b093a9c836d8b855680a8598af25fe" + integrity sha512-NijiNVkS/OL8mdQL1hUbCD6uty/cgFpmNiuFxrmJ5YPH2cXrPKIewoixoji56rbZ6XBPmtM8GA8/sf9unlSuwg== + domutils@1.5.1: version "1.5.1" resolved "https://registry.yarnpkg.com/domutils/-/domutils-1.5.1.tgz#dcd8488a26f563d61079e48c9f7b7e32373682cf" -- cgit v1.2.3-70-g09d2 From 7d40e85e64762db8d420a33ed352f707edd7a9ba Mon Sep 17 00:00:00 2001 From: Emiliano Ciavatta Date: Sat, 3 Oct 2020 00:13:53 +0200 Subject: Add json viewer feature --- frontend/package.json | 1 + frontend/src/components/ConnectionContent.js | 19 +++- frontend/src/components/ConnectionContent.scss | 4 + frontend/src/utils.js | 7 ++ frontend/yarn.lock | 134 +++++++++++++++++++++++-- 5 files changed, 157 insertions(+), 8 deletions(-) diff --git a/frontend/package.json b/frontend/package.json index a2d4166..f22fad5 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -20,6 +20,7 @@ "react-bootstrap": "^1.0.1", "react-dom": "^16.13.1", "react-input-mask": "^3.0.0-alpha.2", + "react-json-view": "^1.19.1", "react-router": "^5.1.2", "react-router-dom": "^5.1.2", "react-scripts": "3.4.1", diff --git a/frontend/src/components/ConnectionContent.js b/frontend/src/components/ConnectionContent.js index 8667886..11b8617 100644 --- a/frontend/src/components/ConnectionContent.js +++ b/frontend/src/components/ConnectionContent.js @@ -6,6 +6,8 @@ import backend from "../backend"; import ButtonField from "./fields/ButtonField"; import ChoiceField from "./fields/ChoiceField"; import DOMPurify from 'dompurify'; +import ReactJson from 'react-json-view' +import {getHeaderValue} from "../utils"; const classNames = require('classnames'); @@ -83,10 +85,21 @@ class ConnectionContent extends Component { {unrollMap(m.trailers)} ; case "http-response": + const contentType = getHeaderValue(m, "Content-Type"); + let body = m.body; + if (contentType && contentType.includes("application/json")) { + try { + const json = JSON.parse(m.body); + body = ; + } catch (e) { + console.log(e); + } + } + return

{m.protocol} {m.status}

{unrollMap(m.headers)} -
{m.body}
+
{body}
{unrollMap(m.trailers)}
; default: @@ -114,7 +127,9 @@ class ConnectionContent extends Component { }} /> ); case "http-response": - if (m.headers && m.headers["Content-Type"].includes("text/html")) { + const contentType = getHeaderValue(m, "Content-Type"); + + if (contentType && contentType.includes("text/html")) { return { let w; if (this.state.renderWindow && !this.state.renderWindow.closed) { diff --git a/frontend/src/components/ConnectionContent.scss b/frontend/src/components/ConnectionContent.scss index c97a4b0..de4d699 100644 --- a/frontend/src/components/ConnectionContent.scss +++ b/frontend/src/components/ConnectionContent.scss @@ -48,6 +48,10 @@ .message-content { padding: 10px; + + .react-json-view { + background-color: inherit !important; + } } &:hover .connection-message-actions { diff --git a/frontend/src/utils.js b/frontend/src/utils.js index 8c7fe0f..fb0e5d9 100644 --- a/frontend/src/utils.js +++ b/frontend/src/utils.js @@ -111,3 +111,10 @@ export function formatSize(size) { export function randomClassName() { return Math.random().toString(36).slice(2); } + +export function getHeaderValue(request, key) { + if (request && request.headers) { + return request.headers[Object.keys(request.headers).find(k => k.toLowerCase() === key.toLowerCase())]; + } + return undefined; +} diff --git a/frontend/yarn.lock b/frontend/yarn.lock index 0a754ad..84d6058 100644 --- a/frontend/yarn.lock +++ b/frontend/yarn.lock @@ -2281,7 +2281,7 @@ arrify@^1.0.1: resolved "https://registry.yarnpkg.com/arrify/-/arrify-1.0.1.tgz#898508da2226f380df904728456849c1501a4b0d" integrity sha1-iYUI2iIm84DfkEcoRWhJwVAaSw0= -asap@~2.0.6: +asap@~2.0.3, asap@~2.0.6: version "2.0.6" resolved "https://registry.yarnpkg.com/asap/-/asap-2.0.6.tgz#e50347611d7e690943208bbdafebcbc2fb866d46" integrity sha1-5QNHYR1+aQlDIIu9r+vLwvuGbUY= @@ -2553,6 +2553,11 @@ balanced-match@^1.0.0: resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.0.tgz#89b4d199ab2bee49de164ea02b89ce462d71b767" integrity sha1-ibTRmasr7kneFk6gK4nORi1xt2c= +base16@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/base16/-/base16-1.0.0.tgz#e297f60d7ec1014a7a971a39ebc8a98c0b681e70" + integrity sha1-4pf2DX7BAUp6lxo568ipjAtoHnA= + base64-js@^1.0.2: version "1.3.1" resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-1.3.1.tgz#58ece8cb75dd07e71ed08c736abc5fac4dbf8df1" @@ -3419,6 +3424,11 @@ core-js-pure@^3.0.0: resolved "https://registry.yarnpkg.com/core-js-pure/-/core-js-pure-3.6.5.tgz#c79e75f5e38dbc85a662d91eea52b8256d53b813" integrity sha512-lacdXOimsiD0QyNf9BC/mxivNJ/ybBGJXQFKzRekp1WTHoVUWsUHEn+2T8GJAzzIhyOuXA+gOxCVN3l+5PLPUA== +core-js@^1.0.0: + version "1.2.7" + resolved "https://registry.yarnpkg.com/core-js/-/core-js-1.2.7.tgz#652294c14651db28fa93bd2d5ff2983a4f08c636" + integrity sha1-ZSKUwUZR2yj6k70tX/KYOk8IxjY= + core-js@^2.4.0: version "2.6.11" resolved "https://registry.yarnpkg.com/core-js/-/core-js-2.6.11.tgz#38831469f9922bded8ee21c9dc46985e0399308c" @@ -4185,6 +4195,13 @@ encodeurl@~1.0.2: resolved "https://registry.yarnpkg.com/encodeurl/-/encodeurl-1.0.2.tgz#ad3ff4c86ec2d029322f5a02c3a9a606c95b3f59" integrity sha1-rT/0yG7C0CkyL1oCw6mmBslbP1k= +encoding@^0.1.11: + version "0.1.13" + resolved "https://registry.yarnpkg.com/encoding/-/encoding-0.1.13.tgz#56574afdd791f54a8e9b2785c0582a2d26210fa9" + integrity sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A== + dependencies: + iconv-lite "^0.6.2" + end-of-stream@^1.0.0, end-of-stream@^1.1.0: version "1.4.4" resolved "https://registry.yarnpkg.com/end-of-stream/-/end-of-stream-1.4.4.tgz#5ae64a5f45057baf3626ec14da0ca5e4b2431eb0" @@ -4767,6 +4784,26 @@ fb-watchman@^2.0.0: dependencies: bser "2.1.1" +fbemitter@^2.0.0: + version "2.1.1" + resolved "https://registry.yarnpkg.com/fbemitter/-/fbemitter-2.1.1.tgz#523e14fdaf5248805bb02f62efc33be703f51865" + integrity sha1-Uj4U/a9SSIBbsC9i78M75wP1GGU= + dependencies: + fbjs "^0.8.4" + +fbjs@^0.8.0, fbjs@^0.8.4: + version "0.8.17" + resolved "https://registry.yarnpkg.com/fbjs/-/fbjs-0.8.17.tgz#c4d598ead6949112653d6588b01a5cdcd9f90fdd" + integrity sha1-xNWY6taUkRJlPWWIsBpc3Nn5D90= + dependencies: + core-js "^1.0.0" + isomorphic-fetch "^2.1.1" + loose-envify "^1.0.0" + object-assign "^4.1.0" + promise "^7.1.1" + setimmediate "^1.0.5" + ua-parser-js "^0.7.18" + figgy-pudding@^3.5.1: version "3.5.2" resolved "https://registry.yarnpkg.com/figgy-pudding/-/figgy-pudding-3.5.2.tgz#b4eee8148abb01dcf1d1ac34367d59e12fa61d6e" @@ -4918,6 +4955,14 @@ flush-write-stream@^1.0.0: inherits "^2.0.3" readable-stream "^2.3.6" +flux@^3.1.3: + version "3.1.3" + resolved "https://registry.yarnpkg.com/flux/-/flux-3.1.3.tgz#d23bed515a79a22d933ab53ab4ada19d05b2f08a" + integrity sha1-0jvtUVp5oi2TOrU6tK2hnQWy8Io= + dependencies: + fbemitter "^2.0.0" + fbjs "^0.8.0" + follow-redirects@^1.0.0: version "1.13.0" resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.13.0.tgz#b42e8d93a2a7eea5ed88633676d6597bc8e384db" @@ -5565,6 +5610,13 @@ iconv-lite@0.4.24, iconv-lite@^0.4.24: dependencies: safer-buffer ">= 2.1.2 < 3" +iconv-lite@^0.6.2: + version "0.6.2" + resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.6.2.tgz#ce13d1875b0c3a674bd6a04b7f76b01b1b6ded01" + integrity sha512-2y91h5OpQlolefMPmUlivelittSWy0rP+oYVpn6A7GwVHNE8AWzoYOBNmlwks3LobaJxgHCYZAnyNo2GgpNRNQ== + dependencies: + safer-buffer ">= 2.1.2 < 3.0.0" + icss-utils@^4.0.0, icss-utils@^4.1.1: version "4.1.1" resolved "https://registry.yarnpkg.com/icss-utils/-/icss-utils-4.1.1.tgz#21170b53789ee27447c2f47dd683081403f9a467" @@ -6051,7 +6103,7 @@ is-root@2.1.0: resolved "https://registry.yarnpkg.com/is-root/-/is-root-2.1.0.tgz#809e18129cf1129644302a4f8544035d51984a9c" integrity sha512-AGOriNp96vNBd3HtU+RzFEc75FfR5ymiYv8E553I71SCeXBiMsVDUtdio1OEFvrPyLIQ9tVR5RxXIFe5PUFjMg== -is-stream@^1.1.0: +is-stream@^1.0.1, is-stream@^1.1.0: version "1.1.0" resolved "https://registry.yarnpkg.com/is-stream/-/is-stream-1.1.0.tgz#12d4a3dd4e68e0b79ceb8dbc84173ae80d91ca44" integrity sha1-EtSj3U5o4Lec6428hBc66A2RykQ= @@ -6129,6 +6181,14 @@ isobject@^3.0.0, isobject@^3.0.1: resolved "https://registry.yarnpkg.com/isobject/-/isobject-3.0.1.tgz#4e431e92b11a9731636aa1f9c8d1ccbcfdab78df" integrity sha1-TkMekrEalzFjaqH5yNHMvP2reN8= +isomorphic-fetch@^2.1.1: + version "2.2.1" + resolved "https://registry.yarnpkg.com/isomorphic-fetch/-/isomorphic-fetch-2.2.1.tgz#611ae1acf14f5e81f729507472819fe9733558a9" + integrity sha1-YRrhrPFPXoH3KVB0coGf6XM1WKk= + dependencies: + node-fetch "^1.0.1" + whatwg-fetch ">=0.10.0" + isstream@~0.1.2: version "0.1.2" resolved "https://registry.yarnpkg.com/isstream/-/isstream-0.1.2.tgz#47e63f7af55afa6f92e1500e690eb8b8529c099a" @@ -6945,6 +7005,16 @@ lodash._reinterpolate@^3.0.0: resolved "https://registry.yarnpkg.com/lodash._reinterpolate/-/lodash._reinterpolate-3.0.0.tgz#0ccf2d89166af03b3663c796538b75ac6e114d9d" integrity sha1-DM8tiRZq8Ds2Y8eWU4t1rG4RTZ0= +lodash.curry@^4.0.1: + version "4.1.1" + resolved "https://registry.yarnpkg.com/lodash.curry/-/lodash.curry-4.1.1.tgz#248e36072ede906501d75966200a86dab8b23170" + integrity sha1-JI42By7ekGUB11lmIAqG2riyMXA= + +lodash.flow@^3.3.0: + version "3.5.0" + resolved "https://registry.yarnpkg.com/lodash.flow/-/lodash.flow-3.5.0.tgz#87bf40292b8cf83e4e8ce1a3ae4209e20071675a" + integrity sha1-h79AKSuM+D5OjOGjrkIJ4gBxZ1o= + lodash.memoize@^4.1.2: version "4.1.2" resolved "https://registry.yarnpkg.com/lodash.memoize/-/lodash.memoize-4.1.2.tgz#bcc6c49a42a2840ed997f323eada5ecd182e0bfe" @@ -7443,6 +7513,14 @@ no-case@^3.0.3: lower-case "^2.0.1" tslib "^1.10.0" +node-fetch@^1.0.1: + version "1.7.3" + resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-1.7.3.tgz#980f6f72d85211a5347c6b2bc18c5b84c3eb47ef" + integrity sha512-NhZ4CsKx7cYm2vSrBAr2PvFOe6sWDf0UYLRqA6svUYg7+/TSfVAu49jYC4BvQ4Sms9SZgdqGBgroqfDhJdTyKQ== + dependencies: + encoding "^0.1.11" + is-stream "^1.0.1" + node-forge@^0.10.0: version "0.10.0" resolved "https://registry.yarnpkg.com/node-forge/-/node-forge-0.10.0.tgz#32dea2afb3e9926f02ee5ce8794902691a676bf3" @@ -8972,6 +9050,13 @@ promise-inflight@^1.0.1: resolved "https://registry.yarnpkg.com/promise-inflight/-/promise-inflight-1.0.1.tgz#98472870bf228132fcbdd868129bad12c3c029e3" integrity sha1-mEcocL8igTL8vdhoEputEsPAKeM= +promise@^7.1.1: + version "7.3.1" + resolved "https://registry.yarnpkg.com/promise/-/promise-7.3.1.tgz#064b72602b18f90f29192b8b1bc418ffd1ebd3bf" + integrity sha512-nolQXZ/4L+bP/UGlkfaIujX9BKxGwmQ9OT4mOt5yvy8iK1h3wqTEJCijzGANTCCl9nWjY41juyAn2K3Q1hLLTg== + dependencies: + asap "~2.0.3" + promise@^8.0.3: version "8.1.0" resolved "https://registry.yarnpkg.com/promise/-/promise-8.1.0.tgz#697c25c3dfe7435dd79fcd58c38a135888eaf05e" @@ -8995,7 +9080,7 @@ prop-types-extra@^1.1.0: react-is "^16.3.2" warning "^4.0.0" -prop-types@^15.6.2, prop-types@^15.7.2: +prop-types@^15.6.0, prop-types@^15.6.2, prop-types@^15.7.2: version "15.7.2" resolved "https://registry.yarnpkg.com/prop-types/-/prop-types-15.7.2.tgz#52c41e75b8c87e72b9d9360e0206b99dcbffa6c5" integrity sha512-8QQikdH7//R2vurIJSutZ1smHYTcLpRWEOlHnzcWHmBYrOGUysKwSsrC89BCiFj3CbrfJ/nXFdJepOVrY1GCHQ== @@ -9079,6 +9164,11 @@ punycode@^2.1.0, punycode@^2.1.1: resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.1.1.tgz#b58b010ac40c22c5657616c8d2c2c02c7bf479ec" integrity sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A== +pure-color@^1.2.0: + version "1.3.0" + resolved "https://registry.yarnpkg.com/pure-color/-/pure-color-1.3.0.tgz#1fe064fb0ac851f0de61320a8bf796836422f33e" + integrity sha1-H+Bk+wrIUfDeYTIKi/eWg2Qi8z4= + q@^1.1.2: version "1.5.1" resolved "https://registry.yarnpkg.com/q/-/q-1.5.1.tgz#7e32f75b41381291d04611f1bf14109ac00651d7" @@ -9166,6 +9256,16 @@ react-app-polyfill@^1.0.6: regenerator-runtime "^0.13.3" whatwg-fetch "^3.0.0" +react-base16-styling@^0.6.0: + version "0.6.0" + resolved "https://registry.yarnpkg.com/react-base16-styling/-/react-base16-styling-0.6.0.tgz#ef2156d66cf4139695c8a167886cb69ea660792c" + integrity sha1-7yFW1mz0E5aVyKFniGy2nqZgeSw= + dependencies: + base16 "^1.0.0" + lodash.curry "^4.0.1" + lodash.flow "^3.3.0" + pure-color "^1.2.0" + react-bootstrap@^1.0.1: version "1.3.0" resolved "https://registry.yarnpkg.com/react-bootstrap/-/react-bootstrap-1.3.0.tgz#d9dde4ad554e9cd21d1465e8b5e5ef6679cae6a1" @@ -9249,6 +9349,16 @@ react-is@^16.12.0, react-is@^16.3.2, react-is@^16.6.0, react-is@^16.7.0, react-i resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.13.1.tgz#789729a4dc36de2999dc156dd6c1d9c18cea56a4" integrity sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ== +react-json-view@^1.19.1: + version "1.19.1" + resolved "https://registry.yarnpkg.com/react-json-view/-/react-json-view-1.19.1.tgz#95d8e59e024f08a25e5dc8f076ae304eed97cf5c" + integrity sha512-u5e0XDLIs9Rj43vWkKvwL8G3JzvXSl6etuS5G42a8klMohZuYFQzSN6ri+/GiBptDqlrXPTdExJVU7x9rrlXhg== + dependencies: + flux "^3.1.3" + react-base16-styling "^0.6.0" + react-lifecycles-compat "^3.0.4" + react-textarea-autosize "^6.1.0" + react-lifecycles-compat@^3.0.4: version "3.0.4" resolved "https://registry.yarnpkg.com/react-lifecycles-compat/-/react-lifecycles-compat-3.0.4.tgz#4f1a273afdfc8f3488a8c516bfda78f872352362" @@ -9362,6 +9472,13 @@ react-tag-autocomplete@^6.0.0-beta.6: resolved "https://registry.yarnpkg.com/react-tag-autocomplete/-/react-tag-autocomplete-6.1.0.tgz#9fb70149a69b33379013e5255bcd7ad97d8ec06b" integrity sha512-AMhVqxEEIrOfzH0A9XrpsTaLZCVYgjjxp3DSTuSvx91LBSFI6uYcKe38ltR/H/TQw4aytofVghQ1hR9sKpXRQA== +react-textarea-autosize@^6.1.0: + version "6.1.0" + resolved "https://registry.yarnpkg.com/react-textarea-autosize/-/react-textarea-autosize-6.1.0.tgz#df91387f8a8f22020b77e3833c09829d706a09a5" + integrity sha512-F6bI1dgib6fSvG8so1HuArPUv+iVEfPliuLWusLF+gAKz0FbB4jLrWUrTAeq1afnPT2c9toEZYUdz/y1uKMy4A== + dependencies: + prop-types "^15.6.0" + react-transition-group@^4.4.1: version "4.4.1" resolved "https://registry.yarnpkg.com/react-transition-group/-/react-transition-group-4.4.1.tgz#63868f9325a38ea5ee9535d828327f85773345c9" @@ -9850,7 +9967,7 @@ safe-regex@^1.1.0: dependencies: ret "~0.1.10" -"safer-buffer@>= 2.1.2 < 3", safer-buffer@^2.0.2, safer-buffer@^2.1.0, safer-buffer@~2.1.0: +"safer-buffer@>= 2.1.2 < 3", "safer-buffer@>= 2.1.2 < 3.0.0", safer-buffer@^2.0.2, safer-buffer@^2.1.0, safer-buffer@~2.1.0: version "2.1.2" resolved "https://registry.yarnpkg.com/safer-buffer/-/safer-buffer-2.1.2.tgz#44fa161b0187b9549dd84bb91802f9bd8385cd6a" integrity sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg== @@ -10048,7 +10165,7 @@ set-value@^2.0.0, set-value@^2.0.1: is-plain-object "^2.0.3" split-string "^3.0.1" -setimmediate@^1.0.4: +setimmediate@^1.0.4, setimmediate@^1.0.5: version "1.0.5" resolved "https://registry.yarnpkg.com/setimmediate/-/setimmediate-1.0.5.tgz#290cbb232e306942d7d7ea9b83732ab7856f8285" integrity sha1-KQy7Iy4waULX1+qbg3Mqt4VvgoU= @@ -10976,6 +11093,11 @@ typedarray@^0.0.6: resolved "https://registry.yarnpkg.com/typedarray/-/typedarray-0.0.6.tgz#867ac74e3864187b1d3d47d996a78ec5c8830777" integrity sha1-hnrHTjhkGHsdPUfZlqeOxciDB3c= +ua-parser-js@^0.7.18: + version "0.7.22" + resolved "https://registry.yarnpkg.com/ua-parser-js/-/ua-parser-js-0.7.22.tgz#960df60a5f911ea8f1c818f3747b99c6e177eae3" + integrity sha512-YUxzMjJ5T71w6a8WWVcMGM6YWOTX27rCoIQgLXiWaxqXSx9D7DNjiGWn1aJIRSQ5qr0xuhra77bSIh6voR/46Q== + uncontrollable@^7.0.0: version "7.1.1" resolved "https://registry.yarnpkg.com/uncontrollable/-/uncontrollable-7.1.1.tgz#f67fed3ef93637126571809746323a9db815d556" @@ -11398,7 +11520,7 @@ whatwg-encoding@^1.0.1, whatwg-encoding@^1.0.3, whatwg-encoding@^1.0.5: dependencies: iconv-lite "0.4.24" -whatwg-fetch@^3.0.0: +whatwg-fetch@>=0.10.0, whatwg-fetch@^3.0.0: version "3.4.1" resolved "https://registry.yarnpkg.com/whatwg-fetch/-/whatwg-fetch-3.4.1.tgz#e5f871572d6879663fa5674c8f833f15a8425ab3" integrity sha512-sofZVzE1wKwO+EYPbWfiwzaKovWiZXf4coEzjGP9b2GBVgQRLQUZ2QcuPpQExGDAW5GItpEm6Tl4OU5mywnAoQ== -- cgit v1.2.3-70-g09d2 From f11e5d9e55c963109af8b8517c7790bf2eb7cac8 Mon Sep 17 00:00:00 2001 From: Emiliano Ciavatta Date: Sat, 3 Oct 2020 17:01:32 +0200 Subject: Add title page window titles --- frontend/public/index.html | 2 +- frontend/src/components/ConnectionContent.js | 2 ++ frontend/src/components/panels/PcapPane.js | 2 ++ frontend/src/components/panels/RulePane.js | 2 ++ frontend/src/components/panels/ServicePane.js | 2 ++ frontend/src/views/App.js | 8 ++++++++ 6 files changed, 17 insertions(+), 1 deletion(-) diff --git a/frontend/public/index.html b/frontend/public/index.html index 5cc974e..1c98f08 100644 --- a/frontend/public/index.html +++ b/frontend/public/index.html @@ -8,7 +8,7 @@ - Caronte + caronte:~/$ diff --git a/frontend/src/components/ConnectionContent.js b/frontend/src/components/ConnectionContent.js index 11b8617..2e4f2fd 100644 --- a/frontend/src/components/ConnectionContent.js +++ b/frontend/src/components/ConnectionContent.js @@ -30,6 +30,8 @@ class ConnectionContent extends Component { if (this.props.connection != null) { this.loadStream(); } + + document.title = "caronte:~/$"; } componentDidUpdate(prevProps, prevState, snapshot) { diff --git a/frontend/src/components/panels/PcapPane.js b/frontend/src/components/panels/PcapPane.js index 7b3fde6..31d8815 100644 --- a/frontend/src/components/panels/PcapPane.js +++ b/frontend/src/components/panels/PcapPane.js @@ -30,6 +30,8 @@ class PcapPane extends Component { componentDidMount() { this.loadSessions(); + + document.title = "caronte:~/pcaps$"; } loadSessions = () => { diff --git a/frontend/src/components/panels/RulePane.js b/frontend/src/components/panels/RulePane.js index 49364d2..4641378 100644 --- a/frontend/src/components/panels/RulePane.js +++ b/frontend/src/components/panels/RulePane.js @@ -39,6 +39,8 @@ class RulePane extends Component { componentDidMount() { this.reset(); this.loadRules(); + + document.title = "caronte:~/rules$"; } emptyRule = { diff --git a/frontend/src/components/panels/ServicePane.js b/frontend/src/components/panels/ServicePane.js index eaefa64..0e99652 100644 --- a/frontend/src/components/panels/ServicePane.js +++ b/frontend/src/components/panels/ServicePane.js @@ -25,6 +25,8 @@ class ServicePane extends Component { services: [], currentService: this.emptyService, }; + + document.title = "caronte:~/services$"; } componentDidMount() { diff --git a/frontend/src/views/App.js b/frontend/src/views/App.js index fb4454c..abd5209 100644 --- a/frontend/src/views/App.js +++ b/frontend/src/views/App.js @@ -18,6 +18,14 @@ class App extends Component { componentDidMount() { backend.get("/api/services").then(_ => this.setState({configured: true})); + + setInterval(() => { + if (document.title.endsWith("❚")) { + document.title = document.title.slice(0, -1); + } else { + document.title += "❚"; + } + }, 500); } render() { -- cgit v1.2.3-70-g09d2 From e905618113309eaba7227ff1328a20f6846e4afd Mon Sep 17 00:00:00 2001 From: Emiliano Ciavatta Date: Mon, 5 Oct 2020 23:46:18 +0200 Subject: Implement timeline --- application_context.go | 2 + application_router.go | 22 +- connection_handler.go | 26 ++ connections_controller.go | 52 ++-- frontend/package.json | 3 + .../src/components/filters/FiltersDefinitions.js | 86 +++--- frontend/src/components/panels/MainPane.js | 34 +-- frontend/src/globals.js | 5 + frontend/src/log.js | 7 + frontend/src/views/App.js | 18 +- frontend/src/views/Connections.js | 158 +++++++---- frontend/src/views/Connections.scss | 52 ++-- frontend/src/views/Footer.js | 173 +++++++++++- frontend/src/views/Footer.scss | 17 +- frontend/yarn.lock | 312 +++++++++++++++++++-- statistics_controller.go | 71 +++++ storage.go | 31 ++ 17 files changed, 850 insertions(+), 219 deletions(-) create mode 100644 frontend/src/globals.js create mode 100644 frontend/src/log.js create mode 100644 statistics_controller.go diff --git a/application_context.go b/application_context.go index e4be74d..6ea449d 100644 --- a/application_context.go +++ b/application_context.go @@ -20,6 +20,7 @@ type ApplicationContext struct { ConnectionsController ConnectionsController ServicesController *ServicesController ConnectionStreamsController ConnectionStreamsController + StatisticsController StatisticsController IsConfigured bool } @@ -93,5 +94,6 @@ func (sm *ApplicationContext) configure() { sm.ServicesController = NewServicesController(sm.Storage) sm.ConnectionsController = NewConnectionsController(sm.Storage, sm.ServicesController) sm.ConnectionStreamsController = NewConnectionStreamsController(sm.Storage) + sm.StatisticsController = NewStatisticsController(sm.Storage) sm.IsConfigured = true } diff --git a/application_router.go b/application_router.go index 501956b..8b5e32f 100644 --- a/application_router.go +++ b/application_router.go @@ -119,7 +119,7 @@ func CreateApplicationRouter(applicationContext *ApplicationContext) *gin.Engine flushAllValue, isPresent := c.GetPostForm("flush_all") flushAll := isPresent && strings.ToLower(flushAllValue) == "true" fileName := fmt.Sprintf("%v-%s", time.Now().UnixNano(), fileHeader.Filename) - if err := c.SaveUploadedFile(fileHeader, ProcessingPcapsBasePath + fileName); err != nil { + if err := c.SaveUploadedFile(fileHeader, ProcessingPcapsBasePath+fileName); err != nil { log.WithError(err).Panic("failed to save uploaded file") } @@ -147,7 +147,7 @@ func CreateApplicationRouter(applicationContext *ApplicationContext) *gin.Engine } fileName := fmt.Sprintf("%v-%s", time.Now().UnixNano(), filepath.Base(request.File)) - if err := CopyFile(ProcessingPcapsBasePath + fileName, request.File); err != nil { + if err := CopyFile(ProcessingPcapsBasePath+fileName, request.File); err != nil { log.WithError(err).Panic("failed to copy pcap file") } if sessionID, err := applicationContext.PcapImporter.ImportPcap(fileName, request.FlushAll); err != nil { @@ -179,9 +179,9 @@ func CreateApplicationRouter(applicationContext *ApplicationContext) *gin.Engine sessionID := c.Param("id") if _, isPresent := applicationContext.PcapImporter.GetSession(sessionID); isPresent { if FileExists(PcapsBasePath + sessionID + ".pcap") { - c.FileAttachment(PcapsBasePath + sessionID + ".pcap", sessionID[:16] + ".pcap") + c.FileAttachment(PcapsBasePath+sessionID+".pcap", sessionID[:16]+".pcap") } else if FileExists(PcapsBasePath + sessionID + ".pcapng") { - c.FileAttachment(PcapsBasePath + sessionID + ".pcapng", sessionID[:16] + ".pcapng") + c.FileAttachment(PcapsBasePath+sessionID+".pcapng", sessionID[:16]+".pcapng") } else { log.WithField("sessionID", sessionID).Panic("pcap file not exists") } @@ -289,9 +289,17 @@ func CreateApplicationRouter(applicationContext *ApplicationContext) *gin.Engine unprocessableEntity(c, err) } }) - } + api.GET("/statistics", func(c *gin.Context) { + var filter StatisticsFilter + if err := c.ShouldBindQuery(&filter); err != nil { + badRequest(c, err) + return + } + success(c, applicationContext.StatisticsController.GetStatistics(c, filter)) + }) + } return router } @@ -352,3 +360,7 @@ func unprocessableEntity(c *gin.Context, err error) { func notFound(c *gin.Context, obj interface{}) { c.JSON(http.StatusNotFound, obj) } + +func serverError(c *gin.Context, err error) { + c.JSON(http.StatusInternalServerError, UnorderedDocument{"result": "error", "error": err.Error()}) +} diff --git a/connection_handler.go b/connection_handler.go index ffe4fac..3d38531 100644 --- a/connection_handler.go +++ b/connection_handler.go @@ -2,6 +2,7 @@ package main import ( "encoding/binary" + "fmt" "github.com/flier/gohs/hyperscan" "github.com/google/gopacket" "github.com/google/gopacket/tcpassembly" @@ -225,6 +226,31 @@ func (ch *connectionHandlerImpl) Complete(handler *StreamHandler) { log.WithError(err).WithField("connection", connection).Error("failed to update all connections streams") } } + + ch.UpdateStatistics(connection) +} + +func (ch *connectionHandlerImpl) UpdateStatistics(connection Connection) { + rangeStart := connection.StartedAt.Unix() / 60 // group statistic records by minutes + duration := connection.ClosedAt.Sub(connection.StartedAt) + // if one of the two parts doesn't close connection, the duration is +infinity or -infinity + if duration.Hours() > 1 || duration.Hours() < -1 { + duration = 0 + } + servicePort := connection.DestinationPort + + var results interface{} + if _, err := ch.Storage().Update(Statistics).Upsert(&results). + Filter(OrderedDocument{{"_id", time.Unix(rangeStart*60, 0)}}).OneComplex(UnorderedDocument{ + "$inc": UnorderedDocument{ + fmt.Sprintf("connections_per_service.%d", servicePort): 1, + fmt.Sprintf("client_bytes_per_service.%d", servicePort): connection.ClientBytes, + fmt.Sprintf("server_bytes_per_service.%d", servicePort): connection.ServerBytes, + fmt.Sprintf("duration_per_service.%d", servicePort): duration.Milliseconds(), + }, + }); err != nil { + log.WithError(err).WithField("connection", connection).Error("failed to update connection statistics") + } } func (ch *connectionHandlerImpl) Storage() Storage { diff --git a/connections_controller.go b/connections_controller.go index 2773506..2894193 100644 --- a/connections_controller.go +++ b/connections_controller.go @@ -31,40 +31,40 @@ type Connection struct { } type ConnectionsFilter struct { - From string `form:"from" binding:"omitempty,hexadecimal,len=24"` - To string `form:"to" binding:"omitempty,hexadecimal,len=24"` - ServicePort uint16 `form:"service_port"` - ClientAddress string `form:"client_address" binding:"omitempty,ip"` - ClientPort uint16 `form:"client_port"` - MinDuration uint `form:"min_duration"` - MaxDuration uint `form:"max_duration" binding:"omitempty,gtefield=MinDuration"` - MinBytes uint `form:"min_bytes"` - MaxBytes uint `form:"max_bytes" binding:"omitempty,gtefield=MinBytes"` - StartedAfter int64 `form:"started_after" ` - StartedBefore int64 `form:"started_before" binding:"omitempty,gtefield=StartedAfter"` - ClosedAfter int64 `form:"closed_after" ` - ClosedBefore int64 `form:"closed_before" binding:"omitempty,gtefield=ClosedAfter"` - Hidden bool `form:"hidden"` - Marked bool `form:"marked"` + From string `form:"from" binding:"omitempty,hexadecimal,len=24"` + To string `form:"to" binding:"omitempty,hexadecimal,len=24"` + ServicePort uint16 `form:"service_port"` + ClientAddress string `form:"client_address" binding:"omitempty,ip"` + ClientPort uint16 `form:"client_port"` + MinDuration uint `form:"min_duration"` + MaxDuration uint `form:"max_duration" binding:"omitempty,gtefield=MinDuration"` + MinBytes uint `form:"min_bytes"` + MaxBytes uint `form:"max_bytes" binding:"omitempty,gtefield=MinBytes"` + StartedAfter int64 `form:"started_after" ` + StartedBefore int64 `form:"started_before" binding:"omitempty,gtefield=StartedAfter"` + ClosedAfter int64 `form:"closed_after" ` + ClosedBefore int64 `form:"closed_before" binding:"omitempty,gtefield=ClosedAfter"` + Hidden bool `form:"hidden"` + Marked bool `form:"marked"` MatchedRules []string `form:"matched_rules" binding:"dive,hexadecimal,len=24"` - Limit int64 `form:"limit"` + Limit int64 `form:"limit"` } type ConnectionsController struct { - storage Storage + storage Storage servicesController *ServicesController } func NewConnectionsController(storage Storage, servicesController *ServicesController) ConnectionsController { return ConnectionsController{ - storage: storage, + storage: storage, servicesController: servicesController, } } func (cc ConnectionsController) GetConnections(c context.Context, filter ConnectionsFilter) []Connection { var connections []Connection - query := cc.storage.Find(Connections).Context(c).Sort("_id", false) + query := cc.storage.Find(Connections).Context(c) from, _ := RowIDFromHex(filter.From) if !from.IsZero() { @@ -73,6 +73,8 @@ func (cc ConnectionsController) GetConnections(c context.Context, filter Connect to, _ := RowIDFromHex(filter.To) if !to.IsZero() { query = query.Filter(OrderedDocument{{"_id", UnorderedDocument{"$gt": to}}}) + } else { + query = query.Sort("_id", false) } if filter.ServicePort > 0 { query = query.Filter(OrderedDocument{{"port_dst", filter.ServicePort}}) @@ -146,6 +148,10 @@ func (cc ConnectionsController) GetConnections(c context.Context, filter Connect } } + if !to.IsZero() { + connections = reverseConnections(connections) + } + return connections } @@ -178,3 +184,11 @@ func (cc ConnectionsController) setProperty(c context.Context, id RowID, propert } return updated } + +func reverseConnections(connections []Connection) []Connection { + for i := 0; i < len(connections)/2; i++ { + j := len(connections) - i - 1 + connections[i], connections[j] = connections[j], connections[i] + } + return connections +} diff --git a/frontend/package.json b/frontend/package.json index f22fad5..5bc13f1 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -14,8 +14,10 @@ "classnames": "^2.2.6", "dompurify": "^2.1.1", "eslint-config-react-app": "^5.2.1", + "flux": "^3.1.3", "lodash": "^4.17.20", "node-sass": "^4.14.0", + "pondjs": "^0.9.0", "react": "^16.13.1", "react-bootstrap": "^1.0.1", "react-dom": "^16.13.1", @@ -25,6 +27,7 @@ "react-router-dom": "^5.1.2", "react-scripts": "3.4.1", "react-tag-autocomplete": "^6.0.0-beta.6", + "react-timeseries-charts": "^0.16.1", "typed.js": "^2.0.11" }, "scripts": { diff --git a/frontend/src/components/filters/FiltersDefinitions.js b/frontend/src/components/filters/FiltersDefinitions.js index 02ccb42..d4f2912 100644 --- a/frontend/src/components/filters/FiltersDefinitions.js +++ b/frontend/src/components/filters/FiltersDefinitions.js @@ -1,12 +1,4 @@ -import { - cleanNumber, - timestampToTime, - timeToTimestamp, - validate24HourTime, - validateIpAddress, - validateMin, - validatePort -} from "../../utils"; +import {cleanNumber, validateIpAddress, validateMin, validatePort} from "../../utils"; import StringConnectionsFilter from "./StringConnectionsFilter"; import React from "react"; import RulesConnectionsFilter from "./RulesConnectionsFilter"; @@ -22,69 +14,69 @@ export const filtersDefinitions = { replaceFunc={cleanNumber} validateFunc={validatePort} key="service_port_filter" - width={200} />, - matched_rules: , + width={200}/>, + matched_rules: , client_address: , + width={320}/>, client_port: , + width={200}/>, min_duration: , + width={200}/>, max_duration: , + width={200}/>, min_bytes: , + width={200}/>, max_bytes: , - started_after: , - started_before: , - closed_after: , - closed_before: , - marked: , - hidden: + width={200}/>, + // started_after: , + // started_before: , + // closed_after: , + // closed_before: , + marked: , + // hidden: }; diff --git a/frontend/src/components/panels/MainPane.js b/frontend/src/components/panels/MainPane.js index 3202d6d..bd25e0c 100644 --- a/frontend/src/components/panels/MainPane.js +++ b/frontend/src/components/panels/MainPane.js @@ -8,24 +8,23 @@ import PcapPane from "./PcapPane"; import backend from "../../backend"; import RulePane from "./RulePane"; import ServicePane from "./ServicePane"; +import log from "../../log"; class MainPane extends Component { - constructor(props) { - super(props); - this.state = { - selectedConnection: null, - loading: false - }; - } + state = {}; componentDidMount() { const match = this.props.location.pathname.match(/^\/connections\/([a-f0-9]{24})$/); if (match != null) { - this.setState({loading: true}); + this.loading = true; backend.get(`/api/connections/${match[1]}`) - .then(res => this.setState({selectedConnection: res.json, loading: false})) - .catch(error => console.log(error)); + .then(res => { + this.loading = false; + this.setState({selectedConnection: res.json}); + log.debug(`Initial connection ${match[1]} loaded`); + }) + .catch(error => log.error("Error loading initial connection", error)); } } @@ -34,18 +33,19 @@ class MainPane extends Component {
{ - !this.state.loading && + !this.loading && this.setState({selectedConnection: c})} - initialConnection={this.state.selectedConnection} /> + initialConnection={this.state.selectedConnection}/> }
- } /> - } /> - } /> - } /> - } /> + }/> + }/> + }/> + }/> + }/>
diff --git a/frontend/src/globals.js b/frontend/src/globals.js new file mode 100644 index 0000000..cd4dc64 --- /dev/null +++ b/frontend/src/globals.js @@ -0,0 +1,5 @@ +import {Dispatcher} from "flux"; + +const dispatcher = new Dispatcher(); + +export default dispatcher; diff --git a/frontend/src/log.js b/frontend/src/log.js new file mode 100644 index 0000000..0883962 --- /dev/null +++ b/frontend/src/log.js @@ -0,0 +1,7 @@ +const log = { + debug: (...obj) => console.info(...obj), + info: (...obj) => console.info(...obj), + error: (...obj) => console.error(obj) +}; + +export default log; diff --git a/frontend/src/views/App.js b/frontend/src/views/App.js index abd5209..00d9110 100644 --- a/frontend/src/views/App.js +++ b/frontend/src/views/App.js @@ -7,17 +7,17 @@ import {BrowserRouter as Router} from "react-router-dom"; import Filters from "./Filters"; import backend from "../backend"; import ConfigurationPane from "../components/panels/ConfigurationPane"; +import log from "../log"; class App extends Component { - constructor(props) { - super(props); - - this.state = {}; - } + state = {}; componentDidMount() { - backend.get("/api/services").then(_ => this.setState({configured: true})); + backend.get("/api/services").then(_ => { + log.debug("Caronte is already configured. Loading main.."); + this.setState({configured: true}); + }); setInterval(() => { if (document.title.endsWith("❚")) { @@ -38,11 +38,11 @@ class App extends Component {
-
this.setState({filterWindowOpen: true})} /> +
this.setState({filterWindowOpen: true})}/>
- {this.state.configured ? : - this.setState({configured: true})} />} + {this.state.configured ? : + this.setState({configured: true})}/>} {modal}
diff --git a/frontend/src/views/Connections.js b/frontend/src/views/Connections.js index 73979c4..fe655b3 100644 --- a/frontend/src/views/Connections.js +++ b/frontend/src/views/Connections.js @@ -6,26 +6,31 @@ import {Redirect} from 'react-router'; import {withRouter} from "react-router-dom"; import backend from "../backend"; import ConnectionMatchedRules from "../components/ConnectionMatchedRules"; +import dispatcher from "../globals"; +import log from "../log"; +import ButtonField from "../components/fields/ButtonField"; class Connections extends Component { + state = { + loading: false, + connections: [], + firstConnection: null, + lastConnection: null, + flagRule: null, + rules: null, + queryString: null + }; + constructor(props) { super(props); - this.state = { - loading: false, - connections: [], - firstConnection: null, - lastConnection: null, - prevParams: null, - flagRule: null, - rules: null, - queryString: null - }; this.scrollTopThreashold = 0.00001; this.scrollBottomThreashold = 0.99999; - this.maxConnections = 500; + this.maxConnections = 200; this.queryLimit = 50; + this.connectionsListRef = React.createRef(); + this.lastScrollPosition = 0; } componentDidMount() { @@ -35,6 +40,17 @@ class Connections extends Component { this.setState({selected: this.props.initialConnection.id}); // TODO: scroll to initial connection } + + dispatcher.register((payload) => { + if (payload.actionType === "timeline-update") { + 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}`)); + } + }); } connectionSelected = (c) => { @@ -46,7 +62,7 @@ class Connections extends Component { if (this.state.loaded && prevProps.location.search !== this.props.location.search) { this.setState({queryString: this.props.location.search}); this.loadConnections({limit: this.queryLimit}) - .then(() => console.log("Connections reloaded after query string update")); + .then(() => log.info("Connections reloaded after query string update")); } } @@ -54,12 +70,26 @@ class Connections extends Component { 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(() => console.log("Following connections loaded")); + .then(() => log.info("Following connections loaded")); } if (!this.state.loading && relativeScroll < this.scrollTopThreashold) { this.loadConnections({to: this.state.firstConnection.id, limit: this.queryLimit,}) - .then(() => console.log("Previous connections loaded")); + .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; }; addServicePortFilter = (port) => { @@ -85,14 +115,14 @@ class Connections extends Component { urlParams.set(name, value); } - this.setState({loading: true, prevParams: params}); + this.setState({loading: true}); let res = (await backend.get(`${url}?${urlParams}`)).json; let connections = this.state.connections; let firstConnection = this.state.firstConnection; let lastConnection = this.state.lastConnection; - if (params !== undefined && params.from !== undefined) { + if (params !== undefined && params.from !== undefined && params.to === undefined) { if (res.length > 0) { connections = this.state.connections.concat(res); lastConnection = connections[connections.length - 1]; @@ -102,7 +132,7 @@ class Connections extends Component { firstConnection = connections[0]; } } - } else if (params !== undefined && params.to !== undefined) { + } else if (params !== undefined && params.to !== undefined && params.from === undefined) { if (res.length > 0) { connections = res.concat(this.state.connections); firstConnection = connections[0]; @@ -112,6 +142,7 @@ class Connections extends Component { } } } else { + this.connectionsListRef.current.scrollTop = 0; if (res.length > 0) { connections = res; firstConnection = connections[0]; @@ -124,7 +155,7 @@ class Connections extends Component { } let rules = this.state.rules; - if (rules === null) { + if (rules == null) { rules = (await backend.get("/api/rules")).json; } @@ -135,15 +166,21 @@ class Connections extends Component { firstConnection: firstConnection, lastConnection: lastConnection }); + + if (firstConnection != null && lastConnection != null) { + dispatcher.dispatch({ + actionType: "connections-update", + from: new Date(lastConnection["started_at"]), + to: new Date(firstConnection["started_at"]) + }); + } } render() { let redirect; let queryString = this.state.queryString !== null ? this.state.queryString : ""; if (this.state.selected) { - let format = this.props.match.params.format; - format = format !== undefined ? "/" + format : ""; - redirect = ; + redirect = ; } let loading = null; @@ -154,48 +191,55 @@ class Connections extends Component { } return ( -
-
- - - - - - - - - - - - - - - - - { - this.state.connections.flatMap(c => { - return [ this.connectionSelected(c)} - selected={this.state.selected === c.id} - onMarked={marked => c.marked = marked} - onEnabled={enabled => c.hidden = !enabled} - addServicePortFilter={this.addServicePortFilter} />, - c.matched_rules.length > 0 && +
+ {this.state.showMoreRecentButton &&
+ + this.loadConnections({limit: this.queryLimit}) + .then(() => log.info("Most recent connections loaded")) + }/> +
} + +
+
servicesrcipsrcportdstipdstportstarted_atdurationupdownactions
+ + + + + + + + + + + + + + + + { + this.state.connections.flatMap(c => { + return [ this.connectionSelected(c)} + selected={this.state.selected === c.id} + onMarked={marked => c.marked = marked} + onEnabled={enabled => c.hidden = !enabled} + addServicePortFilter={this.addServicePortFilter}/>, + c.matched_rules.length > 0 && - ]; - }) - } - {loading} - -
servicesrcipsrcportdstipdstportstarted_atdurationupdownactions
- - {redirect} + addMatchedRulesFilter={this.addMatchedRulesFilter}/> + ]; + }) + } + {loading} + + + + {redirect} +
); } } - export default withRouter(Connections); diff --git a/frontend/src/views/Connections.scss b/frontend/src/views/Connections.scss index c7bd1df..9e2b6ba 100644 --- a/frontend/src/views/Connections.scss +++ b/frontend/src/views/Connections.scss @@ -1,37 +1,39 @@ @import "../colors.scss"; -.connections { +.connections-container { position: relative; - overflow: auto; height: 100%; - padding: 0 10px; + padding: 10px; background-color: $color-primary-3; - .table { - margin-top: 10px; - } + .connections { + position: relative; + overflow-y: scroll; + height: 100%; - .connections-header-padding { - position: sticky; - top: 0; - right: 0; - left: 0; - height: 10px; - margin-bottom: -10px; - background-color: $color-primary-3; - } + .table { + margin-bottom: 0; + } + + th { + font-size: 13.5px; + position: sticky; + top: 0; + padding: 5px; + border: none; + background-color: $color-primary-2; + } - th { - font-size: 13.5px; - position: sticky; - top: 10px; - padding: 5px; - border-top: 3px solid $color-primary-3; - border-bottom: 3px solid $color-primary-3; - background-color: $color-primary-2; + &:hover::-webkit-scrollbar-thumb { + background: $color-secondary-2; + } } - &:hover::-webkit-scrollbar-thumb { - background: $color-secondary-2; + .most-recent-button { + position: absolute; + z-index: 20; + top: 45px; + left: calc(50% - 50px); + background-color: red; } } diff --git a/frontend/src/views/Footer.js b/frontend/src/views/Footer.js index 0a3c5a3..ad1e4f7 100644 --- a/frontend/src/views/Footer.js +++ b/frontend/src/views/Footer.js @@ -1,19 +1,180 @@ import React, {Component} from 'react'; import './Footer.scss'; +import { + ChartContainer, + ChartRow, + Charts, + LineChart, + MultiBrush, + Resizable, + styler, + YAxis +} from "react-timeseries-charts"; +import {TimeRange, TimeSeries} from "pondjs"; +import backend from "../backend"; +import ChoiceField from "../components/fields/ChoiceField"; +import dispatcher from "../globals"; +import {withRouter} from "react-router-dom"; +import log from "../log"; + class Footer extends Component { + state = { + metric: "connections_per_service" + }; + + constructor() { + super(); + + this.disableTimeSeriesChanges = false; + this.selectionTimeout = null; + } + + filteredPort = () => { + const urlParams = new URLSearchParams(this.props.location.search); + return urlParams.get("service_port"); + }; + + componentDidMount() { + const filteredPort = this.filteredPort(); + this.setState({filteredPort}); + this.loadStatistics(this.state.metric, filteredPort).then(() => + log.debug("Statistics loaded after mount")); + + dispatcher.register((payload) => { + if (payload.actionType === "connections-update") { + this.setState({ + selection: new TimeRange(payload.from, payload.to), + }); + } + }); + } + + componentDidUpdate(prevProps, prevState, snapshot) { + const filteredPort = this.filteredPort(); + if (this.state.filteredPort !== filteredPort) { + this.setState({filteredPort}); + this.loadStatistics(this.state.metric, filteredPort).then(() => + log.debug("Statistics reloaded after filtered port changes")); + } + } + + loadStatistics = async (metric, filteredPort) => { + const urlParams = new URLSearchParams(); + urlParams.set("metric", metric); + + let services = (await backend.get("/api/services")).json; + if (filteredPort && services[filteredPort]) { + const service = services[filteredPort]; + services = {}; + services[filteredPort] = service; + } + + const ports = Object.keys(services); + ports.forEach(s => urlParams.append("ports", s)); + + const metrics = (await backend.get("/api/statistics?" + urlParams)).json; + const series = new TimeSeries({ + name: "statistics", + columns: ["time"].concat(ports), + points: metrics.map(m => [new Date(m["range_start"])].concat(ports.map(p => m[metric][p] || 0))) + }); + this.setState({ + metric, + series, + services, + timeRange: series.range(), + }); + log.debug(`Loaded statistics for metric "${metric}" for services [${ports}]`); + }; + + createStyler = () => { + 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({ + actionType: "timeline-update", + from: timeRange.begin(), + to: timeRange.end() + }); + this.selectionTimeout = null; + this.disableTimeSeriesChanges = false; + }, 1000); + }; + + aggregateSeries = (func) => { + const values = this.state.series.columns().map(c => this.state.series[func](c)); + return Math[func](...values); + }; + render() { return ( -