graph.py 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701
  1. # -*- coding: utf-8 -*-
  2. """
  3. Abhängigkeitsgraph für Service-Visualisierung.
  4. Ermöglicht die Erstellung von DOT- und Mermaid-Diagrammen
  5. zur Visualisierung von Service-Abhängigkeiten.
  6. """
  7. from __future__ import annotations
  8. from dataclasses import dataclass, field
  9. from enum import Enum, auto
  10. from typing import TYPE_CHECKING, Any, Iterator
  11. from trixy_core.service.enums import ServicePriority, ServiceGroup, ServiceState
  12. if TYPE_CHECKING:
  13. from trixy_core.service.iservice import IService
  14. class GraphFormat(Enum):
  15. """
  16. Ausgabeformate für den Graphen.
  17. """
  18. DOT = auto()
  19. """GraphViz DOT-Format."""
  20. MERMAID = auto()
  21. """Mermaid-Diagramm-Format."""
  22. JSON = auto()
  23. """JSON-Struktur."""
  24. ASCII = auto()
  25. """ASCII-Baum-Darstellung."""
  26. @dataclass
  27. class GraphNode:
  28. """
  29. Knoten im Abhängigkeitsgraphen.
  30. """
  31. name: str
  32. """Name des Services."""
  33. priority: ServicePriority
  34. """Priorität des Services."""
  35. group: ServiceGroup
  36. """Gruppe des Services."""
  37. state: ServiceState
  38. """Aktueller Zustand."""
  39. dependencies: list[str] = field(default_factory=list)
  40. """Liste der Abhängigkeiten."""
  41. dependents: list[str] = field(default_factory=list)
  42. """Liste der abhängigen Services."""
  43. metadata: dict[str, Any] = field(default_factory=dict)
  44. """Zusätzliche Metadaten."""
  45. def __hash__(self) -> int:
  46. """Hash für Set-Verwendung."""
  47. return hash(self.name)
  48. def __eq__(self, other: object) -> bool:
  49. """Gleichheitsvergleich."""
  50. if isinstance(other, GraphNode):
  51. return self.name == other.name
  52. return False
  53. @dataclass
  54. class GraphEdge:
  55. """
  56. Kante im Abhängigkeitsgraphen.
  57. """
  58. source: str
  59. """Quellknoten (abhängiger Service)."""
  60. target: str
  61. """Zielknoten (Abhängigkeit)."""
  62. edge_type: str = "depends_on"
  63. """Typ der Beziehung."""
  64. def __hash__(self) -> int:
  65. """Hash für Set-Verwendung."""
  66. return hash((self.source, self.target))
  67. def __eq__(self, other: object) -> bool:
  68. """Gleichheitsvergleich."""
  69. if isinstance(other, GraphEdge):
  70. return self.source == other.source and self.target == other.target
  71. return False
  72. @dataclass
  73. class GraphStyle:
  74. """
  75. Styling-Optionen für die Graphen-Ausgabe.
  76. """
  77. # Farben nach Priorität
  78. priority_colors: dict[ServicePriority, str] = field(default_factory=lambda: {
  79. ServicePriority.CRITICAL: "#ff6b6b",
  80. ServicePriority.CORE: "#feca57",
  81. ServicePriority.NETWORK: "#48dbfb",
  82. ServicePriority.MANAGER: "#1dd1a1",
  83. ServicePriority.PLUGIN: "#a55eea",
  84. ServicePriority.OPTIONAL: "#c8d6e5",
  85. })
  86. # Farben nach Zustand
  87. state_colors: dict[ServiceState, str] = field(default_factory=lambda: {
  88. ServiceState.STOPPED: "#c8d6e5",
  89. ServiceState.STARTING: "#feca57",
  90. ServiceState.RUNNING: "#1dd1a1",
  91. ServiceState.STOPPING: "#ff9f43",
  92. ServiceState.FAILED: "#ff6b6b",
  93. })
  94. # Formen nach Gruppe
  95. group_shapes: dict[ServiceGroup, str] = field(default_factory=lambda: {
  96. ServiceGroup.CRITICAL: "doubleoctagon",
  97. ServiceGroup.NETWORK: "hexagon",
  98. ServiceGroup.MANAGER: "box",
  99. ServiceGroup.AUDIO: "ellipse",
  100. ServiceGroup.ML: "diamond",
  101. ServiceGroup.UTILITY: "oval",
  102. ServiceGroup.PLUGIN: "component",
  103. })
  104. edge_color: str = "#576574"
  105. """Farbe für Kanten."""
  106. font_name: str = "Arial"
  107. """Schriftart."""
  108. font_size: int = 12
  109. """Schriftgröße."""
  110. class DependencyGraph:
  111. """
  112. Abhängigkeitsgraph für Services.
  113. Analysiert und visualisiert Service-Abhängigkeiten.
  114. Example:
  115. graph = DependencyGraph()
  116. graph.add_services(service_container.services)
  117. # DOT-Format exportieren
  118. dot = graph.to_dot()
  119. with open("services.dot", "w") as f:
  120. f.write(dot)
  121. # Mermaid-Diagramm
  122. mermaid = graph.to_mermaid()
  123. print(mermaid)
  124. # Zyklen erkennen
  125. cycles = graph.find_cycles()
  126. """
  127. def __init__(self, style: GraphStyle | None = None) -> None:
  128. """
  129. Initialisiert den Graphen.
  130. Args:
  131. style: Optionale Styling-Konfiguration.
  132. """
  133. self._nodes: dict[str, GraphNode] = {}
  134. self._edges: set[GraphEdge] = set()
  135. self._style = style or GraphStyle()
  136. @property
  137. def nodes(self) -> dict[str, GraphNode]:
  138. """Alle Knoten."""
  139. return dict(self._nodes)
  140. @property
  141. def edges(self) -> set[GraphEdge]:
  142. """Alle Kanten."""
  143. return set(self._edges)
  144. @property
  145. def node_count(self) -> int:
  146. """Anzahl der Knoten."""
  147. return len(self._nodes)
  148. @property
  149. def edge_count(self) -> int:
  150. """Anzahl der Kanten."""
  151. return len(self._edges)
  152. def add_service(self, service: "IService") -> GraphNode:
  153. """
  154. Fügt einen Service zum Graphen hinzu.
  155. Args:
  156. service: Der Service.
  157. Returns:
  158. Der erstellte GraphNode.
  159. """
  160. node = GraphNode(
  161. name=service.name,
  162. priority=service.PRIORITY,
  163. group=service.GROUP,
  164. state=service.state,
  165. dependencies=list(service.DEPENDENCIES),
  166. )
  167. self._nodes[service.name] = node
  168. # Kanten für Abhängigkeiten
  169. for dep in service.DEPENDENCIES:
  170. edge = GraphEdge(source=service.name, target=dep)
  171. self._edges.add(edge)
  172. return node
  173. def add_services(self, services: dict[str, "IService"]) -> None:
  174. """
  175. Fügt mehrere Services hinzu.
  176. Args:
  177. services: Dictionary der Services.
  178. """
  179. # Erst alle Nodes erstellen
  180. for service in services.values():
  181. self.add_service(service)
  182. # Dann Dependents berechnen
  183. self._compute_dependents()
  184. def _compute_dependents(self) -> None:
  185. """Berechnet die abhängigen Services für jeden Knoten."""
  186. for node in self._nodes.values():
  187. node.dependents = []
  188. for edge in self._edges:
  189. if edge.target in self._nodes:
  190. self._nodes[edge.target].dependents.append(edge.source)
  191. def get_node(self, name: str) -> GraphNode | None:
  192. """
  193. Gibt einen Knoten zurück.
  194. Args:
  195. name: Name des Knotens.
  196. Returns:
  197. GraphNode oder None.
  198. """
  199. return self._nodes.get(name)
  200. def get_dependencies(self, name: str) -> list[str]:
  201. """
  202. Gibt die Abhängigkeiten eines Knotens zurück.
  203. Args:
  204. name: Name des Knotens.
  205. Returns:
  206. Liste der Abhängigkeiten.
  207. """
  208. node = self._nodes.get(name)
  209. if node:
  210. return list(node.dependencies)
  211. return []
  212. def get_dependents(self, name: str) -> list[str]:
  213. """
  214. Gibt die abhängigen Services zurück.
  215. Args:
  216. name: Name des Knotens.
  217. Returns:
  218. Liste der abhängigen Services.
  219. """
  220. node = self._nodes.get(name)
  221. if node:
  222. return list(node.dependents)
  223. return []
  224. def get_all_dependencies(self, name: str) -> set[str]:
  225. """
  226. Gibt alle transitiven Abhängigkeiten zurück.
  227. Args:
  228. name: Name des Knotens.
  229. Returns:
  230. Set aller Abhängigkeiten (direkt und transitiv).
  231. """
  232. result: set[str] = set()
  233. visited: set[str] = set()
  234. def collect(node_name: str) -> None:
  235. if node_name in visited:
  236. return
  237. visited.add(node_name)
  238. node = self._nodes.get(node_name)
  239. if node:
  240. for dep in node.dependencies:
  241. result.add(dep)
  242. collect(dep)
  243. collect(name)
  244. return result
  245. def get_start_order(self) -> list[str]:
  246. """
  247. Berechnet die optimale Startreihenfolge.
  248. Returns:
  249. Liste der Service-Namen in Startreihenfolge.
  250. """
  251. # Topologische Sortierung mit Kahn's Algorithmus
  252. in_degree: dict[str, int] = {name: 0 for name in self._nodes}
  253. for edge in self._edges:
  254. if edge.source in in_degree and edge.target in self._nodes:
  255. in_degree[edge.source] += 1
  256. # Queue mit Knoten ohne Abhängigkeiten
  257. queue = [
  258. name for name, degree in in_degree.items()
  259. if degree == 0
  260. ]
  261. # Nach Priorität sortieren
  262. queue.sort(key=lambda n: (self._nodes[n].priority, n))
  263. result: list[str] = []
  264. while queue:
  265. # Nächsten Knoten verarbeiten
  266. current = queue.pop(0)
  267. result.append(current)
  268. # Abhängige aktualisieren
  269. for node in self._nodes.values():
  270. if current in node.dependencies:
  271. in_degree[node.name] -= 1
  272. if in_degree[node.name] == 0:
  273. queue.append(node.name)
  274. queue.sort(key=lambda n: (self._nodes[n].priority, n))
  275. return result
  276. def get_stop_order(self) -> list[str]:
  277. """
  278. Berechnet die optimale Stopp-Reihenfolge.
  279. Returns:
  280. Liste der Service-Namen in Stopp-Reihenfolge.
  281. """
  282. return list(reversed(self.get_start_order()))
  283. def find_cycles(self) -> list[list[str]]:
  284. """
  285. Findet Zyklen im Graphen.
  286. Returns:
  287. Liste von Zyklen (jeder Zyklus als Liste von Knoten).
  288. """
  289. cycles: list[list[str]] = []
  290. visited: set[str] = set()
  291. rec_stack: set[str] = set()
  292. path: list[str] = []
  293. def dfs(node_name: str) -> bool:
  294. visited.add(node_name)
  295. rec_stack.add(node_name)
  296. path.append(node_name)
  297. node = self._nodes.get(node_name)
  298. if node:
  299. for dep in node.dependencies:
  300. if dep not in visited:
  301. if dfs(dep):
  302. return True
  303. elif dep in rec_stack:
  304. # Zyklus gefunden
  305. cycle_start = path.index(dep)
  306. cycle = path[cycle_start:] + [dep]
  307. cycles.append(cycle)
  308. return True
  309. path.pop()
  310. rec_stack.remove(node_name)
  311. return False
  312. for name in self._nodes:
  313. if name not in visited:
  314. dfs(name)
  315. return cycles
  316. def get_roots(self) -> list[str]:
  317. """
  318. Gibt die Wurzelknoten zurück (ohne Abhängigkeiten).
  319. Returns:
  320. Liste der Wurzelknoten.
  321. """
  322. return [
  323. name for name, node in self._nodes.items()
  324. if not node.dependencies
  325. ]
  326. def get_leaves(self) -> list[str]:
  327. """
  328. Gibt die Blattknoten zurück (ohne Abhängige).
  329. Returns:
  330. Liste der Blattknoten.
  331. """
  332. return [
  333. name for name, node in self._nodes.items()
  334. if not node.dependents
  335. ]
  336. def to_dot(self, title: str = "Service Dependencies") -> str:
  337. """
  338. Exportiert als GraphViz DOT-Format.
  339. Args:
  340. title: Titel des Graphen.
  341. Returns:
  342. DOT-String.
  343. """
  344. lines: list[str] = [
  345. f'digraph "{title}" {{',
  346. f' rankdir=TB;',
  347. f' node [fontname="{self._style.font_name}", fontsize={self._style.font_size}];',
  348. f' edge [color="{self._style.edge_color}"];',
  349. "",
  350. ]
  351. # Cluster nach Gruppen
  352. groups: dict[ServiceGroup, list[GraphNode]] = {}
  353. for node in self._nodes.values():
  354. groups.setdefault(node.group, []).append(node)
  355. for group, nodes in groups.items():
  356. lines.append(f' subgraph cluster_{group.name.lower()} {{')
  357. lines.append(f' label="{group.name}";')
  358. lines.append(f' style=rounded;')
  359. lines.append(f' bgcolor="#f8f9fa";')
  360. for node in nodes:
  361. color = self._style.state_colors.get(node.state, "#c8d6e5")
  362. shape = self._style.group_shapes.get(node.group, "box")
  363. lines.append(
  364. f' "{node.name}" ['
  365. f'shape={shape}, '
  366. f'style=filled, '
  367. f'fillcolor="{color}", '
  368. f'label="{node.name}\\n({node.priority.name})"'
  369. f'];'
  370. )
  371. lines.append(' }')
  372. lines.append('')
  373. # Kanten
  374. for edge in self._edges:
  375. lines.append(f' "{edge.source}" -> "{edge.target}";')
  376. lines.append('}')
  377. return '\n'.join(lines)
  378. def to_mermaid(self, title: str = "Service Dependencies") -> str:
  379. """
  380. Exportiert als Mermaid-Diagramm.
  381. Args:
  382. title: Titel des Graphen.
  383. Returns:
  384. Mermaid-String.
  385. """
  386. lines: list[str] = [
  387. "```mermaid",
  388. "graph TD",
  389. f" %% {title}",
  390. "",
  391. ]
  392. # Knoten mit Styling
  393. for name, node in self._nodes.items():
  394. # Mermaid-Shapes basierend auf Gruppe
  395. shape_start, shape_end = self._get_mermaid_shape(node.group)
  396. state_class = node.state.name.lower()
  397. lines.append(
  398. f' {name}{shape_start}"{name}<br/>({node.priority.name})"{shape_end}'
  399. )
  400. lines.append("")
  401. # Kanten
  402. for edge in self._edges:
  403. lines.append(f' {edge.source} --> {edge.target}')
  404. lines.append("")
  405. # Styling
  406. lines.append(" %% Styles")
  407. for state, color in self._style.state_colors.items():
  408. lines.append(f" classDef {state.name.lower()} fill:{color}")
  409. # Klassen zuweisen
  410. for state in ServiceState:
  411. nodes_with_state = [
  412. name for name, node in self._nodes.items()
  413. if node.state == state
  414. ]
  415. if nodes_with_state:
  416. lines.append(
  417. f" class {','.join(nodes_with_state)} {state.name.lower()}"
  418. )
  419. lines.append("```")
  420. return '\n'.join(lines)
  421. def _get_mermaid_shape(self, group: ServiceGroup) -> tuple[str, str]:
  422. """
  423. Gibt die Mermaid-Shape-Zeichen für eine Gruppe zurück.
  424. Args:
  425. group: Die Service-Gruppe.
  426. Returns:
  427. Tupel (Start, Ende) für die Shape.
  428. """
  429. shapes = {
  430. ServiceGroup.CRITICAL: ("[[", "]]"), # Subroutine
  431. ServiceGroup.NETWORK: ("{{", "}}"), # Hexagon
  432. ServiceGroup.MANAGER: ("[", "]"), # Rectangle
  433. ServiceGroup.AUDIO: ("([", "])"), # Stadium
  434. ServiceGroup.ML: ("{", "}"), # Rhombus
  435. ServiceGroup.UTILITY: ("(", ")"), # Rounded
  436. ServiceGroup.PLUGIN: ("((", "))"), # Circle
  437. }
  438. return shapes.get(group, ("[", "]"))
  439. def to_json(self) -> dict[str, Any]:
  440. """
  441. Exportiert als JSON-Struktur.
  442. Returns:
  443. Dictionary-Repräsentation.
  444. """
  445. return {
  446. "nodes": [
  447. {
  448. "name": node.name,
  449. "priority": node.priority.name,
  450. "group": node.group.name,
  451. "state": node.state.name,
  452. "dependencies": node.dependencies,
  453. "dependents": node.dependents,
  454. "metadata": node.metadata,
  455. }
  456. for node in self._nodes.values()
  457. ],
  458. "edges": [
  459. {
  460. "source": edge.source,
  461. "target": edge.target,
  462. "type": edge.edge_type,
  463. }
  464. for edge in self._edges
  465. ],
  466. "statistics": {
  467. "node_count": self.node_count,
  468. "edge_count": self.edge_count,
  469. "roots": self.get_roots(),
  470. "leaves": self.get_leaves(),
  471. "has_cycles": len(self.find_cycles()) > 0,
  472. },
  473. }
  474. def to_ascii(self, root: str | None = None) -> str:
  475. """
  476. Exportiert als ASCII-Baum.
  477. Args:
  478. root: Optionaler Wurzelknoten.
  479. Returns:
  480. ASCII-Baum-String.
  481. """
  482. lines: list[str] = []
  483. visited: set[str] = set()
  484. def render_tree(name: str, prefix: str = "", is_last: bool = True) -> None:
  485. if name in visited:
  486. connector = "└── " if is_last else "├── "
  487. lines.append(f"{prefix}{connector}{name} (circular)")
  488. return
  489. visited.add(name)
  490. node = self._nodes.get(name)
  491. if not node:
  492. return
  493. connector = "└── " if is_last else "├── "
  494. state_marker = "●" if node.state == ServiceState.RUNNING else "○"
  495. lines.append(f"{prefix}{connector}{state_marker} {name} [{node.priority.name}]")
  496. # Abhängige rendern
  497. dependents = node.dependents
  498. for i, dep in enumerate(dependents):
  499. is_last_dep = i == len(dependents) - 1
  500. new_prefix = prefix + (" " if is_last else "│ ")
  501. render_tree(dep, new_prefix, is_last_dep)
  502. # Startpunkte bestimmen
  503. if root and root in self._nodes:
  504. roots = [root]
  505. else:
  506. roots = self.get_roots()
  507. for i, root_name in enumerate(sorted(roots)):
  508. is_last_root = i == len(roots) - 1
  509. render_tree(root_name, "", is_last_root)
  510. return '\n'.join(lines)
  511. def export(
  512. self,
  513. format: GraphFormat,
  514. title: str = "Service Dependencies",
  515. ) -> str:
  516. """
  517. Exportiert den Graphen im angegebenen Format.
  518. Args:
  519. format: Ausgabeformat.
  520. title: Titel des Graphen.
  521. Returns:
  522. Export-String.
  523. """
  524. if format == GraphFormat.DOT:
  525. return self.to_dot(title)
  526. elif format == GraphFormat.MERMAID:
  527. return self.to_mermaid(title)
  528. elif format == GraphFormat.JSON:
  529. import json
  530. return json.dumps(self.to_json(), indent=2)
  531. elif format == GraphFormat.ASCII:
  532. return self.to_ascii()
  533. else:
  534. raise ValueError(f"Unbekanntes Format: {format}")
  535. def validate(self) -> list[str]:
  536. """
  537. Validiert den Graphen.
  538. Returns:
  539. Liste von Warnungen/Fehlern.
  540. """
  541. issues: list[str] = []
  542. # Fehlende Abhängigkeiten prüfen
  543. for edge in self._edges:
  544. if edge.target not in self._nodes:
  545. issues.append(
  546. f"Fehlende Abhängigkeit: {edge.source} -> {edge.target}"
  547. )
  548. # Zyklen prüfen
  549. cycles = self.find_cycles()
  550. for cycle in cycles:
  551. issues.append(f"Zyklische Abhängigkeit: {' -> '.join(cycle)}")
  552. # Verwaiste Knoten
  553. orphans = [
  554. name for name, node in self._nodes.items()
  555. if not node.dependencies and not node.dependents
  556. ]
  557. for orphan in orphans:
  558. issues.append(f"Verwaister Service: {orphan}")
  559. return issues