Private/ReportNetworkPath.ps1

# The path view of "Netzwerk im Bild" (ReportNetwork.ps1): the Target Machine and its
# adapters, then the LAN - the gateway at its top with the DNS and DHCP servers - and the VPN
# and the internet beyond the gateway,
# each Server one node with its values. An edge carries the value the way to its node is
# best read by and is coloured by the Severity of the Finding that value belongs to, by its
# part about the Server; where no Finding judges it, it is grey and dashed. The stripe of a
# node shows the worst Severity of all its values.
#
# The geometry is the script's and is looked at, not tested: a Report rendered in a
# headless browser from fixture Measurements.

# The Metric an edge is labelled with: the first of these a Server has a number for, the
# way there before what lies at its end. Every Metric, so that each edge has a label.
$script:ReportNetworkPathMetricOrder = @(
    'Ping', 'Smb', 'Ldap', 'TcpConnect', 'Kerberos', 'Dns', 'Tls', 'Response', 'FirstByte',
    'ClockOffset', 'ConnectFailures', 'Loss', 'PingMax', 'LinkSpeed', 'WifiSignal'
)

$script:ReportNetworkPathCss = @'
<style>
/* Netzwerk im Bild: the path (ReportNetworkPath.ps1). */
.netpath .node rect.box{fill:var(--panel2);stroke:var(--line);stroke-width:1}
.netpath .node rect.stripe{fill:var(--c)}
.netpath .node.NONE rect.stripe{fill:var(--line)}
.netpath .node.go{cursor:pointer}
.netpath .node:hover rect.box,.netpath .node:focus rect.box,.netpath .node.sel rect.box{stroke:var(--accent);stroke-width:1.5}
.netpath .node:focus{outline:none}
.netpath .edge{fill:none;stroke:var(--c);stroke-width:1.6;opacity:.85}
.netpath .edge.NONE{stroke:var(--faint);stroke-dasharray:4 3}
.netpath .edge.dashed{stroke-dasharray:6 4}
.netpath .edge.hl{stroke-width:3;opacity:1}
@media print{
  .netpath .node rect.box{fill:#fff}
}
</style>
'@


$script:ReportNetworkPathScript = @'
<script>
// The path: columns from left to right - the machine, the LAN with the gateway, DNS and
// DHCP at its top, the VPN and the internet - and a curve to each Server, labelled with the value the way
// there is best read by. Widths are counted in characters and not measured: the chapter
// may be hidden when it is drawn.
if (window.GutcheckNet) GutcheckNet.view('path', function(root, net){
  var svg = root.querySelector('svg.netpath'), d = net.data, w = net.words, esc = net.esc;
  if (!svg) return;
  var byId = {};
  d.n.forEach(function(n){ byId[n.id] = n; });
  // The value an edge is labelled with: the first measured, in this order, with a Finding
  // before one without; where nothing was measured, what became of it.
  var first = w.path.First;
  function primary(n){
    var list = net.measurements(n), k, i, found = null;
    for (k = 0; k < first.length; k++) {
      var same = list.filter(function(m){ return m.Metric === first[k] && !net.missing(m); });
      for (i = 0; i < same.length; i++) { if (net.finding(same[i])) return same[i]; }
      if (!found && same.length) found = same[0];
    }
    return found || list[0] || null;
  }
  function cut(s, n){ s = String(s); return s.length > n ? s.slice(0, n - 1) + String.fromCharCode(8230) : s; }
  // A node's card: its values, those measured in lines of a few, each not measured on a
  // line of its own. The machine: each adapter with its link speed, and the Wi-Fi signal.
  function lines(n, room){
    var out = [], cur = [], seen = {};
    // A Server only the events know: how many, and of which kinds the most.
    if (n.place === 'events') {
      var kinds = {}, order = [], all = net.counted(n);
      all.forEach(function(e){ var k = e.Detail || e.Mark; if (!kinds[k]) { kinds[k] = 0; order.push(k); } kinds[k]++; });
      order.sort(function(a, b){ return kinds[b] - kinds[a]; });
      order.slice(0, 3).forEach(function(k){ out.push({ t: k + ': ' + w.detail.Times.replace('{0}', kinds[k]) }); });
      return out;
    }
    net.measurements(n).forEach(function(m){
      var key = m.Metric + '|' + (m.Port || '') + '|' + (n.place === 'machine' ? m.Subject : '');
      if (seen[key]) return;
      seen[key] = 1;
      var text = (n.place === 'machine' && m.Subject && m.Metric === 'LinkSpeed') ? m.Subject + (net.missing(m) ? ': ' : net.dot) + net.value(m) : net.part(m);
      if (net.missing(m)) { if (cur.length) { out.push({ t: cur.join(net.dot) }); cur = []; } out.push({ t: text, miss: true }); return; }
      if (cur.length && (cur.join(net.dot) + net.dot + text).length > room) { out.push({ t: cur.join(net.dot) }); cur = []; }
      cur.push(text);
    });
    if (cur.length) out.push({ t: cur.join(net.dot) });
    if (out.length > 5) { var more = out.length - 4; out = out.slice(0, 4); out.push({ t: w.path.More.replace('{0}', more), miss: true }); }
    return out;
  }
  // The gateway is part of the LAN, not a station before it: it stands at the top of the
  // LAN column with the DNS and DHCP servers, the network's services. The machine is
  // connected to the LAN; the way to the VPN and the internet leaves from the gateway.
  var COL = { machine: [0, 200], c3: [340, 260], c4: [780, 260] }, W = 1050;
  var place = {};
  d.n.forEach(function(n){ (place[n.place] = place[n.place] || []).push(n); });
  var services = ['DnsServer', 'DhcpServer'];
  function serves(n){ return services.indexOf(n.roles[0]) >= 0; }
  var lan = (place.lan || []).filter(function(n){ return !serves(n); });
  var unknown = (place.none || []).filter(function(n){ return !serves(n); });
  var gws = place.gateway || [];
  var service = gws.concat((place.lan || []).filter(serves), (place.none || []).filter(serves));
  var left = [['services', service], ['lan', lan], ['none', unknown], ['events', place.events]].filter(function(g){ return g[1] && g[1].length; });
  var right = [['vpn', place.vpn], ['wan', place.wan]].filter(function(g){ return g[1] && g[1].length; });
  // With nothing on the LAN side the internet stands next to the machine.
  if (!left.length) { left = right; right = []; }
  if (!right.length) W = COL.c3[0] + COL.c3[1] + 10;
  var me = (place.machine || [])[0] || { id: 'machine', place: 'machine', m: [], e: [], names: [], addresses: [], roles: [], s: '' };
  if (!byId[me.id]) byId[me.id] = me;
  // The way to the VPN and the internet: from the gateway, which stands in the LAN column
  // and so has nothing between it and the right one; without a gateway, from the machine,
  // over the top of the LAN column, each edge on a lane of its own in the order of the
  // nodes it leads to, so that it crosses no card.
  var hub = gws[0] || me, far = [];
  right.forEach(function(g){ far = far.concat(g[1]); });
  var TRUNK = (hub === me && left !== right && far.length) ? 14 + far.length * 4 : 0;
  var pos = {}, heads = [], card = {};
  function height(n, room){ card[n.id] = lines(n, room); return 38 + card[n.id].length * 15; }
  function stack(groups, col){
    var y = 30 + TRUNK;
    groups.forEach(function(g){
      heads.push([col[0], y - 10, w.zone[g[0]]]);
      g[1].forEach(function(n){ var h = height(n, Math.floor(col[1] / 6.4)); pos[n.id] = [col[0], y, col[1], h]; y += h + 10; });
      y += 26;
    });
    return y - 26;
  }
  var bottom = Math.max(stack(left, COL.c3), stack(right, COL.c4), 120);
  var mh = height(me, 32);
  pos[me.id] = [COL.machine[0], Math.max(10, bottom / 2 - mh / 2), COL.machine[1], mh];
  var H = Math.max(bottom, pos[me.id][1] + mh) + 12;
 
  var edges = [], labels = [];
  function edge(from, to){
    var a = pos[from.id], b = pos[to.id];
    if (!a || !b) return;
    var x1 = a[0] + a[2], y1 = a[1] + Math.min(a[3] / 2, 22), x2 = b[0], y2 = b[1] + Math.min(b[3] / 2, 22), dx = Math.max(30, (x2 - x1) * 0.5);
    // The edge and its label are one: the value it is labelled with, in the colour of that
    // value's Finding, by the part about this Server (net.severity) - so that the edge and
    // the Finding never disagree; the node's stripe shows the worst of all its values. A
    // Server only the events know is never measured: dashed, in the colour of its events.
    var m = primary(to), s = to.place === 'events' ? to.s : (m ? net.severity(m) : '');
    var lane = TRUNK ? far.indexOf(to) : -1, d;
    if (lane >= 0) {
      var yt = 8 + lane * 4, xa = COL.c3[0] - 26, xb = COL.c3[0] + COL.c3[1] + 26;
      d = 'M' + x1 + ',' + y1 + ' C' + (x1 + 34) + ',' + y1 + ' ' + (xa - 34) + ',' + yt + ' ' + xa + ',' + yt + ' L' + xb + ',' + yt +
          ' C' + (xb + 44) + ',' + yt + ' ' + (x2 - 44) + ',' + y2 + ' ' + x2 + ',' + y2;
    }
    else d = 'M' + x1 + ',' + y1 + ' C' + (x1 + dx) + ',' + y1 + ' ' + (x2 - dx) + ',' + y2 + ' ' + x2 + ',' + y2;
    edges.push('<path class="edge ' + (s || 'NONE') + (to.place === 'events' ? ' dashed' : '') + '" data-from="' + esc(from.id) + '" data-to="' + esc(to.id) + '" d="' + d + '"/>');
    var text, cls;
    if (to.place === 'events') text = w.path.EventCount.replace('{0}', net.counted(to).length);
    else if (!m) return;
    else text = net.part(m);
    cls = 'halo ' + (m && net.missing(m) ? 'miss' : (s ? 'sevt ' + s : 'faint'));
    labels.push('<text class="' + cls + '" x="' + (x2 - 6) + '" y="' + (y2 - 5) + '" text-anchor="end" font-size="11">' + esc(cut(text, 22)) + '</text>');
  }
  // The way: from the machine to everything on the LAN, from the gateway to what lies
  // beyond it.
  left.forEach(function(g){ g[1].forEach(function(n){ edge(me, n); }); });
  if (left !== right) right.forEach(function(g){ g[1].forEach(function(n){ edge(hub, n); }); });
 
  var out = heads.map(function(h){ return '<text class="zonehead" x="' + h[0] + '" y="' + h[1] + '">' + esc(String(h[2]).toUpperCase()) + '</text>'; }).join('');
  out += edges.join('') + labels.join('');
  Object.keys(pos).forEach(function(id){
    var n = byId[id], p = pos[id], x = p[0], y = p[1], wd = p[2], h = p[3], room = Math.floor(wd / 6.6);
    var s = n.s || 'NONE', go = net.findings(n).length > 0;
    var sub = n.place === 'machine' ? w.role.Machine : (n.place === 'events' ? w.path.Events.replace('{0}', net.counted(n).length) : net.roles(n).join(', '));
    out += '<g class="node ' + s + (go ? ' go' : '') + '" data-id="' + esc(id) + '" tabindex="0" role="button" aria-label="' + esc(net.label(n)) + '">' +
      '<rect class="box" x="' + x + '" y="' + y + '" width="' + wd + '" height="' + h + '" rx="7"/>' +
      '<rect class="stripe" x="' + x + '" y="' + y + '" width="4" height="' + h + '" rx="2"/>' +
      '<text x="' + (x + 12) + '" y="' + (y + 17) + '" font-size="13" font-weight="600">' + esc(cut(net.label(n), Math.floor(wd / 7.6))) + '</text>' +
      '<text class="dim" x="' + (x + 12) + '" y="' + (y + 31) + '" font-size="11">' + esc(cut(sub, room)) + '</text>' +
      card[id].map(function(l, i){ return '<text' + (l.miss ? ' class="miss"' : '') + ' x="' + (x + 12) + '" y="' + (y + 48 + i * 15) + '" font-size="11.5">' + esc(cut(l.t, room)) + '</text>'; }).join('') +
      '</g>';
  });
  svg.setAttribute('viewBox', '0 0 ' + W + ' ' + Math.ceil(H));
  svg.style.maxWidth = W + 'px';
  svg.innerHTML = out;
 
  // On the pointer or the keyboard: the node's values and Findings beside it, and the way
  // to it drawn bold. A click, or Enter, leads to its worst Finding.
  function mark(id){
    Array.prototype.forEach.call(svg.querySelectorAll('.node'), function(g){ g.classList.toggle('sel', g.getAttribute('data-id') === id); });
    Array.prototype.forEach.call(svg.querySelectorAll('.edge'), function(e){ e.classList.toggle('hl', !!id && (e.getAttribute('data-to') === id || e.getAttribute('data-from') === id)); });
  }
  Array.prototype.forEach.call(svg.querySelectorAll('.node'), function(g){
    var n = byId[g.getAttribute('data-id')];
    function on(){ mark(n.id); net.showDetail(g, n); }
    function off(){ mark(''); net.hideDetail(); }
    function go(){ var f = net.findings(n)[0]; if (f) net.jump(f.a); }
    g.addEventListener('mouseenter', on); g.addEventListener('focus', on);
    g.addEventListener('mouseleave', off); g.addEventListener('blur', off);
    g.addEventListener('click', go);
    g.addEventListener('keydown', function(e){ if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); go(); } });
  });
});
</script>
'@


