Skip to content

Commit 7ef95a1

Browse files
authored
Merge pull request #52 from ksy36/issue/51/1
Fixes #51: Adds a command to detect spikes and a view to display results
2 parents 39a5bb8 + 1f55cf2 commit 7ef95a1

8 files changed

Lines changed: 452 additions & 1 deletion

File tree

server/frontend/src/api.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,3 +48,6 @@ export const dismissNotification = async (id) =>
4848

4949
export const dismissAllNotifications = async () =>
5050
(await mainAxios.patch("/reportmanager/rest/inbox/mark_all_as_read/")).data;
51+
52+
export const getCurrentBucketSpikes = async (params) =>
53+
(await mainAxios.get("/reportmanager/rest/bucket-spikes/", { params })).data;
Lines changed: 213 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,213 @@
1+
<template>
2+
<div class="panel panel-default">
3+
<div class="panel-heading"><i class="bi bi-activity"></i> Spikes</div>
4+
<div class="panel-body">
5+
<div class="parameters-section">
6+
<div class="row">
7+
<div class="col-md-3">
8+
<div class="form-group">
9+
<label
10+
>Min reports with non-empty comments:
11+
<strong>{{ minCommentsFilter }}</strong></label
12+
>
13+
<input
14+
type="range"
15+
class="form-control-range"
16+
v-model.number="minCommentsFilter"
17+
:min="0"
18+
:max="maxCommentsCount"
19+
step="1"
20+
/>
21+
</div>
22+
</div>
23+
<div class="col-md-3">
24+
<div class="form-group">
25+
<label
26+
>Short Window (days): <strong>{{ shortWindow }}</strong></label
27+
>
28+
<input
29+
type="range"
30+
class="form-control-range"
31+
v-model.number="shortWindow"
32+
:min="1"
33+
:max="14"
34+
step="1"
35+
v-on:change="reloadSpikes"
36+
/>
37+
</div>
38+
</div>
39+
<div class="col-md-3">
40+
<div class="form-group">
41+
<label
42+
>Long Window (days): <strong>{{ longWindow }}</strong></label
43+
>
44+
<input
45+
type="range"
46+
class="form-control-range"
47+
v-model.number="longWindow"
48+
:min="7"
49+
:max="90"
50+
step="1"
51+
v-on:change="reloadSpikes"
52+
/>
53+
</div>
54+
</div>
55+
</div>
56+
</div>
57+
<div v-if="selectedPeriod">
58+
<p>
59+
{{ filteredSpikes.length }} spikes in window
60+
<strong>{{ shortWindowPeriod }}</strong>
61+
</p>
62+
</div>
63+
64+
<div v-if="loading" class="text-center">
65+
<i class="bi bi-hourglass-split"></i> Loading...
66+
</div>
67+
<div v-else-if="filteredSpikes.length === 0" class="text-muted">
68+
No spikes detected with selected parameters.
69+
</div>
70+
</div>
71+
<div v-if="!loading && filteredSpikes.length > 0" class="table-responsive">
72+
<table class="table table-condensed table-bordered table-db">
73+
<thead>
74+
<tr>
75+
<th>Bucket</th>
76+
<th>Domain</th>
77+
<th class="text-right"># of reports</th>
78+
<th class="text-right"># of reports with comments</th>
79+
<th class="text-right">Short Avg</th>
80+
<th class="text-right">Long Avg</th>
81+
<th class="text-right">Ratio</th>
82+
<th>Comments</th>
83+
</tr>
84+
</thead>
85+
<tbody>
86+
<tr v-for="spike in filteredSpikes" :key="spike.id">
87+
<td>
88+
<a :href="spike.bucket_view_url">{{ spike.bucket_id }}</a>
89+
</td>
90+
<td>{{ spike.bucket_domain || "(no domain)" }}</td>
91+
<td class="text-right">{{ spike.short_count }}</td>
92+
<td class="text-right">{{ spike.short_count_with_comments }}</td>
93+
<td class="text-right">{{ spike.short_average.toFixed(2) }}</td>
94+
<td class="text-right">{{ spike.long_average.toFixed(2) }}</td>
95+
<td class="text-right">{{ spike.ratio.toFixed(2) }}</td>
96+
<td class="comments-cell">
97+
<ol
98+
v-if="spike.report_comments && spike.report_comments.length > 0"
99+
>
100+
<li
101+
v-for="(comment, index) in spike.report_comments"
102+
:key="index"
103+
>
104+
{{ comment }}
105+
</li>
106+
</ol>
107+
<span v-else>—</span>
108+
</td>
109+
</tr>
110+
</tbody>
111+
</table>
112+
</div>
113+
</div>
114+
</template>
115+
116+
<script>
117+
import * as api from "../../api";
118+
119+
export default {
120+
name: "SpikesList",
121+
data() {
122+
return {
123+
loading: false,
124+
selectedPeriod: null,
125+
spikes: [],
126+
minCommentsFilter: 3,
127+
shortWindow: 2,
128+
longWindow: 60,
129+
threshold: 1.5,
130+
minTotalReports: 10,
131+
};
132+
},
133+
computed: {
134+
shortWindowPeriod() {
135+
if (!this.selectedPeriod) return "No period selected";
136+
return `${this.selectedPeriod.short_window_start} to ${this.selectedPeriod.short_window_end}`;
137+
},
138+
filteredSpikes() {
139+
return this.spikes.filter(
140+
(spike) => spike.short_count_with_comments >= this.minCommentsFilter,
141+
);
142+
},
143+
maxCommentsCount() {
144+
if (this.spikes.length === 0) return 0;
145+
return Math.max(...this.spikes.map((s) => s.short_count_with_comments));
146+
},
147+
},
148+
async mounted() {
149+
await this.fetchLatestRun();
150+
},
151+
methods: {
152+
async fetchLatestRun() {
153+
this.loading = true;
154+
try {
155+
const params = {
156+
short_window: this.shortWindow,
157+
long_window: this.longWindow,
158+
threshold: this.threshold,
159+
min_reports: this.minTotalReports,
160+
};
161+
162+
const data = await api.getCurrentBucketSpikes(params);
163+
164+
if (data) {
165+
this.selectedPeriod = {
166+
short_window_start: data.short_window_start,
167+
short_window_end: data.short_window_end,
168+
};
169+
this.spikes = data.spikes || [];
170+
}
171+
} catch (err) {
172+
console.error("Error fetching spikes:", err);
173+
}
174+
this.loading = false;
175+
},
176+
async reloadSpikes() {
177+
clearTimeout(this._reloadTimeout);
178+
this._reloadTimeout = setTimeout(() => {
179+
this.fetchLatestRun();
180+
}, 500);
181+
},
182+
},
183+
};
184+
</script>
185+
186+
<style scoped>
187+
.parameters-section {
188+
background-color: #f7f7f7;
189+
padding: 15px;
190+
border-radius: 5px;
191+
margin-bottom: 20px;
192+
}
193+
194+
.form-control-range {
195+
width: 100%;
196+
}
197+
198+
.comments-cell {
199+
max-width: 400px;
200+
white-space: normal;
201+
word-break: break-word;
202+
font-size: 0.9em;
203+
}
204+
205+
.comments-cell ol {
206+
margin: 0;
207+
padding-left: 20px;
208+
}
209+
210+
.comments-cell li {
211+
margin-bottom: 5px;
212+
}
213+
</style>

