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
« 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/>.
14"""The uptime page that shows the time the website is running."""
16from __future__ import annotations
18import logging
19import math
20import time
21from typing import Final, TypedDict
23import regex
24from elasticsearch import AsyncElasticsearch
25from tornado.web import HTTPError, RedirectHandler
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
32LOGGER: Final = logging.getLogger(__name__)
35class AvailabilityDict(TypedDict): # noqa: D101
36 # pylint: disable=missing-class-docstring
37 up: int
38 down: int
39 total: int
40 percentage: None | float
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 )
60class EsAvailabilityKwargs(TypedDict):
61 """Subset of kwargs to Elasticsearch search."""
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]]]
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}
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 )
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 }
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)
137class UptimeHandler(HTMLRequestHandler):
138 """The request handler for the uptime page."""
140 COMPUTE_ETAG = False
141 POSSIBLE_CONTENT_TYPES = (
142 *HTMLRequestHandler.POSSIBLE_CONTENT_TYPES,
143 *APIRequestHandler.POSSIBLE_CONTENT_TYPES,
144 )
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()))
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 }
172class AvailabilityChartHandler(BaseRequestHandler):
173 """The request handler for the availability chart."""
175 POSSIBLE_CONTENT_TYPES = ("image/svg+xml",)
176 COMPUTE_ETAG = False
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 )
213class UptimeAPIHandler(APIRequestHandler, UptimeHandler):
214 """The request handler for the uptime API."""
216 POSSIBLE_CONTENT_TYPES = APIRequestHandler.POSSIBLE_CONTENT_TYPES