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)
        })
}