Files
PingWatch/backend/app/services/alerter.py
T

137 lines
4.7 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Create durable alert events and their notification outbox entries."""
from datetime import datetime
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import Settings, settings
from app.models import (
AlertEvent,
AlertTypeEnum,
NotificationOutbox,
)
from app.services.pinger import DeviceStateChange
class Alerter:
"""Translate a persisted device transition into one atomic outbox event."""
def __init__(self, runtime_settings: Settings | None = None):
self._settings = runtime_settings or settings
async def record_transition(
self,
change: DeviceStateChange,
db: AsyncSession,
) -> AlertEvent:
"""Stage an event and notification without committing the caller session."""
occurred_at = change.device.last_ping_time or datetime.now()
event_type = AlertTypeEnum(change.event_type or change.new_status)
prior_event = await self._find_prior_open_event(change, db)
duration_minutes = self._close_prior_event(prior_event, occurred_at)
event = AlertEvent(
device_id=change.device.id,
alert_type=event_type,
message=change.reason,
start_at=occurred_at,
is_resolved=event_type == AlertTypeEnum.recovered,
duration_minutes=(
duration_minutes
if event_type == AlertTypeEnum.recovered
else None
),
related_event_id=(prior_event.id if prior_event is not None else None),
previous_status=change.old_status,
current_status=change.new_status,
)
db.add(event)
await db.flush()
if prior_event is not None:
prior_event.related_event_id = event.id
db.add(
NotificationOutbox(
alert_event_id=event.id,
message_content=self._render_notification(
change,
occurred_at,
duration_minutes,
),
delivery_scope=(
self._settings.WECOM_TO_PARTY.strip() or "@all"
),
)
)
await db.flush()
return event
@staticmethod
async def _find_prior_open_event(
change: DeviceStateChange,
db: AsyncSession,
) -> AlertEvent | None:
"""Find only the incident that this escalation or recovery supersedes."""
event_type = change.event_type or change.new_status
if event_type == AlertTypeEnum.offline.value and change.old_status == "degraded":
alert_types = (AlertTypeEnum.degraded,)
elif event_type == AlertTypeEnum.recovered.value:
alert_types = (AlertTypeEnum.offline, AlertTypeEnum.degraded)
else:
return None
return await db.scalar(
select(AlertEvent)
.where(AlertEvent.device_id == change.device.id)
.where(AlertEvent.alert_type.in_(alert_types))
.where(AlertEvent.is_resolved.is_(False))
.order_by(AlertEvent.start_at.desc(), AlertEvent.id.desc())
.limit(1)
)
@staticmethod
def _close_prior_event(
event: AlertEvent | None,
occurred_at: datetime,
) -> int | None:
"""Resolve one incident and return a non-negative minute duration."""
if event is None:
return None
duration_minutes = max(
0,
int((occurred_at - event.start_at).total_seconds() / 60),
)
event.end_at = occurred_at
event.duration_minutes = duration_minutes
event.is_resolved = True
return duration_minutes
@staticmethod
def _render_notification(
change: DeviceStateChange,
occurred_at: datetime,
duration_minutes: int | None = None,
) -> str:
"""Render the bounded device and transition details needed by operators."""
labels = {
"offline": "设备离线",
"degraded": "链路质量故障",
"recovered": "设备恢复",
"system": "监控系统异常",
}
event_type = change.event_type or change.new_status
device = change.device
lines = [
f"【{labels.get(event_type, event_type)}】",
f"设备:{device.name}",
f"IP{device.ip}",
f"位置:{device.location or '未配置'}",
f"项目:{device.project_name or '未分组'}",
f"丢包摘要:{change.reason or '无'}",
f"时间:{occurred_at:%Y-%m-%d %H:%M:%S}",
]
if duration_minutes is not None:
lines.append(f"持续时间:{duration_minutes} 分钟")
return "\n".join(lines)