RSS Git Download  Clone
Raw Blame History 18kB 309 lines
import { useState, useMemo, useEffect } from 'react'
import {
    TextField, IconButton, Button, Switch, FormControlLabel,
    Autocomplete, Box, Tooltip, useMediaQuery,
} from '@mui/material'
import {
    Done, Cancel, Add, Delete, Visibility, VisibilityOff, Save,
} from '@mui/icons-material'
import { useI18nStore } from '../stores/i18n.store'
import { useRedisStateStore } from '../stores/redis-state.store'
import { useSettingsStore } from '../stores/settings.store'
import { useCommonStore } from '../stores/common.store'
import { useOverlayStore } from '../stores/overlay.store'
import { request } from '../stores/socket.service'
import P3xrDialog from '../components/P3xrDialog'

interface ConnectionDialogProps {
    open: boolean
    type: 'new' | 'edit'
    model?: any
    onClose: () => void
}

function initModel(type: string, source?: any): any {
    let model: any
    if (source) {
        model = structuredClone(source)
        model.password = source.id
        model.tlsCrt = source.id
        model.tlsKey = source.id
        model.tlsCa = source.id
        model.sshPassword = source.id
        model.sshPrivateKey = source.id
    } else {
        model = {
            name: '', host: '', port: 6379, askAuth: false,
            password: '', username: '', id: undefined, group: '',
            readonly: false, tlsWithoutCert: false, tlsRejectUnauthorized: false,
            tlsCrt: '', tlsKey: '', tlsCa: '',
        }
    }
    if (!model.ssh) {
        model.ssh = false; model.sshHost = model.sshHost || ''
        model.sshPort = model.sshPort || 22; model.sshUsername = model.sshUsername || ''
        model.sshPassword = model.sshPassword || source?.id || ''
        model.sshPrivateKey = model.sshPrivateKey || source?.id || ''
    }
    if (!model.cluster) model.cluster = false
    if (!model.sentinel) model.sentinel = false
    if (!model.nodes) model.nodes = []
    for (const node of model.nodes) { node.password = node.id }
    return model
}

