From fdb55c84be9ac10e9778fc6ab7a5e4ac83de62c1 Mon Sep 17 00:00:00 2001 From: Guillaume LADORME Date: Wed, 21 Jan 2026 15:55:03 +0100 Subject: [PATCH 1/8] [FEATURE] Add annotations Signed-off-by: Guillaume LADORME --- .../TimeSeriesTooltip/TimeChartTooltip.tsx | 8 +- .../Annotations/AnnotationsEditor.tsx | 276 ++++++++++++++++++ .../Annotations/EditAnnotationsButton.tsx | 90 ++++++ .../DashboardToolbar/DashboardToolbar.tsx | 4 + .../components/Variables/VariableEditor.tsx | 6 +- .../src/constants/user-interface-text.ts | 1 + .../AnnotationProvider/AnnotationProvider.tsx | 185 ++++++++++++ .../src/context/AnnotationProvider/index.ts | 14 + .../src/context/AnnotationProvider/utils.ts | 41 +++ dashboards/src/context/index.ts | 1 + .../src/views/ViewDashboard/DashboardApp.tsx | 3 + .../src/views/ViewDashboard/ViewDashboard.tsx | 63 ++-- .../AnnotationEditorForm.tsx | 205 +++++++++++++ .../Annotations/AnnotationEditorForm/index.ts | 14 + .../src/components/Annotations/index.ts | 14 + plugin-system/src/components/index.ts | 1 + .../src/context/ValidationProvider.tsx | 13 + plugin-system/src/model/annotations.ts | 30 ++ plugin-system/src/model/plugins.ts | 2 + plugin-system/src/runtime/annotations.ts | 204 +++++++++++++ plugin-system/src/runtime/index.ts | 1 + 21 files changed, 1143 insertions(+), 33 deletions(-) create mode 100644 dashboards/src/components/Annotations/AnnotationsEditor.tsx create mode 100644 dashboards/src/components/Annotations/EditAnnotationsButton.tsx create mode 100644 dashboards/src/context/AnnotationProvider/AnnotationProvider.tsx create mode 100644 dashboards/src/context/AnnotationProvider/index.ts create mode 100644 dashboards/src/context/AnnotationProvider/utils.ts create mode 100644 plugin-system/src/components/Annotations/AnnotationEditorForm/AnnotationEditorForm.tsx create mode 100644 plugin-system/src/components/Annotations/AnnotationEditorForm/index.ts create mode 100644 plugin-system/src/components/Annotations/index.ts create mode 100644 plugin-system/src/model/annotations.ts create mode 100644 plugin-system/src/runtime/annotations.ts diff --git a/components/src/TimeSeriesTooltip/TimeChartTooltip.tsx b/components/src/TimeSeriesTooltip/TimeChartTooltip.tsx index 3f775274..fffe9f8d 100644 --- a/components/src/TimeSeriesTooltip/TimeChartTooltip.tsx +++ b/components/src/TimeSeriesTooltip/TimeChartTooltip.tsx @@ -75,7 +75,11 @@ export const TimeChartTooltip = memo(function TimeChartTooltip({ // if tooltip is attached to a container, set max height to the height of the container so tooltip does not get cut off const maxHeight = containerElement ? containerElement.getBoundingClientRect().height : undefined; - transform.current = assembleTransform(mousePos, pinnedPos, height ?? 0, width ?? 0, containerElement); + if (!width || !height || !containerElement) { + return null; + } + + transform.current = assembleTransform(mousePos, pinnedPos, height, width, containerElement); // Get series nearby the cursor and pass into tooltip content children. const nearbySeries = getNearbySeriesData({ @@ -92,7 +96,7 @@ export const TimeChartTooltip = memo(function TimeChartTooltip({ return null; } - const totalSeries = data.length; + const totalSeries = data.length; // !*.js,!*.d.ts,!*.js.map return ( diff --git a/dashboards/src/components/Annotations/AnnotationsEditor.tsx b/dashboards/src/components/Annotations/AnnotationsEditor.tsx new file mode 100644 index 00000000..4d7d15a4 --- /dev/null +++ b/dashboards/src/components/Annotations/AnnotationsEditor.tsx @@ -0,0 +1,276 @@ +// Copyright 2024 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 { useState, useMemo, ReactElement } from 'react'; +import { + Button, + Stack, + Box, + TableContainer, + TableBody, + TableRow, + TableCell as MuiTableCell, + Table, + TableHead, + Switch, + Typography, + IconButton, + Alert, + styled, +} from '@mui/material'; +import AddIcon from 'mdi-material-ui/Plus'; +import { Action, AnnotationDefinition } from '@perses-dev/core'; +import { useImmer } from 'use-immer'; +import PencilIcon from 'mdi-material-ui/Pencil'; +import TrashIcon from 'mdi-material-ui/TrashCan'; +import ArrowUp from 'mdi-material-ui/ArrowUp'; +import ArrowDown from 'mdi-material-ui/ArrowDown'; + +import { ValidationProvider, AnnotationEditorForm } from '@perses-dev/plugin-system'; +import { useDiscardChangesConfirmationDialog } from '../../context'; + +function getValidation(annotationDefinitions: AnnotationDefinition[]): { isValid: boolean; errors: string[] } { + const errors: string[] = []; + + /** Annotation names must be unique */ + const annotationNames = annotationDefinitions.map((annotationDefinition) => annotationDefinition.spec.display.name); + const uniqueAnnotationNames = new Set(annotationNames); + if (annotationNames.length !== uniqueAnnotationNames.size) { + errors.push('Annotation names must be unique'); + } + return { + errors: errors, + isValid: errors.length === 0, + }; +} + +export function AnnotationEditor(props: { + annotationDefinitions: AnnotationDefinition[]; + onChange: (annotationDefinitions: AnnotationDefinition[]) => void; + onCancel: () => void; +}): ReactElement { + const [annotationDefinitions, setAnnotationDefinitions] = useImmer(props.annotationDefinitions); + const [annotationEditIdx, setAnnotationEditIdx] = useState(null); + const [annotationFormAction, setAnnotationFormAction] = useState('update'); + + const validation = useMemo(() => getValidation(annotationDefinitions), [annotationDefinitions]); + const currentEditingAnnotationDefinition: AnnotationDefinition | undefined = annotationEditIdx + ? annotationDefinitions[annotationEditIdx] + : undefined; + + const { openDiscardChangesConfirmationDialog, closeDiscardChangesConfirmationDialog } = + useDiscardChangesConfirmationDialog(); + const handleCancel = (): void => { + if (JSON.stringify(props.annotationDefinitions) !== JSON.stringify(annotationDefinitions)) { + openDiscardChangesConfirmationDialog({ + onDiscardChanges: () => { + closeDiscardChangesConfirmationDialog(); + props.onCancel(); + }, + onCancel: () => { + closeDiscardChangesConfirmationDialog(); + }, + description: + 'You have unapplied changes. Are you sure you want to discard these changes? Changes cannot be recovered.', + }); + } else { + props.onCancel(); + } + }; + + const removeAnnotation = (index: number): void => { + setAnnotationDefinitions((draft) => { + draft.splice(index, 1); + }); + }; + + const addAnnotation = (): void => { + setAnnotationFormAction('create'); + setAnnotationDefinitions((draft) => { + draft.push({ + kind: 'Annotation', + spec: { + display: { name: 'NewAnnotation' }, + plugin: {}, + }, + }); + }); + setAnnotationEditIdx(annotationDefinitions.length); + }; + + const editAnnotation = (index: number): void => { + setAnnotationFormAction('update'); + setAnnotationEditIdx(index); + }; + + const toggleAnnotationVisibility = (index: number, visible: boolean): void => { + setAnnotationDefinitions((draft) => { + const v = draft[index]; + if (!v) { + return; + } + v.spec.display.hidden = !visible; + }); + }; + + const changeAnnotationOrder = (index: number, direction: 'up' | 'down'): void => { + setAnnotationDefinitions((draft) => { + if (direction === 'up') { + const prevElement = draft[index - 1]; + const currentElement = draft[index]; + if (index === 0 || !prevElement || !currentElement) { + return; + } + draft[index - 1] = currentElement; + draft[index] = prevElement; + } else { + const nextElement = draft[index + 1]; + const currentElement = draft[index]; + if (index === draft.length - 1 || !nextElement || !currentElement) { + return; + } + draft[index + 1] = currentElement; + draft[index] = nextElement; + } + }); + }; + + return ( + <> + {annotationEditIdx && currentEditingAnnotationDefinition ? ( + + { + setAnnotationDefinitions((draft) => { + draft[annotationEditIdx] = definition; + setAnnotationEditIdx(null); + }); + }} + onClose={() => { + if (annotationFormAction === 'create') { + removeAnnotation(annotationEditIdx); + } + setAnnotationEditIdx(null); + }} + /> + + ) : ( + <> + theme.spacing(1, 2), + borderBottom: (theme) => `1px solid ${theme.palette.divider}`, + }} + > + Edit Dashboard Annotations + + + + + + + + + {!validation.isValid && + validation.errors.map((error) => ( + + {error} + + ))} + + + + + Visibility + Name + Type + Description + Actions + + + + {annotationDefinitions.map((v, idx) => ( + + + { + toggleAnnotationVisibility(idx, e.target.checked); + }} + /> + + + {v.spec.display.name} + + {v.kind} + {v.spec.display?.description ?? ''} + + changeAnnotationOrder(idx, 'up')} disabled={idx === 0}> + + + changeAnnotationOrder(idx, 'down')} + disabled={idx === annotationDefinitions.length - 1} + > + + + editAnnotation(idx)}> + + + removeAnnotation(idx)}> + + + + + ))} + +
+
+ + + +
+
+
+ + )} + + ); +} + +const TableCell = styled(MuiTableCell)(({ theme }) => ({ + borderBottom: `solid 1px ${theme.palette.divider}`, +})); diff --git a/dashboards/src/components/Annotations/EditAnnotationsButton.tsx b/dashboards/src/components/Annotations/EditAnnotationsButton.tsx new file mode 100644 index 00000000..879f600f --- /dev/null +++ b/dashboards/src/components/Annotations/EditAnnotationsButton.tsx @@ -0,0 +1,90 @@ +// Copyright 2024 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 { ReactElement, useState } from 'react'; +import { Button, ButtonProps } from '@mui/material'; +import PencilIcon from 'mdi-material-ui/PencilOutline'; +import { Drawer, InfoTooltip } from '@perses-dev/components'; +import { AnnotationDefinition } from '@perses-dev/core'; +import { TOOLTIP_TEXT, editButtonStyle } from '../../constants'; +import { useAnnotationDefinitionActions, useAnnotationDefinitions } from '../../context'; +import { AnnotationEditor } from './AnnotationsEditor'; + +export interface EditAnnotationsButtonProps extends Pick { + /** + * The variant to use to display the button. + */ + variant?: 'text' | 'outlined'; + + /** + * The color to use to display the button. + */ + color?: 'primary' | 'secondary'; + + /** + * The label used inside the button. + */ + label?: string; +} + +export function EditAnnotationsButton({ + variant = 'text', + label = 'Annotations', + color = 'primary', + fullWidth, +}: EditAnnotationsButtonProps): ReactElement { + const [isAnnotationEditorOpen, setIsAnnotationEditorOpen] = useState(false); + const annotationDefinitions: AnnotationDefinition[] = useAnnotationDefinitions(); + const { setAnnotationDefinitions } = useAnnotationDefinitionActions(); + + const openAnnotationEditor = (): void => { + setIsAnnotationEditorOpen(true); + }; + + const closeAnnotationEditor = (): void => { + setIsAnnotationEditorOpen(false); + }; + + return ( + <> + + + + + { + setAnnotationDefinitions(annotations); + setIsAnnotationEditorOpen(false); + }} + /> + + + ); +} diff --git a/dashboards/src/components/DashboardToolbar/DashboardToolbar.tsx b/dashboards/src/components/DashboardToolbar/DashboardToolbar.tsx index 1db2d056..46c59a8b 100644 --- a/dashboards/src/components/DashboardToolbar/DashboardToolbar.tsx +++ b/dashboards/src/components/DashboardToolbar/DashboardToolbar.tsx @@ -25,6 +25,7 @@ import { EditButton } from '../EditButton'; import { EditJsonButton } from '../EditJsonButton'; import { SaveDashboardButton } from '../SaveDashboardButton'; import { DashboardStickyToolbar } from '../DashboardStickyToolbar'; +import { EditAnnotationsButton } from '../Annotations/EditAnnotationsButton'; import { EditDashboardLinksButton } from '../DashboardLinks'; import { LinksDisplay } from '../LinksDisplay'; @@ -34,6 +35,7 @@ export interface DashboardToolbarProps { initialVariableIsSticky?: boolean; isReadonly: boolean; isVariableEnabled: boolean; + isAnnotationEnabled: boolean; isDatasourceEnabled: boolean; isLinksEnabled?: boolean; onEditButtonClick: () => void; @@ -48,6 +50,7 @@ export const DashboardToolbar = (props: DashboardToolbarProps): ReactElement => initialVariableIsSticky, isReadonly, isVariableEnabled, + isAnnotationEnabled, isDatasourceEnabled, isLinksEnabled = true, onEditButtonClick, @@ -93,6 +96,7 @@ export const DashboardToolbar = (props: DashboardToolbarProps): ReactElement => )} + {isAnnotationEnabled && } {isVariableEnabled && } {isDatasourceEnabled && } {isLinksEnabled && } diff --git a/dashboards/src/components/Variables/VariableEditor.tsx b/dashboards/src/components/Variables/VariableEditor.tsx index ab4350d4..850e05d6 100644 --- a/dashboards/src/components/Variables/VariableEditor.tsx +++ b/dashboards/src/components/Variables/VariableEditor.tsx @@ -88,7 +88,9 @@ export function VariableEditor(props: { const [variableState] = useMemo(() => { return [hydrateVariableDefinitionStates(variableDefinitions, {}, externalVariableDefinitions)]; }, [externalVariableDefinitions, variableDefinitions]); - const currentEditingVariableDefinition = typeof variableEditIdx === 'number' && variableDefinitions[variableEditIdx]; + const currentEditingVariableDefinition: VariableDefinition | undefined = variableEditIdx + ? variableDefinitions[variableEditIdx] + : undefined; const { openDiscardChangesConfirmationDialog, closeDiscardChangesConfirmationDialog } = useDiscardChangesConfirmationDialog(); @@ -181,7 +183,7 @@ export function VariableEditor(props: { return ( <> - {currentEditingVariableDefinition && ( + {variableEditIdx && currentEditingVariableDefinition && ( void; +// annotationState: AnnotationStoreStateMap; +// setAnnotationLoading: (name: string, loading: boolean) => void; +// setAnnotationValue: (name: string, loading: boolean) => void; +// }; +// +// const AnnotationDefinitionStoreContext = createContext | undefined>(undefined); + +// interface AnnotationDefinitionStoreArgs { +// initialAnnotations: AnnotationDefinition[]; +// } +// +// function createAnnotationStore({ +// initialAnnotations, +// }: AnnotationDefinitionStoreArgs): StoreApi { +// const store = createStore()( +// devtools( +// immer((set) => ({ +// annotationDefinitions: initialAnnotations, +// annotationState: hydrateAnnotationDefinitionStates(initialAnnotations), +// setAnnotationDefinitions(definitions: AnnotationDefinition[]): void { +// set( +// (state) => { +// state.annotationDefinitions = definitions; +// state.annotationState = hydrateAnnotationDefinitionStates(definitions); +// }, +// false, +// '[Annotations] setAnnotationDefinitions' // Used for action name in Redux devtools +// ); +// }, +// setAnnotationLoading(name: string, loading: boolean): void { +// set( +// (state) => { +// const annoState = state.annotationState.get({ name }); +// if (!annoState) { +// return; +// } +// annoState.loading = loading; +// }, +// false, +// '[Annotations] setAnnotationLoading' +// ); +// }, +// setAnnotationValue: (name: string, value: AnnotationValue): void => +// set( +// (state) => { +// const varState = state.annotationState.get({ name }); +// if (!varState) { +// return; +// } +// +// varState.value = value; +// }, +// false, +// '[Annotations] setAnnotationValue' +// ), +// })) +// ) +// ); +// return store satisfies StoreApi; +// } + +// function getQueryOptions({plugin, defintion, context}: {plugin?: AnnotationPlugin; definition: AnnotationDefinition; context: AnnotationContext}): { +// queryKey: QueryKey; +// queryEnabled: boolean; +// } { +// +// +// +// return { queryKey: [], queryEnabled: false }; +// } +// +// export function useAnnotationState(definitions: AnnotationDefinition[]): AnnotationStateMap { +// const pluginLoaderResponse = usePlugins('Annotation', definitions.map(definition => ({ kind: definition.spec.plugin.kind }))); +// +// return useQueries({ +// queries: definitions.map((definition, idx) => { +// const plugin = pluginLoaderResponse[idx]?.data; +// const { queryEnabled, queryKey } = getQueryOptions({ definition, plugin }); +// const annotationKind = definition?.spec?.plugin?.kind; +// return { +// enabled: queryEnabled, +// queryKey: queryKey, +// refetchOnMount: false, +// refetchOnWindowFocus: false, +// refetchOnReconnect: false, +// staleTime: Infinity, +// queryFn: async ({ signal }: { signal?: AbortSignal }): Promise => { +// const plugin = await getPlugin(ANNOTATION_KEY, annotationKind); +// const data = await plugin.getAnnotationData(definition.spec.plugin.spec, context, signal); +// return data; +// }, +// } +// })}) +// +// const annotationValues = useQueries({ +// queries: definitions.map((definition) => { +// return { +// queryKey: ['annotationData', definition.spec.display.name], // todo: check key +// queryFn: async (): Promise => { +// const resp = await variablePlugin?.getVariableOptions(spec, { datasourceStore, variables, timeRange }, signal); +// if (!resp?.data?.length) { +// return []; +// } +// }, +// }; +// }), +// }); +// +// const state: AnnotationStateMap = useMemo(() => { +// const stateMap: AnnotationStateMap = {}; +// +// for (const annotationQuery of annotationValues) { +// const definition = definitions.find((def => def.spec.display.name === annotationQuery.queryKey[1]); +// if (definition === undefined) { +// continue; +// } +// const name = definition.spec.display.name; +// const annotationState: AnnotationState = { +// value: annotationQuery.data ?? null, +// loading: annotationQuery.isLoading, +// }; +// stateMap[name] = annotationState; +// } +// +// return stateMap; +// }, [definitions, annotationValues]); +// } + +export type AnnotationState = { + definition: AnnotationDefinition; + data: AnnotationData | null; + isPending: boolean; + error?: Error | null; +}; + +export type AnnotationStateMap = { + [name: string]: AnnotationState; +}; + +const AnnotationDefinitionContext = createContext(undefined); + +export interface AnnotationProviderProps { + children: ReactNode; + initialAnnotations?: AnnotationDefinition[]; +} + +export function AnnotationProvider({ children, initialAnnotations = [] }: AnnotationProviderProps): ReactNode { + const annotations = useAnnotations(initialAnnotations); + + // TODO: check if it is better to create state in provider or in a custom hook? + const state: AnnotationStateMap = useMemo(() => { + const result: AnnotationStateMap = {}; + + for (const [index, definition] of initialAnnotations.entries()) { + const query = annotations[index] ?? null; + if (query) { + result[definition.spec.display.name] = { + definition, + data: query.data ?? null, + isPending: query.isLoading, + error: query.error ?? null, + }; + } + } + + return result; + }, [annotations, initialAnnotations]); + + return {children}; +} + +export function useAnnotationStateMap(): AnnotationStateMap { + const ctx = useContext(AnnotationDefinitionContext); + if (ctx === undefined) { + throw new Error('No AnnotationDefinitionContext found. Did you forget a provider?'); + } + return ctx; +} diff --git a/dashboards/src/context/AnnotationProvider/index.ts b/dashboards/src/context/AnnotationProvider/index.ts new file mode 100644 index 00000000..2d4447f4 --- /dev/null +++ b/dashboards/src/context/AnnotationProvider/index.ts @@ -0,0 +1,14 @@ +// Copyright 2025 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. + +export * from './AnnotationProvider'; diff --git a/dashboards/src/context/AnnotationProvider/utils.ts b/dashboards/src/context/AnnotationProvider/utils.ts new file mode 100644 index 00000000..a0e66eea --- /dev/null +++ b/dashboards/src/context/AnnotationProvider/utils.ts @@ -0,0 +1,41 @@ +// Copyright 2025 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 { AnnotationData, AnnotationDefinition } from '@perses-dev/core'; +import { AnnotationStoreStateMap, AnnotationState } from '@perses-dev/plugin-system'; + +function hydrateAnnotationState(annotation: AnnotationDefinition, value?: AnnotationData): AnnotationState { + const annoState: AnnotationState = { + value: null, + loading: false, + }; + + annoState.value = value ?? null; + + return AnnotationState; +} + +/** + * Build the local annotation states according to the given definitions + * @param definitions local annotation definitions. Dynamic part. + */ +export function hydrateAnnotationDefinitionStates(definitions: AnnotationDefinition[]): AnnotationStoreStateMap { + const state: AnnotationStoreStateMap = new AnnotationStoreStateMap(); + + for (const definition of definitions) { + const name = definition.spec.display.name; + state.set({ name }, hydrateAnnotationState(definition)); + } + + return state; +} diff --git a/dashboards/src/context/index.ts b/dashboards/src/context/index.ts index f854671e..90510440 100644 --- a/dashboards/src/context/index.ts +++ b/dashboards/src/context/index.ts @@ -11,6 +11,7 @@ // See the License for the specific language governing permissions and // limitations under the License. +export * from './AnnotationProvider'; export * from './DashboardProvider'; export * from './DatasourceStoreProvider'; export * from './VariableProvider'; diff --git a/dashboards/src/views/ViewDashboard/DashboardApp.tsx b/dashboards/src/views/ViewDashboard/DashboardApp.tsx index e94effe8..3a443347 100644 --- a/dashboards/src/views/ViewDashboard/DashboardApp.tsx +++ b/dashboards/src/views/ViewDashboard/DashboardApp.tsx @@ -37,6 +37,7 @@ export interface DashboardAppProps { emptyDashboardProps?: Partial; isReadonly: boolean; isVariableEnabled: boolean; + isAnnotationEnabled: boolean; isDatasourceEnabled: boolean; isCreating?: boolean; isInitialVariableSticky?: boolean; @@ -53,6 +54,7 @@ export const DashboardApp = (props: DashboardAppProps): ReactElement => { emptyDashboardProps, isReadonly, isVariableEnabled, + isAnnotationEnabled, isDatasourceEnabled, isCreating, isInitialVariableSticky, @@ -125,6 +127,7 @@ export const DashboardApp = (props: DashboardAppProps): ReactElement => { onSave={onSave} isReadonly={isReadonly} isVariableEnabled={isVariableEnabled} + isAnnotationEnabled={isAnnotationEnabled} isDatasourceEnabled={isDatasourceEnabled} onEditButtonClick={onEditButtonClick} onCancelButtonClick={onCancelButtonClick} diff --git a/dashboards/src/views/ViewDashboard/ViewDashboard.tsx b/dashboards/src/views/ViewDashboard/ViewDashboard.tsx index 6725cc70..134cbd4b 100644 --- a/dashboards/src/views/ViewDashboard/ViewDashboard.tsx +++ b/dashboards/src/views/ViewDashboard/ViewDashboard.tsx @@ -27,6 +27,7 @@ import { DatasourceStoreProvider, VariableProviderProps, VariableProviderWithQueryParams, + AnnotationProvider, } from '../../context'; import { DashboardProviderWithQueryParams } from '../../context/DashboardProvider/DashboardProviderWithQueryParams'; import { DashboardApp, DashboardAppProps } from './DashboardApp'; @@ -49,6 +50,7 @@ export function ViewDashboard(props: ViewDashboardProps): ReactElement { emptyDashboardProps, isReadonly, isVariableEnabled, + isAnnotationEnabled, isDatasourceEnabled, isEditing, isCreating, @@ -119,35 +121,38 @@ export function ViewDashboard(props: ViewDashboardProps): ReactElement { externalVariableDefinitions={externalVariableDefinitions} builtinVariableDefinitions={builtinVariables} > - - - - - + + + + + + + diff --git a/plugin-system/src/components/Annotations/AnnotationEditorForm/AnnotationEditorForm.tsx b/plugin-system/src/components/Annotations/AnnotationEditorForm/AnnotationEditorForm.tsx new file mode 100644 index 00000000..2908b11d --- /dev/null +++ b/plugin-system/src/components/Annotations/AnnotationEditorForm/AnnotationEditorForm.tsx @@ -0,0 +1,205 @@ +// Copyright 2026 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 { DispatchWithoutAction, ReactElement, useState } from 'react'; +import { Box, Typography, TextField, Grid, Divider } from '@mui/material'; +import { AnnotationDefinition, Action } from '@perses-dev/core'; +import { DiscardChangesConfirmationDialog, ErrorAlert, ErrorBoundary, FormActions } from '@perses-dev/components'; +import { Control, Controller, FormProvider, SubmitHandler, useForm, useWatch } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { getSubmitText, getTitleAction } from '../../../utils'; +import { PluginEditor } from '../../PluginEditor'; +import { useValidationSchemas } from '../../../context'; + +interface KindAnnotationEditorFormProps { + action: Action; + control: Control; +} + +function AnnotationPluginControl({ action, control }: KindAnnotationEditorFormProps): ReactElement { + const plugin = useWatch({ control, name: 'spec.plugin' }); + const kind = plugin?.kind; + const pluginSpec = plugin?.spec; + + return ( + { + return ( + { + field.onChange({ kind: v.selection.kind, spec: v.spec }); + }} + /> + ); + }} + /> + ); +} + +interface AnnotationEditorFormProps { + initialAnnotationDefinition: AnnotationDefinition; + action: Action; + isDraft: boolean; + isReadonly?: boolean; + onActionChange?: (action: Action) => void; + onSave: (def: AnnotationDefinition) => void; + onClose: () => void; + onDelete?: DispatchWithoutAction; +} + +export function AnnotationEditorForm({ + initialAnnotationDefinition, + action, + isDraft, + isReadonly, + onActionChange, + onSave, + onClose, + onDelete, +}: AnnotationEditorFormProps): ReactElement { + const [isDiscardDialogOpened, setDiscardDialogOpened] = useState(false); + const titleAction = getTitleAction(action, isDraft); + const submitText = getSubmitText(action, isDraft); + + const { annotationEditorSchema } = useValidationSchemas(); + const form = useForm({ + resolver: zodResolver(annotationEditorSchema), + mode: 'onBlur', + defaultValues: initialAnnotationDefinition, + }); + + const processForm: SubmitHandler = (data: AnnotationDefinition) => { + // reset display attributes to undefined when empty, because we don't want to save empty strings + onSave(data); + }; + + // When user click on cancel, several possibilities: + // - create action: ask for discard approval + // - update action: ask for discard approval if changed + // - read action: don´t ask for discard approval + function handleCancel(): void { + if (JSON.stringify(initialAnnotationDefinition) !== JSON.stringify(form.getValues())) { + setDiscardDialogOpened(true); + } else { + onClose(); + } + } + + return ( + + theme.spacing(1, 2), + borderBottom: (theme) => `1px solid ${theme.palette.divider}`, + }} + > + {titleAction} Annotation + + + + + + ( + { + field.onChange(event); + }} + /> + )} + /> + + + ( + { + field.onChange(event); + }} + /> + )} + /> + + + + + + + + + + { + setDiscardDialogOpened(false); + }} + onDiscardChanges={() => { + setDiscardDialogOpened(false); + onClose(); + }} + /> + + ); +} diff --git a/plugin-system/src/components/Annotations/AnnotationEditorForm/index.ts b/plugin-system/src/components/Annotations/AnnotationEditorForm/index.ts new file mode 100644 index 00000000..0bb1badf --- /dev/null +++ b/plugin-system/src/components/Annotations/AnnotationEditorForm/index.ts @@ -0,0 +1,14 @@ +// Copyright 2026 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. + +export * from './AnnotationEditorForm'; diff --git a/plugin-system/src/components/Annotations/index.ts b/plugin-system/src/components/Annotations/index.ts new file mode 100644 index 00000000..0bb1badf --- /dev/null +++ b/plugin-system/src/components/Annotations/index.ts @@ -0,0 +1,14 @@ +// Copyright 2026 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. + +export * from './AnnotationEditorForm'; diff --git a/plugin-system/src/components/index.ts b/plugin-system/src/components/index.ts index f7151db6..460fdf25 100644 --- a/plugin-system/src/components/index.ts +++ b/plugin-system/src/components/index.ts @@ -11,6 +11,7 @@ // See the License for the specific language governing permissions and // limitations under the License. +export * from './Annotations'; export * from './CalculationSelector'; export * from './DatasourceEditorForm'; export * from './DatasourceSelect'; diff --git a/plugin-system/src/context/ValidationProvider.tsx b/plugin-system/src/context/ValidationProvider.tsx index f5021340..8cbef7cb 100644 --- a/plugin-system/src/context/ValidationProvider.tsx +++ b/plugin-system/src/context/ValidationProvider.tsx @@ -20,6 +20,9 @@ import { variableDefinitionSchema, buildPanelEditorSchema, buildVariableDefinitionSchema, + AnnotationDefinition, + annotationDefinitionSchema, + buildAnnotationDefinitionSchema, } from '@perses-dev/spec'; import { DatasourceDefinition, datasourceDefinitionSchema, buildDatasourceDefinitionSchema } from '@perses-dev/core'; // Todo these things should not be part of the plugin system. Only the spec should be used import { z } from 'zod'; @@ -28,9 +31,11 @@ export interface ValidationSchemas { datasourceEditorSchema: z.Schema; panelEditorSchema: z.Schema; variableEditorSchema: z.Schema; + annotationEditorSchema: z.Schema; setDatasourceEditorSchemaPlugin: (pluginSchema: PluginSchema) => void; setPanelEditorSchemaPlugin: (pluginSchema: PluginSchema) => void; setVariableEditorSchemaPlugin: (pluginSchema: PluginSchema) => void; + setAnnotationEditorSchemaPlugin?: (pluginSchema: PluginSchema) => void; } export const ValidationSchemasContext = createContext(undefined); @@ -56,6 +61,8 @@ export function ValidationProvider({ children }: ValidationProviderProps): React const [panelEditorSchema, setPanelEditorSchema] = useState>(defaultPanelEditorSchema); // TODO I don't get why this does not compile const [variableEditorSchema, setVariableEditorSchema] = useState>(variableDefinitionSchema); + const [annotationEditorSchema, setAnnotationEditorSchema] = + useState>(annotationDefinitionSchema); function setDatasourceEditorSchemaPlugin(pluginSchema: PluginSchema): void { setDatasourceEditorSchema(buildDatasourceDefinitionSchema(pluginSchema)); @@ -69,15 +76,21 @@ export function ValidationProvider({ children }: ValidationProviderProps): React setVariableEditorSchema(buildVariableDefinitionSchema(pluginSchema)); } + function setAnnotationEditorSchemaPlugin(pluginSchema: PluginSchema): void { + setAnnotationEditorSchema(buildAnnotationDefinitionSchema(pluginSchema)); + } + return ( {children} diff --git a/plugin-system/src/model/annotations.ts b/plugin-system/src/model/annotations.ts new file mode 100644 index 00000000..563e1805 --- /dev/null +++ b/plugin-system/src/model/annotations.ts @@ -0,0 +1,30 @@ +import { AbsoluteTimeRange, AnnotationData, UnknownSpec } from '@perses-dev/core'; +import { DatasourceStore, VariableStateMap } from '@perses-dev/plugin-system'; +import { Plugin } from './plugin-base'; + +/** + * An object containing all the dependencies of a AnnotationQuery. + */ +type AnnotationQueryQueryPluginDependencies = { + /** + * Returns a list of variables name this annotation query depends on. + */ + variables?: string[]; +}; + +/** + * A plugin for running annotation queries. + */ +export interface AnnotationPlugin extends Plugin { + getAnnotationData: (spec: Spec, ctx: AnnotationContext, abortSignal?: AbortSignal) => Promise; + dependsOn?: (spec: Spec, ctx: AnnotationContext) => AnnotationQueryQueryPluginDependencies; +} + +/** + * Context available to AnnotationQuery plugins at runtime. + */ +export interface AnnotationContext { + datasourceStore: DatasourceStore; + absoluteTimeRange?: AbsoluteTimeRange; + variableState: VariableStateMap; +} diff --git a/plugin-system/src/model/plugins.ts b/plugin-system/src/model/plugins.ts index 4c5a76c3..e136fca2 100644 --- a/plugin-system/src/model/plugins.ts +++ b/plugin-system/src/model/plugins.ts @@ -21,6 +21,7 @@ import { ProfileQueryPlugin } from './profile-queries'; import { VariablePlugin } from './variables'; import { ExplorePlugin } from './explore'; import { LogQueryPlugin } from './log-queries'; +import { AnnotationPlugin } from './annotations'; export interface PluginModuleSpec { plugins: PluginMetadata[]; @@ -76,6 +77,7 @@ export type PluginType = { */ export interface SupportedPlugins { Variable: VariablePlugin; + Annotation: AnnotationPlugin; Panel: PanelPlugin; TimeSeriesQuery: TimeSeriesQueryPlugin; TraceQuery: TraceQueryPlugin; diff --git a/plugin-system/src/runtime/annotations.ts b/plugin-system/src/runtime/annotations.ts new file mode 100644 index 00000000..d4356933 --- /dev/null +++ b/plugin-system/src/runtime/annotations.ts @@ -0,0 +1,204 @@ +// Copyright 2024 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 { AnnotationData, AnnotationDefinition } from '@perses-dev/core'; +import { QueryKey, useQueries, UseQueryResult } from '@tanstack/react-query'; +import { AnnotationContext, AnnotationPlugin } from '../model/annotations'; +import { usePluginRegistry, usePlugins } from './plugin-registry'; +import { useTimeRange } from './TimeRangeProvider'; +import { useAllVariableValues } from './variables'; +import { useDatasourceStore } from './datasources'; +import { filterVariableStateMap, getVariableValuesKey } from './utils'; +// +// export type AnnotationState = { +// value: AnnotationData | null; +// loading: boolean; +// error?: Error; +// }; +// +// export type AnnotationStateMap = Record; +// +// /** +// * Structure used as key in the {@link AnnotationStoreStateMap}. +// */ +// export type AnnotationStateKey = { +// /** +// * name of the annotation we want to access in the state. +// */ +// name: string; +// }; + +// /** +// * A state map with two entry keys, materialized by {@link AnnotationStateKey} structure. +// */ +// export class AnnotationStoreStateMap { +// /** +// * "Immerable" is mandatory to be able to use this class in an immer context. +// * Ref: https://docs.pmnd.rs/zustand/integrations/immer-middleware#gotchas +// */ +// [immerable] = true; +// +// private readonly _state: Record = {}; +// +// /** +// * Get annotation state by key. +// * @param key +// */ +// get(key: AnnotationStateKey): AnnotationState | undefined { +// return this._state[key.name]; +// } +// +// /** +// * Set annotation state by key. +// * @param key +// * @param value +// */ +// set(key: AnnotationStateKey, value: AnnotationState): AnnotationState | undefined { +// this._state[key.name] = value; +// return value; +// } +// +// /** +// * Check presence of annotation state by key. +// * @param key +// */ +// has(key: AnnotationStateKey): boolean { +// return this._state[key.name] !== undefined; +// } +// +// /** +// * Delete annotation state by key. +// * @param key +// */ +// delete(key: AnnotationStateKey): boolean { +// const result = this.has(key); +// // Delete source state from state if empty +// delete this._state[key.name]; +// +// return result; +// } +// } +// +// export type AnnotationSrv = { +// state: AnnotationStateMap; +// }; +// +// export const AnnotationContext = createContext(undefined); +// +// function useAnnotationContext(): AnnotationSrv { +// const ctx = useContext(AnnotationContext); +// if (ctx === undefined) { +// throw new Error('No AnnotationContext found. Did you forget a Provider?'); +// } +// return ctx; +// } +// +// export function useAnnotationValues(names?: string[]): AnnotationStateMap { +// const { state } = useAnnotationContext(); +// +// const values = useMemo(() => { +// const values: AnnotationStateMap = {}; +// names?.forEach((name) => { +// const s = state[name]; +// if (s) { +// values[name] = s; +// } +// }); +// return values; +// }, [state, names]); +// +// if (names === undefined) { +// return state; +// } +// +// return values; +// } + +export const ANNOTATION_KEY = 'Annotation'; + +function useAnnotationContext(): AnnotationContext { + const { absoluteTimeRange } = useTimeRange(); + const variableState = useAllVariableValues(); + const datasourceStore = useDatasourceStore(); + + return { + variableState, + datasourceStore, + absoluteTimeRange, + }; +} + +function getQueryOptions({ + plugin, + definition, + context, +}: { + plugin?: AnnotationPlugin; + definition: AnnotationDefinition; + context: AnnotationContext; +}): { + queryKey: QueryKey; + queryEnabled: boolean; +} { + const { variableState, absoluteTimeRange } = context; + + const dependencies = plugin?.dependsOn ? plugin.dependsOn(definition.spec.plugin.spec, context) : {}; + const variableDependencies = dependencies?.variables; + + const filteredVariabledState = filterVariableStateMap(variableState, variableDependencies); + const variablesValueKey = getVariableValuesKey(filteredVariabledState); + const queryKey = [ANNOTATION_KEY, definition, absoluteTimeRange, variablesValueKey] as const; + + let waitToLoad = false; + if (variableDependencies) { + waitToLoad = variableDependencies.some((v) => variableState[v]?.loading); + } + + const queryEnabled = plugin !== undefined && !waitToLoad; + return { + queryKey, + queryEnabled, + }; +} + +export function useAnnotations(definitions: AnnotationDefinition[]): Array> { + const { getPlugin } = usePluginRegistry(); + const context = useAnnotationContext(); + + const pluginLoaderResponse = usePlugins( + 'Annotation', + definitions.map((d) => ({ kind: d.spec.plugin.kind })) + ); + + // useQueries() handles data fetching from query plugins (e.g. traceQL queries, promQL queries) + return useQueries({ + queries: definitions.map((definition, idx) => { + const plugin = pluginLoaderResponse[idx]?.data; + const { queryEnabled, queryKey } = getQueryOptions({ context, definition, plugin }); + const annotationKind = definition?.spec?.plugin?.kind; + return { + enabled: queryEnabled, + queryKey: queryKey, + refetchOnMount: false, + refetchOnWindowFocus: false, + refetchOnReconnect: false, + staleTime: Infinity, + queryFn: async ({ signal }: { signal?: AbortSignal }): Promise => { + const plugin = await getPlugin(ANNOTATION_KEY, annotationKind); + const data = await plugin.getAnnotationData(definition.spec.plugin.spec, context, signal); + return data; + }, + }; + }), + }); +} diff --git a/plugin-system/src/runtime/index.ts b/plugin-system/src/runtime/index.ts index 72c86796..20675e5d 100644 --- a/plugin-system/src/runtime/index.ts +++ b/plugin-system/src/runtime/index.ts @@ -11,6 +11,7 @@ // See the License for the specific language governing permissions and // limitations under the License. +export * from './annotations'; export * from './builtin-variables'; export * from './datasources'; export * from './plugin-registry'; From 8edfb44ce50ba6df584fbaba3ea8550347fcfffe Mon Sep 17 00:00:00 2001 From: Guillaume LADORME Date: Thu, 16 Apr 2026 11:16:53 +0200 Subject: [PATCH 2/8] wip2 Signed-off-by: Guillaume LADORME --- .../TimeSeriesTooltip/TimeChartTooltip.tsx | 8 +- .../Annotations/AnnotationsEditor.tsx | 5 +- .../Annotations/EditAnnotationsButton.tsx | 2 +- .../AnnotationProvider/AnnotationProvider.tsx | 186 ++++-------------- .../src/context/AnnotationProvider/utils.ts | 56 +++--- dashboards/src/context/useDashboard.tsx | 2 + .../AnnotationEditorForm.tsx | 3 +- plugin-system/src/model/annotations.ts | 2 +- plugin-system/src/runtime/annotations.ts | 108 +--------- 9 files changed, 80 insertions(+), 292 deletions(-) diff --git a/components/src/TimeSeriesTooltip/TimeChartTooltip.tsx b/components/src/TimeSeriesTooltip/TimeChartTooltip.tsx index fffe9f8d..3f775274 100644 --- a/components/src/TimeSeriesTooltip/TimeChartTooltip.tsx +++ b/components/src/TimeSeriesTooltip/TimeChartTooltip.tsx @@ -75,11 +75,7 @@ export const TimeChartTooltip = memo(function TimeChartTooltip({ // if tooltip is attached to a container, set max height to the height of the container so tooltip does not get cut off const maxHeight = containerElement ? containerElement.getBoundingClientRect().height : undefined; - if (!width || !height || !containerElement) { - return null; - } - - transform.current = assembleTransform(mousePos, pinnedPos, height, width, containerElement); + transform.current = assembleTransform(mousePos, pinnedPos, height ?? 0, width ?? 0, containerElement); // Get series nearby the cursor and pass into tooltip content children. const nearbySeries = getNearbySeriesData({ @@ -96,7 +92,7 @@ export const TimeChartTooltip = memo(function TimeChartTooltip({ return null; } - const totalSeries = data.length; // !*.js,!*.d.ts,!*.js.map + const totalSeries = data.length; return ( diff --git a/dashboards/src/components/Annotations/AnnotationsEditor.tsx b/dashboards/src/components/Annotations/AnnotationsEditor.tsx index 4d7d15a4..710eb310 100644 --- a/dashboards/src/components/Annotations/AnnotationsEditor.tsx +++ b/dashboards/src/components/Annotations/AnnotationsEditor.tsx @@ -29,7 +29,8 @@ import { styled, } from '@mui/material'; import AddIcon from 'mdi-material-ui/Plus'; -import { Action, AnnotationDefinition } from '@perses-dev/core'; +import { Action } from '@perses-dev/core'; +import { AnnotationDefinition, Definition, UnknownSpec } from '@perses-dev/spec'; import { useImmer } from 'use-immer'; import PencilIcon from 'mdi-material-ui/Pencil'; import TrashIcon from 'mdi-material-ui/TrashCan'; @@ -101,7 +102,7 @@ export function AnnotationEditor(props: { kind: 'Annotation', spec: { display: { name: 'NewAnnotation' }, - plugin: {}, + plugin: {} as Definition, }, }); }); diff --git a/dashboards/src/components/Annotations/EditAnnotationsButton.tsx b/dashboards/src/components/Annotations/EditAnnotationsButton.tsx index 879f600f..9c685a74 100644 --- a/dashboards/src/components/Annotations/EditAnnotationsButton.tsx +++ b/dashboards/src/components/Annotations/EditAnnotationsButton.tsx @@ -15,7 +15,7 @@ import { ReactElement, useState } from 'react'; import { Button, ButtonProps } from '@mui/material'; import PencilIcon from 'mdi-material-ui/PencilOutline'; import { Drawer, InfoTooltip } from '@perses-dev/components'; -import { AnnotationDefinition } from '@perses-dev/core'; +import { AnnotationDefinition } from '@perses-dev/spec'; import { TOOLTIP_TEXT, editButtonStyle } from '../../constants'; import { useAnnotationDefinitionActions, useAnnotationDefinitions } from '../../context'; import { AnnotationEditor } from './AnnotationsEditor'; diff --git a/dashboards/src/context/AnnotationProvider/AnnotationProvider.tsx b/dashboards/src/context/AnnotationProvider/AnnotationProvider.tsx index 05ccb2c9..53ed6b71 100644 --- a/dashboards/src/context/AnnotationProvider/AnnotationProvider.tsx +++ b/dashboards/src/context/AnnotationProvider/AnnotationProvider.tsx @@ -1,177 +1,65 @@ import { createContext, ReactNode, useContext, useMemo } from 'react'; -import { AnnotationData, AnnotationDefinition } from '@perses-dev/core'; +import { + AnnotationData, + AnnotationDefinition, + VariableDefinition, +} from '@perses-dev/spec'; import { useAnnotations } from '@perses-dev/plugin-system'; - -// type AnnotationDefinitionStore = { -// annotationDefinitions: AnnotationDefinition[]; -// setAnnotationDefinitions: (annotations: AnnotationDefinition[]) => void; -// annotationState: AnnotationStoreStateMap; -// setAnnotationLoading: (name: string, loading: boolean) => void; -// setAnnotationValue: (name: string, loading: boolean) => void; -// }; -// -// const AnnotationDefinitionStoreContext = createContext | undefined>(undefined); - -// interface AnnotationDefinitionStoreArgs { -// initialAnnotations: AnnotationDefinition[]; -// } -// -// function createAnnotationStore({ -// initialAnnotations, -// }: AnnotationDefinitionStoreArgs): StoreApi { -// const store = createStore()( -// devtools( -// immer((set) => ({ -// annotationDefinitions: initialAnnotations, -// annotationState: hydrateAnnotationDefinitionStates(initialAnnotations), -// setAnnotationDefinitions(definitions: AnnotationDefinition[]): void { -// set( -// (state) => { -// state.annotationDefinitions = definitions; -// state.annotationState = hydrateAnnotationDefinitionStates(definitions); -// }, -// false, -// '[Annotations] setAnnotationDefinitions' // Used for action name in Redux devtools -// ); -// }, -// setAnnotationLoading(name: string, loading: boolean): void { -// set( -// (state) => { -// const annoState = state.annotationState.get({ name }); -// if (!annoState) { -// return; -// } -// annoState.loading = loading; -// }, -// false, -// '[Annotations] setAnnotationLoading' -// ); -// }, -// setAnnotationValue: (name: string, value: AnnotationValue): void => -// set( -// (state) => { -// const varState = state.annotationState.get({ name }); -// if (!varState) { -// return; -// } -// -// varState.value = value; -// }, -// false, -// '[Annotations] setAnnotationValue' -// ), -// })) -// ) -// ); -// return store satisfies StoreApi; -// } - -// function getQueryOptions({plugin, defintion, context}: {plugin?: AnnotationPlugin; definition: AnnotationDefinition; context: AnnotationContext}): { -// queryKey: QueryKey; -// queryEnabled: boolean; -// } { -// -// -// -// return { queryKey: [], queryEnabled: false }; -// } -// -// export function useAnnotationState(definitions: AnnotationDefinition[]): AnnotationStateMap { -// const pluginLoaderResponse = usePlugins('Annotation', definitions.map(definition => ({ kind: definition.spec.plugin.kind }))); -// -// return useQueries({ -// queries: definitions.map((definition, idx) => { -// const plugin = pluginLoaderResponse[idx]?.data; -// const { queryEnabled, queryKey } = getQueryOptions({ definition, plugin }); -// const annotationKind = definition?.spec?.plugin?.kind; -// return { -// enabled: queryEnabled, -// queryKey: queryKey, -// refetchOnMount: false, -// refetchOnWindowFocus: false, -// refetchOnReconnect: false, -// staleTime: Infinity, -// queryFn: async ({ signal }: { signal?: AbortSignal }): Promise => { -// const plugin = await getPlugin(ANNOTATION_KEY, annotationKind); -// const data = await plugin.getAnnotationData(definition.spec.plugin.spec, context, signal); -// return data; -// }, -// } -// })}) -// -// const annotationValues = useQueries({ -// queries: definitions.map((definition) => { -// return { -// queryKey: ['annotationData', definition.spec.display.name], // todo: check key -// queryFn: async (): Promise => { -// const resp = await variablePlugin?.getVariableOptions(spec, { datasourceStore, variables, timeRange }, signal); -// if (!resp?.data?.length) { -// return []; -// } -// }, -// }; -// }), -// }); -// -// const state: AnnotationStateMap = useMemo(() => { -// const stateMap: AnnotationStateMap = {}; -// -// for (const annotationQuery of annotationValues) { -// const definition = definitions.find((def => def.spec.display.name === annotationQuery.queryKey[1]); -// if (definition === undefined) { -// continue; -// } -// const name = definition.spec.display.name; -// const annotationState: AnnotationState = { -// value: annotationQuery.data ?? null, -// loading: annotationQuery.isLoading, -// }; -// stateMap[name] = annotationState; -// } -// -// return stateMap; -// }, [definitions, annotationValues]); -// } +import { UseQueryResult } from '@tanstack/react-query'; export type AnnotationState = { definition: AnnotationDefinition; data: AnnotationData | null; isPending: boolean; - error?: Error | null; + error?: unknown | null; }; export type AnnotationStateMap = { [name: string]: AnnotationState; }; -const AnnotationDefinitionContext = createContext(undefined); - -export interface AnnotationProviderProps { - children: ReactNode; - initialAnnotations?: AnnotationDefinition[]; -} - -export function AnnotationProvider({ children, initialAnnotations = [] }: AnnotationProviderProps): ReactNode { - const annotations = useAnnotations(initialAnnotations); +export function useHydrateAnnotationDefinitions(definitions: AnnotationDefinition[]): AnnotationStateMap { + const annotations: Array> = useAnnotations(definitions); - // TODO: check if it is better to create state in provider or in a custom hook? - const state: AnnotationStateMap = useMemo(() => { + return useMemo(() => { const result: AnnotationStateMap = {}; - for (const [index, definition] of initialAnnotations.entries()) { + for (const [index, definition] of definitions.entries()) { const query = annotations[index] ?? null; if (query) { result[definition.spec.display.name] = { definition, data: query.data ?? null, isPending: query.isLoading, - error: query.error ?? null, + error: query?.error ?? null, }; } } return result; - }, [annotations, initialAnnotations]); + }, [annotations, definitions]); +} + +const AnnotationDefinitionContext = createContext(undefined); + +export interface AnnotationProviderProps { + children: ReactNode; + initialAnnotations?: AnnotationDefinition[]; +} + +export function AnnotationRuntimeProvider({ children, initialAnnotations = [] }: AnnotationProviderProps): ReactNode { + // TODO: will be used for hydrating store states + // executing useQuery here on annotations + // infinite refresh loop ? + // + const state: AnnotationStateMap = useHydrateAnnotationDefinitions(initialAnnotations); + + return {children}; +} + +export function AnnotationProvider({ children, initialAnnotations = [] }: AnnotationProviderProps): ReactNode { + // TODO: refactor to use a store + const state: AnnotationStateMap = useHydrateAnnotationDefinitions(initialAnnotations); return {children}; } @@ -183,3 +71,7 @@ export function useAnnotationStateMap(): AnnotationStateMap { } return ctx; } + +export function setAnnotationDefinitions(definitions: VariableDefinition[]): void { + const newAnnotations = useHydrateAnnotationDefinitions(definitions); +} diff --git a/dashboards/src/context/AnnotationProvider/utils.ts b/dashboards/src/context/AnnotationProvider/utils.ts index a0e66eea..abe4e3ca 100644 --- a/dashboards/src/context/AnnotationProvider/utils.ts +++ b/dashboards/src/context/AnnotationProvider/utils.ts @@ -11,31 +11,31 @@ // See the License for the specific language governing permissions and // limitations under the License. -import { AnnotationData, AnnotationDefinition } from '@perses-dev/core'; -import { AnnotationStoreStateMap, AnnotationState } from '@perses-dev/plugin-system'; - -function hydrateAnnotationState(annotation: AnnotationDefinition, value?: AnnotationData): AnnotationState { - const annoState: AnnotationState = { - value: null, - loading: false, - }; - - annoState.value = value ?? null; - - return AnnotationState; -} - -/** - * Build the local annotation states according to the given definitions - * @param definitions local annotation definitions. Dynamic part. - */ -export function hydrateAnnotationDefinitionStates(definitions: AnnotationDefinition[]): AnnotationStoreStateMap { - const state: AnnotationStoreStateMap = new AnnotationStoreStateMap(); - - for (const definition of definitions) { - const name = definition.spec.display.name; - state.set({ name }, hydrateAnnotationState(definition)); - } - - return state; -} +// import { AnnotationData, AnnotationDefinition } from '@perses-dev/spec'; +// import { AnnotationStoreStateMap, AnnotationState } from '@perses-dev/plugin-system'; +// +// function hydrateAnnotationState(annotation: AnnotationDefinition, value?: AnnotationData): AnnotationState { +// const annoState: AnnotationState = { +// value: null, +// loading: false, +// }; +// +// annoState.value = value ?? null; +// +// return AnnotationState; +// } +// +// /** +// * Build the local annotation states according to the given definitions +// * @param definitions local annotation definitions. Dynamic part. +// */ +// export function hydrateAnnotationDefinitionStates(definitions: AnnotationDefinition[]): AnnotationStoreStateMap { +// const state: AnnotationStoreStateMap = new AnnotationStoreStateMap(); +// +// for (const definition of definitions) { +// const name = definition.spec.display.name; +// state.set({ name }, hydrateAnnotationState(definition)); +// } +// +// return state; +// } diff --git a/dashboards/src/context/useDashboard.tsx b/dashboards/src/context/useDashboard.tsx index 3043b468..98a7556c 100644 --- a/dashboards/src/context/useDashboard.tsx +++ b/dashboards/src/context/useDashboard.tsx @@ -65,6 +65,7 @@ export function useDashboard(): { }) ); const { setVariableDefinitions } = useVariableDefinitionActions(); + // TODO: annotations const variables = useVariableDefinitions(); const layouts = convertPanelGroupsToLayouts(panelGroups, panelGroupOrder); @@ -101,6 +102,7 @@ export function useDashboard(): { }; const setDashboard = (dashboardResource: DashboardResource): void => { + // TODO: annotations setVariableDefinitions(dashboardResource.spec.variables); setDashboardResource(dashboardResource); }; diff --git a/plugin-system/src/components/Annotations/AnnotationEditorForm/AnnotationEditorForm.tsx b/plugin-system/src/components/Annotations/AnnotationEditorForm/AnnotationEditorForm.tsx index 2908b11d..3449ed5c 100644 --- a/plugin-system/src/components/Annotations/AnnotationEditorForm/AnnotationEditorForm.tsx +++ b/plugin-system/src/components/Annotations/AnnotationEditorForm/AnnotationEditorForm.tsx @@ -13,7 +13,8 @@ import { DispatchWithoutAction, ReactElement, useState } from 'react'; import { Box, Typography, TextField, Grid, Divider } from '@mui/material'; -import { AnnotationDefinition, Action } from '@perses-dev/core'; +import { Action } from '@perses-dev/core'; +import { AnnotationDefinition } from '@perses-dev/spec'; import { DiscardChangesConfirmationDialog, ErrorAlert, ErrorBoundary, FormActions } from '@perses-dev/components'; import { Control, Controller, FormProvider, SubmitHandler, useForm, useWatch } from 'react-hook-form'; import { zodResolver } from '@hookform/resolvers/zod'; diff --git a/plugin-system/src/model/annotations.ts b/plugin-system/src/model/annotations.ts index 563e1805..335d2435 100644 --- a/plugin-system/src/model/annotations.ts +++ b/plugin-system/src/model/annotations.ts @@ -1,4 +1,4 @@ -import { AbsoluteTimeRange, AnnotationData, UnknownSpec } from '@perses-dev/core'; +import { AbsoluteTimeRange, AnnotationData, UnknownSpec } from '@perses-dev/spec'; import { DatasourceStore, VariableStateMap } from '@perses-dev/plugin-system'; import { Plugin } from './plugin-base'; diff --git a/plugin-system/src/runtime/annotations.ts b/plugin-system/src/runtime/annotations.ts index d4356933..f18aebda 100644 --- a/plugin-system/src/runtime/annotations.ts +++ b/plugin-system/src/runtime/annotations.ts @@ -11,7 +11,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -import { AnnotationData, AnnotationDefinition } from '@perses-dev/core'; +import { AnnotationData, AnnotationDefinition } from '@perses-dev/spec'; import { QueryKey, useQueries, UseQueryResult } from '@tanstack/react-query'; import { AnnotationContext, AnnotationPlugin } from '../model/annotations'; import { usePluginRegistry, usePlugins } from './plugin-registry'; @@ -19,110 +19,6 @@ import { useTimeRange } from './TimeRangeProvider'; import { useAllVariableValues } from './variables'; import { useDatasourceStore } from './datasources'; import { filterVariableStateMap, getVariableValuesKey } from './utils'; -// -// export type AnnotationState = { -// value: AnnotationData | null; -// loading: boolean; -// error?: Error; -// }; -// -// export type AnnotationStateMap = Record; -// -// /** -// * Structure used as key in the {@link AnnotationStoreStateMap}. -// */ -// export type AnnotationStateKey = { -// /** -// * name of the annotation we want to access in the state. -// */ -// name: string; -// }; - -// /** -// * A state map with two entry keys, materialized by {@link AnnotationStateKey} structure. -// */ -// export class AnnotationStoreStateMap { -// /** -// * "Immerable" is mandatory to be able to use this class in an immer context. -// * Ref: https://docs.pmnd.rs/zustand/integrations/immer-middleware#gotchas -// */ -// [immerable] = true; -// -// private readonly _state: Record = {}; -// -// /** -// * Get annotation state by key. -// * @param key -// */ -// get(key: AnnotationStateKey): AnnotationState | undefined { -// return this._state[key.name]; -// } -// -// /** -// * Set annotation state by key. -// * @param key -// * @param value -// */ -// set(key: AnnotationStateKey, value: AnnotationState): AnnotationState | undefined { -// this._state[key.name] = value; -// return value; -// } -// -// /** -// * Check presence of annotation state by key. -// * @param key -// */ -// has(key: AnnotationStateKey): boolean { -// return this._state[key.name] !== undefined; -// } -// -// /** -// * Delete annotation state by key. -// * @param key -// */ -// delete(key: AnnotationStateKey): boolean { -// const result = this.has(key); -// // Delete source state from state if empty -// delete this._state[key.name]; -// -// return result; -// } -// } -// -// export type AnnotationSrv = { -// state: AnnotationStateMap; -// }; -// -// export const AnnotationContext = createContext(undefined); -// -// function useAnnotationContext(): AnnotationSrv { -// const ctx = useContext(AnnotationContext); -// if (ctx === undefined) { -// throw new Error('No AnnotationContext found. Did you forget a Provider?'); -// } -// return ctx; -// } -// -// export function useAnnotationValues(names?: string[]): AnnotationStateMap { -// const { state } = useAnnotationContext(); -// -// const values = useMemo(() => { -// const values: AnnotationStateMap = {}; -// names?.forEach((name) => { -// const s = state[name]; -// if (s) { -// values[name] = s; -// } -// }); -// return values; -// }, [state, names]); -// -// if (names === undefined) { -// return state; -// } -// -// return values; -// } export const ANNOTATION_KEY = 'Annotation'; @@ -171,7 +67,7 @@ function getQueryOptions({ }; } -export function useAnnotations(definitions: AnnotationDefinition[]): Array> { +export function useAnnotations(definitions: AnnotationDefinition[]): Array> { const { getPlugin } = usePluginRegistry(); const context = useAnnotationContext(); From f6e4b4676f96360ce55d4435de95bcc5fc3b1696 Mon Sep 17 00:00:00 2001 From: Guillaume LADORME Date: Thu, 16 Apr 2026 14:45:38 +0200 Subject: [PATCH 3/8] wip3 - compiling Signed-off-by: Guillaume LADORME --- .../Annotations/EditAnnotationsButton.tsx | 4 +- .../AnnotationHydrationWrapper.tsx | 62 +++++++ .../AnnotationProvider/AnnotationProvider.tsx | 175 ++++++++++++------ dashboards/src/context/useDashboard.tsx | 8 + .../src/views/ViewDashboard/ViewDashboard.tsx | 2 +- 5 files changed, 196 insertions(+), 55 deletions(-) create mode 100644 dashboards/src/context/AnnotationProvider/AnnotationHydrationWrapper.tsx diff --git a/dashboards/src/components/Annotations/EditAnnotationsButton.tsx b/dashboards/src/components/Annotations/EditAnnotationsButton.tsx index 9c685a74..f8cffb29 100644 --- a/dashboards/src/components/Annotations/EditAnnotationsButton.tsx +++ b/dashboards/src/components/Annotations/EditAnnotationsButton.tsx @@ -17,7 +17,7 @@ import PencilIcon from 'mdi-material-ui/PencilOutline'; import { Drawer, InfoTooltip } from '@perses-dev/components'; import { AnnotationDefinition } from '@perses-dev/spec'; import { TOOLTIP_TEXT, editButtonStyle } from '../../constants'; -import { useAnnotationDefinitionActions, useAnnotationDefinitions } from '../../context'; +import { useAnnotationActions, useAnnotationDefinitions } from '../../context'; import { AnnotationEditor } from './AnnotationsEditor'; export interface EditAnnotationsButtonProps extends Pick { @@ -45,7 +45,7 @@ export function EditAnnotationsButton({ }: EditAnnotationsButtonProps): ReactElement { const [isAnnotationEditorOpen, setIsAnnotationEditorOpen] = useState(false); const annotationDefinitions: AnnotationDefinition[] = useAnnotationDefinitions(); - const { setAnnotationDefinitions } = useAnnotationDefinitionActions(); + const { setAnnotationDefinitions } = useAnnotationActions(); const openAnnotationEditor = (): void => { setIsAnnotationEditorOpen(true); diff --git a/dashboards/src/context/AnnotationProvider/AnnotationHydrationWrapper.tsx b/dashboards/src/context/AnnotationProvider/AnnotationHydrationWrapper.tsx new file mode 100644 index 00000000..76786636 --- /dev/null +++ b/dashboards/src/context/AnnotationProvider/AnnotationHydrationWrapper.tsx @@ -0,0 +1,62 @@ +import { ReactNode, useMemo } from 'react'; +import { AnnotationData, AnnotationDefinition } from '@perses-dev/spec'; +import { AnnotationStateMap, useAnnotationActions, useAnnotationDefinitions } from '@perses-dev/dashboards'; +import { useAnnotations } from '@perses-dev/plugin-system'; +import { UseQueryResult } from '@tanstack/react-query'; + +export function useHydrateAnnotationDefinitions(definitions: AnnotationDefinition[]): AnnotationStateMap { + const annotations: Array> = useAnnotations(definitions); + + return useMemo(() => { + const result: AnnotationStateMap = {}; + + for (const [index, definition] of definitions.entries()) { + const query = annotations[index] ?? null; + if (query) { + result[definition.spec.display.name] = { + data: query.data ?? null, + isPending: query.isLoading, + error: (query?.error as Error) ?? null, + }; + } + } + + return result; + }, [annotations, definitions]); +} + +interface AnnotationHydrationWrapperProps { + children: ReactNode; +} + +/* + * This component is responsible for hydrating the annotation states in the store. + * It should be used inside the AnnotationProvider, and will fetch the annotations data and update the store accordingly. + * + * Contrary to VariableProvider that is hydrating variable state at input component level, annotations don't have component + * that can trigger the hydration manually, so we need to do it at provider level. + */ +export function AnnotationHydrationWrapper({ children }: AnnotationHydrationWrapperProps): ReactNode { + const annotationDefinitions = useAnnotationDefinitions(); + const { setAnnotationState } = useAnnotationActions(); + const annotations: Array> = useAnnotations(annotationDefinitions); + + useMemo(() => { + const result: AnnotationStateMap = {}; + + for (const [index, definition] of annotationDefinitions.entries()) { + const query = annotations[index] ?? null; + if (query) { + setAnnotationState(definition.spec.display.name, { + data: query.data ?? null, + isPending: query.isLoading, + error: (query?.error as Error) ?? null, + }); + } + } + + return result; + }, [annotationDefinitions, annotations, setAnnotationState]); + + return <>{children}; +} diff --git a/dashboards/src/context/AnnotationProvider/AnnotationProvider.tsx b/dashboards/src/context/AnnotationProvider/AnnotationProvider.tsx index 53ed6b71..099119d8 100644 --- a/dashboards/src/context/AnnotationProvider/AnnotationProvider.tsx +++ b/dashboards/src/context/AnnotationProvider/AnnotationProvider.tsx @@ -1,77 +1,148 @@ -import { createContext, ReactNode, useContext, useMemo } from 'react'; -import { - AnnotationData, - AnnotationDefinition, - VariableDefinition, -} from '@perses-dev/spec'; -import { useAnnotations } from '@perses-dev/plugin-system'; -import { UseQueryResult } from '@tanstack/react-query'; +import { createContext, ReactNode, useContext, useState } from 'react'; +import { AnnotationData, AnnotationDefinition } from '@perses-dev/spec'; +import { createStore, StoreApi, useStore } from 'zustand'; +import { useStoreWithEqualityFn } from 'zustand/traditional'; +import { shallow } from 'zustand/shallow'; +import { devtools } from 'zustand/middleware'; +import { immer } from 'zustand/middleware/immer'; +import { AnnotationHydrationWrapper } from './AnnotationHydrationWrapper'; export type AnnotationState = { - definition: AnnotationDefinition; data: AnnotationData | null; isPending: boolean; - error?: unknown | null; + error?: Error; }; export type AnnotationStateMap = { [name: string]: AnnotationState; }; -export function useHydrateAnnotationDefinitions(definitions: AnnotationDefinition[]): AnnotationStateMap { - const annotations: Array> = useAnnotations(definitions); - - return useMemo(() => { - const result: AnnotationStateMap = {}; - - for (const [index, definition] of definitions.entries()) { - const query = annotations[index] ?? null; - if (query) { - result[definition.spec.display.name] = { - definition, - data: query.data ?? null, - isPending: query.isLoading, - error: query?.error ?? null, - }; - } - } +type AnnotationStoreState = { + annotationDefinitions: AnnotationDefinition[]; + annotationState: AnnotationStateMap; +}; + +type AnnotationStoreActions = { + setAnnotationDefinitions: (definitions: AnnotationDefinition[]) => void; + setAnnotationState: (name: string, state: AnnotationState) => void; +}; - return result; - }, [annotations, definitions]); +type AnnotationStore = AnnotationStoreState & AnnotationStoreActions; + +const AnnotationStoreContext = createContext | undefined>(undefined); + +export function useAnnotationStoreCtx(): StoreApi { + const context = useContext(AnnotationStoreContext); + if (!context) { + throw new Error('AnnotationStoreContext not initialized'); + } + return context; } -const AnnotationDefinitionContext = createContext(undefined); +export function useAnnotationDefinitions(): AnnotationDefinition[] { + const store = useAnnotationStoreCtx(); + return useStore(store, (s) => s.annotationDefinitions); +} -export interface AnnotationProviderProps { - children: ReactNode; - initialAnnotations?: AnnotationDefinition[]; +export function useAnnotationStates(annotationNames?: string[]): AnnotationStateMap { + const store = useAnnotationStoreCtx(); + return useStoreWithEqualityFn( + store, + (s) => { + if (annotationNames) { + const result: AnnotationStateMap = {}; + annotationNames.forEach((name) => { + const s = store.getState().annotationState[name]; + if (s) { + result[name] = s; + } + }); + return result; + } + + return s.annotationState; + }, + (left, right) => { + return JSON.stringify(left) === JSON.stringify(right); + } + ); } -export function AnnotationRuntimeProvider({ children, initialAnnotations = [] }: AnnotationProviderProps): ReactNode { - // TODO: will be used for hydrating store states - // executing useQuery here on annotations - // infinite refresh loop ? - // - const state: AnnotationStateMap = useHydrateAnnotationDefinitions(initialAnnotations); +export function useAnnotationActions(): AnnotationStoreActions { + const store = useAnnotationStoreCtx(); + return useStoreWithEqualityFn( + store, + (s) => { + return { + setAnnotationState: s.setAnnotationState, + setAnnotationDefinitions: s.setAnnotationDefinitions, + }; + }, + shallow + ); +} - return {children}; +export function useAnnotationDefinitionAndState(name: string): { + definition: AnnotationDefinition | undefined; + state: AnnotationState | undefined; +} { + const store = useAnnotationStoreCtx(); + return useStore(store, (s) => { + return { + definition: s.annotationDefinitions.find((d) => d.spec.display.name === name), + state: s.annotationState[name], + }; + }); } -export function AnnotationProvider({ children, initialAnnotations = [] }: AnnotationProviderProps): ReactNode { - // TODO: refactor to use a store - const state: AnnotationStateMap = useHydrateAnnotationDefinitions(initialAnnotations); +interface AnnotationStoreArgs { + initialAnnotationDefinitions?: AnnotationDefinition[]; +} - return {children}; +function createAnnotationStore({ initialAnnotationDefinitions = [] }: AnnotationStoreArgs): StoreApi { + const store = createStore()( + devtools( + immer((set, _get) => ({ + annotationDefinitions: initialAnnotationDefinitions, + annotationState: {} as Record, + setAnnotationDefinitions(definitions: AnnotationDefinition[]): void { + set( + (s) => { + s.annotationDefinitions = definitions; + }, + false, + '[Annotations] setAnnotationDefinitions' // Used for action name in Redux devtools + ); + }, + setAnnotationState: (name: string, state: AnnotationState): void => { + set( + (s) => { + s.annotationState[name] = state; + }, + false, + '[Annotations] setAnnotationState' // Used for action name in Redux devtools + ); + }, + })) + ) + ); + return store; } -export function useAnnotationStateMap(): AnnotationStateMap { - const ctx = useContext(AnnotationDefinitionContext); - if (ctx === undefined) { - throw new Error('No AnnotationDefinitionContext found. Did you forget a provider?'); - } - return ctx; +export interface AnnotationProviderProps { + children: ReactNode; + initialAnnotationDefinitions?: AnnotationDefinition[]; } -export function setAnnotationDefinitions(definitions: VariableDefinition[]): void { - const newAnnotations = useHydrateAnnotationDefinitions(definitions); +export function AnnotationProvider({ + children, + initialAnnotationDefinitions = [], +}: AnnotationProviderProps): ReactNode { + const [store] = useState(() => createAnnotationStore({ initialAnnotationDefinitions })); + + return ( + + {children} + + ); } diff --git a/dashboards/src/context/useDashboard.tsx b/dashboards/src/context/useDashboard.tsx index 98a7556c..906c1bea 100644 --- a/dashboards/src/context/useDashboard.tsx +++ b/dashboards/src/context/useDashboard.tsx @@ -16,6 +16,7 @@ import { createPanelRef, DashboardSpec, GridDefinition, PanelGroupId } from '@pe import { DashboardResource } from '../model'; import { useDashboardStore } from './DashboardProvider'; import { useVariableDefinitionActions, useVariableDefinitions } from './VariableProvider'; +import { useAnnotationActions, useAnnotationDefinitions } from './AnnotationProvider'; type DashboardType = Omit & { spec: DashboardSpec & { ttl?: DurationString } }; export function useDashboard(): { @@ -65,8 +66,10 @@ export function useDashboard(): { }) ); const { setVariableDefinitions } = useVariableDefinitionActions(); + const { setAnnotationDefinitions } = useAnnotationActions(); // TODO: annotations const variables = useVariableDefinitions(); + const annotations = useAnnotationDefinitions(); const layouts = convertPanelGroupsToLayouts(panelGroups, panelGroupOrder); const dashboard: DashboardType = @@ -79,6 +82,7 @@ export function useDashboard(): { panels, layouts, variables, + annotations, duration, refreshInterval, datasources, @@ -93,6 +97,7 @@ export function useDashboard(): { panels, layouts, variables, + annotations, duration, refreshInterval, datasources, @@ -104,6 +109,9 @@ export function useDashboard(): { const setDashboard = (dashboardResource: DashboardResource): void => { // TODO: annotations setVariableDefinitions(dashboardResource.spec.variables); + if (dashboardResource.spec.annotations) { + setAnnotationDefinitions(dashboardResource.spec.annotations); + } setDashboardResource(dashboardResource); }; diff --git a/dashboards/src/views/ViewDashboard/ViewDashboard.tsx b/dashboards/src/views/ViewDashboard/ViewDashboard.tsx index 134cbd4b..94eae8ab 100644 --- a/dashboards/src/views/ViewDashboard/ViewDashboard.tsx +++ b/dashboards/src/views/ViewDashboard/ViewDashboard.tsx @@ -121,7 +121,7 @@ export function ViewDashboard(props: ViewDashboardProps): ReactElement { externalVariableDefinitions={externalVariableDefinitions} builtinVariableDefinitions={builtinVariables} > - + Date: Wed, 22 Apr 2026 09:34:17 +0200 Subject: [PATCH 4/8] WIP annotations Signed-off-by: Guillaume LADORME --- .../Annotations/AnnotationsEditor.tsx | 7 ++- .../components/Variables/VariableEditor.tsx | 7 ++- .../AnnotationHydrationWrapper.tsx | 44 +++++++++---------- .../AnnotationProvider/AnnotationProvider.tsx | 25 ++++++++++- plugin-system/src/model/annotations.ts | 6 +-- plugin-system/src/model/index.ts | 1 + plugin-system/src/runtime/annotations.ts | 4 +- 7 files changed, 57 insertions(+), 37 deletions(-) diff --git a/dashboards/src/components/Annotations/AnnotationsEditor.tsx b/dashboards/src/components/Annotations/AnnotationsEditor.tsx index 710eb310..6980091e 100644 --- a/dashboards/src/components/Annotations/AnnotationsEditor.tsx +++ b/dashboards/src/components/Annotations/AnnotationsEditor.tsx @@ -65,9 +65,8 @@ export function AnnotationEditor(props: { const [annotationFormAction, setAnnotationFormAction] = useState('update'); const validation = useMemo(() => getValidation(annotationDefinitions), [annotationDefinitions]); - const currentEditingAnnotationDefinition: AnnotationDefinition | undefined = annotationEditIdx - ? annotationDefinitions[annotationEditIdx] - : undefined; + const currentEditingAnnotationDefinition: AnnotationDefinition | undefined = + annotationEditIdx !== null ? annotationDefinitions[annotationEditIdx] : undefined; const { openDiscardChangesConfirmationDialog, closeDiscardChangesConfirmationDialog } = useDiscardChangesConfirmationDialog(); @@ -148,7 +147,7 @@ export function AnnotationEditor(props: { return ( <> - {annotationEditIdx && currentEditingAnnotationDefinition ? ( + {annotationEditIdx !== null && currentEditingAnnotationDefinition ? ( { return [hydrateVariableDefinitionStates(variableDefinitions, {}, externalVariableDefinitions)]; }, [externalVariableDefinitions, variableDefinitions]); - const currentEditingVariableDefinition: VariableDefinition | undefined = variableEditIdx - ? variableDefinitions[variableEditIdx] - : undefined; + const currentEditingVariableDefinition: VariableDefinition | undefined = + variableEditIdx !== null ? variableDefinitions[variableEditIdx] : undefined; const { openDiscardChangesConfirmationDialog, closeDiscardChangesConfirmationDialog } = useDiscardChangesConfirmationDialog(); @@ -183,7 +182,7 @@ export function VariableEditor(props: { return ( <> - {variableEditIdx && currentEditingVariableDefinition && ( + {variableEditIdx !== null && currentEditingVariableDefinition && ( > = useAnnotations(definitions); - - return useMemo(() => { - const result: AnnotationStateMap = {}; - - for (const [index, definition] of definitions.entries()) { - const query = annotations[index] ?? null; - if (query) { - result[definition.spec.display.name] = { - data: query.data ?? null, - isPending: query.isLoading, - error: (query?.error as Error) ?? null, - }; - } - } - - return result; - }, [annotations, definitions]); -} +// export function useHydrateAnnotationDefinitions(definitions: AnnotationDefinition[]): AnnotationStateMap { +// const annotations: Array> = useAnnotations(definitions); +// +// return useMemo(() => { +// const result: AnnotationStateMap = {}; +// +// for (const [index, definition] of definitions.entries()) { +// const query = annotations[index] ?? null; +// if (query) { +// result[definition.spec.display.name] = { +// data: query.data ?? null, +// isPending: query.isLoading, +// error: (query?.error as Error) ?? null, +// }; +// } +// } +// +// return result; +// }, [annotations, definitions]); +// } interface AnnotationHydrationWrapperProps { children: ReactNode; @@ -39,7 +39,7 @@ interface AnnotationHydrationWrapperProps { export function AnnotationHydrationWrapper({ children }: AnnotationHydrationWrapperProps): ReactNode { const annotationDefinitions = useAnnotationDefinitions(); const { setAnnotationState } = useAnnotationActions(); - const annotations: Array> = useAnnotations(annotationDefinitions); + const annotations: Array> = useAnnotations(annotationDefinitions); useMemo(() => { const result: AnnotationStateMap = {}; diff --git a/dashboards/src/context/AnnotationProvider/AnnotationProvider.tsx b/dashboards/src/context/AnnotationProvider/AnnotationProvider.tsx index 099119d8..0f02ae40 100644 --- a/dashboards/src/context/AnnotationProvider/AnnotationProvider.tsx +++ b/dashboards/src/context/AnnotationProvider/AnnotationProvider.tsx @@ -8,7 +8,7 @@ import { immer } from 'zustand/middleware/immer'; import { AnnotationHydrationWrapper } from './AnnotationHydrationWrapper'; export type AnnotationState = { - data: AnnotationData | null; + data: AnnotationData[] | null; isPending: boolean; error?: Error; }; @@ -34,7 +34,7 @@ const AnnotationStoreContext = createContext | undefin export function useAnnotationStoreCtx(): StoreApi { const context = useContext(AnnotationStoreContext); if (!context) { - throw new Error('AnnotationStoreContext not initialized'); + return createAnnotationStore({ initialAnnotationDefinitions: [] }); } return context; } @@ -95,6 +95,27 @@ export function useAnnotationDefinitionAndState(name: string): { }); } +export type AnnotationDefinitionWithData = { + definition: AnnotationDefinition; + data: AnnotationData[]; +}; + +export function useAnnotationsWithData(): AnnotationDefinitionWithData[] { + const store = useAnnotationStoreCtx(); + + return useStore(store, (s) => { + return s.annotationDefinitions + .map((definition) => { + const state = s.annotationState[definition.spec.display.name]; + return { + definition, + data: state?.data, + }; + }) + .filter((annotation) => !!annotation.data) as AnnotationDefinitionWithData[]; + }); +} + interface AnnotationStoreArgs { initialAnnotationDefinitions?: AnnotationDefinition[]; } diff --git a/plugin-system/src/model/annotations.ts b/plugin-system/src/model/annotations.ts index 335d2435..7b094322 100644 --- a/plugin-system/src/model/annotations.ts +++ b/plugin-system/src/model/annotations.ts @@ -5,7 +5,7 @@ import { Plugin } from './plugin-base'; /** * An object containing all the dependencies of a AnnotationQuery. */ -type AnnotationQueryQueryPluginDependencies = { +export type AnnotationQueryQueryPluginDependencies = { /** * Returns a list of variables name this annotation query depends on. */ @@ -16,7 +16,7 @@ type AnnotationQueryQueryPluginDependencies = { * A plugin for running annotation queries. */ export interface AnnotationPlugin extends Plugin { - getAnnotationData: (spec: Spec, ctx: AnnotationContext, abortSignal?: AbortSignal) => Promise; + getAnnotationData: (spec: Spec, ctx: AnnotationContext, abortSignal?: AbortSignal) => Promise; dependsOn?: (spec: Spec, ctx: AnnotationContext) => AnnotationQueryQueryPluginDependencies; } @@ -25,6 +25,6 @@ export interface AnnotationPlugin extends Plugin { */ export interface AnnotationContext { datasourceStore: DatasourceStore; - absoluteTimeRange?: AbsoluteTimeRange; + absoluteTimeRange: AbsoluteTimeRange; variableState: VariableStateMap; } diff --git a/plugin-system/src/model/index.ts b/plugin-system/src/model/index.ts index 350c4190..37cf536d 100644 --- a/plugin-system/src/model/index.ts +++ b/plugin-system/src/model/index.ts @@ -11,6 +11,7 @@ // See the License for the specific language governing permissions and // limitations under the License. +export * from './annotations'; export * from './datasource'; export * from './legend'; export * from './panels'; diff --git a/plugin-system/src/runtime/annotations.ts b/plugin-system/src/runtime/annotations.ts index f18aebda..6b33c078 100644 --- a/plugin-system/src/runtime/annotations.ts +++ b/plugin-system/src/runtime/annotations.ts @@ -67,7 +67,7 @@ function getQueryOptions({ }; } -export function useAnnotations(definitions: AnnotationDefinition[]): Array> { +export function useAnnotations(definitions: AnnotationDefinition[]): Array> { const { getPlugin } = usePluginRegistry(); const context = useAnnotationContext(); @@ -89,7 +89,7 @@ export function useAnnotations(definitions: AnnotationDefinition[]): Array => { + queryFn: async ({ signal }: { signal?: AbortSignal }): Promise => { const plugin = await getPlugin(ANNOTATION_KEY, annotationKind); const data = await plugin.getAnnotationData(definition.spec.plugin.spec, context, signal); return data; From 4b63715b081a7210dec10c14d03a28a541188522 Mon Sep 17 00:00:00 2001 From: Guillaume LADORME Date: Thu, 23 Apr 2026 12:53:01 +0200 Subject: [PATCH 5/8] Move from annotation def to spec Signed-off-by: Guillaume LADORME --- components/package.json | 2 +- dashboards/package.json | 2 +- .../Annotations/AnnotationsEditor.tsx | 65 +++++++++---------- .../Annotations/EditAnnotationsButton.tsx | 14 ++-- .../AnnotationHydrationWrapper.tsx | 33 ++-------- .../AnnotationProvider/AnnotationProvider.tsx | 53 +++++++-------- .../src/context/AnnotationProvider/utils.ts | 6 +- dashboards/src/context/useDashboard.tsx | 8 +-- .../src/views/ViewDashboard/ViewDashboard.tsx | 2 +- package-lock.json | 12 ++-- plugin-system/package.json | 2 +- .../AnnotationEditorForm.tsx | 26 ++++---- .../src/context/ValidationProvider.tsx | 13 ++-- plugin-system/src/runtime/annotations.ts | 16 ++--- 14 files changed, 113 insertions(+), 141 deletions(-) diff --git a/components/package.json b/components/package.json index 5524dbfa..d0ef9153 100644 --- a/components/package.json +++ b/components/package.json @@ -35,7 +35,7 @@ "@fontsource/inter": "^5.0.0", "@mui/x-date-pickers": "^7.23.1", "@perses-dev/core": "0.53.0", - "@perses-dev/spec": "0.2.0-beta.0", + "@perses-dev/spec": "0.2.0-beta.1", "@tanstack/react-table": "^8.20.5", "@uiw/react-codemirror": "^4.19.1", "date-fns": "^4.1.0", diff --git a/dashboards/package.json b/dashboards/package.json index 89a91e0f..1e45dcb0 100644 --- a/dashboards/package.json +++ b/dashboards/package.json @@ -32,7 +32,7 @@ "@perses-dev/components": "0.53.1", "@perses-dev/core": "0.53.0", "@perses-dev/plugin-system": "0.53.1", - "@perses-dev/spec": "0.2.0-beta.0", + "@perses-dev/spec": "0.2.0-beta.1", "@types/react-grid-layout": "^1.3.2", "date-fns": "^4.1.0", "immer": "^10.1.1", diff --git a/dashboards/src/components/Annotations/AnnotationsEditor.tsx b/dashboards/src/components/Annotations/AnnotationsEditor.tsx index 6980091e..902c4747 100644 --- a/dashboards/src/components/Annotations/AnnotationsEditor.tsx +++ b/dashboards/src/components/Annotations/AnnotationsEditor.tsx @@ -30,7 +30,7 @@ import { } from '@mui/material'; import AddIcon from 'mdi-material-ui/Plus'; import { Action } from '@perses-dev/core'; -import { AnnotationDefinition, Definition, UnknownSpec } from '@perses-dev/spec'; +import { AnnotationSpec, Definition, UnknownSpec } from '@perses-dev/spec'; import { useImmer } from 'use-immer'; import PencilIcon from 'mdi-material-ui/Pencil'; import TrashIcon from 'mdi-material-ui/TrashCan'; @@ -40,11 +40,11 @@ import ArrowDown from 'mdi-material-ui/ArrowDown'; import { ValidationProvider, AnnotationEditorForm } from '@perses-dev/plugin-system'; import { useDiscardChangesConfirmationDialog } from '../../context'; -function getValidation(annotationDefinitions: AnnotationDefinition[]): { isValid: boolean; errors: string[] } { +function getValidation(annotationSpecs: AnnotationSpec[]): { isValid: boolean; errors: string[] } { const errors: string[] = []; /** Annotation names must be unique */ - const annotationNames = annotationDefinitions.map((annotationDefinition) => annotationDefinition.spec.display.name); + const annotationNames = annotationSpecs.map((annotationSpec) => annotationSpec.display.name); const uniqueAnnotationNames = new Set(annotationNames); if (annotationNames.length !== uniqueAnnotationNames.size) { errors.push('Annotation names must be unique'); @@ -56,22 +56,22 @@ function getValidation(annotationDefinitions: AnnotationDefinition[]): { isValid } export function AnnotationEditor(props: { - annotationDefinitions: AnnotationDefinition[]; - onChange: (annotationDefinitions: AnnotationDefinition[]) => void; + annotationSpecs: AnnotationSpec[]; + onChange: (annotationSpecs: AnnotationSpec[]) => void; onCancel: () => void; }): ReactElement { - const [annotationDefinitions, setAnnotationDefinitions] = useImmer(props.annotationDefinitions); + const [annotationSpecs, setAnnotationSpecs] = useImmer(props.annotationSpecs); const [annotationEditIdx, setAnnotationEditIdx] = useState(null); const [annotationFormAction, setAnnotationFormAction] = useState('update'); - const validation = useMemo(() => getValidation(annotationDefinitions), [annotationDefinitions]); - const currentEditingAnnotationDefinition: AnnotationDefinition | undefined = - annotationEditIdx !== null ? annotationDefinitions[annotationEditIdx] : undefined; + const validation = useMemo(() => getValidation(annotationSpecs), [annotationSpecs]); + const currentEditingAnnotationSpec: AnnotationSpec | undefined = + annotationEditIdx !== null ? annotationSpecs[annotationEditIdx] : undefined; const { openDiscardChangesConfirmationDialog, closeDiscardChangesConfirmationDialog } = useDiscardChangesConfirmationDialog(); const handleCancel = (): void => { - if (JSON.stringify(props.annotationDefinitions) !== JSON.stringify(annotationDefinitions)) { + if (JSON.stringify(props.annotationSpecs) !== JSON.stringify(annotationSpecs)) { openDiscardChangesConfirmationDialog({ onDiscardChanges: () => { closeDiscardChangesConfirmationDialog(); @@ -89,23 +89,20 @@ export function AnnotationEditor(props: { }; const removeAnnotation = (index: number): void => { - setAnnotationDefinitions((draft) => { + setAnnotationSpecs((draft) => { draft.splice(index, 1); }); }; const addAnnotation = (): void => { setAnnotationFormAction('create'); - setAnnotationDefinitions((draft) => { + setAnnotationSpecs((draft) => { draft.push({ - kind: 'Annotation', - spec: { - display: { name: 'NewAnnotation' }, - plugin: {} as Definition, - }, + display: { name: 'NewAnnotation' }, + plugin: {} as Definition, }); }); - setAnnotationEditIdx(annotationDefinitions.length); + setAnnotationEditIdx(annotationSpecs.length); }; const editAnnotation = (index: number): void => { @@ -114,17 +111,17 @@ export function AnnotationEditor(props: { }; const toggleAnnotationVisibility = (index: number, visible: boolean): void => { - setAnnotationDefinitions((draft) => { + setAnnotationSpecs((draft) => { const v = draft[index]; if (!v) { return; } - v.spec.display.hidden = !visible; + v.display.hidden = !visible; }); }; const changeAnnotationOrder = (index: number, direction: 'up' | 'down'): void => { - setAnnotationDefinitions((draft) => { + setAnnotationSpecs((draft) => { if (direction === 'up') { const prevElement = draft[index - 1]; const currentElement = draft[index]; @@ -147,15 +144,15 @@ export function AnnotationEditor(props: { return ( <> - {annotationEditIdx !== null && currentEditingAnnotationDefinition ? ( + {annotationEditIdx !== null && currentEditingAnnotationSpec ? ( { - setAnnotationDefinitions((draft) => { + onSave={(definition: AnnotationSpec) => { + setAnnotationSpecs((draft) => { draft[annotationEditIdx] = definition; setAnnotationEditIdx(null); }); @@ -181,10 +178,10 @@ export function AnnotationEditor(props: { Edit Dashboard Annotations