Skip to content

Commit d75e8d3

Browse files
authored
Feature: Enhanced direct neighbors view (#172)
1 parent 93225b5 commit d75e8d3

File tree

5 files changed

+419
-36
lines changed

5 files changed

+419
-36
lines changed

src/components/DirectNeighbors.vue

Lines changed: 348 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,348 @@
1+
<template>
2+
<!-- Empty headers array to not mess up with CSS borders -->
3+
<div>
4+
<v-form>
5+
<v-row>
6+
<v-col>
7+
<v-text-field
8+
label="Filter regex (type, value, or description)"
9+
density="compact"
10+
@update:model-value="searchFilterDebounced"
11+
clearable
12+
hide-details
13+
></v-text-field>
14+
</v-col>
15+
<v-col>
16+
<!-- add a checkbox for inline display of description -->
17+
<v-checkbox-btn
18+
label="Dispay inline description"
19+
density="compact"
20+
hide-details
21+
color="primary"
22+
class="float-left mr-4 mt-1"
23+
v-model="inlineDescription"
24+
></v-checkbox-btn>
25+
<v-checkbox-btn
26+
label="Inline markdown"
27+
density="compact"
28+
hide-details
29+
color="primary"
30+
class="float-left mt-1"
31+
v-model="inlineMarkdown"
32+
></v-checkbox-btn>
33+
</v-col>
34+
</v-row>
35+
</v-form>
36+
<v-data-table-server
37+
density="compact"
38+
:items="processedPaths"
39+
:itemsLength="total"
40+
:items-per-page="perPage"
41+
v-model:page="page"
42+
:headers="headers"
43+
@update:options="fetchNeighbors"
44+
@update:items-per-page="perPage = $event"
45+
:sort-by="sortBy"
46+
:search="searchFilter"
47+
hover
48+
>
49+
<!-- <tr> -->
50+
<template v-slot:item.direction="{ item }">
51+
<v-icon v-if="item.target === extendedId">mdi-arrow-left</v-icon>
52+
<v-icon v-else-if="item.source === extendedId">mdi-arrow-right</v-icon>
53+
</template>
54+
55+
<template v-slot:item.created="{ item }">
56+
{{ moment(item.created).format("YYYY-MM-DD HH:mm:ss") }}
57+
</template>
58+
59+
<template v-slot:item.relevant_node.type="{ item }">
60+
<v-chip density="compact" class="ml-2">
61+
<v-icon :icon="getIconForType(item.relevant_node.type)" start size="small"></v-icon>
62+
{{ item.relevant_node.type }}</v-chip
63+
>
64+
</template>
65+
66+
<template v-slot:item.relevant_node.value="{ item }">
67+
<span v-if="item.relevant_node.root_type === 'observable'">
68+
<router-link :to="{ name: 'ObservableDetails', params: { id: item.relevant_node.id } }">
69+
{{ item.relevant_node.value }}
70+
</router-link>
71+
</span>
72+
<span v-if="item.relevant_node.root_type === 'entity'">
73+
<router-link :to="{ name: 'EntityDetails', params: { id: item.relevant_node.id } }">
74+
{{ item.relevant_node.name }}
75+
</router-link>
76+
</span>
77+
<span v-if="item.relevant_node.root_type === 'indicator'">
78+
<router-link :to="{ name: 'IndicatorDetails', params: { id: item.relevant_node.id } }">
79+
{{ item.relevant_node.name }}
80+
</router-link>
81+
</span>
82+
<span v-if="item.relevant_node.root_type === 'dfiq'">
83+
<router-link :to="{ name: 'DFIQDetails', params: { id: item.relevant_node.id } }">
84+
{{ item.relevant_node.name }}
85+
</router-link>
86+
</span>
87+
</template>
88+
89+
<template v-slot:item.relevant_node.tags="{ item }">
90+
<v-chip
91+
v-for="tag in Object.keys(item.relevant_node.tags)"
92+
:key="tag"
93+
class="mr-2"
94+
:color="item.relevant_node.tags[tag].fresh ? 'primary' : 'grey'"
95+
density="compact"
96+
>
97+
{{ tag }}
98+
</v-chip>
99+
</template>
100+
101+
<template v-slot:item.description="{ item }">
102+
<span v-if="inlineDescription">
103+
<v-chip density="compact" class="mr-2" color="success">{{ item.type }} </v-chip>
104+
<span><yeti-markdown :text="item.description" :inline="inlineMarkdown" /></span>
105+
</span>
106+
<v-btn v-else size="small" variant="tonal" append-icon="mdi-information">
107+
<template v-slot:append>
108+
<v-icon v-if="item.description"></v-icon>
109+
</template>
110+
{{ item.type }}
111+
<v-menu activator="parent" v-if="item.description">
112+
<v-sheet class="px-5 py-2" color="background" width="auto" elevation="10" style="font-size: 0.8rem">
113+
<yeti-markdown :text="item.description" />
114+
</v-sheet>
115+
</v-menu>
116+
</v-btn>
117+
</template>
118+
119+
<template v-slot:item.controls="{ item }">
120+
<v-btn
121+
icon="mdi-swap-horizontal"
122+
@click="swapLink(item.id)"
123+
density="compact"
124+
variant="tonal"
125+
color="primary"
126+
class="me-2"
127+
>
128+
</v-btn>
129+
<v-dialog width="700">
130+
<template v-slot:activator="{ props }">
131+
<v-btn icon="mdi-pencil" density="compact" variant="tonal" color="primary" class="me-2" v-bind="props">
132+
</v-btn>
133+
</template>
134+
135+
<template v-slot:default="{ isActive }">
136+
<edit-link
137+
:vertices="vertices"
138+
:edge="item"
139+
:is-active="isActive"
140+
@success="linkUpdateSuccess(item, $event)"
141+
/>
142+
</template>
143+
</v-dialog>
144+
<v-btn
145+
icon="mdi-link-off"
146+
@click="unlink(item.id)"
147+
density="compact"
148+
variant="tonal"
149+
color="error"
150+
class="me-2"
151+
>
152+
</v-btn>
153+
</template>
154+
</v-data-table-server>
155+
</div>
156+
</template>
157+
158+
<script lang="ts" setup>
159+
import axios from "axios";
160+
import moment from "moment";
161+
162+
import { ENTITY_TYPES } from "@/definitions/entityDefinitions.js";
163+
import { INDICATOR_TYPES } from "@/definitions/indicatorDefinitions.js";
164+
import { DFIQ_TYPES } from "@/definitions/dfiqDefinitions.js";
165+
import { OBSERVABLE_TYPES } from "@/definitions/observableDefinitions.js";
166+
import EditLink from "@/components/EditLink.vue";
167+
import YetiMarkdown from "@/components/YetiMarkdown.vue";
168+
169+
import _ from "lodash";
170+
</script>
171+
172+
<script lang="ts">
173+
export default {
174+
name: "DirectNeighbors",
175+
props: {
176+
id: { type: String, required: true },
177+
fields: { type: Array, default: () => ["value", "tags"] },
178+
sourceType: { type: String, default: "observable" },
179+
targetTypes: { type: Array, default: Array },
180+
inlineIcons: { type: Boolean, default: false },
181+
graph: { type: String, default: "links" },
182+
hops: { type: Number, default: 1 }
183+
},
184+
components: {
185+
EditLink,
186+
YetiMarkdown
187+
},
188+
mounted() {
189+
this.$eventBus.on("linkCreated", data => {
190+
const souceType = data.source.type;
191+
const targetType = data.target.type;
192+
if (this.targetTypes.includes(targetType) || this.targetTypes.includes(souceType)) {
193+
this.fetchNeighbors({ page: this.page, itemsPerPage: this.perPage, sortBy: this.sortBy });
194+
}
195+
});
196+
},
197+
data() {
198+
return {
199+
tempChains: [],
200+
paths: [],
201+
processedPaths: [],
202+
vertices: {},
203+
inlineDescription: true,
204+
inlineMarkdown: true,
205+
searchFilter: "",
206+
page: 1,
207+
perPage: 50,
208+
total: 0,
209+
loading: false,
210+
objectTypes: ENTITY_TYPES.concat(INDICATOR_TYPES).concat(DFIQ_TYPES).concat(OBSERVABLE_TYPES),
211+
showEditLink: false,
212+
headers: [
213+
{ title: "", key: "direction", width: "10px" },
214+
{ title: "Linked on", key: "created", width: "170px", sortable: true },
215+
{ title: "Type", key: "relevant_node.type", width: "10px", sortable: false },
216+
{ title: "Value", key: "relevant_node.value", sortable: false },
217+
{ title: "Tags", key: "relevant_node.tags", sortable: false },
218+
{ title: "Description", key: "description", sortable: false },
219+
{ title: "", key: "controls", sortable: false }
220+
],
221+
sortBy: [{ key: "created", order: "asc" }]
222+
};
223+
},
224+
methods: {
225+
linkUpdateSuccess(edge, updatedEdge) {
226+
edge.type = updatedEdge.type;
227+
edge.description = updatedEdge.description;
228+
},
229+
getLabelForField(field) {
230+
let fieldName = field.charAt(0).toUpperCase() + field.slice(1);
231+
fieldName = fieldName.replace(/_/g, " ");
232+
return fieldName;
233+
},
234+
fetchNeighbors({
235+
page,
236+
itemsPerPage,
237+
sortBy
238+
}: {
239+
page: number;
240+
itemsPerPage: number;
241+
sortBy: Array<{ key: string; order: string }>;
242+
}) {
243+
this.loading = true;
244+
let filters = [
245+
{
246+
key: "type",
247+
value: this.searchFilter || "",
248+
operator: "=~"
249+
},
250+
{
251+
key: "description",
252+
value: this.searchFilter || "",
253+
operator: "=~"
254+
},
255+
{
256+
key: "value",
257+
value: this.searchFilter || "",
258+
operator: "=~"
259+
},
260+
{
261+
key: "name",
262+
value: this.searchFilter || "",
263+
operator: "=~"
264+
}
265+
];
266+
let graphSearchRequest = {
267+
source: `${this.sourceType}/${this.id}`,
268+
target_types: this.targetTypes,
269+
graph: this.graph,
270+
hops: this.hops,
271+
direction: "any",
272+
include_original: true,
273+
filter: filters,
274+
count: itemsPerPage === -1 ? 0 : itemsPerPage,
275+
page: page - 1,
276+
sorting: sortBy.map(sort => [sort.key, sort.order === "asc"])
277+
};
278+
279+
axios
280+
.post(`/api/v2/graph/search`, graphSearchRequest)
281+
.then(response => {
282+
this.vertices = response.data.vertices;
283+
this.paths = response.data.paths;
284+
this.processedPaths = response.data.paths.map(path => {
285+
let processedPath = path[0];
286+
let relevantNode = processedPath.source === this.extendedId ? processedPath.target : processedPath.source;
287+
processedPath.relevant_node = this.vertices[relevantNode];
288+
return processedPath;
289+
});
290+
this.total = response.data.total;
291+
this.$emit("totalUpdated", this.total);
292+
})
293+
.catch(error => {
294+
console.log(error);
295+
})
296+
.finally(() => (this.loading = false));
297+
},
298+
getVerticeFromNode(link) {
299+
return link.source === this.extendedId ? this.vertices[link.target] : this.vertices[link.source];
300+
},
301+
swapLink(edgeId) {
302+
axios
303+
.post(`/api/v2/graph/${edgeId}/swap`)
304+
.then(response => {
305+
this.$eventBus.emit("displayMessage", { message: "Link direction swapped succesfully!", status: "success" });
306+
this.fetchNeighbors({ page: this.page, itemsPerPage: this.perPage, sortBy: this.sortBy });
307+
})
308+
.catch(error => {
309+
this.error = error;
310+
console.log(error);
311+
});
312+
},
313+
unlink(id) {
314+
if (!confirm("Are you sure you want to delete this link?")) {
315+
return;
316+
}
317+
axios
318+
.delete(`/api/v2/graph/${id}`)
319+
.then(() => {
320+
this.fetchNeighbors({ page: this.page, itemsPerPage: this.perPage, sortBy: this.sortBy });
321+
})
322+
.catch(error => {
323+
console.log(error);
324+
});
325+
},
326+
getIconForType(type) {
327+
return this.objectTypes.find(objectType => objectType.type === type)?.icon;
328+
},
329+
searchFilterDebounced: _.debounce(function (searchFilter) {
330+
this.searchFilter = searchFilter;
331+
}, 200)
332+
},
333+
computed: {
334+
extendedId() {
335+
return `${this.sourceType}/${this.id}`;
336+
}
337+
},
338+
watch: {
339+
id: function () {
340+
this.page = 1;
341+
this.perPage = 20;
342+
this.fetchNeighbors({ page: this.page, itemsPerPage: this.perPage, sortBy: this.sortBy });
343+
}
344+
}
345+
};
346+
</script>
347+
348+
<style></style>

src/components/EditLink.vue

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,7 @@ export default {
8585
.then(response => {
8686
this.$eventBus.emit("displayMessage", { message: "Link updated succesfully!", status: "success" });
8787
this.isActive.value = false;
88+
this.$emit("success", response.data);
8889
})
8990
.catch(error => {
9091
this.error = error;
@@ -100,6 +101,7 @@ export default {
100101
this.edge["target"] = response.data["target"];
101102
this.localEdge = { ...this.edge };
102103
this.$eventBus.emit("displayMessage", { message: "Link direction swapped succesfully!", status: "success" });
104+
this.$emit("success", response.data);
103105
})
104106
.catch(error => {
105107
this.error = error;

0 commit comments

Comments
 (0)