mirrorwall 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- mirrorwall/__about__.py +1 -0
- mirrorwall/__init__.py +116 -0
- mirrorwall/filters.py +248 -0
- mirrorwall/gallery.py +4 -0
- mirrorwall/health.py +125 -0
- mirrorwall/middleware.py +305 -0
- mirrorwall/py.typed +0 -0
- mirrorwall/responses.py +197 -0
- mirrorwall/sse.py +474 -0
- mirrorwall/static/ASSETS.sha256 +19 -0
- mirrorwall/static/css/charts.css +23 -0
- mirrorwall/static/css/components.css +192 -0
- mirrorwall/static/css/layout.css +81 -0
- mirrorwall/static/css/reset.css +36 -0
- mirrorwall/static/css/tables.css +29 -0
- mirrorwall/static/css/tokens.css +95 -0
- mirrorwall/static/css/tokens.json +70 -0
- mirrorwall/static/icons/sprite.svg +30 -0
- mirrorwall/static/js/charts.js +1 -0
- mirrorwall/static/js/dialog.js +26 -0
- mirrorwall/static/js/drawer.js +65 -0
- mirrorwall/static/js/sse.js +80 -0
- mirrorwall/static/js/table.js +174 -0
- mirrorwall/static/js/telemetry.js +87 -0
- mirrorwall/static/js/theme.js +60 -0
- mirrorwall/static/js/toast.js +34 -0
- mirrorwall/static.py +193 -0
- mirrorwall/templates/mirrorwall/base.html +74 -0
- mirrorwall/templates/mirrorwall/components.html +198 -0
- mirrorwall/templates/mirrorwall/telemetry_bar.html +34 -0
- mirrorwall/templating.py +100 -0
- mirrorwall-0.2.0.dist-info/METADATA +119 -0
- mirrorwall-0.2.0.dist-info/RECORD +35 -0
- mirrorwall-0.2.0.dist-info/WHEEL +4 -0
- mirrorwall-0.2.0.dist-info/licenses/LICENSE +201 -0
mirrorwall/__about__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.2.0"
|
mirrorwall/__init__.py
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"""MirrorWall — the shared UI and web-edge toolkit for the suite's three applications.
|
|
2
|
+
|
|
3
|
+
Design tokens, a layout shell, component macros, template filters, JSON and error envelopes, SSE
|
|
4
|
+
plumbing, request-ID, Host-validation and CSRF middleware, static mounting and health
|
|
5
|
+
primitives — so FreeWeight, LoadCoach and IdeaPress look and behave like one product family
|
|
6
|
+
without sharing a single page.
|
|
7
|
+
|
|
8
|
+
It knows nothing about benchmarks, routing or content, and a term-scan test enforces that: no
|
|
9
|
+
application vocabulary appears anywhere in this package, in Python, in a template, in a CSS class
|
|
10
|
+
name or in a JS module name.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from mirrorwall.__about__ import __version__
|
|
16
|
+
from mirrorwall.filters import (
|
|
17
|
+
EM_DASH,
|
|
18
|
+
STATIC_URL_PREFIX,
|
|
19
|
+
asset_url,
|
|
20
|
+
bytes_human,
|
|
21
|
+
duration_human,
|
|
22
|
+
is_supported_test,
|
|
23
|
+
json_pretty,
|
|
24
|
+
measurement,
|
|
25
|
+
register_filters,
|
|
26
|
+
timestamp,
|
|
27
|
+
truncate_middle,
|
|
28
|
+
)
|
|
29
|
+
from mirrorwall.health import (
|
|
30
|
+
ComponentHealth,
|
|
31
|
+
ComponentStatus,
|
|
32
|
+
health_payload,
|
|
33
|
+
worst_status,
|
|
34
|
+
)
|
|
35
|
+
from mirrorwall.middleware import (
|
|
36
|
+
CSRF_COOKIE_NAME,
|
|
37
|
+
CSRF_FIELD_NAME,
|
|
38
|
+
CsrfMiddleware,
|
|
39
|
+
HostValidationMiddleware,
|
|
40
|
+
RequestIdMiddleware,
|
|
41
|
+
issue_csrf_token,
|
|
42
|
+
loopback_allowlist,
|
|
43
|
+
)
|
|
44
|
+
from mirrorwall.responses import (
|
|
45
|
+
DEFAULT_PAGE_LIMIT,
|
|
46
|
+
MAX_PAGE_LIMIT,
|
|
47
|
+
clamp_limit,
|
|
48
|
+
error_body,
|
|
49
|
+
error_response,
|
|
50
|
+
json_response,
|
|
51
|
+
paginated_response,
|
|
52
|
+
)
|
|
53
|
+
from mirrorwall.sse import (
|
|
54
|
+
TOKEN_EVENT,
|
|
55
|
+
Event,
|
|
56
|
+
EventBroker,
|
|
57
|
+
EventSource,
|
|
58
|
+
Subscription,
|
|
59
|
+
format_frame,
|
|
60
|
+
parse_last_event_id,
|
|
61
|
+
sse_response,
|
|
62
|
+
)
|
|
63
|
+
from mirrorwall.static import HashedStaticFiles, mount_static
|
|
64
|
+
from mirrorwall.templating import (
|
|
65
|
+
DEFAULT_THEME_STORAGE_KEY,
|
|
66
|
+
PACKAGE_STATIC_DIR,
|
|
67
|
+
PACKAGE_TEMPLATE_DIR,
|
|
68
|
+
create_template_environment,
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
__all__ = [
|
|
72
|
+
"CSRF_COOKIE_NAME",
|
|
73
|
+
"CSRF_FIELD_NAME",
|
|
74
|
+
"DEFAULT_PAGE_LIMIT",
|
|
75
|
+
"DEFAULT_THEME_STORAGE_KEY",
|
|
76
|
+
"EM_DASH",
|
|
77
|
+
"MAX_PAGE_LIMIT",
|
|
78
|
+
"PACKAGE_STATIC_DIR",
|
|
79
|
+
"PACKAGE_TEMPLATE_DIR",
|
|
80
|
+
"STATIC_URL_PREFIX",
|
|
81
|
+
"TOKEN_EVENT",
|
|
82
|
+
"ComponentHealth",
|
|
83
|
+
"ComponentStatus",
|
|
84
|
+
"CsrfMiddleware",
|
|
85
|
+
"Event",
|
|
86
|
+
"EventBroker",
|
|
87
|
+
"EventSource",
|
|
88
|
+
"HashedStaticFiles",
|
|
89
|
+
"HostValidationMiddleware",
|
|
90
|
+
"RequestIdMiddleware",
|
|
91
|
+
"Subscription",
|
|
92
|
+
"__version__",
|
|
93
|
+
"asset_url",
|
|
94
|
+
"bytes_human",
|
|
95
|
+
"clamp_limit",
|
|
96
|
+
"create_template_environment",
|
|
97
|
+
"duration_human",
|
|
98
|
+
"error_body",
|
|
99
|
+
"error_response",
|
|
100
|
+
"format_frame",
|
|
101
|
+
"health_payload",
|
|
102
|
+
"is_supported_test",
|
|
103
|
+
"issue_csrf_token",
|
|
104
|
+
"json_pretty",
|
|
105
|
+
"json_response",
|
|
106
|
+
"loopback_allowlist",
|
|
107
|
+
"measurement",
|
|
108
|
+
"mount_static",
|
|
109
|
+
"paginated_response",
|
|
110
|
+
"parse_last_event_id",
|
|
111
|
+
"register_filters",
|
|
112
|
+
"sse_response",
|
|
113
|
+
"timestamp",
|
|
114
|
+
"truncate_middle",
|
|
115
|
+
"worst_status",
|
|
116
|
+
]
|
mirrorwall/filters.py
ADDED
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
"""mirrorwall.filters — the template filters every application in the suite renders through.
|
|
2
|
+
|
|
3
|
+
One rule shapes most of this module. An absent measurement is an em dash, never a zero
|
|
4
|
+
(:doc:`ADR-0016 <../../adr/0016-unsupported-vs-zero>`, UI/UX standards §3): a machine that could
|
|
5
|
+
not read its GPU temperature did not read it as zero degrees, and a zero is indistinguishable
|
|
6
|
+
from a real reading the moment it reaches an average or a chart.
|
|
7
|
+
|
|
8
|
+
:func:`measurement` is the **only** sanctioned way a template touches a
|
|
9
|
+
:class:`~baseaicore.Measurement` (spec §2). ``UNSUPPORTED`` refuses ``__bool__``, so
|
|
10
|
+
``{% if value %}`` on a measurement raises rather than rendering blank — correct behaviour, and
|
|
11
|
+
worth knowing before it happens. Templates write ``{{ value | measurement }}`` and
|
|
12
|
+
``{% if value is supported %}``.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import json
|
|
18
|
+
import posixpath
|
|
19
|
+
from datetime import datetime, timedelta
|
|
20
|
+
from typing import Any, Final
|
|
21
|
+
|
|
22
|
+
from baseaicore import UNSUPPORTED, is_supported
|
|
23
|
+
from baseaicore.timeutil import to_rfc3339
|
|
24
|
+
from markupsafe import Markup, escape
|
|
25
|
+
|
|
26
|
+
__all__ = [
|
|
27
|
+
"EM_DASH",
|
|
28
|
+
"STATIC_URL_PREFIX",
|
|
29
|
+
"asset_url",
|
|
30
|
+
"bytes_human",
|
|
31
|
+
"duration_human",
|
|
32
|
+
"is_supported_test",
|
|
33
|
+
"json_pretty",
|
|
34
|
+
"measurement",
|
|
35
|
+
"register_filters",
|
|
36
|
+
"timestamp",
|
|
37
|
+
"truncate_middle",
|
|
38
|
+
]
|
|
39
|
+
|
|
40
|
+
EM_DASH: Final = "—"
|
|
41
|
+
"""What an absent value renders as, everywhere in the suite. Never ``0``, never blank."""
|
|
42
|
+
|
|
43
|
+
STATIC_URL_PREFIX: Final = "/static/mirrorwall"
|
|
44
|
+
"""Where :func:`mount_static` serves this package's own assets from."""
|
|
45
|
+
|
|
46
|
+
_BYTE_UNITS: Final = ("KiB", "MiB", "GiB", "TiB", "PiB")
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def bytes_human(value: object) -> str:
|
|
50
|
+
"""Render a byte count at human scale.
|
|
51
|
+
|
|
52
|
+
Args:
|
|
53
|
+
value: A byte count, ``None``, or ``UNSUPPORTED``.
|
|
54
|
+
|
|
55
|
+
Returns:
|
|
56
|
+
The scaled string, or an em dash for an absent value — never ``"0 B"``, which is a real
|
|
57
|
+
measurement of an empty thing and must stay distinguishable from an unmeasured one.
|
|
58
|
+
"""
|
|
59
|
+
if value is None or not is_supported(value) or not isinstance(value, (int, float)):
|
|
60
|
+
return EM_DASH
|
|
61
|
+
if value < 1024:
|
|
62
|
+
return f"{int(value)} B"
|
|
63
|
+
scaled = float(value)
|
|
64
|
+
for unit in _BYTE_UNITS:
|
|
65
|
+
scaled /= 1024
|
|
66
|
+
if scaled < 1024 or unit == _BYTE_UNITS[-1]:
|
|
67
|
+
return f"{scaled:.1f} {unit}"
|
|
68
|
+
raise AssertionError("unreachable: the final unit always returns") # pragma: no cover
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def duration_human(value: object) -> str:
|
|
72
|
+
"""Render a duration in seconds at human scale.
|
|
73
|
+
|
|
74
|
+
Args:
|
|
75
|
+
value: Seconds as a number, a :class:`~datetime.timedelta`, ``None``, or ``UNSUPPORTED``.
|
|
76
|
+
|
|
77
|
+
Returns:
|
|
78
|
+
``"820 ms"``, ``"1.4 s"``, ``"2m 05s"`` or ``"3h 12m"``; an em dash for an absent value.
|
|
79
|
+
"""
|
|
80
|
+
if isinstance(value, timedelta):
|
|
81
|
+
value = value.total_seconds()
|
|
82
|
+
if value is None or not is_supported(value) or not isinstance(value, (int, float)):
|
|
83
|
+
return EM_DASH
|
|
84
|
+
seconds = float(value)
|
|
85
|
+
if seconds < 1:
|
|
86
|
+
return f"{seconds * 1000:.0f} ms"
|
|
87
|
+
if seconds < 60:
|
|
88
|
+
return f"{seconds:.1f} s"
|
|
89
|
+
if seconds < 3600:
|
|
90
|
+
return f"{int(seconds // 60)}m {int(seconds % 60):02d}s"
|
|
91
|
+
return f"{int(seconds // 3600)}h {int(seconds % 3600 // 60):02d}m"
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def timestamp(value: object) -> str:
|
|
95
|
+
"""Render an instant as RFC 3339 in UTC.
|
|
96
|
+
|
|
97
|
+
Args:
|
|
98
|
+
value: A timezone-aware :class:`~datetime.datetime`, ``None``, or ``UNSUPPORTED``.
|
|
99
|
+
|
|
100
|
+
Returns:
|
|
101
|
+
The RFC 3339 string, or an em dash.
|
|
102
|
+
"""
|
|
103
|
+
if not isinstance(value, datetime):
|
|
104
|
+
return EM_DASH
|
|
105
|
+
return to_rfc3339(value)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def measurement(value: object, reason: str | None = None, unit: str | None = None) -> Markup:
|
|
109
|
+
"""Render a measurement, or an em dash carrying why it is absent.
|
|
110
|
+
|
|
111
|
+
The only sanctioned way a template touches a :class:`~baseaicore.Measurement`. An
|
|
112
|
+
``UNSUPPORTED`` value renders as an em dash with the producer's own reason in a ``title``
|
|
113
|
+
attribute and in ``aria-label``, so the absence is visible to a reader and to a screen reader
|
|
114
|
+
alike, and is never a zero.
|
|
115
|
+
|
|
116
|
+
Args:
|
|
117
|
+
value: The measurement.
|
|
118
|
+
reason: Why it is unavailable, from the producer (SweatMeter's ``unavailable_reasons``,
|
|
119
|
+
a run's ``unsupported_reason`` column). Rendered as a tooltip.
|
|
120
|
+
unit: A unit suffix appended to a present value, e.g. ``"W"``.
|
|
121
|
+
|
|
122
|
+
Returns:
|
|
123
|
+
Escaped markup: an ``<span>`` for an absent value, the escaped number otherwise.
|
|
124
|
+
"""
|
|
125
|
+
if value is None or value is UNSUPPORTED or not is_supported(value):
|
|
126
|
+
explanation = reason or "not measurable in this environment"
|
|
127
|
+
return Markup(
|
|
128
|
+
'<span class="muted" title="{reason}" aria-label="Unavailable: {reason}">{dash}</span>'
|
|
129
|
+
).format(reason=explanation, dash=EM_DASH)
|
|
130
|
+
rendered = f"{value:g}" if isinstance(value, float) else str(value)
|
|
131
|
+
if unit:
|
|
132
|
+
return Markup("{value} <span class='unit'>{unit}</span>").format(value=rendered, unit=unit)
|
|
133
|
+
return escape(rendered)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def is_supported_test(value: object) -> bool:
|
|
137
|
+
"""Jinja test: whether a measurement carries a real reading.
|
|
138
|
+
|
|
139
|
+
Registered as ``supported`` so a template writes ``{% if value is supported %}``. It exists
|
|
140
|
+
because ``{% if value %}`` on an ``UNSUPPORTED`` raises — deliberately (ADR-0016) — and a
|
|
141
|
+
template needs a way to ask the question that does not.
|
|
142
|
+
|
|
143
|
+
Args:
|
|
144
|
+
value: The measurement.
|
|
145
|
+
|
|
146
|
+
Returns:
|
|
147
|
+
Whether it can be rendered as a number.
|
|
148
|
+
"""
|
|
149
|
+
return value is not None and is_supported(value)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def truncate_middle(value: object, length: int = 40, ellipsis: str = "…") -> str:
|
|
153
|
+
"""Shorten a long identifier from the middle, keeping both ends readable.
|
|
154
|
+
|
|
155
|
+
A canonical model ID is distinguished by its tail as much as its head, so an ordinary
|
|
156
|
+
right-truncation removes the half that disambiguates it.
|
|
157
|
+
|
|
158
|
+
Args:
|
|
159
|
+
value: The string to shorten.
|
|
160
|
+
length: The maximum rendered length, including the ellipsis.
|
|
161
|
+
ellipsis: What replaces the removed middle.
|
|
162
|
+
|
|
163
|
+
Returns:
|
|
164
|
+
The original string when it already fits, otherwise head + ellipsis + tail.
|
|
165
|
+
|
|
166
|
+
Raises:
|
|
167
|
+
ValueError: ``length`` is too small to hold the ellipsis and one character either side.
|
|
168
|
+
"""
|
|
169
|
+
text = "" if value is None else str(value)
|
|
170
|
+
minimum = len(ellipsis) + 2
|
|
171
|
+
if length < minimum:
|
|
172
|
+
message = (
|
|
173
|
+
f"length must be at least {minimum} to hold {ellipsis!r} and one character either side"
|
|
174
|
+
)
|
|
175
|
+
raise ValueError(message)
|
|
176
|
+
if len(text) <= length:
|
|
177
|
+
return text
|
|
178
|
+
room = length - len(ellipsis)
|
|
179
|
+
head = room - room // 2
|
|
180
|
+
return f"{text[:head]}{ellipsis}{text[len(text) - room // 2 :]}"
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def json_pretty(value: object, indent: int = 2) -> str:
|
|
184
|
+
"""Render a value as indented JSON for a ``<pre>`` block.
|
|
185
|
+
|
|
186
|
+
Args:
|
|
187
|
+
value: Anything JSON-serializable. Anything else is rendered with ``str`` rather than
|
|
188
|
+
raising, because a viewer that crashes the page it is diagnosing is useless.
|
|
189
|
+
indent: Indentation width.
|
|
190
|
+
|
|
191
|
+
Returns:
|
|
192
|
+
The JSON text. Not marked safe: the caller's autoescaping still applies, which is what
|
|
193
|
+
keeps a ``<script>`` inside a payload inert.
|
|
194
|
+
"""
|
|
195
|
+
try:
|
|
196
|
+
return json.dumps(value, indent=indent, sort_keys=True, default=str)
|
|
197
|
+
except (TypeError, ValueError): # pragma: no cover — default=str covers all practical inputs
|
|
198
|
+
return str(value)
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def asset_url(path: str, *, prefix: str = STATIC_URL_PREFIX) -> str:
|
|
202
|
+
"""Return the URL this package's asset is served from.
|
|
203
|
+
|
|
204
|
+
Phase 1 emits a plain, traversal-safe path. Phase 2's :mod:`mirrorwall.static` replaces the
|
|
205
|
+
implementation with the content-hashed form; the template seam is this function's name and
|
|
206
|
+
signature, so that change is invisible to every consumer's templates.
|
|
207
|
+
|
|
208
|
+
Args:
|
|
209
|
+
path: A path relative to ``mirrorwall/static/``, e.g. ``"css/tokens.css"``.
|
|
210
|
+
prefix: The URL the static mount serves from.
|
|
211
|
+
|
|
212
|
+
Returns:
|
|
213
|
+
The absolute URL path.
|
|
214
|
+
|
|
215
|
+
Raises:
|
|
216
|
+
ValueError: ``path`` is absolute or escapes the static root — a template must not be able
|
|
217
|
+
to address anything outside the package's own assets.
|
|
218
|
+
"""
|
|
219
|
+
cleaned = path.strip()
|
|
220
|
+
if cleaned.startswith("/") or "\\" in cleaned:
|
|
221
|
+
message = f"asset path must be relative to the static root: {path!r}"
|
|
222
|
+
raise ValueError(message)
|
|
223
|
+
normalized = posixpath.normpath(cleaned)
|
|
224
|
+
if normalized.startswith("..") or normalized == ".":
|
|
225
|
+
message = f"asset path escapes the static root: {path!r}"
|
|
226
|
+
raise ValueError(message)
|
|
227
|
+
return f"{prefix}/{normalized}"
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def register_filters(filters: dict[str, Any], tests: dict[str, Any]) -> None:
|
|
231
|
+
"""Install every shared filter and test onto a Jinja environment's registries.
|
|
232
|
+
|
|
233
|
+
Args:
|
|
234
|
+
filters: The environment's ``filters`` mapping.
|
|
235
|
+
tests: The environment's ``tests`` mapping.
|
|
236
|
+
"""
|
|
237
|
+
filters.update(
|
|
238
|
+
{
|
|
239
|
+
"bytes_human": bytes_human,
|
|
240
|
+
"duration_human": duration_human,
|
|
241
|
+
"timestamp": timestamp,
|
|
242
|
+
"measurement": measurement,
|
|
243
|
+
"truncate_middle": truncate_middle,
|
|
244
|
+
"json_pretty": json_pretty,
|
|
245
|
+
"asset_url": asset_url,
|
|
246
|
+
}
|
|
247
|
+
)
|
|
248
|
+
tests["supported"] = is_supported_test
|
mirrorwall/gallery.py
ADDED
mirrorwall/health.py
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
"""mirrorwall.health — the health payload primitives every application's ``/health`` is built on.
|
|
2
|
+
|
|
3
|
+
The package supplies the shape and the roll-up rule; each application supplies its own components
|
|
4
|
+
(a database, a provider, a queue) and decides what makes each one degraded. That split is the
|
|
5
|
+
point: three applications answer "are you healthy?" in the same words, about different things.
|
|
6
|
+
|
|
7
|
+
``NOT_CONFIGURED`` is a first-class status, not a synonym for ``UNAVAILABLE``. A component nobody
|
|
8
|
+
asked for is not broken, and rolling it up as though it were turns every default install into a
|
|
9
|
+
red dashboard — the same distinction between "absent" and "bad" that ADR-0016 makes for a
|
|
10
|
+
measurement.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from dataclasses import dataclass, field
|
|
16
|
+
from enum import StrEnum
|
|
17
|
+
from typing import TYPE_CHECKING, Any
|
|
18
|
+
|
|
19
|
+
from baseaicore.timeutil import to_rfc3339, utc_now
|
|
20
|
+
|
|
21
|
+
if TYPE_CHECKING:
|
|
22
|
+
from collections.abc import Mapping, Sequence
|
|
23
|
+
from datetime import datetime
|
|
24
|
+
|
|
25
|
+
__all__ = ["ComponentHealth", "ComponentStatus", "health_payload", "worst_status"]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class ComponentStatus(StrEnum):
|
|
29
|
+
"""One component's verdict.
|
|
30
|
+
|
|
31
|
+
Ordered worst-last in :data:`_SEVERITY` rather than by declaration, so adding a status cannot
|
|
32
|
+
silently change how an existing one rolls up.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
OK = "ok"
|
|
36
|
+
DEGRADED = "degraded"
|
|
37
|
+
UNAVAILABLE = "unavailable"
|
|
38
|
+
NOT_CONFIGURED = "not_configured"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
_SEVERITY: dict[ComponentStatus, int] = {
|
|
42
|
+
ComponentStatus.NOT_CONFIGURED: 0,
|
|
43
|
+
ComponentStatus.OK: 1,
|
|
44
|
+
ComponentStatus.DEGRADED: 2,
|
|
45
|
+
ComponentStatus.UNAVAILABLE: 3,
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass(frozen=True, slots=True)
|
|
50
|
+
class ComponentHealth:
|
|
51
|
+
"""One named component's status, with whatever numbers explain it.
|
|
52
|
+
|
|
53
|
+
Attributes:
|
|
54
|
+
name: The component, e.g. ``"database"``. Stable across releases: a dashboard keys on it.
|
|
55
|
+
status: The verdict.
|
|
56
|
+
detail: One human-readable sentence. Free of secrets and of paths outside the data root.
|
|
57
|
+
data: Structured figures behind the verdict — free bytes, a latency, a queue depth.
|
|
58
|
+
checked_at: When the check ran. Defaults to now at payload time.
|
|
59
|
+
"""
|
|
60
|
+
|
|
61
|
+
name: str
|
|
62
|
+
status: ComponentStatus
|
|
63
|
+
detail: str = ""
|
|
64
|
+
data: Mapping[str, Any] = field(default_factory=dict)
|
|
65
|
+
checked_at: datetime | None = None
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def worst_status(components: Sequence[ComponentHealth]) -> ComponentStatus:
|
|
69
|
+
"""Roll several components up into one overall verdict.
|
|
70
|
+
|
|
71
|
+
``NOT_CONFIGURED`` never worsens the roll-up: a component nobody asked for is not a fault. An
|
|
72
|
+
empty component list is ``OK`` — an application with nothing to check has nothing wrong.
|
|
73
|
+
|
|
74
|
+
Args:
|
|
75
|
+
components: The components to roll up.
|
|
76
|
+
|
|
77
|
+
Returns:
|
|
78
|
+
The most severe status present, ``OK`` when the only statuses are ``OK`` and
|
|
79
|
+
``NOT_CONFIGURED``, and ``NOT_CONFIGURED`` only when every component is.
|
|
80
|
+
"""
|
|
81
|
+
if not components:
|
|
82
|
+
return ComponentStatus.OK
|
|
83
|
+
statuses = [component.status for component in components]
|
|
84
|
+
if all(status is ComponentStatus.NOT_CONFIGURED for status in statuses):
|
|
85
|
+
return ComponentStatus.NOT_CONFIGURED
|
|
86
|
+
return max(statuses, key=lambda status: _SEVERITY[status])
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def health_payload(
|
|
90
|
+
*,
|
|
91
|
+
application: str,
|
|
92
|
+
version: str,
|
|
93
|
+
components: Sequence[ComponentHealth],
|
|
94
|
+
checked_at: datetime | None = None,
|
|
95
|
+
) -> dict[str, Any]:
|
|
96
|
+
"""Build the ``/health`` body.
|
|
97
|
+
|
|
98
|
+
Args:
|
|
99
|
+
application: The application's distribution name, lowercase.
|
|
100
|
+
version: Its version.
|
|
101
|
+
components: Every component it checked.
|
|
102
|
+
checked_at: The instant to record for components that did not carry their own. Injected
|
|
103
|
+
so a test can assert a whole payload without patching the clock.
|
|
104
|
+
|
|
105
|
+
Returns:
|
|
106
|
+
The body: overall status, the application's identity, and one entry per component in the
|
|
107
|
+
order given — never reordered, because a dashboard's rows should not move between polls.
|
|
108
|
+
"""
|
|
109
|
+
now = checked_at or utc_now()
|
|
110
|
+
return {
|
|
111
|
+
"status": worst_status(components).value,
|
|
112
|
+
"application": application,
|
|
113
|
+
"version": version,
|
|
114
|
+
"checked_at": to_rfc3339(now),
|
|
115
|
+
"components": [
|
|
116
|
+
{
|
|
117
|
+
"name": component.name,
|
|
118
|
+
"status": component.status.value,
|
|
119
|
+
"detail": component.detail,
|
|
120
|
+
"data": dict(component.data),
|
|
121
|
+
"checked_at": to_rfc3339(component.checked_at or now),
|
|
122
|
+
}
|
|
123
|
+
for component in components
|
|
124
|
+
],
|
|
125
|
+
}
|