diff --git a/jest/setup.js b/jest/setup.js index ad553a12f..26944b898 100644 --- a/jest/setup.js +++ b/jest/setup.js @@ -12,56 +12,58 @@ class ResizeObserver { disconnect() {} } -window.ResizeObserver = ResizeObserver +if (typeof window !== "undefined") { + window.ResizeObserver = ResizeObserver -Element.prototype.getBoundingClientRect = jest.fn(() => { - return { - width: 120, - height: 120, - top: 0, - left: 0, - bottom: 120, - right: 120, - x: 0, - y: 0, - } -}) + Element.prototype.getBoundingClientRect = jest.fn(() => { + return { + width: 120, + height: 120, + top: 0, + left: 0, + bottom: 120, + right: 120, + x: 0, + y: 0, + } + }) -Object.defineProperties(HTMLElement.prototype, { - offsetHeight: { - get() { - return parseFloat(this.style.height) || 500 + Object.defineProperties(HTMLElement.prototype, { + offsetHeight: { + get() { + return parseFloat(this.style.height) || 500 + }, + configurable: true, }, - configurable: true, - }, - offsetWidth: { - get() { - return parseFloat(this.style.width) || 500 + offsetWidth: { + get() { + return parseFloat(this.style.width) || 500 + }, + configurable: true, }, - configurable: true, - }, - scrollHeight: { - get() { - return parseFloat(this.style.minHeight) || parseFloat(this.style.height) || 500 + scrollHeight: { + get() { + return parseFloat(this.style.minHeight) || parseFloat(this.style.height) || 500 + }, + configurable: true, }, - configurable: true, - }, - scrollWidth: { - get() { - return parseFloat(this.style.width) || 500 + scrollWidth: { + get() { + return parseFloat(this.style.width) || 500 + }, + configurable: true, }, - configurable: true, - }, - clientHeight: { - get() { - return parseFloat(this.style.height) || 500 + clientHeight: { + get() { + return parseFloat(this.style.height) || 500 + }, + configurable: true, }, - configurable: true, - }, - clientWidth: { - get() { - return parseFloat(this.style.width) || 500 + clientWidth: { + get() { + return parseFloat(this.style.width) || 500 + }, + configurable: true, }, - configurable: true, - }, -}) + }) +} diff --git a/package.json b/package.json index e5cf057e0..bfeabf218 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@netdata/charts", - "version": "6.12.6", + "version": "6.12.7", "description": "Netdata frontend SDK and chart utilities", "main": "dist/index.js", "module": "dist/es6/index.js", diff --git a/src/components/drawer/compare/statValue.js b/src/components/drawer/compare/statValue.js index a680db060..698fb7b04 100644 --- a/src/components/drawer/compare/statValue.js +++ b/src/components/drawer/compare/statValue.js @@ -1,9 +1,9 @@ import React from "react" import { Flex, Text, TextMicro, TextSmall } from "@netdata/netdata-ui" +import { stripRateUnit } from "@/helpers/units" import { useValueWithUnit } from "@/components/provider" -const getDisplayUnit = (unit, integrated) => - integrated && typeof unit === "string" && unit.endsWith("/s") ? unit.slice(0, -2) : unit +const getDisplayUnit = (unit, integrated) => (integrated ? stripRateUnit(unit) : unit) const StatValue = ({ value, valueKey, prominent, justifyContent }) => { const { convertedValue, convertedUnit } = useValueWithUnit(value, { diff --git a/src/components/drawer/correlate/sparklineCanvas.js b/src/components/drawer/correlate/sparklineCanvas.js index a91d5e08a..859828011 100644 --- a/src/components/drawer/correlate/sparklineCanvas.js +++ b/src/components/drawer/correlate/sparklineCanvas.js @@ -5,13 +5,13 @@ const defaultWidth = 120 const defaultHeight = 24 const linePadding = 4 -export const getSparklinePoints = (values, width, height) => { +export const getSparklinePoints = (values, width, height, range = {}) => { const finiteValues = values.filter(Number.isFinite) if (!finiteValues.length) return [] - const min = Math.min(...finiteValues) - const max = Math.max(...finiteValues) - const range = max - min + const min = Number.isFinite(range.min) ? range.min : Math.min(...finiteValues) + const max = Number.isFinite(range.max) ? range.max : Math.max(...finiteValues) + const span = max - min const xRange = Math.max(width - linePadding * 2, 1) const yRange = Math.max(height - linePadding * 2, 1) const divisor = Math.max(values.length - 1, 1) @@ -21,13 +21,13 @@ export const getSparklinePoints = (values, width, height) => { return { x: linePadding + (index / divisor) * xRange, - y: range ? linePadding + ((max - value) / range) * yRange : height / 2, + y: span ? linePadding + ((max - value) / span) * yRange : height / 2, } }) } -export const drawSparkline = (context, values, width, height, color) => { - const points = getSparklinePoints(values, width, height) +export const drawSparkline = (context, values, width, height, color, range) => { + const points = getSparklinePoints(values, width, height, range) context.clearRect(0, 0, width, height) if (!points.length) return @@ -52,7 +52,7 @@ export const drawSparkline = (context, values, width, height, color) => { context.stroke() } -const SparklineCanvas = ({ values, color, height = defaultHeight }) => { +const SparklineCanvas = ({ values, color, height = defaultHeight, range }) => { const canvasRef = useRef(null) const frameRef = useRef(null) @@ -72,8 +72,8 @@ const SparklineCanvas = ({ values, color, height = defaultHeight }) => { if (!context) return context.setTransform(ratio, 0, 0, ratio, 0, 0) - drawSparkline(context, values, width, height, color) - }, [color, height, values]) + drawSparkline(context, values, width, height, color, range) + }, [color, height, range, values]) useLayoutEffect(() => { draw() diff --git a/src/components/drawer/correlate/sparklineCanvas.test.js b/src/components/drawer/correlate/sparklineCanvas.test.js index 3777424cf..1b7a97fe6 100644 --- a/src/components/drawer/correlate/sparklineCanvas.test.js +++ b/src/components/drawer/correlate/sparklineCanvas.test.js @@ -24,4 +24,15 @@ describe("getSparklinePoints", () => { ]) expect(getSparklinePoints([null, NaN], 100, 20)).toEqual([]) }) + + it("supports a shared range without changing existing callers", () => { + expect(getSparklinePoints([0, 50], 100, 24, { min: 0, max: 100 })).toEqual([ + { x: 4, y: 20 }, + { x: 96, y: 12 }, + ]) + expect(getSparklinePoints([0, 50], 100, 24)).toEqual([ + { x: 4, y: 20 }, + { x: 96, y: 4 }, + ]) + }) }) diff --git a/src/components/drawer/correlate/sparklineData.js b/src/components/drawer/correlate/sparklineData.js index a843635af..5d9a8bab5 100644 --- a/src/components/drawer/correlate/sparklineData.js +++ b/src/components/drawer/correlate/sparklineData.js @@ -1,5 +1,6 @@ import { getAlias } from "@/helpers/units" import { getPointValue } from "@/sdk/makeChart/getPointValue" +import { normalizeDataQueryUnits } from "@/sdk/dataQuery/response" const maxConcurrentRequests = 4 const maxCachedResponses = 100 @@ -13,6 +14,7 @@ const requestAttributeKeys = [ "before", "context", "contextScope", + "dimensionsScope", "eliminateZeroDimensions", "groupBy", "groupByLabel", @@ -20,8 +22,11 @@ const requestAttributeKeys = [ "groupingTime", "host", "liveAnchor", + "limit", "nodesScope", + "nulls2zero", "points", + "postAggregationMethod", "postGroupBy", "postGroupByLabel", "renderedAt", @@ -31,6 +36,11 @@ const requestAttributeKeys = [ "selectedLabels", "selectedNodes", "showPostAggregations", + "sparklineRateVolume", + "tier", + "timeout", + "timeGroupOptions", + "unaligned", ] const states = new WeakMap() @@ -120,13 +130,15 @@ export const getSparklineBatchAttributes = (chart, dimensions, overrides = {}) = } } -export const normalizeSparklinePayload = rawPayload => { +export const normalizeSparklinePayload = (rawPayload, { rateVolume = false, timeGroup } = {}) => { const result = rawPayload?.result if (!result || !Array.isArray(result.labels) || !Array.isArray(result.data)) throw new Error("Invalid sparkline response") - const dimensionUnits = rawPayload.view?.dimensions?.units || [] - const defaultUnit = rawPayload.view?.units || rawPayload.db?.units || "" + const normalizedUnits = normalizeDataQueryUnits(rawPayload, { rateVolume, timeGroup }) + if (!normalizedUnits.available) throw new Error("Unsupported sparkline units") + const dimensionUnits = normalizedUnits.units + const defaultUnit = dimensionUnits[0] || rawPayload.db?.units || "" const seriesByDimension = new Map() for (let column = 1; column < result.labels.length; column++) { @@ -208,7 +220,12 @@ const drainQueue = state => { attrs: entry.attrs, signal: entry.controller.signal, }) - .then(normalizeSparklinePayload) + .then(payload => + normalizeSparklinePayload(payload, { + rateVolume: entry.attrs.sparklineRateVolume, + timeGroup: entry.attrs.groupingMethod, + }) + ) .then(value => { entry.status = "fulfilled" entry.resolve(value) diff --git a/src/components/drawer/correlate/sparklineData.test.js b/src/components/drawer/correlate/sparklineData.test.js index 6cabf7db9..7a10dba59 100644 --- a/src/components/drawer/correlate/sparklineData.test.js +++ b/src/components/drawer/correlate/sparklineData.test.js @@ -3,6 +3,7 @@ import { getSparklineBatchAttributes, getSparklineBatchDimensions, getSparklineDataFetcher, + getSparklineRequestKey, normalizeSparklinePayload, sparklineRequestLimits, } from "./sparklineData" @@ -57,6 +58,35 @@ const makeDimension = (index, overrides = {}) => ({ const makeAttrs = (owner, index) => getSparklineBatchAttributes(owner, [makeDimension(index)]) describe("sparkline batch requests", () => { + it("keys every attribute that can change a fleet trend response", () => { + const attrs = { + contextScope: ["context"], + dimensionsScope: ["dimension"], + limit: 1, + nulls2zero: false, + tier: 0, + timeGroupOptions: ["absolute"], + unaligned: true, + } + + expect(getSparklineRequestKey(attrs)).not.toBe( + getSparklineRequestKey({ + ...attrs, + dimensionsScope: ["other-dimension"], + }) + ) + expect(getSparklineRequestKey(attrs)).not.toBe( + getSparklineRequestKey({ + ...attrs, + timeGroupOptions: ["percentage"], + }) + ) + expect(getSparklineRequestKey(attrs)).not.toBe(getSparklineRequestKey({ ...attrs, limit: 2 })) + expect(getSparklineRequestKey(attrs)).not.toBe( + getSparklineRequestKey({ ...attrs, nulls2zero: true }) + ) + }) + it("builds deterministic bounded batches for one context and node", () => { const dimensions = Array.from({ length: 120 }, (_, index) => makeDimension(index)).reverse() dimensions.push(makeDimension(200, { context: "other-context" })) @@ -131,6 +161,62 @@ describe("normalizeSparklinePayload", () => { it("rejects responses without chart labels and data", () => { expect(() => normalizeSparklinePayload({})).toThrow("Invalid sparkline response") }) + + it("falls back to the first dimension unit for columns beyond heterogeneous units", () => { + const payload = { + view: { + units: "percentage", + dimensions: { units: ["microseconds", "bytes"] }, + }, + result: { + labels: ["time", "latency", "traffic", "extra"], + point: { value: 0, anomalyRate: 1 }, + data: [ + [1000, [1, 0], [2, 0], [3, 0]], + [1005, [3, 0], [8, 0], [9, 0]], + ], + }, + } + + const result = normalizeSparklinePayload(payload) + + expect(result.get("latency").unit).toBe("us") + expect(result.get("traffic").unit).toBe("By") + expect(result.get("extra").unit).toBe("us") + }) + + it("rejects a Volume trend when rate and non-rate dimension units are mixed", () => { + expect(() => + normalizeSparklinePayload( + makePayload(["latency", "traffic"], { units: ["MiB/s", "requests"] }), + { rateVolume: true, timeGroup: "sum" } + ) + ).toThrow("Unsupported sparkline units") + }) + + it("rejects a Volume trend when the time grouping is not a sum", () => { + expect(() => + normalizeSparklinePayload(makePayload(["traffic"], { units: ["MiB/s"] }), { + rateVolume: true, + timeGroup: "average", + }) + ).toThrow("Unsupported sparkline units") + }) + + it("normalizes canonical rate units for a Volume trend and rejects unknown units", () => { + expect( + normalizeSparklinePayload(makePayload(["traffic"], { units: ["MiB/s"] }), { + rateVolume: true, + timeGroup: "sum", + }).get("traffic").unit + ).toBe("MiBy") + expect(() => + normalizeSparklinePayload(makePayload(["traffic"], { units: [""] }), { + rateVolume: true, + timeGroup: "sum", + }) + ).toThrow("Unsupported sparkline units") + }) }) describe("getSparklineDataFetcher", () => { diff --git a/src/helpers/units/index.js b/src/helpers/units/index.js index 961927dde..c7f527a11 100644 --- a/src/helpers/units/index.js +++ b/src/helpers/units/index.js @@ -12,6 +12,10 @@ export const unitsMissing = u => { return typeof allUnits.units[alias] === "undefined" } +export const isRateUnit = unit => typeof unit === "string" && unit.endsWith("/s") + +export const stripRateUnit = unit => (isRateUnit(unit) ? unit.slice(0, -2) : unit) + const unitOrEmpty = u => (u === null || typeof u === "undefined" ? "" : u) export const getUnitConfig = u => { diff --git a/src/helpers/units/index.test.js b/src/helpers/units/index.test.js index dd34e235b..eb8d30485 100644 --- a/src/helpers/units/index.test.js +++ b/src/helpers/units/index.test.js @@ -11,6 +11,8 @@ import unitConverter, { isDecimalByte, getScales, getUnitsString, + isRateUnit, + stripRateUnit, } from "." import scalableUnits from "./scalableUnits" @@ -641,4 +643,21 @@ describe("units helpers", () => { expect(metricScales).toBe(scalableUnits.num) }) }) + + describe("rate units", () => { + it("detects only string units ending with a rate suffix", () => { + expect(isRateUnit("bytes/s")).toBe(true) + expect(isRateUnit("bytes")).toBe(false) + expect(isRateUnit("/s")).toBe(true) + expect(isRateUnit(undefined)).toBe(false) + expect(isRateUnit(123)).toBe(false) + }) + + it("strips the rate suffix only from rate units and leaves others untouched", () => { + expect(stripRateUnit("bytes/s")).toBe("bytes") + expect(stripRateUnit("requests")).toBe("requests") + expect(stripRateUnit("/s")).toBe("") + expect(stripRateUnit(undefined)).toBe(undefined) + }) + }) }) diff --git a/src/sdk/dataQuery/index.js b/src/sdk/dataQuery/index.js new file mode 100644 index 000000000..e0c62cda3 --- /dev/null +++ b/src/sdk/dataQuery/index.js @@ -0,0 +1,139 @@ +import { buildDataRequest, withDataRequestAuth } from "./request" +import { + normalizeDataQueryUnits, + validateDataQueryResponse, + validateDataQueryTierCoverage, +} from "./response" +import { DataRequestError, fetchDataRequest } from "./transport" + +export * from "./request" +export * from "./response" +export * from "./transport" + +const defaultOptions = ["jsonwrap", "flip", "ms", "jw-anomaly-rates", "minify"] +const clientTimeoutGraceMs = 5_000 +const directAgentUrlLimitBytes = 96 * 1024 + +const validateTransportCapability = (request, { agent }) => { + if (!agent) return + + const urlBytes = new TextEncoder().encode(request.url).byteLength + if (urlBytes > directAgentUrlLimitBytes) + throw new DataRequestError("Data request is too large for direct Agent transport", { + code: "request-too-large", + }) +} + +const validateRequestAttributes = (attributes, expectedNodeIds) => { + const { + host, + limit, + tier, + timeout, + after, + before, + points, + groupBy, + groupByLabel, + aggregationMethod, + format, + options, + showPostAggregations, + timeGroupOptions, + time_group_options: snakeCaseTimeGroupOptions, + unaligned, + } = attributes + if (typeof host !== "string" || !host) throw new TypeError("Data query requires a host") + if (!Array.isArray(expectedNodeIds) || !expectedNodeIds.length) + throw new TypeError("Data query requires captured node IDs") + if ( + expectedNodeIds.some(nodeId => typeof nodeId !== "string" || !nodeId) || + new Set(expectedNodeIds).size !== expectedNodeIds.length + ) + throw new TypeError("Data query requires unique captured node IDs") + if (!Number.isInteger(limit) || limit <= 0) + throw new TypeError("Data query limit must be a positive integer") + if (limit !== expectedNodeIds.length) + throw new TypeError("Data query limit must match captured node count") + if (tier != null && (!Number.isInteger(tier) || tier < 0)) + throw new TypeError("Data query tier must be a non-negative integer") + if (timeout != null && (!Number.isInteger(timeout) || timeout <= 0)) + throw new TypeError("Data query timeout must be a positive integer") + if (!Number.isFinite(after) || !Number.isFinite(before)) + throw new TypeError("Data query requires a finite time window") + if (after <= 0 || before <= after) + throw new TypeError("Data query requires an ordered absolute time window") + if (points !== 1) throw new TypeError("Data query requires exactly one point") + if (!Array.isArray(groupBy) || groupBy.length !== 1 || groupBy[0] !== "node") + throw new TypeError("Data query requires final grouping by node") + if (!Array.isArray(groupByLabel) || groupByLabel.length) + throw new TypeError("Data query does not support node label grouping") + if (showPostAggregations) throw new TypeError("Data query does not support post aggregations") + if (typeof aggregationMethod !== "string" || !aggregationMethod) + throw new TypeError("Data query requires a metric aggregation") + if (typeof getTimeGroup(attributes) !== "string" || !getTimeGroup(attributes)) + throw new TypeError("Data query requires a time aggregation") + const resolvedTimeGroupOptions = timeGroupOptions ?? snakeCaseTimeGroupOptions + if (resolvedTimeGroupOptions != null && typeof resolvedTimeGroupOptions !== "string") + throw new TypeError("Data query time group options must be a string") + if (format !== "json2") throw new TypeError("Data query requires JSON2 format") + if (!Array.isArray(options)) throw new TypeError("Data query options must be an array") + if (!unaligned && !options.includes("unaligned")) + throw new TypeError("Data query requires unaligned results") + if (options.includes("nonzero")) throw new TypeError("Data query cannot eliminate zero values") + if (options.includes("null2zero")) throw new TypeError("Data query cannot convert gaps to zero") +} + +const getTimeGroup = attributes => attributes.time_group ?? attributes.groupingMethod + +export default ({ getAttributes }) => + async ( + attributes = {}, + { expectedNodeIds, rateVolume = false, requireTierCoverage = false, ...options } = {} + ) => { + const requestAttributes = { + ...getAttributes(), + ...attributes, + options: attributes.options ?? defaultOptions, + format: attributes.format || "json2", + } + const capturedNodeIds = expectedNodeIds ?? requestAttributes.selectedNodes + validateRequestAttributes(requestAttributes, capturedNodeIds) + if (requireTierCoverage && requestAttributes.tier == null) + throw new TypeError("Data query tier coverage requires an explicit tier") + + const request = buildDataRequest(requestAttributes) + validateTransportCapability(request, requestAttributes) + const payload = await fetchDataRequest( + request, + withDataRequestAuth(requestAttributes, options), + { + ...(requestAttributes.timeout && { + timeoutMs: requestAttributes.timeout + clientTimeoutGraceMs, + }), + } + ) + + if (payload?.errorMessage || payload?.errorMsgKey) + throw new DataRequestError(payload.errorMessage || payload.errorMsgKey, { payload }) + + const validation = validateDataQueryResponse(payload, capturedNodeIds) + const tierCoverage = requireTierCoverage + ? validateDataQueryTierCoverage(payload, { + after: requestAttributes.after, + before: requestAttributes.before, + expectedNodeIds: capturedNodeIds, + tier: requestAttributes.tier, + }) + : undefined + + return { + payload, + ...validation, + ...(tierCoverage && { tierCoverage }), + units: normalizeDataQueryUnits(payload, { + rateVolume, + timeGroup: getTimeGroup(requestAttributes), + }), + } + } diff --git a/src/sdk/dataQuery/index.test.js b/src/sdk/dataQuery/index.test.js new file mode 100644 index 000000000..d7f3f42f8 --- /dev/null +++ b/src/sdk/dataQuery/index.test.js @@ -0,0 +1,656 @@ +/** @jest-environment node */ + +import http from "http" +import makeSDK from "../index" +import { buildDataRequest } from "./request" +import { dataQueryNodeStatus } from "./response" +import { fetchDataRequest } from "./transport" + +const responsePayload = { + db: { + per_tier: [ + { + tier: 0, + queries: 1, + points: 1001, + update_every: 1, + first_entry: 900, + last_entry: 2000, + }, + ], + }, + summary: { + nodes: [{ mg: "machine-1", nd: "node-1", st: { code: 200 }, ds: { qr: 1 } }], + }, + view: { dimensions: { aggregated: [1], units: ["percentage"] } }, + result: { + labels: ["time", "machine-1"], + data: [[1000, 0]], + point: { value: 0 }, + }, +} + +const startServer = (handler, options) => + new Promise((resolve, reject) => { + const server = options ? http.createServer(options, handler) : http.createServer(handler) + server.once("error", reject) + server.listen(0, "127.0.0.1", () => { + const { port } = server.address() + resolve({ server, host: `http://127.0.0.1:${port}` }) + }) + }) + +const closeServer = server => + new Promise((resolve, reject) => + server.close(error => { + if (error) reject(error) + else resolve() + }) + ) + +const readBody = request => + new Promise(resolve => { + let body = "" + request.setEncoding("utf8") + request.on("data", chunk => (body += chunk)) + request.on("end", () => resolve(body)) + }) + +const queryAttributes = { + selectedContexts: ["system.cpu"], + contextScope: ["system.cpu"], + selectedNodes: ["node-1"], + selectedInstances: [], + selectedDimensions: ["user"], + dimensionsScope: ["user", "system"], + selectedLabels: [], + nodesScope: [], + aggregationMethod: "sum", + groupBy: ["node"], + groupByLabel: [], + groupingMethod: "average", + groupingTime: 0, + after: 1000, + before: 2000, + points: 1, + limit: 1, + tier: 0, + timeGroupOptions: "95", + unaligned: true, +} + +const makeGeneratedNodeIds = count => + Array.from( + { length: count }, + (_, index) => `00000000-0000-4000-8000-${String(index).padStart(12, "0")}` + ) + +describe("SDK data query transport", () => { + it.each([ + ["negative limit", { limit: -1 }, "limit must be a positive integer"], + ["zero limit", { limit: 0 }, "limit must be a positive integer"], + ["mismatched limit", { limit: 2 }, "limit must match captured node count"], + ["multiple points", { points: 2 }, "requires exactly one point"], + ["non-node grouping", { groupBy: ["dimension"] }, "requires final grouping by node"], + ["node label grouping", { groupByLabel: ["environment"] }, "node label grouping"], + ["post aggregation", { showPostAggregations: true }, "does not support post aggregations"], + ["relative window", { after: -300, before: 0 }, "requires an ordered absolute time window"], + ["reversed window", { after: 2000, before: 1000 }, "requires an ordered absolute time window"], + ["aligned request", { unaligned: false }, "requires unaligned results"], + ["nonzero option", { options: ["jsonwrap", "nonzero"] }, "cannot eliminate zero values"], + ["null2zero option", { options: ["jsonwrap", "null2zero"] }, "cannot convert gaps to zero"], + ["non-JSON2 format", { format: "csv" }, "requires JSON2 format"], + ["invalid time group options", { timeGroupOptions: { percentile: 95 } }, "must be a string"], + ])("rejects invalid fleet request: %s", async (_name, overrides, message) => { + const sdk = makeSDK({ + ui: {}, + attributes: { agent: false, host: "http://127.0.0.1:1" }, + }) + + await expect( + sdk.queryData({ ...queryAttributes, ...overrides }, { expectedNodeIds: ["node-1"] }) + ).rejects.toThrow(message) + }) + + it("requires an explicit tier when coverage proof is requested", async () => { + const sdk = makeSDK({ + ui: {}, + attributes: { agent: false, host: "http://127.0.0.1:1" }, + }) + + await expect( + sdk.queryData( + { ...queryAttributes, tier: undefined }, + { expectedNodeIds: ["node-1"], requireTierCoverage: true } + ) + ).rejects.toThrow("tier coverage requires an explicit tier") + }) + + it("queries Cloud through the SDK with real transport and inherited bearer", async () => { + let captured + const { server, host } = await startServer(async (request, response) => { + captured = { + method: request.method, + authorization: request.headers.authorization, + body: JSON.parse(await readBody(request)), + } + response.writeHead(200, { "Content-Type": "application/json" }) + response.end(JSON.stringify(responsePayload)) + }) + + try { + const sdk = makeSDK({ ui: {}, attributes: { agent: false, host, bearer: "token" } }) + const result = await sdk.queryData( + { ...queryAttributes, selectedNodes: [] }, + { expectedNodeIds: ["node-1"], requireTierCoverage: true } + ) + + expect(result.complete).toBe(true) + expect(result.tierCoverage).toMatchObject({ exact: true, status: "exact" }) + expect(captured.method).toBe("POST") + expect(captured.authorization).toBe("Bearer token") + expect(captured.body.limit).toBe(1) + expect(captured.body.selectors.nodes).toEqual(["*"]) + expect(captured.body.scope.dimensions).toEqual(["user", "system"]) + expect(captured.body.window.tier).toBe(0) + expect(captured.body.options).toContain("unaligned") + } finally { + await closeServer(server) + } + }) + + it("queries an Agent with real transport and X-Netdata auth", async () => { + let captured + const { server, host } = await startServer((request, response) => { + captured = { + method: request.method, + auth: request.headers["x-netdata-auth"], + url: new URL(request.url, host), + } + response.writeHead(200, { "Content-Type": "application/json" }) + response.end(JSON.stringify(responsePayload)) + }) + + try { + const sdk = makeSDK({ + ui: {}, + attributes: { agent: true, host, xNetdataBearer: "agent-token" }, + }) + const result = await sdk.queryData(queryAttributes, { expectedNodeIds: ["node-1"] }) + + expect(result.complete).toBe(true) + expect(result.tierCoverage).toBeUndefined() + expect(captured.method).toBe("GET") + expect(captured.auth).toBe("Bearer agent-token") + expect(captured.url.searchParams.get("limit")).toBe("1") + expect(captured.url.searchParams.get("tier")).toBe("0") + expect(captured.url.searchParams.get("scope_dimensions")).toBe("user|system") + expect(captured.url.searchParams.get("options")).toContain("unaligned") + } finally { + await closeServer(server) + } + }) + + it("keeps a 6,107-member direct-Agent full-room request compact", async () => { + let captured + const emptyPayload = { + summary: { nodes: [] }, + view: { dimensions: { ids: [], units: [] } }, + result: { labels: [], data: [], point: { value: 0 } }, + } + const { server, host } = await startServer((request, response) => { + captured = { + method: request.method, + url: new URL(request.url, host), + } + response.writeHead(200, { "Content-Type": "application/json" }) + response.end(JSON.stringify(emptyPayload)) + }) + const nodeIds = makeGeneratedNodeIds(6_107) + const attributes = { + ...queryAttributes, + selectedNodes: [], + nodesScope: [], + limit: nodeIds.length, + options: [], + } + + try { + const sdk = makeSDK({ ui: {}, attributes: { agent: true, host } }) + const expectedRequest = buildDataRequest({ ...attributes, agent: true, host }) + const oneMemberRequest = buildDataRequest({ + ...attributes, + agent: true, + host, + limit: 1, + }) + const expectedUrl = new URL(expectedRequest.url) + const oneMemberUrl = new URL(oneMemberRequest.url) + expectedUrl.searchParams.delete("limit") + oneMemberUrl.searchParams.delete("limit") + expect(expectedUrl.href).toBe(oneMemberUrl.href) + expect(new TextEncoder().encode(expectedRequest.url).byteLength).toBeLessThan(2 * 1024) + + const result = await sdk.queryData(attributes, { expectedNodeIds: nodeIds }) + + expect(captured.method).toBe("GET") + expect(captured.url.href).toBe(expectedRequest.url) + expect(captured.url.searchParams.get("nodes")).toBe("*") + expect(captured.url.searchParams.get("scope_nodes")).toBe("*") + expect(captured.url.searchParams.get("limit")).toBe(String(nodeIds.length)) + expect(captured.url.href).not.toMatch(/00000000-0000-4000-8000-/) + expect(result.complete).toBe(true) + expect(result.issues).toEqual([]) + expect(result.missingNodeIds).toHaveLength(nodeIds.length) + expect(result.nodeStatuses.every(status => status === dataQueryNodeStatus.gap)).toBe(true) + expect(result.resultIndexes.every(index => index === -1)).toBe(true) + } finally { + await closeServer(server) + } + }) + + it("rejects an oversized direct-Agent URL before transport", async () => { + let requestCount = 0 + const { server, host } = await startServer( + (_request, response) => { + requestCount += 1 + response.writeHead(200, { "Content-Type": "application/json" }) + response.end(JSON.stringify(responsePayload)) + }, + { maxHeaderSize: 256 * 1024 } + ) + const nodeIds = makeGeneratedNodeIds(3_000) + const attributes = { + ...queryAttributes, + selectedNodes: [], + nodesScope: nodeIds, + limit: nodeIds.length, + } + + try { + const sdk = makeSDK({ ui: {}, attributes: { agent: true, host } }) + const request = buildDataRequest({ ...attributes, agent: true, host }) + expect(new TextEncoder().encode(request.url).byteLength).toBeGreaterThan(96 * 1024) + const error = await sdk + .queryData(attributes, { expectedNodeIds: nodeIds }) + .catch(cause => cause) + + expect(error).toMatchObject({ + code: "request-too-large", + name: "DataRequestError", + }) + expect(error.message).toBe("Data request is too large for direct Agent transport") + expect(error.message).not.toContain(nodeIds[0]) + expect(requestCount).toBe(0) + } finally { + await closeServer(server) + } + }) + + it("allows a direct-Agent URL at the safety ceiling", async () => { + let capturedUrl + const { server, host } = await startServer( + (request, response) => { + capturedUrl = new URL(request.url, host).href + response.writeHead(200, { "Content-Type": "application/json" }) + response.end(JSON.stringify(responsePayload)) + }, + { maxHeaderSize: 128 * 1024 } + ) + const urlLimitBytes = 96 * 1024 + const baseAttributes = { + ...queryAttributes, + selectedNodes: [], + nodesScope: ["a"], + options: [], + } + const baseRequest = buildDataRequest({ ...baseAttributes, agent: true, host }) + const paddingLength = urlLimitBytes - new TextEncoder().encode(baseRequest.url).byteLength + const attributes = { + ...baseAttributes, + nodesScope: [`a${"a".repeat(paddingLength)}`], + } + + try { + const sdk = makeSDK({ ui: {}, attributes: { agent: true, host } }) + await expect(sdk.queryData(attributes, { expectedNodeIds: ["node-1"] })).resolves.toEqual( + expect.objectContaining({ payload: responsePayload }) + ) + + const urlBytes = new TextEncoder().encode(capturedUrl).byteLength + expect(urlBytes).toBe(urlLimitBytes) + } finally { + await closeServer(server) + } + }) + + it("does not apply the direct-Agent URL ceiling to Cloud POST bodies", async () => { + let captured + const { server, host } = await startServer(async (request, response) => { + captured = { + method: request.method, + body: JSON.parse(await readBody(request)), + } + response.writeHead(200, { "Content-Type": "application/json" }) + response.end(JSON.stringify(responsePayload)) + }) + const nodeIds = makeGeneratedNodeIds(3_000) + + try { + const sdk = makeSDK({ ui: {}, attributes: { agent: false, host } }) + const result = await sdk.queryData( + { + ...queryAttributes, + selectedNodes: [], + nodesScope: nodeIds, + limit: nodeIds.length, + }, + { expectedNodeIds: nodeIds } + ) + + expect(result.payload).toEqual(responsePayload) + expect(captured.method).toBe("POST") + expect(captured.body.scope.nodes).toHaveLength(nodeIds.length) + expect(captured.body.selectors.nodes).toEqual(["*"]) + } finally { + await closeServer(server) + } + }) + + it("propagates cancellation without converting it to a metric gap", async () => { + const { server, host } = await startServer((_request, response) => { + setTimeout(() => { + response.writeHead(200, { "Content-Type": "application/json" }) + response.end(JSON.stringify(responsePayload)) + }, 50) + }) + + try { + const sdk = makeSDK({ ui: {}, attributes: { agent: false, host } }) + const controller = new AbortController() + const request = sdk.queryData( + { ...queryAttributes, timeout: 1_000 }, + { + expectedNodeIds: ["node-1"], + signal: controller.signal, + } + ) + controller.abort() + + await expect(request).rejects.toMatchObject({ name: "AbortError" }) + } finally { + await closeServer(server) + } + }) + + it("preserves an earlier lifecycle abort when the deadline fires before fetch rejects", async () => { + let requestStarted + const started = new Promise(resolve => (requestStarted = resolve)) + const { server, host } = await startServer(() => requestStarted()) + const controller = new AbortController() + + jest.useFakeTimers({ + doNotFake: [ + "Date", + "hrtime", + "nextTick", + "performance", + "queueMicrotask", + "setImmediate", + "clearImmediate", + "setInterval", + "clearInterval", + ], + }) + + try { + const request = fetchDataRequest( + { url: host, options: {} }, + { signal: controller.signal }, + { timeoutMs: 25 } + ) + await started + controller.signal.addEventListener("abort", () => jest.advanceTimersByTime(25), { + once: true, + }) + + controller.abort() + + await expect(request).rejects.toMatchObject({ name: "AbortError" }) + } finally { + jest.useRealTimers() + await closeServer(server) + } + }) + + it("preserves an earlier deadline when lifecycle aborts before fetch rejects", async () => { + let requestStarted + const started = new Promise(resolve => (requestStarted = resolve)) + const { server, host } = await startServer(() => requestStarted()) + const controller = new AbortController() + + jest.useFakeTimers({ + doNotFake: [ + "Date", + "hrtime", + "nextTick", + "performance", + "queueMicrotask", + "setImmediate", + "clearImmediate", + "setInterval", + "clearInterval", + ], + }) + + try { + const request = fetchDataRequest( + { url: host, options: {} }, + { signal: controller.signal }, + { timeoutMs: 25 } + ) + await started + + jest.advanceTimersByTime(25) + controller.abort() + + await expect(request).rejects.toMatchObject({ + code: "timeout", + name: "DataRequestError", + }) + } finally { + jest.useRealTimers() + await closeServer(server) + } + }) + + it("enforces an independent client deadline with a typed query failure", async () => { + const { server, host } = await startServer(() => undefined) + + try { + await expect( + fetchDataRequest({ url: host, options: {} }, {}, { timeoutMs: 25 }) + ).rejects.toMatchObject({ + code: "timeout", + name: "DataRequestError", + }) + } finally { + await closeServer(server) + } + }) + + it("derives the client deadline from an explicit backend timeout", async () => { + let requestStarted + const started = new Promise(resolve => (requestStarted = resolve)) + let serverResponse + let serverSocket + let serializedTimeout + const explicitTimeoutMs = 100_000 + const expectedClientDeadlineMs = explicitTimeoutMs + 5_000 + const { server, host } = await startServer(async (request, response) => { + serializedTimeout = JSON.parse(await readBody(request)).timeout + serverResponse = response + serverSocket = request.socket + response.writeHead(200, { "Content-Type": "application/json" }) + response.write("{") + requestStarted() + }) + const controller = new AbortController() + + jest.useFakeTimers({ + doNotFake: [ + "Date", + "hrtime", + "nextTick", + "performance", + "queueMicrotask", + "setImmediate", + "clearImmediate", + "setInterval", + "clearInterval", + ], + }) + + try { + const sdk = makeSDK({ ui: {}, attributes: { agent: false, host } }) + let settled = false + const request = sdk.queryData( + { ...queryAttributes, timeout: explicitTimeoutMs }, + { expectedNodeIds: ["node-1"], signal: controller.signal } + ) + request.then( + () => { + settled = true + }, + () => { + settled = true + } + ) + + await started + expect(serializedTimeout).toBe(explicitTimeoutMs) + + jest.advanceTimersByTime(expectedClientDeadlineMs - 1) + await Promise.resolve() + expect(settled).toBe(false) + + jest.advanceTimersByTime(1) + await expect(request).rejects.toMatchObject({ + code: "timeout", + message: `Data request timed out after ${expectedClientDeadlineMs} ms`, + name: "DataRequestError", + }) + expect(settled).toBe(true) + } finally { + controller.abort() + jest.useRealTimers() + serverResponse?.destroy() + serverSocket?.destroy() + await closeServer(server) + } + }) + + it("preserves cancellation while reading the response body", async () => { + let bodyStarted + const started = new Promise(resolve => (bodyStarted = resolve)) + const serialized = JSON.stringify(responsePayload) + const { server, host } = await startServer((_request, response) => { + response.writeHead(200, { "Content-Type": "application/json" }) + response.write(serialized.slice(0, 1)) + bodyStarted() + setTimeout(() => response.end(serialized.slice(1)), 100) + }) + + try { + const sdk = makeSDK({ ui: {}, attributes: { agent: false, host } }) + const controller = new AbortController() + const request = sdk.queryData(queryAttributes, { + expectedNodeIds: ["node-1"], + signal: controller.signal, + }) + await started + await new Promise(resolve => setTimeout(resolve, 10)) + controller.abort() + + await expect(request).rejects.toMatchObject({ name: "AbortError" }) + } finally { + await closeServer(server) + } + }) + + it.each([ + [404, "application/json", JSON.stringify({ message: "missing route" })], + [200, "text/html", "not json"], + ])("rejects route and JSON failures", async (status, contentType, body) => { + const { server, host } = await startServer((_request, response) => { + response.writeHead(status, { "Content-Type": contentType }) + response.end(body) + }) + + try { + const sdk = makeSDK({ ui: {}, attributes: { agent: false, host } }) + await expect( + sdk.queryData(queryAttributes, { expectedNodeIds: ["node-1"] }) + ).rejects.toMatchObject({ name: "DataRequestError" }) + } finally { + await closeServer(server) + } + }) + + it("preserves the rendered-chart contract for non-OK JSON payloads", async () => { + const payload = { message: "temporary failure" } + const { server, host } = await startServer((_request, response) => { + response.writeHead(503, { "Content-Type": "application/json" }) + response.end(JSON.stringify(payload)) + }) + + try { + await expect( + fetchDataRequest({ url: host, options: {} }, {}, { rejectHttpErrors: false }) + ).resolves.toEqual(payload) + await expect(fetchDataRequest({ url: host, options: {} })).rejects.toMatchObject({ + name: "DataRequestError", + status: 503, + }) + } finally { + await closeServer(server) + } + }) + + it("preserves HTTP status for strict JSON error payloads", async () => { + const payload = { errorMessage: "temporary failure" } + const { server, host } = await startServer((_request, response) => { + response.writeHead(503, { "Content-Type": "application/json" }) + response.end(JSON.stringify(payload)) + }) + + try { + await expect(fetchDataRequest({ url: host, options: {} })).rejects.toMatchObject({ + name: "DataRequestError", + status: 503, + payload, + }) + } finally { + await closeServer(server) + } + }) + + it("preserves native JSON errors for rendered charts", async () => { + const { server, host } = await startServer((_request, response) => { + response.writeHead(200, { "Content-Type": "text/html" }) + response.end("not json") + }) + + try { + await expect( + fetchDataRequest( + { url: host, options: {} }, + {}, + { rejectHttpErrors: false, wrapJsonErrors: false } + ) + ).rejects.toMatchObject({ name: "SyntaxError" }) + } finally { + await closeServer(server) + } + }) +}) diff --git a/src/sdk/dataQuery/request.js b/src/sdk/dataQuery/request.js new file mode 100644 index 000000000..dd2c76096 --- /dev/null +++ b/src/sdk/dataQuery/request.js @@ -0,0 +1,204 @@ +const wildcard = "*" +const wildcardArray = [wildcard] + +const hasValue = value => value !== undefined && value !== null + +const withUnaligned = (options = [], unaligned) => { + const values = Array.isArray(options) ? options : [options] + if (!unaligned || values.includes("unaligned")) return values + + return [...values, "unaligned"] +} + +const selectedContexts = ({ selectedContexts, context }) => + Array.isArray(selectedContexts) && selectedContexts.length + ? selectedContexts + : context + ? [context] + : wildcardArray + +const selectedValues = values => (Array.isArray(values) && values.length ? values : wildcardArray) + +const scopedContexts = values => (Array.isArray(values) && values.length ? values : wildcardArray) + +const scopedDimensions = values => + Array.isArray(values) && values.length ? { dimensions: values } : {} + +const getTimeGroupOptions = attributes => + attributes.timeGroupOptions ?? attributes.time_group_options + +const getTimeGroup = attributes => attributes.time_group ?? attributes.groupingMethod + +const getTimeResampling = attributes => attributes.time_resampling ?? attributes.groupingTime + +export const buildCloudDataPayload = attributes => { + const { + selectedNodes, + selectedInstances, + selectedDimensions, + selectedLabels, + nodesScope, + contextScope, + dimensionsScope, + aggregationMethod, + groupBy, + groupByLabel, + postGroupBy, + postGroupByLabel, + postAggregationMethod, + showPostAggregations, + after, + before, + points, + format = "json2", + tier, + limit, + timeout, + unaligned, + } = attributes + const timeGroupOptions = getTimeGroupOptions(attributes) + const timeGroup = getTimeGroup(attributes) + const timeResampling = getTimeResampling(attributes) + const options = withUnaligned(attributes.options, unaligned) + + return { + format, + options, + scope: { + contexts: scopedContexts(contextScope), + nodes: Array.isArray(nodesScope) && nodesScope.length ? nodesScope : [], + ...scopedDimensions(dimensionsScope), + }, + selectors: { + contexts: selectedContexts(attributes), + nodes: selectedValues(selectedNodes), + instances: selectedValues(selectedInstances), + dimensions: selectedValues(selectedDimensions), + labels: selectedValues(selectedLabels), + }, + aggregations: { + metrics: [ + { + group_by: groupBy, + group_by_label: groupByLabel, + aggregation: aggregationMethod, + }, + showPostAggregations && + Array.isArray(postGroupBy) && + postGroupBy.length && { + group_by: postGroupBy, + group_by_label: postGroupByLabel, + aggregation: postAggregationMethod, + }, + ].filter(Boolean), + time: { + time_group: timeGroup, + ...(hasValue(timeGroupOptions) && { time_group_options: timeGroupOptions }), + time_resampling: timeResampling, + }, + }, + window: { + after, + ...(after > 0 && { before }), + points, + ...(hasValue(tier) && { tier }), + }, + ...(hasValue(limit) && { limit }), + ...(hasValue(timeout) && { timeout }), + } +} + +export const buildAgentDataPayload = attributes => { + const { + selectedNodes, + selectedInstances, + selectedDimensions, + selectedLabels, + nodesScope, + contextScope, + dimensionsScope, + aggregationMethod, + groupBy = [], + groupByLabel = [], + postGroupBy = [], + postGroupByLabel = [], + postAggregationMethod, + showPostAggregations, + selectedContexts: contexts, + context, + after, + before, + points, + format = "json2", + tier, + limit, + timeout, + unaligned, + } = attributes + const timeGroupOptions = getTimeGroupOptions(attributes) + const timeGroup = getTimeGroup(attributes) + const timeResampling = getTimeResampling(attributes) + const options = withUnaligned(attributes.options, unaligned) + + return { + points, + format, + time_group: timeGroup, + ...(hasValue(timeGroupOptions) && { time_group_options: timeGroupOptions }), + time_resampling: timeResampling, + after, + before, + ...(hasValue(tier) && { tier }), + ...(hasValue(limit) && { limit }), + ...(hasValue(timeout) && { timeout }), + options: options.join("|"), + contexts: (Array.isArray(contexts) ? contexts.join("|") : "") || context || wildcard, + scope_contexts: (Array.isArray(contextScope) ? contextScope.join("|") : "") || wildcard, + scope_nodes: (Array.isArray(nodesScope) ? nodesScope.join("|") : "") || wildcard, + ...(Array.isArray(dimensionsScope) && dimensionsScope.length + ? { scope_dimensions: dimensionsScope.join("|") } + : {}), + nodes: (Array.isArray(selectedNodes) ? selectedNodes.join("|") : "") || wildcard, + instances: (Array.isArray(selectedInstances) ? selectedInstances.join("|") : "") || wildcard, + dimensions: (Array.isArray(selectedDimensions) ? selectedDimensions.join("|") : "") || wildcard, + labels: (Array.isArray(selectedLabels) ? selectedLabels.join("|") : "") || wildcard, + "group_by[0]": groupBy.join("|"), + "group_by_label[0]": groupByLabel.join("|"), + "aggregation[0]": aggregationMethod, + ...(showPostAggregations && + postGroupBy.length && { + "group_by[1]": postGroupBy.join("|"), + "group_by_label[1]": postGroupByLabel.join("|"), + "aggregation[1]": postAggregationMethod, + }), + } +} + +export const buildDataRequest = attributes => { + const { agent, host } = attributes + + if (agent) { + const query = new URLSearchParams(buildAgentDataPayload(attributes)).toString() + return { url: `${host}/data?${query}`, options: {} } + } + + return { + url: `${host}/data`, + options: { + method: "POST", + body: JSON.stringify(buildCloudDataPayload(attributes)), + }, + } +} + +export const withDataRequestAuth = (attributes, options = {}) => { + const { bearer, xNetdataBearer } = attributes + if (!bearer && !xNetdataBearer) return options + + return { + ...options, + headers: bearer + ? { Authorization: `Bearer ${bearer}` } + : { "X-Netdata-Auth": `Bearer ${xNetdataBearer}` }, + } +} diff --git a/src/sdk/dataQuery/request.test.js b/src/sdk/dataQuery/request.test.js new file mode 100644 index 000000000..d64190668 --- /dev/null +++ b/src/sdk/dataQuery/request.test.js @@ -0,0 +1,163 @@ +import { + buildAgentDataPayload, + buildCloudDataPayload, + buildDataRequest, + withDataRequestAuth, +} from "./request" + +const attributes = { + host: "https://example.test", + selectedContexts: ["system.cpu"], + context: "ignored.context", + nodesScope: [], + contextScope: ["system.cpu"], + selectedNodes: ["node-1", "node-2"], + selectedInstances: [], + selectedDimensions: ["user"], + selectedLabels: [], + aggregationMethod: "sum", + groupBy: ["node"], + groupByLabel: [], + postGroupBy: ["selected"], + postGroupByLabel: [], + postAggregationMethod: "avg", + showPostAggregations: false, + after: 1000, + before: 2000, + points: 1, + format: "json2", + groupingMethod: "average", + groupingTime: 0, + options: ["jsonwrap", "flip", "ms", "jw-anomaly-rates", "minify"], +} + +describe("data query requests", () => { + it("preserves the legacy Cloud payload when optional data-query attributes are absent", () => { + expect(buildCloudDataPayload(attributes)).toEqual({ + format: "json2", + options: ["jsonwrap", "flip", "ms", "jw-anomaly-rates", "minify"], + scope: { contexts: ["system.cpu"], nodes: [] }, + selectors: { + contexts: ["system.cpu"], + nodes: ["node-1", "node-2"], + instances: ["*"], + dimensions: ["user"], + labels: ["*"], + }, + aggregations: { + metrics: [ + { + group_by: ["node"], + group_by_label: [], + aggregation: "sum", + }, + ], + time: { + time_group: "average", + time_resampling: 0, + }, + }, + window: { after: 1000, before: 2000, points: 1 }, + }) + }) + + it("preserves the legacy Agent payload when optional data-query attributes are absent", () => { + expect(buildAgentDataPayload(attributes)).toEqual({ + points: 1, + format: "json2", + time_group: "average", + time_resampling: 0, + after: 1000, + before: 2000, + options: "jsonwrap|flip|ms|jw-anomaly-rates|minify", + contexts: "system.cpu", + scope_contexts: "system.cpu", + scope_nodes: "*", + nodes: "node-1|node-2", + instances: "*", + dimensions: "user", + labels: "*", + "group_by[0]": "node", + "group_by_label[0]": "", + "aggregation[0]": "sum", + }) + }) + + it("preserves legacy post-aggregation payloads", () => { + const postAggregations = { ...attributes, showPostAggregations: true } + + expect(buildCloudDataPayload(postAggregations).aggregations.metrics[1]).toEqual({ + group_by: ["selected"], + group_by_label: [], + aggregation: "avg", + }) + expect(buildAgentDataPayload(postAggregations)).toEqual( + expect.objectContaining({ + "group_by[1]": "selected", + "group_by_label[1]": "", + "aggregation[1]": "avg", + }) + ) + }) + + it("adds explicit data-query attributes to both transports without duplicating unaligned", () => { + const explicitDataQueryAttributes = { + ...attributes, + dimensionsScope: ["user", "system"], + timeGroupOptions: "95", + tier: 0, + limit: 50_000, + timeout: 180_000, + unaligned: true, + options: [...attributes.options, "unaligned"], + } + + const cloud = buildCloudDataPayload(explicitDataQueryAttributes) + expect(cloud.aggregations.time.time_group_options).toBe("95") + expect(cloud.scope.dimensions).toEqual(["user", "system"]) + expect(cloud.window.tier).toBe(0) + expect(cloud.limit).toBe(50_000) + expect(cloud.timeout).toBe(180_000) + expect(cloud.options.filter(option => option === "unaligned")).toHaveLength(1) + + const agent = buildAgentDataPayload(explicitDataQueryAttributes) + expect(agent.time_group_options).toBe("95") + expect(agent.scope_dimensions).toBe("user|system") + expect(agent.tier).toBe(0) + expect(agent.limit).toBe(50_000) + expect(agent.timeout).toBe(180_000) + expect(agent.options.split("|").filter(option => option === "unaligned")).toHaveLength(1) + }) + + it("builds POST and GET requests from the same attributes", () => { + const cloud = buildDataRequest({ ...attributes, agent: false }) + expect(cloud.url).toBe("https://example.test/data") + expect(cloud.options.method).toBe("POST") + expect(JSON.parse(cloud.options.body)).toEqual(buildCloudDataPayload(attributes)) + + const agent = buildDataRequest({ ...attributes, agent: true }) + expect(agent.url).toContain("https://example.test/data?") + const query = new URL(agent.url).searchParams + expect(query.get("nodes")).toBe("node-1|node-2") + expect(query.get("group_by[0]")).toBe("node") + }) + + it("uses the same bearer precedence as rendered charts", () => { + expect( + withDataRequestAuth( + { bearer: "cloud-token", xNetdataBearer: "agent-token" }, + { signal: "signal" } + ) + ).toEqual({ + signal: "signal", + headers: { Authorization: "Bearer cloud-token" }, + }) + + expect(withDataRequestAuth({ xNetdataBearer: "agent-token" })).toEqual({ + headers: { "X-Netdata-Auth": "Bearer agent-token" }, + }) + + const options = { signal: "signal" } + expect(withDataRequestAuth({}, options)).toBe(options) + }) +}) diff --git a/src/sdk/dataQuery/response.js b/src/sdk/dataQuery/response.js new file mode 100644 index 000000000..41b1ab7de --- /dev/null +++ b/src/sdk/dataQuery/response.js @@ -0,0 +1,470 @@ +import { isRateUnit, stripRateUnit } from "@/helpers/units" +import { getPointValue } from "../makeChart/getPointValue" + +export const dataQueryNodeStatus = Object.freeze({ + unknown: 0, + fresh: 1, + gap: 2, + failure: 3, + unavailable: 4, +}) + +export const dataQueryResultStatus = Object.freeze({ + complete: "complete", + incomplete: "incomplete", + unsupported: "unsupported", +}) + +export const dataQueryTierCoverageStatus = Object.freeze({ + exact: "exact", + partial: "partial", + unavailable: "unavailable", +}) + +const addIssue = (issues, code, details = {}) => issues.push({ code, ...details }) + +const makeExpectedIndex = (expectedNodeIds, issues) => { + const ordinalById = new Map() + + expectedNodeIds.forEach((id, ordinal) => { + if (typeof id !== "string" || !id) { + addIssue(issues, "invalid-expected-node-id", { ordinal }) + return + } + + if (ordinalById.has(id)) { + addIssue(issues, "duplicate-expected-node-id", { id }) + return + } + + ordinalById.set(id, ordinal) + }) + + return ordinalById +} + +const getSummaryNodeOrdinals = (node, ordinalById) => { + const nodeIdOrdinal = ordinalById.get(node?.nd) + const machineGuidOrdinal = ordinalById.get(node?.mg) + + return { + ambiguous: + nodeIdOrdinal !== undefined && + machineGuidOrdinal !== undefined && + nodeIdOrdinal !== machineGuidOrdinal, + machineGuidOrdinal, + nodeIdOrdinal, + ordinal: nodeIdOrdinal ?? machineGuidOrdinal, + } +} + +const toSummaryInteger = value => { + if (value == null) return { valid: true, value: 0 } + if (typeof value !== "number" && typeof value !== "string") return { valid: false, value: 0 } + if (typeof value === "string" && !value.trim()) return { valid: false, value: 0 } + + const number = Number(value) + return Number.isInteger(number) && number >= 0 + ? { valid: true, value: number } + : { valid: false, value: 0 } +} + +const getSummaryNodeStatus = node => { + const fields = [ + ["st.code", toSummaryInteger(node?.st?.code)], + ["is.fl", toSummaryInteger(node?.is?.fl)], + ["ds.fl", toSummaryInteger(node?.ds?.fl)], + ["ds.sl", toSummaryInteger(node?.ds?.sl)], + ["ds.qr", toSummaryInteger(node?.ds?.qr)], + ] + const invalidFields = fields.filter(([, field]) => !field.valid).map(([name]) => name) + if (invalidFields.length) return { invalidFields, status: dataQueryNodeStatus.unknown } + + const [[, code], [, failedInstances], [, failedDimensions]] = fields + + if (code.value === 401 || code.value === 403) + return { invalidFields, status: dataQueryNodeStatus.unavailable } + if ((code.value >= 400 && code.value <= 599) || failedInstances.value || failedDimensions.value) + return { invalidFields, status: dataQueryNodeStatus.failure } + + return { invalidFields, status: null } +} + +const indexSummaryAliases = ({ payload, ordinalById, nodeStatuses, issues }) => { + const ordinalByAlias = new Map() + const summaryByOrdinal = new Array(ordinalById.size) + const summaryNodes = Array.isArray(payload?.summary?.nodes) ? payload.summary.nodes : [] + + summaryNodes.forEach(node => { + const { ambiguous, machineGuidOrdinal, nodeIdOrdinal, ordinal } = getSummaryNodeOrdinals( + node, + ordinalById + ) + if (ambiguous) { + addIssue(issues, "ambiguous-summary-node", { machineGuid: node?.mg, nodeId: node?.nd }) + nodeStatuses[machineGuidOrdinal] = dataQueryNodeStatus.unknown + nodeStatuses[nodeIdOrdinal] = dataQueryNodeStatus.unknown + return + } + if (ordinal === undefined) { + addIssue(issues, "unexpected-summary-node", { machineGuid: node?.mg, nodeId: node?.nd }) + return + } + if (summaryByOrdinal[ordinal] !== undefined) { + addIssue(issues, "duplicate-summary-node", { ordinal }) + nodeStatuses[ordinal] = dataQueryNodeStatus.unknown + return + } + + summaryByOrdinal[ordinal] = node + + const aliases = [node?.mg, node?.nd].filter(Boolean) + aliases.forEach(alias => { + const previousOrdinal = ordinalByAlias.get(alias) + if (previousOrdinal !== undefined && previousOrdinal !== ordinal) { + addIssue(issues, "duplicate-summary-alias", { alias }) + return + } + ordinalByAlias.set(alias, ordinal) + }) + + const { invalidFields, status } = getSummaryNodeStatus(node) + if (invalidFields.length) + addIssue(issues, "invalid-summary-node-status", { ordinal, fields: invalidFields }) + if (status !== null) nodeStatuses[ordinal] = status + }) + + return { ordinalByAlias, summaryByOrdinal } +} + +const resolveResultOrdinal = (label, ordinalById, ordinalByAlias) => + ordinalById.get(label) ?? ordinalByAlias.get(label) + +const getResultShape = (payload, issues) => { + const { result } = payload || {} + if (!result || !Array.isArray(result.labels) || !Array.isArray(result.data)) { + addIssue(issues, "unsupported-result-shape") + return null + } + if (!result.labels.length) { + if (result.data.length) addIssue(issues, "unsupported-result-labels") + return { result, row: null } + } + if (result.labels[0] !== "time") { + addIssue(issues, "unsupported-result-labels") + return null + } + if (result.data.length > 1) + addIssue(issues, "unexpected-point-count", { points: result.data.length }) + + const row = result.data[0] + if (result.data.length && (!Array.isArray(row) || row.length !== result.labels.length)) + addIssue(issues, "misaligned-result-row", { + labels: result.labels.length, + values: Array.isArray(row) ? row.length : null, + }) + if (Array.isArray(row) && (typeof row[0] !== "number" || !isFinite(row[0]))) + addIssue(issues, "invalid-result-timestamp") + + return { result, row: Array.isArray(row) ? row : null } +} + +const getViewNodeIds = (payload, shape, issues) => { + if (!shape) return null + + const ids = payload?.view?.dimensions?.ids + if (ids == null) return null + if (!Array.isArray(ids)) { + addIssue(issues, "invalid-view-node-identities") + return null + } + + const expectedLength = Math.max(0, shape.result.labels.length - 1) + if (ids.length !== expectedLength) + addIssue(issues, "misaligned-view-node-identities", { + identities: ids.length, + labels: expectedLength, + }) + + const seen = new Set() + ids.forEach((id, offset) => { + if (typeof id !== "string" || !id) { + addIssue(issues, "invalid-view-node-identity", { offset }) + return + } + if (seen.has(id)) addIssue(issues, "duplicate-view-node-identity", { id }) + else seen.add(id) + }) + + return ids +} + +const resolveIdentityOrdinal = (identity, ordinalById, ordinalByAlias) => + typeof identity === "string" + ? resolveResultOrdinal(identity, ordinalById, ordinalByAlias) + : undefined + +const applyResultValues = ({ + shape, + ordinalById, + ordinalByAlias, + viewNodeIds, + resultIndexes, + nodeStatuses, + issues, +}) => { + if (!shape) return + + const { result, row } = shape + const resultOrdinals = new Set() + + result.labels.slice(1).forEach((label, offset) => { + const resultIndex = offset + 1 + const viewNodeId = viewNodeIds?.[offset] + if (label === "OTHERS" || viewNodeId === "OTHERS") { + addIssue(issues, "others-result") + return + } + + const labelOrdinal = resolveIdentityOrdinal(label, ordinalById, ordinalByAlias) + const viewOrdinal = resolveIdentityOrdinal(viewNodeId, ordinalById, ordinalByAlias) + if (viewNodeIds !== null && viewOrdinal === undefined) { + addIssue(issues, "unexpected-result-node", { label, viewNodeId }) + return + } + if (labelOrdinal !== undefined && viewOrdinal !== undefined && labelOrdinal !== viewOrdinal) { + addIssue(issues, "conflicting-result-node-identities", { label, viewNodeId }) + return + } + + const ordinal = viewNodeIds === null ? labelOrdinal : viewOrdinal + if (ordinal === undefined) { + addIssue(issues, "unexpected-result-node", { label, viewNodeId }) + return + } + if (resultOrdinals.has(ordinal)) { + addIssue(issues, "duplicate-result-node", { label }) + return + } + + resultOrdinals.add(ordinal) + resultIndexes[ordinal] = resultIndex + if (!row) return + + const value = getPointValue(row[resultIndex], result.point) + if (value === null) { + // Keep a more specific summary or identity status; nodes start as gaps. + } else if (typeof value === "number" && isFinite(value)) { + const currentStatus = nodeStatuses[ordinal] + if ( + currentStatus === dataQueryNodeStatus.failure || + currentStatus === dataQueryNodeStatus.unavailable + ) + addIssue(issues, "value-for-failed-node", { label }) + else if (currentStatus !== dataQueryNodeStatus.unknown) + nodeStatuses[ordinal] = dataQueryNodeStatus.fresh + } else { + if (nodeStatuses[ordinal] === dataQueryNodeStatus.gap) + nodeStatuses[ordinal] = dataQueryNodeStatus.unavailable + addIssue(issues, "invalid-result-value", { label }) + } + }) +} + +const getMissingNodeIds = ({ nodeIds, resultIndexes, nodeStatuses, summaryByOrdinal, issues }) => { + const missingNodeIds = [] + const missingSelectedNodeIds = [] + + nodeIds.forEach((nodeId, ordinal) => { + if (resultIndexes[ordinal] !== -1) return + missingNodeIds.push(nodeId) + + const status = nodeStatuses[ordinal] + if ( + status === dataQueryNodeStatus.failure || + status === dataQueryNodeStatus.unavailable || + status === dataQueryNodeStatus.unknown + ) + return + + const summary = summaryByOrdinal[ordinal] + if (toSummaryInteger(summary?.ds?.sl).value > 0 || toSummaryInteger(summary?.ds?.qr).value > 0) + missingSelectedNodeIds.push(nodeId) + }) + + if (missingSelectedNodeIds.length) + addIssue(issues, "missing-selected-result-nodes", { + count: missingSelectedNodeIds.length, + }) + + return { missingNodeIds, missingSelectedNodeIds } +} + +const getResultStatus = issues => { + if (issues.some(({ code }) => code.startsWith("unsupported-"))) + return dataQueryResultStatus.unsupported + if (issues.length) return dataQueryResultStatus.incomplete + + return dataQueryResultStatus.complete +} + +export const validateDataQueryResponse = (payload, expectedNodeIds = []) => { + const issues = [] + const nodeIds = Array.isArray(expectedNodeIds) ? [...expectedNodeIds] : [] + if (!Array.isArray(expectedNodeIds)) addIssue(issues, "unsupported-expected-node-ids") + + const ordinalById = makeExpectedIndex(nodeIds, issues) + const resultIndexes = new Int32Array(nodeIds.length) + resultIndexes.fill(-1) + const nodeStatuses = new Uint8Array(nodeIds.length) + nodeStatuses.fill(dataQueryNodeStatus.gap) + const { ordinalByAlias, summaryByOrdinal } = indexSummaryAliases({ + payload, + ordinalById, + nodeStatuses, + issues, + }) + const shape = getResultShape(payload, issues) + const viewNodeIds = getViewNodeIds(payload, shape, issues) + + applyResultValues({ + shape, + ordinalById, + ordinalByAlias, + viewNodeIds, + resultIndexes, + nodeStatuses, + issues, + }) + + const { missingNodeIds, missingSelectedNodeIds } = getMissingNodeIds({ + nodeIds, + resultIndexes, + nodeStatuses, + summaryByOrdinal, + issues, + }) + const status = getResultStatus(issues) + + return { + status, + complete: status === dataQueryResultStatus.complete, + nodeIds, + resultIndexes, + nodeStatuses, + missingNodeIds, + missingSelectedNodeIds, + issues, + } +} + +const getTierValue = (value, snakeCase, camelCase = snakeCase) => { + const raw = value?.[snakeCase] ?? value?.[camelCase] + return raw == null ? Number.NaN : Number(raw) +} + +const makeTierCoverage = (status, reason) => ({ + exact: status === dataQueryTierCoverageStatus.exact, + reason, + status, +}) + +export const validateDataQueryTierCoverage = ( + payload, + { after, before, expectedNodeIds = [], tier = 0 } = {} +) => { + if ( + !Number.isFinite(after) || + !Number.isFinite(before) || + before <= after || + !Number.isInteger(tier) || + tier < 0 + ) + return makeTierCoverage(dataQueryTierCoverageStatus.unavailable, "invalid-tier-window") + + if (!Array.isArray(expectedNodeIds) || expectedNodeIds.length !== 1) + return makeTierCoverage( + dataQueryTierCoverageStatus.unavailable, + "per-node-tier-coverage-unavailable" + ) + + const aggregated = payload?.view?.dimensions?.aggregated + if (!Array.isArray(aggregated) || aggregated.length !== 1 || aggregated[0] !== 1) + return makeTierCoverage( + dataQueryTierCoverageStatus.unavailable, + "source-metric-tier-coverage-unavailable" + ) + + const perTier = payload?.db?.per_tier ?? payload?.db?.perTier + if (!Array.isArray(perTier)) + return makeTierCoverage(dataQueryTierCoverageStatus.unavailable, "missing-tier-metadata") + + const selected = perTier.filter(value => getTierValue(value, "tier") === tier) + if (selected.length !== 1) + return makeTierCoverage(dataQueryTierCoverageStatus.unavailable, "missing-tier-metadata") + + if ( + perTier.some(value => getTierValue(value, "tier") !== tier && getTierValue(value, "points") > 0) + ) + return makeTierCoverage(dataQueryTierCoverageStatus.partial, "unexpected-tier-data") + + const metadata = selected[0] + const queries = getTierValue(metadata, "queries") + const updateEvery = getTierValue(metadata, "update_every", "updateEvery") + const firstEntry = getTierValue(metadata, "first_entry", "firstEntry") + const lastEntry = getTierValue(metadata, "last_entry", "lastEntry") + if ( + queries !== 1 || + !Number.isFinite(updateEvery) || + updateEvery <= 0 || + !Number.isFinite(firstEntry) || + firstEntry <= 0 || + !Number.isFinite(lastEntry) || + lastEntry <= 0 + ) + return makeTierCoverage(dataQueryTierCoverageStatus.unavailable, "invalid-tier-metadata") + + if (firstEntry > after + updateEvery) + return makeTierCoverage(dataQueryTierCoverageStatus.partial, "tier-retention-start") + if (lastEntry < before - updateEvery) + return makeTierCoverage(dataQueryTierCoverageStatus.partial, "tier-retention-end") + + return makeTierCoverage(dataQueryTierCoverageStatus.exact) +} + +const getSourceUnits = payload => { + const dimensionUnits = payload?.view?.dimensions?.units + if (Array.isArray(dimensionUnits) && dimensionUnits.length) return [...dimensionUnits] + + const units = payload?.view?.units + if (Array.isArray(units)) return [...units] + if (typeof units === "string") return [units] + + return [] +} + +export const normalizeDataQueryUnits = (payload, { rateVolume = false, timeGroup } = {}) => { + const sourceUnits = getSourceUnits(payload) + if (!rateVolume) + return { available: true, status: "source", sourceUnits, units: [...sourceUnits] } + + if (timeGroup !== "sum") + return { available: false, status: "unavailable", sourceUnits, units: [] } + + if (!sourceUnits.length || sourceUnits.some(unit => typeof unit !== "string" || !unit)) + return { available: false, status: "unavailable", sourceUnits, units: [] } + + const rateUnits = sourceUnits.filter(isRateUnit) + if (rateUnits.length === sourceUnits.length) { + const units = sourceUnits.map(stripRateUnit) + if (units.some(unit => !unit)) + return { available: false, status: "unavailable", sourceUnits, units: [] } + + return { available: true, status: "normalized", sourceUnits, units } + } + + if (!rateUnits.length) + return { available: true, status: "source", sourceUnits, units: [...sourceUnits] } + + return { available: false, status: "unavailable", sourceUnits, units: [] } +} diff --git a/src/sdk/dataQuery/response.test.js b/src/sdk/dataQuery/response.test.js new file mode 100644 index 000000000..9241da33b --- /dev/null +++ b/src/sdk/dataQuery/response.test.js @@ -0,0 +1,615 @@ +import { + dataQueryNodeStatus, + dataQueryTierCoverageStatus, + normalizeDataQueryUnits, + validateDataQueryResponse, + validateDataQueryTierCoverage, +} from "./response" + +const makePayload = ({ + labels, + values, + nodes, + data, + point = { value: 0 }, + units, + ids, + names, + groupedBy, +} = {}) => ({ + summary: { nodes: nodes || [] }, + view: { + dimensions: { + units: units || [], + ...(ids && { ids }), + ...(names && { names }), + ...(groupedBy && { grouped_by: groupedBy }), + }, + }, + result: { + labels: labels || ["time"], + data: data || (values ? [[1000, ...values]] : []), + point, + }, +}) + +describe("data query response validation", () => { + it("maps machine GUID labels, preserves zero and negatives, and records absent nodes as gaps", () => { + const payload = makePayload({ + labels: ["time", "machine-1", "machine-2"], + values: [0, -2], + nodes: [ + { mg: "machine-1", nd: "node-1", st: { code: 200 }, ds: { qr: 1 } }, + { mg: "machine-2", nd: "node-2", st: { code: 200 }, ds: { qr: 1 } }, + ], + }) + + const result = validateDataQueryResponse(payload, ["node-1", "node-2", "node-3"]) + + expect(result.complete).toBe(true) + expect(Array.from(result.resultIndexes)).toEqual([1, 2, -1]) + expect(Array.from(result.nodeStatuses)).toEqual([ + dataQueryNodeStatus.fresh, + dataQueryNodeStatus.fresh, + dataQueryNodeStatus.gap, + ]) + expect(result.missingNodeIds).toEqual(["node-3"]) + expect(result.issues).toEqual([]) + }) + + it("uses ordinal-aligned view IDs for released name-labelled responses", () => { + const payload = makePayload({ + labels: ["time", "duplicate-name", "duplicate-name"], + ids: ["machine-1", "machine-2"], + names: ["duplicate-name", "duplicate-name"], + groupedBy: ["node"], + values: [0, -2], + nodes: [ + { mg: "machine-1", nd: "node-1", nm: "duplicate-name", ds: { sl: 1, qr: 1 } }, + { mg: "machine-2", nd: "node-2", nm: "duplicate-name", ds: { sl: 1, qr: 1 } }, + ], + }) + + const result = validateDataQueryResponse(payload, ["node-1", "node-2", "node-3"]) + + expect(result.complete).toBe(true) + expect(Array.from(result.resultIndexes)).toEqual([1, 2, -1]) + expect(Array.from(result.nodeStatuses)).toEqual([ + dataQueryNodeStatus.fresh, + dataQueryNodeStatus.fresh, + dataQueryNodeStatus.gap, + ]) + expect(result.missingNodeIds).toEqual(["node-3"]) + expect(result.issues).toEqual([]) + }) + + it("rejects conflicting direct and ordinal-aligned node identities", () => { + const payload = makePayload({ + labels: ["time", "node-1"], + ids: ["machine-2"], + values: [5], + nodes: [ + { mg: "machine-1", nd: "node-1" }, + { mg: "machine-2", nd: "node-2" }, + ], + }) + + const result = validateDataQueryResponse(payload, ["node-1", "node-2"]) + + expect(result.status).toBe("incomplete") + expect(result.issues).toContainEqual( + expect.objectContaining({ code: "conflicting-result-node-identities" }) + ) + }) + + it("rejects an unresolved canonical view identity instead of falling back to its label", () => { + const payload = makePayload({ + labels: ["time", "node-1"], + ids: ["unexpected-node"], + values: [5], + }) + + const result = validateDataQueryResponse(payload, ["node-1"]) + + expect(result.status).toBe("incomplete") + expect(Array.from(result.resultIndexes)).toEqual([-1]) + expect(result.issues).toContainEqual( + expect.objectContaining({ + code: "unexpected-result-node", + label: "node-1", + viewNodeId: "unexpected-node", + }) + ) + }) + + it("rejects malformed or misaligned view identity metadata", () => { + const invalid = makePayload({ + labels: ["time", "node-1"], + ids: ["node-1", "node-2"], + values: [5], + }) + const duplicate = makePayload({ + labels: ["time", "name-1", "name-2"], + ids: ["node-1", "node-1"], + values: [5, 6], + }) + + expect(validateDataQueryResponse(invalid, ["node-1"]).issues).toContainEqual( + expect.objectContaining({ code: "misaligned-view-node-identities" }) + ) + expect(validateDataQueryResponse(duplicate, ["node-1", "node-2"]).issues).toContainEqual( + expect.objectContaining({ code: "duplicate-view-node-identity" }) + ) + }) + + it("keeps a selected and successfully queried node with no result incomplete", () => { + const payload = makePayload({ + labels: ["time", "machine-1"], + ids: ["machine-1"], + values: [5], + nodes: [ + { mg: "machine-1", nd: "node-1", ds: { sl: 1, qr: 1 } }, + { mg: "machine-2", nd: "node-2", ds: { sl: 1, qr: 1 } }, + ], + }) + + const result = validateDataQueryResponse(payload, ["node-1", "node-2"]) + + expect(result.status).toBe("incomplete") + expect(result.missingSelectedNodeIds).toEqual(["node-2"]) + expect(result.issues).toContainEqual( + expect.objectContaining({ code: "missing-selected-result-nodes", count: 1 }) + ) + }) + + it("accepts the released empty JSON2 result as complete gaps", () => { + const payload = makePayload({ labels: [], data: [], ids: [] }) + + const result = validateDataQueryResponse(payload, ["node-1", "node-2"]) + + expect(result.complete).toBe(true) + expect(Array.from(result.nodeStatuses)).toEqual([ + dataQueryNodeStatus.gap, + dataQueryNodeStatus.gap, + ]) + expect(result.missingNodeIds).toEqual(["node-1", "node-2"]) + }) + + it("reads compact JSON2 values without expanding the response", () => { + const payload = makePayload({ + labels: ["time", "node-1", "node-2"], + values: [[5, 20], null], + point: { value: 0, arp: 1 }, + }) + + const result = validateDataQueryResponse(payload, ["node-1", "node-2"]) + + expect(result.complete).toBe(true) + expect(Array.from(result.nodeStatuses)).toEqual([ + dataQueryNodeStatus.fresh, + dataQueryNodeStatus.gap, + ]) + expect(result.payload).toBeUndefined() + }) + + it("publishes query failures and authorization failures alongside absent-node gaps", () => { + const payload = makePayload({ + labels: ["time", "machine-1", "machine-2"], + values: [null, null], + nodes: [ + { mg: "machine-1", nd: "node-1", st: { code: "504" }, ds: { fl: "1", qr: "0" } }, + { mg: "machine-2", nd: "node-2", st: { code: "403" } }, + ], + }) + + const result = validateDataQueryResponse(payload, ["node-1", "node-2", "node-3"]) + + expect(result.complete).toBe(true) + expect(Array.from(result.nodeStatuses)).toEqual([ + dataQueryNodeStatus.failure, + dataQueryNodeStatus.unavailable, + dataQueryNodeStatus.gap, + ]) + expect(result.missingNodeIds).toEqual(["node-3"]) + expect(result.issues).toEqual([]) + }) + + it("rejects malformed summary status fields instead of classifying them as gaps", () => { + const payload = makePayload({ + nodes: [ + { mg: "machine-1", nd: "node-1", st: { code: {} } }, + { mg: "machine-2", nd: "node-2", ds: { fl: "not-a-number" } }, + { mg: "machine-3", nd: "node-3", ds: { sl: -1 } }, + { mg: "machine-4", nd: "node-4", ds: { qr: 1.5 } }, + ], + }) + + const result = validateDataQueryResponse(payload, ["node-1", "node-2", "node-3", "node-4"]) + + expect(result.status).toBe("incomplete") + expect(Array.from(result.nodeStatuses)).toEqual([ + dataQueryNodeStatus.unknown, + dataQueryNodeStatus.unknown, + dataQueryNodeStatus.unknown, + dataQueryNodeStatus.unknown, + ]) + expect(result.issues).toEqual([ + { code: "invalid-summary-node-status", ordinal: 0, fields: ["st.code"] }, + { code: "invalid-summary-node-status", ordinal: 1, fields: ["ds.fl"] }, + { code: "invalid-summary-node-status", ordinal: 2, fields: ["ds.sl"] }, + { code: "invalid-summary-node-status", ordinal: 3, fields: ["ds.qr"] }, + ]) + }) + + it("accepts a missing result when the summary provides an explicit node failure", () => { + const payload = makePayload({ + labels: ["time", "machine-1"], + ids: ["machine-1"], + values: [5], + nodes: [ + { mg: "machine-1", nd: "node-1", st: { code: 200 }, ds: { sl: 1, qr: 1 } }, + { mg: "machine-2", nd: "node-2", st: { code: 504 }, ds: { sl: 1, fl: 1 } }, + ], + }) + + const result = validateDataQueryResponse(payload, ["node-1", "node-2"]) + + expect(result.complete).toBe(true) + expect(Array.from(result.nodeStatuses)).toEqual([ + dataQueryNodeStatus.fresh, + dataQueryNodeStatus.failure, + ]) + expect(result.missingNodeIds).toEqual(["node-2"]) + expect(result.issues).toEqual([]) + }) + + it("keeps backend failure states authoritative when inconsistent values are present", () => { + const payload = makePayload({ + labels: ["time", "machine-1", "machine-2", "machine-3"], + values: [1, 2, 3], + nodes: [ + { mg: "machine-1", nd: "node-1", st: { code: 504 } }, + { mg: "machine-2", nd: "node-2", st: { code: 200 }, is: { fl: "1" } }, + { mg: "machine-3", nd: "node-3", st: { code: 200 }, ds: { fl: "1", qr: "1" } }, + ], + }) + + const result = validateDataQueryResponse(payload, ["node-1", "node-2", "node-3"]) + + expect(result.status).toBe("incomplete") + expect(Array.from(result.nodeStatuses)).toEqual([ + dataQueryNodeStatus.failure, + dataQueryNodeStatus.failure, + dataQueryNodeStatus.failure, + ]) + expect(result.issues.filter(({ code }) => code === "value-for-failed-node")).toHaveLength(3) + }) + + it("keeps ambiguous summary identities unknown and marks the result incomplete", () => { + const payload = makePayload({ + nodes: [{ mg: "node-1", nd: "node-2", st: { code: 403 } }], + }) + + const result = validateDataQueryResponse(payload, ["node-1", "node-2"]) + + expect(result.status).toBe("incomplete") + expect(Array.from(result.nodeStatuses)).toEqual([ + dataQueryNodeStatus.unknown, + dataQueryNodeStatus.unknown, + ]) + expect(result.issues).toContainEqual( + expect.objectContaining({ code: "ambiguous-summary-node" }) + ) + }) + + it("does not let result values erase ambiguous identity states", () => { + const payload = makePayload({ + labels: ["time", "node-1", "node-2"], + values: [1, 2], + nodes: [{ mg: "node-1", nd: "node-2", st: { code: 200 } }], + }) + + const result = validateDataQueryResponse(payload, ["node-1", "node-2"]) + + expect(result.status).toBe("incomplete") + expect(Array.from(result.nodeStatuses)).toEqual([ + dataQueryNodeStatus.unknown, + dataQueryNodeStatus.unknown, + ]) + }) + + it("treats explicit empty data for all identified nodes as complete gaps", () => { + const payload = makePayload({ labels: ["time", "node-1", "node-2"], data: [] }) + + const result = validateDataQueryResponse(payload, ["node-1", "node-2"]) + + expect(result.complete).toBe(true) + expect(Array.from(result.nodeStatuses)).toEqual([ + dataQueryNodeStatus.gap, + dataQueryNodeStatus.gap, + ]) + expect(result.missingNodeIds).toEqual([]) + }) + + it("marks summary identities outside the captured node set incomplete", () => { + const payload = makePayload({ + labels: ["time", "node-1"], + values: [1], + nodes: [{ mg: "unexpected-machine", nd: "unexpected-node", st: { code: 504 } }], + }) + + const result = validateDataQueryResponse(payload, ["node-1"]) + + expect(result.status).toBe("incomplete") + expect(Array.from(result.nodeStatuses)).toEqual([dataQueryNodeStatus.fresh]) + expect(result.issues).toContainEqual( + expect.objectContaining({ code: "unexpected-summary-node" }) + ) + }) + + it.each([ + ["OTHERS", "others-result"], + ["unexpected-node", "unexpected-result-node"], + ])("marks %s labels incomplete", (label, issue) => { + const payload = makePayload({ labels: ["time", label], values: [1] }) + const result = validateDataQueryResponse(payload, ["node-1"]) + + expect(result.status).toBe("incomplete") + expect(result.issues).toContainEqual(expect.objectContaining({ code: issue })) + }) + + it("rejects OTHERS when only the canonical view identity exposes it", () => { + const payload = makePayload({ labels: ["time", "Remaining"], ids: ["OTHERS"], values: [1] }) + const result = validateDataQueryResponse(payload, ["node-1"]) + + expect(result.status).toBe("incomplete") + expect(result.issues).toContainEqual(expect.objectContaining({ code: "others-result" })) + }) + + it("marks aliases for the same captured node incomplete", () => { + const payload = makePayload({ + labels: ["time", "machine-1", "node-1"], + values: [1, 2], + nodes: [{ mg: "machine-1", nd: "node-1", st: { code: 200 } }], + }) + const result = validateDataQueryResponse(payload, ["node-1"]) + + expect(result.status).toBe("incomplete") + expect(result.issues).toContainEqual(expect.objectContaining({ code: "duplicate-result-node" })) + }) + + it("accepts one summary node with machine and node aliases", () => { + const payload = makePayload({ + labels: ["time", "machine-1"], + values: [5], + nodes: [{ mg: "machine-1", nd: "node-1", st: { code: 200 } }], + }) + + const result = validateDataQueryResponse(payload, ["node-1"]) + + expect(result.complete).toBe(true) + expect(Array.from(result.nodeStatuses)).toEqual([dataQueryNodeStatus.fresh]) + expect(result.issues).toEqual([]) + }) + + it("marks duplicate summary entries for one captured node incomplete", () => { + const summaryNode = { mg: "machine-1", nd: "node-1", st: { code: 200 } } + const payload = makePayload({ + labels: ["time", "machine-1"], + values: [5], + nodes: [summaryNode, { ...summaryNode }], + }) + + const result = validateDataQueryResponse(payload, ["node-1"]) + + expect(result.status).toBe("incomplete") + expect(Array.from(result.nodeStatuses)).toEqual([dataQueryNodeStatus.unknown]) + expect(result.issues).toContainEqual({ code: "duplicate-summary-node", ordinal: 0 }) + }) + + it("marks duplicate summary aliases incomplete", () => { + const payload = makePayload({ + labels: ["time", "node-1", "node-2"], + values: [1, 2], + nodes: [ + { mg: "shared-machine", nd: "node-1", st: { code: 200 } }, + { mg: "shared-machine", nd: "node-2", st: { code: 200 } }, + ], + }) + + const result = validateDataQueryResponse(payload, ["node-1", "node-2"]) + + expect(result.status).toBe("incomplete") + expect(result.issues).toContainEqual( + expect.objectContaining({ code: "duplicate-summary-alias", alias: "shared-machine" }) + ) + }) + + it("marks non-numeric result values unavailable and incomplete", () => { + const payload = makePayload({ labels: ["time", "node-1"], values: ["invalid"] }) + + const result = validateDataQueryResponse(payload, ["node-1"]) + + expect(result.status).toBe("incomplete") + expect(Array.from(result.nodeStatuses)).toEqual([dataQueryNodeStatus.unavailable]) + expect(result.issues).toContainEqual(expect.objectContaining({ code: "invalid-result-value" })) + }) + + it("rejects multi-point and structurally unsupported responses", () => { + const multiPoint = makePayload({ + labels: ["time", "node-1"], + data: [ + [1000, 1], + [2000, 2], + ], + }) + expect(validateDataQueryResponse(multiPoint, ["node-1"]).status).toBe("incomplete") + expect( + validateDataQueryResponse(makePayload({ labels: ["time", "node-1"], data: [null] }), [ + "node-1", + ]).status + ).toBe("incomplete") + expect(validateDataQueryResponse({}, ["node-1"]).status).toBe("unsupported") + }) + + it("marks a result with an invalid timestamp incomplete", () => { + const payload = makePayload({ labels: ["time", "node-1"], data: [[null, 1]] }) + + const result = validateDataQueryResponse(payload, ["node-1"]) + + expect(result.status).toBe("incomplete") + expect(result.issues).toContainEqual( + expect.objectContaining({ code: "invalid-result-timestamp" }) + ) + }) + + it("validates 50,000 name-labelled members through compact ordinal identity arrays", () => { + const count = 50_000 + const nodeIds = Array.from({ length: count }, (_, ordinal) => `node-${ordinal}`) + const payload = makePayload({ + labels: ["time", ...Array(count).fill("duplicate-name")], + ids: nodeIds, + values: Array.from({ length: count }, (_, ordinal) => ordinal), + }) + + const result = validateDataQueryResponse(payload, nodeIds) + + expect(result.complete).toBe(true) + expect(result.nodeStatuses).toBeInstanceOf(Uint8Array) + expect(result.resultIndexes).toBeInstanceOf(Int32Array) + expect(result.nodeStatuses).toHaveLength(count) + expect(result.resultIndexes[count - 1]).toBe(count) + expect(result.missingNodeIds).toHaveLength(0) + }) +}) + +describe("data query tier coverage validation", () => { + const makeTierPayload = ({ aggregated = [1], perTier } = {}) => ({ + ...makePayload({ labels: ["time", "node-1"], values: [5] }), + db: { + per_tier: perTier || [ + { + tier: 0, + queries: 1, + points: 301, + update_every: 1, + first_entry: 900, + last_entry: 2000, + }, + { + tier: 1, + queries: 0, + points: 0, + update_every: 60, + first_entry: 100, + last_entry: 1980, + }, + ], + }, + view: { dimensions: { aggregated, units: ["percentage"] } }, + }) + const options = { after: 1000, before: 2000, expectedNodeIds: ["node-1"], tier: 0 } + + it("accepts one source metric with complete selected-tier retention", () => { + expect(validateDataQueryTierCoverage(makeTierPayload(), options)).toEqual({ + exact: true, + reason: undefined, + status: dataQueryTierCoverageStatus.exact, + }) + }) + + it.each([ + ["start", { first_entry: 1002, last_entry: 2000 }, "tier-retention-start"], + ["end", { first_entry: 900, last_entry: 1998 }, "tier-retention-end"], + ])("reports partial %s retention", (_boundary, tierOverrides, reason) => { + const payload = makeTierPayload() + payload.db.per_tier[0] = { ...payload.db.per_tier[0], ...tierOverrides } + + expect(validateDataQueryTierCoverage(payload, options)).toMatchObject({ + exact: false, + reason, + status: dataQueryTierCoverageStatus.partial, + }) + }) + + it("rejects lower-tier points instead of treating them as exact", () => { + const payload = makeTierPayload() + payload.db.per_tier[1].points = 1 + + expect(validateDataQueryTierCoverage(payload, options)).toMatchObject({ + exact: false, + reason: "unexpected-tier-data", + status: dataQueryTierCoverageStatus.partial, + }) + }) + + it.each([ + ["multiple nodes", makeTierPayload(), { ...options, expectedNodeIds: ["node-1", "node-2"] }], + ["multiple source metrics", makeTierPayload({ aggregated: [2] }), options], + ["string source metric count", makeTierPayload({ aggregated: ["1"] }), options], + ["boolean source metric count", makeTierPayload({ aggregated: [true] }), options], + ["missing metadata", { ...makeTierPayload(), db: {} }, options], + ])("keeps %s explicitly unavailable", (_name, payload, validationOptions) => { + expect(validateDataQueryTierCoverage(payload, validationOptions)).toMatchObject({ + exact: false, + status: dataQueryTierCoverageStatus.unavailable, + }) + }) + + it("does not interpret null tier metadata as tier zero", () => { + const payload = makeTierPayload() + payload.db.per_tier[0].tier = null + + expect(validateDataQueryTierCoverage(payload, options)).toMatchObject({ + exact: false, + reason: "missing-tier-metadata", + status: dataQueryTierCoverageStatus.unavailable, + }) + }) + + it("requires one query and a valid ordered window", () => { + const payload = makeTierPayload() + payload.db.per_tier[0].queries = 2 + expect(validateDataQueryTierCoverage(payload, options).reason).toBe("invalid-tier-metadata") + expect( + validateDataQueryTierCoverage(makeTierPayload(), { ...options, after: 2000 }).reason + ).toBe("invalid-tier-window") + }) +}) + +describe("rate Volume unit normalization", () => { + it("normalizes only consistently canonical terminal rate units for sum", () => { + const payload = makePayload({ units: ["MiB/s", "events/s"] }) + + expect(normalizeDataQueryUnits(payload, { rateVolume: true, timeGroup: "sum" })).toEqual({ + available: true, + status: "normalized", + sourceUnits: ["MiB/s", "events/s"], + units: ["MiB", "events"], + }) + }) + + it("keeps corrected non-rate units and rejects mixed or unknown units", () => { + const corrected = makePayload({ units: ["MiB", "events"] }) + expect(normalizeDataQueryUnits(corrected, { rateVolume: true, timeGroup: "sum" }).status).toBe( + "source" + ) + + const mixed = makePayload({ units: ["MiB/s", "events"] }) + expect(normalizeDataQueryUnits(mixed, { rateVolume: true, timeGroup: "sum" }).available).toBe( + false + ) + + const unknown = makePayload({ units: [] }) + expect(normalizeDataQueryUnits(unknown, { rateVolume: true, timeGroup: "sum" }).available).toBe( + false + ) + }) + + it("does not expose rate Volume for a different time calculation", () => { + const payload = makePayload({ units: ["MiB/s"] }) + expect( + normalizeDataQueryUnits(payload, { rateVolume: true, timeGroup: "average" }).available + ).toBe(false) + }) +}) diff --git a/src/sdk/dataQuery/transport.js b/src/sdk/dataQuery/transport.js new file mode 100644 index 000000000..4e2e43d9f --- /dev/null +++ b/src/sdk/dataQuery/transport.js @@ -0,0 +1,77 @@ +export class DataRequestError extends Error { + constructor(message, { status, payload, cause, code } = {}) { + super(message) + this.name = "DataRequestError" + this.status = status + this.payload = payload + this.code = code + if (cause) this.cause = cause + } +} + +const getErrorMessage = (payload, status) => + payload?.errorMessage || + payload?.errorMsgKey || + payload?.message || + (status ? `Data request failed with HTTP ${status}` : "Data request failed") + +export const fetchDataRequest = async ( + request, + options = {}, + { rejectHttpErrors = true, timeoutMs, wrapJsonErrors = true } = {} +) => { + const fetchOptions = { ...request.options, ...options } + const lifecycleSignal = fetchOptions.signal + const hasDeadline = Number.isFinite(timeoutMs) && timeoutMs > 0 + const controller = hasDeadline ? new AbortController() : null + let abortCause + let timeout + const abortForLifecycle = () => { + abortCause ??= "lifecycle" + controller.abort() + } + + if (controller) { + fetchOptions.signal = controller.signal + if (lifecycleSignal?.aborted) abortForLifecycle() + else lifecycleSignal?.addEventListener("abort", abortForLifecycle, { once: true }) + timeout = setTimeout(() => { + abortCause ??= "timeout" + controller.abort() + }, timeoutMs) + } + + try { + const response = await fetch(request.url, fetchOptions) + let payload + + try { + payload = await response.json() + } catch (cause) { + if (cause?.name === "AbortError") throw cause + if (!wrapJsonErrors) throw cause + throw new DataRequestError("Data request returned an invalid JSON response", { + status: response.status, + cause, + }) + } + + if (rejectHttpErrors && response.ok === false) + throw new DataRequestError(getErrorMessage(payload, response.status), { + status: response.status, + payload, + }) + + return payload + } catch (cause) { + if (abortCause === "timeout" && cause?.name === "AbortError") + throw new DataRequestError(`Data request timed out after ${timeoutMs} ms`, { + cause, + code: "timeout", + }) + throw cause + } finally { + if (timeout) clearTimeout(timeout) + lifecycleSignal?.removeEventListener?.("abort", abortForLifecycle) + } +} diff --git a/src/sdk/index.js b/src/sdk/index.js index 6a3008f31..bf8d19b04 100644 --- a/src/sdk/index.js +++ b/src/sdk/index.js @@ -2,6 +2,7 @@ import makeListeners from "@/helpers/makeListeners" import makeContainer from "./makeContainer" import makeChart from "./makeChart" import initialAttributes from "./initialAttributes" +import makeDataQuery from "./dataQuery" export default ({ ui, @@ -72,6 +73,8 @@ export default ({ const removeChild = id => root.removeChild(id) + const queryData = makeDataQuery({ getAttributes: () => root.getAttributes() }) + const instance = { ...listeners, getRoot, @@ -86,6 +89,7 @@ export default ({ getNodes, appendChild, removeChild, + queryData, version, ui, } diff --git a/src/sdk/makeChart/api/fetchAgentData.js b/src/sdk/makeChart/api/fetchAgentData.js index 711428419..48587f8c2 100644 --- a/src/sdk/makeChart/api/fetchAgentData.js +++ b/src/sdk/makeChart/api/fetchAgentData.js @@ -1,59 +1,15 @@ -import { getChartURLOptions, getChartPayload } from "./helpers" - -const wildcard = "*" - -const getPayload = (chart, attrs = {}) => { - const { - selectedContexts, - context, - nodesScope, - contextScope, - selectedInstances, - selectedDimensions, - selectedLabels, - aggregationMethod, - groupBy, - groupByLabel, - postGroupBy, - postGroupByLabel, - postAggregationMethod, - showPostAggregations, - } = { ...chart.getAttributes(), ...attrs } - const selectedNodes = chart.getFilteredNodeIds() - - const options = getChartURLOptions(chart) - const extraPayload = getChartPayload(chart, attrs) - - return { - ...extraPayload, - options: options.join("|"), - contexts: - (Array.isArray(selectedContexts) ? selectedContexts.join("|") : "") || context || wildcard, - scope_contexts: (Array.isArray(contextScope) ? contextScope.join("|") : "") || wildcard, - scope_nodes: (Array.isArray(nodesScope) ? nodesScope.join("|") : "") || wildcard, - nodes: (Array.isArray(selectedNodes) ? selectedNodes.join("|") : "") || wildcard, - instances: (Array.isArray(selectedInstances) ? selectedInstances.join("|") : "") || wildcard, - dimensions: (Array.isArray(selectedDimensions) ? selectedDimensions.join("|") : "") || wildcard, - labels: (Array.isArray(selectedLabels) ? selectedLabels.join("|") : "") || wildcard, - "group_by[0]": groupBy.join("|"), - "group_by_label[0]": groupByLabel.join("|"), - "aggregation[0]": aggregationMethod, - ...(showPostAggregations && - !!postGroupBy.length && { - "group_by[1]": postGroupBy.join("|"), - "group_by_label[1]": postGroupByLabel.join("|"), - "aggregation[1]": postAggregationMethod, - }), - } -} +import { buildDataRequest } from "../../dataQuery/request" +import { fetchDataRequest } from "../../dataQuery/transport" +import { getChartDataRequestAttributes } from "./helpers" export default (chart, { attrs, ...options } = {}) => { - const { host } = chart.getAttributes() - - const payload = getPayload(chart, attrs) - - const query = new URLSearchParams(payload).toString() - const url = `${host}/data?${query}` - - return fetch(url, options).then(response => response.json()) + const request = buildDataRequest({ + ...getChartDataRequestAttributes(chart, attrs), + agent: true, + }) + + return fetchDataRequest(request, options, { + rejectHttpErrors: false, + wrapJsonErrors: false, + }) } diff --git a/src/sdk/makeChart/api/fetchCloudData.js b/src/sdk/makeChart/api/fetchCloudData.js index f82f4c049..07acad276 100644 --- a/src/sdk/makeChart/api/fetchCloudData.js +++ b/src/sdk/makeChart/api/fetchCloudData.js @@ -1,97 +1,15 @@ -import { getChartURLOptions, getChartPayload } from "./helpers" - -const wildcardArray = ["*"] - -const getPayload = (chart, attrs = {}) => { - const { - selectedContexts, - context, - nodesScope, - contextScope, - selectedInstances, - selectedDimensions, - selectedLabels, - aggregationMethod, - groupBy, - groupByLabel, - postGroupBy, - postGroupByLabel, - postAggregationMethod, - showPostAggregations, - } = { ...chart.getAttributes(), ...attrs } - - const selectedNodes = chart.getFilteredNodeIds() - - const options = getChartURLOptions(chart) - - const { after, before, points, time_group, time_resampling, format } = getChartPayload( - chart, - attrs - ) - - return { - format, - options, - scope: { - contexts: Array.isArray(contextScope) && contextScope.length ? contextScope : wildcardArray, - nodes: Array.isArray(nodesScope) && nodesScope.length ? nodesScope : [], - }, - selectors: { - contexts: - Array.isArray(selectedContexts) && selectedContexts.length - ? selectedContexts - : context - ? [context] - : wildcardArray, - nodes: Array.isArray(selectedNodes) && selectedNodes.length ? selectedNodes : wildcardArray, - instances: - Array.isArray(selectedInstances) && selectedInstances.length - ? selectedInstances - : wildcardArray, - dimensions: - Array.isArray(selectedDimensions) && selectedDimensions.length - ? selectedDimensions - : wildcardArray, - labels: - Array.isArray(selectedLabels) && selectedLabels.length ? selectedLabels : wildcardArray, - }, - aggregations: { - metrics: [ - { - group_by: groupBy, - group_by_label: groupByLabel, - aggregation: aggregationMethod, - }, - showPostAggregations && - !!postGroupBy.length && { - group_by: postGroupBy, - group_by_label: postGroupByLabel, - aggregation: postAggregationMethod, - }, - ].filter(Boolean), - time: { - time_group, - // time_group_options: "", - time_resampling, - }, - }, - window: { - after, - ...(after > 0 && { before }), - points, - // tier: 0 - }, - } -} +import { buildDataRequest } from "../../dataQuery/request" +import { fetchDataRequest } from "../../dataQuery/transport" +import { getChartDataRequestAttributes } from "./helpers" export default (chart, { attrs, ...options } = {}) => { - const { host } = chart.getAttributes() - - const payload = getPayload(chart, attrs) - - return fetch(`${host}/data`, { - method: "POST", - body: JSON.stringify(payload), - ...options, - }).then(response => response.json()) + const request = buildDataRequest({ + ...getChartDataRequestAttributes(chart, attrs), + agent: false, + }) + + return fetchDataRequest(request, options, { + rejectHttpErrors: false, + wrapJsonErrors: false, + }) } diff --git a/src/sdk/makeChart/api/helpers.js b/src/sdk/makeChart/api/helpers.js index 77d9c67e4..54aa5bd23 100644 --- a/src/sdk/makeChart/api/helpers.js +++ b/src/sdk/makeChart/api/helpers.js @@ -4,8 +4,13 @@ const defaultUrlOptionsByLibrary = { } export const getChartURLOptions = chart => { - const { eliminateZeroDimensions, urlOptions = [], chartLibrary, chartType, nulls2zero } = - chart.getAttributes() + const { + eliminateZeroDimensions, + urlOptions = [], + chartLibrary, + chartType, + nulls2zero, + } = chart.getAttributes() const opts = defaultUrlOptionsByLibrary[chartLibrary] || defaultUrlOptionsByLibrary.default const canEliminateZeroDimensions = chartLibrary !== "table" && chartType !== "heatmap" @@ -104,6 +109,15 @@ export const getChartPayload = (chart, attrs = {}) => { } } +export const getChartDataRequestAttributes = (chart, attrs = {}) => ({ + ...chart.getAttributes(), + ...attrs, + ...getChartPayload(chart, attrs), + host: chart.getAttribute("host"), + selectedNodes: chart.getFilteredNodeIds(), + options: getChartURLOptions(chart), +}) + export const errorCodesToMessage = { ErrAllNodesFailed: "All agents failed to return data", } diff --git a/src/sdk/makeChart/api/helpers.test.js b/src/sdk/makeChart/api/helpers.test.js index a264ac475..6ff6d60a0 100644 --- a/src/sdk/makeChart/api/helpers.test.js +++ b/src/sdk/makeChart/api/helpers.test.js @@ -3,6 +3,7 @@ import { pointMultiplierByChartType, getLiveFetchBefore, getChartPayload, + getChartDataRequestAttributes, errorCodesToMessage, } from "./helpers" import { makeTestChart } from "@jest/testUtilities" @@ -108,6 +109,16 @@ describe("API helpers", () => { }) }) + describe("getChartDataRequestAttributes", () => { + it("keeps the configured chart host when fetch attributes contain a host", () => { + const { chart } = makeTestChart({ attributes: { host: "configured-host" } }) + + const result = getChartDataRequestAttributes(chart, { host: "ignored-host" }) + + expect(result.host).toBe("configured-host") + }) + }) + describe("pointMultiplierByChartType", () => { it("exports correct multipliers", () => { expect(pointMultiplierByChartType).toEqual({ diff --git a/src/sdk/makeChart/api/index.js b/src/sdk/makeChart/api/index.js index 163cffe52..ac7cd4bc3 100644 --- a/src/sdk/makeChart/api/index.js +++ b/src/sdk/makeChart/api/index.js @@ -2,24 +2,13 @@ import fetchAgentData from "./fetchAgentData" import fetchAgentWeights from "./fetchAgentWeights" import fetchCloudData from "./fetchCloudData" import fetchCloudWeights from "./fetchCloudWeights" +import { withDataRequestAuth } from "../../dataQuery/request" export * from "./helpers" export const fetchChartData = (chart, options) => { const { agent } = chart.getAttributes() - - options = { - ...options, - ...((chart.getAttribute("bearer") || chart.getAttribute("xNetdataBearer")) && { - headers: { - ...(chart.getAttribute("bearer") - ? { Authorization: `Bearer ${chart.getAttribute("bearer")}` } - : { - "X-Netdata-Auth": `Bearer ${chart.getAttribute("xNetdataBearer")}`, - }), - }, - }), - } + options = withDataRequestAuth(chart.getAttributes(), options) return agent ? fetchAgentData(chart, options) : fetchCloudData(chart, options) }