diff --git a/public/pages/InflightQueries/InflightQueries.test.tsx b/public/pages/InflightQueries/InflightQueries.test.tsx deleted file mode 100644 index f2903b79..00000000 --- a/public/pages/InflightQueries/InflightQueries.test.tsx +++ /dev/null @@ -1,140 +0,0 @@ -/* - * Copyright OpenSearch Contributors - * SPDX-License-Identifier: Apache-2.0 - */ - -import React from 'react'; -import { CoreStart } from 'opensearch-dashboards/public'; -import { render, screen, waitFor, act } from '@testing-library/react'; - -import { InflightQueries } from './InflightQueries'; -import { retrieveLiveQueries } from '../../../common/utils/QueryUtils'; -jest.mock('vega-embed', () => ({ - __esModule: true, - default: jest.fn().mockResolvedValue({}), -})); -import stubLiveQueries from '../../../cypress/fixtures/stub_live_queries.json'; -import '@testing-library/jest-dom'; - -jest.mock('../../../common/utils/QueryUtils'); - -describe('InflightQueries', () => { - const mockCore = ({ - http: { - get: jest.fn(), - post: jest.fn(), - }, - uiSettings: { - get: jest.fn().mockReturnValue(false), - }, - notifications: { - toasts: { - addSuccess: jest.fn(), - addError: jest.fn(), - }, - }, - } as unknown) as CoreStart; - - beforeEach(() => { - jest.clearAllMocks(); - (retrieveLiveQueries as jest.Mock).mockResolvedValue(stubLiveQueries); - }); - - const renderInflightQueries = () => { - return render(); - }; - - it('displays metric values from fixture', async () => { - renderInflightQueries(); - - await waitFor(() => { - expect(screen.getByText('Active queries')).toBeInTheDocument(); - expect(screen.getByText('20')).toBeInTheDocument(); - expect(screen.getByText('7.19 s')).toBeInTheDocument(); - expect(screen.getByText('9.69 s')).toBeInTheDocument(); - expect(screen.getByText('1.68 ms')).toBeInTheDocument(); - expect(screen.getByText('69.12 KB')).toBeInTheDocument(); - expect(screen.getByText('ID: node-A1B2C4E5:3614')).toBeInTheDocument(); - }); - }); - - it('shows 0 when there are no queries', async () => { - (retrieveLiveQueries as jest.Mock).mockResolvedValue({ - response: { live_queries: [] }, - }); - - const { container } = renderInflightQueries(); - - await waitFor(() => { - expect(screen.getAllByText('0')).toHaveLength(5); - }); - expect(container).toMatchSnapshot(); - }); - - it('updates data periodically', async () => { - jest.useFakeTimers(); - - renderInflightQueries(); - - await waitFor(() => { - expect(retrieveLiveQueries).toHaveBeenCalledTimes(1); - }); - - act(() => { - jest.advanceTimersByTime(6000); - }); - - await waitFor(() => { - expect(retrieveLiveQueries).toHaveBeenCalledTimes(2); - }); - - act(() => { - jest.advanceTimersByTime(6000); - }); - - await waitFor(() => { - expect(retrieveLiveQueries).toHaveBeenCalledTimes(3); - }); - - jest.useRealTimers(); - }); - - it('formats time values correctly', async () => { - const mockSmallLatency = { - response: { - live_queries: [ - { - id: 'query1', - measurements: { - latency: { number: 500 }, - cpu: { number: 1000000 }, - memory: { number: 500 }, - }, - }, - ], - }, - }; - - (retrieveLiveQueries as jest.Mock).mockResolvedValue(mockSmallLatency); - - renderInflightQueries(); - - await waitFor(() => { - expect(screen.getAllByText('0.50 µs')).toHaveLength(3); - expect(screen.getAllByText('1.00 ms')).toHaveLength(2); - }); - }); - - it('displays fallback when live queries API fails', async () => { - (retrieveLiveQueries as jest.Mock).mockResolvedValue({ - ok: false, - error: 'Live queries fetch failed', - }); - - render(); - - await waitFor(() => { - expect(screen.getAllByText('Live queries fetch failed')).toHaveLength(2); - }); - }); -}); diff --git a/public/pages/InflightQueries/InflightQueries.tsx b/public/pages/InflightQueries/InflightQueries.tsx index 58d5beb2..a25e3325 100644 --- a/public/pages/InflightQueries/InflightQueries.tsx +++ b/public/pages/InflightQueries/InflightQueries.tsx @@ -18,32 +18,28 @@ import { EuiInMemoryTable, EuiButton, } from '@elastic/eui'; -import embed from 'vega-embed'; -import type { VisualizationSpec } from 'vega-embed'; import { CoreStart } from 'opensearch-dashboards/public'; import { Duration } from 'luxon'; import { filesize } from 'filesize'; import { LiveSearchQueryResponse } from '../../../types/types'; import { retrieveLiveQueries } from '../../../common/utils/QueryUtils'; import { API_ENDPOINTS } from '../../../common/utils/apiendpoints'; +import { VegaChart } from './vegachart'; +import { createVegaPieSpec } from './vegaspecs/pieChartSpec'; +import { createVegaBarSpec } from './vegaspecs/barChartSpec'; export const InflightQueries = ({ core }: { core: CoreStart }) => { const DEFAULT_REFRESH_INTERVAL = 5000; // default 5s const TOP_N_DISPLAY_LIMIT = 9; const isFetching = useRef(false); const [query, setQuery] = useState(null); - const [nodeChartError, setNodeChartError] = useState(null); - const [indexChartError, setIndexChartError] = useState(null); - const [nodeCounts, setNodeCounts] = useState({}); - const [indexCounts, setIndexCounts] = useState({}); + const [nodeCounts, setNodeCounts] = useState>({}); + const [indexCounts, setIndexCounts] = useState>({}); const [autoRefreshEnabled, setAutoRefreshEnabled] = useState(true); const [refreshInterval, setRefreshInterval] = useState(DEFAULT_REFRESH_INTERVAL); - const chartRefByNode = useRef(null); - const chartRefByIndex = useRef(null); - const liveQueries = query?.response?.live_queries ?? []; const convertTime = (unixTime: number) => { @@ -55,11 +51,10 @@ export const InflightQueries = ({ core }: { core: CoreStart }) => { const fetchliveQueries = async () => { const retrievedQueries = await retrieveLiveQueries(core); - if (retrievedQueries.error || retrievedQueries.ok === false) { - const errorMessage = retrievedQueries.error || 'Failed to load live queries'; - setNodeChartError(errorMessage); - setIndexChartError(errorMessage); + if (retrievedQueries.ok === false) { setQuery(null); + setNodeCounts({}); + setIndexCounts({}); return; } @@ -192,6 +187,7 @@ export const InflightQueries = ({ core }: { core: CoreStart }) => { const onChartChangeByNode = (optionId: string) => { setSelectedChartIdByNode(optionId); }; + const [selectedItems, setSelectedItems] = useState([]); const selection = { @@ -236,101 +232,6 @@ export const InflightQueries = ({ core }: { core: CoreStart }) => { }; }, [query]); - const getChartData = (counts: Record, type: 'node' | 'index') => { - return Object.entries(counts).map(([key, value]) => ({ - label: type === 'node' ? `${key}` : key, - value, - })); - }; - - const getChartSpec = (type: string, chartType: 'node' | 'index'): VisualizationSpec => { - const isDonut = type.includes('donut'); - - return { - width: 400, - height: 300, - mark: isDonut ? { type: 'arc', innerRadius: 50 } : { type: 'bar' }, - encoding: isDonut - ? { - theta: { field: 'value', type: 'quantitative' }, - color: { - field: 'label', - type: 'nominal', - title: chartType === 'node' ? 'Nodes' : 'Indices', - }, - tooltip: [ - { field: 'label', type: 'nominal', title: chartType === 'node' ? 'Node' : 'Index' }, - { field: 'value', type: 'quantitative', title: 'Count' }, - ], - } - : { - x: { - field: 'label', - type: 'nominal', - axis: { labelAngle: -45, title: chartType === 'node' ? 'Node' : 'Index' }, - }, - y: { - field: 'value', - type: 'quantitative', - axis: { title: 'Count' }, - }, - color: { - field: 'label', - type: 'nominal', - title: chartType === 'node' ? 'Node' : 'Index', - }, - tooltip: [ - { field: 'label', type: 'nominal', title: chartType === 'node' ? 'Node' : 'Index' }, - { field: 'value', type: 'quantitative', title: 'Count' }, - ], - }, - }; - }; - - useEffect(() => { - if (chartRefByNode.current) { - embed( - chartRefByNode.current, - { - ...getChartSpec(selectedChartIdByNode, 'node'), - data: { values: getChartData(nodeCounts, 'node') }, - }, - { actions: false, renderer: 'svg' } - ) - .then(() => setNodeChartError(null)) - .catch((error) => { - console.error('Node chart rendering failed:', error); - setNodeChartError('Failed to load chart data'); - core.notifications.toasts.addError(error, { - title: 'Failed to render Queries by Node chart', - toastMessage: 'Please check data or browser console for details.', - }); - }); - } - }, [nodeCounts, selectedChartIdByNode]); - - useEffect(() => { - if (chartRefByIndex.current) { - embed( - chartRefByIndex.current, - { - ...getChartSpec(selectedChartIdByIndex, 'index'), - data: { values: getChartData(indexCounts, 'index') }, - }, - { actions: false, renderer: 'svg' } - ) - .then(() => setIndexChartError(null)) - .catch((error) => { - console.error('Index chart rendering failed:', error); - setIndexChartError('Failed to load chart data'); - core.notifications.toasts.addError(error, { - title: 'Failed to render Queries by Index chart', - toastMessage: 'Please check data or browser console for details.', - }); - }); - } - }, [indexCounts, selectedChartIdByIndex]); - return (
@@ -477,10 +378,10 @@ export const InflightQueries = ({ core }: { core: CoreStart }) => { + - {/* Queries by Node */} - - + +

Queries by Node

@@ -491,38 +392,24 @@ export const InflightQueries = ({ core }: { core: CoreStart }) => { idSelected={selectedChartIdByNode} onChange={onChartChangeByNode} color="primary" + buttonSize="compressed" />
- {Object.keys(nodeCounts).length > 0 && !nodeChartError ? ( -
- ) : nodeChartError ? ( - - - -

{nodeChartError}

-
- -
) : ( - - - -

No data available

-
- -
+ )} - {/* Queries by Index */} - - + +

Queries by Index

@@ -533,35 +420,23 @@ export const InflightQueries = ({ core }: { core: CoreStart }) => { idSelected={selectedChartIdByIndex} onChange={onChartChangeByIndex} color="primary" + buttonSize="compressed" />
- {Object.keys(indexCounts).length > 0 && !indexChartError ? ( -
- ) : indexChartError ? ( - - - -

{indexChartError}

-
- -
) : ( - - - -

No data available

-
- -
+ )} + { placeholder: 'Search queries', schema: false, }, - toolsLeft: selectedItems.length > 0 && [ - { - await Promise.allSettled( - selectedItems.map((item) => - core.http.post(API_ENDPOINTS.CANCEL_TASK(item.id)).then( - () => ({ status: 'fulfilled', id: item.id }), - (err) => ({ status: 'rejected', id: item.id, error: err }) - ) - ) - ); - setSelectedItems([]); - }} - > - Cancel {selectedItems.length} {selectedItems.length !== 1 ? 'queries' : 'query'} - , - ], + toolsLeft: + selectedItems.length > 0 + ? [ + { + await Promise.allSettled( + selectedItems.map((item) => + core.http.post(API_ENDPOINTS.CANCEL_TASK(item.id)).then( + () => ({ status: 'fulfilled', id: item.id }), + (err) => ({ status: 'rejected', id: item.id, error: err }) + ) + ) + ); + setSelectedItems([]); + }} + > + Cancel {selectedItems.length}{' '} + {selectedItems.length !== 1 ? 'queries' : 'query'} + , + ] + : undefined, toolsRight: [ { { name: 'Status', render: (item) => - item.measurements?.is_cancelled === true ? ( + item.is_cancelled === true ? ( Cancelled @@ -681,7 +560,7 @@ export const InflightQueries = ({ core }: { core: CoreStart }) => { icon: 'trash', color: 'danger', type: 'icon', - available: (item) => item.measurements?.is_cancelled !== true, + available: (item) => item.is_cancelled !== true, onClick: async (item) => { try { const taskId = item.id; diff --git a/public/pages/InflightQueries/vegachart.tsx b/public/pages/InflightQueries/vegachart.tsx new file mode 100644 index 00000000..de82b4c8 --- /dev/null +++ b/public/pages/InflightQueries/vegachart.tsx @@ -0,0 +1,51 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React, { useEffect, useRef } from 'react'; +import vega from 'vega'; + +interface VegaChartProps { + spec: object; // Vega spec JSON + width?: number; + height?: number; +} + +export const VegaChart = ({ spec, width = 400, height = 400 }: VegaChartProps) => { + const containerRef = useRef(null); + const viewRef = useRef(null); + + useEffect(() => { + if (!containerRef.current) return; + + const runtime = vega.parse(spec); + const view = new vega.View(runtime) + .renderer('canvas') + .initialize(containerRef.current) + .width(width) + .height(height) + .run(); + + viewRef.current = view; + + return () => { + if (viewRef.current) { + viewRef.current.finalize(); + viewRef.current = null; + } + }; + }, [spec, width, height]); + + return ( +
+ ); +}; diff --git a/public/pages/InflightQueries/vegaspecs/barChartSpec.ts b/public/pages/InflightQueries/vegaspecs/barChartSpec.ts new file mode 100644 index 00000000..6b69c2d2 --- /dev/null +++ b/public/pages/InflightQueries/vegaspecs/barChartSpec.ts @@ -0,0 +1,76 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +export const createVegaBarSpec = (data: Record, width = 100, height = 200) => { + const values = Object.entries(data).map(([category, value]) => ({ category, value })); + + return { + $schema: 'https://vega.github.io/schema/vega/v5.json', + width, + height, + padding: 5, + autosize: 'pad', + data: [ + { + name: 'table', + values, + }, + ], + scales: [ + { + name: 'yscale', + type: 'band', + domain: { data: 'table', field: 'category' }, + range: 'height', + padding: 0.05, + round: true, + }, + { + name: 'xscale', + domain: { data: 'table', field: 'value' }, + nice: true, + range: 'width', + }, + { + name: 'color', + type: 'ordinal', + domain: { data: 'table', field: 'category' }, + range: { scheme: 'category20' }, + }, + ], + axes: [ + { orient: 'left', scale: 'yscale' }, + { orient: 'bottom', scale: 'xscale' }, + ], + marks: [ + { + type: 'rect', + from: { data: 'table' }, + encode: { + enter: { + y: { scale: 'yscale', field: 'category' }, + height: { scale: 'yscale', band: 1 }, + x: { scale: 'xscale', value: 0 }, + x2: { scale: 'xscale', field: 'value' }, + fill: { scale: 'color', field: 'category' }, + }, + }, + }, + ], + legends: [ + { + fill: 'color', + orient: 'bottom', + title: 'Category', + columns: 3, // number of columns for legend items + columnPadding: 10, // optional spacing between columns + encode: { + labels: { update: { fontSize: { value: 16 } } }, + symbols: { update: { size: { value: 100 } } }, + }, + }, + ], + }; +}; diff --git a/public/pages/InflightQueries/vegaspecs/pieChartSpec.ts b/public/pages/InflightQueries/vegaspecs/pieChartSpec.ts new file mode 100644 index 00000000..7de36f7b --- /dev/null +++ b/public/pages/InflightQueries/vegaspecs/pieChartSpec.ts @@ -0,0 +1,82 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +export const createVegaPieSpec = (data: Record, width = 350, height = 350) => { + const values = Object.entries(data).map(([category, value]) => ({ category, value })); + + return { + $schema: 'https://vega.github.io/schema/vega/v5.json', + width, + height: height + 100, + padding: { top: 10, left: 10, right: 10, bottom: 100 }, + autosize: 'pad', + signals: [ + { name: 'startAngle', value: 0 }, + { name: 'endAngle', value: 6.29 }, + { name: 'padAngle', value: 0 }, + { name: 'innerRadius', value: 50 }, + { name: 'cornerRadius', value: 0 }, + { name: 'sort', value: false }, + ], + data: [ + { + name: 'table', + values, + transform: [ + { + type: 'pie', + field: 'value', + startAngle: { signal: 'startAngle' }, + endAngle: { signal: 'endAngle' }, + padAngle: { signal: 'padAngle' }, + sort: { signal: 'sort' }, + }, + ], + }, + ], + scales: [ + { + name: 'color', + type: 'ordinal', + domain: { data: 'table', field: 'category' }, + range: { scheme: 'category20' }, + }, + ], + marks: [ + { + type: 'arc', + from: { data: 'table' }, + encode: { + enter: { + fill: { scale: 'color', field: 'category' }, + x: { signal: 'width / 2' }, + y: { signal: '(height - 100) / 2' }, + startAngle: { field: 'startAngle' }, + endAngle: { field: 'endAngle' }, + innerRadius: { signal: 'innerRadius' }, + outerRadius: { signal: 'min(width, height) / 2 - 50' }, + }, + update: { + cornerRadius: { signal: 'cornerRadius' }, + padAngle: { signal: 'padAngle' }, + }, + }, + }, + ], + legends: [ + { + fill: 'color', + orient: 'bottom', + title: 'Category', + columns: 3, // number of columns for legend items + columnPadding: 10, // optional spacing between columns + encode: { + labels: { update: { fontSize: { value: 16 } } }, + symbols: { update: { size: { value: 100 } } }, + }, + }, + ], + }; +};