Private/ReportNetwork.ps1
|
# "Netzwerk im Bild": the chapter of the Report that draws the network from the Run's # Measurements (ADR-0009, spec #96). What every view shares stands here; each view is a # file of its own beside this one. # # - The Measurements and the Timed Events, as they were handed in, go into the Report as # one JSON block (id "netdata"), as the glossary does with its terms: with them the # Findings they name - Severity, title, Value, the part of each Server it names, and # where in the Report each stands - the nodes they are on, the names of the views and # every word the script shows. # - One node per Server of the Network Map (NetworkMap.ps1, ADR-0012), which alone decides # which Server is which, by the identity of a Server (ServerIdentity.ps1): the chapter # merges nothing. Get-ReportNetworkNode draws the Map's Servers as nodes, here and not in # the script, so that every view draws the same Servers and a test can say so. Each node # has the Map's key, and so has every record on it and every part of a Finding about its # Server: the script compares keys as they are and never decides which Server a name is. # - The Severity a view shows is the Finding's, looked up by the Id a Measurement names, # or that of the Finding's part with the Measurement's key (net.severity). A view # never judges: a value no Finding judges is drawn grey. # - The script shared by the views ($script:ReportNetworkScript) reads the block once and # gives each view what it needs: the data, the words, a value in words, the detail # beside a node, the jump to a Finding, and the switch between the views. # - Drawn with inline SVG by the Report's own script. Nothing is fetched: a Report is # opened at a Customer Site with or without a network. # # - The Timed Events (ADR-0010) go into the same block, for the 30 days: each with the # lane it is drawn in, a Server's lane the node of the Server the Map put it on, and how # far back each log reaches. # # No chapter at all where the Run measured no network - no Measurement, no event of a Server # or of the machine: a Scope without the network Checks is no broken picture, and the crashes # and sign-ins Stability and Logon read are no network (Test-ReportNetworkMeasured). # The views of the chapter, in the order of the switch. One line per view: the function # that renders it. To add a view: # # 1. Write Private/ReportNetwork<Name>.ps1 with a function ConvertTo-ReportNetwork<Name>View # that takes -Model (see Get-ReportNetworkModel) and returns what New-ReportNetworkView # makes - its Id, its title on the switch, its HTML, its CSS and its script - or # nothing where it has nothing to draw. # 2. Its script calls GutcheckNet.view('<id>', function(root, net){ ... }): root is the # view's own element, net the shared part (see $script:ReportNetworkScript). # 3. Its words go into Text.ps1, in a block of its own under "# -------- network # pictures", and reach its script through -Word of New-ReportNetworkView, as # net.words.<id>. # 4. Add its function's name below. $script:ReportNetworkViews = @( 'ConvertTo-ReportNetworkPathView' 'ConvertTo-ReportNetworkTimelineView' 'ConvertTo-ReportNetworkWaterfallView' 'ConvertTo-ReportNetworkRankingView' ) # The Lanes of the Timed Events that are the network, and so open the chapter: a Server's # and the machine's. The markers and a log's reach are drawn beside them, and alone are none. $script:ReportNetworkLanes = @('Server', 'Machine') # The order a node's Roles are sorted by, and its column filled in: the domain first, the # internet last. Every Role of a Measurement ($script:MeasurementRoleValues), as a test # says. $script:ReportNetworkRoleOrder = @( 'Machine', 'Adapter', 'Gateway', 'DomainController', 'DnsServer', 'DhcpServer', 'FileServer', 'DfsTarget', 'PrintServer', 'AppServer', 'TimeSource', 'InternetProbe', 'HttpsService', 'Website', 'WebsiteHost' ) $script:ReportNetworkCss = @' <style> /* Netzwerk im Bild (ReportNetwork.ps1): what every view shares. */ .NONE{--c:var(--faint);--cb:transparent;--cl:var(--line)} .netnote{color:var(--dim);margin:0 0 12px;max-width:920px} /* What the picture cannot show, because the Run did not look: said before it is read. */ .netgap{margin:0 0 14px;padding:8px 14px;border:1px dashed var(--line);border-radius:var(--radius);color:var(--dim);font-size:12.5px;max-width:920px} .netgap b{color:var(--text);font-weight:600} .netgap ul{margin:4px 0 0;padding-left:18px} .netbar{display:flex;flex-wrap:wrap;gap:10px 18px;align-items:center;margin:0 0 14px} .netswitch{display:none;gap:6px} .js .netswitch{display:flex} .netswitch button{background:var(--panel);border:1px solid var(--line);border-radius:999px;padding:3px 13px;color:var(--dim);font-size:12.5px} .netswitch button:hover{color:var(--text);border-color:var(--faint)} .netswitch button[aria-pressed="true"]{color:var(--text);border-color:var(--accent);box-shadow:inset 0 0 0 1px var(--accent)} .netlegend{display:flex;flex-wrap:wrap;gap:6px 14px;align-items:center;color:var(--dim);font-size:12.5px} .netlegend span{display:inline-flex;gap:6px;align-items:center} .netlegend .sw{display:inline-block;width:14px;height:10px;border-radius:2px;background:var(--c)} .js .netview.off{display:none} .netview{margin-bottom:24px;position:relative} /* Over the whole window, as a table of the evidence is: the same sign, the same way back. */ .netview>.wide{display:none;position:absolute;top:0;right:0;align-items:center;justify-content:center;width:28px;height:24px;padding:0;background:var(--panel);border:1px solid var(--line);border-radius:6px;color:var(--dim);cursor:pointer} .js .netview>.wide{display:inline-flex} .netview>.wide:hover{color:var(--text);border-color:var(--accent)} .netview>h3{padding-right:40px} .netview.fullview{position:fixed;inset:14px;z-index:60;margin:0;padding:14px 16px;overflow:auto;background:var(--bg);border:1px solid var(--line);border-radius:var(--radius);box-shadow:0 12px 48px rgba(0,0,0,.6)} .netview.fullview>.wide{top:14px;right:16px} .netview.fullview svg{max-width:none!important} .netview>h3{margin:0 0 4px;font:600 15px var(--sans)} .netview .netsub{color:var(--dim);font-size:12.5px;margin:0 0 8px} /* What a view says of the pointer and the click: not on paper. */ .nethint{color:var(--faint)} .netpanel{background:var(--panel);border:1px solid var(--line);border-radius:var(--radius);padding:8px;overflow-x:auto} .netpanel svg{display:block;width:100%;height:auto} .netpanel svg text{font-family:var(--sans);fill:var(--text)} .netpanel svg .dim{fill:var(--dim)} .netpanel svg .faint{fill:var(--faint)} .netpanel svg .miss{fill:var(--faint);font-style:italic} .netpanel svg .sevt{fill:var(--c)} .netpanel svg .halo{paint-order:stroke;stroke:var(--panel);stroke-width:4px;stroke-linejoin:round} .netpanel svg .zonehead{font:600 11px var(--mono);letter-spacing:.1em;fill:var(--dim)} .netdetail{position:fixed;z-index:70;width:380px;max-width:calc(100vw - 16px);max-height:72vh;overflow:auto;padding:12px 14px;background:var(--panel2);border:1px solid var(--line);border-radius:var(--radius);box-shadow:0 10px 32px rgba(0,0,0,.55);font-size:12.5px} .netdetail h4{margin:0 0 2px;font:600 15px var(--sans);overflow-wrap:anywhere} .netdetail .sub{color:var(--dim);margin-bottom:8px;overflow-wrap:anywhere} .netdetail table{width:100%;border-collapse:collapse;margin:4px 0 8px} .netdetail td{padding:4px 6px 4px 0;border-top:1px solid var(--line);vertical-align:top} .netdetail td.v{text-align:right;white-space:nowrap;font-variant-numeric:tabular-nums;padding-right:0} .netdetail td.v.miss{color:var(--faint);font-style:italic;white-space:normal} .netdetail small{display:block;color:var(--faint);font-size:11.5px} .netdetail .find{display:block;margin:6px 0 0;padding:7px 9px;border-radius:8px;background:var(--cb);border:1px solid var(--cl);color:var(--text);text-decoration:none} .netdetail .find:hover{border-color:var(--c)} .netdetail .find b{font-weight:600;margin-left:6px} .netdetail .find small{margin-top:3px;color:var(--dim);overflow-wrap:anywhere} .netdetail .none{color:var(--faint)} .netdetail ul.rel{margin:0 0 8px;padding-left:16px;color:var(--dim)} .netdetail .go{margin-top:8px;color:var(--faint);font-size:11.5px} @media print{ .netswitch,.netdetail,.nethint,.netview>.wide{display:none!important} .netview.off{display:block!important} .netview{break-inside:avoid-page} .netpanel{overflow:visible;padding:0;border:0} .netpanel svg{-webkit-print-color-adjust:exact;print-color-adjust:exact} } </style> '@ # Shared by every view. Each view's own script follows it and calls GutcheckNet.view. $script:ReportNetworkScript = @' <script> // Netzwerk im Bild: what every view shares. Reads the Measurements once, and hands each // view its element and this: the data, the words, a value in words, the Severity of the // Finding a Measurement names, the detail beside a node, and the jump to a Finding. var GutcheckNet = (function(){ var box = document.getElementById('netdata'), data = null; try { data = box ? JSON.parse(box.textContent) : null; } catch (x) { data = null; } if (!data) return null; var w = data.w, levels = data.levels, dot = ' ' + String.fromCharCode(183) + ' '; function esc(s){ return String(s == null ? '' : s).replace(/[&<>"]/g, function(c){ return { '&': '&', '<': '<', '>': '>', '"': '"' }[c]; }); } function num(v){ return Number(v).toLocaleString('de-DE', { maximumFractionDigits: 1 }); } // The Finding a Measurement or a Timed Event belongs to, or nothing: one no Finding // judges is drawn grey. function finding(m){ return m && m.Finding && data.f[m.Finding] ? data.f[m.Finding] : null; } // The Severity it is drawn in: that of the part of its Finding about its own Server (a // Finding that names several Servers says each one's), else the Finding's. The part is // the one whose key is its own, compared as it is: which Server a name is, the model // decided. Every view colours by this and by nothing else. function severity(m){ var f = finding(m); if (!f) return ''; var key = m.key, parts = f.subjects || []; for (var i = 0; key && i < parts.length; i++) { if (parts[i].key === key) return parts[i].s; } return f.s; } // How the events of its own Server's part of its Finding were spread in time, in words, // or nothing: the Judge says it (PatternText), a view only shows it. function pattern(m){ var f = finding(m), key = m && m.key, parts = f ? f.subjects || [] : []; for (var i = 0; key && i < parts.length; i++) { if (parts[i].key === key) return parts[i].pattern || ''; } return ''; } function worst(list){ var found = ''; list.forEach(function(s){ if (s && levels.indexOf(s) >= 0 && (found === '' || levels.indexOf(s) < levels.indexOf(found))) found = s; }); return found; } function metric(m){ return w.metric[m.Metric] || m.Metric; } function unit(m){ return m.Unit === 'count' ? '' : (m.Unit === '%' ? ' %' : ' ' + m.Unit); } // Whether a Measurement has no number to draw: not measured, cached, blocked, failed. function missing(m){ return m.State !== 'Measured' || m.Value == null; } // A number with its sign, as a clock offset is written: +1,2 or -0,4. The sign is that of // what is written, so that -0,04 is 0 and not -0. function signed(v){ var t = num(Math.abs(v)); return t === '0' ? t : (v < 0 ? '-' : '+') + t; } // The value in words: a number with its unit, or what became of the measuring. Never a // 0 for what was not measured. A ping of 0 ms is one under a millisecond, which Windows // rounds down: said so, here for every view. A clock offset says which way it is off. function value(m){ if (m.State === 'Measured' && (m.Metric === 'Ping' || m.Metric === 'PingMax') && m.Value === 0) return w.value.UnderOneMs; if (m.State === 'Measured' && m.Metric === 'ClockOffset' && m.Value != null) return signed(m.Value) + unit(m); if (m.State === 'Measured' && m.Value != null) return num(m.Value) + unit(m); if (m.State === 'Cached') return w.state.Cached; if (m.State === 'NotMeasured') return w.state.NotMeasured; var said = m.Error || (m.State === 'Blocked' ? w.state.Blocked : w.state.Failed); if (m.Value != null) said = w.state.After.replace('{0}', said).replace('{1}', num(m.Value) + unit(m)); return said; } // The values of measurements of one Metric from the least to the most, "1,4 to 1,8 ms" with an en dash, the // unit once; one value where they agree. function range(list){ var sorted = list.slice().sort(function(a, b){ return a.Value - b.Value; }); var lo = value(sorted[0]), hi = value(sorted[sorted.length - 1]), u = unit(sorted[0]); if (lo === hi) return lo; if (u && lo.slice(-u.length) === u && hi.slice(-u.length) === u) lo = lo.slice(0, -u.length); return lo + String.fromCharCode(8211) + hi; } // "Ping 1,3 ms", "SMB: nicht gemessen". function part(m){ return metric(m) + (missing(m) ? ': ' : ' ') + value(m); } function measurements(n){ return n.m.map(function(i){ return data.m[i]; }); } // The Timed Events of a node, and how many of them count: a Context event is shown and // not counted. function events(n){ return (n.e || []).map(function(i){ return data.t[i]; }); } function counted(n){ return events(n).filter(function(e){ return e.Mark !== 'Context'; }); } function roles(n){ return n.roles.map(function(r){ return w.role[r] || r; }); } function label(n){ return n.place === 'machine' ? data.machine : (n.label || roles(n).join(', ')); } // What else a node is called: its addresses first, so that a cut line keeps them, then its // other names. function aliases(n){ return n.addresses.concat(n.names).filter(function(x){ return x !== n.label; }); } // A time as the model writes it, local, to the second; and in words, by the day or the minute. function parse(s){ var p = /^(\d+)-(\d+)-(\d+)T(\d+):(\d+):(\d+)/.exec(s || ''); return p ? new Date(+p[1], +p[2] - 1, +p[3], +p[4], +p[5], +p[6]) : null; } function two(k){ return (k < 10 ? '0' : '') + k; } function day(t){ return two(t.getDate()) + '.' + two(t.getMonth() + 1) + '.'; } function when(t){ return day(t) + ' ' + two(t.getHours()) + ':' + two(t.getMinutes()); } // When the counted events of a node began and ended, or nothing. function span(n){ var first = null, last = null; counted(n).forEach(function(e){ var t = parse(e.Time); if (!t) return; if (!first || t < first) first = t; if (!last || t > last) last = t; }); return first ? { first: first, last: last } : null; } // The Findings of a node, the worst first, each once. function findings(n){ var seen = {}, list = []; measurements(n).concat(events(n)).forEach(function(m){ var f = finding(m); if (f && !seen[m.Finding]) { seen[m.Finding] = 1; list.push(f); } }); return list.sort(function(a, b){ return levels.indexOf(a.s) - levels.indexOf(b.s); }); } // A click leads to the Finding as a link in the Report does: the Report's own script // shows where it stands, opens it and marks it. function jump(anchor){ if (!anchor) return; var a = document.createElement('a'); a.className = 'jump'; a.href = '#' + anchor; a.hidden = true; document.body.appendChild(a); a.click(); document.body.removeChild(a); } // The detail beside a node: its values, each with the Finding it belongs to, and its // Findings as links. Stays while the pointer moves onto it. More: what a view adds to the // line under the name, as the waterfall a card's whole address. var panel = document.getElementById('netdetail'), timer = null; function detailHtml(n, more){ var sub = [n.place === 'machine' ? w.role.Machine : roles(n).join(', ')].concat(aliases(n), more || []); var h = '<h4>' + esc(label(n)) + '</h4><div class="sub">' + esc(sub.filter(Boolean).join(dot)) + '</div>'; // What the Map's Links say of it, in words: a controller of which Domain, the DNS server // of which adapter. Not judged: in no colour. if ((n.r || []).length) h += '<ul class="rel">' + n.r.map(function(x){ return '<li>' + esc(x) + '</li>'; }).join('') + '</ul>'; if (n.m.length) h += '<table>'; // One row per value and port - on this machine per adapter as well -, however many // Checks measured it: from the least to the most, in the colour of the worst Finding, // with each Finding it belongs to. var rows = {}, rowOrder = []; measurements(n).forEach(function(m){ var key = m.Metric + '|' + (m.Port || '') + '|' + (n.place === 'machine' ? m.Subject : ''); if (!rows[key]) { rows[key] = []; rowOrder.push(key); } rows[key].push(m); }); rowOrder.forEach(function(key){ var list = rows[key], m = list[0], more = [], finds = [], proxies = []; var measured = list.filter(function(x){ return !missing(x); }); if (m.Subject && n.place === 'machine') more.push(m.Subject); if (m.Port) more.push(w.detail.Port.replace('{0}', m.Port)); if (list.length > 1) more.push(w.detail.Measured.replace('{0}', list.length)); else if (m.Samples > 1) more.push(w.detail.Samples.replace('{0}', m.Samples)); list.forEach(function(x){ var f = finding(x); if (f && finds.indexOf(f.t) < 0) finds.push(f.t); if (x.Proxy && proxies.indexOf(x.Proxy) < 0) proxies.push(x.Proxy); }); proxies.forEach(function(p){ more.push(w.detail.Proxy.replace('{0}', p)); }); if (measured.length && measured.length < list.length) more.push(w.detail.NotAll.replace('{0}', list.length - measured.length)); // Nothing measured: what became of each measuring, each once. var shown = measured.length ? range(measured) : list.map(value).filter(function(v, i, a){ return a.indexOf(v) === i; }).join(dot); h += '<tr class="' + (worst(list.map(severity)) || 'NONE') + '"><td><span class="dot"></span> ' + esc(metric(m)) + (more.length ? '<small>' + esc(more.join(dot)) + '</small>' : '') + '<small>' + esc(finds.length ? finds.join(dot) : w.detail.NoFinding) + '</small></td><td class="v' + (measured.length ? '' : ' miss') + '">' + esc(shown) + '</td></tr>'; }); if (n.m.length) h += '</table>'; // What the logs say of it: how many events, from when to when, and of each kind, in the // colour of the Finding that judged that kind, and how its Finding says they were spread. var kinds = {}, order = []; counted(n).forEach(function(e){ var k = e.Detail || e.Mark; if (!kinds[k]) { kinds[k] = { n: 0, s: [], p: '' }; order.push(k); } kinds[k].n++; kinds[k].s.push(severity(e)); if (!kinds[k].p) kinds[k].p = pattern(e); }); if (order.length) { var sp = span(n), at = ''; if (sp) at = when(sp.first) === when(sp.last) ? w.detail.Once.replace('{0}', when(sp.first)) : w.detail.Span.replace('{0}', when(sp.first)).replace('{1}', when(sp.last)); h += '<table><tr><td colspan="2"><b>' + esc(w.detail.Events.replace('{0}', counted(n).length)) + '</b>' + (at ? '<small>' + esc(at) + '</small>' : '') + '</td></tr>'; order.forEach(function(k){ h += '<tr class="' + (worst(kinds[k].s) || 'NONE') + '"><td><span class="dot"></span> ' + esc(k) + (kinds[k].p ? '<small>' + esc(kinds[k].p) + '</small>' : '') + '</td><td class="v">' + esc(w.detail.Times.replace('{0}', kinds[k].n)) + '</td></tr>'; }); h += '</table>'; } var list = findings(n); if (n.place === 'linked') h += '<div class="none">' + esc(w.detail.Linked) + '</div>'; else if (!list.length) h += '<div class="none">' + esc(w.detail.NoFindings) + '</div>'; list.forEach(function(f){ h += '<a class="jump find ' + f.s + '" href="#' + esc(f.a) + '"><span class="badge">' + esc(f.s) + '</span><b>' + esc(f.t) + '</b>' + (f.v ? '<small>' + esc(f.v) + '</small>' : '') + '</a>'; }); if (list.length) h += '<div class="go">' + esc(w.detail.Jump) + '</div>'; return h; } function showDetail(el, n, more){ if (n) showHtml(el, detailHtml(n, more)); } // Any detail beside an element: what a view writes itself, the events of an hour. function showHtml(el, html){ if (!panel || !html) return; clearTimeout(timer); panel.innerHTML = html; panel.hidden = false; var r = el.getBoundingClientRect(), pw = panel.offsetWidth, ph = panel.offsetHeight; var left = r.right + 10; if (left + pw > window.innerWidth - 8) left = Math.max(8, r.left - pw - 10); var top = Math.min(Math.max(8, r.top), Math.max(8, window.innerHeight - ph - 8)); panel.style.left = left + 'px'; panel.style.top = top + 'px'; } function hideDetail(){ clearTimeout(timer); timer = setTimeout(function(){ if (panel) panel.hidden = true; }, 250); } if (panel) { panel.addEventListener('mouseenter', function(){ clearTimeout(timer); }); panel.addEventListener('mouseleave', hideDetail); window.addEventListener('scroll', function(){ panel.hidden = true; }, true); } // The switch between the views. Without the script every view stands under the other. var switches = document.querySelectorAll('#ch-network [data-netpick]'), views = document.querySelectorAll('#ch-network .netview'); function pick(id){ Array.prototype.forEach.call(switches, function(b){ b.setAttribute('aria-pressed', String(b.getAttribute('data-netpick') === id)); }); Array.prototype.forEach.call(views, function(v){ v.classList.toggle('off', v.getAttribute('data-netview') !== id); }); if (panel) panel.hidden = true; } Array.prototype.forEach.call(switches, function(b){ b.addEventListener('click', function(){ pick(b.getAttribute('data-netpick')); }); }); if (data.views.length) pick(data.views[0]); // Each view over the whole window from a sign beside its heading, and back from the same // sign, Escape or a click beside it - as a table of the evidence. var fullIcon = { open: '<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 9V4h5M20 9V4h-5M4 15v5h5M20 15v5h-5"/></svg>', shut: '<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M9 4v5H4M15 4v5h5M9 20v-5H4M15 20v-5h5"/></svg>' }; var full = null; function sign(b, open){ b.innerHTML = open ? fullIcon.open : fullIcon.shut; b.title = open ? w.full.open : w.full.shut; b.setAttribute('aria-label', b.title); } function closeWide(){ if (!full) return; var was = full; full = null; was.classList.remove('fullview'); document.body.classList.remove('has-full'); sign(was.querySelector('.wide'), true); was.scrollIntoView({ block: 'nearest' }); } Array.prototype.forEach.call(views, function(v){ var b = document.createElement('button'); b.type = 'button'; b.className = 'wide'; sign(b, true); v.insertBefore(b, v.firstChild); b.addEventListener('click', function(e){ e.stopPropagation(); if (full === v) { closeWide(); return; } closeWide(); full = v; v.classList.add('fullview'); document.body.classList.add('has-full'); sign(b, false); if (panel) panel.hidden = true; }); }); document.addEventListener('click', function(e){ if (full && e.target.closest && !e.target.closest('.netview.fullview,#netdetail')) closeWide(); }); document.addEventListener('keydown', function(e){ if (e.key === 'Escape' && full) { e.stopImmediatePropagation(); closeWide(); } }, true); window.addEventListener('beforeprint', closeWide); // A view draws itself into its own element, with this. var net = { data: data, words: w, levels: levels, dot: dot, esc: esc, num: num, finding: finding, severity: severity, pattern: pattern, worst: worst, metric: metric, missing: missing, value: value, part: part, measurements: measurements, events: events, counted: counted, roles: roles, label: label, findings: findings, aliases: aliases, parse: parse, day: day, when: when, span: span, jump: jump, showDetail: showDetail, showHtml: showHtml, hideDetail: hideDetail }; net.view = function(id, draw){ var root = document.querySelector('#ch-network [data-netview="' + id + '"]'); if (root) { try { draw(root, net); } catch (x) { if (window.console) console.error(x); } } }; return net; })(); </script> '@ function Get-ReportNetworkNode { <# .SYNOPSIS The Servers of the Network Map as the nodes a view draws, and the machine's own end as one more. Pure; nothing is merged here. .DESCRIPTION Which Server is which the Map decided (New-NetworkMap, ADR-0012), by the identity of a Server: a node is one Server of the Map, with the Map's Key, and holds the Measurements and Timed Events the Map put on it. A Server only a Link names, which no Measurement and no Timed Event does - a controller DNS named and nobody asked, a DFS target that is gone - is a node too, without numbers: it is there, and it was not measured. The machine's own end - the Map's Machine and its adapters, their link speed and the Wi-Fi signal - is one node, "machine". Each node: Id, Key (the Map's, "machine" for the machine's own end), Label (a FQDN where one was measured or logged, else a name, else an address; as it was written, without its port, UNC path or trailing dot), Names (as they were written), Addresses (the one Select-ServerAddress chooses first, then the rest as they came), Roles (in the order of $script:ReportNetworkRoleOrder), Zone (that of its first Measurement that has one), Place (machine, gateway, the Zone: lan, vpn, wan, none; events for a Server no Measurement names, linked for one only Links name), M, the indexes of its Measurements in -Measurement, and E, the indexes of its Timed Events in -TimedEvent, each in the order they were handed in. In each Place the nodes stand by their first Role, then by name. .PARAMETER Map The Network Map of the Run. Where none is handed in it is made here from the Measurements and the Timed Events, by the same New-NetworkMap: there is one rule of which Server is which. A Map handed in is made of these records, or of copies of them: a record is found on its Server by what it says, by the rule the Map placed it by (Find-NetworkMapServer), never as the same object. #> [CmdletBinding()] [OutputType([psobject])] param([AllowNull()][AllowEmptyCollection()]$Measurement, [AllowNull()][AllowEmptyCollection()]$TimedEvent, [AllowNull()]$Map) $all = @($Measurement | Where-Object { $_ }) $events = @($TimedEvent | Where-Object { $_ }) if ($null -eq $Map) { $Map = New-NetworkMap -Measurement $all -TimedEvent $events } # The Server of each record, by what the record says and by the Map's own rule # (Find-NetworkMapServer): the Server the Map put it on, a copy of it too. A Measurement # of the machine or one of its adapters is the machine's; a Timed Event of another lane # than a Server's is no Server's. Asked once for each way a record names a Server. $serverKeyOf = @{} $keyOf = { param([string]$Name, [string]$Address, [string]$Role) $asked = '{0}|{1}|{2}' -f $Name, $Address, $Role if (-not $serverKeyOf.ContainsKey($asked)) { $server = Find-NetworkMapServer -Map $Map -Name $Name -Address $Address -Role $Role $serverKeyOf[$asked] = $(if ($server) { "$($server.Key)" } else { '' }) } $serverKeyOf[$asked] } $indexesOf = @{} $place = { param([string]$Key, [string]$Which, [int]$Index) if (-not $Key) { return } if (-not $indexesOf.ContainsKey($Key)) { $indexesOf[$Key] = @{ M = New-Object System.Collections.Generic.List[int]; E = New-Object System.Collections.Generic.List[int] } } $indexesOf[$Key][$Which].Add($Index) } $machineIndexes = New-Object System.Collections.Generic.List[int] for ($i = 0; $i -lt $all.Count; $i++) { $role = "$(Get-DataProperty $all[$i] 'Role')" if ($role -in 'Machine', 'Adapter') { $machineIndexes.Add($i); continue } & $place (& $keyOf "$(Get-DataProperty $all[$i] 'Subject')" "$(Get-DataProperty $all[$i] 'Address')" $role) 'M' $i } for ($i = 0; $i -lt $events.Count; $i++) { if ("$(Get-DataProperty $events[$i] 'Lane')" -ne 'Server') { continue } & $place (& $keyOf "$(Get-DataProperty $events[$i] 'Subject')" "$(Get-DataProperty $events[$i] 'Address')" '') 'E' $i } $indexes = { param([string]$Key, [string]$Which) [int[]]@($(if ($indexesOf.ContainsKey($Key)) { $indexesOf[$Key][$Which].ToArray() })) } $rank = { param($Role) $at = [array]::IndexOf($script:ReportNetworkRoleOrder, $Role); if ($at -lt 0) { 99 } else { $at } } $placed = New-Object System.Collections.Generic.List[object] $mine = [int[]]$machineIndexes.ToArray() if ($mine.Count) { $placed.Add([pscustomobject]@{ Key = 'machine'; Label = ''; Names = [string[]]@(); Addresses = [string[]]@(); Roles = [string[]]@(); Zone = ''; Place = 'machine'; M = $mine; E = [int[]]@(); Rank = 0 }) } foreach ($server in @((Get-DataCollection $Map 'Servers') | Where-Object { $_ })) { $m = & $indexes "$($server.Key)" 'M' $e = & $indexes "$($server.Key)" 'E' $roles = @(@($server.Roles) | Where-Object { $_ } | Sort-Object { & $rank $_ }) # The name as it was written where it was measured or logged: the subject, not the # lower case the Map keeps, and without the port, UNC path, trailing dot or web # address around it. $written = @{} $subjects = @(@($m | ForEach-Object { $all[$_] }) + @($e | ForEach-Object { $events[$_] }) | ForEach-Object { "$(Get-DataProperty $_ 'Subject')".Trim() }) foreach ($subject in $subjects) { $name = (ConvertTo-NetworkMapSighting -Name $subject).Name if (-not $name -or $written.ContainsKey($name)) { continue } $at = $subject.IndexOf($name, [StringComparison]::OrdinalIgnoreCase) $written[$name] = if ($at -ge 0) { $subject.Substring($at, $name.Length) } else { $name } } $names = @(@($server.Names) | Where-Object { $_ } | ForEach-Object { if ($written.ContainsKey($_)) { $written[$_] } else { $_ } }) # Shown at the address every Gatherer would choose, the others said after it. $first = Select-ServerAddress -Address @($server.Addresses) $addresses = @(@($server.Addresses | Where-Object { $_ -eq $first }) + @($server.Addresses | Where-Object { $_ -and $_ -ne $first })) $label = @($names | Where-Object { $_.Contains('.') }) + @($names) + $addresses | Select-Object -First 1 $zone = @($m | ForEach-Object { "$(Get-DataProperty $all[$_] 'Zone')" } | Where-Object { $_ }) | Select-Object -First 1 $place = if (-not $m.Count -and -not $e.Count) { 'linked' } elseif (-not $m.Count) { 'events' } elseif ($roles -contains 'Gateway') { 'gateway' } elseif ($zone) { $zone.ToLowerInvariant() } else { 'none' } $placed.Add([pscustomobject]@{ Key = "$($server.Key)"; Label = "$label"; Names = [string[]]$names; Addresses = [string[]]$addresses; Roles = [string[]]$roles Zone = "$zone"; Place = $place; M = $m; E = $e; Rank = $(if ($roles.Count) { & $rank $roles[0] } else { 99 }) }) } $n = 0 foreach ($node in @($placed | Sort-Object @{ Expression = { $_.Place -ne 'machine' } }, Rank, Label)) { $id = if ($node.Place -eq 'machine') { 'machine' } else { $n++; 'n{0}' -f $n } [pscustomobject]@{ Id = $id; Key = $node.Key; Label = $node.Label; Names = $node.Names; Addresses = $node.Addresses; Roles = $node.Roles Zone = $node.Zone; Place = $node.Place; M = $node.M; E = $node.E } } } function Get-ReportNetworkGroup { <# .SYNOPSIS The groups the Weg stands its Servers in: by subnet, and the internet. Pure. .DESCRIPTION A Server goes, by the first of these that holds: 1. own: into a subnet of the machine's own, by the address and prefix length of an adapter (its LinkSpeed Measurement) - as exactly as Windows gave it - or by a Subnet the Network Map's Links put an adapter in (-Map). 2. vpn, wan: behind a VPN or on the internet, by its Zone. 3. assumed: in the Zone Lan, or an address of a private range, yet in no own subnet - behind a router - into the /24 (IPv4) or /64 (IPv6) around it. The prefix is not known there, and the Weg says it was assumed. 4. wan: any other address, as for a Server only the events know, which has no Zone. 5. none: a Server without an address. On the left what the machine reaches by itself: its own subnets, in the order of the adapters, then those without an address. On the right what lies beyond the gateway: the assumed subnets by address - another subnet is reached through a router, and the gateway is the one the Run knows - then vpn and wan. Within a group the gateway first, then the DNS and DHCP servers, then the rest, each in the order the nodes came in. Returns each group as id, kind, net (the subnet as 192.0.2.0/24, or empty), adapter (the adapters of an own subnet), side (left or right) and n (the ids of its nodes). .PARAMETER Node As Get-ReportNetworkNode makes them. #> [CmdletBinding()] [OutputType([System.Collections.Specialized.OrderedDictionary])] param([AllowNull()][AllowEmptyCollection()]$Node, [AllowNull()][AllowEmptyCollection()]$Measurement, [AllowNull()]$Map) $cidr = { param($Address, $Length) '{0}/{1}' -f (Get-SubnetAddress -Address $Address -PrefixLength $Length), $Length } # The machine's own subnets, each once, with every adapter that is in it: by its Key, as # New-LinkEnd keys one, called as Windows writes its alias. $own = [ordered]@{} $addAdapter = { param($Net, [string]$Name) if (-not $Name) { return } $key = (New-LinkEnd -Thing Adapter -Name $Name).Key if (-not $own[$Net].Adapters.Contains($key)) { $own[$Net].Adapters[$key] = $Name.Trim() } } foreach ($m in @($Measurement | Where-Object { $_ -and "$(Get-DataProperty $_ 'Role')" -eq 'Adapter' })) { $at = ConvertTo-NetworkAddress -Address "$(Get-DataProperty $m 'Address')" $length = ConvertTo-Number (Get-DataProperty $m 'PrefixLength') if ($null -eq $at -or $null -eq $length) { continue } $net = & $cidr $at ([int]$length) if (-not $own.Contains($net)) { $own[$net] = @{ At = $at; Length = [int]$length; Adapters = [ordered]@{} } } & $addAdapter $net "$(Get-DataProperty $m 'Subject')" } # And each Subnet the Network Map's Links put an adapter in (InSubnet): the Map's # observed Subnets, so that the Weg and what hangs together under it say the same. $subnets = @{} foreach ($subnet in (Get-DataCollection $Map 'Subnets')) { if ($subnet) { $subnets["$($subnet.Key)"] = $subnet } } $adapterName = @{} foreach ($a in (Get-DataCollection $Map 'Adapters')) { if ($a) { $adapterName["$($a.Key)"] = "$($a.Name)" } } foreach ($link in @((Get-DataCollection $Map 'Links') | Where-Object { $_ -and $_.Kind -eq 'InSubnet' })) { $subnet = $subnets["$($link.To.Key)"] $at = $(if ($subnet) { ConvertTo-NetworkAddress -Address "$($subnet.Address)" }) if ($null -eq $at) { continue } $net = & $cidr $at ([int]$subnet.PrefixLength) if (-not $own.Contains($net)) { $own[$net] = @{ At = $at; Length = [int]$subnet.PrefixLength; Adapters = [ordered]@{} } } $key = "$($link.From.Key)" & $addAdapter $net $(if ($adapterName.ContainsKey($key)) { $adapterName[$key] } else { $key }) } $groups = [ordered]@{} $put = { param($Key, $Kind, $Net, $Adapter, $Side, $Sort, $Id) if (-not $groups.Contains($Key)) { $groups[$Key] = @{ kind = $Kind; net = $Net; adapter = $Adapter; side = $Side; sort = $Sort; n = New-Object System.Collections.Generic.List[object] } } $groups[$Key].n.Add($Id) } foreach ($n in @($Node | Where-Object { $_ -and $_.Place -ne 'machine' })) { $addresses = @($n.Addresses | ForEach-Object { ConvertTo-NetworkAddress -Address $_ } | Where-Object { $_ }) $mine = $null foreach ($net in $own.Keys) { if (@($addresses | Where-Object { Test-AddressInPrefix -Address $_ -Network $own[$net].At -PrefixLength $own[$net].Length }).Count) { $mine = $net; break } } if ($mine) { & $put "own|$mine" 'own' $mine (@($own[$mine].Adapters.Values) -join ', ') 'left' ('0|{0:D3}' -f @($own.Keys).IndexOf($mine)) $n.Id; continue } if ($n.Zone -in 'Vpn', 'Wan') { & $put $n.Zone.ToLowerInvariant() $n.Zone.ToLowerInvariant() '' '' 'right' $(if ($n.Zone -eq 'Vpn') { '1' } else { '2' }) $n.Id; continue } if (-not $addresses.Count) { & $put 'none' 'none' '' '' 'left' '1' $n.Id; continue } # Lan as the Judge said it, or as the address says it of a Server only the events know. $first = $addresses[0] if ($n.Zone -eq 'Lan' -or (Get-MeasurementZone -Address $first.ToString()) -eq 'Lan') { $length = if ($first.AddressFamily -eq [System.Net.Sockets.AddressFamily]::InterNetwork) { 24 } else { 64 } $net = & $cidr $first $length $order = (@((Get-SubnetAddress -Address $first -PrefixLength $length).GetAddressBytes() | ForEach-Object { '{0:D3}' -f $_ })) -join '.' & $put "assumed|$net" 'assumed' $net '' 'right' ('0|{0}' -f $order) $n.Id continue } & $put 'wan' 'wan' '' '' 'right' '2' $n.Id } # In a group the gateway first, then the network's services, then the rest, each as it came. $rank = @{} foreach ($n in @($Node | Where-Object { $_ })) { $rank[$n.Id] = if ($n.Roles -contains 'Gateway') { 0 } elseif ($n.Roles -contains 'DnsServer' -or $n.Roles -contains 'DhcpServer') { 1 } else { 2 } } $k = 0 foreach ($side in 'left', 'right') { foreach ($g in @($groups.Values | Where-Object { $_.side -eq $side } | Sort-Object { $_.sort })) { $k++ $ids = $g.n.ToArray() $ordered = @(0..($ids.Count - 1) | Sort-Object { $rank[$ids[$_]] }, { $_ } | ForEach-Object { $ids[$_] }) [ordered]@{ id = 'g{0}' -f $k; kind = $g.kind; net = $g.net; adapter = $g.adapter; side = $g.side; n = [string[]]$ordered } } } } function Get-ReportNetworkLink { <# .SYNOPSIS The Links of the Network Map as the Weg draws them: each Link with the nodes it joins, each adapter with its servers, each Domain with its controllers by site, each Namespace with where it leads, and each node's Links in words. Pure. .DESCRIPTION Read off the Map as it is (ADR-0012): nothing is merged or assumed here, and a Link colours nothing - it says how things are connected, not whether they work. Where a Server stands in the picture and in what colour stays the nodes' business. Returns: - l: every Link of the Map, as it was observed - kind, from and to (thing, key, and n, the id of the node it is, or empty for what is no Server), and check. - adapters: each adapter of the Map - key, name, subnets (the Subnets' keys), gateway, dns, dhcp (the ids of the nodes it uses as each) - and last the Target Machine, key "machine", for what it uses where no adapter is known (DHCP, as the Network Judge says it), where it uses anything. - domains: each Domain - key; sites, each with key, name and n, the nodes of the Servers in it; and unsited, its controllers no Link puts in a site. - namespaces: each Namespace - key, domain (the one it belongs to, or empty) and targets, each share (the share's key, empty where it leads to the Server itself) and n, the node of the Server it lies on. A target no Check measured is a node all the same, without numbers. - r: the Links of each node in words, by its id. .PARAMETER Node As Get-ReportNetworkNode made them of this Map. #> [CmdletBinding()] [OutputType([System.Collections.Specialized.OrderedDictionary])] param([AllowNull()]$Map, [AllowNull()][AllowEmptyCollection()]$Node) $servers = @{} $machine = '' foreach ($n in @($Node | Where-Object { $_ })) { if ($n.Place -eq 'machine') { $machine = $n.Id } else { $servers[$n.Key] = $n.Id } } $nodeOf = { param($End) $key = "$($End.Key)" if ($End.Thing -eq 'Server' -and $servers.ContainsKey($key)) { return $servers[$key] } if ($End.Thing -eq 'Machine') { return $machine } '' } $shareOn = @{} foreach ($share in (Get-DataCollection $Map 'Shares')) { if ($share) { $shareOn["$($share.Key)"] = "$($share.Server)" } } $nodeOfShare = { param([string]$Key) $(if ($shareOn.ContainsKey($Key) -and $servers.ContainsKey($shareOn[$Key])) { $servers[$shareOn[$Key]] } else { '' }) } # Each adapter by its Key, called as Windows writes its alias (New-LinkEnd). $adapterName = @{} foreach ($a in (Get-DataCollection $Map 'Adapters')) { if ($a) { $adapterName["$($a.Key)"] = "$($a.Name)" } } $siteName = @{} foreach ($s in (Get-DataCollection $Map 'Sites')) { if ($s) { $siteName["$($s.Key)"] = "$($s.Name)" } } # Each in a list, once: the same Link from two Checks is two Links, and one target. $add = { param($List, $Item) if ("$Item" -and -not $List.Contains($Item)) { $List.Add($Item) } } $newList = { , (New-Object System.Collections.Generic.List[string]) } $links = @((Get-DataCollection $Map 'Links') | Where-Object { $_ }) $l = New-Object System.Collections.Generic.List[object] foreach ($link in $links) { $l.Add([ordered]@{ kind = "$($link.Kind)" from = [ordered]@{ thing = "$($link.From.Thing)"; key = "$($link.From.Key)"; n = (& $nodeOf $link.From) } to = [ordered]@{ thing = "$($link.To.Thing)"; key = "$($link.To.Key)"; n = (& $nodeOf $link.To) } check = "$($link.CheckName)" }) } # The adapters, in the Map's order, and the Target Machine for what no adapter is known for. $adapters = [ordered]@{} $entry = { param([string]$Key, [string]$Name) if (-not $adapters.Contains($Key)) { $adapters[$Key] = [ordered]@{ key = $Key; name = $Name; subnets = (& $newList); gateway = (& $newList); dns = (& $newList); dhcp = (& $newList) } } $adapters[$Key] } foreach ($a in (Get-DataCollection $Map 'Adapters')) { if ($a) { $null = & $entry "$($a.Key)" "$($a.Name)" } } $own = $null $uses = @{ UsesGateway = 'gateway'; UsesDns = 'dns'; UsesDhcp = 'dhcp' } foreach ($link in $links) { if ($link.Kind -eq 'InSubnet' -and $link.From.Thing -eq 'Adapter') { & $add (& $entry "$($link.From.Key)" '').subnets "$($link.To.Key)" } if (-not $uses.ContainsKey("$($link.Kind)")) { continue } if ($link.From.Thing -eq 'Adapter') { $user = & $entry "$($link.From.Key)" '' } else { if (-not $own) { $own = [ordered]@{ key = 'machine'; name = ''; subnets = (& $newList); gateway = (& $newList); dns = (& $newList); dhcp = (& $newList) } } $user = $own } & $add $user[$uses["$($link.Kind)"]] (& $nodeOf $link.To) } $adapterList = @(@($adapters.Values) + @($own | Where-Object { $_ }) | ForEach-Object { [ordered]@{ key = $_.key; name = $_.name; subnets = $_.subnets.ToArray(); gateway = $_.gateway.ToArray(); dns = $_.dns.ToArray(); dhcp = $_.dhcp.ToArray() } }) # The Domains: their controllers, each in the site a Link puts it in, and the rest together. $domainList = @(foreach ($domain in @((Get-DataCollection $Map 'Domains') | Where-Object { $_ })) { $key = "$($domain.Key)" $controllers = & $newList foreach ($link in @($links | Where-Object { $_.Kind -eq 'ControllerOf' -and "$($_.To.Key)" -eq $key })) { & $add $controllers (& $nodeOf $link.From) } $sited = & $newList $sites = @(foreach ($site in @((Get-DataCollection $Map 'Sites') | Where-Object { $_ -and "$($_.Domain)" -eq $key })) { $in = & $newList foreach ($link in @($links | Where-Object { $_.Kind -eq 'InSite' -and "$($_.To.Key)" -eq "$($site.Key)" })) { $id = & $nodeOf $link.From & $add $in $id & $add $sited $id } [ordered]@{ key = "$($site.Key)"; name = "$($site.Name)"; n = $in.ToArray() } }) [ordered]@{ key = $key; sites = $sites; unsited = @($controllers | Where-Object { -not $sited.Contains($_) }) } }) # The Namespaces, each with where it leads: a share and the Server it lies on, or a Server. $namespaceList = @(foreach ($namespace in @((Get-DataCollection $Map 'Namespaces') | Where-Object { $_ })) { $key = "$($namespace.Key)" $of = @($links | Where-Object { $_.Kind -eq 'NamespaceOf' -and "$($_.From.Key)" -eq $key } | ForEach-Object { "$($_.To.Key)" }) | Select-Object -First 1 $seen = @{} $targets = @(foreach ($link in @($links | Where-Object { $_.Kind -eq 'LeadsTo' -and $_.From.Thing -eq 'Namespace' -and "$($_.From.Key)" -eq $key })) { $share = $(if ($link.To.Thing -eq 'Share') { "$($link.To.Key)" } else { '' }) $id = $(if ($share) { & $nodeOfShare $share } else { & $nodeOf $link.To }) if ($seen.ContainsKey("$share|$id")) { continue } $seen["$share|$id"] = $true [ordered]@{ share = $share; n = $id } }) [ordered]@{ key = $key; domain = "$of"; targets = $targets } }) # Each node's Links in words, in the order they were observed, each once. $role = @{ UsesGateway = Get-Text 'Report.Network.Role.Gateway' UsesDns = Get-Text 'Report.Network.Role.DnsServer' UsesDhcp = Get-Text 'Report.Network.Role.DhcpServer' } $leading = @{} foreach ($link in @($links | Where-Object { $_.Kind -eq 'LeadsTo' -and $_.To.Thing -eq 'Share' })) { $share = "$($link.To.Key)" if (-not $leading.ContainsKey($share)) { $leading[$share] = & $newList } & $add $leading[$share] $(if ($link.From.Thing -eq 'Drive') { (Get-Text 'Report.Network.Link.Drive') -f $link.From.Key } else { "$($link.From.Key)" }) } $r = [ordered]@{} $say = { param([string]$Id, [string]$Text) if (-not $Id) { return }; if (-not $r.Contains($Id)) { $r[$Id] = & $newList }; & $add $r[$Id] $Text } foreach ($link in $links) { $kind = "$($link.Kind)" if ($kind -eq 'ControllerOf') { & $say (& $nodeOf $link.From) ((Get-Text 'Report.Network.Link.ControllerOf') -f $link.To.Key) } elseif ($kind -eq 'InSite') { $name = $(if ($siteName.ContainsKey("$($link.To.Key)")) { $siteName["$($link.To.Key)"] } else { "$($link.To.Key)" }) & $say (& $nodeOf $link.From) ((Get-Text 'Report.Network.Link.InSite') -f $name) } elseif ($uses.ContainsKey($kind)) { $text = if ($link.From.Thing -eq 'Adapter') { $alias = "$($link.From.Key)" if ($adapterName.ContainsKey($alias)) { $alias = $adapterName[$alias] } (Get-Text 'Report.Network.Link.Uses') -f $role[$kind], $alias } else { (Get-Text 'Report.Network.Link.UsesMachine') -f $role[$kind] } & $say (& $nodeOf $link.To) $text } elseif ($kind -eq 'LeadsTo' -and $link.To.Thing -eq 'Server') { & $say (& $nodeOf $link.To) ((Get-Text 'Report.Network.Link.LeadsTo') -f $link.From.Key) } elseif ($kind -eq 'OnServer' -and $link.From.Thing -eq 'Share') { $share = "$($link.From.Key)" $text = if ($leading.ContainsKey($share)) { (Get-Text 'Report.Network.Link.OnServerFrom') -f $share, ($leading[$share] -join ', ') } else { (Get-Text 'Report.Network.Link.OnServer') -f $share } & $say (& $nodeOf $link.To) $text } elseif ($kind -eq 'OnServer') { & $say (& $nodeOf $link.To) ((Get-Text 'Report.Network.Link.Service') -f $link.From.Key) } } $words = [ordered]@{} foreach ($id in $r.Keys) { $words[$id] = $r[$id].ToArray() } [ordered]@{ l = $l.ToArray(); adapters = $adapterList; domains = $domainList; namespaces = $namespaceList; r = $words } } function Get-ReportNetworkUnknown { <# .SYNOPSIS What the Weg's Links cannot show because no Check that ran could have observed it, in words: "the Domain: not checked in this Run". Pure. .DESCRIPTION Read off the Map's coverage (ADR-0012): a part is unknown where the kind of Link it is drawn from, by the thing it starts from, is in Coverage.Unknown - the adapters by HasAdapter:Machine, the Domain by ControllerOf:Server, a controller's site by InSite:Server, where a Namespace leads by LeadsTo:Namespace, and the mapped drives and the shares they lead to by LeadsTo:Drive or OnServer:Share: a part of several kinds is unknown where any of them is. Where no Kind of this module observes it (Coverage.Unobservable), that Gutcheck does not read it, whatever ran: a controller's site. Else of the Scope where a Check that would have observed it was left out by the Scope, of the rights where one lacked them, and of the Run otherwise, as Get-ReportNetworkGap says it. Empty where the part is known - and for every part where the Map knows of no Check at all: a Report made without a Run does not say what it performed. Returns adapters, domain, site, namespace and drives, each the words or empty. #> [CmdletBinding()] [OutputType([System.Collections.Specialized.OrderedDictionary])] param([AllowNull()]$Map) $said = [ordered]@{ adapters = ''; domain = ''; site = ''; namespace = ''; drives = '' } $coverage = Get-DataProperty $Map 'Coverage' $checks = @((Get-DataCollection $coverage 'Checks') | Where-Object { $_ }) if (-not $checks.Count) { return $said } $unknown = @((Get-DataCollection $coverage 'Unknown') | ForEach-Object { "$_" }) $unobservable = @((Get-DataCollection $coverage 'Unobservable') | ForEach-Object { "$_" }) $parts = @( , @('adapters', @('HasAdapter:Machine'), (Get-Text 'Report.Network.Map.Part.Adapters')) , @('domain', @('ControllerOf:Server'), (Get-Text 'Report.Network.Map.Part.Domain')) , @('site', @('InSite:Server'), (Get-Text 'Report.Network.Map.Part.Site')) , @('namespace', @('LeadsTo:Namespace'), (Get-Text 'Report.Network.Map.Part.Namespace')) , @('drives', @('LeadsTo:Drive', 'OnServer:Share'), (Get-Text 'Report.Network.Map.Part.Drives')) ) foreach ($part in $parts) { $missing = @($part[1] | Where-Object { $unknown -contains $_ }) if (-not $missing.Count) { continue } $would = @($checks | Where-Object { $observes = @($_.Links); @($missing | Where-Object { $observes -contains $_ }).Count }) $form = if (-not @($missing | Where-Object { $unobservable -notcontains $_ }).Count) { Get-Text 'Report.Network.Map.NotRead' } elseif (@($would | Where-Object { "$($_.Status)" -eq 'LeftOutByScope' }).Count) { Get-Text 'Report.Network.Gap.Scope' } elseif (@($would | Where-Object { "$($_.Status)" -eq 'LackedRights' }).Count) { Get-Text 'Report.Network.Map.Rights' } else { Get-Text 'Report.Network.Gap.Run' } $said[$part[0]] = $form -f $part[2] } $said } function Get-ReportNetworkWord { <# .SYNOPSIS The words the shared script shows: the Metrics, the Roles, the Zones, what became of a measuring, and the detail beside a node. Pure. .DESCRIPTION One word for every Metric and every Role of Measurement.ps1, as a test says; each key written out, so that the text table's test finds it read. #> [CmdletBinding()] [OutputType([System.Collections.Specialized.OrderedDictionary])] param() [ordered]@{ metric = [ordered]@{ Ping = Get-Text 'Report.Network.Metric.Ping' PingMax = Get-Text 'Report.Network.Metric.PingMax' Loss = Get-Text 'Report.Network.Metric.Loss' TcpConnect = Get-Text 'Report.Network.Metric.TcpConnect' Dns = Get-Text 'Report.Network.Metric.Dns' Ldap = Get-Text 'Report.Network.Metric.Ldap' Kerberos = Get-Text 'Report.Network.Metric.Kerberos' Smb = Get-Text 'Report.Network.Metric.Smb' Tls = Get-Text 'Report.Network.Metric.Tls' Response = Get-Text 'Report.Network.Metric.Response' FirstByte = Get-Text 'Report.Network.Metric.FirstByte' LinkSpeed = Get-Text 'Report.Network.Metric.LinkSpeed' WifiSignal = Get-Text 'Report.Network.Metric.WifiSignal' ClockOffset = Get-Text 'Report.Network.Metric.ClockOffset' ConnectFailures = Get-Text 'Report.Network.Metric.ConnectFailures' } role = [ordered]@{ DomainController = Get-Text 'Report.Network.Role.DomainController' DnsServer = Get-Text 'Report.Network.Role.DnsServer' DhcpServer = Get-Text 'Report.Network.Role.DhcpServer' FileServer = Get-Text 'Report.Network.Role.FileServer' DfsTarget = Get-Text 'Report.Network.Role.DfsTarget' PrintServer = Get-Text 'Report.Network.Role.PrintServer' AppServer = Get-Text 'Report.Network.Role.AppServer' Gateway = Get-Text 'Report.Network.Role.Gateway' InternetProbe = Get-Text 'Report.Network.Role.InternetProbe' HttpsService = Get-Text 'Report.Network.Role.HttpsService' Website = Get-Text 'Report.Network.Role.Website' WebsiteHost = Get-Text 'Report.Network.Role.WebsiteHost' TimeSource = Get-Text 'Report.Network.Role.TimeSource' Machine = Get-Text 'Report.Network.Role.Machine' Adapter = Get-Text 'Report.Network.Role.Adapter' } # The groups of the Weg that are subnets, not Zones (Get-ReportNetworkGroup). group = [ordered]@{ own = Get-Text 'Report.Network.Group.Own' assumed = Get-Text 'Report.Network.Group.Assumed' } zone = [ordered]@{ lan = Get-Text 'Report.Network.Zone.Lan' vpn = Get-Text 'Report.Network.Zone.Vpn' wan = Get-Text 'Report.Network.Zone.Wan' none = Get-Text 'Report.Network.Zone.None' } state = [ordered]@{ NotMeasured = Get-Text 'Report.Network.State.NotMeasured' Cached = Get-Text 'Report.Network.State.Cached' Blocked = Get-Text 'Report.Network.State.Blocked' Failed = Get-Text 'Report.Network.State.Failed' After = Get-Text 'Report.Network.State.After' } value = [ordered]@{ UnderOneMs = Get-Text 'Report.Network.Value.UnderOneMs' } detail = [ordered]@{ NoFinding = Get-Text 'Report.Network.Detail.NoFinding' NoFindings = Get-Text 'Report.Network.Detail.NoFindings' Port = Get-Text 'Report.Network.Detail.Port' Samples = Get-Text 'Report.Network.Detail.Samples' Measured = Get-Text 'Report.Network.Detail.Measured' NotAll = Get-Text 'Report.Network.Detail.NotAll' Proxy = Get-Text 'Report.Network.Detail.Proxy' Jump = Get-Text 'Report.Network.Detail.Jump' Events = Get-Text 'Report.Network.Detail.Events' Times = Get-Text 'Report.Network.Detail.Times' Span = Get-Text 'Report.Network.Detail.Span' Once = Get-Text 'Report.Network.Detail.Once' Linked = Get-Text 'Report.Network.Detail.Linked' } # How the Map's things hang together, under the Weg (Get-ReportNetworkLink). map = [ordered]@{ Heading = Get-Text 'Report.Network.Map.Heading' Note = Get-Text 'Report.Network.Map.Note' Adapter = Get-Text 'Report.Network.Map.Adapter' MachineUses = Get-Text 'Report.Network.Map.MachineUses' Domain = Get-Text 'Report.Network.Map.Domain' Site = Get-Text 'Report.Network.Map.Site' NoSite = Get-Text 'Report.Network.Map.NoSite' Namespace = Get-Text 'Report.Network.Map.Namespace' Nothing = Get-Text 'Report.Network.Map.Nothing' } # The sign that shows a view over the whole window: the words of the evidence's. full = [ordered]@{ open = Get-Text 'Report.Table.Wide' shut = Get-Text 'Report.Table.Narrow' } } } function Get-ReportNetworkSeverity { <# .SYNOPSIS The Severity a Measurement or a Timed Event is drawn in: that of the part of its Finding whose Subject is its own, else the Finding's; nothing without a Finding. Pure. .DESCRIPTION As the shared script's net.severity does it: a Finding that names several Servers says the Severity of each (Subjects, ADR-0009), so that one unreachable DFS target does not colour the reachable file Server beside it. The part is the one whose key is the record's, as they are: the keys are those of the Network Map, which knows that "FS01", "fs01.contoso.example." and fs01 with a port behind it are one Server. .PARAMETER Record A row of the model: key and Finding. .PARAMETER Named The Findings of the model by their Id, as Get-ReportNetworkModel makes them. #> [CmdletBinding()] [OutputType([string])] param([AllowNull()]$Record, [AllowNull()]$Named) # A row of the model is a dictionary, whose keys are no properties. $id = "$($Record['Finding'])" if (-not $id -or $null -eq $Named -or -not $Named.Contains($id)) { return '' } $f = $Named[$id] $key = "$($Record['key'])" if ($key) { foreach ($part in @($f.subjects)) { if ("$($part.key)" -ceq $key) { return "$($part.s)" } } } "$($f.s)" } function Get-ReportNetworkPartKey { <# .SYNOPSIS The key of the Server a Finding's part is about, however the Judge spelled its Subject: another case, a short name, a trailing dot, a port, a UNC path. Pure. .DESCRIPTION The Server of the Network Map the Subject is, as the Map finds it (Find-NetworkMapServer): where it is several, the one that holds a record of the Finding - "FS01" of a Finding about fs01.contoso.example is that Server, not fs01.fabrikam.example. A Subject no Server is - a program, an adapter - is its own key, its name or address as the Map reads it (ConvertTo-NetworkMapSighting), and so is the key of a record on no Server. .PARAMETER Map The Network Map the nodes are the Servers of. .PARAMETER Holding The keys of the nodes that hold a record of the part's Finding. #> [CmdletBinding()] [OutputType([string])] param([AllowNull()][AllowEmptyString()][string]$Subject, [AllowNull()]$Map, [AllowNull()][AllowEmptyCollection()][string[]]$Holding) $server = $(if ($null -ne $Map) { Find-NetworkMapServer -Map $Map -Name $Subject -Prefer @($Holding) }) if ($server) { return "$($server.Key)" } $seen = ConvertTo-NetworkMapSighting -Name $Subject $(if ($seen.Name) { $seen.Name } else { $seen.Address }) } function ConvertTo-ReportNetworkRow { # A record as the model holds it: every column, a number as a number, a time as ISO # 8601 without a zone, as the machine's clock read it, and nothing as an empty text. [CmdletBinding()] [OutputType([System.Collections.Specialized.OrderedDictionary])] param([AllowNull()]$Record, [string[]]$Column, [string[]]$Number = @()) $row = [ordered]@{} foreach ($name in $Column) { $value = Get-DataProperty $Record $name if ($name -in $Number) { $value = ConvertTo-Number $value } elseif ($value -is [datetime]) { $value = $value.ToString('yyyy-MM-ddTHH:mm:ss', [Globalization.CultureInfo]::InvariantCulture) } elseif ($null -eq $value) { $value = '' } else { $value = "$value" } $row[$name] = $value } $row } function Get-ReportNetworkCoverage { <# .SYNOPSIS From when on a row of the 30 days is covered by its logs: the earliest any of them reaches back to. Nothing where one of them reaches back over everything or is not known to stop. Pure. .DESCRIPTION A row is hatched only before the time none of the logs its events come from reaches back - one log that stops early under another that reaches further leaves no stretch unread. A log read as Unread covers nothing. Returns Log (the one that reaches back furthest), Mark (Reaches, or Unread where every one was refused) and Time (as the model writes it, empty for Unread); or nothing. .PARAMETER Log The logs the row's events come from. .PARAMETER Reach How far back each log reaches, as the model holds it (tc): Log, Mark and Time. #> [CmdletBinding()] [OutputType([System.Collections.Specialized.OrderedDictionary])] param([AllowNull()][AllowEmptyCollection()][string[]]$Log, [AllowNull()][AllowEmptyCollection()]$Reach) $logs = @($Log | Where-Object { $_ } | Select-Object -Unique) if (-not $logs.Count) { return } $earliest = $null $refused = $null foreach ($name in $logs) { $known = @($Reach | Where-Object { $_ -and "$($_.Log)" -eq $name }) | Select-Object -First 1 # A log not known to stop may reach back over everything. if (-not $known) { return } if ("$($known.Mark)" -eq 'Unread') { if (-not $refused) { $refused = $name }; continue } $at = ConvertTo-DataTime $known.Time if ($null -eq $at) { return } if ($null -eq $earliest -or $at -lt $earliest.At) { $earliest = @{ At = $at; Log = $name; Time = "$($known.Time)" } } } if ($earliest) { return [ordered]@{ Log = $earliest.Log; Mark = 'Reaches'; Time = $earliest.Time } } [ordered]@{ Log = $refused; Mark = 'Unread'; Time = '' } } function Get-ReportNetworkModel { <# .SYNOPSIS What the chapter's script is handed: the Measurements and the Timed Events as they came, the Findings they name, the nodes and the lanes, and the words. Pure. .DESCRIPTION machine: the Target Machine's name. m: every Measurement, with every field of measurements.csv, and key, the key of its Server in the Network Map. f: each Finding a Measurement or a Timed Event names, by its Id - s its Severity, t its title, v its Value, a where in the Report it stands, subjects each Server it names with the key of its Server (Get-ReportNetworkPartKey), the Severity of its part and how the events counted for it were spread in time, in words ({subject, key, s, pattern}). One naming a Finding that is not in the Report names none here, and is drawn grey. The script finds the part of a record by its key as it is, and decides no identity itself. n: the nodes of Get-ReportNetworkNode, the Map's Servers - id, key (the Map's), label, names, addresses, roles, zone, place - each with s, the worst Severity its values are drawn in (for a Server only the events know, its events), m the indexes of its Measurements and e those of its Timed Events in t, and r its Links in words. g: the groups the Weg stands the nodes in, by subnet (Get-ReportNetworkGroup). l, adapters, domains and namespaces: the Map's Links, each end with its node, and what they say of each adapter, Domain and Namespace (Get-ReportNetworkLink). unknown: what no Check that ran could have observed, in words (Get-ReportNetworkUnknown). levels: the Severities, the worst first, as a Finding has them. w: the words. views: the Ids of the views, filled by ConvertTo-ReportNetwork. The 30 days (ADR-0010): asof, when the Run was, which the axis ends at. t: every Timed Event but a log's reach - every field of timeline.csv, Time local, to the second - key, as a Measurement's, and row, the lane it is drawn in: 'machine', 'marker', or the id of a Server's lane. tl: the Servers' lanes - id, label, names, addresses, node (the id of the node of the path it is), e, the indexes of its events in t, and cover, from when on its logs cover it (Get-ReportNetworkCoverage), or nothing. tc: how far back each log reaches - Log, Mark (Reaches or Unread) and Time. tr: the cover of each row the script draws with events - a Server's lane, machine, crash (crashes and hangs) and start (the other markers) - where one is known; the script hatches a row before it and nowhere else. A Server is one node and one lane: both are the one Server of the Network Map, and no part of the chapter decides which Server is which. .PARAMETER Word The Report's own, for where each Finding stands (Get-ReportFindingAnchor). .PARAMETER NetworkMap The Run's Network Map, made of these Measurements and Timed Events (New-NetworkMap). Where none is handed in - a Report made without a Run - it is made here of them. #> [CmdletBinding()] [OutputType([System.Collections.Specialized.OrderedDictionary])] param( [AllowNull()][AllowEmptyCollection()]$Measurement, [AllowNull()][AllowEmptyCollection()]$Finding, [AllowNull()][hashtable]$Word, [string]$ComputerName = '', [AllowNull()][AllowEmptyCollection()]$TimedEvent, [datetime]$AsOf = (Get-Date), [AllowNull()]$NetworkMap ) $all = @($Measurement | Where-Object { $_ }) if ($null -eq $NetworkMap) { $NetworkMap = New-NetworkMap -Measurement $all -TimedEvent @($TimedEvent | Where-Object { $_ }) } $byId = @{} foreach ($f in @($Finding | Where-Object { $_ })) { $id = "$(Get-DataProperty $f 'Id')".Trim() if ($id -and -not $byId.ContainsKey($id)) { $byId[$id] = $f } } $rows = New-Object System.Collections.Generic.List[object] foreach ($m in $all) { $rows.Add((ConvertTo-ReportNetworkRow -Record $m -Column $script:MeasurementCsvColumns -Number 'Value', 'StartMs', 'Samples', 'Port', 'PrefixLength')) } # The 30 days: every event but a log's reach, with the lane it is drawn in, and the # reach of each log on its own. $events = @($TimedEvent | Where-Object { $_ -and "$(Get-DataProperty $_ 'Lane')" -ne 'Log' }) $stamp = { param($Time) $at = ConvertTo-DataTime $Time; if ($null -eq $at) { '' } else { $at.ToString('yyyy-MM-ddTHH:mm:ss', [Globalization.CultureInfo]::InvariantCulture) } } $timed = New-Object System.Collections.Generic.List[object] foreach ($e in $events) { $row = [ordered]@{} foreach ($column in $script:TimedEventCsvColumns) { $row[$column] = if ($column -eq 'Time') { & $stamp (Get-DataProperty $e 'Time') } else { "$(Get-DataProperty $e $column)" } } $row.row = "$($row.Lane)".ToLowerInvariant() $timed.Add($row) } $reach = @($TimedEvent | Where-Object { $_ -and "$(Get-DataProperty $_ 'Lane')" -eq 'Log' } | ForEach-Object { [ordered]@{ Log = "$(Get-DataProperty $_ 'Log')"; Mark = "$(Get-DataProperty $_ 'Mark')"; Time = & $stamp (Get-DataProperty $_ 'Time') } }) # The Map's Servers for the path and the 30 days: the nodes, and of them those with # events are the Servers' lanes. $mapped = @(Get-ReportNetworkNode -Measurement $all -TimedEvent $events -Map $NetworkMap) # The key of each record: that of its Server. A record on no Server - the machine's # adapters, its own events, the markers - is keyed by its Subject, as a part about no # Server is (Get-ReportNetworkPartKey). foreach ($row in @($rows.ToArray()) + @($timed.ToArray())) { $row.key = Get-ReportNetworkPartKey -Subject $row.Subject } $holding = @{} foreach ($node in $mapped) { if ($node.Place -eq 'machine') { continue } foreach ($index in $node.M) { $rows[$index].key = $node.Key } foreach ($index in $node.E) { $timed[$index].key = $node.Key } foreach ($row in @($node.M | ForEach-Object { $rows[$_] }) + @($node.E | ForEach-Object { $timed[$_] })) { if (-not $row.Finding) { continue } if (-not $holding.ContainsKey($row.Finding)) { $holding[$row.Finding] = New-Object System.Collections.Generic.List[string] } $holding[$row.Finding].Add($node.Key) } } $named = [ordered]@{} foreach ($row in @($rows.ToArray()) + @($timed.ToArray())) { $id = $row.Finding if (-not $id -or -not $byId.ContainsKey($id) -or $named.Contains($id)) { continue } $f = $byId[$id] $held = if ($holding.ContainsKey($id)) { $holding[$id].ToArray() } else { @() } $named[$id] = [ordered]@{ s = "$($f.Severity)"; t = "$($f.Check)"; v = "$($f.Value)" a = Get-ReportFindingAnchor -Finding $f -Word $Word subjects = @((Get-DataCollection $f 'Subjects') | ForEach-Object { $subject = "$(Get-DataProperty $_ 'Subject')" [ordered]@{ subject = $subject; key = Get-ReportNetworkPartKey -Subject $subject -Map $NetworkMap -Holding $held s = "$(Get-DataProperty $_ 'Severity')"; pattern = "$(Get-DataProperty $_ 'PatternText')" } }) } } # How the Map's things hang together, as its Links say: for the Weg and each node's detail. $linked = Get-ReportNetworkLink -Map $NetworkMap -Node $mapped $order = @{} for ($i = 0; $i -lt $script:SeverityValues.Count; $i++) { $order[$script:SeverityValues[$i]] = $i } $nodes = @($mapped | ForEach-Object { $worst = '' $drawn = if ($_.M.Count) { @($_.M | ForEach-Object { $rows[$_] }) } else { @($_.E | ForEach-Object { $timed[$_] }) } foreach ($row in $drawn) { $severity = Get-ReportNetworkSeverity -Record $row -Named $named if ($severity -and (-not $worst -or $order[$severity] -lt $order[$worst])) { $worst = $severity } } [ordered]@{ id = $_.Id; key = $_.Key; label = $_.Label; names = @($_.Names); addresses = @($_.Addresses); roles = @($_.Roles) zone = $_.Zone; place = $_.Place; s = $worst; m = @($_.M); e = @($_.E) r = @($(if ($linked.r.Contains($_.Id)) { $linked.r[$_.Id] })) } }) $lanes = @() $k = 0 foreach ($node in @($mapped | Where-Object { @($_.E).Count } | Sort-Object @{ Expression = { @($_.E).Count }; Descending = $true }, Label)) { $k++ $id = 's{0}' -f $k foreach ($index in $node.E) { $timed[$index].row = $id } $lanes += [ordered]@{ id = $id; label = $node.Label; names = @($node.Names); addresses = @($node.Addresses); node = $node.Id; e = @($node.E) } } # From when on each row is covered by the logs its events come from, by the row the # script draws: a Server's lane, the machine's, and the markers as two rows - the crashes # and hangs, and the rest - as the script splits them. $logsOf = [ordered]@{} foreach ($row in $timed) { $key = if ($row.row -ne 'marker') { $row.row } elseif ($row.Mark -in 'Crash', 'Hang') { 'crash' } else { 'start' } if (-not $logsOf.Contains($key)) { $logsOf[$key] = New-Object System.Collections.Generic.List[string] } if ($row.Log) { $logsOf[$key].Add($row.Log) } } $covered = [ordered]@{} foreach ($key in $logsOf.Keys) { $cover = Get-ReportNetworkCoverage -Log $logsOf[$key].ToArray() -Reach $reach if ($cover) { $covered[$key] = $cover } } foreach ($lane in $lanes) { $lane.cover = $(if ($covered.Contains($lane.id)) { $covered[$lane.id] } else { $null }) } # The groups of the Weg. Into a list and not @(), for the reason m is. $grouped = New-Object System.Collections.Generic.List[object] foreach ($g in (Get-ReportNetworkGroup -Node $mapped -Measurement $all -Map $NetworkMap)) { $grouped.Add($g) } [ordered]@{ machine = $ComputerName asof = $AsOf.ToString('yyyy-MM-ddTHH:mm:ss', [Globalization.CultureInfo]::InvariantCulture) levels = @($script:SeverityValues) views = @() # ToArray and not @(): @() of a list of dictionaries throws "Argument types do not match". m = $rows.ToArray() f = $named n = $nodes t = $timed.ToArray() tl = $lanes tc = $reach tr = $covered # The groups of the Weg, by subnet (Get-ReportNetworkGroup). g = $grouped.ToArray() # The Map's Links, and what they say of the adapters, Domains and Namespaces # (Get-ReportNetworkLink); what no Check that ran could have observed (Get-ReportNetworkUnknown). l = $linked.l adapters = $linked.adapters domains = $linked.domains namespaces = $linked.namespaces unknown = Get-ReportNetworkUnknown -Map $NetworkMap w = Get-ReportNetworkWord } } function New-ReportNetworkView { <# .SYNOPSIS One view of the chapter, as a view's function returns it. Pure. .PARAMETER Id What the view is called in the markup and in its script: path, waterfall. .PARAMETER Title Its name on the switch. .PARAMETER Html What it is drawn into: its heading, its legend, an svg. The script draws the rest. .PARAMETER Css Its own <style> block. .PARAMETER Script Its own <script> block, calling GutcheckNet.view(Id, ...). .PARAMETER Word The words its script shows, there as net.words.<Id>. #> [CmdletBinding()] [OutputType([psobject])] param( [Parameter(Mandatory)][string]$Id, [Parameter(Mandatory)][string]$Title, [Parameter(Mandatory)][string]$Html, [string]$Css = '', [string]$Script = '', [AllowNull()]$Word ) [pscustomobject]@{ Id = $Id; Title = $Title; Html = $Html; Css = $Css; Script = $Script; Word = $Word } } function ConvertTo-ReportNetworkData { # The model as the JSON block the script reads. Inside a script element nothing may # close it. [CmdletBinding()] [OutputType([string])] param([Parameter(Mandatory)]$Model) $json = (ConvertTo-Json -InputObject $Model -Compress -Depth 8) -replace '<', ('{0}u003c' -f [char]92) '<script type="application/json" id="netdata">{0}</script>' -f $json } function Get-ReportNetworkGap { <# .SYNOPSIS What the picture cannot show because the Run did not perform the Checks that measure it: one line each, "the domain controllers: not checked in this Scope". Pure. .DESCRIPTION A picture without domain controllers reads as a network without them. Decided from the Kinds the Run performed: a group is missing where none of its Kinds was. Said of the Scope where a Scope narrowed the Run, and of the Run otherwise. Nothing where the Run did not say what it performed. .PARAMETER Coverage What the Run performed: Kinds, the Kinds of its Checks that were not skipped, and Scope, the title of the Scope it was narrowed to, or empty. #> [CmdletBinding()] [OutputType([string])] param([AllowNull()]$Coverage) if ($null -eq $Coverage) { return } $kinds = @((Get-DataCollection $Coverage 'Kinds') | ForEach-Object { "$_" }) $form = if ("$(Get-DataProperty $Coverage 'Scope')".Trim()) { Get-Text 'Report.Network.Gap.Scope' } else { Get-Text 'Report.Network.Gap.Run' } # What each group of the picture is measured by, in the order the picture reads. $groups = @( , @((Get-Text 'Report.Network.Gap.Network'), @('Network')) , @((Get-Text 'Report.Network.Gap.Domain'), @('Domain')) , @((Get-Text 'Report.Network.Gap.Server'), @('Server', 'NetworkDependency', 'AppConnection')) , @((Get-Text 'Report.Network.Gap.Printing'), @('Printing')) , @((Get-Text 'Report.Network.Gap.Web'), @('HttpsEndpoint', 'Website')) , @((Get-Text 'Report.Network.Gap.Disruption'), @('NetworkDisruption')) ) foreach ($group in $groups) { if (-not @($group[1] | Where-Object { $kinds -contains $_ }).Count) { $form -f $group[0] } } } function Test-ReportNetworkMeasured { <# .SYNOPSIS Whether the Run measured the network: a Measurement, or a Timed Event of a Server's or the machine's lane. Pure. .DESCRIPTION The markers - crashes, hangs, starts, sign-ins - come from Stability and Logon, which a Scope without the network Checks performs as well; and a log's reach is no event. Neither alone is a network to draw (#96, story 27). Where the chapter is there, the 30 days draw them. .PARAMETER TimedEvent As a Judge made them, or as the model holds them: each with its Lane. #> [CmdletBinding()] [OutputType([bool])] param([AllowNull()][AllowEmptyCollection()]$Measurement, [AllowNull()][AllowEmptyCollection()]$TimedEvent) if (@($Measurement | Where-Object { $_ }).Count) { return $true } [bool]@($TimedEvent | Where-Object { $_ -and "$($_.Lane)" -in $script:ReportNetworkLanes }).Count } function ConvertTo-ReportNetwork { <# .SYNOPSIS The chapter "Netzwerk im Bild", or nothing where the Run measured no network (Test-ReportNetworkMeasured), or where no view has anything to draw. Pure. .DESCRIPTION Returns Html (the chapter), Css (for the head), Script (the JSON block and the scripts, after the Report's own), Nav (its entry in the tree, in the group of the network) and Model (what the script is handed). #> [CmdletBinding()] [OutputType([psobject])] param( [AllowNull()][AllowEmptyCollection()]$Measurement, [AllowNull()][AllowEmptyCollection()]$Finding, [AllowNull()][hashtable]$Word, [string]$ComputerName = '', [AllowNull()][AllowEmptyCollection()]$TimedEvent, [datetime]$AsOf = (Get-Date), # What the Run performed, for what the picture says it cannot show # (Get-ReportNetworkGap). Nothing said where it is not given. [AllowNull()]$Coverage, # The Run's Network Map, whose Servers the nodes are (New-NetworkMap). Made here of # the Measurements and Timed Events where it is not given. [AllowNull()]$NetworkMap ) $all = @($Measurement | Where-Object { $_ }) $timed = @($TimedEvent | Where-Object { $_ }) if (-not (Test-ReportNetworkMeasured -Measurement $all -TimedEvent $timed)) { return } $model = Get-ReportNetworkModel -Measurement $all -Finding $Finding -Word $Word -ComputerName $ComputerName -TimedEvent $timed -AsOf $AsOf -NetworkMap $NetworkMap $views = @(foreach ($name in $script:ReportNetworkViews) { & $name -Model $model }) $views = @($views | Where-Object { $_ }) # No empty chapter: where no view has anything to draw, there is none. if (-not $views.Count) { return } $model.views = @($views | ForEach-Object { $_.Id }) foreach ($view in $views) { if ($null -ne $view.Word) { $model.w[$view.Id] = $view.Word } } $switch = @($views | ForEach-Object { '<button type="button" data-netpick="{0}" aria-pressed="false">{1}</button>' -f (ConvertTo-HtmlText $_.Id), (ConvertTo-HtmlText $_.Title) }) -join '' $legend = (@($script:SeverityValues | ForEach-Object { '<span class="{0}"><i class="sw"></i>{0}</span>' -f $_ }) -join '') + ('<span><svg width="34" height="10" aria-hidden="true"><line x1="0" y1="5" x2="34" y2="5" stroke="currentColor" stroke-width="2" stroke-dasharray="4 3"/></svg>{0}</span>' -f (ConvertTo-HtmlText (Get-Text 'Report.Network.Legend.NoFinding'))) $body = @($views | ForEach-Object { '<div class="netview" data-netview="{0}" id="nv-{0}">{1}</div>' -f (ConvertTo-HtmlText $_.Id), $_.Html }) -join "`n" $gaps = @(Get-ReportNetworkGap -Coverage $Coverage) $gapHtml = '' if ($gaps.Count) { $gapHtml = '<div class="netgap"><b>{0}</b><ul>{1}</ul></div>' -f (ConvertTo-HtmlText (Get-Text 'Report.Network.Gap.Heading')), (($gaps | ForEach-Object { '<li>{0}</li>' -f (ConvertTo-HtmlText $_) }) -join '') } $html = ("<section data-chapter=`"network`" id=`"ch-network`">`n<h2>{0}</h2>`n<p class=`"netnote`">{1}</p>`n{6}" + "<div class=`"netbar`"><div class=`"netswitch`" role=`"group`" aria-label=`"{2}`">{3}</div><div class=`"netlegend`">{4}</div></div>`n{5}`n" + "<div class=`"netdetail`" id=`"netdetail`" hidden></div>`n</section>") -f ` (ConvertTo-HtmlText (Get-Text 'Report.Network.Heading')), (ConvertTo-HtmlText (Get-Text 'Report.Network.Intro')), (ConvertTo-HtmlText (Get-Text 'Report.Network.Views')), $switch, $legend, $body, $gapHtml [pscustomobject]@{ Html = $html Css = $script:ReportNetworkCss + (@($views | ForEach-Object { $_.Css }) -join '') Script = (ConvertTo-ReportNetworkData -Model $model) + "`n" + $script:ReportNetworkScript + (@($views | ForEach-Object { $_.Script }) -join '') Nav = [pscustomobject]@{ Pick = 'network'; Label = (Get-Text 'Report.Network.Heading'); Severity = 'plain'; Count = -1; Icon = 'Network'; Tip = (Get-Text 'Report.Network.NavTip') } Model = $model } } |