Ansible-Roles/scripts/generate_report.py
2026-05-08 22:12:36 +03:00

2493 lines
111 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env python3
"""Генерация HTML-отчёта по Ansible-фактам хостов для GitLab Pages.
Использование:
python3 scripts/generate_report.py
Ожидает JSON-файлы в host_facts/<hostname>.json.
Результат записывается в public/ (GitLab Pages).
"""
import ipaddress
import json
import os
import re
import sys
from datetime import datetime, timezone
from pathlib import Path
import yaml
FACTS_DIR = Path("host_facts")
VMWARE_FACTS_DIR = Path("vmware_facts")
OUTPUT_DIR = Path("public")
SERVICE_MAP_FILE = Path("service_map.yml")
SERVICES_DIR = Path("services")
HISTORY_FILE = Path("connection_history.json")
# Локальные сети — внешние подключения из этих диапазонов группируются по подсети
_LOCAL_NETWORKS: list = [
ipaddress.ip_network("10.0.0.0/8"),
ipaddress.ip_network("172.16.0.0/16"),
ipaddress.ip_network("192.168.0.0/16"),
ipaddress.ip_network("100.64.0.0/10"),
ipaddress.ip_network("91.218.84.0/22"),
ipaddress.ip_network("91.236.140.0/22"),
]
BOOTSTRAP_CSS = "https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css"
BOOTSTRAP_JS = "https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"
GENERATED_AT = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC")
# ── Загрузка данных ────────────────────────────────────────────────────────────
def load_vmware_facts() -> dict:
"""Загружает VMware факты из директории vmware_facts/."""
data: dict = {"esxi_hosts": {}, "vms": [], "datastores": []}
if not VMWARE_FACTS_DIR.exists():
return data
for f in sorted(VMWARE_FACTS_DIR.glob("esxi_*.json")):
try:
hostname = f.stem[5:] # убираем префикс "esxi_"
data["esxi_hosts"][hostname] = json.loads(f.read_text(encoding="utf-8"))
except Exception as exc:
print(f"Предупреждение: не удалось прочитать {f}: {exc}", file=sys.stderr)
vms_file = VMWARE_FACTS_DIR / "vms.json"
if vms_file.exists():
try:
data["vms"] = json.loads(vms_file.read_text(encoding="utf-8"))
except Exception as exc:
print(f"Предупреждение: не удалось прочитать {vms_file}: {exc}", file=sys.stderr)
ds_file = VMWARE_FACTS_DIR / "datastores.json"
if ds_file.exists():
try:
data["datastores"] = json.loads(ds_file.read_text(encoding="utf-8"))
except Exception as exc:
print(f"Предупреждение: не удалось прочитать {ds_file}: {exc}", file=sys.stderr)
return data
def load_facts(facts_dir: Path) -> dict:
hosts = {}
for f in sorted(facts_dir.glob("*.json")):
try:
hosts[f.stem] = json.loads(f.read_text(encoding="utf-8"))
except Exception as exc:
print(f"Предупреждение: не удалось прочитать {f}: {exc}", file=sys.stderr)
return hosts
def load_service_map() -> list[dict]:
"""Загружает статическую карту зависимостей из service_map.yml."""
if not SERVICE_MAP_FILE.exists():
return []
try:
data = yaml.safe_load(SERVICE_MAP_FILE.read_text(encoding="utf-8")) or {}
result = []
for item in data.get("dependencies", []) or []:
frm = item.get("from", {})
to = item.get("to", {})
if not frm.get("host") or not to.get("host"):
continue
result.append({
"from_host": frm.get("host", ""),
"from_service": frm.get("service", ""),
"to_host": to.get("host", ""),
"to_service": to.get("service", ""),
"port": item.get("port", ""),
"protocol": item.get("protocol", "tcp"),
"description": item.get("description", ""),
"source": "static",
})
return result
except Exception as exc:
print(f"Предупреждение: не удалось прочитать {SERVICE_MAP_FILE}: {exc}", file=sys.stderr)
return []
def load_global_services() -> list[dict]:
"""Загружает описания глобальных сервисов из директории services/."""
if not SERVICES_DIR.exists():
return []
result = []
for f in sorted(SERVICES_DIR.glob("*.yml")) :
try:
data = yaml.safe_load(f.read_text(encoding="utf-8")) or {}
if not data.get("name") or not data.get("components"):
continue
result.append({
"slug": f.stem,
"name": data["name"],
"description": data.get("description", ""),
"components": data["components"],
"dep_refs": data.get("dep_refs"),
})
except Exception as exc:
print(f"Предупреждение: не удалось прочитать {f}: {exc}", file=sys.stderr)
for f in sorted(SERVICES_DIR.glob("*.yaml")):
try:
data = yaml.safe_load(f.read_text(encoding="utf-8")) or {}
if not data.get("name") or not data.get("components"):
continue
result.append({
"slug": f.stem,
"name": data["name"],
"description": data.get("description", ""),
"components": data["components"],
"dep_refs": data.get("dep_refs"),
})
except Exception as exc:
print(f"Предупреждение: не удалось прочитать {f}: {exc}", file=sys.stderr)
return result
# ── Вспомогательные функции ────────────────────────────────────────────────────
def fmt_bytes(b: int) -> str:
for unit in ("Б", "КБ", "МБ", "ГБ", "ТБ"):
if b < 1024:
return f"{b:.1f} {unit}"
b /= 1024
return f"{b:.1f} ПБ"
def get_os(f: dict) -> str:
dist = f.get("distribution", "")
ver = f.get("distribution_version", "")
return f"{dist} {ver}".strip() or ""
def get_ipv4(f: dict) -> str:
return f.get("default_ipv4", {}).get("address", "")
def get_ram(f: dict) -> str:
mb = f.get("memtotal_mb", 0)
return f"{mb / 1024:.1f} ГБ" if mb >= 1024 else f"{mb} МБ"
def get_uptime(f: dict) -> str:
secs = int(f.get("uptime_seconds", 0))
days, secs = divmod(secs, 86400)
hours = secs // 3600
return f"{days}д {hours}ч"
def get_disk_root(f: dict) -> tuple:
"""Возвращает (строкаспользования, процент_заполнения)."""
for m in f.get("mounts", []):
if m.get("mount") == "/":
total = int(m.get("size_total", 0))
avail = int(m.get("size_available", 0))
if total == 0:
return "", 0
pct = int((total - avail) / total * 100)
return f"{(total - avail) // 1024**3}Г / {total // 1024**3}Г", pct
return "", 0
def os_badge(os_str: str) -> str:
if "CentOS" in os_str:
color = "danger"
elif "Debian" in os_str:
color = "primary"
else:
color = "secondary"
return f'<span class="badge bg-{color}">{os_str}</span>'
def disk_badge(pct: int) -> str:
color = "danger" if pct > 85 else "warning" if pct > 70 else "success"
return f'<span class="badge bg-{color} ms-1">{pct}%</span>'
def parse_docker_containers(f: dict) -> list[dict]:
"""Извлекает данные о запущенных Docker-контейнерах из фактов хоста."""
result = []
for c in f.get("containers", []):
name = c.get("Name", "").lstrip("/")
image = c.get("Config", {}).get("Image", "")
# Порты: NetworkSettings.Ports → {"80/tcp": [{"HostIp": "...", "HostPort": "..."}]}
ports_dict = c.get("NetworkSettings", {}).get("Ports", {})
port_parts = []
for container_port, bindings in sorted(ports_dict.items()):
if bindings:
for b in bindings:
host_ip = b.get("HostIp", "")
host_port = b.get("HostPort", "")
prefix = f"{host_ip}:" if host_ip and host_ip not in ("0.0.0.0", "::") else ""
port_parts.append(f"{prefix}{host_port}{container_port}")
else:
port_parts.append(container_port)
ports_str = ", ".join(port_parts) or ""
# Volumes: Mounts → [{"Type": "...", "Source": "...", "Destination": "..."}]
volume_parts = []
for m in c.get("Mounts", []):
src = m.get("Source", "")
dst = m.get("Destination", "")
volume_parts.append(f"{src}:{dst}" if src else dst)
volumes_str = ", ".join(volume_parts) or ""
result.append({"name": name, "image": image, "ports": ports_str, "volumes": volumes_str})
return result
def parse_ports(raw: str) -> list[dict]:
"""Разбирает вывод ss -tlnup в список словарей."""
ports = []
for line in raw.splitlines():
parts = line.split()
# пропускаем заголовок и короткие строки
if len(parts) < 5 or parts[0] in ("Netid", "State"):
continue
proto = parts[0]
state = parts[1]
local = parts[4]
proc_info = " ".join(parts[6:]) if len(parts) > 6 else ""
m = re.search(r'users:\(\("([^"]+)"', proc_info)
process = m.group(1) if m else ""
m_pid = re.search(r'pid=(\d+)', proc_info)
pid = int(m_pid.group(1)) if m_pid else None
# разбираем адрес:порт с учётом IPv6 [::]:port
m6 = re.match(r"\[([^\]]+)\]:(\d+|\*)", local)
if m6:
addr, port = m6.group(1), m6.group(2)
elif ":" in local:
addr, port = local.rsplit(":", 1)
else:
addr, port = local, ""
ports.append({"proto": proto, "state": state, "addr": addr, "port": port, "process": process, "pid": pid})
return sorted(ports, key=lambda x: (x["proto"], int(x["port"]) if x["port"].isdigit() else 0))
# ── Зависимости между службами ─────────────────────────────────────────────────
def build_ip_to_host(hosts: dict) -> dict[str, str]:
"""Строит маппинг IP-адрес → имя хоста из собранных фактов."""
mapping: dict[str, str] = {}
for hostname, f in hosts.items():
ip = f.get("default_ipv4", {}).get("address", "")
if ip:
mapping[ip] = hostname
for iface in f.get("interfaces", []):
iface_ip = f.get(iface, {}).get("ipv4", {}).get("address", "")
if iface_ip and iface_ip not in mapping:
mapping[iface_ip] = hostname
return mapping
def _parse_addr_port(s: str) -> tuple[str, str]:
"""Разбирает строку вида addr:port или [ipv6]:port."""
m6 = re.match(r"\[([^\]]+)\]:(\d+)$", s)
if m6:
return m6.group(1), m6.group(2)
if ":" in s:
addr, port = s.rsplit(":", 1)
return addr, port
return s, ""
def parse_connections(raw: str) -> list[dict]:
"""Разбирает вывод ss -tnp state established в список соединений.
Поддерживает оба формата iproute2:
- со столбцом State: ESTAB 0 0 local:port peer:port [Process]
- без столбца State: 0 0 local:port peer:port [Process]
"""
conns = []
for line in raw.splitlines():
parts = line.split()
if len(parts) < 4:
continue
# Определяем смещение: первая колонка — State-слово или число (Recv-Q)
if parts[0] in ("ESTAB", "CLOSE-WAIT", "TIME-WAIT", "SYN-SENT"):
offset = 1 # State Recv-Q Send-Q Local Peer [Process]
elif parts[0].isdigit():
offset = 0 # Recv-Q Send-Q Local Peer [Process]
else:
continue # заголовок или неизвестный формат
if len(parts) < offset + 4:
continue
local = parts[offset + 2]
peer = parts[offset + 3]
proc_info = " ".join(parts[offset + 4:]) if len(parts) > offset + 4 else ""
m = re.search(r'users:\(\("([^"]+)"', proc_info)
process = m.group(1) if m else ""
m_pid = re.search(r'pid=(\d+)', proc_info)
pid = int(m_pid.group(1)) if m_pid else None
peer_addr, peer_port = _parse_addr_port(peer)
local_addr, local_port = _parse_addr_port(local)
conns.append({
"local_addr": local_addr,
"local_port": local_port,
"peer_addr": peer_addr,
"peer_port": peer_port,
"process": process,
"pid": pid,
})
return conns
def parse_ps_map(raw: str) -> dict:
"""Строит маппинг PID → понятное имя процесса из вывода ps -eo pid,args.
Для Java-процессов извлекает -Dapp.name=XXX (BGBillingServer, BGInetAccess и т.д.).
Для ActiveMQ определяет по классу запуска.
Для остальных процессов возвращает базовое имя исполняемого файла.
"""
result: dict[int, str] = {}
for line in raw.splitlines():
parts = line.split(None, 1)
if len(parts) < 2 or not parts[0].isdigit():
continue
pid = int(parts[0])
args = parts[1]
m = re.search(r'-Dapp\.name=(\S+)', args)
if m:
result[pid] = m.group(1)
continue
if 'org.apache.activemq' in args:
result[pid] = 'activemq'
continue
exe = args.split()[0]
result[pid] = os.path.basename(exe)
return result
def _resolve_proc(process: str, pid, ps_map: dict) -> str:
"""Возвращает понятное имя процесса, используя ps_map если доступен PID."""
if pid is not None and pid in ps_map:
return ps_map[pid]
return process
_CT_INCLUDE_STATES = {"ESTABLISHED", "TIME_WAIT", "CLOSE_WAIT"}
# Слова-состояния TCP в conntrack (отличают TCP-строку от UDP-строки)
_CT_TCP_STATES = {
"ESTABLISHED", "TIME_WAIT", "CLOSE_WAIT", "SYN_SENT", "SYN_RECV",
"FIN_WAIT", "LAST_ACK", "NONE",
}
def parse_conntrack(raw: str) -> list[dict]:
"""Разбирает вывод conntrack -L (TCP и UDP).
Формат TCP: tcp 6 58 TIME_WAIT src=A dst=B sport=P dport=Q src=... ...
Формат UDP: udp 17 28 src=A dst=B sport=P dport=Q src=... ...
Возвращает список dict с ключами:
local_addr, local_port, peer_addr, peer_port, process, ct_state, proto.
Поле process всегда ''; обогащается позже через корреляцию с ports_raw.
Для TCP принимаются только состояния из _CT_INCLUDE_STATES.
Для UDP состояние отсутствует, принимаются все записи.
"""
result = []
for line in raw.splitlines():
parts = line.split()
if len(parts) < 4:
continue
proto = parts[0]
if proto not in ("tcp", "udp"):
continue
if proto == "tcp":
# parts[3] — слово состояния (ESTABLISHED, TIME_WAIT, …)
if len(parts) < 5:
continue
state = parts[3]
if state not in _CT_INCLUDE_STATES:
continue
field_start = 4
else:
# UDP: нет слова состояния; parts[3] начинается с src= или другого key=val
state = ""
field_start = 3
fields: dict[str, str] = {}
for part in parts[field_start:]:
if "=" in part and "[" not in part:
k, v = part.split("=", 1)
if k not in fields: # только первое вхождение — original direction
fields[k] = v
src = fields.get("src", "")
dst = fields.get("dst", "")
sport = fields.get("sport", "")
dport = fields.get("dport", "")
if src == "" or dst == "":
continue
result.append({
"local_addr": dst,
"local_port": dport,
"peer_addr": src,
"peer_port": sport,
"process": "",
"ct_state": state,
"proto": proto,
})
return result
def detect_auto_deps(hosts: dict, ip_to_host: dict) -> list[dict]:
"""Находит зависимости между хостами по активным TCP-соединениям.
Логика:
1. Только соединения, где peer_port является СЛУШАЮЩИМ портом на хосте-назначении.
Это отсекает "зеркальные" строки (db-2 видит billing-main:38518 — эфемерный порт)
и оставляет только инициирующую сторону (billing-main → db-2:3306).
2. Сопоставляет процессы: хост A видит java→B:3306, хост B видит mysqld←A:ephemeral.
3. Группирует по (from_host, from_service, to_host, to_service, port) — без эфемерных портов.
"""
# Шаг 1: индекс слушающих портов и серверных соединений для каждого хоста
host_listening: dict[str, frozenset] = {}
host_srv_index: dict[str, dict] = {}
host_to_ips: dict[str, set] = {}
host_ps_map: dict[str, dict] = {}
for hostname, f in hosts.items():
ips: set = set()
ip = f.get("default_ipv4", {}).get("address", "")
if ip:
ips.add(ip)
for iface in f.get("interfaces", []):
iface_ip = f.get(iface, {}).get("ipv4", {}).get("address", "")
if iface_ip:
ips.add(iface_ip)
host_to_ips[hostname] = ips
host_listening[hostname] = _get_listening_ports(f)
host_ps_map[hostname] = parse_ps_map(f.get("ps_raw", ""))
# (local_port, peer_addr, peer_port) → (process, pid) — для поиска серверного процесса
index: dict = {}
for conn in parse_connections(f.get("connections_raw", "")):
lp = conn["local_port"]
if lp.isdigit():
key = (lp, conn["peer_addr"], conn["peer_port"])
index[key] = (conn["process"], conn.get("pid"))
host_srv_index[hostname] = index
# Шаг 2: обходим соединения, оставляем только те, где peer_port слушает на to_host
counts: dict = {} # (from_host, from_proc, to_host, to_proc, port) → кол-во соед.
for hostname, f in hosts.items():
ps_map = host_ps_map[hostname]
for conn in parse_connections(f.get("connections_raw", "")):
pp = conn["peer_port"]
lp = conn["local_port"]
if not pp.isdigit():
continue
port = int(pp)
to_host = ip_to_host.get(conn["peer_addr"])
if not to_host or to_host == hostname:
continue
# Фильтр: peer_port должен быть слушающим портом на to_host.
# Если нет — это эфемерный порт (ответный трафик), пропускаем.
if port not in host_listening.get(to_host, frozenset()):
continue
client_proc = _resolve_proc(conn["process"], conn.get("pid"), ps_map)
# Ищем серверный процесс по зеркальной записи на to_host
server_proc = ""
srv_index = host_srv_index.get(to_host, {})
to_ps_map = host_ps_map.get(to_host, {})
for my_ip in host_to_ips.get(hostname, set()):
candidate = srv_index.get((pp, my_ip, lp))
if candidate:
srv_proc_name, srv_pid = candidate
server_proc = _resolve_proc(srv_proc_name, srv_pid, to_ps_map)
break
key = (hostname, client_proc, to_host, server_proc, port)
counts[key] = counts.get(key, 0) + 1
return [
{
"from_host": fh,
"from_service": fp,
"to_host": th,
"to_service": tp,
"port": p,
"count": c,
"source": "auto",
}
for (fh, fp, th, tp, p), c in sorted(counts.items())
]
def _classify_external_ip(addr: str) -> tuple[str, str]:
"""Классифицирует внешний IP-адрес.
Возвращает (тип, подсеть):
('local', '10.0.0.0/8') — адрес входит в одну из локальных сетей
('remote', '') — публичный интернет-адрес
"""
try:
ip = ipaddress.ip_address(addr)
except ValueError:
return "remote", ""
for net in _LOCAL_NETWORKS:
if ip in net:
return "local", str(net)
return "remote", ""
def _get_listening_ports(f: dict) -> frozenset:
"""Возвращает множество портов, на которых хост слушает (из ports_raw)."""
result = set()
for p in parse_ports(f.get("ports_raw", "")):
if p["port"].isdigit():
result.add(int(p["port"]))
return frozenset(result)
def _ip_sort_key(ip: str):
"""Ключ сортировки IP-адресов (IPv4 числовой, IPv6 лексикографический)."""
try:
return (0, int(ipaddress.ip_address(ip)))
except ValueError:
return (1, ip)
def detect_external_deps(hosts: dict, ip_to_host: dict) -> list[dict]:
"""Находит входящие соединения от хостов не из проекта.
Для каждого сервера из инвентаря смотрит его соединения, где:
- local_port является слушающим портом этого хоста (хост — сервер)
- peer_addr НЕ входит в ip_to_host (источник — внешний)
Источники данных: ss (TCP ESTABLISHED) + conntrack -L (TCP и UDP, если доступен).
Conntrack расширяет охват: TCP TIME_WAIT/CLOSE_WAIT и все UDP-соединения.
Каждая запись соответствует одному уникальному IP-адресу источника.
Результат:
- ext_type='local' → источник в локальных подсетях (_LOCAL_NETWORKS)
- ext_type='remote' → публичный интернет
- historical=True → пир из connection_history.json, не активен сейчас
"""
counts: dict = {} # key → int
ct_states: dict = {} # key → лучший ct_state ('ESTABLISHED' предпочтительнее 'TIME_WAIT')
for hostname, f in hosts.items():
listening = _get_listening_ports(f)
# Маппинг порт→процесс для обогащения conntrack-записей
port_to_proc: dict = {}
for p in parse_ports(f.get("ports_raw", "")):
if p["process"] != "" and p["port"].isdigit():
port_to_proc[p["port"]] = p["process"]
# Источник 1: ss (имена процессов есть)
conns_ss = parse_connections(f.get("connections_raw", ""))
# Источник 2: conntrack (обогащаем именами процессов через порты)
conns_ct = parse_conntrack(f.get("conntrack_raw", ""))
for c in conns_ct:
if c["process"] == "" and c["local_port"] in port_to_proc:
c["process"] = port_to_proc[c["local_port"]]
# Дедупликация: убираем из conntrack то, что ss уже показывает
ss_seen = {(c["local_port"], c["peer_addr"], c["peer_port"]) for c in conns_ss}
conns_ct_new = [
c for c in conns_ct
if (c["local_port"], c["peer_addr"], c["peer_port"]) not in ss_seen
]
all_conns = conns_ss + conns_ct_new
for conn in all_conns:
lp = conn["local_port"]
if not lp.isdigit():
continue
local_port_int = int(lp)
# Хост является сервером только если local_port слушает
if local_port_int not in listening:
continue
peer_addr = conn["peer_addr"]
# Пропускаем известные хосты из проекта
if peer_addr in ip_to_host:
continue
ext_type, subnet = _classify_external_ip(peer_addr)
proto = conn.get("proto", "tcp")
key = (hostname, local_port_int, conn["process"], proto, ext_type, subnet, peer_addr)
counts[key] = counts.get(key, 0) + 1
# Предпочитаем '' (ss/ESTABLISHED) над TIME_WAIT
ct = conn.get("ct_state", "")
if key not in ct_states or ct in ("", "ESTABLISHED"):
ct_states[key] = ct
current_keys: set = set()
result = []
for (th, port, proc, proto, etype, subnet, addr), c in sorted(counts.items()):
key = (th, port, proc, proto, etype, subnet, addr)
current_keys.add(key)
entry: dict = {
"to_host": th,
"to_port": port,
"to_service": proc,
"proto": proto,
"ext_type": etype,
"subnet": subnet,
"ip": addr,
"count": c,
}
ct = ct_states.get(key, "")
if ct:
entry["ct_state"] = ct
result.append(entry)
# Добавляем исторические пиры из connection_history.json
if HISTORY_FILE.exists():
try:
history = json.loads(HISTORY_FILE.read_text(encoding="utf-8"))
for peer in history.get("peers", []):
if peer.get("ext_type") != "local":
continue
peer_proto = peer.get("proto", "tcp")
hkey = (
peer["to_host"], peer["to_port"], peer.get("to_service", ""),
peer_proto, "local", peer["subnet"], peer["peer_addr"],
)
if hkey in current_keys:
continue
result.append({
"to_host": peer["to_host"],
"to_port": peer["to_port"],
"to_service": peer.get("to_service", ""),
"proto": peer_proto,
"ext_type": "local",
"subnet": peer["subnet"],
"ip": peer["peer_addr"],
"count": 0,
"historical": True,
"last_seen": peer.get("last_seen", ""),
})
except Exception as exc:
print(f"Предупреждение: не удалось прочитать {HISTORY_FILE}: {exc}", file=sys.stderr)
return result
def detect_intra_deps(hosts: dict) -> list[dict]:
"""Находит зависимости между службами внутри одного хоста по loopback TCP-соединениям.
Для каждого хоста ищет соединения, где peer_addr — это loopback-адрес (127.0.0.1, ::1)
или собственный IP интерфейса, а peer_port является слушающим портом того же хоста.
Серверный процесс определяется по зеркальной строке из того же connections_raw.
Результат группируется по (host, client_service, server_service, port).
"""
result = []
for hostname, f in hosts.items():
# Собираем все "свои" IP-адреса хоста (loopback + все интерфейсы)
own_ips: set = {"127.0.0.1", "::1"}
ip = f.get("default_ipv4", {}).get("address", "")
if ip:
own_ips.add(ip)
for iface in f.get("interfaces", []):
iface_ip = f.get(iface, {}).get("ipv4", {}).get("address", "")
if iface_ip:
own_ips.add(iface_ip)
listening = _get_listening_ports(f)
connections = parse_connections(f.get("connections_raw", ""))
ps_map = parse_ps_map(f.get("ps_raw", ""))
# Индекс серверной стороны: (local_port, peer_addr, peer_port) → (process, pid)
# Используется для поиска серверного процесса по зеркальной строке соединения
srv_index: dict = {}
for conn in connections:
lp = conn["local_port"]
if lp.isdigit():
key = (lp, conn["peer_addr"], conn["peer_port"])
srv_index[key] = (conn["process"], conn.get("pid"))
# (client_proc, server_proc, port) → количество соединений
counts: dict = {}
for conn in connections:
pp = conn["peer_port"]
lp = conn["local_port"]
if not pp.isdigit():
continue
# Только соединения к собственным адресам хоста
if conn["peer_addr"] not in own_ips:
continue
port = int(pp)
# Peer_port должен быть слушающим портом — отсекаем эфемерные порты
if port not in listening:
continue
client_proc = _resolve_proc(conn["process"], conn.get("pid"), ps_map)
# Ищем серверный процесс по зеркальной записи
srv_raw, srv_pid = srv_index.get((pp, conn["local_addr"], lp), ("", None))
server_proc = _resolve_proc(srv_raw, srv_pid, ps_map)
key = (client_proc, server_proc, port)
counts[key] = counts.get(key, 0) + 1
for (cp, sp, port), cnt in sorted(counts.items()):
result.append({
"host": hostname,
"client_service": cp,
"server_service": sp,
"port": port,
"count": cnt,
})
return result
def _node_id(hostname: str) -> str:
"""Возвращает безопасный идентификатор Mermaid-узла для имени хоста."""
return "h_" + re.sub(r"[^a-zA-Z0-9]", "_", hostname)
def host_deps_mermaid(hostname: str, deps: list, ext_deps: list | None = None) -> str:
"""Строит Mermaid-диаграмму зависимостей для одного хоста.
Текущий хост выделен стилем; рёбра подписаны входящим портом.
Внешние локальные подключения — зелёный прямоугольник (subgraph).
Внешние удалённые подключения — оранжевый прямоугольник (subgraph).
"""
ext_deps = ext_deps or []
if not deps and not ext_deps:
return ""
# рёбра: (from_node_id, to_node_id) → labels[]
edges: dict = {}
for d in deps:
fid = _node_id(d["from_host"])
tid = _node_id(d["to_host"])
port = str(d.get("port", "")) if d.get("port") else ""
proto = d.get("protocol", "tcp")
label = f"{port}/{proto}" if port and d.get("source") == "static" else port
key = (fid, tid, d["from_host"], d["to_host"])
edges.setdefault(key, [])
if label and label not in edges[key]:
edges[key].append(label)
all_hosts = sorted(
{d["from_host"] for d in deps} | {d["to_host"] for d in deps} | {hostname}
)
lines = ["graph LR"]
for h in all_hosts:
nid = _node_id(h)
if h == hostname:
lines.append(f' {nid}["{h}"]')
lines.append(f' style {nid} fill:#0d6efd,color:#fff,stroke:#0a58ca')
else:
lines.append(f' {nid}["{h}"]')
for (fid, tid, _fh, _th), labels in edges.items():
label = ", ".join(labels)
if label:
lines.append(f' {fid} -->|"{label}"| {tid}')
else:
lines.append(f' {fid} --> {tid}')
host_nid = _node_id(hostname)
# Внешние локальные подключения — зелёный прямоугольник
local_ext = [d for d in ext_deps if d["ext_type"] == "local"]
if local_ext:
subnet_ports: dict = {}
for d in local_ext:
subnet_ports.setdefault(d["subnet"], set()).add(str(d["to_port"]))
lines.append(' subgraph ext_local_group ["Внешние локальные"]')
lines.append(" style ext_local_group fill:#d4edda,stroke:#28a745,color:#155724")
for sn in sorted(subnet_ports):
sn_id = "ext_loc_" + re.sub(r"[^a-zA-Z0-9]", "_", sn)
lines.append(f' {sn_id}["{sn}"]')
lines.append(" end")
for sn, ports in sorted(subnet_ports.items()):
sn_id = "ext_loc_" + re.sub(r"[^a-zA-Z0-9]", "_", sn)
port_label = ", ".join(sorted(ports, key=lambda x: int(x) if x.isdigit() else 0))
lines.append(f' {sn_id} -->|"{port_label}"| {host_nid}')
# Внешние удалённые подключения — оранжевый прямоугольник
remote_ext = [d for d in ext_deps if d["ext_type"] == "remote"]
if remote_ext:
remote_ports: set = set()
for d in remote_ext:
remote_ports.add(str(d["to_port"]))
lines.append(' subgraph ext_remote_group ["Внешние удалённые"]')
lines.append(" style ext_remote_group fill:#fff3cd,stroke:#fd7e14,color:#856404")
lines.append(' ext_internet["Интернет"]')
lines.append(" end")
port_label = ", ".join(sorted(remote_ports, key=lambda x: int(x) if x.isdigit() else 0))
lines.append(f' ext_internet -->|"{port_label}"| {host_nid}')
return "\n".join(lines)
def deps_mermaid(static_deps: list, auto_deps: list, ext_deps: list | None = None) -> str:
"""Строит текст Mermaid-диаграммы зависимостей (graph LR).
Внешние локальные подключения — зелёный subgraph по подсетям.
Внешние удалённые подключения — оранжевый subgraph с узлом «Интернет».
"""
ext_deps = ext_deps or []
# рёбра: (from_node_id, to_node_id) → {fh, th, labels[]}
edges: dict = {}
# На диаграмме отображаем только входящий порт (порт сервера-получателя)
for d in static_deps:
fid, tid = _node_id(d["from_host"]), _node_id(d["to_host"])
port, proto = d.get("port", ""), d.get("protocol", "tcp")
label = f"{port}/{proto}" if port else ""
key = (fid, tid)
edges.setdefault(key, {"fh": d["from_host"], "th": d["to_host"], "labels": []})
if label not in edges[key]["labels"]:
edges[key]["labels"].append(str(label))
for d in auto_deps:
fid, tid = _node_id(d["from_host"]), _node_id(d["to_host"])
label = str(d["port"])
key = (fid, tid)
edges.setdefault(key, {"fh": d["from_host"], "th": d["to_host"], "labels": []})
if label not in edges[key]["labels"]:
edges[key]["labels"].append(label)
if not edges and not ext_deps:
return ""
all_hosts = sorted(
{v["fh"] for v in edges.values()} | {v["th"] for v in edges.values()}
)
lines = ["graph LR"]
for h in all_hosts:
lines.append(f' {_node_id(h)}["{h}"]')
for (fid, tid), v in edges.items():
label = ", ".join(v["labels"])
lines.append(f' {fid} -->|"{label}"| {tid}')
# Внешние локальные подключения — зелёный прямоугольник
# Группировка: subnet → {to_host → set(ports)}
local_ext = [d for d in ext_deps if d["ext_type"] == "local"]
if local_ext:
subnet_host_ports: dict = {}
for d in local_ext:
sn = d["subnet"]
subnet_host_ports.setdefault(sn, {})
subnet_host_ports[sn].setdefault(d["to_host"], set()).add(str(d["to_port"]))
lines.append(' subgraph ext_local_group ["Внешние локальные"]')
lines.append(" style ext_local_group fill:#d4edda,stroke:#28a745,color:#155724")
for sn in sorted(subnet_host_ports):
sn_id = "ext_loc_" + re.sub(r"[^a-zA-Z0-9]", "_", sn)
lines.append(f' {sn_id}["{sn}"]')
lines.append(" end")
for sn, host_ports in sorted(subnet_host_ports.items()):
sn_id = "ext_loc_" + re.sub(r"[^a-zA-Z0-9]", "_", sn)
for to_host, ports in sorted(host_ports.items()):
port_label = ", ".join(sorted(ports, key=lambda x: int(x) if x.isdigit() else 0))
lines.append(f' {sn_id} -->|"{port_label}"| {_node_id(to_host)}')
# Внешние удалённые подключения — оранжевый прямоугольник
# Группировка: to_host → set(ports)
remote_ext = [d for d in ext_deps if d["ext_type"] == "remote"]
if remote_ext:
host_ports: dict = {}
for d in remote_ext:
host_ports.setdefault(d["to_host"], set()).add(str(d["to_port"]))
lines.append(' subgraph ext_remote_group ["Внешние удалённые"]')
lines.append(" style ext_remote_group fill:#fff3cd,stroke:#fd7e14,color:#856404")
lines.append(' ext_internet["Интернет"]')
lines.append(" end")
for to_host, ports in sorted(host_ports.items()):
port_label = ", ".join(sorted(ports, key=lambda x: int(x) if x.isdigit() else 0))
lines.append(f' ext_internet -->|"{port_label}"| {_node_id(to_host)}')
return "\n".join(lines)
def service_mermaid(svc_def: dict, all_deps: list) -> str:
"""Строит Mermaid-диаграмму для глобального сервиса.
Если dep_refs задан — показывает только указанные рёбра.
Иначе — все рёбра из all_deps, где оба конца входят в components.
"""
_ROLE_COLORS = {
"app": "#0d6efd",
"db-primary": "#198754",
"db-replica": "#d1e7dd",
}
_DEFAULT_COLOR = "#6c757d"
comp_set = {(c["host"], c["service"]) for c in svc_def["components"]}
dep_refs = svc_def.get("dep_refs")
if dep_refs:
ref_pairs = {
(r["from"]["host"], r["from"]["service"], r["to"]["host"], r["to"]["service"])
for r in dep_refs
}
edges = [
d for d in all_deps
if (d["from_host"], d["from_service"], d["to_host"], d["to_service"]) in ref_pairs
]
else:
edges = [
d for d in all_deps
if (d["from_host"], d["from_service"]) in comp_set
and (d["to_host"], d["to_service"]) in comp_set
]
if not edges and len(svc_def["components"]) < 2:
return ""
def _svc_node_id(host: str, service: str) -> str:
return "s_" + re.sub(r"[^a-zA-Z0-9]", "_", host + "_" + service)
# Строим карту компонентов для получения label и role
comp_map = {(c["host"], c["service"]): c for c in svc_def["components"]}
lines = ["graph LR"]
# Узлы всех компонентов
for c in svc_def["components"]:
nid = _svc_node_id(c["host"], c["service"])
label = c.get("label") or f"{c['host']}\\n{c['service']}"
color = _ROLE_COLORS.get(c.get("role", ""), _DEFAULT_COLOR)
lines.append(f' {nid}["{label}"]')
lines.append(f" style {nid} fill:{color},color:#fff,stroke:#333")
# Рёбра
seen = set()
for d in edges:
fid = _svc_node_id(d["from_host"], d["from_service"])
tid = _svc_node_id(d["to_host"], d["to_service"])
port = str(d.get("port", ""))
label = port if port else ""
key = (fid, tid, label)
if key in seen:
continue
seen.add(key)
lines.append(f' {fid} -->|"{label}"| {tid}')
return "\n".join(lines)
# ── Шаблон страницы ────────────────────────────────────────────────────────────
def page(title: str, body: str, depth: int = 0, extra_scripts: str = "") -> str:
root = "../" * depth
return f"""<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{title}</title>
<link rel="stylesheet" href="{BOOTSTRAP_CSS}">
<style>
body {{ background: #f5f7fa; }}
.navbar-brand {{ font-weight: 700; letter-spacing: .3px; }}
code {{ font-size: .85em; }}
.progress {{ height: 14px; }}
th {{ white-space: nowrap; }}
</style>
</head>
<body>
<nav class="navbar navbar-dark bg-dark mb-4 px-3 d-flex align-items-center gap-3">
<a class="navbar-brand" href="{root}index.html">⚙ Ansible Hosts</a>
<a class="nav-link text-light small" href="{root}dependencies.html">Зависимости</a>
<a class="nav-link text-light small" href="{root}services/index.html">Сервисы</a>
<span class="ms-auto text-secondary small">Сгенерировано: {GENERATED_AT}</span>
</nav>
<div class="container-xl px-4 pb-5">
{body}
</div>
<script src="{BOOTSTRAP_JS}"></script>
{extra_scripts}
<script>
function filterTable(inputId, tbodyId) {{
var q = document.getElementById(inputId).value.toLowerCase();
var rows = document.getElementById(tbodyId).querySelectorAll('tr');
rows.forEach(function(row) {{
row.style.display = row.textContent.toLowerCase().includes(q) ? '' : 'none';
}});
}}
</script>
</body>
</html>"""
# ── Docker (индекс + страница) ────────────────────────────────────────────────
def docker_summary_section(hosts: dict) -> str:
"""Рендерит краткую сводку Docker-контейнеров на главной странице."""
all_containers = []
hosts_with_containers = 0
for hostname, f in hosts.items():
containers = parse_docker_containers(f)
if containers:
hosts_with_containers += 1
all_containers.extend(containers)
if not all_containers:
return ""
return f"""
<div class="d-flex align-items-center mt-5 mb-3 gap-2">
<h4 class="mb-0">Docker-контейнеры</h4>
<span class="badge bg-secondary">{len(all_containers)}</span>
<span class="badge bg-info text-dark">{hosts_with_containers} хостов</span>
<a href="containers.html" class="btn btn-sm btn-outline-secondary ms-2">Подробнее →</a>
</div>"""
def docker_page(hosts: dict) -> str:
"""Генерирует HTML-страницу со всеми Docker-контейнерами."""
rows = []
for hostname, f in hosts.items():
for c in parse_docker_containers(f):
rows.append(
f"<tr>"
f"<td><code>{c['name']}</code></td>"
f"<td><a href='hosts/{hostname}.html' class='fw-semibold text-decoration-none'>{hostname}</a></td>"
f"<td><small class='text-muted'>{c['image']}</small></td>"
f"<td><small class='text-muted'>{c['ports']}</small></td>"
f"<td><small class='text-muted'>{c['volumes']}</small></td>"
f"</tr>"
)
containers_section = ""
if rows:
containers_section = f"""
<h5 class="mt-4 mb-2">Контейнеры <span class="badge bg-secondary">{len(rows)}</span></h5>
<div class="mb-2">
<input type="text" id="dockerFilter" class="form-control form-control-sm w-auto d-inline-block"
placeholder="Фильтр по хосту, контейнеру, образу…" oninput="filterTable('dockerFilter','dockerBody')">
</div>
<div class="table-responsive">
<table class="table table-hover table-bordered table-sm align-middle bg-white shadow-sm">
<thead class="table-dark">
<tr>
<th>Имя контейнера</th>
<th>Хост</th>
<th>Образ</th>
<th>Порты</th>
<th>Volumes</th>
</tr>
</thead>
<tbody id="dockerBody">{''.join(rows)}</tbody>
</table>
</div>"""
body = f"""
<nav aria-label="breadcrumb" class="mb-3">
<ol class="breadcrumb">
<li class="breadcrumb-item"><a href="index.html">Хосты</a></li>
<li class="breadcrumb-item active">Docker-контейнеры</li>
</ol>
</nav>
<h4 class="mb-1">Docker-контейнеры</h4>
{containers_section}"""
return page("Docker-контейнеры", body)
# ── Зависимости между службами (страница) ─────────────────────────────────────
def deps_index_section(static_deps: list, auto_deps: list, ext_deps: list, intra_deps: list) -> str:
"""Рендерит краткую сводку зависимостей на главной странице."""
total = len(static_deps) + len(auto_deps)
if total == 0 and not ext_deps and not intra_deps:
return ""
ext_local = sum(1 for d in ext_deps if d["ext_type"] == "local")
ext_remote = sum(1 for d in ext_deps if d["ext_type"] == "remote")
return f"""
<div class="d-flex align-items-center mt-5 mb-3 gap-2 flex-wrap">
<h4 class="mb-0">Зависимости между службами</h4>
<span class="badge bg-secondary">{total}</span>
<span class="badge bg-primary">{len(static_deps)} статических</span>
<span class="badge bg-info text-dark">{len(auto_deps)} авто</span>
<span class="badge bg-success">{len(intra_deps)} внутрихостовых</span>
<span class="badge bg-warning text-dark">{ext_local} внешних локальных</span>
<span class="badge bg-danger">{ext_remote} внешних удалённых</span>
<a href="dependencies.html" class="btn btn-sm btn-outline-secondary ms-2">Зависимости →</a>
<a href="external_connections.html" class="btn btn-sm btn-outline-secondary ms-1">Внешние подключения →</a>
</div>"""
def global_services_index_section(global_services: list) -> str:
"""Рендерит краткую сводку глобальных сервисов на главной странице."""
if not global_services:
return ""
links = "".join(
f'<a href="services/{s["slug"]}.html" class="btn btn-sm btn-outline-primary ms-1">{s["name"]}</a>'
for s in global_services
)
return f"""
<div class="d-flex align-items-center mt-5 mb-3 gap-2 flex-wrap">
<h4 class="mb-0">Глобальные сервисы</h4>
<span class="badge bg-secondary">{len(global_services)}</span>
<a href="services/index.html" class="btn btn-sm btn-outline-secondary ms-2">Все сервисы →</a>
{links}
</div>"""
def external_page(ext_deps: list) -> str:
"""Генерирует HTML-страницу external_connections.html.
Структура: хост → служба → IP-адреса источников без группировки по подсетям.
Локальные подключения — зелёная таблица, удалённые — оранжевая.
"""
if not ext_deps:
body = """
<nav aria-label="breadcrumb" class="mb-3">
<ol class="breadcrumb">
<li class="breadcrumb-item"><a href="index.html">Хосты</a></li>
<li class="breadcrumb-item"><a href="dependencies.html">Зависимости</a></li>
<li class="breadcrumb-item active">Внешние подключения</li>
</ol>
</nav>
<h4 class="mb-3">Внешние подключения</h4>
<p class="text-muted">Данные об внешних подключениях отсутствуют.</p>"""
return page("Внешние подключения", body)
# Структура: {to_host: {to_service: {"local": [(port, proto, ip, count, historical, last_seen, ct_state)], "remote": [(port, ip, count)]}}}
structure: dict = {}
for d in ext_deps:
host = d["to_host"]
svc = d["to_service"]
structure.setdefault(host, {})
structure[host].setdefault(svc, {"local": [], "remote": []})
if d["ext_type"] == "local":
entry = (
d["to_port"], d.get("proto", "tcp"), d.get("ip", ""), d["count"],
d.get("historical", False), d.get("last_seen", ""), d.get("ct_state", ""),
)
structure[host][svc]["local"].append(entry)
else:
structure[host][svc]["remote"].append((d["to_port"], d.get("ip", ""), d["count"]))
host_cards = []
for host in sorted(structure):
svc_blocks = []
for idx, svc in enumerate(sorted(structure[host])):
data = structure[host][svc]
# Таблица локальных подключений — индивидуальные IP, сортировка по порту → IP
local_block = ""
if data["local"]:
rows = []
for port, proto, ip, count, historical, last_seen, ct_state in sorted(
data["local"],
key=lambda x: (x[0], x[1], _ip_sort_key(x[2]))
):
port_badge = f"<span class='badge bg-primary'>{port}/{proto}</span>"
if historical:
ls_short = (last_seen or "")[:10]
rows.append(
f"<tr class='text-muted'>"
f"<td>{port_badge}</td>"
f"<td><code class='text-muted'>{ip}</code></td>"
f"<td class='text-end'><small class='fst-italic'>last seen: {ls_short}</small></td>"
f"</tr>"
)
elif ct_state == "TIME_WAIT":
rows.append(
f"<tr>"
f"<td>{port_badge}</td>"
f"<td><code>{ip}</code></td>"
f"<td class='text-end text-muted'><small>(TIME_WAIT)</small></td>"
f"</tr>"
)
else:
rows.append(
f"<tr>"
f"<td>{port_badge}</td>"
f"<td><code>{ip}</code></td>"
f"<td class='text-end text-muted'><small>{count}</small></td>"
f"</tr>"
)
active_local = sum(1 for _, _, _, _, h, _, _ in data["local"] if not h)
hist_local = sum(1 for _, _, _, _, h, _, _ in data["local"] if h)
hist_badge_l = f' <span class="badge bg-secondary ms-1">{hist_local} истор.</span>' if hist_local else ""
local_block = f"""
<div class="mt-2 mb-1">
<span class="fw-semibold small" style="color:#155724">&#9632; Локальные сети
<span class="badge bg-success ms-1">{active_local}</span>{hist_badge_l}
</span>
</div>
<div class="table-responsive">
<table class="table table-sm table-bordered bg-white mb-2" style="border-color:#28a745;max-width:480px">
<thead style="background-color:#d4edda">
<tr><th>Порт</th><th>IP-адрес источника</th><th class="text-end">Соед.</th></tr>
</thead>
<tbody>{''.join(rows)}</tbody>
</table>
</div>"""
# Таблица удалённых подключений — индивидуальные IP, сортировка по порту → IP
remote_block = ""
if data["remote"]:
rows = []
for port, ip, count in sorted(
data["remote"],
key=lambda x: (x[0], _ip_sort_key(x[1]))
):
rows.append(
f"<tr>"
f"<td><span class='badge bg-primary'>{port}/tcp</span></td>"
f"<td><code>{ip}</code></td>"
f"<td class='text-end text-muted'><small>{count}</small></td>"
f"</tr>"
)
remote_block = f"""
<div class="mt-2 mb-1">
<span class="fw-semibold small" style="color:#856404">&#9632; Публичный интернет
<span class="badge bg-warning text-dark ms-1">{len(rows)}</span>
</span>
</div>
<div class="table-responsive">
<table class="table table-sm table-bordered bg-white mb-2" style="border-color:#fd7e14;max-width:480px">
<thead style="background-color:#fff3cd">
<tr><th>Порт</th><th>IP-адрес источника</th><th class="text-end">Соед.</th></tr>
</thead>
<tbody>{''.join(rows)}</tbody>
</table>
</div>"""
border_top = ' style="border-top:1px solid #dee2e6"' if idx > 0 else ""
svc_blocks.append(f"""
<div class="px-3 py-2"{border_top}>
<div class="fw-semibold mb-1"><code>{svc}</code></div>
{local_block}
{remote_block}
</div>""")
svc_count = len(structure[host])
local_ips = len({(svc, e[2]) for svc, d in structure[host].items() for e in d["local"] if not e[4]})
remote_ips = len({(svc, e[1]) for svc, d in structure[host].items() for e in d["remote"]})
badges = ""
if local_ips:
badges += f' <span class="badge bg-success">{local_ips} лок. IP</span>'
if remote_ips:
badges += f' <span class="badge bg-warning text-dark">{remote_ips} удал. IP</span>'
host_cards.append(f"""
<div class="card shadow-sm mb-4 ext-host-card">
<div class="card-header bg-dark text-white d-flex align-items-center gap-2 flex-wrap">
<a href="hosts/{host}.html" class="text-white fw-semibold text-decoration-none">{host}</a>
<span class="badge bg-secondary">{svc_count} служб</span>{badges}
</div>
<div class="card-body p-0">
{''.join(svc_blocks)}
</div>
</div>""")
total_hosts = len(structure)
total_svcs = sum(len(v) for v in structure.values())
local_ips = len({d.get("ip", "") for d in ext_deps if d["ext_type"] == "local" and not d.get("historical")})
remote_ips = len({d.get("ip", "") for d in ext_deps if d["ext_type"] == "remote"})
body = f"""
<nav aria-label="breadcrumb" class="mb-3">
<ol class="breadcrumb">
<li class="breadcrumb-item"><a href="index.html">Хосты</a></li>
<li class="breadcrumb-item"><a href="dependencies.html">Зависимости</a></li>
<li class="breadcrumb-item active">Внешние подключения</li>
</ol>
</nav>
<div class="d-flex align-items-center mb-3 gap-2 flex-wrap">
<h4 class="mb-0">Внешние подключения</h4>
<span class="badge bg-secondary">{total_hosts} хостов</span>
<span class="badge bg-secondary">{total_svcs} служб</span>
<span class="badge bg-success">{local_ips} локальных IP</span>
<span class="badge bg-warning text-dark">{remote_ips} удалённых IP</span>
</div>
<div class="mb-3">
<input type="text" id="extPageFilter" class="form-control form-control-sm w-auto d-inline-block"
placeholder="Фильтр по хосту, службе или IP…"
oninput="(function(q){{document.querySelectorAll('.ext-host-card').forEach(function(c){{c.style.display=c.textContent.toLowerCase().includes(q)?'':'none'}})}})( this.value.toLowerCase())">
</div>
{''.join(host_cards)}"""
return page("Внешние подключения", body)
def deps_page(static_deps: list, auto_deps: list, ext_deps: list, intra_deps: list) -> str:
"""Генерирует HTML-страницу dependencies.html."""
# ── Mermaid-диаграмма ─────────────────────────────────────────────────────
mermaid_text = deps_mermaid(static_deps, auto_deps, ext_deps)
diagram_section = ""
if mermaid_text:
escaped = mermaid_text.replace("`", "\\`")
diagram_section = f"""
<h5 class="mt-4 mb-2">Граф зависимостей</h5>
<div class="bg-white border rounded p-3 shadow-sm mb-4 text-center">
<div class="mermaid">
{mermaid_text}
</div>
</div>"""
# ── Статические зависимости ───────────────────────────────────────────────
static_rows = []
for d in static_deps:
from_cell = (
f"<a href='hosts/{d['from_host']}.html' class='fw-semibold text-decoration-none'>{d['from_host']}</a>"
+ (f"<br><small class='text-muted'>{d['from_service']}</small>" if d["from_service"] else "")
)
to_cell = (
f"<a href='hosts/{d['to_host']}.html' class='fw-semibold text-decoration-none'>{d['to_host']}</a>"
+ (f"<br><small class='text-muted'>{d['to_service']}</small>" if d["to_service"] else "")
)
port_cell = (
f"<span class='badge bg-primary'>{d['port']}/{d['protocol']}</span>"
if d["port"] else ""
)
static_rows.append(
f"<tr>"
f"<td>{from_cell}</td>"
f"<td>{to_cell}</td>"
f"<td>{port_cell}</td>"
f"<td><small class='text-muted'>{d['description'] or ''}</small></td>"
f"</tr>"
)
static_section = ""
if static_rows:
static_section = f"""
<h5 class="mt-4 mb-2">
Статические зависимости
<span class="badge bg-secondary">{len(static_rows)}</span>
<small class="text-muted fw-normal ms-2" style="font-size:.8em">из service_map.yml</small>
</h5>
<div class="mb-2">
<input type="text" id="staticFilter" class="form-control form-control-sm w-auto d-inline-block"
placeholder="Фильтр по хосту, службе, порту…" oninput="filterTable('staticFilter','staticBody')">
</div>
<div class="table-responsive">
<table class="table table-hover table-bordered table-sm align-middle bg-white shadow-sm">
<thead class="table-dark">
<tr><th>От (хост / служба)</th><th>До (хост / служба)</th><th>Порт</th><th>Описание</th></tr>
</thead>
<tbody id="staticBody">{''.join(static_rows)}</tbody>
</table>
</div>"""
# ── Автоопределённые зависимости ─────────────────────────────────────────
auto_rows = []
for d in sorted(auto_deps, key=lambda x: (x["port"], x["to_host"], x["from_host"])):
from_link = f"<a href='hosts/{d['from_host']}.html' class='fw-semibold text-decoration-none'>{d['from_host']}</a>"
to_link = f"<a href='hosts/{d['to_host']}.html' class='fw-semibold text-decoration-none'>{d['to_host']}</a>"
from_svc = d.get("from_service") or ""
to_svc = d.get("to_service") or ""
port_badge = f"<span class='badge bg-primary'>{d['port']}/tcp</span>"
svc_matched = from_svc != "" and to_svc != ""
match_badge = (
"<span class='badge bg-success ms-1' title='Службы сопоставлены'>✓</span>"
if svc_matched else ""
)
auto_rows.append(
f"<tr>"
f"<td>{from_link}<br><small class='text-muted'>{from_svc}</small></td>"
f"<td>{to_link}<br><small class='text-muted'>{to_svc}</small></td>"
f"<td>{port_badge}{match_badge}</td>"
f"<td class='text-end text-muted'><small>{d['count']}</small></td>"
f"</tr>"
)
auto_section = ""
if auto_rows:
matched = sum(1 for d in auto_deps if d.get("from_service", "") != "" and d.get("to_service", "") != "")
auto_section = f"""
<h5 class="mt-4 mb-2">
Автоопределённые зависимости
<span class="badge bg-secondary">{len(auto_rows)}</span>
<span class="badge bg-success" title="Службы сопоставлены на обоих концах">{matched} сопоставлено</span>
<small class="text-muted fw-normal ms-2" style="font-size:.8em">по активным TCP-соединениям из ss</small>
</h5>
<div class="mb-2">
<input type="text" id="autoFilter" class="form-control form-control-sm w-auto d-inline-block"
placeholder="Фильтр по хосту, службе, порту…" oninput="filterTable('autoFilter','autoBody')">
</div>
<div class="table-responsive">
<table class="table table-hover table-bordered table-sm align-middle bg-white shadow-sm">
<thead class="table-dark">
<tr><th>Источник (хост / служба)</th><th>Назначение (хост / служба)</th><th>Порт</th><th class="text-end">Соед.</th></tr>
</thead>
<tbody id="autoBody">{''.join(auto_rows)}</tbody>
</table>
</div>"""
if not static_section and not auto_section:
auto_section = '<p class="text-muted mt-4">Зависимости не обнаружены. Соберите факты хостов и/или заполните service_map.yml.</p>'
# ── Внутрихостовые зависимости ────────────────────────────────────────────
intra_rows = []
for d in sorted(intra_deps, key=lambda x: (x["host"], x["port"], x["client_service"])):
host_link = f"<a href='hosts/{d['host']}.html' class='fw-semibold text-decoration-none'>{d['host']}</a>"
client_svc = d.get("client_service") or ""
server_svc = d.get("server_service") or ""
port_badge = f"<span class='badge bg-primary'>{d['port']}/tcp</span>"
svc_matched = client_svc != "" and server_svc != ""
match_badge = (
"<span class='badge bg-success ms-1' title='Службы сопоставлены'>✓</span>"
if svc_matched else ""
)
intra_rows.append(
f"<tr>"
f"<td>{host_link}</td>"
f"<td><small class='text-muted'>{client_svc}</small></td>"
f"<td><small class='text-muted'>{server_svc}</small></td>"
f"<td>{port_badge}{match_badge}</td>"
f"<td class='text-end text-muted'><small>{d['count']}</small></td>"
f"</tr>"
)
intra_section = ""
if intra_rows:
matched_intra = sum(
1 for d in intra_deps
if d.get("client_service", "") != "" and d.get("server_service", "") != ""
)
intra_section = f"""
<h5 class="mt-4 mb-2">
Внутрихостовые зависимости
<span class="badge bg-secondary">{len(intra_rows)}</span>
<span class="badge bg-success" title="Службы сопоставлены на обоих концах">{matched_intra} сопоставлено</span>
<small class="text-muted fw-normal ms-2" style="font-size:.8em">loopback TCP-соединения внутри хоста</small>
</h5>
<div class="mb-2">
<input type="text" id="intraFilter" class="form-control form-control-sm w-auto d-inline-block"
placeholder="Фильтр по хосту, службе, порту…" oninput="filterTable('intraFilter','intraBody')">
</div>
<div class="table-responsive">
<table class="table table-hover table-bordered table-sm align-middle bg-white shadow-sm">
<thead class="table-dark">
<tr><th>Хост</th><th>Клиент (служба)</th><th>Сервер (служба)</th><th>Порт</th><th class="text-end">Соед.</th></tr>
</thead>
<tbody id="intraBody">{''.join(intra_rows)}</tbody>
</table>
</div>"""
# ── Внешние локальные подключения ─────────────────────────────────────────
# Показываем индивидуальные IP: текущие (ss/conntrack) и исторические
ext_local_entries = [d for d in ext_deps if d["ext_type"] == "local"]
ext_local_rows = []
for d in sorted(
ext_local_entries,
key=lambda x: (x["to_host"], x["to_port"], x["to_service"], _ip_sort_key(x["ip"])),
):
historical = d.get("historical", False)
ct_state = d.get("ct_state", "")
proto = d.get("proto", "tcp")
to_link = f"<a href='hosts/{d['to_host']}.html' class='fw-semibold text-decoration-none'>{d['to_host']}</a>"
svc_cell = f"<small class='text-muted'>{d['to_service']}</small>" if d["to_service"] != "" else ""
port_badge = f"<span class='badge bg-primary'>{d['to_port']}/{proto}</span>"
subnet_badge = f"<span class='badge bg-warning text-dark'>{d['subnet']}</span>"
if historical:
last_seen = (d.get("last_seen") or "")[:10]
ip_cell = f"<code class='text-muted'>{d['ip']}</code>"
status = f"<small class='text-muted fst-italic'>last seen: {last_seen}</small>"
row_class = " class='text-muted'"
elif ct_state == "TIME_WAIT":
ip_cell = f"<code>{d['ip']}</code>"
status = f"<small class='text-muted'>(TIME_WAIT)</small>"
row_class = ""
else:
ip_cell = f"<code>{d['ip']}</code>"
status = f"<small class='text-muted'>×{d['count']}</small>" if d["count"] > 1 else ""
row_class = ""
ext_local_rows.append(
f"<tr{row_class}>"
f"<td>{to_link}</td>"
f"<td>{svc_cell}</td>"
f"<td>{port_badge}</td>"
f"<td>{ip_cell} {subnet_badge}</td>"
f"<td>{status}</td>"
f"</tr>"
)
ext_local_section = ""
if ext_local_rows:
active_count = sum(1 for d in ext_local_entries if not d.get("historical"))
hist_count = sum(1 for d in ext_local_entries if d.get("historical"))
hist_badge = f' <span class="badge bg-secondary">{hist_count} истор.</span>' if hist_count else ""
ext_local_section = f"""
<h5 class="mt-4 mb-2">
Внешние локальные подключения
<span class="badge bg-success">{active_count} активных</span>{hist_badge}
<small class="text-muted fw-normal ms-2" style="font-size:.8em">источник — локальные сети, индивидуальные IP из ss/conntrack и истории</small>
</h5>
<div class="mb-2">
<input type="text" id="extLocalFilter" class="form-control form-control-sm w-auto d-inline-block"
placeholder="Фильтр по хосту, службе, IP…" oninput="filterTable('extLocalFilter','extLocalBody')">
</div>
<div class="table-responsive">
<table class="table table-hover table-bordered table-sm align-middle bg-white shadow-sm">
<thead class="table-dark">
<tr><th>Сервер</th><th>Служба</th><th>Порт</th><th>IP / Подсеть</th><th>Статус / Соед.</th></tr>
</thead>
<tbody id="extLocalBody">{''.join(ext_local_rows)}</tbody>
</table>
</div>"""
# ── Внешние удалённые подключения ─────────────────────────────────────────
# Группировка: (to_host, to_port, proto, to_service) → total count
remote_groups: dict = {}
for d in ext_deps:
if d["ext_type"] != "remote":
continue
key = (d["to_host"], d["to_port"], d.get("proto", "tcp"), d["to_service"])
remote_groups[key] = remote_groups.get(key, 0) + d["count"]
ext_remote_rows = []
for (to_host, to_port, proto, to_svc), total in sorted(remote_groups.items()):
to_link = f"<a href='hosts/{to_host}.html' class='fw-semibold text-decoration-none'>{to_host}</a>"
svc_cell = f"<small class='text-muted'>{to_svc}</small>" if to_svc != "" else ""
port_badge = f"<span class='badge bg-primary'>{to_port}/{proto}</span>"
ext_remote_rows.append(
f"<tr>"
f"<td>{to_link}</td>"
f"<td>{svc_cell}</td>"
f"<td>{port_badge}</td>"
f"<td class='text-end text-muted'><small>{total}</small></td>"
f"</tr>"
)
ext_remote_section = ""
if ext_remote_rows:
ext_remote_section = f"""
<h5 class="mt-4 mb-2">
Внешние удалённые подключения
<span class="badge bg-secondary">{len(ext_remote_rows)}</span>
<small class="text-muted fw-normal ms-2" style="font-size:.8em">источник — публичный интернет, суммарное количество</small>
</h5>
<div class="mb-2">
<input type="text" id="extRemoteFilter" class="form-control form-control-sm w-auto d-inline-block"
placeholder="Фильтр по хосту или службе…" oninput="filterTable('extRemoteFilter','extRemoteBody')">
</div>
<div class="table-responsive">
<table class="table table-hover table-bordered table-sm align-middle bg-white shadow-sm">
<thead class="table-dark">
<tr><th>Сервер</th><th>Служба</th><th>Порт</th><th class="text-end">Всего соединений</th></tr>
</thead>
<tbody id="extRemoteBody">{''.join(ext_remote_rows)}</tbody>
</table>
</div>"""
body = f"""
<nav aria-label="breadcrumb" class="mb-3">
<ol class="breadcrumb">
<li class="breadcrumb-item"><a href="index.html">Хосты</a></li>
<li class="breadcrumb-item active">Зависимости</li>
</ol>
</nav>
<div class="d-flex align-items-center mb-1 gap-2 flex-wrap">
<h4 class="mb-0">Зависимости между службами</h4>
<a href="external_connections.html" class="btn btn-sm btn-outline-secondary ms-2">Внешние подключения по службам →</a>
</div>
<p class="text-muted mb-0"><small>
Статические — из <code>service_map.yml</code>.
Авто — из активных TCP-соединений (<code>ss -tnp state established</code>), собранных при последнем запуске gather_facts.
</small></p>
{diagram_section}
{static_section}
{auto_section}
{intra_section}
{ext_local_section}
{ext_remote_section}"""
mermaid_script = """<script src="https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.min.js"></script>
<script>mermaid.initialize({{startOnLoad: true, theme: 'default'}});</script>"""
return page("Зависимости между службами", body, extra_scripts=mermaid_script)
# ── VMware vSphere ─────────────────────────────────────────────────────────────
def vmware_index_section(vmw: dict) -> str:
"""Рендерит краткую сводку VMware vSphere на главной странице."""
esxi_count = len(vmw["esxi_hosts"])
vm_count = len(vmw["vms"])
powered_on = sum(1 for v in vmw["vms"] if v.get("power_state") == "poweredOn")
ds_count = len(vmw["datastores"])
if esxi_count == 0 and vm_count == 0:
return ""
return f"""
<div class="d-flex align-items-center mt-5 mb-3 gap-2">
<h4 class="mb-0">VMware vSphere</h4>
<span class="badge bg-secondary">{esxi_count} ESXi</span>
<span class="badge bg-secondary">{vm_count} VM</span>
<span class="badge bg-success">{powered_on} вкл.</span>
<span class="badge bg-secondary">{ds_count} датасторов</span>
<a href="vmware.html" class="btn btn-sm btn-outline-secondary ms-2">Подробнее →</a>
</div>"""
def vmware_page(vmw: dict) -> str:
"""Генерирует HTML-страницу с обзором VMware vSphere инфраструктуры."""
# ── ESXi гипервизоры ──────────────────────────────────────────────────────
esxi_rows = []
for hostname, facts in vmw["esxi_hosts"].items():
vcpus = facts.get("ansible_processor_vcpus", facts.get("ansible_processor_count", ""))
ram_mb = facts.get("ansible_memtotal_mb", 0)
ram_str = f"{ram_mb / 1024:.1f} ГБ" if ram_mb >= 1024 else f"{ram_mb} МБ"
product = facts.get("ansible_product_name", "")
kernel = facts.get("ansible_kernel", "")
esxi_rows.append(
f"<tr>"
f"<td><code>{hostname}</code></td>"
f"<td class='text-end'>{vcpus}</td>"
f"<td class='text-end'>{ram_str}</td>"
f"<td><small>{product}</small></td>"
f"<td><small class='text-muted'>{kernel}</small></td>"
f"</tr>"
)
esxi_section = ""
if esxi_rows:
esxi_section = f"""
<h5 class="mt-4 mb-2">ESXi-гипервизоры <span class="badge bg-secondary">{len(esxi_rows)}</span></h5>
<div class="table-responsive">
<table class="table table-hover table-bordered table-sm align-middle bg-white shadow-sm">
<thead class="table-dark">
<tr><th>Хост</th><th class="text-end">CPU</th><th class="text-end">RAM</th><th>Модель</th><th>Версия ESXi</th></tr>
</thead>
<tbody>{''.join(esxi_rows)}</tbody>
</table>
</div>"""
# ── Виртуальные машины ────────────────────────────────────────────────────
vm_rows = []
for vm in vmw["vms"]:
name = vm.get("guest_name", "")
power = vm.get("power_state", "")
power_badge = (
'<span class="badge bg-success">poweredOn</span>' if power == "poweredOn"
else '<span class="badge bg-secondary">poweredOff</span>' if power == "poweredOff"
else f'<span class="badge bg-warning text-dark">{power}</span>'
)
esxi_host = vm.get("esxi_hostname", "")
ip = vm.get("ip_address") or ""
cpu = vm.get("num_cpu", "")
mem_mb = vm.get("memory_mb", 0)
mem_str = f"{mem_mb / 1024:.1f} ГБ" if mem_mb >= 1024 else f"{mem_mb} МБ"
os_guest = vm.get("guest_fullname") or ""
vm_rows.append(
f"<tr>"
f"<td><span class='fw-semibold'>{name}</span></td>"
f"<td>{power_badge}</td>"
f"<td><small class='text-muted'>{esxi_host}</small></td>"
f"<td><code>{ip}</code></td>"
f"<td class='text-end'>{cpu}</td>"
f"<td class='text-end'>{mem_str}</td>"
f"<td><small>{os_guest}</small></td>"
f"</tr>"
)
vms_section = ""
if vm_rows:
powered_on = sum(1 for v in vmw["vms"] if v.get("power_state") == "poweredOn")
vms_section = f"""
<h5 class="mt-4 mb-2">
Виртуальные машины
<span class="badge bg-secondary">{len(vm_rows)}</span>
<span class="badge bg-success">{powered_on} вкл.</span>
</h5>
<div class="mb-2">
<input type="text" id="vmFilter" class="form-control form-control-sm w-auto d-inline-block"
placeholder="Фильтр по имени, хосту, ОС…" oninput="filterTable('vmFilter','vmBody')">
</div>
<div class="table-responsive">
<table class="table table-hover table-bordered table-sm align-middle bg-white shadow-sm">
<thead class="table-dark">
<tr><th>Имя VM</th><th>Состояние</th><th>Гипервизор</th><th>IP</th>
<th class="text-end">CPU</th><th class="text-end">RAM</th><th>ОС</th></tr>
</thead>
<tbody id="vmBody">{''.join(vm_rows)}</tbody>
</table>
</div>"""
# ── Датасторы ─────────────────────────────────────────────────────────────
ds_rows = []
for ds in vmw["datastores"]:
ds_name = ds.get("name", "")
ds_type = ds.get("type", "")
capacity = int(ds.get("capacity", 0))
free = int(ds.get("freeSpace", 0))
accessible = ds.get("accessible", False)
if capacity > 0:
pct = int((capacity - free) / capacity * 100)
color = "danger" if pct > 85 else "warning" if pct > 70 else "success"
bar = (
f'<div class="progress">'
f'<div class="progress-bar bg-{color}" style="width:{pct}%">{pct}%</div>'
f'</div>'
)
usage_str = f"{fmt_bytes(capacity - free)} / {fmt_bytes(capacity)}"
else:
bar = ""
usage_str = ""
access_badge = (
'<span class="badge bg-success">ОК</span>' if accessible
else '<span class="badge bg-danger">Недоступен</span>'
)
ds_rows.append(
f"<tr>"
f"<td><span class='fw-semibold'>{ds_name}</span></td>"
f"<td><span class='badge bg-info text-dark'>{ds_type}</span></td>"
f"<td>{access_badge}</td>"
f"<td><small>{usage_str}</small></td>"
f'<td style="min-width:110px">{bar}</td>'
f"</tr>"
)
ds_section = ""
if ds_rows:
ds_section = f"""
<h5 class="mt-4 mb-2">Датасторы <span class="badge bg-secondary">{len(ds_rows)}</span></h5>
<div class="mb-2">
<input type="text" id="dsFilter" class="form-control form-control-sm w-auto d-inline-block"
placeholder="Фильтр по имени или типу…" oninput="filterTable('dsFilter','dsBody')">
</div>
<div class="table-responsive">
<table class="table table-hover table-bordered table-sm align-middle bg-white shadow-sm">
<thead class="table-dark">
<tr><th>Датастор</th><th>Тип</th><th>Доступность</th><th>Использование</th>
<th style="min-width:110px">Заполнение</th></tr>
</thead>
<tbody id="dsBody">{''.join(ds_rows)}</tbody>
</table>
</div>"""
body = f"""
<nav aria-label="breadcrumb" class="mb-3">
<ol class="breadcrumb">
<li class="breadcrumb-item"><a href="index.html">Хосты</a></li>
<li class="breadcrumb-item active">VMware vSphere</li>
</ol>
</nav>
<h4 class="mb-1">VMware vSphere</h4>
<p class="text-muted mb-4"><small>Датацентр: <strong>dc1</strong> / Кластер: <strong>cl1</strong></small></p>
{esxi_section}
{vms_section}
{ds_section}"""
return page("VMware vSphere", body)
# ── Глобальные сервисы ─────────────────────────────────────────────────────────
def services_index_page(global_services: list, hosts: dict, all_deps: list) -> str:
"""Генерирует public/services/index.html — каталог всех глобальных сервисов."""
if not global_services:
body = """
<nav aria-label="breadcrumb" class="mb-3">
<ol class="breadcrumb">
<li class="breadcrumb-item"><a href="../index.html">Хосты</a></li>
<li class="breadcrumb-item active">Сервисы</li>
</ol>
</nav>
<h4 class="mb-1">Глобальные сервисы</h4>
<p class="text-muted">Файлы сервисов не найдены. Создайте <code>services/*.yml</code> в корне репозитория.</p>"""
return page("Глобальные сервисы", body, depth=1)
cards = []
for svc in global_services:
comp_rows = []
for c in svc["components"]:
hostname = c["host"]
service = c["service"]
role = c.get("role", "")
label = c.get("label", "")
state = hosts.get(hostname, {}).get("services", {}).get(service, {}).get("state", "")
if state == "running":
state_badge = '<span class="badge bg-success">running</span>'
elif state:
state_badge = f'<span class="badge bg-secondary">{state}</span>'
else:
state_badge = '<span class="text-muted">—</span>'
comp_rows.append(
f"<tr>"
f"<td><a href='../hosts/{hostname}.html'>{hostname}</a></td>"
f"<td><code>{service}</code></td>"
f"<td><small class='text-muted'>{role}</small></td>"
f"<td>{state_badge}</td>"
f"</tr>"
)
comp_table = f"""
<table class="table table-sm table-bordered mb-0">
<thead class="table-dark"><tr><th>Хост</th><th>Служба</th><th>Роль</th><th>Состояние</th></tr></thead>
<tbody>{''.join(comp_rows)}</tbody>
</table>"""
desc_html = f'<p class="card-text text-muted small mb-2">{svc["description"]}</p>' if svc["description"] else ""
cards.append(f"""
<div class="col">
<div class="card h-100 shadow-sm">
<div class="card-header bg-dark text-white fw-semibold">{svc['name']}</div>
<div class="card-body pb-2">
{desc_html}
{comp_table}
</div>
<div class="card-footer bg-transparent">
<a href="./{svc['slug']}.html" class="btn btn-sm btn-outline-primary">Подробнее →</a>
</div>
</div>
</div>""")
body = f"""
<nav aria-label="breadcrumb" class="mb-3">
<ol class="breadcrumb">
<li class="breadcrumb-item"><a href="../index.html">Хосты</a></li>
<li class="breadcrumb-item active">Сервисы</li>
</ol>
</nav>
<div class="d-flex align-items-center mb-3 gap-2">
<h4 class="mb-0">Глобальные сервисы</h4>
<span class="badge bg-secondary">{len(global_services)}</span>
</div>
<div class="row row-cols-1 row-cols-md-2 row-cols-xl-3 g-4">
{''.join(cards)}
</div>"""
return page("Глобальные сервисы", body, depth=1)
def service_detail_page(svc_def: dict, hosts: dict, all_deps: list) -> str:
"""Генерирует public/services/{slug}.html — страница конкретного сервиса."""
name = svc_def["name"]
slug = svc_def["slug"]
mermaid_text = service_mermaid(svc_def, all_deps)
if mermaid_text:
graph_html = f"""
<div class="pre-mermaid mb-4 bg-white p-3 rounded shadow-sm">
<pre class="mermaid">{mermaid_text}</pre>
</div>"""
mermaid_script = """<script src="https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.min.js"></script>
<script>mermaid.initialize({{startOnLoad: true, theme: 'default'}});</script>"""
else:
graph_html = '<p class="text-muted">Зависимости между компонентами не обнаружены.</p>'
mermaid_script = ""
comp_rows = []
for c in svc_def["components"]:
hostname = c["host"]
service = c["service"]
role = c.get("role", "")
label = c.get("label", "")
svc_data = hosts.get(hostname, {}).get("services", {}).get(service, {})
state = svc_data.get("state", "")
enabled = svc_data.get("status", "")
if state == "running":
state_badge = '<span class="badge bg-success">running</span>'
elif state:
state_badge = f'<span class="badge bg-secondary">{state}</span>'
else:
state_badge = '<span class="text-muted">—</span>'
if enabled == "enabled":
enabled_badge = '<span class="badge bg-success">enabled</span>'
elif enabled:
enabled_badge = f'<span class="badge bg-secondary">{enabled}</span>'
else:
enabled_badge = '<span class="text-muted">—</span>'
comp_rows.append(
f"<tr>"
f"<td><a href='../hosts/{hostname}.html'>{hostname}</a></td>"
f"<td><code>{service}</code></td>"
f"<td>{label}</td>"
f"<td><small class='text-muted'>{role}</small></td>"
f"<td>{state_badge}</td>"
f"<td>{enabled_badge}</td>"
f"</tr>"
)
desc_html = f'<p class="text-muted mb-4">{svc_def["description"]}</p>' if svc_def["description"] else ""
body = f"""
<nav aria-label="breadcrumb" class="mb-3">
<ol class="breadcrumb">
<li class="breadcrumb-item"><a href="../index.html">Хосты</a></li>
<li class="breadcrumb-item"><a href="./index.html">Сервисы</a></li>
<li class="breadcrumb-item active">{name}</li>
</ol>
</nav>
<h4 class="mb-1">{name}</h4>
{desc_html}
<h5 class="mt-4 mb-3">Граф зависимостей</h5>
{graph_html}
<h5 class="mt-4 mb-2">Компоненты</h5>
<div class="table-responsive">
<table class="table table-hover table-bordered table-sm align-middle bg-white shadow-sm">
<thead class="table-dark">
<tr><th>Хост</th><th>Служба</th><th>Метка</th><th>Роль</th><th>Состояние</th><th>Автозапуск</th></tr>
</thead>
<tbody>{''.join(comp_rows)}</tbody>
</table>
</div>"""
return page(f"{name} — сервис", body, depth=1, extra_scripts=mermaid_script)
# ── Страница-индекс ────────────────────────────────────────────────────────────
def index_page(hosts: dict, static_deps: list, auto_deps: list, ext_deps: list, intra_deps: list, global_services: list | None = None) -> str:
rows = []
for hostname, f in hosts.items():
disk_str, pct = get_disk_root(f)
disk_cell = f"{disk_str}{disk_badge(pct)}" if pct > 0 else disk_str
vcpus = f.get("processor_vcpus", f.get("processor_count", ""))
collected = f.get("date_time", {}).get("iso8601", "")
rows.append(f"""
<tr>
<td><a href="hosts/{hostname}.html" class="fw-semibold text-decoration-none">{hostname}</a></td>
<td><code>{get_ipv4(f)}</code></td>
<td>{os_badge(get_os(f))}</td>
<td><small class="text-muted">{f.get("kernel", "")}</small></td>
<td class="text-end">{vcpus}</td>
<td class="text-end">{get_ram(f)}</td>
<td><small>{disk_cell}</small></td>
<td class="text-end">{get_uptime(f)}</td>
<td><small class="text-muted">{collected}</small></td>
</tr>""")
vmw = load_vmware_facts()
body = f"""
<div class="d-flex align-items-center mb-3 gap-2">
<h4 class="mb-0">Управляемые хосты</h4>
<span class="badge bg-secondary">{len(hosts)}</span>
</div>
<div class="table-responsive">
<table class="table table-hover table-bordered table-sm align-middle bg-white shadow-sm">
<thead class="table-dark">
<tr>
<th>Хост</th>
<th>IP</th>
<th>ОС</th>
<th>Ядро</th>
<th class="text-end">CPU</th>
<th class="text-end">RAM</th>
<th>Диск /</th>
<th class="text-end">Аптайм</th>
<th>Факты собраны</th>
</tr>
</thead>
<tbody>{''.join(rows)}</tbody>
</table>
</div>
{vmware_index_section(vmw)}
{docker_summary_section(hosts)}
{global_services_index_section(global_services or [])}
{deps_index_section(static_deps, auto_deps, ext_deps, intra_deps)}"""
return page("Ansible Hosts — Обзор", body)
# ── Страница хоста ─────────────────────────────────────────────────────────────
def host_page(hostname: str, f: dict, deps: list | None = None, intra_deps: list | None = None, ext_deps: list | None = None) -> str:
# Сетевые интерфейсы
iface_rows = []
for iface in sorted(f.get("interfaces", [])):
data = f.get(iface, {})
ipv4 = data.get("ipv4", {}).get("address", "")
mask = data.get("ipv4", {}).get("netmask", "")
mac = data.get("macaddress", "")
mtu = data.get("mtu", "")
addr_cell = f"{ipv4} / {mask}" if mask else ipv4
iface_rows.append(
f"<tr><td>{iface}</td><td><code>{addr_cell}</code></td>"
f"<td><code>{mac}</code></td><td>{mtu}</td></tr>"
)
# Файловые системы
mount_rows = []
for m in sorted(f.get("mounts", []), key=lambda x: x.get("mount", "")):
total = int(m.get("size_total", 0))
avail = int(m.get("size_available", 0))
if total > 0:
pct = int((total - avail) / total * 100)
color = "danger" if pct > 85 else "warning" if pct > 70 else "success"
bar = (
f'<div class="progress">'
f'<div class="progress-bar bg-{color}" style="width:{pct}%">{pct}%</div>'
f'</div>'
)
used_str = f"{fmt_bytes(total - avail)} / {fmt_bytes(total)}"
else:
bar = ""
used_str = ""
mount_rows.append(
f"<tr><td><code>{m.get('mount', '?')}</code></td>"
f"<td><small>{m.get('device', '')}</small></td>"
f"<td>{m.get('fstype', '')}</td>"
f"<td><small>{used_str}</small></td>"
f'<td style="min-width:110px">{bar}</td></tr>'
)
# CPU-модель (берём первую непустую нецифровую строку из списка процессоров)
procs = f.get("processor", [])
cpu_model = next((p for p in procs if p and not p.strip().isdigit()), "")
vcpus = f.get("processor_vcpus", f.get("processor_count", ""))
pkg_count = len(f.get("packages", {}))
dns = ", ".join(f.get("dns_nameservers", [])) or ""
gateway = f.get("default_ipv4", {}).get("gateway", "")
iface_name = f.get("default_ipv4", {}).get("interface", "")
collected = f.get("date_time", {}).get("iso8601", "")
cards = f"""
<div class="row g-3 mb-4">
<div class="col-lg-4">
<div class="card h-100 shadow-sm">
<div class="card-header bg-dark text-white fw-semibold">Система</div>
<div class="card-body p-0">
<table class="table table-sm table-borderless mb-0">
<tr><th class="ps-3 w-35">ОС</th><td>{os_badge(get_os(f))}</td></tr>
<tr><th class="ps-3">Ядро</th><td><code>{f.get("kernel", "")}</code></td></tr>
<tr><th class="ps-3">Архитектура</th><td>{f.get("architecture", "")}</td></tr>
<tr><th class="ps-3">FQDN</th><td><small>{f.get("fqdn", "")}</small></td></tr>
<tr><th class="ps-3">Аптайм</th><td>{get_uptime(f)}</td></tr>
<tr><th class="ps-3">Время</th><td><small>{collected}</small></td></tr>
</table>
</div>
</div>
</div>
<div class="col-lg-4">
<div class="card h-100 shadow-sm">
<div class="card-header bg-dark text-white fw-semibold">Железо</div>
<div class="card-body p-0">
<table class="table table-sm table-borderless mb-0">
<tr><th class="ps-3 w-35">CPU (vCPU)</th><td>{vcpus}</td></tr>
<tr><th class="ps-3">Модель CPU</th><td><small>{cpu_model}</small></td></tr>
<tr><th class="ps-3">RAM</th><td>{get_ram(f)}</td></tr>
<tr><th class="ps-3">RAM своб.</th><td>{f.get("memfree_mb", "")} МБ</td></tr>
<tr><th class="ps-3">SWAP</th><td>{f.get("swaptotal_mb", 0)} МБ</td></tr>
<tr><th class="ps-3">Пакеты</th><td>{"%d уст." % pkg_count if pkg_count else ""}</td></tr>
</table>
</div>
</div>
</div>
<div class="col-lg-4">
<div class="card h-100 shadow-sm">
<div class="card-header bg-dark text-white fw-semibold">Сеть</div>
<div class="card-body p-0">
<table class="table table-sm table-borderless mb-0">
<tr><th class="ps-3 w-35">IPv4</th><td><code>{get_ipv4(f)}</code></td></tr>
<tr><th class="ps-3">Шлюз</th><td><code>{gateway}</code></td></tr>
<tr><th class="ps-3">Интерфейс</th><td>{iface_name}</td></tr>
<tr><th class="ps-3">DNS</th><td><small>{dns}</small></td></tr>
</table>
</div>
</div>
</div>
</div>"""
ifaces_section = ""
if iface_rows:
ifaces_section = f"""
<h5 class="mt-4 mb-2">Сетевые интерфейсы</h5>
<div class="table-responsive">
<table class="table table-sm table-bordered bg-white shadow-sm">
<thead class="table-secondary">
<tr><th>Интерфейс</th><th>IPv4 / маска</th><th>MAC</th><th>MTU</th></tr>
</thead>
<tbody>{''.join(iface_rows)}</tbody>
</table>
</div>"""
mounts_section = ""
if mount_rows:
mounts_section = f"""
<h5 class="mt-4 mb-2">Файловые системы</h5>
<div class="table-responsive">
<table class="table table-sm table-bordered bg-white shadow-sm">
<thead class="table-secondary">
<tr>
<th>Точка монтирования</th>
<th>Устройство</th>
<th>Тип ФС</th>
<th>Использование</th>
<th style="min-width:110px">Заполнение</th>
</tr>
</thead>
<tbody>{''.join(mount_rows)}</tbody>
</table>
</div>"""
# Открытые порты
ports_section = ""
ports = parse_ports(f.get("ports_raw", ""))
if ports:
proto_badge = {
"tcp": '<span class="badge bg-primary">TCP</span>',
"tcp6": '<span class="badge bg-primary">TCP6</span>',
"udp": '<span class="badge bg-warning text-dark">UDP</span>',
"udp6": '<span class="badge bg-warning text-dark">UDP6</span>',
}
port_rows = "".join(
f"<tr>"
f"<td>{proto_badge.get(p['proto'], p['proto'])}</td>"
f"<td class='text-end'><strong>{p['port']}</strong></td>"
f"<td><code>{p['addr']}</code></td>"
f"<td><small class='text-muted'>{p['state']}</small></td>"
f"<td><small>{p['process']}</small></td>"
f"</tr>"
for p in ports
)
ports_section = f"""
<h5 class="mt-4 mb-2">Открытые порты</h5>
<div class="mb-2">
<input type="text" id="portFilter" class="form-control form-control-sm w-auto d-inline-block"
placeholder="Фильтр по порту или процессу…" oninput="filterTable('portFilter','portsBody')">
</div>
<div class="table-responsive">
<table class="table table-sm table-bordered bg-white shadow-sm">
<thead class="table-secondary">
<tr><th>Протокол</th><th class="text-end">Порт</th><th>Адрес</th><th>Состояние</th><th>Процесс</th></tr>
</thead>
<tbody id="portsBody">{port_rows}</tbody>
</table>
</div>"""
# Службы
services_section = ""
services = f.get("services", {})
if services:
state_order = {"running": 0, "stopped": 1, "failed": 2}
sorted_svcs = sorted(
services.values(),
key=lambda s: (state_order.get(s.get("state", ""), 3), s.get("name", "")),
)
state_badge = {
"running": '<span class="badge bg-success">running</span>',
"stopped": '<span class="badge bg-secondary">stopped</span>',
"failed": '<span class="badge bg-danger">failed</span>',
}
status_badge = {
"enabled": '<span class="badge bg-success bg-opacity-75">enabled</span>',
"disabled": '<span class="badge bg-secondary">disabled</span>',
"static": '<span class="badge bg-info text-dark">static</span>',
"masked": '<span class="badge bg-dark">masked</span>',
}
svc_rows = "".join(
f"<tr>"
f"<td><small>{s.get('name', '')}</small></td>"
f"<td>{state_badge.get(s.get('state', ''), s.get('state', ''))}</td>"
f"<td>{status_badge.get(s.get('status', ''), s.get('status', ''))}</td>"
f"<td><small class='text-muted'>{s.get('source', '')}</small></td>"
f"</tr>"
for s in sorted_svcs
)
running_count = sum(1 for s in services.values() if s.get("state") == "running")
services_section = f"""
<h5 class="mt-4 mb-2">Службы <span class="badge bg-secondary">{len(services)}</span> <span class="badge bg-success">{running_count} running</span></h5>
<div class="mb-2">
<input type="text" id="svcFilter" class="form-control form-control-sm w-auto d-inline-block"
placeholder="Фильтр по названию службы…" oninput="filterTable('svcFilter','svcsBody')">
</div>
<div class="table-responsive" style="max-height:400px;overflow-y:auto">
<table class="table table-sm table-bordered bg-white shadow-sm">
<thead class="table-secondary sticky-top">
<tr><th>Служба</th><th>Состояние</th><th>Автозапуск</th><th>Источник</th></tr>
</thead>
<tbody id="svcsBody">{svc_rows}</tbody>
</table>
</div>"""
# Фаервол
firewall_section = ""
firewall_raw = f.get("firewall_raw", "").strip()
if firewall_raw:
firewall_section = f"""
<h5 class="mt-4 mb-2">Фаервол</h5>
<pre class="bg-white border rounded p-3 small" style="max-height:400px;overflow-y:auto">{firewall_raw}</pre>"""
# Зависимости хоста
host_deps_section = ""
if deps:
dep_rows = []
for d in deps:
from_link = f"<a href='{d['from_host']}.html' class='fw-semibold text-decoration-none'>{d['from_host']}</a>"
to_link = f"<a href='{d['to_host']}.html' class='fw-semibold text-decoration-none'>{d['to_host']}</a>"
from_svc = d.get("from_service") or ""
proto = d.get("protocol", "tcp")
dst_port = f"<span class='badge bg-primary'>{d['port']}/{proto}</span>" if d.get("port") else ""
src_badge = "<span class='badge bg-primary'>статич.</span>" if d["source"] == "static" else "<span class='badge bg-info text-dark'>авто</span>"
desc = d.get("description", "")
dep_rows.append(
f"<tr>"
f"<td>{from_link}</td>"
f"<td><small class='text-muted'>{from_svc}</small></td>"
f"<td>{to_link}</td>"
f"<td>{dst_port}</td>"
f"<td>{src_badge}</td>"
f"<td><small class='text-muted'>{desc}</small></td>"
f"</tr>"
)
col_heads = "<tr><th>Источник</th><th>Служба источника</th><th>Назначение</th><th>Порт назначения</th><th>Тип</th><th>Описание</th></tr>"
dep_table = ""
if dep_rows:
dep_table = f"""
<div class="table-responsive mb-3">
<table class="table table-sm table-bordered bg-white shadow-sm">
<thead class="table-secondary">{col_heads}</thead>
<tbody>{''.join(dep_rows)}</tbody>
</table>
</div>"""
host_mermaid = host_deps_mermaid(hostname, deps, ext_deps)
diagram = ""
if host_mermaid:
diagram = f"""
<div class="bg-white border rounded p-3 shadow-sm mb-3 text-center">
<div class="mermaid">
{host_mermaid}
</div>
</div>"""
if diagram or dep_table:
host_deps_section = f"""
<h5 class="mt-4 mb-2">Зависимости
<a href="../dependencies.html" class="btn btn-sm btn-outline-secondary ms-2">Все →</a>
</h5>
{diagram}
{dep_table}"""
elif ext_deps:
host_mermaid = host_deps_mermaid(hostname, [], ext_deps)
if host_mermaid:
host_deps_section = f"""
<h5 class="mt-4 mb-2">Зависимости
<a href="../dependencies.html" class="btn btn-sm btn-outline-secondary ms-2">Все →</a>
</h5>
<div class="bg-white border rounded p-3 shadow-sm mb-3 text-center">
<div class="mermaid">
{host_mermaid}
</div>
</div>"""
# Внутрихостовые зависимости
host_intra_deps_section = ""
if intra_deps:
intra_rows = []
for d in sorted(intra_deps, key=lambda x: (x["port"], x["client_service"])):
client_svc = d.get("client_service") or ""
server_svc = d.get("server_service") or ""
port_badge = f"<span class='badge bg-primary'>{d['port']}/tcp</span>"
intra_rows.append(
f"<tr>"
f"<td><small class='text-muted'>{client_svc}</small></td>"
f"<td><small class='text-muted'>{server_svc}</small></td>"
f"<td>{port_badge}</td>"
f"<td class='text-end text-muted'><small>{d['count']}</small></td>"
f"</tr>"
)
if intra_rows:
host_intra_deps_section = f"""
<h5 class="mt-4 mb-2">Внутрихостовые зависимости
<span class="badge bg-secondary">{len(intra_rows)}</span>
<a href="../dependencies.html" class="btn btn-sm btn-outline-secondary ms-2">Все →</a>
</h5>
<div class="table-responsive">
<table class="table table-sm table-bordered bg-white shadow-sm">
<thead class="table-secondary">
<tr><th>Клиент (служба)</th><th>Сервер (служба)</th><th>Порт</th><th class="text-end">Соед.</th></tr>
</thead>
<tbody>{''.join(intra_rows)}</tbody>
</table>
</div>"""
# Внешние локальные подключения на странице хоста
host_ext_local_section = ""
host_ext_remote_section = ""
if ext_deps:
local_host_deps = [d for d in ext_deps if d["ext_type"] == "local"]
if local_host_deps:
local_groups: dict = {}
for d in local_host_deps:
key = (d["to_port"], d.get("proto", "tcp"), d["to_service"])
local_groups.setdefault(key, {})
local_groups[key][d["subnet"]] = local_groups[key].get(d["subnet"], 0) + d["count"]
local_rows = []
for (to_port, proto, to_svc), subnets in sorted(local_groups.items()):
svc_cell = f"<small class='text-muted'>{to_svc}</small>" if to_svc != "" else ""
port_badge = f"<span class='badge bg-primary'>{to_port}/{proto}</span>"
total = sum(subnets.values())
subnet_badges = " ".join(
f"<span class='badge bg-warning text-dark me-1'>{sn}</span>"
f"<small class='text-muted me-2'>×{cnt}</small>"
for sn, cnt in sorted(subnets.items())
)
local_rows.append(
f"<tr>"
f"<td>{svc_cell}</td>"
f"<td>{port_badge}</td>"
f"<td>{subnet_badges}</td>"
f"<td class='text-end text-muted'><small>{total}</small></td>"
f"</tr>"
)
if local_rows:
host_ext_local_section = f"""
<h5 class="mt-4 mb-2" style="color:#155724">Внешние локальные подключения
<span class="badge bg-success">{len(local_rows)}</span>
<a href="../dependencies.html" class="btn btn-sm btn-outline-secondary ms-2">Все →</a>
</h5>
<div class="mb-2">
<input type="text" id="hostExtLocalFilter" class="form-control form-control-sm w-auto d-inline-block"
placeholder="Фильтр по службе или подсети…" oninput="filterTable('hostExtLocalFilter','hostExtLocalBody')">
</div>
<div class="table-responsive">
<table class="table table-sm table-bordered bg-white shadow-sm" style="border-color:#28a745">
<thead style="background-color:#d4edda">
<tr><th>Служба</th><th>Порт</th><th>Источники (подсеть × кол-во)</th><th class="text-end">Всего</th></tr>
</thead>
<tbody id="hostExtLocalBody">{''.join(local_rows)}</tbody>
</table>
</div>"""
remote_host_deps = [d for d in ext_deps if d["ext_type"] == "remote"]
if remote_host_deps:
remote_groups: dict = {}
for d in remote_host_deps:
key = (d["to_port"], d.get("proto", "tcp"), d["to_service"])
remote_groups[key] = remote_groups.get(key, 0) + d["count"]
remote_rows = []
for (to_port, proto, to_svc), total in sorted(remote_groups.items()):
svc_cell = f"<small class='text-muted'>{to_svc}</small>" if to_svc != "" else ""
port_badge = f"<span class='badge bg-primary'>{to_port}/{proto}</span>"
remote_rows.append(
f"<tr>"
f"<td>{svc_cell}</td>"
f"<td>{port_badge}</td>"
f"<td class='text-end text-muted'><small>{total}</small></td>"
f"</tr>"
)
if remote_rows:
host_ext_remote_section = f"""
<h5 class="mt-4 mb-2" style="color:#856404">Внешние удалённые подключения
<span class="badge bg-warning text-dark">{len(remote_rows)}</span>
<a href="../dependencies.html" class="btn btn-sm btn-outline-secondary ms-2">Все →</a>
</h5>
<div class="mb-2">
<input type="text" id="hostExtRemoteFilter" class="form-control form-control-sm w-auto d-inline-block"
placeholder="Фильтр по службе или порту…" oninput="filterTable('hostExtRemoteFilter','hostExtRemoteBody')">
</div>
<div class="table-responsive">
<table class="table table-sm table-bordered bg-white shadow-sm" style="border-color:#fd7e14">
<thead style="background-color:#fff3cd">
<tr><th>Служба</th><th>Порт</th><th class="text-end">Всего соединений</th></tr>
</thead>
<tbody id="hostExtRemoteBody">{''.join(remote_rows)}</tbody>
</table>
</div>"""
mermaid_script = ""
if host_deps_section:
mermaid_script = """<script src="https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.min.js"></script>
<script>mermaid.initialize({startOnLoad: true, theme: 'default'});</script>"""
body = f"""
<nav aria-label="breadcrumb" class="mb-3">
<ol class="breadcrumb">
<li class="breadcrumb-item"><a href="../index.html">Хосты</a></li>
<li class="breadcrumb-item active">{hostname}</li>
</ol>
</nav>
<h4 class="mb-3">{hostname}</h4>
{cards}
{ifaces_section}
{mounts_section}
{ports_section}
{services_section}
{firewall_section}
{host_deps_section}
{host_intra_deps_section}
{host_ext_local_section}
{host_ext_remote_section}"""
return page(f"{hostname} — факты", body, depth=1, extra_scripts=mermaid_script)
# ── Точка входа ────────────────────────────────────────────────────────────────
def main() -> None:
if not FACTS_DIR.exists():
sys.exit(f"Ошибка: директория '{FACTS_DIR}' не найдена")
hosts = load_facts(FACTS_DIR)
if not hosts:
sys.exit(f"Ошибка: в '{FACTS_DIR}' нет JSON-файлов")
OUTPUT_DIR.mkdir(exist_ok=True)
(OUTPUT_DIR / "hosts").mkdir(exist_ok=True)
# Зависимости: статические + автоопределённые + внутрихостовые + внешние
static_deps = load_service_map()
ip_to_host = build_ip_to_host(hosts)
auto_deps = detect_auto_deps(hosts, ip_to_host)
intra_deps = detect_intra_deps(hosts)
ext_deps = detect_external_deps(hosts, ip_to_host)
all_deps = static_deps + auto_deps
global_services = load_global_services()
(OUTPUT_DIR / "index.html").write_text(index_page(hosts, static_deps, auto_deps, ext_deps, intra_deps, global_services), encoding="utf-8")
print(f"index.html — {len(hosts)} хостов")
for hostname, facts in hosts.items():
host_deps = [d for d in all_deps if d["from_host"] == hostname or d["to_host"] == hostname]
host_intra = [d for d in intra_deps if d["host"] == hostname]
host_ext = [d for d in ext_deps if d["to_host"] == hostname]
out = OUTPUT_DIR / "hosts" / f"{hostname}.html"
out.write_text(host_page(hostname, facts, host_deps, host_intra, host_ext), encoding="utf-8")
print(f" hosts/{hostname}.html")
vmw = load_vmware_facts()
if vmw["esxi_hosts"] or vmw["vms"] or vmw["datastores"]:
(OUTPUT_DIR / "vmware.html").write_text(vmware_page(vmw), encoding="utf-8")
print(f"vmware.html — {len(vmw['esxi_hosts'])} ESXi, {len(vmw['vms'])} VM, {len(vmw['datastores'])} датасторов")
else:
print("vmware.html — пропущено (нет данных в vmware_facts/)")
containers_exist = any(parse_docker_containers(f) for f in hosts.values())
if containers_exist:
(OUTPUT_DIR / "containers.html").write_text(docker_page(hosts), encoding="utf-8")
total = sum(len(parse_docker_containers(f)) for f in hosts.values())
print(f"containers.html — {total} контейнеров")
else:
print("containers.html — пропущено (нет данных о контейнерах)")
ext_local = sum(1 for d in ext_deps if d["ext_type"] == "local")
ext_remote = sum(1 for d in ext_deps if d["ext_type"] == "remote")
(OUTPUT_DIR / "dependencies.html").write_text(deps_page(static_deps, auto_deps, ext_deps, intra_deps), encoding="utf-8")
print(f"dependencies.html — {len(static_deps)} статических, {len(auto_deps)} авто, {len(intra_deps)} внутрихостовых, {ext_local} внешних локальных, {ext_remote} внешних удалённых")
(OUTPUT_DIR / "external_connections.html").write_text(external_page(ext_deps), encoding="utf-8")
ext_hosts = len({d["to_host"] for d in ext_deps})
ext_svcs = len({(d["to_host"], d["to_service"]) for d in ext_deps})
print(f"external_connections.html — {ext_hosts} хостов, {ext_svcs} служб, {ext_local} локальных, {ext_remote} удалённых")
(OUTPUT_DIR / "services").mkdir(exist_ok=True)
(OUTPUT_DIR / "services" / "index.html").write_text(
services_index_page(global_services, hosts, all_deps), encoding="utf-8"
)
for svc in global_services:
out = OUTPUT_DIR / "services" / f"{svc['slug']}.html"
out.write_text(service_detail_page(svc, hosts, all_deps), encoding="utf-8")
print(f" services/{svc['slug']}.html")
print(f"services/index.html — {len(global_services)} сервисов")
print(f"\nГотово → {OUTPUT_DIR}/")
if __name__ == "__main__":
main()