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:
@@ -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();
|
||||
}
|
||||
|
||||
+4
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
+5
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user