.github/000077500000000000000000000000001517644030100124175ustar00rootroot00000000000000.github/workflows/000077500000000000000000000000001517644030100144545ustar00rootroot00000000000000.github/workflows/build.yml000066400000000000000000000011261517644030100162760ustar00rootroot00000000000000name: build on: schedule: - cron: '0 0 1 * *' push: branches: [ main ] pull_request: branches: [ main ] jobs: build: runs-on: ubuntu-latest strategy: matrix: node-version: ['lts/*'] steps: - uses: actions/checkout@v5 - name: Use Node.js ${{ matrix.node-version }} uses: actions/setup-node@v6 with: node-version: ${{ matrix.node-version }} - run: npm i -g grunt-cli - run: yarn install - run: grunt .gitignore000066400000000000000000000005161517644030100130510ustar00rootroot00000000000000/build /node_modules /*.log /*.iws .idea/workspace.xml .idea/tasks.xml .idea/profiles_settings.xml .idea/inspectionProfiles/Project_Default.xml .idea/inspectionProfiles/profiles_settings.xml node_modules/.yarn-integrity /.angular/**/*.* /dist /dist-react /dist-vue /dist-svelte /out-tsc .DS_Store /test-results /tests/screenshots.npmignore000066400000000000000000000006311517644030100130560ustar00rootroot00000000000000/.angular /.babelrc /.github /.idea /.vscode /.travis.yml /.scrutinizer.yml /AGENTS.* /agents /artifacts /build /corifeus-boot.json /coverage /Gruntfile.js /node_modules /playwright-report /playwright*.* /secure /test /test-results /tests /tsconfig.json /*.iml /*.ipr /*.iws /*.lock *.log npm-debug.log* yarn-*.log* /scripts secure/ agents/ .claude/ /.angular/**/*.* /src/**/*.* .codex/ CLAUDE.md AGENTS.md Gruntfile.js000066400000000000000000000035611517644030100133610ustar00rootroot00000000000000 module.exports = (grunt) => { const builder = require(`corifeus-builder`); const gruntUtil = builder.utils; const loader = new builder.loader(grunt); loader.js({ }); grunt.registerTask('default', ['cory-npm', 'clean', 'cory-replace', 'cory:license', 'publish']); grunt.registerTask('build', ['publish']); grunt.registerTask('publish', async function() { const done = this.async() const cwd = process.cwd() try { // Build Angular (ng build) and React (vite) in parallel await Promise.all([ // Angular → dist/ gruntUtil.spawn({ grunt: grunt, gruntThis: this, }, { cmd: `${cwd}/node_modules/.bin/ng${gruntUtil.commandAddon}`, args: [ 'build', ] }), // React → dist-react/ gruntUtil.spawn({ grunt: grunt, gruntThis: this, }, { cmd: `${cwd}/node_modules/.bin/vite${gruntUtil.commandAddon}`, args: [ 'build', '--config', './src/react/vite.config.ts', ] }), // Vue → dist-vue/ gruntUtil.spawn({ grunt: grunt, gruntThis: this, }, { cmd: `${cwd}/node_modules/.bin/vite${gruntUtil.commandAddon}`, args: [ 'build', '--config', './src/vue/vite.config.ts', ] }), ]) done() } catch(e) { done(e) } }) } LICENSE000066400000000000000000000020131517644030100120600ustar00rootroot00000000000000MIT License Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.README.md000066400000000000000000000345541517644030100123510ustar00rootroot00000000000000# This is a development package For the full-blown package, please follow: https://github.com/patrikx3/redis-ui https://www.npmjs.com/package/p3x-redis-ui https://corifeus.com/redis-ui [//]: #@corifeus-header [![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://paypal.me/patrikx3) [![Contact Corifeus / P3X](https://img.shields.io/badge/Contact-P3X-ff9900.svg)](https://www.patrikx3.com/en/front/contact) [![Corifeus @ Facebook](https://img.shields.io/badge/Facebook-Corifeus-3b5998.svg)](https://www.facebook.com/corifeus.software) [![Uptime ratio (90 days)](https://network.corifeus.com/public/api/uptime-shield/31ad7a5c194347c33e5445dbaf8.svg)](https://network.corifeus.com/status/31ad7a5c194347c33e5445dbaf8) --- # 💿 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.458 🌌 **Bugs are evident™ - MATRIX️** 🚧 **This project is under active development!** 📢 **We welcome your feedback and contributions.** ### NodeJS LTS is supported ### 🛠️ Built on NodeJs version ```txt v24.14.1 ``` # 📦 Built on Angular ```text 21.2.8 ``` # 📝 Description [//]: #@corifeus-header:end The `p3x-redis-ui-material` package is the **triple frontend** for [p3x-redis-ui](https://github.com/patrikx3/redis-ui). It provides three fully independent, feature-parity GUIs that connect to `p3x-redis-ui-server` via Socket.IO: ### Angular Frontend (`/ng/`) - **Angular** (latest LTS) with standalone components and Angular Signals - **Angular Material** component library - **Webpack** bundler with AOT compilation via `@ngtools/webpack` - **CDK virtual scrolling** for tree view performance ### React Frontend (`/react/`) - **React** (latest LTS) with functional components and hooks - **MUI (Material UI)** component library matching Angular Material's look and feel - **Vite** bundler — instant dev server startup and fast production builds - **Zustand** lightweight state management replacing Angular services - **@tanstack/react-virtual** for virtual scrolling ### Vue Frontend (`/vue/`) - **Vue 3** with Composition API and ` src/main.scss000066400000000000000000000003531517644030100134700ustar00rootroot00000000000000@use "./overlay/overlay.scss"; @use "./ng/pages/database/key/key-types.scss"; @use "./ng/themes/_theme-custom.scss"; @use "./ng/themes/_theme-definitions.scss"; @use "./ng/themes/angular-material-themes.scss"; @use "./scss/vars.scss"; src/main.ts000066400000000000000000000054371517644030100131530ustar00rootroot00000000000000/** * Angular CLI entry point — replaces the old vendor.js + main.js entry chain. * * Responsibilities: * 1. Setup globals (htmlEncode, p3xrDevMode) * 2. Capture Electron bootstrap data before Angular router strips URL params * 3. Load the initial translation, then bootstrap Angular * * zone.js is loaded via angular.json polyfills. * Global CSS (fonts, icons, uPlot) is loaded via angular.json styles. * socket.io-client is imported directly in SocketService. */ declare const P3XR_DEV_MODE: boolean; // --- Dev mode flag (used by SocketService, RedisStateService, AuthService) --- (globalThis as any).p3xrDevMode = typeof P3XR_DEV_MODE !== 'undefined' ? P3XR_DEV_MODE : false; // --- Global htmlEncode (used by ConsoleComponent, DatabaseTreeComponent, DatabaseKeyComponent) --- import { htmlEncode } from 'js-htmlencode'; (globalThis as any).htmlEncode = htmlEncode; // --- Electron bootstrap: capture UI storage data from URL query params --- // Must happen BEFORE Angular's router strips them during initial redirect. try { const encoded = new URLSearchParams(window.location.search).get('p3xreUiStorage'); if (encoded) { const base64 = encoded.replace(/-/g, '+').replace(/_/g, '/').padEnd(Math.ceil(encoded.length / 4) * 4, '='); const parsed = JSON.parse(atob(base64)); if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { (globalThis as any).__p3xr_electron_bootstrap = parsed; } } } catch { // ignore — bootstrap data is optional } // Also try window.name (set by Electron shell via iframe.name before src is loaded) if (!(globalThis as any).__p3xr_electron_bootstrap) { try { const fromName = window.name ? JSON.parse(window.name) : null; if (fromName?.p3xreUiStorage && typeof fromName.p3xreUiStorage === 'object') { (globalThis as any).__p3xr_electron_bootstrap = fromName.p3xreUiStorage; } } catch { // ignore } } // --- Translation loading: load initial language before Angular boots --- import { getTranslations, loadTranslation } from './core/translation-loader'; import enStrings from './strings/en/strings'; // English is always loaded synchronously — it is the required fallback for all other languages. getTranslations()['en'] = enStrings; // Read the language from localStorage or Electron bootstrap storage. let initialLang = 'en'; try { const electronLang = (globalThis as any).__p3xr_electron_bootstrap?.['p3xr-language']; if (electronLang) { initialLang = electronLang; } else { initialLang = localStorage.getItem('p3xr-language') || 'en'; } } catch { /* ignore */ } // Load the initial language (no-op for English — already loaded above), then boot Angular. loadTranslation(initialLang).then(() => { import('./ng/main'); }); src/ng/000077500000000000000000000000001517644030100122525ustar00rootroot00000000000000src/ng/app.routes.ts000066400000000000000000000061721517644030100147300ustar00rootroot00000000000000import { Routes } from '@angular/router'; export const appRoutes: Routes = [ { path: 'info', loadComponent: () => import( /* webpackChunkName: "page-info" */ './pages/info.component' ).then(m => m.InfoComponent), }, { path: 'settings', loadComponent: () => import( /* webpackChunkName: "page-settings" */ './pages/settings.component' ).then(m => m.SettingsComponent), }, { path: 'database', loadComponent: () => import( /* webpackChunkName: "page-main" */ './pages/database/database.component' ).then(m => m.DatabaseComponent), children: [ { path: 'statistics', loadComponent: () => import( /* webpackChunkName: "page-statistics" */ './pages/database/statistics.component' ).then(m => m.StatisticsComponent), }, { path: 'key/:key', loadComponent: () => import( /* webpackChunkName: "page-main-key" */ './pages/database/database-key.component' ).then(m => m.DatabaseKeyComponent), }, { path: '', redirectTo: 'statistics', pathMatch: 'full', }, ], }, { path: 'search', loadComponent: () => import( /* webpackChunkName: "page-search" */ './pages/search/search.component' ).then(m => m.SearchComponent), }, { path: 'monitoring', loadComponent: () => import( /* webpackChunkName: "page-monitoring-shell" */ './pages/monitoring/monitoring-shell.component' ).then(m => m.MonitoringShellComponent), children: [ { path: '', loadComponent: () => import( /* webpackChunkName: "page-monitoring" */ './pages/monitoring/monitoring.component' ).then(m => m.MonitoringComponent), }, { path: 'profiler', loadComponent: () => import( /* webpackChunkName: "page-profiler" */ './pages/profiler/profiler.component' ).then(m => m.ProfilerComponent), }, { path: 'pubsub', loadComponent: () => import( /* webpackChunkName: "page-pubsub" */ './pages/profiler/pubsub.component' ).then(m => m.PubsubComponent), }, { path: 'analysis', loadComponent: () => import( /* webpackChunkName: "page-memory-analysis" */ './pages/monitoring/memory-analysis.component' ).then(m => m.MemoryAnalysisComponent), }, ], }, { path: '', redirectTo: 'settings', pathMatch: 'full', }, { path: '**', redirectTo: 'settings', }, ]; src/ng/components/000077500000000000000000000000001517644030100144375ustar00rootroot00000000000000src/ng/components/confirm-dialog.component.ts000066400000000000000000000042261517644030100217060ustar00rootroot00000000000000import { Component, Inject } from '@angular/core'; import { CommonModule } from '@angular/common'; import { MatDialogModule, MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; import { MatButtonModule } from '@angular/material/button'; import { MatIconModule } from '@angular/material/icon'; import { MatToolbarModule } from '@angular/material/toolbar'; import { DialogCancelButtonComponent } from './dialog-cancel-button.component'; export interface ConfirmDialogData { title: string; message: string; disableCancel?: boolean; okButton?: string; cancelButton?: string; } @Component({ selector: 'p3xr-confirm-dialog', standalone: true, imports: [CommonModule, MatDialogModule, MatButtonModule, MatIconModule, MatToolbarModule, DialogCancelButtonComponent], template: ` {{ data.title }}
@if (!data.disableCancel) { } `, styles: [` .p3xr-dialog-message { white-space: normal; } `], }) export class ConfirmDialogComponent { constructor( @Inject(MAT_DIALOG_DATA) public data: ConfirmDialogData, @Inject(MatDialogRef) private dialogRef: MatDialogRef, ) {} onOk(): void { this.dialogRef.close(true); } onCancel(): void { this.dialogRef.close(false); } } src/ng/components/dialog-cancel-button.component.ts000066400000000000000000000036571517644030100230160ustar00rootroot00000000000000import { Component, Input, Output, EventEmitter, Inject, ChangeDetectorRef } from '@angular/core'; import { CommonModule } from '@angular/common'; import { MatButtonModule } from '@angular/material/button'; import { MatIconModule } from '@angular/material/icon'; import { MatTooltipModule } from '@angular/material/tooltip'; import { BreakpointObserver } from '@angular/cdk/layout'; import { I18nService } from '../services/i18n.service'; /** * Shared responsive cancel button for all dialogs. * - Wide screens: shows icon + text * - Small screens: shows icon only + tooltip * * Usage: * * */ @Component({ selector: 'p3xr-dialog-cancel', standalone: true, imports: [CommonModule, MatButtonModule, MatIconModule, MatTooltipModule], template: ` `, }) export class DialogCancelButtonComponent { @Input() label: string = ''; @Input() icon: string = 'cancel'; @Output() cancel = new EventEmitter(); isWide = true; constructor( @Inject(BreakpointObserver) private breakpointObserver: BreakpointObserver, @Inject(I18nService) private i18n: I18nService, @Inject(ChangeDetectorRef) private cdr: ChangeDetectorRef, ) { this.breakpointObserver.observe('(min-width: 600px)').subscribe(r => { this.isWide = r.matches; this.cdr.markForCheck(); }); } ngOnInit(): void { if (!this.label) { this.label = this.i18n.strings().intention?.cancel; } } } src/ng/components/json-tree.component.ts000066400000000000000000000202541517644030100207210ustar00rootroot00000000000000import { Component, Input, OnChanges, SimpleChanges, ViewEncapsulation } from '@angular/core'; import { CommonModule } from '@angular/common'; import { MatTreeModule, MatTreeFlatDataSource, MatTreeFlattener } from '@angular/material/tree'; import { FlatTreeControl } from '@angular/cdk/tree'; import { MatIconModule } from '@angular/material/icon'; import { MatButtonModule } from '@angular/material/button'; interface JsonNode { key: string; value: any; type: 'object' | 'array' | 'string' | 'number' | 'boolean' | 'null'; children?: JsonNode[]; childCount?: number; } interface FlatJsonNode { key: string; value: any; type: 'object' | 'array' | 'string' | 'number' | 'boolean' | 'null'; level: number; expandable: boolean; childCount?: number; } /** * JSON tree viewer using Angular Material mat-tree. * Displays a JSON object/array as an expandable tree with syntax-colored values. * * Usage: * */ @Component({ selector: 'p3xr-json-tree', standalone: true, imports: [CommonModule, MatTreeModule, MatIconModule, MatButtonModule], encapsulation: ViewEncapsulation.None, template: ` {{ node.key }}: {{ formatDisplay(node) }} {{ node.key }} @if (!treeControl.isExpanded(node)) { {{ node.type === 'array' ? '[' : '{' }} ... {{ node.type === 'array' ? ']' : '}' }} ({{ node.childCount }}) } `, styles: [` .p3xr-json-mat-tree { font-family: 'Roboto Mono', monospace; font-size: 13px; background: inherit !important; } .p3xr-json-mat-tree .mat-tree-node, .p3xr-json-mat-tree .mat-nested-tree-node { background: inherit !important; color: inherit !important; } .p3xr-json-mat-tree .mat-tree-node { min-height: 24px; height: auto; line-height: 1.6; } .p3xr-json-tree-toggle-hidden { visibility: hidden !important; } .p3xr-json-tree-toggle { width: 24px !important; height: 24px !important; padding: 0 !important; flex-shrink: 0; } .p3xr-json-tree-toggle .mat-icon { font-size: 18px; width: 18px; height: 18px; opacity: 0.6; } .p3xr-json-tree-leaf-content { flex: 1; min-width: 0; display: flex; align-items: flex-start; gap: 6px; } .p3xr-json-tree-leaf-key { flex-shrink: 0; white-space: nowrap; } .p3xr-json-tree-value { word-break: break-word; min-width: 0; } .p3xr-json-tree-key { font-weight: bold; color: var(--p3xr-json-key-color, #881391); } .p3xr-json-tree-colon { opacity: 0.6; } .p3xr-json-tree-bracket { opacity: 0.5; } .p3xr-json-tree-ellipsis { opacity: 0.4; margin: 0 2px; } .p3xr-json-tree-count { opacity: 0.4; font-size: 11px; margin-left: 4px; align-self: center; } :host { display: block; } .p3xr-json-tree-value-string { color: var(--p3xr-json-value-string, #0b7500); } .p3xr-json-tree-value-number { color: var(--p3xr-json-value-number, #1a01cc); } .p3xr-json-tree-value-boolean { color: var(--p3xr-json-value-boolean, #c41a16); } .p3xr-json-tree-value-null { color: var(--p3xr-json-value-null, #808080); font-style: italic; } .p3xr-json-mat-tree .p3xr-json-tree-value { word-break: break-all; } .p3xr-json-mat-tree.p3xr-json-tree-nowrap .p3xr-json-tree-value { white-space: nowrap; word-break: normal; } .p3xr-json-mat-tree.p3xr-json-tree-nowrap .mat-tree-node { flex-wrap: nowrap; } `], }) export class JsonTreeComponent implements OnChanges { @Input() data: any; @Input() label: string = ''; @Input() expanded: boolean | 'recursive' = true; @Input() depth: number = 0; @Input() wrap: boolean = true; private transformer = (node: JsonNode, level: number): FlatJsonNode => ({ key: node.key, value: node.value, type: node.type, level: level, expandable: node.type === 'object' || node.type === 'array', childCount: node.childCount, }); treeControl = new FlatTreeControl( node => node.level, node => node.expandable, ); private treeFlattener = new MatTreeFlattener( this.transformer, node => node.level, node => node.expandable, node => node.children, ); dataSource = new MatTreeFlatDataSource(this.treeControl, this.treeFlattener); hasChild = (_: number, node: FlatJsonNode) => node.expandable; ngOnChanges(_changes: SimpleChanges): void { this.buildTree(); } private buildTree(): void { if (this.data === undefined || this.data === null) { this.dataSource.data = []; return; } const rootNode = this.jsonToNode(this.label || 'root', this.data); // If root is object/array, show its children directly under the root label this.dataSource.data = rootNode.children ? [rootNode] : [rootNode]; // Expand based on the expanded input if (this.expanded === 'recursive') { this.treeControl.expandAll(); } else if (this.expanded === true) { // Expand only the first level const flatNodes = this.treeControl.dataNodes; for (const node of flatNodes) { if (node.level === 0 && node.expandable) { this.treeControl.expand(node); } } } } private jsonToNode(key: string, value: any): JsonNode { if (value === null) { return { key, value: null, type: 'null' }; } if (Array.isArray(value)) { const children = value.map((item, index) => this.jsonToNode(String(index), item)); return { key, value, type: 'array', children, childCount: children.length }; } if (typeof value === 'object') { const children = Object.keys(value).map(k => this.jsonToNode(k, value[k])); return { key, value, type: 'object', children, childCount: children.length }; } return { key, value, type: typeof value as any }; } formatDisplay(node: FlatJsonNode): string { if (node.type === 'null') return 'null'; if (node.type === 'string') return `"${node.value}"`; return String(node.value); } } src/ng/components/login.component.ts000066400000000000000000000145401517644030100201240ustar00rootroot00000000000000import { Component, Inject, ChangeDetectionStrategy } from '@angular/core'; import { CommonModule } from '@angular/common'; import { FormsModule } from '@angular/forms'; import { MatFormFieldModule } from '@angular/material/form-field'; import { MatInputModule } from '@angular/material/input'; import { MatButtonModule } from '@angular/material/button'; import { MatIconModule } from '@angular/material/icon'; import { MatToolbarModule } from '@angular/material/toolbar'; import { MatMenuModule } from '@angular/material/menu'; import { I18nService } from '../services/i18n.service'; import { AuthService } from '../services/auth.service'; import { switchGui } from '../../core/gui-switch'; @Component({ selector: 'p3xr-login', standalone: true, imports: [ CommonModule, FormsModule, MatFormFieldModule, MatInputModule, MatButtonModule, MatIconModule, MatToolbarModule, MatMenuModule, ], changeDetection: ChangeDetectionStrategy.OnPush, template: ` `, styles: [` .p3xr-login-dialog-wrapper { display: flex; align-items: center; justify-content: center; min-height: calc(100vh - 96px); } .p3xr-login-dialog { width: 400px; max-width: calc(100vw - 32px); border-radius: 4px; overflow: hidden; box-shadow: 0 11px 15px -7px rgba(0,0,0,.2), 0 24px 38px 3px rgba(0,0,0,.14), 0 9px 46px 8px rgba(0,0,0,.12); } .full-width { width: 100%; } .p3xr-login-error { color: #f44336; font-size: 13px; margin: 4px 0 8px; } .p3xr-layout-spacer { flex: 1 1 auto; } `], }) export class LoginComponent { username = ''; password = ''; loading = false; hidePassword = true; currentGui = 'ng'; constructor( @Inject(I18nService) readonly i18n: I18nService, @Inject(AuthService) readonly auth: AuthService, ) { try { this.currentGui = localStorage.getItem('p3xr-frontend') || 'ng'; } catch {} } async onLogin(): Promise { if (this.loading || !this.username || !this.password) return; this.loading = true; const success = await this.auth.login(this.username, this.password); if (success) { location.reload(); } this.loading = false; } switchGui(gui: string): void { this.currentGui = gui; switchGui(gui); } getErrorMessage(error: string): string { const strings = this.i18n.strings(); return strings?.confirm?.invalidCredentials; } } src/ng/components/p3xr-accordion.component.ts000066400000000000000000000117141517644030100216470ustar00rootroot00000000000000import { Component, Input, Inject, OnInit } from '@angular/core'; import { CommonModule } from '@angular/common'; import { MatToolbarModule } from '@angular/material/toolbar'; import { MatButtonModule } from '@angular/material/button'; import { MatIconModule } from '@angular/material/icon'; import { MatTooltipModule } from '@angular/material/tooltip'; import { I18nService } from '../services/i18n.service'; /** * Accordion component — matches AngularJS p3xrAccordion exactly. * * Production reference: https://redis.patrikx3.com/settings * - Toolbar: grey/muted bg from Layout sub-theme, 48px height, 20px bold title * - Content: white/neutral bg (NOT tinted), thin border matching toolbar color * - No border-radius on content area (square corners) * - Toolbar has slight shadow when collapsed, flat when expanded */ @Component({ selector: 'p3xr-ng-accordion', standalone: true, imports: [CommonModule, MatToolbarModule, MatButtonModule, MatIconModule, MatTooltipModule], template: `
{{ title }}
@if (collapsible) { }
@if (extended) {
}
`, styles: [` :host { display: block; } .p3xr-accordion-wrapper { margin-bottom: 0; } .p3xr-accordion-toolbar { height: 48px; min-height: 48px; max-height: 48px; font-size: 20px; font-weight: 400; background-color: var(--p3xr-accordion-bg) !important; color: rgba(0, 0, 0, 0.87) !important; padding: 0; border-radius: 4px 4px 0 0; box-shadow: 0 1px 1px rgba(0,0,0,0.3); } .p3xr-accordion-toolbar.p3xr-collapsed { box-shadow: 0 1px 1px rgba(0,0,0,0.4); border-radius: 4px; } /* Inner flex layout matching AngularJS md-toolbar-tools */ .p3xr-accordion-toolbar-inner { display: flex; align-items: center; width: 100%; height: 48px; padding: 0 8px 0 16px; box-sizing: border-box; } .p3xr-accordion-content { border: 1px solid var(--p3xr-accordion-bg); border-radius: 0 0 4px 4px; } .p3xr-accordion-title { flex: 1; cursor: pointer; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .p3xr-accordion-actions { display: flex; align-items: center; flex-wrap: wrap; gap: 4px; } .p3xr-accordion-toggle { flex-shrink: 0; width: 40px !important; height: 40px !important; padding: 0 !important; } `] }) export class P3xrAccordionComponent implements OnInit { @Input() title: string = ''; @Input() accordionKey: string = ''; @Input() collapsible: boolean = true; readonly strings; extended = true; private static counter = 0; private storageKey = ''; constructor(@Inject(I18nService) private i18n: I18nService) { this.strings = this.i18n.strings; } ngOnInit(): void { if (!this.accordionKey) { this.accordionKey = String(++P3xrAccordionComponent.counter); } this.storageKey = `p3xr-accordion-extended-${this.accordionKey}`; this.loadState(); } toggle(): void { this.extended = !this.extended; this.saveState(); } private loadState(): void { try { const value = localStorage.getItem(this.storageKey); this.extended = value === null ? true : value === 'true'; } catch { this.extended = true; } } private saveState(): void { try { localStorage.setItem(this.storageKey, String(this.extended)); } catch {} } } src/ng/components/p3xr-button.component.ts000066400000000000000000000075201517644030100212210ustar00rootroot00000000000000import { Component, Input, Inject, ChangeDetectorRef, OnInit, OnDestroy } from '@angular/core'; import { CommonModule } from '@angular/common'; import { MatButtonModule } from '@angular/material/button'; import { MatIconModule } from '@angular/material/icon'; import { MatTooltipModule, TooltipPosition } from '@angular/material/tooltip'; import { BreakpointObserver } from '@angular/cdk/layout'; /** * Button component — Angular standalone replacement for AngularJS p3xrButton. * * Features: * - Shows icon (Material or FontAwesome) + label on wide screens * - Shows icon + tooltip on narrow screens (< 720px) * - Supports custom CSS classes (btn-primary, btn-accent, btn-warn) * - `raised` input switches from flat (mat-button) to filled (mat-flat-button) */ @Component({ selector: 'p3xr-ng-button', standalone: true, imports: [CommonModule, MatButtonModule, MatIconModule, MatTooltipModule], template: ` @if (raised) { @if (isWide) { } @else { } } @else { @if (isWide) { } @else { } } `, styles: [` :host { display: inline-block; } :host button { margin: 0 !important; } `] }) export class P3xrButtonComponent implements OnInit, OnDestroy { @Input() label: string = ''; @Input() mdIcon: string | undefined; @Input() faIcon: string | undefined; @Input() tooltipDirection: string = 'above'; @Input() classes: string = ''; @Input() disabled = false; @Input() raised = false; @Input() breakpoint = 720; isWide = true; private bpSub: any; constructor( @Inject(BreakpointObserver) private breakpointObserver: BreakpointObserver, @Inject(ChangeDetectorRef) private cdr: ChangeDetectorRef, ) {} ngOnInit(): void { this.bpSub = this.breakpointObserver.observe(`(min-width: ${this.breakpoint}px)`).subscribe(result => { this.isWide = result.matches; this.cdr.markForCheck(); }); } ngOnDestroy(): void { this.bpSub?.unsubscribe(); } get tooltipPosition(): TooltipPosition { switch (this.tooltipDirection) { case 'top': return 'above'; case 'bottom': return 'below'; case 'above': case 'below': case 'left': case 'right': case 'before': case 'after': return this.tooltipDirection; default: return 'above'; } } } src/ng/components/p3xr-input.component.ts000066400000000000000000000061721517644030100210470ustar00rootroot00000000000000import { Component, Input, Output, EventEmitter, forwardRef } from '@angular/core'; import { CommonModule } from '@angular/common'; import { FormsModule, NG_VALUE_ACCESSOR, ControlValueAccessor } from '@angular/forms'; /** * Styled input component — Angular standalone replacement for AngularJS p3xrInput directive. * * The AngularJS version used $mdColors and p3xrCommon for dynamic background/color/border. * The Angular version uses CSS custom properties from the theme system: * --p3xr-input-bg, --p3xr-input-color, --p3xr-border-color * * Implements ControlValueAccessor so it works with ngModel and reactive forms. * * AngularJS usage: * Downgraded usage: */ @Component({ selector: 'p3xr-ng-input', standalone: true, imports: [CommonModule, FormsModule], template: ` `, styles: [` :host { display: inline-block; vertical-align: top; } .p3xr-input { box-sizing: border-box; width: 100%; } .p3xr-input { padding: 3px; border-style: solid; border-width: 2px; margin: 1px; } .p3xr-input::placeholder { opacity: 0.5; } .p3xr-input:focus { margin: 0px; border-width: 3px; outline: none; } `], providers: [{ provide: NG_VALUE_ACCESSOR, useExisting: forwardRef(() => P3xrInputComponent), multi: true, }] }) export class P3xrInputComponent implements ControlValueAccessor { @Input() type: string = 'text'; @Input() step: string | undefined; @Input() min: string | undefined; @Input() max: string | undefined; @Input() placeholder: string = ''; @Output() enterPressed = new EventEmitter(); value: any = ''; focused = false; private onChange: (value: any) => void = () => { }; onTouched: () => void = () => { }; onValueChange(newValue: any): void { this.value = newValue; this.onChange(newValue); } writeValue(value: any): void { this.value = value; } registerOnChange(fn: (value: any) => void): void { this.onChange = fn; } registerOnTouched(fn: () => void): void { this.onTouched = fn; } onEnterPressed(event: KeyboardEvent): void { event.preventDefault(); // Emit after the current input event turn so parent ngModel handlers have settled. setTimeout(() => this.enterPressed.emit()); } } src/ng/dialogs/000077500000000000000000000000001517644030100136745ustar00rootroot00000000000000src/ng/dialogs/acl-user-dialog.component.ts000066400000000000000000000370451517644030100212260ustar00rootroot00000000000000import { Component, Inject, ChangeDetectorRef } from '@angular/core'; import { FormsModule } 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 { MatButtonModule } from '@angular/material/button'; import { MatIconModule } from '@angular/material/icon'; import { MatToolbarModule } from '@angular/material/toolbar'; import { MatTooltipModule } from '@angular/material/tooltip'; import { MatSlideToggleModule } from '@angular/material/slide-toggle'; import { MatCheckboxModule } from '@angular/material/checkbox'; import { MatChipsModule, MatChipInputEvent } from '@angular/material/chips'; import { MatAutocompleteModule, MatAutocompleteSelectedEvent } from '@angular/material/autocomplete'; import { COMMA, ENTER, SPACE } from '@angular/cdk/keycodes'; import { BreakpointObserver } from '@angular/cdk/layout'; import { I18nService } from '../services/i18n.service'; import { CommonService } from '../services/common.service'; import { DialogCancelButtonComponent } from '../components/dialog-cancel-button.component'; export interface AclUserDialogData { username: string; rules: string; isNew: boolean; } export interface AclUserDialogResult { username: string; rules: string[]; } @Component({ selector: 'p3xr-acl-user-dialog', standalone: true, imports: [ FormsModule, MatDialogModule, MatFormFieldModule, MatInputModule, MatButtonModule, MatIconModule, MatToolbarModule, MatTooltipModule, MatSlideToggleModule, MatCheckboxModule, MatChipsModule, MatAutocompleteModule, DialogCancelButtonComponent, ], template: ` {{ data.isNew ? (strings().page?.acl?.createUser) : (strings().page?.acl?.editUser) }} {{ strings().page?.acl?.username }} @if (username === 'default') {
info {{ strings().page?.acl?.defaultUserWarning }}
}
{{ strings().page?.acl?.enabled }}
{{ strings().page?.acl?.noPassword }}
@if (!nopass) { {{ strings().page?.acl?.password }} @if (!data.isNew) { {{ strings().page?.acl?.passwordHint }} } } {{ strings().page?.acl?.commands }} @for (rule of commandsList; track rule) { {{ rule }} } @for (group of filteredCmdGroups; track group.name) { @for (opt of group.options; track opt) { {{ opt }} } } {{ strings().page?.acl?.commandsHint }} {{ strings().page?.acl?.keys }} @for (pattern of keysList; track pattern) { {{ pattern }} } @for (opt of filteredKeyOptions; track opt) { {{ opt }} } {{ strings().page?.acl?.keysHint }} {{ strings().page?.acl?.channels }} @for (pattern of channelsList; track pattern) { {{ pattern }} } @for (opt of filteredChanOptions; track opt) { {{ opt }} } {{ strings().page?.acl?.channelsHint }}
`, styles: [` .md-block { width: 100%; } .p3xr-acl-default-warning { display: flex; align-items: flex-start; gap: 8px; padding: 12px; margin-bottom: 16px; border-radius: 4px; background-color: var(--p3xr-btn-warn-bg); color: var(--p3xr-btn-warn-color); font-size: 13px; line-height: 1.4; } .p3xr-acl-default-warning mat-icon { flex-shrink: 0; } mat-chip-row { font-size: 13px; --mdc-chip-elevated-container-color: var(--p3xr-btn-primary-bg) !important; --mdc-chip-label-text-color: var(--p3xr-btn-primary-color) !important; --mat-chip-elevated-selected-container-color: var(--p3xr-btn-primary-bg) !important; --mat-chip-selected-label-text-color: var(--p3xr-btn-primary-color) !important; --mat-chip-selected-trailing-icon-color: var(--p3xr-btn-primary-color) !important; } .p3xr-chip-deny { --mdc-chip-elevated-container-color: var(--p3xr-btn-warn-bg) !important; --mdc-chip-label-text-color: var(--p3xr-btn-warn-color) !important; --mat-chip-elevated-selected-container-color: var(--p3xr-btn-warn-bg) !important; --mat-chip-selected-label-text-color: var(--p3xr-btn-warn-color) !important; --mat-chip-selected-trailing-icon-color: var(--p3xr-btn-warn-color) !important; } `], }) export class AclUserDialogComponent { strings; username: string; enabled = true; nopass = false; password = ''; commandsList: string[] = []; keysList: string[] = []; channelsList: string[] = []; isWide = true; readonly separatorKeys = [ENTER, COMMA, SPACE]; private readonly cmdGroupDefs = [ { key: 'groupCommon', options: ['+@all', '-@all', '+@read', '-@read', '+@write', '-@write', '+@admin', '-@admin', '+@dangerous', '-@dangerous'] }, { key: 'groupDataTypes', options: ['+@string', '+@hash', '+@list', '+@set', '+@sortedset', '+@stream', '+@geo', '+@bitmap', '+@hyperloglog'] }, { key: 'groupOperations', options: ['+@keyspace', '+@pubsub', '+@connection', '+@transaction', '+@scripting', '+@fast', '+@slow', '+@blocking'] }, ]; private readonly allKeyOptions = ['~*', '%R~*', '%W~*', 'resetkeys']; private readonly allChanOptions = ['&*', 'resetchannels']; filteredCmdGroups: { name: string; options: string[] }[] = []; filteredKeyOptions: string[] = []; filteredChanOptions: string[] = []; constructor( @Inject(MAT_DIALOG_DATA) public data: AclUserDialogData, @Inject(MatDialogRef) private dialogRef: MatDialogRef, @Inject(BreakpointObserver) private breakpointObserver: BreakpointObserver, @Inject(I18nService) private i18n: I18nService, @Inject(CommonService) private common: CommonService, @Inject(ChangeDetectorRef) private cdr: ChangeDetectorRef, ) { this.strings = this.i18n.strings; this.username = data.username; this.parseRules(data.rules); this.breakpointObserver.observe('(min-width: 600px)').subscribe(r => { this.isWide = r.matches; this.cdr.markForCheck(); }); } filterOptions(type: 'cmd' | 'key' | 'chan', event: Event | null): void { const filter = event ? (event.target as HTMLInputElement).value.toLowerCase() : ''; if (type === 'cmd') { const acl = this.strings()?.page?.acl || {} as any; this.filteredCmdGroups = this.cmdGroupDefs .map(g => ({ name: acl[g.key] || g.key, options: g.options.filter(o => !this.commandsList.includes(o) && o.toLowerCase().includes(filter)) })) .filter(g => g.options.length > 0); } else if (type === 'key') { this.filteredKeyOptions = this.allKeyOptions.filter(o => !this.keysList.includes(o) && o.toLowerCase().includes(filter)); } else { this.filteredChanOptions = this.allChanOptions.filter(o => !this.channelsList.includes(o) && o.toLowerCase().includes(filter)); } } selectAutocomplete(list: string[], event: MatAutocompleteSelectedEvent, input: HTMLInputElement): void { const value = event.option.viewValue; if (value && !list.includes(value)) list.push(value); input.value = ''; this.filterOptions(list === this.commandsList ? 'cmd' : list === this.keysList ? 'key' : 'chan', null); } addChip(list: string[], event: MatChipInputEvent): void { const value = (event.value || '').trim(); if (value && !list.includes(value)) list.push(value); event.chipInput!.clear(); } removeChip(list: string[], value: string): void { const idx = list.indexOf(value); if (idx >= 0) list.splice(idx, 1); } private parseRules(rules: string): void { const tokens = rules.trim().split(/\s+/).filter(Boolean); for (const t of tokens) { if (t === 'on') this.enabled = true; else if (t === 'off') this.enabled = false; else if (t === 'nopass') this.nopass = true; else if (t.startsWith('>') || t.startsWith('<') || t.startsWith('#') || t === 'resetpass' || t === 'sanitize-payload' || t === 'skip-sanitize-payload') continue; else if (t.startsWith('+') || t.startsWith('-') || t === 'allcommands' || t === 'nocommands') this.commandsList.push(t); else if (t.startsWith('~') || t.startsWith('%') || t === 'allkeys' || t === 'resetkeys') this.keysList.push(t); else if (t.startsWith('&') || t === 'allchannels' || t === 'resetchannels') this.channelsList.push(t); } } async onSave(): Promise { const u = this.username?.trim(); if (!u) return; try { await this.common.confirm({ message: this.strings().intention?.areYouSure }); } catch { return; } const rules: string[] = [this.enabled ? 'on' : 'off']; // When editing, reset permissions first so removals take effect if (!this.data.isNew) { rules.push('nocommands', 'resetkeys', 'resetchannels'); if (this.nopass) rules.push('resetpass', 'nopass'); else if (this.password.trim()) rules.push('resetpass', '>' + this.password.trim()); // No password change → existing passwords preserved (no resetpass sent) } else { if (this.nopass) rules.push('nopass'); else if (this.password.trim()) rules.push('>' + this.password.trim()); } rules.push(...this.commandsList, ...this.keysList, ...this.channelsList); this.dialogRef.close({ username: u, rules } as AclUserDialogResult); } onCancel(): void { this.dialogRef.close(undefined); } } src/ng/dialogs/acl-user-dialog.service.ts000066400000000000000000000016511517644030100206560ustar00rootroot00000000000000import { Injectable, Inject } from '@angular/core'; import { MatDialog } from '@angular/material/dialog'; import type { AclUserDialogData, AclUserDialogResult } from './acl-user-dialog.component'; import { createDialogPopupSettings } from './dialog-popup'; @Injectable({ providedIn: 'root' }) export class AclUserDialogService { constructor(@Inject(MatDialog) private dialog: MatDialog) {} async show(data: AclUserDialogData): Promise { const { AclUserDialogComponent } = await import( /* webpackChunkName: "dialog-acl-user" */ './acl-user-dialog.component' ); const dialogRef = this.dialog.open(AclUserDialogComponent, createDialogPopupSettings({ data, width: '600px', })); return new Promise((resolve) => { dialogRef.afterClosed().subscribe(result => resolve(result)); }); } } src/ng/dialogs/ai-cheatsheet-dialog.component.ts000066400000000000000000000265131517644030100222150ustar00rootroot00000000000000import { Component, Inject, ViewEncapsulation, ChangeDetectorRef, ChangeDetectionStrategy } from '@angular/core'; import { CommonModule } from '@angular/common'; import { FormsModule } from '@angular/forms'; import { MatDialogModule, MatDialogRef } from '@angular/material/dialog'; import { MatFormFieldModule } from '@angular/material/form-field'; import { MatInputModule } from '@angular/material/input'; import { MatButtonModule } from '@angular/material/button'; import { MatIconModule } from '@angular/material/icon'; import { MatToolbarModule } from '@angular/material/toolbar'; import { MatTooltipModule } from '@angular/material/tooltip'; import { I18nService } from '../services/i18n.service'; import { RedisStateService } from '../services/redis-state.service'; interface CheatGroup { key: string; name: string; description?: string; prompts: string[]; } @Component({ selector: 'p3xr-ai-cheatsheet-dialog', standalone: true, imports: [ CommonModule, FormsModule, MatDialogModule, MatFormFieldModule, MatInputModule, MatButtonModule, MatIconModule, MatToolbarModule, MatTooltipModule, ], template: ` {{ strings().label?.cheatsheet?.title }}
@if (strings().label?.cheatsheet?.subtitle) {
{{ strings().label?.cheatsheet?.subtitle }}
} @if (strings().label?.cheatsheet?.footerHint) {
{{ strings().label?.cheatsheet?.footerHint }}
}
@for (g of visibleGroups(); track g.key) {
{{ g.name }}
@if (g.description) {
{{ g.description }}
}
@for (p of filteredPrompts(g.prompts); track p) { }
} @if (emptyResults()) {
{{ strings().label?.cheatsheet?.empty }}
}
`, styles: [` /* mat-dialog-content must be zero-padded so the sticky block can reach the true top of the scroll container (no 24px MDC default padding). */ .p3xr-cheatsheet-content.mat-mdc-dialog-content { padding: 0 !important; } .p3xr-cheatsheet-sticky { position: sticky; top: 0; z-index: 2; background: var(--mat-app-background-color, inherit); border-bottom: 1px solid var(--p3xr-content-border-color, rgba(255, 255, 255, 0.08)); padding: 12px 16px; } .p3xr-cheatsheet-sub, .p3xr-cheatsheet-tip { font-size: 13px; opacity: 0.8; line-height: 1.4; padding-bottom: 4px; } .p3xr-cheatsheet-search { margin: 0; padding: 4px 0 0 0; } /* Kill every source of extra vertical space mat-form-field adds around the input so the sticky block's padding is the ONLY vertical spacing. */ .p3xr-cheatsheet-search .mat-mdc-form-field-subscript-wrapper, .p3xr-cheatsheet-search .mat-mdc-form-field-bottom-align { display: none !important; height: 0 !important; min-height: 0 !important; } .p3xr-cheatsheet-search .mat-mdc-text-field-wrapper, .p3xr-cheatsheet-search .mat-mdc-form-field { margin: 0 !important; } .p3xr-cheatsheet-groups { padding: 12px 16px; } .p3xr-cheatsheet-group:first-child .p3xr-cheatsheet-group-name { margin-top: 0; } /* Only mat-dialog-content scrolls — this block is inline so the dialog owns the single scrollbar. */ .p3xr-cheatsheet-groups { padding: 0 16px 16px 16px; } .p3xr-cheatsheet-group { margin-bottom: 18px; } .p3xr-cheatsheet-group-name { font-size: 14px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px; margin-top: 6px; margin-bottom: 4px; opacity: 0.85; } .p3xr-cheatsheet-group-desc { font-size: 12px; opacity: 0.65; margin-bottom: 8px; } .p3xr-cheatsheet-prompts { display: flex; flex-direction: column; gap: 4px; } /* Plain bordered button — avoids mat-stroked-button's absolutely-positioned border element that doesn't wrap with multi-line content. Consistent padding on both single- and multi-line prompts. */ .p3xr-cheatsheet-prompt { display: block; width: 100%; text-align: left; font-family: 'Roboto Mono', monospace; font-size: 12px; line-height: 1.5; padding: 8px 12px; border: 1px solid var(--p3xr-content-border-color, rgba(127, 127, 127, 0.3)); border-radius: 4px; background: transparent; color: inherit; cursor: pointer; white-space: normal; word-break: break-word; overflow-wrap: anywhere; transition: background 0.1s ease, border-color 0.1s ease; } .p3xr-cheatsheet-prompt:hover { background: var(--p3xr-accordion-bg, rgba(127, 127, 127, 0.12)); border-color: var(--mat-sys-primary, currentColor); } .p3xr-cheatsheet-prompt:focus-visible { outline: 2px solid var(--mat-sys-primary, currentColor); outline-offset: -1px; } .p3xr-cheatsheet-empty { padding: 24px; text-align: center; opacity: 0.6; font-size: 13px; } .p3xr-cheatsheet-footer { padding: 10px 16px !important; min-height: auto !important; } .p3xr-cheatsheet-footer-hint { font-size: 11px; opacity: 0.7; line-height: 1.4; } `], encapsulation: ViewEncapsulation.None, changeDetection: ChangeDetectionStrategy.OnPush, }) export class AiCheatsheetDialogComponent { readonly strings; filter = ''; constructor( @Inject(I18nService) private readonly i18n: I18nService, @Inject(RedisStateService) private readonly state: RedisStateService, @Inject(ChangeDetectorRef) readonly cdr: ChangeDetectorRef, @Inject(MatDialogRef) private readonly dialogRef: MatDialogRef, ) { this.strings = this.i18n.strings; } visibleGroups(): CheatGroup[] { const cs = this.strings()?.label?.cheatsheet?.groups; if (!cs) return []; const modules = (this.state.modules() || []).map((m: any) => (m?.name || '').toLowerCase()); const hasRedisVersion = (major: number): boolean => { const v = this.state.redisVersion?.(); return v?.isAtLeast ? v.isAtLeast(major, 0) : false; }; const isCluster = this.state.info()?.server?.redis_mode === 'cluster'; const result: CheatGroup[] = []; const push = (key: string, group: any) => { if (!group || !Array.isArray(group.prompts) || group.prompts.length === 0) return; result.push({ key, name: group.name, description: group.description, prompts: group.prompts }); }; push('diagnostics', cs.diagnostics); push('keys', cs.keys); push('dataTypes', cs.dataTypes); if (modules.includes('rejson') || modules.includes('rejson-rl') || modules.includes('json')) push('json', cs.json); if (modules.includes('search') || modules.includes('searchlight')) push('search', cs.search); if (modules.includes('timeseries')) push('timeseries', cs.timeseries); if (modules.includes('bf')) push('bloom', cs.bloom); if (hasRedisVersion(8)) { push('vectorSet', cs.vectorSet); push('redis8', cs.redis8); } push('scripting', cs.scripting); if (isCluster) push('cluster', cs.cluster); if (hasRedisVersion(6)) push('acl', cs.acl); push('qna', cs.qna); push('translate', cs.translate); return result; } filteredPrompts(prompts: string[]): string[] { const q = this.filter.trim().toLowerCase(); if (!q) return prompts; return prompts.filter(p => p.toLowerCase().includes(q)); } emptyResults(): boolean { return this.visibleGroups().every(g => this.filteredPrompts(g.prompts).length === 0); } pick(prompt: string): void { this.dialogRef.close('ai: ' + prompt); } openOfficialDocs(): void { window.open('https://redis.io/docs/latest/commands/', '_blank'); } close(): void { this.dialogRef.close(undefined); } } src/ng/dialogs/ai-cheatsheet-dialog.service.ts000066400000000000000000000017461517644030100216540ustar00rootroot00000000000000import { Injectable, Inject } from '@angular/core'; import { MatDialog } from '@angular/material/dialog'; import { createDialogPopupSettings } from './dialog-popup'; @Injectable({ providedIn: 'root' }) export class AiCheatsheetDialogService { constructor(@Inject(MatDialog) private dialog: MatDialog) {} /** Returns the picked prompt (with "ai: " prefix) or undefined if closed without picking. */ async show(): Promise { const { AiCheatsheetDialogComponent } = await import( /* webpackChunkName: "dialog-ai-cheatsheet" */ './ai-cheatsheet-dialog.component' ); const dialogRef = this.dialog.open(AiCheatsheetDialogComponent, createDialogPopupSettings({ width: '720px', maxWidth: '95vw', maxHeight: '90vh', })); return new Promise((resolve) => { dialogRef.afterClosed().subscribe((picked: string | undefined) => resolve(picked)); }); } } src/ng/dialogs/ai-settings-dialog.component.ts000066400000000000000000000114601517644030100217330ustar00rootroot00000000000000import { Component, Inject, ViewEncapsulation, ChangeDetectorRef, ChangeDetectionStrategy } from '@angular/core'; import { CommonModule } from '@angular/common'; import { FormsModule } from '@angular/forms'; import { MatDialogModule, MatDialogRef } from '@angular/material/dialog'; import { MatFormFieldModule } from '@angular/material/form-field'; import { MatInputModule } from '@angular/material/input'; 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 { I18nService } from '../services/i18n.service'; import { SocketService } from '../services/socket.service'; import { CommonService } from '../services/common.service'; import { RedisStateService } from '../services/redis-state.service'; @Component({ selector: 'p3xr-ai-settings-dialog', standalone: true, imports: [ CommonModule, FormsModule, MatDialogModule, MatFormFieldModule, MatInputModule, MatButtonModule, MatIconModule, MatToolbarModule, DialogCancelButtonComponent, ], template: `
{{ strings().label?.aiSettings }}
{{ strings().label?.aiGroqApiKeyInfo }} console.groq.com
{{ strings().label?.aiGroqApiKey }}
`, encapsulation: ViewEncapsulation.None, changeDetection: ChangeDetectionStrategy.OnPush, }) export class AiSettingsDialogComponent { strings; apiKey = ''; saving = false; constructor( @Inject(MatDialogRef) private dialogRef: MatDialogRef, @Inject(I18nService) private i18n: I18nService, @Inject(SocketService) private socket: SocketService, @Inject(CommonService) private common: CommonService, @Inject(ChangeDetectorRef) private cdr: ChangeDetectorRef, @Inject(RedisStateService) private state: RedisStateService, ) { this.strings = this.i18n.strings; } cancel(): void { this.dialogRef.close(); } async save(): Promise { this.saving = true; this.cdr.markForCheck(); try { const key = this.apiKey.trim(); if (key) { const validation = await this.socket.request({ action: 'ai/validate-groq-api-key', payload: { apiKey: key }, }); if (!validation.valid) { this.common.toast({ message: this.strings().label?.aiGroqApiKeyInvalid }); return; } } await this.socket.request({ action: 'ai/set-groq-api-key', payload: { apiKey: key, aiEnabled: this.state.cfg()?.aiEnabled !== false, aiUseOwnKey: this.state.cfg()?.aiUseOwnKey === true }, }); const cfg = { ...this.state.cfg(), groqApiKey: key || '' }; this.state.cfg.set(cfg); this.common.toast({ message: this.strings().label?.aiGroqApiKeySaved }); this.dialogRef.close(); } catch (e: any) { this.common.generalHandleError(e); } finally { this.saving = false; this.cdr.markForCheck(); } } } src/ng/dialogs/ai-settings-dialog.service.ts000066400000000000000000000014541517644030100213730ustar00rootroot00000000000000import { Injectable, Inject } from '@angular/core'; import { MatDialog } from '@angular/material/dialog'; import { createDialogPopupSettings } from './dialog-popup'; @Injectable({ providedIn: 'root' }) export class AiSettingsDialogService { constructor(@Inject(MatDialog) private dialog: MatDialog) {} async show(): Promise { const { AiSettingsDialogComponent } = await import( /* webpackChunkName: "dialog-ai-settings" */ './ai-settings-dialog.component' ); const dialogRef = this.dialog.open(AiSettingsDialogComponent, createDialogPopupSettings({ width: '75vw', maxWidth: '75vw', })); return new Promise((resolve) => { dialogRef.afterClosed().subscribe(() => resolve()); }); } } src/ng/dialogs/ask-authorization-dialog.component.ts000066400000000000000000000070601517644030100231610ustar00rootroot00000000000000import { Component, Inject } from '@angular/core'; import { CommonModule } from '@angular/common'; import { FormsModule } from '@angular/forms'; import { MatDialogModule, MatDialogRef } from '@angular/material/dialog'; import { MatFormFieldModule } from '@angular/material/form-field'; import { MatInputModule } from '@angular/material/input'; 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 { I18nService } from '../services/i18n.service'; /** * Ask Authorization dialog — Angular replacement for p3xrDialogAskAuthorization. * Simple username/password form. Returns { username, password } on submit. */ @Component({ selector: 'p3xr-ask-authorization-dialog', standalone: true, imports: [ CommonModule, FormsModule, MatDialogModule, MatFormFieldModule, MatInputModule, MatButtonModule, MatIconModule, MatToolbarModule, DialogCancelButtonComponent, ], template: `
shield {{ strings().label?.askAuth }}
{{ strings().label?.aclAuthHint }}
{{ strings().form?.connection?.label?.username }} person {{ strings().form?.connection?.label?.password }} lock
`, styles: [` .full-width { width: 100%; } .p3xr-dialog-content { min-width: 300px; } `], }) export class AskAuthorizationDialogComponent { model = { username: '', password: '' }; strings; constructor( @Inject(MatDialogRef) private dialogRef: MatDialogRef, @Inject(I18nService) private i18n: I18nService, ) { this.strings = this.i18n.strings; } submit(): void { this.dialogRef.close(this.model); } cancel(): void { this.dialogRef.close(undefined); } } src/ng/dialogs/ask-authorization-dialog.service.ts000066400000000000000000000022211517644030100226110ustar00rootroot00000000000000import { Injectable, Inject } from '@angular/core'; import { MatDialog } from '@angular/material/dialog'; import { createDialogPopupSettings } from './dialog-popup'; /** * Service to open the Ask Authorization dialog. * Uses dynamic import() for lazy loading — the dialog component code * is only downloaded when the dialog is first opened. */ @Injectable({ providedIn: 'root' }) export class AskAuthorizationDialogService { constructor(@Inject(MatDialog) private dialog: MatDialog) {} async show(options?: { $event?: any }): Promise<{ username: string; password: string }> { const { AskAuthorizationDialogComponent } = await import( /* webpackChunkName: "dialog-ask-auth" */ './ask-authorization-dialog.component' ); const dialogRef = this.dialog.open(AskAuthorizationDialogComponent, createDialogPopupSettings()); return new Promise((resolve, reject) => { dialogRef.afterClosed().subscribe((result) => { if (result) { resolve(result); } else { reject(); } }); }); } } src/ng/dialogs/command-palette-dialog.component.ts000066400000000000000000000122021517644030100225510ustar00rootroot00000000000000import { Component, Inject, OnInit, ViewChild, ElementRef, AfterViewInit } from '@angular/core'; import { CommonModule } from '@angular/common'; import { FormsModule } from '@angular/forms'; import { MatDialogModule, MatDialogRef } from '@angular/material/dialog'; import { MatListModule } from '@angular/material/list'; import { MatIconModule } from '@angular/material/icon'; import { MatInputModule } from '@angular/material/input'; import { MatFormFieldModule } from '@angular/material/form-field'; import { I18nService } from '../services/i18n.service'; import { ShortcutsService, ShortcutDef } from '../services/shortcuts.service'; @Component({ selector: 'p3xr-command-palette-dialog', standalone: true, imports: [ CommonModule, FormsModule, MatDialogModule, MatListModule, MatIconModule, MatInputModule, MatFormFieldModule, ], template: `
@for (item of filtered; track item.label; let i = $index) {
{{ item.description }} {{ item.label }}
} @if (filtered.length === 0) {
{{ strings().label?.noResults }}
}
`, styles: [` .p3xr-command-palette { width: 100%; min-width: 400px; } .p3xr-command-palette-search { display: flex; align-items: center; gap: 8px; padding: 12px 16px; border-bottom: 1px solid var(--p3xr-list-border, rgba(0,0,0,0.12)); } .p3xr-command-palette-search input { flex: 1; border: none; outline: none; background: transparent; color: inherit; font-size: 16px; font-family: inherit; } .p3xr-command-palette-list { max-height: 300px; overflow-y: auto; } .p3xr-command-palette-item { display: flex; align-items: center; justify-content: space-between; padding: 10px 16px; cursor: pointer; } .p3xr-command-palette-item:hover, .p3xr-command-palette-item-active { background: var(--p3xr-hover-bg, rgba(0,0,0,0.04)); } .p3xr-command-palette-empty { padding: 16px; text-align: center; opacity: 0.5; } `], }) export class CommandPaletteDialogComponent implements OnInit, AfterViewInit { @ViewChild('searchInput') searchInput!: ElementRef; search = ''; selectedIndex = 0; strings; allItems: Array<{ label: string; description: string; shortcut: ShortcutDef }> = []; filtered: Array<{ label: string; description: string; shortcut: ShortcutDef }> = []; constructor( @Inject(MatDialogRef) private dialogRef: MatDialogRef, @Inject(I18nService) private i18n: I18nService, @Inject(ShortcutsService) private shortcutsService: ShortcutsService, ) { this.strings = this.i18n.strings; } ngOnInit(): void { const strings = this.strings(); const seen = new Set(); this.allItems = []; for (const s of this.shortcutsService.getShortcuts()) { if (seen.has(s.descriptionKey)) continue; seen.add(s.descriptionKey); this.allItems.push({ label: s.label, description: strings?.label?.[s.descriptionKey] || s.descriptionKey, shortcut: s }); } this.filtered = [...this.allItems]; } ngAfterViewInit(): void { setTimeout(() => this.searchInput?.nativeElement?.focus(), 50); } onKeydown(event: KeyboardEvent): void { if (event.key === 'ArrowDown') { event.preventDefault(); this.selectedIndex = Math.min(this.selectedIndex + 1, this.filtered.length - 1); } else if (event.key === 'ArrowUp') { event.preventDefault(); this.selectedIndex = Math.max(this.selectedIndex - 1, 0); } else if (event.key === 'Enter') { event.preventDefault(); if (this.filtered[this.selectedIndex]) this.execute(this.filtered[this.selectedIndex]); } else if (event.key === 'Escape') { this.dialogRef.close(); } else { this.filter(); } } filter(): void { const q = this.search.toLowerCase().trim(); this.filtered = q ? this.allItems.filter(i => i.description.toLowerCase().includes(q) || i.label.toLowerCase().includes(q)) : [...this.allItems]; this.selectedIndex = 0; } execute(item: { shortcut: ShortcutDef }): void { this.dialogRef.close(); item.shortcut.action(); } } src/ng/dialogs/command-palette-dialog.service.ts000066400000000000000000000015731517644030100222200ustar00rootroot00000000000000import { Injectable, Inject } from '@angular/core'; import { MatDialog, MatDialogRef } from '@angular/material/dialog'; @Injectable({ providedIn: 'root' }) export class CommandPaletteDialogService { private openRef: MatDialogRef | null = null; constructor(@Inject(MatDialog) private dialog: MatDialog) {} async show(): Promise { if (this.openRef) return; const { CommandPaletteDialogComponent } = await import( /* webpackChunkName: "dialog-command-palette" */ './command-palette-dialog.component' ); this.openRef = this.dialog.open(CommandPaletteDialogComponent, { width: '500px', maxWidth: '90vw', position: { top: '100px' }, panelClass: 'p3xr-command-palette-panel', autoFocus: false, }); this.openRef.afterClosed().subscribe(() => { this.openRef = null; }); } } src/ng/dialogs/connection-dialog.component.ts000066400000000000000000001110601517644030100216400ustar00rootroot00000000000000import { 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 }} @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 }} @if (model.askAuth) {
{{ strings().label?.aclAuthHint }}
} @if (!model.askAuth) {
{{ strings().label?.aclAuthHint }}
{{ 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 = {}; onAskAuthChange(): void { if (this.model.askAuth) { this.model.username = ''; this.model.password = ''; } } // 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: 'connection/test', 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); } } src/ng/dialogs/connection-dialog.service.ts000066400000000000000000000033001517644030100212730ustar00rootroot00000000000000import { Injectable, Inject } from '@angular/core'; import { MatDialog } from '@angular/material/dialog'; import type { ConnectionDialogData } from './connection-dialog.component'; import { createDialogPopupSettings } from './dialog-popup'; /** * Service to open the Connection dialog. * Uses dynamic import() for lazy loading -- the dialog component code * is only downloaded when the dialog is first opened. */ @Injectable({ providedIn: 'root' }) export class ConnectionDialogService { constructor(@Inject(MatDialog) private dialog: MatDialog) {} /** * Opens the connection dialog. * Matches the AngularJS p3xrDialogConnection.show() API. * * @param options.type - 'new' for creating a new connection, 'edit' for editing existing * @param options.model - existing connection model (for edit mode) * @param options.$event - the triggering DOM event (unused in Angular Material but kept for API compat) */ async show(options: { type: 'new' | 'edit'; model?: any; $event?: any }): Promise { const { ConnectionDialogComponent } = await import( /* webpackChunkName: "dialog-connection" */ './connection-dialog.component' ); const data: ConnectionDialogData = { type: options.type, model: options.model, }; const dialogRef = this.dialog.open(ConnectionDialogComponent, createDialogPopupSettings({ data, panelClass: ['fullscreen-dialog', 'p3xr-connection-dialog-panel'], })); return new Promise((resolve, reject) => { dialogRef.afterClosed().subscribe(() => { resolve(); }); }); } } src/ng/dialogs/dialog-popup.ts000066400000000000000000000051641517644030100166520ustar00rootroot00000000000000import { MatDialogConfig } from '@angular/material/dialog'; type DialogPanelClass = string | string[] | undefined; type DialogBackdropClass = string | string[] | undefined; export interface DialogPopupSettings extends Omit, 'panelClass' | 'backdropClass'> { panelClass?: DialogPanelClass; backdropClass?: DialogBackdropClass; } const BASE_DIALOG_PANEL_CLASS = 'p3xr-dialog-panel'; const BASE_DIALOG_BACKDROP_CLASS = 'p3xr-dialog-backdrop'; const NO_ANIMATION_PANEL_CLASS = 'p3xr-dialog-no-animation'; const NO_ANIMATION_BACKDROP_CLASS = 'p3xr-dialog-backdrop-no-animation'; function normalizeClassList(value: string | string[] | undefined): string[] { if (!value) { return []; } const classes = Array.isArray(value) ? value : [value]; return classes.filter((value): value is string => typeof value === 'string' && value.length > 0); } function readStorageItem(name: string): string | null { try { return localStorage.getItem(name); } catch { return null; } } function isDialogAnimationEnabled(): boolean { if (typeof document === 'undefined') { return true; } const body = document.body; if (body?.classList.contains('p3xr-no-animation')) { return false; } if (body?.classList.contains('p3xr-animation')) { return true; } return readStorageItem('p3xr-animation-settings') === '1'; } export function createDialogPopupSettings(options: DialogPopupSettings = {}): MatDialogConfig { const { panelClass, backdropClass, autoFocus, disableClose, maxWidth, maxHeight, enterAnimationDuration, exitAnimationDuration, ...rest } = options; const animationEnabled = isDialogAnimationEnabled(); const panelClasses = [BASE_DIALOG_PANEL_CLASS, ...normalizeClassList(panelClass)]; const backdropClasses = [BASE_DIALOG_BACKDROP_CLASS, ...normalizeClassList(backdropClass)]; if (!animationEnabled) { panelClasses.push(NO_ANIMATION_PANEL_CLASS); backdropClasses.push(NO_ANIMATION_BACKDROP_CLASS); } return { autoFocus: autoFocus ?? true, disableClose: disableClose ?? false, maxWidth: maxWidth ?? '100vw', maxHeight: maxHeight ?? 'calc(100vh - 64px)', enterAnimationDuration: enterAnimationDuration ?? (animationEnabled ? undefined : '0ms'), exitAnimationDuration: exitAnimationDuration ?? (animationEnabled ? undefined : '0ms'), ...rest, panelClass: Array.from(new Set(panelClasses)), backdropClass: Array.from(new Set(backdropClasses)), }; } src/ng/dialogs/diff-dialog.component.ts000066400000000000000000000267701517644030100204260ustar00rootroot00000000000000import { Component, Inject, ChangeDetectionStrategy, ViewEncapsulation, signal, computed, OnInit } from '@angular/core'; import { CommonModule } from '@angular/common'; import { MAT_DIALOG_DATA, MatDialogRef, MatDialogModule } from '@angular/material/dialog'; import { MatButtonModule } from '@angular/material/button'; import { MatIconModule } from '@angular/material/icon'; import { MatToolbarModule } from '@angular/material/toolbar'; import { MatTooltipModule } from '@angular/material/tooltip'; import { MatButtonToggleModule } from '@angular/material/button-toggle'; import { BreakpointObserver } from '@angular/cdk/layout'; import { diffLines, Change } from 'diff'; import { I18nService } from '../services/i18n.service'; import { DialogCancelButtonComponent } from '../components/dialog-cancel-button.component'; export interface DiffDialogData { keyName: string; fieldName?: string; oldValue: string; newValue: string; } interface DiffBlock { type: 'added' | 'removed' | 'unchanged' | 'collapse'; lines: string[]; collapsedCount?: number; expanded?: boolean; } const CONTEXT_LINES = 3; @Component({ selector: 'p3xr-diff-dialog', standalone: true, imports: [ CommonModule, MatDialogModule, MatButtonModule, MatIconModule, MatToolbarModule, MatTooltipModule, MatButtonToggleModule, DialogCancelButtonComponent, ], changeDetection: ChangeDetectionStrategy.OnPush, encapsulation: ViewEncapsulation.None, template: ` difference {{ diffStrings().reviewChanges }} {{ diffStrings().inline }} {{ diffStrings().sideBySide }} +{{ additions() }} {{ diffStrings().additions }}, -{{ deletions() }} {{ diffStrings().deletions }} @if (mode() === 'inline') { @for (block of blocks(); track $index) { @if (block.type === 'collapse' && !block.expanded) {
... {{ block.collapsedCount }} {{ diffStrings().unchangedLines }} ...
} @else { @for (line of block.lines; track $index) {
{{ block.type === 'added' ? '+' : block.type === 'removed' ? '-' : ' ' }}{{ line }}
} } } } @else {
{{ diffStrings().before }}
@for (block of blocks(); track $index) { @if (block.type === 'collapse' && !block.expanded) {
... {{ block.collapsedCount }} {{ diffStrings().unchangedLines }} ...
} @else if (block.type !== 'added') { @for (line of block.lines; track $index) {
{{ line }}
} } }
{{ diffStrings().after }}
@for (block of blocks(); track $index) { @if (block.type === 'collapse' && !block.expanded) {
... {{ block.collapsedCount }} {{ diffStrings().unchangedLines }} ...
} @else if (block.type !== 'removed') { @for (line of block.lines; track $index) {
{{ line }}
} } }
}
`, styles: [` .p3xr-diff-content { font-family: 'Roboto Mono', monospace; font-size: 13px; padding: 0 !important; min-height: 200px; max-height: 60vh; overflow: auto; } .p3xr-diff-sbs { display: grid; grid-template-columns: 1fr 1fr; } .p3xr-diff-side { overflow: auto; &:first-child { border-right: 1px solid rgba(128,128,128,0.2); } } .p3xr-diff-side-header { padding: 4px 8px; font-weight: 500; position: sticky; top: 0; z-index: 1; border-bottom: 1px solid rgba(128,128,128,0.2); background: var(--p3xr-content-bg, inherit); } .p3xr-diff-line { padding: 1px 8px; white-space: pre-wrap; word-break: break-all; } .p3xr-diff-prefix { display: inline-block; width: 16px; font-weight: 700; user-select: none; } .p3xr-diff-added { background: rgba(76,175,80,0.12); } .p3xr-diff-removed { background: rgba(244,67,54,0.12); } .p3xr-diff-unchanged { opacity: 0.6; } .p3xr-diff-collapse { padding: 4px 8px; opacity: 0.4; font-style: italic; cursor: pointer; &:hover { opacity: 0.7; } } .p3xr-diff-toggle { height: 28px; margin-right: 4px; border-radius: 4px !important; overflow: hidden; border: 1px solid rgba(255,255,255,0.3) !important; .mat-button-toggle { height: 28px; font-size: 12px; border: none !important; border-left: 1px solid rgba(255,255,255,0.3) !important; border-radius: 0 !important; background: transparent; color: rgba(255,255,255,0.7); } .mat-button-toggle:first-child { border-left: none !important; } .mat-button-toggle-checked { background: rgba(255,255,255,0.15) !important; color: rgba(255,255,255,0.95) !important; } .mat-button-toggle-button { height: 28px; } .mat-button-toggle-label-content { line-height: 28px !important; padding: 0 10px !important; } .mat-pseudo-checkbox, .mdc-button__icon { display: none !important; } .mat-button-toggle-button { padding: 0 !important; } } .p3xr-diff-summary-header { font-size: 12px; opacity: 0.8; white-space: nowrap; margin-left: 8px; margin-right: 4px; } .p3xr-diff-count-add { color: #81c784; font-weight: 700; } .p3xr-diff-count-del { color: #ef9a9a; font-weight: 700; } `], }) export class DiffDialogComponent implements OnInit { readonly strings; readonly diffStrings; readonly mode = signal<'inline' | 'side-by-side'>('inline'); readonly blocks = signal([]); isWide = true; private rawChanges: Change[]; readonly additions; readonly deletions; constructor( @Inject(MAT_DIALOG_DATA) public data: DiffDialogData, @Inject(MatDialogRef) public dialogRef: MatDialogRef, @Inject(I18nService) private i18n: I18nService, @Inject(BreakpointObserver) private breakpointObserver: BreakpointObserver, ) { this.strings = this.i18n.strings; this.diffStrings = computed(() => this.strings()?.diff || {}); this.rawChanges = diffLines(data.oldValue, data.newValue); this.additions = computed(() => this.rawChanges.filter(c => c.added).reduce((n, c) => n + (c.value.split('\n').length - 1 || 1), 0)); this.deletions = computed(() => this.rawChanges.filter(c => c.removed).reduce((n, c) => n + (c.value.split('\n').length - 1 || 1), 0)); } ngOnInit(): void { this.blocks.set(this.buildBlocks()); this.breakpointObserver.observe('(min-width: 600px)').subscribe(r => { this.isWide = r.matches; }); } expandBlock(index: number): void { const updated = [...this.blocks()]; const block = { ...updated[index] }; block.expanded = true; block.type = 'unchanged'; updated[index] = block; this.blocks.set(updated); } private buildBlocks(): DiffBlock[] { const blocks: DiffBlock[] = []; for (const change of this.rawChanges) { const lines = change.value.replace(/\n$/, '').split('\n'); if (change.added) { blocks.push({ type: 'added', lines }); } else if (change.removed) { blocks.push({ type: 'removed', lines }); } else { if (lines.length <= CONTEXT_LINES * 2 + 1) { blocks.push({ type: 'unchanged', lines }); } else { blocks.push({ type: 'unchanged', lines: lines.slice(0, CONTEXT_LINES) }); const collapsed = lines.slice(CONTEXT_LINES, -CONTEXT_LINES); blocks.push({ type: 'collapse', lines: collapsed, collapsedCount: collapsed.length }); blocks.push({ type: 'unchanged', lines: lines.slice(-CONTEXT_LINES) }); } } } return blocks; } } src/ng/dialogs/diff-dialog.service.ts000066400000000000000000000025551517644030100200570ustar00rootroot00000000000000import { Injectable, Inject } from '@angular/core'; import { MatDialog } from '@angular/material/dialog'; import { createDialogPopupSettings } from './dialog-popup'; import { SettingsService } from '../services/settings.service'; export interface DiffDialogOptions { keyName: string; fieldName?: string; oldValue: string; newValue: string; } @Injectable({ providedIn: 'root' }) export class DiffDialogService { constructor( @Inject(MatDialog) private dialog: MatDialog, @Inject(SettingsService) private settings: SettingsService, ) {} async show(options: DiffDialogOptions): Promise { if (!this.settings.showDiffBeforeSave()) return true; options.oldValue = String(options.oldValue ?? ''); options.newValue = String(options.newValue ?? ''); if (options.oldValue === options.newValue) return true; const { DiffDialogComponent } = await import( /* webpackChunkName: "dialog-diff" */ './diff-dialog.component' ); const dialogRef = this.dialog.open(DiffDialogComponent, createDialogPopupSettings({ data: options, width: '800px', maxHeight: '90vh', })); return new Promise((resolve) => { dialogRef.afterClosed().subscribe((result) => resolve(result === true)); }); } } src/ng/dialogs/json-editor-dialog.component.ts000066400000000000000000000300731517644030100217420ustar00rootroot00000000000000import { Component, Inject, OnInit, OnDestroy, ViewChild, ElementRef, AfterViewInit } from '@angular/core'; import { CommonModule } from '@angular/common'; import { MatDialogModule, MatDialogRef, MAT_DIALOG_DATA } from '@angular/material/dialog'; import { MatButtonModule } from '@angular/material/button'; import { MatIconModule } from '@angular/material/icon'; import { MatToolbarModule } from '@angular/material/toolbar'; import { MatTooltipModule } from '@angular/material/tooltip'; import { BreakpointObserver } from '@angular/cdk/layout'; import { I18nService } from '../services/i18n.service'; import { ThemeService } from '../services/theme.service'; import { CommonService } from '../services/common.service'; import { DialogCancelButtonComponent } from '../components/dialog-cancel-button.component'; import { RedisStateService } from '../services/redis-state.service'; import { SettingsService } from '../services/settings.service'; import { DiffDialogService } from './diff-dialog.service'; export interface JsonEditorDialogData { value: string; hideFormatSave?: boolean; } @Component({ selector: 'p3xr-json-editor-dialog', standalone: true, imports: [ CommonModule, MatDialogModule, MatButtonModule, MatIconModule, MatToolbarModule, MatTooltipModule, DialogCancelButtonComponent, ], template: ` edit {{ strings().intention?.jsonViewEditor }} @if (isJson) {
} @else { {{ strings().label?.jsonViewNotParsable }} } @if (isJson && !isReadonly) { @if (!hideFormatSave) { } } `, styles: [` .hide-sm { display: inline; } .p3xr-dialog-content-editor { padding: 0 !important; overflow: hidden !important; max-height: none !important; } .p3xr-dialog-content-message { min-height: 320px; } .p3xr-codemirror-host { height: calc(90vh - 100px); } .p3xr-codemirror-host .cm-editor { height: 100% !important; max-height: 100% !important; } .p3xr-codemirror-host .cm-scroller { overflow: auto !important; min-height: 0 !important; } @media (max-width: 959px) { .hide-sm { display: none; } } `], }) export class JsonEditorDialogComponent implements OnInit, AfterViewInit, OnDestroy { @ViewChild('editorContainer') editorContainer!: ElementRef; isJson = false; isReadonly = false; hideFormatSave = false; lineWrap = true; minHeight = '400px'; strings; private editorView: any; private wrapCompartment: any; private EditorViewClass: any; private obj: any; private resizeHandler: any; constructor( @Inject(MatDialogRef) private dialogRef: MatDialogRef, @Inject(MAT_DIALOG_DATA) private data: JsonEditorDialogData, @Inject(I18nService) private i18n: I18nService, @Inject(ThemeService) private theme: ThemeService, @Inject(CommonService) private common: CommonService, @Inject(BreakpointObserver) private breakpointObserver: BreakpointObserver, @Inject(RedisStateService) private state: RedisStateService, @Inject(SettingsService) private settings: SettingsService, @Inject(DiffDialogService) private diffDialog: DiffDialogService, ) { this.strings = this.i18n.strings; } ngOnInit(): void { try { this.obj = JSON.parse(this.data.value); this.isJson = true; } catch (e) { this.obj = undefined; this.isJson = false; } this.isReadonly = this.state.connection()?.readonly === true; this.hideFormatSave = this.data.hideFormatSave === true; this.updateMinHeight(); } async ngAfterViewInit(): Promise { if (!this.isJson || !this.editorContainer) return; const { EditorView, keymap, lineNumbers, highlightActiveLineGutter, highlightSpecialChars, drawSelection, highlightActiveLine, rectangularSelection, crosshairCursor } = await import( /* webpackChunkName: "codemirror-view" */ '@codemirror/view' ); const { EditorState, Compartment } = await import( /* webpackChunkName: "codemirror-state" */ '@codemirror/state' ); this.wrapCompartment = new Compartment(); const { json } = await import( /* webpackChunkName: "codemirror-lang-json" */ '@codemirror/lang-json' ); const { defaultKeymap, history, historyKeymap } = await import( /* webpackChunkName: "codemirror-commands" */ '@codemirror/commands' ); const { bracketMatching, foldGutter, foldKeymap, indentOnInput, syntaxHighlighting, defaultHighlightStyle } = await import( /* webpackChunkName: "codemirror-language" */ '@codemirror/language' ); const { closeBrackets, closeBracketsKeymap } = await import( /* webpackChunkName: "codemirror-autocomplete" */ '@codemirror/autocomplete' ); const { searchKeymap, highlightSelectionMatches } = await import( /* webpackChunkName: "codemirror-search" */ '@codemirror/search' ); const { lintKeymap } = await import( /* webpackChunkName: "codemirror-lint" */ '@codemirror/lint' ); let themeExtension; if (this.theme.isDark()) { const { oneDark } = await import( /* webpackChunkName: "codemirror-theme-dark" */ '@codemirror/theme-one-dark' ); themeExtension = oneDark; } else { const { githubLight } = await import( /* webpackChunkName: "codemirror-theme-light" */ '@uiw/codemirror-theme-github' ); themeExtension = githubLight; } const doc = JSON.stringify(this.obj, null, this.settings.jsonFormat() ?? 2); this.EditorViewClass = EditorView; this.editorView = new EditorView({ state: EditorState.create({ doc: doc, extensions: [ lineNumbers(), highlightActiveLineGutter(), highlightSpecialChars(), history(), foldGutter(), drawSelection(), indentOnInput(), syntaxHighlighting(defaultHighlightStyle, { fallback: true }), bracketMatching(), closeBrackets(), rectangularSelection(), crosshairCursor(), highlightActiveLine(), highlightSelectionMatches(), keymap.of([ ...closeBracketsKeymap, ...defaultKeymap, ...searchKeymap, ...historyKeymap, ...foldKeymap, ...lintKeymap, ]), json(), themeExtension, EditorView.theme({ '&': { 'height': 'calc(90vh - 100px)', 'max-height': 'calc(90vh - 100px)', }, '.cm-scroller': { 'overflow': 'auto', 'scrollbar-width': 'auto', }, '.cm-scroller::-webkit-scrollbar': { 'height': '12px', 'display': 'block', }, '.cm-scroller::-webkit-scrollbar-track': { 'background': 'rgba(128, 128, 128, 0.1)', }, '.cm-scroller::-webkit-scrollbar-thumb': { 'background': 'rgba(128, 128, 128, 0.4)', 'border-radius': '6px', }, '.cm-scroller::-webkit-scrollbar-thumb:hover': { 'background': 'rgba(128, 128, 128, 0.6)', }, }), this.wrapCompartment.of(this.lineWrap ? EditorView.lineWrapping : []), EditorState.readOnly.of(this.isReadonly), ], }), parent: this.editorContainer.nativeElement, }); // Resize handler this.resizeHandler = () => { this.updateMinHeight(); }; window.addEventListener('resize', this.resizeHandler); } ngOnDestroy(): void { if (this.resizeHandler) { window.removeEventListener('resize', this.resizeHandler); } if (this.editorView) { this.editorView.destroy(); this.editorView = undefined; } } toggleWrap(): void { this.lineWrap = !this.lineWrap; if (this.editorView && this.wrapCompartment && this.EditorViewClass) { this.editorView.dispatch({ effects: this.wrapCompartment.reconfigure(this.lineWrap ? this.EditorViewClass.lineWrapping : []), }); } } async save(format: boolean): Promise { try { const text = this.editorView.state.doc.toString(); const parsed = JSON.parse(text); const result = JSON.stringify(parsed, null, format ? (this.settings.jsonFormat() ?? 2) : 0); const keyName = this.state.connection()?.name || 'key'; const confirmed = await this.diffDialog.show({ keyName, oldValue: this.data.value, newValue: result }); if (!confirmed) return; this.dialogRef.close({ obj: result }); } catch (e) { this.common.generalHandleError(e); } } close(): void { this.dialogRef.close(undefined); } private updateMinHeight(): void { const isMobile = this.breakpointObserver.isMatched('(max-width: 959px)'); this.minHeight = isMobile ? '100%' : `${Math.max(10, window.innerHeight - 100)}px`; } } src/ng/dialogs/json-editor-dialog.service.ts000066400000000000000000000032261517644030100214000ustar00rootroot00000000000000import { Injectable, Inject } from '@angular/core'; import { MatDialog } from '@angular/material/dialog'; import { createDialogPopupSettings } from './dialog-popup'; import { MainCommandService } from '../services/main-command.service'; /** * Service to open the JSON Editor dialog. * Uses dynamic import() for lazy loading. * Returns { obj: string } (JSON string) on save, or rejects on cancel. */ @Injectable({ providedIn: 'root' }) export class JsonEditorDialogService { constructor( @Inject(MatDialog) private dialog: MatDialog, @Inject(MainCommandService) private cmd: MainCommandService, ) {} async show(options: { value: string; event?: any; $event?: any; hideFormatSave?: boolean }): Promise<{ obj: string }> { const { JsonEditorDialogComponent } = await import( /* webpackChunkName: "dialog-json-editor" */ './json-editor-dialog.component' ); // Pause resizer during dialog this.cmd.mainResizer$.next({ drag: false }); const dialogRef = this.dialog.open(JsonEditorDialogComponent, createDialogPopupSettings({ data: { value: options.value, hideFormatSave: options.hideFormatSave }, disableClose: false, width: '90vw', height: '90vh', })); return new Promise((resolve, reject) => { dialogRef.afterClosed().subscribe((result) => { // Resume resizer this.cmd.mainResizer$.next({ drag: true }); if (result) { resolve(result); } else { reject(); } }); }); } } src/ng/dialogs/json-view-dialog.component.ts000066400000000000000000000076211517644030100214310ustar00rootroot00000000000000import { Component, Inject, OnInit, ViewChild } from '@angular/core'; import { CommonModule } from '@angular/common'; import { MatDialogModule, MatDialogRef, MAT_DIALOG_DATA } from '@angular/material/dialog'; import { MatButtonModule } from '@angular/material/button'; import { MatIconModule } from '@angular/material/icon'; import { MatToolbarModule } from '@angular/material/toolbar'; import { MatTooltipModule } from '@angular/material/tooltip'; import { JsonTreeComponent } from '../components/json-tree.component'; import { I18nService } from '../services/i18n.service'; export interface JsonViewDialogData { value: string; } /** * JSON View dialog — Angular replacement for p3xrDialogJsonView. * Displays a JSON string as an expandable tree. Replaces angular-json-tree. */ @Component({ selector: 'p3xr-json-view-dialog', standalone: true, imports: [ CommonModule, MatDialogModule, MatButtonModule, MatIconModule, MatToolbarModule, MatTooltipModule, JsonTreeComponent, ], template: ` account_tree {{ strings().intention?.jsonViewShow }} @if (isJson) { } @if (isJson) { } @else {
{{ strings().label?.jsonViewNotParsable }}
}
`, styles: [` .p3xr-json-view-content { min-height: 200px; max-height: 70vh; overflow: auto; } `], }) export class JsonViewDialogComponent implements OnInit { @ViewChild(JsonTreeComponent) jsonTree?: JsonTreeComponent; obj: any; isJson = false; treeExpanded: boolean | 'recursive' = true; strings; constructor( @Inject(MatDialogRef) private dialogRef: MatDialogRef, @Inject(MAT_DIALOG_DATA) private data: JsonViewDialogData, @Inject(I18nService) private i18n: I18nService, ) { this.strings = this.i18n.strings; } ngOnInit(): void { try { this.obj = JSON.parse(this.data.value); this.isJson = true; } catch (e) { this.obj = undefined; this.isJson = false; } } expandAll(): void { this.jsonTree?.treeControl.expandAll(); } collapseAll(): void { this.jsonTree?.treeControl.collapseAll(); // Keep root expanded const root = this.jsonTree?.treeControl.dataNodes?.[0]; if (root) { this.jsonTree!.treeControl.expand(root); } } close(): void { this.dialogRef.close(); } } src/ng/dialogs/json-view-dialog.service.ts000066400000000000000000000017371517644030100210710ustar00rootroot00000000000000import { Injectable, Inject } from '@angular/core'; import { MatDialog } from '@angular/material/dialog'; import { createDialogPopupSettings } from './dialog-popup'; /** * Service to open the JSON View dialog. * Uses dynamic import() for lazy loading. */ @Injectable({ providedIn: 'root' }) export class JsonViewDialogService { constructor(@Inject(MatDialog) private dialog: MatDialog) {} async show(options: { value: string; event?: any; $event?: any }): Promise { const { JsonViewDialogComponent } = await import( /* webpackChunkName: "dialog-json-view" */ './json-view-dialog.component' ); const dialogRef = this.dialog.open(JsonViewDialogComponent, createDialogPopupSettings({ data: { value: options.value }, width: '75%', maxHeight: '90vh', })); return new Promise((resolve) => { dialogRef.afterClosed().subscribe(() => resolve()); }); } } src/ng/dialogs/key-import-dialog.component.ts000066400000000000000000000117461517644030100216130ustar00rootroot00000000000000import { Component, Inject } from '@angular/core'; import { CommonModule } from '@angular/common'; import { FormsModule } from '@angular/forms'; import { MatDialogModule, MatDialogRef, MAT_DIALOG_DATA } from '@angular/material/dialog'; import { MatToolbarModule } from '@angular/material/toolbar'; import { MatButtonModule } from '@angular/material/button'; import { MatIconModule } from '@angular/material/icon'; import { MatDividerModule } from '@angular/material/divider'; import { MatRadioModule } from '@angular/material/radio'; import { ScrollingModule } from '@angular/cdk/scrolling'; import { DialogCancelButtonComponent } from '../components/dialog-cancel-button.component'; import { I18nService } from '../services/i18n.service'; import { SocketService } from '../services/socket.service'; import { CommonService } from '../services/common.service'; @Component({ selector: 'p3xr-key-import-dialog', standalone: true, imports: [ CommonModule, FormsModule, MatDialogModule, MatToolbarModule, MatButtonModule, MatIconModule, MatDividerModule, MatRadioModule, ScrollingModule, DialogCancelButtonComponent, ], template: ` {{ strings().intention?.importKeys }}
{{ strings().label?.importPreview }} ({{ data.keys.length }})
{{ entry.key }} {{ strings().redisTypes?.[entry.type] || entry.type }}
{{ strings().label?.importConflict }}
{{ strings().label?.importOverwrite }} {{ strings().label?.importSkip }}
`, styles: [` .p3xr-import-preview-list { height: 300px; } .p3xr-import-preview-row { display: flex; align-items: center; justify-content: space-between; width: 100%; gap: 8px; height: 40px; padding: 0 16px; box-sizing: border-box; border-bottom: 1px solid var(--p3xr-list-border, rgba(0,0,0,0.12)); } .p3xr-import-key-name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-family: 'Roboto Mono', monospace; font-size: 13px; } `], }) export class KeyImportDialogComponent { strings; conflictMode: 'overwrite' | 'skip' = 'overwrite'; importing = false; constructor( @Inject(MatDialogRef) private dialogRef: MatDialogRef, @Inject(MAT_DIALOG_DATA) public data: { keys: any[] }, @Inject(I18nService) private i18n: I18nService, @Inject(SocketService) private socket: SocketService, @Inject(CommonService) private common: CommonService, ) { this.strings = this.i18n.strings; } trackByKey(_index: number, entry: any): string { return entry.key; } cancel(): void { this.dialogRef.close(null); } async doImport(): Promise { const keys = this.data.keys; const conflictMode = this.conflictMode; this.dialogRef.close({ pending: true, keys, conflictMode }); } } src/ng/dialogs/key-import-dialog.service.ts000066400000000000000000000020741517644030100212430ustar00rootroot00000000000000import { Injectable, Inject } from '@angular/core'; import { MatDialog } from '@angular/material/dialog'; import { createDialogPopupSettings } from './dialog-popup'; @Injectable({ providedIn: 'root' }) export class KeyImportDialogService { constructor(@Inject(MatDialog) private dialog: MatDialog) {} async show(options: { data: any }): Promise { const { KeyImportDialogComponent } = await import( /* webpackChunkName: "dialog-key-import" */ './key-import-dialog.component' ); const dialogRef = this.dialog.open(KeyImportDialogComponent, createDialogPopupSettings({ data: options.data, disableClose: false, width: '700px', maxWidth: '95vw', maxHeight: '90vh', })); return new Promise((resolve, reject) => { dialogRef.afterClosed().subscribe((result) => { if (result) { resolve(result); } else { reject(); } }); }); } } src/ng/dialogs/key-new-or-set-dialog.component.ts000066400000000000000000000772261517644030100223060ustar00rootroot00000000000000import { Component, Inject, OnInit, ChangeDetectorRef } from '@angular/core'; import { CommonModule } from '@angular/common'; import { FormsModule } 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 { MatTooltipModule } from '@angular/material/tooltip'; import { DialogCancelButtonComponent } from '../components/dialog-cancel-button.component'; import { BreakpointObserver } from '@angular/cdk/layout'; import { I18nService } from '../services/i18n.service'; import { CommonService } from '../services/common.service'; import { SocketService } from '../services/socket.service'; import { RedisStateService } from '../services/redis-state.service'; import { SettingsService } from '../services/settings.service'; import { JsonViewDialogService } from './json-view-dialog.service'; import { JsonEditorDialogService } from './json-editor-dialog.service'; import { OverlayService } from '../services/overlay.service'; import { DiffDialogService } from './diff-dialog.service'; export interface KeyNewOrSetDialogData { type: 'add' | 'edit' | 'append'; $event?: any; node?: any; model?: any; } /** * Key New/Edit dialog — Angular replacement for p3xrDialogKeyNewOrSet. * Multi-type form for creating or editing Redis keys (string, list, hash, set, zset, stream). */ @Component({ selector: 'p3xr-key-new-or-set-dialog', standalone: true, imports: [ CommonModule, FormsModule, MatDialogModule, MatFormFieldModule, MatInputModule, MatSelectModule, MatSlideToggleModule, MatButtonModule, MatIconModule, MatToolbarModule, MatTooltipModule, DialogCancelButtonComponent, ], template: `
{{ getTitle() }} {{ strings().form?.key?.field?.key }} @if (keyForm.controls['key']?.invalid && keyForm.controls['key']?.touched) { {{ strings().form?.key?.error?.key }} } {{ strings().form?.key?.field?.type }} @for (t of types; track t) { {{ strings().redisTypes?.[t] || t }} } @switch (model.type) { @case ('list') { {{ strings().form?.key?.field?.index }}
{{ strings().label?.redisListIndexInfo }}
} @case ('hash') { {{ strings().form?.key?.field?.hashKey }} @if (keyForm.controls['hashKey']?.invalid && keyForm.controls['hashKey']?.touched) { {{ strings().form?.key?.error?.hashKey }} } } @case ('zset') { {{ strings().form?.key?.field?.score }} @if (keyForm.controls['score']?.invalid && keyForm.controls['score']?.touched) { {{ strings().form?.key?.error?.score }} } } @case ('stream') { {{ strings().form?.key?.field?.streamTimestamp }} @if (keyForm.controls['streamTimestamp']?.invalid && keyForm.controls['streamTimestamp']?.touched) { {{ strings().form?.key?.error?.streamTimestamp }} }
{{ strings().label?.streamTimestampId }}
} @case ('timeseries') { @if (options.type === 'add') { {{ strings().page?.key?.timeseries?.retention }} (ms) {{ strings().page?.key?.timeseries?.retentionHint }} {{ strings().page?.key?.timeseries?.duplicatePolicy }} LAST FIRST MIN MAX SUM BLOCK } {{ strings().page?.key?.timeseries?.labels }} {{ strings().page?.key?.timeseries?.labelsHint }} @if (!model.tsBulkMode) { {{ strings().page?.key?.timeseries?.timestamp }} {{ strings().page?.key?.timeseries?.timestampHint }} } @if (model.originalTimestamp === undefined) { {{ strings().page?.key?.timeseries?.bulkMode }} } } @case ('bloom') {
{{ strings().form?.key?.field?.errorRate }} {{ strings().form?.key?.field?.capacity }}
} @case ('cuckoo') { {{ strings().form?.key?.field?.capacity }} } @case ('topk') {
Top K {{ strings().form?.key?.field?.width }} {{ strings().form?.key?.field?.depth }} {{ strings().form?.key?.field?.decay }}
} @case ('cms') {
{{ strings().form?.key?.field?.width }} {{ strings().form?.key?.field?.depth }}
} @case ('tdigest') { {{ strings().form?.key?.field?.compression }} } @case ('vectorset') { {{ strings().page?.key?.vectorset?.elementName }} {{ strings().page?.key?.vectorset?.vectorValues }} } } @if (model.type !== 'stream' && model.type !== 'timeseries' && !isProbabilisticType()) { } @if (model.type !== 'timeseries' && !isProbabilisticType()) { }
@if (model.type !== 'timeseries' && !isProbabilisticType()) { {{ strings().label?.validateJson }} @if (model.type === 'stream') {
{{ strings().label?.streamValue }}
} @if (isBuffer) {
{{ strings().label?.isBuffer?.({ maxValueAsBuffer: getMaxValueAsBufferText() }) }} {{ bufferDisplay(model.value) }}
} } @if (model.type === 'timeseries' && (model.tsEditAll || model.tsBulkMode)) {
{{ strings().page?.key?.timeseries?.autoSpread }} 1 {{ strings().time?.second }} 30 {{ strings().time?.seconds }} 1 {{ strings().time?.minute }} 30 {{ strings().time?.minutes }} 1 {{ strings().time?.hour }} 24 {{ strings().time?.hours }} {{ strings().page?.key?.timeseries?.formula }} {{ strings().page?.key?.timeseries?.none }} sin cos {{ strings().page?.key?.timeseries?.formulaLinear }} {{ strings().page?.key?.timeseries?.formulaRandom }} {{ strings().page?.key?.timeseries?.formulaSawtooth }}
@if (model.tsFormula) {
{{ strings().page?.key?.timeseries?.formulaPoints }} {{ strings().page?.key?.timeseries?.formulaAmplitude }} {{ strings().page?.key?.timeseries?.formulaOffset }}
} {{ strings().page?.key?.timeseries?.dataPoints }} {{ strings().page?.key?.timeseries?.editAllHint }} @if (keyForm.controls['value']?.invalid && keyForm.controls['value']?.touched) { {{ strings().form?.key?.error?.value }} } } @else if (model.type === 'timeseries' && !model.tsBulkMode) { {{ strings().page?.key?.timeseries?.value }} @if (keyForm.controls['value']?.invalid && keyForm.controls['value']?.touched) { {{ strings().form?.key?.error?.value }} } } @else if (!isProbabilisticType()) { {{ strings().form?.key?.field?.value }} @if (keyForm.controls['value']?.invalid && keyForm.controls['value']?.touched) { {{ strings().form?.key?.error?.value }} } }
@if (!isReadonly) { }
`, styles: [` .full-width { width: 100%; } .info-text { opacity: 0.5; font-size: 12px; margin-bottom: 8px; } .hide-sm { display: inline; } @media (max-width: 959px) { .hide-sm { display: none; } } `], }) export class KeyNewOrSetDialogComponent implements OnInit { model: any = {}; options: KeyNewOrSetDialogData; get types(): string[] { const base = ['string', 'list', 'hash', 'set', 'zset', 'stream']; if (this.state.hasTimeSeries()) { base.push('timeseries'); } if (this.state.hasReJSON()) { base.push('json'); } if (this.state.hasBloom()) { base.push('bloom', 'cuckoo', 'topk', 'cms', 'tdigest'); } base.push('vectorset'); return base; } private static readonly PROBABILISTIC_TYPES = ['bloom', 'cuckoo', 'topk', 'cms', 'tdigest']; isProbabilisticType(): boolean { return KeyNewOrSetDialogComponent.PROBABILISTIC_TYPES.includes(this.model.type) || this.model.type === 'vectorset'; } validateJson = false; isReadonly = false; isBuffer = false; isWide = window.innerWidth >= 720; strings; constructor( @Inject(MatDialogRef) private dialogRef: MatDialogRef, @Inject(MAT_DIALOG_DATA) private data: KeyNewOrSetDialogData, @Inject(I18nService) private i18n: I18nService, @Inject(CommonService) private common: CommonService, @Inject(SocketService) private socket: SocketService, @Inject(JsonViewDialogService) private jsonViewDialog: JsonViewDialogService, @Inject(JsonEditorDialogService) private jsonEditorDialog: JsonEditorDialogService, @Inject(BreakpointObserver) private breakpointObserver: BreakpointObserver, @Inject(ChangeDetectorRef) private cdr: ChangeDetectorRef, @Inject(RedisStateService) private state: RedisStateService, @Inject(SettingsService) private settings: SettingsService, @Inject(OverlayService) private overlay: OverlayService, @Inject(DiffDialogService) private diffDialog: DiffDialogService, ) { this.strings = this.i18n.strings; this.options = data; } ngOnInit(): void { this.isReadonly = this.state.connection()?.readonly === true; this.breakpointObserver.observe('(min-width: 720px)').subscribe(r => { this.isWide = r.matches; this.cdr.markForCheck(); }); this.model = { type: 'string', key: this.data.node?.key ? this.data.node.key + (this.settings.redisTreeDivider() ?? ':') : '', value: undefined, score: undefined, streamTimestamp: undefined, tsTimestamp: undefined, tsRetention: 0, tsDuplicatePolicy: 'LAST', tsLabels: '', tsBulkMode: false, tsSpread: 60000, tsFormula: '', tsFormulaPoints: 25, tsFormulaAmplitude: 100, tsFormulaOffset: 0, hashKey: undefined, index: undefined, bloomErrorRate: 0.01, bloomCapacity: 100, cuckooCapacity: 1024, topkK: 10, topkWidth: 2000, topkDepth: 7, topkDecay: 0.9, cmsWidth: 2000, cmsDepth: 7, tdigestCompression: 100, vectorElement: '', vectorValues: '', }; if (this.data.model) { Object.assign(this.model, this.data.model); } this.isBuffer = typeof this.model.value === 'object' && this.model.value !== null; } getTitle(): string { const s = this.strings(); if (this.options.type === 'edit') return s.form?.key?.label?.formName?.edit; if (this.options.type === 'append') return s.form?.key?.label?.formName?.append; return s.form?.key?.label?.formName?.add; } getMaxValueAsBufferText(): string { try { return this.settings.prettyBytes(this.settings.maxValueAsBuffer); } catch { return `${this.settings.maxValueAsBuffer} bytes`; } } bufferDisplay(value: any): string { if (value?.byteLength !== undefined) { return '(' + this.settings.prettyBytes(value.byteLength) + ')'; } return ''; } async copy(): Promise { let value = this.model.value; if (this.model.type === 'timeseries') { value = `TS.ADD ${this.model.key} ${this.model.tsTimestamp || '*'} ${this.model.value}`; } await this.settings.clipboard(value); this.common.toast(this.strings().status?.dataCopied); } async openJsonViewer(): Promise { await this.jsonViewDialog.show({ value: this.model.value }); } async openJsonEditor(): Promise { try { const result = await this.jsonEditorDialog.show({ value: this.model.value }); this.model.value = result.obj; } catch (e) { /* cancelled */ } } formatJson(): void { try { this.model.value = JSON.stringify(JSON.parse(this.model.value), null, this.settings.jsonFormat() ?? 2); } catch (e) { this.common.toast(this.strings().label?.jsonViewNotParsable); } } async onFileSelected(event: Event): Promise { const input = event.target as HTMLInputElement; const file = input.files?.[0]; if (!file) return; try { await this.common.confirm({ message: this.strings().confirm?.uploadBuffer }); const arrayBuffer = await file.arrayBuffer(); this.model.value = arrayBuffer; this.isBuffer = true; this.common.toast(this.strings().confirm?.uploadBufferDone); } catch (e) { /* cancelled */ } input.value = ''; } async submit(): Promise { if (!this.model.key || this.model.key.trim().length === 0) { this.common.toast(this.strings().form?.key?.error?.key); return; } if (this.validateJson) { try { JSON.parse(this.model.value); } catch (e) { this.common.toast(this.strings().label?.jsonViewNotParsable); return; } } try { // Show diff for edits (not new keys) if (this.data.model?.value !== undefined && this.data.model.value !== this.model.value) { const confirmed = await this.diffDialog.show({ keyName: this.model.key, fieldName: this.model.hashKey || undefined, oldValue: String(this.data.model.value), newValue: String(this.model.value), }); if (!confirmed) return; } this.overlay.show(); const response = await this.socket.request({ action: 'key/new-or-set', payload: { type: this.options.type, originalValue: this.data.model?.value, originalHashKey: this.data.model?.hashKey, model: structuredClone(this.model), }, }); if (typeof window['gtag'] === 'function') { window['gtag']('config', this.settings.googleAnalytics, { page_path: '/key-new-or-set' }); } this.common.toast(this.strings().status?.set); this.dialogRef.close(response); } catch (e) { this.common.generalHandleError(e); } finally { this.overlay.hide(); } } generateFormula(): void { const points = Math.min(Math.max(parseInt(this.model.tsFormulaPoints) || 25, 1), 10000); const amplitude = parseFloat(this.model.tsFormulaAmplitude) || 100; const offset = parseFloat(this.model.tsFormulaOffset) || 0; const formula = this.model.tsFormula; const lines: string[] = []; for (let i = 0; i < points; i++) { const x = i / points; let value: number; switch (formula) { case 'sin': value = Math.sin(x * Math.PI * 2) * amplitude + offset; break; case 'cos': value = Math.cos(x * Math.PI * 2) * amplitude + offset; break; case 'linear': value = x * amplitude + offset; break; case 'random': value = Math.random() * amplitude + offset; break; case 'sawtooth': value = (x % 0.25) * 4 * amplitude + offset; break; default: value = offset; } lines.push(`* ${parseFloat(value.toFixed(4))}`); } this.model.value = lines.join('\n'); } cancel(): void { this.dialogRef.close(undefined); } } src/ng/dialogs/key-new-or-set-dialog.service.ts000066400000000000000000000023471517644030100217340ustar00rootroot00000000000000import { Injectable, Inject } from '@angular/core'; import { MatDialog } from '@angular/material/dialog'; import { createDialogPopupSettings } from './dialog-popup'; /** * Service to open the Key New/Edit dialog. * Uses dynamic import() for lazy loading. */ @Injectable({ providedIn: 'root' }) export class KeyNewOrSetDialogService { constructor(@Inject(MatDialog) private dialog: MatDialog) {} async show(options: { type: 'add' | 'edit' | 'append'; $event?: any; node?: any; model?: any; }): Promise { const { KeyNewOrSetDialogComponent } = await import( /* webpackChunkName: "dialog-key-new-or-set" */ './key-new-or-set-dialog.component' ); const dialogRef = this.dialog.open(KeyNewOrSetDialogComponent, createDialogPopupSettings({ data: options, disableClose: false, width: '75%', maxHeight: '90vh', })); return new Promise((resolve, reject) => { dialogRef.afterClosed().subscribe((result) => { if (result) { resolve(result); } else { reject(); } }); }); } } src/ng/dialogs/prompt-dialog.component.ts000066400000000000000000000065611517644030100210330ustar00rootroot00000000000000import { Component, Inject, ChangeDetectorRef } from '@angular/core'; import { CommonModule } from '@angular/common'; import { FormsModule } 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 { MatButtonModule } from '@angular/material/button'; import { MatIconModule } from '@angular/material/icon'; import { MatToolbarModule } from '@angular/material/toolbar'; import { MatTooltipModule } from '@angular/material/tooltip'; import { BreakpointObserver } from '@angular/cdk/layout'; import { DialogCancelButtonComponent } from '../components/dialog-cancel-button.component'; export interface PromptDialogData { title: string; placeholder: string; initialValue?: string; okButton: string; cancelButton: string; } @Component({ selector: 'p3xr-prompt-dialog', standalone: true, imports: [ CommonModule, FormsModule, MatDialogModule, MatFormFieldModule, MatInputModule, MatButtonModule, MatIconModule, MatToolbarModule, MatTooltipModule, DialogCancelButtonComponent, ], template: ` {{ data.title }} {{ data.placeholder }} @if (inputField.invalid && inputField.touched) { {{ data.placeholder }} is required } `, styles: [`.full-width { width: 100%; min-width: 0; }`], }) export class PromptDialogComponent { value: string; isWide = true; constructor( @Inject(MAT_DIALOG_DATA) public data: PromptDialogData, @Inject(MatDialogRef) private dialogRef: MatDialogRef, @Inject(BreakpointObserver) private breakpointObserver: BreakpointObserver, @Inject(ChangeDetectorRef) private cdr: ChangeDetectorRef, ) { this.value = data.initialValue || ''; this.breakpointObserver.observe('(min-width: 600px)').subscribe(r => { this.isWide = r.matches; this.cdr.markForCheck(); }); } onOk(): void { if (!this.value?.trim()) return; this.dialogRef.close(this.value); } onCancel(): void { this.dialogRef.close(undefined); } } src/ng/dialogs/treecontrol-settings-dialog.component.ts000066400000000000000000000454331517644030100237110ustar00rootroot00000000000000import { Component, Inject, OnInit, AfterViewInit, ViewChild, ViewEncapsulation } from '@angular/core'; import { CommonModule } from '@angular/common'; import { FormsModule, NgForm, AbstractControl } from '@angular/forms'; import { MatDialogModule, MatDialogRef } from '@angular/material/dialog'; import { MatFormFieldModule } from '@angular/material/form-field'; import { ErrorStateMatcher } from '@angular/material/core'; import { MatInputModule } from '@angular/material/input'; 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 { I18nService } from '../services/i18n.service'; import { SettingsService } from '../services/settings.service'; import { RedisStateService } from '../services/redis-state.service'; import { CommonService } from '../services/common.service'; import { MainCommandService } from '../services/main-command.service'; import { SocketService } from '../services/socket.service'; import { TreeBuilderService } from '../services/tree-builder.service'; /** * Tree control settings dialog — Angular replacement for p3xrDialogTreecontrolSettings. * Edits pagination, sorting, search, display, and animation settings. */ @Component({ selector: 'p3xr-treecontrol-settings-dialog', standalone: true, imports: [ CommonModule, FormsModule, MatDialogModule, MatFormFieldModule, MatInputModule, MatSlideToggleModule, MatButtonModule, MatIconModule, MatToolbarModule, DialogCancelButtonComponent, ], template: `
{{ strings().form?.treeSettings?.label?.formName }}
@if (reducedFunctions) {
{{ strings().form?.treeSettings?.keyCount?.({ keyCount: keysRawLength }) }}
{{ strings().label?.tooManyKeys?.({ count: keysRawLength, maxLightKeysCount: settings.maxLightKeysCount }) }}
}
{{ strings().form?.treeSettings?.field?.treeSeparator }}
{{ strings().label?.treeSeparatorEmpty }}
{{ strings().form?.treeSettings?.field?.page }} @if (isFieldInvalid('pageCount', 10, 5000)) {
{{ strings().form?.treeSettings?.error?.page }}
}
{{ strings().form?.treeSettings?.field?.keyPageCount }} @if (isFieldInvalid('keyPageCount', 5, 100)) {
{{ strings().form?.treeSettings?.error?.keyPageCount }}
}
{{ strings().form?.treeSettings?.maxValueDisplay }} @if (isFieldInvalid('maxValueDisplay', -1, 32768)) {
{{ strings().form?.treeSettings?.error?.maxValueDisplay }}
} @else {
{{ strings().form?.treeSettings?.maxValueDisplayInfo }}
}
{{ strings().form?.treeSettings?.maxKeys }} @if (isFieldInvalid('maxKeys', 5, 100000)) {
{{ strings().form?.treeSettings?.error?.maxKeys }}
} @else {
{{ strings().form?.treeSettings?.maxKeysInfo }}
}
@if (!reducedFunctions) {
{{ model.keysSort ? strings().label?.keysSort?.on : strings().label?.keysSort?.off }}
{{ strings().label?.treeKeyStore }}
{{ model.searchClientSide ? strings().form?.treeSettings?.label?.searchModeClient : strings().form?.treeSettings?.label?.searchModeServer }}
{{ strings().page?.treeControls?.search?.info?.({ maxLightKeysCount: settings.maxLightKeysCount }) }} @if (dbsize > settings.maxLightKeysCount) {
{{ strings().page?.treeControls?.search?.largeSetInfo }}
}
}
{{ model.searchStartsWith ? strings().form?.treeSettings?.label?.searchModeStartsWith : strings().form?.treeSettings?.label?.searchModeIncludes }}
{{ model.jsonFormat ? strings().form?.treeSettings?.label?.jsonFormatTwoSpace : strings().form?.treeSettings?.label?.jsonFormatFourSpace }}
{{ model.animation ? strings().form?.treeSettings?.label?.animation : strings().form?.treeSettings?.label?.noAnimation }}
{{ model.undoEnabled ? (strings().form?.treeSettings?.label?.undoEnabled) : (strings().form?.treeSettings?.label?.undoDisabled) }}
{{ strings().form?.treeSettings?.undoHint }}
{{ model.showDiffBeforeSave ? (strings().form?.treeSettings?.label?.diffEnabled) : (strings().form?.treeSettings?.label?.diffDisabled) }}
`, encapsulation: ViewEncapsulation.None, styles: [` .md-block { width: 100%; } .p3xr-field-error .mdc-line-ripple::before, .p3xr-field-error .mdc-line-ripple::after { border-bottom-color: #f44336 !important; } .p3xr-field-error .mdc-floating-label, .p3xr-field-error .mat-mdc-form-field-required-marker { color: #f44336 !important; } .p3xr-field-error-text { color: #f44336; font-size: 12px; margin-top: -16px; padding-left: 16px; } .p3xr-field-hint-text { color: var(--mat-app-text-color, rgba(0, 0, 0, 0.6)); opacity: 0.7; font-size: 12px; margin-top: -16px; padding-left: 16px; } `], }) export class TreecontrolSettingsDialogComponent implements OnInit, AfterViewInit { @ViewChild('settingsForm') private formRef?: NgForm; model: any = {}; reducedFunctions = false; keysRawLength = 0; dbsize = 0; strings; constructor( @Inject(MatDialogRef) private dialogRef: MatDialogRef, @Inject(I18nService) private i18n: I18nService, @Inject(SettingsService) public settings: SettingsService, @Inject(RedisStateService) private state: RedisStateService, @Inject(CommonService) private common: CommonService, @Inject(MainCommandService) private cmd: MainCommandService, @Inject(SocketService) private socket: SocketService, @Inject(TreeBuilderService) private treeBuilder: TreeBuilderService, ) { this.strings = this.i18n.strings; } ngOnInit(): void { // Read current settings into local model this.model = { treeSeparator: this.settings.redisTreeDivider(), pageCount: this.settings.pageCount(), keyPageCount: this.settings.keyPageCount(), keysSort: this.settings.keysSort(), searchClientSide: this.settings.searchClientSide(), searchStartsWith: this.settings.searchStartsWith(), maxValueDisplay: this.settings.maxValueDisplay(), maxKeys: this.settings.maxKeys(), jsonFormat: this.settings.jsonFormat() === 2, animation: this.settings.animation(), undoEnabled: this.settings.undoEnabled(), showDiffBeforeSave: this.settings.showDiffBeforeSave(), }; // Read state from signals (with fallback to global) this.reducedFunctions = this.state.reducedFunctions() ?? false; this.keysRawLength = this.state.keysRaw()?.length ?? 0; this.dbsize = this.state.dbsize() ?? 0; } ngAfterViewInit(): void { // Validate all fields after the form is ready so pre-filled invalid values show errors setTimeout(() => this.validateAllFields()); } isFieldInvalid(fieldName: string, min: number, max: number): boolean { const value = this.model[fieldName]; if (value === null || value === undefined || value === '') { return true; } const num = Number(value); return isNaN(num) || !Number.isInteger(num) || num < min || num > max; } validateField(fieldName: string, min: number, max: number): void { const control = this.formRef?.controls[fieldName]; if (!control) { return; } if (this.isFieldInvalid(fieldName, min, max)) { control.setErrors({ range: true }); control.markAsTouched(); } else { control.setErrors(null); } } onFieldChange(fieldName: string, min: number, max: number): void { setTimeout(() => this.validateField(fieldName, min, max)); } validateAllFields(): void { this.validateField('pageCount', 10, 5000); this.validateField('keyPageCount', 5, 100); this.validateField('maxValueDisplay', -1, 32768); this.validateField('maxKeys', 5, 100000); } showFieldError(controlName: string): boolean { const control = this.formRef?.controls[controlName]; return !!control && control.invalid && (control.touched || this.formRef?.submitted); } private markAllControlsTouched(): void { if (!this.formRef) { return; } Object.values(this.formRef.controls).forEach((control) => { control.markAsTouched(); control.updateValueAndValidity(); }); } private handleInvalidForm(): boolean { if (this.formRef?.invalid) { this.common.toast({ message: this.strings().form?.error?.invalid, }); return false; } return true; } submit(): void { this.markAllControlsTouched(); const hasRangeError = this.isFieldInvalid('pageCount', 10, 5000) || this.isFieldInvalid('keyPageCount', 5, 100) || this.isFieldInvalid('maxValueDisplay', -1, 32768) || this.isFieldInvalid('maxKeys', 5, 100000); if (hasRangeError || !this.handleInvalidForm()) { this.common.toast({ message: this.strings().form?.error?.invalid, }); return; } // Save to Angular SettingsService signals this.settings.redisTreeDivider.set(this.model.treeSeparator); this.settings.pageCount.set(this.model.pageCount); this.settings.keyPageCount.set(this.model.keyPageCount); this.settings.keysSort.set(this.model.keysSort); this.settings.searchClientSide.set(this.model.searchClientSide); this.settings.searchStartsWith.set(this.model.searchStartsWith); this.settings.maxValueDisplay.set(this.model.maxValueDisplay); this.settings.maxKeys.set(this.model.maxKeys); this.settings.jsonFormat.set(this.model.jsonFormat ? 2 : 4); this.settings.animation.set(this.model.animation); this.settings.undoEnabled.set(this.model.undoEnabled); this.settings.showDiffBeforeSave.set(this.model.showDiffBeforeSave); this.state.page.set(1); this.state.redisChanged.set(true); // Always refresh from server — settings like sort, page size, max keys affect the data this.cmd.refresh().then(() => { this.socket.stateChanged$.next(); this.socket.tick(); }); this.dialogRef.close(); } cancel(): void { this.dialogRef.close(); } } src/ng/dialogs/treecontrol-settings-dialog.service.ts000066400000000000000000000020551517644030100233400ustar00rootroot00000000000000import { Injectable, Inject } from '@angular/core'; import { MatDialog } from '@angular/material/dialog'; import { createDialogPopupSettings } from './dialog-popup'; /** * Service to open the Tree Control Settings dialog. * Uses dynamic import() for lazy loading. */ @Injectable({ providedIn: 'root' }) export class TreecontrolSettingsDialogService { constructor(@Inject(MatDialog) private dialog: MatDialog) {} async show(options?: { $event?: any }): Promise { const { TreecontrolSettingsDialogComponent } = await import( /* webpackChunkName: "dialog-treecontrol-settings" */ './treecontrol-settings-dialog.component' ); const dialogRef = this.dialog.open(TreecontrolSettingsDialogComponent, createDialogPopupSettings({ width: '75vw', maxWidth: '75vw', panelClass: ['fullscreen-dialog', 'p3xr-tree-settings-dialog-panel'], })); return new Promise((resolve) => { dialogRef.afterClosed().subscribe(() => resolve()); }); } } src/ng/dialogs/ttl-dialog.component.ts000066400000000000000000000120211517644030100203010ustar00rootroot00000000000000import { Component, Inject, OnInit } from '@angular/core'; import { CommonModule } from '@angular/common'; import { FormsModule } 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 { 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 { I18nService } from '../services/i18n.service'; import { CommonService } from '../services/common.service'; import { SettingsService } from '../services/settings.service'; import timestring from 'timestring'; import humanizeDuration from 'humanize-duration'; export interface TtlDialogData { model: { ttl: number }; } /** * TTL dialog — Angular replacement for p3xrDialogTtl. * Edits TTL value with number input and human-readable timestring input. */ @Component({ selector: 'p3xr-ttl-dialog', standalone: true, imports: [ CommonModule, FormsModule, MatDialogModule, MatFormFieldModule, MatInputModule, MatButtonModule, MatIconModule, MatToolbarModule, DialogCancelButtonComponent, ], template: `
{{ strings().confirm?.ttl?.title }}
{{ strings().confirm?.ttl?.textContent }}
{{ strings().confirm?.ttl?.placeholder }} {{ strings().confirm?.ttl?.convertTextToTime }}
`, styles: [` .full-width { width: 100%; } `], }) export class TtlDialogComponent implements OnInit { model: { ttl: number } = { ttl: -1 }; convertTextToTime = ''; strings; constructor( @Inject(MatDialogRef) private dialogRef: MatDialogRef, @Inject(MAT_DIALOG_DATA) private data: TtlDialogData, @Inject(I18nService) private i18n: I18nService, @Inject(CommonService) private common: CommonService, @Inject(SettingsService) private settingsService: SettingsService, ) { this.strings = this.i18n.strings; } ngOnInit(): void { this.model = { ...this.data.model }; if (typeof this.model.ttl === 'number' && this.model.ttl > 0) { try { const hdOpts = this.settingsService.getHumanizeDurationOptions(); this.convertTextToTime = humanizeDuration(this.model.ttl * 1000, { ...hdOpts, delimiter: ' ', }); } catch (e) { this.convertTextToTime = ''; } } } onTextTimeChange(value: string): void { try { this.model.ttl = timestring(String(value), 's'); } catch (e) { console.warn('timestring parse error', e); } } openTimestringNpm(): void { window.open('https://www.npmjs.com/package/timestring#keywords', '_blank'); } submit(): void { if (isNaN(this.model.ttl)) { this.model.ttl = Math.round(this.model.ttl); } this.dialogRef.close({ model: this.model }); } cancel(): void { this.dialogRef.close(undefined); } } src/ng/dialogs/ttl-dialog.service.ts000066400000000000000000000022131517644030100177410ustar00rootroot00000000000000import { Injectable, Inject } from '@angular/core'; import { MatDialog } from '@angular/material/dialog'; import { createDialogPopupSettings } from './dialog-popup'; /** * Service to open the TTL dialog. * Uses dynamic import() for lazy loading — the dialog component code * is only downloaded when the dialog is first opened. */ @Injectable({ providedIn: 'root' }) export class TtlDialogService { constructor(@Inject(MatDialog) private dialog: MatDialog) {} async show(options: { $event?: any; model: { ttl: number } }): Promise<{ model: { ttl: number } }> { const { TtlDialogComponent } = await import( /* webpackChunkName: "dialog-ttl" */ './ttl-dialog.component' ); const dialogRef = this.dialog.open(TtlDialogComponent, createDialogPopupSettings({ data: { model: options.model }, })); return new Promise((resolve, reject) => { dialogRef.afterClosed().subscribe((result) => { if (result) { resolve(result); } else { reject(); } }); }); } } src/ng/layout/000077500000000000000000000000001517644030100135675ustar00rootroot00000000000000src/ng/layout/console-drawer.component.html000066400000000000000000000013531517644030100214040ustar00rootroot00000000000000
src/ng/layout/console-drawer.component.scss000066400000000000000000000070021517644030100214100ustar00rootroot00000000000000@use "../../scss/vars" as v; // --------------------------------------------------------------------------- // Global bottom console drawer // // Mounts at app-shell level (LayoutComponent), sibling of . // No wrapper chrome — the ConsoleComponent's own toolbar acts as the header. // When closed, height is 0 so the open/close transition animates smoothly. // When open, 30vh just above the footer toolbar. // --------------------------------------------------------------------------- :root { --p3xr-console-drawer-height: 30vh; } #p3xr-console-drawer { position: fixed; left: 5px; right: calc(5px + var(--p3xr-scroll-gutter, 0px)); bottom: var(--p3xr-layout-footer-height, 48px); height: 0; overflow: hidden; background: var(--p3xr-content-bg, var(--mat-app-background-color)); color: var(--mat-app-text-color, inherit); border: 0 solid var(--p3xr-accordion-bg); border-radius: 4px 4px 0 0; z-index: 8; transition: height 150ms ease-out; display: flex; flex-direction: column; &.p3xr-drawer-open { height: var(--p3xr-console-drawer-height); border-width: 1px; } } #p3xr-console-drawer-sizer { position: absolute; top: 0; left: 0; right: 0; height: 5px; cursor: ns-resize; z-index: 3; background-color: transparent; transition: background-color 0.15s ease, filter 0.15s ease; &:hover, &.p3xr-resizer-active { background-color: var(--p3xr-accordion-bg); } body.p3xr-theme-dark &:hover { filter: brightness(1.5); } body.p3xr-theme-dark &.p3xr-resizer-active { filter: brightness(2); } body.p3xr-theme-light &:hover { filter: brightness(0.75); } body.p3xr-theme-light &.p3xr-resizer-active { filter: brightness(0.5); } } // Suppress the height transition while the user is actively dragging so the // drawer follows the cursor 1:1 instead of easing toward each intermediate value. html.p3xr-console-drawer-resizing #p3xr-console-drawer.p3xr-drawer-open { transition: none; } #p3xr-console-drawer-body { flex: 1 1 auto; min-height: 0; overflow: hidden; position: relative; // The embedded console component must fill the body p3xr-console { display: block; height: 100%; width: 100%; } } .p3xr-console-drawer-empty { padding: 16px 20px; font-size: 13px; line-height: 1.6; display: flex; flex-direction: column; gap: 6px; .p3xr-console-drawer-empty-row { white-space: pre-wrap; } .p3xr-console-drawer-empty-title { font-size: 15px; font-weight: 500; margin-bottom: 4px; } .p3xr-console-drawer-empty-hint { opacity: 0.7; font-size: 12px; } } // --------------------------------------------------------------------------- // Footer Console button reuses the header's .p3xr-nav-active style when the // drawer is open — no custom styling needed here. // --------------------------------------------------------------------------- // Page content padding when drawer is open // // When the drawer is open, page content must not disappear behind it. // LayoutComponent toggles a class on the root; pages read the value. // --------------------------------------------------------------------------- html.p3xr-console-drawer-open { --p3xr-console-drawer-height-active: var(--p3xr-console-drawer-height); } html:not(.p3xr-console-drawer-open) { --p3xr-console-drawer-height-active: 0px; } src/ng/layout/console-drawer.component.ts000066400000000000000000000154721517644030100210750ustar00rootroot00000000000000import { Component, Inject, ChangeDetectionStrategy, CUSTOM_ELEMENTS_SCHEMA, ViewEncapsulation, ChangeDetectorRef, computed, signal, AfterViewInit, OnDestroy, } from '@angular/core'; import { CommonModule } from '@angular/common'; import { I18nService } from '../services/i18n.service'; import { RedisStateService } from '../services/redis-state.service'; import { ConsoleComponent } from '../pages/console/console.component'; /** * Global bottom console drawer. * * Mounts once at the app-shell level (LayoutComponent), sibling of . * Visibility driven by state.consoleDrawerOpen signal. * * No wrapper chrome — the ConsoleComponent's own toolbar serves as the header, * with the close button emitted back via (closeRequest). * * When connectionState === 'none' | 'connecting', renders a limited-mode empty * state banner instead of the full console. */ @Component({ selector: 'p3xr-console-drawer', standalone: true, imports: [ CommonModule, ConsoleComponent, ], schemas: [CUSTOM_ELEMENTS_SCHEMA], templateUrl: './console-drawer.component.html', styleUrls: ['./console-drawer.component.scss'], encapsulation: ViewEncapsulation.None, changeDetection: ChangeDetectionStrategy.OnPush, }) export class ConsoleDrawerComponent implements AfterViewInit, OnDestroy { private static readonly HEIGHT_KEY = 'p3xr-console-drawer-height'; private static readonly MIN_VH = 15; private static readonly MAX_VH = 66; private static readonly FOOTER_HEIGHT = 48; readonly strings; readonly isOpen = computed(() => this.state.consoleDrawerOpen()); readonly isConnected = computed(() => this.state.connectionState() === 'connected'); readonly isConnecting = computed(() => this.state.connectionState() === 'connecting'); readonly connectionName = computed(() => this.state.connection()?.name ?? ''); readonly resizeClicked = signal(false); private drawerResizeObserver: ResizeObserver | undefined; constructor( @Inject(I18nService) readonly i18n: I18nService, @Inject(RedisStateService) readonly state: RedisStateService, @Inject(ChangeDetectorRef) private readonly cdr: ChangeDetectorRef, ) { this.strings = this.i18n.strings; } ngAfterViewInit(): void { // Saved height is applied at bootstrap (src/core/console-drawer-height.ts) // so it's in place before this component mounts. document.addEventListener('mousedown', this.boundResizeClick); document.addEventListener('mouseup', this.boundResizeClick); document.addEventListener('mousemove', this.boundDocumentMousemove); // Observe the drawer element itself — fires on every size change frame, // covering the open/close height transition as well as live drag. // Listeners (database tree, console rawResize) pick it up via window.resize. const drawerEl = document.getElementById('p3xr-console-drawer'); if (drawerEl && typeof ResizeObserver !== 'undefined') { this.drawerResizeObserver = new ResizeObserver(() => { window.dispatchEvent(new Event('resize')); }); this.drawerResizeObserver.observe(drawerEl); } } ngOnDestroy(): void { document.removeEventListener('mousedown', this.boundResizeClick); document.removeEventListener('mouseup', this.boundResizeClick); document.removeEventListener('mousemove', this.boundDocumentMousemove); this.drawerResizeObserver?.disconnect(); } close(): void { this.state.setConsoleDrawerOpen(false); this.cdr.markForCheck(); } private computeBounds(): { minPx: number; maxPx: number } { return { minPx: (ConsoleDrawerComponent.MIN_VH / 100) * window.innerHeight, maxPx: (ConsoleDrawerComponent.MAX_VH / 100) * window.innerHeight, }; } private readonly boundResizeClick = (event: MouseEvent) => this.resizeClick(event); private readonly boundDocumentMousemove = (event: MouseEvent) => this.documentMousemove(event); private resizeClick(event: MouseEvent): void { const target = event.target as HTMLElement | null; if (event.type === 'mousedown') { if (!target || target.id !== 'p3xr-console-drawer-sizer') return; this.resizeClicked.set(true); this.applyBoundCursor(false); document.body.classList.add('p3xr-not-selectable'); document.documentElement.classList.add('p3xr-console-drawer-resizing'); event.stopPropagation(); event.preventDefault(); } else if (event.type === 'mouseup') { if (!this.resizeClicked()) return; this.resizeClicked.set(false); this.clearBoundCursor(); document.body.classList.remove('p3xr-not-selectable'); document.documentElement.classList.remove('p3xr-console-drawer-resizing'); const current = document.documentElement.style.getPropertyValue('--p3xr-console-drawer-height'); if (current && current.endsWith('px')) { localStorage.setItem(ConsoleDrawerComponent.HEIGHT_KEY, current); } event.stopPropagation(); } } private documentMousemove(event: MouseEvent): void { if (!this.resizeClicked()) return; const { minPx, maxPx } = this.computeBounds(); let newHeight = window.innerHeight - event.clientY - ConsoleDrawerComponent.FOOTER_HEIGHT; const outOfBounds = newHeight < minPx || newHeight > maxPx; if (newHeight < minPx) newHeight = minPx; if (newHeight > maxPx) newHeight = maxPx; this.applyBoundCursor(outOfBounds); document.documentElement.style.setProperty('--p3xr-console-drawer-height', `${Math.round(newHeight)}px`); } /** Force cursor inline with `!important` on html/body/sizer — beats any CSS rule. */ private applyBoundCursor(outOfBounds: boolean): void { const sizerEl = document.getElementById('p3xr-console-drawer-sizer'); if (outOfBounds) { document.documentElement.style.setProperty('cursor', 'not-allowed', 'important'); document.body.style.setProperty('cursor', 'not-allowed', 'important'); sizerEl?.style.setProperty('cursor', 'not-allowed', 'important'); } else { document.documentElement.style.setProperty('cursor', 'ns-resize', 'important'); document.body.style.setProperty('cursor', 'ns-resize', 'important'); sizerEl?.style.removeProperty('cursor'); } } private clearBoundCursor(): void { const sizerEl = document.getElementById('p3xr-console-drawer-sizer'); document.documentElement.style.removeProperty('cursor'); document.body.style.removeProperty('cursor'); sizerEl?.style.removeProperty('cursor'); } } src/ng/layout/layout.component.html000066400000000000000000000347071517644030100200060ustar00rootroot00000000000000
@if (isWide) { } @else { } @if (!showLogin) { @if (currentConnection) { @if (isWide) { } @else { } } @if (currentConnection) { @if (isWide) { } @else { } } @if (currentConnection && hasRediSearch) { @if (isWide) { } @else { } } } @if (!showLogin) { @if (isWide) { } @else { } @if (isWide) { } @else { } } @if (auth.authRequired() && auth.isAuthenticated()) { }
@if (currentVersion && isWide) {
{{ currentVersion }}
}
@if (showLogin) { } @else { }
@if (!showLogin && state.connectionState() === 'connected') { } src/ng/layout/layout.component.scss000066400000000000000000000063631517644030100200120ustar00rootroot00000000000000// The global p3xr-layout.scss defines all the positional rules // (#p3xr-layout-header-container, #p3xr-layout-footer-container, etc.) // via src/injector.scss — no duplication needed here. @use '../../scss/vars' as v; // Host element: block so header+footer fixed divs overlay the page correctly. :host { display: block; } // Flex spacer used in both header and footer toolbars. .p3xr-layout-spacer { flex: 1 1 auto; } .p3xr-layout-content, .p3xr-layout-content-electron { position: fixed; top: v.$toolbar-height; left: 0; right: 0; bottom: calc(#{v.$toolbar-height} + var(--p3xr-console-drawer-height-active, 0px)); overflow-y: auto; overflow-x: hidden; transition: bottom 150ms ease-out; } #p3xr-layout-header-version { position: fixed; top: 35px; left: 20px; width: 120px; text-align: right; z-index: 3; font-size: 10px; line-height: 1; opacity: 0.7; pointer-events: none; } .p3xr-layout-content-electron { } .p3xr-layout-content { padding: v.$layout-padding; // 4px of breathing room between page content and footer/drawer edge — just // enough to avoid content touching the separator, nothing wasted. padding-bottom: 4px !important; // Monitoring page owns its own 100% layout (tab shell + scrollable content // area). The 4px would add unwanted empty space below the tab shell, so // drop it when a monitoring shell is present. &:has(p3xr-monitoring-shell) { padding-bottom: 0 !important; } } // Active navigation button highlight .p3xr-nav-active.mat-mdc-button { background-color: rgba(255, 255, 255, 0.1) !important; } body.p3xr-mat-theme-matrix .p3xr-nav-active.mat-mdc-button { background-color: rgba(0, 0, 0, 0.15) !important; } // Toolbar icon buttons: fix icon centering and rectangular hover .mat-toolbar .mat-mdc-icon-button { border-radius: 4px !important; .mat-icon { margin: 0 !important; } .mat-mdc-button-persistent-ripple { border-radius: 4px !important; } } // Active navigation button highlight .p3xr-nav-active { background-color: rgba(255, 255, 255, 0.1) !important; border-radius: 4px !important; } body.p3xr-mat-theme-matrix .p3xr-nav-active { background-color: rgba(0, 0, 0, 0.15) !important; } #p3xr-layout-header-container { top: 0px; } #p3xr-layout-footer-container { bottom: 0px; } #p3xr-layout-header-container, #p3xr-layout-footer-container { position: fixed; z-index: 2; left: 0px; width: 100%; } // Connection menu group labels .p3xr-connection-menu-group-label { padding: 6px 16px 2px; font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px; opacity: 0.6; pointer-events: none; } // Shared keyboard-key badge used in Info page and Command Palette .p3xr-kbd { display: inline-block; padding: 2px 8px; font-family: 'Roboto Mono', monospace; font-size: 12px; border: 1px solid var(--p3xr-list-border, rgba(0, 0, 0, 0.12)); border-radius: 4px; background: var(--p3xr-input-bg, #f5f5f5); color: var(--p3xr-input-color, #333); min-width: 70px; text-align: center; white-space: nowrap; } .p3xr-kbd-small { min-width: 50px; font-size: 11px; } src/ng/layout/layout.component.ts000066400000000000000000000644521517644030100174700ustar00rootroot00000000000000import { Component, Inject, OnInit, OnDestroy, HostListener, NgZone, ChangeDetectorRef, ChangeDetectionStrategy, CUSTOM_ELEMENTS_SCHEMA, ViewEncapsulation, ViewChild, ElementRef, effect } from '@angular/core'; import { CommonModule } from '@angular/common'; import { RouterModule, Router, NavigationEnd } from '@angular/router'; import { MatToolbarModule } from '@angular/material/toolbar'; import { MatButtonModule } from '@angular/material/button'; import { MatIconModule } from '@angular/material/icon'; import { MatMenuModule, MatMenuTrigger } from '@angular/material/menu'; import { MatDividerModule } from '@angular/material/divider'; import { MatTooltipModule } from '@angular/material/tooltip'; import { BreakpointObserver } from '@angular/cdk/layout'; import { filter } from 'rxjs/operators'; import { ThemeService } from '../services/theme.service'; import { I18nService } from '../services/i18n.service'; import { RedisStateService } from '../services/redis-state.service'; import { SocketService } from '../services/socket.service'; import { CommonService } from '../services/common.service'; import { NavigationService } from '../services/navigation.service'; import { AskAuthorizationDialogService } from '../dialogs/ask-authorization-dialog.service'; import { MainCommandService } from '../services/main-command.service'; import { ShortcutsService } from '../services/shortcuts.service'; import { OverlayService } from '../services/overlay.service'; import { SettingsService } from '../services/settings.service'; import { AuthService } from '../services/auth.service'; import { IconRegistryService } from '../services/icon-registry.service'; import { LoginComponent } from '../components/login.component'; import { ConsoleDrawerComponent } from './console-drawer.component'; import { installOverlayScrolls } from '../../core/overlay-scroll'; /** * Angular layout component — replaces the AngularJS p3xrLayout component. * * Renders the fixed header toolbar (app name, home, settings) and fixed footer * toolbar (connection menu, disconnect, donate, language, theme, github). * * Electron bridge: * global.p3xrSetLanguage(key) — called by webview inject script to set language * global.p3xrSetMenu(route) — called by webview inject script to navigate * * Both globals are preserved exactly as they were in the AngularJS controller * so existing Electron integration continues to work without any changes. */ @Component({ selector: 'p3xr-layout', standalone: true, imports: [ CommonModule, RouterModule, MatToolbarModule, MatButtonModule, MatIconModule, MatMenuModule, MatDividerModule, MatTooltipModule, LoginComponent, ConsoleDrawerComponent, ], schemas: [CUSTOM_ELEMENTS_SCHEMA], templateUrl: './layout.component.html', styleUrls: ['./layout.component.scss'], encapsulation: ViewEncapsulation.None, changeDetection: ChangeDetectionStrategy.OnPush, }) export class LayoutComponent implements OnInit, OnDestroy { // Header buttons: text hidden below 720px (matches AngularJS p3xr-button component) isWide = true; // Footer buttons: different AngularJS breakpoints per button isGtXs = true; // >600px — Theme button text (AngularJS: hide-xs) isGtSm = true; // >960px — Disconnect/Language/GitHub text (AngularJS: hide-xs hide-sm) isElectron = false; isElectronInitialized = false; private readonly unsubFns: Array<() => void> = []; constructor( @Inject(NgZone) private readonly ngZone: NgZone, @Inject(BreakpointObserver) private readonly breakpointObserver: BreakpointObserver, @Inject(ThemeService) readonly theme: ThemeService, @Inject(I18nService) readonly i18n: I18nService, @Inject(RedisStateService) readonly state: RedisStateService, @Inject(SocketService) private readonly socket: SocketService, @Inject(CommonService) private readonly common: CommonService, @Inject(AskAuthorizationDialogService) private readonly authDialog: AskAuthorizationDialogService, @Inject(NavigationService) private readonly nav: NavigationService, @Inject(Router) private readonly router: Router, @Inject(MainCommandService) private readonly cmd: MainCommandService, @Inject(ChangeDetectorRef) private readonly cdr: ChangeDetectorRef, @Inject(ShortcutsService) readonly shortcuts: ShortcutsService, @Inject(OverlayService) private readonly overlay: OverlayService, @Inject(SettingsService) private readonly settings: SettingsService, @Inject(AuthService) readonly auth: AuthService, @Inject(IconRegistryService) iconRegistry: IconRegistryService, ) { iconRegistry.registerAll(); // Reflect the console-drawer open state on so any page can layout // around it via CSS custom properties (see console-drawer.component.scss). // The drawer is always mounted (so loadSavedHeight runs at app start), // but it only opens visually when a connection is live. effect(() => { const open = this.state.consoleDrawerOpen(); const connected = this.state.connectionState() === 'connected'; if (open && connected) { document.documentElement.classList.add('p3xr-console-drawer-open'); } else { document.documentElement.classList.remove('p3xr-console-drawer-open'); } // Note: a ResizeObserver inside ConsoleDrawerComponent fires window.resize // on every frame of the height transition, so pages re-layout live. }); } @HostListener('document:keydown', ['$event']) onKeydown(event: KeyboardEvent): void { // Ctrl+` (or Cmd+` on Mac) toggles the bottom console drawer globally. if (event.key === '`' && (event.ctrlKey || event.metaKey) && !event.altKey && !event.shiftKey) { event.preventDefault(); this.toggleConsoleDrawer(); return; } this.shortcuts.handleKeydown(event); } toggleConsoleDrawer(): void { this.state.toggleConsoleDrawer(); this.cdr.markForCheck(); } ngOnInit(): void { // Check auth status — only proceed with app init when authenticated this.auth.checkAuthStatus().then(() => { this.cdr.markForCheck(); if (this.auth.isAuthenticated()) { // Auto-connect from localStorage on startup const savedConnection = this.readConnectionFromStorage(); if (savedConnection) { this.connect(savedConnection); } } }); // Initialize filtered languages list this.filterLanguages(); // Prefetch other GUI frameworks — fetch HTML, parse script/style tags, cache all assets setTimeout(() => { for (const gui of ['/react/', '/vue/']) { fetch(gui).then(r => r.text()).then(html => { const doc = new DOMParser().parseFromString(html, 'text/html'); doc.querySelectorAll('script[src], link[rel="stylesheet"]').forEach((el: Element) => { const url = (el as any).src || (el as any).href; if (url) fetch(url).catch(() => {}); }); }).catch(() => {}); } }, 3000); // Header: 720px (matches AngularJS p3xr-button component threshold) const sub720 = this.breakpointObserver.observe('(min-width: 720px)').subscribe(r => { this.isWide = r.matches; this.cdr.markForCheck(); }); // Footer: 600px (AngularJS hide-xs — Theme button) const sub600 = this.breakpointObserver.observe('(min-width: 600px)').subscribe(r => { this.isGtXs = r.matches; this.cdr.markForCheck(); }); // Footer: 960px (AngularJS hide-xs hide-sm — Disconnect/Language/GitHub) const sub960 = this.breakpointObserver.observe('(min-width: 960px)').subscribe(r => { this.isGtSm = r.matches; this.cdr.markForCheck(); }); this.unsubFns.push(() => { sub720.unsubscribe(); sub600.unsubscribe(); sub960.unsubscribe(); }); this.isElectron = /electron/i.test(navigator.userAgent); // Subscribe to socket events this.subscribeSocketEvents(); // Google Analytics route tracking this.setupRouteTracking(); // Subscribe to connect/disconnect requests from other components const subConnect = this.cmd.connectRequest$.subscribe((req) => { this.connect(req.connection); }); const subDisconnect = this.cmd.disconnectRequest$.subscribe(() => { this.disconnect(); }); this.unsubFns.push(() => { subConnect.unsubscribe(); subDisconnect.unsubscribe(); }); // Expose Electron bridge globals with a delay so the app is fully ready. setTimeout(() => this.setupElectronBridge(), 3000); // Promo toast — demo site only, once per session if (window.location.hostname === 'p3x.redis.patrikx3.com' && !sessionStorage.getItem('p3xr-promo-shown')) { setTimeout(() => { const promo = this.i18n.strings()?.promo; if (promo?.toastMessage) { sessionStorage.setItem('p3xr-promo-shown', '1'); const msg = promo.toastMessage + (promo.disclaimer ? ' · ' + promo.disclaimer : ''); this.common.toast({ message: msg, hideDelay: 30000 }); } }, 5000); } // Custom overlay scrollbar — macOS-style thin thumb, applied app-wide to // every scrollable element. CodeMirror / xterm / Monaco are excluded // inside the helper so they keep their own native scrollbars. this.uninstallOverlayScrolls = installOverlayScrolls(); } private uninstallOverlayScrolls: (() => void) | null = null; ngOnDestroy(): void { this.unsubFns.forEach(fn => fn()); this.uninstallOverlayScrolls?.(); } // --- Computed properties (read by template) --- get connectionName(): string { const conn = this.state.connection(); const strings = this.i18n.strings(); if (conn) { const fn = strings?.label?.connected; return typeof fn === 'function' ? fn({ name: conn.name }) : (conn.name ?? ''); } return strings?.intention?.connect ?? 'Connect'; } readonly sortedThemeKeys = [ 'light', 'enterprise', 'dark', 'darkNeu', 'darkoBluo', 'matrix', 'redis', ]; get themeSelectedKey(): string { const theme = this.theme.currentTheme(); if (!theme.startsWith('p3xrTheme')) return ''; const raw = theme.slice('p3xrTheme'.length); return raw.charAt(0).toLowerCase() + raw.slice(1); } get showLogin(): boolean { return this.auth.authChecked() && this.auth.authRequired() && !this.auth.isAuthenticated(); } get hasRediSearch(): boolean { return !!this.state.hasRediSearch(); } get reducedFunctions(): boolean { return !!this.state.reducedFunctions(); } get currentVersion(): string | undefined { return this.state.version(); } get connectionsList(): any[] { return this.state.connections()?.list ?? []; } get groupedConnectionsList(): Array<{ name: string; connections: any[] }> { const list = this.connectionsList; let groupMode = false; try { groupMode = localStorage.getItem('p3xr-connection-group-mode') === 'true'; } catch { /* ignore */ } if (!groupMode) { return [{ name: '', connections: list }]; } const groups = new Map(); for (const conn of list) { const groupName = conn.group?.trim() || ''; 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 }); } return result; } get currentConnection(): any { return this.state.connection(); } @ViewChild('languageSearchInput') languageSearchInput!: ElementRef; @ViewChild('languageMenuTrigger') languageMenuTrigger!: MatMenuTrigger; languageSearch = ''; filteredLanguages: string[] = []; highlightedLanguageIndex = 0; get availableLanguages(): string[] { return Object.keys(this.i18n.strings()?.language ?? {}); } onLanguageSearchInput(value: string): void { this.languageSearch = value; this.filterLanguages(); this.highlightedLanguageIndex = this.findCurrentLanguageIndex(); this.cdr.markForCheck(); } onLanguageMenuOpened(): void { this.highlightedLanguageIndex = this.findCurrentLanguageIndex(); setTimeout(() => { this.languageSearchInput?.nativeElement?.focus(); this.scrollHighlightedLanguageIntoView(); }); } private findCurrentLanguageIndex(): number { const idx = this.filteredLanguages.indexOf(this.i18n.currentLang()); return idx >= 0 ? idx : 0; } onLanguageMenuClosed(): void { this.languageSearch = ''; this.filterLanguages(); } onLanguageSearchKeydown(event: KeyboardEvent): void { if (event.key === 'Escape') { this.languageMenuTrigger.closeMenu(); return; } if (event.key === 'Enter') { event.preventDefault(); this.onLanguageSearchEnter(); return; } if (event.key === 'ArrowDown' || event.key === 'ArrowUp') { event.preventDefault(); const len = this.filteredLanguages.length; if (len === 0) return; if (event.key === 'ArrowDown') { this.highlightedLanguageIndex = (this.highlightedLanguageIndex + 1) % len; } else { this.highlightedLanguageIndex = (this.highlightedLanguageIndex - 1 + len) % len; } this.scrollHighlightedLanguageIntoView(); this.cdr.markForCheck(); return; } event.stopPropagation(); } onLanguageSearchEnter(): void { if (this.filteredLanguages.length > 0) { this.setLanguage(this.filteredLanguages[this.highlightedLanguageIndex]); this.languageMenuTrigger.closeMenu(); } } private scrollHighlightedLanguageIntoView(): void { setTimeout(() => { const menu = document.querySelector('.p3xr-language-menu .mat-mdc-menu-content'); if (!menu) return; const items = menu.querySelectorAll('.mat-mdc-menu-item'); const target = items[this.highlightedLanguageIndex]; target?.scrollIntoView({ block: 'nearest' }); }); } private filterLanguages(): void { const all = this.availableLanguages; const search = this.languageSearch.trim().toLowerCase(); if (!search) { this.filteredLanguages = all; return; } this.filteredLanguages = all.filter(key => { const label = this.languageLabel(key).toLowerCase(); return label.includes(search) || key.toLowerCase().includes(search); }); } themeLabel(key: string): string { return this.i18n.strings()?.label?.theme?.[key] ?? key; } languageLabel(key: string): string { return this.i18n.strings()?.language?.[key] ?? key; } // --- Actions --- isActivePage(page: string): boolean { const url = this.nav.currentUrl; switch (page) { case 'database': return url.startsWith('/database'); case 'search': return url === '/search'; case 'monitoring': return url.startsWith('/monitoring'); case 'info': return url === '/info'; case 'settings': return url === '/settings'; default: return false; } } navigateTo(stateName: string, params?: any): void { this.nav.navigateTo(stateName, params); } reloadPage(): void { location.href = '/ng/'; } async logout(): Promise { try { await this.common.confirm({ message: this.i18n.strings()?.intention?.logout, }); this.auth.logout(); } catch { // cancelled } } setTheme(key: string): void { this.theme.setTheme(this.theme.generateThemeName(key)); } setThemeAuto(): void { this.theme.setTheme('auto'); } async setLanguage(key: string): Promise { try { this.i18n.setLanguage(key); if (this.isElectron) { await this.socket.request({ action: 'settings/language', payload: { key } }); this.isElectronInitialized = true; } this.filterLanguages(); this.cdr.markForCheck(); } catch (e) { this.common.generalHandleError(e); } } async connect(connection: any): Promise { console.time('connect'); connection = this.cloneConnection(connection); try { const dbStorageKey = this.settings.getStorageKeyCurrentDatabase(connection.id); const db = this.getStorageString(dbStorageKey); if (connection.askAuth === true) { const auth = await this.authDialog.show(); connection.username = auth.username || undefined; connection.password = auth.password || undefined; } const strings = this.i18n.strings(); this.overlay.show({ message: strings?.title?.connectingRedis ?? 'Connecting...', }); this.state.connectionState.set('connecting'); const response = await this.socket.request({ action: 'connection/connect', payload: { connection, db }, }); // Update state signals directly this.state.page.set(1); this.state.monitor.set(false); this.state.dbsize.set(response.dbsize); const databaseIndexes: number[] = []; let i = 0; while (i < response.databases) databaseIndexes.push(i++); this.state.databaseIndexes.set(databaseIndexes); this.state.connection.set(connection); const commands: string[] = []; Object.keys(response.commands ?? {}).forEach(k => { commands.push(response.commands[k][0]); }); commands.sort(); this.state.commands.set(commands); this.state.commandsMeta.set(response.commandsMeta ?? {}); // Detect loaded Redis modules const modules = Array.isArray(response.modules) ? response.modules : []; this.state.modules.set(modules); this.state.hasReJSON.set(modules.some((m: any) => m.name === 'ReJSON')); this.state.hasRediSearch.set(modules.some((m: any) => m.name === 'search')); this.state.hasTimeSeries.set(modules.some((m: any) => m.name === 'timeseries' || m.name === 'Timeseries')); this.state.hasBloom.set(modules.some((m: any) => m.name === 'bf')); await this.common.loadRedisInfoResponse({ response }); this.socket.stateChanged$.next(); this.setStorageObject( this.settings.connectInfoStorageKey, connection, ); this.state.connectionState.set('connected'); // No navigation — just refresh the current view in place } catch (error) { this.removeStorageItem(this.settings.connectInfoStorageKey); this.state.connection.set(undefined); this.state.connectionState.set('none'); this.common.generalHandleError(error); } finally { this.overlay.hide(); this.cdr.markForCheck(); } console.timeEnd('connect'); } async disconnect(): Promise { await this.cmd.disconnect(); this.cdr.markForCheck(); } reducedFunctionality(): void { const strings = this.i18n.strings(); const fn = strings?.label?.tooManyKeys; const message = typeof fn === 'function' ? fn({ count: this.state.keysRaw()?.length ?? 0, maxLightKeysCount: this.settings.maxLightKeysCount, }) : ''; this.common.confirm({ disableCancel: true, message }).catch(() => {}); } openLink(target: 'github' | 'githubRelease' | 'githubChangelog' | 'donate'): void { const urls: Record = { github: 'https://github.com/patrikx3/redis-ui', githubRelease: 'https://github.com/patrikx3/redis-ui/releases', githubChangelog: 'https://github.com/patrikx3/redis-ui/blob/master/change-log.md#change-log', donate: 'https://www.paypal.me/patrikx3', }; window.open(urls[target], '_blank'); } // --- Private helpers --- private cloneConnection(connection: any): any { return structuredClone(connection); } private readConnectionFromStorage(): any { return this.getStorageObject( this.settings.connectInfoStorageKey, ); } private getStorageString(name: string | undefined): string | undefined { if (!name) return undefined; try { return localStorage.getItem(name) ?? undefined; } catch { return undefined; } } private getStorageObject(name: string | undefined): any { const raw = this.getStorageString(name); if (!raw) return undefined; try { return JSON.parse(raw); } catch { return undefined; } } private setStorageObject(name: string | undefined, value: any): void { if (!name) return; try { localStorage.setItem(name, JSON.stringify(value)); } catch {} } private removeStorageItem(name: string | undefined): void { if (!name) return; try { localStorage.removeItem(name); } catch {} } private subscribeSocketEvents(): void { const sub1 = this.socket.redisDisconnected$.subscribe(() => { this.state.connection.set(undefined); this.state.connectionState.set('none'); this.nav.navigateTo('settings'); this.cdr.markForCheck(); }); const sub2 = this.socket.socketError$.subscribe(() => { this.cdr.markForCheck(); }); const sub3 = this.socket.connections$.subscribe(() => { this.cdr.markForCheck(); }); const sub4 = this.socket.configuration$.subscribe(() => { this.cdr.markForCheck(); }); this.unsubFns.push( () => { sub1.unsubscribe(); sub2.unsubscribe(); sub3.unsubscribe(); sub4.unsubscribe(); } ); } private setupRouteTracking(): void { const sub = this.router.events.pipe( filter((event): event is NavigationEnd => event instanceof NavigationEnd) ).subscribe((event) => { // Update currentPage signal — used by the console drawer + AI context this.state.currentPage.set(this.urlToPage(event.urlAfterRedirects)); // Google Analytics page tracking if (/spider|bot|yahoo|bing|google|yandex|crawl|slurp|curl/i.test(navigator.userAgent)) return; try { const path = event.urlAfterRedirects.toLowerCase().startsWith('/database/key/') ? '/database/key' : event.urlAfterRedirects; (globalThis as any).gtag?.('config', this.settings.googleAnalytics, { page_path: path }, ); } catch { /* noop */ } }); this.unsubFns.push(() => sub.unsubscribe()); } private urlToPage(url: string): 'connections' | 'database' | 'pulse' | 'profiler' | 'pubsub' | 'analysis' | 'search' | 'timeseries' | 'info' | 'settings' | 'unknown' { const u = url.toLowerCase(); if (u.startsWith('/database')) return 'database'; if (u.startsWith('/monitoring/profiler')) return 'profiler'; if (u.startsWith('/monitoring/pubsub')) return 'pubsub'; if (u.startsWith('/monitoring/memory-analysis') || u.startsWith('/monitoring/analysis')) return 'analysis'; if (u.startsWith('/monitoring')) return 'pulse'; if (u.startsWith('/search')) return 'search'; if (u.startsWith('/timeseries')) return 'timeseries'; if (u.startsWith('/info')) return 'info'; if (u.startsWith('/settings')) return 'settings'; return 'unknown'; } /** * Expose the Electron bridge globals. * * Electron injects a script into the webview that calls: * global.p3xrSetLanguage(key) — sets the UI language * global.p3xrSetMenu(route) — navigates to a route * * These are the SAME globals as the AngularJS controller exposed. * Keeping them with the same names and behaviour ensures no changes are * needed in the Electron host application. */ private setupElectronBridge(): void { if (!this.isElectron) return; // Listen for postMessage from the Electron shell (iframe parent). window.addEventListener('message', (event: MessageEvent) => { const data = event.data; if (!data || typeof data.type !== 'string') return; if (data.type === 'p3x-set-language' && typeof data.translation === 'string') { this.ngZone.run(async () => { try { await this.setLanguage(data.translation); } catch (e) { console.warn('[LayoutComponent] p3x-set-language failed', e); } }); } else if (data.type === 'p3x-menu' && typeof data.action === 'string') { this.ngZone.run(() => { try { this.nav.navigateTo(data.action); } catch (e) { console.warn('[LayoutComponent] p3x-menu failed', e); } }); } }); } } src/ng/main.ts000066400000000000000000000036311517644030100135510ustar00rootroot00000000000000import { bootstrapApplication } from '@angular/platform-browser'; import { importProvidersFrom } from '@angular/core'; import { RouterModule } from '@angular/router'; import { MatSnackBarModule, MatSnackBar } from '@angular/material/snack-bar'; import { MatDialogModule } from '@angular/material/dialog'; import { MAT_FORM_FIELD_DEFAULT_OPTIONS } from '@angular/material/form-field'; import { MAT_TOOLTIP_DEFAULT_OPTIONS } from '@angular/material/tooltip'; import { appRoutes } from './app.routes'; import { LayoutComponent } from './layout/layout.component'; import { RedisStateService } from './services/redis-state.service'; import { SettingsService } from './services/settings.service'; import { loadSavedConsoleDrawerHeight } from '../core/console-drawer-height'; // Apply the saved console drawer height BEFORE Angular bootstraps so the CSS // var is in place when the layout + drawer first render. Otherwise the drawer // briefly flashes at the 30vh default before ConsoleDrawerComponent's // ngAfterViewInit runs. loadSavedConsoleDrawerHeight(); bootstrapApplication(LayoutComponent, { providers: [ importProvidersFrom( RouterModule.forRoot(appRoutes), MatSnackBarModule, MatDialogModule, ), { provide: MAT_FORM_FIELD_DEFAULT_OPTIONS, useValue: { appearance: 'fill' } }, { provide: MAT_TOOLTIP_DEFAULT_OPTIONS, useValue: { position: 'above' } }, ], }).then((appRef) => { (globalThis as any).__p3xr_snackbar = appRef.injector.get(MatSnackBar); // Expose state for Playwright E2E tests const stateService = appRef.injector.get(RedisStateService); const settingsService = appRef.injector.get(SettingsService); (globalThis as any).__p3xr_test = { state: stateService, settings: settingsService, }; console.info('Angular bootstrap complete'); }).catch(err => { console.error('Angular bootstrap error:', err); }); src/ng/pages/000077500000000000000000000000001517644030100133515ustar00rootroot00000000000000src/ng/pages/console/000077500000000000000000000000001517644030100150135ustar00rootroot00000000000000src/ng/pages/console/console.component.html000066400000000000000000000143601517644030100213500ustar00rootroot00000000000000
@if (type !== 'quick') { terminal {{ strings().label?.console }} @if (isAiGloballyEnabled()) { {{ aiAutoDetect ? 'check_box' : 'check_box_outline_blank' }} Auto AI } } @else { terminal {{ embedded ? (strings().label?.console) : (strings().intention?.quickConsole) }} @if (isAiGloballyEnabled()) { {{ aiAutoDetect ? 'check_box' : 'check_box_outline_blank' }} Auto AI } @if (!embedded) { } @if (showCloseButton) { } }
@if (type === 'quick' && !embedded) {
}
@if (type !== 'quick') {
}
@if (currentHint) {
{{ currentHint }}
} @if (aiLoading) { } @for (group of filteredCommands; track group.group) { @for (cmd of group.commands; track cmd.name) { {{ cmd.name }} @if (cmd.syntax) { {{ cmd.syntax }} } } }
src/ng/pages/console/console.component.scss000066400000000000000000000140231517644030100213530ustar00rootroot00000000000000p3xr-console { display: block; width: 100%; height: 100%; } .p3xr-console-root { display: flex; flex-direction: column; width: 100%; height: 100%; } .p3xr-console-root-embedded { overflow: hidden; #p3xr-console-content { flex: 1 1 auto; min-height: 0; } } p3xr-console .mat-toolbar { min-height: 48px; height: 48px; position: relative; z-index: 2; padding: 0; } p3xr-console .mat-toolbar * { color: inherit; } // Buttons inside console toolbar: inherit color, match AngularJS md-button styling p3xr-console .mat-toolbar .mat-mdc-button-base:not(.mat-mdc-fab-base) { color: inherit !important; letter-spacing: 0.1px !important; text-transform: uppercase !important; height: 36px !important; min-height: 36px !important; min-width: auto !important; padding: 0px 8px !important; margin: 0px 8px !important; display: inline-flex !important; align-items: center !important; justify-content: center !important; // Hover: accordion-style subtle overlay &:hover { background-color: rgba(0, 0, 0, 0.08) !important; } } p3xr-console .mat-toolbar .mat-mdc-button-base:not(.mat-mdc-fab-base) *, p3xr-console .mat-toolbar .mat-mdc-button-base:not(.mat-mdc-fab-base) .mdc-button__label { color: inherit !important; letter-spacing: 0.1px !important; } p3xr-console .mat-toolbar mat-icon, p3xr-console .mat-toolbar .material-icons { font-size: 24px; width: 24px; height: 24px; } .p3xr-console-toolbar-tools { display: flex; align-items: center; width: 100%; height: 100%; padding: 0 3px; } .p3xr-console-toolbar-actions { display: inline-flex; align-items: center; } // AI auto-detect toggle — custom icon button matching toolbar style p3xr-console .mat-toolbar .p3xr-console-ai-toggle { display: inline-flex; align-items: center; gap: 2px; margin: 0 8px; padding: 0 8px; height: 36px; cursor: pointer; color: inherit !important; font-size: 13px; letter-spacing: 0.1px; text-transform: uppercase; border-radius: 4px; user-select: none; &:hover { background-color: rgba(0, 0, 0, 0.08); } .material-icons { font-size: 20px; width: 20px; height: 20px; } } .p3xr-console-title { font-size: 20px; font-weight: 500; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .p3xr-toolbar-spacer { flex: 1 1 auto; } #p3xr-console-content { font-family: 'Roboto Mono', monospace; font-size: 13px; text-align: center; padding-left: 4px; #p3xr-console-content-resizer { cursor: ew-resize; position: relative; left: -10px; width: 20px !important; } #p3xr-console-content-output { min-width: calc(100% - 20px); text-align: left; overflow: auto; pre { font-family: 'Roboto Mono', monospace; white-space: pre-wrap; word-break: break-word; overflow-wrap: anywhere; } } } .p3xr-console-content-output-item:before { content: "> "; opacity: 0.5; } .p3xr-console-ai-result { display: block; } // Console input #p3xr-console-autocomplete { position: relative; overflow: hidden; width: 100% } .p3xr-input-ai-loading { opacity: 0.55; cursor: not-allowed !important; } .p3xr-console-stop { position: absolute; top: 50%; right: 6px; transform: translateY(-50%); background: transparent; border: none; padding: 2px; margin: 0; cursor: pointer; color: var(--p3xr-btn-primary-bg); display: flex; align-items: center; justify-content: center; z-index: 3; line-height: 1; } .p3xr-console-stop:hover { opacity: 0.8; } .p3xr-console-stop mat-icon, .p3xr-console-stop .mat-icon { color: var(--p3xr-btn-primary-bg) !important; font-size: 24px; width: 24px; height: 24px; line-height: 24px; } #p3xr-console-autocomplete.p3xr-console-autocomplete-embedded { position: relative; width: 100%; min-width: 0; overflow-x: hidden; } #p3xr-console-autocomplete.p3xr-console-autocomplete-embedded #p3xr-console-input { min-width: 100%; width: 100%; position: relative; box-sizing: border-box; overflow: hidden; } p3xr-console.p3xr-console-embedded-collapsed #p3xr-console-autocomplete.p3xr-console-autocomplete-embedded #p3xr-console-input { min-width: calc(100% - 1px); width: calc(100% - 1px); } p3xr-console.p3xr-console-embedded-collapsed .p3xr-console-hint { display: none; } #p3xr-console-input { display: block; width: 100%; box-sizing: border-box; padding: 3px; border-style: solid; border-width: 3px; margin: 0; font-family: 'Roboto Mono', monospace; resize: none; overflow-y: hidden; outline: none; max-height: 90px; } // Argument hint bar above textarea .p3xr-console-hint { font-family: 'Roboto Mono', monospace; font-size: 12px; padding: 2px 6px; opacity: 0.6; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } // Mat-autocomplete panel styling for console .p3xr-console-autocomplete-panel.mat-mdc-autocomplete-panel { font-family: 'Roboto Mono', monospace; font-size: 13px; max-height: 350px; .mat-mdc-optgroup-label { font-size: 11px; font-weight: bold; text-transform: uppercase; letter-spacing: 0.5px; min-height: 28px; opacity: 0.7; } .mat-mdc-option { min-height: 32px; font-size: 13px; font-family: 'Roboto Mono', monospace; } } .p3xr-autocomplete-cmd { font-weight: bold; margin-right: 8px; } .p3xr-autocomplete-syntax { opacity: 0.5; font-size: 11px; } @media (max-width: 959px) { .p3xr-console-root-embedded #p3xr-console-content-output { overflow-x: hidden; pre { white-space: pre-wrap; word-break: break-word; overflow-wrap: anywhere; } } } src/ng/pages/console/console.component.ts000066400000000000000000001027441517644030100210360ustar00rootroot00000000000000import { Component, Input, Output, EventEmitter, Inject, OnInit, OnDestroy, AfterViewInit, NgZone, ElementRef, ViewEncapsulation, ChangeDetectionStrategy, ChangeDetectorRef, CUSTOM_ELEMENTS_SCHEMA } from '@angular/core'; import { CommonModule } from '@angular/common'; import { FormsModule, ReactiveFormsModule } from '@angular/forms'; import { FormControl } from '@angular/forms'; import { MatToolbarModule } from '@angular/material/toolbar'; import { MatTooltipModule } from '@angular/material/tooltip'; import { MatAutocompleteModule } from '@angular/material/autocomplete'; import { MatInputModule } from '@angular/material/input'; import { MatFormFieldModule } from '@angular/material/form-field'; import { MatButtonModule } from '@angular/material/button'; import { MatIconModule } from '@angular/material/icon'; import { P3xrButtonComponent } from '../../components/p3xr-button.component'; import { I18nService } from '../../services/i18n.service'; import { CommonService } from '../../services/common.service'; import { SocketService } from '../../services/socket.service'; import { RedisParserService } from '../../services/redis-parser.service'; import { MainCommandService } from '../../services/main-command.service'; import { AiCheatsheetDialogService } from '../../dialogs/ai-cheatsheet-dialog.service'; import { RedisStateService } from '../../services/redis-state.service'; import { htmlEncode } from 'js-htmlencode'; import { debounce } from 'lodash-es'; const consoleOutputStorageKey = 'p3xr-console-output-v1'; const consoleOutputMaxBytes = 10 * 1024 * 1024; let actionHistoryPosition = -1; @Component({ selector: 'p3xr-console', standalone: true, imports: [ CommonModule, FormsModule, ReactiveFormsModule, MatToolbarModule, MatTooltipModule, MatAutocompleteModule, MatInputModule, MatFormFieldModule, MatButtonModule, MatIconModule, P3xrButtonComponent, ], schemas: [CUSTOM_ELEMENTS_SCHEMA], templateUrl: './console.component.html', styleUrls: ['./console.component.scss'], encapsulation: ViewEncapsulation.None, changeDetection: ChangeDetectionStrategy.OnPush, }) export class ConsoleComponent implements OnInit, AfterViewInit, OnDestroy { @Input() type: string = ''; @Input() embedded: boolean = false; /** When true, show a close button on the toolbar right — wired by the drawer host. */ @Input() showCloseButton: boolean = false; @Output() closeRequest = new EventEmitter(); requestClose(): void { this.closeRequest.emit(); } searchText = ''; searchControl = new FormControl(''); filteredCommands: { group: string; commands: { name: string; syntax: string }[] }[] = []; currentHint = ''; aiLoading = false; private aiRequestSeq = 0; get aiAutoDetect(): boolean { try { return localStorage.getItem('p3xr-ai-auto-detect') !== 'false'; } catch { return true; } } set aiAutoDetect(value: boolean) { try { localStorage.setItem('p3xr-ai-auto-detect', String(value)); } catch {} } readonly strings; private contentClicked = false; private readonly unsubs: Array<() => void> = []; private index = 0; private singleLineHeight = 0; private aiCommandPending = false; // DOM references private containerEl: HTMLElement | null = null; private headerEl: HTMLElement | null = null; private footerEl: HTMLElement | null = null; private consoleHeaderEl: HTMLElement | null = null; private outputEl: HTMLElement | null = null; private autocompleteEl: HTMLElement | null = null; private inputEl: HTMLElement | null = null; private scrollers: HTMLElement | null = null; private persistOutputDebounced: any; private inputFocusHandler: any; private inputResizeHandler: any; private inputBlurHandler: any; private resizeFn: any; constructor( @Inject(NgZone) private readonly ngZone: NgZone, @Inject(ElementRef) private readonly elementRef: ElementRef, @Inject(I18nService) private readonly i18n: I18nService, @Inject(CommonService) private readonly common: CommonService, @Inject(SocketService) private readonly socket: SocketService, @Inject(RedisParserService) private readonly redisParser: RedisParserService, @Inject(MainCommandService) private readonly cmd: MainCommandService, @Inject(RedisStateService) private readonly state: RedisStateService, @Inject(AiCheatsheetDialogService) private readonly cheatsheet: AiCheatsheetDialogService, @Inject(ChangeDetectorRef) private readonly cdr: ChangeDetectorRef, ) { this.strings = this.i18n.strings; } ngOnInit(): void { // Filter commands as user types, grouped by category with syntax hints this.searchControl.valueChanges.subscribe((value: string | null) => { this.searchText = value || ''; this.autoResizeTextarea(); const commands = this.state.commands(); const meta = this.state.commandsMeta(); // Show argument hint for a fully typed command const firstWord = (value || '').trim().split(/\s+/)[0]?.toUpperCase(); if (firstWord && meta[firstWord]?.syntax) { this.currentHint = firstWord + ' ' + meta[firstWord].syntax; } else { this.currentHint = ''; } if (value && value.length > 0 && commands?.length > 0) { const text = value.toUpperCase(); const matched = commands .filter((cmd: string) => cmd.toUpperCase().includes(text)) .slice(0, 20); // Group by category const groups = new Map(); for (const cmd of matched) { const info = meta[cmd.toUpperCase()]; const group = info?.group || 'Other'; const syntax = info?.syntax || ''; if (!groups.has(group)) groups.set(group, []); groups.get(group)!.push({ name: cmd, syntax }); } this.filteredCommands = Array.from(groups.entries()).map(([group, cmds]) => ({ group, commands: cmds })); } else { this.filteredCommands = []; } }); } ngAfterViewInit(): void { this.ngZone.runOutsideAngular(() => this.initJQuery()); } ngOnDestroy(): void { this.elementRef.nativeElement.classList.remove('p3xr-console-embedded-collapsed'); if (this.persistOutputDebounced?.flush) { this.persistOutputDebounced.flush(); } else { this.persistConsoleOutputNow(); } if (this.inputEl) { if (this.inputFocusHandler) this.inputEl.removeEventListener('focus', this.inputFocusHandler); if (this.inputBlurHandler) this.inputEl.removeEventListener('blur', this.inputBlurHandler); if (this.inputResizeHandler) { this.inputEl.removeEventListener('focus', this.inputResizeHandler); this.inputEl.removeEventListener('blur', this.inputResizeHandler); } } window.removeEventListener('resize', this.resizeFn); this.unsubs.forEach(fn => fn()); } // --- Event emission via Angular services --- private emitToAngularJS(eventName: string, payload?: any): void { switch (eventName) { case 'p3xr-console-activate': this.cmd.consoleActivate$.next(); break; case 'p3xr-console-deactivate': this.cmd.consoleDeactivate$.next(); break; case 'p3xr-console-embedded-resize': this.cmd.consoleEmbeddedResize$.next(); break; default: // Other events (p3xr-quick-console, p3xr-quick-console-quit) — noop for now break; } } isAiGloballyEnabled(): boolean { return this.state.cfg()?.aiEnabled !== false; } // --- Actions --- activate(): void { if (this.embedded) { this.emitToAngularJS('p3xr-console-activate'); this.forceScrollToBottom(); } } onContentMouseDown(event: MouseEvent): void { // Flag that user clicked inside console content (for text selection/copy) // This prevents the blur handler from collapsing the console this.contentClicked = true; setTimeout(() => { this.contentClicked = false; }, 500); } private aiExecuting = false; async actionEnter(): Promise { const fullInput = (this.searchText || '').trim(); if (!fullInput) return; if (this.aiLoading) return; try { // Split into lines for multi-line execution const lines = fullInput.split('\n').map(l => l.trim()).filter(l => l.length > 0); if (lines.length === 0) return; // EVAL/EVALSHA commands may span multiple lines — execute as single command const firstWord = lines[0].split(/\s+/)[0].toUpperCase(); const isSingleCommand = lines.length === 1 || firstWord === 'EVAL' || firstWord === 'EVALSHA'; if (isSingleCommand) { await this.executeSingleLine(fullInput); } else { for (const line of lines) { await this.executeSingleLine(line); } } } finally { this.updateCommandHistory(fullInput); // Don't clear input if AI placed a command for the user to review/execute this.currentHint = ''; if (this.aiCommandPending) { this.aiCommandPending = false; } else { this.searchText = ''; this.searchControl.setValue(''); setTimeout(() => this.autoResizeTextarea(), 0); } this.forceScrollToBottom(); if (this.type === 'quick' || this.embedded) { this.cmd.refresh({ withoutParent: true }); } (this.inputEl as HTMLElement)?.focus(); } } private async executeSingleLine(command: string): Promise { const enter = command.trim(); if (!enter) return; // Explicit ai: prefix — works when AI is globally enabled in settings if (this.state.cfg()?.aiEnabled !== false && /^ai:\s*/i.test(enter)) { const prompt = enter.replace(/^ai:\s*/i, '').trim(); if (prompt) { await this.handleAiQuery(prompt, enter); } return; } try { const response = await this.socket.request({ action: 'redis/console', payload: { command: enter }, }); const result = htmlEncode(String(this.redisParser.consoleParse(response.result))); if (this.aiExecuting) { const trimmed = result.replace(/ /g, '').trim(); if (trimmed.length > 0 && this.outputEl) { this.outputEl.insertAdjacentHTML('beforeend', `
${result}

`); this.persistOutputDebounced?.(); } } else { this.outputAppend(`${htmlEncode(enter)}
${result}
`); } if (response.hasOwnProperty('database')) { this.state.currentDatabase.set(response.database); this.state.redisChanged.set(true); this.socket.stateChanged$.next(); } } catch (e: any) { console.error(e); const errorMsg = e.message || ''; // Auto-detect: only when AI is globally enabled AND console toggle is on if (this.state.cfg()?.aiEnabled !== false && this.aiAutoDetect && this.looksLikeNaturalLanguage(enter, errorMsg)) { const aiSuccess = await this.handleAiQuery(enter, enter); if (aiSuccess) return; this.outputAppend(`${htmlEncode(enter)}
${this.i18n.strings().code?.[errorMsg] || errorMsg}
`); return; } this.outputAppend(`${htmlEncode(enter)}
${this.i18n.strings().code?.[errorMsg] || errorMsg}
`); } } private looksLikeNaturalLanguage(input: string, errorMsg: string): boolean { // Try AI if Redis returned an unknown/wrong command error OR we aren't // connected (user is typing natural language to navigate / connect). const isUnknownCmd = /unknown command|wrong number of arguments|ERR unknown|not_connected/i.test(errorMsg); if (!isUnknownCmd) return false; // If the first word is a known Redis command, it's probably a syntax error, not natural language const firstWord = input.trim().split(/\s+/)[0].toUpperCase(); if (this.state.commands()?.includes(firstWord)) return false; return true; } private async handleAiQuery(prompt: string, originalInput: string): Promise { if (prompt.length > 4096) { this.common.toast(this.i18n.strings()?.error?.aiPromptTooLong); return false; } const mySeq = ++this.aiRequestSeq; this.aiLoading = true; this.cdr.markForCheck(); (this.inputEl as HTMLElement)?.focus(); try { // Gather RediSearch indexes for context let indexes: string[] = []; try { const indexResponse = await this.socket.request({ action: 'search/list', payload: {} }); indexes = indexResponse.data || []; } catch { /* no search module, ignore */ } // Gather Redis server info for context (INFO is parsed into nested sections) const info = this.state.info() || {}; const server = info.server || {}; const clients = info.clients || {}; const memory = info.memory || {}; const keyspace = info.keyspace || {}; const redisContext: any = { indexes }; if (server.redis_version) redisContext.redisVersion = server.redis_version; if (server.redis_mode) redisContext.redisMode = server.redis_mode; if (server.os) redisContext.os = server.os; if (clients.connected_clients) redisContext.connectedClients = clients.connected_clients; if (memory.used_memory_human) redisContext.usedMemory = memory.used_memory_human; const dbKeys = Object.keys(keyspace).filter((k: string) => /^db\d+$/.test(k)); if (dbKeys.length > 0) redisContext.databases = dbKeys.map((k: string) => `${k}: ${keyspace[k]}`); if (this.state.modules()?.length > 0) redisContext.modules = this.state.modules(); redisContext.uiLanguage = this.i18n.currentLang(); redisContext.connectionState = this.state.connectionState(); redisContext.currentPage = this.state.currentPage(); const conn = this.state.connection(); if (conn?.name) redisContext.connectionName = conn.name; const db = this.state.currentDatabase(); if (db !== undefined) redisContext.currentDatabase = db; const response = await this.socket.request({ action: 'ai/redis-query', payload: { prompt, context: redisContext, }, }); if (mySeq !== this.aiRequestSeq) return false; const command = response.command || ''; const explanation = response.explanation || ''; const toolTrail = Array.isArray(response.toolTrail) ? response.toolTrail : []; this.outputAppend(`${htmlEncode(originalInput)}`); this.updateCommandHistory(originalInput); // Print each tool call + outcome to the scrollback (transparency). for (const t of toolTrail) { const argsStr = t.args && Object.keys(t.args).length ? '(' + Object.entries(t.args).map(([k, v]) => `${k}=${JSON.stringify(v)}`).join(', ') + ')' : '()'; const head = `tool: ${htmlEncode(t.name + argsStr)} ${t.ms ?? 0}ms`; if (t.ok) { const preview = String(t.result ?? '').split('\n').slice(0, 12).join('\n'); this.outputAppend(`${head}
${htmlEncode(preview)}
`); } else { this.outputAppend(`${head}
${htmlEncode(t.error || 'tool error')}`); } } // If the AI used tools, the returned command is a suggestion — don't auto-prefill. const usedTools = toolTrail.length > 0; if (command) { let aiLine = `AI → ${htmlEncode(command)}`; if (explanation) { aiLine += `
${htmlEncode(explanation)}
`; } this.outputAppend(aiLine); if (!usedTools) { this.searchText = command; this.searchControl.setValue(command, { emitEvent: false }); this.filteredCommands = []; this.aiCommandPending = true; setTimeout(() => this.autoResizeTextarea(), 0); } } else if (explanation) { this.outputAppend(`
${htmlEncode(explanation)}
`); } return true; } catch (e: any) { if (mySeq !== this.aiRequestSeq) return false; console.error('ai-redis-query failed', e); const errMsg = e.message || String(e); this.outputAppend(`AI error: ${htmlEncode(errMsg)}`); // Show user-friendly error for rate limits if (errMsg.includes('429') || errMsg.includes('rate_limit') || errMsg.includes('Rate limit')) { this.common.toast(this.i18n.strings().page?.key?.label?.aiRateLimited); } else { this.common.toast((this.i18n.strings().page?.key?.label?.aiError) + ': ' + errMsg); } return false; } finally { if (mySeq === this.aiRequestSeq) { this.aiLoading = false; this.cdr.markForCheck(); this.forceScrollToBottom(); (this.inputEl as HTMLElement)?.focus(); } } } stopAi(): void { this.aiRequestSeq++; this.aiLoading = false; this.searchText = ''; this.searchControl.setValue('', { emitEvent: false }); this.filteredCommands = []; this.cdr.markForCheck(); setTimeout(() => { this.autoResizeTextarea(); (this.inputEl as HTMLElement)?.focus(); }, 0); } onKeyDown(event: KeyboardEvent): void { // Enter handling: Enter = execute, Shift+Enter = newline if (event.key === 'Enter') { if (event.shiftKey) { // Shift+Enter inserts newline, auto-resize after DOM update setTimeout(() => this.autoResizeTextarea(), 0); return; } event.preventDefault(); this.actionEnter(); return; } // Let mat-autocomplete handle ArrowDown/ArrowUp when panel is open if (this.filteredCommands.length > 0 && (event.key === 'ArrowDown' || event.key === 'ArrowUp')) { return; } // Plain ArrowUp/Down = scroll textarea; Shift+ArrowUp/Down = command history if (event.key !== 'ArrowUp' && event.key !== 'ArrowDown') { actionHistoryPosition = -1; return; } if (!event.shiftKey) { // Let textarea handle natural cursor/scroll movement return; } const actionHistory = this.getActionHistory(); if (actionHistory.length < 1) return; event.preventDefault(); event.stopPropagation(); if (event.key === 'ArrowDown') { if (actionHistoryPosition === -1) actionHistoryPosition = actionHistory.length; actionHistoryPosition--; if (actionHistoryPosition < 0) actionHistoryPosition = actionHistory.length - 1; } else { actionHistoryPosition++; if (actionHistoryPosition >= actionHistory.length) actionHistoryPosition = 0; } const value = actionHistory[actionHistoryPosition] ?? ''; this.searchText = value; this.searchControl.setValue(value, { emitEvent: false }); setTimeout(() => { const el = this.inputEl as HTMLElement; if (el) { el.blur(); el.focus(); } this.autoResizeTextarea(); }, 0); } onAutocompleteSelected(event: any): void { this.searchText = event.option.value; } clearConsole(): void { if (!this.outputEl) return; this.outputEl.innerHTML = ''; const strings = this.i18n.strings(); this.outputAppend('' + (strings?.label?.welcomeConsole ?? 'Welcome to the Redis Console') + ''); this.outputAppend((strings?.label?.welcomeConsoleInfo ?? 'SHIFT + Cursor UP or DOWN history is enabled') + '
'); this.persistConsoleOutputNow(); this.forceScrollToBottom(); (this.inputEl as HTMLElement)?.focus(); } async openCommands(event: Event): Promise { const picked = await this.cheatsheet.show(); if (!picked) return; // Picked prompt is already prefixed with "ai: " by the dialog. this.searchText = picked; this.searchControl.setValue(picked, { emitEvent: false }); setTimeout(() => { (this.inputEl as HTMLElement)?.focus(); this.autoResizeTextarea(); }, 0); } closeConsole(): void { this.emitToAngularJS('p3xr-quick-console-quit'); } dragStart(): void { if (this.embedded) return; this.emitToAngularJS('p3xr-quick-console', { start: true }); } dragEnd(): void { if (this.embedded) return; this.emitToAngularJS('p3xr-quick-console', { start: false }); } // --- DOM init --- private initJQuery(): void { const rootEl = this.elementRef.nativeElement; this.containerEl = rootEl.querySelector('#p3xr-console-content'); this.headerEl = document.getElementById('p3xr-layout-header-container'); this.footerEl = document.getElementById('p3xr-layout-footer-container'); this.consoleHeaderEl = rootEl.querySelector('#p3xr-console-header'); this.outputEl = rootEl.querySelector('#p3xr-console-content-output'); this.autocompleteEl = rootEl.querySelector('#p3xr-console-autocomplete'); this.scrollers = this.containerEl; this.resizeFn = () => this.rawResize(); window.addEventListener('resize', this.resizeFn); this.rawResize(); this.persistOutputDebounced = debounce(() => this.persistConsoleOutputNow(), 100); // Listen for resize events from main component const resizeSub = this.cmd.consoleEmbeddedResize$.subscribe(() => { if (this.embedded) this.rawResize(); }); this.unsubs.push(() => resizeSub.unsubscribe()); // Setup input after a tick setTimeout(() => { this.inputEl = rootEl.querySelector('#p3xr-console-input'); this.setInputTheme(); if (!this.restoreConsoleOutput()) { this.clearConsole(); } else { this.forceScrollToBottom(); } this.rawResize(); // Paste needs a deferred resize (browser hasn't finished layout when valueChanges fires) this.inputEl?.addEventListener('paste', () => { setTimeout(() => this.autoResizeTextarea(), 0); }); // Textarea resize on focus/blur this.inputResizeHandler = () => setTimeout(() => this.autoResizeTextarea(), 0); this.inputEl?.addEventListener('focus', this.inputResizeHandler); this.inputEl?.addEventListener('blur', this.inputResizeHandler); // Embedded focus/blur handlers if (this.embedded) { this.inputFocusHandler = () => { this.emitToAngularJS('p3xr-console-activate'); }; this.inputBlurHandler = () => { setTimeout(() => { // Don't collapse if user clicked inside console content if (this.contentClicked) return; const active = document.activeElement; if (active?.id === 'p3xr-console-input') return; const root = this.elementRef.nativeElement; if (root && active && root.contains(active)) return; // Don't deactivate if user is selecting text in the console output const selection = window.getSelection(); if (selection && selection.toString().length > 0) { const range = selection.getRangeAt?.(0); if (range && root?.contains(range.commonAncestorContainer)) return; } this.emitToAngularJS('p3xr-console-deactivate'); }, 0); }; this.inputEl?.addEventListener('focus', this.inputFocusHandler); this.inputEl?.addEventListener('blur', this.inputBlurHandler); } }); } private setInputTheme(): void { if (!this.inputEl) return; this.inputEl.style.borderColor = 'var(--p3xr-input-border-color, var(--p3xr-border-color))'; this.inputEl.style.backgroundColor = 'var(--p3xr-input-bg)'; this.inputEl.style.color = 'var(--p3xr-input-color)'; } private rawResize(): void { if (!this.containerEl) return; if (this.embedded) { const hostElement = this.elementRef.nativeElement; const hostRect = hostElement?.getBoundingClientRect(); const hostHeight = hostRect?.height || Math.floor(window.innerHeight * 0.33); const headerHeight = this.consoleHeaderEl?.offsetHeight || 0; const autocompleteHeight = this.autocompleteEl?.offsetHeight || 44; const collapsed = hostHeight <= 120; hostElement.classList.toggle('p3xr-console-embedded-collapsed', collapsed); const outputHeight = collapsed ? 0 : Math.max(hostHeight - headerHeight - autocompleteHeight, 0); this.containerEl.style.height = outputHeight + 'px'; this.containerEl.style.maxHeight = outputHeight + 'px'; this.containerEl.style.overflow = collapsed ? 'hidden' : 'auto'; this.containerEl.style.display = collapsed ? 'none' : 'block'; if (this.outputEl) { this.outputEl.style.display = collapsed ? 'none' : 'block'; } return; } // Non-embedded resize — measure available space directly from DOM positions const containerTop = this.containerEl.getBoundingClientRect().top; const footerTop = this.footerEl?.getBoundingClientRect().top ?? window.innerHeight; const autocompleteHeight = this.autocompleteEl?.offsetHeight || 28; const outputHeight = Math.max(footerTop - containerTop - autocompleteHeight, 0); this.containerEl.style.height = outputHeight + 'px'; this.containerEl.style.maxHeight = outputHeight + 'px'; } private autoResizeTextarea(): void { const el = this.inputEl as HTMLTextAreaElement; if (!el) return; if (!this.singleLineHeight) { this.singleLineHeight = el.offsetHeight; } const isFocused = document.activeElement === el; // Blurred with multi-line: collapse to single line if (!isFocused && (el.value || '').includes('\n')) { el.style.height = this.singleLineHeight + 'px'; el.style.overflowY = 'hidden'; this.rawResize(); return; } el.style.height = this.singleLineHeight + 'px'; el.style.overflowY = 'hidden'; // Only grow when focused and there are actual newlines (max 3 lines) if ((el.value || '').includes('\n') && el.scrollHeight > el.clientHeight) { const maxHeight = this.singleLineHeight * 3; const borderHeight = el.offsetHeight - el.clientHeight; const needed = el.scrollHeight + borderHeight; if (needed > maxHeight) { el.style.height = maxHeight + 'px'; el.style.overflowY = 'auto'; } else { el.style.height = needed + 'px'; } } this.rawResize(); } // --- Output management --- private outputAppend(message: string): void { if (!this.outputEl) return; const stripped = (message || '').replace(/<[^>]*>/g, '').replace(/&[a-z]+;/g, '').trim(); if (!stripped) return; this.outputEl.insertAdjacentHTML('beforeend', `${message}
`); this.trimOutputToLimit(consoleOutputMaxBytes); this.persistOutputDebounced?.(); this.scrollOutputToBottom(); } private scrollOutputToBottom(): void { setTimeout(() => { if (!this.scrollers) return; // Only auto-scroll if user is near the bottom (within 100px) const threshold = 100; const isNearBottom = this.scrollers.scrollHeight - this.scrollers.scrollTop - this.scrollers.clientHeight < threshold; if (isNearBottom) { this.scrollers.scrollTop = this.scrollers.scrollHeight; if (this.outputEl) this.outputEl.scrollTop = this.outputEl.scrollHeight; } }, 0); } private forceScrollToBottom(): void { // Double rAF + late setTimeout — survives late
 layout / large tool-trail renders.
        const doScroll = () => {
            if (!this.scrollers) return;
            this.scrollers.scrollTop = this.scrollers.scrollHeight;
            if (this.outputEl) this.outputEl.scrollTop = this.outputEl.scrollHeight;
        };
        requestAnimationFrame(() => {
            requestAnimationFrame(doScroll);
        });
        setTimeout(doScroll, 120);
    }

    private trimOutputToLimit(maxBytes: number): void {
        if (!this.outputEl) return;
        let html = this.outputEl.innerHTML || '';
        while (this.getByteSize(html) > maxBytes) {
            if (!this.dropOldestOutputChunk()) break;
            html = this.outputEl.innerHTML || '';
        }
    }

    private dropOldestOutputChunk(): boolean {
        if (!this.outputEl) return false;
        const items = this.outputEl.querySelectorAll('.p3xr-console-content-output-item');
        if (items.length < 1) return false;
        const removeCount = Math.max(Math.floor(items.length * 0.1), 1);
        for (let i = 0; i < removeCount; i++) items[i].remove();
        return true;
    }

    private getByteSize(value: string): number {
        try { return new Blob([value || '']).size; }
        catch { return (value || '').length; }
    }

    private persistConsoleOutputNow(): void {
        if (!this.outputEl) return;
        this.trimOutputToLimit(consoleOutputMaxBytes);
        while (true) {
            const html = this.outputEl.innerHTML || '';
            try { localStorage.setItem(consoleOutputStorageKey, html); return; }
            catch {
                if (!this.dropOldestOutputChunk()) {
                    try { localStorage.removeItem(consoleOutputStorageKey); } catch { /* ignore */ }
                    return;
                }
            }
        }
    }

    private restoreConsoleOutput(): boolean {
        if (!this.outputEl) return false;
        let stored = '';
        try { stored = localStorage.getItem(consoleOutputStorageKey) || ''; } catch { stored = ''; }
        if (!stored) return false;

        this.outputEl.innerHTML = stored;
        this.trimOutputToLimit(consoleOutputMaxBytes);
        this.persistConsoleOutputNow();

        const items = this.outputEl.querySelectorAll('.p3xr-console-content-output-item');
        const lastItem = items.length > 0 ? items[items.length - 1] : null;
        if (lastItem) {
            const lastIndex = Number(lastItem.getAttribute('data-index'));
            if (Number.isFinite(lastIndex)) this.index = lastIndex + 1;
        }
        return true;
    }

    // --- Command history ---

    private getActionHistory(): string[] {
        try { return JSON.parse(localStorage.getItem('console-history') || '[]'); }
        catch { return []; }
    }

    private updateCommandHistory(entry: string): void {
        let history = this.getActionHistory();
        const idx = history.indexOf(entry);
        if (idx > -1) history.splice(idx, 1);
        history.unshift(entry);
        if (history.length > 20) history = history.slice(0, 20);
        localStorage.setItem('console-history', JSON.stringify(history));
        actionHistoryPosition = -1;
    }
}
src/ng/pages/database/000077500000000000000000000000001517644030100151155ustar00rootroot00000000000000src/ng/pages/database/database-header.component.ts000066400000000000000000000254551517644030100224730ustar00rootroot00000000000000import { Component, Inject, OnInit, OnDestroy, ChangeDetectorRef, ChangeDetectionStrategy } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { MatToolbarModule } from '@angular/material/toolbar';
import { MatButtonModule } from '@angular/material/button';
import { MatIconModule } from '@angular/material/icon';
import { MatTooltipModule } from '@angular/material/tooltip';
import { MatSelectModule } from '@angular/material/select';
import { MatFormFieldModule } from '@angular/material/form-field';
import { BreakpointObserver } from '@angular/cdk/layout';

import { I18nService } from '../../services/i18n.service';
import { MainCommandService } from '../../services/main-command.service';
import { SocketService } from '../../services/socket.service';
import { RedisStateService } from '../../services/redis-state.service';

@Component({
    selector: 'p3xr-database-header',
    standalone: true,
    imports: [
        CommonModule,
        FormsModule,
        MatToolbarModule,
        MatButtonModule,
        MatIconModule,
        MatTooltipModule,
        MatSelectModule,
        MatFormFieldModule,
    ],
    template: `
        
            
@if (!isXs) {

{{ strings().intention?.main }}

} @if (hasConnection) { @if (!isCluster) {
DB: {{ hasKeys(currentDatabase) ? 'radio_button_checked' : 'radio_button_unchecked' }} {{ currentDatabase }} @for (dbIndex of databaseIndexes; track dbIndex) { {{ hasKeys(dbIndex) ? 'radio_button_checked' : 'radio_button_unchecked' }} {{ dbIndex }} }
} @if (!isReadonly) { @if (isWide) { } @else { } } @if (isWide) { } @else { } @if (isWide) { } @else { } }
`, styles: [` :host { display: block; } .p3xr-database-header-toolbar { height: 48px; min-height: 48px; max-height: 48px; padding: 0 8px 0 16px; border-radius: 4px 4px 0 0; } .p3xr-database-header-tools { display: flex; align-items: center; width: 100%; height: 48px; } .p3xr-database-header-title { flex: 1; font-size: 20px; font-weight: 400; margin: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .p3xr-database-header-link { cursor: pointer; text-decoration: none; color: inherit; } .p3xr-database-header-db-selector { display: flex; align-items: center; margin: 0; padding: 0; } .p3xr-database-header-db-label { font-size: 14px; font-weight: bold; margin-right: 2px; } .p3xr-database-header-db-field { width: 80px; position: relative; top: 1px; } .p3xr-database-header-db-field ::ng-deep .mdc-text-field { background: transparent !important; padding: 0 8px !important; } .p3xr-database-header-db-field ::ng-deep .mat-mdc-form-field-subscript-wrapper { display: none; } .p3xr-database-header-db-field ::ng-deep .mdc-line-ripple { display: none; } .p3xr-database-header-db-field ::ng-deep .mat-mdc-select-arrow-wrapper { padding-left: 0; } .p3xr-database-header-db-field ::ng-deep .mat-mdc-select-trigger { display: flex; align-items: center; } .p3xr-database-header-db-field ::ng-deep .mat-mdc-select-value { display: flex; align-items: center; } .p3xr-database-header-db-field ::ng-deep .mat-mdc-select-value-text { display: flex; align-items: center; } .p3xr-database-header-db-field ::ng-deep mat-select-trigger { display: flex; align-items: center; gap: 4px; } .p3xr-database-header-db-field ::ng-deep mat-select-trigger .p3xr-db-indicator { font-size: 18px !important; width: 18px !important; height: 18px !important; line-height: 18px !important; overflow: hidden; flex-shrink: 0; } `], changeDetection: ChangeDetectionStrategy.OnPush, }) export class DatabaseHeaderComponent implements OnInit, OnDestroy { readonly strings; isXs = false; isWide = true; hasConnection = false; isCluster = false; isReadonly = false; currentDatabase: number = 0; databaseIndexes: number[] = []; private keyspaceDatabases: Record = {}; private readonly unsubs: Array<() => void> = []; constructor( @Inject(BreakpointObserver) private readonly breakpointObserver: BreakpointObserver, @Inject(I18nService) private readonly i18n: I18nService, @Inject(MainCommandService) private readonly cmd: MainCommandService, @Inject(SocketService) private readonly socket: SocketService, @Inject(ChangeDetectorRef) private readonly cdr: ChangeDetectorRef, @Inject(RedisStateService) private readonly state: RedisStateService, ) { this.strings = this.i18n.strings; } ngOnInit(): void { this.syncFromGlobal(); // Subscribe to socket events for reactive state updates const sub1 = this.socket.connections$.subscribe(() => this.syncFromGlobal()); const sub2 = this.socket.redisDisconnected$.subscribe(() => this.syncFromGlobal()); const sub3 = this.socket.stateChanged$.subscribe(() => this.syncFromGlobal()); this.unsubs.push(() => { sub1.unsubscribe(); sub2.unsubscribe(); sub3.unsubscribe(); }); const xsSub = this.breakpointObserver.observe('(max-width: 599px)').subscribe(result => { this.isXs = result.matches; this.cdr.markForCheck(); }); this.unsubs.push(() => xsSub.unsubscribe()); const wideSub = this.breakpointObserver.observe('(min-width: 720px)').subscribe(result => { this.isWide = result.matches; this.cdr.markForCheck(); }); this.unsubs.push(() => wideSub.unsubscribe()); } ngOnDestroy(): void { this.unsubs.forEach(fn => fn()); } hasKeys(dbIndex: number): boolean { return !!this.keyspaceDatabases[dbIndex]; } selectDatabase(dbIndex: number): void { this.currentDatabase = dbIndex; this.cmd.selectDatabase(dbIndex).then(() => { this.syncFromGlobal(); }); // Force re-render after mat-select closes setTimeout(() => this.cdr.detectChanges()); } save(): void { this.cmd.save(); } goStatistics(): void { this.cmd.statistics(); } refresh(): void { this.cmd.refresh({ withoutParent: false }); } private syncFromGlobal(): void { const conn = this.state.connection(); this.hasConnection = conn !== undefined; this.isCluster = conn?.cluster === true; this.isReadonly = conn?.readonly === true; this.databaseIndexes = this.state.databaseIndexes() ?? []; this.keyspaceDatabases = this.state.info()?.keyspaceDatabases ?? {}; this.currentDatabase = this.cmd.currentDatabase; this.cdr.detectChanges(); } } src/ng/pages/database/database-key.component.html000066400000000000000000000246471517644030100223530ustar00rootroot00000000000000@if (loading) {
} @if (!loading && response) {
@if (!isReadonly) { @if (isGtSm) { } @else { } @if (isGtSm) { } @else { } @if (isGtSm) { } @else { } @if (isGtSm) { } @else { } } @if (isGtSm) { } @else { }
{{ strings()?.page?.key?.label?.key }}: {{ key }}
{{ strings()?.page?.key?.label?.ttl }}: @if (response.ttl === -1) { {{ strings()?.page?.key?.label?.ttlNotExpire }} } @else { {{ response.ttl }} }
{{ strings()?.page?.key?.label?.type }}: {{ strings()?.redisTypes?.[response.type] }}
{{ strings()?.page?.key?.label?.encoding }}: {{ response.encoding }}
@if (response.compression) {
{{ strings()?.page?.key?.label?.compression }}: {{ response.compression.algorithm.toUpperCase() }} @if (response.compression.ratio >= 0) { {{ response.compression.ratio }}% } @else { {{ -response.compression.ratio }}% }
}
{{ strings()?.page?.key?.label?.length }}: {{ charactersPrettyBytes(response.size) }}  {{ response.size }} {{ strings()?.page?.key?.label?.lengthString }} @if (response.length) { , {{ response.length }} {{ strings()?.page?.key?.label?.lengthItem }} }
@if (response.type !== 'timeseries' && response.type !== 'json' && response.type !== 'bloom' && response.type !== 'cuckoo' && response.type !== 'topk' && response.type !== 'cms' && response.type !== 'tdigest' && response.type !== 'vectorset') {
{{ strings()?.label?.format }}: Raw JSON Hex Base64
}
@switch (response.type) { @case ('string') { } @case ('list') { } @case ('hash') { } @case ('set') { } @case ('zset') { } @case ('stream') { } @case ('json') { } @case ('timeseries') { } @case ('bloom') { } @case ('cuckoo') { } @case ('topk') { } @case ('cms') { } @case ('tdigest') { } @case ('vectorset') { } @default {
{{ strings()?.page?.key?.probabilistic?.noItems }}
} } } src/ng/pages/database/database-key.component.scss000066400000000000000000000056551517644030100223600ustar00rootroot00000000000000.p3xr-database-key-loading { display: flex; justify-content: center; align-items: center; min-height: 100%; padding: 32px; } .p3xr-database-key-actions { display: flex; flex-wrap: wrap; justify-content: flex-end; align-items: center; gap: 8px; padding: 4px 8px; } .p3xr-database-key-info { border-top: 1px solid rgba(255, 255, 255, 0.12); } body.p3xr-theme-light .p3xr-database-key-info { border-top-color: rgba(0, 0, 0, 0.12); } .p3xr-database-key-info-row { display: flex; justify-content: space-between; align-items: baseline; padding: 12px 16px; border-bottom: 1px solid rgba(255, 255, 255, 0.12); strong { white-space: nowrap; margin-right: 16px; } span { text-align: right; overflow: hidden; text-overflow: ellipsis; user-select: text; } } body.p3xr-theme-light .p3xr-database-key-info-row { border-bottom-color: rgba(0, 0, 0, 0.12); } // Only key and TTL rows are clickable with hover .p3xr-database-key-info-row-clickable { cursor: pointer; &:hover { background-color: rgba(255, 255, 255, 0.1) !important; } } body.p3xr-theme-light .p3xr-database-key-info-row-clickable:hover { background-color: rgba(0, 0, 0, 0.1) !important; } .p3xr-database-key-ttl-value { display: flex; flex-direction: column; align-items: flex-end; } .p3xr-database-key-ttl-hint { opacity: 0.5; font-size: 0.85em; font-weight: normal; } // Compression badge .p3xr-compression-badge { display: inline-flex; align-items: center; gap: 6px; } .p3xr-compression-algorithm { background-color: var(--p3xr-btn-accent-bg); color: var(--p3xr-btn-accent-color); padding: 2px 6px; border-radius: 4px; font-size: 11px; font-weight: bold; letter-spacing: 0.5px; } .p3xr-compression-ratio-badge { padding: 2px 6px; border-radius: 4px; font-size: 11px; font-weight: bold; } .p3xr-compression-good { background-color: var(--mat-sys-primary, #4caf50); color: var(--p3xr-btn-primary-color); } .p3xr-compression-bad { background-color: var(--p3xr-btn-warn-bg, #f44336); color: var(--p3xr-btn-warn-color); } .p3xr-format-toggle { border-radius: 4px !important; overflow: hidden !important; box-shadow: none !important; .mat-button-toggle { height: 32px !important; border-radius: 0 !important; .mat-button-toggle-button { height: 32px !important; font-size: 13px !important; padding: 0 12px !important; display: flex !important; align-items: center !important; justify-content: center !important; border-radius: 0 !important; } } .mat-button-toggle:first-child { border-radius: 4px 0 0 4px !important; } .mat-button-toggle:last-child { border-radius: 0 4px 4px 0 !important; } } src/ng/pages/database/database-key.component.ts000066400000000000000000000413051517644030100220230ustar00rootroot00000000000000import { Component, Inject, OnInit, OnDestroy, NgZone, ChangeDetectorRef, ChangeDetectionStrategy, CUSTOM_ELEMENTS_SCHEMA, ViewEncapsulation, effect } from '@angular/core'; import { ActivatedRoute } from '@angular/router'; import { CommonModule } from '@angular/common'; import { MatButtonModule } from '@angular/material/button'; import { MatIconModule } from '@angular/material/icon'; import { MatTooltipModule } from '@angular/material/tooltip'; import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; import { MatButtonToggleModule } from '@angular/material/button-toggle'; import { FormsModule } from '@angular/forms'; import { BreakpointObserver } from '@angular/cdk/layout'; import { I18nService } from '../../services/i18n.service'; import { SocketService } from '../../services/socket.service'; import { CommonService } from '../../services/common.service'; import { MainCommandService } from '../../services/main-command.service'; import { ThemeService } from '../../services/theme.service'; import { TtlDialogService } from '../../dialogs/ttl-dialog.service'; import { KeyStringComponent } from './key/key-string.component'; import { KeyHashComponent } from './key/key-hash.component'; import { KeyListComponent } from './key/key-list.component'; import { KeySetComponent } from './key/key-set.component'; import { KeyZsetComponent } from './key/key-zset.component'; import { KeyStreamComponent } from './key/key-stream.component'; import { KeyJsonComponent } from './key/key-json.component'; import { KeyTimeseriesComponent } from './key/key-timeseries.component'; import { KeyProbabilisticComponent } from './key/key-probabilistic.component'; import { KeyVectorsetComponent } from './key/key-vectorset.component'; import { NavigationService } from '../../services/navigation.service'; import { RedisStateService } from '../../services/redis-state.service'; import { SettingsService } from '../../services/settings.service'; import { htmlEncode } from 'js-htmlencode'; import humanizeDuration from 'humanize-duration'; @Component({ selector: 'p3xr-database-key', standalone: true, imports: [ CommonModule, FormsModule, MatButtonModule, MatIconModule, MatTooltipModule, MatProgressSpinnerModule, MatButtonToggleModule, KeyStringComponent, KeyHashComponent, KeyListComponent, KeySetComponent, KeyZsetComponent, KeyStreamComponent, KeyJsonComponent, KeyTimeseriesComponent, KeyProbabilisticComponent, KeyVectorsetComponent, ], schemas: [CUSTOM_ELEMENTS_SCHEMA], templateUrl: './database-key.component.html', styleUrls: ['./database-key.component.scss', './key/key-types.scss'], encapsulation: ViewEncapsulation.None, changeDetection: ChangeDetectionStrategy.OnPush, }) export class DatabaseKeyComponent implements OnInit, OnDestroy { loading = false; response: any = null; key = ''; isReadonly = false; isGtSm = true; valueFormat: 'raw' | 'json' | 'hex' | 'base64' = 'raw'; strings; get dividerBorder(): string { const currentTheme = this.theme.currentTheme() ?? ''; const isDark = currentTheme.includes('Dark') || currentTheme.includes('Matrix'); return `solid 1px ${isDark ? 'rgba(255,255,255,0.12)' : 'rgba(0,0,0,0.12)'}`; } private ttlInterval: any; private wasExpiring = false; private readonly unsubFns: Array<() => void> = []; constructor( @Inject(NgZone) private readonly ngZone: NgZone, @Inject(BreakpointObserver) private readonly breakpointObserver: BreakpointObserver, @Inject(I18nService) private readonly i18n: I18nService, @Inject(SocketService) private readonly socket: SocketService, @Inject(CommonService) private readonly common: CommonService, @Inject(MainCommandService) private readonly cmd: MainCommandService, @Inject(ThemeService) private readonly theme: ThemeService, @Inject(TtlDialogService) private readonly ttlDialog: TtlDialogService, @Inject(NavigationService) private readonly nav: NavigationService, @Inject(ActivatedRoute) private readonly route: ActivatedRoute, @Inject(ChangeDetectorRef) private readonly cdr: ChangeDetectorRef, @Inject(RedisStateService) private readonly state: RedisStateService, @Inject(SettingsService) private readonly settings: SettingsService, ) { this.strings = this.i18n.strings; // Regenerate highlight when theme changes effect(() => { this.theme.currentTheme(); // track the signal if (this.key) { this.removeHighlight(); this.generateHighlight(); } }); } ngOnInit(): void { this.key = this.getStateParam('key') || ''; this.isReadonly = this.state.connection()?.readonly === true; const sub = this.breakpointObserver.observe('(min-width: 960px)').subscribe(r => { this.isGtSm = r.matches; this.cdr.markForCheck(); }); this.unsubFns.push(() => sub.unsubscribe()); this.loadKey(); this.generateHighlight(); // Listen for refresh events via MainCommandService const refreshSub = this.cmd.refreshKey$.subscribe(() => { this.refresh({ withoutParent: true }); }); this.unsubFns.push(() => refreshSub.unsubscribe()); // React to key-to-key navigation (Angular Router reuses the component) const paramSub = this.route.paramMap.subscribe(params => { const newKey = params.get('key') || ''; if (newKey && newKey !== this.key) { this.key = newKey; this.loadKey(); this.generateHighlight(); this.cdr.markForCheck(); } }); this.unsubFns.push(() => paramSub.unsubscribe()); } ngOnDestroy(): void { this.clearTtlInterval(); this.removeHighlight(); this.unsubFns.forEach(fn => fn()); } // --- Actions --- addKey(event: Event): void { event.stopPropagation(); this.cmd.keyNew$.next({ event, node: { key: this.key } }); } deleteKey(event: Event): void { this.cmd.keyDelete$.next({ key: this.key, event }); } rename(event: Event): void { this.cmd.keyRename$.next({ key: this.key, event }); } async setTtl(event: Event): Promise { try { const confirmResponse = await this.ttlDialog.show({ $event: event, model: { ttl: this.response.ttl === -1 ? '' : this.response.ttl }, }); if (confirmResponse === undefined) return; const ttlStr = String(confirmResponse.model.ttl).trim(); if (ttlStr === '' || confirmResponse.model.ttl == null) { await this.socket.request({ action: 'key/persist', payload: { key: this.key } }); this.gtag('/persist'); await this.cmd.refresh(); await this.refresh({ withoutParent: true }); this.common.toast(this.i18n.strings().status.persisted); } else if (!/^-?\d+$/.test(ttlStr)) { this.common.toast(this.i18n.strings().status.notInteger); } else { await this.socket.request({ action: 'key/expire', payload: { key: this.key, ttl: parseInt(ttlStr) }, }); this.gtag('/expire'); await this.cmd.refresh(); await this.refresh({ withoutParent: true }); this.common.toast(this.i18n.strings().status.ttlChanged); } } catch (e) { this.common.generalHandleError(e); } } async refresh(options: { withoutParent?: boolean } = {}): Promise { this.gtag('/refresh'); await this.loadKey(options); } charactersPrettyBytes(length: number): string { if (!length || length < 1024) return ''; return '(' + (this.settings.prettyBytes(length) ?? '') + ')'; } // --- Private --- private async loadKey(options: { withoutParent?: boolean } = {}): Promise { this.clearTtlInterval(); let hadError: any; try { const response = await this.socket.request({ action: 'key/get', payload: { key: this.key }, }); this.response = response; if (response.ttl === -2) { this.checkTtl(); return; } response.size = 0; this.decodeValueBuffer(response); this.calculateSize(response); if (response.ttl > -1) this.wasExpiring = true; this.loadTtl(); } catch (e) { hadError = e; console.error(e); if ((e as any)?.message === 'Connection is closed.') { this.state.connection.set(undefined); this.common.alert((e as any)?.message ?? String(e)); } else { this.common.alert(this.i18n.strings().label.unableToLoadKey({ key: this.key })); } } finally { if (hadError) { this.navigateTo('database.statistics'); } else if (!options.withoutParent) { const resize = this.getStateParam('resize'); if (resize) resize(); } this.loading = false; this.cdr.markForCheck(); } } private toBytes(buf: any): Uint8Array { if (buf instanceof Uint8Array || buf instanceof ArrayBuffer) return buf instanceof ArrayBuffer ? new Uint8Array(buf) : buf; if (buf && buf.type === 'Buffer' && Array.isArray(buf.data)) return new Uint8Array(buf.data); if (ArrayBuffer.isView(buf)) return new Uint8Array(buf.buffer, buf.byteOffset, buf.byteLength); return buf; } private decodeValueBuffer(response: any): void { const { type, valueBuffer } = response; const td = new TextDecoder(); switch (type) { case 'string': response.value = td.decode(this.toBytes(valueBuffer)); break; case 'list': case 'set': response.value = valueBuffer.map((buf: any) => td.decode(this.toBytes(buf))); break; case 'hash': response.value = {}; Object.entries(valueBuffer).forEach(([key, buf]: [string, any]) => { response.value[key] = td.decode(this.toBytes(buf)); }); break; case 'zset': response.value = []; for (let i = 0; i < valueBuffer.length; i += 2) { response.value.push(td.decode(this.toBytes(valueBuffer[i]))); response.value.push(td.decode(valueBuffer[i + 1])); } break; case 'json': // JSON.GET with $ returns a JSON string (always compact from Redis) const rawJson = td.decode(this.toBytes(valueBuffer)); try { const parsed = JSON.parse(rawJson); // JSONPath $ returns array wrapper, unwrap it const unwrapped = Array.isArray(parsed) ? parsed[0] : parsed; response.value = JSON.stringify(unwrapped, null, this.settings.jsonFormat() ?? 2); } catch { response.value = rawJson; } break; case 'stream': const decodeEntry = (entry: any): any => { return entry.map((item: any) => { if (Array.isArray(item)) return decodeEntry(item); if (ArrayBuffer.isView(item) || item instanceof ArrayBuffer) return td.decode(item); return item; }); }; response.value = valueBuffer.map((entry: any) => decodeEntry(entry)); break; case 'timeseries': // valueBuffer is a JSON-encoded TS.INFO object try { response.value = JSON.parse(td.decode(this.toBytes(valueBuffer))); } catch { response.value = {}; } break; case 'bloom': case 'cuckoo': case 'topk': case 'cms': case 'tdigest': case 'vectorset': try { response.value = JSON.parse(td.decode(this.toBytes(valueBuffer))); } catch { response.value = {}; } break; default: try { response.value = JSON.parse(td.decode(this.toBytes(valueBuffer))); } catch { response.value = td.decode(this.toBytes(valueBuffer)); } break; } } private calculateSize(response: any): void { if (response.type !== 'stream') { if (typeof response.valueBuffer === 'object' && response.length > 0) { for (const k of Object.keys(response.valueBuffer)) { response.size += response.valueBuffer[k].byteLength; } } else if (Array.isArray(response.valueBuffer)) { for (const buf of response.valueBuffer) response.size += buf.byteLength; } else { response.size = response.valueBuffer.byteLength; } } else { const sumBytes = (arr: any[]): number => { let total = 0; const process = (el: any) => { if (ArrayBuffer.isView(el) || el instanceof ArrayBuffer) total += el.byteLength; else if (Array.isArray(el)) el.forEach(process); }; arr.forEach(process); return total; }; response.size = sumBytes(response.valueBuffer); } } private loadTtl(): void { if (!this.response || this.response.ttl <= -1) return; const updateTtl = () => { if (!this.checkTtl()) { this.clearTtlInterval(); return; } const hdOpts = this.settings.getHumanizeDurationOptions(); const parsed = ' ' + humanizeDuration(this.response.ttl * 1000, { ...hdOpts, delimiter: ' ', }); const el = document.getElementById('p3xr-database-key-ttl-counter'); if (el) el.innerText = parsed; }; updateTtl(); if (!this.state.reducedFunctions()) { this.clearTtlInterval(); this.ttlInterval = setInterval(() => { this.response.ttl--; updateTtl(); this.cdr.markForCheck(); }, 1000); } } private checkTtl(): boolean { if (this.response.ttl < -1 || (this.wasExpiring && this.response.ttl < 1)) { this.common.toast(this.i18n.strings().status.keyIsNotExisting); this.clearTtlInterval(); this.state.redisChanged.set(true); this.navigateTo('database.statistics'); return false; } return true; } private clearTtlInterval(): void { if (this.ttlInterval) { clearInterval(this.ttlInterval); this.ttlInterval = null; } } private generateHighlight(): void { this.removeHighlight(); const currentTheme = this.theme.currentTheme() ?? ''; const isDark = currentTheme.includes('Dark') || currentTheme.includes('Matrix'); const bg = isDark ? 'rgba(255,255,255,0.15)' : 'rgba(0,0,0,0.15)'; const color = isDark ? 'white' : 'black'; const style = document.createElement('style'); style.id = 'p3xr-theme-styles-tree-key'; style.textContent = `[data-p3xr-tree-key="${htmlEncode(this.key) ?? ''}"] .p3xr-database-tree-node-label { background-color: ${bg} !important; color: ${color} !important; padding: 2px; }`; document.head.appendChild(style); } private removeHighlight(): void { document.getElementById('p3xr-theme-styles-tree-key')?.remove(); } // --- Helpers --- private getStateParam(name: string): any { return this.route.snapshot.paramMap.get(name); } private navigateTo(state: string, params?: any): void { this.nav.navigateTo(state, params); } private gtag(page: string): void { try { if (typeof (window as any).gtag === 'function') { (window as any).gtag('config', this.settings.googleAnalytics, { page_path: page }); } } catch { /* noop */ } } } src/ng/pages/database/database-tree.component.html000066400000000000000000000126541517644030100225150ustar00rootroot00000000000000
@if (node.expandable) { } @else if (node.keysInfo) { @switch (node.keysInfo.type) { @case ('hash') { } @case ('list') { } @case ('set') { } @case ('string') { } @case ('zset') { } @case ('stream') { } @case ('json') { } @case ('timeseries') { } @case ('bloom') { } @case ('cuckoo') { } @case ('topk') { } @case ('cms') { } @case ('tdigest') { } @case ('vectorset') { } } } @if (node.type !== 'folder' && getRemainingTtl(node) > 0) { schedule } @if (!isReadonly) { @if (node.type === 'folder') { delete } @else { delete } add }
src/ng/pages/database/database-tree.component.scss000066400000000000000000000073751517644030100225300ustar00rootroot00000000000000// Host element — fill the parent container p3xr-database-tree { display: block; height: 100%; width: 100%; overflow: hidden; } // Tree viewport — fills parent container .p3xr-database-tree-viewport { height: 100%; width: 100%; scrollbar-width: none; -ms-overflow-style: none; &::-webkit-scrollbar { width: 0; height: 0; display: none; } } // Each flat node row .p3xr-database-tree-row { display: flex; align-items: center; height: 28px; line-height: 28px; white-space: nowrap; cursor: default; } // Folder expand/collapse icon — Font Awesome folder via ::before .p3xr-tree-branch-head { display: inline-block; font-family: 'Font Awesome 5 Free'; font-style: normal; font-weight: 900; font-size: 24px; line-height: 28px; width: 28px; text-align: center; margin-right: 4px; cursor: pointer; color: var(--p3xr-tree-branch-color); } .p3xr-tree-branch-head.tree-collapsed::before { content: "\f07b"; // fa-folder } .p3xr-tree-branch-head.tree-expanded::before { content: "\f07c"; // fa-folder-open } // Type icon — same box as folder icon, override global margin-left: 5px from index.scss .p3xr-tree-type-icon { display: inline-block !important; font-size: 14px !important; line-height: 28px !important; width: 28px !important; text-align: center !important; margin-left: 0 !important; margin-right: 4px !important; } // Node label container .p3xr-database-tree-node { cursor: pointer; display: inline-flex; align-items: center; height: 28px; white-space: nowrap; } .p3xr-database-tree-node-label { // Used by main-key component CSS for highlighting selected key } .p3xr-database-tree-node-count { opacity: 0.5; margin-left: 4px; } // Hover action icons — shared sizing .p3xr-database-treecontrol-folder-icon, .p3xr-database-treecontrol-delete-icon { font-size: 18px !important; height: 18px !important; width: 18px !important; min-width: 18px !important; min-height: 18px !important; line-height: 18px !important; cursor: pointer; vertical-align: middle; } // Add icon — warn/accent color .p3xr-database-treecontrol-folder-icon { color: var(--p3xr-common-warn-color); } // Delete icon — red warn color .p3xr-database-treecontrol-delete-icon { color: var(--p3xr-btn-warn-bg); } // TTL indicator badge .p3xr-tree-ttl-badge { display: inline-flex; align-items: center; vertical-align: middle; margin-left: 4px; cursor: default; height: 28px; } .p3xr-tree-ttl-icon { font-size: 16px !important; width: 16px !important; height: 16px !important; vertical-align: middle !important; } .p3xr-tree-ttl-green .p3xr-tree-ttl-icon { color: var(--mat-sys-primary, #4caf50); } .p3xr-tree-ttl-yellow .p3xr-tree-ttl-icon { color: var(--mat-sys-tertiary, #ff9800); } .p3xr-tree-ttl-red .p3xr-tree-ttl-icon { color: var(--mat-sys-error, #f44336); } .p3xr-tree-ttl-pulse { animation: p3xr-ttl-pulse 1s infinite; } @keyframes p3xr-ttl-pulse { 0%, 100% { opacity: 0.7; } 50% { opacity: 1; } } .p3xr-database-tree-actions { display: inline-flex; align-items: center; position: relative; top: -1px; visibility: hidden; } .p3xr-database-tree-row:hover .p3xr-database-tree-actions { visibility: visible; } @media (max-width: 599px) { .p3xr-database-tree-node { display: inline-block; } // On mobile the tree is in a flex column with no explicit pixel height. // cdk-virtual-scroll-viewport needs a concrete height to render. p3xr-database-tree { height: auto; min-height: 100px; } .p3xr-database-tree-viewport { height: 20vh; min-height: 100px; } } src/ng/pages/database/database-tree.component.ts000066400000000000000000000465571517644030100222100ustar00rootroot00000000000000import { Component, Input, Inject, OnInit, OnDestroy, NgZone, ElementRef, ViewChild, ChangeDetectorRef, ChangeDetectionStrategy, ViewEncapsulation, CUSTOM_ELEMENTS_SCHEMA, effect } from '@angular/core'; import { CommonModule } from '@angular/common'; import { CdkVirtualScrollViewport, ScrollingModule } from '@angular/cdk/scrolling'; import { MatTooltipModule } from '@angular/material/tooltip'; import { I18nService } from '../../services/i18n.service'; import { CommonService } from '../../services/common.service'; import { ThemeService } from '../../services/theme.service'; import { SocketService } from '../../services/socket.service'; import { KeyNewOrSetDialogService } from '../../dialogs/key-new-or-set-dialog.service'; import { NavigationService } from '../../services/navigation.service'; import { MainCommandService } from '../../services/main-command.service'; import { TreeBuilderService } from '../../services/tree-builder.service'; import { RedisStateService } from '../../services/redis-state.service'; import { SettingsService } from '../../services/settings.service'; import { htmlEncode } from 'js-htmlencode'; import humanizeDuration from 'humanize-duration'; export interface FlatTreeNode { label: string; key: string; level: number; expandable: boolean; type: 'folder' | 'element'; childCount: number; keysInfo?: { type: string; length: number; ttl?: number }; // Reference to the original hierarchical node (for expandedNodes sync) _sourceNode?: any; } @Component({ selector: 'p3xr-database-tree', standalone: true, imports: [ CommonModule, ScrollingModule, MatTooltipModule, ], schemas: [CUSTOM_ELEMENTS_SCHEMA], templateUrl: './database-tree.component.html', styleUrls: ['./database-tree.component.scss'], encapsulation: ViewEncapsulation.None, changeDetection: ChangeDetectionStrategy.OnPush, }) export class DatabaseTreeComponent implements OnInit, OnDestroy { @Input() p3xrResize: any; @Input() p3xrMainRef: any; @ViewChild(CdkVirtualScrollViewport) private viewport?: CdkVirtualScrollViewport; dataSource: FlatTreeNode[] = []; isEnabled = false; isReadonly = false; divider = ':'; readonly strings; private expandedKeys = new Set(); private expandedNodeObjects: any[] = []; private hierarchicalNodes: any[] = []; private get expansionStorageKey(): string { const connId = this.state.connection()?.id || 'none'; const db = this.state.currentDatabase() ?? 0; return `p3xr-tree-expanded-${connId}-${db}`; } private saveExpansion(): void { try { sessionStorage.setItem(this.expansionStorageKey, JSON.stringify([...this.expandedKeys])); } catch {} } private restoreExpansion(): void { try { const raw = sessionStorage.getItem(this.expansionStorageKey); if (raw) this.expandedKeys = new Set(JSON.parse(raw)); } catch {} } private readonly unsubs: Array<() => void> = []; constructor( @Inject(NgZone) private readonly ngZone: NgZone, @Inject(ElementRef) private readonly elementRef: ElementRef, @Inject(I18nService) private readonly i18n: I18nService, @Inject(CommonService) private readonly common: CommonService, @Inject(ThemeService) private readonly theme: ThemeService, @Inject(SocketService) private readonly socket: SocketService, @Inject(KeyNewOrSetDialogService) private readonly keyNewOrSetDialog: KeyNewOrSetDialogService, @Inject(NavigationService) private readonly nav: NavigationService, @Inject(MainCommandService) private readonly cmd: MainCommandService, @Inject(ChangeDetectorRef) private readonly cdr: ChangeDetectorRef, @Inject(TreeBuilderService) private readonly treeBuilder: TreeBuilderService, @Inject(RedisStateService) private readonly state: RedisStateService, @Inject(SettingsService) private readonly settingsService: SettingsService, ) { this.strings = this.i18n.strings; effect(() => { this.i18n.currentLang(); this.cdr.markForCheck(); }); } ngOnInit(): void { this.syncGlobalState(); this.attachWindowFocusListener(); this.startPolling(); this.startTtlRepaint(); // Subscribe to MainCommandService events const subDelete = this.cmd.keyDelete$.subscribe((arg) => { this.ngZone.run(() => this.deleteKey(arg.event, arg.key)); }); this.unsubs.push(() => subDelete.unsubscribe()); const subRename = this.cmd.keyRename$.subscribe((arg) => { this.ngZone.run(() => this.renameKey(arg.event, arg.key)); }); this.unsubs.push(() => subRename.unsubscribe()); const subKeyNew = this.cmd.keyNew$.subscribe((arg) => { this.ngZone.run(() => this.addKey(arg.event, arg.node ? { ...arg, _sourceNode: arg.node } as any : arg as any)); }); this.unsubs.push(() => subKeyNew.unsubscribe()); const subTreeEnabled = this.cmd.treeControlEnabled$.subscribe((enabled) => { this.ngZone.run(() => { this.isEnabled = enabled; }); }); this.unsubs.push(() => subTreeEnabled.unsubscribe()); const subTreeRefresh = this.cmd.treeRefresh$.subscribe(() => { this.ngZone.run(() => { this.syncGlobalState(); this.rebuildTree(); }); }); this.unsubs.push(() => subTreeRefresh.unsubscribe()); const subExpand = this.common.treeExpandAll$.subscribe(() => this.ngZone.run(() => { const allFolderKeys = new Set(); const collect = (nodes: any[]) => { for (const node of nodes) { if (node.type === 'folder') { allFolderKeys.add(node.key); collect(node.children ?? []); } } }; collect(this.hierarchicalNodes); this.expandedKeys = allFolderKeys; this.flattenVisibleNodes(); this.syncExpandedNodesToGlobal(); this.saveExpansion(); this.cdr.markForCheck(); })); this.unsubs.push(() => subExpand.unsubscribe()); const subCollapse = this.common.treeCollapseAll$.subscribe(() => this.ngZone.run(() => { this.expandedKeys = new Set(); this.flattenVisibleNodes(); this.syncExpandedNodesToGlobal(); this.saveExpansion(); this.cdr.markForCheck(); })); this.unsubs.push(() => subCollapse.unsubscribe()); const subExpandLevel = this.common.treeExpandToLevel$.subscribe((level: number) => this.ngZone.run(() => { const keys = new Set(); const collect = (nodes: any[], depth: number) => { for (const node of nodes) { if (node.type === 'folder') { if (depth < level) { keys.add(node.key); } collect(node.children ?? [], depth + 1); } } }; collect(this.hierarchicalNodes, 0); this.expandedKeys = keys; this.flattenVisibleNodes(); this.syncExpandedNodesToGlobal(); this.saveExpansion(); this.cdr.markForCheck(); })); this.unsubs.push(() => subExpandLevel.unsubscribe()); setTimeout(() => { this.isEnabled = true; this.cdr.markForCheck(); }, 50); } ngOnDestroy(): void { this.unsubs.forEach(fn => fn()); } // --- TTL (computed on-the-fly from fetchedAt, single 30s repaint) --- private ttlRepaintTimer: any; private startTtlRepaint(): void { const tick = () => { this.cdr.markForCheck(); let minTtl = Infinity; let hasExpired = false; for (const node of this.dataSource) { if (node.type === 'folder') continue; const serverTtl = node.keysInfo?.ttl; if (!serverTtl || serverTtl <= 0) continue; const remaining = this.getRemainingTtl(node); if (remaining <= 0) { hasExpired = true; } else if (remaining < minTtl) { minTtl = remaining; } } if (hasExpired) { this.cmd.refresh(); // Retry soon in case refresh was throttled this.ttlRepaintTimer = setTimeout(tick, 3000); return; } let interval: number; if (minTtl <= 30) { interval = 1000; } else if (minTtl <= 300) { interval = 5000; } else { interval = 30000; } this.ttlRepaintTimer = setTimeout(tick, interval); }; this.ttlRepaintTimer = setTimeout(tick, 30000); this.unsubs.push(() => clearTimeout(this.ttlRepaintTimer)); } getRemainingTtl(node: FlatTreeNode): number { const ttl = node.keysInfo?.ttl; if (!ttl || ttl <= 0) return -1; const fetchedAt = this.state.keysInfoFetchedAt() ?? Date.now(); const elapsed = Math.floor((Date.now() - fetchedAt) / 1000); const remaining = ttl - elapsed; return remaining > 0 ? remaining : -1; } formatTtl(node: FlatTreeNode): string { const remaining = this.getRemainingTtl(node); if (remaining <= 0) return ''; const hdOpts = this.settingsService.getHumanizeDurationOptions(); return humanizeDuration(remaining * 1000, { ...hdOpts, largest: 2, round: true, delimiter: ' ', }); } getTtlClass(node: FlatTreeNode): string { const remaining = this.getRemainingTtl(node); if (remaining <= 0) return ''; if (remaining < 30) return 'p3xr-tree-ttl-red p3xr-tree-ttl-pulse'; if (remaining < 300) return 'p3xr-tree-ttl-red'; if (remaining < 3600) return 'p3xr-tree-ttl-yellow'; return 'p3xr-tree-ttl-green'; } // --- Tree data --- trackByKey(_index: number, node: FlatTreeNode): string { return node.key; } isExpanded(node: FlatTreeNode): boolean { return this.expandedKeys.has(node.key); } toggleExpand(node: FlatTreeNode): void { if (this.expandedKeys.has(node.key)) { this.expandedKeys.delete(node.key); } else { this.expandedKeys.add(node.key); } this.flattenVisibleNodes(); this.syncExpandedNodesToGlobal(); this.saveExpansion(); } // --- Node actions --- selectNode(node: FlatTreeNode): void { this.navigateTo('database.key', { key: node.key, }); } async deleteKey(event: Event, key: string): Promise { try { event.preventDefault(); event.stopPropagation(); await this.common.confirm({ message: this.i18n.strings().confirm.deleteKey, }); await this.socket.request({ action: 'key/delete', payload: { key }, }); if (typeof window['gtag'] === 'function') { window['gtag']('config', this.settingsService.googleAnalytics, { page_path: '/delete' }); } this.navigateTo('database.statistics'); this.common.toast(this.i18n.strings().status.deletedKey({ key })); await this.cmd.refresh(); this.rebuildTree(); } catch (e) { this.common.generalHandleError(e); } } async renameKey(event: Event, key: string): Promise { try { event?.stopPropagation?.(); const newKey = await this.common.prompt({ title: this.i18n.strings().confirm.rename.title, placeholder: this.i18n.strings().confirm.rename.placeholder, initialValue: key, ok: this.i18n.strings().intention.rename, cancel: this.i18n.strings().intention.cancel, }); await this.socket.request({ action: 'key/rename', payload: { key, keyNew: newKey }, }); if (typeof window['gtag'] === 'function') { window['gtag']('config', this.settingsService.googleAnalytics, { page_path: '/rename' }); } this.navigateTo('database.key', { key: newKey, }); this.common.toast(this.i18n.strings().status.renamedKey); await this.cmd.refresh(); this.rebuildTree(); } catch (e) { this.common.generalHandleError(e); } } async deleteTree(event: Event, node: FlatTreeNode): Promise { try { event.stopPropagation(); await this.common.confirm({ message: this.i18n.strings().confirm.deleteAllKeys({ key: node.key }), }); const divider = this.settingsService.redisTreeDivider(); await this.socket.request({ action: 'key/del-tree', payload: { key: node.key, redisTreeDivider: divider, }, }); this.common.toast(this.i18n.strings().status.treeDeleted({ key: node.key })); // If currently viewing a key under the deleted tree, go to statistics const currentPath = location.pathname; if (currentPath.startsWith('/database/key/')) { const currentKey = decodeURIComponent(currentPath.slice('/database/key/'.length).replace(/~/g, '%')); if (currentKey.startsWith(node.key + divider)) { this.navigateTo('database.statistics'); } } await this.cmd.refresh(); this.rebuildTree(); } catch (e) { this.common.generalHandleError(e); } } async addKey(event: Event, node: FlatTreeNode): Promise { try { event.stopPropagation(); const response = await this.keyNewOrSetDialog.show({ type: 'add', $event: event, node: node._sourceNode ?? { key: node.key }, }); await this.cmd.refresh(); this.rebuildTree(); this.navigateTo('database.key', { key: response.key, }); } catch (e) { this.common.generalHandleError(e); } } // --- Tooltips --- extractNodeTooltip(node: FlatTreeNode): string { if (node.type !== 'folder' && node.keysInfo) { const strings = this.i18n.strings(); return htmlEncode((strings.redisTypes?.[node.keysInfo.type] ?? node.keysInfo.type) + ' - ' + node.key); } return htmlEncode(node.key); } deleteTreeTooltip(node: FlatTreeNode): string { return this.i18n.strings().confirm?.deleteAllKeys?.({ key: node.key }) ?? ''; } // --- Tree rebuild --- private rebuildTree(): void { this.divider = this.settingsService.redisTreeDivider() ?? ':'; this.isReadonly = this.state.connection()?.readonly === true; const keys: string[] = this.state.paginatedKeys() ?? []; const keysInfo: any = this.state.keysInfo() ?? {}; // Restore saved expansion state if current set is empty (fresh mount or connection/db switch) if (this.expandedKeys.size === 0) { this.restoreExpansion(); } this.treeBuilder.keysToTreeControl({ keys, divider: this.divider, keysInfo, }).then(({ nodes }) => { this.hierarchicalNodes = nodes; this.flattenVisibleNodes(); this.requestViewRefresh(); }); } private flattenVisibleNodes(): void { const result: FlatTreeNode[] = []; const flatten = (nodes: any[], level: number) => { for (const node of nodes) { result.push({ label: node.label, key: node.key, level, expandable: node.type === 'folder', type: node.type, childCount: node.childCount ?? 0, keysInfo: node.keysInfo, _sourceNode: node, }); if (node.type === 'folder' && this.expandedKeys.has(node.key) && node.children?.length > 0) { flatten(node.children, level + 1); } } }; flatten(this.hierarchicalNodes, 0); this.dataSource = result; } private syncExpandedNodesToGlobal(): void { // Build array of node objects matching the expanded keys const expandedNodeObjects: any[] = []; const collectExpanded = (nodes: any[]) => { for (const node of nodes) { if (node.type === 'folder' && this.expandedKeys.has(node.key)) { expandedNodeObjects.push(node); } if (node.children?.length > 0) { collectExpanded(node.children); } } }; collectExpanded(this.hierarchicalNodes); // Keep expanded nodes locally this.expandedNodeObjects = expandedNodeObjects; } private syncGlobalState(): void { this.divider = this.settingsService.redisTreeDivider() ?? ':'; this.isReadonly = this.state.connection()?.readonly === true; } // --- Polling for change detection --- private startPolling(): void { let lastSnapshot = ''; const id = setInterval(() => { const snapshot = JSON.stringify({ keysLength: this.state.paginatedKeys()?.length, page: this.state.page(), divider: this.settingsService.redisTreeDivider(), readonly: this.state.connection()?.readonly, }); if (snapshot !== lastSnapshot) { lastSnapshot = snapshot; this.ngZone.run(() => { this.syncGlobalState(); this.rebuildTree(); }); } }, 300); this.unsubs.push(() => clearInterval(id)); // Initial build this.ngZone.run(() => this.rebuildTree()); } private attachWindowFocusListener(): void { const focusListener = () => { if (this.isEnabled) { this.ngZone.run(() => { this.isEnabled = false; setTimeout(() => { this.isEnabled = true; this.rebuildTree(); }); }); } }; window.addEventListener('focus', focusListener); this.unsubs.push(() => window.removeEventListener('focus', focusListener)); } // --- Navigation --- private navigateTo(state: string, params?: any): void { this.nav.navigateTo(state, params); } private requestViewRefresh(): void { setTimeout(() => { try { this.cdr.detectChanges(); this.viewport?.checkViewportSize(); } catch { // Ignore late refreshes during teardown. } }); } } src/ng/pages/database/database-treecontrol-controls.component.html000066400000000000000000000170431517644030100257540ustar00rootroot00000000000000
@if (treeDividers.length > 0) { @for (divider of treeDividers; track divider) { } }
@if (pages > 1) { / {{ pages }} } @else { {{ keyCountText() }}  } src/ng/pages/database/database-treecontrol-controls.component.scss000066400000000000000000000111101517644030100257500ustar00rootroot00000000000000:host { display: block; margin-top: 2px; min-height: 24px; text-align: center; } #p3xr-database-treecontrol-controls-container { display: inline-block; } .p3xr-database-treecontrol-controls-leading { float: left; line-height: 31px; } .p3xr-database-treecontrol-controls-search { clear: both; padding: 5px; text-align: left; line-height: 24px; } .p3xr-database-treecontrol-controls-pager { display: inline-block; position: relative; top: 2px; vertical-align: middle; line-height: 24px; } .p3xr-database-treecontrol-controls-keycount { float: right; line-height: 26px; margin-top: 6px; opacity: 0.5; } .p3xr-database-treecontrol-divider-menu-label { font-family: 'Roboto Mono', monospace; font-size: 14px; font-weight: 500; } // Divider mat-menu overlay panel — rendered outside the component in CDK overlay // translateX shifts popup left to center on the divider input instead of the trigger arrow .p3xr-divider-menu.mat-mdc-menu-panel { min-width: 20px !important; max-width: 40px !important; transform: translateX(-20px); .mat-mdc-menu-content { padding: 0 !important; } .mat-mdc-menu-item { min-height: 28px; height: 28px; padding: 0 !important; min-width: 0; text-align: center; justify-content: center; .mdc-list-item__primary-text { width: 100%; text-align: center; } } .p3xr-database-treecontrol-divider-menu-label { display: block; text-align: center; width: 100%; } } // Icon buttons — match AngularJS md-button.md-icon-button with overrides .p3xr-database-treecontrol-icon-button { align-items: center; background: none; border: 0; border-radius: 50%; color: var(--p3xr-treecontrol-icon-color); cursor: pointer; display: inline-flex; height: 24px; justify-content: center; line-height: 24px; margin: 0; min-height: 24px; min-width: 24px; padding: 0; vertical-align: middle; width: 24px; } .p3xr-database-treecontrol-icon-button:focus { outline: none; } .p3xr-database-treecontrol-icon-button .material-icons { display: block; font-size: 24px; height: 24px; line-height: 24px; width: 24px; } .p3xr-database-treecontrol-root-add { color: var(--p3xr-common-warn-color); cursor: pointer; display: inline-block; font-size: 24px; height: 24px; line-height: 24px; vertical-align: middle; width: 24px; } .p3xr-database-treecontrol-icon-primary { color: var(--p3xr-btn-primary-bg); } // Divider input — sits inline with icon buttons p3xr-ng-input.p3xr-database-treecontrol-divider-input { font-family: 'Roboto Mono', monospace; font-size: 14px; font-weight: 500; text-align: center; vertical-align: middle !important; width: 23px; } p3xr-ng-input.p3xr-database-treecontrol-divider-input input.p3xr-input { text-align: center; } // Divider dropdown trigger button — clicks to open the divider selector .p3xr-database-treecontrol-divider-trigger { background: none; border: 0; color: var(--p3xr-treecontrol-icon-color); cursor: pointer; display: inline-flex; align-items: center; justify-content: center; height: 24px; margin: 0; padding: 0; vertical-align: middle; width: 14px; } .p3xr-database-treecontrol-divider-trigger .material-icons { font-size: 18px; height: 18px; width: 18px; } // Page input — inside pager row p3xr-ng-input.p3xr-database-treecontrol-page-input { vertical-align: middle !important; width: 48px; } // Pager text "/ 101" alignment .p3xr-database-treecontrol-pager-text { vertical-align: middle; } // Menu hint text shown below export/import when search is active .p3xr-menu-hint { padding: 0 16px 8px; font-size: 11px; opacity: 0.5; font-style: italic; pointer-events: none; line-height: 1.3; } // Search input p3xr-ng-input.p3xr-database-treecontrol-search-input { vertical-align: middle !important; width: auto; } // Not readonly: search + hamburger + add = 3 trailing buttons, +clear = 4 .p3xr-database-treecontrol-search-input.search-full { width: calc(100% - 73px); } .p3xr-database-treecontrol-search-input.search-full-clear { width: calc(100% - 98px); } // Readonly: search + hamburger = 2 trailing buttons, +clear = 3 .p3xr-database-treecontrol-search-input.search-readonly { width: calc(100% - 48px); } .p3xr-database-treecontrol-search-input.search-readonly-clear { width: calc(100% - 73px); } src/ng/pages/database/database-treecontrol-controls.component.ts000066400000000000000000000364531517644030100254440ustar00rootroot00000000000000import { Component, Input, Inject, OnInit, OnDestroy, NgZone, ElementRef, ChangeDetectorRef, ChangeDetectionStrategy, CUSTOM_ELEMENTS_SCHEMA, ViewEncapsulation } from '@angular/core'; import { CommonModule } from '@angular/common'; import { FormsModule } from '@angular/forms'; import { MatTooltipModule } from '@angular/material/tooltip'; import { MatMenuModule } from '@angular/material/menu'; import { MatIconModule } from '@angular/material/icon'; import { MatDividerModule } from '@angular/material/divider'; import { Subject } from 'rxjs'; import { debounceTime } from 'rxjs/operators'; import { P3xrInputComponent } from '../../components/p3xr-input.component'; import { I18nService } from '../../services/i18n.service'; import { CommonService } from '../../services/common.service'; import { MainCommandService } from '../../services/main-command.service'; import { SocketService } from '../../services/socket.service'; import { TreecontrolSettingsDialogService } from '../../dialogs/treecontrol-settings-dialog.service'; import { KeyImportDialogService } from '../../dialogs/key-import-dialog.service'; import { RedisStateService } from '../../services/redis-state.service'; import { SettingsService } from '../../services/settings.service'; import { OverlayService } from '../../services/overlay.service'; @Component({ selector: 'p3xr-database-treecontrol-controls', standalone: true, imports: [ CommonModule, FormsModule, MatTooltipModule, MatMenuModule, MatIconModule, MatDividerModule, P3xrInputComponent, ], schemas: [CUSTOM_ELEMENTS_SCHEMA], templateUrl: './database-treecontrol-controls.component.html', styleUrls: ['./database-treecontrol-controls.component.scss'], encapsulation: ViewEncapsulation.None, changeDetection: ChangeDetectionStrategy.OnPush, }) export class DatabaseTreecontrolControlsComponent implements OnInit, OnDestroy { @Input() p3xrMainRef: any; page = 1; pages = 0; search = ''; keyCount = 0; redisTreeDivider = ':'; treeDividers: string[] = []; searchClientSide = false; isReadonly = false; readonly strings; private readonly unsubs: Array<() => void> = []; private readonly dividerChange$ = new Subject(); constructor( @Inject(NgZone) private readonly ngZone: NgZone, @Inject(ElementRef) private readonly elementRef: ElementRef, @Inject(I18nService) private readonly i18n: I18nService, @Inject(CommonService) private readonly common: CommonService, @Inject(MainCommandService) private readonly cmd: MainCommandService, @Inject(TreecontrolSettingsDialogService) private readonly treeSettingsDialog: TreecontrolSettingsDialogService, @Inject(KeyImportDialogService) private readonly keyImportDialog: KeyImportDialogService, @Inject(SocketService) private readonly socket: SocketService, @Inject(ChangeDetectorRef) private readonly cdr: ChangeDetectorRef, @Inject(RedisStateService) private readonly state: RedisStateService, @Inject(SettingsService) private readonly settings: SettingsService, @Inject(OverlayService) private readonly overlay: OverlayService, ) { this.strings = this.i18n.strings; } ngOnInit(): void { this.syncFromGlobal(); // If search was restored from state (e.g. cookie), trigger it if (this.search) { this.onSearchChange(); } const sub = this.dividerChange$.pipe(debounceTime(666)).subscribe((value) => { this.applyDivider(value); }); this.unsubs.push(() => sub.unsubscribe()); const refreshSub = this.cmd.treeRefresh$.subscribe(() => { this.ngZone.run(() => { this.syncFromGlobal(); this.requestViewRefresh(); }); }); this.unsubs.push(() => refreshSub.unsubscribe()); } ngOnDestroy(): void { this.unsubs.forEach((unsub) => unsub()); } keyCountText(): string { const fn = this.strings()?.status?.keyCount; return typeof fn === 'function' ? fn({ keyCount: this.keyCount }) : String(this.keyCount); } searchPlaceholder(): string { const searchStrings = this.strings()?.page?.treeControls?.search; return this.searchClientSide ? (searchStrings?.placeholderClient) : (searchStrings?.placeholderServer); } treeExpandAll(): void { this.common.treeExpandAll$.next(); } treeExpandToLevel(level: number): void { this.common.treeExpandToLevel$.next(level); } treeCollapseAll(): void { this.common.treeCollapseAll$.next(); } async refreshTree(): Promise { await this.cmd.refresh(); this.ngZone.run(() => { this.syncFromGlobal(); this.requestViewRefresh(); }); } async openTreeSettingDialog(event: Event): Promise { await this.treeSettingsDialog.show({ $event: event }); this.syncFromGlobal(); this.requestViewRefresh(); } onDividerInputChange(value: string): void { this.redisTreeDivider = value ?? ''; this.settings.redisTreeDivider.set(this.redisTreeDivider); this.dividerChange$.next(this.redisTreeDivider); } setDivider(value: string): void { this.redisTreeDivider = value ?? ''; this.applyDivider(this.redisTreeDivider); } private applyDivider(value: string): void { this.settings.redisTreeDivider.set(value); this.state.redisChanged.set(true); this.cmd.treeRefresh$.next(); this.syncFromGlobal(); } pageAction(page: 'first' | 'prev' | 'next' | 'last'): void { const currentPage = this.state.page() ?? 1; const totalPages = this.pages; switch (page) { case 'prev': if (currentPage - 1 >= 1) { this.state.page.set(currentPage - 1); } break; case 'next': if (currentPage + 1 <= totalPages) { this.state.page.set(currentPage + 1); } break; case 'last': { this.state.page.set(totalPages); break; } case 'first': { this.state.page.set(1); break; } } this.syncFromGlobal(); } onPageInputChange(value: any): void { const parsed = parseInt(value, 10); const newPage = isNaN(parsed) ? 1 : parsed; this.state.page.set(newPage); this.pageChange(); } pageChange(): void { let currentPage = this.state.page() ?? 1; const totalPages = this.pages; if (currentPage < 1) { currentPage = 1; } else if (currentPage > totalPages) { currentPage = totalPages; } this.state.page.set(currentPage); this.syncFromGlobal(); } onSearchModelChange(value: string): void { this.search = value ?? ''; this.state.search.set(this.search); } async onSearchChange(): Promise { this.state.search.set(this.search); this.state.page.set(1); if (this.settings.searchClientSide()) { this.state.redisChanged.set(true); } await this.cmd.refresh(); this.syncFromGlobal(); this.requestViewRefresh(); this.socket.tick(); } async clearSearch(): Promise { this.search = ''; await this.onSearchChange(); } async exportKeys(): Promise { const keys = this.state.keysRaw(); if (!Array.isArray(keys) || keys.length === 0) { this.common.toast({ message: this.strings().label?.noKeysToExport }); return; } try { this.overlay.show({ message: this.strings().label?.exportProgress, }); const response = await this.socket.request({ action: 'key/export', payload: { keys }, }); const json = JSON.stringify(response.data, null, 2); const blob = new Blob([json], { type: 'application/json' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; const connName = this.state.connection()?.name || 'redis'; const db = this.state.currentDatabase() ?? 0; a.download = `${connName}-db${db}-export.json`; a.click(); URL.revokeObjectURL(url); this.common.toast({ message: this.strings().status?.exportDone }); } catch (e) { this.common.generalHandleError(e); } finally { this.overlay.hide(); } } async importKeys(): Promise { const input = document.createElement('input'); input.type = 'file'; input.accept = '.json'; input.onchange = async () => { const file = input.files?.[0]; if (!file) return; const reader = new FileReader(); reader.onerror = () => this.common.generalHandleError(reader.error); reader.onload = async (e: any) => { try { const parsed = JSON.parse(e.target.result); if (!parsed?.keys || !Array.isArray(parsed.keys) || parsed.keys.length === 0) { this.common.toast({ message: this.strings().label?.importNoKeys }); return; } const result = await this.keyImportDialog.show({ data: parsed }); if (result?.pending) { // Dialog closed, now show overlay and do import try { this.overlay.show({ message: this.strings().label?.importProgress, }); const response = await this.socket.request({ action: 'key/import', payload: { keys: result.keys, conflictMode: result.conflictMode, }, }); const data = response.data; const statusFn = this.strings().status?.importDone; const message = typeof statusFn === 'function' ? statusFn(data) : `Import complete: ${data.created} created, ${data.skipped} skipped, ${data.errors} errors`; this.common.toast({ message }); } finally { this.overlay.hide(); } // Refresh tree after import await this.cmd.refresh(); this.ngZone.run(() => { this.syncFromGlobal(); this.requestViewRefresh(); }); } } catch (e: any) { if (e !== undefined && e !== null) { this.common.generalHandleError(e); } } }; reader.readAsText(file); }; input.click(); } deleteSearchLabel(): string { const strings = this.strings(); if (this.search.length > 0) { const fn = strings.intention?.deleteSearchKeys; return typeof fn === 'function' ? fn({ count: this.keyCount }) : `Delete ${this.keyCount} matching keys`; } const fn = strings.intention?.deleteAllKeysMenu; return typeof fn === 'function' ? fn({ count: this.keyCount }) : `Delete all ${this.keyCount} keys`; } async deleteSearchKeys(): Promise { let match: string; if (this.search.length > 0) { if (this.settings.searchStartsWith()) { match = this.search + '*'; } else { match = '*' + this.search + '*'; } } else { match = '*'; } try { const confirmFn = this.strings().confirm?.deleteSearchKeys; const confirmMsg = typeof confirmFn === 'function' ? confirmFn({ count: this.keyCount, pattern: match }) : `Are you sure to delete all keys matching "${match}"? Found ${this.keyCount} keys.`; await this.common.confirm({ message: confirmMsg }); this.overlay.show({ message: this.strings().label?.deletingSearchKeys, }); const response = await this.socket.request({ action: 'key/delete-search-keys', payload: { match }, }); const deletedCount = response.deletedCount || 0; const statusFn = this.strings().status?.deletedSearchKeys; const message = typeof statusFn === 'function' ? statusFn({ count: deletedCount }) : `Deleted ${deletedCount} keys`; this.common.toast({ message }); await this.cmd.refresh(); this.ngZone.run(() => { this.syncFromGlobal(); this.requestViewRefresh(); }); } catch (e: any) { if (e !== undefined && e !== null) { this.common.generalHandleError(e); } } finally { this.overlay.hide(); } } searchInputClass(): string { const hasSearch = this.search.length > 0; if (this.isReadonly) { return hasSearch ? 'search-readonly-clear' : 'search-readonly'; } return hasSearch ? 'search-full-clear' : 'search-full'; } exportLabel(): string { const strings = this.strings(); if (this.search.length > 0) { const fn = strings.intention?.exportSearchResults; return typeof fn === 'function' ? fn({ count: this.keyCount }) : `Export ${this.keyCount} results`; } const fn = strings.intention?.exportAllKeys; return typeof fn === 'function' ? fn({ count: this.keyCount }) : `Export all ${this.keyCount} keys`; } addRootKey(event: Event): void { this.cmd.addKey({ event }); } private syncFromGlobal(): void { // Access state.filteredKeys() to trigger the computed getter const _keys = this.state.filteredKeys(); this.page = Number(this.state.page() ?? 1); this.pages = Number(this.state.pages() ?? 0); this.search = this.state.search() ?? ''; const keysRaw = this.state.keysRaw(); this.keyCount = Array.isArray(keysRaw) ? keysRaw.length : 0; this.redisTreeDivider = this.settings.redisTreeDivider() ?? ':'; this.treeDividers = Array.isArray(this.state.cfg()?.treeDividers) ? this.state.cfg().treeDividers.slice() : []; this.searchClientSide = !!this.settings.searchClientSide(); this.isReadonly = this.state.connection()?.readonly === true; } private requestViewRefresh(): void { setTimeout(() => { try { this.cdr.detectChanges(); } catch { // Ignore late refreshes during teardown. } }); } } src/ng/pages/database/database.component.html000066400000000000000000000026131517644030100215520ustar00rootroot00000000000000
src/ng/pages/database/database.component.scss000066400000000000000000000041211517644030100215550ustar00rootroot00000000000000@use "../../../scss/vars" as v; p3xr-database { @media (max-width:350px) { .p3xr-database-toolbar-button-hide-on-small { display: none; } } p3xr-database-header, .p3xr-content-border { border-top-left-radius: v.$border-radius; border-top-right-radius: v.$border-radius; } .p3xr-content-border { border: 1px solid var(--p3xr-accordion-bg); border-bottom-left-radius: v.$border-radius; border-bottom-right-radius: v.$border-radius; overflow: hidden; } #p3xr-database-treecontrol-container { position: fixed; overflow: auto; } #p3xr-database-treecontrol-container-directive-small { display: block; flex: 1 1 auto; min-height: 0; max-height: none; overflow-y: auto; overflow-x: auto; } .p3xr-database-treecontrol-folder-icon { transform: scale(0.75); } #p3xr-database-content-container { position: fixed; overflow: auto; display: block; } @media (max-width: 959px) { #p3xr-database-content { overflow-x: hidden; } .p3xr-database-has-connection { display: flex; flex-direction: column; min-height: 100%; } } } #p3xr-database-content-sizer { position: fixed; display: block; cursor: ew-resize; z-index: 8; background-color: var(--p3xr-accordion-bg); transition: background-color 0.15s ease; body.p3xr-theme-dark &:hover { filter: brightness(1.3); } body.p3xr-theme-dark &.p3xr-resizer-active { filter: brightness(1.6); } body.p3xr-theme-light &:hover { filter: brightness(0.85); } body.p3xr-theme-light &.p3xr-resizer-active { filter: brightness(0.7); } } // During a drag, cursor is forced via a dynamically-injected ') // // Uses the Layout sub-theme for border/toolbar colors (matching AngularJS usage of themeLayout) // Uses the Main sub-theme for content-area colors // Uses the Common sub-theme for status/indicator colors @use '@angular/material' as mat; @use 'theme-definitions' as defs; // ============================================================================ // Shared dark/light mixins // ============================================================================ @mixin p3xr-dark-custom-props($layout-theme, $main-theme, $common-theme) { // Layout toolbar (primary default hue — header/footer) --p3xr-toolbar-bg: #{mat.get-theme-color($layout-theme, neutral-variant, 40)}; --p3xr-toolbar-color: #{mat.get-theme-color($layout-theme, neutral-variant, 90)}; --p3xr-toolbar-strong-bg: #{mat.get-theme-color($layout-theme, primary, 20)}; --p3xr-toolbar-strong-color: #{mat.get-theme-color($layout-theme, neutral, 98)}; // Accordion toolbar (primary hue-1 — content area section headers) --p3xr-accordion-bg: #{mat.get-theme-color($layout-theme, neutral-variant, 60)}; --p3xr-accordion-color: #{mat.get-theme-color($layout-theme, neutral-variant, 10)}; // Button colors from Main sub-theme: primary, accent(tertiary), warn(error) --p3xr-btn-primary-bg: #{mat.get-theme-color($main-theme, primary, 80)}; --p3xr-btn-primary-color: rgba(0, 0, 0, 0.87); --p3xr-btn-accent-bg: #{mat.get-theme-color($main-theme, tertiary, 80)}; --p3xr-btn-accent-color: rgba(0, 0, 0, 0.87); --p3xr-btn-warn-bg: #{mat.get-theme-color($main-theme, error, 80)}; --p3xr-btn-warn-color: rgba(0, 0, 0, 0.87); --p3xr-common-btn-primary-bg: #{mat.get-theme-color($common-theme, primary, 80)}; --p3xr-common-btn-primary-color: rgba(0, 0, 0, 0.87); --p3xr-plain-button-color: rgba(255, 255, 255, 0.87); --p3xr-hover-bg: rgba(255, 255, 255, 0.1); --p3xr-hover-bg-inverse: rgba(0, 0, 0, 0.1); --p3xr-border-color: #{mat.get-theme-color($layout-theme, primary, 50)}; --p3xr-input-border-color: #{mat.get-theme-color($layout-theme, primary, 50)}; --p3xr-content-border-color: rgba(255, 255, 255, 0.12); --p3xr-content-border-toolbar-color: rgba(255, 255, 255, 0.12); --p3xr-dialog-surface-bg: var(--p3xr-content-bg); --p3xr-toast-bg: #{mat.get-theme-color($common-theme, neutral, 10)}; --p3xr-toast-border: rgba(255, 255, 255, 0.5); --p3xr-toast-shadow: 0 0 10px rgba(0, 0, 0, 0.6); --p3xr-tree-branch-color: #{mat.get-theme-color($main-theme, tertiary, 80)}; --p3xr-tree-branch-shadow: 1px 1px 1px rgba(55, 29, 27, 0.5); --p3xr-list-odd-bg: rgba(255, 255, 255, 0.05); --p3xr-list-border: rgba(255, 255, 255, 0.05); --p3xr-autofill-bg: rgb(66, 66, 66, 0.9); --p3xr-autofill-color: white; --p3xr-input-bg: rgba(64, 64, 64, 1); --p3xr-input-color: white; --p3xr-fieldset-border: rgba(255, 255, 255, 0.25); --p3xr-selection-color: white; --p3xr-selection-bg: black; --p3xr-placeholder-color: rgba(255, 255, 255, 0.75); --p3xr-menu-selected-bg: rgba(255, 255, 255, 0.1); --p3xr-json-key-color: white; --p3xr-json-value-string: var(--p3xr-btn-accent-bg); --p3xr-json-value-number: var(--p3xr-btn-primary-bg); --p3xr-json-value-boolean: var(--p3xr-btn-warn-bg); --p3xr-json-value-null: rgba(255, 255, 255, 0.4); --p3xr-treecontrol-icon-color: rgba(255, 255, 255, 0.7); --p3xr-link-color: #82b1ff; --p3xr-common-warn-color: var(--p3xr-btn-warn-bg); } // Accordion/toolbar background colors matching AngularJS md-toolbar with Layout sub-theme // AngularJS used md-theme="themeLayout" + class="md-primary md-hue-1" // These are the exact colors from the AngularJS Material palette definitions @mixin p3xr-light-custom-props($layout-theme, $main-theme, $common-theme) { // Layout toolbar (primary default hue — header/footer) --p3xr-toolbar-bg: #{mat.get-theme-color($layout-theme, neutral-variant, 60)}; --p3xr-toolbar-color: #{mat.get-theme-color($layout-theme, neutral-variant, 10)}; --p3xr-toolbar-strong-bg: #{mat.get-theme-color($layout-theme, primary, 20)}; --p3xr-toolbar-strong-color: #{mat.get-theme-color($layout-theme, neutral, 98)}; // Accordion toolbar (primary hue-1 — content area section headers) --p3xr-accordion-bg: #{mat.get-theme-color($layout-theme, neutral-variant, 40)}; --p3xr-accordion-color: #{mat.get-theme-color($layout-theme, neutral-variant, 90)}; // Button colors from Main sub-theme: primary, accent(tertiary), warn(error) --p3xr-btn-primary-bg: #{mat.get-theme-color($main-theme, primary, 40)}; --p3xr-btn-primary-color: white; --p3xr-btn-accent-bg: #{mat.get-theme-color($main-theme, tertiary, 40)}; --p3xr-btn-accent-color: white; --p3xr-btn-warn-bg: #{mat.get-theme-color($main-theme, error, 40)}; --p3xr-btn-warn-color: white; --p3xr-common-btn-primary-bg: #{mat.get-theme-color($common-theme, primary, 40)}; --p3xr-common-btn-primary-color: white; --p3xr-plain-button-color: rgba(0, 0, 0, 0.87); --p3xr-hover-bg: rgba(0, 0, 0, 0.1); --p3xr-hover-bg-inverse: rgba(255, 255, 255, 0.1); --p3xr-border-color: #{mat.get-theme-color($layout-theme, primary, 50)}; --p3xr-input-border-color: #{mat.get-theme-color($layout-theme, primary, 50)}; --p3xr-content-border-color: transparent; --p3xr-content-border-toolbar-color: #{mat.get-theme-color($layout-theme, primary, 50)}; --p3xr-dialog-surface-bg: var(--p3xr-content-bg); --p3xr-toast-bg: auto; --p3xr-toast-border: auto; --p3xr-toast-shadow: none; --p3xr-tree-branch-color: #{mat.get-theme-color($main-theme, tertiary, 40)}; --p3xr-tree-branch-shadow: 1px 1px 0px rgba(55, 11, 0, 0.5); --p3xr-list-odd-bg: rgba(0, 0, 0, 0.04); --p3xr-list-border: rgba(0, 0, 0, 0.06); --p3xr-autofill-bg: rgba(255, 255, 255, 0.5); --p3xr-autofill-color: black; --p3xr-input-bg: white; --p3xr-input-color: black; --p3xr-fieldset-border: rgba(0, 0, 0, 0.5); --p3xr-selection-color: inherit; --p3xr-selection-bg: highlight; --p3xr-placeholder-color: inherit; --p3xr-menu-selected-bg: rgba(0, 0, 0, 0.1); --p3xr-json-key-color: black; --p3xr-json-value-string: var(--p3xr-btn-accent-bg); --p3xr-json-value-number: var(--p3xr-btn-primary-bg); --p3xr-json-value-boolean: var(--p3xr-btn-warn-bg); --p3xr-json-value-null: rgba(0, 0, 0, 0.4); --p3xr-treecontrol-icon-color: rgba(0, 0, 0, 0.87); --p3xr-link-color: #1a73e8; --p3xr-common-warn-color: var(--p3xr-btn-warn-bg); } // ============================================================================ // Per-theme CSS custom properties (using all 3 sub-themes) // ============================================================================ // ============================================================================ // Per-theme EXACT colors from AngularJS Material palette definitions // These override the mixin-generated M3 values with the production hex values // Source: angular-material/angular-material.js palette definitions // ============================================================================ body.p3xr-mat-theme-enterprise { @include p3xr-light-custom-props(defs.$p3xr-theme-enterprise-layout, defs.$p3xr-theme-enterprise, defs.$p3xr-theme-enterprise-common); // Enterprise: Layout primary=grey default:800, hue-1:500 --p3xr-toolbar-bg: #424242; // grey-800 (primary default — header/footer) --p3xr-toolbar-color: rgba(255,255,255,0.87); --p3xr-toolbar-strong-bg: #212121; // grey-900 --p3xr-toolbar-strong-color: rgba(255,255,255,0.87); --p3xr-accordion-bg: #9e9e9e; // grey-500 (primary hue-1 — accordion headers) --p3xr-accordion-color: rgba(0,0,0,0.87); --p3xr-btn-primary-bg: #3f51b5; // indigo-500 --p3xr-btn-primary-color: white; --p3xr-btn-accent-bg: #1976d2; // blue-700 --p3xr-btn-accent-color: white; --p3xr-btn-warn-bg: #d32f2f; // red-700 --p3xr-btn-warn-color: white; --p3xr-common-btn-primary-bg: #4caf50; // green-500 --p3xr-common-btn-primary-color: white; --p3xr-border-color: #424242; --p3xr-input-border-color: #9e9e9e; --p3xr-dialog-surface-bg: #ffffff; --p3xr-content-bg: #fafafa; // near-white (default light bg) --p3xr-body-bg: #e0e0e0; // grey-300 (body background) --p3xr-common-warn-color: #03a9f4; // light-blue-500 } body.p3xr-mat-theme-light { @include p3xr-light-custom-props(defs.$p3xr-theme-light-layout, defs.$p3xr-theme-light, defs.$p3xr-theme-light-common); // Light: Layout primary=blue-grey default:800, hue-1:200 --p3xr-toolbar-bg: #37474f; // blue-grey-800 (primary default — header/footer) --p3xr-toolbar-color: rgba(255,255,255,0.87); --p3xr-toolbar-strong-bg: #263238; // blue-grey-900 --p3xr-toolbar-strong-color: rgba(255,255,255,0.87); --p3xr-accordion-bg: #b0bec5; // blue-grey-200 (primary hue-1 — accordion headers) --p3xr-accordion-color: rgba(0,0,0,0.87); --p3xr-btn-primary-bg: #673ab7; // deep-purple-500 --p3xr-btn-primary-color: white; --p3xr-btn-accent-bg: #9c27b0; // purple-500 --p3xr-btn-accent-color: white; --p3xr-btn-warn-bg: #d32f2f; // red-700 --p3xr-btn-warn-color: white; --p3xr-common-btn-primary-bg: #4caf50; // green-500 --p3xr-common-btn-primary-color: white; --p3xr-border-color: #37474f; --p3xr-input-border-color: #b0bec5; --p3xr-dialog-surface-bg: #cfd8dc; // blue-grey-100 (legacy md-dialog paper) --p3xr-content-bg: #eceff1; // blue-grey-50 --p3xr-body-bg: #cfd8dc; // blue-grey-100 (body background) --p3xr-common-warn-color: #607d8b; // blue-grey-500 } body.p3xr-mat-theme-redis { @include p3xr-light-custom-props(defs.$p3xr-theme-redis-layout, defs.$p3xr-theme-redis, defs.$p3xr-theme-redis-common); // Redis: Layout primary=red default:800, hue-1:200 --p3xr-toolbar-bg: #c62828; // red-800 (primary default — header/footer) --p3xr-toolbar-color: rgba(255,255,255,0.87); --p3xr-accordion-bg: #ef9a9a; // red-200 (primary hue-1 — accordion headers) --p3xr-accordion-color: rgba(0,0,0,0.87); --p3xr-toolbar-strong-bg: #b71c1c; // red-900 --p3xr-toolbar-strong-color: rgba(255,255,255,0.87); --p3xr-btn-primary-bg: #212121; // grey-900 --p3xr-btn-primary-color: rgba(255,255,255,0.87); --p3xr-btn-accent-bg: #757575; // grey-600 --p3xr-btn-accent-color: white; --p3xr-btn-warn-bg: #ffc107; // amber-500 --p3xr-btn-warn-color: white; --p3xr-common-btn-primary-bg: #4caf50; // green-500 --p3xr-common-btn-primary-color: white; --p3xr-border-color: #c62828; --p3xr-input-border-color: #ef9a9a; --p3xr-dialog-surface-bg: #ffffff; --p3xr-content-bg: #fafafa; // near-white (default light bg) --p3xr-body-bg: #ffcdd2; // red-100 (body background) --p3xr-common-warn-color: #f44336; // red-500 } body.p3xr-mat-theme-dark { @include p3xr-dark-custom-props(defs.$p3xr-theme-dark-layout, defs.$p3xr-theme-dark, defs.$p3xr-theme-dark-common); // Dark: Layout primary=grey default:800, hue-1:500 --p3xr-toolbar-bg: #424242; // grey-800 (primary default — header/footer) --p3xr-toolbar-color: rgba(255,255,255,0.87); --p3xr-accordion-bg: #9e9e9e; // grey-500 (primary hue-1 — accordion headers) --p3xr-accordion-color: rgba(0,0,0,0.87); --p3xr-toolbar-strong-bg: #212121; // grey-900 --p3xr-toolbar-strong-color: rgba(255,255,255,0.87); --p3xr-btn-primary-bg: #7986cb; // indigo-300 --p3xr-btn-primary-color: rgba(0,0,0,0.87); --p3xr-btn-accent-bg: #2196f3; // blue-500 --p3xr-btn-accent-color: rgba(0,0,0,0.87); --p3xr-btn-warn-bg: #ff9800; // orange-500 --p3xr-btn-warn-color: rgba(0,0,0,0.87); --p3xr-common-btn-primary-bg: #4caf50; // green-500 --p3xr-common-btn-primary-color: rgba(0,0,0,0.87); --p3xr-border-color: #424242; --p3xr-input-border-color: #9e9e9e; --p3xr-dialog-surface-bg: #424242; // legacy dark md-dialog paper --p3xr-content-bg: #303030; // grey-A400 (dark mode md-content bg) --p3xr-body-bg: #212121; // grey-900 (body background) --p3xr-common-warn-color: #9fa8da; // indigo-200 } body.p3xr-mat-theme-dark-neu { @include p3xr-dark-custom-props(defs.$p3xr-theme-dark-neu-layout, defs.$p3xr-theme-dark-neu, defs.$p3xr-theme-dark-neu-common); // DarkNeu: Layout primary=blue-grey default:800, hue-1:300 --p3xr-toolbar-bg: #37474f; // blue-grey-800 (primary default — header/footer) --p3xr-toolbar-color: rgba(255,255,255,0.87); --p3xr-accordion-bg: #90a4ae; // blue-grey-300 (primary hue-1 — accordion headers) --p3xr-accordion-color: rgba(0,0,0,0.87); --p3xr-toolbar-strong-bg: #263238; // blue-grey-900 --p3xr-toolbar-strong-color: rgba(255,255,255,0.87); --p3xr-btn-primary-bg: #00bcd4; // cyan-500 --p3xr-btn-primary-color: rgba(0,0,0,0.87); --p3xr-btn-accent-bg: #2196f3; // blue-500 --p3xr-btn-accent-color: rgba(0,0,0,0.87); --p3xr-btn-warn-bg: #ffeb3b; // yellow-500 --p3xr-btn-warn-color: rgba(0,0,0,0.87); --p3xr-common-btn-primary-bg: #4caf50; // green-500 --p3xr-common-btn-primary-color: rgba(0,0,0,0.87); --p3xr-border-color: #37474f; --p3xr-input-border-color: #90a4ae; --p3xr-dialog-surface-bg: #424242; --p3xr-content-bg: #303030; // dark mode md-content bg --p3xr-body-bg: #263238; // blue-grey-900 (body background) --p3xr-common-warn-color: #2196f3; // blue-500 } body.p3xr-mat-theme-darko-bluo { @include p3xr-dark-custom-props(defs.$p3xr-theme-darko-bluo-layout, defs.$p3xr-theme-darko-bluo, defs.$p3xr-theme-darko-bluo-common); // DarkoBluo: Layout primary=indigo default:900, hue-1:500 --p3xr-toolbar-bg: #1a237e; // indigo-900 (primary default — header/footer) --p3xr-toolbar-color: rgba(255,255,255,0.87); --p3xr-accordion-bg: #3f51b5; // indigo-500 (primary hue-1 — accordion headers) --p3xr-accordion-color: white; --p3xr-toolbar-strong-bg: #1a237e; // indigo-900 --p3xr-toolbar-strong-color: rgba(255,255,255,0.87); --p3xr-btn-primary-bg: #7986cb; // indigo-300 --p3xr-btn-primary-color: rgba(0,0,0,0.87); --p3xr-btn-accent-bg: #2196f3; // blue-500 --p3xr-btn-accent-color: rgba(0,0,0,0.87); --p3xr-btn-warn-bg: #ff9800; // orange-500 --p3xr-btn-warn-color: rgba(0,0,0,0.87); --p3xr-common-btn-primary-bg: #4caf50; // green-500 --p3xr-common-btn-primary-color: rgba(0,0,0,0.87); --p3xr-border-color: #1a237e; --p3xr-input-border-color: #3f51b5; --p3xr-dialog-surface-bg: #424242; --p3xr-content-bg: #303030; // dark mode md-content bg --p3xr-body-bg: #283593; // indigo-800 (body background) --p3xr-common-warn-color: #03a9f4; // light-blue-500 } body.p3xr-mat-theme-matrix { @include p3xr-dark-custom-props(defs.$p3xr-theme-matrix-layout, defs.$p3xr-theme-matrix, defs.$p3xr-theme-matrix-common); // Matrix: Layout primary=light-green default:A400, hue-1:A400 --p3xr-toolbar-bg: #76ff03; // light-green-A400 (primary default — header/footer) --p3xr-toolbar-color: rgba(0,0,0,0.87); --p3xr-accordion-bg: #76ff03; // light-green-A400 (primary hue-1 — accordion headers, same) --p3xr-accordion-color: rgba(0,0,0,0.87); --p3xr-toolbar-strong-bg: #33691e; // light-green-900 --p3xr-toolbar-strong-color: rgba(255,255,255,0.87); --p3xr-btn-primary-bg: #76ff03; // light-green-A400 --p3xr-btn-primary-color: rgba(0,0,0,0.87); --p3xr-btn-accent-bg: #c6ff00; // lime-A400 --p3xr-btn-accent-color: rgba(0,0,0,0.87); --p3xr-btn-warn-bg: #00c853; // green-A700 --p3xr-btn-warn-color: rgba(0,0,0,0.87); --p3xr-common-btn-primary-bg: #76ff03; // light-green-A400 --p3xr-common-btn-primary-color: rgba(0,0,0,0.87); --p3xr-border-color: #76ff03; --p3xr-tree-branch-color: #76ff03; --p3xr-input-border-color: #76ff03; --p3xr-dialog-surface-bg: #424242; --p3xr-content-bg: #303030; // dark mode md-content bg --p3xr-body-bg: #1b5e20; // green-900 (body background) --p3xr-common-warn-color: #4caf50; // green-500 } src/ng/themes/_theme-definitions.scss000066400000000000000000000133361517644030100202140ustar00rootroot00000000000000// Angular Material theme definitions for P3X Redis UI // // Each AngularJS theme has 3 sub-themes (Layout, Main, Common) with different palettes. // M3 palette mapping from AngularJS M1: // grey → neutral (built into M3) | indigo → $azure-palette // blue → $blue-palette | blue-grey → $azure-palette // cyan → $cyan-palette | deep-purple → $violet-palette // purple → $magenta-palette | light-green → $chartreuse-palette // lime → $chartreuse-palette | green → $green-palette // orange → $orange-palette | red → $red-palette // yellow → $yellow-palette | amber → $orange-palette @use '@angular/material' as mat; // ============================================================================ // Light Theme // AngularJS: Layout=blue-grey, Main=deep-purple/purple/red, Common=green/grey/blue-grey // ============================================================================ $p3xr-theme-light-layout: mat.define-theme(( color: (theme-type: light, primary: mat.$azure-palette, tertiary: mat.$azure-palette) )); $p3xr-theme-light: mat.define-theme(( color: (theme-type: light, primary: mat.$violet-palette, tertiary: mat.$magenta-palette) )); $p3xr-theme-light-common: mat.define-theme(( color: (theme-type: light, primary: mat.$green-palette, tertiary: mat.$azure-palette) )); // ============================================================================ // Enterprise Theme (light) // AngularJS: Layout=grey, Main=indigo/blue-700/red-700, Common=green/grey/light-blue // ============================================================================ $p3xr-theme-enterprise-layout: mat.define-theme(( color: (theme-type: light, primary: mat.$azure-palette, tertiary: mat.$azure-palette) )); $p3xr-theme-enterprise: mat.define-theme(( color: (theme-type: light, primary: mat.$azure-palette, tertiary: mat.$blue-palette) )); $p3xr-theme-enterprise-common: mat.define-theme(( color: (theme-type: light, primary: mat.$green-palette, tertiary: mat.$blue-palette) )); // ============================================================================ // Redis Theme (light) // AngularJS: Layout=red-800/red background, Main=GREY-900/GREY-600/amber (neutral!), Common=green/grey/red // IMPORTANT: Main content uses GREY (neutral) — NOT red. Only toolbar is red. // ============================================================================ $p3xr-theme-redis-layout: mat.define-theme(( color: (theme-type: light, primary: mat.$red-palette, tertiary: mat.$red-palette) )); // Main uses neutral palette — closest to grey-900 primary in M3 $p3xr-theme-redis: mat.define-theme(( color: (theme-type: light, primary: mat.$rose-palette, tertiary: mat.$orange-palette) )); $p3xr-theme-redis-common: mat.define-theme(( color: (theme-type: light, primary: mat.$green-palette, tertiary: mat.$red-palette) )); // ============================================================================ // Dark Theme // AngularJS: Layout=grey-800 dark, Main=indigo-300/blue/ORANGE, Common=green/grey/indigo-200 dark // ============================================================================ $p3xr-theme-dark-layout: mat.define-theme(( color: (theme-type: dark, primary: mat.$azure-palette, tertiary: mat.$azure-palette) )); $p3xr-theme-dark: mat.define-theme(( color: (theme-type: dark, primary: mat.$azure-palette, tertiary: mat.$blue-palette) )); $p3xr-theme-dark-common: mat.define-theme(( color: (theme-type: dark, primary: mat.$green-palette, tertiary: mat.$azure-palette) )); // ============================================================================ // DarkNeu Theme // AngularJS: Layout=blue-grey-800 dark, Main=cyan/blue/yellow dark, Common=green/grey/blue dark // ============================================================================ $p3xr-theme-dark-neu-layout: mat.define-theme(( color: (theme-type: dark, primary: mat.$azure-palette, tertiary: mat.$azure-palette) )); $p3xr-theme-dark-neu: mat.define-theme(( color: (theme-type: dark, primary: mat.$cyan-palette, tertiary: mat.$blue-palette) )); $p3xr-theme-dark-neu-common: mat.define-theme(( color: (theme-type: dark, primary: mat.$green-palette, tertiary: mat.$blue-palette) )); // ============================================================================ // DarkoBluo Theme // AngularJS: Layout=indigo-900 dark, Main=indigo-300/BLUE/orange dark, Common=green/grey/light-blue dark // ============================================================================ $p3xr-theme-darko-bluo-layout: mat.define-theme(( color: (theme-type: dark, primary: mat.$violet-palette, tertiary: mat.$violet-palette) )); // DarkoBluo is more indigo/violet than Dark (which is more azure/blue) $p3xr-theme-darko-bluo: mat.define-theme(( color: (theme-type: dark, primary: mat.$violet-palette, tertiary: mat.$blue-palette) )); $p3xr-theme-darko-bluo-common: mat.define-theme(( color: (theme-type: dark, primary: mat.$green-palette, tertiary: mat.$blue-palette) )); // ============================================================================ // Matrix Theme // AngularJS: Layout=light-green-A400 dark, Main=light-green-A400/lime-A400/green-A700 dark, Common=light-green-A400 dark // ============================================================================ $p3xr-theme-matrix-layout: mat.define-theme(( color: (theme-type: dark, primary: mat.$chartreuse-palette, tertiary: mat.$chartreuse-palette) )); $p3xr-theme-matrix: mat.define-theme(( color: (theme-type: dark, primary: mat.$chartreuse-palette, tertiary: mat.$green-palette) )); $p3xr-theme-matrix-common: mat.define-theme(( color: (theme-type: dark, primary: mat.$chartreuse-palette, tertiary: mat.$green-palette) )); src/ng/themes/angular-material-themes.scss000066400000000000000000001471601517644030100211550ustar00rootroot00000000000000// P3X Redis UI — Angular Material Theme Application // // Each theme has 3 sub-themes matching the AngularJS architecture: // - Layout: toolbar, header, footer → scoped under .p3xr-mat-layout // - Main: content area → scoped under body.p3xr-mat-theme-{name} // - Common: status indicators → scoped under .p3xr-mat-common // // IMPORTANT: Dark themes come FIRST, light themes come LAST. // CSS ordering matters — when switching from dark to light, the light theme's // CSS variables must override the dark theme's. Since body.class selectors have // equal specificity, the LATER rule in the file wins. // // During hybrid mode, these only affect Angular (mat-*) components. // AngularJS (md-*) components continue using their own theme system. @use '@angular/material' as mat; @use 'theme-definitions' as defs; @use 'theme-custom'; // Angular Material core styles (typography, ripple, etc.) — applied once globally @include mat.core(); // ============================================================================ // Default theme (Enterprise — light) applied at root level // ============================================================================ :root { @include mat.all-component-themes(defs.$p3xr-theme-enterprise); } // Light theme M3 surface neutralization is at the END of the file (after all per-theme // blocks) so it wins the CSS cascade. See the body.p3xr-theme-light block at the bottom. // ============================================================================ // DARK THEMES FIRST (so light themes can override when switching) // ============================================================================ body.p3xr-mat-theme-dark { @include mat.all-component-colors(defs.$p3xr-theme-dark); .p3xr-mat-layout { @include mat.toolbar-color(defs.$p3xr-theme-dark-layout); @include mat.button-color(defs.$p3xr-theme-dark-layout); @include mat.icon-button-color(defs.$p3xr-theme-dark-layout); @include mat.menu-color(defs.$p3xr-theme-dark-layout); } .p3xr-mat-common { @include mat.button-color(defs.$p3xr-theme-dark-common); @include mat.icon-color(defs.$p3xr-theme-dark-common); } } body.p3xr-mat-theme-dark-neu { @include mat.all-component-colors(defs.$p3xr-theme-dark-neu); .p3xr-mat-layout { @include mat.toolbar-color(defs.$p3xr-theme-dark-neu-layout); @include mat.button-color(defs.$p3xr-theme-dark-neu-layout); @include mat.icon-button-color(defs.$p3xr-theme-dark-neu-layout); @include mat.menu-color(defs.$p3xr-theme-dark-neu-layout); } .p3xr-mat-common { @include mat.button-color(defs.$p3xr-theme-dark-neu-common); @include mat.icon-color(defs.$p3xr-theme-dark-neu-common); } } body.p3xr-mat-theme-darko-bluo { @include mat.all-component-colors(defs.$p3xr-theme-darko-bluo); .p3xr-mat-layout { @include mat.toolbar-color(defs.$p3xr-theme-darko-bluo-layout); @include mat.button-color(defs.$p3xr-theme-darko-bluo-layout); @include mat.icon-button-color(defs.$p3xr-theme-darko-bluo-layout); @include mat.menu-color(defs.$p3xr-theme-darko-bluo-layout); } .p3xr-mat-common { @include mat.button-color(defs.$p3xr-theme-darko-bluo-common); @include mat.icon-color(defs.$p3xr-theme-darko-bluo-common); } } body.p3xr-mat-theme-matrix { @include mat.all-component-colors(defs.$p3xr-theme-matrix); .p3xr-mat-layout { @include mat.toolbar-color(defs.$p3xr-theme-matrix-layout); @include mat.button-color(defs.$p3xr-theme-matrix-layout); @include mat.icon-button-color(defs.$p3xr-theme-matrix-layout); @include mat.menu-color(defs.$p3xr-theme-matrix-layout); } .p3xr-mat-common { @include mat.button-color(defs.$p3xr-theme-matrix-common); @include mat.icon-color(defs.$p3xr-theme-matrix-common); } } // ============================================================================ // LIGHT THEMES LAST (override dark theme CSS variables on switch) // ============================================================================ body.p3xr-mat-theme-light { @include mat.all-component-colors(defs.$p3xr-theme-light); .p3xr-mat-layout { @include mat.toolbar-color(defs.$p3xr-theme-light-layout); @include mat.button-color(defs.$p3xr-theme-light-layout); @include mat.icon-button-color(defs.$p3xr-theme-light-layout); @include mat.menu-color(defs.$p3xr-theme-light-layout); } .p3xr-mat-common { @include mat.button-color(defs.$p3xr-theme-light-common); @include mat.icon-color(defs.$p3xr-theme-light-common); } } body.p3xr-mat-theme-enterprise { @include mat.all-component-colors(defs.$p3xr-theme-enterprise); .p3xr-mat-layout { @include mat.toolbar-color(defs.$p3xr-theme-enterprise-layout); @include mat.button-color(defs.$p3xr-theme-enterprise-layout); @include mat.icon-button-color(defs.$p3xr-theme-enterprise-layout); @include mat.menu-color(defs.$p3xr-theme-enterprise-layout); } .p3xr-mat-common { @include mat.button-color(defs.$p3xr-theme-enterprise-common); @include mat.icon-color(defs.$p3xr-theme-enterprise-common); } } body.p3xr-mat-theme-redis { @include mat.all-component-colors(defs.$p3xr-theme-redis); .p3xr-mat-layout { @include mat.toolbar-color(defs.$p3xr-theme-redis-layout); @include mat.button-color(defs.$p3xr-theme-redis-layout); @include mat.icon-button-color(defs.$p3xr-theme-redis-layout); @include mat.menu-color(defs.$p3xr-theme-redis-layout); } .p3xr-mat-common { @include mat.button-color(defs.$p3xr-theme-redis-common); @include mat.icon-color(defs.$p3xr-theme-redis-common); } } // ============================================================================ // Shared app-level styles consuming CSS custom properties // ============================================================================ [data-p3xr-tree-key]:hover .p3xr-database-tree-node-label { background-color: var(--p3xr-hover-bg); } .p3xr-content-border { border-left: 1px solid var(--p3xr-content-border-color); border-right: 1px solid var(--p3xr-content-border-color); border-bottom: 1px solid var(--p3xr-content-border-color); } .p3xr-content-border-fixed { border-left: 1px solid var(--p3xr-border-color); border-right: 1px solid var(--p3xr-border-color); border-bottom: 1px solid var(--p3xr-border-color); } .p3xr-content-border-toolbar { border-left: 1px solid var(--p3xr-border-color); border-right: 1px solid var(--p3xr-border-color); border-top: 1px solid var(--p3xr-border-color); } .p3xr-list-key-odd-item { background-color: var(--p3xr-list-odd-bg); } .p3xr-list-key-item { border-bottom: 1px solid var(--p3xr-list-border); } input:-webkit-autofill, input:-webkit-autofill:focus { -webkit-box-shadow: 0 0 0 50px var(--p3xr-autofill-bg) inset !important; -webkit-text-fill-color: var(--p3xr-autofill-color) !important; } fieldset { border-color: var(--p3xr-fieldset-border); } .p3xr-md-menu-item-selected, .p3xr-mat-menu-item-selected { background-color: var(--p3xr-menu-selected-bg) !important; } .p3xr-language-highlighted { background-color: var(--mat-menu-item-hover-state-layer-color, rgba(0, 0, 0, 0.04)) !important; } .p3xr-connection-group-header { display: flex; align-items: center; gap: 8px; padding: 8px 16px; font-weight: 700; font-size: 13px; opacity: 0.8; background: var(--p3xr-list-odd-bg); border-bottom: 1px solid var(--p3xr-list-border); cursor: pointer; user-select: none; } .p3xr-connection-group-header:hover { opacity: 1; background: var(--p3xr-hover-bg); } .p3xr-language-menu { min-width: 320px !important; max-height: 400px !important; .mat-mdc-menu-content { padding-top: 0; } } .p3xr-language-search-container { padding: 8px 0; position: sticky; top: 0; z-index: 1; background: var(--mat-menu-container-color, var(--mat-app-surface)); } .p3xr-language-search-input { display: block; width: calc(100% - 20px); margin: 0 auto; padding: 8px; border: 2px solid var(--p3xr-input-border-color, var(--p3xr-border-color, rgba(0, 0, 0, 0.12))); border-radius: 4px; font-size: 14px; background: var(--p3xr-input-bg, transparent); color: var(--p3xr-input-color, inherit); outline: none; box-sizing: border-box; &:focus { border-width: 3px; border-color: var(--p3xr-input-border-color, var(--p3xr-border-color, #1976d2)); } &::placeholder { color: var(--mat-app-text-color, rgba(0, 0, 0, 0.38)); opacity: 0.5; } } .p3xr-command-palette-panel .mat-mdc-dialog-container .mdc-dialog__surface { padding: 0 !important; } json-tree .key { color: var(--p3xr-json-key-color); font-weight: bold; } // Global button color classes — uses correct Angular Material CSS variable names // mat-flat-button uses: background-color: var(--mat-button-filled-container-color, var(--mat-sys-primary)) .btn-primary { --mdc-filled-button-container-color: var(--p3xr-btn-primary-bg) !important; --mdc-filled-button-label-text-color: var(--p3xr-btn-primary-color) !important; --mdc-protected-button-container-color: var(--p3xr-btn-primary-bg) !important; --mdc-protected-button-label-text-color: var(--p3xr-btn-primary-color) !important; --mat-button-filled-container-color: var(--p3xr-btn-primary-bg) !important; --mat-button-filled-label-text-color: var(--p3xr-btn-primary-color) !important; --mat-button-protected-container-color: var(--p3xr-btn-primary-bg) !important; --mat-button-protected-label-text-color: var(--p3xr-btn-primary-color) !important; --mat-fab-container-color: var(--p3xr-btn-primary-bg) !important; --mat-fab-foreground-color: var(--p3xr-btn-primary-color) !important; --mat-fab-small-container-color: var(--p3xr-btn-primary-bg) !important; --mat-fab-small-foreground-color: var(--p3xr-btn-primary-color) !important; color: var(--p3xr-btn-primary-color) !important; } .btn-accent { --mdc-filled-button-container-color: var(--p3xr-btn-accent-bg) !important; --mdc-filled-button-label-text-color: var(--p3xr-btn-accent-color) !important; --mdc-protected-button-container-color: var(--p3xr-btn-accent-bg) !important; --mdc-protected-button-label-text-color: var(--p3xr-btn-accent-color) !important; --mat-button-filled-container-color: var(--p3xr-btn-accent-bg) !important; --mat-button-filled-label-text-color: var(--p3xr-btn-accent-color) !important; --mat-button-protected-container-color: var(--p3xr-btn-accent-bg) !important; --mat-button-protected-label-text-color: var(--p3xr-btn-accent-color) !important; --mat-fab-container-color: var(--p3xr-btn-accent-bg) !important; --mat-fab-foreground-color: var(--p3xr-btn-accent-color) !important; --mat-fab-small-container-color: var(--p3xr-btn-accent-bg) !important; --mat-fab-small-foreground-color: var(--p3xr-btn-accent-color) !important; color: var(--p3xr-btn-accent-color) !important; } .btn-warn { --mdc-filled-button-container-color: var(--p3xr-btn-warn-bg) !important; --mdc-filled-button-label-text-color: var(--p3xr-btn-warn-color) !important; --mdc-protected-button-container-color: var(--p3xr-btn-warn-bg) !important; --mdc-protected-button-label-text-color: var(--p3xr-btn-warn-color) !important; --mat-button-filled-container-color: var(--p3xr-btn-warn-bg) !important; --mat-button-filled-label-text-color: var(--p3xr-btn-warn-color) !important; --mat-button-protected-container-color: var(--p3xr-btn-warn-bg) !important; --mat-button-protected-label-text-color: var(--p3xr-btn-warn-color) !important; --mat-fab-container-color: var(--p3xr-btn-warn-bg) !important; --mat-fab-foreground-color: var(--p3xr-btn-warn-color) !important; --mat-fab-small-container-color: var(--p3xr-btn-warn-bg) !important; --mat-fab-small-foreground-color: var(--p3xr-btn-warn-color) !important; color: var(--p3xr-btn-warn-color) !important; } .btn-primary, .btn-primary .mdc-button__label, .btn-primary mat-icon, .btn-primary i { color: var(--p3xr-btn-primary-color) !important; } .btn-accent, .btn-accent .mdc-button__label, .btn-accent mat-icon, .btn-accent i { color: var(--p3xr-btn-accent-color) !important; } .btn-warn, .btn-warn .mdc-button__label, .btn-warn mat-icon, .btn-warn i { color: var(--p3xr-btn-warn-color) !important; } // ============================================================================ // Accordion content // ============================================================================ .p3xr-accordion-content { background-color: var(--p3xr-content-bg, #fafafa); color: var(--mat-app-text-color, inherit); overflow: visible; padding: 0; } // ============================================================================ // Buttons — match AngularJS Material md-button md-raised // Production: height 36px, padding 0 6px, border-radius 4px, uppercase // ============================================================================ // AngularJS md-button.md-raised: padding 0 6px, margin 6px 8px, min-width 88px, min-height 36px // AngularJS md-button.md-raised styling for content-area buttons. .mat-mdc-button-base:not(.mat-mdc-icon-button):not(.mat-mdc-fab-base):not(.mat-mdc-snack-bar-action) { text-transform: uppercase !important; letter-spacing: 0.089em !important; border-radius: 4px !important; padding: 0 6px !important; min-width: 88px !important; height: 36px !important; line-height: 36px !important; margin: 6px 8px !important; font-size: 14px !important; // Narrower gap between icon and text than Google M3 default gap: 3px !important; // Match toolbar header/footer letter-spacing letter-spacing: 0.1px !important; } // Dialog content action buttons: match footer button (p3xr-dialog-actions) padding. // Must beat global .mat-mdc-button-base:not():not() specificity (0,3,0). // Uses symmetric padding + justify-content:center so icons are centered in both // icon+text and icon-only states. // Wide: icon + text → asymmetric padding matching footer (10px left, 8px right) // Narrow: icon only → min-width 48px matching footer CANCEL, centered .p3xr-action-btn.mat-mdc-button-base:not(.mat-mdc-icon-button):not(.mat-mdc-fab-base) { padding-left: 10px !important; padding-right: 8px !important; margin: 0 4px !important; min-width: 48px !important; letter-spacing: 0.01em !important; display: inline-flex !important; align-items: center !important; justify-content: center !important; } .p3xr-action-btn.mat-mdc-button-base mat-icon, .p3xr-action-btn.mat-mdc-button-base .mat-icon, .p3xr-action-btn.mat-mdc-button-base i { margin-left: 0 !important; margin-right: 4px !important; } .p3xr-action-btn.mat-mdc-button-base .mdc-button__label { display: inline !important; gap: 0 !important; letter-spacing: inherit !important; } .mat-mdc-button-base:not(.mat-mdc-icon-button):not(.mat-mdc-fab-base):not(.mat-mdc-snack-bar-action) .mdc-button__label { display: inline-flex; align-items: center; gap: 8px; } .mat-mdc-button-base:not(.mat-mdc-icon-button):not(.mat-mdc-fab-base):not(.mat-mdc-snack-bar-action) .mdc-button__label > mat-icon, .mat-mdc-button-base:not(.mat-mdc-icon-button):not(.mat-mdc-fab-base):not(.mat-mdc-snack-bar-action) .mdc-button__label > i { flex-shrink: 0; } .mat-mdc-button-base:not(.mat-mdc-icon-button):not(.mat-mdc-fab-base):not(.mat-mdc-snack-bar-action) .mdc-button__ripple { border-radius: 4px !important; } // Normalize Font Awesome button icons to the same painted size as Material's 24px icons. // Font Awesome ships a visibly larger glyph inside the same 24px box, so normalize the // pseudo-element instead of only sizing the outer . .mat-mdc-button-base i.fa, .mat-mdc-button-base i.fas, .mat-mdc-button-base i.far, .mat-mdc-button-base i.fab { transform: none !important; display: inline-flex !important; align-items: center !important; justify-content: center !important; width: 24px !important; height: 24px !important; line-height: 24px !important; font-size: 24px !important; margin-left: 0 !important; margin-right: 0 !important; } .mat-mdc-button-base i.fa::before, .mat-mdc-button-base i.fas::before, .mat-mdc-button-base i.far::before, .mat-mdc-button-base i.fab::before { display: block !important; font-size: 15px !important; line-height: 15px !important; transform: none !important; } // Settings page connection actions should match the original AngularJS md-button box model. .p3xr-connection-item .btn-primary, .p3xr-connection-item .btn-accent, .p3xr-connection-item .btn-warn { min-width: auto !important; padding-left: 8px !important; padding-right: 8px !important; letter-spacing: 0.01em !important; } .p3xr-connection-item .btn-primary .mdc-button__label, .p3xr-connection-item .btn-accent .mdc-button__label, .p3xr-connection-item .btn-warn .mdc-button__label { display: inline !important; gap: 0 !important; letter-spacing: inherit !important; } .p3xr-connection-item .btn-primary mat-icon, .p3xr-connection-item .btn-accent mat-icon, .p3xr-connection-item .btn-warn mat-icon { margin-left: 0 !important; margin-right: 0 !important; width: 24px !important; height: 24px !important; font-size: 24px !important; } .p3xr-connection-item .btn-primary:not([aria-label]) mat-icon, .p3xr-connection-item .btn-accent:not([aria-label]) mat-icon, .p3xr-connection-item .btn-warn:not([aria-label]) mat-icon, .p3xr-connection-item .btn-primary:not([aria-label]) i, .p3xr-connection-item .btn-accent:not([aria-label]) i, .p3xr-connection-item .btn-warn:not([aria-label]) i { margin-right: 3px !important; } // Standalone p3xr-ng-button should match the original AngularJS md-button: // min-width: 0, horizontal padding: 8px, theme-aware black/white foreground. p3xr-ng-button .mat-mdc-button-base:not(.mat-mdc-icon-button):not(.mat-mdc-fab-base) { min-width: 0 !important; padding-left: 8px !important; padding-right: 8px !important; color: var(--p3xr-plain-button-color) !important; } p3xr-ng-button .mat-mdc-button-base:not(.mat-mdc-icon-button):not(.mat-mdc-fab-base) mat-icon, p3xr-ng-button .mat-mdc-button-base:not(.mat-mdc-icon-button):not(.mat-mdc-fab-base) i, p3xr-ng-button .mat-mdc-button-base:not(.mat-mdc-icon-button):not(.mat-mdc-fab-base) .mdc-button__label { color: inherit !important; } // Accordion toolbar: buttons, icons, text use accordion color (md-primary hue-1, always dark-on-light) .p3xr-accordion-toolbar .p3xr-accordion-title, .p3xr-accordion-toolbar .p3xr-accordion-actions { color: var(--p3xr-accordion-color) !important; } .p3xr-accordion-toolbar .mat-mdc-icon-button, .p3xr-accordion-toolbar .mat-mdc-icon-button mat-icon, .p3xr-accordion-toolbar p3xr-ng-button .mat-mdc-button-base:not(.mat-mdc-icon-button):not(.mat-mdc-fab-base), .p3xr-accordion-toolbar p3xr-ng-button .mat-mdc-button-base:not(.mat-mdc-icon-button):not(.mat-mdc-fab-base) .mdc-button__label, .p3xr-accordion-toolbar p3xr-ng-button .mat-mdc-button-base:not(.mat-mdc-icon-button):not(.mat-mdc-fab-base) mat-icon, .p3xr-accordion-toolbar p3xr-ng-button .mat-mdc-button-base:not(.mat-mdc-icon-button):not(.mat-mdc-fab-base) i { color: var(--p3xr-accordion-color) !important; } .p3xr-accordion-toolbar .mat-mdc-button-base, .p3xr-accordion-toolbar .mat-mdc-icon-button, .p3xr-accordion-toolbar mat-icon, .p3xr-accordion-toolbar .mat-mdc-button-base .mdc-button__label { color: var(--p3xr-accordion-color) !important; } // Version/SNAPSHOT label overlaying the header toolbar — must match toolbar text color. // AngularJS used md-colors="{ color: 'background-A100' }" (white for most themes, grey-900 for Matrix). #p3xr-layout-header-version { color: var(--p3xr-toolbar-color) !important; } body.p3xr-mat-theme-matrix #p3xr-layout-header-version { color: #212121 !important; // grey-900 — AngularJS used getVersionColor() → 'grey-900' for Matrix } // Matrix: bright green toolbar needs dark hover (white is invisible on #76ff03) body.p3xr-mat-theme-matrix mat-toolbar.p3xr-mat-layout .mat-mdc-button-base:not(.mat-mdc-fab-base):hover { background-color: rgba(0, 0, 0, 0.1) !important; } // Matrix: darken dialog footer — bright #76ff03 is too intense as a footer bg body.p3xr-mat-theme-matrix .p3xr-dialog-actions { background-color: #0a2e0d !important; // near-black green } // Accordion toolbar: hue-1 color from Layout sub-theme (section headers in content area). // Uses mat-toolbar.class for higher specificity over mat.all-component-colors() output. mat-toolbar.p3xr-accordion-toolbar { background-color: var(--p3xr-accordion-bg) !important; color: var(--p3xr-accordion-color) !important; } // Force all children in accordion toolbar to inherit accordion color mat-toolbar.p3xr-accordion-toolbar * { color: inherit; } mat-toolbar.p3xr-accordion-toolbar .mat-mdc-select, mat-toolbar.p3xr-accordion-toolbar .mat-mdc-select-value, mat-toolbar.p3xr-accordion-toolbar .mat-mdc-select-arrow, mat-toolbar.p3xr-accordion-toolbar .mat-mdc-form-field, mat-toolbar.p3xr-accordion-toolbar .mdc-text-field, mat-toolbar.p3xr-accordion-toolbar .mat-mdc-floating-label, mat-toolbar.p3xr-accordion-toolbar mat-icon, mat-toolbar.p3xr-accordion-toolbar span, mat-toolbar.p3xr-accordion-toolbar a { color: var(--p3xr-accordion-color) !important; } // Layout toolbar: exact colors matching AngularJS md-toolbar with Layout sub-theme. // The mat.toolbar-color() mixin above applies M3 palette colors which approximate // but don't match the AngularJS Material M1 colors. These CSS custom properties // (defined per-theme in _theme-custom.scss) provide the exact hex values from the // original AngularJS palette definitions. mat-toolbar.p3xr-mat-layout { background-color: var(--p3xr-toolbar-bg) !important; color: var(--p3xr-toolbar-color) !important; // Match AngularJS Material md-toolbar font-size (M3 default is 22px) font-size: 20px !important; // Override Angular Material button letter-spacing CSS variables to match M1 (~0.1px) --mdc-text-button-label-text-tracking: 0.1px !important; --mdc-protected-button-label-text-tracking: 0.1px !important; --mat-toolbar-title-text-tracking: normal !important; // Lock toolbar to 48px at all breakpoints (AM default is 64px desktop / 56px mobile) --mat-toolbar-standard-height: 48px; --mat-toolbar-mobile-height: 48px; } // Header toolbar elevation: Material elevation 8dp #p3xr-layout-header-container mat-toolbar.p3xr-mat-layout { box-shadow: 0px 5px 5px -3px rgba(0, 0, 0, 0.2), 0px 8px 10px 1px rgba(0, 0, 0, 0.14), 0px 3px 14px 2px rgba(0, 0, 0, 0.12) !important; } // Footer toolbar elevation: Material elevation 8dp (upward) #p3xr-layout-footer-container mat-toolbar.p3xr-mat-layout { box-shadow: 0px -5px 5px -3px rgba(0, 0, 0, 0.2), 0px -8px 10px 1px rgba(0, 0, 0, 0.14), 0px -3px 14px 2px rgba(0, 0, 0, 0.12) !important; } // Buttons inside layout toolbars: match AngularJS md-button styling. // Uses :not() pseudo-classes to beat the global .mat-mdc-button-base:not():not() // rule (specificity 0,3,0) that sets letter-spacing: 0.089em. mat-toolbar.p3xr-mat-layout .mat-mdc-button-base:not(.mat-mdc-fab-base) { color: inherit !important; letter-spacing: 0.1px !important; text-transform: uppercase !important; height: 36px !important; min-height: 36px !important; min-width: auto !important; // Narrower padding than production to save space in footer with many buttons padding: 0px 4px !important; margin: 0px 4px !important; // Center icon when button collapses to icon-only display: inline-flex !important; align-items: center !important; justify-content: center !important; // Hover: lighten on dark toolbar background (matches AngularJS md-button-dark-hover-fix). // Uses white alpha since toolbar bg is dark in most themes. // Matrix theme overrides below with dark hover for its bright green toolbar. &:hover { background-color: rgba(255, 255, 255, 0.15) !important; } } // All text-bearing children inside layout toolbar buttons mat-toolbar.p3xr-mat-layout .mat-mdc-button-base:not(.mat-mdc-fab-base) *, mat-toolbar.p3xr-mat-layout .mdc-button__label, mat-toolbar.p3xr-mat-layout span { color: inherit !important; letter-spacing: 0.1px !important; } // Uniform icon-to-text gap: zero out Angular Material's internal gaps, // then use explicit margin-right on icons for consistent 4px gap. mat-toolbar.p3xr-mat-layout .mat-mdc-button-base:not(.mat-mdc-fab-base) { gap: 0 !important; } mat-toolbar.p3xr-mat-layout .mat-mdc-button-base:not(.mat-mdc-fab-base) .mdc-button__label { gap: 0 !important; display: inline-flex !important; align-items: center !important; margin-left: 0 !important; } // Same margin-right on both icon types for uniform gap. // When icon is :last-child (no text label visible), margin is 0 for square icon-only buttons. mat-toolbar.p3xr-mat-layout mat-icon, mat-toolbar.p3xr-mat-layout i.fa, mat-toolbar.p3xr-mat-layout i.fas, mat-toolbar.p3xr-mat-layout i.fab { margin-right: 4px !important; margin-left: 0 !important; } mat-toolbar.p3xr-mat-layout mat-icon:last-child, mat-toolbar.p3xr-mat-layout i.fa:last-child, mat-toolbar.p3xr-mat-layout i.fas:last-child, mat-toolbar.p3xr-mat-layout i.fab:last-child { margin-right: 0 !important; } // All icons inside layout toolbars: uniform 24px matching AngularJS md-icon size mat-toolbar.p3xr-mat-layout mat-icon { font-size: 24px !important; width: 24px !important; height: 24px !important; color: inherit !important; } // FA icons in layout toolbars: 24px matching Material icons. // Must override the global .mat-mdc-button-base i.fa::before rule (specificity 0,2,1) // which forces FA glyphs to 15px — so we use .mat-mdc-button-base in our selector too. mat-toolbar.p3xr-mat-layout i.fa, mat-toolbar.p3xr-mat-layout i.fas, mat-toolbar.p3xr-mat-layout i.fab { font-size: 24px !important; line-height: 1 !important; vertical-align: middle !important; color: inherit !important; } mat-toolbar.p3xr-mat-layout .mat-mdc-button-base i.fa::before, mat-toolbar.p3xr-mat-layout .mat-mdc-button-base i.fas::before, mat-toolbar.p3xr-mat-layout .mat-mdc-button-base i.far::before, mat-toolbar.p3xr-mat-layout .mat-mdc-button-base i.fab::before { font-size: 24px !important; line-height: 24px !important; } // fa-power-off and fa-donate glyphs are visually larger than Material icons — scale down to match mat-toolbar.p3xr-mat-layout .mat-mdc-button-base i.fa-power-off::before, mat-toolbar.p3xr-mat-layout .mat-mdc-button-base i.fa-donate::before { font-size: 21px !important; line-height: 24px !important; } .p3xr-mat-layout-strong { background-color: var(--p3xr-toolbar-strong-bg) !important; color: var(--p3xr-toolbar-strong-color) !important; } .p3xr-mat-layout-strong .mat-mdc-icon-button, .p3xr-mat-layout-strong .mat-icon, .p3xr-mat-layout-strong .mdc-icon-button, .p3xr-mat-layout-strong .mdc-button__label { color: inherit !important; } .p3xr-mat-common .btn-primary { --mdc-filled-button-container-color: var(--p3xr-common-btn-primary-bg) !important; --mdc-filled-button-label-text-color: var(--p3xr-common-btn-primary-color) !important; --mdc-protected-button-container-color: var(--p3xr-common-btn-primary-bg) !important; --mdc-protected-button-label-text-color: var(--p3xr-common-btn-primary-color) !important; --mat-button-filled-container-color: var(--p3xr-common-btn-primary-bg) !important; --mat-button-filled-label-text-color: var(--p3xr-common-btn-primary-color) !important; --mat-button-protected-container-color: var(--p3xr-common-btn-primary-bg) !important; --mat-button-protected-label-text-color: var(--p3xr-common-btn-primary-color) !important; color: var(--p3xr-common-btn-primary-color) !important; } .p3xr-mat-common .btn-primary .mdc-button__label, .p3xr-mat-common .btn-primary mat-icon, .p3xr-mat-common .btn-primary i { color: inherit !important; } // Accordion toolbar icon buttons must keep the original AngularJS md-icon-button geometry: // square 40x40, 8px padding, no 88px min-width from the shared raised-button override. .p3xr-accordion-toolbar .mat-mdc-icon-button { min-width: 0 !important; width: 40px !important; height: 40px !important; padding: 8px !important; margin: 0 6px !important; line-height: 24px !important; border-radius: 50% !important; display: inline-flex !important; align-items: center !important; justify-content: center !important; flex: 0 0 40px !important; } .p3xr-accordion-toolbar .mat-mdc-icon-button .mat-mdc-button-touch-target { width: 40px !important; height: 40px !important; } .p3xr-accordion-toolbar .mat-mdc-icon-button mat-icon { margin: 0 !important; } // Accordion toolbar button/icon styling — mirrors the layout toolbar rules above. // Ensures buttons, icons (Material + FA), and text match the layout footer appearance. mat-toolbar.p3xr-accordion-toolbar .mat-mdc-button-base:not(.mat-mdc-fab-base) *, mat-toolbar.p3xr-accordion-toolbar .mdc-button__label, mat-toolbar.p3xr-accordion-toolbar span { color: inherit !important; letter-spacing: 0.1px !important; } mat-toolbar.p3xr-accordion-toolbar .mat-mdc-button-base:not(.mat-mdc-fab-base) { gap: 0 !important; } mat-toolbar.p3xr-accordion-toolbar .mat-mdc-button-base:not(.mat-mdc-fab-base) .mdc-button__label { gap: 0 !important; display: inline-flex !important; align-items: center !important; margin-left: 0 !important; } mat-toolbar.p3xr-accordion-toolbar mat-icon, mat-toolbar.p3xr-accordion-toolbar i.fa, mat-toolbar.p3xr-accordion-toolbar i.fas, mat-toolbar.p3xr-accordion-toolbar i.fab { margin-right: 4px !important; margin-left: 0 !important; } mat-toolbar.p3xr-accordion-toolbar mat-icon:last-child, mat-toolbar.p3xr-accordion-toolbar i.fa:last-child, mat-toolbar.p3xr-accordion-toolbar i.fas:last-child, mat-toolbar.p3xr-accordion-toolbar i.fab:last-child { margin-right: 0 !important; } mat-toolbar.p3xr-accordion-toolbar mat-icon { font-size: 24px !important; width: 24px !important; height: 24px !important; color: inherit !important; } // FA icons in accordion toolbars: 24px matching Material icons. // Overrides the global .mat-mdc-button-base rule that forces ::before to 15px. mat-toolbar.p3xr-accordion-toolbar i.fa, mat-toolbar.p3xr-accordion-toolbar i.fas, mat-toolbar.p3xr-accordion-toolbar i.far, mat-toolbar.p3xr-accordion-toolbar i.fab { font-size: 24px !important; line-height: 1 !important; vertical-align: middle !important; color: inherit !important; } mat-toolbar.p3xr-accordion-toolbar i.fa::before, mat-toolbar.p3xr-accordion-toolbar i.fas::before, mat-toolbar.p3xr-accordion-toolbar i.far::before, mat-toolbar.p3xr-accordion-toolbar i.fab::before { font-size: 24px !important; line-height: 24px !important; } // Database select dropdown: wider panel, circle indicator, no selection checkmark .p3xr-database-db-select-container { min-width: 120px !important; } .p3xr-database-db-select-container .mat-mdc-option { color: var(--mat-app-text-color, inherit) !important; } .p3xr-database-db-select-container .mat-mdc-option .mat-pseudo-checkbox { display: none !important; } .p3xr-database-db-select-container .mat-mdc-option .p3xr-db-indicator { font-size: 18px !important; width: 18px !important; height: 18px !important; margin-right: 8px !important; vertical-align: middle; color: var(--mat-app-text-color, inherit) !important; } // ============================================================================ // mat-list — match production md-list // ============================================================================ .mat-mdc-list { padding-top: 0 !important; padding-bottom: 0 !important; } .mat-mdc-list-item .mdc-list-item__primary-text { font-weight: 400 !important; color: inherit !important; } .mat-mdc-list-item .p3xr-settings-label { font-weight: 500 !important; } // ============================================================================ // Hover — only on Redis settings accordion items, not the connections list // Buttons inside list items keep their own bg on hover // ============================================================================ .mat-mdc-list-item .mat-mdc-button-base { position: relative; z-index: 1; } // ============================================================================ // Color inheritance fixes for hybrid mode // ============================================================================ .mat-mdc-card { color: var(--mat-app-text-color, inherit); } mat-toolbar { color: var(--mat-toolbar-container-text-color, inherit); } mat-dialog-container { color: var(--mat-app-text-color, inherit); } // Dialog layout: surface → container → component-host → form → toolbar+content+actions // The surface constrains the overall height. The content area scrolls. // Every level in the chain must be flex column with min-height:0 so flex shrinking works. .cdk-overlay-pane.p3xr-dialog-panel { max-height: calc(100vh - 64px) !important; } .cdk-overlay-pane.p3xr-dialog-panel .mat-mdc-dialog-surface { border-radius: 4px !important; background-color: var(--p3xr-dialog-surface-bg, var(--p3xr-content-bg)) !important; box-shadow: 0 7px 8px -4px rgba(0, 0, 0, 0.2), 0 13px 19px 2px rgba(0, 0, 0, 0.14), 0 5px 24px 4px rgba(0, 0, 0, 0.12) !important; display: flex !important; flex-direction: column !important; max-height: inherit !important; height: 100% !important; overflow: hidden !important; } .cdk-overlay-pane.p3xr-dialog-panel .mat-mdc-dialog-container { min-width: 0 !important; max-height: inherit !important; height: 100% !important; display: flex !important; flex-direction: column !important; overflow: hidden !important; } .cdk-overlay-pane.p3xr-dialog-panel .mat-mdc-dialog-component-host { display: flex !important; flex: 1 1 auto; flex-direction: column !important; min-height: 0 !important; overflow: hidden !important; } .cdk-overlay-pane.p3xr-dialog-panel .mat-mdc-dialog-component-host > form { display: flex !important; flex: 1 1 auto; flex-direction: column !important; min-height: 0 !important; overflow: hidden !important; } // Toolbar stays fixed at top, actions at bottom, content scrolls in between .cdk-overlay-pane.p3xr-dialog-panel .p3xr-dialog-toolbar { flex: 0 0 auto; } .cdk-overlay-pane.p3xr-dialog-panel .mat-mdc-dialog-component-host > .p3xr-dialog-content, .cdk-overlay-pane.p3xr-dialog-panel .mat-mdc-dialog-component-host > form > .p3xr-dialog-content { flex: 1 1 auto; min-height: 0 !important; max-height: none !important; overflow-y: auto !important; overflow-x: hidden !important; } .cdk-overlay-pane.p3xr-dialog-panel .mat-mdc-dialog-component-host > .p3xr-dialog-content.p3xr-dialog-content-editor, .cdk-overlay-pane.p3xr-dialog-panel .mat-mdc-dialog-component-host > form > .p3xr-dialog-content.p3xr-dialog-content-editor { overflow: hidden !important; max-height: none !important; position: relative !important; } .cdk-overlay-pane.p3xr-dialog-panel .p3xr-dialog-actions { flex: 0 0 auto; } .cdk-overlay-pane.p3xr-dialog-panel .mat-mdc-dialog-component-host > .p3xr-dialog-actions, .cdk-overlay-pane.p3xr-dialog-panel .mat-mdc-dialog-component-host > form > .p3xr-dialog-actions { flex: 0 0 auto; } // Snackbar dismiss button: constrain hover to a square (width = height) like an icon button .mat-mdc-snack-bar-container .mat-mdc-snack-bar-action .mdc-button { min-width: 0 !important; width: 12px !important; height: 12px !important; padding: 0 !important; display: flex !important; align-items: center !important; justify-content: center !important; } .mat-mdc-snack-bar-container .mat-mdc-snack-bar-action .mdc-button .mdc-button__ripple, .mat-mdc-snack-bar-container .mat-mdc-snack-bar-action .mdc-button .mat-mdc-button-touch-target { width: 12px !important; height: 12px !important; border-radius: 50% !important; } .cdk-overlay-backdrop.p3xr-dialog-backdrop { background-color: rgba(0, 0, 0, 0.48) !important; } html.cdk-global-scrollblock { overflow: hidden !important; } html.cdk-global-scrollblock body { overflow: hidden !important; } // TODO: HACK: Angular CDK's BlockScrollStrategy adds the class cdk-global-scrollblock to the // html element (see @angular/cdk overlay.css: .cdk-global-scrollblock { position: fixed; ... }). // That makes the html element position:fixed, which in turn affects the body's containing block. // When html is position:fixed the CDK overlay pane (position:absolute inside the // position:fixed overlay container) loses its correct containing-block relationship and // dialog content visually overflows the surface boundary (the "clipping" bug visible // in tests/screenshots/dialog-no-clipping-scrolled.png). // Setting the body to position:static cancels that side-effect and the dialog surface // overflow:hidden clips correctly again. // The trade-off is that body.style.top = -scrollY set by CDK has no effect on a static // element, so the page scroll position is not preserved while the dialog is open — which // is acceptable for this app. // Remove this override once you understand the root cause and can fix it properly. html.cdk-global-scrollblock > body { position: static !important; overflow: hidden !important; } body.p3xr-no-animation .cdk-overlay-pane.p3xr-dialog-panel, body.p3xr-no-animation .cdk-overlay-pane.p3xr-dialog-panel *, body.p3xr-no-animation .cdk-overlay-backdrop.p3xr-dialog-backdrop, .cdk-overlay-pane.p3xr-dialog-no-animation, .cdk-overlay-pane.p3xr-dialog-no-animation *, .cdk-overlay-backdrop.p3xr-dialog-backdrop-no-animation { animation: none !important; animation-duration: 0ms !important; transition: none !important; transition-duration: 0ms !important; } .p3xr-dialog-toolbar { box-sizing: border-box; display: flex !important; align-items: center !important; height: 48px !important; min-height: 48px !important; max-height: 48px !important; padding: 0 8px !important; } .p3xr-dialog-toolbar .mat-mdc-button-base { margin: 0 !important; } .p3xr-dialog-toolbar [mat-dialog-title] { margin: 0 !important; padding: 0 !important; } .p3xr-dialog-title { display: flex !important; align-items: center !important; flex: 1 1 auto; height: 100%; line-height: 28px !important; min-width: 0; margin: 0 !important; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .p3xr-dialog-toolbar .p3xr-dialog-title, .p3xr-dialog-toolbar [mat-dialog-title], .p3xr-dialog-toolbar .mat-mdc-dialog-title, .p3xr-dialog-toolbar .mdc-dialog__title { color: inherit !important; } .p3xr-dialog-title-with-icon { display: inline-flex; align-items: center; gap: 8px; } .p3xr-dialog-title-with-icon .mat-icon { margin: 0 !important; } .p3xr-dialog-content { display: block; padding: 16px !important; background-color: var(--p3xr-content-bg) !important; color: var(--mat-app-text-color, inherit); } // Links inside dialog content must contrast with the content background .p3xr-dialog-content .p3xr-timestring-link, .p3xr-dialog-content .p3xr-timestring-link .mdc-button__label { color: var(--mat-app-text-color, inherit) !important; } // Hint text in dark themes: white with opacity for readability body.p3xr-theme-dark .mat-mdc-form-field-hint { color: rgba(255, 255, 255, 0.7) !important; } // Settings page hint text — theme-aware .p3xr-settings-hint { font-size: 12px; color: rgba(0, 0, 0, 0.54); } body.p3xr-theme-dark .p3xr-settings-hint { color: rgba(255, 255, 255, 0.7); } .p3xr-dialog-content-mono { font-family: 'Roboto Mono', monospace; } .p3xr-dialog-actions { align-items: center !important; box-sizing: border-box; justify-content: flex-end !important; gap: 8px; padding: 8px !important; background-color: var(--p3xr-accordion-bg) !important; } .p3xr-dialog-actions .mat-mdc-button-base:not(.mat-mdc-icon-button):not(.mat-mdc-fab-base), .p3xr-dialog-actions button.mat-mdc-button-base:not(.mat-mdc-icon-button):not(.mat-mdc-fab-base) { margin: 0 !important; min-width: 0 !important; padding-left: 10px !important; padding-right: 8px !important; letter-spacing: 0.01em !important; } .p3xr-dialog-actions .mat-mdc-button-base .mdc-button__label { display: inline !important; gap: 0 !important; letter-spacing: inherit !important; } .p3xr-dialog-actions .mat-mdc-button-base mat-icon, .p3xr-dialog-actions .mat-mdc-button-base .mat-icon, .p3xr-dialog-actions .mat-mdc-button-base i, .p3xr-dialog-actions .mat-mdc-button-base .mdc-button__label > mat-icon, .p3xr-dialog-actions .mat-mdc-button-base .mdc-button__label > .mat-icon, .p3xr-dialog-actions .mat-mdc-button-base .mdc-button__label > i { display: inline-flex !important; align-items: center !important; justify-content: center !important; margin-left: 0 !important; width: 24px !important; height: 24px !important; line-height: 24px !important; font-size: 24px !important; margin-right: 3px !important; } .cdk-overlay-pane.p3xr-connection-dialog-panel { width: 75vw !important; max-width: 75vw !important; max-height: calc(100vh - 64px) !important; } .p3xr-connection-dialog-panel .mdc-label, .p3xr-connection-dialog-panel fieldset legend, .p3xr-connection-dialog-panel .mdc-floating-label { font-weight: 700 !important; } .p3xr-connection-dialog-panel .mat-mdc-form-field { display: block; } .p3xr-connection-dialog-panel .p3xr-md-input-container-no-bottom .mat-mdc-form-field-subscript-wrapper { display: none !important; } .p3xr-connection-dialog-panel .p3xr-md-input-container-bottom-info { font-size: 12px; margin-top: 0; } .cdk-overlay-pane.p3xr-tree-settings-dialog-panel { width: 75vw !important; max-width: 75vw !important; max-height: calc(100vh - 64px) !important; --mat-form-field-container-height: 40px; --mat-form-field-container-vertical-padding: 8px; --mat-form-field-filled-with-label-container-padding-top: 16px; --mat-form-field-filled-with-label-container-padding-bottom: 4px; } .p3xr-connection-dialog-content, .p3xr-tree-settings-dialog-content { padding: 0 !important; background-color: var(--p3xr-content-bg) !important; overflow: auto !important; min-height: 0 !important; max-height: none !important; } .p3xr-tree-settings-dialog-content .p3xr-padding { padding-top: 26px; padding-bottom: 8px; } .p3xr-tree-settings-dialog-panel .mdc-label, .p3xr-tree-settings-dialog-panel fieldset legend, .p3xr-tree-settings-dialog-panel .mdc-floating-label { font-weight: 700 !important; } .p3xr-tree-settings-dialog-panel .mat-mdc-form-field { display: block; } .p3xr-tree-settings-dialog-panel .p3xr-md-input-container-no-bottom .mat-mdc-form-field-subscript-wrapper { display: none !important; } .p3xr-tree-settings-dialog-panel .p3xr-md-input-container-bottom-info { font-size: 12px; line-height: normal; margin-top: 0; } .p3xr-tree-settings-field-block { margin-bottom: 21px; } .p3xr-tree-settings-field-block-max-keys { margin-bottom: 16px; } .p3xr-tree-settings-toggle-block, .p3xr-tree-settings-reduced-functions { margin-bottom: 12px; } .p3xr-tree-settings-toggle-block { margin-bottom: 0; } .p3xr-tree-settings-toggle-block-keys-sort { margin-bottom: 18px; } .p3xr-tree-settings-toggle-block-search-client { margin-bottom: 19px; } .p3xr-tree-settings-reduced-functions-note, .p3xr-tree-settings-extra-info { margin-top: 8px; } .p3xr-tree-settings-message-error { color: var(--mat-sys-error, #f44336) !important; opacity: 1 !important; } .p3xr-tree-settings-toggle-block .mat-mdc-slide-toggle { display: block; margin: 0 !important; max-width: 100%; } .p3xr-tree-settings-toggle-block .mdc-form-field { display: inline-flex; align-items: center; max-width: 100%; white-space: normal !important; } .p3xr-tree-settings-toggle-block .mdc-label { white-space: normal !important; overflow-wrap: anywhere; } .p3xr-tree-settings-toggle-block-last { margin-bottom: 0; } .p3xr-connection-node-add, .p3xr-connection-node-actions { display: inline-flex; align-items: center; } .p3xr-connection-node-add .mat-mdc-mini-fab, .p3xr-connection-node-actions .mat-mdc-mini-fab { margin: 0 0 0 8px !important; } .p3xr-connection-node-actions .mat-mdc-mini-fab:first-child { margin-left: 0 !important; margin-right: 8px !important; } .p3xr-connection-inline-toggles { display: flex; flex-wrap: wrap; align-items: flex-start; column-gap: 16px; row-gap: 8px; } .p3xr-connection-inline-toggles .mat-mdc-slide-toggle { margin: 0 !important; max-width: 100%; } .p3xr-connection-inline-toggles .mdc-form-field { white-space: normal !important; } .p3xr-connection-inline-toggles .mdc-label { white-space: normal !important; overflow-wrap: anywhere; } .p3xr-connection-tls-toggles { margin-bottom: 12px; } .p3xr-connection-tls-fields { padding-top: 0; } .p3xr-connection-dialog-panel textarea.mat-mdc-input-element { min-height: 30px !important; resize: vertical; } @media (max-width: 959px) { .cdk-overlay-pane.fullscreen-dialog .mat-mdc-dialog-container, .cdk-overlay-pane.fullscreen-dialog .mat-mdc-dialog-surface { height: 100% !important; max-height: 100% !important; } .cdk-overlay-pane.fullscreen-dialog .mat-mdc-dialog-surface form { display: flex !important; flex-direction: column !important; min-height: 100% !important; height: 100% !important; } .cdk-overlay-pane.fullscreen-dialog .p3xr-dialog-content { flex: 1 1 auto; min-height: 0; overflow: auto !important; } .cdk-overlay-pane.fullscreen-dialog .p3xr-connection-dialog-content { max-height: none !important; } .cdk-overlay-pane.fullscreen-dialog .p3xr-dialog-actions { flex: 0 0 auto; margin-top: auto; } .cdk-overlay-pane.p3xr-connection-dialog-panel { width: 100vw !important; max-width: 100vw !important; height: 100vh !important; max-height: calc(100vh - 64px) !important; } .cdk-overlay-pane.p3xr-connection-dialog-panel .mat-mdc-dialog-surface { border-radius: 0 !important; } .cdk-overlay-pane.p3xr-tree-settings-dialog-panel { width: 100vw !important; max-width: 100vw !important; height: 100vh !important; max-height: calc(100vh - 64px) !important; } .cdk-overlay-pane.p3xr-tree-settings-dialog-panel .mat-mdc-dialog-surface { border-radius: 0 !important; } } p3xr-ng-settings .p3xr-settings-pair-row, p3xr-info .p3xr-settings-pair-row, p3xr-monitoring .p3xr-settings-pair-row, p3xr-memory-analysis .p3xr-settings-pair-row, p3xr-search .p3xr-settings-pair-row, p3xr-key-probabilistic .p3xr-settings-pair-row { display: flex; width: 100%; gap: 16px; } p3xr-ng-settings .p3xr-settings-pair-row, p3xr-info .p3xr-settings-pair-row, p3xr-monitoring .p3xr-settings-pair-row, p3xr-memory-analysis .p3xr-settings-pair-row, p3xr-search .p3xr-settings-pair-row, p3xr-key-probabilistic .p3xr-settings-pair-row { align-items: center; } p3xr-ng-settings .p3xr-settings-row-label, p3xr-info .p3xr-settings-row-label, p3xr-monitoring .p3xr-settings-row-label, p3xr-memory-analysis .p3xr-settings-row-label, p3xr-search .p3xr-settings-row-label, p3xr-key-probabilistic .p3xr-settings-row-label { flex: 1 1 auto; min-width: 0; font-weight: 700; } p3xr-ng-settings .p3xr-settings-row-value, p3xr-info .p3xr-settings-row-value, p3xr-monitoring .p3xr-settings-row-value, p3xr-memory-analysis .p3xr-settings-row-value, p3xr-search .p3xr-settings-row-value, p3xr-key-probabilistic .p3xr-settings-row-value { flex: 0 1 60%; min-width: 0; text-align: right; white-space: normal; overflow-wrap: anywhere; word-break: break-word; } p3xr-ng-settings .p3xr-settings-wrap-text { white-space: normal; overflow-wrap: anywhere; word-break: break-word; } // ============================================================================ // Prevent horizontal scrollbar from body during hybrid mode // ============================================================================ body { overflow-x: hidden; } // ============================================================================ // Neutralize Angular Material primary-tinted surface colors for ALL light themes. // M3 tints surfaces with the primary palette (e.g. violet → purple inputs/selects). // The AngularJS Common sub-theme uses accentPalette('grey') for input backgrounds, // so all Angular Material surfaces must be neutral grey/white, not tinted. // Must come AFTER all per-theme mat.all-component-colors() blocks to win the cascade. // ============================================================================ body.p3xr-theme-light { // Surfaces: pure white (no primary tint) --mat-select-panel-background-color: #ffffff; --mat-dialog-container-color: #ffffff; --mat-menu-container-color: #ffffff; --mat-autocomplete-background-color: #ffffff; --mat-datepicker-calendar-container-background-color: #ffffff; --mat-card-outlined-container-color: #ffffff; --mat-table-background-color: #ffffff; --mat-paginator-container-background-color: #ffffff; // Form fields: neutral grey (matching AngularJS Common accent=grey) --mdc-filled-text-field-container-color: #f5f5f5; --mat-form-field-filled-container-color: #f5f5f5; // Options / selections --mat-option-selected-state-layer-color: rgba(0, 0, 0, 0.12); --mat-option-label-text-color: rgba(0, 0, 0, 0.87); } // Redis overrides p3xr-theme-light background — must come after body.p3xr-theme-light to win cascade body.p3xr-mat-theme-redis { --mat-app-background-color: #ffebee; // red-50 (lightest red) } // Same neutralization for dark themes — M3 tints dark surfaces with primary palette too. // All dark themes use Common accentPalette('grey') except Matrix (light-green), // but ALL should use neutral grey for input backgrounds. body.p3xr-theme-dark { // Surfaces: neutral dark grey (no primary tint) --mat-select-panel-background-color: #424242; --mat-dialog-container-color: #424242; --mat-menu-container-color: #424242; --mat-autocomplete-background-color: #424242; --mat-datepicker-calendar-container-background-color: #424242; --mat-card-outlined-container-color: #424242; --mat-table-background-color: #303030; --mat-paginator-container-background-color: #303030; // Form fields: neutral dark grey (matching AngularJS Common accent=grey) --mdc-filled-text-field-container-color: #404040; --mat-form-field-filled-container-color: #404040; // Options / selections --mat-option-selected-state-layer-color: rgba(255, 255, 255, 0.12); --mat-option-label-text-color: rgba(255, 255, 255, 0.87); } // ============================================================================ // Force AngularJS input/dialog backgrounds to neutral (matching Common accentPalette='grey'). // AngularJS Material's runtime $mdTheming CSS tints surfaces with the primary palette. // These overrides use high specificity to beat the runtime-generated theme CSS. // ============================================================================ body md-dialog, body md-dialog md-dialog-content, body md-dialog md-dialog-content md-content, body [md-theme] md-dialog, body [md-theme] md-dialog md-dialog-content { background-color: var(--p3xr-dialog-surface-bg) !important; } // All form controls: inputs, textareas, selects, switches, checkboxes body md-input-container, body md-input-container .md-input, body md-input-container input, body md-input-container textarea, body md-select, body md-select md-select-value, body md-switch, body md-checkbox, body md-radio-button, body [md-theme] md-input-container, body [md-theme] md-input-container .md-input, body [md-theme] md-input-container input, body [md-theme] md-input-container textarea, body [md-theme] md-select, body [md-theme] md-select md-select-value, body [md-theme] md-switch, body [md-theme] md-checkbox, body [md-theme] md-radio-button { background-color: transparent !important; } // Select dropdown panel and menu content body md-select-menu, body md-select-menu md-content, body md-option, body [md-theme] md-select-menu, body [md-theme] md-select-menu md-content { background-color: var(--p3xr-dialog-surface-bg) !important; } // Fieldset backgrounds inside dialogs (SSH section etc.) body md-dialog fieldset, body [md-theme] md-dialog fieldset { background-color: transparent !important; } // Tree node tooltip: shift 36px right (node label + action buttons) .p3xr-tree-node-tooltip { margin-left: 36px !important; } // Format toggle divider — override Material's --mat-button-toggle-divider-color // Must be at end of file so it comes AFTER mat.all-component-colors() output .p3xr-format-toggle { --mat-button-toggle-divider-color: var(--p3xr-content-border-color); } src/overlay/000077500000000000000000000000001517644030100133275ustar00rootroot00000000000000src/overlay/overlay.scss000066400000000000000000000014611517644030100157070ustar00rootroot00000000000000#p3xr-overlay { font-size: 125%; // Move this declaration above the nested rule position: fixed; left: 0; top: 0; width: 100vw; height: 100vh; text-align: center; /*Flexbox*/ display: flex; flex-direction: column; align-items: center; align-content: center; justify-content: center; background-color: rgba(0, 0, 0, 0.9); z-index: 99999; color: rgba(128, 128, 128, 0.5); i { font-size: 400% !important; } #p3xr-overlay-info { } } body.p3xr-overlay-visible { .cdk-overlay-container, .cdk-global-overlay-wrapper, .cdk-overlay-pane, .cdk-overlay-backdrop { z-index: 1 !important; opacity: 0 !important; visibility: hidden !important; pointer-events: none !important; } } src/public/000077500000000000000000000000001517644030100131245ustar00rootroot00000000000000src/public/images/000077500000000000000000000000001517644030100143715ustar00rootroot00000000000000src/public/images/256x256.png000066400000000000000000000303271517644030100160450ustar00rootroot00000000000000PNG  IHDR\rfbKGD pHYs B(xtIME  ) Fm IDATxy]UsoURLd$-eAYQPA {m6QAVhʠЂaTBJԜJ%d$u|Pg^{bX,bX,bX,bX,bX,bX,bX,bX,bX,bX,@!7|4S ZVb-k,!EʜR}Zrp0}i&ZskZ`1[z#}6S$4F=ֈmk,&,}R .W)ϊ)9a X/;r눏ke>v#~__Kqz:}@_aZR$8WrOKɑˀO)O#).h / /n @xܒӵr>"`J ^xXڗ, vq&_x ՝՚"yzcDO\P.G.p%܎n k#}+NCb @$\?.[\ > Q9 5⁄=}-v8Xl+!gG䐐 uhEݣ=oci^49rv/}DE]OtOOU~]vY`nыRWjU@].WVq wJ)={G@odc#C)p׉clEe4snߧ:;zq:~mr>L*^Ez}?C/ҹ}kfg59'SWS  }qgcoTx<4:}xEzE T~gEٲcAg0:^X UYTk&>u%1&?0=΍'Ϡ޵[hL\ORwfKvZpHeE8F=pO~J#;֭wgϜI_,c<⥳?ZO|yss?uԬ/0Y+%߹s?/t>0ZJ?x&%Oz|+a#^^T \bjtOьi͏='m $`B](?*NZNDU*5xU8ɢPcż@Pg=1VqN%9fIԌudM jbenWqohCbČŔ)*_{UL{I̙]>"ETW'.Rw3ھՈ$!9frUh>CsB#XУ8Ƀk) Tw7w"Hkk\[yoS0{4̉xeW>;]IzёP p_ j^ ,)ץRuL=zM VDdx&׼JsP)3<$64 o}HM.n`o^xۅ ׹礗^_s`ZL2TvUflܹKsG,lIygov~2[>3n,080/dqI/Da jQG͒F\R:.u\5I5;z1-9|N*a?ڿ5Rӧ4$dwRl9! zV-hoabW^}K?Sb-%]?ƖN3Gaك$u2% e3uYT(AZό5|ӮSh Cj"qd\T shA}RtI͈οN)gDGf[ʧy8ET A8Bhi벸.oH'Tj:!FptIKF:tVY?@ٲb!P|B蕚)ɦ׀o5 c8aЧɷWV>gGAM+^R"_kZS8GhX+)e~j!us aH /TIQ^yto/XM(-%=4G0|[m~w0TM)Ɉet:;ݿgOA+Xpp戂ExM唲..㔲gZv.~{csbO0zB0UTЗIJ+A!UӒg< i*jz*ִ|Pu__FRz/O@+v1;1;(AXִδmXԤ/9#艂w^7܄;5ŔPQy~xOl$UPAа+bn$IYxd@IW[ 90fLusn( uu̾v5瞇ppyi^ykjC@ۧ*̷Iz^_њVLlm! 7ʩ%sOQ{GqJ@HlcZ,ROt2(u%XZuB7V]#փڻKbxeeTvէPSKDO߆6ʟ/7x_f$,,Nnݗ|ͺ%!1R9RwL}Qv1Ϛ]I3ګ>{zlr͘A{)63\E%I%( 15+-9shN7Gu4%GA{pw}R*1:CF*ƙ g<0J+jnMLs{N(G6ol Y@%~EE8EES?8Nû}mkeTWmgO#{BDӐpHU `IWfS:{=bpϠd\Υ9 '(W+5ZS*\)!+:rm a+JŠT9:ϡslk߈ᕕQ4s3g1e|Jͧx,J).[yo9KA#NkM%ҟ:4[Rm)IP98'q.gR2Sͣh,g8prRl(xSi}E0雵:}E(su J{<n~lѽ AW HD! $z.uCZܟ]`s79sޛ7ځ^k6Qy95%miI rf|n>o~h)iWuh2#:(<5^n_+:"=;7&Z3s,[.Ck27gHiRDZQ .;&ˎukv=vz`+s ~c֚v?Ht(s3^M`P-_ jǨt.y, xWi@jXHj3)n,xMdηocyiI"TB uCD"q'RTqfh5 _y?ا\6GԂ:j( ]F=<W@ C*Ǜn\q{Yp=(uԂ"4L[CQ G(+ZZޥK'nbo !(D1hX2g.~/2%'cMo#5vfK=i 7ذ1n[@[;zWP98S4\v9S.?GtWq͸v| Ƹ͝[/[{ 2Q*O.]r$Fx~'K>?0nנ\S$DgxkÆ73>gmMM!kĀlа8 5ԝw>~ƃ.57B&犩1hy3U9Ӟ}k @o/Z>2KE!h8T/i'ޓ&~=n~%\3.t+Ozy!1Ct4CŠas;/oڴ[ C%*=ƃ+8[<َ\5_-Ul2Bx?(CgWt]B)Ј{wT{. Џ)໤=4u' -q!{h:*/Fx _t GηWa?2yܗ'Y؋ pҋ&ĉ_y`b‰\а98J=b8g:nZ۟{(g'HM˼5  X}:*]A ˖ތ+pHl]:T:AU4+y~ fO@mV>J͐ ^8vO'mӰ]*cⶍ?E(hO7_*|P eAxa|XKWpj}DEIRr_KPyjJKdo4#5=ҧԻUc$fHi A+K8s7i.UNr5NWF(;x7!ug:%7 (+"*WPzPANA 3Z `Z檧܍%0Ed3 ~UHfJҿ+)EyqaKAie8ш<5Y/@\D}H toMc/5Su kͦdAW+^AQNR-iIk:םI܉1/DQ [R>}<֏J%{A+(w=fȩx]宠s$]MTeQWt~ ֠T>dXWG0+276ACq?^T9F:-瀛S3DiE̽=ƛ)ITNF\W61 (Jי3] ?jv*Y uC0@GSzd铚aaF¼c0{{+W$4%]7¥jC7{sC|EK84&\"q *ڮ8473wP۩jM[:iEАp(6,tG, A倔9*Q%RGZq'!s,5 EK3 GPySҚA*k;Id~P&RQ+11=AC+%TסT@#.JkFtpT#,u؞C[ءm N`J,|ienIT 7!P>n>S{_9Fr}A/s1)KZ%%jf^B@cw0E]E}dkk p+DuR3.s JGACCa{ 셈U,uyPiGȪcdE=Ӱ˗V2. kY&5[r^Кe~acaJx$Pxi:N#M8TffW4ɴV{{U~4h<*HbxE(3g+^P I*7P-14EjɈI|95 @o75-.>F SJ!3l!wPWZi|mXIDATLdyhIuNg %1Airj2ȱ{?uB@{hg~!?|"%G.nNιCqI`1=0K r Dƽ d]RG^7=$)Ÿ#_аgreltw~蕊>*1A+Y lB^$>۟²#/B| &eӒeJjQA?*Y3' "4u(6toFc4Feh:N>|AJ#% Eа vAUW@9)y;3ހ:OYuϡGi;bP)L[S!\#+,'i^ȼa F7vۚmd51S-iOД'O_*|P tM k-iŞҰ1峨+u߇;#0aVӰD+SWv&6En@i>Y737RZv \l8dy kx+%ْTj ؞ SbGfk:c*0 5߇ofXFIiݾzw=ĝU+:jhOˠ^g?隶%R1U\ze%LN϶lĔQ7 Ial^ v(#>`fpǂ|>|qhx'#y^9~XnviL(LL3#-![: 5O0jsAhHZU8FO\tN03Ҵfjog"fΦ2h5}R'U{ߙ|}.23)M$v|)nテ3K**A)3E$.ST}6Kh):4jI`z. r4&h8*=2GLNPk2O5QvtinSkݦupkОdL1h( jpE U JqPiJ34R3 #Ieɴ M:?GIٸ^ 2*]32hiw]JAIFkQך"Za6(m15Jt}PiG̨1d|а_j &28/JOac?ִW yrvtAV!̪VΘzQ2 ㊩}!MiZ؝ցv}(͜ Oca\U{ooZӒw(dsj=3$iE3I;&O w)٪$ ٢sq4iIAY,)mJ]|\˓ÄC mZ @zSi#w+Onlа0ԮM4Ye|Eyx`JNA=hHŚFޔ}ʦ%&ߕnq#[{`XOloPKk9w4 t%R3j䘂W4-BX0NP&w5('+qG%u/OaJk}E`xބ|7{@;5X.19h 6h}Ax \Ewf_ʏ]iS {vMM< /7W@"`D+.APR4Da:D9+|t&[ohgDKqcҥStQ{۬IA]K6P2[PU96Gb[(xmZ_b'9qܹN2K{: К^F4z.\j;|#So\[#N1ǔ&FFnpeGZWPp)6La{DHw/vj[}iӦ--KV9 XD4ܙikXd X]z#Bܫ7[v4Ã*P]AM5/_ZQ_T35~iV epY`Avݫ&`f +׶>RbS2YR5N`ZaV('&Z -OWT? K&+||L *Mg ʫv י i[HxJ(--O&7+ʦS3^05h8WJuxH(u94"[/"-CY{xP*74(7)2u?'j2aZC Vw .Ŏ2WpQ޶NK7mНXΤjr-ħZ`Q\ ǓAq|'dޘIAۄhi/W_t.2ps+55K2,:cRBsu/rkS<W k*.ԠhL8d h!~m_\B>H^s' Vlа.ӥ-l=78gp1)Ժ qJC[ZWw{m-BSͶ3MpՁIg< !VN_K 3zEʊb4,PrCx<֚[:/kaxBŋG뀏8zQ]H;ՏED +_z#ACTϥ5 *Mk:5*)+ϯ^ %q :As R-H xL*u --/Dqs =q k@kz2;;}ߡ } ۢ]FU kǹ82ܷFѰWtT=BnnnNxI4,wr:Z'5R1(#/++Wl[bDq :Ja8bBAj͐ HMqHBHߺnw!IHk0KBW &M Ou'1&),Xuk+2,q@ bu?c# ˸Lkct$O\~z;8 #>[n9:z ogG jg a-ʱ0njs4 CRUж ܾxqgiB{k<ӗd.#RX7ћ}ַ#b @ >9bÆWpX`͝[$Wj8ŽȤ)Ί.;ͧUmvt;͋ⲲR{o @\K,R~ 2E9DqPn#Ol5 Np"ķ]6[X04w+n5b|PjՊ օ up9PKk!aXÝMI$D뀹!3)?߸iSXxжhѹhW4$Nߺaݺ65/^k} ? j!Mxׯmnn_$Ҟwz0ݠGRU5j"8 GQڅ ~;šXru>o*p{o"q_gX`1[_|~ #n @>}hi釵1@PI9<[cp,Ccb{W src/react/000077500000000000000000000000001517644030100127445ustar00rootroot00000000000000src/react/App.tsx000066400000000000000000000071421517644030100142300ustar00rootroot00000000000000import { useMemo, useEffect, lazy, Suspense } from 'react' import { ThemeProvider, CssBaseline } from '@mui/material' import { BrowserRouter, Routes, Route, Navigate, useNavigate } from 'react-router-dom' import { useThemeStore } from './stores/theme.store' import { useI18nStore } from './stores/i18n.store' import { setNavigate } from './stores/navigation.store' import { themes } from './themes' import Layout from './layout/Layout' import ConfirmDialog from './components/ConfirmDialog' import PromptDialog from './components/PromptDialog' import Toast from './components/Toast' import Overlay from './components/Overlay' import AskAuthorizationDialog from './dialogs/AskAuthorizationDialog' import CommandPaletteDialog from './dialogs/CommandPaletteDialog' const SettingsPage = lazy(() => import('./pages/settings/SettingsPage')) const InfoPage = lazy(() => import('./pages/info/InfoPage')) const DatabasePage = lazy(() => import('./pages/database/DatabasePage')) const StatisticsPage = lazy(() => import('./pages/database/StatisticsPage')) const DatabaseKeyPage = lazy(() => import('./pages/database/DatabaseKeyPage')) const SearchPage = lazy(() => import('./pages/search/SearchPage')) const MonitoringShell = lazy(() => import('./pages/monitoring/MonitoringShell')) const PulsePage = lazy(() => import('./pages/monitoring/PulsePage')) const ProfilerPage = lazy(() => import('./pages/monitoring/ProfilerPage')) const PubSubPage = lazy(() => import('./pages/monitoring/PubSubPage')) const MemoryAnalysisPage = lazy(() => import('./pages/monitoring/MemoryAnalysisPage')) function NavigationBridge() { const navigate = useNavigate() useEffect(() => { setNavigate(navigate) }, [navigate]) return null } function App() { const themeKey = useThemeStore(s => s.themeKey) const i18nReady = useI18nStore(s => s.ready) const theme = useMemo(() => themes[themeKey] || themes.enterprise , [themeKey]) if (!i18nReady) return null return ( }> } /> } /> } /> }> } /> } /> } /> }> } /> } /> } /> } /> ) } export default App src/react/components/000077500000000000000000000000001517644030100151315ustar00rootroot00000000000000src/react/components/ConfirmDialog.tsx000066400000000000000000000033641517644030100204140ustar00rootroot00000000000000import { Button, Tooltip, useMediaQuery } from '@mui/material' import { Done, Cancel } from '@mui/icons-material' import { useI18nStore } from '../stores/i18n.store' import { useCommonStore } from '../stores/common.store' import P3xrDialog from './P3xrDialog' export default function ConfirmDialog() { const strings = useI18nStore(s => s.strings) const { confirmOpen, confirmOptions, resolveConfirm } = useCommonStore() const isWide = useMediaQuery('(min-width: 600px)') if (!confirmOpen || !confirmOptions) return null const isAlert = confirmOptions.disableCancel === true const okLabel = isAlert ? strings?.intention?.ok : strings?.intention?.sure const cancelLabel = strings?.intention?.cancel return ( resolveConfirm?.(false)} title={confirmOptions.title} width="600px" actions={ <> {!isAlert && ( )} } >
) } src/react/components/Overlay.tsx000066400000000000000000000032521517644030100173140ustar00rootroot00000000000000import { Box, Typography } from '@mui/material' import { useOverlayStore } from '../stores/overlay.store' import { useAuthStore } from '../stores/auth.store' /** * Full-screen loading overlay — exact port of Angular OverlayService. * * Angular: #p3xr-overlay { font-size: 125%; ... } * i { font-size: 400% } overridden by inline style="font-size: 500%" * global: .fa { transform: scale(1.5); margin: 0 5px; } */ export default function Overlay() { const { visible, message } = useOverlayStore() const { authRequired, isAuthenticated } = useAuthStore() // Don't show overlay when login page is displayed if (!visible || (authRequired && !isAuthenticated)) return null return ( {message && ( <>

{message} )}
) } src/react/components/P3xrAccordion.tsx000066400000000000000000000076411517644030100203570ustar00rootroot00000000000000import { useState, useEffect, ReactNode } from 'react' import { Toolbar, IconButton, Tooltip, Box, useTheme } from '@mui/material' import { KeyboardArrowUp, KeyboardArrowDown } from '@mui/icons-material' import { useI18nStore } from '../stores/i18n.store' let counter = 0 interface P3xrAccordionProps { title: string accordionKey?: string collapsible?: boolean actions?: ReactNode children: ReactNode } export default function P3xrAccordion({ title, accordionKey, collapsible = true, actions, children, }: P3xrAccordionProps) { const strings = useI18nStore(s => s.strings) const theme = useTheme() const [key] = useState(() => accordionKey || String(++counter)) const storageKey = `p3xr-accordion-extended-${key}` const [extended, setExtended] = useState(() => { if (!collapsible) return true try { const v = localStorage.getItem(storageKey) return v === null ? true : v === 'true' } catch { return true } }) useEffect(() => { if (!collapsible) { setExtended(true) return } try { localStorage.setItem(storageKey, String(extended)) } catch {} }, [extended, storageKey, collapsible]) const toggle = () => setExtended(prev => !prev) return ( {/* Toolbar */} {/* Title */} {title} {/* Action buttons slot */} {actions && ( {actions} )} {/* Collapse toggle */} {collapsible && ( {extended ? : } )} {/* Content — hidden via CSS, not unmounted, to preserve uPlot chart DOM */} {children} ) } src/react/components/P3xrButton.tsx000066400000000000000000000045641517644030100177320ustar00rootroot00000000000000import { ReactNode } from 'react' import { Button, IconButton, Fab, Tooltip, useMediaQuery } from '@mui/material' /** * Responsive button — shows icon+text on wide screens, icon-only+tooltip on narrow. * Matches Angular p3xr-ng-button (720px breakpoint). * * raised=false: text button (Button variant="text") / icon button * raised=true: contained button (Button variant="contained") / mini fab style */ interface P3xrButtonProps { label: string icon?: ReactNode raised?: boolean color?: 'inherit' | 'primary' | 'secondary' | 'error' | 'success' | 'warning' | 'info' disabled?: boolean tooltipPlacement?: 'top' | 'bottom' | 'left' | 'right' breakpoint?: number onClick?: (e: React.MouseEvent) => void } export default function P3xrButton({ label, icon, raised = false, color = 'inherit', disabled = false, tooltipPlacement = 'top', breakpoint = 720, onClick, }: P3xrButtonProps) { const isWide = useMediaQuery(`(min-width: ${breakpoint}px)`) if (isWide) { return ( ) } if (raised) { return ( {icon} ) } return ( {icon} ) } src/react/components/P3xrDialog.tsx000066400000000000000000000106471517644030100176550ustar00rootroot00000000000000import { ReactNode } from 'react' import { Dialog, DialogContent, DialogActions, AppBar, Toolbar, IconButton, Box, useMediaQuery, } from '@mui/material' import { Close } from '@mui/icons-material' import { useTheme } from '@mui/material' import { useThemeStore } from '../stores/theme.store' /** * Shared dialog helper — matches Angular p3xr-dialog-toolbar / p3xr-dialog-content / p3xr-dialog-actions exactly. * * Header: strongBg, 48px, padding 0 8px * Content: contentBg (background.paper), padding 16px, scrollable * Footer: accordionBg, padding 8px, gap 8px, right-aligned (Matrix: #0a2e0d) */ interface P3xrDialogProps { open: boolean onClose: () => void title: ReactNode children: ReactNode actions?: ReactNode headerActions?: ReactNode fullScreenOnMobile?: boolean width?: string maxWidth?: string | false scroll?: 'paper' | 'body' contentPadding?: boolean height?: string } export default function P3xrDialog({ open, onClose, title, children, actions, headerActions, contentPadding = true, height, fullScreenOnMobile = true, width = '75vw', maxWidth, scroll = 'paper', }: P3xrDialogProps) { const muiTheme = useTheme() const themeKey = useThemeStore(s => s.themeKey) const isSmall = useMediaQuery('(max-width: 599px)') const fullScreen = fullScreenOnMobile && isSmall // Matrix theme: dialog footer uses dark green instead of bright green accordionBg const footerBg = themeKey === 'matrix' ? '#0a2e0d' : muiTheme.p3xr.accordionBg return ( {/* Header — strongBg, 48px, matches .p3xr-dialog-toolbar */} {title} {headerActions} {/* Content — contentBg, padding 16px, matches .p3xr-dialog-content */} {children} {/* Footer — accordionBg, matches .p3xr-dialog-actions */} {actions && ( {actions} )} ) } src/react/components/PromptDialog.tsx000066400000000000000000000042271517644030100202770ustar00rootroot00000000000000import { useState, useEffect } from 'react' import { Button, Tooltip, TextField, useMediaQuery } from '@mui/material' import { Done, Cancel } from '@mui/icons-material' import { useCommonStore } from '../stores/common.store' import P3xrDialog from './P3xrDialog' export default function PromptDialog() { const { promptOpen, promptOptions, resolvePrompt } = useCommonStore() const isWide = useMediaQuery('(min-width: 600px)') const [value, setValue] = useState('') useEffect(() => { if (promptOpen && promptOptions) { setValue(promptOptions.initialValue ?? '') } }, [promptOpen, promptOptions]) if (!promptOpen || !promptOptions) return null const handleOk = () => { if (!value.trim()) return resolvePrompt?.(value) } return ( resolvePrompt?.(null)} title={promptOptions.title} width="600px" actions={ <> } > setValue(e.target.value)} onKeyDown={e => e.key === 'Enter' && handleOk()} /> ) } src/react/components/Toast.tsx000066400000000000000000000022631517644030100167660ustar00rootroot00000000000000import { Snackbar, IconButton, Button } from '@mui/material' import { Close } from '@mui/icons-material' import { useCommonStore } from '../stores/common.store' export default function Toast() { const { toastOpen, toastMessage, toastDuration, closeToast, toastUndoAction, handleToastUndoClick } = useCommonStore() return ( {toastUndoAction && ( )} } sx={{ '& .MuiSnackbarContent-root': { flexWrap: 'nowrap', }, }} /> ) } src/react/dialogs/000077500000000000000000000000001517644030100143665ustar00rootroot00000000000000src/react/dialogs/AclUserDialog.tsx000066400000000000000000000250321517644030100176060ustar00rootroot00000000000000import { useState, useEffect } from 'react' import { Button, TextField, Switch, Checkbox, FormControlLabel, useMediaQuery, Tooltip, Box, Chip, Autocomplete, useTheme, Alert } from '@mui/material' import { Done, Cancel } from '@mui/icons-material' import { useI18nStore } from '../stores/i18n.store' import { useCommonStore } from '../stores/common.store' import P3xrDialog from '../components/P3xrDialog' interface AclUserDialogProps { open: boolean onClose: (result?: { username: string; rules: string[] }) => void username?: string rules?: string isNew: boolean } function parseRules(rules: string) { const tokens = rules.trim().split(/\s+/).filter(Boolean) let enabled = true, nopass = false const cmds: string[] = [], keys: string[] = [], channels: string[] = [] for (const t of tokens) { if (t === 'on') enabled = true else if (t === 'off') enabled = false else if (t === 'nopass') nopass = true else if (t.startsWith('>') || t.startsWith('<') || t.startsWith('#') || t === 'resetpass' || t === 'sanitize-payload' || t === 'skip-sanitize-payload') continue else if (t.startsWith('+') || t.startsWith('-') || t === 'allcommands' || t === 'nocommands') cmds.push(t) else if (t.startsWith('~') || t.startsWith('%') || t === 'allkeys' || t === 'resetkeys') keys.push(t) else if (t.startsWith('&') || t === 'allchannels' || t === 'resetchannels') channels.push(t) } return { enabled, nopass, cmds, keys, channels } } function chipColor(rule: string): 'primary' | 'error' { return rule.startsWith('-') ? 'error' : 'primary' } interface AclOption { label: string; groupKey: string } const CMD_DEFS: AclOption[] = [ '+@all', '-@all', '+@read', '-@read', '+@write', '-@write', '+@admin', '-@admin', '+@dangerous', '-@dangerous', ].map(label => ({ label, groupKey: 'groupCommon' })).concat([ '+@string', '+@hash', '+@list', '+@set', '+@sortedset', '+@stream', '+@geo', '+@bitmap', '+@hyperloglog', ].map(label => ({ label, groupKey: 'groupDataTypes' }))).concat([ '+@keyspace', '+@pubsub', '+@connection', '+@transaction', '+@scripting', '+@fast', '+@slow', '+@blocking', ].map(label => ({ label, groupKey: 'groupOperations' }))) const KEY_OPTIONS = ['~*', '%R~*', '%W~*', 'resetkeys'] const CHANNEL_OPTIONS = ['&*', 'resetchannels'] export default function AclUserDialog({ open, onClose, username: initUsername = '', rules: initRules = '', isNew }: AclUserDialogProps) { const strings = useI18nStore(s => s.strings) const confirm = useCommonStore(s => s.confirm) const isWide = useMediaQuery('(min-width: 600px)') const muiTheme = useTheme() const [username, setUsername] = useState('') const [enabled, setEnabled] = useState(true) const [nopass, setNopass] = useState(false) const [password, setPassword] = useState('') const [commandsList, setCommandsList] = useState([]) const [keysList, setKeysList] = useState([]) const [channelsList, setChannelsList] = useState([]) useEffect(() => { if (open) { setUsername(initUsername) setPassword('') const parsed = parseRules(initRules) setEnabled(parsed.enabled) setNopass(parsed.nopass) setCommandsList(parsed.cmds) setKeysList(parsed.keys) setChannelsList(parsed.channels) } }, [open, initUsername, initRules]) if (!open) return null const handleSave = async () => { const u = username.trim() if (!u) return try { await confirm({ message: strings?.intention?.areYouSure }) } catch { return } const rules: string[] = [enabled ? 'on' : 'off'] if (!isNew) { // Reset permissions first so removals take effect rules.push('nocommands', 'resetkeys', 'resetchannels') if (nopass) rules.push('resetpass', 'nopass') else if (password.trim()) rules.push('resetpass', '>' + password.trim()) } else { if (nopass) rules.push('nopass') else if (password.trim()) rules.push('>' + password.trim()) } rules.push(...commandsList, ...keysList, ...channelsList) onClose({ username: u, rules }) } const handleCancel = () => onClose() const title = isNew ? strings?.page?.acl?.createUser : strings?.page?.acl?.editUser const primaryBg = muiTheme.palette.primary.main const primaryFg = muiTheme.palette.primary.contrastText const warnBg = muiTheme.palette.warning.main const warnFg = muiTheme.palette.warning.contrastText const chipInput = (label: string, hint: string, placeholder: string, value: string[], onChange: (v: string[]) => void, options: string[]) => ( !value.includes(o))} value={value} onChange={(_, newValue) => onChange(newValue as string[])} renderValue={(val, getItemProps) => (val as string[]).map((option, index) => { const { key, ...rest } = getItemProps({ index }) return }) } renderInput={(params) => ( )} /> ) return ( {isWide ? ( ) : ( )} {isWide ? ( ) : ( )} } > setUsername(e.target.value)} disabled={!isNew} /> {username === 'default' && ( {strings?.page?.acl?.defaultUserWarning} )} setEnabled(v)} />} label={strings?.page?.acl?.enabled} /> setNopass(v)} />} label={strings?.page?.acl?.noPassword} /> {!nopass && ( setPassword(e.target.value)} helperText={!isNew ? strings?.page?.acl?.passwordHint : undefined} /> )} !commandsList.includes(o.label))} groupBy={(o) => typeof o === 'string' ? '' : (strings?.page?.acl as any)?.[o.groupKey] || o.groupKey} getOptionLabel={(o) => typeof o === 'string' ? o : o.label} value={commandsList} onChange={(_, v) => setCommandsList(v.map((x: any) => typeof x === 'string' ? x : x.label))} renderValue={(val, getItemProps) => (val as string[]).map((option, index) => { const { key, ...rest } = getItemProps({ index }) const s = String(option) const deny = s.charAt(0) === '-' return }) } renderInput={(params) => ( )} /> {chipInput( strings?.page?.acl?.keys, strings?.page?.acl?.keysHint, '~*, ~user:* ...', keysList, setKeysList, KEY_OPTIONS )} {chipInput( strings?.page?.acl?.channels, strings?.page?.acl?.channelsHint, '&*, ¬ifications:* ...', channelsList, setChannelsList, CHANNEL_OPTIONS )} ) } src/react/dialogs/AiCheatsheetDialog.tsx000066400000000000000000000173631517644030100206070ustar00rootroot00000000000000import { useMemo, useState } from 'react' import { Box, TextField, Button, InputAdornment } from '@mui/material' import { Search, Close, MenuBook } from '@mui/icons-material' import { useI18nStore } from '../stores/i18n.store' import { useRedisStateStore } from '../stores/redis-state.store' import P3xrDialog from '../components/P3xrDialog' interface AiCheatsheetDialogProps { open: boolean onClose: () => void onPick: (prompt: string) => void } interface CheatGroup { key: string name: string description?: string prompts: string[] } export default function AiCheatsheetDialog({ open, onClose, onPick }: AiCheatsheetDialogProps) { const strings = useI18nStore(s => s.strings) const modules = useRedisStateStore(s => s.modules) || [] const info = useRedisStateStore(s => s.info) const [filter, setFilter] = useState('') const moduleNames = useMemo(() => modules.map((m: any) => (m?.name || '').toLowerCase()), [modules]) const version = useMemo(() => { const v = info?.server?.redis_version || '' const match = /^(\d+)/.exec(v) return match ? parseInt(match[1], 10) : 0 }, [info]) const isCluster = info?.server?.redis_mode === 'cluster' const visibleGroups = useMemo(() => { const cs = strings?.label?.cheatsheet?.groups if (!cs) return [] const result: CheatGroup[] = [] const push = (key: string, g: any) => { if (!g || !Array.isArray(g.prompts) || g.prompts.length === 0) return result.push({ key, name: g.name, description: g.description, prompts: g.prompts }) } push('diagnostics', cs.diagnostics) push('keys', cs.keys) push('dataTypes', cs.dataTypes) if (moduleNames.includes('rejson') || moduleNames.includes('rejson-rl') || moduleNames.includes('json')) push('json', cs.json) if (moduleNames.includes('search') || moduleNames.includes('searchlight')) push('search', cs.search) if (moduleNames.includes('timeseries')) push('timeseries', cs.timeseries) if (moduleNames.includes('bf')) push('bloom', cs.bloom) if (version >= 8) { push('vectorSet', cs.vectorSet) push('redis8', cs.redis8) } push('scripting', cs.scripting) if (isCluster) push('cluster', cs.cluster) if (version >= 6) push('acl', cs.acl) push('qna', cs.qna) push('translate', cs.translate) return result }, [strings, moduleNames, version, isCluster]) const filterPrompts = (prompts: string[]) => { const q = filter.trim().toLowerCase() if (!q) return prompts return prompts.filter(p => p.toLowerCase().includes(q)) } const emptyResults = visibleGroups.every(g => filterPrompts(g.prompts).length === 0) const cs = strings?.label?.cheatsheet if (!open) return null return ( }> {/* Sticky header — P3xrDialog has contentPadding={false} so we own all internal padding. Sticky sits at the true top of the scroll container with its own consistent padding all around. */} {cs?.subtitle && ( {cs.subtitle} )} {cs?.footerHint && ( {cs.footerHint} )} setFilter(e.target.value)} onKeyDown={e => e.stopPropagation()} sx={{ '& .MuiFormHelperText-root': { display: 'none' }, '& .MuiFilledInput-root': { mb: 0 }, }} InputProps={{ startAdornment: ( ), }} /> {visibleGroups.map(g => { const prompts = filterPrompts(g.prompts) if (prompts.length === 0) return null return ( {g.name} {g.description && ( {g.description} )} {prompts.map((p, i) => ( ))} ) })} {emptyResults && ( {cs?.empty} )} ) } src/react/dialogs/AiSettingsDialog.tsx000066400000000000000000000074641517644030100203330ustar00rootroot00000000000000import { useState, useEffect } from 'react' import { TextField, Button, Tooltip, Box, useMediaQuery } from '@mui/material' import { Done, Cancel } from '@mui/icons-material' import { useI18nStore } from '../stores/i18n.store' import { useRedisStateStore } from '../stores/redis-state.store' import { useCommonStore } from '../stores/common.store' import { useOverlayStore } from '../stores/overlay.store' import { request } from '../stores/socket.service' import P3xrDialog from '../components/P3xrDialog' interface AiSettingsDialogProps { open: boolean onClose: () => void } export default function AiSettingsDialog({ open, onClose }: AiSettingsDialogProps) { const strings = useI18nStore(s => s.strings) const cfg = useRedisStateStore(s => s.cfg) const { toast, generalHandleError } = useCommonStore() const overlay = useOverlayStore() const isWide = useMediaQuery('(min-width: 600px)') const [apiKey, setApiKey] = useState('') useEffect(() => { if (open) setApiKey('') }, [open]) const submit = async () => { try { const trimmedKey = apiKey.trim() if (trimmedKey) { overlay.show({ message: strings?.title?.connectingRedis }) let validation: any try { validation = await request({ action: 'ai/validate-groq-api-key', payload: { apiKey: trimmedKey } }) } catch (e) { generalHandleError(e); return } finally { overlay.hide() } if (!validation.valid) { toast(strings?.label?.aiGroqApiKeyInvalid) return } } await request({ action: 'ai/set-groq-api-key', payload: { apiKey: trimmedKey, aiEnabled: cfg?.aiEnabled !== false, aiUseOwnKey: cfg?.aiUseOwnKey === true }, }) useRedisStateStore.setState({ cfg: { ...cfg, groqApiKey: trimmedKey } }) toast(strings?.status?.saved) onClose() } catch (e) { generalHandleError(e) } } if (!open) return null return ( {isWide ? ( ) : ( )} }> {strings?.label?.aiGroqApiKeyInfo}{' '} console.groq.com setApiKey(e.target.value)} autoComplete="off" /> ) } src/react/dialogs/AskAuthorizationDialog.tsx000066400000000000000000000110061517644030100215430ustar00rootroot00000000000000import { useState, useEffect } from 'react' import { Button, IconButton, Tooltip, TextField, useMediaQuery, InputAdornment, Box, } from '@mui/material' import { Done, Cancel, Visibility, VisibilityOff, Person, Lock, Shield } from '@mui/icons-material' import { useCommonStore } from '../stores/common.store' import { useI18nStore } from '../stores/i18n.store' import P3xrDialog from '../components/P3xrDialog' export default function AskAuthorizationDialog() { const { askAuthOpen, resolveAskAuth } = useCommonStore() const strings = useI18nStore(s => s.strings) const isWide = useMediaQuery('(min-width: 600px)') const [username, setUsername] = useState('') const [password, setPassword] = useState('') const [pwVisible, setPwVisible] = useState(false) useEffect(() => { if (askAuthOpen) { setUsername('') setPassword('') setPwVisible(false) } }, [askAuthOpen]) if (!askAuthOpen) return null const handleOk = () => { resolveAskAuth?.({ username, password }) } const handleCancel = () => { resolveAskAuth?.(null) } return ( {strings?.label?.askAuth}} width="400px" actions={ <> {isWide ? ( ) : ( )} {isWide ? ( ) : ( )} } > {strings?.label?.aclAuthHint} setUsername(e.target.value)} autoComplete="off" onKeyDown={e => e.key === 'Enter' && handleOk()} slotProps={{ input: { startAdornment: ( ), }, }} /> setPassword(e.target.value)} autoComplete="off" onKeyDown={e => e.key === 'Enter' && handleOk()} slotProps={{ input: { startAdornment: ( ), endAdornment: ( setPwVisible(!pwVisible)} size="small"> {pwVisible ? : } ), }, }} /> ) } src/react/dialogs/CommandPaletteDialog.tsx000066400000000000000000000135111517644030100211440ustar00rootroot00000000000000import { useState, useEffect, useRef, useMemo, useCallback } from 'react' import { Box, Dialog, InputAdornment, useTheme } from '@mui/material' import { Search } from '@mui/icons-material' import { useCommonStore } from '../stores/common.store' import { useI18nStore } from '../stores/i18n.store' import { getShortcuts, ShortcutDef } from '../stores/shortcuts' interface PaletteItem { label: string description: string shortcut: ShortcutDef } export default function CommandPaletteDialog() { const open = useCommonStore(s => s.commandPaletteOpen) const setOpen = useCommonStore(s => s.setCommandPaletteOpen) const strings = useI18nStore(s => s.strings) const theme = useTheme() const isDark = theme.palette.mode === 'dark' const [search, setSearch] = useState('') const [selectedIndex, setSelectedIndex] = useState(0) const inputRef = useRef(null) const listRef = useRef(null) const allItems = useMemo((): PaletteItem[] => { const seen = new Set() const items: PaletteItem[] = [] for (const s of getShortcuts()) { if (seen.has(s.descriptionKey)) continue seen.add(s.descriptionKey) items.push({ label: s.label, description: strings?.label?.[s.descriptionKey] || s.descriptionKey, shortcut: s, }) } return items }, [strings]) const filtered = useMemo(() => { const q = search.toLowerCase().trim() if (!q) return allItems return allItems.filter(i => i.description.toLowerCase().includes(q) || i.label.toLowerCase().includes(q) ) }, [search, allItems]) useEffect(() => { if (open) { setSearch('') setSelectedIndex(0) setTimeout(() => inputRef.current?.focus(), 50) } }, [open]) // Scroll selected item into view useEffect(() => { if (!open || !listRef.current) return const items = listRef.current.querySelectorAll('.p3xr-cmd-palette-item') items[selectedIndex]?.scrollIntoView({ block: 'nearest' }) }, [selectedIndex, open]) const execute = useCallback((item: PaletteItem) => { setOpen(false) item.shortcut.action() }, [setOpen]) const onKeyDown = useCallback((e: React.KeyboardEvent) => { if (e.key === 'ArrowDown') { e.preventDefault() setSelectedIndex(prev => Math.min(prev + 1, filtered.length - 1)) } else if (e.key === 'ArrowUp') { e.preventDefault() setSelectedIndex(prev => Math.max(prev - 1, 0)) } else if (e.key === 'Enter') { e.preventDefault() if (filtered[selectedIndex]) execute(filtered[selectedIndex]) } else if (e.key === 'Escape') { setOpen(false) } }, [filtered, selectedIndex, execute, setOpen]) if (!open) return null const hoverBg = isDark ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.04)' const activeBg = isDark ? 'rgba(255,255,255,0.12)' : 'rgba(0,0,0,0.08)' return ( setOpen(false)} slotProps={{ paper: { sx: { width: '100%', maxWidth: 500, minWidth: 360, borderRadius: 2, overflow: 'hidden', }, }, }}> ) => { setSearch(e.target.value) setSelectedIndex(0) }} onKeyDown={onKeyDown} placeholder={strings?.label?.commandPalette} autoComplete="off" sx={{ flex: 1, border: 'none', outline: 'none', background: 'transparent', color: 'text.primary', fontSize: 16, fontFamily: 'inherit', '&::placeholder': { color: 'text.secondary', opacity: 0.5 }, }} /> {filtered.map((item, i) => ( execute(item)} sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', px: 2, py: 1.25, cursor: 'pointer', bgcolor: i === selectedIndex ? activeBg : 'transparent', '&:hover': { bgcolor: hoverBg }, }} > {item.description} {item.label} ))} {filtered.length === 0 && ( {strings?.label?.noResults} )} ) } src/react/dialogs/ConnectionDialog.tsx000066400000000000000000000446301517644030100203540ustar00rootroot00000000000000import { useState, useMemo, useEffect } from 'react' import { TextField, IconButton, Button, Switch, FormControlLabel, Autocomplete, Box, Tooltip, useMediaQuery, } from '@mui/material' import { Done, Cancel, Add, Delete, Visibility, VisibilityOff, Save, } from '@mui/icons-material' 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 { request } from '../stores/socket.service' import P3xrDialog from '../components/P3xrDialog' interface ConnectionDialogProps { open: boolean type: 'new' | 'edit' model?: any onClose: () => void } function initModel(type: string, source?: any): any { let model: any if (source) { model = structuredClone(source) model.password = source.id model.tlsCrt = source.id model.tlsKey = source.id model.tlsCa = source.id model.sshPassword = source.id model.sshPrivateKey = source.id } else { model = { name: '', host: '', port: 6379, askAuth: false, password: '', username: '', id: undefined, group: '', readonly: false, tlsWithoutCert: false, tlsRejectUnauthorized: false, tlsCrt: '', tlsKey: '', tlsCa: '', } } if (!model.ssh) { model.ssh = false; model.sshHost = model.sshHost || '' model.sshPort = model.sshPort || 22; model.sshUsername = model.sshUsername || '' model.sshPassword = model.sshPassword || source?.id || '' model.sshPrivateKey = model.sshPrivateKey || source?.id || '' } if (!model.cluster) model.cluster = false if (!model.sentinel) model.sentinel = false if (!model.nodes) model.nodes = [] for (const node of model.nodes) { node.password = node.id } return model } export default function ConnectionDialog({ open, type, model: sourceModel, onClose }: ConnectionDialogProps) { const strings = useI18nStore(s => s.strings) const cfg = useRedisStateStore(s => s.cfg) const connectionsList = useRedisStateStore(s => s.connections)?.list ?? [] const { generateId } = useSettingsStore() const { toast, generalHandleError } = useCommonStore() const overlay = useOverlayStore() const isWide = useMediaQuery('(min-width: 600px)') const readonlyConnections = cfg?.readonlyConnections === true const [model, setModel] = useState(() => initModel(type, sourceModel)) const [pwVisible, setPwVisible] = useState(false) const [sshPwVisible, setSshPwVisible] = useState(false) const [nodePwVisible, setNodePwVisible] = useState>({}) useEffect(() => { if (open) { setModel(initModel(type, sourceModel)) setPwVisible(false); setSshPwVisible(false); setNodePwVisible({}) } }, [open, type, sourceModel]) const existingGroups = useMemo(() => { const groups = new Set() for (const conn of connectionsList) { if (conn.group?.trim()) groups.add(conn.group.trim()) } return [...groups].sort() }, [connectionsList]) const set = (field: string, value: any) => setModel((m: any) => ({ ...m, [field]: value })) const setNode = (idx: number, field: string, value: any) => setModel((m: any) => { const nodes = [...m.nodes] nodes[idx] = { ...nodes[idx], [field]: value } return { ...m, nodes } }) const addNode = (index?: number) => { const newNode = { host: '', port: undefined, password: '', username: '', id: generateId() } setModel((m: any) => { const nodes = [...m.nodes] if (index === undefined) nodes.push(newNode); else nodes.splice(index + 1, 0, newNode) return { ...m, nodes } }) } const removeNode = async (idx: number) => { try { await useCommonStore.getState().confirm({ message: strings?.confirm?.deleteConnectionText }) setModel((m: any) => ({ ...m, nodes: m.nodes.filter((_: any, i: number) => i !== idx) })) toast(strings?.status?.nodeRemoved) } catch {} } const validateForm = (): boolean => { if (!model.name?.trim()) { toast(strings?.form?.error?.invalid) return false } if (model.ssh) { if (!model.sshHost?.trim() || !model.sshUsername?.trim()) { toast(strings?.form?.error?.invalid) return false } } if (model.sentinel && !model.sentinelName?.trim()) { toast(strings?.form?.error?.invalid) return false } return true } const testConnection = async () => { if (!validateForm()) return try { const authModel = structuredClone(model) if (model.askAuth === true) { try { const auth = await useCommonStore.getState().askAuth() authModel.username = auth.username || undefined authModel.password = auth.password || undefined } catch { return // user cancelled } } overlay.show({ message: strings?.title?.connectingRedis }) await request({ action: 'connection/test', payload: { model: authModel } }) toast(strings?.status?.redisConnected) } catch (e) { generalHandleError(e) } finally { overlay.hide() } } const submit = async () => { if (!validateForm()) return const saveModel = structuredClone(model) if (!saveModel.host) saveModel.host = 'localhost' if (!saveModel.port) saveModel.port = 6379 if (type === 'new') saveModel.id = generateId() for (const node of saveModel.nodes) { if (!node.host) node.host = 'localhost' if (!node.id) node.id = generateId() } if (typeof saveModel.group === 'string') saveModel.group = saveModel.group.trim() || undefined try { await request({ action: 'connection/save', payload: { model: saveModel } }) toast(type === 'new' ? strings?.status?.added : strings?.status?.saved) onClose() } catch (e) { generalHandleError(e) } } const title = readonlyConnections ? strings?.label?.connectiondView : type === 'new' ? strings?.label?.connectiondAdd : strings?.label?.connectiondEdit const PasswordField = ({ label, value, onChange, visible, onToggle, disabled }: any) => ( onChange(e.target.value)} disabled={disabled} autoComplete="off" slotProps={{ input: { endAdornment: !disabled && ( {visible ? : } )}}} /> ) if (!open) return null return ( {isWide ? ( ) : ( )} {!readonlyConnections && ( )} }> {model.id && type !== 'new' && ( <> {strings?.label?.id?.info} )} set('name', e.target.value)} disabled={readonlyConnections} /> set('group', v)} disabled={readonlyConnections} renderInput={params => } /> {/* SSH */} set('ssh', v)} disabled={readonlyConnections} />} label={model.ssh ? strings?.label?.ssh?.on : strings?.label?.ssh?.off} /> {model.ssh && ( SSH set('sshHost', e.target.value)} disabled={readonlyConnections} /> set('sshPort', Number(e.target.value))} disabled={readonlyConnections} slotProps={{ htmlInput: { min: 1, max: 65535 } }} /> set('sshUsername', e.target.value)} disabled={readonlyConnections} /> set('sshPassword', v)} visible={sshPwVisible} onToggle={() => setSshPwVisible(!sshPwVisible)} disabled={readonlyConnections} /> {strings?.label?.passwordSecure} set('sshPrivateKey', e.target.value)} disabled={readonlyConnections} autoComplete="off" /> {strings?.label?.secureFeature} )} {/* Node 1 */} Node 1 set('host', e.target.value)} disabled={readonlyConnections} /> set('port', Number(e.target.value))} disabled={readonlyConnections} slotProps={{ htmlInput: { min: 1, max: 65535 } }} /> { set('askAuth', v); if (v) { set('username', ''); set('password', '') } }} disabled={readonlyConnections} />} label={strings?.label?.askAuth} /> {strings?.label?.aclAuthHint} {!model.askAuth && (<> set('username', e.target.value)} disabled={readonlyConnections} autoComplete="off" /> set('password', v)} visible={pwVisible} onToggle={() => setPwVisible(!pwVisible)} disabled={readonlyConnections} /> {strings?.label?.passwordSecure} )} {/* Readonly */} set('readonly', v)} disabled={readonlyConnections} />} label={model.readonly ? strings?.label?.readonly?.on : strings?.label?.readonly?.off} /> {/* Cluster / Sentinel */} { set('cluster', v); if (v) set('sentinel', false) }} disabled={readonlyConnections} />} label={model.cluster ? strings?.label?.cluster?.on : strings?.label?.cluster?.off} /> { set('sentinel', v); if (v) set('cluster', false) }} disabled={readonlyConnections} />} label={model.sentinel ? strings?.label?.sentinel?.on : strings?.label?.sentinel?.off} /> {(model.cluster || model.sentinel) && !readonlyConnections && ( )} {model.sentinel && ( set('sentinelName', e.target.value)} disabled={readonlyConnections} /> )} {/* Dynamic nodes */} {(model.cluster || model.sentinel) && model.nodes.map((node: any, idx: number) => ( Node {idx + 2} {!readonlyConnections && ( )} {node.id && (<>{strings?.label?.id?.info})} setNode(idx, 'host', e.target.value)} disabled={readonlyConnections} /> setNode(idx, 'port', Number(e.target.value))} disabled={readonlyConnections} slotProps={{ htmlInput: { min: 1, max: 65535 } }} /> setNode(idx, 'username', e.target.value)} disabled={readonlyConnections} autoComplete="off" /> setNode(idx, 'password', v)} visible={!!nodePwVisible[idx]} onToggle={() => setNodePwVisible(p => ({ ...p, [idx]: !p[idx] }))} disabled={readonlyConnections} /> {strings?.label?.passwordSecure} ))} {/* TLS */} set('tlsWithoutCert', v)} disabled={readonlyConnections} />} label={strings?.label?.tlsWithoutCert} /> set('tlsRejectUnauthorized', v)} disabled={readonlyConnections} />} label={strings?.label?.tlsRejectUnauthorized} /> {!model.tlsWithoutCert && ( TLS {[{ label: 'TLS (redis.crt)', field: 'tlsCrt' }, { label: 'TLS (redis.key)', field: 'tlsKey' }, { label: 'TLS (ca.crt)', field: 'tlsCa' }].map(({ label, field }) => ( set(field, e.target.value)} disabled={readonlyConnections} autoComplete="off" />{strings?.label?.tlsSecure} ))} )} ) } src/react/dialogs/DiffDialog.tsx000066400000000000000000000162361517644030100171260ustar00rootroot00000000000000import { useState, useMemo } from 'react' import { Button, Box, ToggleButtonGroup, ToggleButton, useMediaQuery, Tooltip, } from '@mui/material' import { Save, Cancel } from '@mui/icons-material' import { diffLines, Change } from 'diff' import { useI18nStore } from '../stores/i18n.store' import P3xrDialog from '../components/P3xrDialog' interface DiffBlock { type: 'added' | 'removed' | 'unchanged' | 'collapse' lines: string[] collapsedCount?: number } const CONTEXT_LINES = 3 function buildBlocks(changes: Change[]): DiffBlock[] { const blocks: DiffBlock[] = [] for (const change of changes) { const lines = change.value.replace(/\n$/, '').split('\n') if (change.added) { blocks.push({ type: 'added', lines }) } else if (change.removed) { blocks.push({ type: 'removed', lines }) } else { if (lines.length <= CONTEXT_LINES * 2 + 1) { blocks.push({ type: 'unchanged', lines }) } else { blocks.push({ type: 'unchanged', lines: lines.slice(0, CONTEXT_LINES) }) const collapsed = lines.slice(CONTEXT_LINES, -CONTEXT_LINES) blocks.push({ type: 'collapse', lines: collapsed, collapsedCount: collapsed.length }) blocks.push({ type: 'unchanged', lines: lines.slice(-CONTEXT_LINES) }) } } } return blocks } interface DiffDialogProps { open: boolean keyName: string fieldName?: string oldValue: string newValue: string onConfirm: () => void onCancel: () => void } export default function DiffDialog({ open, keyName, fieldName, oldValue, newValue, onConfirm, onCancel }: DiffDialogProps) { const strings = useI18nStore(s => s.strings) const d = strings?.diff || {} as any const isWide = useMediaQuery('(min-width: 600px)') const [mode, setMode] = useState<'inline' | 'side-by-side'>('inline') const [expanded, setExpanded] = useState>(new Set()) const changes = useMemo(() => diffLines(String(oldValue ?? ''), String(newValue ?? '')), [oldValue, newValue]) const blocks = useMemo(() => buildBlocks(changes), [changes]) const additions = useMemo(() => changes.filter(c => c.added).reduce((n, c) => n + (c.value.split('\n').length - 1 || 1), 0), [changes]) const deletions = useMemo(() => changes.filter(c => c.removed).reduce((n, c) => n + (c.value.split('\n').length - 1 || 1), 0), [changes]) const toggleExpand = (i: number) => setExpanded(prev => { const s = new Set(prev); s.has(i) ? s.delete(i) : s.add(i); return s }) const lineSx = (type: string) => ({ px: 1, py: '1px', whiteSpace: 'pre-wrap', wordBreak: 'break-all', fontFamily: "'Roboto Mono', monospace", fontSize: 13, ...(type === 'added' ? { bgcolor: 'rgba(76,175,80,0.12)' } : {}), ...(type === 'removed' ? { bgcolor: 'rgba(244,67,54,0.12)' } : {}), ...(type === 'unchanged' || type === 'collapse' ? { opacity: 0.6 } : {}), }) const collapseSx = { px: 1, py: '4px', opacity: 0.4, fontStyle: 'italic', cursor: 'pointer', fontFamily: "'Roboto Mono', monospace", fontSize: 13, '&:hover': { opacity: 0.7 }, } const renderInline = () => blocks.map((block, i) => { if (block.type === 'collapse' && !expanded.has(i)) { return toggleExpand(i)}>... {block.collapsedCount} {d.unchangedLines} ... } return block.lines.map((line, j) => ( {block.type === 'added' ? '+' : block.type === 'removed' ? '-' : ' '} {line} )) }) const renderSide = (side: 'before' | 'after') => { const skipType = side === 'before' ? 'added' : 'removed' return (<> {side === 'before' ? d.before : d.after} {blocks.map((block, i) => { if (block.type === 'collapse' && !expanded.has(i)) { return toggleExpand(i)}>... {block.collapsedCount} {d.unchangedLines} ... } if (block.type === skipType) return null return block.lines.map((line, j) => {line}) })} ) } const title = `${d.reviewChanges} — ${fieldName ? `${fieldName} @ ` : ''}${keyName}` return ( v && setMode(v)} sx={{ mr: 0.5, '& .MuiToggleButton-root': { py: '2px', px: 1.5, fontSize: 12, borderRadius: '4px', textTransform: 'none', color: 'rgba(255,255,255,0.7)', borderColor: 'rgba(255,255,255,0.3)' } }}> {d.inline} {d.sideBySide} +{additions}{' '}{d.additions},{' '} -{deletions}{' '}{d.deletions} } actions={<> } > {mode === 'inline' ? ( {renderInline()} ) : (<> {renderSide('before')} {renderSide('after')} )} ) } src/react/dialogs/JsonEditorDialog.tsx000066400000000000000000000231771517644030100203400ustar00rootroot00000000000000import { useState, useEffect, useRef } from 'react' import { Box, Button, useMediaQuery } from '@mui/material' import { Save, FormatLineSpacing, Cancel, WrapText, Notes } from '@mui/icons-material' import { useI18nStore } from '../stores/i18n.store' import { useCommonStore } from '../stores/common.store' import { useRedisStateStore } from '../stores/redis-state.store' import { useSettingsStore } from '../stores/settings.store' import { useThemeStore } from '../stores/theme.store' import { isDarkTheme } from '../themes' import P3xrDialog from '../components/P3xrDialog' import DiffDialog from './DiffDialog' interface Props { open: boolean value: string hideFormatSave?: boolean onClose: (result?: { obj: string } | null) => void } export default function JsonEditorDialog({ open, value, hideFormatSave, onClose }: Props) { const strings = useI18nStore(s => s.strings) const { generalHandleError } = useCommonStore() const isReadonly = useRedisStateStore(s => s.connection)?.readonly === true const jsonFormat = useSettingsStore(s => s.jsonFormat) const themeKey = useThemeStore(s => s.themeKey) const isWide = useMediaQuery('(min-width: 960px)') const [isJson, setIsJson] = useState(false) const [lineWrap, setLineWrap] = useState(true) const editorRef = useRef(null) const viewRef = useRef(null) const wrapRef = useRef(null) const EditorViewRef = useRef(null) // Init CodeMirror when dialog opens — delay to ensure DOM is ready useEffect(() => { if (!open) return let obj: any try { obj = JSON.parse(value); setIsJson(true) } catch { setIsJson(false); return } const doc = JSON.stringify(obj, null, jsonFormat || 2) let view: any let cancelled = false const initEditor = async () => { // Wait for DOM to be ready while (!editorRef.current && !cancelled) { await new Promise(r => setTimeout(r, 50)) } if (cancelled || !editorRef.current) return const { EditorView, keymap, lineNumbers, highlightActiveLineGutter, highlightSpecialChars, drawSelection, highlightActiveLine, rectangularSelection, crosshairCursor } = await import('@codemirror/view') const { EditorState, Compartment } = await import('@codemirror/state') const { json } = await import('@codemirror/lang-json') const { defaultKeymap, history, historyKeymap } = await import('@codemirror/commands') const { bracketMatching, foldGutter, foldKeymap, indentOnInput, syntaxHighlighting, defaultHighlightStyle } = await import('@codemirror/language') const { closeBrackets, closeBracketsKeymap } = await import('@codemirror/autocomplete') const { searchKeymap, highlightSelectionMatches } = await import('@codemirror/search') const { lintKeymap } = await import('@codemirror/lint') let themeExt: any if (isDarkTheme(themeKey)) { const { oneDark } = await import('@codemirror/theme-one-dark') themeExt = oneDark } else { const { githubLight } = await import('@uiw/codemirror-theme-github') themeExt = githubLight } const wrapCompartment = new Compartment() wrapRef.current = wrapCompartment EditorViewRef.current = EditorView view = new EditorView({ state: EditorState.create({ doc, extensions: [ lineNumbers(), highlightActiveLineGutter(), highlightSpecialChars(), history(), foldGutter(), drawSelection(), indentOnInput(), syntaxHighlighting(defaultHighlightStyle, { fallback: true }), bracketMatching(), closeBrackets(), rectangularSelection(), crosshairCursor(), highlightActiveLine(), highlightSelectionMatches(), keymap.of([ ...closeBracketsKeymap, ...defaultKeymap, ...searchKeymap, ...historyKeymap, ...foldKeymap, ...lintKeymap, ]), json(), themeExt, EditorView.theme({ '.cm-scroller': { 'overflow-x': 'scroll', 'scrollbar-width': 'auto' }, '.cm-scroller::-webkit-scrollbar': { height: '12px', display: 'block' }, '.cm-scroller::-webkit-scrollbar-track': { background: 'rgba(128,128,128,0.1)' }, '.cm-scroller::-webkit-scrollbar-thumb': { background: 'rgba(128,128,128,0.4)', 'border-radius': '6px' }, '.cm-scroller::-webkit-scrollbar-thumb:hover': { background: 'rgba(128,128,128,0.6)' }, }), wrapCompartment.of(EditorView.lineWrapping), EditorState.readOnly.of(isReadonly), ], }), parent: editorRef.current!, }) viewRef.current = view } initEditor() return () => { cancelled = true if (viewRef.current) { viewRef.current.destroy(); viewRef.current = null } } }, [open, value, themeKey]) const toggleWrap = () => { setLineWrap(prev => { const next = !prev if (viewRef.current && wrapRef.current && EditorViewRef.current) { viewRef.current.dispatch({ effects: wrapRef.current.reconfigure(next ? EditorViewRef.current.lineWrapping : []), }) } return next }) } const [diffOpen, setDiffOpen] = useState(false) const [diffNewValue, setDiffNewValue] = useState('') const diffResolveRef = useRef<((v: boolean) => void) | null>(null) const save = async (format: boolean) => { try { const text = viewRef.current.state.doc.toString() const parsed = JSON.parse(text) const result = JSON.stringify(parsed, null, format ? (jsonFormat || 2) : 0) const settings = useSettingsStore.getState() if (settings.showDiffBeforeSave && value !== result) { setDiffNewValue(result) setDiffOpen(true) const confirmed = await new Promise(resolve => { diffResolveRef.current = resolve }) if (!confirmed) return } onClose({ obj: result }) } catch (e) { generalHandleError(e) } } if (!open) return null const minHeight = isWide ? `${Math.max(10, window.innerHeight - 100)}px` : '100%' return ( <> onClose(null)} contentPadding={!isJson} width="90vw" height="90vh" title={ {strings?.intention?.jsonViewEditor} } actions={ <> {isJson && !isReadonly && ( <> {!hideFormatSave && ( )} )} }> {isJson ? ( ) : ( {strings?.label?.jsonViewNotParsable} )} { setDiffOpen(false); diffResolveRef.current?.(true) }} onCancel={() => { setDiffOpen(false); diffResolveRef.current?.(false) }} /> ) } src/react/dialogs/JsonViewDialog.tsx000066400000000000000000000175741517644030100200300ustar00rootroot00000000000000import { useState, useCallback, useEffect } from 'react' import { Box, IconButton, Tooltip } from '@mui/material' import { useTheme } from '@mui/material' import { Button } from '@mui/material' import { Close, KeyboardArrowDown, KeyboardArrowUp, ChevronRight, ExpandMore } from '@mui/icons-material' import { useI18nStore } from '../stores/i18n.store' import P3xrDialog from '../components/P3xrDialog' interface JsonNode { key: string value: any type: 'object' | 'array' | 'string' | 'number' | 'boolean' | 'null' children?: JsonNode[] childCount?: number } function jsonToNode(key: string, value: any): JsonNode { if (value === null) return { key, value: null, type: 'null' } if (Array.isArray(value)) { const children = value.map((item, i) => jsonToNode(String(i), item)) return { key, value, type: 'array', children, childCount: children.length } } if (typeof value === 'object') { const children = Object.keys(value).map(k => jsonToNode(k, value[k])) return { key, value, type: 'object', children, childCount: children.length } } return { key, value, type: typeof value as any } } function formatDisplay(node: JsonNode): string { if (node.type === 'null') return 'null' if (node.type === 'string') return `"${node.value}"` return String(node.value) } // Color map from Angular: string=accent, number=primary, boolean=warn, null=muted function useJsonColors() { const muiTheme = useTheme() const isDark = muiTheme.palette.mode === 'dark' return { key: isDark ? 'white' : 'black', string: muiTheme.palette.secondary.main, // --p3xr-btn-accent-bg number: muiTheme.palette.primary.main, // --p3xr-btn-primary-bg boolean: muiTheme.palette.error.main, // --p3xr-btn-warn-bg null: isDark ? 'rgba(255,255,255,0.4)' : 'rgba(0,0,0,0.4)', } } function TreeNode({ node, level, expandedKeys, toggleExpand }: { node: JsonNode; level: number; expandedKeys: Set; toggleExpand: (path: string) => void }) { const colors = useJsonColors() const path = `${level}-${node.key}` const isExpandable = node.type === 'object' || node.type === 'array' const isExpanded = expandedKeys.has(path) const valueColor = isExpandable ? undefined : (colors as any)[node.type] ?? 'inherit' return ( <> {isExpandable ? ( toggleExpand(path)} sx={{ width: 24, height: 24, p: 0, flexShrink: 0, opacity: 0.6 }}> {isExpanded ? : } ) : ( )} {node.key} : {isExpandable ? ( !isExpanded ? ( <> {node.type === 'array' ? '[' : '{'} ... {node.type === 'array' ? ']' : '}'} ({node.childCount}) ) : null ) : ( {formatDisplay(node)} )} {isExpandable && isExpanded && node.children?.map((child, i) => ( ))} ) } interface Props { open: boolean value: string onClose: () => void } export default function JsonViewDialog({ open, value, onClose }: Props) { const strings = useI18nStore(s => s.strings) // Start with only root expanded (level 0) — matches Angular expanded=true (first level only) const [expandedKeys, setExpandedKeys] = useState>(new Set()) const rootLabel = strings?.label?.tree ?? 'root' let isJson = false let tree: JsonNode | null = null try { const obj = JSON.parse(value) isJson = true tree = jsonToNode(rootLabel, obj) } catch { /* not parsable */ } // Reset to root-only expanded when value changes useEffect(() => { if (open && isJson) setExpandedKeys(new Set([`0-${rootLabel}`])) }, [open, value]) const toggleExpand = useCallback((path: string) => { setExpandedKeys(prev => { const next = new Set(prev) if (next.has(path)) next.delete(path) else next.add(path) return next }) }, []) const expandAll = useCallback(() => { if (!tree) return const keys = new Set() const collect = (node: JsonNode, level: number) => { const path = `${level}-${node.key}` if (node.type === 'object' || node.type === 'array') { keys.add(path) node.children?.forEach((c, i) => collect(c, level + 1)) } } collect(tree, 0) setExpandedKeys(keys) }, [tree]) const collapseAll = useCallback(() => { // Collapse to level 1: only root expanded const rootPath = `0-${strings?.label?.tree ?? 'root'}` setExpandedKeys(new Set([rootPath])) }, [strings]) if (!open) return null return ( ) : undefined} actions={ }> {isJson && tree ? ( ) : ( {strings?.label?.jsonViewNotParsable} )} ) } src/react/dialogs/KeyImportDialog.tsx000066400000000000000000000124541517644030100201770ustar00rootroot00000000000000import { useState, useRef } from 'react' import { Button, Radio, RadioGroup, FormControlLabel, Box, useMediaQuery, Tooltip, } from '@mui/material' import { Cancel, FileUpload } from '@mui/icons-material' import { useTheme } from '@mui/material' import { useVirtualizer } from '@tanstack/react-virtual' import { useI18nStore } from '../stores/i18n.store' import P3xrDialog from '../components/P3xrDialog' interface KeyImportDialogProps { open: boolean data: { keys: any[] } | null onClose: (result: { pending: boolean; keys: any[]; conflictMode: string } | null) => void } function ImportPreviewList({ keys }: { keys: any[] }) { const strings = useI18nStore(s => s.strings) const muiTheme = useTheme() const parentRef = useRef(null) const virtualizer = useVirtualizer({ count: keys.length, getScrollElement: () => parentRef.current, estimateSize: () => 40, overscan: 10, }) return ( {virtualizer.getVirtualItems().map(row => { const entry = keys[row.index] return ( {entry.key} {strings?.redisTypes?.[entry.type] ?? entry.type} ) })} ) } export default function KeyImportDialog({ open, data, onClose }: KeyImportDialogProps) { const strings = useI18nStore(s => s.strings) const isWide = useMediaQuery('(min-width: 600px)') const [conflictMode, setConflictMode] = useState<'overwrite' | 'skip'>('overwrite') if (!open || !data) return null const keys = data.keys ?? [] return ( onClose(null)} title={strings?.intention?.importKeys} actions={ <> {isWide ? ( ) : ( )} } > {strings?.label?.importPreview} ({keys.length}) {strings?.label?.importConflict} setConflictMode(v as any)}> } label={strings?.label?.importOverwrite} /> } label={strings?.label?.importSkip} /> ) } src/react/dialogs/KeyNewOrSetDialog.tsx000066400000000000000000000636631517644030100204430ustar00rootroot00000000000000import { useState, useEffect, useRef } from 'react' import { TextField, Select, MenuItem, FormControl, InputLabel, Button, Tooltip, Switch, FormControlLabel, Box, useMediaQuery, } from '@mui/material' import { Add, Edit, Upload, Description, FormatLineSpacing, AccountTree, ContentCopy, AutoGraph, } from '@mui/icons-material' import { Cancel } from '@mui/icons-material' 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 { trackPage } from '../stores/analytics' import { useOverlayStore } from '../stores/overlay.store' import { request } from '../stores/socket.service' import P3xrDialog from '../components/P3xrDialog' import JsonViewDialog from './JsonViewDialog' import DiffDialog from './DiffDialog' import JsonEditorDialog from './JsonEditorDialog' export interface KeyNewOrSetData { type: 'add' | 'edit' | 'append' node?: any model?: any } interface KeyModel { type: string key: string value: any score: string streamTimestamp: string tsTimestamp: string tsRetention: number tsDuplicatePolicy: string tsLabels: string tsBulkMode: boolean tsSpread: number tsFormula: string tsFormulaPoints: number tsFormulaAmplitude: number tsFormulaOffset: number tsEditAll: boolean hashKey: string index: string bloomErrorRate: number bloomCapacity: number cuckooCapacity: number topkK: number topkWidth: number topkDepth: number topkDecay: number cmsWidth: number cmsDepth: number tdigestCompression: number vectorElement: string vectorValues: string } interface Props { open: boolean data: KeyNewOrSetData | null onClose: (result?: any) => void } export default function KeyNewOrSetDialog({ open, data, onClose }: Props) { const strings = useI18nStore(s => s.strings) const hasTimeSeries = useRedisStateStore(s => s.hasTimeSeries) const hasReJSON = useRedisStateStore(s => s.hasReJSON) const hasBloom = useRedisStateStore(s => s.hasBloom) const connection = useRedisStateStore(s => s.connection) const settings = useSettingsStore() const { toast, generalHandleError } = useCommonStore() const overlay = useOverlayStore() const isWide = useMediaQuery('(min-width: 720px)') const fileInputRef = useRef(null) const isReadonly = connection?.readonly === true const [validateJson, setValidateJson] = useState(false) const [jsonViewOpen, setJsonViewOpen] = useState(false) const [jsonEditorOpen, setJsonEditorOpen] = useState(false) const [model, setModel] = useState({ type: 'string', key: '', value: '', score: '', streamTimestamp: '*', tsTimestamp: '*', tsRetention: 0, tsDuplicatePolicy: 'LAST', tsLabels: '', tsBulkMode: false, tsSpread: 60000, tsFormula: '', tsFormulaPoints: 25, tsFormulaAmplitude: 100, tsFormulaOffset: 0, tsEditAll: false, hashKey: '', index: '', bloomErrorRate: 0.01, bloomCapacity: 100, cuckooCapacity: 1024, topkK: 10, topkWidth: 2000, topkDepth: 7, topkDecay: 0.9, cmsWidth: 2000, cmsDepth: 7, tdigestCompression: 100, vectorElement: '', vectorValues: '', }) const isProbabilistic = ['bloom', 'cuckoo', 'topk', 'cms', 'tdigest'].includes(model.type) const isVectorset = model.type === 'vectorset' const types = (() => { const base = ['string', 'list', 'hash', 'set', 'zset', 'stream'] if (hasTimeSeries) base.push('timeseries') if (hasReJSON) base.push('json') if (hasBloom) base.push('bloom', 'cuckoo', 'topk', 'cms', 'tdigest') base.push('vectorset') return base })() useEffect(() => { if (!open || !data) return const divider = settings.redisTreeDivider const m: KeyModel = { type: 'string', key: data.node?.key ? data.node.key + divider : '', value: '', score: '', streamTimestamp: '*', tsTimestamp: '*', tsRetention: 0, tsDuplicatePolicy: 'LAST', tsLabels: '', tsBulkMode: false, tsSpread: 60000, tsFormula: '', tsFormulaPoints: 25, tsFormulaAmplitude: 100, tsFormulaOffset: 0, tsEditAll: false, hashKey: '', index: '', } if (data.model) Object.assign(m, data.model) setModel(m) setValidateJson(false) }, [open, data]) const set = (field: keyof KeyModel, value: any) => setModel(m => ({ ...m, [field]: value })) const getTitle = () => { if (data?.type === 'edit') return strings?.form?.key?.label?.formName?.edit if (data?.type === 'append') return strings?.form?.key?.label?.formName?.append return strings?.form?.key?.label?.formName?.add } const copy = async () => { let value = model.value if (model.type === 'timeseries') value = `TS.ADD ${model.key} ${model.tsTimestamp} ${model.value}` try { await navigator.clipboard.writeText(String(value)) } catch {} toast(strings?.status?.dataCopied) } const formatJson = () => { try { set('value', JSON.stringify(JSON.parse(model.value), null, settings.jsonFormat || 2)) } catch { toast(strings?.label?.jsonViewNotParsable) } } const onFileSelected = async (event: React.ChangeEvent) => { const file = event.target.files?.[0] if (!file) return try { await useCommonStore.getState().confirm({ message: strings?.confirm?.uploadBuffer }) const buf = await file.arrayBuffer() set('value', buf) toast(strings?.confirm?.uploadBufferDone) } catch {} event.target.value = '' } const generateFormula = () => { const points = Math.min(Math.max(parseInt(String(model.tsFormulaPoints)) || 25, 1), 10000) const amplitude = parseFloat(String(model.tsFormulaAmplitude)) || 100 const offset = parseFloat(String(model.tsFormulaOffset)) || 0 const formula = model.tsFormula const lines: string[] = [] for (let i = 0; i < points; i++) { const x = i / points let v: number switch (formula) { case 'sin': v = Math.sin(x * Math.PI * 2) * amplitude + offset; break case 'cos': v = Math.cos(x * Math.PI * 2) * amplitude + offset; break case 'linear': v = x * amplitude + offset; break case 'random': v = Math.random() * amplitude + offset; break case 'sawtooth': v = (x % 0.25) * 4 * amplitude + offset; break default: v = offset } lines.push(`* ${parseFloat(v.toFixed(4))}`) } set('value', lines.join('\n')) } const [diffOpen, setDiffOpen] = useState(false) const [diffData, setDiffData] = useState({ oldValue: '', newValue: '', fieldName: '' }) const diffResolveRef = useRef<((v: boolean) => void) | null>(null) const submit = async () => { if (!model.key?.trim()) { toast(strings?.form?.key?.error?.key); return } if (validateJson) { try { JSON.parse(model.value) } catch { toast(strings?.label?.jsonViewNotParsable); return } } // Show diff for edits (not new keys) if (data?.model?.value !== undefined && data.model.value !== model.value) { const settings = useSettingsStore.getState() if (settings.showDiffBeforeSave) { setDiffData({ oldValue: String(data.model.value), newValue: String(model.value), fieldName: model.hashKey || '' }) setDiffOpen(true) const confirmed = await new Promise(resolve => { diffResolveRef.current = resolve }) if (!confirmed) return } } try { overlay.show({ message: strings?.label?.saving }) const response = await request({ action: 'key/new-or-set', payload: { type: data?.type, originalValue: data?.model?.value, originalHashKey: data?.model?.hashKey, model: structuredClone(model), }, }) trackPage('/key-new-or-set') toast(strings?.status?.set) onClose(response) } catch (e) { generalHandleError(e) } finally { overlay.hide() } } if (!open || !data) return null const isAdd = data.type === 'add' return ( <> onClose()} title={getTitle()} actions={ <> {!isReadonly && ( )} }> {/* Key */} set('key', e.target.value)} disabled={!isAdd} /> {/* Type */} {strings?.form?.key?.field?.type} {/* Type-specific fields */} {model.type === 'list' && ( <> set('index', e.target.value)} /> {strings?.label?.redisListIndexInfo} )} {model.type === 'hash' && ( set('hashKey', e.target.value)} /> )} {model.type === 'zset' && ( set('score', e.target.value)} /> )} {model.type === 'stream' && ( <> set('streamTimestamp', e.target.value)} /> {strings?.label?.streamTimestampId} )} {model.type === 'timeseries' && isAdd && ( <> set('tsRetention', e.target.value)} helperText={strings?.page?.key?.timeseries?.retentionHint} /> {strings?.page?.key?.timeseries?.duplicatePolicy} )} {model.type === 'timeseries' && ( <> set('tsLabels', e.target.value)} helperText={strings?.page?.key?.timeseries?.labelsHint} /> {!model.tsBulkMode && ( set('tsTimestamp', e.target.value)} disabled={model.originalTimestamp !== undefined} helperText={strings?.page?.key?.timeseries?.timestampHint} /> )} {model.originalTimestamp === undefined && ( set('tsBulkMode', v)} />} label={strings?.page?.key?.timeseries?.bulkMode} /> )} )} {/* Probabilistic type fields */} {model.type === 'bloom' && ( set('bloomErrorRate', parseFloat(e.target.value))} placeholder="0.01 = 1%" sx={{ flex: 1, minWidth: 140 }} /> set('bloomCapacity', parseInt(e.target.value))} sx={{ flex: 1, minWidth: 140 }} /> )} {model.type === 'cuckoo' && ( set('cuckooCapacity', parseInt(e.target.value))} /> )} {model.type === 'topk' && ( set('topkK', parseInt(e.target.value))} sx={{ flex: 1, minWidth: 100 }} /> set('topkWidth', parseInt(e.target.value))} sx={{ flex: 1, minWidth: 100 }} /> set('topkDepth', parseInt(e.target.value))} sx={{ flex: 1, minWidth: 100 }} /> set('topkDecay', parseFloat(e.target.value))} sx={{ flex: 1, minWidth: 100 }} /> )} {model.type === 'cms' && ( set('cmsWidth', parseInt(e.target.value))} sx={{ flex: 1, minWidth: 140 }} /> set('cmsDepth', parseInt(e.target.value))} sx={{ flex: 1, minWidth: 140 }} /> )} {model.type === 'tdigest' && ( set('tdigestCompression', parseInt(e.target.value))} /> )} {model.type === 'vectorset' && ( set('vectorElement', e.target.value)} sx={{ flex: 1, minWidth: 200 }} /> set('vectorValues', e.target.value)} sx={{ flex: 1, minWidth: 200 }} /> )} {/* Action buttons */} {model.type !== 'stream' && model.type !== 'timeseries' && !isProbabilistic && !isVectorset && ( )} {model.type !== 'timeseries' && !isProbabilistic && !isVectorset && ( <> )} {model.type !== 'timeseries' && !isProbabilistic && !isVectorset && ( setValidateJson(v)} />} label={strings?.label?.validateJson} /> )} {/* Timeseries formula generator */} {model.type === 'timeseries' && (model.tsEditAll || model.tsBulkMode) && ( <> {strings?.page?.key?.timeseries?.autoSpread} {strings?.page?.key?.timeseries?.formula} {model.tsFormula && ( set('tsFormulaPoints', e.target.value)} slotProps={{ htmlInput: { min: 1, max: 10000 } }} /> set('tsFormulaAmplitude', e.target.value)} /> set('tsFormulaOffset', e.target.value)} /> )} )} {/* Value field */} {isProbabilistic || isVectorset ? null : model.type === 'timeseries' && (model.tsEditAll || model.tsBulkMode) ? ( set('value', e.target.value)} helperText={strings?.page?.key?.timeseries?.editAllHint} slotProps={{ input: { sx: { fontFamily: "'Roboto Mono', monospace", fontSize: 13 } } }} /> ) : model.type === 'timeseries' && !model.tsBulkMode ? ( set('value', e.target.value)} /> ) : ( <> {model.type === 'stream' && ( {strings?.label?.streamValue} )} set('value', e.target.value)} /> )} setJsonViewOpen(false)} /> { setJsonEditorOpen(false); if (result?.obj) set('value', result.obj) }} /> { setDiffOpen(false); diffResolveRef.current?.(true) }} onCancel={() => { setDiffOpen(false); diffResolveRef.current?.(false) }} /> ) } src/react/dialogs/TreeSettingsDialog.tsx000066400000000000000000000301151517644030100206660ustar00rootroot00000000000000import { useState, useEffect } from 'react' import { TextField, Button, Switch, FormControlLabel, Tooltip, Box, useMediaQuery, } from '@mui/material' import { Done, Cancel } from '@mui/icons-material' 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 { useMainCommandStore } from '../stores/main-command.store' import P3xrDialog from '../components/P3xrDialog' interface TreeSettingsDialogProps { open: boolean onClose: () => void } interface FormModel { treeSeparator: string pageCount: number keyPageCount: number maxValueDisplay: number maxKeys: number keysSort: boolean searchClientSide: boolean searchStartsWith: boolean jsonFormat: boolean animation: boolean undoEnabled: boolean showDiffBeforeSave: boolean } interface FieldRange { min: number max: number required: boolean } const FIELD_RANGES: Record = { pageCount: { min: 10, max: 5000, required: true }, keyPageCount: { min: 5, max: 100, required: true }, maxValueDisplay: { min: -1, max: 32768, required: true }, maxKeys: { min: 5, max: 100000, required: true }, } export default function TreeSettingsDialog({ open, onClose }: TreeSettingsDialogProps) { const strings = useI18nStore(s => s.strings) const settings = useSettingsStore() const state = useRedisStateStore() const { toast, generalHandleError } = useCommonStore() const { refresh } = useMainCommandStore() const isWide = useMediaQuery('(min-width: 600px)') const reducedFunctions = state.reducedFunctions const keysRawLength = state.keysRaw?.length ?? 0 const dbsize = state.dbsize ?? 0 const [model, setModel] = useState({ treeSeparator: '', pageCount: 250, keyPageCount: 5, maxValueDisplay: 1024, maxKeys: 1000, keysSort: true, searchClientSide: false, searchStartsWith: false, jsonFormat: true, animation: true, undoEnabled: true, showDiffBeforeSave: true, }) const [errors, setErrors] = useState>({}) useEffect(() => { if (open) { setModel({ treeSeparator: settings.redisTreeDivider, pageCount: settings.pageCount, keyPageCount: settings.keyPageCount, maxValueDisplay: settings.maxValueDisplay, maxKeys: settings.maxKeys, keysSort: settings.keysSort, searchClientSide: settings.searchClientSide, searchStartsWith: settings.searchStartsWith, jsonFormat: Number(settings.jsonFormat) !== 2, animation: settings.animation, undoEnabled: settings.undoEnabled, showDiffBeforeSave: settings.showDiffBeforeSave, }) setErrors({}) } }, [open, settings]) const set = (field: keyof FormModel, value: any) => { setModel(m => ({ ...m, [field]: value })) const range = FIELD_RANGES[field] if (range) { const num = Number(value) if (isNaN(num) || !Number.isInteger(num)) { setErrors(e => ({ ...e, [field]: strings?.form?.error?.integer })) } else if (num < range.min || num > range.max) { setErrors(e => ({ ...e, [field]: `${range.min} - ${range.max}` })) } else { setErrors(e => { const n = { ...e }; delete n[field]; return n }) } } } const validateAll = (): boolean => { const newErrors: Record = {} for (const [field, range] of Object.entries(FIELD_RANGES)) { const value = (model as any)[field] const num = Number(value) if (range.required && (value === '' || value === undefined || value === null)) { newErrors[field] = strings?.form?.error?.required } else if (isNaN(num) || !Number.isInteger(num)) { newErrors[field] = strings?.form?.error?.integer } else if (num < range.min || num > range.max) { newErrors[field] = `${range.min} - ${range.max}` } } setErrors(newErrors) return Object.keys(newErrors).length === 0 } const submit = async () => { if (!validateAll()) { toast(strings?.form?.error?.invalid) return } try { const s = useSettingsStore.getState() s.setSetting('p3xr-main-treecontrol-divider', model.treeSeparator) s.setSetting('p3xr-main-treecontrol-page-size', model.pageCount) s.setSetting('p3xr-main-key-page-size', model.keyPageCount) s.setSetting('p3xr-main-treecontrol-max-value-display', model.maxValueDisplay) s.setSetting('p3xr-max-keys', model.maxKeys) s.setSetting('p3xr-main-treecontrol-key-sort', model.keysSort) s.setSetting('p3xr-main-treecontrol-search-client-mode', model.searchClientSide) s.setSetting('p3xr-main-treecontrol-search-starts-with', model.searchStartsWith) s.setSetting('p3xr-json-format', model.jsonFormat ? 4 : 2) s.setSetting('p3xr-animation-settings', model.animation ? '1' : '0') s.setSetting('p3xr-undo-enabled', model.undoEnabled) s.setSetting('p3xr-show-diff-before-save', model.showDiffBeforeSave) useRedisStateStore.setState({ page: 1, redisChanged: true }) if (state.connection) await refresh() toast(strings?.status?.saved) onClose() } catch (e) { generalHandleError(e) } } return ( {isWide ? ( ) : ( )} } > set('treeSeparator', e.target.value)} /> set('pageCount', e.target.value === '' ? '' : Number(e.target.value))} error={!!errors.pageCount} helperText={errors.pageCount || strings?.form?.treeSettings?.error?.page} slotProps={{ htmlInput: { min: 10, max: 5000 } }} /> set('keyPageCount', e.target.value === '' ? '' : Number(e.target.value))} error={!!errors.keyPageCount} helperText={errors.keyPageCount || strings?.form?.treeSettings?.error?.keyPageCount} slotProps={{ htmlInput: { min: 5, max: 100 } }} /> set('maxValueDisplay', e.target.value === '' ? '' : Number(e.target.value))} error={!!errors.maxValueDisplay} helperText={errors.maxValueDisplay || strings?.form?.treeSettings?.maxValueDisplayInfo} slotProps={{ htmlInput: { min: -1, max: 32768 } }} /> set('maxKeys', e.target.value === '' ? '' : Number(e.target.value))} error={!!errors.maxKeys} helperText={errors.maxKeys || strings?.form?.treeSettings?.maxKeysInfo} slotProps={{ htmlInput: { min: 5, max: 100000 } }} /> {!reducedFunctions && ( set('keysSort', v)} />} label={model.keysSort ? strings?.label?.keysSort?.on : strings?.label?.keysSort?.off} /> )} {!reducedFunctions && ( set('searchClientSide', v)} disabled={dbsize > settings.maxLightKeysCount} />} label={model.searchClientSide ? strings?.form?.treeSettings?.label?.searchModeClient : strings?.form?.treeSettings?.label?.searchModeServer} /> )} {reducedFunctions && ( {(() => { const fn = strings?.label?.tooManyKeys return typeof fn === 'function' ? fn({ count: keysRawLength, maxLightKeysCount: settings.maxLightKeysCount }) : '' })()} )} set('searchStartsWith', v)} />} label={model.searchStartsWith ? strings?.form?.treeSettings?.label?.searchModeStartsWith : strings?.form?.treeSettings?.label?.searchModeIncludes} /> set('jsonFormat', v)} />} label={model.jsonFormat ? strings?.form?.treeSettings?.label?.jsonFormatFourSpace : strings?.form?.treeSettings?.label?.jsonFormatTwoSpace} /> set('animation', v)} />} label={model.animation ? strings?.form?.treeSettings?.label?.animation : strings?.form?.treeSettings?.label?.noAnimation} /> set('undoEnabled', v)} />} label={model.undoEnabled ? strings?.form?.treeSettings?.label?.undoEnabled : strings?.form?.treeSettings?.label?.undoDisabled} /> {strings?.form?.treeSettings?.undoHint} set('showDiffBeforeSave', v)} />} label={model.showDiffBeforeSave ? strings?.form?.treeSettings?.label?.diffEnabled : strings?.form?.treeSettings?.label?.diffDisabled} /> ) } src/react/dialogs/TtlDialog.tsx000066400000000000000000000064531517644030100170210ustar00rootroot00000000000000import { useState, useEffect } from 'react' import { TextField, Button, Box } from '@mui/material' import { Timer, Cancel } from '@mui/icons-material' import { useI18nStore } from '../stores/i18n.store' import { useSettingsStore } from '../stores/settings.store' import P3xrDialog from '../components/P3xrDialog' import humanizeDuration from 'humanize-duration' import timestring from 'timestring' interface Props { open: boolean ttl: number | string onClose: (result?: { model: { ttl: number } }) => void } export default function TtlDialog({ open, ttl: initialTtl, onClose }: Props) { const strings = useI18nStore(s => s.strings) const [ttl, setTtl] = useState(-1) const [textTime, setTextTime] = useState('') useEffect(() => { if (!open) return const t = initialTtl ?? -1 setTtl(t) if (typeof t === 'number' && t > 0) { try { const hdOpts = useSettingsStore.getState().getHumanizeDurationOptions() setTextTime(humanizeDuration(t * 1000, { ...hdOpts, delimiter: ' ' })) } catch { setTextTime('') } } else { setTextTime('') } }, [open, initialTtl]) const onTextTimeChange = (value: string) => { setTextTime(value) try { setTtl(timestring(String(value), 's')) } catch { /* parse error */ } } const submit = () => { let t = Number(ttl) if (isNaN(t)) t = Math.round(t) onClose({ model: { ttl: t } }) } if (!open) return null return ( onClose()} width="600px" title={strings?.confirm?.ttl?.title} actions={ <> }> {strings?.confirm?.ttl?.textContent} setTtl(e.target.value === '' ? '' : Number(e.target.value))} placeholder={strings?.confirm?.ttl?.placeholderPlaceholder ?? '-1'} slotProps={{ htmlInput: { min: -1 } }} /> onTextTimeChange(e.target.value)} placeholder={strings?.confirm?.ttl?.convertTextToTimePlaceholder ?? '1h 30m'} /> ) } src/react/index.html000066400000000000000000000042661517644030100147510ustar00rootroot00000000000000 P3X Redis UI
src/react/layout/000077500000000000000000000000001517644030100142615ustar00rootroot00000000000000src/react/layout/ConsoleDrawer.tsx000066400000000000000000000157221517644030100175770ustar00rootroot00000000000000import { useEffect, useRef, useState, useCallback } from 'react' import { Box, useTheme } from '@mui/material' import { useRedisStateStore } from '../stores/redis-state.store' import ConsoleComponent from '../pages/console/ConsoleComponent' const HEIGHT_KEY = 'p3xr-console-drawer-height' const MIN_VH = 15 const MAX_VH = 66 const FOOTER_HEIGHT = 48 /** * Global bottom console drawer — always renders the full ConsoleComponent. * The welcome banner inside the console adapts to connectionState (connected * vs limited-AI). This keeps the toolbar, input, Clear / Commands always * usable — disconnected users can still type `ai: what is ZADD?` or eventually * `connect ` without losing the chrome. * * Top 5px grab strip resizes the drawer between MIN_VH and MAX_VH, persisted * to localStorage. A ResizeObserver dispatches window.resize on every frame * the drawer's height changes so pages that read --p3xr-console-drawer-height-active * re-layout live during drag and during open/close transition. */ export default function ConsoleDrawer() { const isOpen = useRedisStateStore(s => s.consoleDrawerOpen) const setConsoleDrawerOpen = useRedisStateStore(s => s.setConsoleDrawerOpen) const muiTheme = useTheme() const isDark = muiTheme.palette.mode === 'dark' const [resizeClicked, setResizeClicked] = useState(false) const [sizerHover, setSizerHover] = useState(false) const drawerRef = useRef(null) const sizerRef = useRef(null) const dragStyleElRef = useRef(null) const applyDragCursor = useCallback((cursor: 'ns-resize' | 'not-allowed') => { let el = dragStyleElRef.current if (!el) { el = document.createElement('style') el.setAttribute('data-p3xr-console-drawer-drag', '') document.head.appendChild(el) dragStyleElRef.current = el } el.textContent = `*, *::before, *::after { cursor: ${cursor} !important; }` }, []) const clearDragCursor = useCallback(() => { dragStyleElRef.current?.remove() dragStyleElRef.current = null }, []) // Saved height is applied at bootstrap (src/core/console-drawer-height.ts) // so it's in place before this component mounts. // Observe the drawer element — fires on every size change frame // (open/close height transition + live drag). Listeners on window.resize // (profiler/pubsub page height calc) pick it up. useEffect(() => { if (!drawerRef.current || typeof ResizeObserver === 'undefined') return const obs = new ResizeObserver(() => { window.dispatchEvent(new Event('resize')) }) obs.observe(drawerRef.current) return () => obs.disconnect() }, []) // Drag handlers — document-level so the drag continues outside the sizer useEffect(() => { if (!resizeClicked) return const handleMouseMove = (e: MouseEvent) => { const minPx = (MIN_VH / 100) * window.innerHeight const maxPx = (MAX_VH / 100) * window.innerHeight let newHeight = window.innerHeight - e.clientY - FOOTER_HEIGHT const outOfBounds = newHeight < minPx || newHeight > maxPx if (newHeight < minPx) newHeight = minPx if (newHeight > maxPx) newHeight = maxPx applyDragCursor(outOfBounds ? 'not-allowed' : 'ns-resize') document.documentElement.style.setProperty('--p3xr-console-drawer-height', `${Math.round(newHeight)}px`) } const handleMouseUp = () => { setResizeClicked(false) clearDragCursor() document.body.classList.remove('p3xr-not-selectable') document.documentElement.classList.remove('p3xr-console-drawer-resizing') const current = document.documentElement.style.getPropertyValue('--p3xr-console-drawer-height') if (current && current.endsWith('px')) { localStorage.setItem(HEIGHT_KEY, current) } } document.addEventListener('mousemove', handleMouseMove) document.addEventListener('mouseup', handleMouseUp) return () => { document.removeEventListener('mousemove', handleMouseMove) document.removeEventListener('mouseup', handleMouseUp) } }, [resizeClicked, applyDragCursor, clearDragCursor]) const handleSizerMouseDown = useCallback((e: React.MouseEvent) => { e.preventDefault() e.stopPropagation() setResizeClicked(true) applyDragCursor('ns-resize') document.body.classList.add('p3xr-not-selectable') document.documentElement.classList.add('p3xr-console-drawer-resizing') }, [applyDragCursor]) const sizerFilter = resizeClicked ? (isDark ? 'brightness(2)' : 'brightness(0.5)') : sizerHover ? (isDark ? 'brightness(1.5)' : 'brightness(0.75)') : 'none' return ( setSizerHover(true)} onMouseLeave={() => setSizerHover(false)} sx={{ position: 'absolute', top: 0, left: 0, right: 0, height: '5px', cursor: 'ns-resize', zIndex: 3, bgcolor: (sizerHover || resizeClicked) ? (muiTheme as any).p3xr?.accordionBg : 'transparent', filter: sizerFilter, transition: 'background-color 0.15s ease, filter 0.15s ease', }} /> setConsoleDrawerOpen(false)} /> ) } src/react/layout/Layout.tsx000066400000000000000000001044431517644030100163040ustar00rootroot00000000000000import { useState, useRef, useEffect, useCallback, useMemo } from 'react' import { AppBar, Toolbar, Button, IconButton, Typography, Menu, MenuItem, Divider, Tooltip, Box, useMediaQuery, } from '@mui/material' import { Storage, MonitorHeart, Search, Info, Settings, Power, PowerOff, Language, Logout, Terminal, } from '@mui/icons-material' import { Outlet, useNavigate, useLocation } from 'react-router-dom' import { ColorLens } from '@mui/icons-material' import { useI18nStore } from '../stores/i18n.store' import { useThemeStore } from '../stores/theme.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 { useAuthStore } from '../stores/auth.store' import LoginPage from '../pages/login/LoginPage' import { trackPage } from '../stores/analytics' import { ALL_THEME_KEYS } from '../themes' import ConsoleDrawer from './ConsoleDrawer' import { installOverlayScrolls } from '../../core/overlay-scroll' const TOOLBAR_HEIGHT = 48 const LAYOUT_PADDING = 5 export default function Layout() { const navigate = useNavigate() const location = useLocation() // Stores const strings = useI18nStore(s => s.strings) const currentLang = useI18nStore(s => s.currentLang) const isLangAuto = useI18nStore(s => s.isAuto) const setLanguage = useI18nStore(s => s.setLanguage) const { themeKey, isAuto, setTheme } = useThemeStore() const connection = useRedisStateStore(s => s.connection) const connections = useRedisStateStore(s => s.connections) const version = useRedisStateStore(s => s.version) const hasRediSearch = useRedisStateStore(s => s.hasRediSearch) const consoleDrawerOpen = useRedisStateStore(s => s.consoleDrawerOpen) const toggleConsoleDrawer = useRedisStateStore(s => s.toggleConsoleDrawer) const settings = useSettingsStore() const { generalHandleError } = useCommonStore() const overlay = useOverlayStore() const { connect, disconnect } = useMainCommandStore() const { authChecked, authRequired, isAuthenticated, checkAuthStatus } = useAuthStore() const showLogin = authChecked && authRequired && !isAuthenticated useEffect(() => { checkAuthStatus().then(() => { const state = useAuthStore.getState() if (state.authRequired && !state.isAuthenticated) { overlay.hide() } }) }, [checkAuthStatus]) // Responsive breakpoints matching Angular layout const isWide = useMediaQuery('(min-width: 720px)') const isGtXs = useMediaQuery('(min-width: 600px)') const isGtSm = useMediaQuery('(min-width: 960px)') const isElectron = useMemo(() => /electron/i.test(navigator.userAgent), []) const connectionsList = connections?.list ?? [] // Connection name (computed, matches Angular) const connectionName = useMemo(() => { if (connection) { const fn = strings?.label?.connected return typeof fn === 'function' ? fn({ name: connection.name }) : connection.name } return strings?.intention?.connect }, [connection, strings]) // Track group mode reactively (Settings page toggles this in localStorage) const [groupMode, setGroupMode] = useState(() => { try { return localStorage.getItem('p3xr-connection-group-mode') === 'true' } catch { return false } }) useEffect(() => { const check = () => { try { setGroupMode(localStorage.getItem('p3xr-connection-group-mode') === 'true') } catch {} } window.addEventListener('storage', check) // Also poll since same-tab localStorage changes don't fire 'storage' const interval = setInterval(check, 1000) return () => { window.removeEventListener('storage', check); clearInterval(interval) } }, []) // Grouped connections const groupedConnectionsList = useMemo(() => { if (!groupMode) return [{ name: '', connections: connectionsList }] 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, groupMode]) const isActivePage = (page: string) => { const url = location.pathname switch (page) { case 'database': return url.startsWith('/database') case 'search': return url === '/search' case 'monitoring': return url.startsWith('/monitoring') case 'info': return url === '/info' case 'settings': return url === '/settings' default: return false } } const navigateTo = (stateName: string) => { const routes: Record = { 'database.statistics': '/database/statistics', 'database': '/database', 'monitoring': '/monitoring', 'search': '/search', 'info': '/info', 'settings': '/settings', } navigate(routes[stateName] || `/${stateName}`) } const openLink = (target: string) => { const urls: Record = { github: 'https://github.com/patrikx3/redis-ui', githubRelease: 'https://github.com/patrikx3/redis-ui/releases', githubChangelog: 'https://github.com/patrikx3/redis-ui/blob/master/change-log.md#change-log', donate: 'https://www.paypal.me/patrikx3', } window.open(urls[target], '_blank') } // --- Menu anchors --- const [connectionAnchor, setConnectionAnchor] = useState(null) const [themeAnchor, setThemeAnchor] = useState(null) const [githubAnchor, setGithubAnchor] = useState(null) const [languageAnchor, setLanguageAnchor] = useState(null) // --- Language menu with search --- const [languageSearch, setLanguageSearch] = useState('') const [highlightedLangIdx, setHighlightedLangIdx] = useState(0) const languageInputRef = useRef(null) const availableLanguages = useMemo(() => Object.keys(strings?.language ?? {}), [strings]) const filteredLanguages = useMemo(() => { const search = languageSearch.trim().toLowerCase() if (!search) return availableLanguages return availableLanguages.filter(key => { const label = (strings?.language?.[key] ?? key).toLowerCase() return label.includes(search) || key.toLowerCase().includes(search) }) }, [availableLanguages, languageSearch, strings]) const languageLabel = useCallback((key: string): string => strings?.language?.[key] ?? key, [strings]) const onLanguageMenuOpen = () => { const idx = filteredLanguages.indexOf(currentLang) setHighlightedLangIdx(idx >= 0 ? idx : 0) // MUI Menu needs time to render before we can focus the input and scroll setTimeout(() => { languageInputRef.current?.focus() // Scroll current language into view const menu = document.querySelector('.p3xr-language-menu .MuiList-root') if (menu) { const items = menu.querySelectorAll('.MuiMenuItem-root') const target = items[idx >= 0 ? idx : 0] target?.scrollIntoView({ block: 'nearest' }) } }, 150) } const onLanguageMenuClose = () => { setLanguageSearch('') } const onLanguageKeyDown = (e: React.KeyboardEvent) => { if (e.key === 'Escape') { setLanguageAnchor(null) return } if (e.key === 'Enter') { e.preventDefault() if (filteredLanguages.length > 0) { setLanguage(filteredLanguages[highlightedLangIdx]) setLanguageAnchor(null) } return } if (e.key === 'ArrowDown' || e.key === 'ArrowUp') { e.preventDefault() const len = filteredLanguages.length if (!len) return setHighlightedLangIdx(prev => e.key === 'ArrowDown' ? (prev + 1) % len : (prev - 1 + len) % len ) return } e.stopPropagation() } // Scroll highlighted language into view useEffect(() => { if (!languageAnchor) return setTimeout(() => { const menu = document.querySelector('.p3xr-language-menu .MuiList-root') if (!menu) return const items = menu.querySelectorAll('.MuiMenuItem-root') items[highlightedLangIdx]?.scrollIntoView({ block: 'nearest' }) }) }, [highlightedLangIdx, languageAnchor]) // Reflect drawer-open state on so CSS + JS recalc can size page content. // Only active when we're connected (no connection = no drawer = no space reserved). // The active var references --p3xr-console-drawer-height (set by ConsoleDrawer and // by loadSavedHeight at bootstrap) so resizing cascades down to page layouts live. useEffect(() => { const active = consoleDrawerOpen && Boolean(connection) if (active) { document.documentElement.classList.add('p3xr-console-drawer-open') document.documentElement.style.setProperty('--p3xr-console-drawer-height-active', 'var(--p3xr-console-drawer-height, 30vh)') } else { document.documentElement.classList.remove('p3xr-console-drawer-open') document.documentElement.style.setProperty('--p3xr-console-drawer-height-active', '0px') } }, [consoleDrawerOpen, connection]) // Body never scrolls — the fixed-height #p3xr-layout-content container // handles all page scrolling. Drawer and footer/header are position: fixed. useEffect(() => { const prev = document.body.style.overflow document.body.style.overflow = 'hidden' return () => { document.body.style.overflow = prev } }, []) // Ctrl+` (or Cmd+`) toggles the drawer globally useEffect(() => { const handler = (e: KeyboardEvent) => { if (e.key === '`' && (e.ctrlKey || e.metaKey) && !e.altKey && !e.shiftKey) { e.preventDefault() toggleConsoleDrawer() } } window.addEventListener('keydown', handler) return () => window.removeEventListener('keydown', handler) }, [toggleConsoleDrawer]) // --- Electron bridge --- useEffect(() => { if (!isElectron) return const handler = (event: MessageEvent) => { const data = event.data if (!data || typeof data.type !== 'string') return if (data.type === 'p3x-set-language' && typeof data.translation === 'string') { setLanguage(data.translation) } else if (data.type === 'p3x-menu' && typeof data.action === 'string') { navigateTo(data.action) } } window.addEventListener('message', handler) return () => { window.removeEventListener('message', handler) } }, [isElectron]) // Auto-connect from localStorage on startup (only when authenticated) useEffect(() => { if (!isAuthenticated) return try { const saved = localStorage.getItem(settings.connectInfoStorageKey) if (saved) { const conn = JSON.parse(saved) if (conn?.id) connect(conn) } } catch {} }, [isAuthenticated]) // Subscribe to redis disconnect → navigate to settings + reset connection state useEffect(() => { const unsub = onSocketEvent('redis-disconnected', () => { useRedisStateStore.setState({ connection: undefined, connectionState: 'none' }) navigateTo('settings') }) return unsub }, []) // Custom overlay scrollbar — macOS-style thin thumb, applied app-wide to // every scrollable element. CodeMirror / xterm / Monaco are excluded inside // the helper so they keep their own native scrollbars. useEffect(() => installOverlayScrolls(), []) // Prefetch other GUI frameworks — fetch HTML, parse script/style tags, cache all assets useEffect(() => { const timer = setTimeout(() => { for (const gui of ['/ng/', '/vue/']) { fetch(gui).then(r => r.text()).then(html => { const doc = new DOMParser().parseFromString(html, 'text/html') doc.querySelectorAll('script[src], link[rel="stylesheet"]').forEach(el => { const url = (el as any).src || (el as any).href if (url) fetch(url).catch(() => {}) }) }).catch(() => {}) } }, 3000) return () => clearTimeout(timer) }, []) // Promo toast — demo site only, once per session useEffect(() => { if (window.location.hostname !== 'p3x.redis.patrikx3.com') return if (sessionStorage.getItem('p3xr-promo-shown')) return const timer = setTimeout(() => { const promo = useI18nStore.getState().strings?.promo if (promo?.toastMessage) { sessionStorage.setItem('p3xr-promo-shown', '1') const msg = promo.toastMessage + (promo.disclaimer ? ' · ' + promo.disclaimer : '') useCommonStore.getState().toast(msg, 30000) } }, 5000) return () => clearTimeout(timer) }, []) // Track route changes for analytics (matches Angular setupRouteTracking) // Also updates the global currentPage signal — used by console drawer + AI context. useEffect(() => { const path = location.pathname.toLowerCase().startsWith('/database/key/') ? '/database/key' : location.pathname trackPage(path) const u = location.pathname.toLowerCase() const page = u.startsWith('/database') ? 'database' : u.startsWith('/monitoring/profiler') ? 'profiler' : u.startsWith('/monitoring/pubsub') ? 'pubsub' : u.startsWith('/monitoring/memory-analysis') || u.startsWith('/monitoring/analysis') ? 'analysis' : u.startsWith('/monitoring') ? 'pulse' : u.startsWith('/search') ? 'search' : u.startsWith('/timeseries') ? 'timeseries' : u.startsWith('/info') ? 'info' : u.startsWith('/settings') ? 'settings' : 'unknown' useRedisStateStore.setState({ currentPage: page as any }) }, [location.pathname]) // Show overlay on raw socket disconnect/error (matches Angular behavior, skip during login) useEffect(() => { const unsubDisconnect = onSocketEvent('disconnect', () => { if (showLogin) return overlay.show({ message: strings?.status?.socketDisconnected }) }) const unsubError = onSocketEvent('socket-error', () => { if (showLogin) return overlay.show({ message: strings?.status?.socketError }) }) return () => { unsubDisconnect(); unsubError() } }, [strings, showLogin]) // --- Responsive button helpers --- const isMatrixTheme = themeKey === 'matrix' const activeSx = { bgcolor: isMatrixTheme ? 'rgba(0,0,0,0.15)' : 'rgba(255,255,255,0.1)' } const NavBtn = ({ icon, label, tooltip, page, onClick }: { icon: React.ReactNode, label: string, tooltip?: string, page?: string, onClick: () => void }) => { const active = page ? isActivePage(page) : false return isWide ? ( ) : ( {icon} ) } const FooterBtn = ({ icon, label, onClick, bp = 'wide' }: { icon: React.ReactNode, label: string, onClick: (e: React.MouseEvent) => void, bp?: 'wide' | 'gtXs' | 'gtSm' }) => { const show = bp === 'gtXs' ? isGtXs : bp === 'gtSm' ? isGtSm : isWide return show ? ( ) : ( {icon} ) } return ( {/* ===== HEADER ===== */} } label={strings?.title?.name} tooltip={`${strings?.title?.name || ''}${version ? ' ' + version : ''}`} onClick={() => navigateTo(connection ? 'database.statistics' : 'settings')} /> {version && isWide && ( {version} )} {connection && ( } label={strings?.intention?.main} page="database" onClick={() => navigateTo('database.statistics')} /> )} {connection && ( } label={strings?.page?.monitor?.title} page="monitoring" onClick={() => navigateTo('monitoring')} /> )} {connection && hasRediSearch && ( } label={strings?.page?.search?.title} page="search" onClick={() => navigateTo('search')} /> )} {!showLogin && ( } label={strings?.intention?.info} page="info" onClick={() => navigateTo('info')} /> )} {!showLogin && ( } label={strings?.intention?.settings} page="settings" onClick={() => navigateTo('settings')} /> )} {/* Logout button — rightmost in header */} {authRequired && isAuthenticated && ( { try { await useCommonStore.getState().confirm({ message: strings?.intention?.logout, }) useAuthStore.getState().logout() } catch {} }}> )} {/* Version overlay — inside AppBar so it inherits toolbar text color */} {/* ===== CONTENT ===== */} {showLogin ? : } {/* ===== GLOBAL CONSOLE DRAWER (only when connected) ===== */} {!showLogin && connection && } {/* ===== FOOTER ===== */} {/* Connection menu — hidden during login */} {!showLogin && connectionsList.length > 0 && ( <> {isWide ? ( ) : ( setConnectionAnchor(e.currentTarget)}> )} setConnectionAnchor(null)} anchorOrigin={{ vertical: 'top', horizontal: 'left' }} transformOrigin={{ vertical: 'bottom', horizontal: 'left' }}> {groupedConnectionsList.map((group, gi) => [ groupedConnectionsList.length > 1 && ( {group.name || strings?.label?.ungrouped} ), ...group.connections.map((conn: any) => ( { setConnectionAnchor(null); connect(conn) }}> {conn.name} )), gi < groupedConnectionsList.length - 1 && groupedConnectionsList.length > 1 && ( ), ])} )} {/* Disconnect — hidden during login */} {!showLogin && connection && ( } label={strings?.intention?.disconnect} bp="gtSm" onClick={() => disconnect()} /> )} {/* Console drawer toggle — only when connected (no console without connection). */} {connection && (isWide ? ( ) : ( toggleConsoleDrawer()} aria-pressed={consoleDrawerOpen} sx={consoleDrawerOpen ? activeSx : undefined}> ))} {/* Language menu with search */} {isGtSm ? ( ) : ( { setLanguageAnchor(e.currentTarget); onLanguageMenuOpen() }}> )} { setLanguageAnchor(null); onLanguageMenuClose() }} className="p3xr-language-menu" disableAutoFocus disableEnforceFocus disableRestoreFocus autoFocus={false} anchorOrigin={{ vertical: 'top', horizontal: 'left' }} transformOrigin={{ vertical: 'bottom', horizontal: 'left' }} slotProps={{ paper: { sx: { minWidth: 320, maxWidth: '90vw', maxHeight: 400, overflow: 'hidden' } }, list: { autoFocus: false, autoFocusItem: false, sx: { pt: 0, overflow: 'auto', maxHeight: 400 } }, }}> ({ position: 'sticky', top: 0, zIndex: 1, bgcolor: theme.palette.mode === 'dark' ? theme.palette.background.paper : 'background.paper', backgroundImage: theme.palette.mode === 'dark' ? 'linear-gradient(rgba(255, 255, 255, 0.12), rgba(255, 255, 255, 0.12))' : 'none', px: 1, py: 1, overflow: 'hidden', })} onClick={e => e.stopPropagation()} onKeyDown={onLanguageKeyDown} > ) => { setLanguageSearch(e.target.value) setHighlightedLangIdx(0) }} autoComplete="off" sx={{ display: 'block', width: '100%', mx: 'auto', px: 1, py: 1, borderStyle: 'solid', borderWidth: 2, borderColor: 'rgba(255,255,255,0.25)', borderRadius: '4px', fontSize: 14, bgcolor: 'transparent', color: 'text.primary', outline: 'none', boxSizing: 'border-box', overflow: 'hidden', textOverflow: 'ellipsis', '&:focus': { borderWidth: 3, borderColor: 'primary.main', }, '&::placeholder': { color: 'text.secondary', opacity: 0.5, }, }} /> { setLanguage('auto'); setLanguageAnchor(null) }}> {strings?.label?.languageAuto} {filteredLanguages.map((key, i) => ( { setLanguage(key); setLanguageAnchor(null) }}> {languageLabel(key)} ))} {/* Theme menu — exact port of Angular theme menu */} {isGtXs ? ( ) : ( setThemeAnchor(e.currentTarget)}> )} setThemeAnchor(null)} anchorOrigin={{ vertical: 'top', horizontal: 'left' }} transformOrigin={{ vertical: 'bottom', horizontal: 'left' }}> { setTheme('auto'); setThemeAnchor(null) }}> {strings?.label?.themeAuto} {ALL_THEME_KEYS.map(key => ( { setTheme(key); setThemeAnchor(null) }}> {strings?.label?.theme?.[key] ?? key} ))} {/* GitHub menu */} {isGtSm ? ( ) : ( setGithubAnchor(e.currentTarget)}> )} setGithubAnchor(null)} anchorOrigin={{ vertical: 'top', horizontal: 'left' }} transformOrigin={{ vertical: 'bottom', horizontal: 'left' }}> { openLink('github'); setGithubAnchor(null) }}> {strings?.intention?.githubRepo} { openLink('githubRelease'); setGithubAnchor(null) }}> {strings?.intention?.githubRelease} { openLink('githubChangelog'); setGithubAnchor(null) }}> {strings?.intention?.githubChangelog} ) } src/react/main.tsx000066400000000000000000000056611517644030100144400ustar00rootroot00000000000000import '@fontsource/roboto/latin-300.css' import '@fontsource/roboto/latin-400.css' import '@fontsource/roboto/latin-500.css' import '@fontsource/roboto/latin-700.css' import '@fontsource/roboto/latin-ext-300.css' import '@fontsource/roboto/latin-ext-400.css' import '@fontsource/roboto/latin-ext-500.css' import '@fontsource/roboto/latin-ext-700.css' import '@fontsource/roboto/cyrillic-300.css' import '@fontsource/roboto/cyrillic-400.css' import '@fontsource/roboto/cyrillic-500.css' import '@fontsource/roboto/cyrillic-700.css' import '@fontsource/roboto/cyrillic-ext-300.css' import '@fontsource/roboto/cyrillic-ext-400.css' import '@fontsource/roboto/cyrillic-ext-500.css' import '@fontsource/roboto/cyrillic-ext-700.css' import '@fontsource/roboto/greek-300.css' import '@fontsource/roboto/greek-400.css' import '@fontsource/roboto/greek-500.css' import '@fontsource/roboto/greek-700.css' import '@fontsource/roboto/vietnamese-300.css' import '@fontsource/roboto/vietnamese-400.css' import '@fontsource/roboto/vietnamese-500.css' import '@fontsource/roboto/vietnamese-700.css' import '@fontsource/roboto-mono/latin-400.css' import '@fontsource/roboto-mono/latin-ext-400.css' import '@fontsource/roboto-mono/cyrillic-400.css' import '@fontsource/roboto-mono/cyrillic-ext-400.css' import '@fontsource/roboto-mono/greek-400.css' import '@fontsource/roboto-mono/vietnamese-400.css' import '@fortawesome/fontawesome-free/css/all.css' // Redirect to Angular if preference is not React (production only, not dev server) if (!globalThis.p3xrDevMode && window.parent === window && location.pathname.startsWith('/react')) { try { if (localStorage.getItem('p3xr-frontend') !== 'react') { location.replace('/ng/' + location.search) } } catch {} } import { StrictMode } from 'react' import { createRoot } from 'react-dom/client' import App from './App' import { useRedisStateStore } from './stores/redis-state.store' import { useSettingsStore } from './stores/settings.store' import { loadSavedConsoleDrawerHeight } from '../core/console-drawer-height' // Apply the saved console drawer height BEFORE React renders so the CSS var // is in place when components read it. Otherwise the drawer flashes at 30vh // default before the component's own useEffect sets it. loadSavedConsoleDrawerHeight() // Initialize Socket.IO connection on app load import './stores/socket.service' // Initialize keyboard shortcuts (Electron only) import './stores/shortcuts' // Expose E2E test interface matching Angular's window.__p3xr_test ;(globalThis as any).__p3xr_test = { state: new Proxy({}, { get(_, prop: string) { return () => (useRedisStateStore.getState() as any)[prop] } }), settings: new Proxy({}, { get(_, prop: string) { return () => (useSettingsStore.getState() as any)[prop] } }), } createRoot(document.getElementById('root')!).render( , ) src/react/pages/000077500000000000000000000000001517644030100140435ustar00rootroot00000000000000src/react/pages/console/000077500000000000000000000000001517644030100155055ustar00rootroot00000000000000src/react/pages/console/ConsoleComponent.tsx000066400000000000000000001037501517644030100215400ustar00rootroot00000000000000import { useState, useEffect, useRef, useCallback, useMemo } from 'react' import { Box, Toolbar, Tooltip, Popper, Paper, ClickAwayListener, IconButton } from '@mui/material' import { CheckBox, CheckBoxOutlineBlank, Terminal, Backspace, MenuBook, KeyboardArrowDown, StopCircle } from '@mui/icons-material' import AiCheatsheetDialog from '../../dialogs/AiCheatsheetDialog' import { useTheme } from '@mui/material' import P3xrButton from '../../components/P3xrButton' import { useI18nStore } from '../../stores/i18n.store' import { useRedisStateStore } from '../../stores/redis-state.store' import { useCommonStore } from '../../stores/common.store' import { useMainCommandStore } from '../../stores/main-command.store' import { request } from '../../stores/socket.service' import { consoleParse } from '../../stores/redis-parser' function htmlEncode(str: string): string { return String(str) .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, ''') } const CONSOLE_OUTPUT_KEY = 'p3xr-console-output-v1' const CONSOLE_OUTPUT_MAX = 10 * 1024 * 1024 let actionHistoryPosition = -1 interface ConsoleProps { embedded?: boolean collapsed?: boolean /** When true, show a close button on the toolbar right — wired by the drawer host. */ showCloseButton?: boolean onCloseRequest?: () => void } export default function ConsoleComponent({ embedded = false, collapsed = false, showCloseButton = false, onCloseRequest }: ConsoleProps) { const strings = useI18nStore(s => s.strings) const cfg = useRedisStateStore(s => s.cfg) const commands = useRedisStateStore(s => s.commands) const commandsMeta = useRedisStateStore(s => s.commandsMeta) const muiTheme = useTheme() const { toast } = useCommonStore() const [searchText, setSearchText] = useState('') const [currentHint, setCurrentHint] = useState('') const [aiLoading, setAiLoading] = useState(false) const aiRequestSeqRef = useRef(0) const [aiAutoDetect, setAiAutoDetect] = useState(() => { try { return localStorage.getItem('p3xr-ai-auto-detect') !== 'false' } catch { return true } }) const outputRef = useRef(null) const scrollerRef = useRef(null) const inputRef = useRef(null) const indexRef = useRef(0) const singleLineHeightRef = useRef(0) const aiCommandPendingRef = useRef(false) const aiEnabled = cfg?.aiEnabled !== false const [autocompleteHighlight, setAutocompleteHighlight] = useState(0) const [autocompleteDismissed, setAutocompleteDismissed] = useState(false) const [autocompleteNavigated, setAutocompleteNavigated] = useState(false) const [cheatsheetOpen, setCheatsheetOpen] = useState(false) // --- Autocomplete: grouped commands matching Angular mat-autocomplete --- const filteredCommands = useMemo(() => { if (!searchText || searchText.length === 0 || !commands?.length) return [] const text = searchText.toUpperCase() const matched = commands .filter((cmd: string) => cmd.toUpperCase().includes(text)) .slice(0, 20) const groups = new Map() for (const cmd of matched) { const info = commandsMeta[cmd.toUpperCase()] const group = info?.group || 'Other' const syntax = info?.syntax || '' if (!groups.has(group)) groups.set(group, []) groups.get(group)!.push({ name: cmd, syntax }) } return Array.from(groups.entries()).map(([group, cmds]) => ({ group, commands: cmds })) }, [searchText, commands, commandsMeta]) const flatOptions = useMemo(() => { const result: { name: string; syntax: string }[] = [] for (const g of filteredCommands) result.push(...g.commands) return result }, [filteredCommands]) // --- AI toggle --- const toggleAiAutoDetect = useCallback(() => { const next = !aiAutoDetect setAiAutoDetect(next) try { localStorage.setItem('p3xr-ai-auto-detect', String(next)) } catch {} }, [aiAutoDetect]) // --- Output (direct DOM matching Angular) --- const getByteSize = (v: string) => { try { return new Blob([v || '']).size } catch { return (v || '').length } } const dropOldest = useCallback(() => { const el = outputRef.current if (!el) return false const items = el.querySelectorAll('.p3xr-console-content-output-item') if (items.length < 1) return false const count = Math.max(Math.floor(items.length * 0.1), 1) for (let i = 0; i < count; i++) items[i].remove() return true }, []) const trimOutput = useCallback(() => { const el = outputRef.current if (!el) return while (getByteSize(el.innerHTML) > CONSOLE_OUTPUT_MAX) { if (!dropOldest()) break } }, [dropOldest]) const persistNow = useCallback(() => { const el = outputRef.current if (!el) return trimOutput() try { localStorage.setItem(CONSOLE_OUTPUT_KEY, el.innerHTML || '') } catch { try { localStorage.removeItem(CONSOLE_OUTPUT_KEY) } catch {} } }, [trimOutput]) const persistTimerRef = useRef(null) const persistDebounced = useCallback(() => { clearTimeout(persistTimerRef.current) persistTimerRef.current = setTimeout(persistNow, 100) }, [persistNow]) const scrollToBottom = useCallback(() => { setTimeout(() => { const s = scrollerRef.current if (!s) return if (s.scrollHeight - s.scrollTop - s.clientHeight < 100) { s.scrollTop = s.scrollHeight } }, 0) }, []) const forceScrollToBottom = useCallback(() => { // Double rAF + late setTimeout — survives late
 layout / large tool-trail renders.
        const doScroll = () => {
            const s = scrollerRef.current
            if (s) s.scrollTop = s.scrollHeight
        }
        requestAnimationFrame(() => requestAnimationFrame(doScroll))
        setTimeout(doScroll, 120)
    }, [])

    const outputAppend = useCallback((message: string) => {
        const el = outputRef.current
        if (!el) return
        const stripped = message.replace(/<[^>]*>/g, '').replace(/&[a-z]+;/g, '').trim()
        if (!stripped) return
        el.insertAdjacentHTML('beforeend',
            `${message}
`) trimOutput() persistDebounced() scrollToBottom() }, [trimOutput, persistDebounced, scrollToBottom]) // --- Init: restore output --- useEffect(() => { const el = outputRef.current if (!el) return let stored = '' try { stored = localStorage.getItem(CONSOLE_OUTPUT_KEY) || '' } catch {} if (stored) { el.innerHTML = stored trimOutput() persistNow() const items = el.querySelectorAll('.p3xr-console-content-output-item') const last = items.length > 0 ? items[items.length - 1] : null if (last) { const idx = Number(last.getAttribute('data-index')) if (Number.isFinite(idx)) indexRef.current = idx + 1 } forceScrollToBottom() } else { // Welcome message el.innerHTML = '' const welcome = strings?.label?.welcomeConsole ?? 'Welcome to the Redis Console' const info = strings?.label?.welcomeConsoleInfo ?? 'Shift + Cursor UP or DOWN for history' el.insertAdjacentHTML('beforeend', `${welcome}
`) el.insertAdjacentHTML('beforeend', `${info}
`) el.insertAdjacentHTML('beforeend', '
 
') persistNow() } }, []) // --- Clear --- const clearConsole = useCallback(() => { const el = outputRef.current if (!el) return el.innerHTML = '' const welcome = strings?.label?.welcomeConsole ?? 'Welcome to the Redis Console' const info = strings?.label?.welcomeConsoleInfo ?? 'Shift + Cursor UP or DOWN for history' outputAppend(`${welcome}`) outputAppend(info) el.insertAdjacentHTML('beforeend', '
 
') persistNow() forceScrollToBottom() inputRef.current?.focus() }, [strings, outputAppend, persistNow, forceScrollToBottom]) // --- History --- const getHistory = (): string[] => { try { return JSON.parse(localStorage.getItem('console-history') || '[]') } catch { return [] } } const updateHistory = (entry: string) => { let h = getHistory() const idx = h.indexOf(entry) if (idx > -1) h.splice(idx, 1) h.unshift(entry) if (h.length > 20) h = h.slice(0, 20) localStorage.setItem('console-history', JSON.stringify(h)) actionHistoryPosition = -1 } // --- Auto-resize --- const autoResize = useCallback(() => { const el = inputRef.current if (!el) return if (!singleLineHeightRef.current) singleLineHeightRef.current = el.offsetHeight const focused = document.activeElement === el if (!focused && (el.value || '').includes('\n')) { el.style.height = singleLineHeightRef.current + 'px' el.style.overflowY = 'hidden' return } el.style.height = singleLineHeightRef.current + 'px' el.style.overflowY = 'hidden' if ((el.value || '').includes('\n') && el.scrollHeight > el.clientHeight) { const max = singleLineHeightRef.current * 3 const border = el.offsetHeight - el.clientHeight const needed = el.scrollHeight + border if (needed > max) { el.style.height = max + 'px' el.style.overflowY = 'auto' } else { el.style.height = needed + 'px' } } }, []) const autocompleteListRef = useRef(null) useEffect(() => { setAutocompleteHighlight(0) }, [flatOptions.length]) // Scroll highlighted autocomplete item into view useEffect(() => { const list = autocompleteListRef.current if (!list) return const item = list.querySelector(`[data-ac-idx="${autocompleteHighlight}"]`) as HTMLElement if (item) item.scrollIntoView({ block: 'nearest' }) }, [autocompleteHighlight]) const selectAutocomplete = useCallback((cmdName: string) => { setSearchText(cmdName) setAutocompleteDismissed(true) setTimeout(() => { inputRef.current?.focus(); autoResize() }, 0) }, [autoResize]) const dismissAutocomplete = useCallback(() => { setAutocompleteDismissed(true) }, []) // --- Natural language detection --- const looksLikeNaturalLanguage = useCallback((input: string, errorMsg: string): boolean => { if (!/unknown command|wrong number of arguments|ERR unknown/i.test(errorMsg)) return false const firstWord = input.trim().split(/\s+/)[0].toUpperCase() if (commands?.includes(firstWord)) return false return true }, [commands]) // --- AI query --- const handleAiQuery = useCallback(async (prompt: string, originalInput: string): Promise => { if (prompt.length > 4096) { toast(strings?.error?.aiPromptTooLong) return false } const mySeq = ++aiRequestSeqRef.current setAiLoading(true) inputRef.current?.focus() try { let indexes: string[] = [] try { const r = await request({ action: 'search/list', payload: {} }); indexes = r.data || [] } catch {} const info = useRedisStateStore.getState().info || {} const server = info.server || {} const clients = info.clients || {} const memory = info.memory || {} const keyspace = info.keyspace || {} const modules = useRedisStateStore.getState().modules || [] const ctx: any = { indexes } if (server.redis_version) ctx.redisVersion = server.redis_version if (server.redis_mode) ctx.redisMode = server.redis_mode if (server.os) ctx.os = server.os if (clients.connected_clients) ctx.connectedClients = clients.connected_clients if (memory.used_memory_human) ctx.usedMemory = memory.used_memory_human const dbKeys = Object.keys(keyspace).filter((k: string) => /^db\d+$/.test(k)) if (dbKeys.length > 0) ctx.databases = dbKeys.map((k: string) => `${k}: ${keyspace[k]}`) if (modules.length > 0) ctx.modules = modules ctx.uiLanguage = useI18nStore.getState().currentLang const rs = useRedisStateStore.getState() ctx.connectionState = rs.connectionState ctx.currentPage = rs.currentPage if (rs.connection?.name) ctx.connectionName = rs.connection.name if (rs.currentDatabase !== undefined) ctx.currentDatabase = rs.currentDatabase const response = await request({ action: 'ai/redis-query', payload: { prompt, context: ctx } }) if (mySeq !== aiRequestSeqRef.current) return false const command = response.command || '' const explanation = response.explanation || '' const toolTrail = Array.isArray(response.toolTrail) ? response.toolTrail : [] outputAppend(`${htmlEncode(originalInput)}`) updateHistory(originalInput) // Print each tool call + outcome to the scrollback (transparency). for (const t of toolTrail) { const argsStr = t.args && Object.keys(t.args).length ? '(' + Object.entries(t.args).map(([k, v]) => `${k}=${JSON.stringify(v)}`).join(', ') + ')' : '()' const head = `tool: ${htmlEncode(t.name + argsStr)} ${t.ms ?? 0}ms` if (t.ok) { const preview = String(t.result ?? '').split('\n').slice(0, 12).join('\n') outputAppend(`${head}
${htmlEncode(preview)}
`) } else { outputAppend(`${head}
${htmlEncode(t.error || 'tool error')}`) } } // Tool-use investigations return the command as a suggestion — do NOT // auto-prefill the input. Pure translation path (no tools) prefills. const usedTools = toolTrail.length > 0 if (command) { let line = `AI → ${htmlEncode(command)}` if (explanation) line += `
${htmlEncode(explanation)}
` outputAppend(line) if (!usedTools) { setSearchText(command) setCurrentHint('') aiCommandPendingRef.current = true setTimeout(() => autoResize(), 0) } } else if (explanation) { outputAppend(`
${htmlEncode(explanation)}
`) } return true } catch (e: any) { if (mySeq !== aiRequestSeqRef.current) return false const msg = e.message || String(e) outputAppend(`AI error: ${htmlEncode(msg)}`) if (msg.includes('429') || msg.includes('rate_limit')) toast(strings?.page?.key?.label?.aiRateLimited) else toast(strings?.page?.key?.label?.aiError + ': ' + msg) return false } finally { if (mySeq === aiRequestSeqRef.current) { setAiLoading(false) forceScrollToBottom() inputRef.current?.focus() } } }, [muiTheme, strings, outputAppend, forceScrollToBottom, toast, autoResize]) const stopAi = useCallback(() => { aiRequestSeqRef.current++ setAiLoading(false) setSearchText('') setCurrentHint('') setTimeout(() => { autoResize() inputRef.current?.focus() }, 0) }, [autoResize]) // --- Execute --- const executeSingleLine = useCallback(async (command: string) => { const enter = command.trim() if (!enter) return if (aiEnabled && /^ai:\s*/i.test(enter)) { const prompt = enter.replace(/^ai:\s*/i, '').trim() if (prompt) await handleAiQuery(prompt, enter) return } try { const response = await request({ action: 'redis/console', payload: { command: enter } }) const result = htmlEncode(String(consoleParse(response.result))) outputAppend(`${htmlEncode(enter)}
${result}
`) if (response.hasOwnProperty('database')) { useRedisStateStore.setState({ currentDatabase: response.database, redisChanged: true }) } } catch (e: any) { const errorMsg = e.message || '' if (aiEnabled && aiAutoDetect && looksLikeNaturalLanguage(enter, errorMsg)) { if (await handleAiQuery(enter, enter)) return } const strs = useI18nStore.getState().strings outputAppend(`${htmlEncode(enter)}
${strs?.code?.[errorMsg] || errorMsg}
`) } }, [aiEnabled, aiAutoDetect, looksLikeNaturalLanguage, handleAiQuery, outputAppend]) const actionEnter = useCallback(async () => { const full = searchText.trim() if (!full || aiLoading) return try { const lines = full.split('\n').map(l => l.trim()).filter(l => l.length > 0) if (!lines.length) return const first = lines[0].split(/\s+/)[0].toUpperCase() const single = lines.length === 1 || first === 'EVAL' || first === 'EVALSHA' if (single) await executeSingleLine(full) else for (const line of lines) await executeSingleLine(line) } finally { updateHistory(full) setCurrentHint('') if (aiCommandPendingRef.current) aiCommandPendingRef.current = false else { setSearchText(''); setTimeout(() => autoResize(), 0) } forceScrollToBottom() if (embedded) useMainCommandStore.getState().refresh({ withoutParent: true, force: true }) inputRef.current?.focus() } }, [searchText, aiLoading, executeSingleLine, autoResize, forceScrollToBottom, embedded]) // --- Input change --- const onInputChange = useCallback((value: string) => { setSearchText(value) setAutocompleteDismissed(false) setAutocompleteNavigated(false) const first = value.trim().split(/\s+/)[0]?.toUpperCase() if (first && commandsMeta[first]?.syntax) setCurrentHint(first + ' ' + commandsMeta[first].syntax) else setCurrentHint('') setTimeout(() => autoResize(), 0) }, [commandsMeta, autoResize]) // --- Key handler --- const autocompleteOpen = flatOptions.length > 0 && !autocompleteDismissed const onKeyDown = useCallback((e: React.KeyboardEvent) => { // Tab — select highlighted autocomplete item if (e.key === 'Tab' && autocompleteOpen) { e.preventDefault() const opt = flatOptions[autocompleteHighlight] if (opt) selectAutocomplete(opt.name) return } if (e.key === 'Enter') { if (e.shiftKey) { setTimeout(() => autoResize(), 0); return } e.preventDefault() // If user navigated autocomplete, Enter selects the item if (autocompleteOpen && autocompleteNavigated) { const opt = flatOptions[autocompleteHighlight] if (opt) { selectAutocomplete(opt.name); return } } setAutocompleteDismissed(true) actionEnter() return } // Arrow keys — autocomplete navigation (without Shift) if (autocompleteOpen && !e.shiftKey && (e.key === 'ArrowDown' || e.key === 'ArrowUp')) { e.preventDefault() setAutocompleteNavigated(true) setAutocompleteHighlight(prev => { if (e.key === 'ArrowDown') return (prev + 1) % flatOptions.length return (prev - 1 + flatOptions.length) % flatOptions.length }) return } if (e.key === 'Escape') { setAutocompleteDismissed(true) return } if (e.key !== 'ArrowUp' && e.key !== 'ArrowDown') { actionHistoryPosition = -1; return } if (!e.shiftKey) return const history = getHistory() if (history.length < 1) return e.preventDefault(); e.stopPropagation() if (e.key === 'ArrowDown') { if (actionHistoryPosition === -1) actionHistoryPosition = history.length actionHistoryPosition-- if (actionHistoryPosition < 0) actionHistoryPosition = history.length - 1 } else { actionHistoryPosition++ if (actionHistoryPosition >= history.length) actionHistoryPosition = 0 } const value = history[actionHistoryPosition] ?? '' setSearchText(value) setTimeout(() => { const el = inputRef.current; if (el) { el.blur(); el.focus() }; autoResize() }, 0) }, [actionEnter, autoResize, flatOptions, autocompleteHighlight, selectAutocomplete, autocompleteDismissed, autocompleteNavigated, autocompleteOpen]) // --- Auto-resize when searchText changes (AI, history, etc.) --- useEffect(() => { requestAnimationFrame(() => autoResize()) }, [searchText, autoResize]) // --- Paste --- useEffect(() => { const el = inputRef.current if (!el) return const handler = () => setTimeout(() => autoResize(), 0) el.addEventListener('paste', handler) return () => el.removeEventListener('paste', handler) }, [autoResize]) // --- Cleanup --- useEffect(() => { return () => { clearTimeout(persistTimerRef.current) persistNow() } }, [persistNow]) return ( {/* Header toolbar — unified with DatabaseHeader (accordion bg + color) */} {strings?.label?.console} {aiEnabled && ( {aiAutoDetect ? : } Auto AI )} } onClick={() => setCheatsheetOpen(true)} /> } onClick={clearConsole} /> {showCloseButton && ( )} {/* Output area — hidden when collapsed */} {/* Autocomplete dropdown — opens ABOVE input via Popper */} {autocompleteOpen && !collapsed && inputRef.current && ( {filteredCommands.map(group => ( {group.group} {group.commands.map(cmd => { const idx = flatOptions.indexOf(cmd) return ( selectAutocomplete(cmd.name)} sx={{ minHeight: 32, lineHeight: '32px', px: 2, cursor: 'pointer', fontSize: 13, fontFamily: "'Roboto Mono', monospace", bgcolor: idx === autocompleteHighlight ? 'action.hover' : 'transparent', '&:hover': { bgcolor: 'action.hover' }, }}> {cmd.name} {cmd.syntax && {cmd.syntax}} ) })} ))} )} {/* Input area */} {currentHint && !collapsed && ( {currentHint} )}