|
|
@@ -1703,12 +1703,16 @@ 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.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-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;
|
|
|
}
|
|
|
|
|
|
@@ -1813,10 +1817,41 @@ if (!colors['2500']) colors['2500'] = '#17C3B2';
|
|
|
}
|
|
|
|
|
|
function buildHistory() {
|
|
|
+ var list = window.L2_HISTORY || [];
|
|
|
+ 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 = [];
|
|
|
+ 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);
|
|
|
+ });
|
|
|
+ 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 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>';
|
|
|
@@ -1846,24 +1881,6 @@ if (!colors['2500']) colors['2500'] = '#17C3B2';
|
|
|
});
|
|
|
});
|
|
|
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);
|
|
|
- });
|
|
|
- sel.value = used || old || '';
|
|
|
- }
|
|
|
}
|
|
|
|
|
|
function esc(s) { return String(s == null ? '' : s).replace(/[&<>"']/g, function (c) { return ({'&':'&','<':'<','>':'>','"':'"',"'":'''})[c]; }); }
|
|
|
@@ -1879,7 +1896,99 @@ if (!colors['2500']) colors['2500'] = '#17C3B2';
|
|
|
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;
|
|
|
var btn = el('l2-export'), oldText = btn ? btn.textContent : '';
|
|
|
if (btn) { btn.disabled = true; btn.textContent = t('l2.exporting', '正在导出…'); }
|
|
|
@@ -1894,10 +2003,11 @@ if (!colors['2500']) colors['2500'] = '#17C3B2';
|
|
|
flowEdges.style('display', 'none');
|
|
|
cy.style(styleFor(false));
|
|
|
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();
|
|
|
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()) +
|
|
|
'_' + p2(now.getHours()) + p2(now.getMinutes()) + suffix + '.png';
|
|
|
var a = document.createElement('a');
|
|
|
@@ -2198,30 +2308,17 @@ if (!colors['2500']) colors['2500'] = '#17C3B2';
|
|
|
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;
|
|
|
- });
|
|
|
-
|
|
|
if (el('l2-ts-go')) {
|
|
|
var goTs = function () {
|
|
|
- var pick = el('l2-ts-pick');
|
|
|
- if (!pick || !pick.value) return;
|
|
|
- var ms = new Date(pick.value).getTime();
|
|
|
- if (isNaN(ms)) return;
|
|
|
+ var sel = el('l2-ts');
|
|
|
+ if (!sel) return;
|
|
|
var u = new URL(window.location.href);
|
|
|
u.searchParams.set('action', 'l2.topology.view');
|
|
|
- u.searchParams.set('ts', String(Math.floor(ms / 1000)));
|
|
|
+ if (sel.value) u.searchParams.set('ts', sel.value);
|
|
|
+ else u.searchParams.delete('ts');
|
|
|
window.location.href = u.toString();
|
|
|
};
|
|
|
el('l2-ts-go').addEventListener('click', goTs);
|
|
|
- if (el('l2-ts-pick')) {
|
|
|
- el('l2-ts-pick').addEventListener('keydown', function (ev) {
|
|
|
- if (ev.key === 'Enter') { ev.preventDefault(); goTs(); }
|
|
|
- });
|
|
|
- }
|
|
|
}
|
|
|
|
|
|
el('l2-pause').addEventListener('click', function () {
|