diff --git a/ui/src/i18n/en.json b/ui/src/i18n/en.json index 35e886601..4774787f7 100644 --- a/ui/src/i18n/en.json +++ b/ui/src/i18n/en.json @@ -373,7 +373,8 @@ "invalidJson": "Configuration must be valid JSON" }, "messages": { - "configHelp": "Enter plugin configuration as a JSON object. Leave empty if the plugin requires no configuration." + "configHelp": "Enter plugin configuration as a JSON object. Leave empty if the plugin requires no configuration.", + "clickPermissions": "Click a permission for details" } } }, diff --git a/ui/src/plugin/PluginList.jsx b/ui/src/plugin/PluginList.jsx index 4b3f5c29e..614fda503 100644 --- a/ui/src/plugin/PluginList.jsx +++ b/ui/src/plugin/PluginList.jsx @@ -162,6 +162,7 @@ const PluginList = (props) => { ) : ( + {!isXsmall && } diff --git a/ui/src/plugin/PluginShow.jsx b/ui/src/plugin/PluginShow.jsx index 30c90aa06..c05d09ab6 100644 --- a/ui/src/plugin/PluginShow.jsx +++ b/ui/src/plugin/PluginShow.jsx @@ -1,15 +1,14 @@ -import React, { useState, useCallback } from 'react' +import React, { useState, useCallback, useMemo } from 'react' import { - Show, - SimpleShowLayout, - TextField, + ShowContextProvider, + useShowController, + useShowContext, useTranslate, useUpdate, useNotify, useRefresh, - useRecordContext, - Toolbar, - SaveButton, + Title as RaTitle, + Loading, } from 'react-admin' import { Typography, @@ -25,112 +24,120 @@ import { Chip, Tooltip, Link, + Grid, + useMediaQuery, + Button, + ClickAwayListener, } from '@material-ui/core' +import Alert from '@material-ui/lab/Alert' import { makeStyles } from '@material-ui/core/styles' -import { MdExpandMore, MdError } from 'react-icons/md' +import { MdExpandMore, MdSave } 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', - }, - 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': { +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, }, - '& dd': { - margin: 0, - }, - }, - 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': { + pathField: { fontFamily: 'monospace', - fontSize: '0.8em', - backgroundColor: 'rgba(255,255,255,0.1)', - padding: '1px 4px', - borderRadius: 2, + 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' }, +) -const PermissionChip = ({ label, permission }) => { - const classes = useStyles() +// 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}} + {permission.reason && ( + {permission.reason} + )} {hasHosts && ( - Allowed hosts: {permission.allowedHosts.map((host, i) => ( - {i > 0 && ', '}{host} + Allowed hosts:{' '} + {permission.allowedHosts.map((host, i) => ( + + {i > 0 && ', '} + {host} + ))} @@ -138,41 +145,334 @@ const PermissionChip = ({ label, permission }) => { ) - const hasTooltip = permission.reason || hasHosts - const chip = ( ) - return hasTooltip ? ( - - {chip} - - ) : chip -} + if (!hasTooltip) { + return chip + } -const PluginTitle = ({ record }) => { - const translate = useTranslate() - const resourceName = translate('resources.plugin.name', { smart_count: 1 }) return ( - + <ClickAwayListener onClickAway={handleClose}> + <div> + <Tooltip + title={tooltipContent} + arrow + open={open} + disableFocusListener + disableHoverListener + disableTouchListener + PopperProps={{ + disablePortal: true, + }} + > + {chip} + </Tooltip> + </div> + </ClickAwayListener> ) } -const PluginShowContent = () => { - const record = useRecordContext() +// Info row component for responsive grid +const InfoRow = ({ label, children, classes, isSmall }) => ( + <> + <Grid item xs={12} sm={4}> + <Typography + variant="body2" + className={classes.infoLabel} + component={isSmall ? 'div' : 'span'} + > + {label} + </Typography> + </Grid> + <Grid item xs={12} sm={8}> + <Typography variant="body2" component="div"> + {children} + </Typography> + </Grid> + </> +) + +// Error display section +const ErrorSection = ({ error, translate }) => { + if (!error) return null + + return ( + <Alert severity="error" style={{ marginBottom: 16 }}> + <Typography variant="subtitle2"> + {translate('resources.plugin.fields.lastError')} + </Typography> + <Typography variant="body2">{error}</Typography> + </Alert> + ) +} + +// Status card with enable/disable toggle +const StatusCard = ({ record, classes, translate, onToggle, loading }) => ( + <Card className={classes.section}> + <CardContent> + <Typography variant="h6" className={classes.sectionTitle}> + {translate('resources.plugin.sections.status')} + </Typography> + <FormControlLabel + control={ + <Switch + checked={record.enabled} + onChange={onToggle} + disabled={loading} + color="primary" + /> + } + label={translate( + record.enabled + ? 'resources.plugin.actions.disable' + : 'resources.plugin.actions.enable', + )} + /> + </CardContent> + </Card> +) + +// Plugin information card +const InfoCard = ({ record, manifest, classes, translate, isSmall }) => ( + <Card className={classes.section}> + <CardContent> + <Typography variant="h6" className={classes.sectionTitle}> + {translate('resources.plugin.sections.info')} + </Typography> + <Grid container spacing={1} className={classes.infoGrid}> + <InfoRow + label={translate('resources.plugin.fields.id')} + classes={classes} + isSmall={isSmall} + > + {record.id} + </InfoRow> + + {manifest?.name && ( + <InfoRow + label={translate('resources.plugin.fields.name')} + classes={classes} + isSmall={isSmall} + > + {manifest.name} + </InfoRow> + )} + + {manifest?.version && ( + <InfoRow + label={translate('resources.plugin.fields.version')} + classes={classes} + isSmall={isSmall} + > + {manifest.version} + </InfoRow> + )} + + {manifest?.description && ( + <InfoRow + label={translate('resources.plugin.fields.description')} + classes={classes} + isSmall={isSmall} + > + {manifest.description} + </InfoRow> + )} + + {manifest?.author && ( + <InfoRow + label={translate('resources.plugin.fields.author')} + classes={classes} + isSmall={isSmall} + > + {manifest.author} + </InfoRow> + )} + + {manifest?.website && ( + <InfoRow + label={translate('resources.plugin.fields.website')} + classes={classes} + isSmall={isSmall} + > + <Link + href={manifest.website} + target="_blank" + rel="noopener noreferrer" + > + {manifest.website} + </Link> + </InfoRow> + )} + + {manifest?.permissions && ( + <InfoRow + label={translate('resources.plugin.fields.permissions')} + classes={classes} + isSmall={isSmall} + > + <Box className={classes.permissionsContainer}> + <PermissionChip + label="HTTP" + permission={manifest.permissions.http} + classes={classes} + /> + <PermissionChip + label="Subsonic API" + permission={manifest.permissions.subsonicapi} + classes={classes} + /> + <PermissionChip + label="Scheduler" + permission={manifest.permissions.scheduler} + classes={classes} + /> + <PermissionChip + label="WebSocket" + permission={manifest.permissions.websocket} + classes={classes} + /> + <PermissionChip + label="Artwork" + permission={manifest.permissions.artwork} + classes={classes} + /> + <PermissionChip + label="Cache" + permission={manifest.permissions.cache} + classes={classes} + /> + </Box> + <Typography + variant="caption" + color="textSecondary" + style={{ marginTop: 4, display: 'block' }} + > + {translate('resources.plugin.messages.clickPermissions')} + </Typography> + </InfoRow> + )} + + <InfoRow + label={translate('resources.plugin.fields.path')} + classes={classes} + isSmall={isSmall} + > + <span className={classes.pathField}>{record.path}</span> + </InfoRow> + + <InfoRow + label={translate('resources.plugin.fields.updatedAt')} + classes={classes} + isSmall={isSmall} + > + <DateField record={record} source="updatedAt" showTime /> + </InfoRow> + + <InfoRow + label={translate('resources.plugin.fields.createdAt')} + classes={classes} + isSmall={isSmall} + > + <DateField record={record} source="createdAt" showTime /> + </InfoRow> + </Grid> + </CardContent> + </Card> +) + +// Manifest accordion +const ManifestSection = ({ manifestJson, classes, translate }) => ( + <Accordion className={classes.section}> + <AccordionSummary expandIcon={<MdExpandMore />}> + <Typography variant="h6"> + {translate('resources.plugin.sections.manifest')} + </Typography> + </AccordionSummary> + <AccordionDetails> + <Box className={classes.manifestBox} width="100%"> + {manifestJson} + </Box> + </AccordionDetails> + </Accordion> +) + +// Configuration editor card +const ConfigCard = ({ + config, + configError, + isDirty, + loading, + classes, + translate, + onConfigChange, + onSave, +}) => ( + <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={onConfigChange} + error={!!configError} + helperText={configError} + placeholder="{}" + InputProps={{ + className: classes.configInput, + }} + /> + <Button + variant="contained" + color="primary" + startIcon={<MdSave />} + onClick={onSave} + disabled={!isDirty || !!configError || loading} + className={classes.saveButton} + > + {translate('ra.action.save')} + </Button> + </CardContent> + </Card> +) + +// Main show layout component +const PluginShowLayout = () => { + const { record, isPending, error } = useShowContext() const classes = useStyles() const translate = useTranslate() const notify = useNotify() const refresh = useRefresh() + const isSmall = useMediaQuery((theme) => theme.breakpoints.down('xs')) - const [config, setConfig] = useState(record?.config || '') + const [config, setConfig] = useState('') const [configError, setConfigError] = useState(null) const [isDirty, setIsDirty] = useState(false) + const [configInitialized, setConfigInitialized] = useState(false) + + // Initialize config when record loads + React.useEffect(() => { + if (record && !configInitialized) { + setConfig(record.config || '') + setConfigInitialized(true) + } + }, [record, configInitialized]) const [updatePlugin, { loading }] = useUpdate( 'plugin', @@ -186,9 +486,9 @@ const PluginShowContent = () => { setIsDirty(false) notify('resources.plugin.notifications.updated', 'info') }, - onFailure: (error) => { + onFailure: (err) => { notify( - error?.message || 'resources.plugin.notifications.error', + err?.message || 'resources.plugin.notifications.error', 'warning', ) }, @@ -196,6 +496,7 @@ const PluginShowContent = () => { ) const handleToggleEnabled = useCallback(() => { + if (!record) return updatePlugin('plugin', record.id, { enabled: !record.enabled }, record) }, [updatePlugin, record]) @@ -216,206 +517,96 @@ const PluginShowContent = () => { ) const handleSaveConfig = useCallback(() => { - if (configError) { + if (configError || !record) { notify('resources.plugin.validation.invalidJson', 'warning') return } updatePlugin('plugin', record.id, { config }, record) }, [updatePlugin, record, config, configError, notify]) + // Parse manifest + const { manifest, manifestJson } = useMemo(() => { + if (!record?.manifest) return { manifest: null, manifestJson: '' } + try { + const parsed = JSON.parse(record.manifest) + return { manifest: parsed, manifestJson: JSON.stringify(parsed, null, 2) } + } catch { + return { manifest: null, manifestJson: record.manifest } + } + }, [record?.manifest]) + + // Handle loading state + if (isPending) { + return <Loading /> + } + + // Handle error state + if (error) { + return ( + <Alert severity="error">{translate('ra.notification.http_error')}</Alert> + ) + } + + // Handle missing record 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 - Enable/Disable Switch Only */} - <Card className={classes.section}> - <CardContent> - <Typography variant="h6" className={classes.sectionTitle}> - {translate('resources.plugin.sections.status')} - </Typography> - <FormControlLabel - control={ - <Switch - checked={record.enabled} - onChange={handleToggleEnabled} - disabled={loading} - color="primary" - /> - } - label={translate( - record.enabled - ? 'resources.plugin.actions.disable' - : 'resources.plugin.actions.enable', - )} + <> + <RaTitle + title={ + <Title + subTitle={`${translate('resources.plugin.name', { smart_count: 1 })} "${record.id}"`} /> - </CardContent> - </Card> + } + /> + <Box className={classes.root}> + <ErrorSection error={record.lastError} translate={translate} /> - {/* 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.id')}</dt> - <dd>{record.id}</dd> + <StatusCard + record={record} + classes={classes} + translate={translate} + onToggle={handleToggleEnabled} + loading={loading} + /> - {manifest?.name && ( - <> - <dt>{translate('resources.plugin.fields.name')}</dt> - <dd>{manifest.name}</dd> - </> - )} + <InfoCard + record={record} + manifest={manifest} + classes={classes} + translate={translate} + isSmall={isSmall} + /> - {manifest?.version && ( - <> - <dt>{translate('resources.plugin.fields.version')}</dt> - <dd>{manifest.version}</dd> - </> - )} + <ManifestSection + manifestJson={manifestJson} + classes={classes} + translate={translate} + /> - {manifest?.description && ( - <> - <dt>{translate('resources.plugin.fields.description')}</dt> - <dd>{manifest.description}</dd> - </> - )} - - {manifest?.author && ( - <> - <dt>{translate('resources.plugin.fields.author')}</dt> - <dd>{manifest.author}</dd> - </> - )} - - {manifest?.website && ( - <> - <dt>{translate('resources.plugin.fields.website')}</dt> - <dd> - <Link - href={manifest.website} - target="_blank" - rel="noopener noreferrer" - > - {manifest.website} - </Link> - </dd> - </> - )} - - {manifest?.permissions && ( - <> - <dt>{translate('resources.plugin.fields.permissions')}</dt> - <dd className={classes.permissionsContainer}> - <PermissionChip label="HTTP" permission={manifest.permissions.http} /> - <PermissionChip label="Subsonic API" permission={manifest.permissions.subsonicapi} /> - <PermissionChip label="Scheduler" permission={manifest.permissions.scheduler} /> - <PermissionChip label="WebSocket" permission={manifest.permissions.websocket} /> - <PermissionChip label="Artwork" permission={manifest.permissions.artwork} /> - <PermissionChip label="Cache" permission={manifest.permissions.cache} /> - </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> - - {/* Manifest (Collapsible) */} - <Accordion className={classes.section}> - <AccordionSummary expandIcon={<MdExpandMore />}> - <Typography variant="h6"> - {translate('resources.plugin.sections.manifest')} - </Typography> - </AccordionSummary> - <AccordionDetails> - <Box className={classes.manifestBox} width="100%"> - {manifestJson} - </Box> - </AccordionDetails> - </Accordion> - - {/* 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> - </Box> + <ConfigCard + config={config} + configError={configError} + isDirty={isDirty} + loading={loading} + classes={classes} + translate={translate} + onConfigChange={handleConfigChange} + onSave={handleSaveConfig} + /> + </Box> + </> ) } const PluginShow = (props) => { + const controllerProps = useShowController(props) return ( - <Show title={<PluginTitle />} actions={false} {...props}> - <SimpleShowLayout> - <PluginShowContent /> - </SimpleShowLayout> - </Show> + <ShowContextProvider value={controllerProps}> + <PluginShowLayout /> + </ShowContextProvider> ) }