Private/ReportNetworkPath.ps1
|
# The path view of "Netzwerk im Bild" (ReportNetwork.ps1): the Target Machine and its # adapters, then the LAN - the gateway at its top with the DNS and DHCP servers - and the VPN # and the internet beyond the gateway, # each Server one node with its values. An edge carries the value the way to its node is # best read by and is coloured by the Severity of the Finding that value belongs to, by its # part about the Server; where no Finding judges it, it is grey and dashed. The stripe of a # node shows the worst Severity of all its values. # # The geometry is the script's and is looked at, not tested: a Report rendered in a # headless browser from fixture Measurements. # The Metric an edge is labelled with: the first of these a Server has a number for, the # way there before what lies at its end. Every Metric, so that each edge has a label. $script:ReportNetworkPathMetricOrder = @( 'Ping', 'Smb', 'Ldap', 'TcpConnect', 'Kerberos', 'Dns', 'Tls', 'Response', 'FirstByte', 'ClockOffset', 'ConnectFailures', 'Loss', 'PingMax', 'LinkSpeed', 'WifiSignal' ) $script:ReportNetworkPathCss = @' <style> /* Netzwerk im Bild: the path (ReportNetworkPath.ps1). */ .netpath .node rect.box{fill:var(--panel2);stroke:var(--line);stroke-width:1} .netpath .node rect.stripe{fill:var(--c)} .netpath .node.NONE rect.stripe{fill:var(--line)} .netpath .node.go{cursor:pointer} .netpath .node:hover rect.box,.netpath .node:focus rect.box,.netpath .node.sel rect.box{stroke:var(--accent);stroke-width:1.5} .netpath .node:focus{outline:none} .netpath .edge{fill:none;stroke:var(--c);stroke-width:1.6;opacity:.85} .netpath .edge.NONE{stroke:var(--faint);stroke-dasharray:4 3} .netpath .edge.dashed{stroke-dasharray:6 4} .netpath .edge.hl{stroke-width:3;opacity:1} @media print{ .netpath .node rect.box{fill:#fff} } </style> '@ $script:ReportNetworkPathScript = @' <script> // The path: columns from left to right - the machine, the LAN with the gateway, DNS and // DHCP at its top, the VPN and the internet - and a curve to each Server, labelled with the value the way // there is best read by. Widths are counted in characters and not measured: the chapter // may be hidden when it is drawn. if (window.GutcheckNet) GutcheckNet.view('path', function(root, net){ var svg = root.querySelector('svg.netpath'), d = net.data, w = net.words, esc = net.esc; if (!svg) return; var byId = {}; d.n.forEach(function(n){ byId[n.id] = n; }); // The value an edge is labelled with: the first measured, in this order, with a Finding // before one without; where nothing was measured, what became of it. var first = w.path.First; function primary(n){ var list = net.measurements(n), k, i, found = null; for (k = 0; k < first.length; k++) { var same = list.filter(function(m){ return m.Metric === first[k] && !net.missing(m); }); for (i = 0; i < same.length; i++) { if (net.finding(same[i])) return same[i]; } if (!found && same.length) found = same[0]; } return found || list[0] || null; } function cut(s, n){ s = String(s); return s.length > n ? s.slice(0, n - 1) + String.fromCharCode(8230) : s; } // A node's card: its values, those measured in lines of a few, each not measured on a // line of its own. The machine: each adapter with its link speed, and the Wi-Fi signal. function lines(n, room){ var out = [], cur = [], seen = {}; // A Server only the events know: how many, and of which kinds the most. if (n.place === 'events') { var kinds = {}, order = [], all = net.counted(n); all.forEach(function(e){ var k = e.Detail || e.Mark; if (!kinds[k]) { kinds[k] = 0; order.push(k); } kinds[k]++; }); order.sort(function(a, b){ return kinds[b] - kinds[a]; }); order.slice(0, 3).forEach(function(k){ out.push({ t: k + ': ' + w.detail.Times.replace('{0}', kinds[k]) }); }); return out; } net.measurements(n).forEach(function(m){ var key = m.Metric + '|' + (m.Port || '') + '|' + (n.place === 'machine' ? m.Subject : ''); if (seen[key]) return; seen[key] = 1; var text = (n.place === 'machine' && m.Subject && m.Metric === 'LinkSpeed') ? m.Subject + (net.missing(m) ? ': ' : net.dot) + net.value(m) : net.part(m); if (net.missing(m)) { if (cur.length) { out.push({ t: cur.join(net.dot) }); cur = []; } out.push({ t: text, miss: true }); return; } if (cur.length && (cur.join(net.dot) + net.dot + text).length > room) { out.push({ t: cur.join(net.dot) }); cur = []; } cur.push(text); }); if (cur.length) out.push({ t: cur.join(net.dot) }); if (out.length > 5) { var more = out.length - 4; out = out.slice(0, 4); out.push({ t: w.path.More.replace('{0}', more), miss: true }); } return out; } // The gateway is part of the LAN, not a station before it: it stands at the top of the // LAN column with the DNS and DHCP servers, the network's services. The machine is // connected to the LAN; the way to the VPN and the internet leaves from the gateway. var COL = { machine: [0, 200], c3: [340, 260], c4: [780, 260] }, W = 1050; var place = {}; d.n.forEach(function(n){ (place[n.place] = place[n.place] || []).push(n); }); var services = ['DnsServer', 'DhcpServer']; function serves(n){ return services.indexOf(n.roles[0]) >= 0; } var lan = (place.lan || []).filter(function(n){ return !serves(n); }); var unknown = (place.none || []).filter(function(n){ return !serves(n); }); var gws = place.gateway || []; var service = gws.concat((place.lan || []).filter(serves), (place.none || []).filter(serves)); var left = [['services', service], ['lan', lan], ['none', unknown], ['events', place.events]].filter(function(g){ return g[1] && g[1].length; }); var right = [['vpn', place.vpn], ['wan', place.wan]].filter(function(g){ return g[1] && g[1].length; }); // With nothing on the LAN side the internet stands next to the machine. if (!left.length) { left = right; right = []; } if (!right.length) W = COL.c3[0] + COL.c3[1] + 10; var me = (place.machine || [])[0] || { id: 'machine', place: 'machine', m: [], e: [], names: [], addresses: [], roles: [], s: '' }; if (!byId[me.id]) byId[me.id] = me; // The way to the VPN and the internet: from the gateway, which stands in the LAN column // and so has nothing between it and the right one; without a gateway, from the machine, // over the top of the LAN column, each edge on a lane of its own in the order of the // nodes it leads to, so that it crosses no card. var hub = gws[0] || me, far = []; right.forEach(function(g){ far = far.concat(g[1]); }); var TRUNK = (hub === me && left !== right && far.length) ? 14 + far.length * 4 : 0; var pos = {}, heads = [], card = {}; function height(n, room){ card[n.id] = lines(n, room); return 38 + card[n.id].length * 15; } function stack(groups, col){ var y = 30 + TRUNK; groups.forEach(function(g){ heads.push([col[0], y - 10, w.zone[g[0]]]); g[1].forEach(function(n){ var h = height(n, Math.floor(col[1] / 6.4)); pos[n.id] = [col[0], y, col[1], h]; y += h + 10; }); y += 26; }); return y - 26; } var bottom = Math.max(stack(left, COL.c3), stack(right, COL.c4), 120); var mh = height(me, 32); pos[me.id] = [COL.machine[0], Math.max(10, bottom / 2 - mh / 2), COL.machine[1], mh]; var H = Math.max(bottom, pos[me.id][1] + mh) + 12; var edges = [], labels = []; function edge(from, to){ var a = pos[from.id], b = pos[to.id]; if (!a || !b) return; var x1 = a[0] + a[2], y1 = a[1] + Math.min(a[3] / 2, 22), x2 = b[0], y2 = b[1] + Math.min(b[3] / 2, 22), dx = Math.max(30, (x2 - x1) * 0.5); // The edge and its label are one: the value it is labelled with, in the colour of that // value's Finding, by the part about this Server (net.severity) - so that the edge and // the Finding never disagree; the node's stripe shows the worst of all its values. A // Server only the events know is never measured: dashed, in the colour of its events. var m = primary(to), s = to.place === 'events' ? to.s : (m ? net.severity(m) : ''); var lane = TRUNK ? far.indexOf(to) : -1, d; if (lane >= 0) { var yt = 8 + lane * 4, xa = COL.c3[0] - 26, xb = COL.c3[0] + COL.c3[1] + 26; d = 'M' + x1 + ',' + y1 + ' C' + (x1 + 34) + ',' + y1 + ' ' + (xa - 34) + ',' + yt + ' ' + xa + ',' + yt + ' L' + xb + ',' + yt + ' C' + (xb + 44) + ',' + yt + ' ' + (x2 - 44) + ',' + y2 + ' ' + x2 + ',' + y2; } else d = 'M' + x1 + ',' + y1 + ' C' + (x1 + dx) + ',' + y1 + ' ' + (x2 - dx) + ',' + y2 + ' ' + x2 + ',' + y2; edges.push('<path class="edge ' + (s || 'NONE') + (to.place === 'events' ? ' dashed' : '') + '" data-from="' + esc(from.id) + '" data-to="' + esc(to.id) + '" d="' + d + '"/>'); var text, cls; if (to.place === 'events') text = w.path.EventCount.replace('{0}', net.counted(to).length); else if (!m) return; else text = net.part(m); cls = 'halo ' + (m && net.missing(m) ? 'miss' : (s ? 'sevt ' + s : 'faint')); labels.push('<text class="' + cls + '" x="' + (x2 - 6) + '" y="' + (y2 - 5) + '" text-anchor="end" font-size="11">' + esc(cut(text, 22)) + '</text>'); } // The way: from the machine to everything on the LAN, from the gateway to what lies // beyond it. left.forEach(function(g){ g[1].forEach(function(n){ edge(me, n); }); }); if (left !== right) right.forEach(function(g){ g[1].forEach(function(n){ edge(hub, n); }); }); var out = heads.map(function(h){ return '<text class="zonehead" x="' + h[0] + '" y="' + h[1] + '">' + esc(String(h[2]).toUpperCase()) + '</text>'; }).join(''); out += edges.join('') + labels.join(''); Object.keys(pos).forEach(function(id){ var n = byId[id], p = pos[id], x = p[0], y = p[1], wd = p[2], h = p[3], room = Math.floor(wd / 6.6); var s = n.s || 'NONE', go = net.findings(n).length > 0; var sub = n.place === 'machine' ? w.role.Machine : (n.place === 'events' ? w.path.Events.replace('{0}', net.counted(n).length) : net.roles(n).join(', ')); out += '<g class="node ' + s + (go ? ' go' : '') + '" data-id="' + esc(id) + '" tabindex="0" role="button" aria-label="' + esc(net.label(n)) + '">' + '<rect class="box" x="' + x + '" y="' + y + '" width="' + wd + '" height="' + h + '" rx="7"/>' + '<rect class="stripe" x="' + x + '" y="' + y + '" width="4" height="' + h + '" rx="2"/>' + '<text x="' + (x + 12) + '" y="' + (y + 17) + '" font-size="13" font-weight="600">' + esc(cut(net.label(n), Math.floor(wd / 7.6))) + '</text>' + '<text class="dim" x="' + (x + 12) + '" y="' + (y + 31) + '" font-size="11">' + esc(cut(sub, room)) + '</text>' + card[id].map(function(l, i){ return '<text' + (l.miss ? ' class="miss"' : '') + ' x="' + (x + 12) + '" y="' + (y + 48 + i * 15) + '" font-size="11.5">' + esc(cut(l.t, room)) + '</text>'; }).join('') + '</g>'; }); svg.setAttribute('viewBox', '0 0 ' + W + ' ' + Math.ceil(H)); svg.style.maxWidth = W + 'px'; svg.innerHTML = out; // On the pointer or the keyboard: the node's values and Findings beside it, and the way // to it drawn bold. A click, or Enter, leads to its worst Finding. function mark(id){ Array.prototype.forEach.call(svg.querySelectorAll('.node'), function(g){ g.classList.toggle('sel', g.getAttribute('data-id') === id); }); Array.prototype.forEach.call(svg.querySelectorAll('.edge'), function(e){ e.classList.toggle('hl', !!id && (e.getAttribute('data-to') === id || e.getAttribute('data-from') === id)); }); } Array.prototype.forEach.call(svg.querySelectorAll('.node'), function(g){ var n = byId[g.getAttribute('data-id')]; function on(){ mark(n.id); net.showDetail(g, n); } function off(){ mark(''); net.hideDetail(); } function go(){ var f = net.findings(n)[0]; if (f) net.jump(f.a); } g.addEventListener('mouseenter', on); g.addEventListener('focus', on); g.addEventListener('mouseleave', off); g.addEventListener('blur', off); g.addEventListener('click', go); g.addEventListener('keydown', function(e){ if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); go(); } }); }); }); </script> '@ function ConvertTo-ReportNetworkPathView { <# .SYNOPSIS The path view: what it is drawn into, and its words. Pure. .DESCRIPTION Nothing where nothing was measured. The script draws from the nodes of the model. #> [CmdletBinding()] [OutputType([psobject])] param([Parameter(Mandatory)]$Model) # Drawn where something was measured; a Server only the events know stands beside those. if (-not @($Model.m).Count) { return } $html = '<h3>{0}</h3><p class="netsub">{1} <span class="nethint">{2}</span></p><div class="netpanel"><svg class="netpath" role="img" aria-label="{0}"></svg></div>' -f ` (ConvertTo-HtmlText (Get-Text 'Report.Network.Path.Heading')), (ConvertTo-HtmlText (Get-Text 'Report.Network.Path.Legend')), (ConvertTo-HtmlText (Get-Text 'Report.Network.Path.Hint')) New-ReportNetworkView -Id 'path' -Title (Get-Text 'Report.Network.Path.Title') -Html $html ` -Css $script:ReportNetworkPathCss -Script $script:ReportNetworkPathScript ` -Word ([ordered]@{ More = Get-Text 'Report.Network.Path.More'; Events = Get-Text 'Report.Network.Path.Events'; EventCount = Get-Text 'Report.Network.Path.EventCount' First = $script:ReportNetworkPathMetricOrder }) } |