Coverage for an_website/uptime/uptime.py: 72.059%

68 statements  

« prev     ^ index     » next       coverage.py v7.6.4, created at 2024-11-16 19:56 +0000

1# This program is free software: you can redistribute it and/or modify 

2# it under the terms of the GNU Affero General Public License as 

3# published by the Free Software Foundation, either version 3 of the 

4# License, or (at your option) any later version. 

5# 

6# This program is distributed in the hope that it will be useful, 

7# but WITHOUT ANY WARRANTY; without even the implied warranty of 

8# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 

9# GNU Affero General Public License for more details. 

10# 

11# You should have received a copy of the GNU Affero General Public License 

12# along with this program. If not, see <https://www.gnu.org/licenses/>. 

13 

14"""The uptime page that shows the time the website is running.""" 

15 

16from __future__ import annotations 

17 

18import logging 

19import math 

20import time 

21from typing import Final, TypedDict 

22 

23import regex 

24from elasticsearch import AsyncElasticsearch 

25from tornado.web import HTTPError, RedirectHandler 

26 

27from .. import EPOCH, EVENT_ELASTICSEARCH, NAME, UPTIME 

28from ..utils.base_request_handler import BaseRequestHandler 

29from ..utils.request_handler import APIRequestHandler, HTMLRequestHandler 

30from ..utils.utils import ModuleInfo, time_to_str 

31 

32LOGGER: Final = logging.getLogger(__name__) 

33 

34 

35class AvailabilityDict(TypedDict): # noqa: D101 

36 # pylint: disable=missing-class-docstring 

37 up: int 

38 down: int 

39 total: int 

40 percentage: None | float 

41 

42 

43def get_module_info() -> ModuleInfo: 

44 """Create and return the ModuleInfo for this module.""" 

45 return ModuleInfo( 

46 handlers=( 

47 (r"/betriebszeit", UptimeHandler), 

48 (r"/betriebszeit/verfuegbarkeit.svg", AvailabilityChartHandler), 

49 (r"/api/betriebszeit", UptimeAPIHandler), 

50 (r"/api/uptime/*", RedirectHandler, {"url": "/api/betriebszeit"}), 

51 ), 

52 name="Betriebszeit", 

53 description="Die Dauer, die die Webseite am Stück in Betrieb ist", 

54 path="/betriebszeit", 

55 aliases=("/uptime",), 

56 keywords=("Uptime", "Betriebszeit", "Zeit"), 

57 ) 

58 

59 

60class EsAvailabilityKwargs(TypedDict): 

61 """Subset of kwargs to Elasticsearch search.""" 

62 

63 index: str 

64 query: dict[str, dict[str, list[dict[str, dict[str, dict[str, str]]]]]] 

65 size: int 

66 aggs: dict[str, dict[str, dict[str, str]]] 

67 

68 

69ES_AVAILABILITY_KWARGS: Final[EsAvailabilityKwargs] = { 

70 "index": "heartbeat-*,synthetics-*", 

71 "query": { 

72 "bool": { 

73 "filter": [ 

74 {"range": {"@timestamp": {"gte": "now-1M"}}}, 

75 {"term": {"monitor.type": {"value": "http"}}}, 

76 { 

77 "term": { 

78 "service.name": {"value": NAME.removesuffix("-dev")} 

79 } 

80 }, 

81 ] 

82 } 

83 }, 

84 "size": 0, 

85 "aggs": { 

86 "up": {"sum": {"field": "summary.up"}}, 

87 "down": {"sum": {"field": "summary.down"}}, 

88 }, 

89} 

90 

91 

92async def get_availability_data( 

93 elasticsearch: AsyncElasticsearch, 

94) -> None | tuple[int, int]: # (up, down) 

95 """Get the availability data.""" 

96 try: 

97 data = await elasticsearch.search(**ES_AVAILABILITY_KWARGS) 

98 except Exception: # pylint: disable=broad-exception-caught 

99 LOGGER.exception("Getting availability data from Elasticsearch failed.") 

100 return None 

101 data.setdefault("aggregations", {"up": {"value": 0}, "down": {"value": 0}}) 

