checkmk-swissknife/user_script/checkmk_swissknife.user.js
Luigi D'Acunto 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

2490 lines
97 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// ==UserScript==
// @name Checkmk SwissKnife
// @namespace https://luigidacunto.com/
// @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
// @homepageURL https://github.com/luigidacunto/checkmk-swissknife
// @updateURL https://luigidacunto.com/scripts/checkmk-swissknife/user_script/checkmk_swissknife.user.js
// @downloadURL https://luigidacunto.com/scripts/checkmk-swissknife/user_script/checkmk_swissknife.user.js
// @include /^https?:\/\/.+\/check_mk\/(index|wato|view)\.py/
// @grant none
// @license CC-BY-NC-4.0
// ==/UserScript==
(function () {
'use strict';
// =========================================================================
// COMMON INFRASTRUCTURE
// =========================================================================
const LOG_PREFIX = '[CMK-SK]';
const POLL_INTERVAL_MS = 500;
const MAX_ATTEMPTS = 60;
// Returns the document to operate on:
// - If there is an iframe (index.py with sidebar) → use iframe's contentDocument
// - If wato.py is opened directly → use document only if it contains the target select
function getWatoDoc(selectId) {
const iframe = document.querySelector('iframe[name="main"], iframe#main');
if (iframe) {
try { return iframe.contentDocument; } catch (e) { return null; }
}
if (document.getElementById(selectId) ||
document.querySelector('select[name*="folder_path"]') ||
document.getElementById('wato_folder')) {
return document;
}
return null;
}
// Reads the "mode" parameter from the target document URL without accessing the DOM
function getPageMode(iDoc) {
try { return new URLSearchParams(iDoc.location.search).get('mode') || ''; }
catch (e) { return ''; }
}
// Returns the document to operate on, handling both the iframe case (index.py with
// sidebar) and the direct case (wato.py opened without sidebar, no iframe present).
function getTargetDoc() {
const iframe = document.querySelector('iframe[name="main"], iframe#main');
if (iframe) { try { return iframe.contentDocument; } catch (e) { return null; } }
return document;
}
// Pages that support accordion badges (same form_edit_host + table.nform structure)
const ACCORDION_MODES = new Set(['edit_host', 'bulkedit', 'editfolder']);
// Injects CSS into the target document (once only, deduplicated by id)
function injectStyles(iDoc, id, css) {
if (iDoc.getElementById(id)) return;
const style = iDoc.createElement('style');
style.id = id;
style.textContent = css;
iDoc.head.appendChild(style);
}
// =========================================================================
// FEATURE: Folder Path Select Enhancement
//
// Improves the folder path <select> in WATO by showing the full path
// in "Root › Level › Leaf" style and enabling search on it.
// Only activates when the #explicit_conditions_p_folder_path select is present.
// =========================================================================
const FOLDER_SELECT_ID = 'explicit_conditions_p_folder_path';
const FOLDER_DIV_ID = 'explicit_conditions_d_folder_path';
function formatPath(value) {
if (!value) return 'Main';
const parts = value.split('/');
return parts.map((p, i) =>
i === parts.length - 1
? p.toUpperCase()
: p.charAt(0).toUpperCase() + p.slice(1).toLowerCase()
).join(' › ');
}
function enhanceFolderSelect(iDoc) {
const sel = iDoc.getElementById(FOLDER_SELECT_ID);
if (!sel) return false;
if (sel.dataset.cmkEnhanced === '1') return true;
const iWin = iDoc.defaultView;
const S2 = iWin.Select2 || (iWin.$ && iWin.$.fn && iWin.$.fn.select2 ? iWin.$ : null);
if (!S2) return false;
Array.from(sel.options).forEach(opt => {
if (!opt.dataset.fullPath) {
opt.dataset.fullPath = formatPath(opt.value);
}
});
try {
const existing = getSelect2Instance(iDoc, sel);
if (existing && typeof existing.destroy === 'function') existing.destroy();
} catch (e) {
console.warn(LOG_PREFIX, 'Failed to destroy Select2:', e);
}
initSelect2Enhanced(iDoc, sel);
sel.dataset.cmkEnhanced = '1';
return true;
}
function getSelect2Instance(iDoc, sel) {
const iWin = iDoc.defaultView;
if (iWin.$ && iWin.$.fn && iWin.$.fn.select2) {
try { return iWin.$(sel).data('select2'); } catch (e) {}
}
if (iWin.Select2) {
try { return iWin.Select2.getInstance(sel); } catch (e) {}
}
return null;
}
function initSelect2Enhanced(iDoc, sel) {
const iWin = iDoc.defaultView;
const templateResult = function (option) {
if (!option.id) return option.text;
const fullPath = option.element?.dataset?.fullPath || formatPath(option.id);
const span = iDoc.createElement('span');
span.title = fullPath;
span.style.cssText = 'font-family: monospace; font-size: 12px;';
span.textContent = fullPath;
return span;
};
const templateSelection = function (option) {
if (!option.id) return option.text;
return formatPath(option.id);
};
const matcher = function (params, option) {
if (!params.term || params.term.trim() === '') return option;
const term = params.term.trim().toLowerCase();
const fullPath = (option.element?.dataset?.fullPath || formatPath(option.id || '')).toLowerCase();
const leafName = (option.id || '').split('/').pop().toLowerCase();
if (fullPath.includes(term) || leafName.includes(term)) return option;
return null;
};
const config = {
width: 'resolve',
allowClear: false,
dropdownAutoWidth: true,
templateResult,
templateSelection,
matcher,
dropdownCssClass: 'cmk-sk-folder-dropdown',
};
if (iWin.$ && iWin.$.fn && iWin.$.fn.select2) {
try {
iWin.$(sel).select2(config);
injectFolderStyles(iDoc);
return;
} catch (e) {
console.warn(LOG_PREFIX, 'jQuery Select2 init failed:', e);
}
}
if (iWin.Select2) {
try {
new iWin.Select2(sel, config);
injectFolderStyles(iDoc);
return;
} catch (e) {
console.warn(LOG_PREFIX, 'Select2 standalone init failed:', e);
}
}
// Fallback: custom search overlay over the native select2
buildCustomSearchOverlay(iDoc, sel);
}
function buildCustomSearchOverlay(iDoc, sel, containerEl) {
const divContainer = containerEl || iDoc.getElementById(FOLDER_DIV_ID);
if (!divContainer) return;
if (divContainer.querySelector('.cmk-sk-folder-overlay')) return;
const options = Array.from(sel.options).map(opt => ({
value: opt.value,
label: opt.value ? formatPath(opt.value) : 'Main',
original: opt.text.trim()
}));
const existingContainer = divContainer.querySelector('.select2-container');
if (existingContainer) existingContainer.style.display = 'none';
const wrapper = iDoc.createElement('div');
wrapper.className = 'cmk-sk-folder-overlay';
wrapper.style.cssText = `
display: inline-block;
position: relative;
min-width: 300px;
max-width: 600px;
width: 100%;
font-family: var(--font-family, sans-serif);
`;
const searchInput = iDoc.createElement('input');
searchInput.type = 'text';
searchInput.placeholder = 'Search folder by name or path... (e.g.: veeam, dc1/veeam)';
searchInput.autocomplete = 'off';
searchInput.spellcheck = false;
searchInput.style.cssText = `
width: 100%;
padding: 4px 8px;
border: 1px solid #666;
background: #1a1a2e;
color: #e0e0e0;
font-size: 12px;
font-family: monospace;
border-radius: 3px;
box-sizing: border-box;
`;
const currentVal = sel.value;
const currentOpt = options.find(o => o.value === currentVal);
if (currentOpt) searchInput.value = currentOpt.label;
const dropdown = iDoc.createElement('div');
dropdown.style.cssText = `
position: absolute;
top: 100%;
left: 0;
right: 0;
max-height: 300px;
overflow-y: auto;
background: #1a1a2e;
border: 1px solid #555;
border-top: none;
z-index: 99999;
display: none;
font-size: 12px;
font-family: monospace;
min-width: 450px;
`;
const badge = iDoc.createElement('div');
badge.style.cssText = `
font-size: 10px;
color: #aaa;
margin-top: 2px;
padding-left: 2px;
font-family: monospace;
`;
updateBadge();
function updateBadge() {
const v = sel.value;
badge.textContent = v ? `Path: ${v}` : 'Path: / (Main)';
}
function escapeHtml(str) {
return str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
function renderDropdown(filter) {
dropdown.innerHTML = '';
const term = filter.trim().toLowerCase();
// Normalize separators: >, ›, / are equivalent in search
const normTerm = term.replace(/\s*[>/›]\s*/g, '/');
const filtered = term
? options.filter(o =>
o.label.toLowerCase().includes(term) ||
o.label.toLowerCase().replace(/\s*›\s*/g, '/').includes(normTerm) ||
o.value.toLowerCase().includes(normTerm) ||
o.original.toLowerCase().includes(term)
)
: options;
if (filtered.length === 0) {
const noRes = iDoc.createElement('div');
noRes.textContent = 'No results';
noRes.style.cssText = 'padding: 6px 10px; color: #aaa;';
dropdown.appendChild(noRes);
}
filtered.slice(0, 200).forEach((opt) => {
const item = iDoc.createElement('div');
item.dataset.value = opt.value;
item.style.cssText = `
padding: 4px 10px;
cursor: pointer;
color: #e0e0e0;
border-bottom: 1px solid #333;
white-space: nowrap;
`;
if (term) {
const labelLow = opt.label.toLowerCase();
const idx = labelLow.indexOf(term);
if (idx >= 0) {
item.innerHTML =
escapeHtml(opt.label.substring(0, idx)) +
'<mark style="background:#f0a500;color:#000;border-radius:2px;">' +
escapeHtml(opt.label.substring(idx, idx + term.length)) +
'</mark>' +
escapeHtml(opt.label.substring(idx + term.length));
} else {
item.textContent = opt.label;
}
} else {
item.textContent = opt.label;
}
if (opt.value === sel.value) {
item.style.background = '#2a4a6e';
item.style.fontWeight = 'bold';
}
item.addEventListener('mouseenter', () => { item.style.background = '#3a3a5e'; });
item.addEventListener('mouseleave', () => {
item.style.background = opt.value === sel.value ? '#2a4a6e' : '';
});
item.addEventListener('mousedown', (e) => {
e.preventDefault();
selectOption(opt.value, opt.label);
});
dropdown.appendChild(item);
});
if (filtered.length > 200) {
const more = iDoc.createElement('div');
more.textContent = `... and ${filtered.length - 200} more results. Refine your search.`;
more.style.cssText = 'padding: 6px 10px; color: #aaa; font-style: italic;';
dropdown.appendChild(more);
}
dropdown.style.display = 'block';
}
function selectOption(value, label) {
sel.value = value;
sel.dispatchEvent(new Event('change', { bubbles: true }));
searchInput.value = label;
dropdown.style.display = 'none';
updateBadge();
}
function highlightItem(items, idx) {
items.forEach(i => i.classList.remove('highlighted'));
if (items[idx]) {
items[idx].classList.add('highlighted');
items[idx].style.background = '#4a6a9e';
items[idx].scrollIntoView({ block: 'nearest' });
}
}
searchInput.addEventListener('focus', () => {
searchInput.select();
renderDropdown(searchInput.value === currentOpt?.label ? '' : searchInput.value);
});
searchInput.addEventListener('input', () => { renderDropdown(searchInput.value); });
searchInput.addEventListener('blur', () => {
setTimeout(() => {
dropdown.style.display = 'none';
const o = options.find(x => x.value === sel.value);
if (o) searchInput.value = o.label;
}, 200);
});
searchInput.addEventListener('keydown', (e) => {
const items = dropdown.querySelectorAll('[data-value]');
const current = dropdown.querySelector('[data-value].highlighted');
const currentIdx = current ? Array.from(items).indexOf(current) : -1;
if (e.key === 'ArrowDown') {
e.preventDefault();
highlightItem(items, Math.min(currentIdx + 1, items.length - 1));
} else if (e.key === 'ArrowUp') {
e.preventDefault();
highlightItem(items, Math.max(currentIdx - 1, 0));
} else if (e.key === 'Enter') {
e.preventDefault();
const highlighted = dropdown.querySelector('[data-value].highlighted');
if (highlighted) {
selectOption(
highlighted.dataset.value,
options.find(o => o.value === highlighted.dataset.value)?.label || highlighted.textContent
);
}
} else if (e.key === 'Escape') {
dropdown.style.display = 'none';
}
});
wrapper.appendChild(searchInput);
wrapper.appendChild(dropdown);
divContainer.appendChild(wrapper);
divContainer.appendChild(badge);
sel.dataset.cmkEnhanced = '1';
injectFolderStyles(iDoc);
}
function injectFolderStyles(iDoc) {
injectStyles(iDoc, 'cmk-sk-folder-styles', `
.cmk-sk-folder-dropdown .select2-results__option {
font-family: monospace !important;
font-size: 12px !important;
white-space: nowrap !important;
}
.cmk-sk-folder-overlay mark {
background: #f0a500;
color: #000;
border-radius: 2px;
padding: 0 1px;
}
.cmk-sk-folder-overlay [data-value].highlighted {
background: #4a6a9e !important;
}
`);
}
// =========================================================================
// FEATURE: Accordion Checked Count Badge
//
// Shows in each accordion title on the edit_host page the number
// of active checkboxes in the group. E.g.: "Services - DB (1)".
// The counter updates in real time when checkboxes change.
// =========================================================================
function updateAccordionBadge(td) {
const table = td.closest('table.nform');
if (!table) return;
const tbody = table.querySelector('tbody');
if (!tbody) return;
const checked = tbody.querySelectorAll('input[type=checkbox]:checked').length;
const badge = td.querySelector('.cmk-sk-acc-count');
if (!badge) return;
if (checked > 0) {
badge.textContent = `(${checked})`;
badge.style.display = 'inline';
} else {
badge.style.display = 'none';
}
}
function updateInheritedBadge(td, iWin) {
const table = td.closest('table.nform');
if (!table) return;
const tbody = table.querySelector('tbody');
if (!tbody) return;
const count = Array.from(tbody.querySelectorAll('div.inherited')).filter(el =>
iWin.getComputedStyle(el).display !== 'none' && el.textContent.includes('Inherited from')
).length;
const badge = td.querySelector('.cmk-sk-inh-count');
if (!badge) return;
if (count > 0) {
badge.textContent = `↑${count}`;
badge.style.display = 'inline';
} else {
badge.style.display = 'none';
}
}
function updateDiffBadge(td, iWin) {
const table = td.closest('table.nform');
if (!table) return;
const tbody = table.querySelector('tbody');
if (!tbody) return;
const count = Array.from(tbody.querySelectorAll('div.inherited')).filter(el =>
iWin.getComputedStyle(el).display !== 'none' && el.textContent.includes('This value differs')
).length;
const badge = td.querySelector('.cmk-sk-diff-count');
if (!badge) return;
if (count > 0) {
badge.textContent = `≠${count}`;
badge.style.display = 'inline';
} else {
badge.style.display = 'none';
}
}
function initAccordionCheckedCounts(iDoc) {
const form = iDoc.getElementById('form_edit_host');
if (!form) return false;
if (form.dataset.cmkAccBadge === '1') return true;
const iWin = iDoc.defaultView;
const isBulkEdit = getPageMode(iDoc) === 'bulkedit';
injectStyles(iDoc, 'cmk-sk-acc-badge-styles', `
.cmk-sk-acc-count {
margin-left: 6px;
padding: 1px 6px;
background: #f0a500;
color: #000;
border-radius: 9px;
font-size: 11px;
font-weight: bold;
vertical-align: middle;
}
.cmk-sk-inh-count {
margin-left: 4px;
padding: 1px 6px;
background: #5ba4e5;
color: #000;
border-radius: 9px;
font-size: 11px;
font-weight: bold;
vertical-align: middle;
}
.cmk-sk-diff-count {
margin-left: 4px;
padding: 1px 6px;
background: #e55b5b;
color: #fff;
border-radius: 9px;
font-size: 11px;
font-weight: bold;
vertical-align: middle;
}
`);
iDoc.querySelectorAll('table.nform thead tr.heading td').forEach(td => {
const table = td.closest('table.nform');
const tbody = table?.querySelector('tbody');
if (!tbody) return;
const badge = iDoc.createElement('span');
badge.className = 'cmk-sk-acc-count';
badge.style.display = 'none';
const badgeInh = iDoc.createElement('span');
badgeInh.className = 'cmk-sk-inh-count';
badgeInh.style.display = 'none';
const img = td.querySelector('img.treeangle');
const afterImg = img?.nextSibling;
if (afterImg) {
afterImg.after(badge);
badge.after(badgeInh);
} else {
td.appendChild(badge);
td.appendChild(badgeInh);
}
if (isBulkEdit) {
const badgeDiff = iDoc.createElement('span');
badgeDiff.className = 'cmk-sk-diff-count';
badgeDiff.style.display = 'none';
badgeInh.after(badgeDiff);
}
updateAccordionBadge(td);
updateInheritedBadge(td, iWin);
if (isBulkEdit) updateDiffBadge(td, iWin);
tbody.addEventListener('change', (e) => {
if (e.target.type === 'checkbox') {
updateAccordionBadge(td);
updateInheritedBadge(td, iWin);
if (isBulkEdit) updateDiffBadge(td, iWin);
}
});
});
form.dataset.cmkAccBadge = '1';
return true;
}
// =========================================================================
// FEATURE: Ineffective Rule Highlight
//
// In mode=edit_ruleset pages, replaces the barely visible
// "icon_hyphen.svg" (title="Ineffective rule") icon with a colored badge
// and adds a left border to the row.
// Works on both direct wato.py (no iframe) and inside index.py (iframe).
// =========================================================================
function highlightIneffectiveRules(doc) {
if (doc.body.dataset.cmkIneffHighlight === '1') return;
const imgs = doc.querySelectorAll('img.icon[title="Ineffective rule"]');
if (!imgs.length) return;
injectStyles(doc, 'cmk-sk-ineff-styles', `
tr.cmk-sk-ineffective > td:first-child {
border-left: 4px solid #e5a500 !important;
}
tr.cmk-sk-ineffective {
background: rgba(229, 165, 0, 0.08) !important;
}
.cmk-sk-ineff-badge {
display: inline-block;
background: #e5a500;
color: #000;
font-size: 10px;
font-weight: bold;
padding: 2px 6px;
border-radius: 3px;
white-space: nowrap;
font-family: monospace;
vertical-align: middle;
cursor: default;
letter-spacing: 0.03em;
}
`);
imgs.forEach(img => {
const row = img.closest('tr');
if (!row || row.classList.contains('cmk-sk-ineffective')) return;
row.classList.add('cmk-sk-ineffective');
const badge = doc.createElement('span');
badge.className = 'cmk-sk-ineff-badge';
badge.title = 'Ineffective rule';
badge.textContent = '⚠ ineffective';
img.replaceWith(badge);
});
doc.body.dataset.cmkIneffHighlight = '1';
}
// =========================================================================
// FEATURE: Rule Match Status Highlight
//
// In mode=edit_ruleset pages opened with host/service context (host= and
// service= parameters in the URL), highlights matching rows (green) and
// dims non-matching ones (grey), replacing the barely visible icon_checkmark
// and icon_hyphen icons with colored badges.
// =========================================================================
function highlightRuleMatchStatus(doc) {
if (doc.body.dataset.cmkMatchHighlight === '1') return;
const matchImgs = doc.querySelectorAll('img.icon[title^="This rule matches"], img.icon[title="Matches"]');
const noMatchImgs = doc.querySelectorAll('img.icon[title^="This rule does not match"]');
if (!matchImgs.length && !noMatchImgs.length) return;
injectStyles(doc, 'cmk-sk-match-styles', `
tr.cmk-sk-rule-match > td:first-child {
border-left: 4px solid #4caf50 !important;
}
tr.cmk-sk-rule-match {
background: rgba(76, 175, 80, 0.10) !important;
}
tr.cmk-sk-rule-nomatch {
opacity: 0.45;
}
tr.cmk-sk-rule-nomatch > td:first-child {
border-left: 4px solid #555 !important;
}
.cmk-sk-match-badge {
display: inline-block;
background: #4caf50;
color: #fff;
font-size: 10px;
font-weight: bold;
padding: 2px 6px;
border-radius: 3px;
white-space: nowrap;
font-family: monospace;
vertical-align: middle;
cursor: default;
letter-spacing: 0.03em;
}
.cmk-sk-nomatch-badge {
display: inline-block;
background: #444;
color: #888;
font-size: 10px;
font-weight: bold;
padding: 2px 6px;
border-radius: 3px;
white-space: nowrap;
font-family: monospace;
vertical-align: middle;
cursor: default;
letter-spacing: 0.03em;
}
`);
matchImgs.forEach(img => {
const row = img.closest('tr');
if (!row) return;
row.classList.add('cmk-sk-rule-match');
const badge = doc.createElement('span');
badge.className = 'cmk-sk-match-badge';
badge.title = img.title;
badge.textContent = '✓ match';
img.replaceWith(badge);
});
noMatchImgs.forEach(img => {
const row = img.closest('tr');
if (!row) return;
row.classList.add('cmk-sk-rule-nomatch');
const badge = doc.createElement('span');
badge.className = 'cmk-sk-nomatch-badge';
badge.title = img.title;
badge.textContent = '✗ no match';
img.replaceWith(badge);
});
doc.body.dataset.cmkMatchHighlight = '1';
}
// =========================================================================
// FEATURE: Ruleset Filter Toggle
//
// After the highlight functions have marked the relevant rows
// (match, no-match, ineffective), adds a bar at the top with a button
// to hide rows and folders with no relevance. Useful on pages with
// hundreds of rules where only a few are relevant.
// =========================================================================
function addRulesetFilterToggle(doc) {
if (doc.body.dataset.cmkFilterToggle === '1') return;
// Le righe "no match" NON sono rilevanti: il filtro serve proprio a nasconderle
const RELEVANT_SEL = 'tr.cmk-sk-rule-match, tr.cmk-sk-ineffective';
// No highlighted rows = no active search, toggle is useless
const relevantCount = doc.querySelectorAll(RELEVANT_SEL).length;
if (!relevantCount) return;
doc.querySelectorAll('tr.data').forEach(row => {
if (!row.matches(RELEVANT_SEL)) row.classList.add('cmk-sk-irrelevant-row');
});
doc.querySelectorAll('div.foldable_wrapper').forEach(wrapper => {
if (!wrapper.querySelector(RELEVANT_SEL)) wrapper.classList.add('cmk-sk-irrelevant-folder');
});
const irrelevantRows = doc.querySelectorAll('tr.cmk-sk-irrelevant-row').length;
const irrelevantFolders = doc.querySelectorAll('div.foldable_wrapper.cmk-sk-irrelevant-folder').length;
if (!irrelevantRows && !irrelevantFolders) return;
// Guard settato solo qui: se la tabella non era ancora renderizzata
// il prossimo tentativo deve poter riprovare
doc.body.dataset.cmkFilterToggle = '1';
injectStyles(doc, 'cmk-sk-filter-toggle-styles', `
#cmk-sk-filter-bar {
display: flex;
align-items: center;
gap: 10px;
padding: 5px 10px;
margin: 6px 0 4px 0;
background: rgba(0,0,0,0.25);
border: 1px solid #3a3a3a;
border-radius: 4px;
font-size: 11px;
color: #999;
font-family: monospace;
}
#cmk-sk-filter-toggle-btn {
cursor: pointer;
padding: 3px 10px;
border-radius: 3px;
border: 1px solid #555;
background: #2a2a2a;
color: #bbb;
font-size: 11px;
font-family: monospace;
font-weight: bold;
letter-spacing: 0.03em;
}
#cmk-sk-filter-toggle-btn:hover { background: #383838; }
#cmk-sk-filter-toggle-btn.active {
background: #1c3320;
border-color: #4caf50;
color: #4caf50;
}
body.cmk-sk-filter-active tr.cmk-sk-irrelevant-row { display: none !important; }
body.cmk-sk-filter-active div.foldable_wrapper.cmk-sk-irrelevant-folder { display: none !important; }
`);
const bar = doc.createElement('div');
bar.id = 'cmk-sk-filter-bar';
const btn = doc.createElement('button');
btn.id = 'cmk-sk-filter-toggle-btn';
btn.type = 'button';
btn.textContent = 'Relevant only';
const info = doc.createElement('span');
info.textContent = `${relevantCount} relevant · ${irrelevantRows} rows and ${irrelevantFolders} folders not relevant`;
btn.addEventListener('click', () => {
const isActive = doc.body.classList.toggle('cmk-sk-filter-active');
btn.classList.toggle('active', isActive);
btn.textContent = isActive ? 'Show all' : 'Relevant only';
});
bar.appendChild(btn);
bar.appendChild(info);
const anchor = doc.querySelector('div.foldable_wrapper') || doc.querySelector('div.wato');
if (anchor) anchor.before(bar);
}
// =========================================================================
// FEATURE: Inventory Button on view.py
//
// In view.py pages (monitoring views), adds a small button next to each
// hostname in the Host column to directly open the host's Service Discovery
// page in a new tab.
//
// The hostname shown in the Host column link is truncated to
// HOST_DISPLAY_MAX_LEN characters (with a title tooltip holding the full
// name) when it exceeds the threshold. The copy buttons (Extra column)
// always use `hostname`, taken from the `host` href parameter, never the
// truncated text.
// =========================================================================
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;
const tables = doc.querySelectorAll('table.data');
if (!tables.length) return;
injectStyles(doc, 'cmk-sk-inv-btn-styles', `
.cmk-sk-extra-th { text-align: center; padding: 2px 6px; font-size: 11px; white-space: nowrap; }
.cmk-sk-extra-td { white-space: nowrap; padding: 1px 4px; vertical-align: middle; text-align: center; }
.cmk-sk-btn-group { display: inline-flex; align-items: center; gap: 2px; }
.cmk-sk-inv-btn, .cmk-sk-copy-btn, .cmk-sk-copy-short-btn, .cmk-sk-copy-ip-btn {
display: inline-flex !important;
align-items: center !important;
justify-content: center !important;
width: 16px !important;
height: 16px !important;
border-radius: 3px !important;
cursor: pointer !important;
opacity: 0.8;
padding: 0 !important;
margin: 1px !important;
box-sizing: border-box !important;
text-decoration: none !important;
font-size: 0 !important;
line-height: 0 !important;
vertical-align: middle !important;
}
.cmk-sk-inv-btn, .cmk-sk-inv-btn:visited {
background: rgba(229,165,0,0.08) !important;
color: #e5a500 !important;
border: 1px solid #e5a500 !important;
}
.cmk-sk-inv-btn:hover { opacity: 1 !important; background: rgba(229,165,0,0.18) !important; }
.cmk-sk-copy-btn { background: rgba(90,180,214,0.08) !important; color: #5ab4d6 !important; border: 1px solid #5ab4d6 !important; }
.cmk-sk-copy-btn:hover { opacity: 1; background: rgba(90,180,214,0.18) !important; }
.cmk-sk-copy-short-btn { background: rgba(160,120,200,0.08) !important; color: #a078c8 !important; border: 1px solid #a078c8 !important; }
.cmk-sk-copy-short-btn:hover { opacity: 1; background: rgba(160,120,200,0.18) !important; }
.cmk-sk-copy-ip-btn { background: rgba(102,187,106,0.08) !important; color: #66bb6a !important; border: 1px solid #66bb6a !important; }
.cmk-sk-copy-ip-btn:hover { opacity: 1; background: rgba(102,187,106,0.18) !important; }
.cmk-sk-copy-btn.copied, .cmk-sk-copy-short-btn.copied, .cmk-sk-copy-ip-btn.copied {
border-color: #4caf50 !important;
color: #4caf50 !important;
background: rgba(76,175,80,0.12) !important;
opacity: 1;
}
.cmk-sk-btn-group svg { display: block; }
body.cmk-sk-extra-hidden .cmk-sk-extra-th, body.cmk-sk-extra-hidden .cmk-sk-extra-td { display: none; }
`);
const CLIP_SVG = '<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>';
const DISC_SVG = '<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><rect x="2" y="3" width="20" height="14" rx="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/><polyline points="6 9 9 12 14 7" stroke-width="2"/></svg>';
function mkCopy(cls, text, label) {
const b = doc.createElement('button');
b.className = cls; b.type = 'button'; b.title = label; b.innerHTML = CLIP_SVG;
b.addEventListener('click', () => navigator.clipboard.writeText(text).then(() => {
b.classList.add('copied'); setTimeout(() => b.classList.remove('copied'), 900);
}));
return b;
}
let found = false;
tables.forEach(table => {
const hostColIdx = findHostColIdx(table);
if (hostColIdx === -1) return;
found = true;
// Inserisci <th>Extra</th> nell'header (primo tr con th figli)
const headerRow = [...table.querySelectorAll('tr')].find(tr => tr.querySelector('th'));
if (headerRow && !headerRow.querySelector('.cmk-sk-extra-th')) {
const th = doc.createElement('th');
th.textContent = 'Extra';
th.className = 'cmk-sk-extra-th';
headerRow.insertBefore(th, [...headerRow.children][hostColIdx] || null);
}
// Capture the original groupheader colspan BEFORE adding the Extra column,
// so the visibility toggle can add/remove exactly 1 without drifting.
table.querySelectorAll('tr.groupheader td[colspan]').forEach(td => {
if (td.dataset.cmkSkBaseColspan === undefined) td.dataset.cmkSkBaseColspan = td.colSpan;
});
// Processa righe dati
table.querySelectorAll('tr.data').forEach(tr => {
if (tr.querySelector('.cmk-sk-extra-td')) return;
const cells = [...tr.children];
const hostTd = cells[hostColIdx];
const extraTd = doc.createElement('td');
extraTd.className = 'cmk-sk-extra-td';
if (hostTd && hostTd.classList.contains('nobr')) {
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);
}
}
tr.insertBefore(extraTd, hostTd || null);
});
});
if (found) {
doc.body.dataset.cmkInventoryBtns = '1';
applyExtraColVisibility(doc);
}
}
// =========================================================================
// FEATURE: Extra Column Visibility Toggle
//
// Adds an Enable/Disable button to the view.py menu bar (next to the
// "Configure in WATO" selector, when present) to show/hide the Extra
// column added by addInventoryButtons. The choice is persisted in
// localStorage (shared across tabs/pages, same origin) so it survives
// page reloads and applies to every monitoring view.
// =========================================================================
const EXTRA_COL_STORAGE_KEY = 'cmkSkExtraColVisible';
function isExtraColVisible() {
return localStorage.getItem(EXTRA_COL_STORAGE_KEY) !== '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;
const menues = doc.querySelector('#page_menu_bar td.menues');
if (!menues) return;
doc.body.dataset.cmkExtraColToggle = '1';
injectStyles(doc, 'cmk-sk-extra-toggle-style', `
.cmk-sk-extra-toggle-btn {
margin-left: 8px; padding: 2px 8px; border-radius: 3px; font-size: 12px;
cursor: pointer; vertical-align: middle;
background: #1a73e8; color: #fff; border: 1px solid #1a73e8;
}
.cmk-sk-extra-toggle-btn.off { background: #555; color: #ccc; border-color: #777; }
`);
const btn = doc.createElement('button');
btn.type = 'button';
function refresh() {
const visible = isExtraColVisible();
btn.textContent = visible ? 'Extra column: ON' : 'Extra column: OFF';
btn.className = 'cmk-sk-extra-toggle-btn' + (visible ? '' : ' off');
}
btn.addEventListener('click', () => {
localStorage.setItem(EXTRA_COL_STORAGE_KEY, isExtraColVisible() ? '0' : '1');
applyExtraColVisibility(doc);
refresh();
});
refresh();
menues.appendChild(btn);
}
function tryAddExtraColumnToggle() {
const doc = getTargetDoc();
if (!doc || !doc.body) {
if (++attemptsExtraColToggle < MAX_ATTEMPTS) setTimeout(tryAddExtraColumnToggle, POLL_INTERVAL_MS);
return;
}
try { if (!/\/view\.py/.test(doc.location.pathname)) return; } catch (e) { return; }
if (!doc.querySelector('.cmk-sk-extra-th')) {
if (++attemptsExtraColToggle < MAX_ATTEMPTS) setTimeout(tryAddExtraColumnToggle, POLL_INTERVAL_MS);
return;
}
addExtraColumnToggle(doc);
}
// =========================================================================
// 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)
//
// In WATO folder pages, adds a small button next to the hostname of each
// active (non-disabled) host to open the monitoring view in a new tab.
// The button occupies the space where the X icon appears for disabled hosts,
// maintaining column alignment.
// =========================================================================
function addWatoFolderMonitorButtons(doc) {
if (doc.body.dataset.cmkFolderMonBtns === '1') return;
const base = doc.location.pathname.replace(/[^/]*$/, '');
injectStyles(doc, 'cmk-sk-folder-mon-btn-styles', `
.cmk-sk-mon-btn, .cmk-sk-mon-btn:visited {
display: inline-flex !important;
align-items: center !important;
justify-content: center !important;
width: 20px !important;
height: 20px !important;
border-radius: 3px !important;
cursor: pointer !important;
opacity: 0.85;
flex-shrink: 0;
padding: 0 !important;
margin: 0 !important;
box-sizing: border-box !important;
text-decoration: none !important;
font-size: 0 !important;
line-height: 0 !important;
vertical-align: middle !important;
background: rgba(76,175,80,0.1) !important;
color: #4caf50 !important;
border: 1px solid #4caf50 !important;
}
.cmk-sk-mon-btn:hover { opacity: 1 !important; background: rgba(76,175,80,0.22) !important; }
.cmk-sk-mon-btn svg { display: block; }
`);
const EYE_SVG = '<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>';
let active = 0, disabled = 0;
doc.querySelectorAll('table.data td').forEach(td => {
// Hostname cell: contains exactly one link with mode=edit_host whose text is the hostname
const links = td.querySelectorAll('a[href*="mode=edit_host"]');
if (links.length !== 1) return;
const link = links[0];
const hostname = new URLSearchParams((link.getAttribute('href') || '').split('?')[1] || '').get('host');
if (!hostname || link.textContent.trim() !== hostname) return;
// Skip disabled hosts (they have an img with title="This host is disabled" in the same cell)
if (td.querySelector('img[title="This host is disabled"]')) { disabled++; return; }
// Prevent duplicate insertion
if (td.querySelector('.cmk-sk-mon-btn')) return;
const viewUrl = base + 'view.py?host=' + encodeURIComponent(hostname) + '&view_name=host';
const href = base + 'index.py?start_url=' + encodeURIComponent(viewUrl);
const btn = doc.createElement('a');
btn.className = 'cmk-sk-mon-btn';
btn.href = href;
btn.target = '_blank';
btn.rel = 'noopener';
btn.title = `Monitoring: ${hostname}`;
btn.innerHTML = EYE_SVG;
td.insertBefore(btn, link);
td.insertBefore(doc.createTextNode(' '), link);
active++;
});
const h3 = doc.querySelector('h3.table');
if (h3 && !h3.querySelector('.cmk-sk-folder-summary')) {
const summary = doc.createElement('span');
summary.className = 'cmk-sk-folder-summary';
summary.style.cssText = 'float:right;font-size:12px;font-weight:normal;opacity:0.75;letter-spacing:0.02em;';
summary.innerHTML =
'<span style="color:#4caf50">&#9679; ' + active + ' monitorati</span>' +
'<span style="margin:0 6px;opacity:0.4;">|</span>' +
'<span style="color:#e05050">&#9679; ' + disabled + ' disabilitati</span>';
h3.appendChild(summary);
}
if (active > 0 || disabled > 0) doc.body.dataset.cmkFolderMonBtns = '1';
}
// =========================================================================
// FEATURE: "Configure in WATO" menu on view.py
//
// Adds a paginated <select> to the menu bar (Commands/Hosts/Export/...) of
// live monitoring views. Each option opens WATO with a group of up to 50
// hosts, pre-filtered by exact hostname via regex ~^(h1|h2|...)$.
// =========================================================================
const WATO_PAGE_SIZE = 50;
// True if the current user has WATO access (admin/configuration role).
// With sidebar: outer document has wato.py links in Setup nav.
// Without sidebar: any wato.py link in the page suffices (incl. injected Extra column buttons).
function hasWatoAccess(doc) {
if (document !== doc && document.querySelector('a[href*="wato.py"]')) return true;
if (doc.querySelector('a[href*="wato.py"]')) return true;
return false;
}
function addViewWatoMenu(doc) {
if (doc.body.dataset.cmkViewWatoMenu === '1') return;
doc.body.dataset.cmkViewWatoMenu = '1';
if (!hasWatoAccess(doc)) 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;
// Views with a dedicated Host column expose it as td.nobr. Views without one
// (e.g. searchsvc "Service search", grouped by service across hosts) still
// carry host= on other per-row links (Service, Icons, Perf-O-Meter...), so
// fall back to any link in the row when the classic column isn't there.
let hostLinks = [...doc.querySelectorAll('tr.data td.nobr a[href*="host="]')];
if (!hostLinks.length) hostLinks = [...doc.querySelectorAll('tr.data a[href*="host="]')];
const hosts = [...new Set(
hostLinks
.map(a => { const m = a.href.match(/[?&]host=([^&]+)/); return m ? decodeURIComponent(m[1]) : null; })
.filter(Boolean)
)];
if (!hosts.length) return;
const folder = new URLSearchParams(doc.location.search).get('wato_folder') || '';
const base = doc.location.pathname.replace(/[^/]*$/, '');
const pages = Math.ceil(hosts.length / WATO_PAGE_SIZE);
const wrapper = doc.createElement('div');
wrapper.className = 'cmk-sk-wato-menu';
wrapper.style.cssText = 'display:inline-flex;align-items:center;padding:0 8px;border-left:1px solid rgba(255,255,255,0.15);';
const sel = doc.createElement('select');
sel.style.cssText = 'background:#444;color:#ddd;border:1px solid #888;border-radius:3px;padding:2px 5px;font-size:12px;cursor:pointer;vertical-align:middle;';
const placeholder = doc.createElement('option');
placeholder.value = '';
placeholder.disabled = true;
placeholder.selected = true;
placeholder.textContent = 'Select hosts (' + hosts.length + ')';
sel.appendChild(placeholder);
for (let i = 0; i < pages; i++) {
const start = i * WATO_PAGE_SIZE;
const end = Math.min(start + WATO_PAGE_SIZE, hosts.length);
const opt = doc.createElement('option');
opt.value = i;
opt.textContent = 'Host ' + (start + 1) + '-' + end;
sel.appendChild(opt);
}
const BTN_STYLE_OFF = 'margin-left:4px;background:#555;color:#999;border:1px solid #777;border-radius:3px;padding:2px 7px;font-size:12px;cursor:not-allowed;vertical-align:middle;';
const BTN_STYLE_ON = 'margin-left:4px;background:#1a73e8;color:#fff;border:1px solid #1a73e8;border-radius:3px;padding:2px 7px;font-size:12px;cursor:pointer;vertical-align:middle;';
const btn = doc.createElement('button');
btn.textContent = 'Apri';
btn.disabled = true;
btn.style.cssText = BTN_STYLE_OFF;
sel.addEventListener('change', function () {
const valid = sel.value !== '';
btn.disabled = !valid;
btn.style.cssText = valid ? BTN_STYLE_ON : BTN_STYLE_OFF;
});
btn.addEventListener('click', function () {
const page = parseInt(sel.value);
if (isNaN(page)) return;
const slice = hosts.slice(page * WATO_PAGE_SIZE, (page + 1) * WATO_PAGE_SIZE);
const regex = '~^(' + slice.join('|') + ')$';
const url = location.origin + base + 'wato.py?mode=folder'
+ (folder ? '&folder=' + encodeURIComponent(folder) : '')
+ '&host_search=1'
+ '&host_search_host=' + encodeURIComponent(regex)
+ '&filled_in=edit_host';
window.open(url, '_blank');
sel.value = '';
btn.disabled = true;
btn.style.cssText = BTN_STYLE_OFF;
});
wrapper.appendChild(sel);
wrapper.appendChild(btn);
menues.appendChild(wrapper);
}
// =========================================================================
// 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
//
// On any view.py page showing a services table (identified structurally by
// a "Display name"/"Service" column — not tied to one specific view_name),
// adds a menu bar button that turns the visible table into a Markdown
// table and both copies it to the clipboard and triggers a .md file
// download. Only the columns actually present on that view are exported,
// always in this order: Site, Host, IP, Display name, Summary, Details.
// Host/IP need a "Host" column with a per-row host link+tooltip; on
// single-host views without one, Host falls back to the page's own
// `host=` URL parameter and IP is left out. Rows are read live at click
// time (views auto-refresh), so the export always matches what's on
// screen.
// =========================================================================
// Converts a table cell to plain text, turning <br> into newlines
// so multi-line Details/Summary content survives outside the DOM.
function cellToText(cell) {
if (!cell) return '';
const clone = cell.cloneNode(true);
clone.querySelectorAll('br').forEach(br => br.replaceWith('\n'));
return clone.textContent.replace(/\n{3,}/g, '\n\n').trim();
}
// A pipe table row is one physical line, so a cell can't hold a literal
// newline: line breaks become <br> (rendered as a line break, collapsed
// to one line in raw text). Pipe characters are escaped so a "|" in
// Details/Summary can't be mistaken for a column separator.
function mdCell(text) {
return String(text || '').replace(/\|/g, '\\|').replace(/\r?\n/g, '<br>');
}
// Maps this table's actual header labels to the columns we know how to
// export. Returns null if there's no Display name/Service column (not a
// services table). A Host column is common but not required: single-host
// views (e.g. "Services of Host X") omit it because it's implied by the
// page's own `host=` URL parameter instead.
function findServiceColumns(headerCells) {
const dispIdx = headerCells.indexOf('Display name') !== -1
? headerCells.indexOf('Display name')
: headerCells.indexOf('Service');
if (dispIdx === -1) return null;
return {
siteIdx: headerCells.indexOf('Site'),
hostIdx: headerCells.indexOf('Host'),
dispIdx,
summaryIdx: headerCells.indexOf('Summary'),
detailsIdx: headerCells.indexOf('Details'),
};
}
// First table.data on the page with a Display name/Service column.
function findServiceTable(doc) {
return [...doc.querySelectorAll('table.data')].find(t => {
const headerRow = [...t.querySelectorAll('tr')].find(tr => tr.querySelector('th'));
if (!headerRow) return false;
const headerCells = [...headerRow.querySelectorAll('th')].map(th => th.textContent.trim());
return !!findServiceColumns(headerCells);
});
}
function buildServiceExportMarkdown(doc, table) {
const headerRow = [...table.querySelectorAll('tr')].find(tr => tr.querySelector('th'));
if (!headerRow) return '';
const headerCells = [...headerRow.querySelectorAll('th')].map(th => th.textContent.trim());
const idx = findServiceColumns(headerCells);
if (!idx) return '';
const hasHostCol = idx.hostIdx !== -1;
const urlHost = new URLSearchParams(doc.location.search).get('host') || '';
const cols = [];
if (idx.siteIdx !== -1) cols.push({ key: 'site', label: 'Site' });
if (hasHostCol || urlHost) cols.push({ key: 'hostname', label: 'Host' });
if (hasHostCol) cols.push({ key: 'ip', label: 'IP' }); // IP only comes from the per-row host cell tooltip
cols.push({ key: 'displayName', label: 'Display name' });
if (idx.summaryIdx !== -1) cols.push({ key: 'summary', label: 'Summary' });
if (idx.detailsIdx !== -1) cols.push({ key: 'details', label: 'Details' });
const rows = [...table.querySelectorAll('tr.data')].map(tr => {
const cells = [...tr.children];
let hostname = urlHost, ip = '';
if (hasHostCol) {
const hostCell = cells[idx.hostIdx];
const link = hostCell?.querySelector('a[href*="host="]');
hostname = link
? new URLSearchParams((link.getAttribute('href') || '').split('?')[1] || '').get('host')
: cellToText(hostCell);
ip = hostCell?.querySelector('span[title]')?.title || '';
}
return {
site: idx.siteIdx !== -1 ? cellToText(cells[idx.siteIdx]) : '',
hostname: hostname || '',
ip,
displayName: cellToText(cells[idx.dispIdx]),
summary: idx.summaryIdx !== -1 ? cellToText(cells[idx.summaryIdx]) : '',
details: idx.detailsIdx !== -1 ? cellToText(cells[idx.detailsIdx]) : '',
};
});
const lines = [
`# Service export — ${new Date().toISOString()}`,
'',
'| ' + cols.map(c => c.label).join(' | ') + ' |',
'|' + cols.map(() => '---').join('|') + '|',
];
rows.forEach(r => {
lines.push('| ' + cols.map(c => mdCell(r[c.key])).join(' | ') + ' |');
});
return lines.join('\n');
}
function downloadMarkdown(doc, filename, text) {
const blob = new Blob([text], { type: 'text/markdown;charset=utf-8' });
const url = URL.createObjectURL(blob);
const a = doc.createElement('a');
a.href = url;
a.download = filename;
doc.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
}
function addServiceExportButton(doc) {
if (doc.body.dataset.cmkSvcExport === '1') return;
try { if (!/\/view\.py/.test(doc.location.pathname)) return; } catch (e) { return; }
if (!findServiceTable(doc)) return;
const panel = getExportMenu(doc);
if (!panel) return;
doc.body.dataset.cmkSvcExport = '1';
const btn = doc.createElement('button');
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)';
btn.addEventListener('click', () => {
const table = findServiceTable(doc);
const md = table ? buildServiceExportMarkdown(doc, table) : '';
const rowCount = table ? table.querySelectorAll('tr.data').length : 0;
if (!md) return;
downloadMarkdown(doc, `service_export_${Date.now()}.md`, md);
navigator.clipboard.writeText(md).then(() => {
const orig = btn.textContent;
btn.classList.add('copied');
btn.textContent = `Copied + downloaded (${rowCount})`;
setTimeout(() => { btn.classList.remove('copied'); btn.textContent = orig; }, 1800);
});
});
panel.appendChild(btn);
}
function tryAddServiceExportButton() {
const doc = getTargetDoc();
if (!doc || !doc.body) {
if (++attemptsSvcExport < MAX_ATTEMPTS) setTimeout(tryAddServiceExportButton, POLL_INTERVAL_MS);
return;
}
try { if (!/\/view\.py/.test(doc.location.pathname)) return; } catch (e) { return; }
if (!findServiceTable(doc)) {
if (++attemptsSvcExport < MAX_ATTEMPTS) setTimeout(tryAddServiceExportButton, POLL_INTERVAL_MS);
return;
}
addServiceExportButton(doc);
}
// =========================================================================
// FEATURE: Quick status filters on Distributed Monitoring (mode=sites)
//
// Adds a row below the "Add connection" shortcut with 4 toggle buttons that
// filter the site connection list: Disabled / Online (from the
// livestatus_status_<site> icon title), Timeout and Not running (any
// connection_status block in the row reporting a read timeout, resp.
// "Site is not running" - both typically found in the Configuration
// connection / replication_status column).
// Only one filter can be active at a time; clicking the active one clears it.
// =========================================================================
const SITES_FILTER_PREDICATES = {
disabled: row => row.querySelector('[id^="livestatus_status_"] img')?.title === 'Disabled',
online: row => row.querySelector('[id^="livestatus_status_"] img')?.title === 'Online',
timeout: row => [...row.querySelectorAll('.connection_status')].some(d => d.textContent.includes('Read timed out')),
notrunning: row => [...row.querySelectorAll('.connection_status')].some(d => d.textContent.includes('Site is not running')),
};
const SITES_FILTER_LABELS = { notrunning: 'Not running' };
function addSitesFilterBar(doc) {
if (doc.body.dataset.cmkSitesFilter === '1') return;
const suggestionsRow = doc.getElementById('suggestions');
const sitesTable = doc.querySelector('table.data');
if (!suggestionsRow || !sitesTable) return;
doc.body.dataset.cmkSitesFilter = '1';
injectStyles(doc, 'cmk-sk-sites-filter-style', `
#cmk-sk-sites-filter-bar {
display: flex; align-items: center; gap: 6px; padding: 4px 0;
font-family: monospace; font-size: 11px;
}
.cmk-sk-sites-filter-btn {
cursor: pointer; padding: 3px 10px; border-radius: 3px;
background: transparent; font-weight: bold; letter-spacing: 0.03em;
transition: background-color .1s, color .1s;
}
.cmk-sk-sites-filter-btn.disabled { color: #e74c3c; border: 1px solid #e74c3c; }
.cmk-sk-sites-filter-btn.disabled:hover { background: rgba(231,76,60,0.15); }
.cmk-sk-sites-filter-btn.disabled.active { background: #e74c3c; color: #fff; }
.cmk-sk-sites-filter-btn.online { color: #27ae60; border: 1px solid #27ae60; }
.cmk-sk-sites-filter-btn.online:hover { background: rgba(39,174,96,0.15); }
.cmk-sk-sites-filter-btn.online.active { background: #27ae60; color: #fff; }
.cmk-sk-sites-filter-btn.timeout { color: #e67e22; border: 1px solid #e67e22; }
.cmk-sk-sites-filter-btn.timeout:hover { background: rgba(230,126,34,0.15); }
.cmk-sk-sites-filter-btn.timeout.active { background: #e67e22; color: #fff; }
.cmk-sk-sites-filter-btn.notrunning { color: #999; border: 1px solid #999; }
.cmk-sk-sites-filter-btn.notrunning:hover { background: rgba(153,153,153,0.15); }
.cmk-sk-sites-filter-btn.notrunning.active { background: #999; color: #111; }
`);
const tr = doc.createElement('tr');
const td = doc.createElement('td');
td.colSpan = 3;
const bar = doc.createElement('div');
bar.id = 'cmk-sk-sites-filter-bar';
let active = null;
const buttons = {};
function applyFilter() {
sitesTable.querySelectorAll('tr.data').forEach(row => {
row.style.display = (!active || SITES_FILTER_PREDICATES[active](row)) ? '' : 'none';
});
Object.entries(buttons).forEach(([key, btn]) => btn.classList.toggle('active', key === active));
}
Object.keys(SITES_FILTER_PREDICATES).forEach(key => {
const btn = doc.createElement('button');
btn.type = 'button';
btn.className = 'cmk-sk-sites-filter-btn ' + key;
btn.textContent = SITES_FILTER_LABELS[key] || (key.charAt(0).toUpperCase() + key.slice(1));
btn.addEventListener('click', () => {
active = active === key ? null : key;
applyFilter();
});
buttons[key] = btn;
bar.appendChild(btn);
});
td.appendChild(bar);
tr.appendChild(td);
suggestionsRow.after(tr);
}
function tryAddSitesFilterBar() {
const doc = getTargetDoc();
if (!doc || !doc.body) {
if (++attemptsSitesFilter < MAX_ATTEMPTS) setTimeout(tryAddSitesFilterBar, POLL_INTERVAL_MS);
return;
}
if (getPageMode(doc) !== 'sites') return;
if (!doc.getElementById('suggestions') || !doc.querySelector('table.data')) {
if (++attemptsSitesFilter < MAX_ATTEMPTS) setTimeout(tryAddSitesFilterBar, POLL_INTERVAL_MS);
return;
}
addSitesFilterBar(doc);
}
// =========================================================================
// BOOTSTRAP: polling for each feature, activated only if the select is present
// =========================================================================
let attemptsFolder = 0;
let attemptsAcc = 0;
let attemptsRuleset = 0;
let attemptsInventory = 0;
let attemptsFolderMon = 0;
let attemptsViewWato = 0;
let attemptsChangelog = 0;
let attemptsAccToggle = 0;
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);
if (!iDoc || !iDoc.body) {
if (++attemptsFolder < MAX_ATTEMPTS) setTimeout(tryEnhanceFolderSelect, POLL_INTERVAL_MS);
return;
}
const sel = iDoc.getElementById(FOLDER_SELECT_ID);
const selVF = iDoc.getElementById('wato_folder');
if (!sel && !selVF) return;
if (sel && !sel.dataset.cmkEnhanced) {
if (!sel.classList.contains('select2-hidden-accessible')) {
if (++attemptsFolder < MAX_ATTEMPTS) setTimeout(tryEnhanceFolderSelect, POLL_INTERVAL_MS);
return;
}
buildCustomSearchOverlay(iDoc, sel);
}
if (selVF && !selVF.dataset.cmkEnhanced) {
if (!selVF.classList.contains('select2-hidden-accessible')) {
if (++attemptsFolder < MAX_ATTEMPTS) setTimeout(tryEnhanceFolderSelect, POLL_INTERVAL_MS);
return;
}
buildCustomSearchOverlay(iDoc, selVF, selVF.closest('.floatfilter') || selVF.parentElement);
}
}
function tryInitAccordionCounts() {
const iDoc = getWatoDoc('form_edit_host');
if (!iDoc || !iDoc.body) {
if (++attemptsAcc < MAX_ATTEMPTS) setTimeout(tryInitAccordionCounts, POLL_INTERVAL_MS);
return;
}
// URL guard: only activates on pages with supported accordions
if (!ACCORDION_MODES.has(getPageMode(iDoc))) return;
if (!initAccordionCheckedCounts(iDoc)) {
if (++attemptsAcc < MAX_ATTEMPTS) setTimeout(tryInitAccordionCounts, POLL_INTERVAL_MS);
}
}
function tryHighlightRuleset() {
const doc = getTargetDoc();
if (!doc || !doc.body) {
if (++attemptsRuleset < MAX_ATTEMPTS) setTimeout(tryHighlightRuleset, POLL_INTERVAL_MS);
return;
}
if (getPageMode(doc) !== 'edit_ruleset') return;
highlightIneffectiveRules(doc);
highlightRuleMatchStatus(doc);
addRulesetFilterToggle(doc);
}
function tryAddInventoryButtons() {
const doc = getTargetDoc();
if (!doc || !doc.body) {
if (++attemptsInventory < MAX_ATTEMPTS) setTimeout(tryAddInventoryButtons, POLL_INTERVAL_MS);
return;
}
try { if (!/\/view\.py/.test(doc.location.pathname)) return; } catch (e) { return; }
addInventoryButtons(doc);
}
// =========================================================================
// FEATURE: Auto-check "Activate foreign changes" on changelog page
// =========================================================================
function tryAutoCheckForeignActivation() {
const doc = getTargetDoc();
if (!doc || !doc.body) {
if (++attemptsChangelog < MAX_ATTEMPTS) setTimeout(tryAutoCheckForeignActivation, POLL_INTERVAL_MS);
return;
}
try { if (getPageMode(doc) !== 'changelog') return; } catch (e) { return; }
const cb = doc.getElementById('cb_activate_p_foreign');
if (!cb) {
if (++attemptsChangelog < MAX_ATTEMPTS) setTimeout(tryAutoCheckForeignActivation, POLL_INTERVAL_MS);
return;
}
if (!cb.checked) cb.click();
}
function tryAddViewWatoMenu() {
const doc = getTargetDoc();
if (!doc || !doc.body) {
if (++attemptsViewWato < MAX_ATTEMPTS) setTimeout(tryAddViewWatoMenu, POLL_INTERVAL_MS);
return;
}
try { if (!/\/view\.py/.test(doc.location.pathname)) return; } catch (e) { return; }
// Wait until the data table is present (classic Host column, or the
// host= fallback used by views without one, e.g. searchsvc)
if (!doc.querySelector('tr.data td.nobr a[href*="host="], tr.data a[href*="host="]')) {
if (++attemptsViewWato < MAX_ATTEMPTS) setTimeout(tryAddViewWatoMenu, POLL_INTERVAL_MS);
return;
}
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) {
if (++attemptsFolderMon < MAX_ATTEMPTS) setTimeout(tryAddWatoFolderMonitorButtons, POLL_INTERVAL_MS);
return;
}
try { if (!/\/wato\.py/.test(doc.location.pathname)) return; } catch (e) { return; }
if (getPageMode(doc) !== 'folder') return;
addWatoFolderMonitorButtons(doc);
}
// =========================================================================
// FEATURE: Copy host list as JSON
//
// Adds a button to the WATO menu bar on any host-list table (plain folder
// browsing or host search results — both render mode=folder with the same
// table.data columns) that copies hostname/alias/IPv4/monitored site for
// every visible row to the clipboard as JSON.
// =========================================================================
function addHostListCopyButton(doc) {
if (doc.body.dataset.cmkHostListCopy === '1') return;
const menues = doc.querySelector('td.menues');
if (!menues) return;
const tables = [...doc.querySelectorAll('table.data')].filter(t =>
[...t.querySelectorAll('tr th')].some(th => th.textContent.trim() === 'Host name'));
if (!tables.length) return;
doc.body.dataset.cmkHostListCopy = '1';
injectStyles(doc, 'cmk-sk-hostlist-copy-style', `
.cmk-sk-hostlist-copy-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-hostlist-copy-btn:hover { background: rgba(90,180,214,0.15); }
.cmk-sk-hostlist-copy-btn.copied { color: #4caf50; border-color: #4caf50; background: rgba(76,175,80,0.15); }
`);
const btn = doc.createElement('button');
btn.className = 'cmk-sk-hostlist-copy-btn';
btn.type = 'button';
btn.textContent = 'Copy host list (JSON)';
btn.title = 'Copy hostname, alias, IPv4 address and monitored site as JSON';
btn.addEventListener('click', () => {
const hosts = [];
tables.forEach(table => {
const headerRow = [...table.querySelectorAll('tr')].find(tr => tr.querySelector('th'));
if (!headerRow) return;
const headerCells = [...headerRow.querySelectorAll('th')].map(th => th.textContent.trim());
const hostIdx = headerCells.indexOf('Host name');
const aliasIdx = headerCells.indexOf('Alias');
const ipIdx = headerCells.indexOf('IPv4 address');
const siteIdx = headerCells.indexOf('Monitored on site');
if (hostIdx === -1) return;
table.querySelectorAll('tr.data').forEach(tr => {
const cells = [...tr.children];
const link = cells[hostIdx] && cells[hostIdx].querySelector('a[href*="mode=edit_host"]');
if (!link) return;
const hostname = new URLSearchParams((link.getAttribute('href') || '').split('?')[1] || '').get('host');
if (!hostname) return;
hosts.push({
hostname,
alias: aliasIdx !== -1 ? cells[aliasIdx].textContent.trim() : '',
ipv4: ipIdx !== -1 ? cells[ipIdx].textContent.trim() : '',
site: siteIdx !== -1 ? cells[siteIdx].textContent.trim() : '',
});
});
});
navigator.clipboard.writeText(JSON.stringify(hosts, null, 2)).then(() => {
const orig = btn.textContent;
btn.classList.add('copied');
btn.textContent = `Copied ${hosts.length} hosts`;
setTimeout(() => { btn.classList.remove('copied'); btn.textContent = orig; }, 1500);
});
});
menues.appendChild(btn);
}
function tryAddHostListCopyButton() {
const doc = getTargetDoc();
if (!doc || !doc.body) {
if (++attemptsHostListCopy < MAX_ATTEMPTS) setTimeout(tryAddHostListCopyButton, POLL_INTERVAL_MS);
return;
}
try { if (!/\/wato\.py/.test(doc.location.pathname)) return; } catch (e) { return; }
if (getPageMode(doc) !== 'folder') return;
addHostListCopyButton(doc);
}
// =========================================================================
// Collapse All / Expand All buttons in the top menu bar.
// Works on edit_host / bulkedit / editfolder (table.nform accordions)
// and edit_ruleset (div.foldable accordions). Uses getTargetDoc() so it
// works with and without the sidebar iframe.
// =========================================================================
function addAccordionToggleButtons(doc) {
if (doc.body.dataset.cmkAccToggle === '1') return;
try { if (!/\/wato\.py/.test(doc.location.pathname)) return; } catch (e) { return; }
const menues = doc.querySelector('td.menues');
if (!menues) return;
if (!doc.querySelector('table.nform') && !doc.querySelector('div.foldable')) return;
injectStyles(doc, 'cmk-sk-acc-toggle-style', `
#cmk-sk-acc-toggle-btns {
display: inline-flex; align-items: center; gap: 4px;
margin-left: 10px; padding-left: 10px;
border-left: 1px solid rgba(255,255,255,0.2);
vertical-align: middle;
}
.cmk-sk-acc-btn {
background: transparent; border-radius: 3px;
padding: 2px 6px; font-size: 11px; font-family: monospace;
font-weight: bold; cursor: pointer; white-space: nowrap; letter-spacing: 0.03em;
}
.cmk-sk-acc-btn.collapse { color: #e67e22; border: 1px solid #e67e22; }
.cmk-sk-acc-btn.collapse:hover { background: rgba(230,126,34,0.15); }
.cmk-sk-acc-btn.expand { color: #27ae60; border: 1px solid #27ae60; }
.cmk-sk-acc-btn.expand:hover { background: rgba(39,174,96,0.15); }
`);
const wrap = doc.createElement('div');
wrap.id = 'cmk-sk-acc-toggle-btns';
const collapseBtn = doc.createElement('button');
collapseBtn.className = 'cmk-sk-acc-btn collapse';
collapseBtn.textContent = 'Collapse All';
collapseBtn.addEventListener('click', () => {
doc.querySelectorAll('table.nform.open thead tr.heading td[onclick]').forEach(td => td.click());
doc.querySelectorAll('div.foldable.open div.foldable_header[onclick]').forEach(h => h.click());
});
const expandBtn = doc.createElement('button');
expandBtn.className = 'cmk-sk-acc-btn expand';
expandBtn.textContent = 'Expand All';
expandBtn.addEventListener('click', () => {
doc.querySelectorAll('table.nform.closed thead tr.heading td[onclick]').forEach(td => td.click());
doc.querySelectorAll('div.foldable.closed div.foldable_header[onclick]').forEach(h => h.click());
});
wrap.appendChild(collapseBtn);
wrap.appendChild(expandBtn);
menues.appendChild(wrap);
doc.body.dataset.cmkAccToggle = '1';
}
function tryAddAccordionToggleButtons() {
const doc = getTargetDoc();
if (!doc || !doc.body) {
if (++attemptsAccToggle < MAX_ATTEMPTS) setTimeout(tryAddAccordionToggleButtons, POLL_INTERVAL_MS);
return;
}
addAccordionToggleButtons(doc);
}
// =========================================================================
// FEATURE: ListChoice Filter Bar
//
// Checkmk's native ListChoice widget (table.listchoice) renders one row
// per item with no grouping or pagination. On rules like "Deploy custom
// files with agent" this means 100+ checkboxes to scroll through to find
// the 3-4 that actually need to be checked. Adds a search box, a live
// selected-count, and a "Selected only" toggle above each such table.
// Pure visibility filter (hides <tr> via CSS class): never touches
// checked state or name attributes, so native form submission on Save
// is completely unaffected.
// =========================================================================
const LISTCHOICE_MIN_ITEMS = 15; // below this, scrolling is not a real problem
function addListChoiceFilter(doc) {
if (doc.body.dataset.cmkListchoiceFilter === '1') return;
const tables = [...doc.querySelectorAll('table.listchoice')]
.filter(t => t.querySelectorAll('input[type="checkbox"]').length >= LISTCHOICE_MIN_ITEMS);
if (!tables.length) return;
doc.body.dataset.cmkListchoiceFilter = '1';
injectStyles(doc, 'cmk-sk-listchoice-filter-styles', `
.cmk-sk-lc-bar {
display: flex; align-items: center; gap: 8px;
padding: 5px 8px; margin-bottom: 4px;
background: rgba(0,0,0,0.25); border: 1px solid #3a3a3a; border-radius: 4px;
font-size: 11px; font-family: monospace; color: #999;
}
.cmk-sk-lc-search {
flex: 0 0 220px; padding: 3px 6px; font-size: 11px; font-family: monospace;
border-radius: 3px; border: 1px solid #555; background: #1c1c1c; color: #ddd;
}
.cmk-sk-lc-toggle {
cursor: pointer; padding: 3px 10px; border-radius: 3px; border: 1px solid #555;
background: #2a2a2a; color: #bbb; font-size: 11px; font-family: monospace;
font-weight: bold; letter-spacing: 0.03em;
}
.cmk-sk-lc-toggle:hover { background: #383838; }
.cmk-sk-lc-toggle.active { background: #1c3320; border-color: #4caf50; color: #4caf50; }
.cmk-sk-lc-hidden { display: none !important; }
`);
tables.forEach(table => {
const rows = [...table.querySelectorAll('tr')].map(tr => {
const cb = tr.querySelector('input[type="checkbox"]');
const label = tr.querySelector('label');
return cb && label ? { tr, cb, text: label.textContent.trim().toLowerCase() } : null;
}).filter(Boolean);
if (!rows.length) return;
const bar = doc.createElement('div');
bar.className = 'cmk-sk-lc-bar';
const search = doc.createElement('input');
search.type = 'text';
search.className = 'cmk-sk-lc-search';
search.placeholder = 'Filter…';
const toggle = doc.createElement('button');
toggle.type = 'button';
toggle.className = 'cmk-sk-lc-toggle';
toggle.textContent = 'Selected only';
const count = doc.createElement('span');
let selectedOnly = false;
function updateCount() {
const selected = rows.filter(r => r.cb.checked).length;
count.textContent = `${selected} / ${rows.length} selected`;
}
function applyFilter() {
const q = search.value.trim().toLowerCase();
rows.forEach(r => {
const matchesSearch = !q || r.text.includes(q);
const matchesToggle = !selectedOnly || r.cb.checked;
r.tr.classList.toggle('cmk-sk-lc-hidden', !(matchesSearch && matchesToggle));
});
}
search.addEventListener('input', applyFilter);
toggle.addEventListener('click', () => {
selectedOnly = !selectedOnly;
toggle.classList.toggle('active', selectedOnly);
toggle.textContent = selectedOnly ? 'Show all' : 'Selected only';
applyFilter();
});
rows.forEach(r => r.cb.addEventListener('change', updateCount));
bar.appendChild(search);
bar.appendChild(toggle);
bar.appendChild(count);
table.before(bar);
updateCount();
});
}
function tryAddListChoiceFilter() {
const doc = getTargetDoc();
if (!doc || !doc.body) {
if (++attemptsListchoice < MAX_ATTEMPTS) setTimeout(tryAddListChoiceFilter, POLL_INTERVAL_MS);
return;
}
addListChoiceFilter(doc);
}
function init() {
const iDoc = getWatoDoc(FOLDER_SELECT_ID);
const mode = getPageMode(iDoc);
const targetDoc = getTargetDoc();
const targetMode = getPageMode(targetDoc);
attemptsFolder = 0;
attemptsAcc = 0;
attemptsRuleset = 0;
attemptsInventory = 0;
attemptsFolderMon = 0;
attemptsViewWato = 0;
attemptsChangelog = 0;
attemptsAccToggle = 0;
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.
if (!mode || ACCORDION_MODES.has(mode)) {
setTimeout(tryInitAccordionCounts, 800);
}
// Ruleset enhancements (ineffective + match status): only on edit_ruleset.
if (!targetMode || targetMode === 'edit_ruleset') {
setTimeout(tryHighlightRuleset, 300);
}
// Collapse/Expand All: on pages with nform or foldable accordions.
setTimeout(tryAddAccordionToggleButtons, 600);
// Inventory button: on view.py, self-stops if not applicable.
setTimeout(tryAddInventoryButtons, 500);
// Monitor button: on wato.py mode=folder, self-stops if not applicable.
setTimeout(tryAddWatoFolderMonitorButtons, 500);
// Copy host list button: on wato.py mode=folder, self-stops if not applicable.
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.
if (!targetMode || targetMode === 'changelog') setTimeout(tryAutoCheckForeignActivation, 500);
// Sites status filters: only on mode=sites, self-stops if not applicable.
if (!targetMode || targetMode === 'sites') setTimeout(tryAddSitesFilterBar, 500);
// ListChoice filter bar: on any page with a large table.listchoice, self-stops if not applicable.
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') {
init();
} else {
window.addEventListener('load', init);
}
// Detect SPA navigation (page change without full reload)
new MutationObserver(() => {
const iDoc = getWatoDoc(FOLDER_SELECT_ID);
if (!iDoc) return;
const mode = getPageMode(iDoc);
const sel = iDoc.getElementById(FOLDER_SELECT_ID);
if (sel && sel.classList.contains('select2-hidden-accessible') && !sel.dataset.cmkEnhanced) {
attemptsFolder = 0;
setTimeout(tryEnhanceFolderSelect, 300);
}
const selVF = iDoc.getElementById('wato_folder');
if (selVF && selVF.classList.contains('select2-hidden-accessible') && !selVF.dataset.cmkEnhanced) {
attemptsFolder = 0;
setTimeout(tryEnhanceFolderSelect, 300);
}
if (ACCORDION_MODES.has(mode)) {
const form = iDoc.getElementById('form_edit_host');
if (form && !form.dataset.cmkAccBadge) {
attemptsAcc = 0;
setTimeout(tryInitAccordionCounts, 300);
}
}
if (mode === 'edit_ruleset' && iDoc.body &&
(!iDoc.body.dataset.cmkIneffHighlight || !iDoc.body.dataset.cmkMatchHighlight)) {
attemptsRuleset = 0;
setTimeout(tryHighlightRuleset, 300);
}
if (mode === 'folder' && iDoc.body && !iDoc.body.dataset.cmkFolderMonBtns) {
attemptsFolderMon = 0;
setTimeout(tryAddWatoFolderMonitorButtons, 300);
}
if (mode === 'folder' && iDoc.body && !iDoc.body.dataset.cmkHostListCopy) {
attemptsHostListCopy = 0;
setTimeout(tryAddHostListCopyButton, 300);
}
const tDoc = getTargetDoc();
if (tDoc && tDoc.body && !tDoc.body.dataset.cmkViewWatoMenu) {
try {
if (/\/view\.py/.test(tDoc.location.pathname) && tDoc.querySelector('tr.data td.nobr a[href*="host="], tr.data a[href*="host="]')) {
attemptsViewWato = 0;
setTimeout(tryAddViewWatoMenu, 300);
}
} catch (e) {}
}
if (tDoc && tDoc.body && !tDoc.body.dataset.cmkSvcExport) {
try {
if (/\/view\.py/.test(tDoc.location.pathname) && findServiceTable(tDoc)) {
attemptsSvcExport = 0;
setTimeout(tryAddServiceExportButton, 300);
}
} 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);
}
if (mode === 'sites' && iDoc.body && !iDoc.body.dataset.cmkSitesFilter) {
attemptsSitesFilter = 0;
setTimeout(tryAddSitesFilterBar, 300);
}
if (tDoc && tDoc.body && !tDoc.body.dataset.cmkListchoiceFilter) {
attemptsListchoice = 0;
setTimeout(tryAddListChoiceFilter, 300);
}
if (tDoc && tDoc.body && !tDoc.body.dataset.cmkExtraColToggle) {
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)
const mainIframe = document.querySelector('iframe[name="main"], iframe#main');
if (mainIframe) {
mainIframe.addEventListener('load', init);
}
})();