137 lines
4.7 KiB
Python
137 lines
4.7 KiB
Python
"""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)
|