import { useMemo, useState, useEffect, useCallback } from 'react' import { Button, IconButton, Tooltip, Divider, List, ListItemButton, ListItem, Switch, Box, useMediaQuery, } from '@mui/material' import { Power, PowerOff, DeleteForever, Edit, ModeComment, AddBox, CheckBox, CheckBoxOutlineBlank, ChevronRight, ExpandMore, Favorite, People, Delete, PersonAdd, Refresh, } from '@mui/icons-material' import { DndContext, closestCenter, PointerSensor, useSensor, useSensors, DragEndEvent, } from '@dnd-kit/core' import { SortableContext, verticalListSortingStrategy, useSortable, arrayMove, } from '@dnd-kit/sortable' import { CSS } from '@dnd-kit/utilities' import P3xrAccordion from '../../components/P3xrAccordion' import P3xrButton from '../../components/P3xrButton' import ConnectionDialog from '../../dialogs/ConnectionDialog' import AiSettingsDialog from '../../dialogs/AiSettingsDialog' import AclUserDialog from '../../dialogs/AclUserDialog' import TreeSettingsDialog from '../../dialogs/TreeSettingsDialog' import { switchGui } from '../../../core/gui-switch' 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 { useMainCommandStore } from '../../stores/main-command.store' import { request, onSocketEvent } from '../../stores/socket.service' import { getPersistentItem, setPersistentItem } from '../../stores/electron-bridge' import { isNotificationsEnabled, setNotificationsEnabled } from '../../stores/notification' // --- Sortable connection row (whole row is draggable) --- function SortableConnectionItem({ conn, isLast, children }: { conn: any, isLast: boolean, children: React.ReactNode }) { const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: conn.id }) return ( <> {children} {!isLast && !isDragging && } ) } // --- Sortable group block (group header is drag handle, whole block moves) --- function SortableGroupBlock({ group, children }: { group: { name: string; connections: any[] }, children: React.ReactNode }) { const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: `group-${group.name}` }) return ( {/* Group header is the drag handle */} {children} ) } export default function SettingsPage() { const strings = useI18nStore(s => s.strings) const connection = useRedisStateStore(s => s.connection) const connections = useRedisStateStore(s => s.connections) const cfg = useRedisStateStore(s => s.cfg) const redisConnections = useRedisStateStore(s => s.redisConnections) const settings = useSettingsStore() const { toast, confirm, generalHandleError } = useCommonStore() const { disconnect } = useMainCommandStore() const isXs = useMediaQuery('(max-width: 599px)') const connectionsList = connections?.list ?? [] const readonlyConnections = cfg?.readonlyConnections === true const currentConnectionId = connection?.id const sensors = useSensors( useSensor(PointerSensor, { activationConstraint: { distance: 5 } }) ) // Group mode const [groupModeEnabled, setGroupModeEnabled] = useState(() => { return getPersistentItem('p3xr-connection-group-mode') === 'true' }) const [collapsedGroups, setCollapsedGroups] = useState>(() => { try { const stored = getPersistentItem('p3xr-collapsed-connection-groups') return stored ? new Set(JSON.parse(stored)) : new Set() } catch { return new Set() } }) // Force re-render on socket events const [, setTick] = useState(0) useEffect(() => { const unsubs = [ onSocketEvent('connections', () => setTick(t => t + 1)), onSocketEvent('configuration', () => setTick(t => t + 1)), onSocketEvent('redis-status', () => setTick(t => t + 1)), ] return () => unsubs.forEach(fn => fn()) }, []) const toggleGroupMode = () => { const next = !groupModeEnabled setGroupModeEnabled(next) setPersistentItem('p3xr-connection-group-mode', String(next)) } const toggleGroup = (name: string) => { setCollapsedGroups(prev => { const next = new Set(prev) next.has(name) ? next.delete(name) : next.add(name) setPersistentItem('p3xr-collapsed-connection-groups', JSON.stringify([...next])) return next }) } // Grouped connections const groupedConnections = useMemo(() => { const groups = new Map() for (const conn of connectionsList) { const name = conn.group?.trim() || '' if (!groups.has(name)) groups.set(name, []) groups.get(name)!.push(conn) } return Array.from(groups, ([name, conns]) => ({ name, connections: conns })) }, [connectionsList]) const getConnectionClients = useCallback((conn: any) => { const results: { key: string; clients: number }[] = [] for (const key of Object.keys(redisConnections || {})) { if (redisConnections[key]?.connection?.name === conn.name) { results.push({ key, clients: redisConnections[key].clients?.length || 0 }) } } return results }, [redisConnections]) // --- Drag & drop handlers --- const handleDragEndFlat = async (event: DragEndEvent) => { const { active, over } = event if (!over || active.id === over.id) return const oldIndex = connectionsList.findIndex((c: any) => c.id === active.id) const newIndex = connectionsList.findIndex((c: any) => c.id === over.id) if (oldIndex === -1 || newIndex === -1) return const reordered = arrayMove(connectionsList, oldIndex, newIndex) try { await request({ action: 'connection/reorder', payload: { ids: reordered.map((c: any) => c.id) } }) } catch (e) { generalHandleError(e) } } // Reorder groups themselves const handleDragEndGroupReorder = async (event: DragEndEvent) => { const { active, over } = event if (!over || active.id === over.id) return const oldIndex = groupedConnections.findIndex(g => `group-${g.name}` === active.id) const newIndex = groupedConnections.findIndex(g => `group-${g.name}` === over.id) if (oldIndex === -1 || newIndex === -1) return const reordered = arrayMove(groupedConnections, oldIndex, newIndex) // Flatten all connections in new group order and persist const allIds: string[] = [] for (const group of reordered) { for (const conn of group.connections) { allIds.push(conn.id) } } try { await request({ action: 'connection/reorder', payload: { ids: allIds } }) } catch (e) { generalHandleError(e) } } // Reorder connections within a group const handleDragEndGroup = async (event: DragEndEvent, groupName: string) => { const { active, over } = event if (!over || active.id === over.id) return const group = groupedConnections.find(g => g.name === groupName) if (!group) return const oldIndex = group.connections.findIndex((c: any) => c.id === active.id) const newIndex = group.connections.findIndex((c: any) => c.id === over.id) if (oldIndex === -1 || newIndex === -1) return const reordered = arrayMove(group.connections, oldIndex, newIndex) try { await request({ action: 'connection/reorder', payload: { group: groupName || undefined, ids: reordered.map((c: any) => c.id) } }) } catch (e) { generalHandleError(e) } } // Dialog states const [dialogOpen, setDialogOpen] = useState(false) const [dialogType, setDialogType] = useState<'new' | 'edit'>('new') const [dialogModel, setDialogModel] = useState(undefined) const [aiDialogOpen, setAiDialogOpen] = useState(false) const [treeDialogOpen, setTreeDialogOpen] = useState(false) const [notifToggle, setNotifToggle] = useState(isNotificationsEnabled) const [aclUsers, setAclUsers] = useState(null) const [aclCurrentUser, setAclCurrentUser] = useState('') const [aclLoading, setAclLoading] = useState(false) const [aclEditOpen, setAclEditOpen] = useState(false) const [aclEditUsername, setAclEditUsername] = useState('') const [aclEditRules, setAclEditRules] = useState('') const [aclEditIsNew, setAclEditIsNew] = useState(false) const currentConnectionName = useMemo(() => connectionsList.find((c: any) => c.id === currentConnectionId)?.name || '', [connectionsList, currentConnectionId]) const loadAclUsers = useCallback(async () => { setAclLoading(true) try { const resp = await request({ action: 'acl/list' }) setAclUsers(resp.data.users) setAclCurrentUser(resp.data.currentUser) } catch { setAclUsers(null) } setAclLoading(false) }, []) useEffect(() => { if (currentConnectionId) { loadAclUsers() } else { setAclUsers(null); setAclCurrentUser('') } }, [currentConnectionId, loadAclUsers]) const handleConnect = async (conn: any) => { const cloned = structuredClone(conn) const overlay = useOverlayStore.getState() try { const dbStorageKey = settings.getStorageKeyCurrentDatabase(cloned.id) let db: string | undefined try { db = localStorage.getItem(dbStorageKey) ?? undefined } catch {} overlay.show({ message: strings?.title?.connectingRedis }) const response = await request({ action: 'connection/connect', payload: { connection: cloned, db }, }) const databaseIndexes: number[] = [] let i = 0 while (i < response.databases) databaseIndexes.push(i++) const commands: string[] = [] Object.keys(response.commands ?? {}).forEach(k => commands.push(response.commands[k][0])) commands.sort() const modules = Array.isArray(response.modules) ? response.modules : [] useRedisStateStore.setState({ page: 1, monitor: false, dbsize: response.dbsize, databaseIndexes, connection: cloned, commands, commandsMeta: response.commandsMeta ?? {}, modules, hasReJSON: modules.some((m: any) => m.name === 'ReJSON'), hasRediSearch: modules.some((m: any) => m.name === 'search'), hasTimeSeries: modules.some((m: any) => m.name === 'timeseries' || m.name === 'Timeseries'), }) useCommonStore.getState().loadRedisInfoResponse({ response }) try { localStorage.setItem(settings.connectInfoStorageKey, JSON.stringify(cloned)) } catch {} } catch (error: any) { try { localStorage.removeItem(settings.connectInfoStorageKey) } catch {} useRedisStateStore.setState({ connection: undefined }) generalHandleError(error) } finally { overlay.hide() } } const handleDelete = async (conn: any) => { try { await confirm({ message: strings?.confirm?.deleteConnectionText }) await request({ action: 'connection/delete', payload: { id: conn.id } }) toast(strings?.status?.deleted) } catch (e: any) { if (e !== undefined) generalHandleError(e) } } const handleConnectionForm = (formType: 'new' | 'edit', formModel?: any) => { setDialogType(formType) setDialogModel(formModel) setDialogOpen(true) } // AI settings const isAiEnabled = cfg?.aiEnabled !== false const hasGroqApiKey = cfg?.hasGroqApiKey === true const isUseOwnKey = cfg?.aiUseOwnKey === true && hasGroqApiKey const isAiReadonly = readonlyConnections || cfg?.groqApiKeyReadonly === true const toggleAiEnabled = async (enabled: boolean) => { try { await request({ action: 'ai/set-groq-api-key', payload: { aiEnabled: enabled } }) useRedisStateStore.setState({ cfg: { ...cfg, aiEnabled: enabled } }) } catch (e) { generalHandleError(e) } } const toggleUseOwnKey = async (useOwn: boolean) => { if (useOwn && !hasGroqApiKey) return try { await request({ action: 'ai/set-groq-api-key', payload: { aiEnabled: isAiEnabled, aiUseOwnKey: useOwn } }) useRedisStateStore.setState({ cfg: { ...cfg, aiUseOwnKey: useOwn } }) } catch (e) { generalHandleError(e) } } // --- Responsive action button (filled/contained, tooltip only when icon-only) --- const ActionBtn = ({ icon, label, color, onClick }: { icon: React.ReactNode, label: string, color: 'primary' | 'secondary' | 'error', onClick: () => void }) => isXs ? ( ) : ( ) // --- Connection info + buttons (shared between flat and grouped) --- const ConnectionButtons = ({ conn }: { conn: any }) => ( <> {conn.name} {conn.host}:{conn.port} {getConnectionClients(conn).map(entry => { const fn = strings?.page?.overview?.connectedCount return typeof fn === 'function' ? fn({ length: entry.clients }) : '' }).join(' ')}  {currentConnectionId !== conn.id ? ( } label={strings?.intention?.connect} color="secondary" onClick={() => handleConnect(conn)} /> ) : ( } label={strings?.intention?.disconnect} color="secondary" onClick={() => disconnect()} /> )} {!readonlyConnections && ( <> } label={strings?.intention?.delete} color="error" onClick={() => handleDelete(conn)} /> } label={strings?.intention?.edit} color="primary" onClick={() => handleConnectionForm('edit', conn)} /> )} {readonlyConnections && ( } label={strings?.intention?.view} color="primary" onClick={() => handleConnectionForm('edit', conn)} /> )} ) return ( <> {/* === Donate === */} } onClick={() => window.open('https://www.paypal.me/patrikx3', '_blank')} /> } > {strings?.title?.donateDescription}
{/* === Connections === */} : } onClick={toggleGroupMode} /> {!readonlyConnections && ( } onClick={() => handleConnectionForm('new')} /> )} }> {connectionsList.length === 0 && ( {strings?.intention?.noConnectionsInSettings} )} {/* Grouped mode: groups are draggable + connections within each group are draggable */} {connectionsList.length > 0 && groupModeEnabled && ( `group-${g.name}`)} strategy={verticalListSortingStrategy}> {groupedConnections.map(group => ( toggleGroup(group.name)} sx={(theme) => ({ display: 'flex', alignItems: 'center', gap: 1, px: 2, py: 1, cursor: 'grab', fontWeight: 700, fontSize: 13, userSelect: 'none', opacity: 0.8, bgcolor: theme.palette.mode === 'dark' ? 'rgba(255,255,255,0.05)' : 'rgba(0,0,0,0.04)', borderBottom: 1, borderColor: theme.palette.mode === 'dark' ? 'rgba(255,255,255,0.05)' : 'rgba(0,0,0,0.06)', '&:hover': { opacity: 1, bgcolor: 'action.hover', }, })} > {collapsedGroups.has(group.name) ? : } {group.name || strings?.label?.ungrouped} ({group.connections.length}) {!collapsedGroups.has(group.name) && ( handleDragEndGroup(e, group.name)}> c.id)} strategy={verticalListSortingStrategy}> {group.connections.map((conn: any, i: number) => ( ))} )} ))} )} {/* Flat mode with drag & drop */} {connectionsList.length > 0 && !groupModeEnabled && ( c.id)} strategy={verticalListSortingStrategy}> {connectionsList.map((conn: any, i: number) => ( ))} )} {currentConnectionId && <>
{/* === ACL Users === */} } label={strings?.intention?.refresh || 'Refresh'} color="inherit" onClick={(e) => { e.stopPropagation(); loadAclUsers() }} /> {!readonlyConnections && } label={strings?.page?.acl?.createUser || 'Create User'} color="inherit" onClick={(e) => { e.stopPropagation() setAclEditIsNew(true); setAclEditUsername(''); setAclEditRules('on >password +@all ~* &*'); setAclEditOpen(true) }} />} }> {aclLoading ? {strings?.page?.acl?.loading || 'Loading...'} : !aclUsers ? {strings?.page?.acl?.noUsers || 'ACL requires Redis 6.0+.'} : {aclUsers.map((user, idx) => ( { setAclEditIsNew(false); setAclEditUsername(user.name) setAclEditRules(user.raw.split(' ').slice(2).join(' ')); setAclEditOpen(true) } : undefined}> {user.name} {user.name === aclCurrentUser && ({strings?.page?.acl?.currentUser || 'Current'})} {!user.enabled && ( warning )} {!readonlyConnections && ( e.stopPropagation()} sx={{ display: 'flex', gap: 0.5 }}> {user.name !== 'default' && user.name !== aclCurrentUser && ( } label={strings?.page?.acl?.deleteUser || 'Delete'} color="error" onClick={async () => { try { await confirm({ message: `${strings?.page?.acl?.confirmDelete || 'Are you sure to delete ACL user'} "${user.name}"?` }) await request({ action: 'acl/del-user', payload: { username: user.name } }) toast(strings?.page?.acl?.userDeleted || 'ACL user deleted') loadAclUsers() } catch {} }} /> )} } label={strings?.page?.acl?.editUser || 'Edit'} color="primary" onClick={() => { setAclEditIsNew(false); setAclEditUsername(user.name) setAclEditRules(user.raw.split(' ').slice(2).join(' ')); setAclEditOpen(true) }} /> )} {idx < aclUsers.length - 1 && } ))} } { setAclEditOpen(false) if (result) { try { await request({ action: 'acl/set-user', payload: { username: result.username, rules: result.rules } }) toast(strings?.page?.acl?.userSaved || 'ACL user saved') loadAclUsers() } catch (err) { generalHandleError(err) } } }} /> }
{/* === GUI Framework === */} switchGui('ng')} sx={{ px: 1.5, py: 1, cursor: 'pointer', fontWeight: 500, fontSize: 14, userSelect: 'none', display: 'inline-flex', alignItems: 'center', color: 'text.primary', '&:hover': { bgcolor: 'action.hover' }, }}> Angular React switchGui('vue')} sx={{ px: 1.5, py: 1, cursor: 'pointer', fontWeight: 500, fontSize: 14, userSelect: 'none', display: 'inline-flex', alignItems: 'center', color: 'text.primary', '&:hover': { bgcolor: 'action.hover' }, }}> Vue
{/* === AI Settings === */} } onClick={() => setAiDialogOpen(true)} /> ) : undefined }> {strings?.label?.aiEnabled} toggleAiEnabled(checked)} /> {isAiEnabled && hasGroqApiKey && ( <> {strings?.label?.aiRouteViaNetwork} toggleUseOwnKey(!checked)} /> {isUseOwnKey ? strings?.label?.aiRoutingDirect : strings?.label?.aiRoutingNetwork} {!isUseOwnKey && ( <> console.groq.com )} {strings?.label?.aiGroqApiKey} {cfg?.groqApiKeyMasked} )}
{/* === Notifications === */} {strings?.label?.desktopNotificationsEnabled || 'Enable desktop notifications'} { setNotificationsEnabled(checked); setNotifToggle(checked) }} /> {strings?.label?.desktopNotificationsInfo || 'Receive OS notifications for Redis disconnections and reconnections when the app is not focused.'}
{/* === Tree/Redis Settings === */} } onClick={() => setTreeDialogOpen(true)} /> }> {[ { label: strings?.form?.treeSettings?.field?.treeSeparator, value: settings.redisTreeDivider || strings?.label?.treeSeparatorEmptyNote }, { label: strings?.form?.treeSettings?.field?.page, value: settings.pageCount, hint: strings?.form?.treeSettings?.error?.page }, { label: strings?.form?.treeSettings?.field?.keyPageCount, value: settings.keyPageCount, hint: strings?.form?.treeSettings?.error?.keyPageCount }, { label: strings?.form?.treeSettings?.maxValueDisplay, value: settings.maxValueDisplay, hint: strings?.form?.treeSettings?.maxValueDisplayInfo }, { label: strings?.form?.treeSettings?.maxKeys, value: settings.maxKeys, hint: strings?.form?.treeSettings?.maxKeysInfo }, { label: strings?.form?.treeSettings?.field?.keysSort, value: settings.keysSort ? strings?.label?.keysSort?.on : strings?.label?.keysSort?.off }, { label: strings?.form?.treeSettings?.field?.searchMode, value: settings.searchClientSide ? strings?.form?.treeSettings?.label?.searchModeClient : strings?.form?.treeSettings?.label?.searchModeServer }, { label: strings?.form?.treeSettings?.field?.searchModeStartsWith, value: settings.searchStartsWith ? strings?.form?.treeSettings?.label?.searchModeStartsWith : strings?.form?.treeSettings?.label?.searchModeIncludes }, { label: null, value: settings.jsonFormat === 2 ? strings?.form?.treeSettings?.label?.jsonFormatTwoSpace : strings?.form?.treeSettings?.label?.jsonFormatFourSpace }, { label: null, value: settings.animation ? strings?.form?.treeSettings?.label?.animation : strings?.form?.treeSettings?.label?.noAnimation }, { label: null, value: settings.undoEnabled ? (strings?.form?.treeSettings?.label?.undoEnabled || 'Undo enabled') : (strings?.form?.treeSettings?.label?.undoDisabled || 'Undo disabled'), hint: strings?.form?.treeSettings?.undoHint || 'Undo is available for string and JSON key types only' }, { label: null, value: settings.showDiffBeforeSave ? (strings?.form?.treeSettings?.label?.diffEnabled || 'Show diff before saving') : (strings?.form?.treeSettings?.label?.diffDisabled || 'Diff before save disabled') }, ].map((item, i, arr) => ( setTreeDialogOpen(true)} sx={{ px: 2, py: 1 }}> {item.label && {item.label}} {String(item.value)} {item.hint && {item.hint}} {i < arr.length - 1 && } ))} {/* Dialogs */} setDialogOpen(false)} /> setAiDialogOpen(false)} /> setTreeDialogOpen(false)} /> ) }