From 330a987faf347e8fa4cdc1ade13e30816a69da3c Mon Sep 17 00:00:00 2001 From: Ludy87 Date: Sun, 15 Feb 2026 18:11:44 +0100 Subject: [PATCH] Add plugin icons and jar creation metadata Add icon and jarCreatedAt fields to plugin descriptors and responses; capture JAR creation time in PluginLoader (with content-type probe and improved logging) and include it in built PluginDescriptor. Update PluginMetadata to accept an icon. Improve URLClassLoader and toArray usages. Expose basePath and pluginsPath in ConfigController. Frontend: remove PluginExplorerModal, add a Plugins section in settings (PluginSection) and wire it into nav; enhance PluginRegistry to build icon URLs, include minHostVersion and jarCreatedAt, and normalize plugin data using app baseUrl; update ToolPicker to render plugin icons and UI tweaks. Misc: small UI/refactor changes and debug logging adjustments. --- .../common/plugins/PluginDescriptor.java | 3 +- .../plugins/PluginDescriptorResponse.java | 4 + .../software/common/plugins/PluginLoader.java | 59 +++++-- .../common/plugins/PluginMetadata.java | 1 + .../common/plugins/PluginResourceConfig.java | 2 +- .../controller/api/misc/ConfigController.java | 5 + .../components/shared/PluginExplorerModal.tsx | 114 -------------- .../core/components/shared/QuickAccessBar.tsx | 14 -- .../shared/config/configNavSections.tsx | 12 ++ .../config/configSections/PluginSection.tsx | 146 ++++++++++++++++++ .../core/components/shared/config/types.ts | 1 + .../src/core/components/tools/ToolPicker.tsx | 101 +++++++----- .../core/contexts/PluginRegistryContext.tsx | 39 ++++- frontend/src/core/pages/PluginPage.tsx | 6 +- 14 files changed, 318 insertions(+), 189 deletions(-) delete mode 100644 frontend/src/core/components/shared/PluginExplorerModal.tsx create mode 100644 frontend/src/core/components/shared/config/configSections/PluginSection.tsx diff --git a/app/common/src/main/java/stirling/software/common/plugins/PluginDescriptor.java b/app/common/src/main/java/stirling/software/common/plugins/PluginDescriptor.java index 5d4dd41634..b8b840bbf3 100644 --- a/app/common/src/main/java/stirling/software/common/plugins/PluginDescriptor.java +++ b/app/common/src/main/java/stirling/software/common/plugins/PluginDescriptor.java @@ -16,6 +16,7 @@ import lombok.Value; public class PluginDescriptor { String id; + String icon; String name; String description; String version; @@ -24,10 +25,10 @@ public class PluginDescriptor { String frontendPath; String iconPath; String minHostVersion; + String jarCreatedAt; @Builder.Default boolean hasFrontend = false; @Builder.Default List backendEndpoints = Collections.emptyList(); - @Builder.Default Map metadata = Collections.emptyMap(); } diff --git a/app/common/src/main/java/stirling/software/common/plugins/PluginDescriptorResponse.java b/app/common/src/main/java/stirling/software/common/plugins/PluginDescriptorResponse.java index f6f969db01..6d1e886624 100644 --- a/app/common/src/main/java/stirling/software/common/plugins/PluginDescriptorResponse.java +++ b/app/common/src/main/java/stirling/software/common/plugins/PluginDescriptorResponse.java @@ -10,6 +10,7 @@ import lombok.Value; @Builder public class PluginDescriptorResponse { String id; + String icon; String name; String description; String version; @@ -18,6 +19,7 @@ public class PluginDescriptorResponse { String frontendLabel; String iconPath; String minHostVersion; + String jarCreatedAt; boolean hasFrontend; List backendEndpoints; Map metadata; @@ -33,6 +35,7 @@ public class PluginDescriptorResponse { return PluginDescriptorResponse.builder() .id(descriptor.getId()) + .icon(descriptor.getIcon()) .name(descriptor.getName()) .description(descriptor.getDescription()) .version(descriptor.getVersion()) @@ -44,6 +47,7 @@ public class PluginDescriptorResponse { .backendEndpoints(descriptor.getBackendEndpoints()) .metadata(descriptor.getMetadata()) .minHostVersion(descriptor.getMinHostVersion()) + .jarCreatedAt(descriptor.getJarCreatedAt()) .build(); } } diff --git a/app/common/src/main/java/stirling/software/common/plugins/PluginLoader.java b/app/common/src/main/java/stirling/software/common/plugins/PluginLoader.java index d9a581de95..223ccb2641 100644 --- a/app/common/src/main/java/stirling/software/common/plugins/PluginLoader.java +++ b/app/common/src/main/java/stirling/software/common/plugins/PluginLoader.java @@ -7,6 +7,7 @@ import java.net.URL; import java.net.URLClassLoader; import java.nio.file.Files; import java.nio.file.Path; +import java.nio.file.attribute.BasicFileAttributes; import java.util.ArrayList; import java.util.Collections; import java.util.Comparator; @@ -42,6 +43,20 @@ public final class PluginLoader { try (Stream stream = Files.list(pluginDir)) { return stream.filter(Files::isRegularFile) .filter(path -> path.toString().toLowerCase().endsWith(".jar")) + .filter( + path -> { + try { + String mimeType = Files.probeContentType(path); + return mimeType != null + && mimeType.equals("application/java-archive"); + } catch (IOException e) { + log.warn( + "Failed to probe content type of {}: {}", + path, + e.getMessage()); + return false; + } + }) .sorted(Comparator.comparing(Path::getFileName)) .collect(Collectors.toList()); } catch (IOException e) { @@ -75,7 +90,7 @@ public final class PluginLoader { "Scanning {} plugin jars in {}", urls.size(), InstallationPathConfig.getPluginsPath()); - return new URLClassLoader(urls.toArray(new URL[0]), parent); + return new URLClassLoader(urls.toArray(URL[]::new), parent); } public static List loadDescriptors() { @@ -111,28 +126,40 @@ public final class PluginLoader { try (JarFile jarFile = new JarFile(jarPath.toFile())) { JarEntry entry = jarFile.getJarEntry(METADATA_RESOURCE); if (entry == null) { - log.debug( - "Plugin jar {} does not include {}, skipping", jarPath, METADATA_RESOURCE); + log.info("Plugin jar {} does not include {}, skipping", jarPath, METADATA_RESOURCE); return null; } + PluginMetadata metadata; try (InputStream inputStream = jarFile.getInputStream(entry)) { - PluginMetadata metadata = - OBJECT_MAPPER.readValue(inputStream, PluginMetadata.class); - if (metadata.getId() == null || metadata.getId().isBlank()) { - log.warn("Plugin metadata in {} is missing required id, ignoring", jarPath); - return null; - } - return buildDescriptor(metadata); + metadata = OBJECT_MAPPER.readValue(inputStream, PluginMetadata.class); } + + BasicFileAttributes attrs = Files.readAttributes(jarPath, BasicFileAttributes.class); + String createdAt = attrs.creationTime().toInstant().toString(); + + if (metadata.getId() == null || metadata.getId().isBlank()) { + log.warn("Plugin metadata in {} is missing required id, ignoring", jarPath); + return null; + } + + String pluginId = metadata.getId(); + log.info( + "Loaded metadata for plugin '{}': name='{}' version='{}'", + pluginId, + metadata.getName(), + metadata.getVersion()); + + return buildDescriptor(metadata, createdAt); } catch (IOException e) { log.warn("Failed to inspect plugin jar {}: {}", jarPath, e.getMessage()); return null; } } - private static PluginDescriptor buildDescriptor(PluginMetadata metadata) { + private static PluginDescriptor buildDescriptor(PluginMetadata metadata, String jarCreatedAt) { PluginMetadata.PluginFrontend frontend = metadata.getFrontend(); String id = metadata.getId(); + String icon = metadata.getIcon(); String frontendPath = (frontend != null @@ -143,6 +170,7 @@ public final class PluginLoader { return PluginDescriptor.builder() .id(id) + .icon(defaultIfEmpty(icon, null)) .name(defaultIfEmpty(metadata.getName(), id)) .description(defaultIfEmpty(metadata.getDescription(), "")) .version(defaultIfEmpty(metadata.getVersion(), "0.0.0")) @@ -153,13 +181,14 @@ public final class PluginLoader { .hasFrontend(frontend != null) .backendEndpoints( metadata.getBackendEndpoints() == null - ? Collections.emptyList() - : metadata.getBackendEndpoints()) + ? Collections.emptyList() + : metadata.getBackendEndpoints()) .metadata( metadata.getMetadata() == null - ? Collections.emptyMap() - : metadata.getMetadata()) + ? Collections.emptyMap() + : metadata.getMetadata()) .minHostVersion(defaultIfEmpty(metadata.getMinHostVersion(), null)) + .jarCreatedAt(jarCreatedAt) .build(); } diff --git a/app/common/src/main/java/stirling/software/common/plugins/PluginMetadata.java b/app/common/src/main/java/stirling/software/common/plugins/PluginMetadata.java index bf6ff8f174..b551c64b18 100644 --- a/app/common/src/main/java/stirling/software/common/plugins/PluginMetadata.java +++ b/app/common/src/main/java/stirling/software/common/plugins/PluginMetadata.java @@ -13,6 +13,7 @@ import lombok.Setter; @JsonIgnoreProperties(ignoreUnknown = true) public class PluginMetadata { private String id; + private String icon; private String name; private String description; private String version; diff --git a/app/common/src/main/java/stirling/software/common/plugins/PluginResourceConfig.java b/app/common/src/main/java/stirling/software/common/plugins/PluginResourceConfig.java index 34268abad7..7dd1dd1c5b 100644 --- a/app/common/src/main/java/stirling/software/common/plugins/PluginResourceConfig.java +++ b/app/common/src/main/java/stirling/software/common/plugins/PluginResourceConfig.java @@ -19,7 +19,7 @@ public class PluginResourceConfig implements WebMvcConfigurer { if (!locations.isEmpty()) { registry.addResourceHandler("/plugins/**") - .addResourceLocations(locations.toArray(new String[0])) + .addResourceLocations(locations.toArray(String[]::new)) .setCachePeriod(0); } } diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/ConfigController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/ConfigController.java index 2be319ad6b..5493c67b82 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/ConfigController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/ConfigController.java @@ -24,6 +24,7 @@ import stirling.software.SPDF.config.InitialSetup; import stirling.software.SPDF.service.plugin.PluginService; import stirling.software.common.annotations.api.ConfigApi; import stirling.software.common.configuration.AppConfig; +import stirling.software.common.configuration.InstallationPathConfig; import stirling.software.common.model.ApplicationProperties; import stirling.software.common.plugins.PluginDescriptorResponse; import stirling.software.common.service.ServerCertificateServiceInterface; @@ -297,6 +298,10 @@ public class ConfigController { // Version/machine info not available } + // config directory path + configData.put("basePath", InstallationPathConfig.getPath()); + configData.put("pluginsPath", InstallationPathConfig.getPluginsPath()); + return ResponseEntity.ok(configData); } catch (Exception e) { diff --git a/frontend/src/core/components/shared/PluginExplorerModal.tsx b/frontend/src/core/components/shared/PluginExplorerModal.tsx deleted file mode 100644 index 8829159f94..0000000000 --- a/frontend/src/core/components/shared/PluginExplorerModal.tsx +++ /dev/null @@ -1,114 +0,0 @@ -import { Badge, Button, Card, Group, Loader, Modal, Stack, Text } from "@mantine/core"; -import { useTranslation } from "react-i18next"; -import { useNavigate } from "react-router-dom"; -import { usePluginRegistry } from "@app/contexts/PluginRegistryContext"; - -interface PluginExplorerModalProps { - opened: boolean; - onClose: () => void; -} - -export default function PluginExplorerModal({ opened, onClose }: PluginExplorerModalProps) { - const { t } = useTranslation(); - const navigate = useNavigate(); - const { plugins, loading, error, refresh } = usePluginRegistry(); - - return ( - - - - {t("plugins.title", "Installed plugins")} - - - - {loading && ( - - {t("plugins.loading", "Checking for available plugins...")} - - )} - - {error && ( - - {t("plugins.error", { - defaultValue: "Failed to load plugins: {{message}}", - message: error, - })} - - )} - - {!loading && plugins.length === 0 && ( - - {t("plugins.empty", "No plugins installed yet. Drop a plugin jar into the plugins directory.")} - - )} - - {plugins.map((plugin) => ( - - - - - {plugin.name} - - {plugin.description || t("plugins.noDescription", "No description provided.")} - - - - - {plugin.version && ( - - {t("plugins.version", { - defaultValue: "v{{version}}", - version: plugin.version, - })} - - )} - - - - - {plugin.backendEndpoints.length > 0 && ( - - {plugin.backendEndpoints.map((endpoint) => ( - - {endpoint} - - ))} - - )} - - - ))} - - - ); -} diff --git a/frontend/src/core/components/shared/QuickAccessBar.tsx b/frontend/src/core/components/shared/QuickAccessBar.tsx index 4a882a135a..0d4ce6e117 100644 --- a/frontend/src/core/components/shared/QuickAccessBar.tsx +++ b/frontend/src/core/components/shared/QuickAccessBar.tsx @@ -27,7 +27,6 @@ import { getActiveNavButton, } from '@app/components/shared/quickAccessBar/QuickAccessBar'; import { Z_INDEX_OVER_FULLSCREEN_SURFACE } from '@app/styles/zIndex'; -import PluginExplorerModal from '@app/components/shared/PluginExplorerModal'; const QuickAccessBar = forwardRef((_, ref) => { const { t } = useTranslation(); @@ -200,15 +199,6 @@ const QuickAccessBar = forwardRef((_, ref) => { // This will be overridden by the wrapper logic }, }, - { - id: 'plugins', - name: t("quickAccess.plugins", "Plugins"), - icon: , - isRound: true, - size: 'md', - type: 'action', - onClick: () => setPluginExplorerOpen(true), - }, ...(shouldHideSettingsButton ? [] : [{ id: 'config', name: t("quickAccess.settings", "Settings"), @@ -395,10 +385,6 @@ const QuickAccessBar = forwardRef((_, ref) => { opened={configModalOpen} onClose={() => setConfigModalOpen(false)} /> - setPluginExplorerOpen(false)} - /> ); }); diff --git a/frontend/src/core/components/shared/config/configNavSections.tsx b/frontend/src/core/components/shared/config/configNavSections.tsx index 7a0bef8c06..84fee2fb2b 100644 --- a/frontend/src/core/components/shared/config/configNavSections.tsx +++ b/frontend/src/core/components/shared/config/configNavSections.tsx @@ -3,6 +3,7 @@ import { useTranslation } from 'react-i18next'; import { NavKey } from '@app/components/shared/config/types'; import HotkeysSection from '@app/components/shared/config/configSections/HotkeysSection'; import GeneralSection from '@app/components/shared/config/configSections/GeneralSection'; +import PluginSection from '@app/components/shared/config/configSections/PluginSection'; export interface ConfigNavItem { key: NavKey; @@ -53,6 +54,17 @@ export const useConfigNavSections = ( }, ], }, + { + title: t('settings.plugins.sectionTitle', 'Extensions'), + items: [ + { + key: 'plugins', + label: t('settings.plugins.label', 'Plugins'), + icon: 'extension-rounded', + component: + }, + ], + }, ]; return sections; diff --git a/frontend/src/core/components/shared/config/configSections/PluginSection.tsx b/frontend/src/core/components/shared/config/configSections/PluginSection.tsx new file mode 100644 index 0000000000..407376df3a --- /dev/null +++ b/frontend/src/core/components/shared/config/configSections/PluginSection.tsx @@ -0,0 +1,146 @@ +import React, { useEffect, useMemo, useState } from "react"; +import { Paper, Text, Group, Stack, Badge, Divider, Avatar, Tooltip } from "@mantine/core"; +import { useTranslation } from "react-i18next"; +import { usePluginRegistry } from "@app/contexts/PluginRegistryContext"; +import { useAppConfig } from "@app/contexts/AppConfigContext"; +import { LocalIcon } from "@app/components/shared/LocalIcon"; +import apiClient from "@app/services/apiClient"; + +const PluginSection: React.FC = () => { + const { t } = useTranslation(); + const { plugins, loading, error } = usePluginRegistry(); + const { config } = useAppConfig(); + const pluginPath = + config?.pluginsPath ?? (config?.basePath ? `${config.basePath}/customFiles/plugins/` : "customFiles/plugins/"); + const [iconStatus, setIconStatus] = useState>({}); + + useEffect(() => { + let active = true; + const toCheck = plugins.filter((plugin) => plugin.iconUrl && iconStatus[plugin.id] === undefined); + + toCheck.forEach((plugin) => { + const iconUrl = plugin.iconUrl!; + apiClient + .get(iconUrl, { responseType: "blob", suppressErrorToast: true }) + .then(() => { + if (!active) return; + setIconStatus((prev) => ({ ...prev, [plugin.id]: true })); + }) + .catch(() => { + if (!active) return; + setIconStatus((prev) => ({ ...prev, [plugin.id]: false })); + }); + }); + + return () => { + active = false; + }; + }, [plugins, iconStatus]); + + const renderIcon = (plugin: ReturnType extends { plugins: (infer T)[] } ? T : never) => { + const isValid = iconStatus[plugin.id]; + if (plugin.iconUrl && isValid) { + return ; + } + return ; + }; + + return ( + + + + + {t("settings.plugins.title", "Plugins")} + + + {t("settings.plugins.count", "Installierte Plugins {{count}}", { count: plugins.length })} + + + + {t("settings.plugins.description", "Erweiterungen durchsuchen, installieren und konfigurieren.")} + + + + {loading && ( + + {t("settings.plugins.loading", "Plugins werden geladen...")} + + )} + + {error && plugins.length > 0 && ( + + {t("settings.plugins.error", "Fehler beim Laden der Plugins")} + + )} + + {!loading && plugins.length === 0 && ( + + {t("settings.plugins.empty", "Keine Plugins gefunden. Droppe eine Plugin-JAR in {{path}}.", { path: pluginPath })} + + )} + + {plugins.map((plugin) => ( + + + + {renderIcon(plugin)} + + + {plugin.frontendLabel && ( + + {plugin.frontendLabel} + + )} + {plugin.minHostVersion && ( + + {t("settings.plugins.minHost", { defaultValue: "min. v{{version}}", version: plugin.minHostVersion })} + + )} + {plugin.version && ( + + {t("plugins.version", { defaultValue: "v{{version}}", version: plugin.version })} + + )} + + + {plugin.name} + + + + + {plugin.description || t("settings.plugins.noDescription", "Keine Beschreibung")} + + {plugin.backendEndpoints.length > 0 && ( + + {plugin.backendEndpoints.map((endpoint) => ( + + + {endpoint.replace(/^\//, "").toUpperCase()} + + + ))} + + )} + + + + {t("settings.plugins.author", "Autor: {{author}}", { + author: plugin.author || t("settings.plugins.unknownAuthor", "unbekannt"), + })} + + + {plugin.jarCreatedAt && ( + + {t("settings.plugins.createdAt", "Erstellt am {{date}}", { + date: new Date(plugin.jarCreatedAt).toLocaleString(), + })} + + )} + + + ))} + + ); +}; + +export default PluginSection; diff --git a/frontend/src/core/components/shared/config/types.ts b/frontend/src/core/components/shared/config/types.ts index 9a73890516..a34a6a3a19 100644 --- a/frontend/src/core/components/shared/config/types.ts +++ b/frontend/src/core/components/shared/config/types.ts @@ -29,6 +29,7 @@ export const VALID_NAV_KEYS = [ 'adminAudit', 'adminUsage', 'adminEndpoints', + 'plugins', ] as const; // Derive the type from the array diff --git a/frontend/src/core/components/tools/ToolPicker.tsx b/frontend/src/core/components/tools/ToolPicker.tsx index e9304a74bd..b68af4ac3c 100644 --- a/frontend/src/core/components/tools/ToolPicker.tsx +++ b/frontend/src/core/components/tools/ToolPicker.tsx @@ -1,5 +1,5 @@ -import React, { useMemo, useRef } from "react"; -import { Box, Button, Stack, Text } from "@mantine/core"; +import React, { useEffect, useMemo, useRef } from "react"; +import { Badge, Box, Button, Stack, Text } from "@mantine/core"; import { useTranslation } from "react-i18next"; import { ToolRegistryEntry } from "@app/data/toolsTaxonomy"; import "@app/components/tools/toolPicker/ToolPicker.css"; @@ -13,7 +13,10 @@ import { useToolWorkflow } from "@app/contexts/ToolWorkflowContext"; import { ToolId } from "@app/types/toolId"; import { getSubcategoryLabel } from "@app/data/toolsTaxonomy"; import { usePluginRegistry } from "@app/contexts/PluginRegistryContext"; +import { Tooltip } from "@app/components/shared/Tooltip"; import { useNavigate } from "react-router-dom"; +import FitText from "@app/components/shared/FitText"; +import { LocalIcon } from '@app/components/shared/LocalIcon'; interface ToolPickerProps { selectedToolKey: string | null; @@ -62,6 +65,12 @@ const ToolPicker = ({ selectedToolKey, onSelect, filteredTools, isSearching = fa [plugins], ); + useEffect(() => { + pluginItems.forEach((plugin) => { + console.debug(`[ToolPicker] Rendering icon for plugin ${plugin.id}:`, plugin.icon); + }); + }, [pluginItems]); + // Build flat list by subcategory for search mode const emptyFilteredTools: ToolPickerProps['filteredTools'] = []; const effectiveFilteredForSearch: ToolPickerProps['filteredTools'] = isSearching ? filteredTools : emptyFilteredTools; @@ -153,41 +162,59 @@ const ToolPicker = ({ selectedToolKey, onSelect, filteredTools, isSearching = fa
{t("plugins.shortTitle", "Plugins")}
{pluginItems.map((plugin) => ( - +
+ + + +
))}
diff --git a/frontend/src/core/contexts/PluginRegistryContext.tsx b/frontend/src/core/contexts/PluginRegistryContext.tsx index 0b3c904613..6701197fa3 100644 --- a/frontend/src/core/contexts/PluginRegistryContext.tsx +++ b/frontend/src/core/contexts/PluginRegistryContext.tsx @@ -1,10 +1,11 @@ -import React, { createContext, useCallback, useContext, useEffect, useMemo, useState } from "react"; +import React, { createContext, ReactNode, useCallback, useContext, useEffect, useMemo, useState } from "react"; import apiClient from "@app/services/apiClient"; import { useAppConfig } from "@app/contexts/AppConfigContext"; interface PluginResponse { id: string; + icon: string; name: string; description?: string; version?: string; @@ -14,9 +15,13 @@ interface PluginResponse { iconPath?: string; hasFrontend?: boolean; backendEndpoints?: string[]; + minHostVersion: string; + iconUrl?: string; + jarCreatedAt?: string; } export interface PluginInfo { + icon: ReactNode; id: string; name: string; description: string; @@ -27,6 +32,9 @@ export interface PluginInfo { frontendLabel?: string; iconPath?: string; backendEndpoints: string[]; + minHostVersion: string; + iconUrl?: string; + jarCreatedAt?: string; } interface PluginRegistryState { @@ -45,8 +53,23 @@ const buildFrontendUrl = (response: PluginResponse): string | undefined => { return response.frontendUrl; }; - const buildPluginInfo = (response: PluginResponse): PluginInfo => ({ +const buildIconUrl = (path?: string, baseUrl?: string): string | undefined => { + if (!path) return undefined; + if (path.startsWith("http")) return path; + const normalizedBase = + baseUrl?.replace(/\/+$/, "") || (typeof window !== "undefined" ? window.location.origin.replace(/\/+$/, "") : ""); + if (!normalizedBase) return path; + return `${normalizedBase}${path.startsWith("/") ? path : `/${path}`}`; +}; + +const buildPluginInfo = (response: PluginResponse, baseUrl?: string): PluginInfo => { + const frontendBase = response.frontendUrl + ? new URL(response.frontendUrl).origin + : baseUrl; + + return { id: response.id, + icon: response.icon, name: response.name, description: response.description || "", version: response.version, @@ -56,10 +79,14 @@ const buildFrontendUrl = (response: PluginResponse): string | undefined => { frontendLabel: response.frontendLabel, iconPath: response.iconPath, backendEndpoints: response.backendEndpoints ?? [], - }); + minHostVersion: response.minHostVersion, + iconUrl: buildIconUrl(response.iconPath, frontendBase), + jarCreatedAt: response.jarCreatedAt, + }; +}; export const PluginRegistryProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { - const { loading: configLoading } = useAppConfig(); + const { loading: configLoading, config } = useAppConfig(); const [plugins, setPlugins] = useState([]); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); @@ -69,7 +96,9 @@ export const PluginRegistryProvider: React.FC<{ children: React.ReactNode }> = ( setError(null); try { const response = await apiClient.get("/api/v1/config/plugins"); - const normalized = response.data.map(buildPluginInfo).sort((a, b) => a.name.localeCompare(b.name)); + const normalized = response.data + .map((plugin) => buildPluginInfo(plugin, config?.baseUrl)) + .sort((a, b) => a.name.localeCompare(b.name)); setPlugins(normalized); } catch (err: any) { if (err?.response?.status === 401) { diff --git a/frontend/src/core/pages/PluginPage.tsx b/frontend/src/core/pages/PluginPage.tsx index a9a418105c..8d628ba2e8 100644 --- a/frontend/src/core/pages/PluginPage.tsx +++ b/frontend/src/core/pages/PluginPage.tsx @@ -139,12 +139,14 @@ const cloneNodeWithResolvedUrls = (node: ChildNode, base: string | null): Node | export default function PluginPage() { const { id } = useParams<{ id: string }>(); - const { plugins, loading } = usePluginRegistry(); + const pluginRegistry = usePluginRegistry(); + const plugins = pluginRegistry?.plugins ?? []; + const loading = pluginRegistry?.loading ?? false; const navigate = useNavigate(); const location = useLocation(); const navigationPlugin = location.state?.plugin as PluginInfo | undefined; - const plugin = useMemo(() => navigationPlugin ?? plugins.find((p) => p.id === id), [navigationPlugin, plugins, id]); + const plugin = useMemo(() => navigationPlugin ?? plugins.find((p: { id: any; }) => p.id === id), [navigationPlugin, plugins, id]); const pluginBaseUrl = useMemo(() => { if (!plugin?.frontendUrl) { return null;