From a242467a1427db15e346166a6c2a415d666e5b09 Mon Sep 17 00:00:00 2001 From: Guillaume LADORME Date: Tue, 11 Aug 2026 17:03:39 +0200 Subject: [PATCH 01/11] [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 2d1eb512..105e8f5d 100644 --- a/dashboards/src/components/DashboardToolbar/DashboardToolbar.tsx +++ b/dashboards/src/components/DashboardToolbar/DashboardToolbar.tsx @@ -28,6 +28,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'; @@ -40,6 +41,7 @@ export interface DashboardToolbarProps { isAnnotationEnabled: boolean; isDatasourceEnabled: boolean; isLinksEnabled?: boolean; + isPluginVersioningEnabled?: boolean; timezone: string; onEditButtonClick: () => void; onCancelButtonClick: () => void; @@ -56,6 +58,7 @@ export const DashboardToolbar = (props: DashboardToolbarProps): ReactElement => isAnnotationEnabled, isDatasourceEnabled, isLinksEnabled = true, + isPluginVersioningEnabled = false, timezone: toolbarTimezone, onEditButtonClick, onCancelButtonClick, @@ -106,6 +109,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 54eff94c..ce42f791 100644 --- a/dashboards/src/views/ViewDashboard/DashboardApp.tsx +++ b/dashboards/src/views/ViewDashboard/DashboardApp.tsx @@ -46,6 +46,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; @@ -75,6 +79,7 @@ const DashboardAppContent = (props: DashboardAppProps): ReactElement => { isCreating, isInitialVariableSticky, isLeavingConfirmDialogEnabled, + isPluginVersioningEnabled, dashboardTitleComponent, userPreferenceTimezone, onSave, @@ -159,6 +164,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 23151a74..be362fb5 100644 --- a/dashboards/src/views/ViewDashboard/ViewDashboard.tsx +++ b/dashboards/src/views/ViewDashboard/ViewDashboard.tsx @@ -57,6 +57,7 @@ export function ViewDashboard(props: ViewDashboardProps): ReactElement { isCreating, isInitialVariableSticky, isLeavingConfirmDialogEnabled, + isPluginVersioningEnabled, dashboardTitleComponent, onSave, onDiscard, @@ -151,6 +152,7 @@ export function ViewDashboard(props: ViewDashboardProps): ReactElement { isCreating={isCreating} isInitialVariableSticky={isInitialVariableSticky} isLeavingConfirmDialogEnabled={isLeavingConfirmDialogEnabled} + isPluginVersioningEnabled={isPluginVersioningEnabled} dashboardTitleComponent={dashboardTitleComponent} onSave={onSave} onDiscard={onDiscard} From 290f51958d25b86ad397f586f00f83b13dc1b3ef Mon Sep 17 00:00:00 2001 From: Guillaume LADORME Date: Wed, 12 Aug 2026 15:45:50 +0200 Subject: [PATCH 02/11] 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 | 22 ++++- .../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 | 18 +++- 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, 318 insertions(+), 52 deletions(-) diff --git a/dashboards/src/components/GridLayout/GridItemContent.tsx b/dashboards/src/components/GridLayout/GridItemContent.tsx index 9fb98e9d..5f2aa66d 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, getPluginOverrides, usePlugin, useSuggestedStepMs } from '@perses-dev/plugin-system'; import type { ReactElement } from 'react'; import React, { useMemo, useState } from 'react'; import { useInView } from 'react-intersection-observer'; @@ -106,7 +106,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 7a1fe2c1..eb3b6244 100644 --- a/dashboards/src/components/Panel/Panel.tsx +++ b/dashboards/src/components/Panel/Panel.tsx @@ -22,7 +22,7 @@ import { useId, } from '@perses-dev/components'; import type { ActionOptions } from '@perses-dev/plugin-system'; -import { useDataQueriesContext, usePluginRegistry } from '@perses-dev/plugin-system'; +import { getPluginOverrides, useDataQueriesContext, usePluginRegistry } from '@perses-dev/plugin-system'; import type { PanelDefinition } from '@perses-dev/spec'; import type { ReactNode } from 'react'; import { memo, useEffect, useMemo, useState } from 'react'; @@ -136,7 +136,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 ( @@ -173,7 +177,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 14f3b47c..dad6c1dc 100644 --- a/dashboards/src/components/Panel/PanelContent.tsx +++ b/dashboards/src/components/Panel/PanelContent.tsx @@ -14,7 +14,7 @@ import { Skeleton } from '@mui/material'; import { LoadingOverlay } from '@perses-dev/components'; import type { PanelProps, QueryData, PanelPlugin } from '@perses-dev/plugin-system'; -import { usePlugin } from '@perses-dev/plugin-system'; +import { getPluginOverrides, usePlugin } from '@perses-dev/plugin-system'; import type { UnknownSpec, PanelDefinition, QueryDataType } from '@perses-dev/spec'; import type { ReactElement } from 'react'; @@ -32,7 +32,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 209a231c..f8f6f406 100644 --- a/dashboards/src/components/Panel/PanelPluginLoader.tsx +++ b/dashboards/src/components/Panel/PanelPluginLoader.tsx @@ -13,7 +13,7 @@ import { Skeleton } from '@mui/material'; import type { PanelProps } from '@perses-dev/plugin-system'; -import { usePlugin } from '@perses-dev/plugin-system'; +import { getPluginOverrides, usePlugin } from '@perses-dev/plugin-system'; import type { UnknownSpec, QueryDataType } from '@perses-dev/spec'; import type { ReactElement } from 'react'; @@ -27,7 +27,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 ab633aad..9da1b525 100644 --- a/dashboards/src/context/DashboardProvider/use-save-dashboard.ts +++ b/dashboards/src/context/DashboardProvider/use-save-dashboard.ts @@ -11,10 +11,17 @@ // See the License for the specific language governing permissions and // limitations under the License. -import { useTimeRange, useTimeZoneParams, useDisableAutoRefreshSetting } from '@perses-dev/plugin-system'; +import { useSnackbar } from '@perses-dev/components'; +import { + useTimeRange, + useTimeZoneParams, + useDisableAutoRefreshSetting, + usePluginRegistry, +} 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/VariableProvider'; import type { OnSaveDashboard } from './common'; @@ -54,6 +61,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 +71,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 7c629067..c27a3576 100644 --- a/dashboards/src/context/DatasourceStoreProvider.tsx +++ b/dashboards/src/context/DatasourceStoreProvider.tsx @@ -22,6 +22,7 @@ import type { DatasourceSelectItemGroup, DatasourceClient, DatasourceSelectItem, + getPluginOverrides, } from '@perses-dev/plugin-system'; import { DatasourceStoreContext, usePluginRegistry, useEvent } from '@perses-dev/plugin-system'; import type { DashboardSpec, DatasourceSelector, DatasourceSpec } from '@perses-dev/spec'; @@ -131,10 +132,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 e8549145..c1e867ac 100644 --- a/plugin-system/src/components/PluginRegistry/PluginRegistry.tsx +++ b/plugin-system/src/components/PluginRegistry/PluginRegistry.tsx @@ -68,13 +68,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; @@ -88,7 +95,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 e10db02d..32d256c4 100644 --- a/plugin-system/src/components/Variables/variable-model.ts +++ b/plugin-system/src/components/Variables/variable-model.ts @@ -18,7 +18,14 @@ import { useCallback, useMemo, useState } from 'react'; import type { GetVariableOptionsContext, VariableOption, VariablePlugin } from '../../model'; import type { VariableStateMap } from '../../runtime'; -import { useAllVariableValues, useDatasourceStore, usePlugin, usePlugins, useTimeRange } from '../../runtime'; +import { + getPluginOverrides, + useAllVariableValues, + useDatasourceStore, + usePlugin, + usePlugins, + useTimeRange, +} from '../../runtime'; export function filterVariableList(data: VariableOption[], capturedRegexp: RegExp): VariableOption[] { const result: VariableOption[] = []; @@ -90,7 +97,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(); @@ -133,7 +145,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 a765b391..f0636d0a 100644 --- a/plugin-system/src/runtime/alerts-queries.ts +++ b/plugin-system/src/runtime/alerts-queries.ts @@ -17,7 +17,7 @@ import { useQueries } from '@tanstack/react-query'; import type { 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'; @@ -35,7 +35,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({ @@ -51,7 +51,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 85f8fdef..7a167b55 100644 --- a/plugin-system/src/runtime/annotations.ts +++ b/plugin-system/src/runtime/annotations.ts @@ -17,7 +17,7 @@ import { useQueries, useQuery } from '@tanstack/react-query'; import type { 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'; @@ -75,7 +75,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 @@ -92,7 +92,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; }, @@ -102,7 +106,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 a9cd6d80..7ef06867 100644 --- a/plugin-system/src/runtime/log-queries.ts +++ b/plugin-system/src/runtime/log-queries.ts @@ -17,7 +17,7 @@ import { useQueries } from '@tanstack/react-query'; import type { 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'; @@ -48,7 +48,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 a388550a..1bed3f37 100644 --- a/plugin-system/src/runtime/plugin-registry.ts +++ b/plugin-system/src/runtime/plugin-registry.ts @@ -46,18 +46,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, @@ -65,38 +111,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 109e595c..ff0c87f0 100644 --- a/plugin-system/src/runtime/profile-queries.ts +++ b/plugin-system/src/runtime/profile-queries.ts @@ -16,7 +16,7 @@ import type { UseQueryResult } from '@tanstack/react-query'; import { useQueries } 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'; @@ -48,7 +48,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 3bd0dfad..27565066 100644 --- a/plugin-system/src/runtime/silences-queries.ts +++ b/plugin-system/src/runtime/silences-queries.ts @@ -17,7 +17,7 @@ import { useQueries } from '@tanstack/react-query'; import type { 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'; @@ -35,7 +35,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({ @@ -51,7 +51,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 c0beb082..36075969 100644 --- a/plugin-system/src/runtime/time-series-queries.ts +++ b/plugin-system/src/runtime/time-series-queries.ts @@ -17,7 +17,7 @@ import { useQueries, useQuery, useQueryClient } from '@tanstack/react-query'; import type { 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'; @@ -82,7 +82,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({ @@ -117,7 +122,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) => { @@ -132,7 +137,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 630a243c..96da0b47 100644 --- a/plugin-system/src/runtime/trace-queries.ts +++ b/plugin-system/src/runtime/trace-queries.ts @@ -17,7 +17,7 @@ import { useQueries } from '@tanstack/react-query'; import type { 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'; @@ -35,7 +35,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) @@ -53,7 +53,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 832995d925a943f41482d87c53ff34156b86c5a5 Mon Sep 17 00:00:00 2001 From: Guillaume LADORME Date: Mon, 17 Aug 2026 11:02:59 +0200 Subject: [PATCH 03/11] 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 4ac18880..edb8add9 100644 --- a/dashboards/src/components/PanelDrawer/PanelEditorForm.tsx +++ b/dashboards/src/components/PanelDrawer/PanelEditorForm.tsx @@ -23,7 +23,7 @@ import { } from '@perses-dev/components'; import type { PanelEditorValues } from '@perses-dev/plugin-system'; import { PluginKindSelect, usePluginEditor, useValidationSchemas } from '@perses-dev/plugin-system'; -import type { PanelDefinition } from '@perses-dev/spec'; +import type { Definition, PanelDefinition, UnknownSpec } from '@perses-dev/spec'; import type { ReactElement } from 'react'; import { useCallback, useEffect, useState } from 'react'; import type { SubmitHandler } from 'react-hook-form'; @@ -68,11 +68,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); @@ -217,13 +222,14 @@ export function PanelEditorForm(props: PanelEditorFormProps): ReactElement { { field.onChange(event.kind); pluginEditor.onSelectionChange(event); @@ -267,3 +273,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 e9b00ee6..fd5a72b8 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 type { 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 0f23b7d1..09e5df0d 100644 --- a/plugin-system/src/components/PluginKindSelect/PluginKindSelect.tsx +++ b/plugin-system/src/components/PluginKindSelect/PluginKindSelect.tsx @@ -15,8 +15,9 @@ import type { TextFieldProps } from '@mui/material'; import { MenuItem, TextField } from '@mui/material'; import type { ReactElement } from 'react'; import { forwardRef, useCallback, useMemo } from 'react'; +import { gt } from 'semver'; -import type { PluginType } from '../../model'; +import type { PluginMetadataWithModule, PluginType } from '../../model'; import { useListPluginMetadata } from '../../runtime'; import type { PluginEditorSelection } from '../PluginEditor'; @@ -25,6 +26,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); + } + }); } /** @@ -35,7 +69,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(() => { @@ -48,8 +82,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)); @@ -61,10 +140,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? @@ -80,15 +165,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} + + ))} ); }); @@ -98,28 +208,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 e7f7ba2c78542c107d492b626026e238b4d8b038 Mon Sep 17 00:00:00 2001 From: Guillaume LADORME Date: Tue, 18 Aug 2026 16:18:53 +0200 Subject: [PATCH 04/11] 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 105e8f5d..d7f0c3ac 100644 --- a/dashboards/src/components/DashboardToolbar/DashboardToolbar.tsx +++ b/dashboards/src/components/DashboardToolbar/DashboardToolbar.tsx @@ -29,6 +29,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'; @@ -110,6 +111,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 bc38d1082ef46fa1a3bc9b56784d74d309a45a6e Mon Sep 17 00:00:00 2001 From: Guillaume LADORME Date: Wed, 19 Aug 2026 15:48:00 +0200 Subject: [PATCH 05/11] 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 c1e867ac..743c82f8 100644 --- a/plugin-system/src/components/PluginRegistry/PluginRegistry.tsx +++ b/plugin-system/src/components/PluginRegistry/PluginRegistry.tsx @@ -35,6 +35,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. @@ -80,6 +94,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 f5498fd4..ec7f2dfb 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 5bdb7bd8..09bd66b7 100644 --- a/plugin-system/src/model/plugins.ts +++ b/plugin-system/src/model/plugins.ts @@ -62,12 +62,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 d9906b6f1707b889dd97e6c2a6252789c6c3e7cd Mon Sep 17 00:00:00 2001 From: Guillaume LADORME Date: Wed, 19 Aug 2026 16:33:05 +0200 Subject: [PATCH 06/11] Reviewing Signed-off-by: Guillaume LADORME --- .../DashboardToolbar/DashboardToolbar.tsx | 16 ++-- .../LockDashboardButton.tsx | 76 ++++++++++++------- .../UpdatePluginsButton.tsx | 5 +- .../UpdatePluginsDrawer/PanelVersionDiff.tsx | 9 ++- .../UpdatePluginsDrawer.tsx | 12 +-- dashboards/src/utils/pluginVersioning.test.ts | 13 ++-- dashboards/src/utils/pluginVersioning.ts | 23 +++--- .../PluginRegistry.dev.test.tsx | 9 ++- .../PluginRegistry/PluginRegistry.tsx | 2 +- 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 +- 13 files changed, 99 insertions(+), 76 deletions(-) diff --git a/dashboards/src/components/DashboardToolbar/DashboardToolbar.tsx b/dashboards/src/components/DashboardToolbar/DashboardToolbar.tsx index d7f0c3ac..c3f6fbe8 100644 --- a/dashboards/src/components/DashboardToolbar/DashboardToolbar.tsx +++ b/dashboards/src/components/DashboardToolbar/DashboardToolbar.tsx @@ -29,8 +29,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 { @@ -110,8 +110,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/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 edb8add9..2cd363ab 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 type { PanelEditorValues } from '@perses-dev/plugin-system'; -import { PluginKindSelect, usePluginEditor, useValidationSchemas } from '@perses-dev/plugin-system'; +import { getPluginOverrides, PluginKindSelect, usePluginEditor, useValidationSchemas } from '@perses-dev/plugin-system'; import type { Definition, PanelDefinition, UnknownSpec } from '@perses-dev/spec'; import type { ReactElement } from 'react'; import { useCallback, useEffect, useState } from 'react'; @@ -63,18 +63,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); @@ -223,16 +229,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); }} /> )} @@ -273,12 +280,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 9da1b525..ab633aad 100644 --- a/dashboards/src/context/DashboardProvider/use-save-dashboard.ts +++ b/dashboards/src/context/DashboardProvider/use-save-dashboard.ts @@ -11,17 +11,10 @@ // 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/VariableProvider'; import type { OnSaveDashboard } from './common'; @@ -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 c27a3576..1cc3315e 100644 --- a/dashboards/src/context/DatasourceStoreProvider.tsx +++ b/dashboards/src/context/DatasourceStoreProvider.tsx @@ -133,7 +133,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 ce42f791..fb49677f 100644 --- a/dashboards/src/views/ViewDashboard/DashboardApp.tsx +++ b/dashboards/src/views/ViewDashboard/DashboardApp.tsx @@ -47,9 +47,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; @@ -79,7 +80,7 @@ const DashboardAppContent = (props: DashboardAppProps): ReactElement => { isCreating, isInitialVariableSticky, isLeavingConfirmDialogEnabled, - isPluginVersioningEnabled, + isLockModeAvailable, dashboardTitleComponent, userPreferenceTimezone, onSave, @@ -164,7 +165,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 be362fb5..fb6d6500 100644 --- a/dashboards/src/views/ViewDashboard/ViewDashboard.tsx +++ b/dashboards/src/views/ViewDashboard/ViewDashboard.tsx @@ -57,7 +57,7 @@ export function ViewDashboard(props: ViewDashboardProps): ReactElement { isCreating, isInitialVariableSticky, isLeavingConfirmDialogEnabled, - isPluginVersioningEnabled, + isLockModeAvailable, dashboardTitleComponent, onSave, onDiscard, @@ -152,7 +152,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 6f54c4e6..12f9afdf 100644 --- a/plugin-system/src/components/PanelSpecEditor/PanelSpecEditor.tsx +++ b/plugin-system/src/components/PanelSpecEditor/PanelSpecEditor.tsx @@ -19,7 +19,7 @@ import type { Control } from 'react-hook-form'; import { Controller } from 'react-hook-form'; import type { PanelEditorValues, PanelPlugin } from '../../model'; -import { useDataQueriesContext, usePlugin } from '../../runtime'; +import { getPluginOverrides, useDataQueriesContext, usePlugin } from '../../runtime'; import type { PanelGroup, VariableDefinitionGroup } from '../LayoutEditor'; import { LayoutEditor } from '../LayoutEditor'; import { LinksEditor } from '../LinksEditor'; @@ -54,7 +54,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 fd5a72b8..30c74b82 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 type { BoxProps } from '@mui/material'; -import type { DatasourceSpec, UnknownSpec } from '@perses-dev/spec'; +import type { DatasourceSpec, PluginDefinitionMetadata, UnknownSpec } from '@perses-dev/spec'; import { produce } from 'immer'; import { useState, useRef, useEffect } from 'react'; import type { PanelPlugin, PluginType } from '../../model'; -import { usePlugin, usePluginRegistry } from '../../runtime'; +import { getPluginOverrides, usePlugin, usePluginRegistry } from '../../runtime'; import { useEvent } from '../../utils'; import type { PluginKindSelectProps } from '../PluginKindSelect'; import type { 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 09e5df0d..cd521f43 100644 --- a/plugin-system/src/components/PluginKindSelect/PluginKindSelect.tsx +++ b/plugin-system/src/components/PluginKindSelect/PluginKindSelect.tsx @@ -13,12 +13,13 @@ import type { TextFieldProps } from '@mui/material'; import { MenuItem, TextField } from '@mui/material'; +import type { PluginDefinitionMetadata } from '@perses-dev/spec'; import type { ReactElement } from 'react'; import { forwardRef, useCallback, useMemo } from 'react'; -import { gt } from 'semver'; import type { PluginMetadataWithModule, PluginType } from '../../model'; import { useListPluginMetadata } from '../../runtime'; +import { comparePluginVersions } from '../../utils'; import type { PluginEditorSelection } from '../PluginEditor'; export interface PluginKindSelectProps extends Omit { @@ -27,38 +28,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; } /** @@ -69,20 +125,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 ?? []) { @@ -93,42 +154,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)); @@ -139,17 +206,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? @@ -165,40 +231,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} + + ))} ); }); @@ -208,20 +245,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 */ @@ -230,12 +272,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 94c0766b..570b264f 100644 --- a/plugin-system/src/components/PluginRegistry/PluginRegistry.tsx +++ b/plugin-system/src/components/PluginRegistry/PluginRegistry.tsx @@ -24,7 +24,7 @@ import type { DefaultPluginKinds, } from '../../model'; import { PluginRegistryContext } from '../../runtime'; -import { useEvent } from '../../utils'; +import { comparePluginVersions, useEvent } from '../../utils'; import { resolvePluginKeys } from './getPluginSearchHelper'; import type { PluginCompoundKey } from './plugin-indexes'; import { usePluginIndexes } from './plugin-indexes'; @@ -49,6 +49,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. @@ -82,20 +109,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); @@ -117,9 +141,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`, ); }, @@ -127,8 +155,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 322ba474..356415c2 100644 --- a/plugin-system/src/components/PluginSpecEditor/PluginSpecEditor.tsx +++ b/plugin-system/src/components/PluginSpecEditor/PluginSpecEditor.tsx @@ -17,7 +17,7 @@ import type { DatasourceSpec, UnknownSpec } from '@perses-dev/spec'; import type { ReactElement } from 'react'; import type { DatasourcePlugin, OptionsEditorProps, Plugin, PluginType } from '../../model'; -import { usePlugin } from '../../runtime'; +import { getPluginOverrides, usePlugin } from '../../runtime'; import type { 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 1bed3f37..20b02dc9 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 type { BuiltinVariableDefinition } from '@perses-dev/spec'; +import type { BuiltinVariableDefinition, Definition, PluginDefinitionMetadata } from '@perses-dev/spec'; import type { UseQueryOptions, UseQueryResult } from '@tanstack/react-query'; import { useQueries, useQuery } from '@tanstack/react-query'; import { createContext, useContext } from 'react'; @@ -23,10 +23,11 @@ import type { 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; } @@ -55,35 +56,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; @@ -101,7 +86,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 @@ -120,10 +105,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 ?? ''}`; } /** @@ -139,13 +130,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()]; @@ -160,8 +149,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 @@ -171,15 +161,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)); +} From e5dab680c93e8dca82c5f8e04b9ba487b02d94f5 Mon Sep 17 00:00:00 2001 From: Guillaume LADORME Date: Wed, 26 Aug 2026 17:52:31 +0200 Subject: [PATCH 08/11] Fix unlock + lock button Signed-off-by: Guillaume LADORME --- .../LockDashboardButton.tsx | 25 ++++++++----------- dashboards/src/utils/pluginVersioning.test.ts | 7 +----- dashboards/src/utils/pluginVersioning.ts | 14 ----------- 3 files changed, 12 insertions(+), 34 deletions(-) diff --git a/dashboards/src/components/LockDashboardButton/LockDashboardButton.tsx b/dashboards/src/components/LockDashboardButton/LockDashboardButton.tsx index 6318cd2c..9c0de998 100644 --- a/dashboards/src/components/LockDashboardButton/LockDashboardButton.tsx +++ b/dashboards/src/components/LockDashboardButton/LockDashboardButton.tsx @@ -22,7 +22,6 @@ import { useDashboard } from '../../context/useDashboard'; import { applyPluginVersions, buildLatestPluginVersions, - hasPinnedPluginVersions, isDashboardLocked, removePluginVersions, } from '../../utils/pluginVersioning'; @@ -45,7 +44,6 @@ export function LockDashboardButton(): ReactElement { const [pendingAction, setPendingAction] = useState<'lock' | 'unlock' | undefined>(undefined); const isLocked = useMemo(() => isDashboardLocked(dashboard), [dashboard]); - const hasPins = useMemo(() => hasPinnedPluginVersions(dashboard), [dashboard]); const closeConfirmation = useCallback((): void => setPendingAction(undefined), []); @@ -63,33 +61,32 @@ export function LockDashboardButton(): ReactElement { return ( <> - {!isLocked && ( - + {isLocked ? ( + - )} - {hasPins && ( - + ) : ( + diff --git a/dashboards/src/utils/pluginVersioning.test.ts b/dashboards/src/utils/pluginVersioning.test.ts index 475dd6c3..61f899c0 100644 --- a/dashboards/src/utils/pluginVersioning.test.ts +++ b/dashboards/src/utils/pluginVersioning.test.ts @@ -20,7 +20,6 @@ import { findOutdatedPlugins, getOutdatedPluginId, getPluginIdentityKey, - hasPinnedPluginVersions, isDashboardLocked, LatestPluginVersions, removePluginVersions, @@ -173,9 +172,8 @@ describe('applyPluginVersions / removePluginVersions / isDashboardLocked', () => ['Annotation', 'TempoAnnotation', '1.4.0'], ]); - test('a fresh dashboard is neither locked nor pinned', () => { + test('a fresh dashboard is not locked', () => { expect(isDashboardLocked(buildDashboard())).toBe(false); - expect(hasPinnedPluginVersions(buildDashboard())).toBe(false); }); test('applies versions to every plugin definition and marks the dashboard as locked', () => { @@ -204,7 +202,6 @@ describe('applyPluginVersions / removePluginVersions / isDashboardLocked', () => 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(); }); @@ -219,13 +216,11 @@ describe('applyPluginVersions / removePluginVersions / isDashboardLocked', () => test('a partially pinned dashboard is pinned but not locked', () => { const partial = applyPluginVersions(buildDashboard(), buildVersions([['Panel', 'TimeSeriesChart', '1.0.0']])); - expect(hasPinnedPluginVersions(partial)).toBe(true); expect(isDashboardLocked(partial)).toBe(false); }); test('the `latest` sentinel does not count as a pin', () => { const sentinel = applyPluginVersions(buildDashboard(), allPluginsAt('latest')); - expect(hasPinnedPluginVersions(sentinel)).toBe(false); expect(isDashboardLocked(sentinel)).toBe(false); }); }); diff --git a/dashboards/src/utils/pluginVersioning.ts b/dashboards/src/utils/pluginVersioning.ts index 9dc002d3..035ebcc2 100644 --- a/dashboards/src/utils/pluginVersioning.ts +++ b/dashboards/src/utils/pluginVersioning.ts @@ -292,17 +292,3 @@ export function isDashboardLocked(dashboard: DashboardResource): boolean { }); return total > 0 && pinned === total; } - -/** - * Whether at least one plugin definition of the dashboard is pinned to an exact version. Versioning can be enforced - * partially, so this is true both for a fully locked dashboard and for one where only a few plugins are pinned. - */ -export function hasPinnedPluginVersions(dashboard: DashboardResource): boolean { - let pinned = false; - visitPluginDefinitions(dashboard, (definition) => { - if (getPinnedVersion(definition)) { - pinned = true; - } - }); - return pinned; -} From d7496e4d48d84c6f8f3985e163cfe26c5ab32e47 Mon Sep 17 00:00:00 2001 From: Guillaume LADORME Date: Sat, 29 Aug 2026 01:34:27 +0200 Subject: [PATCH 09/11] Apply Gabriel feedbacks Signed-off-by: Guillaume LADORME --- .../components/GridLayout/GridItemContent.tsx | 12 ++--- .../LockDashboardButton.tsx | 3 +- dashboards/src/components/Panel/Panel.tsx | 5 +- .../src/components/Panel/PanelContent.tsx | 13 +++-- .../components/Panel/PanelPluginLoader.tsx | 13 +++-- .../PanelDrawer/PanelEditorForm.tsx | 10 ++-- .../UpdatePluginsButton.tsx | 11 ++-- .../UpdatePluginsDrawer/PanelVersionDiff.tsx | 5 +- .../UpdatePluginsDrawer.tsx | 6 ++- .../src/context/DatasourceStoreProvider.tsx | 8 ++- dashboards/src/utils/pluginVersioning.test.ts | 6 +-- dashboards/src/utils/pluginVersioning.ts | 7 +-- .../PanelSpecEditor/PanelSpecEditor.tsx | 7 ++- .../PluginEditor/plugin-editor-api.ts | 7 ++- .../PluginKindSelect.versions.test.tsx | 8 +-- .../PluginRegistry.dev.test.tsx | 7 +-- .../PluginRegistry.versions.test.tsx | 7 +-- .../PluginSpecEditor/PluginSpecEditor.tsx | 7 ++- .../components/Variables/variable-model.ts | 25 ++++----- plugin-system/src/runtime/alerts-queries.ts | 11 ++-- plugin-system/src/runtime/annotations.ts | 21 ++++---- plugin-system/src/runtime/log-queries.ts | 5 +- plugin-system/src/runtime/plugin-registry.ts | 52 ++++++------------- plugin-system/src/runtime/profile-queries.ts | 5 +- plugin-system/src/runtime/silences-queries.ts | 11 ++-- .../src/runtime/time-series-queries.ts | 21 ++++---- plugin-system/src/runtime/trace-queries.ts | 11 ++-- plugin-system/src/utils/plugin-versions.ts | 3 +- 28 files changed, 160 insertions(+), 147 deletions(-) diff --git a/dashboards/src/components/GridLayout/GridItemContent.tsx b/dashboards/src/components/GridLayout/GridItemContent.tsx index 5f2aa66d..976ccd41 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, getPluginOverrides, usePlugin, useSuggestedStepMs } from '@perses-dev/plugin-system'; +import { DataQueriesProvider, usePlugin, useSuggestedStepMs } from '@perses-dev/plugin-system'; import type { ReactElement } from 'react'; import React, { useMemo, useState } from 'react'; import { useInView } from 'react-intersection-observer'; @@ -106,12 +106,10 @@ export function GridItemContent(props: GridItemContentProps): ReactElement { // map TimeSeriesQueryDefinition to Definition const suggestedStepMs = useSuggestedStepMs(width); - const { data: plugin } = usePlugin( - 'Panel', - panelDefinition.spec.plugin.kind, - undefined, - getPluginOverrides(panelDefinition.spec.plugin), - ); + const { data: plugin } = usePlugin('Panel', panelDefinition.spec.plugin.kind, { + version: panelDefinition.spec.plugin.metadata?.version, + registry: panelDefinition.spec.plugin.metadata?.registry, + }); const pluginQueryOptions = typeof plugin?.queryOptions === 'function' diff --git a/dashboards/src/components/LockDashboardButton/LockDashboardButton.tsx b/dashboards/src/components/LockDashboardButton/LockDashboardButton.tsx index 9c0de998..589a3583 100644 --- a/dashboards/src/components/LockDashboardButton/LockDashboardButton.tsx +++ b/dashboards/src/components/LockDashboardButton/LockDashboardButton.tsx @@ -16,7 +16,8 @@ import { Dialog } from '@perses-dev/components'; import { useListPluginMetadata } from '@perses-dev/plugin-system'; import LockOpenOutline from 'mdi-material-ui/LockOpenOutline'; import LockOutline from 'mdi-material-ui/LockOutline'; -import { ReactElement, useCallback, useMemo, useState } from 'react'; +import type { ReactElement } from 'react'; +import { useCallback, useMemo, useState } from 'react'; import { useDashboard } from '../../context/useDashboard'; import { diff --git a/dashboards/src/components/Panel/Panel.tsx b/dashboards/src/components/Panel/Panel.tsx index eb3b6244..3772330f 100644 --- a/dashboards/src/components/Panel/Panel.tsx +++ b/dashboards/src/components/Panel/Panel.tsx @@ -22,7 +22,7 @@ import { useId, } from '@perses-dev/components'; import type { ActionOptions } from '@perses-dev/plugin-system'; -import { getPluginOverrides, useDataQueriesContext, usePluginRegistry } from '@perses-dev/plugin-system'; +import { useDataQueriesContext, usePluginRegistry } from '@perses-dev/plugin-system'; import type { PanelDefinition } from '@perses-dev/spec'; import type { ReactNode } from 'react'; import { memo, useEffect, useMemo, useState } from 'react'; @@ -139,7 +139,8 @@ export const Panel = memo(function Panel(props: PanelProps) { const plugin = await getPlugin({ kind: 'Panel', name: panelPluginKind, - ...getPluginOverrides(definition.spec.plugin), + version: definition.spec.plugin.metadata?.version, + registry: definition.spec.plugin.metadata?.registry, }); // More defensive checking for plugin and actions diff --git a/dashboards/src/components/Panel/PanelContent.tsx b/dashboards/src/components/Panel/PanelContent.tsx index dad6c1dc..249a20a1 100644 --- a/dashboards/src/components/Panel/PanelContent.tsx +++ b/dashboards/src/components/Panel/PanelContent.tsx @@ -14,7 +14,7 @@ import { Skeleton } from '@mui/material'; import { LoadingOverlay } from '@perses-dev/components'; import type { PanelProps, QueryData, PanelPlugin } from '@perses-dev/plugin-system'; -import { getPluginOverrides, usePlugin } from '@perses-dev/plugin-system'; +import { usePlugin } from '@perses-dev/plugin-system'; import type { UnknownSpec, PanelDefinition, QueryDataType } from '@perses-dev/spec'; import type { ReactElement } from 'react'; @@ -32,12 +32,11 @@ 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 }, - getPluginOverrides(definition?.spec.plugin), - ); + const { data: plugin, isLoading: isPanelLoading } = usePlugin('Panel', panelPluginKind, { + useErrorBoundary: true, + version: definition?.spec.plugin.metadata?.version, + registry: definition?.spec.plugin.metadata?.registry, + }); // 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 f8f6f406..b89eef44 100644 --- a/dashboards/src/components/Panel/PanelPluginLoader.tsx +++ b/dashboards/src/components/Panel/PanelPluginLoader.tsx @@ -13,7 +13,7 @@ import { Skeleton } from '@mui/material'; import type { PanelProps } from '@perses-dev/plugin-system'; -import { getPluginOverrides, usePlugin } from '@perses-dev/plugin-system'; +import { usePlugin } from '@perses-dev/plugin-system'; import type { UnknownSpec, QueryDataType } from '@perses-dev/spec'; import type { ReactElement } from 'react'; @@ -27,12 +27,11 @@ 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 }, - getPluginOverrides(definition?.spec.plugin), - ); + const { data: plugin, isLoading: isPanelLoading } = usePlugin('Panel', kind, { + useErrorBoundary: true, + version: definition?.spec.plugin.metadata?.version, + registry: definition?.spec.plugin.metadata?.registry, + }); const PanelComponent = plugin?.PanelComponent; const supportedQueryTypes = plugin?.supportedQueryTypes || []; // Clear out the queryResults parameter for plugins which don't support any query types diff --git a/dashboards/src/components/PanelDrawer/PanelEditorForm.tsx b/dashboards/src/components/PanelDrawer/PanelEditorForm.tsx index 2cd363ab..f1d54fff 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 type { PanelEditorValues } from '@perses-dev/plugin-system'; -import { getPluginOverrides, PluginKindSelect, usePluginEditor, useValidationSchemas } from '@perses-dev/plugin-system'; +import { PluginKindSelect, usePluginEditor, useValidationSchemas } from '@perses-dev/plugin-system'; import type { Definition, PanelDefinition, UnknownSpec } from '@perses-dev/spec'; import type { ReactElement } from 'react'; import { useCallback, useEffect, useState } from 'react'; @@ -62,16 +62,14 @@ export function PanelEditorForm(props: PanelEditorFormProps): ReactElement { mode: 'onBlur', 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); + const pluginMetadata = plugin.metadata; // Use common plugin editor logic even though we've split the inputs up in this form const pluginEditor = usePluginEditor({ pluginTypes: ['Panel'], // 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 }, + value: { selection: { kind: plugin.kind, type: 'Panel', metadata: pluginMetadata }, 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 @@ -236,7 +234,7 @@ export function PanelEditorForm(props: PanelEditorFormProps): ReactElement { disabled={pluginEditor.isLoading} error={!!pluginEditor.error || !!fieldState.error} helperText={pluginEditor.error?.message ?? fieldState.error?.message} - value={{ type: 'Panel', kind: watchedPluginKind, metadata: pinnedPluginMetadata }} + value={{ type: 'Panel', kind: watchedPluginKind, metadata: pluginMetadata }} onChange={(selection) => { field.onChange(selection.kind); pluginEditor.onSelectionChange(selection); diff --git a/dashboards/src/components/UpdatePluginsButton/UpdatePluginsButton.tsx b/dashboards/src/components/UpdatePluginsButton/UpdatePluginsButton.tsx index 7e3eb397..5323f2a6 100644 --- a/dashboards/src/components/UpdatePluginsButton/UpdatePluginsButton.tsx +++ b/dashboards/src/components/UpdatePluginsButton/UpdatePluginsButton.tsx @@ -14,15 +14,12 @@ 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 type { ReactElement } from 'react'; +import { useMemo, useState } from 'react'; import { useDashboard } from '../../context/useDashboard'; -import { - buildLatestPluginVersions, - findOutdatedPlugins, - OutdatedPlugin, - updatePluginVersions, -} from '../../utils/pluginVersioning'; +import type { OutdatedPlugin } from '../../utils/pluginVersioning'; +import { buildLatestPluginVersions, findOutdatedPlugins, updatePluginVersions } from '../../utils/pluginVersioning'; import { UpdatePluginsDrawer } from '../UpdatePluginsDrawer'; /** diff --git a/dashboards/src/components/UpdatePluginsDrawer/PanelVersionDiff.tsx b/dashboards/src/components/UpdatePluginsDrawer/PanelVersionDiff.tsx index c92d620d..13d57791 100644 --- a/dashboards/src/components/UpdatePluginsDrawer/PanelVersionDiff.tsx +++ b/dashboards/src/components/UpdatePluginsDrawer/PanelVersionDiff.tsx @@ -14,8 +14,9 @@ import { Alert, Box, Chip, Stack, Typography } from '@mui/material'; import { ErrorAlert, ErrorBoundary } from '@perses-dev/components'; import { DataQueriesProvider } from '@perses-dev/plugin-system'; -import { PanelDefinition } from '@perses-dev/spec'; -import { ReactElement, useMemo } from 'react'; +import type { PanelDefinition } from '@perses-dev/spec'; +import type { ReactElement } from 'react'; +import { useMemo } from 'react'; import { Panel } from '../Panel/Panel'; diff --git a/dashboards/src/components/UpdatePluginsDrawer/UpdatePluginsDrawer.tsx b/dashboards/src/components/UpdatePluginsDrawer/UpdatePluginsDrawer.tsx index ac302058..e4728185 100644 --- a/dashboards/src/components/UpdatePluginsDrawer/UpdatePluginsDrawer.tsx +++ b/dashboards/src/components/UpdatePluginsDrawer/UpdatePluginsDrawer.tsx @@ -27,10 +27,12 @@ import { Drawer, ErrorAlert, ErrorBoundary } from '@perses-dev/components'; import ArrowRight from 'mdi-material-ui/ArrowRight'; import ChevronDown from 'mdi-material-ui/ChevronDown'; import ChevronUp from 'mdi-material-ui/ChevronUp'; -import { ReactElement, useMemo, useState } from 'react'; +import type { ReactElement } from 'react'; +import { useMemo, useState } from 'react'; import { useDashboard } from '../../context/useDashboard'; -import { OutdatedPlugin, getOutdatedPluginId } from '../../utils/pluginVersioning'; +import type { OutdatedPlugin } from '../../utils/pluginVersioning'; +import { getOutdatedPluginId } from '../../utils/pluginVersioning'; import { PanelVersionDiff } from './PanelVersionDiff'; export interface UpdatePluginsDrawerProps { diff --git a/dashboards/src/context/DatasourceStoreProvider.tsx b/dashboards/src/context/DatasourceStoreProvider.tsx index 1cc3315e..0923268f 100644 --- a/dashboards/src/context/DatasourceStoreProvider.tsx +++ b/dashboards/src/context/DatasourceStoreProvider.tsx @@ -22,7 +22,6 @@ import type { DatasourceSelectItemGroup, DatasourceClient, DatasourceSelectItem, - getPluginOverrides, } from '@perses-dev/plugin-system'; import { DatasourceStoreContext, usePluginRegistry, useEvent } from '@perses-dev/plugin-system'; import type { DashboardSpec, DatasourceSelector, DatasourceSpec } from '@perses-dev/spec'; @@ -133,7 +132,12 @@ export function DatasourceStoreProvider(props: DatasourceStoreProviderProps): Re async function getClient(selector: DatasourceSelector): Promise { const { kind } = selector; const { spec, proxyUrl } = await findDatasource(selector); - const plugin = await getPlugin({ kind: 'Datasource', name: kind, ...getPluginOverrides(spec.plugin) }); + const plugin = await getPlugin({ + kind: 'Datasource', + name: kind, + version: spec.plugin.metadata?.version, + registry: spec.plugin.metadata?.registry, + }); // 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 61f899c0..5f0df094 100644 --- a/dashboards/src/utils/pluginVersioning.test.ts +++ b/dashboards/src/utils/pluginVersioning.test.ts @@ -11,9 +11,10 @@ // 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 type { DashboardResource } from '@perses-dev/client'; +import type { PluginMetadataWithModule } from '@perses-dev/plugin-system'; +import type { LatestPluginVersions } from './pluginVersioning'; import { applyPluginVersions, buildLatestPluginVersions, @@ -21,7 +22,6 @@ import { getOutdatedPluginId, getPluginIdentityKey, isDashboardLocked, - LatestPluginVersions, removePluginVersions, updatePluginVersions, } from './pluginVersioning'; diff --git a/dashboards/src/utils/pluginVersioning.ts b/dashboards/src/utils/pluginVersioning.ts index 035ebcc2..67fe14e2 100644 --- a/dashboards/src/utils/pluginVersioning.ts +++ b/dashboards/src/utils/pluginVersioning.ts @@ -11,9 +11,10 @@ // See the License for the specific language governing permissions and // limitations under the License. -import { DashboardResource } from '@perses-dev/client'; -import { comparePluginVersions, LATEST_PLUGIN_VERSION, PluginMetadataWithModule } from '@perses-dev/plugin-system'; -import { Definition } from '@perses-dev/spec'; +import type { DashboardResource } from '@perses-dev/client'; +import type { PluginMetadataWithModule } from '@perses-dev/plugin-system'; +import { comparePluginVersions, LATEST_PLUGIN_VERSION } from '@perses-dev/plugin-system'; +import type { Definition } from '@perses-dev/spec'; /** * The full runtime identity of a plugin: two plugins with the same kind but a different registry are different plugins, diff --git a/plugin-system/src/components/PanelSpecEditor/PanelSpecEditor.tsx b/plugin-system/src/components/PanelSpecEditor/PanelSpecEditor.tsx index 12f9afdf..208f9ee0 100644 --- a/plugin-system/src/components/PanelSpecEditor/PanelSpecEditor.tsx +++ b/plugin-system/src/components/PanelSpecEditor/PanelSpecEditor.tsx @@ -19,7 +19,7 @@ import type { Control } from 'react-hook-form'; import { Controller } from 'react-hook-form'; import type { PanelEditorValues, PanelPlugin } from '../../model'; -import { getPluginOverrides, useDataQueriesContext, usePlugin } from '../../runtime'; +import { useDataQueriesContext, usePlugin } from '../../runtime'; import type { PanelGroup, VariableDefinitionGroup } from '../LayoutEditor'; import { LayoutEditor } from '../LayoutEditor'; import { LinksEditor } from '../LinksEditor'; @@ -58,7 +58,10 @@ export const PanelSpecEditor = forwardRef data: plugin, isLoading, error, - } = usePlugin('Panel', kind, undefined, getPluginOverrides(panelDefinition.spec.plugin)); + } = usePlugin('Panel', kind, { + version: panelDefinition.spec.plugin.metadata?.version, + registry: panelDefinition.spec.plugin.metadata?.registry, + }); 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 30c74b82..496451fb 100644 --- a/plugin-system/src/components/PluginEditor/plugin-editor-api.ts +++ b/plugin-system/src/components/PluginEditor/plugin-editor-api.ts @@ -17,7 +17,7 @@ import { produce } from 'immer'; import { useState, useRef, useEffect } from 'react'; import type { PanelPlugin, PluginType } from '../../model'; -import { getPluginOverrides, usePlugin, usePluginRegistry } from '../../runtime'; +import { usePlugin, usePluginRegistry } from '../../runtime'; import { useEvent } from '../../utils'; import type { PluginKindSelectProps } from '../PluginKindSelect'; import type { PluginSpecEditorProps } from '../PluginSpecEditor'; @@ -136,7 +136,10 @@ export function usePluginEditor(props: UsePluginEditorProps): { data: plugin, isFetching, error, - } = usePlugin(pendingSelection?.type, pendingSelection?.kind || '', undefined, getPluginOverrides(pendingSelection)); + } = usePlugin(pendingSelection?.type, pendingSelection?.kind || '', { + version: pendingSelection?.metadata?.version, + registry: pendingSelection?.metadata?.registry, + }); useEffect(() => { // Nothing to do if no new plugin kind is pending diff --git a/plugin-system/src/components/PluginKindSelect/PluginKindSelect.versions.test.tsx b/plugin-system/src/components/PluginKindSelect/PluginKindSelect.versions.test.tsx index 954d429f..91c77ee3 100644 --- a/plugin-system/src/components/PluginKindSelect/PluginKindSelect.versions.test.tsx +++ b/plugin-system/src/components/PluginKindSelect/PluginKindSelect.versions.test.tsx @@ -15,10 +15,12 @@ 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 type { PluginModuleResource } from '../../model'; +import { dynamicImportPluginLoader } from '../../model'; +import type { PluginEditorSelection } from '../PluginEditor'; import { PluginRegistry } from '../PluginRegistry'; -import { PluginKindSelect, PluginKindSelectProps } from './PluginKindSelect'; +import type { PluginKindSelectProps } from './PluginKindSelect'; +import { PluginKindSelect } 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 { diff --git a/plugin-system/src/components/PluginRegistry/PluginRegistry.dev.test.tsx b/plugin-system/src/components/PluginRegistry/PluginRegistry.dev.test.tsx index dffb827b..44e06ca3 100644 --- a/plugin-system/src/components/PluginRegistry/PluginRegistry.dev.test.tsx +++ b/plugin-system/src/components/PluginRegistry/PluginRegistry.dev.test.tsx @@ -13,9 +13,10 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { render, screen } from '@testing-library/react'; -import { ReactElement, ReactNode } from 'react'; +import type { ReactElement, ReactNode } from 'react'; -import { dynamicImportPluginLoader, PluginModuleResource } from '../../model'; +import type { PluginModuleResource } from '../../model'; +import { dynamicImportPluginLoader } from '../../model'; import { usePlugin } from '../../runtime'; import { PluginRegistry } from './PluginRegistry'; @@ -68,7 +69,7 @@ function renderWithLoader(children: ReactNode): void { } function Consumer({ version }: { version?: string }): ReactElement { - const { data, isLoading, error } = usePlugin('Variable', PLUGIN_NAME, undefined, version ? { version } : undefined); + const { data, isLoading, error } = usePlugin('Variable', PLUGIN_NAME, version ? { version } : undefined); if (isLoading) return
loading
; if (error) return
error: {error.message}
; return
source: {(data as unknown as { source?: string })?.source}
; diff --git a/plugin-system/src/components/PluginRegistry/PluginRegistry.versions.test.tsx b/plugin-system/src/components/PluginRegistry/PluginRegistry.versions.test.tsx index fe3eea95..cb5258c5 100644 --- a/plugin-system/src/components/PluginRegistry/PluginRegistry.versions.test.tsx +++ b/plugin-system/src/components/PluginRegistry/PluginRegistry.versions.test.tsx @@ -13,9 +13,10 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { render, screen } from '@testing-library/react'; -import { ReactElement, ReactNode } from 'react'; +import type { ReactElement, ReactNode } from 'react'; -import { dynamicImportPluginLoader, PluginModuleResource } from '../../model'; +import type { PluginModuleResource } from '../../model'; +import { dynamicImportPluginLoader } from '../../model'; import { usePlugin } from '../../runtime'; import { PluginRegistry } from './PluginRegistry'; @@ -53,7 +54,7 @@ function renderConsumer(children: ReactNode, resources: Array<[PluginModuleResou } function Consumer({ version, registry }: { version?: string; registry?: string }): ReactElement { - const { data, isLoading, error } = usePlugin('Variable', PLUGIN_NAME, undefined, { version, registry }); + const { data, isLoading, error } = usePlugin('Variable', PLUGIN_NAME, { version, registry }); if (isLoading) return
loading
; if (error) return
error: {error.message}
; return
source: {(data as unknown as { source?: string })?.source}
; diff --git a/plugin-system/src/components/PluginSpecEditor/PluginSpecEditor.tsx b/plugin-system/src/components/PluginSpecEditor/PluginSpecEditor.tsx index 356415c2..9e52dd6c 100644 --- a/plugin-system/src/components/PluginSpecEditor/PluginSpecEditor.tsx +++ b/plugin-system/src/components/PluginSpecEditor/PluginSpecEditor.tsx @@ -17,7 +17,7 @@ import type { DatasourceSpec, UnknownSpec } from '@perses-dev/spec'; import type { ReactElement } from 'react'; import type { DatasourcePlugin, OptionsEditorProps, Plugin, PluginType } from '../../model'; -import { getPluginOverrides, usePlugin } from '../../runtime'; +import { usePlugin } from '../../runtime'; import type { PluginEditorSelection } from '../PluginEditor'; import { DatasourceSpecEditor } from './DatasourceSpecEditor'; @@ -46,7 +46,10 @@ export function PluginSpecEditor(props: PluginSpecEditorProps): ReactElement | n data: plugin, isLoading, error, - } = usePlugin(pluginType, pluginKind, undefined, getPluginOverrides({ metadata: pluginMetadata })); + } = usePlugin(pluginType, pluginKind, { + version: pluginMetadata?.version, + registry: pluginMetadata?.registry, + }); if (error) { return ; diff --git a/plugin-system/src/components/Variables/variable-model.ts b/plugin-system/src/components/Variables/variable-model.ts index 32d256c4..69738126 100644 --- a/plugin-system/src/components/Variables/variable-model.ts +++ b/plugin-system/src/components/Variables/variable-model.ts @@ -18,14 +18,7 @@ import { useCallback, useMemo, useState } from 'react'; import type { GetVariableOptionsContext, VariableOption, VariablePlugin } from '../../model'; import type { VariableStateMap } from '../../runtime'; -import { - getPluginOverrides, - useAllVariableValues, - useDatasourceStore, - usePlugin, - usePlugins, - useTimeRange, -} from '../../runtime'; +import { useAllVariableValues, useDatasourceStore, usePlugin, usePlugins, useTimeRange } from '../../runtime'; export function filterVariableList(data: VariableOption[], capturedRegexp: RegExp): VariableOption[] { const result: VariableOption[] = []; @@ -97,12 +90,10 @@ function resolveDependsOnVariables( } export function useListVariablePluginValues(definition: ListVariableDefinition): UseQueryResult { - const { data: variablePlugin } = usePlugin( - 'Variable', - definition.spec.plugin.kind, - undefined, - getPluginOverrides(definition.spec.plugin), - ); + const { data: variablePlugin } = usePlugin('Variable', definition.spec.plugin.kind, { + version: definition.spec.plugin.metadata?.version, + registry: definition.spec.plugin.metadata?.registry, + }); const variablePluginCtx = useVariablePluginContext(); @@ -145,7 +136,11 @@ export function useResolveListVariableValues(variableDefinitions: VariableDefini const pluginResults = usePlugins( 'Variable', - listVariables.map((d) => ({ kind: d.spec.plugin.kind, ...getPluginOverrides(d.spec.plugin) })), + listVariables.map((d) => ({ + kind: d.spec.plugin.kind, + version: d.spec.plugin.metadata?.version, + registry: d.spec.plugin.metadata?.registry, + })), ); // 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 1bc1271f..0cc41af2 100644 --- a/plugin-system/src/runtime/alerts-queries.ts +++ b/plugin-system/src/runtime/alerts-queries.ts @@ -17,7 +17,7 @@ import { useQueries } from '@tanstack/react-query'; import type { AlertsQueryContext, AlertsQueryPlugin } from '../model'; import { useDatasourceStore } from './datasources'; -import { usePluginRegistry, usePlugins, getPluginOverrides } from './plugin-registry'; +import { usePluginRegistry, usePlugins } from './plugin-registry'; import { filterVariableStateMap, getVariableValuesKey } from './utils'; import { useAllVariableValues } from './variables'; @@ -35,7 +35,11 @@ export function useAlertsQueries(definitions: AlertsQueryDefinition[]): Array ({ kind: d.spec.plugin.kind, ...getPluginOverrides(d.spec.plugin) })), + definitions.map((d) => ({ + kind: d.spec.plugin.kind, + version: d.spec.plugin.metadata?.version, + registry: d.spec.plugin.metadata?.registry, + })), ); return useQueries({ @@ -54,7 +58,8 @@ export function useAlertsQueries(definitions: AlertsQueryDefinition[]): Array ({ kind: d.plugin.kind, ...getPluginOverrides(d.plugin) })), + definitions.map((d) => ({ + kind: d.plugin.kind, + version: d.plugin.metadata?.version, + registry: d.plugin.metadata?.registry, + })), ); // useQueries() handles data fetching from query plugins @@ -95,7 +99,8 @@ export function useAnnotations(definitions: AnnotationSpec[]): Array { - const { data: annotationPlugin } = usePlugin( - 'Annotation', - spec.plugin.kind, - undefined, - getPluginOverrides(spec.plugin), - ); + const { data: annotationPlugin } = usePlugin('Annotation', spec.plugin.kind, { + version: spec.plugin.metadata?.version, + registry: spec.plugin.metadata?.registry, + }); 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 7ef06867..3c97fa22 100644 --- a/plugin-system/src/runtime/log-queries.ts +++ b/plugin-system/src/runtime/log-queries.ts @@ -17,7 +17,7 @@ import { useQueries } from '@tanstack/react-query'; import type { LogQueryResult } from '../model/log-queries'; import { useDatasourceStore } from './datasources'; -import { usePluginRegistry, getPluginOverrides } from './plugin-registry'; +import { usePluginRegistry } from './plugin-registry'; import { useTimeRange } from './TimeRangeProvider'; import { useVariableValues } from './variables'; @@ -51,7 +51,8 @@ export function useLogQueries(definitions: LogQueryDefinition[]): Array(compoundKey: PluginCompoundKey): Promise>; @@ -45,60 +44,41 @@ export function usePluginRegistry(): PluginRegistryContextType { return ctx; } +type UsePluginQueryKey = [string, PluginType | undefined, string, string, string]; + // Allows consumers to pass useQuery options from react-query when loading a plugin type UsePluginOptions = Omit< - UseQueryOptions< - PluginImplementation, - Error, - PluginImplementation, - [string, PluginType | undefined, string, string, string] - >, + UseQueryOptions, Error, PluginImplementation, UsePluginQueryKey>, 'queryKey' | 'queryFn' ->; - -/** - * Extract the pinned version/registry from a plugin definition's `metadata`, if any. Returns `undefined` when nothing - * is pinned so the plugin resolves to its latest available version. - */ -export function getPluginOverrides( - plugin: Pick, 'metadata'> | undefined, -): PluginDefinitionMetadata | undefined { - const metadata = plugin?.metadata; - if (!metadata) { - return undefined; - } - // `latest` means "resolve the latest available version", so it must not be treated as an exact-version pin. - const version = metadata.version === LATEST_PLUGIN_VERSION ? undefined : metadata.version; - const registry = metadata.registry; - if (version === undefined && registry === undefined) { - return undefined; - } - return { version, registry }; -} +> & { + /** Pin resolution to a specific plugin version. When omitted, the latest available version is used. */ + version?: string; + /** Pin resolution to a specific plugin registry. */ + registry?: string; +}; /** * 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 + * When `options.version` is provided, the plugin is resolved with an exact version match: if that version is not * installed, the query fails instead of silently falling back to the latest available version. */ export function usePlugin( pluginType: T | undefined, kind: string, options?: UsePluginOptions, - overrides?: PluginDefinitionMetadata, ): UseQueryResult, Error> { - const { version, registry } = overrides ?? {}; + const { version, registry, ...queryOptions } = options ?? {}; // We never want to ask for a plugin when the kind isn't set yet, so disable those queries automatically - options = { - ...options, - enabled: (options?.enabled ?? true) && pluginType !== undefined && kind !== '', + const useQueryOptions = { + ...queryOptions, + enabled: (queryOptions.enabled ?? true) && pluginType !== undefined && kind !== '', }; const { getPlugin } = usePluginRegistry(); return useQuery({ queryKey: ['getPlugin', pluginType, kind, version ?? '', registry ?? ''], queryFn: () => getPlugin({ kind: pluginType!, name: kind, version, registry }), - ...options, + ...useQueryOptions, }); } diff --git a/plugin-system/src/runtime/profile-queries.ts b/plugin-system/src/runtime/profile-queries.ts index ff0c87f0..45325b97 100644 --- a/plugin-system/src/runtime/profile-queries.ts +++ b/plugin-system/src/runtime/profile-queries.ts @@ -16,7 +16,7 @@ import type { UseQueryResult } from '@tanstack/react-query'; import { useQueries } from '@tanstack/react-query'; import { useDatasourceStore } from './datasources'; -import { usePluginRegistry, getPluginOverrides } from './plugin-registry'; +import { usePluginRegistry } from './plugin-registry'; import { useTimeRange } from './TimeRangeProvider'; export type ProfileQueryDefinition = QueryDefinition<'ProfileQuery', PluginSpec>; export const PROFILE_QUERY_KEY = 'ProfileQuery'; @@ -51,7 +51,8 @@ export function useProfileQueries(definitions: ProfileQueryDefinition[]): Array< const plugin = await getPlugin({ kind: PROFILE_QUERY_KEY, name: profileQueryKind, - ...getPluginOverrides(definition.spec.plugin), + version: definition.spec.plugin.metadata?.version, + registry: definition.spec.plugin.metadata?.registry, }); 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 e18fed28..e06cb5ee 100644 --- a/plugin-system/src/runtime/silences-queries.ts +++ b/plugin-system/src/runtime/silences-queries.ts @@ -17,7 +17,7 @@ import { useQueries } from '@tanstack/react-query'; import type { SilencesQueryContext, SilencesQueryPlugin } from '../model'; import { useDatasourceStore } from './datasources'; -import { usePluginRegistry, usePlugins, getPluginOverrides } from './plugin-registry'; +import { usePluginRegistry, usePlugins } from './plugin-registry'; import { filterVariableStateMap, getVariableValuesKey } from './utils'; import { useAllVariableValues } from './variables'; @@ -35,7 +35,11 @@ export function useSilencesQueries(definitions: SilencesQueryDefinition[]): Arra const pluginLoaderResponse = usePlugins( 'SilencesQuery', - definitions.map((d) => ({ kind: d.spec.plugin.kind, ...getPluginOverrides(d.spec.plugin) })), + definitions.map((d) => ({ + kind: d.spec.plugin.kind, + version: d.spec.plugin.metadata?.version, + registry: d.spec.plugin.metadata?.registry, + })), ); return useQueries({ @@ -54,7 +58,8 @@ export function useSilencesQueries(definitions: SilencesQueryDefinition[]): Arra const plugin = await getPlugin({ kind: SILENCES_QUERY_KEY, name: silencesQueryKind, - ...getPluginOverrides(definition.spec.plugin), + version: definition.spec.plugin.metadata?.version, + registry: definition.spec.plugin.metadata?.registry, }); 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 da652d79..f9c3bf7b 100644 --- a/plugin-system/src/runtime/time-series-queries.ts +++ b/plugin-system/src/runtime/time-series-queries.ts @@ -17,7 +17,7 @@ import { useQueries, useQuery, useQueryClient } from '@tanstack/react-query'; import type { TimeSeriesDataQuery, TimeSeriesQueryContext, TimeSeriesQueryMode, TimeSeriesQueryPlugin } from '../model'; import { useDatasourceStore } from './datasources'; -import { usePlugin, usePluginRegistry, usePlugins, getPluginOverrides } from './plugin-registry'; +import { usePlugin, usePluginRegistry, usePlugins } from './plugin-registry'; import { useTimeRange } from './TimeRangeProvider'; import { filterVariableStateMap, getVariableValuesKey } from './utils'; import { useAllVariableValues } from './variables'; @@ -82,12 +82,10 @@ export const useTimeSeriesQuery = ( options?: UseTimeSeriesQueryOptions, queryOptions?: QueryObserverOptions, ): UseQueryResult => { - const { data: plugin } = usePlugin( - TIME_SERIES_QUERY_KEY, - definition.spec.plugin.kind, - undefined, - getPluginOverrides(definition.spec.plugin), - ); + const { data: plugin } = usePlugin(TIME_SERIES_QUERY_KEY, definition.spec.plugin.kind, { + version: definition.spec.plugin.metadata?.version, + registry: definition.spec.plugin.metadata?.registry, + }); const context = useTimeSeriesQueryContext(); const { queryEnabled, queryKey } = getQueryOptions({ plugin, definition, context }); return useQuery({ @@ -122,7 +120,11 @@ export function useTimeSeriesQueries( const pluginLoaderResponse = usePlugins( TIME_SERIES_QUERY_KEY, - definitions.map((d) => ({ kind: d.spec.plugin.kind, ...getPluginOverrides(d.spec.plugin) })), + definitions.map((d) => ({ + kind: d.spec.plugin.kind, + version: d.spec.plugin.metadata?.version, + registry: d.spec.plugin.metadata?.registry, + })), ); return useQueries({ queries: definitions.map((definition, idx) => { @@ -140,7 +142,8 @@ export function useTimeSeriesQueries( const plugin = await getPlugin({ kind: TIME_SERIES_QUERY_KEY, name: definition.spec.plugin.kind, - ...getPluginOverrides(definition.spec.plugin), + version: definition.spec.plugin.metadata?.version, + registry: definition.spec.plugin.metadata?.registry, }); 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 96da0b47..70cc28cb 100644 --- a/plugin-system/src/runtime/trace-queries.ts +++ b/plugin-system/src/runtime/trace-queries.ts @@ -17,7 +17,7 @@ import { useQueries } from '@tanstack/react-query'; import type { TraceQueryContext, TraceQueryPlugin } from '../model'; import { useDatasourceStore } from './datasources'; -import { usePluginRegistry, usePlugins, getPluginOverrides } from './plugin-registry'; +import { usePluginRegistry, usePlugins } from './plugin-registry'; import { useTimeRange } from './TimeRangeProvider'; import { filterVariableStateMap, getVariableValuesKey } from './utils'; import { useAllVariableValues } from './variables'; @@ -35,7 +35,11 @@ export function useTraceQueries(definitions: TraceQueryDefinition[]): Array ({ kind: d.spec.plugin.kind, ...getPluginOverrides(d.spec.plugin) })), + definitions.map((d) => ({ + kind: d.spec.plugin.kind, + version: d.spec.plugin.metadata?.version, + registry: d.spec.plugin.metadata?.registry, + })), ); // useQueries() handles data fetching from query plugins (e.g. traceQL queries, promQL queries) @@ -56,7 +60,8 @@ export function useTraceQueries(definitions: TraceQueryDefinition[]): Array Date: Tue, 1 Sep 2026 15:10:04 +0200 Subject: [PATCH 10/11] Only show update button if lock mode is available Signed-off-by: Guillaume LADORME --- .../src/components/DashboardToolbar/DashboardToolbar.tsx | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/dashboards/src/components/DashboardToolbar/DashboardToolbar.tsx b/dashboards/src/components/DashboardToolbar/DashboardToolbar.tsx index c5fc0dcc..bb7dc144 100644 --- a/dashboards/src/components/DashboardToolbar/DashboardToolbar.tsx +++ b/dashboards/src/components/DashboardToolbar/DashboardToolbar.tsx @@ -116,8 +116,12 @@ export const DashboardToolbar = (props: DashboardToolbarProps): ReactElement => {isLinksEnabled && } - - {isLockModeAvailable && } + {isLockModeAvailable && ( + <> + + + + )}