diff --git a/ui/src/i18n/en.json b/ui/src/i18n/en.json
index a980771ba..d581a07c5 100644
--- a/ui/src/i18n/en.json
+++ b/ui/src/i18n/en.json
@@ -347,7 +347,9 @@
"lastError": "Error",
"hasError": "Error",
"updatedAt": "Updated",
- "createdAt": "Installed"
+ "createdAt": "Installed",
+ "configKey": "Key",
+ "configValue": "Value"
},
"sections": {
"status": "Status",
@@ -362,7 +364,8 @@
"actions": {
"enable": "Enable",
"disable": "Disable",
- "disabledDueToError": "Fix the error before enabling"
+ "disabledDueToError": "Fix the error before enabling",
+ "addConfig": "Add Configuration"
},
"notifications": {
"enabled": "Plugin enabled",
@@ -374,8 +377,13 @@
"invalidJson": "Configuration must be valid JSON"
},
"messages": {
- "configHelp": "Enter plugin configuration as a JSON object. Leave empty if the plugin requires no configuration.",
- "clickPermissions": "Click a permission for details"
+ "configHelp": "Configure the plugin using key-value pairs. Leave empty if the plugin requires no configuration.",
+ "clickPermissions": "Click a permission for details",
+ "noConfig": "No configuration set"
+ },
+ "placeholders": {
+ "configKey": "key",
+ "configValue": "value"
}
}
},
diff --git a/ui/src/plugin/ConfigCard.jsx b/ui/src/plugin/ConfigCard.jsx
new file mode 100644
index 000000000..9d318be50
--- /dev/null
+++ b/ui/src/plugin/ConfigCard.jsx
@@ -0,0 +1,164 @@
+import React, { useCallback } from 'react'
+import {
+ Card,
+ CardContent,
+ Typography,
+ Box,
+ TextField as MuiTextField,
+ Table,
+ TableBody,
+ TableCell,
+ TableContainer,
+ TableHead,
+ TableRow,
+ IconButton,
+ Paper,
+ Button,
+} from '@material-ui/core'
+import { MdSave, MdDelete } from 'react-icons/md'
+
+export const ConfigCard = ({
+ configPairs,
+ onConfigPairsChange,
+ isDirty,
+ loading,
+ classes,
+ translate,
+ onSave,
+}) => {
+ const handleKeyChange = useCallback(
+ (index, newKey) => {
+ const newPairs = [...configPairs]
+ newPairs[index] = { ...newPairs[index], key: newKey }
+ onConfigPairsChange(newPairs)
+ },
+ [configPairs, onConfigPairsChange],
+ )
+
+ const handleValueChange = useCallback(
+ (index, newValue) => {
+ const newPairs = [...configPairs]
+ newPairs[index] = { ...newPairs[index], value: newValue }
+ onConfigPairsChange(newPairs)
+ },
+ [configPairs, onConfigPairsChange],
+ )
+
+ const handleDeleteRow = useCallback(
+ (index) => {
+ const newPairs = configPairs.filter((_, i) => i !== index)
+ onConfigPairsChange(newPairs)
+ },
+ [configPairs, onConfigPairsChange],
+ )
+
+ const handleAddRow = useCallback(() => {
+ onConfigPairsChange([...configPairs, { key: '', value: '' }])
+ }, [configPairs, onConfigPairsChange])
+
+ return (
+
+
+
+ {translate('resources.plugin.sections.configuration')}
+
+
+ {translate('resources.plugin.messages.configHelp')}
+
+
+
+
+
+
+
+ {translate('resources.plugin.fields.configKey')}
+
+
+ {translate('resources.plugin.fields.configValue')}
+
+
+
+ +
+
+
+
+
+
+ {configPairs.map((pair, index) => (
+
+
+ handleKeyChange(index, e.target.value)}
+ placeholder={translate(
+ 'resources.plugin.placeholders.configKey',
+ )}
+ InputProps={{
+ className: classes.configTableInput,
+ }}
+ />
+
+
+ handleValueChange(index, e.target.value)}
+ placeholder={translate(
+ 'resources.plugin.placeholders.configValue',
+ )}
+ InputProps={{
+ className: classes.configTableInput,
+ }}
+ />
+
+
+ handleDeleteRow(index)}
+ aria-label={translate('ra.action.delete')}
+ className={classes.configActionIconButton}
+ >
+
+
+
+
+ ))}
+ {configPairs.length === 0 && (
+
+
+
+ {translate('resources.plugin.messages.noConfig')}
+
+
+
+ )}
+
+
+
+
+
+ }
+ onClick={onSave}
+ disabled={!isDirty || loading}
+ className={classes.saveButton}
+ >
+ {translate('ra.action.save')}
+
+
+
+
+ )
+}
diff --git a/ui/src/plugin/ErrorSection.jsx b/ui/src/plugin/ErrorSection.jsx
new file mode 100644
index 000000000..61c048e2a
--- /dev/null
+++ b/ui/src/plugin/ErrorSection.jsx
@@ -0,0 +1,16 @@
+import React from 'react'
+import { Typography } from '@material-ui/core'
+import Alert from '@material-ui/lab/Alert'
+
+export const ErrorSection = ({ error, translate }) => {
+ if (!error) return null
+
+ return (
+
+
+ {translate('resources.plugin.fields.lastError')}
+
+ {error}
+
+ )
+}
diff --git a/ui/src/plugin/InfoCard.jsx b/ui/src/plugin/InfoCard.jsx
new file mode 100644
index 000000000..c57146958
--- /dev/null
+++ b/ui/src/plugin/InfoCard.jsx
@@ -0,0 +1,235 @@
+import React, { useState } from 'react'
+import {
+ Card,
+ CardContent,
+ Typography,
+ Grid,
+ Box,
+ Chip,
+ Tooltip,
+ Link,
+ ClickAwayListener,
+} from '@material-ui/core'
+import { DateField } from '../common'
+
+// Helper component for permission chips with clickable persistent tooltips
+const PermissionChip = ({ label, permission, classes }) => {
+ const [open, setOpen] = useState(false)
+
+ if (!permission) return null
+
+ const hasHosts = permission.allowedHosts?.length > 0
+ const hasTooltip = permission.reason || hasHosts
+
+ const handleClick = () => {
+ if (hasTooltip) {
+ setOpen((prev) => !prev)
+ }
+ }
+
+ const handleClose = () => {
+ setOpen(false)
+ }
+
+ const tooltipContent = (
+
+ {permission.reason && (
+ {permission.reason}
+ )}
+ {hasHosts && (
+
+
+ Allowed hosts:{' '}
+ {permission.allowedHosts.map((host, i) => (
+
+ {i > 0 && ', '}
+ {host}
+
+ ))}
+
+
+ )}
+
+ )
+
+ const chip = (
+
+ )
+
+ if (!hasTooltip) {
+ return chip
+ }
+
+ return (
+
+
+
+ {chip}
+
+
+
+ )
+}
+
+// Info row component for responsive grid
+const InfoRow = ({ label, children, classes, isSmall }) => (
+ <>
+
+
+ {label}
+
+
+
+
+ {children}
+
+
+ >
+)
+
+// Plugin information card
+export const InfoCard = ({ record, manifest, classes, translate, isSmall }) => (
+
+
+
+ {translate('resources.plugin.sections.info')}
+
+
+
+ {record.id}
+
+
+ {manifest?.name && (
+
+ {manifest.name}
+
+ )}
+
+ {manifest?.version && (
+
+ {manifest.version}
+
+ )}
+
+ {manifest?.description && (
+
+ {manifest.description}
+
+ )}
+
+ {manifest?.author && (
+
+ {manifest.author}
+
+ )}
+
+ {manifest?.website && (
+
+
+ {manifest.website}
+
+
+ )}
+
+ {manifest?.permissions &&
+ Object.keys(manifest.permissions).length > 0 && (
+
+
+ {Object.entries(manifest.permissions).map(([key, value]) => (
+
+ ))}
+
+
+ {translate('resources.plugin.messages.clickPermissions')}
+
+
+ )}
+
+
+ {record.path}
+
+
+
+
+
+
+
+
+
+
+
+
+)
diff --git a/ui/src/plugin/ManifestSection.jsx b/ui/src/plugin/ManifestSection.jsx
new file mode 100644
index 000000000..3fef65f70
--- /dev/null
+++ b/ui/src/plugin/ManifestSection.jsx
@@ -0,0 +1,24 @@
+import React from 'react'
+import {
+ Accordion,
+ AccordionSummary,
+ AccordionDetails,
+ Typography,
+ Box,
+} from '@material-ui/core'
+import { MdExpandMore } from 'react-icons/md'
+
+export const ManifestSection = ({ manifestJson, classes, translate }) => (
+
+ }>
+
+ {translate('resources.plugin.sections.manifest')}
+
+
+
+
+ {manifestJson}
+
+
+
+)
diff --git a/ui/src/plugin/PluginShow.jsx b/ui/src/plugin/PluginShow.jsx
index 8ddc76773..9f90df309 100644
--- a/ui/src/plugin/PluginShow.jsx
+++ b/ui/src/plugin/PluginShow.jsx
@@ -10,435 +10,77 @@ import {
Title as RaTitle,
Loading,
} from 'react-admin'
-import {
- Typography,
- Box,
- Card,
- CardContent,
- TextField as MuiTextField,
- Accordion,
- AccordionSummary,
- AccordionDetails,
- Chip,
- Tooltip,
- Link,
- Grid,
- useMediaQuery,
- Button,
- ClickAwayListener,
-} from '@material-ui/core'
+import { Box, useMediaQuery } from '@material-ui/core'
import Alert from '@material-ui/lab/Alert'
-import { makeStyles } from '@material-ui/core/styles'
-import { MdExpandMore, MdSave } from 'react-icons/md'
-import { Title, DateField } from '../common'
-import { validateJson } from './jsonValidation'
-import ToggleEnabledSwitch from './ToggleEnabledSwitch'
-
-const useStyles = makeStyles(
- (theme) => ({
- root: {
- padding: theme.spacing(2),
- maxWidth: 900,
- },
- section: {
- marginBottom: theme.spacing(3),
- },
- sectionTitle: {
- marginBottom: theme.spacing(1),
- fontWeight: 600,
- },
- 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',
- },
- saveButton: {
- marginTop: theme.spacing(2),
- },
- infoGrid: {
- '& .MuiGrid-item': {
- paddingTop: theme.spacing(0.5),
- paddingBottom: theme.spacing(0.5),
- },
- },
- infoLabel: {
- fontWeight: 500,
- color: theme.palette.text.secondary,
- },
- pathField: {
- fontFamily: 'monospace',
- fontSize: '0.85rem',
- wordBreak: 'break-all',
- },
- permissionsContainer: {
- display: 'flex',
- flexWrap: 'wrap',
- gap: theme.spacing(0.5),
- },
- permissionChip: {
- fontSize: '0.75rem',
- },
- tooltipContent: {
- '& code': {
- fontFamily: 'monospace',
- fontSize: '0.8em',
- backgroundColor: 'rgba(255,255,255,0.1)',
- padding: '1px 4px',
- borderRadius: 2,
- },
- },
- }),
- { name: 'NDPluginShow' },
-)
-
-// Helper component for permission chips with clickable persistent tooltips
-const PermissionChip = ({ label, permission, classes }) => {
- const [open, setOpen] = React.useState(false)
-
- if (!permission) return null
-
- const hasHosts = permission.allowedHosts?.length > 0
- const hasTooltip = permission.reason || hasHosts
-
- const handleClick = () => {
- if (hasTooltip) {
- setOpen((prev) => !prev)
- }
- }
-
- const handleClose = () => {
- setOpen(false)
- }
-
- const tooltipContent = (
-
- {permission.reason && (
- {permission.reason}
- )}
- {hasHosts && (
-
-
- Allowed hosts:{' '}
- {permission.allowedHosts.map((host, i) => (
-
- {i > 0 && ', '}
- {host}
-
- ))}
-
-
- )}
-
- )
-
- const chip = (
-
- )
-
- if (!hasTooltip) {
- return chip
- }
-
- return (
-
-
-
- {chip}
-
-
-
- )
-}
-
-// Info row component for responsive grid
-const InfoRow = ({ label, children, classes, isSmall }) => (
- <>
-
-
- {label}
-
-
-
-
- {children}
-
-
- >
-)
-
-// Error display section
-const ErrorSection = ({ error, translate }) => {
- if (!error) return null
-
- return (
-
-
- {translate('resources.plugin.fields.lastError')}
-
- {error}
-
- )
-}
-
-// Status card with enable/disable toggle
-const StatusCard = ({ classes, translate }) => {
- return (
-
-
-
- {translate('resources.plugin.sections.status')}
-
-
-
-
- )
-}
-
-// Plugin information card
-const InfoCard = ({ record, manifest, classes, translate, isSmall }) => (
-
-
-
- {translate('resources.plugin.sections.info')}
-
-
-
- {record.id}
-
-
- {manifest?.name && (
-
- {manifest.name}
-
- )}
-
- {manifest?.version && (
-
- {manifest.version}
-
- )}
-
- {manifest?.description && (
-
- {manifest.description}
-
- )}
-
- {manifest?.author && (
-
- {manifest.author}
-
- )}
-
- {manifest?.website && (
-
-
- {manifest.website}
-
-
- )}
-
- {manifest?.permissions &&
- Object.keys(manifest.permissions).length > 0 && (
-
-
- {Object.entries(manifest.permissions).map(([key, value]) => (
-
- ))}
-
-
- {translate('resources.plugin.messages.clickPermissions')}
-
-
- )}
-
-
- {record.path}
-
-
-
-
-
-
-
-
-
-
-
-
-)
-
-// Manifest accordion
-const ManifestSection = ({ manifestJson, classes, translate }) => (
-
- }>
-
- {translate('resources.plugin.sections.manifest')}
-
-
-
-
- {manifestJson}
-
-
-
-)
-
-// Configuration editor card
-const ConfigCard = ({
- config,
- configError,
- isDirty,
- loading,
- classes,
- translate,
- onConfigChange,
- onSave,
-}) => (
-
-
-
- {translate('resources.plugin.sections.configuration')}
-
-
- {translate('resources.plugin.messages.configHelp')}
-
-
- }
- onClick={onSave}
- disabled={!isDirty || !!configError || loading}
- className={classes.saveButton}
- >
- {translate('ra.action.save')}
-
-
-
-)
+import { Title } from '../common'
+import { usePluginShowStyles } from './styles.js'
+import { ErrorSection } from './ErrorSection'
+import { StatusCard } from './StatusCard'
+import { InfoCard } from './InfoCard'
+import { ManifestSection } from './ManifestSection'
+import { ConfigCard } from './ConfigCard'
// Main show layout component
const PluginShowLayout = () => {
const { record, isPending, error } = useShowContext()
- const classes = useStyles()
+ const classes = usePluginShowStyles()
const translate = useTranslate()
const notify = useNotify()
const refresh = useRefresh()
const isSmall = useMediaQuery((theme) => theme.breakpoints.down('xs'))
- const [config, setConfig] = useState('')
- const [configError, setConfigError] = useState(null)
+ const [configPairs, setConfigPairs] = useState([])
const [isDirty, setIsDirty] = useState(false)
const [configInitialized, setConfigInitialized] = useState(false)
+ // Convert JSON config to key-value pairs
+ const jsonToPairs = useCallback((jsonString) => {
+ if (!jsonString || jsonString.trim() === '') return []
+ try {
+ const obj = JSON.parse(jsonString)
+ return Object.entries(obj).map(([key, value]) => ({
+ key,
+ value: typeof value === 'string' ? value : JSON.stringify(value),
+ }))
+ } catch {
+ return []
+ }
+ }, [])
+
+ // Convert key-value pairs to JSON config
+ const pairsToJson = useCallback((pairs) => {
+ if (pairs.length === 0) return ''
+ const obj = {}
+ pairs.forEach((pair) => {
+ if (pair.key.trim()) {
+ // Try to parse value as JSON, otherwise use as string
+ try {
+ obj[pair.key] = JSON.parse(pair.value)
+ } catch {
+ obj[pair.key] = pair.value
+ }
+ }
+ })
+ return JSON.stringify(obj)
+ }, [])
+
// Initialize config when record loads
React.useEffect(() => {
if (record && !configInitialized) {
- setConfig(record.config || '')
+ setConfigPairs(jsonToPairs(record.config || ''))
setConfigInitialized(true)
}
- }, [record, configInitialized])
+ }, [record, configInitialized, jsonToPairs])
+
+ const handleConfigPairsChange = useCallback(
+ (newPairs) => {
+ setConfigPairs(newPairs)
+ const newJson = pairsToJson(newPairs)
+ const originalJson = record?.config || ''
+ setIsDirty(newJson !== originalJson)
+ },
+ [record?.config, pairsToJson],
+ )
const [updatePlugin, { loading }] = useUpdate(
'plugin',
@@ -450,6 +92,7 @@ const PluginShowLayout = () => {
onSuccess: () => {
refresh()
setIsDirty(false)
+ setConfigInitialized(false) // Reset to reinitialize from server
notify('resources.plugin.notifications.updated', 'info')
},
onFailure: (err) => {
@@ -461,29 +104,11 @@ const PluginShowLayout = () => {
},
)
- 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 || !record) {
- notify('resources.plugin.validation.invalidJson', 'warning')
- return
- }
+ if (!record) return
+ const config = pairsToJson(configPairs)
updatePlugin('plugin', record.id, { config }, record)
- }, [updatePlugin, record, config, configError, notify])
+ }, [updatePlugin, record, configPairs, pairsToJson])
// Parse manifest
const { manifest, manifestJson } = useMemo(() => {
@@ -542,13 +167,12 @@ const PluginShowLayout = () => {
/>
diff --git a/ui/src/plugin/StatusCard.jsx b/ui/src/plugin/StatusCard.jsx
new file mode 100644
index 000000000..9be1456c3
--- /dev/null
+++ b/ui/src/plugin/StatusCard.jsx
@@ -0,0 +1,16 @@
+import React from 'react'
+import { Card, CardContent, Typography } from '@material-ui/core'
+import ToggleEnabledSwitch from './ToggleEnabledSwitch'
+
+export const StatusCard = ({ classes, translate }) => {
+ return (
+
+
+
+ {translate('resources.plugin.sections.status')}
+
+
+
+
+ )
+}
diff --git a/ui/src/plugin/styles.js b/ui/src/plugin/styles.js
new file mode 100644
index 000000000..104d8bc0f
--- /dev/null
+++ b/ui/src/plugin/styles.js
@@ -0,0 +1,85 @@
+import { makeStyles } from '@material-ui/core/styles'
+
+export const usePluginShowStyles = makeStyles(
+ (theme) => ({
+ root: {
+ padding: theme.spacing(2),
+ maxWidth: 900,
+ },
+ section: {
+ marginBottom: theme.spacing(3),
+ },
+ sectionTitle: {
+ marginBottom: theme.spacing(1),
+ fontWeight: 600,
+ },
+ 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,
+ },
+ saveButton: {
+ marginTop: theme.spacing(2),
+ },
+ infoGrid: {
+ '& .MuiGrid-item': {
+ paddingTop: theme.spacing(0.5),
+ paddingBottom: theme.spacing(0.5),
+ },
+ },
+ infoLabel: {
+ fontWeight: 500,
+ color: theme.palette.text.secondary,
+ },
+ pathField: {
+ fontFamily: 'monospace',
+ fontSize: '0.85rem',
+ wordBreak: 'break-all',
+ },
+ permissionsContainer: {
+ display: 'flex',
+ flexWrap: 'wrap',
+ gap: theme.spacing(0.5),
+ },
+ permissionChip: {
+ fontSize: '0.75rem',
+ },
+ tooltipContent: {
+ '& code': {
+ fontFamily: 'monospace',
+ fontSize: '0.8em',
+ backgroundColor: 'rgba(255,255,255,0.1)',
+ padding: '1px 4px',
+ borderRadius: 2,
+ },
+ },
+ configTable: {
+ '& .MuiTableCell-root': {
+ padding: theme.spacing(1),
+ },
+ },
+ configTableInput: {
+ fontFamily: 'monospace',
+ fontSize: '0.85rem',
+ },
+ configActionIconButton: {
+ backgroundColor: theme.palette.action.hover,
+ borderRadius: theme.shape.borderRadius,
+ padding: theme.spacing(0.5, 1),
+ fontWeight: 700,
+ '&:hover': {
+ backgroundColor: theme.palette.action.selected,
+ },
+ },
+ }),
+ { name: 'NDPluginShow' },
+)