#!/usr/bin/env python3
"""Генерация HTML-отчёта по Ansible-фактам хостов для GitLab Pages.
Использование:
python3 scripts/generate_report.py
Ожидает JSON-файлы в host_facts/.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'{os_str}'
def disk_badge(pct: int) -> str:
color = "danger" if pct > 85 else "warning" if pct > 70 else "success"
return f'{pct}%'
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"""
{title}
{body}
{extra_scripts}
"""
# ── 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"""
"""
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"
"
)
containers_section = ""
if rows:
containers_section = f"""
Контейнеры {len(rows)}
Имя контейнера
Хост
Образ
Порты
Volumes
{''.join(rows)}
"""
body = f"""
Docker-контейнеры
{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"""
Зависимости между службами
{total}{len(static_deps)} статических{len(auto_deps)} авто{len(intra_deps)} внутрихостовых{ext_local} внешних локальных{ext_remote} внешних удалённыхЗависимости →Внешние подключения →
"""
def global_services_index_section(global_services: list) -> str:
"""Рендерит краткую сводку глобальных сервисов на главной странице."""
if not global_services:
return ""
links = "".join(
f'{s["name"]}'
for s in global_services
)
return f"""
"""
def external_page(ext_deps: list) -> str:
"""Генерирует HTML-страницу external_connections.html.
Структура: хост → служба → IP-адреса источников без группировки по подсетям.
Локальные подключения — зелёная таблица, удалённые — оранжевая.
"""
if not ext_deps:
body = """
Внешние подключения
Данные об внешних подключениях отсутствуют.
"""
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"{port}/{proto}"
if historical:
ls_short = (last_seen or "")[:10]
rows.append(
f"
"
f"
{port_badge}
"
f"
{ip}
"
f"
last seen: {ls_short}
"
f"
"
)
elif ct_state == "TIME_WAIT":
rows.append(
f"
"
f"
{port_badge}
"
f"
{ip}
"
f"
(TIME_WAIT)
"
f"
"
)
else:
rows.append(
f"
"
f"
{port_badge}
"
f"
{ip}
"
f"
{count}
"
f"
"
)
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' {hist_local} истор.' if hist_local else ""
local_block = f"""
■ Локальные сети
{active_local}{hist_badge_l}
Порт
IP-адрес источника
Соед.
{''.join(rows)}
"""
# Таблица удалённых подключений — индивидуальные 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"
""")
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' {local_ips} лок. IP'
if remote_ips:
badges += f' {remote_ips} удал. IP'
host_cards.append(f"""
""")
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"""
Внешние подключения
{total_hosts} хостов{total_svcs} служб{local_ips} локальных IP{remote_ips} удалённых IP
"""
# ── Статические зависимости ───────────────────────────────────────────────
static_rows = []
for d in static_deps:
from_cell = (
f"{d['from_host']}"
+ (f" {d['from_service']}" if d["from_service"] else "")
)
to_cell = (
f"{d['to_host']}"
+ (f" {d['to_service']}" if d["to_service"] else "")
)
port_cell = (
f"{d['port']}/{d['protocol']}"
if d["port"] else "—"
)
static_rows.append(
f"
"
f"
{from_cell}
"
f"
{to_cell}
"
f"
{port_cell}
"
f"
{d['description'] or '—'}
"
f"
"
)
static_section = ""
if static_rows:
static_section = f"""
Статические зависимости
{len(static_rows)}
из service_map.yml
От (хост / служба)
До (хост / служба)
Порт
Описание
{''.join(static_rows)}
"""
# ── Автоопределённые зависимости ─────────────────────────────────────────
auto_rows = []
for d in sorted(auto_deps, key=lambda x: (x["port"], x["to_host"], x["from_host"])):
from_link = f"{d['from_host']}"
to_link = f"{d['to_host']}"
from_svc = d.get("from_service") or "—"
to_svc = d.get("to_service") or "—"
port_badge = f"{d['port']}/tcp"
svc_matched = from_svc != "—" and to_svc != "—"
match_badge = (
"✓"
if svc_matched else ""
)
auto_rows.append(
f"
"
f"
{from_link} {from_svc}
"
f"
{to_link} {to_svc}
"
f"
{port_badge}{match_badge}
"
f"
{d['count']}
"
f"
"
)
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"""
Автоопределённые зависимости
{len(auto_rows)}{matched} сопоставлено
по активным TCP-соединениям из ss
Источник (хост / служба)
Назначение (хост / служба)
Порт
Соед.
{''.join(auto_rows)}
"""
if not static_section and not auto_section:
auto_section = '
Зависимости не обнаружены. Соберите факты хостов и/или заполните service_map.yml.
'
# ── Внутрихостовые зависимости ────────────────────────────────────────────
intra_rows = []
for d in sorted(intra_deps, key=lambda x: (x["host"], x["port"], x["client_service"])):
host_link = f"{d['host']}"
client_svc = d.get("client_service") or "—"
server_svc = d.get("server_service") or "—"
port_badge = f"{d['port']}/tcp"
svc_matched = client_svc != "—" and server_svc != "—"
match_badge = (
"✓"
if svc_matched else ""
)
intra_rows.append(
f"
"
f"
{host_link}
"
f"
{client_svc}
"
f"
{server_svc}
"
f"
{port_badge}{match_badge}
"
f"
{d['count']}
"
f"
"
)
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"""
Внутрихостовые зависимости
{len(intra_rows)}{matched_intra} сопоставлено
loopback TCP-соединения внутри хоста
Хост
Клиент (служба)
Сервер (служба)
Порт
Соед.
{''.join(intra_rows)}
"""
# ── Внешние локальные подключения ─────────────────────────────────────────
# Показываем индивидуальные 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"{d['to_host']}"
svc_cell = f"{d['to_service']}" if d["to_service"] != "—" else "—"
port_badge = f"{d['to_port']}/{proto}"
subnet_badge = f"{d['subnet']}"
if historical:
last_seen = (d.get("last_seen") or "")[:10]
ip_cell = f"{d['ip']}"
status = f"last seen: {last_seen}"
row_class = " class='text-muted'"
elif ct_state == "TIME_WAIT":
ip_cell = f"{d['ip']}"
status = f"(TIME_WAIT)"
row_class = ""
else:
ip_cell = f"{d['ip']}"
status = f"×{d['count']}" if d["count"] > 1 else ""
row_class = ""
ext_local_rows.append(
f"
"
f"
{to_link}
"
f"
{svc_cell}
"
f"
{port_badge}
"
f"
{ip_cell} {subnet_badge}
"
f"
{status}
"
f"
"
)
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' {hist_count} истор.' if hist_count else ""
ext_local_section = f"""
Внешние локальные подключения
{active_count} активных{hist_badge}
источник — локальные сети, индивидуальные IP из ss/conntrack и истории
Сервер
Служба
Порт
IP / Подсеть
Статус / Соед.
{''.join(ext_local_rows)}
"""
# ── Внешние удалённые подключения ─────────────────────────────────────────
# Группировка: (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"{to_host}"
svc_cell = f"{to_svc}" if to_svc != "—" else "—"
port_badge = f"{to_port}/{proto}"
ext_remote_rows.append(
f"
"
f"
{to_link}
"
f"
{svc_cell}
"
f"
{port_badge}
"
f"
{total}
"
f"
"
)
ext_remote_section = ""
if ext_remote_rows:
ext_remote_section = f"""
Внешние удалённые подключения
{len(ext_remote_rows)}
источник — публичный интернет, суммарное количество
"
)
esxi_section = ""
if esxi_rows:
esxi_section = f"""
ESXi-гипервизоры {len(esxi_rows)}
Хост
CPU
RAM
Модель
Версия ESXi
{''.join(esxi_rows)}
"""
# ── Виртуальные машины ────────────────────────────────────────────────────
vm_rows = []
for vm in vmw["vms"]:
name = vm.get("guest_name", "—")
power = vm.get("power_state", "—")
power_badge = (
'poweredOn' if power == "poweredOn"
else 'poweredOff' if power == "poweredOff"
else f'{power}'
)
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"
"
f"
{name}
"
f"
{power_badge}
"
f"
{esxi_host}
"
f"
{ip}
"
f"
{cpu}
"
f"
{mem_str}
"
f"
{os_guest}
"
f"
"
)
vms_section = ""
if vm_rows:
powered_on = sum(1 for v in vmw["vms"] if v.get("power_state") == "poweredOn")
vms_section = f"""
Виртуальные машины
{len(vm_rows)}{powered_on} вкл.
Имя VM
Состояние
Гипервизор
IP
CPU
RAM
ОС
{''.join(vm_rows)}
"""
# ── Датасторы ─────────────────────────────────────────────────────────────
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'
{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"
{iface}
{addr_cell}
"
f"
{mac}
{mtu}
"
)
# Файловые системы
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'
"""
# Внутрихостовые зависимости
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"{d['port']}/tcp"
intra_rows.append(
f"
"""
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"{to_svc}" if to_svc != "—" else "—"
port_badge = f"{to_port}/{proto}"
remote_rows.append(
f"
"
f"
{svc_cell}
"
f"
{port_badge}
"
f"
{total}
"
f"
"
)
if remote_rows:
host_ext_remote_section = f"""