diff --git a/dashboards/src/components/DashboardToolbar/DashboardToolbar.tsx b/dashboards/src/components/DashboardToolbar/DashboardToolbar.tsx index 557c7768..0a257d83 100644 --- a/dashboards/src/components/DashboardToolbar/DashboardToolbar.tsx +++ b/dashboards/src/components/DashboardToolbar/DashboardToolbar.tsx @@ -27,7 +27,9 @@ import { DownloadButton } from '../DownloadButton'; import { EditButton } from '../EditButton'; import { EditJsonButton } from '../EditJsonButton'; import { LinksDisplay } from '../LinksDisplay'; +import { LockDashboardButton } from '../LockDashboardButton'; import { SaveDashboardButton } from '../SaveDashboardButton'; +import { UpdatePluginsButton } from '../UpdatePluginsButton'; import { EditVariablesButton } from '../Variables'; export interface DashboardToolbarProps { @@ -39,6 +41,13 @@ export interface DashboardToolbarProps { isAnnotationEnabled: boolean; isDatasourceEnabled: boolean; isLinksEnabled?: boolean; + /** + * When true, offers the button that locks/unlocks the dashboard, i.e. pins every plugin it uses to an exact version. + * It only makes the action available: whether the dashboard is actually locked is derived from its plugin + * definitions. Not available by default. Plugin versioning itself is always on: the button that updates + * already-pinned plugins is shown regardless of this flag. + */ + isLockModeAvailable?: boolean; timezone: string; onEditButtonClick: () => void; onCancelButtonClick: () => void; @@ -55,6 +64,7 @@ export const DashboardToolbar = (props: DashboardToolbarProps): ReactElement => isAnnotationEnabled, isDatasourceEnabled, isLinksEnabled = true, + isLockModeAvailable = false, timezone: toolbarTimezone, onEditButtonClick, onCancelButtonClick, @@ -105,6 +115,8 @@ export const DashboardToolbar = (props: DashboardToolbarProps): ReactElement => {isLinksEnabled && } + + {isLockModeAvailable && } ) : ( - <> - {isBiggerThanSm && ( - - - - )} - + isBiggerThanSm && ( + + + + ) )} const suggestedStepMs = useSuggestedStepMs(width); - const { data: plugin } = usePlugin('Panel', panelDefinition.spec.plugin.kind); + const { data: plugin } = usePlugin( + 'Panel', + panelDefinition.spec.plugin.kind, + undefined, + getPluginOverrides(panelDefinition.spec.plugin), + ); const pluginQueryOptions = typeof plugin?.queryOptions === 'function' diff --git a/dashboards/src/components/LockDashboardButton/LockDashboardButton.tsx b/dashboards/src/components/LockDashboardButton/LockDashboardButton.tsx new file mode 100644 index 00000000..6318cd2c --- /dev/null +++ b/dashboards/src/components/LockDashboardButton/LockDashboardButton.tsx @@ -0,0 +1,114 @@ +// Copyright The Perses Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Button, Tooltip } from '@mui/material'; +import { Dialog } from '@perses-dev/components'; +import { useListPluginMetadata } from '@perses-dev/plugin-system'; +import LockOpenOutline from 'mdi-material-ui/LockOpenOutline'; +import LockOutline from 'mdi-material-ui/LockOutline'; +import { ReactElement, useCallback, useMemo, useState } from 'react'; + +import { useDashboard } from '../../context/useDashboard'; +import { + applyPluginVersions, + buildLatestPluginVersions, + hasPinnedPluginVersions, + isDashboardLocked, + removePluginVersions, +} from '../../utils/pluginVersioning'; + +/** + * Toolbar button that "locks" or "unlocks" the dashboard. + * + * Locking pins every plugin definition (panels, queries, variables, datasources, annotations) to the latest version + * currently available in the Perses instance, by setting `plugin.metadata.version`. Unlocking removes that pinned + * version so the plugins float on the latest available version again. + * + * A dashboard can also be versioned partially (a single panel pinned from the panel editor, for instance). In that case + * both actions are offered: locking completes the pinning, unlocking clears it. + * + * Both actions are confirmed through a dialog explaining their consequences before the dashboard is updated. + */ +export function LockDashboardButton(): ReactElement { + const { dashboard, setDashboard } = useDashboard(); + const { data: pluginMetadata, isLoading } = useListPluginMetadata(); + const [pendingAction, setPendingAction] = useState<'lock' | 'unlock' | undefined>(undefined); + + const isLocked = useMemo(() => isDashboardLocked(dashboard), [dashboard]); + const hasPins = useMemo(() => hasPinnedPluginVersions(dashboard), [dashboard]); + + const closeConfirmation = useCallback((): void => setPendingAction(undefined), []); + + const handleConfirm = useCallback((): void => { + if (pendingAction === 'unlock') { + setDashboard(removePluginVersions(dashboard)); + } else if (pendingAction === 'lock') { + setDashboard(applyPluginVersions(dashboard, buildLatestPluginVersions(pluginMetadata ?? []))); + } + setPendingAction(undefined); + }, [dashboard, pendingAction, pluginMetadata, setDashboard]); + + const isUnlockAction = pendingAction === 'unlock'; + const confirmLabel = isUnlockAction ? 'Unlock' : 'Lock'; + + return ( + <> + {!isLocked && ( + + + + + + )} + {hasPins && ( + + + + + + )} + + + {isUnlockAction ? 'Unlock Dashboard' : 'Lock Dashboard'} + + + {isUnlockAction + ? 'Unlocking removes the plugin versions pinned on this dashboard. Its panels, queries, variables, datasources and annotations will use the latest plugin versions available in this Perses instance, so their behavior may change when those plugins are updated.' + : 'Locking pins every plugin used by this dashboard (panels, queries, variables, datasources and annotations) to the latest version currently available in this Perses instance. The dashboard keeps using those exact versions, even after the plugins are updated. Plugins that are not installed in this instance cannot be pinned.'} + {' The change only applies once you save the dashboard.'} + + + {confirmLabel} + Cancel + + + + ); +} diff --git a/dashboards/src/components/LockDashboardButton/index.ts b/dashboards/src/components/LockDashboardButton/index.ts new file mode 100644 index 00000000..724e1345 --- /dev/null +++ b/dashboards/src/components/LockDashboardButton/index.ts @@ -0,0 +1,14 @@ +// Copyright The Perses Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +export * from './LockDashboardButton'; diff --git a/dashboards/src/components/Panel/Panel.tsx b/dashboards/src/components/Panel/Panel.tsx index be6eb705..062e4085 100644 --- a/dashboards/src/components/Panel/Panel.tsx +++ b/dashboards/src/components/Panel/Panel.tsx @@ -20,7 +20,7 @@ import { combineSx, useId, } from '@perses-dev/components'; -import { ActionOptions, useDataQueriesContext, usePluginRegistry } from '@perses-dev/plugin-system'; +import { ActionOptions, useDataQueriesContext, usePluginRegistry, getPluginOverrides } from '@perses-dev/plugin-system'; import { PanelDefinition } from '@perses-dev/spec'; import { ReactNode, memo, useEffect, useMemo, useState } from 'react'; import useResizeObserver from 'use-resize-observer'; @@ -132,7 +132,11 @@ export const Panel = memo(function Panel(props: PanelProps) { } try { - const plugin = await getPlugin({ kind: 'Panel', name: panelPluginKind }); + const plugin = await getPlugin({ + kind: 'Panel', + name: panelPluginKind, + ...getPluginOverrides(definition.spec.plugin), + }); // More defensive checking for plugin and actions if ( @@ -169,7 +173,7 @@ export const Panel = memo(function Panel(props: PanelProps) { }; loadPluginActions(); - }, [definition.spec.plugin.kind, panelPropsForActions, getPlugin]); + }, [definition.spec.plugin, panelPropsForActions, getPlugin]); const handleMouseEnter: CardProps['onMouseEnter'] = (e) => { onMouseEnter?.(e); diff --git a/dashboards/src/components/Panel/PanelContent.tsx b/dashboards/src/components/Panel/PanelContent.tsx index 007b5565..9f7c6851 100644 --- a/dashboards/src/components/Panel/PanelContent.tsx +++ b/dashboards/src/components/Panel/PanelContent.tsx @@ -13,7 +13,7 @@ import { Skeleton } from '@mui/material'; import { LoadingOverlay } from '@perses-dev/components'; -import { usePlugin, PanelProps, QueryData, PanelPlugin } from '@perses-dev/plugin-system'; +import { usePlugin, PanelProps, QueryData, PanelPlugin, getPluginOverrides } from '@perses-dev/plugin-system'; import { UnknownSpec, PanelDefinition, QueryDataType } from '@perses-dev/spec'; import { ReactElement } from 'react'; @@ -31,7 +31,12 @@ export interface PanelContentProps extends Omit, 'queryR */ export function PanelContent(props: PanelContentProps): ReactElement { const { panelPluginKind, definition, queryResults, spec, contentDimensions } = props; - const { data: plugin, isLoading: isPanelLoading } = usePlugin('Panel', panelPluginKind, { useErrorBoundary: true }); + const { data: plugin, isLoading: isPanelLoading } = usePlugin( + 'Panel', + panelPluginKind, + { useErrorBoundary: true }, + getPluginOverrides(definition?.spec.plugin), + ); // Show fullsize skeleton if the panel plugin is loading. if (isPanelLoading) { diff --git a/dashboards/src/components/Panel/PanelPluginLoader.tsx b/dashboards/src/components/Panel/PanelPluginLoader.tsx index 0f264a11..262cd4f8 100644 --- a/dashboards/src/components/Panel/PanelPluginLoader.tsx +++ b/dashboards/src/components/Panel/PanelPluginLoader.tsx @@ -12,7 +12,7 @@ // limitations under the License. import { Skeleton } from '@mui/material'; -import { usePlugin, PanelProps } from '@perses-dev/plugin-system'; +import { usePlugin, PanelProps, getPluginOverrides } from '@perses-dev/plugin-system'; import { UnknownSpec, QueryDataType } from '@perses-dev/spec'; import { ReactElement } from 'react'; @@ -26,7 +26,12 @@ interface PanelPluginProps extends PanelProps { */ export function PanelPluginLoader(props: PanelPluginProps): ReactElement { const { kind, spec, contentDimensions, definition, queryResults } = props; - const { data: plugin, isLoading: isPanelLoading } = usePlugin('Panel', kind, { useErrorBoundary: true }); + const { data: plugin, isLoading: isPanelLoading } = usePlugin( + 'Panel', + kind, + { useErrorBoundary: true }, + getPluginOverrides(definition?.spec.plugin), + ); const PanelComponent = plugin?.PanelComponent; const supportedQueryTypes = plugin?.supportedQueryTypes || []; // Clear out the queryResults parameter for plugins which don't support any query types diff --git a/dashboards/src/components/PanelDrawer/PanelEditorForm.tsx b/dashboards/src/components/PanelDrawer/PanelEditorForm.tsx index 922076b1..cdbe57b7 100644 --- a/dashboards/src/components/PanelDrawer/PanelEditorForm.tsx +++ b/dashboards/src/components/PanelDrawer/PanelEditorForm.tsx @@ -21,8 +21,14 @@ import { getSubmitText, getTitleAction, } from '@perses-dev/components'; -import { PanelEditorValues, PluginKindSelect, usePluginEditor, useValidationSchemas } from '@perses-dev/plugin-system'; -import { PanelDefinition } from '@perses-dev/spec'; +import { + getPluginOverrides, + PanelEditorValues, + PluginKindSelect, + usePluginEditor, + useValidationSchemas, +} from '@perses-dev/plugin-system'; +import { PanelDefinition, Definition, UnknownSpec } from '@perses-dev/spec'; import { ReactElement, useCallback, useEffect, useState } from 'react'; import { Controller, FormProvider, SubmitHandler, useForm, useWatch } from 'react-hook-form'; @@ -60,16 +66,27 @@ export function PanelEditorForm(props: PanelEditorFormProps): ReactElement { defaultValues: initialValues, }); + // The version/registry the panel is currently pinned to, if any. `latest` is not a pin, so it is filtered out. + const pinnedPluginMetadata = getPluginOverrides(plugin); + // Use common plugin editor logic even though we've split the inputs up in this form const pluginEditor = usePluginEditor({ pluginTypes: ['Panel'], - value: { selection: { kind: plugin.kind, type: 'Panel' }, spec: plugin.spec }, - onChange: (plugin) => { - form.setValue('panelDefinition.spec.plugin', { kind: plugin.selection.kind, spec: plugin.spec }); - setPlugin({ - kind: plugin.selection.kind, - spec: plugin.spec, - }); + // Carry the current pin so that editing the options doesn't silently drop it, and so the options editor is loaded + // from the pinned implementation. + value: { selection: { kind: plugin.kind, type: 'Panel', metadata: pinnedPluginMetadata }, spec: plugin.spec }, + onChange: (next) => { + // Persist the selected version/registry (if any) as plugin metadata so the panel uses that exact implementation. + // When nothing is selected (a single version/registry is available), metadata is omitted so the latest version + // of the default registry is used. + const metadata = next.selection.metadata; + const nextPlugin: Definition = { + kind: next.selection.kind, + ...(metadata?.version || metadata?.registry ? { metadata } : {}), + spec: next.spec, + }; + form.setValue('panelDefinition.spec.plugin', nextPlugin); + setPlugin(nextPlugin); }, onHideQueryEditorChange: (isHidden) => { setQueries(undefined, isHidden); @@ -214,16 +231,18 @@ export function PanelEditorForm(props: PanelEditorFormProps): ReactElement { { - field.onChange(event.kind); - pluginEditor.onSelectionChange(event); + value={{ type: 'Panel', kind: watchedPluginKind, metadata: pinnedPluginMetadata }} + onChange={(selection) => { + field.onChange(selection.kind); + pluginEditor.onSelectionChange(selection); }} /> )} diff --git a/dashboards/src/components/UpdatePluginsButton/UpdatePluginsButton.tsx b/dashboards/src/components/UpdatePluginsButton/UpdatePluginsButton.tsx new file mode 100644 index 00000000..7e3eb397 --- /dev/null +++ b/dashboards/src/components/UpdatePluginsButton/UpdatePluginsButton.tsx @@ -0,0 +1,78 @@ +// Copyright The Perses Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Badge, Button, Tooltip } from '@mui/material'; +import { useListPluginMetadata } from '@perses-dev/plugin-system'; +import UpdateIcon from 'mdi-material-ui/Update'; +import { ReactElement, useMemo, useState } from 'react'; + +import { useDashboard } from '../../context/useDashboard'; +import { + buildLatestPluginVersions, + findOutdatedPlugins, + OutdatedPlugin, + updatePluginVersions, +} from '../../utils/pluginVersioning'; +import { UpdatePluginsDrawer } from '../UpdatePluginsDrawer'; + +/** + * Toolbar button shown when at least one plugin pinned by the dashboard has a newer version installed. Opens a drawer to + * review and select which plugins to update. + * + * This is not reserved to fully locked dashboards: versioning can be enforced partially (a single panel pinned from the + * panel editor, for instance) and those pins are just as worth updating. + */ +export function UpdatePluginsButton(): ReactElement | null { + const { dashboard, setDashboard } = useDashboard(); + const { data: pluginMetadata } = useListPluginMetadata(); + const [isDrawerOpen, setDrawerOpen] = useState(false); + + const outdatedPlugins = useMemo( + () => findOutdatedPlugins(dashboard, buildLatestPluginVersions(pluginMetadata ?? [])), + [dashboard, pluginMetadata], + ); + + const handleUpdate = (plugins: OutdatedPlugin[]): void => { + setDashboard(updatePluginVersions(dashboard, plugins)); + setDrawerOpen(false); + }; + + // Nothing to update: don't render the button at all. + if (outdatedPlugins.length === 0) { + return null; + } + + return ( + <> + + + + + + setDrawerOpen(false)} + /> + + ); +} diff --git a/dashboards/src/components/UpdatePluginsButton/index.ts b/dashboards/src/components/UpdatePluginsButton/index.ts new file mode 100644 index 00000000..469eb747 --- /dev/null +++ b/dashboards/src/components/UpdatePluginsButton/index.ts @@ -0,0 +1,14 @@ +// Copyright The Perses Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +export * from './UpdatePluginsButton'; diff --git a/dashboards/src/components/UpdatePluginsDrawer/PanelVersionDiff.tsx b/dashboards/src/components/UpdatePluginsDrawer/PanelVersionDiff.tsx new file mode 100644 index 00000000..c92d620d --- /dev/null +++ b/dashboards/src/components/UpdatePluginsDrawer/PanelVersionDiff.tsx @@ -0,0 +1,93 @@ +// Copyright The Perses Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Alert, Box, Chip, Stack, Typography } from '@mui/material'; +import { ErrorAlert, ErrorBoundary } from '@perses-dev/components'; +import { DataQueriesProvider } from '@perses-dev/plugin-system'; +import { PanelDefinition } from '@perses-dev/spec'; +import { ReactElement, useMemo } from 'react'; + +import { Panel } from '../Panel/Panel'; + +const PREVIEW_HEIGHT = 260; + +export interface PanelVersionDiffProps { + /** The panel used as a representative example of the plugin being updated. */ + panelDefinition: PanelDefinition; + /** The version currently pinned in the dashboard spec. */ + currentVersion: string; + /** The latest available version the plugin would be updated to. */ + latestVersion: string; + /** The registry the plugin is pinned to, when the definition pins one. */ + registry?: string; +} + +/** Returns a copy of the panel definition with its panel plugin pinned to the given version/registry. */ +function withPluginVersion(panelDefinition: PanelDefinition, version: string, registry?: string): PanelDefinition { + const next = structuredClone(panelDefinition); + next.spec.plugin.metadata = { ...next.spec.plugin.metadata, version, ...(registry ? { registry } : {}) }; + return next; +} + +/** + * Renders the same panel twice, side by side: once with the plugin version currently pinned in the dashboard, and once + * with the latest available version. This lets users spot new features or rendering regressions before updating. + */ +export function PanelVersionDiff(props: PanelVersionDiffProps): ReactElement { + const { panelDefinition, currentVersion, latestVersion, registry } = props; + + const currentDefinition = useMemo( + () => withPluginVersion(panelDefinition, currentVersion, registry), + [panelDefinition, currentVersion, registry], + ); + const latestDefinition = useMemo( + () => withPluginVersion(panelDefinition, latestVersion, registry), + [panelDefinition, latestVersion, registry], + ); + + const queries = panelDefinition.spec.queries ?? []; + + return ( + + + Preview based on panel "{panelDefinition.spec.display?.name ?? 'Untitled'}" + + {/* Both sides share a single queries provider: only the panel plugin version differs, so the data is the same. */} + + + + + + + + + + + + + + + + + + + + + {queries.length === 0 && ( + + This panel has no query, the preview only reflects rendering differences. + + )} + + ); +} diff --git a/dashboards/src/components/UpdatePluginsDrawer/UpdatePluginsDrawer.tsx b/dashboards/src/components/UpdatePluginsDrawer/UpdatePluginsDrawer.tsx new file mode 100644 index 00000000..ac302058 --- /dev/null +++ b/dashboards/src/components/UpdatePluginsDrawer/UpdatePluginsDrawer.tsx @@ -0,0 +1,210 @@ +// Copyright The Perses Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { + Box, + Button, + Checkbox, + Chip, + Collapse, + Divider, + FormControlLabel, + IconButton, + Stack, + Typography, +} from '@mui/material'; +import { Drawer, ErrorAlert, ErrorBoundary } from '@perses-dev/components'; +import ArrowRight from 'mdi-material-ui/ArrowRight'; +import ChevronDown from 'mdi-material-ui/ChevronDown'; +import ChevronUp from 'mdi-material-ui/ChevronUp'; +import { ReactElement, useMemo, useState } from 'react'; + +import { useDashboard } from '../../context/useDashboard'; +import { OutdatedPlugin, getOutdatedPluginId } from '../../utils/pluginVersioning'; +import { PanelVersionDiff } from './PanelVersionDiff'; + +export interface UpdatePluginsDrawerProps { + isOpen: boolean; + /** The plugins pinned to a version older than the latest available one. */ + outdatedPlugins: OutdatedPlugin[]; + /** Called with the plugins the user selected for update. */ + onUpdate: (plugins: OutdatedPlugin[]) => void; + onClose: () => void; +} + +/** + * Drawer listing every plugin the dashboard pins to an outdated version, letting the user pick which ones to update to + * their latest available version. Panel plugins can be expanded to show a side-by-side preview of a representative + * panel rendered with the current and the new plugin version. + */ +export function UpdatePluginsDrawer(props: UpdatePluginsDrawerProps): ReactElement { + const { isOpen, outdatedPlugins, onUpdate, onClose } = props; + const { dashboard } = useDashboard(); + const panels = dashboard.spec.panels ?? {}; + + // Selected plugin ids. Everything starts unselected so updating is always an explicit action. + // Sets, because these ids are looked up once per rendered row. + const [selectedIds, setSelectedIds] = useState>(new Set()); + const [expandedIds, setExpandedIds] = useState>(new Set()); + + const allIds = useMemo( + () => new Set(outdatedPlugins.map((plugin) => getOutdatedPluginId(plugin))), + [outdatedPlugins], + ); + const selectedCount = selectedIds.size; + const isAllSelected = allIds.size > 0 && selectedCount === allIds.size; + const isPartiallySelected = selectedCount > 0 && !isAllSelected; + + const toggleAll = (): void => { + setSelectedIds(isAllSelected ? new Set() : new Set(allIds)); + }; + + const toggleId = (setIds: typeof setSelectedIds, id: string): void => { + setIds((prev) => { + const next = new Set(prev); + if (!next.delete(id)) { + next.add(id); + } + return next; + }); + }; + + const resetSelection = (): void => { + setSelectedIds(new Set()); + setExpandedIds(new Set()); + }; + + const handleUpdate = (): void => { + const selection = outdatedPlugins.filter((plugin) => selectedIds.has(getOutdatedPluginId(plugin))); + // The parent closes the drawer without going through `handleClose`, so reset here too: otherwise a partial update + // would leave stale selections behind and re-enable Update on plugins that are already up to date. + resetSelection(); + onUpdate(selection); + }; + + const handleClose = (): void => { + resetSelection(); + onClose(); + }; + + return ( + + + theme.spacing(1, 2), + borderBottom: (theme) => `1px solid ${theme.palette.divider}`, + }} + > + Update plugins + + + + + + + theme.spacing(2) }}> + + The following plugins are pinned to an older version than the one installed. Select the plugins you want to + update to their latest version. + + + + } + label={isAllSelected ? 'Unselect all' : 'Select all'} + /> + + + }> + {outdatedPlugins.map((plugin) => { + const id = getOutdatedPluginId(plugin); + const isExpanded = expandedIds.has(id); + // Only panel plugins can be previewed, and only if we found a panel using them. + const examplePanel = + plugin.pluginType === 'Panel' && plugin.examplePanelKey ? panels[plugin.examplePanelKey] : undefined; + + return ( + + + toggleId(setSelectedIds, id)} + inputProps={{ 'aria-label': `Select ${plugin.kind}` }} + /> + + + {plugin.kind} + + {plugin.occurrences > 1 && ( + + {plugin.occurrences} usages + + )} + + + + {plugin.currentVersion} + + + + {plugin.latestVersion} + + + + {examplePanel && ( + toggleId(setExpandedIds, id)} + aria-label={isExpanded ? `Hide preview of ${plugin.kind}` : `Show preview of ${plugin.kind}`} + aria-expanded={isExpanded} + > + {isExpanded ? : } + + )} + + + {examplePanel && ( + + + + + + + + )} + + ); + })} + + + + + ); +} diff --git a/dashboards/src/components/UpdatePluginsDrawer/index.ts b/dashboards/src/components/UpdatePluginsDrawer/index.ts new file mode 100644 index 00000000..c31f70d6 --- /dev/null +++ b/dashboards/src/components/UpdatePluginsDrawer/index.ts @@ -0,0 +1,15 @@ +// Copyright The Perses Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +export * from './UpdatePluginsDrawer'; +export * from './PanelVersionDiff'; diff --git a/dashboards/src/components/index.ts b/dashboards/src/components/index.ts index 65df3f48..6b1c8423 100644 --- a/dashboards/src/components/index.ts +++ b/dashboards/src/components/index.ts @@ -28,6 +28,7 @@ export * from './EditJsonButton'; export * from './EmptyDashboard'; export * from './GridLayout'; export * from './LeaveDialog'; +export * from './LockDashboardButton'; export * from './Panel'; export * from './PanelDrawer'; export * from './PanelGroupDialog'; @@ -35,4 +36,6 @@ export * from './QuerySummaryTable'; export * from './QueryViewerDialog'; export * from './SaveChangesConfirmationDialog'; export * from './SaveDashboardButton'; +export * from './UpdatePluginsButton'; +export * from './UpdatePluginsDrawer'; export * from './Variables'; diff --git a/dashboards/src/context/DatasourceStoreProvider.tsx b/dashboards/src/context/DatasourceStoreProvider.tsx index 5b6ffa8b..d8bd1148 100644 --- a/dashboards/src/context/DatasourceStoreProvider.tsx +++ b/dashboards/src/context/DatasourceStoreProvider.tsx @@ -25,6 +25,7 @@ import { useEvent, DatasourceClient, DatasourceSelectItem, + getPluginOverrides, } from '@perses-dev/plugin-system'; import { DashboardSpec, DatasourceSelector, DatasourceSpec } from '@perses-dev/spec'; import { ReactElement, ReactNode, useCallback, useMemo, useRef, useState } from 'react'; @@ -132,10 +133,8 @@ export function DatasourceStoreProvider(props: DatasourceStoreProviderProps): Re const getDatasourceClient = useCallback( async function getClient(selector: DatasourceSelector): Promise { const { kind } = selector; - const [{ spec, proxyUrl }, plugin] = await Promise.all([ - findDatasource(selector), - getPlugin({ kind: 'Datasource', name: kind }), - ]); + const { spec, proxyUrl } = await findDatasource(selector); + const plugin = await getPlugin({ kind: 'Datasource', name: kind, ...getPluginOverrides(spec.plugin) }); // allows extending client const client = plugin.createClient(spec.plugin.spec, { proxyUrl }) as Client; diff --git a/dashboards/src/utils/index.ts b/dashboards/src/utils/index.ts index a370c5cd..70a23d99 100644 --- a/dashboards/src/utils/index.ts +++ b/dashboards/src/utils/index.ts @@ -12,4 +12,5 @@ // limitations under the License. export * from './panelUtils'; +export * from './pluginVersioning'; export * from './repeatLayoutUtils'; diff --git a/dashboards/src/utils/pluginVersioning.test.ts b/dashboards/src/utils/pluginVersioning.test.ts new file mode 100644 index 00000000..475dd6c3 --- /dev/null +++ b/dashboards/src/utils/pluginVersioning.test.ts @@ -0,0 +1,345 @@ +// Copyright The Perses Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { DashboardResource } from '@perses-dev/client'; +import { PluginMetadataWithModule } from '@perses-dev/plugin-system'; + +import { + applyPluginVersions, + buildLatestPluginVersions, + findOutdatedPlugins, + getOutdatedPluginId, + getPluginIdentityKey, + hasPinnedPluginVersions, + isDashboardLocked, + LatestPluginVersions, + removePluginVersions, + updatePluginVersions, +} from './pluginVersioning'; + +function buildMetadata( + kind: string, + name: string, + moduleVersion: string, + options?: { pluginVersion?: string; registry?: string }, +): PluginMetadataWithModule { + return { + kind, + metadata: options?.pluginVersion ? { version: options.pluginVersion } : undefined, + spec: { name, display: { name } }, + module: { name: `${name}-module`, version: moduleVersion, registry: options?.registry }, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any; +} + +/** Build the version map used by `applyPluginVersions` from a plain `pluginType:kind -> version` record. */ +function buildVersions(entries: Array<[pluginType: string, kind: string, version: string]>): LatestPluginVersions { + return new Map(entries.map(([pluginType, kind, version]) => [getPluginIdentityKey({ pluginType, kind }), version])); +} + +/** Every plugin of the test dashboard, pinned to the same version. */ +function allPluginsAt(version: string): LatestPluginVersions { + return buildVersions([ + ['Panel', 'TimeSeriesChart', version], + ['TimeSeriesQuery', 'PrometheusTimeSeriesQuery', version], + ['Variable', 'PrometheusLabelValuesVariable', version], + ['Datasource', 'PrometheusDatasource', version], + ['Annotation', 'TempoAnnotation', version], + ]); +} + +function buildDashboard(): DashboardResource { + return { + kind: 'Dashboard', + metadata: { name: 'test', project: 'perses', version: 0, createdAt: '', updatedAt: '' }, + spec: { + duration: '1h', + variables: [ + { + kind: 'ListVariable', + spec: { + name: 'foo', + allowMultiple: false, + allowAllValue: false, + plugin: { kind: 'PrometheusLabelValuesVariable', spec: {} }, + }, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any, + { + kind: 'TextVariable', + spec: { name: 'bar', value: 'baz' }, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any, + ], + layouts: [], + panels: { + panel1: { + kind: 'Panel', + spec: { + display: { name: 'Panel 1' }, + plugin: { kind: 'TimeSeriesChart', spec: {} }, + queries: [ + { + kind: 'TimeSeriesQuery', + spec: { plugin: { kind: 'PrometheusTimeSeriesQuery', spec: {} } }, + }, + ], + }, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any, + }, + datasources: { + ds1: { + default: true, + plugin: { kind: 'PrometheusDatasource', spec: {} }, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any, + }, + annotations: [ + { + display: { name: 'anno' }, + plugin: { kind: 'TempoAnnotation', spec: {} }, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any, + ], + }, + }; +} + +describe('buildLatestPluginVersions', () => { + test('keeps the highest version per plugin identity and prefers plugin-level version', () => { + const versions = buildLatestPluginVersions([ + buildMetadata('Panel', 'TimeSeriesChart', '0.1.0'), + buildMetadata('Panel', 'TimeSeriesChart', '0.3.0'), + buildMetadata('Panel', 'TimeSeriesChart', '0.2.0'), + buildMetadata('TimeSeriesQuery', 'PrometheusTimeSeriesQuery', '1.0.0', { pluginVersion: '2.0.0' }), + ]); + expect(versions.get(getPluginIdentityKey({ pluginType: 'Panel', kind: 'TimeSeriesChart' }))).toBe('0.3.0'); + // plugin-level version wins over module version + expect( + versions.get(getPluginIdentityKey({ pluginType: 'TimeSeriesQuery', kind: 'PrometheusTimeSeriesQuery' })), + ).toBe('2.0.0'); + }); + + test('a pre-release never wins over its stable release', () => { + const versions = buildLatestPluginVersions([ + buildMetadata('Panel', 'TimeSeriesChart', '1.0.0'), + buildMetadata('Panel', 'TimeSeriesChart', '1.0.0-beta'), + ]); + expect(versions.get(getPluginIdentityKey({ pluginType: 'Panel', kind: 'TimeSeriesChart' }))).toBe('1.0.0'); + }); + + test('the same kind in two registries keeps a version per registry', () => { + const versions = buildLatestPluginVersions([ + buildMetadata('Panel', 'TimeSeriesChart', '1.0.0', { registry: 'a' }), + buildMetadata('Panel', 'TimeSeriesChart', '2.0.0', { registry: 'b' }), + ]); + expect(versions.get(getPluginIdentityKey({ pluginType: 'Panel', kind: 'TimeSeriesChart', registry: 'a' }))).toBe( + '1.0.0', + ); + expect(versions.get(getPluginIdentityKey({ pluginType: 'Panel', kind: 'TimeSeriesChart', registry: 'b' }))).toBe( + '2.0.0', + ); + // Without a pinned registry, the latest version across registries is used. + expect(versions.get(getPluginIdentityKey({ pluginType: 'Panel', kind: 'TimeSeriesChart' }))).toBe('2.0.0'); + }); + + test('the same kind under two plugin types is versioned independently', () => { + const versions = buildLatestPluginVersions([ + buildMetadata('Panel', 'Shared', '1.0.0'), + buildMetadata('Variable', 'Shared', '2.0.0'), + ]); + expect(versions.get(getPluginIdentityKey({ pluginType: 'Panel', kind: 'Shared' }))).toBe('1.0.0'); + expect(versions.get(getPluginIdentityKey({ pluginType: 'Variable', kind: 'Shared' }))).toBe('2.0.0'); + }); +}); + +describe('applyPluginVersions / removePluginVersions / isDashboardLocked', () => { + const versions = buildVersions([ + ['Panel', 'TimeSeriesChart', '1.0.0'], + ['TimeSeriesQuery', 'PrometheusTimeSeriesQuery', '1.1.0'], + ['Variable', 'PrometheusLabelValuesVariable', '1.2.0'], + ['Datasource', 'PrometheusDatasource', '1.3.0'], + ['Annotation', 'TempoAnnotation', '1.4.0'], + ]); + + test('a fresh dashboard is neither locked nor pinned', () => { + expect(isDashboardLocked(buildDashboard())).toBe(false); + expect(hasPinnedPluginVersions(buildDashboard())).toBe(false); + }); + + test('applies versions to every plugin definition and marks the dashboard as locked', () => { + const dashboard = buildDashboard(); + const locked = applyPluginVersions(dashboard, versions); + + // original is untouched (deep clone) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + expect((dashboard.spec.panels.panel1 as any).spec.plugin.metadata).toBeUndefined(); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + expect((locked.spec.panels.panel1 as any).spec.plugin.metadata.version).toBe('1.0.0'); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + expect((locked.spec.panels.panel1 as any).spec.queries[0].spec.plugin.metadata.version).toBe('1.1.0'); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + expect((locked.spec.variables[0] as any).spec.plugin.metadata.version).toBe('1.2.0'); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + expect((locked.spec.datasources!.ds1 as any).plugin.metadata.version).toBe('1.3.0'); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + expect((locked.spec.annotations![0] as any).plugin.metadata.version).toBe('1.4.0'); + expect(isDashboardLocked(locked)).toBe(true); + }); + + test('removePluginVersions reverts the lock', () => { + const locked = applyPluginVersions(buildDashboard(), versions); + const unlocked = removePluginVersions(locked); + + expect(isDashboardLocked(unlocked)).toBe(false); + expect(hasPinnedPluginVersions(unlocked)).toBe(false); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + expect((unlocked.spec.panels.panel1 as any).spec.plugin.metadata).toBeUndefined(); + }); + + test('plugins without an available version are left unpinned', () => { + const partial = applyPluginVersions(buildDashboard(), buildVersions([['Panel', 'TimeSeriesChart', '1.0.0']])); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + expect((partial.spec.panels.panel1 as any).spec.plugin.metadata.version).toBe('1.0.0'); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + expect((partial.spec.datasources!.ds1 as any).plugin.metadata).toBeUndefined(); + }); + + test('a partially pinned dashboard is pinned but not locked', () => { + const partial = applyPluginVersions(buildDashboard(), buildVersions([['Panel', 'TimeSeriesChart', '1.0.0']])); + expect(hasPinnedPluginVersions(partial)).toBe(true); + expect(isDashboardLocked(partial)).toBe(false); + }); + + test('the `latest` sentinel does not count as a pin', () => { + const sentinel = applyPluginVersions(buildDashboard(), allPluginsAt('latest')); + expect(hasPinnedPluginVersions(sentinel)).toBe(false); + expect(isDashboardLocked(sentinel)).toBe(false); + }); +}); + +describe('findOutdatedPlugins / updatePluginVersions', () => { + const latest = buildVersions([ + ['Panel', 'TimeSeriesChart', '2.0.0'], + ['TimeSeriesQuery', 'PrometheusTimeSeriesQuery', '1.5.0'], + ['Variable', 'PrometheusLabelValuesVariable', '1.2.0'], + ['Datasource', 'PrometheusDatasource', '1.3.0'], + ['Annotation', 'TempoAnnotation', '1.4.0'], + ]); + + // Lock everything to an older version so every plugin is outdated. + const lockedOld = (): DashboardResource => applyPluginVersions(buildDashboard(), allPluginsAt('1.0.0')); + + test('an unpinned dashboard reports nothing as outdated', () => { + expect(findOutdatedPlugins(buildDashboard(), latest)).toEqual([]); + }); + + test('a dashboard pinned to the latest versions reports nothing as outdated', () => { + const upToDate = applyPluginVersions(buildDashboard(), latest); + expect(findOutdatedPlugins(upToDate, latest)).toEqual([]); + }); + + test('detects outdated plugins with their type, versions and example panel', () => { + const outdated = findOutdatedPlugins(lockedOld(), latest); + const kinds = outdated.map((o) => o.kind).toSorted(); + expect(kinds).toEqual([ + 'PrometheusDatasource', + 'PrometheusLabelValuesVariable', + 'PrometheusTimeSeriesQuery', + 'TempoAnnotation', + 'TimeSeriesChart', + ]); + + expect(outdated.find((o) => o.kind === 'TimeSeriesChart')).toMatchObject({ + pluginType: 'Panel', + currentVersion: '1.0.0', + latestVersion: '2.0.0', + examplePanelKey: 'panel1', + }); + + // Query plugins carry their query type and the panel they belong to + expect(outdated.find((o) => o.kind === 'PrometheusTimeSeriesQuery')).toMatchObject({ + pluginType: 'TimeSeriesQuery', + examplePanelKey: 'panel1', + }); + // Non-panel plugins have no example panel + expect(outdated.find((o) => o.kind === 'PrometheusDatasource')?.examplePanelKey).toBeUndefined(); + }); + + test('the `latest` sentinel is not considered outdated', () => { + const dashboard = applyPluginVersions(buildDashboard(), allPluginsAt('latest')); + expect(findOutdatedPlugins(dashboard, latest)).toEqual([]); + }); + + test('a pre-release pin is not reported as newer than its stable release', () => { + const dashboard = applyPluginVersions(buildDashboard(), buildVersions([['Panel', 'TimeSeriesChart', '2.0.0-rc1']])); + expect(findOutdatedPlugins(dashboard, latest)).toMatchObject([ + { kind: 'TimeSeriesChart', currentVersion: '2.0.0-rc1', latestVersion: '2.0.0' }, + ]); + }); + + test('a pin on a plugin registry that has nothing newer is left alone', () => { + const dashboard = applyPluginVersions(buildDashboard(), buildVersions([['Panel', 'TimeSeriesChart', '1.0.0']])); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (dashboard.spec.panels.panel1 as any).spec.plugin.metadata.registry = 'other'; + // `latest` only knows about the registry-less identity, so nothing can be proposed for registry 'other'. + expect(findOutdatedPlugins(dashboard, latest)).toEqual([]); + }); + + test('only the selected plugins are updated', () => { + const dashboard = lockedOld(); + const outdated = findOutdatedPlugins(dashboard, latest); + const panelPlugin = outdated.find((o) => o.kind === 'TimeSeriesChart')!; + + const updated = updatePluginVersions(dashboard, [panelPlugin]); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + expect((updated.spec.panels.panel1 as any).spec.plugin.metadata.version).toBe('2.0.0'); + // Not selected -> untouched + // eslint-disable-next-line @typescript-eslint/no-explicit-any + expect((updated.spec.panels.panel1 as any).spec.queries[0].spec.plugin.metadata.version).toBe('1.0.0'); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + expect((updated.spec.datasources!.ds1 as any).plugin.metadata.version).toBe('1.0.0'); + + // The source dashboard is not mutated + // eslint-disable-next-line @typescript-eslint/no-explicit-any + expect((dashboard.spec.panels.panel1 as any).spec.plugin.metadata.version).toBe('1.0.0'); + }); + + test('updating every outdated plugin clears the outdated list', () => { + const dashboard = lockedOld(); + const updated = updatePluginVersions(dashboard, findOutdatedPlugins(dashboard, latest)); + expect(findOutdatedPlugins(updated, latest)).toEqual([]); + // The dashboard stays locked, just on newer versions + expect(isDashboardLocked(updated)).toBe(true); + }); + + test('updating with an empty selection returns the dashboard unchanged', () => { + const dashboard = lockedOld(); + expect(updatePluginVersions(dashboard, [])).toBe(dashboard); + }); + + test('getOutdatedPluginId distinguishes plugin type, kind, registry and version', () => { + expect(getOutdatedPluginId({ pluginType: 'Panel', kind: 'TimeSeriesChart', currentVersion: '1.0.0' })).toBe( + 'Panel:TimeSeriesChart::1.0.0', + ); + expect(getOutdatedPluginId({ pluginType: 'Panel', kind: 'TimeSeriesChart', currentVersion: '1.1.0' })).not.toBe( + getOutdatedPluginId({ pluginType: 'Panel', kind: 'TimeSeriesChart', currentVersion: '1.0.0' }), + ); + expect( + getOutdatedPluginId({ pluginType: 'Panel', kind: 'TimeSeriesChart', registry: 'a', currentVersion: '1.0.0' }), + ).not.toBe(getOutdatedPluginId({ pluginType: 'Panel', kind: 'TimeSeriesChart', currentVersion: '1.0.0' })); + }); +}); diff --git a/dashboards/src/utils/pluginVersioning.ts b/dashboards/src/utils/pluginVersioning.ts new file mode 100644 index 00000000..9dc002d3 --- /dev/null +++ b/dashboards/src/utils/pluginVersioning.ts @@ -0,0 +1,308 @@ +// Copyright The Perses Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { DashboardResource } from '@perses-dev/client'; +import { comparePluginVersions, LATEST_PLUGIN_VERSION, PluginMetadataWithModule } from '@perses-dev/plugin-system'; +import { Definition } from '@perses-dev/spec'; + +/** + * The full runtime identity of a plugin: two plugins with the same kind but a different registry are different plugins, + * so a version can only be compared or applied within a single (plugin type, kind, registry) triplet. + */ +export interface PluginIdentity { + /** The plugin type (e.g. 'Panel', 'TimeSeriesQuery', 'Variable', ...). */ + pluginType: string; + /** The plugin kind/name (e.g. 'TimeSeriesChart'). */ + kind: string; + /** The registry the plugin comes from, when the definition pins one. */ + registry?: string; +} + +/** A stable string key for a {@link PluginIdentity}, usable as a Map key, React key or selection key. */ +export function getPluginIdentityKey(identity: PluginIdentity): string { + return `${identity.pluginType}:${identity.kind}:${identity.registry ?? ''}`; +} + +/** Context about where a plugin definition lives inside the dashboard spec. */ +interface PluginDefinitionContext { + /** The plugin type (e.g. 'Panel', 'TimeSeriesQuery', 'Variable', ...). */ + pluginType: string; + /** Key of the panel the definition belongs to, for panel plugins and panel query plugins. */ + panelKey?: string; +} + +/** + * Visit every plugin definition contained in a dashboard spec, invoking the provided callback with the definition, its + * plugin type and (when relevant) the key of the panel it belongs to. Covers panel plugins, panel query plugins, + * list-variable plugins, datasource plugins and annotation plugins. + */ +function visitPluginDefinitions( + dashboard: DashboardResource, + visitor: (definition: Definition, context: PluginDefinitionContext) => void, +): void { + const spec = dashboard.spec; + + for (const [panelKey, panel] of Object.entries(spec.panels ?? {})) { + if (panel?.spec?.plugin) { + visitor(panel.spec.plugin, { pluginType: 'Panel', panelKey }); + } + for (const query of panel?.spec?.queries ?? []) { + // For a query definition, `query.kind` is the query plugin type (e.g. 'TimeSeriesQuery'). + if (query?.spec?.plugin && query.kind) { + visitor(query.spec.plugin, { pluginType: query.kind, panelKey }); + } + } + } + + // Only list variables reference a plugin. + for (const variable of spec.variables ?? []) { + if (variable?.kind === 'ListVariable' && variable.spec?.plugin) { + visitor(variable.spec.plugin, { pluginType: 'Variable' }); + } + } + + for (const datasource of Object.values(spec.datasources ?? {})) { + if (datasource?.plugin) { + visitor(datasource.plugin, { pluginType: 'Datasource' }); + } + } + + for (const annotation of spec.annotations ?? []) { + if (annotation?.plugin) { + visitor(annotation.plugin, { pluginType: 'Annotation' }); + } + } +} + +/** Returns the identity of a plugin definition found at the given place in the dashboard spec. */ +function getDefinitionIdentity(definition: Definition, context: PluginDefinitionContext): PluginIdentity { + return { pluginType: context.pluginType, kind: definition.kind, registry: definition.metadata?.registry }; +} + +/** + * Returns the exact version a definition is pinned to, or `undefined` when it floats on the latest available version. + * The `latest` sentinel is explicitly not a pin: the plugin registry resolves it dynamically. + */ +function getPinnedVersion(definition: Definition): string | undefined { + const version = definition.metadata?.version; + return version && version !== LATEST_PLUGIN_VERSION ? version : undefined; +} + +/** + * Extract the version associated with a piece of plugin metadata, preferring the plugin-level version and falling back + * to the containing module's version. + */ +function getMetadataVersion(metadata: PluginMetadataWithModule): string | undefined { + return metadata.metadata?.version ?? metadata.module?.version; +} + +/** Extract the registry a piece of plugin metadata comes from, if any. */ +function getMetadataRegistry(metadata: PluginMetadataWithModule): string | undefined { + return metadata.metadata?.registry ?? metadata.module?.registry; +} + +/** + * The latest version available in the instance for a given plugin identity. + */ +export type LatestPluginVersions = Map; + +/** + * Build a map of plugin identity (plugin type + kind + registry) to the latest version currently available in the + * instance, based on the installed plugin metadata returned by the plugin registry. + * + * Each identity is indexed twice: once with its registry, and once without it. The registry-less entry is what a + * definition that does not pin a registry resolves to, matching how the plugin registry loads it. + */ +export function buildLatestPluginVersions(pluginMetadata: PluginMetadataWithModule[]): LatestPluginVersions { + const versions: LatestPluginVersions = new Map(); + + const keepLatest = (key: string, version: string): void => { + const existing = versions.get(key); + if (existing === undefined || comparePluginVersions(version, existing) > 0) { + versions.set(key, version); + } + }; + + for (const metadata of pluginMetadata) { + const kind = metadata.spec?.name; + const version = getMetadataVersion(metadata); + if (!kind || !version) { + continue; + } + const registry = getMetadataRegistry(metadata); + // A definition without a pinned registry resolves to the latest version across every registry. + keepLatest(getPluginIdentityKey({ pluginType: metadata.kind, kind }), version); + if (registry) { + keepLatest(getPluginIdentityKey({ pluginType: metadata.kind, kind, registry }), version); + } + } + + return versions; +} + +/** A plugin definition pinned to a version older than the latest one available in the instance. */ +export interface OutdatedPlugin extends PluginIdentity { + /** The version currently pinned in the dashboard spec. */ + currentVersion: string; + /** The latest version available in the instance. */ + latestVersion: string; + /** Number of definitions in the dashboard pinned to the outdated version. */ + occurrences: number; + /** + * Key of the first panel using this plugin. Set for panel plugins and panel query plugins, and used to render a + * before/after preview of a representative panel. + */ + examplePanelKey?: string; +} + +/** + * A stable identity for an outdated plugin entry, usable as a React key or selection key. + */ +export function getOutdatedPluginId(plugin: PluginIdentity & Pick): string { + return `${getPluginIdentityKey(plugin)}:${plugin.currentVersion}`; +} + +/** + * Find every plugin in the dashboard that is pinned to a version older than the latest version available in the + * instance. Definitions without a pinned version are ignored: they already float on the latest version. + */ +export function findOutdatedPlugins( + dashboard: DashboardResource, + latestVersions: LatestPluginVersions, +): OutdatedPlugin[] { + const outdated = new Map(); + + visitPluginDefinitions(dashboard, (definition, context) => { + const currentVersion = getPinnedVersion(definition); + if (!currentVersion) { + return; + } + const identity = getDefinitionIdentity(definition, context); + const latestVersion = latestVersions.get(getPluginIdentityKey(identity)); + if (!latestVersion || comparePluginVersions(latestVersion, currentVersion) <= 0) { + return; + } + + const id = getOutdatedPluginId({ ...identity, currentVersion }); + const existing = outdated.get(id); + if (existing) { + existing.occurrences += 1; + existing.examplePanelKey ??= context.panelKey; + return; + } + outdated.set(id, { + ...identity, + currentVersion, + latestVersion, + occurrences: 1, + examplePanelKey: context.panelKey, + }); + }); + + return [...outdated.values()].toSorted( + (a, b) => a.pluginType.localeCompare(b.pluginType) || a.kind.localeCompare(b.kind), + ); +} + +/** + * Return a copy of the dashboard where only the provided outdated plugins are re-pinned to their latest version. Any + * other plugin definition (including other versions of the same kind) is left untouched. + */ +export function updatePluginVersions(dashboard: DashboardResource, plugins: OutdatedPlugin[]): DashboardResource { + if (plugins.length === 0) { + return dashboard; + } + const targets = new Map(plugins.map((plugin) => [getOutdatedPluginId(plugin), plugin.latestVersion])); + const next = structuredClone(dashboard); + visitPluginDefinitions(next, (definition, context) => { + const currentVersion = getPinnedVersion(definition); + if (!currentVersion) { + return; + } + const id = getOutdatedPluginId({ ...getDefinitionIdentity(definition, context), currentVersion }); + const latestVersion = targets.get(id); + if (latestVersion) { + definition.metadata = { ...definition.metadata, version: latestVersion }; + } + }); + return next; +} + +/** + * Return a copy of the dashboard with every plugin definition pinned to its latest available version. Plugin + * definitions whose identity is not present in the version map are left untouched, which means the dashboard is only + * fully locked if every plugin it uses is installed (see {@link isDashboardLocked}). + */ +export function applyPluginVersions(dashboard: DashboardResource, versions: LatestPluginVersions): DashboardResource { + const next = structuredClone(dashboard); + visitPluginDefinitions(next, (definition, context) => { + const version = versions.get(getPluginIdentityKey(getDefinitionIdentity(definition, context))); + if (version) { + definition.metadata = { ...definition.metadata, version }; + } + }); + return next; +} + +/** + * Return a copy of the dashboard with the pinned version removed from every plugin definition. The `metadata` object is + * dropped entirely when it no longer holds any information. + */ +export function removePluginVersions(dashboard: DashboardResource): DashboardResource { + const next = structuredClone(dashboard); + visitPluginDefinitions(next, (definition) => { + if (definition.metadata === undefined) { + return; + } + const { version: _version, ...rest } = definition.metadata; + if (Object.keys(rest).length === 0) { + delete definition.metadata; + } else { + definition.metadata = rest; + } + }); + return next; +} + +/** + * A dashboard is "locked" when *every* plugin definition it contains is pinned to an exact version, which is the + * invariant the lock action establishes. A dashboard where only some definitions are pinned is versioned partially: the + * remaining plugins still float on the latest version, so it is not locked and the Lock action stays available. + * + * The `latest` sentinel does not count as a pin: the plugin registry resolves it dynamically, so it enforces nothing. + */ +export function isDashboardLocked(dashboard: DashboardResource): boolean { + let total = 0; + let pinned = 0; + visitPluginDefinitions(dashboard, (definition) => { + total += 1; + if (getPinnedVersion(definition)) { + pinned += 1; + } + }); + return total > 0 && pinned === total; +} + +/** + * Whether at least one plugin definition of the dashboard is pinned to an exact version. Versioning can be enforced + * partially, so this is true both for a fully locked dashboard and for one where only a few plugins are pinned. + */ +export function hasPinnedPluginVersions(dashboard: DashboardResource): boolean { + let pinned = false; + visitPluginDefinitions(dashboard, (definition) => { + if (getPinnedVersion(definition)) { + pinned = true; + } + }); + return pinned; +} diff --git a/dashboards/src/views/ViewDashboard/DashboardApp.tsx b/dashboards/src/views/ViewDashboard/DashboardApp.tsx index 686b472b..6c208874 100644 --- a/dashboards/src/views/ViewDashboard/DashboardApp.tsx +++ b/dashboards/src/views/ViewDashboard/DashboardApp.tsx @@ -44,6 +44,11 @@ export interface DashboardAppProps { isDatasourceEnabled: boolean; disableShortcuts?: boolean; isCreating?: boolean; + /** + * When true, offers the dashboard "lock/unlock" button that pins every plugin used by the dashboard to an exact + * version. It only makes the action available, it does not lock anything by itself. Not available by default. + */ + isLockModeAvailable?: boolean; isInitialVariableSticky?: boolean; // If true, browser confirmation dialog will be shown when navigating away with unsaved changes (closing tab, ...). isLeavingConfirmDialogEnabled?: boolean; @@ -73,6 +78,7 @@ const DashboardAppContent = (props: DashboardAppProps): ReactElement => { isCreating, isInitialVariableSticky, isLeavingConfirmDialogEnabled, + isLockModeAvailable, dashboardTitleComponent, userPreferenceTimezone, onSave, @@ -157,6 +163,7 @@ const DashboardAppContent = (props: DashboardAppProps): ReactElement => { isVariableEnabled={isVariableEnabled} isAnnotationEnabled={isAnnotationEnabled} isDatasourceEnabled={isDatasourceEnabled} + isLockModeAvailable={isLockModeAvailable} onEditButtonClick={onEditButtonClick} onCancelButtonClick={onCancelButtonClick} /> diff --git a/dashboards/src/views/ViewDashboard/ViewDashboard.tsx b/dashboards/src/views/ViewDashboard/ViewDashboard.tsx index fa4754f9..099ca4ba 100644 --- a/dashboards/src/views/ViewDashboard/ViewDashboard.tsx +++ b/dashboards/src/views/ViewDashboard/ViewDashboard.tsx @@ -59,6 +59,7 @@ export function ViewDashboard(props: ViewDashboardProps): ReactElement { isCreating, isInitialVariableSticky, isLeavingConfirmDialogEnabled, + isLockModeAvailable, dashboardTitleComponent, onSave, onDiscard, @@ -153,6 +154,7 @@ export function ViewDashboard(props: ViewDashboardProps): ReactElement { isCreating={isCreating} isInitialVariableSticky={isInitialVariableSticky} isLeavingConfirmDialogEnabled={isLeavingConfirmDialogEnabled} + isLockModeAvailable={isLockModeAvailable} dashboardTitleComponent={dashboardTitleComponent} onSave={onSave} onDiscard={onDiscard} diff --git a/plugin-system/src/components/PanelSpecEditor/PanelSpecEditor.tsx b/plugin-system/src/components/PanelSpecEditor/PanelSpecEditor.tsx index 75184175..ee5934aa 100644 --- a/plugin-system/src/components/PanelSpecEditor/PanelSpecEditor.tsx +++ b/plugin-system/src/components/PanelSpecEditor/PanelSpecEditor.tsx @@ -17,7 +17,7 @@ import { forwardRef, ReactElement } from 'react'; import { Control, Controller } from 'react-hook-form'; import { PanelEditorValues, PanelPlugin } from '../../model'; -import { useDataQueriesContext, usePlugin } from '../../runtime'; +import { getPluginOverrides, useDataQueriesContext, usePlugin } from '../../runtime'; import { LayoutEditor, PanelGroup, VariableDefinitionGroup } from '../LayoutEditor'; import { LinksEditor } from '../LinksEditor'; import { MultiQueryEditor } from '../MultiQueryEditor'; @@ -50,7 +50,11 @@ export const PanelSpecEditor = forwardRef onJSONChange, } = props; const { kind } = panelDefinition.spec.plugin; - const { data: plugin, isLoading, error } = usePlugin('Panel', kind); + const { + data: plugin, + isLoading, + error, + } = usePlugin('Panel', kind, undefined, getPluginOverrides(panelDefinition.spec.plugin)); const { queryResults } = useDataQueriesContext(); diff --git a/plugin-system/src/components/PluginEditor/plugin-editor-api.ts b/plugin-system/src/components/PluginEditor/plugin-editor-api.ts index 6e5851ea..94b1df3c 100644 --- a/plugin-system/src/components/PluginEditor/plugin-editor-api.ts +++ b/plugin-system/src/components/PluginEditor/plugin-editor-api.ts @@ -12,12 +12,12 @@ // limitations under the License. import { BoxProps } from '@mui/material'; -import { DatasourceSpec, UnknownSpec } from '@perses-dev/spec'; +import { DatasourceSpec, PluginDefinitionMetadata, UnknownSpec } from '@perses-dev/spec'; import { produce } from 'immer'; import { useState, useRef, useEffect } from 'react'; import { PanelPlugin, PluginType } from '../../model'; -import { usePlugin, usePluginRegistry } from '../../runtime'; +import { getPluginOverrides, usePlugin, usePluginRegistry } from '../../runtime'; import { useEvent } from '../../utils'; import { PluginKindSelectProps } from '../PluginKindSelect'; import { PluginSpecEditorProps } from '../PluginSpecEditor'; @@ -25,6 +25,12 @@ import { PluginSpecEditorProps } from '../PluginSpecEditor'; export interface PluginEditorSelection { type: PluginType; kind: string; + /** + * Optional plugin definition metadata (version and/or registry), matching the `metadata` field of a spec + * `Definition`. Only set when the user explicitly picks a specific version/registry of a plugin that has several of + * them available. When omitted, the latest available version is used. + */ + metadata?: PluginDefinitionMetadata; } export interface PluginEditorValue { @@ -124,7 +130,13 @@ export function usePluginEditor(props: UsePluginEditorProps): { } }, [value.selection, defaultPluginKind]); - const { data: plugin, isFetching, error } = usePlugin(pendingSelection?.type, pendingSelection?.kind || ''); + // Load the pending plugin honoring the pinned version/registry, so the initial options come from the exact + // implementation the definition will use rather than from the latest one. + const { + data: plugin, + isFetching, + error, + } = usePlugin(pendingSelection?.type, pendingSelection?.kind || '', undefined, getPluginOverrides(pendingSelection)); useEffect(() => { // Nothing to do if no new plugin kind is pending diff --git a/plugin-system/src/components/PluginKindSelect/PluginKindSelect.tsx b/plugin-system/src/components/PluginKindSelect/PluginKindSelect.tsx index 16697f4f..7193543b 100644 --- a/plugin-system/src/components/PluginKindSelect/PluginKindSelect.tsx +++ b/plugin-system/src/components/PluginKindSelect/PluginKindSelect.tsx @@ -12,10 +12,12 @@ // limitations under the License. import { MenuItem, TextField, TextFieldProps } from '@mui/material'; +import { PluginDefinitionMetadata } from '@perses-dev/spec'; import { forwardRef, ReactElement, useCallback, useMemo } from 'react'; -import { PluginType } from '../../model'; +import { PluginType, PluginMetadataWithModule } from '../../model'; import { useListPluginMetadata } from '../../runtime'; +import { comparePluginVersions } from '../../utils'; import { PluginEditorSelection } from '../PluginEditor'; export interface PluginKindSelectProps extends Omit { @@ -23,6 +25,94 @@ export interface PluginKindSelectProps extends Omit void; + /** + * When true, a plugin that has more than one version available is listed once per version, labeled + * ` - `. Selecting such an option sets `metadata.version` on the selection so it can be + * persisted on the definition. A plugin with a single available version is listed without a version, so it keeps + * resolving to the latest one. Defaults to false. + */ + enableVersionSelection?: boolean; + /** + * When true, a plugin that is available in more than one registry is listed once per registry, labeled + * ` ()`. Selecting such an option sets `metadata.registry` on the selection. A plugin + * available in a single registry is listed without it. Defaults to false. + */ + enableRegistrySelection?: boolean; +} + +/** A plugin kind grouped with all of the variants it is installed under. */ +interface PluginKindGroup { + type: PluginType; + kind: string; + displayName: string; + /** Available variants, sorted from the newest version to the oldest. */ + variants: PluginDefinitionMetadata[]; + hasMultipleVersions: boolean; + hasMultipleRegistries: boolean; +} + +/** A selectable entry of the select input. */ +interface PluginKindOption { + selection: PluginEditorSelection; + label: string; + /** Stringified `selection`, used as the MUI Select option value. */ + value: string; +} + +function getVariant(metadata: PluginMetadataWithModule): PluginDefinitionMetadata { + return { + version: metadata.metadata?.version ?? metadata.module?.version, + registry: metadata.metadata?.registry ?? metadata.module?.registry, + }; +} + +function getVariantKey(variant: PluginDefinitionMetadata): string { + return `${variant.version ?? ''}:${variant.registry ?? ''}`; +} + +/** + * Build the selectable entries of a plugin kind. A version (resp. registry) is only part of the entries when the caller + * enabled its selection *and* the plugin is actually installed in more than one version (resp. registry): there is + * nothing to pick otherwise, and leaving it out keeps the definition floating on the latest version. + */ +function getGroupOptions( + group: PluginKindGroup, + enableVersionSelection: boolean, + enableRegistrySelection: boolean, +): PluginKindOption[] { + const showVersion = enableVersionSelection && group.hasMultipleVersions; + const showRegistry = enableRegistrySelection && group.hasMultipleRegistries; + + if (!showVersion && !showRegistry) { + const selection: PluginEditorSelection = { type: group.type, kind: group.kind }; + return [{ selection, label: group.displayName, value: selectionToOptionValue(selection) }]; + } + + const options: PluginKindOption[] = []; + const seen = new Set(); + for (const variant of group.variants) { + const version = showVersion ? variant.version : undefined; + const registry = showRegistry ? variant.registry : undefined; + const metadata: PluginDefinitionMetadata = { + ...(version ? { version } : {}), + ...(registry ? { registry } : {}), + }; + // Variants that only differ on a field we don't display collapse into a single entry. + const key = getVariantKey({ version, registry }); + if (seen.has(key)) { + continue; + } + seen.add(key); + + const selection: PluginEditorSelection = { + type: group.type, + kind: group.kind, + ...(version || registry ? { metadata } : {}), + }; + const label = `${group.displayName}${version ? ` - ${version}` : ''}${registry ? ` (${registry})` : ''}`; + options.push({ selection, label, value: selectionToOptionValue(selection) }); + } + return options; } /** @@ -33,21 +123,77 @@ export interface PluginKindSelectProps extends Omit { - const { pluginTypes, value: propValue, onChange, filteredQueryPlugins, ...others } = props; + const { + pluginTypes, + value: propValue, + onChange, + filteredQueryPlugins, + enableVersionSelection = false, + enableRegistrySelection = false, + ...others + } = props; const { data, isLoading } = useListPluginMetadata(pluginTypes); const sortedData = useMemo(() => { - if (filteredQueryPlugins?.length) { - return data - ?.filter((i) => filteredQueryPlugins.includes(i.spec.name)) - ?.sort((a, b) => a.spec.display.name.localeCompare(b.spec.display.name)); + const filtered = filteredQueryPlugins?.length + ? data?.filter((i) => filteredQueryPlugins.includes(i.spec.name)) + : data; + return filtered?.toSorted((a, b) => a.spec.display.name.localeCompare(b.spec.display.name)); + }, [data, filteredQueryPlugins]); + + // Group the metadata by plugin kind, collecting all the variants each one is installed under (newest version first). + const kindGroups = useMemo(() => { + const groups = new Map(); + for (const metadata of sortedData ?? []) { + const key = `${metadata.kind}:${metadata.spec.name}`; + let group = groups.get(key); + if (group === undefined) { + group = { + type: metadata.kind, + kind: metadata.spec.name, + displayName: metadata.spec.display.name, + variants: [], + hasMultipleVersions: false, + hasMultipleRegistries: false, + }; + groups.set(key, group); + } + const variant = getVariant(metadata); + if (!group.variants.some((existing) => getVariantKey(existing) === getVariantKey(variant))) { + group.variants.push(variant); + } } + for (const group of groups.values()) { + group.variants = group.variants.toSorted((a, b) => comparePluginVersions(b.version ?? '', a.version ?? '')); + group.hasMultipleVersions = new Set(group.variants.map((v) => v.version ?? '')).size > 1; + group.hasMultipleRegistries = new Set(group.variants.map((v) => v.registry ?? '')).size > 1; + } + return [...groups.values()]; + }, [sortedData]); - return data?.sort((a, b) => a.spec.display.name.localeCompare(b.spec.display.name)); - }, [data, filteredQueryPlugins]); + const options = useMemo( + () => kindGroups.flatMap((group) => getGroupOptions(group, enableVersionSelection, enableRegistrySelection)), + [kindGroups, enableVersionSelection, enableRegistrySelection], + ); + + const labelsByValue = useMemo(() => new Map(options.map((option) => [option.value, option.label])), [options]); // Pass an empty value while options are still loading so MUI doesn't complain about us using an "out of range" value - const value = !propValue || isLoading ? '' : selectionToOptionValue(propValue); + const value = useMemo(() => { + if (!propValue || isLoading) { + return ''; + } + const optionValue = selectionToOptionValue(propValue); + if (labelsByValue.has(optionValue)) { + return optionValue; + } + // The definition is not pinned (or is pinned to something we don't list): fall back to the first entry of that + // plugin kind, which is the one that will actually be used, so the Select has a matching value. + const fallback = options.find( + (option) => option.selection.type === propValue.type && option.selection.kind === propValue.kind, + ); + return fallback?.value ?? optionValue; + }, [propValue, isLoading, labelsByValue, options]); const handleChange = (event: { target: { value: string } }): void => { onChange?.(optionValueToSelection(event.target.value)); @@ -58,11 +204,16 @@ export const PluginKindSelect = forwardRef((props: PluginKindSelectProps, ref): if (selected === '') { return ''; } - const selectedValue = optionValueToSelection(selected as string); - return sortedData?.find((v) => v.kind === selectedValue.type && v.spec.name === selectedValue.kind)?.spec.display - .name; + const optionValue = selected as string; + const label = labelsByValue.get(optionValue); + if (label !== undefined) { + return label; + } + const selectedValue = optionValueToSelection(optionValue); + return kindGroups.find((group) => group.type === selectedValue.type && group.kind === selectedValue.kind) + ?.displayName; }, - [sortedData], + [labelsByValue, kindGroups], ); // TODO: Does this need a loading indicator of some kind? @@ -78,13 +229,9 @@ export const PluginKindSelect = forwardRef((props: PluginKindSelectProps, ref): data-testid="plugin-kind-select" > {isLoading && Loading...} - {sortedData?.map((metadata) => ( - - {metadata.spec.display.name} + {options.map((option) => ( + + {option.label} ))} @@ -96,28 +243,44 @@ PluginKindSelect.displayName = 'PluginKindSelect'; const OPTION_VALUE_DELIMITER = '_____'; /** - * Given a PluginEditorSelection, - * returns a string value like `{type}_____{kind}` that can be used as a Select input value. + * Given a PluginEditorSelection, returns a string value like `{type}_____{kind}` that can be used as a Select input + * value. A pinned version and/or registry is appended as `{type}_____{kind}_____{version}_____{registry}`, with empty + * segments for the parts that are not pinned. * @param selector */ function selectionToOptionValue(selector: PluginEditorSelection): string { - return [selector.type, selector.kind].join(OPTION_VALUE_DELIMITER); + const { version, registry } = selector.metadata ?? {}; + const parts = [selector.type, selector.kind]; + if (version || registry) { + parts.push(version ?? ''); + } + if (registry) { + parts.push(registry); + } + return parts.join(OPTION_VALUE_DELIMITER); } /** - * Given an option value name like `{type}_____{kind}`, - * returns a PluginEditorSelection to be used by the query data model. + * Given an option value name like `{type}_____{kind}` or `{type}_____{kind}_____{version}_____{registry}`, returns a + * PluginEditorSelection to be used by the query data model. * @param optionValue */ function optionValueToSelection(optionValue: string): PluginEditorSelection { const words = optionValue.split(OPTION_VALUE_DELIMITER); const type = words[0] as PluginType | undefined; const kind = words[1]; + const version = words[2]; + const registry = words[3]; if (type === undefined || kind === undefined) { throw new Error('Invalid optionValue string'); } + const metadata: PluginDefinitionMetadata = { + ...(version ? { version } : {}), + ...(registry ? { registry } : {}), + }; return { type, kind, + ...(version || registry ? { metadata } : {}), }; } diff --git a/plugin-system/src/components/PluginKindSelect/PluginKindSelect.versions.test.tsx b/plugin-system/src/components/PluginKindSelect/PluginKindSelect.versions.test.tsx new file mode 100644 index 00000000..954d429f --- /dev/null +++ b/plugin-system/src/components/PluginKindSelect/PluginKindSelect.versions.test.tsx @@ -0,0 +1,169 @@ +// Copyright The Perses Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +import { dynamicImportPluginLoader, PluginModuleResource } from '../../model'; +import { PluginEditorSelection } from '../PluginEditor'; +import { PluginRegistry } from '../PluginRegistry'; +import { PluginKindSelect, PluginKindSelectProps } from './PluginKindSelect'; + +/** A plugin module exposing a single Panel plugin, installed under the given version/registry. */ +function buildResource(pluginName: string, version: string, registry?: string): PluginModuleResource { + return { + kind: 'PluginModule', + metadata: { name: `${pluginName}-${registry ?? 'default'}-${version}`, version, registry }, + spec: { + plugins: [{ kind: 'Panel', spec: { name: pluginName, display: { name: pluginName } } }], + }, + }; +} + +// `Multi` is installed in three versions, `Single` in only one, and `Registries` once per registry. +const RESOURCES: PluginModuleResource[] = [ + buildResource('Multi', '1.0.0'), + buildResource('Multi', '2.0.0'), + buildResource('Multi', '1.10.0'), + buildResource('Single', '1.0.0'), + buildResource('Registries', '1.0.0', 'alpha'), + buildResource('Registries', '2.0.0', 'beta'), +]; + +function renderSelect(props: Omit): void { + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + const pluginLoader = dynamicImportPluginLoader( + RESOURCES.map((resource) => ({ + resource, + // The select only needs the metadata, never the implementation. + importPlugin: (): Promise> => Promise.resolve({}), + })), + ); + render( + + + + + , + ); +} + +/** Opens the select and waits for the options to be loaded, returning their labels in display order. */ +async function openSelect(): Promise { + userEvent.click(screen.getByRole('combobox')); + const options = await screen.findAllByTestId('option'); + return options.map((option) => option.textContent ?? ''); +} + +describe('PluginKindSelect version and registry selection', () => { + it('lists a single entry per plugin kind by default, even when several versions are installed', async () => { + renderSelect({ value: undefined }); + + const labels = await openSelect(); + // One entry per kind, de-duplicated: no version suffix and no duplicated option. + expect(labels).toEqual(['Multi', 'Registries', 'Single']); + }); + + it('lists one entry per version, newest first, when version selection is enabled', async () => { + renderSelect({ value: undefined, enableVersionSelection: true }); + + const labels = await openSelect(); + // `Multi` has several versions so each one is selectable, ordered with semver (1.10.0 sorts above 1.0.0, which a + // lexicographic comparison would get wrong). `Single` has one version only, so it stays version-less and keeps + // floating on the latest. + expect(labels).toEqual([ + 'Multi - 2.0.0', + 'Multi - 1.10.0', + 'Multi - 1.0.0', + 'Registries - 2.0.0', + 'Registries - 1.0.0', + 'Single', + ]); + }); + + it('emits the selected version as definition metadata', async () => { + let selection: PluginEditorSelection | undefined = undefined; + renderSelect({ value: undefined, enableVersionSelection: true, onChange: (s) => (selection = s) }); + + await openSelect(); + userEvent.click(screen.getByRole('option', { name: 'Multi - 1.0.0' })); + + expect(selection).toStrictEqual({ type: 'Panel', kind: 'Multi', metadata: { version: '1.0.0' } }); + }); + + it('does not pin anything when the plugin only has one version', async () => { + let selection: PluginEditorSelection | undefined = undefined; + renderSelect({ value: undefined, enableVersionSelection: true, onChange: (s) => (selection = s) }); + + await openSelect(); + userEvent.click(screen.getByRole('option', { name: 'Single' })); + + expect(selection).toStrictEqual({ type: 'Panel', kind: 'Single' }); + }); + + it('shows the version an existing definition is pinned to', async () => { + renderSelect({ + value: { type: 'Panel', kind: 'Multi', metadata: { version: '1.0.0' } }, + enableVersionSelection: true, + }); + + expect(await screen.findByText('Multi - 1.0.0')).toBeInTheDocument(); + }); + + it('shows the version that will actually be used when the definition is not pinned', async () => { + renderSelect({ value: { type: 'Panel', kind: 'Multi' }, enableVersionSelection: true }); + + // Unpinned means "latest", so the newest version is displayed rather than an out-of-range empty value. + expect(await screen.findByText('Multi - 2.0.0')).toBeInTheDocument(); + }); + + it('keeps a pin the select does not list rather than dropping it silently', async () => { + let selection: PluginEditorSelection | undefined = undefined; + renderSelect({ + // Version selection is disabled, so no version option exists, yet the definition is pinned. + value: { type: 'Panel', kind: 'Multi', metadata: { version: '1.0.0' } }, + onChange: (s) => (selection = s), + }); + + // The displayed value falls back to the plugin kind, and nothing changes until the user picks another option. + expect(await screen.findByText('Multi')).toBeInTheDocument(); + expect(selection).toBeUndefined(); + }); + + it('lists one entry per registry, and emits it, when registry selection is enabled', async () => { + let selection: PluginEditorSelection | undefined = undefined; + renderSelect({ value: undefined, enableRegistrySelection: true, onChange: (s) => (selection = s) }); + + const labels = await openSelect(); + // Only `Registries` is available in more than one registry, so it is the only kind listed per registry. + expect(labels).toEqual(['Multi', 'Registries (beta)', 'Registries (alpha)', 'Single']); + + userEvent.click(screen.getByRole('option', { name: 'Registries (alpha)' })); + expect(selection).toStrictEqual({ type: 'Panel', kind: 'Registries', metadata: { registry: 'alpha' } }); + }); + + it('combines version and registry when both selections are enabled', async () => { + renderSelect({ value: undefined, enableVersionSelection: true, enableRegistrySelection: true }); + + const labels = await openSelect(); + expect(labels).toEqual([ + 'Multi - 2.0.0', + 'Multi - 1.10.0', + 'Multi - 1.0.0', + 'Registries - 2.0.0 (beta)', + 'Registries - 1.0.0 (alpha)', + 'Single', + ]); + }); +}); diff --git a/plugin-system/src/components/PluginRegistry/PluginRegistry.dev.test.tsx b/plugin-system/src/components/PluginRegistry/PluginRegistry.dev.test.tsx new file mode 100644 index 00000000..dffb827b --- /dev/null +++ b/plugin-system/src/components/PluginRegistry/PluginRegistry.dev.test.tsx @@ -0,0 +1,87 @@ +// Copyright The Perses Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { render, screen } from '@testing-library/react'; +import { ReactElement, ReactNode } from 'react'; + +import { dynamicImportPluginLoader, PluginModuleResource } from '../../model'; +import { usePlugin } from '../../runtime'; +import { PluginRegistry } from './PluginRegistry'; + +const PLUGIN_NAME = 'TestVariable'; + +/** Builds a plugin module resource exposing a single Variable plugin, tagged as dev or installed. */ +function buildResource(version: string, inDev: boolean): PluginModuleResource { + return { + kind: 'PluginModule', + metadata: { name: `Module-${version}`, version }, + ...(inDev ? { status: { isLoaded: true, inDev: true } } : {}), + spec: { + plugins: [ + { + kind: 'Variable', + spec: { name: PLUGIN_NAME, display: { name: PLUGIN_NAME } }, + }, + ], + }, + }; +} + +/** The plugin implementation carries a marker so tests can tell which module was loaded. */ +function buildModule(source: string): Record { + return { [PLUGIN_NAME]: { createInitialOptions: () => ({}), source } }; +} + +// A dev plugin on an OLDER version than the installed one: this is the `percli plugin start` case where the +// plugin's package.json version is behind the installed archives. +const devResource = buildResource('1.0.0', true); +const installedResource = buildResource('2.0.0', false); + +function renderWithLoader(children: ReactNode): void { + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + const pluginLoader = dynamicImportPluginLoader([ + { + resource: installedResource, + importPlugin: (): Promise> => Promise.resolve(buildModule('installed')), + }, + { + resource: devResource, + importPlugin: (): Promise> => Promise.resolve(buildModule('dev')), + }, + ]); + render( + + {children} + , + ); +} + +function Consumer({ version }: { version?: string }): ReactElement { + const { data, isLoading, error } = usePlugin('Variable', PLUGIN_NAME, undefined, version ? { version } : undefined); + if (isLoading) return
loading
; + if (error) return
error: {error.message}
; + return
source: {(data as unknown as { source?: string })?.source}
; +} + +describe('PluginRegistry dev plugin precedence', () => { + it('prefers a plugin served in dev over a newer installed one when no version is pinned', async () => { + renderWithLoader(); + expect(await screen.findByText('source: dev', undefined, { timeout: 3000 })).toBeInTheDocument(); + }); + + it('still honors an explicitly pinned version instead of the dev plugin', async () => { + renderWithLoader(); + expect(await screen.findByText('source: installed', undefined, { timeout: 3000 })).toBeInTheDocument(); + }); +}); diff --git a/plugin-system/src/components/PluginRegistry/PluginRegistry.tsx b/plugin-system/src/components/PluginRegistry/PluginRegistry.tsx index eee0abfe..ad9a3829 100644 --- a/plugin-system/src/components/PluginRegistry/PluginRegistry.tsx +++ b/plugin-system/src/components/PluginRegistry/PluginRegistry.tsx @@ -23,7 +23,7 @@ import { DefaultPluginKinds, } from '../../model'; import { PluginRegistryContext } from '../../runtime'; -import { useEvent } from '../../utils'; +import { comparePluginVersions, useEvent } from '../../utils'; import { resolvePluginKeys } from './getPluginSearchHelper'; import { usePluginIndexes, PluginCompoundKey } from './plugin-indexes'; @@ -33,6 +33,47 @@ export interface PluginRegistryProps { children?: ReactNode; } +/** + * Returns the indexed key of a plugin served by a local dev server for the given plugin type and kind, if any. + * Keys are `${kind}:${name}:${registry}:${version}`, so we match on the `kind:name:` prefix. + */ +function findDevPluginKey(devPluginKeys: Set, kind: string, name: string): string | undefined { + const prefix = `${kind}:${name}:`; + for (const key of devPluginKeys) { + if (key.startsWith(prefix)) { + return key; + } + } + return undefined; +} + +/** + * Returns the indexed keys (`${kind}:${name}:${registry}:${version}`) matching *every* field supplied in the query. + * `kind` and `name` are always compared; `registry` and `version` are only compared when they are set, so a + * version-only pin matches whatever registry the plugin happens to be installed under, and a registry-only pin never + * leaks into another registry. Results are ordered from the newest version to the oldest. + */ +function findMatchingPluginKeys( + allKeys: Iterable, + query: PluginCompoundKey, +): string[] { + const { kind, name, registry, version } = query; + const prefix = `${kind}:${name}:`; + const matches: Array<{ key: string; version: string }> = []; + + for (const key of allKeys) { + if (!key.startsWith(prefix)) continue; + const parts = key.split(':'); + if (parts.length !== 4) continue; + const [, , keyRegistry, keyVersion] = parts; + if (registry !== undefined && keyRegistry !== registry) continue; + if (version !== undefined && keyVersion !== version) continue; + matches.push({ key, version: keyVersion ?? '' }); + } + + return matches.toSorted((a, b) => comparePluginVersions(b.version, a.version)).map((match) => match.key); +} + /** * PluginRegistryContext provider that keeps track of all available plugins and provides an API for getting them or * querying the metadata about them. @@ -66,12 +107,24 @@ export function PluginRegistry(props: PluginRegistryProps): ReactElement { const getPlugin = useCallback( async (compoundKeyObj: PluginCompoundKey): Promise> => { const pluginIndexes = await getPluginIndexes(); - const { kind, name } = compoundKeyObj; + const { kind, name, version, registry } = compoundKeyObj; + const allKeys = pluginIndexes.pluginResourcesByNameKindRegistryVersion.keys(); - const candidateKeys = resolvePluginKeys( - pluginIndexes.pluginResourcesByNameKindRegistryVersion.keys(), - compoundKeyObj, - ); + let candidateKeys: string[]; + if (version || registry) { + // A pin is an exact constraint: only the plugins matching every supplied field are acceptable, and we never + // silently fall back to another version or another registry. + candidateKeys = findMatchingPluginKeys(allKeys, compoundKeyObj); + } else { + candidateKeys = resolvePluginKeys(allKeys, compoundKeyObj); + // Nothing pinned: a plugin served by a local dev server (`percli plugin start`) wins over installed archives, + // whatever their versions. Otherwise a dev plugin whose package version is lower than an installed archive would + // never be used, which defeats the purpose of running it in dev. + const devKey = findDevPluginKey(pluginIndexes.devPluginKeys, kind, name); + if (devKey) { + candidateKeys = [devKey, ...candidateKeys.filter((key) => key !== devKey)]; + } + } for (const resourceKey of candidateKeys) { const resource = pluginIndexes.pluginResourcesByNameKindRegistryVersion.get(resourceKey); @@ -86,14 +139,26 @@ export function PluginRegistry(props: PluginRegistryProps): ReactElement { if (versionlessPlugin) return versionlessPlugin as PluginImplementation; } - throw new Error(`A ${name} plugin for kind '${kind}' is not installed`); + const pins = [ + version ? `version '${version}'` : undefined, + registry ? `registry '${registry}'` : undefined, + ].filter((pin) => pin !== undefined); + throw new Error( + pins.length > 0 + ? `A ${name} plugin for kind '${kind}' with ${pins.join(' and ')} is not installed` + : `A ${name} plugin for kind '${kind}' is not installed`, + ); }, [getPluginIndexes, loadPluginModule], ); const listPluginMetadata = useCallback( - async (pluginTypes: PluginType[]) => { + async (pluginTypes?: PluginType[]) => { const pluginIndexes = await getPluginIndexes(); + if (pluginTypes === undefined) { + // No filter: return the metadata of every installed plugin, whatever its type. + return [...pluginIndexes.pluginMetadataByKind.values()].flat(); + } return pluginTypes.flatMap((type) => pluginIndexes.pluginMetadataByKind.get(type) ?? []); }, [getPluginIndexes], diff --git a/plugin-system/src/components/PluginRegistry/PluginRegistry.versions.test.tsx b/plugin-system/src/components/PluginRegistry/PluginRegistry.versions.test.tsx new file mode 100644 index 00000000..fe3eea95 --- /dev/null +++ b/plugin-system/src/components/PluginRegistry/PluginRegistry.versions.test.tsx @@ -0,0 +1,91 @@ +// Copyright The Perses Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { render, screen } from '@testing-library/react'; +import { ReactElement, ReactNode } from 'react'; + +import { dynamicImportPluginLoader, PluginModuleResource } from '../../model'; +import { usePlugin } from '../../runtime'; +import { PluginRegistry } from './PluginRegistry'; + +const PLUGIN_NAME = 'TestVariable'; + +/** A plugin module exposing a single Variable plugin, installed under the given version/registry. */ +function buildResource(version: string, registry?: string): PluginModuleResource { + return { + kind: 'PluginModule', + metadata: { name: `Module-${registry ?? 'default'}-${version}`, version, registry }, + spec: { + plugins: [{ kind: 'Variable', spec: { name: PLUGIN_NAME, display: { name: PLUGIN_NAME } } }], + }, + }; +} + +/** The plugin implementation carries a marker so tests can tell which module was loaded. */ +function buildModule(source: string): Record { + return { [PLUGIN_NAME]: { createInitialOptions: () => ({}), source } }; +} + +function renderConsumer(children: ReactNode, resources: Array<[PluginModuleResource, string]>): void { + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + const pluginLoader = dynamicImportPluginLoader( + resources.map(([resource, source]) => ({ + resource, + importPlugin: (): Promise> => Promise.resolve(buildModule(source)), + })), + ); + render( + + {children} + , + ); +} + +function Consumer({ version, registry }: { version?: string; registry?: string }): ReactElement { + const { data, isLoading, error } = usePlugin('Variable', PLUGIN_NAME, undefined, { version, registry }); + if (isLoading) return
loading
; + if (error) return
error: {error.message}
; + return
source: {(data as unknown as { source?: string })?.source}
; +} + +describe('PluginRegistry version and registry pinning', () => { + it('resolves a version-only pin even when the plugin is installed under a named registry', async () => { + // A version-only pin is what the panel editor produces. The plugin only exists in the `corp` registry, so building + // a synthetic registry-less key would make it look missing. + renderConsumer(, [ + [buildResource('1.0.0', 'corp'), 'corp-1.0.0'], + [buildResource('2.0.0', 'corp'), 'corp-2.0.0'], + ]); + expect(await screen.findByText('source: corp-1.0.0', undefined, { timeout: 3000 })).toBeInTheDocument(); + }); + + it('never falls back to another version when a version is pinned', async () => { + renderConsumer(, [[buildResource('1.0.0'), 'v1']]); + expect(await screen.findByText(/^error:/, undefined, { timeout: 3000 })).toHaveTextContent("version '3.0.0'"); + }); + + it('never falls back to another registry when a registry is pinned', async () => { + renderConsumer(, [[buildResource('1.0.0', 'community'), 'community']]); + expect(await screen.findByText(/^error:/, undefined, { timeout: 3000 })).toHaveTextContent("registry 'corp'"); + }); + + it('resolves the latest version inside the pinned registry', async () => { + renderConsumer(, [ + [buildResource('1.0.0', 'corp'), 'corp-1.0.0'], + [buildResource('2.0.0', 'corp'), 'corp-2.0.0'], + [buildResource('9.0.0', 'community'), 'community-9.0.0'], + ]); + expect(await screen.findByText('source: corp-2.0.0', undefined, { timeout: 3000 })).toBeInTheDocument(); + }); +}); diff --git a/plugin-system/src/components/PluginRegistry/plugin-indexes.ts b/plugin-system/src/components/PluginRegistry/plugin-indexes.ts index f2f8b698..c11196b1 100644 --- a/plugin-system/src/components/PluginRegistry/plugin-indexes.ts +++ b/plugin-system/src/components/PluginRegistry/plugin-indexes.ts @@ -31,6 +31,8 @@ export interface PluginIndexes { pluginResourcesByNameKindRegistryVersion: Map; // Plugin metadata by plugin type pluginMetadataByKind: Map; + // Subset of the keys above that are served by a local dev server (`percli plugin start`) + devPluginKeys: Set; } /** @@ -47,6 +49,7 @@ export function usePluginIndexes( // Create the two indexes from the installed plugins const pluginResourcesByNameKindRegistryVersion = new Map(); const pluginMetadataByKind = new Map(); + const devPluginKeys = new Set(); for (const resource of installedPlugins) { const { @@ -65,6 +68,9 @@ export function usePluginIndexes( ); } pluginResourcesByNameKindRegistryVersion.set(key, resource); + if (resource.status?.inDev) { + devPluginKeys.add(key); + } // Index the metadata by plugin type let list = pluginMetadataByKind.get(kind); @@ -79,6 +85,7 @@ export function usePluginIndexes( return { pluginResourcesByNameKindRegistryVersion, pluginMetadataByKind, + devPluginKeys, }; }); diff --git a/plugin-system/src/components/PluginSpecEditor/PluginSpecEditor.tsx b/plugin-system/src/components/PluginSpecEditor/PluginSpecEditor.tsx index 354fe725..8d14d65a 100644 --- a/plugin-system/src/components/PluginSpecEditor/PluginSpecEditor.tsx +++ b/plugin-system/src/components/PluginSpecEditor/PluginSpecEditor.tsx @@ -17,7 +17,7 @@ import { DatasourceSpec, UnknownSpec } from '@perses-dev/spec'; import { ReactElement } from 'react'; import { DatasourcePlugin, OptionsEditorProps, Plugin, PluginType } from '../../model'; -import { usePlugin } from '../../runtime'; +import { getPluginOverrides, usePlugin } from '../../runtime'; import { PluginEditorSelection } from '../PluginEditor'; import { DatasourceSpecEditor } from './DatasourceSpecEditor'; @@ -36,12 +36,17 @@ function isDatasourcePlugin( export function PluginSpecEditor(props: PluginSpecEditorProps): ReactElement | null { const { - pluginSelection: { type: pluginType, kind: pluginKind }, + pluginSelection: { type: pluginType, kind: pluginKind, metadata: pluginMetadata }, value, testConnection, ...others } = props; - const { data: plugin, isLoading, error } = usePlugin(pluginType, pluginKind); + // Edit the exact implementation the definition is pinned to, so the options editor matches the saved spec schema. + const { + data: plugin, + isLoading, + error, + } = usePlugin(pluginType, pluginKind, undefined, getPluginOverrides({ metadata: pluginMetadata })); if (error) { return ; diff --git a/plugin-system/src/components/Variables/variable-model.ts b/plugin-system/src/components/Variables/variable-model.ts index aed24688..cde7aefc 100644 --- a/plugin-system/src/components/Variables/variable-model.ts +++ b/plugin-system/src/components/Variables/variable-model.ts @@ -21,6 +21,7 @@ import { useDatasourceStore, usePlugin, usePlugins, + getPluginOverrides, useTimeRange, VariableStateMap, } from '../../runtime'; @@ -95,7 +96,12 @@ function resolveDependsOnVariables( } export function useListVariablePluginValues(definition: ListVariableDefinition): UseQueryResult { - const { data: variablePlugin } = usePlugin('Variable', definition.spec.plugin.kind); + const { data: variablePlugin } = usePlugin( + 'Variable', + definition.spec.plugin.kind, + undefined, + getPluginOverrides(definition.spec.plugin), + ); const variablePluginCtx = useVariablePluginContext(); @@ -138,7 +144,7 @@ export function useResolveListVariableValues(variableDefinitions: VariableDefini const pluginResults = usePlugins( 'Variable', - listVariables.map((d) => ({ kind: d.spec.plugin.kind })), + listVariables.map((d) => ({ kind: d.spec.plugin.kind, ...getPluginOverrides(d.spec.plugin) })), ); // Resolved variable state. Updated by onFetched when queries resolve. diff --git a/plugin-system/src/model/plugins.ts b/plugin-system/src/model/plugins.ts index 42f8014d..661083ff 100644 --- a/plugin-system/src/model/plugins.ts +++ b/plugin-system/src/model/plugins.ts @@ -61,12 +61,24 @@ export interface PluginModuleMetadata { registry?: string; } +/** + * Status of a module/package that contains plugins, as reported by the Perses server. + */ +export interface PluginModuleStatus { + isLoaded?: boolean; + /** + * True when the module is served by a local dev server (`percli plugin start`) instead of an installed archive. + */ + inDev?: boolean; +} + /** * Information about a module/package that contains plugins. */ export interface PluginModuleResource { kind: 'PluginModule'; metadata: PluginModuleMetadata; + status?: PluginModuleStatus; spec: PluginModuleSpec; } diff --git a/plugin-system/src/runtime/alerts-queries.ts b/plugin-system/src/runtime/alerts-queries.ts index c4a13a07..623c204c 100644 --- a/plugin-system/src/runtime/alerts-queries.ts +++ b/plugin-system/src/runtime/alerts-queries.ts @@ -16,7 +16,7 @@ import { QueryKey, useQueries, UseQueryResult } from '@tanstack/react-query'; import { AlertsQueryContext, AlertsQueryPlugin } from '../model'; import { useDatasourceStore } from './datasources'; -import { usePluginRegistry, usePlugins } from './plugin-registry'; +import { usePluginRegistry, usePlugins, getPluginOverrides } from './plugin-registry'; import { filterVariableStateMap, getVariableValuesKey } from './utils'; import { useAllVariableValues } from './variables'; @@ -34,7 +34,7 @@ export function useAlertsQueries(definitions: AlertsQueryDefinition[]): Array ({ kind: d.spec.plugin.kind })), + definitions.map((d) => ({ kind: d.spec.plugin.kind, ...getPluginOverrides(d.spec.plugin) })), ); return useQueries({ @@ -50,7 +50,11 @@ export function useAlertsQueries(definitions: AlertsQueryDefinition[]): Array => { - const plugin = await getPlugin({ kind: ALERTS_QUERY_KEY, name: alertsQueryKind }); + const plugin = await getPlugin({ + kind: ALERTS_QUERY_KEY, + name: alertsQueryKind, + ...getPluginOverrides(definition.spec.plugin), + }); const data = await plugin.getAlertsData(definition.spec.plugin.spec, context, signal); return data; }, diff --git a/plugin-system/src/runtime/annotations.ts b/plugin-system/src/runtime/annotations.ts index bc8dbdda..29e0b163 100644 --- a/plugin-system/src/runtime/annotations.ts +++ b/plugin-system/src/runtime/annotations.ts @@ -16,7 +16,7 @@ import { QueryKey, useQueries, useQuery, UseQueryResult } from '@tanstack/react- import { AnnotationContext, AnnotationPlugin } from '../model'; import { useDatasourceStore } from './datasources'; -import { usePlugin, usePluginRegistry, usePlugins } from './plugin-registry'; +import { usePlugin, usePluginRegistry, usePlugins, getPluginOverrides } from './plugin-registry'; import { useTimeRange } from './TimeRangeProvider'; import { filterVariableStateMap, getVariableValuesKey } from './utils'; import { useAllVariableValues } from './variables'; @@ -74,7 +74,7 @@ export function useAnnotations(definitions: AnnotationSpec[]): Array ({ kind: d.plugin.kind })), + definitions.map((d) => ({ kind: d.plugin.kind, ...getPluginOverrides(d.plugin) })), ); // useQueries() handles data fetching from query plugins @@ -91,7 +91,11 @@ export function useAnnotations(definitions: AnnotationSpec[]): Array => { - const plugin = await getPlugin({ kind: ANNOTATION_KEY, name: annotationKind }); + const plugin = await getPlugin({ + kind: ANNOTATION_KEY, + name: annotationKind, + ...getPluginOverrides(definition.plugin), + }); const data = await plugin.getAnnotationData(definition.plugin.spec, context, signal); return data; }, @@ -101,7 +105,12 @@ export function useAnnotations(definitions: AnnotationSpec[]): Array { - const { data: annotationPlugin } = usePlugin('Annotation', spec.plugin.kind); + const { data: annotationPlugin } = usePlugin( + 'Annotation', + spec.plugin.kind, + undefined, + getPluginOverrides(spec.plugin), + ); const datasourceStore = useDatasourceStore(); const allVariables = useAllVariableValues(); diff --git a/plugin-system/src/runtime/log-queries.ts b/plugin-system/src/runtime/log-queries.ts index 2ab4f502..1142d824 100644 --- a/plugin-system/src/runtime/log-queries.ts +++ b/plugin-system/src/runtime/log-queries.ts @@ -16,7 +16,7 @@ import { useQueries, UseQueryResult } from '@tanstack/react-query'; import { LogQueryResult } from '../model/log-queries'; import { useDatasourceStore } from './datasources'; -import { usePluginRegistry } from './plugin-registry'; +import { usePluginRegistry, getPluginOverrides } from './plugin-registry'; import { useTimeRange } from './TimeRangeProvider'; import { useVariableValues } from './variables'; @@ -47,7 +47,11 @@ export function useLogQueries(definitions: LogQueryDefinition[]): Array => { - const plugin = await getPlugin({ kind: LOG_QUERY_KEY, name: logQueryKind }); + const plugin = await getPlugin({ + kind: LOG_QUERY_KEY, + name: logQueryKind, + ...getPluginOverrides(definition.spec.plugin), + }); const data = await plugin.getLogData(definition.spec.plugin.spec, context, signal); return data; }, diff --git a/plugin-system/src/runtime/plugin-registry.ts b/plugin-system/src/runtime/plugin-registry.ts index 328772cc..466647f8 100644 --- a/plugin-system/src/runtime/plugin-registry.ts +++ b/plugin-system/src/runtime/plugin-registry.ts @@ -11,7 +11,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -import { BuiltinVariableDefinition } from '@perses-dev/spec'; +import { BuiltinVariableDefinition, Definition, PluginDefinitionMetadata } from '@perses-dev/spec'; import { useQueries, useQuery, UseQueryOptions, UseQueryResult } from '@tanstack/react-query'; import { createContext, useContext } from 'react'; @@ -22,10 +22,11 @@ import { PluginType, PluginCompoundKey, } from '../model'; +import { LATEST_PLUGIN_VERSION } from '../utils/plugin-versions'; export interface PluginRegistryContextType { getPlugin(compoundKey: PluginCompoundKey): Promise>; - listPluginMetadata(pluginTypes: PluginType[]): Promise; + listPluginMetadata(pluginTypes?: PluginType[]): Promise; defaultPluginKinds?: DefaultPluginKinds; } @@ -45,18 +46,48 @@ export function usePluginRegistry(): PluginRegistryContextType { // Allows consumers to pass useQuery options from react-query when loading a plugin type UsePluginOptions = Omit< - UseQueryOptions, Error, PluginImplementation, [string, PluginType | undefined, string]>, + UseQueryOptions< + PluginImplementation, + Error, + PluginImplementation, + [string, PluginType | undefined, string, string, string] + >, 'queryKey' | 'queryFn' >; +/** + * Extract the pinned version/registry from a plugin definition's `metadata`, if any. Returns `undefined` when nothing + * is pinned so the plugin resolves to its latest available version. + */ +export function getPluginOverrides( + plugin: Pick, 'metadata'> | undefined, +): PluginDefinitionMetadata | undefined { + const metadata = plugin?.metadata; + if (!metadata) { + return undefined; + } + // `latest` means "resolve the latest available version", so it must not be treated as an exact-version pin. + const version = metadata.version === LATEST_PLUGIN_VERSION ? undefined : metadata.version; + const registry = metadata.registry; + if (version === undefined && registry === undefined) { + return undefined; + } + return { version, registry }; +} + /** * Loads a plugin and returns the plugin implementation, along with loading/error state. + * + * When `overrides.version` is provided, the plugin is resolved with an exact version match: if that version is not + * installed, the query fails instead of silently falling back to the latest available version. */ export function usePlugin( pluginType: T | undefined, kind: string, options?: UsePluginOptions, + overrides?: PluginDefinitionMetadata, ): UseQueryResult, Error> { + const { version, registry } = overrides ?? {}; // We never want to ask for a plugin when the kind isn't set yet, so disable those queries automatically options = { ...options, @@ -64,38 +95,62 @@ export function usePlugin( }; const { getPlugin } = usePluginRegistry(); return useQuery({ - queryKey: ['getPlugin', pluginType, kind], - queryFn: () => getPlugin({ kind: pluginType!, name: kind }), + queryKey: ['getPlugin', pluginType, kind, version ?? '', registry ?? ''], + queryFn: () => getPlugin({ kind: pluginType!, name: kind, version, registry }), ...options, }); } +/** + * A plugin reference to load, optionally pinned to a specific version/registry. + */ +export interface UsePluginsItem extends PluginDefinitionMetadata { + kind: string; +} + +/** + * Full identity of a plugin to load. Two definitions pinned to different versions (or registries) of the same kind are + * distinct plugins and must be loaded independently. + */ +function getUsePluginsItemIdentity(plugin: UsePluginsItem): string { + return `${plugin.kind}:${plugin.version ?? ''}:${plugin.registry ?? ''}`; +} + /** * Loads a list of plugins and returns the plugin implementation, along with loading/error state. */ export function usePlugins( pluginType: T, - plugins: Array<{ kind: string }>, + plugins: UsePluginsItem[], ): Array>> { const { getPlugin } = usePluginRegistry(); - // useQueries() does not support queries with duplicate keys, therefore we de-duplicate the plugin kinds before running useQueries() + // useQueries() does not support queries with duplicate keys, therefore we de-duplicate the plugins before running useQueries() // This resolves the following warning in the JS console: "[QueriesObserver]: Duplicate Queries found. This might result in unexpected behavior." // https://github.com/TanStack/query/issues/8224#issuecomment-2523554831 // https://github.com/TanStack/query/issues/4187#issuecomment-1256336901 - const kinds = [...new Set(plugins.map((p) => p.kind))]; + const uniquePlugins = new Map(); + for (const p of plugins) { + const key = getUsePluginsItemIdentity(p); + if (!uniquePlugins.has(key)) { + uniquePlugins.set(key, p); + } + } + const uniqueKeys = [...uniquePlugins.keys()]; + const uniqueValues = [...uniquePlugins.values()]; const result: Array>> = useQueries({ - queries: kinds.map((kind) => { + queries: uniqueValues.map((p) => { return { - queryKey: ['getPlugin', pluginType, kind], - queryFn: () => getPlugin({ kind: pluginType, name: kind }), + queryKey: ['getPlugin', pluginType, p.kind, p.version ?? '', p.registry ?? ''], + queryFn: () => getPlugin({ kind: pluginType, name: p.kind, version: p.version, registry: p.registry }), }; }), }); - // Re-assemble array in original order - return plugins.map((p) => result[kinds.indexOf(p.kind)]!); + // Re-assemble array in original order. Index lookups go through a Map so this stays linear on large panels. + const indexByIdentity = new Map(uniqueKeys.map((key, index) => [key, index])); + return plugins.map((p) => result[indexByIdentity.get(getUsePluginsItemIdentity(p))!]!); } // Allow consumers to pass useQuery options from react-query when listing metadata @@ -105,15 +160,17 @@ type UseListPluginMetadataOptions = Omit< >; /** - * Gets a list of plugin metadata for the specified plugin type and returns it, along with loading/error state. + * Gets a list of plugin metadata for the specified plugin types and returns it, along with loading/error state. When + * `pluginTypes` is omitted, the metadata of every installed plugin is returned, whatever its type. */ export function useListPluginMetadata( - pluginTypes: PluginType[], + pluginTypes?: PluginType[], options?: UseListPluginMetadataOptions, ): UseQueryResult { const { listPluginMetadata } = usePluginRegistry(); return useQuery({ - queryKey: ['listPluginMetadata', pluginTypes], + // `['*']` marks the "every plugin type" query so it gets its own cache entry. + queryKey: ['listPluginMetadata', pluginTypes ?? ['*']], queryFn: () => listPluginMetadata(pluginTypes), ...options, }); diff --git a/plugin-system/src/runtime/profile-queries.ts b/plugin-system/src/runtime/profile-queries.ts index a6dda945..13a8a33a 100644 --- a/plugin-system/src/runtime/profile-queries.ts +++ b/plugin-system/src/runtime/profile-queries.ts @@ -15,7 +15,7 @@ import { QueryDefinition, UnknownSpec, ProfileData } from '@perses-dev/spec'; import { useQueries, UseQueryResult } from '@tanstack/react-query'; import { useDatasourceStore } from './datasources'; -import { usePluginRegistry } from './plugin-registry'; +import { usePluginRegistry, getPluginOverrides } from './plugin-registry'; import { useTimeRange } from './TimeRangeProvider'; export type ProfileQueryDefinition = QueryDefinition<'ProfileQuery', PluginSpec>; export const PROFILE_QUERY_KEY = 'ProfileQuery'; @@ -47,7 +47,11 @@ export function useProfileQueries(definitions: ProfileQueryDefinition[]): Array< refetchOnReconnect: false, staleTime: Infinity, queryFn: async ({ signal }: { signal?: AbortSignal }): Promise => { - const plugin = await getPlugin({ kind: PROFILE_QUERY_KEY, name: profileQueryKind }); + const plugin = await getPlugin({ + kind: PROFILE_QUERY_KEY, + name: profileQueryKind, + ...getPluginOverrides(definition.spec.plugin), + }); const data = await plugin.getProfileData(definition.spec.plugin.spec, context, signal); return data; }, diff --git a/plugin-system/src/runtime/silences-queries.ts b/plugin-system/src/runtime/silences-queries.ts index 3a407f77..fdc9dbd9 100644 --- a/plugin-system/src/runtime/silences-queries.ts +++ b/plugin-system/src/runtime/silences-queries.ts @@ -16,7 +16,7 @@ import { QueryKey, useQueries, UseQueryResult } from '@tanstack/react-query'; import { SilencesQueryContext, SilencesQueryPlugin } from '../model'; import { useDatasourceStore } from './datasources'; -import { usePluginRegistry, usePlugins } from './plugin-registry'; +import { usePluginRegistry, usePlugins, getPluginOverrides } from './plugin-registry'; import { filterVariableStateMap, getVariableValuesKey } from './utils'; import { useAllVariableValues } from './variables'; @@ -34,7 +34,7 @@ export function useSilencesQueries(definitions: SilencesQueryDefinition[]): Arra const pluginLoaderResponse = usePlugins( 'SilencesQuery', - definitions.map((d) => ({ kind: d.spec.plugin.kind })), + definitions.map((d) => ({ kind: d.spec.plugin.kind, ...getPluginOverrides(d.spec.plugin) })), ); return useQueries({ @@ -50,7 +50,11 @@ export function useSilencesQueries(definitions: SilencesQueryDefinition[]): Arra refetchOnReconnect: false, staleTime: 60_000, queryFn: async ({ signal }: { signal?: AbortSignal }): Promise => { - const plugin = await getPlugin({ kind: SILENCES_QUERY_KEY, name: silencesQueryKind }); + const plugin = await getPlugin({ + kind: SILENCES_QUERY_KEY, + name: silencesQueryKind, + ...getPluginOverrides(definition.spec.plugin), + }); const data = await plugin.getSilencesData(definition.spec.plugin.spec, context, signal); return data; }, diff --git a/plugin-system/src/runtime/time-series-queries.ts b/plugin-system/src/runtime/time-series-queries.ts index edc5bb44..5cbbd513 100644 --- a/plugin-system/src/runtime/time-series-queries.ts +++ b/plugin-system/src/runtime/time-series-queries.ts @@ -25,7 +25,7 @@ import { import { TimeSeriesDataQuery, TimeSeriesQueryContext, TimeSeriesQueryMode, TimeSeriesQueryPlugin } from '../model'; import { useDatasourceStore } from './datasources'; -import { usePlugin, usePluginRegistry, usePlugins } from './plugin-registry'; +import { usePlugin, usePluginRegistry, usePlugins, getPluginOverrides } from './plugin-registry'; import { useTimeRange } from './TimeRangeProvider'; import { filterVariableStateMap, getVariableValuesKey } from './utils'; import { useAllVariableValues } from './variables'; @@ -90,7 +90,12 @@ export const useTimeSeriesQuery = ( options?: UseTimeSeriesQueryOptions, queryOptions?: QueryObserverOptions, ): UseQueryResult => { - const { data: plugin } = usePlugin(TIME_SERIES_QUERY_KEY, definition.spec.plugin.kind); + const { data: plugin } = usePlugin( + TIME_SERIES_QUERY_KEY, + definition.spec.plugin.kind, + undefined, + getPluginOverrides(definition.spec.plugin), + ); const context = useTimeSeriesQueryContext(); const { queryEnabled, queryKey } = getQueryOptions({ plugin, definition, context }); return useQuery({ @@ -125,7 +130,7 @@ export function useTimeSeriesQueries( const pluginLoaderResponse = usePlugins( TIME_SERIES_QUERY_KEY, - definitions.map((d) => ({ kind: d.spec.plugin.kind })), + definitions.map((d) => ({ kind: d.spec.plugin.kind, ...getPluginOverrides(d.spec.plugin) })), ); return useQueries({ queries: definitions.map((definition, idx) => { @@ -140,7 +145,11 @@ export function useTimeSeriesQueries( staleTime: Infinity, queryKey: queryKey, queryFn: async ({ signal }: { signal: AbortSignal }): Promise => { - const plugin = await getPlugin({ kind: TIME_SERIES_QUERY_KEY, name: definition.spec.plugin.kind }); + const plugin = await getPlugin({ + kind: TIME_SERIES_QUERY_KEY, + name: definition.spec.plugin.kind, + ...getPluginOverrides(definition.spec.plugin), + }); const data = await plugin.getTimeSeriesData(definition.spec.plugin.spec, context, signal); return data; }, diff --git a/plugin-system/src/runtime/trace-queries.ts b/plugin-system/src/runtime/trace-queries.ts index d3bb2442..4dbadabb 100644 --- a/plugin-system/src/runtime/trace-queries.ts +++ b/plugin-system/src/runtime/trace-queries.ts @@ -16,7 +16,7 @@ import { QueryKey, useQueries, UseQueryResult } from '@tanstack/react-query'; import { TraceQueryContext, TraceQueryPlugin } from '../model'; import { useDatasourceStore } from './datasources'; -import { usePluginRegistry, usePlugins } from './plugin-registry'; +import { usePluginRegistry, usePlugins, getPluginOverrides } from './plugin-registry'; import { useTimeRange } from './TimeRangeProvider'; import { filterVariableStateMap, getVariableValuesKey } from './utils'; import { useAllVariableValues } from './variables'; @@ -34,7 +34,7 @@ export function useTraceQueries(definitions: TraceQueryDefinition[]): Array ({ kind: d.spec.plugin.kind })), + definitions.map((d) => ({ kind: d.spec.plugin.kind, ...getPluginOverrides(d.spec.plugin) })), ); // useQueries() handles data fetching from query plugins (e.g. traceQL queries, promQL queries) @@ -52,7 +52,11 @@ export function useTraceQueries(definitions: TraceQueryDefinition[]): Array => { - const plugin = await getPlugin({ kind: TRACE_QUERY_KEY, name: traceQueryKind }); + const plugin = await getPlugin({ + kind: TRACE_QUERY_KEY, + name: traceQueryKind, + ...getPluginOverrides(definition.spec.plugin), + }); const data = await plugin.getTraceData(definition.spec.plugin.spec, context, signal); return data; }, diff --git a/plugin-system/src/utils/index.ts b/plugin-system/src/utils/index.ts index 113242ee..fa75cc9b 100644 --- a/plugin-system/src/utils/index.ts +++ b/plugin-system/src/utils/index.ts @@ -12,5 +12,6 @@ // limitations under the License. export * from './event'; +export * from './plugin-versions'; export * from './variables'; export * from './csv-export'; diff --git a/plugin-system/src/utils/plugin-versions.test.ts b/plugin-system/src/utils/plugin-versions.test.ts new file mode 100644 index 00000000..5da02896 --- /dev/null +++ b/plugin-system/src/utils/plugin-versions.test.ts @@ -0,0 +1,48 @@ +// Copyright The Perses Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { comparePluginVersions, sortPluginVersionsDesc } from './plugin-versions'; + +describe('comparePluginVersions', () => { + test.each([ + ['1.0.0', '1.0.0', 0], + ['1.2.0', '1.1.9', 1], + ['1.1.0', '1.2.0', -1], + ['v2.0.0', '1.9.9', 1], + // Numeric, not lexicographic, comparison of each segment + ['0.10.0', '0.9.0', 1], + ['1.10.0', '1.9.0', 1], + // A pre-release orders below its stable release + ['1.0.0-beta', '1.0.0', -1], + ['1.0.0-rc.2', '1.0.0-rc.1', 1], + // Loose forms the backend also accepts + ['1.0', '1.0.0', 0], + // Anything unparseable orders below a real version so it can never be picked as "the latest" + ['not-a-version', '0.0.1', -1], + ['0.0.1', 'not-a-version', 1], + ])('comparePluginVersions(%s, %s)', (a, b, expected) => { + expect(Math.sign(comparePluginVersions(a as string, b as string))).toBe(expected); + }); + + test('two unparseable versions are compared lexicographically', () => { + expect(Math.sign(comparePluginVersions('abc', 'abd'))).toBe(-1); + }); +}); + +describe('sortPluginVersionsDesc', () => { + test('sorts from newest to oldest without mutating the input', () => { + const versions = ['1.0.0', '2.0.0-rc1', '1.10.0', '2.0.0']; + expect(sortPluginVersionsDesc(versions)).toEqual(['2.0.0', '2.0.0-rc1', '1.10.0', '1.0.0']); + expect(versions).toEqual(['1.0.0', '2.0.0-rc1', '1.10.0', '2.0.0']); + }); +}); diff --git a/plugin-system/src/utils/plugin-versions.ts b/plugin-system/src/utils/plugin-versions.ts new file mode 100644 index 00000000..712cb476 --- /dev/null +++ b/plugin-system/src/utils/plugin-versions.ts @@ -0,0 +1,57 @@ +// Copyright The Perses Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { coerce, compare, parse, SemVer } from 'semver'; + +/** + * Sentinel version meaning "the latest version available in the Perses instance". It mirrors the backend + * `plugin.LatestVersion` constant. A plugin definition using it is not pinned to an exact version: the plugin registry + * resolves it dynamically at load time. + */ +export const LATEST_PLUGIN_VERSION = 'latest'; + +/** + * Parse a plugin version with semver, tolerating the loose forms the backend also accepts (a leading `v`, a missing + * patch segment, ...). Returns `null` when the value cannot be understood as a version at all. + */ +function parsePluginVersion(version: string): SemVer | null { + return parse(version, { loose: true }) ?? coerce(version); +} + +/** + * Compare two plugin version strings with semver semantics, the same way the Perses backend orders plugin versions. + * Returns a positive number when `a` is greater than `b`, a negative number when it is lower, and 0 when they are equal. + * + * Pre-releases order below their stable release (`1.0.0-beta` < `1.0.0`), as semver mandates. Versions that cannot be + * parsed at all always order below parseable ones, and are compared lexicographically between themselves, so an + * unexpected value can never be picked as "the latest version". + */ +export function comparePluginVersions(a: string, b: string): number { + const parsedA = parsePluginVersion(a); + const parsedB = parsePluginVersion(b); + if (parsedA && parsedB) { + return compare(parsedA, parsedB); + } + if (parsedA) { + return 1; + } + if (parsedB) { + return -1; + } + return a.localeCompare(b); +} + +/** Return a new array of versions sorted from newest to oldest, using {@link comparePluginVersions}. */ +export function sortPluginVersionsDesc(versions: string[]): string[] { + return versions.toSorted((a, b) => comparePluginVersions(b, a)); +}