diff --git a/user.js/AGENTS.md b/user.js/AGENTS.md index 69a5e81..8ed646d 100644 --- a/user.js/AGENTS.md +++ b/user.js/AGENTS.md @@ -15,7 +15,13 @@ most modules. when editing a script here" below before touching one). - `*.src.js` — editable sources for the three consolidated scripts (CP, FP, DeskPro). - `lib/admincom-common.js` — shared fragment (gated debug logging + retry/backoff request wrapper) - inlined into every `.user.js` by the build script. + inlined into every `.user.js` by the build script. Two sibling fragments ride the same mechanism: + `lib/admincom-entity-exclusions.js` (the two-tier entity-exclusion data, inlined into all three + scripts) and `lib/admincom-shared-helpers.js` (formatting/DOM/IP helpers plus the ASN → + network-name resolver `fetchAsnNetworkName`, whose transport is injectable because CP/FP are + same-origin to the API while DeskPro must pass its own GM_xmlhttpRequest-backed transport — + inlined only into the DeskPro script for now; CP/FP inclusion is deliberately deferred until a + concrete caller lands there). - `scripts/build_userscripts.py` — regenerates `.user.js` from `.src.js` + the lib. - `*.meta.js` — lightweight update-check manifests, one per script, hand-maintained (not generated). - `README.md` — human-facing docs: installation, module catalog, feature-flag console recipes, @@ -163,6 +169,13 @@ no new dependency beyond Node itself, which is already required for `node --chec row's parent net or ix is do-not-touch protected — each paired with a non-excluded control that proves the same call does issue the write, so a broken write path cannot masquerade as the guard holding); +- the shared helper fragment (`lib-shared-helpers.test.js` — `lib/admincom-shared-helpers.js`'s + `formatSpeedLabel`, `getTabSessionStorage`, the editable-region predicates + `isNodeInsideEditableRegion`/`isAnchorInsideEditableRegion`, and the ASN → network-name resolver + `fetchAsnNetworkName`: memory/persisted-cache hits, miss caching, in-flight dedupe, best-item + selection, `name_long` preference, and the transport-failure-is-not-cached rule — all through the + resolver's injectable `fetchJson` transport, which is the cross-script contract itself. Loaded + through DP's hooks per the `dp-shared-cache-helpers.test.js` precedent); - the shared cache namespace's remaining helpers (`dp-shared-cache-helpers.test.js` — `getSharedCacheStorageKey`'s type/id validation and normalization branch, and the negative-cache pair `cacheNegativeLookup`/`isNegativeCacheEntry` that DP's entity fetchers use to avoid repeated diff --git a/user.js/lib/admincom-shared-helpers.js b/user.js/lib/admincom-shared-helpers.js new file mode 100644 index 0000000..6dfdfa6 --- /dev/null +++ b/user.js/lib/admincom-shared-helpers.js @@ -0,0 +1,231 @@ +// Shared cross-script helpers for the admincom Tampermonkey userscripts: +// formatting, DOM-safety and IP-token predicates, plus an ASN -> +// network-name resolver built on the shared cache and retry wrapper from +// admincom-common.js (which every script inlines ahead of this +// fragment's marker). For now only the DeskPro script includes this fragment; +// whether CP and FP adopt it is a decision deferred until a concrete +// caller lands there. +// +// This is a source fragment, not a standalone script: it is inlined into +// a *.user.js by scripts/build_userscripts.py at the `/* @include +// admincom-shared-helpers.js */` marker in that script's *.src.js. Edit +// this file, then re-run the build script -- do not hand-edit the +// generated block inside the .user.js files, your changes will be +// overwritten. +// +// Symbols below that no script references yet carry the frozen @staged +// grammar; drop a symbol's marker in the commit that wires its first +// caller. + +// Selector for DeskPro-style rich-text editor containers. Mutating text +// nodes/anchors under an active editor's selection can desync the editor +// and hang the tab, so decoration code checks ancestry against this first. +const EDITABLE_CONTAINER_SELECTOR = '[contenteditable="true"]'; + +/** + * Determines whether a node sits inside an editable composer region. + * Purpose: Avoid modifying editor content (message composer, or a message + * opened for in-place editing) while snippets are inserted/managed. + * @param {Node} node - Element or text node to evaluate. + * @returns {boolean} True when inside a contenteditable ancestor. + */ +function isNodeInsideEditableRegion(node) { + const el = node?.nodeType === Node.ELEMENT_NODE ? node : node?.parentElement; + return Boolean(el?.closest?.(EDITABLE_CONTAINER_SELECTOR)); +} + +/** + * Determines whether an anchor is inside an editable composer region. + * Anchor-flavored alias of isNodeInsideEditableRegion() for call sites + * that deal in anchors specifically. + * @staged wip — no caller yet in any script; adopt where anchor decoration needs the editable-region check by name. + * @param {HTMLAnchorElement} anchor - Anchor to evaluate. + * @returns {boolean} True when inside a contenteditable ancestor. + */ +function isAnchorInsideEditableRegion(anchor) { + return isNodeInsideEditableRegion(anchor); +} + +// Strict single-token IP predicates: reject CIDR-suffixed and extra-octet +// tokens. These are test-regexes (no /g flag) safe for repeated .test() +// calls; DP's linkify pipeline is the live consumer, and this fragment +// is the canonical home should CP or FP grow an IP-token need. +const IPV4_TEST_REGEX = /\b(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(?:\.(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}(?!\/\d)(?!\.\d)\b/; +const IPV6_TEST_REGEX = /\b(?=[0-9a-fA-F:]*:[0-9a-fA-F:]*)(?:[0-9a-fA-F]{0,4}:){2,7}[0-9a-fA-F]{0,4}(?!:)(?!\/\d)\b/; + +/** + * Formats a speed integer (Mbit/s, as the PeeringDB API reports it) into a + * compact human-readable label. + * @staged wip — no caller yet in any script; netixlan/ixlan speed labels in CP and FP are the intended adopters. + * @param {string|number} speed - Speed value from API. + * @returns {string} Speed label ("750M", "10G", "1T", or "speed n/a"). + */ +function formatSpeedLabel(speed) { + const numericSpeed = Number(speed); + if (!Number.isFinite(numericSpeed) || numericSpeed <= 0) return "speed n/a"; + if (numericSpeed >= 1000000) return `${Math.round(numericSpeed / 1000000)}T`; + if (numericSpeed >= 1000) return `${Math.round(numericSpeed / 1000)}G`; + return `${numericSpeed}M`; +} + +/** + * Returns storage for tab-scoped transient values. + * @staged wip — no caller yet in any script; CP's legacy org-name-tab-cache sweep keeps a local twin and is the intended adopter once this fragment is included beyond DP. + * @returns {Storage|null} sessionStorage instance, or null when unavailable + * (e.g. blocked by browser privacy settings, which makes the property + * getter itself throw). + */ +function getTabSessionStorage() { + try { + if (window.sessionStorage) return window.sessionStorage; + } catch (_error) { + // Ignore; session storage may be unavailable. + } + return null; +} + +/** + * Selects the best network item for ASN lookups from list-style API payloads. + * Purpose: Prefer exact ASN and active status from `/api/net` responses. + * @param {*} payload - Parsed API response. + * @param {string} expectedAsn - ASN value used in the query. + * @returns {object|null} Matching network entry, or null when unavailable. + */ +function getBestApiNetDataItem(payload, expectedAsn) { + if (!payload || typeof payload !== "object") return null; + const data = payload.data; + if (!Array.isArray(data) || data.length === 0) return null; + + const expectedAsnNumber = Number(expectedAsn); + const exactOk = data.find( + (item) => Number(item?.asn) === expectedAsnNumber && String(item?.status || "").toLowerCase() === "ok", + ); + if (exactOk) return exactOk; + + const exact = data.find((item) => Number(item?.asn) === expectedAsnNumber); + if (exact) return exact; + + return data[0] || null; +} + +/** + * Resolves the authoritative legal display name for an entity payload. + * Prefers long legal name when available, then falls back to short name. + * @param {object|null|undefined} entity - API entity payload. + * @returns {string} Resolved legal-preferred name. + */ +function resolveEntityLegalName(entity) { + return String(entity?.name_long || entity?.name || "").trim(); +} + +// ASN -> network-name resolver state. One instance per host script (the +// fragment is inlined, not shared at runtime). TTLs mirror DeskPro's +// general/miss cache policy. +// @staged wip — resolver cluster has no caller yet in any script; DP's linkify enrichment and CP/FP ASN labels are the intended adopters. +const ASN_NETWORK_NAME_CACHE_TTL_MS = 7.5 * 60 * 60 * 1000; +// @staged wip — resolver cluster has no caller yet in any script; DP's linkify enrichment and CP/FP ASN labels are the intended adopters. +const ASN_NETWORK_NAME_MISS_TTL_MS = 15 * 60 * 1000; +// @staged wip — resolver cluster has no caller yet in any script; DP's linkify enrichment and CP/FP ASN labels are the intended adopters. +const asnNetworkNameCache = new Map(); +// @staged wip — resolver cluster has no caller yet in any script; DP's linkify enrichment and CP/FP ASN labels are the intended adopters. +const asnNetworkNameInFlight = new Map(); + +/** + * Default JSON transport for fetchAsnNetworkName: same-origin + * fetchWithRetry. Correct for CP and FP, which run on the peeringdb.com + * origin; DeskPro runs cross-origin and must inject its + * GM_xmlhttpRequest-backed transport instead (see fetchAsnNetworkName). + * @staged wip — resolver cluster has no caller yet in any script; DP's linkify enrichment and CP/FP ASN labels are the intended adopters. + * @param {string} url - API URL to fetch. + * @returns {Promise} Parsed JSON payload, or null on a non-2xx + * response or unparseable body. + */ +async function fetchAsnNameJsonSameOrigin(url) { + const response = await fetchWithRetry(url, { credentials: "same-origin" }); + if (!response.ok) return null; + return response.json().catch(() => null); +} + +/** + * Resolves the network name for an ASN via the PeeringDB API, with + * in-memory and shared-storage caching plus in-flight dedupe. + * Restored from DeskPro's retired resolver and reworked for cross-script + * use: the transport is injectable because the hosts differ -- CP/FP are + * same-origin and default to fetchAsnNameJsonSameOrigin, while DeskPro + * must pass its own cross-origin transport (pdbFetch). Persisted names + * share the "asn" cache type with DeskPro's existing writers, so a name + * resolved by one script is a cache hit for its siblings on the same + * origin. + * @staged wip — resolver cluster has no caller yet in any script; DP's linkify enrichment and CP/FP ASN labels are the intended adopters. + * @param {string|number} asn - ASN number to resolve. + * @param {{ fetchJson?: (url: string) => Promise }} [opts] - + * Optional transport override returning the parsed payload or null. + * @returns {Promise} Resolved network name, or "" when unavailable + * (invalid ASN, no match, or transport failure -- failures are not + * cached, so the next call retries). + */ +async function fetchAsnNetworkName(asn, { fetchJson } = {}) { + const normalizedAsn = String(asn || "").trim(); + if (!/^\d+$/.test(normalizedAsn)) return ""; + + const cached = asnNetworkNameCache.get(normalizedAsn); + if (cached && cached.expiresAt > Date.now()) { + return String(cached.name || ""); + } + if (cached) asnNetworkNameCache.delete(normalizedAsn); + + const persisted = getCachedDataFromStorage("asn", normalizedAsn); + const persistedName = + persisted && !isNegativeCacheEntry(persisted) ? String(persisted.name || "").trim() : ""; + if (persistedName) { + asnNetworkNameCache.set(normalizedAsn, { + name: persistedName, + expiresAt: Date.now() + ASN_NETWORK_NAME_CACHE_TTL_MS, + }); + return persistedName; + } + + if (asnNetworkNameInFlight.has(normalizedAsn)) { + return asnNetworkNameInFlight.get(normalizedAsn); + } + + const requestPromise = (async () => { + const params = new URLSearchParams({ + asn: normalizedAsn, + depth: "0", + status: "ok", + limit: "1", + }); + const url = `https://www.peeringdb.com/api/net?${params.toString()}`; + + let payload = null; + try { + payload = await (fetchJson || fetchAsnNameJsonSameOrigin)(url); + } catch (_error) { + // Transport failure: report unavailable without caching, so the next + // call retries instead of pinning a transient outage for hours. + return ""; + } + + const net = getBestApiNetDataItem(payload, normalizedAsn); + const resolved = resolveEntityLegalName(net); + const ttl = resolved ? ASN_NETWORK_NAME_CACHE_TTL_MS : ASN_NETWORK_NAME_MISS_TTL_MS; + + asnNetworkNameCache.set(normalizedAsn, { + name: resolved, + expiresAt: Date.now() + ttl, + }); + if (resolved) { + setCachedDataInStorage("asn", normalizedAsn, { name: resolved }, ASN_NETWORK_NAME_CACHE_TTL_MS); + } + + return resolved; + })(); + + asnNetworkNameInFlight.set(normalizedAsn, requestPromise); + try { + return await requestPromise; + } finally { + asnNetworkNameInFlight.delete(normalizedAsn); + } +} diff --git a/user.js/peeringdb-deskpro-tools.meta.js b/user.js/peeringdb-deskpro-tools.meta.js index 1df8d2a..074fc89 100644 --- a/user.js/peeringdb-deskpro-tools.meta.js +++ b/user.js/peeringdb-deskpro-tools.meta.js @@ -1,7 +1,7 @@ // ==UserScript== // @name PeeringDB DP - Consolidated Tools // @namespace https://www.peeringdb.com/ -// @version 1.7.11 +// @version 1.7.12 // @description Consolidated DeskPro tools: linkifies/enriches PeeringDB links (ASN/IP/IX/NET/FAC/Carrier), adds an owning-org shortcut link beside each, copies mailto addresses, normalizes PeeringDB CP double-slash links, generates pihole whitelist commands for IX/NET/FAC/Carrier approval tickets // @author // @match https://peeringdb.deskpro.com/app* diff --git a/user.js/peeringdb-deskpro-tools.src.js b/user.js/peeringdb-deskpro-tools.src.js index b6823a8..0394e9b 100644 --- a/user.js/peeringdb-deskpro-tools.src.js +++ b/user.js/peeringdb-deskpro-tools.src.js @@ -1,7 +1,7 @@ // ==UserScript== // @name PeeringDB DP - Consolidated Tools // @namespace https://www.peeringdb.com/ -// @version 1.7.11 +// @version 1.7.12 // @description Consolidated DeskPro tools: linkifies/enriches PeeringDB links (ASN/IP/IX/NET/FAC/Carrier), adds an owning-org shortcut link beside each, copies mailto addresses, normalizes PeeringDB CP double-slash links, generates pihole whitelist commands for IX/NET/FAC/Carrier approval tickets // @author // @match https://peeringdb.deskpro.com/app* @@ -94,17 +94,13 @@ const FP_LINK_ICON_URL = "https://icons.duckduckgo.com/ip2/peeringdb.com.ico"; const FP_LINK_ICON_SIZE_PX = 12; const ACTION_EMOJI_COPY = "📋"; - const ACTION_EMOJI_IX = "🏢"; const ACTION_EMOJI_ORG = "🏛"; - const ACTION_LINK_ICON_ATTR = "data-pdb-action-link-icon"; const ACTION_LINK_TEXT_ATTR = "data-pdb-action-link-text"; - const IX_SHORTCUT_ATTR = "data-pdb-ix-shortcut"; const ORG_SHORTCUT_ATTR = "data-pdb-org-shortcut"; const EXISTING_PDB_LINK_DECORATED_ATTR = "data-pdb-existing-link-decorated"; - const EXISTING_PDB_LINK_ICON_ATTR = "data-pdb-existing-link-icon"; - const EXISTING_PDB_LINK_TEXT_ATTR = "data-pdb-existing-link-text"; const PDB_LINK_CANDIDATE_SELECTOR = 'a[href*="peeringdb.com"]'; - const EDITABLE_CONTAINER_SELECTOR = '[contenteditable="true"]'; + // EDITABLE_CONTAINER_SELECTOR and isNodeInsideEditableRegion now come from + // lib/admincom-shared-helpers.js (see the @include marker below). const TARGET_ACTION_LINK_LABELS = new Set([ "review affiliation/ownership request", "approve ownership request and notify user", @@ -123,18 +119,14 @@ const ASN_NAME_CACHE_MISS_TTL_MS = 15 * 60 * 1000; const ASN_NAME_CACHE_TTL_MS = CACHE_TTL_MS; const ORG_CACHE_TTL_MS = CACHE_TTL_MS; - const USER_CACHE_TTL_MS = CACHE_TTL_MS; const FACILITY_CACHE_TTL_MS = CACHE_TTL_MS; const NETIXLAN_CACHE_TTL_MS = 3 * 60 * 60 * 1000; // 3 hours — separate from general TTL - // IPv4: matches a.b.c.d, requires word boundary, rejects CIDR suffix /N and additional octet. - const IPV4_TOKEN_REGEX = /\b(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(?:\.(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}(?!\/\d)(?!\.\d)\b/g; - const IPV4_TEST_REGEX = /\b(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(?:\.(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}(?!\/\d)(?!\.\d)\b/; - // IPv6: colon-hex compressed notation, rejects CIDR suffix /N. - const IPV6_TOKEN_REGEX = /\b(?=[0-9a-fA-F:]*:[0-9a-fA-F:]*)(?:[0-9a-fA-F]{0,4}:){2,7}[0-9a-fA-F]{0,4}(?!:)(?!\/\d)\b/g; - const IPV6_TEST_REGEX = /\b(?=[0-9a-fA-F:]*:[0-9a-fA-F:]*)(?:[0-9a-fA-F]{0,4}:){2,7}[0-9a-fA-F]{0,4}(?!:)(?!\/\d)\b/; + // IPV4_TEST_REGEX/IPV6_TEST_REGEX now come from + // lib/admincom-shared-helpers.js (see the @include marker below). const ENTITY_EXISTENCE_CACHE_TTL_MS = 60 * 60 * 1000; + // @staged wip — batch ASN-name enrichment for multi-ASN tickets has no caller yet; wire into linkifyText's ASN hydration path when the batch flow lands. const BATCH_FETCH_MAX_ASNS = 100; // Per-request limit for asn__in queries const RATE_LIMIT_MIN_REMAINING = 10; // Backoff threshold @@ -178,7 +170,6 @@ // "->" plus Unicode "→" and the word "to" as separators. const RN_PREFIX_PAIR_REGEX = /(\b(?:\d{1,3}\.){3}\d{1,3}\/\d{1,2}|\b[0-9A-Fa-f:]+\/\d{1,3})\s*(?:->|=>|→|to|TO|To)\s*((?:\d{1,3}\.){3}\d{1,3}\/\d{1,2}|[0-9A-Fa-f:]+\/\d{1,3})/g; - const RN_SUBJECT_HINT_REGEX = /\b(?:renumber|prefix change|new prefix|migrate)\b/i; const RN_BACKDROP_ID = "pdb-dp-rn-backdrop"; const RN_MODAL_ID = "pdb-dp-rn-modal"; // DOM ids for modal nodes (scoped namespace). @@ -186,23 +177,8 @@ const WL_MODAL_ID = "pdb-dp-wl-modal"; const WL_STYLES_INJECTED_ATTR = "data-pdb-dp-wl-styles-injected"; - const asnNameCache = new Map(); - const asnNameInFlight = new Map(); const rateLimitState = { limit: null, remaining: null, resetTime: null }; // Track rate-limit quotas - /** - * Returns storage for tab-scoped transient values. - * @returns {Storage|null} sessionStorage instance, or null when unavailable. - */ - function getTabSessionStorage() { - try { - if (window.sessionStorage) return window.sessionStorage; - } catch (_error) { - // Ignore; session storage may be unavailable. - } - return null; - } - /** * Generates or retrieves a persistent session UUID for the browser session. * Purpose: Provides a unique identifier for correlating requests within a session. @@ -406,46 +382,7 @@ /* @include admincom-entity-exclusions.js */ - /** - * Normalizes ASN value into a stable cache key suffix. - * @param {string|number} asn - Raw ASN value. - * @returns {string} Trimmed ASN string. - */ - function normalizeAsnForCache(asn) { - return String(asn || "").trim(); - } - - /** - * Builds localStorage key for ASN-name cache entries (backward compat wrapper). - * @param {string|number} asn - ASN value. - * @returns {string} Namespaced cache key, or empty string when invalid. - */ - function getAsnNameCacheStorageKey(asn) { - const normalizedAsn = normalizeAsnForCache(asn); - if (!normalizedAsn || !/^\d+$/.test(normalizedAsn)) return ""; - return getSharedCacheStorageKey("asn", normalizedAsn); - } - - /** - * Reads ASN name from localStorage cache when valid (backward compat). - * @param {string|number} asn - ASN value. - * @returns {string|null} Cached ASN name, or null when absent/expired/invalid. - */ - function getCachedAsnNameFromStorage(asn) { - const data = getCachedDataFromStorage("asn", asn); - return data ? String(data.name || "").trim() || null : null; - } - - /** - * Stores ASN name into localStorage cache with TTL/schema metadata (backward compat). - * @param {string|number} asn - ASN value. - * @param {string} name - Resolved network name. - */ - function setCachedAsnNameInStorage(asn, name) { - const normalizedName = String(name || "").trim(); - if (!normalizedName) return; - setCachedDataInStorage("asn", asn, { name: normalizedName }, ASN_NAME_CACHE_TTL_MS); - } + /* @include admincom-shared-helpers.js */ /** * Constructs request headers for script-driven HTTP requests. @@ -765,103 +702,8 @@ return buildCpChangeUrl(cpModel, id); } - /** - * Selects the best network item for ASN lookups from list-style API payloads. - * Purpose: Prefer exact ASN and active status from `/api/net` responses. - * @param {*} payload - Parsed API response. - * @param {string} expectedAsn - ASN value used in the query. - * @returns {object|null} Matching network entry, or null when unavailable. - */ - function getBestApiNetDataItem(payload, expectedAsn) { - if (!payload || typeof payload !== "object") return null; - const data = payload.data; - if (!Array.isArray(data) || data.length === 0) return null; - - const expectedAsnNumber = Number(expectedAsn); - const exactOk = data.find( - (item) => Number(item?.asn) === expectedAsnNumber && String(item?.status || "").toLowerCase() === "ok", - ); - if (exactOk) return exactOk; - - const exact = data.find((item) => Number(item?.asn) === expectedAsnNumber); - if (exact) return exact; - - return data[0] || null; - } - - /** - * Resolves the authoritative legal display name for an entity payload. - * Prefers long legal name when available, then falls back to short name. - * @param {object|null|undefined} entity - API entity payload. - * @returns {string} Resolved legal-preferred name. - */ - function resolveEntityLegalName(entity) { - return String(entity?.name_long || entity?.name || "").trim(); - } - - /** - * Resolves network name for an ASN via PeeringDB API with cache and in-flight dedupe. - * Purpose: Enrich ASN link labels with authoritative network names. - * Necessity: Limits duplicate API calls when the same ASN appears repeatedly in one ticket. - * @param {string|number} asn - ASN number to resolve. - * @returns {Promise} Resolved network name, or empty string when unavailable. - */ - async function fetchAsnNetworkName(asn) { - const normalizedAsn = normalizeAsnForCache(asn); - if (!/^\d+$/.test(normalizedAsn)) return ""; - - const cached = asnNameCache.get(normalizedAsn); - if (cached && cached.expiresAt > Date.now()) { - return String(cached.name || ""); - } - - if (cached && cached.expiresAt <= Date.now()) { - asnNameCache.delete(normalizedAsn); - } - - const persistedName = getCachedAsnNameFromStorage(normalizedAsn); - if (persistedName) { - asnNameCache.set(normalizedAsn, { - name: persistedName, - expiresAt: Date.now() + ASN_NAME_CACHE_TTL_MS, - }); - return persistedName; - } - - if (asnNameInFlight.has(normalizedAsn)) { - return asnNameInFlight.get(normalizedAsn); - } - - const requestPromise = (async () => { - const params = new URLSearchParams({ - asn: normalizedAsn, - depth: "0", - status: "ok", - limit: "1", - }); - const url = `https://www.peeringdb.com/api/net?${params.toString()}`; - const payload = await pdbFetch(url); - const net = getBestApiNetDataItem(payload, normalizedAsn); - const resolved = resolveEntityLegalName(net); - const ttl = resolved ? ASN_NAME_CACHE_TTL_MS : ASN_NAME_CACHE_MISS_TTL_MS; - - asnNameCache.set(normalizedAsn, { - name: resolved, - expiresAt: Date.now() + ttl, - }); - if (resolved) setCachedAsnNameInStorage(normalizedAsn, resolved); - - return resolved; - })(); - - asnNameInFlight.set(normalizedAsn, requestPromise); - - try { - return await requestPromise; - } finally { - asnNameInFlight.delete(normalizedAsn); - } - } + // getBestApiNetDataItem and resolveEntityLegalName now come from + // lib/admincom-shared-helpers.js (see the @include marker above). /** * Fetches organization details including nested user/POC information. @@ -1023,6 +865,7 @@ * @param {array} arr - Array to chunk. * @param {number} chunkSize - Maximum size per chunk. * @returns {array} Array of chunks. + * @staged wip — batch ASN-name enrichment for multi-ASN tickets has no caller yet; wire into linkifyText's ASN hydration path when the batch flow lands. */ function chunkArray(arr, chunkSize) { if (!Array.isArray(arr) || chunkSize < 1) return []; @@ -1039,6 +882,7 @@ * Reduces latency vs. serial requests: 5 ASNs typically <2s vs. ~5s serial. * @param {array} asnList - Array of ASN numbers to fetch. * @returns {Promise} Flattened array of network objects form all chunks. + * @staged wip — batch ASN-name enrichment for multi-ASN tickets has no caller yet; wire into linkifyText's ASN hydration path when the batch flow lands. */ async function batchFetchNetworks(asnList) { if (!Array.isArray(asnList) || asnList.length === 0) return []; @@ -1183,16 +1027,19 @@ /** * Classifies an API error into categories for retry/abort decisions. * Purpose: Distinguish transient (retry-able) from fatal (abort) errors. + * Every branch returns the same shape: `backoffMs` is null when no retry + * delay applies; `ttl` is null unless the result should be negative-cached. + * @staged wip — error-classification for pdbFetch retry/negative-cache decisions; not yet consulted by pdbFetch/pdbFetchStatus. * @param {number} [status] - HTTP status code, or null/undefined for network error. - * @param {Error} [error] - Optional error object. - * @returns {object} Classification with type, retryable flag, and guidance. + * @returns {{type:string,retryable:boolean,backoffMs:number|null,ttl:number|null,label:string}} Classification. */ - function classifyError(status, error) { + function classifyError(status) { if (status === 429 || status === 503) { return { type: "transient", retryable: true, backoffMs: 5000, + ttl: null, label: "Rate-limited or temporarily unavailable", }; } @@ -1200,6 +1047,7 @@ return { type: "not_found", retryable: false, + backoffMs: null, ttl: 1.5 * 3600 * 1000, // Cache negative result for 1.5 hours label: "Resource not found (404)", }; @@ -1208,6 +1056,8 @@ return { type: "auth", retryable: false, + backoffMs: null, + ttl: null, label: "Authentication failed or access denied", }; } @@ -1216,6 +1066,7 @@ type: "server", retryable: true, backoffMs: 10000, + ttl: null, label: "Server error (5xx)", }; } @@ -1224,12 +1075,15 @@ type: "network", retryable: true, backoffMs: 3000, + ttl: null, label: "Network error or timeout", }; } return { type: "unknown", retryable: false, + backoffMs: null, + ttl: null, label: `Unknown error (HTTP ${status})`, }; } @@ -1458,53 +1312,37 @@ /** * Fetches the best netixlan record for an IPv4 address. + * Uses the shared (type, id) cache primitives with the sibling fetchers' + * null-means-miss contract and negative-caches empty lookups. + * @staged wip — IP-tooltip enrichment module not yet built; wire into hydrateExistingPeeringDbAnchor / linkifyText when it lands. * @param {string} ip - IPv4 address. * @returns {Promise} Netixlan record, or null when not found. */ async function fetchNetixlanByIp(ip) { const normalizedIp = String(ip || "").trim(); if (!normalizedIp) return null; - const cacheKey = `netixlan_ip_${normalizedIp}`; - const cached = getCachedDataFromStorage(cacheKey); - if (cached !== undefined) return cached; - const url = `${PEERINGDB_API_BASE_URL}/netixlan?ipaddr4=${encodeURIComponent(normalizedIp)}&depth=2`; + const cached = getCachedDataFromStorage("netixlan_ip", normalizedIp); + if (cached) { + if (isNegativeCacheEntry(cached)) return null; + return cached; + } + const url = `https://www.peeringdb.com/api/netixlan?ipaddr4=${encodeURIComponent(normalizedIp)}&depth=2`; try { const data = await pdbFetch(url); const items = Array.isArray(data?.data) ? data.data : []; const best = getBestNetixlanDataItem(items); - setCachedDataInStorage(cacheKey, best, NETIXLAN_CACHE_TTL_MS); + if (!best) { + cacheNegativeLookup("netixlan_ip", normalizedIp, NETIXLAN_CACHE_TTL_MS); + return null; + } + setCachedDataInStorage("netixlan_ip", normalizedIp, best, NETIXLAN_CACHE_TTL_MS); return best; } catch { - setCachedDataInStorage(cacheKey, null, NETIXLAN_CACHE_TTL_MS); + cacheNegativeLookup("netixlan_ip", normalizedIp, NETIXLAN_CACHE_TTL_MS); return null; } } - /** - * Adds a compact IX shortcut icon next to an enriched link. - * @param {HTMLAnchorElement} anchor - Primary anchor. - * @param {string|number} ixId - Exchange id. - * @param {string} [ixName=""] - Optional exchange name for tooltip. - */ - function ensureIxShortcut(anchor, ixId, ixName = "") { - if (!anchor?.isConnected) return; - if (!/^\d+$/.test(String(ixId || "").trim())) return; - if (anchor.nextElementSibling?.getAttribute?.(IX_SHORTCUT_ATTR) === "true") return; - - const ixLink = document.createElement("a"); - ixLink.href = `https://www.peeringdb.com/ix/${ixId}`; - ixLink.target = "_blank"; - ixLink.rel = "noopener noreferrer"; - ixLink.setAttribute(IX_SHORTCUT_ATTR, "true"); - ixLink.style.marginLeft = "3px"; - ixLink.style.textDecoration = "none"; - ixLink.title = ixName ? `Open IX ${ixName} in PeeringDB` : `Open IX ${ixId} in PeeringDB`; - ixLink.textContent = ACTION_EMOJI_IX; - ixLink.setAttribute("aria-label", ixLink.title); - - anchor.insertAdjacentElement("afterend", ixLink); - } - /** * Adds a compact organization shortcut link next to an entity anchor. * Purpose: Surface the owning organization as a directly clickable link @@ -1533,19 +1371,6 @@ anchor.insertAdjacentElement("afterend", orgLink); } - /** - * Formats a speed integer into a compact human-readable label. - * @param {string|number} speed - Speed value from API. - * @returns {string} Speed label. - */ - function formatSpeedLabel(speed) { - const numericSpeed = Number(speed); - if (!Number.isFinite(numericSpeed) || numericSpeed <= 0) return "speed n/a"; - if (numericSpeed >= 1000000) return `${Math.round(numericSpeed / 1000000)}T`; - if (numericSpeed >= 1000) return `${Math.round(numericSpeed / 1000)}G`; - return `${numericSpeed}M`; - } - /** * Builds organization search anchor with link emoji styling. * Purpose: Link affiliation organization names to PeeringDB search results. @@ -1558,6 +1383,7 @@ * Used as a secondary gate after the IPv6 regex to reject false positives. * @param {string} text - Candidate string. * @returns {boolean} + * @staged wip — IP-tooltip enrichment module not yet built; wire into hydrateExistingPeeringDbAnchor / linkifyText when it lands. */ function isLikelyIpv6Address(text) { if (!text.includes(":")) return false; @@ -1577,6 +1403,7 @@ * Purpose: Link bare IP addresses to PeeringDB search results. * @param {string} ip - IP address to linkify. * @returns {HTMLAnchorElement} Configured IP-search anchor. + * @staged wip — IP-tooltip enrichment module not yet built; wire into hydrateExistingPeeringDbAnchor / linkifyText when it lands. */ function makeIpLink(ip) { const query = String(ip || "").trim(); @@ -1602,6 +1429,7 @@ * @param {HTMLAnchorElement} anchor - Anchor to update. * @param {string} ip - IPv4 address used for lookup. * @returns {Promise} + * @staged wip — IP-tooltip enrichment module not yet built; wire into hydrateExistingPeeringDbAnchor / linkifyText when it lands. */ async function hydrateIpLinkLabel(anchor, ip) { try { @@ -1787,29 +1615,8 @@ return previous.querySelector?.("a[href]") || null; } - /** - * Determines whether a node is inside (or is) a live contenteditable region. - * Purpose: Avoid modifying DeskPro editor content (message composer, or a - * single message opened for in-place editing) while snippets are - * inserted/managed. Mutating text nodes/anchors under an active rich-text - * editor's selection can desync the editor and hang the tab. - * @param {Node} node - Element or text node to evaluate. - * @returns {boolean} True when inside a contenteditable ancestor. - */ - function isNodeInsideEditableRegion(node) { - const el = node?.nodeType === Node.ELEMENT_NODE ? node : node?.parentElement; - return Boolean(el?.closest?.(EDITABLE_CONTAINER_SELECTOR)); - } - - /** - * Determines whether an anchor is inside an editable composer region. - * Purpose: Avoid modifying DeskPro editor content while snippets are inserted/managed. - * @param {HTMLAnchorElement} anchor - Anchor to evaluate. - * @returns {boolean} True when inside a contenteditable ancestor. - */ - function isAnchorInsideEditableRegion(anchor) { - return isNodeInsideEditableRegion(anchor); - } + // isNodeInsideEditableRegion now comes from + // lib/admincom-shared-helpers.js (see the @include marker above). /** * Ensures an existing PeeringDB anchor is marked as visited by the script. @@ -2345,9 +2152,7 @@ const windowText = lines.slice(windowStart, windowEnd).join("\n"); const prevLine = String(lines[i - 1] || "").toLowerCase(); - const ipv4Re = /\b(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(?:\.(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}\b/; - const ipv6Re = /\b(?=[0-9a-fA-F:]*:[0-9a-fA-F:]*)(?:[0-9a-fA-F]{0,4}:){2,7}[0-9a-fA-F]{0,4}\b/; - const hasIpPair = ipv4Re.test(windowText) && ipv6Re.test(windowText); + const hasIpPair = IPV4_TEST_REGEX.test(windowText) && IPV6_TEST_REGEX.test(windowText); const hasMemberBlock = /\b(speed|policy)\b/i.test(windowText) && /\b(ipv4|ipaddr4)\b/i.test(windowText) && /\b(ipv6|ipaddr6)\b/i.test(windowText); const precededByLabel = /\b(member\s+asn|network\s+asn|asn|as)\b/.test(prevLine); @@ -3218,7 +3023,7 @@ * Necessity: Operators routinely paste the prefix change directly into the * ticket; detecting it removes a copy/paste step and reduces typos. * @param {{ticketSubject: string, ticketBodyText: string}} ctx - Ticket context. - * @returns {{ pairs: Array<{family:4|6, old:string, new:string}>, subjectHinted: boolean }} + * @returns {{ pairs: Array<{family:4|6, old:string, new:string}> }} */ function collectRenumberCandidates(ctx) { const pairs = []; @@ -3237,7 +3042,7 @@ seen.add(key); pairs.push({ family, old: oldCidr, new: newCidr }); } - return { pairs, subjectHinted: RN_SUBJECT_HINT_REGEX.test(String(ctx?.ticketSubject || "")) }; + return { pairs }; } /** @@ -3653,10 +3458,19 @@ extractMailtoAddress, buildCpEmailSearchUrl, classifyError, + fetchNetixlanByIp, getSharedCacheStorageKey, cacheNegativeLookup, isNegativeCacheEntry, getCachedDataFromStorage, + setCachedDataInStorage, + // From lib/admincom-shared-helpers.js, inlined identically into all + // three scripts; DP is simply the host for their tests. + formatSpeedLabel, + getTabSessionStorage, + isNodeInsideEditableRegion, + isAnchorInsideEditableRegion, + fetchAsnNetworkName, }; } else if (document.readyState === "loading") { document.addEventListener("DOMContentLoaded", init); diff --git a/user.js/peeringdb-deskpro-tools.user.js b/user.js/peeringdb-deskpro-tools.user.js index 237466f..19c1a79 100644 --- a/user.js/peeringdb-deskpro-tools.user.js +++ b/user.js/peeringdb-deskpro-tools.user.js @@ -1,7 +1,7 @@ // ==UserScript== // @name PeeringDB DP - Consolidated Tools // @namespace https://www.peeringdb.com/ -// @version 1.7.11 +// @version 1.7.12 // @description Consolidated DeskPro tools: linkifies/enriches PeeringDB links (ASN/IP/IX/NET/FAC/Carrier), adds an owning-org shortcut link beside each, copies mailto addresses, normalizes PeeringDB CP double-slash links, generates pihole whitelist commands for IX/NET/FAC/Carrier approval tickets // @author // @match https://peeringdb.deskpro.com/app* @@ -94,17 +94,13 @@ const FP_LINK_ICON_URL = "https://icons.duckduckgo.com/ip2/peeringdb.com.ico"; const FP_LINK_ICON_SIZE_PX = 12; const ACTION_EMOJI_COPY = "📋"; - const ACTION_EMOJI_IX = "🏢"; const ACTION_EMOJI_ORG = "🏛"; - const ACTION_LINK_ICON_ATTR = "data-pdb-action-link-icon"; const ACTION_LINK_TEXT_ATTR = "data-pdb-action-link-text"; - const IX_SHORTCUT_ATTR = "data-pdb-ix-shortcut"; const ORG_SHORTCUT_ATTR = "data-pdb-org-shortcut"; const EXISTING_PDB_LINK_DECORATED_ATTR = "data-pdb-existing-link-decorated"; - const EXISTING_PDB_LINK_ICON_ATTR = "data-pdb-existing-link-icon"; - const EXISTING_PDB_LINK_TEXT_ATTR = "data-pdb-existing-link-text"; const PDB_LINK_CANDIDATE_SELECTOR = 'a[href*="peeringdb.com"]'; - const EDITABLE_CONTAINER_SELECTOR = '[contenteditable="true"]'; + // EDITABLE_CONTAINER_SELECTOR and isNodeInsideEditableRegion now come from + // lib/admincom-shared-helpers.js (see the @include marker below). const TARGET_ACTION_LINK_LABELS = new Set([ "review affiliation/ownership request", "approve ownership request and notify user", @@ -123,18 +119,14 @@ const ASN_NAME_CACHE_MISS_TTL_MS = 15 * 60 * 1000; const ASN_NAME_CACHE_TTL_MS = CACHE_TTL_MS; const ORG_CACHE_TTL_MS = CACHE_TTL_MS; - const USER_CACHE_TTL_MS = CACHE_TTL_MS; const FACILITY_CACHE_TTL_MS = CACHE_TTL_MS; const NETIXLAN_CACHE_TTL_MS = 3 * 60 * 60 * 1000; // 3 hours — separate from general TTL - // IPv4: matches a.b.c.d, requires word boundary, rejects CIDR suffix /N and additional octet. - const IPV4_TOKEN_REGEX = /\b(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(?:\.(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}(?!\/\d)(?!\.\d)\b/g; - const IPV4_TEST_REGEX = /\b(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(?:\.(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}(?!\/\d)(?!\.\d)\b/; - // IPv6: colon-hex compressed notation, rejects CIDR suffix /N. - const IPV6_TOKEN_REGEX = /\b(?=[0-9a-fA-F:]*:[0-9a-fA-F:]*)(?:[0-9a-fA-F]{0,4}:){2,7}[0-9a-fA-F]{0,4}(?!:)(?!\/\d)\b/g; - const IPV6_TEST_REGEX = /\b(?=[0-9a-fA-F:]*:[0-9a-fA-F:]*)(?:[0-9a-fA-F]{0,4}:){2,7}[0-9a-fA-F]{0,4}(?!:)(?!\/\d)\b/; + // IPV4_TEST_REGEX/IPV6_TEST_REGEX now come from + // lib/admincom-shared-helpers.js (see the @include marker below). const ENTITY_EXISTENCE_CACHE_TTL_MS = 60 * 60 * 1000; + // @staged wip — batch ASN-name enrichment for multi-ASN tickets has no caller yet; wire into linkifyText's ASN hydration path when the batch flow lands. const BATCH_FETCH_MAX_ASNS = 100; // Per-request limit for asn__in queries const RATE_LIMIT_MIN_REMAINING = 10; // Backoff threshold @@ -178,7 +170,6 @@ // "->" plus Unicode "→" and the word "to" as separators. const RN_PREFIX_PAIR_REGEX = /(\b(?:\d{1,3}\.){3}\d{1,3}\/\d{1,2}|\b[0-9A-Fa-f:]+\/\d{1,3})\s*(?:->|=>|→|to|TO|To)\s*((?:\d{1,3}\.){3}\d{1,3}\/\d{1,2}|[0-9A-Fa-f:]+\/\d{1,3})/g; - const RN_SUBJECT_HINT_REGEX = /\b(?:renumber|prefix change|new prefix|migrate)\b/i; const RN_BACKDROP_ID = "pdb-dp-rn-backdrop"; const RN_MODAL_ID = "pdb-dp-rn-modal"; // DOM ids for modal nodes (scoped namespace). @@ -186,23 +177,8 @@ const WL_MODAL_ID = "pdb-dp-wl-modal"; const WL_STYLES_INJECTED_ATTR = "data-pdb-dp-wl-styles-injected"; - const asnNameCache = new Map(); - const asnNameInFlight = new Map(); const rateLimitState = { limit: null, remaining: null, resetTime: null }; // Track rate-limit quotas - /** - * Returns storage for tab-scoped transient values. - * @returns {Storage|null} sessionStorage instance, or null when unavailable. - */ - function getTabSessionStorage() { - try { - if (window.sessionStorage) return window.sessionStorage; - } catch (_error) { - // Ignore; session storage may be unavailable. - } - return null; - } - /** * Generates or retrieves a persistent session UUID for the browser session. * Purpose: Provides a unique identifier for correlating requests within a session. @@ -902,46 +878,240 @@ } // >>> END GENERATED BLOCK <<< + // >>> GENERATED by user.js/scripts/build_userscripts.py from peeringdb-deskpro-tools.src.js + lib/admincom-shared-helpers.js <<< + // >>> Do not hand-edit the block below; edit the source and regenerate. <<< + // Shared cross-script helpers for the admincom Tampermonkey userscripts: + // formatting, DOM-safety and IP-token predicates, plus an ASN -> + // network-name resolver built on the shared cache and retry wrapper from + // admincom-common.js (which every script inlines ahead of this + // fragment's marker). For now only the DeskPro script includes this fragment; + // whether CP and FP adopt it is a decision deferred until a concrete + // caller lands there. + // + // This is a source fragment, not a standalone script: it is inlined into + // a *.user.js by scripts/build_userscripts.py at the `/* @include + // admincom-shared-helpers.js */` marker in that script's *.src.js. Edit + // this file, then re-run the build script -- do not hand-edit the + // generated block inside the .user.js files, your changes will be + // overwritten. + // + // Symbols below that no script references yet carry the frozen @staged + // grammar; drop a symbol's marker in the commit that wires its first + // caller. + + // Selector for DeskPro-style rich-text editor containers. Mutating text + // nodes/anchors under an active editor's selection can desync the editor + // and hang the tab, so decoration code checks ancestry against this first. + const EDITABLE_CONTAINER_SELECTOR = '[contenteditable="true"]'; + /** - * Normalizes ASN value into a stable cache key suffix. - * @param {string|number} asn - Raw ASN value. - * @returns {string} Trimmed ASN string. + * Determines whether a node sits inside an editable composer region. + * Purpose: Avoid modifying editor content (message composer, or a message + * opened for in-place editing) while snippets are inserted/managed. + * @param {Node} node - Element or text node to evaluate. + * @returns {boolean} True when inside a contenteditable ancestor. */ - function normalizeAsnForCache(asn) { - return String(asn || "").trim(); + function isNodeInsideEditableRegion(node) { + const el = node?.nodeType === Node.ELEMENT_NODE ? node : node?.parentElement; + return Boolean(el?.closest?.(EDITABLE_CONTAINER_SELECTOR)); } /** - * Builds localStorage key for ASN-name cache entries (backward compat wrapper). - * @param {string|number} asn - ASN value. - * @returns {string} Namespaced cache key, or empty string when invalid. + * Determines whether an anchor is inside an editable composer region. + * Anchor-flavored alias of isNodeInsideEditableRegion() for call sites + * that deal in anchors specifically. + * @staged wip — no caller yet in any script; adopt where anchor decoration needs the editable-region check by name. + * @param {HTMLAnchorElement} anchor - Anchor to evaluate. + * @returns {boolean} True when inside a contenteditable ancestor. + */ + function isAnchorInsideEditableRegion(anchor) { + return isNodeInsideEditableRegion(anchor); + } + + // Strict single-token IP predicates: reject CIDR-suffixed and extra-octet + // tokens. These are test-regexes (no /g flag) safe for repeated .test() + // calls; DP's linkify pipeline is the live consumer, and this fragment + // is the canonical home should CP or FP grow an IP-token need. + const IPV4_TEST_REGEX = /\b(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(?:\.(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}(?!\/\d)(?!\.\d)\b/; + const IPV6_TEST_REGEX = /\b(?=[0-9a-fA-F:]*:[0-9a-fA-F:]*)(?:[0-9a-fA-F]{0,4}:){2,7}[0-9a-fA-F]{0,4}(?!:)(?!\/\d)\b/; + + /** + * Formats a speed integer (Mbit/s, as the PeeringDB API reports it) into a + * compact human-readable label. + * @staged wip — no caller yet in any script; netixlan/ixlan speed labels in CP and FP are the intended adopters. + * @param {string|number} speed - Speed value from API. + * @returns {string} Speed label ("750M", "10G", "1T", or "speed n/a"). + */ + function formatSpeedLabel(speed) { + const numericSpeed = Number(speed); + if (!Number.isFinite(numericSpeed) || numericSpeed <= 0) return "speed n/a"; + if (numericSpeed >= 1000000) return `${Math.round(numericSpeed / 1000000)}T`; + if (numericSpeed >= 1000) return `${Math.round(numericSpeed / 1000)}G`; + return `${numericSpeed}M`; + } + + /** + * Returns storage for tab-scoped transient values. + * @staged wip — no caller yet in any script; CP's legacy org-name-tab-cache sweep keeps a local twin and is the intended adopter once this fragment is included beyond DP. + * @returns {Storage|null} sessionStorage instance, or null when unavailable + * (e.g. blocked by browser privacy settings, which makes the property + * getter itself throw). + */ + function getTabSessionStorage() { + try { + if (window.sessionStorage) return window.sessionStorage; + } catch (_error) { + // Ignore; session storage may be unavailable. + } + return null; + } + + /** + * Selects the best network item for ASN lookups from list-style API payloads. + * Purpose: Prefer exact ASN and active status from `/api/net` responses. + * @param {*} payload - Parsed API response. + * @param {string} expectedAsn - ASN value used in the query. + * @returns {object|null} Matching network entry, or null when unavailable. */ - function getAsnNameCacheStorageKey(asn) { - const normalizedAsn = normalizeAsnForCache(asn); - if (!normalizedAsn || !/^\d+$/.test(normalizedAsn)) return ""; - return getSharedCacheStorageKey("asn", normalizedAsn); + function getBestApiNetDataItem(payload, expectedAsn) { + if (!payload || typeof payload !== "object") return null; + const data = payload.data; + if (!Array.isArray(data) || data.length === 0) return null; + + const expectedAsnNumber = Number(expectedAsn); + const exactOk = data.find( + (item) => Number(item?.asn) === expectedAsnNumber && String(item?.status || "").toLowerCase() === "ok", + ); + if (exactOk) return exactOk; + + const exact = data.find((item) => Number(item?.asn) === expectedAsnNumber); + if (exact) return exact; + + return data[0] || null; } /** - * Reads ASN name from localStorage cache when valid (backward compat). - * @param {string|number} asn - ASN value. - * @returns {string|null} Cached ASN name, or null when absent/expired/invalid. + * Resolves the authoritative legal display name for an entity payload. + * Prefers long legal name when available, then falls back to short name. + * @param {object|null|undefined} entity - API entity payload. + * @returns {string} Resolved legal-preferred name. */ - function getCachedAsnNameFromStorage(asn) { - const data = getCachedDataFromStorage("asn", asn); - return data ? String(data.name || "").trim() || null : null; + function resolveEntityLegalName(entity) { + return String(entity?.name_long || entity?.name || "").trim(); + } + + // ASN -> network-name resolver state. One instance per host script (the + // fragment is inlined, not shared at runtime). TTLs mirror DeskPro's + // general/miss cache policy. + // @staged wip — resolver cluster has no caller yet in any script; DP's linkify enrichment and CP/FP ASN labels are the intended adopters. + const ASN_NETWORK_NAME_CACHE_TTL_MS = 7.5 * 60 * 60 * 1000; + // @staged wip — resolver cluster has no caller yet in any script; DP's linkify enrichment and CP/FP ASN labels are the intended adopters. + const ASN_NETWORK_NAME_MISS_TTL_MS = 15 * 60 * 1000; + // @staged wip — resolver cluster has no caller yet in any script; DP's linkify enrichment and CP/FP ASN labels are the intended adopters. + const asnNetworkNameCache = new Map(); + // @staged wip — resolver cluster has no caller yet in any script; DP's linkify enrichment and CP/FP ASN labels are the intended adopters. + const asnNetworkNameInFlight = new Map(); + + /** + * Default JSON transport for fetchAsnNetworkName: same-origin + * fetchWithRetry. Correct for CP and FP, which run on the peeringdb.com + * origin; DeskPro runs cross-origin and must inject its + * GM_xmlhttpRequest-backed transport instead (see fetchAsnNetworkName). + * @staged wip — resolver cluster has no caller yet in any script; DP's linkify enrichment and CP/FP ASN labels are the intended adopters. + * @param {string} url - API URL to fetch. + * @returns {Promise} Parsed JSON payload, or null on a non-2xx + * response or unparseable body. + */ + async function fetchAsnNameJsonSameOrigin(url) { + const response = await fetchWithRetry(url, { credentials: "same-origin" }); + if (!response.ok) return null; + return response.json().catch(() => null); } /** - * Stores ASN name into localStorage cache with TTL/schema metadata (backward compat). - * @param {string|number} asn - ASN value. - * @param {string} name - Resolved network name. + * Resolves the network name for an ASN via the PeeringDB API, with + * in-memory and shared-storage caching plus in-flight dedupe. + * Restored from DeskPro's retired resolver and reworked for cross-script + * use: the transport is injectable because the hosts differ -- CP/FP are + * same-origin and default to fetchAsnNameJsonSameOrigin, while DeskPro + * must pass its own cross-origin transport (pdbFetch). Persisted names + * share the "asn" cache type with DeskPro's existing writers, so a name + * resolved by one script is a cache hit for its siblings on the same + * origin. + * @staged wip — resolver cluster has no caller yet in any script; DP's linkify enrichment and CP/FP ASN labels are the intended adopters. + * @param {string|number} asn - ASN number to resolve. + * @param {{ fetchJson?: (url: string) => Promise }} [opts] - + * Optional transport override returning the parsed payload or null. + * @returns {Promise} Resolved network name, or "" when unavailable + * (invalid ASN, no match, or transport failure -- failures are not + * cached, so the next call retries). */ - function setCachedAsnNameInStorage(asn, name) { - const normalizedName = String(name || "").trim(); - if (!normalizedName) return; - setCachedDataInStorage("asn", asn, { name: normalizedName }, ASN_NAME_CACHE_TTL_MS); + async function fetchAsnNetworkName(asn, { fetchJson } = {}) { + const normalizedAsn = String(asn || "").trim(); + if (!/^\d+$/.test(normalizedAsn)) return ""; + + const cached = asnNetworkNameCache.get(normalizedAsn); + if (cached && cached.expiresAt > Date.now()) { + return String(cached.name || ""); + } + if (cached) asnNetworkNameCache.delete(normalizedAsn); + + const persisted = getCachedDataFromStorage("asn", normalizedAsn); + const persistedName = + persisted && !isNegativeCacheEntry(persisted) ? String(persisted.name || "").trim() : ""; + if (persistedName) { + asnNetworkNameCache.set(normalizedAsn, { + name: persistedName, + expiresAt: Date.now() + ASN_NETWORK_NAME_CACHE_TTL_MS, + }); + return persistedName; + } + + if (asnNetworkNameInFlight.has(normalizedAsn)) { + return asnNetworkNameInFlight.get(normalizedAsn); + } + + const requestPromise = (async () => { + const params = new URLSearchParams({ + asn: normalizedAsn, + depth: "0", + status: "ok", + limit: "1", + }); + const url = `https://www.peeringdb.com/api/net?${params.toString()}`; + + let payload = null; + try { + payload = await (fetchJson || fetchAsnNameJsonSameOrigin)(url); + } catch (_error) { + // Transport failure: report unavailable without caching, so the next + // call retries instead of pinning a transient outage for hours. + return ""; + } + + const net = getBestApiNetDataItem(payload, normalizedAsn); + const resolved = resolveEntityLegalName(net); + const ttl = resolved ? ASN_NETWORK_NAME_CACHE_TTL_MS : ASN_NETWORK_NAME_MISS_TTL_MS; + + asnNetworkNameCache.set(normalizedAsn, { + name: resolved, + expiresAt: Date.now() + ttl, + }); + if (resolved) { + setCachedDataInStorage("asn", normalizedAsn, { name: resolved }, ASN_NETWORK_NAME_CACHE_TTL_MS); + } + + return resolved; + })(); + + asnNetworkNameInFlight.set(normalizedAsn, requestPromise); + try { + return await requestPromise; + } finally { + asnNetworkNameInFlight.delete(normalizedAsn); + } } + // >>> END GENERATED BLOCK <<< /** * Constructs request headers for script-driven HTTP requests. @@ -1261,103 +1431,8 @@ return buildCpChangeUrl(cpModel, id); } - /** - * Selects the best network item for ASN lookups from list-style API payloads. - * Purpose: Prefer exact ASN and active status from `/api/net` responses. - * @param {*} payload - Parsed API response. - * @param {string} expectedAsn - ASN value used in the query. - * @returns {object|null} Matching network entry, or null when unavailable. - */ - function getBestApiNetDataItem(payload, expectedAsn) { - if (!payload || typeof payload !== "object") return null; - const data = payload.data; - if (!Array.isArray(data) || data.length === 0) return null; - - const expectedAsnNumber = Number(expectedAsn); - const exactOk = data.find( - (item) => Number(item?.asn) === expectedAsnNumber && String(item?.status || "").toLowerCase() === "ok", - ); - if (exactOk) return exactOk; - - const exact = data.find((item) => Number(item?.asn) === expectedAsnNumber); - if (exact) return exact; - - return data[0] || null; - } - - /** - * Resolves the authoritative legal display name for an entity payload. - * Prefers long legal name when available, then falls back to short name. - * @param {object|null|undefined} entity - API entity payload. - * @returns {string} Resolved legal-preferred name. - */ - function resolveEntityLegalName(entity) { - return String(entity?.name_long || entity?.name || "").trim(); - } - - /** - * Resolves network name for an ASN via PeeringDB API with cache and in-flight dedupe. - * Purpose: Enrich ASN link labels with authoritative network names. - * Necessity: Limits duplicate API calls when the same ASN appears repeatedly in one ticket. - * @param {string|number} asn - ASN number to resolve. - * @returns {Promise} Resolved network name, or empty string when unavailable. - */ - async function fetchAsnNetworkName(asn) { - const normalizedAsn = normalizeAsnForCache(asn); - if (!/^\d+$/.test(normalizedAsn)) return ""; - - const cached = asnNameCache.get(normalizedAsn); - if (cached && cached.expiresAt > Date.now()) { - return String(cached.name || ""); - } - - if (cached && cached.expiresAt <= Date.now()) { - asnNameCache.delete(normalizedAsn); - } - - const persistedName = getCachedAsnNameFromStorage(normalizedAsn); - if (persistedName) { - asnNameCache.set(normalizedAsn, { - name: persistedName, - expiresAt: Date.now() + ASN_NAME_CACHE_TTL_MS, - }); - return persistedName; - } - - if (asnNameInFlight.has(normalizedAsn)) { - return asnNameInFlight.get(normalizedAsn); - } - - const requestPromise = (async () => { - const params = new URLSearchParams({ - asn: normalizedAsn, - depth: "0", - status: "ok", - limit: "1", - }); - const url = `https://www.peeringdb.com/api/net?${params.toString()}`; - const payload = await pdbFetch(url); - const net = getBestApiNetDataItem(payload, normalizedAsn); - const resolved = resolveEntityLegalName(net); - const ttl = resolved ? ASN_NAME_CACHE_TTL_MS : ASN_NAME_CACHE_MISS_TTL_MS; - - asnNameCache.set(normalizedAsn, { - name: resolved, - expiresAt: Date.now() + ttl, - }); - if (resolved) setCachedAsnNameInStorage(normalizedAsn, resolved); - - return resolved; - })(); - - asnNameInFlight.set(normalizedAsn, requestPromise); - - try { - return await requestPromise; - } finally { - asnNameInFlight.delete(normalizedAsn); - } - } + // getBestApiNetDataItem and resolveEntityLegalName now come from + // lib/admincom-shared-helpers.js (see the @include marker above). /** * Fetches organization details including nested user/POC information. @@ -1519,6 +1594,7 @@ * @param {array} arr - Array to chunk. * @param {number} chunkSize - Maximum size per chunk. * @returns {array} Array of chunks. + * @staged wip — batch ASN-name enrichment for multi-ASN tickets has no caller yet; wire into linkifyText's ASN hydration path when the batch flow lands. */ function chunkArray(arr, chunkSize) { if (!Array.isArray(arr) || chunkSize < 1) return []; @@ -1535,6 +1611,7 @@ * Reduces latency vs. serial requests: 5 ASNs typically <2s vs. ~5s serial. * @param {array} asnList - Array of ASN numbers to fetch. * @returns {Promise} Flattened array of network objects form all chunks. + * @staged wip — batch ASN-name enrichment for multi-ASN tickets has no caller yet; wire into linkifyText's ASN hydration path when the batch flow lands. */ async function batchFetchNetworks(asnList) { if (!Array.isArray(asnList) || asnList.length === 0) return []; @@ -1679,16 +1756,19 @@ /** * Classifies an API error into categories for retry/abort decisions. * Purpose: Distinguish transient (retry-able) from fatal (abort) errors. + * Every branch returns the same shape: `backoffMs` is null when no retry + * delay applies; `ttl` is null unless the result should be negative-cached. + * @staged wip — error-classification for pdbFetch retry/negative-cache decisions; not yet consulted by pdbFetch/pdbFetchStatus. * @param {number} [status] - HTTP status code, or null/undefined for network error. - * @param {Error} [error] - Optional error object. - * @returns {object} Classification with type, retryable flag, and guidance. + * @returns {{type:string,retryable:boolean,backoffMs:number|null,ttl:number|null,label:string}} Classification. */ - function classifyError(status, error) { + function classifyError(status) { if (status === 429 || status === 503) { return { type: "transient", retryable: true, backoffMs: 5000, + ttl: null, label: "Rate-limited or temporarily unavailable", }; } @@ -1696,6 +1776,7 @@ return { type: "not_found", retryable: false, + backoffMs: null, ttl: 1.5 * 3600 * 1000, // Cache negative result for 1.5 hours label: "Resource not found (404)", }; @@ -1704,6 +1785,8 @@ return { type: "auth", retryable: false, + backoffMs: null, + ttl: null, label: "Authentication failed or access denied", }; } @@ -1712,6 +1795,7 @@ type: "server", retryable: true, backoffMs: 10000, + ttl: null, label: "Server error (5xx)", }; } @@ -1720,12 +1804,15 @@ type: "network", retryable: true, backoffMs: 3000, + ttl: null, label: "Network error or timeout", }; } return { type: "unknown", retryable: false, + backoffMs: null, + ttl: null, label: `Unknown error (HTTP ${status})`, }; } @@ -1954,53 +2041,37 @@ /** * Fetches the best netixlan record for an IPv4 address. + * Uses the shared (type, id) cache primitives with the sibling fetchers' + * null-means-miss contract and negative-caches empty lookups. + * @staged wip — IP-tooltip enrichment module not yet built; wire into hydrateExistingPeeringDbAnchor / linkifyText when it lands. * @param {string} ip - IPv4 address. * @returns {Promise} Netixlan record, or null when not found. */ async function fetchNetixlanByIp(ip) { const normalizedIp = String(ip || "").trim(); if (!normalizedIp) return null; - const cacheKey = `netixlan_ip_${normalizedIp}`; - const cached = getCachedDataFromStorage(cacheKey); - if (cached !== undefined) return cached; - const url = `${PEERINGDB_API_BASE_URL}/netixlan?ipaddr4=${encodeURIComponent(normalizedIp)}&depth=2`; + const cached = getCachedDataFromStorage("netixlan_ip", normalizedIp); + if (cached) { + if (isNegativeCacheEntry(cached)) return null; + return cached; + } + const url = `https://www.peeringdb.com/api/netixlan?ipaddr4=${encodeURIComponent(normalizedIp)}&depth=2`; try { const data = await pdbFetch(url); const items = Array.isArray(data?.data) ? data.data : []; const best = getBestNetixlanDataItem(items); - setCachedDataInStorage(cacheKey, best, NETIXLAN_CACHE_TTL_MS); + if (!best) { + cacheNegativeLookup("netixlan_ip", normalizedIp, NETIXLAN_CACHE_TTL_MS); + return null; + } + setCachedDataInStorage("netixlan_ip", normalizedIp, best, NETIXLAN_CACHE_TTL_MS); return best; } catch { - setCachedDataInStorage(cacheKey, null, NETIXLAN_CACHE_TTL_MS); + cacheNegativeLookup("netixlan_ip", normalizedIp, NETIXLAN_CACHE_TTL_MS); return null; } } - /** - * Adds a compact IX shortcut icon next to an enriched link. - * @param {HTMLAnchorElement} anchor - Primary anchor. - * @param {string|number} ixId - Exchange id. - * @param {string} [ixName=""] - Optional exchange name for tooltip. - */ - function ensureIxShortcut(anchor, ixId, ixName = "") { - if (!anchor?.isConnected) return; - if (!/^\d+$/.test(String(ixId || "").trim())) return; - if (anchor.nextElementSibling?.getAttribute?.(IX_SHORTCUT_ATTR) === "true") return; - - const ixLink = document.createElement("a"); - ixLink.href = `https://www.peeringdb.com/ix/${ixId}`; - ixLink.target = "_blank"; - ixLink.rel = "noopener noreferrer"; - ixLink.setAttribute(IX_SHORTCUT_ATTR, "true"); - ixLink.style.marginLeft = "3px"; - ixLink.style.textDecoration = "none"; - ixLink.title = ixName ? `Open IX ${ixName} in PeeringDB` : `Open IX ${ixId} in PeeringDB`; - ixLink.textContent = ACTION_EMOJI_IX; - ixLink.setAttribute("aria-label", ixLink.title); - - anchor.insertAdjacentElement("afterend", ixLink); - } - /** * Adds a compact organization shortcut link next to an entity anchor. * Purpose: Surface the owning organization as a directly clickable link @@ -2029,19 +2100,6 @@ anchor.insertAdjacentElement("afterend", orgLink); } - /** - * Formats a speed integer into a compact human-readable label. - * @param {string|number} speed - Speed value from API. - * @returns {string} Speed label. - */ - function formatSpeedLabel(speed) { - const numericSpeed = Number(speed); - if (!Number.isFinite(numericSpeed) || numericSpeed <= 0) return "speed n/a"; - if (numericSpeed >= 1000000) return `${Math.round(numericSpeed / 1000000)}T`; - if (numericSpeed >= 1000) return `${Math.round(numericSpeed / 1000)}G`; - return `${numericSpeed}M`; - } - /** * Builds organization search anchor with link emoji styling. * Purpose: Link affiliation organization names to PeeringDB search results. @@ -2054,6 +2112,7 @@ * Used as a secondary gate after the IPv6 regex to reject false positives. * @param {string} text - Candidate string. * @returns {boolean} + * @staged wip — IP-tooltip enrichment module not yet built; wire into hydrateExistingPeeringDbAnchor / linkifyText when it lands. */ function isLikelyIpv6Address(text) { if (!text.includes(":")) return false; @@ -2073,6 +2132,7 @@ * Purpose: Link bare IP addresses to PeeringDB search results. * @param {string} ip - IP address to linkify. * @returns {HTMLAnchorElement} Configured IP-search anchor. + * @staged wip — IP-tooltip enrichment module not yet built; wire into hydrateExistingPeeringDbAnchor / linkifyText when it lands. */ function makeIpLink(ip) { const query = String(ip || "").trim(); @@ -2098,6 +2158,7 @@ * @param {HTMLAnchorElement} anchor - Anchor to update. * @param {string} ip - IPv4 address used for lookup. * @returns {Promise} + * @staged wip — IP-tooltip enrichment module not yet built; wire into hydrateExistingPeeringDbAnchor / linkifyText when it lands. */ async function hydrateIpLinkLabel(anchor, ip) { try { @@ -2283,29 +2344,8 @@ return previous.querySelector?.("a[href]") || null; } - /** - * Determines whether a node is inside (or is) a live contenteditable region. - * Purpose: Avoid modifying DeskPro editor content (message composer, or a - * single message opened for in-place editing) while snippets are - * inserted/managed. Mutating text nodes/anchors under an active rich-text - * editor's selection can desync the editor and hang the tab. - * @param {Node} node - Element or text node to evaluate. - * @returns {boolean} True when inside a contenteditable ancestor. - */ - function isNodeInsideEditableRegion(node) { - const el = node?.nodeType === Node.ELEMENT_NODE ? node : node?.parentElement; - return Boolean(el?.closest?.(EDITABLE_CONTAINER_SELECTOR)); - } - - /** - * Determines whether an anchor is inside an editable composer region. - * Purpose: Avoid modifying DeskPro editor content while snippets are inserted/managed. - * @param {HTMLAnchorElement} anchor - Anchor to evaluate. - * @returns {boolean} True when inside a contenteditable ancestor. - */ - function isAnchorInsideEditableRegion(anchor) { - return isNodeInsideEditableRegion(anchor); - } + // isNodeInsideEditableRegion now comes from + // lib/admincom-shared-helpers.js (see the @include marker above). /** * Ensures an existing PeeringDB anchor is marked as visited by the script. @@ -2841,9 +2881,7 @@ const windowText = lines.slice(windowStart, windowEnd).join("\n"); const prevLine = String(lines[i - 1] || "").toLowerCase(); - const ipv4Re = /\b(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(?:\.(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}\b/; - const ipv6Re = /\b(?=[0-9a-fA-F:]*:[0-9a-fA-F:]*)(?:[0-9a-fA-F]{0,4}:){2,7}[0-9a-fA-F]{0,4}\b/; - const hasIpPair = ipv4Re.test(windowText) && ipv6Re.test(windowText); + const hasIpPair = IPV4_TEST_REGEX.test(windowText) && IPV6_TEST_REGEX.test(windowText); const hasMemberBlock = /\b(speed|policy)\b/i.test(windowText) && /\b(ipv4|ipaddr4)\b/i.test(windowText) && /\b(ipv6|ipaddr6)\b/i.test(windowText); const precededByLabel = /\b(member\s+asn|network\s+asn|asn|as)\b/.test(prevLine); @@ -3714,7 +3752,7 @@ * Necessity: Operators routinely paste the prefix change directly into the * ticket; detecting it removes a copy/paste step and reduces typos. * @param {{ticketSubject: string, ticketBodyText: string}} ctx - Ticket context. - * @returns {{ pairs: Array<{family:4|6, old:string, new:string}>, subjectHinted: boolean }} + * @returns {{ pairs: Array<{family:4|6, old:string, new:string}> }} */ function collectRenumberCandidates(ctx) { const pairs = []; @@ -3733,7 +3771,7 @@ seen.add(key); pairs.push({ family, old: oldCidr, new: newCidr }); } - return { pairs, subjectHinted: RN_SUBJECT_HINT_REGEX.test(String(ctx?.ticketSubject || "")) }; + return { pairs }; } /** @@ -4149,10 +4187,19 @@ extractMailtoAddress, buildCpEmailSearchUrl, classifyError, + fetchNetixlanByIp, getSharedCacheStorageKey, cacheNegativeLookup, isNegativeCacheEntry, getCachedDataFromStorage, + setCachedDataInStorage, + // From lib/admincom-shared-helpers.js, inlined identically into all + // three scripts; DP is simply the host for their tests. + formatSpeedLabel, + getTabSessionStorage, + isNodeInsideEditableRegion, + isAnchorInsideEditableRegion, + fetchAsnNetworkName, }; } else if (document.readyState === "loading") { document.addEventListener("DOMContentLoaded", init); diff --git a/user.js/tests/dp-netixlan-ip-cache.test.js b/user.js/tests/dp-netixlan-ip-cache.test.js new file mode 100644 index 0000000..d9fc7d0 --- /dev/null +++ b/user.js/tests/dp-netixlan-ip-cache.test.js @@ -0,0 +1,67 @@ +'use strict'; + +// Tests for DP's fetchNetixlanByIp cache path (staged IP-tooltip enrichment +// cluster). The function is @staged wip -- no production caller yet -- but +// staged code must still be correct: these cases pin the shared +// (type, id, data, ttl) cache signatures, the null-means-miss contract, and +// negative-caching of empty lookups, which were all wrong before the fix +// (single-string cache key, `cached !== undefined` miss-check that made the +// function unconditionally return null without ever fetching). + +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const path = require('node:path'); +const { loadScript } = require('./helpers/browser-shim'); + +const SCRIPT_PATH = path.join(__dirname, '..', 'peeringdb-deskpro-tools.user.js'); + +const IP = '185.1.184.10'; +const API_URL = `https://www.peeringdb.com/api/netixlan?ipaddr4=${encodeURIComponent(IP)}&depth=2`; +const NETIXLAN_ROW = { id: 42, asn: 64500, ipaddr4: IP, ix: { id: 7, name: 'Test-IX' } }; + +function loadDp(opts = {}) { + return loadScript(SCRIPT_PATH, { hooksKey: '__pdbDpTestHooks__', pathname: '/app/ticket', ...opts }); +} + +test('fetchNetixlanByIp cache behavior', async (t) => { + await t.test('miss then hit: fetches once, second call served from the shared cache', async () => { + const { hooks, fetchCalls } = loadDp({ fetchMap: { [API_URL]: { data: [NETIXLAN_ROW] } } }); + + const first = await hooks.fetchNetixlanByIp(IP); + assert.equal(first.id, 42); + assert.equal(first.ix.name, 'Test-IX'); + assert.equal(fetchCalls.length, 1); + assert.equal(fetchCalls[0].url, API_URL); + + const second = await hooks.fetchNetixlanByIp(IP); + assert.equal(second.id, 42); + assert.equal(fetchCalls.length, 1, 'cache hit must not re-fetch'); + }); + + await t.test('stores under the shared (type, id) cache key namespace', async () => { + const { hooks, window } = loadDp({ fetchMap: { [API_URL]: { data: [NETIXLAN_ROW] } } }); + + await hooks.fetchNetixlanByIp(IP); + const storageKey = hooks.getSharedCacheStorageKey('netixlan_ip', IP); + assert.ok(storageKey, 'cache key must be valid for the netixlan_ip type'); + const raw = window.localStorage.getItem(storageKey); + assert.ok(raw, 'entry must be persisted under the shared cache key'); + assert.equal(JSON.parse(raw).data.id, 42); + }); + + await t.test('empty API result is negative-cached: returns null, second call does not re-fetch', async () => { + const { hooks, fetchCalls } = loadDp({ fetchMap: { [API_URL]: { data: [] } } }); + + assert.equal(await hooks.fetchNetixlanByIp(IP), null); + assert.equal(fetchCalls.length, 1); + assert.equal(await hooks.fetchNetixlanByIp(IP), null); + assert.equal(fetchCalls.length, 1, 'negative-cache hit must not re-fetch'); + }); + + await t.test('blank input returns null without fetching', async () => { + const { hooks, fetchCalls } = loadDp({ fetchMap: {} }); + assert.equal(await hooks.fetchNetixlanByIp(''), null); + assert.equal(await hooks.fetchNetixlanByIp(null), null); + assert.equal(fetchCalls.length, 0); + }); +}); diff --git a/user.js/tests/dp-renumber-launcher.test.js b/user.js/tests/dp-renumber-launcher.test.js index 3b244c5..010c0b1 100644 --- a/user.js/tests/dp-renumber-launcher.test.js +++ b/user.js/tests/dp-renumber-launcher.test.js @@ -53,7 +53,6 @@ test('collectRenumberCandidates', async (t) => { assert.equal(r.pairs[0].family, 4); assert.equal(r.pairs[0].old, '185.0.1.0/24'); assert.equal(r.pairs[0].new, '185.1.184.0/23'); - assert.equal(r.subjectHinted, false); }); await t.test('accepts the Unicode "→" separator', () => { @@ -86,14 +85,6 @@ test('collectRenumberCandidates', async (t) => { assert.equal(r.pairs.length, 1); }); - await t.test('subjectHinted is true when the subject contains a renumber-ish keyword', () => { - assert.equal(hooks.collectRenumberCandidates({ ticketSubject: 'Please renumber our prefix', ticketBodyText: '' }).subjectHinted, true); - }); - - await t.test('subjectHinted is false for an unrelated subject', () => { - assert.equal(hooks.collectRenumberCandidates({ ticketSubject: 'Random subject', ticketBodyText: '' }).subjectHinted, false); - }); - await t.test('returns an empty pairs list when nothing matches', () => { assert.equal(hooks.collectRenumberCandidates({ ticketSubject: '', ticketBodyText: 'nothing here' }).pairs.length, 0); }); @@ -101,7 +92,6 @@ test('collectRenumberCandidates', async (t) => { await t.test('tolerates a completely empty context object', () => { const r = hooks.collectRenumberCandidates({}); assert.equal(r.pairs.length, 0); - assert.equal(r.subjectHinted, false); }); }); diff --git a/user.js/tests/helpers/browser-shim.js b/user.js/tests/helpers/browser-shim.js index 7894e58..1ea9026 100644 --- a/user.js/tests/helpers/browser-shim.js +++ b/user.js/tests/helpers/browser-shim.js @@ -220,6 +220,11 @@ function loadScript(scriptPath, opts) { requestAnimationFrame: (cb) => setTimeout(cb, 0), addEventListener() {}, removeEventListener() {}, + // Just the node-type constants the scripts compare against; a vm context + // has its own JS intrinsics but no DOM globals, so code reading + // Node.ELEMENT_NODE (e.g. the shared isNodeInsideEditableRegion helper) + // would otherwise throw ReferenceError on first call. + Node: { ELEMENT_NODE: 1, TEXT_NODE: 3 }, // Tampermonkey always injects GM_info regardless of @grant; the scripts read // GM_info.script.version rather than hard-coding their own version string, so // the sandbox must provide it or the IIFE throws before exporting its hooks. diff --git a/user.js/tests/lib-shared-helpers.test.js b/user.js/tests/lib-shared-helpers.test.js new file mode 100644 index 0000000..de8e382 --- /dev/null +++ b/user.js/tests/lib-shared-helpers.test.js @@ -0,0 +1,184 @@ +'use strict'; + +// Tests for lib/admincom-shared-helpers.js, the cross-script helper +// fragment inlined into CP, FP and DP: formatSpeedLabel, +// getTabSessionStorage, the editable-region predicates, and the +// ASN -> network-name resolver fetchAsnNetworkName. +// +// Loaded through DP's hooks (the shared-cache precedent: the fragment is +// inlined identically into all three scripts, so any host proves the +// shipped code; DP hosts the rest of the resolver-adjacent tests already). +// The resolver is exercised through its injectable fetchJson transport +// rather than the shim's fetchMap, because transport injection is the +// contract itself: CP/FP default to same-origin fetchWithRetry while +// cross-origin DP must pass its own GM_xmlhttpRequest-backed transport, +// and a stub transport lets the cases count calls and simulate failures +// directly. All expected values below were captured empirically from the +// real functions before being hardcoded. + +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const path = require('node:path'); +const { loadScript } = require('./helpers/browser-shim'); + +const SCRIPT_PATH = path.join(__dirname, '..', 'peeringdb-deskpro-tools.user.js'); + +function loadDp(opts = {}) { + return loadScript(SCRIPT_PATH, { hooksKey: '__pdbDpTestHooks__', pathname: '/app/ticket', ...opts }); +} + +// Minimal transport stub: returns the given payload (or throws), and +// counts calls so cache behavior is observable. +function makeFetchJsonStub(payload, { fail = false } = {}) { + const stub = async () => { + stub.calls += 1; + if (fail) throw new Error('transport down'); + return payload; + }; + stub.calls = 0; + return stub; +} + +const NET_64500 = { asn: 64500, status: 'ok', name: 'Example Net' }; + +test('formatSpeedLabel', async (t) => { + const { hooks } = loadDp(); + + await t.test('sub-gigabit stays in megabits', () => { + assert.equal(hooks.formatSpeedLabel(750), '750M'); + }); + + await t.test('gigabit and terabit tiers round to whole units', () => { + assert.equal(hooks.formatSpeedLabel(1000), '1G'); + assert.equal(hooks.formatSpeedLabel(10000), '10G'); + assert.equal(hooks.formatSpeedLabel(1500), '2G'); + assert.equal(hooks.formatSpeedLabel(1000000), '1T'); + assert.equal(hooks.formatSpeedLabel(2500000), '3T'); + }); + + await t.test('zero, negative, and non-numeric report "speed n/a"', () => { + for (const bad of [0, -100, '', 'fast', null, undefined, NaN]) { + assert.equal(hooks.formatSpeedLabel(bad), 'speed n/a', String(bad)); + } + }); +}); + +test('getTabSessionStorage', async (t) => { + await t.test('returns the window sessionStorage instance', () => { + const { hooks, window } = loadDp(); + assert.equal(hooks.getTabSessionStorage(), window.sessionStorage); + }); +}); + +test('editable-region predicates', async (t) => { + const { hooks } = loadDp(); + + // Hand-rolled minimal nodes: the shim's FakeElement always answers + // closest() with null, so the positive case needs an object whose + // closest() recognizes the contenteditable selector. + const editableAncestorEl = { + nodeType: 1, + closest: (selector) => (selector === '[contenteditable="true"]' ? {} : null), + }; + const plainEl = { nodeType: 1, closest: () => null }; + + await t.test('element inside a contenteditable ancestor is editable', () => { + assert.equal(hooks.isNodeInsideEditableRegion(editableAncestorEl), true); + assert.equal(hooks.isAnchorInsideEditableRegion(editableAncestorEl), true); + }); + + await t.test('element outside any editable region is not', () => { + assert.equal(hooks.isNodeInsideEditableRegion(plainEl), false); + }); + + await t.test('a text node is judged by its parent element', () => { + const textNode = { nodeType: 3, parentElement: editableAncestorEl }; + assert.equal(hooks.isNodeInsideEditableRegion(textNode), true); + const orphanTextNode = { nodeType: 3, parentElement: null }; + assert.equal(hooks.isNodeInsideEditableRegion(orphanTextNode), false); + }); + + await t.test('null input is not editable', () => { + assert.equal(hooks.isNodeInsideEditableRegion(null), false); + }); +}); + +test('fetchAsnNetworkName', async (t) => { + await t.test('resolves a name and serves the repeat call from memory', async () => { + const { hooks } = loadDp(); + const fetchJson = makeFetchJsonStub({ data: [NET_64500] }); + + assert.equal(await hooks.fetchAsnNetworkName(64500, { fetchJson }), 'Example Net'); + assert.equal(await hooks.fetchAsnNetworkName(64500, { fetchJson }), 'Example Net'); + assert.equal(fetchJson.calls, 1); + }); + + await t.test('prefers name_long over name', async () => { + const { hooks } = loadDp(); + const fetchJson = makeFetchJsonStub({ + data: [{ asn: 64500, status: 'ok', name: 'Short', name_long: 'Long Legal Name Ltd.' }], + }); + assert.equal(await hooks.fetchAsnNetworkName(64500, { fetchJson }), 'Long Legal Name Ltd.'); + }); + + await t.test('prefers the exact-ASN active row over an earlier stale row', async () => { + const { hooks } = loadDp(); + const fetchJson = makeFetchJsonStub({ + data: [ + { asn: 64500, status: 'deleted', name: 'Old Net' }, + { asn: 64500, status: 'ok', name: 'Current Net' }, + ], + }); + assert.equal(await hooks.fetchAsnNetworkName(64500, { fetchJson }), 'Current Net'); + }); + + await t.test('a persisted shared-cache name short-circuits the transport', async () => { + const { hooks } = loadDp(); + hooks.setCachedDataInStorage('asn', '64501', { name: 'Persisted Net' }); + const fetchJson = makeFetchJsonStub(null, { fail: true }); + + assert.equal(await hooks.fetchAsnNetworkName(64501, { fetchJson }), 'Persisted Net'); + assert.equal(fetchJson.calls, 0); + }); + + await t.test('an empty result is "" and the miss is memory-cached', async () => { + const { hooks } = loadDp(); + const fetchJson = makeFetchJsonStub({ data: [] }); + + assert.equal(await hooks.fetchAsnNetworkName(64502, { fetchJson }), ''); + assert.equal(await hooks.fetchAsnNetworkName(64502, { fetchJson }), ''); + assert.equal(fetchJson.calls, 1); + }); + + await t.test('a transport failure is "" but NOT cached -- the next call retries', async () => { + const { hooks } = loadDp(); + const fetchJson = makeFetchJsonStub(null, { fail: true }); + + assert.equal(await hooks.fetchAsnNetworkName(64503, { fetchJson }), ''); + assert.equal(await hooks.fetchAsnNetworkName(64503, { fetchJson }), ''); + assert.equal(fetchJson.calls, 2); + }); + + await t.test('concurrent calls for one ASN share a single in-flight request', async () => { + const { hooks } = loadDp(); + const fetchJson = makeFetchJsonStub({ data: [NET_64500] }); + + const [first, second] = await Promise.all([ + hooks.fetchAsnNetworkName(64500, { fetchJson }), + hooks.fetchAsnNetworkName(64500, { fetchJson }), + ]); + assert.equal(first, 'Example Net'); + assert.equal(second, 'Example Net'); + assert.equal(fetchJson.calls, 1); + }); + + await t.test('non-numeric ASN input is "" with no transport call', async () => { + const { hooks } = loadDp(); + const fetchJson = makeFetchJsonStub({ data: [NET_64500] }); + + for (const bad of ['', 'AS64500', 'abc', null, undefined]) { + assert.equal(await hooks.fetchAsnNetworkName(bad, { fetchJson }), '', String(bad)); + } + assert.equal(fetchJson.calls, 0); + }); +});