Presence, AD/SCCM/DNS reasons, monitors, snapshots, new-device priority
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.
This commit is contained in:
1 parent
50c2a18a68
commit
7868948bfc
10 files changed
+1230
-356
No files matched your search
+34
-13
@@ -342,9 +342,9 @@
|
||||
return { tagMap, manualPairs, skipTags };
|
||||
}
|
||||
|
||||
function blankRow(src, dst, tags) {
|
||||
function blankRow(src, dst, tags, pairing) {
|
||||
return {
|
||||
"Source Device": src, "Destination Device": dst, "Tags": tags,
|
||||
"Source Device": src, "Destination Device": dst, "Tags": tags, "Pairing": pairing,
|
||||
"Source Total Space": "N/A", "Source Free Space": "N/A", "USMT Status": "N/A", "Backup Status": "N/A",
|
||||
"Source Online": "N/A", "Destination Online": "N/A",
|
||||
"Source Scanstate Line": "N/A", "Destination Loadstate Line": "N/A",
|
||||
@@ -376,9 +376,17 @@
|
||||
if (a.IP && a.IP !== 'N/A') { const p = a.IP.split('.'); a.IP = `${p[0]}.${p[1]}.${10 + Math.floor(rand(name, 'ip3') * 240)}.${10 + Math.floor(rand(name, 'ip4') * 240)}`; }
|
||||
if (a.Disk) a.Disk = a.Disk.replace(/S\/N: [^<]*$/, 'S/N: ' + hex(name + 'sn', 8).join('').replace(/(.{4})/g, '$1_').replace(/_$/, '.'));
|
||||
for (const k of ['LastUser', 'SCCMPrimaryUsers']) if (typeof a[k] === 'string') a[k] = renameIn(a[k], templateName, name).replace(templateName.toLowerCase(), name.toLowerCase());
|
||||
a.FirstChecked = new Date().toISOString();
|
||||
return a;
|
||||
}
|
||||
|
||||
// What the worker records for a name nothing knows (a typo): not in AD, SCCM or DNS
|
||||
function unknownAdv() {
|
||||
const now = new Date().toISOString();
|
||||
return { IP: "N/A", MAC: "N/A", OU: "N/A", Groups: "N/A", Model: "N/A", RAM: "N/A", Disk: "N/A", ClientStatus: "N/A", MonitorCount: "N/A", Monitors: "N/A",
|
||||
RoomType: "N/A", InAD: false, InSCCM: false, InDNS: false, Fetched: now, LastChecked: now, LastPing: "Not Found", FirstChecked: now, LastOnline: null };
|
||||
}
|
||||
|
||||
// Builds the Data.json rows the worker would produce for a roster, plus synthetic
|
||||
// advanced/software entries for devices the fleet doesn't know.
|
||||
function buildRows(tracked, serverRows, serverAdv) {
|
||||
@@ -393,7 +401,7 @@
|
||||
const srow = bySrc[dev];
|
||||
|
||||
if (tagMap[dev].some(t => skipTags.has(t))) {
|
||||
const r = blankRow(dev, "N/A", tags);
|
||||
const r = blankRow(dev, "N/A", tags, "Skipped");
|
||||
Object.assign(r, { "USMT Status": "Skipped", "Backup Status": "Skipped", "Source Online": "Skipped", "Destination Online": "-" });
|
||||
out.push(r);
|
||||
if (!serverAdv[dev]) { const t = pickTemplate(dev, serverRows); if (t && SCHEME.test(dev)) { adv[dev] = synthAdv(dev, t["Source Device"], serverAdv); software[dev] = t["Source Device"]; } }
|
||||
@@ -403,12 +411,14 @@
|
||||
if (manualPairs[dev]) {
|
||||
const dst = manualPairs[dev];
|
||||
if (srow && srow["Destination Device"] === dst && srow["USMT Status"] === "N/A") { out.push(Object.assign(clone(srow), { Tags: tags })); continue; }
|
||||
const r = blankRow(dev, dst, tags);
|
||||
for (const [n, col] of [[dev, "Source Online"], [dst, "Destination Online"]]) {
|
||||
const r = blankRow(dev, dst, tags, "Manual");
|
||||
const now = new Date().toISOString();
|
||||
for (const [n, col] of [[dev, "Source"], [dst, "Destination"]]) {
|
||||
const t = SCHEME.test(n) ? pickTemplate(n, serverRows) : null;
|
||||
r[col] = t ? t["Source Online"] : "Not Found";
|
||||
r[col + " Online"] = t ? t["Source Online"] : "Not Found";
|
||||
r[col + " Checked"] = now;
|
||||
if (t && !serverAdv[n]) { adv[n] = synthAdv(n, t["Source Device"], serverAdv); software[n] = t["Source Device"]; }
|
||||
else if (!t) software[n] = null;
|
||||
else if (!t) { software[n] = null; if (!serverAdv[n]) adv[n] = unknownAdv(); }
|
||||
}
|
||||
out.push(r);
|
||||
continue;
|
||||
@@ -420,11 +430,12 @@
|
||||
const t = SCHEME.test(dev) ? pickTemplate(dev, serverRows) : null;
|
||||
if (!t) {
|
||||
// Not in the fleet and not a valid name: what the worker reports for a typo
|
||||
const r = blankRow(dev, "N/A", tags);
|
||||
const r = blankRow(dev, "N/A", tags, "None");
|
||||
Object.assign(r, { "USMT Status": "Not Associated", "Backup Status": "Not Associated", "Source Online": "Not Found", "Destination Online": "-",
|
||||
"Source Scanstate Line": "Unreachable (Not Found)", "Source Scanstate File": "Unreachable" });
|
||||
"Source Scanstate Line": "Unreachable (Not Found)", "Source Scanstate File": "Unreachable", "Source Checked": new Date().toISOString() });
|
||||
out.push(r);
|
||||
software[dev] = null; // SCCM has no inventory for it: an empty list, same as Get-Software.ps1
|
||||
if (!serverAdv[dev]) adv[dev] = unknownAdv();
|
||||
continue;
|
||||
}
|
||||
const tSrc = t["Source Device"], tDst = t["Destination Device"], dst = synthDest(dev);
|
||||
@@ -506,7 +517,7 @@
|
||||
return W;
|
||||
}
|
||||
add([' -> Querying SCCM for associations...', 'DarkGray']);
|
||||
if (r["Source Online"] === "Cached") {
|
||||
if (r["Pairing"] === "Cached" || r["Source Online"] === "Cached") {
|
||||
add([' -> SCCM DB Status: Completed (Raw: 2)', 'DarkGray']);
|
||||
add([' -> [CACHE HIT] Fast-tracking row and bypassing network sweeps.', 'Green']);
|
||||
return W;
|
||||
@@ -564,7 +575,7 @@
|
||||
const before = new Set(Object.keys(parseRoster(applied).tagMap));
|
||||
const { rows } = buildRows(tracked, snap.rows, snap.adv);
|
||||
const { tagMap } = parseRoster(tracked);
|
||||
const total = Object.keys(tagMap).length;
|
||||
const total = rows.length; // source devices only, like the worker
|
||||
const started = new Date();
|
||||
const state = (status, cur) => { Sim.worker = { Status: status, Current: cur, Total: total }; };
|
||||
|
||||
@@ -582,11 +593,14 @@
|
||||
}
|
||||
w([['[Software Cache Synchronized]', 'Green']]);
|
||||
w([]);
|
||||
w([[`--- Commencing Data Sweep (${total} Devices) ---`, 'White']]);
|
||||
// Devices the visitor just added are swept first, like the worker does for new devices
|
||||
const fresh = rows.filter(r => !before.has(r["Source Device"]));
|
||||
const ordered = fresh.concat(rows.filter(r => before.has(r["Source Device"])));
|
||||
w([[`--- Commencing Data Sweep (${total} Devices${fresh.length ? `, ${fresh.length} new first` : ''}) ---`, 'White']]);
|
||||
|
||||
const delay = Math.max(15, Math.min(90, 9000 / Math.max(1, rows.length)));
|
||||
let i = 0;
|
||||
for (const r of rows) {
|
||||
for (const r of ordered) {
|
||||
i++;
|
||||
state('Updating Devices...', i);
|
||||
const src = r["Source Device"];
|
||||
@@ -594,6 +608,13 @@
|
||||
w([[` -> Tags: [${r.Tags}]`, 'DarkCyan']]);
|
||||
for (const segs of deviceLines(r, !before.has(src), full)) w(segs);
|
||||
await sleep(delay);
|
||||
if (fresh.length && i === fresh.length && i < ordered.length) {
|
||||
w([[` -> [PRIORITY] ${fresh.length} new device(s) swept. Publishing early...`, 'Green']]);
|
||||
if (!s.tracked) await ensureOwnRoster();
|
||||
s.appliedTracked = clone(tracked);
|
||||
s.rev++;
|
||||
Store.save();
|
||||
}
|
||||
}
|
||||
|
||||
w([[' -> Pruning and Compressing Software Cache...', 'DarkGray']]);
|
||||
|
||||
@@ -0,0 +1,242 @@
|
||||
/*
|
||||
* USMT Dash - device info helpers
|
||||
*
|
||||
* Shared by the details dialog, Custom View, the tables and the Overview page:
|
||||
* - Advanced fields say WHY a value is missing ("Not in AD", "Not in SCCM", "No DNS record")
|
||||
* instead of a bare N/A, and "Waiting for worker..." when the worker hasn't looked yet.
|
||||
* - Presence: last time a device answered a ping and how long it has been offline.
|
||||
* - Snapshot markers: values the worker saved while a device was reachable are shown with
|
||||
* a clock icon and the time they were captured.
|
||||
*/
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
const WAITING = 'Waiting for worker...';
|
||||
const AD_FIELDS = new Set(['OU', 'Groups']);
|
||||
// Fields computed here rather than stored by the worker (also offered as Custom View columns)
|
||||
const COMPUTED = {
|
||||
DaysOffline: 'Days Offline',
|
||||
LastOnline: 'Last Online',
|
||||
LastChecked: 'Last Ping Check',
|
||||
FoundIn: 'Found In (AD / SCCM / DNS)'
|
||||
};
|
||||
// Internal bookkeeping fields that aren't useful as columns
|
||||
const HIDDEN = new Set(['InAD', 'InSCCM', 'InDNS', 'Fetched', 'LastPing', 'FirstChecked', 'LastOnlineSource']);
|
||||
|
||||
const cache = () => (typeof advancedDataCache !== 'undefined' && advancedDataCache) ? advancedDataCache : {};
|
||||
const isName = n => !!n && n !== 'N/A' && n !== '---';
|
||||
const empty = v => v === undefined || v === null || v === '' || v === 'N/A';
|
||||
|
||||
function entry(name) {
|
||||
if (!isName(name)) return null;
|
||||
const c = cache();
|
||||
return c[name] || c[String(name).toUpperCase()] || null;
|
||||
}
|
||||
|
||||
function toDate(iso) {
|
||||
if (!iso) return null;
|
||||
const d = new Date(iso);
|
||||
return isNaN(d) ? null : d;
|
||||
}
|
||||
|
||||
function fmtWhen(iso) {
|
||||
const d = toDate(iso);
|
||||
if (!d) return '';
|
||||
const sameYear = d.getFullYear() === new Date().getFullYear();
|
||||
return d.toLocaleString(undefined, { month: 'short', day: 'numeric', year: sameYear ? undefined : 'numeric', hour: 'numeric', minute: '2-digit' });
|
||||
}
|
||||
|
||||
function ago(iso) {
|
||||
const d = toDate(iso);
|
||||
if (!d) return '';
|
||||
const s = Math.max(0, (Date.now() - d.getTime()) / 1000);
|
||||
if (s < 60) return 'just now';
|
||||
if (s < 3600) return Math.floor(s / 60) + ' min ago';
|
||||
if (s < 86400) return Math.floor(s / 3600) + ' h ago';
|
||||
const days = Math.floor(s / 86400);
|
||||
return days + (days === 1 ? ' day ago' : ' days ago');
|
||||
}
|
||||
|
||||
// Why a field is empty, from the worker's lookup flags (null = no particular reason)
|
||||
function missingReason(a, key) {
|
||||
const noAD = a.InAD === false, noSCCM = a.InSCCM === false, noDNS = a.InDNS === false;
|
||||
if (noAD && noSCCM && noDNS) return 'Not in AD, SCCM or DNS';
|
||||
if (AD_FIELDS.has(key)) return noAD ? 'Not in AD' : null;
|
||||
if (key === 'IP') {
|
||||
if (noDNS && noSCCM) return 'No DNS record, not in SCCM';
|
||||
if (noDNS) return 'No DNS record';
|
||||
return null;
|
||||
}
|
||||
return noSCCM ? 'Not in SCCM' : null;
|
||||
}
|
||||
|
||||
// { text, state } where state is value | waiting | missing | na
|
||||
function field(name, key) {
|
||||
if (!isName(name)) return { text: 'N/A', state: 'na' };
|
||||
if (COMPUTED[key]) return computed(name, key);
|
||||
const a = entry(name);
|
||||
if (!a) return { text: WAITING, state: 'waiting' };
|
||||
const v = a[key];
|
||||
// the worker's default room type isn't real data when SCCM has no record
|
||||
if (key === 'RoomType' && a.InSCCM === false && v === 'Standard') return { text: missingReason(a, key), state: 'missing' };
|
||||
if (!empty(v)) return { text: String(v), state: 'value' };
|
||||
const why = missingReason(a, key);
|
||||
return why ? { text: why, state: 'missing' } : { text: 'N/A', state: 'na' };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------
|
||||
// Presence
|
||||
// ---------------------------------------------------------------------------------
|
||||
function presence(name) {
|
||||
const a = entry(name);
|
||||
if (!a || !a.LastChecked) return null;
|
||||
const last = toDate(a.LastOnline);
|
||||
const online = a.LastPing === 'Online';
|
||||
const days = online ? 0 : (last ? (Date.now() - last.getTime()) / 86400000 : null);
|
||||
return { online, last, lastIso: a.LastOnline, checked: a.LastChecked, ping: a.LastPing, first: a.FirstChecked, days, source: a.LastOnlineSource };
|
||||
}
|
||||
|
||||
// Whole days offline (0 when online or offline less than a day), null if never seen
|
||||
function daysOffline(name) {
|
||||
const p = presence(name);
|
||||
if (!p) return undefined;
|
||||
if (p.online) return 0;
|
||||
if (p.days === null) return null;
|
||||
return Math.floor(p.days);
|
||||
}
|
||||
|
||||
function offlineText(name) {
|
||||
if (!isName(name)) return 'N/A';
|
||||
const p = presence(name);
|
||||
if (!p) return WAITING;
|
||||
if (p.online) return 'Online now';
|
||||
if (!p.last) return 'Never seen online (tracked since ' + fmtWhen(p.first) + ')';
|
||||
const hours = (Date.now() - p.last.getTime()) / 3600000;
|
||||
if (hours < 1) return 'Offline less than an hour';
|
||||
if (hours < 24) return 'Offline ' + Math.floor(hours) + ' h';
|
||||
const d = Math.floor(hours / 24);
|
||||
return d + (d === 1 ? ' day' : ' days') + ' offline';
|
||||
}
|
||||
|
||||
function computed(name, key) {
|
||||
const a = entry(name);
|
||||
if (!a) return { text: WAITING, state: 'waiting' };
|
||||
if (key === 'DaysOffline') {
|
||||
const d = daysOffline(name);
|
||||
if (d === undefined) return { text: WAITING, state: 'waiting' };
|
||||
if (d === null) return { text: 'Never seen', state: 'value' };
|
||||
if (d === 0) return { text: presence(name).online ? 'Online' : '0 days', state: 'value' };
|
||||
return { text: d + (d === 1 ? ' day' : ' days'), state: 'value' };
|
||||
}
|
||||
if (key === 'LastOnline') {
|
||||
const p = presence(name);
|
||||
if (!p) return { text: WAITING, state: 'waiting' };
|
||||
if (p.online) return { text: 'Online now', state: 'value' };
|
||||
return p.last ? { text: fmtWhen(p.lastIso), state: 'value' } : { text: 'Never seen', state: 'value' };
|
||||
}
|
||||
if (key === 'LastChecked') {
|
||||
return a.LastChecked ? { text: fmtWhen(a.LastChecked) + ' (' + (a.LastPing || '?') + ')', state: 'value' } : { text: WAITING, state: 'waiting' };
|
||||
}
|
||||
if (key === 'FoundIn') {
|
||||
if (a.InAD === undefined && a.InSCCM === undefined && a.InDNS === undefined) return { text: WAITING, state: 'waiting' };
|
||||
const yes = [], no = [];
|
||||
[['AD', a.InAD], ['SCCM', a.InSCCM], ['DNS', a.InDNS]].forEach(([n, v]) => { if (v === true) yes.push(n); else if (v === false) no.push(n); });
|
||||
if (!no.length) return { text: 'AD, SCCM, DNS', state: 'value' };
|
||||
return { text: 'Missing from ' + no.join(', '), state: 'missing' };
|
||||
}
|
||||
return { text: 'N/A', state: 'na' };
|
||||
}
|
||||
|
||||
// True when AD, SCCM and DNS all said "never heard of it" (not imaged yet, or a typo)
|
||||
function unknownEverywhere(name) {
|
||||
const a = entry(name);
|
||||
return !!a && a.InAD === false && a.InSCCM === false && a.InDNS === false;
|
||||
}
|
||||
|
||||
// HTML for a details-dialog cell
|
||||
function html(name, key) {
|
||||
const f = field(name, key);
|
||||
if (f.state === 'waiting') return `<span class="di-waiting">${f.text}</span>`;
|
||||
if (f.state === 'missing') return `<span class="di-missing">${f.text}</span>`;
|
||||
return f.text;
|
||||
}
|
||||
|
||||
function presenceHtml(name) {
|
||||
if (!isName(name)) return 'N/A';
|
||||
const p = presence(name);
|
||||
if (!p) return `<span class="di-waiting">${WAITING}</span>`;
|
||||
const txt = offlineText(name);
|
||||
const cls = p.online ? 'di-online' : (p.days !== null && p.days >= 3 ? 'di-offline-long' : 'di-offline');
|
||||
let sub = '';
|
||||
if (!p.online && p.last) sub = `Last online ${fmtWhen(p.lastIso)}${p.source === 'Inventory' ? ' (from client activity)' : ''}<br>`;
|
||||
sub += `Last checked ${fmtWhen(p.checked)} (${ago(p.checked)})`;
|
||||
return `<b class="${cls}">${txt}</b><br><span class="di-sub">${sub}</span>`;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------
|
||||
// Snapshot / staleness markers
|
||||
// ---------------------------------------------------------------------------------
|
||||
const CLOCK = '<svg viewBox="0 0 16 16" aria-hidden="true"><circle cx="8" cy="8" r="6.5" fill="none" stroke="currentColor" stroke-width="1.6"/><path d="M8 4.5V8l2.5 1.6" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/></svg>';
|
||||
|
||||
// side: 'Source' | 'Destination'
|
||||
function snapshotMark(row, side) {
|
||||
const at = row && row[side + ' Snapshot'];
|
||||
if (!at) return '';
|
||||
const why = row['Backup Status'] === 'Completed' ? 'migration completed' : 'device unreachable';
|
||||
return ` <span class="snap-mark" title="Last captured ${fmtWhen(at)} (${why})">${CLOCK}</span>`;
|
||||
}
|
||||
|
||||
// Ping results older than 15 minutes (completed pairs are only re-pinged by the presence sweep)
|
||||
function pingMark(row, side) {
|
||||
const at = row && row[side + ' Checked'];
|
||||
const d = toDate(at);
|
||||
if (!d || Date.now() - d.getTime() < 15 * 60000) return '';
|
||||
return ` <span class="snap-mark" title="Checked ${fmtWhen(at)}">${CLOCK}</span>`;
|
||||
}
|
||||
|
||||
function snapshotLine(row, side) {
|
||||
const at = row && row[side + ' Snapshot'];
|
||||
if (!at) return '';
|
||||
return `<br><span class="di-sub">${CLOCK.replace('<svg', '<svg class="di-inline-clock"')} Snapshot from ${fmtWhen(at)} (${ago(at)})</span>`;
|
||||
}
|
||||
|
||||
// Software inventory timestamp for a device (Software-Cache.json entries carry one)
|
||||
window.DeviceInfo = {
|
||||
WAITING, COMPUTED, HIDDEN,
|
||||
entry, field, html, unknownEverywhere, presence, presenceHtml, daysOffline, offlineText,
|
||||
snapshotMark, snapshotLine, pingMark, fmtWhen, ago, isName
|
||||
};
|
||||
})();
|
||||
|
||||
// Data Synchronization dialog: per-type timing of the last cycle (SCCM associations vs
|
||||
// manual pairs vs completed snapshots...), the phases, and the presence sweep schedule.
|
||||
(function () {
|
||||
'use strict';
|
||||
const esc = v => String(v).replace(/[&<>"]/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c]));
|
||||
const secs = s => (s >= 60 ? (s / 60).toFixed(1) + ' min' : (+s).toFixed(1) + ' s');
|
||||
const per = ms => (ms >= 1000 ? (ms / 1000).toFixed(1) + ' s' : ms + ' ms');
|
||||
const HINT = {
|
||||
SCCM: 'Association lookup, ping + disk + log on both devices',
|
||||
Manual: 'No SCCM lookup: ping both devices only',
|
||||
Cached: 'Completed: shown from its snapshot, no network checks',
|
||||
NoAssoc: 'No association: source checked on its own',
|
||||
Skipped: 'Inventory only, no network checks'
|
||||
};
|
||||
window.DeviceInfo.syncBreakdownHtml = function (state) {
|
||||
const c = state && state.LastCycle;
|
||||
if (!c || !Array.isArray(c.Categories)) return '';
|
||||
const rows = c.Categories.map(x => `<tr title="${esc(HINT[x.Key] || '')}"><td>${esc(x.Label)}</td><td>${x.Count}</td><td>${secs(x.Seconds)}</td><td>${per(x.AvgMs)}</td></tr>`).join('');
|
||||
const ph = c.Phases || {};
|
||||
const ps = c.PresenceSweep || {};
|
||||
let presence = '';
|
||||
if (ps.Last) {
|
||||
const next = new Date(new Date(ps.Last).getTime() + (ps.EveryMinutes || 60) * 60000);
|
||||
presence = `Presence sweep: last ${DeviceInfo.fmtWhen(ps.Last)}${ps.Ran ? ` (pinged ${ps.Pinged} more)` : ''}, next after ${next.toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit' })}`;
|
||||
}
|
||||
return `
|
||||
<div class="sync-bd-title">Last cycle by type <span>${[c.NewFirst ? `${c.NewFirst} new device(s) swept first` : '', c.FullRefresh ? 'full refresh' : ''].filter(Boolean).join(' · ')}</span></div>
|
||||
<table class="sync-bd"><thead><tr><th>Type</th><th>Devices</th><th>Time</th><th>Avg</th></tr></thead><tbody>${rows}</tbody></table>
|
||||
<div class="sync-bd-phases">Software ${secs(ph.Software || 0)} · Sweep ${secs(ph.Sweep || 0)} · Presence ${secs(ph.Presence || 0)} · Save ${secs(ph.Save || 0)}</div>
|
||||
${presence ? `<div class="sync-bd-phases">${presence}</div>` : ''}`;
|
||||
};
|
||||
})();
|
||||
+9
-5
@@ -7,8 +7,8 @@
|
||||
* The value list only shows values from rows that pass the OTHER columns' filters, filters
|
||||
* on several columns combine (AND), and a filtered column's button shows a funnel.
|
||||
* List-type columns (Tags, Missing/Specialty Software) are split so each tag or app is
|
||||
* its own checkbox; a row matches if it has ANY of the ticked items, or with "Has only
|
||||
* ticked" if it has nothing BUT ticked items. Free-space columns ("78 GB / 221 GB") filter
|
||||
* its own checkbox; a row matches if it has ANY of the ticked items, or with "Exactly
|
||||
* ticked" if its items are exactly the ticked set (no more, no fewer). Free-space columns ("78 GB / 221 GB") filter
|
||||
* by disk size plus a free-space threshold, and sort by disk size or by free space.
|
||||
*
|
||||
* It plugs into the dashboard's existing filter passes (applyFilters, filterSoftwareTab,
|
||||
@@ -166,7 +166,11 @@
|
||||
if (allowed.free.op === 'ge' ? sp.free < allowed.free.gb : sp.free >= allowed.free.gb) return false;
|
||||
}
|
||||
const vals = cellValues(td, col);
|
||||
if (col.list && allowed.only) { if (!vals.every(v => allowed.has(v))) return false; }
|
||||
if (col.list && allowed.only) {
|
||||
// exact set: the row's items are precisely the ticked ones, no more, no fewer
|
||||
const own = new Set(vals);
|
||||
if (own.size !== allowed.size || !vals.every(v => allowed.has(v))) return false;
|
||||
}
|
||||
else if (!vals.some(v => allowed.has(v))) return false;
|
||||
}
|
||||
return true;
|
||||
@@ -472,7 +476,7 @@
|
||||
let onlyToggle = null;
|
||||
if (col.list) {
|
||||
const seg = el('div', 'xf-seg');
|
||||
seg.title = 'Any: devices with at least one ticked item. Only: devices with nothing except ticked items.';
|
||||
seg.title = 'Any: devices with at least one ticked item. Exactly: devices whose list is exactly the ticked items, nothing more and nothing less.';
|
||||
const mk = (text, only) => {
|
||||
const b = el('button', 'xf-seg-btn', text); b.type = 'button';
|
||||
b.onclick = () => { seg.querySelectorAll('.xf-seg-btn').forEach(x => x.classList.toggle('on', x === b)); };
|
||||
@@ -481,7 +485,7 @@
|
||||
return b;
|
||||
};
|
||||
mk('Has any ticked', false);
|
||||
onlyToggle = mk('Has only ticked', true);
|
||||
onlyToggle = mk('Exactly ticked', true);
|
||||
menu.appendChild(seg);
|
||||
}
|
||||
|
||||
|
||||
+198
-100
@@ -196,11 +196,11 @@
|
||||
.sort-icon.active { color: white; font-weight: bold; }
|
||||
|
||||
/* ===== Custom View responsive table ===== */
|
||||
.custom-scroll-area { overflow: auto; }
|
||||
#custom { min-width: 0; max-width: 100%; width: 100%; overflow-x: hidden; box-sizing: border-box; }
|
||||
#custom * { min-width: 0; }
|
||||
#wrapper-custom { min-width: 0; max-width: 100%; width: 100%; overflow: hidden; box-sizing: border-box; }
|
||||
#scroll-custom { max-width: 100%; overflow: auto; max-height: calc(100vh - 60px - 50px - 210px); }
|
||||
.custom-scroll-area { overflow: auto; }
|
||||
#custom { min-width: 0; max-width: 100%; width: 100%; overflow-x: hidden; box-sizing: border-box; }
|
||||
#custom * { min-width: 0; }
|
||||
#wrapper-custom { min-width: 0; max-width: 100%; width: 100%; overflow: hidden; box-sizing: border-box; }
|
||||
#scroll-custom { max-width: 100%; overflow: auto; max-height: calc(100vh - 60px - 50px - 210px); }
|
||||
#custom-table-host { max-width: none; }
|
||||
#wrapper-custom.has-extra .custom-scroll-area thead th { position: sticky; top: 0; z-index: 20; box-shadow: inset 0 -2px 0 var(--border-light); }
|
||||
/* Header: bordered drag-grip strip on top; label left; icons grouped far right */
|
||||
@@ -226,10 +226,10 @@
|
||||
#table-custom .custom-th .col-remove { color: var(--danger, #e74c3c); font-weight: bold; cursor: pointer; opacity: 0; transition: opacity 0.15s; font-size: 1em; }
|
||||
#table-custom .custom-th:hover .col-remove { opacity: 1; }
|
||||
/* Full-height resize handles (overlay), active anywhere along the column border */
|
||||
#custom-resize-overlay { z-index: 40; }
|
||||
#custom-resize-overlay { z-index: 40; }
|
||||
#custom-resize-overlay .rz-handle { position: absolute; top: 0; width: 9px; height: 100%; cursor: col-resize; pointer-events: auto; transform: translateX(-50%); z-index: 40; }
|
||||
#custom-resize-overlay .rz-handle::after { content: ""; position: absolute; left: 50%; top: 0; width: 1px; height: 100%; background: transparent; transform: translateX(-50%); }
|
||||
#table-custom .custom-spacer-th, #table-custom .custom-spacer-td { border-right: none !important; border-left: none !important; background: var(--bg-card); }
|
||||
#table-custom .custom-spacer-th, #table-custom .custom-spacer-td { border-right: none !important; border-left: none !important; background: var(--bg-card); }
|
||||
#table-custom thead th.no-copy:not(.custom-th) { border-left: none; }
|
||||
#wrapper-custom.has-extra #table-custom tbody tr:hover { filter: brightness(1.08); }
|
||||
#wrapper-custom.has-extra #table-custom td, #wrapper-custom.has-extra #table-custom th { border-right: 1px solid var(--border-light); }
|
||||
@@ -353,6 +353,7 @@
|
||||
</style>
|
||||
<link rel="stylesheet" href="/layout.css">
|
||||
<script src="/layout.js"></script>
|
||||
<script src="/device-info.js"></script>
|
||||
<script src="/excel-filter.js" defer></script>
|
||||
</head>
|
||||
<body>
|
||||
@@ -662,6 +663,7 @@
|
||||
|
||||
<div class="sync-details" id="syncDetailsText">Waiting for server response...</div>
|
||||
<div class="sync-details" id="syncDurationText" style="color: var(--text-main); font-style: italic; margin-top: 5px;">Last Cycle Duration: N/A</div>
|
||||
<div id="syncBreakdown" class="sync-breakdown"></div>
|
||||
|
||||
<button class="force-btn" id="forceSyncBtn" onclick="forceUpdate()">Force Update Now</button>
|
||||
<button class="force-btn" id="forceFullBtn" onclick="forceFullRefresh()" style="margin-top: 10px;">Full Software/Advanced Refresh</button>
|
||||
@@ -2607,6 +2609,7 @@
|
||||
}
|
||||
|
||||
async function fetchSoftware(name) {
|
||||
window.currentSoftwareDevice = name;
|
||||
document.getElementById('modal').style.display = 'block';
|
||||
document.getElementById('modalTitle').innerHTML = `Software: ${name} ${inlineCopyBtn(name)}`;
|
||||
|
||||
@@ -2641,7 +2644,7 @@
|
||||
const newJsonString = JSON.stringify(data);
|
||||
|
||||
if (newJsonString !== cachedJsonString) {
|
||||
drawModalTables(data, false, name);
|
||||
if (window.currentSoftwareDevice === name) drawModalTables(data, false, name);
|
||||
softwareCache[name] = data;
|
||||
pullData();
|
||||
}
|
||||
@@ -2789,6 +2792,7 @@
|
||||
if (!match) return;
|
||||
|
||||
window.currentDetailsMatch = match;
|
||||
window.currentDetailsKey = match["Source Device"];
|
||||
document.getElementById('detailsModal').style.display = 'block';
|
||||
renderDetailsHTML();
|
||||
}
|
||||
@@ -2867,25 +2871,48 @@
|
||||
const tdStyle = `style="white-space: normal; word-wrap: break-word; overflow-wrap: break-word; padding: 10px;"`;
|
||||
const scrollBoxStyle = `style="flex: 1; overflow-y: auto; padding: 5px; border: 1px solid var(--border-light); border-radius: 4px;"`;
|
||||
|
||||
let advSrc = advancedDataCache[match["Source Device"]] || {};
|
||||
let advDst = advancedDataCache[match["Destination Device"]] || {};
|
||||
|
||||
const getAdv = (obj, key) => obj[key] ? obj[key] : 'N/A';
|
||||
const srcName = match["Source Device"], dstName = match["Destination Device"];
|
||||
const hasDst = DeviceInfo.isName(dstName);
|
||||
// Advanced fields explain a blank: "Not in AD", "Not in SCCM", "No DNS record", or
|
||||
// "Waiting for worker..." when the worker hasn't looked the device up yet
|
||||
const getAdv = (name, key) => DeviceInfo.html(name, key);
|
||||
const hwSpecs = name => {
|
||||
const m = DeviceInfo.field(name, "Model"), r = DeviceInfo.field(name, "RAM");
|
||||
if (m.state !== "value" && r.state !== "value") return getAdv(name, "Model");
|
||||
return `${getAdv(name, "Model")}<br>${r.state === "value" ? r.text + " RAM" : getAdv(name, "RAM")}`;
|
||||
};
|
||||
const monitorsCell = name => {
|
||||
const c = DeviceInfo.field(name, "MonitorCount");
|
||||
if (c.state !== "value") return getAdv(name, "MonitorCount");
|
||||
const list = DeviceInfo.field(name, "Monitors");
|
||||
return `<b>${c.text}</b>${list.state === "value" && list.text !== "None" ? "<br>" + list.text : ""}`;
|
||||
};
|
||||
const pairing = match["Pairing"] === "Manual" ? "Manual pair (no SCCM association)"
|
||||
: match["Pairing"] === "Skipped" ? "Skip USMT tag (inventory only)"
|
||||
: match["Pairing"] === "None" ? "No SCCM association"
|
||||
: match["Pairing"] === "Cached" ? "SCCM association (completed, network checks skipped)"
|
||||
: match["Pairing"] === "SCCM" ? "SCCM association" : null;
|
||||
const elapsed = e => (e && e !== "N/A" && e !== "Cached") ? '<br>Elapsed Time: ' + e : '';
|
||||
const space = match["Source Free Space"] && match["Source Free Space"] !== "N/A" && match["Source Free Space"] !== "Cached"
|
||||
? match["Source Free Space"] + ' / ' + match["Source Total Space"] : (match["Source Free Space"] || "N/A");
|
||||
|
||||
let mainRows = `
|
||||
<div ${window.isAdvView ? scrollBoxStyle : ''}>
|
||||
<table class="details-table" style="width:100%; border-collapse: collapse; table-layout: fixed;">
|
||||
${window.isAdvView ? `<tr><th colspan="2" style="text-align: center; background: var(--table-header-bg); color: var(--table-header-text);">Migration Overview</th></tr>` : ''}
|
||||
<tr><th width="40%">Source Device</th><td ${tdStyle}><b>${match["Source Device"]}</b> ${inlineCopyBtn(match["Source Device"])} ${srcAppBtn}</td></tr>
|
||||
<tr><th>Source Ping</th><td style="color: ${getPingColor(match["Source Online"])}; font-weight: bold; padding: 10px;">${match["Source Online"]}</td></tr>
|
||||
<tr><th>Space Available</th><td ${tdStyle}>${match["Source Free Space"] !== "N/A" ? match["Source Free Space"] + ' / ' + match["Source Total Space"] : "N/A"}</td></tr>
|
||||
<tr><th>Scanstate Line</th><td ${tdStyle}>${match["Source Scanstate Line"]}${(match["Source Scanstate Elapsed"] && match["Source Scanstate Elapsed"] !== "N/A" && match["Source Scanstate Elapsed"] !== "Cached") ? '<br>Elapsed Time: ' + match["Source Scanstate Elapsed"] : ''}</td></tr>
|
||||
<tr><th>Dest Device</th><td ${tdStyle}><b>${match["Destination Device"]}</b> ${inlineCopyBtn(match["Destination Device"])} ${dstAppBtn}</td></tr>
|
||||
<tr><th>Dest Ping</th><td style="color: ${getPingColor(match["Destination Online"])}; font-weight: bold; padding: 10px;">${match["Destination Online"]}</td></tr>
|
||||
<tr><th>Loadstate Line</th><td ${tdStyle}>${match["Destination Loadstate Line"]}${(match["Destination Loadstate Elapsed"] && match["Destination Loadstate Elapsed"] !== "N/A" && match["Destination Loadstate Elapsed"] !== "Cached") ? '<br>Elapsed Time: ' + match["Destination Loadstate Elapsed"] : ''}</td></tr>
|
||||
<tr><th width="40%">Source Device</th><td ${tdStyle}><b>${srcName}</b> ${inlineCopyBtn(srcName)} ${srcAppBtn}</td></tr>
|
||||
<tr><th>Source Ping</th><td style="color: ${getPingColor(match["Source Online"])}; font-weight: bold; padding: 10px;">${match["Source Online"]}${DeviceInfo.pingMark(match, "Source")}</td></tr>
|
||||
<tr><th>Source Presence</th><td ${tdStyle}>${DeviceInfo.presenceHtml(srcName)}</td></tr>
|
||||
<tr><th>Space Available</th><td ${tdStyle}>${space}${match["Source Space Checked"] && !match["Source Snapshot"] ? `<br><span class="di-sub">Checked ${DeviceInfo.fmtWhen(match["Source Space Checked"])}</span>` : ''}${DeviceInfo.snapshotLine(match, "Source")}</td></tr>
|
||||
<tr><th>Scanstate Line</th><td ${tdStyle}>${match["Source Scanstate Line"]}${elapsed(match["Source Scanstate Elapsed"])}${DeviceInfo.snapshotLine(match, "Source")}</td></tr>
|
||||
<tr><th>Dest Device</th><td ${tdStyle}><b>${dstName}</b> ${inlineCopyBtn(dstName)} ${dstAppBtn}</td></tr>
|
||||
<tr><th>Dest Ping</th><td style="color: ${getPingColor(match["Destination Online"])}; font-weight: bold; padding: 10px;">${match["Destination Online"]}${DeviceInfo.pingMark(match, "Destination")}</td></tr>
|
||||
${hasDst ? `<tr><th>Dest Presence</th><td ${tdStyle}>${DeviceInfo.presenceHtml(dstName)}</td></tr>` : ''}
|
||||
<tr><th>Loadstate Line</th><td ${tdStyle}>${match["Destination Loadstate Line"]}${elapsed(match["Destination Loadstate Elapsed"])}${DeviceInfo.snapshotLine(match, "Destination")}</td></tr>
|
||||
<tr><th>Backup Status</th><td style="background-color: ${statusBg}; color: ${statusText}; padding: 10px;"><b>${match["Backup Status"]}</b></td></tr>
|
||||
<tr><th>Specialty Software</th><td ${tdStyle}>${specialtySoftware}</td></tr>
|
||||
<tr><th>USMT Status</th><td ${tdStyle}>${match["USMT Status"]}</td></tr>
|
||||
<tr><th>USMT Status</th><td ${tdStyle}>${match["USMT Status"]}${pairing ? `<br><span class="di-sub">${pairing}</span>` : ''}</td></tr>
|
||||
${window.isAdvView ? '' : `<tr><th>Monitors</th><td ${tdStyle}>${monitorsCell(srcName)}</td></tr>`}
|
||||
<tr><th>Tags</th><td ${tdStyle}>${match["Tags"] || 'None'}</td></tr>
|
||||
</table>
|
||||
</div>
|
||||
@@ -2898,21 +2925,25 @@
|
||||
<div ${scrollBoxStyle}>
|
||||
<table class="details-table" style="width:100%; border-collapse: collapse; table-layout: fixed;">
|
||||
<tr><th colspan="2" style="text-align: center; background: #34495e; color: white;">Source Info</th></tr>
|
||||
<tr><th width="40%">Device Name</th><td ${tdStyle}><b>${match["Source Device"]}</b> ${inlineCopyBtn(match["Source Device"])}</td></tr>
|
||||
<tr><th>Source IP / MAC</th><td ${tdStyle}>${getAdv(advSrc, "IP")}<br>${getAdv(advSrc, "MAC")}</td></tr>
|
||||
<tr><th>Source OU</th><td ${tdStyle}>${getAdv(advSrc, "OU")}</td></tr>
|
||||
<tr><th>Source Groups</th><td ${tdStyle}>${getAdv(advSrc, "Groups")}</td></tr>
|
||||
<tr><th>Hardware Specs</th><td ${tdStyle}>${getAdv(advSrc, "Model")}<br>${(getAdv(advSrc, "RAM") !== 'N/A') ? getAdv(advSrc, "RAM") + ' RAM' : 'N/A'}</td></tr>
|
||||
<tr><th>Physical Disk</th><td ${tdStyle}>${getAdv(advSrc, "Disk")}</td></tr>
|
||||
<tr><th>SCCM Client</th><td ${tdStyle}>${getAdv(advSrc, "ClientStatus")}</td></tr>
|
||||
<tr><th>Dept / Bldg</th><td ${tdStyle}>${getAdv(advSrc, "Department")} / ${getAdv(advSrc, "Building")}</td></tr>
|
||||
<tr><th>Location / Vanity</th><td ${tdStyle}>${getAdv(advSrc, "Location")} / ${getAdv(advSrc, "Vanity")}</td></tr>
|
||||
<tr><th>Phone / Mount</th><td ${tdStyle}>Ext: ${getAdv(advSrc, "PhoneExt")}<br>${getAdv(advSrc, "Mount")}</td></tr>
|
||||
<tr><th>Room Type</th><td ${tdStyle}>${getAdv(advSrc, "RoomType")}</td></tr>
|
||||
<tr><th>Users (Pri / Last)</th><td ${tdStyle}>${getAdv(advSrc, "PrimaryUser")} / ${getAdv(advSrc, "LastUser")}</td></tr>
|
||||
<tr><th>Primary Users</th><td ${tdStyle}>${getAdv(advSrc, "SCCMPrimaryUsers")}</td></tr>
|
||||
<tr><th>Asset Info Entered</th><td ${tdStyle}>${getAdv(advSrc, "Updated")}<br>(By: ${getAdv(advSrc, "InfoBy")})</td></tr>
|
||||
<tr><th>Notes</th><td ${tdStyle}>${getAdv(advSrc, "Notes")}</td></tr>
|
||||
<tr><th width="40%">Device Name</th><td ${tdStyle}><b>${srcName}</b> ${inlineCopyBtn(srcName)}</td></tr>
|
||||
<tr><th>Found In</th><td ${tdStyle}>${getAdv(srcName, "FoundIn")}</td></tr>
|
||||
<tr><th>Presence</th><td ${tdStyle}>${DeviceInfo.presenceHtml(srcName)}</td></tr>
|
||||
${DeviceInfo.unknownEverywhere(srcName) ? `<tr><td colspan="2" ${tdStyle}><span class="di-missing">Not in AD, SCCM or DNS.</span><br><span class="di-sub">Nothing in the directory, the site database or DNS knows this name yet: a replacement that hasn't been imaged, or a typo in the tag.</span></td></tr>` : `
|
||||
<tr><th>Source IP / MAC</th><td ${tdStyle}>${getAdv(srcName, "IP")}<br>${getAdv(srcName, "MAC")}</td></tr>
|
||||
<tr><th>Source OU</th><td ${tdStyle}>${getAdv(srcName, "OU")}</td></tr>
|
||||
<tr><th>Source Groups</th><td ${tdStyle}>${getAdv(srcName, "Groups")}</td></tr>
|
||||
<tr><th>Hardware Specs</th><td ${tdStyle}>${hwSpecs(srcName)}</td></tr>
|
||||
<tr><th>Physical Disk</th><td ${tdStyle}>${getAdv(srcName, "Disk")}</td></tr>
|
||||
<tr><th>Monitors</th><td ${tdStyle}>${monitorsCell(srcName)}</td></tr>
|
||||
<tr><th>SCCM Client</th><td ${tdStyle}>${getAdv(srcName, "ClientStatus")}</td></tr>
|
||||
<tr><th>Dept / Bldg</th><td ${tdStyle}>${getAdv(srcName, "Department")} / ${getAdv(srcName, "Building")}</td></tr>
|
||||
<tr><th>Location / Vanity</th><td ${tdStyle}>${getAdv(srcName, "Location")} / ${getAdv(srcName, "Vanity")}</td></tr>
|
||||
<tr><th>Phone / Mount</th><td ${tdStyle}>Ext: ${getAdv(srcName, "PhoneExt")}<br>${getAdv(srcName, "Mount")}</td></tr>
|
||||
<tr><th>Room Type</th><td ${tdStyle}>${getAdv(srcName, "RoomType")}</td></tr>
|
||||
<tr><th>Users (Pri / Last)</th><td ${tdStyle}>${getAdv(srcName, "PrimaryUser")} / ${getAdv(srcName, "LastUser")}</td></tr>
|
||||
<tr><th>Primary Users</th><td ${tdStyle}>${getAdv(srcName, "SCCMPrimaryUsers")}</td></tr>
|
||||
<tr><th>Asset Info Entered</th><td ${tdStyle}>${getAdv(srcName, "Updated")}<br>(By: ${getAdv(srcName, "InfoBy")})</td></tr>
|
||||
<tr><th>Notes</th><td ${tdStyle}>${getAdv(srcName, "Notes")}</td></tr>`}
|
||||
</table>
|
||||
</div>
|
||||
`;
|
||||
@@ -2921,21 +2952,25 @@
|
||||
<div ${scrollBoxStyle}>
|
||||
<table class="details-table" style="width:100%; border-collapse: collapse; table-layout: fixed;">
|
||||
<tr><th colspan="2" style="text-align: center; background: #34495e; color: white;">Destination Info</th></tr>
|
||||
<tr><th width="40%">Device Name</th><td ${tdStyle}><b>${match["Destination Device"]}</b> ${inlineCopyBtn(match["Destination Device"])}</td></tr>
|
||||
<tr><th>Dest IP / MAC</th><td ${tdStyle}>${getAdv(advDst, "IP")}<br>${getAdv(advDst, "MAC")}</td></tr>
|
||||
<tr><th>Dest OU</th><td ${tdStyle}>${getAdv(advDst, "OU")}</td></tr>
|
||||
<tr><th>Dest Groups</th><td ${tdStyle}>${getAdv(advDst, "Groups")}</td></tr>
|
||||
<tr><th>Hardware Specs</th><td ${tdStyle}>${getAdv(advDst, "Model")}<br>${(getAdv(advDst, "RAM") !== 'N/A') ? getAdv(advDst, "RAM") + ' RAM' : 'N/A'}</td></tr>
|
||||
<tr><th>Physical Disk</th><td ${tdStyle}>${getAdv(advDst, "Disk")}</td></tr>
|
||||
<tr><th>SCCM Client</th><td ${tdStyle}>${getAdv(advDst, "ClientStatus")}</td></tr>
|
||||
<tr><th>Dept / Bldg</th><td ${tdStyle}>${getAdv(advDst, "Department")} / ${getAdv(advDst, "Building")}</td></tr>
|
||||
<tr><th>Location / Vanity</th><td ${tdStyle}>${getAdv(advDst, "Location")} / ${getAdv(advDst, "Vanity")}</td></tr>
|
||||
<tr><th>Phone / Mount</th><td ${tdStyle}>Ext: ${getAdv(advDst, "PhoneExt")}<br>${getAdv(advDst, "Mount")}</td></tr>
|
||||
<tr><th>Room Type</th><td ${tdStyle}>${getAdv(advDst, "RoomType")}</td></tr>
|
||||
<tr><th>Users (Pri / Last)</th><td ${tdStyle}>${getAdv(advDst, "PrimaryUser")} / ${getAdv(advDst, "LastUser")}</td></tr>
|
||||
<tr><th>Primary Users</th><td ${tdStyle}>${getAdv(advDst, "SCCMPrimaryUsers")}</td></tr>
|
||||
<tr><th>Asset Info Entered</th><td ${tdStyle}>${getAdv(advDst, "Updated")}<br>(By: ${getAdv(advDst, "InfoBy")})</td></tr>
|
||||
<tr><th>Notes</th><td ${tdStyle}>${getAdv(advDst, "Notes")}</td></tr>
|
||||
<tr><th width="40%">Device Name</th><td ${tdStyle}><b>${dstName}</b> ${inlineCopyBtn(dstName)}</td></tr>
|
||||
<tr><th>Found In</th><td ${tdStyle}>${getAdv(dstName, "FoundIn")}</td></tr>
|
||||
<tr><th>Presence</th><td ${tdStyle}>${DeviceInfo.presenceHtml(dstName)}</td></tr>
|
||||
${DeviceInfo.unknownEverywhere(dstName) ? `<tr><td colspan="2" ${tdStyle}><span class="di-missing">Not in AD, SCCM or DNS.</span><br><span class="di-sub">Nothing in the directory, the site database or DNS knows this name yet: a replacement that hasn't been imaged, or a typo in the tag.</span></td></tr>` : `
|
||||
<tr><th>Dest IP / MAC</th><td ${tdStyle}>${getAdv(dstName, "IP")}<br>${getAdv(dstName, "MAC")}</td></tr>
|
||||
<tr><th>Dest OU</th><td ${tdStyle}>${getAdv(dstName, "OU")}</td></tr>
|
||||
<tr><th>Dest Groups</th><td ${tdStyle}>${getAdv(dstName, "Groups")}</td></tr>
|
||||
<tr><th>Hardware Specs</th><td ${tdStyle}>${hwSpecs(dstName)}</td></tr>
|
||||
<tr><th>Physical Disk</th><td ${tdStyle}>${getAdv(dstName, "Disk")}</td></tr>
|
||||
<tr><th>Monitors</th><td ${tdStyle}>${monitorsCell(dstName)}</td></tr>
|
||||
<tr><th>SCCM Client</th><td ${tdStyle}>${getAdv(dstName, "ClientStatus")}</td></tr>
|
||||
<tr><th>Dept / Bldg</th><td ${tdStyle}>${getAdv(dstName, "Department")} / ${getAdv(dstName, "Building")}</td></tr>
|
||||
<tr><th>Location / Vanity</th><td ${tdStyle}>${getAdv(dstName, "Location")} / ${getAdv(dstName, "Vanity")}</td></tr>
|
||||
<tr><th>Phone / Mount</th><td ${tdStyle}>Ext: ${getAdv(dstName, "PhoneExt")}<br>${getAdv(dstName, "Mount")}</td></tr>
|
||||
<tr><th>Room Type</th><td ${tdStyle}>${getAdv(dstName, "RoomType")}</td></tr>
|
||||
<tr><th>Users (Pri / Last)</th><td ${tdStyle}>${getAdv(dstName, "PrimaryUser")} / ${getAdv(dstName, "LastUser")}</td></tr>
|
||||
<tr><th>Primary Users</th><td ${tdStyle}>${getAdv(dstName, "SCCMPrimaryUsers")}</td></tr>
|
||||
<tr><th>Asset Info Entered</th><td ${tdStyle}>${getAdv(dstName, "Updated")}<br>(By: ${getAdv(dstName, "InfoBy")})</td></tr>
|
||||
<tr><th>Notes</th><td ${tdStyle}>${getAdv(dstName, "Notes")}</td></tr>`}
|
||||
</table>
|
||||
</div>
|
||||
`;
|
||||
@@ -3225,13 +3260,15 @@
|
||||
Department: "Department", Building: "Building", Location: "Location", Vanity: "Vanity",
|
||||
PhoneExt: "Phone Ext", Mount: "Mount", RoomType: "Room Type",
|
||||
PrimaryUser: "Primary User", LastUser: "Last User", SCCMPrimaryUsers: "SCCM Primary Users",
|
||||
Updated: "Asset Info Updated", InfoBy: "Info Entered By", Notes: "Notes"
|
||||
Updated: "Asset Info Updated", InfoBy: "Info Entered By", Notes: "Notes",
|
||||
MonitorCount: "Monitor Count", Monitors: "Monitors",
|
||||
DaysOffline: "Days Offline", LastOnline: "Last Online", LastChecked: "Last Ping Check", FoundIn: "Found In (AD/SCCM/DNS)"
|
||||
};
|
||||
const ADV_FIELD_ORDER = ["IP","MAC","OU","Groups","Model","RAM","Disk","ClientStatus","Department","Building","Location","Vanity","PhoneExt","Mount","RoomType","PrimaryUser","LastUser","SCCMPrimaryUsers","Updated","InfoBy","Notes"];
|
||||
const ADV_FIELD_ORDER = ["DaysOffline","LastOnline","LastChecked","FoundIn","IP","MAC","OU","Groups","Model","RAM","Disk","MonitorCount","Monitors","ClientStatus","Department","Building","Location","Vanity","PhoneExt","Mount","RoomType","PrimaryUser","LastUser","SCCMPrimaryUsers","Updated","InfoBy","Notes"];
|
||||
|
||||
let customColumns = JSON.parse(localStorage.getItem('usmt_custom_columns')) || [...CUSTOM_DEFAULT_COLS]; // ordered list of column ids
|
||||
let customColWidths = JSON.parse(localStorage.getItem('usmt_custom_widths')) || {}; // id -> px width
|
||||
let customWidthsLocked = localStorage.getItem('usmt_custom_widths_locked') === '1'; // true once user manually resizes
|
||||
let customColWidths = JSON.parse(localStorage.getItem('usmt_custom_widths')) || {}; // id -> px width
|
||||
let customWidthsLocked = localStorage.getItem('usmt_custom_widths_locked') === '1'; // true once user manually resizes
|
||||
const MIN_COL_W = 110; // minimum column width (fits the header icon row)
|
||||
// Per-column filter: { text:"", excluded:[values] }. Excluded stored as arrays in localStorage.
|
||||
let customFilters = JSON.parse(localStorage.getItem('usmt_custom_filters')) || {}; // id -> {text, excluded:[]}
|
||||
@@ -3270,7 +3307,7 @@
|
||||
function getAdvKeys() {
|
||||
let keys = new Set(ADV_FIELD_ORDER);
|
||||
Object.values(advancedDataCache || {}).forEach(obj => {
|
||||
if (obj && typeof obj === 'object') Object.keys(obj).forEach(k => keys.add(k));
|
||||
if (obj && typeof obj === 'object') Object.keys(obj).forEach(k => { if (!DeviceInfo.HIDDEN.has(k)) keys.add(k); });
|
||||
});
|
||||
return Array.from(keys).sort((a, b) => {
|
||||
let ia = ADV_FIELD_ORDER.indexOf(a), ib = ADV_FIELD_ORDER.indexOf(b);
|
||||
@@ -3393,8 +3430,8 @@
|
||||
function getCustomCellValue(row, col) {
|
||||
const blank = v => (v === undefined || v === null || v === "") ? "N/A" : v;
|
||||
if (col.kind === "apiKey") return blank(row[col.key]);
|
||||
if (col.kind === "adv") return blank((advancedDataCache[row["Source Device"]] || {})[col.key]);
|
||||
if (col.kind === "advDst") return blank((advancedDataCache[row["Destination Device"]] || {})[col.key]);
|
||||
if (col.kind === "adv") return DeviceInfo.field(row["Source Device"], col.key).text;
|
||||
if (col.kind === "advDst") return DeviceInfo.field(row["Destination Device"], col.key).text;
|
||||
if (col.kind === "special") return getCustomSpecialty(row["Source Device"]);
|
||||
if (col.kind === "migration") {
|
||||
if (col.fn === "space") return (row["Source Free Space"] && row["Source Free Space"] !== "N/A") ? row["Source Free Space"] + " / " + row["Source Total Space"] : "N/A";
|
||||
@@ -3404,35 +3441,35 @@
|
||||
return "N/A";
|
||||
}
|
||||
|
||||
// Returns {td-inner-html, cellStyle} for a column, replicating All Devices styling.
|
||||
function renderCustomCell(row, col, val) {
|
||||
const safe = String(val).replace(/"/g, '"');
|
||||
// Ping columns: colored + bold
|
||||
if (col.id === "__srcping" || col.id === "__destping") {
|
||||
return { html: `<b>${val}</b>`, style: `color:${getPingColor(val)}; font-weight:bold;` };
|
||||
}
|
||||
// Device names: bold + inline copy
|
||||
if (col.kind === "apiKey" && (col.key === "Source Device" || col.key === "Destination Device")) {
|
||||
return { html: `<b>${val}</b>${inlineCopyBtn(String(val))}`, style: "" };
|
||||
}
|
||||
// Backup / USMT status: bold
|
||||
if (col.key === "Backup Status" || col.id === "__usmt") {
|
||||
return { html: `<b>${val}</b>`, style: "" };
|
||||
}
|
||||
// Specialty Software: hyperlink any app that has documentation
|
||||
if (col.kind === "special") {
|
||||
if (!val || val === "None" || val === "Loading...") return { html: val, style: "" };
|
||||
const linked = val.split(", ").map(app => {
|
||||
const m = availableDocs.find(d => d.toLowerCase() === app.toLowerCase());
|
||||
return m
|
||||
? `<a href="?doc=${encodeURIComponent(m)}" target="_blank" onclick="event.stopPropagation();" style="color:#3498db; font-weight:bold;">${app}</a>`
|
||||
: app;
|
||||
});
|
||||
return { html: linked.join(", "), style: "" };
|
||||
}
|
||||
return { html: safe, style: "" };
|
||||
}
|
||||
|
||||
// Returns {td-inner-html, cellStyle} for a column, replicating All Devices styling.
|
||||
function renderCustomCell(row, col, val) {
|
||||
const safe = String(val).replace(/"/g, '"');
|
||||
// Ping columns: colored + bold
|
||||
if (col.id === "__srcping" || col.id === "__destping") {
|
||||
return { html: `<b>${val}</b>`, style: `color:${getPingColor(val)}; font-weight:bold;` };
|
||||
}
|
||||
// Device names: bold + inline copy
|
||||
if (col.kind === "apiKey" && (col.key === "Source Device" || col.key === "Destination Device")) {
|
||||
return { html: `<b>${val}</b>${inlineCopyBtn(String(val))}`, style: "" };
|
||||
}
|
||||
// Backup / USMT status: bold
|
||||
if (col.key === "Backup Status" || col.id === "__usmt") {
|
||||
return { html: `<b>${val}</b>`, style: "" };
|
||||
}
|
||||
// Specialty Software: hyperlink any app that has documentation
|
||||
if (col.kind === "special") {
|
||||
if (!val || val === "None" || val === "Loading...") return { html: val, style: "" };
|
||||
const linked = val.split(", ").map(app => {
|
||||
const m = availableDocs.find(d => d.toLowerCase() === app.toLowerCase());
|
||||
return m
|
||||
? `<a href="?doc=${encodeURIComponent(m)}" target="_blank" onclick="event.stopPropagation();" style="color:#3498db; font-weight:bold;">${app}</a>`
|
||||
: app;
|
||||
});
|
||||
return { html: linked.join(", "), style: "" };
|
||||
}
|
||||
return { html: safe, style: "" };
|
||||
}
|
||||
|
||||
function renderCustomTable() {
|
||||
const headerRow = document.getElementById('custom-header-row');
|
||||
const tbody = document.getElementById('tbody-custom');
|
||||
@@ -4095,22 +4132,22 @@
|
||||
|
||||
const dstPingStatus = row["Destination Online"] || "N/A";
|
||||
|
||||
const spaceStr = (row["Source Free Space"] !== "N/A" && row["Source Free Space"]) ?
|
||||
`${row["Source Free Space"]} / ${row["Source Total Space"]}` : "N/A";
|
||||
const spaceStr = (row["Source Free Space"] !== "N/A" && row["Source Free Space"] !== "Cached" && row["Source Free Space"]) ?
|
||||
`${row["Source Free Space"]} / ${row["Source Total Space"]}` : (row["Source Free Space"] || "N/A");
|
||||
|
||||
const destAppsBtn = (row["Destination Device"] && row["Destination Device"] !== "N/A") ?
|
||||
`<button class="soft-btn" onclick="event.stopPropagation(); fetchSoftware('${row["Destination Device"]}')">Apps</button>` : `<span style="color:var(--text-muted);">---</span>`;
|
||||
|
||||
rowsHtml += `<tr class="clickable-row" style='background-color:${themes[currentTheme].groupColors[g]}; cursor: pointer;' onclick="showDeviceDetails('${row["Source Device"]}')">
|
||||
<td title="${row["Source Device"]}"><b>${row["Source Device"]}</b>${inlineCopyBtn(row["Source Device"])}</td>
|
||||
<td style="color: ${getPingColor(srcPingStatus)}; font-weight: bold;">${srcPingStatus}</td>
|
||||
<td style="color: ${getPingColor(srcPingStatus)}; font-weight: bold;">${srcPingStatus}${DeviceInfo.pingMark(row, "Source")}</td>
|
||||
<td class="no-copy"><button class="soft-btn" onclick="event.stopPropagation(); fetchSoftware('${row["Source Device"]}')">Apps</button></td>
|
||||
<td title="${row["Source Scanstate Line"]}">${(g === "Backup In Progress" && row["Source Scanstate Elapsed"] && row["Source Scanstate Elapsed"] !== "N/A" && row["Source Scanstate Elapsed"] !== "Cached") ? row["Source Scanstate Elapsed"] + " " + String(row["Source Scanstate Line"]).trim().split(/\s+/).pop() : row["Source Scanstate Line"]}</td>
|
||||
<td title="${spaceStr}">${spaceStr}</td>
|
||||
<td title="${row["Source Scanstate Line"]}">${(g === "Backup In Progress" && row["Source Scanstate Elapsed"] && row["Source Scanstate Elapsed"] !== "N/A" && row["Source Scanstate Elapsed"] !== "Cached") ? row["Source Scanstate Elapsed"] + " " + String(row["Source Scanstate Line"]).trim().split(/\s+/).pop() : row["Source Scanstate Line"]}${DeviceInfo.snapshotMark(row, "Source")}</td>
|
||||
<td title="${spaceStr}">${spaceStr}${DeviceInfo.snapshotMark(row, "Source")}</td>
|
||||
<td title="${row["Destination Device"]}"><b>${row["Destination Device"]}</b>${inlineCopyBtn(row["Destination Device"])}</td>
|
||||
<td style="color: ${getPingColor(dstPingStatus)}; font-weight: bold;">${dstPingStatus}</td>
|
||||
<td style="color: ${getPingColor(dstPingStatus)}; font-weight: bold;">${dstPingStatus}${DeviceInfo.pingMark(row, "Destination")}</td>
|
||||
<td class="no-copy">${destAppsBtn}</td>
|
||||
<td title="${row["Destination Loadstate Line"]}">${(g === "Restore In Progress" && row["Destination Loadstate Elapsed"] && row["Destination Loadstate Elapsed"] !== "N/A" && row["Destination Loadstate Elapsed"] !== "Cached") ? row["Destination Loadstate Elapsed"] + " " + String(row["Destination Loadstate Line"]).trim().split(/\s+/).pop() : row["Destination Loadstate Line"]}</td>
|
||||
<td title="${row["Destination Loadstate Line"]}">${(g === "Restore In Progress" && row["Destination Loadstate Elapsed"] && row["Destination Loadstate Elapsed"] !== "N/A" && row["Destination Loadstate Elapsed"] !== "Cached") ? row["Destination Loadstate Elapsed"] + " " + String(row["Destination Loadstate Line"]).trim().split(/\s+/).pop() : row["Destination Loadstate Line"]}${DeviceInfo.snapshotMark(row, "Destination")}</td>
|
||||
<td title="${row["USMT Status"] || "N/A"}">${row["USMT Status"] || "N/A"}</td>
|
||||
<td title="${row.Tags || 'None'}">${row.Tags || 'None'}</td>
|
||||
<td style="text-align:center;" class="no-copy"><span class="copy-row-btn" title="Copy Row Data" onclick="copyRowData(this, event)">${COPY_SVG}</span></td>
|
||||
@@ -4267,7 +4304,7 @@
|
||||
document.getElementById('syncProgressFill').style.backgroundColor = "#27ae60";
|
||||
|
||||
if (state.LastDuration && state.LastDuration !== "N/A") {
|
||||
document.getElementById('syncDurationText').innerText = `Last cycle finished in ${state.LastDuration} with ${state.Total} devices`;
|
||||
document.getElementById('syncDurationText').innerText = `Last cycle finished in ${state.LastDuration} with ${state.Total} source devices`;
|
||||
}
|
||||
|
||||
forceBtn.disabled = false;
|
||||
@@ -4276,19 +4313,23 @@
|
||||
let percentage = 0;
|
||||
if (state.Total > 0) percentage = Math.round((state.Current / state.Total) * 100);
|
||||
document.getElementById('syncStatusText').innerText = "Live Sync in Progress...";
|
||||
document.getElementById('syncDetailsText').innerText = `${state.Status}: Device ${state.Current} of ${state.Total}`;
|
||||
document.getElementById('syncDetailsText').innerText = state.Status === "Presence Sweep..."
|
||||
? `Presence sweep: pinging device ${state.Current} of ${state.Total}`
|
||||
: `${state.Status}: Device ${state.Current} of ${state.Total}`;
|
||||
document.getElementById('syncProgressFill').style.width = `${percentage}%`;
|
||||
document.getElementById('syncProgressFill').innerText = `${percentage}%`;
|
||||
document.getElementById('syncProgressFill').style.backgroundColor = "#3498db";
|
||||
|
||||
if (state.LastDuration && state.LastDuration !== "N/A") {
|
||||
document.getElementById('syncDurationText').innerText = `Previous cycle took ${state.LastDuration} with ${state.Total} devices`;
|
||||
document.getElementById('syncDurationText').innerText = `Previous cycle took ${state.LastDuration}${state.LastCycle ? ` with ${state.LastCycle.Sources} source devices` : ''}`;
|
||||
}
|
||||
|
||||
forceBtn.disabled = true;
|
||||
forceFullBtn.disabled = true;
|
||||
}
|
||||
|
||||
document.getElementById('syncBreakdown').innerHTML = DeviceInfo.syncBreakdownHtml(state);
|
||||
|
||||
// Check if Tracked-Devices was updated by you or a teammate
|
||||
const trackRes = await fetch('Tracked-Devices.json?t=' + Date.now()).catch(() => null);
|
||||
let currentTrackedHash = "";
|
||||
@@ -4359,6 +4400,18 @@
|
||||
});
|
||||
}
|
||||
|
||||
// A manual pair whose destination changed (or a device that became / stopped being a
|
||||
// manual pair) drops its stale row right away; a "Waiting for Worker..." row for
|
||||
// the new pairing is added below until the worker sweeps it
|
||||
apiData = apiData.filter(row => {
|
||||
let src = (row["Source Device"] || "").toUpperCase();
|
||||
let dest = (row["Destination Device"] || "").toUpperCase();
|
||||
let isManualRow = row["Pairing"] ? row["Pairing"] === "Manual" : (row["USMT Status"] === "N/A" && row["Backup Status"] === "N/A" && dest !== "N/A");
|
||||
if (manualPairs[src]) return isManualRow && dest === manualPairs[src];
|
||||
if (isManualRow && !manualPairs[src]) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
// Surgically strip old devices from apiData before the backend worker finishes purging them
|
||||
apiData = apiData.filter(row => {
|
||||
let src = (row["Source Device"] || "").toUpperCase();
|
||||
@@ -4397,13 +4450,14 @@
|
||||
apiData.push({
|
||||
"Source Device": dev,
|
||||
"Destination Device": destDev,
|
||||
"Pairing": isManual ? "Manual" : undefined,
|
||||
"Tags": tags.join(", "),
|
||||
"Source Total Space": placeholder,
|
||||
"Source Free Space": placeholder,
|
||||
"USMT Status": placeholder,
|
||||
"Backup Status": status,
|
||||
"Source Online": placeholder,
|
||||
"Destination Online": "Waiting for Worker...",
|
||||
"Source Online": "Waiting for Worker...",
|
||||
"Destination Online": isManual ? "Waiting for Worker..." : "-",
|
||||
"Source Scanstate Line": placeholder,
|
||||
"Destination Loadstate Line": placeholder
|
||||
});
|
||||
@@ -4415,6 +4469,7 @@
|
||||
renderMasterList();
|
||||
renderSoftwareTable(); // <-- Forces the Software tab to update instantly!
|
||||
renderCustomTable();
|
||||
refreshOpenDialogs();
|
||||
|
||||
// 3. LAZY LOAD SOFTWARE LAST
|
||||
fetch('Software-Cache.json?t=' + Date.now())
|
||||
@@ -4433,8 +4488,11 @@
|
||||
} else if (entry && entry.Data) {
|
||||
rawData = Array.isArray(entry.Data) ? entry.Data : [entry.Data];
|
||||
}
|
||||
if (rawData && !softwareCache[device]) {
|
||||
// Re-read an entry whenever the worker refreshed it (nightly sweep, new device)
|
||||
const stamp = (entry && entry.Timestamp) ? String(entry.Timestamp) : (rawData ? String(rawData.length) : '');
|
||||
if (rawData && (!softwareCache[device] || softwareStamps[device] !== stamp)) {
|
||||
softwareCache[device] = rawData.filter(s => s && s.Name && s.Name.trim() !== '' && s.Name.trim() !== '--');
|
||||
softwareStamps[device] = stamp;
|
||||
cacheUpdated = true;
|
||||
}
|
||||
}
|
||||
@@ -4442,7 +4500,8 @@
|
||||
myStacks.forEach(s => refreshStackTable(s.id));
|
||||
renderMasterList();
|
||||
renderSoftwareTable();
|
||||
renderDetailsHTML();
|
||||
renderCustomTable();
|
||||
refreshOpenDialogs(true);
|
||||
}
|
||||
}
|
||||
}).catch(() => null);
|
||||
@@ -4450,6 +4509,45 @@
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
// Open dialogs follow the data: when the worker finishes a cycle (or the software cache
|
||||
// changes) the device details and software dialogs re-render in place, keeping scroll
|
||||
// position, unless the user is selecting text in them.
|
||||
let softwareStamps = {};
|
||||
let lastDetailsSig = "", lastSoftwareSig = "";
|
||||
function refreshOpenDialogs(force) {
|
||||
const selecting = () => { const sel = window.getSelection(); return sel && sel.toString().length > 0; };
|
||||
const dm = document.getElementById('detailsModal');
|
||||
if (dm && dm.style.display === 'block' && window.currentDetailsKey && !selecting()) {
|
||||
const key = window.currentDetailsKey;
|
||||
const m = apiData.find(x => x["Source Device"] === key) || apiData.find(x => x["Destination Device"] === key);
|
||||
if (m) {
|
||||
const sig = JSON.stringify([m, advancedDataCache[m["Source Device"]], advancedDataCache[m["Destination Device"]],
|
||||
(softwareCache[m["Source Device"]] || []).length, (softwareCache[m["Destination Device"]] || []).length]);
|
||||
if (force || sig !== lastDetailsSig) {
|
||||
lastDetailsSig = sig;
|
||||
window.currentDetailsMatch = m;
|
||||
const mc = dm.querySelector('.modal-content');
|
||||
const boxes = Array.from(dm.querySelectorAll('#detailsBody div')).map(d => d.scrollTop);
|
||||
const top = mc.scrollTop;
|
||||
renderDetailsHTML();
|
||||
mc.scrollTop = top;
|
||||
Array.from(dm.querySelectorAll('#detailsBody div')).forEach((d, i) => { if (boxes[i]) d.scrollTop = boxes[i]; });
|
||||
}
|
||||
}
|
||||
}
|
||||
const sm = document.getElementById('modal');
|
||||
const dev = window.currentSoftwareDevice;
|
||||
if (sm && sm.style.display === 'block' && dev && Array.isArray(softwareCache[dev]) && !selecting()) {
|
||||
const sig = dev + JSON.stringify(softwareCache[dev]);
|
||||
if (sig !== lastSoftwareSig) {
|
||||
lastSoftwareSig = sig;
|
||||
drawModalTables(softwareCache[dev], true, dev);
|
||||
const search = document.getElementById('softSearch');
|
||||
if (search && search.value) filterSoftwareModal();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function startDashboard() {
|
||||
renderStacksOnly();
|
||||
await pullData();
|
||||
|
||||
+56
-4
@@ -41,11 +41,37 @@ table.xf-table thead tr.filter-row { display: none !important; }
|
||||
#scroll-custom { max-height: none !important; overflow-x: clip !important; overflow-y: visible !important; }
|
||||
#table-custom thead th { position: sticky !important; top: var(--sticky-top, 112px) !important; z-index: 20; }
|
||||
.custom-hscroll {
|
||||
position: sticky; bottom: var(--dock-h, 40px); z-index: 15; height: 16px;
|
||||
overflow-x: auto; overflow-y: hidden;
|
||||
background: var(--bg-card); border-top: 1px solid var(--border-light); border-radius: 0 0 8px 8px;
|
||||
position: sticky; bottom: var(--dock-h, 40px); z-index: 15;
|
||||
display: flex; align-items: center; gap: 6px; height: 30px; padding: 0 8px;
|
||||
background: var(--bg-card); border-top: 2px solid #3498db; border-radius: 0 0 8px 8px;
|
||||
box-shadow: 0 -3px 10px rgba(0,0,0,0.18); user-select: none; touch-action: none;
|
||||
}
|
||||
.custom-hscroll > div { height: 1px; }
|
||||
.custom-hscroll[hidden] { display: none; }
|
||||
.chs-btn {
|
||||
flex: 0 0 30px; height: 22px; border: 1px solid var(--border-color, #ccc); border-radius: 5px;
|
||||
background: var(--bg-body, #f0f2f5); color: var(--text-main, #333); cursor: pointer;
|
||||
font-size: 11px; line-height: 1; padding: 0;
|
||||
}
|
||||
.chs-btn:hover:not(:disabled) { background: #3498db; border-color: #3498db; color: #fff; }
|
||||
.chs-btn:disabled { opacity: 0.35; cursor: default; }
|
||||
.chs-track {
|
||||
position: relative; flex: 1 1 auto; height: 14px; border-radius: 7px; cursor: pointer;
|
||||
background: rgba(127,127,127,0.22); box-shadow: inset 0 1px 2px rgba(0,0,0,0.25); outline: none;
|
||||
}
|
||||
.chs-track:focus-visible { box-shadow: 0 0 0 2px #3498db; }
|
||||
.chs-thumb {
|
||||
position: absolute; top: -3px; left: 0; height: 20px; min-width: 48px; border-radius: 10px;
|
||||
background: #3498db; cursor: grab; box-shadow: 0 1px 4px rgba(0,0,0,0.35);
|
||||
display: flex; align-items: center; justify-content: center; transition: background 0.15s;
|
||||
}
|
||||
.chs-thumb span { width: 14px; height: 8px; border-left: 2px solid rgba(255,255,255,0.8); border-right: 2px solid rgba(255,255,255,0.8); }
|
||||
.chs-thumb:hover, .custom-hscroll.dragging .chs-thumb { background: #1d7fc4; }
|
||||
.custom-hscroll.dragging .chs-thumb { cursor: grabbing; }
|
||||
.chs-info { flex: 0 0 auto; min-width: 34px; text-align: right; font-size: 0.75em; color: var(--text-muted, #666); font-variant-numeric: tabular-nums; }
|
||||
[data-theme="dark"] .chs-track { background: rgba(255,255,255,0.16); }
|
||||
[data-theme="dark"] .chs-thumb { background: #5dade2; }
|
||||
[data-theme="dark"] .chs-thumb:hover, [data-theme="dark"] .custom-hscroll.dragging .chs-thumb { background: #85c1e9; }
|
||||
[data-theme="dark"] .chs-thumb span { border-color: rgba(0,0,0,0.55); }
|
||||
|
||||
/* Custom View header on one line like the other tables: drag grip, name, then the
|
||||
Excel-style button (sort + filter) and copy icon; remove (x) appears on hover */
|
||||
@@ -397,3 +423,29 @@ body.col-dragging, body.col-dragging * { cursor: grabbing !important; user-selec
|
||||
.xf-seg-btn.on { background: #3498db; color: #fff; font-weight: 600; }
|
||||
/* Only the value list shrinks when the menu is height-limited; controls keep their size */
|
||||
.xf-menu > :not(.xf-list):not(.xf-add):not(.xf-fields) { flex-shrink: 0; }
|
||||
|
||||
/* ---------- Device info: reasons, presence, snapshot markers ---------- */
|
||||
.di-waiting { color: var(--text-muted); font-style: italic; }
|
||||
.di-missing { color: #c0392b; font-weight: 600; }
|
||||
[data-theme="dark"] .di-missing { color: #f1948a; }
|
||||
.di-online { color: #27ae60; }
|
||||
.di-offline { color: #d68910; }
|
||||
.di-offline-long { color: #c0392b; }
|
||||
[data-theme="dark"] .di-offline-long { color: #f1948a; }
|
||||
.di-sub { color: var(--text-muted); font-size: 0.85em; }
|
||||
.di-inline-clock { width: 11px; height: 11px; vertical-align: -1px; }
|
||||
.snap-mark { display: inline-flex; vertical-align: -2px; margin-left: 3px; color: var(--text-muted); opacity: 0.8; cursor: help; }
|
||||
.snap-mark svg { width: 12px; height: 12px; }
|
||||
|
||||
/* ---------- Data Synchronization: per-type timing ---------- */
|
||||
.sync-breakdown:empty { display: none; }
|
||||
.sync-breakdown { margin-top: 14px; font-size: 0.85em; }
|
||||
.sync-bd-title { font-weight: 700; margin-bottom: 6px; color: var(--text-main); }
|
||||
.sync-bd-title span { font-weight: 400; color: var(--text-muted); margin-left: 4px; }
|
||||
table.sync-bd { width: 100%; border-collapse: collapse; }
|
||||
table.sync-bd th, table.sync-bd td { text-align: right; padding: 4px 6px; border-bottom: 1px solid var(--border-color); white-space: nowrap; }
|
||||
table.sync-bd th:first-child, table.sync-bd td:first-child { text-align: left; white-space: normal; }
|
||||
table.sync-bd th { color: var(--text-muted); font-weight: 600; }
|
||||
table.sync-bd tbody tr { cursor: help; }
|
||||
.sync-bd-phases { margin-top: 6px; color: var(--text-muted); line-height: 1.4; }
|
||||
#syncModal .modal-content { max-width: 460px !important; }
|
||||
+87
-14
@@ -81,8 +81,12 @@
|
||||
|
||||
// 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 pinned to the bottom of the window. Wheel, shift+wheel and
|
||||
// touch swipes over the table drive the same scrollbar.
|
||||
// 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');
|
||||
@@ -93,27 +97,96 @@
|
||||
bar = document.createElement('div');
|
||||
bar.id = 'custom-hscroll';
|
||||
bar.className = 'custom-hscroll no-copy';
|
||||
bar.appendChild(document.createElement('div'));
|
||||
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);
|
||||
const applyX = () => { host.style.transform = bar.scrollLeft ? `translateX(${-bar.scrollLeft}px)` : ''; };
|
||||
bar.addEventListener('scroll', applyX, { passive: true });
|
||||
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();
|
||||
bar.scrollLeft += dx;
|
||||
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: bar.scrollLeft, lock: null };
|
||||
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(); bar.scrollLeft = start.left - dx; }
|
||||
if (start.lock === 'x') { e.preventDefault(); customHScroll.set(start.left - dx); }
|
||||
}, { passive: false });
|
||||
box.addEventListener('touchend', () => { start = null; }, { passive: true });
|
||||
}
|
||||
@@ -124,11 +197,11 @@
|
||||
}
|
||||
const full = Math.ceil(table.getBoundingClientRect().width);
|
||||
const view = box.clientWidth;
|
||||
const inner = bar.firstChild;
|
||||
if (inner.style.width !== full + 'px') inner.style.width = full + 'px';
|
||||
const hide = full <= view + 1;
|
||||
const max = Math.max(0, full - view);
|
||||
const hide = max <= 1;
|
||||
if (bar.hidden !== hide) bar.hidden = hide;
|
||||
if (hide && bar.scrollLeft) { bar.scrollLeft = 0; host.style.transform = ''; }
|
||||
customHScroll.max = hide ? 0 : max;
|
||||
customHScroll.set(customHScroll.left);
|
||||
}
|
||||
|
||||
// Auto-sizing text boxes (Workbench stacks) only grow while typing in the original;
|
||||
@@ -206,8 +279,8 @@
|
||||
const box = document.getElementById('scroll-custom');
|
||||
if (bar && !bar.hidden && box) {
|
||||
const r = box.getBoundingClientRect();
|
||||
if (e.clientX < r.left + 40) bar.scrollLeft -= 18;
|
||||
else if (e.clientX > r.right - 40) bar.scrollLeft += 18;
|
||||
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
|
||||
|
||||
Reference in new issue
Block a user