Skip to content

Commit e5cb7f0

Browse files
committed
feat: add universal columns vis
If you wanted to visualize a custom watcher, you would need to either use the timeline view or make your own. This makes a universal vis that just shows arbitrary keys from the data that is saved. While it might not be great for some special types of custom watchers, it works really well for watchers such as the lastfm.
1 parent 6e3c4d8 commit e5cb7f0

File tree

4 files changed

+273
-1
lines changed

4 files changed

+273
-1
lines changed

src/components/SelectableVisualization.vue

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,13 @@ div
9090
:namefunc="e => e.data.label",
9191
:colorfunc="e => e.data.label",
9292
with_limit)
93+
div(v-if="type == 'watcher_columns'")
94+
aw-watcher-columns(
95+
:initialBucketId="props ? props.bucketId : ''",
96+
:initialField="props ? props.field : ''",
97+
:initialCustomField="props ? props.customField : ''",
98+
@update-props="onWatcherPropsChange"
99+
)
93100
</template>
94101

95102
<style lang="scss">
@@ -118,6 +125,7 @@ import { build_category_hierarchy } from '~/util/classes';
118125
import { useActivityStore } from '~/stores/activity';
119126
import { useCategoryStore } from '~/stores/categories';
120127
import { useBucketsStore } from '~/stores/buckets';
128+
import { useViewsStore } from '~/stores/views';
121129
122130
import moment from 'moment';
123131
@@ -133,6 +141,7 @@ export default {
133141
id: Number,
134142
type: String,
135143
props: Object,
144+
viewId: { type: String, default: '' },
136145
editable: { type: Boolean, default: true },
137146
},
138147
data: function () {
@@ -158,6 +167,7 @@ export default {
158167
'vis_timeline',
159168
'score',
160169
'top_stopwatches',
170+
'watcher_columns'
161171
],
162172
// TODO: Move this function somewhere else
163173
top_editor_files_namefunc: e => {
@@ -251,6 +261,10 @@ export default {
251261
title: 'Top Stopwatch Events',
252262
available: this.activityStore.stopwatch.available,
253263
},
264+
watcher_columns: {
265+
title: 'Watcher Columns',
266+
available: true,
267+
},
254268
};
255269
},
256270
has_prerequisites() {
@@ -325,6 +339,16 @@ export default {
325339
}
326340
},
327341
methods: {
342+
onWatcherPropsChange(newProps) {
343+
if (!this.viewId) return;
344+
const mergedProps = { ...(this.props || {}), ...newProps };
345+
useViewsStore().editView({
346+
view_id: this.viewId,
347+
el_id: this.id,
348+
type: this.type,
349+
props: mergedProps,
350+
});
351+
},
328352
getTimelineBuckets: async function () {
329353
if (this.type != 'vis_timeline') return;
330354
if (!this.timeline_daterange) return;

src/main.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ Vue.component('aw-summary', () => import('./visualizations/Summary.vue'));
5252
Vue.component('aw-periodusage', () => import('./visualizations/PeriodUsage.vue'));
5353
Vue.component('aw-eventlist', () => import('./visualizations/EventList.vue'));
5454
Vue.component('aw-sunburst-categories', () => import('./visualizations/SunburstCategories.vue'));
55+
Vue.component('aw-watcher-columns', () => import('./visualizations/WatcherColumns.vue'));
5556
Vue.component('aw-sunburst-clock', () => import('./visualizations/SunburstClock.vue'));
5657
Vue.component('aw-timeline-inspect', () => import('./visualizations/TimelineInspect.vue'));
5758
Vue.component('aw-timeline', () => import('./visualizations/TimelineSimple.vue'));

src/views/activity/ActivityView.vue

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ div(v-if="view")
33
draggable.row(v-model="elements" handle=".handle")
44
// TODO: Handle large/variable sized visualizations better
55
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)}")
6-
aw-selectable-vis(:id="index" :type="el.type" :props="el.props" @onTypeChange="onTypeChange" @onRemove="onRemove" :editable="editing")
6+
aw-selectable-vis(:id="index" :type="el.type" :props="el.props" :view-id="view.id" @onTypeChange="onTypeChange" @onRemove="onRemove" :editable="editing")
77