export default function ConnectionDialog({ open, type, model: sourceModel, onClose }: ConnectionDialogProps) {
    const strings = useI18nStore(s => s.strings)
    const cfg = useRedisStateStore(s => s.cfg)
    const connectionsList = useRedisStateStore(s => s.connections)?.list ?? []
    const { generateId } = useSettingsStore()
    const { toast, generalHandleError } = useCommonStore()
    const overlay = useOverlayStore()
    const isWide = useMediaQuery('(min-width: 600px)')
    const readonlyConnections = cfg?.readonlyConnections === true

    const [model, setModel] = useState(() => initModel(type, sourceModel))
    const [pwVisible, setPwVisible] = useState(false)
    const [sshPwVisible, setSshPwVisible] = useState(false)
    const [nodePwVisible, setNodePwVisible] = useState<Record<number, boolean>>({})

    useEffect(() => {
        if (open) {
            setModel(initModel(type, sourceModel))
            setPwVisible(false); setSshPwVisible(false); setNodePwVisible({})
        }
    }, [open, type, sourceModel])

    const existingGroups = useMemo(() => {
        const groups = new Set<string>()
        for (const conn of connectionsList) {
            if (conn.group?.trim()) groups.add(conn.group.trim())
        }
        return [...groups].sort()
    }, [connectionsList])

    const set = (field: string, value: any) => setModel((m: any) => ({ ...m, [field]: value }))
    const setNode = (idx: number, field: string, value: any) => setModel((m: any) => {
        const nodes = [...m.nodes]
        nodes[idx] = { ...nodes[idx], [field]: value }
        return { ...m, nodes }
    })

    const addNode = (index?: number) => {
        const newNode = { host: '', port: undefined, password: '', username: '', id: generateId() }
        setModel((m: any) => {
            const nodes = [...m.nodes]
            if (index === undefined) nodes.push(newNode); else nodes.splice(index + 1, 0, newNode)
            return { ...m, nodes }
        })
    }

    const removeNode = async (idx: number) => {
        try {
            await useCommonStore.getState().confirm({ message: strings?.confirm?.deleteConnectionText })
            setModel((m: any) => ({ ...m, nodes: m.nodes.filter((_: any, i: number) => i !== idx) }))
            toast(strings?.status?.nodeRemoved)
        } catch {}
    }

    const validateForm = (): boolean => {
        if (!model.name?.trim()) {
            toast(strings?.form?.error?.invalid)
            return false
        }
        if (model.ssh) {
            if (!model.sshHost?.trim() || !model.sshUsername?.trim()) {
                toast(strings?.form?.error?.invalid)
                return false
            }
        }
        if (model.sentinel && !model.sentinelName?.trim()) {
            toast(strings?.form?.error?.invalid)
            return false
        }
        return true
    }

    const testConnection = async () => {
        if (!validateForm()) return
        try {
            const authModel = structuredClone(model)
            if (model.askAuth === true) {
                try {
                    const auth = await useCommonStore.getState().askAuth()
                    authModel.username = auth.username || undefined
                    authModel.password = auth.password || undefined
                } catch {
                    return // user cancelled
                }
            }
            overlay.show({ message: strings?.title?.connectingRedis })
            await request({ action: 'connection/test', payload: { model: authModel } })
            toast(strings?.status?.redisConnected)
        } catch (e) { generalHandleError(e) }
        finally { overlay.hide() }
    }

    const submit = async () => {
        if (!validateForm()) return
        const saveModel = structuredClone(model)
        if (!saveModel.host) saveModel.host = 'localhost'
        if (!saveModel.port) saveModel.port = 6379
        if (type === 'new') saveModel.id = generateId()
        for (const node of saveModel.nodes) {
            if (!node.host) node.host = 'localhost'
            if (!node.id) node.id = generateId()
        }
        if (typeof saveModel.group === 'string') saveModel.group = saveModel.group.trim() || undefined
        try {
            await request({ action: 'connection/save', payload: { model: saveModel } })
            toast(type === 'new' ? strings?.status?.added : strings?.status?.saved)
            onClose()
        } catch (e) { generalHandleError(e) }
    }

    const title = readonlyConnections ? strings?.label?.connectiondView
        : type === 'new' ? strings?.label?.connectiondAdd : strings?.label?.connectiondEdit

    const PasswordField = ({ label, value, onChange, visible, onToggle, disabled }: any) => (
        <TextField fullWidth margin="dense" label={label}
            type={visible ? 'text' : 'password'} value={value || ''} onChange={(e: any) => onChange(e.target.value)}
            disabled={disabled} autoComplete="off"
            slotProps={{ input: { endAdornment: !disabled && (
                <IconButton onClick={onToggle} size="small">
                    {visible ? <VisibilityOff fontSize="small" /> : <Visibility fontSize="small" />}
                </IconButton>
            )}}}
        />
    )

    if (!open) return null

    return (
        <P3xrDialog open onClose={onClose} title={title}
            actions={
                <>
                    {isWide ? (
                        <Button variant="contained" color="error" size="small" onClick={onClose}>
                            <Cancel fontSize="small" /><span style={{ marginLeft: 3 }}>{strings?.intention?.cancel}</span>
                        </Button>
                    ) : (
                        <Tooltip title={strings?.intention?.cancel} placement="top">
                            <Button variant="contained" color="error" size="small" onClick={onClose}
                                sx={{ minWidth: 40, width: 40, height: 36, p: 0 }}>
                                <Cancel fontSize="small" />
                            </Button>
                        </Tooltip>
                    )}
                    <Button variant="contained" color="success" size="small" onClick={testConnection}>
                        <i className="fas fa-plug" /><span style={{ marginLeft: 3 }}>{strings?.intention?.testConnection}</span>
                    </Button>
                    {!readonlyConnections && (
                        <Button variant="contained" color="primary" size="small" onClick={submit}>
                            {type === 'new' ? <Add fontSize="small" /> : <Save fontSize="small" />}
                            <span style={{ marginLeft: 3 }}>{type === 'new' ? strings?.intention?.add : strings?.intention?.save}</span>
                        </Button>
                    )}
                </>
            }>
            {model.id && type !== 'new' && (
                <>
                    <TextField fullWidth margin="dense" label={strings?.label?.id?.id} value={model.id} disabled />
                    <Box sx={{ fontSize: 12, opacity: 0.7, mb: 1 }}>{strings?.label?.id?.info}</Box>
                </>
            )}
            <TextField fullWidth margin="dense" label={strings?.form?.connection?.label?.name}
                required value={model.name || ''} onChange={e => set('name', e.target.value)} disabled={readonlyConnections} />
            <Autocomplete freeSolo options={existingGroups} value={model.group || ''}
                onInputChange={(_, v) => set('group', v)} disabled={readonlyConnections}
                renderInput={params => <TextField {...params} fullWidth margin="dense" label={strings?.form?.connection?.label?.group} />}
            />

            {/* SSH */}
            <FormControlLabel control={<Switch checked={!!model.ssh} onChange={(_, v) => set('ssh', v)} disabled={readonlyConnections} />}
                label={model.ssh ? strings?.label?.ssh?.on : strings?.label?.ssh?.off} />
            {model.ssh && (
                <Box component="fieldset" sx={{ border: 1, borderColor: 'divider', borderRadius: 1, p: 2, mt: 1 }}>
                    <legend style={{ fontWeight: 700 }}>SSH</legend>
                    <TextField fullWidth margin="dense" label={strings?.label?.ssh?.sshHost} required value={model.sshHost || ''} onChange={e => set('sshHost', e.target.value)} disabled={readonlyConnections} />
                    <TextField fullWidth margin="dense" label={strings?.label?.ssh?.sshPort} required type="number" value={model.sshPort || ''} onChange={e => set('sshPort', Number(e.target.value))} disabled={readonlyConnections} slotProps={{ htmlInput: { min: 1, max: 65535 } }} />
                    <TextField fullWidth margin="dense" label={strings?.label?.ssh?.sshUsername} required value={model.sshUsername || ''} onChange={e => set('sshUsername', e.target.value)} disabled={readonlyConnections} />
                    <PasswordField label={strings?.label?.ssh?.sshPassword} value={model.sshPassword} onChange={(v: string) => set('sshPassword', v)} visible={sshPwVisible} onToggle={() => setSshPwVisible(!sshPwVisible)} disabled={readonlyConnections} />
                    <Box sx={{ fontSize: 12, opacity: 0.7 }}>{strings?.label?.passwordSecure}</Box>
                    <TextField fullWidth margin="dense" label={strings?.label?.ssh?.sshPrivateKey} multiline minRows={1} value={model.sshPrivateKey || ''} onChange={e => set('sshPrivateKey', e.target.value)} disabled={readonlyConnections} autoComplete="off" />
                    <Box sx={{ fontSize: 12, opacity: 0.7 }}>{strings?.label?.secureFeature}</Box>
                </Box>
            )}

            {/* Node 1 */}
            <Box component="fieldset" sx={{ border: 1, borderColor: 'divider', borderRadius: 1, p: 2, mt: 2 }}>
                <legend style={{ fontWeight: 700 }}>Node 1</legend>
                <TextField fullWidth margin="dense" label={strings?.form?.connection?.label?.host} value={model.host || ''} onChange={e => set('host', e.target.value)} disabled={readonlyConnections} />
                <TextField fullWidth margin="dense" label={strings?.form?.connection?.label?.port} type="number" value={model.port || ''} onChange={e => set('port', Number(e.target.value))} disabled={readonlyConnections} slotProps={{ htmlInput: { min: 1, max: 65535 } }} />
                <FormControlLabel control={<Switch checked={!!model.askAuth} onChange={(_, v) => { set('askAuth', v); if (v) { set('username', ''); set('password', '') } }} disabled={readonlyConnections} />} label={strings?.label?.askAuth} />
                <Box sx={{ fontSize: 12, opacity: 0.7, mb: 0.5 }}>{strings?.label?.aclAuthHint}</Box>
                {!model.askAuth && (<>
                    <TextField fullWidth margin="dense" label={strings?.form?.connection?.label?.username} value={model.username || ''} onChange={e => set('username', e.target.value)} disabled={readonlyConnections} autoComplete="off" />
                    <PasswordField label={strings?.form?.connection?.label?.password} value={model.password} onChange={(v: string) => set('password', v)} visible={pwVisible} onToggle={() => setPwVisible(!pwVisible)} disabled={readonlyConnections} />
                    <Box sx={{ fontSize: 12, opacity: 0.7 }}>{strings?.label?.passwordSecure}</Box>
                </>)}
            </Box>

            {/* Readonly */}
            <FormControlLabel sx={{ mt: 1 }} control={<Switch checked={!!model.readonly} onChange={(_, v) => set('readonly', v)} disabled={readonlyConnections} />}
                label={model.readonly ? strings?.label?.readonly?.on : strings?.label?.readonly?.off} />

            {/* Cluster / Sentinel */}
            <Box sx={{ display: 'flex', alignItems: 'center', mt: 1, gap: 2, flexWrap: 'wrap' }}>
                <FormControlLabel control={<Switch checked={!!model.cluster} onChange={(_, v) => { set('cluster', v); if (v) set('sentinel', false) }} disabled={readonlyConnections} />}
                    label={model.cluster ? strings?.label?.cluster?.on : strings?.label?.cluster?.off} />
                <FormControlLabel control={<Switch checked={!!model.sentinel} onChange={(_, v) => { set('sentinel', v); if (v) set('cluster', false) }} disabled={readonlyConnections} />}
                    label={model.sentinel ? strings?.label?.sentinel?.on : strings?.label?.sentinel?.off} />
                <Box sx={{ flex: 1 }} />
                {(model.cluster || model.sentinel) && !readonlyConnections && (
                    <Button variant="contained" color="primary" size="small" onClick={() => addNode()}>
                        <Add fontSize="small" /><span>{strings?.label?.addNode}</span>
                    </Button>
                )}
            </Box>

            {model.sentinel && (
                <TextField fullWidth margin="dense" label={strings?.label?.sentinel?.name} required value={model.sentinelName || ''} onChange={e => set('sentinelName', e.target.value)} disabled={readonlyConnections} />
            )}

            {/* Dynamic nodes */}
            {(model.cluster || model.sentinel) && model.nodes.map((node: any, idx: number) => (
                <Box key={node.id || idx} component="fieldset" sx={{ border: 1, borderColor: 'divider', borderRadius: 1, p: 2, mt: 2 }}>
                    <legend style={{ fontWeight: 700 }}>Node {idx + 2}</legend>
                    {!readonlyConnections && (
                        <Box sx={{ float: 'right', display: 'flex', gap: 0.5 }}>
                            <Tooltip title={strings?.confirm?.deleteConnectionText}><Button variant="contained" color="error" size="small" sx={{ minWidth: 40, p: 0.5 }} onClick={() => removeNode(idx)}><Delete fontSize="small" /></Button></Tooltip>
                            <Tooltip title={strings?.label?.addNode}><Button variant="contained" color="primary" size="small" sx={{ minWidth: 40, p: 0.5 }} onClick={() => addNode(idx)}><Add fontSize="small" /></Button></Tooltip>
                        </Box>
                    )}
                    {node.id && (<><TextField fullWidth margin="dense" label={strings?.label?.id?.nodeId} value={node.id} disabled /><Box sx={{ fontSize: 12, opacity: 0.7, mb: 1 }}>{strings?.label?.id?.info}</Box></>)}
                    <TextField fullWidth margin="dense" label={strings?.form?.connection?.label?.host} value={node.host || ''} onChange={e => setNode(idx, 'host', e.target.value)} disabled={readonlyConnections} />
                    <TextField fullWidth margin="dense" label={strings?.form?.connection?.label?.port} type="number" required value={node.port || ''} onChange={e => setNode(idx, 'port', Number(e.target.value))} disabled={readonlyConnections} slotProps={{ htmlInput: { min: 1, max: 65535 } }} />
                    <TextField fullWidth margin="dense" label={strings?.form?.connection?.label?.username} value={node.username || ''} onChange={e => setNode(idx, 'username', e.target.value)} disabled={readonlyConnections} autoComplete="off" />
                    <PasswordField label={strings?.form?.connection?.label?.password} value={node.password} onChange={(v: string) => setNode(idx, 'password', v)} visible={!!nodePwVisible[idx]} onToggle={() => setNodePwVisible(p => ({ ...p, [idx]: !p[idx] }))} disabled={readonlyConnections} />
                    <Box sx={{ fontSize: 12, opacity: 0.7 }}>{strings?.label?.passwordSecure}</Box>
                </Box>
            ))}

            {/* TLS */}
            <Box sx={{ display: 'flex', gap: 2, mt: 2, flexWrap: 'wrap' }}>
                <FormControlLabel control={<Switch checked={!!model.tlsWithoutCert} onChange={(_, v) => set('tlsWithoutCert', v)} disabled={readonlyConnections} />} label={strings?.label?.tlsWithoutCert} />
                <FormControlLabel control={<Switch checked={!!model.tlsRejectUnauthorized} onChange={(_, v) => set('tlsRejectUnauthorized', v)} disabled={readonlyConnections} />} label={strings?.label?.tlsRejectUnauthorized} />
            </Box>
            {!model.tlsWithoutCert && (
                <Box component="fieldset" sx={{ border: 1, borderColor: 'divider', borderRadius: 1, p: 2, mt: 1 }}>
                    <legend style={{ fontWeight: 700 }}>TLS</legend>
                    {[{ label: 'TLS (redis.crt)', field: 'tlsCrt' }, { label: 'TLS (redis.key)', field: 'tlsKey' }, { label: 'TLS (ca.crt)', field: 'tlsCa' }].map(({ label, field }) => (
                        <Box key={field}><TextField fullWidth margin="dense" label={label} multiline minRows={1} value={(model as any)[field] || ''} onChange={e => set(field, e.target.value)} disabled={readonlyConnections} autoComplete="off" /><Box sx={{ fontSize: 12, opacity: 0.7, mb: 1 }}>{strings?.label?.tlsSecure}</Box></Box>
                    ))}
                </Box>
            )}
        </P3xrDialog>
    )
}