Dashboard built with React.js
This commit is contained in:
		
							parent
							
								
									4db8e84cac
								
							
						
					
					
						commit
						54b1aa9c56
					
				| @ -154,5 +154,6 @@ func (s *Server) dashboardHandler(w http.ResponseWriter, r *http.Request) { | |||||||
| 	tmpl.ExecuteTemplate(w, "dashboard", map[string]interface{}{ | 	tmpl.ExecuteTemplate(w, "dashboard", map[string]interface{}{ | ||||||
| 		"version":  Version, | 		"version":  Version, | ||||||
| 		"hostname": hostname, | 		"hostname": hostname, | ||||||
|  | 		"port":     s.port, | ||||||
| 	}) | 	}) | ||||||
| } | } | ||||||
|  | |||||||
| @ -4,7 +4,7 @@ | |||||||
|     font-weight: 300; |     font-weight: 300; | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| .heading, table { | .heading, #dashboard { | ||||||
|     position: absolute; |     position: absolute; | ||||||
|     left: 50%; |     left: 50%; | ||||||
|     width: 650px; |     width: 650px; | ||||||
| @ -15,8 +15,11 @@ | |||||||
|     font-size: 1.8em; |     font-size: 1.8em; | ||||||
|     text-align: center; |     text-align: center; | ||||||
| } | } | ||||||
| table { | #dashboard { | ||||||
|     top: 100px; |     top: 100px; | ||||||
|  | } | ||||||
|  | table { | ||||||
|  |     width: 100%; | ||||||
|     border-collapse: collapse; |     border-collapse: collapse; | ||||||
|     border-spacing: 0; |     border-spacing: 0; | ||||||
| } | } | ||||||
| @ -31,9 +34,6 @@ th { | |||||||
| thead tr { | thead tr { | ||||||
|     border-bottom: #666 1px solid; |     border-bottom: #666 1px solid; | ||||||
| } | } | ||||||
| /*tbody tr:nth-child(even) { |  | ||||||
|     background-color: #f5f5f5; |  | ||||||
| }*/ |  | ||||||
| .title { | .title { | ||||||
|     position: relative; |     position: relative; | ||||||
|     width: 350px; |     width: 350px; | ||||||
| @ -69,15 +69,7 @@ thead tr { | |||||||
|     font-weight: 600; |     font-weight: 600; | ||||||
|     color: #f20; |     color: #f20; | ||||||
| } | } | ||||||
| #loading { | .placeholder { | ||||||
|     display: none; |  | ||||||
|     position: absolute; |  | ||||||
|     bottom: 10px; |  | ||||||
|     right: 10px; |  | ||||||
|     font-size: 0.5em; |  | ||||||
|     width: auto; |  | ||||||
| } |  | ||||||
| #placeholder td { |  | ||||||
|     text-align: center; |     text-align: center; | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
|  | |||||||
| @ -1,191 +0,0 @@ | |||||||
| /* |  | ||||||
|  * Dashboard |  | ||||||
|  */ |  | ||||||
| 
 |  | ||||||
| function loadStatus(callback) { |  | ||||||
|     var xhr = new XMLHttpRequest(), |  | ||||||
|         loading = document.getElementById('loading'); |  | ||||||
| 
 |  | ||||||
|     loading.setAttribute('style', 'display: block;'); |  | ||||||
|     xhr.open('GET', '/status?rates=please', true); |  | ||||||
|     xhr.onreadystatechange = function() { |  | ||||||
|         if (xhr.readyState === 4) { |  | ||||||
|             if (xhr.status === 200) { |  | ||||||
|                 var queues = JSON.parse(xhr.responseText); |  | ||||||
|                 loading.setAttribute('style', 'display: none;'); |  | ||||||
|                 callback(queues); |  | ||||||
|             } |  | ||||||
|         } |  | ||||||
|     }; |  | ||||||
|     xhr.send(null); |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| function updateDashboard(queues) { |  | ||||||
|     var queuesList = document.getElementById('queues'), |  | ||||||
|         placeholder = document.getElementById('placeholder'), |  | ||||||
|         fatThreshold = 100, |  | ||||||
|         hotThreshold = 1000; |  | ||||||
| 
 |  | ||||||
|     if (Object.keys(queues).length === 0) { |  | ||||||
|         var td = placeholder.getElementsByTagName('td')[0]; |  | ||||||
|         td.innerHTML = 'Empty'; |  | ||||||
|     } else if (placeholder) { |  | ||||||
|         queuesList.removeChild(placeholder); |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     for (queue in queues) { |  | ||||||
|         var meta = queues[queue], |  | ||||||
|             id = 'queue_' + queue, |  | ||||||
|             tr = document.getElementById(id); |  | ||||||
| 
 |  | ||||||
|         if (!tr) { |  | ||||||
|             tr = document.createElement('tr'); |  | ||||||
|             tr.setAttribute('id', id); |  | ||||||
| 
 |  | ||||||
|             var titleCol = document.createElement('td'), |  | ||||||
|                 messagesCol = document.createElement('td'), |  | ||||||
|                 subscriptionsCol = document.createElement('td'), |  | ||||||
|                 nameDiv = document.createElement('div'), |  | ||||||
|                 svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'), |  | ||||||
|                 pathIn = document.createElementNS('http://www.w3.org/2000/svg', 'path'), |  | ||||||
|                 pathOut = document.createElementNS('http://www.w3.org/2000/svg', 'path'), |  | ||||||
|                 messagesDiv = document.createElement('div'), |  | ||||||
|                 subscriptionsDiv = document.createElement('div'); |  | ||||||
| 
 |  | ||||||
|             pathIn.setAttribute('class', 'in'); |  | ||||||
|             pathOut.setAttribute('class', 'out'); |  | ||||||
|             svg.setAttributeNS(null, 'viewbox', '0 0 300 40'); |  | ||||||
|             svg.appendChild(pathIn); |  | ||||||
|             svg.appendChild(pathOut); |  | ||||||
| 
 |  | ||||||
|             nameDiv.setAttribute('class', 'name'); |  | ||||||
|             svg.setAttribute('class', 'chart'); |  | ||||||
|             titleCol.setAttribute('class', 'title'); |  | ||||||
|             messagesCol.setAttribute('class', 'messages'); |  | ||||||
|             subscriptionsCol.setAttribute('class', 'subscriptions'); |  | ||||||
| 
 |  | ||||||
|             nameDiv.appendChild(document.createTextNode(queue)); |  | ||||||
|             titleCol.appendChild(svg); |  | ||||||
|             titleCol.appendChild(nameDiv); |  | ||||||
|             messagesCol.appendChild(messagesDiv); |  | ||||||
|             subscriptionsCol.appendChild(subscriptionsDiv); |  | ||||||
|             tr.appendChild(titleCol); |  | ||||||
|             tr.appendChild(messagesCol); |  | ||||||
|             tr.appendChild(subscriptionsCol); |  | ||||||
|             queuesList.appendChild(tr); |  | ||||||
|         } |  | ||||||
| 
 |  | ||||||
|         var titleCol = tr.getElementsByClassName('title')[0], |  | ||||||
|             nameDiv = titleCol.getElementsByClassName('name')[0], |  | ||||||
|             svg = titleCol.getElementsByClassName('chart')[0], |  | ||||||
|             messagesCol = tr.getElementsByClassName('messages')[0], |  | ||||||
|             subscriptionsCol = tr.getElementsByClassName('subscriptions')[0], |  | ||||||
|             messagesDiv = messagesCol.getElementsByTagName('div')[0], |  | ||||||
|             subscriptionsDiv = subscriptionsCol.getElementsByTagName('div')[0], |  | ||||||
|             messages = Number(meta.messages).toLocaleString(), |  | ||||||
|             subscriptions = Number(meta.subscriptions).toLocaleString(); |  | ||||||
| 
 |  | ||||||
|         messagesDiv.innerHTML = messages; |  | ||||||
|         subscriptionsDiv.innerHTML = subscriptions; |  | ||||||
| 
 |  | ||||||
|         if (meta.messages > hotThreshold) { |  | ||||||
|             nameDiv.setAttribute('class', 'name hot'); |  | ||||||
|             messagesDiv.setAttribute('class', 'num messages hot'); |  | ||||||
|         } else if (meta.messages > fatThreshold) { |  | ||||||
|             nameDiv.setAttribute('class', 'name fat'); |  | ||||||
|             messagesDiv.setAttribute('class', 'num messages fat'); |  | ||||||
|         } else if (meta.messages === 0) { |  | ||||||
|             messagesDiv.setAttribute('class', 'num messages zero'); |  | ||||||
|         } else { |  | ||||||
|             nameDiv.setAttribute('class', 'name'); |  | ||||||
|             messagesDiv.setAttribute('class', 'num messages'); |  | ||||||
|         } |  | ||||||
| 
 |  | ||||||
|         if (meta.subscriptions === 0) { |  | ||||||
|             subscriptionsDiv.setAttribute('class', 'num subscriptions zero'); |  | ||||||
|         } else { |  | ||||||
|             subscriptionsDiv.setAttribute('class', 'num subscriptions'); |  | ||||||
|         } |  | ||||||
| 
 |  | ||||||
|         svg.setAttributeNS(null, 'viewbox', '0 0 300 '+ titleCol.offsetTop); |  | ||||||
|         drawChart(svg, titleCol.offsetTop, meta.in_rate_history, meta.out_rate_history); |  | ||||||
|     } |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| /* |  | ||||||
|  * Charts |  | ||||||
|  */ |  | ||||||
| 
 |  | ||||||
| function drawChart(svg, maxHeight, valuesIn, valuesOut) { |  | ||||||
|     var pathIn = svg.getElementsByClassName('in')[0], |  | ||||||
|         pathOut = svg.getElementsByClassName('out')[0], |  | ||||||
|         // valuesIn = generateValues(300),
 |  | ||||||
|         // valuesOut = generateValues(300),
 |  | ||||||
|         maxDouble = calcMaxDouble(valuesIn, valuesOut), |  | ||||||
|         pointsIn = [], |  | ||||||
|         pointsOut = []; |  | ||||||
| 
 |  | ||||||
|     for (var i = 0; i < valuesIn.length; i++) { |  | ||||||
|         var normIn = Math.ceil(valuesIn[i] / maxDouble * maxHeight), |  | ||||||
|             normOut = Math.ceil(valuesOut[i] / maxDouble * maxHeight), |  | ||||||
|             pointIn = maxHeight/2 - normIn, |  | ||||||
|             pointOut = maxHeight/2 + normOut; |  | ||||||
| 
 |  | ||||||
|         pointsIn.push(pointIn); |  | ||||||
|         pointsOut.push(pointOut); |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     pathIn.setAttributeNS(null, 'd', buildPathD(pointsIn, maxHeight)); |  | ||||||
|     pathIn.setAttributeNS(null, 'class', 'in'); |  | ||||||
|     pathOut.setAttributeNS(null, 'd', buildPathD(pointsOut, maxHeight)); |  | ||||||
|     pathOut.setAttributeNS(null, 'class', 'out'); |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| function generateValues(num) { |  | ||||||
|     var values = []; |  | ||||||
|     for (var i = 0; i < num; i++) { |  | ||||||
|         var value = Math.ceil(Math.random() * 60) + 30; |  | ||||||
|         values.push(value); |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     return values; |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| function calcMaxDouble(a, b) { |  | ||||||
|     var doubleValue = 0; |  | ||||||
|     for (var i = 0; i < a.length; i++) { |  | ||||||
|         if (a[i] * 2 > doubleValue) { |  | ||||||
|             doubleValue = a[i] * 2; |  | ||||||
|         } |  | ||||||
|         if (b[i] * 2 > doubleValue) { |  | ||||||
|             doubleValue = b[i] * 2; |  | ||||||
|         } |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     return doubleValue * 1.2; |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| function buildPathD(points, maxHeight) { |  | ||||||
|     var d = ['M0,'+ maxHeight/2]; |  | ||||||
|     for (var i = 0; i < points.length; i++) { |  | ||||||
|         d.push('L'+ i +','+ points[i]); |  | ||||||
|     } |  | ||||||
|     d.push('L300,'+ maxHeight/2, 'Z'); |  | ||||||
| 
 |  | ||||||
|     return d.join(' '); |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
|  /* |  | ||||||
|  * Starting up |  | ||||||
|  */ |  | ||||||
| 
 |  | ||||||
| function loop(timeout, func) { |  | ||||||
|     func(); |  | ||||||
|     window.setTimeout(function(){ |  | ||||||
|         loop(timeout, func); |  | ||||||
|     }, timeout); |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| loop(1000, function(){ |  | ||||||
|     loadStatus(updateDashboard); |  | ||||||
| }); |  | ||||||
							
								
								
									
										193
									
								
								server/static/app.jsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										193
									
								
								server/static/app.jsx
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,193 @@ | |||||||
|  | var Chart = React.createClass({ | ||||||
|  |     getInitialState: function() { | ||||||
|  |         return {pointsIn: [], pointsOut: []}; | ||||||
|  |     }, | ||||||
|  | 
 | ||||||
|  |     componentDidMount: function() { | ||||||
|  |         this.buildPoints(this.props); | ||||||
|  |     }, | ||||||
|  | 
 | ||||||
|  |     componentWillReceiveProps: function(nextProps) { | ||||||
|  |         this.buildPoints(nextProps); | ||||||
|  |     }, | ||||||
|  | 
 | ||||||
|  |     buildPoints: function(props) { | ||||||
|  |         var maxDouble = this.calcMaxDouble(props.valuesIn, props.valuesOut) || 1, | ||||||
|  |             pointsIn = [], | ||||||
|  |             pointsOut = []; | ||||||
|  | 
 | ||||||
|  |         for (var i = 0; i < props.valuesIn.length; i++) { | ||||||
|  |             var normIn = Math.ceil(props.valuesIn[i] / maxDouble * props.height), | ||||||
|  |                 normOut = Math.ceil(props.valuesOut[i] / maxDouble * props.height), | ||||||
|  |                 pointIn = props.height/2 - normIn, | ||||||
|  |                 pointOut = props.height/2 + normOut; | ||||||
|  | 
 | ||||||
|  |             pointsIn.push(pointIn); | ||||||
|  |             pointsOut.push(pointOut); | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         this.setState({pointsIn: pointsIn, pointsOut: pointsOut}); | ||||||
|  |     }, | ||||||
|  | 
 | ||||||
|  |     calcMaxDouble: function(a, b) { | ||||||
|  |         var doubleValue = 0; | ||||||
|  |         for (var i = 0; i < a.length; i++) { | ||||||
|  |             if (a[i] * 2 > doubleValue) { | ||||||
|  |                 doubleValue = a[i] * 2; | ||||||
|  |             } | ||||||
|  |             if (b[i] * 2 > doubleValue) { | ||||||
|  |                 doubleValue = b[i] * 2; | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         return doubleValue * 1.2; | ||||||
|  |     }, | ||||||
|  | 
 | ||||||
|  |     buildPathD: function(points) { | ||||||
|  |         var d = ['M0,'+ this.props.height/2], | ||||||
|  |             missing = this.props.width - points.length; | ||||||
|  | 
 | ||||||
|  |         for (var i = 0; i < missing; i++) { | ||||||
|  |             d.push('L'+ i +','+ this.props.height/2); | ||||||
|  |         } | ||||||
|  |         for (var i = 0; i < points.length; i++) { | ||||||
|  |             d.push('L'+ missing+i +','+ points[i]); | ||||||
|  |         } | ||||||
|  |         d.push('L'+ this.props.width +','+ this.props.height/2, 'Z'); | ||||||
|  | 
 | ||||||
|  |         return d.join(' '); | ||||||
|  |     }, | ||||||
|  | 
 | ||||||
|  |     render: function() { | ||||||
|  |         var viewBox = [0, 0, this.props.width, this.props.height].join(' '); | ||||||
|  |         return ( | ||||||
|  |             <svg className="chart" viewBox={viewBox}> | ||||||
|  |                 <path className="in" d={this.buildPathD(this.state.pointsIn)} /> | ||||||
|  |                 <path className="out" d={this.buildPathD(this.state.pointsOut)} /> | ||||||
|  |             </svg> | ||||||
|  |         ); | ||||||
|  |     } | ||||||
|  | }); | ||||||
|  | 
 | ||||||
|  | var QueuesList = React.createClass({ | ||||||
|  |     render: function(){ | ||||||
|  |         if (!this.props.isDataRecieved) { | ||||||
|  |             return ( | ||||||
|  |                 <tbody id="queues"> | ||||||
|  |                     <tr> | ||||||
|  |                         <td colSpan="3" className="placeholder">Loading...</td> | ||||||
|  |                     </tr> | ||||||
|  |                 </tbody> | ||||||
|  |             ) | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         if (Object.keys(this.props.queues) === 0) { | ||||||
|  |             return ( | ||||||
|  |                 <tbody id="queues"> | ||||||
|  |                     <tr> | ||||||
|  |                         <td colSpan="3" className="placeholder">This server has no queues</td> | ||||||
|  |                     </tr> | ||||||
|  |                 </tbody> | ||||||
|  |             ) | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         var queues = this.props.queues; | ||||||
|  |         var createQueue = function(name) { | ||||||
|  |             var meta = queues[name], | ||||||
|  |                 titleClasses = ['title'], | ||||||
|  |                 messagesClasses = ['messages'], | ||||||
|  |                 subscriptionsClasses = ['subscriptions']; | ||||||
|  | 
 | ||||||
|  |             if (meta.messages > 1000) { | ||||||
|  |                 titleClasses.push('hot'); | ||||||
|  |                 messagesClasses.push('hot'); | ||||||
|  |             } else if (meta.messages > 100) { | ||||||
|  |                 titleClasses.push('fat'); | ||||||
|  |                 messagesClasses.push('fat'); | ||||||
|  |             } else if (meta.messages === 0) { | ||||||
|  |                 messagesClasses.push('zero'); | ||||||
|  |             } | ||||||
|  |             if (meta.subscriptions === 0) { | ||||||
|  |                 subscriptionsClasses.push('zero'); | ||||||
|  |             } | ||||||
|  |             return ( | ||||||
|  |                 <tr key={name}> | ||||||
|  |                     <td className="title"> | ||||||
|  |                         <div className={titleClasses.join(' ')}>{name}</div> | ||||||
|  |                         <Chart | ||||||
|  |                             valuesIn={meta.in_rate_history} | ||||||
|  |                             valuesOut={meta.out_rate_history} | ||||||
|  |                             width={300} | ||||||
|  |                             height={40} /> | ||||||
|  |                     </td> | ||||||
|  |                     <td className={messagesClasses.join(' ')}> | ||||||
|  |                         <div className="num">{meta.messages}</div> | ||||||
|  |                     </td> | ||||||
|  |                     <td className={subscriptionsClasses.join(' ')}> | ||||||
|  |                         <div className="num">{meta.subscriptions}</div> | ||||||
|  |                     </td> | ||||||
|  |                 </tr> | ||||||
|  |             ); | ||||||
|  |         }; | ||||||
|  | 
 | ||||||
|  |         return ( | ||||||
|  |             <tbody id="queues"> | ||||||
|  |                 {Object.keys(queues).map(createQueue)} | ||||||
|  |             </tbody> | ||||||
|  |         ); | ||||||
|  |     } | ||||||
|  | }); | ||||||
|  | 
 | ||||||
|  | var Dashboard = React.createClass({ | ||||||
|  |     getInitialState: function() { | ||||||
|  |         return {queues: {}, isDataRecieved: false}; | ||||||
|  |     }, | ||||||
|  | 
 | ||||||
|  |     componentDidMount: function() { | ||||||
|  |         this.loop(this.props.interval, this.refresh); | ||||||
|  |     }, | ||||||
|  | 
 | ||||||
|  |     componentWillUnmount: function() { | ||||||
|  |         clearTimeout(this.timeout); | ||||||
|  |     }, | ||||||
|  | 
 | ||||||
|  |     loop: function (timeout, func) { | ||||||
|  |         var loop = this.loop; | ||||||
|  |         func(); | ||||||
|  |         this.timeout = setTimeout(function(){ | ||||||
|  |             loop(timeout, func); | ||||||
|  |         }, timeout); | ||||||
|  |     }, | ||||||
|  | 
 | ||||||
|  |     refresh: function () { | ||||||
|  |         var xhr = new XMLHttpRequest() | ||||||
|  |             self = this; | ||||||
|  |         xhr.open('GET', '/status?rates=please', true); | ||||||
|  |         xhr.onreadystatechange = function() { | ||||||
|  |             if (xhr.readyState === 4) { | ||||||
|  |                 if (xhr.status === 200) { | ||||||
|  |                     self.setState({ | ||||||
|  |                         queues: JSON.parse(xhr.responseText), | ||||||
|  |                         isDataRecieved: true | ||||||
|  |                     }); | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|  |         }; | ||||||
|  |         xhr.send(null); | ||||||
|  |     }, | ||||||
|  | 
 | ||||||
|  |     render: function() { | ||||||
|  |         return ( | ||||||
|  |             <table className="stats"> | ||||||
|  |                 <thead> | ||||||
|  |                     <tr> | ||||||
|  |                         <th className="name">Queue</th> | ||||||
|  |                         <th className="messages">Messages</th> | ||||||
|  |                         <th className="subscriptions">Subscriptions</th> | ||||||
|  |                     </tr> | ||||||
|  |                 </thead> | ||||||
|  |                 <QueuesList queues={this.state.queues} isDataRecieved={this.state.isDataRecieved} /> | ||||||
|  |             </table> | ||||||
|  |         ); | ||||||
|  |     } | ||||||
|  | }); | ||||||
| @ -4,26 +4,20 @@ | |||||||
| <head> | <head> | ||||||
|     <title>Queues @ {{.hostname}}</title> |     <title>Queues @ {{.hostname}}</title> | ||||||
|     <meta charset="utf8"> |     <meta charset="utf8"> | ||||||
|     <link rel="stylesheet" type="text/css" href="/static/app.css"> |     <link rel="stylesheet" href="/static/app.css"> | ||||||
|  |     <script src="http://fb.me/react-0.12.2.js"></script> | ||||||
|  |     <script src="http://fb.me/JSXTransformer-0.12.2.js"></script> | ||||||
| </head> | </head> | ||||||
| <body> | <body> | ||||||
|     <h1 class="heading">Burlesque v{{.version}} at {{.hostname}}</h1> |     <h1 class="heading">Burlesque v{{.version}} at {{.hostname}}</h1> | ||||||
|     <table class="stats"> |     <div id="dashboard"></div> | ||||||
|         <thead> |     <script type="text/jsx" src="/static/app.jsx"></script> | ||||||
|             <tr> |     <script type="text/jsx"> | ||||||
|                 <th class="name">Queue</th> |     React.render( | ||||||
|                 <th class="messages">Messages</th> |         <Dashboard api="http://127.0.0.1:{{.port}}/status" interval={1000} />, | ||||||
|                 <th class="subscriptions">Subscriptions</th> |         document.getElementById('dashboard') | ||||||
|             </tr> |     ); | ||||||
|         </thead> |     </script> | ||||||
|         <tbody id="queues"> |  | ||||||
|             <tr id="placeholder"> |  | ||||||
|                 <td colspan="3">Loading queues...</td> |  | ||||||
|             </tr> |  | ||||||
|         </tbody> |  | ||||||
|         <div id="loading">Loading...</div> |  | ||||||
|     </table> |  | ||||||
|     <script type="text/javascript" src="/static/app.js"></script> |  | ||||||
| </body> | </body> | ||||||
| </html> | </html> | ||||||
| {{end}} | {{end}} | ||||||
|  | |||||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user