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:
parent
a5f7b1a934
commit
3990656407
3 changed files with 208 additions and 1 deletions
|
|
@ -5,6 +5,11 @@ All notable changes to **Checkmk SwissKnife** are documented in this file.
|
||||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
|
## [2.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
|
## [2.19.0] - 2026-07-31
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
|
||||||
|
|
@ -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` |
|
| **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` |
|
| **Extra Column Toggle** | Adds an ON/OFF button to the menu bar to show/hide the Extra column; the choice is remembered across reloads | `addExtraColumnToggle` |
|
||||||
| **Copy Host List (JSON)** | Adds a button to the menu bar that copies hostname, alias, IPv4 address and monitored site for every host in the list to the clipboard as JSON | `addHostListCopyButton` |
|
| **Copy Host List (JSON)** | Adds a button to the menu bar that copies hostname, alias, IPv4 address and monitored site for every host in the list to the clipboard as JSON | `addHostListCopyButton` |
|
||||||
|
| **Service Export to Markdown** | On any view.py services table (Host + Display name/Service columns), adds a button that exports a Markdown table with whichever of Site/Host/IP/Display name/Summary/Details are present, to the clipboard and as a downloaded `.md` file | `addServiceExportButton` |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -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).
|
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
|
## How it works
|
||||||
|
|
||||||
The script injects enhancements at page load and re-applies them on SPA navigation (Checkmk reloads content inside an `<iframe>` without a full page reload). Each feature is independent — a feature that does not apply to the current page does nothing and stops polling.
|
The script injects enhancements at page load and re-applies them on SPA navigation (Checkmk reloads content inside an `<iframe>` without a full page reload). Each feature is independent — a feature that does not apply to the current page does nothing and stops polling.
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
// ==UserScript==
|
// ==UserScript==
|
||||||
// @name Checkmk SwissKnife
|
// @name Checkmk SwissKnife
|
||||||
// @namespace https://luigidacunto.com/
|
// @namespace https://luigidacunto.com/
|
||||||
// @version 2.19.0
|
// @version 2.20.0
|
||||||
// @checkmk 2.3.x - 2.4.x
|
// @checkmk 2.3.x - 2.4.x
|
||||||
// @description Collection of UI improvements for Checkmk WATO. Each fix or enhancement is added here as an independent feature.
|
// @description Collection of UI improvements for Checkmk WATO. Each fix or enhancement is added here as an independent feature.
|
||||||
// @author Luigi D'Acunto
|
// @author Luigi D'Acunto
|
||||||
|
|
@ -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)
|
// FEATURE: Quick status filters on Distributed Monitoring (mode=sites)
|
||||||
//
|
//
|
||||||
|
|
@ -1361,6 +1543,7 @@
|
||||||
let attemptsListchoice = 0;
|
let attemptsListchoice = 0;
|
||||||
let attemptsExtraColToggle = 0;
|
let attemptsExtraColToggle = 0;
|
||||||
let attemptsHostListCopy = 0;
|
let attemptsHostListCopy = 0;
|
||||||
|
let attemptsSvcExport = 0;
|
||||||
|
|
||||||
function tryEnhanceFolderSelect() {
|
function tryEnhanceFolderSelect() {
|
||||||
const iDoc = getWatoDoc(FOLDER_SELECT_ID);
|
const iDoc = getWatoDoc(FOLDER_SELECT_ID);
|
||||||
|
|
@ -1747,6 +1930,7 @@
|
||||||
attemptsListchoice = 0;
|
attemptsListchoice = 0;
|
||||||
attemptsExtraColToggle = 0;
|
attemptsExtraColToggle = 0;
|
||||||
attemptsHostListCopy = 0;
|
attemptsHostListCopy = 0;
|
||||||
|
attemptsSvcExport = 0;
|
||||||
// Folder select: self-stops if element not found, always schedules.
|
// Folder select: self-stops if element not found, always schedules.
|
||||||
setTimeout(tryEnhanceFolderSelect, 800);
|
setTimeout(tryEnhanceFolderSelect, 800);
|
||||||
// Accordion: only on pages in ACCORDION_MODES.
|
// Accordion: only on pages in ACCORDION_MODES.
|
||||||
|
|
@ -1767,6 +1951,8 @@
|
||||||
setTimeout(tryAddHostListCopyButton, 500);
|
setTimeout(tryAddHostListCopyButton, 500);
|
||||||
// WATO menu: on view.py with host rows, self-stops if not applicable.
|
// WATO menu: on view.py with host rows, self-stops if not applicable.
|
||||||
setTimeout(tryAddViewWatoMenu, 800);
|
setTimeout(tryAddViewWatoMenu, 800);
|
||||||
|
// 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.
|
// Auto-check foreign activation: only on mode=changelog.
|
||||||
if (!targetMode || targetMode === 'changelog') setTimeout(tryAutoCheckForeignActivation, 500);
|
if (!targetMode || targetMode === 'changelog') setTimeout(tryAutoCheckForeignActivation, 500);
|
||||||
// Sites status filters: only on mode=sites, self-stops if not applicable.
|
// Sites status filters: only on mode=sites, self-stops if not applicable.
|
||||||
|
|
@ -1827,6 +2013,14 @@
|
||||||
}
|
}
|
||||||
} catch (e) {}
|
} 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) {
|
if (tDoc && tDoc.body && !tDoc.body.dataset.cmkAccToggle) {
|
||||||
attemptsAccToggle = 0;
|
attemptsAccToggle = 0;
|
||||||
setTimeout(tryAddAccordionToggleButtons, 300);
|
setTimeout(tryAddAccordionToggleButtons, 300);
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue