Private/ReportNetworkRoutes.ps1

# The path view of "Big Picture" (ReportNetwork.ps1): the Target Machine and its
# adapters, then its own subnets - each with its gateway, DNS and DHCP servers first - and
# beyond the gateway the other subnets, the VPN and the internet, 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. Stripe and edges are what the Run measured;
# the events line of a measured Server is what the logs held over the 30 days, in the colour
# of the worst Finding about those events. A Server only the Network Map's Links name is a
# node without numbers, its edge grey and dashed.
#
# The path and its numbers alone: how the Map's things hang together is shown by Apps & Services
# (ReportNetworkApps.ps1, ADR-0014).
#
# 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:ReportNetworkRoutesMetricOrder = @(
    'Ping', 'Smb', 'Ldap', 'TcpConnect', 'Kerberos', 'Dns', 'Tls', 'Response', 'FirstByte',
    'ClockOffset', 'ConnectFailures', 'Loss', 'PingMax', 'LinkSpeed', 'WifiSignal'
)

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


$script:ReportNetworkRoutesScript = @'
<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('routes', function(root, net){
  var svg = root.querySelector('svg.netroutes'), 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.routes.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; }
  // From which day to which the events of a node fell: one day where it was one.
  function days(sp){ return net.day(sp.first) === net.day(sp.last) ? net.day(sp.first) : w.routes.Span.replace('{0}', net.day(sp.first)).replace('{1}', net.day(sp.last)); }
  // A value on the card: with its port only where the node has the same Metric on another
  // port, so that the two can be told apart - SMB on 445 says nothing more by its port.
  function said(m, ported){
    if (!m.Port || !ported[m.Metric]) return net.part(m);
    return w.routes.Port.replace('{0}', net.metric(m)).replace('{1}', m.Port) + (net.missing(m) ? ': ' : ' ') + net.value(m);
  }
  // A node's card: its values, those measured in lines of a few, each not measured on a
  // line of its own, and last its events from when to when. The machine: each adapter with
  // its link speed, and the Wi-Fi signal.
  function lines(n, room){
    var out = [], cur = [], seen = {}, sp = net.span(n), ports = {}, ported = {};
    // A Server only the Map's Links name: there, and not measured.
    if (n.place === 'linked') return [{ t: w.routes.Linked, miss: true }];
    // 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]) }); });
      if (sp) out.push({ t: days(sp), dim: true });
      return out;
    }
    net.measurements(n).forEach(function(m){
      if (!m.Port) return;
      ports[m.Metric] = ports[m.Metric] || {};
      ports[m.Metric][m.Port] = 1;
      if (Object.keys(ports[m.Metric]).length > 1) ported[m.Metric] = 1;
    });
    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) : said(m, ported);
      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.routes.More.replace('{0}', more), miss: true }); }
    // A measured Server the logs know as well: how many events, from when to when, in the
    // colour of the worst Finding about them. The stripe is what the Run measured; this line
    // is what the 30 days held - a Server that answers now may have dropped every day.
    if (sp) {
      var past = net.worst(net.counted(n).map(net.severity)), held = w.routes.EventSpan.replace('{0}', net.eventCount(net.counted(n).length)).replace('{1}', days(sp));
      out.push(past ? { t: String.fromCharCode(9679) + ' ' + held, s: past } : { t: held, dim: true });
    }
    return out;
  }
  // The columns, as the model grouped them (Get-ReportNetworkGroup): on the left what the
  // machine reaches by itself - its own subnets, then the Servers without an address - and
  // on the right what lies beyond the gateway: the other subnets, whose prefix is assumed,
  // the VPN and the internet. The gateway stands first in its subnet; the way to all on
  // the right leaves from it.
  // A Server's card is wide enough for a name, its roles and a line of values; what is
  // longer goes on to a second line rather than being cut (wrap). The drawing is as wide
  // as the page gives it on a large screen and scales down on a smaller one.
  var COL = { machine: [0, 210], c3: [340, 300], c4: [820, 300] }, W = 1130;
  function head(g){
    if (g.kind === 'own') return w.group.own.replace('{0}', g.net).replace('{1}', g.adapter);
    if (g.kind === 'assumed') return w.group.assumed.replace('{0}', g.net);
    return w.zone[g.kind] || '';
  }
  var groups = (d.g || []).map(function(g){ return [head(g), g.n.map(function(id){ return byId[id]; }).filter(Boolean), g.side]; });
  var left = groups.filter(function(g){ return g[2] === 'left' && g[1].length; });
  var right = groups.filter(function(g){ return g[2] === 'right' && g[1].length; });
  var gws = d.n.filter(function(n){ return n.place === 'gateway'; });
  var place = { machine: d.n.filter(function(n){ return n.place === 'machine'; }) };
  // 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 = {}, ident = {};
  // The card's head: the name, the roles, and what else the Server is called, where anything.
  function height(n, room){
    card[n.id] = [];
    lines(n, room).forEach(function(l){ wrap(l.t, room).forEach(function(t){ card[n.id].push({ t: t, miss: l.miss, dim: l.dim, s: l.s }); }); });
    ident[n.id] = n.place === 'machine' ? [] : wrap(net.aliases(n).join(net.dot), room);
    return 38 + ident[n.id].length * 14 + card[n.id].length * 15;
  }
  // A line of a card in at most two: broken after a separator or a space where it is too
  // long, and only the second cut, so that the end of a value is seen where it fits.
  function wrap(s, room){
    s = String(s || '');
    if (!s) return [];
    if (s.length <= room) return [s];
    var at = s.lastIndexOf(' ', room);
    if (at < room / 2) at = room;
    var rest = s.slice(at).replace(/^[\s\u00b7]+/, '');
    return [s.slice(0, at).replace(/[\s\u00b7]+$/, ''), cut(rest, room)];
  }
  function stack(groups, col, top){
    var y = top;
    groups.forEach(function(g){
      heads.push([col[0], y - 10, g[0]]);
      g[1].forEach(function(n){ var h = height(n, Math.floor(col[1] / 6.6)); pos[n.id] = [col[0], y, col[1], h]; y += h + 10; });
      y += 26;
    });
    return y - 26;
  }
  // Both sides level with where their way leaves: what lies beyond the gateway starts level
  // with the gateway's card, and the machine stands level with the first cards - not halfway
  // down a long column, with every line to the top and the bottom as long.
  var top = 30 + TRUNK, leftBottom = stack(left, COL.c3, top);
  var rightTop = hub !== me && pos[hub.id] ? pos[hub.id][1] : top;
  var bottom = Math.max(leftBottom, stack(right, COL.c4, rightTop), 120);
  var mh = height(me, Math.floor(COL.machine[1] / 6.6));
  pos[me.id] = [COL.machine[0], top, 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 = net.eventCount(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 + '" data-to="' + esc(to.id) + '" 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, top = 48 + ident[id].length * 14;
    var sub = n.place === 'machine' ? w.role.Machine : (n.place === 'events' ? w.routes.Events.replace('{0}', net.eventCount(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.findText(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>' +
      ident[id].map(function(t, i){ return '<text class="dim" x="' + (x + 12) + '" y="' + (y + 45 + i * 14) + '" font-size="11">' + esc(t) + '</text>'; }).join('') +
      card[id].map(function(l, i){ return '<text' + (l.miss ? ' class="miss"' : (l.s ? ' class="sevt ' + l.s + '"' : (l.dim ? ' class="dim"' : ''))) + ' x="' + (x + 12) + '" y="' + (y + top + 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-ReportNetworkRoutesView {
    <#
    .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="netroutes" role="img" aria-label="{0}"></svg></div>' -f `
        (ConvertTo-HtmlText (Get-Text 'Report.Network.Routes.Heading')), (ConvertTo-HtmlText (Get-Text 'Report.Network.Routes.Legend')),
        (ConvertTo-HtmlText (Get-Text 'Report.Network.Routes.Hint'))

    New-ReportNetworkView -Id 'routes' -Title (Get-Text 'Report.Network.Routes.Title') -Html $html `
        -Css $script:ReportNetworkRoutesCss -Script $script:ReportNetworkRoutesScript `
        -Word ([ordered]@{
            More = Get-Text 'Report.Network.Routes.More'; Events = Get-Text 'Report.Network.Routes.Events'
            Port = Get-Text 'Report.Network.Routes.Port'; Span = Get-Text 'Report.Network.Routes.Span'; EventSpan = Get-Text 'Report.Network.Routes.EventSpan'
            Linked = Get-Text 'Report.Network.Routes.Linked'
            First = $script:ReportNetworkRoutesMetricOrder
        })
}