diff --git a/scripts/opensearch-export/opensearch-export.user.js b/scripts/opensearch-export/opensearch-export.user.js new file mode 100644 index 0000000..108e683 --- /dev/null +++ b/scripts/opensearch-export/opensearch-export.user.js @@ -0,0 +1,470 @@ +// ==UserScript== +// @name OpenSearch Export Logs to JSON +// @namespace https://git.ntnu.no/M365-Drift/MonkeyMagic/ +// @version 1.1.1 +// @description Adds an "Export Logs to JSON" button to OpenSearch Dashboards that exports all filtered logs (beyond the 500 limit) using the scroll API. +// @author Øyvind Nilsen (on@ntnu.no) +// @match https://log.it.ntnu.no/app/data-explorer/* +// @grant none +// @run-at document-end +// @icon https://www.google.com/s2/favicons?sz=64&domain=opensearch.org +// @updateURL https://git.ntnu.no/M365-Drift/MonkeyMagic/raw/main/scripts/opensearch-export/opensearch-export.user.js +// @downloadURL https://git.ntnu.no/M365-Drift/MonkeyMagic/raw/main/scripts/opensearch-export/opensearch-export.user.js +// ==/UserScript== + +(function () { + 'use strict'; + + // ── Configuration ────────────────────────────────────────────── + const BASE_URL = 'https://log.it.ntnu.no'; + const SCROLL_BATCH_SIZE = 5000; + const SCROLL_KEEP_ALIVE = '2m'; + const MAX_DOCS = 100000; + + // ── Rison parser (minimal, handles OSD URL encoding) ─────────── + // OSD uses a variant of Rison in the URL hash parameters. + // We decode it into JS objects. + const Rison = (() => { + // Rison spec: https://github.com/Nanonid/rison + function decode(s) { + if (!s) return undefined; + return new RisonParser(s).readValue(); + } + + class RisonParser { + constructor(s) { + this.s = s; + this.i = 0; + } + + readValue() { + this.skipWhitespace(); + const c = this.s[this.i]; + + if (c === '(') return this.readObject(); + if (c === '!') return this.readSpecial(); + if (c === "'") return this.readString(); + if (c === '-' || (c >= '0' && c <= '9')) return this.readNumber(); + // bare string (unquoted identifier) + return this.readId(); + } + + readObject() { + this.i++; // skip ( + const obj = {}; + while (this.i < this.s.length) { + this.skipWhitespace(); + if (this.s[this.i] === ')') { this.i++; return obj; } + if (this.s[this.i] === ',') { this.i++; continue; } + const key = this.readValue(); + this.skipWhitespace(); + if (this.s[this.i] === ':') this.i++; + const val = this.readValue(); + obj[key] = val; + } + return obj; + } + + readSpecial() { + this.i++; // skip ! + const c = this.s[this.i]; + if (c === 't') { this.i++; return true; } + if (c === 'f') { this.i++; return false; } + if (c === 'n') { this.i++; return null; } + if (c === '(') return this.readArray(); + throw new Error(`Unknown rison special: !${c} at ${this.i}`); + } + + readArray() { + this.i++; // skip ( + const arr = []; + while (this.i < this.s.length) { + this.skipWhitespace(); + if (this.s[this.i] === ')') { this.i++; return arr; } + if (this.s[this.i] === ',') { this.i++; continue; } + arr.push(this.readValue()); + } + return arr; + } + + readString() { + this.i++; // skip opening ' + let s = ''; + while (this.i < this.s.length) { + const c = this.s[this.i]; + if (c === "'") { this.i++; return s; } + if (c === '!') { + this.i++; + s += this.s[this.i]; + this.i++; + } else { + s += c; + this.i++; + } + } + return s; + } + + readNumber() { + let start = this.i; + if (this.s[this.i] === '-') this.i++; + while (this.i < this.s.length && ((this.s[this.i] >= '0' && this.s[this.i] <= '9') || this.s[this.i] === '.')) { + this.i++; + } + if (this.s[this.i] === 'e' || this.s[this.i] === 'E') { + this.i++; + if (this.s[this.i] === '+' || this.s[this.i] === '-') this.i++; + while (this.i < this.s.length && this.s[this.i] >= '0' && this.s[this.i] <= '9') this.i++; + } + return parseFloat(this.s.substring(start, this.i)); + } + + readId() { + // Bare identifier – ends at : , ) or end-of-string + let start = this.i; + while (this.i < this.s.length && !':,)('.includes(this.s[this.i])) { + this.i++; + } + const id = this.s.substring(start, this.i); + // Could be a number without leading context + if (/^-?[0-9]/.test(id)) return parseFloat(id); + return id; + } + + skipWhitespace() { + while (this.i < this.s.length && ' \t\n\r'.includes(this.s[this.i])) this.i++; + } + } + + return { decode }; + })(); + + // ── Parse the OSD URL hash into structured query info ────────── + function parseOsdUrl() { + const hash = window.location.hash || ''; + // Extract _a, _g, _q from the query string portion of the hash + const qsMatch = hash.match(/\?(.+)$/); + if (!qsMatch) return null; + + const qs = qsMatch[1]; + const params = {}; + // Split on &_ to get parameter boundaries (params are _a=..., _g=..., _q=...) + for (const part of qs.split('&')) { + const eqIdx = part.indexOf('='); + if (eqIdx === -1) continue; + const key = decodeURIComponent(part.substring(0, eqIdx)); + const val = decodeURIComponent(part.substring(eqIdx + 1)); + params[key] = val; + } + + let _a = null, _g = null, _q = null; + try { _a = Rison.decode(params['_a']); } catch (e) { console.warn('[OSExport] Failed to parse _a:', e); } + try { _g = Rison.decode(params['_g']); } catch (e) { console.warn('[OSExport] Failed to parse _g:', e); } + try { _q = Rison.decode(params['_q']); } catch (e) { console.warn('[OSExport] Failed to parse _q:', e); } + + console.log('[OSExport] Parsed URL →', { _a, _g, _q }); + return { _a, _g, _q }; + } + + // ── Build an OpenSearch DSL query from the parsed URL ────────── + function buildQuery(parsed) { + if (!parsed) throw new Error('Could not parse the current URL. Are you on a Discover page?'); + + const { _a, _g, _q } = parsed; + + // Index pattern + const indexPattern = _a?.metadata?.indexPattern; + if (!indexPattern) throw new Error('No index pattern found in URL. Navigate to a saved search first.'); + + // Time range + const timeFrom = _g?.time?.from; + const timeTo = _g?.time?.to; + + // Build the bool query + const must = []; + const filter = []; + + // Time range filter + if (timeFrom && timeTo) { + filter.push({ + range: { + '@timestamp': { + gte: timeFrom, + lte: timeTo, + format: 'strict_date_optional_time', + }, + }, + }); + } + + // KQL query (from the query bar) + const kqlQuery = _q?.query?.query; + if (kqlQuery && kqlQuery.trim()) { + must.push({ + query_string: { + query: kqlQuery, + analyze_wildcard: true, + default_operator: 'AND', + }, + }); + } + + // Filters from _q.filters + if (_q?.filters && Array.isArray(_q.filters)) { + for (const f of _q.filters) { + // Skip disabled filters + if (f?.meta?.disabled) continue; + // The actual query clause is in f.query + if (f.query) { + if (f.meta?.negate) { + filter.push({ bool: { must_not: [f.query] } }); + } else { + filter.push(f.query); + } + } + } + } + + // Global filters from _g.filters + if (_g?.filters && Array.isArray(_g.filters)) { + for (const f of _g.filters) { + if (f?.meta?.disabled) continue; + if (f.query) { + if (f.meta?.negate) { + filter.push({ bool: { must_not: [f.query] } }); + } else { + filter.push(f.query); + } + } + } + } + + const query = { + bool: { + must: must.length > 0 ? must : [{ match_all: {} }], + filter: filter, + }, + }; + + // Sort from _a + let sort = [{ '@timestamp': { order: 'desc' } }]; + if (_a?.discover?.sort && Array.isArray(_a.discover.sort) && _a.discover.sort.length > 0) { + sort = _a.discover.sort.map(s => { + if (Array.isArray(s)) return { [s[0]]: { order: s[1] || 'desc' } }; + return s; + }); + } + + return { indexPattern, query, sort }; + } + + // ── Resolve index-pattern ID → actual index name ─────────────── + async function resolveIndexPattern(id) { + const res = await fetch(`${BASE_URL}/api/saved_objects/index-pattern/${id}`, { + headers: osdHeaders(), + }); + if (!res.ok) { + // Fallback: try using the ID as a wildcard pattern directly + console.warn(`[OSExport] Could not resolve index-pattern ${id}, using as-is`); + return id; + } + const obj = await res.json(); + return obj?.attributes?.title || id; + } + + // ── OSD headers ──────────────────────────────────────────────── + function osdHeaders() { + return { + 'Content-Type': 'application/json', + 'osd-xsrf': 'true', + 'kbn-xsrf': 'true', + }; + } + + // ── Extract _source documents from scroll response ───────────── + function extractHits(scrollData) { + return (scrollData.hits?.hits || []).map(hit => ({ + _index: hit._index, + _id: hit._id, + ...hit._source, + })); + } + + // ── Main export function ─────────────────────────────────────── + async function exportLogs() { + const statusOverlay = createStatusOverlay(); + + try { + updateStatus(statusOverlay, 'Parsing URL and building query...', 0); + + const parsed = parseOsdUrl(); + const { indexPattern, query, sort } = buildQuery(parsed); + + // Resolve the index-pattern UUID to an actual index name/pattern + updateStatus(statusOverlay, 'Resolving index pattern...', 0); + const index = await resolveIndexPattern(indexPattern); + console.log('[OSExport] Resolved index:', index); + + const scrollQuery = { + size: SCROLL_BATCH_SIZE, + query: query, + sort: sort, + }; + + updateStatus(statusOverlay, `Starting scroll export on "${index}"...`, 0); + + // ── Initial scroll request ─────────────────────────── + const initRes = await fetch( + `${BASE_URL}/api/console/proxy?path=${encodeURIComponent(index + '/_search?scroll=' + SCROLL_KEEP_ALIVE)}&method=POST`, + { method: 'POST', headers: osdHeaders(), body: JSON.stringify(scrollQuery) } + ); + + if (!initRes.ok) { + const errText = await initRes.text(); + throw new Error(`Initial scroll failed (${initRes.status}): ${errText}`); + } + + let scrollData = await initRes.json(); + let scrollId = scrollData._scroll_id; + const totalHits = scrollData.hits?.total?.value ?? scrollData.hits?.total ?? '?'; + let allDocs = extractHits(scrollData); + + updateStatus(statusOverlay, `Fetched ${allDocs.length} / ${totalHits} documents...`, allDocs.length / (typeof totalHits === 'number' ? totalHits : MAX_DOCS)); + + // ── Continue scrolling ─────────────────────────────── + while (scrollId && allDocs.length < MAX_DOCS) { + const scrollRes = await fetch( + `${BASE_URL}/api/console/proxy?path=${encodeURIComponent('_search/scroll')}&method=POST`, + { + method: 'POST', + headers: osdHeaders(), + body: JSON.stringify({ scroll: SCROLL_KEEP_ALIVE, scroll_id: scrollId }), + } + ); + + if (!scrollRes.ok) { + console.warn('[OSExport] Scroll request failed:', await scrollRes.text()); + break; + } + + scrollData = await scrollRes.json(); + const newHits = extractHits(scrollData); + if (newHits.length === 0) break; + + allDocs = allDocs.concat(newHits); + scrollId = scrollData._scroll_id; + + updateStatus(statusOverlay, `Fetched ${allDocs.length} / ${totalHits} documents...`, allDocs.length / (typeof totalHits === 'number' ? totalHits : MAX_DOCS)); + } + + // ── Clear scroll context ───────────────────────────── + if (scrollId) { + fetch( + `${BASE_URL}/api/console/proxy?path=${encodeURIComponent('_search/scroll')}&method=DELETE`, + { method: 'POST', headers: osdHeaders(), body: JSON.stringify({ scroll_id: scrollId }) } + ).catch(() => {}); + } + + if (allDocs.length >= MAX_DOCS) { + updateStatus(statusOverlay, `Export capped at ${MAX_DOCS} documents. Downloading...`, 1); + } else { + updateStatus(statusOverlay, `Export complete! ${allDocs.length} documents. Downloading...`, 1); + } + + // ── Download JSON ──────────────────────────────────── + const json = JSON.stringify(allDocs, null, 2); + const blob = new Blob([json], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `opensearch-export-${new Date().toISOString().replace(/[:.]/g, '-')}.json`; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + + setTimeout(() => removeStatusOverlay(statusOverlay), 3000); + + } catch (err) { + console.error('[OSExport] Export error:', err); + updateStatus(statusOverlay, `Error: ${err.message}`, 0, true); + setTimeout(() => removeStatusOverlay(statusOverlay), 10000); + } + } + + // ── UI: status overlay ───────────────────────────────────────── + function createStatusOverlay() { + const overlay = document.createElement('div'); + overlay.id = 'os-export-status'; + overlay.style.cssText = ` + position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%); + background: #1a1a2e; color: #e0e0e0; padding: 24px 32px; + border-radius: 8px; z-index: 100000; min-width: 420px; + box-shadow: 0 8px 32px rgba(0,0,0,0.4); font-family: 'Inter', 'Segoe UI', sans-serif; + `; + overlay.innerHTML = ` +