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/components/src/ColorPicker/OptionsColorPicker.tsx b/components/src/ColorPicker/OptionsColorPicker.tsx index c7f1099f..2bacf0d1 100644 --- a/components/src/ColorPicker/OptionsColorPicker.tsx +++ b/components/src/ColorPicker/OptionsColorPicker.tsx @@ -20,11 +20,18 @@ import { ColorPicker } from './ColorPicker'; export interface OptionsColorPickerProps { label: string; color: string; + size?: 'small' | 'medium' | 'large'; onColorChange: (color: string) => void; onClear?: () => void; } -export function OptionsColorPicker({ label, color, onColorChange, onClear }: OptionsColorPickerProps): ReactElement { +export function OptionsColorPicker({ + label, + color, + size = 'small', + onColorChange, + onClear, +}: OptionsColorPickerProps): ReactElement { const [anchorEl, setAnchorEl] = useState(null); const isOpen = Boolean(anchorEl); @@ -43,7 +50,7 @@ export function OptionsColorPicker({ label, color, onColorChange, onClear }: Opt return ( <> annotationSpec.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: { + annotationSpecs: AnnotationSpec[]; + onChange: (annotationSpecs: AnnotationSpec[]) => void; + onCancel: () => void; +}): ReactElement { + const [annotationSpecs, setAnnotationSpecs] = useImmer(props.annotationSpecs); + const [annotationEditIdx, setAnnotationEditIdx] = useState(null); + const [annotationFormAction, setAnnotationFormAction] = useState('update'); + + 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.annotationSpecs) !== JSON.stringify(annotationSpecs)) { + 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 => { + setAnnotationSpecs((draft) => { + draft.splice(index, 1); + }); + }; + + const addAnnotation = (): void => { + setAnnotationFormAction('create'); + setAnnotationSpecs((draft) => { + draft.push({ + display: { name: 'NewAnnotation' }, + plugin: {} as Definition, + }); + }); + setAnnotationEditIdx(annotationSpecs.length); + }; + + const editAnnotation = (index: number): void => { + setAnnotationFormAction('update'); + setAnnotationEditIdx(index); + }; + + const toggleAnnotationVisibility = (index: number, visible: boolean): void => { + setAnnotationSpecs((draft) => { + const v = draft[index]; + if (!v) { + return; + } + v.display.hidden = !visible; + }); + }; + + const changeAnnotationOrder = (index: number, direction: 'up' | 'down'): void => { + setAnnotationSpecs((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 !== null && currentEditingAnnotationSpec ? ( + + { + setAnnotationSpecs((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 + + + + {annotationSpecs.map((v, idx) => ( + + + { + toggleAnnotationVisibility(idx, e.target.checked); + }} + /> + + + {v.display.name} + + {v.plugin.kind} + {v.display?.description ?? ''} + + changeAnnotationOrder(idx, 'up')} disabled={idx === 0}> + + + changeAnnotationOrder(idx, 'down')} + disabled={idx === annotationSpecs.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..dfcfb3e7 --- /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 { AnnotationSpec } from '@perses-dev/spec'; +import { TOOLTIP_TEXT, editButtonStyle } from '../../constants'; +import { useAnnotationActions, useAnnotationSpecs } 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 annotationSpecs: AnnotationSpec[] = useAnnotationSpecs(); + const { setAnnotationSpecs } = useAnnotationActions(); + + const openAnnotationEditor = (): void => { + setIsAnnotationEditorOpen(true); + }; + + const closeAnnotationEditor = (): void => { + setIsAnnotationEditorOpen(false); + }; + + return ( + <> + + + + + { + setAnnotationSpecs(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..813503f4 100644 --- a/dashboards/src/components/Variables/VariableEditor.tsx +++ b/dashboards/src/components/Variables/VariableEditor.tsx @@ -88,7 +88,8 @@ 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 !== null ? variableDefinitions[variableEditIdx] : undefined; const { openDiscardChangesConfirmationDialog, closeDiscardChangesConfirmationDialog } = useDiscardChangesConfirmationDialog(); @@ -181,7 +182,7 @@ export function VariableEditor(props: { return ( <> - {currentEditingVariableDefinition && ( + {variableEditIdx !== null && currentEditingVariableDefinition && ( > = useAnnotations(annotationSpecs); + + useEffect(() => { + for (const [index, definition] of annotationSpecs.entries()) { + const query = annotations[index] ?? null; + if (query) { + setAnnotationState(definition.display.name, { + data: query.data ?? null, + isPending: query.isLoading, + error: (query?.error as Error) ?? null, + }); + } + } + }, [annotationSpecs, annotations, setAnnotationState]); + + return <>{children}; +} diff --git a/dashboards/src/context/AnnotationProvider/AnnotationProvider.tsx b/dashboards/src/context/AnnotationProvider/AnnotationProvider.tsx new file mode 100644 index 00000000..bdb8e8ad --- /dev/null +++ b/dashboards/src/context/AnnotationProvider/AnnotationProvider.tsx @@ -0,0 +1,166 @@ +import { createContext, ReactNode, useContext, useState } from 'react'; +import { AnnotationData, AnnotationSpec } 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 = { + data: AnnotationData[] | null; + isPending: boolean; + error?: Error; +}; + +export type AnnotationStateMap = { + [name: string]: AnnotationState; +}; + +type AnnotationStoreState = { + annotationSpecs: AnnotationSpec[]; + annotationState: AnnotationStateMap; +}; + +type AnnotationStoreActions = { + setAnnotationSpecs: (definitions: AnnotationSpec[]) => void; + setAnnotationState: (name: string, state: AnnotationState) => void; +}; + +type AnnotationStore = AnnotationStoreState & AnnotationStoreActions; + +const AnnotationStoreContext = createContext | undefined>(undefined); + +export function useAnnotationStoreCtx(): StoreApi { + const context = useContext(AnnotationStoreContext); + if (!context) { + return createAnnotationStore({ initialAnnotationSpecs: [] }); + } + return context; +} + +export function useAnnotationSpecs(): AnnotationSpec[] { + const store = useAnnotationStoreCtx(); + return useStore(store, (s) => s.annotationSpecs); +} + +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 useAnnotationActions(): AnnotationStoreActions { + const store = useAnnotationStoreCtx(); + return useStoreWithEqualityFn( + store, + (s) => { + return { + setAnnotationState: s.setAnnotationState, + setAnnotationSpecs: s.setAnnotationSpecs, + }; + }, + shallow + ); +} + +export function useAnnotationSpecAndState(name: string): { + definition: AnnotationSpec | undefined; + state: AnnotationState | undefined; +} { + const store = useAnnotationStoreCtx(); + return useStore(store, (s) => { + return { + definition: s.annotationSpecs.find((d) => d.display.name === name), + state: s.annotationState[name], + }; + }); +} + +export type AnnotationSpecWithData = { + definition: AnnotationSpec; + data: AnnotationData[]; +}; + +export function useAnnotationsWithData(): AnnotationSpecWithData[] { + const store = useAnnotationStoreCtx(); + + return useStore(store, (s) => { + return s.annotationSpecs + .map((definition) => { + const state = s.annotationState[definition.display.name]; + return { + definition, + data: state?.data, + }; + }) + .filter((annotation) => !!annotation.data) as AnnotationSpecWithData[]; + }); +} + +interface AnnotationStoreArgs { + initialAnnotationSpecs?: AnnotationSpec[]; +} + +function createAnnotationStore({ initialAnnotationSpecs = [] }: AnnotationStoreArgs): StoreApi { + const store = createStore()( + devtools( + immer((set, _get) => ({ + annotationSpecs: initialAnnotationSpecs, + annotationState: {} as Record, + setAnnotationSpecs(definitions: AnnotationSpec[]): void { + set( + (s) => { + s.annotationSpecs = definitions; + }, + false, + '[Annotations] setAnnotationSpecs' // 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 interface AnnotationProviderProps { + children: ReactNode; + initialAnnotationSpecs?: AnnotationSpec[]; +} + +export function AnnotationProvider({ children, initialAnnotationSpecs = [] }: AnnotationProviderProps): ReactNode { + const [store] = useState(() => createAnnotationStore({ initialAnnotationSpecs })); + + return ( + + {children} + + ); +} 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..4d8ef80e --- /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, AnnotationSpec } from '@perses-dev/spec'; +// import { AnnotationStoreStateMap, AnnotationState } from '@perses-dev/plugin-system'; +// +// function hydrateAnnotationState(annotation: AnnotationSpec, 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 hydrateAnnotationSpecStates(definitions: AnnotationSpec[]): 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/context/useDashboard.tsx b/dashboards/src/context/useDashboard.tsx index 3043b468..0b3e7f59 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, useAnnotationSpecs } from './AnnotationProvider'; type DashboardType = Omit & { spec: DashboardSpec & { ttl?: DurationString } }; export function useDashboard(): { @@ -65,7 +66,10 @@ export function useDashboard(): { }) ); const { setVariableDefinitions } = useVariableDefinitionActions(); + const { setAnnotationSpecs } = useAnnotationActions(); + // TODO: annotations const variables = useVariableDefinitions(); + const annotations = useAnnotationSpecs(); const layouts = convertPanelGroupsToLayouts(panelGroups, panelGroupOrder); const dashboard: DashboardType = @@ -78,6 +82,7 @@ export function useDashboard(): { panels, layouts, variables, + annotations, duration, refreshInterval, datasources, @@ -92,6 +97,7 @@ export function useDashboard(): { panels, layouts, variables, + annotations, duration, refreshInterval, datasources, @@ -101,7 +107,11 @@ export function useDashboard(): { }; const setDashboard = (dashboardResource: DashboardResource): void => { + // TODO: annotations setVariableDefinitions(dashboardResource.spec.variables); + if (dashboardResource.spec.annotations) { + setAnnotationSpecs(dashboardResource.spec.annotations); + } setDashboardResource(dashboardResource); }; 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..026c3715 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/package-lock.json b/package-lock.json index 3a644829..10454a29 100644 --- a/package-lock.json +++ b/package-lock.json @@ -69,7 +69,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", @@ -104,7 +104,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", @@ -3969,9 +3969,9 @@ "link": true }, "node_modules/@perses-dev/spec": { - "version": "0.2.0-beta.0", - "resolved": "https://registry.npmjs.org/@perses-dev/spec/-/spec-0.2.0-beta.0.tgz", - "integrity": "sha512-9qT3ofOjBcO7okudC9Rz8t8ugNcTscYincvvmyFtZ/9oHrkDTiJPcRHLZYoCPE6r70OEj27JpvCqWCCil5yA1Q==", + "version": "0.2.0-beta.1", + "resolved": "https://registry.npmjs.org/@perses-dev/spec/-/spec-0.2.0-beta.1.tgz", + "integrity": "sha512-MBdGQUWCMPZd+u9YgPfwO/lnXW72vrqvvCIgIeiuUobYi2DS6IHwavLPi8eTA0OcUGa1tJuTEnd660McghmW3w==", "license": "Apache-2.0", "dependencies": { "date-fns": "^4.1.0", @@ -16903,7 +16903,7 @@ "@module-federation/enhanced": "^0.21.4", "@perses-dev/components": "0.53.1", "@perses-dev/core": "0.53.0", - "@perses-dev/spec": "0.2.0-beta.0", + "@perses-dev/spec": "0.2.0-beta.1", "date-fns": "^4.1.0", "date-fns-tz": "^3.2.0", "immer": "^10.1.1", diff --git a/plugin-system/package.json b/plugin-system/package.json index efcdc6e0..3d349994 100644 --- a/plugin-system/package.json +++ b/plugin-system/package.json @@ -31,7 +31,7 @@ "@module-federation/enhanced": "^0.21.4", "@perses-dev/components": "0.53.1", "@perses-dev/core": "0.53.0", - "@perses-dev/spec": "0.2.0-beta.0", + "@perses-dev/spec": "0.2.0-beta.1", "date-fns": "^4.1.0", "date-fns-tz": "^3.2.0", "immer": "^10.1.1", 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..79a92cc1 --- /dev/null +++ b/plugin-system/src/components/Annotations/AnnotationEditorForm/AnnotationEditorForm.tsx @@ -0,0 +1,272 @@ +// 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, useCallback, useState } from 'react'; +import { Box, Typography, TextField, Grid, Divider, Stack, Switch, FormControlLabel, IconButton } from '@mui/material'; +import { Action } from '@perses-dev/core'; +import { AnnotationSpec } from '@perses-dev/spec'; +import { + DiscardChangesConfirmationDialog, + ErrorAlert, + ErrorBoundary, + FormActions, + OptionsColorPicker, +} from '@perses-dev/components'; +import { Control, Controller, FormProvider, SubmitHandler, useForm, useWatch } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { useQueryClient } from '@tanstack/react-query'; +import InvertColorsIcon from 'mdi-material-ui/InvertColors'; +import { getSubmitText, getTitleAction } from '../../../utils'; +import { PluginEditor } from '../../PluginEditor'; +import { useValidationSchemas } from '../../../context'; +import { AnnotationPreview } from './AnnotationPreview'; + +const DEFAULT_ANNOTATION_COLOR = '#FF6B6B'; + +function FallbackPreview(): ReactElement { + return
Error previewing annotations
; +} + +interface KindAnnotationEditorFormProps { + action: Action; + control: Control; + onRunQuery: () => void; +} + +function AnnotationPluginControl({ action, control, onRunQuery }: KindAnnotationEditorFormProps): ReactElement { + const plugin = useWatch({ control, name: 'plugin' }); + const kind = plugin?.kind; + const pluginSpec = plugin?.spec; + + return ( + { + return ( + { + field.onChange({ kind: v.selection.kind, spec: v.spec }); + }} + onRunQuery={onRunQuery} + /> + ); + }} + /> + ); +} + +interface AnnotationEditorFormProps { + initialAnnotationSpec: AnnotationSpec; + action: Action; + isDraft: boolean; + isReadonly?: boolean; + onActionChange?: (action: Action) => void; + onSave: (def: AnnotationSpec) => void; + onClose: () => void; + onDelete?: DispatchWithoutAction; +} + +export function AnnotationEditorForm({ + initialAnnotationSpec, + action, + isDraft, + isReadonly, + onActionChange, + onSave, + onClose, + onDelete, +}: AnnotationEditorFormProps): ReactElement { + const queryClient = useQueryClient(); + + 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: initialAnnotationSpec, + }); + + /* We use `previewDefinition` to explicitly update the spec + * that will be used for preview when running query. The reason why we do this is to avoid + * having to re-fetch the values when the user is still editing the spec. + * Using structuredClone to not have reference issues with nested objects. + */ + const [previewSpec, setPreviewSpec] = useState(structuredClone(form.getValues())); + + const handleRunQuery = useCallback(async () => { + const values = form.getValues(); + if (JSON.stringify(previewSpec) === JSON.stringify(values)) { + await queryClient.invalidateQueries({ queryKey: ['annotation', previewSpec] }); + } else { + setPreviewSpec(structuredClone(values)); + } + }, [form, previewSpec, queryClient]); + + const processForm: SubmitHandler = (data: AnnotationSpec) => { + // 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(initialAnnotationSpec) !== 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); + }} + /> + )} + /> + + + + { + const isEnabled = field.value !== undefined; + return ( + <> + {isEnabled ? ( + field.onChange(color)} + onClear={() => field.onChange(undefined)} + /> + ) : ( + field.onChange(DEFAULT_ANNOTATION_COLOR)}> + + + )} + + ); + }} + /> + + ( + { + field.onChange(event); + }} + /> + )} + /> + + + + + + + + + + + + + + + { + setDiscardDialogOpened(false); + }} + onDiscardChanges={() => { + setDiscardDialogOpened(false); + onClose(); + }} + /> + + ); +} diff --git a/plugin-system/src/components/Annotations/AnnotationEditorForm/AnnotationPreview.tsx b/plugin-system/src/components/Annotations/AnnotationEditorForm/AnnotationPreview.tsx new file mode 100644 index 00000000..e6058bba --- /dev/null +++ b/plugin-system/src/components/Annotations/AnnotationEditorForm/AnnotationPreview.tsx @@ -0,0 +1,138 @@ +import React, { ReactNode, useMemo, useState } from 'react'; +import { AnnotationData, AnnotationSpec } from '@perses-dev/spec'; +import { + Card, + CardContent, + CardHeader, + CardProps, + Chip, + CircularProgress, + Divider, + IconButton, + Stack, + Typography, +} from '@mui/material'; +import { useAnnotationData } from '@perses-dev/plugin-system'; +import { InfoTooltip, useTimeZone } from '@perses-dev/components'; +import AlertIcon from 'mdi-material-ui/Alert'; + +const formatDate = (timeMs: number, format: (date: Date, format: string) => string): { date: string; time: string } => { + const d = new Date(timeMs); + return { + date: format(d, 'MMM dd, yyyy'), + time: format(d, 'HH:mm:ss'), + }; +}; + +interface AnnotationPreviewCardProps extends CardProps { + value: AnnotationData; + formatWithUserTimeZone: (date: Date, format: string) => string; +} + +function AnnotationPreviewCard({ value, formatWithUserTimeZone, ...props }: AnnotationPreviewCardProps): ReactNode { + const start = formatDate(value.start, formatWithUserTimeZone); + const end = value.end !== undefined ? formatDate(value.start, formatWithUserTimeZone) : null; + + const tags = useMemo(() => { + return Object.entries(value.tags ?? []).map(([key, value]) => { + return { key: key, value: value }; + }); + }, [value.tags]); + + return ( + + + + {value.title && {value.title}} + {value.legend && {value.legend}} + + + {tags.map((tag) => ( + + ))} + + + + + + + + {start.date} - {start.time} + + {end && ( + <> + {' → '} + + {end.date} - {end.time} + + + )} + + + + ); +} + +export interface AnnotationPreviewProps extends CardProps { + spec: AnnotationSpec; +} + +export function AnnotationPreview({ spec, ...props }: AnnotationPreviewProps): ReactNode { + const { data, isFetching, error } = useAnnotationData(spec); + const { formatWithUserTimeZone } = useTimeZone(); + + const [showAll, setShowAll] = useState(false); + const annotationsToShow = showAll ? data : data?.slice(0, 1); + let notShown = 0; + if (data && data?.length > 0 && annotationsToShow) { + notShown = data.length - annotationsToShow.length; + } + + const stateIndicator = useMemo((): ReactNode | undefined => { + if (isFetching) { + return ; + } else if (error) { + return ( + + + theme.palette.error.main, + }} + /> + + + ); + } + }, [isFetching, error]); + + return ( + + + Preview Annotations + {stateIndicator} +
+ } + /> + + {annotationsToShow?.map((item, index) => ( + + ))} + {notShown > 0 && ( + setShowAll(true)} variant="outlined" size="small" label={`+${notShown} more`} /> + )} + {showAll && data && data.length > 1 && ( + setShowAll(false)} variant="outlined" size="small" label="-" /> + )} + + + ); +} 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..47343f9e 100644 --- a/plugin-system/src/context/ValidationProvider.tsx +++ b/plugin-system/src/context/ValidationProvider.tsx @@ -20,6 +20,9 @@ import { variableDefinitionSchema, buildPanelEditorSchema, buildVariableDefinitionSchema, + AnnotationSpec, + annotationSpecSchema, + buildAnnotationSpecSchema, } 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,7 @@ 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>(annotationSpecSchema); function setDatasourceEditorSchemaPlugin(pluginSchema: PluginSchema): void { setDatasourceEditorSchema(buildDatasourceDefinitionSchema(pluginSchema)); @@ -69,15 +75,21 @@ export function ValidationProvider({ children }: ValidationProviderProps): React setVariableEditorSchema(buildVariableDefinitionSchema(pluginSchema)); } + function setAnnotationEditorSchemaPlugin(pluginSchema: PluginSchema): void { + setAnnotationEditorSchema(buildAnnotationSpecSchema(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..7b094322 --- /dev/null +++ b/plugin-system/src/model/annotations.ts @@ -0,0 +1,30 @@ +import { AbsoluteTimeRange, AnnotationData, UnknownSpec } from '@perses-dev/spec'; +import { DatasourceStore, VariableStateMap } from '@perses-dev/plugin-system'; +import { Plugin } from './plugin-base'; + +/** + * An object containing all the dependencies of a AnnotationQuery. + */ +export 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/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/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..aa67d055 --- /dev/null +++ b/plugin-system/src/runtime/annotations.ts @@ -0,0 +1,141 @@ +// 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, AnnotationSpec } from '@perses-dev/spec'; +import { QueryKey, useQueries, useQuery, UseQueryResult } from '@tanstack/react-query'; +import { AnnotationContext, AnnotationPlugin } from '../model'; +import { filterVariableList } from '../components'; +import { usePlugin, usePluginRegistry, usePlugins } from './plugin-registry'; +import { useTimeRange } from './TimeRangeProvider'; +import { useAllVariableValues } from './variables'; +import { useDatasourceStore } from './datasources'; +import { filterVariableStateMap, getVariableValuesKey } from './utils'; + +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: AnnotationSpec; + context: AnnotationContext; +}): { + queryKey: QueryKey; + queryEnabled: boolean; +} { + const { variableState, absoluteTimeRange } = context; + + const dependencies = plugin?.dependsOn ? plugin.dependsOn(definition.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: AnnotationSpec[]): Array> { + const { getPlugin } = usePluginRegistry(); + const context = useAnnotationContext(); + + const pluginLoaderResponse = usePlugins( + 'Annotation', + definitions.map((d) => ({ kind: d.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?.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.plugin.spec, context, signal); + return data; + }, + }; + }), + }); +} + +export function useAnnotationData(spec: AnnotationSpec): UseQueryResult { + const { data: annotationPlugin } = usePlugin('Annotation', spec.plugin.kind); + + const datasourceStore = useDatasourceStore(); + const allVariables = useAllVariableValues(); + const { absoluteTimeRange: timeRange } = useTimeRange(); + const variablePluginCtx = { absoluteTimeRange: timeRange, datasourceStore, variableState: allVariables }; + + let dependsOnVariables: string[] = Object.keys(allVariables); // Default to all variables + if (annotationPlugin?.dependsOn) { + const dependencies = annotationPlugin.dependsOn(spec.plugin.spec, variablePluginCtx); + dependsOnVariables = dependencies.variables ? dependencies.variables : dependsOnVariables; + } + + const variables = useAllVariableValues(dependsOnVariables); + + let waitToLoad = false; + if (dependsOnVariables) { + waitToLoad = dependsOnVariables.some((v) => variables[v]?.loading); + } + + const variablesValueKey = getVariableValuesKey(variables); + + return useQuery({ + queryKey: ['annotation', spec, timeRange, variablesValueKey], + queryFn: async ({ signal }) => { + const resp = await annotationPlugin?.getAnnotationData( + spec.plugin.spec, + { ...variablePluginCtx, variableState: variables }, + signal + ); + if (!resp?.length) { + return []; + } + return resp; + }, + enabled: !!annotationPlugin || waitToLoad, + }); +} 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';