From c7ea05005e499eadaa30a4a7844928571a0d25ff Mon Sep 17 00:00:00 2001 From: Guillaume LADORME Date: Tue, 11 Aug 2026 17:03:39 +0200 Subject: [PATCH 1/7] [FEATURE] Add dashboard versioning Signed-off-by: Guillaume LADORME --- .../DashboardToolbar/DashboardToolbar.tsx | 4 + .../LockDashboardButton.tsx | 72 +++++++ .../components/LockDashboardButton/index.ts | 14 ++ dashboards/src/components/index.ts | 1 + dashboards/src/utils/index.ts | 1 + dashboards/src/utils/pluginVersioning.test.ts | 176 +++++++++++++++ dashboards/src/utils/pluginVersioning.ts | 202 ++++++++++++++++++ .../src/views/ViewDashboard/DashboardApp.tsx | 6 + .../src/views/ViewDashboard/ViewDashboard.tsx | 2 + 9 files changed, 478 insertions(+) create mode 100644 dashboards/src/components/LockDashboardButton/LockDashboardButton.tsx create mode 100644 dashboards/src/components/LockDashboardButton/index.ts create mode 100644 dashboards/src/utils/pluginVersioning.test.ts create mode 100644 dashboards/src/utils/pluginVersioning.ts diff --git a/dashboards/src/components/DashboardToolbar/DashboardToolbar.tsx b/dashboards/src/components/DashboardToolbar/DashboardToolbar.tsx index 557c7768..c5a64d5e 100644 --- a/dashboards/src/components/DashboardToolbar/DashboardToolbar.tsx +++ b/dashboards/src/components/DashboardToolbar/DashboardToolbar.tsx @@ -27,6 +27,7 @@ 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 { EditVariablesButton } from '../Variables'; @@ -39,6 +40,7 @@ export interface DashboardToolbarProps { isAnnotationEnabled: boolean; isDatasourceEnabled: boolean; isLinksEnabled?: boolean; + isPluginVersioningEnabled?: boolean; timezone: string; onEditButtonClick: () => void; onCancelButtonClick: () => void; @@ -55,6 +57,7 @@ export const DashboardToolbar = (props: DashboardToolbarProps): ReactElement => isAnnotationEnabled, isDatasourceEnabled, isLinksEnabled = true, + isPluginVersioningEnabled = false, timezone: toolbarTimezone, onEditButtonClick, onCancelButtonClick, @@ -105,6 +108,7 @@ export const DashboardToolbar = (props: DashboardToolbarProps): ReactElement => {isLinksEnabled && } + {isPluginVersioningEnabled && } + + + ); +} 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/index.ts b/dashboards/src/components/index.ts index 65df3f48..4cc643fe 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'; 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..4bf6c314 --- /dev/null +++ b/dashboards/src/utils/pluginVersioning.test.ts @@ -0,0 +1,176 @@ +// 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, + compareVersions, + isDashboardLocked, + removePluginVersions, +} from './pluginVersioning'; + +function buildMetadata( + kind: string, + name: string, + moduleVersion: string, + pluginVersion?: string +): PluginMetadataWithModule { + return { + kind, + metadata: pluginVersion ? { version: pluginVersion } : undefined, + spec: { name, display: { name } }, + module: { name: `${name}-module`, version: moduleVersion }, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any; +} + +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('compareVersions', () => { + 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], + ['0.10.0', '0.9.0', 1], + ])('compareVersions(%s, %s)', (a, b, expected) => { + expect(Math.sign(compareVersions(a as string, b as string))).toBe(expected); + }); +}); + +describe('buildLatestPluginVersions', () => { + test('keeps the highest version per plugin name and prefers plugin-level version', () => { + const metadata: PluginMetadataWithModule[] = [ + buildMetadata('Panel', 'TimeSeriesChart', '0.1.0'), + buildMetadata('Panel', 'TimeSeriesChart', '0.3.0'), + buildMetadata('Panel', 'TimeSeriesChart', '0.2.0'), + buildMetadata('TimeSeriesQuery', 'PrometheusTimeSeriesQuery', '1.0.0', '2.0.0'), + ]; + const versions = buildLatestPluginVersions(metadata); + expect(versions.get('TimeSeriesChart')).toBe('0.3.0'); + // plugin-level version wins over module version + expect(versions.get('PrometheusTimeSeriesQuery')).toBe('2.0.0'); + }); +}); + +describe('applyPluginVersions / removePluginVersions / isDashboardLocked', () => { + const versions = new Map([ + ['TimeSeriesChart', '1.0.0'], + ['PrometheusTimeSeriesQuery', '1.1.0'], + ['PrometheusLabelValuesVariable', '1.2.0'], + ['PrometheusDatasource', '1.3.0'], + ['TempoAnnotation', '1.4.0'], + ]); + + test('a fresh dashboard is not locked', () => { + expect(isDashboardLocked(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 dashboard = buildDashboard(); + const locked = applyPluginVersions(dashboard, versions); + const unlocked = removePluginVersions(locked); + + expect(isDashboardLocked(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 dashboard = buildDashboard(); + const partial = applyPluginVersions(dashboard, new Map([['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(); + }); +}); diff --git a/dashboards/src/utils/pluginVersioning.ts b/dashboards/src/utils/pluginVersioning.ts new file mode 100644 index 00000000..b0cd98e5 --- /dev/null +++ b/dashboards/src/utils/pluginVersioning.ts @@ -0,0 +1,202 @@ +// 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 { Definition } from '@perses-dev/spec'; +import { DashboardResource } from '@perses-dev/client'; +import { PluginMetadataWithModule, PluginType } from '@perses-dev/plugin-system'; + +/** + * Optional metadata attached to a plugin definition. Mirrors the backend `Plugin.Metadata` model (and the + * `@perses-dev/spec` `Definition.metadata` field) so a plugin can be pinned to a specific version/registry. + */ +export interface PluginDefinitionMetadata { + version?: string; + registry?: string; +} + +/** + * A plugin definition that may carry version/registry metadata. We augment the spec `Definition` type locally so the + * feature keeps compiling regardless of the installed `@perses-dev/spec` version. + */ +type VersionedDefinition = Definition & { metadata?: PluginDefinitionMetadata }; + +/** + * Plugin types whose definitions may appear inside a dashboard spec and therefore can be pinned to a version when the + * dashboard is "locked". + */ +export const PLUGIN_VERSIONING_TYPES: PluginType[] = [ + 'Panel', + 'TimeSeriesQuery', + 'TraceQuery', + 'ProfileQuery', + 'LogQuery', + 'AlertsQuery', + 'SilencesQuery', + 'Variable', + 'Datasource', + 'Annotation', +]; + +/** + * Compare two version strings using a best-effort semantic-versioning comparison. + * Returns a positive number when `a` is greater than `b`, a negative number when it is lower, and 0 when equal. + */ +export function compareVersions(a: string, b: string): number { + const normalize = (v: string): number[] => + v + .replace(/^v/, '') + .split(/[.+-]/) + .map((part) => Number.parseInt(part, 10)); + + const aParts = normalize(a); + const bParts = normalize(b); + const length = Math.max(aParts.length, bParts.length); + + for (let i = 0; i < length; i++) { + const aPart = aParts[i]; + const bPart = bParts[i]; + // If a segment is not a number (e.g. a pre-release tag), fall back to a string comparison of the whole version. + if (aPart === undefined || Number.isNaN(aPart) || bPart === undefined || Number.isNaN(bPart)) { + return a.localeCompare(b); + } + if (aPart !== bPart) { + return aPart - bPart; + } + } + return 0; +} + +/** + * 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 getPluginVersion(metadata: PluginMetadataWithModule): string | undefined { + return metadata.metadata?.version ?? metadata.module?.version; +} + +/** + * Build a map of plugin kind (e.g. "TimeSeriesChart") to the latest version currently available in the instance, based + * on the installed plugin metadata returned by the plugin registry. + */ +export function buildLatestPluginVersions(pluginMetadata: PluginMetadataWithModule[]): Map { + const versions = new Map(); + for (const metadata of pluginMetadata) { + const name = metadata.spec?.name; + const version = getPluginVersion(metadata); + if (!name || !version) { + continue; + } + const existing = versions.get(name); + if (existing === undefined || compareVersions(version, existing) > 0) { + versions.set(name, version); + } + } + return versions; +} + +/** + * Visit every plugin definition contained in a dashboard spec, invoking the provided callback for each one. Covers + * panel plugins, panel query plugins, list-variable plugins, datasource plugins and annotation plugins. + */ +function visitPluginDefinitions( + dashboard: DashboardResource, + visitor: (definition: VersionedDefinition) => void +): void { + const spec = dashboard.spec; + + // Panels and their queries + for (const panel of Object.values(spec.panels ?? {})) { + if (panel?.spec?.plugin) { + visitor(panel.spec.plugin as VersionedDefinition); + } + for (const query of panel?.spec?.queries ?? []) { + if (query?.spec?.plugin) { + visitor(query.spec.plugin as VersionedDefinition); + } + } + } + + // Variables (only list variables reference a plugin) + for (const variable of spec.variables ?? []) { + if (variable?.kind === 'ListVariable' && variable.spec?.plugin) { + visitor(variable.spec.plugin as VersionedDefinition); + } + } + + // Datasources + for (const datasource of Object.values(spec.datasources ?? {})) { + if (datasource?.plugin) { + visitor(datasource.plugin as VersionedDefinition); + } + } + + // Annotations + for (const annotation of spec.annotations ?? []) { + if (annotation?.plugin) { + visitor(annotation.plugin as VersionedDefinition); + } + } +} + +/** Deep-clone a dashboard resource so mutations don't affect the source object. */ +function cloneDashboard(dashboard: DashboardResource): DashboardResource { + return JSON.parse(JSON.stringify(dashboard)); +} + +/** + * Return a copy of the dashboard with every plugin definition pinned to its latest available version. Plugin + * definitions whose kind is not present in the version map are left untouched. + */ +export function applyPluginVersions(dashboard: DashboardResource, versions: Map): DashboardResource { + const next = cloneDashboard(dashboard); + visitPluginDefinitions(next, (definition) => { + const version = versions.get(definition.kind); + 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 = cloneDashboard(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 considered "locked" as soon as at least one of its plugin definitions is pinned to a version. + */ +export function isDashboardLocked(dashboard: DashboardResource): boolean { + let locked = false; + visitPluginDefinitions(dashboard, (definition) => { + if (definition.metadata?.version) { + locked = true; + } + }); + return locked; +} diff --git a/dashboards/src/views/ViewDashboard/DashboardApp.tsx b/dashboards/src/views/ViewDashboard/DashboardApp.tsx index 686b472b..be94dbac 100644 --- a/dashboards/src/views/ViewDashboard/DashboardApp.tsx +++ b/dashboards/src/views/ViewDashboard/DashboardApp.tsx @@ -44,6 +44,10 @@ export interface DashboardAppProps { isDatasourceEnabled: boolean; disableShortcuts?: boolean; isCreating?: boolean; + /** + * When true, displays the dashboard "lock/unlock" button that pins plugin versions. Disabled by default. + */ + isPluginVersioningEnabled?: boolean; isInitialVariableSticky?: boolean; // If true, browser confirmation dialog will be shown when navigating away with unsaved changes (closing tab, ...). isLeavingConfirmDialogEnabled?: boolean; @@ -73,6 +77,7 @@ const DashboardAppContent = (props: DashboardAppProps): ReactElement => { isCreating, isInitialVariableSticky, isLeavingConfirmDialogEnabled, + isPluginVersioningEnabled, dashboardTitleComponent, userPreferenceTimezone, onSave, @@ -157,6 +162,7 @@ const DashboardAppContent = (props: DashboardAppProps): ReactElement => { isVariableEnabled={isVariableEnabled} isAnnotationEnabled={isAnnotationEnabled} isDatasourceEnabled={isDatasourceEnabled} + isPluginVersioningEnabled={isPluginVersioningEnabled} onEditButtonClick={onEditButtonClick} onCancelButtonClick={onCancelButtonClick} /> diff --git a/dashboards/src/views/ViewDashboard/ViewDashboard.tsx b/dashboards/src/views/ViewDashboard/ViewDashboard.tsx index fa4754f9..0c553cc0 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, + isPluginVersioningEnabled, dashboardTitleComponent, onSave, onDiscard, @@ -153,6 +154,7 @@ export function ViewDashboard(props: ViewDashboardProps): ReactElement { isCreating={isCreating} isInitialVariableSticky={isInitialVariableSticky} isLeavingConfirmDialogEnabled={isLeavingConfirmDialogEnabled} + isPluginVersioningEnabled={isPluginVersioningEnabled} dashboardTitleComponent={dashboardTitleComponent} onSave={onSave} onDiscard={onDiscard} From a3b373f76c216535af2d5a416a2e307e6d117006 Mon Sep 17 00:00:00 2001 From: Guillaume LADORME Date: Wed, 12 Aug 2026 15:45:50 +0200 Subject: [PATCH 2/7] Enforce version selected Signed-off-by: Guillaume LADORME --- .../components/GridLayout/GridItemContent.tsx | 9 +- dashboards/src/components/Panel/Panel.tsx | 10 ++- .../src/components/Panel/PanelContent.tsx | 9 +- .../components/Panel/PanelPluginLoader.tsx | 9 +- .../DashboardProvider/use-save-dashboard.ts | 20 ++++- .../src/context/DatasourceStoreProvider.tsx | 8 +- dashboards/src/utils/pluginVersioning.test.ts | 47 ++++++++++ dashboards/src/utils/pluginVersioning.ts | 56 ++++++++++++ .../PluginRegistry/PluginRegistry.tsx | 17 +++- .../components/Variables/variable-model.ts | 10 ++- plugin-system/src/runtime/alerts-queries.ts | 10 ++- plugin-system/src/runtime/annotations.ts | 17 +++- plugin-system/src/runtime/log-queries.ts | 8 +- plugin-system/src/runtime/plugin-registry.ts | 85 ++++++++++++++++--- plugin-system/src/runtime/profile-queries.ts | 8 +- plugin-system/src/runtime/silences-queries.ts | 10 ++- .../src/runtime/time-series-queries.ts | 17 +++- plugin-system/src/runtime/trace-queries.ts | 10 ++- 18 files changed, 307 insertions(+), 53 deletions(-) diff --git a/dashboards/src/components/GridLayout/GridItemContent.tsx b/dashboards/src/components/GridLayout/GridItemContent.tsx index a1969551..2e22c8a6 100644 --- a/dashboards/src/components/GridLayout/GridItemContent.tsx +++ b/dashboards/src/components/GridLayout/GridItemContent.tsx @@ -12,7 +12,7 @@ // limitations under the License. import { Box, useForkRef } from '@mui/material'; -import { DataQueriesProvider, usePlugin, useSuggestedStepMs } from '@perses-dev/plugin-system'; +import { DataQueriesProvider, usePlugin, useSuggestedStepMs, getPluginOverrides } from '@perses-dev/plugin-system'; import React, { ReactElement, useMemo, useState } from 'react'; import { useInView } from 'react-intersection-observer'; @@ -103,7 +103,12 @@ export function GridItemContent(props: GridItemContentProps): ReactElement { // map TimeSeriesQueryDefinition to Definition 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/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..19c870a4 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..7192d0f4 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/context/DashboardProvider/use-save-dashboard.ts b/dashboards/src/context/DashboardProvider/use-save-dashboard.ts index 0aefe440..64eb3ad6 100644 --- a/dashboards/src/context/DashboardProvider/use-save-dashboard.ts +++ b/dashboards/src/context/DashboardProvider/use-save-dashboard.ts @@ -11,12 +11,13 @@ // See the License for the specific language governing permissions and // limitations under the License. -import { useTimeRange, useTimeZoneParams, useDisableAutoRefreshSetting } from '@perses-dev/plugin-system'; +import { useTimeRange, useTimeZoneParams, useDisableAutoRefreshSetting, usePluginRegistry } from '@perses-dev/plugin-system'; import { isRelativeTimeRange } from '@perses-dev/spec'; import { useCallback, useState } from 'react'; - +import { useSnackbar } from '@perses-dev/components'; +import { useVariableDefinitionActions } from '../VariableProvider'; import { useDashboard } from '../useDashboard'; -import { useVariableDefinitionActions } from '../VariableProvider/VariableProvider'; +import { buildAvailablePluginVersions, findInvalidPinnedVersions, PLUGIN_VERSIONING_TYPES } from '../../utils'; import { OnSaveDashboard } from './common'; import { useEditMode, useSaveChangesConfirmationDialog } from './dashboard-provider-api'; import { SaveChangesConfirmationDialogOptions } from './save-changes-dialog-slice'; @@ -54,6 +55,8 @@ export function useSaveDashboard(onSave?: OnSaveDashboard): SaveDashboardResult const { timeZone } = useTimeZoneParams(); const { getSavedVariablesStatus, setVariableDefaultValues } = useVariableDefinitionActions(); const { openSaveChangesConfirmationDialog, closeSaveChangesConfirmationDialog } = useSaveChangesConfirmationDialog(); + const { listPluginMetadata } = usePluginRegistry(); + const { exceptionSnackbar } = useSnackbar(); const performSave = useCallback(async (): Promise => { if (!onSave) { setEditMode(false); @@ -62,13 +65,22 @@ export function useSaveDashboard(onSave?: OnSaveDashboard): SaveDashboardResult try { setSaving(true); + // Validate that any pinned plugin version (plugin.metadata.version) actually exists in the registry before + // saving, so we never persist a dashboard referencing a plugin version that cannot be loaded. + const pluginMetadata = await listPluginMetadata(PLUGIN_VERSIONING_TYPES); + const invalidPins = findInvalidPinnedVersions(dashboard, buildAvailablePluginVersions(pluginMetadata)); + if (invalidPins.length > 0) { + const details = invalidPins.map((pin) => `${pin.kind}@${pin.version}`).join(', '); + exceptionSnackbar(new Error(`Cannot save dashboard: pinned plugin version(s) not available: ${details}`)); + return; + } await onSave(dashboard); closeSaveChangesConfirmationDialog(); setEditMode(false); } finally { setSaving(false); } - }, [closeSaveChangesConfirmationDialog, dashboard, onSave, setEditMode]); + }, [closeSaveChangesConfirmationDialog, dashboard, onSave, setEditMode, listPluginMetadata, exceptionSnackbar]); const saveDashboard = useCallback((): void => { if (isSaving) { diff --git a/dashboards/src/context/DatasourceStoreProvider.tsx b/dashboards/src/context/DatasourceStoreProvider.tsx index 5b6ffa8b..cd61cedd 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,9 @@ 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); + // Resolve the datasource plugin, honoring any version/registry pinned in the datasource spec. + 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/pluginVersioning.test.ts b/dashboards/src/utils/pluginVersioning.test.ts index 4bf6c314..62a8430f 100644 --- a/dashboards/src/utils/pluginVersioning.test.ts +++ b/dashboards/src/utils/pluginVersioning.test.ts @@ -15,8 +15,10 @@ import { DashboardResource } from '@perses-dev/client'; import { PluginMetadataWithModule } from '@perses-dev/plugin-system'; import { applyPluginVersions, + buildAvailablePluginVersions, buildLatestPluginVersions, compareVersions, + findInvalidPinnedVersions, isDashboardLocked, removePluginVersions, } from './pluginVersioning'; @@ -174,3 +176,48 @@ describe('applyPluginVersions / removePluginVersions / isDashboardLocked', () => expect((partial.spec.datasources!.ds1 as any).plugin.metadata).toBeUndefined(); }); }); + +describe('buildAvailablePluginVersions / findInvalidPinnedVersions', () => { + const available = buildAvailablePluginVersions([ + buildMetadata('Panel', 'TimeSeriesChart', '1.0.0'), + buildMetadata('Panel', 'TimeSeriesChart', '2.0.0'), + buildMetadata('TimeSeriesQuery', 'PrometheusTimeSeriesQuery', '1.1.0'), + buildMetadata('Variable', 'PrometheusLabelValuesVariable', '1.2.0'), + buildMetadata('Datasource', 'PrometheusDatasource', '1.3.0'), + buildMetadata('Annotation', 'TempoAnnotation', '1.4.0'), + ]); + + test('collects every available version per plugin name', () => { + expect(available.get('TimeSeriesChart')).toEqual(new Set(['1.0.0', '2.0.0'])); + }); + + test('no invalid pins when all pinned versions exist', () => { + const dashboard = applyPluginVersions( + buildDashboard(), + new Map([ + ['TimeSeriesChart', '2.0.0'], + ['PrometheusTimeSeriesQuery', '1.1.0'], + ['PrometheusLabelValuesVariable', '1.2.0'], + ['PrometheusDatasource', '1.3.0'], + ['TempoAnnotation', '1.4.0'], + ]) + ); + expect(findInvalidPinnedVersions(dashboard, available)).toEqual([]); + }); + + test('reports pins whose version is not available', () => { + const dashboard = applyPluginVersions( + buildDashboard(), + new Map([ + ['TimeSeriesChart', '99.0.0'], // not available + ['PrometheusTimeSeriesQuery', '1.1.0'], // available + ]) + ); + const invalid = findInvalidPinnedVersions(dashboard, available); + expect(invalid).toEqual([{ kind: 'TimeSeriesChart', version: '99.0.0' }]); + }); + + test('unpinned dashboards have no invalid pins', () => { + expect(findInvalidPinnedVersions(buildDashboard(), available)).toEqual([]); + }); +}); diff --git a/dashboards/src/utils/pluginVersioning.ts b/dashboards/src/utils/pluginVersioning.ts index b0cd98e5..74de55c2 100644 --- a/dashboards/src/utils/pluginVersioning.ts +++ b/dashboards/src/utils/pluginVersioning.ts @@ -200,3 +200,59 @@ export function isDashboardLocked(dashboard: DashboardResource): boolean { }); return locked; } + +/** + * Build a map of plugin kind (e.g. "TimeSeriesChart") to the full set of versions currently available in the instance, + * based on the installed plugin metadata returned by the plugin registry. + */ +export function buildAvailablePluginVersions(pluginMetadata: PluginMetadataWithModule[]): Map> { + const versions = new Map>(); + for (const metadata of pluginMetadata) { + const name = metadata.spec?.name; + const version = getPluginVersion(metadata); + if (!name || !version) { + continue; + } + let set = versions.get(name); + if (!set) { + set = new Set(); + versions.set(name, set); + } + set.add(version); + } + return versions; +} + +/** A plugin definition pinned to a version that is not available in the registry. */ +export interface InvalidPinnedVersion { + kind: string; + version: string; +} + +/** + * Return the list of plugin definitions in the dashboard that are pinned to a version which is not present in the + * provided set of available versions (built from the plugin registry). An empty result means every pin is valid. + */ +export function findInvalidPinnedVersions( + dashboard: DashboardResource, + availableVersions: Map> +): InvalidPinnedVersion[] { + const invalid: InvalidPinnedVersion[] = []; + const seen = new Set(); + visitPluginDefinitions(dashboard, (definition) => { + const version = definition.metadata?.version; + if (!version) { + return; + } + const identity = `${definition.kind}@${version}`; + if (seen.has(identity)) { + return; + } + seen.add(identity); + const versions = availableVersions.get(definition.kind); + if (!versions || !versions.has(version)) { + invalid.push({ kind: definition.kind, version }); + } + }); + return invalid; +} diff --git a/plugin-system/src/components/PluginRegistry/PluginRegistry.tsx b/plugin-system/src/components/PluginRegistry/PluginRegistry.tsx index eee0abfe..e681556b 100644 --- a/plugin-system/src/components/PluginRegistry/PluginRegistry.tsx +++ b/plugin-system/src/components/PluginRegistry/PluginRegistry.tsx @@ -66,13 +66,20 @@ 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 } = compoundKeyObj; - const candidateKeys = resolvePluginKeys( + let candidateKeys = resolvePluginKeys( pluginIndexes.pluginResourcesByNameKindRegistryVersion.keys(), compoundKeyObj, ); + // When a specific version is pinned, enforce an exact match and do NOT silently fall back to another (e.g. the + // latest) version. resolvePluginKeys() always returns the exact-match key first when a version is provided, so we + // only keep that first candidate. + if (version) { + candidateKeys = candidateKeys.slice(0, 1); + } + for (const resourceKey of candidateKeys) { const resource = pluginIndexes.pluginResourcesByNameKindRegistryVersion.get(resourceKey); if (!resource) continue; @@ -86,7 +93,11 @@ export function PluginRegistry(props: PluginRegistryProps): ReactElement { if (versionlessPlugin) return versionlessPlugin as PluginImplementation; } - throw new Error(`A ${name} plugin for kind '${kind}' is not installed`); + throw new Error( + version + ? `A ${name} plugin for kind '${kind}' with version '${version}' is not installed` + : `A ${name} plugin for kind '${kind}' is not installed` + ); }, [getPluginIndexes, loadPluginModule], ); diff --git a/plugin-system/src/components/Variables/variable-model.ts b/plugin-system/src/components/Variables/variable-model.ts index aed24688..aacd849a 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/runtime/alerts-queries.ts b/plugin-system/src/runtime/alerts-queries.ts index c4a13a07..90aaa71e 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..af646946 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..1377e357 100644 --- a/plugin-system/src/runtime/plugin-registry.ts +++ b/plugin-system/src/runtime/plugin-registry.ts @@ -45,18 +45,64 @@ 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' >; +/** + * Optional overrides used to pin a plugin to a specific version/registry when loading it. When omitted, the latest + * available version is resolved. + */ +export interface UsePluginOverrides { + version?: string; + registry?: string; +} + +/** + * A plugin definition, as stored in a dashboard/datasource spec, that may carry pinned version/registry metadata. We + * describe `metadata` locally so this keeps compiling regardless of the installed `@perses-dev/spec` version. + */ +type PluginDefinitionLike = + | { kind?: string; spec?: unknown; metadata?: { version?: string; registry?: string } } + | undefined; + +/** + * 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: PluginDefinitionLike): UsePluginOverrides | undefined { + const metadata = plugin?.metadata; + if (!metadata) { + return undefined; + } + // `latest` is a sentinel (see the Go plugin.LatestVersion constant) meaning "resolve the latest available version", + // so it must not be treated as an exact-version pin. + const version = metadata.version === 'latest' ? 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?: UsePluginOverrides, ): 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 +110,57 @@ 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 { + kind: string; + version?: string; + registry?: string; +} + /** * 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))]; + // Plugins are de-duplicated on their full identity (kind + version + registry) so that two definitions pinned to + // different versions of the same kind are still loaded independently. + const identity = (p: UsePluginsItem): string => `${p.kind}:${p.version ?? ''}:${p.registry ?? ''}`; + const uniquePlugins = new Map(); + for (const p of plugins) { + if (!uniquePlugins.has(identity(p))) { + uniquePlugins.set(identity(p), 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)]!); + return plugins.map((p) => result[uniqueKeys.indexOf(identity(p))]!); } // Allow consumers to pass useQuery options from react-query when listing metadata 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..905d4f09 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..e8c1cd77 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; }, From 012251ed691a3f46cccfd77d1fd13bcca62c191f Mon Sep 17 00:00:00 2001 From: Guillaume LADORME Date: Mon, 17 Aug 2026 11:02:59 +0200 Subject: [PATCH 3/7] Add versioning to panel type Signed-off-by: Guillaume LADORME --- .../PanelDrawer/PanelEditorForm.tsx | 25 ++- .../PluginEditor/plugin-editor-api.ts | 5 + .../PluginKindSelect/PluginKindSelect.tsx | 156 +++++++++++++++--- 3 files changed, 161 insertions(+), 25 deletions(-) diff --git a/dashboards/src/components/PanelDrawer/PanelEditorForm.tsx b/dashboards/src/components/PanelDrawer/PanelEditorForm.tsx index 922076b1..e017a480 100644 --- a/dashboards/src/components/PanelDrawer/PanelEditorForm.tsx +++ b/dashboards/src/components/PanelDrawer/PanelEditorForm.tsx @@ -22,7 +22,7 @@ import { getTitleAction, } from '@perses-dev/components'; import { PanelEditorValues, PluginKindSelect, usePluginEditor, useValidationSchemas } from '@perses-dev/plugin-system'; -import { PanelDefinition } from '@perses-dev/spec'; +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'; @@ -65,11 +65,16 @@ export function PanelEditorForm(props: PanelEditorFormProps): ReactElement { 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({ + // Persist the selected version (if any) as plugin metadata so the panel uses that specific version. When no + // version is selected (single version available), metadata is omitted so the latest version is used. + const version = plugin.selection.version; + const nextPlugin: Definition & { metadata?: { version?: string } } = { kind: plugin.selection.kind, + ...(version ? { metadata: { version } } : {}), spec: plugin.spec, - }); + }; + form.setValue('panelDefinition.spec.plugin', nextPlugin); + setPlugin(nextPlugin); }, onHideQueryEditorChange: (isHidden) => { setQueries(undefined, isHidden); @@ -214,13 +219,14 @@ export function PanelEditorForm(props: PanelEditorFormProps): ReactElement { { field.onChange(event.kind); pluginEditor.onSelectionChange(event); @@ -264,3 +270,12 @@ export function PanelEditorForm(props: PanelEditorFormProps): ReactElement { * The `id` attribute added to the `PanelEditorForm` component, allowing submit buttons to live outside the form. */ export const panelEditorFormId = 'panel-editor-form'; + +/** + * Reads a pinned plugin version from a panel plugin definition's metadata. The `latest` sentinel is treated as + * "unpinned" so the Type select shows the latest version option rather than an out-of-range value. + */ +function getPinnedPluginVersion(plugin: Definition): string | undefined { + const version = (plugin as { metadata?: { version?: string } }).metadata?.version; + return version && version !== 'latest' ? version : undefined; +} diff --git a/plugin-system/src/components/PluginEditor/plugin-editor-api.ts b/plugin-system/src/components/PluginEditor/plugin-editor-api.ts index 6e5851ea..d89532ce 100644 --- a/plugin-system/src/components/PluginEditor/plugin-editor-api.ts +++ b/plugin-system/src/components/PluginEditor/plugin-editor-api.ts @@ -25,6 +25,11 @@ import { PluginSpecEditorProps } from '../PluginSpecEditor'; export interface PluginEditorSelection { type: PluginType; kind: string; + /** + * Optional plugin version. Only set when the user explicitly selects a specific version of a plugin that has + * multiple versions available. When omitted, the latest available version is used. + */ + version?: string; } export interface PluginEditorValue { diff --git a/plugin-system/src/components/PluginKindSelect/PluginKindSelect.tsx b/plugin-system/src/components/PluginKindSelect/PluginKindSelect.tsx index 16697f4f..d5157d51 100644 --- a/plugin-system/src/components/PluginKindSelect/PluginKindSelect.tsx +++ b/plugin-system/src/components/PluginKindSelect/PluginKindSelect.tsx @@ -14,7 +14,8 @@ import { MenuItem, TextField, TextFieldProps } from '@mui/material'; import { forwardRef, ReactElement, useCallback, useMemo } from 'react'; -import { PluginType } from '../../model'; +import { gt } from 'semver'; +import { PluginType, PluginMetadataWithModule } from '../../model'; import { useListPluginMetadata } from '../../runtime'; import { PluginEditorSelection } from '../PluginEditor'; @@ -23,6 +24,39 @@ export interface PluginKindSelectProps extends Omit void; + /** + * When true, plugins that have more than one version available are listed once per version, labeled + * ` - `. Selecting such an option sets the version on the selection so it can be persisted on + * the definition. Plugins with a single available version are listed without a version (they always use the latest). + * Defaults to false, in which case a single entry per plugin kind is shown (no version). + */ + enableVersionSelection?: boolean; +} + +/** A plugin kind grouped with all of its available versions. */ +interface PluginKindGroup { + type: PluginType; + kind: string; + displayName: string; + /** Available versions, sorted from newest to oldest. */ + versions: string[]; +} + +function getMetadataVersion(metadata: PluginMetadataWithModule): string | undefined { + return metadata.metadata?.version ?? metadata.module?.version; +} + +/** Sort versions from newest to oldest, falling back to a reverse string comparison for non-semver values. */ +function sortVersionsDesc(versions: string[]): string[] { + return [...versions].sort((a, b) => { + try { + if (gt(a, b)) return -1; + if (gt(b, a)) return 1; + return 0; + } catch { + return b.localeCompare(a); + } + }); } /** @@ -33,7 +67,7 @@ export interface PluginKindSelectProps extends Omit { - const { pluginTypes, value: propValue, onChange, filteredQueryPlugins, ...others } = props; + const { pluginTypes, value: propValue, onChange, filteredQueryPlugins, enableVersionSelection, ...others } = props; const { data, isLoading } = useListPluginMetadata(pluginTypes); const sortedData = useMemo(() => { @@ -46,8 +80,53 @@ export const PluginKindSelect = forwardRef((props: PluginKindSelectProps, ref): return data?.sort((a, b) => a.spec.display.name.localeCompare(b.spec.display.name)); }, [data, filteredQueryPlugins]); + // Group the metadata by plugin kind, collecting all the available versions for each one (newest 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, + versions: [], + }; + groups.set(key, group); + } + const version = getMetadataVersion(metadata); + if (version && !group.versions.includes(version)) { + group.versions.push(version); + } + } + for (const group of groups.values()) { + group.versions = sortVersionsDesc(group.versions); + } + return [...groups.values()]; + }, [sortedData]); + + const findGroup = useCallback( + (selection: PluginEditorSelection): PluginKindGroup | undefined => + kindGroups.find((g) => g.type === selection.type && g.kind === selection.kind), + [kindGroups] + ); + // 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 ''; + } + // When version selection is enabled and the definition is not pinned to a version, but multiple versions exist, + // display the newest version option (which is what will actually be used) so the Select has a matching value. + if (enableVersionSelection && !propValue.version) { + const group = findGroup(propValue); + if (group && group.versions.length > 1) { + return selectionToOptionValue({ ...propValue, version: group.versions[0] }); + } + } + return selectionToOptionValue(propValue); + }, [propValue, isLoading, enableVersionSelection, findGroup]); const handleChange = (event: { target: { value: string } }): void => { onChange?.(optionValueToSelection(event.target.value)); @@ -59,10 +138,16 @@ export const PluginKindSelect = forwardRef((props: PluginKindSelectProps, ref): return ''; } const selectedValue = optionValueToSelection(selected as string); - return sortedData?.find((v) => v.kind === selectedValue.type && v.spec.name === selectedValue.kind)?.spec.display - .name; + const group = findGroup(selectedValue); + const displayName = + group?.displayName ?? + sortedData?.find((v) => v.kind === selectedValue.type && v.spec.name === selectedValue.kind)?.spec.display.name; + if (enableVersionSelection && selectedValue.version && group && group.versions.length > 1) { + return `${displayName} - ${selectedValue.version}`; + } + return displayName; }, - [sortedData], + [findGroup, sortedData, enableVersionSelection], ); // TODO: Does this need a loading indicator of some kind? @@ -78,15 +163,40 @@ export const PluginKindSelect = forwardRef((props: PluginKindSelectProps, ref): data-testid="plugin-kind-select" > {isLoading && Loading...} - {sortedData?.map((metadata) => ( - - {metadata.spec.display.name} - - ))} + {enableVersionSelection + ? kindGroups.flatMap((group) => { + // A single available version behaves like "latest": show one entry without a version. + if (group.versions.length <= 1) { + return [ + + {group.displayName} + , + ]; + } + // Multiple versions: one selectable entry per version, labeled " - ". + return group.versions.map((version) => ( + + {`${group.displayName} - ${version}`} + + )); + }) + : sortedData?.map((metadata) => ( + + {metadata.spec.display.name} + + ))} ); }); @@ -96,28 +206,34 @@ 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}` (or `{type}_____{kind}_____{version}` + * when a version is present) that can be used as a Select input value. * @param selector */ function selectionToOptionValue(selector: PluginEditorSelection): string { - return [selector.type, selector.kind].join(OPTION_VALUE_DELIMITER); + const parts = [selector.type, selector.kind]; + if (selector.version) { + parts.push(selector.version); + } + 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}`, 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]; if (type === undefined || kind === undefined) { throw new Error('Invalid optionValue string'); } return { type, kind, + ...(version ? { version } : {}), }; } From 9c5157e746c49ea0c717500c77099c368cc98b8a Mon Sep 17 00:00:00 2001 From: Guillaume LADORME Date: Tue, 18 Aug 2026 16:18:53 +0200 Subject: [PATCH 4/7] Add update drawer Signed-off-by: Guillaume LADORME --- .../DashboardToolbar/DashboardToolbar.tsx | 2 + .../UpdatePluginsButton.tsx | 78 +++++++ .../components/UpdatePluginsButton/index.ts | 14 ++ .../UpdatePluginsDrawer/PanelVersionDiff.tsx | 91 ++++++++ .../UpdatePluginsDrawer.tsx | 195 ++++++++++++++++++ .../components/UpdatePluginsDrawer/index.ts | 15 ++ dashboards/src/components/index.ts | 2 + dashboards/src/utils/pluginVersioning.test.ts | 110 ++++++++++ dashboards/src/utils/pluginVersioning.ts | 167 ++++++++++++++- 9 files changed, 666 insertions(+), 8 deletions(-) create mode 100644 dashboards/src/components/UpdatePluginsButton/UpdatePluginsButton.tsx create mode 100644 dashboards/src/components/UpdatePluginsButton/index.ts create mode 100644 dashboards/src/components/UpdatePluginsDrawer/PanelVersionDiff.tsx create mode 100644 dashboards/src/components/UpdatePluginsDrawer/UpdatePluginsDrawer.tsx create mode 100644 dashboards/src/components/UpdatePluginsDrawer/index.ts diff --git a/dashboards/src/components/DashboardToolbar/DashboardToolbar.tsx b/dashboards/src/components/DashboardToolbar/DashboardToolbar.tsx index c5a64d5e..f6dec25c 100644 --- a/dashboards/src/components/DashboardToolbar/DashboardToolbar.tsx +++ b/dashboards/src/components/DashboardToolbar/DashboardToolbar.tsx @@ -28,6 +28,7 @@ import { EditButton } from '../EditButton'; import { EditJsonButton } from '../EditJsonButton'; import { LinksDisplay } from '../LinksDisplay'; import { LockDashboardButton } from '../LockDashboardButton'; +import { UpdatePluginsButton } from '../UpdatePluginsButton'; import { SaveDashboardButton } from '../SaveDashboardButton'; import { EditVariablesButton } from '../Variables'; @@ -109,6 +110,7 @@ export const DashboardToolbar = (props: DashboardToolbarProps): ReactElement => {isPluginVersioningEnabled && } + {isPluginVersioningEnabled && } + + + 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..a3a8293a --- /dev/null +++ b/dashboards/src/components/UpdatePluginsDrawer/PanelVersionDiff.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 { Alert, Box, Chip, Stack, Typography } from '@mui/material'; +import { PanelDefinition } from '@perses-dev/spec'; +import { DataQueriesProvider } from '@perses-dev/plugin-system'; +import { ErrorAlert, ErrorBoundary } from '@perses-dev/components'; +import { ReactElement, useMemo } from 'react'; +import { Panel } from '../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; +} + +/** Returns a copy of the panel definition with its panel plugin pinned to the given version. */ +function withPluginVersion(panelDefinition: PanelDefinition, version: string): PanelDefinition { + const next: PanelDefinition = JSON.parse(JSON.stringify(panelDefinition)); + const plugin = next.spec.plugin as typeof next.spec.plugin & { metadata?: { version?: string } }; + plugin.metadata = { ...plugin.metadata, version }; + 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 } = props; + + const currentDefinition = useMemo( + () => withPluginVersion(panelDefinition, currentVersion), + [panelDefinition, currentVersion] + ); + const latestDefinition = useMemo( + () => withPluginVersion(panelDefinition, latestVersion), + [panelDefinition, latestVersion] + ); + + 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..2aa9bb2d --- /dev/null +++ b/dashboards/src/components/UpdatePluginsDrawer/UpdatePluginsDrawer.tsx @@ -0,0 +1,195 @@ +// 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 ChevronDown from 'mdi-material-ui/ChevronDown'; +import ChevronUp from 'mdi-material-ui/ChevronUp'; +import ArrowRight from 'mdi-material-ui/ArrowRight'; +import { Drawer, ErrorAlert, ErrorBoundary } from '@perses-dev/components'; +import { ReactElement, useMemo, useState } from 'react'; +import { OutdatedPlugin, getOutdatedPluginId } from '../../utils'; +import { useDashboard } from '../../context'; +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 outdated plugin of a locked dashboard, 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. + const [selectedIds, setSelectedIds] = useState([]); + const [expandedIds, setExpandedIds] = useState([]); + + const allIds = useMemo(() => outdatedPlugins.map((plugin) => getOutdatedPluginId(plugin)), [outdatedPlugins]); + const selectedCount = selectedIds.length; + const isAllSelected = allIds.length > 0 && selectedCount === allIds.length; + const isPartiallySelected = selectedCount > 0 && !isAllSelected; + + const toggleAll = (): void => { + setSelectedIds(isAllSelected ? [] : allIds); + }; + + const toggleOne = (id: string): void => { + setSelectedIds((prev) => (prev.includes(id) ? prev.filter((item) => item !== id) : [...prev, id])); + }; + + const toggleExpanded = (id: string): void => { + setExpandedIds((prev) => (prev.includes(id) ? prev.filter((item) => item !== id) : [...prev, id])); + }; + + const handleUpdate = (): void => { + onUpdate(outdatedPlugins.filter((plugin) => selectedIds.includes(getOutdatedPluginId(plugin)))); + }; + + const handleClose = (): void => { + setSelectedIds([]); + setExpandedIds([]); + 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.includes(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 ( + + + toggleOne(id)} + inputProps={{ 'aria-label': `Select ${plugin.kind}` }} + /> + + + {plugin.kind} + + {plugin.occurrences > 1 && ( + + {plugin.occurrences} usages + + )} + + + + {plugin.currentVersion} + + + + {plugin.latestVersion} + + + + {examplePanel && ( + toggleExpanded(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 4cc643fe..6b1c8423 100644 --- a/dashboards/src/components/index.ts +++ b/dashboards/src/components/index.ts @@ -36,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/utils/pluginVersioning.test.ts b/dashboards/src/utils/pluginVersioning.test.ts index 62a8430f..b4c4b133 100644 --- a/dashboards/src/utils/pluginVersioning.test.ts +++ b/dashboards/src/utils/pluginVersioning.test.ts @@ -19,8 +19,11 @@ import { buildLatestPluginVersions, compareVersions, findInvalidPinnedVersions, + findOutdatedPlugins, + getOutdatedPluginId, isDashboardLocked, removePluginVersions, + updatePluginVersions, } from './pluginVersioning'; function buildMetadata( @@ -221,3 +224,110 @@ describe('buildAvailablePluginVersions / findInvalidPinnedVersions', () => { expect(findInvalidPinnedVersions(buildDashboard(), available)).toEqual([]); }); }); + +describe('findOutdatedPlugins / updatePluginVersions', () => { + const latest = new Map([ + ['TimeSeriesChart', '2.0.0'], + ['PrometheusTimeSeriesQuery', '1.5.0'], + ['PrometheusLabelValuesVariable', '1.2.0'], + ['PrometheusDatasource', '1.3.0'], + ['TempoAnnotation', '1.4.0'], + ]); + + // Lock everything to an older version so every plugin is outdated. + const lockedOld = (): DashboardResource => + applyPluginVersions( + buildDashboard(), + new Map([ + ['TimeSeriesChart', '1.0.0'], + ['PrometheusTimeSeriesQuery', '1.0.0'], + ['PrometheusLabelValuesVariable', '1.0.0'], + ['PrometheusDatasource', '1.0.0'], + ['TempoAnnotation', '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).sort(); + expect(kinds).toEqual([ + 'PrometheusDatasource', + 'PrometheusLabelValuesVariable', + 'PrometheusTimeSeriesQuery', + 'TempoAnnotation', + 'TimeSeriesChart', + ]); + + const panelPlugin = outdated.find((o) => o.kind === 'TimeSeriesChart'); + expect(panelPlugin).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(), new Map([['TimeSeriesChart', 'latest']])); + 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 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' }) + ); + }); +}); diff --git a/dashboards/src/utils/pluginVersioning.ts b/dashboards/src/utils/pluginVersioning.ts index 74de55c2..0ae9392a 100644 --- a/dashboards/src/utils/pluginVersioning.ts +++ b/dashboards/src/utils/pluginVersioning.ts @@ -30,6 +30,12 @@ export interface PluginDefinitionMetadata { */ type VersionedDefinition = Definition & { metadata?: PluginDefinitionMetadata }; +/** + * Sentinel version meaning "the latest version available in the instance" (mirrors the Go `plugin.LatestVersion`). + * A definition using it is not considered pinned to a specific version. + */ +export const LATEST_VERSION = 'latest'; + /** * Plugin types whose definitions may appear inside a dashboard spec and therefore can be pinned to a version when the * dashboard is "locked". @@ -47,19 +53,21 @@ export const PLUGIN_VERSIONING_TYPES: PluginType[] = [ 'Annotation', ]; +/** Split a version string into its numeric segments, ignoring a leading `v`. */ +function normalizeVersion(version: string): number[] { + return version + .replace(/^v/, '') + .split(/[.+-]/) + .map((part) => Number.parseInt(part, 10)); +} + /** * Compare two version strings using a best-effort semantic-versioning comparison. * Returns a positive number when `a` is greater than `b`, a negative number when it is lower, and 0 when equal. */ export function compareVersions(a: string, b: string): number { - const normalize = (v: string): number[] => - v - .replace(/^v/, '') - .split(/[.+-]/) - .map((part) => Number.parseInt(part, 10)); - - const aParts = normalize(a); - const bParts = normalize(b); + const aParts = normalizeVersion(a); + const bParts = normalizeVersion(b); const length = Math.max(aParts.length, bParts.length); for (let i = 0; i < length; i++) { @@ -153,6 +161,149 @@ function cloneDashboard(dashboard: DashboardResource): DashboardResource { return JSON.parse(JSON.stringify(dashboard)); } +/** Context about where a plugin definition lives inside the dashboard spec. */ +interface PluginDefinitionContext { + /** The plugin type (e.g. 'Panel', 'TimeSeriesQuery', 'Variable', ...). */ + pluginType: PluginType; + /** Key of the panel the definition belongs to, for panel plugins and panel query plugins. */ + panelKey?: string; +} + +/** + * Like {@link visitPluginDefinitions}, but also provides the plugin type and (when relevant) the panel key that the + * definition belongs to. Used to tell panel plugins apart from query/variable/datasource/annotation plugins. + */ +function visitPluginDefinitionsWithContext( + dashboard: DashboardResource, + visitor: (definition: VersionedDefinition, 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 as VersionedDefinition, { 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 as VersionedDefinition, { pluginType: query.kind as PluginType, panelKey }); + } + } + } + + for (const variable of spec.variables ?? []) { + if (variable?.kind === 'ListVariable' && variable.spec?.plugin) { + visitor(variable.spec.plugin as VersionedDefinition, { pluginType: 'Variable' }); + } + } + + for (const datasource of Object.values(spec.datasources ?? {})) { + if (datasource?.plugin) { + visitor(datasource.plugin as VersionedDefinition, { pluginType: 'Datasource' }); + } + } + + for (const annotation of spec.annotations ?? []) { + if (annotation?.plugin) { + visitor(annotation.plugin as VersionedDefinition, { pluginType: 'Annotation' }); + } + } +} + +/** A plugin definition pinned to a version older than the latest one available in the instance. */ +export interface OutdatedPlugin { + /** The plugin type (e.g. 'Panel', 'TimeSeriesQuery'). */ + pluginType: PluginType; + /** The plugin kind/name (e.g. 'TimeSeriesChart'). */ + kind: string; + /** 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: Pick): string { + return `${plugin.pluginType}:${plugin.kind}:${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: Map +): OutdatedPlugin[] { + const outdated = new Map(); + + visitPluginDefinitionsWithContext(dashboard, (definition, context) => { + const currentVersion = definition.metadata?.version; + if (!currentVersion || currentVersion === LATEST_VERSION) { + return; + } + const latestVersion = latestVersions.get(definition.kind); + if (!latestVersion || compareVersions(latestVersion, currentVersion) <= 0) { + return; + } + + const id = getOutdatedPluginId({ pluginType: context.pluginType, kind: definition.kind, currentVersion }); + const existing = outdated.get(id); + if (existing) { + existing.occurrences += 1; + if (existing.examplePanelKey === undefined) { + existing.examplePanelKey = context.panelKey; + } + return; + } + outdated.set(id, { + pluginType: context.pluginType, + kind: definition.kind, + 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 = cloneDashboard(dashboard); + visitPluginDefinitionsWithContext(next, (definition, context) => { + const currentVersion = definition.metadata?.version; + if (!currentVersion) { + return; + } + const id = getOutdatedPluginId({ pluginType: context.pluginType, kind: definition.kind, 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 kind is not present in the version map are left untouched. From 8fa04b701baa886f232336fbb1169ad553abeb8b Mon Sep 17 00:00:00 2001 From: Guillaume LADORME Date: Wed, 19 Aug 2026 15:48:00 +0200 Subject: [PATCH 5/7] Handle dev plugin Signed-off-by: Guillaume LADORME --- .../PluginRegistry.dev.test.tsx | 84 +++++++++++++++++++ .../PluginRegistry/PluginRegistry.tsx | 22 +++++ .../PluginRegistry/plugin-indexes.ts | 7 ++ plugin-system/src/model/plugins.ts | 12 +++ 4 files changed, 125 insertions(+) create mode 100644 plugin-system/src/components/PluginRegistry/PluginRegistry.dev.test.tsx 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..68720ced --- /dev/null +++ b/plugin-system/src/components/PluginRegistry/PluginRegistry.dev.test.tsx @@ -0,0 +1,84 @@ +// Copyright The Perses Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { render, screen } from '@testing-library/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +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 e681556b..3b556a96 100644 --- a/plugin-system/src/components/PluginRegistry/PluginRegistry.tsx +++ b/plugin-system/src/components/PluginRegistry/PluginRegistry.tsx @@ -33,6 +33,20 @@ 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; +} + /** * PluginRegistryContext provider that keeps track of all available plugins and provides an API for getting them or * querying the metadata about them. @@ -78,6 +92,14 @@ export function PluginRegistry(props: PluginRegistryProps): ReactElement { // only keep that first candidate. if (version) { candidateKeys = candidateKeys.slice(0, 1); + } else { + // No version 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) { 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/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; } From 6182d57869ed9a96aa3589b058f72484eb5ed154 Mon Sep 17 00:00:00 2001 From: Guillaume LADORME Date: Wed, 19 Aug 2026 16:33:05 +0200 Subject: [PATCH 6/7] Reviewing Signed-off-by: Guillaume LADORME --- .../DashboardToolbar/DashboardToolbar.tsx | 16 ++-- .../components/GridLayout/GridItemContent.tsx | 2 +- .../LockDashboardButton.tsx | 76 ++++++++++++------- .../src/components/Panel/PanelContent.tsx | 2 +- .../components/Panel/PanelPluginLoader.tsx | 2 +- .../UpdatePluginsButton.tsx | 5 +- .../UpdatePluginsDrawer/PanelVersionDiff.tsx | 9 ++- .../UpdatePluginsDrawer.tsx | 12 +-- .../DashboardProvider/use-save-dashboard.ts | 14 +++- dashboards/src/utils/pluginVersioning.test.ts | 13 ++-- dashboards/src/utils/pluginVersioning.ts | 23 +++--- .../PluginKindSelect/PluginKindSelect.tsx | 4 +- .../PluginRegistry.dev.test.tsx | 9 ++- .../PluginRegistry/PluginRegistry.tsx | 2 +- .../components/Variables/variable-model.ts | 4 +- plugin-system/src/runtime/alerts-queries.ts | 2 +- plugin-system/src/runtime/annotations.ts | 2 +- plugin-system/src/runtime/silences-queries.ts | 2 +- .../src/runtime/time-series-queries.ts | 4 +- 19 files changed, 116 insertions(+), 87 deletions(-) diff --git a/dashboards/src/components/DashboardToolbar/DashboardToolbar.tsx b/dashboards/src/components/DashboardToolbar/DashboardToolbar.tsx index f6dec25c..fbb8e2e2 100644 --- a/dashboards/src/components/DashboardToolbar/DashboardToolbar.tsx +++ b/dashboards/src/components/DashboardToolbar/DashboardToolbar.tsx @@ -28,8 +28,8 @@ import { EditButton } from '../EditButton'; import { EditJsonButton } from '../EditJsonButton'; import { LinksDisplay } from '../LinksDisplay'; import { LockDashboardButton } from '../LockDashboardButton'; -import { UpdatePluginsButton } from '../UpdatePluginsButton'; import { SaveDashboardButton } from '../SaveDashboardButton'; +import { UpdatePluginsButton } from '../UpdatePluginsButton'; import { EditVariablesButton } from '../Variables'; export interface DashboardToolbarProps { @@ -109,8 +109,8 @@ export const DashboardToolbar = (props: DashboardToolbarProps): ReactElement => {isLinksEnabled && } - {isPluginVersioningEnabled && } {isPluginVersioningEnabled && } + {isPluginVersioningEnabled && } ) : ( - <> - {isBiggerThanSm && ( - - - - )} - + isBiggerThanSm && ( + + + + ) )} isDashboardLocked(dashboard), [dashboard]); - const handleClick = (): void => { + const openConfirmation = useCallback((): void => setConfirmationOpen(true), []); + const closeConfirmation = useCallback((): void => setConfirmationOpen(false), []); + + const handleConfirm = useCallback((): void => { if (locked) { setDashboard(removePluginVersions(dashboard)); - return; + } else { + const versions = buildLatestPluginVersions(pluginMetadata ?? []); + setDashboard(applyPluginVersions(dashboard, versions)); } - const versions = buildLatestPluginVersions(pluginMetadata ?? []); - setDashboard(applyPluginVersions(dashboard, versions)); - }; + setConfirmationOpen(false); + }, [dashboard, locked, pluginMetadata, setDashboard]); const label = locked ? 'Unlock' : 'Lock'; - const tooltip = locked - ? 'Remove the pinned plugin versions from the dashboard' - : 'Pin every plugin to its latest available version'; + const tooltip = locked ? 'Remove the pinned plugin versions' : 'Pin every plugin to its latest version'; return ( - - - - - + <> + + + + + + + + {locked ? 'Unlock Dashboard' : 'Lock Dashboard'} + + + {locked + ? '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.'} + {' The change only applies once you save the dashboard.'} + + + {label} + Cancel + + + ); } diff --git a/dashboards/src/components/Panel/PanelContent.tsx b/dashboards/src/components/Panel/PanelContent.tsx index 19c870a4..9f7c6851 100644 --- a/dashboards/src/components/Panel/PanelContent.tsx +++ b/dashboards/src/components/Panel/PanelContent.tsx @@ -35,7 +35,7 @@ export function PanelContent(props: PanelContentProps): ReactElement { 'Panel', panelPluginKind, { useErrorBoundary: true }, - getPluginOverrides(definition?.spec.plugin) + getPluginOverrides(definition?.spec.plugin), ); // Show fullsize skeleton if the panel plugin is loading. diff --git a/dashboards/src/components/Panel/PanelPluginLoader.tsx b/dashboards/src/components/Panel/PanelPluginLoader.tsx index 7192d0f4..262cd4f8 100644 --- a/dashboards/src/components/Panel/PanelPluginLoader.tsx +++ b/dashboards/src/components/Panel/PanelPluginLoader.tsx @@ -30,7 +30,7 @@ export function PanelPluginLoader(props: PanelPluginProps): ReactElement { 'Panel', kind, { useErrorBoundary: true }, - getPluginOverrides(definition?.spec.plugin) + getPluginOverrides(definition?.spec.plugin), ); const PanelComponent = plugin?.PanelComponent; const supportedQueryTypes = plugin?.supportedQueryTypes || []; diff --git a/dashboards/src/components/UpdatePluginsButton/UpdatePluginsButton.tsx b/dashboards/src/components/UpdatePluginsButton/UpdatePluginsButton.tsx index 84512c50..3a048bd8 100644 --- a/dashboards/src/components/UpdatePluginsButton/UpdatePluginsButton.tsx +++ b/dashboards/src/components/UpdatePluginsButton/UpdatePluginsButton.tsx @@ -12,9 +12,10 @@ // 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 { useListPluginMetadata } from '@perses-dev/plugin-system'; + import { useDashboard } from '../../context'; import { buildLatestPluginVersions, @@ -54,7 +55,7 @@ export function UpdatePluginsButton(): ReactElement | null { return ( <> - + - - - + {!isLocked && ( + + + + + + )} + {hasPins && ( + + + + + + )} + - {locked ? 'Unlock Dashboard' : 'Lock Dashboard'} + {isUnlockAction ? 'Unlock Dashboard' : 'Lock Dashboard'} - {locked + {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.'} + : '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.'} - {label} + {confirmLabel} Cancel diff --git a/dashboards/src/components/PanelDrawer/PanelEditorForm.tsx b/dashboards/src/components/PanelDrawer/PanelEditorForm.tsx index e017a480..cdbe57b7 100644 --- a/dashboards/src/components/PanelDrawer/PanelEditorForm.tsx +++ b/dashboards/src/components/PanelDrawer/PanelEditorForm.tsx @@ -21,7 +21,13 @@ import { getSubmitText, getTitleAction, } from '@perses-dev/components'; -import { PanelEditorValues, PluginKindSelect, usePluginEditor, useValidationSchemas } from '@perses-dev/plugin-system'; +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,18 +66,24 @@ 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) => { - // Persist the selected version (if any) as plugin metadata so the panel uses that specific version. When no - // version is selected (single version available), metadata is omitted so the latest version is used. - const version = plugin.selection.version; - const nextPlugin: Definition & { metadata?: { version?: string } } = { - kind: plugin.selection.kind, - ...(version ? { metadata: { version } } : {}), - 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); @@ -220,16 +232,17 @@ export function PanelEditorForm(props: PanelEditorFormProps): ReactElement { {...field} pluginTypes={['Panel']} enableVersionSelection + enableRegistrySelection required fullWidth label="Type" disabled={pluginEditor.isLoading} error={!!pluginEditor.error || !!fieldState.error} helperText={pluginEditor.error?.message ?? fieldState.error?.message} - value={{ type: 'Panel', kind: watchedPluginKind, version: getPinnedPluginVersion(plugin) }} - onChange={(event) => { - field.onChange(event.kind); - pluginEditor.onSelectionChange(event); + value={{ type: 'Panel', kind: watchedPluginKind, metadata: pinnedPluginMetadata }} + onChange={(selection) => { + field.onChange(selection.kind); + pluginEditor.onSelectionChange(selection); }} /> )} @@ -270,12 +283,3 @@ export function PanelEditorForm(props: PanelEditorFormProps): ReactElement { * The `id` attribute added to the `PanelEditorForm` component, allowing submit buttons to live outside the form. */ export const panelEditorFormId = 'panel-editor-form'; - -/** - * Reads a pinned plugin version from a panel plugin definition's metadata. The `latest` sentinel is treated as - * "unpinned" so the Type select shows the latest version option rather than an out-of-range value. - */ -function getPinnedPluginVersion(plugin: Definition): string | undefined { - const version = (plugin as { metadata?: { version?: string } }).metadata?.version; - return version && version !== 'latest' ? version : undefined; -} diff --git a/dashboards/src/components/UpdatePluginsButton/UpdatePluginsButton.tsx b/dashboards/src/components/UpdatePluginsButton/UpdatePluginsButton.tsx index 3a048bd8..7e3eb397 100644 --- a/dashboards/src/components/UpdatePluginsButton/UpdatePluginsButton.tsx +++ b/dashboards/src/components/UpdatePluginsButton/UpdatePluginsButton.tsx @@ -16,32 +16,31 @@ import { useListPluginMetadata } from '@perses-dev/plugin-system'; import UpdateIcon from 'mdi-material-ui/Update'; import { ReactElement, useMemo, useState } from 'react'; -import { useDashboard } from '../../context'; +import { useDashboard } from '../../context/useDashboard'; import { buildLatestPluginVersions, findOutdatedPlugins, - isDashboardLocked, OutdatedPlugin, - PLUGIN_VERSIONING_TYPES, updatePluginVersions, -} from '../../utils'; +} from '../../utils/pluginVersioning'; import { UpdatePluginsDrawer } from '../UpdatePluginsDrawer'; /** - * Toolbar button shown next to the lock button when the dashboard is locked and at least one of its pinned plugins has - * a newer version installed. Opens a drawer to review and select which plugins to update. + * 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(PLUGIN_VERSIONING_TYPES); + const { data: pluginMetadata } = useListPluginMetadata(); const [isDrawerOpen, setDrawerOpen] = useState(false); - const outdatedPlugins = useMemo(() => { - if (!isDashboardLocked(dashboard)) { - return []; - } - return findOutdatedPlugins(dashboard, buildLatestPluginVersions(pluginMetadata ?? [])); - }, [dashboard, pluginMetadata]); + const outdatedPlugins = useMemo( + () => findOutdatedPlugins(dashboard, buildLatestPluginVersions(pluginMetadata ?? [])), + [dashboard, pluginMetadata], + ); const handleUpdate = (plugins: OutdatedPlugin[]): void => { setDashboard(updatePluginVersions(dashboard, plugins)); diff --git a/dashboards/src/components/UpdatePluginsDrawer/PanelVersionDiff.tsx b/dashboards/src/components/UpdatePluginsDrawer/PanelVersionDiff.tsx index 084ac92e..c92d620d 100644 --- a/dashboards/src/components/UpdatePluginsDrawer/PanelVersionDiff.tsx +++ b/dashboards/src/components/UpdatePluginsDrawer/PanelVersionDiff.tsx @@ -17,7 +17,7 @@ import { DataQueriesProvider } from '@perses-dev/plugin-system'; import { PanelDefinition } from '@perses-dev/spec'; import { ReactElement, useMemo } from 'react'; -import { Panel } from '../Panel'; +import { Panel } from '../Panel/Panel'; const PREVIEW_HEIGHT = 260; @@ -28,13 +28,14 @@ export interface PanelVersionDiffProps { 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. */ -function withPluginVersion(panelDefinition: PanelDefinition, version: string): PanelDefinition { - const next: PanelDefinition = JSON.parse(JSON.stringify(panelDefinition)); - const plugin = next.spec.plugin as typeof next.spec.plugin & { metadata?: { version?: string } }; - plugin.metadata = { ...plugin.metadata, version }; +/** 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; } @@ -43,15 +44,15 @@ function withPluginVersion(panelDefinition: PanelDefinition, version: string): P * 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 } = props; + const { panelDefinition, currentVersion, latestVersion, registry } = props; const currentDefinition = useMemo( - () => withPluginVersion(panelDefinition, currentVersion), - [panelDefinition, currentVersion], + () => withPluginVersion(panelDefinition, currentVersion, registry), + [panelDefinition, currentVersion, registry], ); const latestDefinition = useMemo( - () => withPluginVersion(panelDefinition, latestVersion), - [panelDefinition, latestVersion], + () => withPluginVersion(panelDefinition, latestVersion, registry), + [panelDefinition, latestVersion, registry], ); const queries = panelDefinition.spec.queries ?? []; diff --git a/dashboards/src/components/UpdatePluginsDrawer/UpdatePluginsDrawer.tsx b/dashboards/src/components/UpdatePluginsDrawer/UpdatePluginsDrawer.tsx index 85e126bf..ac302058 100644 --- a/dashboards/src/components/UpdatePluginsDrawer/UpdatePluginsDrawer.tsx +++ b/dashboards/src/components/UpdatePluginsDrawer/UpdatePluginsDrawer.tsx @@ -29,8 +29,8 @@ import ChevronDown from 'mdi-material-ui/ChevronDown'; import ChevronUp from 'mdi-material-ui/ChevronUp'; import { ReactElement, useMemo, useState } from 'react'; -import { useDashboard } from '../../context'; -import { OutdatedPlugin, getOutdatedPluginId } from '../../utils'; +import { useDashboard } from '../../context/useDashboard'; +import { OutdatedPlugin, getOutdatedPluginId } from '../../utils/pluginVersioning'; import { PanelVersionDiff } from './PanelVersionDiff'; export interface UpdatePluginsDrawerProps { @@ -43,9 +43,9 @@ export interface UpdatePluginsDrawerProps { } /** - * Drawer listing every outdated plugin of a locked dashboard, 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. + * 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; @@ -53,33 +53,47 @@ export function UpdatePluginsDrawer(props: UpdatePluginsDrawerProps): ReactEleme const panels = dashboard.spec.panels ?? {}; // Selected plugin ids. Everything starts unselected so updating is always an explicit action. - const [selectedIds, setSelectedIds] = useState([]); - const [expandedIds, setExpandedIds] = useState([]); + // 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(() => outdatedPlugins.map((plugin) => getOutdatedPluginId(plugin)), [outdatedPlugins]); - const selectedCount = selectedIds.length; - const isAllSelected = allIds.length > 0 && selectedCount === allIds.length; + 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 ? [] : allIds); + setSelectedIds(isAllSelected ? new Set() : new Set(allIds)); }; - const toggleOne = (id: string): void => { - setSelectedIds((prev) => (prev.includes(id) ? prev.filter((item) => item !== id) : [...prev, id])); + 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 toggleExpanded = (id: string): void => { - setExpandedIds((prev) => (prev.includes(id) ? prev.filter((item) => item !== id) : [...prev, id])); + const resetSelection = (): void => { + setSelectedIds(new Set()); + setExpandedIds(new Set()); }; const handleUpdate = (): void => { - onUpdate(outdatedPlugins.filter((plugin) => selectedIds.includes(getOutdatedPluginId(plugin)))); + 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 => { - setSelectedIds([]); - setExpandedIds([]); + resetSelection(); onClose(); }; @@ -127,7 +141,7 @@ export function UpdatePluginsDrawer(props: UpdatePluginsDrawerProps): ReactEleme }> {outdatedPlugins.map((plugin) => { const id = getOutdatedPluginId(plugin); - const isExpanded = expandedIds.includes(id); + 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; @@ -136,8 +150,8 @@ export function UpdatePluginsDrawer(props: UpdatePluginsDrawerProps): ReactEleme toggleOne(id)} + checked={selectedIds.has(id)} + onChange={() => toggleId(setSelectedIds, id)} inputProps={{ 'aria-label': `Select ${plugin.kind}` }} /> @@ -162,7 +176,7 @@ export function UpdatePluginsDrawer(props: UpdatePluginsDrawerProps): ReactEleme {examplePanel && ( toggleExpanded(id)} + onClick={() => toggleId(setExpandedIds, id)} aria-label={isExpanded ? `Hide preview of ${plugin.kind}` : `Show preview of ${plugin.kind}`} aria-expanded={isExpanded} > @@ -179,6 +193,7 @@ export function UpdatePluginsDrawer(props: UpdatePluginsDrawerProps): ReactEleme panelDefinition={examplePanel} currentVersion={plugin.currentVersion} latestVersion={plugin.latestVersion} + registry={plugin.registry} /> diff --git a/dashboards/src/context/DashboardProvider/use-save-dashboard.ts b/dashboards/src/context/DashboardProvider/use-save-dashboard.ts index 03f8febb..0aefe440 100644 --- a/dashboards/src/context/DashboardProvider/use-save-dashboard.ts +++ b/dashboards/src/context/DashboardProvider/use-save-dashboard.ts @@ -11,19 +11,12 @@ // See the License for the specific language governing permissions and // limitations under the License. -import { useSnackbar } from '@perses-dev/components'; -import { - useTimeRange, - useTimeZoneParams, - useDisableAutoRefreshSetting, - usePluginRegistry, -} from '@perses-dev/plugin-system'; +import { useTimeRange, useTimeZoneParams, useDisableAutoRefreshSetting } from '@perses-dev/plugin-system'; import { isRelativeTimeRange } from '@perses-dev/spec'; import { useCallback, useState } from 'react'; -import { buildAvailablePluginVersions, findInvalidPinnedVersions, PLUGIN_VERSIONING_TYPES } from '../../utils'; import { useDashboard } from '../useDashboard'; -import { useVariableDefinitionActions } from '../VariableProvider'; +import { useVariableDefinitionActions } from '../VariableProvider/VariableProvider'; import { OnSaveDashboard } from './common'; import { useEditMode, useSaveChangesConfirmationDialog } from './dashboard-provider-api'; import { SaveChangesConfirmationDialogOptions } from './save-changes-dialog-slice'; @@ -61,8 +54,6 @@ export function useSaveDashboard(onSave?: OnSaveDashboard): SaveDashboardResult const { timeZone } = useTimeZoneParams(); const { getSavedVariablesStatus, setVariableDefaultValues } = useVariableDefinitionActions(); const { openSaveChangesConfirmationDialog, closeSaveChangesConfirmationDialog } = useSaveChangesConfirmationDialog(); - const { listPluginMetadata } = usePluginRegistry(); - const { exceptionSnackbar } = useSnackbar(); const performSave = useCallback(async (): Promise => { if (!onSave) { setEditMode(false); @@ -71,22 +62,13 @@ export function useSaveDashboard(onSave?: OnSaveDashboard): SaveDashboardResult try { setSaving(true); - // Validate that any pinned plugin version (plugin.metadata.version) actually exists in the registry before - // saving, so we never persist a dashboard referencing a plugin version that cannot be loaded. - const pluginMetadata = await listPluginMetadata(PLUGIN_VERSIONING_TYPES); - const invalidPins = findInvalidPinnedVersions(dashboard, buildAvailablePluginVersions(pluginMetadata)); - if (invalidPins.length > 0) { - const details = invalidPins.map((pin) => `${pin.kind}@${pin.version}`).join(', '); - exceptionSnackbar(new Error(`Cannot save dashboard: pinned plugin version(s) not available: ${details}`)); - return; - } await onSave(dashboard); closeSaveChangesConfirmationDialog(); setEditMode(false); } finally { setSaving(false); } - }, [closeSaveChangesConfirmationDialog, dashboard, onSave, setEditMode, listPluginMetadata, exceptionSnackbar]); + }, [closeSaveChangesConfirmationDialog, dashboard, onSave, setEditMode]); const saveDashboard = useCallback((): void => { if (isSaving) { diff --git a/dashboards/src/context/DatasourceStoreProvider.tsx b/dashboards/src/context/DatasourceStoreProvider.tsx index cd61cedd..d8bd1148 100644 --- a/dashboards/src/context/DatasourceStoreProvider.tsx +++ b/dashboards/src/context/DatasourceStoreProvider.tsx @@ -134,7 +134,6 @@ export function DatasourceStoreProvider(props: DatasourceStoreProviderProps): Re async function getClient(selector: DatasourceSelector): Promise { const { kind } = selector; const { spec, proxyUrl } = await findDatasource(selector); - // Resolve the datasource plugin, honoring any version/registry pinned in the datasource spec. const plugin = await getPlugin({ kind: 'Datasource', name: kind, ...getPluginOverrides(spec.plugin) }); // allows extending client diff --git a/dashboards/src/utils/pluginVersioning.test.ts b/dashboards/src/utils/pluginVersioning.test.ts index 03224991..475dd6c3 100644 --- a/dashboards/src/utils/pluginVersioning.test.ts +++ b/dashboards/src/utils/pluginVersioning.test.ts @@ -16,13 +16,13 @@ import { PluginMetadataWithModule } from '@perses-dev/plugin-system'; import { applyPluginVersions, - buildAvailablePluginVersions, buildLatestPluginVersions, - compareVersions, - findInvalidPinnedVersions, findOutdatedPlugins, getOutdatedPluginId, + getPluginIdentityKey, + hasPinnedPluginVersions, isDashboardLocked, + LatestPluginVersions, removePluginVersions, updatePluginVersions, } from './pluginVersioning'; @@ -31,17 +31,33 @@ function buildMetadata( kind: string, name: string, moduleVersion: string, - pluginVersion?: string, + options?: { pluginVersion?: string; registry?: string }, ): PluginMetadataWithModule { return { kind, - metadata: pluginVersion ? { version: pluginVersion } : undefined, + metadata: options?.pluginVersion ? { version: options.pluginVersion } : undefined, spec: { name, display: { name } }, - module: { name: `${name}-module`, version: moduleVersion }, + 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', @@ -100,44 +116,66 @@ function buildDashboard(): DashboardResource { }; } -describe('compareVersions', () => { - 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], - ['0.10.0', '0.9.0', 1], - ])('compareVersions(%s, %s)', (a, b, expected) => { - expect(Math.sign(compareVersions(a as string, b as string))).toBe(expected); - }); -}); - describe('buildLatestPluginVersions', () => { - test('keeps the highest version per plugin name and prefers plugin-level version', () => { - const metadata: PluginMetadataWithModule[] = [ + 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', '2.0.0'), - ]; - const versions = buildLatestPluginVersions(metadata); - expect(versions.get('TimeSeriesChart')).toBe('0.3.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('PrometheusTimeSeriesQuery')).toBe('2.0.0'); + 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 = new Map([ - ['TimeSeriesChart', '1.0.0'], - ['PrometheusTimeSeriesQuery', '1.1.0'], - ['PrometheusLabelValuesVariable', '1.2.0'], - ['PrometheusDatasource', '1.3.0'], - ['TempoAnnotation', '1.4.0'], + 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 not locked', () => { + 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', () => { @@ -162,91 +200,47 @@ describe('applyPluginVersions / removePluginVersions / isDashboardLocked', () => }); test('removePluginVersions reverts the lock', () => { - const dashboard = buildDashboard(); - const locked = applyPluginVersions(dashboard, versions); + 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 dashboard = buildDashboard(); - const partial = applyPluginVersions(dashboard, new Map([['TimeSeriesChart', '1.0.0']])); + 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(); }); -}); - -describe('buildAvailablePluginVersions / findInvalidPinnedVersions', () => { - const available = buildAvailablePluginVersions([ - buildMetadata('Panel', 'TimeSeriesChart', '1.0.0'), - buildMetadata('Panel', 'TimeSeriesChart', '2.0.0'), - buildMetadata('TimeSeriesQuery', 'PrometheusTimeSeriesQuery', '1.1.0'), - buildMetadata('Variable', 'PrometheusLabelValuesVariable', '1.2.0'), - buildMetadata('Datasource', 'PrometheusDatasource', '1.3.0'), - buildMetadata('Annotation', 'TempoAnnotation', '1.4.0'), - ]); - - test('collects every available version per plugin name', () => { - expect(available.get('TimeSeriesChart')).toEqual(new Set(['1.0.0', '2.0.0'])); - }); - - test('no invalid pins when all pinned versions exist', () => { - const dashboard = applyPluginVersions( - buildDashboard(), - new Map([ - ['TimeSeriesChart', '2.0.0'], - ['PrometheusTimeSeriesQuery', '1.1.0'], - ['PrometheusLabelValuesVariable', '1.2.0'], - ['PrometheusDatasource', '1.3.0'], - ['TempoAnnotation', '1.4.0'], - ]), - ); - expect(findInvalidPinnedVersions(dashboard, available)).toEqual([]); - }); - test('reports pins whose version is not available', () => { - const dashboard = applyPluginVersions( - buildDashboard(), - new Map([ - ['TimeSeriesChart', '99.0.0'], // not available - ['PrometheusTimeSeriesQuery', '1.1.0'], // available - ]), - ); - const invalid = findInvalidPinnedVersions(dashboard, available); - expect(invalid).toEqual([{ kind: 'TimeSeriesChart', version: '99.0.0' }]); + 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('unpinned dashboards have no invalid pins', () => { - expect(findInvalidPinnedVersions(buildDashboard(), available)).toEqual([]); + 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 = new Map([ - ['TimeSeriesChart', '2.0.0'], - ['PrometheusTimeSeriesQuery', '1.5.0'], - ['PrometheusLabelValuesVariable', '1.2.0'], - ['PrometheusDatasource', '1.3.0'], - ['TempoAnnotation', '1.4.0'], + 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(), - new Map([ - ['TimeSeriesChart', '1.0.0'], - ['PrometheusTimeSeriesQuery', '1.0.0'], - ['PrometheusLabelValuesVariable', '1.0.0'], - ['PrometheusDatasource', '1.0.0'], - ['TempoAnnotation', '1.0.0'], - ]), - ); + const lockedOld = (): DashboardResource => applyPluginVersions(buildDashboard(), allPluginsAt('1.0.0')); test('an unpinned dashboard reports nothing as outdated', () => { expect(findOutdatedPlugins(buildDashboard(), latest)).toEqual([]); @@ -259,7 +253,7 @@ describe('findOutdatedPlugins / updatePluginVersions', () => { test('detects outdated plugins with their type, versions and example panel', () => { const outdated = findOutdatedPlugins(lockedOld(), latest); - const kinds = outdated.map((o) => o.kind).sort(); + const kinds = outdated.map((o) => o.kind).toSorted(); expect(kinds).toEqual([ 'PrometheusDatasource', 'PrometheusLabelValuesVariable', @@ -268,8 +262,7 @@ describe('findOutdatedPlugins / updatePluginVersions', () => { 'TimeSeriesChart', ]); - const panelPlugin = outdated.find((o) => o.kind === 'TimeSeriesChart'); - expect(panelPlugin).toMatchObject({ + expect(outdated.find((o) => o.kind === 'TimeSeriesChart')).toMatchObject({ pluginType: 'Panel', currentVersion: '1.0.0', latestVersion: '2.0.0', @@ -286,7 +279,22 @@ describe('findOutdatedPlugins / updatePluginVersions', () => { }); test('the `latest` sentinel is not considered outdated', () => { - const dashboard = applyPluginVersions(buildDashboard(), new Map([['TimeSeriesChart', 'latest']])); + 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([]); }); @@ -323,12 +331,15 @@ describe('findOutdatedPlugins / updatePluginVersions', () => { expect(updatePluginVersions(dashboard, [])).toBe(dashboard); }); - test('getOutdatedPluginId distinguishes plugin type, kind and version', () => { + 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', + '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 index 74a50e18..9dc002d3 100644 --- a/dashboards/src/utils/pluginVersioning.ts +++ b/dashboards/src/utils/pluginVersioning.ts @@ -12,205 +12,146 @@ // limitations under the License. import { DashboardResource } from '@perses-dev/client'; -import { PluginMetadataWithModule, PluginType } from '@perses-dev/plugin-system'; +import { comparePluginVersions, LATEST_PLUGIN_VERSION, PluginMetadataWithModule } from '@perses-dev/plugin-system'; import { Definition } from '@perses-dev/spec'; /** - * Optional metadata attached to a plugin definition. Mirrors the backend `Plugin.Metadata` model (and the - * `@perses-dev/spec` `Definition.metadata` field) so a plugin can be pinned to a specific version/registry. + * 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 PluginDefinitionMetadata { - version?: string; +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 plugin definition that may carry version/registry metadata. We augment the spec `Definition` type locally so the - * feature keeps compiling regardless of the installed `@perses-dev/spec` version. - */ -type VersionedDefinition = Definition & { metadata?: PluginDefinitionMetadata }; - -/** - * Sentinel version meaning "the latest version available in the instance" (mirrors the Go `plugin.LatestVersion`). - * A definition using it is not considered pinned to a specific version. - */ -export const LATEST_VERSION = 'latest'; - -/** - * Plugin types whose definitions may appear inside a dashboard spec and therefore can be pinned to a version when the - * dashboard is "locked". - */ -export const PLUGIN_VERSIONING_TYPES: PluginType[] = [ - 'Panel', - 'TimeSeriesQuery', - 'TraceQuery', - 'ProfileQuery', - 'LogQuery', - 'AlertsQuery', - 'SilencesQuery', - 'Variable', - 'Datasource', - 'Annotation', -]; - -/** Split a version string into its numeric segments, ignoring a leading `v`. */ -function normalizeVersion(version: string): number[] { - return version - .replace(/^v/, '') - .split(/[.+-]/) - .map((part) => Number.parseInt(part, 10)); -} - -/** - * Compare two version strings using a best-effort semantic-versioning comparison. - * Returns a positive number when `a` is greater than `b`, a negative number when it is lower, and 0 when equal. - */ -export function compareVersions(a: string, b: string): number { - const aParts = normalizeVersion(a); - const bParts = normalizeVersion(b); - const length = Math.max(aParts.length, bParts.length); - - for (let i = 0; i < length; i++) { - const aPart = aParts[i]; - const bPart = bParts[i]; - // If a segment is not a number (e.g. a pre-release tag), fall back to a string comparison of the whole version. - if (aPart === undefined || Number.isNaN(aPart) || bPart === undefined || Number.isNaN(bPart)) { - return a.localeCompare(b); - } - if (aPart !== bPart) { - return aPart - bPart; - } - } - return 0; -} - -/** - * 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 getPluginVersion(metadata: PluginMetadataWithModule): string | undefined { - return metadata.metadata?.version ?? metadata.module?.version; +/** 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 ?? ''}`; } -/** - * Build a map of plugin kind (e.g. "TimeSeriesChart") to the latest version currently available in the instance, based - * on the installed plugin metadata returned by the plugin registry. - */ -export function buildLatestPluginVersions(pluginMetadata: PluginMetadataWithModule[]): Map { - const versions = new Map(); - for (const metadata of pluginMetadata) { - const name = metadata.spec?.name; - const version = getPluginVersion(metadata); - if (!name || !version) { - continue; - } - const existing = versions.get(name); - if (existing === undefined || compareVersions(version, existing) > 0) { - versions.set(name, version); - } - } - return versions; +/** 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 for each one. Covers - * panel plugins, panel query plugins, list-variable plugins, datasource plugins and annotation plugins. + * 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: VersionedDefinition) => void, + visitor: (definition: Definition, context: PluginDefinitionContext) => void, ): void { const spec = dashboard.spec; - // Panels and their queries - for (const panel of Object.values(spec.panels ?? {})) { + for (const [panelKey, panel] of Object.entries(spec.panels ?? {})) { if (panel?.spec?.plugin) { - visitor(panel.spec.plugin as VersionedDefinition); + visitor(panel.spec.plugin, { pluginType: 'Panel', panelKey }); } for (const query of panel?.spec?.queries ?? []) { - if (query?.spec?.plugin) { - visitor(query.spec.plugin as VersionedDefinition); + // 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 }); } } } - // Variables (only list variables reference a plugin) + // Only list variables reference a plugin. for (const variable of spec.variables ?? []) { if (variable?.kind === 'ListVariable' && variable.spec?.plugin) { - visitor(variable.spec.plugin as VersionedDefinition); + visitor(variable.spec.plugin, { pluginType: 'Variable' }); } } - // Datasources for (const datasource of Object.values(spec.datasources ?? {})) { if (datasource?.plugin) { - visitor(datasource.plugin as VersionedDefinition); + visitor(datasource.plugin, { pluginType: 'Datasource' }); } } - // Annotations for (const annotation of spec.annotations ?? []) { if (annotation?.plugin) { - visitor(annotation.plugin as VersionedDefinition); + visitor(annotation.plugin, { pluginType: 'Annotation' }); } } } -/** Context about where a plugin definition lives inside the dashboard spec. */ -interface PluginDefinitionContext { - /** The plugin type (e.g. 'Panel', 'TimeSeriesQuery', 'Variable', ...). */ - pluginType: PluginType; - /** Key of the panel the definition belongs to, for panel plugins and panel query plugins. */ - panelKey?: string; +/** 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 }; } /** - * Like {@link visitPluginDefinitions}, but also provides the plugin type and (when relevant) the panel key that the - * definition belongs to. Used to tell panel plugins apart from query/variable/datasource/annotation plugins. + * 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 visitPluginDefinitionsWithContext( - dashboard: DashboardResource, - visitor: (definition: VersionedDefinition, context: PluginDefinitionContext) => void, -): void { - const spec = dashboard.spec; +function getPinnedVersion(definition: Definition): string | undefined { + const version = definition.metadata?.version; + return version && version !== LATEST_PLUGIN_VERSION ? version : undefined; +} - for (const [panelKey, panel] of Object.entries(spec.panels ?? {})) { - if (panel?.spec?.plugin) { - visitor(panel.spec.plugin as VersionedDefinition, { 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 as VersionedDefinition, { pluginType: query.kind as PluginType, panelKey }); - } - } - } +/** + * 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; +} - for (const variable of spec.variables ?? []) { - if (variable?.kind === 'ListVariable' && variable.spec?.plugin) { - visitor(variable.spec.plugin as VersionedDefinition, { pluginType: 'Variable' }); - } - } +/** 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; +} - for (const datasource of Object.values(spec.datasources ?? {})) { - if (datasource?.plugin) { - visitor(datasource.plugin as VersionedDefinition, { pluginType: 'Datasource' }); +/** + * 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 annotation of spec.annotations ?? []) { - if (annotation?.plugin) { - visitor(annotation.plugin as VersionedDefinition, { pluginType: 'Annotation' }); + 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 { - /** The plugin type (e.g. 'Panel', 'TimeSeriesQuery'). */ - pluginType: PluginType; - /** The plugin kind/name (e.g. 'TimeSeriesChart'). */ - kind: string; +export interface OutdatedPlugin extends PluginIdentity { /** The version currently pinned in the dashboard spec. */ currentVersion: string; /** The latest version available in the instance. */ @@ -227,8 +168,8 @@ export interface OutdatedPlugin { /** * A stable identity for an outdated plugin entry, usable as a React key or selection key. */ -export function getOutdatedPluginId(plugin: Pick): string { - return `${plugin.pluginType}:${plugin.kind}:${plugin.currentVersion}`; +export function getOutdatedPluginId(plugin: PluginIdentity & Pick): string { + return `${getPluginIdentityKey(plugin)}:${plugin.currentVersion}`; } /** @@ -237,32 +178,30 @@ export function getOutdatedPluginId(plugin: Pick, + latestVersions: LatestPluginVersions, ): OutdatedPlugin[] { const outdated = new Map(); - visitPluginDefinitionsWithContext(dashboard, (definition, context) => { - const currentVersion = definition.metadata?.version; - if (!currentVersion || currentVersion === LATEST_VERSION) { + visitPluginDefinitions(dashboard, (definition, context) => { + const currentVersion = getPinnedVersion(definition); + if (!currentVersion) { return; } - const latestVersion = latestVersions.get(definition.kind); - if (!latestVersion || compareVersions(latestVersion, currentVersion) <= 0) { + const identity = getDefinitionIdentity(definition, context); + const latestVersion = latestVersions.get(getPluginIdentityKey(identity)); + if (!latestVersion || comparePluginVersions(latestVersion, currentVersion) <= 0) { return; } - const id = getOutdatedPluginId({ pluginType: context.pluginType, kind: definition.kind, currentVersion }); + const id = getOutdatedPluginId({ ...identity, currentVersion }); const existing = outdated.get(id); if (existing) { existing.occurrences += 1; - if (existing.examplePanelKey === undefined) { - existing.examplePanelKey = context.panelKey; - } + existing.examplePanelKey ??= context.panelKey; return; } outdated.set(id, { - pluginType: context.pluginType, - kind: definition.kind, + ...identity, currentVersion, latestVersion, occurrences: 1, @@ -285,12 +224,12 @@ export function updatePluginVersions(dashboard: DashboardResource, plugins: Outd } const targets = new Map(plugins.map((plugin) => [getOutdatedPluginId(plugin), plugin.latestVersion])); const next = structuredClone(dashboard); - visitPluginDefinitionsWithContext(next, (definition, context) => { - const currentVersion = definition.metadata?.version; + visitPluginDefinitions(next, (definition, context) => { + const currentVersion = getPinnedVersion(definition); if (!currentVersion) { return; } - const id = getOutdatedPluginId({ pluginType: context.pluginType, kind: definition.kind, currentVersion }); + const id = getOutdatedPluginId({ ...getDefinitionIdentity(definition, context), currentVersion }); const latestVersion = targets.get(id); if (latestVersion) { definition.metadata = { ...definition.metadata, version: latestVersion }; @@ -301,12 +240,13 @@ export function updatePluginVersions(dashboard: DashboardResource, plugins: Outd /** * Return a copy of the dashboard with every plugin definition pinned to its latest available version. Plugin - * definitions whose kind is not present in the version map are left untouched. + * 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: Map): DashboardResource { +export function applyPluginVersions(dashboard: DashboardResource, versions: LatestPluginVersions): DashboardResource { const next = structuredClone(dashboard); - visitPluginDefinitions(next, (definition) => { - const version = versions.get(definition.kind); + visitPluginDefinitions(next, (definition, context) => { + const version = versions.get(getPluginIdentityKey(getDefinitionIdentity(definition, context))); if (version) { definition.metadata = { ...definition.metadata, version }; } @@ -335,70 +275,34 @@ export function removePluginVersions(dashboard: DashboardResource): DashboardRes } /** - * A dashboard is considered "locked" as soon as at least one of its plugin definitions is pinned to a version. + * 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 locked = false; + let total = 0; + let pinned = 0; visitPluginDefinitions(dashboard, (definition) => { - if (definition.metadata?.version) { - locked = true; + total += 1; + if (getPinnedVersion(definition)) { + pinned += 1; } }); - return locked; + return total > 0 && pinned === total; } /** - * Build a map of plugin kind (e.g. "TimeSeriesChart") to the full set of versions currently available in the instance, - * based on the installed plugin metadata returned by the plugin registry. + * 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 buildAvailablePluginVersions(pluginMetadata: PluginMetadataWithModule[]): Map> { - const versions = new Map>(); - for (const metadata of pluginMetadata) { - const name = metadata.spec?.name; - const version = getPluginVersion(metadata); - if (!name || !version) { - continue; - } - let set = versions.get(name); - if (!set) { - set = new Set(); - versions.set(name, set); - } - set.add(version); - } - return versions; -} - -/** A plugin definition pinned to a version that is not available in the registry. */ -export interface InvalidPinnedVersion { - kind: string; - version: string; -} - -/** - * Return the list of plugin definitions in the dashboard that are pinned to a version which is not present in the - * provided set of available versions (built from the plugin registry). An empty result means every pin is valid. - */ -export function findInvalidPinnedVersions( - dashboard: DashboardResource, - availableVersions: Map>, -): InvalidPinnedVersion[] { - const invalid: InvalidPinnedVersion[] = []; - const seen = new Set(); +export function hasPinnedPluginVersions(dashboard: DashboardResource): boolean { + let pinned = false; visitPluginDefinitions(dashboard, (definition) => { - const version = definition.metadata?.version; - if (!version) { - return; - } - const identity = `${definition.kind}@${version}`; - if (seen.has(identity)) { - return; - } - seen.add(identity); - const versions = availableVersions.get(definition.kind); - if (!versions || !versions.has(version)) { - invalid.push({ kind: definition.kind, version }); + if (getPinnedVersion(definition)) { + pinned = true; } }); - return invalid; + return pinned; } diff --git a/dashboards/src/views/ViewDashboard/DashboardApp.tsx b/dashboards/src/views/ViewDashboard/DashboardApp.tsx index be94dbac..6c208874 100644 --- a/dashboards/src/views/ViewDashboard/DashboardApp.tsx +++ b/dashboards/src/views/ViewDashboard/DashboardApp.tsx @@ -45,9 +45,10 @@ export interface DashboardAppProps { disableShortcuts?: boolean; isCreating?: boolean; /** - * When true, displays the dashboard "lock/unlock" button that pins plugin versions. Disabled by default. + * 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. */ - isPluginVersioningEnabled?: boolean; + isLockModeAvailable?: boolean; isInitialVariableSticky?: boolean; // If true, browser confirmation dialog will be shown when navigating away with unsaved changes (closing tab, ...). isLeavingConfirmDialogEnabled?: boolean; @@ -77,7 +78,7 @@ const DashboardAppContent = (props: DashboardAppProps): ReactElement => { isCreating, isInitialVariableSticky, isLeavingConfirmDialogEnabled, - isPluginVersioningEnabled, + isLockModeAvailable, dashboardTitleComponent, userPreferenceTimezone, onSave, @@ -162,7 +163,7 @@ const DashboardAppContent = (props: DashboardAppProps): ReactElement => { isVariableEnabled={isVariableEnabled} isAnnotationEnabled={isAnnotationEnabled} isDatasourceEnabled={isDatasourceEnabled} - isPluginVersioningEnabled={isPluginVersioningEnabled} + isLockModeAvailable={isLockModeAvailable} onEditButtonClick={onEditButtonClick} onCancelButtonClick={onCancelButtonClick} /> diff --git a/dashboards/src/views/ViewDashboard/ViewDashboard.tsx b/dashboards/src/views/ViewDashboard/ViewDashboard.tsx index 0c553cc0..099ca4ba 100644 --- a/dashboards/src/views/ViewDashboard/ViewDashboard.tsx +++ b/dashboards/src/views/ViewDashboard/ViewDashboard.tsx @@ -59,7 +59,7 @@ export function ViewDashboard(props: ViewDashboardProps): ReactElement { isCreating, isInitialVariableSticky, isLeavingConfirmDialogEnabled, - isPluginVersioningEnabled, + isLockModeAvailable, dashboardTitleComponent, onSave, onDiscard, @@ -154,7 +154,7 @@ export function ViewDashboard(props: ViewDashboardProps): ReactElement { isCreating={isCreating} isInitialVariableSticky={isInitialVariableSticky} isLeavingConfirmDialogEnabled={isLeavingConfirmDialogEnabled} - isPluginVersioningEnabled={isPluginVersioningEnabled} + 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 d89532ce..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'; @@ -26,10 +26,11 @@ export interface PluginEditorSelection { type: PluginType; kind: string; /** - * Optional plugin version. Only set when the user explicitly selects a specific version of a plugin that has - * multiple versions available. When omitted, the latest available version is used. + * 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. */ - version?: string; + metadata?: PluginDefinitionMetadata; } export interface PluginEditorValue { @@ -129,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 079d5ab7..7193543b 100644 --- a/plugin-system/src/components/PluginKindSelect/PluginKindSelect.tsx +++ b/plugin-system/src/components/PluginKindSelect/PluginKindSelect.tsx @@ -12,11 +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 { gt } from 'semver'; import { PluginType, PluginMetadataWithModule } from '../../model'; import { useListPluginMetadata } from '../../runtime'; +import { comparePluginVersions } from '../../utils'; import { PluginEditorSelection } from '../PluginEditor'; export interface PluginKindSelectProps extends Omit { @@ -25,38 +26,93 @@ export interface PluginKindSelectProps extends Omit void; /** - * When true, plugins that have more than one version available are listed once per version, labeled - * ` - `. Selecting such an option sets the version on the selection so it can be persisted on - * the definition. Plugins with a single available version are listed without a version (they always use the latest). - * Defaults to false, in which case a single entry per plugin kind is shown (no version). + * 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 its available versions. */ +/** A plugin kind grouped with all of the variants it is installed under. */ interface PluginKindGroup { type: PluginType; kind: string; displayName: string; - /** Available versions, sorted from newest to oldest. */ - versions: 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 getMetadataVersion(metadata: PluginMetadataWithModule): string | undefined { - return metadata.metadata?.version ?? metadata.module?.version; +function getVariantKey(variant: PluginDefinitionMetadata): string { + return `${variant.version ?? ''}:${variant.registry ?? ''}`; } -/** Sort versions from newest to oldest, falling back to a reverse string comparison for non-semver values. */ -function sortVersionsDesc(versions: string[]): string[] { - return [...versions].sort((a, b) => { - try { - if (gt(a, b)) return -1; - if (gt(b, a)) return 1; - return 0; - } catch { - return b.localeCompare(a); +/** + * 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; } /** @@ -67,20 +123,25 @@ function sortVersionsDesc(versions: string[]): string[] { * when the user changes the plugin type (it fires at start for the default value.) */ export const PluginKindSelect = forwardRef((props: PluginKindSelectProps, ref): ReactElement => { - const { pluginTypes, value: propValue, onChange, filteredQueryPlugins, enableVersionSelection, ...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)); - } - - return data?.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 available versions for each one (newest first). + // 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 ?? []) { @@ -91,42 +152,48 @@ export const PluginKindSelect = forwardRef((props: PluginKindSelectProps, ref): type: metadata.kind, kind: metadata.spec.name, displayName: metadata.spec.display.name, - versions: [], + variants: [], + hasMultipleVersions: false, + hasMultipleRegistries: false, }; groups.set(key, group); } - const version = getMetadataVersion(metadata); - if (version && !group.versions.includes(version)) { - group.versions.push(version); + const variant = getVariant(metadata); + if (!group.variants.some((existing) => getVariantKey(existing) === getVariantKey(variant))) { + group.variants.push(variant); } } for (const group of groups.values()) { - group.versions = sortVersionsDesc(group.versions); + 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]); - const findGroup = useCallback( - (selection: PluginEditorSelection): PluginKindGroup | undefined => - kindGroups.find((g) => g.type === selection.type && g.kind === selection.kind), - [kindGroups], + 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 = useMemo(() => { if (!propValue || isLoading) { return ''; } - // When version selection is enabled and the definition is not pinned to a version, but multiple versions exist, - // display the newest version option (which is what will actually be used) so the Select has a matching value. - if (enableVersionSelection && !propValue.version) { - const group = findGroup(propValue); - if (group && group.versions.length > 1) { - return selectionToOptionValue({ ...propValue, version: group.versions[0] }); - } + const optionValue = selectionToOptionValue(propValue); + if (labelsByValue.has(optionValue)) { + return optionValue; } - return selectionToOptionValue(propValue); - }, [propValue, isLoading, enableVersionSelection, findGroup]); + // 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)); @@ -137,17 +204,16 @@ export const PluginKindSelect = forwardRef((props: PluginKindSelectProps, ref): if (selected === '') { return ''; } - const selectedValue = optionValueToSelection(selected as string); - const group = findGroup(selectedValue); - const displayName = - group?.displayName ?? - sortedData?.find((v) => v.kind === selectedValue.type && v.spec.name === selectedValue.kind)?.spec.display.name; - if (enableVersionSelection && selectedValue.version && group && group.versions.length > 1) { - return `${displayName} - ${selectedValue.version}`; + const optionValue = selected as string; + const label = labelsByValue.get(optionValue); + if (label !== undefined) { + return label; } - return displayName; + const selectedValue = optionValueToSelection(optionValue); + return kindGroups.find((group) => group.type === selectedValue.type && group.kind === selectedValue.kind) + ?.displayName; }, - [findGroup, sortedData, enableVersionSelection], + [labelsByValue, kindGroups], ); // TODO: Does this need a loading indicator of some kind? @@ -163,40 +229,11 @@ export const PluginKindSelect = forwardRef((props: PluginKindSelectProps, ref): data-testid="plugin-kind-select" > {isLoading && Loading...} - {enableVersionSelection - ? kindGroups.flatMap((group) => { - // A single available version behaves like "latest": show one entry without a version. - if (group.versions.length <= 1) { - return [ - - {group.displayName} - , - ]; - } - // Multiple versions: one selectable entry per version, labeled " - ". - return group.versions.map((version) => ( - - {`${group.displayName} - ${version}`} - - )); - }) - : sortedData?.map((metadata) => ( - - {metadata.spec.display.name} - - ))} + {options.map((option) => ( + + {option.label} + + ))} ); }); @@ -206,20 +243,25 @@ PluginKindSelect.displayName = 'PluginKindSelect'; const OPTION_VALUE_DELIMITER = '_____'; /** - * Given a PluginEditorSelection, returns a string value like `{type}_____{kind}` (or `{type}_____{kind}_____{version}` - * when a version is present) 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 { + const { version, registry } = selector.metadata ?? {}; const parts = [selector.type, selector.kind]; - if (selector.version) { - parts.push(selector.version); + 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}` or `{type}_____{kind}_____{version}`, returns a + * 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 */ @@ -228,12 +270,17 @@ function optionValueToSelection(optionValue: string): PluginEditorSelection { 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 ? { version } : {}), + ...(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.tsx b/plugin-system/src/components/PluginRegistry/PluginRegistry.tsx index af41083b..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'; @@ -47,6 +47,33 @@ function findDevPluginKey(devPluginKeys: Set, kind: string, name: string 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. @@ -80,20 +107,17 @@ export function PluginRegistry(props: PluginRegistryProps): ReactElement { const getPlugin = useCallback( async (compoundKeyObj: PluginCompoundKey): Promise> => { const pluginIndexes = await getPluginIndexes(); - const { kind, name, version } = compoundKeyObj; - - let candidateKeys = resolvePluginKeys( - pluginIndexes.pluginResourcesByNameKindRegistryVersion.keys(), - compoundKeyObj, - ); - - // When a specific version is pinned, enforce an exact match and do NOT silently fall back to another (e.g. the - // latest) version. resolvePluginKeys() always returns the exact-match key first when a version is provided, so we - // only keep that first candidate. - if (version) { - candidateKeys = candidateKeys.slice(0, 1); + const { kind, name, version, registry } = compoundKeyObj; + const allKeys = pluginIndexes.pluginResourcesByNameKindRegistryVersion.keys(); + + 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 { - // No version pinned: a plugin served by a local dev server (`percli plugin start`) wins over installed archives, + 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); @@ -115,9 +139,13 @@ export function PluginRegistry(props: PluginRegistryProps): ReactElement { if (versionlessPlugin) return versionlessPlugin as PluginImplementation; } + const pins = [ + version ? `version '${version}'` : undefined, + registry ? `registry '${registry}'` : undefined, + ].filter((pin) => pin !== undefined); throw new Error( - version - ? `A ${name} plugin for kind '${kind}' with version '${version}' is not installed` + 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`, ); }, @@ -125,8 +153,12 @@ export function PluginRegistry(props: PluginRegistryProps): ReactElement { ); 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/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/runtime/plugin-registry.ts b/plugin-system/src/runtime/plugin-registry.ts index 1377e357..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; } @@ -54,35 +55,19 @@ type UsePluginOptions = Omit< 'queryKey' | 'queryFn' >; -/** - * Optional overrides used to pin a plugin to a specific version/registry when loading it. When omitted, the latest - * available version is resolved. - */ -export interface UsePluginOverrides { - version?: string; - registry?: string; -} - -/** - * A plugin definition, as stored in a dashboard/datasource spec, that may carry pinned version/registry metadata. We - * describe `metadata` locally so this keeps compiling regardless of the installed `@perses-dev/spec` version. - */ -type PluginDefinitionLike = - | { kind?: string; spec?: unknown; metadata?: { version?: string; registry?: string } } - | undefined; - /** * 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: PluginDefinitionLike): UsePluginOverrides | undefined { +export function getPluginOverrides( + plugin: Pick, 'metadata'> | undefined, +): PluginDefinitionMetadata | undefined { const metadata = plugin?.metadata; if (!metadata) { return undefined; } - // `latest` is a sentinel (see the Go plugin.LatestVersion constant) meaning "resolve the latest available version", - // so it must not be treated as an exact-version pin. - const version = metadata.version === 'latest' ? undefined : metadata.version; + // `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; @@ -100,7 +85,7 @@ export function usePlugin( pluginType: T | undefined, kind: string, options?: UsePluginOptions, - overrides?: UsePluginOverrides, + 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 @@ -119,10 +104,16 @@ export function usePlugin( /** * A plugin reference to load, optionally pinned to a specific version/registry. */ -export interface UsePluginsItem { +export interface UsePluginsItem extends PluginDefinitionMetadata { kind: string; - version?: string; - registry?: 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 ?? ''}`; } /** @@ -138,13 +129,11 @@ export function usePlugins( // 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 - // Plugins are de-duplicated on their full identity (kind + version + registry) so that two definitions pinned to - // different versions of the same kind are still loaded independently. - const identity = (p: UsePluginsItem): string => `${p.kind}:${p.version ?? ''}:${p.registry ?? ''}`; const uniquePlugins = new Map(); for (const p of plugins) { - if (!uniquePlugins.has(identity(p))) { - uniquePlugins.set(identity(p), p); + const key = getUsePluginsItemIdentity(p); + if (!uniquePlugins.has(key)) { + uniquePlugins.set(key, p); } } const uniqueKeys = [...uniquePlugins.keys()]; @@ -159,8 +148,9 @@ export function usePlugins( }), }); - // Re-assemble array in original order - return plugins.map((p) => result[uniqueKeys.indexOf(identity(p))]!); + // 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 @@ -170,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/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)); +}