diff --git a/packages/comms/src/services/wsLogaccess.ts b/packages/comms/src/services/wsLogaccess.ts index 222b50d38b..85615d6353 100644 --- a/packages/comms/src/services/wsLogaccess.ts +++ b/packages/comms/src/services/wsLogaccess.ts @@ -40,7 +40,7 @@ export const enum TargetAudience { Audit = "ADT" } -//properties here are "LogType" values in Ws_logaccess.GetLogAccessInfo +// properties here are "LogType" values in Ws_logaccess.GetLogAccessInfo export interface LogLine { audience?: string; class?: string; @@ -59,6 +59,142 @@ export interface GetLogsExResponse { total: number, } +const knownLogManagerTypes = new Set(["azureloganalyticscurl", "elasticstack", "grafanacurl"]); +const logColumnTypeValues = new Set(Object.values(WsLogaccess.LogColumnType)); + +function getLogCategory(searchField: string): WsLogaccess.LogAccessType { + switch (searchField) { + case WsLogaccess.LogColumnType.workunits: + case "hpcc.log.jobid": + return WsLogaccess.LogAccessType.ByJobID; + case WsLogaccess.LogColumnType.audience: + case "hpcc.log.audience": + return WsLogaccess.LogAccessType.ByTargetAudience; + case WsLogaccess.LogColumnType.class: + case "hpcc.log.class": + return WsLogaccess.LogAccessType.ByLogType; + case WsLogaccess.LogColumnType.components: + case "kubernetes.container.name": + return WsLogaccess.LogAccessType.ByComponent; + default: + return WsLogaccess.LogAccessType.ByFieldName; + } +} + +// Explicit list of filter-bearing keys on GetLogsExRequest. +// Using an allowlist avoids accidentally treating control fields (StartDate, LogLineLimit, etc.) +// as log filters if the server ever returns a column whose name collides with them. +const FILTER_KEYS = ["audience", "class", "workunits", "message", "processid", "logid", "threadid", "timestamp", "components", "instance"] as const; + +function buildFilters(request: GetLogsExRequest, columnMap: Record): WsLogaccess.leftFilter[] { + const filters: WsLogaccess.leftFilter[] = []; + for (const key of FILTER_KEYS) { + const value = request[key]; + if (value == null || value === "" || (Array.isArray(value) && value.length === 0)) { + continue; + } + if (!(key in columnMap)) continue; + + const isKnownLogType = logColumnTypeValues.has(key as WsLogaccess.LogColumnType); + let searchField: string = isKnownLogType ? key : columnMap[key]; + const logCategory = getLogCategory(searchField); + if (logCategory === WsLogaccess.LogAccessType.ByFieldName) { + searchField = columnMap[key]; + } + + const appendWildcard = logCategory === WsLogaccess.LogAccessType.ByComponent; + const rawValues: string[] = Array.isArray(value) ? value : [value as string]; + for (const raw of rawValues) { + filters.push({ + LogCategory: logCategory, + SearchField: searchField, + // append wildcard to end of search value to include ephemeral + // containers that aren't listed in ECL Watch's filters + SearchByValue: appendWildcard ? raw + "*" : raw + }); + } + } + return filters; +} + +// Builds a left-leaning OR chain from filters that share the same SearchField. +function buildOrGroup(group: WsLogaccess.leftFilter[]): WsLogaccess.BinaryLogFilter { + const root: WsLogaccess.BinaryLogFilter = { leftFilter: group[0] } as WsLogaccess.BinaryLogFilter; + let node = root; + for (let i = 1; i < group.length; i++) { + node.Operator = WsLogaccess.LogAccessFilterOperator.OR; + if (i === group.length - 1) { + node.rightFilter = group[i] as WsLogaccess.rightFilter; + } else { + node.rightBinaryFilter = { BinaryLogFilter: [{ leftFilter: group[i] } as WsLogaccess.BinaryLogFilter] }; + node = node.rightBinaryFilter.BinaryLogFilter[0]; + } + } + return root; +} + +// Recursively AND-chains two or more groups into a BinaryLogFilter (used for nesting beyond depth 1). +function buildAndChain(groups: WsLogaccess.leftFilter[][]): WsLogaccess.BinaryLogFilter { + const [firstGroup, ...remainingGroups] = groups; + const node: WsLogaccess.BinaryLogFilter = {} as WsLogaccess.BinaryLogFilter; + if (firstGroup.length === 1) { + node.leftFilter = firstGroup[0]; + } else { + node.leftBinaryFilter = { BinaryLogFilter: [buildOrGroup(firstGroup)] }; + } + if (remainingGroups.length === 0) return node; + node.Operator = WsLogaccess.LogAccessFilterOperator.AND; + if (remainingGroups.length === 1) { + const [secondGroup] = remainingGroups; + if (secondGroup.length === 1) { + node.rightFilter = secondGroup[0] as WsLogaccess.rightFilter; + } else { + node.rightBinaryFilter = { BinaryLogFilter: [buildOrGroup(secondGroup)] }; + } + } else { + node.rightBinaryFilter = { BinaryLogFilter: [buildAndChain(remainingGroups)] }; + } + return node; +} + +// Groups filters by SearchField, OR-chains each group, then AND-chains the groups together. +// This ensures e.g. [class_INF, class_ERR, audience_USR] always produces +// (class_INF OR class_ERR) AND audience_USR regardless of input order. +function buildFilterTree(filters: WsLogaccess.leftFilter[]): WsLogaccess.Filter { + const groupMap = new Map(); + for (const f of filters) { + const existing = groupMap.get(f.SearchField); + if (existing) existing.push(f); else groupMap.set(f.SearchField, [f]); + } + const groups = [...groupMap.values()]; + + if (groups.length === 0) { + return { leftFilter: { LogCategory: WsLogaccess.LogAccessType.All } as WsLogaccess.leftFilter }; + } + + const [firstGroup, ...remainingGroups] = groups; + const filter: WsLogaccess.Filter = {}; + if (firstGroup.length === 1) { + filter.leftFilter = firstGroup[0]; + } else { + filter.leftBinaryFilter = { BinaryLogFilter: [buildOrGroup(firstGroup)] }; + } + + if (remainingGroups.length === 0) return filter; + filter.Operator = WsLogaccess.LogAccessFilterOperator.AND; + if (remainingGroups.length === 1) { + const [secondGroup] = remainingGroups; + if (secondGroup.length === 1) { + filter.rightFilter = secondGroup[0] as WsLogaccess.rightFilter; + } else { + filter.rightBinaryFilter = { BinaryLogFilter: [buildOrGroup(secondGroup)] }; + } + } else { + filter.rightBinaryFilter = { BinaryLogFilter: [buildAndChain(remainingGroups)] }; + } + return filter; +} + export class LogaccessService extends LogaccessServiceBase { protected _logAccessInfo: Promise; @@ -74,36 +210,31 @@ export class LogaccessService extends LogaccessServiceBase { return super.GetLogs(request); } + private convertLogLine(columnMap: Record, line: any): LogLine { + const retVal: LogLine = {}; + const fields = line?.fields ? Object.assign({}, ...line.fields) : null; + for (const key in columnMap) { + retVal[key] = fields ? fields[columnMap[key]] ?? "" : ""; + } + return retVal; + } + async GetLogsEx(request: GetLogsExRequest): Promise { const logInfo = await this.GetLogAccessInfo(); - const columnMap = {}; + const columnMap: Record = {}; logInfo.Columns.Column.forEach(column => columnMap[column.LogType] = column.Name); - const convertLogLine = (line: any) => { - const retVal: LogLine = {}; - for (const key in columnMap) { - if (line?.fields) { - retVal[key] = Object.assign({}, ...line.fields)[columnMap[key]] ?? ""; - } else { - retVal[key] = ""; - } - } - return retVal; + const filters = buildFilters(request, columnMap); + const range: Record = { + StartDate: request.StartDate instanceof Date ? request.StartDate.toISOString() : new Date(0).toISOString() }; + if (request.EndDate instanceof Date) { + range.EndDate = request.EndDate.toISOString(); + } const getLogsRequest: WsLogaccess.GetLogsRequest = { - Filter: { - leftBinaryFilter: { - BinaryLogFilter: [{ - leftFilter: { - LogCategory: WsLogaccess.LogAccessType.All, - }, - } as WsLogaccess.BinaryLogFilter] - } - }, - Range: { - StartDate: new Date(0).toISOString(), - }, + Filter: buildFilterTree(filters), + Range: range, LogLineStartFrom: request.LogLineStartFrom ?? 0, LogLineLimit: request.LogLineLimit ?? 100, SelectColumnMode: WsLogaccess.LogSelectColumnMode.DEFAULT, @@ -117,142 +248,14 @@ export class LogaccessService extends LogaccessServiceBase { } }; - const filters: WsLogaccess.leftFilter[] = []; - const logTypes = Object.values(WsLogaccess.LogColumnType); - for (const key in request) { - if (request[key] == null || request[key] === "" || (Array.isArray(request[key]) && request[key].length === 0)) { - continue; - } - let searchField; - if (key in columnMap) { - if (logTypes.includes(key as WsLogaccess.LogColumnType)) { - searchField = key; - } else { - searchField = columnMap[key]; - } - } - let logCategory; - if (searchField) { - switch (searchField) { - case WsLogaccess.LogColumnType.workunits: - case "hpcc.log.jobid": - logCategory = WsLogaccess.LogAccessType.ByJobID; - break; - case WsLogaccess.LogColumnType.audience: - case "hpcc.log.audience": - logCategory = WsLogaccess.LogAccessType.ByTargetAudience; - break; - case WsLogaccess.LogColumnType.class: - case "hpcc.log.class": - logCategory = WsLogaccess.LogAccessType.ByLogType; - break; - case WsLogaccess.LogColumnType.components: - case "kubernetes.container.name": - logCategory = WsLogaccess.LogAccessType.ByComponent; - break; - default: - logCategory = WsLogaccess.LogAccessType.ByFieldName; - searchField = columnMap[key]; - } - if (Array.isArray(request[key])) { - request[key].forEach(value => { - if (logCategory === WsLogaccess.LogAccessType.ByComponent) { - value += "*"; - } - filters.push({ - LogCategory: logCategory, - SearchField: searchField, - SearchByValue: value - }); - }); - } else { - let value = request[key]; - if (logCategory === WsLogaccess.LogAccessType.ByComponent) { - // append wildcard to end of search value to include ephemeral - // containers that aren't listed in ECL Watch's filters - value += "*"; - } - filters.push({ - LogCategory: logCategory, - SearchField: searchField, - SearchByValue: value - }); - } - } - } - - if (filters.length > 2) { - let binaryLogFilter = getLogsRequest.Filter.leftBinaryFilter.BinaryLogFilter[0]; - filters.forEach((filter, i) => { - let operator = WsLogaccess.LogAccessFilterOperator.AND; - if (i > 0) { - if (filters[i - 1].SearchField === filter.SearchField) { - operator = WsLogaccess.LogAccessFilterOperator.OR; - } - if (i === filters.length - 1) { - binaryLogFilter.Operator = operator; - binaryLogFilter.rightFilter = filter as WsLogaccess.rightFilter; - } else { - binaryLogFilter.Operator = operator; - binaryLogFilter.rightBinaryFilter = { - BinaryLogFilter: [{ - leftFilter: filter - } as WsLogaccess.BinaryLogFilter] - }; - binaryLogFilter = binaryLogFilter.rightBinaryFilter.BinaryLogFilter[0]; - } - } else { - binaryLogFilter.leftFilter = filter as WsLogaccess.leftFilter; - } - }); - } else { - delete getLogsRequest.Filter.leftBinaryFilter; - getLogsRequest.Filter.leftFilter = { - LogCategory: WsLogaccess.LogAccessType.All - } as WsLogaccess.leftFilter; - if (filters[0]?.SearchField) { - getLogsRequest.Filter.leftFilter = { - LogCategory: filters[0]?.LogCategory, - SearchField: filters[0]?.SearchField, - SearchByValue: filters[0]?.SearchByValue - }; - } - if (filters[1]?.SearchField) { - getLogsRequest.Filter.Operator = WsLogaccess.LogAccessFilterOperator.AND; - if (filters[0].SearchField === filters[1].SearchField) { - getLogsRequest.Filter.Operator = WsLogaccess.LogAccessFilterOperator.OR; - } - getLogsRequest.Filter.rightFilter = { - LogCategory: filters[1]?.LogCategory, - SearchField: filters[1]?.SearchField, - SearchByValue: filters[1]?.SearchByValue - }; - } - } - - if (request.StartDate) { - getLogsRequest.Range.StartDate = request.StartDate.toISOString(); - } - if (request.EndDate) { - getLogsRequest.Range.EndDate = request.EndDate.toISOString(); - } - return this.GetLogs(getLogsRequest).then(response => { try { const logLines = JSON.parse(response.LogLines); - let lines = []; - switch (logInfo.RemoteLogManagerType) { - case "azureloganalyticscurl": - case "elasticstack": - case "grafanacurl": - lines = logLines.lines?.map(convertLogLine) ?? []; - break; - default: - logger.warning(`Unknown RemoteLogManagerType: ${logInfo.RemoteLogManagerType}`); - lines = []; - } + const lines = knownLogManagerTypes.has(logInfo.RemoteLogManagerType) + ? (logLines.lines?.map((line: any) => this.convertLogLine(columnMap, line)) ?? []) + : (logger.warning(`Unknown RemoteLogManagerType: ${logInfo.RemoteLogManagerType}`), []); return { - lines: lines, + lines, total: response.TotalLogLinesAvailable ?? 10000 }; } catch (e: any) { diff --git a/packages/comms/tests/fixtures/logaccess.ts b/packages/comms/tests/fixtures/logaccess.ts new file mode 100644 index 0000000000..7560a61fa7 --- /dev/null +++ b/packages/comms/tests/fixtures/logaccess.ts @@ -0,0 +1,271 @@ +import { WsLogaccess } from "@hpcc-js/comms"; + +export const elkInfo = { + "Columns": { + "Column": [ + { + "Name": "message", + "LogType": "global", + "ColumnMode": "ALL", + "ColumnType": "string" + }, + { + "Name": "hpcc.log.jobid", + "LogType": "workunits", + "ColumnMode": "DEFAULT", + "ColumnType": "string" + }, + { + "Name": "kubernetes.container.name", + "LogType": "components", + "ColumnMode": "MIN", + "ColumnType": "string" + }, + { + "Name": "hpcc.log.audience", + "LogType": "audience", + "EnumeratedValues": { + "Item": [ + "OPR", + "USR", + "PRO", + "MON", + "ADT" + ] + }, + "ColumnMode": "DEFAULT", + "ColumnType": "enum" + }, + { + "Name": "hpcc.log.class", + "LogType": "class", + "EnumeratedValues": { + "Item": [ + "DIS", + "ERR", + "WRN", + "INF", + "PRO", + "EVT", + "MET" + ] + }, + "ColumnMode": "DEFAULT", + "ColumnType": "enum" + }, + { + "Name": "container.id", + "LogType": "instance", + "ColumnMode": "ALL", + "ColumnType": "string" + }, + { + "Name": "kubernetes.node.hostname", + "LogType": "node", + "ColumnMode": "ALL", + "ColumnType": "string" + }, + { + "Name": "hpcc.log.message", + "LogType": "message", + "ColumnMode": "MIN", + "ColumnType": "string" + }, + { + "Name": "hpcc.log.sequence", + "LogType": "logid", + "ColumnMode": "DEFAULT", + "ColumnType": "numeric" + }, + { + "Name": "hpcc.log.procid", + "LogType": "processid", + "ColumnMode": "DEFAULT", + "ColumnType": "numeric" + }, + { + "Name": "hpcc.log.threadid", + "LogType": "threadid", + "ColumnMode": "DEFAULT", + "ColumnType": "numeric" + }, + { + "Name": "hpcc.log.timestamp", + "LogType": "timestamp", + "ColumnMode": "MIN", + "ColumnType": "datetime" + }, + { + "Name": "kubernetes.pod.name", + "LogType": "pod", + "ColumnMode": "DEFAULT", + "ColumnType": "string" + }, + { + "Name": "hpcc.log.traceid", + "LogType": "traceid", + "ColumnMode": "DEFAULT", + "ColumnType": "string" + }, + { + "Name": "hpcc.log.spanid", + "LogType": "spanid", + "ColumnMode": "DEFAULT", + "ColumnType": "string" + } + ] + }, + "RemoteLogManagerType": "elasticstack", + "RemoteLogManagerConnectionString": "http://elasticsearch-master.default.svc.cluster.local:9200/", + "SupportsResultPaging": true +} as unknown as WsLogaccess.GetLogAccessInfoResponse; + +export const elkLogs: Partial = { + "LogLines": JSON.stringify({ + lines: [{ + "fields": [{ + "hpcc.log.procid": "39", + "hpcc.log.message": "Compile request processing for workunit W20260318-215244", + "timestamp": "2026-03-18T21:52:45.002Z", + "kubernetes.container.name": "myeclccserver", + "hpcc.log.timestamp": "2026-03-18 21:52:45.002", + "hpcc.log.sequence": "0000013A", + "kubernetes.pod.name": "myeclccserver-658575f5d9-5tbd9", + "hpcc.log.audience": "PRG", + "hpcc.log.class": "INF", + "hpcc.log.threadid": "9552", + "hpcc.log.jobid": "W20260318-215244" + }] + }, { + "fields": [{ + "hpcc.log.procid": "39", + "hpcc.log.message": "compile: Creating PIPE program process : 'kubectl replace --force -f -' - hasinput=1, hasoutput=1 stderrbufsize=1048576 [] in (.)", + "timestamp": "2026-03-18T21:52:45.013Z", + "kubernetes.container.name": "myeclccserver", + "hpcc.log.timestamp": "2026-03-18 21:52:45.013", + "hpcc.log.sequence": "0000013B", + "kubernetes.pod.name": "myeclccserver-658575f5d9-5tbd9", + "hpcc.log.audience": "USR", + "hpcc.log.class": "PRO", + "hpcc.log.threadid": "9552", + "hpcc.log.jobid": "W20260318-215244" + }] + }, { + "fields": [{ + "hpcc.log.procid": "39", + "hpcc.log.message": "compile: Pipe: process 33974 complete 0", + "timestamp": "2026-03-18T21:52:45.138Z", + "kubernetes.container.name": "myeclccserver", + "hpcc.log.timestamp": "2026-03-18 21:52:45.138", + "hpcc.log.sequence": "0000013C", + "kubernetes.pod.name": "myeclccserver-658575f5d9-5tbd9", + "hpcc.log.audience": "USR", + "hpcc.log.class": "PRO", + "hpcc.log.threadid": "9552", + "hpcc.log.jobid": "W20260318-215244" + }] + }, { + "fields": [{ + "hpcc.log.procid": "39", + "hpcc.log.message": "kubectl replace --force -f -: ret=0, stdout=job.batch/compile-job-eclcc-myeclccserver-658575f5d9-5tbd9-1 replaced", + "timestamp": "2026-03-18T21:52:45.138Z", + "kubernetes.container.name": "myeclccserver", + "hpcc.log.timestamp": "2026-03-18 21:52:45.138", + "hpcc.log.sequence": "0000013D", + "kubernetes.pod.name": "myeclccserver-658575f5d9-5tbd9", + "hpcc.log.audience": "PRG", + "hpcc.log.class": "INF", + "hpcc.log.threadid": "9552", + "hpcc.log.jobid": "W20260318-215244" + }] + }, { + "fields": [{ + "hpcc.log.procid": "39", + "hpcc.log.message": "compile: Creating PIPE program process : 'kubectl get jobs compile-job-eclcc-myeclccserver-658575f5d9-5tbd9-1 -o jsonpath={.status.active}' - hasinput=0, hasoutput=1 stderrbufsize=1048576 [] in (.)", + "timestamp": "2026-03-18T21:52:45.138Z", + "kubernetes.container.name": "myeclccserver", + "hpcc.log.timestamp": "2026-03-18 21:52:45.138", + "hpcc.log.sequence": "0000013E", + "kubernetes.pod.name": "myeclccserver-658575f5d9-5tbd9", + "hpcc.log.audience": "USR", + "hpcc.log.class": "PRO", + "hpcc.log.threadid": "9552", + "hpcc.log.jobid": "W20260318-215244" + }] + }, { + "fields": [{ + "hpcc.log.procid": "39", + "hpcc.log.message": "compile: Pipe: process 33990 complete 0", + "timestamp": "2026-03-18T21:52:45.202Z", + "kubernetes.container.name": "myeclccserver", + "hpcc.log.timestamp": "2026-03-18 21:52:45.202", + "hpcc.log.sequence": "0000013F", + "kubernetes.pod.name": "myeclccserver-658575f5d9-5tbd9", + "hpcc.log.audience": "USR", + "hpcc.log.class": "PRO", + "hpcc.log.threadid": "9552", + "hpcc.log.jobid": "W20260318-215244" + }] + }, { + "fields": [{ + "hpcc.log.procid": "39", + "hpcc.log.message": "kubectl get jobs compile-job-eclcc-myeclccserver-658575f5d9-5tbd9-1 -o jsonpath={.status.active}: ret=0, stdout=1", + "timestamp": "2026-03-18T21:52:45.202Z", + "kubernetes.container.name": "myeclccserver", + "hpcc.log.timestamp": "2026-03-18 21:52:45.202", + "hpcc.log.sequence": "00000140", + "kubernetes.pod.name": "myeclccserver-658575f5d9-5tbd9", + "hpcc.log.audience": "PRG", + "hpcc.log.class": "INF", + "hpcc.log.threadid": "9552", + "hpcc.log.jobid": "W20260318-215244" + }] + }, { + "fields": [{ + "hpcc.log.procid": "39", + "hpcc.log.message": "kubectl get pods --selector=job-name=compile-job-eclcc-myeclccserver-658575f5d9-5tbd9-1 --output=jsonpath={.items[*].status.conditions[?(@.type=='PodScheduled')].status}: ret=0, stdout=True", + "timestamp": "2026-03-18T21:52:45.266Z", + "kubernetes.container.name": "myeclccserver", + "hpcc.log.timestamp": "2026-03-18 21:52:45.266", + "hpcc.log.sequence": "00000141", + "kubernetes.pod.name": "myeclccserver-658575f5d9-5tbd9", + "hpcc.log.audience": "PRG", + "hpcc.log.class": "INF", + "hpcc.log.threadid": "9552", + "hpcc.log.jobid": "W20260318-215244" + }] + }, { + "fields": [{ + "hpcc.log.procid": "39", + "hpcc.log.message": "compile: Creating PIPE program process : 'kubectl get jobs compile-job-eclcc-myeclccserver-658575f5d9-5tbd9-1 -o jsonpath={.status.active}' - hasinput=0, hasoutput=1 stderrbufsize=1048576 [] in (.)", + "timestamp": "2026-03-18T21:52:45.367Z", + "kubernetes.container.name": "myeclccserver", + "hpcc.log.timestamp": "2026-03-18 21:52:45.367", + "hpcc.log.sequence": "00000142", + "kubernetes.pod.name": "myeclccserver-658575f5d9-5tbd9", + "hpcc.log.audience": "USR", + "hpcc.log.class": "PRO", + "hpcc.log.threadid": "9552", + "hpcc.log.jobid": "W20260318-215244" + }] + }, { + "fields": [{ + "hpcc.log.procid": "39", + "hpcc.log.message": "compile: Pipe: process 34021 complete 0", + "timestamp": "2026-03-18T21:52:45.428Z", + "kubernetes.container.name": "myeclccserver", + "hpcc.log.timestamp": "2026-03-18 21:52:45.428", + "hpcc.log.sequence": "00000143", + "kubernetes.pod.name": "myeclccserver-658575f5d9-5tbd9", + "hpcc.log.audience": "USR", + "hpcc.log.class": "PRO", + "hpcc.log.threadid": "9552", + "hpcc.log.jobid": "W20260318-215244" + }] + }] + }), + // TotalLogLinesAvailable and LogLineCount were taken from an actual GetLogs response, + // irrespective of how many lines are included in this mocked data + "LogLineCount": 88, + "TotalLogLinesAvailable": 88 +}; \ No newline at end of file diff --git a/packages/comms/tests/wsLogaccess.spec.ts b/packages/comms/tests/wsLogaccess.spec.ts new file mode 100644 index 0000000000..d2ea4af62a --- /dev/null +++ b/packages/comms/tests/wsLogaccess.spec.ts @@ -0,0 +1,104 @@ +import { describe, it, expect, vi } from "vitest"; +import { LogaccessService, WsLogaccess } from "../src/services/wsLogaccess.ts"; +import { elkInfo, elkLogs } from "./fixtures/logaccess.ts"; + +function makeService() { + const svc = new LogaccessService({ baseUrl: "http://localhost" }); + vi.spyOn(svc, "GetLogAccessInfo").mockResolvedValue(elkInfo as WsLogaccess.GetLogAccessInfoResponse); + vi.spyOn(svc, "GetLogs").mockResolvedValue(elkLogs as any); + return svc; +} + +describe("LogaccessService.GetLogsEx (ELK stack)", () => { + it("returns all lines and correct total from elasticstack fixture", async () => { + const result = await makeService().GetLogsEx({ LogLineStartFrom: 0, LogLineLimit: 100 }); + expect(result.total).toBe(88); + expect(result.lines).toHaveLength(10); + }); + + it("maps message field via column name", async () => { + const result = await makeService().GetLogsEx({ LogLineStartFrom: 0, LogLineLimit: 100 }); + expect(result.lines[0].message).toBe("Compile request processing for workunit W20260318-215244"); + }); + + it("maps workunits field via hpcc.log.jobid", async () => { + const result = await makeService().GetLogsEx({ LogLineStartFrom: 0, LogLineLimit: 100 }); + expect(result.lines[0].workunits).toBe("W20260318-215244"); + }); + + it("maps components field via kubernetes.container.name", async () => { + const result = await makeService().GetLogsEx({ LogLineStartFrom: 0, LogLineLimit: 100 }); + expect(result.lines[0].components).toBe("myeclccserver"); + }); + + it("maps audience and class fields", async () => { + const result = await makeService().GetLogsEx({ LogLineStartFrom: 0, LogLineLimit: 100 }); + expect(result.lines[0].audience).toBe("PRG"); + expect(result.lines[0].class).toBe("INF"); + }); + + it("returns empty lines for an unknown RemoteLogManagerType", async () => { + const svc = new LogaccessService({ baseUrl: "http://localhost" }); + vi.spyOn(svc, "GetLogAccessInfo").mockResolvedValue({ ...elkInfo, RemoteLogManagerType: "unknownengine" } as WsLogaccess.GetLogAccessInfoResponse); + vi.spyOn(svc, "GetLogs").mockResolvedValue(elkLogs as any); + const result = await svc.GetLogsEx({ LogLineStartFrom: 0, LogLineLimit: 100 }); + expect(result.lines).toHaveLength(0); + }); + + it("returns empty lines and zero total when LogLines JSON is malformed", async () => { + const svc = new LogaccessService({ baseUrl: "http://localhost" }); + vi.spyOn(svc, "GetLogAccessInfo").mockResolvedValue(elkInfo as WsLogaccess.GetLogAccessInfoResponse); + vi.spyOn(svc, "GetLogs").mockResolvedValue({ LogLines: "NOT VALID JSON", TotalLogLinesAvailable: 50 } as any); + const result = await svc.GetLogsEx({ LogLineStartFrom: 0, LogLineLimit: 100 }); + expect(result.lines).toHaveLength(0); + expect(result.total).toBe(0); + }); +}); + +describe("LogaccessService.GetLogsEx — filter construction", () => { + it("single class value produces a ByLogType leftFilter", async () => { + const svc = makeService(); + await svc.GetLogsEx({ class: ["INF"], LogLineStartFrom: 0, LogLineLimit: 100 }); + const filter = vi.mocked(svc.GetLogs).mock.calls[0][0].Filter!; + expect(filter.leftFilter).toMatchObject({ + LogCategory: WsLogaccess.LogAccessType.ByLogType, + SearchField: "class", + SearchByValue: "INF" + }); + expect(filter.Operator).toBeUndefined(); + expect(filter.rightFilter).toBeUndefined(); + }); + + it("two class values produce an OR BinaryLogFilter", async () => { + const svc = makeService(); + await svc.GetLogsEx({ class: ["INF", "PRO"], LogLineStartFrom: 0, LogLineLimit: 100 }); + const filter = vi.mocked(svc.GetLogs).mock.calls[0][0].Filter!; + const binary = filter.leftBinaryFilter?.BinaryLogFilter?.[0]!; + expect(binary).toBeDefined(); + expect(binary.leftFilter).toMatchObject({ + LogCategory: WsLogaccess.LogAccessType.ByLogType, + SearchField: "class", + SearchByValue: "INF" + }); + expect(binary.Operator).toBe(WsLogaccess.LogAccessFilterOperator.OR); + expect(binary.rightFilter).toMatchObject({ + LogCategory: WsLogaccess.LogAccessType.ByLogType, + SearchField: "class", + SearchByValue: "PRO" + }); + }); + + it("class and audience values produce an AND chain of two ByLogType filters", async () => { + const svc = makeService(); + await svc.GetLogsEx({ class: ["INF"], audience: "USR", LogLineStartFrom: 0, LogLineLimit: 100 }); + const filter = vi.mocked(svc.GetLogs).mock.calls[0][0].Filter!; + expect(filter.Operator).toBe(WsLogaccess.LogAccessFilterOperator.AND); + // one field on the left, the other on the right + const left = filter.leftFilter ?? filter.leftBinaryFilter?.BinaryLogFilter?.[0]?.leftFilter; + const right = filter.rightFilter ?? filter.rightBinaryFilter?.BinaryLogFilter?.[0]?.leftFilter; + const searchFields = new Set([left?.SearchField, right?.SearchField]); + expect(searchFields).toContain("class"); + expect(searchFields).toContain("audience"); + }); +}); +