mirror of
https://github.com/navidrome/navidrome.git
synced 2026-05-03 06:51:16 +00:00
feat(plugins UI): add PluginList and PluginShow components with plugin management functionality
Signed-off-by: Deluan <deluan@navidrome.org>
This commit is contained in:
parent
690785120a
commit
2cc29793a6
@ -14,6 +14,7 @@ import (
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
@ -116,6 +117,13 @@ func GetManager() *Manager {
|
||||
})
|
||||
}
|
||||
|
||||
// IsConfigured returns true if the manager has been configured with a DataStore.
|
||||
// This is useful for API handlers to know if they should use the manager or fall back
|
||||
// to direct DB operations (e.g., in test environments).
|
||||
func (m *Manager) IsConfigured() bool {
|
||||
return m.ds != nil
|
||||
}
|
||||
|
||||
// adminContext returns a context with admin privileges for DB operations.
|
||||
func adminContext(ctx context.Context) context.Context {
|
||||
return request.WithUser(ctx, model.User{IsAdmin: true})
|
||||
@ -941,6 +949,7 @@ func (m *Manager) UnloadPlugin(name string) error {
|
||||
}
|
||||
}
|
||||
|
||||
runtime.GC()
|
||||
log.Info(m.ctx, "Unloaded plugin", "plugin", name)
|
||||
return nil
|
||||
}
|
||||
|
||||
@ -11,6 +11,7 @@ import (
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/plugins"
|
||||
"github.com/navidrome/navidrome/server"
|
||||
)
|
||||
|
||||
@ -49,9 +50,11 @@ type PluginUpdateRequest struct {
|
||||
|
||||
func (api *Router) updatePlugin(w http.ResponseWriter, r *http.Request) {
|
||||
id := chi.URLParam(r, "id")
|
||||
repo := api.ds.Plugin(r.Context())
|
||||
ctx := r.Context()
|
||||
repo := api.ds.Plugin(ctx)
|
||||
manager := plugins.GetManager()
|
||||
|
||||
// Get existing plugin
|
||||
// Get existing plugin to verify it exists
|
||||
plugin, err := repo.Get(id)
|
||||
if err != nil {
|
||||
if errors.Is(err, rest.ErrPermissionDenied) {
|
||||
@ -62,7 +65,7 @@ func (api *Router) updatePlugin(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "Plugin not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
log.Error(r.Context(), "Error getting plugin", "id", id, err)
|
||||
log.Error(ctx, "Error getting plugin", "id", id, err)
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
@ -70,38 +73,82 @@ func (api *Router) updatePlugin(w http.ResponseWriter, r *http.Request) {
|
||||
// Parse update request
|
||||
var req PluginUpdateRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
log.Error(r.Context(), "Error decoding request", err)
|
||||
log.Error(ctx, "Error decoding request", err)
|
||||
http.Error(w, "Invalid request body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Apply updates
|
||||
if req.Enabled != nil {
|
||||
plugin.Enabled = *req.Enabled
|
||||
}
|
||||
if req.Config != nil {
|
||||
// Validate JSON if not empty
|
||||
if *req.Config != "" && !isValidJSON(*req.Config) {
|
||||
http.Error(w, "Invalid JSON in config field", http.StatusBadRequest)
|
||||
// If manager is configured, use it to properly load/unload plugins
|
||||
// Otherwise, fall back to direct DB operations (e.g., in tests)
|
||||
if manager.IsConfigured() {
|
||||
// Handle config update first (if provided)
|
||||
if req.Config != nil {
|
||||
// Validate JSON if not empty
|
||||
if *req.Config != "" && !isValidJSON(*req.Config) {
|
||||
http.Error(w, "Invalid JSON in config field", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if err := manager.UpdatePluginConfig(ctx, id, *req.Config); err != nil {
|
||||
log.Error(ctx, "Error updating plugin config", "id", id, err)
|
||||
http.Error(w, "Error updating plugin configuration: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Handle enable/disable
|
||||
if req.Enabled != nil {
|
||||
if *req.Enabled {
|
||||
if err := manager.EnablePlugin(ctx, id); err != nil {
|
||||
log.Error(ctx, "Error enabling plugin", "id", id, err)
|
||||
// Refresh plugin from DB to get the error
|
||||
plugin, _ = repo.Get(id)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusUnprocessableEntity)
|
||||
_ = json.NewEncoder(w).Encode(plugin)
|
||||
return
|
||||
}
|
||||
} else {
|
||||
if err := manager.DisablePlugin(ctx, id); err != nil {
|
||||
log.Error(ctx, "Error disabling plugin", "id", id, err)
|
||||
http.Error(w, "Error disabling plugin: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Fallback: direct DB operations (for tests or when manager is not started)
|
||||
if req.Config != nil {
|
||||
if *req.Config != "" && !isValidJSON(*req.Config) {
|
||||
http.Error(w, "Invalid JSON in config field", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
plugin.Config = *req.Config
|
||||
}
|
||||
if req.Enabled != nil {
|
||||
plugin.Enabled = *req.Enabled
|
||||
}
|
||||
if err := repo.Put(plugin); err != nil {
|
||||
if errors.Is(err, rest.ErrPermissionDenied) {
|
||||
http.Error(w, "Access denied: admin privileges required", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
log.Error(ctx, "Error updating plugin", "id", id, err)
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
plugin.Config = *req.Config
|
||||
}
|
||||
|
||||
// Save
|
||||
if err := repo.Put(plugin); err != nil {
|
||||
if errors.Is(err, rest.ErrPermissionDenied) {
|
||||
http.Error(w, "Access denied: admin privileges required", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
log.Error(r.Context(), "Error updating plugin", "id", id, err)
|
||||
// Refresh and return updated plugin
|
||||
plugin, err = repo.Get(id)
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error getting updated plugin", "id", id, err)
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
if err := json.NewEncoder(w).Encode(plugin); err != nil {
|
||||
log.Error(r.Context(), "Error encoding plugin response", err)
|
||||
log.Error(ctx, "Error encoding plugin response", err)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -74,6 +74,7 @@ func serveIndex(ds model.DataStore, fs fs.FS, shareInfo *model.Share) http.Handl
|
||||
"defaultDownsamplingFormat": conf.Server.DefaultDownsamplingFormat,
|
||||
"separator": string(os.PathSeparator),
|
||||
"enableInspect": conf.Server.Inspect.Enabled,
|
||||
"pluginsEnabled": conf.Server.Plugins.Enabled,
|
||||
}
|
||||
if strings.HasPrefix(conf.Server.UILoginBackgroundURL, "/") {
|
||||
appConfig["loginBackgroundURL"] = path.Join(conf.Server.BasePath, conf.Server.UILoginBackgroundURL)
|
||||
|
||||
@ -16,6 +16,7 @@ import playlist from './playlist'
|
||||
import radio from './radio'
|
||||
import share from './share'
|
||||
import library from './library'
|
||||
import plugin from './plugin'
|
||||
import { Player } from './audioplayer'
|
||||
import customRoutes from './routes'
|
||||
import {
|
||||
@ -139,6 +140,13 @@ const Admin = (props) => {
|
||||
options={{ subMenu: 'settings' }}
|
||||
/>
|
||||
) : null,
|
||||
permissions === 'admin' && config.pluginsEnabled ? (
|
||||
<Resource
|
||||
name="plugin"
|
||||
{...plugin}
|
||||
options={{ subMenu: 'settings' }}
|
||||
/>
|
||||
) : null,
|
||||
|
||||
<Resource name="translation" />,
|
||||
<Resource name="genre" />,
|
||||
|
||||
@ -38,6 +38,7 @@ const defaultConfig = {
|
||||
publicBaseUrl: '/share',
|
||||
separator: '/',
|
||||
enableInspect: true,
|
||||
pluginsEnabled: true,
|
||||
}
|
||||
|
||||
let config
|
||||
|
||||
@ -330,6 +330,47 @@
|
||||
"scanInProgress": "Scan in progress...",
|
||||
"noLibrariesAssigned": "No libraries assigned to this user"
|
||||
}
|
||||
},
|
||||
"plugin": {
|
||||
"name": "Plugin |||| Plugins",
|
||||
"fields": {
|
||||
"name": "Name",
|
||||
"description": "Description",
|
||||
"version": "Version",
|
||||
"enabled": "Enabled",
|
||||
"status": "Status",
|
||||
"path": "Path",
|
||||
"lastError": "Error",
|
||||
"hasError": "Error",
|
||||
"updatedAt": "Updated",
|
||||
"createdAt": "Installed"
|
||||
},
|
||||
"sections": {
|
||||
"status": "Status",
|
||||
"info": "Plugin Information",
|
||||
"configuration": "Configuration",
|
||||
"manifest": "Manifest"
|
||||
},
|
||||
"status": {
|
||||
"enabled": "Enabled",
|
||||
"disabled": "Disabled"
|
||||
},
|
||||
"actions": {
|
||||
"enable": "Enable",
|
||||
"disable": "Disable"
|
||||
},
|
||||
"notifications": {
|
||||
"enabled": "Plugin enabled",
|
||||
"disabled": "Plugin disabled",
|
||||
"updated": "Plugin updated",
|
||||
"error": "Error updating plugin"
|
||||
},
|
||||
"validation": {
|
||||
"invalidJson": "Configuration must be valid JSON"
|
||||
},
|
||||
"messages": {
|
||||
"configHelp": "Enter plugin configuration as a JSON object. Leave empty if the plugin requires no configuration."
|
||||
}
|
||||
}
|
||||
},
|
||||
"ra": {
|
||||
|
||||
223
ui/src/plugin/PluginList.jsx
Normal file
223
ui/src/plugin/PluginList.jsx
Normal file
@ -0,0 +1,223 @@
|
||||
import React, { useMemo, useCallback } from 'react'
|
||||
import {
|
||||
Datagrid,
|
||||
TextField,
|
||||
useUpdate,
|
||||
useNotify,
|
||||
useRefresh,
|
||||
useRecordContext,
|
||||
useTranslate,
|
||||
FunctionField,
|
||||
} from 'react-admin'
|
||||
import Switch from '@material-ui/core/Switch'
|
||||
import { makeStyles } from '@material-ui/core/styles'
|
||||
import { useMediaQuery, Tooltip, Chip } from '@material-ui/core'
|
||||
import { MdError } from 'react-icons/md'
|
||||
import { List, DateField, SimpleList } from '../common'
|
||||
|
||||
const useStyles = makeStyles((theme) => ({
|
||||
errorIcon: {
|
||||
color: theme.palette.error.main,
|
||||
marginRight: theme.spacing(0.5),
|
||||
verticalAlign: 'middle',
|
||||
},
|
||||
errorChip: {
|
||||
backgroundColor: theme.palette.error.light,
|
||||
color: theme.palette.error.contrastText,
|
||||
},
|
||||
enabledSwitch: {
|
||||
'& .MuiSwitch-colorSecondary.Mui-checked': {
|
||||
color: theme.palette.success?.main || theme.palette.primary.main,
|
||||
},
|
||||
'& .MuiSwitch-colorSecondary.Mui-checked + .MuiSwitch-track': {
|
||||
backgroundColor:
|
||||
theme.palette.success?.main || theme.palette.primary.main,
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
const ToggleEnabledInput = ({ resource }) => {
|
||||
const record = useRecordContext()
|
||||
const notify = useNotify()
|
||||
const refresh = useRefresh()
|
||||
const translate = useTranslate()
|
||||
const classes = useStyles()
|
||||
|
||||
const [toggleEnabled, { loading }] = useUpdate(
|
||||
resource,
|
||||
record.id,
|
||||
{ enabled: !record.enabled },
|
||||
record,
|
||||
{
|
||||
undoable: false,
|
||||
onSuccess: () => {
|
||||
refresh()
|
||||
notify(
|
||||
record.enabled
|
||||
? 'resources.plugin.notifications.disabled'
|
||||
: 'resources.plugin.notifications.enabled',
|
||||
'info',
|
||||
)
|
||||
},
|
||||
onFailure: (error) => {
|
||||
notify(
|
||||
error?.message || 'resources.plugin.notifications.error',
|
||||
'warning',
|
||||
)
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
const handleClick = useCallback(
|
||||
(e) => {
|
||||
e.stopPropagation()
|
||||
toggleEnabled()
|
||||
},
|
||||
[toggleEnabled],
|
||||
)
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
title={translate(
|
||||
record.enabled
|
||||
? 'resources.plugin.actions.disable'
|
||||
: 'resources.plugin.actions.enable',
|
||||
)}
|
||||
>
|
||||
<span>
|
||||
<Switch
|
||||
checked={record.enabled}
|
||||
onClick={handleClick}
|
||||
disabled={loading}
|
||||
className={classes.enabledSwitch}
|
||||
size="small"
|
||||
/>
|
||||
</span>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
const ErrorIndicator = () => {
|
||||
const record = useRecordContext()
|
||||
const translate = useTranslate()
|
||||
const classes = useStyles()
|
||||
|
||||
if (!record.lastError) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<Tooltip title={record.lastError}>
|
||||
<Chip
|
||||
size="small"
|
||||
icon={<MdError className={classes.errorIcon} />}
|
||||
label={translate('resources.plugin.fields.hasError')}
|
||||
className={classes.errorChip}
|
||||
/>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
const VersionField = () => {
|
||||
const record = useRecordContext()
|
||||
|
||||
if (!record.manifest) {
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
const manifest = JSON.parse(record.manifest)
|
||||
return <span>{manifest.version || '-'}</span>
|
||||
} catch {
|
||||
return <span>-</span>
|
||||
}
|
||||
}
|
||||
|
||||
const DescriptionField = () => {
|
||||
const record = useRecordContext()
|
||||
|
||||
if (!record.manifest) {
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
const manifest = JSON.parse(record.manifest)
|
||||
return <span>{manifest.description || '-'}</span>
|
||||
} catch {
|
||||
return <span>-</span>
|
||||
}
|
||||
}
|
||||
|
||||
const PluginList = (props) => {
|
||||
const isXsmall = useMediaQuery((theme) => theme.breakpoints.down('xs'))
|
||||
const translate = useTranslate()
|
||||
|
||||
const toggleableFields = useMemo(
|
||||
() => ({
|
||||
description: !isXsmall && (
|
||||
<FunctionField
|
||||
source="manifest"
|
||||
label="resources.plugin.fields.description"
|
||||
render={() => <DescriptionField />}
|
||||
/>
|
||||
),
|
||||
version: (
|
||||
<FunctionField
|
||||
source="manifest"
|
||||
label="resources.plugin.fields.version"
|
||||
render={() => <VersionField />}
|
||||
/>
|
||||
),
|
||||
enabled: (
|
||||
<FunctionField
|
||||
source="enabled"
|
||||
label="resources.plugin.fields.enabled"
|
||||
render={() => <ToggleEnabledInput resource="plugin" />}
|
||||
/>
|
||||
),
|
||||
error: (
|
||||
<FunctionField
|
||||
source="lastError"
|
||||
label="resources.plugin.fields.status"
|
||||
render={() => <ErrorIndicator />}
|
||||
/>
|
||||
),
|
||||
}),
|
||||
[isXsmall],
|
||||
)
|
||||
|
||||
return (
|
||||
<List {...props} sort={{ field: 'id', order: 'ASC' }} exporter={false}>
|
||||
{isXsmall ? (
|
||||
<SimpleList
|
||||
primaryText={(record) => record.id}
|
||||
secondaryText={(record) => {
|
||||
try {
|
||||
const manifest = JSON.parse(record.manifest)
|
||||
return manifest.description || ''
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}}
|
||||
tertiaryText={(record) =>
|
||||
record.enabled
|
||||
? translate('resources.plugin.status.enabled')
|
||||
: translate('resources.plugin.status.disabled')
|
||||
}
|
||||
linkType="show"
|
||||
/>
|
||||
) : (
|
||||
<Datagrid rowClick="show">
|
||||
<TextField source="id" label="resources.plugin.fields.name" />
|
||||
{toggleableFields['description']}
|
||||
{toggleableFields['version']}
|
||||
{toggleableFields['enabled']}
|
||||
{toggleableFields['error']}
|
||||
<DateField source="updatedAt" sortByOrder={'DESC'} />
|
||||
</Datagrid>
|
||||
)}
|
||||
</List>
|
||||
)
|
||||
}
|
||||
|
||||
export default PluginList
|
||||
73
ui/src/plugin/PluginList.test.jsx
Normal file
73
ui/src/plugin/PluginList.test.jsx
Normal file
@ -0,0 +1,73 @@
|
||||
import React from 'react'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
|
||||
// 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()),
|
||||
useTranslate: vi.fn(() => (key) => key),
|
||||
useRecordContext: vi.fn(() => ({
|
||||
id: 'test-plugin',
|
||||
manifest: JSON.stringify({
|
||||
version: '1.0.0',
|
||||
description: 'Test plugin',
|
||||
}),
|
||||
enabled: true,
|
||||
lastError: null,
|
||||
})),
|
||||
FunctionField: ({ render, label }) => (
|
||||
<span data-testid={`field-${label}`}>{render && render()}</span>
|
||||
),
|
||||
Datagrid: ({ children }) => (
|
||||
<table data-testid="datagrid">{children}</table>
|
||||
),
|
||||
TextField: ({ source }) => <span data-testid={`text-${source}`} />,
|
||||
}
|
||||
})
|
||||
|
||||
// Mock common components
|
||||
vi.mock('../common', async () => {
|
||||
return {
|
||||
List: ({ children, ...props }) => (
|
||||
<div data-testid="list" {...props}>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
DateField: ({ source }) => <span data-testid={`date-${source}`} />,
|
||||
SimpleList: ({ primaryText, secondaryText }) => (
|
||||
<div data-testid="simple-list" />
|
||||
),
|
||||
}
|
||||
})
|
||||
|
||||
// Mock Material-UI
|
||||
vi.mock('@material-ui/core', async () => {
|
||||
const actual = await vi.importActual('@material-ui/core')
|
||||
return {
|
||||
...actual,
|
||||
useMediaQuery: vi.fn(() => false),
|
||||
}
|
||||
})
|
||||
|
||||
import PluginList from './PluginList'
|
||||
|
||||
describe('PluginList', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('renders the list component', () => {
|
||||
render(<PluginList />)
|
||||
expect(screen.getByTestId('list')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders the datagrid on desktop', () => {
|
||||
render(<PluginList />)
|
||||
expect(screen.getByTestId('datagrid')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
349
ui/src/plugin/PluginShow.jsx
Normal file
349
ui/src/plugin/PluginShow.jsx
Normal file
@ -0,0 +1,349 @@
|
||||
import React, { useState, useCallback } from 'react'
|
||||
import {
|
||||
Show,
|
||||
SimpleShowLayout,
|
||||
TextField,
|
||||
useTranslate,
|
||||
useUpdate,
|
||||
useNotify,
|
||||
useRefresh,
|
||||
useRecordContext,
|
||||
Toolbar,
|
||||
SaveButton,
|
||||
} from 'react-admin'
|
||||
import {
|
||||
Typography,
|
||||
Box,
|
||||
Switch,
|
||||
FormControlLabel,
|
||||
Card,
|
||||
CardContent,
|
||||
TextField as MuiTextField,
|
||||
Accordion,
|
||||
AccordionSummary,
|
||||
AccordionDetails,
|
||||
} from '@material-ui/core'
|
||||
import { makeStyles } from '@material-ui/core/styles'
|
||||
import { MdExpandMore, MdError, MdCheckCircle } from 'react-icons/md'
|
||||
import { Title, DateField } from '../common'
|
||||
import { validateJson } from './jsonValidation'
|
||||
|
||||
const useStyles = makeStyles((theme) => ({
|
||||
root: {
|
||||
padding: theme.spacing(2),
|
||||
maxWidth: 900,
|
||||
},
|
||||
section: {
|
||||
marginBottom: theme.spacing(3),
|
||||
},
|
||||
sectionTitle: {
|
||||
marginBottom: theme.spacing(1),
|
||||
fontWeight: 600,
|
||||
},
|
||||
errorBox: {
|
||||
backgroundColor: theme.palette.error.light,
|
||||
color: theme.palette.error.contrastText,
|
||||
padding: theme.spacing(2),
|
||||
borderRadius: theme.shape.borderRadius,
|
||||
marginBottom: theme.spacing(2),
|
||||
display: 'flex',
|
||||
alignItems: 'flex-start',
|
||||
gap: theme.spacing(1),
|
||||
},
|
||||
errorIcon: {
|
||||
marginTop: 2,
|
||||
},
|
||||
manifestBox: {
|
||||
backgroundColor:
|
||||
theme.palette.type === 'dark'
|
||||
? theme.palette.grey[900]
|
||||
: theme.palette.grey[100],
|
||||
padding: theme.spacing(2),
|
||||
borderRadius: theme.shape.borderRadius,
|
||||
fontFamily: 'monospace',
|
||||
fontSize: '0.85rem',
|
||||
whiteSpace: 'pre-wrap',
|
||||
wordBreak: 'break-word',
|
||||
overflow: 'auto',
|
||||
maxHeight: 400,
|
||||
},
|
||||
configInput: {
|
||||
fontFamily: 'monospace',
|
||||
fontSize: '0.85rem',
|
||||
},
|
||||
statusEnabled: {
|
||||
color: theme.palette.success?.main || theme.palette.primary.main,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: theme.spacing(0.5),
|
||||
},
|
||||
statusDisabled: {
|
||||
color: theme.palette.text.secondary,
|
||||
},
|
||||
toolbar: {
|
||||
display: 'flex',
|
||||
justifyContent: 'flex-start',
|
||||
paddingLeft: 0,
|
||||
paddingRight: 0,
|
||||
marginTop: theme.spacing(2),
|
||||
},
|
||||
infoGrid: {
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'auto 1fr',
|
||||
gap: theme.spacing(1, 2),
|
||||
'& dt': {
|
||||
fontWeight: 500,
|
||||
color: theme.palette.text.secondary,
|
||||
},
|
||||
'& dd': {
|
||||
margin: 0,
|
||||
},
|
||||
},
|
||||
pathField: {
|
||||
fontFamily: 'monospace',
|
||||
fontSize: '0.85rem',
|
||||
wordBreak: 'break-all',
|
||||
},
|
||||
}))
|
||||
|
||||
const PluginTitle = ({ record }) => {
|
||||
const translate = useTranslate()
|
||||
const resourceName = translate('resources.plugin.name', { smart_count: 1 })
|
||||
return (
|
||||
<Title subTitle={`${resourceName} ${record ? `"${record.id}"` : ''}`} />
|
||||
)
|
||||
}
|
||||
|
||||
const PluginShowContent = () => {
|
||||
const record = useRecordContext()
|
||||
const classes = useStyles()
|
||||
const translate = useTranslate()
|
||||
const notify = useNotify()
|
||||
const refresh = useRefresh()
|
||||
|
||||
const [config, setConfig] = useState(record?.config || '')
|
||||
const [configError, setConfigError] = useState(null)
|
||||
const [isDirty, setIsDirty] = useState(false)
|
||||
|
||||
const [updatePlugin, { loading }] = useUpdate(
|
||||
'plugin',
|
||||
record?.id,
|
||||
{},
|
||||
record,
|
||||
{
|
||||
undoable: false,
|
||||
onSuccess: () => {
|
||||
refresh()
|
||||
setIsDirty(false)
|
||||
notify('resources.plugin.notifications.updated', 'info')
|
||||
},
|
||||
onFailure: (error) => {
|
||||
notify(
|
||||
error?.message || 'resources.plugin.notifications.error',
|
||||
'warning',
|
||||
)
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
const handleToggleEnabled = useCallback(() => {
|
||||
updatePlugin('plugin', record.id, { enabled: !record.enabled }, record)
|
||||
}, [updatePlugin, record])
|
||||
|
||||
const handleConfigChange = useCallback(
|
||||
(e) => {
|
||||
const value = e.target.value
|
||||
setConfig(value)
|
||||
setIsDirty(value !== (record?.config || ''))
|
||||
|
||||
if (value === '') {
|
||||
setConfigError(null)
|
||||
} else {
|
||||
const validation = validateJson(value)
|
||||
setConfigError(validation.error)
|
||||
}
|
||||
},
|
||||
[record?.config],
|
||||
)
|
||||
|
||||
const handleSaveConfig = useCallback(() => {
|
||||
if (configError) {
|
||||
notify('resources.plugin.validation.invalidJson', 'warning')
|
||||
return
|
||||
}
|
||||
updatePlugin('plugin', record.id, { config }, record)
|
||||
}, [updatePlugin, record, config, configError, notify])
|
||||
|
||||
if (!record) {
|
||||
return null
|
||||
}
|
||||
|
||||
let manifest = null
|
||||
let manifestJson = ''
|
||||
try {
|
||||
manifest = JSON.parse(record.manifest)
|
||||
manifestJson = JSON.stringify(manifest, null, 2)
|
||||
} catch {
|
||||
manifestJson = record.manifest
|
||||
}
|
||||
|
||||
return (
|
||||
<Box className={classes.root}>
|
||||
{/* Error Section */}
|
||||
{record.lastError && (
|
||||
<Box className={classes.errorBox}>
|
||||
<MdError size={20} className={classes.errorIcon} />
|
||||
<Box>
|
||||
<Typography variant="subtitle2">
|
||||
{translate('resources.plugin.fields.lastError')}
|
||||
</Typography>
|
||||
<Typography variant="body2">{record.lastError}</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Status and Enable/Disable */}
|
||||
<Card className={classes.section}>
|
||||
<CardContent>
|
||||
<Typography variant="h6" className={classes.sectionTitle}>
|
||||
{translate('resources.plugin.sections.status')}
|
||||
</Typography>
|
||||
<Box
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
justifyContent="space-between"
|
||||
>
|
||||
<Box>
|
||||
{record.enabled ? (
|
||||
<Typography className={classes.statusEnabled}>
|
||||
<MdCheckCircle />
|
||||
{translate('resources.plugin.status.enabled')}
|
||||
</Typography>
|
||||
) : (
|
||||
<Typography className={classes.statusDisabled}>
|
||||
{translate('resources.plugin.status.disabled')}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
checked={record.enabled}
|
||||
onChange={handleToggleEnabled}
|
||||
disabled={loading}
|
||||
color="primary"
|
||||
/>
|
||||
}
|
||||
label={translate(
|
||||
record.enabled
|
||||
? 'resources.plugin.actions.disable'
|
||||
: 'resources.plugin.actions.enable',
|
||||
)}
|
||||
labelPlacement="start"
|
||||
/>
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Plugin Info */}
|
||||
<Card className={classes.section}>
|
||||
<CardContent>
|
||||
<Typography variant="h6" className={classes.sectionTitle}>
|
||||
{translate('resources.plugin.sections.info')}
|
||||
</Typography>
|
||||
<dl className={classes.infoGrid}>
|
||||
<dt>{translate('resources.plugin.fields.name')}</dt>
|
||||
<dd>{record.id}</dd>
|
||||
|
||||
{manifest?.version && (
|
||||
<>
|
||||
<dt>{translate('resources.plugin.fields.version')}</dt>
|
||||
<dd>{manifest.version}</dd>
|
||||
</>
|
||||
)}
|
||||
|
||||
{manifest?.description && (
|
||||
<>
|
||||
<dt>{translate('resources.plugin.fields.description')}</dt>
|
||||
<dd>{manifest.description}</dd>
|
||||
</>
|
||||
)}
|
||||
|
||||
<dt>{translate('resources.plugin.fields.path')}</dt>
|
||||
<dd className={classes.pathField}>{record.path}</dd>
|
||||
|
||||
<dt>{translate('resources.plugin.fields.updatedAt')}</dt>
|
||||
<dd>
|
||||
<DateField record={record} source="updatedAt" showTime />
|
||||
</dd>
|
||||
|
||||
<dt>{translate('resources.plugin.fields.createdAt')}</dt>
|
||||
<dd>
|
||||
<DateField record={record} source="createdAt" showTime />
|
||||
</dd>
|
||||
</dl>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Configuration */}
|
||||
<Card className={classes.section}>
|
||||
<CardContent>
|
||||
<Typography variant="h6" className={classes.sectionTitle}>
|
||||
{translate('resources.plugin.sections.configuration')}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="textSecondary" gutterBottom>
|
||||
{translate('resources.plugin.messages.configHelp')}
|
||||
</Typography>
|
||||
<MuiTextField
|
||||
multiline
|
||||
fullWidth
|
||||
minRows={4}
|
||||
maxRows={15}
|
||||
variant="outlined"
|
||||
value={config}
|
||||
onChange={handleConfigChange}
|
||||
error={!!configError}
|
||||
helperText={configError}
|
||||
placeholder="{}"
|
||||
InputProps={{
|
||||
className: classes.configInput,
|
||||
}}
|
||||
/>
|
||||
<Toolbar className={classes.toolbar}>
|
||||
<SaveButton
|
||||
handleSubmitWithRedirect={handleSaveConfig}
|
||||
disabled={!isDirty || !!configError || loading}
|
||||
saving={loading}
|
||||
/>
|
||||
</Toolbar>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Manifest */}
|
||||
<Accordion>
|
||||
<AccordionSummary expandIcon={<MdExpandMore />}>
|
||||
<Typography variant="h6">
|
||||
{translate('resources.plugin.sections.manifest')}
|
||||
</Typography>
|
||||
</AccordionSummary>
|
||||
<AccordionDetails>
|
||||
<Box className={classes.manifestBox} width="100%">
|
||||
{manifestJson}
|
||||
</Box>
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
const PluginShow = (props) => {
|
||||
return (
|
||||
<Show title={<PluginTitle />} actions={false} {...props}>
|
||||
<SimpleShowLayout>
|
||||
<PluginShowContent />
|
||||
</SimpleShowLayout>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
||||
export default PluginShow
|
||||
9
ui/src/plugin/index.js
Normal file
9
ui/src/plugin/index.js
Normal file
@ -0,0 +1,9 @@
|
||||
import { VscExtensions } from 'react-icons/vsc'
|
||||
import PluginList from './PluginList'
|
||||
import PluginShow from './PluginShow'
|
||||
|
||||
export default {
|
||||
icon: VscExtensions,
|
||||
list: PluginList,
|
||||
show: PluginShow,
|
||||
}
|
||||
68
ui/src/plugin/jsonValidation.js
Normal file
68
ui/src/plugin/jsonValidation.js
Normal file
@ -0,0 +1,68 @@
|
||||
/**
|
||||
* Validates a JSON string and returns validation result
|
||||
* @param {string} value - The JSON string to validate
|
||||
* @returns {{ valid: boolean, error: string|null, parsed: object|null }}
|
||||
*/
|
||||
export const validateJson = (value) => {
|
||||
if (!value || value.trim() === '') {
|
||||
return { valid: true, error: null, parsed: null }
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(value)
|
||||
// Ensure config is an object, not an array or primitive
|
||||
if (
|
||||
typeof parsed !== 'object' ||
|
||||
parsed === null ||
|
||||
Array.isArray(parsed)
|
||||
) {
|
||||
return {
|
||||
valid: false,
|
||||
error: 'Configuration must be a JSON object',
|
||||
parsed: null,
|
||||
}
|
||||
}
|
||||
return { valid: true, error: null, parsed }
|
||||
} catch (e) {
|
||||
// Try to provide helpful error messages
|
||||
let error = 'Invalid JSON'
|
||||
|
||||
if (e instanceof SyntaxError) {
|
||||
const message = e.message
|
||||
|
||||
// Extract position information if available
|
||||
const positionMatch = message.match(/position (\d+)/)
|
||||
if (positionMatch) {
|
||||
const position = parseInt(positionMatch[1], 10)
|
||||
const lines = value.substring(0, position).split('\n')
|
||||
const line = lines.length
|
||||
const column = lines[lines.length - 1].length + 1
|
||||
error = `Invalid JSON at line ${line}, column ${column}`
|
||||
} else if (message.includes('Unexpected end of JSON')) {
|
||||
error = 'Incomplete JSON - check for missing brackets or quotes'
|
||||
} else if (message.includes('Unexpected token')) {
|
||||
error = 'Invalid JSON - unexpected character found'
|
||||
}
|
||||
}
|
||||
|
||||
return { valid: false, error, parsed: null }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats JSON string with proper indentation
|
||||
* @param {string} value - The JSON string to format
|
||||
* @returns {string} - Formatted JSON string or original if invalid
|
||||
*/
|
||||
export const formatJson = (value) => {
|
||||
if (!value || value.trim() === '') {
|
||||
return value
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(value)
|
||||
return JSON.stringify(parsed, null, 2)
|
||||
} catch {
|
||||
return value
|
||||
}
|
||||
}
|
||||
97
ui/src/plugin/jsonValidation.test.js
Normal file
97
ui/src/plugin/jsonValidation.test.js
Normal file
@ -0,0 +1,97 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { validateJson, formatJson } from './jsonValidation'
|
||||
|
||||
describe('validateJson', () => {
|
||||
it('returns valid for empty string', () => {
|
||||
const result = validateJson('')
|
||||
expect(result.valid).toBe(true)
|
||||
expect(result.error).toBeNull()
|
||||
expect(result.parsed).toBeNull()
|
||||
})
|
||||
|
||||
it('returns valid for whitespace only', () => {
|
||||
const result = validateJson(' ')
|
||||
expect(result.valid).toBe(true)
|
||||
expect(result.error).toBeNull()
|
||||
})
|
||||
|
||||
it('returns valid for valid JSON object', () => {
|
||||
const result = validateJson('{"key": "value"}')
|
||||
expect(result.valid).toBe(true)
|
||||
expect(result.error).toBeNull()
|
||||
expect(result.parsed).toEqual({ key: 'value' })
|
||||
})
|
||||
|
||||
it('returns valid for nested JSON object', () => {
|
||||
const result = validateJson('{"outer": {"inner": 123}}')
|
||||
expect(result.valid).toBe(true)
|
||||
expect(result.parsed).toEqual({ outer: { inner: 123 } })
|
||||
})
|
||||
|
||||
it('returns invalid for JSON array', () => {
|
||||
const result = validateJson('[1, 2, 3]')
|
||||
expect(result.valid).toBe(false)
|
||||
expect(result.error).toBe('Configuration must be a JSON object')
|
||||
})
|
||||
|
||||
it('returns invalid for JSON primitive string', () => {
|
||||
const result = validateJson('"hello"')
|
||||
expect(result.valid).toBe(false)
|
||||
expect(result.error).toBe('Configuration must be a JSON object')
|
||||
})
|
||||
|
||||
it('returns invalid for JSON primitive number', () => {
|
||||
const result = validateJson('42')
|
||||
expect(result.valid).toBe(false)
|
||||
expect(result.error).toBe('Configuration must be a JSON object')
|
||||
})
|
||||
|
||||
it('returns invalid for JSON null', () => {
|
||||
const result = validateJson('null')
|
||||
expect(result.valid).toBe(false)
|
||||
expect(result.error).toBe('Configuration must be a JSON object')
|
||||
})
|
||||
|
||||
it('returns invalid for malformed JSON', () => {
|
||||
const result = validateJson('{"key": }')
|
||||
expect(result.valid).toBe(false)
|
||||
expect(result.error).toContain('Invalid JSON')
|
||||
})
|
||||
|
||||
it('returns invalid for incomplete JSON', () => {
|
||||
const result = validateJson('{"key": "value"')
|
||||
expect(result.valid).toBe(false)
|
||||
expect(result.error).toContain('Invalid JSON')
|
||||
})
|
||||
|
||||
it('returns invalid for JSON with trailing comma', () => {
|
||||
const result = validateJson('{"key": "value",}')
|
||||
expect(result.valid).toBe(false)
|
||||
expect(result.error).toContain('Invalid JSON')
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatJson', () => {
|
||||
it('returns empty string unchanged', () => {
|
||||
expect(formatJson('')).toBe('')
|
||||
})
|
||||
|
||||
it('returns whitespace unchanged', () => {
|
||||
expect(formatJson(' ')).toBe(' ')
|
||||
})
|
||||
|
||||
it('formats compact JSON with indentation', () => {
|
||||
const result = formatJson('{"key":"value"}')
|
||||
expect(result).toBe('{\n "key": "value"\n}')
|
||||
})
|
||||
|
||||
it('formats nested JSON with proper indentation', () => {
|
||||
const result = formatJson('{"outer":{"inner":123}}')
|
||||
expect(result).toBe('{\n "outer": {\n "inner": 123\n }\n}')
|
||||
})
|
||||
|
||||
it('returns invalid JSON unchanged', () => {
|
||||
const invalid = '{"key": }'
|
||||
expect(formatJson(invalid)).toBe(invalid)
|
||||
})
|
||||
})
|
||||
Loading…
x
Reference in New Issue
Block a user