woninet 2.0.1__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.
Files changed (53) hide show
  1. woninet/__init__.py +2 -0
  2. woninet/__main__.py +4 -0
  3. woninet/core/__init__.py +2 -0
  4. woninet/core/alerts.py +251 -0
  5. woninet/core/collectors.py +303 -0
  6. woninet/core/engine.py +353 -0
  7. woninet/core/graph.py +63 -0
  8. woninet/core/manual_ip_enumerator.py +33 -0
  9. woninet/core/models.py +124 -0
  10. woninet/core/storage.py +163 -0
  11. woninet/core/subnet_enumerator.py +32 -0
  12. woninet/database/__init__.py +2 -0
  13. woninet/database/base.py +9 -0
  14. woninet/database/engine.py +44 -0
  15. woninet/database/repositories/__init__.py +2 -0
  16. woninet/database/repositories/alert_event_repository.py +69 -0
  17. woninet/database/repositories/alert_state_repository.py +81 -0
  18. woninet/database/repositories/device_repository.py +105 -0
  19. woninet/database/repositories/metric_repository.py +75 -0
  20. woninet/database/tables.py +89 -0
  21. woninet/logging.yaml +49 -0
  22. woninet/main.py +225 -0
  23. woninet/server/__init__.py +2 -0
  24. woninet/server/app.py +101 -0
  25. woninet/server/dependencies.py +25 -0
  26. woninet/server/routes/__init__.py +2 -0
  27. woninet/server/routes/api/__init__.py +0 -0
  28. woninet/server/routes/api/devices.py +43 -0
  29. woninet/server/routes/api/stats.py +34 -0
  30. woninet/server/routes/page/__init__.py +0 -0
  31. woninet/server/routes/page/devices.py +28 -0
  32. woninet/server/static/css/404.css +164 -0
  33. woninet/server/static/css/style.css +368 -0
  34. woninet/server/static/js/app.js +242 -0
  35. woninet/server/static/js/device_info.js +95 -0
  36. woninet/server/static/swagger-ui/favicon-32x32.png +0 -0
  37. woninet/server/static/swagger-ui/swagger-ui-bundle.js +2 -0
  38. woninet/server/static/swagger-ui/swagger-ui.css +3 -0
  39. woninet/server/templates/404.html +76 -0
  40. woninet/server/templates/dashboard.html +118 -0
  41. woninet/server/templates/device_info.html +93 -0
  42. woninet/utilities/__init__.py +2 -0
  43. woninet/utilities/arguments.py +94 -0
  44. woninet/utilities/cli_parser.py +90 -0
  45. woninet/utilities/detect_ip_range.py +21 -0
  46. woninet/utilities/ip_validator.py +39 -0
  47. woninet/utilities/logger.py +97 -0
  48. woninet-2.0.1.dist-info/METADATA +243 -0
  49. woninet-2.0.1.dist-info/RECORD +53 -0
  50. woninet-2.0.1.dist-info/WHEEL +5 -0
  51. woninet-2.0.1.dist-info/entry_points.txt +2 -0
  52. woninet-2.0.1.dist-info/licenses/LICENSE +21 -0
  53. woninet-2.0.1.dist-info/top_level.txt +1 -0
