Inline frontend instead of submodule
This commit is contained in:
310
frontend/src/App.vue
Normal file
310
frontend/src/App.vue
Normal file
@@ -0,0 +1,310 @@
|
||||
<template>
|
||||
<div id="app">
|
||||
<div class="bg-lines"></div>
|
||||
<Navbar/>
|
||||
<div class="container-fluid app-frame">
|
||||
<div class="row">
|
||||
<transition name="fade" mode="out-in">
|
||||
<router-view name="sidebar" ref="sidebar"/>
|
||||
</transition>
|
||||
<main role="main" class="col-sm-9 ml-sm-auto px-4 app-main">
|
||||
<transition name="fade" mode="out-in">
|
||||
<router-view name="content"/>
|
||||
</transition>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
<Settings/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!--suppress JSUnresolvedVariable -->
|
||||
<script>
|
||||
export const DB_OBJSTORE_COUNTERS_HISTORY = 'countersHistory';
|
||||
|
||||
import Navbar from './components/Navbar';
|
||||
import Settings from './views/Settings';
|
||||
import SockJS from 'sockjs-client';
|
||||
import {openDB,} from 'idb';
|
||||
|
||||
export default {
|
||||
data() {
|
||||
return {
|
||||
websocket: null,
|
||||
db: null,
|
||||
};
|
||||
},
|
||||
mounted() {
|
||||
this.connectWs();
|
||||
openDB('packmate', 1, {
|
||||
upgrade(db, oldVersion, newVersion) {
|
||||
console.info('[IDB] Creating new database! Old rev %d, new rev %d',
|
||||
oldVersion, newVersion);
|
||||
db.createObjectStore(DB_OBJSTORE_COUNTERS_HISTORY, {
|
||||
autoIncrement: false,
|
||||
keyPath: null,
|
||||
});
|
||||
},
|
||||
})
|
||||
.then(db => {
|
||||
this.db = db;
|
||||
})
|
||||
.catch(e => {
|
||||
console.error('[IDB] Failed to open DB!', e);
|
||||
});
|
||||
},
|
||||
beforeDestroy() {
|
||||
this.websocket?.close();
|
||||
},
|
||||
methods: {
|
||||
connectWs() {
|
||||
if (this.websocket !== null) return;
|
||||
this.websocket = new SockJS(this.$http.defaults.baseURL + '/ws');
|
||||
this.websocket.onopen = () => {
|
||||
console.info('[WS] Connected');
|
||||
};
|
||||
this.websocket.onclose = (ev) => {
|
||||
console.info('[WS] Disconnected', ev.code, ev.reason);
|
||||
this.websocket = null;
|
||||
if (ev.code === 1008) {
|
||||
console.info('[WS] Security timeout, reconnecting...');
|
||||
this.connectWs();
|
||||
}
|
||||
if (ev.code !== 1000) { // Normal closure
|
||||
setTimeout(this.connectWs, 3000);
|
||||
console.info('[WS] Reconnecting...');
|
||||
}
|
||||
};
|
||||
this.websocket.onmessage = (ev) => {
|
||||
const parsed = JSON.parse(ev.data);
|
||||
|
||||
switch (parsed.type) {
|
||||
case 'NEW_STREAM': {
|
||||
this.$refs.sidebar.addStreamFromWs(parsed.value);
|
||||
break;
|
||||
}
|
||||
case 'SAVE_SERVICE': {
|
||||
this.addServiceFromWs(parsed.value);
|
||||
break;
|
||||
}
|
||||
case 'DELETE_SERVICE': {
|
||||
this.deleteServiceFromWs(parsed.value);
|
||||
break;
|
||||
}
|
||||
case 'SAVE_PATTERN': {
|
||||
this.addPatternFromWs(parsed.value);
|
||||
break;
|
||||
}
|
||||
case 'COUNTERS_UPDATE': {
|
||||
const data = parsed.value;
|
||||
this.$store.commit('setCurrentPacketsCount', data.totalPackets);
|
||||
this.$store.commit('setCurrentStreamsCount', data.totalStreams);
|
||||
this.$store.commit('setCurrentServicesPacketsCount', data.servicesPackets);
|
||||
this.$store.commit('setCurrentServicesStreamsCount', data.servicesStreams);
|
||||
|
||||
console.debug('Adding new counters to DB', parsed.value);
|
||||
const tx = this.db.transaction(DB_OBJSTORE_COUNTERS_HISTORY, 'readwrite');
|
||||
tx.store.add({
|
||||
newPacketsCount: data.totalPackets,
|
||||
newStreamsCount: data.totalStreams,
|
||||
servicesPackets: data.servicesPackets,
|
||||
servicesStreams: data.servicesStreams,
|
||||
}, Date.now()).then(() => {
|
||||
console.debug('[IDB] Added entry');
|
||||
}).catch(e => {
|
||||
console.error('[IDB] Failed to add entry!', e);
|
||||
});
|
||||
break;
|
||||
}
|
||||
case 'ENABLE_PATTERN': {
|
||||
this.togglePatternFromWs(parsed.value, true);
|
||||
break;
|
||||
}
|
||||
case 'DISABLE_PATTERN': {
|
||||
this.togglePatternFromWs(parsed.value, false);
|
||||
break;
|
||||
}
|
||||
case 'PCAP_STARTED': {
|
||||
this.$store.commit('startPcap');
|
||||
this.$bvToast.toast(`Pcap file processing started`, {
|
||||
title: 'Notification',
|
||||
variant: 'info',
|
||||
autoHideDelay: 5000,
|
||||
});
|
||||
break;
|
||||
}
|
||||
case 'PCAP_STOPPED': {
|
||||
this.$bvToast.toast(`All streams processed`, {
|
||||
title: 'Notification',
|
||||
variant: 'success',
|
||||
autoHideDelay: 5000,
|
||||
});
|
||||
break;
|
||||
}
|
||||
case 'FINISH_LOOKBACK': {
|
||||
console.debug('Lookback completed');
|
||||
this.$bvToast.toast(`Lookback completed`, {
|
||||
title: 'Notification',
|
||||
variant: 'success',
|
||||
autoHideDelay: 5000,
|
||||
});
|
||||
this.$refs.sidebar.streams = [];
|
||||
this.$refs.sidebar.$refs.infiniteLoader.stateChanger.reset();
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
console.error('[WS] Event is not implemented!', parsed);
|
||||
break;
|
||||
}
|
||||
}
|
||||
};
|
||||
this.websocket.onerror = (ev) => {
|
||||
console.warn('[WS] Error', ev);
|
||||
};
|
||||
},
|
||||
addPatternFromWs(pattern) {
|
||||
const foundIndex = this.$store.state.patterns.findIndex(el => el.id === pattern.id);
|
||||
if (foundIndex === -1) {
|
||||
this.$store.commit('addPattern', pattern);
|
||||
return;
|
||||
}
|
||||
|
||||
let newPatterns = this.$store.state.patterns.slice();
|
||||
newPatterns.splice(foundIndex, 1, pattern);
|
||||
this.$store.commit('setPatterns', newPatterns);
|
||||
},
|
||||
togglePatternFromWs(id, enabled) {
|
||||
this.$store.state.patterns.forEach(pattern => {
|
||||
if (pattern.id === id) {
|
||||
pattern.enabled = enabled;
|
||||
}
|
||||
});
|
||||
},
|
||||
addServiceFromWs(service) {
|
||||
const foundIndex = this.$store.state.services.findIndex(el => el.port === service.port);
|
||||
if (foundIndex === -1) {
|
||||
this.$store.commit('addService', service);
|
||||
return;
|
||||
}
|
||||
|
||||
let newServices = this.$store.state.services.slice();
|
||||
newServices.splice(foundIndex, 1, service);
|
||||
this.$store.commit('setServices', newServices);
|
||||
},
|
||||
deleteServiceFromWs(port) {
|
||||
this.$store.commit('setServices', this.$store.state.services.filter(o => o.port !== port));
|
||||
},
|
||||
},
|
||||
components: {
|
||||
Settings,
|
||||
Navbar,
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style>
|
||||
/* cyrillic-ext */
|
||||
@font-face {
|
||||
font-family: 'Open Sans';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: local('Open Sans Regular'), local('OpenSans-Regular'), url(https://fonts.gstatic.com/s/opensans/v16/mem8YaGs126MiZpBA-UFWJ0bbck.woff2) format('woff2');
|
||||
unicode-range: U+0460-052F, U+1C80-1C88, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
|
||||
}
|
||||
|
||||
/* cyrillic */
|
||||
@font-face {
|
||||
font-family: 'Open Sans';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: local('Open Sans Regular'), local('OpenSans-Regular'), url(https://fonts.gstatic.com/s/opensans/v16/mem8YaGs126MiZpBA-UFUZ0bbck.woff2) format('woff2');
|
||||
unicode-range: U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
|
||||
}
|
||||
|
||||
/* greek-ext */
|
||||
@font-face {
|
||||
font-family: 'Open Sans';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: local('Open Sans Regular'), local('OpenSans-Regular'), url(https://fonts.gstatic.com/s/opensans/v16/mem8YaGs126MiZpBA-UFWZ0bbck.woff2) format('woff2');
|
||||
unicode-range: U+1F00-1FFF;
|
||||
}
|
||||
|
||||
/* greek */
|
||||
@font-face {
|
||||
font-family: 'Open Sans';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: local('Open Sans Regular'), local('OpenSans-Regular'), url(https://fonts.gstatic.com/s/opensans/v16/mem8YaGs126MiZpBA-UFVp0bbck.woff2) format('woff2');
|
||||
unicode-range: U+0370-03FF;
|
||||
}
|
||||
|
||||
/* vietnamese */
|
||||
@font-face {
|
||||
font-family: 'Open Sans';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: local('Open Sans Regular'), local('OpenSans-Regular'), url(https://fonts.gstatic.com/s/opensans/v16/mem8YaGs126MiZpBA-UFWp0bbck.woff2) format('woff2');
|
||||
unicode-range: U+0102-0103, U+0110-0111, U+1EA0-1EF9, U+20AB;
|
||||
}
|
||||
|
||||
/* latin-ext */
|
||||
@font-face {
|
||||
font-family: 'Open Sans';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: local('Open Sans Regular'), local('OpenSans-Regular'), url(https://fonts.gstatic.com/s/opensans/v16/mem8YaGs126MiZpBA-UFW50bbck.woff2) format('woff2');
|
||||
unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF;
|
||||
}
|
||||
|
||||
/* latin */
|
||||
@font-face {
|
||||
font-family: 'Open Sans';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: local('Open Sans Regular'), local('OpenSans-Regular'), url(https://fonts.gstatic.com/s/opensans/v16/mem8YaGs126MiZpBA-UFVZ0b.woff2) format('woff2');
|
||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||
}
|
||||
|
||||
html * {
|
||||
font-family: 'Open Sans', Helvetica, Arial, sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
font-size: .875rem;
|
||||
}
|
||||
|
||||
body {
|
||||
overflow-y: scroll;
|
||||
}
|
||||
|
||||
/*noinspection CssUnusedSymbol*/
|
||||
.fade-enter-active, .fade-leave-active {
|
||||
transition: opacity .3s;
|
||||
}
|
||||
|
||||
/*noinspection CssUnusedSymbol*/
|
||||
.fade-enter, .fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
/* Patterns dropdown fix */
|
||||
/*noinspection CssUnusedSymbol*/
|
||||
.dropdown-menu.show {
|
||||
position: fixed !important;
|
||||
/*transform: translate3d(15px, 38px, 0) !important;*/
|
||||
transform: none !important;
|
||||
left: 15px !important;
|
||||
top: 38px !important;
|
||||
}
|
||||
|
||||
[role="main"] {
|
||||
padding-top: 55px; /* Space for fixed navbar */
|
||||
}
|
||||
</style>
|
||||
303
frontend/src/assets/neon.css
Normal file
303
frontend/src/assets/neon.css
Normal file
@@ -0,0 +1,303 @@
|
||||
@import url('https://fonts.googleapis.com/css2?family=Press+Start+2P&family=JetBrains+Mono:wght@400;600&display=swap');
|
||||
|
||||
:root {
|
||||
--bg: #040410;
|
||||
--bg-2: #0a0f25;
|
||||
--panel: rgba(10, 17, 40, 0.82);
|
||||
--panel-strong: rgba(12, 22, 52, 0.92);
|
||||
--accent: #5ef2ff;
|
||||
--accent-2: #ff6ad5;
|
||||
--text: #e7f5ff;
|
||||
--muted: #9bb3d6;
|
||||
--mono-font: 'JetBrains Mono', 'Ubuntu Mono', monospace;
|
||||
--pixel-font: 'Press Start 2P', 'JetBrains Mono', monospace;
|
||||
}
|
||||
|
||||
[data-theme="light"] {
|
||||
--bg: #eaf2ff;
|
||||
--bg-2: #f6f8ff;
|
||||
--panel: rgba(245, 248, 255, 0.9);
|
||||
--panel-strong: rgba(238, 244, 255, 0.95);
|
||||
--text: #0d1b2f;
|
||||
--muted: #4b5873;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
background:
|
||||
radial-gradient(circle at 18% 24%, rgba(255, 106, 213, 0.16), transparent 25%),
|
||||
radial-gradient(circle at 82% 12%, rgba(94, 242, 255, 0.2), transparent 25%),
|
||||
linear-gradient(135deg, #02030b 0%, #0b1535 50%, #050811 100%);
|
||||
color: var(--text);
|
||||
font-family: var(--pixel-font);
|
||||
letter-spacing: 0.6px;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
body[data-theme="light"] {
|
||||
background: linear-gradient(135deg, #f4f7ff 0%, #dfeaff 40%, #f8fbff 100%);
|
||||
}
|
||||
|
||||
#app {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.bg-lines {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background-image: linear-gradient(rgba(94, 242, 255, 0.06) 1px, transparent 1px), linear-gradient(90deg, rgba(255, 106, 213, 0.08) 1px, transparent 1px);
|
||||
background-size: 120px 120px;
|
||||
pointer-events: none;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.container-fluid.app-frame {
|
||||
padding-top: 84px;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.neon-navbar {
|
||||
background: var(--panel-strong) !important;
|
||||
border-bottom: 1px solid rgba(94, 242, 255, 0.35);
|
||||
box-shadow: 0 10px 35px rgba(0, 0, 0, 0.45), 0 0 25px rgba(255, 106, 213, 0.25);
|
||||
padding: 12px 18px;
|
||||
}
|
||||
|
||||
.navbar-brand {
|
||||
font-family: var(--pixel-font);
|
||||
letter-spacing: 1px;
|
||||
color: var(--accent) !important;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.navbar-brand .brand-dot {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(135deg, var(--accent), var(--accent-2));
|
||||
box-shadow: 0 0 10px rgba(94, 242, 255, 0.6);
|
||||
}
|
||||
|
||||
.navbar-sub {
|
||||
font-size: 10px;
|
||||
color: var(--muted);
|
||||
margin-left: 6px;
|
||||
}
|
||||
|
||||
.navbar-metrics {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
margin-left: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.metric-chip {
|
||||
background: rgba(94, 242, 255, 0.08);
|
||||
border: 1px solid rgba(94, 242, 255, 0.25);
|
||||
color: var(--text);
|
||||
padding: 8px 10px;
|
||||
border-radius: 10px;
|
||||
display: grid;
|
||||
grid-template-columns: auto auto;
|
||||
column-gap: 8px;
|
||||
align-items: center;
|
||||
font-size: 11px;
|
||||
box-shadow: 0 0 14px rgba(94, 242, 255, 0.18);
|
||||
}
|
||||
|
||||
.metric-chip .label {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.metric-chip .value {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.navbar-nav .nav-link,
|
||||
.neon-tab {
|
||||
color: var(--text) !important;
|
||||
border-radius: 12px;
|
||||
padding: 8px 10px;
|
||||
margin: 4px 4px;
|
||||
transition: all .2s ease;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.navbar-nav .nav-link:hover,
|
||||
.navbar-nav .nav-item.active > .nav-link,
|
||||
.neon-tab:hover {
|
||||
background: rgba(255, 106, 213, 0.18);
|
||||
box-shadow: 0 0 14px rgba(255, 106, 213, 0.2);
|
||||
color: #fff !important;
|
||||
}
|
||||
|
||||
.navbar-cogs > i {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.navbar-cogs > i:hover {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.app-main {
|
||||
background: var(--panel);
|
||||
border: 1px solid rgba(94, 242, 255, 0.28);
|
||||
box-shadow: inset 0 0 24px rgba(0, 0, 0, 0.35), 0 0 35px rgba(94, 242, 255, 0.16);
|
||||
border-radius: 18px;
|
||||
padding: 28px;
|
||||
margin-bottom: 24px;
|
||||
min-height: calc(100vh - 120px);
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
background: var(--panel);
|
||||
border-right: 1px solid rgba(94, 242, 255, 0.25);
|
||||
box-shadow: inset -10px 0 24px rgba(0, 0, 0, 0.25);
|
||||
min-height: calc(100vh - 84px);
|
||||
padding-top: 14px;
|
||||
}
|
||||
|
||||
.sidebar .btn {
|
||||
font-family: var(--pixel-font);
|
||||
letter-spacing: 0.6px;
|
||||
border-radius: 10px;
|
||||
border-color: rgba(94, 242, 255, 0.35);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.sidebar .btn-outline-primary,
|
||||
.sidebar .btn-outline-info,
|
||||
.sidebar .btn-outline-success,
|
||||
.sidebar .btn-outline-warning {
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
}
|
||||
|
||||
.sidebar .btn:hover {
|
||||
box-shadow: 0 0 12px rgba(255, 106, 213, 0.22);
|
||||
}
|
||||
|
||||
.sidebar-sticky {
|
||||
padding: 8px 6px 14px;
|
||||
max-height: calc(100vh - 180px);
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.stream-item {
|
||||
margin: 8px 0;
|
||||
border-radius: 14px;
|
||||
border: 1px solid rgba(94, 242, 255, 0.2);
|
||||
background: linear-gradient(125deg, rgba(94, 242, 255, 0.06), rgba(255, 106, 213, 0.08));
|
||||
box-shadow: 0 12px 24px rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
|
||||
.stream-item .nav-link {
|
||||
color: var(--text) !important;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.stream-item .nav-link:hover {
|
||||
background: rgba(94, 242, 255, 0.08);
|
||||
}
|
||||
|
||||
.highlight {
|
||||
border-color: rgba(255, 106, 213, 0.45);
|
||||
box-shadow: 0 0 12px rgba(255, 106, 213, 0.3);
|
||||
}
|
||||
|
||||
.legend-bar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
margin-bottom: 18px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.legend-pill {
|
||||
padding: 8px 10px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid rgba(94, 242, 255, 0.35);
|
||||
background: rgba(94, 242, 255, 0.08);
|
||||
box-shadow: 0 0 12px rgba(94, 242, 255, 0.15);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.packet-outgoing, .packet-incoming {
|
||||
border-radius: 16px;
|
||||
padding: 14px;
|
||||
margin-bottom: 14px;
|
||||
border: 1px solid rgba(94, 242, 255, 0.22);
|
||||
}
|
||||
|
||||
.packet-incoming {
|
||||
background: radial-gradient(circle at 18% 20%, rgba(94, 242, 255, 0.12), rgba(10, 17, 40, 0.9));
|
||||
}
|
||||
|
||||
.packet-outgoing {
|
||||
background: radial-gradient(circle at 18% 20%, rgba(255, 106, 213, 0.12), rgba(10, 17, 40, 0.9));
|
||||
}
|
||||
|
||||
.packet-incoming div,
|
||||
.packet-outgoing div {
|
||||
font-size: 11px;
|
||||
color: var(--muted);
|
||||
font-family: var(--pixel-font);
|
||||
}
|
||||
|
||||
.packet-incoming button,
|
||||
.packet-outgoing button {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.packet-incoming p,
|
||||
.packet-outgoing p {
|
||||
font-family: var(--mono-font);
|
||||
font-size: 13px;
|
||||
background: rgba(0, 0, 0, 0.22);
|
||||
padding: 12px;
|
||||
border-radius: 10px;
|
||||
color: var(--text);
|
||||
box-shadow: inset 0 0 12px rgba(0,0,0,0.35);
|
||||
}
|
||||
|
||||
.btn, .form-control, .custom-select, .page-link {
|
||||
font-family: var(--pixel-font);
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
background: var(--panel);
|
||||
color: var(--text);
|
||||
border: 1px solid rgba(94, 242, 255, 0.3);
|
||||
box-shadow: 0 0 24px rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
|
||||
.dropdown-menu {
|
||||
background: var(--panel);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.dropdown-item {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.dropdown-item:hover {
|
||||
background: rgba(94, 242, 255, 0.15);
|
||||
}
|
||||
|
||||
.toast {
|
||||
background: var(--panel-strong);
|
||||
color: var(--text);
|
||||
border: 1px solid rgba(255, 106, 213, 0.22);
|
||||
}
|
||||
|
||||
.theme-toggle {
|
||||
margin-left: auto;
|
||||
}
|
||||
194
frontend/src/components/Navbar.vue
Normal file
194
frontend/src/components/Navbar.vue
Normal file
@@ -0,0 +1,194 @@
|
||||
<template>
|
||||
<nav class="navbar navbar-dark navbar-expand fixed-top bg-dark flex-md-nowrap p-0 shadow neon-navbar">
|
||||
<div class="d-flex align-items-center pl-3">
|
||||
<span class="navbar-brand mb-0 ml-2">
|
||||
<span class="brand-dot"></span>
|
||||
0xb00b5 team Packmate
|
||||
</span>
|
||||
<span class="navbar-sub">@danosito</span>
|
||||
</div>
|
||||
|
||||
<div class="navbar-metrics">
|
||||
<span class="metric-chip">
|
||||
<span class="label">SPM</span>
|
||||
<span class="value">{{ this.$store.state.currentStreamsCount }}</span>
|
||||
</span>
|
||||
<span class="metric-chip">
|
||||
<span class="label">PPS</span>
|
||||
<span class="value">{{ packetsPerStream }}</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<PatternsDropdown ref="patternsDropdown"/>
|
||||
|
||||
<span v-if="this.$route.query.pattern" class="navbar-text navbar-nowrap">
|
||||
{{ selectedPatternText }}
|
||||
</span>
|
||||
|
||||
<div class="navbar-collapse collapse">
|
||||
<ul class="navbar-nav px-1 mr-auto">
|
||||
<li class="nav-item text-nowrap">
|
||||
<router-link class="nav-link" :to="{name:'stream', params: {}, query: $route.query}" exact>All</router-link>
|
||||
</li>
|
||||
<template v-for="service in this.$store.state.services">
|
||||
<router-link :key="service.port" tag="li" class="nav-item text-nowrap edit-button"
|
||||
:to="{name:'stream', params: {servicePort: service.port}, query: $route.query}">
|
||||
<a class="nav-link">
|
||||
{{service.name}} #{{service.port}}
|
||||
({{ getSpmForService(service.port) }}
|
||||
<u title="Streams per minute">SPM</u>)
|
||||
</a>
|
||||
|
||||
<a class="nav-link pl-0" style="cursor: pointer" @click.stop.prevent="editService(service)">
|
||||
<i class="fas fa-pencil-alt"/>
|
||||
</a>
|
||||
</router-link>
|
||||
</template>
|
||||
<li class="nav-item text-nowrap" style="padding-left: 1em;">
|
||||
<div class="my-2 mr-3 navbar-cogs" style="cursor: pointer;"
|
||||
@click.stop.prevent="showAddService">
|
||||
<i class="fas fa-plus-circle"/>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div class="my-2 my-lg-0 mr-3 navbar-cogs" style="cursor: pointer;"
|
||||
@click.stop.prevent="showSettings">
|
||||
<i class="fas fa-cogs"/>
|
||||
</div>
|
||||
</div>
|
||||
<ServiceModal :creating="serviceModalIsCreating" :initial-service="serviceModalEditingService"
|
||||
@service-update-needed="updateServices"/>
|
||||
</nav>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import PatternsDropdown from './PatternsDropdown';
|
||||
import ServiceModal from '../views/ServiceModal';
|
||||
|
||||
export default {
|
||||
name: 'Navbar',
|
||||
computed: {
|
||||
packetsPerStream: function() {
|
||||
let streams = this.$store.state.currentStreamsCount;
|
||||
let packets = this.$store.state.currentPacketsCount;
|
||||
|
||||
if (streams === 0) {
|
||||
return 0;
|
||||
} else {
|
||||
let pps = packets / streams;
|
||||
return Math.round((pps + Number.EPSILON) * 10) / 10;
|
||||
}
|
||||
},
|
||||
selectedPatternText: function () {
|
||||
let selected = this.$route.query.pattern;
|
||||
if (typeof selected === 'string') {
|
||||
selected = parseInt(selected);
|
||||
}
|
||||
|
||||
let pattern = this.$store.state.patterns.find(o => o.id === selected);
|
||||
if (pattern) {
|
||||
return `[Selected: ${pattern.name}]`;
|
||||
} else {
|
||||
return '[Invalid pattern]';
|
||||
}
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
serviceModalIsCreating: true,
|
||||
serviceModalEditingService: {},
|
||||
};
|
||||
},
|
||||
mounted() {
|
||||
this.updateServices();
|
||||
},
|
||||
methods: {
|
||||
getSpmForService(port) {
|
||||
return this.$store.state.currentServicesStreamsCount[port] ?? 0;
|
||||
},
|
||||
editService(service) {
|
||||
this.serviceModalIsCreating = false;
|
||||
this.serviceModalEditingService = {};
|
||||
this.$nextTick(() => {
|
||||
this.serviceModalEditingService = service;
|
||||
this.$bvModal.show('serviceModal');
|
||||
});
|
||||
},
|
||||
showSettings() {
|
||||
this.$bvModal.show('settingsModal');
|
||||
console.debug('Showing settings...');
|
||||
},
|
||||
showAddService() {
|
||||
this.serviceModalIsCreating = true;
|
||||
this.serviceModalEditingService = {};
|
||||
this.$bvModal.show('serviceModal');
|
||||
console.debug('Showing addService...');
|
||||
},
|
||||
updateServices() {
|
||||
this.$http.get('service/')
|
||||
.then(r => this.$store.commit('setServices', r.data))
|
||||
.catch(e => {
|
||||
this.$bvToast.toast(`Failed to load services: ${e}`, {
|
||||
title: 'Error',
|
||||
variant: 'danger',
|
||||
});
|
||||
console.error('Failed to load services:', e);
|
||||
});
|
||||
},
|
||||
},
|
||||
components: {
|
||||
ServiceModal,
|
||||
PatternsDropdown,
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.nav-link {
|
||||
transition: all .3s;
|
||||
color: var(--text) !important;
|
||||
}
|
||||
|
||||
.edit-button {
|
||||
display: inherit;
|
||||
}
|
||||
|
||||
.navbar-cogs > i {
|
||||
transition: all .3s;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.navbar-cogs > i:hover {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.navbar-nowrap {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
nav {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
u {
|
||||
text-underline-position: under;
|
||||
text-decoration-style: dotted;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar {
|
||||
height: 0.2em
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-button {
|
||||
width: 0;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track-piece {
|
||||
background: #F1F1F1
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: #C1C1C1
|
||||
}
|
||||
</style>
|
||||
218
frontend/src/components/Packet.vue
Normal file
218
frontend/src/components/Packet.vue
Normal file
@@ -0,0 +1,218 @@
|
||||
<template>
|
||||
<div :class="{'packet-incoming': packet.incoming, 'packet-outgoing': !packet.incoming}">
|
||||
<div>#{{ packet.id }} at {{ dateToText(packet.timestamp) }}
|
||||
<template v-if="offset !== null"> (+{{ offset }} ms)</template>
|
||||
{{ printPacketFlags(packet) }}
|
||||
<button @click.prevent="copyRaw" class="btn btn-link">Copy HEX</button>
|
||||
<button @click.prevent="copyText" class="btn btn-link">Copy text</button>
|
||||
<button @click.prevent="copyPythonBytes" class="btn btn-link">Copy as Python bytes</button>
|
||||
</div>
|
||||
<p v-if="!this.$store.state.hexdumpMode"
|
||||
class="pt-2 pb-2 mb-3"
|
||||
v-html="stringdata"/>
|
||||
<p v-else
|
||||
class="pt-2 pb-2 mb-3"
|
||||
v-html="hexdata"/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!--suppress JSUnresolvedVariable, JSDeprecatedSymbols -->
|
||||
<script>
|
||||
export default {
|
||||
name: 'Packet',
|
||||
props: {
|
||||
packet: {
|
||||
id: Number(),
|
||||
matches: Array(),
|
||||
timestamp: Number(),
|
||||
incoming: Boolean(),
|
||||
ungzipped: Boolean(),
|
||||
webSocketParsed: Boolean(),
|
||||
tlsDecrypted: Boolean(),
|
||||
content: String(),
|
||||
},
|
||||
offset: Number(),
|
||||
},
|
||||
computed: {
|
||||
hexdata() {
|
||||
const dataString = this.atou(this.packet.content);
|
||||
const dump = this.hexdump(dataString, this.$store.state.hexdumpBlockSize, this.$store.state.hexdumpLineNumberBase);
|
||||
return this.escapeHtml(dump)
|
||||
.split('\n')
|
||||
.join('<br>'); // Replace all \n to <br>
|
||||
},
|
||||
stringdata() {
|
||||
const dataString = this.atou(this.packet.content);
|
||||
const dump = this.highlightPatterns(dataString);
|
||||
return this.escapeHtml(dump)
|
||||
.split('\n')
|
||||
.join('<br>');
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
atou(b64) {
|
||||
const text = atob(b64);
|
||||
const length = text.length;
|
||||
const bytes = new Uint8Array(length);
|
||||
for (let i = 0; i < length; i++) {
|
||||
bytes[i] = text.charCodeAt(i);
|
||||
}
|
||||
const decoder = new TextDecoder();
|
||||
return decoder.decode(bytes);
|
||||
},
|
||||
printPacketFlags(packet) {
|
||||
let flags = [];
|
||||
|
||||
if (packet.ungzipped) {
|
||||
flags.push('GZIP');
|
||||
}
|
||||
|
||||
if (packet.webSocketParsed) {
|
||||
flags.push('WS');
|
||||
}
|
||||
|
||||
if (packet.tlsDecrypted) {
|
||||
flags.push('TLS');
|
||||
}
|
||||
|
||||
return flags.join(' ');
|
||||
},
|
||||
hexdump(buffer, blockSize, lineNumberBase) {
|
||||
blockSize = parseInt(blockSize, 10) || 16;
|
||||
lineNumberBase = parseInt(lineNumberBase, 10) || 10;
|
||||
let lines = [];
|
||||
const hex = '0123456789ABCDEF';
|
||||
for (let b = 0; b < buffer.length; b += blockSize) {
|
||||
const block = buffer.slice(b, Math.min(b + blockSize, buffer.length));
|
||||
const addr = ('0000000000' + b.toString(lineNumberBase)).slice(-10);
|
||||
let codes = block.split('').map(ch => {
|
||||
const code = ch.charCodeAt(0);
|
||||
return ' ' + hex[(0xF0 & code) >> 4] + hex[0x0F & code];
|
||||
}).join('');
|
||||
codes += ' ..'.repeat(blockSize - block.length);
|
||||
// eslint-disable-next-line no-control-regex
|
||||
let chars = block.replace(/[\x00-\x1F]/g, '.');
|
||||
chars += ' '.repeat(blockSize - block.length);
|
||||
lines.push(addr + ':' + codes + ' |' + chars + '|');
|
||||
}
|
||||
return lines.join('\n');
|
||||
},
|
||||
dateToText(unixTimestamp) {
|
||||
return new Date(unixTimestamp).toLocaleDateString('ru-RU', {
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
});
|
||||
},
|
||||
escapeHtml(in_) {
|
||||
return in_.replace(/(<span style="background-color: #(?:[A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})">|<\/span>)|[&<>"'/]/g, ($0, $1) => {
|
||||
const entityMap = {
|
||||
'&': '&',
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
'"': '"',
|
||||
'\'': ''',
|
||||
'/': '/',
|
||||
};
|
||||
|
||||
return $1 ? $1 : entityMap[$0];
|
||||
});
|
||||
},
|
||||
highlightPatterns(raw) {
|
||||
const patterns = this.$store.state.patterns.reduce((obj, item) => {
|
||||
obj[item.id] = item;
|
||||
return obj;
|
||||
}, {}); // Array to object
|
||||
let offset = 0;
|
||||
this.packet.matches
|
||||
.filter(match => !patterns[match.patternId].deleted)
|
||||
.sort((a, b) => a.startPosition - b.startPosition)
|
||||
.forEach(match => {
|
||||
const pattern = patterns[match.patternId];
|
||||
if (!pattern) {
|
||||
console.info(`Pattern #${match.patternId} does not exist`);
|
||||
return;
|
||||
}
|
||||
const firstTag = `<span style="background-color: ${pattern.color}">`;
|
||||
const secondTag = '</span>';
|
||||
|
||||
const positionStart = match.startPosition + offset;
|
||||
raw = raw.substring(0, positionStart) + firstTag + raw.substring(positionStart);
|
||||
offset += firstTag.length;
|
||||
|
||||
const positionEnd = match.endPosition + offset + 1;
|
||||
raw = raw.substring(0, positionEnd) + secondTag + raw.substring(positionEnd);
|
||||
offset += secondTag.length;
|
||||
});
|
||||
return raw;
|
||||
},
|
||||
|
||||
copyPythonBytes() {
|
||||
const data = 'b\'' + atob(this.packet.content)
|
||||
.split('')
|
||||
.map((aChar) => {
|
||||
return '\\x' + ('0' + aChar.charCodeAt(0).toString(16)).slice(-2);
|
||||
})
|
||||
.join('') + '\'';
|
||||
this.copyContent(data);
|
||||
},
|
||||
copyRaw() {
|
||||
this.copyContent(Buffer.from(this.packet.content, 'base64').toString('hex'));
|
||||
},
|
||||
copyText() {
|
||||
this.copyContent(atob(this.packet.content));
|
||||
},
|
||||
copyContent(data) {
|
||||
const tempEl = document.createElement('textarea');
|
||||
tempEl.value = data; // Chrome
|
||||
tempEl.textContent = data; // Firefox
|
||||
document.body.appendChild(tempEl);
|
||||
tempEl.select();
|
||||
const result = document.execCommand('copy');
|
||||
document.body.removeChild(tempEl);
|
||||
console.debug('Copy result is', result);
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.packet-outgoing {
|
||||
background: linear-gradient(135deg, rgba(255, 106, 213, 0.12), rgba(14, 18, 40, 0.85));
|
||||
border: 1px solid rgba(255, 106, 213, 0.35);
|
||||
box-shadow: 0 0 14px rgba(255, 106, 213, 0.2);
|
||||
}
|
||||
|
||||
.packet-incoming {
|
||||
background: linear-gradient(135deg, rgba(94, 242, 255, 0.16), rgba(14, 18, 40, 0.85));
|
||||
border: 1px solid rgba(94, 242, 255, 0.35);
|
||||
box-shadow: 0 0 14px rgba(94, 242, 255, 0.18);
|
||||
}
|
||||
|
||||
div {
|
||||
font-size: 11px;
|
||||
color: var(--muted);
|
||||
font-family: var(--pixel-font);
|
||||
}
|
||||
|
||||
p {
|
||||
font-family: var(--mono-font);
|
||||
font-size: 13px;
|
||||
margin-bottom: 10px;
|
||||
padding: 10px 12px;
|
||||
word-break: break-word;
|
||||
background: rgba(0, 0, 0, 0.25);
|
||||
border-radius: 10px;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
button {
|
||||
padding: 0;
|
||||
top: -0.1em;
|
||||
position: relative;
|
||||
margin-left: 5px;
|
||||
color: var(--accent);
|
||||
}
|
||||
</style>
|
||||
204
frontend/src/components/PatternsDropdown.vue
Normal file
204
frontend/src/components/PatternsDropdown.vue
Normal file
@@ -0,0 +1,204 @@
|
||||
<template>
|
||||
<b-dropdown no-flip text="Patterns" block variant="dark" class="col-sm-1 mr-0 p-0">
|
||||
<li role="presentation" style="padding-left: 0.5em; padding-right: 0.5em;">
|
||||
<button role="menuitem" type="button" class="btn btn-sm btn-primary btn-block"
|
||||
@click.stop.prevent="showAddPattern">
|
||||
<i class="fas fa-plus"/>
|
||||
</button>
|
||||
</li>
|
||||
<b-dropdown-divider/>
|
||||
<b-dropdown-item-button @click.stop.prevent="resetPatternSelection">
|
||||
<strong>All streams</strong>
|
||||
</b-dropdown-item-button>
|
||||
<b-dropdown-item-button v-for="pattern in existingPatterns"
|
||||
:key="pattern.id" @click.stop.prevent="openPattern(pattern)"
|
||||
:class="{ 'ignore-pattern' : pattern.actionType === 'IGNORE' }">
|
||||
|
||||
<strong v-if="pattern.enabled" :style="getPatternColor">{{ pattern.name }}</strong>
|
||||
<s v-else :style="`color: ${pattern.color};`">{{ pattern.name }}</s>:
|
||||
|
||||
<code>{{ getSearchTypeValue(pattern.searchType, pattern.value) }}</code>;
|
||||
|
||||
<template v-if="pattern.actionType === 'FIND'">search </template>
|
||||
<template v-else>ignore </template>
|
||||
|
||||
<template v-if="pattern.directionType === 'BOTH'">anywhere </template>
|
||||
<template v-else-if="pattern.directionType === 'INPUT'">in request </template>
|
||||
<template v-else>in response </template>
|
||||
|
||||
<template v-if="pattern.serviceId === null">of any service</template>
|
||||
<template v-else>of service {{ getServiceName(pattern.serviceId) }} #{{ pattern.serviceId }}</template>
|
||||
|
||||
<div class="float-right" style="margin-left: 2em;">
|
||||
<button type="button" class="btn btn-outline-info btn-sm mr-1"
|
||||
@click.stop.prevent="showEditPattern(pattern)"
|
||||
title="Edit pattern">
|
||||
<i class="fas fa-edit"></i>
|
||||
</button>
|
||||
|
||||
<button v-if="pattern.actionType === 'FIND'" type="button" class="btn btn-outline-warning btn-sm mr-1"
|
||||
@click.stop.prevent="showLookBack(pattern)"
|
||||
title="Apply pattern to older streams">
|
||||
<i class="fas fa-backward"></i>
|
||||
</button>
|
||||
|
||||
<button v-if="pattern.enabled" type="button" class="btn btn-outline-danger btn-sm mr-1"
|
||||
@click.stop.prevent="togglePattern(pattern)"
|
||||
title="Stop matching streams with this pattern">
|
||||
<i class="fas fa-pause"></i>
|
||||
</button>
|
||||
<button v-else type="button" class="btn btn-outline-success btn-sm mr-1"
|
||||
@click.stop.prevent="togglePattern(pattern)"
|
||||
title="Start matching streams with this pattern again">
|
||||
<i class="fas fa-play"></i>
|
||||
</button>
|
||||
|
||||
<button type="button" class="btn btn-outline-danger btn-sm"
|
||||
@click.stop.prevent="deletePattern(pattern)"
|
||||
title="Permanently delete this pattern">
|
||||
<i class="fas fa-trash"></i>
|
||||
</button>
|
||||
</div>
|
||||
</b-dropdown-item-button>
|
||||
|
||||
<PatternModal :creating="patternModalIsCreating" :initial-pattern="patternModalEditingPattern" />
|
||||
<LookBack :pattern-id="patternIdForLookback" />
|
||||
</b-dropdown>
|
||||
</template>
|
||||
<!--suppress JSUnresolvedReference -->
|
||||
<script>
|
||||
import PatternModal from '@/views/PatternModal.vue';
|
||||
import LookBack from '@/views/LookBack.vue';
|
||||
|
||||
export default {
|
||||
name: 'PatternsDropdown',
|
||||
components: {LookBack, PatternModal, },
|
||||
data() {
|
||||
return {
|
||||
patternModalIsCreating: true,
|
||||
patternModalEditingPattern: {},
|
||||
patternIdForLookback: 0,
|
||||
};
|
||||
},
|
||||
mounted() {
|
||||
this.updatePatterns();
|
||||
},
|
||||
computed: {
|
||||
existingPatterns: function () {
|
||||
return this.$store.state.patterns.filter(p => !p.deleted)
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
getSearchTypeValue(searchType, value) {
|
||||
if (searchType === 'REGEX') return `/${value}/`;
|
||||
else if (searchType === 'SUBSTRING') return `'${value}'`;
|
||||
else return `0x${value}`;
|
||||
},
|
||||
getPatternColor(pattern) {
|
||||
if (pattern.actionType === 'FIND') {
|
||||
return `color: ${pattern.color};`;
|
||||
} else {
|
||||
return `color: inherit;`;
|
||||
}
|
||||
},
|
||||
getServiceName(port) {
|
||||
return this.$store.state.services.find(o => o.port === port)?.name ?? '<Deleted service>'
|
||||
},
|
||||
showAddPattern() {
|
||||
this.patternModalIsCreating = true;
|
||||
this.patternModalEditingPattern = {};
|
||||
this.$bvModal.show('patternModal');
|
||||
console.debug('Showing patternModal (create)');
|
||||
},
|
||||
showEditPattern(pattern) {
|
||||
this.patternModalIsCreating = false;
|
||||
this.patternModalEditingPattern = {};
|
||||
this.$nextTick(() => {
|
||||
this.patternModalEditingPattern = pattern;
|
||||
this.$bvModal.show('patternModal');
|
||||
console.debug('Showing patternModal (edit)');
|
||||
});
|
||||
},
|
||||
showLookBack(pattern) {
|
||||
this.patternIdForLookback = pattern.id;
|
||||
this.$bvModal.show('lookBackModal');
|
||||
},
|
||||
updatePatterns() {
|
||||
this.$http.get('pattern/')
|
||||
.then(r => this.$store.commit('setPatterns', r.data))
|
||||
.catch(e => {
|
||||
this.$bvToast.toast(`Failed to load patterns: ${e}`, {
|
||||
title: 'Error',
|
||||
variant: 'danger',
|
||||
});
|
||||
console.error('Failed to load patterns:', e);
|
||||
});
|
||||
},
|
||||
openPattern(pattern) {
|
||||
if (pattern.actionType === 'IGNORE') {
|
||||
return;
|
||||
}
|
||||
|
||||
console.debug('Opening pattern w/ id', pattern.id);
|
||||
this.$router.push({
|
||||
name: 'stream',
|
||||
params: this.$route.params,
|
||||
query: {pattern: pattern.id,},
|
||||
}, () => {});
|
||||
},
|
||||
resetPatternSelection() {
|
||||
console.debug('Resetting pattern selection');
|
||||
this.$router.push({
|
||||
name: 'stream',
|
||||
params: this.$route.params,
|
||||
}, () => {});
|
||||
},
|
||||
togglePattern(pattern) {
|
||||
const enabled = !pattern.enabled;
|
||||
console.debug('Toggling pattern', pattern);
|
||||
this.$http.post(`pattern/${pattern.id}/enable`, null, {
|
||||
params: {
|
||||
enabled,
|
||||
},
|
||||
})
|
||||
.then(response => {
|
||||
const data = response.data;
|
||||
console.debug('Done toggling pattern', data);
|
||||
this.$emit('patternAddComplete');
|
||||
}).catch(e => {
|
||||
this.$bvToast.toast(`Failed to toggle pattern: ${e}`, {
|
||||
title: 'Error',
|
||||
variant: 'danger',
|
||||
});
|
||||
console.error('Failed to toggle pattern', e);
|
||||
});
|
||||
},
|
||||
deletePattern(pattern) {
|
||||
console.debug('Deleting pattern', pattern);
|
||||
|
||||
this.$http.delete(`pattern/${pattern.id}`, null)
|
||||
.then(() => {
|
||||
console.debug('Done deleting pattern');
|
||||
this.$emit('patternAddComplete');
|
||||
}).catch(e => {
|
||||
this.$bvToast.toast(`Failed to delete pattern: ${e}`, {
|
||||
title: 'Error',
|
||||
variant: 'danger',
|
||||
});
|
||||
console.error('Failed to delete pattern', e);
|
||||
});
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
<style scoped>
|
||||
code {
|
||||
font-family: "Ubuntu Mono", "Lucida Console", monospace;
|
||||
font-size: 100%;
|
||||
}
|
||||
</style>
|
||||
<style>
|
||||
.ignore-pattern button {
|
||||
cursor: default !important;
|
||||
}
|
||||
</style>
|
||||
272
frontend/src/components/Sidebar.vue
Normal file
272
frontend/src/components/Sidebar.vue
Normal file
@@ -0,0 +1,272 @@
|
||||
<template>
|
||||
<nav class="col-sm-3 d-none d-sm-block sidebar">
|
||||
<div class="m-2 d-flex flex-wrap align-items-center" style="gap: 6px;">
|
||||
<button type="button" class="btn btn-sm"
|
||||
:title="this.$store.state.pause ? 'Continue' : 'Pause new streams'"
|
||||
:class="this.$store.state.pause ? 'btn-danger' : 'btn-outline-success'"
|
||||
@click.stop.prevent="togglePause">
|
||||
<i :class="this.$store.state.pause ? 'fas fa-play' : 'fas fa-pause'"/>
|
||||
</button>
|
||||
<button type="button" class="btn btn-sm ml-1"
|
||||
:title="this.$store.state.displayFavoritesOnly ? 'Show all streams' : 'Show only favorite streams'"
|
||||
:class="this.$store.state.displayFavoritesOnly ? 'btn-danger' : 'btn-outline-danger'"
|
||||
@click.stop.prevent="toggleFavorites">
|
||||
<i class="fas fa-star"/>
|
||||
</button>
|
||||
<button type="button" class="btn btn-outline-primary btn-sm ml-1"
|
||||
:title="this.$store.state.hexdumpMode ? 'Switch to text view' : 'Switch to hexdump view'"
|
||||
@click.stop.prevent="toggleHexdump">
|
||||
<i :class="this.$store.state.hexdumpMode ? 'far fa-file-code' : 'fas fa-align-left'"/>
|
||||
</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-warning ml-1" v-if="!this.$store.state.pcapStarted"
|
||||
title="Start pcap file processing"
|
||||
@click.stop.prevent="startPcap">
|
||||
<i class="fas fa-arrow-circle-down"/>
|
||||
</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-info" style="float: right;"
|
||||
title="Scroll to top"
|
||||
@click.stop.prevent="scrollUp">
|
||||
<i class="fas fa-angle-double-up"/>
|
||||
</button>
|
||||
</div>
|
||||
<div class="sidebar-sticky">
|
||||
<ul class="nav flex-column">
|
||||
<SidebarStream v-for="stream in streams"
|
||||
:key="stream.id"
|
||||
:stream="stream"/>
|
||||
<infinite-loading @infinite="infiniteLoadingHandler" ref="infiniteLoader"/>
|
||||
</ul>
|
||||
</div>
|
||||
</nav>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import SidebarStream from './SidebarStream';
|
||||
|
||||
export default {
|
||||
name: 'Sidebar',
|
||||
props: ['servicePort', 'streamId',],
|
||||
data() {
|
||||
return {
|
||||
streams: [],
|
||||
navigationKeysCallback: (e) => {
|
||||
if (!e.ctrlKey) return;
|
||||
if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
const index = this.streams.findIndex(e => e.id === this.$route.params.streamId);
|
||||
const newStream = this.streams[index - 1];
|
||||
if (!newStream) return;
|
||||
const newId = newStream.id;
|
||||
this.$router.push({
|
||||
name: 'stream',
|
||||
params: {servicePort: this.$route.params.servicePort, streamId: newId,},
|
||||
query: this.$route.query,
|
||||
});
|
||||
document.getElementById(`stream-${newId}`).scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'center',
|
||||
inline: 'center',
|
||||
});
|
||||
} else if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
const index = this.streams.findIndex(e => e.id === this.$route.params.streamId);
|
||||
const newStream = this.streams[index + 1];
|
||||
if (!newStream) return;
|
||||
const newId = newStream.id;
|
||||
this.$router.push({
|
||||
name: 'stream',
|
||||
params: {servicePort: this.$route.params.servicePort, streamId: newId,},
|
||||
query: this.$route.query,
|
||||
});
|
||||
document.getElementById(`stream-${newId}`).scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'center',
|
||||
inline: 'center',
|
||||
});
|
||||
} else if (e.key === 'Home') {
|
||||
const newStream = this.streams[0];
|
||||
if (!newStream) return;
|
||||
const newId = newStream.id;
|
||||
this.$router.push({
|
||||
name: 'stream',
|
||||
params: {servicePort: this.$route.params.servicePort, streamId: newId,},
|
||||
query: this.$route.query,
|
||||
});
|
||||
document.getElementById(`stream-${newId}`).scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'center',
|
||||
inline: 'center',
|
||||
});
|
||||
} else if (e.key === 'End') {
|
||||
const newStream = this.streams[this.streams.length - 1];
|
||||
if (!newStream) return;
|
||||
const newId = newStream.id;
|
||||
this.$router.push({
|
||||
name: 'stream',
|
||||
params: {servicePort: this.$route.params.servicePort, streamId: newId,},
|
||||
query: this.$route.query,
|
||||
});
|
||||
document.getElementById(`stream-${newId}`).scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'center',
|
||||
inline: 'center',
|
||||
});
|
||||
}
|
||||
},
|
||||
};
|
||||
},
|
||||
watch: {
|
||||
'$route.params.servicePort': function () {
|
||||
console.debug('Port reselected');
|
||||
this.streams = [];
|
||||
this.$refs.infiniteLoader.stateChanger.reset();
|
||||
},
|
||||
'$route.query.pattern': function () {
|
||||
console.debug('Pattern selected');
|
||||
this.streams = [];
|
||||
this.$refs.infiniteLoader.stateChanger.reset();
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
infiniteLoadingHandler($state) {
|
||||
const ourStreams = this.streams;
|
||||
const pageSize = this.$store.state.pageSize;
|
||||
|
||||
let startsFrom;
|
||||
if (ourStreams?.length && ourStreams[ourStreams.length - 1]) {
|
||||
startsFrom = ourStreams[ourStreams.length - 1].id;
|
||||
} else {
|
||||
startsFrom = null;
|
||||
}
|
||||
|
||||
this.$http.post(`/stream/${this.$route?.params?.servicePort || 'all'}`, {
|
||||
startingFrom: startsFrom,
|
||||
pageSize: pageSize,
|
||||
pattern: this.$route.query.pattern ? {id: this.$route.query.pattern,} : null,
|
||||
favorites: this.$store.state.displayFavoritesOnly,
|
||||
}).then(r => {
|
||||
const data = r.data;
|
||||
if (data?.length === 0) {
|
||||
console.log('Finished loading streams (empty page)');
|
||||
return $state.complete();
|
||||
}
|
||||
|
||||
if (data[0]?.id === this.streams[0]?.id) {
|
||||
console.log('Finished loading streams (overlap detected)');
|
||||
return $state.complete();
|
||||
}
|
||||
|
||||
this.streams.push(...data);
|
||||
|
||||
if (data.length < pageSize) {
|
||||
// this was the last page
|
||||
console.log('Finished loading streams (last page was not full)');
|
||||
$state.complete();
|
||||
} else {
|
||||
console.log('Loaded another page of streams');
|
||||
$state.loaded()
|
||||
}
|
||||
}).catch(e => {
|
||||
this.$bvToast.toast(`Failed to load portion of streams: ${e}`, {
|
||||
title: 'Error',
|
||||
variant: 'danger',
|
||||
});
|
||||
console.error('Failed to load portion of streams:', e);
|
||||
return $state.error();
|
||||
});
|
||||
},
|
||||
toggleFavorites() {
|
||||
this.$store.commit('toggleDisplayFavoritesOnly');
|
||||
this.streams = [];
|
||||
this.$refs.infiniteLoader.stateChanger.reset();
|
||||
},
|
||||
togglePause() {
|
||||
this.$store.commit('togglePause');
|
||||
},
|
||||
toggleHexdump() {
|
||||
this.$store.commit('toggleHexdumpMode');
|
||||
},
|
||||
startPcap() {
|
||||
this.$http.post('pcap/start')
|
||||
.then(() => {
|
||||
this.$store.commit('startPcap');
|
||||
}).catch(e => {
|
||||
this.$bvToast.toast(`Failed to start pcap: ${e}`, {
|
||||
title: 'Error',
|
||||
variant: 'danger',
|
||||
});
|
||||
console.error('Failed to start pcap', e);
|
||||
});
|
||||
},
|
||||
addStreamFromWs(stream) {
|
||||
const currentPort = parseInt(this.$route?.params?.servicePort, 10);
|
||||
if (currentPort && currentPort !== stream.service) return;
|
||||
if (this.$store.state.displayFavoritesOnly || this.$store.state.pause) return;
|
||||
if (this.$route.query.pattern && !stream.foundPatterns.some(e => e.id === this.$route.query.pattern)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.streams.unshift(stream);
|
||||
},
|
||||
scrollUp() {
|
||||
const newStream = this.streams[0];
|
||||
if (!newStream) return;
|
||||
const newId = newStream.id;
|
||||
this.$router.push({
|
||||
name: 'stream',
|
||||
params: {servicePort: this.$route.params.servicePort, streamId: newId,},
|
||||
query: this.$route.query,
|
||||
});
|
||||
document.getElementById(`stream-${newId}`).scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'center',
|
||||
inline: 'center',
|
||||
});
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
document.addEventListener('keydown', this.navigationKeysCallback);
|
||||
|
||||
this.$http.get('pcap/started')
|
||||
.then(r => this.$store.state.pcapStarted = r.data)
|
||||
.catch(e => {
|
||||
console.error('Failed to get pcap status, defaulting to true:', e);
|
||||
this.$store.state.pcapStarted = true;
|
||||
});
|
||||
},
|
||||
beforeDestroy() {
|
||||
document.removeEventListener('keydown', this.navigationKeysCallback);
|
||||
},
|
||||
components: {
|
||||
SidebarStream,
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.sidebar {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
z-index: 100; /* Behind the navbar */
|
||||
padding: 40px 0 0; /* Height of navbar */
|
||||
box-shadow: inset -1px 0 0 rgba(0, 0, 0, .1);
|
||||
}
|
||||
|
||||
.sidebar-sticky {
|
||||
position: relative;
|
||||
top: 0;
|
||||
height: calc(100vh - 85px);
|
||||
/*padding-top: .5rem;*/
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto; /* Scrollable contents if viewport is shorter than content. */
|
||||
}
|
||||
|
||||
@supports (position: sticky) {
|
||||
.sidebar-sticky {
|
||||
position: sticky;
|
||||
}
|
||||
}
|
||||
|
||||
</style>
|
||||
145
frontend/src/components/SidebarStream.vue
Normal file
145
frontend/src/components/SidebarStream.vue
Normal file
@@ -0,0 +1,145 @@
|
||||
<template>
|
||||
<li :id="`stream-${stream.id}`" class="nav-item stream-item" :class="{highlight: favorite0}">
|
||||
<router-link class="nav-link"
|
||||
:to="{name: 'stream', params: {servicePort: this.stream.service, streamId: this.stream.id}, query: this.$route.query}">
|
||||
<a @click.stop.prevent="toggleFavorite">
|
||||
<i class="fa-star" style="color: #DC3545" :class="favorite0 ? 'fas' : 'far'"/>
|
||||
</a>
|
||||
{{ stream.id }} {{ stream.protocol }}
|
||||
<template v-if="stream.ttl">TTL {{ stream.ttl }}</template>
|
||||
<template v-if="shouldShowServiceName"><br/>{{ getServiceName(stream.service) }} #{{stream.service}}</template>
|
||||
<br/>
|
||||
{{ dateToText(stream.startTimestamp) }}
|
||||
<template v-if="!dateMatches(stream.startTimestamp, stream.endTimestamp)">
|
||||
- {{ dateToText(stream.endTimestamp, dayMatches(stream.startTimestamp, stream.endTimestamp)) }}
|
||||
</template>
|
||||
<template v-if="stream.userAgentHash"><br/>UA: {{ stream.userAgentHash }}</template>
|
||||
<br/>
|
||||
{{ stream.sizeBytes }} bytes in {{ stream.packetsCount }} packets
|
||||
<br/>
|
||||
<span v-for="pattern in notDeletedFoundPatterns"
|
||||
:key="pattern.id"
|
||||
:style="`color: ${pattern.color};`">
|
||||
{{ pattern.name }}
|
||||
</span>
|
||||
</router-link>
|
||||
</li>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'SidebarStream',
|
||||
props: {
|
||||
stream: {
|
||||
id: Number(),
|
||||
protocol: String(),
|
||||
service: Number(),
|
||||
startTimestamp: Number(),
|
||||
endTimestamp: Number(),
|
||||
foundPatternsIds: Array(),
|
||||
favorite: Boolean(),
|
||||
ttl: Number(),
|
||||
userAgentHash: String(),
|
||||
sizeBytes: Number(),
|
||||
packetsCount: Number(),
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
shouldShowServiceName: function () {
|
||||
return this.$route?.params?.servicePort === undefined;
|
||||
},
|
||||
foundPatterns: function () {
|
||||
return this.$store.state.patterns.filter(p => this.stream.foundPatternsIds.includes(p.id));
|
||||
},
|
||||
notDeletedFoundPatterns: function () {
|
||||
return this.foundPatterns.filter(p => !p.deleted);
|
||||
},
|
||||
},
|
||||
data: function () {
|
||||
return {
|
||||
favorite0: this.stream.favorite,
|
||||
getServiceName: function (port) {
|
||||
return this.$store.state.services.find(o => o.port === port)?.name ?? '<Deleted service>'
|
||||
},
|
||||
};
|
||||
},
|
||||
methods: {
|
||||
dateToText(unixTimestamp, short = false) {
|
||||
const date = new Date(unixTimestamp);
|
||||
const options = {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
};
|
||||
if (!short && !this.isToday(date)) {
|
||||
options.month = '2-digit';
|
||||
options.day = '2-digit';
|
||||
return date.toLocaleDateString('ru-RU', options);
|
||||
}
|
||||
return date.toLocaleTimeString('ru-RU', options);
|
||||
},
|
||||
|
||||
isToday(someDate) {
|
||||
const today = new Date();
|
||||
return someDate.getDate() === today.getDate() &&
|
||||
someDate.getMonth() === today.getMonth() &&
|
||||
someDate.getFullYear() === today.getFullYear();
|
||||
},
|
||||
|
||||
dayMatches(rFirst, rSecond) {
|
||||
const first = new Date(rFirst);
|
||||
const second = new Date(rSecond);
|
||||
|
||||
return first.getDate() === second.getDate() &&
|
||||
first.getMonth() === second.getMonth() &&
|
||||
first.getFullYear() === second.getFullYear();
|
||||
},
|
||||
|
||||
dateMatches(rFirst, rSecond) {
|
||||
const first = new Date(rFirst);
|
||||
const second = new Date(rSecond);
|
||||
return first.getDate() === second.getDate() &&
|
||||
first.getMonth() === second.getMonth() &&
|
||||
first.getFullYear() === second.getFullYear() &&
|
||||
first.getHours() === second.getHours() &&
|
||||
first.getMinutes() === second.getMinutes() &&
|
||||
first.getSeconds() === second.getSeconds();
|
||||
},
|
||||
|
||||
toggleFavorite() {
|
||||
this.$http.post(`stream/${this.stream.id}/${this.favorite0 ? 'unfavorite' : 'favorite'}`)
|
||||
.then(() => this.favorite0 = !this.favorite0)
|
||||
.catch(e => {
|
||||
this.$bvToast.toast(`Failed to fav service: ${e}`, {
|
||||
title: 'Error',
|
||||
variant: 'danger',
|
||||
});
|
||||
console.error('Failed to fav service', e);
|
||||
});
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.nav-link {
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
font-family: var(--pixel-font);
|
||||
}
|
||||
|
||||
/*noinspection CssUnusedSymbol*/
|
||||
.nav-link.active {
|
||||
color: var(--accent);
|
||||
text-shadow: 0 0 10px rgba(94, 242, 255, 0.35);
|
||||
}
|
||||
|
||||
.highlight {
|
||||
background: rgba(255, 106, 213, 0.18);
|
||||
box-shadow: 0 0 12px rgba(255, 106, 213, 0.28);
|
||||
}
|
||||
|
||||
.stream-item {
|
||||
transition: all .3s;
|
||||
}
|
||||
</style>
|
||||
127
frontend/src/components/ThemeButton.vue
Normal file
127
frontend/src/components/ThemeButton.vue
Normal file
@@ -0,0 +1,127 @@
|
||||
<template>
|
||||
<div>
|
||||
<input
|
||||
@change="toggleTheme"
|
||||
id="checkbox"
|
||||
type="checkbox"
|
||||
class="switch-checkbox"
|
||||
/>
|
||||
<label for="checkbox" class="switch-label">
|
||||
<span class="checkbox-emoji">🌙</span>
|
||||
<span class="checkbox-emoji">☀️</span>
|
||||
<div
|
||||
class="switch-toggle"
|
||||
:class="{ 'switch-toggle-checked': chosenTheme === 'dark' }"
|
||||
></div>
|
||||
</label>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
|
||||
export default {
|
||||
mounted() {
|
||||
this.initTheme();
|
||||
},
|
||||
|
||||
computed: {
|
||||
chosenTheme: {
|
||||
get() {
|
||||
return this.$store.state.theme || this.detectTheme();
|
||||
},
|
||||
|
||||
set(theme) {
|
||||
this.$store.commit('setTheme', theme);
|
||||
this.displayTheme(theme);
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
methods: {
|
||||
toggleTheme() {
|
||||
const activeTheme = this.chosenTheme;
|
||||
|
||||
if (activeTheme === "light") {
|
||||
this.chosenTheme = 'dark';
|
||||
} else {
|
||||
this.chosenTheme = 'light';
|
||||
}
|
||||
|
||||
console.debug('Toggling theme from ', activeTheme, ' to ', this.chosenTheme);
|
||||
},
|
||||
|
||||
initTheme() {
|
||||
this.displayTheme(this.chosenTheme);
|
||||
},
|
||||
|
||||
detectTheme() {
|
||||
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
||||
},
|
||||
|
||||
displayTheme(theme) {
|
||||
document.body.setAttribute('data-theme', theme);
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.switch-checkbox {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.switch-label {
|
||||
align-items: center;
|
||||
background: var(--button-text-primary-color);
|
||||
border: calc(var(--button-element-size) * 0.025) solid var(--button-accent-color);
|
||||
border-radius: var(--button-element-size);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
font-size: calc(var(--button-element-size) * 0.3);
|
||||
height: calc(var(--button-element-size) * 0.35);
|
||||
position: relative;
|
||||
padding: calc(var(--button-element-size) * 0.1);
|
||||
transition: background 0.5s ease;
|
||||
justify-content: space-between;
|
||||
width: var(--button-element-size);
|
||||
z-index: 1;
|
||||
box-sizing: content-box;
|
||||
}
|
||||
|
||||
.switch-toggle {
|
||||
position: absolute;
|
||||
background-color: var(--button-background-color-primary);
|
||||
border-radius: 50%;
|
||||
top: calc(var(--button-element-size) * 0.07);
|
||||
left: calc(var(--button-element-size) * 0.07);
|
||||
height: calc(var(--button-element-size) * 0.4);
|
||||
width: calc(var(--button-element-size) * 0.4);
|
||||
transform: translateX(0);
|
||||
transition: transform 0.3s ease, background-color 0.5s ease;
|
||||
}
|
||||
|
||||
.switch-toggle-checked {
|
||||
transform: translateX(calc(var(--button-element-size) * 0.6)) !important;
|
||||
}
|
||||
|
||||
.checkbox-emoji {
|
||||
color: transparent;
|
||||
text-shadow: 0 0 0 var(--button-background-color-primary);
|
||||
}
|
||||
|
||||
>>> {
|
||||
--button-element-size: 3rem;
|
||||
|
||||
--button-background-color-primary: var(--panel);
|
||||
--button-background-color-secondary: var(--bg-2);
|
||||
--button-accent-color: var(--accent-2);
|
||||
--button-text-primary-color: var(--text);
|
||||
}
|
||||
|
||||
[data-theme=light] * {
|
||||
--button-background-color-primary: #f0f4ff;
|
||||
--button-background-color-secondary: #e4ebff;
|
||||
--button-accent-color: var(--accent);
|
||||
--button-text-primary-color: #0d1b2f;
|
||||
}
|
||||
</style>
|
||||
48
frontend/src/main.js
Normal file
48
frontend/src/main.js
Normal file
@@ -0,0 +1,48 @@
|
||||
import Vue from 'vue';
|
||||
import BootstrapVue from 'bootstrap-vue';
|
||||
import Axios from 'axios';
|
||||
import InfiniteLoading from 'vue-infinite-loading';
|
||||
import App from './App.vue';
|
||||
import router from './router';
|
||||
|
||||
import store from './store';
|
||||
import 'bootstrap/dist/css/bootstrap.css';
|
||||
import 'bootstrap-vue/dist/bootstrap-vue.css';
|
||||
import '@fortawesome/fontawesome-free/css/all.css';
|
||||
import "bootstrap-darkmode/scss/darktheme.scss";
|
||||
import './assets/neon.css';
|
||||
|
||||
Vue.config.productionTip = false;
|
||||
Vue.use(BootstrapVue);
|
||||
Vue.use(InfiniteLoading, {
|
||||
slots: {
|
||||
noResults: 'No results...',
|
||||
noMore: 'No more data',
|
||||
error: 'An error has occurred!',
|
||||
errorBtnText: 'Retry',
|
||||
},
|
||||
props: {
|
||||
spinner: 'waveDots',
|
||||
},
|
||||
});
|
||||
|
||||
const axiosInstance = Axios.create({
|
||||
baseURL: '/api', // TO!DO: edit for release!
|
||||
// baseURL: 'http://192.168.79.131:65000/api',
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
timeout: 5000,
|
||||
withCredentials: true,
|
||||
maxContentLength: 20000,
|
||||
});
|
||||
// noinspection JSUnusedGlobalSymbols
|
||||
Vue.prototype.$http = axiosInstance;
|
||||
|
||||
// noinspection JSUnusedGlobalSymbols
|
||||
new Vue({
|
||||
router,
|
||||
store,
|
||||
render: h => h(App),
|
||||
}).$mount('#app');
|
||||
118
frontend/src/objectAssignDeep.js
Normal file
118
frontend/src/objectAssignDeep.js
Normal file
@@ -0,0 +1,118 @@
|
||||
// https://github.com/saikojosh/Object-Assign-Deep/blob/master/objectAssignDeep.js
|
||||
|
||||
/*
|
||||
* A unified way of returning a string that describes the type of the given variable.
|
||||
*/
|
||||
function getTypeOf(input) {
|
||||
|
||||
if (input === null) {
|
||||
return 'null';
|
||||
} else if (typeof input === 'undefined') {
|
||||
return 'undefined';
|
||||
} else if (typeof input === 'object') {
|
||||
return (Array.isArray(input) ? 'array' : 'object');
|
||||
}
|
||||
|
||||
return typeof input;
|
||||
|
||||
}
|
||||
|
||||
/*
|
||||
* Branching logic which calls the correct function to clone the given value base on its type.
|
||||
*/
|
||||
function cloneValue(value) {
|
||||
|
||||
// The value is an object so let's clone it.
|
||||
if (getTypeOf(value) === 'object') {
|
||||
return quickCloneObject(value);
|
||||
}
|
||||
|
||||
// The value is an array so let's clone it.
|
||||
else if (getTypeOf(value) === 'array') {
|
||||
return quickCloneArray(value);
|
||||
}
|
||||
|
||||
// Any other value can just be copied.
|
||||
return value;
|
||||
|
||||
}
|
||||
|
||||
/*
|
||||
* Enumerates the given array and returns a new array, with each of its values cloned (i.e. references broken).
|
||||
*/
|
||||
function quickCloneArray(input) {
|
||||
return input.map(cloneValue);
|
||||
}
|
||||
|
||||
/*
|
||||
* Enumerates the properties of the given object (ignoring the prototype chain) and returns a new object, with each of
|
||||
* its values cloned (i.e. references broken).
|
||||
*/
|
||||
function quickCloneObject(input) {
|
||||
|
||||
const output = {};
|
||||
|
||||
for (const key in input) {
|
||||
if (!Object.prototype.hasOwnProperty.call(input, key)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
output[key] = cloneValue(input[key]);
|
||||
}
|
||||
|
||||
return output;
|
||||
|
||||
}
|
||||
|
||||
/*
|
||||
* Does the actual deep merging.
|
||||
*/
|
||||
function executeDeepMerge(target, _objects = [], _options = {}) {
|
||||
|
||||
const options = {
|
||||
arrayBehaviour: _options.arrayBehaviour || 'replace', // Can be "merge" or "replace".
|
||||
};
|
||||
|
||||
// Ensure we have actual objects for each.
|
||||
const objects = _objects.map(object => object || {});
|
||||
const output = target || {};
|
||||
|
||||
// Enumerate the objects and their keys.
|
||||
for (let oindex = 0; oindex < objects.length; oindex++) {
|
||||
const object = objects[oindex];
|
||||
const keys = Object.keys(object);
|
||||
|
||||
for (let kindex = 0; kindex < keys.length; kindex++) {
|
||||
const key = keys[kindex];
|
||||
const value = object[key];
|
||||
const type = getTypeOf(value);
|
||||
const existingValueType = getTypeOf(output[key]);
|
||||
|
||||
if (type === 'object') {
|
||||
if (existingValueType !== 'undefined') {
|
||||
const existingValue = (existingValueType === 'object' ? output[key] : {});
|
||||
output[key] = executeDeepMerge({}, [existingValue, quickCloneObject(value),], options);
|
||||
} else {
|
||||
output[key] = quickCloneObject(value);
|
||||
}
|
||||
} else if (type === 'array') {
|
||||
if (existingValueType === 'array') {
|
||||
const newValue = quickCloneArray(value);
|
||||
output[key] = (options.arrayBehaviour === 'merge' ? output[key].concat(newValue) : newValue);
|
||||
} else {
|
||||
output[key] = quickCloneArray(value);
|
||||
}
|
||||
} else {
|
||||
output[key] = value;
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
return output;
|
||||
|
||||
}
|
||||
|
||||
export function objectAssignDeep(target, ...objects) {
|
||||
return executeDeepMerge(target, objects);
|
||||
}
|
||||
21
frontend/src/router.js
Normal file
21
frontend/src/router.js
Normal file
@@ -0,0 +1,21 @@
|
||||
import Vue from 'vue';
|
||||
import Router from 'vue-router';
|
||||
import ContentStream from './views/ContentStream';
|
||||
import Sidebar from './components/Sidebar';
|
||||
|
||||
Vue.use(Router);
|
||||
|
||||
export default new Router({
|
||||
routes: [
|
||||
{
|
||||
path: '/:servicePort?/:streamId?',
|
||||
name: 'stream',
|
||||
props: true,
|
||||
components: {
|
||||
sidebar: Sidebar,
|
||||
content: ContentStream,
|
||||
},
|
||||
},
|
||||
],
|
||||
linkActiveClass: 'active',
|
||||
});
|
||||
89
frontend/src/store.js
Normal file
89
frontend/src/store.js
Normal file
@@ -0,0 +1,89 @@
|
||||
import Vue from 'vue';
|
||||
import Vuex from 'vuex';
|
||||
import createMutationsSharer from 'vuex-shared-mutations';
|
||||
import createPersistedState from 'vuex-persistedstate';
|
||||
|
||||
Vue.use(Vuex);
|
||||
|
||||
// noinspection JSUnusedLocalSymbols,JSUnusedGlobalSymbols
|
||||
export default new Vuex.Store({
|
||||
state: {
|
||||
theme: null,
|
||||
|
||||
hexdumpBlockSize: 16,
|
||||
hexdumpLineNumberBase: 10,
|
||||
pageSize: 25,
|
||||
displayFavoritesOnly: false,
|
||||
pause: false,
|
||||
pcapStarted: true,
|
||||
|
||||
hexdumpMode: false,
|
||||
|
||||
serviceModalName: '',
|
||||
serviceModalId: 0,
|
||||
|
||||
patterns: [],
|
||||
services: [],
|
||||
|
||||
currentPacketsCount: 0,
|
||||
currentStreamsCount: 0,
|
||||
currentServicesPacketsCount: {},
|
||||
currentServicesStreamsCount: {},
|
||||
},
|
||||
mutations: {
|
||||
setTheme: (s, p) => s.theme = p,
|
||||
setHexdumpBlockSize: (s, p) => s.hexdumpBlockSize = p,
|
||||
setHexdumpLineNumberBase: (s, p) => s.hexdumpLineNumberBase = p,
|
||||
setPageSize: (s, p) => s.pageSize = p,
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
toggleDisplayFavoritesOnly: (s, p) => s.displayFavoritesOnly = !s.displayFavoritesOnly,
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
togglePause: (s, p) => s.pause = !s.pause,
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
startPcap: (s, p) => s.pcapStarted = true,
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
toggleHexdumpMode: (s, p) => s.hexdumpMode = !s.hexdumpMode,
|
||||
|
||||
setServiceModalName: (s, p) => s.serviceModalName = p,
|
||||
setServiceModalId: (s, p) => s.serviceModalId = p,
|
||||
|
||||
setPatterns: (s, p) => s.patterns = p,
|
||||
addPattern: (s, p) => s.patterns.push(p),
|
||||
|
||||
setServices: (s, p) => s.services = p,
|
||||
addService: (s, p) => s.services.push(p),
|
||||
|
||||
setCurrentPacketsCount: (s, p) => s.currentPacketsCount = p,
|
||||
setCurrentStreamsCount: (s, p) => s.currentStreamsCount = p,
|
||||
setCurrentServicesPacketsCount: (s, p) => s.currentServicesPacketsCount = p,
|
||||
setCurrentServicesStreamsCount: (s, p) => s.currentServicesStreamsCount = p,
|
||||
},
|
||||
actions: {},
|
||||
plugins: [
|
||||
createPersistedState(),
|
||||
createMutationsSharer({
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
predicate: (mutation, state) => {
|
||||
console.debug('Got mutation:', mutation);
|
||||
const mName = mutation?.type;
|
||||
const process = mName !== 'toggleDisplayFavoritesOnly'
|
||||
&& mName !== 'setTheme'
|
||||
&& mName !== 'setServiceModalName'
|
||||
&& mName !== 'setServiceModalId'
|
||||
&& mName !== 'togglePause'
|
||||
&& mName !== 'startPcap'
|
||||
&& mName !== 'toggleHexdumpMode'
|
||||
&& mName !== 'setPatterns'
|
||||
&& mName !== 'addPattern'
|
||||
&& mName !== 'setServices'
|
||||
&& mName !== 'addService'
|
||||
&& mName !== 'setCurrentPacketsCount'
|
||||
&& mName !== 'setCurrentStreamsCount'
|
||||
&& mName !== 'setCurrentServicesPacketsCount'
|
||||
&& mName !== 'setCurrentServicesStreamsCount';
|
||||
console.debug('Processing?', process);
|
||||
return process;
|
||||
},
|
||||
}),
|
||||
],
|
||||
});
|
||||
129
frontend/src/views/ContentStream.vue
Normal file
129
frontend/src/views/ContentStream.vue
Normal file
@@ -0,0 +1,129 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="legend-bar">
|
||||
<div class="d-flex align-items-center" style="gap: 10px;">
|
||||
<span class="legend-pill request-pill">Request</span>
|
||||
<span class="legend-pill response-pill">Response</span>
|
||||
</div>
|
||||
<ThemeButton class="d-inline-flex theme-toggle"/>
|
||||
</div>
|
||||
<Packet v-for="packetWithOffset in packetsWithOffsets"
|
||||
:key="packetWithOffset.packet.id"
|
||||
:packet="packetWithOffset.packet"
|
||||
:offset="packetWithOffset.offset" />
|
||||
<infinite-loading @infinite="infiniteLoadingHandler" ref="infiniteLoader">
|
||||
<span slot="no-results"></span>
|
||||
</infinite-loading>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import Packet from '../components/Packet';
|
||||
import ThemeButton from '@/components/ThemeButton.vue';
|
||||
|
||||
export default {
|
||||
name: 'ContentStream',
|
||||
props: ['servicePort', 'streamId',],
|
||||
data() {
|
||||
return {
|
||||
packets: [],
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
packetsWithOffsets: function() {
|
||||
return this.packets.map((el, i) => {
|
||||
let offset = null;
|
||||
|
||||
if (i !== 0) {
|
||||
offset = el.timestamp - this.packets[i - 1].timestamp;
|
||||
}
|
||||
|
||||
return {packet: el, offset: offset,}
|
||||
})
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
'$route.params.streamId': function () {
|
||||
this.packets = [];
|
||||
this.$refs.infiniteLoader.stateChanger.reset();
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
infiniteLoadingHandler($state) {
|
||||
if (!this.$route.params.streamId) return $state.complete();
|
||||
|
||||
const packets = this.packets;
|
||||
const pageSize = this.$store.state.pageSize;
|
||||
let startsFrom;
|
||||
if (packets && packets.length && packets[packets.length - 1]) {
|
||||
startsFrom = packets[packets.length - 1].id;
|
||||
} else {
|
||||
startsFrom = null;
|
||||
}
|
||||
|
||||
this.$http.post(`packet/${this.$route.params.streamId}`, {
|
||||
startingFrom: startsFrom,
|
||||
pageSize: pageSize,
|
||||
}).then(response => {
|
||||
const data = response.data;
|
||||
if (data.length === 0) {
|
||||
console.log('Finished loading packets (empty page)');
|
||||
return $state.complete();
|
||||
}
|
||||
|
||||
if (data[0] && this.packets[0] && data[0].id === this.packets[0].id) {
|
||||
console.log('Finished loading packets (overlap detected)');
|
||||
return $state.complete();
|
||||
}
|
||||
|
||||
this.packets.push(...data);
|
||||
|
||||
if (data.length < pageSize) {
|
||||
// this was the last page
|
||||
console.log('Finished loading packets (last page was not full)');
|
||||
$state.complete();
|
||||
} else {
|
||||
console.log('Loaded another page of packets');
|
||||
$state.loaded();
|
||||
}
|
||||
}).catch(e => {
|
||||
this.$bvToast.toast(`Failed to load portion of packets: ${e}`, {
|
||||
title: 'Error',
|
||||
variant: 'danger',
|
||||
});
|
||||
console.error('Failed to load portion of packets:', e);
|
||||
return $state.error();
|
||||
});
|
||||
},
|
||||
},
|
||||
components: {
|
||||
ThemeButton,
|
||||
Packet,
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style>
|
||||
:root {
|
||||
--response-packet-color: rgba(255, 106, 213, 0.16);
|
||||
--request-packet-color: rgba(94, 242, 255, 0.2);
|
||||
}
|
||||
|
||||
:root [data-theme=dark] {
|
||||
color-scheme: dark;
|
||||
--response-packet-color: rgba(255, 106, 213, 0.16);
|
||||
--request-packet-color: rgba(94, 242, 255, 0.2);
|
||||
}
|
||||
|
||||
.request-pill {
|
||||
border-color: rgba(94, 242, 255, 0.45);
|
||||
color: #c9f8ff;
|
||||
background: rgba(94, 242, 255, 0.1);
|
||||
}
|
||||
|
||||
.response-pill {
|
||||
border-color: rgba(255, 106, 213, 0.45);
|
||||
color: #ffd6f6;
|
||||
background: rgba(255, 106, 213, 0.1);
|
||||
}
|
||||
</style>
|
||||
57
frontend/src/views/LookBack.vue
Normal file
57
frontend/src/views/LookBack.vue
Normal file
@@ -0,0 +1,57 @@
|
||||
<template>
|
||||
<b-modal @ok="lookBack" id="lookBackModal"
|
||||
title="Search into the past"
|
||||
cancel-title="Cancel"
|
||||
centered scrollable>
|
||||
<form ref="lookBackForm">
|
||||
<b-form-group
|
||||
label-cols-sm="4"
|
||||
label-cols-lg="3"
|
||||
label="Minutes"
|
||||
description="How far into the past do we want to look"
|
||||
label-for="lookback-minutes">
|
||||
<b-form-input @keydown.native.enter="lookBack" id="lookback-minutes" required v-model="minutes"/>
|
||||
</b-form-group>
|
||||
</form>
|
||||
</b-modal>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'LookBack',
|
||||
props: {
|
||||
patternId: Number,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
minutes: 5,
|
||||
};
|
||||
},
|
||||
methods: {
|
||||
checkValidity() {
|
||||
return this.$refs.lookBackForm.reportValidity();
|
||||
},
|
||||
|
||||
lookBack(ev) {
|
||||
ev.preventDefault();
|
||||
if (!this.checkValidity()) {
|
||||
console.debug('Form is invalid');
|
||||
return;
|
||||
}
|
||||
console.debug(`Looking back with pattern ${this.patternId}`);
|
||||
|
||||
this.$http.post(`pattern/${this.patternId}/lookback`, this.minutes)
|
||||
.then(() => {
|
||||
console.debug('Lookback started');
|
||||
this.$bvModal.hide('lookBackModal');
|
||||
}).catch(e => {
|
||||
this.$bvToast.toast(`Failed to start lookback: ${e}`, {
|
||||
title: 'Error',
|
||||
variant: 'danger',
|
||||
});
|
||||
console.error('Failed to start lookback', e);
|
||||
});
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
205
frontend/src/views/PatternModal.vue
Normal file
205
frontend/src/views/PatternModal.vue
Normal file
@@ -0,0 +1,205 @@
|
||||
<template>
|
||||
<b-modal @ok="addPattern" @cancel="reset" id="patternModal"
|
||||
:title="creating ? 'Creating pattern' : 'Editing pattern'"
|
||||
cancel-title="Cancel"
|
||||
centered scrollable>
|
||||
<form ref="addPatternForm">
|
||||
<b-form-group
|
||||
label-cols-sm="4"
|
||||
label-cols-lg="3"
|
||||
label="Name"
|
||||
description="It is displayed in the list and highlighted in the packet contents"
|
||||
label-for="pattern-name">
|
||||
<b-form-input @keydown.native.enter="addPattern" id="pattern-name" required v-model="newPattern.name"/>
|
||||
</b-form-group>
|
||||
|
||||
<b-form-group
|
||||
v-if="creating"
|
||||
label-cols-sm="4"
|
||||
label-cols-lg="3"
|
||||
label="Pattern"
|
||||
description="Substring, RegEx or bytes"
|
||||
label-for="pattern-value">
|
||||
<b-form-input @keydown.native.enter="addPattern" @keydown="validateKey"
|
||||
id="pattern-value" required v-model="newPattern.value"
|
||||
:placeholder="getPlaceholder()"/>
|
||||
</b-form-group>
|
||||
|
||||
<b-form-group
|
||||
v-if="creating"
|
||||
label-cols-sm="4"
|
||||
label-cols-lg="3"
|
||||
label="Search action"
|
||||
description="What to do with matching streams"
|
||||
label-for="pattern-actionType">
|
||||
<b-form-select id="pattern-actionType" required v-model="newPattern.actionType">
|
||||
<option value="FIND" selected>Highlight found pattern</option>
|
||||
<option value="IGNORE">Ignore matching streams</option>
|
||||
</b-form-select>
|
||||
</b-form-group>
|
||||
|
||||
<b-form-group v-if="newPattern.actionType === 'FIND'"
|
||||
label-cols-sm="4"
|
||||
label-cols-lg="3"
|
||||
label="Color"
|
||||
description="The highlight color of the pattern in the packets and streams"
|
||||
label-for="pattern-color">
|
||||
<b-form-input id="pattern-color" required type="color" v-model="newPattern.color"/>
|
||||
</b-form-group>
|
||||
|
||||
<b-form-group
|
||||
v-if="creating"
|
||||
label-cols-sm="4"
|
||||
label-cols-lg="3"
|
||||
label="Search method"
|
||||
description="The way to search for patterns in packets"
|
||||
label-for="pattern-searchType">
|
||||
<b-form-select id="pattern-searchType" required v-model="newPattern.searchType">
|
||||
<option value="REGEX" selected>Regular expression</option>
|
||||
<option value="SUBSTRING">Substring</option>
|
||||
<option value="SUBBYTES">Bytes</option>
|
||||
</b-form-select>
|
||||
</b-form-group>
|
||||
|
||||
<b-form-group
|
||||
v-if="creating"
|
||||
label-cols-sm="4"
|
||||
label-cols-lg="3"
|
||||
label="Search type"
|
||||
description="In which packets to search for a pattern"
|
||||
label-for="pattern-type">
|
||||
<b-form-select id="pattern-type" required v-model="newPattern.directionType">
|
||||
<option value="INPUT">Request</option>
|
||||
<option value="OUTPUT">Response</option>
|
||||
<option value="BOTH" selected>Anywhere</option>
|
||||
</b-form-select>
|
||||
</b-form-group>
|
||||
|
||||
<b-form-group
|
||||
v-if="creating"
|
||||
label-cols-sm="4"
|
||||
label-cols-lg="3"
|
||||
label="Service"
|
||||
description="Apply this pattern only to the specific service"
|
||||
label-for="pattern-service">
|
||||
<b-form-select id="pattern-service" :options="serviceOptions"
|
||||
v-model="newPattern.serviceId"></b-form-select>
|
||||
</b-form-group>
|
||||
</form>
|
||||
</b-modal>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
|
||||
const hexRegex = /[0-9A-Fa-f ]/;
|
||||
const defaultPattern = {
|
||||
name: '',
|
||||
value: '',
|
||||
color: '#FF7474',
|
||||
searchType: 'SUBSTRING',
|
||||
directionType: 'BOTH',
|
||||
actionType: 'FIND',
|
||||
serviceId: null,
|
||||
};
|
||||
|
||||
export default {
|
||||
name: 'PatternModal',
|
||||
props: {
|
||||
creating: Boolean,
|
||||
|
||||
initialPattern: {
|
||||
name: String,
|
||||
value: String,
|
||||
color: String,
|
||||
searchType: String,
|
||||
directionType: String,
|
||||
actionType: String,
|
||||
serviceId: Number,
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
newPattern: {},
|
||||
};
|
||||
},
|
||||
watch: {
|
||||
initialPattern() {
|
||||
console.debug('initialService changed, reassigning...', this.initialPattern);
|
||||
this.newPattern = {...defaultPattern, ...this.initialPattern,};
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
serviceOptions: function () {
|
||||
let services = this.$store.state.services;
|
||||
let options = services.map(service => {
|
||||
return {
|
||||
value: service.port,
|
||||
text: `${service.name} #${service.port}`,
|
||||
}
|
||||
});
|
||||
|
||||
return [
|
||||
{ value: null, text: 'Any service', },
|
||||
...options,
|
||||
];
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
getPlaceholder() {
|
||||
if (this.newPattern.searchType === 'REGEX') return '[A-Z0-9]{31}=';
|
||||
else if (this.newPattern.searchType === 'SUBSTRING') return 'HTTP/2';
|
||||
else return 'DEAD BEEF 1337';
|
||||
},
|
||||
validateKey(e) {
|
||||
console.log('', e);
|
||||
if (this.newPattern.searchType !== 'SUBBYTES') return;
|
||||
if (!hexRegex.test(e?.key)) {
|
||||
e.preventDefault();
|
||||
}
|
||||
},
|
||||
|
||||
checkValidity() {
|
||||
return this.$refs.addPatternForm.reportValidity();
|
||||
},
|
||||
|
||||
reset() {
|
||||
this.newPattern = {...defaultPattern,};
|
||||
},
|
||||
|
||||
addPattern(ev) {
|
||||
ev.preventDefault();
|
||||
if (!this.checkValidity()) {
|
||||
console.debug('Form is invalid');
|
||||
return;
|
||||
}
|
||||
console.debug('Adding/editing pattern...', this.newPattern);
|
||||
|
||||
if (this.newPattern.searchType === 'SUBBYTES') {
|
||||
this.newPattern.value = this.newPattern.value.replace(/\s+/g, '').toLowerCase();
|
||||
}
|
||||
|
||||
let url;
|
||||
if (this.creating) {
|
||||
url = 'pattern/'
|
||||
} else {
|
||||
url = 'pattern/' + this.newPattern.id
|
||||
}
|
||||
|
||||
this.$http.post(url, this.newPattern)
|
||||
.then(response => {
|
||||
const data = response.data;
|
||||
console.debug('Done adding/editing pattern', data);
|
||||
this.$emit('patternAddComplete');
|
||||
this.reset();
|
||||
this.$bvModal.hide('patternModal');
|
||||
}).catch(e => {
|
||||
this.$bvToast.toast(`Failed to add pattern: ${e}`, {
|
||||
title: 'Error',
|
||||
variant: 'danger',
|
||||
});
|
||||
console.error('Failed to add pattern', e);
|
||||
});
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
136
frontend/src/views/ServiceModal.vue
Normal file
136
frontend/src/views/ServiceModal.vue
Normal file
@@ -0,0 +1,136 @@
|
||||
<template>
|
||||
<b-modal id="serviceModal" size="lg"
|
||||
:title="creating ? 'Creating service' : 'Editing service'"
|
||||
centered scrollable @ok.prevent="submit">
|
||||
<b-form ref="serviceForm">
|
||||
<b-form-group label-cols-sm="4"
|
||||
label="Name"
|
||||
label-for="service-name">
|
||||
<b-form-input id="service-name" required v-model="service.name"
|
||||
@keyup.enter.stop.prevent="submit"/>
|
||||
</b-form-group>
|
||||
|
||||
<b-form-group v-if="creating"
|
||||
label-cols-sm="4"
|
||||
label="Port"
|
||||
label-for="service-port">
|
||||
<b-form-input id="service-port" required
|
||||
type="number" min="1" max="65535" v-model.number="service.port"
|
||||
@keyup.enter.stop.prevent="submit"/>
|
||||
</b-form-group>
|
||||
|
||||
<b-form-group label-cols-sm="4"
|
||||
label="Is an HTTP service"
|
||||
label-for="service-is-http">
|
||||
<b-form-checkbox id="service-is-http" required
|
||||
v-model="service.http" />
|
||||
</b-form-group>
|
||||
|
||||
<b-form-group label-cols-sm="4"
|
||||
label="Apply urldecode"
|
||||
label-for="service-urldecode">
|
||||
<b-form-checkbox id="service-urldecode" required
|
||||
v-model="service.urldecodeHttpRequests"/>
|
||||
</b-form-group>
|
||||
|
||||
<b-form-group label-cols-sm="4"
|
||||
label="Merge adjacent packets"
|
||||
label-for="service-mergeAdjacent">
|
||||
<b-form-checkbox id="service-mergeAdjacent" required
|
||||
v-model="service.mergeAdjacentPackets"/>
|
||||
</b-form-group>
|
||||
|
||||
<b-form-group label-cols-sm="4"
|
||||
label="Inflate WebSockets"
|
||||
label-for="service-inflateWS">
|
||||
<b-form-checkbox id="service-inflateWS" required
|
||||
v-model="service.parseWebSockets"/>
|
||||
</b-form-group>
|
||||
|
||||
<b-form-group label-cols-sm="4"
|
||||
label="Decrypt TLS (TLS_RSA_WITH_AES only)"
|
||||
label-for="service-decryptTls">
|
||||
<b-form-checkbox id="service-decryptTls" required
|
||||
v-model="service.decryptTls"/>
|
||||
</b-form-group>
|
||||
|
||||
<b-button v-if="!creating" variant="danger" @click="deleteService">Delete</b-button>
|
||||
</b-form>
|
||||
</b-modal>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import {objectAssignDeep,} from '@/objectAssignDeep';
|
||||
|
||||
export default {
|
||||
name: 'ServiceModal',
|
||||
props: {
|
||||
creating: Boolean,
|
||||
|
||||
initialService: {
|
||||
name: String,
|
||||
port: Number,
|
||||
http: Boolean,
|
||||
urldecodeHttpRequests: Boolean,
|
||||
mergeAdjacentPackets: Boolean,
|
||||
parseWebSockets: Boolean,
|
||||
decryptTls: Boolean,
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
service: {},
|
||||
};
|
||||
},
|
||||
watch: {
|
||||
initialService() {
|
||||
console.debug('initialService changed, reassigning...', this.initialService);
|
||||
this.service = objectAssignDeep({}, this.initialService);
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
submit() {
|
||||
if (!this.$refs.serviceForm.reportValidity()) {
|
||||
return;
|
||||
}
|
||||
console.debug('Submitting service...', this.service, this.creating);
|
||||
|
||||
let url;
|
||||
|
||||
if (this.creating) {
|
||||
url = 'service/';
|
||||
} else {
|
||||
url = 'service/' + this.initialService.port
|
||||
}
|
||||
|
||||
this.$http.post(url, this.service)
|
||||
.then(response => {
|
||||
console.info('Done editing/creating service', response.data);
|
||||
this.$emit('service-update-needed');
|
||||
this.$bvModal.hide('serviceModal');
|
||||
})
|
||||
.catch(e => {
|
||||
this.$bvToast.toast(`Failed to ${this.creating ? 'create' : 'edit'} service: ${e}`, {
|
||||
title: 'Error',
|
||||
variant: 'danger',
|
||||
});
|
||||
console.error('Failed to edit/create service', e);
|
||||
});
|
||||
},
|
||||
deleteService() {
|
||||
this.$http.delete(`service/${this.service.port}`)
|
||||
.then(() => {
|
||||
console.info('Done deleting service', this.service);
|
||||
this.$emit('service-update-needed');
|
||||
this.$bvModal.hide('serviceModal');
|
||||
}).catch(e => {
|
||||
this.$bvToast.toast(`Failed to delete service: ${e}`, {
|
||||
title: 'Error',
|
||||
variant: 'danger',
|
||||
});
|
||||
console.error('Failed to delete service', e);
|
||||
});
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
63
frontend/src/views/Settings.vue
Normal file
63
frontend/src/views/Settings.vue
Normal file
@@ -0,0 +1,63 @@
|
||||
<template>
|
||||
<b-modal id="settingsModal" title="Settings" centered scrollable ok-only>
|
||||
<b-form-group
|
||||
label-cols-sm="4"
|
||||
label-cols-lg="3"
|
||||
description="The number of bytes to display in the binary representation of the packet"
|
||||
label="HEX block width"
|
||||
label-for="settings-hexdumpBlockSize">
|
||||
<b-form-input type="number" id="settings-hexdumpBlockSize" v-model.number="hexdumpBlockSize"/>
|
||||
</b-form-group>
|
||||
<b-form-group
|
||||
label-cols-sm="4"
|
||||
label-cols-lg="3"
|
||||
description="The number system used in line numbers of a binary representation of a packet"
|
||||
label="Line numbering"
|
||||
label-for="settings-hexdumpLineNumberBase">
|
||||
<b-form-select id="settings-hexdumpLineNumberBase" v-model="hexdumpLineNumberBase">
|
||||
<option :value="10" selected>Decimal</option>
|
||||
<option :value="16">Hexadecimal</option>
|
||||
</b-form-select>
|
||||
</b-form-group>
|
||||
<b-form-group
|
||||
label-cols-sm="4"
|
||||
label-cols-lg="3"
|
||||
description="The number of streams to download at a time"
|
||||
label="Page size"
|
||||
label-for="settings-pageSize">
|
||||
<b-form-input type="number" id="settings-pageSize" v-model.number="pageSize"/>
|
||||
</b-form-group>
|
||||
</b-modal>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'Settings',
|
||||
computed: {
|
||||
hexdumpBlockSize: {
|
||||
get() {
|
||||
return this.$store.state.hexdumpBlockSize;
|
||||
},
|
||||
set(v) {
|
||||
this.$store.commit('setHexdumpBlockSize', v);
|
||||
},
|
||||
},
|
||||
hexdumpLineNumberBase: {
|
||||
get() {
|
||||
return this.$store.state.hexdumpLineNumberBase;
|
||||
},
|
||||
set(v) {
|
||||
this.$store.commit('setHexdumpLineNumberBase', v);
|
||||
},
|
||||
},
|
||||
pageSize: {
|
||||
get() {
|
||||
return this.$store.state.pageSize;
|
||||
},
|
||||
set(v) {
|
||||
this.$store.commit('setPageSize', v);
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
Reference in New Issue
Block a user