Skip to content

Commit ebde11b

Browse files
committed
Add heartbeat monitor
1 parent cb13a16 commit ebde11b

File tree

11 files changed

+379
-21
lines changed

11 files changed

+379
-21
lines changed

api/pkg/emails/hermes_user_email_factory.go

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,17 @@ func (factory *hermesUserEmailFactory) PhoneDead(user *entities.User, lastHeartb
120120
fmt.Sprintf("We haven't received any heartbeat event from your mobile phone %s since %s.", owner, lastHeartbeatTimestamp.Format(time.RFC1123)),
121121
fmt.Sprintf("Check if the mobile phone is powered on and if it has stable internet connection."),
122122
},
123+
Actions: []hermes.Action{
124+
{
125+
Instructions: "Click the button below to upgrade your plan and continue sending more messages",
126+
Button: hermes.Button{
127+
Color: "#329ef4",
128+
TextColor: "#FFFFFF",
129+
Text: "HEARTBEATS",
130+
Link: fmt.Sprintf("https://httpsms.com/heartbeats/%s", owner),
131+
},
132+
},
133+
},
123134
Title: "Hey,",
124135
Signature: "Cheers",
125136
Outros: []string{

api/pkg/entities/user.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ type User struct {
3737
ID UserID `json:"id" gorm:"primaryKey;type:string;" example:"WB7DRDWrJZRGbYrv2CKGkqbzvqdC"`
3838
Email string `json:"email" example:"[email protected]"` // gorm:"uniqueIndex"
3939
APIKey string `json:"api_key" example:"xyz"` // gorm:"uniqueIndex"
40+
Timezone string `json:"timezone" example:"Europe/Helsinki" gorm:"default:Africa/Accra"`
4041
ActivePhoneID *uuid.UUID `json:"active_phone_id" gorm:"type:uuid;" example:"32343a19-da5e-4b1b-a767-3298a73703cb"`
4142
SubscriptionName SubscriptionName `json:"subscription_name" example:"free"`
4243
SubscriptionID *string `json:"subscription_id" example:"8f9c71b8-b84e-4417-8408-a62274f65a08"`

web/components/MessageThreadHeader.vue

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,10 @@
4545
<v-btn
4646
x-small
4747
v-bind="attrs"
48+
:to="{
49+
name: 'heartbeats-id',
50+
params: { id: $store.getters.getOwner },
51+
}"
4852
color="success"
4953
class="ml-2 mt-1 mb-n1"
5054
icon

web/nuxt.config.js

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,11 @@ export default {
5757
css: [],
5858

5959
// Plugins to run before rendering page: https://go.nuxtjs.dev/config-plugins
60-
plugins: ['~plugins/filters.ts', { src: '~/plugins/vue-glow', ssr: false }],
60+
plugins: [
61+
'~plugins/filters.ts',
62+
{ src: '~/plugins/vue-glow', ssr: false },
63+
{ src: '~/plugins/chart', ssr: false },
64+
],
6165

6266
// Auto import components: https://go.nuxtjs.dev/config-components
6367
components: true,
@@ -143,7 +147,9 @@ export default {
143147
},
144148

145149
// Build Configuration: https://go.nuxtjs.dev/config-build
146-
build: {},
150+
build: {
151+
transpile: ['chart.js', 'vue-chartjs'],
152+
},
147153

148154
server: {
149155
port: 30000, // default: 3000

web/package.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,15 +26,19 @@
2626
"@nuxtjs/dotenv": "^1.4.1",
2727
"@nuxtjs/firebase": "^8.2.2",
2828
"@nuxtjs/sitemap": "^2.4.0",
29+
"chart.js": "^4.3.0",
30+
"chartjs-adapter-moment": "^1.0.1",
2931
"core-js": "^3.30.2",
3032
"date-fns": "^2.30.0",
3133
"dotenv": "^16.0.3",
3234
"firebase": "^9.22.0",
3335
"firebaseui": "^6.0.2",
3436
"libphonenumber-js": "^1.10.30",
37+
"moment": "^2.29.4",
3538
"nuxt": "^2.16.3",
3639
"nuxt-highlightjs": "^1.0.3",
3740
"vue": "^2.6.14",
41+
"vue-chartjs": "^5.2.0",
3842
"vue-class-component": "^7.2.6",
3943
"vue-glow": "^1.4.2",
4044
"vue-property-decorator": "^9.1.2",

web/pages/heartbeats/_id.vue

Lines changed: 250 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,250 @@
1+
<template>
2+
<v-container fluid class="pa-0" :fill-height="$vuetify.breakpoint.lgAndUp">
3+
<div class="w-full h-full">
4+
<v-app-bar height="60" :dense="$vuetify.breakpoint.mdAndDown" fixed>
5+
<v-btn icon to="/threads">
6+
<v-icon>{{ mdiArrowLeft }}</v-icon>
7+
</v-btn>
8+
<v-toolbar-title
9+
>Heartbeats
10+
<v-icon x-small class="mx-2" color="primary">{{ mdiCircle }}</v-icon>
11+
<span v-if="$store.getters.getOwner">{{
12+
$store.getters.getOwner | phoneNumber
13+
}}</span></v-toolbar-title
14+
>
15+
</v-app-bar>
16+
<v-container class="mt-16">
17+
<v-row>
18+
<v-col cols="12">
19+
<p>
20+
Every 15 minutes, the httpSMS app on your Android phone sends a
21+
heartbeat event to the httpsms API to show that it is alive. The
22+
reason for this is because the Android operating system sometimes
23+
kills an application to save battery
24+
<a
25+
href="https://dontkillmyapp.com"
26+
class="text-decoration-none"
27+
target="_blank"
28+
>https://dontkillmyapp.com</a
29+
>.
30+
</p>
31+
<p>
32+
If httpSMS doesn't get any heartbeat event in a 1-hour interval,
33+
you will get an email notification about it so you can check if
34+
there is an issue with your Android phone.
35+
</p>
36+
</v-col>
37+
<v-col v-if="$vuetify.breakpoint.mdAndUp" cols="12" class="px-0">
38+
<bar-chart :data="chartData" :options="chartOptions"></bar-chart>
39+
</v-col>
40+
<v-col cols="12">
41+
<p>
42+
The table below shows the last 100 heartbeat events received from
43+
the httpSMS app on your Android phone.
44+
</p>
45+
<v-data-table
46+
:value="selected"
47+
hide-default-footer
48+
:headers="dataTableHeaders"
49+
:items="dataTableItems"
50+
sort-by="timestamp"
51+
sort-desc
52+
:items-per-page="100"
53+
class="heartbeat--table"
54+
>
55+
<template #item.interval="{ item }">
56+
{{ formatDuration(item.interval) }}
57+
</template>
58+
<template #item.owner="{ item }">
59+
{{ item.owner | phoneNumber }}
60+
</template>
61+
<template #item.timestamp="{ item }">
62+
{{ item.timestamp | timestamp }}
63+
</template>
64+
</v-data-table>
65+
</v-col>
66+
</v-row>
67+
</v-container>
68+
</div>
69+
</v-container>
70+
</template>
71+
72+
<script>
73+
import { mdiArrowLeft, mdiCircle } from '@mdi/js'
74+
import 'chartjs-adapter-moment'
75+
import { formatDuration, intervalToDuration } from 'date-fns'
76+
import vueClassComponentEsm from 'vue-class-component'
77+
78+
export default {
79+
name: 'HeartbeatIndex',
80+
middleware: ['auth'],
81+
82+
data() {
83+
return {
84+
mdiArrowLeft,
85+
mdiCircle,
86+
heartbeats: [],
87+
selected: [3, 6],
88+
dataTableHeaders: [
89+
{
90+
text: 'HEARTBEAT ID',
91+
align: 'start',
92+
sortable: false,
93+
value: 'id',
94+
},
95+
{ text: 'PHONE NUMBER', value: 'owner', sortable: false },
96+
{ text: 'RECEIVED AT', value: 'timestamp' },
97+
{ text: 'TIME INTERVAL', value: 'interval' },
98+
],
99+
}
100+
},
101+
102+
head() {
103+
return {
104+
title: 'Heartbeats - Http SMS',
105+
}
106+
},
107+
108+
computed: {
109+
vueClassComponentEsm() {
110+
return vueClassComponentEsm
111+
},
112+
dataTableItems() {
113+
return this.heartbeats.map((heartbeat, index) => {
114+
let interval = 0
115+
if (index < 99) {
116+
interval = this.getDiff(
117+
heartbeat.timestamp,
118+
this.heartbeats[index + 1].timestamp
119+
)
120+
}
121+
const item = {
122+
id: heartbeat.id,
123+
timestamp: heartbeat.timestamp,
124+
owner: heartbeat.owner,
125+
interval,
126+
}
127+
if (interval > 3600000) {
128+
this.selected.push(item)
129+
}
130+
return item
131+
})
132+
},
133+
chartOptions() {
134+
const minDate = new Date()
135+
minDate.setDate(minDate.getDate() - 1)
136+
return {
137+
responsive: true,
138+
maintainAspectRatio: false,
139+
plugins: {
140+
legend: {
141+
display: false,
142+
},
143+
tooltip: {
144+
callbacks: {
145+
label: function (context) {
146+
if (context.dataIndex === 99) {
147+
return '-'
148+
}
149+
const duration = intervalToDuration({
150+
start: new Date(
151+
context.dataset.data[context.dataIndex + 1].x
152+
),
153+
end: new Date(context.dataset.data[context.dataIndex].x),
154+
})
155+
return formatDuration(duration)
156+
},
157+
},
158+
},
159+
},
160+
scales: {
161+
x: {
162+
type: 'time',
163+
},
164+
y: {
165+
display: false,
166+
},
167+
},
168+
}
169+
},
170+
chartData() {
171+
const data = this.heartbeats.map((heartbeat) => {
172+
return {
173+
x: new Date(heartbeat.timestamp).toISOString(),
174+
y: 1,
175+
}
176+
})
177+
178+
if (!data.length) {
179+
return {
180+
datasets: [
181+
{
182+
data,
183+
backgroundColor: '#2196f3',
184+
},
185+
],
186+
}
187+
}
188+
189+
let prev = new Date(data[0].x)
190+
const newData = []
191+
for (let i = 1; i < data.length; i++) {
192+
const current = new Date(data[i].x)
193+
const diff = prev - current
194+
if (diff > 600000) {
195+
// 10 minutes
196+
newData.push(data[i])
197+
prev = current
198+
}
199+
}
200+
201+
return {
202+
datasets: [
203+
{
204+
data: newData,
205+
backgroundColor: '#2196f3',
206+
},
207+
],
208+
}
209+
},
210+
},
211+
212+
async mounted() {
213+
await this.$store.dispatch('loadUser')
214+
await this.$store.dispatch('loadPhones')
215+
this.getHeartbeat()
216+
},
217+
218+
methods: {
219+
getDiff(a, b) {
220+
return new Date(a) - new Date(b)
221+
},
222+
223+
formatDuration(duration) {
224+
if (duration === 0) {
225+
return '-'
226+
}
227+
const start = new Date()
228+
start.setMilliseconds(start.getMilliseconds() + duration)
229+
return (
230+
formatDuration(intervalToDuration({ start: new Date(), end: start })) ||
231+
'0 seconds'
232+
)
233+
},
234+
235+
getHeartbeat() {
236+
this.$store.dispatch('getHeartbeat', 100).then((heartbeats) => {
237+
this.heartbeats = heartbeats
238+
})
239+
},
240+
},
241+
}
242+
</script>
243+
244+
<style lang="scss">
245+
.v-application {
246+
.heartbeat--table.v-data-table tbody tr.v-data-table__selected {
247+
background: #b71c1c;
248+
}
249+
}
250+
</style>

web/pages/messages/index.vue

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,17 @@
11
<template>
22
<v-container fluid class="pa-0" :fill-height="$vuetify.breakpoint.lgAndUp">
33
<div class="w-full h-full">
4-
<v-app-bar height="60" :dense="$vuetify.breakpoint.mdAndDown">
4+
<v-app-bar height="60" :dense="$vuetify.breakpoint.mdAndDown" fixed>
55
<v-btn icon to="/threads">
66
<v-icon>{{ mdiArrowLeft }}</v-icon>
77
</v-btn>
8-
<v-toolbar-title>New Message</v-toolbar-title>
8+
<v-toolbar-title
9+
>New Message
10+
<v-icon x-small class="mx-2" color="primary">{{ mdiCircle }}</v-icon>
11+
{{ $store.getters.getOwner | phoneNumber }}</v-toolbar-title
12+
>
913
</v-app-bar>
10-
<v-container>
14+
<v-container class="mt-16">
1115
<v-row>
1216
<v-col cols="12" md="8" offset-md="2" xl="6" offset-xl="3">
1317
<v-form @submit.prevent="sendMessage">
@@ -64,7 +68,7 @@
6468
</template>
6569

6670
<script>
67-
import { mdiArrowLeft, mdiSend, mdiSim } from '@mdi/js'
71+
import { mdiArrowLeft, mdiSend, mdiSim, mdiCircle } from '@mdi/js'
6872
import axios from '@/plugins/axios'
6973
7074
export default {
@@ -74,6 +78,7 @@ export default {
7478
return {
7579
mdiArrowLeft,
7680
mdiSend,
81+
mdiCircle,
7782
mdiSim,
7883
simOptions: [
7984
{ title: 'Default', code: 'DEFAULT' },

web/pages/settings/index.vue

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,15 +5,15 @@
55
:fill-height="$vuetify.breakpoint.lgAndUp"
66
>
77
<div class="w-full h-full">
8-
<v-app-bar height="60" :dense="$vuetify.breakpoint.mdAndDown">
8+
<v-app-bar height="60" fixed :dense="$vuetify.breakpoint.mdAndDown">
99
<v-btn icon to="/threads">
1010
<v-icon>{{ mdiArrowLeft }}</v-icon>
1111
</v-btn>
1212
<v-toolbar-title>
1313
<div class="py-16">Settings</div>
1414
</v-toolbar-title>
1515
</v-app-bar>
16-
<v-container>
16+
<v-container class="mt-16">
1717
<v-row>
1818
<v-col cols="12" md="9" offset-md="1" xl="8" offset-xl="2">
1919
<div v-if="$fire.auth.currentUser" class="text-center">

0 commit comments

Comments
 (0)