Skip to content

Commit

Permalink
Add ApexChart based visualizations (#3040)
Browse files Browse the repository at this point in the history
* Adding custom ApexChart based aggregations & visualizations.

---------

Co-authored-by: Syd Pleno <sydp@google.com>
Co-authored-by: Janosch <99879757+jkppr@users.noreply.github.com>
  • Loading branch information
3 people authored Jul 16, 2024
1 parent 1792757 commit 98c7305
Show file tree
Hide file tree
Showing 23 changed files with 4,294 additions and 19 deletions.
50 changes: 35 additions & 15 deletions timesketch/api/v1/resources/aggregation.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
from timesketch.api.v1 import utils
from timesketch.lib import forms
from timesketch.lib import utils as lib_utils
from timesketch.lib.aggregators import apex
from timesketch.lib.definitions import HTTP_STATUS_CODE_OK
from timesketch.lib.definitions import HTTP_STATUS_CODE_CREATED
from timesketch.lib.definitions import HTTP_STATUS_CODE_BAD_REQUEST
Expand Down Expand Up @@ -69,6 +70,12 @@ def get(self, sketch_id, aggregation_id): # pylint: disable=unused-argument
)
aggregation = Aggregation.get_by_id(aggregation_id)

# Check that the aggregation exists
if not aggregation:
abort(
HTTP_STATUS_CODE_NOT_FOUND,
"The aggregation ID ({0:d}) does not exist.".format(aggregation_id),
)
# Check that this aggregation belongs to the sketch
if aggregation.sketch_id != sketch.id:
abort(
Expand Down Expand Up @@ -468,17 +475,20 @@ def post(self, sketch_id):
aggregator_name = form.aggregator_name.data

if aggregator_name:
if isinstance(form.aggregator_parameters.data, dict):
aggregator_parameters = form.aggregator_parameters.data
else:
aggregator_parameters = json.loads(form.aggregator_parameters.data)

agg_class = aggregator_manager.AggregatorManager.get_aggregator(
aggregator_name
)
if not agg_class:
return {}
if not aggregator_parameters:
abort(
HTTP_STATUS_CODE_NOT_FOUND,
f"Aggregator {aggregator_name} not found",
)

if form.aggregator_parameters.data:
aggregator_parameters = form.aggregator_parameters.data
if not isinstance(aggregator_parameters, dict):
aggregator_parameters = json.loads(aggregator_parameters)
else:
aggregator_parameters = {}

indices = aggregator_parameters.pop("index", sketch_indices)
Expand All @@ -490,12 +500,10 @@ def post(self, sketch_id):
aggregator = agg_class(
sketch_id=sketch_id, indices=indices, timeline_ids=timeline_ids
)
aggregator_description = aggregator.describe

# legacy chart settings
chart_type = aggregator_parameters.pop("supported_charts", None)
chart_color = aggregator_parameters.pop("chart_color", "")
chart_title = aggregator_parameters.pop(
"chart_title", aggregator.chart_title
)

time_before = time.time()
try:
Expand All @@ -515,24 +523,36 @@ def post(self, sketch_id):
)
time_after = time.time()

aggregator_description = aggregator.describe

buckets = result_obj.to_dict()
buckets["buckets"] = buckets.pop("values")
if "labels" in buckets:
buckets["labels"] = buckets.pop("labels")
if "chart_options" in buckets:
buckets["chart_options"] = buckets.pop("chart_options")

result = {"aggregation_result": {aggregator_name: buckets}}
meta = {
"method": "aggregator_run",
"aggregator_class": (
"apex" if isinstance(aggregator, apex.ApexAggregation) else "legacy"
),
"chart_type": chart_type,
"name": aggregator_description.get("name"),
"description": aggregator_description.get("description"),
"es_time": time_after - time_before,
}