server/frontend/src/main.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import Inbox from "./components/Notifications/Inbox.vue";
1515
import ProviderKey from "./components/ProviderKey.vue";
1616
import BucketView from "./components/Buckets/View.vue";
1717
import BucketList from "./components/Buckets/List.vue";
18+
import SpikesList from "./components/Spikes/List.vue";
1819

1920
import "vue-popperjs/dist/vue-popper.css";
2021

@@ -37,6 +38,7 @@ document.addEventListener("DOMContentLoaded", function () {
3738
providerkey: ProviderKey,
3839
bucketlist: BucketList,
3940
bucketview: BucketView,
41+
spikeslist: SpikesList,
4042
},
4143
router,
4244
});

server/reportmanager/serializers.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -311,3 +311,21 @@ def get_external_bug_url(self, notification):
311311
f"/{notification.target.external_id}"
312312
)
313313
return None
314+
315+
316+
class BucketSpikeSerializer(serializers.Serializer):
317+
bucket_id = serializers.IntegerField()
318+
bucket_domain = serializers.CharField(allow_null=True)
319+
bucket_view_url = serializers.SerializerMethodField()
320+
short_count = serializers.IntegerField()
321+
short_count_with_comments = serializers.IntegerField()
322+
short_average = serializers.FloatField()
323+
long_average = serializers.FloatField()
324+
long_count = serializers.IntegerField()
325+
ratio = serializers.FloatField()
326+
short_window_start = serializers.DateField()
327+
short_window_end = serializers.DateField()
328+
report_comments = serializers.ListField(child=serializers.CharField())
329+
330+
def get_bucket_view_url(self, obj):
331+
return reverse("reportmanager:bucketview", kwargs={"sig_id": obj["bucket_id"]})
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
{% extends 'layouts/layout_base.html' %}
2+
3+
{% block title %}Spike Detection{% endblock title %}
4+
5+
{% block body_content %}
6+
<spikeslist />
7+
{% endblock body_content %}

server/reportmanager/templates/shared/header.html

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
<li><a href="{% url 'reportmanager:bucketwatch' %}"><i class="bi bi-binoculars-fill"></i> Watches</a></li>
2525
<li><a href="{% url 'reportmanager:stats' %}"><i class="bi bi-bar-chart-fill"></i> Statistics</a></li>
2626
<li><a href="{% url 'reportmanager:bugproviders' %}"><i class="bi bi-bug-fill"></i> Providers</a></li>
27+
<li><a href="{% url 'reportmanager:spikes' %}"><i class="bi bi-graph-up"></i> Spikes</a></li>
2728
{% endif %}
2829
</ul>
2930
<ul class="nav navbar-nav navbar-right">

server/reportmanager/urls.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,11 @@
1515
)
1616
router.register(r"inbox", views.NotificationViewSet, basename="inbox")
1717
router.register(r"reports", views.ReportEntryViewSet, basename="reports")
18+
router.register(
19+
r"bucket-spikes",
20+
views.BucketSpikeViewSet,
21+
basename="bucket-spikes",
22+
)
1823

1924
app_name = "reportmanager"
2025
urlpatterns = [
@@ -134,6 +139,7 @@
134139
r"^buckets/watch/create/$", views.bucket_watch_create, name="createbucketwatch"
135140
),
136141
re_path(r"^buckets/create/$", views.signature_create, name="createbucket"),
142+
re_path(r"^spikes/$", views.spike_list_view, name="spikes"),
137143
re_path(r"^stats/$", views.stats, name="stats"),
138144
re_path(
139145
r"^usersettings/$", views.UserSettingsEditView.as_view(), name="usersettings"

0 commit comments

Comments
 (0)