Procházet zdrojové kódy

feat(l2): add core hierarchy config, DB-backed history and date-first snapshot picker

clm0081 před 2 dny
rodič
revize
468b208bf1

+ 69 - 13
collector/l2_snapshot.py

@@ -4,6 +4,7 @@
 import csv, json, os, re, collections, time, datetime, urllib.request, gzip, base64, subprocess
 
 import l2cfg
+import l2db
 
 TOK = 'd3abb125eb301a021ef80a2847a803a7cd2d0b1d3c8337a14d09db9d571369b5'
 API = 'http://192.168.87.20:8080/api_jsonrpc.php'
@@ -13,10 +14,6 @@ EP  = '/tmp/fdb_endpoints.tsv'
 PS  = '/tmp/port_status.tsv'
 LAT = '/tmp/latency.tsv'
 POE = '/tmp/poe_switches.tsv'
-MYSQL_CONTAINER = os.environ.get('L2_MYSQL_CONTAINER', 'zabbix-mysql')
-MYSQL_USER = os.environ.get('L2_MYSQL_USER', 'root')
-MYSQL_PASS = os.environ.get('L2_MYSQL_PASS', 'CobcoIT132')
-MYSQL_DB = os.environ.get('L2_MYSQL_DB', 'zabbix')
 SEV = ['NC', 'INFO', 'WARN', 'AVG', 'HIGH', 'DISASTER']  # names localized on the frontend
 PHYS = {'6', '62', '117'}
 
@@ -100,11 +97,9 @@ def load_poe():
     return out
 
 def mysql(sql):
-    return subprocess.run(
-        ['docker', 'exec', '-i', MYSQL_CONTAINER, 'mysql',
-         '-u' + MYSQL_USER, '-p' + MYSQL_PASS, MYSQL_DB,
-         '--default-character-set=utf8mb4'],
-        input=sql.encode('utf-8'), capture_output=True, timeout=120)
+    # Credentials are read from the running Zabbix MySQL container itself;
+    # there is no separate L2 DB account or password file.
+    return l2db.run_sql(sql)
 
 def write_history(snap, now):
     cfg = l2cfg.config()
@@ -136,6 +131,7 @@ def write_history(snap, now):
 
 def main():
     now = int(time.time())
+    cfg = l2cfg.config()
     edges_raw = load(STP); eps = load(EP); ps = load(PS)
     lat = load_latency()
     poe = load_poe()
@@ -151,7 +147,7 @@ def main():
         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)
+    days = int(cfg.get('history_days') or 7)
     cutoff = now - days * 86400
 
     # ---------- port status maps ----------
@@ -282,6 +278,55 @@ def main():
                     'error': i.get('error', '') or ''
                 }
 
+    # ---------- optional upstream hierarchy ----------
+    # Firewall -> behavior management -> total core are display-only roles
+    # above the sub-cores.  They are resolved from the configured name/IP
+    # patterns; an empty role contributes no node and no layout space.
+    def hier_hit(rule, name, ip):
+        pattern = str(rule.get('pattern') or '').strip()
+        if not pattern:
+            return False
+        hay = (name or '') + ' ' + (ip or '')
+        if str(rule.get('match') or 'contains') == 'regex':
+            try:
+                return re.search(pattern, hay, re.I) is not None
+            except re.error:
+                return False
+        return pattern.lower() in hay.lower()
+
+    upstream = []
+    upstream_seen = set()
+    hcfg = cfg.get('core_hierarchy') if isinstance(cfg.get('core_hierarchy'), dict) else {}
+    for role in ('firewall', 'behavior', 'total_core'):
+        rule = hcfg.get(role) if isinstance(hcfg.get(role), dict) else {}
+        if not rule.get('pattern'):
+            continue
+        for h in hs:
+            hn = norm(h.get('name') or '')
+            cand = [i for i in h.get('interfaces', []) if i.get('ip')]
+            pick = None
+            for i in cand:
+                if hier_hit(rule, hn, str(i.get('ip') or '')):
+                    pick = i
+                    if i.get('type') == '2':
+                        break
+            if not pick:
+                continue
+            ip = str(pick.get('ip') or '')
+            if not ip or ip in upstream_seen:
+                continue
+            upstream_seen.add(ip)
+            al = ip_alarms.get(ip, [])
+            upstream.append({
+                'id': ip, 'name': hn or ip_name.get(ip, ip), 'ip': ip,
+                'tier': 'upstream', 'hierarchy_role': role,
+                'hostid': h.get('hostid'), 'oper': 'down' if str(pick.get('available', '0')) == '2' else 'up',
+                'offline': str(pick.get('available', '0')) == '2',
+                'alarms': sorted(al, key=lambda a: -a['sev']),
+                'maxsev': max([a['sev'] for a in al], default=-1),
+                'latency_ms': lat.get(ip), 'children': 0, 'ports': [], 'last_online': now
+            })
+
     # ---------- 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.
@@ -376,6 +421,14 @@ def main():
             'ports': ports_by_ip.get(ip, []),
             'last_online': last_online
         })
+    # A configured upstream host that is also a protocol switch gets a role
+    # marker instead of a duplicate node.
+    upstream_by_ip = {u['id']: u for u in upstream}
+    for n in nodes:
+        u = upstream_by_ip.get(n['id'])
+        if u:
+            n['hierarchy_role'] = u['hierarchy_role']
+    upstream = [u for u in upstream if u['id'] not in known_ips]
 
     # ---------- edges ----------
     edges = []
@@ -423,9 +476,10 @@ def main():
         'ports': sum(len(n.get('ports') or []) for n in nodes),
         '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)
+        'alarms': sum(len(n['alarms']) for n in nodes) + sum(len(n['alarms']) for n in upstream),
+        'devices_alarm': sum(1 for n in nodes if n['alarms']) + sum(1 for n in upstream if n['alarms']),
+        'upstream': len(upstream),
+        'maxsev': max([n['maxsev'] for n in nodes] + [n['maxsev'] for n in upstream], default=-1)
     }
     in_nodes = set(known_ips)
     other = []
@@ -440,6 +494,8 @@ def main():
             'refresh_default': 300, 'roots': roots, 'stats': stats,
             'other_alarms': other,
             'nodes': nodes, 'edges': edges, 'terminals': terminals}
+    if upstream:
+        snap['upstream'] = upstream
     os.makedirs(os.path.dirname(OUT), exist_ok=True)
     tmp = OUT + '.tmp'
     with open(tmp, 'w', encoding='utf-8') as f:

+ 42 - 2
collector/l2cfg.py

