Private/ReportNetworkRoutes.ps1
|
# The path view of "Big Picture" (ReportNetwork.ps1): the Target Machine and its # adapters, then its own subnets - each with its gateway, DNS and DHCP servers first - and # beyond the gateway the other subnets, the VPN and the internet, 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. Stripe and edges are what the Run measured; # the events line of a measured Server is what the logs held over the 30 days, in the colour # of the worst Finding about those events. A Server only the Network Map's Links name is a # node without numbers, its edge grey and dashed. # # The path and its numbers alone: how the Map's things hang together is shown by Apps & Services # (ReportNetworkApps.ps1, ADR-0014). # # 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:ReportNetworkRoutesMetricOrder = @( 'Ping', 'Smb', 'Ldap', 'TcpConnect', 'Kerberos', 'Dns', 'DnsDc', 'Tls', 'Response', 'FirstByte', 'ClockOffset', 'ConnectFailures', 'Loss', 'PingMax', 'LinkSpeed', 'WifiSignal' ) $script:ReportNetworkRoutesCss = @' <style> /* Big Picture: Routes (ReportNetworkRoutes.ps1). */ .netroutes .node rect.box{fill:var(--panel2);stroke:var(--line);stroke-width:1} .netroutes .node rect.stripe{fill:var(--c)} .netroutes .node.NONE rect.stripe{fill:var(--line)} .netroutes .node.go{cursor:pointer} .netroutes .node:hover rect.box,.netroutes .node:focus rect.box,.netroutes .node.sel rect.box{stroke:var(--accent);stroke-width:1.5} .netroutes .node:focus{outline:none} .netroutes .edge{fill:none;stroke:var(--c);stroke-width:1.6;opacity:.85} .netroutes .edge.NONE{stroke:var(--faint);stroke-dasharray:4 3} .netroutes .edge.dashed{stroke-dasharray:6 4} .netroutes .edge.hl{stroke-width:3;opacity:1} @media print{ .netroutes .node rect.box{fill:#fff} } </style> '@ $script:ReportNetworkRoutesScript = @' <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('routes', function(root, net){ var svg = root.querySelector('svg.netroutes'), 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.routes.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; } // From which day to which the events of a node fell: one day where it was one. function days(sp){ return net.day(sp.first) === net.day(sp.last) ? net.day(sp.first) : w.routes.Span.replace('{0}', net.day(sp.first)).replace('{1}', net.day(sp.last)); } // A value on the card: with its port only where the node has the same Metric on another // port, so that the two can be told apart - SMB on 445 says nothing more by its port. function said(m, ported){ if (!m.Port || !ported[m.Metric]) return net.part(m); return w.routes.Port.replace('{0}', net.metric(m)).replace('{1}', m.Port) + (net.missing(m) ? ': ' : ' ') + net.value(m); } // A node's card: its values, those measured in lines of a few, each not measured on a // line of its own, and last its events from when to when. The machine: each adapter with // its link speed, and the Wi-Fi signal. function lines(n, room){ var out = [], cur = [], seen = {}, sp = net.span(n), ports = {}, ported = {}; // A Server only the Map's Links name: there, and not measured. if (n.place === 'linked') return [{ t: w.routes.Linked, miss: true }]; // 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]) }); }); if (sp) out.push({ t: days(sp), dim: true }); return out; } net.measurements(n).forEach(function(m){ if (!m.Port) return; ports[m.Metric] = ports[m.Metric] || {}; ports[m.Metric][m.Port] = 1; if (Object.keys(ports[m.Metric]).length > 1) ported[m.Metric] = 1; }); 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) : said(m, ported); 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.routes.More.replace('{0}', more), miss: true }); } // A measured Server the logs know as well: how many events, from when to when, in the // colour of the worst Finding about them. The stripe is what the Run measured; this line // is what the 30 days held - a Server that answers now may have dropped every day. if (sp) { var past = net.worst(net.counted(n).map(net.severity)), held = w.routes.EventSpan.replace('{0}', net.eventCount(net.counted(n).length)).replace('{1}', days(sp)); out.push(past ? { t: String.fromCharCode(9679) + ' ' + held, s: past } : { t: held, dim: true }); } return out; } // The columns, as the model grouped them (Get-ReportNetworkGroup): on the left what the // machine reaches by itself - its own subnets, then the Servers without an address - and // on the right what lies beyond the gateway: the other subnets, whose prefix is assumed, // the VPN and the internet. The gateway stands first in its subnet; the way to all on // the right leaves from it. // A Server's card is wide enough for a name, its roles and a line of values; what is // longer goes on to a second line rather than being cut (wrap). The drawing is as wide // as the page gives it on a large screen and scales down on a smaller one. var COL = { machine: [0, 210], c3: [340, 300], c4: [820, 300] }, W = 1130; function head(g){ if (g.kind === 'own') return w.group.own.replace('{0}', g.net).replace('{1}', g.adapter); if (g.kind === 'assumed') return w.group.assumed.replace('{0}', g.net); return w.zone[g.kind] || ''; } var groups = (d.g || []).map(function(g){ return [head(g), g.n.map(function(id){ return byId[id]; }).filter(Boolean), g.side]; }); var left = groups.filter(function(g){ return g[2] === 'left' && g[1].length; }); var right = groups.filter(function(g){ return g[2] === 'right' && g[1].length; }); var gws = d.n.filter(function(n){ return n.place === 'gateway'; }); var place = { machine: d.n.filter(function(n){ return n.place === 'machine'; }) }; // 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 = {}, ident = {}; // The card's head: the name, the roles, and what else the Server is called, where anything. function height(n, room){ card[n.id] = []; lines(n, room).forEach(function(l){ wrap(l.t, room).forEach(function(t){ card[n.id].push({ t: t, miss: l.miss, dim: l.dim, s: l.s }); }); }); ident[n.id] = n.place === 'machine' ? [] : wrap(net.aliases(n).join(net.dot), room); return 38 + ident[n.id].length * 14 + card[n.id].length * 15; } // A line of a card in at most two: broken after a separator or a space where it is too // long, and only the second cut, so that the end of a value is seen where it fits. function wrap(s, room){ s = String(s || ''); if (!s) return []; if (s.length <= room) return [s]; var at = s.lastIndexOf(' ', room); if (at < room / 2) at = room; var rest = s.slice(at).replace(/^[\s\u00b7]+/, ''); return [s.slice(0, at).replace(/[\s\u00b7]+$/, ''), cut(rest, room)]; } function stack(groups, col, top){ var y = top; groups.forEach(function(g){ heads.push([col[0], y - 10, g[0]]); g[1].forEach(function(n){ var h = height(n, Math.floor(col[1] / 6.6)); pos[n.id] = [col[0], y, col[1], h]; y += h + 10; }); y += 26; }); return y - 26; } // Both sides level with where their way leaves: what lies beyond the gateway starts level // with the gateway's card, and the machine stands level with the first cards - not halfway // down a long column, with every line to the top and the bottom as long. var top = 30 + TRUNK, leftBottom = stack(left, COL.c3, top); var rightTop = hub !== me && pos[hub.id] ? pos[hub.id][1] : top; var bottom = Math.max(leftBottom, stack(right, COL.c4, rightTop), 120); var mh = height(me, Math.floor(COL.machine[1] / 6.6)); pos[me.id] = [COL.machine[0], top, 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 = net.eventCount(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 + '" data-to="' + esc(to.id) + '" 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, top = 48 + ident[id].length * 14; var sub = n.place === 'machine' ? w.role.Machine : (n.place === 'events' ? w.routes.Events.replace('{0}', net.eventCount(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.findText(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>' + ident[id].map(function(t, i){ return '<text class="dim" x="' + (x + 12) + '" y="' + (y + 45 + i * 14) + '" font-size="11">' + esc(t) + '</text>'; }).join('') + card[id].map(function(l, i){ return '<text' + (l.miss ? ' class="miss"' : (l.s ? ' class="sevt ' + l.s + '"' : (l.dim ? ' class="dim"' : ''))) + ' x="' + (x + 12) + '" y="' + (y + top + 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-ReportNetworkRoutesView { <# .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="netroutes" role="img" aria-label="{0}"></svg></div>' -f ` (ConvertTo-HtmlText (Get-Text 'Report.Network.Routes.Heading')), (ConvertTo-HtmlText (Get-Text 'Report.Network.Routes.Legend')), (ConvertTo-HtmlText (Get-Text 'Report.Network.Routes.Hint')) New-ReportNetworkView -Id 'routes' -Title (Get-Text 'Report.Network.Routes.Title') -Html $html ` -Css $script:ReportNetworkRoutesCss -Script $script:ReportNetworkRoutesScript ` -Word ([ordered]@{ More = Get-Text 'Report.Network.Routes.More'; Events = Get-Text 'Report.Network.Routes.Events' Port = Get-Text 'Report.Network.Routes.Port'; Span = Get-Text 'Report.Network.Routes.Span'; EventSpan = Get-Text 'Report.Network.Routes.EventSpan' Linked = Get-Text 'Report.Network.Routes.Linked' First = $script:ReportNetworkRoutesMetricOrder }) } |