> => Promise.resolve(buildModule('dev')),
+ },
+ ]);
+ render(
+
+ {children}
+ ,
+ );
+}
+
+function Consumer({ version }: { version?: string }): ReactElement {
+ const { data, isLoading, error } = usePlugin('Variable', PLUGIN_NAME, undefined, version ? { version } : undefined);
+ if (isLoading) return loading
;
+ if (error) return error: {error.message}
;
+ return source: {(data as unknown as { source?: string })?.source}
;
+}
+
+describe('PluginRegistry dev plugin precedence', () => {
+ it('prefers a plugin served in dev over a newer installed one when no version is pinned', async () => {
+ renderWithLoader();
+ expect(await screen.findByText('source: dev', undefined, { timeout: 3000 })).toBeInTheDocument();
+ });
+
+ it('still honors an explicitly pinned version instead of the dev plugin', async () => {
+ renderWithLoader();
+ expect(await screen.findByText('source: installed', undefined, { timeout: 3000 })).toBeInTheDocument();
+ });
+});
diff --git a/plugin-system/src/components/PluginRegistry/PluginRegistry.tsx b/plugin-system/src/components/PluginRegistry/PluginRegistry.tsx
index eee0abfe..ad9a3829 100644
--- a/plugin-system/src/components/PluginRegistry/PluginRegistry.tsx
+++ b/plugin-system/src/components/PluginRegistry/PluginRegistry.tsx
@@ -23,7 +23,7 @@ import {
DefaultPluginKinds,
} from '../../model';
import { PluginRegistryContext } from '../../runtime';
-import { useEvent } from '../../utils';
+import { comparePluginVersions, useEvent } from '../../utils';
import { resolvePluginKeys } from './getPluginSearchHelper';
import { usePluginIndexes, PluginCompoundKey } from './plugin-indexes';
@@ -33,6 +33,47 @@ export interface PluginRegistryProps {
children?: ReactNode;
}
+/**
+ * Returns the indexed key of a plugin served by a local dev server for the given plugin type and kind, if any.
+ * Keys are `${kind}:${name}:${registry}:${version}`, so we match on the `kind:name:` prefix.
+ */
+function findDevPluginKey(devPluginKeys: Set, kind: string, name: string): string | undefined {
+ const prefix = `${kind}:${name}:`;
+ for (const key of devPluginKeys) {
+ if (key.startsWith(prefix)) {
+ return key;
+ }
+ }
+ return undefined;
+}
+
+/**
+ * Returns the indexed keys (`${kind}:${name}:${registry}:${version}`) matching *every* field supplied in the query.
+ * `kind` and `name` are always compared; `registry` and `version` are only compared when they are set, so a
+ * version-only pin matches whatever registry the plugin happens to be installed under, and a registry-only pin never
+ * leaks into another registry. Results are ordered from the newest version to the oldest.
+ */
+function findMatchingPluginKeys(
+ allKeys: Iterable,
+ query: PluginCompoundKey,
+): string[] {
+ const { kind, name, registry, version } = query;
+ const prefix = `${kind}:${name}:`;
+ const matches: Array<{ key: string; version: string }> = [];
+
+ for (const key of allKeys) {
+ if (!key.startsWith(prefix)) continue;
+ const parts = key.split(':');
+ if (parts.length !== 4) continue;
+ const [, , keyRegistry, keyVersion] = parts;
+ if (registry !== undefined && keyRegistry !== registry) continue;
+ if (version !== undefined && keyVersion !== version) continue;
+ matches.push({ key, version: keyVersion ?? '' });
+ }
+
+ return matches.toSorted((a, b) => comparePluginVersions(b.version, a.version)).map((match) => match.key);
+}
+
/**
* PluginRegistryContext provider that keeps track of all available plugins and provides an API for getting them or
* querying the metadata about them.
@@ -66,12 +107,24 @@ export function PluginRegistry(props: PluginRegistryProps): ReactElement {
const getPlugin = useCallback(
async (compoundKeyObj: PluginCompoundKey): Promise> => {
const pluginIndexes = await getPluginIndexes();
- const { kind, name } = compoundKeyObj;
+ const { kind, name, version, registry } = compoundKeyObj;
+ const allKeys = pluginIndexes.pluginResourcesByNameKindRegistryVersion.keys();
- const candidateKeys = resolvePluginKeys(
- pluginIndexes.pluginResourcesByNameKindRegistryVersion.keys(),
- compoundKeyObj,
- );
+ let candidateKeys: string[];
+ if (version || registry) {
+ // A pin is an exact constraint: only the plugins matching every supplied field are acceptable, and we never
+ // silently fall back to another version or another registry.
+ candidateKeys = findMatchingPluginKeys(allKeys, compoundKeyObj);
+ } else {
+ candidateKeys = resolvePluginKeys(allKeys, compoundKeyObj);
+ // Nothing pinned: a plugin served by a local dev server (`percli plugin start`) wins over installed archives,
+ // whatever their versions. Otherwise a dev plugin whose package version is lower than an installed archive would
+ // never be used, which defeats the purpose of running it in dev.
+ const devKey = findDevPluginKey(pluginIndexes.devPluginKeys, kind, name);
+ if (devKey) {
+ candidateKeys = [devKey, ...candidateKeys.filter((key) => key !== devKey)];
+ }
+ }
for (const resourceKey of candidateKeys) {
const resource = pluginIndexes.pluginResourcesByNameKindRegistryVersion.get(resourceKey);
@@ -86,14 +139,26 @@ export function PluginRegistry(props: PluginRegistryProps): ReactElement {
if (versionlessPlugin) return versionlessPlugin as PluginImplementation;
}
- throw new Error(`A ${name} plugin for kind '${kind}' is not installed`);
+ const pins = [
+ version ? `version '${version}'` : undefined,
+ registry ? `registry '${registry}'` : undefined,
+ ].filter((pin) => pin !== undefined);
+ throw new Error(
+ pins.length > 0
+ ? `A ${name} plugin for kind '${kind}' with ${pins.join(' and ')} is not installed`
+ : `A ${name} plugin for kind '${kind}' is not installed`,
+ );
},
[getPluginIndexes, loadPluginModule],
);
const listPluginMetadata = useCallback(
- async (pluginTypes: PluginType[]) => {
+ async (pluginTypes?: PluginType[]) => {
const pluginIndexes = await getPluginIndexes();
+ if (pluginTypes === undefined) {
+ // No filter: return the metadata of every installed plugin, whatever its type.
+ return [...pluginIndexes.pluginMetadataByKind.values()].flat();
+ }
return pluginTypes.flatMap((type) => pluginIndexes.pluginMetadataByKind.get(type) ?? []);
},
[getPluginIndexes],
diff --git a/plugin-system/src/components/PluginRegistry/PluginRegistry.versions.test.tsx b/plugin-system/src/components/PluginRegistry/PluginRegistry.versions.test.tsx
new file mode 100644
index 00000000..fe3eea95
--- /dev/null
+++ b/plugin-system/src/components/PluginRegistry/PluginRegistry.versions.test.tsx
@@ -0,0 +1,91 @@
+// Copyright The Perses Authors
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
+import { render, screen } from '@testing-library/react';
+import { ReactElement, ReactNode } from 'react';
+
+import { dynamicImportPluginLoader, PluginModuleResource } from '../../model';
+import { usePlugin } from '../../runtime';
+import { PluginRegistry } from './PluginRegistry';
+
+const PLUGIN_NAME = 'TestVariable';
+
+/** A plugin module exposing a single Variable plugin, installed under the given version/registry. */
+function buildResource(version: string, registry?: string): PluginModuleResource {
+ return {
+ kind: 'PluginModule',
+ metadata: { name: `Module-${registry ?? 'default'}-${version}`, version, registry },
+ spec: {
+ plugins: [{ kind: 'Variable', spec: { name: PLUGIN_NAME, display: { name: PLUGIN_NAME } } }],
+ },
+ };
+}
+
+/** The plugin implementation carries a marker so tests can tell which module was loaded. */
+function buildModule(source: string): Record {
+ return { [PLUGIN_NAME]: { createInitialOptions: () => ({}), source } };
+}
+
+function renderConsumer(children: ReactNode, resources: Array<[PluginModuleResource, string]>): void {
+ const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
+ const pluginLoader = dynamicImportPluginLoader(
+ resources.map(([resource, source]) => ({
+ resource,
+ importPlugin: (): Promise> => Promise.resolve(buildModule(source)),
+ })),
+ );
+ render(
+
+ {children}
+ ,
+ );
+}
+
+function Consumer({ version, registry }: { version?: string; registry?: string }): ReactElement {
+ const { data, isLoading, error } = usePlugin('Variable', PLUGIN_NAME, undefined, { version, registry });
+ if (isLoading) return loading
;
+ if (error) return error: {error.message}
;
+ return source: {(data as unknown as { source?: string })?.source}
;
+}
+
+describe('PluginRegistry version and registry pinning', () => {
+ it('resolves a version-only pin even when the plugin is installed under a named registry', async () => {
+ // A version-only pin is what the panel editor produces. The plugin only exists in the `corp` registry, so building
+ // a synthetic registry-less key would make it look missing.
+ renderConsumer(, [
+ [buildResource('1.0.0', 'corp'), 'corp-1.0.0'],
+ [buildResource('2.0.0', 'corp'), 'corp-2.0.0'],
+ ]);
+ expect(await screen.findByText('source: corp-1.0.0', undefined, { timeout: 3000 })).toBeInTheDocument();
+ });
+
+ it('never falls back to another version when a version is pinned', async () => {
+ renderConsumer(, [[buildResource('1.0.0'), 'v1']]);
+ expect(await screen.findByText(/^error:/, undefined, { timeout: 3000 })).toHaveTextContent("version '3.0.0'");
+ });
+
+ it('never falls back to another registry when a registry is pinned', async () => {
+ renderConsumer(, [[buildResource('1.0.0', 'community'), 'community']]);
+ expect(await screen.findByText(/^error:/, undefined, { timeout: 3000 })).toHaveTextContent("registry 'corp'");
+ });
+
+ it('resolves the latest version inside the pinned registry', async () => {
+ renderConsumer(, [
+ [buildResource('1.0.0', 'corp'), 'corp-1.0.0'],
+ [buildResource('2.0.0', 'corp'), 'corp-2.0.0'],
+ [buildResource('9.0.0', 'community'), 'community-9.0.0'],
+ ]);
+ expect(await screen.findByText('source: corp-2.0.0', undefined, { timeout: 3000 })).toBeInTheDocument();
+ });
+});
diff --git a/plugin-system/src/components/PluginRegistry/plugin-indexes.ts b/plugin-system/src/components/PluginRegistry/plugin-indexes.ts
index f2f8b698..c11196b1 100644
--- a/plugin-system/src/components/PluginRegistry/plugin-indexes.ts
+++ b/plugin-system/src/components/PluginRegistry/plugin-indexes.ts
@@ -31,6 +31,8 @@ export interface PluginIndexes {
pluginResourcesByNameKindRegistryVersion: Map;
// Plugin metadata by plugin type
pluginMetadataByKind: Map;
+ // Subset of the keys above that are served by a local dev server (`percli plugin start`)
+ devPluginKeys: Set;
}
/**
@@ -47,6 +49,7 @@ export function usePluginIndexes(
// Create the two indexes from the installed plugins
const pluginResourcesByNameKindRegistryVersion = new Map();
const pluginMetadataByKind = new Map();
+ const devPluginKeys = new Set();
for (const resource of installedPlugins) {
const {
@@ -65,6 +68,9 @@ export function usePluginIndexes(
);
}
pluginResourcesByNameKindRegistryVersion.set(key, resource);
+ if (resource.status?.inDev) {
+ devPluginKeys.add(key);
+ }
// Index the metadata by plugin type
let list = pluginMetadataByKind.get(kind);
@@ -79,6 +85,7 @@ export function usePluginIndexes(
return {
pluginResourcesByNameKindRegistryVersion,
pluginMetadataByKind,
+ devPluginKeys,
};
});
diff --git a/plugin-system/src/components/PluginSpecEditor/PluginSpecEditor.tsx b/plugin-system/src/components/PluginSpecEditor/PluginSpecEditor.tsx
index 354fe725..8d14d65a 100644
--- a/plugin-system/src/components/PluginSpecEditor/PluginSpecEditor.tsx
+++ b/plugin-system/src/components/PluginSpecEditor/PluginSpecEditor.tsx
@@ -17,7 +17,7 @@ import { DatasourceSpec, UnknownSpec } from '@perses-dev/spec';
import { ReactElement } from 'react';
import { DatasourcePlugin, OptionsEditorProps, Plugin, PluginType } from '../../model';
-import { usePlugin } from '../../runtime';
+import { getPluginOverrides, usePlugin } from '../../runtime';
import { PluginEditorSelection } from '../PluginEditor';
import { DatasourceSpecEditor } from './DatasourceSpecEditor';
@@ -36,12 +36,17 @@ function isDatasourcePlugin(
export function PluginSpecEditor(props: PluginSpecEditorProps): ReactElement | null {
const {
- pluginSelection: { type: pluginType, kind: pluginKind },
+ pluginSelection: { type: pluginType, kind: pluginKind, metadata: pluginMetadata },
value,
testConnection,
...others
} = props;
- const { data: plugin, isLoading, error } = usePlugin(pluginType, pluginKind);
+ // Edit the exact implementation the definition is pinned to, so the options editor matches the saved spec schema.
+ const {
+ data: plugin,
+ isLoading,
+ error,
+ } = usePlugin(pluginType, pluginKind, undefined, getPluginOverrides({ metadata: pluginMetadata }));
if (error) {
return ;
diff --git a/plugin-system/src/components/Variables/variable-model.ts b/plugin-system/src/components/Variables/variable-model.ts
index aed24688..cde7aefc 100644
--- a/plugin-system/src/components/Variables/variable-model.ts
+++ b/plugin-system/src/components/Variables/variable-model.ts
@@ -21,6 +21,7 @@ import {
useDatasourceStore,
usePlugin,
usePlugins,
+ getPluginOverrides,
useTimeRange,
VariableStateMap,
} from '../../runtime';
@@ -95,7 +96,12 @@ function resolveDependsOnVariables(
}
export function useListVariablePluginValues(definition: ListVariableDefinition): UseQueryResult {
- const { data: variablePlugin } = usePlugin('Variable', definition.spec.plugin.kind);
+ const { data: variablePlugin } = usePlugin(
+ 'Variable',
+ definition.spec.plugin.kind,
+ undefined,
+ getPluginOverrides(definition.spec.plugin),
+ );
const variablePluginCtx = useVariablePluginContext();
@@ -138,7 +144,7 @@ export function useResolveListVariableValues(variableDefinitions: VariableDefini
const pluginResults = usePlugins(
'Variable',
- listVariables.map((d) => ({ kind: d.spec.plugin.kind })),
+ listVariables.map((d) => ({ kind: d.spec.plugin.kind, ...getPluginOverrides(d.spec.plugin) })),
);
// Resolved variable state. Updated by onFetched when queries resolve.
diff --git a/plugin-system/src/model/plugins.ts b/plugin-system/src/model/plugins.ts
index 42f8014d..661083ff 100644
--- a/plugin-system/src/model/plugins.ts
+++ b/plugin-system/src/model/plugins.ts
@@ -61,12 +61,24 @@ export interface PluginModuleMetadata {
registry?: string;
}
+/**
+ * Status of a module/package that contains plugins, as reported by the Perses server.
+ */
+export interface PluginModuleStatus {
+ isLoaded?: boolean;
+ /**
+ * True when the module is served by a local dev server (`percli plugin start`) instead of an installed archive.
+ */
+ inDev?: boolean;
+}
+
/**
* Information about a module/package that contains plugins.
*/
export interface PluginModuleResource {
kind: 'PluginModule';
metadata: PluginModuleMetadata;
+ status?: PluginModuleStatus;
spec: PluginModuleSpec;
}
diff --git a/plugin-system/src/runtime/alerts-queries.ts b/plugin-system/src/runtime/alerts-queries.ts
index c4a13a07..623c204c 100644
--- a/plugin-system/src/runtime/alerts-queries.ts
+++ b/plugin-system/src/runtime/alerts-queries.ts
@@ -16,7 +16,7 @@ import { QueryKey, useQueries, UseQueryResult } from '@tanstack/react-query';
import { AlertsQueryContext, AlertsQueryPlugin } from '../model';
import { useDatasourceStore } from './datasources';
-import { usePluginRegistry, usePlugins } from './plugin-registry';
+import { usePluginRegistry, usePlugins, getPluginOverrides } from './plugin-registry';
import { filterVariableStateMap, getVariableValuesKey } from './utils';
import { useAllVariableValues } from './variables';
@@ -34,7 +34,7 @@ export function useAlertsQueries(definitions: AlertsQueryDefinition[]): Array ({ kind: d.spec.plugin.kind })),
+ definitions.map((d) => ({ kind: d.spec.plugin.kind, ...getPluginOverrides(d.spec.plugin) })),
);
return useQueries({
@@ -50,7 +50,11 @@ export function useAlertsQueries(definitions: AlertsQueryDefinition[]): Array => {
- const plugin = await getPlugin({ kind: ALERTS_QUERY_KEY, name: alertsQueryKind });
+ const plugin = await getPlugin({
+ kind: ALERTS_QUERY_KEY,
+ name: alertsQueryKind,
+ ...getPluginOverrides(definition.spec.plugin),
+ });
const data = await plugin.getAlertsData(definition.spec.plugin.spec, context, signal);
return data;
},
diff --git a/plugin-system/src/runtime/annotations.ts b/plugin-system/src/runtime/annotations.ts
index bc8dbdda..29e0b163 100644
--- a/plugin-system/src/runtime/annotations.ts
+++ b/plugin-system/src/runtime/annotations.ts
@@ -16,7 +16,7 @@ import { QueryKey, useQueries, useQuery, UseQueryResult } from '@tanstack/react-
import { AnnotationContext, AnnotationPlugin } from '../model';
import { useDatasourceStore } from './datasources';
-import { usePlugin, usePluginRegistry, usePlugins } from './plugin-registry';
+import { usePlugin, usePluginRegistry, usePlugins, getPluginOverrides } from './plugin-registry';
import { useTimeRange } from './TimeRangeProvider';
import { filterVariableStateMap, getVariableValuesKey } from './utils';
import { useAllVariableValues } from './variables';
@@ -74,7 +74,7 @@ export function useAnnotations(definitions: AnnotationSpec[]): Array ({ kind: d.plugin.kind })),
+ definitions.map((d) => ({ kind: d.plugin.kind, ...getPluginOverrides(d.plugin) })),
);
// useQueries() handles data fetching from query plugins
@@ -91,7 +91,11 @@ export function useAnnotations(definitions: AnnotationSpec[]): Array => {
- const plugin = await getPlugin({ kind: ANNOTATION_KEY, name: annotationKind });
+ const plugin = await getPlugin({
+ kind: ANNOTATION_KEY,
+ name: annotationKind,
+ ...getPluginOverrides(definition.plugin),
+ });
const data = await plugin.getAnnotationData(definition.plugin.spec, context, signal);
return data;
},
@@ -101,7 +105,12 @@ export function useAnnotations(definitions: AnnotationSpec[]): Array {
- const { data: annotationPlugin } = usePlugin('Annotation', spec.plugin.kind);
+ const { data: annotationPlugin } = usePlugin(
+ 'Annotation',
+ spec.plugin.kind,
+ undefined,
+ getPluginOverrides(spec.plugin),
+ );
const datasourceStore = useDatasourceStore();
const allVariables = useAllVariableValues();
diff --git a/plugin-system/src/runtime/log-queries.ts b/plugin-system/src/runtime/log-queries.ts
index 2ab4f502..1142d824 100644
--- a/plugin-system/src/runtime/log-queries.ts
+++ b/plugin-system/src/runtime/log-queries.ts
@@ -16,7 +16,7 @@ import { useQueries, UseQueryResult } from '@tanstack/react-query';
import { LogQueryResult } from '../model/log-queries';
import { useDatasourceStore } from './datasources';
-import { usePluginRegistry } from './plugin-registry';
+import { usePluginRegistry, getPluginOverrides } from './plugin-registry';
import { useTimeRange } from './TimeRangeProvider';
import { useVariableValues } from './variables';
@@ -47,7 +47,11 @@ export function useLogQueries(definitions: LogQueryDefinition[]): Array => {
- const plugin = await getPlugin({ kind: LOG_QUERY_KEY, name: logQueryKind });
+ const plugin = await getPlugin({
+ kind: LOG_QUERY_KEY,
+ name: logQueryKind,
+ ...getPluginOverrides(definition.spec.plugin),
+ });
const data = await plugin.getLogData(definition.spec.plugin.spec, context, signal);
return data;
},
diff --git a/plugin-system/src/runtime/plugin-registry.ts b/plugin-system/src/runtime/plugin-registry.ts
index 328772cc..466647f8 100644
--- a/plugin-system/src/runtime/plugin-registry.ts
+++ b/plugin-system/src/runtime/plugin-registry.ts
@@ -11,7 +11,7 @@
// See the License for the specific language governing permissions and
// limitations under the License.
-import { BuiltinVariableDefinition } from '@perses-dev/spec';
+import { BuiltinVariableDefinition, Definition, PluginDefinitionMetadata } from '@perses-dev/spec';
import { useQueries, useQuery, UseQueryOptions, UseQueryResult } from '@tanstack/react-query';
import { createContext, useContext } from 'react';
@@ -22,10 +22,11 @@ import {
PluginType,
PluginCompoundKey,
} from '../model';
+import { LATEST_PLUGIN_VERSION } from '../utils/plugin-versions';
export interface PluginRegistryContextType {
getPlugin(compoundKey: PluginCompoundKey): Promise>;
- listPluginMetadata(pluginTypes: PluginType[]): Promise;
+ listPluginMetadata(pluginTypes?: PluginType[]): Promise;
defaultPluginKinds?: DefaultPluginKinds;
}
@@ -45,18 +46,48 @@ export function usePluginRegistry(): PluginRegistryContextType {
// Allows consumers to pass useQuery options from react-query when loading a plugin
type UsePluginOptions = Omit<
- UseQueryOptions, Error, PluginImplementation, [string, PluginType | undefined, string]>,
+ UseQueryOptions<
+ PluginImplementation,
+ Error,
+ PluginImplementation,
+ [string, PluginType | undefined, string, string, string]
+ >,
'queryKey' | 'queryFn'
>;
+/**
+ * Extract the pinned version/registry from a plugin definition's `metadata`, if any. Returns `undefined` when nothing
+ * is pinned so the plugin resolves to its latest available version.
+ */
+export function getPluginOverrides(
+ plugin: Pick, 'metadata'> | undefined,
+): PluginDefinitionMetadata | undefined {
+ const metadata = plugin?.metadata;
+ if (!metadata) {
+ return undefined;
+ }
+ // `latest` means "resolve the latest available version", so it must not be treated as an exact-version pin.
+ const version = metadata.version === LATEST_PLUGIN_VERSION ? undefined : metadata.version;
+ const registry = metadata.registry;
+ if (version === undefined && registry === undefined) {
+ return undefined;
+ }
+ return { version, registry };
+}
+
/**
* Loads a plugin and returns the plugin implementation, along with loading/error state.
+ *
+ * When `overrides.version` is provided, the plugin is resolved with an exact version match: if that version is not
+ * installed, the query fails instead of silently falling back to the latest available version.
*/
export function usePlugin(
pluginType: T | undefined,
kind: string,
options?: UsePluginOptions,
+ overrides?: PluginDefinitionMetadata,
): UseQueryResult, Error> {
+ const { version, registry } = overrides ?? {};
// We never want to ask for a plugin when the kind isn't set yet, so disable those queries automatically
options = {
...options,
@@ -64,38 +95,62 @@ export function usePlugin(
};
const { getPlugin } = usePluginRegistry();
return useQuery({
- queryKey: ['getPlugin', pluginType, kind],
- queryFn: () => getPlugin({ kind: pluginType!, name: kind }),
+ queryKey: ['getPlugin', pluginType, kind, version ?? '', registry ?? ''],
+ queryFn: () => getPlugin({ kind: pluginType!, name: kind, version, registry }),
...options,
});
}
+/**
+ * A plugin reference to load, optionally pinned to a specific version/registry.
+ */
+export interface UsePluginsItem extends PluginDefinitionMetadata {
+ kind: string;
+}
+
+/**
+ * Full identity of a plugin to load. Two definitions pinned to different versions (or registries) of the same kind are
+ * distinct plugins and must be loaded independently.
+ */
+function getUsePluginsItemIdentity(plugin: UsePluginsItem): string {
+ return `${plugin.kind}:${plugin.version ?? ''}:${plugin.registry ?? ''}`;
+}
+
/**
* Loads a list of plugins and returns the plugin implementation, along with loading/error state.
*/
export function usePlugins(
pluginType: T,
- plugins: Array<{ kind: string }>,
+ plugins: UsePluginsItem[],
): Array>> {
const { getPlugin } = usePluginRegistry();
- // useQueries() does not support queries with duplicate keys, therefore we de-duplicate the plugin kinds before running useQueries()
+ // useQueries() does not support queries with duplicate keys, therefore we de-duplicate the plugins before running useQueries()
// This resolves the following warning in the JS console: "[QueriesObserver]: Duplicate Queries found. This might result in unexpected behavior."
// https://github.com/TanStack/query/issues/8224#issuecomment-2523554831
// https://github.com/TanStack/query/issues/4187#issuecomment-1256336901
- const kinds = [...new Set(plugins.map((p) => p.kind))];
+ const uniquePlugins = new Map();
+ for (const p of plugins) {
+ const key = getUsePluginsItemIdentity(p);
+ if (!uniquePlugins.has(key)) {
+ uniquePlugins.set(key, p);
+ }
+ }
+ const uniqueKeys = [...uniquePlugins.keys()];
+ const uniqueValues = [...uniquePlugins.values()];
const result: Array>> = useQueries({
- queries: kinds.map((kind) => {
+ queries: uniqueValues.map((p) => {
return {
- queryKey: ['getPlugin', pluginType, kind],
- queryFn: () => getPlugin({ kind: pluginType, name: kind }),
+ queryKey: ['getPlugin', pluginType, p.kind, p.version ?? '', p.registry ?? ''],
+ queryFn: () => getPlugin({ kind: pluginType, name: p.kind, version: p.version, registry: p.registry }),
};
}),
});
- // Re-assemble array in original order
- return plugins.map((p) => result[kinds.indexOf(p.kind)]!);
+ // Re-assemble array in original order. Index lookups go through a Map so this stays linear on large panels.
+ const indexByIdentity = new Map(uniqueKeys.map((key, index) => [key, index]));
+ return plugins.map((p) => result[indexByIdentity.get(getUsePluginsItemIdentity(p))!]!);
}
// Allow consumers to pass useQuery options from react-query when listing metadata
@@ -105,15 +160,17 @@ type UseListPluginMetadataOptions = Omit<
>;
/**
- * Gets a list of plugin metadata for the specified plugin type and returns it, along with loading/error state.
+ * Gets a list of plugin metadata for the specified plugin types and returns it, along with loading/error state. When
+ * `pluginTypes` is omitted, the metadata of every installed plugin is returned, whatever its type.
*/
export function useListPluginMetadata(
- pluginTypes: PluginType[],
+ pluginTypes?: PluginType[],
options?: UseListPluginMetadataOptions,
): UseQueryResult {
const { listPluginMetadata } = usePluginRegistry();
return useQuery({
- queryKey: ['listPluginMetadata', pluginTypes],
+ // `['*']` marks the "every plugin type" query so it gets its own cache entry.
+ queryKey: ['listPluginMetadata', pluginTypes ?? ['*']],
queryFn: () => listPluginMetadata(pluginTypes),
...options,
});
diff --git a/plugin-system/src/runtime/profile-queries.ts b/plugin-system/src/runtime/profile-queries.ts
index a6dda945..13a8a33a 100644
--- a/plugin-system/src/runtime/profile-queries.ts
+++ b/plugin-system/src/runtime/profile-queries.ts
@@ -15,7 +15,7 @@ import { QueryDefinition, UnknownSpec, ProfileData } from '@perses-dev/spec';
import { useQueries, UseQueryResult } from '@tanstack/react-query';
import { useDatasourceStore } from './datasources';
-import { usePluginRegistry } from './plugin-registry';
+import { usePluginRegistry, getPluginOverrides } from './plugin-registry';
import { useTimeRange } from './TimeRangeProvider';
export type ProfileQueryDefinition = QueryDefinition<'ProfileQuery', PluginSpec>;
export const PROFILE_QUERY_KEY = 'ProfileQuery';
@@ -47,7 +47,11 @@ export function useProfileQueries(definitions: ProfileQueryDefinition[]): Array<
refetchOnReconnect: false,
staleTime: Infinity,
queryFn: async ({ signal }: { signal?: AbortSignal }): Promise => {
- const plugin = await getPlugin({ kind: PROFILE_QUERY_KEY, name: profileQueryKind });
+ const plugin = await getPlugin({
+ kind: PROFILE_QUERY_KEY,
+ name: profileQueryKind,
+ ...getPluginOverrides(definition.spec.plugin),
+ });
const data = await plugin.getProfileData(definition.spec.plugin.spec, context, signal);
return data;
},
diff --git a/plugin-system/src/runtime/silences-queries.ts b/plugin-system/src/runtime/silences-queries.ts
index 3a407f77..fdc9dbd9 100644
--- a/plugin-system/src/runtime/silences-queries.ts
+++ b/plugin-system/src/runtime/silences-queries.ts
@@ -16,7 +16,7 @@ import { QueryKey, useQueries, UseQueryResult } from '@tanstack/react-query';
import { SilencesQueryContext, SilencesQueryPlugin } from '../model';
import { useDatasourceStore } from './datasources';
-import { usePluginRegistry, usePlugins } from './plugin-registry';
+import { usePluginRegistry, usePlugins, getPluginOverrides } from './plugin-registry';
import { filterVariableStateMap, getVariableValuesKey } from './utils';
import { useAllVariableValues } from './variables';
@@ -34,7 +34,7 @@ export function useSilencesQueries(definitions: SilencesQueryDefinition[]): Arra
const pluginLoaderResponse = usePlugins(
'SilencesQuery',
- definitions.map((d) => ({ kind: d.spec.plugin.kind })),
+ definitions.map((d) => ({ kind: d.spec.plugin.kind, ...getPluginOverrides(d.spec.plugin) })),
);
return useQueries({
@@ -50,7 +50,11 @@ export function useSilencesQueries(definitions: SilencesQueryDefinition[]): Arra
refetchOnReconnect: false,
staleTime: 60_000,
queryFn: async ({ signal }: { signal?: AbortSignal }): Promise => {
- const plugin = await getPlugin({ kind: SILENCES_QUERY_KEY, name: silencesQueryKind });
+ const plugin = await getPlugin({
+ kind: SILENCES_QUERY_KEY,
+ name: silencesQueryKind,
+ ...getPluginOverrides(definition.spec.plugin),
+ });
const data = await plugin.getSilencesData(definition.spec.plugin.spec, context, signal);
return data;
},
diff --git a/plugin-system/src/runtime/time-series-queries.ts b/plugin-system/src/runtime/time-series-queries.ts
index edc5bb44..5cbbd513 100644
--- a/plugin-system/src/runtime/time-series-queries.ts
+++ b/plugin-system/src/runtime/time-series-queries.ts
@@ -25,7 +25,7 @@ import {
import { TimeSeriesDataQuery, TimeSeriesQueryContext, TimeSeriesQueryMode, TimeSeriesQueryPlugin } from '../model';
import { useDatasourceStore } from './datasources';
-import { usePlugin, usePluginRegistry, usePlugins } from './plugin-registry';
+import { usePlugin, usePluginRegistry, usePlugins, getPluginOverrides } from './plugin-registry';
import { useTimeRange } from './TimeRangeProvider';
import { filterVariableStateMap, getVariableValuesKey } from './utils';
import { useAllVariableValues } from './variables';
@@ -90,7 +90,12 @@ export const useTimeSeriesQuery = (
options?: UseTimeSeriesQueryOptions,
queryOptions?: QueryObserverOptions,
): UseQueryResult => {
- const { data: plugin } = usePlugin(TIME_SERIES_QUERY_KEY, definition.spec.plugin.kind);
+ const { data: plugin } = usePlugin(
+ TIME_SERIES_QUERY_KEY,
+ definition.spec.plugin.kind,
+ undefined,
+ getPluginOverrides(definition.spec.plugin),
+ );
const context = useTimeSeriesQueryContext();
const { queryEnabled, queryKey } = getQueryOptions({ plugin, definition, context });
return useQuery({
@@ -125,7 +130,7 @@ export function useTimeSeriesQueries(
const pluginLoaderResponse = usePlugins(
TIME_SERIES_QUERY_KEY,
- definitions.map((d) => ({ kind: d.spec.plugin.kind })),
+ definitions.map((d) => ({ kind: d.spec.plugin.kind, ...getPluginOverrides(d.spec.plugin) })),
);
return useQueries({
queries: definitions.map((definition, idx) => {
@@ -140,7 +145,11 @@ export function useTimeSeriesQueries(
staleTime: Infinity,
queryKey: queryKey,
queryFn: async ({ signal }: { signal: AbortSignal }): Promise => {
- const plugin = await getPlugin({ kind: TIME_SERIES_QUERY_KEY, name: definition.spec.plugin.kind });
+ const plugin = await getPlugin({
+ kind: TIME_SERIES_QUERY_KEY,
+ name: definition.spec.plugin.kind,
+ ...getPluginOverrides(definition.spec.plugin),
+ });
const data = await plugin.getTimeSeriesData(definition.spec.plugin.spec, context, signal);
return data;
},
diff --git a/plugin-system/src/runtime/trace-queries.ts b/plugin-system/src/runtime/trace-queries.ts
index d3bb2442..4dbadabb 100644
--- a/plugin-system/src/runtime/trace-queries.ts
+++ b/plugin-system/src/runtime/trace-queries.ts
@@ -16,7 +16,7 @@ import { QueryKey, useQueries, UseQueryResult } from '@tanstack/react-query';
import { TraceQueryContext, TraceQueryPlugin } from '../model';
import { useDatasourceStore } from './datasources';
-import { usePluginRegistry, usePlugins } from './plugin-registry';
+import { usePluginRegistry, usePlugins, getPluginOverrides } from './plugin-registry';
import { useTimeRange } from './TimeRangeProvider';
import { filterVariableStateMap, getVariableValuesKey } from './utils';
import { useAllVariableValues } from './variables';
@@ -34,7 +34,7 @@ export function useTraceQueries(definitions: TraceQueryDefinition[]): Array ({ kind: d.spec.plugin.kind })),
+ definitions.map((d) => ({ kind: d.spec.plugin.kind, ...getPluginOverrides(d.spec.plugin) })),
);
// useQueries() handles data fetching from query plugins (e.g. traceQL queries, promQL queries)
@@ -52,7 +52,11 @@ export function useTraceQueries(definitions: TraceQueryDefinition[]): Array => {
- const plugin = await getPlugin({ kind: TRACE_QUERY_KEY, name: traceQueryKind });
+ const plugin = await getPlugin({
+ kind: TRACE_QUERY_KEY,
+ name: traceQueryKind,
+ ...getPluginOverrides(definition.spec.plugin),
+ });
const data = await plugin.getTraceData(definition.spec.plugin.spec, context, signal);
return data;
},
diff --git a/plugin-system/src/utils/index.ts b/plugin-system/src/utils/index.ts
index 113242ee..fa75cc9b 100644
--- a/plugin-system/src/utils/index.ts
+++ b/plugin-system/src/utils/index.ts
@@ -12,5 +12,6 @@
// limitations under the License.
export * from './event';
+export * from './plugin-versions';
export * from './variables';
export * from './csv-export';
diff --git a/plugin-system/src/utils/plugin-versions.test.ts b/plugin-system/src/utils/plugin-versions.test.ts
new file mode 100644
index 00000000..5da02896
--- /dev/null
+++ b/plugin-system/src/utils/plugin-versions.test.ts
@@ -0,0 +1,48 @@
+// Copyright The Perses Authors
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import { comparePluginVersions, sortPluginVersionsDesc } from './plugin-versions';
+
+describe('comparePluginVersions', () => {
+ test.each([
+ ['1.0.0', '1.0.0', 0],
+ ['1.2.0', '1.1.9', 1],
+ ['1.1.0', '1.2.0', -1],
+ ['v2.0.0', '1.9.9', 1],
+ // Numeric, not lexicographic, comparison of each segment
+ ['0.10.0', '0.9.0', 1],
+ ['1.10.0', '1.9.0', 1],
+ // A pre-release orders below its stable release
+ ['1.0.0-beta', '1.0.0', -1],
+ ['1.0.0-rc.2', '1.0.0-rc.1', 1],
+ // Loose forms the backend also accepts
+ ['1.0', '1.0.0', 0],
+ // Anything unparseable orders below a real version so it can never be picked as "the latest"
+ ['not-a-version', '0.0.1', -1],
+ ['0.0.1', 'not-a-version', 1],
+ ])('comparePluginVersions(%s, %s)', (a, b, expected) => {
+ expect(Math.sign(comparePluginVersions(a as string, b as string))).toBe(expected);
+ });
+
+ test('two unparseable versions are compared lexicographically', () => {
+ expect(Math.sign(comparePluginVersions('abc', 'abd'))).toBe(-1);
+ });
+});
+
+describe('sortPluginVersionsDesc', () => {
+ test('sorts from newest to oldest without mutating the input', () => {
+ const versions = ['1.0.0', '2.0.0-rc1', '1.10.0', '2.0.0'];
+ expect(sortPluginVersionsDesc(versions)).toEqual(['2.0.0', '2.0.0-rc1', '1.10.0', '1.0.0']);
+ expect(versions).toEqual(['1.0.0', '2.0.0-rc1', '1.10.0', '2.0.0']);
+ });
+});
diff --git a/plugin-system/src/utils/plugin-versions.ts b/plugin-system/src/utils/plugin-versions.ts
new file mode 100644
index 00000000..712cb476
--- /dev/null
+++ b/plugin-system/src/utils/plugin-versions.ts
@@ -0,0 +1,57 @@
+// Copyright The Perses Authors
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import { coerce, compare, parse, SemVer } from 'semver';
+
+/**
+ * Sentinel version meaning "the latest version available in the Perses instance". It mirrors the backend
+ * `plugin.LatestVersion` constant. A plugin definition using it is not pinned to an exact version: the plugin registry
+ * resolves it dynamically at load time.
+ */
+export const LATEST_PLUGIN_VERSION = 'latest';
+
+/**
+ * Parse a plugin version with semver, tolerating the loose forms the backend also accepts (a leading `v`, a missing
+ * patch segment, ...). Returns `null` when the value cannot be understood as a version at all.
+ */
+function parsePluginVersion(version: string): SemVer | null {
+ return parse(version, { loose: true }) ?? coerce(version);
+}
+
+/**
+ * Compare two plugin version strings with semver semantics, the same way the Perses backend orders plugin versions.
+ * Returns a positive number when `a` is greater than `b`, a negative number when it is lower, and 0 when they are equal.
+ *
+ * Pre-releases order below their stable release (`1.0.0-beta` < `1.0.0`), as semver mandates. Versions that cannot be
+ * parsed at all always order below parseable ones, and are compared lexicographically between themselves, so an
+ * unexpected value can never be picked as "the latest version".
+ */
+export function comparePluginVersions(a: string, b: string): number {
+ const parsedA = parsePluginVersion(a);
+ const parsedB = parsePluginVersion(b);
+ if (parsedA && parsedB) {
+ return compare(parsedA, parsedB);
+ }
+ if (parsedA) {
+ return 1;
+ }
+ if (parsedB) {
+ return -1;
+ }
+ return a.localeCompare(b);
+}
+
+/** Return a new array of versions sorted from newest to oldest, using {@link comparePluginVersions}. */
+export function sortPluginVersionsDesc(versions: string[]): string[] {
+ return versions.toSorted((a, b) => comparePluginVersions(b, a));
+}