Private/ReportNetworkTimeline.ps1
|
# The Timeline view of "Big Picture" (ReportNetwork.ps1): the 30 days before the Run, # drawn from the Timed Events (ADR-0010). One lane per Server - merged as the nodes of the # path are, so that one Server is one lane however Windows logged it -, the machine's own # lane, the crashes, hangs, starts and sign-ins and what happened to the User's domain # account as markers above them, and the logs below, each with how far back it reaches. # Under it a day-by-hour strip with the totals per day and per hour, for every lane or for # the one whose name was clicked; on the pointer, what a # cell, a day or an hour holds, summed up by lane and kind, however many events it is. # # An hour with at least one event is one stroke, in the colour of the Finding that judged # that kind of event, grey where none does: the timeline raises no alarm the Findings do # not. Where none of the logs a lane's events come from reaches back over the 30 days, the # stretch before the earliest of them is hatched: an empty stretch there is not a quiet one. # From when on is the model's (Get-ReportNetworkCoverage), not the script's. # # The geometry is the script's and is looked at, not tested: a Report rendered in a # headless browser from fixture Timed Events. $script:ReportNetworkTimelineCss = @' <style> /* Big Picture: the Timeline (ReportNetworkTimeline.ps1). */ .nettimelegend{display:flex;flex-wrap:wrap;gap:6px 16px;align-items:center;color:var(--dim);font-size:12.5px;margin:0 0 8px} .nettimelegend span{display:inline-flex;gap:6px;align-items:center} .nettime .grid{stroke:var(--line);stroke-width:1} .nettime .wkend{fill:var(--panel2)} .nettime .run{stroke:var(--accent);stroke-width:1.5} .nettime .head{font:600 10.5px var(--mono);letter-spacing:.1em;fill:var(--dim)} .nettime .lab{cursor:pointer} .nettime .lab:hover text,.nettime .lab.sel text{fill:var(--accent)} .nettime .lab.sel text{font-weight:600} .nettime .lab rect{fill:transparent} .nettime .ev{fill:var(--c)} .nettime .ev.NONE{fill:var(--faint);fill-opacity:.75} /* An hour in which the Service only refused the identity it was given: hollow, in the colour of its Finding, apart from one that could not be reached; an hour of both, filled under a hollow cap, so that a refusal never hides behind a failure. */ .nettime .ev.den{fill:none;stroke:var(--c);stroke-width:1.5} .nettime .NONE .ev.den{stroke:var(--faint)} .nettime .mk{fill:var(--c);stroke:var(--panel);stroke-width:1} .nettime .mk.NONE{fill:var(--faint)} .nettime .mk.ring{fill:none;stroke:var(--c);stroke-width:2} .nettime .mk.ring.NONE{stroke:var(--faint)} .nettime .hit{cursor:default} .nettime .hit.go{cursor:pointer} .nettime .hit:focus{outline:none} .nettime .hit:hover .ev,.nettime .hit:focus .ev,.nettime .hit.sel .ev{stroke:var(--text);stroke-width:1} .nettime .gap{fill:var(--faint);fill-opacity:.12;stroke:var(--faint);stroke-opacity:.5;stroke-dasharray:3 3} .nettime .hatch{stroke:var(--faint);stroke-opacity:.35;stroke-width:1} .nettime .cov{stroke:var(--dim);stroke-width:3} .nettimeout{color:var(--faint);font-size:12px;margin:4px 2px 0} .netheatbar{display:flex;flex-wrap:wrap;gap:6px 14px;align-items:center;margin:14px 0 6px;color:var(--dim);font-size:12.5px} .netheatbar b{color:var(--text);font-weight:600} .netheatbar button{background:var(--panel);border:1px solid var(--line);border-radius:999px;padding:2px 11px;color:var(--dim);font-size:12px} .netheatbar button:hover{color:var(--text);border-color:var(--faint)} .netheat .cell{fill:var(--accent)} .netheat .zero{fill:var(--panel2)} .netheat .tot{fill:var(--dim)} .netheat .on{stroke:var(--text);stroke-width:1} .netheat .go{cursor:pointer} .netheat:focus{outline:none} .netheat:focus-visible{outline:2px solid var(--accent);outline-offset:2px} .netheat .sdot{fill:var(--c);stroke:var(--panel);stroke-width:.8;pointer-events:none} .netdetail tr[data-netrow]{cursor:pointer} .netdetail tr[data-netrow]:hover td{background:var(--panel)} @media print{ .netheatbar button{display:none!important} .nettime .hit:hover .ev{stroke:none} .nettime,.netheat{-webkit-print-color-adjust:exact;print-color-adjust:exact} } </style> '@ $script:ReportNetworkTimelineScript = @' <script> // The Timeline: a lane per Server, the machine's lane, the markers and the logs, one stroke per // hour with an event; and the day-by-hour strip below, for all lanes or the one picked. Widths // are counted and not measured: the chapter may be hidden when it is drawn. if (window.GutcheckNet) GutcheckNet.view('timeline', function(root, net){ var svg = root.querySelector('svg.nettime'), heat = root.querySelector('svg.netheat'), d = net.data, w = net.words, r = w.timeline, esc = net.esc; var headBox = root.querySelector('.netheathead'), resetBox = root.querySelector('.netheatreset'), outBox = root.querySelector('.nettimeout'); if (!svg || !heat || !d.t) return; var DAYS = 30, ell = String.fromCharCode(8230), levels = net.levels; // What happened to the User's domain account: a row of its own, so that a lockout is not lost among the day's sign-ins. var ACCOUNT = ['PasswordSet', 'LockedOut', 'BadPassword']; // A time as the model writes it, local, to the second. function parse(s){ var p = /^(\d+)-(\d+)-(\d+)T(\d+):(\d+):(\d+)/.exec(s || ''); return p ? new Date(+p[1], +p[2] - 1, +p[3], +p[4], +p[5], +p[6]) : null; } function two(n){ return (n < 10 ? '0' : '') + n; } function dayText(t){ return two(t.getDate()) + '.' + two(t.getMonth() + 1) + '.'; } function timeText(t){ return two(t.getHours()) + ':' + two(t.getMinutes()) + ':' + two(t.getSeconds()); } function cut(s, n){ s = String(s); return s.length > n ? s.slice(0, Math.max(1, n - 1)) + ell : s; } var asof = parse(d.asof) || new Date(); var day0 = new Date(asof.getFullYear(), asof.getMonth(), asof.getDate() - (DAYS - 1)); // Which of the 30 days a time is on, counted in calendar days: a day with a change of the // clock is a day all the same. function dayOf(t){ return Math.round((new Date(t.getFullYear(), t.getMonth(), t.getDate()) - day0) / 864e5); } function dayAt(k){ return new Date(day0.getFullYear(), day0.getMonth(), day0.getDate() + k); } var W = 1100, LX = 236, RX = 44, PW = W - LX - RX, RH = 17, PH = 13, HW = PW / DAYS / 24; function xAt(t){ return LX + (dayOf(t) + (t.getHours() + t.getMinutes() / 60) / 24) / DAYS * PW; } // The events within the 30 days, each with its time and its row. var events = [], outside = 0; d.t.forEach(function(e, i){ var at = parse(e.Time); if (!at) return; var k = dayOf(at); if (k < 0 || k >= DAYS) { outside++; return; } var row = e.row === 'marker' ? (e.Mark === 'Crash' || e.Mark === 'Hang' ? 'crash' : ACCOUNT.indexOf(e.Mark) >= 0 ? 'account' : 'start') : e.row; events.push({ e: e, i: i, at: at, row: row, day: k, hour: at.getHours() }); }); if (outBox) { outBox.hidden = !outside; outBox.textContent = outside ? (outside === 1 ? r.OutsideOne : r.Outside.replace('{0}', outside)) : ''; } // Where a row's stretch that none of its logs reaches ends: from the model (tr), which // says from when on the logs of the row's events cover it together. // A log's own row: its own reach. function gapOf(row){ var c = row.log || (d.tr || {})[row.id]; if (!c) return null; if (c.Mark === 'Unread') return { until: null, log: c.Log, unread: true }; var at = parse(c.Time); return at && at > day0 ? { until: at, log: c.Log } : null; } // The rows, in their groups: the markers, this machine, the Servers, the logs. var rows = [], byId = {}; function add(group, id, label, extra){ var x = { group: group, id: id, label: label, ev: [], logs: [], said: [] }; for (var k in extra) x[k] = extra[k]; rows.push(x); byId[id] = x; return x; } var hasMark = function(ids){ return events.some(function(x){ return ids.indexOf(x.row) >= 0; }); }; if (hasMark(['crash'])) add(r.Group.Markers, 'crash', r.Row.Crash, { marker: true }); if (hasMark(['start'])) add(r.Group.Markers, 'start', r.Row.Start, { marker: true }); if (hasMark(['account'])) add(r.Group.Markers, 'account', r.Row.Account, { marker: true }); if (hasMark(['machine'])) add(r.Group.Machine, 'machine', d.machine || w.role.Machine, {}); (d.tl || []).forEach(function(l){ add(r.Group.Servers, l.id, l.label, { lane: l }); }); events.forEach(function(x){ var row = byId[x.row]; if (row) { row.ev.push(x); if (x.e.Log && row.logs.indexOf(x.e.Log) < 0) row.logs.push(x.e.Log); } }); // How the events of a Server's lane were spread in time, as each Finding that counts them // says it of that Server: a sentence per Finding, behind the kind of event it counts. The // Judge decided it (ADR-0002); the view only shows it. rows.forEach(function(row){ var seen = {}; if (!row.lane) return; row.ev.forEach(function(x){ var text = x.e.Mark === 'Context' ? '' : net.pattern(x.e); if (!text || seen[x.e.Finding]) return; seen[x.e.Finding] = 1; row.said.push(r.Pattern.replace('{0}', x.e.Detail || r.Mark[x.e.Mark] || x.e.Mark).replace('{1}', text)); }); }); var logRows = []; (d.tc || []).forEach(function(c){ if (!byId['log:' + c.Log]) logRows.push(add(r.Group.Logs, 'log:' + c.Log, c.Log.replace(/^Microsoft-Windows-/, ''), { log: c, logs: [c.Log] })); }); // The colour of an hour: the worst its events are drawn in, each by the part of its // Finding about its own Server, as every view colours (net.severity). function sevOf(list){ return net.worst(list.map(function(x){ return net.severity(x.e); })); } // The Finding a click leads to: that of the event drawn in the worst colour. function worstFinding(list){ var best = null, drawn = ''; list.forEach(function(x){ var f = net.finding(x.e), s = net.severity(x.e); if (f && (!best || levels.indexOf(s) < levels.indexOf(drawn))) { best = f; drawn = s; } }); return best; } // What one event of a row was, in words: its kind, and whom and what it names beyond the row. function eventWords(row, x){ var what = [r.Mark[x.e.Mark] || x.e.Mark]; if (x.e.Subject && !row.lane) what.push(x.e.Subject); if (x.e.Detail && x.e.Detail !== what[0]) what.push(x.e.Detail); if (row.lane && x.e.Subject && x.e.Subject !== row.label) what.push(x.e.Subject); if (x.e.Address && row.lane && x.e.Address !== row.label) what.push(x.e.Address); return what; } // One hour of one row as the search reads it: the row, the hour, and every event's words, // its log and its Finding - what the pointer shows of it, and nothing drawn. function hourLabel(row, list){ var t = list[0].at, said = [row.label, dayText(t) + ' ' + two(t.getHours())], seen = {}; list.forEach(function(x){ var f = net.finding(x.e); eventWords(row, x).concat(x.e.Log ? [x.e.Log] : [], f ? [f.t] : []).forEach(function(s){ if (!seen[s]) { seen[s] = 1; said.push(s); } }); }); return said.join(net.dot); } // The events of one hour of one row, in words, with the Findings they belong to. function hourHtml(row, list){ var t = list[0].at, h = t.getHours(); var out = '<h4>' + esc(row.label) + '</h4><div class="sub">' + esc(r.InHour.replace('{0}', net.eventCount(list.length)).replace('{1}', dayText(t) + ' ' + two(h)).replace('{2}', two((h + 1) % 24))) + '</div>' + (row.said.length ? '<div class="sub">' + row.said.map(esc).join('<br>') + '</div>' : '') + '<table>'; var seen = {}, finds = []; list.slice(0, 8).forEach(function(x){ var f = net.finding(x.e), what = eventWords(row, x); out += '<tr class="' + (net.severity(x.e) || 'NONE') + '" data-netrow="' + esc(row.id) + '"><td><span class="dot"></span> ' + esc(what.join(net.dot)) + '<small>' + esc(f ? f.t : w.detail.NoFinding) + '</small></td><td class="v">' + esc(timeText(x.at)) + '</td></tr>'; }); out += '</table>'; if (list.length > 8) out += '<div class="none">' + esc(r.More.replace('{0}', list.length - 8)) + '</div>'; out += '<div class="go">' + esc(r.PickRow) + '</div>'; list.forEach(function(x){ var f = net.finding(x.e); if (f && !seen[x.e.Finding]) { seen[x.e.Finding] = 1; finds.push(f); } }); finds.sort(function(a, b){ return levels.indexOf(a.s) - levels.indexOf(b.s); }).forEach(function(f){ out += '<a class="jump find ' + f.s + '" href="#' + esc(f.a) + '"><span class="badge">' + esc(f.s) + '</span><b>' + esc(f.t) + '</b>' + (f.v ? '<small>' + esc(f.v) + '</small>' : '') + '</a>'; }); return out; } // The hatching of a stretch a row's log does not reach: lines, since a Report draws no // pattern it would have to fetch by a name. function gapSvg(row, y){ var gap = gapOf(row); if (!gap) return ''; var x2 = gap.unread ? LX + PW : Math.min(LX + PW, xAt(gap.until)), h = RH - 4, o = ''; var tip = gap.unread ? r.Log.GapUnread.replace('{0}', gap.log) : r.Log.Gap.replace('{0}', dayText(gap.until) + ' ' + timeText(gap.until).slice(0, 5)).replace('{1}', gap.log); o += '<g><title>' + esc(tip) + '</title><rect class="gap" x="' + LX + '" y="' + (y + 2) + '" width="' + Math.max(1, x2 - LX).toFixed(1) + '" height="' + h + '"/>'; for (var x = LX + 6; x + h <= x2; x += 8) o += '<line class="hatch" x1="' + x.toFixed(1) + '" y1="' + (y + 2 + h) + '" x2="' + (x + h).toFixed(1) + '" y2="' + (y + 2) + '"/>'; return o + '</g>'; } var selected = ''; function draw(){ var TOP = 26, y = TOP, out = '', lastGroup = null, placed = []; rows.forEach(function(row){ if (row.group !== lastGroup) { y += 6; out += '<text class="head" x="6" y="' + (y + 11) + '">' + esc(row.group.toUpperCase()) + '</text>'; y += 17; lastGroup = row.group; } placed.push({ row: row, y: y }); y += RH + (row.said.length ? PH : 0); }); var H = y + 22; // The days: weekends set apart, a line at each day, a date every second one, the Run. for (var k = 0; k < DAYS; k++) { var day = dayAt(k), x = LX + k / DAYS * PW, dw = PW / DAYS; if (day.getDay() === 0 || day.getDay() === 6) out = '<rect class="wkend" x="' + x.toFixed(1) + '" y="' + (TOP - 4) + '" width="' + dw.toFixed(1) + '" height="' + (H - TOP - 14) + '"/>' + out; out += '<line class="grid" x1="' + x.toFixed(1) + '" y1="' + (TOP - 4) + '" x2="' + x.toFixed(1) + '" y2="' + (H - 18) + '"/>'; if ((DAYS - 1 - k) % 2 === 0) out += '<text class="faint" x="' + (x + 2).toFixed(1) + '" y="' + (TOP - 10) + '" font-size="10">' + esc(dayText(day)) + '</text>'; } // The Run, with its word under the lanes, where no date stands. var rx = xAt(asof); out += '<line class="run" x1="' + rx.toFixed(1) + '" y1="' + (TOP - 4) + '" x2="' + rx.toFixed(1) + '" y2="' + (H - 14) + '"/>' + '<text class="faint" x="' + (rx - 3).toFixed(1) + '" y="' + (H - 4) + '" font-size="10" text-anchor="end">' + esc(r.Run + ' ' + dayText(asof) + ' ' + timeText(asof).slice(0, 5)) + '</text>'; placed.forEach(function(p){ var row = p.row, y = p.y; out += gapSvg(row, y); var lab = row.lane ? row.lane.label : row.label, tip = row.lane ? row.lane.names.concat(row.lane.addresses).join(net.dot) : (row.log ? row.log.Log : ''); if (row.said.length) tip += '\n' + row.said.join('\n'); // A lane that picks itself on a click is a button, and the search reads it by its names; // a log's row is neither. var findable = row.log ? '' : ' role="button" aria-label="' + esc([lab].concat(tip ? tip.split('\n') : []).join(net.dot)) + '"'; out += '<g class="lab' + (selected === row.id ? ' sel' : '') + '" id="tl-lab-' + esc(row.id) + '" data-row="' + esc(row.id) + '"' + findable + '><rect x="0" y="' + y + '" width="' + (LX - 6) + '" height="' + RH + '"/>' + (tip ? '<title>' + esc(tip) + '</title>' : '') + '<text x="14" y="' + (y + 12) + '" font-size="11.5">' + esc(cut(lab, 34)) + '</text></g>'; if (row.log) { var c = row.log, at = parse(c.Time), x1 = c.Mark === 'Unread' ? null : (at && at > day0 ? xAt(at) : LX); if (x1 != null) out += '<line class="cov" x1="' + x1.toFixed(1) + '" y1="' + (y + RH / 2) + '" x2="' + (LX + PW) + '" y2="' + (y + RH / 2) + '"/>'; var said = c.Mark === 'Unread' ? r.Log.Unread : (at && at > day0 ? r.Log.Reaches.replace('{0}', dayText(at)) : r.Log.Whole); out += '<text class="dim" x="' + (LX + 4) + '" y="' + (y + 12) + '" font-size="10">' + esc(c.Mark === 'Unread' || (at && at > day0) ? said : '') + '</text>'; return; } // How the lane's events were spread in time, on a line of its own under its strokes. if (row.said.length) out += '<text class="dim" x="' + (LX + 4) + '" y="' + (y + RH + 9) + '" font-size="10">' + esc(cut(row.said.join(net.dot), 160)) + '</text>'; // One stroke, or one mark, per hour with an event. var hours = {}; row.ev.forEach(function(x){ var key = x.day * 24 + x.hour; (hours[key] = hours[key] || []).push(x); }); Object.keys(hours).forEach(function(key){ var list = hours[key].sort(function(a, b){ return a.at - b.at; }), s = sevOf(list) || 'NONE', x = LX + Number(key) * HW; var go = worstFinding(list) ? ' go' : ''; out += '<g class="hit ' + s + go + '" data-row="' + esc(row.id) + '" data-h="' + key + '" data-lights="tl-lab-' + esc(row.id) + '" tabindex="0" role="button" aria-label="' + esc(hourLabel(row, list)) + '">'; if (row.marker) { var cx = x + HW / 2, cy = y + RH / 2; if (row.id === 'crash') out += '<path class="mk ' + s + '" d="M' + cx.toFixed(1) + ' ' + (cy - 5) + 'l5 5l-5 5l-5 -5z"/>'; else if (row.id === 'account') out += '<rect class="mk ' + s + '" x="' + (cx - 4).toFixed(1) + '" y="' + (cy - 4) + '" width="8" height="8"/>'; else out += '<circle class="mk ring ' + s + '" cx="' + cx.toFixed(1) + '" cy="' + cy + '" r="4"/>'; } else { // Hollow where every counted event of the hour is a sign-in the Service refused // (Denied) - context, shown and not counted, says nothing of it; filled under a // hollow cap where the hour has refusals and failures both. var counted = list.filter(function(z){ return z.e.Mark !== 'Context'; }); var refused = counted.filter(function(z){ return z.e.Mark === 'Denied'; }).length; var only = refused && refused === counted.length, bw = Math.max(only ? 4 : 3, HW); if (only) out += '<rect class="ev den" x="' + x.toFixed(1) + '" y="' + (y + 3) + '" width="' + bw.toFixed(1) + '" height="' + (RH - 6) + '" rx="1"/>'; else { out += '<rect class="ev" x="' + x.toFixed(1) + '" y="' + (y + 3 + (refused ? 5 : 0)) + '" width="' + bw.toFixed(1) + '" height="' + (RH - 6 - (refused ? 5 : 0)) + '" rx="1"/>'; if (refused) out += '<rect class="ev den" x="' + x.toFixed(1) + '" y="' + (y + 3) + '" width="' + bw.toFixed(1) + '" height="4" rx="1"/>'; } } out += '</g>'; }); out += '<text class="faint" x="' + (W - RX + 6) + '" y="' + (y + 12) + '" font-size="10">' + row.ev.length + '</text>'; }); svg.setAttribute('viewBox', '0 0 ' + W + ' ' + Math.ceil(H)); svg.style.maxWidth = W + 'px'; svg.innerHTML = out; Array.prototype.forEach.call(svg.querySelectorAll('.hit'), function(el){ var row = byId[el.getAttribute('data-row')], key = Number(el.getAttribute('data-h')); var list = row.ev.filter(function(x){ return x.day * 24 + x.hour === key; }).sort(function(a, b){ return a.at - b.at; }); function on(){ el.classList.add('sel'); net.showHtml(el, hourHtml(row, list)); } function off(){ el.classList.remove('sel'); net.hideDetail(); } function go(){ var f = worstFinding(list); 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(); } }); }); Array.prototype.forEach.call(svg.querySelectorAll('.lab'), function(el){ var row = byId[el.getAttribute('data-row')]; if (row.log) { el.style.cursor = 'default'; return; } el.addEventListener('click', function(){ selected = selected === row.id ? '' : row.id; draw(); }); var node = row.lane && row.lane.node ? d.n.filter(function(n){ return n.id === row.lane.node; })[0] : null; if (node) { el.addEventListener('mouseenter', function(){ net.showDetail(el, node); }); el.addEventListener('mouseleave', net.hideDetail); } }); strip(); // Drawn anew, the search's hits went with what was there. if (net.refind) net.refind(); } // Day by hour: how many events of the lanes shown fell in each hour of each day, with the // totals per day below and per hour beside. // The rows the strip is drawn for and their name, which its summaries are of. var stripRows = [], stripName = '', busiest = null; function strip(){ stripRows = rows.filter(function(row){ return !row.log && (!selected || row.id === selected); }); var grid = [], severities = [], perDay = [], perHour = [], judgedDay = [], judgedHour = [], total = 0, max = 0, k, h; for (k = 0; k < DAYS; k++) { grid.push([]); severities.push([]); perDay.push(0); judgedDay.push(false); for (h = 0; h < 24; h++) { grid[k].push(0); severities[k].push([]); } } for (h = 0; h < 24; h++) { perHour.push(0); judgedHour.push(false); } // What the search reads of a cell: the words of its events, each once, as of an hour's stroke. var words = []; for (k = 0; k < DAYS; k++) { words.push([]); for (h = 0; h < 24; h++) words[k].push(null); } stripRows.forEach(function(row){ row.ev.forEach(function(x){ var s = net.severity(x.e), f = net.finding(x.e); grid[x.day][x.hour]++; severities[x.day][x.hour].push(s); perDay[x.day]++; perHour[x.hour]++; total++; if (s) { judgedDay[x.day] = true; judgedHour[x.hour] = true; } var said = words[x.day][x.hour] = words[x.day][x.hour] || { list: [], seen: {} }; [row.label].concat(eventWords(row, x), x.e.Log ? [x.e.Log] : [], f ? [f.t] : []).forEach(function(t){ if (t && !said.seen[t]) { said.seen[t] = 1; said.list.push(t); } }); }); }); // A cell is found by its words; its dot, and the totals of its day and its hour, are lit // with it and faded without it. function cellId(k, h){ return 'hc-' + k + '-' + h; } function cellsOf(k, h){ var ids = [], a, b; for (a = 0; a < DAYS; a++) for (b = 0; b < 24; b++) if (grid[a][b] && (k == null || a === k) && (h == null || b === h)) ids.push(cellId(a, b)); return ids.join(' '); } busiest = null; grid.forEach(function(c, day){ c.forEach(function(v, hour){ if (v > max) { max = v; busiest = { k: day, h: hour }; } }); }); stripName = selected ? byId[selected].label : r.HeatAll; if (headBox) headBox.innerHTML = esc(r.Heat.replace('{0}', '')) + '<b>' + esc(stripName) + '</b>' + net.dot + esc(net.eventCount(total)); if (resetBox) resetBox.hidden = !selected; var TOP = 24, CH = 9, cw = PW / DAYS, H = TOP + 24 * CH + 46, o = ''; for (k = 0; k < DAYS; k++) if ((DAYS - 1 - k) % 2 === 0) o += '<text class="faint" x="' + (LX + k * cw + 2).toFixed(1) + '" y="' + (TOP - 8) + '" font-size="10">' + esc(dayText(dayAt(k))) + '</text>'; for (h = 0; h < 24; h += 3) o += '<text class="faint" x="' + (LX - 8) + '" y="' + (TOP + h * CH + 8) + '" font-size="10" text-anchor="end">' + two(h) + ':00</text>'; o += '<text class="dim" x="14" y="' + (TOP + 12 * CH) + '" font-size="11">' + esc(r.Hour) + '</text>'; for (k = 0; k < DAYS; k++) for (h = 0; h < 24; h++) { var v = grid[k][h], worstHere = v ? net.worst(severities[k][h]) : ''; o += '<rect class="' + (v ? 'cell sum' + (worstHere ? ' go' : '') : 'zero') + '" x="' + (LX + k * cw + 0.5).toFixed(1) + '" y="' + (TOP + h * CH + 0.5) + '" width="' + (cw - 1).toFixed(1) + '" height="' + (CH - 1) + '"' + (v ? ' fill-opacity="' + (0.25 + 0.75 * v / max).toFixed(2) + '" data-id="' + cellId(k, h) + '" role="button" aria-label="' + esc([dayText(dayAt(k)) + ' ' + two(h)].concat(words[k][h].list).join(net.dot)) + '"' : '') + ' data-k="' + k + '" data-h="' + h + '"/>'; // A dot where a FAIL or a WARN fell in that hour, in its colour: how many is the // cell's shade, how bad its dot. if (worstHere === 'FAIL' || worstHere === 'WARN') o += '<circle class="sdot ' + worstHere + '" data-with="' + cellId(k, h) + '" cx="' + (LX + k * cw + cw / 2).toFixed(1) + '" cy="' + (TOP + h * CH + CH / 2) + '" r="2.6"/>'; } var dayMax = Math.max.apply(null, perDay.concat([1])), hourMax = Math.max.apply(null, perHour.concat([1])), by = TOP + 24 * CH + 6; for (k = 0; k < DAYS; k++) { var bh = perDay[k] / dayMax * 30; if (perDay[k]) o += '<rect class="tot sum' + (judgedDay[k] ? ' go' : '') + '" x="' + (LX + k * cw + 1).toFixed(1) + '" y="' + (by + 30 - bh).toFixed(1) + '" width="' + (cw - 2).toFixed(1) + '" height="' + bh.toFixed(1) + '" data-k="' + k + '" data-with="' + cellsOf(k, null) + '"/>'; } o += '<text class="dim" x="' + (LX - 8) + '" y="' + (by + 28) + '" font-size="10" text-anchor="end">' + esc(r.PerDay) + '</text>'; for (h = 0; h < 24; h++) { var bw = perHour[h] / hourMax * (RX - 8); if (perHour[h]) o += '<rect class="tot sum' + (judgedHour[h] ? ' go' : '') + '" x="' + (LX + PW + 4) + '" y="' + (TOP + h * CH + 1) + '" width="' + bw.toFixed(1) + '" height="' + (CH - 2) + '" data-h="' + h + '" data-with="' + cellsOf(null, h) + '"/>'; } o += '<text class="dim" x="' + (W - 1) + '" y="' + (by + 28) + '" font-size="10" text-anchor="end">' + esc(r.PerHour) + '</text>'; heat.setAttribute('viewBox', '0 0 ' + W + ' ' + H); heat.style.maxWidth = W + 'px'; heat.innerHTML = o; // On the pointer: what a cell, a day or an hour holds, summed up; a click leads to the // Finding of its worst event, as a stroke's does. Array.prototype.forEach.call(heat.querySelectorAll('.sum'), function(el){ el.addEventListener('mouseenter', function(){ mark(el); showSum(el); }); el.addEventListener('mouseleave', function(){ mark(null); net.hideDetail(); }); el.addEventListener('click', function(){ jumpFrom(el); }); }); } // The events of a day, an hour of every day, or one hour of one day, of the rows shown: // looked up when asked for, and not for each of some 800 places at every drawing. function where(el){ var k = el.getAttribute('data-k'), h = el.getAttribute('data-h'); return { day: k == null ? null : +k, hour: h == null ? null : +h }; } function eventsAt(at){ var list = []; stripRows.forEach(function(row){ row.ev.forEach(function(x){ if ((at.day == null || x.day === at.day) && (at.hour == null || x.hour === at.hour)) list.push({ row: row, x: x }); }); }); return list.sort(function(a, b){ return a.x.at - b.x.at; }); } function dayCount(list){ var seen = {}, n = 0; list.forEach(function(p){ if (!seen[p.x.day]) { seen[p.x.day] = 1; n++; } }); return n; } function showSum(el){ var at = where(el), list = eventsAt(at), said; if (!list.length) { net.hideDetail(); return; } if (at.day != null && at.hour != null) said = r.InHour.replace('{0}', net.eventCount(list.length)).replace('{1}', dayText(dayAt(at.day)) + ' ' + two(at.hour)).replace('{2}', two((at.hour + 1) % 24)); else if (at.day != null) said = r.OnDay.replace('{0}', net.eventCount(list.length)).replace('{1}', dayText(dayAt(at.day))); else said = (dayCount(list) == 1 ? r.AtHourOne : r.AtHour).replace('{0}', net.eventCount(list.length)).replace('{1}', two(at.hour)).replace('{2}', two((at.hour + 1) % 24)).replace('{3}', dayCount(list)); net.showHtml(el, sumHtml(stripName, said, list, at.day != null && at.hour != null)); } function jumpFrom(el){ var f = worstFinding(eventsAt(where(el)).map(function(p){ return p.x; })); if (f) net.jump(f.a); } var marked = null; function mark(el){ if (marked) marked.classList.remove('on'); marked = el; if (el) el.classList.add('on'); } // From the keyboard: the strip is one stop of the tab key, the arrows go from hour to // hour and from day to day, Enter to the Finding - some 700 stops would be none. var cursor = null; function moveTo(c){ cursor = c; var el = heat.querySelector('rect[data-k="' + c.k + '"][data-h="' + c.h + '"]'); mark(el); if (el) showSum(el); } // A click focuses the strip as well, and leaves the summary where the pointer is. var byPointer = false; heat.addEventListener('mousedown', function(){ byPointer = true; }); heat.addEventListener('focus', function(){ if (byPointer) { byPointer = false; return; } moveTo(cursor || busiest || { k: DAYS - 1, h: 0 }); }); heat.addEventListener('blur', function(){ mark(null); net.hideDetail(); }); heat.addEventListener('keydown', function(e){ if (!cursor) cursor = busiest || { k: DAYS - 1, h: 0 }; var step = { ArrowLeft: [-1, 0], ArrowRight: [1, 0], ArrowUp: [0, -1], ArrowDown: [0, 1] }[e.key]; if (step) { e.preventDefault(); moveTo({ k: Math.min(DAYS - 1, Math.max(0, cursor.k + step[0])), h: Math.min(23, Math.max(0, cursor.h + step[1])) }); } else if (e.key === 'Enter') { e.preventDefault(); if (marked) jumpFrom(marked); } else if (e.key === 'Escape') net.hideDetail(); }); // Many events in words without a list of each: from when to when, the rows they came from, // the worst first, each with how many of which kind; the Findings they belong to. A busy // hour of hundreds is as long as a quiet one of three. var SUMROWS = 6, SUMFINDS = 4, times = String.fromCharCode(215) + ' '; function sumHtml(name, said, list, inHour){ var first = list[0].x.at, last = list[list.length - 1].x.at; var at = function(t){ return inHour ? timeText(t).slice(0, 5) : net.when(t); }; var out = '<h4>' + esc(name) + '</h4><div class="sub">' + esc(said) + (list.length > 1 ? '<br>' + esc(w.detail.Span.replace('{0}', at(first)).replace('{1}', at(last))) : '') + '</div><table>'; var byRow = {}, order = []; list.forEach(function(p){ var g = byRow[p.row.id]; if (!g) { g = byRow[p.row.id] = { row: p.row, n: 0, s: [], kinds: {}, kindOrder: [] }; order.push(g); } // What happened, as the log says it; an event shown for the context says so beside. var markWord = r.Mark[p.x.e.Mark] || p.x.e.Mark, kind = p.x.e.Detail || markWord; if (p.x.e.Detail && p.x.e.Mark === 'Context') kind += ' ' + String.fromCharCode(8211) + ' ' + markWord; if (!g.kinds[kind]) { g.kinds[kind] = 0; g.kindOrder.push(kind); } g.kinds[kind]++; g.n++; g.s.push(net.severity(p.x.e)); }); order.forEach(function(g){ g.sev = net.worst(g.s); }); order.sort(function(a, b){ var sa = a.sev ? levels.indexOf(a.sev) : levels.length, sb = b.sev ? levels.indexOf(b.sev) : levels.length; return sa - sb || b.n - a.n; }); order.slice(0, SUMROWS).forEach(function(g){ var kinds = g.kindOrder.sort(function(a, b){ return g.kinds[b] - g.kinds[a]; }).map(function(k){ return g.kinds[k] + times + k; }); out += '<tr class="' + (g.sev || 'NONE') + '" data-netrow="' + esc(g.row.id) + '"><td><span class="dot"></span> ' + esc(g.row.label) + '<small>' + esc(kinds.slice(0, 3).join(net.dot) + (kinds.length > 3 ? net.dot + ell : '')) + '</small>' + (g.row.said.length ? '<small>' + g.row.said.map(esc).join('<br>') + '</small>' : '') + '</td><td class="v">' + esc(String(g.n)) + '</td></tr>'; }); out += '</table>'; if (order.length > SUMROWS) { var rest = order.slice(SUMROWS).reduce(function(s, g){ return s + g.n; }, 0); out += '<div class="none">' + esc(r.MoreRows.replace('{0}', order.length - SUMROWS).replace('{1}', rest)) + '</div>'; } out += '<div class="go">' + esc(r.PickRow) + '</div>'; var seen = {}, finds = []; list.forEach(function(p){ var f = net.finding(p.x.e); if (f && !seen[p.x.e.Finding]) { seen[p.x.e.Finding] = 1; finds.push(f); } }); // Each Finding by its name alone: its value names every Server, the rows above say which. finds.sort(function(a, b){ return levels.indexOf(a.s) - levels.indexOf(b.s); }).slice(0, SUMFINDS).forEach(function(f){ out += '<a class="jump find ' + f.s + '" href="#' + esc(f.a) + '"><span class="badge">' + esc(f.s) + '</span><b>' + esc(f.t) + '</b></a>'; }); if (finds.length > SUMFINDS) out += '<div class="none">' + esc(r.More.replace('{0}', finds.length - SUMFINDS)) + '</div>'; return out; } if (resetBox) resetBox.addEventListener('click', function(){ selected = ''; draw(); }); // A row of a detail the timeline wrote: the grid for that row alone, as its name's click // gives, and the grid brought into view. var panel = document.getElementById('netdetail'); if (panel) panel.addEventListener('click', function(e){ var tr = e.target.closest && e.target.closest('tr[data-netrow]'); if (!tr || !byId[tr.getAttribute('data-netrow')]) return; selected = tr.getAttribute('data-netrow'); panel.hidden = true; draw(); heat.scrollIntoView({ block: 'nearest' }); }); draw(); }); </script> '@ function ConvertTo-ReportNetworkTimelineView { <# .SYNOPSIS The timeline view: what it is drawn into, and its words. Pure. .DESCRIPTION Nothing where the Run read no event: a log's reach alone is no timeline. Nor where it measured no network (Test-ReportNetworkMeasured): crashes and sign-ins alone are no Timeline of the network; beside a network they are drawn. #> [CmdletBinding()] [OutputType([psobject])] param([Parameter(Mandatory)]$Model) if (-not @($Model.t).Count -or -not (Test-ReportNetworkMeasured -Measurement $Model.m -TimedEvent $Model.t)) { return } $legend = ('<span><svg width="12" height="12" aria-hidden="true"><path d="M6 1l5 5l-5 5l-5 -5z" fill="currentColor"/></svg>{0}</span>' + '<span><svg width="12" height="12" aria-hidden="true"><circle cx="6" cy="6" r="4" fill="none" stroke="currentColor" stroke-width="2"/></svg>{1}</span>' + '<span><svg width="12" height="12" aria-hidden="true"><rect x="2" y="2" width="8" height="8" fill="currentColor"/></svg>{4}</span>' + '<span><svg width="6" height="12" aria-hidden="true"><rect x="0.75" y="0.75" width="4.5" height="10.5" rx="1" fill="none" stroke="currentColor" stroke-width="1.5"/></svg>{3}</span>' + '<span><svg width="22" height="12" aria-hidden="true"><rect x="0.5" y="0.5" width="21" height="11" fill="none" stroke="currentColor" stroke-dasharray="3 3"/><line x1="4" y1="11" x2="14" y2="1" stroke="currentColor"/></svg>{2}</span>') -f ` (ConvertTo-HtmlText (Get-Text 'Report.Network.Timeline.Legend.Crash')), (ConvertTo-HtmlText (Get-Text 'Report.Network.Timeline.Legend.Start')), (ConvertTo-HtmlText (Get-Text 'Report.Network.Timeline.Legend.Gap')), (ConvertTo-HtmlText (Get-Text 'Report.Network.Timeline.Legend.Denied')), (ConvertTo-HtmlText (Get-Text 'Report.Network.Timeline.Legend.Account')) $html = ('<h3>{0}</h3><p class="netsub">{1} <span class="nethint">{2}</span></p><div class="nettimelegend">{3}</div>' + '<div class="netpanel"><svg class="nettime" role="img" aria-label="{0}"></svg></div><p class="nettimeout" hidden></p>' + '<div class="netheatbar"><span class="netheathead"></span><button type="button" class="netheatreset" hidden>{4}</button></div>' + '<div class="netpanel"><svg class="netheat" role="img" aria-label="{0}" tabindex="0"></svg></div>') -f ` (ConvertTo-HtmlText (Get-Text 'Report.Network.Timeline.Heading')), (ConvertTo-HtmlText (Get-Text 'Report.Network.Timeline.Legend')), (ConvertTo-HtmlText (Get-Text 'Report.Network.Timeline.Hint')), $legend, (ConvertTo-HtmlText (Get-Text 'Report.Network.Timeline.HeatReset')) New-ReportNetworkView -Id 'timeline' -Title (Get-Text 'Report.Network.Timeline.Title') -Html $html ` -Css $script:ReportNetworkTimelineCss -Script $script:ReportNetworkTimelineScript ` -Word ([ordered]@{ Group = [ordered]@{ Markers = Get-Text 'Report.Network.Timeline.Group.Markers' Machine = Get-Text 'Report.Network.Timeline.Group.Machine' Servers = Get-Text 'Report.Network.Timeline.Group.Servers' Logs = Get-Text 'Report.Network.Timeline.Group.Logs' } Row = [ordered]@{ Crash = Get-Text 'Report.Network.Timeline.Row.Crash' Start = Get-Text 'Report.Network.Timeline.Row.Start' Account = Get-Text 'Report.Network.Timeline.Row.Account' } Mark = [ordered]@{ Lost = Get-Text 'Report.Network.Timeline.Mark.Lost' Failed = Get-Text 'Report.Network.Timeline.Mark.Failed' ShareNotFound = Get-Text 'Report.Network.Timeline.Mark.ShareNotFound' Denied = Get-Text 'Report.Network.Timeline.Mark.Denied' Context = Get-Text 'Report.Network.Timeline.Mark.Context' Crash = Get-Text 'Report.Network.Timeline.Mark.Crash' Hang = Get-Text 'Report.Network.Timeline.Mark.Hang' Start = Get-Text 'Report.Network.Timeline.Mark.Start' SignIn = Get-Text 'Report.Network.Timeline.Mark.SignIn' PasswordSet = Get-Text 'Report.Network.Timeline.Mark.PasswordSet' LockedOut = Get-Text 'Report.Network.Timeline.Mark.LockedOut' BadPassword = Get-Text 'Report.Network.Timeline.Mark.BadPassword' } Log = [ordered]@{ Reaches = Get-Text 'Report.Network.Timeline.Log.Reaches' Whole = Get-Text 'Report.Network.Timeline.Log.Whole' Unread = Get-Text 'Report.Network.Timeline.Log.Unread' Gap = Get-Text 'Report.Network.Timeline.Log.Gap' GapUnread = Get-Text 'Report.Network.Timeline.Log.GapUnread' } Run = Get-Text 'Report.Network.Timeline.Run' Heat = Get-Text 'Report.Network.Timeline.Heat' HeatAll = Get-Text 'Report.Network.Timeline.HeatAll' PerDay = Get-Text 'Report.Network.Timeline.PerDay' PerHour = Get-Text 'Report.Network.Timeline.PerHour' Hour = Get-Text 'Report.Network.Timeline.Hour' InHour = Get-Text 'Report.Network.Timeline.InHour' OnDay = Get-Text 'Report.Network.Timeline.OnDay' AtHour = Get-Text 'Report.Network.Timeline.AtHour' AtHourOne = Get-Text 'Report.Network.Timeline.AtHour.One' MoreRows = Get-Text 'Report.Network.Timeline.MoreRows' PickRow = Get-Text 'Report.Network.Timeline.PickRow' More = Get-Text 'Report.Network.Timeline.More' Outside = Get-Text 'Report.Network.Timeline.Outside' OutsideOne = Get-Text 'Report.Network.Timeline.Outside.One' Pattern = Get-Text 'Report.Network.Timeline.Pattern' }) } |