From be5a47964ba1c04d51173c8a5d3fefb61d6fab45 Mon Sep 17 00:00:00 2001 From: Deluan Date: Sun, 28 Dec 2025 15:58:01 -0500 Subject: [PATCH] feat(plugins): implement configuration management UI with key-value pairs support Signed-off-by: Deluan --- ui/src/i18n/en.json | 16 +- ui/src/plugin/ConfigCard.jsx | 164 ++++++++++ ui/src/plugin/ErrorSection.jsx | 16 + ui/src/plugin/InfoCard.jsx | 235 ++++++++++++++ ui/src/plugin/ManifestSection.jsx | 24 ++ ui/src/plugin/PluginShow.jsx | 494 ++++-------------------------- ui/src/plugin/StatusCard.jsx | 16 + ui/src/plugin/styles.js | 85 +++++ 8 files changed, 611 insertions(+), 439 deletions(-) create mode 100644 ui/src/plugin/ConfigCard.jsx create mode 100644 ui/src/plugin/ErrorSection.jsx create mode 100644 ui/src/plugin/InfoCard.jsx create mode 100644 ui/src/plugin/ManifestSection.jsx create mode 100644 ui/src/plugin/StatusCard.jsx create mode 100644 ui/src/plugin/styles.js 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')} + + + + )} + +
+
+ + + + +
+
+ ) +} 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')} - - - - - -) +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' }, +)