diff --git a/src/util/swimlane.js b/src/util/swimlane.js new file mode 100644 index 00000000..33cc4d5c --- /dev/null +++ b/src/util/swimlane.js @@ -0,0 +1,28 @@ +import moment from 'moment'; +import { seconds_to_duration } from './time'; +import DOMPurify from 'dompurify'; +import _ from 'lodash'; + +const sanitize = DOMPurify.sanitize; + +export function getSwimlane(bucket, color, groupBy, e) { + // WARNING: XSS risk, make sure to sanitize properly + // FIXME: Not actually tested against XSS attacks, implementation needs to be verified in tests. + let subgroup = 'unknown'; + + if (groupBy == 'category') { + subgroup = sanitize(color); + } else if (groupBy == 'bucketType') { + if (bucket.type == 'currentwindow') { + subgroup = sanitize(e.data.app); + } else if (bucket.type == 'web.tab.current') { + subgroup = sanitize((new URL(e.data.url)).hostname.replace('www.','')); + } else if (bucket.type.startsWith('app.editor')) { + subgroup = sanitize(e.data.language); + } else if (bucket.type.startsWith('general.stopwatch')) { + subgroup = sanitize(e.data.label); + } + } + + return subgroup; +} diff --git a/src/views/Timeline.vue b/src/views/Timeline.vue index 228588c6..3462d8a2 100644 --- a/src/views/Timeline.vue +++ b/src/views/Timeline.vue @@ -7,9 +7,15 @@ div // blocks div.d-inline-block.border.rounded.p-2.mr-2 | Events shown: {{ num_events }} + div.d-inline-block.border.rounded.p-2.mr-2 + | Swimlanes: + select(v-model="swimlane") + option(value='null') None + option(value='category') Categories + option(value='bucketType') Bucket Specific details.d-inline-block.bg-light.small.border.rounded.mr-2.px-2 summary.p-2 - b Filters + b Filters: {{ filter_summary }} div.p-2.bg-light table tr @@ -26,12 +32,29 @@ div select(v-model="filter_client") option(:value='null') All option(v-for="client in clients", :value="client") {{ client }} + tr + th.pt-2.pr-3 + label Duration: + td + select(v-model="filter_duration") + option(value='null') All + option(value='2') 2+ secs + option(value='5') 5+ secs + option(value='10') 10+ secs + option(value='30') 30+ sec + option(value='60') 1+ mins + option(value='120') 2+ mins + option(value='180') 3+ mins + option(value='600') 10+ mins + option(value='1800') 30+ mins + option(value='3600') 1+ hrs + option(value='7200') 2+ hrs div(style="float: right; color: #999").d-inline-block.pt-3 | Drag to pan and scroll to zoom div(v-if="buckets !== null") div(style="clear: both") - vis-timeline(:buckets="buckets", :showRowLabels='true', :queriedInterval="daterange") + vis-timeline(:buckets="buckets", :showRowLabels='true', :queriedInterval="daterange", :swimlane="swimlane") aw-devonly(reason="Not ready for production, still experimenting") aw-calendar(:buckets="buckets") @@ -43,6 +66,7 @@ div import _ from 'lodash'; import { useSettingsStore } from '~/stores/settings'; import { useBucketsStore } from '~/stores/buckets'; +import { seconds_to_duration } from '~/util/time'; export default { name: 'Timeline', @@ -56,6 +80,8 @@ export default { maxDuration: 31 * 24 * 60 * 60, filter_hostname: null, filter_client: null, + filter_duration: null, + swimlane: null, }; }, computed: { @@ -66,6 +92,23 @@ export default { num_events() { return _.sumBy(this.buckets, 'events.length'); }, + filter_summary() { + let desc = []; + if (this.filter_hostname) { + desc.push(this.filter_hostname); + } + if (this.filter_client) { + desc.push(this.filter_client); + } + if (this.filter_duration > 0) { + desc.push(seconds_to_duration(this.filter_duration)); + } + + if (desc.length > 0) { + return desc.join(", "); + } + return "none" + } }, watch: { daterange() { @@ -77,6 +120,12 @@ export default { filter_client() { this.getBuckets(); }, + filter_duration() { + this.getBuckets(); + }, + swimlane() { + this.getBuckets(); + }, }, methods: { getBuckets: async function () { @@ -103,6 +152,13 @@ export default { if (this.filter_client) { buckets = _.filter(buckets, b => b.client == this.filter_client); } + + if (this.filter_duration > 0) { + for (const bucket of buckets) { + bucket.events = _.filter(bucket.events, e => e.duration >= this.filter_duration); + } + } + this.buckets = buckets; }, }, diff --git a/src/visualizations/VisTimeline.vue b/src/visualizations/VisTimeline.vue index 8fbbdbcd..43364ce8 100644 --- a/src/visualizations/VisTimeline.vue +++ b/src/visualizations/VisTimeline.vue @@ -39,6 +39,7 @@ import moment from 'moment'; import Color from 'color'; import { buildTooltip } from '../util/tooltip.js'; import { getColorFromString, getTitleAttr } from '../util/color'; +import { getSwimlane } from '../util/swimlane.js'; import { Timeline } from 'vis-timeline/esnext'; import 'vis-timeline/styles/vis-timeline-graph2d.css'; @@ -54,6 +55,7 @@ export default { showRowLabels: { type: Boolean }, queriedInterval: { type: Array }, showQueriedInterval: { type: Boolean }, + swimlane: { type: String }, }, data() { return { @@ -110,13 +112,15 @@ export default { } events.sort((a, b) => a.timestamp.valueOf() - b.timestamp.valueOf()); _.each(events, e => { + let color = getColorFromString(getTitleAttr(bucket, e)); data.push([ bucket.id, getTitleAttr(bucket, e), buildTooltip(bucket, e), new Date(e.timestamp), new Date(moment(e.timestamp).add(e.duration, 'seconds').valueOf()), - getColorFromString(getTitleAttr(bucket, e)), + color, + getSwimlane(bucket, color, this.swimlane, e), e, ]); }); @@ -223,6 +227,7 @@ export default { start: moment(row[3]), end: moment(row[4]), style: `background-color: ${bgColor}; border-color: ${borderColor}`, + subgroup: row[6], }; }); @@ -245,6 +250,7 @@ export default { start: this.queriedInterval[0], end: this.queriedInterval[1], style: 'background-color: #aaa; height: 10px', + subgroup: ``, }); }