Coverage for an_website/uptime/uptime.py: 67.797%
59 statements
« prev ^ index » next coverage.py v7.14.1, created at 2026-06-10 18:56 +0000
« prev ^ index » next coverage.py v7.14.1, created at 2026-06-10 18: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."""
16import logging
17import math
18import time
19from typing import Final, TypedDict
21import regex
22from elasticsearch import AsyncElasticsearch
23from tornado.web import HTTPError, RedirectHandler
25from .. import EPOCH, EVENT_ELASTICSEARCH, NAME, UPTIME
26from ..utils.base_request_handler import BaseRequestHandler
27from ..utils.request_handler import APIRequestHandler, HTMLRequestHandler
28from ..utils.utils import ModuleInfo, time_to_str
30LOGGER: Final = logging.getLogger(__name__)
33class AvailabilityDict(TypedDict): # noqa: D101
34 # pylint: disable=missing-class-docstring
35 up: int
36 down: int
37 total: int
38 percentage: None | float
41def get_module_info() -> ModuleInfo:
42 """Create and return the ModuleInfo for this module."""
43 return ModuleInfo(
44 handlers=(
45 (r"/betriebszeit", UptimeHandler),
46 (r"/betriebszeit/verfuegbarkeit.svg", AvailabilityChartHandler),
47 (r"/api/betriebszeit", UptimeAPIHandler),
48 (r"/api/uptime/*", RedirectHandler, {"url": "/api/betriebszeit"}),
49 ),
50 name="Betriebszeit",
51 description="Die Dauer, die die Webseite am Stück in Betrieb ist",
52 path="/betriebszeit",
53 aliases=("/uptime",),
54 keywords=("Uptime", "Betriebszeit", "Zeit"),
55 )
58class EsAvailabilityKwargs(TypedDict):
59 """Subset of kwargs to Elasticsearch search."""
61 index: str
62 query: dict[str, dict[str, list[dict[str, dict[str, dict[str, str]]]]]]
63 size: int
64 aggs: dict[str, dict[str, dict[str, str]]]
67ES_AVAILABILITY_KWARGS: Final[EsAvailabilityKwargs] = {
68 "index": "heartbeat-*,synthetics-*",
69 "query": {
70 "bool": {
71 "filter": [
72 {"range": {"@timestamp": {"gte": "now-1M"}}},
73 {"term": {"monitor.type": {"value": "http"}}},
74 {
75 "term": {
76 "service.name": {"value": NAME.removesuffix("-dev")}
77 }
78 },
79 ]
80 }
81 },
82 "size": 0,
83 "aggs": {
84 "up": {"sum": {"field": "summary.up"}},
85 "down": {"sum": {"field": "summary.down"}},
86 },
87}
90async def get_availability_data(
91 elasticsearch: AsyncElasticsearch,
92) -> None | tuple[int, int]: # (up, down)
93 """Get the availability data."""
94 try:
95 data = await elasticsearch.search(**ES_AVAILABILITY_KWARGS)
96 except Exception: # pylint: disable=broad-exception-caught
97 LOGGER.exception("Getting availability data from Elasticsearch failed.")
98 return None
99 data.setdefault("aggregations", {"up": {"value": 0}, "down": {"value": 0}})
100 return (
101 int(data["aggregations"]["up"]["value"]),
102 int(data["aggregations"]["down"]["value"]),
103 )
106def get_availability_dict(up: int, down: int) -> AvailabilityDict:
107 """Get the availability data as a dict."""
108 return {
109 "up": up,
110 "down": down,
111 "total": up + down,
112 "percentage": 100 * up / (up + down) if up + down else None,
113 }
116AVAILABILITY_CHART: Final[str] = regex.sub(
117 r"\s+",
118 " ",
119 """
120<svg height="20"
121 width="20"
122 viewBox="0 0 20 20"
123 xmlns="http://www.w3.org/2000/svg"
124><circle r="10" cx="10" cy="10" fill="red" />
125 <circle r="5" cx="10" cy="10" fill="transparent"
126 stroke="green"
127 stroke-width="10"
128 stroke-dasharray="%2.2f 31.4159"
129 transform="rotate(-90) translate(-20)" />
130</svg>
131""".strip(),
132)
135class UptimeHandler(HTMLRequestHandler):
136 """The request handler for the uptime page."""
138 COMPUTE_ETAG = False
139 POSSIBLE_CONTENT_TYPES = (
140 *HTMLRequestHandler.POSSIBLE_CONTENT_TYPES,
141 *APIRequestHandler.POSSIBLE_CONTENT_TYPES,
142 )
144 async def get(self, *, head: bool = False) -> None:
145 """Handle GET requests."""
146 self.set_header("Cache-Control", "no-cache")
147 if head:
148 return
149 if self.content_type in APIRequestHandler.POSSIBLE_CONTENT_TYPES:
150 return await self.finish(await self.get_uptime_data())
151 await self.render("pages/uptime.html", **(await self.get_uptime_data()))
153 async def get_uptime_data(
154 self,
155 ) -> dict[str, str | float | AvailabilityDict]:
156 """Get uptime data."""
157 availability_data = (
158 await get_availability_data(self.elasticsearch)
159 if EVENT_ELASTICSEARCH.is_set()
160 else None
161 ) or (0, 0)
162 return {
163 "uptime": (uptime := UPTIME.get()),
164 "uptime_str": time_to_str(uptime),
165 "start_time": time.time() - uptime - EPOCH,
166 "availability": get_availability_dict(*availability_data),
167 }
170class AvailabilityChartHandler(BaseRequestHandler):
171 """The request handler for the availability chart."""
173 POSSIBLE_CONTENT_TYPES = ("image/svg+xml",)
174 COMPUTE_ETAG = False
176 async def get(self, *, head: bool = False) -> None:
177 """Handle GET requests."""
178 if not (availability := self.get_argument("a", None)):
179 if not EVENT_ELASTICSEARCH.is_set():
180 raise HTTPError(503)
181 availability_data = await get_availability_data(self.elasticsearch)
182 if not availability_data:
183 raise HTTPError(503)
184 self.redirect(
185 self.fix_url(
186 self.request.full_url(),
187 query_args={
188 "a": int(
189 (
190 get_availability_dict(*availability_data)[
191 "percentage"
192 ]
193 or 0
194 )
195 * 100
196 )
197 / (100 * 100),
198 },
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