import uFuzzy from '@leeoniya/ufuzzy'; import { PluginSignatureStatus, dateTimeParse, PluginError, PluginType, PluginErrorCode } from '@grafana/data'; import { config, featureEnabled } from '@grafana/runtime'; import { contextSrv } from 'app/core/core'; import { AccessControlAction } from 'app/types/accessControl'; import { CatalogPlugin, InstancePlugin, LocalPlugin, ProvisionedPlugin, RemotePlugin, RemotePluginStatus, Version, } from './types'; export function mergeLocalsAndRemotes({ local = [], remote = [], instance = [], provisioned = [], pluginErrors: errors, }: { local: LocalPlugin[]; remote?: RemotePlugin[]; instance?: InstancePlugin[]; provisioned?: ProvisionedPlugin[]; pluginErrors?: PluginError[]; }): CatalogPlugin[] { const catalogPlugins: CatalogPlugin[] = []; const errorByPluginId = groupErrorsByPluginId(errors); const instancesMap = instance.reduce((map, instancePlugin) => { map.set(instancePlugin.pluginSlug, instancePlugin); return map; }, new Map()); const provisionedSet = provisioned.reduce((map, provisionedPlugin) => { map.add(provisionedPlugin.slug); return map; }, new Set()); // add locals local.forEach((localPlugin) => { const remoteCounterpart = remote.find((r) => r.slug === localPlugin.id); const error = errorByPluginId[localPlugin.id]; if (!remoteCounterpart) { catalogPlugins.push(mergeLocalAndRemote(localPlugin, undefined, error)); } }); // add remote remote.forEach((remotePlugin) => { const localCounterpart = local.find((l) => l.id === remotePlugin.slug); const error = errorByPluginId[remotePlugin.slug]; const shouldSkip = remotePlugin.status === RemotePluginStatus.Deprecated && !localCounterpart; // We are only listing deprecated plugins in case they are installed. if (!shouldSkip) { const catalogPlugin = mergeLocalAndRemote(localCounterpart, remotePlugin, error); // for managed instances, check if plugin is installed, but not yet present in the current instance if (config.pluginAdminExternalManageEnabled) { catalogPlugin.isFullyInstalled = catalogPlugin.isCore ? true : (instancesMap.has(remotePlugin.slug) || provisionedSet.has(remotePlugin.slug)) && catalogPlugin.isInstalled; catalogPlugin.isInstalled = instancesMap.has(remotePlugin.slug) || catalogPlugin.isInstalled; const instancePlugin = instancesMap.get(remotePlugin.slug); catalogPlugin.isUpdatingFromInstance = instancesMap.has(remotePlugin.slug) && catalogPlugin.hasUpdate && catalogPlugin.installedVersion !== instancePlugin?.version; if (instancePlugin?.version && instancePlugin?.version !== remotePlugin.version) { catalogPlugin.hasUpdate = true; } catalogPlugin.isUninstallingFromInstance = Boolean(localCounterpart) && !instancesMap.has(remotePlugin.slug); catalogPlugin.isProvisioned = provisionedSet.has(remotePlugin.slug); } catalogPlugins.push(catalogPlugin); } }); return catalogPlugins; } export function mergeLocalAndRemote(local?: LocalPlugin, remote?: RemotePlugin, error?: PluginError): CatalogPlugin { if (!local && remote) { return mapRemoteToCatalog(remote, error); } if (local && !remote) { return mapLocalToCatalog(local, error); } return mapToCatalogPlugin(local, remote, error); } export function mapRemoteToCatalog(plugin: RemotePlugin, error?: PluginError): CatalogPlugin { const { name, slug: id, description, version, orgName, popularity, downloads, typeCode, updatedAt, createdAt: publishedAt, status, angularDetected, keywords, signatureType, versionSignatureType, versionSignedByOrgName, url, } = plugin; const isDisabled = !!error; return { description, downloads, id, info: { logos: { small: `${config.appSubUrl}/api/gnet/plugins/${id}/versions/${version}/logos/small`, large: `${config.appSubUrl}/api/gnet/plugins/${id}/versions/${version}/logos/large`, }, keywords, }, name, orgName, popularity, publishedAt, signature: getPluginSignature({ remote: plugin, error }), signatureType: signatureType || versionSignatureType || undefined, signatureOrg: versionSignedByOrgName, updatedAt, hasUpdate: false, isPublished: true, isInstalled: isDisabled, isDisabled: isDisabled, isManaged: isManagedPlugin(id), isPreinstalled: isPreinstalledPlugin(id), isDeprecated: status === RemotePluginStatus.Deprecated, isCore: plugin.internal, isDev: false, isEnterprise: status === RemotePluginStatus.Enterprise, type: typeCode, error: error?.errorCode, angularDetected, isFullyInstalled: isDisabled, latestVersion: plugin.version, url, }; } export function mapLocalToCatalog(plugin: LocalPlugin, error?: PluginError): CatalogPlugin { const { name, info: { description, version, logos, updated, author, keywords }, id, dev, type, signature, signatureOrg, signatureType, hasUpdate, accessControl, angularDetected, } = plugin; const isDisabled = !!error; return { description, downloads: 0, id, info: { logos, keywords }, name, orgName: author.name, popularity: 0, publishedAt: '', signature: getPluginSignature({ local: plugin, error }), signatureOrg, signatureType, updatedAt: updated, installedVersion: version, hasUpdate, isInstalled: true, isDisabled: isDisabled, isCore: signature === 'internal', isPublished: false, isDeprecated: false, isDev: Boolean(dev), isEnterprise: false, isManaged: isManagedPlugin(id), isPreinstalled: isPreinstalledPlugin(id), type, error: error?.errorCode, accessControl: accessControl, angularDetected, isFullyInstalled: true, iam: plugin.iam, latestVersion: plugin.latestVersion, }; } // TODO: change the signature by removing the optionals for local and remote. export function mapToCatalogPlugin(local?: LocalPlugin, remote?: RemotePlugin, error?: PluginError): CatalogPlugin { const installedVersion = local?.info.version; const id = remote?.slug || local?.id || ''; const type = local?.type || remote?.typeCode; const isDisabled = !!error; const keywords = remote?.keywords || local?.info.keywords || []; let logos = { small: `/public/build/img/icn-${type}.svg`, large: `/public/build/img/icn-${type}.svg`, }; if (remote) { logos = { small: `${config.appSubUrl}/api/gnet/plugins/${id}/versions/${remote.version}/logos/small`, large: `${config.appSubUrl}/api/gnet/plugins/${id}/versions/${remote.version}/logos/large`, }; } else if (local && local.info.logos) { logos = local.info.logos; } return { description: local?.info.description || remote?.description || '', downloads: remote?.downloads || 0, hasUpdate: local?.hasUpdate || false, id, info: { logos, keywords, }, isCore: Boolean(remote?.internal || local?.signature === PluginSignatureStatus.internal), isDev: Boolean(local?.dev), isEnterprise: remote?.status === RemotePluginStatus.Enterprise, isInstalled: Boolean(local) || isDisabled, isDisabled: isDisabled, isDeprecated: remote?.status === RemotePluginStatus.Deprecated, isPublished: true, isManaged: isManagedPlugin(id), isPreinstalled: isPreinstalledPlugin(id), // TODO name: remote?.name || local?.name || '', // TODO orgName: remote?.orgName || local?.info.author.name || '', popularity: remote?.popularity || 0, publishedAt: remote?.createdAt || '', type, signature: getPluginSignature({ local, remote, error }), signatureOrg: local?.signatureOrg || remote?.versionSignedByOrgName, signatureType: local?.signatureType || remote?.versionSignatureType || remote?.signatureType || undefined, // TODO updatedAt: remote?.updatedAt || local?.info.updated || '', installedVersion, error: error?.errorCode, // Only local plugins have access control metadata accessControl: local?.accessControl, angularDetected: local?.angularDetected ?? remote?.angularDetected, isFullyInstalled: Boolean(local) || isDisabled, iam: local?.iam, latestVersion: local?.latestVersion || remote?.version || '', url: remote?.url || '', }; } export const getExternalManageLink = (pluginId: string) => `${config.pluginCatalogURL}${pluginId}`; export enum Sorters { nameAsc = 'nameAsc', nameDesc = 'nameDesc', updated = 'updated', published = 'published', downloads = 'downloads', } export const sortPlugins = (plugins: CatalogPlugin[], sortBy: Sorters) => { const sorters: { [name: string]: (a: CatalogPlugin, b: CatalogPlugin) => number } = { nameAsc: (a: CatalogPlugin, b: CatalogPlugin) => a.name.localeCompare(b.name), nameDesc: (a: CatalogPlugin, b: CatalogPlugin) => b.name.localeCompare(a.name), updated: (a: CatalogPlugin, b: CatalogPlugin) => dateTimeParse(b.updatedAt).valueOf() - dateTimeParse(a.updatedAt).valueOf(), published: (a: CatalogPlugin, b: CatalogPlugin) => dateTimeParse(b.publishedAt).valueOf() - dateTimeParse(a.publishedAt).valueOf(), downloads: (a: CatalogPlugin, b: CatalogPlugin) => b.downloads - a.downloads, }; if (sorters[sortBy]) { return plugins.sort(sorters[sortBy]); } return plugins; }; function groupErrorsByPluginId(errors: PluginError[] = []): Record { return errors.reduce>((byId, error) => { byId[error.pluginId] = error; return byId; }, {}); } function getPluginSignature(options: { local?: LocalPlugin; remote?: RemotePlugin; error?: PluginError; }): PluginSignatureStatus { const { error, local, remote } = options; if (error) { switch (error.errorCode) { case PluginErrorCode.invalidSignature: return PluginSignatureStatus.invalid; case PluginErrorCode.missingSignature: return PluginSignatureStatus.missing; case PluginErrorCode.modifiedSignature: return PluginSignatureStatus.modified; } } if (local?.signature) { return local.signature; } if (remote?.signatureType && remote?.versionSignatureType) { return PluginSignatureStatus.valid; } return PluginSignatureStatus.missing; } export function getLatestCompatibleVersion(versions: Version[] | undefined): Version | undefined { if (!versions) { return; } const [latest] = versions.filter((v) => Boolean(v.isCompatible)); return latest; } export const isInstallControlsEnabled = () => config.pluginAdminEnabled; export const hasInstallControlWarning = ( plugin: CatalogPlugin, isRemotePluginsAvailable: boolean, latestCompatibleVersion?: Version ) => { const isExternallyManaged = config.pluginAdminExternalManageEnabled; const hasPermission = contextSrv.hasPermission(AccessControlAction.PluginsInstall); const isCompatible = Boolean(latestCompatibleVersion); return ( plugin.type === PluginType.renderer || (plugin.isEnterprise && !featureEnabled('enterprise.plugins')) || plugin.isDev || (!hasPermission && !isExternallyManaged) || !plugin.isPublished || !isCompatible || !isRemotePluginsAvailable ); }; export const isLocalPluginVisibleByConfig = (p: LocalPlugin) => isNotHiddenByConfig(p.id); export const isRemotePluginVisibleByConfig = (p: RemotePlugin) => isNotHiddenByConfig(p.slug); function isNotHiddenByConfig(id: string) { const { pluginCatalogHiddenPlugins }: { pluginCatalogHiddenPlugins: string[] } = config; return !pluginCatalogHiddenPlugins.includes(id); } export function isManagedPlugin(id: string) { const { pluginCatalogManagedPlugins }: { pluginCatalogManagedPlugins: string[] } = config; return pluginCatalogManagedPlugins?.includes(id); } export function isPreinstalledPlugin(id: string): { found: boolean; withVersion: boolean } { const { pluginCatalogPreinstalledPlugins } = config; const plugin = pluginCatalogPreinstalledPlugins?.find((p) => p.id === id); return { found: !!plugin?.id, withVersion: !!plugin?.version }; } export function isLocalCorePlugin(local?: LocalPlugin): boolean { return Boolean(local?.signature === 'internal'); } function getId(inputString: string): string { const parts = inputString.split(' - '); return parts[0]; } function getPluginDetailsForFuzzySearch(plugins: CatalogPlugin[]): string[] { return plugins.reduce((result: string[], { id, name, type, orgName, info }: CatalogPlugin) => { const keywordsForSearch = info.keywords?.join(' ').toLowerCase(); const pluginString = `${id} - ${name} - ${type} - ${orgName} - ${keywordsForSearch}`; result.push(pluginString); return result; }, []); } export function filterByKeyword(plugins: CatalogPlugin[], query: string) { const dataArray = getPluginDetailsForFuzzySearch(plugins); let uf = new uFuzzy({ intraMode: 1, intraSub: 0 }); let idxs = uf.filter(dataArray, query); if (idxs === null) { return null; } return idxs.map((id) => getId(dataArray[id])); } function isPluginModifiable(plugin: CatalogPlugin) { if ( plugin.isProvisioned || //provisioned plugins cannot be modified plugin.isCore || //core plugins cannot be modified plugin.type === PluginType.renderer || // currently renderer plugins are not supported by the catalog due to complications related to installation / update / uninstall plugin.isPreinstalled.withVersion || // Preinstalled plugins (with specified version) cannot be modified plugin.isManaged // Managed plugins cannot be modified ) { return false; } return true; } export function isPluginUpdatable(plugin: CatalogPlugin) { if (!isPluginModifiable(plugin)) { return false; } // If there is no update available, the plugin cannot be updated if (!plugin.hasUpdate) { return false; } // If the plugin is currently being updated, it should not be updated if (plugin.isUpdatingFromInstance) { return false; } return true; } export function shouldDisablePluginInstall(plugin: CatalogPlugin) { if ( !isPluginModifiable(plugin) || (plugin.isEnterprise && !featureEnabled('enterprise.plugins')) || !plugin.isPublished || plugin.isDisabled || !isInstallControlsEnabled() ) { return true; } return false; } export function isNonAngularVersion(version?: Version) { if (!version) { return false; } return version.angularDetected === false; } export function isDisabledAngularPlugin(plugin: CatalogPlugin) { return plugin.isDisabled && plugin.error === PluginErrorCode.angular; }