diff --git a/plugins/manager.go b/plugins/manager.go index 0fe0ecf4e..370a0e514 100644 --- a/plugins/manager.go +++ b/plugins/manager.go @@ -410,6 +410,18 @@ func (m *Manager) UpdatePluginLibraries(ctx context.Context, id, librariesJSON s }) } +// RescanPlugins triggers a manual rescan of the plugins folder. +// This synchronizes the database with the filesystem, discovering new plugins, +// updating changed ones, and removing deleted ones. +func (m *Manager) RescanPlugins(ctx context.Context) error { + folder := conf.Server.Plugins.Folder + if folder == "" { + return fmt.Errorf("plugins folder not configured") + } + log.Info(ctx, "Manual plugin rescan requested", "folder", folder) + return m.syncPlugins(ctx, folder) +} + // updatePluginSettings is a common implementation for updating plugin settings. // The updateFn is called to apply the specific field updates to the plugin. // If the plugin is enabled, it will be reloaded. If users permission is required diff --git a/resources/i18n/pt-br.json b/resources/i18n/pt-br.json index 857fde18f..53eed88ea 100644 --- a/resources/i18n/pt-br.json +++ b/resources/i18n/pt-br.json @@ -373,7 +373,8 @@ "disabledDueToError": "Corrija o erro antes de habilitar", "disabledUsersRequired": "Selecione usuários antes de habilitar", "disabledLibrariesRequired": "Selecione bibliotecas antes de habilitar", - "addConfig": "Adicionar configuração" + "addConfig": "Adicionar configuração", + "rescan": "Rescanear" }, "notifications": { "enabled": "Plugin habilitado", diff --git a/server/nativeapi/native_api.go b/server/nativeapi/native_api.go index db17302e5..3db55a8ad 100644 --- a/server/nativeapi/native_api.go +++ b/server/nativeapi/native_api.go @@ -28,6 +28,7 @@ type PluginManager interface { UpdatePluginConfig(ctx context.Context, id, configJSON string) error UpdatePluginUsers(ctx context.Context, id, usersJSON string, allUsers bool) error UpdatePluginLibraries(ctx context.Context, id, librariesJSON string, allLibraries bool) error + RescanPlugins(ctx context.Context) error } type Router struct { diff --git a/server/nativeapi/plugin.go b/server/nativeapi/plugin.go index cebfb9600..d3b81c2f7 100644 --- a/server/nativeapi/plugin.go +++ b/server/nativeapi/plugin.go @@ -22,6 +22,7 @@ func (api *Router) addPluginRoute(r chi.Router) { r.Route("/plugin", func(r chi.Router) { r.Use(pluginsEnabledMiddleware) r.Get("/", rest.GetAll(constructor)) + r.Post("/rescan", api.rescanPlugins) r.Route("/{id}", func(r chi.Router) { r.Use(server.URLParamsMiddleware) r.Get("/", rest.Get(constructor)) @@ -30,6 +31,18 @@ func (api *Router) addPluginRoute(r chi.Router) { }) } +func (api *Router) rescanPlugins(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + if err := api.pluginManager.RescanPlugins(ctx); err != nil { + log.Error(ctx, "Error rescanning plugins", err) + http.Error(w, "Error rescanning plugins: "+err.Error(), http.StatusInternalServerError) + return + } + + w.WriteHeader(http.StatusOK) +} + // Middleware to check if plugins feature is enabled func pluginsEnabledMiddleware(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { diff --git a/server/nativeapi/plugin_test.go b/server/nativeapi/plugin_test.go index 68da24e6c..2c0e7df5b 100644 --- a/server/nativeapi/plugin_test.go +++ b/server/nativeapi/plugin_test.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "encoding/json" + "errors" "net/http" "net/http/httptest" @@ -393,6 +394,32 @@ var _ = Describe("Plugin API", func() { Expect(w.Code).To(Equal(http.StatusBadRequest)) }) }) + + Describe("POST /api/plugin/rescan", func() { + It("triggers plugin rescan", func() { + req := httptest.NewRequest("POST", "/plugin/rescan", nil) + req.Header.Set(consts.UIAuthorizationHeader, "Bearer "+adminToken) + w := httptest.NewRecorder() + + router.ServeHTTP(w, req) + + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(mockManager.RescanPluginsCalls).To(Equal(1)) + }) + + It("returns error when rescan fails", func() { + mockManager.RescanError = errors.New("folder not configured") + + req := httptest.NewRequest("POST", "/plugin/rescan", nil) + req.Header.Set(consts.UIAuthorizationHeader, "Bearer "+adminToken) + w := httptest.NewRecorder() + + router.ServeHTTP(w, req) + + Expect(w.Code).To(Equal(http.StatusInternalServerError)) + Expect(w.Body.String()).To(ContainSubstring("folder not configured")) + }) + }) }) Describe("as regular user", func() { @@ -435,6 +462,16 @@ var _ = Describe("Plugin API", func() { Expect(w.Code).To(Equal(http.StatusForbidden)) }) + + It("denies access to POST /api/plugin/rescan", func() { + req := httptest.NewRequest("POST", "/plugin/rescan", nil) + req.Header.Set(consts.UIAuthorizationHeader, "Bearer "+userToken) + w := httptest.NewRecorder() + + router.ServeHTTP(w, req) + + Expect(w.Code).To(Equal(http.StatusForbidden)) + }) }) Describe("without authentication", func() { diff --git a/tests/mock_plugin_manager.go b/tests/mock_plugin_manager.go index c57b3645d..871f68b4b 100644 --- a/tests/mock_plugin_manager.go +++ b/tests/mock_plugin_manager.go @@ -5,7 +5,7 @@ import ( ) // MockPluginManager is a mock implementation of plugins.PluginManager for testing. -// It implements EnablePlugin, DisablePlugin, UpdatePluginConfig, UpdatePluginUsers and UpdatePluginLibraries methods. +// It implements EnablePlugin, DisablePlugin, UpdatePluginConfig, UpdatePluginUsers, UpdatePluginLibraries and RescanPlugins methods. type MockPluginManager struct { // EnablePluginFn is called when EnablePlugin is invoked. If nil, returns EnableError. EnablePluginFn func(ctx context.Context, id string) error @@ -17,6 +17,8 @@ type MockPluginManager struct { UpdatePluginUsersFn func(ctx context.Context, id, usersJSON string, allUsers bool) error // UpdatePluginLibrariesFn is called when UpdatePluginLibraries is invoked. If nil, returns LibrariesError. UpdatePluginLibrariesFn func(ctx context.Context, id, librariesJSON string, allLibraries bool) error + // RescanPluginsFn is called when RescanPlugins is invoked. If nil, returns RescanError. + RescanPluginsFn func(ctx context.Context) error // Default errors to return when Fn callbacks are not set EnableError error @@ -24,6 +26,7 @@ type MockPluginManager struct { ConfigError error UsersError error LibrariesError error + RescanError error // Track calls for assertions EnablePluginCalls []string @@ -42,6 +45,7 @@ type MockPluginManager struct { LibrariesJSON string AllLibraries bool } + RescanPluginsCalls int } func (m *MockPluginManager) EnablePlugin(ctx context.Context, id string) error { @@ -94,3 +98,11 @@ func (m *MockPluginManager) UpdatePluginLibraries(ctx context.Context, id, libra } return m.LibrariesError } + +func (m *MockPluginManager) RescanPlugins(ctx context.Context) error { + m.RescanPluginsCalls++ + if m.RescanPluginsFn != nil { + return m.RescanPluginsFn(ctx) + } + return m.RescanError +} diff --git a/ui/src/i18n/en.json b/ui/src/i18n/en.json index 9bd410c8d..28f125a8e 100644 --- a/ui/src/i18n/en.json +++ b/ui/src/i18n/en.json @@ -373,7 +373,8 @@ "disabledDueToError": "Fix the error before enabling", "disabledUsersRequired": "Select users before enabling", "disabledLibrariesRequired": "Select libraries before enabling", - "addConfig": "Add Configuration" + "addConfig": "Add Configuration", + "rescan": "Rescan" }, "notifications": { "enabled": "Plugin enabled", diff --git a/ui/src/plugin/PluginList.jsx b/ui/src/plugin/PluginList.jsx index 92d6dd8bb..67af85b81 100644 --- a/ui/src/plugin/PluginList.jsx +++ b/ui/src/plugin/PluginList.jsx @@ -1,14 +1,19 @@ -import React, { useMemo } from 'react' +import React, { useMemo, useState, useCallback } from 'react' import { + Button, Datagrid, TextField, + TopToolbar, + useNotify, useRecordContext, + useRefresh, useTranslate, } from 'react-admin' import { makeStyles } from '@material-ui/core/styles' import { useMediaQuery, Tooltip, Chip, Typography } from '@material-ui/core' -import { MdError } from 'react-icons/md' +import { MdError, MdRefresh } from 'react-icons/md' import { List, DateField, SimpleList, useResourceRefresh } from '../common' +import { httpClient } from '../dataProvider' import ToggleEnabledSwitch from './ToggleEnabledSwitch' const useStyles = makeStyles((theme) => ({ @@ -67,13 +72,53 @@ const ManifestField = ({ source }) => { return {manifest[source] || '-'} } +const PluginListActions = () => { + const translate = useTranslate() + const notify = useNotify() + const refresh = useRefresh() + const [loading, setLoading] = useState(false) + + const handleRescan = useCallback(() => { + setLoading(true) + httpClient('/api/plugin/rescan', { method: 'POST' }) + .then(() => { + refresh() + }) + .catch((error) => { + notify(error.message || 'ra.page.error', { type: 'warning' }) + }) + .finally(() => { + setLoading(false) + }) + }, [notify, refresh]) + + return ( + + + + ) +} + const PluginList = (props) => { const isXsmall = useMediaQuery((theme) => theme.breakpoints.down('xs')) const translate = useTranslate() useResourceRefresh('plugin') return ( - + } + > {isXsmall ? ( record.id} diff --git a/ui/src/plugin/PluginList.test.jsx b/ui/src/plugin/PluginList.test.jsx index 1abe056b7..3688c68fc 100644 --- a/ui/src/plugin/PluginList.test.jsx +++ b/ui/src/plugin/PluginList.test.jsx @@ -1,15 +1,18 @@ import React from 'react' -import { render, screen } from '@testing-library/react' +import { render, screen, fireEvent, waitFor } from '@testing-library/react' import { describe, it, expect, vi, beforeEach } from 'vitest' +const mockNotify = vi.fn() +const mockRefresh = vi.fn() + // Mock react-admin hooks vi.mock('react-admin', async () => { const actual = await vi.importActual('react-admin') return { ...actual, useUpdate: vi.fn(() => [vi.fn(), { loading: false }]), - useNotify: vi.fn(() => vi.fn()), - useRefresh: vi.fn(() => vi.fn()), + useNotify: vi.fn(() => mockNotify), + useRefresh: vi.fn(() => mockRefresh), useTranslate: vi.fn(() => (key) => key), useResourceContext: vi.fn(() => 'plugin'), useRecordContext: vi.fn(() => ({ @@ -22,6 +25,15 @@ vi.mock('react-admin', async () => { enabled: true, lastError: null, })), + Button: ({ onClick, disabled, label, children }) => ( + + ), + TopToolbar: ({ children }) => ( +
{children}
+ ), Datagrid: ({ children }) => ( {children}
), @@ -32,8 +44,9 @@ vi.mock('react-admin', async () => { // Mock common components vi.mock('../common', async () => { return { - List: ({ children, ...props }) => ( -
+ List: ({ children, actions, ...props }) => ( +
+ {actions} {children}
), @@ -59,11 +72,18 @@ vi.mock('./ToggleEnabledSwitch', () => ({ default: () => , })) +// Mock httpClient +const mockHttpClient = vi.fn() +vi.mock('../dataProvider', () => ({ + httpClient: (...args) => mockHttpClient(...args), +})) + import PluginList from './PluginList' describe('PluginList', () => { beforeEach(() => { vi.clearAllMocks() + mockHttpClient.mockResolvedValue({}) }) it('renders the list component', () => { @@ -75,4 +95,46 @@ describe('PluginList', () => { render() expect(screen.getByTestId('datagrid')).toBeInTheDocument() }) + + it('renders the rescan button', () => { + render() + expect(screen.getByTestId('rescan-button')).toBeInTheDocument() + }) + + it('calls rescan endpoint when rescan button is clicked', async () => { + render() + const rescanButton = screen.getByTestId('rescan-button') + + fireEvent.click(rescanButton) + + await waitFor(() => { + expect(mockHttpClient).toHaveBeenCalledWith('/api/plugin/rescan', { + method: 'POST', + }) + }) + }) + + it('calls refresh after successful rescan', async () => { + render() + const rescanButton = screen.getByTestId('rescan-button') + + fireEvent.click(rescanButton) + + await waitFor(() => { + expect(mockRefresh).toHaveBeenCalled() + }) + }) + + it('shows error notification on rescan failure', async () => { + mockHttpClient.mockRejectedValue(new Error('Network error')) + + render() + const rescanButton = screen.getByTestId('rescan-button') + + fireEvent.click(rescanButton) + + await waitFor(() => { + expect(mockNotify).toHaveBeenCalledWith('Network error', { type: 'warning' }) + }) + }) })