Add HTML templates for dashboard, metrics, overview, and backtesting
CI / lint-test-build (push) Failing after 1m7s
CI / lint-test-build (push) Failing after 1m7s
- Introduced new HTML templates for the dashboard, metrics, overview, and backtesting functionalities. - Implemented partial templates for metrics, overview, audit, controls, and charts to enhance modularity. - Updated the Jinja2 template resolution logic to support different deployment environments. - Added a health check template to display the service status. - Included a test suite to verify the template resolution logic. - Updated `pyproject.toml` to include new HTML templates in the package data.
This commit is contained in:
@@ -0,0 +1,400 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import smtplib
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime
|
||||
from email.message import EmailMessage
|
||||
from typing import Literal, Protocol, runtime_checkable
|
||||
|
||||
import httpx
|
||||
|
||||
AlertSeverity = Literal["info", "warning", "error", "critical"]
|
||||
|
||||
_SEVERITY_RANK: dict[AlertSeverity, int] = {
|
||||
"info": 10,
|
||||
"warning": 20,
|
||||
"error": 30,
|
||||
"critical": 40,
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class AlertEvent:
|
||||
category: str
|
||||
severity: AlertSeverity
|
||||
title: str
|
||||
message: str
|
||||
occurred_at: datetime
|
||||
details: dict[str, str]
|
||||
|
||||
|
||||
class AlertChannel(Protocol):
|
||||
async def send(self, event: AlertEvent) -> None: ...
|
||||
|
||||
|
||||
class SupportsAlerts(Protocol):
|
||||
async def notify(
|
||||
self,
|
||||
*,
|
||||
category: str,
|
||||
severity: AlertSeverity,
|
||||
title: str,
|
||||
message: str,
|
||||
details: dict[str, str] | None = None,
|
||||
) -> bool: ...
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class SupportsAlertStatus(Protocol):
|
||||
def status_snapshot(self) -> dict[str, object]: ...
|
||||
|
||||
|
||||
class AlertNotifier:
|
||||
def __init__(
|
||||
self,
|
||||
channels: list[AlertChannel],
|
||||
*,
|
||||
enabled: bool = True,
|
||||
min_severity: AlertSeverity = "info",
|
||||
dedup_seconds: float = 0.0,
|
||||
category_flags: dict[str, bool] | None = None,
|
||||
) -> None:
|
||||
if dedup_seconds < 0.0:
|
||||
raise ValueError("dedup_seconds must be >= 0.0")
|
||||
self._channels = channels
|
||||
self._enabled = enabled
|
||||
self._min_severity: AlertSeverity = min_severity
|
||||
self._dedup_seconds = dedup_seconds
|
||||
self._category_flags = {key.lower(): value for key, value in (category_flags or {}).items()}
|
||||
self._last_sent_at: dict[str, datetime] = {}
|
||||
self._last_result: str = "never"
|
||||
self._last_attempted_at: datetime | None = None
|
||||
self._last_success_at: datetime | None = None
|
||||
self._last_error: str | None = None
|
||||
self._last_event_title: str | None = None
|
||||
self._last_event_category: str | None = None
|
||||
self._last_event_severity: AlertSeverity | None = None
|
||||
self._last_channel_results: list[str] = []
|
||||
|
||||
@property
|
||||
def has_channels(self) -> bool:
|
||||
return bool(self._channels)
|
||||
|
||||
async def notify(
|
||||
self,
|
||||
*,
|
||||
category: str,
|
||||
severity: AlertSeverity,
|
||||
title: str,
|
||||
message: str,
|
||||
details: dict[str, str] | None = None,
|
||||
) -> bool:
|
||||
if not self._enabled or not self._channels:
|
||||
self._last_result = "skipped_disabled" if not self._enabled else "skipped_no_channels"
|
||||
return False
|
||||
|
||||
normalized_category = category.strip().lower()
|
||||
if self._category_flags and not self._category_flags.get(normalized_category, True):
|
||||
self._last_result = "skipped_category"
|
||||
return False
|
||||
|
||||
if _SEVERITY_RANK[severity] < _SEVERITY_RANK[self._min_severity]:
|
||||
self._last_result = "skipped_severity"
|
||||
return False
|
||||
|
||||
dedup_key = f"{normalized_category}|{severity}|{title}|{message}"
|
||||
now = datetime.now(UTC)
|
||||
if self._dedup_seconds > 0.0:
|
||||
previous = self._last_sent_at.get(dedup_key)
|
||||
if previous is not None:
|
||||
elapsed = (now - previous).total_seconds()
|
||||
if elapsed < self._dedup_seconds:
|
||||
self._last_result = "skipped_dedup"
|
||||
return False
|
||||
|
||||
event = AlertEvent(
|
||||
category=normalized_category,
|
||||
severity=severity,
|
||||
title=title,
|
||||
message=message,
|
||||
occurred_at=now,
|
||||
details=details or {},
|
||||
)
|
||||
|
||||
results = await asyncio.gather(
|
||||
*(channel.send(event) for channel in self._channels),
|
||||
return_exceptions=True,
|
||||
)
|
||||
self._last_attempted_at = now
|
||||
self._last_event_title = title
|
||||
self._last_event_category = normalized_category
|
||||
self._last_event_severity = severity
|
||||
self._last_channel_results = []
|
||||
for channel, result in zip(self._channels, results, strict=False):
|
||||
channel_name = type(channel).__name__
|
||||
if isinstance(result, Exception):
|
||||
self._last_channel_results.append(f"{channel_name}: error")
|
||||
else:
|
||||
self._last_channel_results.append(f"{channel_name}: ok")
|
||||
|
||||
if all(isinstance(result, Exception) for result in results):
|
||||
self._last_result = "failed"
|
||||
self._last_error = "all channels failed"
|
||||
return False
|
||||
|
||||
self._last_result = (
|
||||
"partial_success"
|
||||
if any(isinstance(result, Exception) for result in results)
|
||||
else "success"
|
||||
)
|
||||
self._last_error = None
|
||||
self._last_success_at = now
|
||||
|
||||
self._last_sent_at[dedup_key] = now
|
||||
return True
|
||||
|
||||
def status_snapshot(self) -> dict[str, object]:
|
||||
return {
|
||||
"enabled": self._enabled,
|
||||
"has_channels": self.has_channels,
|
||||
"configured_channels": [type(channel).__name__ for channel in self._channels],
|
||||
"min_severity": self._min_severity,
|
||||
"dedup_seconds": self._dedup_seconds,
|
||||
"last_result": self._last_result,
|
||||
"last_attempted_at": (
|
||||
self._last_attempted_at.isoformat() if self._last_attempted_at is not None else None
|
||||
),
|
||||
"last_success_at": (
|
||||
self._last_success_at.isoformat() if self._last_success_at is not None else None
|
||||
),
|
||||
"last_error": self._last_error,
|
||||
"last_event": (
|
||||
None
|
||||
if self._last_event_title is None
|
||||
else {
|
||||
"title": self._last_event_title,
|
||||
"category": self._last_event_category,
|
||||
"severity": self._last_event_severity,
|
||||
}
|
||||
),
|
||||
"last_channel_results": self._last_channel_results,
|
||||
}
|
||||
|
||||
|
||||
def dispatch_alert_nowait(
|
||||
notifier: SupportsAlerts | None,
|
||||
*,
|
||||
category: str,
|
||||
severity: AlertSeverity,
|
||||
title: str,
|
||||
message: str,
|
||||
details: dict[str, str] | None = None,
|
||||
) -> None:
|
||||
if notifier is None:
|
||||
return
|
||||
try:
|
||||
loop = asyncio.get_running_loop()
|
||||
except RuntimeError:
|
||||
return
|
||||
|
||||
loop.create_task(
|
||||
notifier.notify(
|
||||
category=category,
|
||||
severity=severity,
|
||||
title=title,
|
||||
message=message,
|
||||
details=details,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _format_event_text(event: AlertEvent) -> str:
|
||||
lines = [
|
||||
f"[{event.severity.upper()}] {event.title}",
|
||||
f"Category: {event.category}",
|
||||
f"Time: {event.occurred_at.isoformat()}",
|
||||
event.message,
|
||||
]
|
||||
if event.details:
|
||||
lines.append("Details:")
|
||||
for key, value in sorted(event.details.items()):
|
||||
lines.append(f"- {key}: {value}")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
class TelegramChannel:
|
||||
def __init__(self, *, bot_token: str, chat_id: str, timeout_seconds: float = 10.0) -> None:
|
||||
self._bot_token = bot_token
|
||||
self._chat_id = chat_id
|
||||
self._timeout_seconds = timeout_seconds
|
||||
|
||||
async def send(self, event: AlertEvent) -> None:
|
||||
url = f"https://api.telegram.org/bot{self._bot_token}/sendMessage"
|
||||
payload = {
|
||||
"chat_id": self._chat_id,
|
||||
"text": _format_event_text(event),
|
||||
"disable_web_page_preview": True,
|
||||
}
|
||||
timeout = httpx.Timeout(self._timeout_seconds)
|
||||
async with httpx.AsyncClient(timeout=timeout) as client:
|
||||
response = await client.post(url, json=payload)
|
||||
response.raise_for_status()
|
||||
|
||||
|
||||
class DiscordWebhookChannel:
|
||||
def __init__(self, *, webhook_url: str, timeout_seconds: float = 10.0) -> None:
|
||||
self._webhook_url = webhook_url
|
||||
self._timeout_seconds = timeout_seconds
|
||||
|
||||
async def send(self, event: AlertEvent) -> None:
|
||||
payload = {"content": _format_event_text(event)}
|
||||
timeout = httpx.Timeout(self._timeout_seconds)
|
||||
async with httpx.AsyncClient(timeout=timeout) as client:
|
||||
response = await client.post(self._webhook_url, json=payload)
|
||||
response.raise_for_status()
|
||||
|
||||
|
||||
class EmailSmtpChannel:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
host: str,
|
||||
port: int,
|
||||
sender: str,
|
||||
recipients: list[str],
|
||||
username: str | None = None,
|
||||
password: str | None = None,
|
||||
use_tls: bool = True,
|
||||
timeout_seconds: float = 10.0,
|
||||
) -> None:
|
||||
if not recipients:
|
||||
raise ValueError("recipients must not be empty")
|
||||
|
||||
self._host = host
|
||||
self._port = port
|
||||
self._sender = sender
|
||||
self._recipients = recipients
|
||||
self._username = username
|
||||
self._password = password
|
||||
self._use_tls = use_tls
|
||||
self._timeout_seconds = timeout_seconds
|
||||
|
||||
async def send(self, event: AlertEvent) -> None:
|
||||
message = EmailMessage()
|
||||
message["From"] = self._sender
|
||||
message["To"] = ", ".join(self._recipients)
|
||||
message["Subject"] = f"[{event.severity.upper()}] {event.title}"
|
||||
message.set_content(_format_event_text(event))
|
||||
|
||||
await asyncio.to_thread(self._send_sync, message)
|
||||
|
||||
def _send_sync(self, message: EmailMessage) -> None:
|
||||
with smtplib.SMTP(self._host, self._port, timeout=self._timeout_seconds) as client:
|
||||
if self._use_tls:
|
||||
client.starttls()
|
||||
if self._username and self._password:
|
||||
client.login(self._username, self._password)
|
||||
client.send_message(message)
|
||||
|
||||
|
||||
class _AlertSettings(Protocol):
|
||||
alerts_enabled: bool
|
||||
alert_min_severity: str
|
||||
alert_dedup_seconds: float
|
||||
alert_on_trade_events: bool
|
||||
alert_on_error_events: bool
|
||||
alert_on_threshold_events: bool
|
||||
alert_on_system_events: bool
|
||||
|
||||
telegram_alerts_enabled: bool
|
||||
telegram_bot_token: str | None
|
||||
telegram_chat_id: str | None
|
||||
|
||||
discord_alerts_enabled: bool
|
||||
discord_webhook_url: str | None
|
||||
|
||||
email_alerts_enabled: bool
|
||||
email_smtp_host: str | None
|
||||
email_smtp_port: int
|
||||
email_smtp_username: str | None
|
||||
email_smtp_password: str | None
|
||||
email_alert_from: str | None
|
||||
email_alert_to: str | None
|
||||
email_smtp_use_tls: bool
|
||||
|
||||
|
||||
def _as_alert_severity(value: str) -> AlertSeverity:
|
||||
normalized = value.strip().lower()
|
||||
if normalized == "info":
|
||||
return "info"
|
||||
if normalized == "warning":
|
||||
return "warning"
|
||||
if normalized == "error":
|
||||
return "error"
|
||||
if normalized == "critical":
|
||||
return "critical"
|
||||
else:
|
||||
raise ValueError("alert_min_severity must be one of: info, warning, error, critical")
|
||||
|
||||
|
||||
def build_channels_from_settings(settings: _AlertSettings) -> list[AlertChannel]:
|
||||
channels: list[AlertChannel] = []
|
||||
|
||||
if settings.telegram_alerts_enabled:
|
||||
if not settings.telegram_bot_token or not settings.telegram_chat_id:
|
||||
raise ValueError("telegram alerts require bot token and chat id")
|
||||
channels.append(
|
||||
TelegramChannel(
|
||||
bot_token=settings.telegram_bot_token,
|
||||
chat_id=settings.telegram_chat_id,
|
||||
)
|
||||
)
|
||||
|
||||
if settings.discord_alerts_enabled:
|
||||
if not settings.discord_webhook_url:
|
||||
raise ValueError("discord alerts require webhook url")
|
||||
channels.append(DiscordWebhookChannel(webhook_url=settings.discord_webhook_url))
|
||||
|
||||
if settings.email_alerts_enabled:
|
||||
if not settings.email_smtp_host:
|
||||
raise ValueError("email alerts require SMTP host")
|
||||
if not settings.email_alert_from:
|
||||
raise ValueError("email alerts require sender address")
|
||||
if not settings.email_alert_to:
|
||||
raise ValueError("email alerts require recipient list")
|
||||
|
||||
recipients = [
|
||||
address.strip() for address in settings.email_alert_to.split(",") if address.strip()
|
||||
]
|
||||
channels.append(
|
||||
EmailSmtpChannel(
|
||||
host=settings.email_smtp_host,
|
||||
port=settings.email_smtp_port,
|
||||
sender=settings.email_alert_from,
|
||||
recipients=recipients,
|
||||
username=settings.email_smtp_username,
|
||||
password=settings.email_smtp_password,
|
||||
use_tls=settings.email_smtp_use_tls,
|
||||
)
|
||||
)
|
||||
|
||||
return channels
|
||||
|
||||
|
||||
def build_notifier_from_settings(settings: _AlertSettings) -> AlertNotifier:
|
||||
severity = _as_alert_severity(settings.alert_min_severity)
|
||||
channels = build_channels_from_settings(settings)
|
||||
category_flags = {
|
||||
"trade": settings.alert_on_trade_events,
|
||||
"error": settings.alert_on_error_events,
|
||||
"threshold": settings.alert_on_threshold_events,
|
||||
"system": settings.alert_on_system_events,
|
||||
}
|
||||
return AlertNotifier(
|
||||
channels,
|
||||
enabled=settings.alerts_enabled,
|
||||
min_severity=severity,
|
||||
dedup_seconds=settings.alert_dedup_seconds,
|
||||
category_flags=category_flags,
|
||||
)
|
||||
Reference in New Issue
Block a user