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,392 @@
|
|
|
1
|
+
"""MonitoringService orchestrator for data collection, rendering, and delivery.
|
|
2
|
+
|
|
3
|
+
The MonitoringService provides a high-level API for building monitoring dashboards:
|
|
4
|
+
|
|
5
|
+
1. **Collect** - Run data collectors (sync or async callables)
|
|
6
|
+
2. **Render** - Generate HTML using Jinja2 templates and render types
|
|
7
|
+
3. **Deliver** - Send via email (kstlib.mail) or save to file
|
|
8
|
+
|
|
9
|
+
Examples:
|
|
10
|
+
Basic usage with collectors:
|
|
11
|
+
|
|
12
|
+
>>> from kstlib.monitoring import MonitoringService, StatusCell, StatusLevel
|
|
13
|
+
>>> service = MonitoringService(
|
|
14
|
+
... template=\"\"\"<p>Status: {{ status | render }}</p>\"\"\",
|
|
15
|
+
... collectors={
|
|
16
|
+
... "status": lambda: StatusCell("UP", StatusLevel.OK),
|
|
17
|
+
... },
|
|
18
|
+
... )
|
|
19
|
+
>>> result = service.run_sync()
|
|
20
|
+
>>> "status-ok" in result.html
|
|
21
|
+
True
|
|
22
|
+
|
|
23
|
+
Async collectors for real-time data:
|
|
24
|
+
|
|
25
|
+
>>> async def get_metrics():
|
|
26
|
+
... # Fetch from API, database, etc.
|
|
27
|
+
... return {"cpu": 75.2, "memory": 8192}
|
|
28
|
+
>>> service = MonitoringService(
|
|
29
|
+
... template=\"\"\"<p>CPU: {{ metrics.cpu }}%</p>\"\"\",
|
|
30
|
+
... collectors={"metrics": get_metrics},
|
|
31
|
+
... )
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
from __future__ import annotations
|
|
35
|
+
|
|
36
|
+
import asyncio
|
|
37
|
+
import inspect
|
|
38
|
+
from collections.abc import Awaitable, Callable
|
|
39
|
+
from dataclasses import dataclass, field
|
|
40
|
+
from datetime import datetime, timezone
|
|
41
|
+
from typing import TYPE_CHECKING, Any
|
|
42
|
+
|
|
43
|
+
from kstlib.monitoring.exceptions import CollectorError, RenderError
|
|
44
|
+
from kstlib.monitoring.renderer import create_environment
|
|
45
|
+
|
|
46
|
+
if TYPE_CHECKING:
|
|
47
|
+
from email.message import EmailMessage
|
|
48
|
+
|
|
49
|
+
from kstlib.mail.transport import AsyncMailTransport, MailTransport
|
|
50
|
+
|
|
51
|
+
# Type alias for collectors: sync or async callables returning any data
|
|
52
|
+
Collector = Callable[[], Any] | Callable[[], Awaitable[Any]]
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@dataclass
|
|
56
|
+
class MonitoringResult:
|
|
57
|
+
"""Result of a monitoring run.
|
|
58
|
+
|
|
59
|
+
Attributes:
|
|
60
|
+
html: The rendered HTML string.
|
|
61
|
+
data: The collected data dictionary.
|
|
62
|
+
collected_at: Timestamp when data was collected.
|
|
63
|
+
rendered_at: Timestamp when HTML was rendered.
|
|
64
|
+
errors: List of collector errors (if fail_fast=False).
|
|
65
|
+
"""
|
|
66
|
+
|
|
67
|
+
html: str
|
|
68
|
+
data: dict[str, Any]
|
|
69
|
+
collected_at: datetime
|
|
70
|
+
rendered_at: datetime
|
|
71
|
+
errors: list[CollectorError] = field(default_factory=list)
|
|
72
|
+
|
|
73
|
+
@property
|
|
74
|
+
def success(self) -> bool:
|
|
75
|
+
"""Return True if no collector errors occurred."""
|
|
76
|
+
return len(self.errors) == 0
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class MonitoringService:
|
|
80
|
+
"""Orchestrator for collecting, rendering, and delivering monitoring dashboards.
|
|
81
|
+
|
|
82
|
+
The service manages the full lifecycle of a monitoring dashboard:
|
|
83
|
+
|
|
84
|
+
1. **Collectors** - Register data sources as sync or async callables
|
|
85
|
+
2. **Template** - Jinja2 template with ``| render`` filter support
|
|
86
|
+
3. **Render** - Generate HTML with automatic CSS handling
|
|
87
|
+
4. **Deliver** - Optional email delivery via kstlib.mail transports
|
|
88
|
+
|
|
89
|
+
Args:
|
|
90
|
+
template: Jinja2 template string for rendering the dashboard.
|
|
91
|
+
collectors: Optional dict mapping names to collector callables.
|
|
92
|
+
inline_css: If True (default), use inline CSS for email compatibility.
|
|
93
|
+
fail_fast: If True, raise on first collector error. If False, continue
|
|
94
|
+
and report errors in the result.
|
|
95
|
+
|
|
96
|
+
Examples:
|
|
97
|
+
Simple dashboard with status cell:
|
|
98
|
+
|
|
99
|
+
>>> from kstlib.monitoring import MonitoringService, StatusCell, StatusLevel
|
|
100
|
+
>>> service = MonitoringService(
|
|
101
|
+
... template=\"\"\"<div>{{ status | render }}</div>\"\"\",
|
|
102
|
+
... collectors={"status": lambda: StatusCell("OK", StatusLevel.OK)},
|
|
103
|
+
... )
|
|
104
|
+
>>> result = service.run_sync()
|
|
105
|
+
>>> "OK" in result.html
|
|
106
|
+
True
|
|
107
|
+
|
|
108
|
+
Adding collectors after construction (chainable):
|
|
109
|
+
|
|
110
|
+
>>> service = MonitoringService(template="<p>{{ msg }}</p>")
|
|
111
|
+
>>> service.add_collector("msg", lambda: "Hello").run_sync().html
|
|
112
|
+
'<p>Hello</p>'
|
|
113
|
+
"""
|
|
114
|
+
|
|
115
|
+
def __init__(
|
|
116
|
+
self,
|
|
117
|
+
template: str,
|
|
118
|
+
collectors: dict[str, Collector] | None = None,
|
|
119
|
+
*,
|
|
120
|
+
inline_css: bool = True,
|
|
121
|
+
fail_fast: bool = True,
|
|
122
|
+
) -> None:
|
|
123
|
+
"""Initialize the monitoring service.
|
|
124
|
+
|
|
125
|
+
Args:
|
|
126
|
+
template: Jinja2 template string.
|
|
127
|
+
collectors: Optional initial collectors dict.
|
|
128
|
+
inline_css: Use inline CSS for email compatibility.
|
|
129
|
+
fail_fast: Raise immediately on collector errors.
|
|
130
|
+
"""
|
|
131
|
+
self._template = template
|
|
132
|
+
self._collectors: dict[str, Collector] = dict(collectors) if collectors else {}
|
|
133
|
+
self._inline_css = inline_css
|
|
134
|
+
self._fail_fast = fail_fast
|
|
135
|
+
self._env = create_environment()
|
|
136
|
+
|
|
137
|
+
@property
|
|
138
|
+
def template(self) -> str:
|
|
139
|
+
"""Return the template string."""
|
|
140
|
+
return self._template
|
|
141
|
+
|
|
142
|
+
@property
|
|
143
|
+
def inline_css(self) -> bool:
|
|
144
|
+
"""Return whether inline CSS is enabled."""
|
|
145
|
+
return self._inline_css
|
|
146
|
+
|
|
147
|
+
@property
|
|
148
|
+
def collector_names(self) -> list[str]:
|
|
149
|
+
"""Return list of registered collector names."""
|
|
150
|
+
return list(self._collectors)
|
|
151
|
+
|
|
152
|
+
def add_collector(self, name: str, collector: Collector) -> MonitoringService:
|
|
153
|
+
"""Add a data collector.
|
|
154
|
+
|
|
155
|
+
Collectors are callables (sync or async) that return data to be passed
|
|
156
|
+
to the template. The returned data can be any type including Renderable
|
|
157
|
+
objects like StatusCell, MonitorTable, etc.
|
|
158
|
+
|
|
159
|
+
Args:
|
|
160
|
+
name: Name to use in the template (e.g., "status" for {{ status }}).
|
|
161
|
+
collector: Callable returning the data. Can be sync or async.
|
|
162
|
+
|
|
163
|
+
Returns:
|
|
164
|
+
Self for method chaining.
|
|
165
|
+
|
|
166
|
+
Examples:
|
|
167
|
+
>>> service = MonitoringService(template="<p>{{ x }} + {{ y }}</p>")
|
|
168
|
+
>>> service.add_collector("x", lambda: 1).add_collector("y", lambda: 2)
|
|
169
|
+
<kstlib.monitoring.service.MonitoringService object at ...>
|
|
170
|
+
"""
|
|
171
|
+
self._collectors[name] = collector
|
|
172
|
+
return self
|
|
173
|
+
|
|
174
|
+
def remove_collector(self, name: str) -> MonitoringService:
|
|
175
|
+
"""Remove a collector by name.
|
|
176
|
+
|
|
177
|
+
Args:
|
|
178
|
+
name: Name of the collector to remove.
|
|
179
|
+
|
|
180
|
+
Returns:
|
|
181
|
+
Self for method chaining.
|
|
182
|
+
|
|
183
|
+
Raises:
|
|
184
|
+
KeyError: If collector name not found.
|
|
185
|
+
"""
|
|
186
|
+
del self._collectors[name]
|
|
187
|
+
return self
|
|
188
|
+
|
|
189
|
+
def _cancel_tasks(self, tasks: dict[str, asyncio.Task[Any]], exclude: str = "") -> None:
|
|
190
|
+
"""Cancel all pending async tasks except the excluded one."""
|
|
191
|
+
for name, task in tasks.items():
|
|
192
|
+
if name != exclude and not task.done():
|
|
193
|
+
task.cancel()
|
|
194
|
+
|
|
195
|
+
def _handle_collector_error(
|
|
196
|
+
self,
|
|
197
|
+
name: str,
|
|
198
|
+
exc: Exception,
|
|
199
|
+
errors: list[CollectorError],
|
|
200
|
+
tasks: dict[str, asyncio.Task[Any]],
|
|
201
|
+
) -> CollectorError:
|
|
202
|
+
"""Handle a collector error: cancel tasks if fail_fast, else append to errors."""
|
|
203
|
+
error = CollectorError(name, exc)
|
|
204
|
+
if self._fail_fast:
|
|
205
|
+
self._cancel_tasks(tasks)
|
|
206
|
+
raise error from exc
|
|
207
|
+
errors.append(error)
|
|
208
|
+
return error
|
|
209
|
+
|
|
210
|
+
async def collect(self) -> tuple[dict[str, Any], list[CollectorError]]:
|
|
211
|
+
"""Run all collectors and gather data.
|
|
212
|
+
|
|
213
|
+
Collectors are run concurrently when possible. Async collectors are
|
|
214
|
+
awaited, sync collectors are called directly.
|
|
215
|
+
|
|
216
|
+
Returns:
|
|
217
|
+
Tuple of (collected data dict, list of errors).
|
|
218
|
+
|
|
219
|
+
Raises:
|
|
220
|
+
CollectorError: If fail_fast=True and any collector fails.
|
|
221
|
+
"""
|
|
222
|
+
data: dict[str, Any] = {}
|
|
223
|
+
errors: list[CollectorError] = []
|
|
224
|
+
async_tasks: dict[str, asyncio.Task[Any]] = {}
|
|
225
|
+
sync_collectors: dict[str, Collector] = {}
|
|
226
|
+
|
|
227
|
+
# Separate and schedule collectors
|
|
228
|
+
for name, collector in self._collectors.items():
|
|
229
|
+
if inspect.iscoroutinefunction(collector):
|
|
230
|
+
async_tasks[name] = asyncio.create_task(collector())
|
|
231
|
+
else:
|
|
232
|
+
sync_collectors[name] = collector
|
|
233
|
+
|
|
234
|
+
# Run sync collectors
|
|
235
|
+
for name, collector in sync_collectors.items():
|
|
236
|
+
try:
|
|
237
|
+
data[name] = collector()
|
|
238
|
+
except Exception as e:
|
|
239
|
+
self._handle_collector_error(name, e, errors, async_tasks)
|
|
240
|
+
data[name] = None
|
|
241
|
+
|
|
242
|
+
# Await async collectors
|
|
243
|
+
for name, task in async_tasks.items():
|
|
244
|
+
try:
|
|
245
|
+
data[name] = await task
|
|
246
|
+
except asyncio.CancelledError:
|
|
247
|
+
raise
|
|
248
|
+
except Exception as e:
|
|
249
|
+
self._handle_collector_error(name, e, errors, async_tasks)
|
|
250
|
+
data[name] = None
|
|
251
|
+
|
|
252
|
+
return data, errors
|
|
253
|
+
|
|
254
|
+
def render(self, data: dict[str, Any]) -> str:
|
|
255
|
+
"""Render the template with collected data.
|
|
256
|
+
|
|
257
|
+
Args:
|
|
258
|
+
data: Dictionary of data to pass to the template.
|
|
259
|
+
|
|
260
|
+
Returns:
|
|
261
|
+
Rendered HTML string.
|
|
262
|
+
|
|
263
|
+
Raises:
|
|
264
|
+
RenderError: If template rendering fails.
|
|
265
|
+
"""
|
|
266
|
+
from kstlib.monitoring._styles import get_css_classes
|
|
267
|
+
|
|
268
|
+
try:
|
|
269
|
+
template = self._env.from_string(self._template)
|
|
270
|
+
html = template.render(**data)
|
|
271
|
+
except Exception as e:
|
|
272
|
+
raise RenderError(f"Template rendering failed: {e}") from e
|
|
273
|
+
|
|
274
|
+
# Prepend CSS classes if not using inline CSS
|
|
275
|
+
if not self._inline_css:
|
|
276
|
+
html = get_css_classes() + "\n" + html
|
|
277
|
+
|
|
278
|
+
return html
|
|
279
|
+
|
|
280
|
+
async def run(self) -> MonitoringResult:
|
|
281
|
+
"""Collect data and render the dashboard.
|
|
282
|
+
|
|
283
|
+
This is the main entry point for async usage. It runs all collectors,
|
|
284
|
+
renders the template, and returns a MonitoringResult.
|
|
285
|
+
|
|
286
|
+
Returns:
|
|
287
|
+
MonitoringResult with HTML, data, timestamps, and any errors.
|
|
288
|
+
|
|
289
|
+
Examples:
|
|
290
|
+
>>> import asyncio
|
|
291
|
+
>>> service = MonitoringService(
|
|
292
|
+
... template="<p>{{ msg }}</p>",
|
|
293
|
+
... collectors={"msg": lambda: "Hello"},
|
|
294
|
+
... )
|
|
295
|
+
>>> result = asyncio.run(service.run())
|
|
296
|
+
>>> "Hello" in result.html
|
|
297
|
+
True
|
|
298
|
+
"""
|
|
299
|
+
collected_at = datetime.now(timezone.utc)
|
|
300
|
+
data, errors = await self.collect()
|
|
301
|
+
|
|
302
|
+
html = self.render(data)
|
|
303
|
+
rendered_at = datetime.now(timezone.utc)
|
|
304
|
+
|
|
305
|
+
return MonitoringResult(
|
|
306
|
+
html=html,
|
|
307
|
+
data=data,
|
|
308
|
+
collected_at=collected_at,
|
|
309
|
+
rendered_at=rendered_at,
|
|
310
|
+
errors=errors,
|
|
311
|
+
)
|
|
312
|
+
|
|
313
|
+
def run_sync(self) -> MonitoringResult:
|
|
314
|
+
"""Synchronous version of run().
|
|
315
|
+
|
|
316
|
+
Convenience method for non-async contexts. Creates a new event loop
|
|
317
|
+
if needed.
|
|
318
|
+
|
|
319
|
+
Returns:
|
|
320
|
+
MonitoringResult with HTML, data, timestamps, and any errors.
|
|
321
|
+
|
|
322
|
+
Examples:
|
|
323
|
+
>>> service = MonitoringService(
|
|
324
|
+
... template="<p>{{ msg }}</p>",
|
|
325
|
+
... collectors={"msg": lambda: "World"},
|
|
326
|
+
... )
|
|
327
|
+
>>> "World" in service.run_sync().html
|
|
328
|
+
True
|
|
329
|
+
"""
|
|
330
|
+
try:
|
|
331
|
+
loop = asyncio.get_running_loop()
|
|
332
|
+
except RuntimeError:
|
|
333
|
+
loop = None
|
|
334
|
+
|
|
335
|
+
if loop is not None:
|
|
336
|
+
# Already in an async context - can't use asyncio.run
|
|
337
|
+
# Use a new thread or raise
|
|
338
|
+
import concurrent.futures
|
|
339
|
+
|
|
340
|
+
with concurrent.futures.ThreadPoolExecutor(max_workers=1) as executor:
|
|
341
|
+
future = executor.submit(asyncio.run, self.run())
|
|
342
|
+
return future.result()
|
|
343
|
+
|
|
344
|
+
return asyncio.run(self.run())
|
|
345
|
+
|
|
346
|
+
async def deliver(
|
|
347
|
+
self,
|
|
348
|
+
transport: MailTransport | AsyncMailTransport,
|
|
349
|
+
message_builder: Callable[[str], EmailMessage],
|
|
350
|
+
) -> MonitoringResult:
|
|
351
|
+
"""Collect, render, and deliver via email transport.
|
|
352
|
+
|
|
353
|
+
This combines run() with email delivery. The message_builder callable
|
|
354
|
+
receives the rendered HTML and should return a complete EmailMessage.
|
|
355
|
+
|
|
356
|
+
Args:
|
|
357
|
+
transport: Mail transport (sync or async) from kstlib.mail.
|
|
358
|
+
message_builder: Callable that takes HTML and returns EmailMessage.
|
|
359
|
+
|
|
360
|
+
Returns:
|
|
361
|
+
MonitoringResult from the run.
|
|
362
|
+
|
|
363
|
+
Examples:
|
|
364
|
+
>>> from email.message import EmailMessage
|
|
365
|
+
>>> def build_message(html: str) -> EmailMessage:
|
|
366
|
+
... msg = EmailMessage()
|
|
367
|
+
... msg["From"] = "bot@example.com"
|
|
368
|
+
... msg["To"] = "team@example.com"
|
|
369
|
+
... msg["Subject"] = "Dashboard"
|
|
370
|
+
... msg.set_content(html, subtype="html")
|
|
371
|
+
... return msg
|
|
372
|
+
"""
|
|
373
|
+
result = await self.run()
|
|
374
|
+
|
|
375
|
+
message = message_builder(result.html)
|
|
376
|
+
|
|
377
|
+
# Check if transport is async or sync
|
|
378
|
+
if hasattr(transport, "send") and inspect.iscoroutinefunction(transport.send):
|
|
379
|
+
await transport.send(message)
|
|
380
|
+
else:
|
|
381
|
+
# Sync transport - run in thread pool
|
|
382
|
+
loop = asyncio.get_running_loop()
|
|
383
|
+
await loop.run_in_executor(None, transport.send, message)
|
|
384
|
+
|
|
385
|
+
return result
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
__all__ = [
|
|
389
|
+
"Collector",
|
|
390
|
+
"MonitoringResult",
|
|
391
|
+
"MonitoringService",
|
|
392
|
+
]
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
"""MonitorTable render type for tabular status displays.
|
|
2
|
+
|
|
3
|
+
A ``MonitorTable`` renders as an HTML ``<table>`` with striped rows,
|
|
4
|
+
styled headers, and support for ``StatusCell`` values within cells.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import html
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
from typing import TYPE_CHECKING
|
|
12
|
+
|
|
13
|
+
from kstlib.monitoring._styles import (
|
|
14
|
+
TABLE_BORDER_COLOR,
|
|
15
|
+
TABLE_FONT_FAMILY,
|
|
16
|
+
TABLE_HEADER_BG,
|
|
17
|
+
TABLE_HEADER_TEXT,
|
|
18
|
+
TABLE_ROW_BG,
|
|
19
|
+
TABLE_ROW_TEXT,
|
|
20
|
+
TABLE_STRIPE_BG,
|
|
21
|
+
)
|
|
22
|
+
from kstlib.monitoring.cell import StatusCell
|
|
23
|
+
from kstlib.monitoring.exceptions import RenderError
|
|
24
|
+
|
|
25
|
+
if TYPE_CHECKING:
|
|
26
|
+
from kstlib.monitoring.types import CellValue
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass(slots=True)
|
|
30
|
+
class MonitorTable:
|
|
31
|
+
"""A table rendered as an HTML ``<table>`` with striped rows.
|
|
32
|
+
|
|
33
|
+
This is the only mutable render type: rows are added via :meth:`add_row`.
|
|
34
|
+
|
|
35
|
+
Attributes:
|
|
36
|
+
headers: Column headers.
|
|
37
|
+
title: Optional caption rendered above the table.
|
|
38
|
+
|
|
39
|
+
Examples:
|
|
40
|
+
>>> from kstlib.monitoring.table import MonitorTable
|
|
41
|
+
>>> t = MonitorTable(headers=["Service", "Status"])
|
|
42
|
+
>>> t.add_row(["API", "OK"])
|
|
43
|
+
>>> "<table" in t.render()
|
|
44
|
+
True
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
headers: list[str]
|
|
48
|
+
title: str = ""
|
|
49
|
+
_rows: list[list[CellValue | StatusCell]] = field(default_factory=list, init=False, repr=False)
|
|
50
|
+
|
|
51
|
+
def add_row(self, row: list[CellValue | StatusCell]) -> None:
|
|
52
|
+
"""Append a row to the table.
|
|
53
|
+
|
|
54
|
+
Args:
|
|
55
|
+
row: List of cell values matching the number of headers.
|
|
56
|
+
|
|
57
|
+
Raises:
|
|
58
|
+
RenderError: If the row length does not match the header count.
|
|
59
|
+
"""
|
|
60
|
+
if len(row) != len(self.headers):
|
|
61
|
+
msg = f"Row has {len(row)} cells but table has {len(self.headers)} headers"
|
|
62
|
+
raise RenderError(msg)
|
|
63
|
+
self._rows.append(row)
|
|
64
|
+
|
|
65
|
+
@property
|
|
66
|
+
def row_count(self) -> int:
|
|
67
|
+
"""Return the number of data rows."""
|
|
68
|
+
return len(self._rows)
|
|
69
|
+
|
|
70
|
+
def _render_cell(self, cell: CellValue | StatusCell, *, inline_css: bool) -> str:
|
|
71
|
+
"""Render a single cell value as an HTML ``<td>`` element."""
|
|
72
|
+
rendered = cell.render(inline_css=inline_css) if isinstance(cell, StatusCell) else html.escape(str(cell))
|
|
73
|
+
if inline_css:
|
|
74
|
+
td_style = f"padding:8px 12px;border-bottom:1px solid {TABLE_BORDER_COLOR}"
|
|
75
|
+
return f'<td style="{td_style}">{rendered}</td>'
|
|
76
|
+
return f"<td>{rendered}</td>"
|
|
77
|
+
|
|
78
|
+
def _render_header(self, text: str, *, inline_css: bool) -> str:
|
|
79
|
+
"""Render a single header as an HTML ``<th>`` element."""
|
|
80
|
+
escaped = html.escape(text)
|
|
81
|
+
if inline_css:
|
|
82
|
+
th_style = f"background:{TABLE_HEADER_BG};color:{TABLE_HEADER_TEXT};padding:8px 12px;text-align:left"
|
|
83
|
+
return f'<th style="{th_style}">{escaped}</th>'
|
|
84
|
+
return f"<th>{escaped}</th>"
|
|
85
|
+
|
|
86
|
+
def render(self, *, inline_css: bool = False) -> str:
|
|
87
|
+
"""Render the table as an HTML ``<table>``.
|
|
88
|
+
|
|
89
|
+
Args:
|
|
90
|
+
inline_css: If True, use inline styles instead of CSS classes.
|
|
91
|
+
|
|
92
|
+
Returns:
|
|
93
|
+
HTML ``<table>`` string.
|
|
94
|
+
"""
|
|
95
|
+
parts: list[str] = []
|
|
96
|
+
|
|
97
|
+
if self.title:
|
|
98
|
+
escaped_title = html.escape(self.title)
|
|
99
|
+
parts.append(f"<h3>{escaped_title}</h3>")
|
|
100
|
+
|
|
101
|
+
if inline_css:
|
|
102
|
+
table_style = f"border-collapse:collapse;width:100%;font-family:{TABLE_FONT_FAMILY};background:{TABLE_ROW_BG};color:{TABLE_ROW_TEXT}"
|
|
103
|
+
parts.append(f'<table style="{table_style}">')
|
|
104
|
+
else:
|
|
105
|
+
parts.append('<table class="monitor-table">')
|
|
106
|
+
|
|
107
|
+
# Header row
|
|
108
|
+
parts.append("<thead><tr>")
|
|
109
|
+
parts.extend(self._render_header(h, inline_css=inline_css) for h in self.headers)
|
|
110
|
+
parts.append("</tr></thead>")
|
|
111
|
+
|
|
112
|
+
# Data rows
|
|
113
|
+
parts.append("<tbody>")
|
|
114
|
+
for idx, row in enumerate(self._rows):
|
|
115
|
+
if inline_css and idx % 2 == 1:
|
|
116
|
+
parts.append(f'<tr style="background:{TABLE_STRIPE_BG}">')
|
|
117
|
+
else:
|
|
118
|
+
parts.append("<tr>")
|
|
119
|
+
parts.extend(self._render_cell(c, inline_css=inline_css) for c in row)
|
|
120
|
+
parts.append("</tr>")
|
|
121
|
+
parts.append("</tbody>")
|
|
122
|
+
|
|
123
|
+
parts.append("</table>")
|
|
124
|
+
return "".join(parts)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
__all__ = [
|
|
128
|
+
"MonitorTable",
|
|
129
|
+
]
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""Core types for the kstlib.monitoring module.
|
|
2
|
+
|
|
3
|
+
Defines ``StatusLevel`` enum, ``Renderable`` protocol, and ``CellValue`` alias.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from enum import IntEnum
|
|
9
|
+
from typing import Protocol, runtime_checkable
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class StatusLevel(IntEnum):
|
|
13
|
+
"""Severity level for monitoring status indicators.
|
|
14
|
+
|
|
15
|
+
Values are ordered by severity so comparisons work naturally:
|
|
16
|
+
``StatusLevel.OK < StatusLevel.WARNING < StatusLevel.ERROR``.
|
|
17
|
+
|
|
18
|
+
Attributes:
|
|
19
|
+
OK: Normal operation (#16A085 green).
|
|
20
|
+
WARNING: Degraded but functional (#F1C40F yellow).
|
|
21
|
+
ERROR: Service failure (#E85A4F red).
|
|
22
|
+
CRITICAL: Critical failure requiring immediate action (#c0392b dark red).
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
OK = 10
|
|
26
|
+
WARNING = 20
|
|
27
|
+
ERROR = 30
|
|
28
|
+
CRITICAL = 40
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@runtime_checkable
|
|
32
|
+
class Renderable(Protocol):
|
|
33
|
+
"""Protocol for objects that can render themselves as HTML."""
|
|
34
|
+
|
|
35
|
+
def render(self, *, inline_css: bool = False) -> str:
|
|
36
|
+
"""Render this object as an HTML string.
|
|
37
|
+
|
|
38
|
+
Args:
|
|
39
|
+
inline_css: If True, embed styles as inline ``style`` attributes
|
|
40
|
+
instead of CSS class references. Useful for email rendering.
|
|
41
|
+
|
|
42
|
+
Returns:
|
|
43
|
+
HTML string representation.
|
|
44
|
+
"""
|
|
45
|
+
... # pragma: no cover
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
#: Type alias for values accepted in monitoring cells.
|
|
49
|
+
#: Covers primitive scalar types that can appear in tables, KV pairs, and lists.
|
|
50
|
+
CellValue = str | int | float | bool
|
|
51
|
+
|
|
52
|
+
__all__ = [
|
|
53
|
+
"CellValue",
|
|
54
|
+
"Renderable",
|
|
55
|
+
"StatusLevel",
|
|
56
|
+
]
|
kstlib/ops/__init__.py
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""Session management with tmux and container backends.
|
|
2
|
+
|
|
3
|
+
This module provides config-driven session management for running
|
|
4
|
+
persistent processes like trading bots. It supports two backends:
|
|
5
|
+
|
|
6
|
+
- **tmux**: For local development and backtesting with detach/attach
|
|
7
|
+
- **container**: For production with Podman/Docker and persistent logs
|
|
8
|
+
|
|
9
|
+
The module is designed around the principle of backend abstraction,
|
|
10
|
+
allowing the same code to work with either tmux sessions or containers.
|
|
11
|
+
|
|
12
|
+
Examples:
|
|
13
|
+
Local development with tmux:
|
|
14
|
+
|
|
15
|
+
>>> from kstlib.ops import SessionManager
|
|
16
|
+
>>> session = SessionManager("astro", backend="tmux")
|
|
17
|
+
>>> session.start("python -m astro.bot") # doctest: +SKIP
|
|
18
|
+
>>> session.attach() # tmux attach-session -t astro # doctest: +SKIP
|
|
19
|
+
|
|
20
|
+
Production with containers:
|
|
21
|
+
|
|
22
|
+
>>> session = SessionManager(
|
|
23
|
+
... "astro",
|
|
24
|
+
... backend="container",
|
|
25
|
+
... image="astro-bot:latest",
|
|
26
|
+
... )
|
|
27
|
+
>>> session.start() # doctest: +SKIP
|
|
28
|
+
>>> session.attach() # podman attach astro # doctest: +SKIP
|
|
29
|
+
|
|
30
|
+
Config-driven usage:
|
|
31
|
+
|
|
32
|
+
>>> session = SessionManager.from_config("astro") # doctest: +SKIP
|
|
33
|
+
>>> session.start() # doctest: +SKIP
|
|
34
|
+
|
|
35
|
+
Note:
|
|
36
|
+
The attach() method uses os.execvp and replaces the current process.
|
|
37
|
+
It does not return on success.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
from kstlib.ops.base import AbstractRunner
|
|
41
|
+
from kstlib.ops.container import ContainerRunner
|
|
42
|
+
from kstlib.ops.exceptions import (
|
|
43
|
+
BackendNotFoundError,
|
|
44
|
+
ContainerRuntimeNotFoundError,
|
|
45
|
+
OpsError,
|
|
46
|
+
SessionAmbiguousError,
|
|
47
|
+
SessionAttachError,
|
|
48
|
+
SessionError,
|
|
49
|
+
SessionExistsError,
|
|
50
|
+
SessionNotFoundError,
|
|
51
|
+
SessionStartError,
|
|
52
|
+
SessionStopError,
|
|
53
|
+
TmuxNotFoundError,
|
|
54
|
+
)
|
|
55
|
+
from kstlib.ops.manager import SessionConfigError, SessionManager, auto_detect_backend
|
|
56
|
+
from kstlib.ops.models import (
|
|
57
|
+
BackendType,
|
|
58
|
+
SessionConfig,
|
|
59
|
+
SessionState,
|
|
60
|
+
SessionStatus,
|
|
61
|
+
)
|
|
62
|
+
from kstlib.ops.tmux import TmuxRunner
|
|
63
|
+
|
|
64
|
+
__all__ = [
|
|
65
|
+
"AbstractRunner",
|
|
66
|
+
"BackendNotFoundError",
|
|
67
|
+
"BackendType",
|
|
68
|
+
"ContainerRunner",
|
|
69
|
+
"ContainerRuntimeNotFoundError",
|
|
70
|
+
"OpsError",
|
|
71
|
+
"SessionAmbiguousError",
|
|
72
|
+
"SessionAttachError",
|
|
73
|
+
"SessionConfig",
|
|
74
|
+
"SessionConfigError",
|
|
75
|
+
"SessionError",
|
|
76
|
+
"SessionExistsError",
|
|
77
|
+
"SessionManager",
|
|
78
|
+
"SessionNotFoundError",
|
|
79
|
+
"SessionStartError",
|
|
80
|
+
"SessionState",
|
|
81
|
+
"SessionStatus",
|
|
82
|
+
"SessionStopError",
|
|
83
|
+
"TmuxNotFoundError",
|
|
84
|
+
"TmuxRunner",
|
|
85
|
+
"auto_detect_backend",
|
|
86
|
+
]
|