Skip to content

Commit 23903d3

Browse files
committed
feat: add ACL user management, memory doctor, and cluster slot map
- Add visual ACL user editor (create/edit/delete) across Angular, React, and Vue with chip-based permission fields, enable/disable toggle, and no-password option - Add ACL user dialog component and service for Angular, AclUserDialog for React, and AclUserDialog.vue for Vue - Add Memory Doctor diagnostics panel with auto-refresh toggle to the memory analysis page across all three frontends - Add Cluster Slot Map table showing shard ranges, slot counts, and replicas with auto-refresh and export in the monitoring page - Add slow log reset button with confirmation dialog - Add guards preventing deletion of the default user or currently connected user, hide edit controls in readonly mode - Add i18n strings for ACL, memory doctor, cluster slot map, and slow log reset across all 54+ languages - Add Playwright test specs for the new features - Update README with ACL management documentation - Bump version to 2026.4.437
1 parent 159a88f commit 23903d3

75 files changed

Lines changed: 3494 additions & 190 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

README.md

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ https://corifeus.com/redis-ui
1212

1313

1414
---
15-
# 💿 P3X Redis UI triple frontend — Angular + React/MUI + Vue/Vuetify with 54 languages, 7 themes, Socket.IO, desktop notifications, and full feature parity v2026.4.436
15+
# 💿 P3X Redis UI triple frontend — Angular + React/MUI + Vue/Vuetify with 54 languages, 7 themes, Socket.IO, desktop notifications, and full feature parity v2026.4.437
1616

1717

1818

