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