Compare commits

...

2 commits

Author SHA1 Message Date
4655ea459f Add Columns dropdown and merge export buttons into a menu
Adds a per-column ON/OFF toggle list (with Show all/Hide all) for
native monitoring table columns, replacing the earlier scattered
approach: hiding State also tints the service name with its state
color. Also consolidates the Markdown/host export buttons into a
single Export menu to keep the bar from growing too wide.
2026-09-23 16:26:31 +02:00
b0252672fa 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.
2026-08-25 12:18:30 +02:00
3 changed files with 532 additions and 66 deletions

View file

@ -5,6 +5,19 @@ 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/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [2.22.0] - 2026-09-23
### Added
- "Columns" dropdown in the monitoring view menu bar, on any view.py page with a data table. Lets you hide/show native Checkmk columns that eat horizontal space but aren't always needed (State, Site alias, Host icons, Service icons, Checked, Check command, Groups) — only the columns actually present on the current view are listed, each with its own ON/OFF button plus "Show all"/"Hide all" shortcuts, so it's always clear at a glance what's currently shown. Purely visual, doesn't touch form/checkbox state; the choice is saved and applies to every view. Hiding the State column also tints the service name with that row's state color (green/amber/red/purple), so problems stay identifiable without the column.
### Changed
- "Export to Markdown (.md)", "Copy hosts (JSON)" and "Copy hosts (TSV)" are now grouped under a single "Export ▾" dropdown in the menu bar instead of three separate buttons, to keep the bar from growing too wide now that the Columns dropdown has joined it.
## [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
### Fixed

View file

