Private/ReportNetworkWaterfall.ps1

# The waterfall view of "Netzwerk im Bild" (ReportNetwork.ps1): one card per website and per
# HTTPS service, each step of it as a bar on the card's own time axis - name resolution,
# connection, TLS, the wait for the first byte, the answer - so that a slow page shows which
# host and which step it waited for.
#
# A card is a Group (ADR-0009): a site with every host it needed, or one HTTPS endpoint. A
# bar runs from where its step began (StartMs) for as long as it took, in the colour of the
# Finding its value belongs to, grey and dashed where none judges it. A name answered from a
# cache is a mark and says so, not a 0 ms bar; a step that failed is a bar as long as it took
# to fail with its error beside it, or, where its end is not known, a dashed mark open to the
# right; a step not measured has no bar and says so.
#
# Which Measurements form a card is decided here and handed to the script, so that a test can
# say it; the geometry is the script's and is looked at in a rendered Report, not tested.

# The Roles a card is made of.
$script:ReportNetworkWaterfallRoles = @('HttpsService', 'Website', 'WebsiteHost')

$script:ReportNetworkWaterfallCss = @'
<style>
/* Netzwerk im Bild: the waterfall (ReportNetworkWaterfall.ps1). */
.netwfcard{margin:0 0 12px;padding:10px 12px}
.netwfcard header{display:flex;gap:10px;align-items:center;flex-wrap:wrap}
.netwfcard h4{margin:0;font:600 14px var(--sans);overflow-wrap:anywhere}
.netwfsub{margin:4px 0 6px;color:var(--dim);font-size:12.5px;overflow-wrap:anywhere}
.netwfsub b{font-weight:600;color:var(--text)}
.netwf .grid{stroke:var(--line);stroke-width:1}
.netwf .hit{fill:transparent}
.netwf .row:hover .hit,.netwf .row:focus .hit,.netwf .row.sel .hit{fill:var(--panel2)}
.netwf .row:focus{outline:none}
.netwf .row.go{cursor:pointer}
.netwf .bar{fill:var(--c)}
.netwf .row.NONE .bar{fill:var(--faint);fill-opacity:.3;stroke:var(--faint);stroke-width:1;stroke-dasharray:4 3}
.netwf .span{fill:var(--c);fill-opacity:.55}
.netwf .row.NONE .span{fill:var(--faint)}
.netwf .open{fill:none;stroke:var(--c);stroke-width:1.5;stroke-dasharray:5 3}
.netwf .host{font-weight:600}
.netwf .err{fill:var(--c);font-style:italic}
@media print{
  .netwfcard{break-inside:avoid-page}
  .netwf .hit{fill:none!important}
}
</style>
'@