@@ -4,6 +4,12 @@ selected templates to template ids, and list the hosts that should be collected.
 """
 import os
 import json
+import time
+
+try:
+    import l2db
+except Exception:  # pragma: no cover - only used on an installed host
+    l2db = None
 
 DEFAULT_PATHS = [
     os.environ.get('L2_CONFIG') or '',
@@ -13,7 +19,25 @@ DEFAULT_PATHS = [
 ]
 
 
-def config():
+_CACHE = None
+_CACHE_AT = 0
+
+
+def _from_db():
+    """Read the module config from Zabbix's own DB (primary source)."""
+    if l2db is None:
+        return None
+    try:
+        raw = l2db.scalar('SELECT HEX(config) FROM l2_topology_config WHERE id=1')
+        if raw:
+            return json.loads(bytes.fromhex(raw).decode('utf-8'))
+    except Exception:
+        pass
+    return None
+
+
+def _from_mirror():
+    """Fallback for a fresh install before the first DB save."""
     for p in DEFAULT_PATHS:
         if p and os.path.isfile(p):
             try:
@@ -21,7 +45,23 @@ def config():
                     return json.load(f)
             except Exception:
                 pass
-    return {}
+    return None
+
+
+def config():
+    """Return the L2 configuration, DB first and JSON mirror as fallback."""
+    global _CACHE, _CACHE_AT
+    now = time.time()
+    if _CACHE is not None and now - _CACHE_AT < 30:
+        return _CACHE
+    data = _from_db()
+    if data is None:
+        data = _from_mirror()
+    if data is None:
+        data = {}
+    _CACHE = data
+    _CACHE_AT = now
+    return data
 
 
 def template_ids(call):

+ 72 - 0
collector/l2db.py

@@ -0,0 +1,72 @@
+# -*- coding: utf-8 -*-
+"""Small DB bridge for the out-of-process L2 collectors.
+
+The collectors run on the Docker host, outside the Zabbix PHP container.  We
+deliberately do not keep a second set of MySQL credentials: the credentials
+are read from the already-running Zabbix MySQL container's environment (the
+same MYSQL_* values that the official Zabbix compose file passed to MySQL and
+Zabbix).  Queries are executed inside that container, so the DB host never
+needs to be resolvable from the host shell.
+"""
+import json
+import os
+import subprocess
+
+
+def _container_name():
+    return os.environ.get('L2_DB_CONTAINER') or os.environ.get('L2_MYSQL_CONTAINER') or 'zabbix-mysql'
+
+
+def _container_env():
+    """Return the real environment of the Zabbix MySQL container as a dict."""
+    name = _container_name()
+    out = subprocess.check_output(
+        ['docker', 'inspect', name, '--format', '{{json .Config.Env}}'],
+        text=True, timeout=30
+    )
+    env = {}
+    for item in json.loads(out or '[]'):
+        key, sep, value = str(item).partition('=')
+        if sep:
+            env[key] = value
+    return env
+
+
+def credentials():
+    """Return (container, user, password, database) without hard-coded secrets."""
+    env = _container_env()
+    user = env.get('MYSQL_USER') or env.get('DB_SERVER_USER') or ''
+    password = env.get('MYSQL_PASSWORD') or env.get('DB_SERVER_PASS') or ''
+    database = env.get('MYSQL_DATABASE') or env.get('DB_SERVER_DBNAME') or 'zabbix'
+    if not user or not password:
+        raise RuntimeError('Zabbix MySQL container has no MYSQL_USER/MYSQL_PASSWORD')
+    return _container_name(), user, password, database
+
+
+def run_sql(sql, timeout=120):
+    """Execute SQL inside the Zabbix MySQL container.
+
+    Returns the completed process.  stdin is used instead of argv so long
+    compressed history blobs never hit the command-line length limit.
+    """
+    container, user, password, database = credentials()
+    cmd = [
+        'docker', 'exec', '-i', container, 'mysql',
+        '-N', '-B', '--default-character-set=utf8mb4',
+        '-u' + user, '-p' + password, database
+    ]
+    return subprocess.run(cmd, input=sql.encode('utf-8'), capture_output=True, timeout=timeout)
+
+
+def query(sql, timeout=120):
+    """Run a SELECT and return rows as lists of strings (TSV, no header)."""
+    r = run_sql(sql, timeout=timeout)
+    if r.returncode != 0:
+        raise RuntimeError((r.stderr or b'').decode('utf-8', 'ignore')[:500])
+    text = (r.stdout or b'').decode('utf-8', 'ignore')
+    return [line.split('\t') for line in text.splitlines() if line != '']
+
+
+def scalar(sql, timeout=120):
+    rows = query(sql, timeout=timeout)
+    return rows[0][0] if rows and rows[0] else None

+ 50 - 4
web-modules/WecomCenter/actions/WecomCenterSave.php

@@ -281,9 +281,55 @@ class WecomCenterSave extends CController {
 			if ($networks) { $cfg['networks'] = $networks; }
 		}
 