@@ -0,0 +1,163 @@
1
+ from woninet.core.models import Device, MetricRecord
2
+ from woninet.database.tables import AlertStateTable, AlertEventTable
3
+ from woninet.database.repositories.device_repository import DeviceRepository
4
+ from woninet.database.repositories.metric_repository import MetricRepository
5
+ from woninet.database.repositories.alert_state_repository import AlertStateRepository
6
+ from woninet.database.repositories.alert_event_repository import AlertEventRepository
7
+
8
+
9
+ class StorageEngine:
10
+ """
11
+ Provide a high-level API for storing, updating, and
12
+ retrieving data from the database.
13
+
14
+ Also manage database sessions and delegate persistence
15
+ operations to repository classes.
16
+
17
+ Attributes:
18
+ session_factory (SessionLocal): Factory responsible for
19
+ creating SQLAlchemy sessions.
20
+ """
21
+
22
+ def __init__(self, session_factory) -> None:
23
+ """
24
+ Initialize the storage engine.
25
+
26
+ Args:
27
+ session_factory (SessionLocal): Factory responsible for
28
+ creating SQLAlchemy sessions.
29
+ """
30
+ self.session_factory = session_factory
31
+
32
+ def store_device(self, device: Device) -> None:
33
+ """
34
+ Insert or update a collected device.
35
+
36
+ Args:
37
+ device (Device): Device instance to be persisted.
38
+ """
39
+ with self.session_factory() as session:
40
+ repo = DeviceRepository(session)
41
+ repo.upsert(device)
42
+ session.commit()
43
+
44
+ def list_device_history(self) -> list[Device]:
45
+ """
46
+ Return all stored devices in the database.
47
+
48
+ Returns:
49
+ list[Device]: list devices retrieved from the database.
50
+ """
51
+ with self.session_factory() as session:
52
+ repo = DeviceRepository(session)
53
+ return repo.fetch_devices()
54
+
55
+ def fetch_device_info(self, ip) -> Device | None:
56
+ """
57
+ Return a device from the database for the given IP address or
58
+ `None` if not found.
59
+ """
60
+ with self.session_factory() as session:
61
+ repo = DeviceRepository(session)
62
+ return repo.fetch_device(ip)
63
+
64
+ def count_devices_and_metrics(self) -> tuple[int, int]:
65
+ """
66
+ Return number of devices and metrics in the database.
67
+
68
+ Returns:
69
+ tuple[int,int]: Number of devices and metrics in the database.
70
+ """
71
+ with self.session_factory() as session:
72
+ device_repo = DeviceRepository(session)
73
+ metric_repo = MetricRepository(session)
74
+ return (
75
+ device_repo.count_devices(),
76
+ metric_repo.count_metrics(),
77
+ )
78
+
79
+ def store_metric(self, metric: MetricRecord) -> None:
80
+ """
81
+ Persist newly collected metric record.
82
+
83
+ Args:
84
+ metric (MetricRecord): A metric record captured during a scan cycle.
85
+ """
86
+ with self.session_factory() as session:
87
+ repo = MetricRepository(session)
88
+ repo.insert(metric)
89
+ session.commit()
90
+
91
+ def list_metric_history(self) -> list[MetricRecord]:
92
+ """
93
+ Return stored metric history.
94
+
95
+ Returns:
96
+ list[MetricRecord]: Metric records retrieved from the database.
97
+ """
98
+ with self.session_factory() as session:
99
+ repo = MetricRepository(session)
100
+ return repo.fetch_metrics()
101
+
102
+ def get_or_create_alert_state(
103
+ self, ip: str, metric: str, consecutive_checks: int
104
+ ) -> AlertStateTable:
105
+ """
106
+ Return the alert state for a given IP address and metric.
107
+
108
+ Returns:
109
+ AlertStateTable: The found alert state.
110
+ """
111
+ with self.session_factory() as session:
112
+ repo = AlertStateRepository(session=session)
113
+ return repo.fetch_or_create_alert_state(
114
+ ip=ip, metric=metric, consecutive_checks=consecutive_checks
115
+ )
116
+
117
+ def update_alert_state(self, state: AlertStateTable) -> None:
118
+ """
119
+ Update an alert state.
120
+
121
+ Args:
122
+ state (AlertStateTable): The updated instance of AlertStateTable class.
123
+ """
124
+ with self.session_factory() as session:
125
+ repo = AlertStateRepository(session=session)
126
+ repo.update(state=state)
127
+ session.commit()
128
+
129
+ def get_device_alert_state(self, ip: str) -> dict[str, str] | None:
130
+ """
131
+ Return alert state of a device for a given IP address.
132
+ """
133
+ with self.session_factory() as session:
134
+ repo = AlertStateRepository(session)
135
+ return repo.fetch_alert_state(ip=ip)
136
+
137
+ def store_alert_event(self, event: AlertEventTable) -> None:
138
+ """
139
+ Store the new alert event.
140
+
141
+ Args:
142
+ event (AlertEvent): Instance of the AlertEventTable class.
143
+ """
144
+ with self.session_factory() as session:
145
+ repo = AlertEventRepository(session=session)
146
+ repo.insert(event=event)
147
+ session.commit()
148
+
149
+ def get_recent_alert_events(self) -> list[AlertEventTable]:
150
+ """
151
+ Return recent alert events of all devices.
152
+ """
153
+ with self.session_factory() as session:
154
+ repo = AlertEventRepository(session)
155
+ return repo.fetch_recent_alert_events()
156
+
157
+ def get_recent_device_alert_events(self, ip: str) -> list[AlertEventTable]:
158
+ """
159
+ Return recent alert events of a device for a given IP address.
160
+ """
161
+ with self.session_factory() as session:
162
+ repo = AlertEventRepository(session=session)
163
+ return repo.fetch_recent_device_alert_events(ip=ip)
@@ -0,0 +1,32 @@
1
+ import logging
2
+ from woninet.core.models import Device
3
+
4
+ core_logger = logging.getLogger("core")
5
+
6
+
7
+ class SubnetEnumerator:
8
+ """
9
+ Build a dictionary of candidate devices for a given IPv4 /24 subnet.
10
+ """
11
+
12
+ def enumerate(self, ip: str) -> dict[str, Device]:
13
+ """
14
+ Build a dictionary of candidate devices based on the /24 subnet
15
+ of the provided IPv4 address.
16
+
17
+ Args:
18
+ ip (str): IPv4 address used to derive the subnet.
19
+
20
+ Returns:
21
+ dict[str,Device]: Dictionary of candidate devices containing host
22
+ IP addresses and the related `Device` instances. Hosts range
23
+ from `.1` to `.254`.
24
+ """
25
+ ip_split = ip.split(".")
26
+ subnet = f"{ip_split[0]}.{ip_split[1]}.{ip_split[2]}"
27
+ core_logger.debug(f"Building a dictionaty of IP addresses base on {subnet}.x")
28
+ devices = {}
29
+ for i in range(1, 255):
30
+ ip = f"{subnet}.{i}"
31
+ devices[ip] = Device(ip)
32
+ return devices
@@ -0,0 +1,2 @@
1
+ # woninet/database/__init__.py
2
+ # (This file intentionally left blank)
@@ -0,0 +1,9 @@
1
+ from sqlalchemy.orm import DeclarativeBase
2
+
3
+
4
+ class Base(DeclarativeBase):
5
+ """
6
+ Used as the base for new declarative mappings.
7
+ """
8
+
9
+ pass
@@ -0,0 +1,44 @@
1
+ from sqlalchemy import create_engine, Engine
2
+ from sqlalchemy.orm import sessionmaker, Session
3
+ from woninet.database.base import Base
4
+
5
+
6
+ class DatabaseEngine:
7
+ """
8
+ Encapsulate database engine creation and initialization logic.
9
+
10
+ Attributes:
11
+ database_path (str): Path to the SQLite database file.
12
+ """
13
+
14
+ def __init__(self, database_path: str) -> None:
15
+ """
16
+ Create a SQLAlchemy engine and session factory for the given database.
17
+
18
+ Args:
19
+ database_path (str): Path to the SQLite database file.
20
+ """
21
+ self.engine = create_engine(
22
+ f"sqlite:///{database_path}", echo=False, future=True
23
+ )
24
+ self.SessionLocal = sessionmaker(bind=self.engine, autoflush=False)
25
+
26
+ def init_db(self) -> None:
27
+ """
28
+ Initialize the database and create missing tables.
29
+ """
30
+ import woninet.database.tables # noqa: F401
31
+
32
+ Base.metadata.create_all(bind=self.engine)
33
+
34
+ def get_database_enigne(self) -> Engine:
35
+ """
36
+ Return the SQLAlchemy Engine instance.
37
+ """
38
+ return self.engine
39
+
40
+ def get_session_factory(self) -> sessionmaker[Session]:
41
+ """
42
+ Return the SQLAlchemy session factory used to create sessions.
43
+ """
44
+ return self.SessionLocal
@@ -0,0 +1,2 @@
1
+ # woninet/database/repositories/__init__.py
2
+ # (This file intentionally left blank)
@@ -0,0 +1,69 @@
1
+ from sqlalchemy.orm import Session
2
+ from woninet.database.tables import AlertEventTable
3
+
4
+
5
+ class AlertEventRepository:
6
+ """
7
+ Repository for presisting Alert Event entries.
8
+
9
+ Encapsulate ORM operations on `AlertEventTable`.
10
+
11
+ Attributes:
12
+ session (Session): SQLAlchemy session used for
13
+ database operations
14
+ """
15
+
16
+ def __init__(self, session: Session) -> None:
17
+ """
18
+ Initialize the repository.
19
+
20
+ Args:
21
+ session (Session): SQLAlchemy session used for all database operations.
22
+ """
23
+ self.session = session
24
+
25
+ def insert(self, event: AlertEventTable) -> None:
26
+ """
27
+ Insert new alert event in the database.
28
+
29
+ Args:
30
+ event (AlertEvent): Instance of the AlertEventTable class.
31
+ """
32
+ self.session.add(event)
33
+
34
+ def fetch_recent_alert_events(self, limit: int = 10) -> list[AlertEventTable]:
35
+ """
36
+ Return the last 10 alert events of all devices.
37
+
38
+ Args:
39
+ limit (int): Apply a limit to search.
40
+
41
+ Returns: List of found events, or an empty list if found none
42
+ """
43
+ return (
44
+ self.session.query(AlertEventTable)
45
+ .order_by(AlertEventTable.id.desc())
46
+ .limit(limit)
47
+ .all()
48
+ )
49
+
50
+ def fetch_recent_device_alert_events(
51
+ self, ip: str, limit: int = 10
52
+ ) -> list[AlertEventTable]:
53
+ """
54
+ Returns the last 10 alert events of a device for a given IP address.
55
+
56
+ Args:
57
+ ip (str): IP address to search for.
58
+ limit (int): Apply a limit to search.
59
+
60
+ Returns:
61
+ list[AlertEventTable]: List of found event, or an empty list if found none.
62
+ """
63
+ return (
64
+ self.session.query(AlertEventTable)
65
+ .filter_by(device_ip=ip)
66
+ .order_by(AlertEventTable.id.desc())
67
+ .limit(limit)
68
+ .all()
69
+ )
@@ -0,0 +1,81 @@
1
+ from sqlalchemy.orm import Session
2
+ from woninet.database.tables import AlertStateTable
3
+
4
+
5
+ class AlertStateRepository:
6
+ """
7
+ Repository for presisting and updating Alert State entries.
8
+
9
+ Encapsulate ORM operations on `AlertStateTable`.
10
+
11
+ Attributes:
12
+ session (Session): SQLAlchemy session used for
13
+ database operations
14
+ """
15
+
16
+ def __init__(self, session: Session) -> None:
17
+ """
18
+ Initialize the repository.
19
+
20
+ Args:
21
+ session (Session): SQLAlchemy session used for all database operations.
22
+ """
23
+ self.session = session
24
+
25
+ def fetch_or_create_alert_state(
26
+ self, ip: str, metric: str, consecutive_checks: int
27
+ ) -> AlertStateTable:
28
+ """
29
+ Return an alert state for a specfic IP address and metric; Create a new one
30
+ if it already doesn't exist.
31
+
32
+ Args:
33
+ ip (str): IP address of host.
34
+ metric (str): Name of the metric beaing monitored.
35
+ consecutive_checks (int): Number of consecutive evaluations required
36
+ before a state change.
37
+
38
+ Returns:
39
+ AlertStateTable: The found alert state for a given IP and metric,
40
+ or a new alert state for new entries.
41
+ """
42
+ state = (
43
+ self.session.query(AlertStateTable)
44
+ .filter_by(device_ip=ip, metric=metric)
45
+ .one_or_none()
46
+ )
47
+
48
+ if state is None:
49
+ state = AlertStateTable(device_ip=ip, metric=metric)
50
+ state.state = "ok"
51
+ state.consecutive_checks = consecutive_checks
52
+ self.session.add(state)
53
+
54
+ return state
55
+
56
+ def update(self, state: AlertStateTable) -> None:
57
+ """
58
+ Update an alert state.
59
+
60
+ Args:
61
+ state (AlertStateTable): The updated instance of AlertStateTable class.
62
+ """
63
+ self.session.add(state)
64
+
65
+ def fetch_alert_state(self, ip: str) -> dict[str, str] | None:
66
+ """
67
+ Return current alert state for a given IP address.
68
+
69
+ Args:
70
+ ip (str): IP address to search for.
71
+
72
+ Returns:
73
+ dict[str,str]|None: A Dictionary containing `metric` and `state`.
74
+ """
75
+ device_state = self.session.query(AlertStateTable).filter_by(device_ip=ip).all()
76
+ if device_state is None:
77
+ return None
78
+ result = {}
79
+ for state in device_state:
80
+ result[state.metric] = state.state
81
+ return result
@@ -0,0 +1,105 @@
1
+ from datetime import datetime
2
+ from sqlalchemy.orm import Session
3
+ from woninet.database.tables import DeviceTable
4
+ from woninet.core.models import Device
5
+
6
+
7
+ class DeviceRepository:
8
+ """
9
+ Repository for persisting and retrieving Device entities.
10
+
11
+ Eencapsulate ORM operations on `DeviceTable` and expose a
12
+ simple API in terms of the domain model `Device`.
13
+
14
+ Attributes:
15
+ session (Session): SQLAlchemy session used for
16
+ database operations
17
+ """
18
+
19
+ def __init__(self, session: Session) -> None:
20
+ """
21
+ Initialize the repository.
22
+
23
+ Args:
24
+ session (Session): SQLAlchemy session used for all database operations.
25
+ """
26
+ self.session: Session = session
27
+
28
+ def upsert(self, dev: Device) -> None:
29
+ """
30
+ Insert or update a device into the database.
31
+
32
+ If `dev.ip` is present, the existing record is looked up by IP
33
+ address, otherwise it is looked up by MAC address. If the record
34
+ is found, its IP and latency fields are updated. The `last_seen`
35
+ timestamp is set to `dev.last_seen` or the current time, and is only
36
+ updated when device is reachable (`dev.latency > 0`).
37
+
38
+ All changes are committed immediatly.
39
+
40
+ Args:
41
+ dev (Device): Domain `Device` instance.
42
+ """
43
+ existing = self.session.query(DeviceTable).filter(DeviceTable.ip == dev.ip).first()
44
+ if existing is None and dev.mac:
45
+ existing = self.session.query(DeviceTable).filter(DeviceTable.mac == dev.mac).first()
46
+ now = datetime.now()
47
+
48
+ if existing:
49
+ existing.ip = dev.ip
50
+ existing.latency = dev.latency
51
+ if dev.latency > 0:
52
+ existing.last_seen = dev.last_seen or now
53
+ existing.packet_loss = dev.packet_loss
54
+ else:
55
+ existing = DeviceTable(
56
+ ip=dev.ip,
57
+ mac=dev.mac,
58
+ latency=dev.latency,
59
+ packet_loss=dev.packet_loss,
60
+ last_seen=dev.last_seen or now,
61
+ )
62
+ self.session.add(existing)
63
+
64
+ def fetch_devices(self) -> list[Device]:
65
+ """
66
+ Return all existing devices from the database.
67
+
68
+ Returns:
69
+ list[Device]: A list of `Device` instances from `DeviceTable` in the database.
70
+ """
71
+ rows = self.session.query(DeviceTable).all()
72
+ result = []
73
+ for row in rows:
74
+ dev = Device(ip=row.ip)
75
+ dev.mac = row.mac
76
+ dev.latency = row.latency
77
+ dev.packet_loss = row.packet_loss
78
+ dev.last_seen = row.last_seen
79
+ result.append(dev)
80
+ return result
81
+
82
+ def fetch_device(self, ip: str) -> Device | None:
83
+ """
84
+ Return a device from the database for the given IP address or
85
+ `None` if not found.
86
+ """
87
+ row = self.session.query(DeviceTable).filter_by(ip=ip).one_or_none()
88
+ if row is not None:
89
+ device = Device(ip=row.ip)
90
+ device.mac = row.mac
91
+ device.latency = row.latency
92
+ device.last_seen = row.last_seen
93
+ device.packet_loss = row.packet_loss
94
+ return device
95
+ return None
96
+
97
+ def count_devices(self) -> int:
98
+ """
99
+ Return the number of existing devices in the database.
100
+
101
+ Returns:
102
+ int: Number of devices in the database.
103
+ """
104
+ total = self.session.query(DeviceTable).count()
105
+ return total
@@ -0,0 +1,75 @@
1
+ from datetime import datetime
2
+ from sqlalchemy.orm import Session
3
+ from woninet.database.tables import MetricTable
4
+ from woninet.core.models import MetricRecord
5
+
6
+
7
+ class MetricRepository:
8
+ """
9
+ Repository for persisting and retrieving MetricRevord entities.
10
+
11
+ Encapsulate OEM operations on `MetricTable` and expose a
12
+ simple API in terms of the domain model `MetricRecord`.
13
+
14
+ Attributes:
15
+ session (Session): SQLAlchemy session used for
16
+ database operations
17
+ """
18
+
19
+ def __init__(self, session: Session) -> None:
20
+ """
21
+ Initialize the repository.
22
+
23
+ Args:
24
+ session (Session): SQLAlchemy session used for all database operations.
25
+ """
26
+ self.session: Session = session
27
+
28
+ def insert(self, metric: MetricRecord) -> None:
29
+ """
30
+ Insert new metric in the database.
31
+
32
+ Args:
33
+ metric (MetricRecord): An object of domain `MetricRecord` instance.
34
+ """
35
+ self.session.add(
36
+ MetricTable(
37
+ device_ip=metric.device_ip,
38
+ metric=metric.metric,
39
+ value=metric.value,
40
+ timestamp=metric.timestamp or datetime.now(),
41
+ )
42
+ )
43
+
44
+ def fetch_metrics(self) -> list[MetricRecord]:
45
+ """
46
+ Return all metric records from database.
47
+
48
+ Returns:
49
+ list[MetricRecord]: A list of `MetricRecord` instances from `MetricTable` in the database.
50
+ """
51
+ rows = self.session.query(MetricTable).all()
52
+ result = []
53
+ for row in rows:
54
+ metric = MetricTable(
55
+ device_ip=row.device_ip, metric=row.metric, value=row.value
56
+ )
57
+ result.append(metric)
58
+ return result
59
+
60
+ def remove_db_metrics(self) -> None:
61
+ """
62
+ Remove all rows from the `MetricTable`.
63
+ """
64
+ self.session.query(MetricTable).delete()
65
+ self.session.commit()
66
+
67
+ def count_metrics(self) -> int:
68
+ """
69
+ Return the number of metrics in the database.
70
+
71
+ Returns:
72
+ int: Number of metrics in the database.
73
+ """
74
+ total = self.session.query(MetricTable).count()
75
+ return total
@@ -0,0 +1,89 @@
1
+ from sqlalchemy import Integer, String, DateTime, Float, UniqueConstraint
2
+ from sqlalchemy.orm import Mapped, mapped_column
3
+ from datetime import datetime
4
+ from woninet.database.base import Base
5
+
6
+
7
+ class DeviceTable(Base):
8
+ """
9
+ ORM model representing a discovered network device.
10
+
11
+ Each row stores the most recently observed state for a device,
12
+ including IP address, MAC address, latency, and timestamp
13
+ of the last time device was reachable.
14
+ """
15
+
16
+ __tablename__ = "devices"
17
+
18
+ id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
19
+ ip: Mapped[str] = mapped_column(String, unique=True, index=True)
20
+ mac: Mapped[str | None] = mapped_column(
21
+ String, unique=True, index=True, nullable=True
22
+ )
23
+ latency: Mapped[float] = mapped_column(Float)
24
+ packet_loss: Mapped[int] = mapped_column(Integer)
25
+ last_seen: Mapped[datetime] = mapped_column(
26
+ DateTime(timezone=False), default=lambda: datetime.now(), index=True
27
+ )
28
+
29
+
30
+ class MetricTable(Base):
31
+ """
32
+ ORM model representing a metric record of a device model.
33
+
34
+ Each row stores the metric record of device, including IP address,
35
+ metric name, value and timestamp of the time the metric was recorded.
36
+ """
37
+
38
+ __tablename__ = "metrics"
39
+
40
+ id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
41
+ device_ip: Mapped[str] = mapped_column(String, index=True)
42
+ metric: Mapped[str] = mapped_column(String, index=True)
43
+ value: Mapped[float] = mapped_column(Float)
44
+ timestamp: Mapped[datetime] = mapped_column(
45
+ DateTime(timezone=False), default=lambda: datetime.now(), index=True
46
+ )
47
+
48
+
49
+ class AlertStateTable(Base):
50
+ """
51
+ ORM model representing an alert state for a specific device.
52
+
53
+ Each row stores the alert state of a device, including device IP address,
54
+ metric name, last state, and last time alert triggered.
55
+ """
56
+
57
+ __tablename__ = "alert_state"
58
+ __table_args__ = (
59
+ UniqueConstraint("device_ip", "metric", name="uix_device_metric"),
60
+ )
61
+
62
+ id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
63
+ device_ip: Mapped[str] = mapped_column(String, index=True)
64
+ metric: Mapped[str] = mapped_column(String, index=True)
65
+ state: Mapped[str] = mapped_column(String, default="ok", index=True)
66
+ consecutive_checks: Mapped[int] = mapped_column(Integer, index=True, nullable=False)
67
+ triggered_at: Mapped[datetime] = mapped_column(
68
+ DateTime(timezone=False), default=lambda: datetime.now(), index=True
69
+ )
70
+
71
+
72
+ class AlertEventTable(Base):
73
+ """
74
+ ORM model representing an alert event occured.
75
+
76
+ Each row stores the alert event of a device, including device IP address,
77
+ metric name, captured value of metric, event type, and timestamp.
78
+ """
79
+
80
+ __tablename__ = "alert_event"
81
+
82
+ id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
83
+ device_ip: Mapped[str] = mapped_column(String, index=True)
84
+ metric: Mapped[str] = mapped_column(String, index=True)
85
+ value: Mapped[float] = mapped_column(Float)
86
+ event_type: Mapped[str] = mapped_column(String, index=True)
87
+ timestamp: Mapped[datetime] = mapped_column(
88
+ DateTime(timezone=False), default=lambda: datetime.now(), index=True
89
+ )