generated from erangel1/generic-template
261 lines
8.6 KiB
Python
261 lines
8.6 KiB
Python
"""
|
||
Core graph models for LabGraph.
|
||
|
||
Graph structure:
|
||
Node — vertices (any infrastructure component)
|
||
Edge — directed relationships between nodes
|
||
Network — IP network segments (VLANs/subnets)
|
||
WikiPage — Markdown documentation anchored to a node
|
||
HeartbeatLog — time-series liveness records per node
|
||
"""
|
||
|
||
import uuid
|
||
|
||
import markdown as md
|
||
from django.db import models
|
||
|
||
|
||
class Node(models.Model):
|
||
"""
|
||
Polymorphic infrastructure node — the vertex type in the LabGraph.
|
||
|
||
node_type drives rendering and determines what metadata keys are
|
||
expected in the JSONField. Pydantic schemas in schemas.py validate
|
||
metadata content at the API boundary before writes reach the ORM.
|
||
"""
|
||
|
||
class NodeType(models.TextChoices):
|
||
LOCATION = "location", "Physical Location"
|
||
HARDWARE = "hardware", "Physical Hardware"
|
||
HYPERVISOR = "hypervisor", "Hypervisor Host"
|
||
VM = "vm", "Virtual Machine"
|
||
CONTAINER = "container", "Container"
|
||
APPLICATION = "application", "Application / Service"
|
||
NETWORK_DEVICE = "network_device", "Network Device"
|
||
|
||
class Status(models.TextChoices):
|
||
ONLINE = "online", "Online"
|
||
OFFLINE = "offline", "Offline"
|
||
DEGRADED = "degraded", "Degraded"
|
||
UNKNOWN = "unknown", "Unknown"
|
||
MAINTENANCE = "maintenance", "Maintenance"
|
||
|
||
class DiscoverySource(models.TextChoices):
|
||
MANUAL = "manual", "Manually Added"
|
||
PROXMOX = "proxmox", "Proxmox API"
|
||
NMAP = "nmap", "Nmap Scan"
|
||
SNMP = "snmp", "SNMP"
|
||
|
||
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
||
|
||
label = models.CharField(max_length=255, db_index=True)
|
||
node_type = models.CharField(
|
||
max_length=30, choices=NodeType.choices, db_index=True
|
||
)
|
||
status = models.CharField(
|
||
max_length=20,
|
||
choices=Status.choices,
|
||
default=Status.UNKNOWN,
|
||
db_index=True,
|
||
)
|
||
|
||
ip_address = models.GenericIPAddressField(null=True, blank=True, db_index=True)
|
||
# Stored as colon-separated hex: "aa:bb:cc:dd:ee:ff"
|
||
mac_address = models.CharField(max_length=17, null=True, blank=True)
|
||
|
||
# Rated power draw in watts — used by the power cost estimator (Phase 3)
|
||
wattage = models.DecimalField(max_digits=8, decimal_places=2, null=True, blank=True)
|
||
|
||
# Flexible per-type data; validated by Pydantic at the API boundary
|
||
metadata = models.JSONField(default=dict, blank=True)
|
||
|
||
discovery_source = models.CharField(
|
||
max_length=20,
|
||
choices=DiscoverySource.choices,
|
||
default=DiscoverySource.MANUAL,
|
||
)
|
||
# ID in the originating external system, e.g. Proxmox VMID or Docker container ID
|
||
external_id = models.CharField(max_length=255, null=True, blank=True, db_index=True)
|
||
|
||
created_at = models.DateTimeField(auto_now_add=True)
|
||
updated_at = models.DateTimeField(auto_now=True)
|
||
|
||
class Meta:
|
||
ordering = ["label"]
|
||
indexes = [
|
||
models.Index(fields=["node_type", "status"]),
|
||
models.Index(fields=["discovery_source", "external_id"]),
|
||
]
|
||
verbose_name = "Node"
|
||
verbose_name_plural = "Nodes"
|
||
|
||
def __str__(self) -> str:
|
||
return f"{self.label} ({self.get_node_type_display()})"
|
||
|
||
|
||
class Edge(models.Model):
|
||
"""
|
||
Directed edge between two Nodes in the infrastructure graph.
|
||
|
||
Direction: source → target.
|
||
Multiple edge types allow different relationship semantics to coexist
|
||
(e.g. a VM has a parent_child edge to its hypervisor AND a network
|
||
edge to a switch).
|
||
"""
|
||
|
||
class EdgeType(models.TextChoices):
|
||
PARENT_CHILD = "parent_child", "Parent / Child"
|
||
NETWORK = "network", "Network Connection"
|
||
DEPENDENCY = "dependency", "Software Dependency"
|
||
PHYSICAL = "physical", "Physical Connection"
|
||
|
||
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
||
|
||
source = models.ForeignKey(
|
||
Node, on_delete=models.CASCADE, related_name="outgoing_edges"
|
||
)
|
||
target = models.ForeignKey(
|
||
Node, on_delete=models.CASCADE, related_name="incoming_edges"
|
||
)
|
||
edge_type = models.CharField(
|
||
max_length=20, choices=EdgeType.choices, db_index=True
|
||
)
|
||
|
||
weight = models.FloatField(default=1.0)
|
||
label = models.CharField(max_length=255, blank=True)
|
||
metadata = models.JSONField(default=dict, blank=True)
|
||
|
||
created_at = models.DateTimeField(auto_now_add=True)
|
||
|
||
class Meta:
|
||
unique_together = [("source", "target", "edge_type")]
|
||
indexes = [models.Index(fields=["edge_type"])]
|
||
verbose_name = "Edge"
|
||
verbose_name_plural = "Edges"
|
||
|
||
def __str__(self) -> str:
|
||
return f"{self.source.label} → {self.target.label} [{self.edge_type}]"
|
||
|
||
|
||
class Network(models.Model):
|
||
"""
|
||
An IP network segment that nodes attach to.
|
||
|
||
Nodes reference Networks via metadata or a future ManyToMany through-model
|
||
(Phase 2 IPAM). Networks are separate entities from Nodes so that VLAN
|
||
and subnet data can be managed independently.
|
||
"""
|
||
|
||
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
||
|
||
name = models.CharField(max_length=255)
|
||
vlan_id = models.PositiveSmallIntegerField(
|
||
null=True,
|
||
blank=True,
|
||
help_text="802.1Q VLAN tag (1–4094)",
|
||
)
|
||
# CIDR notation, e.g. "192.168.10.0/24"
|
||
cidr = models.CharField(max_length=43, db_index=True)
|
||
gateway = models.GenericIPAddressField(null=True, blank=True)
|
||
description = models.TextField(blank=True)
|
||
|
||
created_at = models.DateTimeField(auto_now_add=True)
|
||
updated_at = models.DateTimeField(auto_now=True)
|
||
|
||
class Meta:
|
||
ordering = ["cidr"]
|
||
verbose_name = "Network"
|
||
verbose_name_plural = "Networks"
|
||
|
||
def __str__(self) -> str:
|
||
vlan_str = f" (VLAN {self.vlan_id})" if self.vlan_id else ""
|
||
return f"{self.name} {self.cidr}{vlan_str}"
|
||
|
||
|
||
class WikiPage(models.Model):
|
||
"""
|
||
Human-authored documentation anchored to a single Node.
|
||
|
||
Content is stored as raw Markdown. rendered_html() converts it to
|
||
safe HTML at render time using the Markdown package.
|
||
"""
|
||
|
||
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
||
|
||
node = models.OneToOneField(
|
||
Node, on_delete=models.CASCADE, related_name="wiki_page"
|
||
)
|
||
content = models.TextField(blank=True, help_text="Markdown content")
|
||
last_edited_by = models.ForeignKey(
|
||
"auth.User",
|
||
null=True,
|
||
blank=True,
|
||
on_delete=models.SET_NULL,
|
||
related_name="wiki_edits",
|
||
)
|
||
|
||
created_at = models.DateTimeField(auto_now_add=True)
|
||
updated_at = models.DateTimeField(auto_now=True)
|
||
|
||
class Meta:
|
||
verbose_name = "Wiki Page"
|
||
verbose_name_plural = "Wiki Pages"
|
||
|
||
def __str__(self) -> str:
|
||
return f"Wiki: {self.node.label}"
|
||
|
||
def rendered_html(self) -> str:
|
||
"""Convert Markdown content to safe HTML."""
|
||
return md.markdown(
|
||
self.content,
|
||
extensions=["fenced_code", "tables", "nl2br"],
|
||
)
|
||
|
||
|
||
class HeartbeatLog(models.Model):
|
||
"""
|
||
Time-series liveness record for a Node.
|
||
|
||
Written by tasks/heartbeat.py on every check cycle. High-volume —
|
||
records accumulate fast. The maintenance task prunes rows older than
|
||
30 days nightly.
|
||
"""
|
||
|
||
class CheckType(models.TextChoices):
|
||
ICMP = "icmp", "ICMP Ping"
|
||
TCP = "tcp", "TCP Port Check"
|
||
HTTP = "http", "HTTP/S Check"
|
||
SNMP = "snmp", "SNMP Poll"
|
||
|
||
class HeartbeatStatus(models.TextChoices):
|
||
UP = "up", "Up"
|
||
DOWN = "down", "Down"
|
||
TIMEOUT = "timeout", "Timeout"
|
||
ERROR = "error", "Error"
|
||
|
||
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
||
|
||
node = models.ForeignKey(
|
||
Node, on_delete=models.CASCADE, related_name="heartbeat_logs"
|
||
)
|
||
timestamp = models.DateTimeField(auto_now_add=True, db_index=True)
|
||
status = models.CharField(
|
||
max_length=10, choices=HeartbeatStatus.choices, db_index=True
|
||
)
|
||
check_type = models.CharField(max_length=10, choices=CheckType.choices)
|
||
# Null on timeout or error — response never completed
|
||
response_time_ms = models.PositiveIntegerField(null=True, blank=True)
|
||
error_message = models.TextField(blank=True)
|
||
|
||
class Meta:
|
||
ordering = ["-timestamp"]
|
||
indexes = [
|
||
models.Index(fields=["node", "-timestamp"]),
|
||
models.Index(fields=["status", "-timestamp"]),
|
||
]
|
||
verbose_name = "Heartbeat Log"
|
||
verbose_name_plural = "Heartbeat Logs"
|
||
|
||
def __str__(self) -> str:
|
||
return f"{self.node.label} [{self.status}] @ {self.timestamp:%Y-%m-%d %H:%M:%S}"
|