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

59 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-04-01 02: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 

16 

17import logging 

18import math 

19import time 

20from typing import Final, TypedDict 

21 

22import regex 

23from elasticsearch import AsyncElasticsearch 

24from tornado.web import HTTPError, RedirectHandler 

25 

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

27from ..utils.base_request_handler import BaseRequestHandler 

28from ..utils.request_handler import APIRequestHandler, HTMLRequestHandler 

29from ..utils.utils import ModuleInfo, time_to_str 

30 

31LOGGER: Final = logging.getLogger(__name__) 

32 

33 

34class AvailabilityDict(TypedDict): # noqa: D101 

35 # pylint: disable=missing-class-docstring 

36 up: int 

37 down: int 

38 total: int 

39 percentage: None | float 

40 

41 

42def get_module_info() -> ModuleInfo: 

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

44 return ModuleInfo( 

45 handlers=( 

46 (r"/betriebszeit", UptimeHandler), 

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

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

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

50 ), 

51 name="Betriebszeit", 

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

53 path="/betriebszeit", 

54 aliases=("/uptime",), 

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

56 ) 

57 

58 

59class EsAvailabilityKwargs(TypedDict): 

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

61 

62 index: str 

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

64 size: int 

65 aggs: dict[str, dict[str, dict[str, str]]] 

66 

67 

68ES_AVAILABILITY_KWARGS: Final[EsAvailabilityKwargs] = { 

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

70 "query": { 

71 "bool": { 

72 "filter": [ 

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

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

75 { 

76 "term": { 

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

78 } 

79 }, 

80 ] 

81 } 

82 }, 

83 "size": 0, 

84 "aggs": { 

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

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

87 }, 

88} 

89 

90 

91async def get_availability_data( 

92 elasticsearch: AsyncElasticsearch, 

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

94 """Get the availability data.""" 

95 try: 

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

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

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

99 return None 

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

101 return ( 

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

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

104 ) 

105 

106 

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

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

109 return { 

110 "up": up, 

111 "down": down, 

112 "total": up + down, 

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

114 } 

115 

116 

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

118 r"\s+", 

119 " ", 

120 """ 

121<svg height="20" 

122 width="20" 

123 viewBox="0 0 20 20" 

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

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

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

127 stroke="green" 

128 stroke-width="10" 

129 stroke-dasharray="%2.2f 31.4159" 

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

131</svg> 

132""".strip(), 

133) 

134 

135 

136class UptimeHandler(HTMLRequestHandler): 

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

138 

139 COMPUTE_ETAG = False 

140 POSSIBLE_CONTENT_TYPES = ( 

141 *HTMLRequestHandler.POSSIBLE_CONTENT_TYPES, 

142 *APIRequestHandler.POSSIBLE_CONTENT_TYPES, 

143 ) 

144 

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

146 """Handle GET requests.""" 

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

148 if head: 

149 return 

150 if self.content_type in APIRequestHandler.POSSIBLE_CONTENT_TYPES: 

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

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

153 

154 async def get_uptime_data( 

155 self, 

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

157 """Get uptime data.""" 

158 availability_data = ( 

159 await get_availability_data(self.elasticsearch) 

160 if EVENT_ELASTICSEARCH.is_set() 

161 else None 

162 ) or (0, 0) 

163 return { 

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

165 "uptime_str": time_to_str(uptime), 

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

167 "availability": get_availability_dict(*availability_data), 

168 } 

169 

170 

171class AvailabilityChartHandler(BaseRequestHandler): 

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

173 

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

175 COMPUTE_ETAG = False 

176 

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

178 """Handle GET requests.""" 

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

180 if not EVENT_ELASTICSEARCH.is_set(): 

181 raise HTTPError(503) 

182 availability_data = await get_availability_data(self.elasticsearch) 

183 if not availability_data: 

184 raise HTTPError(503) 

185 self.redirect( 

186 self.fix_url( 

187 self.request.full_url(), 

188 query_args={ 

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 ), 

201 permanent=False, 

202 ) 

203 return 

204 self.set_header( 

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

206 ) 

207 if head: 

208 return 

209 await self.finish( 

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

211 ) 

212 

213 

214class UptimeAPIHandler(APIRequestHandler, UptimeHandler): 

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

216 

217 POSSIBLE_CONTENT_TYPES = APIRequestHandler.POSSIBLE_CONTENT_TYPES