Worker: pings every tracked device on a presence sweep (hourly in business hours plus midnight) and records last online; inventory reports whether a device was found in AD and SCCM; monitors from WmiMonitorID; snapshots of the last reachable state replace "Cached"; newly tracked devices are swept and published first; totals count source devices only; per-type timing in Worker-State. Front end: details dialog shows presence, reasons instead of N/A, "Waiting for worker...", monitors and snapshot times, and refreshes in place; Custom View gains Days Offline, Last Online, Found In and monitor columns; manual pair changes show immediately; sync dialog shows timing by type. Custom View gets a large scrollbar; "Exactly ticked" filter.
369 lines
19 KiB
JavaScript
369 lines
19 KiB
JavaScript
/*
|
|
* Layout helpers (see layout.css).
|
|
*
|
|
* Sticky headers: measures the top nav and each table's group header so the column
|
|
* header rows can stick directly beneath them (--nav-h, --sticky-top, --thead-r1).
|
|
*
|
|
* Card view: on phones and tablets each table row is shown as a card, with every cell
|
|
* rendered as "Column name: value". CSS can't look up a cell's column header, so this
|
|
* copies each header's text onto its cells as data-label.
|
|
*
|
|
* The dashboard re-renders its tables constantly, so both are reapplied (once per frame
|
|
* at most) whenever the DOM changes.
|
|
*/
|
|
(function () {
|
|
'use strict';
|
|
|
|
const cardsQuery = window.matchMedia('(max-width: 1100px)');
|
|
const clean = text => text.replace(/[\u2195\u25B2\u25BC\u00D7\u2715]/g, '').replace(/\s+/g, ' ').trim();
|
|
|
|
function labelTable(table) {
|
|
const thead = table.tHead;
|
|
if (!thead) return;
|
|
|
|
// The header row is the one with the most text (Custom View has a drag-handle row above it)
|
|
let headerRow = null, best = -1;
|
|
for (const row of thead.rows) {
|
|
if (row.classList.contains('filter-row')) continue;
|
|
const named = Array.from(row.cells).filter(c => clean(c.textContent)).length;
|
|
if (named > best) { best = named; headerRow = row; }
|
|
}
|
|
if (!headerRow) return;
|
|
|
|
for (const row of thead.rows) {
|
|
if (row === headerRow) row.classList.add('card-label-row');
|
|
else if (!row.classList.contains('filter-row')) row.classList.add('card-aux-row');
|
|
}
|
|
|
|
const labels = Array.from(headerRow.cells).map(c => clean(c.textContent));
|
|
const apply = row => {
|
|
Array.from(row.cells).forEach((cell, i) => {
|
|
const label = cell.colSpan > 1 ? '' : (labels[i] || '');
|
|
if (cell.dataset.label !== label) cell.dataset.label = label;
|
|
});
|
|
};
|
|
thead.querySelectorAll('tr.filter-row').forEach(apply);
|
|
for (const body of table.tBodies) for (const row of body.rows) apply(row);
|
|
|
|
}
|
|
|
|
// --- Sticky offsets ---------------------------------------------------------------
|
|
function setVar(el, name, value) {
|
|
if (el.style.getPropertyValue(name) !== value) el.style.setProperty(name, value);
|
|
}
|
|
function groupHeaderFor(table) {
|
|
// The group header is a sibling of the table or of its wrapper
|
|
for (let el = table.parentElement, depth = 0; el && depth < 3; el = el.parentElement, depth++) {
|
|
const gh = el.querySelector(':scope > .group-header');
|
|
if (gh) return gh;
|
|
}
|
|
return null;
|
|
}
|
|
function updateSticky() {
|
|
const root = document.documentElement;
|
|
const nav = document.querySelector('.top-nav');
|
|
const navH = nav ? nav.getBoundingClientRect().height : 60;
|
|
setVar(root, '--nav-h', navH + 'px');
|
|
const dock = document.getElementById('usmt-term');
|
|
setVar(root, '--dock-h', (dock ? Math.ceil(dock.getBoundingClientRect().height) : 0) + 'px');
|
|
|
|
document.querySelectorAll('table.master-table').forEach(table => {
|
|
if (!table.offsetParent) return; // hidden page/group: keep old values
|
|
const gh = groupHeaderFor(table);
|
|
const ghH = gh && getComputedStyle(gh).position === 'sticky' ? gh.getBoundingClientRect().height : 0;
|
|
// 1px overlap tucks the table header under the group header: no hairline gap
|
|
const top = Math.max(0, navH + ghH - 1) + 'px';
|
|
setVar(table, '--sticky-top', top);
|
|
if (table.parentElement) setVar(table.parentElement, '--sticky-top', top); // for the filter chip bar
|
|
});
|
|
setupCustomHScroll();
|
|
}
|
|
|
|
// Custom View can be wider than the page. A sideways-scrolling box would stop its header
|
|
// from sticking, so the box clips instead (overflow-x: clip) and the table is shifted
|
|
// sideways by a scrollbar of our own, pinned to the bottom of the window: a wide track
|
|
// with a draggable thumb (click the track to page), arrow buttons that repeat while held,
|
|
// and a position readout. Wheel, shift+wheel, trackpad and touch swipes over the table
|
|
// drive the same position. customHScroll.by(dx) is also used by the column drag.
|
|
const customHScroll = { left: 0, max: 0, bar: null, by(dx) { this.set(this.left + dx); }, set() {} };
|
|
window.customHScroll = customHScroll;
|
|
function setupCustomHScroll() {
|
|
const box = document.getElementById('scroll-custom');
|
|
const host = document.getElementById('custom-table-host');
|
|
const table = document.getElementById('table-custom');
|
|
if (!box || !host || !table) return;
|
|
let bar = document.getElementById('custom-hscroll');
|
|
if (!bar) {
|
|
bar = document.createElement('div');
|
|
bar.id = 'custom-hscroll';
|
|
bar.className = 'custom-hscroll no-copy';
|
|
bar.innerHTML =
|
|
'<button type="button" class="chs-btn chs-left" aria-label="Scroll columns left">◀</button>' +
|
|
'<div class="chs-track" role="scrollbar" aria-orientation="horizontal" aria-controls="table-custom" tabindex="0"><div class="chs-thumb"><span></span></div></div>' +
|
|
'<button type="button" class="chs-btn chs-right" aria-label="Scroll columns right">▶</button>' +
|
|
'<span class="chs-info"></span>';
|
|
box.insertAdjacentElement('afterend', bar);
|
|
customHScroll.bar = bar;
|
|
const track = bar.querySelector('.chs-track'), thumb = bar.querySelector('.chs-thumb');
|
|
const info = bar.querySelector('.chs-info');
|
|
const geom = () => {
|
|
const tw = track.clientWidth, view = box.clientWidth, full = view + customHScroll.max;
|
|
const w = Math.max(48, Math.round(tw * view / Math.max(full, 1)));
|
|
return { tw, w, room: Math.max(tw - w, 1) };
|
|
};
|
|
customHScroll.draw = () => {
|
|
const g = geom();
|
|
thumb.style.width = g.w + 'px';
|
|
thumb.style.transform = `translateX(${Math.round(g.room * (customHScroll.max ? customHScroll.left / customHScroll.max : 0))}px)`;
|
|
const pct = customHScroll.max ? Math.round(100 * customHScroll.left / customHScroll.max) : 0;
|
|
info.textContent = pct + '%';
|
|
track.setAttribute('aria-valuenow', pct);
|
|
bar.querySelector('.chs-left').disabled = customHScroll.left <= 0;
|
|
bar.querySelector('.chs-right').disabled = customHScroll.left >= customHScroll.max;
|
|
};
|
|
customHScroll.set = x => {
|
|
const v = Math.max(0, Math.min(customHScroll.max, Math.round(x)));
|
|
customHScroll.left = v;
|
|
host.style.transform = v ? `translateX(${-v}px)` : '';
|
|
customHScroll.draw();
|
|
};
|
|
// Thumb drag
|
|
thumb.addEventListener('pointerdown', e => {
|
|
e.preventDefault(); e.stopPropagation();
|
|
thumb.setPointerCapture(e.pointerId);
|
|
bar.classList.add('dragging');
|
|
const x0 = e.clientX, l0 = customHScroll.left, g = geom();
|
|
const move = ev => customHScroll.set(l0 + (ev.clientX - x0) * customHScroll.max / g.room);
|
|
const up = () => { bar.classList.remove('dragging'); thumb.removeEventListener('pointermove', move); thumb.removeEventListener('pointerup', up); thumb.removeEventListener('pointercancel', up); };
|
|
thumb.addEventListener('pointermove', move);
|
|
thumb.addEventListener('pointerup', up);
|
|
thumb.addEventListener('pointercancel', up);
|
|
});
|
|
// Click on the track: jump so the thumb centres on the pointer
|
|
track.addEventListener('pointerdown', e => {
|
|
if (e.target !== track) return;
|
|
const g = geom(), r = track.getBoundingClientRect();
|
|
customHScroll.set(((e.clientX - r.left - g.w / 2) / g.room) * customHScroll.max);
|
|
});
|
|
track.addEventListener('keydown', e => {
|
|
const page = box.clientWidth * 0.8;
|
|
const k = { ArrowLeft: -60, ArrowRight: 60, PageUp: -page, PageDown: page }[e.key];
|
|
if (k) { e.preventDefault(); customHScroll.by(k); }
|
|
else if (e.key === 'Home') { e.preventDefault(); customHScroll.set(0); }
|
|
else if (e.key === 'End') { e.preventDefault(); customHScroll.set(customHScroll.max); }
|
|
});
|
|
// Arrow buttons: one step per click, keep going while held
|
|
bar.querySelectorAll('.chs-btn').forEach(btn => {
|
|
const dir = btn.classList.contains('chs-left') ? -1 : 1;
|
|
let timer = null;
|
|
const stop = () => { clearInterval(timer); clearTimeout(timer); timer = null; };
|
|
btn.addEventListener('pointerdown', e => {
|
|
e.preventDefault();
|
|
customHScroll.by(dir * 160);
|
|
timer = setTimeout(() => { timer = setInterval(() => customHScroll.by(dir * 40), 30); }, 300);
|
|
});
|
|
['pointerup', 'pointerleave', 'pointercancel'].forEach(t => btn.addEventListener(t, stop));
|
|
});
|
|
box.addEventListener('wheel', e => {
|
|
if (bar.hidden) return;
|
|
const dx = Math.abs(e.deltaX) > Math.abs(e.deltaY) ? e.deltaX : (e.shiftKey ? e.deltaY : 0);
|
|
if (!dx) return;
|
|
e.preventDefault();
|
|
customHScroll.by(dx);
|
|
}, { passive: false });
|
|
bar.addEventListener('wheel', e => {
|
|
const d = Math.abs(e.deltaX) > Math.abs(e.deltaY) ? e.deltaX : e.deltaY;
|
|
if (!d) return;
|
|
e.preventDefault();
|
|
customHScroll.by(d);
|
|
}, { passive: false });
|
|
let start = null;
|
|
box.addEventListener('touchstart', e => {
|
|
if (bar.hidden || e.touches.length !== 1) { start = null; return; }
|
|
start = { x: e.touches[0].clientX, y: e.touches[0].clientY, left: customHScroll.left, lock: null };
|
|
}, { passive: true });
|
|
box.addEventListener('touchmove', e => {
|
|
if (!start) return;
|
|
const dx = e.touches[0].clientX - start.x, dy = e.touches[0].clientY - start.y;
|
|
if (!start.lock && Math.hypot(dx, dy) > 8) start.lock = Math.abs(dx) > Math.abs(dy) ? 'x' : 'y';
|
|
if (start.lock === 'x') { e.preventDefault(); customHScroll.set(start.left - dx); }
|
|
}, { passive: false });
|
|
box.addEventListener('touchend', () => { start = null; }, { passive: true });
|
|
}
|
|
if (cardsQuery.matches || !table.offsetParent) {
|
|
bar.hidden = true;
|
|
if (host.style.transform) host.style.transform = '';
|
|
return;
|
|
}
|
|
const full = Math.ceil(table.getBoundingClientRect().width);
|
|
const view = box.clientWidth;
|
|
const max = Math.max(0, full - view);
|
|
const hide = max <= 1;
|
|
if (bar.hidden !== hide) bar.hidden = hide;
|
|
customHScroll.max = hide ? 0 : max;
|
|
customHScroll.set(customHScroll.left);
|
|
}
|
|
|
|
// Auto-sizing text boxes (Workbench stacks) only grow while typing in the original;
|
|
// size them to their contents whenever they're shown or re-rendered too.
|
|
function autosizeTextareas() {
|
|
document.querySelectorAll('textarea.auto-resize').forEach(t => {
|
|
if (!t.offsetParent || document.activeElement === t) return;
|
|
if (Math.abs(t.scrollHeight - t.clientHeight) <= 2) return;
|
|
t.style.height = 'auto';
|
|
t.style.height = t.scrollHeight + 'px';
|
|
});
|
|
}
|
|
|
|
// Custom View column drag (desktop). Grab a column by its grip or anywhere on its header
|
|
// (a plain click still sorts). While dragging, the real column slides into the place it
|
|
// would land, drawn as a translucent ghost, and a label follows the pointer. Only the
|
|
// pointer's horizontal position matters: passing a column's middle moves the ghost past
|
|
// it. Release to drop, Esc to cancel. Replaces the browser's native drag-and-drop.
|
|
const colDrag = { active: null, pendingRender: false };
|
|
|
|
function setupColumnDrag() {
|
|
const table = document.getElementById('table-custom');
|
|
if (!table || table.dataset.pdrag) return;
|
|
table.dataset.pdrag = '1';
|
|
|
|
// Native drag-and-drop on the grips is replaced by the pointer drag below
|
|
table.addEventListener('dragstart', e => { if (e.target.closest && e.target.closest('thead')) e.preventDefault(); }, true);
|
|
|
|
table.addEventListener('pointerdown', e => {
|
|
if (e.button !== 0 || cardsQuery.matches || colDrag.active) return;
|
|
const th = e.target.closest('thead th.custom-th');
|
|
if (!th || e.target.closest('.th-icons') || e.target.closest('.col-resize-handle')) return;
|
|
const headers = Array.from(th.parentElement.cells);
|
|
const from = headers.indexOf(th);
|
|
if (from < 0) return;
|
|
const st = { th, from, pos: from, x0: e.clientX, y0: e.clientY, started: false, cells: null, float: null };
|
|
colDrag.active = st;
|
|
const move = ev => onDragMove(ev, st, table);
|
|
const up = ev => { finish(ev, st, table, false); };
|
|
const key = ev => { if (ev.key === 'Escape' && st.started) finish(ev, st, table, true); };
|
|
st.cleanup = () => {
|
|
window.removeEventListener('pointermove', move);
|
|
window.removeEventListener('pointerup', up);
|
|
window.removeEventListener('pointercancel', up);
|
|
window.removeEventListener('keydown', key, true);
|
|
};
|
|
window.addEventListener('pointermove', move);
|
|
window.addEventListener('pointerup', up);
|
|
window.addEventListener('pointercancel', up);
|
|
window.addEventListener('keydown', key, true);
|
|
});
|
|
}
|
|
|
|
function onDragMove(e, st, table) {
|
|
if (!st.started) {
|
|
if (Math.hypot(e.clientX - st.x0, e.clientY - st.y0) < 6) return;
|
|
st.started = true;
|
|
st.cells = Array.from(table.rows).map(r => r.cells[st.from]).filter(Boolean);
|
|
st.cells.forEach(c => c.classList.add('col-ghost'));
|
|
st.float = document.createElement('div');
|
|
st.float.className = 'col-drag-float';
|
|
const label = st.th.querySelector('.label-text');
|
|
st.float.textContent = label ? label.textContent : st.th.textContent.trim();
|
|
document.body.appendChild(st.float);
|
|
document.body.classList.add('col-dragging');
|
|
const overlay = document.getElementById('custom-resize-overlay');
|
|
if (overlay) overlay.style.visibility = 'hidden';
|
|
}
|
|
e.preventDefault();
|
|
st.float.style.left = e.clientX + 'px';
|
|
st.float.style.top = e.clientY + 'px';
|
|
|
|
// Nudge the sideways scrollbar when dragging near the edges of a wide table
|
|
const bar = document.getElementById('custom-hscroll');
|
|
const box = document.getElementById('scroll-custom');
|
|
if (bar && !bar.hidden && box) {
|
|
const r = box.getBoundingClientRect();
|
|
if (e.clientX < r.left + 40) customHScroll.by(-18);
|
|
else if (e.clientX > r.right - 40) customHScroll.by(18);
|
|
}
|
|
|
|
// Target slot = number of other columns whose middle is left of the pointer
|
|
const headerCells = Array.from(st.th.parentElement.cells).filter(c => c !== st.th);
|
|
let pos = 0;
|
|
for (const c of headerCells) { const b = c.getBoundingClientRect(); if (e.clientX > b.left + b.width / 2) pos++; }
|
|
if (pos === st.pos) return;
|
|
st.pos = pos;
|
|
Array.from(table.rows).forEach((row, i) => {
|
|
const cell = st.cells[i];
|
|
if (!cell || cell.parentElement !== row) return;
|
|
const others = Array.from(row.cells).filter(c => c !== cell);
|
|
row.insertBefore(cell, others[pos] || null);
|
|
});
|
|
}
|
|
|
|
function finish(e, st, table, cancelled) {
|
|
st.cleanup();
|
|
colDrag.active = null;
|
|
if (!st.started) return; // a click: let it sort as usual
|
|
// swallow the click that follows a drag so it doesn't also sort
|
|
const stop = ev => { ev.stopPropagation(); ev.preventDefault(); };
|
|
window.addEventListener('click', stop, true);
|
|
setTimeout(() => window.removeEventListener('click', stop, true), 0);
|
|
|
|
st.cells.forEach(c => c.classList.remove('col-ghost'));
|
|
if (st.float) st.float.remove();
|
|
document.body.classList.remove('col-dragging');
|
|
const overlay = document.getElementById('custom-resize-overlay');
|
|
if (overlay) overlay.style.visibility = '';
|
|
|
|
if (!cancelled && st.pos !== st.from) moveCustomColumn(st.from, st.pos); // re-renders in the new order
|
|
else if (st.pos !== st.from || colDrag.pendingRender) renderCustomTable(); // put cells back
|
|
colDrag.pendingRender = false;
|
|
}
|
|
|
|
let queued = false;
|
|
function run() {
|
|
queued = false;
|
|
updateSticky();
|
|
autosizeTextareas();
|
|
if (!cardsQuery.matches) return;
|
|
document.querySelectorAll('table.master-table').forEach(labelTable);
|
|
}
|
|
function schedule() {
|
|
if (queued) return;
|
|
queued = true;
|
|
requestAnimationFrame(run);
|
|
}
|
|
|
|
function start() {
|
|
new MutationObserver(schedule).observe(document.body, { childList: true, subtree: true });
|
|
if (cardsQuery.addEventListener) cardsQuery.addEventListener('change', schedule);
|
|
else cardsQuery.addListener(schedule);
|
|
window.addEventListener('resize', schedule);
|
|
// Groups and pages are shown by toggling display (not a DOM change), so also
|
|
// re-measure as the user scrolls; writes only happen when a value changes.
|
|
window.addEventListener('scroll', schedule, { passive: true });
|
|
// Custom View column drag; a data refresh mid-drag waits until the drop
|
|
setupColumnDrag();
|
|
if (typeof window.renderCustomTable === 'function') {
|
|
const origRender = window.renderCustomTable;
|
|
window.renderCustomTable = function () {
|
|
if (colDrag.active && colDrag.active.started) { colDrag.pendingRender = true; return; }
|
|
return origRender.apply(this, arguments);
|
|
};
|
|
}
|
|
// Page switches show and hide pages without changing the DOM
|
|
if (typeof window.switchPage === 'function') {
|
|
const orig = window.switchPage;
|
|
window.switchPage = function () { const r = orig.apply(this, arguments); schedule(); return r; };
|
|
}
|
|
// The terminal dock opens, closes and resizes without a page resize
|
|
if (window.ResizeObserver) {
|
|
const watchDock = () => {
|
|
const dock = document.getElementById('usmt-term');
|
|
if (dock) new ResizeObserver(schedule).observe(dock); else setTimeout(watchDock, 500);
|
|
};
|
|
watchDock();
|
|
}
|
|
schedule();
|
|
}
|
|
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', start);
|
|
else start();
|
|
})();
|