102 return ( 

103 int(data["aggregations"]["up"]["value"]), 

104 int(data["aggregations"]["down"]["value"]), 

105 ) 

106 

107 

108def get_availability_dict(up: int, down: int) -> AvailabilityDict: 

109 """Get the availability data as a dict.""" 

110 return { 

111 "up": up, 

112 "down": down, 

113 "total": up + down, 

114 "percentage": 100 * up / (up + down) if up + down else None, 

115 } 

116 

117 

118AVAILABILITY_CHART: Final[str] = regex.sub( 

119 r"\s+", 

120 " ", 

121 """ 

122<svg height="20" 

123 width="20" 

124 viewBox="0 0 20 20" 

125 xmlns="http://www.w3.org/2000/svg" 

126><circle r="10" cx="10" cy="10" fill="red" /> 

127 <circle r="5" cx="10" cy="10" fill="transparent" 

128 stroke="green" 

129 stroke-width="10" 

130 stroke-dasharray="%2.2f 31.4159" 

131 transform="rotate(-90) translate(-20)" /> 

132</svg> 

133""".strip(), 

134) 

135 

136 

137class UptimeHandler(HTMLRequestHandler): 

138 """The request handler for the uptime page.""" 

139 

140 COMPUTE_ETAG = False 

141 POSSIBLE_CONTENT_TYPES = ( 

142 *HTMLRequestHandler.POSSIBLE_CONTENT_TYPES, 

143 *APIRequestHandler.POSSIBLE_CONTENT_TYPES, 

144 ) 

145 

146 async def get(self, *, head: bool = False) -> None: 

147 """Handle GET requests.""" 

148 self.set_header("Cache-Control", "no-cache") 

149 if head: 

150 return 

151 if self.content_type in APIRequestHandler.POSSIBLE_CONTENT_TYPES: 

152 return await self.finish(await self.get_uptime_data()) 

153 await self.render("pages/uptime.html", **(await self.get_uptime_data())) 

154 

155 async def get_uptime_data( 

156 self, 

157 ) -> dict[str, str | float | AvailabilityDict]: 

158 """Get uptime data.""" 

159 availability_data = ( 

160 await get_availability_data(self.elasticsearch) 

161 if EVENT_ELASTICSEARCH.is_set() 

162 else None 

163 ) or (0, 0) 

164 return { 

165 "uptime": (uptime := UPTIME.get()), 

166 "uptime_str": time_to_str(uptime), 

167 "start_time": time.time() - uptime - EPOCH, 

168 "availability": get_availability_dict(*availability_data), 

169 } 

170 

171 

172class AvailabilityChartHandler(BaseRequestHandler): 

173 """The request handler for the availability chart.""" 

174 

175 POSSIBLE_CONTENT_TYPES = ("image/svg+xml",) 

176 COMPUTE_ETAG = False 

177 

178 async def get(self, *, head: bool = False) -> None: 

179 """Handle GET requests.""" 

180 if not (availability := self.get_argument("a", None)): 

181 if not EVENT_ELASTICSEARCH.is_set(): 

182 raise HTTPError(503) 

183 availability_data = await get_availability_data(self.elasticsearch) 

184 if not availability_data: 

185 raise HTTPError(503) 

186 self.redirect( 

187 self.fix_url( 

188 self.request.full_url(), 

189 a=int( 

190 ( 

191 get_availability_dict(*availability_data)[ 

192 "percentage" 

193 ] 

194 or 0 

195 ) 

196 * 100 

197 ) 

198 / (100 * 100), 

199 ), 

200 permanent=False, 

201 ) 

202 return 

203 self.set_header( 

204 "Cache-Control", f"public, min-fresh={60 * 60 * 24 * 14}, immutable" 

205 ) 

206 if head: 

207 return 

208 await self.finish( 

209 AVAILABILITY_CHART % (math.pi * 10 * float(availability)) 

210 ) 

211 

212 

213class UptimeAPIHandler(APIRequestHandler, UptimeHandler): 

214 """The request handler for the uptime API.""" 

215 

216 POSSIBLE_CONTENT_TYPES = APIRequestHandler.POSSIBLE_CONTENT_TYPES