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: `
`,
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);
}
}