function ConvertTo-ReportNetworkPathView {
    <#
    .SYNOPSIS
        The path view: what it is drawn into, and its words. Pure.
    .DESCRIPTION
        Nothing where nothing was measured. The script draws from the nodes of the model.
    #>

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

    # Drawn where something was measured; a Server only the events know stands beside those.
    if (-not @($Model.m).Count) { return }

    $html = '<h3>{0}</h3><p class="netsub">{1} <span class="nethint">{2}</span></p><div class="netpanel"><svg class="netpath" role="img" aria-label="{0}"></svg></div>' -f `
        (ConvertTo-HtmlText (Get-Text 'Report.Network.Path.Heading')), (ConvertTo-HtmlText (Get-Text 'Report.Network.Path.Legend')),
        (ConvertTo-HtmlText (Get-Text 'Report.Network.Path.Hint'))

    New-ReportNetworkView -Id 'path' -Title (Get-Text 'Report.Network.Path.Title') -Html $html `
        -Css $script:ReportNetworkPathCss -Script $script:ReportNetworkPathScript `
        -Word ([ordered]@{
            More = Get-Text 'Report.Network.Path.More'; Events = Get-Text 'Report.Network.Path.Events'; EventCount = Get-Text 'Report.Network.Path.EventCount'
            First = $script:ReportNetworkPathMetricOrder
        })
}