Private/ReportNetworkPath.ps1
|
# The path view of "Netzwerk im Bild" (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. # # Under the picture, how the Map's things hang together (Get-ReportNetworkLink): each adapter # with its gateway, DNS and DHCP servers, each Domain with its controllers by site and its # Namespaces with where each leads - in HTML, as the subnets the picture is grouped by are no # place to group by Domain - and that it was not looked at where no Check that ran could have # seen it. # # 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} /* How the Map's things hang together, under the picture: a chip per Server, striped in the colour of its node, dashed where only a Link names it. */ .netmap{margin-top:14px} .netmap>h4{margin:0 0 2px;font:600 13.5px var(--sans)} .netmap .mapgrid{display:grid;grid-template-columns:repeat(auto-fill,minmax(min(100%,300px),1fr));gap:10px} .netmap .mapitem{min-width:0;padding:8px 12px;background:var(--panel);border:1px solid var(--line);border-radius:var(--radius)} .netmap h5{margin:6px 0 4px;font:600 12.5px var(--sans);overflow-wrap:anywhere} .netmap h5:first-child{margin-top:0} .netmap h6{margin:8px 0 2px;font:600 12px var(--sans);color:var(--dim);overflow-wrap:anywhere} .netmap .maprow{display:flex;flex-wrap:wrap;gap:4px 8px;align-items:baseline;margin:3px 0} .netmap .maplabel{min-width:84px;color:var(--dim);font-size:12px;overflow-wrap:anywhere} .netmap .mapchips{display:flex;flex-wrap:wrap;gap:4px;min-width:0} .netmap .chip{display:inline-flex;flex-wrap:wrap;align-items:baseline;gap:0 6px;max-width:100%;padding:2px 8px;background:var(--panel2);border:1px solid var(--line);border-left:3px solid var(--c);border-radius:6px;font-size:12px;overflow-wrap:anywhere} .netmap .chip.NONE{border-left-color:var(--line)} .netmap .chip.bare{border-left-style:dashed} .netmap .chip.go{cursor:pointer} .netmap .chip:hover,.netmap .chip:focus{border-color:var(--accent);outline:none} .netmap .chip small{color:var(--dim)} .netmap .chip.bare small{color:var(--faint);font-style:italic} .netmap .mapgap{margin:6px 0 0;padding:5px 10px;border:1px dashed var(--line);border-radius:6px;color:var(--dim);font-size:12px} .netmap .mapnone{color:var(--faint);font-size:12px} @media print{ .netpath .node rect.box{fill:#fff} .netmap .mapitem{break-inside:avoid} } </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; } // 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.path.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.path.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.path.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.path.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.path.EventSpan.replace('{0}', 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. var COL = { machine: [0, 200], c3: [340, 260], c4: [780, 260] }, W = 1050; 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); ident[n.id] = n.place === 'machine' ? '' : net.aliases(n).join(net.dot); return 38 + (ident[n.id] ? 14 : 0) + card[n.id].length * 15; } function stack(groups, col){ var y = 30 + TRUNK; 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.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, top = ident[id] ? 62 : 48; 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>' + (ident[id] ? '<text class="dim" x="' + (x + 12) + '" y="' + (y + 45) + '" font-size="11">' + esc(cut(ident[id], room)) + '</text>' : '') + 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(); } }); }); // How the Map's things hang together, under the picture (Get-ReportNetworkLink): each // adapter with the servers it uses, each Domain with its controllers by site and its // Namespaces with where each leads, and in place of a part no Check that ran could have // seen, that it was not looked at. A Server is a chip in the colour of its node - what its // Findings judged; a Link colours nothing. In HTML and not in the svg: it wraps on a phone. var box = root.querySelector('.netmap'); if (!box) return; var M = w.map, uk = d.unknown || {}, arrow = ' ' + String.fromCharCode(8594) + ' '; function chip(id){ var n = byId[id]; if (!n) return ''; var m = primary(n), said = n.place === 'linked' ? w.state.NotMeasured : (m ? net.part(m) : (n.place === 'events' ? w.path.EventCount.replace('{0}', net.counted(n).length) : '')); return '<span class="chip ' + (n.s || 'NONE') + (n.place === 'linked' ? ' bare' : '') + (net.findings(n).length ? ' go' : '') + '" data-id="' + esc(n.id) + '" tabindex="0" role="button">' + esc(net.label(n)) + (said ? '<small>' + esc(said) + '</small>' : '') + '</span>'; } function row(label, body){ return '<div class="maprow">' + (label ? '<span class="maplabel">' + esc(label) + '</span>' : '') + '<span class="mapchips">' + body + '</span></div>'; } function gap(text){ return '<div class="mapgap">' + esc(text) + '</div>'; } function namespace(ns){ return '<h6>' + esc(M.Namespace.replace('{0}', ns.key)) + '</h6>' + ns.targets.map(function(t){ return row(t.share ? t.share + arrow : '', chip(t.n)); }).join('') + (uk.namespace ? gap(uk.namespace) : ''); } var parts = [], ad = d.adapters || [], doms = d.domains || [], nss = d.namespaces || []; if (ad.length || uk.adapters) { var h = ''; ad.forEach(function(a){ var head = a.key === 'machine' ? M.MachineUses : M.Adapter.replace('{0}', a.name || a.key) + (a.subnets.length ? net.dot + a.subnets.join(', ') : ''); var rows = [[w.role.Gateway, a.gateway], [w.role.DnsServer, a.dns], [w.role.DhcpServer, a.dhcp]].filter(function(r){ return r[1].length; }); h += '<h5>' + esc(head) + '</h5>' + (rows.length ? rows.map(function(r){ return row(r[0], r[1].map(chip).join('')); }).join('') : '<div class="mapnone">' + esc(M.Nothing) + '</div>'); }); parts.push(h + (uk.adapters ? gap(uk.adapters) : '')); } doms.forEach(function(dm){ var h = '<h5>' + esc(M.Domain.replace('{0}', dm.key)) + '</h5>'; dm.sites.forEach(function(s){ h += row(M.Site.replace('{0}', s.name), s.n.map(chip).join('')); }); if (dm.unsited.length) h += row(M.NoSite, dm.unsited.map(chip).join('')) + (uk.site ? gap(uk.site) : ''); nss.filter(function(ns){ return ns.domain === dm.key; }).forEach(function(ns){ h += namespace(ns); }); parts.push(h); }); var homes = doms.map(function(dm){ return dm.key; }); nss.filter(function(ns){ return homes.indexOf(ns.domain) < 0; }).forEach(function(ns){ parts.push(namespace(ns)); }); if (uk.domain) parts.push(gap(uk.domain)); if (uk.drives) parts.push(gap(uk.drives)); if (!parts.length) return; box.innerHTML = '<h4>' + esc(M.Heading) + '</h4><p class="netsub">' + esc(M.Note) + '</p><div class="mapgrid">' + parts.map(function(p){ return '<div class="mapitem">' + p + '</div>'; }).join('') + '</div>'; Array.prototype.forEach.call(box.querySelectorAll('.chip'), function(c){ var n = byId[c.getAttribute('data-id')]; function go(){ var f = net.findings(n)[0]; if (f) net.jump(f.a); } c.addEventListener('mouseenter', function(){ net.showDetail(c, n); }); c.addEventListener('focus', function(){ net.showDetail(c, n); }); c.addEventListener('mouseleave', net.hideDetail); c.addEventListener('blur', net.hideDetail); c.addEventListener('click', go); c.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><div class="netmap"></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' Port = Get-Text 'Report.Network.Path.Port'; Span = Get-Text 'Report.Network.Path.Span'; EventSpan = Get-Text 'Report.Network.Path.EventSpan' Linked = Get-Text 'Report.Network.Path.Linked' First = $script:ReportNetworkPathMetricOrder }) } |