mirror of
https://github.com/openobserve/openobserve.git
synced 2026-11-05 08:09:22 +00:00
## What this is Database Monitoring: an aggregation and presentation layer over telemetry OpenObserve already ingests. It reads three vantages — client spans, server-vantage logs, and collector metrics — and turns them into per-statement, per-instance and per-database views. No new storage engine and no new ingest path. Statements are normalized and rolled up into a stats stream, and the read API queries that stream through the existing search path. ## Shape of the change | | | |---|---| | Read endpoints | 17 | | UI pages | 8 (plus a shell) | | Commits | 141 | **Backend** — `src/core/src/db_monitoring/` holds the non-HTTP half (normalization, rollup, server-vantage parsing). The read API lives in `src/api/management/src/request/db_monitoring/` as a handler / models / service trio; the service layer is split one file per feature. **Frontend** — `web/src/views/DatabaseMonitoring/`, with shared logic in `web/src/composables/dbm/`. ## Feature availability Databases, queries, query detail and history, endpoints, samples, activity, insights, plans, server metrics, server queries, server samples, instances, instance metrics, badges — is available in OSS. ## Reviewing this It is a large diff, and most of it is additive: 327 files, and the two largest non-test files are the shared service module and the deadlocks service. Suggested order: 1. `src/api/management/src/request/db_monitoring/handler.rs` — the 17 entry points and their gating 2. `src/api/management/src/request/db_monitoring/service/common.rs` — auth, SQL building, the shared read path 3. `src/core/src/db_monitoring/rollup.rs` — how the stats stream is produced 4. `web/src/views/DatabaseMonitoring/` — the pages Time semantics are the subtlest part. Rollup rows are stamped at the window **end**, so reads are exclusive at both edges; `stats_read_range` carries the reasoning and a test pins it. ## Known follow-ups - **The server-vantage stream named as `_o2_dbm_server`.** ## Testing - Rust: unit tests across the DBM core and read service, including SQL-building, auth-gating and normalizer cases - Web: unit tests across the DBM pages and composables - Playwright: DBM suite covering the pages and cross-page navigation Verified before opening: OSS and enterprise builds both compile warning-free, `cargo fmt` clean, `vue-tsc` clean, and the full web unit suite passes (44,704 tests).
969 lines
30 KiB
TypeScript
969 lines
30 KiB
TypeScript
// Copyright 2026 OpenObserve Inc.
|
|
|
|
import { describe, it, expect, afterEach, vi } from "vitest";
|
|
|
|
// All vi.mock calls MUST be at the TOP (hoisted by Vitest)
|
|
|
|
vi.mock("../aws-exports", () => ({
|
|
default: { isCloud: "false" },
|
|
}));
|
|
|
|
vi.mock("vuex", () => ({
|
|
useStore: vi.fn(),
|
|
}));
|
|
|
|
vi.mock("@/utils/timezone", () => ({
|
|
getFunctionErrorMessage: vi.fn((msg: string) => `${msg} (Data returned for: start to end)`),
|
|
}));
|
|
|
|
// Imports AFTER mocks
|
|
import config from "../aws-exports";
|
|
import { useStore } from "vuex";
|
|
import { getFunctionErrorMessage } from "@/utils/timezone";
|
|
|
|
import {
|
|
getPath,
|
|
getImageURL,
|
|
queryIndexSplit,
|
|
addSpacesToOperators,
|
|
mergeRoutes,
|
|
mergeDeep,
|
|
validateEmail,
|
|
isValidResourceName,
|
|
escapeSingleQuotes,
|
|
splitQuotedString,
|
|
arraysMatch,
|
|
deepCopy,
|
|
mergeAndRemoveDuplicates,
|
|
maxLengthCharValidation,
|
|
validateUrl,
|
|
getWebSocketUrl,
|
|
isWebSocketEnabled,
|
|
isStreamingEnabled,
|
|
getIngestionURL,
|
|
getEndPoint,
|
|
getCronIntervalDifferenceInSeconds,
|
|
getCronIntervalInMinutes,
|
|
isAboveMinRefreshInterval,
|
|
describeCron,
|
|
convertMinutesToCron,
|
|
processQueryMetadataErrors,
|
|
} from "./queryUtils";
|
|
import { gt } from "@/types/i18n";
|
|
|
|
afterEach(() => {
|
|
vi.clearAllMocks();
|
|
(config as any).isCloud = "false";
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Helper: set window.location
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function setLocation(origin: string, pathname: string) {
|
|
Object.defineProperty(window, "location", {
|
|
value: { origin, pathname, protocol: "http:" },
|
|
writable: true,
|
|
configurable: true,
|
|
});
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// getPath
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe("getPath", () => {
|
|
it("returns '/web/' when origin is localhost:8081 and pathname contains /web/", () => {
|
|
setLocation("http://localhost:8081", "/web/app");
|
|
|
|
expect(getPath()).toBe("/web/");
|
|
});
|
|
|
|
it("returns '/' when origin is localhost:8081 and pathname does NOT contain /web/", () => {
|
|
setLocation("http://localhost:8081", "/app");
|
|
|
|
expect(getPath()).toBe("/");
|
|
});
|
|
|
|
it("returns the path prefix when origin is non-localhost and /web/ is in pathname", () => {
|
|
setLocation("https://example.com", "/web/app/logs");
|
|
|
|
const result = getPath();
|
|
expect(result).toBe("/web/");
|
|
});
|
|
|
|
it("returns '' when origin is non-localhost and /web/ is not in pathname", () => {
|
|
setLocation("https://example.com", "/app/logs");
|
|
|
|
expect(getPath()).toBe("");
|
|
});
|
|
|
|
it("works with isCloud=true (same path logic)", () => {
|
|
(config as any).isCloud = "true";
|
|
setLocation("http://localhost:8081", "/web/app");
|
|
|
|
expect(getPath()).toBe("/web/");
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// getImageURL
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe("getImageURL", () => {
|
|
it("prepends getPath() + 'src/assets/' to the image path", () => {
|
|
setLocation("http://localhost:8081", "/web/app");
|
|
|
|
const result = getImageURL("logo.png");
|
|
|
|
expect(result).toBe("/web/src/assets/logo.png");
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// queryIndexSplit
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe("queryIndexSplit", () => {
|
|
it("splits on the found word (case-insensitive)", () => {
|
|
const result = queryIndexSplit("SELECT * FROM logs WHERE level='error'", "from");
|
|
expect(result[0]).toBe("SELECT * ");
|
|
expect(result[1]).toBe(" logs WHERE level='error'");
|
|
});
|
|
|
|
it("returns [original, ''] when split word is not found", () => {
|
|
const result = queryIndexSplit("hello world", "missing");
|
|
expect(result).toEqual(["hello world", ""]);
|
|
});
|
|
|
|
it("matches case-insensitively", () => {
|
|
const result = queryIndexSplit("SELECT * FROM logs", "FROM");
|
|
expect(result[0]).toBe("SELECT * ");
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// addSpacesToOperators
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe("addSpacesToOperators", () => {
|
|
it("returns unchanged string when no operators present", () => {
|
|
expect(addSpacesToOperators("hello world")).toBe("hello world");
|
|
});
|
|
|
|
it("adds spaces around !=", () => {
|
|
const result = addSpacesToOperators("a!=b");
|
|
expect(result).toContain("!=");
|
|
expect(result.includes(" != ") || result.includes(" !=") || result.includes("!= ")).toBe(true);
|
|
});
|
|
|
|
it("adds spaces around <=", () => {
|
|
const result = addSpacesToOperators("a<=b");
|
|
expect(result).toContain("<=");
|
|
expect(result.includes(" <=") || result.includes("<= ")).toBe(true);
|
|
});
|
|
|
|
it("adds spaces around >=", () => {
|
|
const result = addSpacesToOperators("a>=b");
|
|
expect(result).toContain(">=");
|
|
});
|
|
|
|
it("adds spaces around = (single)", () => {
|
|
const result = addSpacesToOperators("a=b");
|
|
expect(result).toContain("=");
|
|
expect(result.trim().length).toBeGreaterThan(3);
|
|
});
|
|
|
|
it("adds spaces around >", () => {
|
|
const result = addSpacesToOperators("a>b");
|
|
expect(result).toContain(">");
|
|
});
|
|
|
|
it("adds spaces around <", () => {
|
|
const result = addSpacesToOperators("a<b");
|
|
expect(result).toContain("<");
|
|
});
|
|
|
|
it("does NOT modify operators inside single quotes", () => {
|
|
const input = "name='a!=b'";
|
|
const result = addSpacesToOperators(input);
|
|
// The content inside quotes should be preserved
|
|
expect(result).toContain("a!=b");
|
|
});
|
|
|
|
it("does NOT modify operators inside double quotes", () => {
|
|
const input = 'name="a!=b"';
|
|
const result = addSpacesToOperators(input);
|
|
expect(result).toContain("a!=b");
|
|
});
|
|
|
|
it("does NOT modify operators inside parentheses", () => {
|
|
const input = "(a!=b)";
|
|
const result = addSpacesToOperators(input);
|
|
// Inside parens operators are not processed
|
|
expect(result).toBe("(a!=b)");
|
|
});
|
|
|
|
it("normalizes '! =' (! space =) to '!='", () => {
|
|
const input = "a! =b";
|
|
const result = addSpacesToOperators(input);
|
|
expect(result).toContain("!=");
|
|
});
|
|
|
|
it("handles operator at the start with no prevChar", () => {
|
|
const result = addSpacesToOperators("=b");
|
|
expect(result).toContain("=");
|
|
});
|
|
|
|
it("handles operator already followed by a space", () => {
|
|
const result = addSpacesToOperators("a= b");
|
|
expect(result).toContain("=");
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// mergeRoutes
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe("mergeRoutes", () => {
|
|
it("merges routes with matching path+name", () => {
|
|
const r1 = [{ path: "/logs", name: "logs", meta: { r1: true } }];
|
|
const r2 = [{ path: "/logs", name: "logs", meta: { r2: true } }];
|
|
|
|
const result = mergeRoutes(r1, r2);
|
|
|
|
expect(result).toHaveLength(1);
|
|
expect(result[0].meta.r1).toBe(true);
|
|
});
|
|
|
|
it("appends non-matching routes from r2", () => {
|
|
const r1 = [{ path: "/logs", name: "logs" }];
|
|
const r2 = [{ path: "/metrics", name: "metrics" }];
|
|
|
|
const result = mergeRoutes(r1, r2);
|
|
|
|
expect(result).toHaveLength(2);
|
|
expect(result.some((r: any) => r.name === "metrics")).toBe(true);
|
|
});
|
|
|
|
it("recursively merges children", () => {
|
|
const r1 = [
|
|
{
|
|
path: "/parent",
|
|
name: "parent",
|
|
children: [{ path: "/child", name: "child", meta: { from: "r1" } }],
|
|
},
|
|
];
|
|
const r2 = [
|
|
{
|
|
path: "/parent",
|
|
name: "parent",
|
|
children: [{ path: "/child", name: "child", meta: { from: "r2" } }],
|
|
},
|
|
];
|
|
|
|
const result = mergeRoutes(r1, r2);
|
|
|
|
expect(result[0].children).toHaveLength(1);
|
|
expect(result[0].children[0].meta.from).toBe("r1");
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// mergeDeep
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe("mergeDeep", () => {
|
|
it("merges nested objects", () => {
|
|
const target = { a: { b: 1 } };
|
|
const source = { a: { c: 2 } };
|
|
|
|
const result = mergeDeep(target, source);
|
|
|
|
expect(result.a.b).toBe(1);
|
|
expect(result.a.c).toBe(2);
|
|
});
|
|
|
|
it("overwrites primitive values", () => {
|
|
const target = { a: 1 };
|
|
const source = { a: 2 };
|
|
|
|
const result = mergeDeep(target, source);
|
|
|
|
expect(result.a).toBe(2);
|
|
});
|
|
|
|
it("returns target unchanged for non-object inputs", () => {
|
|
expect(mergeDeep("string" as any, { a: 1 })).toBe("string");
|
|
expect(mergeDeep({ a: 1 }, null as any)).toEqual({ a: 1 });
|
|
});
|
|
|
|
it("does NOT pollute prototype via __proto__", () => {
|
|
const target: any = {};
|
|
mergeDeep(target, JSON.parse('{"__proto__":{"polluted":true}}'));
|
|
|
|
expect(({} as any).polluted).toBeUndefined();
|
|
});
|
|
|
|
it("does NOT pollute via constructor key", () => {
|
|
const target: any = {};
|
|
mergeDeep(target, { constructor: { polluted: true } });
|
|
|
|
expect(({} as any).polluted).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// validateEmail
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe("validateEmail", () => {
|
|
it("returns true for a valid email", () => {
|
|
expect(validateEmail("user@example.com")).toBe(true);
|
|
});
|
|
|
|
it("returns false for null", () => {
|
|
expect(validateEmail(null as any)).toBe(false);
|
|
});
|
|
|
|
it("returns false for missing @", () => {
|
|
expect(validateEmail("noatsign.com")).toBe(false);
|
|
});
|
|
|
|
it("returns false for missing TLD", () => {
|
|
expect(validateEmail("user@domain")).toBe(false);
|
|
});
|
|
|
|
it("returns true for email with subdomains", () => {
|
|
expect(validateEmail("user@mail.example.co.uk")).toBe(true);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// isValidResourceName
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe("isValidResourceName", () => {
|
|
it("returns true for a valid name", () => {
|
|
expect(isValidResourceName("my-stream_01")).toBe(true);
|
|
});
|
|
|
|
it("returns false for name with colon", () => {
|
|
expect(isValidResourceName("my:stream")).toBe(false);
|
|
});
|
|
|
|
it("returns false for name with hash", () => {
|
|
expect(isValidResourceName("my#stream")).toBe(false);
|
|
});
|
|
|
|
it("returns false for name with question mark", () => {
|
|
expect(isValidResourceName("my?stream")).toBe(false);
|
|
});
|
|
|
|
it("returns false for name with ampersand", () => {
|
|
expect(isValidResourceName("my&stream")).toBe(false);
|
|
});
|
|
|
|
it("returns false for name with percent", () => {
|
|
expect(isValidResourceName("my%stream")).toBe(false);
|
|
});
|
|
|
|
it("returns false for name with single quote", () => {
|
|
expect(isValidResourceName("my'stream")).toBe(false);
|
|
});
|
|
|
|
it("returns false for name with double quote", () => {
|
|
expect(isValidResourceName('my"stream')).toBe(false);
|
|
});
|
|
|
|
it("returns false for name with space", () => {
|
|
expect(isValidResourceName("my stream")).toBe(false);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// escapeSingleQuotes
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe("escapeSingleQuotes", () => {
|
|
it("escapes single quotes by doubling them", () => {
|
|
expect(escapeSingleQuotes("it's a test")).toBe("it''s a test");
|
|
});
|
|
|
|
it("returns unchanged string when no single quotes", () => {
|
|
expect(escapeSingleQuotes("no quotes here")).toBe("no quotes here");
|
|
});
|
|
|
|
it("returns undefined for null/undefined input", () => {
|
|
expect(escapeSingleQuotes(null)).toBeUndefined();
|
|
expect(escapeSingleQuotes(undefined)).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// splitQuotedString
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe("splitQuotedString", () => {
|
|
it("returns null for null input", () => {
|
|
expect(splitQuotedString(null)).toBeNull();
|
|
});
|
|
|
|
it("returns empty array for empty string", () => {
|
|
expect(splitQuotedString("")).toEqual([]);
|
|
});
|
|
|
|
it("splits on single-quoted values", () => {
|
|
// Regex group 1 captures 'hello'. The space+'world' becomes second element
|
|
// via unquoted group (because the comma stops the regex, and ' world' restarts).
|
|
// The trim inside removes leading spaces.
|
|
const result = splitQuotedString("'hello','world'");
|
|
expect(result).toContain("hello");
|
|
expect(result).toContain("world");
|
|
});
|
|
|
|
it("splits on double-quoted values", () => {
|
|
const result = splitQuotedString('"foo","bar"');
|
|
expect(result).toContain("foo");
|
|
expect(result).toContain("bar");
|
|
});
|
|
|
|
it("splits mixed quoted and unquoted", () => {
|
|
const result = splitQuotedString("plain,'quoted'");
|
|
expect(result).toContain("plain");
|
|
expect(result).toContain("quoted");
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// arraysMatch
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe("arraysMatch", () => {
|
|
it("returns true for equal arrays in different order", () => {
|
|
expect(arraysMatch([3, 1, 2], [1, 2, 3])).toBe(true);
|
|
});
|
|
|
|
it("returns false for arrays with different values", () => {
|
|
expect(arraysMatch([1, 2, 3], [1, 2, 4])).toBe(false);
|
|
});
|
|
|
|
it("returns false for arrays with different lengths", () => {
|
|
expect(arraysMatch([1, 2], [1, 2, 3])).toBe(false);
|
|
});
|
|
|
|
it("returns true for two empty arrays", () => {
|
|
expect(arraysMatch([], [])).toBe(true);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// deepCopy
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe("deepCopy", () => {
|
|
it("deep copies a plain object", () => {
|
|
const obj = { a: 1, b: { c: 2 } };
|
|
const copy = deepCopy(obj);
|
|
|
|
copy.b.c = 99;
|
|
|
|
expect(obj.b.c).toBe(2);
|
|
});
|
|
|
|
it("deep copies an array", () => {
|
|
const arr = [1, [2, 3]];
|
|
const copy = deepCopy(arr);
|
|
|
|
(copy[1] as number[])[0] = 99;
|
|
|
|
expect((arr[1] as number[])[0]).toBe(2);
|
|
});
|
|
|
|
it("returns original value and logs error for circular references", () => {
|
|
const errSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
const circular: any = { a: 1 };
|
|
circular.self = circular;
|
|
|
|
const result = deepCopy(circular);
|
|
|
|
expect(result).toBe(circular); // returns original
|
|
expect(errSpy).toHaveBeenCalled();
|
|
errSpy.mockRestore();
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// mergeAndRemoveDuplicates
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe("mergeAndRemoveDuplicates", () => {
|
|
it("merges two arrays and removes duplicates", () => {
|
|
const result = mergeAndRemoveDuplicates(["a", "b"], ["b", "c"]);
|
|
expect(result).toEqual(["a", "b", "c"]);
|
|
});
|
|
|
|
it("preserves order (first occurrence wins)", () => {
|
|
const result = mergeAndRemoveDuplicates(["b", "a"], ["a", "c"]);
|
|
expect(result[0]).toBe("b");
|
|
expect(result[1]).toBe("a");
|
|
expect(result[2]).toBe("c");
|
|
});
|
|
|
|
it("handles empty arrays", () => {
|
|
expect(mergeAndRemoveDuplicates([], [])).toEqual([]);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// maxLengthCharValidation
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe("maxLengthCharValidation", () => {
|
|
it("returns true for a string within the limit", () => {
|
|
expect(maxLengthCharValidation("hello", 10)).toBe(true);
|
|
});
|
|
|
|
it("returns true for a string exactly at the limit", () => {
|
|
expect(maxLengthCharValidation("hello", 5)).toBe(true);
|
|
});
|
|
|
|
it("returns error string for a string over the limit", () => {
|
|
const result = maxLengthCharValidation("hello world", 5);
|
|
expect(typeof result).toBe("string");
|
|
expect(result).toContain("5 characters");
|
|
});
|
|
|
|
it("returns error string for empty string (default limit 50)", () => {
|
|
// Empty string: val.length = 0, 0 <= 50 is true... wait, empty string is falsy
|
|
// val && val.length — empty string is falsy so returns the error string
|
|
const result = maxLengthCharValidation("");
|
|
expect(typeof result).toBe("string");
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// validateUrl
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe("validateUrl", () => {
|
|
it("returns true for a valid https URL", () => {
|
|
expect(validateUrl("https://example.com/path")).toBe(true);
|
|
});
|
|
|
|
it("returns true for a valid http URL", () => {
|
|
expect(validateUrl("http://example.com")).toBe(true);
|
|
});
|
|
|
|
it("returns error string for an invalid URL", () => {
|
|
expect(validateUrl("not-a-url")).toBe("Please provide correct URL.");
|
|
});
|
|
|
|
it("returns false for ftp URL (not http/https)", () => {
|
|
expect(validateUrl("ftp://files.example.com")).toBe(false);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// getWebSocketUrl
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe("getWebSocketUrl", () => {
|
|
it("uses wss: when protocol is https", () => {
|
|
Object.defineProperty(window, "location", {
|
|
value: { protocol: "https:", origin: "https://example.com", pathname: "/" },
|
|
writable: true,
|
|
configurable: true,
|
|
});
|
|
|
|
const result = getWebSocketUrl("req-1", "my-org", "https://api.example.com");
|
|
|
|
expect(result.startsWith("wss://")).toBe(true);
|
|
});
|
|
|
|
it("uses ws: when protocol is http", () => {
|
|
Object.defineProperty(window, "location", {
|
|
value: { protocol: "http:", origin: "http://localhost:8081", pathname: "/" },
|
|
writable: true,
|
|
configurable: true,
|
|
});
|
|
|
|
const result = getWebSocketUrl("req-1", "my-org", "http://api.example.com");
|
|
|
|
expect(result.startsWith("ws://")).toBe(true);
|
|
});
|
|
|
|
it("includes org_identifier and request_id in URL", () => {
|
|
const result = getWebSocketUrl("req-123", "my-org", "http://api.example.com");
|
|
|
|
expect(result).toContain("my-org");
|
|
expect(result).toContain("req-123");
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// isWebSocketEnabled
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe("isWebSocketEnabled", () => {
|
|
it("always returns false", () => {
|
|
expect(isWebSocketEnabled({})).toBe(false);
|
|
expect(isWebSocketEnabled(null)).toBe(false);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// isStreamingEnabled
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe("isStreamingEnabled", () => {
|
|
it("always returns true", () => {
|
|
expect(isStreamingEnabled({})).toBe(true);
|
|
expect(isStreamingEnabled(null)).toBe(true);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// getIngestionURL
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe("getIngestionURL", () => {
|
|
it("returns API_ENDPOINT when no ingestion_url is set", () => {
|
|
vi.mocked(useStore).mockReturnValue({
|
|
state: {
|
|
API_ENDPOINT: "https://api.example.com",
|
|
zoConfig: {},
|
|
},
|
|
} as any);
|
|
|
|
const result = getIngestionURL();
|
|
|
|
expect(result).toBe("https://api.example.com");
|
|
});
|
|
|
|
it("returns ingestion_url when it is set in zoConfig", () => {
|
|
vi.mocked(useStore).mockReturnValue({
|
|
state: {
|
|
API_ENDPOINT: "https://api.example.com",
|
|
zoConfig: { ingestion_url: "https://ingest.example.com" },
|
|
},
|
|
} as any);
|
|
|
|
const result = getIngestionURL();
|
|
|
|
expect(result).toBe("https://ingest.example.com");
|
|
});
|
|
|
|
it("returns API_ENDPOINT when ingestion_url is empty string", () => {
|
|
vi.mocked(useStore).mockReturnValue({
|
|
state: {
|
|
API_ENDPOINT: "https://api.example.com",
|
|
zoConfig: { ingestion_url: "" },
|
|
},
|
|
} as any);
|
|
|
|
const result = getIngestionURL();
|
|
|
|
expect(result).toBe("https://api.example.com");
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// getEndPoint
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe("getEndPoint", () => {
|
|
it("returns correct endpoint for https with explicit port", () => {
|
|
const result = getEndPoint("https://api.example.com:9200");
|
|
|
|
expect(result.host).toBe("api.example.com");
|
|
expect(result.port).toBe("9200");
|
|
expect(result.protocol).toBe("https");
|
|
expect(result.tls).toBe("On");
|
|
});
|
|
|
|
it("defaults to port 443 for https without explicit port", () => {
|
|
const result = getEndPoint("https://api.example.com");
|
|
|
|
expect(result.port).toBe("443");
|
|
});
|
|
|
|
it("returns correct endpoint for http with explicit port", () => {
|
|
const result = getEndPoint("http://api.example.com:8080");
|
|
|
|
expect(result.port).toBe("8080");
|
|
expect(result.tls).toBe("Off");
|
|
});
|
|
|
|
it("defaults to port 80 for http without explicit port", () => {
|
|
const result = getEndPoint("http://api.example.com");
|
|
|
|
expect(result.port).toBe("80");
|
|
});
|
|
|
|
// A same-origin deployment sets the endpoint to a RELATIVE value. "/" becomes
|
|
// "" once the store strips its trailing slash, and `new URL("")` throws —
|
|
// which, since this runs in the setup() of every ingestion card, took the
|
|
// whole Data Sources page down rather than degrading one field.
|
|
it("resolves an empty endpoint against the page origin instead of throwing", () => {
|
|
// Pinned rather than read back off `window.location`: setLocation() above
|
|
// replaces it with a partial stub that has no `hostname`.
|
|
setLocation("http://o2.example.com:5190", "/web/");
|
|
|
|
expect(() => getEndPoint("")).not.toThrow();
|
|
|
|
const result = getEndPoint("");
|
|
expect(result.host).toBe("o2.example.com");
|
|
expect(result.port).toBe("5190");
|
|
expect(result.protocol).toBe("http");
|
|
// Empty in means the caller has no string of its own to preserve, so the
|
|
// snippets get a usable absolute origin rather than "undefined/api/...".
|
|
expect(result.url).toBe("http://o2.example.com:5190");
|
|
});
|
|
|
|
it("resolves a relative endpoint against the page origin", () => {
|
|
setLocation("http://o2.example.com:5190", "/web/");
|
|
|
|
const result = getEndPoint("/");
|
|
|
|
expect(result.host).toBe("o2.example.com");
|
|
// The caller's own string is preserved — snippets embed it verbatim.
|
|
expect(result.url).toBe("/");
|
|
});
|
|
|
|
it("falls back to the origin rather than throwing on a malformed URL", () => {
|
|
expect(() => getEndPoint("ht!tp://[not a url")).not.toThrow();
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// getCronIntervalDifferenceInSeconds
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe("getCronIntervalDifferenceInSeconds", () => {
|
|
it("returns 60 for every-minute cron", () => {
|
|
const result = getCronIntervalDifferenceInSeconds("0 * * * * *");
|
|
expect(result).toBe(60);
|
|
});
|
|
|
|
it("returns 300 for every-5-minutes cron", () => {
|
|
const result = getCronIntervalDifferenceInSeconds("0 */5 * * * *");
|
|
expect(result).toBe(300);
|
|
});
|
|
|
|
it("throws for invalid cron expression", () => {
|
|
expect(() => getCronIntervalDifferenceInSeconds("not-a-cron")).toThrow(
|
|
"Invalid cron expression",
|
|
);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// getCronIntervalInMinutes
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe("getCronIntervalInMinutes", () => {
|
|
it("returns 1 for every-minute cron", () => {
|
|
const result = getCronIntervalInMinutes("0 * * * * *");
|
|
expect(result).toBe(1);
|
|
});
|
|
|
|
it("returns 5 for every-5-minutes cron", () => {
|
|
const result = getCronIntervalInMinutes("0 */5 * * * *");
|
|
expect(result).toBe(5);
|
|
});
|
|
|
|
it("throws for invalid cron expression", () => {
|
|
expect(() => getCronIntervalInMinutes("bad cron")).toThrow("Invalid cron expression");
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// isAboveMinRefreshInterval
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe("isAboveMinRefreshInterval", () => {
|
|
it("returns true when value equals min_auto_refresh_interval", () => {
|
|
expect(isAboveMinRefreshInterval(5, { min_auto_refresh_interval: 5 })).toBe(true);
|
|
});
|
|
|
|
it("returns true when value is above min_auto_refresh_interval", () => {
|
|
expect(isAboveMinRefreshInterval(10, { min_auto_refresh_interval: 5 })).toBe(true);
|
|
});
|
|
|
|
it("returns false when value is below min_auto_refresh_interval", () => {
|
|
expect(isAboveMinRefreshInterval(3, { min_auto_refresh_interval: 5 })).toBe(false);
|
|
});
|
|
|
|
it("defaults min interval to 1 when config prop is missing", () => {
|
|
expect(isAboveMinRefreshInterval(1, {})).toBe(true);
|
|
expect(isAboveMinRefreshInterval(0, {})).toBe(false);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// describeCron
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe("describeCron", () => {
|
|
it("returns '' for empty string", () => {
|
|
expect(describeCron(gt, "")).toBe("");
|
|
});
|
|
|
|
it("returns '' for whitespace-only string", () => {
|
|
expect(describeCron(gt, " ")).toBe("");
|
|
});
|
|
|
|
it("returns error for wrong number of fields", () => {
|
|
expect(describeCron(gt, "* * * * *")).toBe("invalid cron (expected 6 fields)");
|
|
});
|
|
|
|
it("describes every N minutes pattern", () => {
|
|
expect(describeCron(gt, "0 */5 * * * *")).toBe("every 5 minutes");
|
|
});
|
|
|
|
it("describes every 1 minute (singular)", () => {
|
|
expect(describeCron(gt, "0 */1 * * * *")).toBe("every 1 minute");
|
|
});
|
|
|
|
it("describes every N hours pattern", () => {
|
|
expect(describeCron(gt, "0 0 */2 * * *")).toBe("every 2 hours");
|
|
});
|
|
|
|
it("describes every 1 hour (singular)", () => {
|
|
expect(describeCron(gt, "0 0 */1 * * *")).toBe("every 1 hour");
|
|
});
|
|
|
|
it("describes daily at HH:MM pattern", () => {
|
|
const result = describeCron(gt, "0 30 14 * * *", "UTC");
|
|
expect(result).toBe("daily at 14:30 (UTC)");
|
|
});
|
|
|
|
it("describes every hour at minute pattern", () => {
|
|
const result = describeCron(gt, "0 15 * * * *");
|
|
expect(result).toBe("every hour at minute 15");
|
|
});
|
|
|
|
it("returns next check string for unrecognized but valid cron", () => {
|
|
const result = describeCron(gt, "0 0 10 15 * *", "UTC");
|
|
expect(result).toContain("next check at");
|
|
});
|
|
|
|
it("returns 'invalid cron expression' for invalid cron", () => {
|
|
const result = describeCron(gt, "99 99 99 99 99 99");
|
|
expect(result).toBe("invalid cron expression");
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// convertMinutesToCron
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe("convertMinutesToCron", () => {
|
|
it("returns '' for 0", () => {
|
|
expect(convertMinutesToCron(0)).toBe("");
|
|
});
|
|
|
|
it("returns '' for null", () => {
|
|
expect(convertMinutesToCron(null as any)).toBe("");
|
|
});
|
|
|
|
it("returns '' for negative values", () => {
|
|
expect(convertMinutesToCron(-5)).toBe("");
|
|
});
|
|
|
|
it("returns cron expression for positive minutes", () => {
|
|
expect(convertMinutesToCron(5)).toBe("0 */5 * * * *");
|
|
});
|
|
|
|
it("returns cron expression for 1 minute", () => {
|
|
expect(convertMinutesToCron(1)).toBe("0 */1 * * * *");
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// processQueryMetadataErrors
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe("processQueryMetadataErrors", () => {
|
|
it("returns '' for null", () => {
|
|
expect(processQueryMetadataErrors(null)).toBe("");
|
|
});
|
|
|
|
it("returns '' for empty array", () => {
|
|
expect(processQueryMetadataErrors([])).toBe("");
|
|
});
|
|
|
|
it("handles nested array format with timestamps", () => {
|
|
const metadata = [
|
|
[
|
|
{
|
|
function_error: "Error A",
|
|
new_start_time: 1_000_000,
|
|
new_end_time: 2_000_000,
|
|
},
|
|
],
|
|
];
|
|
|
|
const result = processQueryMetadataErrors(metadata);
|
|
|
|
expect(getFunctionErrorMessage).toHaveBeenCalledWith("Error A", 1_000_000, 2_000_000, "UTC");
|
|
expect(result).toContain("Error A");
|
|
});
|
|
|
|
it("handles nested array format without timestamps (pushes function_error array)", () => {
|
|
const metadata = [
|
|
[
|
|
{
|
|
function_error: ["Error B", "Error C"],
|
|
},
|
|
],
|
|
];
|
|
|
|
const result = processQueryMetadataErrors(metadata);
|
|
|
|
expect(result).toContain("Error B");
|
|
expect(result).toContain("Error C");
|
|
});
|
|
|
|
it("handles flat single-query format with timestamps", () => {
|
|
const metadata = [
|
|
{
|
|
function_error: "Flat error",
|
|
new_start_time: 3_000_000,
|
|
new_end_time: 4_000_000,
|
|
},
|
|
];
|
|
|
|
const result = processQueryMetadataErrors(metadata, "UTC");
|
|
|
|
expect(getFunctionErrorMessage).toHaveBeenCalledWith("Flat error", 3_000_000, 4_000_000, "UTC");
|
|
expect(result).toContain("Flat error");
|
|
});
|
|
|
|
it("handles flat single-query format without timestamps", () => {
|
|
const metadata = [{ function_error: "Simple error" }];
|
|
|
|
const result = processQueryMetadataErrors(metadata);
|
|
|
|
expect(result).toContain("Simple error");
|
|
});
|
|
|
|
it("deduplicates repeated messages", () => {
|
|
const metadata = [
|
|
[
|
|
{ function_error: "Duplicate error", new_start_time: 1, new_end_time: 2 },
|
|
{ function_error: "Duplicate error", new_start_time: 1, new_end_time: 2 },
|
|
],
|
|
];
|
|
vi.mocked(getFunctionErrorMessage).mockReturnValue(
|
|
"Duplicate error (Data returned for: start to end)",
|
|
);
|
|
|
|
const result = processQueryMetadataErrors(metadata);
|
|
|
|
const lines = result.split("\n").filter(Boolean);
|
|
expect(lines).toHaveLength(1);
|
|
});
|
|
});
|