if chart_type:
meta["vega_spec"] = result_obj.to_chart(
chart_color = aggregator_parameters.pop("chart_color", "")
chart_title = aggregator_parameters.pop("chart_title", None)
chart_spec = result_obj.to_chart(
chart_name=chart_type, chart_title=chart_title, color=chart_color
)
meta["vega_chart_title"] = chart_title
if chart_spec:
meta["vega_spec"] = chart_spec
if not chart_title:
chart_title = aggregator.chart_title
meta["vega_chart_title"] = chart_title

elif aggregation_dsl:
# pylint: disable=unexpected-keyword-arg
Expand Down
271 changes: 271 additions & 0 deletions timesketch/frontend-ng/src/components/LeftPanel/Visualizations.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,271 @@
<!--
Copyright 2023 Google Inc. All rights reserved.

Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at

http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->
<template>
<div
v-if="iconOnly"
key="iconOnly"
class="pa-4"
style="cursor: pointer"
@click="
$emit('toggleDrawer')
expanded = true
"
>
<v-icon left>
mdi-chart-bar
</v-icon>
<div style="height: 1px">
</div>
</div>
<div
v-else
key="iconOnly"
>
<div
:style="!(savedVisualizations && savedVisualizations.length) ? '' : 'cursor: pointer'"
class="pa-4"
@click="expanded = !expanded"
:class="$vuetify.theme.dark ? 'dark-hover' : 'light-hover'"
>
<span>
<v-icon left>
mdi-chart-bar
</v-icon>
Visualizations
</span>

<v-btn
v-if="expanded || !(savedVisualizations && savedVisualizations.length)"
icon
text
class="float-right mt-n1 mr-n1"
:to="{ name: 'VisualizationNew' }"
@click.stop=""
>
<v-icon title="Create a new visualization">
mdi-plus
</v-icon>
</v-btn>
<span
v-if="!expanded"
class="float-right"
style="margin-right: 10px"
>
<small
v-if="savedVisualizations && savedVisualizations.length"
>
<strong>
{{ visualizationCount }}
</strong>
</small>
</span>
</div>
<v-expand-transition>
<div v-show="expanded && savedVisualizations.length">
<div
v-for="(savedVisualization, key) in savedVisualizations"
:key="key"

style="cursor: pointer; font-size: 0.9em; text-decoration: none"

>
<v-row
no-gutters
class="pa-2 pl-5"
:class="$vuetify.theme.dark ? 'dark-hover' : 'light-hover'"
>
<v-col
:class="$vuetify.theme.dark ? 'dark-font' : 'light-font'"
@click="navigateToSavedVisualization(savedVisualization.id)"
>

<span class="d-inline-block text-truncate" style="max-width: 250px">
<v-icon left small>
{{ getIcon(savedVisualization.chart_type) }}
</v-icon>
<!-- {{ savedVisualization.name }} -->
<v-tooltip bottom :disabled="savedVisualization.name && savedVisualization.name.length < 34">
<template v-slot:activator="{ on, attrs }">
<span
v-bind="attrs"
v-on="on"
>{{ savedVisualization.name }}</span>
</template>
<span>{{ savedVisualization.name }}</span>
</v-tooltip>
</span>

</v-col>
<v-col cols="auto">
<v-menu offset-y>
<template v-slot:activator="{ on, attrs }">
<v-btn
small
icon
v-bind="attrs"
v-on="on"
class="mr-1"
>
<v-icon
title="Actions"
small
>
mdi-dots-vertical
</v-icon>
</v-btn>
</template>
<v-list dense class="mx-auto">
<v-list-item style="cursor: pointer" @click="copyVisualizationIdToClipboard(savedVisualization.id)">
<v-list-item-icon>
<v-icon small>mdi-identifier</v-icon>
</v-list-item-icon>
<v-list-item-title>Copy visualization ID</v-list-item-title>
</v-list-item>
<v-list-item style="cursor: pointer" @click="copyVisualizationUrlToClipboard(savedVisualization.id)">
<v-list-item-icon>
<v-icon small>mdi-link-variant</v-icon>
</v-list-item-icon>
<v-list-item-title>Copy link to this visualization</v-list-item-title>
</v-list-item>
<v-list-item style="cursor: pointer" @click="deleteVisualization(savedVisualization.id)">
<v-list-item-icon>
<v-icon small>mdi-trash-can</v-icon>
</v-list-item-icon>
<v-list-item-title>Delete</v-list-item-title>
</v-list-item>
</v-list>
</v-menu>
</v-col>
</v-row>
</div>
</div>
</v-expand-transition>
<v-divider></v-divider>
</div>
</template>

<script>
import ApiClient from '../../utils/RestApiClient'

export default {
props: {
iconOnly: {
type: Boolean,
},
},
data: function () {
return {
expanded: false,
}
},
methods: {
copyVisualizationIdToClipboard(savedVisualizationId) {
try {
navigator.clipboard.writeText(savedVisualizationId)
this.infoSnackBar('Saved Visualization ID copied to clipboard')
} catch (error) {
this.errorSnackBar('Failed to load Saved Visualization ID into the clipboard!')
console.error(error)
}
},
copyVisualizationUrlToClipboard(savedVisualizationId) {
try {
let url = window.location.origin + '/sketch/' + this.sketch.id + '/visualization/view/' + savedVisualizationId
navigator.clipboard.writeText(url)
this.infoSnackBar('Saved Visualization URL copied to clipboard')
} catch (error) {
this.errorSnackBar('Failed to load Saved Visualization URL into the clipboard!')
console.error(error)
}
},
deleteVisualization(savedVisualizationId) {
if (confirm('Delete Saved Visualization?')) {
ApiClient.deleteAggregationById(this.sketch.id, savedVisualizationId)
.then((response) => {
this.$store.dispatch('updateSavedVisualizationList', this.sketch.id)
this.infoSnackBar('Saved Visualization has been deleted')
let params = {
name: 'VisualizationView',
params: {
aggregationId: savedVisualizationId
}
}
let currentPath = this.$route.fullPath
let deletedPath = this.$router.resolve(params).route.fullPath

if (currentPath === deletedPath) {
this.$router.push({ name: 'VisualizationNew', })
}
})
.catch((e) => {
this.errorSnackBar('Failed to delete Saved Visualization!')
console.error(e)
})
}
},
getIcon(chartType) {
return {
'bar': 'mdi-poll mdi-rotate-90',
'column': 'mdi-chart-bar',
'line': 'mdi-chart-line',
'table': 'mdi-table',
'heatmap': 'mdi-blur-linear',
'donut': 'mdi-chart-donut',

}[chartType]
},
navigateToSavedVisualization(savedVisualizationId) {
let params = {
name: 'VisualizationView',
params: { aggregationId: savedVisualizationId }
}
let nextPath = this.$router.resolve(params).route.fullPath
let currentPath = this.$route.fullPath
if (nextPath !== currentPath) {
this.$router.push(params)
}
}
},
computed: {
savedVisualizations() {
if (!this.$store.state.savedVisualizations) {
return []
}
return this.$store.state.savedVisualizations.filter(
(e) => JSON.parse(e.parameters)['aggregator_class'] === 'apex'
)
},
visualizationCount() {
if (!this.$store.state.savedVisualizations) {
return 0
}
return this.$store.state.savedVisualizations.filter(
(e) => JSON.parse(e.parameters)['aggregator_class'] === 'apex'
).length
},
sketch() {
return this.$store.state.sketch
},
meta() {
return this.$store.state.meta
},
},
mounted() {
this.$store.dispatch('updateSavedVisualizationList', this.sketch.id)
},

}
</script>
Loading

0 comments on commit 98c7305

Please sign in to comment.