-		// Core/root switches: one row per switch, arrays are parallel:
-		//   core_net[] | core_match[] | core_pattern[]
-		if (isset($_REQUEST['core_rules_present'])) {
+		// Core hierarchy: optional upstream roles (empty pattern = hidden) and
+		// the sub-core definitions (monitoring / office / future production).
+		if (isset($_REQUEST['core_hierarchy_present'])) {
+			$netKeys = array_map(function ($n) { return (string) ($n['key'] ?? ''); }, (array) $cfg['networks']);
+			$hier = [];
+			foreach (['firewall', 'behavior', 'total_core'] as $role) {
+				$match = (string) ($_REQUEST['hier_'.$role.'_match'] ?? 'contains');
+				if ($match !== 'regex') { $match = 'contains'; }
+				$hier[$role] = [
+					'match' => $match,
+					'pattern' => trim((string) ($_REQUEST['hier_'.$role.'_pattern'] ?? ''))
+				];
+			}
+			$subNet = (array) ($_REQUEST['sub_net'] ?? []);
+			$subKey = (array) ($_REQUEST['sub_key'] ?? []);
+			$subLabel = (array) ($_REQUEST['sub_label'] ?? []);
+			$subMatch = (array) ($_REQUEST['sub_match'] ?? []);
+			$subPattern = (array) ($_REQUEST['sub_pattern'] ?? []);
+			$subs = [];
+			$coreRules = [];
+			foreach ($subKey as $i => $key) {
+				$pattern = trim((string) ($subPattern[$i] ?? ''));
+				$net = strtoupper(trim((string) ($subNet[$i] ?? '')));
+				if ($net !== '' && !in_array($net, $netKeys, true)) { $net = ''; }
+				$match = (string) ($subMatch[$i] ?? 'contains');
+				if ($match !== 'regex') { $match = 'contains'; }
+				$key = preg_replace('/[^a-z0-9_]/i', '', (string) $key);
+				if ($key === '') { $key = 'sub'.($i + 1); }
+				$label = trim((string) ($subLabel[$i] ?? ''));
+				$subs[] = [
+					'key' => $key,
+					'label' => $label !== '' ? $label : $key,
+					'net' => $net,
+					'match' => $match,
+					'pattern' => $pattern
+				];
+				if ($pattern !== '') {
+					$coreRules[] = ['net' => $net, 'match' => $match, 'pattern' => $pattern];
+				}
+			}
+			$hier['sub_cores'] = $subs;
+			$cfg['core_hierarchy'] = $hier;
+			// Keep the old flat key in sync for collectors/frontends that
+			// still read core_rules.  Empty future-core rows are retained in
+			// core_hierarchy as visible placeholder configuration but are not
+			// emitted as active matching rules.
+			$cfg['core_rules'] = $coreRules;
+		}
+		elseif (isset($_REQUEST['core_rules_present'])) {
 			$coreNets = (array) ($_REQUEST['core_net'] ?? []);
 			$coreMatch = (array) ($_REQUEST['core_match'] ?? []);
 			$corePattern = (array) ($_REQUEST['core_pattern'] ?? []);
@@ -304,7 +350,7 @@ class WecomCenterSave extends CController {
 		$cfg['latency_bad']        = (float) ($_REQUEST['latency_bad'] ?? $cfg['latency_bad']);
 
 		$colors = $cfg['colors'];
-		foreach (['100', '1000', '10000', '20000', 'unknown', 'down'] as $k) {
+		foreach (['100', '1000', '2500', '10000', '20000', 'unknown', 'down'] as $k) {
 			$v = trim((string) ($_REQUEST['color_'.$k] ?? ''));
 			if ($v !== '' && preg_match('/^#[0-9a-fA-F]{6}$/', $v)) {
 				$colors[$k] = $v;

+ 139 - 71
web-modules/WecomCenter/assets/l2topology/app.js

@@ -17,8 +17,8 @@ if (!colors['2500']) colors['2500'] = '#17C3B2';
   var LATW = (CFG.latency_warn != null) ? +CFG.latency_warn : 3;
   var LATB = (CFG.latency_bad != null) ? +CFG.latency_bad : 5;
 
-  var TIER_BG = {core:'#1F4E78', dist:'#2E75B6', access:'#548235'};
-  var TIER_BG_DARK = {core:'#1B4A72', dist:'#2A5E93', access:'#3E6B2E'};
+  var TIER_BG = {core:'#1F4E78', dist:'#2E75B6', access:'#548235', upstream:'#34495E'};
+  var TIER_BG_DARK = {core:'#1B4A72', dist:'#2A5E93', access:'#3E6B2E', upstream:'#2C3E50'};
   var SEV_COLORS = ['#97AAB3','#7499FF','#FFC859','#FFA059','#E97659','#E45959'];
 
   var NODE_W = 156, NODE_H = 50, GX = 26, GY = 18, PADX = 20, PADY = 42;
@@ -574,6 +574,8 @@ if (!colors['2500']) colors['2500'] = '#17C3B2';
   // Multiple cores in the same network are supported and all of them are
   // placed in the left-most core column.
   function coreRules() {
+    var h = CFG.core_hierarchy;
+    if (h && h.sub_cores instanceof Array) return h.sub_cores;
     var r = CFG.core_rules;
     return (r instanceof Array) ? r : [];
   }
@@ -599,6 +601,13 @@ if (!colors['2500']) colors['2500'] = '#17C3B2';
     for (var i = 0; i < rules.length; i++) if (coreRuleHit(rules[i], n)) return true;
     return false;
   }
+  function coreLabelOf(n) {
+    var rules = coreRules();
+    for (var i = 0; i < rules.length; i++) {
+      if (coreRuleHit(rules[i], n)) return String(rules[i].label || '');
+    }
+    return '';
+  }
   function configuredCoreNet(n) {
     var rules = coreRules();
     for (var i = 0; i < rules.length; i++) {
@@ -645,6 +654,9 @@ if (!colors['2500']) colors['2500'] = '#17C3B2';
     return net;
   }
 
+  function isUpstreamRole(n) {
+    return ['firewall', 'behavior', 'total_core'].indexOf(String((n && n.hierarchy_role) || '')) >= 0;
+  }
   function roleOf(n) {
     var p = parseName(n.name || '');
     var r = String(p.role || '').toLowerCase();
@@ -652,10 +664,12 @@ if (!colors['2500']) colors['2500'] = '#17C3B2';
     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';
+    // Backward-compatible fallback only when no core configuration exists
+    // (old config).  An explicitly empty list means the administrator wants
+    // no configured core column.
+    var hasCoreCfg = (CFG.core_hierarchy && CFG.core_hierarchy.sub_cores instanceof Array)
+      || (CFG.core_rules instanceof Array);
+    if (!hasCoreCfg && (/^core(\s|-)/i.test(String(n.name || '')) || groupOf(n).label === 'Core')) return 'core';
     // Hostname role is the display truth for Access switches.  An access
     // switch may itself have access children (cascade/stack); it still stays
     // an access node and the children simply take a further-right column.
@@ -693,7 +707,16 @@ if (!colors['2500']) colors['2500'] = '#17C3B2';
   function switchLabel(n, role, netKey) {
     var g = groupOf(n).label;
     var s;
-    if (role === 'core') s = roleName('core') + ' · ' + (netKey || rawNetOf(n)) + ' · ' + (n.ip || '');
+    if (role === 'upstream') {
+      var rl = n.hierarchy_role === 'firewall' ? t('l2.firewall', 'Firewall')
+        : (n.hierarchy_role === 'behavior' ? t('l2.behavior', 'Behavior management')
+          : t('l2.total_core', 'Total core'));
+      s = rl + ' · ' + (n.ip || '');
+    }
+    else if (role === 'core') {
+      var cl = coreLabelOf(n) || roleName('core');
+      s = cl + ' · ' + (netKey || rawNetOf(n)) + ' · ' + (n.ip || '');
+    }
     else if (role === 'main') s = roleName('main') + ' · ' + g + ' · ' + (n.ip || '');
     else {
       var loc = shortLoc(n);
@@ -739,6 +762,7 @@ if (!colors['2500']) colors['2500'] = '#17C3B2';
       return cur || n;
     }
     (snap.nodes || []).forEach(function (n) {
+      if (isUpstreamRole(n)) return;
       var r = roleOf(n);
       if (r === 'core' || r === 'main') return;
       var root = rootOf(n);
@@ -750,6 +774,7 @@ if (!colors['2500']) colors['2500'] = '#17C3B2';
   function workshopList(snap, netMap, netKey, wsKeys) {
     var map = {}, order = [];
     (snap.nodes || []).forEach(function (n) {
+      if (isUpstreamRole(n)) return;
       if (netMap[n.id] !== netKey) return;
       var r = roleOf(n);
       if (r === 'core' || r === 'main') return;
@@ -898,11 +923,54 @@ if (!colors['2500']) colors['2500'] = '#17C3B2';
   }
 
   function computeFull(snap) {
-    var nodes = snap.nodes || [];
+    var nodes = (snap.nodes || []).slice();
     var netMap = assignNetworks(snap);
     var sections = [], placements = [], frames = [], roleMap = {};
     var wsKeys = workshopKeys(snap);
     var y = 100, maxRight = CHART_W;
+    // Optional upstream band: firewall -> behavior management -> total core.
+    // If a role is empty or has no matching host in this snapshot, it is not
+    // placed at all, so the topology reserves no blank space for it.
+    var upstream = (snap.upstream || []).filter(function (n) { return n && n.id; });
+    var upstreamIds = {};
+    upstream.forEach(function (n) { upstreamIds[n.id] = true; });
+    // A configured upstream host can also be part of the STP graph.  The
+    // collector then keeps one node and marks it with hierarchy_role; move
+    // that node into the upstream band instead of drawing it twice.
+    nodes = nodes.filter(function (n) {
+      if (!isUpstreamRole(n)) return true;
+      if (!upstreamIds[n.id]) {
+        upstream.push(n);
+        upstreamIds[n.id] = true;
+      }
+      return false;
+    });
+    var UP_ROLE_ORDER = ['firewall', 'behavior', 'total_core'];
+    var UP_ROLE_LABEL = {
+      firewall: t('l2.firewall', '防火墙'),
+      behavior: t('l2.behavior', '行为管理'),
+      total_core: t('l2.total_core', '总核心')
+    };
+    if (upstream.length) {
+      upstream.sort(function (a, b) {
+        var ai = UP_ROLE_ORDER.indexOf(a.hierarchy_role), bi = UP_ROLE_ORDER.indexOf(b.hierarchy_role);
+        return (ai < 0 ? 99 : ai) - (bi < 0 ? 99 : bi);
+      });
+      // x is the node centre in Cytoscape, so start at half the node width
+      // plus the frame's left padding; otherwise the first node is clipped by
+      // the sidebar/canvas edge.  Keep enough width for “role · IP” labels.
+      var ux = 170;
+      upstream.forEach(function (n) {
+        var rl = UP_ROLE_LABEL[n.hierarchy_role] || t('l2.upstream', '上游');
+        placements.push({node: n, role: 'upstream', x: ux, y: 68, w: 280, h: 46, net: '', group: rl});
+        roleMap[n.id] = 'upstream';
+        ux += 296;
+      });
+      frames.push({id: 'upframe', type: 'wsframe', x: 10, y: 34, w: CHART_W - 20, h: 84,
+        label: t('l2.upstream', '上游层级'), net: '', color: '#8E44AD'});
+      y = 146;
+      maxRight = Math.max(maxRight, ux + 20);
+    }
     NETWORKS.forEach(function (net) {
       var secNodes = nodes.filter(function (n) { return netMap[n.id] === net.key; });
       if (!secNodes.length) return;
@@ -959,6 +1027,7 @@ if (!colors['2500']) colors['2500'] = '#17C3B2';
     });
     var chartW = Math.max(CHART_W, maxRight + 40);
     sections.forEach(function (sec) { sec.w = chartW - 20; });
+    frames.forEach(function (f) { if (f.id === 'upframe') f.w = chartW - 20; });
     var root = {x: 0, y: 0, w: chartW, h: y + 6};
     return {sections: sections, placements: placements, frames: frames, netMap: netMap, roleMap: roleMap, root: root};
   }
@@ -1343,7 +1412,8 @@ if (!colors['2500']) colors['2500'] = '#17C3B2';
 
     L.placements.forEach(function (p) {
       var n = p.node, cls = [];
-      var bg = p.role === 'core' ? TIER_BG.core : (p.role === 'main' ? '#2E75B6' : (p.role === 'dist' ? TIER_BG.dist : TIER_BG.access));
+      var bg = p.role === 'upstream' ? TIER_BG.upstream
+        : (p.role === 'core' ? TIER_BG.core : (p.role === 'main' ? '#2E75B6' : (p.role === 'dist' ? TIER_BG.dist : TIER_BG.access)));
       var bd = bg;
       // Alarm and offline are node-level states: fill the whole node red.
       // Only latency > LATB is allowed to draw a red frame; LATW..LATB uses
@@ -1363,7 +1433,8 @@ if (!colors['2500']) colors['2500'] = '#17C3B2';
         maxsev: n.maxsev, alarms: n.alarms || [], term_online: n.term_online, term_offline: 0, latency_ms: n.latency_ms,
         poe: !!n.poe,
         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
+        group: p.role === 'upstream' ? p.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(' ')});
     });
 
@@ -1718,7 +1789,7 @@ if (!colors['2500']) colors['2500'] = '#17C3B2';
 
   function buildAlarms(snap) {
     var items = [];
-    (snap.nodes || []).forEach(function (n) {
+    (snap.nodes || []).concat(snap.upstream || []).forEach(function (n) {
       if (n.maxsev != null && n.maxsev >= 0 && n.alarms && n.alarms.length) {
         n.alarms.forEach(function (a) {
           items.push({sev: n.maxsev, title: (a.name || a.description || ''), host: n.name, ip: n.ip, id: n.id, hostid: n.hostid});
@@ -1818,69 +1889,65 @@ if (!colors['2500']) colors['2500'] = '#17C3B2';
 
   function buildHistory() {
     var list = window.L2_HISTORY || [];
+    var dateSel = el('l2-ts-date');
+    var timeSel = el('l2-ts-time');
+    if (!dateSel || !timeSel) return;
     var used = (window.L2_META && !window.L2_META.isLive && window.L2_META.usedTs) ? String(window.L2_META.usedTs) : '';
-    // The toolbar selector is the only time control.  It is built strictly
-    // from snapshots that really exist in l2_topology_history, so the user
-    // can never submit an arbitrary/empty timestamp.
-    var sel = el('l2-ts');
-    if (sel) {
-      var old = sel.value;
-      sel.innerHTML = '';
-      var live = document.createElement('option');
-      live.value = '';
-      live.textContent = t('l2.time_live', 'Live');
-      sel.appendChild(live);
-      var groups = {}, order = [];
+    // Two-stage picker: the first select lists only days that really have
+    // snapshots; the second lists only times recorded on that day.  A user can
+    // therefore never submit an arbitrary timestamp or get a blank page.
+    var groups = {}, days = [];
+    list.forEach(function (it) {
+      var day = String(it.captured_at || fmtTs(it.ts)).slice(0, 10);
+      if (!groups[day]) { groups[day] = []; days.push(day); }
+      groups[day].push(it);
+    });
+    var usedDay = '';
+    if (used) {
       list.forEach(function (it) {
-        var day = String(it.captured_at || fmtTs(it.ts)).slice(0, 10);
-        if (!groups[day]) { groups[day] = []; order.push(day); }
-        groups[day].push(it);
+        if (String(it.ts) === used) usedDay = String(it.captured_at || fmtTs(it.ts)).slice(0, 10);
       });
-      order.forEach(function (day) {
-        var og = document.createElement('optgroup');
-        og.label = day;
-        groups[day].forEach(function (it) {
-          var opt = document.createElement('option');
-          opt.value = String(it.ts || '');
-          var ca = String(it.captured_at || '');
-          opt.textContent = ca.length >= 19 ? ca.slice(11, 19) : fmtTs(it.ts);
-          og.appendChild(opt);
-        });
-        sel.appendChild(og);
-      });
-      sel.value = used || old || '';
     }
-    var box = el('l2-history-list');
-    if (!box) return;
-    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>';
+    dateSel.innerHTML = '';
+    var live = document.createElement('option');
+    live.value = '';
+    live.textContent = t('l2.time_live', 'Live');
+    dateSel.appendChild(live);
+    days.forEach(function (day) {
+      var opt = document.createElement('option');
+      opt.value = day;
+      opt.textContent = day + ' (' + groups[day].length + ')';
+      dateSel.appendChild(opt);
+    });
+    dateSel.value = usedDay || '';
+
+    function fillTimes(preferTs) {
+      var day = dateSel.value;
+      timeSel.innerHTML = '';
+      if (!day) {
+        var dash = document.createElement('option');
+        dash.value = '';
+        dash.textContent = '--:--';
+        timeSel.appendChild(dash);
+        timeSel.disabled = true;
+        return;
+      }
+      var items = groups[day] || [];
+      items.forEach(function (it) {
+        var opt = document.createElement('option');
+        opt.value = String(it.ts || '');
+        var ca = String(it.captured_at || '');
+        opt.textContent = ca.length >= 19 ? ca.slice(11, 19) : fmtTs(it.ts);
+        timeSel.appendChild(opt);
       });
+      timeSel.disabled = false;
+      var chosen = preferTs && items.some(function (it) { return String(it.ts) === String(preferTs); }) ? String(preferTs) : '';
+      if (!chosen && items.length) chosen = String(items[0].ts || '');
+      timeSel.value = chosen;
     }
-    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;
+    fillTimes(used);
+    dateSel.onchange = function () { fillTimes(''); };
+    timeSel.onchange = null;
   }
 
   function esc(s) { return String(s == null ? '' : s).replace(/[&<>"']/g, function (c) { return ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'})[c]; }); }
@@ -2310,11 +2377,12 @@ if (!colors['2500']) colors['2500'] = '#17C3B2';
 
     if (el('l2-ts-go')) {
       var goTs = function () {
-        var sel = el('l2-ts');
-        if (!sel) return;
+        var dateSel = el('l2-ts-date');
+        var timeSel = el('l2-ts-time');
+        if (!dateSel || !timeSel) return;
         var u = new URL(window.location.href);
         u.searchParams.set('action', 'l2.topology.view');
-        if (sel.value) u.searchParams.set('ts', sel.value);
+        if (dateSel.value && timeSel.value) u.searchParams.set('ts', timeSel.value);
         else u.searchParams.delete('ts');
         window.location.href = u.toString();
       };

+ 11 - 0
web-modules/WecomCenter/assets/l2topology/config.json

@@ -9,6 +9,16 @@
     {"net": "SURVE", "match": "contains", "pattern": "core surve"},
     {"net": "OFFICE", "match": "contains", "pattern": "core office"}
   ],
+  "core_hierarchy": {
+    "firewall": {"match": "contains", "pattern": ""},
+    "behavior": {"match": "contains", "pattern": ""},
+    "total_core": {"match": "contains", "pattern": ""},
+    "sub_cores": [
+      {"key": "monitor", "label": "监控核心", "net": "SURVE", "match": "contains", "pattern": "core surve"},
+      {"key": "office", "label": "办公核心", "net": "OFFICE", "match": "contains", "pattern": "core office"},
+      {"key": "production", "label": "生产核心", "net": "", "match": "contains", "pattern": ""}
+    ]
+  },
   "templates": ["Huawei VRP by SNMP (walk)"],
   "full_interval_min": 5,
   "alarm_interval_min": 1,
@@ -17,6 +27,7 @@
   "colors": {
     "100": "#F39C12",
     "1000": "#2ECC71",
+    "2500": "#17C3B2",
     "10000": "#3498DB",
     "20000": "#9B59B6",
     "unknown": "#E74C3C",

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

@@ -65,6 +65,9 @@ select.l2-ts-select {
   box-sizing: border-box; line-height: normal; font-family: inherit;
   flex: 0 0 auto; margin: 0;
 }
+select.l2-ts-date { width: 150px; }
+select.l2-ts-time { width: 104px; }
+select.l2-ts-time:disabled { opacity: .55; }
 
 .l2-btn {
   height: 32px; padding: 0 12px; border-radius: 8px; cursor: pointer; font-size: 12.5px;

+ 2 - 0
web-modules/WecomCenter/assets/wecom.center.css

@@ -204,6 +204,8 @@ select.wc-input option { color: #1f2c33; }
 	padding: 8px; border: 1px solid #e3e6ea; border-radius: 8px; background: #fafbfd;
 }
 .wc-rule .wc-input { min-width: 0; }
+.wc-rule-label { flex: 0 0 82px; font-size: 12.5px; font-weight: 600; color: #34495e; }
+:is(html[color-scheme="dark"], html[theme="dark-theme"]) .wc-rule-label { color: #cfe0f5; }
 .wc-rule input[name="rule_pattern[]"],
 .wc-rule input[name="rule_label[]"],
 .wc-rule input[name="core_pattern[]"] { flex: 1 1 0; }

+ 16 - 0
web-modules/WecomCenter/lib/L2Store.php

@@ -43,6 +43,21 @@ class L2Store {
 				['net' => 'SURVE',  'match' => 'contains', 'pattern' => 'core surve'],
 				['net' => 'OFFICE', 'match' => 'contains', 'pattern' => 'core office']
 			],
+			// Optional physical hierarchy above the sub-cores.  The display
+			// order is firewall -> behavior -> total core -> sub-core.  Every
+			// pattern may be empty; an empty role is not drawn and reserves no
+			// space in the topology.  sub_cores feed core_rules for backwards
+			// compatibility with older collectors/frontends.
+			'core_hierarchy' => [
+				'firewall'   => ['match' => 'contains', 'pattern' => ''],
+				'behavior'   => ['match' => 'contains', 'pattern' => ''],
+				'total_core' => ['match' => 'contains', 'pattern' => ''],
+				'sub_cores'  => [
+					['key' => 'monitor',    'label' => '监控核心', 'net' => 'SURVE',  'match' => 'contains', 'pattern' => 'core surve'],
+					['key' => 'office',     'label' => '办公核心', 'net' => 'OFFICE', 'match' => 'contains', 'pattern' => 'core office'],
+					['key' => 'production', 'label' => '生产核心', 'net' => '',       'match' => 'contains', 'pattern' => '']
+				]
+			],
 			// Collection --------------------------------------------------------
 			'templates'         => ['Huawei VRP by SNMP (walk)'],
 			'full_interval_min' => 5,            // full topology/port collection
@@ -53,6 +68,7 @@ class L2Store {
 			'colors' => [
 				'100'     => '#F39C12', // 100M  橙色
 				'1000'    => '#2ECC71', // 1G    绿色
+				'2500'    => '#17C3B2', // 2.5G  青色
 				'10000'   => '#3498DB', // 10G   蓝色
 				'20000'   => '#9B59B6', // 20G+  紫色
 				'unknown' => '#E74C3C', // 未知  红色

+ 12 - 2
web-modules/WecomCenter/locale/en.php

@@ -167,8 +167,9 @@ return [
 	'sec.l2_latency'     => 'Latency thresholds',
 	'sec.l2_group'       => 'Grouping rules',
 	'sec.l2_networks'     => 'Physical networks',
-	'sec.l2_cores' => 'Core switches',
-	'lbl.l2_cores_hint' => 'Define the root switch (left-most column). One row per core switch: network | match | name/IP pattern. Multiple cores in one network are supported; order does not matter.',
+	'sec.l2_cores' => 'Core hierarchy',
+	'lbl.l2_cores_hint' => 'Upstream order: firewall → behavior management → total core. Leave a field empty when the device is not connected yet; the topology will not reserve space for it.',
+	'lbl.l2_subcores_hint' => 'Sub-cores: monitoring core, office core, and the future production core. Each row takes a network, match mode and name/IP pattern; empty rows are hidden.',
 	'lbl.core_net' => 'Network',
 	'lbl.core_any' => 'All networks',
 	'lbl.core_pattern' => 'Name or IP pattern',
@@ -187,6 +188,7 @@ return [
 	'lbl.l2_other'       => 'Fallback group label',
 	'lbl.color_100'      => '100M',
 	'lbl.color_1000'     => '1G',
+	'lbl.color_2500'     => '2.5G',
 	'lbl.color_10000'    => '10G',
 	'lbl.color_20000'    => '20G+',
 	'lbl.color_unknown'  => 'Unknown',
@@ -226,6 +228,14 @@ return [
 	'l2.time_live'       => 'Live',
 	'l2.time_btn'        => 'Go',
 	'l2.time_label'      => 'Snapshot time',
+	'l2.time_date'       => 'Date',
+	'l2.time_time'       => 'Time',
+	'l2.time_date_title' => 'Only dates with saved snapshots are listed',
+	'l2.time_time_title' => 'Only times saved on the selected date are listed',
+	'l2.firewall'        => 'Firewall',
+	'l2.behavior'        => 'Behavior management',
+	'l2.total_core'      => 'Total core',
+	'l2.upstream'        => 'Upstream hierarchy',
 	'l2.time_pick'       => 'Pick date/time',
 	'l2.view'            => 'View',
 	'l2.no_data'         => 'No snapshot yet — the collector has not run.',

+ 12 - 2
web-modules/WecomCenter/locale/zh_CN.php

@@ -154,8 +154,9 @@ return [
 	'sec.l2_latency'     => '延迟阈值',
 	'sec.l2_group'       => '分组规则',
 	'sec.l2_networks'     => '物理网络定义',
-	'sec.l2_cores' => '核心交换机',
-	'lbl.l2_cores_hint' => '定义根交换机(最左侧一列)。每行一个核心:网络 | 匹配方式 | 名称或IP规则;同一网络可配置多个核心,顺序不限。',
+	'sec.l2_cores' => '核心层级',
+	'lbl.l2_cores_hint' => '上游层级顺序:防火墙 → 行为管理 → 总核心。留空表示该设备尚未接入,拓扑图不会为它预留位置。',
+	'lbl.l2_subcores_hint' => '分核心:监控核心、办公核心、未来的生产核心。每行填写网络、匹配方式和名称或IP规则;留空同样不显示。',
 	'lbl.core_net' => '网络',
 	'lbl.core_any' => '全部网络',
 	'lbl.core_pattern' => '名称或IP规则',
@@ -174,6 +175,7 @@ return [
 	'lbl.l2_other'       => '兜底分组名',
 	'lbl.color_100'      => '100M',
 	'lbl.color_1000'     => '1G',
+	'lbl.color_2500'     => '2.5G',
 	'lbl.color_10000'    => '10G',
 	'lbl.color_20000'    => '20G+',
 	'lbl.color_unknown'  => '未知',
@@ -213,6 +215,14 @@ return [
 	'l2.time_live'       => '实时',
 	'l2.time_btn'        => '查看',
 	'l2.time_label'      => '快照时间',
+	'l2.time_date'       => '日期',
+	'l2.time_time'       => '时间',
+	'l2.time_date_title' => '只列出实际保存过快照的日期',
+	'l2.time_time_title' => '只列出所选日期实际保存过快照的时间',
+	'l2.firewall'        => '防火墙',
+	'l2.behavior'        => '行为管理',
+	'l2.total_core'      => '总核心',
+	'l2.upstream'        => '上游层级',
 	'l2.time_pick'       => '选择日期时间',
 	'l2.no_data'         => '暂无快照——采集尚未运行。',
 	'l2.offline_empty'   => '没有离线交换机。',

+ 18 - 9
web-modules/WecomCenter/views/topology.view.php

@@ -23,7 +23,8 @@ $i18nKeys = [
 	'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.terminals','l2.offline_short','l2.last_online','l2.history','l2.history_live',
-	'l2.history_live_hint','l2.history_empty'
+	'l2.history_live_hint','l2.history_empty','l2.time_date','l2.time_time',
+	'l2.time_date_title','l2.time_time_title','l2.firewall','l2.behavior','l2.total_core','l2.upstream'
 ];
 $i18n = [];
 foreach ($i18nKeys as $k) { $i18n[$k] = I18n::t($k); }
@@ -32,18 +33,23 @@ $i18nJson = str_replace('</', '<\/', json_encode($i18n, JSON_UNESCAPED_UNICODE |
 $hist = $data['history'] ?? [];
 $isLive = !empty($data['is_live']);
 $usedTs = (int) ($data['used_ts'] ?? 0);
-$tsOpts = '<option value=""'.($isLive ? ' selected' : '').'>'.htmlspecialchars(I18n::t('l2.time_live')).'</option>';
 $byDay = [];
 foreach ($hist as $h) {
 	$byDay[date('Y-m-d', (int) $h['ts'])][] = $h;
 }
+$usedDay = (!$isLive && $usedTs) ? date('Y-m-d', $usedTs) : '';
+$dateOpts = '<option value=""'.($isLive ? ' selected' : '').'>'.htmlspecialchars(I18n::t('l2.time_live')).'</option>';
+$timeOpts = '<option value="">--:--</option>';
 foreach ($byDay as $day => $items) {
-	$tsOpts .= '<optgroup label="'.htmlspecialchars($day).'">';
-	foreach ($items as $h) {
-		$sel = (!$isLive && $usedTs === (int) $h['ts']) ? ' selected' : '';
-		$tsOpts .= '<option value="'.(int) $h['ts'].'"'.$sel.'>'.date('H:i:s', (int) $h['ts']).'</option>';
+	$dateOpts .= '<option value="'.htmlspecialchars($day).'"'.($day === $usedDay ? ' selected' : '').'>'
+		.htmlspecialchars($day).' ('.count($items).')</option>';
+	if ($day === $usedDay) {
+		$timeOpts = '';
+		foreach ($items as $h) {
+			$sel = ($usedTs === (int) $h['ts']) ? ' selected' : '';
+			$timeOpts .= '<option value="'.(int) $h['ts'].'"'.$sel.'>'.date('H:i:s', (int) $h['ts']).'</option>';
+		}
 	}
-	$tsOpts .= '</optgroup>';
 }
 $refreshSec = (int) ($data['cfg_refresh'] ?? 300);
 
@@ -60,8 +66,11 @@ if (!function_exists('l2e')) { function l2e($v) { return htmlspecialchars((strin
 
     <label class="l2-chk"><input type="checkbox" id="l2-showterm"/> <?php echo l2e(I18n::t('l2.show_term')); ?></label>
 
-    <label class="l2-ts-label"><?php echo l2e(I18n::t('l2.time_label')); ?>
-      <select id="l2-ts" class="l2-btn l2-ts-select" title="只列出实际保存过快照的时间"><?php echo $tsOpts; ?></select>
+    <label class="l2-ts-label"><?php echo l2e(I18n::t('l2.time_date')); ?>
+      <select id="l2-ts-date" class="l2-btn l2-ts-select l2-ts-date" title="<?php echo l2e(I18n::t('l2.time_date_title')); ?>"><?php echo $dateOpts; ?></select>
+    </label>
+    <label class="l2-ts-label"><?php echo l2e(I18n::t('l2.time_time')); ?>
+      <select id="l2-ts-time" class="l2-btn l2-ts-select l2-ts-time" title="<?php echo l2e(I18n::t('l2.time_time_title')); ?>"<?php echo $isLive ? ' disabled' : ''; ?>><?php echo $timeOpts; ?></select>
     </label>
     <button class="l2-btn" id="l2-ts-go"><?php echo l2e(I18n::t('l2.time_btn')); ?></button>
 

+ 57 - 18
web-modules/WecomCenter/views/wecom.center.view.php

@@ -367,35 +367,74 @@ $networkBody =
 	.'<textarea class="wc-input wc-input--mono" name="networks_text" rows="5" style="min-height:120px">'.$e(trim($netLines)).'</textarea></div>';
 $networkCard = $card('&#127760;', $t('sec.l2_networks'), $networkBody, ' wc-card--wide');
 
-$coreRowHtml = function (array $r) use ($e, $t, $l2) {
-	$netSel = '<select class="wc-input" name="core_net[]" style="max-width:230px">'
+$hier = is_array($l2['core_hierarchy'] ?? null) ? $l2['core_hierarchy'] : [];
+$matchSel = function (string $name, $value) use ($e) {
+	$isRegex = ((string) $value === 'regex');
+	return '<select class="wc-input" name="'.$e($name).'" style="max-width:120px">'
+		.'<option value="contains"'.($isRegex ? '' : ' selected').'>contains</option>'
+		.'<option value="regex"'.($isRegex ? ' selected' : '').'>regex</option></select>';
+};
+$hierRow = function (string $role, string $label, array $r) use ($e, $t, $matchSel) {
+	return '<div class="wc-rule"><span class="wc-rule-label">'.$e($label).'</span>'
+		.$matchSel('hier_'.$role.'_match', $r['match'] ?? 'contains')
+		.'<input class="wc-input wc-input--mono" name="hier_'.$role.'_pattern" value="'.$e($r['pattern'] ?? '').'" placeholder="'.$t('lbl.core_pattern').'(留空 = 不显示)">'
+		.'</div>';
+};
+$hierRows = '';
+foreach ([
+	'firewall' => '防火墙',
+	'behavior' => '行为管理',
+	'total_core' => '总核心'
+] as $role => $label) {
+	$hierRows .= $hierRow($role, $label, (array) ($hier[$role] ?? []));
+}
+$subRows = '';
+$defaultSubs = [
+	['key' => 'monitor', 'label' => '监控核心', 'net' => 'SURVE', 'match' => 'contains', 'pattern' => 'core surve'],
+	['key' => 'office', 'label' => '办公核心', 'net' => 'OFFICE', 'match' => 'contains', 'pattern' => 'core office'],
+	['key' => 'production', 'label' => '生产核心', 'net' => '', 'match' => 'contains', 'pattern' => '']
+];
+$savedSubs = (array) ($hier['sub_cores'] ?? []);
+$savedByKey = [];
+foreach ($savedSubs as $sr) {
+	$savedByKey[(string) ($sr['key'] ?? '')] = $sr;
+}
+$subDefs = [];
+foreach ($defaultSubs as $d) {
+	$r = $savedByKey[$d['key']] ?? $d;
+	$r['label'] = $d['label'];
+	$subDefs[] = $r;
+	unset($savedByKey[$d['key']]);
+}
+foreach ($savedByKey as $extra) {
+	$subDefs[] = $extra;
+}
+foreach ($subDefs as $i => $r) {
+	$netSel = '<select class="wc-input" name="sub_net[]" style="max-width:190px">'
 		.'<option value="">'.$t('lbl.core_any').'</option>';
 	foreach ((array) $l2['networks'] as $nw) {
 		$k = (string) ($nw['key'] ?? '');
 		$netSel .= '<option value="'.$e($k).'"'.((string) ($r['net'] ?? '') === $k ? ' selected' : '').'>'.$e($k).' · '.$e($nw['label'] ?? $k).'</option>';
 	}
 	$netSel .= '</select>';
-	$isRegex = (($r['match'] ?? 'contains') === 'regex');
-	return '<div class="wc-rule">'.$netSel
-		.'<select class="wc-input" name="core_match[]" style="max-width:120px">'
-		.'<option value="contains"'.($isRegex ? '' : ' selected').'>contains</option>'
-		.'<option value="regex"'.($isRegex ? ' selected' : '').'>regex</option></select>'
-		.'<input class="wc-input wc-input--mono" name="core_pattern[]" value="'.$e($r['pattern'] ?? '').'" placeholder="'.$t('lbl.core_pattern').'">'
-		.'<button type="button" class="wc-btn wc-btn--ghost wc-rule-del" title="remove">&times;</button></div>';
-};
-$coreRows = '';
-foreach ((array) ($l2['core_rules'] ?? []) as $r) {
-	$coreRows .= $coreRowHtml($r);
+	$subRows .= '<div class="wc-rule"><span class="wc-rule-label">'.$e($r['label'] ?? ('核心 '.($i + 1))).'</span>'
+		.'<input type="hidden" name="sub_key[]" value="'.$e($r['key'] ?? ('sub'.($i + 1))).'">'
+		.'<input type="hidden" name="sub_label[]" value="'.$e($r['label'] ?? ('核心 '.($i + 1))).'">'
+		.$netSel
+		.$matchSel('sub_match[]', $r['match'] ?? 'contains')
+		.'<input class="wc-input wc-input--mono" name="sub_pattern[]" value="'.$e($r['pattern'] ?? '').'" placeholder="'.$t('lbl.core_pattern').'(留空 = 不显示)">'
+		.'</div>';
 }
 $coreBody =
 	'<div class="wc-hint">'.$t('lbl.l2_cores_hint').'</div>'
-	.'<div class="wc-rules" id="wc-cores">'.$coreRows.'</div>'
-	.'<div class="wc-actions"><button type="button" class="wc-btn wc-btn--ghost" id="wc-core-add">'.$t('btn.add_core').'</button></div>'
-	.'<script type="text/template" id="wc-core-tpl">'.$coreRowHtml(['net' => '', 'match' => 'contains', 'pattern' => '']).'</script>';
+	.'<div class="wc-rules">'.$hierRows.'</div>'
+	.'<div class="wc-divider"></div>'
+	.'<div class="wc-hint">'.$t('lbl.l2_subcores_hint').'</div>'
+	.'<div class="wc-rules">'.$subRows.'</div>';
 $coreCard = $card('&#128273;', $t('sec.l2_cores'), $coreBody, ' wc-card--wide');
 
 $colorFields = '';
-foreach (['100' => 'lbl.color_100', '1000' => 'lbl.color_1000', '10000' => 'lbl.color_10000',
+foreach (['100' => 'lbl.color_100', '1000' => 'lbl.color_1000', '2500' => 'lbl.color_2500', '10000' => 'lbl.color_10000',
 		'20000' => 'lbl.color_20000', 'unknown' => 'lbl.color_unknown', 'down' => 'lbl.color_down'] as $ck => $lab) {
 	$colorFields .= '<div class="wc-field" style="flex:1;min-width:110px"><label>'.$t($lab).'</label>'
 		.'<input class="wc-input wc-input--color" type="color" name="color_'.$ck.'" value="'.$e($l2['colors'][$ck] ?? '#000000').'"></div>';
@@ -437,7 +476,7 @@ $groupCard = $card('&#128193;', $t('sec.l2_group'), $groupBody, ' wc-card--wide'
 
 $tab_l2 = '<form method="post" action="'.$save.'" id="wc-l2-form">'
 	.'<input type="hidden" name="op" value="save_l2"><input type="hidden" name="tab" value="l2">'
-	.'<input type="hidden" name="core_rules_present" value="1">'
+	.'<input type="hidden" name="core_hierarchy_present" value="1">'
 	.'<div class="wc-grid">'.$collectCard.$displayCard.$networkCard.$coreCard.$colorCard.$latCard.$groupCard.'</div>'
 	.'<div class="wc-actions" style="margin-top:18px"><button class="wc-btn wc-btn--primary" type="submit">'.$t('btn.save').'</button></div>'
 	.'</form>'

+ 26 - 4
web-modules/install.sh

@@ -14,7 +14,8 @@
 #
 # Env knobs:
 #   MYSQL_CONTAINER (default: auto / zabbix-mysql)
-#   MYSQL_ROOT_PASSWORD (default: CobcoIT132)
+#   MYSQL_USER / MYSQL_PASSWORD (default: read from the Zabbix MySQL container)
+#   MYSQL_ROOT_PASSWORD (optional fallback only; leave unset to use MYSQL_USER)
 #   MYSQL_DATABASE (default: zabbix)
 #   COLLECTOR_DIR (default: /opt/zabbix/l2collector)
 #   INSTALL_CRON=0 to skip installing crontab entries.
@@ -24,7 +25,9 @@ set -euo pipefail
 HERE="$(cd "$(dirname "$0")" && pwd)"
 SRC="$HERE/WecomCenter"
 MYSQL_CONTAINER="${MYSQL_CONTAINER:-}"
-MYSQL_ROOT_PASSWORD="${MYSQL_ROOT_PASSWORD:-CobcoIT132}"
+MYSQL_USER="${MYSQL_USER:-}"
+MYSQL_PASSWORD="${MYSQL_PASSWORD:-}"
+MYSQL_ROOT_PASSWORD="${MYSQL_ROOT_PASSWORD:-}"
 MYSQL_DATABASE="${MYSQL_DATABASE:-zabbix}"
 COLLECTOR_DIR="${COLLECTOR_DIR:-/opt/zabbix/l2collector}"
 INSTALL_CRON="${INSTALL_CRON:-1}"
@@ -66,8 +69,24 @@ if [ -z "$MYSQL_CONTAINER" ]; then
   MYSQL_CONTAINER="$(docker ps --format '{{.Names}}' 2>/dev/null | grep -iE 'zabbix.*(mysql|db|mariadb)' | head -n1 || true)"
 fi
 if [ -n "$MYSQL_CONTAINER" ]; then
-  echo "Ensuring L2 tables in container '$MYSQL_CONTAINER' ..."
-  docker exec -i "$MYSQL_CONTAINER" mysql -uroot -p"$MYSQL_ROOT_PASSWORD" "$MYSQL_DATABASE" <<'SQL' || echo "WARN: could not create tables automatically (page will retry on first load)."
+  # Reuse the credentials Zabbix already injected into its MySQL container;
+  # never keep a second, hard-coded database login in this installer.
+  if [ -z "$MYSQL_USER" ] || [ -z "$MYSQL_PASSWORD" ]; then
+    MYSQL_ENV="$(docker inspect "$MYSQL_CONTAINER" --format '{{range .Config.Env}}{{println .}}{{end}}' 2>/dev/null || true)"
+    MYSQL_USER="${MYSQL_USER:-$(printf '%s\n' "$MYSQL_ENV" | sed -n 's/^MYSQL_USER=//p' | head -n1)}"
+    MYSQL_PASSWORD="${MYSQL_PASSWORD:-$(printf '%s\n' "$MYSQL_ENV" | sed -n 's/^MYSQL_PASSWORD=//p' | head -n1)}"
+    MYSQL_DATABASE="${MYSQL_DATABASE:-$(printf '%s\n' "$MYSQL_ENV" | sed -n 's/^MYSQL_DATABASE=//p' | head -n1)}"
+  fi
+  if [ -n "$MYSQL_USER" ] && [ -n "$MYSQL_PASSWORD" ]; then
+    MYSQL_AUTH=(-u"$MYSQL_USER" -p"$MYSQL_PASSWORD")
+  elif [ -n "$MYSQL_ROOT_PASSWORD" ]; then
+    MYSQL_AUTH=(-uroot -p"$MYSQL_ROOT_PASSWORD")
+  else
+    MYSQL_AUTH=()
+  fi
+  if [ ${#MYSQL_AUTH[@]} -gt 0 ]; then
+    echo "Ensuring L2 tables in container '$MYSQL_CONTAINER' ..."
+    docker exec -i "$MYSQL_CONTAINER" mysql "${MYSQL_AUTH[@]}" "$MYSQL_DATABASE" <<'SQL' || echo "WARN: could not create tables automatically (page will retry on first load)."
 CREATE TABLE IF NOT EXISTS l2_topology_history (
   id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, ts BIGINT UNSIGNED NOT NULL,
   captured_at DATETIME NOT NULL, stats TEXT NOT NULL, snapshot MEDIUMBLOB NOT NULL,
@@ -78,6 +97,9 @@ CREATE TABLE IF NOT EXISTS l2_topology_config (
   PRIMARY KEY (id)
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;
 SQL
+  else
+    echo "WARN: no MYSQL_USER/MYSQL_PASSWORD found in '$MYSQL_CONTAINER'; page auto-creates tables on load."
+  fi
 else
   echo "WARN: no MySQL container detected; skip table creation (page auto-creates on load)."
 fi