@ -32,7 +32,9 @@ A [Tampermonkey](https://www.tampermonkey.net/) userscript that enhances the Che
| **ListChoice Filter Bar** | Adds a search box, a "N / total selected" counter and a "Selected only" toggle above large checkbox lists (e.g. "Deploy custom files with agent") | `addListChoiceFilter` |
| **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` |
| **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 an "Export ▾" menu entry 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 "Copy hosts (JSON)"/"Copy hosts (TSV)" entries to the same "Export ▾" menu on any view.py host list, copying hostname (FQDN) and IP for every host on the page to the clipboard | `addHostExportButtons` |
| **Column Visibility Toggle** | Adds a "Columns" dropdown with a per-column ON/OFF button plus "Show all"/"Hide all", to hide/show bulky native columns (State, Site alias, Host/Service icons, Checked, Check command, Groups); only columns present on the current view are listed, and the choice is remembered across reloads. Hiding State tints the service name with its state's color instead | `addColumnVisibilityToggle` |
---
@ -172,10 +174,26 @@ Applies to: `wato.py?mode=folder` (folder browsing and host search results, with
---
### Service Export to Markdown
On any monitoring view showing a services table — detected structurally by a **Host** column plus a **Display name**/**Service** column, not tied to one specific view — adds an **"Export to Markdown (.md)"** button to the menu bar. It turns the visible table into a standard Markdown table, one row per service, copies it to the clipboard and downloads it as a `.md` file in one click. Columns are Site, Host, IP, Display name, Summary, Details in that order, but only the ones actually present on the current view are included — a view without a Details column simply won't have one in the export. Rows are read at click time, so the export always matches what's currently on screen.
On any monitoring view showing a services table — detected structurally by a **Host** column plus a **Display name**/**Service** column, not tied to one specific view — adds an **"Export to Markdown (.md)"** entry to the **"Export ▾"** dropdown in the menu bar. It turns the visible table into a standard Markdown table, one row per service, copies it to the clipboard and downloads it as a `.md` file in one click. Columns are Site, Host, IP, Display name, Summary, Details in that order, but only the ones actually present on the current view are included — a view without a Details column simply won't have one in the export. Rows are read at click time, so the export always matches what's currently on screen.
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)"** entries to the same **"Export ▾"** dropdown, 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).
---
### Column Visibility Toggle
Adds a **"Columns"** dropdown to the menu bar on any monitoring view table. Each entry hides/shows one native Checkmk column — State, Site alias, Host icons, Service icons, Checked, Check command, Groups (service + host) — found by its header text, so it works on any view without being tied to one specific one. Only the columns actually present on the current view are listed, each with its own **ON/OFF** button so it's always clear at a glance what's currently shown, plus **"Show all"**/**"Hide all"** shortcuts at the top of the panel to reset everything in one click. Purely visual (CSS only, never touches form/checkbox state), and the choice is saved so it applies the same way to every monitoring view you open afterwards.
Hiding the **State** column also tints the service name itself with that row's state color (green OK, amber WARN, red CRIT, purple UNKNOWN), so problems stay identifiable at a glance without the column taking up space.
Applies to: any `view.py` page with a data table (with or without sidebar).
## 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.

View file

@ -1,7 +1,7 @@
// ==UserScript==
// @name Checkmk SwissKnife
// @namespace https://luigidacunto.com/
// @version 2.20.2
// @version 2.22.0
// @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.
// @author Luigi D'Acunto
@ -831,6 +831,34 @@
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) {
if (doc.body.dataset.cmkInventoryBtns === '1') return;
@ -895,19 +923,7 @@
let found = false;
tables.forEach(table => {
// Trova indice colonna host dalla prima riga dati con link host
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;
}
const hostColIdx = findHostColIdx(table);
if (hostColIdx === -1) return;
found = true;
@ -936,38 +952,35 @@
extraTd.className = 'cmk-sk-extra-td';
if (hostTd && hostTd.classList.contains('nobr')) {
const a = hostTd.querySelector('a[href*="host="]');
// IP è nel title dello span che wrappa l'anchor: <span title="x.x.x.x"><a>...</a></span>
const ip = (hostTd.querySelector('span[title]') || {}).title || null;
if (a) {
const h = new URLSearchParams(a.getAttribute('href').split('?')[1] || '').get('host');
if (h) {
const hostname = h, shortname = h.split('.')[0];
const r = extractHostCell(hostTd);
if (r) {
const { hostname, ip } = r;
const shortname = hostname.split('.')[0];
const a = hostTd.querySelector('a[href*="host="]');
// Truncate the displayed name (copy buttons below always use the full `hostname`/`shortname`)
const displayName = a.textContent.trim();
if (displayName.length > HOST_DISPLAY_MAX_LEN) {
a.title = displayName;
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);
// Truncate the displayed name (copy buttons below always use the full `hostname`/`shortname`)
const displayName = a.textContent.trim();
if (displayName.length > HOST_DISPLAY_MAX_LEN) {
a.title = displayName;
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);
}
}
tr.insertBefore(extraTd, hostTd || null);
@ -997,16 +1010,36 @@
return localStorage.getItem(EXTRA_COL_STORAGE_KEY) !== '0';
}
// Applies the persisted visibility preference: toggles the hide CSS class
// and resyncs groupheader colspan (base + 1 when visible, base when hidden).
function applyExtraColVisibility(doc) {
const visible = isExtraColVisible();
doc.body.classList.toggle('cmk-sk-extra-hidden', !visible);
doc.querySelectorAll('tr.groupheader td[data-cmk-sk-base-colspan]').forEach(td => {
td.colSpan = Number(td.dataset.cmkSkBaseColspan) + (visible ? 1 : 0);
// Shared by the Extra column toggle and the native Column visibility toggle
// (below): both hide/show whole columns of the same table.data and must
// agree on one final colspan per groupheader row, or they'd stomp on each
// other's write. Recomputed from scratch each time: base (captured before
// either feature touched the table) + 1 if the Extra column is visible,
// minus however many native columns are currently hidden.
function recomputeGroupheaderColspans(doc) {
const extraDelta = isExtraColVisible() ? 1 : 0;
const hidden = getHiddenColumns();
doc.querySelectorAll('table.data').forEach(table => {
const headerRow = [...table.querySelectorAll('tr')].find(tr => tr.querySelector('th'));
if (!headerRow) return;
const headerTexts = [...headerRow.children].map(c => c.textContent.trim());
let hiddenCount = 0;
COLUMN_DEFS.forEach(def => {
if (hidden.has(def.key)) hiddenCount += def.match(headerTexts).length;
});
table.querySelectorAll('tr.groupheader td[data-cmk-sk-base-colspan]').forEach(td => {
td.colSpan = Number(td.dataset.cmkSkBaseColspan) + extraDelta - hiddenCount;
});
});
}
// Applies the persisted visibility preference: toggles the hide CSS class
// and resyncs groupheader colspan.
function applyExtraColVisibility(doc) {
doc.body.classList.toggle('cmk-sk-extra-hidden', !isExtraColVisible());
recomputeGroupheaderColspans(doc);
}
function addExtraColumnToggle(doc) {
if (doc.body.dataset.cmkExtraColToggle === '1') return;
if (!doc.querySelector('.cmk-sk-extra-th')) return;
@ -1057,6 +1090,259 @@
}
// =========================================================================
// FEATURE: Native Column Visibility Toggle
//
// Adds a "Columns" dropdown to the view.py menu bar to hide/show bulky
// native Checkmk columns (state, host/service icons, check command,
// groups, site alias) that eat horizontal space but aren't always needed.
// Only columns actually present on the current view are offered, each with
// its own ON/OFF toggle plus "Show all"/"Hide all" shortcuts. Purely
// visual (CSS display:none on th/td, never touches form/checkbox state),
// so it can't affect Save/Commands. Persisted in localStorage (shared
// across tabs/pages, same origin) so it applies to every monitoring view.
// Hiding State also tints the Service name with that row's state color
// (see applyStateColorTint below), so problems stay identifiable.
// =========================================================================
const COLUMN_VIS_STORAGE_KEY = 'cmkSkHiddenColumns';
// Each def locates its column(s) by header text + position, not a fixed
// index: column order varies per view. "Icons" appears twice on service
// views (host icons right after Host, service icons right after Service),
// so those two are matched by their preceding header, not by text alone.
const COLUMN_DEFS = [
{ key: 'state', label: 'State', match: h => { const i = h.indexOf('State'); return i !== -1 ? [i] : []; } },
{ key: 'siteAlias', label: 'Site alias', match: h => { const i = h.indexOf('Site alias'); return i !== -1 ? [i] : []; } },
// Scans every position, not just the first "Host"/"Service": views that render
// 2 hosts per physical row (e.g. searchhost) repeat the whole header set, so
// indexOf() alone would only catch the first pair and silently miss the rest.
{ key: 'hostIcons', label: 'Host icons', match: h => h.reduce((acc, t, i) => (t === 'Icons' && h[i - 1] === 'Host') ? acc.concat(i) : acc, []) },
{ key: 'svcIcons', label: 'Service icons', match: h => h.reduce((acc, t, i) => (t === 'Icons' && h[i - 1] === 'Service') ? acc.concat(i) : acc, []) },
{ key: 'checked', label: 'Checked', match: h => { const i = h.indexOf('Checked'); return i !== -1 ? [i] : []; } },
{ key: 'checkCommand', label: 'Check command', match: h => { const i = h.indexOf('Check command'); return i !== -1 ? [i] : []; } },
{ key: 'groups', label: 'Groups', match: h => h.reduce((acc, t, i) => t === 'Groups' ? acc.concat(i) : acc, []) },
];
function getHiddenColumns() {
try { return new Set(JSON.parse(localStorage.getItem(COLUMN_VIS_STORAGE_KEY)) || []); }
catch (e) { return new Set(); }
}
function setHiddenColumns(set) {
localStorage.setItem(COLUMN_VIS_STORAGE_KEY, JSON.stringify([...set]));
}
const STATE_COLORS = { '0': '#4caf50', '1': '#e5a500', '2': '#e55b5b', '3': '#a078c8' };
// Whenever the State column is hidden (via the Columns dropdown, in any
// combination — no separate "Clean view" mode needed), the Service name is
// tinted with that row's state color instead, so CRIT/WARN/UNKNOWN stay
// visible at a glance without the column. Driven directly by the
// hidden-columns set. No-op (and clears prior tint) when State is visible,
// or on views without both a State and a Service column.
function applyStateColorTint(doc) {
const stateHidden = getHiddenColumns().has('state');
doc.querySelectorAll('table.data').forEach(table => {
const headerRow = [...table.querySelectorAll('tr')].find(tr => tr.querySelector('th'));
if (!headerRow) return;
const headerTexts = [...headerRow.children].map(c => c.textContent.trim());
const stateIdx = headerTexts.indexOf('State');
const svcIdx = headerTexts.indexOf('Service');
if (stateIdx === -1 || svcIdx === -1) return;
table.querySelectorAll('tr.data').forEach(tr => {
const cells = [...tr.children];
const svcLink = cells[svcIdx]?.querySelector('a') || cells[svcIdx];
if (!svcLink) return;
if (!stateHidden) { svcLink.style.removeProperty('color'); return; }
const stateCls = [...(cells[stateIdx]?.classList || [])].find(c => /^state\d$/.test(c));
const color = stateCls && STATE_COLORS[stateCls.slice(-1)];
if (color) svcLink.style.setProperty('color', color, 'important');
else svcLink.style.removeProperty('color');
});
});
}
// Applies the persisted hide/show set: toggles a class on every th/td at
// the matched index (in every row, header included), resyncs colspan, and
// refreshes the State color tint.
function applyColumnVisibility(doc) {
const hidden = getHiddenColumns();
doc.querySelectorAll('table.data').forEach(table => {
const headerRow = [...table.querySelectorAll('tr')].find(tr => tr.querySelector('th'));
if (!headerRow) return;
const headerTexts = [...headerRow.children].map(c => c.textContent.trim());
table.querySelectorAll('tr.groupheader td[colspan]').forEach(td => {
if (td.dataset.cmkSkBaseColspan === undefined) td.dataset.cmkSkBaseColspan = td.colSpan;
});
COLUMN_DEFS.forEach(def => {
const doHide = hidden.has(def.key);
def.match(headerTexts).forEach(idx => {
table.querySelectorAll('tr').forEach(tr => {
const cell = tr.children[idx];
if (cell) cell.classList.toggle('cmk-sk-col-hidden', doHide);
});
});
});
});
recomputeGroupheaderColspans(doc);
applyStateColorTint(doc);
}
function addColumnVisibilityToggle(doc) {
if (doc.body.dataset.cmkColVisToggle === '1') return;
const menues = doc.querySelector('#page_menu_bar td.menues');
if (!menues) return;
const tables = [...doc.querySelectorAll('table.data')];
const tableHeaderTexts = tables.map(t => {
const headerRow = [...t.querySelectorAll('tr')].find(tr => tr.querySelector('th'));
return headerRow ? [...headerRow.children].map(c => c.textContent.trim()) : [];
});
const presentDefs = COLUMN_DEFS.filter(def => tableHeaderTexts.some(h => def.match(h).length > 0));
if (!presentDefs.length) return;
doc.body.dataset.cmkColVisToggle = '1';
injectStyles(doc, 'cmk-sk-colvis-style', `
.cmk-sk-col-hidden { display: none !important; }
.cmk-sk-colvis-wrap { position: relative; display: inline-block; margin-left: 8px; vertical-align: middle; }
.cmk-sk-colvis-btn {
padding: 2px 8px; border-radius: 3px; font-size: 12px; cursor: pointer;
background: transparent; color: #ddd; border: 1px solid #888;
}
.cmk-sk-colvis-btn:hover { background: rgba(255,255,255,0.1); }
.cmk-sk-colvis-panel {
display: none; position: absolute; top: 100%; left: 0; z-index: 100;
background: #333; border: 1px solid #666; border-radius: 3px; padding: 6px 10px;
white-space: nowrap; box-shadow: 0 2px 6px rgba(0,0,0,0.4); min-width: 190px;
}
.cmk-sk-colvis-panel.open { display: block; }
.cmk-sk-colvis-bulk { display: flex; gap: 6px; padding-bottom: 6px; margin-bottom: 6px; border-bottom: 1px solid #555; }
.cmk-sk-colvis-bulk-btn {
flex: 1; padding: 3px 6px; font-size: 11px; cursor: pointer;
background: #2a2a2a; color: #bbb; border: 1px solid #555; border-radius: 3px;
}
.cmk-sk-colvis-bulk-btn:hover { background: #383838; }
.cmk-sk-colvis-row {
display: flex; align-items: center; justify-content: space-between; gap: 14px;
padding: 3px 0; font-size: 12px; color: #ddd;
}
.cmk-sk-colvis-toggle {
min-width: 34px; padding: 1px 8px; border-radius: 3px; font-size: 11px; font-weight: bold;
cursor: pointer; background: #1a73e8; color: #fff; border: 1px solid #1a73e8;
}
.cmk-sk-colvis-toggle.off { background: #555; color: #ccc; border-color: #777; }
`);
const wrap = doc.createElement('div');
wrap.className = 'cmk-sk-colvis-wrap';
const btn = doc.createElement('button');
btn.type = 'button';
btn.className = 'cmk-sk-colvis-btn';
btn.textContent = 'Columns ▾';
const panel = doc.createElement('div');
panel.className = 'cmk-sk-colvis-panel';
const bulkRow = doc.createElement('div');
bulkRow.className = 'cmk-sk-colvis-bulk';
const showAllBtn = doc.createElement('button');
showAllBtn.type = 'button';
showAllBtn.className = 'cmk-sk-colvis-bulk-btn';
showAllBtn.textContent = 'Show all';
const hideAllBtn = doc.createElement('button');
hideAllBtn.type = 'button';
hideAllBtn.className = 'cmk-sk-colvis-bulk-btn';
hideAllBtn.textContent = 'Hide all';
bulkRow.appendChild(showAllBtn);
bulkRow.appendChild(hideAllBtn);
panel.appendChild(bulkRow);
// Every control below (bulk buttons, per-column toggles) mutates the
// same hidden-columns set, so they all call refreshAll() afterwards to
// stay in sync with each other and with what's actually hidden right
// now — never a checkbox that only reflects its own click.
const rowRefreshers = [];
function refreshAll() {
rowRefreshers.forEach(r => r());
}
presentDefs.forEach(def => {
const row = doc.createElement('div');
row.className = 'cmk-sk-colvis-row';
const label = doc.createElement('span');
label.textContent = def.label;
const toggle = doc.createElement('button');
toggle.type = 'button';
toggle.className = 'cmk-sk-colvis-toggle';
function refreshToggle() {
const visible = !getHiddenColumns().has(def.key);
toggle.textContent = visible ? 'ON' : 'OFF';
toggle.className = 'cmk-sk-colvis-toggle' + (visible ? '' : ' off');
}
toggle.addEventListener('click', () => {
const set = getHiddenColumns();
if (set.has(def.key)) set.delete(def.key); else set.add(def.key);
setHiddenColumns(set);
applyColumnVisibility(doc);
refreshAll();
});
refreshToggle();
rowRefreshers.push(refreshToggle);
row.appendChild(label);
row.appendChild(toggle);
panel.appendChild(row);
});
showAllBtn.addEventListener('click', () => {
const set = getHiddenColumns();
presentDefs.forEach(def => set.delete(def.key));
setHiddenColumns(set);
applyColumnVisibility(doc);
refreshAll();
});
hideAllBtn.addEventListener('click', () => {
const set = getHiddenColumns();
presentDefs.forEach(def => set.add(def.key));
setHiddenColumns(set);
applyColumnVisibility(doc);
refreshAll();
});
btn.addEventListener('click', () => panel.classList.toggle('open'));
doc.addEventListener('click', e => {
if (!wrap.contains(e.target)) panel.classList.remove('open');
});
wrap.appendChild(btn);
wrap.appendChild(panel);
menues.appendChild(wrap);
applyColumnVisibility(doc);
}
function tryAddColumnVisibilityToggle() {
const doc = getTargetDoc();
if (!doc || !doc.body) {
if (++attemptsColVisToggle < MAX_ATTEMPTS) setTimeout(tryAddColumnVisibilityToggle, POLL_INTERVAL_MS);
return;
}
try { if (!/\/view\.py/.test(doc.location.pathname)) return; } catch (e) { return; }
if (!doc.querySelector('table.data')) {
if (++attemptsColVisToggle < MAX_ATTEMPTS) setTimeout(tryAddColumnVisibilityToggle, POLL_INTERVAL_MS);
return;
}
addColumnVisibilityToggle(doc);
}
// =========================================================================
// FEATURE: Monitor Button on wato.py (mode=folder)
//
@ -1250,6 +1536,67 @@
}
// =========================================================================
// Shared "Export" dropdown, used by the two features below (Markdown export
// and Copy hosts JSON/TSV): each used to add its own separate button(s)
// straight to the menu bar, which got crowded once the Columns dropdown
// joined it. Whichever feature runs first creates the dropdown; the other
// just appends into its panel — idempotent via a DOM lookup, not a dataset
// guard, since either one (or both) may need to add to it.
// =========================================================================
function getExportMenu(doc) {
let wrap = doc.querySelector('.cmk-sk-export-wrap');
if (wrap) return wrap.querySelector('.cmk-sk-export-panel');
const menues = doc.querySelector('#page_menu_bar td.menues');
if (!menues) return null;
injectStyles(doc, 'cmk-sk-export-style', `
.cmk-sk-export-wrap { position: relative; display: inline-block; margin-left: 8px; vertical-align: middle; }
.cmk-sk-export-btn {
padding: 2px 8px; border-radius: 3px; font-size: 12px; cursor: pointer;
background: transparent; color: #5ab4d6; border: 1px solid #5ab4d6;
}
.cmk-sk-export-btn:hover { background: rgba(90,180,214,0.15); }
.cmk-sk-export-panel {
display: none; position: absolute; top: 100%; left: 0; z-index: 100;
background: #333; border: 1px solid #666; border-radius: 3px; padding: 4px;
white-space: nowrap; box-shadow: 0 2px 6px rgba(0,0,0,0.4); min-width: 200px;
}
.cmk-sk-export-panel.open { display: block; }
.cmk-sk-export-panel button {
display: block; width: 100%; text-align: left; margin: 0; padding: 5px 8px;
font-size: 12px; background: transparent; border: none; color: #ddd;
cursor: pointer; border-radius: 3px;
}
.cmk-sk-export-panel button:hover { background: rgba(255,255,255,0.1); }
.cmk-sk-export-panel button.copied { color: #4caf50; }
`);
wrap = doc.createElement('div');
wrap.className = 'cmk-sk-export-wrap';
const btn = doc.createElement('button');
btn.type = 'button';
btn.className = 'cmk-sk-export-btn';
btn.textContent = 'Export ▾';
const panel = doc.createElement('div');
panel.className = 'cmk-sk-export-panel';
btn.addEventListener('click', () => panel.classList.toggle('open'));
doc.addEventListener('click', e => {
if (!wrap.contains(e.target)) panel.classList.remove('open');
});
wrap.appendChild(btn);
wrap.appendChild(panel);
menues.appendChild(wrap);
return panel;
}
// =========================================================================
// FEATURE: Export service tables to Markdown
//
@ -1378,23 +1725,13 @@
function addServiceExportButton(doc) {
if (doc.body.dataset.cmkSvcExport === '1') return;
try { if (!/\/view\.py/.test(doc.location.pathname)) return; } catch (e) { return; }
const menues = doc.querySelector('#page_menu_bar td.menues');
if (!menues) return;
if (!findServiceTable(doc)) return;
const panel = getExportMenu(doc);
if (!panel) return;
doc.body.dataset.cmkSvcExport = '1';
injectStyles(doc, 'cmk-sk-svc-export-style', `
.cmk-sk-svc-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-svc-export-btn:hover { background: rgba(90,180,214,0.15); }
.cmk-sk-svc-export-btn.copied { color: #4caf50; border-color: #4caf50; background: rgba(76,175,80,0.15); }
`);
const btn = doc.createElement('button');
btn.className = 'cmk-sk-svc-export-btn';
btn.type = 'button';
btn.textContent = 'Export to Markdown (.md)';
btn.title = 'Copy to clipboard and download as .md: Site, Host, IP, Display name, Summary, Details (whichever are present on this view)';
@ -1414,7 +1751,7 @@
});
});
menues.appendChild(btn);
panel.appendChild(btn);
}
function tryAddServiceExportButton() {
@ -1548,8 +1885,10 @@
let attemptsSitesFilter = 0;
let attemptsListchoice = 0;
let attemptsExtraColToggle = 0;
let attemptsColVisToggle = 0;
let attemptsHostListCopy = 0;
let attemptsSvcExport = 0;
let attemptsHostExport = 0;
function tryEnhanceFolderSelect() {
const iDoc = getWatoDoc(FOLDER_SELECT_ID);
@ -1648,6 +1987,84 @@
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;
if (!collectHostRows(doc).length) return;
const panel = getExportMenu(doc);
if (!panel) return;
doc.body.dataset.cmkHostExport = '1';
function mkExportBtn(label, title, build) {
const btn = doc.createElement('button');
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;
}
panel.appendChild(mkExportBtn(
'Copy hosts (JSON)',
'Copy hostname (FQDN) and IP for every host on this page as JSON',
list => JSON.stringify(list, null, 2)
));
panel.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() {
const doc = getTargetDoc();
if (!doc || !doc.body) {
@ -1936,8 +2353,10 @@
attemptsSitesFilter = 0;
attemptsListchoice = 0;
attemptsExtraColToggle = 0;
attemptsColVisToggle = 0;
attemptsHostListCopy = 0;
attemptsSvcExport = 0;
attemptsHostExport = 0;
// Folder select: self-stops if element not found, always schedules.
setTimeout(tryEnhanceFolderSelect, 800);
// Accordion: only on pages in ACCORDION_MODES.
@ -1958,6 +2377,8 @@
setTimeout(tryAddHostListCopyButton, 500);
// WATO menu: on view.py with host rows, self-stops if not applicable.
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.
setTimeout(tryAddServiceExportButton, 500);
// Auto-check foreign activation: only on mode=changelog.
@ -1968,6 +2389,8 @@
setTimeout(tryAddListChoiceFilter, 600);
// Extra column toggle: on view.py once the Extra column exists, self-stops if not applicable.
setTimeout(tryAddExtraColumnToggle, 700);
// Column visibility toggle: on any view.py table, self-stops if not applicable.
setTimeout(tryAddColumnVisibilityToggle, 700);
}
if (document.readyState === 'complete') {
@ -2028,6 +2451,14 @@
}
} 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) {
attemptsAccToggle = 0;
setTimeout(tryAddAccordionToggleButtons, 300);
@ -2044,6 +2475,10 @@
attemptsExtraColToggle = 0;
setTimeout(tryAddExtraColumnToggle, 300);
}
if (tDoc && tDoc.body && !tDoc.body.dataset.cmkColVisToggle) {
attemptsColVisToggle = 0;
setTimeout(tryAddColumnVisibilityToggle, 300);
}
}).observe(document.body, { childList: true, subtree: true });
// Restart on iframe load (sidebar layout)