|
@@ -17,8 +17,8 @@ if (!colors['2500']) colors['2500'] = '#17C3B2';
|
|
|
var LATW = (CFG.latency_warn != null) ? +CFG.latency_warn : 3;
|
|
var LATW = (CFG.latency_warn != null) ? +CFG.latency_warn : 3;
|
|
|
var LATB = (CFG.latency_bad != null) ? +CFG.latency_bad : 5;
|
|
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 SEV_COLORS = ['#97AAB3','#7499FF','#FFC859','#FFA059','#E97659','#E45959'];
|
|
|
|
|
|
|
|
var NODE_W = 156, NODE_H = 50, GX = 26, GY = 18, PADX = 20, PADY = 42;
|
|
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
|
|
// Multiple cores in the same network are supported and all of them are
|
|
|
// placed in the left-most core column.
|
|
// placed in the left-most core column.
|
|
|
function coreRules() {
|
|
function coreRules() {
|
|
|
|
|
+ var h = CFG.core_hierarchy;
|
|
|
|
|
+ if (h && h.sub_cores instanceof Array) return h.sub_cores;
|
|
|
var r = CFG.core_rules;
|
|
var r = CFG.core_rules;
|
|
|
return (r instanceof Array) ? r : [];
|
|
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;
|
|
for (var i = 0; i < rules.length; i++) if (coreRuleHit(rules[i], n)) return true;
|
|
|
return false;
|
|
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) {
|
|
function configuredCoreNet(n) {
|
|
|
var rules = coreRules();
|
|
var rules = coreRules();
|
|
|
for (var i = 0; i < rules.length; i++) {
|
|
for (var i = 0; i < rules.length; i++) {
|
|
@@ -645,6 +654,9 @@ if (!colors['2500']) colors['2500'] = '#17C3B2';
|
|
|
return net;
|
|
return net;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+ function isUpstreamRole(n) {
|
|
|
|
|
+ return ['firewall', 'behavior', 'total_core'].indexOf(String((n && n.hierarchy_role) || '')) >= 0;
|
|
|
|
|
+ }
|
|
|
function roleOf(n) {
|
|
function roleOf(n) {
|
|
|
var p = parseName(n.name || '');
|
|
var p = parseName(n.name || '');
|
|
|
var r = String(p.role || '').toLowerCase();
|
|
var r = String(p.role || '').toLowerCase();
|
|
@@ -652,10 +664,12 @@ if (!colors['2500']) colors['2500'] = '#17C3B2';
|
|
|
var childCount = parseInt(n.children, 10);
|
|
var childCount = parseInt(n.children, 10);
|
|
|
if (isNaN(childCount)) childCount = -1;
|
|
if (isNaN(childCount)) childCount = -1;
|
|
|
if (configuredCore(n)) return 'core';
|
|
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
|
|
// Hostname role is the display truth for Access switches. An access
|
|
|
// switch may itself have access children (cascade/stack); it still stays
|
|
// switch may itself have access children (cascade/stack); it still stays
|
|
|
// an access node and the children simply take a further-right column.
|
|
// 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) {
|
|
function switchLabel(n, role, netKey) {
|
|
|
var g = groupOf(n).label;
|
|
var g = groupOf(n).label;
|
|
|
var s;
|
|
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 if (role === 'main') s = roleName('main') + ' · ' + g + ' · ' + (n.ip || '');
|
|
|
else {
|
|
else {
|
|
|
var loc = shortLoc(n);
|
|
var loc = shortLoc(n);
|
|
@@ -739,6 +762,7 @@ if (!colors['2500']) colors['2500'] = '#17C3B2';
|
|
|
return cur || n;
|
|
return cur || n;
|
|
|
}
|
|
}
|
|
|
(snap.nodes || []).forEach(function (n) {
|
|
(snap.nodes || []).forEach(function (n) {
|
|
|
|
|
+ if (isUpstreamRole(n)) return;
|
|
|
var r = roleOf(n);
|
|
var r = roleOf(n);
|
|
|
if (r === 'core' || r === 'main') return;
|
|
if (r === 'core' || r === 'main') return;
|
|
|
var root = rootOf(n);
|
|
var root = rootOf(n);
|
|
@@ -750,6 +774,7 @@ if (!colors['2500']) colors['2500'] = '#17C3B2';
|
|
|
function workshopList(snap, netMap, netKey, wsKeys) {
|
|
function workshopList(snap, netMap, netKey, wsKeys) {
|
|
|
var map = {}, order = [];
|
|
var map = {}, order = [];
|
|
|
(snap.nodes || []).forEach(function (n) {
|
|
(snap.nodes || []).forEach(function (n) {
|
|
|
|
|
+ if (isUpstreamRole(n)) return;
|
|
|
if (netMap[n.id] !== netKey) return;
|
|
if (netMap[n.id] !== netKey) return;
|
|
|
var r = roleOf(n);
|
|
var r = roleOf(n);
|
|
|
if (r === 'core' || r === 'main') return;
|
|
if (r === 'core' || r === 'main') return;
|
|
@@ -898,11 +923,54 @@ if (!colors['2500']) colors['2500'] = '#17C3B2';
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
function computeFull(snap) {
|
|
function computeFull(snap) {
|
|
|
- var nodes = snap.nodes || [];
|
|
|
|
|
|
|
+ var nodes = (snap.nodes || []).slice();
|
|
|
var netMap = assignNetworks(snap);
|
|
var netMap = assignNetworks(snap);
|
|
|
var sections = [], placements = [], frames = [], roleMap = {};
|
|
var sections = [], placements = [], frames = [], roleMap = {};
|
|
|
var wsKeys = workshopKeys(snap);
|
|
var wsKeys = workshopKeys(snap);
|
|
|
var y = 100, maxRight = CHART_W;
|
|
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) {
|
|
NETWORKS.forEach(function (net) {
|
|
|
var secNodes = nodes.filter(function (n) { return netMap[n.id] === net.key; });
|
|
var secNodes = nodes.filter(function (n) { return netMap[n.id] === net.key; });
|
|
|
if (!secNodes.length) return;
|
|
if (!secNodes.length) return;
|
|
@@ -959,6 +1027,7 @@ if (!colors['2500']) colors['2500'] = '#17C3B2';
|
|
|
});
|
|
});
|
|
|
var chartW = Math.max(CHART_W, maxRight + 40);
|
|
var chartW = Math.max(CHART_W, maxRight + 40);
|
|
|
sections.forEach(function (sec) { sec.w = chartW - 20; });
|
|
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};
|
|
var root = {x: 0, y: 0, w: chartW, h: y + 6};
|
|
|
return {sections: sections, placements: placements, frames: frames, netMap: netMap, roleMap: roleMap, root: root};
|
|
return {sections: sections, placements: placements, frames: frames, netMap: netMap, roleMap: roleMap, root: root};
|
|
|
}
|
|
}
|
|
@@ -1310,7 +1379,6 @@ if (!colors['2500']) colors['2500'] = '#17C3B2';
|
|
|
buildAlarms(snap);
|
|
buildAlarms(snap);
|
|
|
buildOffline(snap);
|
|
buildOffline(snap);
|
|
|
buildTerminals(snap);
|
|
buildTerminals(snap);
|
|
|
- buildHistory();
|
|
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
function renderFull(snap) {
|
|
function renderFull(snap) {
|
|
@@ -1343,11 +1411,12 @@ if (!colors['2500']) colors['2500'] = '#17C3B2';
|
|
|
|
|
|
|
|
L.placements.forEach(function (p) {
|
|
L.placements.forEach(function (p) {
|
|
|
var n = p.node, cls = [];
|
|
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;
|
|
var bd = bg;
|
|
|
// Alarm and offline are node-level states: fill the whole node red.
|
|
// Alarm and offline are node-level states: fill the whole node red.
|
|
|
- // Only latency > LATB is allowed to draw a red frame; 3-5 ms uses an
|
|
|
|
|
- // orange frame. Alarm must never be represented by a red border alone.
|
|
|
|
|
|
|
+ // Only latency > LATB is allowed to draw a red frame; LATW..LATB uses
|
|
|
|
|
+ // an orange frame. Alarm must never be represented by a red border alone.
|
|
|
if (n.alarms && n.alarms.length) { cls.push('alarm'); bg = '#E45959'; bd = '#E45959'; }
|
|
if (n.alarms && n.alarms.length) { cls.push('alarm'); bg = '#E45959'; bd = '#E45959'; }
|
|
|
if (n.offline || n.oper === 'down') { cls.push('offline'); bg = '#C0392B'; bd = '#C0392B'; }
|
|
if (n.offline || n.oper === 'down') { cls.push('offline'); bg = '#C0392B'; bd = '#C0392B'; }
|
|
|
// Alarm/offline already owns the whole fill; do not put a latency frame
|
|
// Alarm/offline already owns the whole fill; do not put a latency frame
|
|
@@ -1363,7 +1432,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,
|
|
maxsev: n.maxsev, alarms: n.alarms || [], term_online: n.term_online, term_offline: 0, latency_ms: n.latency_ms,
|
|
|
poe: !!n.poe,
|
|
poe: !!n.poe,
|
|
|
label: switchLabel(n, p.role, p.net), tw: p.w - 12, bg: bg, bd: bd, w: p.w, h: p.h,
|
|
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(' ')});
|
|
}, position: {x: p.x, y: p.y}, classes: cls.join(' ')});
|
|
|
});
|
|
});
|
|
|
|
|
|
|
@@ -1683,7 +1753,7 @@ if (!colors['2500']) colors['2500'] = '#17C3B2';
|
|
|
/* ---------------- side panels & stats ---------------- */
|
|
/* ---------------- side panels & stats ---------------- */
|
|
|
function updateStats(snap) {
|
|
function updateStats(snap) {
|
|
|
var s = snap.stats || {};
|
|
var s = snap.stats || {};
|
|
|
- var txt = '● ' + (s.switches || 0) + ' | ⇄ ' + (s.edges || 0) + ' | ' +
|
|
|
|
|
|
|
+ var txt = '● ' + (s.switches || 0) + ' | ⇄ ' + (s.edges || 0) + ' | ⚡ ' + (s.poe_switches || 0) + ' ' + t('l2.poe', 'PoE') + ' | ' +
|
|
|
(s.term_total || 0) + ' ' + t('l2.terminals', 'terminals') + ' | ' +
|
|
(s.term_total || 0) + ' ' + t('l2.terminals', 'terminals') + ' | ' +
|
|
|
(s.switches_offline || 0) + ' ' + t('l2.offline_short', 'offline SW') + ' | ⚠ ' + (s.alarms || 0);
|
|
(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) : '';
|
|
var up = snap.generated_iso ? (' · ' + t('l2.updated', 'Updated') + ' ' + snap.generated_iso) : '';
|
|
@@ -1703,18 +1773,22 @@ if (!colors['2500']) colors['2500'] = '#17C3B2';
|
|
|
'<span class="lg-box" style="background:' + TIER_BG.dist + ';margin-left:8px"></span>' + t('l2.dist', 'Distribution') +
|
|
'<span class="lg-box" style="background:' + TIER_BG.dist + ';margin-left:8px"></span>' + t('l2.dist', 'Distribution') +
|
|
|
'<span class="lg-box" style="background:' + TIER_BG.access + ';margin-left:8px"></span>' + t('l2.access', 'Access') + '</div>';
|
|
'<span class="lg-box" style="background:' + TIER_BG.access + ';margin-left:8px"></span>' + t('l2.access', 'Access') + '</div>';
|
|
|
h += '<div class="lg-title">' + t('l2.legend_lat', 'Latency') + '</div>';
|
|
h += '<div class="lg-title">' + t('l2.legend_lat', 'Latency') + '</div>';
|
|
|
- h += '<div class="lg-row"><span class="lg-box" style="background:transparent;border:2px solid #F39C12;border-radius:2px"></span>>' + LATW + 'ms' +
|
|
|
|
|
- '<span class="lg-box" style="background:transparent;border:2px solid #E74C3C;border-radius:2px;margin-left:8px"></span>>' + 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:#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>';
|
|
|
|
|
|
|
+ // Latency + alarm state share one compact row. This keeps the legend
|
|
|
|
|
+ // short enough to leave more room for the real topology on small screens.
|
|
|
|
|
+ h += '<div class="lg-row lg-compact">' +
|
|
|
|
|
+ '<span class="lg-box" style="background:transparent;border:2px solid #F39C12;border-radius:2px"></span>>' + LATW + 'ms' +
|
|
|
|
|
+ '<span class="lg-box" style="background:transparent;border:2px solid #E74C3C;border-radius:2px"></span>>' + LATB + 'ms' +
|
|
|
|
|
+ '<span class="lg-sep"></span>' +
|
|
|
|
|
+ '<span class="lg-box" style="background:#E45959;border-radius:2px"></span>' + t('l2.alarms', 'Alarms') +
|
|
|
|
|
+ '<span class="lg-box" style="background:#2ECC71;border-radius:50%"></span>' + t('l2.term_on', 'Online') +
|
|
|
|
|
+ '<span class="lg-box" style="background:#C0392B;border-radius:2px"></span>' + t('l2.offline', 'Offline switch') +
|
|
|
|
|
+ '</div>';
|
|
|
el('l2-legend').innerHTML = h;
|
|
el('l2-legend').innerHTML = h;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
function buildAlarms(snap) {
|
|
function buildAlarms(snap) {
|
|
|
var items = [];
|
|
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) {
|
|
if (n.maxsev != null && n.maxsev >= 0 && n.alarms && n.alarms.length) {
|
|
|
n.alarms.forEach(function (a) {
|
|
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});
|
|
items.push({sev: n.maxsev, title: (a.name || a.description || ''), host: n.name, ip: n.ip, id: n.id, hostid: n.hostid});
|
|
@@ -1813,57 +1887,155 @@ if (!colors['2500']) colors['2500'] = '#17C3B2';
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
function buildHistory() {
|
|
function buildHistory() {
|
|
|
- var box = el('l2-history-list');
|
|
|
|
|
- if (!box) return;
|
|
|
|
|
|
|
+ var picker = el('l2-ts-picker');
|
|
|
|
|
+ if (!picker || picker.getAttribute('data-built') === '1') return;
|
|
|
|
|
+ picker.setAttribute('data-built', '1');
|
|
|
|
|
+
|
|
|
|
|
+ var trigger = el('l2-ts-trigger');
|
|
|
|
|
+ var pop = el('l2-ts-pop');
|
|
|
|
|
+ var daysEl = el('l2-ts-days');
|
|
|
|
|
+ var timesEl = el('l2-ts-times');
|
|
|
|
|
+ var timesTitle = el('l2-ts-times-title');
|
|
|
|
|
+ var label = el('l2-ts-current');
|
|
|
var list = window.L2_HISTORY || [];
|
|
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 {
|
|
|
|
|
|
|
+ var meta = window.L2_META || {};
|
|
|
|
|
+ var used = (!meta.isLive && meta.usedTs) ? String(meta.usedTs) : '';
|
|
|
|
|
+ var groups = {}, days = [];
|
|
|
|
|
+
|
|
|
|
|
+ // Group only timestamps that actually exist in l2_topology_history.
|
|
|
|
|
+ // The popover therefore never offers an arbitrary date/time and cannot
|
|
|
|
|
+ // lead to a blank page.
|
|
|
|
|
+ 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 currentDay = '';
|
|
|
|
|
+ var currentTs = '';
|
|
|
|
|
+ if (used) {
|
|
|
list.forEach(function (it) {
|
|
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>';
|
|
|
|
|
|
|
+ if (String(it.ts) === used) {
|
|
|
|
|
+ currentTs = used;
|
|
|
|
|
+ currentDay = String(it.captured_at || fmtTs(it.ts)).slice(0, 10);
|
|
|
|
|
+ }
|
|
|
});
|
|
});
|
|
|
}
|
|
}
|
|
|
- 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();
|
|
|
|
|
|
|
+
|
|
|
|
|
+ function timeText(it) {
|
|
|
|
|
+ var ca = String(it.captured_at || '');
|
|
|
|
|
+ return ca.length >= 19 ? ca.slice(11, 19) : fmtTs(it.ts);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ function go(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();
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ function positionPop() {
|
|
|
|
|
+ if (!pop) return;
|
|
|
|
|
+ if (window.matchMedia('(max-width: 720px)').matches) {
|
|
|
|
|
+ var rect = trigger.getBoundingClientRect();
|
|
|
|
|
+ var width = Math.min(430, window.innerWidth - 20);
|
|
|
|
|
+ var left = Math.max(10, Math.min(rect.left, window.innerWidth - 10 - width));
|
|
|
|
|
+ pop.style.position = 'fixed';
|
|
|
|
|
+ pop.style.left = left + 'px';
|
|
|
|
|
+ pop.style.right = 'auto';
|
|
|
|
|
+ pop.style.top = Math.max(10, rect.bottom + 6) + 'px';
|
|
|
|
|
+ pop.style.width = width + 'px';
|
|
|
|
|
+ }
|
|
|
|
|
+ else {
|
|
|
|
|
+ pop.style.position = '';
|
|
|
|
|
+ pop.style.left = '';
|
|
|
|
|
+ pop.style.right = '';
|
|
|
|
|
+ pop.style.top = '';
|
|
|
|
|
+ pop.style.width = '';
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ function setExpanded(open) {
|
|
|
|
|
+ picker.classList.toggle('open', open);
|
|
|
|
|
+ trigger.setAttribute('aria-expanded', open ? 'true' : 'false');
|
|
|
|
|
+ if (open) positionPop();
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ function updateLabel() {
|
|
|
|
|
+ if (!currentDay) { label.textContent = t('l2.time_live_full', '实时(最新)'); return; }
|
|
|
|
|
+ if (!currentTs) { label.textContent = currentDay + ' · ' + t('l2.time_choose', '选择时间'); return; }
|
|
|
|
|
+ var found = null;
|
|
|
|
|
+ (groups[currentDay] || []).forEach(function (it) { if (String(it.ts) === currentTs) found = it; });
|
|
|
|
|
+ label.textContent = found ? currentDay + ' ' + timeText(found) : currentDay;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ function renderDays() {
|
|
|
|
|
+ daysEl.innerHTML = '';
|
|
|
|
|
+ var live = document.createElement('button');
|
|
|
|
|
+ live.type = 'button';
|
|
|
|
|
+ live.className = 'l2-ts-day' + (currentDay ? '' : ' active');
|
|
|
|
|
+ live.textContent = t('l2.time_live_full', '实时(最新)');
|
|
|
|
|
+ live.addEventListener('click', function () { go(''); });
|
|
|
|
|
+ daysEl.appendChild(live);
|
|
|
|
|
+ days.forEach(function (day) {
|
|
|
|
|
+ var b = document.createElement('button');
|
|
|
|
|
+ b.type = 'button';
|
|
|
|
|
+ b.className = 'l2-ts-day' + (day === currentDay ? ' active' : '');
|
|
|
|
|
+ b.textContent = day + ' (' + groups[day].length + ')';
|
|
|
|
|
+ b.addEventListener('click', function (ev) {
|
|
|
|
|
+ ev.stopPropagation();
|
|
|
|
|
+ currentDay = day;
|
|
|
|
|
+ currentTs = '';
|
|
|
|
|
+ renderDays();
|
|
|
|
|
+ renderTimes();
|
|
|
|
|
+ updateLabel();
|
|
|
|
|
+ });
|
|
|
|
|
+ daysEl.appendChild(b);
|
|
|
});
|
|
});
|
|
|
- });
|
|
|
|
|
- if (el('l2-history-count')) el('l2-history-count').textContent = list.length;
|
|
|
|
|
- // Keep the toolbar selector in sync with the same history list. The
|
|
|
|
|
- // side panel is the primary entry point; the selector is a shortcut.
|
|
|
|
|
- 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);
|
|
|
|
|
- list.forEach(function (it) {
|
|
|
|
|
- var opt = document.createElement('option');
|
|
|
|
|
- opt.value = String(it.ts || '');
|
|
|
|
|
- opt.textContent = it.captured_at || fmtTs(it.ts);
|
|
|
|
|
- sel.appendChild(opt);
|
|
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ function renderTimes() {
|
|
|
|
|
+ timesEl.innerHTML = '';
|
|
|
|
|
+ if (!currentDay) {
|
|
|
|
|
+ timesTitle.textContent = t('l2.time_live', '实时');
|
|
|
|
|
+ var empty = document.createElement('div');
|
|
|
|
|
+ empty.className = 'l2-ts-empty';
|
|
|
|
|
+ empty.textContent = t('l2.time_live_hint', '当前显示最新采集数据');
|
|
|
|
|
+ timesEl.appendChild(empty);
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ timesTitle.textContent = currentDay;
|
|
|
|
|
+ (groups[currentDay] || []).forEach(function (it) {
|
|
|
|
|
+ var b = document.createElement('button');
|
|
|
|
|
+ b.type = 'button';
|
|
|
|
|
+ b.className = 'l2-ts-time' + (String(it.ts) === currentTs ? ' active' : '');
|
|
|
|
|
+ b.textContent = timeText(it);
|
|
|
|
|
+ b.addEventListener('click', function () { go(String(it.ts)); });
|
|
|
|
|
+ timesEl.appendChild(b);
|
|
|
});
|
|
});
|
|
|
- sel.value = used || old || '';
|
|
|
|
|
}
|
|
}
|
|
|
|
|
+
|
|
|
|
|
+ trigger.addEventListener('click', function (ev) {
|
|
|
|
|
+ ev.stopPropagation();
|
|
|
|
|
+ setExpanded(!picker.classList.contains('open'));
|
|
|
|
|
+ });
|
|
|
|
|
+ window.addEventListener('resize', function () {
|
|
|
|
|
+ if (picker.classList.contains('open')) positionPop();
|
|
|
|
|
+ });
|
|
|
|
|
+ window.addEventListener('scroll', function () {
|
|
|
|
|
+ if (picker.classList.contains('open')) positionPop();
|
|
|
|
|
+ }, {passive: true});
|
|
|
|
|
+ document.addEventListener('click', function (ev) {
|
|
|
|
|
+ if (!picker.contains(ev.target)) setExpanded(false);
|
|
|
|
|
+ });
|
|
|
|
|
+ document.addEventListener('keydown', function (ev) {
|
|
|
|
|
+ if (ev.key === 'Escape') setExpanded(false);
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ renderDays();
|
|
|
|
|
+ renderTimes();
|
|
|
|
|
+ updateLabel();
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
function esc(s) { return String(s == null ? '' : s).replace(/[&<>"']/g, function (c) { return ({'&':'&','<':'<','>':'>','"':'"',"'":'''})[c]; }); }
|
|
function esc(s) { return String(s == null ? '' : s).replace(/[&<>"']/g, function (c) { return ({'&':'&','<':'<','>':'>','"':'"',"'":'''})[c]; }); }
|
|
@@ -1879,7 +2051,99 @@ if (!colors['2500']) colors['2500'] = '#17C3B2';
|
|
|
setTimeout(function () { n.removeClass('hl'); }, 2600);
|
|
setTimeout(function () { n.removeClass('hl'); }, 2600);
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- function exportPng() {
|
|
|
|
|
|
|
+ // cy.png() only contains the Cytoscape canvas, not the HTML legend. Append
|
|
|
|
|
+ // a dedicated legend strip to the exported PNG (same symbols as the page,
|
|
|
|
|
+ // alarm block on the latency row, and no hover hint).
|
|
|
|
|
+ function withExportLegend(dataUrl) {
|
|
|
|
|
+ return new Promise(function (resolve, reject) {
|
|
|
|
|
+ var img = new Image();
|
|
|
|
|
+ img.onload = function () {
|
|
|
|
|
+ try {
|
|
|
|
|
+ var s = Math.max(1.0, Math.min(1.6, img.width / 1300));
|
|
|
|
|
+ var pad = Math.round(16 * s);
|
|
|
|
|
+ var legendH = Math.round(154 * s);
|
|
|
|
|
+ var cv = document.createElement('canvas');
|
|
|
|
|
+ cv.width = img.width;
|
|
|
|
|
+ cv.height = img.height + legendH;
|
|
|
|
|
+ var ctx = cv.getContext('2d');
|
|
|
|
|
+ ctx.fillStyle = '#ffffff';
|
|
|
|
|
+ ctx.fillRect(0, 0, cv.width, cv.height);
|
|
|
|
|
+ ctx.drawImage(img, 0, 0);
|
|
|
|
|
+ var y0 = img.height;
|
|
|
|
|
+ ctx.strokeStyle = '#cbd5e1';
|
|
|
|
|
+ ctx.lineWidth = Math.max(1, Math.round(s));
|
|
|
|
|
+ ctx.beginPath(); ctx.moveTo(0, y0); ctx.lineTo(cv.width, y0); ctx.stroke();
|
|
|
|
|
+ ctx.textBaseline = 'middle';
|
|
|
|
|
+ var family = '"Microsoft YaHei","PingFang SC",Arial,sans-serif';
|
|
|
|
|
+ function title(text, y) {
|
|
|
|
|
+ ctx.fillStyle = '#1f2937';
|
|
|
|
|
+ ctx.font = '600 ' + Math.round(12.5 * s) + 'px ' + family;
|
|
|
|
|
+ ctx.fillText(text, pad, y);
|
|
|
|
|
+ }
|
|
|
|
|
+ function lineItem(x, y, color, text, dashed) {
|
|
|
|
|
+ ctx.strokeStyle = color;
|
|
|
|
|
+ ctx.lineWidth = Math.max(2, Math.round(3 * s));
|
|
|
|
|
+ ctx.setLineDash(dashed ? [Math.round(5 * s), Math.round(3 * s)] : []);
|
|
|
|
|
+ ctx.beginPath();
|
|
|
|
|
+ ctx.moveTo(x, y); ctx.lineTo(x + Math.round(24 * s), y); ctx.stroke();
|
|
|
|
|
+ ctx.setLineDash([]);
|
|
|
|
|
+ ctx.fillStyle = '#263445';
|
|
|
|
|
+ ctx.font = Math.round(11.5 * s) + 'px ' + family;
|
|
|
|
|
+ ctx.fillText(text, x + Math.round(30 * s), y + 1);
|
|
|
|
|
+ return x + Math.round(30 * s) + ctx.measureText(text).width + Math.round(18 * s);
|
|
|
|
|
+ }
|
|
|
|
|
+ function boxItem(x, y, color, text, circle, outline) {
|
|
|
|
|
+ var bw = Math.round(12 * s), by = y - bw / 2;
|
|
|
|
|
+ ctx.fillStyle = outline ? 'transparent' : color;
|
|
|
|
|
+ ctx.strokeStyle = color;
|
|
|
|
|
+ ctx.lineWidth = Math.max(2, Math.round(2 * s));
|
|
|
|
|
+ ctx.beginPath();
|
|
|
|
|
+ if (circle) ctx.arc(x + bw / 2, y, bw / 2, 0, Math.PI * 2);
|
|
|
|
|
+ else ctx.rect(x, by, bw, bw);
|
|
|
|
|
+ if (outline) ctx.stroke(); else ctx.fill();
|
|
|
|
|
+ ctx.fillStyle = '#263445';
|
|
|
|
|
+ ctx.font = Math.round(11.5 * s) + 'px ' + family;
|
|
|
|
|
+ ctx.fillText(text, x + bw + Math.round(5 * s), y + 1);
|
|
|
|
|
+ return x + bw + Math.round(5 * s) + ctx.measureText(text).width + Math.round(16 * s);
|
|
|
|
|
+ }
|
|
|
|
|
+ var y = y0 + Math.round(18 * s);
|
|
|
|
|
+ title(t('l2.legend_speed', '链路速率'), y);
|
|
|
|
|
+ y += Math.round(22 * s);
|
|
|
|
|
+ var x = pad;
|
|
|
|
|
+ x = lineItem(x, y, colors['100'], '100M');
|
|
|
|
|
+ x = lineItem(x, y, colors['1000'], '1G');
|
|
|
|
|
+ x = lineItem(x, y, colors['2500'], '2.5G');
|
|
|
|
|
+ x = lineItem(x, y, colors['10000'], '10G');
|
|
|
|
|
+ x = lineItem(x, y, colors['20000'], '20G+');
|
|
|
|
|
+ x = lineItem(x, y, colors.unknown, t('lbl.color_unknown', 'Unknown'));
|
|
|
|
|
+ lineItem(x, y, colors.down, t('lbl.color_down', 'Down'), true);
|
|
|
|
|
+ y += Math.round(24 * s);
|
|
|
|
|
+ title(t('l2.legend_tier', '层级'), y);
|
|
|
|
|
+ y += Math.round(22 * s);
|
|
|
|
|
+ x = pad;
|
|
|
|
|
+ x = boxItem(x, y, TIER_BG.core, t('l2.core', '核心'));
|
|
|
|
|
+ x = boxItem(x, y, '#2E75B6', t('l2.main', '主交换'));
|
|
|
|
|
+ x = boxItem(x, y, TIER_BG.dist, t('l2.dist', '汇聚'));
|
|
|
|
|
+ boxItem(x, y, TIER_BG.access, t('l2.access', '接入'));
|
|
|
|
|
+ y += Math.round(24 * s);
|
|
|
|
|
+ title(t('l2.legend_lat', '延迟'), y);
|
|
|
|
|
+ y += Math.round(22 * s);
|
|
|
|
|
+ x = pad;
|
|
|
|
|
+ x = boxItem(x, y, '#F39C12', '>' + LATW + 'ms', false, true);
|
|
|
|
|
+ x = boxItem(x, y, '#E74C3C', '>' + LATB + 'ms', false, true);
|
|
|
|
|
+ x += Math.round(16 * s);
|
|
|
|
|
+ x = boxItem(x, y, '#E45959', t('l2.alarms', '告警'));
|
|
|
|
|
+ x = boxItem(x, y, '#2ECC71', t('l2.term_on', '在线'), true);
|
|
|
|
|
+ boxItem(x, y, '#C0392B', t('l2.offline', '离线交换机'));
|
|
|
|
|
+ resolve(cv.toDataURL('image/png'));
|
|
|
|
|
+ } catch (e) { reject(e); }
|
|
|
|
|
+ };
|
|
|
|
|
+ img.onerror = function () { reject(new Error('legend image load failed')); };
|
|
|
|
|
+ img.src = dataUrl;
|
|
|
|
|
+ });
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ async function exportPng() {
|
|
|
if (!cy) return;
|
|
if (!cy) return;
|
|
|
var btn = el('l2-export'), oldText = btn ? btn.textContent : '';
|
|
var btn = el('l2-export'), oldText = btn ? btn.textContent : '';
|
|
|
if (btn) { btn.disabled = true; btn.textContent = t('l2.exporting', '正在导出…'); }
|
|
if (btn) { btn.disabled = true; btn.textContent = t('l2.exporting', '正在导出…'); }
|
|
@@ -1894,10 +2158,11 @@ if (!colors['2500']) colors['2500'] = '#17C3B2';
|
|
|
flowEdges.style('display', 'none');
|
|
flowEdges.style('display', 'none');
|
|
|
cy.style(styleFor(false));
|
|
cy.style(styleFor(false));
|
|
|
if (typeof cy.forceRender === 'function') cy.forceRender();
|
|
if (typeof cy.forceRender === 'function') cy.forceRender();
|
|
|
- var data = cy.png({full: true, scale: 1, maxWidth: 2600, maxHeight: 18000, bg: '#ffffff'});
|
|
|
|
|
|
|
+ var raw = cy.png({full: true, scale: 1, maxWidth: 2600, maxHeight: 18000, bg: '#ffffff'});
|
|
|
|
|
+ var data = await withExportLegend(raw);
|
|
|
var now = new Date();
|
|
var now = new Date();
|
|
|
function p2(v) { return (v < 10 ? '0' : '') + v; }
|
|
function p2(v) { return (v < 10 ? '0' : '') + v; }
|
|
|
- var suffix = META.usedTs ? ('_' + META.usedTs) : '_live';
|
|
|
|
|
|
|
+ var suffix = (!META.isLive && META.usedTs) ? ('_' + META.usedTs) : '_live';
|
|
|
var name = 'L2_Topology_' + now.getFullYear() + p2(now.getMonth() + 1) + p2(now.getDate()) +
|
|
var name = 'L2_Topology_' + now.getFullYear() + p2(now.getMonth() + 1) + p2(now.getDate()) +
|
|
|
'_' + p2(now.getHours()) + p2(now.getMinutes()) + suffix + '.png';
|
|
'_' + p2(now.getHours()) + p2(now.getMinutes()) + suffix + '.png';
|
|
|
var a = document.createElement('a');
|
|
var a = document.createElement('a');
|
|
@@ -2198,13 +2463,6 @@ if (!colors['2500']) colors['2500'] = '#17C3B2';
|
|
|
if (!ev.target.closest('.l2-search-wrap')) el('l2-sug').style.display = 'none';
|
|
if (!ev.target.closest('.l2-search-wrap')) el('l2-sug').style.display = 'none';
|
|
|
});
|
|
});
|
|
|
|
|
|
|
|
- el('l2-ts').addEventListener('change', function () {
|
|
|
|
|
- var v = el('l2-ts').value;
|
|
|
|
|
- var u = '/zabbix.php?action=l2.topology.view';
|
|
|
|
|
- if (v) u += '&ts=' + encodeURIComponent(v);
|
|
|
|
|
- window.location = u;
|
|
|
|
|
- });
|
|
|
|
|
-
|
|
|
|
|
el('l2-pause').addEventListener('click', function () {
|
|
el('l2-pause').addEventListener('click', function () {
|
|
|
paused = !paused;
|
|
paused = !paused;
|
|
|
el('l2-pause').classList.toggle('on', paused);
|
|
el('l2-pause').classList.toggle('on', paused);
|
|
@@ -2238,6 +2496,7 @@ if (!colors['2500']) colors['2500'] = '#17C3B2';
|
|
|
/* ---------------- boot ---------------- */
|
|
/* ---------------- boot ---------------- */
|
|
|
function boot() {
|
|
function boot() {
|
|
|
setupControls();
|
|
setupControls();
|
|
|
|
|
+ buildHistory();
|
|
|
fitViewport();
|
|
fitViewport();
|
|
|
try {
|
|
try {
|
|
|
var qs = new URLSearchParams(window.location.search);
|
|
var qs = new URLSearchParams(window.location.search);
|