Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion dashboards/src/components/Panel/Panel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -129,7 +129,7 @@ export const Panel = memo(function Panel(props: PanelProps) {
}

try {
const plugin = await getPlugin('Panel', panelPluginKind);
const plugin = await getPlugin({ kind: 'Panel', name: panelPluginKind });

// More defensive checking for plugin and actions
if (
Expand Down
5 changes: 4 additions & 1 deletion dashboards/src/context/DatasourceStoreProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,10 @@ export function DatasourceStoreProvider(props: DatasourceStoreProviderProps): Re
const getDatasourceClient = useCallback(
async function getClient<Client extends DatasourceClient>(selector: DatasourceSelector): Promise<Client> {
const { kind } = selector;
const [{ spec, proxyUrl }, plugin] = await Promise.all([findDatasource(selector), getPlugin('Datasource', kind)]);
const [{ spec, proxyUrl }, plugin] = await Promise.all([
findDatasource(selector),
getPlugin({ kind: 'Datasource', name: kind }),
]);

// allows extending client
const client = plugin.createClient(spec.plugin.spec, { proxyUrl }) as Client;
Expand Down
8 changes: 4 additions & 4 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion plugin-system/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,8 @@
"immer": "^10.1.1",
"react-hook-form": "^7.46.1",
"use-query-params": "^2.2.1",
"zod": "^3.25.76"
"zod": "^3.25.76",
"semver": "^7.8.0"
},
"peerDependencies": {
"@emotion/react": "^11.14.0",
Expand Down
67 changes: 54 additions & 13 deletions plugin-system/src/components/PluginRegistry/PluginRegistry.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,8 @@ import {
} from '../../model';
import { PluginRegistryContext } from '../../runtime';
import { useEvent } from '../../utils';
import { usePluginIndexes, getTypeAndKindKey } from './plugin-indexes';
import { usePluginIndexes, PluginCompoundKey } from './plugin-indexes';
import { lookUpDefaultPluginKey } from './getPluginSearchHelper';

export interface PluginRegistryProps {
pluginLoader: PluginLoader;
Expand Down Expand Up @@ -62,28 +63,68 @@ export function PluginRegistry(props: PluginRegistryProps): ReactElement {
});

const getPlugin = useCallback(
async <T extends PluginType>(kind: T, name: string): Promise<PluginImplementation<T>> => {
// Get the indexes of the installed plugins
async <T extends PluginType>(compoundKeyObj: PluginCompoundKey<T>): Promise<PluginImplementation<T>> => {
const pluginIndexes = await getPluginIndexes();
let resource: PluginModuleResource | undefined = undefined;
let pluginModule: Record<string, Plugin<UnknownSpec>> | undefined;
const { kind, name, version, registry } = compoundKeyObj;

// Figure out what module the plugin is in by looking in the index
const typeAndKindKey = getTypeAndKindKey(kind, name);
const resource = pluginIndexes.pluginResourcesByNameAndKind.get(typeAndKindKey);
if (resource === undefined) {
/**
* By default both version and registry are undefined,
* If one or both are passed, the registry will check if the plugin with the specific version and registry is available,
* falling back to the current behavior which is returning the default.
*/

if (registry || version) {
let compoundKey = '';
/**
* This branch tries to look up a resource deterministically, using a compound_key which consists of kind, name, registry, and version
* Based on the user input, the likely keys are
* 1- kind:name:registry:version (This is the complete compound key. It is very likely the key is looked up)
* 2- kind:name::version (This is very likely, because registry is an optional field of the resource object)
* 3- kind:name:registry: (It is impossible to find any combination, because version is a mandatory field of the resource. So, this will be handled by the fallback)
* Note: It is likely that the key is not found. However, the search should NOT give up easily!
* Instead it should continue with the fallback mechanism
*/
compoundKey = `${kind}:${name}:${registry}:${version}`;
Comment thread
jgbernalp marked this conversation as resolved.
Outdated
resource = pluginIndexes.pluginResourcesByNameKindRegistryVersion.get(compoundKey);
if (resource) {
pluginModule = (await loadPluginModule(resource)) as Record<string, Plugin<UnknownSpec>>;
const plugin = pluginModule?.[`${name}:${registry ?? ''}:${version ?? ''}`];
Comment thread
jgbernalp marked this conversation as resolved.
Outdated
if (!plugin) {
throw new Error(
`The ${name} plugin for kind '${kind}' is missing from the ${resource.metadata.name} plugin module`
);
}
return plugin as PluginImplementation<T>;
}
}
/**
* This is the fallback mechanism branch.
* It performs a minimal search using the mandatory inputs from user and returns the default resource
* More information can be found in the searchHelper.ts
*/
const resourceKey = lookUpDefaultPluginKey(
Array.from(pluginIndexes.pluginResourcesByNameKindRegistryVersion.keys()),
{
kind,
name,
}
);

if (!resourceKey) {
throw new Error(`A ${name} plugin for kind '${kind}' is not installed`);
}

// Treat the plugin module as a bunch of named exports that have plugins
const pluginModule = (await loadPluginModule(resource)) as Record<string, Plugin<UnknownSpec>>;
resource = pluginIndexes.pluginResourcesByNameKindRegistryVersion.get(resourceKey)!;
pluginModule = (await loadPluginModule(resource)) as Record<string, Plugin<UnknownSpec>>;

// We currently assume that plugin modules will have named exports that match the kinds they handle
const plugin = pluginModule[name];
if (plugin === undefined) {
const plugin = pluginModule?.[resourceKey];
if (!plugin) {
throw new Error(
`The ${name} plugin for kind '${kind}' is missing from the ${resource.metadata.name} plugin module`
);
}

return plugin as PluginImplementation<T>;
},
[getPluginIndexes, loadPluginModule]
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
// 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 { lookUpDefaultPluginKey } from './getPluginSearchHelper';

describe('getPluginSearchHelper', () => {
describe('same registers but different versions', () => {
const keys: string[] = ['Panel:TimeSeriesChart:dev:1.0.0', 'Panel:TimeSeriesChart:dev:2.0.0'];
it('should take the higher version', () => {
expect(lookUpDefaultPluginKey(keys, { kind: 'Panel', name: 'TimeSeriesChart' })).toBe(
'Panel:TimeSeriesChart:dev:2.0.0'
);
});
});

describe('with and without registry, versions race', () => {
const keys: string[] = [
'Panel:TimeSeriesChart:dev:2.0.0',
'Panel:TimeSeriesChart::1.0.0',
'Panel:TimeSeriesChartX:dev:1.0.0',
'Panel:TimeSeriesChartX::2.0.0',
];

it('should take the higher version', () => {
expect(lookUpDefaultPluginKey(keys, { kind: 'Panel', name: 'TimeSeriesChart' })).toBe(
'Panel:TimeSeriesChart:dev:2.0.0'
);

expect(lookUpDefaultPluginKey(keys, { kind: 'Panel', name: 'TimeSeriesChartX' })).toBe(
'Panel:TimeSeriesChartX::2.0.0'
);
});
});

describe('with and without registry - same versions - check policy', () => {
const keys: string[] = ['Panel:TimeSeriesChart:dev:2.0.0', 'Panel:TimeSeriesChart::2.0.0'];
it('should return the one without the registry by default', () => {
expect(lookUpDefaultPluginKey(keys, { kind: 'Panel', name: 'TimeSeriesChart' })).toBe(
'Panel:TimeSeriesChart::2.0.0'
);
});

it('should return the one with the registry', () => {
expect(
lookUpDefaultPluginKey(keys, { kind: 'Panel', name: 'TimeSeriesChart' }, { registryOverVersion: true })
).toBe('Panel:TimeSeriesChart:dev:2.0.0');
});
});
});
108 changes: 108 additions & 0 deletions plugin-system/src/components/PluginRegistry/getPluginSearchHelper.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
// 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 { gt } from 'semver';
import { PluginType } from '../../model';
import { PluginCompoundKey } from './plugin-indexes';

/**
* ____ LOOK UP DEFAULT PLUGIN KEYS WITH PLUGIN TYPE (KIND) AND NAME ___
* This is the fallback mechanism to look up the default plugin using the plugin type (kind) and the name
* When the version and registry are not available, the function shortlists all plugins which have the kind and name combination
* If multiple plugins are nominated, the function will follow a precedence policy
* ___ PLUGIN LOOKUP PRECEDENCE POLICY ___
* The search finds the latest versions available for plugins with and without registry by keeping them in two separate buckets
* 1- If nothing found, simply return undefined
* 2- If only one of the buckets have value, there will be no comparison. Return the one with the value
* 3- If both have the value, check the Precedence Logic input and act accordingly
* 3.1- If the one WITH the registry has a greater version, return it.
* 3.2- If the one WITHOUT the registry has a greater version, return it
* 3.2.1- If we have a draw consider the policy flag
*/

export interface PluginLookupPrecedenceLogic {
registryOverVersion: boolean;
}

const PLUGIN_LOOKUP_PRECEDENCE_LOGIC: PluginLookupPrecedenceLogic = { registryOverVersion: false };

export const lookUpDefaultPluginKey = <T extends PluginType>(
pluginModuleResourceMapKeys: string[],
query: Pick<PluginCompoundKey<T>, 'kind' | 'name'>,
precedenceLogic: PluginLookupPrecedenceLogic = PLUGIN_LOOKUP_PRECEDENCE_LOGIC
): string | undefined => {
type PluginBucket = { key: string; version: string };
const latestFoundVersionWithRegistry: PluginBucket = { key: '', version: '' };
const latestFoundVersionWithoutRegistry: PluginBucket = { key: '', version: '' };

for (const np of pluginModuleResourceMapKeys) {
if (!np.startsWith(`${query.kind}:${query.name}:`)) continue;
const split = np.split(':');

/**
* This is not a valid key. A valid key has 4 sections. The registry is optional. It might be empty or holds a value
*/
if (split.length !== 4) {
console.warn(`An invalid Plugin Resource key detected during default plugin lookup: ${np}`);
continue;
}

const [kind, name, registry, version] = split;
/**
* Such a case is representing a wrong key and it is not technically possible (Just to be precautious)
* A resource MUST have kind, name, and version according to its definition and interface
* So skip this record
*/
if (!kind || !name || !version) {
console.warn(`An invalid Plugin Resource key detected during default plugin lookup: ${np}`);
continue;
}

if (registry) {
if (!latestFoundVersionWithRegistry.key || gt(version, latestFoundVersionWithRegistry.version!)) {
latestFoundVersionWithRegistry.key = np;
latestFoundVersionWithRegistry.version = version;
}
continue;
}

if (!latestFoundVersionWithoutRegistry.key || gt(version, latestFoundVersionWithoutRegistry.version!)) {
latestFoundVersionWithoutRegistry.key = np;
latestFoundVersionWithoutRegistry.version = version;
}
}

/* Before it proceeds with the precedence logic, it checks whether so far anything has been found, if not return undefined */
if ([latestFoundVersionWithRegistry, latestFoundVersionWithoutRegistry].every((p) => !p.key)) {
return undefined;
}

if (latestFoundVersionWithRegistry.key && latestFoundVersionWithoutRegistry.key) {
const { registryOverVersion } = precedenceLogic;

// Registry version is strictly higher
if (gt(latestFoundVersionWithRegistry.version, latestFoundVersionWithoutRegistry.version)) {
return latestFoundVersionWithRegistry.key;
}

// None-registry version is strictly higher
if (gt(latestFoundVersionWithoutRegistry.version, latestFoundVersionWithRegistry.version)) {
return latestFoundVersionWithoutRegistry.key;
}

// Versions are equal - use the tie-breaker
return registryOverVersion ? latestFoundVersionWithRegistry.key : latestFoundVersionWithoutRegistry.key;
}

return latestFoundVersionWithRegistry.key || latestFoundVersionWithoutRegistry?.key;
};
36 changes: 24 additions & 12 deletions plugin-system/src/components/PluginRegistry/plugin-indexes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,8 @@ import { PluginLoader, PluginMetadataWithModule, PluginModuleResource, PluginTyp
import { useEvent } from '../../utils';

export interface PluginIndexes {
// Plugin resources by plugin type and kind (i.e. look up what module a plugin type and kind is in)
pluginResourcesByNameAndKind: Map<string, PluginModuleResource>;
// Plugin resources by plugin type, kind, registry, and version
pluginResourcesByNameKindRegistryVersion: Map<string, PluginModuleResource>;
// Plugin metadata by plugin type
pluginMetadataByKind: Map<PluginType, PluginMetadataWithModule[]>;
}
Expand All @@ -34,22 +34,26 @@ export function usePluginIndexes(
const installedPlugins = await getInstalledPlugins();

// Create the two indexes from the installed plugins
const pluginResourcesByNameAndKind = new Map<string, PluginModuleResource>();
const pluginResourcesByNameKindRegistryVersion = new Map<string, PluginModuleResource>();
const pluginMetadataByKind = new Map<PluginType, PluginMetadataWithModule[]>();

for (const resource of installedPlugins) {
const {
metadata: { version, registry },
} = resource;
for (const pluginMetadata of resource.spec.plugins) {
const {
kind,
spec: { name },
} = pluginMetadata;

// Index the plugin by type and kind to point at the module that contains it
const key = getTypeAndKindKey(kind, name);
if (pluginResourcesByNameAndKind.has(key)) {
console.warn(`Got more than one ${kind} plugin for kind ${name}`);
const key = getPluginModuleCompoundKey({ kind, name, registry, version });
if (pluginResourcesByNameKindRegistryVersion.has(key)) {
console.warn(
`Got more than one ${kind} plugin for kind ${name}, registry '${registry || 'undefined'}', and version '${version || 'undefined'}'`
);
}
pluginResourcesByNameAndKind.set(key, resource);
pluginResourcesByNameKindRegistryVersion.set(key, resource);

// Index the metadata by plugin type
let list = pluginMetadataByKind.get(kind);
Expand All @@ -62,7 +66,7 @@ export function usePluginIndexes(
}

return {
pluginResourcesByNameAndKind,
pluginResourcesByNameKindRegistryVersion,
pluginMetadataByKind,
};
});
Expand All @@ -84,9 +88,17 @@ export function usePluginIndexes(
return getPluginIndexes;
}

export type PluginCompoundKey<T extends PluginType> = {
kind: T;
name: string;
registry?: string;
version?: string;
};

/**
* Gets a unique key for a plugin type/kind that can be used as a cache key.
* Gets a unique key for a plugin type/kind/version/registry that can be used as a cache key.
*/
export function getTypeAndKindKey(kind: PluginType, name: string): string {
return `${kind}:${name}`;
export function getPluginModuleCompoundKey(compoundKey: PluginCompoundKey<PluginType>): string {
const { kind, name, registry, version } = compoundKey;
return `${kind}:${name}:${registry ?? ''}:${version ?? ''}`;
}
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,6 @@ export function PluginSpecEditor(props: PluginSpecEditorProps): ReactElement | n
...others
} = props;
const { data: plugin, isLoading, error } = usePlugin(pluginType, pluginKind);

if (error) {
return <ErrorAlert error={error} />;
}
Expand Down
Loading
Loading