l2_snapshot.py 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512
  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. import l2db
  7. TOK = 'd3abb125eb301a021ef80a2847a803a7cd2d0b1d3c8337a14d09db9d571369b5'
  8. API = 'http://192.168.87.20:8080/api_jsonrpc.php'
  9. OUT = '/opt/zabbix/zabbix-server/web-modules/WecomCenter/assets/l2topology/snapshot.json'
  10. STP = '/tmp/stp_topology.tsv'
  11. EP = '/tmp/fdb_endpoints.tsv'
  12. PS = '/tmp/port_status.tsv'
  13. LAT = '/tmp/latency.tsv'
  14. POE = '/tmp/poe_switches.tsv'
  15. SEV = ['NC', 'INFO', 'WARN', 'AVG', 'HIGH', 'DISASTER'] # names localized on the frontend
  16. PHYS = {'6', '62', '117'}
  17. def api(m, p):
  18. d = json.dumps({"jsonrpc": "2.0", "method": m, "params": p, "id": 1}).encode()
  19. q = urllib.request.Request(API, data=d, headers={'Content-Type': 'application/json', 'Authorization': 'Bearer ' + TOK})
  20. return json.loads(urllib.request.urlopen(q, timeout=60).read())
  21. def load(path):
  22. with open(path, encoding='utf-8') as f:
  23. return list(csv.DictReader(f, delimiter='\t'))
  24. def norm(s):
  25. return re.sub(r'\s+', ' ', (s or '').strip())
  26. PHYS_TYPES = {'6', '62', '117'}
  27. IFACE_SPEED_RE = re.compile(r'^(?:(100GE|50GE|40GE|25GE|10GE|XGE|2\.5GE|GE|FE))', re.I)
  28. def port_max_mbps(name, current=0):
  29. """Best effort front-panel capability from the interface name.
  30. ``ifSpeed``/``ifHighSpeed`` is the negotiated link rate, not the port's
  31. maximum capability (a 10GE port with a 1G module reports 1000). Huawei
  32. interface names retain the hardware family, so use that prefix; fall back
  33. to the observed rate when the name is not descriptive.
  34. """
  35. s = (name or '').strip().upper().replace(' ', '')
  36. m = IFACE_SPEED_RE.match(s)
  37. if m:
  38. fam = m.group(1).upper()
  39. return {
  40. '100GE': 100000, '50GE': 50000, '40GE': 40000, '25GE': 25000,
  41. '10GE': 10000, 'XGE': 10000, '2.5GE': 2500, 'GE': 1000, 'FE': 100
  42. }.get(fam, current or 0)
  43. if s.startswith('XGIGABITETHERNET'):
  44. return 10000
  45. if s.startswith('GIGABITETHERNET'):
  46. return 1000
  47. if s.startswith('FASTETHERNET'):
  48. return 100
  49. try:
  50. return int(current or 0)
  51. except Exception:
  52. return 0
  53. def is_physical_port(row):
  54. itype = str(row.get('ifType') or '').strip()
  55. if itype not in PHYS_TYPES:
  56. return False
  57. name = (row.get('ifName') or row.get('ifDescr') or '').strip()
  58. if not name:
  59. return False
  60. if re.search(r'(?i)^(ETH-TRUNK|VLANIF|NULL|INLOOPBACK|STACK-PORT|LOOPBACK|TUNNEL|METH|VBDIF)', name):
  61. return False
  62. return True
  63. def load_latency():
  64. lat = {}
  65. try:
  66. with open(LAT, encoding='utf-8') as f:
  67. for line in f:
  68. p = line.rstrip('\n').split('\t')
  69. if len(p) >= 2 and p[0] and p[1]:
  70. try:
  71. lat[p[0]] = float(p[1])
  72. except ValueError:
  73. pass
  74. except Exception:
  75. pass
  76. return lat
  77. def load_poe():
  78. out = {}
  79. try:
  80. for row in load(POE):
  81. ip = (row.get('switch_ip') or '').strip()
  82. if ip:
  83. out[ip] = (row.get('poe') or '0').strip() == '1'
  84. except Exception:
  85. pass
  86. return out
  87. def mysql(sql):
  88. # Credentials are read from the running Zabbix MySQL container itself;
  89. # there is no separate L2 DB account or password file.
  90. return l2db.run_sql(sql)
  91. def write_history(snap, now):
  92. cfg = l2cfg.config()
  93. days = max(1, min(30, int(cfg.get('history_days') or 7)))
  94. interval = max(5, int(cfg.get('history_interval_min') or 5))
  95. interval_sec = interval * 60
  96. raw = json.dumps(snap, ensure_ascii=False, separators=(',', ':')).encode('utf-8')
  97. b64 = base64.b64encode(gzip.compress(raw, 9)).decode('ascii')
  98. st = json.dumps(snap.get('stats', {}), ensure_ascii=False)
  99. cap = datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S')
  100. # One row per configured interval. The guard makes both run_full.sh and
  101. # refresh_alarms.sh safe to call this function: whichever runs first at a
  102. # boundary inserts, the other calls become no-ops. Old rows are always
  103. # pruned, so retention is a hard cap rather than a best effort.
  104. sql = (
  105. "CREATE TABLE IF NOT EXISTS l2_topology_history ("
  106. "id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, ts BIGINT NOT NULL, "
  107. "captured_at DATETIME NOT NULL, stats TEXT, snapshot MEDIUMBLOB, "
  108. "PRIMARY KEY (id), KEY idx_l2_ts (ts)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;\n"
  109. "INSERT INTO l2_topology_history (ts,captured_at,stats,snapshot) "
  110. "SELECT %d,'%s','%s',FROM_BASE64('%s') FROM DUAL "
  111. "WHERE NOT EXISTS (SELECT 1 FROM l2_topology_history WHERE ts > %d);\n"
  112. "DELETE FROM l2_topology_history WHERE ts < %d;\n"
  113. % (now, cap, st, b64, now - interval_sec, now - days * 86400)
  114. )
  115. r = mysql(sql)
  116. if r.returncode != 0:
  117. print('history write failed:', r.stderr.decode('utf-8', 'ignore')[:400])
  118. def main():
  119. now = int(time.time())
  120. cfg = l2cfg.config()
  121. edges_raw = load(STP); eps = load(EP); ps = load(PS)
  122. lat = load_latency()
  123. poe = load_poe()
  124. # Previous snapshot is the switch inventory / last-known-position source.
  125. # Terminals are deliberately NOT compared with it: a terminal is shown
  126. # only if it is present in the current collection.
  127. prev_snap = {}
  128. try:
  129. with open(OUT, encoding='utf-8') as f:
  130. prev_snap = json.load(f)
  131. except Exception:
  132. pass
  133. prev_nodes = {str(n.get('id')): n for n in prev_snap.get('nodes', []) if n.get('id')}
  134. prev_edges = prev_snap.get('edges', []) or []
  135. days = int(cfg.get('history_days') or 7)
  136. cutoff = now - days * 86400
  137. # ---------- port status maps ----------
  138. pmap = {} # (ip, ifName) -> row
  139. for r in ps:
  140. pmap[(r['switch_ip'], r['ifName'])] = r
  141. # Full physical front-panel inventory, including unused ports. This is
  142. # what the topology page uses to draw the switch port matrix when the
  143. # "show terminals" option is enabled.
  144. ports_by_ip = collections.defaultdict(list)
  145. for r in ps:
  146. if not is_physical_port(r):
  147. continue
  148. ip = (r.get('switch_ip') or '').strip()
  149. name = (r.get('ifName') or r.get('ifDescr') or '').strip()
  150. if not ip or not name:
  151. continue
  152. try:
  153. speed = int(float(r.get('speed_mbps') or 0))
  154. except Exception:
  155. speed = 0
  156. try:
  157. order = int(r.get('ifIndex') or 0)
  158. except Exception:
  159. order = 0
  160. ports_by_ip[ip].append({
  161. 'name': name, 'order': order, 'speed': speed,
  162. 'max': port_max_mbps(name, speed),
  163. 'up': (r.get('oper_status') == 'up'),
  164. })
  165. for ip in ports_by_ip:
  166. # Keep one row per interface name; sort by ifIndex so the front-panel
  167. # order is deterministic across collection runs.
  168. uniq = {}
  169. for p in ports_by_ip[ip]:
  170. uniq[p['name']] = p
  171. ports_by_ip[ip] = sorted(uniq.values(), key=lambda p: (p['order'], p['name']))
  172. # ---------- switch identity ----------
  173. sname = {}
  174. for e in edges_raw:
  175. sname[e['down_ip']] = norm(e['down_switch'])
  176. sname[e['up_ip']] = norm(e['up_switch'])
  177. # ---------- undirected graph & depth ----------
  178. adj = collections.defaultdict(set); pair = {}
  179. for e in edges_raw:
  180. a, b = e['down_ip'], e['up_ip']
  181. if a == b: continue
  182. adj[a].add(b); adj[b].add(a)
  183. k = tuple(sorted([a, b]))
  184. pair.setdefault(k, {}).setdefault('ports', {})
  185. pair[k]['ports'].setdefault(a, {})['down'] = e['down_port']
  186. pair[k]['ports'].setdefault(b, {})['up'] = e['up_port']
  187. downcnt = collections.Counter(e['down_ip'] for e in edges_raw)
  188. nodes_ip = set(adj)
  189. # components + true root (fewest times as downstream) -> depth, parent, children
  190. depth = {}; parent = {}; children = collections.defaultdict(list); roots = []
  191. seen = set()
  192. for start in nodes_ip:
  193. if start in seen: continue
  194. comp = []; st = [start]
  195. while st:
  196. u = st.pop()
  197. if u in seen: continue
  198. seen.add(u); comp.append(u); st.extend(adj[u] - seen)
  199. root = min(comp, key=lambda n: (downcnt.get(n, 0), -len(adj[n]), n))
  200. roots.append(root); depth[root] = 0; parent[root] = None
  201. order = [root]; qi = 0
  202. while qi < len(order):
  203. u = order[qi]; qi += 1
  204. for v in sorted(adj[u]):
  205. if v not in depth:
  206. depth[v] = depth[u] + 1; parent[v] = u; children[u].append(v); order.append(v)
  207. def tier(ip):
  208. d = depth.get(ip, 99)
  209. if d == 0: return 'core'
  210. if children.get(ip): return 'distribution'
  211. return 'access'
  212. # ---------- alarms ----------
  213. problems = api('problem.get', {'output': ['eventid', 'objectid', 'name', 'severity', 'clock', 'acknowledged'], 'recent': False})['result']
  214. trigids = sorted(set(p['objectid'] for p in problems))
  215. trig = {}
  216. if trigids:
  217. tg = api('trigger.get', {'triggerids': trigids, 'output': ['triggerid'], 'selectHosts': ['hostid', 'name', 'interfaces']})['result']
  218. for t in tg:
  219. trig[t['triggerid']] = t.get('hosts', [])
  220. ip_alarms = collections.defaultdict(list)
  221. # hosts & availability (need hostid -> ip for alarms)
  222. hs = api('host.get', {'output': ['hostid', 'name'], 'selectInterfaces': ['ip', 'type', 'available', 'error']})['result']
  223. hid_ip = {}; ip_name = {}; hid_by_ip = {}; avail = {}
  224. for h in hs:
  225. cand = [i for i in h['interfaces'] if i.get('ip')]
  226. if not cand: continue
  227. snmp = [i for i in cand if i['type'] == '2']
  228. pick = (snmp or cand)[0]
  229. hid_ip.setdefault(h['hostid'], pick['ip'])
  230. ip_name[pick['ip']] = h['name']; hid_by_ip[pick['ip']] = h['hostid']
  231. for i in snmp:
  232. avail[i['ip']] = i.get('available', '0')
  233. for p in problems:
  234. for h in trig.get(p['objectid'], []):
  235. ip = hid_ip.get(h['hostid'])
  236. if not ip: continue
  237. ip_alarms[ip].append({'sev': int(p['severity']), 'name': p['name'], 'clock': int(p['clock']), 'ack': p['acknowledged'] == '1'})
  238. for ip, av in avail.items():
  239. if av == '2':
  240. ip_alarms[ip].append({'sev': 4, 'name': 'SNMP unreachable / host unavailable', 'clock': now, 'ack': False})
  241. # ---------- switch inventory / hosts ----------
  242. def is_switch_name(name):
  243. n = (name or '').upper()
  244. return bool(re.search(r'-(DIST|ACCESS|CORE)(-|$)', n)) or bool(re.match(r'^COBCO-', n))
  245. switch_hosts = {}
  246. for h in hs:
  247. hn = norm(h.get('name') or '')
  248. if not is_switch_name(hn):
  249. continue
  250. for i in h.get('interfaces', []):
  251. if i.get('type') == '2' and i.get('ip'):
  252. switch_hosts[i['ip']] = {
  253. 'name': hn, 'hostid': h.get('hostid'),
  254. 'available': str(i.get('available', '0')),
  255. 'error': i.get('error', '') or ''
  256. }
  257. # ---------- optional upstream hierarchy ----------
  258. # Firewall -> behavior management -> total core are display-only roles
  259. # above the sub-cores. They are resolved from the configured name/IP
  260. # patterns; an empty role contributes no node and no layout space.
  261. def hier_hit(rule, name, ip):
  262. pattern = str(rule.get('pattern') or '').strip()
  263. if not pattern:
  264. return False
  265. hay = (name or '') + ' ' + (ip or '')
  266. if str(rule.get('match') or 'contains') == 'regex':
  267. try:
  268. return re.search(pattern, hay, re.I) is not None
  269. except re.error:
  270. return False
  271. return pattern.lower() in hay.lower()
  272. upstream = []
  273. upstream_seen = set()
  274. hcfg = cfg.get('core_hierarchy') if isinstance(cfg.get('core_hierarchy'), dict) else {}
  275. for role in ('firewall', 'behavior', 'total_core'):
  276. rule = hcfg.get(role) if isinstance(hcfg.get(role), dict) else {}
  277. if not rule.get('pattern'):
  278. continue
  279. for h in hs:
  280. hn = norm(h.get('name') or '')
  281. cand = [i for i in h.get('interfaces', []) if i.get('ip')]
  282. pick = None
  283. for i in cand:
  284. if hier_hit(rule, hn, str(i.get('ip') or '')):
  285. pick = i
  286. if i.get('type') == '2':
  287. break
  288. if not pick:
  289. continue
  290. ip = str(pick.get('ip') or '')
  291. if not ip or ip in upstream_seen:
  292. continue
  293. upstream_seen.add(ip)
  294. al = ip_alarms.get(ip, [])
  295. upstream.append({
  296. 'id': ip, 'name': hn or ip_name.get(ip, ip), 'ip': ip,
  297. 'tier': 'upstream', 'hierarchy_role': role,
  298. 'hostid': h.get('hostid'), 'oper': 'down' if str(pick.get('available', '0')) == '2' else 'up',
  299. 'offline': str(pick.get('available', '0')) == '2',
  300. 'alarms': sorted(al, key=lambda a: -a['sev']),
  301. 'maxsev': max([a['sev'] for a in al], default=-1),
  302. 'latency_ms': lat.get(ip), 'children': 0, 'ports': [], 'last_online': now
  303. })
  304. # ---------- terminals (current collection only; online only) ----------
  305. # An FDB entry on a port that is not up is stale and is not an "offline
  306. # terminal": the product only displays terminals that are present now.
  307. terminals = []
  308. for t in eps:
  309. mac = (t['mac'] or '').lower()
  310. if not mac: continue
  311. st = pmap.get((t['switch_ip'], t['port']), {})
  312. oper = st.get('oper_status', '')
  313. try: mc = int(st.get('mac_count', '0') or 0)
  314. except: mc = 0
  315. if oper != 'up' or mc <= 0:
  316. continue
  317. terminals.append({'mac': mac, 'ip': t['ip'], 'name': t['name'], 'sw_ip': t['switch_ip'],
  318. 'sw_name': norm(t['switch_name']), 'port': t['port'], 'alias': t.get('port_alias', ''),
  319. 'speed': st.get('speed_mbps', ''), 'online': True, 'last_seen': now})
  320. # terminal counts per switch (online only)
  321. tcnt = collections.defaultdict(lambda: {'online': 0, 'offline': 0})
  322. for t in terminals:
  323. tcnt[t['sw_ip']]['online'] += 1
  324. # ---------- switch inventory: online topology + recorded offline ----------
  325. offline_ips = set()
  326. # Hosts whose SNMP interface is unavailable are offline even if they have no
  327. # current STP edge. This is how a failed switch stays on the map.
  328. for ip, hn in switch_hosts.items():
  329. if hn['available'] == '2':
  330. offline_ips.add(ip)
  331. # Keep a previously recorded switch for history_days when it disappears
  332. # from the protocol-derived graph. The last_online stamp prevents old
  333. # decommissioned devices from living forever.
  334. for ip, pn in prev_nodes.items():
  335. if ip in nodes_ip:
  336. continue
  337. last_online = int(pn.get('last_online') or pn.get('last_seen') or prev_snap.get('generated') or 0)
  338. if last_online >= cutoff:
  339. offline_ips.add(ip)
  340. known_ips = set(nodes_ip) | offline_ips
  341. def name_role(name):
  342. n = (name or '').upper()
  343. if re.search(r'-(DIST)(-|$)', n): return 'distribution'
  344. if re.search(r'-(ACCESS)(-|$)', n): return 'access'
  345. if re.search(r'-(CORE)(-|$)', n): return 'core'
  346. return ''
  347. def node_tier(ip, name, pn=None):
  348. pn = pn or {}
  349. ch = int(pn.get('children') or 0)
  350. pt = str(pn.get('tier') or '').lower()
  351. # Graph degree is the protocol truth: a switch with children is an
  352. # aggregation/distribution point, regardless of a wrong name suffix.
  353. if ch > 0:
  354. return 'distribution'
  355. if pt in ('distribution', 'access'):
  356. return pt
  357. nr = name_role(name)
  358. if nr:
  359. return nr
  360. if pt == 'core': return 'core'
  361. return 'access'
  362. nodes = []
  363. for ip in sorted(known_ips, key=lambda x: (depth.get(x, 99), x)):
  364. online = ip in nodes_ip
  365. pn = prev_nodes.get(ip, {})
  366. hn = switch_hosts.get(ip, {})
  367. nm = sname.get(ip) or pn.get('name') or hn.get('name') or ip_name.get(ip) or ip
  368. al = ip_alarms.get(ip, [])
  369. maxsev = max([a['sev'] for a in al], default=-1)
  370. if online:
  371. ntier = tier(ip)
  372. ndepth = depth.get(ip, 99)
  373. nchildren = len(children.get(ip, []))
  374. oper = 'down' if avail.get(ip) == '2' else 'up'
  375. last_online = now
  376. else:
  377. ntier = node_tier(ip, nm, pn)
  378. ndepth = int(pn.get('depth', 99))
  379. nchildren = int(pn.get('children') or 0)
  380. oper = 'down'
  381. last_online = int(pn.get('last_online') or pn.get('last_seen') or prev_snap.get('generated') or now)
  382. nodes.append({
  383. 'id': ip, 'name': nm, 'ip': ip, 'tier': ntier, 'depth': ndepth,
  384. 'hostid': hid_by_ip.get(ip) or hn.get('hostid') or pn.get('hostid'),
  385. 'oper': oper, 'offline': not online,
  386. 'alarms': sorted(al, key=lambda a: -a['sev']), 'maxsev': maxsev,
  387. 'term_online': tcnt[ip]['online'], 'term_offline': 0,
  388. 'latency_ms': lat.get(ip) if online else pn.get('latency_ms'),
  389. 'children': nchildren, 'poe': bool(poe.get(ip, pn.get('poe', False))),
  390. 'ports': ports_by_ip.get(ip, []),
  391. 'last_online': last_online
  392. })
  393. # A configured upstream host that is also a protocol switch gets a role
  394. # marker instead of a duplicate node.
  395. upstream_by_ip = {u['id']: u for u in upstream}
  396. for n in nodes:
  397. u = upstream_by_ip.get(n['id'])
  398. if u:
  399. n['hierarchy_role'] = u['hierarchy_role']
  400. upstream = [u for u in upstream if u['id'] not in known_ips]
  401. # ---------- edges ----------
  402. edges = []
  403. edge_pairs = set()
  404. for k, d in pair.items():
  405. a, b = k
  406. p = d['ports']
  407. pa = p.get(a, {}); pb = p.get(b, {})
  408. a_port = pa.get('down') or pa.get('up') or ''
  409. b_port = pb.get('up') or pb.get('down') or ''
  410. speeds = []
  411. for ip, port in ((a, a_port), (b, b_port)):
  412. row = pmap.get((ip, port), {})
  413. try: sp = int(row.get('speed_mbps', '0') or 0)
  414. except: sp = 0
  415. speeds.append(sp)
  416. speed = max(speeds) if speeds else 0
  417. ops = []
  418. for ip, port in ((a, a_port), (b, b_port)):
  419. row = pmap.get((ip, port))
  420. ops.append(row.get('oper_status') if row else None)
  421. state = 'down' if ('down' in ops) else 'up'
  422. if a not in known_ips or b not in known_ips:
  423. continue
  424. edges.append({'source': a, 'target': b, 'speed': speed, 'state': state,
  425. 'a_port': a_port, 'b_port': b_port})
  426. edge_pairs.add(tuple(sorted((a, b))))
  427. # Keep the last-known uplink for an offline switch so it is shown in its
  428. # workshop frame and its historical position is not lost.
  429. for e in prev_edges:
  430. a, b = str(e.get('source')), str(e.get('target'))
  431. k = tuple(sorted((a, b)))
  432. if k in edge_pairs or a not in known_ips or b not in known_ips:
  433. continue
  434. if a in nodes_ip and b in nodes_ip:
  435. continue
  436. edges.append({'source': a, 'target': b, 'speed': e.get('speed', 0), 'state': 'down',
  437. 'a_port': e.get('a_port', ''), 'b_port': e.get('b_port', '')})
  438. edge_pairs.add(k)
  439. stats = {
  440. 'switches': len(nodes), 'switches_online': len(nodes_ip), 'switches_offline': len(offline_ips),
  441. 'poe_switches': sum(1 for n in nodes if n.get('poe')),
  442. 'ports': sum(len(n.get('ports') or []) for n in nodes),
  443. 'edges': len(edges),
  444. 'term_total': len(terminals), 'term_online': len(terminals), 'term_offline': 0,
  445. 'alarms': sum(len(n['alarms']) for n in nodes) + sum(len(n['alarms']) for n in upstream),
  446. 'devices_alarm': sum(1 for n in nodes if n['alarms']) + sum(1 for n in upstream if n['alarms']),
  447. 'upstream': len(upstream),
  448. 'maxsev': max([n['maxsev'] for n in nodes] + [n['maxsev'] for n in upstream], default=-1)
  449. }
  450. in_nodes = set(known_ips)
  451. other = []
  452. for ip, al in ip_alarms.items():
  453. if ip in in_nodes:
  454. continue
  455. for a in al:
  456. other.append({'ip': ip, 'host': ip_name.get(ip, ip), 'sev': a['sev'], 'name': a['name'], 'clock': a['clock']})
  457. other.sort(key=lambda x: -x['sev'])
  458. stats['other_alarms'] = len(other)
  459. snap = {'generated': now, 'generated_iso': datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S'),
  460. 'refresh_default': 300, 'roots': roots, 'stats': stats,
  461. 'other_alarms': other,
  462. 'nodes': nodes, 'edges': edges, 'terminals': terminals}
  463. if upstream:
  464. snap['upstream'] = upstream
  465. os.makedirs(os.path.dirname(OUT), exist_ok=True)
  466. tmp = OUT + '.tmp'
  467. with open(tmp, 'w', encoding='utf-8') as f:
  468. json.dump(snap, f, ensure_ascii=False, separators=(',', ':'))
  469. os.replace(tmp, OUT)
  470. print('snapshot', OUT, os.path.getsize(OUT), 'bytes;', stats)
  471. if os.environ.get('L2_WRITE_HISTORY', '0') == '1':
  472. try:
  473. write_history(snap, now)
  474. except Exception as e:
  475. print('history write error:', e)
  476. if __name__ == '__main__':
  477. main()