initial commit. phase 1 complete

This commit is contained in:
2026-05-05 20:45:19 +02:00
parent d9c68313a0
commit 89e058ffac
20631 changed files with 3224610 additions and 43 deletions
+260
View File
@@ -0,0 +1,260 @@
"""
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 (14094)",
)
# 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}"