Parcourir la source

L2 topology: offline switches, graph-aware roles, history panel

clm0081 il y a 3 jours
Parent
commit
4a04c68277
39 fichiers modifiés avec 581 ajouts et 177 suppressions
  1. 127 33
      collector/l2_snapshot.py
  2. 94 35
      web-modules/WecomCenter/assets/l2topology/app.js
  3. 2 0
      web-modules/WecomCenter/assets/l2topology/style.css
  4. 10 3
      web-modules/WecomCenter/locale/bg_BG.php
  5. 10 3
      web-modules/WecomCenter/locale/ca_ES.php
  6. 10 3
      web-modules/WecomCenter/locale/cs_CZ.php
  7. 10 3
      web-modules/WecomCenter/locale/da_DK.php
  8. 10 3
      web-modules/WecomCenter/locale/de_DE.php
  9. 10 3
      web-modules/WecomCenter/locale/el_GR.php
  10. 10 3
      web-modules/WecomCenter/locale/en.php
  11. 10 3
      web-modules/WecomCenter/locale/en_GB.php
  12. 10 3
      web-modules/WecomCenter/locale/es_ES.php
  13. 10 3
      web-modules/WecomCenter/locale/fa_IR.php
  14. 10 3
      web-modules/WecomCenter/locale/fi_FI.php
  15. 10 3
      web-modules/WecomCenter/locale/fr_FR.php
  16. 10 3
      web-modules/WecomCenter/locale/he_IL.php
  17. 10 3
      web-modules/WecomCenter/locale/hu_HU.php
  18. 10 3
      web-modules/WecomCenter/locale/id_ID.php
  19. 10 3
      web-modules/WecomCenter/locale/it_IT.php
  20. 10 3
      web-modules/WecomCenter/locale/ja_JP.php
  21. 10 3
      web-modules/WecomCenter/locale/ka_GE.php
  22. 10 3
      web-modules/WecomCenter/locale/ko_KR.php
  23. 10 3
      web-modules/WecomCenter/locale/lt_LT.php
  24. 10 3
      web-modules/WecomCenter/locale/lv_LV.php
  25. 10 3
      web-modules/WecomCenter/locale/nb_NO.php
  26. 10 3
      web-modules/WecomCenter/locale/nl_NL.php
  27. 10 3
      web-modules/WecomCenter/locale/pl_PL.php
  28. 10 3
      web-modules/WecomCenter/locale/pt_BR.php
  29. 10 3
      web-modules/WecomCenter/locale/ro_RO.php
  30. 10 3
      web-modules/WecomCenter/locale/ru_RU.php
  31. 10 3
      web-modules/WecomCenter/locale/sk_SK.php
  32. 10 3
      web-modules/WecomCenter/locale/sv_SE.php
  33. 10 3
      web-modules/WecomCenter/locale/tr_TR.php
  34. 10 3
      web-modules/WecomCenter/locale/uk_UA.php
  35. 10 3
      web-modules/WecomCenter/locale/uz_UZ.php
  36. 10 3
      web-modules/WecomCenter/locale/vi_VN.php
  37. 10 3
      web-modules/WecomCenter/locale/zh_CN.php
  38. 10 3
      web-modules/WecomCenter/locale/zh_TW.php
  39. 8 4
      web-modules/WecomCenter/views/topology.view.php

+ 127 - 33
collector/l2_snapshot.py

@@ -79,6 +79,20 @@ def main():
     edges_raw = load(STP); eps = load(EP); ps = load(PS)
     lat = load_latency()
 
+    # Previous snapshot is the switch inventory / last-known-position source.
+    # Terminals are deliberately NOT compared with it: a terminal is shown
+    # only if it is present in the current collection.
+    prev_snap = {}
+    try:
+        with open(OUT, encoding='utf-8') as f:
+            prev_snap = json.load(f)
+    except Exception:
+        pass
+    prev_nodes = {str(n.get('id')): n for n in prev_snap.get('nodes', []) if n.get('id')}
+    prev_edges = prev_snap.get('edges', []) or []
+    days = int(l2cfg.config().get('history_days') or 7)
+    cutoff = now - days * 86400
+
     # ---------- port status maps ----------
     pmap = {}          # (ip, ifName) -> row
     for r in ps:
@@ -157,57 +171,121 @@ def main():
         if av == '2':
             ip_alarms[ip].append({'sev': 4, 'name': 'SNMP unreachable / host unavailable', 'clock': now, 'ack': False})
 
-    # ---------- terminals (with history for offline detection) ----------
-    prev_term = {}
-    try:
-        prev = json.load(open(OUT, encoding='utf-8'))
-        for t in prev.get('terminals', []):
-            prev_term[t['mac']] = t
-    except Exception:
-        pass
-    seen_mac = set(); terminals = []
+    # ---------- switch inventory / hosts ----------
+    def is_switch_name(name):
+        n = (name or '').upper()
+        return bool(re.search(r'-(DIST|ACCESS|CORE)(-|$)', n)) or bool(re.match(r'^COBCO-', n))
+
+    switch_hosts = {}
+    for h in hs:
+        hn = norm(h.get('name') or '')
+        if not is_switch_name(hn):
+            continue
+        for i in h.get('interfaces', []):
+            if i.get('type') == '2' and i.get('ip'):
+                switch_hosts[i['ip']] = {
+                    'name': hn, 'hostid': h.get('hostid'),
+                    'available': str(i.get('available', '0')),
+                    'error': i.get('error', '') or ''
+                }
+
+    # ---------- terminals (current collection only; online only) ----------
+    # An FDB entry on a port that is not up is stale and is not an "offline
+    # terminal": the product only displays terminals that are present now.
+    terminals = []
     for t in eps:
         mac = (t['mac'] or '').lower()
         if not mac: continue
-        seen_mac.add(mac)
         st = pmap.get((t['switch_ip'], t['port']), {})
         oper = st.get('oper_status', '')
         try: mc = int(st.get('mac_count', '0') or 0)
         except: mc = 0
-        online = (oper == 'up' and mc > 0)
+        if oper != 'up' or mc <= 0:
+            continue
         terminals.append({'mac': mac, 'ip': t['ip'], 'name': t['name'], 'sw_ip': t['switch_ip'],
                           'sw_name': norm(t['switch_name']), 'port': t['port'], 'alias': t.get('port_alias', ''),
-                          'speed': st.get('speed_mbps', ''), 'online': online,
-                          'last_seen': now if online else (prev_term.get(mac, {}).get('last_seen') or None)})
-    # carry previously-known but now-absent terminals as offline
-    for mac, pt in prev_term.items():
-        if mac not in seen_mac:
-            nt = dict(pt); nt['online'] = False
-            terminals.append(nt)
+                          'speed': st.get('speed_mbps', ''), 'online': True, 'last_seen': now})
 
-    # terminal counts per switch
+    # terminal counts per switch (online only)
     tcnt = collections.defaultdict(lambda: {'online': 0, 'offline': 0})
     for t in terminals:
-        c = tcnt[t['sw_ip']]
-        c['online' if t['online'] else 'offline'] += 1
+        tcnt[t['sw_ip']]['online'] += 1
+
+    # ---------- switch inventory: online topology + recorded offline ----------
+    offline_ips = set()
+    # Hosts whose SNMP interface is unavailable are offline even if they have no
+    # current STP edge.  This is how a failed switch stays on the map.
+    for ip, hn in switch_hosts.items():
+        if hn['available'] == '2':
+            offline_ips.add(ip)
+    # Keep a previously recorded switch for history_days when it disappears
+    # from the protocol-derived graph.  The last_online stamp prevents old
+    # decommissioned devices from living forever.
+    for ip, pn in prev_nodes.items():
+        if ip in nodes_ip:
+            continue
+        last_online = int(pn.get('last_online') or pn.get('last_seen') or prev_snap.get('generated') or 0)
+        if last_online >= cutoff:
+            offline_ips.add(ip)
+    known_ips = set(nodes_ip) | offline_ips
+
+    def name_role(name):
+        n = (name or '').upper()
+        if re.search(r'-(DIST)(-|$)', n): return 'distribution'
+        if re.search(r'-(ACCESS)(-|$)', n): return 'access'
+        if re.search(r'-(CORE)(-|$)', n): return 'core'
+        return ''
+
+    def node_tier(ip, name, pn=None):
+        pn = pn or {}
+        ch = int(pn.get('children') or 0)
+        pt = str(pn.get('tier') or '').lower()
+        # Graph degree is the protocol truth: a switch with children is an
+        # aggregation/distribution point, regardless of a wrong name suffix.
+        if ch > 0:
+            return 'distribution'
+        if pt in ('distribution', 'access'):
+            return pt
+        nr = name_role(name)
+        if nr:
+            return nr
+        if pt == 'core': return 'core'
+        return 'access'
 
-    # ---------- nodes ----------
     nodes = []
-    for ip in sorted(nodes_ip, key=lambda x: (depth.get(x, 99), x)):
+    for ip in sorted(known_ips, key=lambda x: (depth.get(x, 99), x)):
+        online = ip in nodes_ip
+        pn = prev_nodes.get(ip, {})
+        hn = switch_hosts.get(ip, {})
+        nm = sname.get(ip) or pn.get('name') or hn.get('name') or ip_name.get(ip) or ip
         al = ip_alarms.get(ip, [])
         maxsev = max([a['sev'] for a in al], default=-1)
+        if online:
+            ntier = tier(ip)
+            ndepth = depth.get(ip, 99)
+            nchildren = len(children.get(ip, []))
+            oper = 'down' if avail.get(ip) == '2' else 'up'
+            last_online = now
+        else:
+            ntier = node_tier(ip, nm, pn)
+            ndepth = int(pn.get('depth', 99))
+            nchildren = int(pn.get('children') or 0)
+            oper = 'down'
+            last_online = int(pn.get('last_online') or pn.get('last_seen') or prev_snap.get('generated') or now)
         nodes.append({
-            'id': ip, 'name': sname.get(ip, ip), 'ip': ip, 'tier': tier(ip), 'depth': depth.get(ip, 99),
-            'hostid': hid_by_ip.get(ip),
-            'oper': 'down' if avail.get(ip) == '2' else 'up',
+            'id': ip, 'name': nm, 'ip': ip, 'tier': ntier, 'depth': ndepth,
+            'hostid': hid_by_ip.get(ip) or hn.get('hostid') or pn.get('hostid'),
+            'oper': oper, 'offline': not online,
             'alarms': sorted(al, key=lambda a: -a['sev']), 'maxsev': maxsev,
-            'term_online': tcnt[ip]['online'], 'term_offline': tcnt[ip]['offline'],
-            'latency_ms': lat.get(ip),
-            'children': len(children.get(ip, []))
+            'term_online': tcnt[ip]['online'], 'term_offline': 0,
+            'latency_ms': lat.get(ip) if online else pn.get('latency_ms'),
+            'children': nchildren,
+            'last_online': last_online
         })
 
     # ---------- edges ----------
     edges = []
+    edge_pairs = set()
     for k, d in pair.items():
         a, b = k
         p = d['ports']
@@ -226,18 +304,34 @@ def main():
             row = pmap.get((ip, port))
             ops.append(row.get('oper_status') if row else None)
         state = 'down' if ('down' in ops) else 'up'
+        if a not in known_ips or b not in known_ips:
+            continue
         edges.append({'source': a, 'target': b, 'speed': speed, 'state': state,
                       'a_port': a_port, 'b_port': b_port})
+        edge_pairs.add(tuple(sorted((a, b))))
+
+    # Keep the last-known uplink for an offline switch so it is shown in its
+    # workshop frame and its historical position is not lost.
+    for e in prev_edges:
+        a, b = str(e.get('source')), str(e.get('target'))
+        k = tuple(sorted((a, b)))
+        if k in edge_pairs or a not in known_ips or b not in known_ips:
+            continue
+        if a in nodes_ip and b in nodes_ip:
+            continue
+        edges.append({'source': a, 'target': b, 'speed': e.get('speed', 0), 'state': 'down',
+                      'a_port': e.get('a_port', ''), 'b_port': e.get('b_port', '')})
+        edge_pairs.add(k)
 
     stats = {
-        'switches': len(nodes), 'edges': len(edges),
-        'term_total': len(terminals), 'term_online': sum(1 for t in terminals if t['online']),
-        'term_offline': sum(1 for t in terminals if not t['online']),
+        'switches': len(nodes), 'switches_online': len(nodes_ip), 'switches_offline': len(offline_ips),
+        'edges': len(edges),
+        'term_total': len(terminals), 'term_online': len(terminals), 'term_offline': 0,
         'alarms': sum(len(n['alarms']) for n in nodes),
         'devices_alarm': sum(1 for n in nodes if n['alarms']),
         'maxsev': max([n['maxsev'] for n in nodes], default=-1)
     }
