|
|
@@ -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 ({'&':'&','<':'<','>':'>','"':'"',"'":'''})[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();
|
|
|
};
|