import { Component, Inject, OnInit, OnDestroy, ChangeDetectorRef, ChangeDetectionStrategy } from '@angular/core'; import { FormsModule } from '@angular/forms'; import { MatToolbarModule } from '@angular/material/toolbar'; import { MatButtonModule } from '@angular/material/button'; import { MatIconModule } from '@angular/material/icon'; import { MatListModule } from '@angular/material/list'; import { MatSlideToggleModule } from '@angular/material/slide-toggle'; import { MatTooltipModule } from '@angular/material/tooltip'; import { MatDividerModule } from '@angular/material/divider'; import { AclUserDialogService } from '../dialogs/acl-user-dialog.service'; import { BreakpointObserver } from '@angular/cdk/layout'; import { CdkDragDrop, DragDropModule, moveItemInArray } from '@angular/cdk/drag-drop'; import { I18nService } from '../services/i18n.service'; import { NotificationService } from '../services/notification.service'; import { SettingsService } from '../services/settings.service'; import { RedisStateService } from '../services/redis-state.service'; import { CommonService } from '../services/common.service'; import { SocketService } from '../services/socket.service'; import { MainCommandService } from '../services/main-command.service'; import { ConnectionDialogService } from '../dialogs/connection-dialog.service'; import { TreecontrolSettingsDialogService } from '../dialogs/treecontrol-settings-dialog.service'; import { AiSettingsDialogService } from '../dialogs/ai-settings-dialog.service'; import { P3xrAccordionComponent } from '../components/p3xr-accordion.component'; import { P3xrButtonComponent } from '../components/p3xr-button.component'; import { switchGui } from '../../core/gui-switch'; /** * Settings page — Angular replacement for AngularJS p3xrSettings. * First complete Angular page migration. * * Contains: * - Connections list (add/edit/delete/connect/disconnect) * - License info panel * - Tree settings panel */ @Component({ selector: 'p3xr-ng-settings', standalone: true, imports: [ FormsModule, MatToolbarModule, MatButtonModule, MatIconModule, MatListModule, MatSlideToggleModule, MatTooltipModule, MatDividerModule, DragDropModule, P3xrAccordionComponent, P3xrButtonComponent, ], template: `
{{ strings().title?.donateDescription }}
@if (isPromoDomain) {
{{ strings().promo?.description }}
{{ strings().promo?.disclaimer }}
}
@if (!readonlyConnections) { }
@if (connectionsList.length === 0) {
{{ strings().intention?.noConnectionsInSettings }}
} @if (connectionsList.length > 0) {
@if (groupModeEnabled) {
@for (group of groupedConnections; track group.name) {
{{ collapsedGroups.has(group.name) ? 'chevron_right' : 'expand_more' }} {{ getGroupDisplayName(group.name) }} ({{ group.connections.length }})
@if (!collapsedGroups.has(group.name)) {
@for (connection of group.connections; track connection.id; let last = $last) {
{{ connection.name }}
{{ connection.host }}:{{ connection.port }}
@for (entry of getConnectionClients(connection); track entry.key) { {{ strings().page?.overview?.connectedCount?.({ length: entry.clients }) }} }  
@if (currentConnectionId !== connection.id) { @if (isXs) { } @else { } } @if (currentConnectionId === connection.id) { @if (isXs) { } @else { } } @if (!readonlyConnections) { @if (isXs) { } @else { } @if (isXs) { } @else { } } @if (readonlyConnections) { @if (isXs) { } @else { } }
@if (!last) { } }
}
}
} @if (!groupModeEnabled) {
@for (connection of connectionsList; track connection.id; let last = $last) {
{{ connection.name }}
{{ connection.host }}:{{ connection.port }}
@for (entry of getConnectionClients(connection); track entry.key) { {{ strings().page?.overview?.connectedCount?.({ length: entry.clients }) }} }  
@if (currentConnectionId !== connection.id) { @if (isXs) { } @else { } } @if (currentConnectionId === connection.id) { @if (isXs) { } @else { } } @if (!readonlyConnections) { @if (isXs) { } @else { } @if (isXs) { } @else { } } @if (readonlyConnections) { @if (isXs) { } @else { } }
@if (!last) { } }
}
}
@if (currentConnectionId) {
@if (!readonlyConnections) { }
@if (aclLoading) {
{{ strings().page?.acl?.loading }}
} @else if (!aclUsers) {
{{ strings().page?.acl?.noUsers }}
} @else {
@for (user of aclUsers; track user.name; let last = $last) {
{{ user.name }} @if (user.name === aclCurrentUser) { ({{ strings().page?.acl?.currentUser }}) }
@if (!user.enabled) { warning } @if (!readonlyConnections) { @if (user.name !== 'default' && user.name !== aclCurrentUser) { @if (isXs) { } @else { } } @if (isXs) { } @else { } }
@if (!last) { } }
}
}
Angular React Vue

@if (!readonlyConnections && !isGroqApiKeyReadonly()) { }
{{ strings().label?.aiEnabled }}
@if (isAiEnabled() && hasGroqApiKey()) {
{{ strings().label?.aiRouteViaNetwork }}
{{ isUseOwnKey() ? (strings().label?.aiRoutingDirect) : (strings().label?.aiRoutingNetwork) }} @if (!isUseOwnKey()) { console.groq.com }
{{ strings().label?.aiGroqApiKey }}
{{ state.cfg()?.groqApiKeyMasked }}
}

{{ strings().label?.desktopNotificationsEnabled }}
{{ strings().label?.desktopNotificationsInfo }}

{{ strings().form?.treeSettings?.field?.treeSeparator }} {{ settings.redisTreeDivider() || strings().label?.treeSeparatorEmptyNote }}
{{ strings().form?.treeSettings?.field?.page }}{{ settings.pageCount() }}
{{ strings().form?.treeSettings?.error?.page }}
{{ strings().form?.treeSettings?.field?.keyPageCount }}{{ settings.keyPageCount() }}
{{ strings().form?.treeSettings?.error?.keyPageCount }}
{{ strings().form?.treeSettings?.maxValueDisplay }}{{ settings.maxValueDisplay() }}
{{ strings().form?.treeSettings?.maxValueDisplayInfo }}
{{ strings().form?.treeSettings?.maxKeys }}{{ settings.maxKeys() }}
{{ strings().form?.treeSettings?.maxKeysInfo }}
{{ strings().form?.treeSettings?.field?.keysSort }} {{ settings.keysSort() ? strings().label?.keysSort?.on : strings().label?.keysSort?.off }}
{{ strings().form?.treeSettings?.field?.searchMode }} {{ settings.searchClientSide() ? strings().form?.treeSettings?.label?.searchModeClient : strings().form?.treeSettings?.label?.searchModeServer }}
{{ strings().form?.treeSettings?.field?.searchModeStartsWith }} {{ settings.searchStartsWith() ? strings().form?.treeSettings?.label?.searchModeStartsWith : strings().form?.treeSettings?.label?.searchModeIncludes }}
{{ settings.jsonFormat() === 2 ? strings().form?.treeSettings?.label?.jsonFormatTwoSpace : strings().form?.treeSettings?.label?.jsonFormatFourSpace }}
{{ settings.animation() ? strings().form?.treeSettings?.label?.animation : strings().form?.treeSettings?.label?.noAnimation }}
{{ settings.undoEnabled() ? (strings().form?.treeSettings?.label?.undoEnabled) : (strings().form?.treeSettings?.label?.undoDisabled) }}
{{ strings().form?.treeSettings?.undoHint }}
{{ settings.showDiffBeforeSave() ? (strings().form?.treeSettings?.label?.diffEnabled) : (strings().form?.treeSettings?.label?.diffDisabled) }}
`, styles: [` :host { display: block; color: var(--mat-app-text-color, inherit); } .p3xr-settings-hint { font-size: 12px; color: var(--mat-app-text-color, rgba(0, 0, 0, 0.54)); opacity: 0.7; } /* GUI toggle */ .p3xr-gui-toggle { display: inline-flex; align-items: stretch; border-radius: 4px; overflow: hidden; border: 1px solid var(--p3xr-border-color, rgba(0,0,0,0.12)); } .p3xr-gui-toggle-active, .p3xr-gui-toggle-item { padding: 8px 12px; font-size: 14px; user-select: none; display: inline-flex; align-items: center; } .p3xr-gui-toggle-active { font-weight: 700; background-color: var(--p3xr-btn-primary-bg); color: var(--p3xr-btn-primary-color); } .p3xr-gui-toggle-item { font-weight: 500; cursor: pointer; } .p3xr-gui-toggle-active i[class*="fa-"] { text-shadow: 0 0 3px rgba(0,0,0,0.6), 0 0 8px rgba(0,0,0,0.3); } .p3xr-gui-toggle-item:hover { background-color: var(--p3xr-hover-bg); } /* Wide screens: show button text, hide tooltip */ .hide-xs { display: inline; } .show-xs-tooltip { display: none; } /* Small screens: hide text, show icon-only square buttons */ @media (max-width: 599px) { .hide-xs { display: none !important; } /* Buttons become square icon buttons on mobile */ .p3xr-connection-item button { min-width: 40px !important; width: 40px !important; height: 40px !important; padding: 0 !important; margin: 2px !important; display: inline-flex !important; align-items: center !important; justify-content: center !important; } .p3xr-connection-item button mat-icon, .p3xr-connection-item button i { margin: 0 !important; } } /* Connection items: match production md-list-item */ .p3xr-connection-item { display: flex; align-items: center; gap: 4px; padding: 8px 8px 8px 16px; min-height: 56px; box-sizing: border-box; } .p3xr-connection-info { flex: 1; min-width: 0; overflow: hidden; } .p3xr-connection-item button { flex-shrink: 0; } /* Drag and drop */ .p3xr-connection-item.cdk-drag-preview { box-sizing: border-box; border-radius: 4px; box-shadow: 0 5px 5px -3px rgba(0,0,0,0.2), 0 8px 10px 1px rgba(0,0,0,0.14), 0 3px 14px 2px rgba(0,0,0,0.12); background: var(--mat-app-background-color, #fff); } .p3xr-connection-item.cdk-drag-placeholder { opacity: 0.3; } .cdk-drop-list-dragging .p3xr-connection-item:not(.cdk-drag-placeholder) { transition: transform 250ms cubic-bezier(0, 0, 0.2, 1); } .p3xr-connection-group-block.cdk-drag-preview { box-sizing: border-box; border-radius: 4px; box-shadow: 0 5px 5px -3px rgba(0,0,0,0.2), 0 8px 10px 1px rgba(0,0,0,0.14), 0 3px 14px 2px rgba(0,0,0,0.12); background: var(--mat-app-background-color, #fff); } .p3xr-connection-group-block.cdk-drag-placeholder { opacity: 0.3; } .p3xr-group-drop-list.cdk-drop-list-dragging .p3xr-connection-group-block:not(.cdk-drag-placeholder) { transition: transform 250ms cubic-bezier(0, 0, 0.2, 1); } .p3xr-connection-group-header[cdkDragHandle] { cursor: grab; } /* Only tree settings rows are clickable/hoverable. License rows stay static like AngularJS. */ .p3xr-tree-settings-list mat-list-item { cursor: pointer; } .p3xr-tree-settings-list mat-list-item:hover { background-color: var(--p3xr-hover-bg); } /* ACL users list: hoverable rows only when editable */ .p3xr-acl-users-list .p3xr-acl-clickable { cursor: pointer; } .p3xr-acl-users-list .p3xr-acl-clickable:hover { background-color: var(--p3xr-hover-bg); } /* Settings list: bold label (left), normal value (right) */ ::ng-deep .p3xr-tree-settings-list .mdc-list-item__primary-text { width: 100%; } ::ng-deep .p3xr-settings-label { font-weight: 500; } ::ng-deep .p3xr-settings-value { font-weight: 400; opacity: 0.8; } `], changeDetection: ChangeDetectionStrategy.OnPush, }) export class SettingsComponent implements OnInit, OnDestroy { private static readonly UNGROUPED_GROUP_KEY = ''; strings; connectionsList: any[] = []; groupedConnections: Array<{ name: string; connections: any[] }> = []; collapsedGroups: Set; groupModeEnabled = false; private static readonly COLLAPSED_GROUPS_KEY = 'p3xr-collapsed-connection-groups'; private static readonly GROUP_MODE_KEY = 'p3xr-connection-group-mode'; isElectron = /electron/i.test(navigator.userAgent); isPromoDomain = window.location.hostname === 'p3x.redis.patrikx3.com'; readonlyConnections = false; currentConnectionId: string | undefined; aclUsers: any[] | null = null; aclCurrentUser = ''; aclLoading = false; isXs = false; private electronUiStorage: Record | null = null; private readonly unsubs: Array<() => void> = []; constructor( @Inject(I18nService) private i18n: I18nService, @Inject(SettingsService) public settings: SettingsService, @Inject(RedisStateService) private state: RedisStateService, @Inject(CommonService) private common: CommonService, @Inject(SocketService) private socket: SocketService, @Inject(MainCommandService) private cmd: MainCommandService, @Inject(ConnectionDialogService) private connectionDialog: ConnectionDialogService, @Inject(TreecontrolSettingsDialogService) private treeSettingsDialog: TreecontrolSettingsDialogService, @Inject(AiSettingsDialogService) private aiSettingsDialog: AiSettingsDialogService, @Inject(AclUserDialogService) private aclUserDialog: AclUserDialogService, @Inject(BreakpointObserver) private breakpointObserver: BreakpointObserver, @Inject(ChangeDetectorRef) private cdr: ChangeDetectorRef, @Inject(NotificationService) public notificationService: NotificationService, ) { this.strings = this.i18n.strings; this.restoreGroupingState(); this.breakpointObserver.observe('(max-width: 599px)').subscribe(result => { this.isXs = result.matches; this.cdr.markForCheck(); }); } ngOnInit(): void { this.refreshState(); // Subscribe to socket events for reactive updates const sub1 = this.socket.connections$.subscribe(() => this.refreshState()); const sub2 = this.socket.configuration$.subscribe(() => this.refreshState()); const sub3 = this.socket.stateChanged$.subscribe(() => this.refreshState()); const sub4 = this.socket.redisStatus$.subscribe(() => this.refreshState()); this.unsubs.push(() => { sub1.unsubscribe(); sub2.unsubscribe(); sub3.unsubscribe(); sub4.unsubscribe(); }); } ngOnDestroy(): void { this.unsubs.forEach(fn => fn()); } get currentConnectionName(): string { const conn = this.connectionsList.find((c: any) => c.id === this.currentConnectionId); return conn?.name || ''; } private refreshState(): void { this.connectionsList = this.state.connections()?.list || []; this.readonlyConnections = this.state.cfg()?.readonlyConnections === true; const prevConnId = this.currentConnectionId; this.currentConnectionId = this.state.connection()?.id; // Auto-load ACL when connection changes if (this.currentConnectionId && this.currentConnectionId !== prevConnId) { this.loadAclUsers(); } else if (!this.currentConnectionId && prevConnId) { this.aclUsers = null; this.aclCurrentUser = ''; } this.buildGroupedConnections(); this.cdr.detectChanges(); } toggleGroupMode(): void { this.groupModeEnabled = !this.groupModeEnabled; this.setPersistentItem(SettingsComponent.GROUP_MODE_KEY, String(this.groupModeEnabled)); } toggleGroup(name: string): void { if (this.collapsedGroups.has(name)) { this.collapsedGroups.delete(name); } else { this.collapsedGroups.add(name); } this.setPersistentItem(SettingsComponent.COLLAPSED_GROUPS_KEY, JSON.stringify([...this.collapsedGroups])); } private restoreGroupingState(): void { this.groupModeEnabled = this.getPersistentItem(SettingsComponent.GROUP_MODE_KEY) === 'true'; // Sync bootstrap value to localStorage so React can read it (shared origin in Electron) this.setPersistentItem(SettingsComponent.GROUP_MODE_KEY, String(this.groupModeEnabled)); try { const stored = this.getPersistentItem(SettingsComponent.COLLAPSED_GROUPS_KEY); this.collapsedGroups = stored ? new Set(JSON.parse(stored).map((name: string) => this.normalizeCollapsedGroupName(name))) : new Set(); } catch { this.collapsedGroups = new Set(); } } private getPersistentItem(key: string): string | null { const value = this.getElectronUiStorage()[key]; if (typeof value === 'string') { return value; } try { return localStorage.getItem(key); } catch { return null; } } private setPersistentItem(key: string, value: string): void { try { localStorage.setItem(key, value); } catch { /* ignore */ } const storage = this.getElectronUiStorage(); storage[key] = value; this.electronUiStorage = storage; try { if (window.parent && window.parent !== window) { window.parent.postMessage({ type: 'p3x-ui-storage-set', key, value }, '*'); } } catch { /* ignore */ } } private getElectronUiStorage(): Record { if (this.electronUiStorage !== null) { return this.electronUiStorage; } // Read from __p3xr_electron_bootstrap which was captured in main.js // BEFORE Angular's router stripped the query params. let storage: Record = {}; try { const bootstrap = (globalThis as any).__p3xr_electron_bootstrap; if (bootstrap && typeof bootstrap === 'object' && !Array.isArray(bootstrap)) { storage = this.normalizeElectronUiStorage(bootstrap); } } catch { storage = {}; } this.electronUiStorage = storage; return storage; } private normalizeElectronUiStorage(value: unknown): Record { if (!value || typeof value !== 'object' || Array.isArray(value)) { return {}; } return Object.entries(value).reduce((result: Record, [key, entryValue]) => { if (typeof entryValue === 'string') { result[key] = entryValue; } return result; }, {}); } getGroupDisplayName(name: string): string { return name === SettingsComponent.UNGROUPED_GROUP_KEY ? this.getUngroupedLabel() : name; } private getUngroupedLabel(): string { return this.strings().label?.ungrouped; } private normalizeCollapsedGroupName(name: unknown): string { if (typeof name !== 'string') { return ''; } return this.isLegacyUngroupedGroupName(name) ? SettingsComponent.UNGROUPED_GROUP_KEY : name; } private isLegacyUngroupedGroupName(name: string): boolean { return name === 'Ungrouped' || name === this.getUngroupedLabel(); } private buildGroupedConnections(): void { // Use a Map to preserve the order groups first appear in the connections list. // This respects the server-persisted order (including after drag reorder). const groups = new Map(); for (const conn of this.connectionsList) { const groupName = conn.group?.trim() || SettingsComponent.UNGROUPED_GROUP_KEY; if (!groups.has(groupName)) { groups.set(groupName, []); } groups.get(groupName)!.push(conn); } const result: Array<{ name: string; connections: any[] }> = []; for (const [name, connections] of groups) { result.push({ name, connections }); } this.groupedConnections = result; } // Predicates prevent items from entering the wrong drop list level groupDropPredicate = (drag: any) => drag.data && 'connections' in drag.data; connectionDropPredicate = (drag: any) => drag.data && !('connections' in drag.data); async dropGroup(event: CdkDragDrop): Promise { if (event.previousIndex === event.currentIndex) return; moveItemInArray(this.groupedConnections, event.previousIndex, event.currentIndex); // Rebuild flat list in new group order and persist const allIds: string[] = []; for (const group of this.groupedConnections) { for (const conn of group.connections) { allIds.push(conn.id); } } try { await this.socket.request({ action: 'connection/reorder', payload: { ids: allIds }, }); } catch (e) { this.common.generalHandleError(e); this.refreshState(); } } async dropConnection(event: CdkDragDrop, groupName: string): Promise { if (event.previousIndex === event.currentIndex) return; moveItemInArray(event.container.data, event.previousIndex, event.currentIndex); // Persist the new order to the server try { const reorderedIds = event.container.data.map((c: any) => c.id); await this.socket.request({ action: 'connection/reorder', payload: { group: groupName || undefined, ids: reorderedIds }, }); } catch (e) { this.common.generalHandleError(e); this.refreshState(); } } async dropUngroupedConnection(event: CdkDragDrop): Promise { if (event.previousIndex === event.currentIndex) return; moveItemInArray(this.connectionsList, event.previousIndex, event.currentIndex); try { const reorderedIds = this.connectionsList.map((c: any) => c.id); await this.socket.request({ action: 'connection/reorder', payload: { ids: reorderedIds }, }); } catch (e) { this.common.generalHandleError(e); this.refreshState(); } } // --- Connections --- connectionForm(type: string, model?: any): void { this.connectionDialog.show({ type: type as any, model, $event: undefined }); } async connect(connection: any): Promise { this.cmd.connectRequest$.next({ connection, disableState: true }); } async disconnect(): Promise { await this.cmd.disconnect(); this.refreshState(); } async deleteConnection(connection: any, $event: any): Promise { try { await this.common.confirm({ event: $event, message: this.strings().confirm?.deleteConnectionText, }); await this.socket.request({ action: 'connection/delete', payload: { id: connection.id }, }); this.common.toast(this.strings().status?.deleted); } catch (e) { if (e !== undefined) { this.common.generalHandleError(e); } } } getConnectionClients(connection: any): { key: string; clients: number }[] { const redisConnections = this.state.redisConnections() || {}; const results: { key: string; clients: number }[] = []; for (const key of Object.keys(redisConnections)) { if (redisConnections[key].connection?.name === connection.name) { results.push({ key, clients: redisConnections[key].clients?.length || 0 }); } } return results; } // --- AI Settings --- isAiEnabled(): boolean { return this.state.cfg()?.aiEnabled !== false; } async toggleAiEnabled(enabled: boolean): Promise { try { await this.socket.request({ action: 'ai/set-groq-api-key', payload: { aiEnabled: enabled, }, }); const cfg = { ...this.state.cfg(), aiEnabled: enabled }; this.state.cfg.set(cfg); this.cdr.markForCheck(); } catch (e) { this.common.generalHandleError(e); } } hasGroqApiKey(): boolean { return this.state.cfg()?.hasGroqApiKey === true; } isUseOwnKey(): boolean { return this.state.cfg()?.aiUseOwnKey === true && this.hasGroqApiKey(); } async toggleUseOwnKey(useOwn: boolean): Promise { if (useOwn && !this.hasGroqApiKey()) { return; } try { await this.socket.request({ action: 'ai/set-groq-api-key', payload: { aiEnabled: this.state.cfg()?.aiEnabled !== false, aiUseOwnKey: useOwn, }, }); const cfg = { ...this.state.cfg(), aiUseOwnKey: useOwn }; this.state.cfg.set(cfg); this.cdr.markForCheck(); } catch (e) { this.common.generalHandleError(e); } } isAiReadonly(): boolean { return this.readonlyConnections || this.state.cfg()?.groqApiKeyReadonly === true; } isGroqApiKeyReadonly(): boolean { return this.state.cfg()?.groqApiKeyReadonly === true; } async openAiSettings($event: any): Promise { await this.aiSettingsDialog.show(); this.cdr.markForCheck(); } // --- Tree Settings --- openTreeSettings($event: any): void { this.treeSettingsDialog.show({ $event }); } // --- ACL Management --- async loadAclUsers(): Promise { this.aclLoading = true; this.cdr.markForCheck(); try { const resp = await this.socket.request({ action: 'acl/list' }); this.aclUsers = resp.data.users; this.aclCurrentUser = resp.data.currentUser; } catch { this.aclUsers = null; } this.aclLoading = false; this.cdr.markForCheck(); } async deleteAclUser(username: string): Promise { try { const msg = (this.strings().page?.acl?.confirmDelete) + ` "${username}"?`; await this.common.confirm({ message: msg }); await this.socket.request({ action: 'acl/del-user', payload: { username } }); this.common.toast({ message: this.strings().page?.acl?.userDeleted }); this.loadAclUsers(); } catch {} } async openAclCreate(): Promise { const result = await this.aclUserDialog.show({ username: '', rules: 'on >password +@all ~* &*', isNew: true, }); if (result) { try { await this.socket.request({ action: 'acl/set-user', payload: { username: result.username, rules: result.rules } }); this.common.toast({ message: this.strings().page?.acl?.userSaved }); this.loadAclUsers(); } catch (e) { this.common.generalHandleError(e); } } } async openAclEdit(user: any): Promise { const parts = user.raw.split(' '); const result = await this.aclUserDialog.show({ username: user.name, rules: parts.slice(2).join(' '), isNew: false, }); if (result) { try { await this.socket.request({ action: 'acl/set-user', payload: { username: result.username, rules: result.rules } }); this.common.toast({ message: this.strings().page?.acl?.userSaved }); this.loadAclUsers(); } catch (e) { this.common.generalHandleError(e); } } } // --- GUI Framework Switch --- switchToReact(): void { switchGui('react'); } switchToVue(): void { switchGui('vue'); } }