kstlib 0.0.1a0__py3-none-any.whl → 1.0.1__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.
Files changed (166) hide show
  1. kstlib/__init__.py +266 -1
  2. kstlib/__main__.py +16 -0
  3. kstlib/alerts/__init__.py +110 -0
  4. kstlib/alerts/channels/__init__.py +36 -0
  5. kstlib/alerts/channels/base.py +197 -0
  6. kstlib/alerts/channels/email.py +227 -0
  7. kstlib/alerts/channels/slack.py +389 -0
  8. kstlib/alerts/exceptions.py +72 -0
  9. kstlib/alerts/manager.py +651 -0
  10. kstlib/alerts/models.py +142 -0
  11. kstlib/alerts/throttle.py +263 -0
  12. kstlib/auth/__init__.py +139 -0
  13. kstlib/auth/callback.py +399 -0
  14. kstlib/auth/config.py +502 -0
  15. kstlib/auth/errors.py +127 -0
  16. kstlib/auth/models.py +316 -0
  17. kstlib/auth/providers/__init__.py +14 -0
  18. kstlib/auth/providers/base.py +393 -0
  19. kstlib/auth/providers/oauth2.py +645 -0
  20. kstlib/auth/providers/oidc.py +821 -0
  21. kstlib/auth/session.py +338 -0
  22. kstlib/auth/token.py +482 -0
  23. kstlib/cache/__init__.py +50 -0
  24. kstlib/cache/decorator.py +261 -0
  25. kstlib/cache/strategies.py +516 -0
  26. kstlib/cli/__init__.py +8 -0
  27. kstlib/cli/app.py +195 -0
  28. kstlib/cli/commands/__init__.py +5 -0
  29. kstlib/cli/commands/auth/__init__.py +39 -0
  30. kstlib/cli/commands/auth/common.py +122 -0
  31. kstlib/cli/commands/auth/login.py +325 -0
  32. kstlib/cli/commands/auth/logout.py +74 -0
  33. kstlib/cli/commands/auth/providers.py +57 -0
  34. kstlib/cli/commands/auth/status.py +291 -0
  35. kstlib/cli/commands/auth/token.py +199 -0
  36. kstlib/cli/commands/auth/whoami.py +106 -0
  37. kstlib/cli/commands/config.py +89 -0
  38. kstlib/cli/commands/ops/__init__.py +39 -0
  39. kstlib/cli/commands/ops/attach.py +49 -0
  40. kstlib/cli/commands/ops/common.py +269 -0
  41. kstlib/cli/commands/ops/list_sessions.py +252 -0
  42. kstlib/cli/commands/ops/logs.py +49 -0
  43. kstlib/cli/commands/ops/start.py +98 -0
  44. kstlib/cli/commands/ops/status.py +138 -0
  45. kstlib/cli/commands/ops/stop.py +60 -0
  46. kstlib/cli/commands/rapi/__init__.py +60 -0
  47. kstlib/cli/commands/rapi/call.py +341 -0
  48. kstlib/cli/commands/rapi/list.py +99 -0
  49. kstlib/cli/commands/rapi/show.py +206 -0
  50. kstlib/cli/commands/secrets/__init__.py +35 -0
  51. kstlib/cli/commands/secrets/common.py +425 -0
  52. kstlib/cli/commands/secrets/decrypt.py +88 -0
  53. kstlib/cli/commands/secrets/doctor.py +743 -0
  54. kstlib/cli/commands/secrets/encrypt.py +242 -0
  55. kstlib/cli/commands/secrets/shred.py +96 -0
  56. kstlib/cli/common.py +86 -0
  57. kstlib/config/__init__.py +76 -0
  58. kstlib/config/exceptions.py +110 -0
  59. kstlib/config/export.py +225 -0
  60. kstlib/config/loader.py +963 -0
  61. kstlib/config/sops.py +287 -0
  62. kstlib/db/__init__.py +54 -0
  63. kstlib/db/aiosqlcipher.py +137 -0
  64. kstlib/db/cipher.py +112 -0
  65. kstlib/db/database.py +367 -0
  66. kstlib/db/exceptions.py +25 -0
  67. kstlib/db/pool.py +302 -0
  68. kstlib/helpers/__init__.py +35 -0
  69. kstlib/helpers/exceptions.py +11 -0
  70. kstlib/helpers/time_trigger.py +396 -0
  71. kstlib/kstlib.conf.yml +890 -0
  72. kstlib/limits.py +963 -0
  73. kstlib/logging/__init__.py +108 -0
  74. kstlib/logging/manager.py +633 -0
  75. kstlib/mail/__init__.py +42 -0
  76. kstlib/mail/builder.py +626 -0
  77. kstlib/mail/exceptions.py +27 -0
  78. kstlib/mail/filesystem.py +248 -0
  79. kstlib/mail/transport.py +224 -0
  80. kstlib/mail/transports/__init__.py +19 -0
  81. kstlib/mail/transports/gmail.py +268 -0
  82. kstlib/mail/transports/resend.py +324 -0
  83. kstlib/mail/transports/smtp.py +326 -0
  84. kstlib/meta.py +72 -0
  85. kstlib/metrics/__init__.py +88 -0
  86. kstlib/metrics/decorators.py +1090 -0
  87. kstlib/metrics/exceptions.py +14 -0
  88. kstlib/monitoring/__init__.py +116 -0
  89. kstlib/monitoring/_styles.py +163 -0
  90. kstlib/monitoring/cell.py +57 -0
  91. kstlib/monitoring/config.py +424 -0
  92. kstlib/monitoring/delivery.py +579 -0
  93. kstlib/monitoring/exceptions.py +63 -0
  94. kstlib/monitoring/image.py +220 -0
  95. kstlib/monitoring/kv.py +79 -0
  96. kstlib/monitoring/list.py +69 -0
  97. kstlib/monitoring/metric.py +88 -0
  98. kstlib/monitoring/monitoring.py +341 -0
  99. kstlib/monitoring/renderer.py +139 -0
  100. kstlib/monitoring/service.py +392 -0
  101. kstlib/monitoring/table.py +129 -0
  102. kstlib/monitoring/types.py +56 -0
  103. kstlib/ops/__init__.py +86 -0
  104. kstlib/ops/base.py +148 -0
  105. kstlib/ops/container.py +577 -0
  106. kstlib/ops/exceptions.py +209 -0
  107. kstlib/ops/manager.py +407 -0
  108. kstlib/ops/models.py +176 -0
  109. kstlib/ops/tmux.py +372 -0
  110. kstlib/ops/validators.py +287 -0
  111. kstlib/py.typed +0 -0
  112. kstlib/rapi/__init__.py +118 -0
  113. kstlib/rapi/client.py +875 -0
  114. kstlib/rapi/config.py +861 -0
  115. kstlib/rapi/credentials.py +887 -0
  116. kstlib/rapi/exceptions.py +213 -0
  117. kstlib/resilience/__init__.py +101 -0
  118. kstlib/resilience/circuit_breaker.py +440 -0
  119. kstlib/resilience/exceptions.py +95 -0
  120. kstlib/resilience/heartbeat.py +491 -0
  121. kstlib/resilience/rate_limiter.py +506 -0
  122. kstlib/resilience/shutdown.py +417 -0
  123. kstlib/resilience/watchdog.py +637 -0
  124. kstlib/secrets/__init__.py +29 -0
  125. kstlib/secrets/exceptions.py +19 -0
  126. kstlib/secrets/models.py +62 -0
  127. kstlib/secrets/providers/__init__.py +79 -0
  128. kstlib/secrets/providers/base.py +58 -0
  129. kstlib/secrets/providers/environment.py +66 -0
  130. kstlib/secrets/providers/keyring.py +107 -0
  131. kstlib/secrets/providers/kms.py +223 -0
  132. kstlib/secrets/providers/kwargs.py +101 -0
  133. kstlib/secrets/providers/sops.py +209 -0
  134. kstlib/secrets/resolver.py +221 -0
  135. kstlib/secrets/sensitive.py +130 -0
  136. kstlib/secure/__init__.py +23 -0
  137. kstlib/secure/fs.py +194 -0
  138. kstlib/secure/permissions.py +70 -0
  139. kstlib/ssl.py +347 -0
  140. kstlib/ui/__init__.py +23 -0
  141. kstlib/ui/exceptions.py +26 -0
  142. kstlib/ui/panels.py +484 -0
  143. kstlib/ui/spinner.py +864 -0
  144. kstlib/ui/tables.py +382 -0
  145. kstlib/utils/__init__.py +48 -0
  146. kstlib/utils/dict.py +36 -0
  147. kstlib/utils/formatting.py +338 -0
  148. kstlib/utils/http_trace.py +237 -0
  149. kstlib/utils/lazy.py +49 -0
  150. kstlib/utils/secure_delete.py +205 -0
  151. kstlib/utils/serialization.py +247 -0
  152. kstlib/utils/text.py +56 -0
  153. kstlib/utils/validators.py +124 -0
  154. kstlib/websocket/__init__.py +97 -0
  155. kstlib/websocket/exceptions.py +214 -0
  156. kstlib/websocket/manager.py +1102 -0
  157. kstlib/websocket/models.py +361 -0
  158. kstlib-1.0.1.dist-info/METADATA +201 -0
  159. kstlib-1.0.1.dist-info/RECORD +163 -0
  160. {kstlib-0.0.1a0.dist-info → kstlib-1.0.1.dist-info}/WHEEL +1 -1
  161. kstlib-1.0.1.dist-info/entry_points.txt +2 -0
  162. kstlib-1.0.1.dist-info/licenses/LICENSE.md +9 -0
  163. kstlib-0.0.1a0.dist-info/METADATA +0 -29
  164. kstlib-0.0.1a0.dist-info/RECORD +0 -6
  165. kstlib-0.0.1a0.dist-info/licenses/LICENSE.md +0 -5
  166. {kstlib-0.0.1a0.dist-info → kstlib-1.0.1.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,220 @@
1
+ r"""MonitorImage render type for embedded images.
2
+
3
+ A ``MonitorImage`` renders as an HTML ``<img>`` tag with a base64-encoded
4
+ ``data:`` URI, suitable for embedding logos and icons in email templates
5
+ where external image references are typically blocked.
6
+
7
+ Examples:
8
+ >>> from kstlib.monitoring.image import MonitorImage
9
+ >>> img = MonitorImage(b"\x89PNG\r\n\x1a\n" + b"\x00" * 50, alt="Logo")
10
+ >>> "data:image/png;base64," in img.render()
11
+ True
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import base64
17
+ import html
18
+ import re
19
+ from dataclasses import dataclass
20
+ from typing import TYPE_CHECKING
21
+
22
+ from kstlib.monitoring.exceptions import RenderError
23
+
24
+ if TYPE_CHECKING:
25
+ from pathlib import Path
26
+
27
+ # ---------------------------------------------------------------------------
28
+ # Image format detection and limits
29
+ # ---------------------------------------------------------------------------
30
+
31
+ #: Maximum allowed image size in bytes (512 KB).
32
+ IMAGE_MAX_BYTES: int = 512 * 1024
33
+
34
+ #: Supported image MIME types with their magic byte signatures.
35
+ #: Each entry maps a MIME type to a tuple of possible magic byte prefixes.
36
+ _MAGIC_SIGNATURES: dict[str, tuple[bytes, ...]] = {
37
+ "image/png": (b"\x89PNG\r\n\x1a\n",),
38
+ "image/jpeg": (b"\xff\xd8\xff",),
39
+ "image/gif": (b"GIF87a", b"GIF89a"),
40
+ "image/webp": (b"RIFF",), # RIFF....WEBP (bytes 8-11 checked separately)
41
+ "image/svg+xml": (), # detected by text heuristic, not magic bytes
42
+ }
43
+
44
+ #: Allowed MIME types for images.
45
+ ALLOWED_MIME_TYPES: frozenset[str] = frozenset(_MAGIC_SIGNATURES)
46
+
47
+ #: Pattern matching dangerous SVG content (script tags, event handlers).
48
+ _SVG_DANGEROUS_PATTERN: re.Pattern[str] = re.compile(
49
+ r"<script|on\w+\s*=",
50
+ re.IGNORECASE,
51
+ )
52
+
53
+
54
+ def _detect_mime_type(data: bytes) -> str | None:
55
+ """Detect image MIME type from magic bytes.
56
+
57
+ Args:
58
+ data: Raw image bytes (at least the first 12 bytes).
59
+
60
+ Returns:
61
+ MIME type string if recognized, or None.
62
+ """
63
+ for mime, signatures in _MAGIC_SIGNATURES.items():
64
+ for sig in signatures:
65
+ if data.startswith(sig):
66
+ if mime == "image/webp" and data[8:12] != b"WEBP":
67
+ continue
68
+ return mime
69
+
70
+ # SVG heuristic: XML-like text containing <svg
71
+ if len(data) > 4 and data[:4] != b"\x00\x00\x00\x00":
72
+ try:
73
+ head = data[:1024].decode("utf-8", errors="strict")
74
+ except UnicodeDecodeError:
75
+ return None
76
+ if "<svg" in head.lower():
77
+ return "image/svg+xml"
78
+
79
+ return None
80
+
81
+
82
+ def _validate_svg(data: bytes) -> None:
83
+ """Validate SVG content for dangerous patterns.
84
+
85
+ Args:
86
+ data: Raw SVG bytes.
87
+
88
+ Raises:
89
+ RenderError: If dangerous content is detected.
90
+ """
91
+ try:
92
+ text = data.decode("utf-8")
93
+ except UnicodeDecodeError:
94
+ msg = "SVG content is not valid UTF-8"
95
+ raise RenderError(msg) # noqa: B904 - intentional chain break for clean message
96
+ if _SVG_DANGEROUS_PATTERN.search(text):
97
+ msg = "SVG contains dangerous content (script or event handler)"
98
+ raise RenderError(msg)
99
+
100
+
101
+ # ---------------------------------------------------------------------------
102
+ # MonitorImage
103
+ # ---------------------------------------------------------------------------
104
+
105
+
106
+ @dataclass(frozen=True, slots=True)
107
+ class MonitorImage:
108
+ r"""An image rendered as an HTML ``<img>`` with a base64 data URI.
109
+
110
+ The image data can be provided directly as ``bytes`` or loaded from
111
+ a file ``path``. Exactly one of ``data`` or ``path`` must be given.
112
+
113
+ Attributes:
114
+ data: Raw image bytes. Mutually exclusive with ``path``.
115
+ path: Path to an image file. Mutually exclusive with ``data``.
116
+ alt: Alt text for the ``<img>`` tag (always HTML-escaped).
117
+ width: Optional width attribute (pixels).
118
+ height: Optional height attribute (pixels).
119
+
120
+ Raises:
121
+ RenderError: If both or neither of ``data``/``path`` are given,
122
+ the image exceeds size limits, or the format is unsupported.
123
+
124
+ Examples:
125
+ >>> from kstlib.monitoring.image import MonitorImage
126
+ >>> img = MonitorImage(b"\x89PNG\r\n\x1a\n" + b"\x00" * 50, alt="Logo")
127
+ >>> "<img" in img.render()
128
+ True
129
+ """
130
+
131
+ data: bytes | None = None
132
+ path: Path | None = None
133
+ alt: str = ""
134
+ width: int | None = None
135
+ height: int | None = None
136
+
137
+ def __post_init__(self) -> None:
138
+ """Validate inputs at construction time."""
139
+ if self.data is not None and self.path is not None:
140
+ msg = "Provide either data or path, not both"
141
+ raise RenderError(msg)
142
+ if self.data is None and self.path is None:
143
+ msg = "Provide either data or path"
144
+ raise RenderError(msg)
145
+ if self.width is not None and self.width <= 0:
146
+ msg = f"width must be positive, got {self.width}"
147
+ raise RenderError(msg)
148
+ if self.height is not None and self.height <= 0:
149
+ msg = f"height must be positive, got {self.height}"
150
+ raise RenderError(msg)
151
+
152
+ def _load_data(self) -> bytes:
153
+ """Load and validate image bytes."""
154
+ if self.data is not None:
155
+ raw = self.data
156
+ else:
157
+ assert self.path is not None # guaranteed by __post_init__
158
+ if not self.path.is_file():
159
+ msg = f"Image file not found: {self.path}"
160
+ raise RenderError(msg)
161
+ raw = self.path.read_bytes()
162
+
163
+ if len(raw) > IMAGE_MAX_BYTES:
164
+ size_kb = len(raw) // 1024
165
+ limit_kb = IMAGE_MAX_BYTES // 1024
166
+ msg = f"Image too large: {size_kb} KB (limit: {limit_kb} KB)"
167
+ raise RenderError(msg)
168
+
169
+ if len(raw) < 4:
170
+ msg = "Image data too small to be valid"
171
+ raise RenderError(msg)
172
+
173
+ return raw
174
+
175
+ def render(self, *, inline_css: bool = False) -> str:
176
+ """Render the image as an HTML ``<img>`` with a data URI.
177
+
178
+ The ``inline_css`` parameter is accepted for protocol conformance
179
+ but has no effect on image rendering (images are always inline).
180
+
181
+ Args:
182
+ inline_css: Accepted for Renderable protocol compatibility.
183
+
184
+ Returns:
185
+ HTML ``<img>`` string with base64 data URI.
186
+
187
+ Raises:
188
+ RenderError: If the image cannot be loaded, exceeds size
189
+ limits, has an unsupported format, or (for SVG) contains
190
+ dangerous content.
191
+ """
192
+ _ = inline_css # accepted for Renderable protocol, no effect on images
193
+ raw = self._load_data()
194
+ mime = _detect_mime_type(raw)
195
+ if mime is None:
196
+ msg = "Unsupported image format (allowed: PNG, JPEG, GIF, WebP, SVG)"
197
+ raise RenderError(msg)
198
+ if mime not in ALLOWED_MIME_TYPES:
199
+ msg = f"Image type {mime} is not allowed"
200
+ raise RenderError(msg)
201
+ if mime == "image/svg+xml":
202
+ _validate_svg(raw)
203
+
204
+ b64 = base64.b64encode(raw).decode("ascii")
205
+ escaped_alt = html.escape(self.alt)
206
+
207
+ attrs = [f'src="data:{mime};base64,{b64}"', f'alt="{escaped_alt}"']
208
+ if self.width is not None:
209
+ attrs.append(f'width="{self.width}"')
210
+ if self.height is not None:
211
+ attrs.append(f'height="{self.height}"')
212
+
213
+ return f"<img {' '.join(attrs)}>"
214
+
215
+
216
+ __all__ = [
217
+ "ALLOWED_MIME_TYPES",
218
+ "IMAGE_MAX_BYTES",
219
+ "MonitorImage",
220
+ ]
@@ -0,0 +1,79 @@
1
+ """MonitorKV render type for key-value pair display.
2
+
3
+ A ``MonitorKV`` renders as an HTML ``<dl>`` definition list with a
4
+ two-column grid layout, suitable for status summaries and stat panels.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import html
10
+ from dataclasses import dataclass
11
+ from typing import TYPE_CHECKING
12
+
13
+ from kstlib.monitoring.cell import StatusCell
14
+
15
+ if TYPE_CHECKING:
16
+ from kstlib.monitoring.types import CellValue
17
+
18
+
19
+ @dataclass(frozen=True, slots=True)
20
+ class MonitorKV:
21
+ """A key-value grid rendered as an HTML ``<dl>``.
22
+
23
+ Values can be plain scalars or ``StatusCell`` objects for colored badges.
24
+
25
+ Attributes:
26
+ items: Ordered mapping of keys to values.
27
+ title: Optional heading rendered above the list.
28
+
29
+ Examples:
30
+ >>> from kstlib.monitoring.kv import MonitorKV
31
+ >>> kv = MonitorKV({"Host": "srv-01", "Port": 8080})
32
+ >>> "<dl" in kv.render()
33
+ True
34
+ """
35
+
36
+ items: dict[str, CellValue | StatusCell]
37
+ title: str = ""
38
+
39
+ def render(self, *, inline_css: bool = False) -> str:
40
+ """Render the key-value pairs as an HTML ``<dl>``.
41
+
42
+ Args:
43
+ inline_css: If True, use inline styles instead of CSS classes.
44
+
45
+ Returns:
46
+ HTML ``<dl>`` string, optionally preceded by an ``<h3>`` title.
47
+ """
48
+ parts: list[str] = []
49
+
50
+ if self.title:
51
+ escaped_title = html.escape(self.title)
52
+ parts.append(f"<h3>{escaped_title}</h3>")
53
+
54
+ if inline_css:
55
+ style = "display:grid;grid-template-columns:auto 1fr;gap:4px 12px"
56
+ parts.append(f'<dl style="{style}">')
57
+ else:
58
+ parts.append('<dl class="monitor-kv">')
59
+
60
+ for key, value in self.items.items():
61
+ escaped_key = html.escape(str(key))
62
+ if inline_css:
63
+ parts.append(f'<dt style="font-weight:bold">{escaped_key}</dt>')
64
+ else:
65
+ parts.append(f"<dt>{escaped_key}</dt>")
66
+
67
+ if isinstance(value, StatusCell):
68
+ rendered_value = value.render(inline_css=inline_css)
69
+ else:
70
+ rendered_value = html.escape(str(value))
71
+ parts.append(f"<dd>{rendered_value}</dd>")
72
+
73
+ parts.append("</dl>")
74
+ return "".join(parts)
75
+
76
+
77
+ __all__ = [
78
+ "MonitorKV",
79
+ ]
@@ -0,0 +1,69 @@
1
+ """MonitorList render type for ordered and unordered lists.
2
+
3
+ A ``MonitorList`` renders as an HTML ``<ul>`` or ``<ol>`` element,
4
+ suitable for event logs, alert lists, and step-by-step status reports.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import html
10
+ from dataclasses import dataclass
11
+ from typing import TYPE_CHECKING
12
+
13
+ from kstlib.monitoring.cell import StatusCell
14
+
15
+ if TYPE_CHECKING:
16
+ from kstlib.monitoring.types import CellValue
17
+
18
+
19
+ @dataclass(frozen=True, slots=True)
20
+ class MonitorList:
21
+ """A list rendered as an HTML ``<ul>`` or ``<ol>``.
22
+
23
+ Items can be plain scalars or ``StatusCell`` objects for colored badges.
24
+
25
+ Attributes:
26
+ items: Sequence of list items.
27
+ ordered: If True, render as ``<ol>``; otherwise ``<ul>``.
28
+ title: Optional heading rendered above the list.
29
+
30
+ Examples:
31
+ >>> from kstlib.monitoring.list import MonitorList
32
+ >>> ml = MonitorList(["Event A", "Event B"])
33
+ >>> "<ul>" in ml.render()
34
+ True
35
+ """
36
+
37
+ items: list[CellValue | StatusCell]
38
+ ordered: bool = False
39
+ title: str = ""
40
+
41
+ def render(self, *, inline_css: bool = False) -> str:
42
+ """Render the list as an HTML ``<ul>`` or ``<ol>``.
43
+
44
+ Args:
45
+ inline_css: If True, use inline styles instead of CSS classes.
46
+
47
+ Returns:
48
+ HTML list string, optionally preceded by an ``<h3>`` title.
49
+ """
50
+ parts: list[str] = []
51
+
52
+ if self.title:
53
+ escaped_title = html.escape(self.title)
54
+ parts.append(f"<h3>{escaped_title}</h3>")
55
+
56
+ tag = "ol" if self.ordered else "ul"
57
+ parts.append(f"<{tag}>")
58
+
59
+ for item in self.items:
60
+ rendered = item.render(inline_css=inline_css) if isinstance(item, StatusCell) else html.escape(str(item))
61
+ parts.append(f"<li>{rendered}</li>")
62
+
63
+ parts.append(f"</{tag}>")
64
+ return "".join(parts)
65
+
66
+
67
+ __all__ = [
68
+ "MonitorList",
69
+ ]
@@ -0,0 +1,88 @@
1
+ """MonitorMetric render type for hero number display.
2
+
3
+ A ``MonitorMetric`` renders as a ``<div>`` with a large value and an
4
+ optional label, suitable for KPI dashboards (P&L, uptime, etc.).
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import html
10
+ from dataclasses import dataclass
11
+ from typing import TYPE_CHECKING
12
+
13
+ from kstlib.monitoring._styles import (
14
+ METRIC_FONT_SIZE,
15
+ METRIC_LABEL_COLOR,
16
+ STATUS_COLORS,
17
+ )
18
+ from kstlib.monitoring.types import StatusLevel
19
+
20
+ if TYPE_CHECKING:
21
+ from kstlib.monitoring.types import CellValue
22
+
23
+
24
+ @dataclass(frozen=True, slots=True)
25
+ class MonitorMetric:
26
+ """A hero-number metric rendered as an HTML ``<div>``.
27
+
28
+ Attributes:
29
+ value: The metric value to display prominently.
30
+ label: Optional descriptive label shown below the value.
31
+ level: Severity level controlling the value color.
32
+ unit: Optional unit suffix appended to the value (e.g. "%", "ms").
33
+
34
+ Examples:
35
+ >>> from kstlib.monitoring.metric import MonitorMetric
36
+ >>> from kstlib.monitoring.types import StatusLevel
37
+ >>> m = MonitorMetric(99.9, label="Uptime", level=StatusLevel.OK, unit="%")
38
+ >>> "99.9" in m.render()
39
+ True
40
+ """
41
+
42
+ value: CellValue
43
+ label: str = ""
44
+ level: StatusLevel = StatusLevel.OK
45
+ unit: str = ""
46
+
47
+ def render(self, *, inline_css: bool = False) -> str:
48
+ """Render the metric as an HTML ``<div>``.
49
+
50
+ Args:
51
+ inline_css: If True, use inline styles instead of CSS classes.
52
+
53
+ Returns:
54
+ HTML ``<div>`` string.
55
+ """
56
+ escaped_val = html.escape(str(self.value))
57
+ escaped_unit = html.escape(self.unit)
58
+ display = f"{escaped_val}{escaped_unit}" if self.unit else escaped_val
59
+
60
+ if inline_css:
61
+ color = STATUS_COLORS[self.level]
62
+ wrapper_style = "text-align:center;padding:12px"
63
+ value_style = f"font-size:{METRIC_FONT_SIZE};font-weight:bold;color:{color}"
64
+ parts = [
65
+ f'<div style="{wrapper_style}">',
66
+ f'<div style="{value_style}">{display}</div>',
67
+ ]
68
+ if self.label:
69
+ label_style = f"color:{METRIC_LABEL_COLOR};font-size:0.9em"
70
+ escaped_label = html.escape(self.label)
71
+ parts.append(f'<div style="{label_style}">{escaped_label}</div>')
72
+ parts.append("</div>")
73
+ return "".join(parts)
74
+
75
+ parts = [
76
+ '<div class="monitor-metric">',
77
+ f'<div class="metric-value">{display}</div>',
78
+ ]
79
+ if self.label:
80
+ escaped_label = html.escape(self.label)
81
+ parts.append(f'<div class="metric-label">{escaped_label}</div>')
82
+ parts.append("</div>")
83
+ return "".join(parts)
84
+
85
+
86
+ __all__ = [
87
+ "MonitorMetric",
88
+ ]