Private/ReportNetworkWaterfall.ps1
|
# The waterfall view of "Big Picture" (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> /* Big Picture: 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 }); }); // Each host as the card names it, however the page spelled it: which are one, the model decided. (card.hosts || []).forEach(function(h){ var mine = h.m.map(function(i){ return { m: d.m[i], i: i }; }); if (!mine.length) return; var span = of(mine, 'Response')[0]; rows.push({ x: span || { m: mine[0].m, i: mine[0].i }, label: h.name, 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, and hosts - each host a site needed, once for each Server of the Network Map it is, by the key the model gives its Measurements: "CDN.contoso.example" and "cdn.contoso.example" are one, and CDN is the one the Map put it with, as on Routes. Named as it was first written, with m, the indexes of its Measurements, in the order the hosts came. 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] hosts = New-Object System.Collections.Generic.List[object] } } $card = $cards[$group] $card.m.Add($i) if ("$($row.Role)" -in 'Website', 'WebsiteHost') { $card.kind = 'site' } if ("$($row.Role)" -ne 'WebsiteHost') { continue } $name = "$($row.Subject)".Trim() $key = "$($row.key)" if (-not $key) { $key = $name } $known = @($card.hosts | Where-Object { $_.key -ceq $key }) | Select-Object -First 1 if (-not $known) { $known = [ordered]@{ key = $key; name = $name; m = New-Object System.Collections.Generic.List[int] }; $card.hosts.Add($known) } $known.m.Add($i) } 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() foreach ($h in $card.hosts) { $h.m = [int[]]$h.m.ToArray() } # ToArray and not @(), for the reason the model's m is. $card.hosts = $card.hosts.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 }) } |