2493 lines
111 KiB
Python
2493 lines
111 KiB
Python
#!/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">■ Локальные сети
|
||
<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">■ Публичный интернет
|
||
<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()
|