Add "Export to Markdown (.md)" button for services tables

Adds a menu bar button on any view.py page showing a services table
(detected by its Display name/Service column, not tied to one
specific view) that exports Site/Host/IP/Display name/Summary/Details
- whichever are actually present on that view - as a Markdown table,
copying it to the clipboard and downloading it as a .md file.
This commit is contained in:
Luigi D'Acunto 2026-07-31 19:31:38 +02:00
parent a5f7b1a934
commit 3990656407
3 changed files with 208 additions and 1 deletions

View file

@ -5,6 +5,11 @@ All notable changes to **Checkmk SwissKnife** are documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [2.20.0] - 2026-07-31
### Added
- "Export to Markdown (.md)" button in the menu bar, on any monitoring view showing a services table (detected by its Host + Display name/Service columns, not tied to one specific view). Exports a Markdown table with whichever of Site, Host, IP, Display name, Summary, Details are present on that view, copies it to the clipboard and downloads it as a `.md` file in one click.
## [2.19.0] - 2026-07-31
### Added

View file

@ -32,6 +32,7 @@ A [Tampermonkey](https://www.tampermonkey.net/) userscript that enhances the Che
| **ListChoice Filter Bar** | Adds a search box, a "N / total selected" counter and a "Selected only" toggle above large checkbox lists (e.g. "Deploy custom files with agent") | `addListChoiceFilter` |
| **Extra Column Toggle** | Adds an ON/OFF button to the menu bar to show/hide the Extra column; the choice is remembered across reloads | `addExtraColumnToggle` |
| **Copy Host List (JSON)** | Adds a button to the menu bar that copies hostname, alias, IPv4 address and monitored site for every host in the list to the clipboard as JSON | `addHostListCopyButton` |
| **Service Export to Markdown** | On any view.py services table (Host + Display name/Service columns), adds a button that exports a Markdown table with whichever of Site/Host/IP/Display name/Summary/Details are present, to the clipboard and as a downloaded `.md` file | `addServiceExportButton` |
---
@ -167,6 +168,13 @@ Adds a **"Copy host list (JSON)"** button to the menu bar on any WATO host-list
Applies to: `wato.py?mode=folder` (folder browsing and host search results, with or without sidebar).
---
### Service Export to Markdown
On any monitoring view showing a services table — detected structurally by a **Host** column plus a **Display name**/**Service** column, not tied to one specific view — adds an **"Export to Markdown (.md)"** button to the menu bar. It turns the visible table into a standard Markdown table, one row per service, copies it to the clipboard and downloads it as a `.md` file in one click. Columns are Site, Host, IP, Display name, Summary, Details in that order, but only the ones actually present on the current view are included — a view without a Details column simply won't have one in the export. Rows are read at click time, so the export always matches what's currently on screen.
Applies to: any `view.py` page with a services table (with or without sidebar).
## How it works
The script injects enhancements at page load and re-applies them on SPA navigation (Checkmk reloads content inside an `<iframe>` without a full page reload). Each feature is independent — a feature that does not apply to the current page does nothing and stops polling.

View file

@ -1,7 +1,7 @@
// ==UserScript==
// @name Checkmk SwissKnife
// @namespace https://luigidacunto.com/
// @version 2.19.0
// @version 2.20.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
@ -1244,6 +1244,188 @@
}
// =========================================================================
// 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; }
const menues = doc.querySelector('#page_menu_bar td.menues');
if (!menues) return;
if (!findServiceTable(doc)) return;
doc.body.dataset.cmkSvcExport = '1';
injectStyles(doc, 'cmk-sk-svc-export-style', `
.cmk-sk-svc-export-btn {
margin-left: 8px; padding: 2px 8px; border-radius: 3px; font-size: 12px;
cursor: pointer; background: transparent; color: #5ab4d6; border: 1px solid #5ab4d6;
}
.cmk-sk-svc-export-btn:hover { background: rgba(90,180,214,0.15); }
.cmk-sk-svc-export-btn.copied { color: #4caf50; border-color: #4caf50; background: rgba(76,175,80,0.15); }
`);
const btn = doc.createElement('button');
btn.className = 'cmk-sk-svc-export-btn';
btn.type = 'button';
btn.textContent = 'Export to Markdown (.md)';
btn.title = 'Copy to clipboard and download as .md: Site, Host, IP, Display name, Summary, Details (whichever are present on this view)';
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);
});
});
menues.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)
//
@ -1361,6 +1543,7 @@
let attemptsListchoice = 0;
let attemptsExtraColToggle = 0;
let attemptsHostListCopy = 0;
let attemptsSvcExport = 0;
function tryEnhanceFolderSelect() {
const iDoc = getWatoDoc(FOLDER_SELECT_ID);
@ -1747,6 +1930,7 @@
attemptsListchoice = 0;
attemptsExtraColToggle = 0;
attemptsHostListCopy = 0;
attemptsSvcExport = 0;
// Folder select: self-stops if element not found, always schedules.
setTimeout(tryEnhanceFolderSelect, 800);
// Accordion: only on pages in ACCORDION_MODES.
@ -1767,6 +1951,8 @@
setTimeout(tryAddHostListCopyButton, 500);
// WATO menu: on view.py with host rows, self-stops if not applicable.
setTimeout(tryAddViewWatoMenu, 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.
@ -1827,6 +2013,14 @@
}
} 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.cmkAccToggle) {
attemptsAccToggle = 0;
setTimeout(tryAddAccordionToggleButtons, 300);