truthound-dashboard 1.0.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.
- truthound_dashboard/__init__.py +11 -0
- truthound_dashboard/__main__.py +6 -0
- truthound_dashboard/api/__init__.py +15 -0
- truthound_dashboard/api/deps.py +153 -0
- truthound_dashboard/api/drift.py +179 -0
- truthound_dashboard/api/error_handlers.py +287 -0
- truthound_dashboard/api/health.py +78 -0
- truthound_dashboard/api/history.py +62 -0
- truthound_dashboard/api/middleware.py +626 -0
- truthound_dashboard/api/notifications.py +561 -0
- truthound_dashboard/api/profile.py +52 -0
- truthound_dashboard/api/router.py +83 -0
- truthound_dashboard/api/rules.py +277 -0
- truthound_dashboard/api/schedules.py +329 -0
- truthound_dashboard/api/schemas.py +136 -0
- truthound_dashboard/api/sources.py +229 -0
- truthound_dashboard/api/validations.py +125 -0
- truthound_dashboard/cli.py +226 -0
- truthound_dashboard/config.py +132 -0
- truthound_dashboard/core/__init__.py +264 -0
- truthound_dashboard/core/base.py +185 -0
- truthound_dashboard/core/cache.py +479 -0
- truthound_dashboard/core/connections.py +331 -0
- truthound_dashboard/core/encryption.py +409 -0
- truthound_dashboard/core/exceptions.py +627 -0
- truthound_dashboard/core/logging.py +488 -0
- truthound_dashboard/core/maintenance.py +542 -0
- truthound_dashboard/core/notifications/__init__.py +56 -0
- truthound_dashboard/core/notifications/base.py +390 -0
- truthound_dashboard/core/notifications/channels.py +557 -0
- truthound_dashboard/core/notifications/dispatcher.py +453 -0
- truthound_dashboard/core/notifications/events.py +155 -0
- truthound_dashboard/core/notifications/service.py +744 -0
- truthound_dashboard/core/sampling.py +626 -0
- truthound_dashboard/core/scheduler.py +311 -0
- truthound_dashboard/core/services.py +1531 -0
- truthound_dashboard/core/truthound_adapter.py +659 -0
- truthound_dashboard/db/__init__.py +67 -0
- truthound_dashboard/db/base.py +108 -0
- truthound_dashboard/db/database.py +196 -0
- truthound_dashboard/db/models.py +732 -0
- truthound_dashboard/db/repository.py +237 -0
- truthound_dashboard/main.py +309 -0
- truthound_dashboard/schemas/__init__.py +150 -0
- truthound_dashboard/schemas/base.py +96 -0
- truthound_dashboard/schemas/drift.py +118 -0
- truthound_dashboard/schemas/history.py +74 -0
- truthound_dashboard/schemas/profile.py +91 -0
- truthound_dashboard/schemas/rule.py +199 -0
- truthound_dashboard/schemas/schedule.py +88 -0
- truthound_dashboard/schemas/schema.py +121 -0
- truthound_dashboard/schemas/source.py +138 -0
- truthound_dashboard/schemas/validation.py +192 -0
- truthound_dashboard/static/assets/index-BqJMyAHX.js +110 -0
- truthound_dashboard/static/assets/index-DMDxHCTs.js +465 -0
- truthound_dashboard/static/assets/index-Dm2D11TK.css +1 -0
- truthound_dashboard/static/index.html +15 -0
- truthound_dashboard/static/mockServiceWorker.js +349 -0
- truthound_dashboard-1.0.0.dist-info/METADATA +218 -0
- truthound_dashboard-1.0.0.dist-info/RECORD +62 -0
- truthound_dashboard-1.0.0.dist-info/WHEEL +4 -0
- truthound_dashboard-1.0.0.dist-info/entry_points.txt +5 -0
|
@@ -0,0 +1,557 @@
|
|
|
1
|
+
"""Notification channel implementations.
|
|
2
|
+
|
|
3
|
+
This module provides concrete implementations of notification channels
|
|
4
|
+
for Slack, Email, and Webhook notifications.
|
|
5
|
+
|
|
6
|
+
Each channel is registered with the ChannelRegistry and can be
|
|
7
|
+
instantiated dynamically based on channel type.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from email.mime.multipart import MIMEMultipart
|
|
13
|
+
from email.mime.text import MIMEText
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
import httpx
|
|
17
|
+
|
|
18
|
+
from .base import (
|
|
19
|
+
BaseNotificationChannel,
|
|
20
|
+
ChannelRegistry,
|
|
21
|
+
NotificationEvent,
|
|
22
|
+
)
|
|
23
|
+
from .events import (
|
|
24
|
+
DriftDetectedEvent,
|
|
25
|
+
ScheduleFailedEvent,
|
|
26
|
+
TestNotificationEvent,
|
|
27
|
+
ValidationFailedEvent,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@ChannelRegistry.register("slack")
|
|
32
|
+
class SlackChannel(BaseNotificationChannel):
|
|
33
|
+
"""Slack notification channel using incoming webhooks.
|
|
34
|
+
|
|
35
|
+
Configuration:
|
|
36
|
+
webhook_url: Slack incoming webhook URL
|
|
37
|
+
channel: Optional channel override
|
|
38
|
+
username: Optional username override
|
|
39
|
+
icon_emoji: Optional emoji icon (e.g., ":robot:")
|
|
40
|
+
|
|
41
|
+
Example config:
|
|
42
|
+
{
|
|
43
|
+
"webhook_url": "https://hooks.slack.com/services/...",
|
|
44
|
+
"username": "Truthound Bot",
|
|
45
|
+
"icon_emoji": ":bar_chart:"
|
|
46
|
+
}
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
channel_type = "slack"
|
|
50
|
+
|
|
51
|
+
@classmethod
|
|
52
|
+
def get_config_schema(cls) -> dict[str, Any]:
|
|
53
|
+
"""Get Slack channel configuration schema."""
|
|
54
|
+
return {
|
|
55
|
+
"webhook_url": {
|
|
56
|
+
"type": "string",
|
|
57
|
+
"required": True,
|
|
58
|
+
"description": "Slack incoming webhook URL",
|
|
59
|
+
},
|
|
60
|
+
"channel": {
|
|
61
|
+
"type": "string",
|
|
62
|
+
"required": False,
|
|
63
|
+
"description": "Channel override (e.g., #alerts)",
|
|
64
|
+
},
|
|
65
|
+
"username": {
|
|
66
|
+
"type": "string",
|
|
67
|
+
"required": False,
|
|
68
|
+
"description": "Bot username override",
|
|
69
|
+
},
|
|
70
|
+
"icon_emoji": {
|
|
71
|
+
"type": "string",
|
|
72
|
+
"required": False,
|
|
73
|
+
"description": "Emoji icon (e.g., :robot:)",
|
|
74
|
+
},
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async def send(
|
|
78
|
+
self,
|
|
79
|
+
message: str,
|
|
80
|
+
event: NotificationEvent | None = None,
|
|
81
|
+
**kwargs: Any,
|
|
82
|
+
) -> bool:
|
|
83
|
+
"""Send notification to Slack.
|
|
84
|
+
|
|
85
|
+
Args:
|
|
86
|
+
message: Message text (supports Slack markdown).
|
|
87
|
+
event: Optional triggering event.
|
|
88
|
+
**kwargs: Additional Slack message options.
|
|
89
|
+
|
|
90
|
+
Returns:
|
|
91
|
+
True if message was sent successfully.
|
|
92
|
+
"""
|
|
93
|
+
webhook_url = self.config["webhook_url"]
|
|
94
|
+
|
|
95
|
+
# Build payload
|
|
96
|
+
payload: dict[str, Any] = {
|
|
97
|
+
"text": message,
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
# Add blocks for rich formatting
|
|
101
|
+
blocks = self._build_blocks(message, event)
|
|
102
|
+
if blocks:
|
|
103
|
+
payload["blocks"] = blocks
|
|
104
|
+
|
|
105
|
+
# Add optional overrides
|
|
106
|
+
if self.config.get("channel"):
|
|
107
|
+
payload["channel"] = self.config["channel"]
|
|
108
|
+
if self.config.get("username"):
|
|
109
|
+
payload["username"] = self.config["username"]
|
|
110
|
+
if self.config.get("icon_emoji"):
|
|
111
|
+
payload["icon_emoji"] = self.config["icon_emoji"]
|
|
112
|
+
|
|
113
|
+
# Merge any additional kwargs
|
|
114
|
+
payload.update(kwargs)
|
|
115
|
+
|
|
116
|
+
async with httpx.AsyncClient(timeout=30.0) as client:
|
|
117
|
+
response = await client.post(webhook_url, json=payload)
|
|
118
|
+
return response.status_code == 200
|
|
119
|
+
|
|
120
|
+
def _build_blocks(
|
|
121
|
+
self,
|
|
122
|
+
message: str,
|
|
123
|
+
event: NotificationEvent | None,
|
|
124
|
+
) -> list[dict[str, Any]]:
|
|
125
|
+
"""Build Slack blocks for rich message formatting."""
|
|
126
|
+
blocks = []
|
|
127
|
+
|
|
128
|
+
# Main message section
|
|
129
|
+
blocks.append(
|
|
130
|
+
{
|
|
131
|
+
"type": "section",
|
|
132
|
+
"text": {"type": "mrkdwn", "text": message},
|
|
133
|
+
}
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
# Add context for specific event types
|
|
137
|
+
if isinstance(event, ValidationFailedEvent):
|
|
138
|
+
context_elements = [
|
|
139
|
+
{"type": "mrkdwn", "text": f"*Severity:* {event.severity}"},
|
|
140
|
+
{"type": "mrkdwn", "text": f"*Issues:* {event.total_issues}"},
|
|
141
|
+
]
|
|
142
|
+
if event.validation_id:
|
|
143
|
+
context_elements.append(
|
|
144
|
+
{"type": "mrkdwn", "text": f"*ID:* `{event.validation_id[:8]}...`"}
|
|
145
|
+
)
|
|
146
|
+
blocks.append({"type": "context", "elements": context_elements})
|
|
147
|
+
|
|
148
|
+
elif isinstance(event, DriftDetectedEvent):
|
|
149
|
+
context_elements = [
|
|
150
|
+
{
|
|
151
|
+
"type": "mrkdwn",
|
|
152
|
+
"text": f"*Drift:* {event.drifted_columns}/{event.total_columns} columns",
|
|
153
|
+
},
|
|
154
|
+
{
|
|
155
|
+
"type": "mrkdwn",
|
|
156
|
+
"text": f"*Percentage:* {event.drift_percentage:.1f}%",
|
|
157
|
+
},
|
|
158
|
+
]
|
|
159
|
+
blocks.append({"type": "context", "elements": context_elements})
|
|
160
|
+
|
|
161
|
+
return blocks
|
|
162
|
+
|
|
163
|
+
def format_message(self, event: NotificationEvent) -> str:
|
|
164
|
+
"""Format message for Slack with mrkdwn syntax."""
|
|
165
|
+
if isinstance(event, ValidationFailedEvent):
|
|
166
|
+
emoji = ":rotating_light:" if event.has_critical else ":warning:"
|
|
167
|
+
return (
|
|
168
|
+
f"{emoji} *Validation Failed*\n\n"
|
|
169
|
+
f"*Source:* {event.source_name or 'Unknown'}\n"
|
|
170
|
+
f"*Severity:* {event.severity}\n"
|
|
171
|
+
f"*Total Issues:* {event.total_issues}"
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
elif isinstance(event, ScheduleFailedEvent):
|
|
175
|
+
return (
|
|
176
|
+
f":clock1: *Scheduled Validation Failed*\n\n"
|
|
177
|
+
f"*Schedule:* {event.schedule_name}\n"
|
|
178
|
+
f"*Source:* {event.source_name or 'Unknown'}\n"
|
|
179
|
+
f"*Error:* {event.error_message or 'Validation failed'}"
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
elif isinstance(event, DriftDetectedEvent):
|
|
183
|
+
emoji = ":chart_with_upwards_trend:" if event.has_high_drift else ":chart_with_downwards_trend:"
|
|
184
|
+
return (
|
|
185
|
+
f"{emoji} *Drift Detected*\n\n"
|
|
186
|
+
f"*Baseline:* {event.baseline_source_name}\n"
|
|
187
|
+
f"*Current:* {event.current_source_name}\n"
|
|
188
|
+
f"*Drifted Columns:* {event.drifted_columns}/{event.total_columns} "
|
|
189
|
+
f"({event.drift_percentage:.1f}%)"
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
elif isinstance(event, TestNotificationEvent):
|
|
193
|
+
return (
|
|
194
|
+
f":white_check_mark: *Test Notification*\n\n"
|
|
195
|
+
f"This is a test from truthound-dashboard.\n"
|
|
196
|
+
f"Channel: {event.channel_name}"
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
return self._default_format(event)
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
@ChannelRegistry.register("email")
|
|
203
|
+
class EmailChannel(BaseNotificationChannel):
|
|
204
|
+
"""Email notification channel using SMTP.
|
|
205
|
+
|
|
206
|
+
Configuration:
|
|
207
|
+
smtp_host: SMTP server hostname
|
|
208
|
+
smtp_port: SMTP server port (default: 587)
|
|
209
|
+
smtp_username: SMTP authentication username
|
|
210
|
+
smtp_password: SMTP authentication password
|
|
211
|
+
from_email: Sender email address
|
|
212
|
+
recipients: List of recipient email addresses
|
|
213
|
+
use_tls: Whether to use TLS (default: True)
|
|
214
|
+
|
|
215
|
+
Example config:
|
|
216
|
+
{
|
|
217
|
+
"smtp_host": "smtp.gmail.com",
|
|
218
|
+
"smtp_port": 587,
|
|
219
|
+
"smtp_username": "user@gmail.com",
|
|
220
|
+
"smtp_password": "app-password",
|
|
221
|
+
"from_email": "alerts@example.com",
|
|
222
|
+
"recipients": ["admin@example.com"],
|
|
223
|
+
"use_tls": true
|
|
224
|
+
}
|
|
225
|
+
"""
|
|
226
|
+
|
|
227
|
+
channel_type = "email"
|
|
228
|
+
|
|
229
|
+
@classmethod
|
|
230
|
+
def get_config_schema(cls) -> dict[str, Any]:
|
|
231
|
+
"""Get Email channel configuration schema."""
|
|
232
|
+
return {
|
|
233
|
+
"smtp_host": {
|
|
234
|
+
"type": "string",
|
|
235
|
+
"required": True,
|
|
236
|
+
"description": "SMTP server hostname",
|
|
237
|
+
},
|
|
238
|
+
"smtp_port": {
|
|
239
|
+
"type": "integer",
|
|
240
|
+
"required": False,
|
|
241
|
+
"default": 587,
|
|
242
|
+
"description": "SMTP server port",
|
|
243
|
+
},
|
|
244
|
+
"smtp_username": {
|
|
245
|
+
"type": "string",
|
|
246
|
+
"required": False,
|
|
247
|
+
"description": "SMTP authentication username",
|
|
248
|
+
},
|
|
249
|
+
"smtp_password": {
|
|
250
|
+
"type": "string",
|
|
251
|
+
"required": False,
|
|
252
|
+
"secret": True,
|
|
253
|
+
"description": "SMTP authentication password",
|
|
254
|
+
},
|
|
255
|
+
"from_email": {
|
|
256
|
+
"type": "string",
|
|
257
|
+
"required": True,
|
|
258
|
+
"description": "Sender email address",
|
|
259
|
+
},
|
|
260
|
+
"recipients": {
|
|
261
|
+
"type": "array",
|
|
262
|
+
"required": True,
|
|
263
|
+
"items": {"type": "string"},
|
|
264
|
+
"description": "List of recipient email addresses",
|
|
265
|
+
},
|
|
266
|
+
"use_tls": {
|
|
267
|
+
"type": "boolean",
|
|
268
|
+
"required": False,
|
|
269
|
+
"default": True,
|
|
270
|
+
"description": "Use TLS encryption",
|
|
271
|
+
},
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
async def send(
|
|
275
|
+
self,
|
|
276
|
+
message: str,
|
|
277
|
+
event: NotificationEvent | None = None,
|
|
278
|
+
subject: str | None = None,
|
|
279
|
+
**kwargs: Any,
|
|
280
|
+
) -> bool:
|
|
281
|
+
"""Send notification via email.
|
|
282
|
+
|
|
283
|
+
Args:
|
|
284
|
+
message: Email body text.
|
|
285
|
+
event: Optional triggering event (used for subject).
|
|
286
|
+
subject: Optional subject override.
|
|
287
|
+
**kwargs: Additional options.
|
|
288
|
+
|
|
289
|
+
Returns:
|
|
290
|
+
True if email was sent successfully.
|
|
291
|
+
"""
|
|
292
|
+
try:
|
|
293
|
+
import aiosmtplib
|
|
294
|
+
except ImportError:
|
|
295
|
+
raise ImportError(
|
|
296
|
+
"aiosmtplib is required for email notifications. "
|
|
297
|
+
"Install with: pip install aiosmtplib"
|
|
298
|
+
)
|
|
299
|
+
|
|
300
|
+
# Build subject
|
|
301
|
+
if subject is None:
|
|
302
|
+
subject = self._build_subject(event)
|
|
303
|
+
|
|
304
|
+
# Create message
|
|
305
|
+
msg = MIMEMultipart("alternative")
|
|
306
|
+
msg["Subject"] = subject
|
|
307
|
+
msg["From"] = self.config["from_email"]
|
|
308
|
+
msg["To"] = ", ".join(self.config["recipients"])
|
|
309
|
+
|
|
310
|
+
# Plain text version
|
|
311
|
+
text_part = MIMEText(message, "plain")
|
|
312
|
+
msg.attach(text_part)
|
|
313
|
+
|
|
314
|
+
# HTML version
|
|
315
|
+
html_content = self._build_html(message, event)
|
|
316
|
+
html_part = MIMEText(html_content, "html")
|
|
317
|
+
msg.attach(html_part)
|
|
318
|
+
|
|
319
|
+
# Send
|
|
320
|
+
await aiosmtplib.send(
|
|
321
|
+
msg,
|
|
322
|
+
hostname=self.config["smtp_host"],
|
|
323
|
+
port=self.config.get("smtp_port", 587),
|
|
324
|
+
username=self.config.get("smtp_username"),
|
|
325
|
+
password=self.config.get("smtp_password"),
|
|
326
|
+
use_tls=self.config.get("use_tls", True),
|
|
327
|
+
)
|
|
328
|
+
|
|
329
|
+
return True
|
|
330
|
+
|
|
331
|
+
def _build_subject(self, event: NotificationEvent | None) -> str:
|
|
332
|
+
"""Build email subject from event."""
|
|
333
|
+
if event is None:
|
|
334
|
+
return "[Truthound] Notification"
|
|
335
|
+
|
|
336
|
+
if isinstance(event, ValidationFailedEvent):
|
|
337
|
+
severity = event.severity
|
|
338
|
+
return f"[Truthound] {severity} - Validation Failed: {event.source_name}"
|
|
339
|
+
|
|
340
|
+
elif isinstance(event, ScheduleFailedEvent):
|
|
341
|
+
return f"[Truthound] Schedule Failed: {event.schedule_name}"
|
|
342
|
+
|
|
343
|
+
elif isinstance(event, DriftDetectedEvent):
|
|
344
|
+
return f"[Truthound] Drift Detected: {event.baseline_source_name} → {event.current_source_name}"
|
|
345
|
+
|
|
346
|
+
elif isinstance(event, TestNotificationEvent):
|
|
347
|
+
return "[Truthound] Test Notification"
|
|
348
|
+
|
|
349
|
+
return f"[Truthound] {event.event_type}"
|
|
350
|
+
|
|
351
|
+
def _build_html(self, message: str, event: NotificationEvent | None) -> str:
|
|
352
|
+
"""Build HTML email body."""
|
|
353
|
+
# Convert newlines to <br> for simple HTML
|
|
354
|
+
html_message = message.replace("\n", "<br>")
|
|
355
|
+
|
|
356
|
+
return f"""
|
|
357
|
+
<!DOCTYPE html>
|
|
358
|
+
<html>
|
|
359
|
+
<head>
|
|
360
|
+
<style>
|
|
361
|
+
body {{ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; }}
|
|
362
|
+
.container {{ max-width: 600px; margin: 0 auto; padding: 20px; }}
|
|
363
|
+
.header {{ background: #fd9e4b; color: white; padding: 15px; border-radius: 5px 5px 0 0; }}
|
|
364
|
+
.content {{ background: #f9f9f9; padding: 20px; border: 1px solid #ddd; border-top: none; }}
|
|
365
|
+
.footer {{ font-size: 12px; color: #666; margin-top: 20px; }}
|
|
366
|
+
</style>
|
|
367
|
+
</head>
|
|
368
|
+
<body>
|
|
369
|
+
<div class="container">
|
|
370
|
+
<div class="header">
|
|
371
|
+
<h2 style="margin: 0;">Truthound Dashboard</h2>
|
|
372
|
+
</div>
|
|
373
|
+
<div class="content">
|
|
374
|
+
<p>{html_message}</p>
|
|
375
|
+
</div>
|
|
376
|
+
<div class="footer">
|
|
377
|
+
<p>This notification was sent by Truthound Dashboard.</p>
|
|
378
|
+
</div>
|
|
379
|
+
</div>
|
|
380
|
+
</body>
|
|
381
|
+
</html>
|
|
382
|
+
"""
|
|
383
|
+
|
|
384
|
+
def format_message(self, event: NotificationEvent) -> str:
|
|
385
|
+
"""Format message for email (plain text)."""
|
|
386
|
+
if isinstance(event, ValidationFailedEvent):
|
|
387
|
+
return (
|
|
388
|
+
f"Validation Failed\n\n"
|
|
389
|
+
f"Source: {event.source_name or 'Unknown'}\n"
|
|
390
|
+
f"Severity: {event.severity}\n"
|
|
391
|
+
f"Total Issues: {event.total_issues}\n"
|
|
392
|
+
f"Validation ID: {event.validation_id}\n\n"
|
|
393
|
+
f"Please check the dashboard for details."
|
|
394
|
+
)
|
|
395
|
+
|
|
396
|
+
elif isinstance(event, ScheduleFailedEvent):
|
|
397
|
+
return (
|
|
398
|
+
f"Scheduled Validation Failed\n\n"
|
|
399
|
+
f"Schedule: {event.schedule_name}\n"
|
|
400
|
+
f"Source: {event.source_name or 'Unknown'}\n"
|
|
401
|
+
f"Error: {event.error_message or 'Validation failed'}\n\n"
|
|
402
|
+
f"Please check the dashboard for details."
|
|
403
|
+
)
|
|
404
|
+
|
|
405
|
+
elif isinstance(event, DriftDetectedEvent):
|
|
406
|
+
return (
|
|
407
|
+
f"Drift Detected\n\n"
|
|
408
|
+
f"Baseline: {event.baseline_source_name}\n"
|
|
409
|
+
f"Current: {event.current_source_name}\n"
|
|
410
|
+
f"Drifted Columns: {event.drifted_columns}/{event.total_columns} "
|
|
411
|
+
f"({event.drift_percentage:.1f}%)\n\n"
|
|
412
|
+
f"Please check the dashboard for details."
|
|
413
|
+
)
|
|
414
|
+
|
|
415
|
+
elif isinstance(event, TestNotificationEvent):
|
|
416
|
+
return (
|
|
417
|
+
f"Test Notification\n\n"
|
|
418
|
+
f"This is a test notification from truthound-dashboard.\n"
|
|
419
|
+
f"Channel: {event.channel_name}\n\n"
|
|
420
|
+
f"If you received this, your email channel is configured correctly."
|
|
421
|
+
)
|
|
422
|
+
|
|
423
|
+
return self._default_format(event)
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
@ChannelRegistry.register("webhook")
|
|
427
|
+
class WebhookChannel(BaseNotificationChannel):
|
|
428
|
+
"""Generic webhook notification channel.
|
|
429
|
+
|
|
430
|
+
Sends JSON payloads to any HTTP endpoint.
|
|
431
|
+
|
|
432
|
+
Configuration:
|
|
433
|
+
url: Webhook endpoint URL
|
|
434
|
+
method: HTTP method (default: POST)
|
|
435
|
+
headers: Optional custom headers
|
|
436
|
+
include_event_data: Whether to include full event data (default: True)
|
|
437
|
+
|
|
438
|
+
Example config:
|
|
439
|
+
{
|
|
440
|
+
"url": "https://example.com/webhook",
|
|
441
|
+
"method": "POST",
|
|
442
|
+
"headers": {
|
|
443
|
+
"Authorization": "Bearer token",
|
|
444
|
+
"X-Custom-Header": "value"
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
"""
|
|
448
|
+
|
|
449
|
+
channel_type = "webhook"
|
|
450
|
+
|
|
451
|
+
@classmethod
|
|
452
|
+
def get_config_schema(cls) -> dict[str, Any]:
|
|
453
|
+
"""Get Webhook channel configuration schema."""
|
|
454
|
+
return {
|
|
455
|
+
"url": {
|
|
456
|
+
"type": "string",
|
|
457
|
+
"required": True,
|
|
458
|
+
"description": "Webhook endpoint URL",
|
|
459
|
+
},
|
|
460
|
+
"method": {
|
|
461
|
+
"type": "string",
|
|
462
|
+
"required": False,
|
|
463
|
+
"default": "POST",
|
|
464
|
+
"description": "HTTP method (GET, POST, PUT)",
|
|
465
|
+
},
|
|
466
|
+
"headers": {
|
|
467
|
+
"type": "object",
|
|
468
|
+
"required": False,
|
|
469
|
+
"description": "Custom HTTP headers",
|
|
470
|
+
},
|
|
471
|
+
"include_event_data": {
|
|
472
|
+
"type": "boolean",
|
|
473
|
+
"required": False,
|
|
474
|
+
"default": True,
|
|
475
|
+
"description": "Include full event data in payload",
|
|
476
|
+
},
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
async def send(
|
|
480
|
+
self,
|
|
481
|
+
message: str,
|
|
482
|
+
event: NotificationEvent | None = None,
|
|
483
|
+
payload: dict[str, Any] | None = None,
|
|
484
|
+
**kwargs: Any,
|
|
485
|
+
) -> bool:
|
|
486
|
+
"""Send notification via webhook.
|
|
487
|
+
|
|
488
|
+
Args:
|
|
489
|
+
message: Message text.
|
|
490
|
+
event: Optional triggering event.
|
|
491
|
+
payload: Optional custom payload (overrides default).
|
|
492
|
+
**kwargs: Additional options.
|
|
493
|
+
|
|
494
|
+
Returns:
|
|
495
|
+
True if webhook call was successful.
|
|
496
|
+
"""
|
|
497
|
+
url = self.config["url"]
|
|
498
|
+
method = self.config.get("method", "POST").upper()
|
|
499
|
+
headers = self.config.get("headers", {})
|
|
500
|
+
|
|
501
|
+
# Build payload
|
|
502
|
+
if payload is None:
|
|
503
|
+
payload = self._build_payload(message, event)
|
|
504
|
+
|
|
505
|
+
async with httpx.AsyncClient(timeout=30.0) as client:
|
|
506
|
+
if method == "GET":
|
|
507
|
+
response = await client.get(url, headers=headers, params=payload)
|
|
508
|
+
elif method == "PUT":
|
|
509
|
+
response = await client.put(url, headers=headers, json=payload)
|
|
510
|
+
else: # Default to POST
|
|
511
|
+
response = await client.post(url, headers=headers, json=payload)
|
|
512
|
+
|
|
513
|
+
# Consider 2xx responses as success
|
|
514
|
+
return 200 <= response.status_code < 300
|
|
515
|
+
|
|
516
|
+
def _build_payload(
|
|
517
|
+
self,
|
|
518
|
+
message: str,
|
|
519
|
+
event: NotificationEvent | None,
|
|
520
|
+
) -> dict[str, Any]:
|
|
521
|
+
"""Build webhook payload."""
|
|
522
|
+
payload: dict[str, Any] = {
|
|
523
|
+
"message": message,
|
|
524
|
+
"channel_id": self.channel_id,
|
|
525
|
+
"channel_name": self.name,
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
if event and self.config.get("include_event_data", True):
|
|
529
|
+
payload["event"] = event.to_dict()
|
|
530
|
+
payload["event_type"] = event.event_type
|
|
531
|
+
|
|
532
|
+
return payload
|
|
533
|
+
|
|
534
|
+
def format_message(self, event: NotificationEvent) -> str:
|
|
535
|
+
"""Format message for webhook (plain text)."""
|
|
536
|
+
if isinstance(event, ValidationFailedEvent):
|
|
537
|
+
return (
|
|
538
|
+
f"Validation failed for {event.source_name or 'Unknown'}: "
|
|
539
|
+
f"{event.total_issues} issues ({event.severity})"
|
|
540
|
+
)
|
|
541
|
+
|
|
542
|
+
elif isinstance(event, ScheduleFailedEvent):
|
|
543
|
+
return (
|
|
544
|
+
f"Schedule '{event.schedule_name}' failed for "
|
|
545
|
+
f"{event.source_name or 'Unknown'}"
|
|
546
|
+
)
|
|
547
|
+
|
|
548
|
+
elif isinstance(event, DriftDetectedEvent):
|
|
549
|
+
return (
|
|
550
|
+
f"Drift detected: {event.drifted_columns}/{event.total_columns} columns "
|
|
551
|
+
f"({event.drift_percentage:.1f}%)"
|
|
552
|
+
)
|
|
553
|
+
|
|
554
|
+
elif isinstance(event, TestNotificationEvent):
|
|
555
|
+
return f"Test notification from truthound-dashboard"
|
|
556
|
+
|
|
557
|
+
return self._default_format(event)
|