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