Add "Copy hosts (JSON)" and "Copy hosts (TSV)" buttons for host list views

Adds two buttons to the view.py menu bar that copy hostname (FQDN) and
IP for every host on the page to the clipboard, as JSON or as
tab-separated lines. Works on views grouped by folder across multiple
tables as well as single-table views, and correctly handles views that
render 2 hosts per physical row as side-by-side column pairs.
This commit is contained in:
Luigi D'Acunto 2026-08-25 12:18:30 +02:00
parent f91470d103
commit b0252672fa
3 changed files with 170 additions and 44 deletions

View file

@ -5,6 +5,11 @@ All notable changes to **Checkmk SwissKnife** are documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [2.21.0] - 2026-08-25
### Added
- "Copy hosts (JSON)" and "Copy hosts (TSV)" buttons in the monitoring view menu bar, on any view.py page listing hosts (e.g. "Host search"). Copies hostname (FQDN) and IP for every host on the page — deduplicated, and correctly handling views that render 2 hosts per physical row as side-by-side column pairs — either as JSON or as tab-separated `FQDN\tIP` lines ready to paste into a spreadsheet.
## [2.20.2] - 2026-08-20 ## [2.20.2] - 2026-08-20
### Fixed ### Fixed

View file

@ -33,6 +33,7 @@ A [Tampermonkey](https://www.tampermonkey.net/) userscript that enhances the Che
| **Extra Column Toggle** | Adds an ON/OFF button to the menu bar to show/hide the Extra column; the choice is remembered across reloads | `addExtraColumnToggle` | | **Extra Column Toggle** | Adds an ON/OFF button to the menu bar to show/hide the Extra column; the choice is remembered across reloads | `addExtraColumnToggle` |
| **Copy Host List (JSON)** | Adds a button to the menu bar that copies hostname, alias, IPv4 address and monitored site for every host in the list to the clipboard as JSON | `addHostListCopyButton` | | **Copy Host List (JSON)** | Adds a button to the menu bar that copies hostname, alias, IPv4 address and monitored site for every host in the list to the clipboard as JSON | `addHostListCopyButton` |
| **Service Export to Markdown** | On any view.py services table (Host + Display name/Service columns), adds a button that exports a Markdown table with whichever of Site/Host/IP/Display name/Summary/Details are present, to the clipboard and as a downloaded `.md` file | `addServiceExportButton` | | **Service Export to Markdown** | On any view.py services table (Host + Display name/Service columns), adds a button that exports a Markdown table with whichever of Site/Host/IP/Display name/Summary/Details are present, to the clipboard and as a downloaded `.md` file | `addServiceExportButton` |
| **Copy Hosts (JSON/TSV)** | Adds two buttons to the menu bar on any view.py host list that copy hostname (FQDN) and IP for every host on the page to the clipboard, as JSON or as tab-separated lines | `addHostExportButtons` |
--- ---
@ -176,6 +177,13 @@ On any monitoring view showing a services table — detected structurally by a *
Applies to: any `view.py` page with a services table (with or without sidebar). Applies to: any `view.py` page with a services table (with or without sidebar).
---
### Copy Hosts (JSON/TSV)
Adds **"Copy hosts (JSON)"** and **"Copy hosts (TSV)"** buttons to the menu bar on any monitoring view listing hosts (e.g. "Host search"), whether results are grouped by folder across several tables or shown as a single table. Clicking one copies hostname (FQDN) and IP for every host currently listed to the clipboard — deduplicated — either as a JSON array or as tab-separated `FQDN` / `IP` lines (with a header row) ready to paste into a spreadsheet.
Applies to: any `view.py` page listing hosts (with or without sidebar).
## How it works ## How it works
The script injects enhancements at page load and re-applies them on SPA navigation (Checkmk reloads content inside an `<iframe>` without a full page reload). Each feature is independent — a feature that does not apply to the current page does nothing and stops polling. The script injects enhancements at page load and re-applies them on SPA navigation (Checkmk reloads content inside an `<iframe>` without a full page reload). Each feature is independent — a feature that does not apply to the current page does nothing and stops polling.

View file

@ -1,7 +1,7 @@
// ==UserScript== // ==UserScript==
// @name Checkmk SwissKnife // @name Checkmk SwissKnife
// @namespace https://luigidacunto.com/ // @namespace https://luigidacunto.com/
// @version 2.20.2 // @version 2.21.0
// @checkmk 2.3.x - 2.4.x // @checkmk 2.3.x - 2.4.x
// @description Collection of UI improvements for Checkmk WATO. Each fix or enhancement is added here as an independent feature. // @description Collection of UI improvements for Checkmk WATO. Each fix or enhancement is added here as an independent feature.
// @author Luigi D'Acunto // @author Luigi D'Acunto
@ -831,6 +831,34 @@
const HOST_DISPLAY_MAX_LEN = 32; // ~15 char typical hostname + ".ad.aruba.it" + margin const HOST_DISPLAY_MAX_LEN = 32; // ~15 char typical hostname + ".ad.aruba.it" + margin
// Index of the "nobr" host column in a table.data (first tr.data whose nobr
// cell contains a link with a `host=` param), or -1 if none found.
// Shared by addInventoryButtons and the host export buttons below.
function findHostColIdx(table) {
for (const tr of table.querySelectorAll('tr.data')) {
const cells = [...tr.children];
for (let i = 0; i < cells.length; i++) {
if (!cells[i].classList.contains('nobr')) continue;
const a = cells[i].querySelector('a[href*="host="]');
if (!a) continue;
const h = new URLSearchParams(a.getAttribute('href').split('?')[1] || '').get('host');
if (h) return i;
}
}
return -1;
}
// Extracts {hostname, ip} from a host cell. IP is the title of the
// `<span title="x.x.x.x">` wrapping the host link, when present.
function extractHostCell(hostTd) {
const a = hostTd && hostTd.querySelector('a[href*="host="]');
if (!a) return null;
const hostname = new URLSearchParams(a.getAttribute('href').split('?')[1] || '').get('host');
if (!hostname) return null;
const ip = (hostTd.querySelector('span[title]') || {}).title || '';
return { hostname, ip };
}
function addInventoryButtons(doc) { function addInventoryButtons(doc) {
if (doc.body.dataset.cmkInventoryBtns === '1') return; if (doc.body.dataset.cmkInventoryBtns === '1') return;
@ -895,19 +923,7 @@
let found = false; let found = false;
tables.forEach(table => { tables.forEach(table => {
// Trova indice colonna host dalla prima riga dati con link host const hostColIdx = findHostColIdx(table);
let hostColIdx = -1;
for (const tr of table.querySelectorAll('tr.data')) {
const cells = [...tr.children];
for (let i = 0; i < cells.length; i++) {
if (!cells[i].classList.contains('nobr')) continue;
const a = cells[i].querySelector('a[href*="host="]');
if (!a) continue;
const h = new URLSearchParams(a.getAttribute('href').split('?')[1] || '').get('host');
if (h) { hostColIdx = i; break; }
}
if (hostColIdx !== -1) break;
}
if (hostColIdx === -1) return; if (hostColIdx === -1) return;
found = true; found = true;
@ -936,38 +952,35 @@
extraTd.className = 'cmk-sk-extra-td'; extraTd.className = 'cmk-sk-extra-td';
if (hostTd && hostTd.classList.contains('nobr')) { if (hostTd && hostTd.classList.contains('nobr')) {
const a = hostTd.querySelector('a[href*="host="]'); const r = extractHostCell(hostTd);
// IP è nel title dello span che wrappa l'anchor: <span title="x.x.x.x"><a>...</a></span> if (r) {
const ip = (hostTd.querySelector('span[title]') || {}).title || null; const { hostname, ip } = r;
if (a) { const shortname = hostname.split('.')[0];
const h = new URLSearchParams(a.getAttribute('href').split('?')[1] || '').get('host'); const a = hostTd.querySelector('a[href*="host="]');
if (h) {
const hostname = h, shortname = h.split('.')[0];
// Truncate the displayed name (copy buttons below always use the full `hostname`/`shortname`) // Truncate the displayed name (copy buttons below always use the full `hostname`/`shortname`)
const displayName = a.textContent.trim(); const displayName = a.textContent.trim();
if (displayName.length > HOST_DISPLAY_MAX_LEN) { if (displayName.length > HOST_DISPLAY_MAX_LEN) {
a.title = displayName; a.title = displayName;
a.textContent = displayName.slice(0, HOST_DISPLAY_MAX_LEN - 1) + '…'; a.textContent = displayName.slice(0, HOST_DISPLAY_MAX_LEN - 1) + '…';
}
const group = doc.createElement('span');
group.className = 'cmk-sk-btn-group';
const disc = doc.createElement('a');
disc.className = 'cmk-sk-inv-btn';
disc.href = `wato.py?host=${encodeURIComponent(hostname)}&mode=inventory`;
disc.target = '_blank'; disc.rel = 'noopener';
disc.title = `Service Discovery: ${hostname}`;
disc.innerHTML = DISC_SVG;
group.appendChild(disc);
group.appendChild(mkCopy('cmk-sk-copy-btn', hostname, `Copy hostname: ${hostname}`));
group.appendChild(mkCopy('cmk-sk-copy-short-btn', shortname, `Copy short hostname: ${shortname}`));
if (ip) group.appendChild(mkCopy('cmk-sk-copy-ip-btn', ip, `Copy IP: ${ip}`));
extraTd.appendChild(group);
} }
const group = doc.createElement('span');
group.className = 'cmk-sk-btn-group';
const disc = doc.createElement('a');
disc.className = 'cmk-sk-inv-btn';
disc.href = `wato.py?host=${encodeURIComponent(hostname)}&mode=inventory`;
disc.target = '_blank'; disc.rel = 'noopener';
disc.title = `Service Discovery: ${hostname}`;
disc.innerHTML = DISC_SVG;
group.appendChild(disc);
group.appendChild(mkCopy('cmk-sk-copy-btn', hostname, `Copy hostname: ${hostname}`));
group.appendChild(mkCopy('cmk-sk-copy-short-btn', shortname, `Copy short hostname: ${shortname}`));
if (ip) group.appendChild(mkCopy('cmk-sk-copy-ip-btn', ip, `Copy IP: ${ip}`));
extraTd.appendChild(group);
} }
} }
tr.insertBefore(extraTd, hostTd || null); tr.insertBefore(extraTd, hostTd || null);
@ -1550,6 +1563,7 @@
let attemptsExtraColToggle = 0; let attemptsExtraColToggle = 0;
let attemptsHostListCopy = 0; let attemptsHostListCopy = 0;
let attemptsSvcExport = 0; let attemptsSvcExport = 0;
let attemptsHostExport = 0;
function tryEnhanceFolderSelect() { function tryEnhanceFolderSelect() {
const iDoc = getWatoDoc(FOLDER_SELECT_ID); const iDoc = getWatoDoc(FOLDER_SELECT_ID);
@ -1648,6 +1662,94 @@
addViewWatoMenu(doc); addViewWatoMenu(doc);
} }
// =========================================================================
// FEATURE: Copy host list (view.py search results)
//
// Adds two buttons to the view.py menu bar that copy every host row on the
// page — hostname (FQDN) + IP, deduplicated by hostname — to the clipboard
// as JSON or as tab-separated FQDN/IP lines. Scans every `td.nobr` host
// cell in every `tr.data` of every `table.data` on the page (not a fixed
// column index, unlike addInventoryButtons): some views render 2 hosts
// per physical row as side-by-side column pairs (e.g. "searchhost" with
// num_columns=2), so a single hostColIdx would silently drop half the
// hosts. This works whether results are split across multiple tables
// (grouped by folder) or rendered as a single table, and regardless of
// how many host columns a row has. Reuses extractHostCell (hostname/IP
// extraction) from addInventoryButtons.
// =========================================================================
function collectHostRows(doc) {
const rows = new Map();
doc.querySelectorAll('table.data tr.data').forEach(tr => {
tr.querySelectorAll('td.nobr').forEach(td => {
const r = extractHostCell(td);
if (r && !rows.has(r.hostname)) rows.set(r.hostname, r.ip);
});
});
return [...rows].map(([hostname, ip]) => ({ hostname, ip }));
}
function addHostExportButtons(doc) {
if (doc.body.dataset.cmkHostExport === '1') return;
const menues = doc.querySelector('#page_menu_bar td.menues');
if (!menues) return;
if (!collectHostRows(doc).length) return;
doc.body.dataset.cmkHostExport = '1';
injectStyles(doc, 'cmk-sk-host-export-style', `
.cmk-sk-host-export-btn {
margin-left: 8px; padding: 2px 8px; border-radius: 3px; font-size: 12px;
cursor: pointer; background: transparent; color: #5ab4d6; border: 1px solid #5ab4d6;
}
.cmk-sk-host-export-btn:hover { background: rgba(90,180,214,0.15); }
.cmk-sk-host-export-btn.copied { color: #4caf50; border-color: #4caf50; background: rgba(76,175,80,0.15); }
`);
function mkExportBtn(label, title, build) {
const btn = doc.createElement('button');
btn.className = 'cmk-sk-host-export-btn';
btn.type = 'button';
btn.textContent = label;
btn.title = title;
btn.addEventListener('click', () => {
const list = collectHostRows(doc);
navigator.clipboard.writeText(build(list)).then(() => {
const orig = btn.textContent;
btn.classList.add('copied');
btn.textContent = `Copied ${list.length} hosts`;
setTimeout(() => { btn.classList.remove('copied'); btn.textContent = orig; }, 1500);
});
});
return btn;
}
menues.appendChild(mkExportBtn(
'Copy hosts (JSON)',
'Copy hostname (FQDN) and IP for every host on this page as JSON',
list => JSON.stringify(list, null, 2)
));
menues.appendChild(mkExportBtn(
'Copy hosts (TSV)',
'Copy hostname (FQDN) and IP for every host on this page as tab-separated lines',
list => ['FQDN\tIP', ...list.map(h => `${h.hostname}\t${h.ip}`)].join('\n')
));
}
function tryAddHostExportButtons() {
const doc = getTargetDoc();
if (!doc || !doc.body) {
if (++attemptsHostExport < MAX_ATTEMPTS) setTimeout(tryAddHostExportButtons, POLL_INTERVAL_MS);
return;
}
try { if (!/\/view\.py/.test(doc.location.pathname)) return; } catch (e) { return; }
if (!collectHostRows(doc).length) {
if (++attemptsHostExport < MAX_ATTEMPTS) setTimeout(tryAddHostExportButtons, POLL_INTERVAL_MS);
return;
}
addHostExportButtons(doc);
}
function tryAddWatoFolderMonitorButtons() { function tryAddWatoFolderMonitorButtons() {
const doc = getTargetDoc(); const doc = getTargetDoc();
if (!doc || !doc.body) { if (!doc || !doc.body) {
@ -1938,6 +2040,7 @@
attemptsExtraColToggle = 0; attemptsExtraColToggle = 0;
attemptsHostListCopy = 0; attemptsHostListCopy = 0;
attemptsSvcExport = 0; attemptsSvcExport = 0;
attemptsHostExport = 0;
// Folder select: self-stops if element not found, always schedules. // Folder select: self-stops if element not found, always schedules.
setTimeout(tryEnhanceFolderSelect, 800); setTimeout(tryEnhanceFolderSelect, 800);
// Accordion: only on pages in ACCORDION_MODES. // Accordion: only on pages in ACCORDION_MODES.
@ -1958,6 +2061,8 @@
setTimeout(tryAddHostListCopyButton, 500); setTimeout(tryAddHostListCopyButton, 500);
// WATO menu: on view.py with host rows, self-stops if not applicable. // WATO menu: on view.py with host rows, self-stops if not applicable.
setTimeout(tryAddViewWatoMenu, 800); setTimeout(tryAddViewWatoMenu, 800);
// Host list export (JSON/TSV): on view.py with host rows, self-stops if not applicable.
setTimeout(tryAddHostExportButtons, 800);
// Service export to Markdown: any view.py page with a services table, self-stops if not applicable. // Service export to Markdown: any view.py page with a services table, self-stops if not applicable.
setTimeout(tryAddServiceExportButton, 500); setTimeout(tryAddServiceExportButton, 500);
// Auto-check foreign activation: only on mode=changelog. // Auto-check foreign activation: only on mode=changelog.
@ -2028,6 +2133,14 @@
} }
} catch (e) {} } catch (e) {}
} }
if (tDoc && tDoc.body && !tDoc.body.dataset.cmkHostExport) {
try {
if (/\/view\.py/.test(tDoc.location.pathname) && collectHostRows(tDoc).length) {
attemptsHostExport = 0;
setTimeout(tryAddHostExportButtons, 300);
}
} catch (e) {}
}
if (tDoc && tDoc.body && !tDoc.body.dataset.cmkAccToggle) { if (tDoc && tDoc.body && !tDoc.body.dataset.cmkAccToggle) {
attemptsAccToggle = 0; attemptsAccToggle = 0;
setTimeout(tryAddAccordionToggleButtons, 300); setTimeout(tryAddAccordionToggleButtons, 300);