.github/000077500000000000000000000000001517727315400124325ustar00rootroot00000000000000.github/workflows/000077500000000000000000000000001517727315400144675ustar00rootroot00000000000000.github/workflows/build.yml000066400000000000000000000011321517727315400163060ustar00rootroot00000000000000name: build on: schedule: - cron: '0 0 1 * *' push: branches: [ master ] pull_request: branches: [ master ] 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 .gitignore000066400000000000000000000005161517727315400130640ustar00rootroot00000000000000/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.npmignore000066400000000000000000000005741517727315400130770ustar00rootroot00000000000000/.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/**/*.*Gruntfile.js000066400000000000000000000036321517727315400133730ustar00rootroot00000000000000const utils = require('corifeus-utils'); 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) } }) } LICENSE000066400000000000000000000020131517727315400120730ustar00rootroot00000000000000MIT 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.md000066400000000000000000000315201517727315400123520ustar00rootroot00000000000000# 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.441 🌌 **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.scss000066400000000000000000000003531517727315400135030ustar00rootroot00000000000000@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.ts000066400000000000000000000054371517727315400131660ustar00rootroot00000000000000/** * 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/000077500000000000000000000000001517727315400122655ustar00rootroot00000000000000src/ng/app.routes.ts000066400000000000000000000061721517727315400147430ustar00rootroot00000000000000import { 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/000077500000000000000000000000001517727315400144525ustar00rootroot00000000000000src/ng/components/confirm-dialog.component.ts000066400000000000000000000042261517727315400217210ustar00rootroot00000000000000import { 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.ts000066400000000000000000000036571517727315400230310ustar00rootroot00000000000000import { 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.ts000066400000000000000000000202741517727315400207360ustar00rootroot00000000000000import { 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; overflow: auto; } .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.ts000066400000000000000000000145401517727315400201370ustar00rootroot00000000000000import { 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.ts000066400000000000000000000117141517727315400216620ustar00rootroot00000000000000import { 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.ts000066400000000000000000000075201517727315400212340ustar00rootroot00000000000000import { 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.ts000066400000000000000000000061721517727315400210620ustar00rootroot00000000000000import { 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/000077500000000000000000000000001517727315400137075ustar00rootroot00000000000000src/ng/dialogs/acl-user-dialog.component.ts000066400000000000000000000370451517727315400212410ustar00rootroot00000000000000import { 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.ts000066400000000000000000000016511517727315400206710ustar00rootroot00000000000000import { 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-settings-dialog.component.ts000066400000000000000000000114601517727315400217460ustar00rootroot00000000000000import { 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.ts000066400000000000000000000014541517727315400214060ustar00rootroot00000000000000import { 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.ts000066400000000000000000000070601517727315400231740ustar00rootroot00000000000000import { 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.ts000066400000000000000000000022211517727315400226240ustar00rootroot00000000000000import { 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.ts000066400000000000000000000122021517727315400225640ustar00rootroot00000000000000import { 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.ts000066400000000000000000000015731517727315400222330ustar00rootroot00000000000000import { 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.ts000066400000000000000000001110601517727315400216530ustar00rootroot00000000000000import { 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.ts000066400000000000000000000033001517727315400213060ustar00rootroot00000000000000import { 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.ts000066400000000000000000000051641517727315400166650ustar00rootroot00000000000000import { 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.ts000066400000000000000000000267701517727315400204410ustar00rootroot00000000000000import { 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.ts000066400000000000000000000025551517727315400200720ustar00rootroot00000000000000import { 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.ts000066400000000000000000000300731517727315400217550ustar00rootroot00000000000000import { 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.ts000066400000000000000000000032261517727315400214130ustar00rootroot00000000000000import { 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.ts000066400000000000000000000076211517727315400214440ustar00rootroot00000000000000import { 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.ts000066400000000000000000000017371517727315400211040ustar00rootroot00000000000000import { 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.ts000066400000000000000000000117461517727315400216260ustar00rootroot00000000000000import { 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.ts000066400000000000000000000020741517727315400212560ustar00rootroot00000000000000import { 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.ts000066400000000000000000000772261517727315400223210ustar00rootroot00000000000000import { 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.ts000066400000000000000000000023471517727315400217470ustar00rootroot00000000000000import { 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.ts000066400000000000000000000065611517727315400210460ustar00rootroot00000000000000import { 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.ts000066400000000000000000000454331517727315400237240ustar00rootroot00000000000000import { 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.ts000066400000000000000000000020551517727315400233530ustar00rootroot00000000000000import { 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.ts000066400000000000000000000120211517727315400203140ustar00rootroot00000000000000import { 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.ts000066400000000000000000000022131517727315400177540ustar00rootroot00000000000000import { 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/000077500000000000000000000000001517727315400136025ustar00rootroot00000000000000src/ng/layout/layout.component.html000066400000000000000000000323221517727315400200100ustar00rootroot00000000000000
@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 { }
src/ng/layout/layout.component.scss000066400000000000000000000053011517727315400200140ustar00rootroot00000000000000// 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: absolute; left: 0px; right: 0px; margin-bottom: v.$toolbar-height; } #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; padding-bottom: 0px !important; margin-top: v.$toolbar-height; } // 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.ts000066400000000000000000000563251517727315400175030ustar00rootroot00000000000000import { Component, Inject, OnInit, OnDestroy, HostListener, NgZone, ChangeDetectorRef, ChangeDetectionStrategy, CUSTOM_ELEMENTS_SCHEMA, ViewEncapsulation, ViewChild, ElementRef } 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'; /** * 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, ], 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(); } @HostListener('document:keydown', ['$event']) onKeydown(event: KeyboardEvent): void { this.shortcuts.handleKeydown(event); } 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); } } ngOnDestroy(): void { this.unsubFns.forEach(fn => fn()); } // --- 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...', }); 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, ); // No navigation — just refresh the current view in place } catch (error) { this.removeStorageItem(this.settings.connectInfoStorageKey); this.state.connection.set(undefined); 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.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 { if (/spider|bot|yahoo|bing|google|yandex|crawl|slurp|curl/i.test(navigator.userAgent)) return; const sub = this.router.events.pipe( filter((event): event is NavigationEnd => event instanceof NavigationEnd) ).subscribe((event) => { 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()); } /** * 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.ts000066400000000000000000000030551517727315400135640ustar00rootroot00000000000000import { 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'; 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/000077500000000000000000000000001517727315400133645ustar00rootroot00000000000000src/ng/pages/console/000077500000000000000000000000001517727315400150265ustar00rootroot00000000000000src/ng/pages/console/console.component.html000066400000000000000000000123601517727315400213610ustar00rootroot00000000000000
@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 (type === 'quick' && !embedded) {
}
@if (type !== 'quick') {
}
@if (currentHint) {
{{ currentHint }}
} @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.scss000066400000000000000000000126161517727315400213740ustar00rootroot00000000000000p3xr-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; color: white; 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: lighten on dark toolbar background (matches header buttons) &:hover { background-color: rgba(255, 255, 255, 0.15) !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(255, 255, 255, 0.15); } .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; #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-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.ts000066400000000000000000000727211517727315400210520ustar00rootroot00000000000000import { Component, Input, 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 { 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 { 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, 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; searchText = ''; searchControl = new FormControl(''); filteredCommands: { group: string; commands: { name: string; syntax: string }[] }[] = []; currentHint = ''; aiLoading = false; 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, ) { 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 { // Only try AI if Redis returned an unknown/wrong command error const isUnknownCmd = /unknown command|wrong number of arguments|ERR unknown/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; } this.aiLoading = true; (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(); const response = await this.socket.request({ action: 'ai/redis-query', payload: { prompt, context: redisContext, }, }); const command = response.command || ''; const explanation = response.explanation || ''; this.outputAppend(htmlEncode(originalInput)); this.updateCommandHistory(originalInput); if (command) { let aiLine = `AI → ${htmlEncode(command)}`; if (explanation) { aiLine += `
${htmlEncode(explanation)}`; } this.outputAppend(aiLine + '
'); this.searchText = command; this.searchControl.setValue(command, { emitEvent: false }); this.filteredCommands = []; this.aiCommandPending = true; setTimeout(() => this.autoResizeTextarea(), 0); } return true; } catch (e: any) { console.error('ai-redis-query failed', e); const errMsg = e.message || String(e); // 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 { this.aiLoading = false; this.forceScrollToBottom(); (this.inputEl as HTMLElement)?.focus(); } } 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 = ''; this.outputAppend('' + (this.i18n.strings().label?.welcomeConsole ?? 'Welcome to the Redis Console') + ''); this.outputAppend((this.i18n.strings().label?.welcomeConsoleInfo ?? 'Cursor UP or DOWN history is enabled') + '
'); this.persistConsoleOutputNow(); this.forceScrollToBottom(); (this.inputEl as HTMLElement)?.focus(); } openCommands(event: Event): void { window.open('https://redis.io/docs/latest/commands/', '_blank'); } 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 = debounce(() => this.rawResize(), 100); 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 { setTimeout(() => { if (!this.scrollers) return; this.scrollers.scrollTop = this.scrollers.scrollHeight; if (this.outputEl) this.outputEl.scrollTop = this.outputEl.scrollHeight; }, 0); } 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/000077500000000000000000000000001517727315400151305ustar00rootroot00000000000000src/ng/pages/database/database-header.component.ts000066400000000000000000000254551517727315400225060ustar00rootroot00000000000000import { 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.html000066400000000000000000000246471517727315400223660ustar00rootroot00000000000000@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.scss000066400000000000000000000056551517727315400223730ustar00rootroot00000000000000.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.ts000066400000000000000000000413051517727315400220360ustar00rootroot00000000000000import { 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.html000066400000000000000000000126541517727315400225300ustar00rootroot00000000000000
@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.scss000066400000000000000000000071461517727315400225370ustar00rootroot00000000000000// 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%; } // 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.ts000066400000000000000000000465571517727315400222230ustar00rootroot00000000000000import { 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.html000066400000000000000000000167711517727315400257760ustar00rootroot00000000000000
@if (treeDividers.length > 0) { @for (divider of treeDividers; track divider) { } }
@if (pages > 1) { / {{ pages }} } @else { {{ keyCountText() }}  } src/ng/pages/database/database-treecontrol-controls.component.scss000066400000000000000000000111101517727315400257630ustar00rootroot00000000000000: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.ts000066400000000000000000000364531517727315400254570ustar00rootroot00000000000000import { 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.html000066400000000000000000000034271517727315400215710ustar00rootroot00000000000000
src/ng/pages/database/database.component.scss000066400000000000000000000046111517727315400215740ustar00rootroot00000000000000@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-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; } #p3xr-database-content.p3xr-database-content-with-bottom-console { position: relative; overflow-x: hidden; &::after { content: ''; position: absolute; left: 0; right: 0; bottom: 0; height: 2px; background: inherit; pointer-events: none; z-index: 1; } } .p3xr-database-bottom-console-mobile { height: 33vh; min-height: 220px; margin-top: auto; border-top: 1px solid rgba(255, 255, 255, 0.16); overflow-x: hidden; } @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); } } #p3xr-database-bottom-console-panel { position: fixed; overflow: hidden; box-sizing: border-box; border-top: 1px solid rgba(255, 255, 255, 0.16); z-index: 9; } src/ng/pages/database/database.component.ts000066400000000000000000000464031517727315400212540ustar00rootroot00000000000000import { Component, Inject, OnInit, OnDestroy, NgZone, ChangeDetectorRef, CUSTOM_ELEMENTS_SCHEMA, ViewEncapsulation } from '@angular/core'; import { CommonModule } from '@angular/common'; import { RouterModule } from '@angular/router'; import { BreakpointObserver } from '@angular/cdk/layout'; import { I18nService } from '../../services/i18n.service'; import { MainCommandService } from '../../services/main-command.service'; import { NavigationService } from '../../services/navigation.service'; import { SocketService } from '../../services/socket.service'; import { RedisStateService } from '../../services/redis-state.service'; import { SettingsService } from '../../services/settings.service'; import { DatabaseHeaderComponent } from './database-header.component'; import { DatabaseTreecontrolControlsComponent } from './database-treecontrol-controls.component'; import { DatabaseTreeComponent } from './database-tree.component'; import { ConsoleComponent } from '../console/console.component'; import { debounce } from 'lodash-es'; @Component({ selector: 'p3xr-database', standalone: true, imports: [ CommonModule, RouterModule, DatabaseHeaderComponent, DatabaseTreecontrolControlsComponent, DatabaseTreeComponent, ConsoleComponent, ], schemas: [CUSTOM_ELEMENTS_SCHEMA], templateUrl: './database.component.html', styleUrls: ['./database.component.scss'], styles: [` :host { display: block; } `], encapsulation: ViewEncapsulation.None, }) export class DatabaseComponent implements OnInit, OnDestroy { readonly strings; isXs = false; hasConnection = false; hasConnections = false; resizerActive = false; resizeClicked = false; private resizerMouseoverOn = false; private resizeLeft: number | undefined = undefined; private static readonly PANEL_WIDTH_KEY = 'p3xr-database-panel-width'; private bottomConsoleExpanded = false; private screenSizeIsSmall = false; private containerEl!: HTMLElement; private headerEl!: HTMLElement; private footerEl!: HTMLElement; private consoleHeaderEl!: HTMLElement; private resizerEl: HTMLElement | undefined; private resizeObserver!: ResizeObserver; private observedElement: HTMLElement | null = null; private resizeTimeoutId: any; private readonly unsubs: Array<() => void> = []; private readonly resizeMinWidth: number; private get bottomConsoleCollapsedHeight(): number { const panel = document.getElementById('p3xr-database-bottom-console-panel'); if (panel) { const toolbar = panel.querySelector('#p3xr-console-header') as HTMLElement; const autocomplete = panel.querySelector('#p3xr-console-autocomplete') as HTMLElement; if (toolbar && autocomplete) { // +1 for the panel's border-top return toolbar.offsetHeight + autocomplete.offsetHeight + 1; } } return 88; } constructor( @Inject(NgZone) private readonly ngZone: NgZone, @Inject(BreakpointObserver) private readonly breakpointObserver: BreakpointObserver, @Inject(I18nService) private readonly i18n: I18nService, @Inject(MainCommandService) private readonly cmd: MainCommandService, @Inject(NavigationService) private readonly nav: NavigationService, @Inject(SocketService) private readonly socket: SocketService, @Inject(ChangeDetectorRef) private readonly cdr: ChangeDetectorRef, @Inject(RedisStateService) private readonly state: RedisStateService, @Inject(SettingsService) private readonly settings: SettingsService, ) { this.strings = this.i18n.strings; this.resizeMinWidth = this.settings.resizeMinWidth; } 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(); this.nav.navigateTo('settings'); }); const sub3 = this.socket.configuration$.subscribe(() => this.syncFromGlobal()); const sub4 = this.socket.stateChanged$.subscribe(() => { this.syncFromGlobal(); setTimeout(() => this.rawResize(), 50); }); this.unsubs.push(() => { sub1.unsubscribe(); sub2.unsubscribe(); sub3.unsubscribe(); sub4.unsubscribe(); }); const xsSub = this.breakpointObserver.observe('(max-width: 599px)').subscribe(result => { const wasSmall = this.isXs; this.isXs = result.matches; if (!this.isXs && wasSmall) { clearTimeout(this.resizeTimeoutId); this.resizeTimeoutId = setTimeout(() => this.rawResize(), 4 * this.settings.debounce); } this.screenSizeIsSmall = this.isXs; this.cdr.markForCheck(); }); this.unsubs.push(() => xsSub.unsubscribe()); // Init DOM references this.ngZone.runOutsideAngular(() => { setTimeout(() => this.initDom(), 0); }); } ngOnDestroy(): void { this.unsubs.forEach(fn => fn()); window.removeEventListener('resize', this.boundRawResize); document.removeEventListener('mousedown', this.boundOnDocumentMouseDown); this.destroyResizer(); this.resizeObserver?.disconnect(); } // --- Template methods --- goSettings(): void { this.nav.navigateTo('settings'); } // --- Resize engine (ported from AngularJS) --- readonly resize = debounce(() => { this.resizeLeft = undefined; this.rawResize(); }, 100); private readonly boundRawResize = () => this.rawResize(); private readonly boundOnDocumentMouseDown = (e: MouseEvent) => this.onDocumentMouseDown(e); private initDom(): void { this.containerEl = document.getElementById('p3xr-database-content')!; this.headerEl = document.getElementById('p3xr-layout-header-container')!; this.footerEl = document.getElementById('p3xr-layout-footer-container')!; this.consoleHeaderEl = document.querySelector('p3xr-database-header') as HTMLElement; // Load saved panel width and convert to absolute position const savedWidth = localStorage.getItem(DatabaseComponent.PANEL_WIDTH_KEY); if (savedWidth && this.containerEl) { const width = parseInt(savedWidth, 10); if (!isNaN(width) && width >= this.resizeMinWidth) { const containerLeft = this.containerEl.getBoundingClientRect().left; this.resizeLeft = containerLeft + width; } } this.rawResize(); window.addEventListener('resize', this.boundRawResize); document.addEventListener('mousedown', this.boundOnDocumentMouseDown); // Navigate to statistics if on bare /database if (this.nav.currentUrl === '/database' || this.nav.currentUrl === '/database/') { this.nav.navigateTo('database.statistics'); } if (this.state.redisChanged()) { this.state.redisChanged.set(false); if (this.state.connection()) { this.cmd.refresh(); } } this.state.page.set(1); setTimeout(() => this.rawResize(), 250); // ResizeObserver for tree controls this.resizeObserver = new ResizeObserver(entries => { if (!this.resizeClicked) { window.requestAnimationFrame(() => { if (!Array.isArray(entries) || !entries.length) return; this.rawResize(); }); } }); this.watchResizeObserver(); // Listen for events via Angular services const consoleSub1 = this.cmd.consoleActivate$.subscribe(() => { if (!this.isXs && !this.bottomConsoleExpanded) { this.bottomConsoleExpanded = true; this.rawResize(); this.cmd.consoleEmbeddedResize$.next(); } }); const consoleSub2 = this.cmd.consoleDeactivate$.subscribe(() => { if (!this.isXs && this.bottomConsoleExpanded) { this.bottomConsoleExpanded = false; this.rawResize(); this.cmd.consoleEmbeddedResize$.next(); } }); const stateSub = this.socket.stateChanged$.subscribe(() => this.watchResizeObserver()); this.unsubs.push(() => { consoleSub1.unsubscribe(); consoleSub2.unsubscribe(); stateSub.unsubscribe(); }); } private rawResize(): void { if (!this.containerEl || !this.headerEl || !this.footerEl || !this.consoleHeaderEl) return; let minus = 0; for (const el of [this.headerEl, this.footerEl, this.consoleHeaderEl]) { minus += el.offsetHeight; } const windowHeight = window.innerHeight; const outputPositionMinus = 11; const bottomConsolePanel = document.getElementById('p3xr-database-bottom-console-panel'); const isDesktop = !this.isXs; let bottomConsoleHeight = 0; const hasDesktopConsole = isDesktop && this.state.connection() !== undefined; const availableHeight = Math.max(windowHeight - minus - outputPositionMinus, 100); if (hasDesktopConsole) { bottomConsoleHeight = this.getBottomConsoleHeight(availableHeight); } const containerHeight = Math.max(availableHeight, 0); this.containerEl.style.height = containerHeight + 'px'; this.containerEl.style.maxHeight = containerHeight + 'px'; const containerPosition = this.containerEl.getBoundingClientRect(); if (!containerPosition || !Number.isFinite(containerPosition.height) || !Number.isFinite(containerPosition.width)) { return; } const contentAreaHeight = Math.max(containerPosition.height - bottomConsoleHeight, 0); // Bottom console panel if (bottomConsolePanel) { if (hasDesktopConsole && bottomConsoleHeight > 0) { const s = bottomConsolePanel.style; s.display = 'block'; s.position = 'absolute'; s.top = 'auto'; s.left = '-1px'; s.height = bottomConsoleHeight + 'px'; s.width = 'auto'; s.right = '-1px'; s.bottom = '0'; } else { bottomConsolePanel.style.display = 'none'; } } // Tree control const treeControl = document.getElementById('p3xr-database-treecontrol-container'); if (treeControl) { const treeControlControls = document.getElementById('p3xr-database-treecontrol-controls-container'); if (!treeControlControls) { this.destroyResizer(); return; } const treeControlControlsPosition = treeControlControls.getBoundingClientRect(); treeControl.style.top = (containerPosition.top + treeControlControlsPosition.height) + 'px'; treeControl.style.left = containerPosition.left + 'px'; treeControl.style.height = (contentAreaHeight - treeControlControlsPosition.height) + 'px'; treeControl.style.maxHeight = contentAreaHeight + 'px'; if (this.resizeLeft !== undefined) { treeControl.style.width = (this.resizeLeft - containerPosition.left) + 'px'; } else { treeControl.style.width = this.resizeMinWidth + 'px'; } treeControl.style.minWidth = this.resizeMinWidth + 'px'; const treeControlPosition = treeControl.getBoundingClientRect(); if (!this.resizerEl) { this.decorateResizer(); } const resizerWidth = 5; if (this.resizerEl) { this.resizerEl = document.getElementById('p3xr-database-content-sizer')!; if (this.resizerEl) { this.resizerEl.addEventListener('mouseover', this.boundResizerMouseover); this.resizerEl.addEventListener('mouseout', this.boundResizerMouseout); this.resizerEl.style.top = containerPosition.top + 'px'; const resizerHeight = Math.max(contentAreaHeight - (bottomConsoleHeight > 0 ? 1 : 0), 0); this.resizerEl.style.height = resizerHeight + 'px'; this.resizerEl.style.left = (containerPosition.left + treeControlPosition.width) + 'px'; this.resizerEl.style.width = resizerWidth + 'px'; treeControlControls.style.width = (containerPosition.left + treeControlPosition.width) + 'px'; } } const content = document.getElementById('p3xr-database-content-container'); if (content) { content.style.top = containerPosition.top + 'px'; content.style.height = contentAreaHeight + 'px'; content.style.left = (containerPosition.left + treeControlPosition.width + resizerWidth) + 'px'; content.style.width = (containerPosition.width - treeControlPosition.width - resizerWidth) + 'px'; } treeControlControls.style.width = treeControlPosition.width + 'px'; } else { this.destroyResizer(); } if (hasDesktopConsole && bottomConsoleHeight > 0) { this.cmd.consoleEmbeddedResize$.next(); } } private getBottomConsoleHeight(containerHeight: number): number { if (this.bottomConsoleExpanded) { let expandedHeight = Math.max(Math.floor(containerHeight * 0.33), 220); expandedHeight = Math.min(expandedHeight, Math.max(containerHeight - 120, this.bottomConsoleCollapsedHeight)); return expandedHeight; } return this.bottomConsoleCollapsedHeight; } // --- Resizer drag --- private readonly boundResizerMouseover = () => { this.resizerMouseoverOn = true; this.updateResizerColor(); }; private readonly boundResizerMouseout = () => { this.resizerMouseoverOn = false; this.updateResizerColor(); }; private readonly boundResizeClick = (event: MouseEvent) => this.resizeClick(event); private readonly boundDocumentMousemove = (event: MouseEvent) => this.documentMousemove(event); private updateResizerColor(): void { this.resizerActive = this.resizeClicked || this.resizerMouseoverOn; } private resizeClick(event: MouseEvent): void { if (event.type === 'mousedown' && (event.target as HTMLElement).id !== 'p3xr-database-content-sizer') return; if (event.type === 'mousedown') { this.resizeClicked = true; document.documentElement.style.cursor = 'ew-resize'; document.body.classList.add('p3xr-not-selectable'); } else if (event.type === 'mouseup') { document.documentElement.style.cursor = 'auto'; this.resizeClicked = false; document.body.classList.remove('p3xr-not-selectable'); // Persist panel width if (this.resizeLeft !== undefined && this.containerEl) { const containerLeft = this.containerEl.getBoundingClientRect().left; const width = this.resizeLeft - containerLeft; if (width >= this.resizeMinWidth) { localStorage.setItem(DatabaseComponent.PANEL_WIDTH_KEY, String(width)); } } } if (!this.resizeClicked) { this.rawResize(); } event.stopPropagation(); this.updateResizerColor(); } private documentMousemove(event: MouseEvent): void { if (!this.resizeClicked || !this.containerEl) return; const containerPosition = this.containerEl.getBoundingClientRect(); if (event.clientX < containerPosition.left + this.resizeMinWidth || event.clientX > window.innerWidth - this.resizeMinWidth) { document.documentElement.style.cursor = 'not-allowed'; } else { document.documentElement.style.cursor = 'ew-resize'; if (this.resizerEl) { this.resizerEl.style.left = event.clientX + 'px'; } this.resizeLeft = event.clientX; this.rawResize(); } } private decorateResizer(): void { this.resizerEl = document.getElementById('p3xr-database-content-sizer') ?? undefined; if (!this.resizerEl) return; this.resizerEl.addEventListener('mouseover', this.boundResizerMouseover); this.resizerEl.addEventListener('mouseout', this.boundResizerMouseout); document.addEventListener('mousemove', this.boundDocumentMousemove); document.addEventListener('mousedown', this.boundResizeClick); document.addEventListener('mouseup', this.boundResizeClick); } private destroyResizer(): void { if (this.resizerEl) { this.resizerEl.removeEventListener('mouseover', this.boundResizerMouseover); this.resizerEl.removeEventListener('mouseout', this.boundResizerMouseout); this.resizerEl = undefined; } document.removeEventListener('mousedown', this.boundResizeClick); document.removeEventListener('mouseup', this.boundResizeClick); document.removeEventListener('mousemove', this.boundDocumentMousemove); } // --- Bottom console expand/collapse --- private onDocumentMouseDown(event: MouseEvent): void { const bottomConsolePanel = document.getElementById('p3xr-database-bottom-console-panel'); if (this.isXs || !bottomConsolePanel) return; if (bottomConsolePanel.contains(event.target as Node)) { // Toolbar action buttons/checkboxes: keep current state const actions = bottomConsolePanel.querySelector('.p3xr-console-toolbar-actions'); if (actions && actions.contains(event.target as Node)) return; // Console content, input, toolbar title: expand if (!this.bottomConsoleExpanded) { this.bottomConsoleExpanded = true; this.rawResize(); this.cmd.consoleEmbeddedResize$.next(); } return; } if (this.bottomConsoleExpanded) { this.bottomConsoleExpanded = false; this.rawResize(); this.cmd.consoleEmbeddedResize$.next(); } } // --- ResizeObserver for tree controls --- private async watchResizeObserver(): Promise { if (this.observedElement) { this.resizeObserver.unobserve(this.observedElement); } if (!this.state.connection()) return; if (this.isXs) { this.rawResize(); return; } let elem: HTMLElement | null = null; while (elem === null) { elem = document.getElementById('p3xr-database-treecontrol-controls-container'); if (!elem) { await new Promise(resolve => setTimeout(resolve)); } } this.observedElement = elem; this.resizeObserver.observe(this.observedElement); } // --- State sync --- private syncFromGlobal(): void { this.hasConnection = this.state.connection() !== undefined; this.hasConnections = (this.state.connections()?.list?.length ?? 0) > 0; } } src/ng/pages/database/key/000077500000000000000000000000001517727315400157205ustar00rootroot00000000000000src/ng/pages/database/key/hex-monitor.component.ts000066400000000000000000000110711517727315400225420ustar00rootroot00000000000000import { Component, Input, ViewEncapsulation, ViewChild, ElementRef, AfterViewInit, OnDestroy, ChangeDetectorRef } from '@angular/core'; interface HexLine { addr: string; hexReal: string; hexPad: string; asciiReal: string; asciiPad: string; } @Component({ selector: 'p3xr-hex-monitor', standalone: true, template: `
@for (line of lines; track line.addr) {
{{ line.addr }} {{ line.hexReal }}{{ line.hexPad }} {{ line.asciiReal }}{{ line.asciiPad }}
}
`, encapsulation: ViewEncapsulation.None, styles: [`:host { display: block; }`], }) export class HexMonitorComponent implements AfterViewInit, OnDestroy { lines: HexLine[] = []; contentScrollWidth = 0; @Input() truncated: boolean = false; @ViewChild('hexContent') contentRef!: ElementRef; @ViewChild('hexScrollbar') scrollbarRef!: ElementRef; private _value = ''; private resizeObs: ResizeObserver | null = null; private viewReady = false; constructor(private cdr: ChangeDetectorRef) {} @Input() set value(val: string) { this._value = val ?? ''; this.lines = HexMonitorComponent.parseHexLines(this._value); if (this.viewReady) { requestAnimationFrame(() => this.measure()); } } get value(): string { return this._value; } ngAfterViewInit(): void { this.viewReady = true; this.measure(); this.resizeObs = new ResizeObserver(() => { this.measure(); this.cdr.detectChanges(); }); if (this.contentRef?.nativeElement) { this.resizeObs.observe(this.contentRef.nativeElement); } } ngOnDestroy(): void { this.resizeObs?.disconnect(); } syncScroll(): void { if (this.contentRef?.nativeElement && this.scrollbarRef?.nativeElement) { this.contentRef.nativeElement.scrollLeft = this.scrollbarRef.nativeElement.scrollLeft; } } private measure(): void { const el = this.contentRef?.nativeElement; if (!el) return; this.contentScrollWidth = el.scrollWidth; } static parseHexLines(str: string): HexLine[] { if (!str) return []; const encoded = new TextEncoder().encode(str); const lines: HexLine[] = []; for (let i = 0; i < encoded.length; i += 16) { const chunk = encoded.slice(i, i + 16); const n = chunk.length; const addr = i.toString(16).padStart(8, '0'); const padded = new Uint8Array(16); padded.set(chunk); const left = Array.from(padded.slice(0, 8)).map(b => b.toString(16).padStart(2, '0')).join(' '); const right = Array.from(padded.slice(8)).map(b => b.toString(16).padStart(2, '0')).join(' '); const full = left + ' ' + right; if (n === 16) { const ascii = Array.from(padded).map(b => b >= 0x20 && b <= 0x7e ? String.fromCharCode(b) : '.').join(''); lines.push({ addr, hexReal: full, hexPad: '', asciiReal: ascii, asciiPad: '' }); } else { const splitPos = n <= 8 ? 3 * n - 1 : 25 + 3 * (n - 8) - 1; const asciiAll = Array.from(padded).map(b => b >= 0x20 && b <= 0x7e ? String.fromCharCode(b) : '.').join(''); lines.push({ addr, hexReal: full.substring(0, splitPos), hexPad: full.substring(splitPos), asciiReal: asciiAll.substring(0, n), asciiPad: asciiAll.substring(n), }); } } return lines; } } src/ng/pages/database/key/key-hash.component.html000066400000000000000000000053261517727315400223260ustar00rootroot00000000000000
{{ strings?.page?.key?.hash?.table?.hashkey }} {{ strings?.page?.key?.hash?.table?.value }} @if (!isReadonly) { }
@for (item of pagedItems; track item.key) {
{{ item.key }} @if (valueFormat === 'hex') {} @else {{{ truncateDisplay(formatValue(item.value)) }}@if (isTruncated(item.value)) {...}} @if (!isReadonly && redisState.redisVersion().isAtLeast(8, 0)) { schedule } @if (!isReadonly) { delete } account_tree content_copy download @if (!isReadonly) { edit }
}
src/ng/pages/database/key/key-hash.component.ts000066400000000000000000000217261517727315400220120ustar00rootroot00000000000000import { Component, Inject, OnInit, OnChanges, OnDestroy, SimpleChanges, ChangeDetectorRef, CUSTOM_ELEMENTS_SCHEMA, ViewEncapsulation } from '@angular/core'; import { CommonModule } from '@angular/common'; import { FormsModule } from '@angular/forms'; 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'; import { SocketService } from '../../../services/socket.service'; import { CommonService } from '../../../services/common.service'; import { JsonViewDialogService } from '../../../dialogs/json-view-dialog.service'; import { KeyNewOrSetDialogService } from '../../../dialogs/key-new-or-set-dialog.service'; import { MainCommandService } from '../../../services/main-command.service'; import { RedisStateService } from '../../../services/redis-state.service'; import { SettingsService } from '../../../services/settings.service'; import { KeyTypeBase } from './key-type-base'; import { HexMonitorComponent } from './hex-monitor.component'; import { KeyPaging } from './key-paging'; import { KeyPagerInlineComponent } from './key-pager-inline.component'; import { TtlDialogService } from '../../../dialogs/ttl-dialog.service'; import humanizeDuration from 'humanize-duration'; @Component({ selector: 'p3xr-key-hash', standalone: true, imports: [CommonModule, FormsModule, MatButtonModule, MatIconModule, MatTooltipModule, KeyPagerInlineComponent, HexMonitorComponent], schemas: [CUSTOM_ELEMENTS_SCHEMA], templateUrl: './key-hash.component.html', encapsulation: ViewEncapsulation.None, }) export class KeyHashComponent extends KeyTypeBase implements OnInit, OnChanges, OnDestroy { paging: KeyPaging; pagedItems: Array<{ key: string; value: any }> = []; fieldTtls: Record = {}; private fieldTtlsFetchedAt = 0; private ttlCountdownInterval: any = null; constructor( @Inject(I18nService) i18n: I18nService, @Inject(SocketService) socket: SocketService, @Inject(CommonService) common: CommonService, @Inject(JsonViewDialogService) jsonViewDialog: JsonViewDialogService, @Inject(KeyNewOrSetDialogService) keyNewOrSetDialog: KeyNewOrSetDialogService, @Inject(BreakpointObserver) breakpointObserver: BreakpointObserver, @Inject(MainCommandService) cmd: MainCommandService, @Inject(ChangeDetectorRef) cdr: ChangeDetectorRef, @Inject(RedisStateService) redisState: RedisStateService, @Inject(SettingsService) settingsService: SettingsService, @Inject(TtlDialogService) private ttlDialog: TtlDialogService, ) { super(i18n, socket, common, jsonViewDialog, keyNewOrSetDialog, breakpointObserver, cmd, cdr, redisState, settingsService); this.paging = new KeyPaging({ settingsService }); } ngOnInit(): void { this.updatePaging(); this.loadFieldTtls(); } ngOnChanges(changes: SimpleChanges): void { if (changes['p3xrValue']) { this.updatePaging(); this.loadFieldTtls(); } } updatePaging(): void { if (!this.p3xrValue) return; this.paging.figurePaging(Object.keys(this.p3xrValue).length); this.updatePagedItems(); } updatePagedItems(): void { if (!this.p3xrValue) { this.pagedItems = []; return; } const keys = Object.keys(this.p3xrValue); this.pagedItems = keys.slice(this.paging.startIndex, this.paging.endIndex) .map(k => ({ key: k, value: this.p3xrValue[k] })); } async addHash(event: Event): Promise { try { await this.keyNewOrSetDialog.show({ $event: event, type: 'append', model: { type: 'hash', key: this.p3xrKey } }); this.refreshKey(); } catch (e) { this.common.generalHandleError(e); } } async deleteHashKey(hashKey: string, event: Event): Promise { try { await this.common.confirm({ message: this.i18n.strings().confirm?.deleteHashKey }); await this.socket.request({ action: 'key/hash-delete-field', payload: { key: this.p3xrKey, hashKey } }); this.common.toast(this.i18n.strings().status?.deletedHashKey); this.refreshKey(); } catch (e) { this.common.generalHandleError(e); } } async editValue(hashKey: string, value: any, event: Event): Promise { try { const editValue = typeof value === 'string' && value.length >= this.maxValueAsBuffer ? this.p3xrValueBuffer[hashKey] : value; await this.keyNewOrSetDialog.show({ $event: event, type: 'edit', model: { type: 'hash', key: this.p3xrKey, hashKey, value: editValue }, }); this.refreshKey(); } catch (e) { this.common.generalHandleError(e); } } ngOnDestroy(): void { this.clearTtlCountdown(); } private clearTtlCountdown(): void { if (this.ttlCountdownInterval) { clearInterval(this.ttlCountdownInterval); this.ttlCountdownInterval = null; } } private startTtlCountdown(): void { this.clearTtlCountdown(); const hasAnyTtl = Object.values(this.fieldTtls).some(t => t > 0); if (!hasAnyTtl) return; this.ttlCountdownInterval = setInterval(() => { // Check if any field just expired — refresh the key view const anyExpired = Object.keys(this.fieldTtls).some(f => { const ttl = this.fieldTtls[f]; if (!ttl || ttl <= 0) return false; const elapsed = Math.floor((Date.now() - this.fieldTtlsFetchedAt) / 1000); return ttl - elapsed <= 0; }); if (anyExpired) { this.clearTtlCountdown(); this.refreshKey(); return; } this.cdr.markForCheck(); }, 1000); } private getRemainingFieldTtl(field: string): number { const ttl = this.fieldTtls[field]; if (!ttl || ttl <= 0) return -1; const elapsed = Math.floor((Date.now() - this.fieldTtlsFetchedAt) / 1000); const remaining = ttl - elapsed; return remaining > 0 ? remaining : -1; } async loadFieldTtls(): Promise { if (!this.redisState.redisVersion().isAtLeast(8, 0) || !this.pagedItems.length) return; try { const fields = this.pagedItems.map(item => item.key); if (fields.length === 0) return; const response = await this.socket.request({ action: 'hash-field/ttls', payload: { key: this.p3xrKey, fields }, }); this.fieldTtls = (response as any).fieldTtls || {}; this.fieldTtlsFetchedAt = Date.now(); this.startTtlCountdown(); this.cdr.markForCheck(); } catch { this.fieldTtls = {}; } } getFieldTtlColor(field: string): string { const remaining = this.getRemainingFieldTtl(field); if (remaining <= 0) return ''; if (remaining < 300) return 'var(--mat-sys-error, #f44336)'; if (remaining < 3600) return 'var(--mat-sys-tertiary, #ff9800)'; return '#4caf50'; } isFieldTtlPulsing(field: string): boolean { const remaining = this.getRemainingFieldTtl(field); return remaining > 0 && remaining < 30; } hasFieldTtl(field: string): boolean { return this.getRemainingFieldTtl(field) > 0; } formatFieldTtl(field: string): string { const remaining = this.getRemainingFieldTtl(field); if (remaining <= 0) return ''; const hdOpts = this.settingsService.getHumanizeDurationOptions(); return humanizeDuration(remaining * 1000, { ...hdOpts, largest: 2, round: true, delimiter: ' ' }); } async setFieldTtl(hashKey: string, event: Event): Promise { try { // Get current field TTL const ttlResponse = await this.socket.request({ action: 'hash-field/ttl-get', payload: { key: this.p3xrKey, field: hashKey }, }); const currentTtl = ttlResponse.ttl ?? -1; const result = await this.ttlDialog.show({ $event: event, model: { ttl: currentTtl } }); await this.socket.request({ action: 'hash-field/ttl', payload: { key: this.p3xrKey, field: hashKey, ttl: result.model.ttl }, }); this.common.toast(`${hashKey}: TTL ${result.model.ttl === -1 ? 'removed' : result.model.ttl + 's'}`); this.loadFieldTtls(); } catch (e: any) { if (e !== undefined) this.common.generalHandleError(e); } } copyItem(value: any): void { this.copy(value); } showJsonItem(value: any, event: Event): void { this.showJson(value, event); } downloadItem(hashKey: string): void { this.downloadBuffer(this.p3xrValueBuffer[hashKey], `${this.p3xrKey}-${hashKey}`); } } src/ng/pages/database/key/key-json.component.html000066400000000000000000000074041517727315400223530ustar00rootroot00000000000000
@if (isGtSm) { } @else { } @if (isGtSm) { } @else { } @if (isGtSm) { } @else { } @if (isGtSm) { } @else { } @if (isGtSm) { } @else { } @if (!isReadonly) { @if (isGtSm) { } @else { } }
@if (jsonObj !== undefined) { } @else {
{{ truncateDisplay(p3xrValue) }}
}
src/ng/pages/database/key/key-json.component.ts000066400000000000000000000130241517727315400220300ustar00rootroot00000000000000import { Component, Inject, OnInit, OnDestroy, OnChanges, SimpleChanges, ChangeDetectorRef, ViewChild, CUSTOM_ELEMENTS_SCHEMA, ViewEncapsulation } 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 { MainCommandService } from '../../../services/main-command.service'; import { I18nService } from '../../../services/i18n.service'; import { SocketService } from '../../../services/socket.service'; import { CommonService } from '../../../services/common.service'; import { JsonViewDialogService } from '../../../dialogs/json-view-dialog.service'; import { JsonEditorDialogService } from '../../../dialogs/json-editor-dialog.service'; import { KeyNewOrSetDialogService } from '../../../dialogs/key-new-or-set-dialog.service'; import { RedisStateService } from '../../../services/redis-state.service'; import { SettingsService } from '../../../services/settings.service'; import { JsonTreeComponent } from '../../../components/json-tree.component'; import { KeyTypeBase } from './key-type-base'; import { OverlayService } from '../../../services/overlay.service'; import { DiffDialogService } from '../../../dialogs/diff-dialog.service'; @Component({ selector: 'p3xr-key-json', standalone: true, imports: [CommonModule, MatButtonModule, MatIconModule, MatTooltipModule, JsonTreeComponent], schemas: [CUSTOM_ELEMENTS_SCHEMA], templateUrl: './key-json.component.html', encapsulation: ViewEncapsulation.None, }) export class KeyJsonComponent extends KeyTypeBase implements OnInit, OnDestroy, OnChanges { @ViewChild(JsonTreeComponent) jsonTree?: JsonTreeComponent; jsonObj: any; treeExpanded: boolean | 'recursive' = true; treeWrap = true; constructor( @Inject(I18nService) i18n: I18nService, @Inject(SocketService) socket: SocketService, @Inject(CommonService) common: CommonService, @Inject(JsonViewDialogService) jsonViewDialog: JsonViewDialogService, @Inject(KeyNewOrSetDialogService) keyNewOrSetDialog: KeyNewOrSetDialogService, @Inject(BreakpointObserver) breakpointObserver: BreakpointObserver, @Inject(JsonEditorDialogService) private jsonEditorDialog: JsonEditorDialogService, @Inject(MainCommandService) cmd: MainCommandService, @Inject(ChangeDetectorRef) cdr: ChangeDetectorRef, @Inject(RedisStateService) redisState: RedisStateService, @Inject(SettingsService) settingsService: SettingsService, @Inject(OverlayService) private overlay: OverlayService, @Inject(DiffDialogService) private diffDialog: DiffDialogService, ) { super(i18n, socket, common, jsonViewDialog, keyNewOrSetDialog, breakpointObserver, cmd, cdr, redisState, settingsService); } ngOnInit(): void { this.parseJson(); } ngOnChanges(changes: SimpleChanges): void { if (changes['p3xrValue']) { this.parseJson(); } } ngOnDestroy(): void { this.destroyBase(); } private parseJson(): void { try { this.jsonObj = JSON.parse(this.p3xrValue); } catch { this.jsonObj = undefined; } } 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); } } toggleWrap(): void { this.treeWrap = !this.treeWrap; } async copyValue(): Promise { await this.copy(this.p3xrValue); } async jsonEditor(): Promise { try { const oldValue = this.p3xrValue; const result = await this.jsonEditorDialog.show({ value: this.p3xrValue, hideFormatSave: true }); const value = typeof result.obj === 'string' ? result.obj : JSON.stringify(result.obj); this.overlay.show(); await this.socket.request({ action: 'key/json-set', payload: { key: this.p3xrKey, path: '$', value }, }); this.gtag('/key-json-set'); this.refreshKey(); this.overlay.hide(); if (this.settingsService.undoEnabled() && oldValue !== undefined && oldValue !== value) { const undoClicked = await this.common.toastWithUndo(this.strings?.status?.set); if (undoClicked) { this.overlay.show({ message: 'Undo...' }); await this.socket.request({ action: 'key/json-set', payload: { key: this.p3xrKey, path: '$', value: oldValue }, }); this.refreshKey(); this.overlay.hide(); this.common.toast(this.strings?.status?.reverted); } } } catch (e) { if (e) this.common.generalHandleError(e); this.overlay.hide(); } } downloadJsonFile(): void { const blob = new Blob([this.p3xrValue], { type: 'application/json' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = `${this.p3xrKey}.json`; a.click(); URL.revokeObjectURL(url); } } src/ng/pages/database/key/key-list.component.html000066400000000000000000000041211517727315400223460ustar00rootroot00000000000000
{{ strings?.page?.key?.list?.table?.index }} {{ strings?.page?.key?.list?.table?.value }} @if (!isReadonly) { }
@for (item of pagedItems; track item.index) {
{{ item.index }} @if (valueFormat === 'hex') {} @else {{{ truncateDisplay(formatValue(item.value)) }}@if (isTruncated(item.value)) {...}} @if (!isReadonly) { delete } account_tree content_copy download @if (!isReadonly) { edit }
}
src/ng/pages/database/key/key-list.component.ts000066400000000000000000000113211517727315400220300ustar00rootroot00000000000000import { Component, Inject, OnInit, OnChanges, SimpleChanges, ChangeDetectorRef, CUSTOM_ELEMENTS_SCHEMA, ViewEncapsulation } from '@angular/core'; import { CommonModule } from '@angular/common'; import { FormsModule } from '@angular/forms'; 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'; import { SocketService } from '../../../services/socket.service'; import { CommonService } from '../../../services/common.service'; import { JsonViewDialogService } from '../../../dialogs/json-view-dialog.service'; import { KeyNewOrSetDialogService } from '../../../dialogs/key-new-or-set-dialog.service'; import { MainCommandService } from '../../../services/main-command.service'; import { RedisStateService } from '../../../services/redis-state.service'; import { SettingsService } from '../../../services/settings.service'; import { KeyTypeBase } from './key-type-base'; import { HexMonitorComponent } from './hex-monitor.component'; import { KeyPaging } from './key-paging'; import { KeyPagerInlineComponent } from './key-pager-inline.component'; @Component({ selector: 'p3xr-key-list', standalone: true, imports: [CommonModule, FormsModule, MatButtonModule, MatIconModule, MatTooltipModule, KeyPagerInlineComponent, HexMonitorComponent], schemas: [CUSTOM_ELEMENTS_SCHEMA], templateUrl: './key-list.component.html', encapsulation: ViewEncapsulation.None, }) export class KeyListComponent extends KeyTypeBase implements OnInit, OnChanges { paging: KeyPaging; pagedItems: Array<{ index: number; value: any }> = []; constructor( @Inject(I18nService) i18n: I18nService, @Inject(SocketService) socket: SocketService, @Inject(CommonService) common: CommonService, @Inject(JsonViewDialogService) jsonViewDialog: JsonViewDialogService, @Inject(KeyNewOrSetDialogService) keyNewOrSetDialog: KeyNewOrSetDialogService, @Inject(BreakpointObserver) breakpointObserver: BreakpointObserver, @Inject(MainCommandService) cmd: MainCommandService, @Inject(ChangeDetectorRef) cdr: ChangeDetectorRef, @Inject(RedisStateService) redisState: RedisStateService, @Inject(SettingsService) settingsService: SettingsService, ) { super(i18n, socket, common, jsonViewDialog, keyNewOrSetDialog, breakpointObserver, cmd, cdr, redisState, settingsService); this.paging = new KeyPaging({ settingsService }); } ngOnInit(): void { this.updatePaging(); } ngOnChanges(c: SimpleChanges): void { if (c['p3xrValue']) this.updatePaging(); } updatePaging(): void { if (!this.p3xrValue) return; this.paging.figurePaging(this.p3xrValue.length); this.updatePagedItems(); } updatePagedItems(): void { if (!this.p3xrValue) { this.pagedItems = []; return; } this.pagedItems = this.p3xrValue.slice(this.paging.startIndex, this.paging.endIndex) .map((v: any, i: number) => ({ index: this.paging.startIndex + i, value: v })); } async appendValue(event: Event): Promise { try { await this.keyNewOrSetDialog.show({ $event: event, type: 'append', model: { type: 'list', key: this.p3xrKey } }); this.refreshKey(); } catch (e) { this.common.generalHandleError(e); } } async deleteListElement(index: number, event: Event): Promise { try { await this.common.confirm({ message: this.i18n.strings().confirm?.deleteListItem ?? this.i18n.strings().confirm?.areYouSure ?? 'Are you sure?' }); await this.socket.request({ action: 'key/list-delete-index', payload: { key: this.p3xrKey, index } }); this.common.toast(this.i18n.strings().status?.deletedListElement); this.refreshKey(); } catch (e) { this.common.generalHandleError(e); } } async editValue(index: number, value: any, event: Event): Promise { try { const editValue = typeof value === 'string' && value.length >= this.maxValueAsBuffer ? this.p3xrValueBuffer[index] : value; await this.keyNewOrSetDialog.show({ $event: event, type: 'edit', model: { type: 'list', key: this.p3xrKey, index, value: editValue }, }); this.refreshKey(); } catch (e) { this.common.generalHandleError(e); } } copyItem(value: any): void { this.copy(value); } showJsonItem(value: any, event: Event): void { this.showJson(value, event); } downloadItem(index: number): void { this.downloadBuffer(this.p3xrValueBuffer[index]); } } src/ng/pages/database/key/key-pager-inline.component.ts000066400000000000000000000074211517727315400234350ustar00rootroot00000000000000import { Component, Inject, Input, Output, EventEmitter } from '@angular/core'; import { CommonModule } from '@angular/common'; import { FormsModule } from '@angular/forms'; import { MatTooltipModule } from '@angular/material/tooltip'; import { P3xrInputComponent } from '../../../components/p3xr-input.component'; import { I18nService } from '../../../services/i18n.service'; import { KeyPaging } from './key-paging'; @Component({ selector: 'p3xr-key-pager-inline', standalone: true, imports: [CommonModule, FormsModule, MatTooltipModule, P3xrInputComponent], template: ` @if (paging.pages > 1) {
/ {{ paging.pages }}
} `, styles: [` .p3xr-key-pager-inline { display: flex; align-items: center; justify-content: center; padding: 4px 0; } .p3xr-key-pager-btn { background: none; border: none; color: var(--p3xr-input-border-color, var(--p3xr-border-color)); cursor: pointer; display: inline-flex; align-items: center; justify-content: center; height: 28px; width: 28px; margin: 0; padding: 0; } .p3xr-key-pager-btn:focus { outline: none; } .p3xr-key-pager-btn .material-icons { font-size: 24px; } :host ::ng-deep p3xr-ng-input.p3xr-key-pager-input { vertical-align: middle !important; width: 64px; margin: 0 4px; } .p3xr-key-pager-text { margin: 0 4px; color: var(--p3xr-input-color, inherit); } `], }) export class KeyPagerInlineComponent { @Input() paging!: KeyPaging; @Output() pageChanged = new EventEmitter(); constructor(@Inject(I18nService) private i18n: I18nService) {} get strings() { return this.i18n.strings(); } onPageChange(value: any): void { this.paging.page = value; this.paging.pageChange(); this.pageChanged.emit(); } } src/ng/pages/database/key/key-paging.ts000066400000000000000000000033011517727315400203200ustar00rootroot00000000000000import { SettingsService } from '../../../services/settings.service'; /** * Shared pagination logic for key type renderers. * Replaces AngularJS p3xrKeyPaging factory. */ export class KeyPaging { page = 1; pages = 1; private zsetMode: boolean; private settingsService?: SettingsService; constructor(options?: { zsetMode?: boolean; settingsService?: SettingsService }) { this.zsetMode = options?.zsetMode ?? false; this.settingsService = options?.settingsService; } figurePaging(valueLength: number): void { const pageCount = this.settingsService?.keyPageCount() ?? 50; const itemCount = this.zsetMode ? Math.ceil(valueLength / 2) : valueLength; this.pages = Math.max(Math.ceil(itemCount / pageCount), 1); // Keep current page if still valid, otherwise clamp to last page if (this.page > this.pages) { this.page = this.pages; } else if (this.page < 1) { this.page = 1; } } get pageCount(): number { return this.settingsService?.keyPageCount() ?? 50; } get startIndex(): number { return this.pageCount * (this.page - 1); } get endIndex(): number { return this.startIndex + this.pageCount; } pager(action: string): void { switch (action) { case 'first': this.page = 1; break; case 'prev': if (this.page > 1) this.page--; break; case 'next': if (this.page < this.pages) this.page++; break; case 'last': this.page = this.pages; break; } } pageChange(): void { if (this.page < 1) this.page = 1; if (this.page > this.pages) this.page = this.pages; } } src/ng/pages/database/key/key-probabilistic.component.html000066400000000000000000000213321517727315400242240ustar00rootroot00000000000000

@if (!autoRefresh) { }
@for (item of infoItems; track item.key; let last = $last) {
{{ item.key }} {{ item.value }}
@if (!last) { } }

@if (p3xrResponse.type === 'tdigest') { {{ strings()?.form?.key?.field?.value }} } @else { {{ strings()?.page?.key?.probabilistic?.item }} } @if (p3xrResponse.type === 'cms') { {{ strings()?.form?.key?.field?.increment }} } @if (p3xrResponse.type === 'tdigest') { {{ strings()?.page?.key?.probabilistic?.quantile }} } @if (!readonly) { @if (isGtSm) { } @else { } } @if (p3xrResponse.type === 'bloom' || p3xrResponse.type === 'cuckoo') { @if (isGtSm) { } @else { } } @if (p3xrResponse.type === 'cuckoo' && !readonly) { @if (isGtSm) { } @else { } } @if (p3xrResponse.type === 'cms') { @if (isGtSm) { } @else { } } @if (p3xrResponse.type === 'tdigest') { @if (isGtSm) { } @else { } } @if (p3xrResponse.type === 'tdigest' && !readonly) { @if (isGtSm) { } @else { } }
@if (p3xrResponse.type === 'topk' && topkItems.length > 0) { @for (entry of topkItems; track entry.item; let last = $last) {
{{ entry.item }} {{ entry.count }}
@if (!last) { } }
}
src/ng/pages/database/key/key-probabilistic.component.ts000066400000000000000000000234771517727315400237220ustar00rootroot00000000000000import { Component, Inject, OnInit, OnChanges, OnDestroy, SimpleChanges, ChangeDetectorRef, CUSTOM_ELEMENTS_SCHEMA, ViewEncapsulation } from '@angular/core'; import { CommonModule } from '@angular/common'; import { FormsModule } from '@angular/forms'; import { MatButtonModule } from '@angular/material/button'; import { MatIconModule } from '@angular/material/icon'; import { MatTooltipModule } from '@angular/material/tooltip'; import { MatFormFieldModule } from '@angular/material/form-field'; import { MatInputModule } from '@angular/material/input'; import { MatListModule } from '@angular/material/list'; import { MatDividerModule } from '@angular/material/divider'; 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 { JsonViewDialogService } from '../../../dialogs/json-view-dialog.service'; import { KeyNewOrSetDialogService } from '../../../dialogs/key-new-or-set-dialog.service'; import { MainCommandService } from '../../../services/main-command.service'; import { RedisStateService } from '../../../services/redis-state.service'; import { SettingsService } from '../../../services/settings.service'; import { KeyTypeBase } from './key-type-base'; import { P3xrAccordionComponent } from '../../../components/p3xr-accordion.component'; import { P3xrButtonComponent } from '../../../components/p3xr-button.component'; @Component({ selector: 'p3xr-key-probabilistic', standalone: true, imports: [ CommonModule, FormsModule, MatButtonModule, MatIconModule, MatTooltipModule, MatFormFieldModule, MatInputModule, MatListModule, MatDividerModule, P3xrAccordionComponent, P3xrButtonComponent, ], schemas: [CUSTOM_ELEMENTS_SCHEMA], templateUrl: './key-probabilistic.component.html', encapsulation: ViewEncapsulation.None, }) export class KeyProbabilisticComponent extends KeyTypeBase implements OnInit, OnChanges, OnDestroy { infoItems: Array<{ key: string; value: any }> = []; topkItems: Array<{ item: string; count: number }> = []; itemInput = ''; incrementInput = 1; quantileInput = 0.5; autoRefresh = false; private autoRefreshInterval: any = null; readonly = false; constructor( @Inject(I18nService) i18n: I18nService, @Inject(SocketService) socket: SocketService, @Inject(CommonService) common: CommonService, @Inject(JsonViewDialogService) jsonViewDialog: JsonViewDialogService, @Inject(KeyNewOrSetDialogService) keyNewOrSetDialog: KeyNewOrSetDialogService, @Inject(BreakpointObserver) breakpointObserver: BreakpointObserver, @Inject(MainCommandService) cmd: MainCommandService, @Inject(ChangeDetectorRef) cdr: ChangeDetectorRef, @Inject(RedisStateService) redisState: RedisStateService, @Inject(SettingsService) settingsService: SettingsService, ) { super(i18n, socket, common, jsonViewDialog, keyNewOrSetDialog, breakpointObserver, cmd, cdr, redisState, settingsService); } get strings() { return this.i18n.strings; } get type(): string { return this.p3xrResponse?.type || ''; } ngOnInit() { this.readonly = this.redisState.connection()?.readonly === true; this.parseInfo(); if (this.type === 'topk') { this.loadTopkList(); } } ngOnChanges(changes: SimpleChanges) { if (changes['p3xrValue']) { this.parseInfo(); } } ngOnDestroy() { this.stopAutoRefresh(); this.destroyBase(); } toggleAutoRefresh(): void { this.autoRefresh = !this.autoRefresh; if (this.autoRefresh) { this.startAutoRefresh(); } else { this.stopAutoRefresh(); } this.cdr.markForCheck(); } private startAutoRefresh(): void { this.stopAutoRefresh(); this.autoRefreshInterval = setInterval(() => { this.refresh(); }, 10000); } private stopAutoRefresh(): void { if (this.autoRefreshInterval) { clearInterval(this.autoRefreshInterval); this.autoRefreshInterval = null; } } private parseInfo() { this.infoItems = []; try { const val = this.p3xrValue; let info: any; if (typeof val === 'object' && val !== null && !ArrayBuffer.isView(val)) { info = val; } else if (typeof val === 'string') { info = JSON.parse(val); } else if (ArrayBuffer.isView(val)) { info = JSON.parse(new TextDecoder().decode(val as any)); } if (info && typeof info === 'object') { this.infoItems = Object.entries(info).map(([key, value]) => ({ key, value })); } } catch { // ignore parse errors } } async addItem() { if (!this.itemInput.trim()) return; try { await this.socket.request({ action: 'probabilistic/add', payload: { key: this.p3xrKey, type: this.type, item: this.itemInput.trim(), increment: this.incrementInput, }, }); this.common.toast(this.strings()?.page?.key?.probabilistic?.addedSuccessfully); this.itemInput = ''; this.refresh(); } catch (e: any) { this.common.toast(e.message || 'Error'); } this.cdr.markForCheck(); } async checkItem() { if (!this.itemInput.trim()) return; try { const response = await this.socket.request({ action: 'probabilistic/check', payload: { key: this.p3xrKey, type: this.type, item: this.itemInput.trim(), }, }); const strings = this.strings(); const exists = (response as any).result === 1; this.common.toast(`"${this.itemInput}" — ${exists ? (strings?.page?.key?.probabilistic?.exists) : (strings?.page?.key?.probabilistic?.doesNotExist)}`); } catch (e: any) { this.common.toast(e.message || 'Error'); } this.cdr.markForCheck(); } async deleteItem() { if (!this.itemInput.trim()) return; try { await this.common.confirm({ message: this.strings()?.confirm?.delete, }); await this.socket.request({ action: 'probabilistic/delete', payload: { key: this.p3xrKey, type: this.type, item: this.itemInput.trim(), }, }); this.common.toast(this.strings()?.page?.key?.probabilistic?.deletedSuccessfully); this.itemInput = ''; this.refresh(); } catch (e: any) { if (e?.message) this.common.toast(e.message || 'Error'); } this.cdr.markForCheck(); } async queryItem() { if (!this.itemInput.trim()) return; try { const response = await this.socket.request({ action: 'probabilistic/check', payload: { key: this.p3xrKey, type: this.type, item: this.itemInput.trim(), }, }); const count = Array.isArray((response as any).result) ? (response as any).result[0] : (response as any).result; this.common.toast(`"${this.itemInput}" — ${this.strings()?.page?.key?.probabilistic?.topkCount}: ${count}`); } catch (e: any) { this.common.toast(e.message || 'Error'); } this.cdr.markForCheck(); } async queryQuantile() { try { const response = await this.socket.request({ action: 'probabilistic/check', payload: { key: this.p3xrKey, type: this.type, quantile: this.quantileInput, }, }); const strings = this.strings(); const result = Array.isArray((response as any).result) ? (response as any).result[0] : (response as any).result; this.common.toast(`${strings?.page?.key?.probabilistic?.quantile} ${this.quantileInput} = ${result}`); } catch (e: any) { this.common.toast(e.message || 'Error'); } this.cdr.markForCheck(); } async resetTdigest() { try { await this.common.confirm({ message: this.strings()?.page?.key?.probabilistic?.resetConfirm, }); await this.socket.request({ action: 'probabilistic/delete', payload: { key: this.p3xrKey, type: 'tdigest', }, }); this.common.toast('Reset'); this.refresh(); } catch (e: any) { if (e?.message) this.common.toast(e.message || 'Error'); } this.cdr.markForCheck(); } async loadTopkList() { try { const response = await this.socket.request({ action: 'probabilistic/check', payload: { key: this.p3xrKey, type: 'topk', }, }); this.topkItems = (response as any).result || []; } catch { this.topkItems = []; } this.cdr.markForCheck(); } refresh() { this.cmd.refreshKey$.next(); if (this.type === 'topk') { this.loadTopkList(); } } } src/ng/pages/database/key/key-set.component.html000066400000000000000000000035351517727315400221760ustar00rootroot00000000000000
{{ strings?.page?.key?.set?.table?.value }} @if (!isReadonly) { }
@for (item of pagedItems; track item.index) {
@if (valueFormat === 'hex') {} @else {{{ truncateDisplay(formatValue(item.value)) }}@if (isTruncated(item.value)) {...}} @if (!isReadonly) { delete } account_tree content_copy download @if (!isReadonly) { edit }
}
src/ng/pages/database/key/key-set.component.ts000066400000000000000000000112341517727315400216530ustar00rootroot00000000000000import { Component, Inject, OnInit, OnChanges, SimpleChanges, ChangeDetectorRef, CUSTOM_ELEMENTS_SCHEMA, ViewEncapsulation } from '@angular/core'; import { CommonModule } from '@angular/common'; import { FormsModule } from '@angular/forms'; 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'; import { SocketService } from '../../../services/socket.service'; import { CommonService } from '../../../services/common.service'; import { JsonViewDialogService } from '../../../dialogs/json-view-dialog.service'; import { KeyNewOrSetDialogService } from '../../../dialogs/key-new-or-set-dialog.service'; import { MainCommandService } from '../../../services/main-command.service'; import { RedisStateService } from '../../../services/redis-state.service'; import { SettingsService } from '../../../services/settings.service'; import { KeyTypeBase } from './key-type-base'; import { HexMonitorComponent } from './hex-monitor.component'; import { KeyPaging } from './key-paging'; import { KeyPagerInlineComponent } from './key-pager-inline.component'; @Component({ selector: 'p3xr-key-set', standalone: true, imports: [CommonModule, FormsModule, MatButtonModule, MatIconModule, MatTooltipModule, KeyPagerInlineComponent, HexMonitorComponent], schemas: [CUSTOM_ELEMENTS_SCHEMA], templateUrl: './key-set.component.html', encapsulation: ViewEncapsulation.None, }) export class KeySetComponent extends KeyTypeBase implements OnInit, OnChanges { paging: KeyPaging; pagedItems: Array<{ index: number; value: any }> = []; constructor( @Inject(I18nService) i18n: I18nService, @Inject(SocketService) socket: SocketService, @Inject(CommonService) common: CommonService, @Inject(JsonViewDialogService) jsonViewDialog: JsonViewDialogService, @Inject(KeyNewOrSetDialogService) keyNewOrSetDialog: KeyNewOrSetDialogService, @Inject(BreakpointObserver) breakpointObserver: BreakpointObserver, @Inject(MainCommandService) cmd: MainCommandService, @Inject(ChangeDetectorRef) cdr: ChangeDetectorRef, @Inject(RedisStateService) redisState: RedisStateService, @Inject(SettingsService) settingsService: SettingsService, ) { super(i18n, socket, common, jsonViewDialog, keyNewOrSetDialog, breakpointObserver, cmd, cdr, redisState, settingsService); this.paging = new KeyPaging({ settingsService }); } ngOnInit(): void { this.updatePaging(); } ngOnChanges(c: SimpleChanges): void { if (c['p3xrValue']) this.updatePaging(); } updatePaging(): void { if (!this.p3xrValue) return; this.paging.figurePaging(this.p3xrValue.length); this.updatePagedItems(); } updatePagedItems(): void { if (!this.p3xrValue) { this.pagedItems = []; return; } this.pagedItems = this.p3xrValue.slice(this.paging.startIndex, this.paging.endIndex) .map((v: any, i: number) => ({ index: this.paging.startIndex + i, value: v })); } async addSet(event: Event): Promise { try { await this.keyNewOrSetDialog.show({ $event: event, type: 'append', model: { type: 'set', key: this.p3xrKey } }); this.refreshKey(); } catch (e) { this.common.generalHandleError(e); } } async deleteSetMember(index: number, event: Event): Promise { try { await this.common.confirm({ message: this.i18n.strings().confirm?.deleteSetMember }); await this.socket.request({ action: 'key/set-delete-member', payload: { key: this.p3xrKey, value: this.p3xrValueBuffer[index] } }); this.common.toast(this.i18n.strings().status?.deletedSetMember); this.refreshKey(); } catch (e) { this.common.generalHandleError(e); } } async editValue(index: number, value: any, event: Event): Promise { try { const editValue = typeof value === 'string' && value.length >= this.maxValueAsBuffer ? this.p3xrValueBuffer[index] : value; await this.keyNewOrSetDialog.show({ $event: event, type: 'edit', model: { type: 'set', key: this.p3xrKey, value: editValue }, }); this.refreshKey(); } catch (e) { this.common.generalHandleError(e); } } copyItem(value: any): void { this.copy(value); } showJsonItem(value: any, event: Event): void { this.showJson(value, event); } downloadItem(index: number): void { this.downloadBuffer(this.p3xrValueBuffer[index]); } } src/ng/pages/database/key/key-stream.component.html000066400000000000000000000051221517727315400226700ustar00rootroot00000000000000
{{ strings?.page?.key?.stream?.table?.timestamp }} @if (!isReadonly) { }
@for (entry of pagedEntries; track entry.id) {
{{ entry.id }} {{ showTimestamp(entry.id) }} @if (!isReadonly) { delete } account_tree content_copy download
@for (field of entry.fields; track field[0]) {
{{ field[0] }} @if (valueFormat === 'hex') {} @else {{{ truncateDisplay(formatValue(field[1])) }}@if (isTruncated(field[1])) {...}}
}
}
src/ng/pages/database/key/key-stream.component.ts000066400000000000000000000223261517727315400223570ustar00rootroot00000000000000import { Component, Inject, OnInit, OnChanges, SimpleChanges, ChangeDetectorRef, CUSTOM_ELEMENTS_SCHEMA, ViewEncapsulation, effect } from '@angular/core'; import { CommonModule } from '@angular/common'; import { FormsModule } from '@angular/forms'; 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'; import { SocketService } from '../../../services/socket.service'; import { CommonService } from '../../../services/common.service'; import { JsonViewDialogService } from '../../../dialogs/json-view-dialog.service'; import { KeyNewOrSetDialogService } from '../../../dialogs/key-new-or-set-dialog.service'; import { MainCommandService } from '../../../services/main-command.service'; import { RedisStateService } from '../../../services/redis-state.service'; import { SettingsService } from '../../../services/settings.service'; import { KeyTypeBase } from './key-type-base'; import { HexMonitorComponent } from './hex-monitor.component'; import { KeyPaging } from './key-paging'; import { KeyPagerInlineComponent } from './key-pager-inline.component'; const intlLocaleMap: Record = { 'zn': 'zh-CN', 'no': 'nb', 'fil': 'tl' }; @Component({ selector: 'p3xr-key-stream', standalone: true, imports: [CommonModule, FormsModule, MatButtonModule, MatIconModule, MatTooltipModule, KeyPagerInlineComponent, HexMonitorComponent], schemas: [CUSTOM_ELEMENTS_SCHEMA], templateUrl: './key-stream.component.html', encapsulation: ViewEncapsulation.None, }) export class KeyStreamComponent extends KeyTypeBase implements OnInit, OnChanges { paging: KeyPaging; pagedEntries: Array<{ id: string; fields: Array<[string, string]>; data: any; displayData: any; hasDuplicateFields: boolean }> = []; private allEntries: Array<{ id: string; fields: Array<[string, string]>; data: any; displayData: any; hasDuplicateFields: boolean }> = []; constructor( @Inject(I18nService) i18n: I18nService, @Inject(SocketService) socket: SocketService, @Inject(CommonService) common: CommonService, @Inject(JsonViewDialogService) jsonViewDialog: JsonViewDialogService, @Inject(KeyNewOrSetDialogService) keyNewOrSetDialog: KeyNewOrSetDialogService, @Inject(BreakpointObserver) breakpointObserver: BreakpointObserver, @Inject(MainCommandService) cmd: MainCommandService, @Inject(ChangeDetectorRef) cdr: ChangeDetectorRef, @Inject(RedisStateService) redisState: RedisStateService, @Inject(SettingsService) settingsService: SettingsService, ) { super(i18n, socket, common, jsonViewDialog, keyNewOrSetDialog, breakpointObserver, cmd, cdr, redisState, settingsService); this.paging = new KeyPaging({ settingsService }); effect(() => { this.i18n.strings(); if (this.allEntries.length === 0) { return; } this.allEntries = this.allEntries.map((entry) => ({ ...entry, displayData: this.toDisplayData(entry.data, entry.hasDuplicateFields), })); this.updatePagedItems(); this.cdr.markForCheck(); }); } ngOnInit(): void { this.updatePaging(); } ngOnChanges(c: SimpleChanges): void { if (c['p3xrValue']) this.updatePaging(); } updatePaging(): void { if (!this.p3xrValue) return; this.allEntries = this.p3xrValue.map((entry: any) => { const id = entry[0]; const rawData = entry[1]; const fields: Array<[string, string]> = []; for (let i = 0; i < rawData.length; i += 2) { fields.push([rawData[i], rawData[i + 1]]); } const hasDuplicateFields = this.hasDuplicateFields(fields); const data = hasDuplicateFields ? this.fieldsToArray(fields) : this.fieldsToObject(fields); return { id, fields, data, displayData: this.toDisplayData(data, hasDuplicateFields), hasDuplicateFields, }; }); this.paging.figurePaging(this.allEntries.length); this.updatePagedItems(); } updatePagedItems(): void { this.pagedEntries = this.allEntries.slice(this.paging.startIndex, this.paging.endIndex); } showTimestamp(id: string): string { try { const ms = parseInt(id.slice(0, id.indexOf('-'))); const lang = this.i18n.currentLang() || 'en'; const locale = intlLocaleMap[lang] || lang; const date = new Date(ms); return date.toLocaleString(locale, { year: 'numeric', month: 'numeric', day: 'numeric', hour: '2-digit', minute: '2-digit', second: '2-digit' }); } catch { return id; } } isJsonValue(value: string): boolean { if (!value || value.length < 2) return false; const first = value.charAt(0); if (first !== '{' && first !== '[') return false; try { JSON.parse(value); return true; } catch { return false; } } formatJsonValue(value: string): string { try { return JSON.stringify(JSON.parse(value), null, this.settingsService.jsonFormat() ?? 2); } catch { return value; } } private parseFieldValue(value: string): any { try { return JSON.parse(value); } catch { return value; } } private hasDuplicateFields(fields: Array<[string, string]>): boolean { const seen = new Set(); for (const [key] of fields) { if (seen.has(key)) { return true; } seen.add(key); } return false; } private fieldsToObject(fields: Array<[string, string]>): any { const obj: any = {}; for (const [key, value] of fields) { obj[key] = this.parseFieldValue(value); } return obj; } private fieldsToArray(fields: Array<[string, string]>): Array<{ field: string; value: any }> { return fields.map(([field, value]) => ({ field, value: this.parseFieldValue(value), })); } private toDisplayData(data: any, hasDuplicateFields: boolean): any { if (!hasDuplicateFields) { return data; } const fieldLabel = this.strings?.page?.key?.stream?.table?.field; const valueLabel = this.strings?.page?.key?.stream?.table?.value; return data.map((item: { field: string; value: any }) => ({ [fieldLabel]: item.field, [valueLabel]: item.value, })); } private entryToExport(entry: { id: string; fields: Array<[string, string]>; data: any; displayData: any; hasDuplicateFields: boolean }): any { if (entry.hasDuplicateFields) { return { id: entry.id, fields: entry.data, }; } return { id: entry.id, ...entry.data }; } downloadEntry(entry: { id: string; fields: Array<[string, string]> }): void { const lines = [entry.id]; for (const [field, value] of entry.fields) { lines.push(field); lines.push(value); } const text = lines.join('\n'); const blob = new Blob([text], { type: 'text/plain' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = `${this.p3xrKey}-${entry.id}.txt`; a.click(); URL.revokeObjectURL(url); } async copyEntry(entry: { id: string; fields: Array<[string, string]>; data: any; displayData: any; hasDuplicateFields: boolean }): Promise { const obj = this.entryToExport(entry); await this.copy(JSON.stringify(obj, null, 2)); } async viewEntryJson(entry: { id: string; fields: Array<[string, string]>; data: any; displayData: any; hasDuplicateFields: boolean }, event?: Event): Promise { const obj = this.entryToExport(entry); await this.showJson(JSON.stringify(obj), event); } async viewFieldJson(value: string, event?: Event): Promise { await this.showJson(value, event); } copyField(value: any): void { this.copy(value); } async addStream(event: Event): Promise { try { await this.keyNewOrSetDialog.show({ $event: event, type: 'append', model: { type: 'stream', key: this.p3xrKey } }); this.refreshKey(); } catch (e) { this.common.generalHandleError(e); } } async deleteStreamTimestamp(id: string, event: Event): Promise { try { await this.common.confirm({ message: this.i18n.strings().confirm?.deleteStreamTimestamp }); await this.socket.request({ action: 'key/stream-delete-timestamp', payload: { key: this.p3xrKey, streamTimestamp: id } }); this.common.toast(this.i18n.strings().status?.deletedStreamTimestamp || this.i18n.strings().status?.deletedKey); this.refreshKey(); } catch (e) { if (e) this.common.generalHandleError(e); } } } src/ng/pages/database/key/key-string.component.html000066400000000000000000000207551517727315400227140ustar00rootroot00000000000000 @if (!editable) {
@if (!isReadonly) { @if (isGtSm) { } @else { } } @if (isGtSm) { } @else { } @if (isGtSm) { } @else { } @if (isGtSm) { } @else { } @if (!isReadonly) { @if (isGtSm) { } @else { } } @if (isGtSm) { } @else { } @if (redisState.redisVersion().isAtLeast(8, 4)) { @if (isGtSm) { } @else { } } @if (!isReadonly) { @if (isGtSm) { } @else { } }
} @if (editable) {
@if (!isReadonly) { {{ strings?.label?.validateJson }} } @if (isGtSm) { } @else { } @if (!isReadonly) { @if (isGtSm) { } @else { } } @if (!isReadonly) { @if (isGtSm) { } @else { } }
}
@if (editable) { @if (p3xrValue && p3xrValue.toString() === '[object ArrayBuffer]') {
{{ strings?.label?.isBuffer?.({ maxValueAsBuffer: prettyBytes(maxValueAsBuffer) }) }} {{ bufferDisplay() }}
} @if (buffer) {
{{ strings?.label?.isBuffer?.({ maxValueAsBuffer: prettyBytes(maxValueAsBuffer) }) }} {{ bufferDisplay() }}
} @else { }
} @else {
@if (valueFormat === 'hex') { } @else { {{ truncateDisplay(formatValue(p3xrValue)) }}@if (isTruncated(p3xrValue)) {...} }
}
src/ng/pages/database/key/key-string.component.ts000066400000000000000000000243741517727315400223770ustar00rootroot00000000000000import { Component, Inject, OnInit, OnDestroy, ChangeDetectorRef, CUSTOM_ELEMENTS_SCHEMA, ViewEncapsulation } from '@angular/core'; import { CommonModule } from '@angular/common'; import { FormsModule } from '@angular/forms'; import { MatButtonModule } from '@angular/material/button'; import { MatIconModule } from '@angular/material/icon'; import { MatTooltipModule } from '@angular/material/tooltip'; import { MatSlideToggleModule } from '@angular/material/slide-toggle'; import { MatInputModule } from '@angular/material/input'; import { MatFormFieldModule } from '@angular/material/form-field'; import { TextFieldModule } from '@angular/cdk/text-field'; import { BreakpointObserver } from '@angular/cdk/layout'; import { MainCommandService } from '../../../services/main-command.service'; import { I18nService } from '../../../services/i18n.service'; import { SocketService } from '../../../services/socket.service'; import { CommonService } from '../../../services/common.service'; import { JsonViewDialogService } from '../../../dialogs/json-view-dialog.service'; import { JsonEditorDialogService } from '../../../dialogs/json-editor-dialog.service'; import { KeyNewOrSetDialogService } from '../../../dialogs/key-new-or-set-dialog.service'; import { RedisStateService } from '../../../services/redis-state.service'; import { SettingsService } from '../../../services/settings.service'; import { KeyTypeBase } from './key-type-base'; import { HexMonitorComponent } from './hex-monitor.component'; import { OverlayService } from '../../../services/overlay.service'; import { DiffDialogService } from '../../../dialogs/diff-dialog.service'; @Component({ selector: 'p3xr-key-string', standalone: true, imports: [CommonModule, FormsModule, MatButtonModule, MatIconModule, MatTooltipModule, MatSlideToggleModule, MatInputModule, MatFormFieldModule, TextFieldModule, HexMonitorComponent], schemas: [CUSTOM_ELEMENTS_SCHEMA], templateUrl: './key-string.component.html', encapsulation: ViewEncapsulation.None, }) export class KeyStringComponent extends KeyTypeBase implements OnInit { editable = false; buffer = false; validateJson = false; originalValue: any; constructor( @Inject(I18nService) i18n: I18nService, @Inject(SocketService) socket: SocketService, @Inject(CommonService) common: CommonService, @Inject(JsonViewDialogService) jsonViewDialog: JsonViewDialogService, @Inject(KeyNewOrSetDialogService) keyNewOrSetDialog: KeyNewOrSetDialogService, @Inject(BreakpointObserver) breakpointObserver: BreakpointObserver, @Inject(MainCommandService) cmd: MainCommandService, @Inject(JsonEditorDialogService) private jsonEditorDialog: JsonEditorDialogService, @Inject(ChangeDetectorRef) cdr: ChangeDetectorRef, @Inject(RedisStateService) redisState: RedisStateService, @Inject(SettingsService) settingsService: SettingsService, @Inject(OverlayService) private overlay: OverlayService, @Inject(DiffDialogService) private diffDialog: DiffDialogService, ) { super(i18n, socket, common, jsonViewDialog, keyNewOrSetDialog, breakpointObserver, cmd, cdr, redisState, settingsService); } ngOnInit(): void {} async showDigest(): Promise { try { const response = await this.socket.request({ action: 'key/string-digest', payload: { key: this.p3xrKey }, }); this.common.toast(response.digest || 'No digest'); } catch (e: any) { this.common.generalHandleError(e); } } edit(): void { const value = this.p3xrValue; if (typeof value === 'string' && value.length >= this.maxValueAsBuffer) { this.buffer = true; this.originalValue = structuredClone(this.p3xrValueBuffer); } else { this.buffer = false; this.originalValue = structuredClone(this.p3xrValue); } this.editable = true; } cancelEdit(): void { if (this.buffer) { this.p3xrValueBuffer = this.originalValue; } else { this.p3xrValue = this.originalValue; } this.editable = false; this.buffer = false; } async save(): Promise { const valueToSave = this.buffer ? this.p3xrValueBuffer : this.p3xrValue; const oldValue = this.originalValue; try { if (this.validateJson) { JSON.parse(valueToSave); } if (oldValue !== undefined) { const confirmed = await this.diffDialog.show({ keyName: this.p3xrKey, oldValue, newValue: valueToSave }); if (!confirmed) return; } this.overlay.show({ message: this.strings?.intention?.save ?? 'Saving...' }); await this.socket.request({ action: 'key/set', payload: { type: this.p3xrResponse?.type, key: this.p3xrKey, value: valueToSave, }, }); this.gtag('/key-set'); this.editable = false; this.buffer = false; this.refreshKey(); this.overlay.hide(); // Undo support if (this.settingsService.undoEnabled() && oldValue !== undefined && oldValue !== valueToSave) { const undoClicked = await this.common.toastWithUndo(this.strings?.status?.saved); if (undoClicked) { this.overlay.show({ message: 'Undo...' }); await this.socket.request({ action: 'key/set', payload: { type: this.p3xrResponse?.type, key: this.p3xrKey, value: oldValue, }, }); this.refreshKey(); this.overlay.hide(); this.common.toast(this.strings?.status?.reverted); } } } catch (e) { this.common.generalHandleError(e); this.overlay.hide(); } } async setBufferUpload(): Promise { const input = document.createElement('input'); input.type = 'file'; input.onchange = async () => { const file = input.files?.[0]; if (!file) return; const reader = new FileReader(); reader.onerror = (error) => { this.common.generalHandleError(error); }; reader.onload = async (loadEvent: any) => { const arrayBuffer = loadEvent.target.result; try { if (this.editable) { await this.common.confirm({ message: this.i18n.strings().confirm?.uploadBuffer }); if (this.buffer) { this.p3xrValueBuffer = arrayBuffer; } else { this.p3xrValue = arrayBuffer; } this.common.toast(this.i18n.strings().confirm?.uploadBufferDone); return; } await this.common.confirm({ message: this.i18n.strings().confirm?.uploadBuffer }); this.overlay.show(); await this.socket.request({ action: 'key/set', payload: { type: this.p3xrResponse?.type, value: arrayBuffer, key: this.p3xrKey, }, }); this.common.toast(this.i18n.strings().confirm?.uploadBufferDoneAndSave); this.gtag('/key-set'); this.refreshKey(); } catch (e) { this.common.generalHandleError(e); } finally { this.overlay.hide(); } }; reader.readAsArrayBuffer(file); }; input.click(); } async jsonViewer(event?: Event): Promise { await this.showJson(this.p3xrValue, event); } async jsonEditor(): Promise { try { const oldValue = this.p3xrValue; const result = await this.jsonEditorDialog.show({ value: this.p3xrValue }); this.originalValue = undefined; this.overlay.show({ message: this.strings?.intention?.save ?? 'Saving...' }); await this.socket.request({ action: 'key/set', payload: { type: this.p3xrResponse?.type, key: this.p3xrKey, value: result.obj }, }); this.p3xrValue = result.obj; this.editable = false; this.buffer = false; this.refreshKey(); this.overlay.hide(); if (this.settingsService.undoEnabled() && oldValue !== undefined && oldValue !== result.obj) { const undoClicked = await this.common.toastWithUndo(this.strings?.status?.saved); if (undoClicked) { this.overlay.show({ message: 'Undo...' }); await this.socket.request({ action: 'key/set', payload: { type: this.p3xrResponse?.type, key: this.p3xrKey, value: oldValue }, }); this.refreshKey(); this.overlay.hide(); this.common.toast(this.strings?.status?.reverted); } } } catch { /* cancelled */ } } async formatJson(): Promise { try { this.p3xrValue = JSON.stringify(JSON.parse(this.p3xrValue), null, this.settingsService.jsonFormat() ?? 2); await this.save(); } catch { this.common.toast(this.strings?.label?.jsonViewNotParsable ?? 'Not valid JSON'); } } copyValue(): void { this.copy(this.p3xrValue); } downloadBufferFile(): void { this.downloadBuffer(this.p3xrValueBuffer); } bufferDisplay(): string { if (this.p3xrValueBuffer?.byteLength !== undefined) { return '(' + this.prettyBytes(this.p3xrValueBuffer.byteLength) + ')'; } return ''; } } src/ng/pages/database/key/key-timeseries.component.html000066400000000000000000000303221517727315400235460ustar00rootroot00000000000000

@if (!isReadonly) { } @if (!autoRefresh) { }
{{ strings?.page?.key?.timeseries?.from }} {{ strings?.page?.key?.timeseries?.to }} {{ strings?.page?.key?.timeseries?.aggregation }} {{ strings?.page?.key?.timeseries?.none }} @for (agg of aggregationTypes; track agg) { {{ agg }} } @if (aggregationType) { {{ strings?.page?.key?.timeseries?.timeBucket }} } {{ strings?.page?.key?.timeseries?.overlay }} {{ strings?.page?.key?.timeseries?.mrangeFilter }}
{{ rangeData.length }} {{ strings?.page?.key?.timeseries?.dataPoints }}
@if (!isReadonly) {
{{ strings?.page?.key?.timeseries?.timestamp }} {{ strings?.page?.key?.timeseries?.value }} @if (isGtSm) { } @else { }
}
@if (rangeData.length > 0) {
{{ strings?.page?.key?.timeseries?.timestamp }} {{ strings?.page?.key?.timeseries?.value }} @if (!isReadonly) { }
{{ formatTimestamp(point.timestamp) }} {{ point.value }} @if (!isReadonly) { delete edit }
}
@if (!isReadonly) {
}
@if (alterMode) {
{{ 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 }}
} @for (item of infoLabels; track item.key) {
{{ item.key }}{{ item.value }}
} @if (tsLabels.length > 0) {
{{ strings?.page?.key?.timeseries?.labels }}
@for (label of tsLabels; track label.key) {
{{ label.key }}{{ label.value }}
} } @if (tsRules.length > 0) {
{{ strings?.page?.key?.timeseries?.rules }}
@for (rule of tsRules; track rule.destKey) {
{{ rule.destKey }}{{ rule.aggregationType }} / {{ rule.bucketDuration }}ms
} }
src/ng/pages/database/key/key-timeseries.component.ts000066400000000000000000000562001517727315400232330ustar00rootroot00000000000000import { Component, Inject, OnInit, OnChanges, OnDestroy, SimpleChanges, ChangeDetectorRef, CUSTOM_ELEMENTS_SCHEMA, ViewEncapsulation, ViewChild, ViewChildren, QueryList, ElementRef, AfterViewInit } from '@angular/core'; import { CommonModule } from '@angular/common'; import { FormsModule } from '@angular/forms'; import { ScrollingModule, CdkVirtualScrollViewport } from '@angular/cdk/scrolling'; import { MatButtonModule } from '@angular/material/button'; import { MatIconModule } from '@angular/material/icon'; import { MatTooltipModule } from '@angular/material/tooltip'; import { MatFormFieldModule } from '@angular/material/form-field'; import { MatInputModule } from '@angular/material/input'; import { MatSelectModule } from '@angular/material/select'; import { MatListModule } from '@angular/material/list'; import { MatDividerModule } from '@angular/material/divider'; 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 { JsonViewDialogService } from '../../../dialogs/json-view-dialog.service'; import { KeyNewOrSetDialogService } from '../../../dialogs/key-new-or-set-dialog.service'; import { MainCommandService } from '../../../services/main-command.service'; import { RedisStateService } from '../../../services/redis-state.service'; import { SettingsService } from '../../../services/settings.service'; import { KeyTypeBase } from './key-type-base'; import { P3xrAccordionComponent } from '../../../components/p3xr-accordion.component'; import { P3xrButtonComponent } from '../../../components/p3xr-button.component'; @Component({ selector: 'p3xr-key-timeseries', standalone: true, imports: [ CommonModule, FormsModule, ScrollingModule, MatButtonModule, MatIconModule, MatTooltipModule, MatFormFieldModule, MatInputModule, MatSelectModule, MatListModule, MatDividerModule, P3xrAccordionComponent, P3xrButtonComponent, ], schemas: [CUSTOM_ELEMENTS_SCHEMA], templateUrl: './key-timeseries.component.html', encapsulation: ViewEncapsulation.None, }) export class KeyTimeseriesComponent extends KeyTypeBase implements OnInit, OnChanges, OnDestroy, AfterViewInit { @ViewChild('tsChart') chartRef!: ElementRef; @ViewChild('tsDataViewport') dataViewport?: CdkVirtualScrollViewport; tsInfo: any = {}; rangeData: Array<{ timestamp: number; value: number }> = []; // Range controls rangeFrom = ''; rangeTo = ''; aggregationType = ''; aggregationBucket = ''; // Add data point addTimestamp = '*'; addValue = ''; autoRefresh = false; alterMode = false; alterRetention = 0; alterDuplicatePolicy = ''; alterLabels = ''; overlayKeysInput = ''; mrangeFilter = ''; overlaySeries: Array<{ key: string; data: Array<{ timestamp: number; value: number }> }> = []; readonly aggregationTypes = ['avg', 'min', 'max', 'sum', 'count', 'first', 'last', 'range', 'std.p', 'std.s', 'var.p', 'var.s']; private uPlot: any = null; private plot: any = null; private resizeObserver: ResizeObserver | null = null; private themeObserver: MutationObserver | null = null; private langCheckInterval: any = null; private autoRefreshInterval: any = null; private loadRangeDebounceTimer: any = null; constructor( @Inject(I18nService) i18n: I18nService, @Inject(SocketService) socket: SocketService, @Inject(CommonService) common: CommonService, @Inject(JsonViewDialogService) jsonViewDialog: JsonViewDialogService, @Inject(KeyNewOrSetDialogService) keyNewOrSetDialog: KeyNewOrSetDialogService, @Inject(BreakpointObserver) breakpointObserver: BreakpointObserver, @Inject(MainCommandService) cmd: MainCommandService, @Inject(ChangeDetectorRef) cdr: ChangeDetectorRef, @Inject(RedisStateService) redisState: RedisStateService, @Inject(SettingsService) settingsService: SettingsService, ) { super(i18n, socket, common, jsonViewDialog, keyNewOrSetDialog, breakpointObserver, cmd, cdr, redisState, settingsService); } ngOnInit(): void { this.tsInfo = this.p3xrValue || {}; this.ensureDefaultLabel(); this.loadRange(); // Re-render chart on theme change this.themeObserver = new MutationObserver(() => { setTimeout(() => this.reinitChart(), 100); }); this.themeObserver.observe(document.body, { attributes: true, attributeFilter: ['class'] }); // Re-render chart on language change let prevLang = this.i18n.currentLang() || 'en'; this.langCheckInterval = setInterval(() => { const currentLang = this.i18n.currentLang() || 'en'; if (currentLang !== prevLang) { prevLang = currentLang; setTimeout(() => this.reinitChart(), 100); } }, 500); } ngOnChanges(changes: SimpleChanges): void { if (changes['p3xrValue'] && !changes['p3xrValue'].firstChange) { this.tsInfo = this.p3xrValue || {}; this.loadRange(); } } ngAfterViewInit(): void { this.loadUPlot(); } ngOnDestroy(): void { this.destroyBase(); this.destroyChart(); this.stopAutoRefresh(); this.themeObserver?.disconnect(); if (this.langCheckInterval) clearInterval(this.langCheckInterval); } toggleAutoRefresh(): void { this.autoRefresh = !this.autoRefresh; if (this.autoRefresh) { this.startAutoRefresh(); } else { this.stopAutoRefresh(); } this.cdr.markForCheck(); } private startAutoRefresh(): void { this.stopAutoRefresh(); this.autoRefreshInterval = setInterval(() => { this.loadRange(); }, 10000); } private stopAutoRefresh(): void { if (this.autoRefreshInterval) { clearInterval(this.autoRefreshInterval); this.autoRefreshInterval = null; } } get infoLabels(): Array<{ key: string; value: any }> { if (!this.tsInfo) return []; const skip = new Set(['labels', 'rules', 'sourceKey', 'chunks']); return Object.entries(this.tsInfo) .filter(([k]) => !skip.has(k)) .map(([key, value]) => ({ key, value })); } get tsLabels(): Array<{ key: string; value: string }> { const labels = this.tsInfo?.labels; if (!labels || typeof labels !== 'object') return []; return Object.entries(labels).map(([key, value]) => ({ key, value: String(value) })); } get tsRules(): any[] { return Array.isArray(this.tsInfo?.rules) ? this.tsInfo.rules : []; } capitalize(str: string): string { return str ? str.charAt(0).toUpperCase() + str.slice(1) : ''; } debouncedLoadRange(): void { clearTimeout(this.loadRangeDebounceTimer); this.loadRangeDebounceTimer = setTimeout(() => { this.loadRange(); }, 500); } async loadRange(): Promise { try { const payload: any = { key: this.p3xrKey }; if (this.rangeFrom) payload.from = this.rangeFrom; if (this.rangeTo) payload.to = this.rangeTo; if (this.aggregationType && this.aggregationBucket) { payload.aggregation = { type: this.aggregationType, timeBucket: parseInt(this.aggregationBucket, 10), }; } const response = await this.socket.request({ action: 'timeseries/range', payload, }); this.rangeData = response.data || []; // Load overlay keys this.overlaySeries = []; const overlayKeys = this.overlayKeysInput.split(',').map(k => k.trim()).filter(k => k.length > 0); for (const overlayKey of overlayKeys) { try { const overlayPayload: any = { key: overlayKey }; if (this.rangeFrom) overlayPayload.from = this.rangeFrom; if (this.rangeTo) overlayPayload.to = this.rangeTo; if (this.aggregationType && this.aggregationBucket) { overlayPayload.aggregation = { type: this.aggregationType, timeBucket: parseInt(this.aggregationBucket, 10) }; } const overlayResponse = await this.socket.request({ action: 'timeseries/range', payload: overlayPayload }); this.overlaySeries.push({ key: overlayKey, data: overlayResponse.data || [] }); } catch { /* skip invalid keys */ } } // Load MRANGE by label filter if (this.mrangeFilter.trim().length > 0) { try { const mrangePayload: any = { filter: this.mrangeFilter.trim() }; if (this.rangeFrom) mrangePayload.from = this.rangeFrom; if (this.rangeTo) mrangePayload.to = this.rangeTo; if (this.aggregationType && this.aggregationBucket) { mrangePayload.aggregation = { type: this.aggregationType, timeBucket: parseInt(this.aggregationBucket, 10) }; } const mrangeResponse = await this.socket.request({ action: 'timeseries/mrange', payload: mrangePayload }); for (const entry of (mrangeResponse.data || [])) { if (entry.key !== this.p3xrKey) { this.overlaySeries.push({ key: entry.key, data: entry.data }); } } } catch { /* skip mrange errors */ } } this.updateChart(); this.cdr.markForCheck(); // Keep checking viewport size until accordion is opened const checkInterval = setInterval(() => { if (this.dataViewport) { this.dataViewport.checkViewportSize(); const el = this.dataViewport.elementRef.nativeElement; if (el.clientHeight > 0) { clearInterval(checkInterval); } } }, 200); setTimeout(() => clearInterval(checkInterval), 30000); } catch (e: any) { this.common.generalHandleError(e); } } private async ensureDefaultLabel(): Promise { if (this.isReadonly) return; const labels = this.tsInfo?.labels; const labelCount = labels && typeof labels === 'object' ? Object.keys(labels).length : 0; if (labelCount === 0) { try { await this.socket.request({ action: 'timeseries/alter', payload: { key: this.p3xrKey, labels: `key ${this.p3xrKey}`, }, }); this.tsInfo.labels = { key: this.p3xrKey }; this.cdr.markForCheck(); } catch { /* ignore errors */ } } } exportChartPng(): void { if (!this.plot) return; const el = this.chartRef?.nativeElement; if (!el) return; const chartCanvas = el.querySelector('canvas') as HTMLCanvasElement; if (!chartCanvas) return; const isDark = document.body.classList.contains('p3xr-theme-dark'); const bgColor = isDark ? '#1e1e1e' : '#ffffff'; const textColor = isDark ? 'rgba(255,255,255,0.87)' : 'rgba(0,0,0,0.87)'; const padding = 20; const titleHeight = 30; const legendHeight = 30; const totalWidth = chartCanvas.width + padding * 2; const totalHeight = chartCanvas.height + padding * 2 + titleHeight + legendHeight; const exportCanvas = document.createElement('canvas'); exportCanvas.width = totalWidth; exportCanvas.height = totalHeight; const ctx = exportCanvas.getContext('2d')!; // Background ctx.fillStyle = bgColor; ctx.fillRect(0, 0, totalWidth, totalHeight); // Title ctx.fillStyle = textColor; ctx.font = 'bold 14px Roboto, sans-serif'; ctx.fillText(this.p3xrKey, padding, padding + 16); // Chart ctx.drawImage(chartCanvas, padding, padding + titleHeight); // Legend const series = [this.p3xrKey, ...this.overlaySeries.map(s => s.key)]; const colors = [this.getChartColors().primary, ...this.overlaySeries.map((_, i) => this.seriesColors[(i + 1) % this.seriesColors.length])]; let legendX = padding; const legendY = padding + titleHeight + chartCanvas.height + 16; ctx.font = '12px Roboto, sans-serif'; for (let i = 0; i < series.length; i++) { ctx.fillStyle = colors[i]; ctx.fillRect(legendX, legendY - 8, 12, 12); ctx.fillStyle = textColor; ctx.fillText(series[i], legendX + 16, legendY + 2); legendX += ctx.measureText(series[i]).width + 32; } // Download const url = exportCanvas.toDataURL('image/png'); const a = document.createElement('a'); a.href = url; a.download = `${this.p3xrKey}-chart.png`; a.click(); } toggleAlterMode(): void { this.alterMode = !this.alterMode; if (this.alterMode) { this.alterRetention = this.tsInfo?.retentionTime || 0; this.alterDuplicatePolicy = this.tsInfo?.duplicatePolicy || 'LAST'; const labels = this.tsLabels.map(l => `${l.key} ${l.value}`).join(' '); this.alterLabels = labels || `key ${this.p3xrKey}`; } this.cdr.markForCheck(); } async saveAlter(): Promise { try { // Default label if empty: key const labels = this.alterLabels.trim().length > 0 ? this.alterLabels : `key ${this.p3xrKey}`; await this.socket.request({ action: 'timeseries/alter', payload: { key: this.p3xrKey, retention: this.alterRetention, duplicatePolicy: this.alterDuplicatePolicy, labels: labels, }, }); this.common.toast(this.strings?.status?.saved); this.alterMode = false; this.refreshKey(); } catch (e: any) { this.common.generalHandleError(e); } } async editDataPoint(point: { timestamp: number; value: number }): Promise { try { await this.keyNewOrSetDialog.show({ type: 'edit', model: { type: 'timeseries', key: this.p3xrKey, tsTimestamp: String(point.timestamp), value: point.value, originalTimestamp: point.timestamp, }, }); this.refreshKey(); await this.loadRange(); } catch (e: any) { if (e !== undefined && e !== null) { this.common.generalHandleError(e); } } } async editAllDataPoints(event: Event): Promise { try { const allPoints = this.rangeData.map(p => `${p.timestamp} ${p.value}`).join('\n'); const currentLabels = this.tsLabels.map(l => `${l.key} ${l.value}`).join(' ') || `key ${this.p3xrKey}`; await this.keyNewOrSetDialog.show({ type: 'edit', $event: event, model: { type: 'timeseries', key: this.p3xrKey, value: allPoints, tsEditAll: true, tsLabels: currentLabels, }, }); this.refreshKey(); await this.loadRange(); } catch (e: any) { if (e !== undefined && e !== null) { this.common.generalHandleError(e); } } } async deleteDataPoint(point: { timestamp: number; value: number }): Promise { try { await this.common.confirm({ message: this.i18n.strings().confirm?.delete, }); await this.socket.request({ action: 'timeseries/del', payload: { key: this.p3xrKey, from: point.timestamp, to: point.timestamp, }, }); this.common.toast(this.strings?.status?.deleted); this.refreshKey(); } catch (e: any) { if (e !== undefined && e !== null) { this.common.generalHandleError(e); } } } formatTimestamp(ts: number): string { const lang = this.i18n.currentLang() || 'en'; return new Date(ts).toLocaleString(lang, { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit', fractionalSecondDigits: 3 }); } async addDataPoint(): Promise { if (!this.addValue) return; try { await this.socket.request({ action: 'timeseries/add', payload: { key: this.p3xrKey, timestamp: this.addTimestamp || '*', value: parseFloat(this.addValue), }, }); this.common.toast(this.strings?.status?.added); this.addValue = ''; this.refreshKey(); } catch (e: any) { this.common.generalHandleError(e); } } // --- uPlot chart --- private async loadUPlot(): Promise { try { const mod = await import('uplot'); this.uPlot = mod.default; this.initChart(); } catch (e) { console.error('Failed to load uPlot', e); } } private readonly seriesColors = ['#1976d2', '#9c27b0', '#f44336', '#4caf50', '#ff9800', '#00bcd4', '#e91e63', '#8bc34a']; private getChartColors() { const isDark = document.body.classList.contains('p3xr-theme-dark'); const style = getComputedStyle(document.body); const primary = style.getPropertyValue('--p3xr-btn-primary-bg').trim(); return { primary: primary || (isDark ? '#90caf9' : '#1976d2'), text: isDark ? 'rgba(255,255,255,0.87)' : 'rgba(0,0,0,0.87)', grid: isDark ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.08)', }; } private createOpts(width: number): any { const colors = this.getChartColors(); const s = this.strings?.page?.key?.timeseries || {}; const seriesConfig: any[] = [ { label: this.strings?.label?.time, value: (_: any, v: number) => { if (!v) return ''; const lang = this.i18n.currentLang() || 'en'; return new Date(v * 1000).toLocaleString(lang, { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit' }); }, }, { label: this.p3xrKey, stroke: colors.primary, width: 2, fill: colors.primary + '15', }, ]; // Add overlay series for (let i = 0; i < this.overlaySeries.length; i++) { const color = this.seriesColors[(i + 1) % this.seriesColors.length]; seriesConfig.push({ label: this.overlaySeries[i].key, stroke: color, width: 2, }); } return { width, height: 400, cursor: { show: true, drag: { x: false, y: false } }, legend: { show: true, live: true }, scales: { x: { time: true } }, axes: [ { stroke: colors.text, grid: { stroke: colors.grid, width: 1 }, ticks: { stroke: colors.grid }, font: '11px Roboto', values: (_: any, ticks: number[]) => ticks.map(t => new Date(t * 1000).toLocaleTimeString(this.i18n.currentLang(), { hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false })), }, { stroke: colors.text, grid: { stroke: colors.grid, width: 1 }, ticks: { stroke: colors.grid }, font: '11px Roboto Mono', size: 65, }, ], series: seriesConfig, }; } private initChart(): void { if (!this.uPlot) return; const el = this.chartRef?.nativeElement; if (!el) return; this.destroyChart(); const w = el.clientWidth || 400; const chartData = this.buildChartData(); this.plot = new this.uPlot(this.createOpts(w), chartData, el); let resizeTimer: any; this.resizeObserver = new ResizeObserver(() => { clearTimeout(resizeTimer); resizeTimer = setTimeout(() => { const nw = el.clientWidth; if (nw > 0) this.plot?.setSize({ width: nw, height: 400 }); }, 50); }); this.resizeObserver.observe(el); } private reinitChart(): void { this.destroyChart(); this.initChart(); } private updateChart(): void { // Reinit when series count changes (overlay added/removed) const expectedSeries = 2 + this.overlaySeries.length; if (!this.plot || (this.plot.series?.length !== expectedSeries)) { this.reinitChart(); return; } const chartData = this.buildChartData(); this.plot.setData(chartData, true); if (chartData[0].length > 0) { this.plot.setScale('x', { min: chartData[0][0], max: chartData[0][chartData[0].length - 1] }); } } private buildChartData(): number[][] { if (this.overlaySeries.length === 0) { // Simple case: single series return [ this.rangeData.map(d => d.timestamp / 1000), this.rangeData.map(d => d.value), ]; } // Multiple series: merge all timestamps, align values with nulls for gaps const allSeries = [this.rangeData, ...this.overlaySeries.map(s => s.data)]; const tsSet = new Set(); for (const series of allSeries) { for (const d of series) tsSet.add(d.timestamp); } const sortedTs = Array.from(tsSet).sort((a, b) => a - b); const timestamps = sortedTs.map(t => t / 1000); const result: number[][] = [timestamps]; for (const series of allSeries) { const valueMap = new Map(); for (const d of series) valueMap.set(d.timestamp, d.value); result.push(sortedTs.map(t => valueMap.has(t) ? valueMap.get(t)! : null as any)); } return result; } private destroyChart(): void { this.resizeObserver?.disconnect(); this.resizeObserver = null; this.plot?.destroy(); this.plot = null; } } src/ng/pages/database/key/key-type-base.ts000066400000000000000000000124111517727315400207460ustar00rootroot00000000000000import { Input, Directive, ChangeDetectorRef } from '@angular/core'; import { detectFileType } from '../../../../core/detect-file-type'; 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 { JsonViewDialogService } from '../../../dialogs/json-view-dialog.service'; import { KeyNewOrSetDialogService } from '../../../dialogs/key-new-or-set-dialog.service'; import { RedisStateService } from '../../../services/redis-state.service'; import { SettingsService } from '../../../services/settings.service'; /** * Shared base for all key type renderers. * Provides common inputs, dialog calls, clipboard, download, event broadcasting, * and responsive breakpoint (isGtSm for button text visibility). */ @Directive() export abstract class KeyTypeBase { @Input() p3xrResponse: any; @Input() p3xrValue: any; @Input() p3xrValueBuffer: any; @Input() p3xrKey: string = ''; /** >960px — show button text labels (matching AngularJS hide-xs hide-sm) */ isGtSm = window.innerWidth >= 960; /** Value display format */ @Input() valueFormat: 'raw' | 'json' | 'hex' | 'base64' = 'raw'; protected readonly unsubFns: Array<() => void> = []; constructor( protected readonly i18n: I18nService, protected readonly socket: SocketService, protected readonly common: CommonService, protected readonly jsonViewDialog: JsonViewDialogService, protected readonly keyNewOrSetDialog: KeyNewOrSetDialogService, protected readonly breakpointObserver: BreakpointObserver, protected readonly cmd: MainCommandService, protected readonly cdr: ChangeDetectorRef, protected readonly redisState: RedisStateService, protected readonly settingsService: SettingsService, ) { const sub = this.breakpointObserver.observe('(min-width: 960px)').subscribe(r => { this.isGtSm = r.matches; this.cdr.markForCheck(); }); this.unsubFns.push(() => sub.unsubscribe()); } destroyBase(): void { this.unsubFns.forEach(fn => fn()); } get strings() { return this.i18n.strings(); } get isReadonly(): boolean { return this.redisState.connection()?.readonly === true; } get maxValueDisplay(): number { return this.settingsService.maxValueDisplay() ?? 1024; } get maxValueAsBuffer(): number { return this.settingsService.maxValueAsBuffer; } async copy(value: any): Promise { await this.settingsService.clipboard(value); this.common.toast(this.strings?.status?.dataCopied); } downloadBuffer(buffer: any, filename?: string): void { const bytes = buffer instanceof Uint8Array ? buffer : new Uint8Array(buffer); const { ext, mime } = detectFileType(bytes); const blob = new Blob([bytes], { type: mime }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = `${filename || this.p3xrKey}.${ext}`; a.click(); URL.revokeObjectURL(url); } async showJson(value: any, event?: Event): Promise { try { await this.jsonViewDialog.show({ value, $event: event }); } catch { /* cancelled */ } } protected refreshKey(): void { this.cmd.refreshKey$.next(); } protected gtag(page: string): void { try { if (typeof (window as any).gtag === 'function') { (window as any).gtag('config', this.settingsService.googleAnalytics, { page_path: page }); } } catch { /* noop */ } } protected truncateDisplay(value: any): string { if (value == null) return ''; const str = String(value); if (this.maxValueDisplay <= 0) return str; if (str.length > this.maxValueDisplay) { return str.substring(0, this.maxValueDisplay); } return str; } protected isTruncated(value: any): boolean { if (value == null || this.maxValueDisplay <= 0) return false; return String(value).length > this.maxValueDisplay; } formatValue(value: any): string { if (value == null) return ''; const str = String(value); switch (this.valueFormat) { case 'json': try { return JSON.stringify(JSON.parse(str), null, 2); } catch { return str; } case 'base64': { const raw = new TextEncoder().encode(str); let binary = ''; for (let i = 0; i < raw.length; i++) { binary += String.fromCharCode(raw[i]); } return btoa(binary); } default: return str; } } protected isBufferValue(value: any): boolean { return typeof value === 'object' && value !== null && value.byteLength !== undefined; } protected prettyBytes(length: number): string { return this.settingsService.prettyBytes(length) ?? `${length} bytes`; } } src/ng/pages/database/key/key-types.scss000066400000000000000000000111511517727315400205460ustar00rootroot00000000000000// Shared styles for all key type renderers .p3xr-key-type-actions { display: flex; flex-wrap: wrap; justify-content: flex-end; align-items: center; gap: 8px; padding: 4px 8px; } .p3xr-key-type-content { padding: 8px 16px 24px; } .p3xr-key-type-textarea { width: 100%; font-family: monospace; font-size: 13px; background: var(--p3xr-input-bg); color: var(--p3xr-input-color); border: 1px solid var(--p3xr-fieldset-border); border-radius: 4px; padding: 8px; resize: vertical; } .p3xr-key-type-value { white-space: pre-wrap; word-break: break-all; padding: 8px; margin: 0; font-size: 13px; } .p3xr-key-type-display { padding: 8px; } // Full-width mat-form-field editor matching AngularJS md-input-container md-block .p3xr-key-type-editor { width: 100%; textarea { font-family: 'Roboto Mono', monospace; font-size: 13px; } } .p3xr-key-type-buffer-info { padding: 8px; opacity: 0.7; font-style: italic; } // Table layout for hash/list/set/zset/stream .p3xr-key-type-table { width: 100%; } .p3xr-key-type-header { display: flex; align-items: center; gap: 8px; padding: 8px 16px; font-weight: bold; // Themed header background matching AngularJS md-colors="{ background: 'primary-300' }" background-color: var(--p3xr-btn-primary-bg); color: var(--p3xr-btn-primary-color); border-bottom: 2px solid var(--p3xr-list-border); // Header icon button should contrast against the primary background .mat-mdc-icon-button { color: var(--p3xr-btn-primary-color) !important; } } .p3xr-key-type-row { display: flex; align-items: flex-start; gap: 8px; padding: 6px 16px; border-bottom: 1px solid var(--p3xr-list-border); &:hover { background-color: var(--p3xr-hover-bg); } // Odd row alternating background &:nth-child(odd) { background-color: var(--p3xr-list-odd-bg); &:hover { background-color: var(--p3xr-hover-bg); } } // Key column: single-line truncated .p3xr-key-col { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; user-select: text; } // Value column: multiline with scroll, scrollbar hidden by default, visible on hover .p3xr-value-col { overflow-x: hidden; overflow-y: auto; max-height: 200px; white-space: pre-wrap; word-break: break-all; user-select: text; &::-webkit-scrollbar { width: 6px; } &::-webkit-scrollbar-track { background: transparent; } &::-webkit-scrollbar-thumb { background: transparent; border-radius: 3px; } &:hover::-webkit-scrollbar-thumb { background: rgba(128, 128, 128, 0.4); } } } .p3xr-key-type-row-actions { white-space: nowrap !important; mat-icon { cursor: pointer; font-size: 24px; width: 24px; height: 24px; margin: 0 2px; opacity: 0.7; &:hover { opacity: 1; } } // Themed icon colors matching AngularJS md-warn, md-accent, md-primary .icon-warn { color: var(--p3xr-btn-warn-bg); } .icon-accent { color: var(--p3xr-btn-accent-bg); } .icon-primary { color: var(--p3xr-btn-primary-bg); } } // Stream entry block layout .p3xr-key-stream-entry-block { border-bottom: 1px solid var(--p3xr-list-border); &:nth-child(odd) { background-color: var(--p3xr-list-odd-bg); } &:hover { background-color: var(--p3xr-hover-bg); } } .p3xr-key-stream-entry-header { display: flex; align-items: center; justify-content: space-between; padding: 6px 16px; font-size: 13px; } .p3xr-key-stream-timestamp { display: flex; align-items: center; gap: 12px; } .p3xr-key-stream-timestamp-human { opacity: 0.5; font-size: 12px; } .p3xr-key-stream-data { padding: 0 16px 8px 16px; overflow: auto; max-height: 300px; } // TimeSeries .p3xr-timeseries-controls { display: flex; flex-wrap: wrap; align-items: flex-start; gap: 12px; padding: 8px 0; } .p3xr-timeseries-field { min-width: 140px; max-width: 200px; } .p3xr-timeseries-chart-container { width: 100%; min-height: 400px; } .p3xr-timeseries-chart-info { padding: 4px 0; opacity: 0.6; font-size: 13px; } .p3xr-timeseries-info-key { min-width: 180px; flex-shrink: 0; } .p3xr-timeseries-info-value { word-break: break-all; } src/ng/pages/database/key/key-vectorset.component.html000066400000000000000000000216621517727315400234220ustar00rootroot00000000000000

@if (!autoRefresh) { }
@for (item of infoItems; track item.key; let last = $last) {
{{ item.key }} {{ item.value }}
@if (!last) { } }

{{ strings()?.page?.key?.vectorset?.element }} {{ strings()?.page?.key?.vectorset?.score }} @if (!readonly) { }
@for (elem of pagedElements; track elem.element) {
{{ elem.element }} {{ elem.score?.toFixed(4) }} search info @if (!readonly) { delete }
}
@if (!readonly && showAddForm) {
{{ strings()?.page?.key?.vectorset?.elementName }} {{ strings()?.page?.key?.vectorset?.vectorValues }} @if (isGtSm) { } @else { }
}

{{ simMode === 'element' ? (strings()?.page?.key?.vectorset?.elementName) : (strings()?.page?.key?.vectorset?.vectorValues) }} {{ strings()?.page?.key?.vectorset?.count }} @if (redisState.redisVersion().isAtLeast(8, 2)) { {{ strings()?.page?.key?.vectorset?.filter }} } @if (isGtSm) { } @else { }
@if (simResults.length > 0) { @for (result of simResults; track result.element; let last = $last) {
{{ result.element }} {{ result.score }}
@if (!last) { } }
}
src/ng/pages/database/key/key-vectorset.component.ts000066400000000000000000000222561517727315400231040ustar00rootroot00000000000000import { Component, Inject, OnInit, OnChanges, OnDestroy, SimpleChanges, ChangeDetectorRef, CUSTOM_ELEMENTS_SCHEMA, ViewEncapsulation } from '@angular/core'; import { CommonModule } from '@angular/common'; import { FormsModule } from '@angular/forms'; import { MatButtonModule } from '@angular/material/button'; import { MatIconModule } from '@angular/material/icon'; import { MatTooltipModule } from '@angular/material/tooltip'; import { MatFormFieldModule } from '@angular/material/form-field'; import { MatInputModule } from '@angular/material/input'; import { MatListModule } from '@angular/material/list'; import { MatDividerModule } from '@angular/material/divider'; 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 { JsonViewDialogService } from '../../../dialogs/json-view-dialog.service'; import { KeyNewOrSetDialogService } from '../../../dialogs/key-new-or-set-dialog.service'; import { MainCommandService } from '../../../services/main-command.service'; import { RedisStateService } from '../../../services/redis-state.service'; import { SettingsService } from '../../../services/settings.service'; import { KeyTypeBase } from './key-type-base'; import { KeyPaging } from './key-paging'; import { KeyPagerInlineComponent } from './key-pager-inline.component'; import { P3xrAccordionComponent } from '../../../components/p3xr-accordion.component'; import { P3xrButtonComponent } from '../../../components/p3xr-button.component'; @Component({ selector: 'p3xr-key-vectorset', standalone: true, imports: [ CommonModule, FormsModule, MatButtonModule, MatIconModule, MatTooltipModule, MatFormFieldModule, MatInputModule, MatListModule, MatDividerModule, P3xrAccordionComponent, P3xrButtonComponent, KeyPagerInlineComponent, ], schemas: [CUSTOM_ELEMENTS_SCHEMA], templateUrl: './key-vectorset.component.html', encapsulation: ViewEncapsulation.None, }) export class KeyVectorsetComponent extends KeyTypeBase implements OnInit, OnChanges, OnDestroy { infoItems: Array<{ key: string; value: any }> = []; elements: Array<{ element: string; score: number }> = []; pagedElements: Array<{ element: string; score: number }> = []; paging: KeyPaging; simResults: Array<{ element: string; score: number }> = []; elementInput = ''; vectorInput = ''; simQueryInput = ''; simCountInput = 10; simFilterInput = ''; simMode: 'element' | 'vector' = 'element'; autoRefresh = false; private autoRefreshInterval: any = null; readonly = false; showAddForm = false; constructor( @Inject(I18nService) i18n: I18nService, @Inject(SocketService) socket: SocketService, @Inject(CommonService) common: CommonService, @Inject(JsonViewDialogService) jsonViewDialog: JsonViewDialogService, @Inject(KeyNewOrSetDialogService) keyNewOrSetDialog: KeyNewOrSetDialogService, @Inject(BreakpointObserver) breakpointObserver: BreakpointObserver, @Inject(MainCommandService) cmd: MainCommandService, @Inject(ChangeDetectorRef) cdr: ChangeDetectorRef, @Inject(RedisStateService) redisState: RedisStateService, @Inject(SettingsService) settingsService: SettingsService, ) { super(i18n, socket, common, jsonViewDialog, keyNewOrSetDialog, breakpointObserver, cmd, cdr, redisState, settingsService); this.paging = new KeyPaging({ settingsService }); } updatePagedElements(): void { this.pagedElements = this.elements.slice(this.paging.startIndex, this.paging.endIndex); } get strings() { return this.i18n.strings; } ngOnInit() { this.readonly = this.redisState.connection()?.readonly === true; this.parseInfo(); this.loadElements(); } ngOnChanges(changes: SimpleChanges) { if (changes['p3xrValue']) { this.parseInfo(); } } ngOnDestroy() { this.stopAutoRefresh(); this.destroyBase(); } toggleAutoRefresh(): void { this.autoRefresh = !this.autoRefresh; if (this.autoRefresh) { this.startAutoRefresh(); } else { this.stopAutoRefresh(); } this.cdr.markForCheck(); } private startAutoRefresh(): void { this.stopAutoRefresh(); this.autoRefreshInterval = setInterval(() => { this.refresh(); }, 10000); } private stopAutoRefresh(): void { if (this.autoRefreshInterval) { clearInterval(this.autoRefreshInterval); this.autoRefreshInterval = null; } } private parseInfo() { this.infoItems = []; try { const val = this.p3xrValue; let info: any; if (typeof val === 'object' && val !== null && !ArrayBuffer.isView(val)) { info = val; } else if (typeof val === 'string') { info = JSON.parse(val); } else if (ArrayBuffer.isView(val)) { info = JSON.parse(new TextDecoder().decode(val as any)); } if (info && typeof info === 'object') { this.infoItems = Object.entries(info).map(([key, value]) => ({ key, value })); } } catch { // ignore parse errors } } async loadElements() { try { const response = await this.socket.request({ action: 'vectorset/elements', payload: { key: this.p3xrKey, }, }); this.elements = (response as any).elements || []; this.paging.figurePaging(this.elements.length); this.updatePagedElements(); } catch { this.elements = []; this.pagedElements = []; } this.cdr.markForCheck(); } async searchSimilar(queryValue?: string, mode?: 'element' | 'vector') { const query = queryValue || this.simQueryInput; const searchMode = mode || this.simMode; if (!query.trim()) return; try { const response = await this.socket.request({ action: 'vectorset/sim', payload: { key: this.p3xrKey, query: query.trim(), count: this.simCountInput, mode: searchMode, filter: this.simFilterInput.trim() || undefined, }, }); this.simResults = (response as any).results || []; this.common.toast(this.strings()?.page?.key?.vectorset?.searchComplete); } catch (e: any) { this.common.toast(e.message || 'Error'); this.simResults = []; } this.cdr.markForCheck(); } searchSimilarByElement(element: string) { this.simQueryInput = element; this.simMode = 'element'; this.searchSimilar(element, 'element'); } async addElement() { if (!this.elementInput.trim() || !this.vectorInput.trim()) return; try { await this.socket.request({ action: 'vectorset/add', payload: { key: this.p3xrKey, element: this.elementInput.trim(), vector: this.vectorInput.trim(), }, }); this.common.toast(this.strings()?.page?.key?.vectorset?.addedSuccessfully); this.elementInput = ''; this.vectorInput = ''; this.refresh(); } catch (e: any) { this.common.toast(e.message || 'Error'); } this.cdr.markForCheck(); } async removeElement(element: string) { try { await this.common.confirm({ message: this.strings()?.confirm?.delete, }); await this.socket.request({ action: 'vectorset/remove', payload: { key: this.p3xrKey, element: element, }, }); this.common.toast(this.strings()?.page?.key?.vectorset?.removedSuccessfully); this.refresh(); } catch (e: any) { if (e?.message) this.common.toast(e.message); } this.cdr.markForCheck(); } async getAttributes(element: string) { try { const response = await this.socket.request({ action: 'vectorset/getattr', payload: { key: this.p3xrKey, element: element, }, }); const attrs = (response as any).attributes; if (attrs) { this.common.toast(`${element}: ${JSON.stringify(attrs)}`); } else { this.common.toast(`${element}: ${this.strings()?.page?.key?.vectorset?.noAttributes}`); } } catch (e: any) { this.common.toast(e.message || 'Error'); } this.cdr.markForCheck(); } refresh() { this.cmd.refreshKey$.next(); this.loadElements(); } } src/ng/pages/database/key/key-zset.component.html000066400000000000000000000040201517727315400223560ustar00rootroot00000000000000
{{ strings?.page?.key?.zset?.table?.score }} {{ strings?.page?.key?.zset?.table?.value }} @if (!isReadonly) { }
@for (item of pagedItems; track item.index) {
{{ item.score }} @if (valueFormat === 'hex') {} @else {{{ truncateDisplay(formatValue(item.member)) }}@if (isTruncated(item.member)) {...}} @if (!isReadonly) { delete } account_tree content_copy download @if (!isReadonly) { edit }
}
src/ng/pages/database/key/key-zset.component.ts000066400000000000000000000117601517727315400220510ustar00rootroot00000000000000import { Component, Inject, OnInit, OnChanges, SimpleChanges, ChangeDetectorRef, CUSTOM_ELEMENTS_SCHEMA, ViewEncapsulation } from '@angular/core'; import { CommonModule } from '@angular/common'; import { FormsModule } from '@angular/forms'; 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'; import { SocketService } from '../../../services/socket.service'; import { CommonService } from '../../../services/common.service'; import { JsonViewDialogService } from '../../../dialogs/json-view-dialog.service'; import { KeyNewOrSetDialogService } from '../../../dialogs/key-new-or-set-dialog.service'; import { MainCommandService } from '../../../services/main-command.service'; import { RedisStateService } from '../../../services/redis-state.service'; import { SettingsService } from '../../../services/settings.service'; import { KeyTypeBase } from './key-type-base'; import { HexMonitorComponent } from './hex-monitor.component'; import { KeyPaging } from './key-paging'; import { KeyPagerInlineComponent } from './key-pager-inline.component'; @Component({ selector: 'p3xr-key-zset', standalone: true, imports: [CommonModule, FormsModule, MatButtonModule, MatIconModule, MatTooltipModule, KeyPagerInlineComponent, HexMonitorComponent], schemas: [CUSTOM_ELEMENTS_SCHEMA], templateUrl: './key-zset.component.html', encapsulation: ViewEncapsulation.None, }) export class KeyZsetComponent extends KeyTypeBase implements OnInit, OnChanges { paging: KeyPaging; pagedItems: Array<{ score: string; member: string; index: number }> = []; constructor( @Inject(I18nService) i18n: I18nService, @Inject(SocketService) socket: SocketService, @Inject(CommonService) common: CommonService, @Inject(JsonViewDialogService) jsonViewDialog: JsonViewDialogService, @Inject(KeyNewOrSetDialogService) keyNewOrSetDialog: KeyNewOrSetDialogService, @Inject(BreakpointObserver) breakpointObserver: BreakpointObserver, @Inject(MainCommandService) cmd: MainCommandService, @Inject(ChangeDetectorRef) cdr: ChangeDetectorRef, @Inject(RedisStateService) redisState: RedisStateService, @Inject(SettingsService) settingsService: SettingsService, ) { super(i18n, socket, common, jsonViewDialog, keyNewOrSetDialog, breakpointObserver, cmd, cdr, redisState, settingsService); this.paging = new KeyPaging({ zsetMode: true, settingsService }); } ngOnInit(): void { this.updatePaging(); } ngOnChanges(c: SimpleChanges): void { if (c['p3xrValue']) this.updatePaging(); } updatePaging(): void { if (!this.p3xrValue) return; this.paging.figurePaging(this.p3xrValue.length); this.updatePagedItems(); } updatePagedItems(): void { if (!this.p3xrValue) { this.pagedItems = []; return; } // Zset flat array: [member, score, member, score, ...] const items: Array<{ score: string; member: string; index: number }> = []; for (let i = 0; i < this.p3xrValue.length; i += 2) { items.push({ member: this.p3xrValue[i], score: this.p3xrValue[i + 1], index: i / 2 }); } this.pagedItems = items.slice(this.paging.startIndex, this.paging.endIndex); } async addZSet(event: Event): Promise { try { await this.keyNewOrSetDialog.show({ $event: event, type: 'append', model: { type: 'zset', key: this.p3xrKey } }); this.refreshKey(); } catch (e) { this.common.generalHandleError(e); } } async deleteZSet(item: any, event: Event): Promise { try { await this.common.confirm({ message: this.i18n.strings().confirm?.deleteZSetMember }); await this.socket.request({ action: 'key/zset-delete-member', payload: { key: this.p3xrKey, value: this.p3xrValueBuffer[item.index * 2] }, }); this.common.toast(this.i18n.strings().status?.deletedZSetMember); this.refreshKey(); } catch (e) { this.common.generalHandleError(e); } } async editValue(item: any, event: Event): Promise { try { const editValue = typeof item.member === 'string' && item.member.length >= this.maxValueAsBuffer ? this.p3xrValueBuffer[item.index * 2] : item.member; await this.keyNewOrSetDialog.show({ $event: event, type: 'edit', model: { type: 'zset', key: this.p3xrKey, value: editValue, score: item.score }, }); this.refreshKey(); } catch (e) { this.common.generalHandleError(e); } } copyItem(value: any): void { this.copy(value); } showJsonItem(value: any, event: Event): void { this.showJson(value, event); } downloadItem(index: number): void { this.downloadBuffer(this.p3xrValueBuffer[index * 2]); } } src/ng/pages/database/statistics.component.html000066400000000000000000000030421517727315400222100ustar00rootroot00000000000000 @if (hasDatabases && !isCluster) { @for (dbEntry of keyspaceDatabaseEntries; track dbEntry.key) {
@for (item of getKeyspaceItems(dbEntry.key); track item.key) {
{{ generateKey(item.key) }} {{ item.value }}
}
}
} @for (section of infoSections; track section.key) {
@for (item of section.items; track item.key) {
{{ generateKey(item.key) }} {{ formatValue(item.value) }}
}
}
src/ng/pages/database/statistics.component.scss000066400000000000000000000037531517727315400222300ustar00rootroot00000000000000// Sticky tab headers — stay visible when scrolling content. // The scroll container is #p3xr-database-content-container (position:fixed, overflow:auto). // sticky works relative to the nearest scrolling ancestor. p3xr-ng-main-statistics > :first-child > .mat-mdc-tab-header, p3xr-database-statistics > :first-child > .mat-mdc-tab-header { position: sticky !important; top: 0 !important; z-index: 2 !important; background-color: var(--p3xr-content-bg, #303030) !important; } p3xr-ng-main-statistics .mat-mdc-tab .mdc-tab__text-label, p3xr-database-statistics .mat-mdc-tab .mdc-tab__text-label { text-transform: none !important; } .p3xr-statistics-list { padding: 8px 16px; } .p3xr-statistics-item { display: flex; align-items: baseline; justify-content: space-between; padding: 8px 0; border-bottom: 1px solid rgba(0, 0, 0, 0.06); span { text-align: right; } } // Dark theme: lighter border body.p3xr-theme-dark .p3xr-statistics-item { border-bottom-color: rgba(255, 255, 255, 0.06); } // DB sub-tabs: primary background matching AngularJS md-tabs.md-primary // Uses the main theme's primary palette (--p3xr-btn-primary-bg) .p3xr-statistics-db-tabs .mat-mdc-tab-header { background-color: var(--p3xr-btn-primary-bg) !important; } .p3xr-statistics-db-tabs .mat-mdc-tab:not(.mdc-tab--active) .mdc-tab__text-label { color: rgba(255, 255, 255, 0.7) !important; } .p3xr-statistics-db-tabs .mat-mdc-tab.mdc-tab--active .mdc-tab__text-label { color: white !important; } .p3xr-statistics-db-tabs .mat-mdc-tab-header .mdc-tab-indicator__content--underline { border-color: white !important; } // Matrix: black text on bright green tab background body.p3xr-mat-theme-matrix .p3xr-statistics-db-tabs .mat-mdc-tab .mdc-tab__text-label { color: rgba(0, 0, 0, 0.87) !important; } body.p3xr-mat-theme-matrix .p3xr-statistics-db-tabs .mat-mdc-tab-header .mdc-tab-indicator__content--underline { border-color: rgba(0, 0, 0, 0.87) !important; } src/ng/pages/database/statistics.component.ts000066400000000000000000000137161517727315400217030ustar00rootroot00000000000000import { Component, Inject, OnInit, OnDestroy, ChangeDetectorRef, ChangeDetectionStrategy, CUSTOM_ELEMENTS_SCHEMA, ViewEncapsulation, effect } from '@angular/core'; import { MatTabsModule } from '@angular/material/tabs'; import { BreakpointObserver } from '@angular/cdk/layout'; import { I18nService } from '../../services/i18n.service'; import { MainCommandService } from '../../services/main-command.service'; import { RedisStateService } from '../../services/redis-state.service'; @Component({ selector: 'p3xr-database-statistics', standalone: true, imports: [MatTabsModule], schemas: [CUSTOM_ELEMENTS_SCHEMA], templateUrl: './statistics.component.html', styleUrls: ['./statistics.component.scss'], encapsulation: ViewEncapsulation.None, changeDetection: ChangeDetectionStrategy.OnPush, }) export class StatisticsComponent implements OnInit, OnDestroy { maxHeight: number | string = 'auto'; hasDatabases = false; isCluster = false; // Parsed from state.info() (snapshot taken in ngOnInit) keyspaceDatabaseEntries: Array<{ key: string; value: any }> = []; keyspaceItems: Record> = {}; infoSections: Array<{ key: string; items: Array<{ key: string; value: any }> }> = []; private readonly unsubFns: Array<() => void> = []; private static readonly EXCLUDE = ['in', 'run', 'per']; private static readonly INCLUDE = ['sha1']; private static readonly REPLACE: Record = { perc: 'percent', sec: 'seconds' }; constructor( @Inject(BreakpointObserver) private readonly breakpointObserver: BreakpointObserver, @Inject(I18nService) readonly i18n: I18nService, @Inject(MainCommandService) private readonly cmd: MainCommandService, @Inject(ChangeDetectorRef) private readonly cdr: ChangeDetectorRef, @Inject(RedisStateService) private readonly state: RedisStateService, ) { effect(() => { this.i18n.currentLang(); this.cdr.markForCheck(); }); } ngOnInit(): void { const info = this.state.info(); // Check if tree needs refresh if (this.state.redisChanged()) { this.state.redisChanged.set(false); this.broadcastRefresh(); } // Parse info data const connection = this.state.connection(); this.isCluster = connection?.cluster === true; if (info) { const ksDbs = info.keyspaceDatabases ?? {}; this.hasDatabases = Object.keys(ksDbs).length > 0; this.keyspaceDatabaseEntries = Object.keys(ksDbs).map(k => ({ key: k, value: ksDbs[k] })); // Snapshot keyspace items per DB so the template doesn't read live data for (const dbEntry of this.keyspaceDatabaseEntries) { const ks = info?.keyspace?.['db' + dbEntry.key]; this.keyspaceItems[dbEntry.key] = ks ? Object.keys(ks).map(k => ({ key: k, value: ks[k] })) : []; } this.infoSections = Object.keys(info) .filter(k => k !== 'keyspace' && k !== 'keyspaceDatabases') .map(k => ({ key: k, items: Object.keys(info[k]).map(ik => ({ key: ik, value: info[k][ik] })), })); // Replace or add Modules section with full MODULE LIST data const modules = Array.isArray(this.state.modules()) ? this.state.modules() : []; if (modules.length > 0) { const moduleItems = modules.map((m: any) => ({ key: m.name, value: `v${m.ver}`, })); const existingIdx = this.infoSections.findIndex(s => s.key.toLowerCase() === 'modules'); if (existingIdx >= 0) { this.infoSections[existingIdx].items = moduleItems; } else { this.infoSections.push({ key: 'modules', items: moduleItems }); } } // Hide sections with no content this.infoSections = this.infoSections.filter(s => s.items.length > 0); } // Responsive height const sub = this.breakpointObserver.observe('(max-width: 599px)').subscribe(r => { this.recalcHeight(r.matches); this.cdr.markForCheck(); }); this.unsubFns.push(() => sub.unsubscribe()); } ngOnDestroy(): void { this.unsubFns.forEach(fn => fn()); } getKeyspaceItems(dbKey: string): Array<{ key: string; value: any }> { return this.keyspaceItems[dbKey] ?? []; } formatValue(value: any): string { if (value === null || value === undefined) return ''; if (typeof value === 'object') return JSON.stringify(value); return String(value); } generateKey(key: string): string { const strings = this.i18n.strings(); if (strings?.title?.hasOwnProperty(key)) { return strings.title[key]; } return key.split('_').map((instance, index) => { if (StatisticsComponent.REPLACE.hasOwnProperty(instance)) { instance = StatisticsComponent.REPLACE[instance]; } if (StatisticsComponent.INCLUDE.includes(instance) || (instance.length < 4 && !StatisticsComponent.EXCLUDE.includes(instance))) { return instance.toUpperCase(); } else if (index === 0) { return instance[0].toUpperCase() + instance.substring(1); } return instance; }).join(' '); } private recalcHeight(isXSmall: boolean): void { if (isXSmall) { this.maxHeight = 'auto'; } else { const container = document.getElementById('p3xr-database-content-container'); this.maxHeight = container ? container.offsetHeight - 50 : 'auto'; } } private broadcastRefresh(): void { this.cmd.treeRefresh$.next(); } } src/ng/pages/info.component.ts000066400000000000000000000166361517727315400167040ustar00rootroot00000000000000import { Component, Inject, OnInit, OnDestroy } from '@angular/core'; import { CommonModule } from '@angular/common'; import { MatListModule } from '@angular/material/list'; import { MatDividerModule } from '@angular/material/divider'; import { MatIconModule } from '@angular/material/icon'; import { I18nService } from '../services/i18n.service'; import { ShortcutsService } from '../services/shortcuts.service'; import { SocketService } from '../services/socket.service'; import { P3xrAccordionComponent } from '../components/p3xr-accordion.component'; import { RedisStateService } from '../services/redis-state.service'; @Component({ selector: 'p3xr-info', standalone: true, imports: [ CommonModule, MatListModule, MatDividerModule, MatIconModule, P3xrAccordionComponent, ], template: ` @if (isElectron) {
@for (shortcut of shortcutsList; track shortcut.key) {
{{ shortcut.key }}
{{ shortcut.description }}
}

}
{{ strings().label?.version }}
{{ version }}
@if (isConnected) {
{{ strings().label?.redisVersion }}
{{ redisVersion }}
} @if (isConnected && modules.length > 0) {
{{ strings().label?.modules }}
{{ modules.join(', ') }}
}
{{ strings().title?.donate }}
{{ strings().intention?.githubChangelog }}

@for (lang of languageList; track lang.code) {
{{ lang.code }}
{{ lang.name }}
}
`, styles: [` :host { display: block; padding-bottom: 64px; } `], }) export class InfoComponent implements OnInit, OnDestroy { strings; isElectron: boolean; shortcutsList: Array<{ key: string; description: string }> = []; get version(): string { return this.state.version() || ''; } get isConnected(): boolean { return !!this.state.connection(); } get redisVersion(): string { return this.state.info()?.server?.redis_version || '-'; } get modules(): string[] { return (this.state.modules() || []).map((m: any) => m.name); } get languageList(): Array<{ code: string; name: string }> { const langObj = this.strings()?.language || {}; return Object.keys(langObj) .sort() .map(code => ({ code, name: langObj[code] })); } private unsubs: Array<() => void> = []; constructor( @Inject(I18nService) private i18n: I18nService, @Inject(ShortcutsService) private shortcutsService: ShortcutsService, @Inject(SocketService) private socket: SocketService, @Inject(RedisStateService) private state: RedisStateService, ) { this.strings = this.i18n.strings; this.isElectron = this.shortcutsService.isEnabled(); this.shortcutsList = this.shortcutsService.getShortcutsWithDescriptions(); } ngOnInit(): void { const sub = this.socket.redisDisconnected$.subscribe(() => {}); this.unsubs.push(() => sub.unsubscribe()); } ngOnDestroy(): void { this.unsubs.forEach(fn => fn()); } } src/ng/pages/monitoring/000077500000000000000000000000001517727315400155515ustar00rootroot00000000000000src/ng/pages/monitoring/memory-analysis.component.html000066400000000000000000000270511517727315400235760ustar00rootroot00000000000000
@if (!autoRefreshDoctor) { }
@if (!doctorText) {
{{ s().doctorNoData }}
} @else {
{{ doctorText }}
}

@if (loading && !data) {
hourglass_empty {{ s().running }}
} @if (!loading && !data) {
analytics {{ s().noData }}
} @if (data) {
{{ s().keysScanned }}
{{ data.totalScanned | number }} / {{ data.dbSize | number }}
{{ s().topN }}
{{ s().maxScanKeys }}

{{ s().totalMemory }}
{{ data.memoryInfo.usedHuman }}
{{ s().rssMemory }}
{{ data.memoryInfo.rssHuman }}
{{ s().peakMemory }}
{{ data.memoryInfo.peakHuman }}
{{ s().overheadMemory }}
{{ formatBytes(data.memoryInfo.overhead) }}
{{ s().datasetMemory }}
{{ formatBytes(data.memoryInfo.dataset) }}
{{ s().luaMemory }}
{{ formatBytes(data.memoryInfo.lua) }}
{{ s().fragmentation }}
{{ data.memoryInfo.fragRatio }}x
{{ s().allocator }}
{{ data.memoryInfo.allocator }}

@for (item of typeEntries; track item.type) {
{{ item.type }} {{ item.count }} keys
{{ formatBytes(item.bytes) }}
}

@for (item of data.prefixMemory; track item.prefix; let i = $index) {
#{{ i + 1 }} {{ item.prefix }} {{ item.keyCount }} keys
{{ formatBytes(item.totalBytes) }}
}

{{ s().withTTL }}
{{ data.expirationOverview.withTTL | number }}
{{ s().persistent }}
{{ data.expirationOverview.persistent | number }}
{{ s().avgTTL }}
{{ formatTTL(data.expirationOverview.avgTTL) }}
} src/ng/pages/monitoring/memory-analysis.component.scss000066400000000000000000000011131517727315400235740ustar00rootroot00000000000000p3xr-memory-analysis { display: block; color: var(--mat-app-text-color, inherit); } .p3xr-analysis-loading { display: flex; align-items: center; justify-content: center; gap: 8px; padding: 64px; opacity: 0.5; font-size: 18px; } .p3xr-analysis-server-info { opacity: 0.6; font-size: 12px; white-space: nowrap; } .p3xr-analysis-sub { opacity: 0.5; font-size: 12px; margin-left: 8px; } .p3xr-analysis-chart { width: 100%; min-height: 200px; overflow: hidden; canvas { width: 100% !important; } } src/ng/pages/monitoring/memory-analysis.component.ts000066400000000000000000000353001517727315400232540ustar00rootroot00000000000000import { Component, Inject, OnInit, OnDestroy, ChangeDetectorRef, ChangeDetectionStrategy, ElementRef, ViewChild, AfterViewInit, NgZone, ViewEncapsulation } from '@angular/core'; import { CommonModule } from '@angular/common'; import { FormsModule } from '@angular/forms'; import { MatIconModule } from '@angular/material/icon'; import { MatButtonModule } from '@angular/material/button'; import { MatTooltipModule } from '@angular/material/tooltip'; import { MatDividerModule } from '@angular/material/divider'; import { MatListModule } from '@angular/material/list'; import { I18nService } from '../../services/i18n.service'; import { SocketService } from '../../services/socket.service'; import { CommonService } from '../../services/common.service'; import { SettingsService } from '../../services/settings.service'; import { P3xrAccordionComponent } from '../../components/p3xr-accordion.component'; import { P3xrButtonComponent } from '../../components/p3xr-button.component'; import { P3xrInputComponent } from '../../components/p3xr-input.component'; import { RedisStateService } from '../../services/redis-state.service'; import humanizeDuration from 'humanize-duration'; @Component({ selector: 'p3xr-memory-analysis', standalone: true, imports: [ CommonModule, FormsModule, MatIconModule, MatButtonModule, MatTooltipModule, MatDividerModule, MatListModule, P3xrAccordionComponent, P3xrButtonComponent, P3xrInputComponent, ], templateUrl: './memory-analysis.component.html', styleUrls: ['./memory-analysis.component.scss'], encapsulation: ViewEncapsulation.None, changeDetection: ChangeDetectionStrategy.OnPush, }) export class MemoryAnalysisComponent implements OnInit, OnDestroy, AfterViewInit { strings; data: any = null; loading = false; topN = 20; maxScanKeys = 5000; typeEntries: Array<{ type: string; count: number; bytes: number }> = []; doctorText: string | null = null; doctorLoading = false; autoRefreshDoctor = false; private doctorInterval: any = null; @ViewChild('typeChart') typeChartRef!: ElementRef; @ViewChild('prefixChart') prefixChartRef!: ElementRef; private unsubFns: Array<() => void> = []; private resizeObserver: ResizeObserver | null = null; private themeObserver: MutationObserver | null = null; private resizeTimer: any; constructor( @Inject(I18nService) private i18n: I18nService, @Inject(SocketService) private socket: SocketService, @Inject(CommonService) private common: CommonService, @Inject(ChangeDetectorRef) private cdr: ChangeDetectorRef, @Inject(NgZone) private ngZone: NgZone, @Inject(ElementRef) private elementRef: ElementRef, @Inject(RedisStateService) private state: RedisStateService, @Inject(SettingsService) private settings: SettingsService, ) { this.strings = this.i18n.strings; } s() { return this.strings().page?.analysis || {}; } get connName(): string { return this.state.connection()?.name || 'redis'; } ngOnInit(): void { this.autoRefreshDoctor = localStorage.getItem('p3xr-monitor-auto-doctor') === 'true'; if (this.autoRefreshDoctor) this.startDoctorInterval(); if (this.state.connection()) this.runAnalysis(); const sub = this.socket.stateChanged$.subscribe(() => { this.data = null; if (this.state.connection()) this.runAnalysis(); }); this.unsubFns.push(() => sub.unsubscribe()); } ngAfterViewInit(): void { this.ngZone.runOutsideAngular(() => { this.resizeObserver = new ResizeObserver(() => { clearTimeout(this.resizeTimer); this.resizeTimer = setTimeout(() => { if (this.data) this.drawCharts(); }, 150); }); this.resizeObserver.observe(this.elementRef.nativeElement); }); this.ngZone.runOutsideAngular(() => { this.themeObserver = new MutationObserver(() => { if (this.data) setTimeout(() => this.drawCharts(), 100); }); this.themeObserver.observe(document.body, { attributes: true, attributeFilter: ['class'] }); }); } ngOnDestroy(): void { this.stopDoctorInterval(); this.themeObserver?.disconnect(); this.resizeObserver?.disconnect(); this.unsubFns.forEach(fn => fn()); } private recalcHostHeight(): void { const el = this.elementRef.nativeElement as HTMLElement; if (!el) return; const rect = el.getBoundingClientRect(); const footerHeight = document.getElementById('p3xr-layout-footer-container')?.offsetHeight || 48; const available = window.innerHeight - rect.top - footerHeight; el.style.height = Math.max(available, 100) + 'px'; el.style.overflowY = 'auto'; } async runAnalysis(): Promise { if (this.loading) return; this.loading = true; this.safeDetectChanges(); try { const response = await this.socket.request({ action: 'memory/analysis', payload: { topN: this.topN, maxScanKeys: this.maxScanKeys }, }); this.data = response.data; this.typeEntries = Object.keys(this.data.typeDistribution).map(type => ({ type, count: this.data.typeDistribution[type], bytes: this.data.typeMemory[type] || 0, })).sort((a, b) => b.bytes - a.bytes); this.loading = false; this.safeDetectChanges(); setTimeout(() => this.drawCharts(), 100); } catch (e) { this.loading = false; this.safeDetectChanges(); this.common.generalHandleError(e); } } async runDoctor(): Promise { this.doctorLoading = true; this.safeDetectChanges(); try { const resp = await this.socket.request({ action: 'memory/doctor' }); this.doctorText = resp.data.text; } catch (e) { this.common.generalHandleError(e); } finally { this.doctorLoading = false; this.safeDetectChanges(); } } toggleAutoDoctor(): void { this.autoRefreshDoctor = !this.autoRefreshDoctor; localStorage.setItem('p3xr-monitor-auto-doctor', String(this.autoRefreshDoctor)); if (this.autoRefreshDoctor) { this.runDoctor(); this.startDoctorInterval(); } else { this.stopDoctorInterval(); } } private startDoctorInterval(): void { this.stopDoctorInterval(); this.doctorInterval = setInterval(() => this.runDoctor(), 2000); } private stopDoctorInterval(): void { if (this.doctorInterval) { clearInterval(this.doctorInterval); this.doctorInterval = null; } } exportDoctor(): void { if (!this.doctorText) return; this.downloadText(this.doctorText, `${this.connName}-memory-doctor.txt`); } formatBytes(bytes: number): string { if (bytes == null || isNaN(bytes)) return '-'; if (bytes < 1024) return bytes + ' B'; if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'; if (bytes < 1024 * 1024 * 1024) return (bytes / (1024 * 1024)).toFixed(1) + ' MB'; return (bytes / (1024 * 1024 * 1024)).toFixed(2) + ' GB'; } formatTTL(seconds: number): string { if (!seconds || seconds <= 0) return '-'; try { const hdOpts = this.settings.getHumanizeDurationOptions(); return humanizeDuration(seconds * 1000, { ...hdOpts, delimiter: ' ' }); } catch { if (seconds < 60) return seconds + 's'; if (seconds < 3600) return Math.floor(seconds / 60) + 'm ' + (seconds % 60) + 's'; if (seconds < 86400) return Math.floor(seconds / 3600) + 'h ' + Math.floor((seconds % 3600) / 60) + 'm'; return Math.floor(seconds / 86400) + 'd ' + Math.floor((seconds % 86400) / 3600) + 'h'; } } private formatUptime(s: number): string { const d = Math.floor(s / 86400); const h = Math.floor((s % 86400) / 3600); const m = Math.floor((s % 3600) / 60); return d > 0 ? `${d}d ${h}h ${m}m` : h > 0 ? `${h}h ${m}m` : `${m}m`; } private downloadText(content: string, filename: string): void { const blob = new Blob([content], { type: 'text/plain' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = filename; a.click(); URL.revokeObjectURL(url); } exportOverview(): void { if (!this.data) return; const t = this.s(); this.downloadText([ `${t.keysScanned}: ${this.data.totalScanned} / ${this.data.dbSize}`, `${t.topN}: ${this.topN}`, `${t.maxScanKeys}: ${this.maxScanKeys}`, ].join('\n'), `${this.connName}-analysis-overview.txt`); } exportMemoryBreakdown(): void { if (!this.data) return; const t = this.s(); const m = this.data.memoryInfo; this.downloadText([ `${t.totalMemory}: ${m.usedHuman}`, `${t.rssMemory}: ${m.rssHuman}`, `${t.peakMemory}: ${m.peakHuman}`, `${t.overheadMemory}: ${this.formatBytes(m.overhead)}`, `${t.datasetMemory}: ${this.formatBytes(m.dataset)}`, `${t.luaMemory}: ${this.formatBytes(m.lua)}`, `${t.fragmentation}: ${m.fragRatio}x`, `${t.allocator}: ${m.allocator}`, ].join('\n'), `${this.connName}-memory-breakdown.txt`); } exportExpiration(): void { if (!this.data) return; const t = this.s(); const e = this.data.expirationOverview; this.downloadText([ `${t.withTTL}: ${e.withTTL}`, `${t.persistent}: ${e.persistent}`, `${t.avgTTL}: ${this.formatTTL(e.avgTTL)}`, ].join('\n'), `${this.connName}-expiration.txt`); } exportChart(chartRef: ElementRef | undefined, name: string): void { const canvas = chartRef?.nativeElement?.querySelector('canvas') as HTMLCanvasElement; if (!canvas) return; // Create a copy with solid background const exportCanvas = document.createElement('canvas'); exportCanvas.width = canvas.width; exportCanvas.height = canvas.height; const ctx = exportCanvas.getContext('2d')!; ctx.fillStyle = getComputedStyle(document.body).getPropertyValue('--p3xr-body-bg').trim() || '#ffffff'; ctx.fillRect(0, 0, exportCanvas.width, exportCanvas.height); ctx.drawImage(canvas, 0, 0); const url = exportCanvas.toDataURL('image/png'); const a = document.createElement('a'); a.href = url; a.download = `${this.connName}-${name}.png`; a.click(); } private safeDetectChanges(): void { this.ngZone.run(() => { try { this.cdr.detectChanges(); } catch { /* teardown */ } }); } drawCharts(): void { this.drawBarChart(this.typeChartRef?.nativeElement, this.typeEntries.map(t => ({ label: t.type, value: t.bytes, }))); this.drawBarChart(this.prefixChartRef?.nativeElement, (this.data?.prefixMemory || []).slice(0, 20).map((p: any) => ({ label: p.prefix, value: p.totalBytes, }))); } private getChartColors() { const isDark = document.body.classList.contains('p3xr-theme-dark'); const style = getComputedStyle(document.body); const primary = style.getPropertyValue('--p3xr-btn-primary-bg').trim(); const accent = style.getPropertyValue('--p3xr-btn-accent-bg').trim(); const warn = style.getPropertyValue('--p3xr-btn-warn-bg').trim(); return { primary: primary || (isDark ? '#90caf9' : '#1976d2'), accent: accent || (isDark ? '#ce93d8' : '#9c27b0'), warn: warn || (isDark ? '#ef9a9a' : '#f44336'), text: isDark ? 'rgba(255,255,255,0.87)' : 'rgba(0,0,0,0.87)', grid: isDark ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.08)', isDark, }; } private getBarColors(colors: ReturnType): string[] { const isDark = colors.isDark; return [ colors.primary, colors.accent, colors.warn, isDark ? '#ffb74d' : '#ff9800', isDark ? '#81c784' : '#4caf50', isDark ? '#4dd0e1' : '#00bcd4', isDark ? '#a1887f' : '#795548', isDark ? '#90a4ae' : '#607d8b', ]; } private drawBarChart(container: HTMLDivElement | undefined, items: Array<{ label: string; value: number }>): void { if (!container || items.length === 0 || container.offsetWidth <= 0) return; container.innerHTML = ''; const colors = this.getChartColors(); const barColors = this.getBarColors(colors); const canvas = document.createElement('canvas'); const dpr = window.devicePixelRatio || 1; const width = container.offsetWidth || 500; const barHeight = 24; const labelWidth = 120; const valueWidth = 80; const chartLeft = labelWidth + 8; const chartRight = width - valueWidth - 8; const chartWidth = chartRight - chartLeft; const topPad = 8; const height = topPad + items.length * (barHeight + 4) + 8; canvas.width = width * dpr; canvas.height = height * dpr; canvas.style.width = width + 'px'; canvas.style.height = height + 'px'; const ctx = canvas.getContext('2d')!; ctx.scale(dpr, dpr); const maxVal = Math.max(...items.map(i => i.value), 1); items.forEach((item, i) => { const y = topPad + i * (barHeight + 4); ctx.fillStyle = colors.text; ctx.font = '12px Roboto, sans-serif'; ctx.textAlign = 'right'; ctx.textBaseline = 'middle'; ctx.fillText(item.label.length > 15 ? item.label.substring(0, 14) + '…' : item.label, labelWidth, y + barHeight / 2); ctx.fillStyle = colors.grid; ctx.fillRect(chartLeft, y, chartWidth, barHeight); const barWidth = (item.value / maxVal) * chartWidth; ctx.fillStyle = barColors[i % barColors.length]; ctx.fillRect(chartLeft, y, barWidth, barHeight); ctx.fillStyle = colors.text; ctx.font = '11px Roboto Mono, monospace'; ctx.textAlign = 'left'; ctx.fillText(this.formatBytes(item.value), chartRight + 8, y + barHeight / 2); }); container.appendChild(canvas); } } src/ng/pages/monitoring/monitoring-data.service.ts000066400000000000000000000155221517727315400226610ustar00rootroot00000000000000import { Injectable } from '@angular/core'; import { Subject } from 'rxjs'; import { decode as msgpackDecode } from '@msgpack/msgpack'; export interface ProfilerEntry { displayTime: string; fullTimestamp: string; database: string; source: string; command: string; } export interface PubsubEntry { displayTime: string; fullTimestamp: string; channel: string; message: string; } const PROFILER_STORAGE_KEY = 'p3xr-profiler-entries'; const PUBSUB_STORAGE_KEY = 'p3xr-pubsub-entries'; const MAX_ENTRIES = 10000; const MAX_STORAGE_ENTRIES = 100; const SAVE_DEBOUNCE = 2000; @Injectable({ providedIn: 'root' }) export class MonitoringDataService { profilerEntries: ProfilerEntry[] = []; pubsubEntries: PubsubEntry[] = []; readonly profilerEntry$ = new Subject(); readonly pubsubEntry$ = new Subject(); profilerStarted = false; pubsubStarted = false; pubsubPattern = '*'; private socket: any; private langFn: () => string; private profilerSaveTimeout: any = null; private pubsubSaveTimeout: any = null; private initialized = false; init(socket: any, langFn: () => string): void { this.socket = socket; this.langFn = langFn; if (!this.initialized) { this.restoreFromStorage(); this.initialized = true; } } destroy(): void { this.saveProfilerNow(); this.savePubSubNow(); } async startProfiler(): Promise { if (this.profilerStarted) return; await this.socket.request({ action: 'monitor/set', payload: { enabled: true } }); this.profilerStarted = true; this.socket.getClient()?.on?.('monitor-data', this.onMonitorData); } stopProfiler(): void { if (!this.profilerStarted) return; this.socket.request({ action: 'monitor/set', payload: { enabled: false } }).catch(() => {}); this.socket.getClient()?.removeListener?.('monitor-data', this.onMonitorData); this.profilerStarted = false; this.saveProfilerNow(); } async startPubSub(): Promise { if (this.pubsubStarted) return; await this.socket.request({ action: 'settings/subscription', payload: { subscription: true, subscriberPattern: this.pubsubPattern }, }); this.pubsubStarted = true; this.socket.getClient()?.on?.('pubsub-message', this.onPubSubMessage); } stopPubSub(): void { if (!this.pubsubStarted) return; this.socket.request({ action: 'settings/subscription', payload: { subscription: false, subscriberPattern: '*' } }).catch(() => {}); this.socket.getClient()?.removeListener?.('pubsub-message', this.onPubSubMessage); this.pubsubStarted = false; this.savePubSubNow(); } async restartPubSub(): Promise { this.stopPubSub(); await this.startPubSub(); } clearProfiler(): void { this.profilerEntries = []; try { localStorage.removeItem(PROFILER_STORAGE_KEY); } catch {} } clearPubSub(): void { this.pubsubEntries = []; try { localStorage.removeItem(PUBSUB_STORAGE_KEY); } catch {} } private onMonitorData = (data: any) => { const lang = this.langFn() || 'en'; const date = new Date(parseFloat(data.time) * 1000); const displayTime = date.toLocaleTimeString(lang, { hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false, fractionalSecondDigits: 3 } as any); const entry: ProfilerEntry = { displayTime, fullTimestamp: date.toISOString(), database: data.database, source: data.source, command: (data.args || []).join(' '), }; this.profilerEntries.push(entry); if (this.profilerEntries.length > MAX_ENTRIES) { this.profilerEntries = this.profilerEntries.slice(-MAX_ENTRIES); } this.profilerEntry$.next(entry); this.debounceSaveProfiler(); }; private decodePubsubMessage(message: any): string { if (message instanceof ArrayBuffer) { try { const decoded = msgpackDecode(new Uint8Array(message)); return JSON.stringify(decoded, null, 2); } catch { return new TextDecoder().decode(message); } } return String(message); } private onPubSubMessage = (data: any) => { const lang = this.langFn() || 'en'; const date = new Date(); const displayTime = date.toLocaleTimeString(lang, { hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false }); const entry: PubsubEntry = { displayTime, fullTimestamp: date.toISOString(), channel: data.channel, message: this.decodePubsubMessage(data.message), }; this.pubsubEntries.push(entry); if (this.pubsubEntries.length > MAX_ENTRIES) { this.pubsubEntries = this.pubsubEntries.slice(-MAX_ENTRIES); } this.pubsubEntry$.next(entry); this.debounceSavePubSub(); }; private debounceSaveProfiler(): void { if (this.profilerSaveTimeout) return; this.profilerSaveTimeout = setTimeout(() => { this.profilerSaveTimeout = null; this.saveProfilerNow(); }, SAVE_DEBOUNCE); } private debounceSavePubSub(): void { if (this.pubsubSaveTimeout) return; this.pubsubSaveTimeout = setTimeout(() => { this.pubsubSaveTimeout = null; this.savePubSubNow(); }, SAVE_DEBOUNCE); } private saveProfilerNow(): void { if (this.profilerSaveTimeout) { clearTimeout(this.profilerSaveTimeout); this.profilerSaveTimeout = null; } this.saveToStorage(PROFILER_STORAGE_KEY, this.profilerEntries); } private savePubSubNow(): void { if (this.pubsubSaveTimeout) { clearTimeout(this.pubsubSaveTimeout); this.pubsubSaveTimeout = null; } this.saveToStorage(PUBSUB_STORAGE_KEY, this.pubsubEntries); } private saveToStorage(key: string, entries: any[]): void { const toSave = entries.slice(-MAX_STORAGE_ENTRIES); try { localStorage.setItem(key, JSON.stringify(toSave)); } catch { try { localStorage.removeItem(key); } catch {} } } private restoreFromStorage(): void { try { const profilerJson = localStorage.getItem(PROFILER_STORAGE_KEY); if (profilerJson) { this.profilerEntries = JSON.parse(profilerJson); } } catch {} try { const pubsubJson = localStorage.getItem(PUBSUB_STORAGE_KEY); if (pubsubJson) { this.pubsubEntries = JSON.parse(pubsubJson); } } catch {} } } src/ng/pages/monitoring/monitoring-shell.component.scss000066400000000000000000000010111517727315400237320ustar00rootroot00000000000000@use '../../../scss/vars' as v; p3xr-monitoring-shell { display: block; } .p3xr-monitoring-shell-container { display: flex; flex-direction: column; height: calc(100vh - v.$toolbar-height * 2); margin: -(v.$layout-padding); } .p3xr-monitoring-tabs { flex-shrink: 0; > .mat-mdc-tab-header { background-color: var(--p3xr-content-bg, #303030) !important; } } .p3xr-monitoring-shell-content { flex: 1; overflow-y: auto; min-height: 0; padding: v.$layout-padding; } src/ng/pages/monitoring/monitoring-shell.component.ts000066400000000000000000000105711517727315400234200ustar00rootroot00000000000000import { Component, Inject, OnInit, OnDestroy, ViewEncapsulation } from '@angular/core'; import { Router, RouterOutlet, NavigationEnd } from '@angular/router'; import { MatTabsModule } from '@angular/material/tabs'; import { Subscription } from 'rxjs'; import { filter } from 'rxjs/operators'; import { I18nService } from '../../services/i18n.service'; import { SocketService } from '../../services/socket.service'; import { CommonService } from '../../services/common.service'; import { MonitoringDataService } from './monitoring-data.service'; import { RedisStateService } from '../../services/redis-state.service'; @Component({ selector: 'p3xr-monitoring-shell', standalone: true, imports: [RouterOutlet, MatTabsModule], styleUrls: ['./monitoring-shell.component.scss'], template: `
`, encapsulation: ViewEncapsulation.None, }) export class MonitoringShellComponent implements OnInit, OnDestroy { strings; selectedTab = 0; private readonly routes = ['/monitoring', '/monitoring/profiler', '/monitoring/pubsub', '/monitoring/analysis']; private routerSub?: Subscription; private subs: Subscription[] = []; private servicesStarted = false; constructor( @Inject(I18nService) private readonly i18n: I18nService, @Inject(Router) private readonly router: Router, @Inject(SocketService) private readonly socket: SocketService, @Inject(CommonService) private readonly common: CommonService, @Inject(MonitoringDataService) private readonly data: MonitoringDataService, @Inject(RedisStateService) private readonly state: RedisStateService, ) { this.strings = this.i18n.strings; } ngOnInit(): void { this.syncTab(this.router.url); this.routerSub = this.router.events .pipe(filter(e => e instanceof NavigationEnd)) .subscribe((e: NavigationEnd) => this.syncTab(e.urlAfterRedirects)); // Redirect to settings on Redis disconnect this.subs.push(this.socket.redisDisconnected$.subscribe(() => { this.router.navigate(['/settings']); })); // If connected, start immediately; otherwise wait for connection if (this.state.connection()) { this.initServices(); } else { this.subs.push(this.socket.stateChanged$.subscribe(() => { if (this.state.connection() && !this.servicesStarted) { this.initServices(); } })); } } ngOnDestroy(): void { this.data.stopProfiler(); this.data.stopPubSub(); this.data.destroy(); this.routerSub?.unsubscribe(); this.subs.forEach(s => s.unsubscribe()); } private initServices(): void { this.servicesStarted = true; this.data.init(this.socket, () => this.i18n.currentLang()); this.startServices(); } private async startServices(): Promise { try { await this.data.startProfiler(); } catch (e) { this.common.generalHandleError(e); } try { await this.data.startPubSub(); } catch (e) { this.common.generalHandleError(e); } } onTabChange(index: number): void { if (index >= 0 && index < this.routes.length) { this.router.navigate([this.routes[index]]); } } private syncTab(url: string): void { if (url.startsWith('/monitoring/profiler')) { this.selectedTab = 1; } else if (url.startsWith('/monitoring/pubsub')) { this.selectedTab = 2; } else if (url.startsWith('/monitoring/analysis')) { this.selectedTab = 3; } else { this.selectedTab = 0; } } } src/ng/pages/monitoring/monitoring.component.html000066400000000000000000000774001517727315400226350ustar00rootroot00000000000000@if (!current) {
hourglass_empty {{ strings().label?.loading }}
} @if (current) {
Redis {{ current.server.version }} · {{ current.server.mode }}
{{ uptimeFormatted }}
{{ strings().page?.monitor?.memory }}
{{ current.memory.usedHuman }}
{{ strings().page?.monitor?.rss }}
{{ current.memory.rssHuman }}
{{ strings().page?.monitor?.peak }}
{{ current.memory.peakHuman }}
{{ strings().page?.monitor?.fragmentation }}
{{ current.memory.fragRatio }}x
{{ strings().page?.monitor?.opsPerSec }}
{{ current.stats.opsPerSec }}
{{ strings().page?.monitor?.totalCommands }}
{{ current.stats.totalCommands }}
{{ strings().page?.monitor?.clients }}
{{ current.clients.connected }}
{{ strings().page?.monitor?.blocked }}
{{ current.clients.blocked }}
{{ strings().page?.monitor?.hitsMisses }}
{{ current.stats.hitRate }}%
{{ strings().page?.monitor?.hitsAndMisses }}
{{ current.stats.hits }} / {{ current.stats.misses }}
{{ strings().page?.monitor?.networkIo }}
{{ current.stats.inputKbps | number:'1.1-1' }} / {{ current.stats.outputKbps | number:'1.1-1' }} KB/s
{{ strings().page?.monitor?.expired }}
{{ current.stats.expiredKeys }}
{{ strings().page?.monitor?.evicted }}
{{ current.stats.evictedKeys }}
@if (serverInfo) {
@if (serverInfo.os) {
{{ strings().page?.monitor?.os }}
{{ serverInfo.os }}
} @if (serverInfo.port) {
{{ strings().page?.monitor?.port }}
{{ serverInfo.port }}
} @if (serverInfo.pid) {
{{ strings().page?.monitor?.pid }}
{{ serverInfo.pid }}
} @if (serverInfo.configFile) {
{{ strings().page?.monitor?.configFile }}
{{ serverInfo.configFile }}
}
{{ strings().page?.monitor?.cpuSys }} CPU
{{ serverInfo.cpuSys }}
{{ strings().page?.monitor?.cpuUser }} CPU
{{ serverInfo.cpuUser }}
} @if (persistenceInfo) {
{{ strings().page?.monitor?.rdbLastSave }}
{{ persistenceInfo.rdbLastSave }}
{{ strings().page?.monitor?.rdbStatus }}
{{ persistenceInfo.rdbStatus }}
{{ strings().page?.monitor?.rdbChanges }}
{{ persistenceInfo.rdbChanges }}
{{ strings().page?.monitor?.aofEnabled }}
{{ persistenceInfo.aofEnabled }}
@if (persistenceInfo.aofSize) {
{{ strings().page?.monitor?.aofSize }}
{{ persistenceInfo.aofSize }}
}
} @if (replicationInfo) {
{{ strings().page?.monitor?.role }}
{{ replicationInfo.role }}
@if (replicationInfo.replicas !== undefined) {
{{ strings().page?.monitor?.replicas }}
{{ replicationInfo.replicas }}
} @if (replicationInfo.masterHost) {
{{ strings().page?.monitor?.masterHost }}
{{ replicationInfo.masterHost }}:{{ replicationInfo.masterPort }}
} @if (replicationInfo.linkStatus) {
{{ strings().page?.monitor?.linkStatus }}
{{ replicationInfo.linkStatus }}
}
} @if (keyspaceEntries.length > 0) {
@for (entry of keyspaceEntries; track entry.db; let last = $last) {
{{ entry.db }}
{{ strings().page?.monitor?.keys }}: {{ entry.keys }} · {{ strings().page?.monitor?.expires }}: {{ entry.expires }}
@if (!last) { } }
}
@if (modulesList.length === 0) {
{{ strings().page?.monitor?.noModules }}
} @if (modulesList.length > 0) { @for (mod of modulesList; track mod.name; let last = $last) {
{{ mod.name }}
v{{ mod.ver }}
@if (!last) { } }
}





@if (!isReadonly) { }
@if (current.slowlog.length === 0) {
{{ strings().page?.monitor?.noSlowQueries }}
} @for (entry of current.slowlog; track entry.id) {
{{ entry.duration }}µs {{ entry.command }}
}

@if (!autoRefreshClients) { }
@if (clientList.length === 0 && clientListLoaded) {
{{ strings().page?.monitor?.noClients }}
} @if (clientList.length === 0 && !clientListLoaded) {
{{ strings().label?.loading }}
} @if (clientList.length > 0) { @for (client of clientList; track client.id) {
{{ client.addr }} @if (client.name) { ({{ client.name }}) } db{{ client.db }} · {{ client.cmd }} · {{ client.idle }}s @if (!isReadonly) { close }
}
}

@if (!autoRefreshTopKeys) { }
@if (topKeys.length === 0 && topKeysLoaded) {
{{ strings().page?.monitor?.noKeys }}
} @if (topKeys.length === 0 && !topKeysLoaded) {
{{ strings().label?.loading }}
} @if (topKeys.length > 0) { @for (entry of topKeys; track entry.key; let i = $index) {
#{{ i + 1 }} {{ entry.key }}
{{ formatBytes(entry.bytes) }}
}
}
@if (isCluster && state.redisVersion().isAtLeast(8, 2)) {
Metric
@if (slotStats.length === 0 && slotStatsLoaded) {
No slot data
} @if (slotStats.length > 0) { @for (entry of slotStats; track entry.slot; let i = $index) {
#{{ i + 1 }} Slot {{ entry.slot }}
@if (slotStatsMetric === 'KEY-COUNT') { {{ entry['key-count'] }} keys } @if (slotStatsMetric === 'CPU-USEC') { {{ entry['cpu-usec'] }} μs } @if (slotStatsMetric === 'MEMORY-BYTES') { {{ formatBytes(entry['memory-bytes']) }} }
}
}
} @if (isCluster) {
@if (!autoRefreshShards) { }
@if (!clusterShards) {
{{ strings().page?.monitor?.noClusterData }}
} @else { @for (shard of clusterShards; track shard.master.id) {
{{ shard.master.host }}:{{ shard.master.port }} {{ formatSlotRanges(shard) }}
{{ getSlotCount(shard) }} {{ strings().page?.monitor?.totalSlots }} @if (shard.replicas.length > 0) { ({{ formatReplicas(shard) }}) }
}
16384 slots across {{ clusterShards.length }} masters
}
} } src/ng/pages/monitoring/monitoring.component.scss000066400000000000000000000034571517727315400226450ustar00rootroot00000000000000p3xr-monitoring { display: block; color: var(--mat-app-text-color, inherit); } .p3xr-monitoring-loading { display: flex; align-items: center; justify-content: center; gap: 8px; padding: 64px; opacity: 0.5; font-size: 18px; } .p3xr-monitoring-server-info { opacity: 0.6; font-size: 12px; white-space: nowrap; } .p3xr-mono { font-family: 'Roboto Mono', monospace; } .p3xr-monitoring-sub { opacity: 0.5; font-size: 12px; margin-left: 8px; } .p3xr-monitoring-chart { width: 100%; min-height: 180px; overflow: hidden; .uplot { font-family: 'Roboto', sans-serif; } .u-legend { font-size: 12px; color: var(--mat-app-text-color, inherit); opacity: 0.8; } .u-legend .u-series td { padding: 1px 4px; } } .p3xr-monitoring-client-row { display: flex; align-items: center; width: 100%; gap: 8px; } .p3xr-monitoring-client-addr { font-size: 13px; font-weight: 700; min-width: 150px; } .p3xr-monitoring-client-name { opacity: 0.5; font-size: 12px; } .p3xr-monitoring-client-info { flex: 1; text-align: right; font-family: 'Roboto Mono', monospace; font-size: 12px; opacity: 0.6; } .p3xr-monitoring-client-kill { cursor: pointer; font-size: 18px !important; width: 18px !important; height: 18px !important; color: var(--p3xr-btn-warn-bg, #f44336); opacity: 0.7; flex-shrink: 0; &:hover { opacity: 1; } } .p3xr-monitoring-slowlog-row { display: flex; align-items: center; gap: 12px; width: 100%; } .p3xr-monitoring-slowlog-cmd { font-family: 'Roboto Mono', monospace; font-size: 13px; flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } src/ng/pages/monitoring/monitoring.component.ts000066400000000000000000001534741517727315400223250ustar00rootroot00000000000000import { Component, Inject, OnInit, OnDestroy, ChangeDetectorRef, ChangeDetectionStrategy, ElementRef, ViewChild, AfterViewInit, NgZone, ViewEncapsulation } from '@angular/core'; import { CommonModule } from '@angular/common'; import { FormsModule } from '@angular/forms'; import { MatIconModule } from '@angular/material/icon'; import { MatButtonModule } from '@angular/material/button'; import { MatTooltipModule } from '@angular/material/tooltip'; import { MatDividerModule } from '@angular/material/divider'; import { MatListModule } from '@angular/material/list'; import { MatFormFieldModule } from '@angular/material/form-field'; import { MatInputModule } from '@angular/material/input'; const timeFormatter = new Intl.DateTimeFormat(undefined, { hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false }); const formatTime = (ms: number) => timeFormatter.format(new Date(ms)); import { I18nService } from '../../services/i18n.service'; import { SocketService } from '../../services/socket.service'; import { CommonService } from '../../services/common.service'; import { P3xrAccordionComponent } from '../../components/p3xr-accordion.component'; import { P3xrButtonComponent } from '../../components/p3xr-button.component'; import { RedisStateService } from '../../services/redis-state.service'; import { MonitoringDataService } from './monitoring-data.service'; interface MonitorSnapshot { timestamp: number; memory: { used: number; rss: number; peak: number; usedHuman: string; rssHuman: string; peakHuman: string; fragRatio: number }; stats: { opsPerSec: number; hits: number; misses: number; hitRate: number; inputKbps: number; outputKbps: number; totalCommands: number; expiredKeys: number; evictedKeys: number }; clients: { connected: number; blocked: number }; server: { version: string; uptime: number; mode: string }; keyspace: Record; slowlog: Array<{ id: number; timestamp: number; duration: number; command: string }>; } const MAX_HISTORY = 120; @Component({ selector: 'p3xr-monitoring', standalone: true, imports: [ CommonModule, FormsModule, MatIconModule, MatButtonModule, MatTooltipModule, MatDividerModule, MatListModule, MatFormFieldModule, MatInputModule, P3xrAccordionComponent, P3xrButtonComponent, ], templateUrl: './monitoring.component.html', styleUrls: ['./monitoring.component.scss'], encapsulation: ViewEncapsulation.None, changeDetection: ChangeDetectionStrategy.OnPush, }) export class MonitoringComponent implements OnInit, OnDestroy, AfterViewInit { strings; current: MonitorSnapshot | null = null; history: MonitorSnapshot[] = []; paused = false; clientList: any[] = []; topKeys: any[] = []; isReadonly = false; autoRefreshClients = localStorage.getItem('p3xr-monitor-auto-clients') === 'true'; autoRefreshTopKeys = localStorage.getItem('p3xr-monitor-auto-topkeys') === 'true'; clientListLoaded = false; topKeysLoaded = false; slotStats: Array<{ slot: number; 'key-count'?: number; 'cpu-usec'?: number; 'memory-bytes'?: number }> = []; slotStatsMetric = 'KEY-COUNT'; slotStatsLoaded = false; isCluster = false; clusterShards: any[] | null = null; autoRefreshShards = localStorage.getItem('p3xr-monitor-auto-shards') === 'true'; private shardsInterval: any = null; @ViewChild('memoryChart') memoryChartRef!: ElementRef; @ViewChild('opsChart') opsChartRef!: ElementRef; @ViewChild('clientsChart') clientsChartRef!: ElementRef; @ViewChild('networkChart') networkChartRef!: ElementRef; private intervalId: any; private uPlot: any; private memoryPlot: any; private opsPlot: any; private clientsPlot: any; private networkPlot: any; private chartsInitialized = false; private resizeObserver: ResizeObserver | null = null; private themeObserver: MutationObserver | null = null; private unsubFns: Array<() => void> = []; private boundRecalcHost: (() => void) | null = null; constructor( @Inject(I18nService) private i18n: I18nService, @Inject(SocketService) private socket: SocketService, @Inject(CommonService) private common: CommonService, @Inject(ChangeDetectorRef) private cdr: ChangeDetectorRef, @Inject(NgZone) private ngZone: NgZone, @Inject(ElementRef) private elementRef: ElementRef, @Inject(RedisStateService) private state: RedisStateService, @Inject(MonitoringDataService) private monitorData: MonitoringDataService, ) { this.strings = this.i18n.strings; } ngOnInit(): void { this.isReadonly = this.state.connection()?.readonly === true; this.isCluster = this.state.connection()?.cluster === true; this.fetchData(); this.loadClientList(); this.loadTopKeys(); if (this.isCluster && this.state.redisVersion().isAtLeast(8, 2)) { this.loadSlotStats(); } // Reload all data when connection changes const sub = this.socket.stateChanged$.subscribe(() => { this.isReadonly = this.state.connection()?.readonly === true; this.isCluster = this.state.connection()?.cluster === true; this.history = []; this.chartsInitialized = false; this.memoryPlot?.destroy(); this.opsPlot?.destroy(); this.clientsPlot?.destroy(); this.networkPlot?.destroy(); this.fetchData(); this.loadClientList(); this.loadTopKeys(); }); this.unsubFns.push(() => sub.unsubscribe()); this.ngZone.runOutsideAngular(() => { this.intervalId = setInterval(() => { if (!this.paused) { this.fetchData(); if (this.autoRefreshClients) this.loadClientList(); if (this.autoRefreshTopKeys) this.loadTopKeys(); } }, 2000); // Reinit charts on theme or language change this.themeObserver = new MutationObserver(() => { if (this.chartsInitialized) { setTimeout(() => this.reinitCharts(), 100); } }); this.themeObserver.observe(document.body, { attributes: true, attributeFilter: ['class'] }); // Watch for language changes via i18n signal let prevLang = this.i18n.currentLang(); const langCheckInterval = setInterval(() => { const currentLang = this.i18n.currentLang(); if (currentLang !== prevLang) { prevLang = currentLang; if (this.chartsInitialized) { setTimeout(() => this.reinitCharts(), 100); } } }, 500); this.unsubFns.push(() => clearInterval(langCheckInterval)); }); } ngAfterViewInit(): void { // Delay chart init to ensure DOM has layout setTimeout(() => this.loadUPlot(), 500); } ngOnDestroy(): void { if (this.intervalId) clearInterval(this.intervalId); if (this.shardsInterval) clearInterval(this.shardsInterval); this.unsubFns.forEach(fn => fn()); this.themeObserver?.disconnect(); this.resizeObserver?.disconnect(); this.memoryPlot?.destroy(); this.opsPlot?.destroy(); this.clientsPlot?.destroy(); this.networkPlot?.destroy(); } serverInfoLabel(): string { if (!this.current) return ''; const s = this.current.server; const pause = this.paused ? (this.strings().intention?.resume) : (this.strings().intention?.pause); return `Redis ${s.version} · ${s.mode} · ${this.uptimeFormatted} · ${pause}`; } toggleAutoRefreshClients(): void { this.autoRefreshClients = !this.autoRefreshClients; try { localStorage.setItem('p3xr-monitor-auto-clients', String(this.autoRefreshClients)); } catch {} } toggleAutoRefreshTopKeys(): void { this.autoRefreshTopKeys = !this.autoRefreshTopKeys; try { localStorage.setItem('p3xr-monitor-auto-topkeys', String(this.autoRefreshTopKeys)); } catch {} } async loadClientList(): Promise { try { const response = await this.socket.request({ action: 'client/list', payload: {} }); this.clientList = response.data; this.clientListLoaded = true; this.safeDetectChanges(); } catch { this.clientListLoaded = true; } } async killClient(id: string, event: Event): Promise { event.stopPropagation(); try { await this.common.confirm({ message: this.strings().page?.monitor?.confirmKillClient, }); await this.socket.request({ action: 'client/kill', payload: { id } }); this.common.toast({ message: this.strings().page?.monitor?.clientKilled }); await this.loadClientList(); } catch (e) { if (e !== undefined) this.common.generalHandleError(e); } } async loadSlotStats(): Promise { try { const response = await this.socket.request({ action: 'cluster/slot-stats', payload: { metric: this.slotStatsMetric, limit: 20 }, }); this.slotStats = response.slots || []; this.slotStatsLoaded = true; this.safeDetectChanges(); } catch { this.slotStatsLoaded = true; this.slotStats = []; } } async loadTopKeys(): Promise { try { const response = await this.socket.request({ action: 'memory/top-keys', payload: { topN: 20 } }); this.topKeys = response.data; this.topKeysLoaded = true; this.safeDetectChanges(); } catch { this.topKeysLoaded = true; } } private safeDetectChanges(): void { this.ngZone.run(() => { const scrollContainer = document.getElementById('p3xr-database-content-container') || document.querySelector('.p3xr-layout-content'); const scrollTop = scrollContainer?.scrollTop ?? window.scrollY; try { this.cdr.detectChanges(); } catch { /* ignore late teardown */ } requestAnimationFrame(() => { if (scrollContainer) { scrollContainer.scrollTop = scrollTop; } else { window.scrollTo(0, scrollTop); } }); }); } formatBytes(bytes: number): string { if (bytes < 1024) return bytes + ' B'; if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'; return (bytes / (1024 * 1024)).toFixed(1) + ' MB'; } togglePause(): void { this.paused = !this.paused; } // --- Dashboard computed properties --- get serverInfo(): { os: string; port: string; pid: string; configFile: string; cpuSys: string; cpuUser: string } | null { const info = this.state.info(); if (!info) return null; const s = info.server || {}; const c = info.cpu || {}; return { os: s.os || '', port: s.tcp_port || '', pid: s.process_id || '', configFile: s.config_file || '', cpuSys: c.used_cpu_sys || '0', cpuUser: c.used_cpu_user || '0', }; } get persistenceInfo(): { rdbLastSave: string; rdbStatus: string; rdbChanges: string; aofEnabled: string; aofSize: string } | null { const info = this.state.info(); if (!info?.persistence) return null; const p = info.persistence; const lastSaveTs = parseInt(p.rdb_last_save_time, 10); const lastSave = lastSaveTs ? new Date(lastSaveTs * 1000).toLocaleString() : 'N/A'; return { rdbLastSave: lastSave, rdbStatus: p.rdb_last_bgsave_status || 'N/A', rdbChanges: p.rdb_changes_since_last_save ?? 'N/A', aofEnabled: p.aof_enabled === '1' ? 'Yes' : 'No', aofSize: p.aof_enabled === '1' ? this.formatBytes(parseInt(p.aof_current_size, 10) || 0) : '', }; } get replicationInfo(): { role: string; replicas?: string; masterHost?: string; masterPort?: string; linkStatus?: string } | null { const info = this.state.info(); if (!info?.replication) return null; const r = info.replication; const result: any = { role: r.role || 'unknown' }; if (r.role === 'master') { result.replicas = r.connected_slaves ?? '0'; } else if (r.role === 'slave') { result.masterHost = r.master_host; result.masterPort = r.master_port; result.linkStatus = r.master_link_status; } return result; } get keyspaceEntries(): Array<{ db: string; keys: string; expires: string }> { const info = this.state.info(); if (!info?.keyspace) return []; return Object.keys(info.keyspace) .filter(k => k.startsWith('db')) .sort((a, b) => parseInt(a.slice(2), 10) - parseInt(b.slice(2), 10)) .map(db => { const entry = info.keyspace[db]; return { db, keys: typeof entry === 'object' ? (entry.keys || '0') : '0', expires: typeof entry === 'object' ? (entry.expires || '0') : '0', }; }); } get modulesList(): Array<{ name: string; ver: string }> { return (this.state.modules() || []).map((m: any) => ({ name: m.name || 'unknown', ver: String(m.ver ?? m.version ?? ''), })); } // --- Dashboard export methods --- exportServerInfo(): void { const s = this.serverInfo; if (!s) return; const mon = this.strings().page?.monitor || {}; const lines = [ `${mon.os}: ${s.os}`, `${mon.port}: ${s.port}`, `${mon.pid}: ${s.pid}`, `${mon.configFile}: ${s.configFile}`, `${mon.cpuSys} CPU: ${s.cpuSys}`, `${mon.cpuUser} CPU: ${s.cpuUser}`, ]; this.downloadText(lines.join('\n'), `${this.connName}-server-info.txt`); } exportPersistence(): void { const p = this.persistenceInfo; if (!p) return; const mon = this.strings().page?.monitor || {}; const lines = [ `${mon.rdbLastSave}: ${p.rdbLastSave}`, `${mon.rdbStatus}: ${p.rdbStatus}`, `${mon.rdbChanges}: ${p.rdbChanges}`, `${mon.aofEnabled}: ${p.aofEnabled}`, ]; if (p.aofSize) lines.push(`${mon.aofSize}: ${p.aofSize}`); this.downloadText(lines.join('\n'), `${this.connName}-persistence.txt`); } exportReplication(): void { const r = this.replicationInfo; if (!r) return; const mon = this.strings().page?.monitor || {}; const lines = [`${mon.role}: ${r.role}`]; if (r.replicas !== undefined) lines.push(`${mon.replicas}: ${r.replicas}`); if (r.masterHost) lines.push(`${mon.masterHost}: ${r.masterHost}:${r.masterPort}`); if (r.linkStatus) lines.push(`${mon.linkStatus}: ${r.linkStatus}`); this.downloadText(lines.join('\n'), `${this.connName}-replication.txt`); } exportKeyspace(): void { const entries = this.keyspaceEntries; if (entries.length === 0) return; const mon = this.strings().page?.monitor || {}; const lines = entries.map(e => `${e.db}: ${mon.keys}: ${e.keys}, ${mon.expires}: ${e.expires}`); this.downloadText(lines.join('\n'), `${this.connName}-keyspace.txt`); } exportModules(): void { const mods = this.modulesList; const mon = this.strings().page?.monitor || {}; if (mods.length === 0) { this.downloadText(mon.noModules, `${this.connName}-modules.txt`); return; } const lines = mods.map(m => `${m.name} v${m.ver}`); this.downloadText(lines.join('\n'), `${this.connName}-modules.txt`); } private get connName(): string { return this.state.connection()?.name || 'redis'; } exportOverview(): void { if (!this.current) return; const c = this.current; const mon = this.strings().page?.monitor || {}; const lines = [ `${mon.memory}: ${c.memory.usedHuman}`, `${mon.rss}: ${c.memory.rssHuman}`, `${mon.peak}: ${c.memory.peakHuman}`, `${mon.fragmentation}: ${c.memory.fragRatio}x`, `${mon.opsPerSec}: ${c.stats.opsPerSec}`, `${mon.totalCommands}: ${c.stats.totalCommands}`, `${mon.clients}: ${c.clients.connected}`, `${mon.blocked}: ${c.clients.blocked}`, `${mon.hitsMisses}: ${c.stats.hitRate}%`, `${mon.hitsAndMisses}: ${c.stats.hits} / ${c.stats.misses}`, `${mon.networkIo}: ${c.stats.inputKbps.toFixed(1)} / ${c.stats.outputKbps.toFixed(1)} KB/s`, `${mon.expired}: ${c.stats.expiredKeys}`, `${mon.evicted}: ${c.stats.evictedKeys}`, ]; this.downloadText(lines.join('\n'), `${this.connName}-overview.txt`); } exportChart(chartRef: ElementRef | undefined, name: string): void { const canvas = chartRef?.nativeElement?.querySelector('canvas') as HTMLCanvasElement; if (!canvas) return; const exportCanvas = document.createElement('canvas'); exportCanvas.width = canvas.width; exportCanvas.height = canvas.height; const ctx = exportCanvas.getContext('2d')!; ctx.fillStyle = getComputedStyle(document.body).getPropertyValue('--p3xr-body-bg').trim() || '#ffffff'; ctx.fillRect(0, 0, exportCanvas.width, exportCanvas.height); ctx.drawImage(canvas, 0, 0); const url = exportCanvas.toDataURL('image/png'); const a = document.createElement('a'); a.href = url; a.download = `${this.connName}-${name}.png`; a.click(); } async resetSlowLog(): Promise { try { await this.common.confirm({ message: this.strings().page?.monitor?.confirmSlowLogReset }); await this.socket.request({ action: 'monitor/slowlog-reset' }); this.common.toast({ message: this.strings().page?.monitor?.slowLogResetDone }); } catch {} } exportSlowLog(): void { if (!this.current) return; const lines = this.current.slowlog.map(e => `${e.duration}µs ${e.command}`); this.downloadText(lines.join('\n'), `${this.connName}-slowlog.txt`); } async loadClusterShards(): Promise { try { const resp = await this.socket.request({ action: 'cluster/shards' }); this.clusterShards = resp.data.shards; this.safeDetectChanges(); } catch (e) { this.common.generalHandleError(e); } } toggleAutoRefreshShards(): void { this.autoRefreshShards = !this.autoRefreshShards; localStorage.setItem('p3xr-monitor-auto-shards', String(this.autoRefreshShards)); if (this.autoRefreshShards) { this.loadClusterShards(); this.shardsInterval = setInterval(() => this.loadClusterShards(), 2000); } else { clearInterval(this.shardsInterval); this.shardsInterval = null; } } getSlotCount(shard: any): number { return shard.slotRanges.reduce((sum: number, [a, b]: [number, number]) => sum + (b - a + 1), 0); } formatSlotRanges(shard: any): string { return shard.slotRanges.map(([a, b]: [number, number]) => `${a}-${b}`).join(', '); } formatReplicas(shard: any): string { return shard.replicas.map((r: any) => `${r.host}:${r.port}`).join(', '); } exportClusterSlots(): void { if (!this.clusterShards) return; const lines = this.clusterShards.map(s => { const slots = s.slotRanges.map(([a, b]: [number, number]) => `${a}-${b}`).join(', '); const count = this.getSlotCount(s); const replicas = s.replicas.map((r: any) => `${r.host}:${r.port}`).join(', '); return `${s.master.host}:${s.master.port} | ${slots} | ${count} slots | replicas: ${replicas || 'none'}`; }); this.downloadText(lines.join('\n'), `${this.connName}-cluster-slots.txt`); } exportClientList(): void { const lines = this.clientList.map(c => `${c.addr} ${c.name || ''} db${c.db} ${c.cmd} idle:${c.idle}s`); this.downloadText(lines.join('\n'), `${this.connName}-clients.txt`); } exportTopKeys(): void { const lines = this.topKeys.map((e, i) => `#${i + 1} ${e.key} ${this.formatBytes(e.bytes)}`); this.downloadText(lines.join('\n'), `${this.connName}-topkeys.txt`); } async exportAll(): Promise { if (!this.current) return; try { const JSZip = (await import('jszip')).default; const zip = new JSZip(); const c = this.current; const sections: string[] = []; // === PULSE === const mon = this.strings().page?.monitor || {}; const a = this.strings().page?.analysis || {}; sections.push( `============================`, ` PULSE`, `============================`, ``, `--- ${mon.title} ---`, `Redis ${c.server.version} · ${c.server.mode} · Uptime: ${this.uptimeFormatted}`, `${mon.memory}: ${c.memory.usedHuman}`, `${mon.rss}: ${c.memory.rssHuman}`, `${mon.peak}: ${c.memory.peakHuman}`, `${mon.fragmentation}: ${c.memory.fragRatio}x`, `${mon.opsPerSec}: ${c.stats.opsPerSec}`, `${mon.totalCommands}: ${c.stats.totalCommands}`, `${mon.clients}: ${c.clients.connected}`, `${mon.blocked}: ${c.clients.blocked}`, `${mon.hitsMisses}: ${c.stats.hitRate}%`, `${mon.hitsAndMisses}: ${c.stats.hits} / ${c.stats.misses}`, `${mon.networkIo}: ${c.stats.inputKbps.toFixed(1)} / ${c.stats.outputKbps.toFixed(1)} KB/s`, `${mon.expired}: ${c.stats.expiredKeys}`, `${mon.evicted}: ${c.stats.evictedKeys}`, ); // Dashboard sections const si = this.serverInfo; if (si) { sections.push(``, `--- ${mon.serverInfo} ---`); sections.push(`${mon.os}: ${si.os}`, `${mon.port}: ${si.port}`, `${mon.pid}: ${si.pid}`); if (si.configFile) sections.push(`${mon.configFile}: ${si.configFile}`); sections.push(`${mon.cpuSys} CPU: ${si.cpuSys}`, `${mon.cpuUser} CPU: ${si.cpuUser}`); } const pi = this.persistenceInfo; if (pi) { sections.push(``, `--- ${mon.persistence} ---`); sections.push(`${mon.rdbLastSave}: ${pi.rdbLastSave}`, `${mon.rdbStatus}: ${pi.rdbStatus}`); sections.push(`${mon.rdbChanges}: ${pi.rdbChanges}`, `${mon.aofEnabled}: ${pi.aofEnabled}`); if (pi.aofSize) sections.push(`${mon.aofSize}: ${pi.aofSize}`); } const ri = this.replicationInfo; if (ri) { sections.push(``, `--- ${mon.replication} ---`); sections.push(`${mon.role}: ${ri.role}`); if (ri.replicas !== undefined) sections.push(`${mon.replicas}: ${ri.replicas}`); if (ri.masterHost) sections.push(`${mon.masterHost}: ${ri.masterHost}:${ri.masterPort}`); if (ri.linkStatus) sections.push(`${mon.linkStatus}: ${ri.linkStatus}`); } const ks = this.keyspaceEntries; if (ks.length > 0) { sections.push(``, `--- ${mon.keyspace} ---`); sections.push(...ks.map(e => `${e.db}: ${mon.keys}: ${e.keys}, ${mon.expires}: ${e.expires}`)); } const mods = this.modulesList; if (mods.length > 0) { sections.push(``, `--- ${mon.modules} ---`); sections.push(...mods.map(m => `${m.name} v${m.ver}`)); } else { sections.push(``, `--- ${mon.modules} ---`, mon.noModules); } if (c.slowlog.length > 0) { sections.push(``, `--- ${mon.slowLog} ---`); sections.push(...c.slowlog.map(e => `${e.duration}µs ${e.command}`)); } if (this.clientList.length > 0) { sections.push(``, `--- ${mon.clientList} ---`); sections.push(...this.clientList.map(cl => `${cl.addr} ${cl.name || ''} db${cl.db} ${cl.cmd} idle:${cl.idle}s`)); } if (this.topKeys.length > 0) { sections.push(``, `--- ${mon.topKeys} ---`); sections.push(...this.topKeys.map((e, i) => `#${i + 1} ${e.key} ${this.formatBytes(e.bytes)}`)); } // === ANALYSIS === let analysisChartItems: Array<{ name: string; items: Array<{ label: string; value: number }> }> = []; try { const resp = await this.socket.request({ action: 'memory/analysis', payload: { topN: 20, maxScanKeys: 5000 } }); const d = resp.data; if (d) { const m = d.memoryInfo; const exp = d.expirationOverview; const typeEntries = Object.keys(d.typeDistribution || {}).map((t: string) => ({ type: t, count: d.typeDistribution[t], bytes: d.typeMemory?.[t] || 0, })).sort((a: any, b: any) => b.bytes - a.bytes); sections.push(``, ``, `============================`, ` ANALYSIS`, `============================`); sections.push(``, `--- ${a.keysScanned} ---`, `${a.keysScanned}: ${d.totalScanned} / ${d.dbSize}`); sections.push(``, `--- ${a.memoryBreakdown} ---`); sections.push(`${a.totalMemory}: ${m.usedHuman}`, `${a.rssMemory}: ${m.rssHuman}`, `${a.peakMemory}: ${m.peakHuman}`); sections.push(`${a.overheadMemory}: ${this.formatBytes(m.overhead)}`, `${a.datasetMemory}: ${this.formatBytes(m.dataset)}`); sections.push(`${a.luaMemory}: ${this.formatBytes(m.lua)}`, `${a.fragmentation}: ${m.fragRatio}x`, `${a.allocator}: ${m.allocator}`); sections.push(``, `--- ${a.typeDistribution} ---`); sections.push(...typeEntries.map((t: any) => `${t.type}: ${t.count} ${a.keyCount}, ${this.formatBytes(t.bytes)}`)); if (d.prefixMemory?.length > 0) { sections.push(``, `--- ${a.prefixMemory} ---`); sections.push(...d.prefixMemory.map((p: any, i: number) => `#${i + 1} ${p.prefix} \u2014 ${p.keyCount} ${a.keyCount}, ${this.formatBytes(p.totalBytes)}`)); } sections.push(``, `--- ${a.expirationOverview} ---`); sections.push(`${a.withTTL}: ${exp.withTTL}`, `${a.persistent}: ${exp.persistent}`, `${a.avgTTL}: ${exp.avgTTL}s`); analysisChartItems = [ { name: a.typeDistribution, items: typeEntries.map((t: any) => ({ label: t.type, value: t.bytes })) }, { name: a.prefixMemory, items: (d.prefixMemory || []).slice(0, 20).map((p: any) => ({ label: p.prefix, value: p.totalBytes })) }, ]; } } catch { /* analysis optional */ } // Profiler + PubSub tail sections (long, go last in txt and PDF) // Sanitize: strip null bytes and non-printable control chars from raw Redis data const sanitize = (s: string) => s.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F]/g, ''); const tailSections: string[] = []; if (this.monitorData.profilerEntries.length > 0) { tailSections.push(``, ``, `============================`, ` PROFILER`, `============================`, ``); tailSections.push(...this.monitorData.profilerEntries.map( e => sanitize(`${e.fullTimestamp} [${e.database} ${e.source}] ${e.command}`) )); } if (this.monitorData.pubsubEntries.length > 0) { tailSections.push(``, ``, `============================`, ` PUBSUB`, `============================`, ``); tailSections.push(...this.monitorData.pubsubEntries.map( e => sanitize(`${e.fullTimestamp} ${e.channel} ${e.message}`) )); } // Write the single text file: sections + tail (UTF-8 with BOM) const textContent = [...sections, ...tailSections].join('\n'); const textBytes = new TextEncoder().encode(textContent); const bom = new Uint8Array([0xEF, 0xBB, 0xBF]); const txtWithBom = new Uint8Array(bom.length + textBytes.length); txtWithBom.set(bom); txtWithBom.set(textBytes, bom.length); zip.file('monitoring.txt', txtWithBom); // Collect all chart canvases and stitch into 1 tall PNG const allCanvases: Array<{ label: string; canvas: HTMLCanvasElement }> = []; // Pulse charts (render offscreen so they work even with collapsed accordions) allCanvases.push(...this.renderPulseChartsForExport()); // Analysis charts (render offscreen) for (const ci of analysisChartItems) { if (ci.items.length === 0) continue; const canvas = this.renderBarChart(ci.items); if (canvas) allCanvases.push({ label: ci.name, canvas }); } // Stitch all canvases into 1 tall image if (allCanvases.length > 0) { const blob = await this.stitchCharts(allCanvases); if (blob) zip.file('charts.png', blob); } // Generate PDF with text + charts try { const pdfBlob = await this.generatePdf(sections, allCanvases, tailSections); if (pdfBlob) zip.file('monitoring.pdf', pdfBlob); } catch { /* pdf optional */ } const content = await zip.generateAsync({ type: 'blob' }); const url = URL.createObjectURL(content); const link = document.createElement('a'); link.href = url; link.download = `${this.connName}-monitoring.zip`; link.click(); URL.revokeObjectURL(url); } catch (e) { this.common.generalHandleError(e); } } private async stitchCharts(items: Array<{ label: string; canvas: HTMLCanvasElement }>): Promise { const padding = 32; const labelHeight = 60; const chartSpacing = 40; // Use full native pixel width of the widest chart, minimum 2400px const width = Math.max(2400, ...items.map(i => i.canvas.width)); // Calculate total height at native pixel resolution let totalHeight = padding; for (const item of items) { const scaledH = item.canvas.height * (width / item.canvas.width); totalHeight += labelHeight + scaledH + chartSpacing; } totalHeight += padding; const stitched = document.createElement('canvas'); stitched.width = width; stitched.height = totalHeight; const ctx = stitched.getContext('2d')!; const bgColor = getComputedStyle(document.body).getPropertyValue('--p3xr-body-bg').trim() || '#ffffff'; const colors = this.getChartColors(); ctx.fillStyle = bgColor; ctx.fillRect(0, 0, width, totalHeight); let y = padding; for (const item of items) { // Label ctx.fillStyle = colors.text; ctx.font = 'bold 28px Roboto, sans-serif'; ctx.textAlign = 'left'; ctx.textBaseline = 'top'; ctx.fillText(item.label, padding, y); y += labelHeight; // Draw chart at full width const drawW = width - padding * 2; const drawH = item.canvas.height * (drawW / item.canvas.width); ctx.drawImage(item.canvas, padding, y, drawW, drawH); y += drawH + chartSpacing; } return new Promise(resolve => stitched.toBlob(b => resolve(b), 'image/png')); } private renderPulseChartsForExport(): Array<{ label: string; canvas: HTMLCanvasElement }> { // Use history if available, otherwise build a minimal dataset from the current snapshot let data: ReturnType; if (this.history.length >= 2) { data = this.buildChartData(); } else if (this.current) { const c = this.current; const now = Date.now() / 1000; data = { timestamps: [now - 1, now], memUsed: [c.memory.used / (1024 * 1024), c.memory.used / (1024 * 1024)], memRss: [c.memory.rss / (1024 * 1024), c.memory.rss / (1024 * 1024)], ops: [c.stats.opsPerSec, c.stats.opsPerSec], connected: [c.clients.connected, c.clients.connected], blocked: [c.clients.blocked, c.clients.blocked], netIn: [c.stats.inputKbps, c.stats.inputKbps], netOut: [c.stats.outputKbps, c.stats.outputKbps], }; } else { return []; } const colors = this.getChartColors(); const s = this.strings().page?.monitor || {}; const chartConfigs: Array<{ label: string; series: Array<{ label: string; color: string; values: number[]; fill?: boolean }>; }> = [ { label: (s.memory) + ' (MB)', series: [ { label: s.memory, color: colors.primary, values: data.memUsed, fill: true }, { label: 'RSS', color: colors.accent, values: data.memRss }, ], }, { label: s.opsPerSec, series: [ { label: s.opsPerSec, color: colors.primary, values: data.ops, fill: true }, ], }, { label: s.clients, series: [ { label: s.clients, color: colors.primary, values: data.connected }, { label: s.blocked, color: colors.warn, values: data.blocked }, ], }, { label: (s.networkIo) + ' (KB/s)', series: [ { label: '\u2193 In', color: colors.primary, values: data.netIn, fill: true }, { label: '\u2191 Out', color: colors.accent, values: data.netOut }, ], }, ]; return chartConfigs.map(config => ({ label: config.label, canvas: this.renderLineChart(data.timestamps, config.series, colors) }) ); } private renderLineChart( timestamps: number[], series: Array<{ label: string; color: string; values: number[]; fill?: boolean }>, colors: ReturnType, ): HTMLCanvasElement { const dpr = 2; const width = 900; const height = 260; const padTop = 32; const padBottom = 40; const padLeft = 60; const padRight = 16; const legendH = 20; const chartW = width - padLeft - padRight; const chartH = height - padTop - padBottom - legendH; const canvas = document.createElement('canvas'); canvas.width = width * dpr; canvas.height = height * dpr; const ctx = canvas.getContext('2d')!; ctx.scale(dpr, dpr); // Background ctx.fillStyle = getComputedStyle(document.body).getPropertyValue('--p3xr-body-bg').trim() || '#ffffff'; ctx.fillRect(0, 0, width, height); const n = timestamps.length; if (n < 2) return canvas; // Compute Y scale across all series let yMin = Infinity, yMax = -Infinity; for (const s of series) { for (const v of s.values) { if (v < yMin) yMin = v; if (v > yMax) yMax = v; } } if (yMin === yMax) { yMin -= 1; yMax += 1; } const yRange = yMax - yMin; const tMin = timestamps[0], tMax = timestamps[n - 1]; const tRange = tMax - tMin || 1; const toX = (t: number) => padLeft + ((t - tMin) / tRange) * chartW; const toY = (v: number) => padTop + chartH - ((v - yMin) / yRange) * chartH; // Grid lines ctx.strokeStyle = colors.grid; ctx.lineWidth = 1; const ySteps = 5; for (let i = 0; i <= ySteps; i++) { const gy = padTop + (chartH / ySteps) * i; ctx.beginPath(); ctx.moveTo(padLeft, gy); ctx.lineTo(padLeft + chartW, gy); ctx.stroke(); const val = yMax - (yRange / ySteps) * i; ctx.fillStyle = colors.text; ctx.font = '10px Roboto Mono, monospace'; ctx.textAlign = 'right'; ctx.textBaseline = 'middle'; ctx.fillText(val >= 1000 ? (val / 1000).toFixed(1) + 'k' : val.toFixed(1), padLeft - 6, gy); } // Time labels const labelCount = Math.min(6, n); ctx.font = '10px Roboto, sans-serif'; ctx.textAlign = 'center'; ctx.textBaseline = 'top'; ctx.fillStyle = colors.text; for (let i = 0; i < labelCount; i++) { const idx = Math.round((i / (labelCount - 1)) * (n - 1)); const t = timestamps[idx]; const d = new Date(t * 1000); const label = `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}:${String(d.getSeconds()).padStart(2, '0')}`; ctx.fillText(label, toX(t), padTop + chartH + 6); } // Draw series for (const s of series) { ctx.strokeStyle = s.color; ctx.lineWidth = 2; ctx.lineJoin = 'round'; ctx.beginPath(); for (let i = 0; i < n; i++) { const x = toX(timestamps[i]); const y = toY(s.values[i]); if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y); } ctx.stroke(); if (s.fill) { ctx.fillStyle = s.color + '20'; ctx.beginPath(); ctx.moveTo(toX(timestamps[0]), toY(s.values[0])); for (let i = 1; i < n; i++) ctx.lineTo(toX(timestamps[i]), toY(s.values[i])); ctx.lineTo(toX(timestamps[n - 1]), padTop + chartH); ctx.lineTo(toX(timestamps[0]), padTop + chartH); ctx.closePath(); ctx.fill(); } } // Legend let lx = padLeft; const ly = height - legendH + 4; ctx.font = '11px Roboto, sans-serif'; ctx.textAlign = 'left'; ctx.textBaseline = 'middle'; for (const s of series) { ctx.fillStyle = s.color; ctx.fillRect(lx, ly - 4, 12, 8); ctx.fillStyle = colors.text; ctx.fillText(s.label, lx + 16, ly); lx += ctx.measureText(s.label).width + 32; } return canvas; } private renderBarChart(items: Array<{ label: string; value: number }>): HTMLCanvasElement | null { if (items.length === 0) return null; const colors = this.getChartColors(); const isDark = colors.text.includes('255'); const barColors = [ colors.primary, colors.accent, colors.warn, isDark ? '#ffb74d' : '#ff9800', isDark ? '#81c784' : '#4caf50', isDark ? '#4dd0e1' : '#00bcd4', isDark ? '#a1887f' : '#795548', isDark ? '#90a4ae' : '#607d8b', ]; const dpr = 2; const width = 800; const barHeight = 24; const labelWidth = 120; const valueWidth = 80; const chartLeft = labelWidth + 8; const chartRight = width - valueWidth - 8; const chartWidth = chartRight - chartLeft; const topPad = 8; const height = topPad + items.length * (barHeight + 4) + 8; const canvas = document.createElement('canvas'); canvas.width = width * dpr; canvas.height = height * dpr; const ctx = canvas.getContext('2d')!; ctx.scale(dpr, dpr); ctx.fillStyle = getComputedStyle(document.body).getPropertyValue('--p3xr-body-bg').trim() || '#ffffff'; ctx.fillRect(0, 0, width, height); const maxVal = Math.max(...items.map(i => i.value), 1); items.forEach((item, i) => { const y = topPad + i * (barHeight + 4); ctx.fillStyle = colors.text; ctx.font = '12px Roboto, sans-serif'; ctx.textAlign = 'right'; ctx.textBaseline = 'middle'; ctx.fillText(item.label.length > 15 ? item.label.substring(0, 14) + '…' : item.label, labelWidth, y + barHeight / 2); ctx.fillStyle = colors.grid; ctx.fillRect(chartLeft, y, chartWidth, barHeight); ctx.fillStyle = barColors[i % barColors.length]; ctx.fillRect(chartLeft, y, (item.value / maxVal) * chartWidth, barHeight); ctx.fillStyle = colors.text; ctx.font = '11px Roboto Mono, monospace'; ctx.textAlign = 'left'; ctx.fillText(this.formatBytes(item.value), chartRight + 8, y + barHeight / 2); }); return canvas; } private async generatePdf(sections: string[], charts: Array<{ label: string; canvas: HTMLCanvasElement }>, tailSections: string[] = []): Promise { const { jsPDF } = await import('jspdf'); const isDark = document.body.classList.contains('p3xr-theme-dark'); const bgColor = getComputedStyle(document.body).getPropertyValue('--p3xr-body-bg').trim() || '#ffffff'; const textColor = isDark ? '#e0e0e0' : '#212121'; const headerColor = isDark ? '#90caf9' : '#1565c0'; const pdf = new jsPDF({ orientation: 'portrait', unit: 'mm', format: 'a4' }); const pageW = pdf.internal.pageSize.getWidth(); const pageH = pdf.internal.pageSize.getHeight(); const margin = 12; const contentW = pageW - margin * 2; let y = margin; const fillBg = () => { pdf.setFillColor(bgColor); pdf.rect(0, 0, pageW, pageH, 'F'); }; fillBg(); const checkPage = (needed: number) => { if (y + needed > pageH - margin) { pdf.addPage(); fillBg(); y = margin; } }; // Text sections for (const line of sections) { if (line.startsWith('====')) { continue; } const isSectionTitle = line.trim() === 'PULSE' || line.trim() === 'PROFILER' || line.trim() === 'PUBSUB' || line.trim() === 'ANALYSIS'; if (isSectionTitle) { checkPage(14); y += 4; pdf.setFontSize(14); pdf.setTextColor(headerColor); pdf.text(line.trim(), margin, y); y += 8; continue; } if (line.startsWith('---') && line.endsWith('---')) { checkPage(8); const title = line.replace(/^-+\s*/, '').replace(/\s*-+$/, ''); y += 2; pdf.setFontSize(10); pdf.setTextColor(headerColor); pdf.text(title, margin, y); y += 5; continue; } if (line === '') { y += 2; continue; } checkPage(4); pdf.setTextColor(textColor); pdf.setFontSize(8); const wrapped = pdf.splitTextToSize(line, contentW); for (const wl of wrapped) { checkPage(4); pdf.text(wl, margin, y); y += 3.5; } } // Charts — each on its own page for (const chart of charts) { pdf.addPage(); fillBg(); y = margin; pdf.setFontSize(12); pdf.setTextColor(headerColor); pdf.text(chart.label, margin, y); y += 8; const imgData = chart.canvas.toDataURL('image/png'); const ratio = chart.canvas.height / chart.canvas.width; const availH = pageH - y - margin; const imgW = contentW; const imgH = imgW * ratio; if (imgH > availH) { // Scale to fit available height, keep full width const drawH = availH; const drawW = drawH / ratio; pdf.addImage(imgData, 'PNG', margin, y, drawW, drawH); y += drawH; } else { pdf.addImage(imgData, 'PNG', margin, y, imgW, imgH); y += imgH; } } // Tail sections (Profiler / PubSub — after charts, start new page) if (tailSections.length > 0 && charts.length > 0) { pdf.addPage(); fillBg(); y = margin; } for (const line of tailSections) { if (line.startsWith('====')) { continue; } const isSectionTitle = line.trim() === 'PROFILER' || line.trim() === 'PUBSUB'; if (isSectionTitle) { checkPage(14); y += 4; pdf.setFontSize(14); pdf.setTextColor(headerColor); pdf.text(line.trim(), margin, y); y += 8; continue; } if (line === '') { y += 2; continue; } checkPage(4); pdf.setTextColor(textColor); pdf.setFontSize(8); const wrapped = pdf.splitTextToSize(line, contentW); for (const wl of wrapped) { checkPage(4); pdf.text(wl, margin, y); y += 3.5; } } return pdf.output('blob') as unknown as Blob; } private downloadText(content: string, filename: string): void { const blob = new Blob([content], { type: 'text/plain' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = filename; a.click(); URL.revokeObjectURL(url); } get uptimeFormatted(): string { if (!this.current) return '-'; const s = this.current.server.uptime; const d = Math.floor(s / 86400); const h = Math.floor((s % 86400) / 3600); const m = Math.floor((s % 3600) / 60); return d > 0 ? `${d}d ${h}h ${m}m` : h > 0 ? `${h}h ${m}m` : `${m}m`; } private async fetchData(): Promise { try { const response = await this.socket.request({ action: 'monitor/info', payload: {}, }); const data: MonitorSnapshot = response.data; this.current = data; this.isCluster = this.state.connection()?.cluster === true; this.history.push(data); if (this.history.length > MAX_HISTORY) { this.history.shift(); } if (this.chartsInitialized) { this.updateCharts(); } else if (this.uPlot && this.history.length >= 2) { this.initCharts(); } this.safeDetectChanges(); } catch { /* next tick will retry */ } } private async loadUPlot(): Promise { const uPlotModule = await import('uplot'); this.uPlot = uPlotModule.default; // uPlot CSS is loaded globally via angular.json styles[] if (this.history.length >= 2) { this.initCharts(); } } private getChartColors() { const isDark = document.body.classList.contains('p3xr-theme-dark'); const style = getComputedStyle(document.body); const primary = style.getPropertyValue('--p3xr-btn-primary-bg').trim(); const accent = style.getPropertyValue('--p3xr-btn-accent-bg').trim(); const warn = style.getPropertyValue('--p3xr-btn-warn-bg').trim(); return { primary: primary || (isDark ? '#90caf9' : '#1976d2'), accent: accent || (isDark ? '#ce93d8' : '#9c27b0'), warn: warn || (isDark ? '#ef9a9a' : '#f44336'), text: isDark ? 'rgba(255,255,255,0.87)' : 'rgba(0,0,0,0.87)', grid: isDark ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.08)', }; } private reinitCharts(): void { this.memoryPlot?.destroy(); this.opsPlot?.destroy(); this.clientsPlot?.destroy(); this.networkPlot?.destroy(); this.chartsInitialized = false; if (this.history.length >= 2) { this.initCharts(); } } private getChartWidth(el: HTMLDivElement | undefined): number { return el?.offsetWidth || 500; } private createOpts(width: number, seriesConfig: any[]): any { const colors = this.getChartColors(); return { width, height: 180, cursor: { show: true, drag: { x: false, y: false } }, legend: { show: true, live: false }, scales: { x: { time: true }, }, axes: [ { stroke: colors.text, grid: { stroke: colors.grid, width: 1 }, ticks: { stroke: colors.grid }, font: '11px Roboto', values: (_: any, ticks: number[]) => ticks.map(t => formatTime(t * 1000)), }, { stroke: colors.text, grid: { stroke: colors.grid, width: 1 }, ticks: { stroke: colors.grid }, font: '11px Roboto Mono', size: 55, }, ], series: [ { label: this.strings().label?.time, value: (_: any, rawValue: number) => rawValue ? formatTime(rawValue * 1000) : '' }, ...seriesConfig, ], }; } private initCharts(): void { if (!this.uPlot || this.chartsInitialized) return; const colors = this.getChartColors(); const data = this.buildChartData(); const memEl = this.memoryChartRef?.nativeElement; const opsEl = this.opsChartRef?.nativeElement; const cliEl = this.clientsChartRef?.nativeElement; const netEl = this.networkChartRef?.nativeElement; if (!memEl || !opsEl || !cliEl || !netEl) return; const s = this.strings().page?.monitor || {}; this.memoryPlot = new this.uPlot( this.createOpts(this.getChartWidth(memEl), [ { label: s.memory, stroke: colors.primary, width: 2, fill: colors.primary + '15' }, { label: 'RSS', stroke: colors.accent, width: 2 }, ]), [data.timestamps, data.memUsed, data.memRss], memEl, ); this.opsPlot = new this.uPlot( this.createOpts(this.getChartWidth(opsEl), [ { label: s.opsPerSec, stroke: colors.primary, width: 2, fill: colors.primary + '20' }, ]), [data.timestamps, data.ops], opsEl, ); this.clientsPlot = new this.uPlot( this.createOpts(this.getChartWidth(cliEl), [ { label: s.clients, stroke: colors.primary, width: 2 }, { label: s.blocked, stroke: colors.warn, width: 2 }, ]), [data.timestamps, data.connected, data.blocked], cliEl, ); this.networkPlot = new this.uPlot( this.createOpts(this.getChartWidth(netEl), [ { label: '↓ In', stroke: colors.primary, width: 2, fill: colors.primary + '15' }, { label: '↑ Out', stroke: colors.accent, width: 2 }, ]), [data.timestamps, data.netIn, data.netOut], netEl, ); this.chartsInitialized = true; // Auto-resize charts on container resize (window resize, accordion toggle) let resizeTimer: any; this.resizeObserver = new ResizeObserver(() => { clearTimeout(resizeTimer); resizeTimer = setTimeout(() => { const mw = this.getChartWidth(memEl); const ow = this.getChartWidth(opsEl); const cw = this.getChartWidth(cliEl); const nw = this.getChartWidth(netEl); if (mw > 0) this.memoryPlot?.setSize({ width: mw, height: 180 }); if (ow > 0) this.opsPlot?.setSize({ width: ow, height: 180 }); if (cw > 0) this.clientsPlot?.setSize({ width: cw, height: 180 }); if (nw > 0) this.networkPlot?.setSize({ width: nw, height: 180 }); }, 50); }); this.resizeObserver.observe(memEl); this.resizeObserver.observe(opsEl); this.resizeObserver.observe(cliEl); this.resizeObserver.observe(netEl); } private buildChartData() { return { timestamps: this.history.map(h => h.timestamp / 1000), memUsed: this.history.map(h => h.memory.used / (1024 * 1024)), memRss: this.history.map(h => h.memory.rss / (1024 * 1024)), ops: this.history.map(h => h.stats.opsPerSec), connected: this.history.map(h => h.clients.connected), blocked: this.history.map(h => h.clients.blocked), netIn: this.history.map(h => h.stats.inputKbps), netOut: this.history.map(h => h.stats.outputKbps), }; } private updateCharts(): void { if (!this.chartsInitialized) return; const data = this.buildChartData(); this.memoryPlot?.setData([data.timestamps, data.memUsed, data.memRss]); this.opsPlot?.setData([data.timestamps, data.ops]); this.clientsPlot?.setData([data.timestamps, data.connected, data.blocked]); this.networkPlot?.setData([data.timestamps, data.netIn, data.netOut]); } } src/ng/pages/profiler/000077500000000000000000000000001517727315400152065ustar00rootroot00000000000000src/ng/pages/profiler/profiler.component.html000066400000000000000000000012511517727315400217160ustar00rootroot00000000000000
src/ng/pages/profiler/profiler.component.ts000066400000000000000000000131471517727315400214070ustar00rootroot00000000000000import { Component, Inject, OnInit, OnDestroy, AfterViewInit, ElementRef, ViewChild, NgZone, ViewEncapsulation } from '@angular/core'; import { CommonModule } from '@angular/common'; import { MatButtonModule } from '@angular/material/button'; import { MatIconModule } from '@angular/material/icon'; import { Subscription } from 'rxjs'; import { P3xrAccordionComponent } from '../../components/p3xr-accordion.component'; import { P3xrButtonComponent } from '../../components/p3xr-button.component'; import { I18nService } from '../../services/i18n.service'; import { MonitoringDataService, ProfilerEntry } from '../monitoring/monitoring-data.service'; import { RedisStateService } from '../../services/redis-state.service'; @Component({ selector: 'p3xr-profiler', standalone: true, imports: [CommonModule, MatButtonModule, MatIconModule, P3xrAccordionComponent, P3xrButtonComponent], templateUrl: './profiler.component.html', encapsulation: ViewEncapsulation.None, styles: [` p3xr-profiler { display: block; color: var(--mat-app-text-color, inherit); } .p3xr-profiler-output { font-family: 'Roboto Mono', monospace; font-size: 13px; overflow-y: auto; word-break: break-all; white-space: normal; } .p3xr-profiler-entry { padding: 6px 16px; word-break: break-all; white-space: normal; } .p3xr-profiler-entry-odd { background-color: var(--p3xr-list-odd-bg); } `], }) export class ProfilerComponent implements OnInit, AfterViewInit, OnDestroy { @ViewChild('profilerOutput') profilerOutputRef?: ElementRef; strings; private readonly maxDomEntries = 66; private entryIndex = 0; private sub?: Subscription; private resizeFn: (() => void) | null = null; constructor( @Inject(I18nService) private readonly i18n: I18nService, @Inject(MonitoringDataService) private readonly data: MonitoringDataService, @Inject(RedisStateService) private readonly state: RedisStateService, @Inject(NgZone) private readonly ngZone: NgZone, ) { this.strings = this.i18n.strings; } ngOnInit(): void { setTimeout(() => { this.renderExistingEntries(); this.sub = this.data.profilerEntry$.subscribe(entry => this.renderEntry(entry)); }); } ngAfterViewInit(): void { document.body.classList.add('p3xr-no-main-scroll'); this.ngZone.runOutsideAngular(() => { this.resizeFn = () => this.recalcHeight(); window.addEventListener('resize', this.resizeFn); setTimeout(() => { this.recalcHeight(); const el = this.profilerOutputRef?.nativeElement; if (el) el.scrollTop = el.scrollHeight; }, 50); }); } ngOnDestroy(): void { document.body.classList.remove('p3xr-no-main-scroll'); this.sub?.unsubscribe(); if (this.resizeFn) window.removeEventListener('resize', this.resizeFn); } clearProfiler(): void { this.data.clearProfiler(); this.entryIndex = 0; if (this.profilerOutputRef?.nativeElement) { this.profilerOutputRef.nativeElement.innerHTML = ''; } } exportProfiler(): void { const connName = this.state.connection()?.name || 'redis'; const lines = this.data.profilerEntries.map(e => `${e.fullTimestamp} [${e.database} ${e.source}] ${e.command}`); this.downloadText(lines.join('\n'), `${connName}-profiler-export.txt`); } private renderExistingEntries(): void { const el = this.profilerOutputRef?.nativeElement; if (!el) return; const entries = this.data.profilerEntries; const start = Math.max(0, entries.length - this.maxDomEntries); this.entryIndex = start; for (let i = start; i < entries.length; i++) { this.renderEntry(entries[i]); } el.scrollTop = el.scrollHeight; } private renderEntry(entry: ProfilerEntry): void { const el = this.profilerOutputRef?.nativeElement; if (!el) return; const odd = this.entryIndex++ % 2 === 1 ? ' p3xr-profiler-entry-odd' : ''; el.insertAdjacentHTML('beforeend', `
${this.escapeHtml(entry.displayTime)} [${this.escapeHtml(entry.database)} ${this.escapeHtml(entry.source)}] ${this.escapeHtml(entry.command)}
`); while (el.children.length > this.maxDomEntries) { el.removeChild(el.firstChild!); } el.scrollTop = el.scrollHeight; } private recalcHeight(): void { const el = this.profilerOutputRef?.nativeElement; if (!el) return; const rect = el.getBoundingClientRect(); const footerHeight = document.getElementById('p3xr-layout-footer-container')?.offsetHeight || 48; const available = window.innerHeight - rect.top - footerHeight - 8; el.style.height = Math.max(available, 100) + 'px'; } private escapeHtml(str: string): string { return str.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); } private downloadText(content: string, filename: string): void { const blob = new Blob([content], { type: 'text/plain' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = filename; a.click(); URL.revokeObjectURL(url); } } src/ng/pages/profiler/pubsub.component.html000066400000000000000000000017121517727315400213760ustar00rootroot00000000000000
Pattern
src/ng/pages/profiler/pubsub.component.ts000066400000000000000000000137661517727315400210740ustar00rootroot00000000000000import { Component, Inject, OnInit, OnDestroy, AfterViewInit, ElementRef, ViewChild, NgZone, ViewEncapsulation } from '@angular/core'; import { CommonModule } from '@angular/common'; import { FormsModule } from '@angular/forms'; import { MatButtonModule } from '@angular/material/button'; import { MatIconModule } from '@angular/material/icon'; import { MatFormFieldModule } from '@angular/material/form-field'; import { MatInputModule } from '@angular/material/input'; import { Subscription } from 'rxjs'; import { P3xrAccordionComponent } from '../../components/p3xr-accordion.component'; import { P3xrButtonComponent } from '../../components/p3xr-button.component'; import { I18nService } from '../../services/i18n.service'; import { MonitoringDataService, PubsubEntry } from '../monitoring/monitoring-data.service'; import { RedisStateService } from '../../services/redis-state.service'; @Component({ selector: 'p3xr-pubsub', standalone: true, imports: [CommonModule, FormsModule, MatButtonModule, MatIconModule, MatFormFieldModule, MatInputModule, P3xrAccordionComponent, P3xrButtonComponent], templateUrl: './pubsub.component.html', encapsulation: ViewEncapsulation.None, styles: [` p3xr-pubsub { display: block; color: var(--mat-app-text-color, inherit); } .p3xr-pubsub-output { font-family: 'Roboto Mono', monospace; font-size: 13px; overflow-y: auto; word-break: break-all; white-space: normal; } .p3xr-pubsub-entry { padding: 6px 16px; word-break: break-all; white-space: normal; } .p3xr-pubsub-entry-odd { background-color: var(--p3xr-list-odd-bg); } .p3xr-pubsub-pattern { padding: 8px 16px; } `], }) export class PubsubComponent implements OnInit, AfterViewInit, OnDestroy { @ViewChild('pubsubOutput') pubsubOutputRef?: ElementRef; strings; get pubsubPattern(): string { return this.data.pubsubPattern; } set pubsubPattern(v: string) { this.data.pubsubPattern = v; } private readonly maxDomEntries = 66; private entryIndex = 0; private sub?: Subscription; private resizeFn: (() => void) | null = null; constructor( @Inject(I18nService) private readonly i18n: I18nService, @Inject(MonitoringDataService) private readonly data: MonitoringDataService, @Inject(RedisStateService) private readonly state: RedisStateService, @Inject(NgZone) private readonly ngZone: NgZone, ) { this.strings = this.i18n.strings; } ngOnInit(): void { setTimeout(() => { this.renderExistingEntries(); this.sub = this.data.pubsubEntry$.subscribe(entry => this.renderEntry(entry)); }); } ngAfterViewInit(): void { document.body.classList.add('p3xr-no-main-scroll'); this.ngZone.runOutsideAngular(() => { this.resizeFn = () => this.recalcHeight(); window.addEventListener('resize', this.resizeFn); setTimeout(() => { this.recalcHeight(); const el = this.pubsubOutputRef?.nativeElement; if (el) el.scrollTop = el.scrollHeight; }, 50); }); } ngOnDestroy(): void { document.body.classList.remove('p3xr-no-main-scroll'); this.sub?.unsubscribe(); if (this.resizeFn) window.removeEventListener('resize', this.resizeFn); } async restartPubSub(): Promise { await this.data.restartPubSub(); } clearPubSub(): void { this.data.clearPubSub(); this.entryIndex = 0; if (this.pubsubOutputRef?.nativeElement) { this.pubsubOutputRef.nativeElement.innerHTML = ''; } } exportPubSub(): void { const connName = this.state.connection()?.name || 'redis'; const lines = this.data.pubsubEntries.map(e => `${e.fullTimestamp} ${e.channel} ${e.message}`); this.downloadText(lines.join('\n'), `${connName}-pubsub-export.txt`); } private renderExistingEntries(): void { const el = this.pubsubOutputRef?.nativeElement; if (!el) return; const entries = this.data.pubsubEntries; const start = Math.max(0, entries.length - this.maxDomEntries); this.entryIndex = start; for (let i = start; i < entries.length; i++) { this.renderEntry(entries[i]); } el.scrollTop = el.scrollHeight; } private renderEntry(entry: PubsubEntry): void { const el = this.pubsubOutputRef?.nativeElement; if (!el) return; const odd = this.entryIndex++ % 2 === 1 ? ' p3xr-pubsub-entry-odd' : ''; el.insertAdjacentHTML('beforeend', `
${this.escapeHtml(entry.displayTime)} ${this.escapeHtml(entry.channel)} ${this.escapeHtml(entry.message)}
`); while (el.children.length > this.maxDomEntries) { el.removeChild(el.firstChild!); } el.scrollTop = el.scrollHeight; } private recalcHeight(): void { const el = this.pubsubOutputRef?.nativeElement; if (!el) return; const rect = el.getBoundingClientRect(); const footerHeight = document.getElementById('p3xr-layout-footer-container')?.offsetHeight || 48; const available = window.innerHeight - rect.top - footerHeight - 8; el.style.height = Math.max(available, 100) + 'px'; } private escapeHtml(str: string): string { return str.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); } private downloadText(content: string, filename: string): void { const blob = new Blob([content], { type: 'text/plain' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = filename; a.click(); URL.revokeObjectURL(url); } } src/ng/pages/search/000077500000000000000000000000001517727315400146315ustar00rootroot00000000000000src/ng/pages/search/search.component.html000066400000000000000000000253631517727315400207760ustar00rootroot00000000000000
@if (indexes.length === 0) {
{{ strings().page?.search?.noIndex }}
} @if (indexes.length > 0) {
{{ strings().page?.search?.index }} @for (idx of indexes; track idx) { {{ idx }} } @if (!isReadonly && selectedIndex) { }
{{ strings().page?.search?.query }} @if (state.redisVersion().isAtLeast(8, 4)) {
{{ strings().page?.search?.hybridMode }}
@if (hybridMode) {
{{ strings().page?.search?.vectorField }} {{ strings().page?.search?.vectorValues }} Count
} }
@if (isGtSm) { } @else { }
}
@if (searchDone && total === 0) {
{{ strings().label?.noResults }}
} @if (results.length > 0 || total > 0) {
@if (pages > 1) { {{ currentPage }} / {{ pages }} }
@for (doc of results; track doc._key) {
{{ doc._key }}
@for (field of getDocKeys(doc); track field) { {{ field }}: {{ doc[field] }} @if (!$last) { · } }
}
} @if (selectedIndex && indexInfo) {
@if (!isReadonly) { }
@if (indexInfo) { @for (key of getDocKeys(indexInfo); track key) {
{{ key }}
{{ indexInfo[key] | json }}
}
}
} @if (!isReadonly) {
{{ strings().page?.search?.indexName }} {{ strings().page?.search?.prefix }}
Schema
@for (field of newIndexFields; track $index; let i = $index) {
{{ strings().page?.search?.fieldName }}
{{ strings().label?.type }} TEXT NUMERIC TAG GEO VECTOR
}
@if (isGtSm) { } @else { }
}
src/ng/pages/search/search.component.scss000066400000000000000000000023101517727315400207700ustar00rootroot00000000000000:host { display: block; padding-bottom: 64px; color: var(--mat-app-text-color, inherit); } .md-block { width: 100%; } .p3xr-search-result-item { width: 100%; padding: 4px 0; } .p3xr-search-result-key { margin-bottom: 4px; } .p3xr-search-result-fields { display: flex; flex-wrap: wrap; gap: 4px 16px; } .p3xr-search-result-field { font-size: 13px; } .p3xr-search-field-name { font-weight: 600; opacity: 0.6; margin-right: 4px; } .p3xr-search-field-value { font-family: 'Roboto Mono', monospace; font-size: 12px; } .p3xr-search-schema-row { display: flex; align-items: center; gap: 8px; margin-bottom: 4px; } .p3xr-search-schema-type-row { display: flex; align-items: center; gap: 8px; flex-shrink: 0; .mat-mdc-fab-base { margin-bottom: 20px; } } @media (max-width: 599px) { .p3xr-search-schema-row { flex-wrap: wrap; > mat-form-field { width: 100% !important; flex: 1 1 100% !important; } } .p3xr-search-schema-type-row { flex: 1; mat-form-field { flex: 1; width: auto !important; } } } src/ng/pages/search/search.component.ts000066400000000000000000000261401517727315400204520ustar00rootroot00000000000000import { Component, Inject, OnInit, OnDestroy, ChangeDetectorRef, ChangeDetectionStrategy, NgZone, ViewEncapsulation } from '@angular/core'; import { CommonModule } from '@angular/common'; import { FormsModule } from '@angular/forms'; import { MatIconModule } from '@angular/material/icon'; import { MatButtonModule } from '@angular/material/button'; import { MatTooltipModule } from '@angular/material/tooltip'; import { MatDividerModule } from '@angular/material/divider'; import { MatListModule } from '@angular/material/list'; import { MatSelectModule } from '@angular/material/select'; import { MatFormFieldModule } from '@angular/material/form-field'; import { BreakpointObserver } from '@angular/cdk/layout'; import { MatInputModule } from '@angular/material/input'; import { MatSlideToggleModule } from '@angular/material/slide-toggle'; import { I18nService } from '../../services/i18n.service'; import { SocketService } from '../../services/socket.service'; import { CommonService } from '../../services/common.service'; import { P3xrAccordionComponent } from '../../components/p3xr-accordion.component'; import { P3xrButtonComponent } from '../../components/p3xr-button.component'; import { RedisStateService } from '../../services/redis-state.service'; import { OverlayService } from '../../services/overlay.service'; @Component({ selector: 'p3xr-search', standalone: true, imports: [ CommonModule, FormsModule, MatIconModule, MatButtonModule, MatTooltipModule, MatDividerModule, MatListModule, MatSelectModule, MatFormFieldModule, MatInputModule, MatSlideToggleModule, P3xrAccordionComponent, P3xrButtonComponent, ], templateUrl: './search.component.html', styleUrls: ['./search.component.scss'], encapsulation: ViewEncapsulation.None, changeDetection: ChangeDetectionStrategy.OnPush, }) export class SearchComponent implements OnInit, OnDestroy { strings; indexes: string[] = []; selectedIndex = ''; query = '*'; offset = 0; limit = 20; total = 0; results: any[] = []; indexInfo: any = null; searching = false; searchDone = false; isReadonly = false; isGtSm = true; aiLoading = false; // Hybrid search (FT.HYBRID, Redis 8.4+) hybridMode = false; vectorField = ''; vectorValues = ''; vectorCount = 10; // Index creation newIndexName = ''; newIndexPrefix = ''; newIndexFields: Array<{ name: string; type: string; sortable: boolean }> = [ { name: '', type: 'TEXT', sortable: false }, ]; private unsubs: Array<() => void> = []; constructor( @Inject(I18nService) private i18n: I18nService, @Inject(SocketService) private socket: SocketService, @Inject(CommonService) private common: CommonService, @Inject(ChangeDetectorRef) private cdr: ChangeDetectorRef, @Inject(NgZone) private ngZone: NgZone, @Inject(BreakpointObserver) private breakpointObserver: BreakpointObserver, @Inject(RedisStateService) private state: RedisStateService, @Inject(OverlayService) private overlay: OverlayService, ) { this.strings = this.i18n.strings; } ngOnInit(): void { this.isReadonly = this.state.connection()?.readonly === true; const sub960 = this.breakpointObserver.observe('(min-width: 960px)').subscribe(r => { this.isGtSm = r.matches; this.cdr.markForCheck(); }); this.unsubs.push(() => sub960.unsubscribe()); this.loadIndexes(); const sub = this.socket.stateChanged$.subscribe(() => { this.isReadonly = this.state.connection()?.readonly === true; this.loadIndexes(); }); this.unsubs.push(() => sub.unsubscribe()); } async searchAndRefreshInfo(): Promise { await Promise.all([ this.search(), this.loadIndexInfo(), ]); } ngOnDestroy(): void { this.unsubs.forEach(fn => fn()); } get pages(): number { return Math.ceil(this.total / this.limit); } get currentPage(): number { return Math.floor(this.offset / this.limit) + 1; } async loadIndexes(): Promise { try { const response = await this.socket.request({ action: 'search/list', payload: {} }); this.indexes = response.data; if (this.indexes.length > 0 && !this.selectedIndex) { this.selectedIndex = this.indexes[0]; this.loadIndexInfo(); } this.cdr.markForCheck(); } catch { /* ignore */ } } async search(): Promise { if (!this.selectedIndex || !this.query) return; this.searching = true; this.cdr.markForCheck(); try { let response: any; if (this.hybridMode && this.vectorField && this.vectorValues) { const values = this.vectorValues.split(',').map((v: string) => parseFloat(v.trim())).filter((v: number) => !isNaN(v)); response = await this.socket.request({ action: 'search/hybrid', payload: { index: this.selectedIndex, query: this.query, vectorField: this.vectorField, vectorValues: values, count: this.vectorCount, offset: this.offset, limit: this.limit, }, }); } else { response = await this.socket.request({ action: 'search/query', payload: { index: this.selectedIndex, query: this.query, offset: this.offset, limit: this.limit, }, }); } this.total = response.data.total; this.results = response.data.docs; } catch (e) { this.common.generalHandleError(e); this.results = []; this.total = 0; } finally { this.searching = false; this.searchDone = true; this.cdr.markForCheck(); } } pageAction(action: string): void { switch (action) { case 'first': this.offset = 0; break; case 'prev': this.offset = Math.max(0, this.offset - this.limit); break; case 'next': this.offset = Math.min((this.pages - 1) * this.limit, this.offset + this.limit); break; case 'last': this.offset = (this.pages - 1) * this.limit; break; } this.search(); } async loadIndexInfo(): Promise { if (!this.selectedIndex) return; try { const response = await this.socket.request({ action: 'search/index-info', payload: { index: this.selectedIndex }, }); this.indexInfo = response.data; this.cdr.markForCheck(); } catch (e) { this.common.generalHandleError(e); } } async dropIndex(): Promise { if (!this.selectedIndex) return; try { await this.common.confirm({ message: this.strings().confirm?.dropIndex, }); await this.socket.request({ action: 'search/index-drop', payload: { index: this.selectedIndex }, }); this.common.toast({ message: this.strings().status?.indexDropped }); this.selectedIndex = ''; this.results = []; this.total = 0; this.searchDone = false; this.indexInfo = null; await this.loadIndexes(); } catch (e) { if (e !== undefined) this.common.generalHandleError(e); } } addField(): void { this.newIndexFields.push({ name: '', type: 'TEXT', sortable: false }); } async confirmRemoveField(index: number): Promise { try { const label = this.strings().intention?.delete; await this.common.confirm({ message: label + '?' }); this.newIndexFields.splice(index, 1); this.newIndexFields = [...this.newIndexFields]; this.cdr.markForCheck(); } catch (e) { if (e !== undefined) this.common.generalHandleError(e); } } async createIndex(): Promise { if (!this.newIndexName.trim()) return; const schema = this.newIndexFields.filter(f => f.name.trim()); if (schema.length === 0) return; try { await this.socket.request({ action: 'search/index-create', payload: { name: this.newIndexName.trim(), prefix: this.newIndexPrefix.trim() || undefined, schema, }, }); this.common.toast({ message: this.strings().status?.indexCreated }); this.newIndexName = ''; this.newIndexPrefix = ''; this.newIndexFields = [{ name: '', type: 'TEXT', sortable: false }]; await this.loadIndexes(); } catch (e) { this.common.generalHandleError(e); } } async handleSearchEnter(): Promise { const q = (this.query || '').trim(); // Explicit ai: prefix if (/^ai:\s*/i.test(q)) { await this.handleAiQuery(q.replace(/^ai:\s*/i, '').trim()); return; } // Try normal search first try { await this.searchAndRefreshInfo(); } catch (e: any) { // If search failed and query looks like natural language, try AI if (q.length > 2 && q !== '*' && /\s/.test(q)) { this.overlay.show(); try { await this.handleAiQuery(q); } finally { this.overlay.hide(); } } } } private async handleAiQuery(prompt: string): Promise { if (!prompt) return; this.aiLoading = true; this.cdr.markForCheck(); try { let indexSchema: any = undefined; if (this.selectedIndex && this.indexInfo) { indexSchema = this.indexInfo; } const response = await this.socket.request({ action: 'ai/redis-query', payload: { prompt, context: { indexes: this.indexes, schema: indexSchema, }, }, }); this.query = response.command || '*'; if (response.explanation) { this.common.toast({ message: response.explanation }); } this.offset = 0; await this.searchAndRefreshInfo(); } catch (e: any) { this.common.generalHandleError(e); } finally { this.aiLoading = false; this.cdr.markForCheck(); } } getDocKeys(doc: any): string[] { return Object.keys(doc).filter(k => k !== '_key'); } } src/ng/pages/settings.component.ts000066400000000000000000001610451517727315400176040ustar00rootroot00000000000000import { Component, Inject, OnInit, OnDestroy, ChangeDetectorRef, ChangeDetectionStrategy } from '@angular/core'; import { FormsModule } from '@angular/forms'; import { MatToolbarModule } from '@angular/material/toolbar'; import { MatButtonModule } from '@angular/material/button'; import { MatIconModule } from '@angular/material/icon'; import { MatListModule } from '@angular/material/list'; import { MatSlideToggleModule } from '@angular/material/slide-toggle'; import { MatTooltipModule } from '@angular/material/tooltip'; import { MatDividerModule } from '@angular/material/divider'; import { AclUserDialogService } from '../dialogs/acl-user-dialog.service'; import { BreakpointObserver } from '@angular/cdk/layout'; import { CdkDragDrop, DragDropModule, moveItemInArray } from '@angular/cdk/drag-drop'; import { I18nService } from '../services/i18n.service'; import { NotificationService } from '../services/notification.service'; import { SettingsService } from '../services/settings.service'; import { RedisStateService } from '../services/redis-state.service'; import { CommonService } from '../services/common.service'; import { SocketService } from '../services/socket.service'; import { MainCommandService } from '../services/main-command.service'; import { ConnectionDialogService } from '../dialogs/connection-dialog.service'; import { TreecontrolSettingsDialogService } from '../dialogs/treecontrol-settings-dialog.service'; import { AiSettingsDialogService } from '../dialogs/ai-settings-dialog.service'; import { P3xrAccordionComponent } from '../components/p3xr-accordion.component'; import { P3xrButtonComponent } from '../components/p3xr-button.component'; import { switchGui } from '../../core/gui-switch'; /** * Settings page — Angular replacement for AngularJS p3xrSettings. * First complete Angular page migration. * * Contains: * - Connections list (add/edit/delete/connect/disconnect) * - License info panel * - Tree settings panel */ @Component({ selector: 'p3xr-ng-settings', standalone: true, imports: [ FormsModule, MatToolbarModule, MatButtonModule, MatIconModule, MatListModule, MatSlideToggleModule, MatTooltipModule, MatDividerModule, DragDropModule, P3xrAccordionComponent, P3xrButtonComponent, ], template: `
{{ strings().title?.donateDescription }}
@if (isPromoDomain) {
{{ strings().promo?.description }}
{{ strings().promo?.disclaimer }}
}
@if (!readonlyConnections) { }
@if (connectionsList.length === 0) {
{{ strings().intention?.noConnectionsInSettings }}
} @if (connectionsList.length > 0) {
@if (groupModeEnabled) {
@for (group of groupedConnections; track group.name) {
{{ collapsedGroups.has(group.name) ? 'chevron_right' : 'expand_more' }} {{ getGroupDisplayName(group.name) }} ({{ group.connections.length }})
@if (!collapsedGroups.has(group.name)) {
@for (connection of group.connections; track connection.id; let last = $last) {
{{ connection.name }}
{{ connection.host }}:{{ connection.port }}
@for (entry of getConnectionClients(connection); track entry.key) { {{ strings().page?.overview?.connectedCount?.({ length: entry.clients }) }} }  
@if (currentConnectionId !== connection.id) { @if (isXs) { } @else { } } @if (currentConnectionId === connection.id) { @if (isXs) { } @else { } } @if (!readonlyConnections) { @if (isXs) { } @else { } @if (isXs) { } @else { } } @if (readonlyConnections) { @if (isXs) { } @else { } }
@if (!last) { } }
}
}
} @if (!groupModeEnabled) {
@for (connection of connectionsList; track connection.id; let last = $last) {
{{ connection.name }}
{{ connection.host }}:{{ connection.port }}
@for (entry of getConnectionClients(connection); track entry.key) { {{ strings().page?.overview?.connectedCount?.({ length: entry.clients }) }} }  
@if (currentConnectionId !== connection.id) { @if (isXs) { } @else { } } @if (currentConnectionId === connection.id) { @if (isXs) { } @else { } } @if (!readonlyConnections) { @if (isXs) { } @else { } @if (isXs) { } @else { } } @if (readonlyConnections) { @if (isXs) { } @else { } }
@if (!last) { } }
}
}
@if (currentConnectionId) {
@if (!readonlyConnections) { }
@if (aclLoading) {
{{ strings().page?.acl?.loading }}
} @else if (!aclUsers) {
{{ strings().page?.acl?.noUsers }}
} @else {
@for (user of aclUsers; track user.name; let last = $last) {
{{ user.name }} @if (user.name === aclCurrentUser) { ({{ strings().page?.acl?.currentUser }}) }
@if (!user.enabled) { warning } @if (!readonlyConnections) { @if (user.name !== 'default' && user.name !== aclCurrentUser) { @if (isXs) { } @else { } } @if (isXs) { } @else { } }
@if (!last) { } }
}
}
Angular React Vue

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

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

{{ strings().form?.treeSettings?.field?.treeSeparator }} {{ settings.redisTreeDivider() || strings().label?.treeSeparatorEmptyNote }}
{{ strings().form?.treeSettings?.field?.page }}{{ settings.pageCount() }}
{{ strings().form?.treeSettings?.error?.page }}
{{ strings().form?.treeSettings?.field?.keyPageCount }}{{ settings.keyPageCount() }}
{{ strings().form?.treeSettings?.error?.keyPageCount }}
{{ strings().form?.treeSettings?.maxValueDisplay }}{{ settings.maxValueDisplay() }}
{{ strings().form?.treeSettings?.maxValueDisplayInfo }}
{{ strings().form?.treeSettings?.maxKeys }}{{ settings.maxKeys() }}
{{ strings().form?.treeSettings?.maxKeysInfo }}
{{ strings().form?.treeSettings?.field?.keysSort }} {{ settings.keysSort() ? strings().label?.keysSort?.on : strings().label?.keysSort?.off }}
{{ strings().form?.treeSettings?.field?.searchMode }} {{ settings.searchClientSide() ? strings().form?.treeSettings?.label?.searchModeClient : strings().form?.treeSettings?.label?.searchModeServer }}
{{ strings().form?.treeSettings?.field?.searchModeStartsWith }} {{ settings.searchStartsWith() ? strings().form?.treeSettings?.label?.searchModeStartsWith : strings().form?.treeSettings?.label?.searchModeIncludes }}
{{ settings.jsonFormat() === 2 ? strings().form?.treeSettings?.label?.jsonFormatTwoSpace : strings().form?.treeSettings?.label?.jsonFormatFourSpace }}
{{ settings.animation() ? strings().form?.treeSettings?.label?.animation : strings().form?.treeSettings?.label?.noAnimation }}
{{ settings.undoEnabled() ? (strings().form?.treeSettings?.label?.undoEnabled) : (strings().form?.treeSettings?.label?.undoDisabled) }}
{{ strings().form?.treeSettings?.undoHint }}
{{ settings.showDiffBeforeSave() ? (strings().form?.treeSettings?.label?.diffEnabled) : (strings().form?.treeSettings?.label?.diffDisabled) }}
`, styles: [` :host { display: block; color: var(--mat-app-text-color, inherit); } .p3xr-settings-hint { font-size: 12px; color: var(--mat-app-text-color, rgba(0, 0, 0, 0.54)); opacity: 0.7; } /* GUI toggle */ .p3xr-gui-toggle { display: inline-flex; align-items: stretch; border-radius: 4px; overflow: hidden; border: 1px solid var(--p3xr-border-color, rgba(0,0,0,0.12)); } .p3xr-gui-toggle-active, .p3xr-gui-toggle-item { padding: 8px 12px; font-size: 14px; user-select: none; display: inline-flex; align-items: center; } .p3xr-gui-toggle-active { font-weight: 700; background-color: var(--p3xr-btn-primary-bg); color: var(--p3xr-btn-primary-color); } .p3xr-gui-toggle-item { font-weight: 500; cursor: pointer; } .p3xr-gui-toggle-active i[class*="fa-"] { text-shadow: 0 0 3px rgba(0,0,0,0.6), 0 0 8px rgba(0,0,0,0.3); } .p3xr-gui-toggle-item:hover { background-color: var(--p3xr-hover-bg); } /* Wide screens: show button text, hide tooltip */ .hide-xs { display: inline; } .show-xs-tooltip { display: none; } /* Small screens: hide text, show icon-only square buttons */ @media (max-width: 599px) { .hide-xs { display: none !important; } /* Buttons become square icon buttons on mobile */ .p3xr-connection-item button { min-width: 40px !important; width: 40px !important; height: 40px !important; padding: 0 !important; margin: 2px !important; display: inline-flex !important; align-items: center !important; justify-content: center !important; } .p3xr-connection-item button mat-icon, .p3xr-connection-item button i { margin: 0 !important; } } /* Connection items: match production md-list-item */ .p3xr-connection-item { display: flex; align-items: center; gap: 4px; padding: 8px 8px 8px 16px; min-height: 56px; box-sizing: border-box; } .p3xr-connection-info { flex: 1; min-width: 0; overflow: hidden; } .p3xr-connection-item button { flex-shrink: 0; } /* Drag and drop */ .p3xr-connection-item.cdk-drag-preview { box-sizing: border-box; border-radius: 4px; box-shadow: 0 5px 5px -3px rgba(0,0,0,0.2), 0 8px 10px 1px rgba(0,0,0,0.14), 0 3px 14px 2px rgba(0,0,0,0.12); background: var(--mat-app-background-color, #fff); } .p3xr-connection-item.cdk-drag-placeholder { opacity: 0.3; } .cdk-drop-list-dragging .p3xr-connection-item:not(.cdk-drag-placeholder) { transition: transform 250ms cubic-bezier(0, 0, 0.2, 1); } .p3xr-connection-group-block.cdk-drag-preview { box-sizing: border-box; border-radius: 4px; box-shadow: 0 5px 5px -3px rgba(0,0,0,0.2), 0 8px 10px 1px rgba(0,0,0,0.14), 0 3px 14px 2px rgba(0,0,0,0.12); background: var(--mat-app-background-color, #fff); } .p3xr-connection-group-block.cdk-drag-placeholder { opacity: 0.3; } .p3xr-group-drop-list.cdk-drop-list-dragging .p3xr-connection-group-block:not(.cdk-drag-placeholder) { transition: transform 250ms cubic-bezier(0, 0, 0.2, 1); } .p3xr-connection-group-header[cdkDragHandle] { cursor: grab; } /* Only tree settings rows are clickable/hoverable. License rows stay static like AngularJS. */ .p3xr-tree-settings-list mat-list-item { cursor: pointer; } .p3xr-tree-settings-list mat-list-item:hover { background-color: var(--p3xr-hover-bg); } /* ACL users list: hoverable rows only when editable */ .p3xr-acl-users-list .p3xr-acl-clickable { cursor: pointer; } .p3xr-acl-users-list .p3xr-acl-clickable:hover { background-color: var(--p3xr-hover-bg); } /* Settings list: bold label (left), normal value (right) */ ::ng-deep .p3xr-tree-settings-list .mdc-list-item__primary-text { width: 100%; } ::ng-deep .p3xr-settings-label { font-weight: 500; } ::ng-deep .p3xr-settings-value { font-weight: 400; opacity: 0.8; } `], changeDetection: ChangeDetectionStrategy.OnPush, }) export class SettingsComponent implements OnInit, OnDestroy { private static readonly UNGROUPED_GROUP_KEY = ''; strings; connectionsList: any[] = []; groupedConnections: Array<{ name: string; connections: any[] }> = []; collapsedGroups: Set; groupModeEnabled = false; private static readonly COLLAPSED_GROUPS_KEY = 'p3xr-collapsed-connection-groups'; private static readonly GROUP_MODE_KEY = 'p3xr-connection-group-mode'; isElectron = /electron/i.test(navigator.userAgent); isPromoDomain = window.location.hostname === 'p3x.redis.patrikx3.com'; readonlyConnections = false; currentConnectionId: string | undefined; aclUsers: any[] | null = null; aclCurrentUser = ''; aclLoading = false; isXs = false; private electronUiStorage: Record | null = null; private readonly unsubs: Array<() => void> = []; constructor( @Inject(I18nService) private i18n: I18nService, @Inject(SettingsService) public settings: SettingsService, @Inject(RedisStateService) private state: RedisStateService, @Inject(CommonService) private common: CommonService, @Inject(SocketService) private socket: SocketService, @Inject(MainCommandService) private cmd: MainCommandService, @Inject(ConnectionDialogService) private connectionDialog: ConnectionDialogService, @Inject(TreecontrolSettingsDialogService) private treeSettingsDialog: TreecontrolSettingsDialogService, @Inject(AiSettingsDialogService) private aiSettingsDialog: AiSettingsDialogService, @Inject(AclUserDialogService) private aclUserDialog: AclUserDialogService, @Inject(BreakpointObserver) private breakpointObserver: BreakpointObserver, @Inject(ChangeDetectorRef) private cdr: ChangeDetectorRef, @Inject(NotificationService) public notificationService: NotificationService, ) { this.strings = this.i18n.strings; this.restoreGroupingState(); this.breakpointObserver.observe('(max-width: 599px)').subscribe(result => { this.isXs = result.matches; this.cdr.markForCheck(); }); } ngOnInit(): void { this.refreshState(); // Subscribe to socket events for reactive updates const sub1 = this.socket.connections$.subscribe(() => this.refreshState()); const sub2 = this.socket.configuration$.subscribe(() => this.refreshState()); const sub3 = this.socket.stateChanged$.subscribe(() => this.refreshState()); const sub4 = this.socket.redisStatus$.subscribe(() => this.refreshState()); this.unsubs.push(() => { sub1.unsubscribe(); sub2.unsubscribe(); sub3.unsubscribe(); sub4.unsubscribe(); }); } ngOnDestroy(): void { this.unsubs.forEach(fn => fn()); } get currentConnectionName(): string { const conn = this.connectionsList.find((c: any) => c.id === this.currentConnectionId); return conn?.name || ''; } private refreshState(): void { this.connectionsList = this.state.connections()?.list || []; this.readonlyConnections = this.state.cfg()?.readonlyConnections === true; const prevConnId = this.currentConnectionId; this.currentConnectionId = this.state.connection()?.id; // Auto-load ACL when connection changes if (this.currentConnectionId && this.currentConnectionId !== prevConnId) { this.loadAclUsers(); } else if (!this.currentConnectionId && prevConnId) { this.aclUsers = null; this.aclCurrentUser = ''; } this.buildGroupedConnections(); this.cdr.detectChanges(); } toggleGroupMode(): void { this.groupModeEnabled = !this.groupModeEnabled; this.setPersistentItem(SettingsComponent.GROUP_MODE_KEY, String(this.groupModeEnabled)); } toggleGroup(name: string): void { if (this.collapsedGroups.has(name)) { this.collapsedGroups.delete(name); } else { this.collapsedGroups.add(name); } this.setPersistentItem(SettingsComponent.COLLAPSED_GROUPS_KEY, JSON.stringify([...this.collapsedGroups])); } private restoreGroupingState(): void { this.groupModeEnabled = this.getPersistentItem(SettingsComponent.GROUP_MODE_KEY) === 'true'; // Sync bootstrap value to localStorage so React can read it (shared origin in Electron) this.setPersistentItem(SettingsComponent.GROUP_MODE_KEY, String(this.groupModeEnabled)); try { const stored = this.getPersistentItem(SettingsComponent.COLLAPSED_GROUPS_KEY); this.collapsedGroups = stored ? new Set(JSON.parse(stored).map((name: string) => this.normalizeCollapsedGroupName(name))) : new Set(); } catch { this.collapsedGroups = new Set(); } } private getPersistentItem(key: string): string | null { const value = this.getElectronUiStorage()[key]; if (typeof value === 'string') { return value; } try { return localStorage.getItem(key); } catch { return null; } } private setPersistentItem(key: string, value: string): void { try { localStorage.setItem(key, value); } catch { /* ignore */ } const storage = this.getElectronUiStorage(); storage[key] = value; this.electronUiStorage = storage; try { if (window.parent && window.parent !== window) { window.parent.postMessage({ type: 'p3x-ui-storage-set', key, value }, '*'); } } catch { /* ignore */ } } private getElectronUiStorage(): Record { if (this.electronUiStorage !== null) { return this.electronUiStorage; } // Read from __p3xr_electron_bootstrap which was captured in main.js // BEFORE Angular's router stripped the query params. let storage: Record = {}; try { const bootstrap = (globalThis as any).__p3xr_electron_bootstrap; if (bootstrap && typeof bootstrap === 'object' && !Array.isArray(bootstrap)) { storage = this.normalizeElectronUiStorage(bootstrap); } } catch { storage = {}; } this.electronUiStorage = storage; return storage; } private normalizeElectronUiStorage(value: unknown): Record { if (!value || typeof value !== 'object' || Array.isArray(value)) { return {}; } return Object.entries(value).reduce((result: Record, [key, entryValue]) => { if (typeof entryValue === 'string') { result[key] = entryValue; } return result; }, {}); } getGroupDisplayName(name: string): string { return name === SettingsComponent.UNGROUPED_GROUP_KEY ? this.getUngroupedLabel() : name; } private getUngroupedLabel(): string { return this.strings().label?.ungrouped; } private normalizeCollapsedGroupName(name: unknown): string { if (typeof name !== 'string') { return ''; } return this.isLegacyUngroupedGroupName(name) ? SettingsComponent.UNGROUPED_GROUP_KEY : name; } private isLegacyUngroupedGroupName(name: string): boolean { return name === 'Ungrouped' || name === this.getUngroupedLabel(); } private buildGroupedConnections(): void { // Use a Map to preserve the order groups first appear in the connections list. // This respects the server-persisted order (including after drag reorder). const groups = new Map(); for (const conn of this.connectionsList) { const groupName = conn.group?.trim() || SettingsComponent.UNGROUPED_GROUP_KEY; if (!groups.has(groupName)) { groups.set(groupName, []); } groups.get(groupName)!.push(conn); } const result: Array<{ name: string; connections: any[] }> = []; for (const [name, connections] of groups) { result.push({ name, connections }); } this.groupedConnections = result; } // Predicates prevent items from entering the wrong drop list level groupDropPredicate = (drag: any) => drag.data && 'connections' in drag.data; connectionDropPredicate = (drag: any) => drag.data && !('connections' in drag.data); async dropGroup(event: CdkDragDrop): Promise { if (event.previousIndex === event.currentIndex) return; moveItemInArray(this.groupedConnections, event.previousIndex, event.currentIndex); // Rebuild flat list in new group order and persist const allIds: string[] = []; for (const group of this.groupedConnections) { for (const conn of group.connections) { allIds.push(conn.id); } } try { await this.socket.request({ action: 'connection/reorder', payload: { ids: allIds }, }); } catch (e) { this.common.generalHandleError(e); this.refreshState(); } } async dropConnection(event: CdkDragDrop, groupName: string): Promise { if (event.previousIndex === event.currentIndex) return; moveItemInArray(event.container.data, event.previousIndex, event.currentIndex); // Persist the new order to the server try { const reorderedIds = event.container.data.map((c: any) => c.id); await this.socket.request({ action: 'connection/reorder', payload: { group: groupName || undefined, ids: reorderedIds }, }); } catch (e) { this.common.generalHandleError(e); this.refreshState(); } } async dropUngroupedConnection(event: CdkDragDrop): Promise { if (event.previousIndex === event.currentIndex) return; moveItemInArray(this.connectionsList, event.previousIndex, event.currentIndex); try { const reorderedIds = this.connectionsList.map((c: any) => c.id); await this.socket.request({ action: 'connection/reorder', payload: { ids: reorderedIds }, }); } catch (e) { this.common.generalHandleError(e); this.refreshState(); } } // --- Connections --- connectionForm(type: string, model?: any): void { this.connectionDialog.show({ type: type as any, model, $event: undefined }); } async connect(connection: any): Promise { this.cmd.connectRequest$.next({ connection, disableState: true }); } async disconnect(): Promise { await this.cmd.disconnect(); this.refreshState(); } async deleteConnection(connection: any, $event: any): Promise { try { await this.common.confirm({ event: $event, message: this.strings().confirm?.deleteConnectionText, }); await this.socket.request({ action: 'connection/delete', payload: { id: connection.id }, }); this.common.toast(this.strings().status?.deleted); } catch (e) { if (e !== undefined) { this.common.generalHandleError(e); } } } getConnectionClients(connection: any): { key: string; clients: number }[] { const redisConnections = this.state.redisConnections() || {}; const results: { key: string; clients: number }[] = []; for (const key of Object.keys(redisConnections)) { if (redisConnections[key].connection?.name === connection.name) { results.push({ key, clients: redisConnections[key].clients?.length || 0 }); } } return results; } // --- AI Settings --- isAiEnabled(): boolean { return this.state.cfg()?.aiEnabled !== false; } async toggleAiEnabled(enabled: boolean): Promise { try { await this.socket.request({ action: 'ai/set-groq-api-key', payload: { aiEnabled: enabled, }, }); const cfg = { ...this.state.cfg(), aiEnabled: enabled }; this.state.cfg.set(cfg); this.cdr.markForCheck(); } catch (e) { this.common.generalHandleError(e); } } hasGroqApiKey(): boolean { return this.state.cfg()?.hasGroqApiKey === true; } isUseOwnKey(): boolean { return this.state.cfg()?.aiUseOwnKey === true && this.hasGroqApiKey(); } async toggleUseOwnKey(useOwn: boolean): Promise { if (useOwn && !this.hasGroqApiKey()) { return; } try { await this.socket.request({ action: 'ai/set-groq-api-key', payload: { aiEnabled: this.state.cfg()?.aiEnabled !== false, aiUseOwnKey: useOwn, }, }); const cfg = { ...this.state.cfg(), aiUseOwnKey: useOwn }; this.state.cfg.set(cfg); this.cdr.markForCheck(); } catch (e) { this.common.generalHandleError(e); } } isAiReadonly(): boolean { return this.readonlyConnections || this.state.cfg()?.groqApiKeyReadonly === true; } isGroqApiKeyReadonly(): boolean { return this.state.cfg()?.groqApiKeyReadonly === true; } async openAiSettings($event: any): Promise { await this.aiSettingsDialog.show(); this.cdr.markForCheck(); } // --- Tree Settings --- openTreeSettings($event: any): void { this.treeSettingsDialog.show({ $event }); } // --- ACL Management --- async loadAclUsers(): Promise { this.aclLoading = true; this.cdr.markForCheck(); try { const resp = await this.socket.request({ action: 'acl/list' }); this.aclUsers = resp.data.users; this.aclCurrentUser = resp.data.currentUser; } catch { this.aclUsers = null; } this.aclLoading = false; this.cdr.markForCheck(); } async deleteAclUser(username: string): Promise { try { const msg = (this.strings().page?.acl?.confirmDelete) + ` "${username}"?`; await this.common.confirm({ message: msg }); await this.socket.request({ action: 'acl/del-user', payload: { username } }); this.common.toast({ message: this.strings().page?.acl?.userDeleted }); this.loadAclUsers(); } catch {} } async openAclCreate(): Promise { const result = await this.aclUserDialog.show({ username: '', rules: 'on >password +@all ~* &*', isNew: true, }); if (result) { try { await this.socket.request({ action: 'acl/set-user', payload: { username: result.username, rules: result.rules } }); this.common.toast({ message: this.strings().page?.acl?.userSaved }); this.loadAclUsers(); } catch (e) { this.common.generalHandleError(e); } } } async openAclEdit(user: any): Promise { const parts = user.raw.split(' '); const result = await this.aclUserDialog.show({ username: user.name, rules: parts.slice(2).join(' '), isNew: false, }); if (result) { try { await this.socket.request({ action: 'acl/set-user', payload: { username: result.username, rules: result.rules } }); this.common.toast({ message: this.strings().page?.acl?.userSaved }); this.loadAclUsers(); } catch (e) { this.common.generalHandleError(e); } } } // --- GUI Framework Switch --- switchToReact(): void { switchGui('react'); } switchToVue(): void { switchGui('vue'); } } src/ng/services/000077500000000000000000000000001517727315400141105ustar00rootroot00000000000000src/ng/services/auth.service.ts000066400000000000000000000057511517727315400170700ustar00rootroot00000000000000import { Injectable, signal } from '@angular/core'; declare const P3XR_API_PORT: number; declare const P3XR_DEV_MODE: boolean; const AUTH_TOKEN_KEY = 'p3xr-auth-token'; function getApiBase(): string { if (typeof P3XR_DEV_MODE !== 'undefined' && P3XR_DEV_MODE) { const url = new URL(location.toString()); const apiPort = typeof P3XR_API_PORT !== 'undefined' ? P3XR_API_PORT : 7843; return `http://${url.hostname}:${apiPort}`; } return ''; } @Injectable({ providedIn: 'root' }) export class AuthService { readonly authRequired = signal(false); readonly isAuthenticated = signal(false); readonly authChecked = signal(false); readonly loginError = signal(''); private readonly apiBase = getApiBase(); async checkAuthStatus(): Promise { try { const res = await fetch(`${this.apiBase}/api/auth-status`); const data = await res.json(); this.authRequired.set(data.enabled); if (!data.enabled) { this.isAuthenticated.set(true); } else { const token = this.getToken(); if (token) { const verifyRes = await fetch(`${this.apiBase}/api/verify-token`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ token }), }); const verifyData = await verifyRes.json(); this.isAuthenticated.set(verifyData.valid); if (!verifyData.valid) { localStorage.removeItem(AUTH_TOKEN_KEY); } } } } catch { // Network error — assume no auth required so app isn't blocked this.isAuthenticated.set(true); } this.authChecked.set(true); } async login(username: string, password: string): Promise { this.loginError.set(''); try { const res = await fetch(`${this.apiBase}/api/login`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username, password }), }); const data = await res.json(); if (data.status === 'ok' && data.token) { localStorage.setItem(AUTH_TOKEN_KEY, data.token); this.isAuthenticated.set(true); return true; } this.loginError.set(data.error || 'login_failed'); return false; } catch { this.loginError.set('network_error'); return false; } } getToken(): string | null { try { return localStorage.getItem(AUTH_TOKEN_KEY); } catch { return null; } } logout(): void { try { localStorage.removeItem(AUTH_TOKEN_KEY); } catch {} location.reload(); } } src/ng/services/common.service.ts000066400000000000000000000226371517727315400174210ustar00rootroot00000000000000import { Injectable, Inject } from '@angular/core'; import { Subject } from 'rxjs'; import { MatSnackBar } from '@angular/material/snack-bar'; import { MatDialog } from '@angular/material/dialog'; import type { ConfirmDialogData } from '../components/confirm-dialog.component'; import { createDialogPopupSettings } from '../dialogs/dialog-popup'; import { I18nService } from './i18n.service'; import { RedisParserService } from './redis-parser.service'; import { RedisStateService } from './redis-state.service'; import { SettingsService } from './settings.service'; import { TreeBuilderService } from './tree-builder.service'; /** * Common service — Angular replacement for AngularJS p3xrCommon factory. * * Provides: * - toast(): notification via MatSnackBar (replaces $mdToast) * - confirm(): confirmation dialog via MatDialog (replaces $mdDialog.confirm()) * - alert(): alert dialog via MatDialog (replaces $mdDialog.alert()) * - generalHandleError(): centralized error handling with i18n code lookup * - loadRedisInfoResponse(): parses Redis info and populates state * * During hybrid mode, both this service and the AngularJS p3xrCommon factory coexist. * New Angular components use this service; existing AngularJS components keep using the factory. */ @Injectable({ providedIn: 'root' }) export class CommonService { readonly treeExpandAll$ = new Subject(); readonly treeCollapseAll$ = new Subject(); readonly treeExpandToLevel$ = new Subject(); private lastResponse: any; constructor( @Inject(MatSnackBar) private snackBar: MatSnackBar, @Inject(MatDialog) private dialog: MatDialog, @Inject(I18nService) private i18n: I18nService, @Inject(RedisParserService) private redisParser: RedisParserService, @Inject(RedisStateService) private state: RedisStateService, @Inject(SettingsService) private settings: SettingsService, @Inject(TreeBuilderService) private treeBuilder: TreeBuilderService, ) {} /** * Show a toast notification. * Replaces AngularJS $mdToast. */ toast(options: string | { message: string; hideDelay?: number }): void { if (typeof options === 'string') { options = { message: options }; } const ref = this.snackBar.open(options.message, 'x', { duration: options.hideDelay || 5000, horizontalPosition: 'right', verticalPosition: 'bottom', }); ref.onAction().subscribe(() => ref.dismiss()); } /** * Show a toast with an "Undo" action button. * Returns a Promise that resolves to true if Undo was clicked, false if dismissed. */ toastWithUndo(message: string): Promise { return new Promise(resolve => { const ref = this.snackBar.open(message, 'Undo', { duration: 5000, horizontalPosition: 'right', verticalPosition: 'bottom', }); let acted = false; ref.onAction().subscribe(() => { acted = true; resolve(true); }); ref.afterDismissed().subscribe(() => { if (!acted) resolve(false); }); }); } /** * Show a confirmation dialog with OK and Cancel buttons. * Returns a Promise that resolves on OK and rejects on Cancel. * Replaces AngularJS $mdDialog.confirm(). */ async confirm(options: { message: string; title?: string; event?: any; disableCancel?: boolean; panelClass?: string | string[]; autoFocus?: boolean; }): Promise { const strings = this.i18n.strings(); const isAlert = options.hasOwnProperty('disableCancel') && options.disableCancel; const data: ConfirmDialogData = { title: options.title || (isAlert ? (strings.confirm?.info) : (strings.confirm?.title)), message: options.message, disableCancel: isAlert, okButton: isAlert ? (strings.intention?.ok) : (strings.intention?.sure), cancelButton: strings.intention?.cancel, }; const { ConfirmDialogComponent } = await import( /* webpackChunkName: "dialog-confirm" */ '../components/confirm-dialog.component' ); const dialogRef = this.dialog.open(ConfirmDialogComponent, createDialogPopupSettings({ data, autoFocus: options.autoFocus ?? true, panelClass: options.panelClass, })); return new Promise((resolve, reject) => { dialogRef.afterClosed().subscribe((result) => { if (result) { resolve(); } else { reject(); } }); }); } /** * Show an alert dialog with only OK button. * Replaces AngularJS $mdDialog.alert(). */ async alert(options: string | { title?: string; message: string; panelClass?: string | string[]; autoFocus?: boolean; }): Promise { if (typeof options === 'string') { options = { message: options }; } try { await this.confirm({ title: options.title, message: options.message, disableCancel: true, panelClass: options.panelClass, autoFocus: options.autoFocus, }); } catch { // Alert always resolves — user dismissed the dialog } } /** * Show a prompt dialog with text input. * Replaces AngularJS $mdDialog.prompt(). * Returns the entered value, or throws if cancelled. */ async prompt(options: { title: string; placeholder: string; initialValue?: string; ok: string; cancel: string; }): Promise { const { PromptDialogComponent } = await import( /* webpackChunkName: "dialog-prompt" */ '../dialogs/prompt-dialog.component' ); const { createDialogPopupSettings } = await import('../dialogs/dialog-popup'); const dialogRef = this.dialog.open(PromptDialogComponent, createDialogPopupSettings({ data: { title: options.title, placeholder: options.placeholder, initialValue: options.initialValue ?? '', okButton: options.ok, cancelButton: options.cancel, }, })); return new Promise((resolve, reject) => { dialogRef.afterClosed().subscribe(result => { if (result !== undefined && result !== null) { resolve(result); } else { reject(); } }); }); } /** * Centralized error handling with i18n code lookup. * Returns true if data is OK, false if it was an error. * Replaces AngularJS p3xrCommon.generalHandleError(). */ generalHandleError(dataOrError: any): boolean { if (dataOrError === undefined) { return true; } if (!(dataOrError instanceof Error || dataOrError instanceof Object)) { dataOrError = new Error(String(dataOrError)); } if (dataOrError instanceof Error || dataOrError.status === 'error') { let error: any; if (dataOrError instanceof Error) { error = dataOrError; } else { error = dataOrError.error; } console.warn('generalHandleError'); console.error(error); // i18n code lookup const strings = this.i18n.strings(); const codes = strings.code || {}; if (typeof error === 'string' && codes.hasOwnProperty(error)) { error = new Error(codes[error]); } else if (error?.code && codes.hasOwnProperty(error.code)) { error.message = codes[error.code]; } else if (error?.message && codes.hasOwnProperty(error.message)) { error.message = codes[error.message]; } // Handle connection closed if (error?.message === 'Connection is closed.') { this.state.connection.set(undefined); } this.alert({ title: strings.title?.error, message: '
' + (error?.message || error) + '
', }); return false; } return true; } /** * Parse Redis INFO response and populate state. * Replaces AngularJS p3xrCommon.loadRedisInfoResponse(). */ async loadRedisInfoResponse(options: { response?: any } = {}): Promise { let response = options.response || this.lastResponse; this.lastResponse = response; if (!response) return; console.time('loadRedisInfoResponse'); const info = this.redisParser.info(response.info); const shouldSort = this.settings.keysSort() && response.keys.length <= this.settings.maxLightKeysCount; // Sort in Web Worker if needed const keys = shouldSort ? await this.treeBuilder.sortKeys(response.keys) : response.keys; // Update signals this.state.info.set(info); this.state.keysRaw.set(keys); this.state.keysInfo.set(response.keysInfo); this.state.keysInfoFetchedAt.set(response.keysInfoFetchedAt || Date.now()); console.timeEnd('loadRedisInfoResponse'); } } src/ng/services/i18n.service.ts000066400000000000000000000102611517727315400166760ustar00rootroot00000000000000import { Injectable, signal, computed, effect } from '@angular/core'; import { merge } from 'lodash-es'; import { getTranslations, loadTranslation as loadTranslationChunk } from '../../core/translation-loader'; import { detectLanguageFromLocale } from '../../core/detect-language'; /** * i18n service — Angular-native translation management. * * Uses function-valued translations (e.g. arrow functions that accept params), * which no standard i18n library supports. Translation storage and lazy loading * are provided by the standalone translation-loader module. * * Language changes are persisted to localStorage. */ @Injectable({ providedIn: 'root' }) export class I18nService { private static readonly STORAGE_KEY = 'p3xr-language'; private static readonly AUTO = 'auto'; /** * Whether language is in auto-detect mode (from browser/system locale). */ readonly isAuto = signal(this.detectIsAuto()); /** * Current language code signal. * Initialized from localStorage or browser detection, same as AngularJS boot.js. */ readonly currentLang = signal(this.detectInitialLanguage()); /** * Merged strings object: English fallback merged with current language. * Recomputes when currentLang changes. Supports function-valued translations. */ readonly strings = computed(() => { const translations = this.getTranslations(); const en = translations['en'] || {}; const current = translations[this.currentLang()] || {}; return merge({}, en, current); }); constructor() { // Persist language changes to localStorage and sync with AngularJS effect(() => { const lang = this.currentLang(); const auto = this.isAuto(); const storageValue = auto ? I18nService.AUTO : lang; this.setStorageItem(I18nService.STORAGE_KEY, storageValue); this.applyDocumentLanguage(lang); // Notify Electron shell so language persists across restarts try { if (window.parent && window.parent !== window) { window.parent.postMessage({ type: 'p3x-ui-storage-set', key: I18nService.STORAGE_KEY, value: storageValue }, '*'); } } catch { /* not in iframe */ } }); } /** * Switch the active language. 'auto' resolves from browser locale. * Lazily loads the translation chunk if not yet cached. */ setLanguage(choice: string): void { const auto = choice === I18nService.AUTO; const lang = auto ? this.resolveAutoLanguage() : (choice || 'en'); this.isAuto.set(auto); loadTranslationChunk(lang).then( () => this.currentLang.set(lang), () => this.currentLang.set(lang), ); } private resolveAutoLanguage(): string { try { return detectLanguageFromLocale(navigator.language); } catch { return 'en'; } } /** * Get available language codes. */ getAvailableLanguages(): string[] { return Object.keys(this.getTranslations()); } // --- Private helpers --- private getTranslations(): Record { return getTranslations(); } private detectIsAuto(): boolean { const stored = this.readStorageItem(I18nService.STORAGE_KEY); return !stored || stored === I18nService.AUTO; } private detectInitialLanguage(): string { const storedLang = this.readStorageItem(I18nService.STORAGE_KEY); if (storedLang && storedLang !== I18nService.AUTO) return storedLang; try { return detectLanguageFromLocale(navigator.language); } catch { return 'en'; } } private readStorageItem(name: string): string | null { try { return localStorage.getItem(name); } catch { return null; } } private setStorageItem(name: string, value: string): void { try { localStorage.setItem(name, value); } catch {} } private applyDocumentLanguage(lang: string): void { if (typeof document === 'undefined') { return; } document.documentElement.setAttribute('lang', lang === 'zn' ? 'zh' : lang); } } src/ng/services/icon-registry.service.ts000066400000000000000000000075301517727315400207220ustar00rootroot00000000000000import { Injectable } from '@angular/core' import { MatIconRegistry } from '@angular/material/icon' import { DomSanitizer } from '@angular/platform-browser' import { mdiAccount, mdiArchive, mdiBackspace, mdiBookOpenPageVariant, mdiCancel, mdiChartBar, mdiChartLine, mdiCheck, mdiCheckboxBlankOutline, mdiCheckboxMarked, mdiChevronDown, mdiChevronLeft, mdiChevronRight, mdiChevronUp, mdiClose, mdiCodeArray, mdiCog, mdiCommentText, mdiConsole, mdiContentCopy, mdiContentSave, mdiDelete, mdiDeleteForever, mdiDeleteSweep, mdiDns, mdiDownload, mdiEye, mdiEyeOff, mdiFileDocumentOutline, mdiFileTree, mdiFingerprint, mdiFormatLineSpacing, mdiGraph, mdiHeart, mdiHeartPulse, mdiImage, mdiInformation, mdiLogin, mdiLogout, mdiMagnify, mdiMinus, mdiNoteText, mdiPalette, mdiPencil, mdiPlus, mdiPlusBox, mdiPound, mdiPower, mdiRefresh, mdiSkipNext, mdiSkipPrevious, mdiSwapVertical, mdiTimer, mdiTimerSand, mdiUndo, mdiUnfoldLessHorizontal, mdiUnfoldMoreHorizontal, mdiUpload, mdiWrap, } from '@mdi/js' // Map Angular Material icon names to @mdi/js SVG paths (tree-shakeable) const ICON_MAP: Record = { 'account_tree': mdiFileTree, 'add': mdiPlus, 'add_box': mdiPlusBox, 'analytics': mdiChartBar, 'archive': mdiArchive, 'auto_graph': mdiGraph, 'backspace': mdiBackspace, 'bar_chart': mdiChartBar, 'cancel': mdiCancel, 'check_box': mdiCheckboxMarked, 'check_box_outline_blank': mdiCheckboxBlankOutline, 'chevron_right': mdiChevronRight, 'close': mdiClose, 'color_lens': mdiPalette, 'content_copy': mdiContentCopy, 'data_array': mdiCodeArray, 'delete': mdiDelete, 'delete_forever': mdiDeleteForever, 'delete_sweep': mdiDeleteSweep, 'description': mdiFileDocumentOutline, 'difference': mdiSwapVertical, 'done': mdiCheck, 'download': mdiDownload, 'edit': mdiPencil, 'expand_more': mdiChevronDown, 'favorite': mdiHeart, 'file_download': mdiDownload, 'file_upload': mdiUpload, 'fingerprint': mdiFingerprint, 'format_line_spacing': mdiFormatLineSpacing, 'hourglass_empty': mdiTimerSand, 'image': mdiImage, 'info': mdiInformation, 'keyboard_arrow_down': mdiChevronDown, 'keyboard_arrow_left': mdiChevronLeft, 'keyboard_arrow_right': mdiChevronRight, 'keyboard_arrow_up': mdiChevronUp, 'login': mdiLogin, 'logout': mdiLogout, 'menu_book': mdiBookOpenPageVariant, 'mode_comment': mdiCommentText, 'monitor_heart': mdiHeartPulse, 'notes': mdiNoteText, 'numbers': mdiPound, 'person': mdiAccount, 'power': mdiPower, 'refresh': mdiRefresh, 'remove': mdiMinus, 'save': mdiContentSave, 'search': mdiMagnify, 'settings': mdiCog, 'show_chart': mdiChartLine, 'skip_next': mdiSkipNext, 'skip_previous': mdiSkipPrevious, 'storage': mdiDns, 'terminal': mdiConsole, 'timer': mdiTimer, 'undo': mdiUndo, 'unfold_less': mdiUnfoldLessHorizontal, 'unfold_more': mdiUnfoldMoreHorizontal, 'upload': mdiUpload, 'visibility': mdiEye, 'visibility_off': mdiEyeOff, 'wrap_text': mdiWrap, } @Injectable({ providedIn: 'root' }) export class IconRegistryService { constructor( private iconRegistry: MatIconRegistry, private sanitizer: DomSanitizer, ) {} registerAll(): void { for (const [name, path] of Object.entries(ICON_MAP)) { const svg = `` this.iconRegistry.addSvgIconLiteral( name, this.sanitizer.bypassSecurityTrustHtml(svg), ) } } } src/ng/services/main-command.service.ts000066400000000000000000000164661517727315400204740ustar00rootroot00000000000000import { Injectable, Inject } from '@angular/core'; import { Subject } from 'rxjs'; import { SocketService } from './socket.service'; import { CommonService } from './common.service'; import { RedisParserService } from './redis-parser.service'; import { RedisStateService } from './redis-state.service'; import { SettingsService } from './settings.service'; import { I18nService } from './i18n.service'; import { NavigationService } from './navigation.service'; /** * Main command service — encapsulates Redis operations previously in AngularJS p3xrMain controller. * * Provides: * - selectDatabase(): switch Redis DB index * - save(): persist Redis data to disk * - refresh(): reload keys and info from server * - statistics(): navigate to statistics and refresh * - currentDatabase getter/setter with localStorage persistence * - addKey(): broadcast new key event * * Used by main-home-header, main-treecontrol-controls, and the main page component. */ @Injectable({ providedIn: 'root' }) export class MainCommandService { readonly refreshKey$ = new Subject(); readonly keyNew$ = new Subject<{ event: Event; node?: any }>(); readonly keyDelete$ = new Subject<{ key: string; event: Event }>(); readonly keyRename$ = new Subject<{ key: string; event: Event }>(); readonly treeControlEnabled$ = new Subject(); readonly mainResizer$ = new Subject<{ drag: boolean }>(); readonly treeRefresh$ = new Subject(); readonly consoleEmbeddedResize$ = new Subject(); readonly consoleActivate$ = new Subject(); readonly consoleDeactivate$ = new Subject(); readonly connectRequest$ = new Subject<{ connection: any; disableState?: boolean }>(); readonly disconnectRequest$ = new Subject(); constructor( @Inject(SocketService) private readonly socket: SocketService, @Inject(CommonService) private readonly common: CommonService, @Inject(RedisParserService) private readonly redisParser: RedisParserService, @Inject(RedisStateService) private readonly state: RedisStateService, @Inject(SettingsService) private readonly settings: SettingsService, @Inject(I18nService) private readonly i18n: I18nService, @Inject(NavigationService) private readonly nav: NavigationService, ) {} get currentDatabase(): number { let db: number | string | undefined | null = this.state.currentDatabase(); if (db === undefined) { db = this.readStorageItem(this.getStorageKey()); } if (db === undefined || db === null) { db = 0; } return Number(db); } set currentDatabase(value: number) { this.state.currentDatabase.set(value); const storageKey = this.getStorageKey(); if (storageKey) { try { localStorage.setItem(storageKey, String(value)); } catch {} } } async selectDatabase(dbIndex: number): Promise { this.currentDatabase = dbIndex; this.socket.stateChanged$.next(); try { this.state.page.set(1); await this.socket.request({ action: 'redis/console', payload: { command: `select ${dbIndex}` } }); const strings = this.i18n.strings(); this.common.toast({ message: strings.status?.dbChanged?.({ db: dbIndex }) ?? `Database changed to ${dbIndex}` }); await this.statistics(); } catch (e) { this.common.generalHandleError(e); } finally { this.socket.stateChanged$.next(); } } async save(): Promise { try { const response = await this.socket.request({ action: 'redis/save' }); const info = this.redisParser.info(response.info); this.state.info.set(info); const strings = this.i18n.strings(); this.common.toast({ message: strings.status?.savedRedis ?? 'Redis saved' }); } catch (e) { this.common.generalHandleError(e); } } async statistics(): Promise { try { this.navigateTo('database.statistics'); await this.refresh({ force: true }); } catch (e) { this.common.generalHandleError(e); } } private lastRefreshAt = 0; async refresh(options: { withoutParent?: boolean; force?: boolean } = {}): Promise { // Throttle: skip if last refresh was less than 2s ago const now = Date.now(); if (!options.force && now - this.lastRefreshAt < 2000) return; this.lastRefreshAt = now; const { withoutParent = false } = options; console.time('refresh'); try { const payload: any = {}; const searchValue = this.state.search(); if (!this.settings.searchClientSide() && typeof searchValue === 'string' && searchValue.length > 0) { if (this.settings.searchStartsWith()) { payload.match = searchValue + '*'; } else { payload.match = '*' + searchValue + '*'; } } const response = await this.socket.request({ action: 'redis/refresh', payload }); this.state.dbsize.set(response.dbsize); this.state.redisChanged.set(true); await this.common.loadRedisInfoResponse({ response }); // Tell tree to rebuild with new keys this.treeRefresh$.next(); if (!withoutParent) { this.refreshKey$.next(); } } catch (e) { this.common.generalHandleError(e); } finally { console.timeEnd('refresh'); this.socket.stateChanged$.next(); } } addKey(options: { event: Event; node?: any }): void { const { event, node } = options; event.stopPropagation(); this.keyNew$.next({ event, node }); } async disconnect(): Promise { const conn = this.state.connection(); const storageKey = this.settings.connectInfoStorageKey; // Clear state + storage immediately for instant UI feedback if (storageKey) { try { localStorage.removeItem(storageKey); } catch {} } this.state.connection.set(undefined); this.state.redisConnections.set({}); this.state.monitor.set(false); this.socket.stateChanged$.next(); try { await this.socket.request({ action: 'connection/disconnect', payload: { connectionId: conn?.id }, }); } catch { // Ignore — state already cleared } finally { this.nav.navigateTo('settings'); } } navigateTo(state: string, params?: any): void { this.nav.navigateTo(state, params); } // --- Private helpers --- private getStorageKey(): string { try { return this.settings.getStorageKeyCurrentDatabase(this.state.connection()?.id) ?? ''; } catch { return ''; } } private readStorageItem(name: string): string | null { if (!name) return null; try { return localStorage.getItem(name); } catch { return null; } } } src/ng/services/navigation.service.ts000066400000000000000000000040461517727315400202620ustar00rootroot00000000000000import { Injectable, Inject } from '@angular/core'; import { Router } from '@angular/router'; /** * Centralized navigation service — replaces AngularJS UI-Router $state.go(). * * Maps the old UI-Router state names to Angular Router paths: * - 'settings' → /settings * - 'database.statistics' → /database/statistics * - 'database.key' → /database/key/:key * * Legacy 'main.*' names are supported for backward compatibility. */ @Injectable({ providedIn: 'root' }) export class NavigationService { constructor(@Inject(Router) private readonly router: Router) {} /** * Navigate using state name. */ navigateTo(state: string, params?: any): void { switch (state) { case 'info': this.router.navigate(['/info']); break; case 'settings': this.router.navigate(['/settings']); break; case 'monitoring': this.router.navigate(['/monitoring']); break; case 'search': this.router.navigate(['/search']); break; case 'database.statistics': case 'main.statistics': this.router.navigate(['/database/statistics']); break; case 'database.key': case 'main.key': this.router.navigate(['/database/key', params?.key ?? '']); break; default: console.warn(`[NavigationService] Unknown state: ${state}`); this.router.navigate(['/settings']); } } /** * Get the current route URL. */ get currentUrl(): string { return this.router.url; } /** * Get a route parameter (for key viewer). */ getParam(name: string): string | null { const url = this.router.url; if (name === 'key' && url.startsWith('/database/key/')) { return decodeURIComponent(url.substring('/database/key/'.length)); } return null; } } src/ng/services/notification.service.ts000066400000000000000000000033711517727315400206110ustar00rootroot00000000000000import { Injectable } from '@angular/core'; const STORAGE_KEY = 'p3xr-desktop-notifications'; const isElectron = /electron/i.test(navigator.userAgent); /** * Desktop notification service — works in Electron (native) and web (Notification API). * Default: disabled. User opts in via Settings toggle. * Only fires when the app/tab is not focused. */ @Injectable({ providedIn: 'root' }) export class NotificationService { isEnabled(): boolean { try { const stored = localStorage.getItem(STORAGE_KEY); if (stored === null) return isElectron; // default: on in Electron, off in web return stored === 'true'; } catch { return false; } } setEnabled(enabled: boolean): void { try { localStorage.setItem(STORAGE_KEY, String(enabled)); } catch {} if (enabled && !isElectron) this.requestWebPermission(); } private requestWebPermission(): void { if (typeof Notification === 'undefined') return; if (Notification.permission !== 'granted' && Notification.permission !== 'denied') { Notification.requestPermission(); } } notify(title: string, body: string): void { if (!this.isEnabled()) return; if (!document.hidden && document.hasFocus()) return; if (isElectron) { try { window.parent?.postMessage({ type: 'p3x-notify', title, body }, '*'); } catch {} return; } if (typeof Notification !== 'undefined' && Notification.permission === 'granted') { try { const n = new Notification(title, { body, icon: '/images/redis.svg' }); n.onclick = () => { window.focus(); n.close(); }; } catch {} } } } src/ng/services/overlay.service.ts000066400000000000000000000016161517727315400176040ustar00rootroot00000000000000import { Injectable } from '@angular/core'; /** * Loading overlay service — shows/hides a full-screen spinner overlay. */ @Injectable({ providedIn: 'root' }) export class OverlayService { private isShown = false; show(options: { message?: string } = {}): void { this.hide(); document.body.classList.add('p3xr-overlay-visible'); const html = `
${options.message ? '

' + options.message : ''}
`; document.body.insertAdjacentHTML('beforeend', html); this.isShown = true; } hide(): void { this.isShown = false; document.body.classList.remove('p3xr-overlay-visible'); const el = document.getElementById('p3xr-overlay'); if (el) el.remove(); } } src/ng/services/redis-parser.service.ts000066400000000000000000000160051517727315400205210ustar00rootroot00000000000000import { Injectable } from '@angular/core'; /** * Angular service that mirrors the AngularJS p3xrRedisParser factory. * Pure logic — no AngularJS dependencies. During hybrid mode, both this * service and the AngularJS factory coexist. Future Angular components * inject this service; existing AngularJS components keep using the factory. */ @Injectable({ providedIn: 'root' }) export class RedisParserService { /** * Parses a key=value line into an object. * e.g. "keys=10,expires=5" → { keys: "10", expires: "5" } */ array(options: { line: string; divider?: string; fieldDivider?: string }): Record { const { line } = options; const divider = options.divider ?? ','; const fieldDivider = options.fieldDivider ?? '='; const rows = line.split(divider); const obj: Record = {}; for (const row of rows) { const rowLine = row.split(fieldDivider); const rowLineData = rowLine[1] ?? ''; obj[rowLine[0]] = rowLineData.trim(); } return obj; } /** * Parses Redis INFO command output into a nested object grouped by section. */ info(str: string): any { const lines = str.split('\n'); const obj: any = {}; let section: string | undefined; let currentSectionObj: any = {}; let pikaIndex = 0; for (const line of lines) { if (line.startsWith('#')) { if (section !== undefined) { obj[section] = currentSectionObj; } section = line.substring(1).toLowerCase().trim(); currentSectionObj = {}; } else if (line.length > 2) { if (line.includes(':')) { const lineArray = line.split(':'); const value = lineArray[1] ?? ''; currentSectionObj[lineArray[0]] = value.includes(',') ? this.array({ line: value.trim() }) : value.trim(); } else { // pika format const [key, ...rest] = line.split(/ (.+)/); const values = rest[0] ?? ''; const value = values .split(',') .map((item: string) => `${pikaIndex}-${item.trim()}`) .join(','); if (currentSectionObj.hasOwnProperty('db0')) { Object.assign( currentSectionObj['db0'], value.includes(',') ? this.array({ line: value.trim() }) : value.trim() ); } else { currentSectionObj['db0'] = value.includes(',') ? this.array({ line: value.trim() }) : value.trim(); } pikaIndex++; } } } if (section !== undefined && Object.keys(currentSectionObj).length > 0) { obj[section] = currentSectionObj; } obj.keyspaceDatabases = {}; if (obj.hasOwnProperty('keyspace')) { Object.keys(obj.keyspace).forEach((key) => { const dbIndex = parseInt(key.substring(2)); obj.keyspaceDatabases[dbIndex] = true; }); } return obj; } /** * Converts a flat list of Redis keys into a hierarchical tree structure. * Used by the tree control to display keys grouped by divider (default ':'). */ keysToTreeControl(options: { keys: string[]; divider?: string; keysInfo?: any; savedExpandedNodes?: any[]; }): { nodes: any[]; expandedNodes: any[] } { const { keys } = options; const divider = options.divider ?? ':'; const keysInfo = options.keysInfo ?? {}; const savedExpandedNodes = options.savedExpandedNodes ?? []; const mainNodes: any[] = []; const newExpandedNodes: any[] = []; const recursiveNodes = (splitKey: string[], level: number = 0, nodes: any[] = mainNodes) => { let foundNode: any = false; if (level + 1 < splitKey.length) { for (const node of nodes) { if (node.label === splitKey[level] && node.type === 'folder') { foundNode = node; } } } if (!foundNode) { const defaultFoundNode: any = { label: splitKey[level], key: splitKey.slice(0, level + 1).join(divider), children: [], childCount: 0, type: level + 1 === splitKey.length ? 'element' : 'folder', }; if (defaultFoundNode.type === 'element') { defaultFoundNode.keysInfo = keysInfo[defaultFoundNode.key]; } nodes.push(defaultFoundNode); foundNode = defaultFoundNode; for (const saveExpandedNode of savedExpandedNodes) { if (saveExpandedNode.key === foundNode.key) { newExpandedNodes.push(foundNode); } } } if (level + 1 < splitKey.length) { recursiveNodes(splitKey, level + 1, foundNode.children); } }; for (const key of keys) { const splitkey = divider === '' ? [key] : key.split(divider); recursiveNodes(splitkey); } const recursiveKeyCount = (node: any) => { node.childCount = 0; for (const child of node.children) { if (child.type === 'element') { const info = child.keysInfo; if (info && info.type !== 'string' && info.type !== 'json' && info.length != null) { node.childCount += info.length; } else { node.childCount += 1; } } } for (const child of node.children) { recursiveKeyCount(child); if (child.type === 'folder') { node.childCount += child.childCount; } } }; for (const node of mainNodes) { recursiveKeyCount(node); } return { nodes: mainNodes, expandedNodes: newExpandedNodes }; } /** * Parses console command response into a display string. */ consoleParse(responseResult: any): string { if (responseResult !== null && typeof responseResult === 'object') { let result = ''; Object.keys(responseResult).forEach((key) => { if (result !== '') { result += '\n'; } result += responseResult[key]; }); return result; } else { return responseResult; } } } src/ng/services/redis-state.service.ts000066400000000000000000000104201517727315400203400ustar00rootroot00000000000000import { Injectable, Inject, signal, computed } from '@angular/core'; import { SettingsService } from './settings.service'; import { parseRedisVersion, RedisVersion } from '../../core/redis-version'; declare const P3XR_API_PORT: number; declare const P3XR_DEV_MODE: boolean; /** * Runtime state service using Angular signals. * * Single source of truth for all Redis UI runtime state. No global object dependency. */ @Injectable({ providedIn: 'root' }) export class RedisStateService { // --- Writable signals for runtime state --- readonly theme = signal(undefined); readonly connection = signal(undefined); readonly currentDatabase = signal(undefined); readonly databaseIndexes = signal([0]); readonly connections = signal({ list: [] }); readonly redisConnections = signal>({}); readonly keysRaw = signal([]); readonly keysInfo = signal(undefined); readonly search = signal(this.getStoredSearch()); readonly page = signal(1); readonly info = signal(undefined); readonly dbsize = signal(undefined); readonly redisChanged = signal(false); readonly failed = signal(false); readonly monitor = signal(false); readonly monitorPattern = signal('*'); readonly commands = signal([]); readonly commandsMeta = signal>({}); readonly cfg = signal(undefined); readonly version = signal(undefined); readonly modules = signal([]); readonly hasRediSearch = signal(false); readonly hasReJSON = signal(false); readonly hasTimeSeries = signal(false); readonly hasBloom = signal(false); readonly reducedFunctions = signal(false); readonly keysInfoFetchedAt = signal(Date.now()); // --- Computed values --- /** Parsed Redis server version for feature gating (e.g. redisVersion().isAtLeast(8, 2)) */ readonly redisVersion = computed(() => parseRedisVersion(this.info()?.server?.redis_version) ); readonly themeLayout = computed(() => { const t = this.theme(); return t ? t + 'Layout' : undefined; }); readonly themeCommon = computed(() => { const t = this.theme(); return t ? t + 'Common' : undefined; }); readonly filteredKeys = computed(() => { let keys = this.keysRaw().slice(); const search = this.search(); const settings = this.settings; // Apply client-side search filter if (settings.searchClientSide() && typeof search === 'string' && search.length > 0) { if (settings.searchStartsWith()) { keys = keys.filter((key) => key.startsWith(search)); } else { keys = keys.filter((key) => key.includes(search)); } } return keys; }); readonly paginatedKeys = computed(() => { const keys = this.filteredKeys(); const pageSize = this.settings.pageCount(); if (keys.length <= pageSize) { return keys; } const start = (this.page() - 1) * pageSize; return keys.slice(start, start + pageSize); }); readonly pages = computed(() => { return Math.ceil(this.filteredKeys().length / this.settings.pageCount()); }); // --- API host (computed once at startup) --- readonly apiHost: string = (() => { const apiUrl = new URL(location.toString()); if (typeof P3XR_DEV_MODE !== 'undefined' && P3XR_DEV_MODE) { const apiPort = typeof P3XR_API_PORT !== 'undefined' ? P3XR_API_PORT : 7843; return `http://${apiUrl.hostname}:${apiPort}`; } return `${apiUrl.protocol}//${apiUrl.host}`; })(); constructor(@Inject(SettingsService) private settings: SettingsService) {} /** * Resets connections to default state. */ resetConnections(): void { this.connections.set({ list: [] }); } private getStoredSearch(): string { try { return localStorage.getItem('p3xr-state-search') ?? ''; } catch { return ''; } } } src/ng/services/settings.service.ts000066400000000000000000000231331517727315400177610ustar00rootroot00000000000000import { Injectable, Inject, Injector, signal, computed, effect } from '@angular/core'; import prettyBytes from 'pretty-bytes'; import { I18nService } from './i18n.service'; /** * LocalStorage-backed settings service using Angular signals. * * Each setting is a WritableSignal that reads its initial value from * localStorage and persists changes back to localStorage. */ @Injectable({ providedIn: 'root' }) export class SettingsService { // --- LocalStorage-backed signals --- readonly redisTreeDivider = signal(this.getStorage('p3xr-main-treecontrol-divider', ':')); readonly jsonFormat = signal(this.getStorageInt('p3xr-json-format', 4)); readonly animation = signal(this.getStorageInt('p3xr-animation-settings', 0) === 1); readonly maxValueDisplay = signal(this.getStorageInt('p3xr-main-treecontrol-max-value-display', 1024)); readonly maxKeys = signal(this.clampMaxKeys(this.getStorageInt('p3xr-max-keys', 1000))); readonly keysSort = signal(this.getStorageBool('p3xr-main-treecontrol-key-sort', true)); readonly searchClientSide = signal(this.getStorageBool('p3xr-main-treecontrol-search-client-mode', false)); readonly searchStartsWith = signal(this.getStorageBool('p3xr-main-treecontrol-search-starts-with', false)); readonly pageCount = signal(this.getStorageInt('p3xr-main-treecontrol-page-size', 250)); readonly keyPageCount = signal(this.getStorageInt('p3xr-main-key-page-size', 5)); readonly language = signal(this.getStorage('p3xr-language', 'en')); readonly undoEnabled = signal(this.getStorageBool('p3xr-undo-enabled', true)); readonly showDiffBeforeSave = signal(this.getStorageBool('p3xr-show-diff-before-save', false)); // --- Static config --- readonly maxKeysMax = 100000; readonly maxLightKeysCount = 110000; readonly resizeMinWidth = 315; readonly debounce = 100; readonly debounceSearch = 2000; readonly googleAnalytics = 'G-8M2CK7993T'; readonly maxValueAsBuffer = 1000 * 256; readonly socketTimeout = 300000; readonly connectInfoStorageKey = 'p3xr-connect-info'; readonly currentDatabaseStorageKeyPrefix = 'p3xr-main-current-database'; // --- Utility methods --- prettyBytes(value: number): string { const lang = this.resolveAutoLang(); return prettyBytes(value, { locale: lang }); } getStorageKeyCurrentDatabase(connectionId: string): string { return this.currentDatabaseStorageKeyPrefix + '-' + connectionId; } generateId(): string { return Date.now().toString(36) + '-' + Math.random().toString(36).substring(2, 10); } async clipboard(value: string): Promise { try { await navigator.clipboard.writeText(value); return true; } catch { return false; } } // Custom humanize-duration languages for unsupported locales private readonly humanizeDurationCustomLanguages: Record = { az: { y: () => 'il', mo: () => 'ay', w: () => 'həftə', d: () => 'gün', h: () => 'saat', m: () => 'dəqiqə', s: () => 'saniyə', ms: () => 'millisaniyə' }, be: { y: (c: number) => c === 1 ? 'год' : 'гадоў', mo: (c: number) => c === 1 ? 'месяц' : 'месяцаў', w: (c: number) => c === 1 ? 'тыдзень' : 'тыдняў', d: (c: number) => c === 1 ? 'дзень' : 'дзён', h: (c: number) => c === 1 ? 'гадзіна' : 'гадзін', m: (c: number) => c === 1 ? 'хвіліна' : 'хвілін', s: (c: number) => c === 1 ? 'секунда' : 'секунд', ms: (c: number) => c === 1 ? 'мілісекунда' : 'мілісекунд' }, bs: { y: () => 'godina', mo: () => 'mjeseci', w: () => 'sedmica', d: () => 'dana', h: () => 'sati', m: () => 'minuta', s: () => 'sekundi', ms: () => 'milisekundi' }, fil: { y: () => 'taon', mo: () => 'buwan', w: () => 'linggo', d: () => 'araw', h: () => 'oras', m: () => 'minuto', s: () => 'segundo', ms: () => 'millisegundo' }, hy: { y: () => 'տարի', mo: () => ' delays', w: () => 'շաբdelays', d: () => 'օdelays', h: () => 'ժdelays', m: () => 'delays', s: () => 'delays', ms: () => 'delays' }, ka: { y: () => 'წელი', mo: () => 'თვე', w: () => 'კვირა', d: () => 'დღე', h: () => 'საათი', m: () => 'წუთი', s: () => 'წამი', ms: () => 'მილიწამი' }, kk: { y: () => 'жыл', mo: () => 'ай', w: () => 'апта', d: () => 'күн', h: () => 'сағат', m: () => 'минут', s: () => 'секунд', ms: () => 'миллисекунд' }, ky: { y: () => 'жыл', mo: () => 'ай', w: () => 'апта', d: () => 'күн', h: () => 'саат', m: () => 'мүнөт', s: () => 'секунд', ms: () => 'миллисекунд' }, ne: { y: () => 'वर्ष', mo: () => 'महिना', w: () => 'हप्ता', d: () => 'दिन', h: () => 'घण्टा', m: () => 'मिनेट', s: () => 'सेकेन्ड', ms: () => 'मिलिसेकेन्ड' }, si: { y: () => 'වසර', mo: () => 'මාස', w: () => 'සති', d: () => 'දින', h: () => 'පැය', m: () => 'මිනිත්තු', s: () => 'තත්පර', ms: () => 'මිලි තත්පර' }, tg: { y: () => 'сол', mo: () => 'моҳ', w: () => 'ҳафта', d: () => 'рӯз', h: () => 'соат', m: () => 'дақиқа', s: () => 'сония', ms: () => 'миллисония' }, nb: { y: (c: number) => c === 1 ? 'år' : 'år', mo: (c: number) => c === 1 ? 'måned' : 'måneder', w: (c: number) => c === 1 ? 'uke' : 'uker', d: (c: number) => c === 1 ? 'dag' : 'dager', h: (c: number) => c === 1 ? 'time' : 'timer', m: (c: number) => c === 1 ? 'minutt' : 'minutter', s: (c: number) => c === 1 ? 'sekund' : 'sekunder', ms: () => 'millisekund' }, }; private readonly humanizeDurationLanguageMap: Record = { 'pt-BR': 'pt', 'zn': 'zh_CN', 'zh-HK': 'zh_TW', 'zh-TW': 'zh_TW', 'pt-PT': 'pt', }; private resolveAutoLang(): string { const i18n = this.injector.get(I18nService) as { currentLang: () => string }; return i18n.currentLang() || 'en'; } getHumanizeDurationOptions(): { language: string; languages: Record } { // Always read from I18nService for the current active language // (SettingsService.language signal is only initialized at startup) const lang = this.resolveAutoLang(); return { language: this.humanizeDurationLanguageMap[lang] || lang || 'en', languages: this.humanizeDurationCustomLanguages, }; } constructor(@Inject(Injector) private readonly injector: Injector) { // Persist signal changes back to localStorage effect(() => { this.setStorage('p3xr-main-treecontrol-divider', this.redisTreeDivider()); }); effect(() => { this.setStorage('p3xr-json-format', String(this.jsonFormat())); }); effect(() => { this.setStorage('p3xr-animation-settings', this.animation() ? '1' : '0'); }); effect(() => { this.applyAnimationClass(this.animation()); }); effect(() => { this.setStorage('p3xr-main-treecontrol-max-value-display', String(this.maxValueDisplay())); }); effect(() => { this.setStorage('p3xr-max-keys', String(this.maxKeys())); }); effect(() => { this.setStorage('p3xr-main-treecontrol-key-sort', String(this.keysSort())); }); effect(() => { this.setStorage('p3xr-main-treecontrol-search-client-mode', String(this.searchClientSide())); }); effect(() => { this.setStorage('p3xr-main-treecontrol-search-starts-with', String(this.searchStartsWith())); }); effect(() => { this.setStorage('p3xr-main-treecontrol-page-size', String(this.pageCount())); }); effect(() => { this.setStorage('p3xr-main-key-page-size', String(this.keyPageCount())); }); effect(() => { this.setStorage('p3xr-language', this.language()); }); effect(() => { this.setStorage('p3xr-undo-enabled', String(this.undoEnabled())); }); effect(() => { this.setStorage('p3xr-show-diff-before-save', String(this.showDiffBeforeSave())); }); } // --- Storage helpers --- private getStorage(name: string, defaultValue: string): string { try { const value = localStorage.getItem(name); return value !== null ? value : defaultValue; } catch { return defaultValue; } } private getStorageInt(name: string, defaultValue: number): number { const value = this.getStorage(name, ''); if (!value) return defaultValue; const parsed = parseInt(value, 10); return isNaN(parsed) ? defaultValue : parsed; } private getStorageBool(name: string, defaultValue: boolean): boolean { const value = this.getStorage(name, ''); if (!value) return defaultValue; if (value === 'true') return true; if (value === 'false') return false; return defaultValue; } private clampMaxKeys(value: number): number { if (isNaN(value) || value < 5 || value > this.maxKeysMax) { return 1000; } return value; } private setStorage(name: string, value: string): void { try { localStorage.setItem(name, value); } catch { /* ignore */ } } private applyAnimationClass(enabled: boolean): void { if (typeof document === 'undefined') { return; } document.body.classList.toggle('p3xr-animation', enabled); document.body.classList.toggle('p3xr-no-animation', !enabled); } } src/ng/services/shortcuts.service.ts000066400000000000000000000132311517727315400201550ustar00rootroot00000000000000import { Injectable, Inject } from '@angular/core'; import { I18nService } from './i18n.service'; import { NavigationService } from './navigation.service'; import { MainCommandService } from './main-command.service'; import { SocketService } from './socket.service'; import { CommonService } from './common.service'; import { CommandPaletteDialogService } from '../dialogs/command-palette-dialog.service'; import { RedisStateService } from './redis-state.service'; export interface ShortcutDef { key: string; ctrlKey?: boolean; shiftKey?: boolean; altKey?: boolean; label: string; descriptionKey: string; action: () => void; } @Injectable({ providedIn: 'root' }) export class ShortcutsService { private shortcuts: ShortcutDef[] = []; private readonly isElectron: boolean; constructor( @Inject(I18nService) private i18n: I18nService, @Inject(NavigationService) private nav: NavigationService, @Inject(MainCommandService) private cmd: MainCommandService, @Inject(SocketService) private socket: SocketService, @Inject(CommonService) private common: CommonService, @Inject(CommandPaletteDialogService) private commandPalette: CommandPaletteDialogService, @Inject(RedisStateService) private state: RedisStateService, ) { this.isElectron = /electron/i.test(navigator.userAgent); if (this.isElectron) { this.initShortcuts(); } } private get isConnected(): boolean { return !!this.state.connection(); } private requireConnection(action: () => void): void { if (this.isConnected) { action(); } else { const strings = this.i18n.strings(); this.common.toast(strings?.label?.connectFirst); } } private requireConnectionAndHome(action: () => void): void { if (!this.isConnected) { const strings = this.i18n.strings(); this.common.toast(strings?.label?.connectFirst); return; } // Navigate to home if not already there if (!this.nav.currentUrl.startsWith('/database')) { this.nav.navigateTo('database.statistics'); setTimeout(() => action(), 300); } else { action(); } } private initShortcuts(): void { this.shortcuts = [ { key: 'r', ctrlKey: true, label: 'Ctrl+R', descriptionKey: 'shortcutRefresh', action: () => this.requireConnection(() => this.cmd.treeRefresh$.next()), }, { key: 'F5', label: 'F5', descriptionKey: 'shortcutRefresh', action: () => this.requireConnection(() => this.cmd.treeRefresh$.next()), }, { key: 'f', ctrlKey: true, label: 'Ctrl+F', descriptionKey: 'shortcutSearch', action: () => this.requireConnectionAndHome(() => { const el = document.querySelector('.p3xr-database-treecontrol-controls-search input'); if (el) { el.focus(); } }), }, { key: 'n', ctrlKey: true, label: 'Ctrl+N', descriptionKey: 'shortcutNewKey', action: () => this.requireConnectionAndHome(() => { this.cmd.keyNew$.next({ event: new Event('shortcut') }); }), }, { key: 'k', ctrlKey: true, label: 'Ctrl+K', descriptionKey: 'shortcutCommandPalette', action: () => this.commandPalette.show(), }, { key: 'd', ctrlKey: true, label: 'Ctrl+D', descriptionKey: 'shortcutDisconnect', action: () => this.requireConnection(() => this.cmd.disconnectRequest$.next()), }, ]; } isEnabled(): boolean { return this.isElectron; } getShortcuts(): ShortcutDef[] { return this.shortcuts; } getShortcutsWithDescriptions(): Array<{ key: string; description: string }> { const strings = this.i18n.strings(); return this.shortcuts .filter((s, i, arr) => arr.findIndex(x => x.descriptionKey === s.descriptionKey) === i) .map(s => ({ key: s.label, description: strings?.label?.[s.descriptionKey] || s.descriptionKey, })); } handleKeydown(event: KeyboardEvent): boolean { if (!this.isElectron) return false; const target = event.target as HTMLElement; const tag = target?.tagName?.toLowerCase(); if (tag === 'input' || tag === 'textarea' || target?.closest('.cm-editor')) { return false; } for (const shortcut of this.shortcuts) { const ctrlMatch = shortcut.ctrlKey ? event.ctrlKey || event.metaKey : !event.ctrlKey && !event.metaKey; const shiftMatch = shortcut.shiftKey ? event.shiftKey : !event.shiftKey; const altMatch = shortcut.altKey ? event.altKey : !event.altKey; const keyMatch = event.key.toLowerCase() === shortcut.key.toLowerCase() || event.key === shortcut.key; if (ctrlMatch && shiftMatch && altMatch && keyMatch) { event.preventDefault(); event.stopPropagation(); shortcut.action(); return true; } } return false; } } src/ng/services/socket.service.ts000066400000000000000000000226661517727315400174230ustar00rootroot00000000000000import { Injectable, Inject, ApplicationRef } from '@angular/core'; import { Subject } from 'rxjs'; import { RedisStateService } from './redis-state.service'; import { SettingsService } from './settings.service'; import { OverlayService } from './overlay.service'; import { I18nService } from './i18n.service'; import { NotificationService } from './notification.service'; import { io } from 'socket.io-client'; declare const P3XR_DEV_MODE: boolean; /** * Angular Socket.IO service — standalone, no AngularJS dependency. * All callbacks run inside Angular's zone for automatic change detection. */ @Injectable({ providedIn: 'root' }) export class SocketService { private ioClient: any; private reconnect = false; private connectErrorWas = false; private disconnected = false; private authBlocked = false; readonly connections$ = new Subject(); readonly redisDisconnected$ = new Subject(); readonly redisStatus$ = new Subject(); readonly configuration$ = new Subject(); readonly socketError$ = new Subject(); readonly stateChanged$ = new Subject(); constructor( @Inject(ApplicationRef) private appRef: ApplicationRef, @Inject(RedisStateService) private state: RedisStateService, @Inject(SettingsService) private settings: SettingsService, @Inject(OverlayService) private overlay: OverlayService, @Inject(I18nService) private i18n: I18nService, @Inject(NotificationService) private notificationService: NotificationService, ) { this.initConnection(); } tick(): void { setTimeout(() => { this.appRef.tick(); }); } private initConnection(): void { const ioOptions: any = { rejectUnauthorized: false, path: '/socket.io', secure: true, reconnection: true, reconnectionAttempts: Infinity, reconnectionDelay: 1000, reconnectionDelayMax: 5000, }; if (typeof P3XR_DEV_MODE !== 'undefined' && P3XR_DEV_MODE) { ioOptions.transports = ['websocket']; } // Include auth token if stored (JWT login) const authToken = localStorage.getItem('p3xr-auth-token'); if (authToken) { ioOptions.auth = { token: authToken }; } this.ioClient = io(this.state.apiHost, ioOptions); this.ioClient.on('connect', async () => { if (this.disconnected || this.connectErrorWas) { console.log('p3xr-socket RE-connected', this.ioClient.id); const strings = this.i18n.strings(); this.notificationService.notify(strings?.title?.name, strings?.status?.connectionRestored); this.disconnected = false; this.connectErrorWas = false; location.reload(); return; } if (this.reconnect) { console.log('p3xr-socket RE-connected', this.ioClient.id); } else { console.log('p3xr-socket connected', this.ioClient.id); } this.reconnect = true; }); this.ioClient.on('disconnect', () => { if (this.authBlocked) return; this.disconnected = true; try { this.overlay.show(); } catch {} }); this.ioClient.on('error', (error: any) => { this.handleSocketError(error); }); this.ioClient.on('connect_error', (error: any) => { if (error?.message === 'auth_required') { // Auth required but no valid token — stop reconnecting, no overlay this.authBlocked = true; this.ioClient.disconnect(); return; } this.handleSocketError(error); }); this.ioClient.on('connections', (data: any) => { if (data.status === 'error') { this.state.resetConnections(); this.tick(); return; } this.state.connections.set(data.connections); this.connections$.next(data); this.tick(); }); this.ioClient.on('redis-disconnected', (data: any) => { if (this.state.connection() !== undefined && this.state.connection().id === data.connectionId) { this.state.monitor.set(false); this.state.connection.set(undefined); if (data.status === 'error') { const strings = this.i18n.strings(); const msg = strings?.status?.redisDisconnected?.(data) ?? 'Redis disconnected'; this.showToast(msg); this.notificationService.notify(strings?.title?.name, msg); } else if (data.status === 'code') { const strings = this.i18n.strings(); const codes = strings?.code ?? {}; const msg = codes[data.code] ?? `unknown redis disconnect code: ${data.code}`; this.showToast(msg); this.notificationService.notify(strings?.title?.name, msg); } this.redisDisconnected$.next(data); this.tick(); this.request({ action: 'connection/trigger-disconnect', enableResponse: false }).catch(() => {}); } }); this.ioClient.on('redis-status', (data: any) => { this.state.redisConnections.set(data.redisConnections); this.redisStatus$.next(data); this.tick(); }); let receivedVersion = false; this.ioClient.on('configuration', (data: any) => { this.state.cfg.set(data); if (data.snapshot === true) { this.state.version.set('SNAPSHOT'); } else { this.state.version.set('v' + data.version); if (!receivedVersion) { receivedVersion = true; try { (window as any).gtag?.('config', this.settings.googleAnalytics, { page_path: '/version/' + this.state.version() }); } catch { /* noop */ } } } this.configuration$.next(data); this.tick(); }); } private handleSocketError(error: any): void { try { this.overlay.show(); } catch {} if (!this.connectErrorWas) { this.connectErrorWas = true; this.socketError$.next(error); } } private showToast(message: string): void { try { const snackBar = (globalThis as any).__p3xr_snackbar; if (snackBar) { const ref = snackBar.open(message, 'x', { duration: 5000, horizontalPosition: 'right', verticalPosition: 'bottom', }); ref.onAction().subscribe(() => ref.dismiss()); } } catch { /* noop */ } } // --- Request API --- request(options: { action: string; payload?: any; enableResponse?: boolean; }): Promise { if (!this.ioClient) { return Promise.reject(new Error('Socket.IO client unavailable')); } if (!options.payload) { options.payload = {}; } options.payload.maxKeys = parseInt(String(this.settings.maxKeys() ?? '10000')); const enableResponse = options.enableResponse !== false; if (!enableResponse) { this.ioClient.emit('p3xr-request', options); return Promise.resolve(); } return new Promise((resolve, reject) => { const requestId = this.settings.generateId(); (options as any).requestId = requestId; const responseEvent = `p3xr-response-${requestId}`; let timeout: any; const response = (data: any) => { clearTimeout(timeout); this.ioClient.off(responseEvent); if (data?.status === 'ok') { resolve(data); } else { let errMsg = 'Unknown error'; try { const err = data?.error; if (typeof err === 'string') { errMsg = err; } else if (err?.message) { errMsg = err.message; } else if (err !== undefined && err !== null) { errMsg = String(err); } } catch { /* noop */ } reject(new Error(errMsg)); } // Tick after await continuations settle (avoids NG0100 in dev mode) this.tick(); }; timeout = setTimeout(() => { this.ioClient.off(responseEvent, response); const strings = this.i18n.strings(); const msg = strings?.label?.socketIoTimeout?.({ timeout: this.settings.socketTimeout }) ?? `Socket.IO request timeout (${this.settings.socketTimeout}ms)`; reject(new Error(msg)); this.tick(); }, this.settings.socketTimeout); this.ioClient.on(responseEvent, response); this.ioClient.emit('p3xr-request', options); }); } getClient(): any { return this.ioClient; } } src/ng/services/theme.service.ts000066400000000000000000000203501517727315400172210ustar00rootroot00000000000000import { Injectable, Inject, signal, computed, effect } from '@angular/core'; import { RedisStateService } from './redis-state.service'; /** * Theme management service using Angular signals. * * Manages theme selection, persistence (localStorage), dark/light classification, * and body class toggling. During hybrid mode, the AngularJS p3xrTheme provider * handles the AngularJS Material-specific parts ($mdThemingProvider, $mdColors, * dynamic CSS injection via jQuery). This service handles the framework-agnostic * parts that both Angular and AngularJS components need. * * After full migration (Phase 6), this service will also manage Angular Material * theming via Sass-compiled CSS class switching. * * Theme architecture: * - Each theme has 3 sub-themes: {Name}, {Name}Layout, {Name}Common * - Themes are classified as dark or light * - Current theme is persisted to localStorage key 'p3xr-theme' * - Body gets class 'p3xr-theme-dark' or 'p3xr-theme-light' * - document.documentElement gets data-color-scheme="dark"/"light" (for scrollbar styling) */ @Injectable({ providedIn: 'root' }) export class ThemeService { private static readonly STORAGE_KEY = 'p3xr-theme'; private static readonly AUTO_THEME = 'auto'; /** Theme classification: which themes are dark, which are light */ private static readonly DARK_THEMES = [ 'p3xrThemeDarkNeu', 'p3xrThemeDark', 'p3xrThemeDarkoBluo', 'p3xrThemeMatrix', ]; private static readonly LIGHT_THEMES = [ 'p3xrThemeLight', 'p3xrThemeEnterprise', 'p3xrThemeRedis', ]; /** All available theme names */ static readonly ALL_THEMES = [...ThemeService.DARK_THEMES, ...ThemeService.LIGHT_THEMES]; /** * Maps AngularJS theme names to Angular Material CSS class suffixes. * AngularJS: 'p3xrThemeDark' → Angular Material: 'p3xr-mat-theme-dark' */ private static readonly THEME_CSS_CLASS_MAP: Record = { 'p3xrThemeDark': 'p3xr-mat-theme-dark', 'p3xrThemeDarkNeu': 'p3xr-mat-theme-dark-neu', 'p3xrThemeDarkoBluo': 'p3xr-mat-theme-darko-bluo', 'p3xrThemeMatrix': 'p3xr-mat-theme-matrix', 'p3xrThemeLight': 'p3xr-mat-theme-light', 'p3xrThemeEnterprise': 'p3xr-mat-theme-enterprise', 'p3xrThemeRedis': 'p3xr-mat-theme-redis', }; /** Default theme based on system preference */ private static readonly DEFAULT_THEME = (typeof window !== 'undefined' && window.matchMedia?.('(prefers-color-scheme: dark)').matches) ? 'p3xrThemeDark' : 'p3xrThemeEnterprise'; // --- Signals --- /** Current theme name signal, persisted to localStorage */ readonly currentTheme: ReturnType>; /** Whether the current theme is a dark theme */ readonly isDark = computed(() => ThemeService.DARK_THEMES.includes(this.currentTheme())); /** Layout sub-theme name (e.g. 'p3xrThemeDarkLayout') */ readonly themeLayout = computed(() => this.currentTheme() + 'Layout'); /** Common sub-theme name (e.g. 'p3xrThemeDarkCommon') */ readonly themeCommon = computed(() => this.currentTheme() + 'Common'); /** Whether the current mode is auto (follows system) */ readonly isAuto: ReturnType>; constructor(@Inject(RedisStateService) private state: RedisStateService) { const initial = this.getInitialTheme(); const isAutoMode = initial === ThemeService.AUTO_THEME; this.isAuto = signal(isAutoMode); // If auto, resolve to system preference const resolvedTheme = isAutoMode ? ThemeService.getSystemTheme() : initial; this.currentTheme = signal(resolvedTheme); // Apply body classes and persist to localStorage on theme change effect(() => { const theme = this.currentTheme(); this.applyTheme(theme); }); // Listen for system dark/light mode changes if (typeof window !== 'undefined' && window.matchMedia) { window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', (e) => { if (this.isAuto()) { this.currentTheme.set(e.matches ? 'p3xrThemeDark' : 'p3xrThemeEnterprise'); } }); } } private static getSystemTheme(): string { return (typeof window !== 'undefined' && window.matchMedia?.('(prefers-color-scheme: dark)').matches) ? 'p3xrThemeDark' : 'p3xrThemeEnterprise'; } /** * Switch to a different theme. */ setTheme(themeName: string): void { if (themeName === ThemeService.AUTO_THEME) { this.isAuto.set(true); const resolved = ThemeService.getSystemTheme(); this.currentTheme.set(resolved); return; } if (!ThemeService.ALL_THEMES.includes(themeName)) { console.warn(`[ThemeService] Unknown theme: ${themeName}`); return; } this.isAuto.set(false); this.currentTheme.set(themeName); } /** * Get theme display name from internal name. * e.g. 'p3xrThemeDark' → 'Dark' */ getDisplayName(themeName: string): string { return themeName.replace('p3xrTheme', ''); } /** * Generate the internal theme name from a raw display name. * e.g. 'Dark' → 'p3xrThemeDark' */ generateThemeName(rawName: string): string { return 'p3xrTheme' + rawName[0].toUpperCase() + rawName.substring(1); } // --- Private helpers --- /** * Convert short localStorage key to Angular internal name. * e.g. 'dark' → 'p3xrThemeDark', 'enterprise' → 'p3xrThemeEnterprise' */ private fromShortKey(key: string): string { if (key.startsWith('p3xrTheme')) return key; // already Angular format const angularName = 'p3xrTheme' + key.charAt(0).toUpperCase() + key.slice(1); return ThemeService.ALL_THEMES.includes(angularName) ? angularName : key; } /** * Convert Angular internal name to short localStorage key. * e.g. 'p3xrThemeDark' → 'dark', 'p3xrThemeEnterprise' → 'enterprise' */ private toShortKey(themeName: string): string { if (!themeName.startsWith('p3xrTheme')) return themeName; const name = themeName.replace('p3xrTheme', ''); return name.charAt(0).toLowerCase() + name.slice(1); } private getInitialTheme(): string { const stored = this.readStorageItem(ThemeService.STORAGE_KEY); if (!stored) return ThemeService.AUTO_THEME; if (stored === ThemeService.AUTO_THEME) return stored; return this.fromShortKey(stored); } private applyTheme(themeName: string): void { const dark = ThemeService.DARK_THEMES.includes(themeName); this.setStorageItem(ThemeService.STORAGE_KEY, this.isAuto() ? ThemeService.AUTO_THEME : this.toShortKey(themeName)); if (typeof document !== 'undefined') { document.body.classList.remove('p3xr-theme-light', 'p3xr-theme-dark'); document.body.classList.add(dark ? 'p3xr-theme-dark' : 'p3xr-theme-light'); const allMatClasses = Object.values(ThemeService.THEME_CSS_CLASS_MAP); document.body.classList.remove(...allMatClasses); const matClass = ThemeService.THEME_CSS_CLASS_MAP[themeName]; if (matClass) { document.body.classList.add(matClass); } document.documentElement.style.display = 'none'; document.documentElement.setAttribute('data-color-scheme', dark ? 'dark' : 'light'); document.body.clientWidth; document.documentElement.style.display = ''; // Notify Electron shell (iframe parent) about theme change for scrollbar styling try { window.parent?.postMessage({ type: 'p3x-theme-change', dark: dark }, '*'); } catch (e) { /* not in iframe or cross-origin */ } } this.state.theme.set(themeName); } private readStorageItem(name: string): string | null { try { return localStorage.getItem(name); } catch { return null; } } private setStorageItem(name: string, value: string): void { try { localStorage.setItem(name, value); } catch {} } } src/ng/services/tree-builder.service.ts000066400000000000000000000240261517727315400205060ustar00rootroot00000000000000import { Injectable } from '@angular/core'; /** * Offloads keysToTreeControl and key sorting to a Web Worker. * Falls back to main-thread execution if Workers are unavailable. */ @Injectable({ providedIn: 'root' }) export class TreeBuilderService { private worker: Worker | null = null; private nextRequestId = 0; private pendingResolves = new Map void>(); constructor() { this.initWorker(); } private initWorker(): void { try { const blob = new Blob([ `(${workerFn.toString()})()` ], { type: 'application/javascript' }); this.worker = new Worker(URL.createObjectURL(blob)); this.worker.onmessage = (e: MessageEvent) => { const data = e.data; const requestId = data._requestId; const resolve = this.pendingResolves.get(requestId); if (resolve) { this.pendingResolves.delete(requestId); resolve(data); } }; this.worker.onerror = () => { this.worker = null; }; } catch { this.worker = null; } } /** * Build tree from keys — runs in Web Worker. */ keysToTreeControl(options: { keys: string[]; divider: string; keysInfo: any; savedExpandedNodes?: any[]; }): Promise<{ nodes: any[]; expandedNodes: any[] }> { if (this.worker) { const id = ++this.nextRequestId; return new Promise((resolve) => { this.pendingResolves.set(id, resolve); this.worker!.postMessage({ _requestId: id, action: 'buildTree', keys: options.keys, divider: options.divider, keysInfo: options.keysInfo, savedExpandedNodes: options.savedExpandedNodes ?? [], }); }); } return Promise.resolve(buildTreeSync(options)); } /** * Sort keys with natural compare — runs in Web Worker. */ sortKeys(keys: string[]): Promise { if (this.worker) { const id = ++this.nextRequestId; return new Promise((resolve) => { this.pendingResolves.set(id, (result: any) => resolve(result.keys)); this.worker!.postMessage({ _requestId: id, action: 'sortKeys', keys, }); }); } return Promise.resolve(keys.sort(naturalCompare())); } } // ============================================================================ // Worker function — serialized into a Blob URL // ============================================================================ function workerFn() { const naturalCompare = () => { return (a: string, b: string) => { const regex = /(\d+)|(\D+)/g; const ax: any[] = [], bx: any[] = []; a.replace(regex, (_: any, $1: any, $2: any) => { ax.push([$1 || Infinity, $2 || '']); return ''; }); b.replace(regex, (_: any, $1: any, $2: any) => { bx.push([$1 || Infinity, $2 || '']); return ''; }); while (ax.length && bx.length) { const an = ax.shift()!; const bn = bx.shift()!; const nn = (parseFloat(an[0]) - parseFloat(bn[0])) || an[1].localeCompare(bn[1]); if (nn) return nn; } return ax.length - bx.length; }; }; const buildTree = (keys: string[], divider: string, keysInfo: any, savedExpandedNodes: any[]) => { const mainNodes: any[] = []; const newExpandedNodes: any[] = []; const saved = savedExpandedNodes || []; const recursiveNodes = (splitKey: string[], level = 0, nodes: any[] = mainNodes) => { let foundNode: any = false; if (level + 1 < splitKey.length) { for (let i = 0; i < nodes.length; i++) { if (nodes[i].label === splitKey[level] && nodes[i].type === 'folder') { foundNode = nodes[i]; break; } } } if (!foundNode) { const node: any = { label: splitKey[level], key: splitKey.slice(0, level + 1).join(divider), children: [], childCount: 0, type: level + 1 === splitKey.length ? 'element' : 'folder', }; if (node.type === 'element' && keysInfo) { node.keysInfo = keysInfo[node.key]; } nodes.push(node); foundNode = node; for (let j = 0; j < saved.length; j++) { if (saved[j].key === foundNode.key) { newExpandedNodes.push(foundNode); } } } if (level + 1 < splitKey.length) { recursiveNodes(splitKey, level + 1, foundNode.children); } }; for (let i = 0; i < keys.length; i++) { recursiveNodes(divider === '' ? [keys[i]] : keys[i].split(divider)); } const recursiveKeyCount = (node: any) => { node.childCount = 0; for (let i = 0; i < node.children.length; i++) { if (node.children[i].type === 'element') { const info = node.children[i].keysInfo; if (info && info.type !== 'string' && info.type !== 'json' && info.length != null) { node.childCount += info.length; } else { node.childCount++; } } } for (let i = 0; i < node.children.length; i++) { recursiveKeyCount(node.children[i]); if (node.children[i].type === 'folder') node.childCount += node.children[i].childCount; } }; for (let i = 0; i < mainNodes.length; i++) { recursiveKeyCount(mainNodes[i]); } return { nodes: mainNodes, expandedNodes: newExpandedNodes }; }; (self as any).onmessage = function (e: MessageEvent) { const data = e.data; const _requestId = data._requestId; if (data.action === 'sortKeys') { const sorted = data.keys.sort(naturalCompare()); (self as any).postMessage({ _requestId, keys: sorted }); } else if (data.action === 'buildTree') { const result = buildTree(data.keys, data.divider, data.keysInfo, data.savedExpandedNodes); (self as any).postMessage({ _requestId: _requestId, nodes: result.nodes, expandedNodes: result.expandedNodes }); } }; } // ============================================================================ // Main-thread fallbacks // ============================================================================ function naturalCompare() { return (a: string, b: string) => { const regex = /(\d+)|(\D+)/g; const ax: any[] = [], bx: any[] = []; a.replace(regex, (_: any, $1: any, $2: any) => { ax.push([$1 || Infinity, $2 || '']); return ''; }); b.replace(regex, (_: any, $1: any, $2: any) => { bx.push([$1 || Infinity, $2 || '']); return ''; }); while (ax.length && bx.length) { const an = ax.shift()!; const bn = bx.shift()!; const nn = (parseFloat(an[0]) - parseFloat(bn[0])) || an[1].localeCompare(bn[1]); if (nn) return nn; } return ax.length - bx.length; }; } function buildTreeSync(options: { keys: string[]; divider: string; keysInfo: any; savedExpandedNodes?: any[]; }): { nodes: any[]; expandedNodes: any[] } { const { keys, divider, keysInfo } = options; const saved = options.savedExpandedNodes ?? []; const mainNodes: any[] = []; const newExpandedNodes: any[] = []; const recursiveNodes = (splitKey: string[], level = 0, nodes: any[] = mainNodes) => { let foundNode: any = false; if (level + 1 < splitKey.length) { for (const node of nodes) { if (node.label === splitKey[level] && node.type === 'folder') { foundNode = node; break; } } } if (!foundNode) { const node: any = { label: splitKey[level], key: splitKey.slice(0, level + 1).join(divider), children: [], childCount: 0, type: level + 1 === splitKey.length ? 'element' : 'folder', }; if (node.type === 'element' && keysInfo) { node.keysInfo = keysInfo[node.key]; } nodes.push(node); foundNode = node; for (const s of saved) { if (s.key === foundNode.key) newExpandedNodes.push(foundNode); } } if (level + 1 < splitKey.length) { recursiveNodes(splitKey, level + 1, foundNode.children); } }; for (const key of keys) { recursiveNodes(divider === '' ? [key] : key.split(divider)); } const recursiveKeyCount = (node: any) => { node.childCount = 0; for (const child of node.children) { if (child.type === 'element') { const info = child.keysInfo; if (info && info.type !== 'string' && info.type !== 'json' && info.length != null) { node.childCount += info.length; } else { node.childCount += 1; } } } for (const child of node.children) { recursiveKeyCount(child); if (child.type === 'folder') node.childCount += child.childCount; } }; for (const node of mainNodes) { recursiveKeyCount(node); } return { nodes: mainNodes, expandedNodes: newExpandedNodes }; } src/ng/themes/000077500000000000000000000000001517727315400135525ustar00rootroot00000000000000src/ng/themes/_theme-custom.scss000066400000000000000000000375311517727315400172310ustar00rootroot00000000000000// Per-theme custom CSS properties // // Replaces the dynamic CSS injected via jQuery in p3xr-theme.js: // $('head').append('') // // 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.scss000066400000000000000000000133361517727315400202270ustar00rootroot00000000000000// 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.scss000066400000000000000000001471601517727315400211700ustar00rootroot00000000000000// 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/000077500000000000000000000000001517727315400133425ustar00rootroot00000000000000src/overlay/overlay.scss000066400000000000000000000014611517727315400157220ustar00rootroot00000000000000#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/000077500000000000000000000000001517727315400131375ustar00rootroot00000000000000src/public/images/000077500000000000000000000000001517727315400144045ustar00rootroot00000000000000src/public/images/256x256.png000066400000000000000000000303271517727315400160600ustar00rootroot00000000000000PNG  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/000077500000000000000000000000001517727315400127575ustar00rootroot00000000000000src/react/App.tsx000066400000000000000000000071421517727315400142430ustar00rootroot00000000000000import { 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/000077500000000000000000000000001517727315400151445ustar00rootroot00000000000000src/react/components/ConfirmDialog.tsx000066400000000000000000000033641517727315400204270ustar00rootroot00000000000000import { 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.tsx000066400000000000000000000032521517727315400173270ustar00rootroot00000000000000import { 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.tsx000066400000000000000000000076411517727315400203720ustar00rootroot00000000000000import { 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.tsx000066400000000000000000000045641517727315400177450ustar00rootroot00000000000000import { 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.tsx000066400000000000000000000106471517727315400176700ustar00rootroot00000000000000import { 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.tsx000066400000000000000000000042271517727315400203120ustar00rootroot00000000000000import { 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.tsx000066400000000000000000000022631517727315400170010ustar00rootroot00000000000000import { 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/000077500000000000000000000000001517727315400144015ustar00rootroot00000000000000src/react/dialogs/AclUserDialog.tsx000066400000000000000000000250321517727315400176210ustar00rootroot00000000000000import { 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/AiSettingsDialog.tsx000066400000000000000000000074641517727315400203460ustar00rootroot00000000000000import { 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.tsx000066400000000000000000000110061517727315400215560ustar00rootroot00000000000000import { 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.tsx000066400000000000000000000135111517727315400211570ustar00rootroot00000000000000import { 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.tsx000066400000000000000000000446301517727315400203670ustar00rootroot00000000000000import { 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.tsx000066400000000000000000000162361517727315400171410ustar00rootroot00000000000000import { 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.tsx000066400000000000000000000231771517727315400203530ustar00rootroot00000000000000import { 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.tsx000066400000000000000000000175741517727315400200430ustar00rootroot00000000000000import { 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.tsx000066400000000000000000000124541517727315400202120ustar00rootroot00000000000000import { 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.tsx000066400000000000000000000636631517727315400204560ustar00rootroot00000000000000import { 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.tsx000066400000000000000000000301151517727315400207010ustar00rootroot00000000000000import { 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.tsx000066400000000000000000000064531517727315400170340ustar00rootroot00000000000000import { 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.html000066400000000000000000000042661517727315400147640ustar00rootroot00000000000000 P3X Redis UI
src/react/layout/000077500000000000000000000000001517727315400142745ustar00rootroot00000000000000src/react/layout/Layout.tsx000066400000000000000000000734541517727315400163260ustar00rootroot00000000000000import { 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, } 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' 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 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]) // --- 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 useEffect(() => { const unsub = onSocketEvent('redis-disconnected', () => { navigateTo('settings') }) return unsub }, []) // 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) useEffect(() => { const path = location.pathname.toLowerCase().startsWith('/database/key/') ? '/database/key' : location.pathname trackPage(path) }, [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 ? : } {/* ===== 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()} /> )} {/* Donate */} } label={strings?.title?.donate} onClick={() => openLink('donate')} /> {/* 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.tsx000066400000000000000000000051611517727315400144460ustar00rootroot00000000000000import '@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' // 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/000077500000000000000000000000001517727315400140565ustar00rootroot00000000000000src/react/pages/console/000077500000000000000000000000001517727315400155205ustar00rootroot00000000000000src/react/pages/console/ConsoleComponent.tsx000066400000000000000000000711461517727315400215560ustar00rootroot00000000000000import { useState, useEffect, useRef, useCallback, useMemo } from 'react' import { Box, Toolbar, Tooltip, Popper, Paper, ClickAwayListener } from '@mui/material' import { CheckBox, CheckBoxOutlineBlank, Terminal, Backspace, MenuBook } from '@mui/icons-material' 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 } export default function ConsoleComponent({ embedded = false, collapsed = false }: 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 [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) // --- 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(() => { setTimeout(() => { const s = scrollerRef.current if (s) s.scrollTop = s.scrollHeight }, 0) }, []) 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}

`) 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}
`) 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 } 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 response = await request({ action: 'ai/redis-query', payload: { prompt, context: ctx } }) const command = response.command || '' const explanation = response.explanation || '' outputAppend(htmlEncode(originalInput)) updateHistory(originalInput) if (command) { let line = `AI → ${htmlEncode(command)}` if (explanation) line += `
${htmlEncode(explanation)}` outputAppend(line + '
') setSearchText(command) setCurrentHint('') aiCommandPendingRef.current = true setTimeout(() => autoResize(), 0) } return true } catch (e: any) { const msg = e.message || String(e) 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 { setAiLoading(false) forceScrollToBottom() inputRef.current?.focus() } }, [muiTheme, strings, outputAppend, forceScrollToBottom, toast, 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 — strongBg, 48px */} {strings?.label?.console} {aiEnabled && ( {aiAutoDetect ? : } Auto AI )} } onClick={() => window.open('https://redis.io/docs/latest/commands/', '_blank')} /> } onClick={clearConsole} /> {/* 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} )}