-    in_nodes = set(nodes_ip)
+    in_nodes = set(known_ips)
     other = []
     for ip, al in ip_alarms.items():
         if ip in in_nodes:

+ 94 - 35
web-modules/WecomCenter/assets/l2topology/app.js

@@ -134,7 +134,7 @@
       var g = groupOf(n);
       var s = map[g.label] || (map[g.label] = {label: g.label, big: g.big, nodes: [], alarms: 0, offline: 0, maxsev: -1, latmax: 0, up: 0});
       s.nodes.push(n);
-      s.offline += (+n.term_offline || 0);
+      s.offline += (n.offline || n.oper === 'down') ? 1 : 0;
       if (n.alarms && n.alarms.length) {
         s.alarms += n.alarms.length;
         var sev = (n.maxsev != null && +n.maxsev >= 0) ? +n.maxsev : -1;
@@ -577,15 +577,24 @@
     var p = parseName(n.name || '');
     var r = String(p.role || '').toLowerCase();
     var tier = String(n.tier || '').toLowerCase();
+    var childCount = parseInt(n.children, 10);
+    if (isNaN(childCount)) childCount = -1;
     if (configuredCore(n)) return 'core';
     // Backward-compatible fallback only when the core list is absent (old
     // config).  An explicitly empty list means the administrator wants no
     // configured core column.
     if (!(CFG.core_rules instanceof Array) && (/^core(\s|-)/i.test(String(n.name || '')) || groupOf(n).label === 'Core')) return 'core';
-    if (tier === 'core' || r === 'core') return 'main';
-    if (r === 'dist') return 'dist';
-    if (r === 'access') return 'access';
-    if (tier.indexOf('dist') === 0) return 'dist';
+    // STP topology is the truth for Dist vs Access: a switch with downstream
+    // children is a distribution point even when its hostname says Access.
+    // This fixes the 3301/4504 cases where the old snapshot tier was a root
+    // tier but the device is really an aggregation switch.
+    if (tier === 'core') {
+      if (childCount > 0 || r === 'dist') return 'dist';
+      return 'main';
+    }
+    if (childCount > 0) return 'dist';
+    if (r === 'dist' && tier !== 'access') return 'dist';
+    if (tier === 'distribution') return 'dist';
     return 'access';
   }
 
@@ -970,6 +979,7 @@
         'curve-style': 'bezier', 'z-index-compare': 'manual', 'z-index': 11
       }},
       {selector: 'node.alarm', style: {'border-width': 6}},
+      {selector: 'node.offline', style: {'border-color': '#C0392B', 'border-width': 6, 'border-style': 'dashed', 'opacity': 0.9}},
       {selector: 'node.dim', style: {'opacity': 0.12}},
       {selector: 'edge.dim', style: {'opacity': 0.08}},
       {selector: 'edge.hl', style: {'width': 5, 'line-color': '#FFD54A', 'opacity': 1, 'z-index-compare': 'manual', 'z-index': 45}},
@@ -1052,6 +1062,7 @@
     buildAlarms(snap);
     buildOffline(snap);
     buildTerminals(snap);
+    buildHistory();
   }
 
   function renderFull(snap) {
@@ -1086,6 +1097,7 @@
       var bg = p.role === 'core' ? TIER_BG.core : (p.role === 'main' ? '#2E75B6' : (p.role === 'dist' ? TIER_BG.dist : TIER_BG.access));
       var bd = bg;
       if (n.alarms && n.alarms.length) { cls.push('alarm'); bd = '#E45959'; }
+      if (n.offline || n.oper === 'down') cls.push('offline');
       if (n.latency_ms != null) {
         if (+n.latency_ms > LATB) cls.push('lat-bad');
         else if (+n.latency_ms > LATW) cls.push('lat-warn');
@@ -1093,7 +1105,8 @@
       if (p.role === 'core') cls.push('root');
       els.push({group: 'nodes', data: {
         id: n.id, type: 'switch', name: n.name, ip: n.ip, tier: n.tier, depth: n.depth, hostid: n.hostid, oper: n.oper,
-        maxsev: n.maxsev, alarms: n.alarms || [], term_online: n.term_online, term_offline: n.term_offline, latency_ms: n.latency_ms,
+        offline: !!(n.offline || n.oper === 'down'), last_online: n.last_online,
+        maxsev: n.maxsev, alarms: n.alarms || [], term_online: n.term_online, term_offline: 0, latency_ms: n.latency_ms,
         label: switchLabel(n, p.role, p.net), tw: p.w - 12, bg: bg, bd: bd, w: p.w, h: p.h,
         group: p.group + ' · ' + netLabel(p.net), blockGroup: p.group, net: p.net, role: p.role
       }, position: {x: p.x, y: p.y}, classes: cls.join(' ')});
@@ -1131,7 +1144,7 @@
     var bySw = {};
     ((lastSnap && lastSnap.terminals) || []).forEach(function (tm) {
       if (!byId[tm.sw_ip]) return;
-      if (onlyOff && terminalOnline(tm)) return;
+      if (!terminalOnline(tm)) return;
       (bySw[tm.sw_ip] = bySw[tm.sw_ip] || []).push(tm);
     });
     Object.keys(bySw).forEach(function (sw) {
@@ -1289,7 +1302,7 @@
       var bySw = {};
       (snap.terminals || []).forEach(function (tm) {
         if (!switchIds[tm.sw_ip]) return;
-        if (onlyOff && tm.online) return;
+        if (!terminalOnline(tm)) return;
         (bySw[tm.sw_ip] = bySw[tm.sw_ip] || []).push(tm);
       });
       Object.keys(bySw).forEach(function (sw) {
@@ -1299,7 +1312,7 @@
           var col = k % 9, row = Math.floor(k / 9);
           var x = base.x - 54 + col * 13;
           var y = base.y + NODE_H / 2 + 16 + row * 13;
-          els.push({group: 'nodes', data: {id: 't_' + tm.mac, type: 'term', bg: tm.online ? '#2ECC71' : '#95a5a6', mac: tm.mac, ip: tm.ip, online: tm.online},
+          els.push({group: 'nodes', data: {id: 't_' + tm.mac, type: 'term', bg: '#2ECC71', mac: tm.mac, ip: tm.ip, online: true},
             position: {x: x, y: y}, selectable: false});
           els.push({group: 'edges', data: {id: 'te_' + tm.mac, type: 'term', source: sw, target: 't_' + tm.mac}});
         });
@@ -1314,7 +1327,8 @@
   function updateStats(snap) {
     var s = snap.stats || {};
     var txt = '● ' + (s.switches || 0) + '  |  ⇄ ' + (s.edges || 0) + '  |  ' +
-      (s.term_total || 0) + '(' + (s.term_online || 0) + '/' + (s.term_offline || 0) + ')  |  ⚠ ' + (s.alarms || 0);
+      (s.term_total || 0) + ' ' + t('l2.terminals', 'terminals') + '  |  ' +
+      (s.switches_offline || 0) + ' ' + t('l2.offline_short', 'offline SW') + '  |  ⚠ ' + (s.alarms || 0);
     var up = snap.generated_iso ? ('  ·  ' + t('l2.updated', 'Updated') + ' ' + snap.generated_iso) : '';
     el('l2-stats').textContent = txt + up;
   }
@@ -1336,7 +1350,7 @@
          '<span class="lg-box" style="background:#E74C3C;border-radius:2px;margin-left:8px"></span>&gt;' + LATB + 'ms</div>';
     h += '<div class="lg-row"><span class="lg-box" style="background:#E45959;border-radius:2px"></span>' + t('l2.alarms', 'Alarms') + '</div>';
     h += '<div class="lg-row"><span class="lg-box" style="background:#2ECC71;border-radius:50%"></span>' + t('l2.term_on', 'Online') +
-         '<span class="lg-box" style="background:#95a5a6;border-radius:50%;margin-left:8px"></span>' + t('l2.term_off', 'Offline') + '</div>';
+         '<span class="lg-box" style="background:#C0392B;border-radius:2px;margin-left:8px"></span>' + t('l2.offline', 'Offline switch') + '</div>';
     h += '<div class="lg-row" style="opacity:.72;margin-top:4px">' + t('l2.click_hint', 'Hover a switch to highlight its full upstream path') + '</div>';
     el('l2-legend').innerHTML = h;
   }
@@ -1372,28 +1386,46 @@
     });
   }
 
+  function fmtTs(ts) {
+    if (!ts) return '—';
+    try {
+      var d = new Date(Number(ts) * 1000);
+      var p = function (x) { return (x < 10 ? '0' : '') + x; };
+      return d.getFullYear() + '-' + p(d.getMonth() + 1) + '-' + p(d.getDate()) + ' ' + p(d.getHours()) + ':' + p(d.getMinutes());
+    } catch (e) { return '—'; }
+  }
+
   function buildOffline(snap) {
-    var off = (snap.terminals || []).filter(function (tm) { return !terminalOnline(tm); });
+    var off = (snap.nodes || []).filter(function (n) { return n.offline || n.oper === 'down'; });
     el('l2-offline-count').textContent = off.length;
-    if (!off.length) { el('l2-offline-list').innerHTML = '<div class="panel-empty">' + t('l2.offline_empty', 'No offline terminals.') + '</div>'; return; }
+    if (!off.length) { el('l2-offline-list').innerHTML = '<div class="panel-empty">' + t('l2.offline_empty', 'No offline switches.') + '</div>'; return; }
+    off.sort(function (a, b) {
+      var ra = roleOf(a), rb = roleOf(b);
+      if (ra !== rb) return ra === 'dist' ? -1 : 1;
+      return String(a.name || '').localeCompare(String(b.name || ''));
+    });
     var h = '';
-    off.forEach(function (tm) {
-      h += '<div class="off-row" data-mac="' + esc(tm.mac) + '">' +
-        '<div class="off-main"><div class="off-mac">' + esc(tm.mac) + '</div>' +
-        '<div class="off-sub">' + esc(speedText(tm.speed)) + ' · ' + esc(tm.sw_name || tm.sw_ip) + ' · ' + esc(tm.port || '') + '</div></div></div>';
+    off.forEach(function (n) {
+      var role = roleOf(n);
+      var roleTxt = role === 'dist' ? t('l2.dist', 'Distribution') : t('l2.access', 'Access');
+      h += '<div class="off-row sw-row" data-id="' + esc(n.id) + '">' +
+        '<span class="term-dot" style="background:#C0392B"></span>' +
+        '<div class="off-main"><div class="off-mac">' + esc(n.name || n.ip) + '</div>' +
+        '<div class="off-sub">' + esc(roleTxt) + ' · ' + esc(n.ip || '') +
+        ' · ' + t('l2.last_online', 'last online') + ': ' + esc(fmtTs(n.last_online)) + '</div></div></div>';
     });
     var box = el('l2-offline-list');
     box.innerHTML = h;
     box.querySelectorAll('.off-row').forEach(function (r) {
       r.addEventListener('click', function () {
-        var mac = r.getAttribute('data-mac');
-        revealById('t_' + mac);
+        var id = r.getAttribute('data-id');
+        if (id) revealById(id);
       });
     });
   }
 
   function buildTerminals(snap) {
-    var list = (snap.terminals || []).filter(function (tm) { return !onlyOff || !terminalOnline(tm); });
+    var list = (snap.terminals || []).filter(function (tm) { return terminalOnline(tm); });
     if (el('l2-term-count')) el('l2-term-count').textContent = list.length;
     var box = el('l2-term-list');
     if (!box) return;
@@ -1411,12 +1443,10 @@
     });
     var h = '';
     list.forEach(function (tm) {
-      var on = terminalOnline(tm);
       h += '<div class="off-row term-row" data-mac="' + esc(tm.mac) + '">' +
-        '<span class="term-dot" style="background:' + (on ? '#2ECC71' : '#95a5a6') + '"></span>' +
+        '<span class="term-dot" style="background:#2ECC71"></span>' +
         '<div class="off-main"><div class="off-mac">' + esc(tm.mac) + '</div>' +
         '<div class="off-sub"><span class="term-rate">' + esc(speedText(tm.speed)) + '</span> · ' +
-        (on ? t('l2.term_on', 'online') : t('l2.term_off', 'offline')) + ' · ' +
         esc(tm.sw_name || tm.sw_ip || '') + ' · ' + esc(tm.port || '') + '</div></div></div>';
     });
     box.innerHTML = h;
@@ -1425,6 +1455,42 @@
     });
   }
 
