import { AfterViewInit, Component, Inject, NgZone, QueryList, ViewChild, ViewChildren } from '@angular/core'; import { CommonModule } from '@angular/common'; import { CdkTextareaAutosize, TextFieldModule } from '@angular/cdk/text-field'; import { FormsModule, NgForm } from '@angular/forms'; import { MatDialogModule, MatDialogRef, MAT_DIALOG_DATA } from '@angular/material/dialog'; import { MatFormFieldModule } from '@angular/material/form-field'; import { MatInputModule } from '@angular/material/input'; import { MatSelectModule } from '@angular/material/select'; import { MatSlideToggleModule } from '@angular/material/slide-toggle'; import { MatButtonModule } from '@angular/material/button'; import { MatIconModule } from '@angular/material/icon'; import { MatToolbarModule } from '@angular/material/toolbar'; import { DialogCancelButtonComponent } from '../components/dialog-cancel-button.component'; import { MatTooltipModule } from '@angular/material/tooltip'; import { MatAutocompleteModule } from '@angular/material/autocomplete'; import { I18nService } from '../services/i18n.service'; import { SocketService } from '../services/socket.service'; import { CommonService } from '../services/common.service'; import { AskAuthorizationDialogService } from './ask-authorization-dialog.service'; import { RedisStateService } from '../services/redis-state.service'; import { SettingsService } from '../services/settings.service'; import { OverlayService } from '../services/overlay.service'; export interface ConnectionDialogData { type: 'new' | 'edit'; model?: any; } /** * Connection dialog -- Angular replacement for p3xrDialogConnection. * Allows creating/editing Redis connections with support for SSH, TLS, * cluster, and sentinel modes. */ @Component({ selector: 'p3xr-connection-dialog', standalone: true, imports: [ CommonModule, FormsModule, TextFieldModule, MatDialogModule, MatFormFieldModule, MatInputModule, MatSelectModule, MatSlideToggleModule, MatButtonModule, MatIconModule, MatToolbarModule, MatTooltipModule, MatAutocompleteModule, DialogCancelButtonComponent, ], template: `
@if (readonlyConnections) { {{ strings().label?.connectiondView }} } @else if (options.type === 'new') { {{ strings().label?.connectiondAdd }} } @else { {{ strings().label?.connectiondEdit }} }
@if (model.id && options.type !== 'new') { {{ strings().label?.id?.id }}
{{ strings().label?.id?.info }}
} {{ strings().form?.connection?.label?.name }} @if (p3xrConnectionForm.controls['name']?.hasError('required') && p3xrConnectionForm.controls['name']?.touched) { {{ strings().form?.error?.required }} } {{ strings().form?.connection?.label?.group || 'Group' }} @for (g of existingGroups; track g) { {{ g }} } {{ model.ssh ? strings().label?.ssh?.on : strings().label?.ssh?.off }} @if (model.ssh) {
SSH {{ strings().label?.ssh?.sshHost }} @if (p3xrConnectionForm.controls['sshHost']?.hasError('required') && p3xrConnectionForm.controls['sshHost']?.touched) { {{ strings().form?.error?.required }} } {{ strings().label?.ssh?.sshPort }} @if (p3xrConnectionForm.controls['sshPort']?.hasError('required') && p3xrConnectionForm.controls['sshPort']?.touched) { {{ strings().form?.error?.required }} } {{ strings().label?.ssh?.sshUsername }} @if (p3xrConnectionForm.controls['sshUsername']?.hasError('required') && p3xrConnectionForm.controls['sshUsername']?.touched) { {{ strings().form?.error?.required }} } {{ strings().label?.ssh?.sshPassword }} @if (!readonlyConnections) { }
{{ strings().label?.passwordSecure }}
{{ strings().label?.ssh?.sshPrivateKey }}
{{ strings().label?.secureFeature }}

}

Node 1 {{ strings().form?.connection?.label?.host }} {{ strings().form?.connection?.label?.port }} @if (p3xrConnectionForm.controls['port']?.hasError('min') || p3xrConnectionForm.controls['port']?.hasError('max')) { {{ strings().form?.error?.port }} } {{ strings().label?.askAuth }} {{ strings().form?.connection?.label?.username }} {{ strings().form?.connection?.label?.password }} @if (!readonlyConnections) { }
{{ strings().label?.passwordSecure }}


{{ model.readonly ? strings().label?.readonly?.on : strings().label?.readonly?.off }}
{{ model.cluster ? strings().label?.cluster?.on : strings().label?.cluster?.off }}
{{ model.sentinel ? strings().label?.sentinel?.on : strings().label?.sentinel?.off }}
@if ((model.cluster === true || model.sentinel === true) && !readonlyConnections) {
{{ strings().label?.addNode }}
}
@if (model.sentinel === true) { {{ strings().label?.sentinel?.name }} @if (p3xrConnectionForm.controls['sentinelName']?.hasError('required') && p3xrConnectionForm.controls['sentinelName']?.touched) { {{ strings().form?.error?.required }} } } @if (model.cluster === true || model.sentinel === true) {
@for (node of model.nodes; track node.id; let idx = $index; let last = $last) {
Node {{ idx + 2 }} @if (!readonlyConnections) {
}
@if (node.id) { {{ strings().label?.id?.nodeId }}
{{ strings().label?.id?.info }}
} {{ strings().form?.connection?.label?.host }} {{ strings().form?.connection?.label?.port }} @if (p3xrConnectionForm.controls['nodePort' + idx]?.hasError('min') || p3xrConnectionForm.controls['nodePort' + idx]?.hasError('max')) { {{ strings().form?.error?.port }} } @if (p3xrConnectionForm.controls['nodePort' + idx]?.hasError('required') && p3xrConnectionForm.controls['nodePort' + idx]?.touched) { {{ strings().form?.error?.required }} } {{ strings().form?.connection?.label?.username }} {{ strings().form?.connection?.label?.password }} @if (!readonlyConnections) { }
{{ strings().label?.passwordSecure }}
@if (!last) {
 
}
}
}
{{ strings().label?.tlsWithoutCert }} {{ strings().label?.tlsRejectUnauthorized }}
@if (model.tlsWithoutCert !== true) {
TLS TLS (redis.crt)
{{ strings().label?.tlsSecure }}

TLS (redis.key)
{{ strings().label?.tlsSecure }}

TLS (ca.crt)
{{ strings().label?.tlsSecure }}

}
@if (!readonlyConnections) { }
`, styles: [` .md-block { width: 100%; } .p3xr-hide-xs { } .p3xr-show-xs { display: none; } @media (max-width: 699px) { .p3xr-hide-xs { display: none; } .p3xr-show-xs { display: inline; } } `], }) export class ConnectionDialogComponent implements AfterViewInit { @ViewChild('p3xrConnectionForm') formRef!: NgForm; @ViewChildren(CdkTextareaAutosize) autosizeTextareas!: QueryList; options: ConnectionDialogData; model: any; strings; existingGroups: string[] = []; groupEnabled = false; // Password visibility toggles passwordVisible = false; sshPasswordVisible = false; nodePasswordVisible: Record = {}; // Readonly connections mode from global state get readonlyConnections(): boolean { return !!this.state.cfg()?.readonlyConnections; } constructor( @Inject(MatDialogRef) private dialogRef: MatDialogRef, @Inject(MAT_DIALOG_DATA) private data: ConnectionDialogData, @Inject(I18nService) private i18n: I18nService, @Inject(SocketService) private socketService: SocketService, @Inject(CommonService) private commonService: CommonService, @Inject(AskAuthorizationDialogService) private askAuthDialogService: AskAuthorizationDialogService, @Inject(NgZone) private ngZone: NgZone, @Inject(RedisStateService) private state: RedisStateService, @Inject(SettingsService) private settings: SettingsService, @Inject(OverlayService) private overlay: OverlayService, ) { this.strings = this.i18n.strings; this.options = data; this.model = this.initModel(data); // Collect existing group names for autocomplete const connections = this.state.connections()?.list || []; const groups = new Set(); for (const conn of connections) { if (conn.group && typeof conn.group === 'string' && conn.group.trim()) { groups.add(conn.group.trim()); } } this.existingGroups = [...groups].sort(); this.groupEnabled = !!this.model.group?.trim(); } onGroupToggle(): void { if (!this.groupEnabled) { this.model.group = undefined; } } ngAfterViewInit(): void { this.scheduleTextareaResize(); this.autosizeTextareas.changes.subscribe(() => this.scheduleTextareaResize()); } scheduleTextareaResize(): void { this.ngZone.runOutsideAngular(() => { requestAnimationFrame(() => { requestAnimationFrame(() => { this.autosizeTextareas?.forEach((textarea) => textarea.resizeToFitContent(true)); }); }); }); } private initModel(data: ConnectionDialogData): any { let model: any; if (data.model !== undefined) { model = structuredClone(data.model); // For existing connections, set sensitive fields to the model id // (server-side resolves these by id) model.password = data.model.id; model.tlsCrt = data.model.id; model.tlsKey = data.model.id; model.tlsCa = data.model.id; model.sshPassword = data.model.id; model.sshPrivateKey = data.model.id; } else { model = { name: undefined, host: undefined, port: 6379, askAuth: false, password: undefined, username: undefined, id: undefined, group: undefined, readonly: undefined, tlsWithoutCert: false, tlsRejectUnauthorized: false, tlsCrt: undefined, tlsKey: undefined, tlsCa: undefined, }; } // Ensure SSH fields exist if (!model.hasOwnProperty('ssh')) { model = { ...model, ssh: false, sshHost: undefined, sshPort: 22, sshUsername: undefined, sshPassword: data.model?.id, sshPrivateKey: data.model?.id, }; } if (!model.hasOwnProperty('cluster')) { model.cluster = false; } if (!model.hasOwnProperty('sentinel')) { model.sentinel = false; } if (!model.hasOwnProperty('nodes')) { model.nodes = []; } // For existing nodes, set password to node id (server-side resolves) for (const node of model.nodes) { node.password = node.id; } return model; } // --- Cluster/Sentinel mutual exclusion --- onClusterChange(): void { if (this.model.cluster === true) { this.model.sentinel = false; } } onSentinelChange(): void { if (this.model.sentinel === true) { this.model.cluster = false; } } // --- Node management --- addNode(index?: number): void { const newNode = { host: undefined, port: undefined, password: undefined, username: undefined, id: this.settings.generateId(), }; if (index === undefined) { this.model.nodes.push(newNode); } else { this.model.nodes.splice(index + 1, 0, newNode); } } async removeNode(ev: Event, index: number): Promise { try { await this.commonService.confirm({ event: ev, message: this.strings().confirm?.deleteConnectionText, }); this.model.nodes.splice(index, 1); this.commonService.toast({ message: this.strings().status?.nodeRemoved, }); } catch (e) { if (e === undefined) { return; } this.commonService.generalHandleError(e); } } // --- Form validation --- private handleInvalidForm(): boolean { if (this.formRef && this.formRef.invalid) { this.commonService.toast({ message: this.strings().form?.error?.invalid, }); return false; } return true; } // --- Test connection --- async testConnection($event: Event): Promise { // Mark form as submitted to trigger validation display if (this.formRef) { Object.keys(this.formRef.controls).forEach(key => { this.formRef.controls[key].markAsTouched(); }); } if (!this.handleInvalidForm()) { return; } try { const authModel = structuredClone(this.model); if (this.model.askAuth === true) { const auth = await this.askAuthDialogService.show({ $event: $event, }); authModel.username = undefined; authModel.password = undefined; if (auth.username) { authModel.username = auth.username; } if (auth.password) { authModel.password = auth.password; } } this.overlay.show({ message: this.strings().title?.connectingRedis, }); const response = await this.socketService.request({ action: 'redis-test-connection', payload: { model: authModel, }, }); console.warn('response', response); this.commonService.toast({ message: this.strings().status?.redisConnected, }); } catch (e) { this.commonService.generalHandleError(e); } finally { this.overlay.hide(); } } // --- Save --- async submit(): Promise { if (!this.handleInvalidForm()) { return; } if (this.model.host === undefined) { this.model.host = 'localhost'; } if (this.model.port === undefined) { this.model.port = 6379; } if (this.options.type === 'new') { this.model.id = this.settings.generateId(); } for (const node of this.model.nodes) { if (node.host === undefined) { node.host = 'localhost'; } if (node.id === undefined) { node.id = this.settings.generateId(); } } try { const saveModel = structuredClone(this.model); // Trim group name to avoid inconsistencies if (typeof saveModel.group === 'string') { saveModel.group = saveModel.group.trim() || undefined; } await this.socketService.request({ action: 'connection-save', payload: { model: saveModel, }, }); this.commonService.toast({ message: this.options.type === 'new' ? this.strings().status?.added : this.strings().status?.saved, }); this.dialogRef.close(undefined); } catch (e) { this.commonService.generalHandleError(e); } } // --- Cancel --- cancel(): void { this.dialogRef.close(undefined); } }