l2_snapshot.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266
  1. #!/usr/bin/env python3
  2. # -*- coding: utf-8 -*-
  3. """Merge collectors' TSVs + Zabbix alarms -> snapshot.json for the L2 topology page."""
  4. import csv, json, os, re, collections, time, datetime, urllib.request, gzip, base64, subprocess
  5. import l2cfg
  6. TOK = 'd3abb125eb301a021ef80a2847a803a7cd2d0b1d3c8337a14d09db9d571369b5'
  7. API = 'http://192.168.87.20:8080/api_jsonrpc.php'
  8. OUT = '/opt/zabbix/zabbix-server/web-modules/WecomCenter/assets/l2topology/snapshot.json'
  9. STP = '/tmp/stp_topology.tsv'
  10. EP = '/tmp/fdb_endpoints.tsv'
  11. PS = '/tmp/port_status.tsv'
  12. LAT = '/tmp/latency.tsv'
  13. MYSQL_CONTAINER = os.environ.get('L2_MYSQL_CONTAINER', 'zabbix-mysql')
  14. MYSQL_USER = os.environ.get('L2_MYSQL_USER', 'root')
  15. MYSQL_PASS = os.environ.get('L2_MYSQL_PASS', 'CobcoIT132')
  16. MYSQL_DB = os.environ.get('L2_MYSQL_DB', 'zabbix')
  17. SEV = ['NC', 'INFO', 'WARN', 'AVG', 'HIGH', 'DISASTER'] # names localized on the frontend
  18. PHYS = {'6', '62', '117'}
  19. def api(m, p):
  20. d = json.dumps({"jsonrpc": "2.0", "method": m, "params": p, "id": 1}).encode()
  21. q = urllib.request.Request(API, data=d, headers={'Content-Type': 'application/json', 'Authorization': 'Bearer ' + TOK})
  22. return json.loads(urllib.request.urlopen(q, timeout=60).read())
  23. def load(path):
  24. with open(path, encoding='utf-8') as f:
  25. return list(csv.DictReader(f, delimiter='\t'))
  26. def norm(s):
  27. return re.sub(r'\s+', ' ', (s or '').strip())
  28. def load_latency():
  29. lat = {}
  30. try:
  31. with open(LAT, encoding='utf-8') as f:
  32. for line in f:
  33. p = line.rstrip('\n').split('\t')
  34. if len(p) >= 2 and p[0] and p[1]:
  35. try:
  36. lat[p[0]] = float(p[1])
  37. except ValueError:
  38. pass
  39. except Exception:
  40. pass
  41. return lat
  42. def mysql(sql):
  43. return subprocess.run(
  44. ['docker', 'exec', '-i', MYSQL_CONTAINER, 'mysql',
  45. '-u' + MYSQL_USER, '-p' + MYSQL_PASS, MYSQL_DB,
  46. '--default-character-set=utf8mb4'],
  47. input=sql.encode('utf-8'), capture_output=True, timeout=120)
  48. def write_history(snap, now):
  49. cfg = l2cfg.config()
  50. days = int(cfg.get('history_days') or 7)
  51. raw = json.dumps(snap, ensure_ascii=False, separators=(',', ':')).encode('utf-8')
  52. b64 = base64.b64encode(gzip.compress(raw, 9)).decode('ascii')
  53. st = json.dumps(snap.get('stats', {}), ensure_ascii=False)
  54. cap = datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S')
  55. sql = (
  56. "CREATE TABLE IF NOT EXISTS l2_topology_history ("
  57. "id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, ts BIGINT NOT NULL, "
  58. "captured_at DATETIME NOT NULL, stats TEXT, snapshot MEDIUMBLOB, "
  59. "PRIMARY KEY (id), KEY idx_l2_ts (ts)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;\n"
  60. "INSERT INTO l2_topology_history (ts,captured_at,stats,snapshot) VALUES "
  61. "(%d,'%s','%s',FROM_BASE64('%s'));\n"
  62. "DELETE FROM l2_topology_history WHERE ts < %d;\n"
  63. % (now, cap, st, b64, now - days * 86400)
  64. )
  65. r = mysql(sql)
  66. if r.returncode != 0:
  67. print('history write failed:', r.stderr.decode('utf-8', 'ignore')[:400])
  68. def main():
  69. now = int(time.time())
  70. edges_raw = load(STP); eps = load(EP); ps = load(PS)
  71. lat = load_latency()
  72. # ---------- port status maps ----------
  73. pmap = {} # (ip, ifName) -> row
  74. for r in ps:
  75. pmap[(r['switch_ip'], r['ifName'])] = r
  76. # ---------- switch identity ----------
  77. sname = {}
  78. for e in edges_raw:
  79. sname[e['down_ip']] = norm(e['down_switch'])
  80. sname[e['up_ip']] = norm(e['up_switch'])
  81. # ---------- undirected graph & depth ----------
  82. adj = collections.defaultdict(set); pair = {}
  83. for e in edges_raw:
  84. a, b = e['down_ip'], e['up_ip']
  85. if a == b: continue
  86. adj[a].add(b); adj[b].add(a)
  87. k = tuple(sorted([a, b]))
  88. pair.setdefault(k, {}).setdefault('ports', {})
  89. pair[k]['ports'].setdefault(a, {})['down'] = e['down_port']
  90. pair[k]['ports'].setdefault(b, {})['up'] = e['up_port']
  91. downcnt = collections.Counter(e['down_ip'] for e in edges_raw)
  92. nodes_ip = set(adj)
  93. # components + true root (fewest times as downstream) -> depth, parent, children
  94. depth = {}; parent = {}; children = collections.defaultdict(list); roots = []
  95. seen = set()
  96. for start in nodes_ip:
  97. if start in seen: continue
  98. comp = []; st = [start]
  99. while st:
  100. u = st.pop()
  101. if u in seen: continue
  102. seen.add(u); comp.append(u); st.extend(adj[u] - seen)
  103. root = min(comp, key=lambda n: (downcnt.get(n, 0), -len(adj[n]), n))
  104. roots.append(root); depth[root] = 0; parent[root] = None
  105. order = [root]; qi = 0
  106. while qi < len(order):
  107. u = order[qi]; qi += 1
  108. for v in sorted(adj[u]):
  109. if v not in depth:
  110. depth[v] = depth[u] + 1; parent[v] = u; children[u].append(v); order.append(v)
  111. def tier(ip):
  112. d = depth.get(ip, 99)
  113. if d == 0: return 'core'
  114. if children.get(ip): return 'distribution'
  115. return 'access'
  116. # ---------- alarms ----------
  117. problems = api('problem.get', {'output': ['eventid', 'objectid', 'name', 'severity', 'clock', 'acknowledged'], 'recent': False})['result']
  118. trigids = sorted(set(p['objectid'] for p in problems))
  119. trig = {}
  120. if trigids:
  121. tg = api('trigger.get', {'triggerids': trigids, 'output': ['triggerid'], 'selectHosts': ['hostid', 'name', 'interfaces']})['result']
  122. for t in tg:
  123. trig[t['triggerid']] = t.get('hosts', [])
  124. ip_alarms = collections.defaultdict(list)
  125. # hosts & availability (need hostid -> ip for alarms)
  126. hs = api('host.get', {'output': ['hostid', 'name'], 'selectInterfaces': ['ip', 'type', 'available', 'error']})['result']
  127. hid_ip = {}; ip_name = {}; hid_by_ip = {}; avail = {}
  128. for h in hs:
  129. cand = [i for i in h['interfaces'] if i.get('ip')]
  130. if not cand: continue
  131. snmp = [i for i in cand if i['type'] == '2']
  132. pick = (snmp or cand)[0]
  133. hid_ip.setdefault(h['hostid'], pick['ip'])
  134. ip_name[pick['ip']] = h['name']; hid_by_ip[pick['ip']] = h['hostid']
  135. for i in snmp:
  136. avail[i['ip']] = i.get('available', '0')
  137. for p in problems:
  138. for h in trig.get(p['objectid'], []):
  139. ip = hid_ip.get(h['hostid'])
  140. if not ip: continue
  141. ip_alarms[ip].append({'sev': int(p['severity']), 'name': p['name'], 'clock': int(p['clock']), 'ack': p['acknowledged'] == '1'})
  142. for ip, av in avail.items():
  143. if av == '2':
  144. ip_alarms[ip].append({'sev': 4, 'name': 'SNMP unreachable / host unavailable', 'clock': now, 'ack': False})
  145. # ---------- terminals (with history for offline detection) ----------
  146. prev_term = {}
  147. try:
  148. prev = json.load(open(OUT, encoding='utf-8'))
  149. for t in prev.get('terminals', []):
  150. prev_term[t['mac']] = t
  151. except Exception:
  152. pass
  153. seen_mac = set(); terminals = []
  154. for t in eps:
  155. mac = (t['mac'] or '').lower()
  156. if not mac: continue
  157. seen_mac.add(mac)
  158. st = pmap.get((t['switch_ip'], t['port']), {})
  159. oper = st.get('oper_status', '')
  160. try: mc = int(st.get('mac_count', '0') or 0)
  161. except: mc = 0
  162. online = (oper == 'up' and mc > 0)
  163. terminals.append({'mac': mac, 'ip': t['ip'], 'name': t['name'], 'sw_ip': t['switch_ip'],
  164. 'sw_name': norm(t['switch_name']), 'port': t['port'], 'alias': t.get('port_alias', ''),
  165. 'speed': st.get('speed_mbps', ''), 'online': online,
  166. 'last_seen': now if online else (prev_term.get(mac, {}).get('last_seen') or None)})
  167. # carry previously-known but now-absent terminals as offline
  168. for mac, pt in prev_term.items():
  169. if mac not in seen_mac:
  170. nt = dict(pt); nt['online'] = False
  171. terminals.append(nt)
  172. # terminal counts per switch
  173. tcnt = collections.defaultdict(lambda: {'online': 0, 'offline': 0})
  174. for t in terminals:
  175. c = tcnt[t['sw_ip']]
  176. c['online' if t['online'] else 'offline'] += 1
  177. # ---------- nodes ----------
  178. nodes = []
  179. for ip in sorted(nodes_ip, key=lambda x: (depth.get(x, 99), x)):
  180. al = ip_alarms.get(ip, [])
  181. maxsev = max([a['sev'] for a in al], default=-1)
  182. nodes.append({
  183. 'id': ip, 'name': sname.get(ip, ip), 'ip': ip, 'tier': tier(ip), 'depth': depth.get(ip, 99),
  184. 'hostid': hid_by_ip.get(ip),
  185. 'oper': 'down' if avail.get(ip) == '2' else 'up',
  186. 'alarms': sorted(al, key=lambda a: -a['sev']), 'maxsev': maxsev,
  187. 'term_online': tcnt[ip]['online'], 'term_offline': tcnt[ip]['offline'],
  188. 'latency_ms': lat.get(ip),
  189. 'children': len(children.get(ip, []))
  190. })
  191. # ---------- edges ----------
  192. edges = []
  193. for k, d in pair.items():
  194. a, b = k
  195. p = d['ports']
  196. pa = p.get(a, {}); pb = p.get(b, {})
  197. a_port = pa.get('down') or pa.get('up') or ''
  198. b_port = pb.get('up') or pb.get('down') or ''
  199. speeds = []
  200. for ip, port in ((a, a_port), (b, b_port)):
  201. row = pmap.get((ip, port), {})
  202. try: sp = int(row.get('speed_mbps', '0') or 0)
  203. except: sp = 0
  204. speeds.append(sp)
  205. speed = max(speeds) if speeds else 0
  206. ops = []
  207. for ip, port in ((a, a_port), (b, b_port)):
  208. row = pmap.get((ip, port))
  209. ops.append(row.get('oper_status') if row else None)
  210. state = 'down' if ('down' in ops) else 'up'
  211. edges.append({'source': a, 'target': b, 'speed': speed, 'state': state,
  212. 'a_port': a_port, 'b_port': b_port})
  213. stats = {
  214. 'switches': len(nodes), 'edges': len(edges),
  215. 'term_total': len(terminals), 'term_online': sum(1 for t in terminals if t['online']),
  216. 'term_offline': sum(1 for t in terminals if not t['online']),
  217. 'alarms': sum(len(n['alarms']) for n in nodes),
  218. 'devices_alarm': sum(1 for n in nodes if n['alarms']),
  219. 'maxsev': max([n['maxsev'] for n in nodes], default=-1)
  220. }
  221. in_nodes = set(nodes_ip)
  222. other = []
  223. for ip, al in ip_alarms.items():
  224. if ip in in_nodes:
  225. continue
  226. for a in al:
  227. other.append({'ip': ip, 'host': ip_name.get(ip, ip), 'sev': a['sev'], 'name': a['name'], 'clock': a['clock']})
  228. other.sort(key=lambda x: -x['sev'])
  229. stats['other_alarms'] = len(other)
  230. snap = {'generated': now, 'generated_iso': datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S'),
  231. 'refresh_default': 300, 'roots': roots, 'stats': stats,
  232. 'other_alarms': other,
  233. 'nodes': nodes, 'edges': edges, 'terminals': terminals}
  234. os.makedirs(os.path.dirname(OUT), exist_ok=True)
  235. tmp = OUT + '.tmp'
  236. with open(tmp, 'w', encoding='utf-8') as f:
  237. json.dump(snap, f, ensure_ascii=False, separators=(',', ':'))
  238. os.replace(tmp, OUT)
  239. print('snapshot', OUT, os.path.getsize(OUT), 'bytes;', stats)
  240. if os.environ.get('L2_WRITE_HISTORY', '0') == '1':
  241. try:
  242. write_history(snap, now)
  243. except Exception as e:
  244. print('history write error:', e)
  245. if __name__ == '__main__':
  246. main()