@@ -83,6 +83,22 @@ The `p3x-redis-ui-material` package is the **triple frontend** for [p3x-redis-ui
8383
- **Playwright E2E tests** — run against all three frontends in parallel
8484
- **Live switching** — toggle between Angular, React, and Vue in Settings
8585

86+
### ACL Management (Redis 6.0+)
87+
88+
The first Redis GUI with a **visual ACL (Access Control List) editor**. No other Redis desktop tool provides this — not even RedisInsight.
89+
90+
- **Auto-discovery** — ACL section appears in Settings only when connected to Redis 6.0+, auto-loads users
91+
- **Visual user list** — hoverable rows showing username, current user badge, disabled warning icon
92+
- **Chip-based permission editor** — commands, key patterns, and pub/sub channels as removable chips instead of raw text
93+
- Color-coded command chips: blue for allow (`+@all`), red for deny (`-@dangerous`)
94+
- Type and press Enter/Space/Comma to add chips
95+
- **Structured form** — enable/disable toggle, no-password checkbox, password field, separate fields for commands, keys, and channels
96+
- **Full CRUD** — create, edit, and delete ACL users with proper confirmation dialogs
97+
- **Safe editing** — resets permissions before applying changes so removed chips actually take effect
98+
- **Cluster-aware** — ACL SETUSER/DELUSER broadcast to all master nodes
99+
- **Readonly mode** — edit/delete/create buttons hidden when connection is readonly
100+
- **Guards** — cannot delete the `default` user or the currently connected user
101+
86102
### Project Structure
87103

88104
```
@@ -273,7 +289,7 @@ All my domains, including [patrikx3.com](https://patrikx3.com), [corifeus.eu](ht
273289
---
274290

275291

276-
[**P3X-REDIS-UI-MATERIAL**](https://corifeus.com/redis-ui-material) Build v2026.4.436
292+
[**P3X-REDIS-UI-MATERIAL**](https://corifeus.com/redis-ui-material) Build v2026.4.437
277293

278294
[![NPM](https://img.shields.io/npm/v/p3x-redis-ui-material.svg)](https://www.npmjs.com/package/p3x-redis-ui-material) [![Donate for PatrikX3 / P3X](https://img.shields.io/badge/Donate-PatrikX3-003087.svg)](https://www.paypal.com/cgi-bin/webscr?cmd=_s-xclick&hosted_button_id=QZVM4V6HVZJW6) [![Contact Corifeus / P3X](https://img.shields.io/badge/Contact-P3X-ff9900.svg)](https://www.patrikx3.com/en/front/contact) [![Like Corifeus @ Facebook](https://img.shields.io/badge/LIKE-Corifeus-3b5998.svg)](https://www.facebook.com/corifeus.software)
279295

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "p3x-redis-ui-material",
3-
"version": "2026.4.436",
3+
"version": "2026.4.437",
44
"description": "💿 P3X Redis UI triple frontend — Angular + React/MUI + Vue/Vuetify with 54 languages, 7 themes, Socket.IO, desktop notifications, and full feature parity",
55
"corifeus": {
66
"icon": "fas fa-palette",
Lines changed: 221 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,221 @@
1+
import { Component, Inject, ChangeDetectorRef } from '@angular/core';
2+
import { FormsModule } from '@angular/forms';
3+
import { MatDialogModule, MatDialogRef, MAT_DIALOG_DATA } from '@angular/material/dialog';
4+
import { MatFormFieldModule } from '@angular/material/form-field';
5+
import { MatInputModule } from '@angular/material/input';
6+
import { MatButtonModule } from '@angular/material/button';
7+
import { MatIconModule } from '@angular/material/icon';
8+
import { MatToolbarModule } from '@angular/material/toolbar';
9+
import { MatTooltipModule } from '@angular/material/tooltip';
10+
import { MatSlideToggleModule } from '@angular/material/slide-toggle';
11+
import { MatCheckboxModule } from '@angular/material/checkbox';
12+
import { MatChipsModule, MatChipInputEvent } from '@angular/material/chips';
13+
import { COMMA, ENTER, SPACE } from '@angular/cdk/keycodes';
14+
import { BreakpointObserver } from '@angular/cdk/layout';
15+
import { I18nService } from '../services/i18n.service';
16+
import { DialogCancelButtonComponent } from '../components/dialog-cancel-button.component';
17+
18+
export interface AclUserDialogData {
19+
username: string;
20+
rules: string;
21+
isNew: boolean;
22+
}
23+
24+
export interface AclUserDialogResult {
25+
username: string;
26+
rules: string[];
27+
}
28+
29+
@Component({
30+
selector: 'p3xr-acl-user-dialog',
31+
standalone: true,
32+
imports: [
33+
FormsModule, MatDialogModule, MatFormFieldModule, MatInputModule,
34+
MatButtonModule, MatIconModule, MatToolbarModule, MatTooltipModule,
35+
MatSlideToggleModule, MatCheckboxModule, MatChipsModule,
36+
DialogCancelButtonComponent,
37+
],
38+
template: `
39+
<mat-toolbar class="p3xr-dialog-toolbar p3xr-mat-layout-strong">
40+
<span mat-dialog-title class="p3xr-dialog-title">
41+
{{ data.isNew ? (strings().page?.acl?.createUser || 'Create User') : (strings().page?.acl?.editUser || 'Edit User') }}
42+
</span>
43+
<button mat-icon-button type="button" (click)="onCancel()">
44+
<mat-icon>close</mat-icon>
45+
</button>
46+
</mat-toolbar>
47+
48+
<mat-dialog-content class="p3xr-dialog-content">
49+
<mat-form-field class="md-block">
50+
<mat-label>{{ strings().page?.acl?.username || 'Username' }}</mat-label>
51+
<input matInput [(ngModel)]="username" [disabled]="!data.isNew" />
52+
</mat-form-field>
53+
54+
<div style="margin-bottom: 16px;">
55+
<mat-slide-toggle [(ngModel)]="enabled">
56+
{{ strings().page?.acl?.enabled || 'Enabled' }}
57+
</mat-slide-toggle>
58+
</div>
59+
60+
<div style="margin-bottom: 12px;">
61+
<mat-checkbox [(ngModel)]="nopass">
62+
{{ strings().page?.acl?.noPassword || 'No password (nopass)' }}
63+
</mat-checkbox>
64+
</div>
65+
66+
@if (!nopass) {
67+
<mat-form-field class="md-block">
68+
<mat-label>{{ strings().page?.acl?.password || 'Password' }}</mat-label>
69+
<input matInput [(ngModel)]="password" type="password" autocomplete="new-password" />
70+
@if (!data.isNew) {
71+
<mat-hint>{{ strings().page?.acl?.passwordHint || 'Leave empty to keep current password' }}</mat-hint>
72+
}
73+
</mat-form-field>
74+
}
75+
76+
<mat-form-field class="md-block">
77+
<mat-label>{{ strings().page?.acl?.commands || 'Commands' }}</mat-label>
78+
<mat-chip-grid #cmdGrid>
79+
@for (rule of commandsList; track rule) {
80+
<mat-chip-row (removed)="removeChip(commandsList, rule)"
81+
[highlighted]="true"
82+
[class.p3xr-chip-deny]="rule.startsWith('-')">
83+
{{ rule }}
84+
<button matChipRemove><mat-icon>cancel</mat-icon></button>
85+
</mat-chip-row>
86+
}
87+
</mat-chip-grid>
88+
<input matInput [matChipInputFor]="cmdGrid"
89+
[matChipInputSeparatorKeyCodes]="separatorKeys"
90+
(matChipInputTokenEnd)="addChip(commandsList, $event)"
91+
placeholder="+@all, -@dangerous, +get ..." />
92+
<mat-hint>{{ strings().page?.acl?.commandsHint || 'e.g., +@all or +@read -@dangerous' }}</mat-hint>
93+
</mat-form-field>
94+
95+
<mat-form-field class="md-block">
96+
<mat-label>{{ strings().page?.acl?.keys || 'Key Patterns' }}</mat-label>
97+
<mat-chip-grid #keyGrid>
98+
@for (pattern of keysList; track pattern) {
99+
<mat-chip-row (removed)="removeChip(keysList, pattern)"
100+
[highlighted]="true">
101+
{{ pattern }}
102+
<button matChipRemove><mat-icon>cancel</mat-icon></button>
103+
</mat-chip-row>
104+
}
105+
</mat-chip-grid>
106+
<input matInput [matChipInputFor]="keyGrid"
107+
[matChipInputSeparatorKeyCodes]="separatorKeys"
108+
(matChipInputTokenEnd)="addChip(keysList, $event)"
109+
placeholder="~*, ~user:* ..." />
110+
<mat-hint>{{ strings().page?.acl?.keysHint || 'e.g., ~* or ~user:*' }}</mat-hint>
111+
</mat-form-field>
112+
113+
<mat-form-field class="md-block">
114+
<mat-label>{{ strings().page?.acl?.channels || 'Pub/Sub Channels' }}</mat-label>
115+
<mat-chip-grid #chanGrid>
116+
@for (pattern of channelsList; track pattern) {
117+
<mat-chip-row (removed)="removeChip(channelsList, pattern)"
118+
[highlighted]="true">
119+
{{ pattern }}
120+
<button matChipRemove><mat-icon>cancel</mat-icon></button>
121+
</mat-chip-row>
122+
}
123+
</mat-chip-grid>
124+
<input matInput [matChipInputFor]="chanGrid"
125+
[matChipInputSeparatorKeyCodes]="separatorKeys"
126+
(matChipInputTokenEnd)="addChip(channelsList, $event)"
127+
placeholder="&*, &notifications:* ..." />
128+
<mat-hint>{{ strings().page?.acl?.channelsHint || 'e.g., &* or &notifications:*' }}</mat-hint>
129+
</mat-form-field>
130+
</mat-dialog-content>
131+
132+
<mat-dialog-actions class="p3xr-dialog-actions">
133+
<p3xr-dialog-cancel (cancel)="onCancel()"></p3xr-dialog-cancel>
134+
<button mat-raised-button class="btn-primary" type="button" (click)="onSave()"
135+
[disabled]="!username?.trim()"
136+
[matTooltip]="strings().intention?.save || 'Save'" [matTooltipDisabled]="isWide">
137+
<mat-icon>done</mat-icon>
138+
@if (isWide) { <span>{{ strings().intention?.save || 'Save' }}</span> }
139+
</button>
140+
</mat-dialog-actions>
141+
`,
142+
styles: [`
143+
.md-block { width: 100%; }
144+
mat-chip-row { font-size: 13px; }
145+
.p3xr-chip-deny { --mdc-chip-elevated-container-color: var(--p3xr-btn-warn-bg, #f44336); --mdc-chip-label-text-color: #fff; }
146+
`],
147+
})
148+
export class AclUserDialogComponent {
149+
strings;
150+
username: string;
151+
enabled = true;
152+
nopass = false;
153+
password = '';
154+
commandsList: string[] = [];
155+
keysList: string[] = [];
156+
channelsList: string[] = [];
157+
isWide = true;
158+
readonly separatorKeys = [ENTER, COMMA, SPACE];
159+
160+
constructor(
161+
@Inject(MAT_DIALOG_DATA) public data: AclUserDialogData,
162+
@Inject(MatDialogRef) private dialogRef: MatDialogRef<AclUserDialogComponent>,
163+
@Inject(BreakpointObserver) private breakpointObserver: BreakpointObserver,
164+
@Inject(I18nService) private i18n: I18nService,
165+
@Inject(ChangeDetectorRef) private cdr: ChangeDetectorRef,
166+
) {
167+
this.strings = this.i18n.strings;
168+
this.username = data.username;
169+
this.parseRules(data.rules);
170+
this.breakpointObserver.observe('(min-width: 600px)').subscribe(r => {
171+
this.isWide = r.matches;
172+
this.cdr.markForCheck();
173+
});
174+
}
175+
176+
addChip(list: string[], event: MatChipInputEvent): void {
177+
const value = (event.value || '').trim();
178+
if (value && !list.includes(value)) list.push(value);
179+
event.chipInput!.clear();
180+
}
181+
182+
removeChip(list: string[], value: string): void {
183+
const idx = list.indexOf(value);
184+
if (idx >= 0) list.splice(idx, 1);
185+
}
186+
187+
private parseRules(rules: string): void {
188+
const tokens = rules.trim().split(/\s+/).filter(Boolean);
189+
for (const t of tokens) {
190+
if (t === 'on') this.enabled = true;
191+
else if (t === 'off') this.enabled = false;
192+
else if (t === 'nopass') this.nopass = true;
193+
else if (t.startsWith('>') || t.startsWith('<') || t.startsWith('#') || t === 'resetpass' || t === 'sanitize-payload' || t === 'skip-sanitize-payload') continue;
194+
else if (t.startsWith('+') || t.startsWith('-') || t === 'allcommands' || t === 'nocommands') this.commandsList.push(t);
195+
else if (t.startsWith('~') || t.startsWith('%') || t === 'allkeys' || t === 'resetkeys') this.keysList.push(t);
196+
else if (t.startsWith('&') || t === 'allchannels' || t === 'resetchannels') this.channelsList.push(t);
197+
}
198+
}
199+
200+
onSave(): void {
201+
const u = this.username?.trim();
202+
if (!u) return;
203+
const rules: string[] = [this.enabled ? 'on' : 'off'];
204+
// When editing, reset permissions first so removals take effect
205+
if (!this.data.isNew) {
206+
rules.push('nocommands', 'resetkeys', 'resetchannels');
207+
if (this.nopass) rules.push('resetpass', 'nopass');
208+
else if (this.password.trim()) rules.push('resetpass', '>' + this.password.trim());
209+
// No password change → existing passwords preserved (no resetpass sent)
210+
} else {
211+
if (this.nopass) rules.push('nopass');
212+
else if (this.password.trim()) rules.push('>' + this.password.trim());
213+
}
214+
rules.push(...this.commandsList, ...this.keysList, ...this.channelsList);
215+
this.dialogRef.close({ username: u, rules } as AclUserDialogResult);
216+
}
217+
218+
onCancel(): void {
219+
this.dialogRef.close(undefined);
220+
}
221+
}
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import { Injectable, Inject } from '@angular/core';
2+
import { MatDialog } from '@angular/material/dialog';
3+
import type { AclUserDialogData, AclUserDialogResult } from './acl-user-dialog.component';
4+
import { createDialogPopupSettings } from './dialog-popup';
5+
6+
@Injectable({ providedIn: 'root' })
7+
export class AclUserDialogService {
8+
9+
constructor(@Inject(MatDialog) private dialog: MatDialog) {}
10+
11+
async show(data: AclUserDialogData): Promise<AclUserDialogResult | undefined> {
12+
const { AclUserDialogComponent } = await import(
13+
/* webpackChunkName: "dialog-acl-user" */
14+
'./acl-user-dialog.component'
15+
);
16+
17+
const dialogRef = this.dialog.open(AclUserDialogComponent, createDialogPopupSettings({
18+
data,
19+
width: '600px',
20+
}));
21+
22+
return new Promise((resolve) => {
23+
dialogRef.afterClosed().subscribe(result => resolve(result));
24+
});
25+
}
26+
}

src/ng/pages/monitoring/memory-analysis.component.html

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,38 @@
1+
<!-- Memory Doctor -->
2+
<p3xr-ng-accordion [title]="s().memoryDoctor || 'Memory Doctor'" accordionKey="analysis-doctor">
3+
<div actions>
4+
<p3xr-ng-button
5+
(click)="toggleAutoDoctor(); $event.stopPropagation()"
6+
[label]="strings().label?.autoRefresh || 'Auto'"
7+
[mdIcon]="autoRefreshDoctor ? 'check_box' : 'check_box_outline_blank'">
8+
</p3xr-ng-button>
9+
@if (!autoRefreshDoctor) {
10+
<p3xr-ng-button
11+
(click)="runDoctor(); $event.stopPropagation()"
12+
[label]="doctorLoading ? (strings().label?.loading || 'Loading...') : (strings().intention?.refresh || 'Refresh')"
13+
[mdIcon]="doctorLoading ? 'hourglass_empty' : 'refresh'"
14+
[disabled]="doctorLoading">
15+
</p3xr-ng-button>
16+
}
17+
<p3xr-ng-button
18+
(click)="exportDoctor(); $event.stopPropagation()"
19+
[label]="strings().intention?.export || 'Export'"
20+
mdIcon="download">
21+
</p3xr-ng-button>
22+
</div>
23+
<div content>
24+
@if (!doctorText) {
25+
<div style="padding: 12px 16px; opacity: 0.6;">
26+
{{ s().doctorNoData || 'Click Refresh to run Memory Doctor diagnostics.' }}
27+
</div>
28+
} @else {
29+
<pre style="white-space: pre-wrap; font-family: 'Roboto Mono', monospace; font-size: 13px; padding: 12px 16px; margin: 0;">{{ doctorText }}</pre>
30+
}
31+
</div>
32+
</p3xr-ng-accordion>
33+
34+
<br />
35+
136
@if (loading && !data) {
237
<div class="p3xr-analysis-loading">
338
<mat-icon>hourglass_empty</mat-icon>

0 commit comments

Comments
 (0)