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.
- kstlib/__init__.py +266 -1
- kstlib/__main__.py +16 -0
- kstlib/alerts/__init__.py +110 -0
- kstlib/alerts/channels/__init__.py +36 -0
- kstlib/alerts/channels/base.py +197 -0
- kstlib/alerts/channels/email.py +227 -0
- kstlib/alerts/channels/slack.py +389 -0
- kstlib/alerts/exceptions.py +72 -0
- kstlib/alerts/manager.py +651 -0
- kstlib/alerts/models.py +142 -0
- kstlib/alerts/throttle.py +263 -0
- kstlib/auth/__init__.py +139 -0
- kstlib/auth/callback.py +399 -0
- kstlib/auth/config.py +502 -0
- kstlib/auth/errors.py +127 -0
- kstlib/auth/models.py +316 -0
- kstlib/auth/providers/__init__.py +14 -0
- kstlib/auth/providers/base.py +393 -0
- kstlib/auth/providers/oauth2.py +645 -0
- kstlib/auth/providers/oidc.py +821 -0
- kstlib/auth/session.py +338 -0
- kstlib/auth/token.py +482 -0
- kstlib/cache/__init__.py +50 -0
- kstlib/cache/decorator.py +261 -0
- kstlib/cache/strategies.py +516 -0
- kstlib/cli/__init__.py +8 -0
- kstlib/cli/app.py +195 -0
- kstlib/cli/commands/__init__.py +5 -0
- kstlib/cli/commands/auth/__init__.py +39 -0
- kstlib/cli/commands/auth/common.py +122 -0
- kstlib/cli/commands/auth/login.py +325 -0
- kstlib/cli/commands/auth/logout.py +74 -0
- kstlib/cli/commands/auth/providers.py +57 -0
- kstlib/cli/commands/auth/status.py +291 -0
- kstlib/cli/commands/auth/token.py +199 -0
- kstlib/cli/commands/auth/whoami.py +106 -0
- kstlib/cli/commands/config.py +89 -0
- kstlib/cli/commands/ops/__init__.py +39 -0
- kstlib/cli/commands/ops/attach.py +49 -0
- kstlib/cli/commands/ops/common.py +269 -0
- kstlib/cli/commands/ops/list_sessions.py +252 -0
- kstlib/cli/commands/ops/logs.py +49 -0
- kstlib/cli/commands/ops/start.py +98 -0
- kstlib/cli/commands/ops/status.py +138 -0
- kstlib/cli/commands/ops/stop.py +60 -0
- kstlib/cli/commands/rapi/__init__.py +60 -0
- kstlib/cli/commands/rapi/call.py +341 -0
- kstlib/cli/commands/rapi/list.py +99 -0
- kstlib/cli/commands/rapi/show.py +206 -0
- kstlib/cli/commands/secrets/__init__.py +35 -0
- kstlib/cli/commands/secrets/common.py +425 -0
- kstlib/cli/commands/secrets/decrypt.py +88 -0
- kstlib/cli/commands/secrets/doctor.py +743 -0
- kstlib/cli/commands/secrets/encrypt.py +242 -0
- kstlib/cli/commands/secrets/shred.py +96 -0
- kstlib/cli/common.py +86 -0
- kstlib/config/__init__.py +76 -0
- kstlib/config/exceptions.py +110 -0
- kstlib/config/export.py +225 -0
- kstlib/config/loader.py +963 -0
- kstlib/config/sops.py +287 -0
- kstlib/db/__init__.py +54 -0
- kstlib/db/aiosqlcipher.py +137 -0
- kstlib/db/cipher.py +112 -0
- kstlib/db/database.py +367 -0
- kstlib/db/exceptions.py +25 -0
- kstlib/db/pool.py +302 -0
- kstlib/helpers/__init__.py +35 -0
- kstlib/helpers/exceptions.py +11 -0
- kstlib/helpers/time_trigger.py +396 -0
- kstlib/kstlib.conf.yml +890 -0
- kstlib/limits.py +963 -0
- kstlib/logging/__init__.py +108 -0
- kstlib/logging/manager.py +633 -0
- kstlib/mail/__init__.py +42 -0
- kstlib/mail/builder.py +626 -0
- kstlib/mail/exceptions.py +27 -0
- kstlib/mail/filesystem.py +248 -0
- kstlib/mail/transport.py +224 -0
- kstlib/mail/transports/__init__.py +19 -0
- kstlib/mail/transports/gmail.py +268 -0
- kstlib/mail/transports/resend.py +324 -0
- kstlib/mail/transports/smtp.py +326 -0
- kstlib/meta.py +72 -0
- kstlib/metrics/__init__.py +88 -0
- kstlib/metrics/decorators.py +1090 -0
- kstlib/metrics/exceptions.py +14 -0
- kstlib/monitoring/__init__.py +116 -0
- kstlib/monitoring/_styles.py +163 -0
- kstlib/monitoring/cell.py +57 -0
- kstlib/monitoring/config.py +424 -0
- kstlib/monitoring/delivery.py +579 -0
- kstlib/monitoring/exceptions.py +63 -0
- kstlib/monitoring/image.py +220 -0
- kstlib/monitoring/kv.py +79 -0
- kstlib/monitoring/list.py +69 -0
- kstlib/monitoring/metric.py +88 -0
- kstlib/monitoring/monitoring.py +341 -0
- kstlib/monitoring/renderer.py +139 -0
- kstlib/monitoring/service.py +392 -0
- kstlib/monitoring/table.py +129 -0
- kstlib/monitoring/types.py +56 -0
- kstlib/ops/__init__.py +86 -0
- kstlib/ops/base.py +148 -0
- kstlib/ops/container.py +577 -0
- kstlib/ops/exceptions.py +209 -0
- kstlib/ops/manager.py +407 -0
- kstlib/ops/models.py +176 -0
- kstlib/ops/tmux.py +372 -0
- kstlib/ops/validators.py +287 -0
- kstlib/py.typed +0 -0
- kstlib/rapi/__init__.py +118 -0
- kstlib/rapi/client.py +875 -0
- kstlib/rapi/config.py +861 -0
- kstlib/rapi/credentials.py +887 -0
- kstlib/rapi/exceptions.py +213 -0
- kstlib/resilience/__init__.py +101 -0
- kstlib/resilience/circuit_breaker.py +440 -0
- kstlib/resilience/exceptions.py +95 -0
- kstlib/resilience/heartbeat.py +491 -0
- kstlib/resilience/rate_limiter.py +506 -0
- kstlib/resilience/shutdown.py +417 -0
- kstlib/resilience/watchdog.py +637 -0
- kstlib/secrets/__init__.py +29 -0
- kstlib/secrets/exceptions.py +19 -0
- kstlib/secrets/models.py +62 -0
- kstlib/secrets/providers/__init__.py +79 -0
- kstlib/secrets/providers/base.py +58 -0
- kstlib/secrets/providers/environment.py +66 -0
- kstlib/secrets/providers/keyring.py +107 -0
- kstlib/secrets/providers/kms.py +223 -0
- kstlib/secrets/providers/kwargs.py +101 -0
- kstlib/secrets/providers/sops.py +209 -0
- kstlib/secrets/resolver.py +221 -0
- kstlib/secrets/sensitive.py +130 -0
- kstlib/secure/__init__.py +23 -0
- kstlib/secure/fs.py +194 -0
- kstlib/secure/permissions.py +70 -0
- kstlib/ssl.py +347 -0
- kstlib/ui/__init__.py +23 -0
- kstlib/ui/exceptions.py +26 -0
- kstlib/ui/panels.py +484 -0
- kstlib/ui/spinner.py +864 -0
- kstlib/ui/tables.py +382 -0
- kstlib/utils/__init__.py +48 -0
- kstlib/utils/dict.py +36 -0
- kstlib/utils/formatting.py +338 -0
- kstlib/utils/http_trace.py +237 -0
- kstlib/utils/lazy.py +49 -0
- kstlib/utils/secure_delete.py +205 -0
- kstlib/utils/serialization.py +247 -0
- kstlib/utils/text.py +56 -0
- kstlib/utils/validators.py +124 -0
- kstlib/websocket/__init__.py +97 -0
- kstlib/websocket/exceptions.py +214 -0
- kstlib/websocket/manager.py +1102 -0
- kstlib/websocket/models.py +361 -0
- kstlib-1.0.1.dist-info/METADATA +201 -0
- kstlib-1.0.1.dist-info/RECORD +163 -0
- {kstlib-0.0.1a0.dist-info → kstlib-1.0.1.dist-info}/WHEEL +1 -1
- kstlib-1.0.1.dist-info/entry_points.txt +2 -0
- kstlib-1.0.1.dist-info/licenses/LICENSE.md +9 -0
- kstlib-0.0.1a0.dist-info/METADATA +0 -29
- kstlib-0.0.1a0.dist-info/RECORD +0 -6
- kstlib-0.0.1a0.dist-info/licenses/LICENSE.md +0 -5
- {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
|
+
]
|
kstlib/monitoring/kv.py
ADDED
|
@@ -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
|
+
]
|