feat: persist probe quality and notification outbox
This commit is contained in:
@@ -22,7 +22,12 @@ async def get_db() -> AsyncGenerator[AsyncSession, None]:
|
||||
|
||||
|
||||
async def init_db():
|
||||
"""创建所有表"""
|
||||
from app.models.device import Base
|
||||
"""Apply non-destructive schema upgrades before creating missing tables."""
|
||||
from app.models import Base
|
||||
from migrations.versions.20260803_reliability_monitoring import (
|
||||
run_reliability_migration,
|
||||
)
|
||||
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(run_reliability_migration)
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
from .device import Device, DeviceTypeEnum
|
||||
from .device import Base, Device, DeviceMonitoringPolicy, DeviceTypeEnum
|
||||
from .ping_record import PingRecord
|
||||
from .alert_event import AlertEvent, AlertTypeEnum
|
||||
from .notification_outbox import NotificationOutbox, NotificationStatus
|
||||
from .user import User, UserRoleEnum
|
||||
|
||||
__all__ = [
|
||||
"Device", "DeviceTypeEnum",
|
||||
"Base",
|
||||
"Device", "DeviceMonitoringPolicy", "DeviceTypeEnum",
|
||||
"PingRecord",
|
||||
"AlertEvent", "AlertTypeEnum",
|
||||
"NotificationOutbox", "NotificationStatus",
|
||||
"User", "UserRoleEnum",
|
||||
]
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
import enum
|
||||
from datetime import datetime
|
||||
from sqlalchemy import Column, Integer, String, Boolean, DateTime, BigInteger, Enum
|
||||
from sqlalchemy import BigInteger, Boolean, Column, DateTime, Enum, Integer, String
|
||||
from .device import Base
|
||||
|
||||
|
||||
PRIMARY_KEY_TYPE = BigInteger().with_variant(Integer, "sqlite")
|
||||
|
||||
|
||||
class AlertTypeEnum(str, enum.Enum):
|
||||
offline = "offline" # 设备离线
|
||||
degraded = "degraded" # 设备丢包故障
|
||||
recovered = "recovered" # 设备恢复
|
||||
system = "system" # 系统告警(如上游断网检测)
|
||||
|
||||
@@ -14,7 +18,7 @@ class AlertEvent(Base):
|
||||
"""告警事件表"""
|
||||
__tablename__ = "alert_events"
|
||||
|
||||
id = Column(BigInteger, primary_key=True, autoincrement=True)
|
||||
id = Column(PRIMARY_KEY_TYPE, primary_key=True, autoincrement=True)
|
||||
device_id = Column(Integer, nullable=False, index=True, comment="关联设备 ID")
|
||||
alert_type = Column(Enum(AlertTypeEnum), nullable=False, comment="告警类型")
|
||||
message = Column(String(1024), default="", comment="告警消息摘要")
|
||||
@@ -26,6 +30,14 @@ class AlertEvent(Base):
|
||||
|
||||
is_resolved = Column(Boolean, default=False, comment="是否已恢复")
|
||||
notification_sent = Column(Boolean, default=False, comment="是否已发送通知")
|
||||
notification_attempts = Column(Integer, default=0, comment="通知尝试次数")
|
||||
last_notification_error = Column(
|
||||
String(512),
|
||||
nullable=True,
|
||||
comment="最近一次通知失败原因",
|
||||
)
|
||||
previous_status = Column(String(16), nullable=True, comment="状态变更前状态")
|
||||
current_status = Column(String(16), nullable=True, comment="状态变更后状态")
|
||||
acknowledged_at = Column(DateTime, nullable=True, comment="用户确认时间")
|
||||
|
||||
created_at = Column(DateTime, default=datetime.now, comment="创建时间")
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
import enum
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from sqlalchemy import Column, Integer, String, Float, Boolean, DateTime, Enum, Text
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from sqlalchemy import Boolean, Column, DateTime, Enum, Float, Integer, String
|
||||
from sqlalchemy.orm import DeclarativeBase
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.config import Settings
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
pass
|
||||
@@ -16,6 +22,56 @@ class DeviceTypeEnum(str, enum.Enum):
|
||||
other = "other" # 其他
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DeviceMonitoringPolicy:
|
||||
"""Resolved monitoring thresholds for one device.
|
||||
|
||||
Nullable device fields intentionally inherit the runtime defaults so a
|
||||
configuration change can apply to devices that do not need exceptions.
|
||||
"""
|
||||
|
||||
probe_packets_per_round: int
|
||||
offline_consecutive_rounds: int
|
||||
degraded_window_rounds: int
|
||||
degraded_loss_percent: float
|
||||
recovery_consecutive_clean_rounds: int
|
||||
|
||||
@classmethod
|
||||
def from_device(
|
||||
cls,
|
||||
device: "Device",
|
||||
settings: "Settings",
|
||||
) -> "DeviceMonitoringPolicy":
|
||||
"""Resolve device overrides without changing the stored device."""
|
||||
return cls(
|
||||
probe_packets_per_round=(
|
||||
device.probe_packets_per_round
|
||||
if device.probe_packets_per_round is not None
|
||||
else settings.probe_packets_per_round
|
||||
),
|
||||
offline_consecutive_rounds=(
|
||||
device.offline_consecutive_rounds
|
||||
if device.offline_consecutive_rounds is not None
|
||||
else settings.offline_consecutive_rounds
|
||||
),
|
||||
degraded_window_rounds=(
|
||||
device.degraded_window_rounds
|
||||
if device.degraded_window_rounds is not None
|
||||
else settings.degraded_window_rounds
|
||||
),
|
||||
degraded_loss_percent=(
|
||||
device.degraded_loss_percent
|
||||
if device.degraded_loss_percent is not None
|
||||
else settings.degraded_loss_percent
|
||||
),
|
||||
recovery_consecutive_clean_rounds=(
|
||||
device.recovery_consecutive_clean_rounds
|
||||
if device.recovery_consecutive_clean_rounds is not None
|
||||
else settings.recovery_consecutive_clean_rounds
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class Device(Base):
|
||||
__tablename__ = "devices"
|
||||
|
||||
@@ -32,8 +88,39 @@ class Device(Base):
|
||||
alert_threshold = Column(Integer, default=5, comment="连续失败次数判离线")
|
||||
is_enabled = Column(Boolean, default=True, comment="是否启用监控")
|
||||
|
||||
# 设备级监测策略覆盖;NULL 时使用运行环境中的全局默认值。
|
||||
probe_packets_per_round = Column(
|
||||
Integer,
|
||||
nullable=True,
|
||||
comment="单轮探测发包数覆盖",
|
||||
)
|
||||
offline_consecutive_rounds = Column(
|
||||
Integer,
|
||||
nullable=True,
|
||||
comment="连续全丢包离线轮数覆盖",
|
||||
)
|
||||
degraded_window_rounds = Column(
|
||||
Integer,
|
||||
nullable=True,
|
||||
comment="故障丢包滑动窗口轮数覆盖",
|
||||
)
|
||||
degraded_loss_percent = Column(
|
||||
Float,
|
||||
nullable=True,
|
||||
comment="故障丢包率阈值覆盖",
|
||||
)
|
||||
recovery_consecutive_clean_rounds = Column(
|
||||
Integer,
|
||||
nullable=True,
|
||||
comment="连续零丢包恢复轮数覆盖",
|
||||
)
|
||||
|
||||
# 运行状态
|
||||
current_status = Column(String(16), default="unknown", comment="当前状态: online/offline/unknown")
|
||||
current_status = Column(
|
||||
String(16),
|
||||
default="unknown",
|
||||
comment="当前状态: online/degraded/offline/unknown",
|
||||
)
|
||||
consecutive_failures = Column(Integer, default=0, comment="当前连续失败次数")
|
||||
last_ping_time = Column(DateTime, nullable=True, comment="最后一次 ping 时间")
|
||||
last_online_time = Column(DateTime, nullable=True, comment="最后一次在线时间")
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
"""Durable delivery queue for alert notifications."""
|
||||
|
||||
import enum
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import BigInteger, Column, DateTime, Enum, Index, Integer, String, Text
|
||||
|
||||
from .device import Base
|
||||
|
||||
|
||||
PRIMARY_KEY_TYPE = BigInteger().with_variant(Integer, "sqlite")
|
||||
|
||||
|
||||
class NotificationStatus(str, enum.Enum):
|
||||
"""Lifecycle states for a notification delivery attempt."""
|
||||
|
||||
pending = "pending"
|
||||
sending = "sending"
|
||||
sent = "sent"
|
||||
failed = "failed"
|
||||
|
||||
|
||||
class NotificationOutbox(Base):
|
||||
"""A notification retained until a dispatcher records its final outcome."""
|
||||
|
||||
__tablename__ = "notification_outbox"
|
||||
|
||||
id = Column(PRIMARY_KEY_TYPE, primary_key=True, autoincrement=True)
|
||||
alert_event_id = Column(BigInteger, nullable=False, index=True, comment="关联告警事件 ID")
|
||||
message_content = Column(Text, nullable=False, comment="待发送的脱敏消息摘要")
|
||||
delivery_scope = Column(String(256), nullable=True, comment="受控投递范围摘要")
|
||||
status = Column(
|
||||
Enum(NotificationStatus),
|
||||
nullable=False,
|
||||
default=NotificationStatus.pending,
|
||||
server_default=NotificationStatus.pending.value,
|
||||
comment="投递状态",
|
||||
)
|
||||
attempt_count = Column(Integer, nullable=False, default=0, comment="投递尝试次数")
|
||||
next_attempt_at = Column(DateTime, nullable=True, comment="下次允许投递时间")
|
||||
locked_at = Column(DateTime, nullable=True, comment="投递器领取时间")
|
||||
sent_at = Column(DateTime, nullable=True, comment="成功投递时间")
|
||||
last_error = Column(String(512), nullable=True, comment="最近一次投递错误摘要")
|
||||
created_at = Column(DateTime, default=datetime.now, nullable=False, comment="创建时间")
|
||||
updated_at = Column(
|
||||
DateTime,
|
||||
default=datetime.now,
|
||||
onupdate=datetime.now,
|
||||
nullable=False,
|
||||
comment="更新时间",
|
||||
)
|
||||
|
||||
__table_args__ = (
|
||||
Index("idx_notification_outbox_status_next_attempt", "status", "next_attempt_at"),
|
||||
)
|
||||
@@ -1,18 +1,32 @@
|
||||
from datetime import datetime
|
||||
from sqlalchemy import Column, Integer, Float, Boolean, DateTime, BigInteger
|
||||
from sqlalchemy import BigInteger, Boolean, Column, DateTime, Float, Index, Integer, String
|
||||
from .device import Base
|
||||
|
||||
|
||||
PRIMARY_KEY_TYPE = BigInteger().with_variant(Integer, "sqlite")
|
||||
|
||||
|
||||
class PingRecord(Base):
|
||||
"""单次 ping 结果记录"""
|
||||
__tablename__ = "ping_records"
|
||||
|
||||
id = Column(BigInteger, primary_key=True, autoincrement=True)
|
||||
id = Column(PRIMARY_KEY_TYPE, primary_key=True, autoincrement=True)
|
||||
device_id = Column(Integer, nullable=False, index=True)
|
||||
is_alive = Column(Boolean, nullable=False, comment="是否通")
|
||||
response_time_ms = Column(Float, nullable=True, comment="响应时间毫秒,不通则为 NULL")
|
||||
round_num = Column(Integer, nullable=False, comment="轮次编号(从 1 递增)")
|
||||
created_at = Column(DateTime, default=datetime.now, index=True, comment="记录时间")
|
||||
|
||||
sent_count = Column(Integer, nullable=True, comment="本轮发包数")
|
||||
received_count = Column(Integer, nullable=True, comment="本轮收包数")
|
||||
packet_loss_percent = Column(Float, nullable=True, comment="本轮丢包率百分比")
|
||||
average_rtt_ms = Column(Float, nullable=True, comment="本轮平均时延毫秒")
|
||||
is_valid = Column(Boolean, nullable=False, default=True, comment="探测结果是否有效")
|
||||
failure_reason = Column(String(512), nullable=True, comment="无效探测的原因")
|
||||
|
||||
__table_args__ = (
|
||||
Index("idx_ping_records_device_created_at", "device_id", "created_at"),
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<PingRecord(device={self.device_id}, alive={self.is_alive}, rtt={self.response_time_ms})>"
|
||||
|
||||
Reference in New Issue
Block a user