$script:ReportNetworkWaterfallScript = @'
<script>
// The waterfall: one card per Group, each step a bar from where it began for as long as it
// took. Widths are counted in characters and not measured: the chapter may be hidden when it
// is drawn.
if (window.GutcheckNet) GutcheckNet.view('waterfall', function(root, net){
  var box = root.querySelector('.netwfcards'), d = net.data, v = net.words.waterfall, esc = net.esc;
  if (!box || !v) return;
  var ell = String.fromCharCode(8230), diamond = String.fromCharCode(9670);
  var nodeOf = {};
  d.n.forEach(function(n){ n.m.forEach(function(i){ nodeOf[i] = n; }); });
  function cut(s, n){ s = String(s); return s.length > n ? s.slice(0, Math.max(1, n - 1)) + ell : s; }
  function has(x){ return x != null && x !== ''; }
 
  // The rows of a card, top to bottom. A service: its four steps. A site: the page itself,
  // then each host it needed with the span of its requests, and under it the host's steps.
  function rowsOf(card){
    var all = card.m.map(function(i){ return { m: d.m[i], i: i }; }), rows = [];
    function of(list, metric){ return list.filter(function(x){ return x.m.Metric === metric; }); }
    if (card.kind === 'service') {
      ['Dns', 'TcpConnect', 'Tls', 'Response'].forEach(function(k){
        of(all, k).forEach(function(x){ rows.push({ x: x, label: v.step[k], indent: 0 }); });
      });
      return rows;
    }
    var page = all.filter(function(x){ return x.m.Role === 'Website'; });
    of(page, 'Response').forEach(function(x){ rows.push({ x: x, label: v.Page, indent: 0, head: true }); });
    of(page, 'FirstByte').forEach(function(x){ rows.push({ x: x, label: v.PageFirstByte, indent: 0 }); });
    var hosts = [];
    all.forEach(function(x){ if (x.m.Role === 'WebsiteHost' && hosts.indexOf(x.m.Subject) < 0) hosts.push(x.m.Subject); });
    hosts.forEach(function(h){
      var mine = all.filter(function(x){ return x.m.Role === 'WebsiteHost' && x.m.Subject === h; });
      var span = of(mine, 'Response')[0];
      rows.push({ x: span || { m: mine[0].m, i: mine[0].i }, label: h, indent: 0, head: true, span: !!span, host: true });
      ['Dns', 'TcpConnect', 'Tls', 'FirstByte'].forEach(function(k){
        of(mine, k).forEach(function(x){ rows.push({ x: x, label: v.step[k], indent: 1 }); });
      });
    });
    return rows;
  }
 
  // The end of the card's time axis: the latest end of a step, or start of one without an end.
  function axis(rows){
    var max = 0;
    rows.forEach(function(r){
      var m = r.x.m;
      if (!has(m.StartMs)) return;
      max = Math.max(max, m.StartMs + (has(m.Value) ? m.Value : 0));
    });
    max = Math.max(max, 1);
    var step = [1, 2, 5, 10, 20, 50, 100, 200, 500, 1000, 2000, 5000, 10000, 20000, 50000, 100000].filter(function(s){ return max / s <= 8; })[0] || 200000;
    return { hi: Math.ceil(max / step) * step, step: step };
  }
 
  // On the pointer or the keyboard: the values and Findings of the row's host beside it.
  // A click, or Enter, leads to the Finding the value belongs to.
  // The whole address of the card stands in the detail, as the card's heading shows it short.
  function wire(el){
    var i = Number(el.getAttribute('data-i')), m = d.m[i], c = v.cards[Number(el.getAttribute('data-c'))] || {}, at = el.querySelector('.bar, .span, .open, .cache') || el;
    function on(){ el.classList.add('sel'); if (nodeOf[i]) net.showDetail(at, nodeOf[i], [c.full]); }
    function off(){ el.classList.remove('sel'); net.hideDetail(); }
    function go(){ var f = net.finding(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 card(c, ci){
    var rows = rowsOf(c), ax = axis(rows);
    var W = 1100, LX = 230, VX = W - 330, PW = VX - LX - 14, RH = 19, TOP = 24;
    function sx(t){ return LX + Math.max(0, t) / ax.hi * PW; }
    var H = TOP + rows.length * RH + 6, out = '';
    for (var t = 0; t <= ax.hi + 0.0001; t += ax.step) {
      var gx = sx(t).toFixed(1);
      out += '<line class="grid" x1="' + gx + '" y1="' + (TOP - 4) + '" x2="' + gx + '" y2="' + (H - 4) + '"/>' +
        '<text class="faint" x="' + gx + '" y="' + (TOP - 9) + '" font-size="10" text-anchor="middle">' + esc(v.Ms.replace('{0}', net.num(t))) + '</text>';
    }
    rows.forEach(function(r, k){
      var m = r.x.m, f = net.finding(m), s = net.severity(m) || 'NONE', y = TOP + k * RH, mid = y + RH / 2;
      var start = has(m.StartMs) ? m.StartMs : null, said, cls = 'dim', mark = '';
      if (r.host && !r.span) { said = ''; }
      else {
        said = net.value(m);
        if (m.State === 'Measured' && has(m.Value) && start != null) {
          var bw = Math.max(2, sx(start + m.Value) - sx(start));
          mark = '<rect class="' + (r.span ? 'span' : 'bar') + '" x="' + sx(start).toFixed(1) + '" y="' + (r.span ? mid - 2 : y + 4) + '" width="' + bw.toFixed(1) + '" height="' + (r.span ? 4 : RH - 8) + '" rx="1.5"/>';
          if (r.span) said = v.step.Requests + ': ' + said;
        }
        else if (m.State === 'Cached') {
          cls = 'miss';
          if (start != null) mark = '<text class="cache sevt" x="' + sx(start).toFixed(1) + '" y="' + (y + 14) + '" font-size="12" text-anchor="middle">' + diamond + '</text>';
        }
        else if (m.State === 'Failed' && has(m.Value) && start != null) {
          cls = 'err';
          var fw = Math.max(2, sx(start + m.Value) - sx(start));
          mark = '<rect class="bar" x="' + sx(start).toFixed(1) + '" y="' + (y + 4) + '" width="' + fw.toFixed(1) + '" height="' + (RH - 8) + '" rx="1.5"/>';
        }
        else if (m.State === 'Failed' || m.State === 'Blocked') {
          cls = 'err';
          if (start != null) mark = '<rect class="open" x="' + sx(start).toFixed(1) + '" y="' + (y + 4) + '" width="' + (LX + PW - sx(start)).toFixed(1) + '" height="' + (RH - 8) + '"/>';
        }
        else if (m.State !== 'Measured') { cls = 'miss'; }
      }
      var label = cut(r.label, r.indent ? 34 : 38), x0 = 8 + r.indent * 16;
      out += '<g class="row ' + s + (f ? ' go' : '') + '" data-i="' + r.x.i + '" data-c="' + ci + '" tabindex="0" role="button" aria-label="' + esc(r.label + (said ? ': ' + said : '')) + '">' +
        '<rect class="hit" x="0" y="' + y + '" width="' + W + '" height="' + RH + '"/>' +
        '<text class="' + (r.head ? 'host' : 'dim') + '" x="' + x0 + '" y="' + (y + 13) + '" font-size="' + (r.head ? 12 : 11.5) + '">' + esc(label) + '</text>' +
        mark + (said ? '<text class="' + cls + '" x="' + VX + '" y="' + (y + 13) + '" font-size="11">' + esc(cut(said, 56)) + '</text>' : '') + '</g>';
    });
    // The worst Finding of the card, and what it says, above the bars.
    var list = [], seen = {};
    c.m.forEach(function(i){ var f = net.finding(d.m[i]); if (f && !seen[d.m[i].Finding]) { seen[d.m[i].Finding] = 1; list.push(f); } });
    var worst = net.worst(list.map(function(f){ return f.s; }));
    var first = list.filter(function(f){ return f.s === worst; })[0];
    return '<div class="netpanel netwfcard"><header class="' + (worst || 'NONE') + '">' + (worst ? '<span class="badge">' + esc(worst) + '</span>' : '') +
      '<h4 title="' + esc(c.full) + '">' + esc(c.title) + '</h4></header>' +
      (first ? '<p class="netwfsub"><b>' + esc(first.t) + '</b>' + (first.v ? net.dot + esc(first.v) : '') + '</p>' : '<p class="netwfsub">' + esc(v.NoFinding) + '</p>') +
      '<svg class="netwf" role="img" aria-label="' + esc(c.title) + '" viewBox="0 0 ' + W + ' ' + Math.ceil(H) + '" style="max-width:' + W + 'px">' + out + '</svg></div>';
  }
 
  box.innerHTML = v.cards.map(function(c, ci){ return card(c, ci); }).join('');
  Array.prototype.forEach.call(box.querySelectorAll('.row'), wire);
});
</script>
'@


function Get-ReportNetworkUrlTitle {
    <#
    .SYNOPSIS
        An address as a card is named by: host and path, without the scheme, the query and
        the fragment, and cut with an ellipsis where it is longer than -Max. Pure.
    .DESCRIPTION
        Where it is cut, the host stays whole and the end of the path is kept, from a slash
        where one is near: "login.contoso.example/.../openid-configuration" says more than
        its first sixty characters. A host alone where the path is only "/". What is no
        absolute address - a subject an older module named a card by - is kept as given,
        cut at its end.
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param([AllowNull()][AllowEmptyString()][string]$Url, [int]$Max = 60)

    $ellipsis = [string][char]0x2026
    $text = "$Url".Trim()
    $parsed = $null
    if (-not [Uri]::TryCreate($text, [UriKind]::Absolute, [ref]$parsed) -or -not $parsed.Host) {
        if ($text.Length -le $Max) { return $text }
        return $text.Substring(0, $Max - 1) + $ellipsis
    }
    $hostName = $parsed.Host
    $path = $parsed.AbsolutePath
    $said = if ($path -eq '/') { $hostName } else { $hostName + $path }
    if ($said.Length -le $Max) { return $said }

    $room = $Max - $hostName.Length - 1
    if ($room -lt 12) { return $said.Substring(0, $Max - 1) + $ellipsis }
    $tail = $said.Substring($said.Length - $room)
    $slash = $tail.IndexOf('/')
    if ($slash -gt 0 -and $tail.Length - $slash -ge 12) { $tail = $tail.Substring($slash) }
    $hostName + $ellipsis + $tail
}

function Get-ReportNetworkWaterfallCard {
    <#
    .SYNOPSIS
        The cards of the waterfall: one per Group of the Measurements of websites and HTTPS
        services, in the order they were handed in. Pure.
    .DESCRIPTION
        Each: group (what names it), kind (site where it holds a website's Measurements,
        service otherwise), title (its address shortened, Get-ReportNetworkUrlTitle), full
        (its whole address, for the title attribute and the detail beside its rows), and m -
        the indexes of its Measurements in the model, in the order they were handed in. A
        Measurement without a Group, as an older module made it, joins the card of its
        Subject.
    #>

    [CmdletBinding()]
    [OutputType([System.Collections.Specialized.OrderedDictionary])]
    param([Parameter(Mandatory)]$Model)

    $cards = [ordered]@{}
    $rows = @($Model.m)
    for ($i = 0; $i -lt $rows.Count; $i++) {
        $row = $rows[$i]
        if ("$($row.Role)" -notin $script:ReportNetworkWaterfallRoles) { continue }
        $group = "$($row.Group)".Trim()
        if (-not $group) { $group = "$($row.Subject)".Trim() }
        if (-not $cards.Contains($group)) { $cards[$group] = [ordered]@{ group = $group; kind = 'service'; title = ''; full = $group; m = New-Object System.Collections.Generic.List[int] } }
        $cards[$group].m.Add($i)
        if ("$($row.Role)" -in 'Website', 'WebsiteHost') { $cards[$group].kind = 'site' }
    }
    foreach ($card in $cards.Values) {
        $said = if ($card.kind -eq 'site') { Get-Text 'Report.Network.Waterfall.Site' } else { Get-Text 'Report.Network.Waterfall.Service' }
        $card.title = $said -f (Get-ReportNetworkUrlTitle -Url $card.group)
        $card.m = [int[]]$card.m.ToArray()
        $card
    }
}

function ConvertTo-ReportNetworkWaterfallView {
    <#
    .SYNOPSIS
        The waterfall view: what it is drawn into, its cards and its words. Pure.
    .DESCRIPTION
        Nothing where the Run measured no website and no HTTPS service.
    #>

    [CmdletBinding()]
    [OutputType([psobject])]
    param([Parameter(Mandatory)]$Model)

    $cards = @(Get-ReportNetworkWaterfallCard -Model $Model)
    if (-not $cards.Count) { return }

    $html = '<h3>{0}</h3><p class="netsub">{1} <span class="nethint">{2}</span></p><div class="netwfcards"></div>' -f `
        (ConvertTo-HtmlText (Get-Text 'Report.Network.Waterfall.Heading')), (ConvertTo-HtmlText (Get-Text 'Report.Network.Waterfall.Legend')),
        (ConvertTo-HtmlText (Get-Text 'Report.Network.Waterfall.Hint'))

    New-ReportNetworkView -Id 'waterfall' -Title (Get-Text 'Report.Network.Waterfall.Title') -Html $html `
        -Css $script:ReportNetworkWaterfallCss -Script $script:ReportNetworkWaterfallScript `
        -Word ([ordered]@{
            Page          = Get-Text 'Report.Network.Waterfall.Page'
            PageFirstByte = Get-Text 'Report.Network.Waterfall.PageFirstByte'
            Ms            = Get-Text 'Report.Network.Waterfall.Ms'
            NoFinding     = Get-Text 'Report.Network.Waterfall.NoFinding'
            step          = [ordered]@{
                Dns        = Get-Text 'Report.Network.Waterfall.Step.Dns'
                TcpConnect = Get-Text 'Report.Network.Waterfall.Step.TcpConnect'
                Tls        = Get-Text 'Report.Network.Waterfall.Step.Tls'
                FirstByte  = Get-Text 'Report.Network.Waterfall.Step.FirstByte'
                Response   = Get-Text 'Report.Network.Waterfall.Step.Response'
                Requests   = Get-Text 'Report.Network.Waterfall.Step.Requests'
            }
            cards         = $cards
        })
}