diff --git a/.oxlintrc.json b/.oxlintrc.json index 2ec28ddcb..5e10cd949 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -14,6 +14,7 @@ "browser": true }, "rules": { + "typescript/no-explicit-any": "error", "typescript/explicit-function-return-type": "error", "typescript/explicit-module-boundary-types": "error", "typescript/array-type": ["error", { "default": "array-simple" }], @@ -44,9 +45,18 @@ "react/jsx-no-useless-fragment": "warn", "react/no-unescaped-entities": "error", "react/exhaustive-deps": "error", - "react/react-compiler": "warn", - "react-doctor/no-fetch-in-effect": "warn", - "react-doctor/no-derived-state": "warn", + "react/unsupported-syntax": "error", + "react/no-deriving-state-in-effects": "error", + "react/invariant": "error", + "react/rule-suppression": "error", + "react/syntax": "error", + "react/todo": "error", + "react/capitalized-calls": "error", + "react/exhaustive-effect-dependencies": "error", + "react/hooks": "error", + "react/memo-dependencies": "error", + "react-doctor/no-fetch-in-effect": "error", + "react-doctor/no-derived-state": "error", "react/jsx-curly-brace-presence": [ "error", { "props": "never", "children": "never", "propElementValues": "always" } diff --git a/README.md b/README.md index 1153f25ea..b5459da4d 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,8 @@ Then in [`perses`](https://github.com/perses/perses) repository: Run `npm run lint` for the regular Oxlint checks, including the React Doctor rules configured in `.oxlintrc.json`. Run `npm run doctor` for the full React Doctor project scan. Pull requests and pushes to `main` also run the scan in GitHub -Actions. +Actions. React Compiler rules and the configured React Doctor rules are enforced as errors; resolve their diagnostics +before merging. ### Working with Snapshots diff --git a/STYLEGUIDE.md b/STYLEGUIDE.md index 8600e18d0..886612521 100644 --- a/STYLEGUIDE.md +++ b/STYLEGUIDE.md @@ -37,7 +37,7 @@ touch without expanding the task into unrelated cleanup. for dynamic values passed across memoized boundaries. Do not add memoization blindly or omit dependencies. - Memoize React context values when they contain objects or functions whose identity would otherwise change each render. - Avoid array-index keys when a stable domain identifier exists. -- Treat `react/react-compiler` and `react-perf` diagnostics as design feedback. Fix new warnings rather than disabling +- Treat React Compiler and `react-perf` diagnostics as design feedback. Fix errors and new warnings rather than disabling the rule or increasing the repository warning ceiling. ## Components, state, and accessibility diff --git a/alertmanager/src/components/ColumnsEditor.test.tsx b/alertmanager/src/components/ColumnsEditor.test.tsx new file mode 100644 index 000000000..2a5f84ced --- /dev/null +++ b/alertmanager/src/components/ColumnsEditor.test.tsx @@ -0,0 +1,84 @@ +// Copyright The Perses Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { fireEvent, render, screen } from '@testing-library/react'; +import type { ReactElement } from 'react'; +import { useCallback, useState } from 'react'; + +import type { BaseColumnDefinition } from './ColumnsEditor'; +import { ColumnsEditor } from './ColumnsEditor'; + +const SORT_MODES = { alphabetical: 'Alphabetical' }; +const INITIAL_COLUMNS = [{ name: 'first' }, { name: 'second' }]; +const getName = (column: BaseColumnDefinition): string => column.name; +const renderName = (column: BaseColumnDefinition): ReactElement => ( + +); +const onUpdate = vi.fn(); + +function Editor(): ReactElement { + const [columns, setColumns] = useState(INITIAL_COLUMNS); + const onAdd = useCallback((): void => setColumns((previous) => [...previous, { name: 'added' }]), []); + const onRemove = useCallback( + (index: number): void => setColumns((previous) => previous.filter((_, i) => i !== index)), + [], + ); + const move = useCallback((index: number, offset: number): void => { + setColumns((previous) => { + const next = [...previous]; + const [column] = next.splice(index, 1); + if (column) next.splice(index + offset, 0, column); + return next; + }); + }, []); + const onMoveUp = useCallback((index: number): void => move(index, -1), [move]); + const onMoveDown = useCallback((index: number): void => move(index, 1), [move]); + + return ( + + ); +} + +describe('ColumnsEditor', () => { + it('keeps local edits with their column when moving, removing, and adding columns', () => { + render(); + const firstInput = screen.getAllByRole('textbox', { name: 'Column name' })[0]!; + fireEvent.change(firstInput, { target: { value: 'draft' } }); + + fireEvent.click(screen.getAllByRole('button', { name: 'Move column down' })[0]!); + expect(screen.getAllByRole('textbox', { name: 'Column name' })[1]).toBe(firstInput); + expect(firstInput).toHaveValue('draft'); + + fireEvent.click(screen.getAllByRole('button', { name: 'Move column up' })[1]!); + expect(screen.getAllByRole('textbox', { name: 'Column name' })[0]).toBe(firstInput); + + fireEvent.click(screen.getAllByRole('button', { name: 'Remove column' })[1]!); + fireEvent.click(screen.getByRole('button', { name: 'Add column' })); + expect(screen.getAllByRole('textbox', { name: 'Column name' })[0]).toBe(firstInput); + expect(firstInput).toHaveValue('draft'); + expect(screen.getAllByRole('textbox', { name: 'Column name' })[1]).toHaveValue('added'); + }); +}); diff --git a/alertmanager/src/components/ColumnsEditor.tsx b/alertmanager/src/components/ColumnsEditor.tsx index e3abb391f..76c906850 100644 --- a/alertmanager/src/components/ColumnsEditor.tsx +++ b/alertmanager/src/components/ColumnsEditor.tsx @@ -32,7 +32,7 @@ import ArrowUpIcon from 'mdi-material-ui/ArrowUp'; import DeleteIcon from 'mdi-material-ui/Delete'; import PlusIcon from 'mdi-material-ui/Plus'; import type { ReactElement } from 'react'; -import { useCallback, useRef } from 'react'; +import { useCallback, useState } from 'react'; export interface BaseColumnDefinition { name: string; @@ -202,22 +202,27 @@ export function ColumnsEditor(props: ColumnsEdit renderNameField, } = props; - const idCounterRef = useRef(0); - const idsRef = useRef([]); - - while (idsRef.current.length < columns.length) { - idsRef.current.push(idCounterRef.current++); + const [columnIds, setColumnIds] = useState(() => ({ + ids: columns.map((_, index) => index), + nextId: columns.length, + })); + if (columnIds.ids.length !== columns.length) { + const ids = columnIds.ids.slice(0, columns.length); + let nextId = columnIds.nextId; + while (ids.length < columns.length) { + ids.push(nextId++); + } + setColumnIds({ ids, nextId }); } - idsRef.current.length = columns.length; const handleAdd = useCallback((): void => { - idsRef.current.push(idCounterRef.current++); + setColumnIds(({ ids, nextId }) => ({ ids: [...ids, nextId], nextId: nextId + 1 })); onAdd(); }, [onAdd]); const handleRemove = useCallback( (index: number): void => { - idsRef.current.splice(index, 1); + setColumnIds((previous) => ({ ...previous, ids: previous.ids.filter((_, i) => i !== index) })); onRemove(index); }, [onRemove], @@ -226,9 +231,12 @@ export function ColumnsEditor(props: ColumnsEdit const handleMoveUp = useCallback( (index: number): void => { if (index <= 0) return; - const ids = idsRef.current; - const id = ids.splice(index, 1)[0]!; - ids.splice(index - 1, 0, id); + setColumnIds((previous) => { + const ids = [...previous.ids]; + const id = ids.splice(index, 1)[0]!; + ids.splice(index - 1, 0, id); + return { ...previous, ids }; + }); onMoveUp(index); }, [onMoveUp], @@ -236,13 +244,16 @@ export function ColumnsEditor(props: ColumnsEdit const handleMoveDown = useCallback( (index: number): void => { - const ids = idsRef.current; - if (index >= ids.length - 1) return; - const id = ids.splice(index, 1)[0]!; - ids.splice(index + 1, 0, id); + if (index >= columns.length - 1) return; + setColumnIds((previous) => { + const ids = [...previous.ids]; + const id = ids.splice(index, 1)[0]!; + ids.splice(index + 1, 0, id); + return { ...previous, ids }; + }); onMoveDown(index); }, - [onMoveDown], + [onMoveDown, columns.length], ); return ( @@ -252,7 +263,7 @@ export function ColumnsEditor(props: ColumnsEdit {description} {columns.map((column, index) => ( - + {index > 0 && } { + it('preserves an uncommitted draft across renders and commits it on blur', () => { + const onCommit = vi.fn(); + const { rerender } = render(); + fireEvent.change(screen.getByRole('textbox'), { target: { value: 'draft' } }); + rerender(); + + expect(screen.getByRole('textbox')).toHaveValue('draft'); + expect(onCommit).not.toHaveBeenCalled(); + fireEvent.blur(screen.getByRole('textbox')); + expect(onCommit).toHaveBeenCalledWith('draft'); + }); + + it('resets a draft when the committed value changes or is cleared', () => { + const onCommit = vi.fn(); + const { rerender } = render(); + fireEvent.change(screen.getByRole('textbox'), { target: { value: 'draft' } }); + rerender(); + expect(screen.getByRole('textbox')).toHaveValue('updated'); + + rerender(); + expect(screen.getByRole('textbox')).toHaveValue(''); + expect(onCommit).not.toHaveBeenCalled(); + }); +}); diff --git a/alertmanager/src/components/LazyTextField.tsx b/alertmanager/src/components/LazyTextField.tsx index 02bcab7fe..0232bb5ee 100644 --- a/alertmanager/src/components/LazyTextField.tsx +++ b/alertmanager/src/components/LazyTextField.tsx @@ -13,7 +13,7 @@ import { TextField } from '@mui/material'; import type { ChangeEvent, ReactElement } from 'react'; -import { useCallback, useEffect, useState } from 'react'; +import { useCallback, useState } from 'react'; export interface LazyTextFieldProps { label: string; @@ -29,9 +29,11 @@ export function LazyTextField(props: LazyTextFieldProps): ReactElement { const { value, onCommit, ...textFieldProps } = props; const [draftValue, setDraftValue] = useState(value ?? ''); - useEffect(() => { + const [previousValue, setPreviousValue] = useState(value); + if (value !== previousValue) { + setPreviousValue(value); setDraftValue(value ?? ''); - }, [value]); + } const handleChange = useCallback((event: ChangeEvent): void => { setDraftValue(event.target.value); diff --git a/alertmanager/src/explore/AlertManagerSilencesExplorer.tsx b/alertmanager/src/explore/AlertManagerSilencesExplorer.tsx index 86ebda342..790f43be4 100644 --- a/alertmanager/src/explore/AlertManagerSilencesExplorer.tsx +++ b/alertmanager/src/explore/AlertManagerSilencesExplorer.tsx @@ -20,7 +20,7 @@ import type { DatasourceSelector, QueryDefinition } from '@perses-dev/spec'; import { useQueryClient } from '@tanstack/react-query'; import BellOffIcon from 'mdi-material-ui/BellOff'; import type { ReactElement } from 'react'; -import { useCallback, useMemo, useRef, useState } from 'react'; +import { useCallback, useMemo, useState } from 'react'; import { SilenceForm } from '../components/SilenceForm'; import type { AlertManagerClient, PostableSilence } from '../model'; @@ -50,10 +50,10 @@ function CreateSilenceButton({ queries }: { queries: QueryDefinition[] }): React const { successSnackbar, exceptionSnackbar } = useSnackbar(); const [open, setOpen] = useState(false); - const formKeyRef = useRef(0); + const [formKey, setFormKey] = useState(0); const handleOpen = useCallback(() => { - formKeyRef.current++; + setFormKey((previous) => previous + 1); setOpen(true); }, []); @@ -77,7 +77,7 @@ function CreateSilenceButton({ queries }: { queries: QueryDefinition[] }): React - setOpen(false)} onSubmit={handleSubmit} /> + setOpen(false)} onSubmit={handleSubmit} /> ); } diff --git a/alertmanager/src/plugins/alert-table/AlertTablePanel.tsx b/alertmanager/src/plugins/alert-table/AlertTablePanel.tsx index bc44cd608..1c54ffee0 100644 --- a/alertmanager/src/plugins/alert-table/AlertTablePanel.tsx +++ b/alertmanager/src/plugins/alert-table/AlertTablePanel.tsx @@ -51,7 +51,7 @@ import MagnifyIcon from 'mdi-material-ui/Magnify'; import UnfoldLessHorizontalIcon from 'mdi-material-ui/UnfoldLessHorizontal'; import UnfoldMoreHorizontalIcon from 'mdi-material-ui/UnfoldMoreHorizontal'; import type { ReactElement } from 'react'; -import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useCallback, useMemo, useState } from 'react'; import { SilenceForm } from '../../components/SilenceForm'; import { StatusBadge } from '../../components/StatusBadge'; @@ -347,9 +347,9 @@ export function AlertTablePanel({ spec, queryResults, contentDimensions }: Alert const queryClient = useQueryClient(); const [silenceTarget, setSilenceTarget] = useState(null); - const silenceKeyRef = useRef(0); + const [silenceKey, setSilenceKey] = useState(0); const handleSetSilenceTarget = useCallback((alert: Alert) => { - silenceKeyRef.current++; + setSilenceKey((previous) => previous + 1); setSilenceTarget(alert); }, []); const [search, setSearch] = useState(''); @@ -416,9 +416,11 @@ export function AlertTablePanel({ spec, queryResults, contentDimensions }: Alert const [groupBy, setGroupBy] = useState(resolvedDefaultGroupBy); - useEffect(() => { + const [previousDefaultGroupBy, setPreviousDefaultGroupBy] = useState(resolvedDefaultGroupBy); + if (resolvedDefaultGroupBy !== previousDefaultGroupBy) { + setPreviousDefaultGroupBy(resolvedDefaultGroupBy); setGroupBy(resolvedDefaultGroupBy); - }, [resolvedDefaultGroupBy]); + } const effectiveActions = useMemo( () => spec.allowedActions ?? ALL_ALERT_ACTIONS, @@ -519,18 +521,17 @@ export function AlertTablePanel({ spec, queryResults, contentDimensions }: Alert return result; }, [alerts, groupBy, allTrackedKeys, sortState]); - const prevGroupKeysRef = useRef(''); - useEffect(() => { - const currentKeys = groups.map((g) => g.key).join('\0'); - if (currentKeys === prevGroupKeysRef.current) return; - prevGroupKeysRef.current = currentKeys; + const currentKeys = useMemo(() => groups.map((g) => g.key).join('\0'), [groups]); + const [previousGroupKeys, setPreviousGroupKeys] = useState(''); + if (currentKeys !== previousGroupKeys) { + setPreviousGroupKeys(currentKeys); if (groups.length === 1) { setExpandedGroups(new Set(groups.map((g) => g.key))); } else { setExpandedGroups(new Set()); } - }, [groups]); + } const handleToggleGroup = useCallback((key: string) => { setExpandedGroups((prev) => { @@ -701,7 +702,7 @@ export function AlertTablePanel({ spec, queryResults, contentDimensions }: Alert setSilenceTarget(null)} onSubmit={handleSilenceSubmit} diff --git a/alertmanager/src/plugins/alertmanager-alerts-query/AlertManagerAlertsQueryEditor.tsx b/alertmanager/src/plugins/alertmanager-alerts-query/AlertManagerAlertsQueryEditor.tsx index e700865b9..c75193486 100644 --- a/alertmanager/src/plugins/alertmanager-alerts-query/AlertManagerAlertsQueryEditor.tsx +++ b/alertmanager/src/plugins/alertmanager-alerts-query/AlertManagerAlertsQueryEditor.tsx @@ -12,12 +12,11 @@ // limitations under the License. import { Checkbox, FormControl, FormControlLabel, Stack } from '@mui/material'; -import { useId } from '@perses-dev/components'; import type { DatasourceSelectProps } from '@perses-dev/plugin-system'; import { DatasourceSelect } from '@perses-dev/plugin-system'; import { produce } from 'immer'; import type { ReactElement } from 'react'; -import { useCallback } from 'react'; +import { useId, useCallback } from 'react'; import { LazyTextField } from '../../components/LazyTextField'; import { @@ -36,7 +35,7 @@ interface AlertManagerAlertsQueryEditorProps { export function AlertManagerAlertsQueryEditor(props: AlertManagerAlertsQueryEditorProps): ReactElement { const { onChange, value } = props; const datasourceSelectValue = value.datasource ?? DEFAULT_ALERTMANAGER; - const datasourceSelectLabelID = useId('alertmanager-datasource-label'); + const datasourceSelectLabelID = useId(); const handleDatasourceChange: DatasourceSelectProps['onChange'] = (next) => { if (isAlertManagerDatasourceSelector(next)) { diff --git a/alertmanager/src/plugins/alertmanager-silences-query/AlertManagerSilencesQueryEditor.tsx b/alertmanager/src/plugins/alertmanager-silences-query/AlertManagerSilencesQueryEditor.tsx index 6831ef6b0..29880ea16 100644 --- a/alertmanager/src/plugins/alertmanager-silences-query/AlertManagerSilencesQueryEditor.tsx +++ b/alertmanager/src/plugins/alertmanager-silences-query/AlertManagerSilencesQueryEditor.tsx @@ -12,12 +12,11 @@ // limitations under the License. import { FormControl, Stack } from '@mui/material'; -import { useId } from '@perses-dev/components'; import type { DatasourceSelectProps } from '@perses-dev/plugin-system'; import { DatasourceSelect } from '@perses-dev/plugin-system'; import { produce } from 'immer'; import type { ReactElement } from 'react'; -import { useCallback } from 'react'; +import { useId, useCallback } from 'react'; import { LazyTextField } from '../../components/LazyTextField'; import { @@ -36,7 +35,7 @@ interface AlertManagerSilencesQueryEditorProps { export function AlertManagerSilencesQueryEditor(props: AlertManagerSilencesQueryEditorProps): ReactElement { const { onChange, value } = props; const datasourceSelectValue = value.datasource ?? DEFAULT_ALERTMANAGER; - const datasourceSelectLabelID = useId('alertmanager-silences-datasource-label'); + const datasourceSelectLabelID = useId(); const handleDatasourceChange: DatasourceSelectProps['onChange'] = (next) => { if (isAlertManagerDatasourceSelector(next)) { diff --git a/alertmanager/src/plugins/silence-table/SilenceTablePanel.tsx b/alertmanager/src/plugins/silence-table/SilenceTablePanel.tsx index 6f1dd5283..efa5ea40e 100644 --- a/alertmanager/src/plugins/silence-table/SilenceTablePanel.tsx +++ b/alertmanager/src/plugins/silence-table/SilenceTablePanel.tsx @@ -148,24 +148,23 @@ export function SilenceTablePanel({ spec, queryResults, contentDimensions }: Sil const handleExpire = useCallback(async () => { if (!amClient || !expireTarget) return; setIsExpiring(true); - try { - await amClient.deleteSilence(expireTarget.id); - setExpireTarget(null); - successSnackbar('Silence expired successfully'); - queryClient.invalidateQueries({ queryKey: ['query', 'AlertsQuery'] }); - queryClient.invalidateQueries({ queryKey: ['query', 'SilencesQuery'] }); - } catch (err) { - exceptionSnackbar(err); - } finally { - setIsExpiring(false); - } + await amClient + .deleteSilence(expireTarget.id) + .then(() => { + setExpireTarget(null); + successSnackbar('Silence expired successfully'); + queryClient.invalidateQueries({ queryKey: ['query', 'AlertsQuery'] }); + queryClient.invalidateQueries({ queryKey: ['query', 'SilencesQuery'] }); + }) + .catch(exceptionSnackbar) + .finally(() => setIsExpiring(false)); }, [amClient, expireTarget, queryClient, successSnackbar, exceptionSnackbar]); const [search, setSearch] = useState(''); const effectiveActions = useMemo( - () => spec?.allowedActions ?? ALL_SILENCE_ACTIONS, - [spec?.allowedActions], + () => spec.allowedActions ?? ALL_SILENCE_ACTIONS, + [spec.allowedActions], ); const showActionsColumn = effectiveActions.length > 0; diff --git a/canvas/src/components/editor/EditorCanvas.tsx b/canvas/src/components/editor/EditorCanvas.tsx index 33b099d0e..67221f3a0 100644 --- a/canvas/src/components/editor/EditorCanvas.tsx +++ b/canvas/src/components/editor/EditorCanvas.tsx @@ -202,11 +202,11 @@ export function EditorCanvas({ return ( <> - {/* eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions */} + {/* oxlint-disable-next-line jsx-a11y/no-noninteractive-element-interactions */} { onChange( produce(value, (draft) => { - draft.edgeThresholdWidths ??= []; + draft.edgeThresholdWidths = draft.edgeThresholdWidths ?? []; const existingIdx = draft.edgeThresholdWidths.findIndex((w) => w.value === stepValue); if (strokeWidth !== undefined) { if (existingIdx >= 0) { diff --git a/canvas/src/contexts/SpecContext.tsx b/canvas/src/contexts/SpecContext.tsx index 802fc7af9..4567fc4ab 100644 --- a/canvas/src/contexts/SpecContext.tsx +++ b/canvas/src/contexts/SpecContext.tsx @@ -74,7 +74,7 @@ export function SpecProvider({ spec, onChange, children }: SpecProviderProps): R const id = generateId('node'); onChange( produce(spec, (draft) => { - (draft.nodes ??= []).push({ + (draft.nodes = draft.nodes ?? []).push({ id, position, width: DEFAULT_NODE_WIDTH, @@ -93,7 +93,7 @@ export function SpecProvider({ spec, onChange, children }: SpecProviderProps): R const id = generateId('bg'); onChange( produce(spec, (draft) => { - (draft.backgrounds ??= []).push({ id, position, width, height }); + (draft.backgrounds = draft.backgrounds ?? []).push({ id, position, width, height }); }), ); selectItems(new Set([id])); diff --git a/canvas/src/hooks/useEdgeConnect.ts b/canvas/src/hooks/useEdgeConnect.ts index 2e8aecd94..e044ec325 100644 --- a/canvas/src/hooks/useEdgeConnect.ts +++ b/canvas/src/hooks/useEdgeConnect.ts @@ -199,7 +199,7 @@ export function useEdgeConnect(): UseEdgeConnectResult { if (!snap && sourceNode && pointInsideNode(sourceNode, pt, SNAP_RADIUS)) { return; } - (draft.edges ??= []).push(buildNewEdge(dragEdge, snap, pt)); + (draft.edges = draft.edges ?? []).push(buildNewEdge(dragEdge, snap, pt)); } }, [dragEdge, nodeById], diff --git a/datasourcevariable/src/DatasourceVariable.tsx b/datasourcevariable/src/DatasourceVariable.tsx index af531e818..b37365071 100644 --- a/datasourcevariable/src/DatasourceVariable.tsx +++ b/datasourcevariable/src/DatasourceVariable.tsx @@ -41,16 +41,17 @@ export const DatasourceVariableOptionEditor = (props: OptionsEditorProps option.label === datasourcePluginKind) ?? EMPTY_SELECTED_KIND; // If there is no selected kind and there are available options, select the first one + const selectedKindValue = selectedKind.value; useEffect(() => { const datasourcePluginKindArray = Array.from(datasourcePluginKindSet); if ( - selectedKind.value === EMPTY_SELECTED_KIND.value && + selectedKindValue === EMPTY_SELECTED_KIND.value && datasourcePluginKindArray.length > 0 && datasourcePluginKindArray[0] ) { onChange({ datasourcePluginKind: datasourcePluginKindArray[0] }); } - }, [selectedKind, datasourcePluginKind, onChange, datasourcePluginKindSet]); + }, [selectedKindValue, onChange, datasourcePluginKindSet]); return ( { + const level = api.value(0); + const start = api.coord([api.value(1), level]); + const end = api.coord([api.value(2), level]); + const height = (((api.size && api.size([0, 1])) || [0, 20]) as number[])[1]; + const width = (end?.[0] ?? 0) - (start?.[0] ?? 0); + + return { + type: 'rect', + transition: ['shape'], + shape: { + x: start?.[0], + y: (start?.[1] ?? 0) - (height ?? 0) / 2, + width, + height: (height ?? ITEM_GAP) - ITEM_GAP, + r: 0, + }, + style: { + fill: api.visual('color'), + }, + emphasis: { + style: { + stroke: '#000', + }, + }, + textConfig: { + position: 'insideLeft', + }, + textContent: { + style: { + text: api.value(3), + fill: '#000', + width: width - 4, + overflow: 'truncate', + ellipsis: '..', + truncateMinChar: 1, + }, + emphasis: { + style: { + stroke: '#000', + lineWidth: 0.5, + }, + }, + }, + } as CustomSeriesRenderItemReturn; +}; + export interface FlameChartProps { width: number; height: number; @@ -63,7 +110,7 @@ export function FlameChart(props: FlameChartProps): ReactElement { [palette, data.metadata, data.profile.stackTrace, selectedId, searchValue], ); - const handleItemClick = (params: MouseEventsParameters): void => { + const handleItemClick = useCallback((params: MouseEventsParameters): void => { const data: Sample = params.data; const functionName = data.value[6]; const functionId = data.name; @@ -78,7 +125,7 @@ export function FlameChart(props: FlameChartProps): ReactElement { mouseY: mouseEvent.event.clientY - 4, }); } - }; + }, []); const handleFocusBlock = (): void => { onSelectedIdChange(selectedItem.id); @@ -104,53 +151,6 @@ export function FlameChart(props: FlameChartProps): ReactElement { if (isCopied) setIsCopied(false); }; - const renderItem: CustomSeriesRenderItem = (params: CustomSeriesRenderItemParams, api: CustomSeriesRenderItemAPI) => { - const level = api.value(0); - const start = api.coord([api.value(1), level]); - const end = api.coord([api.value(2), level]); - const height = (((api.size && api.size([0, 1])) || [0, 20]) as number[])[1]; - const width = (end?.[0] ?? 0) - (start?.[0] ?? 0); - - return { - type: 'rect', - transition: ['shape'], - shape: { - x: start?.[0], - y: (start?.[1] ?? 0) - (height ?? 0) / 2, - width, - height: (height ?? ITEM_GAP) - ITEM_GAP, - r: 0, - }, - style: { - fill: api.visual('color'), - }, - emphasis: { - style: { - stroke: '#000', - }, - }, - textConfig: { - position: 'insideLeft', - }, - textContent: { - style: { - text: api.value(3), - fill: '#000', - width: width - 4, - overflow: 'truncate', - ellipsis: '..', - truncateMinChar: 1, - }, - emphasis: { - style: { - stroke: '#000', - lineWidth: 0.5, - }, - }, - }, - } as CustomSeriesRenderItemReturn; - }; - const option: EChartsCoreOption = useMemo(() => { if (data.profile.stackTrace === undefined) return chartsTheme.noDataOption; @@ -234,7 +234,7 @@ export function FlameChart(props: FlameChartProps): ReactElement { }} /> ), - [chartsTheme.echartsTheme, height, option, width], + [chartsTheme.echartsTheme, height, option, width, handleItemClick], ); return ( diff --git a/flamechart/src/components/FlameChartPanel.tsx b/flamechart/src/components/FlameChartPanel.tsx index 6ac9995b2..d06b50ce9 100644 --- a/flamechart/src/components/FlameChartPanel.tsx +++ b/flamechart/src/components/FlameChartPanel.tsx @@ -18,7 +18,7 @@ import type { PanelProps } from '@perses-dev/plugin-system'; import type { ProfileData, StackTrace } from '@perses-dev/spec'; import type { TitleComponentOption } from 'echarts'; import type { FC } from 'react'; -import { useState, useEffect, useMemo } from 'react'; +import { useState, useMemo } from 'react'; import type { FlameChartOptions } from '../flame-chart-model'; import { filterStackTraceById, getMaxDepth } from '../utils/data-transform'; @@ -45,12 +45,13 @@ export const FlameChartPanel: FC = (props) => { // This spec is used to manage settings temporarily const [liveSpec, setLiveSpec] = useState(spec); - // keep liveSpec up to date - useEffect(() => { + const [previousSpec, setPreviousSpec] = useState(spec); + if (spec !== previousSpec) { + setPreviousSpec(spec); setLiveSpec(spec); setSelectedId(0); setSearchValue(''); - }, [spec]); + } const chartsTheme = useChartsTheme(); const flameChartData = useMemo(() => { diff --git a/flamechart/src/components/Settings.tsx b/flamechart/src/components/Settings.tsx index 4ded9580e..2295b0e41 100644 --- a/flamechart/src/components/Settings.tsx +++ b/flamechart/src/components/Settings.tsx @@ -59,10 +59,6 @@ export function Settings(props: SettingsProps): ReactElement { setAnchorEl(null); }; - const isTableSelected = (): boolean => selectedView === 'table'; - const isFlameGraphSelected = (): boolean => selectedView === 'flame-graph'; - const isBothSelected = (): boolean => selectedView === 'both'; - // Update selected view based on the value of showTable and showFlameGraph const selectedView: 'table' | 'flame-graph' | 'both' | 'none' = useMemo(() => { if (!value.showTable && !value.showFlameGraph) { @@ -76,6 +72,10 @@ export function Settings(props: SettingsProps): ReactElement { } }, [value.showTable, value.showFlameGraph]); + const isTableSelected = (): boolean => selectedView === 'table'; + const isFlameGraphSelected = (): boolean => selectedView === 'flame-graph'; + const isBothSelected = (): boolean => selectedView === 'both'; + return ( {selectedId !== 0 && ( diff --git a/jaeger/src/plugins/JaegerTraceQueryEditor.test.tsx b/jaeger/src/plugins/JaegerTraceQueryEditor.test.tsx index 36279ab13..e403a33cf 100644 --- a/jaeger/src/plugins/JaegerTraceQueryEditor.test.tsx +++ b/jaeger/src/plugins/JaegerTraceQueryEditor.test.tsx @@ -17,10 +17,6 @@ import type { MockedFunction } from 'vitest'; import { JaegerTraceQueryEditor } from './JaegerTraceQueryEditor'; -vi.mock('@perses-dev/components', () => ({ - useId: vi.fn(() => 'jaeger-datasource-label'), -})); - vi.mock('@perses-dev/plugin-system', () => ({ DatasourceSelect: vi.fn(() => null), useDatasourceClient: vi.fn(), @@ -32,6 +28,9 @@ const mockedUseDatasourceSelectValueToSelector = useDatasourceSelectValueToSelec typeof useDatasourceSelectValueToSelector >; +const initialValue = { service: 'frontend', traceId: 'initial' }; +const updatedValue = { service: 'backend', traceId: 'updated' }; + describe('JaegerTraceQueryEditor', () => { beforeEach(() => { mockedUseDatasourceSelectValueToSelector.mockImplementation( @@ -119,4 +118,20 @@ describe('JaegerTraceQueryEditor', () => { await waitFor(() => expect(searchServices).toHaveBeenCalledTimes(1)); await waitFor(() => expect(searchOperations).toHaveBeenCalledWith('frontend')); }); + it('preserves drafts until committed values change', () => { + mockedUseDatasourceClient.mockReturnValue({ data: undefined } as never); + const onChange = vi.fn(); + const { rerender } = render(); + + fireEvent.change(screen.getByLabelText('Trace ID'), { target: { value: 'draft' } }); + fireEvent.change(screen.getByLabelText('Service'), { target: { value: 'draft-service' } }); + rerender(); + expect(screen.getByLabelText('Trace ID')).toHaveValue('draft'); + expect(screen.getByLabelText('Service')).toHaveValue('draft-service'); + expect(onChange).not.toHaveBeenCalled(); + + rerender(); + expect(screen.getByLabelText('Trace ID')).toHaveValue('updated'); + expect(screen.getByLabelText('Service')).toHaveValue('backend'); + }); }); diff --git a/jaeger/src/plugins/JaegerTraceQueryEditor.tsx b/jaeger/src/plugins/JaegerTraceQueryEditor.tsx index b2ce00aaf..37c4230a9 100644 --- a/jaeger/src/plugins/JaegerTraceQueryEditor.tsx +++ b/jaeger/src/plugins/JaegerTraceQueryEditor.tsx @@ -13,12 +13,11 @@ import type { TextFieldProps } from '@mui/material'; import { Autocomplete, FormControl, InputLabel, MenuItem, Select, Stack, TextField } from '@mui/material'; -import { useId } from '@perses-dev/components'; import type { DatasourceSelectProps } from '@perses-dev/plugin-system'; import { DatasourceSelect, useDatasourceClient, useDatasourceSelectValueToSelector } from '@perses-dev/plugin-system'; import { produce } from 'immer'; import type { ChangeEvent, ReactElement, SyntheticEvent } from 'react'; -import { useCallback, useEffect, useMemo, useState } from 'react'; +import { useId, useCallback, useEffect, useMemo, useState } from 'react'; import type { JaegerClient, JaegerTraceQuerySpec } from '../model'; import { DEFAULT_JAEGER, isDefaultJaegerSelector, isJaegerDatasourceSelector, JAEGER_DATASOURCE_KIND } from '../model'; @@ -46,7 +45,7 @@ export function JaegerTraceQueryEditor(props: JaegerTraceQueryEditorProps): Reac const { onChange, value } = props; const datasourceSelectValue = value.datasource ?? DEFAULT_JAEGER; const selectedDatasource = useDatasourceSelectValueToSelector(datasourceSelectValue, JAEGER_DATASOURCE_KIND); - const datasourceSelectLabelID = useId('jaeger-datasource-label'); + const datasourceSelectLabelID = useId(); const { data: client } = useDatasourceClient(selectedDatasource); const serviceOptions = useServiceOptions(client); const operationOptions = useOperationOptions(client, value.service); @@ -201,9 +200,11 @@ function LazyTextField(props: LazyTextFieldProps): ReactElement { const { value, onCommit, ...textFieldProps } = props; const [draftValue, setDraftValue] = useState(value ?? ''); - useEffect(() => { + const [previousValue, setPreviousValue] = useState(value); + if (value !== previousValue) { + setPreviousValue(value); setDraftValue(value ?? ''); - }, [value]); + } const handleChange = useCallback((event: ChangeEvent): void => { setDraftValue(event.target.value); @@ -226,9 +227,11 @@ function LazyAutocompleteTextField(props: LazyAutocompleteTextFieldProps): React const { value, options, onCommit, ...textFieldProps } = props; const [draftValue, setDraftValue] = useState(value ?? ''); - useEffect(() => { + const [previousValue, setPreviousValue] = useState(value); + if (value !== previousValue) { + setPreviousValue(value); setDraftValue(value ?? ''); - }, [value]); + } const commitValue = useCallback( (nextValue: string): void => { @@ -271,11 +274,16 @@ function LazyAutocompleteTextField(props: LazyAutocompleteTextFieldProps): React function useServiceOptions(client: JaegerClient | undefined): string[] { const [serviceOptions, setServiceOptions] = useState([]); + const [previousClient, setPreviousClient] = useState(client); + if (client !== previousClient) { + setPreviousClient(client); + if (!client) setServiceOptions([]); + } + useEffect(() => { let ignore = false; if (!client) { - setServiceOptions([]); return; } @@ -306,11 +314,16 @@ function useOperationOptions(client: JaegerClient | undefined, service: string | const [operationOptions, setOperationOptions] = useState([]); const normalizedService = useMemo(() => service?.trim(), [service]); + const [previousSource, setPreviousSource] = useState({ client, normalizedService }); + if (client !== previousSource.client || normalizedService !== previousSource.normalizedService) { + setPreviousSource({ client, normalizedService }); + if (!client || !normalizedService) setOperationOptions([]); + } + useEffect(() => { let ignore = false; if (!client || normalizedService === undefined || normalizedService === '') { - setOperationOptions([]); return; } diff --git a/jaeger/src/plugins/get-trace-data.test.ts b/jaeger/src/plugins/get-trace-data.test.ts index daa7438ed..9160c6d54 100644 --- a/jaeger/src/plugins/get-trace-data.test.ts +++ b/jaeger/src/plugins/get-trace-data.test.ts @@ -90,15 +90,13 @@ const makeClient = (): JaegerClient => { return client; }; -const getDatasource: Mock = vi.fn( - (): DatasourceSpec => ({ - default: false, - plugin: { - kind: 'JaegerDatasource', - spec: datasource, - }, - }), -); +const getDatasource: Mock = vi.fn((): DatasourceSpec => ({ + default: false, + plugin: { + kind: 'JaegerDatasource', + spec: datasource, + }, +})); function createContext(client: JaegerClient): TraceQueryContext { return { diff --git a/loki/src/components/logql-editor.tsx b/loki/src/components/logql-editor.tsx index c42a4f1f1..c7e370db3 100644 --- a/loki/src/components/logql-editor.tsx +++ b/loki/src/components/logql-editor.tsx @@ -18,7 +18,7 @@ import type { ReactElement } from 'react'; import { useMemo } from 'react'; import type { CompletionConfig } from './logql-extension'; -import { LogQLExtension } from './logql-extension'; +import { createLogQLExtension } from './logql-extension'; export type LogQLEditorProps = Omit & { completionConfig?: CompletionConfig; @@ -30,7 +30,7 @@ export function LogQLEditor(props: LogQLEditorProps): ReactElement { const isDarkMode = theme.palette.mode === 'dark'; const logqlExtension = useMemo(() => { - return LogQLExtension(completionConfig); + return createLogQLExtension(completionConfig); }, [completionConfig]); const codemirrorTheme = useMemo(() => { diff --git a/loki/src/components/logql-extension.ts b/loki/src/components/logql-extension.ts index 3d32c5920..fcfcd9f5c 100644 --- a/loki/src/components/logql-extension.ts +++ b/loki/src/components/logql-extension.ts @@ -41,7 +41,7 @@ export interface CompletionConfig { timeRange?: AbsoluteTimeRange; } -export function LogQLExtension(completionCfg?: CompletionConfig): Array { +export function createLogQLExtension(completionCfg?: CompletionConfig): Array { const language = logqlLanguage(); // Only add autocomplete if config is provided diff --git a/package-lock.json b/package-lock.json index 34a82113a..ce3ddb560 100644 --- a/package-lock.json +++ b/package-lock.json @@ -66,8 +66,8 @@ "express": "^4.21.3", "jsdom": "^27.0.0", "mdi-material-ui": "^7.9.2", - "oxfmt": "0.55.0", - "oxlint": "1.70.0", + "oxfmt": "0.70.0", + "oxlint": "1.85.0", "oxlint-plugin-react-doctor": "0.9.12", "react": "^18.3.0", "react-dom": "^18.3.0", @@ -2621,9 +2621,9 @@ } }, "node_modules/@oxfmt/binding-android-arm-eabi": { - "version": "0.55.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-android-arm-eabi/-/binding-android-arm-eabi-0.55.0.tgz", - "integrity": "sha512-+rFDOqQe5LOWgxrAJaZgLRudr6GQm0wGI6gtu7vVkrdLGjNMUSGbAlaCr8j7F2H2Er97vYQCU8WDb30onqMM1g==", + "version": "0.70.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-android-arm-eabi/-/binding-android-arm-eabi-0.70.0.tgz", + "integrity": "sha512-Xd7YO4/T2axEj6FTLcj4Why3mTBqFMg+x24xtorT4Lb2+1g82090GH0a/4U1m0pABGYiix2bq1pqkYrmV3f0Sw==", "cpu": [ "arm" ], @@ -2638,9 +2638,9 @@ } }, "node_modules/@oxfmt/binding-android-arm64": { - "version": "0.55.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-android-arm64/-/binding-android-arm64-0.55.0.tgz", - "integrity": "sha512-ctulLq8s3x8Zmvw6+iccB09TIKERAklRSmbJ10gk8mlAn05qZxoyo52dj3Hi9IJcmDSwF54fQaTVh2CbL6PInw==", + "version": "0.70.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-android-arm64/-/binding-android-arm64-0.70.0.tgz", + "integrity": "sha512-x9rlMYyKXdgKdYyUJzGsK1ZV8P4di/J32ipzcS6Jet6p9r9UAh28neXIMtdlSaJJycdi61Z4YkcLKLpk8ueFjg==", "cpu": [ "arm64" ], @@ -2655,9 +2655,9 @@ } }, "node_modules/@oxfmt/binding-darwin-arm64": { - "version": "0.55.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-darwin-arm64/-/binding-darwin-arm64-0.55.0.tgz", - "integrity": "sha512-xDQczLH9pw/RBk1h/GH0qcGMm8hQtmtVHBNLSH3lk1gEIR09hZ4L+mJQl4VqiVAvPK9VG9PYrWWuSQLt7xTbiA==", + "version": "0.70.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-darwin-arm64/-/binding-darwin-arm64-0.70.0.tgz", + "integrity": "sha512-IUTUPvrBVYy7POh4stXzRdz4IVC/1QSaviCWoyenSlOhGu0X9j5K07vCTM9biLjAA2Zs31l0Rj5vvRpj9n95wA==", "cpu": [ "arm64" ], @@ -2672,9 +2672,9 @@ } }, "node_modules/@oxfmt/binding-darwin-x64": { - "version": "0.55.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-darwin-x64/-/binding-darwin-x64-0.55.0.tgz", - "integrity": "sha512-JaNoFCkF2CJdGgpPSMbuO9HVyXyoNGIhMHPvp6NYAjeVKw9XEYc0HcUWJLPQa3Q69WV5wMa9m5jPMJPtbLtcRg==", + "version": "0.70.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-darwin-x64/-/binding-darwin-x64-0.70.0.tgz", + "integrity": "sha512-vw745q870oTd6J517O24asoX4/E+eK0nxYIFoedSLqgJ+nI5En7+ZS82iZSHZ69zevQrnOXiyHP01dA+t8xD8w==", "cpu": [ "x64" ], @@ -2689,9 +2689,9 @@ } }, "node_modules/@oxfmt/binding-freebsd-x64": { - "version": "0.55.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-freebsd-x64/-/binding-freebsd-x64-0.55.0.tgz", - "integrity": "sha512-DNbszhpg6S2MIzax5azdHFTTBIVkR5xr8yyRZuA4yoDAwOkzIp3tmldgKZM2+VlT+hJIG0xUksA+elISzMEAfA==", + "version": "0.70.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-freebsd-x64/-/binding-freebsd-x64-0.70.0.tgz", + "integrity": "sha512-NO14EgSM9dFkcg+MfGPxvsKqXYs9LKaxPrOKXpv1R0rLokGGFDcCq6dBMq18dE4wlpFOovX0UZY2uh1P30O7QA==", "cpu": [ "x64" ], @@ -2706,9 +2706,9 @@ } }, "node_modules/@oxfmt/binding-linux-arm-gnueabihf": { - "version": "0.55.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-0.55.0.tgz", - "integrity": "sha512-2snoaoRfFFyGnbOcKUK36rREBYxe/Xgz3uHbiA5zbCB/s6R4DQj4mHqYAaWWhgizCUSDxV8cE9zAZ0XleNpKGw==", + "version": "0.70.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-0.70.0.tgz", + "integrity": "sha512-139OEhHarj9CYoJ/i9gXlPv4KLBGtLj2toseOWYFf09QwlhklaZk+wW3aOvlqoeZtuayvkoSNVWra7WJ21s3VQ==", "cpu": [ "arm" ], @@ -2723,9 +2723,9 @@ } }, "node_modules/@oxfmt/binding-linux-arm-musleabihf": { - "version": "0.55.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-0.55.0.tgz", - "integrity": "sha512-q1aktHF/WRpSK81BX1dE/9vWrS2jGw1Nax2kb4DBLGAewubCLcoNyp4Zl/NSMgbv3vUS46Z33wIQkBVYOP3PYg==", + "version": "0.70.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-0.70.0.tgz", + "integrity": "sha512-GEh2PY3IWTE0M24eNhTduountANSbWyDmMnzFSQE/nGg/bjPugbUgiGuFu+xdqcQSd/HKSwH80/F2yVVD48yhA==", "cpu": [ "arm" ], @@ -2740,13 +2740,16 @@ } }, "node_modules/@oxfmt/binding-linux-arm64-gnu": { - "version": "0.55.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-0.55.0.tgz", - "integrity": "sha512-VD0y36aENezl/3tsclA/4G53Cc7iV+7Uoh7gz4yvcOTaEYBtJpQsE6PKDGTtUtOvGS4kv51ybfXY/nWZejO5IA==", + "version": "0.70.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-0.70.0.tgz", + "integrity": "sha512-En5i+UJmZSPxuSf47F2Hl5YOzKB0bicQLnGQkeTCMQ35cWLtbrSwACJKfiLqRZrk05DwSnsJkhBRaM3OURtIaA==", "cpu": [ "arm64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -2757,13 +2760,16 @@ } }, "node_modules/@oxfmt/binding-linux-arm64-musl": { - "version": "0.55.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-arm64-musl/-/binding-linux-arm64-musl-0.55.0.tgz", - "integrity": "sha512-r8xlKJFcsRmn0H5jZrdORae6RX9jDBrZVvOoxF+bCQtampQJClv80aZEHsv+NsLsp2KCE5ql79O7DpPVzYWpXA==", + "version": "0.70.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-arm64-musl/-/binding-linux-arm64-musl-0.70.0.tgz", + "integrity": "sha512-WWOoV5W9Im3flVwOVrWn/2DUlOF8v5vcCip+kcNuaMpulRCh6nzzt1Su2vcL2F908YJIXNV3HvegbBHuyLwKHg==", "cpu": [ "arm64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -2774,13 +2780,16 @@ } }, "node_modules/@oxfmt/binding-linux-ppc64-gnu": { - "version": "0.55.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-0.55.0.tgz", - "integrity": "sha512-GRKv/HXHcwIVld/WU61rF0g0R16hl5EJ+ScKdpjevT57lnLnagj/U2YUbXf2mT+2Pg1uCzWC+mvGicPV3CDdLQ==", + "version": "0.70.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-0.70.0.tgz", + "integrity": "sha512-YUouneIqW+5n7aE8xx/zeZ6/utr/KH7oykcGoFyd8Uz8uh591T1oKlnoWA3BsRq/ZR42oY1w4MUYvS/0e/MQOA==", "cpu": [ "ppc64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -2791,13 +2800,16 @@ } }, "node_modules/@oxfmt/binding-linux-riscv64-gnu": { - "version": "0.55.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-0.55.0.tgz", - "integrity": "sha512-rdv57enTiPtpSYRMKfAiEbQb0Puw5t9N7isVinDoo5qeLDScro2gznmZqSgSWbVZRzLisTeCTW8Qwgw0bOHv3A==", + "version": "0.70.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-0.70.0.tgz", + "integrity": "sha512-iEnMf21S5aGVa4hViDGY8sAQ/AHyCu2JPyrQF8P06wtHhSkD1YJBeT4m/KiGewgf7+a5XCYSCRIPcRQa1xwEoQ==", "cpu": [ "riscv64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -2808,13 +2820,16 @@ } }, "node_modules/@oxfmt/binding-linux-riscv64-musl": { - "version": "0.55.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-0.55.0.tgz", - "integrity": "sha512-7v1nNrlD43VY6+sYQ6efYyb3lE6QY182304PD/768ZxTjOmFd/3dQa3u/nGBUAXYdGSWOQc5N3PnS0QzUXyEIA==", + "version": "0.70.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-0.70.0.tgz", + "integrity": "sha512-91Sdniaj20fQzyMeCxMDzTP4c9s4RB8dGQ308xHhDR0n6U7+1Xq7N9klE7mfXq8iV3lRmIGSXi5X23Hn/0XX/g==", "cpu": [ "riscv64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -2825,13 +2840,16 @@ } }, "node_modules/@oxfmt/binding-linux-s390x-gnu": { - "version": "0.55.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-0.55.0.tgz", - "integrity": "sha512-f4lJLUSPOgScjFl9LiflKCTocyNRwE25JmTMbN4XQdDjoZzEHjqf3wA3VESF1/csg7i8m7+EQLbrZyYDqe10UQ==", + "version": "0.70.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-0.70.0.tgz", + "integrity": "sha512-uUV30M6E+2TKKGMaKiwfeL4RZrviHXlUxsrYJ/jFBb+1EZy+pnFT+hF73eeWdzh5NqPOAnw0iiMAIqjqiLZPFg==", "cpu": [ "s390x" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -2842,13 +2860,16 @@ } }, "node_modules/@oxfmt/binding-linux-x64-gnu": { - "version": "0.55.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-x64-gnu/-/binding-linux-x64-gnu-0.55.0.tgz", - "integrity": "sha512-MihqiPziJNoWy4MqNSV+jVA1g+07iQDjZiR0vaCaDoPgFEiJpCMsxamktzLV07cEeQsSJ04vQaU4CzCQwIvtDA==", + "version": "0.70.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-x64-gnu/-/binding-linux-x64-gnu-0.70.0.tgz", + "integrity": "sha512-ivMcX6kNDPhqtbOaBt/ItFlLlTlXNHLgRuNmxP6Na6UuYXRT10llpJcAPbGeRgjjb3Qzv4jwPp3fB0hui50WNQ==", "cpu": [ "x64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -2859,13 +2880,16 @@ } }, "node_modules/@oxfmt/binding-linux-x64-musl": { - "version": "0.55.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-x64-musl/-/binding-linux-x64-musl-0.55.0.tgz", - "integrity": "sha512-Yqghym7KYAVjP9MmSrNZiDeerMuoejNjo0r3ox5H3GDKk8eAfl8VyJm9i+pWCLDCTnAbcTUMMN2ZKjUYXH1v3g==", + "version": "0.70.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-x64-musl/-/binding-linux-x64-musl-0.70.0.tgz", + "integrity": "sha512-w+S+fERxYmlZSyZlJK/U292FjyBoH8cCEj21/tYJX6atX5kNSn+HDkhlFQKT2zcMwUW0uAtUL/bOrlwJZwqRdA==", "cpu": [ "x64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -2876,9 +2900,9 @@ } }, "node_modules/@oxfmt/binding-openharmony-arm64": { - "version": "0.55.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-openharmony-arm64/-/binding-openharmony-arm64-0.55.0.tgz", - "integrity": "sha512-s5SDvVVSbyQl1V5UU3Yl12M+XLUQ3rl5SglNqgAA2K4PXUtQhyNSS00wivONPEnNo5W01rCou8WkDNyvI/RGHg==", + "version": "0.70.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-openharmony-arm64/-/binding-openharmony-arm64-0.70.0.tgz", + "integrity": "sha512-Zlom1Xkx257R8bk4ZI4zJsrGno2opknz1+5v5baka3nn4FPyvNSdh8JUL4CdN1S1OWRMvJ9UJQ+RfIqGGCUEfA==", "cpu": [ "arm64" ], @@ -2893,9 +2917,9 @@ } }, "node_modules/@oxfmt/binding-win32-arm64-msvc": { - "version": "0.55.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-0.55.0.tgz", - "integrity": "sha512-7p9FB5R32tw2KyyNX3wpQrR2WHwEHvMEiBlGXxeTCaRMCVNx3UtFMAUbaQ/pRNWIrEUZmYhJ6tcUH52uPTRYjQ==", + "version": "0.70.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-0.70.0.tgz", + "integrity": "sha512-FQgPW5R17vzt7cgrJ8eG/dqX00o2xHsqFeLfw4xzA9FRHpN/DjFo9YDonvIIXGxiEuS9F/jZPnGO+KHNKuCo4Q==", "cpu": [ "arm64" ], @@ -2910,9 +2934,9 @@ } }, "node_modules/@oxfmt/binding-win32-ia32-msvc": { - "version": "0.55.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-0.55.0.tgz", - "integrity": "sha512-ZYqj3fDnOT1IaVGMP5kpmkQl4F3tQIm2ZyAxvqkJYmI0xgWWak4ss4XYwv3VDfM+TWXeC9K4uQ/wW5jm/5XABA==", + "version": "0.70.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-0.70.0.tgz", + "integrity": "sha512-ZfZublNhZ+XBndMiXhkiLlPE+XyGRDa0CweeTL6t1fZypfCh1LTg7e5CvnOeTBunq15MskOcRempumSPGAaCQA==", "cpu": [ "ia32" ], @@ -2927,9 +2951,9 @@ } }, "node_modules/@oxfmt/binding-win32-x64-msvc": { - "version": "0.55.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-win32-x64-msvc/-/binding-win32-x64-msvc-0.55.0.tgz", - "integrity": "sha512-eEYT5tivGnGbPHuOHuQpi6CGLObhh0re/5jcNQHihD2GRYkTM85dyi5a19zjP8Q00t1uqAx+/QGLUGdHeqzWyg==", + "version": "0.70.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-win32-x64-msvc/-/binding-win32-x64-msvc-0.70.0.tgz", + "integrity": "sha512-HlIZEn+WzLQL0DszNzldiRl/DPRCX5R0Vkt6qeUPR1YHwy52hZZo4x6HoTOVmKRP2wUiwPGtKsihNY/f8KRaBg==", "cpu": [ "x64" ], @@ -2944,9 +2968,9 @@ } }, "node_modules/@oxlint/binding-android-arm-eabi": { - "version": "1.70.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm-eabi/-/binding-android-arm-eabi-1.70.0.tgz", - "integrity": "sha512-zFh0P4cswmRvw6nkyb89dr18rRanuaCPAsEXsFDoQY8WdaquI8Pt4NWFjaMJg6L23cy5NeN8J9cBnREbWzZhaw==", + "version": "1.85.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm-eabi/-/binding-android-arm-eabi-1.85.0.tgz", + "integrity": "sha512-q2KO/Zso9UT+OMn0NF9ywn4E4t0MI3yxiDhNyhsQ7DyQJrC4FhFE4TXOi4bktFnOWXTMds8qZSbpv2XwRaNOBg==", "cpu": [ "arm" ], @@ -2961,9 +2985,9 @@ } }, "node_modules/@oxlint/binding-android-arm64": { - "version": "1.70.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm64/-/binding-android-arm64-1.70.0.tgz", - "integrity": "sha512-qI8o4HZjeGiBrWv+pJv4lH0Yi2Gl/JSp/EumBUApezJprIKa5PS4nU0lQsQngtky8k+SplQIOjv6hwu0SSxeyg==", + "version": "1.85.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm64/-/binding-android-arm64-1.85.0.tgz", + "integrity": "sha512-SxLN3ALjoT9NNdvpjEevGeHvfzTAFrF0NBYB5tzK7/GtCKMze3j1e/m/X2ozqGj2U9hfGG/dg/OG8vpVK4PiDA==", "cpu": [ "arm64" ], @@ -2978,9 +3002,9 @@ } }, "node_modules/@oxlint/binding-darwin-arm64": { - "version": "1.70.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-arm64/-/binding-darwin-arm64-1.70.0.tgz", - "integrity": "sha512-8KjgVVHI5F9nVwHCRwwA78Ty7zNKP4Wd9OeN5PSv3iu/F/u1RVXoOCgLhWqust6HmwQG6xc8c+RCyaWENy24+w==", + "version": "1.85.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-arm64/-/binding-darwin-arm64-1.85.0.tgz", + "integrity": "sha512-Y/Sup/J4f0f9UGsSd/xyCNTeWL+gepO63GBdEDAfue9nBsnk9zMmnIXx1O6b1V8C90vB5nucYNZ0pbMXAp8zJA==", "cpu": [ "arm64" ], @@ -2995,9 +3019,9 @@ } }, "node_modules/@oxlint/binding-darwin-x64": { - "version": "1.70.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-x64/-/binding-darwin-x64-1.70.0.tgz", - "integrity": "sha512-WVydssv5PSUBXFJTdNBWlmGkbNmvPGaFt/2SUT/EZRB6bq6bEOHmMlbnupZD5jmlEvi9+mZJHi8TCw15lyfSfQ==", + "version": "1.85.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-x64/-/binding-darwin-x64-1.85.0.tgz", + "integrity": "sha512-ApOSNC04ynpDTwvBD+//0wyfODRSbEzvRoKpX8teffmc27z8AockwSNeMXGJXn5KP85eahDgR/2llICWLkzcnw==", "cpu": [ "x64" ], @@ -3012,9 +3036,9 @@ } }, "node_modules/@oxlint/binding-freebsd-x64": { - "version": "1.70.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-freebsd-x64/-/binding-freebsd-x64-1.70.0.tgz", - "integrity": "sha512-hJucmUf8OlinHNb1R7fI4Fw6WsAstOz7i8nmkWQfiHoZXtbufNm+MxiDTIMk1ggh2Ro4vLzgQ+bKvRY54MZoRA==", + "version": "1.85.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-freebsd-x64/-/binding-freebsd-x64-1.85.0.tgz", + "integrity": "sha512-bNrVrCOA/kHky3Tu79IXWXe5bhIgLXfUuUEDHlAGOHUk96MkvDZ1ecaQF19rwstrnaqfP1o9nBTqzIr9+ZHkUg==", "cpu": [ "x64" ], @@ -3029,9 +3053,9 @@ } }, "node_modules/@oxlint/binding-linux-arm-gnueabihf": { - "version": "1.70.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.70.0.tgz", - "integrity": "sha512-1BnS7wbCYDSXwWzJJ+mc3NURoha6m6m6RT5c6vgAY3oz7C3OVXP+S0awo2mRq97arrJkVvO3qRQfyAHL+76xtQ==", + "version": "1.85.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.85.0.tgz", + "integrity": "sha512-NUrzOJ1s/EqsVvfn2L/1D8Wro2LPIZUbihL8kOJLh5fEdGEN3rdOGUYq3HwnUIL8sjpoP+4N6RaGrgmMJnaMPw==", "cpu": [ "arm" ], @@ -3046,9 +3070,9 @@ } }, "node_modules/@oxlint/binding-linux-arm-musleabihf": { - "version": "1.70.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-1.70.0.tgz", - "integrity": "sha512-yKy/UdbR55+M2yEcuiV5DCNC/gdQAjr/GioUy50QwBzSrKm8ueWADqyRLS9Xk+qjNeCYGg6A8FvUBds56ttfqg==", + "version": "1.85.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-1.85.0.tgz", + "integrity": "sha512-UJXrAT3E/RWkEqXLIs2ehETja1qfgkPb+5gwLIIS+o/6cf+grHvoOXTa5997a/YNQfcJS0DRBTOfZt95cvOI1g==", "cpu": [ "arm" ], @@ -3063,13 +3087,16 @@ } }, "node_modules/@oxlint/binding-linux-arm64-gnu": { - "version": "1.70.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.70.0.tgz", - "integrity": "sha512-0A5XJ4alvmqFUFP/4oYSyaO+qLto/HrKEWTSaegiVl+HOufFngK2BjYw9x4RbwBt/du5QG6l5q1zeWiJYYG5yg==", + "version": "1.85.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.85.0.tgz", + "integrity": "sha512-lK40QLjI0HxigO7CjDDshEtfYIeiYS0020v5BHFPqN4uuQBQxd2K9LNom2dW15o9F1937quSCRVp4ZsVhdbYdg==", "cpu": [ "arm64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -3080,13 +3107,16 @@ } }, "node_modules/@oxlint/binding-linux-arm64-musl": { - "version": "1.70.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.70.0.tgz", - "integrity": "sha512-JiylyurlB0CLSedNtx1gzv3FvfWPF1h/2Y3BJszPLNt5XQFlBsH5ke0Jle3iJb3uqu5m2e7A/DwzpuCAHdiU+A==", + "version": "1.85.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.85.0.tgz", + "integrity": "sha512-c2zbdBwGKreHXwRx3gWBuFGJxLhxgsg6YlZ+3H+RgRusU/UEV9jNwJ3HGYK+nRo0LvBa7mt6Kj86xoVotUo8cw==", "cpu": [ "arm64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -3097,13 +3127,16 @@ } }, "node_modules/@oxlint/binding-linux-ppc64-gnu": { - "version": "1.70.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.70.0.tgz", - "integrity": "sha512-J8VPG7I3/HmgaU4u8pNU2kFx2+0U+vPLS1dXFxXOaR/2TQ0f8AC7DRz0SRGRI1bfphnX2hVYTTtLuhL4nYKL+Q==", + "version": "1.85.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.85.0.tgz", + "integrity": "sha512-tlt/Hy8lZ97/lCPmCgw/B3k/mwh+BzaIPbPkldZEly7TwLmx0xe2CQcaW2g/rR0dOgS9JNGCZsMEqLhUNMGvaw==", "cpu": [ "ppc64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -3114,13 +3147,16 @@ } }, "node_modules/@oxlint/binding-linux-riscv64-gnu": { - "version": "1.70.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-1.70.0.tgz", - "integrity": "sha512-N2+4lV2KLN+oXTIIIwmWDhwkrnvqf5oX7Hw0zPjk+RuIVgiBQSOlJWF7uQoFx2siEYX0ZQ5cfSbEAHm+J3t7Wg==", + "version": "1.85.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-1.85.0.tgz", + "integrity": "sha512-3tNR9Xey82X0zKuY1d8hJ6Rc9gwRDurmqGLnQZa5xqOXy8/YyiqFXjAtugkKLY82obOlpK1eSiDRlgcNPuxtIg==", "cpu": [ "riscv64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -3131,13 +3167,16 @@ } }, "node_modules/@oxlint/binding-linux-riscv64-musl": { - "version": "1.70.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-1.70.0.tgz", - "integrity": "sha512-1e2L7cFCvx9QDzq6NPP+0tABKb5z6nWHyddWTNKprEsjO9xNrAtPowuCGpjNXxkTdsMiZ4jc8YQ5SstZd4XK6g==", + "version": "1.85.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-1.85.0.tgz", + "integrity": "sha512-wbGRd5PqCcjkJFHhZuZ2OBSUQY9czlQsoA/cQQB9JK/L9mC5MQgGoKAh+xd8QjA5V+0D3j+Qd1lAWn1I8zlelA==", "cpu": [ "riscv64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -3148,13 +3187,16 @@ } }, "node_modules/@oxlint/binding-linux-s390x-gnu": { - "version": "1.70.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.70.0.tgz", - "integrity": "sha512-Kwu/l/8GcYibCWA9m9N5pRXMIKVSsL/YbgpLzYkqDhWTiqdRfnNJ/+nqIKRKQiFbHWsdlHEhzMwruJK+qcEruA==", + "version": "1.85.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.85.0.tgz", + "integrity": "sha512-3Sn0kSrE4DPZCWV/8o+n4x3aFZxI9ulMnkYlwCbJ8eUVkwRK2IerohE/A/z3SNbCwoPFOCJmGE5Avrq0rrvdvQ==", "cpu": [ "s390x" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -3165,13 +3207,16 @@ } }, "node_modules/@oxlint/binding-linux-x64-gnu": { - "version": "1.70.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.70.0.tgz", - "integrity": "sha512-tap04CsHYOl0nSAQJfPNIuBxqEPB2HnhQqwaOXLg1jnp2XfRo8Fa814dA4QC4zpvTWXCjAAaCY1W5LOORkEQuQ==", + "version": "1.85.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.85.0.tgz", + "integrity": "sha512-JY2pxxYfB62bAGfejljVCqc44etItehPuAyaeSAdMuEMtwNA00ggMnS66lC1oIhos6oOXUkuU6mZ9bpFh3BqWg==", "cpu": [ "x64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -3182,13 +3227,16 @@ } }, "node_modules/@oxlint/binding-linux-x64-musl": { - "version": "1.70.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-musl/-/binding-linux-x64-musl-1.70.0.tgz", - "integrity": "sha512-hzJa/WgvtJpbBD9rgfy0qe+MjbxOXNUT0bfR1S6EQQzfTtBFA9xg5q8KSwRrQ2QfSS+TaP4j+4mVPQrfNc6UNg==", + "version": "1.85.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-musl/-/binding-linux-x64-musl-1.85.0.tgz", + "integrity": "sha512-5k74vZ6qJBjBHEOlBk9B/iv68Yu0F1Afw/vvT2ar6OGCqEeXLaSjXz2n/IPCbhLG22UoKoYEJTzpYraRdcp6PA==", "cpu": [ "x64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -3199,9 +3247,9 @@ } }, "node_modules/@oxlint/binding-openharmony-arm64": { - "version": "1.70.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-openharmony-arm64/-/binding-openharmony-arm64-1.70.0.tgz", - "integrity": "sha512-xbsaNSNzVSnaJACCUYr1HQMyY/Q/Q1LkePmHG3UvZPvGCYGNxrsZp9OmtA6ick8xH47ltRRbRrPCM1YXYcyC+A==", + "version": "1.85.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-openharmony-arm64/-/binding-openharmony-arm64-1.85.0.tgz", + "integrity": "sha512-GbAl5qt5TCkPLXTaIISZJnugrcBhra6rodcXc9jYt620UtdsTt71NlNmJmm0frxzFpd54x/G+MkitEJA8I/BoA==", "cpu": [ "arm64" ], @@ -3216,9 +3264,9 @@ } }, "node_modules/@oxlint/binding-win32-arm64-msvc": { - "version": "1.70.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.70.0.tgz", - "integrity": "sha512-icAEsUI7JbW1TMRdEXV83mVAInhRVQYuuAlPpxdGwJ95chNdnCzjloRW8GglT0WvzOEZSio6fnYSk2DJ2Hv7LQ==", + "version": "1.85.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.85.0.tgz", + "integrity": "sha512-kjmws5MK0et2swk4ND85D7NVQyDHw162i6whtZDLUA/lo6FQyBZDcmMRCMcVZcNrAhIaftVb00x9ChGDOjjNJA==", "cpu": [ "arm64" ], @@ -3233,9 +3281,9 @@ } }, "node_modules/@oxlint/binding-win32-ia32-msvc": { - "version": "1.70.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-1.70.0.tgz", - "integrity": "sha512-FHMSWbVsPVs/f+Jcl04ws4JJ2wUnauyTzlpxWRG/lSO/8GpX08Fo2gQZqdA6CrRFI+zvkxl+N/KwJGWfUwYVZA==", + "version": "1.85.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-1.85.0.tgz", + "integrity": "sha512-eSsIJx9n4yxvOqYTZyPEMyEXRmE60XH7xGAU7i0Qbsn1lf6Za3CWJ9aRd82oSFKXaxhp+sA6/yMJVRIpLpna6A==", "cpu": [ "ia32" ], @@ -3250,9 +3298,9 @@ } }, "node_modules/@oxlint/binding-win32-x64-msvc": { - "version": "1.70.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.70.0.tgz", - "integrity": "sha512-ptOlKwCz7n4AKs5VweMqG6DAg677FmKOK+vBkkL9DMNgFATIQ+upqUYBTOEwRQyRAx1ncGlPlXleV2hIcm3z4g==", + "version": "1.85.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.85.0.tgz", + "integrity": "sha512-pBebIPUpKKhWrhSMWhy8TdAZBewiXnfxmaAGxhzxM1068GagqFaTwgKlU6e+UyJ2sPR+VoHouhXuGJkQjsrDvA==", "cpu": [ "x64" ], @@ -9065,13 +9113,13 @@ } }, "node_modules/oxfmt": { - "version": "0.55.0", - "resolved": "https://registry.npmjs.org/oxfmt/-/oxfmt-0.55.0.tgz", - "integrity": "sha512-jSj2wCTakwgPMxkfiVZX0jf+nX+Nz6xlyAZjqNE0qXTFdCBPYlP6JAN+ODjmealw7DXBjOzYbdsqwBMAZnPZ6A==", + "version": "0.70.0", + "resolved": "https://registry.npmjs.org/oxfmt/-/oxfmt-0.70.0.tgz", + "integrity": "sha512-IsHxZ4y0wQLLMhnrJblBJgZsLDzfULrJnAw5j/QqsTlMa/m3AqsbToi+W71uhBGaqlqq/PbbjvHc09TJwdv3Tw==", "dev": true, "license": "MIT", "dependencies": { - "tinypool": "2.1.0" + "tinypool": "2.1.2" }, "bin": { "oxfmt": "bin/oxfmt" @@ -9080,28 +9128,28 @@ "node": "^20.19.0 || >=22.12.0" }, "funding": { - "url": "https://github.com/sponsors/Boshen" + "url": "https://github.com/sponsors/oxc-project" }, "optionalDependencies": { - "@oxfmt/binding-android-arm-eabi": "0.55.0", - "@oxfmt/binding-android-arm64": "0.55.0", - "@oxfmt/binding-darwin-arm64": "0.55.0", - "@oxfmt/binding-darwin-x64": "0.55.0", - "@oxfmt/binding-freebsd-x64": "0.55.0", - "@oxfmt/binding-linux-arm-gnueabihf": "0.55.0", - "@oxfmt/binding-linux-arm-musleabihf": "0.55.0", - "@oxfmt/binding-linux-arm64-gnu": "0.55.0", - "@oxfmt/binding-linux-arm64-musl": "0.55.0", - "@oxfmt/binding-linux-ppc64-gnu": "0.55.0", - "@oxfmt/binding-linux-riscv64-gnu": "0.55.0", - "@oxfmt/binding-linux-riscv64-musl": "0.55.0", - "@oxfmt/binding-linux-s390x-gnu": "0.55.0", - "@oxfmt/binding-linux-x64-gnu": "0.55.0", - "@oxfmt/binding-linux-x64-musl": "0.55.0", - "@oxfmt/binding-openharmony-arm64": "0.55.0", - "@oxfmt/binding-win32-arm64-msvc": "0.55.0", - "@oxfmt/binding-win32-ia32-msvc": "0.55.0", - "@oxfmt/binding-win32-x64-msvc": "0.55.0" + "@oxfmt/binding-android-arm-eabi": "0.70.0", + "@oxfmt/binding-android-arm64": "0.70.0", + "@oxfmt/binding-darwin-arm64": "0.70.0", + "@oxfmt/binding-darwin-x64": "0.70.0", + "@oxfmt/binding-freebsd-x64": "0.70.0", + "@oxfmt/binding-linux-arm-gnueabihf": "0.70.0", + "@oxfmt/binding-linux-arm-musleabihf": "0.70.0", + "@oxfmt/binding-linux-arm64-gnu": "0.70.0", + "@oxfmt/binding-linux-arm64-musl": "0.70.0", + "@oxfmt/binding-linux-ppc64-gnu": "0.70.0", + "@oxfmt/binding-linux-riscv64-gnu": "0.70.0", + "@oxfmt/binding-linux-riscv64-musl": "0.70.0", + "@oxfmt/binding-linux-s390x-gnu": "0.70.0", + "@oxfmt/binding-linux-x64-gnu": "0.70.0", + "@oxfmt/binding-linux-x64-musl": "0.70.0", + "@oxfmt/binding-openharmony-arm64": "0.70.0", + "@oxfmt/binding-win32-arm64-msvc": "0.70.0", + "@oxfmt/binding-win32-ia32-msvc": "0.70.0", + "@oxfmt/binding-win32-x64-msvc": "0.70.0" }, "peerDependencies": { "svelte": "^5.0.0", @@ -9117,9 +9165,9 @@ } }, "node_modules/oxlint": { - "version": "1.70.0", - "resolved": "https://registry.npmjs.org/oxlint/-/oxlint-1.70.0.tgz", - "integrity": "sha512-D6JgHtzkhRwvEC+A0Nw5AEc5bk8x5i1pHzvZIEf/a0C4hOzmAACNGtkDGPyFaxxX3ZVGxCPeig3P3rMM8XU3/g==", + "version": "1.85.0", + "resolved": "https://registry.npmjs.org/oxlint/-/oxlint-1.85.0.tgz", + "integrity": "sha512-bc26s97nuvPj1ViyPsqmKecVkUWFMEdtayO8MaQ6oiLfs1pj94cQlZZhrh4BPNlr9HQosjhIlwgZKsfcwmcNgg==", "dev": true, "license": "MIT", "bin": { @@ -9129,31 +9177,31 @@ "node": "^20.19.0 || >=22.12.0" }, "funding": { - "url": "https://github.com/sponsors/Boshen" + "url": "https://github.com/sponsors/oxc-project" }, "optionalDependencies": { - "@oxlint/binding-android-arm-eabi": "1.70.0", - "@oxlint/binding-android-arm64": "1.70.0", - "@oxlint/binding-darwin-arm64": "1.70.0", - "@oxlint/binding-darwin-x64": "1.70.0", - "@oxlint/binding-freebsd-x64": "1.70.0", - "@oxlint/binding-linux-arm-gnueabihf": "1.70.0", - "@oxlint/binding-linux-arm-musleabihf": "1.70.0", - "@oxlint/binding-linux-arm64-gnu": "1.70.0", - "@oxlint/binding-linux-arm64-musl": "1.70.0", - "@oxlint/binding-linux-ppc64-gnu": "1.70.0", - "@oxlint/binding-linux-riscv64-gnu": "1.70.0", - "@oxlint/binding-linux-riscv64-musl": "1.70.0", - "@oxlint/binding-linux-s390x-gnu": "1.70.0", - "@oxlint/binding-linux-x64-gnu": "1.70.0", - "@oxlint/binding-linux-x64-musl": "1.70.0", - "@oxlint/binding-openharmony-arm64": "1.70.0", - "@oxlint/binding-win32-arm64-msvc": "1.70.0", - "@oxlint/binding-win32-ia32-msvc": "1.70.0", - "@oxlint/binding-win32-x64-msvc": "1.70.0" + "@oxlint/binding-android-arm-eabi": "1.85.0", + "@oxlint/binding-android-arm64": "1.85.0", + "@oxlint/binding-darwin-arm64": "1.85.0", + "@oxlint/binding-darwin-x64": "1.85.0", + "@oxlint/binding-freebsd-x64": "1.85.0", + "@oxlint/binding-linux-arm-gnueabihf": "1.85.0", + "@oxlint/binding-linux-arm-musleabihf": "1.85.0", + "@oxlint/binding-linux-arm64-gnu": "1.85.0", + "@oxlint/binding-linux-arm64-musl": "1.85.0", + "@oxlint/binding-linux-ppc64-gnu": "1.85.0", + "@oxlint/binding-linux-riscv64-gnu": "1.85.0", + "@oxlint/binding-linux-riscv64-musl": "1.85.0", + "@oxlint/binding-linux-s390x-gnu": "1.85.0", + "@oxlint/binding-linux-x64-gnu": "1.85.0", + "@oxlint/binding-linux-x64-musl": "1.85.0", + "@oxlint/binding-openharmony-arm64": "1.85.0", + "@oxlint/binding-win32-arm64-msvc": "1.85.0", + "@oxlint/binding-win32-ia32-msvc": "1.85.0", + "@oxlint/binding-win32-x64-msvc": "1.85.0" }, "peerDependencies": { - "oxlint-tsgolint": ">=0.22.1", + "oxlint-tsgolint": ">=7.0.2001", "vite-plus": "*" }, "peerDependenciesMeta": { @@ -10447,9 +10495,9 @@ } }, "node_modules/tinypool": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-2.1.0.tgz", - "integrity": "sha512-Pugqs6M0m7Lv1I7FtxN4aoyToKg1C4tu+/381vH35y8oENM/Ai7f7C4StcoK4/+BSw9ebcS8jRiVrORFKCALLw==", + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-2.1.2.tgz", + "integrity": "sha512-9YodfrxS9g9IbFr/KOjE5bAeJ0p61n3bW6mqvy0jtoeKd1kTW1Cxm0oulm6KX2lyM9Gl6WIe8nEbY7LWv5ZJww==", "dev": true, "license": "MIT", "engines": { diff --git a/package.json b/package.json index e80c5b4a0..9ad24cc32 100644 --- a/package.json +++ b/package.json @@ -92,8 +92,8 @@ "express": "^4.21.3", "jsdom": "^27.0.0", "mdi-material-ui": "^7.9.2", - "oxfmt": "0.55.0", - "oxlint": "1.70.0", + "oxfmt": "0.70.0", + "oxlint": "1.85.0", "oxlint-plugin-react-doctor": "0.9.12", "react": "^18.3.0", "react-dom": "^18.3.0", diff --git a/piechart/src/PieChartBase.tsx b/piechart/src/PieChartBase.tsx index 47d06d880..31a37cfa5 100644 --- a/piechart/src/PieChartBase.tsx +++ b/piechart/src/PieChartBase.tsx @@ -36,6 +36,7 @@ registerECharts([ ]); const CHART_SX = { width: '100%', height: '100%' }; +const CONTAINER_SX = { overflow: 'auto' }; const EMPHASIS_SHADOW_BLUR = 10; const EMPHASIS_SCALE_SIZE = 5; const PIE_INSET = EMPHASIS_SHADOW_BLUR + EMPHASIS_SCALE_SIZE; @@ -75,9 +76,11 @@ export function PieChartBase(props: PieChartBaseProps): ReactElement { const chartRef = useRef(); useLayoutEffect(() => { - chartRef.current?.resize(); + chartRef.current?.resize({ width, height }); }, [height, width]); + const containerStyle = useMemo(() => ({ width, height }), [width, height]); + const option = useMemo(() => { // ECharts treats numeric radii as pixels, so convert persisted percentages at the rendering boundary. const radius = innerRadius === undefined ? `${outerRadius}%` : [`${innerRadius}%`, `${outerRadius}%`]; @@ -125,13 +128,7 @@ export function PieChartBase(props: PieChartBaseProps): ReactElement { }, [data, formatOptions, innerRadius, mode, muiTheme.palette.background.default, outerRadius, showLabels]); return ( - + ); diff --git a/piechart/src/PieChartPanel.tsx b/piechart/src/PieChartPanel.tsx index 54b674fff..b24f7b5d8 100644 --- a/piechart/src/PieChartPanel.tsx +++ b/piechart/src/PieChartPanel.tsx @@ -13,13 +13,13 @@ import { Box } from '@mui/material'; import type { ChartInstance, LegendProps, SelectedLegendItemState } from '@perses-dev/components'; -import { ContentWithLegend, DEFAULT_LEGEND, useChartsTheme, useId } from '@perses-dev/components'; +import { ContentWithLegend, DEFAULT_LEGEND, useChartsTheme } from '@perses-dev/components'; import type { CalculationType, PanelProps } from '@perses-dev/plugin-system'; import { CalculationsMap, validateLegendSpec } from '@perses-dev/plugin-system'; import type { TimeSeriesData } from '@perses-dev/spec'; import merge from 'lodash/merge'; import type { ReactElement } from 'react'; -import { useMemo, useRef, useState } from 'react'; +import { useId, useMemo, useRef, useState } from 'react'; import { getSeriesColor } from './colors'; import { resolvePieChartVisualOptions } from './pie-chart-model'; @@ -39,7 +39,7 @@ export function PieChartPanel(props: PieChartPanelProps): ReactElement | null { } = props; const { colorPalette, innerRadius, outerRadius } = resolvePieChartVisualOptions(props.spec); const chartsTheme = useChartsTheme(); - const chartId = useId('time-series-panel'); + const chartId = useId(); const seriesNames = queryResults.flatMap((result) => result?.data.series?.map((series) => series.name) || []); // Memoize the color list so it only regenerates when color/palette/series count changes diff --git a/prometheus/src/annotations/PrometheusPromQLAnnotationOptionEditor.tsx b/prometheus/src/annotations/PrometheusPromQLAnnotationOptionEditor.tsx index 6998243b4..c40711317 100644 --- a/prometheus/src/annotations/PrometheusPromQLAnnotationOptionEditor.tsx +++ b/prometheus/src/annotations/PrometheusPromQLAnnotationOptionEditor.tsx @@ -12,11 +12,11 @@ // limitations under the License. import { Autocomplete, FormControl, Stack, TextField } from '@mui/material'; -import { useId } from '@perses-dev/components'; import type { DatasourceSelectProps, DatasourceSelectValue, OptionsEditorProps } from '@perses-dev/plugin-system'; import { DatasourceSelect, useDatasourceClient, useDatasourceSelectValueToSelector } from '@perses-dev/plugin-system'; import { produce } from 'immer'; import type { ReactElement } from 'react'; +import { useId } from 'react'; import { PromQLEditor } from '../components'; import type { PrometheusClient, PrometheusDatasourceSelector } from '../model'; @@ -42,7 +42,7 @@ export function PrometheusPromQLAnnotationOptionEditor(props: PrometheusAnnotati const datasourceSelectValue = datasource ?? DEFAULT_PROM; - const datasourceSelectLabelID = useId('prom-datasource-label'); // for panels with multiple queries, this component is rendered multiple times on the same page + const datasourceSelectLabelID = useId(); // for panels with multiple queries, this component is rendered multiple times on the same page const selectedDatasource = useDatasourceSelectValueToSelector( datasourceSelectValue, diff --git a/prometheus/src/components/TreeNode.tsx b/prometheus/src/components/TreeNode.tsx index def175d77..7434378f2 100644 --- a/prometheus/src/components/TreeNode.tsx +++ b/prometheus/src/components/TreeNode.tsx @@ -90,16 +90,6 @@ export default function TreeNode({ const [nodeEl, setNodeEl] = useState(null); const nodeRef = useCallback((node: HTMLDivElement) => setNodeEl(node), []); - const [resultStats, setResultStats] = useState<{ - numSeries: number; - labelExamples: Record>; - sortedLabelCards: Array<[string, number]>; - }>({ - numSeries: 0, - labelExamples: {}, - sortedLabelCards: [], - }); - const [connectorStyle, setConnectorStyle] = useState({ borderColor: theme.palette.grey['500'], borderLeftStyle: 'solid', @@ -166,37 +156,46 @@ export default function TreeNode({ return; } - const parentRect = parentEl.getBoundingClientRect(); - const nodeRect = nodeEl.getBoundingClientRect(); - if (reverse) { - setConnectorStyle((prevStyle) => ({ - ...prevStyle, - top: 'calc(50% - 1px)', - bottom: nodeRect.bottom - parentRect.top, - borderTopLeftRadius: 10, - borderTopStyle: 'solid', - borderBottomLeftRadius: undefined, - })); - } else { - setConnectorStyle((prevStyle) => ({ - ...prevStyle, - top: parentRect.bottom - nodeRect.top, - bottom: 'calc(50% - 1px)', - borderBottomLeftRadius: 10, - borderBottomStyle: 'solid', - borderTopLeftRadius: undefined, - })); - } - }, [parentEl, nodeEl, reverse, nodeRef, setConnectorStyle]); + const updateConnector = (): void => { + const parentRect = parentEl.getBoundingClientRect(); + const nodeRect = nodeEl.getBoundingClientRect(); + if (reverse) { + setConnectorStyle((prevStyle) => ({ + ...prevStyle, + top: 'calc(50% - 1px)', + bottom: nodeRect.bottom - parentRect.top, + borderTopLeftRadius: 10, + borderTopStyle: 'solid', + borderBottomLeftRadius: undefined, + borderBottomStyle: undefined, + })); + } else { + setConnectorStyle((prevStyle) => ({ + ...prevStyle, + top: parentRect.bottom - nodeRect.top, + bottom: 'calc(50% - 1px)', + borderBottomLeftRadius: 10, + borderBottomStyle: 'solid', + borderTopLeftRadius: undefined, + borderTopStyle: undefined, + })); + } + }; + const observer = new ResizeObserver(updateConnector); + observer.observe(parentEl); + observer.observe(nodeEl); + return (): void => observer.disconnect(); + }, [parentEl, nodeEl, reverse]); - // Update the node info state based on the query result. useEffect(() => { - if (instantQueryResponse?.status !== 'success') { - return; + if (instantQueryResponse?.status === 'success') { + reportNodeState?.(childIdx, 'success'); } + }, [instantQueryResponse, reportNodeState, childIdx]); - if (reportNodeState) { - reportNodeState(childIdx, 'success'); + const resultStats = useMemo(() => { + if (instantQueryResponse?.status !== 'success') { + return { numSeries: 0, labelExamples: {}, sortedLabelCards: [] }; } let resultSeries = 0; @@ -233,12 +232,12 @@ export default function TreeNode({ .map(([lv, cnt]) => ({ value: lv, count: cnt })); }); - setResultStats({ + return { numSeries: resultSeries, sortedLabelCards: Object.entries(labelCardinalities).toSorted((a, b) => b[1] - a[1]), labelExamples, - }); - }, [instantQueryResponse, reportNodeState, childIdx]); + }; + }, [instantQueryResponse]); const innerNode = ( diff --git a/prometheus/src/explore/PrometheusMetricsFinder/filter/FilterInputs.tsx b/prometheus/src/explore/PrometheusMetricsFinder/filter/FilterInputs.tsx index 89daa9b57..133d98ea6 100644 --- a/prometheus/src/explore/PrometheusMetricsFinder/filter/FilterInputs.tsx +++ b/prometheus/src/explore/PrometheusMetricsFinder/filter/FilterInputs.tsx @@ -25,7 +25,7 @@ import { import type { DatasourceSelector } from '@perses-dev/spec'; import DeleteIcon from 'mdi-material-ui/Delete'; import type { HTMLAttributes, ReactElement, SyntheticEvent } from 'react'; -import { cloneElement, forwardRef, useMemo, useRef, useState } from 'react'; +import { cloneElement, forwardRef, useCallback, useMemo, useState } from 'react'; import { Virtuoso } from 'react-virtuoso'; import type { LabelFilter, Operator } from '../types'; @@ -75,27 +75,34 @@ export function LabelFilterInput({ export const ListboxComponent = forwardRef>( ({ children, ...rest }, ref) => { const data = children as ReactElement[]; - const localRef = useRef('500px'); + const [maxHeight, setMaxHeight] = useState('500px'); const [height, setHeight] = useState(0); + const listRef = useCallback( + (reference: HTMLUListElement | null) => { + if (reference) { + const measuredMaxHeight = getComputedStyle(reference).maxHeight; + setMaxHeight((prev) => (measuredMaxHeight && measuredMaxHeight !== prev ? measuredMaxHeight : prev)); + } + + if (typeof ref === 'function') { + ref(reference); + } else if (ref) { + ref.current = reference; + } + }, + [ref], + ); + return (
    { - const maxHeight = reference ? getComputedStyle(reference).maxHeight : null; - if (maxHeight && maxHeight !== localRef.current) { - localRef.current = maxHeight; - } - - if (typeof ref === 'function') { - ref(reference); - } - }} + ref={listRef} {...rest} > { diff --git a/prometheus/src/plugins/PrometheusDatasourceEditor.tsx b/prometheus/src/plugins/PrometheusDatasourceEditor.tsx index ab45be2f5..d0c6a445f 100644 --- a/prometheus/src/plugins/PrometheusDatasourceEditor.tsx +++ b/prometheus/src/plugins/PrometheusDatasourceEditor.tsx @@ -37,14 +37,14 @@ export function PrometheusDatasourceEditor(props: PrometheusDatasourceEditorProp const { value, onChange, isReadonly, testConnection } = props; // Counter for generating unique IDs - const nextIdRef = useRef(0); + const nextIdRef = useRef(Object.keys(value.queryParams ?? {}).length); // Use local state to maintain an array of entries during editing, instead of // manipulating a map directly which causes weird UX. const [entries, setEntries] = useState(() => { const queryParams: QueryParamValues = value.queryParams ?? {}; - return Object.entries(queryParams).map(([key, val]) => ({ - id: String(nextIdRef.current++), + return Object.entries(queryParams).map(([key, val], index) => ({ + id: String(index), key, value: Array.isArray(val) ? val.join(',') : val, })); diff --git a/prometheus/src/plugins/prometheus-time-series-query/PrometheusTimeSeriesQueryEditor.tsx b/prometheus/src/plugins/prometheus-time-series-query/PrometheusTimeSeriesQueryEditor.tsx index 84944addc..6153f0fd4 100644 --- a/prometheus/src/plugins/prometheus-time-series-query/PrometheusTimeSeriesQueryEditor.tsx +++ b/prometheus/src/plugins/prometheus-time-series-query/PrometheusTimeSeriesQueryEditor.tsx @@ -12,7 +12,6 @@ // limitations under the License. import { Button, ButtonGroup, FormControl, Stack, TextField } from '@mui/material'; -import { useId } from '@perses-dev/components'; import { PanelEditorContext } from '@perses-dev/dashboards'; import type { DatasourceSelectProps } from '@perses-dev/plugin-system'; import { @@ -27,7 +26,7 @@ import { } from '@perses-dev/plugin-system'; import { produce } from 'immer'; import type { ReactElement } from 'react'; -import { useContext, useMemo } from 'react'; +import { useId, useContext, useMemo } from 'react'; import { PromQLEditor } from '../../components'; import type { DurationString, PrometheusClient, PrometheusDatasourceSelector } from '../../model'; @@ -57,7 +56,7 @@ export function PrometheusTimeSeriesQueryEditor(props: PrometheusTimeSeriesQuery const datasourceSelectValue = datasource ?? DEFAULT_PROM; - const datasourceSelectLabelID = useId('prom-datasource-label'); // for panels with multiple queries, this component is rendered multiple times on the same page + const datasourceSelectLabelID = useId(); // for panels with multiple queries, this component is rendered multiple times on the same page const selectedDatasource = useDatasourceSelectValueToSelector( datasourceSelectValue, diff --git a/pyroscope/src/plugins/pyroscope-profile-query/PyroscopeProfileQueryEditor.tsx b/pyroscope/src/plugins/pyroscope-profile-query/PyroscopeProfileQueryEditor.tsx index 91269ab98..e3e6e26b1 100644 --- a/pyroscope/src/plugins/pyroscope-profile-query/PyroscopeProfileQueryEditor.tsx +++ b/pyroscope/src/plugins/pyroscope-profile-query/PyroscopeProfileQueryEditor.tsx @@ -12,11 +12,11 @@ // limitations under the License. import { FormControl, InputLabel, Stack, TextField, useTheme } from '@mui/material'; -import { useId } from '@perses-dev/components'; import type { DatasourceSelectProps } from '@perses-dev/plugin-system'; import { DatasourceSelect, useDatasourceSelectValueToSelector } from '@perses-dev/plugin-system'; import { produce } from 'immer'; import type { ReactElement } from 'react'; +import { useId } from 'react'; import { ProfileTypeSelector, Service, Filters } from '../../components'; import type { PyroscopeDatasourceSelector } from '../../model'; @@ -37,7 +37,7 @@ export function PyroscopeProfileQueryEditor(props: ProfileQueryEditorProps): Rea datasourceSelectValue, PYROSCOPE_DATASOURCE_KIND, ) as PyroscopeDatasourceSelector; - const datasourceSelectLabelID = useId('pyroscope-datasource-label'); + const datasourceSelectLabelID = useId(); const { maxNodes, handleMaxNodesChange, maxNodesHasError } = useMaxNodesState(props); const { profileType, handleProfileTypeChange } = useProfileTypeState(props); diff --git a/rsbuild.shared.ts b/rsbuild.shared.ts index f899762ca..14ce99913 100644 --- a/rsbuild.shared.ts +++ b/rsbuild.shared.ts @@ -14,8 +14,10 @@ import { readFileSync } from 'node:fs'; import { resolve } from 'node:path'; -import { ModuleFederationOptions, pluginModuleFederation } from '@module-federation/rsbuild-plugin'; -import { mergeRsbuildConfig, RsbuildConfig } from '@rsbuild/core'; +import type { ModuleFederationOptions } from '@module-federation/rsbuild-plugin'; +import { pluginModuleFederation } from '@module-federation/rsbuild-plugin'; +import type { RsbuildConfig } from '@rsbuild/core'; +import { mergeRsbuildConfig } from '@rsbuild/core'; /** The base path for all plugin assets. This should match the path where plugins are stored on the Perses server. * @see {@link https://github.com/perses/perses} diff --git a/scatterchart/src/Scatterplot.tsx b/scatterchart/src/Scatterplot.tsx index 2d8d20e5a..c115b3b25 100644 --- a/scatterchart/src/Scatterplot.tsx +++ b/scatterchart/src/Scatterplot.tsx @@ -19,7 +19,7 @@ import { useRouterContext, useTimeRange, } from '@perses-dev/plugin-system'; -import type { EChartsOption, ScatterSeriesOption } from 'echarts'; +import type { EChartsOption, ScatterSeriesOption, TooltipComponentFormatterCallbackParams } from 'echarts'; import { ScatterChart as EChartsScatterChart } from 'echarts/charts'; import { DatasetComponent, @@ -119,10 +119,10 @@ export function Scatterplot(props: ScatterplotProps): ReactElement { axisPointer: { type: 'cross', }, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - formatter: function (params: any) { - // TODO: import type from ECharts instead of using any - const data = params[0].data as EChartTraceValue; + formatter: function (params: TooltipComponentFormatterCallbackParams): string { + const item = Array.isArray(params) ? params[0] : params; + if (!item) return ''; + const data = item.data as EChartTraceValue; return [ `Service name: ${data.rootServiceName}
    `, `Span name: ${data.rootTraceName}
    `, diff --git a/statchart/src/StatChartBase.tsx b/statchart/src/StatChartBase.tsx index 74ce1c70a..e682ea7b4 100644 --- a/statchart/src/StatChartBase.tsx +++ b/statchart/src/StatChartBase.tsx @@ -240,7 +240,7 @@ export const StatChartBase: FC = (props) => { {data.seriesData?.name} ); - }, [colorMode, showSeriesName, secondary, color, containerPadding, seriesNameFontSize, data?.seriesData?.name]); + }, [colorMode, showSeriesName, secondary, color, containerPadding, seriesNameFontSize, data.seriesData?.name]); return ( { }, ], }, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - definition: { query: 'some-query' } as any, + definition: { + kind: 'TimeSeriesQuery', + spec: { plugin: { kind: 'PrometheusTimeSeriesQuery', spec: { query: 'some-query' } } }, + }, }, ]; const colors = ['#ff0000', '#00ff00']; diff --git a/table/src/TableExportAction.tsx b/table/src/TableExportAction.tsx index 5db01c034..87bcce9fd 100644 --- a/table/src/TableExportAction.tsx +++ b/table/src/TableExportAction.tsx @@ -71,6 +71,20 @@ export function buildTableData( return { data: transformed, columns }; } +function downloadCsv(csvBlob: Blob, filename: string): void { + const url = URL.createObjectURL(csvBlob); + try { + const link = document.createElement('a'); + link.href = url; + link.download = filename; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + } finally { + URL.revokeObjectURL(url); + } +} + export const TableExportAction: React.FC = ({ queryResults, spec, definition }) => { const tableData = useMemo(() => buildTableData(queryResults, spec), [queryResults, spec]); @@ -92,17 +106,7 @@ export const TableExportAction: React.FC = ({ queryResults, spec, de const baseFilename = sanitizeFilename(title); const filename = `${baseFilename}_data.csv`; - const url = URL.createObjectURL(csvBlob); - try { - const link = document.createElement('a'); - link.href = url; - link.download = filename; - document.body.appendChild(link); - link.click(); - document.body.removeChild(link); - } finally { - URL.revokeObjectURL(url); - } + downloadCsv(csvBlob, filename); } catch (error) { console.error('Table CSV export failed:', error); } diff --git a/table/src/components/ColumnsEditor/EmbeddedPanelOptionsEditor.tsx b/table/src/components/ColumnsEditor/EmbeddedPanelOptionsEditor.tsx index 26456dece..8be8202d4 100644 --- a/table/src/components/ColumnsEditor/EmbeddedPanelOptionsEditor.tsx +++ b/table/src/components/ColumnsEditor/EmbeddedPanelOptionsEditor.tsx @@ -13,11 +13,11 @@ import { CircularProgress, Stack, Typography } from '@mui/material'; import type { PanelPlugin } from '@perses-dev/plugin-system'; -import { OptionsEditorTabs, usePlugin } from '@perses-dev/plugin-system'; +import { OptionsEditorTabs, useEvent, usePlugin } from '@perses-dev/plugin-system'; import type { UnknownSpec } from '@perses-dev/spec'; import merge from 'lodash/merge'; import type { ReactElement } from 'react'; -import { useEffect, useMemo, useRef } from 'react'; +import { useEffect, useMemo } from 'react'; export interface EmbeddedPanelOptionsEditorProps { kind: string; @@ -52,16 +52,15 @@ export function EmbeddedPanelOptionsEditor({ kind, spec, onChange }: EmbeddedPan return mergeWithPluginDefaults(panelPlugin, spec); }, [panelPlugin, spec]); - const onChangeRef = useRef(onChange); - onChangeRef.current = onChange; + const handleChange = useEvent(onChange); // Persist plugin defaults when the column still has an empty spec (e.g. after switching panel kind). useEffect(() => { if (!panelPlugin || !isSpecEmpty(spec)) { return; } - onChangeRef.current(mergeWithPluginDefaults(panelPlugin, spec)); - }, [panelPlugin, kind, spec]); + handleChange(mergeWithPluginDefaults(panelPlugin, spec)); + }, [panelPlugin, spec, handleChange]); if (isLoading) { return ( diff --git a/table/src/components/TablePanel.test.tsx b/table/src/components/TablePanel.test.tsx index ca1b176ad..1ab095e36 100644 --- a/table/src/components/TablePanel.test.tsx +++ b/table/src/components/TablePanel.test.tsx @@ -19,6 +19,7 @@ import type { TimeSeriesData } from '@perses-dev/spec'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { render, screen, waitFor, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; +import type { ReactElement } from 'react'; import { VirtuosoMockContext } from 'react-virtuoso'; import type { TableOptions, TimeSeriesTableProps } from '../models'; @@ -34,12 +35,10 @@ import { import { TablePanel } from './TablePanel'; /* mock all variables */ -const MOCK_VARIABLE_STATE_MAP = vi.hoisted( - (): VariableStateMap => ({ - myproject: { loading: false, value: 'my_project' }, - __range: { loading: false, value: '1h' }, - }), -); +const MOCK_VARIABLE_STATE_MAP = vi.hoisted((): VariableStateMap => ({ + myproject: { loading: false, value: 'my_project' }, + __range: { loading: false, value: '1h' }, +})); vi.mock('@perses-dev/plugin-system', async (importOriginal) => ({ ...(await importOriginal()), // Return a stable reference (like the real hook, which memoizes) so consumers @@ -56,6 +55,36 @@ const TEST_TIME_SERIES_TABLE_PROPS: Omit = spec: {}, }; +const VIRTUOSO_MOCK_CONTEXT = { viewportHeight: 600, itemHeight: 100 }; +const FILTERING_SPEC: TableOptions = { enableFiltering: true }; +const FILTERING_QUERY_RESULTS = [ + { definition: MOCK_TIME_SERIES_QUERY_DEFINITION, data: MOCK_TIME_SERIES_DATA_SINGLEVALUE }, +]; +const NARROW_DIMENSIONS = { width: 500, height: 500 }; +const WIDE_DIMENSIONS = { width: 600, height: 500 }; + +function renderFilteringTable(contentDimensions: TimeSeriesTableProps['contentDimensions']): ReactElement { + return ( + + + + + + + + + + ); +} + +function getFirstFilterCell(): HTMLElement { + return screen.getAllByRole('button', { name: '▼' })[0]!.parentElement!; +} + describe('TablePanel', () => { // Helper to render the panel with some context set const renderPanel = (data: TimeSeriesData, options?: TableOptions): void => { @@ -524,4 +553,57 @@ describe('TablePanel', () => { TEST_TIMEOUT, ); }); + + describe('filter row alignment', () => { + let headerCellWidth = 0; + const observedElements = new Set(); + + beforeEach(() => { + headerCellWidth = 120; + observedElements.clear(); + vi.spyOn(HTMLTableCellElement.prototype, 'getBoundingClientRect').mockImplementation( + () => ({ width: headerCellWidth }) as DOMRect, + ); + vi.stubGlobal( + 'ResizeObserver', + class { + observe(element: Element): void { + observedElements.add(element); + } + unobserve(element: Element): void { + observedElements.delete(element); + } + disconnect(): void { + observedElements.clear(); + } + }, + ); + }); + + afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + }); + + it( + 'should re-sync filter cell widths when the table replaces its header row', + async () => { + const { container, rerender } = render(renderFilteringTable(NARROW_DIMENSIONS)); + + await waitFor(() => expect(getFirstFilterCell().style.width).toBe('120px')); + const initialHeaderRow = container.querySelector('thead tr'); + expect(observedElements.has(initialHeaderRow!)).toBe(true); + + headerCellWidth = 150; + rerender(renderFilteringTable(WIDE_DIMENSIONS)); + + await waitFor(() => expect(getFirstFilterCell().style.width).toBe('150px')); + const currentHeaderRow = container.querySelector('thead tr'); + expect(currentHeaderRow).not.toBe(initialHeaderRow); + expect(observedElements.has(currentHeaderRow!)).toBe(true); + expect(observedElements.has(initialHeaderRow!)).toBe(false); + }, + TEST_TIMEOUT, + ); + }); }); diff --git a/table/src/components/TablePanel.tsx b/table/src/components/TablePanel.tsx index b95728810..156e725c1 100644 --- a/table/src/components/TablePanel.tsx +++ b/table/src/components/TablePanel.tsx @@ -21,7 +21,7 @@ import { CalculationsMap, replaceVariablesInString, useAllVariableValues } from import type { JsonData, QueryDataType, TimeSeriesData } from '@perses-dev/spec'; import type { ColumnFiltersState, PaginationState, RowSelectionState, SortingState } from '@tanstack/react-table'; import type { ReactElement } from 'react'; -import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; import { createPortal } from 'react-dom'; import type { CellSettings, ColumnSettings, TableOptions } from '../models'; @@ -395,7 +395,7 @@ export function TablePanel({ contentDimensions, spec, queryResults }: TableProps const filteredDataRef = useRef>>([]); // Refs used to keep the filter row in sync with the table's horizontal - const panelContainerRef = useRef(null); + const [panelContainer, setPanelContainer] = useState(null); const filterRowInnerRef = useRef(null); const filterCellRefs = useRef>([]); @@ -699,10 +699,10 @@ export function TablePanel({ contentDimensions, spec, queryResults }: TableProps setOpenFilterColumn(columnId); }; - const handleFilterClose = (): void => { + const handleFilterClose = useCallback((): void => { setFilterAnchorEl({}); setOpenFilterColumn(null); - }; + }, []); // Close filter when clicking outside useEffect(() => { @@ -723,23 +723,22 @@ export function TablePanel({ contentDimensions, spec, queryResults }: TableProps clearTimeout(timer); document.removeEventListener('click', handleClick); }; - }, [openFilterColumn]); + }, [openFilterColumn, handleFilterClose]); // Keep ref in sync with filtered data for use in selection handler - filteredDataRef.current = filteredData; + useLayoutEffect(() => { + filteredDataRef.current = filteredData; + }, [filteredData]); const [pagination, setPagination] = useState( spec.pagination ? { pageIndex: 0, pageSize: 10 } : undefined, ); - useEffect(() => { - // If the pagination setting changes from no pagination to pagination, but the pagination state is undefined, update the pagination state - if (spec.pagination && !pagination) { - setPagination({ pageIndex: 0, pageSize: 10 }); - } else if (!spec.pagination && pagination) { - setPagination(undefined); - } - }, [spec.pagination, pagination]); + if (spec.pagination && !pagination) { + setPagination({ pageIndex: 0, pageSize: 10 }); + } else if (!spec.pagination && pagination) { + setPagination(undefined); + } // Sync the filter row's horizontal position with the table scroll. useEffect(() => { @@ -747,7 +746,7 @@ export function TablePanel({ contentDimensions, spec, queryResults }: TableProps return; } - const scrollContainer = panelContainerRef.current?.querySelector('.MuiTableContainer-root'); + const scrollContainer = panelContainer?.querySelector('.MuiTableContainer-root'); const filterRowInner = filterRowInnerRef.current; if (!scrollContainer || !filterRowInner) { @@ -761,13 +760,13 @@ export function TablePanel({ contentDimensions, spec, queryResults }: TableProps setFilterAnchorEl((current) => (Object.keys(current).length === 0 ? current : {})); }; - syncFilterRowScroll(); + filterRowInner.style.transform = `translateX(-${scrollContainer.scrollLeft}px)`; scrollContainer.addEventListener('scroll', syncFilterRowScroll, { passive: true }); return (): void => { scrollContainer.removeEventListener('scroll', syncFilterRowScroll); }; - }, [spec.enableFiltering, columns, contentDimensions]); + }, [panelContainer, spec.enableFiltering]); // Sync filter cell widths with the actual rendered table column widths to keep them aligned. useEffect(() => { @@ -775,16 +774,11 @@ export function TablePanel({ contentDimensions, spec, queryResults }: TableProps return; } - const scrollContainer = panelContainerRef.current?.querySelector('.MuiTableContainer-root'); + const scrollContainer = panelContainer?.querySelector('.MuiTableContainer-root'); if (!scrollContainer) { return; } - const headerRow = scrollContainer.querySelector('thead tr'); - if (!headerRow) { - return; - } - const syncColumnWidths = (): void => { const headerCells = scrollContainer.querySelectorAll('thead tr th'); @@ -802,18 +796,35 @@ export function TablePanel({ contentDimensions, spec, queryResults }: TableProps }); }; - syncColumnWidths(); - - // Re-sync whenever the header row's size changes + // Re-sync whenever the header row's size changes. const resizeObserver = new ResizeObserver(syncColumnWidths); - if (headerRow) { - resizeObserver.observe(headerRow); - } + // The virtualized table remounts its header row when its width, rows, or columns change, + // so follow the current header row instead of observing a detached one. + let headerRow: Element | null = null; + const observeHeaderRow = (): void => { + const currentHeaderRow = scrollContainer.querySelector('thead tr'); + if (currentHeaderRow === headerRow) { + return; + } + if (headerRow) { + resizeObserver.unobserve(headerRow); + } + headerRow = currentHeaderRow; + if (headerRow) { + resizeObserver.observe(headerRow); + } + syncColumnWidths(); + }; + + observeHeaderRow(); + const mutationObserver = new MutationObserver(observeHeaderRow); + mutationObserver.observe(scrollContainer, { childList: true, subtree: true }); return (): void => { + mutationObserver.disconnect(); resizeObserver.disconnect(); }; - }, [spec.enableFiltering, columns, contentDimensions, selectionEnabled, actionButtons]); + }, [panelContainer, spec.enableFiltering, columns]); if (contentDimensions === undefined) { return null; @@ -835,7 +846,7 @@ export function TablePanel({ contentDimensions, spec, queryResults }: TableProps } return ( -
    +
    {confirmDialog} {spec.enableFiltering && (
    ; diff --git a/tempo/src/components/AttributeFilters.tsx b/tempo/src/components/AttributeFilters.tsx index 3bc9f9de5..4dec3523d 100644 --- a/tempo/src/components/AttributeFilters.tsx +++ b/tempo/src/components/AttributeFilters.tsx @@ -17,7 +17,7 @@ import { useTimeRange } from '@perses-dev/plugin-system'; import type { UseQueryResult } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query'; import type { ReactElement, SyntheticEvent } from 'react'; -import { useCallback, useEffect, useState } from 'react'; +import { useCallback, useState } from 'react'; import type { TempoClient } from '../model'; import { getUnixTimeRange } from '../plugins'; @@ -220,9 +220,11 @@ function LazyTextInput(props: LazyTextInputProps): ReactElement { const [draftValue, setDraftValue] = useState(value); const isValidInput = draftValue === '' || validationRegex === undefined || validationRegex.test(draftValue); - useEffect(() => { + const [previousValue, setPreviousValue] = useState(value); + if (value !== previousValue) { + setPreviousValue(value); setDraftValue(value); - }, [value, setDraftValue]); + } const handleChange = useCallback((event: React.ChangeEvent) => { setDraftValue(event.target.value); diff --git a/tempo/src/components/TraceQLEditor.tsx b/tempo/src/components/TraceQLEditor.tsx index ac4193399..814aa2a21 100644 --- a/tempo/src/components/TraceQLEditor.tsx +++ b/tempo/src/components/TraceQLEditor.tsx @@ -20,7 +20,7 @@ import type { ReactElement } from 'react'; import { useMemo } from 'react'; import type { TempoClient } from '../model'; -import { TraceQLExtension } from './TraceQLExtension'; +import { TraceQLExtension as createTraceQLExtension } from './TraceQLExtension'; export interface TraceQLEditorProps extends Omit { client?: TempoClient; @@ -32,7 +32,7 @@ export function TraceQLEditor({ client, ...rest }: TraceQLEditorProps): ReactEle const { absoluteTimeRange } = useTimeRange(); const traceQLExtension = useMemo(() => { - return TraceQLExtension({ client, timeRange: absoluteTimeRange }); + return createTraceQLExtension({ client, timeRange: absoluteTimeRange }); }, [client, absoluteTimeRange]); const codemirrorTheme = useMemo(() => { diff --git a/tempo/src/plugins/tempo-trace-query/TempoTraceQueryEditor.tsx b/tempo/src/plugins/tempo-trace-query/TempoTraceQueryEditor.tsx index 450a4d887..a949e5f72 100644 --- a/tempo/src/plugins/tempo-trace-query/TempoTraceQueryEditor.tsx +++ b/tempo/src/plugins/tempo-trace-query/TempoTraceQueryEditor.tsx @@ -12,12 +12,11 @@ // limitations under the License. import { Box, Button, FormControl, InputLabel, MenuItem, Select, Stack } from '@mui/material'; -import { useId } from '@perses-dev/components'; import type { DatasourceSelectProps } from '@perses-dev/plugin-system'; import { DatasourceSelect, useDatasourceClient, useDatasourceSelectValueToSelector } from '@perses-dev/plugin-system'; import { produce } from 'immer'; import type { ReactElement } from 'react'; -import { useCallback, useState } from 'react'; +import { useId, useCallback, useState } from 'react'; import { TraceQLEditor, filterToTraceQL, traceQLToFilter } from '../../components'; import { AttributeFilters } from '../../components/AttributeFilters'; @@ -35,7 +34,7 @@ export function TempoTraceQueryEditor(props: TraceQueryEditorProps): ReactElemen const datasourceSelectValue = datasource ?? DEFAULT_TEMPO; const selectedDatasource = useDatasourceSelectValueToSelector(datasourceSelectValue, TEMPO_DATASOURCE_KIND); - const datasourceSelectLabelID = useId('tempo-datasource-label'); // for panels with multiple queries, this component is rendered multiple times on the same page + const datasourceSelectLabelID = useId(); // for panels with multiple queries, this component is rendered multiple times on the same page const { data: client } = useDatasourceClient(selectedDatasource); const { query, handleQueryChange, handleQueryBlur } = useQueryState(props); diff --git a/timeserieschart/src/QuerySettingsEditor.tsx b/timeserieschart/src/QuerySettingsEditor.tsx index 9d24c7395..efdbc4199 100644 --- a/timeserieschart/src/QuerySettingsEditor.tsx +++ b/timeserieschart/src/QuerySettingsEditor.tsx @@ -34,7 +34,7 @@ import CloseIcon from 'mdi-material-ui/Close'; import DeleteIcon from 'mdi-material-ui/DeleteOutline'; import AddIcon from 'mdi-material-ui/Plus'; import type { ReactElement } from 'react'; -import React, { useEffect, useMemo, useRef, useState } from 'react'; +import React, { useCallback, useMemo, useRef, useState } from 'react'; import type { QuerySettingsOptions, @@ -58,13 +58,12 @@ export function QuerySettingsEditor(props: TimeSeriesChartOptionsEditorProps): R ); }; // Every time a new query settings input is added, we want to focus the recently added input - const recentlyAddedInputRef = useRef(null); const focusRef = useRef(false); - useEffect(() => { - if (!recentlyAddedInputRef.current || !focusRef.current) return; - recentlyAddedInputRef.current?.focus(); + const recentlyAddedInputRef = useCallback((input: HTMLInputElement | null): void => { + if (!input || !focusRef.current) return; + input.focus(); focusRef.current = false; - }, [querySettingsList?.length]); + }, []); const handleQueryIndexChange = (e: React.ChangeEvent, i: number): void => { if (querySettingsList !== undefined) { @@ -348,7 +347,7 @@ interface QuerySettingsInputProps { onLineStyleChange: (lineStyle: string) => void; onAreaOpacityChange: (event: Event, value: number | number[]) => void; onDelete: () => void; - inputRef?: React.RefObject; + inputRef?: React.Ref; // Optional control handlers onAddColor: () => void; onRemoveColor: () => void; diff --git a/timeserieschart/src/TimeSeriesChartBase.tsx b/timeserieschart/src/TimeSeriesChartBase.tsx index 3be9ed12b..dcb4ec87a 100644 --- a/timeserieschart/src/TimeSeriesChartBase.tsx +++ b/timeserieschart/src/TimeSeriesChartBase.tsx @@ -69,7 +69,7 @@ import { CanvasRenderer } from 'echarts/renderers'; import isEqual from 'lodash/isEqual'; import merge from 'lodash/merge'; import type { MouseEvent } from 'react'; -import { forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react'; +import { forwardRef, useCallback, useImperativeHandle, useMemo, useRef, useState } from 'react'; import useResizeObserver from 'use-resize-observer'; import { ANNOTATION_Y_AXIS, AnnotationTooltip, buildAnnotationSeries } from './annotations/AnnotationTooltip'; @@ -223,7 +223,7 @@ export const TimeSeriesChartBase = forwardRef(fun }; }, []); - const handleEvents: OnEventsType = useMemo(() => { + const handleEvents = useMemo>(() => { return { datazoom: (params): void => { if (onDataZoom === undefined) { @@ -248,19 +248,21 @@ export const TimeSeriesChartBase = forwardRef(fun enableDataZoom(chartRef.current); } }, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - mouseover: (params: any): void => { + mouseover: (params): void => { if ( params.componentType === 'series' && params.seriesType === 'scatter' && + 'seriesId' in params && typeof params.seriesId === 'string' && params.seriesId.startsWith(EXEMPLAR_SERIES_ID_PREFIX) ) { - if (params.data?.exemplar) { + // This series ID identifies the metadata embedded by getExemplarSeries. + const point = params.data as { exemplar?: Exemplar; seriesLabels?: Labels; value?: number[] }; + if (point?.exemplar) { setHoveredExemplar({ - exemplar: params.data.exemplar, - seriesLabels: params.data.seriesLabels, - plottedValue: params.data?.value?.[1] ?? params.data.exemplar.value, + exemplar: point.exemplar, + seriesLabels: point.seriesLabels, + plottedValue: point.value?.[1] ?? point.exemplar.value, }); return; } @@ -269,7 +271,7 @@ export const TimeSeriesChartBase = forwardRef(fun // Only markPoint (triangles under the X-axis) opens the annotation tooltip. // Hovering markLine or anything else keeps the regular TimeSeries tooltip visible // and clears any stale hovered annotation (mouseout is sometimes missed by ECharts). - if (annotations && params.componentType === 'markPoint' && params.data?.annotationIndex !== undefined) { + if (annotations && params.componentType === 'markPoint' && typeof params.data?.annotationIndex === 'number') { const matchedAnnotation = annotations[params.data.annotationIndex] || null; if (matchedAnnotation) { setHoveredAnnotation(matchedAnnotation); @@ -278,11 +280,11 @@ export const TimeSeriesChartBase = forwardRef(fun } setHoveredAnnotation(null); }, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - mouseout: (params: any): void => { + mouseout: (params): void => { if ( params.componentType === 'series' && params.seriesType === 'scatter' && + 'seriesId' in params && typeof params.seriesId === 'string' && params.seriesId.startsWith(EXEMPLAR_SERIES_ID_PREFIX) ) { @@ -292,7 +294,7 @@ export const TimeSeriesChartBase = forwardRef(fun if ( annotations && params.componentType === 'markPoint' && - params.data?.annotationIndex !== undefined && + typeof params.data?.annotationIndex === 'number' && annotations ) { // Only clear if the mouseout corresponds to the currently hovered annotation, so that @@ -420,18 +422,21 @@ export const TimeSeriesChartBase = forwardRef(fun getTimezoneAwareAxisFormatter, ]); - // Update adjacent charts so tooltip is unpinned when current chart is clicked. - useEffect(() => { - // Only allow pinning one tooltip at a time, subsequent tooltip click unpins previous. - // Multiple tooltips can only be pinned if Ctrl or Cmd key is pressed while clicking. - const multipleTooltipsPinned = tooltipPinnedCoords !== null && lastTooltipPinnedCoords !== null; - if (multipleTooltipsPinned) { - if (!isEqual(lastTooltipPinnedCoords, tooltipPinnedCoords)) { - setTooltipPinnedCoords(null); - if (tooltipPinnedCoords !== null && pinnedCrosshair !== null) { - setPinnedCrosshair(null); - } - } + // Only changes from another chart (or new series) can clear the local pin. + // A local click may update its coordinates without updating the shared pin. + const [previousPinState, setPreviousPinState] = useState({ lastTooltipPinnedCoords, seriesMapping }); + if ( + previousPinState.lastTooltipPinnedCoords !== lastTooltipPinnedCoords || + previousPinState.seriesMapping !== seriesMapping + ) { + setPreviousPinState({ lastTooltipPinnedCoords, seriesMapping }); + if ( + tooltipPinnedCoords !== null && + lastTooltipPinnedCoords !== null && + !isEqual(lastTooltipPinnedCoords, tooltipPinnedCoords) + ) { + setTooltipPinnedCoords(null); + setPinnedCrosshair(null); } // A pinned exemplar tooltip is also unpinned when a tooltip is pinned in another chart, // unless it is the one just pinned by this chart at these exact coordinates. @@ -443,9 +448,7 @@ export const TimeSeriesChartBase = forwardRef(fun setPinnedExemplar(null); setPinnedExemplarPos(null); } - // tooltipPinnedCoords CANNOT be in dep array or tooltip pinning breaks in the current chart's onClick - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [lastTooltipPinnedCoords, seriesMapping]); + } return ( (null); @@ -238,7 +237,7 @@ function TimeSeriesChartPanelComponent(props: TimeSeriesChartProps): ReactElemen exemplars: [], }); } - seriesIndex++; + seriesIndex += 1; } for (const group of exemplars ?? []) { if (group.exemplars.length === 0) continue; @@ -405,7 +404,7 @@ function TimeSeriesChartPanelComponent(props: TimeSeriesChartProps): ReactElemen values: thresholdTimeValueTuple, }); timeSeriesMapping.push(getThresholdSeries(thresholdName, stepOption, seriesIndex)); - seriesIndex++; + seriesIndex += 1; }); } diff --git a/timeserieschart/src/annotations/AnnotationTooltip.tsx b/timeserieschart/src/annotations/AnnotationTooltip.tsx index 07d7691fa..d6f4b53ad 100644 --- a/timeserieschart/src/annotations/AnnotationTooltip.tsx +++ b/timeserieschart/src/annotations/AnnotationTooltip.tsx @@ -24,7 +24,7 @@ import { useMousePosition, } from '@perses-dev/components'; import { DEFAULT_ANNOTATION_COLOR } from '@perses-dev/plugin-system'; -import type { LineSeriesOption, YAXisComponentOption } from 'echarts'; +import type { LineSeriesOption, MarkPointComponentOption, YAXisComponentOption } from 'echarts'; import Pin from 'mdi-material-ui/Pin'; import PinOutline from 'mdi-material-ui/PinOutline'; import useResizeObserver from 'use-resize-observer'; @@ -183,8 +183,14 @@ export function buildAnnotationSeries( label?: { show: boolean }; annotationIndex?: number; }> = []; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const markPointData: any[] = []; + const markPointData: Array< + NonNullable[number] & { + annotationIndex: number; + isStart?: boolean; + isEnd?: boolean; + isPoint?: boolean; + } + > = []; annotations.forEach((annotation, index) => { const color = annotation.color ?? DEFAULT_ANNOTATION_COLOR; @@ -217,6 +223,7 @@ export function buildAnnotationSeries( // Add start and end markers for (const isStart of [true, false]) { markPointData.push({ + name: '', coord: [isStart ? annotation.start : annotation.end, 0], symbol: 'triangle', symbolSize: [12, 12], @@ -242,6 +249,7 @@ export function buildAnnotationSeries( // Add point marker markPointData.push({ + name: '', coord: [annotation.start, 0], symbol: 'triangle', symbolSize: [12, 10], diff --git a/timeserieschart/src/utils/data-transform.ts b/timeserieschart/src/utils/data-transform.ts index 86a8f464d..b1caa3853 100644 --- a/timeserieschart/src/utils/data-transform.ts +++ b/timeserieschart/src/utils/data-transform.ts @@ -252,7 +252,6 @@ function findMax(data: LegacyTimeSeries[] | TimeSeries[]): number { if (data.length && data[0] !== undefined && (data as TimeSeries[])[0]?.values) { (data as TimeSeries[]).forEach((series) => { series.values.forEach((valueTuple: TimeSeriesValueTuple) => { - // eslint-disable-next-line @typescript-eslint/no-unused-vars const [_, value] = valueTuple; // Use the absolute value so percent thresholds compute correctly against // negated series (e.g. when `querySettings[].negativeY` is enabled). diff --git a/timeseriestable/src/components/DataTable.tsx b/timeseriestable/src/components/DataTable.tsx index 841f17f3e..5d68567fe 100644 --- a/timeseriestable/src/components/DataTable.tsx +++ b/timeseriestable/src/components/DataTable.tsx @@ -82,7 +82,7 @@ export const DataTable = ({ queryResults, spec }: DataTableProps): ReactElement const itemActionsConfig = spec.actions ? (spec.actions as ActionOptions) : undefined; const itemActionsListConfig = useMemo( () => (itemActionsConfig?.enabled && itemActionsConfig.displayWithItem ? itemActionsConfig.actionsList : []), - [itemActionsConfig?.enabled, itemActionsConfig?.displayWithItem, itemActionsConfig?.actionsList], + [itemActionsConfig], ); const { getItemActionButtons, confirmDialog, actionButtons } = useSelectionItemActions({ diff --git a/tracetable/src/DataTable.tsx b/tracetable/src/DataTable.tsx index 2600e7e1a..ee3121dd1 100644 --- a/tracetable/src/DataTable.tsx +++ b/tracetable/src/DataTable.tsx @@ -108,7 +108,7 @@ export function DataTable(props: DataTableProps): ReactElement { } } return result_rows; - }, [result, options.links?.trace, variableValues]); + }, [result, options.links, variableValues]); const rowsById = useMemo(() => { const map = new Map(); diff --git a/tracingganttchart/src/TracingGanttChart/GanttTable/ResizableDivider.tsx b/tracingganttchart/src/TracingGanttChart/GanttTable/ResizableDivider.tsx index edcfbd8f4..b22365eae 100644 --- a/tracingganttchart/src/TracingGanttChart/GanttTable/ResizableDivider.tsx +++ b/tracingganttchart/src/TracingGanttChart/GanttTable/ResizableDivider.tsx @@ -18,18 +18,20 @@ import { useEffect, useState } from 'react'; interface ResizableDividerProps { parentRef: React.RefObject; - spacing?: number; onMove: (left: number) => void; } export function ResizableDivider(props: ResizableDividerProps): ReactElement { - const { parentRef, spacing = 0, onMove } = props; + const { parentRef, onMove } = props; const [isResizing, setResizing] = useState(false); + const [spacing, setSpacing] = useState(0); const handleMouseDown = (e: ReactMouseEvent): void => { // disable any default actions (text selection, etc.) e.preventDefault(); + // read the flex gap once per drag instead of on every mousemove + setSpacing(parentRef.current ? parseFloat(getComputedStyle(parentRef.current).columnGap) || 0 : 0); setResizing(true); }; diff --git a/tracingganttchart/src/TracingGanttChart/MiniGanttChart/Canvas.tsx b/tracingganttchart/src/TracingGanttChart/MiniGanttChart/Canvas.tsx index 91c38cdc5..e72a4367f 100644 --- a/tracingganttchart/src/TracingGanttChart/MiniGanttChart/Canvas.tsx +++ b/tracingganttchart/src/TracingGanttChart/MiniGanttChart/Canvas.tsx @@ -60,13 +60,13 @@ export function Canvas(props: CanvasProps): ReactElement { ); useEffect(() => { - if (!canvasRef.current || !width || !height) return; + if (!canvasRef.current || !width) return; const ctx = canvasRef.current.getContext('2d'); if (!ctx) return; - drawSpans(ctx, width, height, trace, spanColorGenerator); - }, [width, height, trace, spanColorGenerator]); + drawSpans(ctx, width, CANVAS_HEIGHT, trace, spanColorGenerator); + }, [width, trace, spanColorGenerator]); const translateCursorToTime = (e: ReactMouseEvent | MouseEvent): number => { if (!canvasRef.current || !width) return 0; diff --git a/tracingganttchart/src/TracingGanttChart/TracingGanttChart.tsx b/tracingganttchart/src/TracingGanttChart/TracingGanttChart.tsx index f85a2b89b..a8b469bef 100644 --- a/tracingganttchart/src/TracingGanttChart/TracingGanttChart.tsx +++ b/tracingganttchart/src/TracingGanttChart/TracingGanttChart.tsx @@ -64,7 +64,6 @@ export function TracingGanttChart(props: TracingGanttChartProps): ReactElement { // setTableWidth() is only called by const [tableWidth, setTableWidth] = useState(0.82); const gap = 2; - const spacing = ganttChart.current ? parseFloat(getComputedStyle(ganttChart.current).columnGap) || 0 : 0; return ( @@ -86,7 +85,7 @@ export function TracingGanttChart(props: TracingGanttChartProps): ReactElement { {selectedSpan && ( <> - + ; @@ -26,7 +26,7 @@ export function LogsQLEditor(props: LogsQLEditorProps): ReactElement { const isDarkMode = theme.palette.mode === 'dark'; const logsqlExtension = useMemo(() => { - return LogsQLExtension(); + return createLogsQLExtension(); }, []); const codemirrorTheme = useMemo(() => { diff --git a/victorialogs/src/components/logsql-extension.ts b/victorialogs/src/components/logsql-extension.ts index c2809fc1a..636cdb89f 100644 --- a/victorialogs/src/components/logsql-extension.ts +++ b/victorialogs/src/components/logsql-extension.ts @@ -29,7 +29,7 @@ function logsqlLanguage(): LRLanguage { }); } -export function LogsQLExtension(): Array { +export function createLogsQLExtension(): Array { const language = logsqlLanguage(); return [language]; } diff --git a/victorialogs/src/queries/victorialogs-log-query/VictoriaLogsLogQueryEditor.tsx b/victorialogs/src/queries/victorialogs-log-query/VictoriaLogsLogQueryEditor.tsx index 2c2451cfd..2e90cadd8 100644 --- a/victorialogs/src/queries/victorialogs-log-query/VictoriaLogsLogQueryEditor.tsx +++ b/victorialogs/src/queries/victorialogs-log-query/VictoriaLogsLogQueryEditor.tsx @@ -16,7 +16,7 @@ import { createModEnterHandler } from '@perses-dev/dashboards'; import type { DatasourceSelectProps, OptionsEditorProps } from '@perses-dev/plugin-system'; import { DatasourceSelect, isVariableDatasource, useDatasourceSelectValueToSelector } from '@perses-dev/plugin-system'; import type { ReactElement } from 'react'; -import { useCallback, useState, useEffect } from 'react'; +import { useCallback, useState } from 'react'; import { LogsQLEditor } from '../../components/logsql-editor'; import type { VictoriaLogsDatasourceSelector } from '../../model'; @@ -41,10 +41,11 @@ export function VictoriaLogsLogQueryEditor(props: VictoriaLogsQueryEditorProps): // Local state for editor value to prevent query_range calls on every keystroke const [localQuery, setLocalQuery] = useState(value.query); - // Update local state when prop changes - useEffect(() => { + const [previousQuery, setPreviousQuery] = useState(value.query); + if (value.query !== previousQuery) { + setPreviousQuery(value.query); setLocalQuery(value.query); - }, [value.query]); + } const handleDatasourceChange: DatasourceSelectProps['onChange'] = (newDatasourceSelection) => { if (!isVariableDatasource(newDatasourceSelection) && newDatasourceSelection.kind === DATASOURCE_KIND) { diff --git a/victorialogs/src/queries/victorialogs-time-series-query/VictoriaLogsTimeSeriesQueryEditor.tsx b/victorialogs/src/queries/victorialogs-time-series-query/VictoriaLogsTimeSeriesQueryEditor.tsx index b64c4a53a..f7d3a73ee 100644 --- a/victorialogs/src/queries/victorialogs-time-series-query/VictoriaLogsTimeSeriesQueryEditor.tsx +++ b/victorialogs/src/queries/victorialogs-time-series-query/VictoriaLogsTimeSeriesQueryEditor.tsx @@ -16,7 +16,7 @@ import { createModEnterHandler } from '@perses-dev/dashboards'; import type { DatasourceSelectProps, OptionsEditorProps } from '@perses-dev/plugin-system'; import { DatasourceSelect, isVariableDatasource, useDatasourceSelectValueToSelector } from '@perses-dev/plugin-system'; import type { ReactElement } from 'react'; -import { useCallback, useState, useEffect } from 'react'; +import { useCallback, useState } from 'react'; import { LogsQLEditor } from '../../components/logsql-editor'; import type { VictoriaLogsDatasourceSelector } from '../../model'; @@ -38,10 +38,11 @@ export function VictoriaLogsQueryEditor(props: VictoriaLogsQueryEditorProps): Re // Local state for editor value to prevent query_range calls on every keystroke const [localQuery, setLocalQuery] = useState(value.query); - // Update local state when prop changes - useEffect(() => { + const [previousQuery, setPreviousQuery] = useState(value.query); + if (value.query !== previousQuery) { + setPreviousQuery(value.query); setLocalQuery(value.query); - }, [value.query]); + } const handleDatasourceChange: DatasourceSelectProps['onChange'] = (newDatasourceSelection) => { if (!isVariableDatasource(newDatasourceSelection) && newDatasourceSelection.kind === DATASOURCE_KIND) { diff --git a/vitest.shared.ts b/vitest.shared.ts index fd58ce49a..35cfcfa6b 100644 --- a/vitest.shared.ts +++ b/vitest.shared.ts @@ -52,7 +52,7 @@ export function definePackageVitestConfig({ packageDir, setupFiles = [], passWithNoTests = false, -}: VitestPackageOptions) { +}: VitestPackageOptions): ReturnType { return mergeConfig( sharedConfig, defineConfig({