Private/ReportNetworkRanking.ps1
|
# The ranking view of "Netzwerk im Bild" (ReportNetwork.ps1): every Measurement with a # number as one bar, the worst on top - the largest time, the smallest link speed -, # coloured by the Severity of the Finding it belongs to and grey and dashed where no Finding # judges it. Below the bars what has no number - not # measured, answered from a cache, blocked, failed without a time - with what became of it, # so that a missing bar is not read as a fast one. # # One family of Metrics at a time: a time, a loss in percent and a link speed on one axis # would be a picture of nothing. The family "all times" is every Metric in milliseconds and # is shown first; each other family is one or two Metrics of one unit. Every Metric stands in # a family besides "all times", so that whatever a Run measured is somewhere in the ranking. # # The geometry is the script's and is looked at, not tested: a Report rendered in a # headless browser from fixture Measurements. # The Metrics whose smallest value is the worst: a slow link, a weak Wi-Fi. Of every other # the largest is. The one place the ranking's order is decided, for each family. $script:ReportNetworkSmallerIsWorse = @('LinkSpeed', 'WifiSignal') # The Metrics whose worst is the furthest from 0, either way: a clock 90 s ahead is as wrong # as one 90 s behind. Their bars are as long as the value's size, and the label keeps the sign. $script:ReportNetworkByAbsolute = @('ClockOffset') $script:ReportNetworkRankingCss = @' <style> /* Netzwerk im Bild: the ranking (ReportNetworkRanking.ps1). */ .netrankbar{display:flex;flex-wrap:wrap;gap:8px 18px;align-items:center;margin:0 0 10px} .netrankbar div{display:flex;flex-wrap:wrap;gap:6px} .netrankbar button{background:var(--panel);border:1px solid var(--line);border-radius:999px;padding:2px 11px;color:var(--dim);font-size:12px} .netrankbar button:hover{color:var(--text);border-color:var(--faint)} .netrankbar button[aria-pressed="true"]{color:var(--text);border-color:var(--accent);box-shadow:inset 0 0 0 1px var(--accent)} .netrankbar button small{color:var(--faint);margin-left:5px;font-size:11px} .netrankshown{display:none;color:var(--dim);font-size:12.5px;margin:0 0 6px} .netrankempty{color:var(--faint);font-style:italic;margin:6px 4px} .netrank .grid{stroke:var(--line);stroke-width:1} .netrank .hit{fill:transparent} .netrank .row:hover .hit,.netrank .row:focus .hit,.netrank .row.sel .hit{fill:var(--panel2)} .netrank .row:focus{outline:none} .netrank .row.go{cursor:pointer} .netrank .bar{fill:var(--c)} .netrank .row.NONE .bar{fill:var(--faint);fill-opacity:.3;stroke:var(--faint);stroke-width:1;stroke-dasharray:4 3} .netrankmiss h4{margin:12px 0 2px;font:600 11px var(--mono);letter-spacing:.1em;color:var(--dim);text-transform:uppercase} .netrankmiss p{margin:0 0 6px;color:var(--faint);font-size:12px} .netrankmiss ul{list-style:none;margin:0;padding:0;font-size:12.5px} .netrankmiss li{padding:3px 8px;margin:2px 0;border-left:3px solid var(--cl);border-radius:2px} .netrankmiss li.NONE{border-left-color:var(--line)} .netrankmiss li.go{cursor:pointer} .netrankmiss li:hover,.netrankmiss li:focus,.netrankmiss li.sel{background:var(--panel2);outline:none} .netrankmiss .dim{color:var(--dim)} .netrankmiss .why{color:var(--faint);font-style:italic} @media print{ .netrankbar{display:none!important} .netrankshown{display:block} .netrank .hit{fill:none!important} .netrankmiss li{background:none!important} } </style> '@ $script:ReportNetworkRankingScript = @' <script> // The ranking: the Measurements of one family of Metrics, those with a number as bars from // the worst down, on a logarithmic or a linear axis, those without as a list below. Widths // are counted in characters and not measured: the chapter may be hidden when it is drawn. if (window.GutcheckNet) GutcheckNet.view('ranking', function(root, net){ var svg = root.querySelector('svg.netrank'), d = net.data, w = net.words, r = w.ranking, esc = net.esc; var pickBox = root.querySelector('.netrankpick'), scaleBox = root.querySelector('.netrankscale'); var shown = root.querySelector('.netrankshown'), empty = root.querySelector('.netrankempty'), missBox = root.querySelector('.netrankmiss'); if (!svg || !pickBox || !scaleBox) return; var ell = String.fromCharCode(8230); // Each Measurement with the node it was merged into: its name, its place, its detail. var nodeOf = {}; d.n.forEach(function(n){ n.m.forEach(function(i){ nodeOf[i] = n; }); }); var rows = d.m.map(function(m, i){ return { m: m, i: i, n: nodeOf[i] }; }); // A number to draw: measured, or the time a step failed after. function has(m){ return m.Value != null && (m.State === 'Measured' || m.State === 'Failed'); } function inFamily(f){ return function(x){ return f.metrics.indexOf(x.m.Metric) >= 0; }; } var families = r.families.filter(function(f){ return rows.some(inFamily(f)); }); if (!families.length) return; var cur = families[0].id, log = true; function cut(s, n){ s = String(s); return s.length > n ? s.slice(0, Math.max(1, n - 1)) + ell : s; } function name(x){ var n = x.n; if (n && n.place === 'machine') return x.m.Subject || d.machine; return (n && net.label(n)) || x.m.Subject || (n ? net.roles(n).join(', ') : ''); } function where(x){ var n = x.n; if (!n) return ''; if (n.place === 'machine') return w.role.Machine; if (n.place === 'gateway') return w.role.Gateway; return w.zone[n.place] || ''; } function what(m){ return net.metric(m) + (m.Port && m.Metric === 'TcpConnect' ? ' ' + w.detail.Port.replace('{0}', m.Port) : ''); } // What became of a measuring without a number, in words, with the error where one is. function reason(m){ var said = w.state[m.State] || w.state.Failed; return m.Error && m.Error !== said ? said + ': ' + m.Error : said; } function tick(v, unit){ var t = Number(v).toLocaleString('de-DE', { maximumFractionDigits: 3 }); return unit === 'count' ? t : t + ' ' + unit; } function lg(v){ return Math.log(v) / Math.LN10; } function buttons(){ pickBox.innerHTML = families.map(function(f){ var count = rows.filter(inFamily(f)).filter(function(x){ return has(x.m); }).length; return '<button type="button" data-rank="' + esc(f.id) + '" aria-pressed="' + (f.id === cur) + '">' + esc(f.name) + '<small>' + count + '</small></button>'; }).join(''); scaleBox.innerHTML = '<button type="button" data-scale="log" aria-pressed="' + log + '">' + esc(r.Log) + '</button>' + '<button type="button" data-scale="lin" aria-pressed="' + !log + '">' + esc(r.Linear) + '</button>'; } pickBox.addEventListener('click', function(e){ var b = e.target.closest('button'); if (b) { cur = b.getAttribute('data-rank'); draw(); } }); scaleBox.addEventListener('click', function(e){ var b = e.target.closest('button'); if (b) { log = b.getAttribute('data-scale') === 'log'; draw(); } }); // On the pointer or the keyboard: the values and Findings of the bar's Server beside it. // A click, or Enter, leads to the Finding the value belongs to. function wire(el){ var x = rows[Number(el.getAttribute('data-i'))], at = el.querySelector('.bar, .why') || el; function on(){ el.classList.add('sel'); if (x.n) net.showDetail(at, x.n); } function off(){ el.classList.remove('sel'); net.hideDetail(); } function go(){ var f = net.finding(x.m); if (f) net.jump(f.a); } el.addEventListener('mouseenter', on); el.addEventListener('focus', on); el.addEventListener('mouseleave', off); el.addEventListener('blur', off); el.addEventListener('click', go); el.addEventListener('keydown', function(e){ if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); go(); } }); } function draw(){ buttons(); var fam = families.filter(function(f){ return f.id === cur; })[0] || families[0]; var mine = rows.filter(inFamily(fam)); // How long a bar is: the value, or its size where either sign is as bad (a clock offset). function size(m){ return fam.worst === 'abs' ? Math.abs(m.Value) : m.Value; } // The worst on top: the largest, or the smallest where less is worse (a link, a signal). var low = fam.worst === 'low'; var bars = mine.filter(function(x){ return has(x.m); }).sort(function(a, b){ return (low ? size(a.m) - size(b.m) : size(b.m) - size(a.m)) || name(a).localeCompare(name(b)); }); var miss = mine.filter(function(x){ return !has(x.m); }); if (shown) shown.textContent = r.Shown.replace('{0}', fam.name).replace('{1}', log ? r.Log : r.Linear); if (!bars.length) { svg.innerHTML = ''; svg.style.display = 'none'; if (empty) empty.hidden = false; } else { svg.style.display = ''; if (empty) empty.hidden = true; var unit = bars[0].m.Unit, values = bars.map(function(x){ return size(x.m); }); var max = Math.max.apply(null, values), small = values.filter(function(v){ return v > 0; }); var W = 1100, ZW = 96, RH = 19, TOP = 30, longest = 0; bars.forEach(function(x){ longest = Math.max(longest, (name(x) + net.dot + what(x.m)).length); }); var LX = ZW + Math.round(Math.min(380, Math.max(150, longest * 6.3))) + 10, PW = W - LX - 200; var lo, hi, ticks = [], sx; if (log) { var least = small.length ? Math.min.apply(null, small) : 1; lo = Math.pow(10, Math.floor(lg(least))); if (least / lo < 2) lo = lo / 10; hi = Math.pow(10, Math.ceil(lg(Math.max(max, lo * 10)))); for (var t = lo; t <= hi * 1.0001; t = t * 10) ticks.push(t); sx = function(v){ return v <= lo ? 0 : (lg(v) - lg(lo)) / (lg(hi) - lg(lo)) * PW; }; } else { var raw = Math.max(max, 1e-9) / 5, mag = Math.pow(10, Math.floor(lg(raw))), step = mag; [1, 2, 5, 10].some(function(k){ step = k * mag; return step >= raw; }); lo = 0; hi = Math.ceil(max / step) * step || step; for (var k = 0; k <= hi / step + 0.0001; k++) ticks.push(k * step); sx = function(v){ return Math.max(0, v) / hi * PW; }; } var H = TOP + bars.length * RH + 8; var out = ticks.map(function(t){ var x = (LX + sx(t)).toFixed(1); return '<line class="grid" x1="' + x + '" y1="' + (TOP - 4) + '" x2="' + x + '" y2="' + (H - 4) + '"/>' + '<text class="faint" x="' + x + '" y="' + (TOP - 10) + '" font-size="10" text-anchor="middle">' + esc(tick(t, unit)) + '</text>'; }).join(''); var room = Math.floor((LX - ZW - 14) / 6.3); bars.forEach(function(x, k){ var m = x.m, f = net.finding(m), s = net.severity(m) || 'NONE', y = TOP + k * RH, bw = Math.max(3, sx(size(m))); var said = net.value(m), cls = m.State !== 'Measured' ? 'miss' : (f ? 'sevt' : 'faint'); var label = what(m), who = cut(name(x), Math.max(8, room - label.length - 3)); out += '<g class="row ' + s + (f ? ' go' : '') + '" data-i="' + x.i + '" tabindex="0" role="button" aria-label="' + esc(name(x) + net.dot + label + ': ' + said) + '">' + '<rect class="hit" x="0" y="' + y + '" width="' + W + '" height="' + RH + '"/>' + '<text class="faint" x="4" y="' + (y + 13) + '" font-size="10.5">' + esc(cut(where(x), 15)) + '</text>' + '<text x="' + (LX - 10) + '" y="' + (y + 13) + '" font-size="11.5" text-anchor="end">' + esc(who) + '<tspan class="dim">' + esc(net.dot + label) + '</tspan></text>' + '<rect class="bar" x="' + LX + '" y="' + (y + 3) + '" width="' + bw.toFixed(1) + '" height="' + (RH - 6) + '" rx="2"/>' + '<text class="' + cls + '" x="' + (LX + bw + 6).toFixed(1) + '" y="' + (y + 13) + '" font-size="11">' + esc(cut(said, 34)) + '</text></g>'; }); svg.setAttribute('viewBox', '0 0 ' + W + ' ' + Math.ceil(H)); svg.style.maxWidth = W + 'px'; svg.innerHTML = out; Array.prototype.forEach.call(svg.querySelectorAll('.row'), wire); } if (missBox) { if (!miss.length) { missBox.innerHTML = ''; missBox.hidden = true; } else { missBox.hidden = false; missBox.innerHTML = '<h4>' + esc(r.Missing) + '</h4><p>' + esc(r.MissingNote) + '</p><ul>' + miss.map(function(x){ var f = net.finding(x.m); return '<li class="' + (f ? (net.severity(x.m) || 'NONE') + ' go' : 'NONE') + '" data-i="' + x.i + '" tabindex="0" role="button">' + esc(name(x)) + '<span class="dim">' + esc(net.dot + what(x.m)) + '</span>' + '<span class="why">' + esc(net.dot + reason(x.m)) + '</span></li>'; }).join('') + '</ul>'; Array.prototype.forEach.call(missBox.querySelectorAll('li'), wire); } } } draw(); }); </script> '@ function Get-ReportNetworkRankingFamily { <# .SYNOPSIS The families of Metrics the ranking shows one at a time, in the order of its switch. Pure. .DESCRIPTION First "all times": every Metric in milliseconds, so that a Metric added later in milliseconds is ranked without a word changed here. Then the families of the times, then each Metric of another unit alone. Each: id, name (its word on the switch), metrics, and worst - high where the largest value is the worst, low where the smallest is ($script:ReportNetworkSmallerIsWorse), abs where the furthest from 0 either way is ($script:ReportNetworkByAbsolute): the worst stands on top. #> [CmdletBinding()] [OutputType([System.Collections.Specialized.OrderedDictionary])] param() $times = @($script:MeasurementMetricUnits.Keys | Where-Object { $script:MeasurementMetricUnits[$_] -eq 'ms' }) $families = @( [ordered]@{ id = 'times'; name = Get-Text 'Report.Network.Ranking.Family.Times'; metrics = $times } [ordered]@{ id = 'ping'; name = Get-Text 'Report.Network.Ranking.Family.Ping'; metrics = @('Ping', 'PingMax') } [ordered]@{ id = 'tcp'; name = Get-Text 'Report.Network.Ranking.Family.Tcp'; metrics = @('TcpConnect') } [ordered]@{ id = 'smb'; name = Get-Text 'Report.Network.Ranking.Family.Smb'; metrics = @('Smb') } [ordered]@{ id = 'directory'; name = Get-Text 'Report.Network.Ranking.Family.Directory'; metrics = @('Ldap', 'Kerberos') } [ordered]@{ id = 'dns'; name = Get-Text 'Report.Network.Ranking.Family.Dns'; metrics = @('Dns') } [ordered]@{ id = 'tls'; name = Get-Text 'Report.Network.Ranking.Family.Tls'; metrics = @('Tls') } [ordered]@{ id = 'response'; name = Get-Text 'Report.Network.Ranking.Family.Response'; metrics = @('Response', 'FirstByte') } [ordered]@{ id = 'loss'; name = Get-Text 'Report.Network.Metric.Loss'; metrics = @('Loss') } [ordered]@{ id = 'link'; name = Get-Text 'Report.Network.Metric.LinkSpeed'; metrics = @('LinkSpeed') } [ordered]@{ id = 'wifi'; name = Get-Text 'Report.Network.Metric.WifiSignal'; metrics = @('WifiSignal') } [ordered]@{ id = 'clock'; name = Get-Text 'Report.Network.Metric.ClockOffset'; metrics = @('ClockOffset') } [ordered]@{ id = 'failures'; name = Get-Text 'Report.Network.Metric.ConnectFailures'; metrics = @('ConnectFailures') } ) # Which end is worse, from the Metrics: the worst stands on top in every family. foreach ($family in $families) { $family.worst = if (@($family.metrics | Where-Object { $_ -in $script:ReportNetworkSmallerIsWorse }).Count) { 'low' } elseif (@($family.metrics | Where-Object { $_ -in $script:ReportNetworkByAbsolute }).Count) { 'abs' } else { 'high' } $family } } function ConvertTo-ReportNetworkRankingView { <# .SYNOPSIS The ranking view: what it is drawn into, and its words. Pure. .DESCRIPTION Nothing where there is no Measurement. Every Measurement is either a bar or a line of the list below, so there is something to show wherever there is one. #> [CmdletBinding()] [OutputType([psobject])] param([Parameter(Mandatory)]$Model) if (-not @($Model.m).Count) { return } $html = ('<h3>{0}</h3><p class="netsub">{1} <span class="nethint">{2}</span></p>' + '<div class="netrankbar"><div class="netrankpick" role="group" aria-label="{3}"></div><div class="netrankscale" role="group" aria-label="{4}"></div></div>' + '<div class="netpanel"><p class="netrankshown"></p><svg class="netrank" role="img" aria-label="{0}"></svg><p class="netrankempty" hidden>{5}</p>' + '<div class="netrankmiss" hidden></div></div>') -f ` (ConvertTo-HtmlText (Get-Text 'Report.Network.Ranking.Heading')), (ConvertTo-HtmlText (Get-Text 'Report.Network.Ranking.Legend')), (ConvertTo-HtmlText (Get-Text 'Report.Network.Ranking.Hint')), (ConvertTo-HtmlText (Get-Text 'Report.Network.Ranking.Metrics')), (ConvertTo-HtmlText (Get-Text 'Report.Network.Ranking.Scale')), (ConvertTo-HtmlText (Get-Text 'Report.Network.Ranking.Empty')) New-ReportNetworkView -Id 'ranking' -Title (Get-Text 'Report.Network.Ranking.Title') -Html $html ` -Css $script:ReportNetworkRankingCss -Script $script:ReportNetworkRankingScript ` -Word ([ordered]@{ Log = Get-Text 'Report.Network.Ranking.Log' Linear = Get-Text 'Report.Network.Ranking.Linear' Shown = Get-Text 'Report.Network.Ranking.Shown' Missing = Get-Text 'Report.Network.Ranking.Missing' MissingNote = Get-Text 'Report.Network.Ranking.MissingNote' families = @(Get-ReportNetworkRankingFamily) }) } |