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.
This commit is contained in:
Ludy87
2026-02-15 18:11:44 +01:00
parent 5806dfecf6
commit 330a987faf
14 changed files with 318 additions and 189 deletions
@@ -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<String> backendEndpoints = Collections.emptyList();
@Builder.Default Map<String, String> metadata = Collections.emptyMap();
}
@@ -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<String> backendEndpoints;
Map<String, String> 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();
}
}
@@ -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<Path> 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<PluginDescriptor> 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();
}
@@ -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;
@@ -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);
}
}
@@ -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) {
@@ -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 (
<Modal
opened={opened}
onClose={onClose}
title={t("plugins.title", "Installed plugins")}
size="lg"
centered
>
<Stack gap="md">
<Group justify="space-between">
<Text fw={600}>{t("plugins.title", "Installed plugins")}</Text>
<Button
size="xs"
variant="outline"
onClick={() => void refresh()}
leftSection={loading ? <Loader size="xs" /> : undefined}
disabled={loading}
>
{t("plugins.refresh", "Refresh")}
</Button>
</Group>
{loading && (
<Text size="sm" color="dimmed">
{t("plugins.loading", "Checking for available plugins...")}
</Text>
)}
{error && (
<Text size="sm" color="red">
{t("plugins.error", {
defaultValue: "Failed to load plugins: {{message}}",
message: error,
})}
</Text>
)}
{!loading && plugins.length === 0 && (
<Text size="sm" color="dimmed">
{t("plugins.empty", "No plugins installed yet. Drop a plugin jar into the plugins directory.")}
</Text>
)}
{plugins.map((plugin) => (
<Card key={plugin.id} shadow="sm" withBorder>
<Stack gap="sm">
<Group justify="space-between">
<Stack gap="0">
<Text fw={600}>{plugin.name}</Text>
<Text size="sm" color="dimmed">
{plugin.description || t("plugins.noDescription", "No description provided.")}
</Text>
</Stack>
<Group gap="xs">
{plugin.version && (
<Badge variant="outline" color="gray">
{t("plugins.version", {
defaultValue: "v{{version}}",
version: plugin.version,
})}
</Badge>
)}
<Button
size="xs"
variant="outline"
onClick={() => {
if (!plugin.hasFrontend || !plugin.frontendUrl) return;
console.debug(
`[PluginExplorerModal] Navigating to plugin UI: ${plugin.name} (id: ${plugin.id})`,
);
navigate(`/plugins/${plugin.id}`, { state: { plugin } });
onClose();
}}
disabled={!plugin.hasFrontend || !plugin.frontendUrl}
>
{plugin.hasFrontend
? t("plugins.open", "Open UI")
: t("plugins.backendOnly", "Backend only")}
</Button>
</Group>
</Group>
{plugin.backendEndpoints.length > 0 && (
<Group gap="xs" wrap="wrap">
{plugin.backendEndpoints.map((endpoint) => (
<Badge key={endpoint} variant="gradient" gradient={{ from: "indigo", to: "cyan" }}>
{endpoint}
</Badge>
))}
</Group>
)}
</Stack>
</Card>
))}
</Stack>
</Modal>
);
}
@@ -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<HTMLDivElement>((_, ref) => {
const { t } = useTranslation();
@@ -200,15 +199,6 @@ const QuickAccessBar = forwardRef<HTMLDivElement>((_, ref) => {
// This will be overridden by the wrapper logic
},
},
{
id: 'plugins',
name: t("quickAccess.plugins", "Plugins"),
icon: <LocalIcon icon="extension-rounded" width="1.25rem" height="1.25rem" />,
isRound: true,
size: 'md',
type: 'action',
onClick: () => setPluginExplorerOpen(true),
},
...(shouldHideSettingsButton ? [] : [{
id: 'config',
name: t("quickAccess.settings", "Settings"),
@@ -395,10 +385,6 @@ const QuickAccessBar = forwardRef<HTMLDivElement>((_, ref) => {
opened={configModalOpen}
onClose={() => setConfigModalOpen(false)}
/>
<PluginExplorerModal
opened={pluginExplorerOpen}
onClose={() => setPluginExplorerOpen(false)}
/>
</div>
);
});
@@ -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: <PluginSection />
},
],
},
];
return sections;
@@ -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<Record<string, boolean>>({});
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<typeof usePluginRegistry> extends { plugins: (infer T)[] } ? T : never) => {
const isValid = iconStatus[plugin.id];
if (plugin.iconUrl && isValid) {
return <Avatar radius="md" w="36px" h="36px" src={plugin.iconUrl} alt="Plugin Icon" />;
}
return <LocalIcon icon="extension-outline" width="1.5rem" height="1.5rem" />;
};
return (
<Stack gap="md">
<Paper radius="md" p="md" withBorder style={{ background: "var(--modal-content-bg)" }}>
<Group justify="space-between">
<Text size="lg" fw={600}>
{t("settings.plugins.title", "Plugins")}
</Text>
<Badge variant="outline" color="gray">
{t("settings.plugins.count", "Installierte Plugins {{count}}", { count: plugins.length })}
</Badge>
</Group>
<Text size="sm" c="dimmed" mt="xs">
{t("settings.plugins.description", "Erweiterungen durchsuchen, installieren und konfigurieren.")}
</Text>
</Paper>
{loading && (
<Text size="sm" c="dimmed">
{t("settings.plugins.loading", "Plugins werden geladen...")}
</Text>
)}
{error && plugins.length > 0 && (
<Text size="sm" c="red">
{t("settings.plugins.error", "Fehler beim Laden der Plugins")}
</Text>
)}
{!loading && plugins.length === 0 && (
<Text size="sm" c="dimmed">
{t("settings.plugins.empty", "Keine Plugins gefunden. Droppe eine Plugin-JAR in {{path}}.", { path: pluginPath })}
</Text>
)}
{plugins.map((plugin) => (
<Paper key={plugin.id} radius="md" p="md" withBorder style={{ background: "var(--modal-content-bg)" }}>
<Stack gap="sm">
<Group align="center" gap="sm">
{renderIcon(plugin)}
<Stack gap="0">
<Group gap="xs">
{plugin.frontendLabel && (
<Badge color="teal" variant="light">
{plugin.frontendLabel}
</Badge>
)}
{plugin.minHostVersion && (
<Badge color="blue" variant="light">
{t("settings.plugins.minHost", { defaultValue: "min. v{{version}}", version: plugin.minHostVersion })}
</Badge>
)}
{plugin.version && (
<Badge variant="outline" color="gray">
{t("plugins.version", { defaultValue: "v{{version}}", version: plugin.version })}
</Badge>
)}
</Group>
<Text fw={600} c={plugin.hasFrontend ? "var(--mantine-color-blue-3)" : undefined}>
{plugin.name}
</Text>
</Stack>
</Group>
<Text size="sm" c="dimmed">
{plugin.description || t("settings.plugins.noDescription", "Keine Beschreibung")}
</Text>
{plugin.backendEndpoints.length > 0 && (
<Group gap="xs">
{plugin.backendEndpoints.map((endpoint) => (
<Tooltip key={endpoint} label={endpoint} position="bottom" withArrow>
<Badge variant="outline" color="cyan" style={{ fontSize: "0.7rem", letterSpacing: 0.4 }}>
{endpoint.replace(/^\//, "").toUpperCase()}
</Badge>
</Tooltip>
))}
</Group>
)}
<Divider />
<Group gap="xs" wrap="nowrap">
<Text size="xs" c="dimmed">
{t("settings.plugins.author", "Autor: {{author}}", {
author: plugin.author || t("settings.plugins.unknownAuthor", "unbekannt"),
})}
</Text>
</Group>
{plugin.jarCreatedAt && (
<Text size="xs" c="dimmed">
{t("settings.plugins.createdAt", "Erstellt am {{date}}", {
date: new Date(plugin.jarCreatedAt).toLocaleString(),
})}
</Text>
)}
</Stack>
</Paper>
))}
</Stack>
);
};
export default PluginSection;
@@ -29,6 +29,7 @@ export const VALID_NAV_KEYS = [
'adminAudit',
'adminUsage',
'adminEndpoints',
'plugins',
] as const;
// Derive the type from the array
@@ -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
<div style={headerTextStyle}>{t("plugins.shortTitle", "Plugins")}</div>
<div style={{ display: "flex", flexDirection: "column", gap: "0.375rem" }}>
{pluginItems.map((plugin) => (
<Button
key={`plugin-${plugin.id}`}
variant="subtle"
size="sm"
radius="md"
fullWidth
justify="flex-start"
onClick={() => {
console.debug(`[ToolPicker] Navigating to plugin ${plugin.id}`);
setReaderMode(false);
setSearchQuery("");
setLeftPanelView("hidden");
navigate(`/plugins/${plugin.id}`, { state: { plugin } });
}}
data-tour={`plugin-button-${plugin.id}`}
styles={{
root: {
borderRadius: 0,
textAlign: "left",
display: "flex",
flexDirection: "column",
gap: "0.125rem",
paddingTop: 12,
paddingBottom: 12,
color: "var(--tools-text-and-icon-color)",
},
}}
>
<Text size="sm" fw={600}>
{plugin.name}
</Text>
<Text size="xs" color="var(--mantine-color-dimmed)">
{plugin.description || t("plugins.noDescription", "No description provided.")}
</Text>
</Button>
<div key={`plugin-${plugin.id}`} className="tool-button-container">
<Tooltip content={plugin.description} position="right" arrow={true} delay={500}>
<Button
component="a"
key={`plugin-${plugin.id}`}
variant="subtle"
size="sm"
radius="md"
fullWidth
className="tool-button"
justify="flex-start"
onClick={() => {
console.debug(`[ToolPicker] Navigating to plugin ${plugin.id}`);
navigate(`/plugins/${plugin.id}`, { state: { plugin } });
}}
data-tour={`plugin-button-${plugin.id}`}
styles={{
root: {
borderRadius: 0,
color: "var(--tools-text-and-icon-color)",
overflow: 'visible'
},
label: { overflow: 'visible' }
}}
>
<>
<div
className="tool-button-icon"
style={{
transform: "scale(0.8)",
transformOrigin: "center",
opacity: 1,
color: "var(--tools-text-and-icon-color)",
marginRight: "0.5rem"
}}
>
<LocalIcon icon={typeof plugin.icon === 'string' ? plugin.icon : 'extension'} width="24" height="24" />
</div>
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-start', flex: 1, overflow: 'visible' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', width: '100%' }}>
<FitText
text={plugin.name}
lines={1}
minimumFontScale={0.8}
as="span"
style={{ display: 'inline-block', maxWidth: '100%', opacity: 1 }}
/>
</div>
</div>
</>
</Button>
</Tooltip>
</div>
))}
</div>
</Box>
@@ -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<PluginInfo[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
@@ -69,7 +96,9 @@ export const PluginRegistryProvider: React.FC<{ children: React.ReactNode }> = (
setError(null);
try {
const response = await apiClient.get<PluginResponse[]>("/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) {
+4 -2
View File
@@ -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;