+  function buildHistory() {
+    var box = el('l2-history-list');
+    if (!box) return;
+    var list = window.L2_HISTORY || [];
+    var used = window.L2_META && window.L2_META.usedTs ? String(window.L2_META.usedTs) : '';
+    var h = '<div class="off-row hist-row' + (used ? '' : ' active') + '" data-ts=""><div class="off-main">' +
+      '<div class="off-mac">' + t('l2.history_live', 'Live / latest') + '</div>' +
+      '<div class="off-sub">' + t('l2.history_live_hint', 'Newest snapshot, auto refresh') + '</div></div></div>';
+    if (!list.length) {
+      h += '<div class="panel-empty">' + t('l2.history_empty', 'No history snapshots yet.') + '</div>';
+    } else {
+      list.forEach(function (it) {
+        var st = it.stats || {};
+        var ts = String(it.ts || '');
+        var sub = (st.switches != null ? st.switches + ' ' + t('l2.unit_sw', 'sw') : '') +
+          (st.term_total != null ? ' · ' + st.term_total + ' ' + t('l2.terminals', 'terminals') : '') +
+          (st.switches_offline ? ' · ' + st.switches_offline + ' ' + t('l2.offline_short', 'offline SW') : '');
+        h += '<div class="off-row hist-row' + (ts === used ? ' active' : '') + '" data-ts="' + esc(ts) + '">' +
+          '<div class="off-main"><div class="off-mac">' + esc(it.captured_at || fmtTs(it.ts)) + '</div>' +
+          '<div class="off-sub">' + esc(sub) + '</div></div></div>';
+      });
+    }
+    box.innerHTML = h;
+    box.querySelectorAll('.hist-row').forEach(function (r) {
+      r.addEventListener('click', function () {
+        var ts = r.getAttribute('data-ts');
+        var u = new URL(window.location.href);
+        u.searchParams.set('action', 'l2.topology.view');
+        if (ts) u.searchParams.set('ts', ts);
+        else u.searchParams.delete('ts');
+        window.location.href = u.toString();
+      });
+    });
+    if (el('l2-history-count')) el('l2-history-count').textContent = list.length;
+  }
+
   function esc(s) { return String(s == null ? '' : s).replace(/[&<>"']/g, function (c) { return ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'})[c]; }); }
 
   /* ---------------- interactions ---------------- */
@@ -1453,11 +1519,6 @@
       pinnedTerm = tm.mac;
       showTerm = true;
       if (el('l2-showterm')) el('l2-showterm').checked = true;
-      // A search hit must be visible even when the offline-only filter is on.
-      if (onlyOff && terminalOnline(tm)) {
-        onlyOff = false;
-        if (el('l2-onlyoff')) el('l2-onlyoff').checked = false;
-      }
       if (!cy || cy.getElementById(id).empty()) render(lastSnap);
       setTimeout(function () {
         // Center the switch that the terminal hangs under, then flash the
@@ -1507,11 +1568,13 @@
       var arr = d.alarms || [];
       var sev = (d.maxsev != null && d.maxsev >= 0) ? ('<span style="color:' + SEV_COLORS[d.maxsev] + '">● ' + SEV_NAMES[d.maxsev] + '</span>') : '—';
       var lat = (d.latency_ms != null) ? (d.latency_ms + ' ms') : '—';
+      var stat = (d.offline || d.oper === 'down') ? ('<span style="color:#E45959">' + esc(t('l2.offline', 'Offline')) + '</span>') : esc(d.oper || 'up');
       var html = '<b>' + esc(d.name) + '</b><br>' + esc(d.ip) + ' · ' + esc(d.group || '') + '<br>' +
-        'Tier: ' + esc(d.tier) + ' · ' + (d.oper || '') + '<br>' +
+        'Tier: ' + esc(d.tier) + ' · ' + stat + '<br>' +
         'Latency: ' + lat + '<br>' +
-        'Terminals: ' + (d.term_online || 0) + ' on / ' + (d.term_offline || 0) + ' off<br>' +
+        'Terminals: ' + (d.term_online || 0) + '<br>' +
         'Alarm: ' + sev;
+      if (d.offline || d.oper === 'down') html += '<br>' + esc(t('l2.last_online', 'last online')) + ': ' + esc(fmtTs(d.last_online));
       if (arr.length) html += '<hr>' + arr.map(function (a) { return esc(a.name || a.description || ''); }).join('<br>');
       showTip(e, html);
     });
@@ -1573,6 +1636,7 @@
     var idx = [];
     (snap.nodes || []).forEach(function (n) { idx.push({id: n.id, label: n.name, sub: n.ip, kind: 'sw'}); });
     (snap.terminals || []).forEach(function (tm) {
+      if (!terminalOnline(tm)) return;
       idx.push({id: 't_' + tm.mac, label: tm.mac, sub: (tm.ip || '') + ' · ' + speedText(tm.speed) + ' · ' + (tm.sw_name || ''), kind: 'term', mac: tm.mac});
     });
     return idx;
@@ -1651,11 +1715,6 @@
     if (back) back.style.display = 'none';
 
     el('l2-showterm').addEventListener('change', function () { showTerm = el('l2-showterm').checked; render(lastSnap); });
-    el('l2-onlyoff').addEventListener('change', function () {
-      onlyOff = el('l2-onlyoff').checked;
-      if (onlyOff && !showTerm) { showTerm = true; el('l2-showterm').checked = true; }
-      render(lastSnap);
-    });
     el('l2-zin').addEventListener('click', function () { if (cy) cy.zoom({level: cy.zoom() * 1.25, renderedPosition: {x: cy.width() / 2, y: cy.height() / 2}}); });
     el('l2-zout').addEventListener('click', function () { if (cy) cy.zoom({level: cy.zoom() / 1.25, renderedPosition: {x: cy.width() / 2, y: cy.height() / 2}}); });
     el('l2-fit').addEventListener('click', function () { if (cy) cy.fit(cy.elements(), 50); });

+ 2 - 0
web-modules/WecomCenter/assets/l2topology/style.css

@@ -139,6 +139,8 @@
 .term-row { align-items: flex-start; }
 .term-dot { flex: 0 0 auto; width: 8px; height: 8px; border-radius: 50%; margin-top: 5px; }
 .term-rate { color: var(--brand); font-weight: 700; }
+.hist-row.active { background: color-mix(in srgb, var(--brand) 14%, transparent); border-left: 3px solid var(--brand); padding-left: 11px; }
+.hist-row.active .off-mac { color: var(--brand); }
 
 /* tooltip */
 .l2-tip {

+ 10 - 3
web-modules/WecomCenter/locale/bg_BG.php

@@ -169,18 +169,18 @@ return [	'menu.title' => 'ROMAN Custom Center',
 	'l2.title' => 'L2 Physical Topology',
 	'l2.search_ph' => 'Search IP / MAC / name…',
 	'l2.show_term' => 'Show terminals',
-	'l2.only_off' => 'Only offline',
+	'l2.only_off' => 'Only offline switches',
 	'l2.auto_refresh' => 'Auto refresh',
 	'l2.min' => 'min',
 	'l2.refresh_now' => 'Refresh now',
 	'l2.fit' => 'Fit to window',
 	'l2.alarms' => 'Alarms',
-	'l2.offline' => 'Offline terminals',
+	'l2.offline' => 'Offline switches',
 	'l2.time_live' => 'Live',
 	'l2.time_btn' => 'Go',
 	'l2.time_label' => 'Snapshot time',
 	'l2.no_data' => 'No snapshot yet — the collector has not run.',
-	'l2.offline_empty' => 'No offline terminals.',
+	'l2.offline_empty' => 'No offline switches.',
 	'l2.alarm_empty' => 'No active alarms.',
 	'l2.updated' => 'Updated',
 	'l2.pause' => 'Pause auto-refresh',
@@ -215,4 +215,11 @@ return [	'menu.title' => 'ROMAN Custom Center',
 	'l2.net_office' => 'OFFICE office',
 	'l2.net_other' => 'Other network',
 	'l2.floor_na' => 'Other',
+	'l2.terminals' => 'Terminals',
+	'l2.offline_short' => 'offline SW',
+	'l2.last_online' => 'last online',
+	'l2.history' => 'History',
+	'l2.history_live' => 'Live / latest',
+	'l2.history_live_hint' => 'Newest snapshot, auto refresh',
+	'l2.history_empty' => 'No history snapshots yet.',
 ];

+ 10 - 3
web-modules/WecomCenter/locale/ca_ES.php

@@ -169,18 +169,18 @@ return [	'menu.title' => 'ROMAN Custom Center',
 	'l2.title' => 'L2 Physical Topology',
 	'l2.search_ph' => 'Search IP / MAC / name…',
 	'l2.show_term' => 'Show terminals',
-	'l2.only_off' => 'Only offline',
+	'l2.only_off' => 'Only offline switches',
 	'l2.auto_refresh' => 'Auto refresh',
 	'l2.min' => 'min',
 	'l2.refresh_now' => 'Refresh now',
 	'l2.fit' => 'Fit to window',
 	'l2.alarms' => 'Alarms',
-	'l2.offline' => 'Offline terminals',
+	'l2.offline' => 'Offline switches',
 	'l2.time_live' => 'Live',
 	'l2.time_btn' => 'Go',
 	'l2.time_label' => 'Snapshot time',
 	'l2.no_data' => 'No snapshot yet — the collector has not run.',
-	'l2.offline_empty' => 'No offline terminals.',
+	'l2.offline_empty' => 'No offline switches.',
 	'l2.alarm_empty' => 'No active alarms.',
 	'l2.updated' => 'Updated',
 	'l2.pause' => 'Pause auto-refresh',
@@ -215,4 +215,11 @@ return [	'menu.title' => 'ROMAN Custom Center',
 	'l2.net_office' => 'OFFICE office',
 	'l2.net_other' => 'Other network',
 	'l2.floor_na' => 'Other',
+	'l2.terminals' => 'Terminals',
+	'l2.offline_short' => 'offline SW',
+	'l2.last_online' => 'last online',
+	'l2.history' => 'History',
+	'l2.history_live' => 'Live / latest',
+	'l2.history_live_hint' => 'Newest snapshot, auto refresh',
+	'l2.history_empty' => 'No history snapshots yet.',
 ];

+ 10 - 3
web-modules/WecomCenter/locale/cs_CZ.php

@@ -169,18 +169,18 @@ return [	'menu.title' => 'ROMAN Custom Center',
 	'l2.title' => 'L2 Physical Topology',
 	'l2.search_ph' => 'Search IP / MAC / name…',
 	'l2.show_term' => 'Show terminals',
-	'l2.only_off' => 'Only offline',
+	'l2.only_off' => 'Only offline switches',
 	'l2.auto_refresh' => 'Auto refresh',
 	'l2.min' => 'min',
 	'l2.refresh_now' => 'Refresh now',
 	'l2.fit' => 'Fit to window',
 	'l2.alarms' => 'Alarms',
-	'l2.offline' => 'Offline terminals',
+	'l2.offline' => 'Offline switches',
 	'l2.time_live' => 'Live',
 	'l2.time_btn' => 'Go',
 	'l2.time_label' => 'Snapshot time',
 	'l2.no_data' => 'No snapshot yet — the collector has not run.',
-	'l2.offline_empty' => 'No offline terminals.',
+	'l2.offline_empty' => 'No offline switches.',
 	'l2.alarm_empty' => 'No active alarms.',
 	'l2.updated' => 'Updated',
 	'l2.pause' => 'Pause auto-refresh',
@@ -215,4 +215,11 @@ return [	'menu.title' => 'ROMAN Custom Center',
 	'l2.net_office' => 'OFFICE office',
 	'l2.net_other' => 'Other network',
 	'l2.floor_na' => 'Other',
+	'l2.terminals' => 'Terminals',
+	'l2.offline_short' => 'offline SW',
+	'l2.last_online' => 'last online',
+	'l2.history' => 'History',
+	'l2.history_live' => 'Live / latest',
+	'l2.history_live_hint' => 'Newest snapshot, auto refresh',
+	'l2.history_empty' => 'No history snapshots yet.',
 ];

+ 10 - 3
web-modules/WecomCenter/locale/da_DK.php

@@ -169,18 +169,18 @@ return [	'menu.title' => 'ROMAN Custom Center',
 	'l2.title' => 'L2 Physical Topology',
 	'l2.search_ph' => 'Search IP / MAC / name…',
 	'l2.show_term' => 'Show terminals',
-	'l2.only_off' => 'Only offline',
+	'l2.only_off' => 'Only offline switches',
 	'l2.auto_refresh' => 'Auto refresh',
 	'l2.min' => 'min',
 	'l2.refresh_now' => 'Refresh now',
 	'l2.fit' => 'Fit to window',
 	'l2.alarms' => 'Alarms',
-	'l2.offline' => 'Offline terminals',
+	'l2.offline' => 'Offline switches',
 	'l2.time_live' => 'Live',
 	'l2.time_btn' => 'Go',
 	'l2.time_label' => 'Snapshot time',
 	'l2.no_data' => 'No snapshot yet — the collector has not run.',
-	'l2.offline_empty' => 'No offline terminals.',
+	'l2.offline_empty' => 'No offline switches.',
 	'l2.alarm_empty' => 'No active alarms.',
 	'l2.updated' => 'Updated',
 	'l2.pause' => 'Pause auto-refresh',
@@ -215,4 +215,11 @@ return [	'menu.title' => 'ROMAN Custom Center',
 	'l2.net_office' => 'OFFICE office',
 	'l2.net_other' => 'Other network',
 	'l2.floor_na' => 'Other',
+	'l2.terminals' => 'Terminals',
+	'l2.offline_short' => 'offline SW',
+	'l2.last_online' => 'last online',
+	'l2.history' => 'History',
+	'l2.history_live' => 'Live / latest',
+	'l2.history_live_hint' => 'Newest snapshot, auto refresh',
+	'l2.history_empty' => 'No history snapshots yet.',
 ];

+ 10 - 3
web-modules/WecomCenter/locale/de_DE.php

@@ -180,18 +180,18 @@ return [
 	'l2.title' => 'L2 Physical Topology',
 	'l2.search_ph' => 'Search IP / MAC / name…',
 	'l2.show_term' => 'Show terminals',
-	'l2.only_off' => 'Only offline',
+	'l2.only_off' => 'Only offline switches',
 	'l2.auto_refresh' => 'Auto refresh',
 	'l2.min' => 'min',
 	'l2.refresh_now' => 'Refresh now',
 	'l2.fit' => 'Fit to window',
 	'l2.alarms' => 'Alarms',
-	'l2.offline' => 'Offline terminals',
+	'l2.offline' => 'Offline switches',
 	'l2.time_live' => 'Live',
 	'l2.time_btn' => 'Go',
 	'l2.time_label' => 'Snapshot time',
 	'l2.no_data' => 'No snapshot yet — the collector has not run.',
-	'l2.offline_empty' => 'No offline terminals.',
+	'l2.offline_empty' => 'No offline switches.',
 	'l2.alarm_empty' => 'No active alarms.',
 	'l2.updated' => 'Updated',
 	'l2.pause' => 'Pause auto-refresh',
@@ -225,4 +225,11 @@ return [
 	'l2.net_office' => 'OFFICE office',
 	'l2.net_other' => 'Other network',
 	'l2.floor_na' => 'Other',
+	'l2.terminals' => 'Terminals',
+	'l2.offline_short' => 'offline SW',
+	'l2.last_online' => 'last online',
+	'l2.history' => 'History',
+	'l2.history_live' => 'Live / latest',
+	'l2.history_live_hint' => 'Newest snapshot, auto refresh',
+	'l2.history_empty' => 'No history snapshots yet.',
 ];

+ 10 - 3
web-modules/WecomCenter/locale/el_GR.php

@@ -169,18 +169,18 @@ return [	'menu.title' => 'ROMAN Custom Center',
 	'l2.title' => 'L2 Physical Topology',
 	'l2.search_ph' => 'Search IP / MAC / name…',
 	'l2.show_term' => 'Show terminals',
-	'l2.only_off' => 'Only offline',
+	'l2.only_off' => 'Only offline switches',
 	'l2.auto_refresh' => 'Auto refresh',
 	'l2.min' => 'min',
 	'l2.refresh_now' => 'Refresh now',
 	'l2.fit' => 'Fit to window',
 	'l2.alarms' => 'Alarms',
-	'l2.offline' => 'Offline terminals',
+	'l2.offline' => 'Offline switches',
 	'l2.time_live' => 'Live',
 	'l2.time_btn' => 'Go',
 	'l2.time_label' => 'Snapshot time',
 	'l2.no_data' => 'No snapshot yet — the collector has not run.',
-	'l2.offline_empty' => 'No offline terminals.',
+	'l2.offline_empty' => 'No offline switches.',
 	'l2.alarm_empty' => 'No active alarms.',
 	'l2.updated' => 'Updated',
 	'l2.pause' => 'Pause auto-refresh',
@@ -215,4 +215,11 @@ return [	'menu.title' => 'ROMAN Custom Center',
 	'l2.net_office' => 'OFFICE office',
 	'l2.net_other' => 'Other network',
 	'l2.floor_na' => 'Other',
+	'l2.terminals' => 'Terminals',
+	'l2.offline_short' => 'offline SW',
+	'l2.last_online' => 'last online',
+	'l2.history' => 'History',
+	'l2.history_live' => 'Live / latest',
+	'l2.history_live_hint' => 'Newest snapshot, auto refresh',
+	'l2.history_empty' => 'No history snapshots yet.',
 ];

+ 10 - 3
web-modules/WecomCenter/locale/en.php

@@ -210,18 +210,18 @@ return [
 'l2.floor_na'        => 'Other',
 	'l2.search_ph'       => 'Search IP / MAC / name…',
 	'l2.show_term'       => 'Show terminals',
-	'l2.only_off'        => 'Only offline',
+	'l2.only_off'        => 'Only offline switches',
 	'l2.auto_refresh'    => 'Auto refresh',
 	'l2.min'             => 'min',
 	'l2.refresh_now'     => 'Refresh now',
 	'l2.fit'             => 'Fit to window',
 	'l2.alarms'          => 'Alarms',
-	'l2.offline'         => 'Offline terminals',
+	'l2.offline'         => 'Offline switches',
 	'l2.time_live'       => 'Live',
 	'l2.time_btn'        => 'Go',
 	'l2.time_label'      => 'Snapshot time',
 	'l2.no_data'         => 'No snapshot yet — the collector has not run.',
-	'l2.offline_empty'   => 'No offline terminals.',
+	'l2.offline_empty'   => 'No offline switches.',
 	'l2.alarm_empty'     => 'No active alarms.',
 	'l2.updated'         => 'Updated',
 	'l2.pause'           => 'Pause auto-refresh',
@@ -247,4 +247,11 @@ return [
 	'l2.snapshot'        => 'Snapshot',
 	'l2.showing'         => 'Showing',
 	'l2.live_tag'        => 'LIVE',
+	'l2.terminals' => 'Terminals',
+	'l2.offline_short' => 'offline SW',
+	'l2.last_online' => 'last online',
+	'l2.history' => 'History',
+	'l2.history_live' => 'Live / latest',
+	'l2.history_live_hint' => 'Newest snapshot, auto refresh',
+	'l2.history_empty' => 'No history snapshots yet.',
 ];

+ 10 - 3
web-modules/WecomCenter/locale/en_GB.php

@@ -169,18 +169,18 @@ return [	'menu.title' => 'ROMAN Custom Center',
 	'l2.title' => 'L2 Physical Topology',
 	'l2.search_ph' => 'Search IP / MAC / name…',
 	'l2.show_term' => 'Show terminals',
-	'l2.only_off' => 'Only offline',
+	'l2.only_off' => 'Only offline switches',
 	'l2.auto_refresh' => 'Auto refresh',
 	'l2.min' => 'min',
 	'l2.refresh_now' => 'Refresh now',
 	'l2.fit' => 'Fit to window',
 	'l2.alarms' => 'Alarms',
-	'l2.offline' => 'Offline terminals',
+	'l2.offline' => 'Offline switches',
 	'l2.time_live' => 'Live',
 	'l2.time_btn' => 'Go',
 	'l2.time_label' => 'Snapshot time',
 	'l2.no_data' => 'No snapshot yet — the collector has not run.',
-	'l2.offline_empty' => 'No offline terminals.',
+	'l2.offline_empty' => 'No offline switches.',
 	'l2.alarm_empty' => 'No active alarms.',
 	'l2.updated' => 'Updated',
 	'l2.pause' => 'Pause auto-refresh',
@@ -215,4 +215,11 @@ return [	'menu.title' => 'ROMAN Custom Center',
 	'l2.net_office' => 'OFFICE office',
 	'l2.net_other' => 'Other network',
 	'l2.floor_na' => 'Other',
+	'l2.terminals' => 'Terminals',
+	'l2.offline_short' => 'offline SW',
+	'l2.last_online' => 'last online',
+	'l2.history' => 'History',
+	'l2.history_live' => 'Live / latest',
+	'l2.history_live_hint' => 'Newest snapshot, auto refresh',
+	'l2.history_empty' => 'No history snapshots yet.',
 ];

+ 10 - 3
web-modules/WecomCenter/locale/es_ES.php

@@ -180,18 +180,18 @@ return [
 	'l2.title' => 'L2 Physical Topology',
 	'l2.search_ph' => 'Search IP / MAC / name…',
 	'l2.show_term' => 'Show terminals',
-	'l2.only_off' => 'Only offline',
+	'l2.only_off' => 'Only offline switches',
 	'l2.auto_refresh' => 'Auto refresh',
 	'l2.min' => 'min',
 	'l2.refresh_now' => 'Refresh now',
 	'l2.fit' => 'Fit to window',
 	'l2.alarms' => 'Alarms',
-	'l2.offline' => 'Offline terminals',
+	'l2.offline' => 'Offline switches',
 	'l2.time_live' => 'Live',
 	'l2.time_btn' => 'Go',
 	'l2.time_label' => 'Snapshot time',
 	'l2.no_data' => 'No snapshot yet — the collector has not run.',
-	'l2.offline_empty' => 'No offline terminals.',
+	'l2.offline_empty' => 'No offline switches.',
 	'l2.alarm_empty' => 'No active alarms.',
 	'l2.updated' => 'Updated',
 	'l2.pause' => 'Pause auto-refresh',
@@ -225,4 +225,11 @@ return [
 	'l2.net_office' => 'OFFICE office',
 	'l2.net_other' => 'Other network',
 	'l2.floor_na' => 'Other',
+	'l2.terminals' => 'Terminals',
+	'l2.offline_short' => 'offline SW',
+	'l2.last_online' => 'last online',
+	'l2.history' => 'History',
+	'l2.history_live' => 'Live / latest',
+	'l2.history_live_hint' => 'Newest snapshot, auto refresh',
+	'l2.history_empty' => 'No history snapshots yet.',
 ];

+ 10 - 3
web-modules/WecomCenter/locale/fa_IR.php

@@ -169,18 +169,18 @@ return [	'menu.title' => 'ROMAN Custom Center',
 	'l2.title' => 'L2 Physical Topology',
 	'l2.search_ph' => 'Search IP / MAC / name…',
 	'l2.show_term' => 'Show terminals',
-	'l2.only_off' => 'Only offline',
+	'l2.only_off' => 'Only offline switches',
 	'l2.auto_refresh' => 'Auto refresh',
 	'l2.min' => 'min',
 	'l2.refresh_now' => 'Refresh now',
 	'l2.fit' => 'Fit to window',
 	'l2.alarms' => 'Alarms',
-	'l2.offline' => 'Offline terminals',
+	'l2.offline' => 'Offline switches',
 	'l2.time_live' => 'Live',
 	'l2.time_btn' => 'Go',
 	'l2.time_label' => 'Snapshot time',
 	'l2.no_data' => 'No snapshot yet — the collector has not run.',
-	'l2.offline_empty' => 'No offline terminals.',
+	'l2.offline_empty' => 'No offline switches.',
 	'l2.alarm_empty' => 'No active alarms.',
 	'l2.updated' => 'Updated',
 	'l2.pause' => 'Pause auto-refresh',
@@ -215,4 +215,11 @@ return [	'menu.title' => 'ROMAN Custom Center',
 	'l2.net_office' => 'OFFICE office',
 	'l2.net_other' => 'Other network',
 	'l2.floor_na' => 'Other',
+	'l2.terminals' => 'Terminals',
+	'l2.offline_short' => 'offline SW',
+	'l2.last_online' => 'last online',
+	'l2.history' => 'History',
+	'l2.history_live' => 'Live / latest',
+	'l2.history_live_hint' => 'Newest snapshot, auto refresh',
+	'l2.history_empty' => 'No history snapshots yet.',
 ];

+ 10 - 3
web-modules/WecomCenter/locale/fi_FI.php

@@ -169,18 +169,18 @@ return [	'menu.title' => 'ROMAN Custom Center',
 	'l2.title' => 'L2 Physical Topology',
 	'l2.search_ph' => 'Search IP / MAC / name…',
 	'l2.show_term' => 'Show terminals',
-	'l2.only_off' => 'Only offline',
+	'l2.only_off' => 'Only offline switches',
 	'l2.auto_refresh' => 'Auto refresh',
 	'l2.min' => 'min',
 	'l2.refresh_now' => 'Refresh now',
 	'l2.fit' => 'Fit to window',
 	'l2.alarms' => 'Alarms',
-	'l2.offline' => 'Offline terminals',
+	'l2.offline' => 'Offline switches',
 	'l2.time_live' => 'Live',
 	'l2.time_btn' => 'Go',
 	'l2.time_label' => 'Snapshot time',
 	'l2.no_data' => 'No snapshot yet — the collector has not run.',
-	'l2.offline_empty' => 'No offline terminals.',
+	'l2.offline_empty' => 'No offline switches.',
 	'l2.alarm_empty' => 'No active alarms.',
 	'l2.updated' => 'Updated',
 	'l2.pause' => 'Pause auto-refresh',
@@ -215,4 +215,11 @@ return [	'menu.title' => 'ROMAN Custom Center',
 	'l2.net_office' => 'OFFICE office',
 	'l2.net_other' => 'Other network',
 	'l2.floor_na' => 'Other',
+	'l2.terminals' => 'Terminals',
+	'l2.offline_short' => 'offline SW',
+	'l2.last_online' => 'last online',
+	'l2.history' => 'History',
+	'l2.history_live' => 'Live / latest',
+	'l2.history_live_hint' => 'Newest snapshot, auto refresh',
+	'l2.history_empty' => 'No history snapshots yet.',
 ];

+ 10 - 3
web-modules/WecomCenter/locale/fr_FR.php

@@ -180,18 +180,18 @@ return [
 	'l2.title' => 'L2 Physical Topology',
 	'l2.search_ph' => 'Search IP / MAC / name…',
 	'l2.show_term' => 'Show terminals',
-	'l2.only_off' => 'Only offline',
+	'l2.only_off' => 'Only offline switches',
 	'l2.auto_refresh' => 'Auto refresh',
 	'l2.min' => 'min',
 	'l2.refresh_now' => 'Refresh now',
 	'l2.fit' => 'Fit to window',
 	'l2.alarms' => 'Alarms',
-	'l2.offline' => 'Offline terminals',
+	'l2.offline' => 'Offline switches',
 	'l2.time_live' => 'Live',
 	'l2.time_btn' => 'Go',
 	'l2.time_label' => 'Snapshot time',
 	'l2.no_data' => 'No snapshot yet — the collector has not run.',
-	'l2.offline_empty' => 'No offline terminals.',
+	'l2.offline_empty' => 'No offline switches.',
 	'l2.alarm_empty' => 'No active alarms.',
 	'l2.updated' => 'Updated',
 	'l2.pause' => 'Pause auto-refresh',
@@ -225,4 +225,11 @@ return [
 	'l2.net_office' => 'OFFICE office',
 	'l2.net_other' => 'Other network',
 	'l2.floor_na' => 'Other',
+	'l2.terminals' => 'Terminals',
+	'l2.offline_short' => 'offline SW',
+	'l2.last_online' => 'last online',
+	'l2.history' => 'History',
+	'l2.history_live' => 'Live / latest',
+	'l2.history_live_hint' => 'Newest snapshot, auto refresh',
+	'l2.history_empty' => 'No history snapshots yet.',
 ];

+ 10 - 3
web-modules/WecomCenter/locale/he_IL.php

@@ -169,18 +169,18 @@ return [	'menu.title' => 'ROMAN Custom Center',
 	'l2.title' => 'L2 Physical Topology',
 	'l2.search_ph' => 'Search IP / MAC / name…',
 	'l2.show_term' => 'Show terminals',
-	'l2.only_off' => 'Only offline',
+	'l2.only_off' => 'Only offline switches',
 	'l2.auto_refresh' => 'Auto refresh',
 	'l2.min' => 'min',
 	'l2.refresh_now' => 'Refresh now',
 	'l2.fit' => 'Fit to window',
 	'l2.alarms' => 'Alarms',
-	'l2.offline' => 'Offline terminals',
+	'l2.offline' => 'Offline switches',
 	'l2.time_live' => 'Live',
 	'l2.time_btn' => 'Go',
 	'l2.time_label' => 'Snapshot time',
 	'l2.no_data' => 'No snapshot yet — the collector has not run.',
-	'l2.offline_empty' => 'No offline terminals.',
+	'l2.offline_empty' => 'No offline switches.',
 	'l2.alarm_empty' => 'No active alarms.',
 	'l2.updated' => 'Updated',
 	'l2.pause' => 'Pause auto-refresh',
@@ -215,4 +215,11 @@ return [	'menu.title' => 'ROMAN Custom Center',
 	'l2.net_office' => 'OFFICE office',
 	'l2.net_other' => 'Other network',
 	'l2.floor_na' => 'Other',
+	'l2.terminals' => 'Terminals',
+	'l2.offline_short' => 'offline SW',
+	'l2.last_online' => 'last online',
+	'l2.history' => 'History',
+	'l2.history_live' => 'Live / latest',
+	'l2.history_live_hint' => 'Newest snapshot, auto refresh',
+	'l2.history_empty' => 'No history snapshots yet.',
 ];

+ 10 - 3
web-modules/WecomCenter/locale/hu_HU.php

@@ -169,18 +169,18 @@ return [	'menu.title' => 'ROMAN Custom Center',
 	'l2.title' => 'L2 Physical Topology',
 	'l2.search_ph' => 'Search IP / MAC / name…',
 	'l2.show_term' => 'Show terminals',
-	'l2.only_off' => 'Only offline',
+	'l2.only_off' => 'Only offline switches',
 	'l2.auto_refresh' => 'Auto refresh',
 	'l2.min' => 'min',
 	'l2.refresh_now' => 'Refresh now',
 	'l2.fit' => 'Fit to window',
 	'l2.alarms' => 'Alarms',
-	'l2.offline' => 'Offline terminals',
+	'l2.offline' => 'Offline switches',
 	'l2.time_live' => 'Live',
 	'l2.time_btn' => 'Go',
 	'l2.time_label' => 'Snapshot time',
 	'l2.no_data' => 'No snapshot yet — the collector has not run.',
-	'l2.offline_empty' => 'No offline terminals.',
+	'l2.offline_empty' => 'No offline switches.',
 	'l2.alarm_empty' => 'No active alarms.',
 	'l2.updated' => 'Updated',
 	'l2.pause' => 'Pause auto-refresh',
@@ -215,4 +215,11 @@ return [	'menu.title' => 'ROMAN Custom Center',
 	'l2.net_office' => 'OFFICE office',
 	'l2.net_other' => 'Other network',
 	'l2.floor_na' => 'Other',
+	'l2.terminals' => 'Terminals',
+	'l2.offline_short' => 'offline SW',
+	'l2.last_online' => 'last online',
+	'l2.history' => 'History',
+	'l2.history_live' => 'Live / latest',
+	'l2.history_live_hint' => 'Newest snapshot, auto refresh',
+	'l2.history_empty' => 'No history snapshots yet.',
 ];

+ 10 - 3
web-modules/WecomCenter/locale/id_ID.php

@@ -169,18 +169,18 @@ return [	'menu.title' => 'ROMAN Custom Center',
 	'l2.title' => 'L2 Physical Topology',
 	'l2.search_ph' => 'Search IP / MAC / name…',
 	'l2.show_term' => 'Show terminals',
-	'l2.only_off' => 'Only offline',
+	'l2.only_off' => 'Only offline switches',
 	'l2.auto_refresh' => 'Auto refresh',
 	'l2.min' => 'min',
 	'l2.refresh_now' => 'Refresh now',
 	'l2.fit' => 'Fit to window',
 	'l2.alarms' => 'Alarms',
-	'l2.offline' => 'Offline terminals',
+	'l2.offline' => 'Offline switches',
 	'l2.time_live' => 'Live',
 	'l2.time_btn' => 'Go',
 	'l2.time_label' => 'Snapshot time',
 	'l2.no_data' => 'No snapshot yet — the collector has not run.',
-	'l2.offline_empty' => 'No offline terminals.',
+	'l2.offline_empty' => 'No offline switches.',
 	'l2.alarm_empty' => 'No active alarms.',
 	'l2.updated' => 'Updated',
 	'l2.pause' => 'Pause auto-refresh',
@@ -215,4 +215,11 @@ return [	'menu.title' => 'ROMAN Custom Center',
 	'l2.net_office' => 'OFFICE office',
 	'l2.net_other' => 'Other network',
 	'l2.floor_na' => 'Other',
+	'l2.terminals' => 'Terminals',
+	'l2.offline_short' => 'offline SW',
+	'l2.last_online' => 'last online',
+	'l2.history' => 'History',
+	'l2.history_live' => 'Live / latest',
+	'l2.history_live_hint' => 'Newest snapshot, auto refresh',
+	'l2.history_empty' => 'No history snapshots yet.',
 ];

+ 10 - 3
web-modules/WecomCenter/locale/it_IT.php

@@ -180,18 +180,18 @@ return [
 	'l2.title' => 'L2 Physical Topology',
 	'l2.search_ph' => 'Search IP / MAC / name…',
 	'l2.show_term' => 'Show terminals',
-	'l2.only_off' => 'Only offline',
+	'l2.only_off' => 'Only offline switches',
 	'l2.auto_refresh' => 'Auto refresh',
 	'l2.min' => 'min',
 	'l2.refresh_now' => 'Refresh now',
 	'l2.fit' => 'Fit to window',
 	'l2.alarms' => 'Alarms',
-	'l2.offline' => 'Offline terminals',
+	'l2.offline' => 'Offline switches',
 	'l2.time_live' => 'Live',
 	'l2.time_btn' => 'Go',
 	'l2.time_label' => 'Snapshot time',
 	'l2.no_data' => 'No snapshot yet — the collector has not run.',
-	'l2.offline_empty' => 'No offline terminals.',
+	'l2.offline_empty' => 'No offline switches.',
 	'l2.alarm_empty' => 'No active alarms.',
 	'l2.updated' => 'Updated',
 	'l2.pause' => 'Pause auto-refresh',
@@ -225,4 +225,11 @@ return [
 	'l2.net_office' => 'OFFICE office',
 	'l2.net_other' => 'Other network',
 	'l2.floor_na' => 'Other',
+	'l2.terminals' => 'Terminals',
+	'l2.offline_short' => 'offline SW',
+	'l2.last_online' => 'last online',
+	'l2.history' => 'History',
+	'l2.history_live' => 'Live / latest',
+	'l2.history_live_hint' => 'Newest snapshot, auto refresh',
+	'l2.history_empty' => 'No history snapshots yet.',
 ];

+ 10 - 3
web-modules/WecomCenter/locale/ja_JP.php

@@ -180,18 +180,18 @@ return [
 	'l2.title' => 'L2 Physical Topology',
 	'l2.search_ph' => 'Search IP / MAC / name…',
 	'l2.show_term' => 'Show terminals',
-	'l2.only_off' => 'Only offline',
+	'l2.only_off' => 'Only offline switches',
 	'l2.auto_refresh' => 'Auto refresh',
 	'l2.min' => 'min',
 	'l2.refresh_now' => 'Refresh now',
 	'l2.fit' => 'Fit to window',
 	'l2.alarms' => 'Alarms',
-	'l2.offline' => 'Offline terminals',
+	'l2.offline' => 'Offline switches',
 	'l2.time_live' => 'Live',
 	'l2.time_btn' => 'Go',
 	'l2.time_label' => 'Snapshot time',
 	'l2.no_data' => 'No snapshot yet — the collector has not run.',
-	'l2.offline_empty' => 'No offline terminals.',
+	'l2.offline_empty' => 'No offline switches.',
 	'l2.alarm_empty' => 'No active alarms.',
 	'l2.updated' => 'Updated',
 	'l2.pause' => 'Pause auto-refresh',
@@ -225,4 +225,11 @@ return [
 	'l2.net_office' => 'OFFICE office',
 	'l2.net_other' => 'Other network',
 	'l2.floor_na' => 'Other',
+	'l2.terminals' => 'Terminals',
+	'l2.offline_short' => 'offline SW',
+	'l2.last_online' => 'last online',
+	'l2.history' => 'History',
+	'l2.history_live' => 'Live / latest',
+	'l2.history_live_hint' => 'Newest snapshot, auto refresh',
+	'l2.history_empty' => 'No history snapshots yet.',
 ];

+ 10 - 3
web-modules/WecomCenter/locale/ka_GE.php

@@ -169,18 +169,18 @@ return [	'menu.title' => 'ROMAN Custom Center',
 	'l2.title' => 'L2 Physical Topology',
 	'l2.search_ph' => 'Search IP / MAC / name…',
 	'l2.show_term' => 'Show terminals',
-	'l2.only_off' => 'Only offline',
+	'l2.only_off' => 'Only offline switches',
 	'l2.auto_refresh' => 'Auto refresh',
 	'l2.min' => 'min',
 	'l2.refresh_now' => 'Refresh now',
 	'l2.fit' => 'Fit to window',
 	'l2.alarms' => 'Alarms',
-	'l2.offline' => 'Offline terminals',
+	'l2.offline' => 'Offline switches',
 	'l2.time_live' => 'Live',
 	'l2.time_btn' => 'Go',
 	'l2.time_label' => 'Snapshot time',
 	'l2.no_data' => 'No snapshot yet — the collector has not run.',
-	'l2.offline_empty' => 'No offline terminals.',
+	'l2.offline_empty' => 'No offline switches.',
 	'l2.alarm_empty' => 'No active alarms.',
 	'l2.updated' => 'Updated',
 	'l2.pause' => 'Pause auto-refresh',
@@ -215,4 +215,11 @@ return [	'menu.title' => 'ROMAN Custom Center',
 	'l2.net_office' => 'OFFICE office',
 	'l2.net_other' => 'Other network',
 	'l2.floor_na' => 'Other',
+	'l2.terminals' => 'Terminals',
+	'l2.offline_short' => 'offline SW',
+	'l2.last_online' => 'last online',
+	'l2.history' => 'History',
+	'l2.history_live' => 'Live / latest',
+	'l2.history_live_hint' => 'Newest snapshot, auto refresh',
+	'l2.history_empty' => 'No history snapshots yet.',
 ];

+ 10 - 3
web-modules/WecomCenter/locale/ko_KR.php

@@ -180,18 +180,18 @@ return [
 	'l2.title' => 'L2 Physical Topology',
 	'l2.search_ph' => 'Search IP / MAC / name…',
 	'l2.show_term' => 'Show terminals',
-	'l2.only_off' => 'Only offline',
+	'l2.only_off' => 'Only offline switches',
 	'l2.auto_refresh' => 'Auto refresh',
 	'l2.min' => 'min',
 	'l2.refresh_now' => 'Refresh now',
 	'l2.fit' => 'Fit to window',
 	'l2.alarms' => 'Alarms',
-	'l2.offline' => 'Offline terminals',
+	'l2.offline' => 'Offline switches',
 	'l2.time_live' => 'Live',
 	'l2.time_btn' => 'Go',
 	'l2.time_label' => 'Snapshot time',
 	'l2.no_data' => 'No snapshot yet — the collector has not run.',
-	'l2.offline_empty' => 'No offline terminals.',
+	'l2.offline_empty' => 'No offline switches.',
 	'l2.alarm_empty' => 'No active alarms.',
 	'l2.updated' => 'Updated',
 	'l2.pause' => 'Pause auto-refresh',
@@ -225,4 +225,11 @@ return [
 	'l2.net_office' => 'OFFICE office',
 	'l2.net_other' => 'Other network',
 	'l2.floor_na' => 'Other',
+	'l2.terminals' => 'Terminals',
+	'l2.offline_short' => 'offline SW',
+	'l2.last_online' => 'last online',
+	'l2.history' => 'History',
+	'l2.history_live' => 'Live / latest',
+	'l2.history_live_hint' => 'Newest snapshot, auto refresh',
+	'l2.history_empty' => 'No history snapshots yet.',
 ];

+ 10 - 3
web-modules/WecomCenter/locale/lt_LT.php

@@ -169,18 +169,18 @@ return [	'menu.title' => 'ROMAN Custom Center',
 	'l2.title' => 'L2 Physical Topology',
 	'l2.search_ph' => 'Search IP / MAC / name…',
 	'l2.show_term' => 'Show terminals',
-	'l2.only_off' => 'Only offline',
+	'l2.only_off' => 'Only offline switches',
 	'l2.auto_refresh' => 'Auto refresh',
 	'l2.min' => 'min',
 	'l2.refresh_now' => 'Refresh now',
 	'l2.fit' => 'Fit to window',
 	'l2.alarms' => 'Alarms',
-	'l2.offline' => 'Offline terminals',
+	'l2.offline' => 'Offline switches',
 	'l2.time_live' => 'Live',
 	'l2.time_btn' => 'Go',
 	'l2.time_label' => 'Snapshot time',
 	'l2.no_data' => 'No snapshot yet — the collector has not run.',
-	'l2.offline_empty' => 'No offline terminals.',
+	'l2.offline_empty' => 'No offline switches.',
 	'l2.alarm_empty' => 'No active alarms.',
 	'l2.updated' => 'Updated',
 	'l2.pause' => 'Pause auto-refresh',
@@ -215,4 +215,11 @@ return [	'menu.title' => 'ROMAN Custom Center',
 	'l2.net_office' => 'OFFICE office',
 	'l2.net_other' => 'Other network',
 	'l2.floor_na' => 'Other',
+	'l2.terminals' => 'Terminals',
+	'l2.offline_short' => 'offline SW',
+	'l2.last_online' => 'last online',
+	'l2.history' => 'History',
+	'l2.history_live' => 'Live / latest',
+	'l2.history_live_hint' => 'Newest snapshot, auto refresh',
+	'l2.history_empty' => 'No history snapshots yet.',
 ];

+ 10 - 3
web-modules/WecomCenter/locale/lv_LV.php

@@ -169,18 +169,18 @@ return [	'menu.title' => 'ROMAN Custom Center',
 	'l2.title' => 'L2 Physical Topology',
 	'l2.search_ph' => 'Search IP / MAC / name…',
 	'l2.show_term' => 'Show terminals',
-	'l2.only_off' => 'Only offline',
+	'l2.only_off' => 'Only offline switches',
 	'l2.auto_refresh' => 'Auto refresh',
 	'l2.min' => 'min',
 	'l2.refresh_now' => 'Refresh now',
 	'l2.fit' => 'Fit to window',
 	'l2.alarms' => 'Alarms',
-	'l2.offline' => 'Offline terminals',
+	'l2.offline' => 'Offline switches',
 	'l2.time_live' => 'Live',
 	'l2.time_btn' => 'Go',
 	'l2.time_label' => 'Snapshot time',
 	'l2.no_data' => 'No snapshot yet — the collector has not run.',
-	'l2.offline_empty' => 'No offline terminals.',
+	'l2.offline_empty' => 'No offline switches.',
 	'l2.alarm_empty' => 'No active alarms.',
 	'l2.updated' => 'Updated',
 	'l2.pause' => 'Pause auto-refresh',
@@ -215,4 +215,11 @@ return [	'menu.title' => 'ROMAN Custom Center',
 	'l2.net_office' => 'OFFICE office',
 	'l2.net_other' => 'Other network',
 	'l2.floor_na' => 'Other',
+	'l2.terminals' => 'Terminals',
+	'l2.offline_short' => 'offline SW',
+	'l2.last_online' => 'last online',
+	'l2.history' => 'History',
+	'l2.history_live' => 'Live / latest',
+	'l2.history_live_hint' => 'Newest snapshot, auto refresh',
+	'l2.history_empty' => 'No history snapshots yet.',
 ];

+ 10 - 3
web-modules/WecomCenter/locale/nb_NO.php

@@ -169,18 +169,18 @@ return [	'menu.title' => 'ROMAN Custom Center',
 	'l2.title' => 'L2 Physical Topology',
 	'l2.search_ph' => 'Search IP / MAC / name…',
 	'l2.show_term' => 'Show terminals',
-	'l2.only_off' => 'Only offline',
+	'l2.only_off' => 'Only offline switches',
 	'l2.auto_refresh' => 'Auto refresh',
 	'l2.min' => 'min',
 	'l2.refresh_now' => 'Refresh now',
 	'l2.fit' => 'Fit to window',
 	'l2.alarms' => 'Alarms',
-	'l2.offline' => 'Offline terminals',
+	'l2.offline' => 'Offline switches',
 	'l2.time_live' => 'Live',
 	'l2.time_btn' => 'Go',
 	'l2.time_label' => 'Snapshot time',
 	'l2.no_data' => 'No snapshot yet — the collector has not run.',
-	'l2.offline_empty' => 'No offline terminals.',
+	'l2.offline_empty' => 'No offline switches.',
 	'l2.alarm_empty' => 'No active alarms.',
 	'l2.updated' => 'Updated',
 	'l2.pause' => 'Pause auto-refresh',
@@ -215,4 +215,11 @@ return [	'menu.title' => 'ROMAN Custom Center',
 	'l2.net_office' => 'OFFICE office',
 	'l2.net_other' => 'Other network',
 	'l2.floor_na' => 'Other',
+	'l2.terminals' => 'Terminals',
+	'l2.offline_short' => 'offline SW',
+	'l2.last_online' => 'last online',
+	'l2.history' => 'History',
+	'l2.history_live' => 'Live / latest',
+	'l2.history_live_hint' => 'Newest snapshot, auto refresh',
+	'l2.history_empty' => 'No history snapshots yet.',
 ];

+ 10 - 3
web-modules/WecomCenter/locale/nl_NL.php

@@ -169,18 +169,18 @@ return [	'menu.title' => 'ROMAN Custom Center',
 	'l2.title' => 'L2 Physical Topology',
 	'l2.search_ph' => 'Search IP / MAC / name…',
 	'l2.show_term' => 'Show terminals',
-	'l2.only_off' => 'Only offline',
+	'l2.only_off' => 'Only offline switches',
 	'l2.auto_refresh' => 'Auto refresh',
 	'l2.min' => 'min',
 	'l2.refresh_now' => 'Refresh now',
 	'l2.fit' => 'Fit to window',
 	'l2.alarms' => 'Alarms',
-	'l2.offline' => 'Offline terminals',
+	'l2.offline' => 'Offline switches',
 	'l2.time_live' => 'Live',
 	'l2.time_btn' => 'Go',
 	'l2.time_label' => 'Snapshot time',
 	'l2.no_data' => 'No snapshot yet — the collector has not run.',
-	'l2.offline_empty' => 'No offline terminals.',
+	'l2.offline_empty' => 'No offline switches.',
 	'l2.alarm_empty' => 'No active alarms.',
 	'l2.updated' => 'Updated',
 	'l2.pause' => 'Pause auto-refresh',
@@ -215,4 +215,11 @@ return [	'menu.title' => 'ROMAN Custom Center',
 	'l2.net_office' => 'OFFICE office',
 	'l2.net_other' => 'Other network',
 	'l2.floor_na' => 'Other',
+	'l2.terminals' => 'Terminals',
+	'l2.offline_short' => 'offline SW',
+	'l2.last_online' => 'last online',
+	'l2.history' => 'History',
+	'l2.history_live' => 'Live / latest',
+	'l2.history_live_hint' => 'Newest snapshot, auto refresh',
+	'l2.history_empty' => 'No history snapshots yet.',
 ];

+ 10 - 3
web-modules/WecomCenter/locale/pl_PL.php

@@ -180,18 +180,18 @@ return [
 	'l2.title' => 'L2 Physical Topology',
 	'l2.search_ph' => 'Search IP / MAC / name…',
 	'l2.show_term' => 'Show terminals',
-	'l2.only_off' => 'Only offline',
+	'l2.only_off' => 'Only offline switches',
 	'l2.auto_refresh' => 'Auto refresh',
 	'l2.min' => 'min',
 	'l2.refresh_now' => 'Refresh now',
 	'l2.fit' => 'Fit to window',
 	'l2.alarms' => 'Alarms',
-	'l2.offline' => 'Offline terminals',
+	'l2.offline' => 'Offline switches',
 	'l2.time_live' => 'Live',
 	'l2.time_btn' => 'Go',
 	'l2.time_label' => 'Snapshot time',
 	'l2.no_data' => 'No snapshot yet — the collector has not run.',
-	'l2.offline_empty' => 'No offline terminals.',
+	'l2.offline_empty' => 'No offline switches.',
 	'l2.alarm_empty' => 'No active alarms.',
 	'l2.updated' => 'Updated',
 	'l2.pause' => 'Pause auto-refresh',
@@ -225,4 +225,11 @@ return [
 	'l2.net_office' => 'OFFICE office',
 	'l2.net_other' => 'Other network',
 	'l2.floor_na' => 'Other',
+	'l2.terminals' => 'Terminals',
+	'l2.offline_short' => 'offline SW',
+	'l2.last_online' => 'last online',
+	'l2.history' => 'History',
+	'l2.history_live' => 'Live / latest',
+	'l2.history_live_hint' => 'Newest snapshot, auto refresh',
+	'l2.history_empty' => 'No history snapshots yet.',
 ];

+ 10 - 3
web-modules/WecomCenter/locale/pt_BR.php

@@ -180,18 +180,18 @@ return [
 	'l2.title' => 'L2 Physical Topology',
 	'l2.search_ph' => 'Search IP / MAC / name…',
 	'l2.show_term' => 'Show terminals',
-	'l2.only_off' => 'Only offline',
+	'l2.only_off' => 'Only offline switches',
 	'l2.auto_refresh' => 'Auto refresh',
 	'l2.min' => 'min',
 	'l2.refresh_now' => 'Refresh now',
 	'l2.fit' => 'Fit to window',
 	'l2.alarms' => 'Alarms',
-	'l2.offline' => 'Offline terminals',
+	'l2.offline' => 'Offline switches',
 	'l2.time_live' => 'Live',
 	'l2.time_btn' => 'Go',
 	'l2.time_label' => 'Snapshot time',
 	'l2.no_data' => 'No snapshot yet — the collector has not run.',
-	'l2.offline_empty' => 'No offline terminals.',
+	'l2.offline_empty' => 'No offline switches.',
 	'l2.alarm_empty' => 'No active alarms.',
 	'l2.updated' => 'Updated',
 	'l2.pause' => 'Pause auto-refresh',
@@ -225,4 +225,11 @@ return [
 	'l2.net_office' => 'OFFICE office',
 	'l2.net_other' => 'Other network',
 	'l2.floor_na' => 'Other',
+	'l2.terminals' => 'Terminals',
+	'l2.offline_short' => 'offline SW',
+	'l2.last_online' => 'last online',
+	'l2.history' => 'History',
+	'l2.history_live' => 'Live / latest',
+	'l2.history_live_hint' => 'Newest snapshot, auto refresh',
+	'l2.history_empty' => 'No history snapshots yet.',
 ];

+ 10 - 3
web-modules/WecomCenter/locale/ro_RO.php

@@ -169,18 +169,18 @@ return [	'menu.title' => 'ROMAN Custom Center',
 	'l2.title' => 'L2 Physical Topology',
 	'l2.search_ph' => 'Search IP / MAC / name…',
 	'l2.show_term' => 'Show terminals',
-	'l2.only_off' => 'Only offline',
+	'l2.only_off' => 'Only offline switches',
 	'l2.auto_refresh' => 'Auto refresh',
 	'l2.min' => 'min',
 	'l2.refresh_now' => 'Refresh now',
 	'l2.fit' => 'Fit to window',
 	'l2.alarms' => 'Alarms',
-	'l2.offline' => 'Offline terminals',
+	'l2.offline' => 'Offline switches',
 	'l2.time_live' => 'Live',
 	'l2.time_btn' => 'Go',
 	'l2.time_label' => 'Snapshot time',
 	'l2.no_data' => 'No snapshot yet — the collector has not run.',
-	'l2.offline_empty' => 'No offline terminals.',
+	'l2.offline_empty' => 'No offline switches.',
 	'l2.alarm_empty' => 'No active alarms.',
 	'l2.updated' => 'Updated',
 	'l2.pause' => 'Pause auto-refresh',
@@ -215,4 +215,11 @@ return [	'menu.title' => 'ROMAN Custom Center',
 	'l2.net_office' => 'OFFICE office',
 	'l2.net_other' => 'Other network',
 	'l2.floor_na' => 'Other',
+	'l2.terminals' => 'Terminals',
+	'l2.offline_short' => 'offline SW',
+	'l2.last_online' => 'last online',
+	'l2.history' => 'History',
+	'l2.history_live' => 'Live / latest',
+	'l2.history_live_hint' => 'Newest snapshot, auto refresh',
+	'l2.history_empty' => 'No history snapshots yet.',
 ];

+ 10 - 3
web-modules/WecomCenter/locale/ru_RU.php

@@ -180,18 +180,18 @@ return [
 	'l2.title' => 'L2 Physical Topology',
 	'l2.search_ph' => 'Search IP / MAC / name…',
 	'l2.show_term' => 'Show terminals',
-	'l2.only_off' => 'Only offline',
+	'l2.only_off' => 'Only offline switches',
 	'l2.auto_refresh' => 'Auto refresh',
 	'l2.min' => 'min',
 	'l2.refresh_now' => 'Refresh now',
 	'l2.fit' => 'Fit to window',
 	'l2.alarms' => 'Alarms',
-	'l2.offline' => 'Offline terminals',
+	'l2.offline' => 'Offline switches',
 	'l2.time_live' => 'Live',
 	'l2.time_btn' => 'Go',
 	'l2.time_label' => 'Snapshot time',
 	'l2.no_data' => 'No snapshot yet — the collector has not run.',
-	'l2.offline_empty' => 'No offline terminals.',
+	'l2.offline_empty' => 'No offline switches.',
 	'l2.alarm_empty' => 'No active alarms.',
 	'l2.updated' => 'Updated',
 	'l2.pause' => 'Pause auto-refresh',
@@ -225,4 +225,11 @@ return [
 	'l2.net_office' => 'OFFICE office',
 	'l2.net_other' => 'Other network',
 	'l2.floor_na' => 'Other',
+	'l2.terminals' => 'Terminals',
+	'l2.offline_short' => 'offline SW',
+	'l2.last_online' => 'last online',
+	'l2.history' => 'History',
+	'l2.history_live' => 'Live / latest',
+	'l2.history_live_hint' => 'Newest snapshot, auto refresh',
+	'l2.history_empty' => 'No history snapshots yet.',
 ];

+ 10 - 3
web-modules/WecomCenter/locale/sk_SK.php

@@ -169,18 +169,18 @@ return [	'menu.title' => 'ROMAN Custom Center',
 	'l2.title' => 'L2 Physical Topology',
 	'l2.search_ph' => 'Search IP / MAC / name…',
 	'l2.show_term' => 'Show terminals',
-	'l2.only_off' => 'Only offline',
+	'l2.only_off' => 'Only offline switches',
 	'l2.auto_refresh' => 'Auto refresh',
 	'l2.min' => 'min',
 	'l2.refresh_now' => 'Refresh now',
 	'l2.fit' => 'Fit to window',
 	'l2.alarms' => 'Alarms',
-	'l2.offline' => 'Offline terminals',
+	'l2.offline' => 'Offline switches',
 	'l2.time_live' => 'Live',
 	'l2.time_btn' => 'Go',
 	'l2.time_label' => 'Snapshot time',
 	'l2.no_data' => 'No snapshot yet — the collector has not run.',
-	'l2.offline_empty' => 'No offline terminals.',
+	'l2.offline_empty' => 'No offline switches.',
 	'l2.alarm_empty' => 'No active alarms.',
 	'l2.updated' => 'Updated',
 	'l2.pause' => 'Pause auto-refresh',
@@ -215,4 +215,11 @@ return [	'menu.title' => 'ROMAN Custom Center',
 	'l2.net_office' => 'OFFICE office',
 	'l2.net_other' => 'Other network',
 	'l2.floor_na' => 'Other',
+	'l2.terminals' => 'Terminals',
+	'l2.offline_short' => 'offline SW',
+	'l2.last_online' => 'last online',
+	'l2.history' => 'History',
+	'l2.history_live' => 'Live / latest',
+	'l2.history_live_hint' => 'Newest snapshot, auto refresh',
+	'l2.history_empty' => 'No history snapshots yet.',
 ];

+ 10 - 3
web-modules/WecomCenter/locale/sv_SE.php

@@ -169,18 +169,18 @@ return [	'menu.title' => 'ROMAN Custom Center',
 	'l2.title' => 'L2 Physical Topology',
 	'l2.search_ph' => 'Search IP / MAC / name…',
 	'l2.show_term' => 'Show terminals',
-	'l2.only_off' => 'Only offline',
+	'l2.only_off' => 'Only offline switches',
 	'l2.auto_refresh' => 'Auto refresh',
 	'l2.min' => 'min',
 	'l2.refresh_now' => 'Refresh now',
 	'l2.fit' => 'Fit to window',
 	'l2.alarms' => 'Alarms',
-	'l2.offline' => 'Offline terminals',
+	'l2.offline' => 'Offline switches',
 	'l2.time_live' => 'Live',
 	'l2.time_btn' => 'Go',
 	'l2.time_label' => 'Snapshot time',
 	'l2.no_data' => 'No snapshot yet — the collector has not run.',
-	'l2.offline_empty' => 'No offline terminals.',
+	'l2.offline_empty' => 'No offline switches.',
 	'l2.alarm_empty' => 'No active alarms.',
 	'l2.updated' => 'Updated',
 	'l2.pause' => 'Pause auto-refresh',
@@ -215,4 +215,11 @@ return [	'menu.title' => 'ROMAN Custom Center',
 	'l2.net_office' => 'OFFICE office',
 	'l2.net_other' => 'Other network',
 	'l2.floor_na' => 'Other',
+	'l2.terminals' => 'Terminals',
+	'l2.offline_short' => 'offline SW',
+	'l2.last_online' => 'last online',
+	'l2.history' => 'History',
+	'l2.history_live' => 'Live / latest',
+	'l2.history_live_hint' => 'Newest snapshot, auto refresh',
+	'l2.history_empty' => 'No history snapshots yet.',
 ];

+ 10 - 3
web-modules/WecomCenter/locale/tr_TR.php

@@ -180,18 +180,18 @@ return [
 	'l2.title' => 'L2 Physical Topology',
 	'l2.search_ph' => 'Search IP / MAC / name…',
 	'l2.show_term' => 'Show terminals',
-	'l2.only_off' => 'Only offline',
+	'l2.only_off' => 'Only offline switches',
 	'l2.auto_refresh' => 'Auto refresh',
 	'l2.min' => 'min',
 	'l2.refresh_now' => 'Refresh now',
 	'l2.fit' => 'Fit to window',
 	'l2.alarms' => 'Alarms',
-	'l2.offline' => 'Offline terminals',
+	'l2.offline' => 'Offline switches',
 	'l2.time_live' => 'Live',
 	'l2.time_btn' => 'Go',
 	'l2.time_label' => 'Snapshot time',
 	'l2.no_data' => 'No snapshot yet — the collector has not run.',
-	'l2.offline_empty' => 'No offline terminals.',
+	'l2.offline_empty' => 'No offline switches.',
 	'l2.alarm_empty' => 'No active alarms.',
 	'l2.updated' => 'Updated',
 	'l2.pause' => 'Pause auto-refresh',
@@ -225,4 +225,11 @@ return [
 	'l2.net_office' => 'OFFICE office',
 	'l2.net_other' => 'Other network',
 	'l2.floor_na' => 'Other',
+	'l2.terminals' => 'Terminals',
+	'l2.offline_short' => 'offline SW',
+	'l2.last_online' => 'last online',
+	'l2.history' => 'History',
+	'l2.history_live' => 'Live / latest',
+	'l2.history_live_hint' => 'Newest snapshot, auto refresh',
+	'l2.history_empty' => 'No history snapshots yet.',
 ];

+ 10 - 3
web-modules/WecomCenter/locale/uk_UA.php

@@ -169,18 +169,18 @@ return [	'menu.title' => 'ROMAN Custom Center',
 	'l2.title' => 'L2 Physical Topology',
 	'l2.search_ph' => 'Search IP / MAC / name…',
 	'l2.show_term' => 'Show terminals',
-	'l2.only_off' => 'Only offline',
+	'l2.only_off' => 'Only offline switches',
 	'l2.auto_refresh' => 'Auto refresh',
 	'l2.min' => 'min',
 	'l2.refresh_now' => 'Refresh now',
 	'l2.fit' => 'Fit to window',
 	'l2.alarms' => 'Alarms',
-	'l2.offline' => 'Offline terminals',
+	'l2.offline' => 'Offline switches',
 	'l2.time_live' => 'Live',
 	'l2.time_btn' => 'Go',
 	'l2.time_label' => 'Snapshot time',
 	'l2.no_data' => 'No snapshot yet — the collector has not run.',
-	'l2.offline_empty' => 'No offline terminals.',
+	'l2.offline_empty' => 'No offline switches.',
 	'l2.alarm_empty' => 'No active alarms.',
 	'l2.updated' => 'Updated',
 	'l2.pause' => 'Pause auto-refresh',
@@ -215,4 +215,11 @@ return [	'menu.title' => 'ROMAN Custom Center',
 	'l2.net_office' => 'OFFICE office',
 	'l2.net_other' => 'Other network',
 	'l2.floor_na' => 'Other',
+	'l2.terminals' => 'Terminals',
+	'l2.offline_short' => 'offline SW',
+	'l2.last_online' => 'last online',
+	'l2.history' => 'History',
+	'l2.history_live' => 'Live / latest',
+	'l2.history_live_hint' => 'Newest snapshot, auto refresh',
+	'l2.history_empty' => 'No history snapshots yet.',
 ];

+ 10 - 3
web-modules/WecomCenter/locale/uz_UZ.php

@@ -169,18 +169,18 @@ return [	'menu.title' => 'ROMAN Custom Center',
 	'l2.title' => 'L2 Physical Topology',
 	'l2.search_ph' => 'Search IP / MAC / name…',
 	'l2.show_term' => 'Show terminals',
-	'l2.only_off' => 'Only offline',
+	'l2.only_off' => 'Only offline switches',
 	'l2.auto_refresh' => 'Auto refresh',
 	'l2.min' => 'min',
 	'l2.refresh_now' => 'Refresh now',
 	'l2.fit' => 'Fit to window',
 	'l2.alarms' => 'Alarms',
-	'l2.offline' => 'Offline terminals',
+	'l2.offline' => 'Offline switches',
 	'l2.time_live' => 'Live',
 	'l2.time_btn' => 'Go',
 	'l2.time_label' => 'Snapshot time',
 	'l2.no_data' => 'No snapshot yet — the collector has not run.',
-	'l2.offline_empty' => 'No offline terminals.',
+	'l2.offline_empty' => 'No offline switches.',
 	'l2.alarm_empty' => 'No active alarms.',
 	'l2.updated' => 'Updated',
 	'l2.pause' => 'Pause auto-refresh',
@@ -215,4 +215,11 @@ return [	'menu.title' => 'ROMAN Custom Center',
 	'l2.net_office' => 'OFFICE office',
 	'l2.net_other' => 'Other network',
 	'l2.floor_na' => 'Other',
+	'l2.terminals' => 'Terminals',
+	'l2.offline_short' => 'offline SW',
+	'l2.last_online' => 'last online',
+	'l2.history' => 'History',
+	'l2.history_live' => 'Live / latest',
+	'l2.history_live_hint' => 'Newest snapshot, auto refresh',
+	'l2.history_empty' => 'No history snapshots yet.',
 ];

+ 10 - 3
web-modules/WecomCenter/locale/vi_VN.php

@@ -169,18 +169,18 @@ return [	'menu.title' => 'ROMAN Custom Center',
 	'l2.title' => 'L2 Physical Topology',
 	'l2.search_ph' => 'Search IP / MAC / name…',
 	'l2.show_term' => 'Show terminals',
-	'l2.only_off' => 'Only offline',
+	'l2.only_off' => 'Only offline switches',
 	'l2.auto_refresh' => 'Auto refresh',
 	'l2.min' => 'min',
 	'l2.refresh_now' => 'Refresh now',
 	'l2.fit' => 'Fit to window',
 	'l2.alarms' => 'Alarms',
-	'l2.offline' => 'Offline terminals',
+	'l2.offline' => 'Offline switches',
 	'l2.time_live' => 'Live',
 	'l2.time_btn' => 'Go',
 	'l2.time_label' => 'Snapshot time',
 	'l2.no_data' => 'No snapshot yet — the collector has not run.',
-	'l2.offline_empty' => 'No offline terminals.',
+	'l2.offline_empty' => 'No offline switches.',
 	'l2.alarm_empty' => 'No active alarms.',
 	'l2.updated' => 'Updated',
 	'l2.pause' => 'Pause auto-refresh',
@@ -215,4 +215,11 @@ return [	'menu.title' => 'ROMAN Custom Center',
 	'l2.net_office' => 'OFFICE office',
 	'l2.net_other' => 'Other network',
 	'l2.floor_na' => 'Other',
+	'l2.terminals' => 'Terminals',
+	'l2.offline_short' => 'offline SW',
+	'l2.last_online' => 'last online',
+	'l2.history' => 'History',
+	'l2.history_live' => 'Live / latest',
+	'l2.history_live_hint' => 'Newest snapshot, auto refresh',
+	'l2.history_empty' => 'No history snapshots yet.',
 ];

+ 10 - 3
web-modules/WecomCenter/locale/zh_CN.php

@@ -197,18 +197,18 @@ return [
 	'l2.floor_na'        => '其他',
 	'l2.search_ph'       => '搜索 IP / MAC / 名称…',
 	'l2.show_term'       => '显示终端',
-	'l2.only_off'        => '只看离线',
+	'l2.only_off'        => '只看离线交换机',
 	'l2.auto_refresh'    => '自动刷新',
 	'l2.min'             => '分钟',
 	'l2.refresh_now'     => '立即刷新',
 	'l2.fit'             => '适应窗口',
 	'l2.alarms'          => '告警',
-	'l2.offline'         => '离线终端',
+	'l2.offline'         => '离线交换机',
 	'l2.time_live'       => '实时',
 	'l2.time_btn'        => '查看',
 	'l2.time_label'      => '快照时间',
 	'l2.no_data'         => '暂无快照——采集尚未运行。',
-	'l2.offline_empty'   => '没有离线终端。',
+	'l2.offline_empty'   => '没有离线交换机。',
 	'l2.alarm_empty'     => '当前没有活动告警。',
 	'l2.updated'         => '更新时间',
 	'l2.pause'           => '暂停自动刷新',
@@ -234,4 +234,11 @@ return [
 	'l2.snapshot'        => '快照',
 	'l2.showing'         => '当前',
 	'l2.live_tag'        => '实时',
+	'l2.terminals' => '终端',
+	'l2.offline_short' => '离线交换机',
+	'l2.last_online' => '最后在线',
+	'l2.history' => '历史快照',
+	'l2.history_live' => '实时 / 最新',
+	'l2.history_live_hint' => '最新快照,自动刷新',
+	'l2.history_empty' => '暂无历史快照。',
 ];

+ 10 - 3
web-modules/WecomCenter/locale/zh_TW.php

@@ -180,18 +180,18 @@ return [
 	'l2.title' => 'L2 Physical Topology',
 	'l2.search_ph' => 'Search IP / MAC / name…',
 	'l2.show_term' => 'Show terminals',
-	'l2.only_off' => 'Only offline',
+	'l2.only_off' => '只看離線交換機',
 	'l2.auto_refresh' => 'Auto refresh',
 	'l2.min' => 'min',
 	'l2.refresh_now' => 'Refresh now',
 	'l2.fit' => 'Fit to window',
 	'l2.alarms' => 'Alarms',
-	'l2.offline' => 'Offline terminals',
+	'l2.offline' => '離線交換機',
 	'l2.time_live' => 'Live',
 	'l2.time_btn' => 'Go',
 	'l2.time_label' => 'Snapshot time',
 	'l2.no_data' => 'No snapshot yet — the collector has not run.',
-	'l2.offline_empty' => 'No offline terminals.',
+	'l2.offline_empty' => '沒有離線交換機。',
 	'l2.alarm_empty' => 'No active alarms.',
 	'l2.updated' => 'Updated',
 	'l2.pause' => 'Pause auto-refresh',
@@ -225,4 +225,11 @@ return [
 	'l2.net_office' => 'OFFICE office',
 	'l2.net_other' => 'Other network',
 	'l2.floor_na' => 'Other',
+	'l2.terminals' => '終端',
+	'l2.offline_short' => '離線交換機',
+	'l2.last_online' => '最後上線',
+	'l2.history' => '歷史快照',
+	'l2.history_live' => '即時 / 最新',
+	'l2.history_live_hint' => '最新快照,自動重新整理',
+	'l2.history_empty' => '尚無歷史快照。',
 ];

+ 8 - 4
web-modules/WecomCenter/views/topology.view.php

@@ -6,7 +6,7 @@
 */
 use Modules\WecomCenter\Lib\I18n;
 
-$this->addCssFile('modules/WecomCenter/assets/l2topology/style.css?v=20261106');
+$this->addCssFile('modules/WecomCenter/assets/l2topology/style.css?v=20261108');
 $base = '/modules/WecomCenter/assets/l2topology/';
 
 $snapJson = (string) ($data['snap_json'] ?? 'null');
@@ -21,7 +21,9 @@ $i18nKeys = [
 	'l2.pause','l2.resume','l2.legend_speed','l2.legend_tier','l2.legend_lat','l2.core','l2.main','l2.dist','l2.access',
 	'l2.lat_norm','l2.term_on','l2.term_off','l2.term_title','l2.term_mac','l2.term_rate','l2.term_switch','l2.term_port','l2.term_ip','l2.zoom_in','l2.zoom_out','l2.snapshot','l2.showing','l2.live_tag',
 	'l2.unit_sw','l2.tile_hint','l2.not_in_topo','l2.click_hint','l2.floor_na',
-	'l2.net_surve','l2.net_office','l2.net_other'
+	'l2.net_surve','l2.net_office','l2.net_other',
+	'l2.terminals','l2.offline_short','l2.last_online','l2.history','l2.history_live',
+	'l2.history_live_hint','l2.history_empty'
 ];
 $i18n = [];
 foreach ($i18nKeys as $k) { $i18n[$k] = I18n::t($k); }
@@ -49,7 +51,6 @@ if (!function_exists('l2e')) { function l2e($v) { return htmlspecialchars((strin
     </div>
 
     <label class="l2-chk"><input type="checkbox" id="l2-showterm"/> <?php echo l2e(I18n::t('l2.show_term')); ?></label>
-    <label class="l2-chk"><input type="checkbox" id="l2-onlyoff"/> <?php echo l2e(I18n::t('l2.only_off')); ?></label>
 
     <label class="l2-ts-label"><?php echo l2e(I18n::t('l2.time_label')); ?>
       <select id="l2-ts" class="l2-btn"><?php echo $tsOpts; ?></select>
@@ -75,11 +76,13 @@ if (!function_exists('l2e')) { function l2e($v) { return htmlspecialchars((strin
         <div class="side-tab active" data-sec="alarms"><?php echo l2e(I18n::t('l2.alarms')); ?> <span class="badge" id="l2-alarm-count">0</span></div>
         <div class="side-tab" data-sec="offline"><?php echo l2e(I18n::t('l2.offline')); ?> <span class="badge" id="l2-offline-count" style="background:#78909c">0</span></div>
         <div class="side-tab" data-sec="terms"><?php echo l2e(I18n::t('l2.show_term')); ?> <span class="badge" id="l2-term-count" style="background:#2E75B6">0</span></div>
+        <div class="side-tab" data-sec="history"><?php echo l2e(I18n::t('l2.history')); ?> <span class="badge" id="l2-history-count" style="background:#8e6fb3">0</span></div>
       </div>
       <div class="side-body">
         <div class="panel-sec active" id="sec-alarms"><div id="l2-alarm-list"></div></div>
         <div class="panel-sec" id="sec-offline"><div id="l2-offline-list"></div></div>
         <div class="panel-sec" id="sec-terms"><div id="l2-term-list"></div></div>
+        <div class="panel-sec" id="sec-history"><div id="l2-history-list"></div></div>
       </div>
       <div class="l2-legend" id="l2-legend"></div>
     </div>
@@ -92,6 +95,7 @@ if (!function_exists('l2e')) { function l2e($v) { return htmlspecialchars((strin
 window.L2SNAP = <?php echo $snapSafe; ?>;
 window.L2_CFG  = <?php echo $cfgSafe; ?>;
 window.L2_I18N = <?php echo $i18nJson; ?>;
+window.L2_HISTORY = <?php echo str_replace('</', '<\/', json_encode($hist, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)); ?>;
 window.L2_META = {
   base: '<?php echo $base; ?>',
   snapshotUrl: '<?php echo $base; ?>snapshot.json',
@@ -102,7 +106,7 @@ window.L2_META = {
 };
 </script>
 <script src="<?php echo $base; ?>cytoscape.min.js"></script>
-<script src="<?php echo $base; ?>app.js?v=20261106"></script>
+<script src="<?php echo $base; ?>app.js?v=20261108"></script>
 <script>
 (function(){
   var tabs = document.querySelectorAll('.side-tab');