// ==UserScript== // @name Checkmk SwissKnife // @namespace https://luigidacunto.com/ // @version 2.16.0 // @checkmk 2.3.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 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; const hosts = [...new Set( [...doc.querySelectorAll('tr.data td.nobr a[href*="host="]')] .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); } // ========================================================================= // 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_ 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; 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 if (!doc.querySelector('tr.data td.nobr a[href*="host="]')) { if (++attemptsViewWato < MAX_ATTEMPTS) setTimeout(tryAddViewWatoMenu, POLL_INTERVAL_MS); return; } addViewWatoMenu(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); } // ========================================================================= // 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); } 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; // 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); // WATO menu: on view.py with host rows, self-stops if not applicable. setTimeout(tryAddViewWatoMenu, 800); // 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); } 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); } 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="]')) { attemptsViewWato = 0; setTimeout(tryAddViewWatoMenu, 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); } }).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); } })();