diff --git a/oas_docs/output/kibana.serverless.yaml b/oas_docs/output/kibana.serverless.yaml index e4696e257d931..0c65621212b7b 100644 --- a/oas_docs/output/kibana.serverless.yaml +++ b/oas_docs/output/kibana.serverless.yaml @@ -62218,7 +62218,7 @@ paths: Refer to [Spaces](https://www.elastic.co/docs/deploy-manage/manage-spaces) for more information. - Get paginated per-agent action results for a specific scheduled query execution, with success/failure aggregation and execution metadata (pack name, query name/text, timestamp). + Get paginated per-agent action results for a specific scheduled query execution, with success/failure aggregation and execution metadata (pack name, query name/text, timestamp, and optionally queryInterval when the resultCountsEnabled experimental feature is enabled). operationId: OsqueryGetScheduledActionResults parameters: - description: The schedule ID of the scheduled query. @@ -62292,6 +62292,7 @@ paths: executionCount: 3 packId: 42ba9c50-0cc5-11ed-aa1d-2b27890bc90d packName: My Pack + queryInterval: 3600 queryName: uptime queryText: select * from uptime; scheduleId: pack_my_pack_uptime @@ -113529,6 +113530,11 @@ components: saved_query_id: 42ba9c50-0cc5-11ed-aa1d-2b27890bc90d status: completed successful: 0 + result_counts: + error_agents: 0 + responded_agents: 1 + successful_agents: 1 + total_rows: 42 status: completed user_id: elastic type: object @@ -113595,6 +113601,26 @@ components: description: Number of successful agents. type: integer type: array + result_counts: + description: | + Aggregated result count statistics for the query. May be omitted when aggregation data is unavailable. Single-query actions include responded_agents; pack actions include queries_with_results and queries_total instead. + type: object + properties: + error_agents: + type: integer + queries_total: + description: Total number of sub-queries in the pack (pack actions only). + type: integer + queries_with_results: + description: Number of sub-queries that returned at least one row (pack actions only). + type: integer + responded_agents: + description: Number of agents that responded (single-query actions only). + type: integer + successful_agents: + type: integer + total_rows: + type: integer status: description: Global status of the live query (completed, running). enum: @@ -113683,12 +113709,20 @@ components: type: string type: array result_counts: - description: Result count statistics (present when withResultCounts is true). + description: | + Result count statistics (present when withResultCounts is true). Single-query actions include responded_agents; pack actions include queries_with_results and queries_total instead. type: object properties: error_agents: type: integer + queries_total: + description: Total number of sub-queries in the pack (pack actions only). + type: integer + queries_with_results: + description: Number of sub-queries that returned at least one row (pack actions only). + type: integer responded_agents: + description: Number of agents that responded (single-query actions only). type: integer successful_agents: type: integer @@ -114105,6 +114139,7 @@ components: executionCount: 3 packId: 42ba9c50-0cc5-11ed-aa1d-2b27890bc90d packName: My Pack + queryInterval: 3600 queryName: uptime queryText: select * from uptime; scheduleId: pack_my_pack_uptime @@ -114424,6 +114459,10 @@ components: packName: description: The name of the pack containing the query. type: string + queryInterval: + description: | + Interval in seconds from the pack saved object query definition. Present only when the resultCountsEnabled experimental feature is enabled and the pack query defines an interval. + type: integer queryName: description: The name of the query within the pack. type: string diff --git a/oas_docs/output/kibana.yaml b/oas_docs/output/kibana.yaml index 4d9f56d69aad5..fc3e7d0441137 100644 --- a/oas_docs/output/kibana.yaml +++ b/oas_docs/output/kibana.yaml @@ -65485,7 +65485,7 @@ paths: Refer to [Spaces](https://www.elastic.co/docs/deploy-manage/manage-spaces) for more information. - Get paginated per-agent action results for a specific scheduled query execution, with success/failure aggregation and execution metadata (pack name, query name/text, timestamp). + Get paginated per-agent action results for a specific scheduled query execution, with success/failure aggregation and execution metadata (pack name, query name/text, timestamp, and optionally queryInterval when the resultCountsEnabled experimental feature is enabled). operationId: OsqueryGetScheduledActionResults parameters: - description: The schedule ID of the scheduled query. @@ -65559,6 +65559,7 @@ paths: executionCount: 3 packId: 42ba9c50-0cc5-11ed-aa1d-2b27890bc90d packName: My Pack + queryInterval: 3600 queryName: uptime queryText: select * from uptime; scheduleId: pack_my_pack_uptime @@ -125029,6 +125030,11 @@ components: saved_query_id: 42ba9c50-0cc5-11ed-aa1d-2b27890bc90d status: completed successful: 0 + result_counts: + error_agents: 0 + responded_agents: 1 + successful_agents: 1 + total_rows: 42 status: completed user_id: elastic type: object @@ -125095,6 +125101,26 @@ components: description: Number of successful agents. type: integer type: array + result_counts: + description: | + Aggregated result count statistics for the query. May be omitted when aggregation data is unavailable. Single-query actions include responded_agents; pack actions include queries_with_results and queries_total instead. + type: object + properties: + error_agents: + type: integer + queries_total: + description: Total number of sub-queries in the pack (pack actions only). + type: integer + queries_with_results: + description: Number of sub-queries that returned at least one row (pack actions only). + type: integer + responded_agents: + description: Number of agents that responded (single-query actions only). + type: integer + successful_agents: + type: integer + total_rows: + type: integer status: description: Global status of the live query (completed, running). enum: @@ -125183,12 +125209,20 @@ components: type: string type: array result_counts: - description: Result count statistics (present when withResultCounts is true). + description: | + Result count statistics (present when withResultCounts is true). Single-query actions include responded_agents; pack actions include queries_with_results and queries_total instead. type: object properties: error_agents: type: integer + queries_total: + description: Total number of sub-queries in the pack (pack actions only). + type: integer + queries_with_results: + description: Number of sub-queries that returned at least one row (pack actions only). + type: integer responded_agents: + description: Number of agents that responded (single-query actions only). type: integer successful_agents: type: integer @@ -125605,6 +125639,7 @@ components: executionCount: 3 packId: 42ba9c50-0cc5-11ed-aa1d-2b27890bc90d packName: My Pack + queryInterval: 3600 queryName: uptime queryText: select * from uptime; scheduleId: pack_my_pack_uptime @@ -125924,6 +125959,10 @@ components: packName: description: The name of the pack containing the query. type: string + queryInterval: + description: | + Interval in seconds from the pack saved object query definition. Present only when the resultCountsEnabled experimental feature is enabled and the pack query defines an interval. + type: integer queryName: description: The name of the query within the pack. type: string diff --git a/x-pack/platform/plugins/shared/osquery/common/api/live_query/find_live_query.gen.ts b/x-pack/platform/plugins/shared/osquery/common/api/live_query/find_live_query.gen.ts index 9e9e511d94174..241d902faa98f 100644 --- a/x-pack/platform/plugins/shared/osquery/common/api/live_query/find_live_query.gen.ts +++ b/x-pack/platform/plugins/shared/osquery/common/api/live_query/find_live_query.gen.ts @@ -82,14 +82,26 @@ export const FindLiveQueryResponse = z.object({ ) .optional(), /** - * Result count statistics (present when withResultCounts is true). - */ + * Result count statistics (present when withResultCounts is true). Single-query actions include responded_agents; pack actions include queries_with_results and queries_total instead. + + */ result_counts: z .object({ total_rows: z.number().int().optional(), + /** + * Number of agents that responded (single-query actions only). + */ responded_agents: z.number().int().optional(), successful_agents: z.number().int().optional(), error_agents: z.number().int().optional(), + /** + * Number of sub-queries that returned at least one row (pack actions only). + */ + queries_with_results: z.number().int().optional(), + /** + * Total number of sub-queries in the pack (pack actions only). + */ + queries_total: z.number().int().optional(), }) .optional(), }) @@ -119,6 +131,29 @@ export const FindLiveQueryDetailsResponse = z.object({ * Global status of the live query (completed, running). */ status: z.enum(['completed', 'running']).optional(), + /** + * Aggregated result count statistics for the query. May be omitted when aggregation data is unavailable. Single-query actions include responded_agents; pack actions include queries_with_results and queries_total instead. + + */ + result_counts: z + .object({ + total_rows: z.number().int().optional(), + /** + * Number of agents that responded (single-query actions only). + */ + responded_agents: z.number().int().optional(), + successful_agents: z.number().int().optional(), + error_agents: z.number().int().optional(), + /** + * Number of sub-queries that returned at least one row (pack actions only). + */ + queries_with_results: z.number().int().optional(), + /** + * Total number of sub-queries in the pack (pack actions only). + */ + queries_total: z.number().int().optional(), + }) + .optional(), /** * The queries with their execution status. */ diff --git a/x-pack/platform/plugins/shared/osquery/common/api/live_query/find_live_query.schema.yaml b/x-pack/platform/plugins/shared/osquery/common/api/live_query/find_live_query.schema.yaml index 0ee3ff10d5530..fb51a5464f374 100644 --- a/x-pack/platform/plugins/shared/osquery/common/api/live_query/find_live_query.schema.yaml +++ b/x-pack/platform/plugins/shared/osquery/common/api/live_query/find_live_query.schema.yaml @@ -84,16 +84,28 @@ components: type: string result_counts: type: object - description: 'Result count statistics (present when withResultCounts is true).' + description: > + Result count statistics (present when withResultCounts is true). + Single-query actions include responded_agents; pack actions include + queries_with_results and queries_total instead. properties: total_rows: type: integer responded_agents: + description: 'Number of agents that responded (single-query actions only).' type: integer successful_agents: type: integer error_agents: type: integer + queries_with_results: + description: 'Number of sub-queries that returned at least one row (pack actions only).' + type: integer + queries_total: + description: 'Total number of sub-queries in the pack (pack actions only).' + type: integer + # Single-query example shown below; for pack actions, result_counts uses + # queries_with_results and queries_total instead of responded_agents. example: data: total: 1 @@ -154,6 +166,29 @@ components: description: 'Global status of the live query (completed, running).' type: string enum: [completed, running] + result_counts: + type: object + description: > + Aggregated result count statistics for the query. May be omitted + when aggregation data is unavailable. Single-query actions include + responded_agents; pack actions include queries_with_results and + queries_total instead. + properties: + total_rows: + type: integer + responded_agents: + description: 'Number of agents that responded (single-query actions only).' + type: integer + successful_agents: + type: integer + error_agents: + type: integer + queries_with_results: + description: 'Number of sub-queries that returned at least one row (pack actions only).' + type: integer + queries_total: + description: 'Total number of sub-queries in the pack (pack actions only).' + type: integer queries: description: 'The queries with their execution status.' type: array @@ -193,6 +228,8 @@ components: description: 'Status of this individual query.' type: string enum: [completed, running] + # Single-query example shown below; for pack actions, result_counts uses + # queries_with_results and queries_total instead of responded_agents. example: data: action_id: "3c42c847-eb30-4452-80e0-728584042334" @@ -201,6 +238,11 @@ components: agents: [ "16d7caf5-efd2-4212-9b62-73dafc91fa13" ] user_id: "elastic" status: "completed" + result_counts: + total_rows: 42 + responded_agents: 1 + successful_agents: 1 + error_agents: 0 queries: - action_id: "609c4c66-ba3d-43fa-afdd-53e244577aa0" id: "6724a474-cbba-41ef-a1aa-66aebf0879e2" diff --git a/x-pack/platform/plugins/shared/osquery/common/api/scheduled_results/get_scheduled_action_results.gen.ts b/x-pack/platform/plugins/shared/osquery/common/api/scheduled_results/get_scheduled_action_results.gen.ts index 8ead460f5f114..e964f5dafee41 100644 --- a/x-pack/platform/plugins/shared/osquery/common/api/scheduled_results/get_scheduled_action_results.gen.ts +++ b/x-pack/platform/plugins/shared/osquery/common/api/scheduled_results/get_scheduled_action_results.gen.ts @@ -49,6 +49,11 @@ export const ScheduledExecutionMetadata = z.object({ * The timestamp of the most recent response for this execution. */ timestamp: z.string().optional(), + /** + * Interval in seconds from the pack saved object query definition. Present only when the resultCountsEnabled experimental feature is enabled and the pack query defines an interval. + + */ + queryInterval: z.number().int().optional(), }); export type ScheduledActionResultsAggregations = z.infer; diff --git a/x-pack/platform/plugins/shared/osquery/common/api/scheduled_results/get_scheduled_action_results.schema.yaml b/x-pack/platform/plugins/shared/osquery/common/api/scheduled_results/get_scheduled_action_results.schema.yaml index 695af36dfd03d..1166c2ab454fe 100644 --- a/x-pack/platform/plugins/shared/osquery/common/api/scheduled_results/get_scheduled_action_results.schema.yaml +++ b/x-pack/platform/plugins/shared/osquery/common/api/scheduled_results/get_scheduled_action_results.schema.yaml @@ -30,6 +30,12 @@ components: timestamp: description: 'The timestamp of the most recent response for this execution.' type: string + queryInterval: + description: > + Interval in seconds from the pack saved object query definition. + Present only when the resultCountsEnabled experimental feature is enabled + and the pack query defines an interval. + type: integer ScheduledActionResultsAggregations: type: object @@ -86,6 +92,7 @@ components: queryName: 'uptime' queryText: 'select * from uptime;' timestamp: '2024-07-26T09:00:00.000Z' + queryInterval: 3600 edges: - _id: 'result-001' fields: diff --git a/x-pack/platform/plugins/shared/osquery/common/api/scheduled_results/scheduled_results.schema.yaml b/x-pack/platform/plugins/shared/osquery/common/api/scheduled_results/scheduled_results.schema.yaml index 8ec3cbd5e53c5..c72da969a5609 100644 --- a/x-pack/platform/plugins/shared/osquery/common/api/scheduled_results/scheduled_results.schema.yaml +++ b/x-pack/platform/plugins/shared/osquery/common/api/scheduled_results/scheduled_results.schema.yaml @@ -9,7 +9,8 @@ paths: description: > Get paginated per-agent action results for a specific scheduled query execution, with success/failure aggregation and execution metadata - (pack name, query name/text, timestamp). + (pack name, query name/text, timestamp, and optionally queryInterval when + the resultCountsEnabled experimental feature is enabled). operationId: OsqueryGetScheduledActionResults x-codegen-enabled: true x-labels: [ serverless, ess ] @@ -80,6 +81,7 @@ paths: queryName: 'uptime' queryText: 'select * from uptime;' timestamp: '2024-07-26T09:00:00.000Z' + queryInterval: 3600 edges: - _id: 'result-001' fields: diff --git a/x-pack/platform/plugins/shared/osquery/common/experimental_features.ts b/x-pack/platform/plugins/shared/osquery/common/experimental_features.ts index d33ec571abc05..e3019c00927f6 100644 --- a/x-pack/platform/plugins/shared/osquery/common/experimental_features.ts +++ b/x-pack/platform/plugins/shared/osquery/common/experimental_features.ts @@ -25,6 +25,12 @@ export const allowedExperimentalValues = Object.freeze({ * adding KQL search, document flyout, per-row actions, and column curation. */ unifiedDataTable: true, + /** + * Enables result_counts aggregation in the live query details and list API + * responses, providing total rows, agent success/error breakdowns. Required + * by the upcoming "About" tab on the query details page. + */ + resultCountsEnabled: false, }); type ExperimentalFeatures = { [K in keyof typeof allowedExperimentalValues]: boolean }; diff --git a/x-pack/platform/plugins/shared/osquery/docs/openapi/ess/osquery_api_2023_10_31.bundled.schema.yaml b/x-pack/platform/plugins/shared/osquery/docs/openapi/ess/osquery_api_2023_10_31.bundled.schema.yaml index 20b43173c95c1..5fc611a41f52e 100644 --- a/x-pack/platform/plugins/shared/osquery/docs/openapi/ess/osquery_api_2023_10_31.bundled.schema.yaml +++ b/x-pack/platform/plugins/shared/osquery/docs/openapi/ess/osquery_api_2023_10_31.bundled.schema.yaml @@ -640,7 +640,8 @@ paths: description: > Get paginated per-agent action results for a specific scheduled query execution, with success/failure aggregation and execution metadata (pack - name, query name/text, timestamp). + name, query name/text, timestamp, and optionally queryInterval when the + resultCountsEnabled experimental feature is enabled). operationId: OsqueryGetScheduledActionResults parameters: - description: The schedule ID of the scheduled query. @@ -714,6 +715,7 @@ paths: executionCount: 3 packId: 42ba9c50-0cc5-11ed-aa1d-2b27890bc90d packName: My Pack + queryInterval: 3600 queryName: uptime queryText: select * from uptime; scheduleId: pack_my_pack_uptime @@ -1497,6 +1499,11 @@ components: saved_query_id: 42ba9c50-0cc5-11ed-aa1d-2b27890bc90d status: completed successful: 0 + result_counts: + error_agents: 0 + responded_agents: 1 + successful_agents: 1 + total_rows: 42 status: completed user_id: elastic type: object @@ -1563,6 +1570,31 @@ components: description: Number of successful agents. type: integer type: array + result_counts: + description: > + Aggregated result count statistics for the query. May be omitted + when aggregation data is unavailable. Single-query actions + include responded_agents; pack actions include + queries_with_results and queries_total instead. + type: object + properties: + error_agents: + type: integer + queries_total: + description: Total number of sub-queries in the pack (pack actions only). + type: integer + queries_with_results: + description: >- + Number of sub-queries that returned at least one row (pack + actions only). + type: integer + responded_agents: + description: Number of agents that responded (single-query actions only). + type: integer + successful_agents: + type: integer + total_rows: + type: integer status: description: Global status of the live query (completed, running). enum: @@ -1651,14 +1683,29 @@ components: type: string type: array result_counts: - description: >- + description: > Result count statistics (present when withResultCounts - is true). + is true). Single-query actions include + responded_agents; pack actions include + queries_with_results and queries_total instead. type: object properties: error_agents: type: integer + queries_total: + description: >- + Total number of sub-queries in the pack (pack + actions only). + type: integer + queries_with_results: + description: >- + Number of sub-queries that returned at least one + row (pack actions only). + type: integer responded_agents: + description: >- + Number of agents that responded (single-query + actions only). type: integer successful_agents: type: integer @@ -2079,6 +2126,7 @@ components: executionCount: 3 packId: 42ba9c50-0cc5-11ed-aa1d-2b27890bc90d packName: My Pack + queryInterval: 3600 queryName: uptime queryText: select * from uptime; scheduleId: pack_my_pack_uptime @@ -2407,6 +2455,12 @@ components: packName: description: The name of the pack containing the query. type: string + queryInterval: + description: > + Interval in seconds from the pack saved object query definition. + Present only when the resultCountsEnabled experimental feature is + enabled and the pack query defines an interval. + type: integer queryName: description: The name of the query within the pack. type: string diff --git a/x-pack/platform/plugins/shared/osquery/docs/openapi/serverless/osquery_api_2023_10_31.bundled.schema.yaml b/x-pack/platform/plugins/shared/osquery/docs/openapi/serverless/osquery_api_2023_10_31.bundled.schema.yaml index b006fce91c201..9f37ecc3a2762 100644 --- a/x-pack/platform/plugins/shared/osquery/docs/openapi/serverless/osquery_api_2023_10_31.bundled.schema.yaml +++ b/x-pack/platform/plugins/shared/osquery/docs/openapi/serverless/osquery_api_2023_10_31.bundled.schema.yaml @@ -640,7 +640,8 @@ paths: description: > Get paginated per-agent action results for a specific scheduled query execution, with success/failure aggregation and execution metadata (pack - name, query name/text, timestamp). + name, query name/text, timestamp, and optionally queryInterval when the + resultCountsEnabled experimental feature is enabled). operationId: OsqueryGetScheduledActionResults parameters: - description: The schedule ID of the scheduled query. @@ -714,6 +715,7 @@ paths: executionCount: 3 packId: 42ba9c50-0cc5-11ed-aa1d-2b27890bc90d packName: My Pack + queryInterval: 3600 queryName: uptime queryText: select * from uptime; scheduleId: pack_my_pack_uptime @@ -1497,6 +1499,11 @@ components: saved_query_id: 42ba9c50-0cc5-11ed-aa1d-2b27890bc90d status: completed successful: 0 + result_counts: + error_agents: 0 + responded_agents: 1 + successful_agents: 1 + total_rows: 42 status: completed user_id: elastic type: object @@ -1563,6 +1570,31 @@ components: description: Number of successful agents. type: integer type: array + result_counts: + description: > + Aggregated result count statistics for the query. May be omitted + when aggregation data is unavailable. Single-query actions + include responded_agents; pack actions include + queries_with_results and queries_total instead. + type: object + properties: + error_agents: + type: integer + queries_total: + description: Total number of sub-queries in the pack (pack actions only). + type: integer + queries_with_results: + description: >- + Number of sub-queries that returned at least one row (pack + actions only). + type: integer + responded_agents: + description: Number of agents that responded (single-query actions only). + type: integer + successful_agents: + type: integer + total_rows: + type: integer status: description: Global status of the live query (completed, running). enum: @@ -1651,14 +1683,29 @@ components: type: string type: array result_counts: - description: >- + description: > Result count statistics (present when withResultCounts - is true). + is true). Single-query actions include + responded_agents; pack actions include + queries_with_results and queries_total instead. type: object properties: error_agents: type: integer + queries_total: + description: >- + Total number of sub-queries in the pack (pack + actions only). + type: integer + queries_with_results: + description: >- + Number of sub-queries that returned at least one + row (pack actions only). + type: integer responded_agents: + description: >- + Number of agents that responded (single-query + actions only). type: integer successful_agents: type: integer @@ -2079,6 +2126,7 @@ components: executionCount: 3 packId: 42ba9c50-0cc5-11ed-aa1d-2b27890bc90d packName: My Pack + queryInterval: 3600 queryName: uptime queryText: select * from uptime; scheduleId: pack_my_pack_uptime @@ -2407,6 +2455,12 @@ components: packName: description: The name of the pack containing the query. type: string + queryInterval: + description: > + Interval in seconds from the pack saved object query definition. + Present only when the resultCountsEnabled experimental feature is + enabled and the pack query defines an interval. + type: integer queryName: description: The name of the query within the pack. type: string diff --git a/x-pack/platform/plugins/shared/osquery/public/actions/use_live_query_details.ts b/x-pack/platform/plugins/shared/osquery/public/actions/use_live_query_details.ts index 02defc4c54db3..c7cbd450a8952 100644 --- a/x-pack/platform/plugins/shared/osquery/public/actions/use_live_query_details.ts +++ b/x-pack/platform/plugins/shared/osquery/public/actions/use_live_query_details.ts @@ -33,8 +33,26 @@ export interface PackQueriesQuery { saved_query_id?: string; expiration?: string; timeout?: number; + interval?: number | string; } +export interface SingleQueryResultCounts { + total_rows: number; + responded_agents: number; + successful_agents: number; + error_agents: number; +} + +export interface PackResultCounts { + total_rows: number; + queries_with_results: number; + queries_total: number; + successful_agents: number; + error_agents: number; +} + +export type ResultCounts = SingleQueryResultCounts | PackResultCounts; + export interface LiveQueryDetailsItem { action_id: string; expiration?: string; @@ -45,12 +63,14 @@ export interface LiveQueryDetailsItem { agent_policy_ids: string[]; agents?: string[]; user_id?: string; + user_profile_uid?: string; pack_id?: string; pack_name?: string; pack_prebuilt?: boolean; tags?: string[]; status?: string; queries?: PackQueriesQuery[]; + result_counts?: ResultCounts; } export const useLiveQueryDetails = ({ diff --git a/x-pack/platform/plugins/shared/osquery/public/actions/use_scheduled_execution_details.ts b/x-pack/platform/plugins/shared/osquery/public/actions/use_scheduled_execution_details.ts index 5020be52ef4a5..bc650b223931d 100644 --- a/x-pack/platform/plugins/shared/osquery/public/actions/use_scheduled_execution_details.ts +++ b/x-pack/platform/plugins/shared/osquery/public/actions/use_scheduled_execution_details.ts @@ -19,6 +19,7 @@ export interface ScheduledExecutionDetailsItem { queryName: string; queryText: string; timestamp: string; + queryInterval?: number; agentCount: number; successCount: number; errorCount: number; @@ -51,6 +52,7 @@ interface ScheduledActionResultsResponse { queryName: string; queryText: string; timestamp: string; + queryInterval?: number; }; aggregations: { totalRowCount: number; diff --git a/x-pack/platform/plugins/shared/osquery/public/live_queries/form/pack_queries_status_table.tsx b/x-pack/platform/plugins/shared/osquery/public/live_queries/form/pack_queries_status_table.tsx index a1f368144bb1a..34008812d72ca 100644 --- a/x-pack/platform/plugins/shared/osquery/public/live_queries/form/pack_queries_status_table.tsx +++ b/x-pack/platform/plugins/shared/osquery/public/live_queries/form/pack_queries_status_table.tsx @@ -23,6 +23,7 @@ import { import type { UseEuiTheme } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import type { ECSMapping } from '@kbn/osquery-io-ts-types'; +import type { ReactNode } from 'react'; import { QueryDetailsFlyout } from './query_details_flyout'; import { PackResultsHeader } from './pack_results_header'; import { Direction } from '../../../common/search_strategy'; @@ -113,7 +114,7 @@ const DocsColumnResults: React.FC = ({ count, isLive }) ); -interface AgentsColumnResultsProps { +export interface AgentsColumnResultsProps { successful?: number; pending?: number; failed?: number; @@ -121,7 +122,7 @@ interface AgentsColumnResultsProps { const agentsSeparatorCss = ({ euiTheme }: UseEuiTheme) => ({ color: euiTheme.colors.subduedText }); -const AgentsColumnResults: React.FC = ({ +export const AgentsColumnResults: React.FC = ({ successful, pending, failed, @@ -174,6 +175,7 @@ interface PackQueriesStatusTableProps { packName?: string; tags?: string[]; onSaveQuery?: () => void; + renderAboutTab?: (queryItem: PackQueryStatusItem) => ReactNode; } const PackQueriesStatusTableComponent: React.FC = ({ @@ -190,6 +192,7 @@ const PackQueriesStatusTableComponent: React.FC = ( packName, tags, onSaveQuery, + renderAboutTab, }) => { const isHistoryEnabled = useIsExperimentalFeatureEnabled('queryHistoryRework'); const [queryDetailsFlyoutOpen, setQueryDetailsFlyoutOpen] = useState<{ @@ -349,6 +352,7 @@ const PackQueriesStatusTableComponent: React.FC = ( addToTimeline={addToTimeline} scheduleId={scheduleId} executionCount={executionCount} + aboutTab={renderAboutTab?.(item)} /> @@ -358,7 +362,16 @@ const PackQueriesStatusTableComponent: React.FC = ( return itemIdToExpandedRowMapValues; }); }, - [actionId, startDate, expirationDate, agentIds, addToTimeline, scheduleId, executionCount] + [ + actionId, + startDate, + expirationDate, + agentIds, + addToTimeline, + scheduleId, + executionCount, + renderAboutTab, + ] ); const renderToggleResultsAction = useCallback( @@ -648,10 +661,55 @@ const PackQueriesStatusTableComponent: React.FC = ( ); useEffect(() => { - // reset the expanded row map when the data changes setItemIdToExpandedRowMap({}); }, [queryId, actionId]); + useEffect(() => { + setItemIdToExpandedRowMap((prevMap) => { + const expandedIds = Object.keys(prevMap); + if (!expandedIds.length || !data) return prevMap; + + const updated: Record = {}; + for (const id of expandedIds) { + const item = data.find((q) => q.id === id); + if (item) { + updated[id] = ( + + + + + + ); + } + } + + return updated; + }); + }, [ + renderAboutTab, + data, + actionId, + startDate, + expirationDate, + agentIds, + addToTimeline, + scheduleId, + executionCount, + ]); + useEffect(() => { const shouldAutoExpand = data?.length === 1 && diff --git a/x-pack/platform/plugins/shared/osquery/public/routes/history/scheduled_execution_details.tsx b/x-pack/platform/plugins/shared/osquery/public/routes/history/scheduled_execution_details.tsx index 86d2dc1458baa..de66266cef788 100644 --- a/x-pack/platform/plugins/shared/osquery/public/routes/history/scheduled_execution_details.tsx +++ b/x-pack/platform/plugins/shared/osquery/public/routes/history/scheduled_execution_details.tsx @@ -32,11 +32,41 @@ import { mapScheduledDetailsToQueryData, } from '../../actions/use_scheduled_execution_details'; import { PackQueriesStatusTable } from '../../live_queries/form/pack_queries_status_table'; +import { AboutTab } from '../live_queries/details/about_tab'; +import type { QueryItemAgents } from '../live_queries/details/about_tab'; +import type { LiveQueryDetailsItem } from '../../actions/use_live_query_details'; +import type { ScheduledExecutionDetailsItem } from '../../actions/use_scheduled_execution_details'; const tableWrapperCss = { paddingLeft: '10px', }; +function mapToLiveQueryDetailsItem( + details: ScheduledExecutionDetailsItem, + schedId: string +): LiveQueryDetailsItem { + return { + action_id: schedId, + '@timestamp': details.timestamp, + agent_all: false, + agent_ids: [], + agent_platforms: [], + agent_policy_ids: [], + pack_id: details.packId, + pack_name: details.packName, + status: 'completed', + queries: [ + { + action_id: schedId, + id: details.queryName || schedId, + query: details.queryText || '', + agents: [], + interval: details.queryInterval, + }, + ], + }; +} + const ScheduledExecutionDetailsPageComponent = () => { const isHistoryEnabled = useIsExperimentalFeatureEnabled('queryHistoryRework'); const { scheduleId, executionCount: executionCountStr } = useParams<{ @@ -62,6 +92,31 @@ const ScheduledExecutionDetailsPageComponent = () => { skip: !isValid, }); + const isResultCountsEnabled = useIsExperimentalFeatureEnabled('resultCountsEnabled'); + + const aboutData = useMemo( + () => (data ? mapToLiveQueryDetailsItem(data, scheduleId) : undefined), + [data, scheduleId] + ); + + const renderAboutTab = useMemo(() => { + if (!isResultCountsEnabled || !aboutData) { + return undefined; + } + + const AboutTabRenderer = (queryItem: QueryItemAgents) => ( + + ); + AboutTabRenderer.displayName = 'AboutTabRenderer'; + + return AboutTabRenderer; + }, [isResultCountsEnabled, aboutData, executionCount]); + const queryData = useMemo( () => (data ? mapScheduledDetailsToQueryData(data, scheduleId) : undefined), [data, scheduleId] @@ -109,6 +164,7 @@ const ScheduledExecutionDetailsPageComponent = () => { scheduleId={scheduleId} executionCount={executionCount} packName={data?.packName} + renderAboutTab={renderAboutTab} /> ); diff --git a/x-pack/platform/plugins/shared/osquery/public/routes/live_queries/details/about_card.test.tsx b/x-pack/platform/plugins/shared/osquery/public/routes/live_queries/details/about_card.test.tsx new file mode 100644 index 0000000000000..0a2e1cdd5e91d --- /dev/null +++ b/x-pack/platform/plugins/shared/osquery/public/routes/live_queries/details/about_card.test.tsx @@ -0,0 +1,67 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { render, screen } from '@testing-library/react'; + +import { AboutCard } from './about_card'; +import { TestProviders } from '../../../actions/__test_helpers__/mock_data'; + +const renderWithProviders = (element: React.ReactElement) => + render(element, { wrapper: TestProviders }); + +describe('AboutCard', () => { + it('renders title and description list items', () => { + renderWithProviders( + + ); + + expect(screen.getByTestId('test-card')).toBeInTheDocument(); + expect(screen.getByText('Test Card')).toBeInTheDocument(); + expect(screen.getByText('Label 1')).toBeInTheDocument(); + expect(screen.getByText('Value 1')).toBeInTheDocument(); + expect(screen.getByText('Label 2')).toBeInTheDocument(); + expect(screen.getByText('Value 2')).toBeInTheDocument(); + }); + + it('renders ReactNode descriptions', () => { + renderWithProviders( + Active, + }, + ]} + /> + ); + + expect(screen.getByTestId('custom-node')).toBeInTheDocument(); + expect(screen.getByText('Active')).toBeInTheDocument(); + }); + + it('renders titleExtra when provided', () => { + renderWithProviders( + Edit} + /> + ); + + expect(screen.getByTestId('edit-btn')).toBeInTheDocument(); + expect(screen.getByText('Tags')).toBeInTheDocument(); + }); +}); diff --git a/x-pack/platform/plugins/shared/osquery/public/routes/live_queries/details/about_card.tsx b/x-pack/platform/plugins/shared/osquery/public/routes/live_queries/details/about_card.tsx new file mode 100644 index 0000000000000..dababae11e32a --- /dev/null +++ b/x-pack/platform/plugins/shared/osquery/public/routes/live_queries/details/about_card.tsx @@ -0,0 +1,61 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useMemo } from 'react'; +import type { ReactNode } from 'react'; +import { EuiDescriptionList, EuiHorizontalRule, EuiPanel, EuiTitle } from '@elastic/eui'; + +export interface AboutCardItem { + title: string; + description: NonNullable; +} + +interface AboutCardProps { + title: string; + items: AboutCardItem[]; + 'data-test-subj'?: string; + titleExtra?: ReactNode; +} + +const titleExtraWrapperCss = { + display: 'flex', + justifyContent: 'space-between', + alignItems: 'center', +}; + +const AboutCardComponent: React.FC = ({ + title, + items, + 'data-test-subj': dataTestSubj, + titleExtra, +}) => { + const listItems = useMemo( + () => items.map((item) => ({ title: item.title, description: item.description })), + [items] + ); + + return ( + + {titleExtra ? ( +
+ +

{title}

+
+ {titleExtra} +
+ ) : ( + +

{title}

+
+ )} + + +
+ ); +}; + +export const AboutCard = React.memo(AboutCardComponent); diff --git a/x-pack/platform/plugins/shared/osquery/public/routes/live_queries/details/about_tab.test.tsx b/x-pack/platform/plugins/shared/osquery/public/routes/live_queries/details/about_tab.test.tsx new file mode 100644 index 0000000000000..df0faaa81e7c9 --- /dev/null +++ b/x-pack/platform/plugins/shared/osquery/public/routes/live_queries/details/about_tab.test.tsx @@ -0,0 +1,265 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { render, screen, fireEvent } from '@testing-library/react'; +import type { UserProfileWithAvatar } from '@kbn/user-profile-components'; + +import { AboutTab } from './about_tab'; +import type { QueryItemAgents } from './about_tab'; +import { useGenericBulkGetUserProfiles } from '../../../common/use_bulk_get_user_profiles'; +import type { LiveQueryDetailsItem } from '../../../actions/use_live_query_details'; +import { TestProviders } from '../../../actions/__test_helpers__/mock_data'; + +jest.mock('../../../common/use_bulk_get_user_profiles'); + +const useGenericBulkGetUserProfilesMock = useGenericBulkGetUserProfiles as jest.MockedFunction< + typeof useGenericBulkGetUserProfiles +>; + +const renderWithProviders = (element: React.ReactElement) => + render(element, { wrapper: TestProviders }); + +const createMockData = (overrides?: Partial): LiveQueryDetailsItem => ({ + action_id: 'test-action-1', + '@timestamp': '2025-12-05T14:30:00.000Z', + agent_all: false, + agent_ids: ['agent-1', 'agent-2'], + agent_platforms: [], + agent_policy_ids: [], + agents: ['agent-1', 'agent-2'], + user_id: 'elastic', + status: 'completed', + queries: [ + { + action_id: 'test-action-1', + id: 'suspicious_processes_linux', + query: 'SELECT pid, name, path FROM processes WHERE on_disk = 0', + agents: ['agent-1', 'agent-2'], + }, + ], + ...overrides, +}); + +const mockQueryItemAgents: QueryItemAgents = { + successful: 2, + pending: 0, + failed: 0, + docs: 42, +}; + +describe('AboutTab', () => { + beforeEach(() => { + jest.clearAllMocks(); + useGenericBulkGetUserProfilesMock.mockReturnValue({ + profilesMap: new Map(), + isLoading: false, + }); + }); + + it('renders the query card with query text', () => { + renderWithProviders(); + + expect(screen.getByTestId('osquery-about-tab-query-card')).toBeInTheDocument(); + expect(screen.getByTestId('osquery-about-tab-query-code')).toHaveTextContent( + 'SELECT pid, name, path FROM processes WHERE on_disk = 0' + ); + }); + + it('renders the query ID in a copyable code block', () => { + renderWithProviders(); + + const queryIdBlock = screen.getByTestId('osquery-about-tab-query-id'); + expect(queryIdBlock).toBeInTheDocument(); + expect(queryIdBlock).toHaveTextContent('suspicious_processes_linux'); + }); + + it('does not render the query ID when query has no id', () => { + const data = createMockData({ + queries: [ + { + action_id: 'test-action-1', + id: '', + query: 'SELECT 1', + agents: ['agent-1'], + }, + ], + }); + + renderWithProviders(); + + expect(screen.queryByTestId('osquery-about-tab-query-id')).not.toBeInTheDocument(); + }); + + it('renders the about card with Created at and Run at fields', () => { + renderWithProviders(); + + expect(screen.getByTestId('osquery-about-tab-about-card')).toBeInTheDocument(); + expect(screen.getByText('Created at')).toBeInTheDocument(); + expect(screen.getByText('Run at')).toBeInTheDocument(); + }); + + it('does not render a Status field in the about card', () => { + renderWithProviders(); + + expect(screen.queryByText('Status')).not.toBeInTheDocument(); + }); + + it('renders rows and agents from queryItemAgents', () => { + renderWithProviders(); + + expect(screen.getByText('Rows')).toBeInTheDocument(); + expect(screen.getByText('42')).toBeInTheDocument(); + expect(screen.getByText('Agents')).toBeInTheDocument(); + }); + + it('does not render rows/agents when queryItemAgents is not provided', () => { + renderWithProviders(); + + expect(screen.queryByText('Rows')).not.toBeInTheDocument(); + expect(screen.queryByText('Agents')).not.toBeInTheDocument(); + }); + + it('renders the run by column with user profile', () => { + const mockProfile: UserProfileWithAvatar = { + uid: 'profile-uid-1', + enabled: true, + user: { username: 'admin', full_name: 'Admin User' }, + data: { avatar: {} }, + }; + + useGenericBulkGetUserProfilesMock.mockReturnValue({ + profilesMap: new Map([['profile-uid-1', mockProfile]]), + isLoading: false, + }); + + const data = createMockData({ user_profile_uid: 'profile-uid-1' }); + + renderWithProviders(); + + expect(screen.getByText('Run by')).toBeInTheDocument(); + expect(screen.getByText('Admin User')).toBeInTheDocument(); + }); + + it('renders fallback for run by when no profile is available', () => { + const data = createMockData({ user_id: 'elastic', user_profile_uid: undefined }); + + renderWithProviders(); + + expect(screen.getByText('elastic')).toBeInTheDocument(); + }); + + it('does not render schedule card for non-scheduled, non-pack queries', () => { + renderWithProviders(); + + expect(screen.queryByTestId('osquery-about-tab-schedule-card')).not.toBeInTheDocument(); + }); + + it('renders schedule card when isScheduled is true', () => { + const data = createMockData({ + queries: [ + { + action_id: 'test-action-1', + id: 'q1', + query: 'SELECT 1', + agents: ['agent-1'], + interval: 3600, + }, + ], + }); + + renderWithProviders(); + + expect(screen.getByTestId('osquery-about-tab-schedule-card')).toBeInTheDocument(); + expect(screen.getByText('1h')).toBeInTheDocument(); + }); + + it('does not render schedule card for live pack queries without isScheduled', () => { + const data = createMockData({ + pack_id: 'my-pack-123', + queries: [ + { + action_id: 'test-action-1', + id: 'q1', + query: 'SELECT 1', + agents: ['agent-1'], + interval: 1800, + }, + ], + }); + + renderWithProviders(); + + expect(screen.queryByTestId('osquery-about-tab-schedule-card')).not.toBeInTheDocument(); + }); + + it('renders execution count only for scheduled queries', () => { + renderWithProviders(); + + expect(screen.getByText('Execution count')).toBeInTheDocument(); + expect(screen.getByText('647')).toBeInTheDocument(); + }); + + it('does not render execution count for non-scheduled queries', () => { + renderWithProviders(); + + expect(screen.queryByText('Execution count')).not.toBeInTheDocument(); + }); + + it('renders tags when present for non-scheduled queries', () => { + const data = createMockData({ tags: ['endpoint', 'forensic', 'linux'] }); + + renderWithProviders(); + + expect(screen.getByTestId('osquery-about-tab-tags-card')).toBeInTheDocument(); + expect(screen.getByText('endpoint')).toBeInTheDocument(); + expect(screen.getByText('forensic')).toBeInTheDocument(); + expect(screen.getByText('linux')).toBeInTheDocument(); + }); + + it('renders "No tags added" for empty tags', () => { + const data = createMockData({ tags: [] }); + + renderWithProviders(); + + expect(screen.getByTestId('osquery-about-tab-tags-card')).toBeInTheDocument(); + expect(screen.getByText('No tags added')).toBeInTheDocument(); + }); + + it('does not render tags card for scheduled queries', () => { + const data = createMockData({ tags: ['endpoint'] }); + + renderWithProviders(); + + expect(screen.queryByTestId('osquery-about-tab-tags-card')).not.toBeInTheDocument(); + }); + + it('renders edit tags button when onEditTags is provided', () => { + const handleEditTags = jest.fn(); + renderWithProviders(); + + const editButton = screen.getByTestId('osquery-about-tab-edit-tags'); + expect(editButton).toBeInTheDocument(); + fireEvent.click(editButton); + expect(handleEditTags).toHaveBeenCalledTimes(1); + }); + + it('does not render edit tags button when onEditTags is not provided', () => { + renderWithProviders(); + + expect(screen.queryByTestId('osquery-about-tab-edit-tags')).not.toBeInTheDocument(); + }); + + it('handles missing queries gracefully', () => { + const data = createMockData({ queries: undefined }); + + renderWithProviders(); + + expect(screen.getByTestId('osquery-about-tab-query-card')).toBeInTheDocument(); + expect(screen.queryByTestId('osquery-about-tab-query-id')).not.toBeInTheDocument(); + }); +}); diff --git a/x-pack/platform/plugins/shared/osquery/public/routes/live_queries/details/about_tab.tsx b/x-pack/platform/plugins/shared/osquery/public/routes/live_queries/details/about_tab.tsx new file mode 100644 index 0000000000000..1f5d29024cfa7 --- /dev/null +++ b/x-pack/platform/plugins/shared/osquery/public/routes/live_queries/details/about_tab.tsx @@ -0,0 +1,348 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useMemo } from 'react'; +import { + EuiFlexGroup, + EuiFlexItem, + EuiCodeBlock, + EuiHorizontalRule, + EuiSpacer, + EuiPanel, + EuiTitle, + EuiBadge, + EuiBadgeGroup, + EuiButtonIcon, + EuiText, + formatDate, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import moment from 'moment-timezone'; + +import type { LiveQueryDetailsItem } from '../../../actions/use_live_query_details'; +import { AboutCard } from './about_card'; +import type { AboutCardItem } from './about_card'; +import type { AgentsColumnResultsProps } from '../../../live_queries/form/pack_queries_status_table'; +import { RunByColumn } from '../../../actions/components/run_by_column'; +import { useGenericBulkGetUserProfiles } from '../../../common/use_bulk_get_user_profiles'; + +const QUERY_CARD_TITLE = i18n.translate('xpack.osquery.aboutTab.queryCardTitle', { + defaultMessage: 'Query', +}); + +const ABOUT_CARD_TITLE = i18n.translate('xpack.osquery.aboutTab.aboutCardTitle', { + defaultMessage: 'About', +}); + +const SCHEDULE_CARD_TITLE = i18n.translate('xpack.osquery.aboutTab.scheduleCardTitle', { + defaultMessage: 'Schedule', +}); + +const TAGS_CARD_TITLE = i18n.translate('xpack.osquery.aboutTab.tagsCardTitle', { + defaultMessage: 'Tags', +}); + +const ROWS_LABEL = i18n.translate('xpack.osquery.aboutTab.rowsLabel', { + defaultMessage: 'Rows', +}); + +const AGENTS_LABEL = i18n.translate('xpack.osquery.aboutTab.agentsLabel', { + defaultMessage: 'Agents', +}); + +const CREATED_AT_LABEL = i18n.translate('xpack.osquery.aboutTab.createdAtLabel', { + defaultMessage: 'Created at', +}); + +const RUN_AT_LABEL = i18n.translate('xpack.osquery.aboutTab.runAtLabel', { + defaultMessage: 'Run at', +}); + +const RUN_BY_LABEL = i18n.translate('xpack.osquery.aboutTab.runByLabel', { + defaultMessage: 'Run by', +}); + +const REOCCURRENCE_LABEL = i18n.translate('xpack.osquery.aboutTab.reoccurrenceLabel', { + defaultMessage: 'Reoccurrence', +}); + +const INTERVAL_LABEL = i18n.translate('xpack.osquery.aboutTab.intervalLabel', { + defaultMessage: 'Interval', +}); + +const INTERVALS_LABEL = i18n.translate('xpack.osquery.aboutTab.intervalsLabel', { + defaultMessage: 'Intervals', +}); + +const DASH = '\u2014'; + +const aboutTabContentCss = { padding: 16 }; +const agentBadgeCss = { padding: '0 8px' }; +const tagsHeaderCss = { + display: 'flex', + justifyContent: 'space-between', + alignItems: 'center', +}; + +const AgentsBadges: React.FC = ({ successful, pending, failed }) => ( + + + + {successful} + + + + + {pending} + + + + + {failed} + + + +); + +export interface QueryItemAgents { + successful?: number; + pending?: number; + failed?: number; + docs?: number; + interval?: number | string; + id?: string; + query?: string; +} + +interface AboutTabProps { + data: LiveQueryDetailsItem; + queryItemAgents?: QueryItemAgents; + isScheduled?: boolean; + executionCount?: number; + onEditTags?: () => void; +} + +const AboutTabComponent: React.FC = ({ + data, + queryItemAgents, + isScheduled, + executionCount, + onEditTags, +}) => { + const queryText = queryItemAgents?.query ?? data.queries?.[0]?.query ?? ''; + const queryId = queryItemAgents?.id ?? data.queries?.[0]?.id; + const interval = queryItemAgents?.interval ?? data.queries?.[0]?.interval; + + const showScheduleCard = !!isScheduled; + + const userProfileUids = useMemo( + () => (data.user_profile_uid ? [data.user_profile_uid] : []), + [data.user_profile_uid] + ); + const { profilesMap, isLoading: isLoadingProfiles } = + useGenericBulkGetUserProfiles(userProfileUids); + + const aboutItems = useMemo(() => { + const items: AboutCardItem[] = []; + + if (queryItemAgents) { + items.push({ + title: ROWS_LABEL, + description: {queryItemAgents.docs ?? 0}, + }); + + items.push({ + title: AGENTS_LABEL, + description: ( + + ), + }); + } + + if (showScheduleCard && executionCount != null) { + items.push({ + title: i18n.translate('xpack.osquery.aboutTab.executionCountLabel', { + defaultMessage: 'Execution count', + }), + description: {executionCount}, + }); + } + + items.push({ + title: CREATED_AT_LABEL, + description: ( + {data['@timestamp'] ? formatDate(data['@timestamp']) : DASH} + ), + }); + + items.push({ + title: RUN_AT_LABEL, + description: ( + {data['@timestamp'] ? formatDate(data['@timestamp']) : DASH} + ), + }); + + items.push({ + title: RUN_BY_LABEL, + description: ( + + ), + }); + + return items; + }, [data, queryItemAgents, profilesMap, isLoadingProfiles, showScheduleCard, executionCount]); + + const formattedInterval = useMemo(() => { + if (!interval) return DASH; + const dur = moment.duration(Number(interval), 'seconds'); + const d = Math.floor(dur.asDays()); + const h = dur.hours(); + const m = dur.minutes(); + const s = dur.seconds(); + const parts = [d ? `${d}d` : '', h ? `${h}h` : '', m ? `${m}m` : '', s ? `${s}s` : ''].filter( + Boolean + ); + + return parts.length ? parts.join(' ') : '0s'; + }, [interval]); + + const scheduleItems = useMemo(() => { + const items: AboutCardItem[] = [ + { + title: REOCCURRENCE_LABEL, + description: {INTERVAL_LABEL}, + }, + { + title: INTERVALS_LABEL, + description: {formattedInterval}, + }, + ]; + + return items; + }, [formattedInterval]); + + const tagsEditButton = useMemo( + () => + onEditTags ? ( + + ) : null, + [onEditTags] + ); + + return ( + + + + +

{QUERY_CARD_TITLE}

+
+ + {queryId && ( + <> + +

{'ID'}

+
+ + + {queryId} + + + )} + + +

{QUERY_CARD_TITLE}

+
+ + + {queryText} + +
+
+ + + + + + + + {showScheduleCard && ( + + + + )} + + {!isScheduled && ( + + +
+ +

{TAGS_CARD_TITLE}

+
+ {tagsEditButton} +
+ + {data.tags && data.tags.length > 0 ? ( + + {data.tags.map((tag) => ( + + {tag} + + ))} + + ) : ( + + {i18n.translate('xpack.osquery.aboutTab.noTagsLabel', { + defaultMessage: 'No tags added', + })} + + )} +
+
+ )} +
+
+
+ ); +}; + +export const AboutTab = React.memo(AboutTabComponent); diff --git a/x-pack/platform/plugins/shared/osquery/public/routes/live_queries/details/index.tsx b/x-pack/platform/plugins/shared/osquery/public/routes/live_queries/details/index.tsx index 7d6f16ab56606..63ad0f2692985 100644 --- a/x-pack/platform/plugins/shared/osquery/public/routes/live_queries/details/index.tsx +++ b/x-pack/platform/plugins/shared/osquery/public/routes/live_queries/details/index.tsx @@ -7,7 +7,7 @@ import { EuiButtonEmpty, EuiFlexGroup, EuiFlexItem, EuiSpacer, EuiText } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n-react'; -import React, { useLayoutEffect, useMemo, useState } from 'react'; +import React, { useCallback, useLayoutEffect, useMemo, useState } from 'react'; import { useParams } from 'react-router-dom'; import { useRouterNavigate } from '../../../common/lib/kibana'; @@ -24,6 +24,9 @@ import { PackQueriesStatusTable } from '../../../live_queries/form/pack_queries_ import { useIsExperimentalFeatureEnabled } from '../../../common/experimental_features_context'; import { SavedQueryFlyout } from '../../../saved_queries'; import { useSaveQueryFromDetails } from './use_save_query_from_details'; +import { AboutTab } from './about_tab'; +import type { QueryItemAgents } from './about_tab'; +import { AddTagsFlyout } from '../../../actions/components/add_tags_flyout'; const tableWrapperCss = { paddingLeft: 0, @@ -49,6 +52,27 @@ const LiveQueryDetailsPageComponent = () => { savedQueryDefaultValue, } = useSaveQueryFromDetails({ data }); + const isResultCountsEnabled = useIsExperimentalFeatureEnabled('resultCountsEnabled'); + + const [isTagsFlyoutOpen, setIsTagsFlyoutOpen] = useState(false); + const handleOpenTagsFlyout = useCallback(() => setIsTagsFlyoutOpen(true), []); + const handleCloseTagsFlyout = useCallback(() => setIsTagsFlyoutOpen(false), []); + + const currentTags = useMemo(() => data?.tags ?? [], [data?.tags]); + + const renderAboutTab = useMemo(() => { + if (!isResultCountsEnabled || !data) { + return undefined; + } + + const AboutTabRenderer = (queryItem: QueryItemAgents) => ( + + ); + AboutTabRenderer.displayName = 'AboutTabRenderer'; + + return AboutTabRenderer; + }, [isResultCountsEnabled, data, handleOpenTagsFlyout]); + const LeftColumn = useMemo( () => ( @@ -106,6 +130,7 @@ const LiveQueryDetailsPageComponent = () => { showResultsHeader tags={data?.tags} onSaveQuery={onSaveQuery} + renderAboutTab={renderAboutTab} /> ); @@ -114,6 +139,15 @@ const LiveQueryDetailsPageComponent = () => { ) : null; + const tagsFlyout = + isTagsFlyoutOpen && actionId ? ( + + ) : null; + if (isHistoryEnabled) { return ( <> @@ -125,6 +159,7 @@ const LiveQueryDetailsPageComponent = () => { {savedQueryFlyout} + {tagsFlyout} ); } @@ -141,9 +176,11 @@ const LiveQueryDetailsPageComponent = () => { agentIds={data?.agents} showResultsHeader tags={data?.tags} + renderAboutTab={renderAboutTab} /> + {tagsFlyout} ); }; diff --git a/x-pack/platform/plugins/shared/osquery/public/routes/saved_queries/edit/tabs.tsx b/x-pack/platform/plugins/shared/osquery/public/routes/saved_queries/edit/tabs.tsx index 4e651a6491635..b6eee7980f74d 100644 --- a/x-pack/platform/plugins/shared/osquery/public/routes/saved_queries/edit/tabs.tsx +++ b/x-pack/platform/plugins/shared/osquery/public/routes/saved_queries/edit/tabs.tsx @@ -8,6 +8,7 @@ import { EuiTabbedContent, EuiNotificationBadge, EuiPanel } from '@elastic/eui'; import type { UseEuiTheme } from '@elastic/eui'; import React, { useMemo } from 'react'; +import type { ReactNode } from 'react'; import type { ECSMapping } from '@kbn/osquery-io-ts-types'; import { ResultsTable } from '../../../results/results_table'; @@ -33,6 +34,7 @@ interface ResultTabsProps { addToTimeline?: AddToTimelineHandler; scheduleId?: string; executionCount?: number; + aboutTab?: ReactNode; } const ResultTabsComponent: React.FC = ({ @@ -47,6 +49,7 @@ const ResultTabsComponent: React.FC = ({ addToTimeline, scheduleId, executionCount, + aboutTab, }) => { const tabs = useMemo( () => [ @@ -90,6 +93,16 @@ const ResultTabsComponent: React.FC = ({ ) : null, }, + ...(aboutTab + ? [ + { + id: 'about', + name: 'About', + 'data-test-subj': 'osquery-about-tab', + content: <>{aboutTab}, + }, + ] + : []), ], [ actionId, @@ -103,6 +116,7 @@ const ResultTabsComponent: React.FC = ({ addToTimeline, scheduleId, executionCount, + aboutTab, ] ); diff --git a/x-pack/platform/plugins/shared/osquery/server/lib/build_result_counts.test.ts b/x-pack/platform/plugins/shared/osquery/server/lib/build_result_counts.test.ts new file mode 100644 index 0000000000000..d688da956f666 --- /dev/null +++ b/x-pack/platform/plugins/shared/osquery/server/lib/build_result_counts.test.ts @@ -0,0 +1,132 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { ResultCountsMap } from './get_result_counts_for_actions'; +import { buildPackResultCounts, buildSingleQueryResultCounts } from './build_result_counts'; + +describe('buildSingleQueryResultCounts', () => { + it('returns counts from the map for a valid action id', () => { + const map: ResultCountsMap = new Map([ + ['query-1', { totalRows: 10, respondedAgents: 3, successfulAgents: 2, errorAgents: 1 }], + ]); + + expect(buildSingleQueryResultCounts('query-1', map)).toEqual({ + total_rows: 10, + responded_agents: 3, + successful_agents: 2, + error_agents: 1, + }); + }); + + it('returns zeroes when action id is not in the map', () => { + const map: ResultCountsMap = new Map(); + + expect(buildSingleQueryResultCounts('missing-id', map)).toEqual({ + total_rows: 0, + responded_agents: 0, + successful_agents: 0, + error_agents: 0, + }); + }); + + it('returns zeroes when action id is undefined', () => { + const map: ResultCountsMap = new Map([ + ['query-1', { totalRows: 10, respondedAgents: 3, successfulAgents: 2, errorAgents: 1 }], + ]); + + expect(buildSingleQueryResultCounts(undefined, map)).toEqual({ + total_rows: 0, + responded_agents: 0, + successful_agents: 0, + error_agents: 0, + }); + }); +}); + +describe('buildPackResultCounts', () => { + it('aggregates counts across multiple queries', () => { + const map: ResultCountsMap = new Map([ + ['q-1', { totalRows: 5, respondedAgents: 2, successfulAgents: 2, errorAgents: 0 }], + ['q-2', { totalRows: 3, respondedAgents: 4, successfulAgents: 3, errorAgents: 1 }], + ['q-3', { totalRows: 0, respondedAgents: 1, successfulAgents: 1, errorAgents: 0 }], + ]); + + expect(buildPackResultCounts(['q-1', 'q-2', 'q-3'], map)).toEqual({ + total_rows: 8, + queries_with_results: 2, + queries_total: 3, + successful_agents: 3, + error_agents: 1, + }); + }); + + it('returns zeroes when no action ids match', () => { + const map: ResultCountsMap = new Map(); + + expect(buildPackResultCounts(['q-1', 'q-2'], map)).toEqual({ + total_rows: 0, + queries_with_results: 0, + queries_total: 2, + successful_agents: 0, + error_agents: 0, + }); + }); + + it('uses agent counts from the query with most responded agents', () => { + const map: ResultCountsMap = new Map([ + ['q-1', { totalRows: 1, respondedAgents: 10, successfulAgents: 8, errorAgents: 2 }], + ['q-2', { totalRows: 1, respondedAgents: 5, successfulAgents: 5, errorAgents: 0 }], + ]); + + const result = buildPackResultCounts(['q-1', 'q-2'], map); + expect(result.successful_agents).toBe(8); + expect(result.error_agents).toBe(2); + }); + + it('returns zeroes with queries_total: 0 for empty queryActionIds array', () => { + const map: ResultCountsMap = new Map([ + ['q-1', { totalRows: 10, respondedAgents: 1, successfulAgents: 1, errorAgents: 0 }], + ]); + + expect(buildPackResultCounts([], map)).toEqual({ + total_rows: 0, + queries_with_results: 0, + queries_total: 0, + successful_agents: 0, + error_agents: 0, + }); + }); + + it('uses first-seen agent counts when multiple queries tie on respondedAgents', () => { + const map: ResultCountsMap = new Map([ + ['q-1', { totalRows: 5, respondedAgents: 3, successfulAgents: 2, errorAgents: 1 }], + ['q-2', { totalRows: 5, respondedAgents: 3, successfulAgents: 1, errorAgents: 2 }], + ]); + + const result = buildPackResultCounts(['q-1', 'q-2'], map); + expect(result.successful_agents).toBe(2); + expect(result.error_agents).toBe(1); + }); + + it('ignores entries in the map that are not referenced by queryActionIds', () => { + const map: ResultCountsMap = new Map([ + ['q-1', { totalRows: 5, respondedAgents: 1, successfulAgents: 1, errorAgents: 0 }], + [ + 'q-unreferenced', + { totalRows: 100, respondedAgents: 50, successfulAgents: 50, errorAgents: 0 }, + ], + ]); + + expect(buildPackResultCounts(['q-1'], map)).toEqual({ + total_rows: 5, + queries_with_results: 1, + queries_total: 1, + successful_agents: 1, + error_agents: 0, + }); + }); +}); diff --git a/x-pack/platform/plugins/shared/osquery/server/lib/build_result_counts.ts b/x-pack/platform/plugins/shared/osquery/server/lib/build_result_counts.ts new file mode 100644 index 0000000000000..83afb1b5d740c --- /dev/null +++ b/x-pack/platform/plugins/shared/osquery/server/lib/build_result_counts.ts @@ -0,0 +1,77 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { ResultCountsMap } from './get_result_counts_for_actions'; + +export interface SingleQueryResultCountsResponse { + total_rows: number; + responded_agents: number; + successful_agents: number; + error_agents: number; +} + +export interface PackResultCountsResponse { + total_rows: number; + queries_with_results: number; + queries_total: number; + successful_agents: number; + error_agents: number; +} + +export type ResultCountsResponse = SingleQueryResultCountsResponse | PackResultCountsResponse; + +export const buildPackResultCounts = ( + queryActionIds: string[], + resultCountsMap: ResultCountsMap +): PackResultCountsResponse => { + let totalRows = 0; + let queriesWithResults = 0; + let successfulAgents = 0; + let errorAgents = 0; + let maxRespondedAgents = 0; + + // Agent success/error counts are taken from the sub-query with the most + // responded agents (not summed), since all pack queries target the same + // agent set and per-agent status is identical across queries. + for (const actionId of queryActionIds) { + const counts = resultCountsMap.get(actionId); + if (counts) { + totalRows += counts.totalRows; + if (counts.totalRows > 0) { + queriesWithResults++; + } + + if (counts.respondedAgents > maxRespondedAgents) { + maxRespondedAgents = counts.respondedAgents; + successfulAgents = counts.successfulAgents; + errorAgents = counts.errorAgents; + } + } + } + + return { + total_rows: totalRows, + queries_with_results: queriesWithResults, + queries_total: queryActionIds.length, + successful_agents: successfulAgents, + error_agents: errorAgents, + }; +}; + +export const buildSingleQueryResultCounts = ( + queryActionId: string | undefined, + resultCountsMap: ResultCountsMap +): SingleQueryResultCountsResponse => { + const counts = queryActionId ? resultCountsMap.get(queryActionId) : undefined; + + return { + total_rows: counts?.totalRows ?? 0, + responded_agents: counts?.respondedAgents ?? 0, + successful_agents: counts?.successfulAgents ?? 0, + error_agents: counts?.errorAgents ?? 0, + }; +}; diff --git a/x-pack/platform/plugins/shared/osquery/server/routes/live_query/find_live_query_route.test.ts b/x-pack/platform/plugins/shared/osquery/server/routes/live_query/find_live_query_route.test.ts index dda60d0efeed7..8bc772855330b 100644 --- a/x-pack/platform/plugins/shared/osquery/server/routes/live_query/find_live_query_route.test.ts +++ b/x-pack/platform/plugins/shared/osquery/server/routes/live_query/find_live_query_route.test.ts @@ -60,6 +60,8 @@ describe('findLiveQueryRoute', () => { getStartServices: jest .fn() .mockResolvedValue([{ elasticsearch: { client: { asInternalUser: mockEsClient } } }]), + logFactory: { get: jest.fn().mockReturnValue({ warn: jest.fn() }) }, + experimentalFeatures: { resultCountsEnabled: true }, } as unknown as OsqueryAppContext; }); @@ -318,6 +320,48 @@ describe('findLiveQueryRoute', () => { ); }); + it('skips result_counts when resultCountsEnabled is false even if withResultCounts is true', async () => { + (mockOsqueryContext as any).experimentalFeatures = { resultCountsEnabled: false }; + + const edges = [ + { + _source: { + action_id: 'action-1', + queries: [{ action_id: 'query-1', query: 'select 1;', agents: ['agent-1'] }], + }, + fields: { action_id: ['action-1'] }, + }, + ]; + + const mockSearchFn = jest.fn().mockReturnValue( + of({ + edges, + rawResponse: { hits: { total: 1 } }, + total: 1, + }) + ); + + setupRoute(); + + const mockRequest = httpServerMock.createKibanaRequest({ + query: { kuery: undefined, page: 0, pageSize: 20, withResultCounts: true }, + }); + const mockResponse = httpServerMock.createResponseFactory(); + + await routeHandler(createMockContext(mockSearchFn) as any, mockRequest, mockResponse); + + expect(getResultCountsForActions).not.toHaveBeenCalled(); + expect(mockResponse.ok).toHaveBeenCalledWith( + expect.objectContaining({ + body: expect.objectContaining({ + data: expect.objectContaining({ + items: edges, + }), + }), + }) + ); + }); + it('handles items without queries gracefully', async () => { const edges = [ { diff --git a/x-pack/platform/plugins/shared/osquery/server/routes/live_query/find_live_query_route.ts b/x-pack/platform/plugins/shared/osquery/server/routes/live_query/find_live_query_route.ts index 297c6db3f2a7f..e6e5d56ec6913 100644 --- a/x-pack/platform/plugins/shared/osquery/server/routes/live_query/find_live_query_route.ts +++ b/x-pack/platform/plugins/shared/osquery/server/routes/live_query/find_live_query_route.ts @@ -28,6 +28,7 @@ import { findLiveQueryRequestQuerySchema } from '../../../common/api'; import { generateTablePaginationOptions } from '../../../common/utils/build_query'; import { getResultCountsForActions } from '../../lib/get_result_counts_for_actions'; import { hasConnectedRemoteClusters } from '../../utils/ccs_utils'; +import { buildPackResultCounts, buildSingleQueryResultCounts } from '../../lib/build_result_counts'; import { findLiveQueryResponseSchema } from './response_schemas'; export const findLiveQueryRoute = ( @@ -92,7 +93,11 @@ export const findLiveQueryRoute = ( let items = res.edges; - if (request.query.withResultCounts && items.length > 0) { + if ( + osqueryContext.experimentalFeatures.resultCountsEnabled && + request.query.withResultCounts && + items.length > 0 + ) { try { const [coreStartServices] = await osqueryContext.getStartServices(); const esClient = coreStartServices.elasticsearch.client.asInternalUser; @@ -121,64 +126,22 @@ export const findLiveQueryRoute = ( const action = item._source as ActionDetails | undefined; if (!action?.queries) return item; - if (action.pack_id) { - let totalRows = 0; - let queriesWithResults = 0; - let successfulAgents = 0; - let errorAgents = 0; - let maxRespondedAgents = 0; + const actionQueryIds = action.queries + .map((query) => query.action_id) + .filter((id): id is string => !!id); - for (const query of action.queries) { - if (query.action_id) { - const counts = resultCountsMap.get(query.action_id); - if (counts) { - totalRows += counts.totalRows; - if (counts.totalRows > 0) { - queriesWithResults++; - } - - if (counts.respondedAgents > maxRespondedAgents) { - maxRespondedAgents = counts.respondedAgents; - successfulAgents = counts.successfulAgents; - errorAgents = counts.errorAgents; - } - } - } - } - - return { - ...item, - _source: { - ...action, - result_counts: { - total_rows: totalRows, - queries_with_results: queriesWithResults, - queries_total: action.queries.length, - successful_agents: successfulAgents, - error_agents: errorAgents, - }, - }, - }; - } - - const queryActionId = action.queries[0]?.action_id; - const counts = queryActionId ? resultCountsMap.get(queryActionId) : undefined; + const resultCounts = action.pack_id + ? buildPackResultCounts(actionQueryIds, resultCountsMap) + : buildSingleQueryResultCounts(actionQueryIds[0], resultCountsMap); return { ...item, - _source: { - ...action, - result_counts: { - total_rows: counts?.totalRows ?? 0, - responded_agents: counts?.respondedAgents ?? 0, - successful_agents: counts?.successfulAgents ?? 0, - error_agents: counts?.errorAgents ?? 0, - }, - }, + _source: { ...action, result_counts: resultCounts }, }; }); - } catch { - // Result counts are supplementary — don't fail the listing if aggregation errors + } catch (err) { + const logger = osqueryContext.logFactory.get('findLiveQuery'); + logger.warn(`Failed to enrich result_counts for live query listing: ${String(err)}`); } } diff --git a/x-pack/platform/plugins/shared/osquery/server/routes/live_query/get_live_query_details_route.test.ts b/x-pack/platform/plugins/shared/osquery/server/routes/live_query/get_live_query_details_route.test.ts index 37d5d8c43af37..d8f68b5cadab2 100644 --- a/x-pack/platform/plugins/shared/osquery/server/routes/live_query/get_live_query_details_route.test.ts +++ b/x-pack/platform/plugins/shared/osquery/server/routes/live_query/get_live_query_details_route.test.ts @@ -18,6 +18,16 @@ jest.mock('./utils', () => ({ getActionResponses: jest.fn(), })); +jest.mock('../../lib/get_result_counts_for_actions', () => ({ + getResultCountsForActions: jest.fn(), +})); + +import { getResultCountsForActions } from '../../lib/get_result_counts_for_actions'; + +jest.mock('../../utils/ccs_utils', () => ({ + hasConnectedRemoteClusters: jest.fn().mockResolvedValue(false), +})); + describe('getLiveQueryDetailsRoute', () => { let routeHandler: RequestHandler; let mockOsqueryContext: OsqueryAppContext; @@ -49,42 +59,46 @@ describe('getLiveQueryDetailsRoute', () => { jest.clearAllMocks(); }); - it('returns action details with completed status', async () => { - const mockSearchFn = jest.fn().mockReturnValue( - of({ - actionDetails: { - _source: { - action_id: 'action-1', - user_id: 'test-user', - user_profile_uid: 'u_test-profile-uid', - queries: [ - { - action_id: 'query-1', - query: 'select 1;', - agents: ['agent-1'], - }, - ], - }, - fields: { expiration: [new Date(Date.now() + 60000).toISOString()] }, + const singleQueryActionDetails = () => ({ + _source: { + action_id: 'action-1', + user_id: 'test-user', + user_profile_uid: 'u_test-profile-uid', + queries: [ + { + action_id: 'query-1', + query: 'select 1;', + agents: ['agent-1'], }, - }) - ); + ], + }, + fields: { expiration: [new Date(Date.now() + 60000).toISOString()] }, + }); - (getActionResponses as jest.Mock).mockReturnValue( - of({ - action_id: 'query-1', - pending: 0, - responded: 1, - successful: 1, - failed: 0, - docs: 1, - }) - ); + const packActionDetails = () => ({ + _source: { + action_id: 'action-1', + user_id: 'test-user', + pack_id: 'pack-1', + queries: [ + { action_id: 'query-1', query: 'select 1;', agents: ['agent-1'] }, + { action_id: 'query-2', query: 'select 2;', agents: ['agent-1'] }, + ], + }, + fields: { expiration: [new Date(Date.now() + 60000).toISOString()] }, + }); + const setupRoute = (overrides?: Partial) => { mockOsqueryContext = { service: { getActiveSpace: jest.fn().mockResolvedValue({ id: 'space-a' }), }, + getStartServices: jest + .fn() + .mockResolvedValue([{ elasticsearch: { client: { asInternalUser: {} } } }]), + logFactory: { get: jest.fn().mockReturnValue({ warn: jest.fn() }) }, + experimentalFeatures: { resultCountsEnabled: true }, + ...overrides, } as unknown as OsqueryAppContext; const mockRouter = createMockRouter(); @@ -97,6 +111,24 @@ describe('getLiveQueryDetailsRoute', () => { } routeHandler = routeVersion.handler; + }; + + it('returns action details with completed status and result_counts', async () => { + const mockSearchFn = jest + .fn() + .mockReturnValue(of({ actionDetails: singleQueryActionDetails() })); + + (getActionResponses as jest.Mock).mockReturnValue( + of({ action_id: 'query-1', pending: 0, responded: 1, successful: 1, failed: 0, docs: 1 }) + ); + + (getResultCountsForActions as jest.Mock).mockResolvedValue( + new Map([ + ['query-1', { totalRows: 5, respondedAgents: 1, successfulAgents: 1, errorAgents: 0 }], + ]) + ); + + setupRoute(); const mockRequest = httpServerMock.createKibanaRequest({ params: { id: 'action-1' }, @@ -122,6 +154,12 @@ describe('getLiveQueryDetailsRoute', () => { user_id: 'test-user', user_profile_uid: 'u_test-profile-uid', status: 'completed', + result_counts: { + total_rows: 5, + responded_agents: 1, + successful_agents: 1, + error_agents: 0, + }, queries: [ expect.objectContaining({ action_id: 'query-1', @@ -133,4 +171,97 @@ describe('getLiveQueryDetailsRoute', () => { }, }); }); + + it('omits result_counts when resultCountsEnabled is false', async () => { + const mockSearchFn = jest + .fn() + .mockReturnValue(of({ actionDetails: singleQueryActionDetails() })); + + (getActionResponses as jest.Mock).mockReturnValue( + of({ action_id: 'query-1', pending: 0, responded: 1, successful: 1, failed: 0, docs: 1 }) + ); + + setupRoute({ experimentalFeatures: { resultCountsEnabled: false } } as any); + + const mockRequest = httpServerMock.createKibanaRequest({ + params: { id: 'action-1' }, + query: {}, + }); + const mockResponse = httpServerMock.createResponseFactory(); + + await routeHandler(createMockContext(mockSearchFn) as any, mockRequest, mockResponse); + + expect(getResultCountsForActions).not.toHaveBeenCalled(); + + const body = (mockResponse.ok.mock.calls[0][0] as any).body; + expect(body.data).not.toHaveProperty('result_counts'); + }); + + it('returns pack result_counts for pack queries', async () => { + const mockSearchFn = jest.fn().mockReturnValue(of({ actionDetails: packActionDetails() })); + + (getActionResponses as jest.Mock).mockImplementation((_search: any, actionId: string) => + of({ action_id: actionId, pending: 0, responded: 1, successful: 1, failed: 0, docs: 1 }) + ); + + (getResultCountsForActions as jest.Mock).mockResolvedValue( + new Map([ + ['query-1', { totalRows: 10, respondedAgents: 1, successfulAgents: 1, errorAgents: 0 }], + ['query-2', { totalRows: 0, respondedAgents: 1, successfulAgents: 0, errorAgents: 1 }], + ]) + ); + + setupRoute(); + + const mockRequest = httpServerMock.createKibanaRequest({ + params: { id: 'action-1' }, + query: {}, + }); + const mockResponse = httpServerMock.createResponseFactory(); + + await routeHandler(createMockContext(mockSearchFn) as any, mockRequest, mockResponse); + + const body = (mockResponse.ok.mock.calls[0][0] as any).body; + expect(body.data.result_counts).toEqual({ + total_rows: 10, + queries_with_results: 1, + queries_total: 2, + successful_agents: 1, + error_agents: 0, + }); + }); + + it('omits result_counts and logs warning when aggregation fails', async () => { + const mockSearchFn = jest + .fn() + .mockReturnValue(of({ actionDetails: singleQueryActionDetails() })); + + (getActionResponses as jest.Mock).mockReturnValue( + of({ action_id: 'query-1', pending: 0, responded: 1, successful: 1, failed: 0, docs: 1 }) + ); + + (getResultCountsForActions as jest.Mock).mockRejectedValue( + new Error('index_not_found_exception') + ); + + setupRoute(); + + const mockRequest = httpServerMock.createKibanaRequest({ + params: { id: 'action-1' }, + query: {}, + }); + const mockResponse = httpServerMock.createResponseFactory(); + + await routeHandler(createMockContext(mockSearchFn) as any, mockRequest, mockResponse); + + expect(mockResponse.ok).toHaveBeenCalled(); + const body = (mockResponse.ok.mock.calls[0][0] as any).body; + expect(body.data).not.toHaveProperty('result_counts'); + + const mockLoggerWarn = (mockOsqueryContext.logFactory.get as jest.Mock).mock.results[0].value + .warn; + expect(mockLoggerWarn).toHaveBeenCalledWith( + expect.stringContaining('index_not_found_exception') + ); + }); }); diff --git a/x-pack/platform/plugins/shared/osquery/server/routes/live_query/get_live_query_details_route.ts b/x-pack/platform/plugins/shared/osquery/server/routes/live_query/get_live_query_details_route.ts index e6c2d38def90c..ff51f910102e1 100644 --- a/x-pack/platform/plugins/shared/osquery/server/routes/live_query/get_live_query_details_route.ts +++ b/x-pack/platform/plugins/shared/osquery/server/routes/live_query/get_live_query_details_route.ts @@ -31,6 +31,9 @@ import { } from '../../../common/api'; import type { OsqueryAppContext } from '../../lib/osquery_app_context_services'; import { getLiveQueryDetailsResponseSchema } from './response_schemas'; +import { getResultCountsForActions } from '../../lib/get_result_counts_for_actions'; +import { hasConnectedRemoteClusters } from '../../utils/ccs_utils'; +import { buildPackResultCounts, buildSingleQueryResultCounts } from '../../lib/build_result_counts'; export const getLiveQueryDetailsRoute = ( router: IRouter, @@ -87,8 +90,14 @@ export const getLiveQueryDetailsRoute = ( ) ); - const queries = actionDetails?._source?.queries; - const expirationDate = actionDetails?.fields?.expiration?.[0]; + if (!actionDetails) { + return response.notFound({ + body: { message: `Live query ${request.params.id} not found` }, + }); + } + + const queries = actionDetails._source?.queries; + const expirationDate = actionDetails.fields?.expiration?.[0]; const expired = !expirationDate ? true : new Date(expirationDate) < new Date(); @@ -103,11 +112,36 @@ export const getLiveQueryDetailsRoute = ( const isCompleted = expired || (responseData && every(responseData, ['pending', 0])); const agentByActionIdStatusMap = mapKeys(responseData, 'action_id'); + const queryActionIds = map(queries, 'action_id').filter((id): id is string => !!id); + let resultCounts; + if (osqueryContext.experimentalFeatures.resultCountsEnabled) { + try { + const [coreStartServices] = await osqueryContext.getStartServices(); + const esClient = coreStartServices.elasticsearch.client.asInternalUser; + const ccsEnabled = await hasConnectedRemoteClusters(esClient); + const resultCountsMap = await getResultCountsForActions( + esClient, + queryActionIds, + spaceId, + ccsEnabled + ); + + resultCounts = actionDetails._source?.pack_id + ? buildPackResultCounts(queryActionIds, resultCountsMap) + : buildSingleQueryResultCounts(queryActionIds[0], resultCountsMap); + } catch (err) { + const logger = osqueryContext.logFactory.get('liveQueryDetails'); + logger.warn( + `Failed to fetch result_counts for action ${request.params.id}: ${String(err)}` + ); + } + } + return response.ok({ body: { data: { ...pick( - actionDetails._source, + actionDetails?._source, 'action_id', 'expiration', '@timestamp', @@ -120,6 +154,7 @@ export const getLiveQueryDetailsRoute = ( 'prebuilt_pack', 'tags' ), + ...(resultCounts ? { result_counts: resultCounts } : {}), queries: reduce< { action_id: string; diff --git a/x-pack/platform/plugins/shared/osquery/server/routes/live_query/response_schemas.ts b/x-pack/platform/plugins/shared/osquery/server/routes/live_query/response_schemas.ts index 11f8088e1cf51..9dc1e4a4e3247 100644 --- a/x-pack/platform/plugins/shared/osquery/server/routes/live_query/response_schemas.ts +++ b/x-pack/platform/plugins/shared/osquery/server/routes/live_query/response_schemas.ts @@ -144,6 +144,16 @@ export const getLiveQueryDetailsResponseSchema = schema.object({ tags: schema.maybe(schema.arrayOf(schema.string())), status: schema.maybe(schema.string()), queries: schema.maybe(schema.arrayOf(liveQueryDetailsQueryItemSchema)), + result_counts: schema.maybe( + schema.object({ + total_rows: schema.maybe(schema.number()), + responded_agents: schema.maybe(schema.number()), + queries_with_results: schema.maybe(schema.number()), + queries_total: schema.maybe(schema.number()), + successful_agents: schema.maybe(schema.number()), + error_agents: schema.maybe(schema.number()), + }) + ), }, { unknowns: 'allow' } ) diff --git a/x-pack/platform/plugins/shared/osquery/server/routes/scheduled_results/get_scheduled_action_results_route.test.ts b/x-pack/platform/plugins/shared/osquery/server/routes/scheduled_results/get_scheduled_action_results_route.test.ts index b0b1cbe385a17..eba1bc8fc5849 100644 --- a/x-pack/platform/plugins/shared/osquery/server/routes/scheduled_results/get_scheduled_action_results_route.test.ts +++ b/x-pack/platform/plugins/shared/osquery/server/routes/scheduled_results/get_scheduled_action_results_route.test.ts @@ -139,13 +139,19 @@ describe('getScheduledActionResultsRoute', () => { service: { getActiveSpace: jest.fn().mockResolvedValue({ id: 'space-a' }), }, + experimentalFeatures: { resultCountsEnabled: false }, } as unknown as OsqueryAppContext; const soGet = jest.fn().mockResolvedValue({ attributes: { name: 'My Pack', queries: [ - { schedule_id: 'sched-1', name: 'uptime_query', query: 'SELECT * FROM uptime;' }, + { + schedule_id: 'sched-1', + name: 'uptime_query', + query: 'SELECT * FROM uptime;', + interval: 3600, + }, ], }, }); @@ -193,6 +199,114 @@ describe('getScheduledActionResultsRoute', () => { }); }); + describe('queryInterval metadata (resultCountsEnabled)', () => { + it('includes queryInterval from pack query when resultCountsEnabled is true', async () => { + const mockSearchFn = jest.fn().mockReturnValue( + of( + createMockScheduledResponse({ + edges: [{ _id: 'hit-1' }], + total: 1, + successCount: 1, + errorCount: 0, + rowsCount: 1, + packId: 'pack-1', + }) + ) + ); + + const mockOsqueryContext = { + service: { + getActiveSpace: jest.fn().mockResolvedValue({ id: 'default' }), + }, + experimentalFeatures: { resultCountsEnabled: true }, + } as unknown as OsqueryAppContext; + + const soGet = jest.fn().mockResolvedValue({ + attributes: { + name: 'Pack', + queries: [ + { + schedule_id: 'sched-1', + name: 'q1', + query: 'SELECT 1', + interval: 7200, + }, + ], + }, + }); + + registerRoute(mockOsqueryContext); + + const mockRequest = httpServerMock.createKibanaRequest({ + params: { scheduleId: 'sched-1', executionCount: 1 }, + query: {}, + }); + const mockResponse = httpServerMock.createResponseFactory(); + + await routeHandler( + createMockContext(mockSearchFn, { get: soGet }) as any, + mockRequest, + mockResponse + ); + + const responseBody = (mockResponse.ok as jest.Mock).mock.calls[0][0].body; + expect(responseBody.metadata.queryInterval).toBe(7200); + }); + + it('omits queryInterval when resultCountsEnabled is false', async () => { + const mockSearchFn = jest.fn().mockReturnValue( + of( + createMockScheduledResponse({ + edges: [{ _id: 'hit-1' }], + total: 1, + successCount: 1, + errorCount: 0, + rowsCount: 1, + packId: 'pack-1', + }) + ) + ); + + const mockOsqueryContext = { + service: { + getActiveSpace: jest.fn().mockResolvedValue({ id: 'default' }), + }, + experimentalFeatures: { resultCountsEnabled: false }, + } as unknown as OsqueryAppContext; + + const soGet = jest.fn().mockResolvedValue({ + attributes: { + name: 'Pack', + queries: [ + { + schedule_id: 'sched-1', + name: 'q1', + query: 'SELECT 1', + interval: 7200, + }, + ], + }, + }); + + registerRoute(mockOsqueryContext); + + const mockRequest = httpServerMock.createKibanaRequest({ + params: { scheduleId: 'sched-1', executionCount: 1 }, + query: {}, + }); + const mockResponse = httpServerMock.createResponseFactory(); + + await routeHandler( + createMockContext(mockSearchFn, { get: soGet }) as any, + mockRequest, + mockResponse + ); + + const responseBody = (mockResponse.ok as jest.Mock).mock.calls[0][0].body; + expect(responseBody.metadata.queryInterval).toBeUndefined(); + }); + }); + describe('space ID resolution', () => { it('should pass resolved space ID to search strategy', async () => { const mockSearchFn = jest diff --git a/x-pack/platform/plugins/shared/osquery/server/routes/scheduled_results/get_scheduled_action_results_route.ts b/x-pack/platform/plugins/shared/osquery/server/routes/scheduled_results/get_scheduled_action_results_route.ts index 2ed6d733df671..d3cc451ad5aa8 100644 --- a/x-pack/platform/plugins/shared/osquery/server/routes/scheduled_results/get_scheduled_action_results_route.ts +++ b/x-pack/platform/plugins/shared/osquery/server/routes/scheduled_results/get_scheduled_action_results_route.ts @@ -142,6 +142,9 @@ export const getScheduledActionResultsRoute = ( let packName = ''; let queryName = ''; let queryText = ''; + let queryInterval: number | undefined; + + const resultCountsEnabled = osqueryContext.experimentalFeatures?.resultCountsEnabled; if (packId) { try { @@ -156,6 +159,12 @@ export const getScheduledActionResultsRoute = ( if (matchingQuery) { queryName = matchingQuery.name || matchingQuery.id || ''; queryText = matchingQuery.query || ''; + if (resultCountsEnabled && matchingQuery.interval != null) { + const n = Number(matchingQuery.interval); + if (!Number.isNaN(n)) { + queryInterval = n; + } + } } } catch { // Pack deleted — gracefully degrade to empty name fields @@ -172,6 +181,7 @@ export const getScheduledActionResultsRoute = ( queryName, queryText, timestamp, + ...(resultCountsEnabled && queryInterval !== undefined ? { queryInterval } : {}), }, edges: res.edges, total, diff --git a/x-pack/platform/test/api_integration/apis/osquery/config.ts b/x-pack/platform/test/api_integration/apis/osquery/config.ts index de36aad213584..4429ad2fb7c64 100644 --- a/x-pack/platform/test/api_integration/apis/osquery/config.ts +++ b/x-pack/platform/test/api_integration/apis/osquery/config.ts @@ -17,7 +17,7 @@ export default async function ({ readConfigFile }: FtrConfigProviderContext) { ...baseIntegrationTestsConfig.get('kbnTestServer'), serverArgs: [ ...baseIntegrationTestsConfig.get('kbnTestServer.serverArgs'), - '--xpack.osquery.enableExperimental=["queryHistoryRework"]', + '--xpack.osquery.enableExperimental=["queryHistoryRework","resultCountsEnabled"]', ], }, }; diff --git a/x-pack/platform/test/api_integration/apis/osquery/index.ts b/x-pack/platform/test/api_integration/apis/osquery/index.ts index 4b118098e990a..bb2bb7c780b07 100644 --- a/x-pack/platform/test/api_integration/apis/osquery/index.ts +++ b/x-pack/platform/test/api_integration/apis/osquery/index.ts @@ -18,5 +18,6 @@ export default function ({ loadTestFile }: FtrProviderContext) { loadTestFile(require.resolve('./live_queries')); loadTestFile(require.resolve('./history_tags')); loadTestFile(require.resolve('./unified_history')); + loadTestFile(require.resolve('./scheduled_action_results')); }); } diff --git a/x-pack/platform/test/api_integration/apis/osquery/scheduled_action_results.ts b/x-pack/platform/test/api_integration/apis/osquery/scheduled_action_results.ts new file mode 100644 index 0000000000000..42817f1a84d76 --- /dev/null +++ b/x-pack/platform/test/api_integration/apis/osquery/scheduled_action_results.ts @@ -0,0 +1,75 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import expect from '@kbn/expect'; +import type { Test } from 'supertest'; +import type { FtrProviderContext } from '../../ftr_provider_context'; + +const OSQUERY_PUBLIC_API_VERSION = '2023-10-31'; + +export default function ({ getService }: FtrProviderContext) { + const supertest = getService('supertest'); + + const withOsqueryHeaders = (request: Test) => + request.set('kbn-xsrf', 'true').set('elastic-api-version', OSQUERY_PUBLIC_API_VERSION); + + describe('Scheduled action results', () => { + it('returns 200 with the expected response envelope shape', async () => { + const response = await withOsqueryHeaders( + supertest.get('/api/osquery/scheduled_results/non-existent-id/1') + ).expect(200); + + expect(response.body).to.have.property('metadata'); + expect(response.body).to.have.property('edges'); + expect(response.body).to.have.property('total'); + expect(response.body).to.have.property('currentPage'); + expect(response.body).to.have.property('pageSize'); + expect(response.body).to.have.property('totalPages'); + expect(response.body).to.have.property('aggregations'); + }); + + it('echoes scheduleId and executionCount back in metadata', async () => { + const response = await withOsqueryHeaders( + supertest.get('/api/osquery/scheduled_results/my-schedule-abc/7') + ).expect(200); + + expect(response.body.metadata.scheduleId).to.be('my-schedule-abc'); + expect(response.body.metadata.executionCount).to.be(7); + }); + + it('defaults metadata fields to empty strings when no data exists', async () => { + const response = await withOsqueryHeaders( + supertest.get('/api/osquery/scheduled_results/no-data-here/1') + ).expect(200); + + const { metadata } = response.body; + expect(metadata.packId).to.be(''); + expect(metadata.packName).to.be(''); + expect(metadata.queryName).to.be(''); + expect(metadata.queryText).to.be(''); + expect(metadata.timestamp).to.be(''); + expect(metadata.queryInterval).to.be(undefined); + }); + + it('returns 400 when pagination exceeds the maximum allowed', async () => { + const response = await withOsqueryHeaders( + supertest.get('/api/osquery/scheduled_results/any-id/1?page=500&pageSize=21') + ); + + expect(response.status).to.be(400); + expect(response.body.message).to.contain('Cannot paginate beyond'); + }); + + it('accepts optional query parameters', async () => { + await withOsqueryHeaders( + supertest.get( + '/api/osquery/scheduled_results/any-id/1?sort=agent_id&sortOrder=asc&page=0&pageSize=5' + ) + ).expect(200); + }); + }); +} diff --git a/x-pack/solutions/security/packages/test-api-clients/supertest/osquery.gen.ts b/x-pack/solutions/security/packages/test-api-clients/supertest/osquery.gen.ts index ebc5d1f5940f5..7252c74224645 100644 --- a/x-pack/solutions/security/packages/test-api-clients/supertest/osquery.gen.ts +++ b/x-pack/solutions/security/packages/test-api-clients/supertest/osquery.gen.ts @@ -304,7 +304,7 @@ const securitySolutionApiServiceFactory = (supertest: SuperTest.Agent) => ({ .set(X_ELASTIC_INTERNAL_ORIGIN_REQUEST, 'kibana'); }, /** - * Get paginated per-agent action results for a specific scheduled query execution, with success/failure aggregation and execution metadata (pack name, query name/text, timestamp). + * Get paginated per-agent action results for a specific scheduled query execution, with success/failure aggregation and execution metadata (pack name, query name/text, timestamp, and optionally queryInterval when the resultCountsEnabled experimental feature is enabled). */ osqueryGetScheduledActionResults(