diff --git a/plugins/manager.go b/plugins/manager.go
index 573906ca1..55d699113 100644
--- a/plugins/manager.go
+++ b/plugins/manager.go
@@ -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
}
diff --git a/server/nativeapi/plugin.go b/server/nativeapi/plugin.go
index 1636ce44c..243e692b2 100644
--- a/server/nativeapi/plugin.go
+++ b/server/nativeapi/plugin.go
@@ -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)
}
}
diff --git a/server/serve_index.go b/server/serve_index.go
index 38e646982..d70bf1d84 100644
--- a/server/serve_index.go
+++ b/server/serve_index.go
@@ -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)
diff --git a/ui/src/App.jsx b/ui/src/App.jsx
index dc4fe9b53..2dbe72421 100644
--- a/ui/src/App.jsx
+++ b/ui/src/App.jsx
@@ -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 ? (
+
+ ) : null,
,
,
diff --git a/ui/src/config.js b/ui/src/config.js
index a53a97de7..9582e95ee 100644
--- a/ui/src/config.js
+++ b/ui/src/config.js
@@ -38,6 +38,7 @@ const defaultConfig = {
publicBaseUrl: '/share',
separator: '/',
enableInspect: true,
+ pluginsEnabled: true,
}
let config
diff --git a/ui/src/i18n/en.json b/ui/src/i18n/en.json
index 9ef65d668..1f6086a20 100644
--- a/ui/src/i18n/en.json
+++ b/ui/src/i18n/en.json
@@ -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": {
diff --git a/ui/src/plugin/PluginList.jsx b/ui/src/plugin/PluginList.jsx
new file mode 100644
index 000000000..9afafd53a
--- /dev/null
+++ b/ui/src/plugin/PluginList.jsx
@@ -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 (
+
+
+
+
+
+ )
+}
+
+const ErrorIndicator = () => {
+ const record = useRecordContext()
+ const translate = useTranslate()
+ const classes = useStyles()
+
+ if (!record.lastError) {
+ return null
+ }
+
+ return (
+
+ }
+ label={translate('resources.plugin.fields.hasError')}
+ className={classes.errorChip}
+ />
+
+ )
+}
+
+const VersionField = () => {
+ const record = useRecordContext()
+
+ if (!record.manifest) {
+ return null
+ }
+
+ try {
+ const manifest = JSON.parse(record.manifest)
+ return {manifest.version || '-'}
+ } catch {
+ return -
+ }
+}
+
+const DescriptionField = () => {
+ const record = useRecordContext()
+
+ if (!record.manifest) {
+ return null
+ }
+
+ try {
+ const manifest = JSON.parse(record.manifest)
+ return {manifest.description || '-'}
+ } catch {
+ return -
+ }
+}
+
+const PluginList = (props) => {
+ const isXsmall = useMediaQuery((theme) => theme.breakpoints.down('xs'))
+ const translate = useTranslate()
+
+ const toggleableFields = useMemo(
+ () => ({
+ description: !isXsmall && (
+ }
+ />
+ ),
+ version: (
+ }
+ />
+ ),
+ enabled: (
+ }
+ />
+ ),
+ error: (
+ }
+ />
+ ),
+ }),
+ [isXsmall],
+ )
+
+ return (
+
+ {isXsmall ? (
+ 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"
+ />
+ ) : (
+
+
+ {toggleableFields['description']}
+ {toggleableFields['version']}
+ {toggleableFields['enabled']}
+ {toggleableFields['error']}
+
+
+ )}
+
+ )
+}
+
+export default PluginList
diff --git a/ui/src/plugin/PluginList.test.jsx b/ui/src/plugin/PluginList.test.jsx
new file mode 100644
index 000000000..be61d4609
--- /dev/null
+++ b/ui/src/plugin/PluginList.test.jsx
@@ -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 }) => (
+ {render && render()}
+ ),
+ Datagrid: ({ children }) => (
+
+ ),
+ TextField: ({ source }) => ,
+ }
+})
+
+// Mock common components
+vi.mock('../common', async () => {
+ return {
+ List: ({ children, ...props }) => (
+
+ {children}
+
+ ),
+ DateField: ({ source }) => ,
+ SimpleList: ({ primaryText, secondaryText }) => (
+
+ ),
+ }
+})
+
+// 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()
+ expect(screen.getByTestId('list')).toBeInTheDocument()
+ })
+
+ it('renders the datagrid on desktop', () => {
+ render()
+ expect(screen.getByTestId('datagrid')).toBeInTheDocument()
+ })
+})
diff --git a/ui/src/plugin/PluginShow.jsx b/ui/src/plugin/PluginShow.jsx
new file mode 100644
index 000000000..f683aab60
--- /dev/null
+++ b/ui/src/plugin/PluginShow.jsx
@@ -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 (
+
+ )
+}
+
+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 (
+
+ {/* Error Section */}
+ {record.lastError && (
+
+
+
+
+ {translate('resources.plugin.fields.lastError')}
+
+ {record.lastError}
+
+
+ )}
+
+ {/* Status and Enable/Disable */}
+
+
+
+ {translate('resources.plugin.sections.status')}
+
+
+
+ {record.enabled ? (
+
+
+ {translate('resources.plugin.status.enabled')}
+
+ ) : (
+
+ {translate('resources.plugin.status.disabled')}
+
+ )}
+
+
+ }
+ label={translate(
+ record.enabled
+ ? 'resources.plugin.actions.disable'
+ : 'resources.plugin.actions.enable',
+ )}
+ labelPlacement="start"
+ />
+
+
+
+
+ {/* Plugin Info */}
+
+
+
+ {translate('resources.plugin.sections.info')}
+
+
+ - {translate('resources.plugin.fields.name')}
+ - {record.id}
+
+ {manifest?.version && (
+ <>
+ - {translate('resources.plugin.fields.version')}
+ - {manifest.version}
+ >
+ )}
+
+ {manifest?.description && (
+ <>
+ - {translate('resources.plugin.fields.description')}
+ - {manifest.description}
+ >
+ )}
+
+ - {translate('resources.plugin.fields.path')}
+ - {record.path}
+
+ - {translate('resources.plugin.fields.updatedAt')}
+ -
+
+
+
+ - {translate('resources.plugin.fields.createdAt')}
+ -
+
+
+
+
+
+
+ {/* Configuration */}
+
+
+
+ {translate('resources.plugin.sections.configuration')}
+
+
+ {translate('resources.plugin.messages.configHelp')}
+
+
+
+
+
+
+
+
+ {/* Manifest */}
+
+ }>
+
+ {translate('resources.plugin.sections.manifest')}
+
+
+
+
+ {manifestJson}
+
+
+
+
+ )
+}
+
+const PluginShow = (props) => {
+ return (
+ } actions={false} {...props}>
+
+
+
+
+ )
+}
+
+export default PluginShow
diff --git a/ui/src/plugin/index.js b/ui/src/plugin/index.js
new file mode 100644
index 000000000..2385308cc
--- /dev/null
+++ b/ui/src/plugin/index.js
@@ -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,
+}
diff --git a/ui/src/plugin/jsonValidation.js b/ui/src/plugin/jsonValidation.js
new file mode 100644
index 000000000..408d6dac5
--- /dev/null
+++ b/ui/src/plugin/jsonValidation.js
@@ -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
+ }
+}
diff --git a/ui/src/plugin/jsonValidation.test.js b/ui/src/plugin/jsonValidation.test.js
new file mode 100644
index 000000000..f56549d79
--- /dev/null
+++ b/ui/src/plugin/jsonValidation.test.js
@@ -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)
+ })
+})