Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions src/components/SelectableVisualization.vue
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,13 @@ div
:namefunc="e => e.data.label",
:colorfunc="e => e.data.label",
with_limit)
div(v-if="type == 'top_bucket_data'")
aw-top-bucket-data(
:initialBucketId="props ? props.bucketId : ''",
:initialField="props ? props.field : ''",
:initialCustomField="props ? props.customField : ''",
@update-props="onWatcherPropsChange"
)
</template>

<style lang="scss">
Expand Down Expand Up @@ -118,6 +125,7 @@ import { build_category_hierarchy } from '~/util/classes';
import { useActivityStore } from '~/stores/activity';
import { useCategoryStore } from '~/stores/categories';
import { useBucketsStore } from '~/stores/buckets';
import { useViewsStore } from '~/stores/views';

import moment from 'moment';

Expand All @@ -133,6 +141,7 @@ export default {
id: Number,
type: String,
props: Object,
viewId: { type: String, default: '' },
editable: { type: Boolean, default: true },
},
data: function () {
Expand All @@ -158,6 +167,7 @@ export default {
'vis_timeline',
'score',
'top_stopwatches',
'top_bucket_data',
],
// TODO: Move this function somewhere else
top_editor_files_namefunc: e => {
Expand Down Expand Up @@ -251,6 +261,10 @@ export default {
title: 'Top Stopwatch Events',
available: this.activityStore.stopwatch.available,
},
top_bucket_data: {
title: 'Top Bucket Data',
available: true,
},
};
},
has_prerequisites() {
Expand Down Expand Up @@ -325,6 +339,16 @@ export default {
}
},
methods: {
onWatcherPropsChange(newProps) {
if (!this.viewId) return;
const mergedProps = { ...(this.props || {}), ...newProps };
useViewsStore().editView({
view_id: this.viewId,
el_id: this.id,
type: this.type,
props: mergedProps,
});
},
getTimelineBuckets: async function () {
if (this.type != 'vis_timeline') return;
if (!this.timeline_daterange) return;
Expand Down
1 change: 1 addition & 0 deletions src/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ Vue.component('aw-summary', () => import('./visualizations/Summary.vue'));
Vue.component('aw-periodusage', () => import('./visualizations/PeriodUsage.vue'));
Vue.component('aw-eventlist', () => import('./visualizations/EventList.vue'));
Vue.component('aw-sunburst-categories', () => import('./visualizations/SunburstCategories.vue'));
Vue.component('aw-top-bucket-data', () => import('./visualizations/TopBucketData.vue'));
Vue.component('aw-sunburst-clock', () => import('./visualizations/SunburstClock.vue'));
Vue.component('aw-timeline-inspect', () => import('./visualizations/TimelineInspect.vue'));
Vue.component('aw-timeline', () => import('./visualizations/TimelineSimple.vue'));
Expand Down
2 changes: 1 addition & 1 deletion src/views/activity/ActivityView.vue
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ div(v-if="view")
draggable.row(v-model="elements" handle=".handle")
// TODO: Handle large/variable sized visualizations better
div.col-md-6.col-lg-4.p-3(v-for="el, index in elements", :key="index", :class="{'col-md-12': isVisLarge(el), 'col-lg-12': isVisLarge(el)}")
aw-selectable-vis(:id="index" :type="el.type" :props="el.props" @onTypeChange="onTypeChange" @onRemove="onRemove" :editable="editing")
aw-selectable-vis(:id="index" :type="el.type" :props="el.props" :view-id="view.id" @onTypeChange="onTypeChange" @onRemove="onRemove" :editable="editing")

div.col-md-6.col-lg-4.p-3(v-if="editing")
b-button(@click="addVisualization" variant="outline-dark" block size="lg")
Expand Down
246 changes: 246 additions & 0 deletions src/visualizations/TopBucketData.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,246 @@
<template lang="pug">
div
b-row
b-col(cols="12", md="6").mb-2
b-form-group(label="Bucket")
b-form-select(
v-model="selectedBucketId",
:options="bucketOptions",
:disabled="bucketOptions.length === 0 || loading"
)
b-col(cols="12", md="6").mb-2
b-form-group
template(#label)
span Field in event data
span.info-icon(
title="Field names come from event data. Dot notation is supported (e.g., data.title)."
) i
b-form-select(
v-model="selectedField",
:options="fieldSelectOptions",
:disabled="!selectedBucketId || loading"
)
b-form-input.mt-2(
v-if="selectedField === '__custom' || fieldOptions.length === 0",
v-model="customField",
placeholder="e.g. data.title",
:disabled="loading"
)
b-alert.mt-2(v-if="error", show, variant="danger") {{ error }}
b-alert.mt-2(v-else-if="!selectedBucketId" show variant="info")
| Select a watcher to load events for this period.
b-alert.mt-2(v-else-if="!loading && aggregated.length === 0" show variant="warning")
| No events found for this watcher and time range.

div.mt-2
div.text-center.py-4(v-if="loading")
b-spinner(small type="grow" label="Loading")
span.ml-2 Loading events...
aw-summary(
v-else-if="aggregated.length",
:fields="aggregated",
:namefunc="namefunc",
:hoverfunc="hoverfunc",
:colorfunc="colorfunc",
with_limit
)
div.text-muted.text-center.py-4(v-else)
| Pick a field to see results.
</template>

<script lang="ts">
import _ from 'lodash';
import moment from 'moment';
import { useActivityStore } from '~/stores/activity';
import { useBucketsStore } from '~/stores/buckets';
import { getClient } from '~/util/awclient';
interface AggregatedEvent {
duration: number;
data: Record<string, any>;
}
function formatValue(value: unknown): string {
if (Array.isArray(value)) return value.join(' > ');
if (value === null || value === undefined) return 'Unknown';
if (typeof value === 'object') return JSON.stringify(value);
return String(value);
}
export default {
name: 'aw-top-bucket-data',
props: {
initialBucketId: { type: String, default: '' },
initialField: { type: String, default: '' },
initialCustomField: { type: String, default: '' },
},
data() {
return {
bucketsStore: useBucketsStore(),
activityStore: useActivityStore(),
selectedBucketId: this.initialBucketId || '',
selectedField: this.initialField || '',
customField: this.initialCustomField || '',
fieldOptions: [] as string[],
events: [] as any[],
aggregated: [] as AggregatedEvent[],
loading: false,
error: '',
};
},
computed: {
bucketOptions(): { value: string; text: string }[] {
return this.bucketsStore.buckets.map(b => ({
value: b.id,
text: `${b.id} (${b.type || 'unknown'})`,
}));
},
fieldSelectOptions(): { value: string; text: string }[] {
const options = this.fieldOptions.map(f => ({ value: f, text: f }));
options.push({ value: '__custom', text: 'Custom field…' });
return options;
},
selectedFieldValue(): string {
if (this.selectedField === '__custom') return this.customField;
return this.selectedField;
},
timeRange(): { start: string; end: string } | null {
const opts = this.activityStore.query_options;
if (!opts || !opts.timeperiod) return null;
const start = opts.timeperiod.start;
const end = moment(start)
.add(...opts.timeperiod.length)
.toISOString();
return { start, end };
},
namefunc(): (e: AggregatedEvent) => string {
return e => e.data.display;
},
hoverfunc(): (e: AggregatedEvent) => string {
return e => e.data.display;
},
colorfunc(): (e: AggregatedEvent) => string {
return e => e.data.colorKey;
},
},
watch: {
selectedBucketId: function () {
this.emitSelection();
this.loadEvents();
},
selectedField: function () {
this.emitSelection();
this.aggregate();
},
customField: function () {
if (this.selectedField === '__custom') {
this.emitSelection();
this.aggregate();
}
},
timeRange() {
this.loadEvents();
},
},
async mounted() {
await this.bucketsStore.ensureLoaded();
this.setDefaultBucket();
this.emitSelection();
if (this.selectedBucketId) {
await this.loadEvents();
}
},
methods: {
setDefaultBucket() {
if (this.selectedBucketId) return;
const host = this.activityStore.query_options && this.activityStore.query_options.host;
const byHost = host ? this.bucketsStore.buckets.filter(b => b.hostname === host) : [];
if (byHost.length > 0) {
this.selectedBucketId = byHost[0].id;
} else if (this.bucketsStore.buckets.length > 0) {
this.selectedBucketId = this.bucketsStore.buckets[0].id;
}
},
async loadEvents() {
if (!this.selectedBucketId || !this.timeRange) return;
this.loading = true;
this.error = '';
this.aggregated = [];
try {
this.events = await getClient().getEvents(this.selectedBucketId, {
start: this.timeRange.start,
end: this.timeRange.end,
limit: -1,
});
this.fieldOptions = this.extractFields(this.events);
if (!this.selectedField && this.fieldOptions.length > 0) {
this.selectedField = this.fieldOptions[0];
} else if (!this.selectedField && this.fieldOptions.length === 0) {
this.selectedField = '__custom';
}
this.aggregate();
} catch (err) {
console.error(err);
this.events = [];
this.fieldOptions = [];
this.error = err?.message || 'Failed to load events for the selected watcher.';
} finally {
this.loading = false;
}
Comment on lines +161 to +186
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The loadEvents method could be called multiple times in quick succession (e.g., when both bucket and time range change). This can lead to race conditions where older requests complete after newer ones, causing the wrong data to be displayed.

Consider adding request cancellation or tracking the latest request to ignore stale responses:

Suggested change
async loadEvents() {
if (!this.selectedBucketId || !this.timeRange) return;
this.loading = true;
this.error = '';
this.aggregated = [];
try {
this.events = await getClient().getEvents(this.selectedBucketId, {
start: this.timeRange.start,
end: this.timeRange.end,
limit: -1,
});
this.fieldOptions = this.extractFields(this.events);
if (!this.selectedField && this.fieldOptions.length > 0) {
this.selectedField = this.fieldOptions[0];
} else if (!this.selectedField && this.fieldOptions.length === 0) {
this.selectedField = '__custom';
}
this.aggregate();
} catch (err) {
console.error(err);
this.events = [];
this.fieldOptions = [];
this.error = err?.message || 'Failed to load events for the selected watcher.';
} finally {
this.loading = false;
}
async loadEvents() {
if (!this.selectedBucketId || !this.timeRange) return;
// Track this request to ignore stale responses
const requestId = Date.now();
this.currentRequestId = requestId;
this.loading = true;
this.error = '';
this.aggregated = [];
try {
this.events = await getClient().getEvents(this.selectedBucketId, {
start: this.timeRange.start,
end: this.timeRange.end,
limit: -1,
});
// Ignore if a newer request has been initiated
if (this.currentRequestId !== requestId) return;
this.fieldOptions = this.extractFields(this.events);
if (!this.selectedField && this.fieldOptions.length > 0) {
this.selectedField = this.fieldOptions[0];
} else if (!this.selectedField && this.fieldOptions.length === 0) {
this.selectedField = '__custom';
}
this.aggregate();
} catch (err) {
if (this.currentRequestId !== requestId) return;
console.error(err);
this.events = [];
this.fieldOptions = [];
this.error = err?.message || 'Failed to load events for the selected watcher.';
} finally {
if (this.currentRequestId === requestId) {
this.loading = false;
}
}
},

You would also need to add currentRequestId: 0 to the data() return object.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/visualizations/WatcherColumns.vue
Line: 161:186

Comment:
The `loadEvents` method could be called multiple times in quick succession (e.g., when both bucket and time range change). This can lead to race conditions where older requests complete after newer ones, causing the wrong data to be displayed.

Consider adding request cancellation or tracking the latest request to ignore stale responses:

```suggestion
    async loadEvents() {
      if (!this.selectedBucketId || !this.timeRange) return;
      
      // Track this request to ignore stale responses
      const requestId = Date.now();
      this.currentRequestId = requestId;
      
      this.loading = true;
      this.error = '';
      this.aggregated = [];
      try {
        this.events = await getClient().getEvents(this.selectedBucketId, {
          start: this.timeRange.start,
          end: this.timeRange.end,
          limit: -1,
        });
        
        // Ignore if a newer request has been initiated
        if (this.currentRequestId !== requestId) return;
        
        this.fieldOptions = this.extractFields(this.events);
        if (!this.selectedField && this.fieldOptions.length > 0) {
          this.selectedField = this.fieldOptions[0];
        } else if (!this.selectedField && this.fieldOptions.length === 0) {
          this.selectedField = '__custom';
        }
        this.aggregate();
      } catch (err) {
        if (this.currentRequestId !== requestId) return;
        console.error(err);
        this.events = [];
        this.fieldOptions = [];
        this.error = err?.message || 'Failed to load events for the selected watcher.';
      } finally {
        if (this.currentRequestId === requestId) {
          this.loading = false;
        }
      }
    },
```

You would also need to add `currentRequestId: 0` to the data() return object.

How can I resolve this? If you propose a fix, please make it concise.

},
extractFields(events: any[]): string[] {
// Sample first 100 events for field discovery (performance vs coverage tradeoff)
const keys = new Set<string>();
events.slice(0, 100).forEach(e => {
Object.keys(e.data || {}).forEach(k => keys.add(k));
});
return Array.from(keys).sort();
},
aggregate() {
const fieldValue = (this.selectedFieldValue || '').trim();
if (!fieldValue) {
this.aggregated = [];
return;
}
// Even though the user should start the manual data fields with "data", we want
// to gracefully let it pass. Chances are there are no nested data.data.title.
const path = fieldValue.startsWith('data.') ? fieldValue : `data.${fieldValue}`;
const grouped = new Map<string, AggregatedEvent>();
this.events.forEach(e => {
const value = _.get(e, path);
const display = formatValue(value);
const key = Array.isArray(value) ? JSON.stringify(value) : String(display);
if (!grouped.has(key)) {
grouped.set(key, {
duration: 0,
data: { display, raw: value, colorKey: display },
});
}
const entry = grouped.get(key);
entry.duration += e.duration || 0;
});
this.aggregated = Array.from(grouped.values()).sort((a, b) => b.duration - a.duration);
},
emitSelection() {
this.$emit('update-props', {
bucketId: this.selectedBucketId,
field: this.selectedField === '__custom' ? '__custom' : this.selectedField,
customField: this.selectedField === '__custom' ? this.customField : '',
});
},
},
};
</script>

<style scoped>
.info-icon {
display: inline-flex;
align-items: center;
justify-content: center;
margin-left: 0.25rem;
width: 1.15em;
height: 1.15em;
border: 1px solid #888;
border-radius: 50%;
font-size: 0.75em;
color: #555;
cursor: help;
}
</style>
Loading