88
div.col-md-6.col-lg-4.p-3(v-if="editing")
99
b-button(@click="addVisualization" variant="outline-dark" block size="lg")
Lines changed: 247 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,247 @@
1+
<template lang="pug">
2+
div
3+
b-row
4+
b-col(cols="12", md="6").mb-2
5+
b-form-group(label="Watcher (bucket)")
6+
b-form-select(
7+
v-model="selectedBucketId",
8+
:options="bucketOptions",
9+
:disabled="bucketOptions.length === 0 || loading"
10+
)
11+
b-col(cols="12", md="6").mb-2
12+
b-form-group
13+
template(#label)
14+
span Field in event data
15+
span.info-icon(
16+
title="Field names come from event data. Dot notation is supported (e.g., data.title)."
17+
) i
18+
b-form-select(
19+
v-model="selectedField",
20+
:options="fieldSelectOptions",
21+
:disabled="!selectedBucketId || loading"
22+
)
23+
b-form-input.mt-2(
24+
v-if="selectedField === '__custom' || fieldOptions.length === 0",
25+
v-model="customField",
26+
placeholder="e.g. data.title",
27+
:disabled="loading"
28+
)
29+
b-alert.mt-2(v-if="error", show, variant="danger") {{ error }}
30+
b-alert.mt-2(v-else-if="!selectedBucketId" show variant="info")
31+
| Select a watcher to load events for this period.
32+
b-alert.mt-2(v-else-if="!loading && aggregated.length === 0" show variant="warning")
33+
| No events found for this watcher and time range.
34+
35+
div.mt-2
36+
div.text-center.py-4(v-if="loading")
37+
b-spinner(small type="grow" label="Loading")
38+
span.ml-2 Loading events...
39+
aw-summary(
40+
v-else-if="aggregated.length",
41+
:fields="aggregated",
42+
:namefunc="namefunc",
43+
:hoverfunc="hoverfunc",
44+
:colorfunc="colorfunc",
45+
with_limit
46+
)
47+
div.text-muted.text-center.py-4(v-else)
48+
| Pick a field to see results.
49+
</template>
50+
51+
<script lang="ts">
52+
import _ from 'lodash';
53+
import moment from 'moment';
54+
import { useActivityStore } from '~/stores/activity';
55+
import { useBucketsStore } from '~/stores/buckets';
56+
import { getClient } from '~/util/awclient';
57+
interface AggregatedEvent {
58+
duration: number;
59+
data: Record<string, any>;
60+
}
61+
function formatValue(value: unknown): string {
62+
if (Array.isArray(value)) return value.join(' > ');
63+
if (value === null || value === undefined) return 'Unknown';
64+
if (typeof value === 'object') return JSON.stringify(value);
65+
return String(value);
66+
}
67+
export default {
68+
name: 'aw-watcher-columns',
69+
props: {
70+
initialBucketId: { type: String, default: '' },
71+
initialField: { type: String, default: '' },
72+
initialCustomField: { type: String, default: '' },
73+
},
74+
data() {
75+
return {
76+
bucketsStore: useBucketsStore(),
77+
activityStore: useActivityStore(),
78+
selectedBucketId: this.initialBucketId || '',
79+
selectedField: this.initialField || '',
80+
customField: this.initialCustomField || '',
81+
fieldOptions: [] as string[],
82+
events: [] as any[],
83+
aggregated: [] as AggregatedEvent[],
84+
loading: false,
85+
error: '',
86+
};
87+
},
88+
computed: {
89+
bucketOptions(): { value: string; text: string }[] {
90+
return this.bucketsStore.buckets.map(b => ({
91+
value: b.id,
92+
text: `${b.id} (${b.type || 'unknown'})`,
93+
}));
94+
},
95+
fieldSelectOptions(): { value: string; text: string }[] {
96+
const options = this.fieldOptions.map(f => ({ value: f, text: f }));
97+
options.push({ value: '__custom', text: 'Custom field…' });
98+
return options;
99+
},
100+
selectedFieldValue(): string {
101+
if (this.selectedField === '__custom') return this.customField;
102+
return this.selectedField;
103+
},
104+
timeRange(): { start: string; end: string } | null {
105+
const opts = this.activityStore.query_options;
106+
if (!opts || !opts.timeperiod) return null;
107+
const start = opts.timeperiod.start;
108+
const end = moment(start)
109+
.add(...opts.timeperiod.length)
110+
.toISOString();
111+
return { start, end };
112+
},
113+
namefunc(): (e: AggregatedEvent) => string {
114+
return e => e.data.display;
115+
},
116+
hoverfunc(): (e: AggregatedEvent) => string {
117+
return e => e.data.display;
118+
},
119+
colorfunc(): (e: AggregatedEvent) => string {
120+
return e => e.data.colorKey;
121+
},
122+
},
123+
watch: {
124+
selectedBucketId: function () {
125+
this.emitSelection();
126+
this.loadEvents();
127+
},
128+
selectedField: function () {
129+
this.emitSelection();
130+
this.aggregate();
131+
},
132+
customField: function () {
133+
if (this.selectedField === '__custom') {
134+
this.emitSelection();
135+
this.aggregate();
136+
}
137+
},
138+
timeRange: {
139+
handler() {
140+
this.loadEvents();
141+
},
142+
deep: true,
143+
},
144+
},
145+
async mounted() {
146+
await this.bucketsStore.ensureLoaded();
147+
this.setDefaultBucket();
148+
this.emitSelection();
149+
if (this.selectedBucketId) {
150+
await this.loadEvents();
151+
}
152+
},
153+
methods: {
154+
setDefaultBucket() {
155+
if (this.selectedBucketId) return;
156+
const host = this.activityStore.query_options && this.activityStore.query_options.host;
157+
const byHost = host ? this.bucketsStore.buckets.filter(b => b.hostname === host) : [];
158+
if (byHost.length > 0) {
159+
this.selectedBucketId = byHost[0].id;
160+
} else if (this.bucketsStore.buckets.length > 0) {
161+
this.selectedBucketId = this.bucketsStore.buckets[0].id;
162+
}
163+
},
164+
async loadEvents() {
165+
if (!this.selectedBucketId || !this.timeRange) return;
166+
this.loading = true;
167+
this.error = '';
168+
this.aggregated = [];
169+
try {
170+
this.events = await getClient().getEvents(this.selectedBucketId, {
171+
start: this.timeRange.start,
172+
end: this.timeRange.end,
173+
limit: -1,
174+
});
175+
this.fieldOptions = this.extractFields(this.events);
176+
if (!this.selectedField && this.fieldOptions.length > 0) {
177+
this.selectedField = this.fieldOptions[0];
178+
} else if (!this.selectedField && this.fieldOptions.length === 0) {
179+
this.selectedField = '__custom';
180+
}
181+
this.aggregate();
182+
} catch (err) {
183+
console.error(err);
184+
this.events = [];
185+
this.fieldOptions = [];
186+
this.error = err?.message || 'Failed to load events for the selected watcher.';
187+
} finally {
188+
this.loading = false;
189+
}
190+
},
191+
extractFields(events: any[]): string[] {
192+
const keys = new Set<string>();
193+
events.slice(0, 50).forEach(e => {
194+
Object.keys(e.data || {}).forEach(k => keys.add(k));
195+
});
196+
return Array.from(keys).sort();
197+
},
198+
aggregate() {
199+
if (!this.selectedFieldValue) {
200+
this.aggregated = [];
201+
return;
202+
}
203+
const path = this.selectedFieldValue.startsWith('data.')
204+
? this.selectedFieldValue
205+
: `data.${this.selectedFieldValue}`;
206+
const grouped = new Map<string, AggregatedEvent>();
207+
this.events.forEach(e => {
208+
const value = _.get(e, path);
209+
const display = formatValue(value);
210+
const key = Array.isArray(value) ? JSON.stringify(value) : String(display);
211+
if (!grouped.has(key)) {
212+
grouped.set(key, {
213+
duration: 0,
214+
data: { display, raw: value, colorKey: display },
215+
});
216+
}
217+
const entry = grouped.get(key);
218+
entry.duration += e.duration || 0;
219+
});
220+
this.aggregated = Array.from(grouped.values()).sort((a, b) => b.duration - a.duration);
221+
},
222+
emitSelection() {
223+
this.$emit('update-props', {
224+
bucketId: this.selectedBucketId,
225+
field: this.selectedField === '__custom' ? '__custom' : this.selectedField,
226+
customField: this.selectedField === '__custom' ? this.customField : '',
227+
});
228+
},
229+
},
230+
};
231+
</script>
232+
233+
<style scoped>
234+
.info-icon {
235+
display: inline-flex;
236+
align-items: center;
237+
justify-content: center;
238+
margin-left: 0.25rem;
239+
width: 1.15em;
240+
height: 1.15em;
241+
border: 1px solid #888;
242+
border-radius: 50%;
243+
font-size: 0.75em;
244+
color: #555;
245+
cursor: help;
246+
}
247+
</style>

0 commit comments

Comments
 (0)