.babelrc000066400000000000000000000003501517660044600124600ustar00rootroot00000000000000{ "presets": [ [ "@babel/preset-env", { "targets": { "node": "current" } } ] ], "plugins": ["angularjs-annotate"] } .github/000077500000000000000000000000001517660044600124275ustar00rootroot00000000000000.github/workflows/000077500000000000000000000000001517660044600144645ustar00rootroot00000000000000.github/workflows/build.yml000066400000000000000000000011321517660044600163030ustar00rootroot00000000000000name: 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@v4 - name: Use Node.js ${{ matrix.node-version }} uses: actions/setup-node@v4 with: node-version: ${{ matrix.node-version }} - run: npm i -g grunt-cli - run: yarn install - run: grunt .gitignore000066400000000000000000000004341517660044600130600ustar00rootroot00000000000000/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 /dist /dist-react .DS_Store /test-results /tests/screenshots.npmignore000066400000000000000000000004741517660044600130730ustar00rootroot00000000000000/.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* /scriptsGruntfile.js000066400000000000000000000031221517660044600133620ustar00rootroot00000000000000const 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 (webpack) and React (vite) in parallel await Promise.all([ // Angular → dist/ gruntUtil.spawn({ grunt: grunt, gruntThis: this, }, { cmd: `${cwd}/node_modules/.bin/webpack${gruntUtil.commandAddon}`, args: [ '--config', './src/builder/webpack.config.js', '--mode=production' ] }), // 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', ] }), ]) done() } catch(e) { done(e) } }) } LICENSE000066400000000000000000000020131517660044600120700ustar00rootroot00000000000000MIT 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.md000066400000000000000000000242711517660044600123540ustar00rootroot00000000000000# 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 dual frontend — Angular + React/MUI with 54 languages, 7 themes, Socket.IO, desktop notifications, and full feature parity v2026.4.410 🌌 **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.6 ``` # 📝 Description [//]: #@corifeus-header:end The `p3x-redis-ui-material` package is the **dual frontend** for [p3x-redis-ui](https://github.com/patrikx3/redis-ui). It provides two 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 ### Shared Across Both - **54 languages** with auto browser/system locale detection and "Auto (system)" option - **7 themes** — Light, Enterprise, Redis (light) + Dark, Dark Neu, Darko Bluo, Matrix (dark) — with auto system preference detection - **Same Socket.IO protocol** — identical backend API - **Same translation system** — single source of truth in `src/strings/` - **CodeMirror** JSON editor with GitHub dark/light themes - **uPlot** lightweight canvas charts for monitoring dashboards - **Web Worker tree building** — key sorting off the main thread - **Desktop notifications** — Electron native + Web Notification API - **Playwright E2E tests** — run against both frontends in parallel - **Live switching** — toggle between Angular and React in Settings ### Project Structure ``` src/ ├── ng/ # Angular frontend │ ├── pages/ # Lazy-loaded page components │ ├── dialogs/ # Modal dialogs │ ├── components/ # Reusable UI components │ ├── services/ # Angular services (signals-based state) │ └── layout/ # App shell, header, footer ├── react/ # React frontend │ ├── pages/ # Page components (console, database, monitoring, search, settings, info) │ ├── dialogs/ # Modal dialogs │ ├── components/ # Reusable UI components │ ├── stores/ # Zustand stores (state management) │ ├── layout/ # App shell, header, footer │ ├── themes/ # MUI theme definitions │ ├── vite.config.ts # Vite configuration │ └── index.html # React entry HTML ├── core/ # Shared utilities (detect-language, translation-loader) ├── strings/ # 54 language translation files │ ├── en/strings.js # English (primary) │ ├── de/strings.js # German │ └── .../strings.js # 52 more languages ├── scss/ # Shared theme CSS variables (7 themes) ├── public/ # Static assets (images, icons) ├── builder/ # Webpack config for Angular │ └── webpack.config.js └── tests/ # Playwright E2E tests ├── redis-ui.spec.js # Shared test spec (runs against both GUIs) └── run-e2e.sh # Test runner script ``` ## NPM Scripts | Script | Description | |--------|-------------| | `yarn run dev` | Start Angular dev server (Webpack, port 8080) | | `yarn run dev-react` | Start React dev server (Vite, port 8082) | | `yarn run build` | Production build Angular → `dist/` | | `yarn run build-react` | Production build React → `dist-react/` | | `yarn run stats` | Angular bundle analysis with `webpack-bundle-analyzer` | | `yarn run test:e2e` | Run Playwright E2E tests (both GUIs) | | `yarn run test:e2e:gui` | Run E2E tests with Playwright UI | ## Development For file names do not use camelCase, but use kebab-case. Folder should be named as kebab-case as well. As you can see, all code filenames are using it like that, please do not change that. Please apply the `.editorconfig` settings in your IDE. ### Prerequisites Requires a running `p3x-redis-ui-server` backend (default port 7843). Override with `P3XR_API_PORT`: ```bash P3XR_API_PORT=7844 yarn run dev-react ``` ### Angular development ```bash yarn install yarn run dev ``` - Dev server: http://localhost:8080/ng/ - Webpack proxies Socket.IO to backend on port 7843 - Hot module reload enabled - CSP headers configured for development ### React development ```bash yarn install yarn run dev-react ``` - Dev server: http://localhost:8082/react/ - Vite proxies Socket.IO to backend on port 7843 - Instant HMR via Vite's native ESM - CJS translation files auto-transformed to ESM via custom plugin ### Running both simultaneously ```bash # Terminal 1: Angular yarn run dev # Terminal 2: React yarn run dev-react # Terminal 3: Backend cd ../redis-ui-server && yarn run dev ``` ## Key Dependencies All dependencies track the latest LTS versions and are regularly upgraded. ### Angular (devDependencies — bundled at build time) | Package | Purpose | |---------|---------| | `@angular/core` | Framework | | `@angular/material` | UI component library | | `@angular/cdk` | Virtual scrolling, drag-drop | | `@ngtools/webpack` | AOT compilation | | `webpack` | Bundler | | `typescript` | Type system | ### React (dependencies — shipped in npm package) | Package | Purpose | |---------|---------| | `react` | Framework | | `@mui/material` | UI component library | | `zustand` | State management | | `@tanstack/react-virtual` | Virtual scrolling | | `react-router-dom` | Client-side routing | | `vite` | Bundler (devDependency) | ### Shared | Package | Purpose | |---------|---------| | `socket.io-client` | Real-time communication with backend | | `codemirror` + `@codemirror/*` | JSON editor | | `uplot` | Lightweight canvas charts (monitoring) | | `jspdf` | PDF export | | `jszip` | ZIP export (memory analysis) | | `@dnd-kit/*` | Drag-and-drop (connection groups) | | `lodash` | Utility functions (merge for i18n) | ## E2E Testing Playwright tests run against both frontends in parallel using a shared test spec: ```bash # Run all tests (Angular + React) yarn run test:e2e # Run with Playwright UI yarn run test:e2e:gui ``` Tests cover: connect, disconnect, key operations, search, settings, monitoring, and GUI switching. [//]: #@corifeus-footer --- ## 🚀 Quick and Affordable Web Development Services If you want to quickly and affordably develop your next digital project, visit [corifeus.eu](https://corifeus.eu) for expert solutions tailored to your needs. --- ## 🌐 Powerful Online Networking Tool Discover the powerful and free online networking tool at [network.corifeus.com](https://network.corifeus.com). **🆓 Free** Designed for professionals and enthusiasts, this tool provides essential features for network analysis, troubleshooting, and management. Additionally, it offers tools for: - 📡 Monitoring TCP, HTTP, and Ping to ensure optimal network performance and reliability. - 📊 Status page management to track uptime, performance, and incidents in real time with customizable dashboards. All these features are completely free to use. --- ## ❤️ Support Our Open-Source Project If you appreciate our work, consider ⭐ starring this repository or 💰 making a donation to support server maintenance and ongoing development. Your support means the world to us—thank you! --- ### 🌍 About My Domains All my domains, including [patrikx3.com](https://patrikx3.com), [corifeus.eu](https://corifeus.eu), and [corifeus.com](https://corifeus.com), are developed in my spare time. While you may encounter minor errors, the sites are generally stable and fully functional. --- ### 📈 Versioning Policy **Version Structure:** We follow a **Major.Minor.Patch** versioning scheme: - **Major:** 📅 Corresponds to the current year. - **Minor:** 🌓 Set as 4 for releases from January to June, and 10 for July to December. - **Patch:** 🔧 Incremental, updated with each build. **🚨 Important Changes:** Any breaking changes are prominently noted in the readme to keep you informed. --- [**P3X-REDIS-UI-MATERIAL**](https://corifeus.com/redis-ui-material) Build v2026.4.410 [![NPM](https://img.shields.io/npm/v/p3x-redis-ui-material.svg)](https://www.npmjs.com/package/p3x-redis-ui-material) [![Donate for PatrikX3 / P3X](https://img.shields.io/badge/Donate-PatrikX3-003087.svg)](https://www.paypal.com/cgi-bin/webscr?cmd=_s-xclick&hosted_button_id=QZVM4V6HVZJW6) [![Contact Corifeus / P3X](https://img.shields.io/badge/Contact-P3X-ff9900.svg)](https://www.patrikx3.com/en/front/contact) [![Like Corifeus @ Facebook](https://img.shields.io/badge/LIKE-Corifeus-3b5998.svg)](https://www.facebook.com/corifeus.software) [//]: #@corifeus-footer:end artifacts/000077500000000000000000000000001517660044600130475ustar00rootroot00000000000000artifacts/reduce-bundle-size.txt000066400000000000000000000022451517660044600173010ustar00rootroot000000000000002020 October 18 08:00 AM -r--r----- 1 www-data www-data 2.3K Oct 17 20:38 2eb6240af39282952504b5e016895183.js -r--r----- 1 www-data www-data 2.6K Oct 17 20:38 59bde89bce5c1126ba5ee99c55ec48c8.js -r--r----- 1 www-data www-data 186K Oct 17 20:38 main.3b4993afb7ef0c773dd2.js -r--r----- 1 www-data www-data 12K Oct 17 20:38 main.82603f4092c1cd8f8cf9.css -r--r----- 1 www-data www-data 2.2M Oct 17 20:38 vendor.2bf99f4796897bbcdde3.js -r--r----- 1 www-data www-data 500K Oct 17 20:38 vendor.8cec4ee3d875369b3db2.css 2955,7 2020 October 18 11:00 AM -r-------- 1 www-data www-data 2.3K Oct 18 10:43 2eb6240af39282952504b5e016895183.js -r-------- 1 www-data www-data 2.6K Oct 18 10:43 59bde89bce5c1126ba5ee99c55ec48c8.js -r-------- 1 www-data www-data 23K Oct 18 10:43 f7186078e00d958aa2b316483dfc7e1c.js -r-------- 1 www-data www-data 1.2M Oct 18 10:43 362.chunk.js -r-------- 1 www-data www-data 189K Oct 18 10:43 main.5bb26a2fed2bb6572b27.js -r-------- 1 www-data www-data 12K Oct 18 10:43 main.f0afde82d9f3f4ba2c8d.css -r-------- 1 www-data www-data 925K Oct 18 10:43 vendor.423e956f43a2d1406ae5.js -r-------- 1 www-data www-data 500K Oct 18 10:43 vendor.8adbf377a94fd3e73b2c.css 2882,7 package.json000066400000000000000000000120151517660044600133540ustar00rootroot00000000000000{ "name": "p3x-redis-ui-material", "version": "2026.4.410", "description": "💿 P3X Redis UI dual frontend — Angular + React/MUI with 54 languages, 7 themes, Socket.IO, desktop notifications, and full feature parity", "corifeus": { "icon": "fas fa-database", "code": "Fireball", "opencollective": false, "build": true, "nodejs": "v24.14.1", "reponame": "redis-ui-material", "publish": true, "prefix": "p3x-", "type": "p3x", "angular": "21.2.6" }, "main": "src/index.js", "scripts": { "test": "grunt", "dev": "webpack serve --config ./src/builder/webpack.config.js", "dev-webpack": "webpack serve --config ./src/builder/webpack.config.js", "build": "grunt build && webpack --config ./src/builder/webpack.config.js --mode=production", "stats": "grunt build && WEBPACK_STATS=1 webpack --mode=production --config ./src/builder/webpack.config.js && webpack-bundle-analyzer ./dist/stats.json", "dev-react": "vite --config ./src/react/vite.config.ts", "build-react": "vite build --config ./src/react/vite.config.ts", "test:e2e": "bash tests/run-e2e.sh", "test:e2e:gui": "bash tests/run-e2e.sh --gui" }, "repository": { "type": "git", "url": "https://github.com/patrikx3/redis-ui-material.git" }, "keywords": [ "redis", "ui", "gui", "web", "electron", "desktop", "server", "angularjs", "javascript", "material", "dark", "light" ], "author": "Patrik Laszlo ", "license": "MIT", "devDependencies": { "@angular-devkit/build-angular": "^21.2.6", "@angular/animations": "^21", "@angular/cdk": "^21.2.5", "@angular/common": "^21", "@angular/compiler": "^21", "@angular/compiler-cli": "^21.2.7", "@angular/core": "^21", "@angular/forms": "^21", "@angular/material": "^21.2.5", "@angular/platform-browser": "^21", "@angular/platform-browser-dynamic": "^21", "@angular/router": "^21.2.7", "@babel/core": "^7.29.0", "@babel/preset-env": "^7.29.2", "@fontsource/roboto": "^5.2.10", "@fontsource/roboto-mono": "^5.2.8", "@fortawesome/fontawesome-free": "^7.2.0", "@ngtools/webpack": "^21.2.6", "@playwright/test": "^1.59.1", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^6.0.1", "babel-loader": "^10.1.1", "clean-webpack-plugin": "^4.0.0", "concurrently": "^9.2.1", "copy-webpack-plugin": "^14.0.0", "corifeus-builder": "^2025.4.135", "corifeus-utils": "^2025.4.123", "css-loader": "^7.1.4", "css-minimizer-webpack-plugin": "^8.0.0", "html-loader": "^5.1.0", "html-webpack-plugin": "^5.6.6", "humanize-duration": "^3.33.2", "js-htmlencode": "^0.3.0", "lodash": "^4.18.1", "material-design-icons-iconfont": "^6.7.0", "mini-css-extract-plugin": "^2.10.2", "mobile-detect": "^1.4.5", "playwright": "^1.59.1", "pretty-bytes": "^7.1.0", "raw-loader": "^4.0.2", "rxjs": "^7.8.2", "sass": "^1.99.0", "sass-loader": "^16.0.7", "socket.io-client": "^4.8.3", "source-map-loader": "^5.0.0", "style-loader": "^4.0.0", "terser-webpack-plugin": "^5.4.0", "timestring": "^7.0.0", "ts-loader": "^9.5.7", "typescript": "^6.0.2", "vite": "^8.0.5", "webpack": "^5.105.4", "webpack-bundle-analyzer": "^5.3.0", "webpack-cli": "^7.0.2", "webpack-dev-server": "^5.2.3", "webpack-remove-debug": "^0.1.0", "zone.js": "^0.16.1" }, "engines": { "node": ">=12.13.0" }, "homepage": "https://corifeus.com/redis-ui-material", "packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e", "dependencies": { "@codemirror/lang-json": "^6.0.2", "@codemirror/state": "^6.6.0", "@codemirror/theme-one-dark": "^6.1.3", "@codemirror/view": "^6.41.0", "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", "@emotion/react": "^11.14.0", "@emotion/styled": "^11.14.1", "@msgpack/msgpack": "^3.1.3", "@mui/icons-material": "^7.3.9", "@mui/material": "^7.3.9", "@tanstack/react-virtual": "^3.13.23", "@uiw/codemirror-theme-github": "^4.25.9", "codemirror": "^6.0.2", "jspdf": "^4.2.1", "jszip": "^3.10.1", "react": "^19.2.4", "react-dom": "^19.2.4", "react-router-dom": "^7.14.0", "uplot": "^1.6.32", "zustand": "^5.0.12" }, "resolutions": { "@codemirror/view": "^6.41.0" } }playwright.config.mjs000066400000000000000000000015741517660044600152470ustar00rootroot00000000000000import { defineConfig } from '@playwright/test'; export default defineConfig({ testDir: './tests', testMatch: '**/*.spec.js', timeout: 30000, retries: 0, workers: 1, use: { baseURL: process.env.P3XR_URL || `http://localhost:${process.env.P3XR_TEST_FRONTEND_PORT || '28080'}`, headless: process.env.P3XR_HEADLESS !== 'false', screenshot: 'only-on-failure', trace: 'on-first-retry', }, projects: [ { name: 'chromium', use: { browserName: 'chromium' } }, ], reporter: [['list'], ['html', { open: 'never', outputFolder: 'test-results' }]], // Tests expect both redis-ui-server (port 7843) and webpack-dev-server (port 8080) running. // Start them manually before running tests: // Terminal 1: cd ../redis-ui-server && node src/lib/boot.mjs // Terminal 2: cd ../redis-ui-material && yarn dev }); playwright.config.ts000066400000000000000000000033511517660044600150770ustar00rootroot00000000000000import { defineConfig } from '@playwright/test'; import { existsSync, readFileSync } from 'fs'; import { resolve } from 'path'; // Auto-load secure/playwright.env if it exists (not committed to git) const envFile = resolve(__dirname, 'secure/playwright.env'); if (existsSync(envFile)) { for (const line of readFileSync(envFile, 'utf8').split('\n')) { const m = line.match(/^([^#=]+)=(.*)$/); if (m) process.env[m[1].trim()] ??= m[2].trim(); } } const productionSettingsUrl = process.env.PLAYWRIGHT_PROD_SETTINGS_URL || 'https://redis.patrikx3.com/ng/settings'; const productionHttpUsername = process.env.PLAYWRIGHT_PROD_HTTP_USERNAME; const productionHttpPassword = process.env.PLAYWRIGHT_PROD_HTTP_PASSWORD; const productionHttpCredentials = productionHttpUsername && productionHttpPassword ? { username: productionHttpUsername, password: productionHttpPassword, origin: new URL(productionSettingsUrl).origin, } : undefined; const baseHost = process.env.PLAYWRIGHT_BASE_HOST || 'http://localhost:8080'; export default defineConfig({ testDir: './tests', timeout: 30000, use: { headless: true, httpCredentials: productionHttpCredentials, viewport: { width: 1280, height: 900 }, screenshot: 'only-on-failure', }, projects: [ { name: 'angular', use: { browserName: 'chromium', baseURL: process.env.PLAYWRIGHT_BASE_URL || `${baseHost}/ng/`, }, }, { name: 'react', use: { browserName: 'chromium', baseURL: process.env.PLAYWRIGHT_BASE_URL_REACT || `${baseHost}/react/`, }, }, ], }); scripts/000077500000000000000000000000001517660044600125565ustar00rootroot00000000000000scripts/inject-ai-codes.js000066400000000000000000001072011517660044600160530ustar00rootroot00000000000000#!/usr/bin/env node 'use strict' const fs = require('fs') const path = require('path') const STRINGS_DIR = path.resolve(__dirname, '..', 'src', 'strings') const TRANSLATIONS = { ar: { AI_DISABLED: 'الذكاء الاصطناعي معطل. قم بتفعيله في إعدادات AI.', AI_PROMPT_REQUIRED: 'مطلوب إدخال نص للذكاء الاصطناعي.', GROQ_API_KEY_READONLY: 'مفتاح Groq API للقراءة فقط ولا يمكن تعديله.', blocked_api_access: 'خطة Groq API الخاصة بك لا تسمح بالوصول إلى هذا النموذج. يرجى ترقية خطة Groq أو استخدام وكيل network.corifeus.com.', rate_limit: 'تم الوصول إلى حد معدل AI. حاول مرة أخرى لاحقًا أو استخدم مفتاح Groq API الخاص بك في الإعدادات.', }, az: { AI_DISABLED: 'AI deaktivdir. AI Parametrlərindən aktivləşdirin.', AI_PROMPT_REQUIRED: 'AI sorğusu tələb olunur.', GROQ_API_KEY_READONLY: 'Groq API açarı yalnız oxunur və dəyişdirilə bilməz.', blocked_api_access: 'Groq API planınız bu modelə girişə icazə vermir. Groq planınızı yüksəldin və ya network.corifeus.com proksisindən istifadə edin.', rate_limit: 'AI limit həddinə çatıldı. Daha sonra yenidən cəhd edin və ya Parametrlərdə öz Groq API açarınızı istifadə edin.', }, be: { AI_DISABLED: 'AI адключаны. Уключыце яго ў наладах AI.', AI_PROMPT_REQUIRED: 'Патрабуецца запыт AI.', GROQ_API_KEY_READONLY: 'Ключ Groq API даступны толькі для чытання і не можа быць зменены.', blocked_api_access: 'Ваш план Groq API не дазваляе доступ да гэтай мадэлі. Калі ласка, абнавіце план Groq або выкарыстоўвайце проксі network.corifeus.com.', rate_limit: 'Дасягнуты ліміт AI. Паспрабуйце пазней або выкарыстоўвайце свой уласны ключ Groq API ў наладах.', }, bg: { AI_DISABLED: 'AI е деактивиран. Активирайте го в AI настройките.', AI_PROMPT_REQUIRED: 'Необходим е AI запрос.', GROQ_API_KEY_READONLY: 'Ключът на Groq API е само за четене и не може да бъде променен.', blocked_api_access: 'Вашият план на Groq API не позволява достъп до този модел. Моля, надградете плана си или използвайте прокси network.corifeus.com.', rate_limit: 'Достигнат е лимитът на AI. Опитайте по-късно или използвайте собствен Groq API ключ в настройките.', }, bn: { AI_DISABLED: 'AI নিষ্ক্রিয়। AI সেটিংসে এটি সক্রিয় করুন।', AI_PROMPT_REQUIRED: 'AI প্রম্পট প্রয়োজন।', GROQ_API_KEY_READONLY: 'Groq API কী শুধুমাত্র পঠনযোগ্য এবং পরিবর্তন করা যায় না।', blocked_api_access: 'আপনার Groq API পরিকল্পনা এই মডেলে অ্যাক্সেসের অনুমতি দেয় না। অনুগ্রহ করে আপনার Groq পরিকল্পনা আপগ্রেড করুন বা network.corifeus.com প্রক্সি ব্যবহার করুন।', rate_limit: 'AI হার সীমায় পৌঁছেছে। পরে আবার চেষ্টা করুন বা সেটিংসে আপনার নিজের Groq API কী ব্যবহার করুন।', }, bs: { AI_DISABLED: 'AI je onemogućen. Omogućite ga u AI postavkama.', AI_PROMPT_REQUIRED: 'AI upit je obavezan.', GROQ_API_KEY_READONLY: 'Groq API ključ je samo za čitanje i ne može se mijenjati.', blocked_api_access: 'Vaš Groq API plan ne dozvoljava pristup ovom modelu. Nadogradite Groq plan ili koristite network.corifeus.com proxy.', rate_limit: 'Dostignut je AI limit. Pokušajte ponovo kasnije ili koristite vlastiti Groq API ključ u postavkama.', }, cs: { AI_DISABLED: 'AI je deaktivováno. Povolte ho v nastavení AI.', AI_PROMPT_REQUIRED: 'Je vyžadován AI dotaz.', GROQ_API_KEY_READONLY: 'Klíč Groq API je pouze pro čtení a nelze ho upravit.', blocked_api_access: 'Váš plán Groq API neumožňuje přístup k tomuto modelu. Upgradujte svůj plán Groq nebo použijte proxy network.corifeus.com.', rate_limit: 'Byl dosažen limit AI. Zkuste to později nebo použijte vlastní klíč Groq API v nastavení.', }, da: { AI_DISABLED: 'AI er deaktiveret. Aktiver det i AI-indstillinger.', AI_PROMPT_REQUIRED: 'AI-forespørgsel er påkrævet.', GROQ_API_KEY_READONLY: 'Groq API-nøglen er skrivebeskyttet og kan ikke ændres.', blocked_api_access: 'Din Groq API-plan tillader ikke adgang til denne model. Opgrader din Groq-plan eller brug network.corifeus.com proxy.', rate_limit: 'AI-hastighedsgrænse nået. Prøv igen senere eller brug din egen Groq API-nøgle i indstillingerne.', }, de: { AI_DISABLED: 'AI ist deaktiviert. Aktivieren Sie es in den AI-Einstellungen.', AI_PROMPT_REQUIRED: 'AI-Eingabe ist erforderlich.', GROQ_API_KEY_READONLY: 'Der Groq API-Schlüssel ist schreibgeschützt und kann nicht geändert werden.', blocked_api_access: 'Ihr Groq API-Plan erlaubt keinen Zugriff auf dieses Modell. Bitte upgraden Sie Ihren Groq-Plan oder verwenden Sie den network.corifeus.com Proxy.', rate_limit: 'AI-Ratenlimit erreicht. Versuchen Sie es später erneut oder verwenden Sie Ihren eigenen Groq API-Schlüssel in den Einstellungen.', }, el: { AI_DISABLED: 'Το AI είναι απενεργοποιημένο. Ενεργοποιήστε το στις ρυθμίσεις AI.', AI_PROMPT_REQUIRED: 'Απαιτείται ερώτημα AI.', GROQ_API_KEY_READONLY: 'Το κλειδί Groq API είναι μόνο για ανάγνωση και δεν μπορεί να τροποποιηθεί.', blocked_api_access: 'Το πλάνο Groq API σας δεν επιτρέπει πρόσβαση σε αυτό το μοντέλο. Αναβαθμίστε το πλάνο Groq ή χρησιμοποιήστε τον proxy network.corifeus.com.', rate_limit: 'Συμπληρώθηκε το όριο AI. Δοκιμάστε αργότερα ή χρησιμοποιήστε το δικό σας κλειδί Groq API στις ρυθμίσεις.', }, es: { AI_DISABLED: 'AI está desactivado. Actívelo en la configuración de AI.', AI_PROMPT_REQUIRED: 'Se requiere una consulta de AI.', GROQ_API_KEY_READONLY: 'La clave de Groq API es de solo lectura y no se puede modificar.', blocked_api_access: 'Su plan de Groq API no permite el acceso a este modelo. Actualice su plan de Groq o use el proxy network.corifeus.com.', rate_limit: 'Se alcanzó el límite de AI. Inténtelo más tarde o use su propia clave de Groq API en la configuración.', }, et: { AI_DISABLED: 'AI on keelatud. Lubage see AI seadetes.', AI_PROMPT_REQUIRED: 'AI päring on nõutav.', GROQ_API_KEY_READONLY: 'Groq API võti on kirjutuskaitstud ja seda ei saa muuta.', blocked_api_access: 'Teie Groq API plaan ei luba juurdepääsu sellele mudelile. Uuendage oma Groq plaani või kasutage network.corifeus.com puhverserverit.', rate_limit: 'AI piirang saavutatud. Proovige hiljem uuesti või kasutage seadetes oma Groq API võtit.', }, fi: { AI_DISABLED: 'AI on pois käytöstä. Ota se käyttöön AI-asetuksissa.', AI_PROMPT_REQUIRED: 'AI-kysely vaaditaan.', GROQ_API_KEY_READONLY: 'Groq API-avain on vain luku -tilassa eikä sitä voi muokata.', blocked_api_access: 'Groq API-suunnitelmasi ei salli pääsyä tähän malliin. Päivitä Groq-suunnitelmasi tai käytä network.corifeus.com-välityspalvelinta.', rate_limit: 'AI-nopeusraja saavutettu. Yritä myöhemmin uudelleen tai käytä omaa Groq API-avainta asetuksissa.', }, fil: { AI_DISABLED: 'Ang AI ay naka-disable. I-enable ito sa AI Settings.', AI_PROMPT_REQUIRED: 'Kinakailangan ang AI prompt.', GROQ_API_KEY_READONLY: 'Ang Groq API key ay read-only at hindi maaaring baguhin.', blocked_api_access: 'Hindi pinapayagan ng iyong Groq API plan ang access sa modelong ito. Mag-upgrade ng Groq plan o gamitin ang network.corifeus.com proxy.', rate_limit: 'Naabot na ang AI rate limit. Subukan muli mamaya o gamitin ang sariling Groq API key sa Settings.', }, fr: { AI_DISABLED: "L'IA est désactivée. Activez-la dans les paramètres IA.", AI_PROMPT_REQUIRED: "Une requête IA est requise.", GROQ_API_KEY_READONLY: "La clé Groq API est en lecture seule et ne peut pas être modifiée.", blocked_api_access: "Votre plan Groq API ne permet pas l'accès à ce modèle. Veuillez mettre à niveau votre plan Groq ou utiliser le proxy network.corifeus.com.", rate_limit: "Limite de débit IA atteinte. Réessayez plus tard ou utilisez votre propre clé Groq API dans les paramètres.", }, he: { AI_DISABLED: 'AI מושבת. הפעל אותו בהגדרות AI.', AI_PROMPT_REQUIRED: 'נדרשת שאילתת AI.', GROQ_API_KEY_READONLY: 'מפתח Groq API הוא לקריאה בלבד ולא ניתן לשנותו.', blocked_api_access: 'תוכנית Groq API שלך אינה מאפשרת גישה למודל זה. שדרג את תוכנית Groq או השתמש בפרוקסי network.corifeus.com.', rate_limit: 'הגעת למגבלת קצב AI. נסה שוב מאוחר יותר או השתמש במפתח Groq API שלך בהגדרות.', }, hr: { AI_DISABLED: 'AI je onemogućen. Omogućite ga u AI postavkama.', AI_PROMPT_REQUIRED: 'AI upit je obavezan.', GROQ_API_KEY_READONLY: 'Groq API ključ je samo za čitanje i ne može se mijenjati.', blocked_api_access: 'Vaš Groq API plan ne dopušta pristup ovom modelu. Nadogradite Groq plan ili koristite network.corifeus.com proxy.', rate_limit: 'Dosegnut je AI limit. Pokušajte ponovno kasnije ili koristite vlastiti Groq API ključ u postavkama.', }, hu: { AI_DISABLED: 'Az AI le van tiltva. Engedélyezze az AI beállításokban.', AI_PROMPT_REQUIRED: 'AI lekérdezés szükséges.', GROQ_API_KEY_READONLY: 'A Groq API kulcs csak olvasható és nem módosítható.', blocked_api_access: 'A Groq API csomagja nem engedélyezi a hozzáférést ehhez a modellhez. Frissítse a Groq csomagját vagy használja a network.corifeus.com proxyt.', rate_limit: 'AI sebességkorlát elérve. Próbálja újra később vagy használja saját Groq API kulcsát a beállításokban.', }, hy: { AI_DISABLED: 'AI-ն անջատված է: Միացրեք այն AI կարգավորումներում:', AI_PROMPT_REQUIRED: 'AI հարցումը պարտադիր է:', GROQ_API_KEY_READONLY: 'Groq API բանալին միայն կարդալու համար է և չի կարող փոփոխվել:', blocked_api_access: 'Ձեր Groq API պլանը թույլ չի տալիս մուտք գործել այս մdelays: Խնդրում ենք թարմացնել Groq պլdelays կամ օգտagorespace network.corifeus.com proxy-ն:', rate_limit: 'AI արագdelays սdelays հasiondelays: Փdelays ավdelays delays delays delays Groq API delays delays delays:', }, id: { AI_DISABLED: 'AI dinonaktifkan. Aktifkan di Pengaturan AI.', AI_PROMPT_REQUIRED: 'Permintaan AI diperlukan.', GROQ_API_KEY_READONLY: 'Kunci Groq API hanya-baca dan tidak dapat diubah.', blocked_api_access: 'Paket Groq API Anda tidak mengizinkan akses ke model ini. Tingkatkan paket Groq Anda atau gunakan proxy network.corifeus.com.', rate_limit: 'Batas kecepatan AI tercapai. Coba lagi nanti atau gunakan kunci Groq API Anda sendiri di Pengaturan.', }, it: { AI_DISABLED: "L'AI è disabilitata. Abilitala nelle impostazioni AI.", AI_PROMPT_REQUIRED: 'È richiesta una richiesta AI.', GROQ_API_KEY_READONLY: 'La chiave Groq API è di sola lettura e non può essere modificata.', blocked_api_access: 'Il tuo piano Groq API non consente l\'accesso a questo modello. Aggiorna il tuo piano Groq o usa il proxy network.corifeus.com.', rate_limit: 'Limite di velocità AI raggiunto. Riprova più tardi o usa la tua chiave Groq API nelle impostazioni.', }, ja: { AI_DISABLED: 'AIが無効です。AI設定で有効にしてください。', AI_PROMPT_REQUIRED: 'AIプロンプトが必要です。', GROQ_API_KEY_READONLY: 'Groq APIキーは読み取り専用で変更できません。', blocked_api_access: 'お使いのGroq APIプランではこのモデルにアクセスできません。Groqプランをアップグレードするか、network.corifeus.comプロキシを使用してください。', rate_limit: 'AIレート制限に達しました。後でもう一度お試しいただくか、設定で独自のGroq APIキーを使用してください。', }, ka: { AI_DISABLED: 'AI გამორთულია. ჩართეთ AI პარამეტრებში.', AI_PROMPT_REQUIRED: 'AI მოთხოვნა სავალდებულოა.', GROQ_API_KEY_READONLY: 'Groq API გასაღები მხოლოდ წასაკითხია და ვერ შეიცვლება.', blocked_api_access: 'თქვენი Groq API გეგმა არ იძლევა ამ მოდელზე წვდომის საშუალებას. გააუმჯობესეთ Groq გეგმა ან გამოიყენეთ network.corifeus.com პროქსი.', rate_limit: 'AI სიჩქარის ლიმიტი მიღწეულია. სცადეთ მოგვიანებით ან გამოიყენეთ თქვენი Groq API გასაღები პარამეტრებში.', }, kk: { AI_DISABLED: 'AI өшірілген. AI параметрлерінде қосыңыз.', AI_PROMPT_REQUIRED: 'AI сұрауы қажет.', GROQ_API_KEY_READONLY: 'Groq API кілті тек оқу үшін және өзгертуге болмайды.', blocked_api_access: 'Groq API жоспарыңыз бұл модельге кіруге рұқсат бермейді. Groq жоспарын жаңартыңыз немесе network.corifeus.com проксиін пайдаланыңыз.', rate_limit: 'AI жылдамдық шегіне жетті. Кейінірек қайталаңыз немесе параметрлерде өз Groq API кілтіңізді пайдаланыңыз.', }, km: { AI_DISABLED: 'AI ត្រូវបានបិទ។ បើកវានៅក្នុងការកំណត់ AI។', AI_PROMPT_REQUIRED: 'ត្រូវការសំណួរ AI។', GROQ_API_KEY_READONLY: 'សោ Groq API គឺអានតែប៉ុណ្ណោះ ហើយមិនអាចកែប្រែបានទេ។', blocked_api_access: 'គម្រោង Groq API របស់អ្នកមិនអនុញ្ញាតឱ្យចូលប្រើម៉ូដែលនេះទេ។ សូមធ្វើឱ្យប្រសើរគម្រោង Groq ឬប្រើ proxy network.corifeus.com។', rate_limit: 'ដល់កំណត់អត្រា AI។ សូមព្យាយាមម្តងទៀតនៅពេលក្រោយ ឬប្រើសោ Groq API ផ្ទាល់ខ្លួននៅក្នុងការកំណត់។', }, ko: { AI_DISABLED: 'AI가 비활성화되었습니다. AI 설정에서 활성화하세요.', AI_PROMPT_REQUIRED: 'AI 프롬프트가 필요합니다.', GROQ_API_KEY_READONLY: 'Groq API 키는 읽기 전용이며 수정할 수 없습니다.', blocked_api_access: 'Groq API 플랜에서 이 모델에 대한 액세스를 허용하지 않습니다. Groq 플랜을 업그레이드하거나 network.corifeus.com 프록시를 사용하세요.', rate_limit: 'AI 속도 제한에 도달했습니다. 나중에 다시 시도하거나 설정에서 자신의 Groq API 키를 사용하세요.', }, ky: { AI_DISABLED: 'AI өчүрүлгөн. AI жөндөөлөрүндө иштетиңиз.', AI_PROMPT_REQUIRED: 'AI суроосу талап кылынат.', GROQ_API_KEY_READONLY: 'Groq API ачкычы окуу үчүн гана жана өзгөртүүгө болбойт.', blocked_api_access: 'Groq API планыңыз бул моделге кирүүгө уруксат бербейт. Groq планын жаңыртыңыз же network.corifeus.com проксисин колдонуңуз.', rate_limit: 'AI ылдамдык чегине жетти. Кийинчерээк кайталаңыз же жөндөөлөрдө өз Groq API ачкычыңызды колдонуңуз.', }, lt: { AI_DISABLED: 'AI išjungtas. Įjunkite jį AI nustatymuose.', AI_PROMPT_REQUIRED: 'Reikalinga AI užklausa.', GROQ_API_KEY_READONLY: 'Groq API raktas yra tik skaitomas ir negali būti keičiamas.', blocked_api_access: 'Jūsų Groq API planas neleidžia pasiekti šio modelio. Atnaujinkite savo Groq planą arba naudokite network.corifeus.com tarpinį serverį.', rate_limit: 'Pasiektas AI greičio limitas. Bandykite vėliau arba naudokite savo Groq API raktą nustatymuose.', }, mk: { AI_DISABLED: 'AI е оневозможен. Овозможете го во AI поставки.', AI_PROMPT_REQUIRED: 'Потребно е AI барање.', GROQ_API_KEY_READONLY: 'Groq API клучот е само за читање и не може да се менува.', blocked_api_access: 'Вашиот Groq API план не дозволува пристап до овој модел. Надградете го Groq планот или користете network.corifeus.com прокси.', rate_limit: 'Достигнат е AI лимитот. Обидете се повторно подоцна или користете сопствен Groq API клуч во поставките.', }, ms: { AI_DISABLED: 'AI dinyahaktifkan. Aktifkan dalam Tetapan AI.', AI_PROMPT_REQUIRED: 'Permintaan AI diperlukan.', GROQ_API_KEY_READONLY: 'Kunci Groq API adalah baca sahaja dan tidak boleh diubah.', blocked_api_access: 'Pelan Groq API anda tidak membenarkan akses kepada model ini. Naik taraf pelan Groq anda atau gunakan proksi network.corifeus.com.', rate_limit: 'Had kadar AI dicapai. Cuba lagi kemudian atau gunakan kunci Groq API anda sendiri dalam Tetapan.', }, ne: { AI_DISABLED: 'AI निष्क्रिय छ। AI सेटिङमा सक्रिय गर्नुहोस्।', AI_PROMPT_REQUIRED: 'AI प्रम्प्ट आवश्यक छ।', GROQ_API_KEY_READONLY: 'Groq API कुञ्जी पठन-मात्र हो र परिमार्जन गर्न सकिँदैन।', blocked_api_access: 'तपाईंको Groq API योजनाले यो मोडेलमा पहुँच दिँदैन। Groq योजना अपग्रेड गर्नुहोस् वा network.corifeus.com प्रोक्सी प्रयोग गर्नुहोस्।', rate_limit: 'AI दर सीमामा पुगियो। पछि फेरि प्रयास गर्नुहोस् वा सेटिङमा आफ्नो Groq API कुञ्जी प्रयोग गर्नुहोस्।', }, nl: { AI_DISABLED: 'AI is uitgeschakeld. Schakel het in bij AI-instellingen.', AI_PROMPT_REQUIRED: 'AI-prompt is vereist.', GROQ_API_KEY_READONLY: 'De Groq API-sleutel is alleen-lezen en kan niet worden gewijzigd.', blocked_api_access: 'Uw Groq API-plan staat geen toegang tot dit model toe. Upgrade uw Groq-plan of gebruik de network.corifeus.com proxy.', rate_limit: 'AI-snelheidslimiet bereikt. Probeer het later opnieuw of gebruik uw eigen Groq API-sleutel in de instellingen.', }, no: { AI_DISABLED: 'AI er deaktivert. Aktiver det i AI-innstillinger.', AI_PROMPT_REQUIRED: 'AI-forespørsel er påkrevd.', GROQ_API_KEY_READONLY: 'Groq API-nøkkelen er skrivebeskyttet og kan ikke endres.', blocked_api_access: 'Groq API-planen din tillater ikke tilgang til denne modellen. Oppgrader Groq-planen din eller bruk network.corifeus.com proxy.', rate_limit: 'AI-hastighetsgrense nådd. Prøv igjen senere eller bruk din egen Groq API-nøkkel i innstillingene.', }, pl: { AI_DISABLED: 'AI jest wyłączone. Włącz je w ustawieniach AI.', AI_PROMPT_REQUIRED: 'Zapytanie AI jest wymagane.', GROQ_API_KEY_READONLY: 'Klucz Groq API jest tylko do odczytu i nie może być modyfikowany.', blocked_api_access: 'Twój plan Groq API nie pozwala na dostęp do tego modelu. Zaktualizuj plan Groq lub użyj proxy network.corifeus.com.', rate_limit: 'Osiągnięto limit AI. Spróbuj ponownie później lub użyj własnego klucza Groq API w ustawieniach.', }, 'pt-BR': { AI_DISABLED: 'IA está desativada. Ative nas configurações de IA.', AI_PROMPT_REQUIRED: 'Consulta de IA é obrigatória.', GROQ_API_KEY_READONLY: 'A chave Groq API é somente leitura e não pode ser modificada.', blocked_api_access: 'Seu plano Groq API não permite acesso a este modelo. Atualize seu plano Groq ou use o proxy network.corifeus.com.', rate_limit: 'Limite de taxa de IA atingido. Tente novamente mais tarde ou use sua própria chave Groq API nas configurações.', }, 'pt-PT': { AI_DISABLED: 'IA está desativada. Ative nas definições de IA.', AI_PROMPT_REQUIRED: 'Consulta de IA é obrigatória.', GROQ_API_KEY_READONLY: 'A chave Groq API é apenas de leitura e não pode ser modificada.', blocked_api_access: 'O seu plano Groq API não permite acesso a este modelo. Atualize o seu plano Groq ou utilize o proxy network.corifeus.com.', rate_limit: 'Limite de taxa de IA atingido. Tente novamente mais tarde ou utilize a sua própria chave Groq API nas definições.', }, ro: { AI_DISABLED: 'AI este dezactivat. Activați-l în setările AI.', AI_PROMPT_REQUIRED: 'Interogarea AI este necesară.', GROQ_API_KEY_READONLY: 'Cheia Groq API este doar pentru citire și nu poate fi modificată.', blocked_api_access: 'Planul dvs. Groq API nu permite accesul la acest model. Actualizați planul Groq sau utilizați proxy-ul network.corifeus.com.', rate_limit: 'Limita de rată AI atinsă. Încercați din nou mai târziu sau utilizați propria cheie Groq API în setări.', }, ru: { AI_DISABLED: 'AI отключен. Включите его в настройках AI.', AI_PROMPT_REQUIRED: 'Требуется запрос AI.', GROQ_API_KEY_READONLY: 'Ключ Groq API доступен только для чтения и не может быть изменён.', blocked_api_access: 'Ваш план Groq API не позволяет доступ к этой модели. Обновите план Groq или используйте прокси network.corifeus.com.', rate_limit: 'Достигнут лимит AI. Попробуйте позже или используйте свой ключ Groq API в настройках.', }, si: { AI_DISABLED: 'AI අක්‍රියයි. AI සැකසුම් තුළ සක්‍රිය කරන්න.', AI_PROMPT_REQUIRED: 'AI ඉල්ලීම අවශ්‍යයි.', GROQ_API_KEY_READONLY: 'Groq API යතුර කියවීම පමණක් වන අතර වෙනස් කළ නොහැක.', blocked_api_access: 'ඔබේ Groq API සැලැස්ම මෙම ආකෘතියට ප්‍රවේශය ලබා දෙන්නේ නැත. Groq සැලැස්ම උත්ශ්‍රේණි කරන්න හෝ network.corifeus.com proxy භාවිතා කරන්න.', rate_limit: 'AI අනුපාත සීමාවට ළඟා විය. පසුව නැවත උත්සාහ කරන්න හෝ සැකසුම් තුළ ඔබේම Groq API යතුර භාවිතා කරන්න.', }, sk: { AI_DISABLED: 'AI je deaktivované. Povoľte ho v nastaveniach AI.', AI_PROMPT_REQUIRED: 'Je vyžadovaný AI dotaz.', GROQ_API_KEY_READONLY: 'Kľúč Groq API je iba na čítanie a nedá sa upraviť.', blocked_api_access: 'Váš plán Groq API neumožňuje prístup k tomuto modelu. Aktualizujte plán Groq alebo použite proxy network.corifeus.com.', rate_limit: 'Dosiahnutý limit AI. Skúste to neskôr alebo použite vlastný kľúč Groq API v nastaveniach.', }, sl: { AI_DISABLED: 'AI je onemogočen. Omogočite ga v nastavitvah AI.', AI_PROMPT_REQUIRED: 'Zahteva AI je obvezna.', GROQ_API_KEY_READONLY: 'Ključ Groq API je samo za branje in ga ni mogoče spremeniti.', blocked_api_access: 'Vaš načrt Groq API ne dovoljuje dostopa do tega modela. Nadgradite načrt Groq ali uporabite proxy network.corifeus.com.', rate_limit: 'Dosežena je omejitev AI. Poskusite znova pozneje ali uporabite lastni ključ Groq API v nastavitvah.', }, sr: { AI_DISABLED: 'AI је онемогућен. Омогућите га у AI подешавањима.', AI_PROMPT_REQUIRED: 'AI упит је обавезан.', GROQ_API_KEY_READONLY: 'Groq API кључ је само за читање и не може се мењати.', blocked_api_access: 'Ваш Groq API план не дозвољава приступ овом моделу. Надоградите Groq план или користите network.corifeus.com прокси.', rate_limit: 'Достигнут је AI лимит. Покушајте поново касније или користите сопствени Groq API кључ у подешавањима.', }, sv: { AI_DISABLED: 'AI är inaktiverad. Aktivera det i AI-inställningar.', AI_PROMPT_REQUIRED: 'AI-förfrågan krävs.', GROQ_API_KEY_READONLY: 'Groq API-nyckeln är skrivskyddad och kan inte ändras.', blocked_api_access: 'Din Groq API-plan tillåter inte åtkomst till denna modell. Uppgradera din Groq-plan eller använd network.corifeus.com proxy.', rate_limit: 'AI-hastighetsgräns nådd. Försök igen senare eller använd din egen Groq API-nyckel i inställningarna.', }, sw: { AI_DISABLED: 'AI imezimwa. Iwashe katika Mipangilio ya AI.', AI_PROMPT_REQUIRED: 'Ombi la AI linahitajika.', GROQ_API_KEY_READONLY: 'Ufunguo wa Groq API ni wa kusoma tu na hauwezi kubadilishwa.', blocked_api_access: 'Mpango wako wa Groq API hauruhusu ufikiaji wa modeli hii. Boresha mpango wako wa Groq au tumia proksi ya network.corifeus.com.', rate_limit: 'Kikomo cha kiwango cha AI kimefikiwa. Jaribu tena baadaye au tumia ufunguo wako wa Groq API katika Mipangilio.', }, ta: { AI_DISABLED: 'AI முடக்கப்பட்டுள்ளது. AI அமைப்புகளில் இயக்கவும்.', AI_PROMPT_REQUIRED: 'AI வினவல் தேவை.', GROQ_API_KEY_READONLY: 'Groq API விசை படிக்க மட்டுமே மற்றும் மாற்ற முடியாது.', blocked_api_access: 'உங்கள் Groq API திட்டம் இந்த மாதிரிக்கான அணுகலை அனுமதிக்கவில்லை. Groq திட்டத்தை மேம்படுத்தவும் அல்லது network.corifeus.com ப்ராக்ஸியைப் பயன்படுத்தவும்.', rate_limit: 'AI வீத வரம்பு எட்டப்பட்டது. பின்னர் மீண்டும் முயற்சிக்கவும் அல்லது அமைப்புகளில் உங்கள் சொந்த Groq API விசையைப் பயன்படுத்தவும்.', }, tg: { AI_DISABLED: 'AI ғайрифаъол аст. Онро дар танзимоти AI фаъол кунед.', AI_PROMPT_REQUIRED: 'Дархости AI лозим аст.', GROQ_API_KEY_READONLY: 'Калиди Groq API танҳо барои хондан аст ва тағйир дода намешавад.', blocked_api_access: 'Нақшаи Groq API-и шумо ба ин модел дастрасӣ намедиҳад. Нақшаи Groq-ро навсозӣ кунед ё прокси network.corifeus.com-ро истифода баред.', rate_limit: 'Ба ҳадди суръати AI расидед. Баъдтар бори дигар кӯшиш кунед ё калиди Groq API-и худро дар танзимот истифода баред.', }, th: { AI_DISABLED: 'AI ถูกปิดใช้งาน เปิดใช้งานในการตั้งค่า AI', AI_PROMPT_REQUIRED: 'ต้องมีคำถาม AI', GROQ_API_KEY_READONLY: 'คีย์ Groq API เป็นแบบอ่านอย่างเดียวและไม่สามารถแก้ไขได้', blocked_api_access: 'แผน Groq API ของคุณไม่อนุญาตให้เข้าถึงโมเดลนี้ กรุณาอัปเกรดแผน Groq หรือใช้ proxy network.corifeus.com', rate_limit: 'ถึงขีดจำกัดอัตรา AI แล้ว ลองอีกครั้งในภายหลังหรือใช้คีย์ Groq API ของคุณเองในการตั้งค่า', }, tr: { AI_DISABLED: 'AI devre dışı. AI Ayarlarında etkinleştirin.', AI_PROMPT_REQUIRED: 'AI sorgusu gereklidir.', GROQ_API_KEY_READONLY: 'Groq API anahtarı salt okunurdur ve değiştirilemez.', blocked_api_access: 'Groq API planınız bu modele erişime izin vermiyor. Groq planınızı yükseltin veya network.corifeus.com proxy kullanın.', rate_limit: 'AI hız sınırına ulaşıldı. Daha sonra tekrar deneyin veya Ayarlarda kendi Groq API anahtarınızı kullanın.', }, uk: { AI_DISABLED: 'AI вимкнено. Увімкніть його в налаштуваннях AI.', AI_PROMPT_REQUIRED: 'Потрібен запит AI.', GROQ_API_KEY_READONLY: 'Ключ Groq API доступний лише для читання і не може бути змінений.', blocked_api_access: 'Ваш план Groq API не дозволяє доступ до цієї моделі. Оновіть план Groq або використовуйте проксі network.corifeus.com.', rate_limit: 'Досягнуто ліміт AI. Спробуйте пізніше або використовуйте власний ключ Groq API в налаштуваннях.', }, vi: { AI_DISABLED: 'AI đã bị tắt. Bật nó trong Cài đặt AI.', AI_PROMPT_REQUIRED: 'Yêu cầu AI là bắt buộc.', GROQ_API_KEY_READONLY: 'Khóa Groq API chỉ đọc và không thể sửa đổi.', blocked_api_access: 'Gói Groq API của bạn không cho phép truy cập vào mô hình này. Vui lòng nâng cấp gói Groq hoặc sử dụng proxy network.corifeus.com.', rate_limit: 'Đã đạt giới hạn tốc độ AI. Thử lại sau hoặc sử dụng khóa Groq API của riêng bạn trong Cài đặt.', }, 'zh-HK': { AI_DISABLED: 'AI 已停用。請在 AI 設定中啟用。', AI_PROMPT_REQUIRED: '需要 AI 提示。', GROQ_API_KEY_READONLY: 'Groq API 金鑰為唯讀,無法修改。', blocked_api_access: '您的 Groq API 方案不允許存取此模型。請升級您的 Groq 方案或使用 network.corifeus.com 代理。', rate_limit: '已達到 AI 速率限制。請稍後再試或在設定中使用您自己的 Groq API 金鑰。', }, 'zh-TW': { AI_DISABLED: 'AI 已停用。請在 AI 設定中啟用。', AI_PROMPT_REQUIRED: '需要 AI 提示。', GROQ_API_KEY_READONLY: 'Groq API 金鑰為唯讀,無法修改。', blocked_api_access: '您的 Groq API 方案不允許存取此模型。請升級您的 Groq 方案或使用 network.corifeus.com 代理。', rate_limit: '已達到 AI 速率限制。請稍後再試或在設定中使用您自己的 Groq API 金鑰。', }, zn: { AI_DISABLED: 'AI 已禁用。请在 AI 设置中启用。', AI_PROMPT_REQUIRED: '需要 AI 提示。', GROQ_API_KEY_READONLY: 'Groq API 密钥为只读,无法修改。', blocked_api_access: '您的 Groq API 计划不允许访问此模型。请升级您的 Groq 计划或使用 network.corifeus.com 代理。', rate_limit: '已达到 AI 速率限制。请稍后重试或在设置中使用您自己的 Groq API 密钥。', }, } function injectEntries(filePath, entries) { let content = fs.readFileSync(filePath, 'utf8') const marker = 'invalid_console_command:' const markerIdx = content.indexOf(marker) if (markerIdx === -1) { return false } // Find end of that line const lineEnd = content.indexOf('\n', markerIdx) if (lineEnd === -1) return false // Ensure trailing comma on existing line const existingLine = content.substring(markerIdx, lineEnd) let updatedLine = existingLine if (!existingLine.trimEnd().endsWith(',')) { updatedLine = existingLine.trimEnd() + ',' } // Build new lines const newLines = Object.entries(entries) .map(([k, v]) => { const escaped = v.replace(/\\/g, '\\\\').replace(/"/g, '\\"') return ` "${k}": "${escaped}",` }) .join('\n') const before = content.substring(0, markerIdx) const after = content.substring(lineEnd) content = before + updatedLine + '\n' + newLines + after // Remove trailing comma before closing brace content = content.replace(/,(\s*\n\s*\})/g, '$1') fs.writeFileSync(filePath, content) return true } let count = 0 for (const lang of Object.keys(TRANSLATIONS)) { const filePath = path.join(STRINGS_DIR, lang, 'strings.js') if (!fs.existsSync(filePath)) { console.warn(`SKIP ${lang}: file not found`) continue } const content = fs.readFileSync(filePath, 'utf8') if (content.includes('blocked_api_access')) { console.log(`SKIP ${lang}: already done`) continue } if (injectEntries(filePath, TRANSLATIONS[lang])) { console.log(`OK ${lang}`) count++ } else { console.warn(`FAIL ${lang}: marker not found`) } } console.log(`\nDone: ${count} languages updated`) scripts/inject-donate-strings.js000066400000000000000000001002121517660044600173230ustar00rootroot00000000000000#!/usr/bin/env node 'use strict' const fs = require('fs') const path = require('path') const STRINGS_DIR = path.resolve(__dirname, '..', 'src', 'strings') const TRANSLATIONS = { ar: { donateTitle: 'ادعم P3X Redis UI', donateDescription: 'P3X Redis UI هو مشروع مجاني ومفتوح المصدر. تكاليف صيانة التطبيق وميزات الذكاء الاصطناعي وصور Docker والخوادم والبنية التحتية تأتي من جيب المطور الخاص. إذا وجدت هذه الأداة مفيدة، يرجى التفكير في دعم تطويرها المستمر بتبرع. كل مساهمة تساعد في الحفاظ على المشروع حيًا ومتناميًا. شكرًا لك!', }, az: { donateTitle: 'P3X Redis UI-ni dəstəkləyin', donateDescription: 'P3X Redis UI pulsuz, açıq mənbəli layihədir. Tətbiqin, AI funksiyalarının, Docker təsvirlərinin, serverlərin və infrastrukturun saxlanma xərcləri tərtibatçının öz cibindən ödənilir. Bu aləti faydalı hesab edirsinizsə, inkişafını dəstəkləmək üçün ianə etməyi düşünün. Hər bir töhfə layihənin yaşamasına və böyüməsinə kömək edir. Təşəkkürlər!', }, be: { donateTitle: 'Падтрымайце P3X Redis UI', donateDescription: 'P3X Redis UI — бясплатны праект з адкрытым зыходным кодам. Выдаткі на абслугоўванне прыкладання, функцыі AI, вобразы Docker, серверы і інфраструктуру аплачваюцца з уласнай кішэні распрацоўшчыка. Калі вы лічыце гэты інструмент карысным, калі ласка, падтрымайце яго далейшае развіццё ахвяраваннем. Кожны ўнёсак дапамагае праекту жыць і расці. Дзякуй!', }, bg: { donateTitle: 'Подкрепете P3X Redis UI', donateDescription: 'P3X Redis UI е безплатен проект с отворен код. Разходите за поддръжка на приложението, AI функциите, Docker образите, сървърите и инфраструктурата идват от собствения джоб на разработчика. Ако намирате този инструмент за полезен, моля обмислете да подкрепите развитието му с дарение. Всеки принос помага проектът да живее и расте. Благодаря!', }, bn: { donateTitle: 'P3X Redis UI সমর্থন করুন', donateDescription: 'P3X Redis UI একটি বিনামূল্যে, ওপেন-সোর্স প্রকল্প। অ্যাপ, AI ফিচার, Docker ইমেজ, সার্ভার এবং অবকাঠামো রক্ষণাবেক্ষণের খরচ ডেভেলপারের নিজের পকেট থেকে আসে। আপনি যদি এই টুলটি দরকারী মনে করেন, দয়া করে একটি অনুদান দিয়ে এর ক্রমাগত উন্নয়নে সহায়তা করুন। প্রতিটি অবদান প্রকল্পটিকে জীবিত ও ক্রমবর্ধমান রাখতে সাহায্য করে। ধন্যবাদ!', }, bs: { donateTitle: 'Podržite P3X Redis UI', donateDescription: 'P3X Redis UI je besplatan projekat otvorenog koda. Troškovi održavanja aplikacije, AI funkcija, Docker slika, servera i infrastrukture dolaze iz džepa programera. Ako vam je ovaj alat koristan, razmislite o podršci daljnjem razvoju donacijom. Svaki doprinos pomaže da projekat živi i raste. Hvala!', }, cs: { donateTitle: 'Podpořte P3X Redis UI', donateDescription: 'P3X Redis UI je bezplatný open-source projekt. Náklady na údržbu aplikace, AI funkce, Docker obrazy, servery a infrastrukturu hradí vývojář z vlastní kapsy. Pokud vám tento nástroj pomáhá, zvažte prosím podporu jeho dalšího vývoje příspěvkem. Každý příspěvek pomáhá udržet projekt živý a rostoucí. Děkujeme!', }, da: { donateTitle: 'Støt P3X Redis UI', donateDescription: 'P3X Redis UI er et gratis open source-projekt. Vedligeholdelse af appen, AI-funktioner, Docker-images, servere og infrastruktur betales af udviklerens egen lomme. Hvis du finder dette værktøj nyttigt, overvej venligst at støtte dets fortsatte udvikling med en donation. Ethvert bidrag hjælper med at holde projektet i live og voksende. Tak!', }, de: { donateTitle: 'Unterstützen Sie P3X Redis UI', donateDescription: 'P3X Redis UI ist ein kostenloses Open-Source-Projekt. Die Kosten für die Wartung der App, AI-Funktionen, Docker-Images, Server und Infrastruktur werden vom Entwickler aus eigener Tasche bezahlt. Wenn Sie dieses Tool nützlich finden, unterstützen Sie bitte seine Weiterentwicklung mit einer Spende. Jeder Beitrag hilft, das Projekt am Leben zu erhalten und wachsen zu lassen. Vielen Dank!', }, el: { donateTitle: 'Υποστηρίξτε το P3X Redis UI', donateDescription: 'Το P3X Redis UI είναι ένα δωρεάν έργο ανοιχτού κώδικα. Τα έξοδα συντήρησης της εφαρμογής, των λειτουργιών AI, των Docker images, των διακομιστών και της υποδομής προέρχονται από την τσέπη του προγραμματιστή. Αν βρίσκετε αυτό το εργαλείο χρήσιμο, σκεφτείτε να υποστηρίξετε τη συνεχή ανάπτυξή του με μια δωρεά. Κάθε συνεισφορά βοηθά το έργο να παραμείνει ζωντανό και να αναπτύσσεται. Ευχαριστούμε!', }, es: { donateTitle: 'Apoya P3X Redis UI', donateDescription: 'P3X Redis UI es un proyecto gratuito y de código abierto. Los costos de mantenimiento de la aplicación, funciones de IA, imágenes Docker, servidores e infraestructura salen del bolsillo del desarrollador. Si encuentras útil esta herramienta, considera apoyar su desarrollo continuo con una donación. Cada contribución ayuda a mantener el proyecto vivo y creciendo. ¡Gracias!', }, et: { donateTitle: 'Toetage P3X Redis UI-d', donateDescription: 'P3X Redis UI on tasuta avatud lähtekoodiga projekt. Rakenduse, AI funktsioonide, Dockeri piltide, serverite ja taristu hoolduskulud tulevad arendaja enda taskust. Kui leiate selle tööriista kasulikuks, kaaluge palun selle jätkuva arenduse toetamist annetusega. Iga panus aitab projekti elus ja kasvamas hoida. Aitäh!', }, fi: { donateTitle: 'Tue P3X Redis UI:ta', donateDescription: 'P3X Redis UI on ilmainen avoimen lähdekoodin projekti. Sovelluksen, AI-ominaisuuksien, Docker-kuvien, palvelimien ja infrastruktuurin ylläpitokustannukset tulevat kehittäjän omasta taskusta. Jos pidät tätä työkalua hyödyllisenä, harkitse sen jatkuvan kehityksen tukemista lahjoituksella. Jokainen panos auttaa pitämään projektin elossa ja kasvamassa. Kiitos!', }, fil: { donateTitle: 'Suportahan ang P3X Redis UI', donateDescription: 'Ang P3X Redis UI ay isang libre, open-source na proyekto. Ang mga gastos sa pagpapanatili ng app, AI features, Docker images, servers, at infrastructure ay galing sa sariling bulsa ng developer. Kung nakakatulong sa iyo ang tool na ito, pag-isipang suportahan ang patuloy na pag-develop nito sa pamamagitan ng donasyon. Bawat kontribusyon ay nakakatulong na mapanatiling buhay at lumalaki ang proyekto. Salamat!', }, fr: { donateTitle: 'Soutenez P3X Redis UI', donateDescription: "P3X Redis UI est un projet gratuit et open source. Les coûts de maintenance de l'application, des fonctionnalités IA, des images Docker, des serveurs et de l'infrastructure proviennent de la poche du développeur. Si vous trouvez cet outil utile, veuillez envisager de soutenir son développement continu par un don. Chaque contribution aide à maintenir le projet en vie et en croissance. Merci !", }, he: { donateTitle: 'תמכו ב-P3X Redis UI', donateDescription: 'P3X Redis UI הוא פרויקט חינמי בקוד פתוח. עלויות תחזוקת האפליקציה, תכונות AI, תמונות Docker, שרתים ותשתית יוצאות מכיסו של המפתח. אם אתם מוצאים כלי זה שימושי, אנא שקלו לתמוך בפיתוח המתמשך שלו בתרומה. כל תרומה עוזרת לשמור על הפרויקט חי וצומח. תודה!', }, hr: { donateTitle: 'Podržite P3X Redis UI', donateDescription: 'P3X Redis UI je besplatan projekt otvorenog koda. Troškovi održavanja aplikacije, AI značajki, Docker slika, servera i infrastrukture dolaze iz džepa programera. Ako vam je ovaj alat koristan, razmislite o podršci daljnjem razvoju donacijom. Svaki doprinos pomaže da projekt živi i raste. Hvala!', }, hu: { donateTitle: 'Támogasd a P3X Redis UI-t', donateDescription: 'A P3X Redis UI egy ingyenes, nyílt forráskódú projekt. Az alkalmazás, az AI funkciók, a Docker képfájlok, a szerverek és az infrastruktúra karbantartási költségei a fejlesztő saját zsebéből jönnek. Ha hasznosnak találod ezt az eszközt, kérjük, fontold meg a folyamatos fejlesztés támogatását adománnyal. Minden hozzájárulás segít a projekt életben tartásában és növekedésében. Köszönjük!', }, hy: { donateTitle: 'Աջակցեք P3X Redis UI-ին', donateDescription: 'P3X Redis UI-ն անվճար, բաց կոադային նախագիծ է: Հավելվածի, AI գործառույթների, Docker պատկերների, սերdelays և ենthink ծախserversdelays delays delays delays delays delays delays delays delays delays delays delays delays delays delays:', }, id: { donateTitle: 'Dukung P3X Redis UI', donateDescription: 'P3X Redis UI adalah proyek gratis dan open-source. Biaya pemeliharaan aplikasi, fitur AI, image Docker, server, dan infrastruktur berasal dari kantong pengembang sendiri. Jika Anda merasa alat ini berguna, pertimbangkan untuk mendukung pengembangan berkelanjutannya dengan donasi. Setiap kontribusi membantu menjaga proyek tetap hidup dan berkembang. Terima kasih!', }, it: { donateTitle: 'Supporta P3X Redis UI', donateDescription: "P3X Redis UI è un progetto gratuito e open source. I costi di manutenzione dell'app, delle funzionalità AI, delle immagini Docker, dei server e dell'infrastruttura provengono dalle tasche dello sviluppatore. Se trovi utile questo strumento, considera di supportare il suo sviluppo continuo con una donazione. Ogni contributo aiuta a mantenere il progetto vivo e in crescita. Grazie!", }, ja: { donateTitle: 'P3X Redis UIを支援する', donateDescription: 'P3X Redis UIは無料のオープンソースプロジェクトです。アプリの保守、AI機能、Dockerイメージ、サーバー、インフラストラクチャの費用は開発者の自腹で賄われています。このツールが役立つと感じたら、寄付で継続的な開発をサポートしてください。すべての貢献がプロジェクトを存続させ、成長させる助けになります。ありがとうございます!', }, ka: { donateTitle: 'მხარი დაუჭირეთ P3X Redis UI-ს', donateDescription: 'P3X Redis UI არის უფასო, ღია კოდის პროექტი. აპლიკაციის, AI ფუნქციების, Docker სურათების, სერვერების და ინფრასტრუქტურის მოვლის ხარჯები დეველოპერის საკუთარი ჯიბიდან მოდის. თუ ეს ინსტრუმენტი სასარგებლო გეჩვენებათ, გთხოვთ, განიხილოთ მისი მუდმივი განვითარების მხარდაჭერა შემოწირულობით. ყოველი წვლილი ეხმარება პროექტს სიცოცხლესა და ზრდაში. გმადლობთ!', }, kk: { donateTitle: 'P3X Redis UI-ді қолдаңыз', donateDescription: 'P3X Redis UI — тегін, ашық бастапқы код жобасы. Қосымшаны, AI мүмкіндіктерін, Docker кескіндерін, серверлерді және инфрақұрылымды ұстау шығындары әзірлеушінің өз қалтасынан шығады. Бұл құралды пайдалы деп тапсаңыз, оның үздіксіз дамуын қайырмалдықпен қолдауды қарастырыңыз. Әрбір үлес жобаны тірі және өсіп келе жатқан ұстауға көмектеседі. Рахмет!', }, km: { donateTitle: 'គាំទ្រ P3X Redis UI', donateDescription: 'P3X Redis UI គឺជាគម្រោងឥតគិតថ្លៃ និងកូដបើកចំហ។ ការចំណាយលើការថែទាំកម្មវិធី មុខងារ AI រូបភាព Docker ម៉ាស៊ីនមេ និងហេដ្ឋារចនាសម្ព័ន្ធ មកពីហោប៉ៅផ្ទាល់ខ្លួនរបស់អ្នកអភិវឌ្ឍន៍។ ប្រសិនបើអ្នកយល់ថាឧបករណ៍នេះមានប្រយោជន៍ សូមពិចារណាគាំទ្រការអភិវឌ្ឍន៍បន្តរបស់វាដោយការបរិច្ចាគ។ រាល់ការរួមចំណែកជួយរក្សាគម្រោងនេះឱ្យមានជីវិត និងរីកចម្រើន។ សូមអរគុណ!', }, ko: { donateTitle: 'P3X Redis UI 지원하기', donateDescription: 'P3X Redis UI는 무료 오픈소스 프로젝트입니다. 앱 유지 관리, AI 기능, Docker 이미지, 서버 및 인프라 비용은 개발자의 사비로 충당됩니다. 이 도구가 유용하다면, 기부를 통해 지속적인 개발을 지원해 주세요. 모든 기여는 프로젝트를 유지하고 성장시키는 데 도움이 됩니다. 감사합니다!', }, ky: { donateTitle: 'P3X Redis UI-ди колдоңуз', donateDescription: 'P3X Redis UI — бекер, ачык баштапкы коддуу долбоор. Колдонмону, AI мүмкүнчүлүктөрүн, Docker сүрөттөрүн, серверлерди жана инфраструктураны тейлөө чыгымдары иштеп чыгуучунун өз чөнтөгүнөн чыгат. Бул куралды пайдалуу деп тапсаңыз, аны кайрымдуулук менен колдоону карап көрүңүз. Ар бир салым долбоорду тирүү жана өсүп жатканда кармоого жардам берет. Рахмат!', }, lt: { donateTitle: 'Palaikykite P3X Redis UI', donateDescription: 'P3X Redis UI yra nemokamas atvirojo kodo projektas. Programėlės, AI funkcijų, Docker atvaizdų, serverių ir infrastruktūros priežiūros išlaidos padengiamos iš kūrėjo kišenės. Jei manote, kad šis įrankis naudingas, apsvarstykite galimybę paremti jo tolesnę plėtrą auka. Kiekvienas indėlis padeda projektui gyvuoti ir augti. Ačiū!', }, mk: { donateTitle: 'Поддржете го P3X Redis UI', donateDescription: 'P3X Redis UI е бесплатен проект со отворен код. Трошоците за одржување на апликацијата, AI функциите, Docker сликите, серверите и инфраструктурата доаѓаат од џебот на програмерот. Ако го сметате овој алат за корисен, размислете да го поддржите неговиот понатамошен развој со донација. Секој придонес помага проектот да живее и расте. Благодарам!', }, ms: { donateTitle: 'Sokong P3X Redis UI', donateDescription: 'P3X Redis UI adalah projek percuma dan sumber terbuka. Kos penyelenggaraan aplikasi, ciri AI, imej Docker, pelayan dan infrastruktur datang dari poket pembangun sendiri. Jika anda mendapati alat ini berguna, sila pertimbangkan untuk menyokong pembangunan berterusannya dengan derma. Setiap sumbangan membantu memastikan projek ini terus hidup dan berkembang. Terima kasih!', }, ne: { donateTitle: 'P3X Redis UI लाई समर्थन गर्नुहोस्', donateDescription: 'P3X Redis UI एक निःशुल्क, ओपन-सोर्स परियोजना हो। अ्यप, AI सुविधाहरू, Docker छविहरू, सर्भरहरू र पूर्वाधारको मर्मत खर्च विकासकर्ताको आफ्नै खल्तीबाट आउँछ। यदि तपाईंलाई यो उपकरण उपयोगी लाग्छ भने, कृपया दानको माध्यमबाट यसको निरन्तर विकासलाई समर्थन गर्ने विचार गर्नुहोस्। प्रत्येक योगदानले परियोजनालाई जीवित र बढ्दो राख्न मद्दत गर्छ। धन्यवाद!', }, nl: { donateTitle: 'Steun P3X Redis UI', donateDescription: 'P3X Redis UI is een gratis, open-source project. De kosten voor het onderhoud van de app, AI-functies, Docker-images, servers en infrastructuur komen uit de eigen zak van de ontwikkelaar. Als u dit hulpmiddel nuttig vindt, overweeg dan om de voortdurende ontwikkeling te steunen met een donatie. Elke bijdrage helpt het project levend en groeiend te houden. Bedankt!', }, no: { donateTitle: 'Støtt P3X Redis UI', donateDescription: 'P3X Redis UI er et gratis åpen kildekode-prosjekt. Kostnadene for vedlikehold av appen, AI-funksjoner, Docker-bilder, servere og infrastruktur kommer fra utviklerens egen lomme. Hvis du synes dette verktøyet er nyttig, vurder å støtte den videre utviklingen med en donasjon. Ethvert bidrag hjelper prosjektet å leve og vokse. Takk!', }, pl: { donateTitle: 'Wesprzyj P3X Redis UI', donateDescription: 'P3X Redis UI to darmowy projekt open source. Koszty utrzymania aplikacji, funkcji AI, obrazów Docker, serwerów i infrastruktury pokrywane są z kieszeni programisty. Jeśli uważasz to narzędzie za przydatne, rozważ wsparcie jego dalszego rozwoju darowizną. Każdy wkład pomaga utrzymać projekt przy życiu i rozwijać go. Dziękujemy!', }, 'pt-BR': { donateTitle: 'Apoie o P3X Redis UI', donateDescription: 'O P3X Redis UI é um projeto gratuito e de código aberto. Os custos de manutenção do aplicativo, recursos de IA, imagens Docker, servidores e infraestrutura saem do bolso do desenvolvedor. Se você acha esta ferramenta útil, considere apoiar seu desenvolvimento contínuo com uma doação. Cada contribuição ajuda a manter o projeto vivo e crescendo. Obrigado!', }, 'pt-PT': { donateTitle: 'Apoie o P3X Redis UI', donateDescription: 'O P3X Redis UI é um projeto gratuito e de código aberto. Os custos de manutenção da aplicação, funcionalidades de IA, imagens Docker, servidores e infraestrutura saem do bolso do programador. Se considera esta ferramenta útil, por favor considere apoiar o seu desenvolvimento contínuo com um donativo. Cada contribuição ajuda a manter o projeto vivo e em crescimento. Obrigado!', }, ro: { donateTitle: 'Susțineți P3X Redis UI', donateDescription: 'P3X Redis UI este un proiect gratuit, open-source. Costurile de întreținere a aplicației, funcțiilor AI, imaginilor Docker, serverelor și infrastructurii vin din buzunarul dezvoltatorului. Dacă considerați acest instrument util, vă rugăm să luați în considerare susținerea dezvoltării sale continue cu o donație. Fiecare contribuție ajută proiectul să rămână viu și în creștere. Mulțumim!', }, ru: { donateTitle: 'Поддержите P3X Redis UI', donateDescription: 'P3X Redis UI — бесплатный проект с открытым исходным кодом. Расходы на обслуживание приложения, функции ИИ, образы Docker, серверы и инфраструктуру оплачиваются из собственного кармана разработчика. Если вы считаете этот инструмент полезным, пожалуйста, поддержите его дальнейшее развитие пожертвованием. Каждый вклад помогает проекту жить и расти. Спасибо!', }, si: { donateTitle: 'P3X Redis UI සඳහා සහාය වන්න', donateDescription: 'P3X Redis UI නිදහස්, විවෘත මූලාශ්‍ර ව්‍යාපෘතියකි. යෙදුම, AI විශේෂාංග, Docker පින්තූර, සේවාදායක සහ යටිතල පහසුකම් නඩත්තු කිරීමේ වියදම් සංවර්ධකයාගේ සාක්කුවෙන් පැමිණේ. ඔබට මෙම මෙවලම ප්‍රයෝජනවත් යැයි හැඟේ නම්, කරුණාකර පරිත්‍යාගයක් මගින් එහි අඛණ්ඩ සංවර්ධනයට සහාය දීම සලකා බලන්න. සෑම දායකත්වයක්ම ව්‍යාපෘතිය ජීවමාන හා වර්ධනය වෙමින් පවත්වා ගැනීමට උපකාරී වේ. ස්තූතියි!', }, sk: { donateTitle: 'Podporte P3X Redis UI', donateDescription: 'P3X Redis UI je bezplatný open-source projekt. Náklady na údržbu aplikácie, AI funkcie, Docker obrazy, servery a infraštruktúru hradí vývojár z vlastného vrecka. Ak vám tento nástroj pomáha, zvážte prosím podporu jeho ďalšieho vývoja príspevkom. Každý príspevok pomáha udržať projekt živý a rastúci. Ďakujeme!', }, sl: { donateTitle: 'Podprite P3X Redis UI', donateDescription: 'P3X Redis UI je brezplačen odprtokodni projekt. Stroški vzdrževanja aplikacije, funkcij AI, Docker slik, strežnikov in infrastrukture prihajajo iz žepa razvijalca. Če se vam zdi to orodje koristno, razmislite o podpori njegovega nadaljnjega razvoja z donacijo. Vsak prispevek pomaga ohranjati projekt živ in rastoč. Hvala!', }, sr: { donateTitle: 'Подржите P3X Redis UI', donateDescription: 'P3X Redis UI је бесплатан пројекат отвореног кода. Трошкови одржавања апликације, AI функција, Docker слика, сервера и инфраструктуре долазе из џепа програмера. Ако вам је овај алат користан, размислите о подршци даљем развоју донацијом. Сваки допринос помаже да пројекат живи и расте. Хвала!', }, sv: { donateTitle: 'Stöd P3X Redis UI', donateDescription: 'P3X Redis UI är ett gratis projekt med öppen källkod. Kostnaderna för underhåll av appen, AI-funktioner, Docker-images, servrar och infrastruktur kommer ur utvecklarens egen ficka. Om du tycker att detta verktyg är användbart, överväg att stödja dess fortsatta utveckling med en donation. Varje bidrag hjälper projektet att leva och växa. Tack!', }, sw: { donateTitle: 'Saidia P3X Redis UI', donateDescription: 'P3X Redis UI ni mradi wa bure na wa chanzo huria. Gharama za kudumisha programu, vipengele vya AI, picha za Docker, seva na miundombinu zinatoka mfukoni mwa msanidi programu. Ikiwa unapata chombo hiki kuwa muhimu, tafadhali fikiria kusaidia maendeleo yake yanayoendelea kwa mchango. Kila mchango husaidia mradi kuendelea kuishi na kukua. Asante!', }, ta: { donateTitle: 'P3X Redis UI-ஐ ஆதரிக்கவும்', donateDescription: 'P3X Redis UI ஒரு இலவச, திறந்த மூல திட்டமாகும். செயலி, AI அம்சங்கள், Docker படங்கள், சேவையகங்கள் மற்றும் உள்கட்டமைப்பை பராமரிக்கும் செலவுகள் டெவலப்பரின் சொந்த பணத்தில் இருந்து வருகின்றன. இந்த கருவி உங்களுக்கு பயனுள்ளதாக இருந்தால், நன்கொடை மூலம் அதன் தொடர்ச்சியான வளர்ச்சியை ஆதரிக்கவும். ஒவ்வொரு பங்களிப்பும் திட்டத்தை உயிருடன் வளர்ந்து கொண்டிருக்க உதவுகிறது. நன்றி!', }, tg: { donateTitle: 'P3X Redis UI-ро дастгирӣ кунед', donateDescription: 'P3X Redis UI як лоиҳаи ройгон ва кушодаасос аст. Хароҷот барои нигоҳдории барнома, хусусиятҳои AI, тасвирҳои Docker, серверҳо ва инфрасохтор аз ҷайби шахсии таҳиягар меоянд. Агар шумо ин абзорро муфид меёбед, лутфан дастгирии рушди давомдори онро тавассути хайрия баррасӣ кунед. Ҳар як саҳм ба зинда ва рушдёбандаи лоиҳа кӯмак мекунад. Ташаккур!', }, th: { donateTitle: 'สนับสนุน P3X Redis UI', donateDescription: 'P3X Redis UI เป็นโปรเจกต์ฟรีและโอเพ่นซอร์ส ค่าใช้จ่ายในการดูแลแอป ฟีเจอร์ AI อิมเมจ Docker เซิร์ฟเวอร์ และโครงสร้างพื้นฐาน มาจากกระเป๋าของนักพัฒนาเอง หากคุณพบว่าเครื่องมือนี้มีประโยชน์ โปรดพิจารณาสนับสนุนการพัฒนาอย่างต่อเนื่องด้วยการบริจาค ทุกการสนับสนุนช่วยให้โปรเจกต์มีชีวิตและเติบโต ขอบคุณ!', }, tr: { donateTitle: 'P3X Redis UI\'yi Destekleyin', donateDescription: 'P3X Redis UI ücretsiz, açık kaynaklı bir projedir. Uygulamanın, AI özelliklerinin, Docker görüntülerinin, sunucuların ve altyapının bakım maliyetleri geliştiricinin kendi cebinden karşılanmaktadır. Bu aracı faydalı buluyorsanız, lütfen bir bağışla süregelen gelişimini desteklemeyi düşünün. Her katkı projenin yaşamasına ve büyümesine yardımcı olur. Teşekkürler!', }, uk: { donateTitle: 'Підтримайте P3X Redis UI', donateDescription: 'P3X Redis UI — безкоштовний проєкт з відкритим кодом. Витрати на підтримку додатку, функцій AI, образів Docker, серверів та інфраструктури оплачуються з власної кишені розробника. Якщо ви вважаєте цей інструмент корисним, будь ласка, підтримайте його подальший розвиток пожертвою. Кожен внесок допомагає проєкту жити та рости. Дякуємо!', }, vi: { donateTitle: 'Hỗ trợ P3X Redis UI', donateDescription: 'P3X Redis UI là một dự án miễn phí, mã nguồn mở. Chi phí bảo trì ứng dụng, tính năng AI, Docker image, máy chủ và cơ sở hạ tầng đều từ túi tiền riêng của nhà phát triển. Nếu bạn thấy công cụ này hữu ích, vui lòng cân nhắc hỗ trợ sự phát triển liên tục của nó bằng một khoản quyên góp. Mọi đóng góp đều giúp dự án sống và phát triển. Cảm ơn bạn!', }, 'zh-HK': { donateTitle: '支持 P3X Redis UI', donateDescription: 'P3X Redis UI 是一個免費的開源項目。應用程式、AI 功能、Docker 映像、伺服器和基礎設施的維護成本均來自開發者自掏腰包。如果您覺得這個工具有用,請考慮通過捐款支持其持續開發。每一份貢獻都有助於保持項目的活力和成長。謝謝!', }, 'zh-TW': { donateTitle: '支持 P3X Redis UI', donateDescription: 'P3X Redis UI 是一個免費的開源專案。應用程式、AI 功能、Docker 映像檔、伺服器和基礎設施的維護成本均來自開發者自掏腰包。如果您覺得這個工具有用,請考慮透過捐款支持其持續開發。每一份貢獻都有助於保持專案的活力和成長。謝謝!', }, zn: { donateTitle: '支持 P3X Redis UI', donateDescription: 'P3X Redis UI 是一个免费的开源项目。应用程序、AI 功能、Docker 镜像、服务器和基础设施的维护成本均来自开发者自掏腰包。如果您觉得这个工具有用,请考虑通过捐款支持其持续开发。每一份贡献都有助于保持项目的活力和发展。谢谢!', }, } function injectEntries(filePath, entries) { let content = fs.readFileSync(filePath, 'utf8') // Find "donate:" in the title section const marker = 'donate:' const markerIdx = content.indexOf(marker) if (markerIdx === -1) return false // Find end of that line const lineEnd = content.indexOf('\n', markerIdx) if (lineEnd === -1) return false // Check if already injected if (content.includes('donateTitle:')) return 'skip' // Ensure trailing comma const existingLine = content.substring(markerIdx, lineEnd) let updatedLine = existingLine if (!existingLine.trimEnd().endsWith(',')) { updatedLine = existingLine.trimEnd() + ',' } const newLines = Object.entries(entries) .map(([k, v]) => { const escaped = v.replace(/\\/g, '\\\\').replace(/"/g, '\\"') return ` ${k}: "${escaped}",` }) .join('\n') const before = content.substring(0, markerIdx) const after = content.substring(lineEnd) content = before + updatedLine + '\n' + newLines + after fs.writeFileSync(filePath, content) return true } let count = 0 for (const lang of Object.keys(TRANSLATIONS)) { const filePath = path.join(STRINGS_DIR, lang, 'strings.js') if (!fs.existsSync(filePath)) { console.warn(`SKIP ${lang}: file not found`) continue } const result = injectEntries(filePath, TRANSLATIONS[lang]) if (result === 'skip') { console.log(`SKIP ${lang}: already done`) } else if (result) { console.log(`OK ${lang}`) count++ } else { console.warn(`FAIL ${lang}: marker not found`) } } console.log(`\nDone: ${count} languages updated`) scripts/screenshots.mjs000066400000000000000000000106721517660044600156370ustar00rootroot00000000000000import { chromium } from 'playwright' import { mkdirSync, rmSync } from 'fs' import { resolve, dirname } from 'path' import { fileURLToPath } from 'url' const __dirname = dirname(fileURLToPath(import.meta.url)) const outDir = resolve(__dirname, '../../redis-ui/artifacts/preview-images') for (let i = 0; i <= 20; i++) { try { rmSync(resolve(outDir, i === 0 ? 'preview.png' : `preview-${i + 1}.png`)) } catch {} } mkdirSync(outDir, { recursive: true }) const BASE = process.env.SCREENSHOT_URL || 'https://p3x.redis.patrikx3.com' const WIDTH = 1280 const HEIGHT = 800 async function shot(page, index, label) { const name = index === 0 ? 'preview' : `preview-${index + 1}` await page.screenshot({ path: resolve(outDir, `${name}.png`), fullPage: false }) console.log(` ${name}.png — ${label}`) } async function switchTheme(page, themeName) { const themeBtn = page.locator('button').filter({ hasText: 'THEME' }).first() await themeBtn.click({ force: true }) await page.waitForTimeout(1000) const item = page.locator('[role="menuitem"]').filter({ hasText: themeName }).first() await item.click({ force: true }) await page.waitForTimeout(1500) await page.keyboard.press('Escape') await page.waitForTimeout(500) } async function clickNav(page, label) { await page.locator(`a, button`).filter({ hasText: label }).first().click({ force: true }) await page.waitForTimeout(5000) } async function run() { const browser = await chromium.launch({ headless: true }) const context = await browser.newContext({ viewport: { width: WIDTH, height: HEIGHT } }) const page = await context.newPage() // Connect console.log('Connecting...') await page.goto(`${BASE}/ng/settings`, { waitUntil: 'networkidle', timeout: 30000 }) await page.waitForFunction(() => !document.getElementById('p3xr-loading'), { timeout: 15000 }).catch(() => {}) await page.waitForTimeout(5000) await page.locator('button').nth(10).click({ force: true }) await page.waitForTimeout(8000) console.log('Connected!\n') // 1. Database + tree + key click (Dark) console.log('1. Database + Key (Dark)') await clickNav(page, 'DATABASE') await page.waitForTimeout(5000) const node = page.locator('.p3xr-tree-node-label').first() if (await node.isVisible({ timeout: 8000 }).catch(() => false)) { await node.click() await page.waitForTimeout(3000) } await shot(page, 0, 'Database + Key (Dark)') // 2. Monitoring Pulse (Matrix) console.log('2. Monitoring Pulse (Matrix)') await switchTheme(page, 'Matrix') await clickNav(page, 'MONITORING') await page.waitForTimeout(10000) await shot(page, 1, 'Monitoring Pulse (Matrix)') // 3. Console with commands (Enterprise) console.log('3. Console (Enterprise)') await switchTheme(page, 'Enterprise') await clickNav(page, 'DATABASE') await page.waitForTimeout(4000) const input = page.locator('#p3xr-console-input') if (await input.isVisible({ timeout: 5000 }).catch(() => false)) { await input.fill('PING') await input.press('Enter') await page.waitForTimeout(1500) await input.fill('INFO server') await input.press('Enter') await page.waitForTimeout(2000) } await shot(page, 2, 'Console (Enterprise)') // 4. Settings (Dark enterprise) console.log('4. Settings (Dark enterprise)') await switchTheme(page, 'Dark enterprise') await clickNav(page, 'SETTINGS') await page.waitForTimeout(4000) await shot(page, 3, 'Settings (Dark enterprise)') // 5. Info page (Darko bluo) console.log('5. Info (Darko bluo)') await switchTheme(page, 'Darko bluo') await clickNav(page, 'INFO') await page.waitForTimeout(4000) await shot(page, 4, 'Info (Darko bluo)') // 6. Database + Statistics tab (Redis theme) console.log('6. Statistics (Redis)') await switchTheme(page, 'Redis') await clickNav(page, 'DATABASE') await page.waitForTimeout(4000) const statsBtn = page.locator('button').filter({ hasText: 'STATISTICS' }).first() if (await statsBtn.isVisible({ timeout: 3000 }).catch(() => false)) { await statsBtn.click({ force: true }) await page.waitForTimeout(3000) } await shot(page, 5, 'Statistics (Redis)') await page.close() await context.close() await browser.close() console.log(`\nDone! 6 screenshots saved to: ${outDir}`) } run().catch(e => { console.error(e); process.exit(1) }) src/000077500000000000000000000000001517660044600116565ustar00rootroot00000000000000src/builder/000077500000000000000000000000001517660044600133045ustar00rootroot00000000000000src/builder/webpack.config.js000066400000000000000000000207651517660044600165340ustar00rootroot00000000000000const path = require('path'); const config = require('corifeus-builder/src/utils/config').config const webpack = require('webpack'); const { AngularWebpackPlugin } = require('@ngtools/webpack'); const HtmlWebpackPlugin = require('html-webpack-plugin'); const TerserPlugin = require('terser-webpack-plugin') const MiniCssExtractPlugin = require('mini-css-extract-plugin'); const CleanWebpackPlugin = require('clean-webpack-plugin').CleanWebpackPlugin; const minimize = process.argv.includes('--mode=production'); const mode = minimize ? 'production' : 'development'; const useStats = process.env.hasOwnProperty('WEBPACK_STATS') const filenamePrefix = minimize ? '[id].[contenthash]' : '[name]' let minimizer = undefined; const top = process.cwd() const buildDir = top + `/dist`; let devtool; devtool = minimize ? false : 'source-map'; const pkg = require('../../package') // Note: 'unsafe-eval' is required for Angular JIT compiler (used during development / hybrid ngUpgrade mode). // Once fully migrated to Angular AOT compilation, 'unsafe-eval' can be removed. const cspPolicy = "default-src 'self'; script-src 'self' https://www.googletagmanager.com 'unsafe-inline' 'unsafe-eval'; worker-src 'self' blob:; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self' data:; connect-src 'self' ws: wss: http://localhost:* http://127.0.0.1:* https://www.google-analytics.com https://region1.google-analytics.com; frame-src https://redis.io; object-src 'none'; base-uri 'self'; form-action 'self'" // https://github.com/webpack-contrib/webpack-hot-middleware/tree/master/example /* https://stackoverflow.com/questions/44317394/webpack-dev-server-with-hot-reload-reloading-entire-page-with-css-changes 'webpack-dev-server/client?http://localhost:8080', 'webpack/hot/only-dev-server', */ const vendorEntry = [ top + "/src/vendor.js" ] const mainEntry = [ top + (minimize ? "/src/main.js" : '/src/main-development.js') ] const entry = { vendor: vendorEntry, main: mainEntry, // editor: editorEntry, } if (!minimize) { vendorEntry.push('webpack/hot/only-dev-server') vendorEntry.unshift('webpack-dev-server/client?http://localhost:8080/') } const plugins = [ new HtmlWebpackPlugin({ template: `${top}/src/index.html`, inject: 'head', scriptLoading: 'defer', chunks: ['vendor', 'main'], title: pkg.description, minify: minimize }), new MiniCssExtractPlugin({ // Options similar to the same options in webpackOptions.output // both options are optional filename: !minimize ? '[name].css' : '[id].[contenthash].css', chunkFilename: !minimize ? '[name].css' : '[id].[contenthash].css', }), ]; if (useStats) { const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin; plugins.push( new BundleAnalyzerPlugin() ) } if (minimize) { plugins.unshift( new CleanWebpackPlugin() ) devtool = false; const bannerText = require('corifeus-builder').utils.license(); const CssMinimizerPlugin = require('css-minimizer-webpack-plugin'); minimizer = [ new CssMinimizerPlugin(), new TerserPlugin({ parallel: true, extractComments: { condition: /^\**!|@preserve|@license|@cc_on/, filename: function (fileOptions) { return `${fileOptions.filename}.LICENSE.txt`; }, banner: function (webpackBanner) { return ` ${bannerText} For more information about all licenses, please see ${webpackBanner} `; } }, terserOptions: { compress: { warnings: false }, ecma: config.ecma, // todo found out if mangle use or not // mangle: false === keep function names // mangle: true === drop function names // for mangle true we are using angularjs-annotate with babel mangle: true, }, }), ] plugins.push( new webpack.BannerPlugin({ banner: bannerText, include: /\.css$/, exclude: /\.ts$|\.js$/, // hash:[hash], chunkhash:[chunkhash], name:[name], filebase:[filebase], query:[query], file:[file] }) ) /* https://webpack.js.org/guides/build-performance/#source-maps plugins.push( new webpack.SourceMapDevToolPlugin({ filename: 'sourcemaps/[file].map', append: '\n//# sourceMappingURL=./[url]' }) ) */ } // Inject API port for test/dev override (P3XR_API_PORT env var) plugins.push( new webpack.DefinePlugin({ P3XR_API_PORT: JSON.stringify(parseInt(process.env.P3XR_API_PORT || '7843')), }) ) // Angular AOT compilation — eliminates @angular/compiler from the bundle (~1MB savings) plugins.push( new AngularWebpackPlugin({ tsconfig: path.resolve(__dirname, '../../tsconfig.json'), jitMode: false, }) ) const rules = [ { test: /\.[cm]?js$/, include: /node_modules/, resolve: { fullySpecified: false }, use: { loader: 'babel-loader', options: { compact: false, plugins: [ '@angular/compiler-cli/linker/babel', ], }, }, }, { test: /\.[jt]sx?$/, exclude: [/node_modules/, /src\/react/], loader: '@ngtools/webpack', }, { test: /\.(scss|css)$/, // exclude: [`${cwd}/src/assets/ngivr.scss`], use: [ { loader: MiniCssExtractPlugin.loader, options: { }, }, 'css-loader', 'sass-loader', ], }, { test: /\.html$/i, use: [{ loader: 'html-loader', options: { minimize: minimize, esModule: false, }, }] }, { test: /\.(png|jpe?g|gif|ico)$/, type: 'asset/resource', }, { test: /\.woff(\?v=\d+\.\d+\.\d+)?$/, type: 'asset/resource', }, { test: /\.woff2(\?v=\d+\.\d+\.\d+)?$/, type: 'asset/resource', }, { test: /\.ttf(\?v=\d+\.\d+\.\d+)?$/, type: 'asset/resource', }, { test: /\.eot(\?v=\d+\.\d+\.\d+)?$/, type: 'asset/resource', }, { test: /\.svg(\?v=\d+\.\d+\.\d+)?$/, type: 'asset/resource', }, ] let optimization = { minimize: minimize, minimizer: minimizer, } if (minimize) { } else { optimization = Object.assign(optimization, { runtimeChunk: 'single', splitChunks: { cacheGroups: { vendor: { test: /[\\/]node_modules[\\/]/, name: 'vendor-modules', chunks: 'all', }, }, }, }) } const webpackConfig = { // watch: true, devtool: devtool, entry: entry, output: { path: buildDir, filename: `${filenamePrefix}.js`, // chunkFilename: `${filenamePrefix}.js`, // publicPath: '{{ app.url_subdir }}/webpack/', publicPath: `/ng/`, assetModuleFilename: 'assets/[hash][ext]', }, resolve: { extensions: ['.ts', '.js'], }, module: { rules: rules }, optimization: optimization, plugins: plugins, mode: mode, devServer: { headers: { 'Content-Security-Policy': cspPolicy, }, static: { directory: './src/public', staticOptions: {}, publicPath: "/ng/", serveIndex: true, watch: true, }, host: '0.0.0.0', historyApiFallback: { rewrites: [ {from: /^\/ng\/.*/, to: '/ng/index.html'}, {from: /.*/, to: '/ng/index.html'}, ] }, setupMiddlewares: (middlewares, devServer) => { devServer.app.get('/', (req, res) => res.redirect('/ng/')); return middlewares; }, // hotOnly: true, client: { overlay: false, }, }, } webpackConfig.ignoreWarnings = [/Failed to parse source map/]; // Ignore the React/MUI port directory to prevent hot-reload loops when Vite is running webpackConfig.watchOptions = { ignored: [ '**/src/react/**', path.resolve(top, 'dist-react') + '/**', ], }; module.exports = webpackConfig src/core/000077500000000000000000000000001517660044600126065ustar00rootroot00000000000000src/core/detect-language.js000066400000000000000000000035551517660044600162050ustar00rootroot00000000000000/** * Auto-detect the best matching UI language from the browser/system locale. * Used by both Angular and React i18n services. */ const AVAILABLE_LANGUAGES = [ 'ar', 'az', 'be', 'bg', 'bn', 'bs', 'cs', 'da', 'de', 'el', 'en', 'es', 'et', 'fi', 'fil', 'fr', 'he', 'hr', 'hu', 'hy', 'id', 'it', 'ja', 'ka', 'kk', 'km', 'ko', 'ky', 'lt', 'mk', 'ms', 'ne', 'nl', 'no', 'pl', 'pt-BR', 'pt-PT', 'ro', 'ru', 'si', 'sk', 'sl', 'sr', 'sv', 'sw', 'ta', 'tg', 'th', 'tr', 'uk', 'vi', 'zh-HK', 'zh-TW', 'zn', ] function detectLanguageFromLocale(locale) { if (!locale) return 'en' const raw = locale.trim() const lower = raw.toLowerCase() // Chinese special cases (must come before generic matching) if (lower.startsWith('zh')) { if (lower === 'zh-hk') return 'zh-HK' if (lower === 'zh-tw' || lower.startsWith('zh-hant')) return 'zh-TW' // zh, zh-CN, zh-Hans, zh-SG → simplified Chinese return 'zn' } // Try exact match (case-insensitive against available) const exactMatch = AVAILABLE_LANGUAGES.find(l => l.toLowerCase() === lower) if (exactMatch) return exactMatch // Try with region (e.g., pt-BR → pt-BR) const withRegion = AVAILABLE_LANGUAGES.find(l => l.toLowerCase() === lower.replace('_', '-')) if (withRegion) return withRegion // Extract base language (e.g., fr-FR → fr, de-DE → de) const base = lower.split('-')[0].split('_')[0] // Portuguese without region → pt-PT if (base === 'pt') return 'pt-PT' // Filipino/Tagalog if (base === 'tl') return 'fil' // Norwegian variants if (base === 'nb' || base === 'nn') return 'no' // Try base language match const baseMatch = AVAILABLE_LANGUAGES.find(l => l.toLowerCase() === base) if (baseMatch) return baseMatch return 'en' } export { detectLanguageFromLocale, AVAILABLE_LANGUAGES } src/core/translation-loader.js000066400000000000000000000147331517660044600167560ustar00rootroot00000000000000/** * Standalone translation storage and lazy-loading. * * Decoupled from the p3xr global. Both main.js (pre-Angular) and I18nService * (Angular) import from this module. */ const translations = {} function getTranslations() { return translations } /** * Lazily load a translation chunk. Each case produces a separate webpack chunk * (~25 KiB each) loaded only when needed. */ function loadTranslation(lang) { if (translations[lang]) { return Promise.resolve(translations[lang]) } let loader switch (lang) { case 'ar': loader = import(/* webpackChunkName: "i18n-ar" */ '../strings/ar/strings'); break case 'az': loader = import(/* webpackChunkName: "i18n-az" */ '../strings/az/strings'); break case 'be': loader = import(/* webpackChunkName: "i18n-be" */ '../strings/be/strings'); break case 'bg': loader = import(/* webpackChunkName: "i18n-bg" */ '../strings/bg/strings'); break case 'bn': loader = import(/* webpackChunkName: "i18n-bn" */ '../strings/bn/strings'); break case 'cs': loader = import(/* webpackChunkName: "i18n-cs" */ '../strings/cs/strings'); break case 'da': loader = import(/* webpackChunkName: "i18n-da" */ '../strings/da/strings'); break case 'de': loader = import(/* webpackChunkName: "i18n-de" */ '../strings/de/strings'); break case 'el': loader = import(/* webpackChunkName: "i18n-el" */ '../strings/el/strings'); break case 'es': loader = import(/* webpackChunkName: "i18n-es" */ '../strings/es/strings'); break case 'et': loader = import(/* webpackChunkName: "i18n-et" */ '../strings/et/strings'); break case 'fi': loader = import(/* webpackChunkName: "i18n-fi" */ '../strings/fi/strings'); break case 'fil': loader = import(/* webpackChunkName: "i18n-fil" */ '../strings/fil/strings'); break case 'fr': loader = import(/* webpackChunkName: "i18n-fr" */ '../strings/fr/strings'); break case 'he': loader = import(/* webpackChunkName: "i18n-he" */ '../strings/he/strings'); break case 'hr': loader = import(/* webpackChunkName: "i18n-hr" */ '../strings/hr/strings'); break case 'hu': loader = import(/* webpackChunkName: "i18n-hu" */ '../strings/hu/strings'); break case 'hy': loader = import(/* webpackChunkName: "i18n-hy" */ '../strings/hy/strings'); break case 'id': loader = import(/* webpackChunkName: "i18n-id" */ '../strings/id/strings'); break case 'it': loader = import(/* webpackChunkName: "i18n-it" */ '../strings/it/strings'); break case 'ja': loader = import(/* webpackChunkName: "i18n-ja" */ '../strings/ja/strings'); break case 'ka': loader = import(/* webpackChunkName: "i18n-ka" */ '../strings/ka/strings'); break case 'kk': loader = import(/* webpackChunkName: "i18n-kk" */ '../strings/kk/strings'); break case 'km': loader = import(/* webpackChunkName: "i18n-km" */ '../strings/km/strings'); break case 'ko': loader = import(/* webpackChunkName: "i18n-ko" */ '../strings/ko/strings'); break case 'ky': loader = import(/* webpackChunkName: "i18n-ky" */ '../strings/ky/strings'); break case 'lt': loader = import(/* webpackChunkName: "i18n-lt" */ '../strings/lt/strings'); break case 'mk': loader = import(/* webpackChunkName: "i18n-mk" */ '../strings/mk/strings'); break case 'ms': loader = import(/* webpackChunkName: "i18n-ms" */ '../strings/ms/strings'); break case 'ne': loader = import(/* webpackChunkName: "i18n-ne" */ '../strings/ne/strings'); break case 'nl': loader = import(/* webpackChunkName: "i18n-nl" */ '../strings/nl/strings'); break case 'no': loader = import(/* webpackChunkName: "i18n-no" */ '../strings/no/strings'); break case 'pl': loader = import(/* webpackChunkName: "i18n-pl" */ '../strings/pl/strings'); break case 'pt-BR': loader = import(/* webpackChunkName: "i18n-pt-BR" */ '../strings/pt-BR/strings'); break case 'pt-PT': loader = import(/* webpackChunkName: "i18n-pt-PT" */ '../strings/pt-PT/strings'); break case 'ro': loader = import(/* webpackChunkName: "i18n-ro" */ '../strings/ro/strings'); break case 'ru': loader = import(/* webpackChunkName: "i18n-ru" */ '../strings/ru/strings'); break case 'sk': loader = import(/* webpackChunkName: "i18n-sk" */ '../strings/sk/strings'); break case 'sl': loader = import(/* webpackChunkName: "i18n-sl" */ '../strings/sl/strings'); break case 'sr': loader = import(/* webpackChunkName: "i18n-sr" */ '../strings/sr/strings'); break case 'sv': loader = import(/* webpackChunkName: "i18n-sv" */ '../strings/sv/strings'); break case 'tg': loader = import(/* webpackChunkName: "i18n-tg" */ '../strings/tg/strings'); break case 'th': loader = import(/* webpackChunkName: "i18n-th" */ '../strings/th/strings'); break case 'tr': loader = import(/* webpackChunkName: "i18n-tr" */ '../strings/tr/strings'); break case 'uk': loader = import(/* webpackChunkName: "i18n-uk" */ '../strings/uk/strings'); break case 'vi': loader = import(/* webpackChunkName: "i18n-vi" */ '../strings/vi/strings'); break case 'zh-HK': loader = import(/* webpackChunkName: "i18n-zh-HK" */ '../strings/zh-HK/strings'); break case 'zh-TW': loader = import(/* webpackChunkName: "i18n-zh-TW" */ '../strings/zh-TW/strings'); break case 'zn': loader = import(/* webpackChunkName: "i18n-zn" */ '../strings/zn/strings'); break case 'bs': loader = import(/* webpackChunkName: "i18n-bs" */ '../strings/bs/strings'); break case 'si': loader = import(/* webpackChunkName: "i18n-si" */ '../strings/si/strings'); break case 'sw': loader = import(/* webpackChunkName: "i18n-sw" */ '../strings/sw/strings'); break case 'ta': loader = import(/* webpackChunkName: "i18n-ta" */ '../strings/ta/strings'); break default: return Promise.resolve(translations['en']) } return loader.then(m => { translations[lang] = m.default || m return translations[lang] }) } module.exports = { getTranslations, loadTranslation } src/index.html000066400000000000000000000124521517660044600136570ustar00rootroot00000000000000 P3X Redis UI
src/main-development.js000066400000000000000000000003741517660044600154640ustar00rootroot00000000000000global.p3xrDevMode = true console.log('-------------------------------------') console.log(' development mode ') console.log('-------------------------------------') if (module.hot) { module.hot.accept() } require('./main') src/main.js000066400000000000000000000037161517660044600131470ustar00rootroot00000000000000require('./scss/index.scss') // Capture Electron UI storage bootstrap data from URL query params BEFORE Angular's // router strips them during its initial redirect (e.g. / -> /settings). // Stored on globalThis.__p3xr_electron_bootstrap for SettingsComponent. 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.__p3xr_electron_bootstrap = parsed } } } catch (e) { // ignore — bootstrap data is optional } // Also try window.name (set by Electron shell via iframe.name before src is loaded) if (!globalThis.__p3xr_electron_bootstrap) { try { const fromName = window.name ? JSON.parse(window.name) : null if (fromName?.p3xreUiStorage && typeof fromName.p3xreUiStorage === 'object') { globalThis.__p3xr_electron_bootstrap = fromName.p3xreUiStorage } } catch (e) { // ignore } } // Translation loading — standalone module, no p3xr global const { getTranslations, loadTranslation } = require('./core/translation-loader') // English is always loaded synchronously — it is the required fallback for all other languages. getTranslations()['en'] = require('./strings/en/strings') // Read the language from localStorage or Electron bootstrap storage. let _initialLang = 'en' try { const electronLang = globalThis.__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(() => { require('./ng/main') }) src/main.scss000066400000000000000000000003531517660044600135000ustar00rootroot00000000000000@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/ng/000077500000000000000000000000001517660044600122625ustar00rootroot00000000000000src/ng/app.routes.ts000066400000000000000000000061721517660044600147400ustar00rootroot00000000000000import { 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/000077500000000000000000000000001517660044600144475ustar00rootroot00000000000000src/ng/components/confirm-dialog.component.ts000066400000000000000000000042261517660044600217160ustar00rootroot00000000000000import { 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.ts000066400000000000000000000036731517660044600230240ustar00rootroot00000000000000import { 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 || 'Cancel'; } } } src/ng/components/json-tree.component.ts000066400000000000000000000202741517660044600207330ustar00rootroot00000000000000import { 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/p3xr-accordion.component.ts000066400000000000000000000117461517660044600216640ustar00rootroot00000000000000import { 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.ts000066400000000000000000000075201517660044600212310ustar00rootroot00000000000000import { 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.ts000066400000000000000000000061121517660044600210510ustar00rootroot00000000000000import { 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: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/000077500000000000000000000000001517660044600137045ustar00rootroot00000000000000src/ng/dialogs/ai-settings-dialog.component.ts000066400000000000000000000117461517660044600217520ustar00rootroot00000000000000import { 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 || 'AI Settings' }}
{{ strings().label?.aiGroqApiKeyInfo || 'Optional. Your own Groq API key for better performance. Get a free key from' }} console.groq.com
{{ strings().label?.aiGroqApiKey || 'Groq API Key' }}
`, 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: 'validate-groq-api-key', payload: { apiKey: key }, }); if (!validation.valid) { this.common.toast({ message: this.strings().label?.aiGroqApiKeyInvalid || 'Invalid Groq API key' }); return; } } await this.socket.request({ action: '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 || 'AI settings saved' }); this.dialogRef.close(); } catch (e: any) { this.common.generalHandleError(e); } finally { this.saving = false; this.cdr.markForCheck(); } } } src/ng/dialogs/ai-settings-dialog.service.ts000066400000000000000000000014541517660044600214030ustar00rootroot00000000000000import { 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.ts000066400000000000000000000063631517660044600231760ustar00rootroot00000000000000import { 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: `
{{ strings().label?.askAuth || 'Authorization' }} {{ strings().form?.connection?.label?.username || 'Username' }} {{ strings().form?.connection?.label?.password || 'Password' }}
`, 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.ts000066400000000000000000000022211517660044600226210ustar00rootroot00000000000000import { 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.ts000066400000000000000000000122471517660044600225720ustar00rootroot00000000000000import { 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 || 'No results' }}
}
`, 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.ts000066400000000000000000000015731517660044600222300ustar00rootroot00000000000000import { 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.ts000066400000000000000000001073011517660044600216530ustar00rootroot00000000000000import { AfterViewInit, Component, Inject, NgZone, QueryList, ViewChild, ViewChildren } from '@angular/core'; import { CommonModule } from '@angular/common'; import { CdkTextareaAutosize, TextFieldModule } from '@angular/cdk/text-field'; import { FormsModule, NgForm } from '@angular/forms'; import { MatDialogModule, MatDialogRef, MAT_DIALOG_DATA } from '@angular/material/dialog'; import { MatFormFieldModule } from '@angular/material/form-field'; import { MatInputModule } from '@angular/material/input'; import { MatSelectModule } from '@angular/material/select'; import { MatSlideToggleModule } from '@angular/material/slide-toggle'; import { MatButtonModule } from '@angular/material/button'; import { MatIconModule } from '@angular/material/icon'; import { MatToolbarModule } from '@angular/material/toolbar'; import { DialogCancelButtonComponent } from '../components/dialog-cancel-button.component'; import { MatTooltipModule } from '@angular/material/tooltip'; import { MatAutocompleteModule } from '@angular/material/autocomplete'; import { I18nService } from '../services/i18n.service'; import { SocketService } from '../services/socket.service'; import { CommonService } from '../services/common.service'; import { AskAuthorizationDialogService } from './ask-authorization-dialog.service'; import { RedisStateService } from '../services/redis-state.service'; import { SettingsService } from '../services/settings.service'; import { OverlayService } from '../services/overlay.service'; export interface ConnectionDialogData { type: 'new' | 'edit'; model?: any; } /** * Connection dialog -- Angular replacement for p3xrDialogConnection. * Allows creating/editing Redis connections with support for SSH, TLS, * cluster, and sentinel modes. */ @Component({ selector: 'p3xr-connection-dialog', standalone: true, imports: [ CommonModule, FormsModule, TextFieldModule, MatDialogModule, MatFormFieldModule, MatInputModule, MatSelectModule, MatSlideToggleModule, MatButtonModule, MatIconModule, MatToolbarModule, MatTooltipModule, MatAutocompleteModule, DialogCancelButtonComponent, ], template: `
@if (readonlyConnections) { {{ strings().label?.connectiondView }} } @else if (options.type === 'new') { {{ strings().label?.connectiondAdd }} } @else { {{ strings().label?.connectiondEdit }} }
@if (model.id && options.type !== 'new') { {{ strings().label?.id?.id }}
{{ strings().label?.id?.info }}
} {{ strings().form?.connection?.label?.name }} @if (p3xrConnectionForm.controls['name']?.hasError('required') && p3xrConnectionForm.controls['name']?.touched) { {{ strings().form?.error?.required }} } {{ strings().form?.connection?.label?.group || 'Group' }} @for (g of existingGroups; track g) { {{ g }} } {{ model.ssh ? strings().label?.ssh?.on : strings().label?.ssh?.off }} @if (model.ssh) {
SSH {{ strings().label?.ssh?.sshHost }} @if (p3xrConnectionForm.controls['sshHost']?.hasError('required') && p3xrConnectionForm.controls['sshHost']?.touched) { {{ strings().form?.error?.required }} } {{ strings().label?.ssh?.sshPort }} @if (p3xrConnectionForm.controls['sshPort']?.hasError('required') && p3xrConnectionForm.controls['sshPort']?.touched) { {{ strings().form?.error?.required }} } {{ strings().label?.ssh?.sshUsername }} @if (p3xrConnectionForm.controls['sshUsername']?.hasError('required') && p3xrConnectionForm.controls['sshUsername']?.touched) { {{ strings().form?.error?.required }} } {{ strings().label?.ssh?.sshPassword }} @if (!readonlyConnections) { }
{{ strings().label?.passwordSecure }}
{{ strings().label?.ssh?.sshPrivateKey }}
{{ strings().label?.secureFeature }}

}

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


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

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

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

}
@if (!readonlyConnections) { }
`, styles: [` .md-block { width: 100%; } .p3xr-hide-xs { } .p3xr-show-xs { display: none; } @media (max-width: 699px) { .p3xr-hide-xs { display: none; } .p3xr-show-xs { display: inline; } } `], }) export class ConnectionDialogComponent implements AfterViewInit { @ViewChild('p3xrConnectionForm') formRef!: NgForm; @ViewChildren(CdkTextareaAutosize) autosizeTextareas!: QueryList; options: ConnectionDialogData; model: any; strings; existingGroups: string[] = []; groupEnabled = false; // Password visibility toggles passwordVisible = false; sshPasswordVisible = false; nodePasswordVisible: Record = {}; // Readonly connections mode from global state get readonlyConnections(): boolean { return !!this.state.cfg()?.readonlyConnections; } constructor( @Inject(MatDialogRef) private dialogRef: MatDialogRef, @Inject(MAT_DIALOG_DATA) private data: ConnectionDialogData, @Inject(I18nService) private i18n: I18nService, @Inject(SocketService) private socketService: SocketService, @Inject(CommonService) private commonService: CommonService, @Inject(AskAuthorizationDialogService) private askAuthDialogService: AskAuthorizationDialogService, @Inject(NgZone) private ngZone: NgZone, @Inject(RedisStateService) private state: RedisStateService, @Inject(SettingsService) private settings: SettingsService, @Inject(OverlayService) private overlay: OverlayService, ) { this.strings = this.i18n.strings; this.options = data; this.model = this.initModel(data); // Collect existing group names for autocomplete const connections = this.state.connections()?.list || []; const groups = new Set(); for (const conn of connections) { if (conn.group && typeof conn.group === 'string' && conn.group.trim()) { groups.add(conn.group.trim()); } } this.existingGroups = [...groups].sort(); this.groupEnabled = !!this.model.group?.trim(); } onGroupToggle(): void { if (!this.groupEnabled) { this.model.group = undefined; } } ngAfterViewInit(): void { this.scheduleTextareaResize(); this.autosizeTextareas.changes.subscribe(() => this.scheduleTextareaResize()); } scheduleTextareaResize(): void { this.ngZone.runOutsideAngular(() => { requestAnimationFrame(() => { requestAnimationFrame(() => { this.autosizeTextareas?.forEach((textarea) => textarea.resizeToFitContent(true)); }); }); }); } private initModel(data: ConnectionDialogData): any { let model: any; if (data.model !== undefined) { model = structuredClone(data.model); // For existing connections, set sensitive fields to the model id // (server-side resolves these by id) model.password = data.model.id; model.tlsCrt = data.model.id; model.tlsKey = data.model.id; model.tlsCa = data.model.id; model.sshPassword = data.model.id; model.sshPrivateKey = data.model.id; } else { model = { name: undefined, host: undefined, port: 6379, askAuth: false, password: undefined, username: undefined, id: undefined, group: undefined, readonly: undefined, tlsWithoutCert: false, tlsRejectUnauthorized: false, tlsCrt: undefined, tlsKey: undefined, tlsCa: undefined, }; } // Ensure SSH fields exist if (!model.hasOwnProperty('ssh')) { model = { ...model, ssh: false, sshHost: undefined, sshPort: 22, sshUsername: undefined, sshPassword: data.model?.id, sshPrivateKey: data.model?.id, }; } if (!model.hasOwnProperty('cluster')) { model.cluster = false; } if (!model.hasOwnProperty('sentinel')) { model.sentinel = false; } if (!model.hasOwnProperty('nodes')) { model.nodes = []; } // For existing nodes, set password to node id (server-side resolves) for (const node of model.nodes) { node.password = node.id; } return model; } // --- Cluster/Sentinel mutual exclusion --- onClusterChange(): void { if (this.model.cluster === true) { this.model.sentinel = false; } } onSentinelChange(): void { if (this.model.sentinel === true) { this.model.cluster = false; } } // --- Node management --- addNode(index?: number): void { const newNode = { host: undefined, port: undefined, password: undefined, username: undefined, id: this.settings.generateId(), }; if (index === undefined) { this.model.nodes.push(newNode); } else { this.model.nodes.splice(index + 1, 0, newNode); } } async removeNode(ev: Event, index: number): Promise { try { await this.commonService.confirm({ event: ev, message: this.strings().confirm?.deleteConnectionText, }); this.model.nodes.splice(index, 1); this.commonService.toast({ message: this.strings().status?.nodeRemoved, }); } catch (e) { if (e === undefined) { return; } this.commonService.generalHandleError(e); } } // --- Form validation --- private handleInvalidForm(): boolean { if (this.formRef && this.formRef.invalid) { this.commonService.toast({ message: this.strings().form?.error?.invalid, }); return false; } return true; } // --- Test connection --- async testConnection($event: Event): Promise { // Mark form as submitted to trigger validation display if (this.formRef) { Object.keys(this.formRef.controls).forEach(key => { this.formRef.controls[key].markAsTouched(); }); } if (!this.handleInvalidForm()) { return; } try { const authModel = structuredClone(this.model); if (this.model.askAuth === true) { const auth = await this.askAuthDialogService.show({ $event: $event, }); authModel.username = undefined; authModel.password = undefined; if (auth.username) { authModel.username = auth.username; } if (auth.password) { authModel.password = auth.password; } } this.overlay.show({ message: this.strings().title?.connectingRedis, }); const response = await this.socketService.request({ action: 'redis-test-connection', payload: { model: authModel, }, }); console.warn('response', response); this.commonService.toast({ message: this.strings().status?.redisConnected, }); } catch (e) { this.commonService.generalHandleError(e); } finally { this.overlay.hide(); } } // --- Save --- async submit(): Promise { if (!this.handleInvalidForm()) { return; } if (this.model.host === undefined) { this.model.host = 'localhost'; } if (this.model.port === undefined) { this.model.port = 6379; } if (this.options.type === 'new') { this.model.id = this.settings.generateId(); } for (const node of this.model.nodes) { if (node.host === undefined) { node.host = 'localhost'; } if (node.id === undefined) { node.id = this.settings.generateId(); } } try { const saveModel = structuredClone(this.model); // Trim group name to avoid inconsistencies if (typeof saveModel.group === 'string') { saveModel.group = saveModel.group.trim() || undefined; } await this.socketService.request({ action: 'connection-save', payload: { model: saveModel, }, }); this.commonService.toast({ message: this.options.type === 'new' ? this.strings().status?.added : this.strings().status?.saved, }); this.dialogRef.close(undefined); } catch (e) { this.commonService.generalHandleError(e); } } // --- Cancel --- cancel(): void { this.dialogRef.close(undefined); } } src/ng/dialogs/connection-dialog.service.ts000066400000000000000000000033001517660044600213030ustar00rootroot00000000000000import { 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.ts000066400000000000000000000051641517660044600166620ustar00rootroot00000000000000import { 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/json-editor-dialog.component.ts000066400000000000000000000275021517660044600217550ustar00rootroot00000000000000import { 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'; 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 || 'JSON Editor' }} @if (isJson) {
} @else { {{ strings().label?.jsonViewNotParsable || 'Not valid JSON' }} } @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, ) { 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 : []), }); } } save(format: boolean): void { 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); 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.ts000066400000000000000000000032251517660044600214070ustar00rootroot00000000000000import { 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: true, 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.ts000066400000000000000000000077521517660044600214460ustar00rootroot00000000000000import { 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: ` table_chart {{ strings().intention?.jsonViewShow || 'JSON View' }} @if (isJson) { } @if (isJson) { } @else {
{{ strings().label?.jsonViewNotParsable || 'Not valid JSON' }}
}
`, 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.ts000066400000000000000000000017371517660044600211010ustar00rootroot00000000000000import { 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.ts000066400000000000000000000121271517660044600216150ustar00rootroot00000000000000import { 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 || 'Import Keys' }}
{{ strings().label?.importPreview || 'Preview' }} ({{ data.keys.length }})
{{ entry.key }} {{ strings().redisTypes?.[entry.type] || entry.type }}
{{ strings().label?.importConflict || 'If key already exists:' }}
{{ strings().label?.importOverwrite || 'Overwrite' }} {{ strings().label?.importSkip || 'Skip' }}
`, 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.ts000066400000000000000000000020731517660044600212520ustar00rootroot00000000000000import { 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: true, 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.ts000066400000000000000000000650311517660044600223050ustar00rootroot00000000000000import { 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'; 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 || 'Key' }} @if (keyForm.controls['key']?.invalid && keyForm.controls['key']?.touched) { {{ strings().form?.key?.error?.key }} } {{ strings().form?.key?.field?.type || 'Type' }} @for (t of types; track t) { {{ strings().redisTypes?.[t] || t }} } @switch (model.type) { @case ('list') { {{ strings().form?.key?.field?.index || 'Index' }}
{{ strings().label?.redisListIndexInfo }}
} @case ('hash') { {{ strings().form?.key?.field?.hashKey || 'Hash Key' }} @if (keyForm.controls['hashKey']?.invalid && keyForm.controls['hashKey']?.touched) { {{ strings().form?.key?.error?.hashKey }} } } @case ('zset') { {{ strings().form?.key?.field?.score || 'Score' }} @if (keyForm.controls['score']?.invalid && keyForm.controls['score']?.touched) { {{ strings().form?.key?.error?.score }} } } @case ('stream') { {{ strings().form?.key?.field?.streamTimestamp || 'Timestamp' }} @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 || 'Retention' }} (ms) {{ strings().page?.key?.timeseries?.retentionHint || '0 = no expiry, or milliseconds' }} {{ strings().page?.key?.timeseries?.duplicatePolicy || 'Duplicate policy' }} LAST FIRST MIN MAX SUM BLOCK } {{ strings().page?.key?.timeseries?.labels || 'Labels' }} {{ strings().page?.key?.timeseries?.labelsHint || 'key1 value1 key2 value2' }} @if (!model.tsBulkMode) { {{ strings().page?.key?.timeseries?.timestamp || 'Timestamp' }} {{ strings().page?.key?.timeseries?.timestampHint || "'*' means auto generated, or milliseconds timestamp" }} } @if (model.originalTimestamp === undefined) { {{ strings().page?.key?.timeseries?.bulkMode || 'Bulk generate' }} } } } @if (model.type !== 'stream' && model.type !== 'timeseries') { } @if (model.type !== 'timeseries') { }
@if (model.type !== 'timeseries') { {{ strings().label?.validateJson || 'Validate JSON' }} @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 || 'Auto * spread' }} 1 {{ strings().time?.second || 'second' }} 30 {{ strings().time?.seconds || 'seconds' }} 1 {{ strings().time?.minute || 'minute' }} 30 {{ strings().time?.minutes || 'minutes' }} 1 {{ strings().time?.hour || 'hour' }} 24 {{ strings().time?.hours || 'hours' }} {{ strings().page?.key?.timeseries?.formula || 'Formula' }} {{ strings().page?.key?.timeseries?.none || 'None' }} sin cos {{ strings().page?.key?.timeseries?.formulaLinear || 'Linear' }} {{ strings().page?.key?.timeseries?.formulaRandom || 'Random' }} {{ strings().page?.key?.timeseries?.formulaSawtooth || 'Sawtooth' }}
@if (model.tsFormula) {
{{ strings().page?.key?.timeseries?.formulaPoints || 'Points' }} {{ strings().page?.key?.timeseries?.formulaAmplitude || 'Amplitude' }} {{ strings().page?.key?.timeseries?.formulaOffset || 'Offset' }}
} {{ strings().page?.key?.timeseries?.dataPoints || 'data points' }} {{ strings().page?.key?.timeseries?.editAllHint || 'One data point per line: timestamp value (timestamp can be * for auto)' }} @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 || 'Value' }} @if (keyForm.controls['value']?.invalid && keyForm.controls['value']?.touched) { {{ strings().form?.key?.error?.value }} } } @else { {{ strings().form?.key?.field?.value || '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'); } return base; } 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, ) { 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: '*', tsTimestamp: '*', tsRetention: 0, tsDuplicatePolicy: 'LAST', tsLabels: '', tsBulkMode: false, tsSpread: 60000, tsFormula: '', tsFormulaPoints: 25, tsFormulaAmplitude: 100, tsFormulaOffset: 0, hashKey: undefined, index: undefined, }; 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 || 'Edit Key'; if (this.options.type === 'append') return s.form?.key?.label?.formName?.append || 'Append'; return s.form?.key?.label?.formName?.add || 'Add Key'; } 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 || 'Copied'); } 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 || 'Not valid JSON'); } } 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 || 'Upload buffer?' }); const arrayBuffer = await file.arrayBuffer(); this.model.value = arrayBuffer; this.isBuffer = true; this.common.toast(this.strings().confirm?.uploadBufferDone || 'Buffer uploaded'); } 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 || 'Key cannot be empty'); return; } if (this.validateJson) { try { JSON.parse(this.model.value); } catch (e) { this.common.toast(this.strings().label?.jsonViewNotParsable || 'Not valid JSON'); return; } } try { 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 || 'Saved'); 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.ts000066400000000000000000000023461517660044600217430ustar00rootroot00000000000000import { 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: true, 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.ts000066400000000000000000000065611517660044600210430ustar00rootroot00000000000000import { 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.ts000066400000000000000000000431171517660044600237160ustar00rootroot00000000000000import { 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 || 'Redis Settings' }}
@if (reducedFunctions) {
{{ strings().form?.treeSettings?.keyCount?.({ keyCount: keysRawLength }) }}
{{ strings().label?.tooManyKeys?.({ count: keysRawLength, maxLightKeysCount: settings.maxLightKeysCount }) }}
}
{{ strings().form?.treeSettings?.field?.treeSeparator || 'Tree separator' }}
{{ strings().label?.treeSeparatorEmpty }}
{{ strings().form?.treeSettings?.field?.page || 'Page size' }} @if (isFieldInvalid('pageCount', 10, 5000)) {
{{ strings().form?.treeSettings?.error?.page || 'The page count must be an integer between 10 - 5000' }}
}
{{ strings().form?.treeSettings?.field?.keyPageCount || 'Key page size' }} @if (isFieldInvalid('keyPageCount', 5, 100)) {
{{ strings().form?.treeSettings?.error?.keyPageCount }}
}
{{ strings().form?.treeSettings?.maxValueDisplay || 'Max value display' }} @if (isFieldInvalid('maxValueDisplay', -1, 32768)) {
{{ strings().form?.treeSettings?.error?.maxValueDisplay }}
} @else {
{{ strings().form?.treeSettings?.maxValueDisplayInfo }}
}
{{ strings().form?.treeSettings?.maxKeys || 'Max keys' }} @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 }}
`, 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(), }; // 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 || 'Invalid form', }); 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 || 'Please fix the errors before saving', }); 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.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.ts000066400000000000000000000020551517660044600233500ustar00rootroot00000000000000import { 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.ts000066400000000000000000000121461517660044600203210ustar00rootroot00000000000000import { 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'; const timestring = require('timestring'); const humanizeDuration = require('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 || 'TTL' }}
{{ strings().confirm?.ttl?.textContent }}
{{ strings().confirm?.ttl?.placeholder || 'TTL (seconds)' }} {{ strings().confirm?.ttl?.convertTextToTime || 'Duration' }}
`, 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.ts000066400000000000000000000022131517660044600177510ustar00rootroot00000000000000import { 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/000077500000000000000000000000001517660044600135775ustar00rootroot00000000000000src/ng/layout/layout.component.html000066400000000000000000000314071517660044600200100ustar00rootroot00000000000000
@if (isWide) { } @else { } @if (currentConnection) { @if (isWide) { } @else { } } @if (currentConnection) { @if (isWide) { } @else { } } @if (currentConnection && hasRediSearch) { @if (isWide) { } @else { } } @if (isWide) { } @else { } @if (isWide) { } @else { }
@if (currentVersion && isWide) {
{{ currentVersion }}
}
src/ng/layout/layout.component.scss000066400000000000000000000047461517660044600200250ustar00rootroot00000000000000// 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; } // 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; } #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.ts000066400000000000000000000520771517660044600175000ustar00rootroot00000000000000import { 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'; // Side-effect: webpack processes the SCSS through sass-loader → css-loader → MiniCssExtractPlugin require('./layout.component.scss'); /** * 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, ], schemas: [CUSTOM_ELEMENTS_SCHEMA], templateUrl: './layout.component.html', 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, ) {} @HostListener('document:keydown', ['$event']) onKeydown(event: KeyboardEvent): void { this.shortcuts.handleKeydown(event); } ngOnInit(): void { // Remove the loading splash shown before Angular bootstraps document.getElementById('p3xr-loading')?.remove(); // Initialize filtered languages list this.filterLanguages(); // 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); // Auto-connect from localStorage on startup const savedConnection = this.readConnectionFromStorage(); if (savedConnection) { this.connect(savedConnection); } // 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); } 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 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/'; } 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: 'set-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')); 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.ts000066400000000000000000000035211517660044600135570ustar00rootroot00000000000000import 'zone.js'; import { bootstrapApplication } from '@angular/platform-browser'; import { importProvidersFrom, enableProdMode, isDevMode } 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'; // Enable Angular production mode when webpack builds in production mode. // This disables dev-only assertion checks (NG0100, "Should be run in update mode"). if (process.env.NODE_ENV === 'production') { enableProdMode(); } 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 { RedisStateService } = require('./services/redis-state.service'); const { SettingsService } = require('./services/settings.service'); 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/000077500000000000000000000000001517660044600133615ustar00rootroot00000000000000src/ng/pages/console/000077500000000000000000000000001517660044600150235ustar00rootroot00000000000000src/ng/pages/console/console.component.html000066400000000000000000000124661517660044600213650ustar00rootroot00000000000000
@if (type !== 'quick') { {{ strings().label?.console || 'Console' }} @if (isAiGloballyEnabled()) { {{ aiAutoDetect ? 'check_box' : 'check_box_outline_blank' }} Auto AI } } @else { {{ embedded ? (strings().label?.console || 'Console') : (strings().intention?.quickConsole || 'Quick Console') }} @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.scss000066400000000000000000000124211517660044600213630ustar00rootroot00000000000000p3xr-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; } 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 8px; } .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: 4px; 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; 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-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.ts000066400000000000000000000721631517660044600210470ustar00rootroot00000000000000import { 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'; require('./console.component.scss'); const htmlEncode = (globalThis as any).htmlEncode; 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', 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: '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 { 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 const info = this.state.info() || {}; const redisContext: any = { indexes }; if (info.redis_version) redisContext.redisVersion = info.redis_version; if (info.redis_mode) redisContext.redisMode = info.redis_mode; if (info.os) redisContext.os = info.os; if (info.connected_clients) redisContext.connectedClients = info.connected_clients; if (info.used_memory_human) redisContext.usedMemory = info.used_memory_human; if (info.db0 || info.db1) redisContext.databases = Object.keys(info).filter((k: string) => /^db\d+$/.test(k)).map((k: string) => `${k}: ${info[k]}`); if (info.modules) redisContext.modules = info.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 || 'AI rate limit reached. Try again later or use your own Groq API key in Settings.'); } else { this.common.toast((this.i18n.strings().page?.key?.label?.aiError || 'AI query failed') + ': ' + 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 debounce = require('lodash/debounce'); 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/000077500000000000000000000000001517660044600151255ustar00rootroot00000000000000src/ng/pages/database/database-header.component.ts000066400000000000000000000256331517660044600225010ustar00rootroot00000000000000import { 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 || 'P3X Redis UI' }}

} @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.html000066400000000000000000000207561517660044600223600ustar00rootroot00000000000000@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 || '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') {
{{ strings()?.label?.format || 'Format' }}: Raw JSON Hex Base64
}
@switch (response.type) { @case ('string') { } @case ('list') { } @case ('hash') { } @case ('set') { } @case ('zset') { } @case ('stream') { } @case ('json') { } @case ('timeseries') { } } } src/ng/pages/database/database-key.component.scss000066400000000000000000000056131517660044600223620ustar00rootroot00000000000000.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; .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.ts000066400000000000000000000361221517660044600220340ustar00rootroot00000000000000import { 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 { NavigationService } from '../../services/navigation.service'; import { RedisStateService } from '../../services/redis-state.service'; import { SettingsService } from '../../services/settings.service'; require('./database-key.component.scss'); require('./key/key-types.scss'); @Component({ selector: 'p3xr-database-key', standalone: true, imports: [ CommonModule, FormsModule, MatButtonModule, MatIconModule, MatTooltipModule, MatProgressSpinnerModule, MatButtonToggleModule, KeyStringComponent, KeyHashComponent, KeyListComponent, KeySetComponent, KeyZsetComponent, KeyStreamComponent, KeyJsonComponent, KeyTimeseriesComponent, ], schemas: [CUSTOM_ELEMENTS_SCHEMA], templateUrl: './database-key.component.html', 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; 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: '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: '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 decodeValueBuffer(response: any): void { const { type, valueBuffer } = response; const td = new TextDecoder(); switch (type) { case 'string': response.value = td.decode(valueBuffer); break; case 'list': case 'set': response.value = valueBuffer.map((buf: any) => td.decode(buf)); break; case 'hash': response.value = {}; Object.entries(valueBuffer).forEach(([key, buf]: [string, any]) => { response.value[key] = td.decode(buf); }); break; case 'zset': response.value = []; for (let i = 0; i < valueBuffer.length; i += 2) { response.value.push(td.decode(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(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(valueBuffer)); } catch { response.value = {}; } 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 humanizeDuration = require('humanize-duration'); 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="${(globalThis as any).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.html000066400000000000000000000115361517660044600225230ustar00rootroot00000000000000
@if (node.expandable) { } @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.scss000066400000000000000000000064031517660044600225270ustar00rootroot00000000000000// 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 } // Redis type icons .p3xr-database-treecontrol-node-icon { display: inline-block; min-width: 12px; text-align: center; } // 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; } // 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; margin-left: 4px; cursor: default; } .p3xr-tree-ttl-icon { font-size: 16px; width: 16px; height: 16px; } .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.ts000066400000000000000000000447071517660044600222130ustar00rootroot00000000000000import { 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'; require('./database-tree.component.scss'); 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', 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 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.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.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.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 humanizeDuration = require('humanize-duration'); 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(); } // --- 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: '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: '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 (globalThis as any).htmlEncode((strings.redisTypes?.[node.keysInfo.type] ?? node.keysInfo.type) + ' - ' + node.key); } return (globalThis as any).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() ?? {}; 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.html000066400000000000000000000177201517660044600257660ustar00rootroot00000000000000
@if (treeDividers.length > 0) { @for (divider of treeDividers; track divider) { } }
@if (pages > 1) { / {{ pages }} } @else { {{ keyCountText() }}  } src/ng/pages/database/database-treecontrol-controls.component.scss000066400000000000000000000111101517660044600257600ustar00rootroot00000000000000: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.ts000066400000000000000000000366421517660044600254540ustar00rootroot00000000000000import { Component, Input, Inject, OnInit, OnDestroy, NgZone, ElementRef, ChangeDetectorRef, ChangeDetectionStrategy, CUSTOM_ELEMENTS_SCHEMA } 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'; require('./database-treecontrol-controls.component.scss'); @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', 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 || 'Search keys') : (searchStrings?.placeholderServer || 'Search keys on server'); } 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 || 'No keys to export' }); return; } try { this.overlay.show({ message: this.strings().label?.exportProgress || 'Exporting keys...', }); 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 || 'Export complete' }); } 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 || 'No keys found in file' }); 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 || 'Importing keys...', }); 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 || 'Deleting matching keys...', }); const response = await this.socket.request({ action: '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.html000066400000000000000000000034271517660044600215660ustar00rootroot00000000000000
src/ng/pages/database/database.component.scss000066400000000000000000000046111517660044600215710ustar00rootroot00000000000000@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.ts000066400000000000000000000443541517660044600212540ustar00rootroot00000000000000import { Component, Inject, OnInit, OnDestroy, NgZone, ChangeDetectorRef, CUSTOM_ELEMENTS_SCHEMA } 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'; require('./database.component.scss'); const debounce = require('lodash/debounce'); @Component({ selector: 'p3xr-database', standalone: true, imports: [ CommonModule, RouterModule, DatabaseHeaderComponent, DatabaseTreecontrolControlsComponent, DatabaseTreeComponent, ConsoleComponent, ], schemas: [CUSTOM_ELEMENTS_SCHEMA], templateUrl: './database.component.html', styles: [` :host { display: block; } `], }) 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 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; 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'); } 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/000077500000000000000000000000001517660044600157155ustar00rootroot00000000000000src/ng/pages/database/key/key-hash.component.html000066400000000000000000000037501517660044600223220ustar00rootroot00000000000000
{{ strings?.page?.key?.hash?.table?.hashkey || 'Hash Key' }} {{ strings?.page?.key?.hash?.table?.value || 'Value' }} @if (!isReadonly) { }
@for (item of pagedItems; track item.key) {
{{ item.key }} {{ formatValue(truncateDisplay(item.value)) }}@if (isTruncated(item.value)) {...} @if (!isReadonly) { delete } table_chart content_copy download @if (!isReadonly) { edit }
}
src/ng/pages/database/key/key-hash.component.ts000066400000000000000000000112411517660044600217760ustar00rootroot00000000000000import { 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 { KeyPaging } from './key-paging'; import { KeyPagerInlineComponent } from './key-pager-inline.component'; @Component({ selector: 'p3xr-key-hash', standalone: true, imports: [CommonModule, FormsModule, MatButtonModule, MatIconModule, MatTooltipModule, KeyPagerInlineComponent], schemas: [CUSTOM_ELEMENTS_SCHEMA], templateUrl: './key-hash.component.html', encapsulation: ViewEncapsulation.None, }) export class KeyHashComponent extends KeyTypeBase implements OnInit, OnChanges { paging: KeyPaging; pagedItems: Array<{ key: string; 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(changes: SimpleChanges): void { if (changes['p3xrValue']) this.updatePaging(); } 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); } } 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}.bin`); } } src/ng/pages/database/key/key-json.component.html000066400000000000000000000075621517660044600223550ustar00rootroot00000000000000
@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.ts000066400000000000000000000115231517660044600220270ustar00rootroot00000000000000import { 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'; @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, ) { 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 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: value, }, }); this.gtag('/key-json-set'); this.common.toast(this.strings?.status?.set || 'Saved'); this.refreshKey(); } catch (e) { if (e) { this.common.generalHandleError(e); } } finally { 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.html000066400000000000000000000036561517660044600223570ustar00rootroot00000000000000
{{ strings?.page?.key?.list?.table?.index || 'Index' }} {{ strings?.page?.key?.list?.table?.value || 'Value' }} @if (!isReadonly) { }
@for (item of pagedItems; track item.index) {
{{ item.index }} {{ formatValue(truncateDisplay(item.value)) }}@if (isTruncated(item.value)) {...} @if (!isReadonly) { delete } table_chart content_copy download @if (!isReadonly) { edit }
}
src/ng/pages/database/key/key-list.component.ts000066400000000000000000000111751517660044600220340ustar00rootroot00000000000000import { 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 { 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], 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.ts000066400000000000000000000074211517660044600234320ustar00rootroot00000000000000import { 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.ts000066400000000000000000000027731517660044600203310ustar00rootroot00000000000000import { 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); 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-set.component.html000066400000000000000000000032601517660044600221660ustar00rootroot00000000000000
{{ strings?.page?.key?.set?.table?.value || 'Member' }} @if (!isReadonly) { }
@for (item of pagedItems; track item.index) {
{{ formatValue(truncateDisplay(item.value)) }}@if (isTruncated(item.value)) {...} @if (!isReadonly) { delete } table_chart content_copy download @if (!isReadonly) { edit }
}
src/ng/pages/database/key/key-set.component.ts000066400000000000000000000111101517660044600216410ustar00rootroot00000000000000import { 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 { 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], 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.html000066400000000000000000000046571517660044600227010ustar00rootroot00000000000000
{{ strings?.page?.key?.stream?.table?.timestamp || 'Timestamp ID' }} @if (!isReadonly) { }
@for (entry of pagedEntries; track entry.id) {
{{ entry.id }} {{ showTimestamp(entry.id) }} content_copy download table_chart @if (!isReadonly) { delete }
@for (field of entry.fields; track field[0]) {
{{ field[0] }} {{ formatValue(truncateDisplay(field[1])) }}@if (isTruncated(field[1])) {...}
}
}
src/ng/pages/database/key/key-stream.component.ts000066400000000000000000000222451517660044600223540ustar00rootroot00000000000000import { 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 { 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], 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 || 'Field'; const valueLabel = this.strings?.page?.key?.stream?.table?.value || '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 || 'Deleted'); this.refreshKey(); } catch (e) { if (e) this.common.generalHandleError(e); } } } src/ng/pages/database/key/key-string.component.html000066400000000000000000000174331517660044600227100ustar00rootroot00000000000000 @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 (!isReadonly) { @if (isGtSm) { } @else { } }
} @if (editable) {
@if (!isReadonly) { {{ strings?.label?.validateJson || 'Validate JSON' }} } @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 {
{{ formatValue(truncateDisplay(p3xrValue)) }}@if (isTruncated(p3xrValue)) {...}
}
src/ng/pages/database/key/key-string.component.ts000066400000000000000000000166471517660044600224000ustar00rootroot00000000000000import { 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 { OverlayService } from '../../../services/overlay.service'; @Component({ selector: 'p3xr-key-string', standalone: true, imports: [CommonModule, FormsModule, MatButtonModule, MatIconModule, MatTooltipModule, MatSlideToggleModule, MatInputModule, MatFormFieldModule, TextFieldModule], 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, ) { super(i18n, socket, common, jsonViewDialog, keyNewOrSetDialog, breakpointObserver, cmd, cdr, redisState, settingsService); } ngOnInit(): void {} 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; try { if (this.validateJson) { JSON.parse(valueToSave); } 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(); } catch (e) { this.common.generalHandleError(e); } finally { 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 result = await this.jsonEditorDialog.show({ value: this.p3xrValue }); this.p3xrValue = result.obj; await this.save(); } 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.html000066400000000000000000000312441517660044600235470ustar00rootroot00000000000000

@if (!isReadonly) { } @if (!autoRefresh) { }
{{ strings?.page?.key?.timeseries?.from || 'From (ms or -)' }} {{ strings?.page?.key?.timeseries?.to || 'To (ms or +)' }} {{ strings?.page?.key?.timeseries?.aggregation || 'Aggregation' }} {{ strings?.page?.key?.timeseries?.none || 'None' }} @for (agg of aggregationTypes; track agg) { {{ agg }} } @if (aggregationType) { {{ strings?.page?.key?.timeseries?.timeBucket || 'Bucket (ms)' }} } {{ strings?.page?.key?.timeseries?.overlay || 'Overlay keys' }} {{ strings?.page?.key?.timeseries?.mrangeFilter || 'Label filter' }}
{{ rangeData.length }} {{ strings?.page?.key?.timeseries?.dataPoints || 'data points' }}
@if (!isReadonly) {
{{ strings?.page?.key?.timeseries?.timestamp || 'Timestamp' }} {{ strings?.page?.key?.timeseries?.value || 'Value' }} @if (isGtSm) { } @else { }
}
@if (rangeData.length > 0) {
{{ strings?.page?.key?.timeseries?.timestamp || 'Timestamp' }} {{ strings?.page?.key?.timeseries?.value || 'Value' }} @if (!isReadonly) { }
{{ formatTimestamp(point.timestamp) }} {{ point.value }} @if (!isReadonly) { delete edit }
}
@if (!isReadonly) {
}
@if (alterMode) {
{{ strings?.page?.key?.timeseries?.retention || 'Retention' }} (ms) {{ strings?.page?.key?.timeseries?.retentionHint || '0 = no expiry, or milliseconds' }} {{ strings?.page?.key?.timeseries?.duplicatePolicy || 'Duplicate policy' }} LAST FIRST MIN MAX SUM BLOCK   {{ strings?.page?.key?.timeseries?.labels || 'Labels' }} {{ strings?.page?.key?.timeseries?.labelsHint || 'key1 value1 key2 value2' }}
} @for (item of infoLabels; track item.key) {
{{ item.key }}{{ item.value }}
} @if (tsLabels.length > 0) {
{{ strings?.page?.key?.timeseries?.labels || 'Labels' }}
@for (label of tsLabels; track label.key) {
{{ label.key }}{{ label.value }}
} } @if (tsRules.length > 0) {
{{ strings?.page?.key?.timeseries?.rules || 'Rules' }}
@for (rule of tsRules; track rule.destKey) {
{{ rule.destKey }}{{ rule.aggregationType }} / {{ rule.bucketDuration }}ms
} }
src/ng/pages/database/key/key-timeseries.component.ts000066400000000000000000000562701517660044600232370ustar00rootroot00000000000000import { 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 = 'LAST'; 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(); this.langCheckInterval = setInterval(() => { const currentLang = this.i18n.currentLang(); 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 || 'Updated'); 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 || '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 || '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 || '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 || '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() || 'en', { 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.ts000066400000000000000000000132121517660044600207430ustar00rootroot00000000000000import { Input, Directive, ChangeDetectorRef } from '@angular/core'; 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 || 'Copied'); } downloadBuffer(buffer: any, filename?: string): void { const blob = new Blob([buffer]); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = filename || `${this.p3xrKey}.bin`; 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 'hex': { const encoder = new TextEncoder(); const encoded = encoder.encode(str); const lines: string[] = []; for (let i = 0; i < encoded.length; i += 16) { const chunk = encoded.slice(i, i + 16); const addr = i.toString(16).padStart(8, '0'); const hexPart = Array.from(chunk).map(b => b.toString(16).padStart(2, '0')).join(' '); lines.push(`${addr} ${hexPart}`); } return lines.join('\n'); } 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.scss000066400000000000000000000103501517660044600205430ustar00rootroot00000000000000// 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, matching AngularJS overflow:auto + max-height:200px .p3xr-value-col { overflow: auto !important; max-height: 200px; white-space: pre-wrap; word-break: break-all; user-select: text; } } .p3xr-key-type-row-actions { white-space: nowrap !important; mat-icon { cursor: pointer; font-size: 18px; width: 18px; height: 18px; 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-zset.component.html000066400000000000000000000035541517660044600223660ustar00rootroot00000000000000
{{ strings?.page?.key?.zset?.table?.score || 'Score' }} {{ strings?.page?.key?.zset?.table?.value || 'Member' }} @if (!isReadonly) { }
@for (item of pagedItems; track item.index) {
{{ item.score }} {{ formatValue(truncateDisplay(item.member)) }}@if (isTruncated(item.member)) {...} @if (!isReadonly) { delete } table_chart content_copy download @if (!isReadonly) { edit }
}
src/ng/pages/database/key/key-zset.component.ts000066400000000000000000000116341517660044600220460ustar00rootroot00000000000000import { 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 { 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], 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.html000066400000000000000000000030421517660044600222050ustar00rootroot00000000000000 @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.scss000066400000000000000000000035131517660044600222170ustar00rootroot00000000000000// 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-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.ts000066400000000000000000000135061517660044600216750ustar00rootroot00000000000000import { 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'; require('./statistics.component.scss'); @Component({ selector: 'p3xr-database-statistics', standalone: true, imports: [MatTabsModule], schemas: [CUSTOM_ELEMENTS_SCHEMA], templateUrl: './statistics.component.html', 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 }); } } } // 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.ts000066400000000000000000000170441517660044600166730ustar00rootroot00000000000000import { 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' }}
{{ version }}
@if (isConnected) {
{{ strings().label?.redisVersion || 'Redis Version' }}
{{ redisVersion }}
} @if (isConnected && modules.length > 0) {
{{ strings().label?.modules || 'Modules' }}
{{ modules.join(', ') }}
}
{{ strings().title?.donate || 'Donate' }}
{{ strings().intention?.githubChangelog || 'Changelog' }}

@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/000077500000000000000000000000001517660044600155465ustar00rootroot00000000000000src/ng/pages/monitoring/memory-analysis.component.html000066400000000000000000000253101517660044600235670ustar00rootroot00000000000000@if (loading && !data) {
hourglass_empty {{ s().running || 'Analyzing...' }}
} @if (!loading && !data) {
analytics {{ s().noData || 'No data. Click Run Analysis to start.' }}
} @if (data) {
{{ s().keysScanned || 'Keys Scanned' }}
{{ data.totalScanned | number }} / {{ data.dbSize | number }}
{{ s().topN || 'Top N' }}
{{ s().maxScanKeys || 'Max Scan Keys' }}

{{ s().totalMemory || 'Total Memory' }}
{{ data.memoryInfo.usedHuman }}
{{ s().rssMemory || 'RSS Memory' }}
{{ data.memoryInfo.rssHuman }}
{{ s().peakMemory || 'Peak Memory' }}
{{ data.memoryInfo.peakHuman }}
{{ s().overheadMemory || 'Overhead' }}
{{ formatBytes(data.memoryInfo.overhead) }}
{{ s().datasetMemory || 'Dataset' }}
{{ formatBytes(data.memoryInfo.dataset) }}
{{ s().luaMemory || 'Lua Memory' }}
{{ formatBytes(data.memoryInfo.lua) }}
{{ s().fragmentation || 'Fragmentation' }}
{{ data.memoryInfo.fragRatio }}x
{{ s().allocator || '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 || 'With TTL' }}
{{ data.expirationOverview.withTTL | number }}
{{ s().persistent || 'Persistent' }}
{{ data.expirationOverview.persistent | number }}
{{ s().avgTTL || 'Average TTL' }}
{{ formatTTL(data.expirationOverview.avgTTL) }}
} src/ng/pages/monitoring/memory-analysis.component.scss000066400000000000000000000011131517660044600235710ustar00rootroot00000000000000p3xr-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.ts000066400000000000000000000323341517660044600232550ustar00rootroot00000000000000import { Component, Inject, OnInit, OnDestroy, ChangeDetectorRef, ChangeDetectionStrategy, ElementRef, ViewChild, AfterViewInit, NgZone } 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'; require('./memory-analysis.component.scss'); @Component({ selector: 'p3xr-memory-analysis', standalone: true, imports: [ CommonModule, FormsModule, MatIconModule, MatButtonModule, MatTooltipModule, MatDividerModule, MatListModule, P3xrAccordionComponent, P3xrButtonComponent, P3xrInputComponent, ], templateUrl: './memory-analysis.component.html', 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 }> = []; @ViewChild('typeChart') typeChartRef!: ElementRef; @ViewChild('prefixChart') prefixChartRef!: ElementRef; private unsubFns: Array<() => void> = []; private boundRecalcHost: (() => void) | 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.runAnalysis(); const sub = this.socket.stateChanged$.subscribe(() => { this.data = null; this.runAnalysis(); }); this.unsubFns.push(() => sub.unsubscribe()); } ngAfterViewInit(): void { this.ngZone.runOutsideAngular(() => { this.boundRecalcHost = () => { clearTimeout(this.resizeTimer); this.resizeTimer = setTimeout(() => { if (this.data) this.drawCharts(); }, 150); }; window.addEventListener('resize', this.boundRecalcHost); }); 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.themeObserver?.disconnect(); if (this.boundRecalcHost) { window.removeEventListener('resize', this.boundRecalcHost); } 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); } } 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 humanizeDuration = require('humanize-duration'); 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 || 'Keys Scanned'}: ${this.data.totalScanned} / ${this.data.dbSize}`, `${t.topN || 'Top N'}: ${this.topN}`, `${t.maxScanKeys || 'Max Scan Keys'}: ${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 || 'Total'}: ${m.usedHuman}`, `${t.rssMemory || 'RSS'}: ${m.rssHuman}`, `${t.peakMemory || 'Peak'}: ${m.peakHuman}`, `${t.overheadMemory || 'Overhead'}: ${this.formatBytes(m.overhead)}`, `${t.datasetMemory || 'Dataset'}: ${this.formatBytes(m.dataset)}`, `${t.luaMemory || 'Lua'}: ${this.formatBytes(m.lua)}`, `${t.fragmentation || 'Fragmentation'}: ${m.fragRatio}x`, `${t.allocator || '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 || 'With TTL'}: ${e.withTTL}`, `${t.persistent || 'Persistent'}: ${e.persistent}`, `${t.avgTTL || 'Average TTL'}: ${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) 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.ts000066400000000000000000000155251517660044600226610ustar00rootroot00000000000000import { 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 = () => 'en'; 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: 'set-monitor', payload: { enabled: true } }); this.profilerStarted = true; this.socket.getClient()?.on?.('monitor-data', this.onMonitorData); } stopProfiler(): void { if (!this.profilerStarted) return; this.socket.request({ action: 'set-monitor', 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: 'set-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: 'set-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.scss000066400000000000000000000010111517660044600237270ustar00rootroot00000000000000@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.ts000066400000000000000000000106451517660044600234170ustar00rootroot00000000000000import { 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'; require('./monitoring-shell.component.scss'); @Component({ selector: 'p3xr-monitoring-shell', standalone: true, imports: [RouterOutlet, MatTabsModule], 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.html000066400000000000000000000365001517660044600226260ustar00rootroot00000000000000@if (!current) {
hourglass_empty {{ strings().label?.loading || 'Loading...' }}
} @if (current) {
Redis {{ current.server.version }} · {{ current.server.mode }}
{{ uptimeFormatted }}
{{ strings().page?.monitor?.memory || 'Memory' }}
{{ current.memory.usedHuman }}
{{ strings().page?.monitor?.rss || 'RSS' }}
{{ current.memory.rssHuman }}
{{ strings().page?.monitor?.peak || 'Peak' }}
{{ current.memory.peakHuman }}
{{ strings().page?.monitor?.fragmentation || 'Fragmentation' }}
{{ current.memory.fragRatio }}x
{{ strings().page?.monitor?.opsPerSec || 'Ops/sec' }}
{{ current.stats.opsPerSec }}
{{ strings().page?.monitor?.totalCommands || 'Total Commands' }}
{{ current.stats.totalCommands }}
{{ strings().page?.monitor?.clients || 'Clients' }}
{{ current.clients.connected }}
{{ strings().page?.monitor?.blocked || 'Blocked' }}
{{ current.clients.blocked }}
{{ strings().page?.monitor?.hitsMisses || 'Hit Rate' }}
{{ current.stats.hitRate }}%
{{ strings().page?.monitor?.hitsAndMisses || 'Hits / Misses' }}
{{ current.stats.hits }} / {{ current.stats.misses }}
{{ strings().page?.monitor?.networkIo || 'Network I/O' }}
{{ current.stats.inputKbps | number:'1.1-1' }} / {{ current.stats.outputKbps | number:'1.1-1' }} KB/s
{{ strings().page?.monitor?.expired || 'Expired' }}
{{ current.stats.expiredKeys }}
{{ strings().page?.monitor?.evicted || 'Evicted' }}
{{ current.stats.evictedKeys }}




@if (current.slowlog.length > 0) {
@for (entry of current.slowlog; track entry.id) {
{{ entry.duration }}µs {{ entry.command }}
}
}
@if (!autoRefreshClients) { }
@if (clientList.length === 0 && clientListLoaded) {
{{ strings().page?.monitor?.noClients || 'No clients' }}
} @if (clientList.length === 0 && !clientListLoaded) {
{{ strings().label?.loading || '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 || 'No keys' }}
} @if (topKeys.length === 0 && !topKeysLoaded) {
{{ strings().label?.loading || 'Loading...' }}
} @if (topKeys.length > 0) { @for (entry of topKeys; track entry.key; let i = $index) {
#{{ i + 1 }} {{ entry.key }}
{{ formatBytes(entry.bytes) }}
}
}
} src/ng/pages/monitoring/monitoring.component.scss000066400000000000000000000034571517660044600226420ustar00rootroot00000000000000p3xr-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.ts000066400000000000000000001312371517660044600223130ustar00rootroot00000000000000import { Component, Inject, OnInit, OnDestroy, ChangeDetectorRef, ChangeDetectionStrategy, ElementRef, ViewChild, AfterViewInit, NgZone } from '@angular/core'; import { CommonModule } from '@angular/common'; 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'; 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'; require('./monitoring.component.scss'); 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, MatIconModule, MatButtonModule, MatTooltipModule, MatDividerModule, MatListModule, P3xrAccordionComponent, P3xrButtonComponent, ], templateUrl: './monitoring.component.html', 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; @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.fetchData(); this.loadClientList(); this.loadTopKeys(); // Reload all data when connection changes const sub = this.socket.stateChanged$.subscribe(() => { this.isReadonly = this.state.connection()?.readonly === 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); 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 || 'Resume') : (this.strings().intention?.pause || '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 || 'Are you sure to kill this client?', }); await this.socket.request({ action: 'client-kill', payload: { id } }); this.common.toast({ message: this.strings().page?.monitor?.clientKilled || 'Client killed' }); await this.loadClientList(); } catch (e) { if (e !== undefined) this.common.generalHandleError(e); } } 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; } 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 || 'Memory'}: ${c.memory.usedHuman}`, `${mon.rss || 'RSS'}: ${c.memory.rssHuman}`, `${mon.peak || 'Peak'}: ${c.memory.peakHuman}`, `${mon.fragmentation || 'Fragmentation'}: ${c.memory.fragRatio}x`, `${mon.opsPerSec || 'Ops/sec'}: ${c.stats.opsPerSec}`, `${mon.totalCommands || 'Total'}: ${c.stats.totalCommands}`, `${mon.clients || 'Clients'}: ${c.clients.connected}`, `${mon.blocked || 'Blocked'}: ${c.clients.blocked}`, `${mon.hitsMisses || 'Hit Rate'}: ${c.stats.hitRate}%`, `${mon.hitsAndMisses || 'Hits / Misses'}: ${c.stats.hits} / ${c.stats.misses}`, `${mon.networkIo || 'Network I/O'}: ${c.stats.inputKbps.toFixed(1)} / ${c.stats.outputKbps.toFixed(1)} KB/s`, `${mon.expired || 'Expired'}: ${c.stats.expiredKeys}`, `${mon.evicted || '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(); } 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`); } 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 || 'Monitoring'} ---`, `Redis ${c.server.version} · ${c.server.mode} · Uptime: ${this.uptimeFormatted}`, `${mon.memory || 'Memory'}: ${c.memory.usedHuman}`, `${mon.rss || 'RSS'}: ${c.memory.rssHuman}`, `${mon.peak || 'Peak'}: ${c.memory.peakHuman}`, `${mon.fragmentation || 'Fragmentation'}: ${c.memory.fragRatio}x`, `${mon.opsPerSec || 'Ops/sec'}: ${c.stats.opsPerSec}`, `${mon.totalCommands || 'Total'}: ${c.stats.totalCommands}`, `${mon.clients || 'Clients'}: ${c.clients.connected}`, `${mon.blocked || 'Blocked'}: ${c.clients.blocked}`, `${mon.hitsMisses || 'Hit Rate'}: ${c.stats.hitRate}%`, `${mon.hitsAndMisses || 'Hits / Misses'}: ${c.stats.hits} / ${c.stats.misses}`, `${mon.networkIo || 'Network I/O'}: ${c.stats.inputKbps.toFixed(1)} / ${c.stats.outputKbps.toFixed(1)} KB/s`, `${mon.expired || 'Expired'}: ${c.stats.expiredKeys}`, `${mon.evicted || 'Evicted'}: ${c.stats.evictedKeys}`, ); if (c.slowlog.length > 0) { sections.push(``, `--- ${mon.slowLog || 'Slow Log'} ---`); sections.push(...c.slowlog.map(e => `${e.duration}µs ${e.command}`)); } if (this.clientList.length > 0) { sections.push(``, `--- ${mon.clientList || 'Client List'} ---`); 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 || 'Top Keys by Memory'} ---`); 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 || 'Keys Scanned'} ---`, `${a.keysScanned || 'Keys Scanned'}: ${d.totalScanned} / ${d.dbSize}`); sections.push(``, `--- ${a.memoryBreakdown || 'Memory Breakdown'} ---`); sections.push(`${a.totalMemory || 'Total'}: ${m.usedHuman}`, `${a.rssMemory || 'RSS'}: ${m.rssHuman}`, `${a.peakMemory || 'Peak'}: ${m.peakHuman}`); sections.push(`${a.overheadMemory || 'Overhead'}: ${this.formatBytes(m.overhead)}`, `${a.datasetMemory || 'Dataset'}: ${this.formatBytes(m.dataset)}`); sections.push(`${a.luaMemory || 'Lua'}: ${this.formatBytes(m.lua)}`, `${a.fragmentation || 'Fragmentation'}: ${m.fragRatio}x`, `${a.allocator || 'Allocator'}: ${m.allocator}`); sections.push(``, `--- ${a.typeDistribution || 'Type Distribution'} ---`); sections.push(...typeEntries.map((t: any) => `${t.type}: ${t.count} ${a.keyCount || 'keys'}, ${this.formatBytes(t.bytes)}`)); if (d.prefixMemory?.length > 0) { sections.push(``, `--- ${a.prefixMemory || 'Memory by Prefix'} ---`); sections.push(...d.prefixMemory.map((p: any, i: number) => `#${i + 1} ${p.prefix} \u2014 ${p.keyCount} ${a.keyCount || 'keys'}, ${this.formatBytes(p.totalBytes)}`)); } sections.push(``, `--- ${a.expirationOverview || 'Key Expiration'} ---`); sections.push(`${a.withTTL || 'With TTL'}: ${exp.withTTL}`, `${a.persistent || 'Persistent'}: ${exp.persistent}`, `${a.avgTTL || 'Average TTL'}: ${exp.avgTTL}s`); analysisChartItems = [ { name: a.typeDistribution || 'Type Distribution', items: typeEntries.map((t: any) => ({ label: t.type, value: t.bytes })) }, { name: a.prefixMemory || 'Memory by Prefix', 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 || 'Memory') + ' (MB)', series: [ { label: s.memory || 'Memory', color: colors.primary, values: data.memUsed, fill: true }, { label: 'RSS', color: colors.accent, values: data.memRss }, ], }, { label: s.opsPerSec || 'Ops/sec', series: [ { label: s.opsPerSec || 'Ops/s', color: colors.primary, values: data.ops, fill: true }, ], }, { label: s.clients || 'Clients', series: [ { label: s.clients || 'Connected', color: colors.primary, values: data.connected }, { label: s.blocked || 'Blocked', color: colors.warn, values: data.blocked }, ], }, { label: (s.networkIo || 'Network I/O') + ' (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.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; // Import uPlot CSS inline if (!document.getElementById('uplot-css')) { const style = document.createElement('style'); style.id = 'uplot-css'; try { const cssModule = require('uplot/dist/uPlot.min.css'); style.textContent = typeof cssModule === 'string' ? cssModule : ''; } catch { // Fallback: minimal uPlot styles style.textContent = '.uplot { font-family: inherit; } .u-legend { display: flex; gap: 12px; padding: 4px 0; font-size: 12px; }'; } document.head.appendChild(style); } 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 || '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 || '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 || 'Ops/s', 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 || 'Connected', stroke: colors.primary, width: 2 }, { label: s.blocked || '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/000077500000000000000000000000001517660044600152035ustar00rootroot00000000000000src/ng/pages/profiler/profiler.component.html000066400000000000000000000013001517660044600217060ustar00rootroot00000000000000
src/ng/pages/profiler/profiler.component.ts000066400000000000000000000131471517660044600214040ustar00rootroot00000000000000import { 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.html000066400000000000000000000017411517660044600213750ustar00rootroot00000000000000
Pattern
src/ng/pages/profiler/pubsub.component.ts000066400000000000000000000137661517660044600210710ustar00rootroot00000000000000import { 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/000077500000000000000000000000001517660044600146265ustar00rootroot00000000000000src/ng/pages/search/search.component.html000066400000000000000000000231131517660044600207620ustar00rootroot00000000000000
@if (indexes.length === 0) {
{{ strings().page?.search?.noIndex || 'No indexes found' }}
} @if (indexes.length > 0) {
{{ strings().page?.search?.index || 'Index' }} @for (idx of indexes; track idx) { {{ idx }} } @if (!isReadonly && selectedIndex) { }
{{ strings().page?.search?.query || 'Query' }}
@if (isGtSm) { } @else { }
}
@if (searchDone && total === 0) {
{{ strings().label?.noResults || 'No results' }}
} @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 || 'Index Name' }} {{ strings().page?.search?.prefix || 'Key Prefix (optional)' }}
Schema
@for (field of newIndexFields; track $index; let i = $index) {
{{ strings().page?.search?.fieldName || 'Field Name' }}
{{ strings().label?.type || 'Type' }} TEXT NUMERIC TAG GEO VECTOR
}
@if (isGtSm) { } @else { }
}
src/ng/pages/search/search.component.scss000066400000000000000000000023101517660044600207650ustar00rootroot00000000000000: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.ts000066400000000000000000000242361517660044600204530ustar00rootroot00000000000000import { 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 { 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'; require('./search.component.scss'); @Component({ selector: 'p3xr-search', standalone: true, imports: [ CommonModule, FormsModule, MatIconModule, MatButtonModule, MatTooltipModule, MatDividerModule, MatListModule, MatSelectModule, MatFormFieldModule, MatInputModule, P3xrAccordionComponent, P3xrButtonComponent, ], templateUrl: './search.component.html', 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; // 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 { const 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 || 'Are you sure to drop this index?', }); await this.socket.request({ action: 'search-index-drop', payload: { index: this.selectedIndex }, }); this.common.toast({ message: this.strings().status?.indexDropped || 'Index dropped' }); 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 || '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 || 'Index created' }); 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.ts000066400000000000000000001354471517660044600176100ustar00rootroot00000000000000import { Component, Inject, OnInit, OnDestroy, ChangeDetectorRef, ChangeDetectionStrategy } from '@angular/core'; 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 { 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'; /** * 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: [ MatToolbarModule, MatButtonModule, MatIconModule, MatListModule, MatSlideToggleModule, MatTooltipModule, MatDividerModule, DragDropModule, P3xrAccordionComponent, P3xrButtonComponent, ], template: `
{{ strings().title?.donateDescription }}

@if (!readonlyConnections) { }
@if (connectionsList.length === 0) {
{{ strings().intention?.noConnectionsInSettings || 'No connections' }}
} @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) { } } }
}

Angular React

@if (!readonlyConnections && !isGroqApiKeyReadonly()) { }
{{ strings().label?.aiEnabled || 'AI Enabled' }}
@if (isAiEnabled() && hasGroqApiKey()) {
{{ strings().label?.aiRouteViaNetwork || 'Route via network.corifeus.com' }}
{{ isUseOwnKey() ? (strings().label?.aiRoutingDirect || 'Queries go directly to Groq using your own API key, bypassing network.corifeus.com.') : (strings().label?.aiRoutingNetwork || 'AI queries are routed through network.corifeus.com. If you have your own free Groq API key, you can turn off this switch to route directly to Groq without network.corifeus.com.') }} @if (!isUseOwnKey()) { console.groq.com }
{{ strings().label?.aiGroqApiKey || 'Groq API Key' }}
{{ getGroqApiKeyDisplay() }}
}

{{ strings().label?.desktopNotificationsEnabled || 'Enable desktop notifications' }}
{{ strings().label?.desktopNotificationsInfo || 'Receive OS notifications for Redis disconnections and reconnections when the app is not focused.' }}

{{ 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 }}
`, 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; border-radius: 4px; overflow: hidden; border: 1px solid var(--p3xr-border-color, rgba(0,0,0,0.12)); } .p3xr-gui-toggle-active { padding: 8px 24px; font-weight: 700; font-size: 14px; user-select: none; background-color: var(--p3xr-btn-primary-bg); color: var(--p3xr-btn-primary-color); } .p3xr-gui-toggle-item { padding: 8px 24px; font-weight: 500; font-size: 14px; user-select: none; cursor: pointer; } .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); } /* 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); readonlyConnections = false; currentConnectionId: string | undefined; 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(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()); } private refreshState(): void { this.connectionsList = this.state.connections()?.list || []; this.readonlyConnections = this.state.cfg()?.readonlyConnections === true; this.currentConnectionId = this.state.connection()?.id; 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 || '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: 'connections-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: 'connections-reorder', payload: { group: groupName || undefined, 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 || 'Delete this connection?', }); await this.socket.request({ action: 'connection-delete', payload: { id: connection.id }, }); this.common.toast(this.strings().status?.deleted || '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: 'set-groq-api-key', payload: { apiKey: this.state.cfg()?.groqApiKey || '', 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 { const key = this.state.cfg()?.groqApiKey || ''; return key.startsWith('gsk_') && key.length > 20; } isUseOwnKey(): boolean { // Can only use own key if one is actually set return this.state.cfg()?.aiUseOwnKey === true && this.hasGroqApiKey(); } async toggleUseOwnKey(useOwn: boolean): Promise { // Can't use own key if no key is set if (useOwn && !this.hasGroqApiKey()) { return; } try { await this.socket.request({ action: 'set-groq-api-key', payload: { apiKey: this.state.cfg()?.groqApiKey || '', 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(); } getGroqApiKeyDisplay(): string { const key = this.state.cfg()?.groqApiKey || ''; if (!key) return this.strings().label?.aiGroqApiKeyNotSet || 'Not set'; if (key.length <= 8) return '****'; return `${key.slice(0, 4)}...${key.slice(-4)}`; } // --- Tree Settings --- openTreeSettings($event: any): void { this.treeSettingsDialog.show({ $event }); } // --- GUI Framework Switch --- switchToReact(): void { try { localStorage.setItem('p3xr-frontend', 'react'); } catch {} try { if (window.parent && window.parent !== window) { window.parent.postMessage({ type: 'p3x-ui-storage-set', key: 'p3xr-frontend', value: 'react' }, '*'); } } catch {} location.href = '/react/settings'; } } src/ng/services/000077500000000000000000000000001517660044600141055ustar00rootroot00000000000000src/ng/services/common.service.ts000066400000000000000000000215141517660044600174070ustar00rootroot00000000000000import { 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 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 || 'Info') : (strings.confirm?.title || 'Confirm')), message: options.message, disableCancel: isAlert, okButton: isAlert ? (strings.intention?.ok || 'OK') : (strings.intention?.sure || 'Sure'), cancelButton: strings.intention?.cancel || '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 || '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.ts000066400000000000000000000102751517660044600167000ustar00rootroot00000000000000import { Injectable, signal, computed, effect } from '@angular/core'; const merge = require('lodash/merge'); const { getTranslations, loadTranslation: loadTranslationChunk } = require('../../core/translation-loader'); const { detectLanguageFromLocale } = require('../../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/main-command.service.ts000066400000000000000000000164441517660044600204650ustar00rootroot00000000000000import { 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: '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: '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: '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.ts000066400000000000000000000040461517660044600202570ustar00rootroot00000000000000import { 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.ts000066400000000000000000000033711517660044600206060ustar00rootroot00000000000000import { 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.ts000066400000000000000000000016161517660044600176010ustar00rootroot00000000000000import { 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.ts000066400000000000000000000162761517660044600205300ustar00rootroot00000000000000import { 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 hadSection = false; let pikaIndex = 0; for (const line of lines) { if (line.startsWith('#')) { if (hadSection) { continue; } hadSection = true; if (section !== undefined) { obj[section] = currentSectionObj; } section = line.substring(1).toLowerCase().trim(); currentSectionObj = {}; } else if (line.length > 2) { hadSection = false; 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.ts000066400000000000000000000076021517660044600203450ustar00rootroot00000000000000import { Injectable, Inject, signal, computed } from '@angular/core'; import { SettingsService } from './settings.service'; declare const P3XR_API_PORT: number; /** * 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 reducedFunctions = signal(false); readonly keysInfoFetchedAt = signal(Date.now()); // --- Computed values --- 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 ((globalThis as any).p3xrDevMode === true) { 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.ts000066400000000000000000000225301517660044600177560ustar00rootroot00000000000000import { Injectable, Inject, Injector, signal, computed, effect } from '@angular/core'; const prettyBytesModule = require('pretty-bytes'); const prettyBytesFn = prettyBytesModule.default || prettyBytesModule; /** * 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')); // --- 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 { let lang = this.language(); if (lang === 'auto') lang = this.resolveAutoLang(); return prettyBytesFn(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 { // Lazy resolve to avoid circular dependency with I18nService const { I18nService } = require('./i18n.service'); const i18n = this.injector.get(I18nService) as { currentLang: () => string }; return i18n.currentLang() || 'en'; } getHumanizeDurationOptions(): { language: string; languages: Record } { let lang = this.language(); if (lang === 'auto') { 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()); }); } // --- 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.ts000066400000000000000000000133431517660044600201560ustar00rootroot00000000000000import { 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 || 'Connect to a Redis server first'); } } private requireConnectionAndHome(action: () => void): void { if (!this.isConnected) { const strings = this.i18n.strings(); this.common.toast(strings?.label?.connectFirst || 'Connect to a Redis server first'); 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.ts000066400000000000000000000216451517660044600174140ustar00rootroot00000000000000import { 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'; declare const io: any; /** * 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; 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 ((globalThis as any).p3xrDevMode === true) { ioOptions.transports = ['websocket']; } this.ioClient = io.connect(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 || 'P3X Redis UI', strings?.status?.connectionRestored || 'Connection restored'); 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', () => { this.disconnected = true; try { this.overlay.show(); } catch {} }); this.ioClient.on('error', (error: any) => { this.handleSocketError(error); }); this.ioClient.on('connect_error', (error: any) => { 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 || 'P3X Redis UI', 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 || 'P3X Redis UI', msg); } this.redisDisconnected$.next(data); this.tick(); this.request({ action: 'trigger-redis-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.ts000066400000000000000000000203501517660044600172160ustar00rootroot00000000000000import { 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.ts000066400000000000000000000237011517660044600205020ustar00rootroot00000000000000import { 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 { _requestId, ...result } = e.data; const resolve = this.pendingResolves.get(_requestId); if (resolve) { this.pendingResolves.delete(_requestId); resolve(result); } }; 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, ...result }); } }; } // ============================================================================ // 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/000077500000000000000000000000001517660044600135475ustar00rootroot00000000000000src/ng/themes/_theme-custom.scss000066400000000000000000000375311517660044600172260ustar00rootroot00000000000000// 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.scss000066400000000000000000000133361517660044600202240ustar00rootroot00000000000000// 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.scss000066400000000000000000001462551517660044600211710ustar00rootroot00000000000000// 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 { 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 { 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 { 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 { 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; } src/overlay/000077500000000000000000000000001517660044600133375ustar00rootroot00000000000000src/overlay/overlay.scss000066400000000000000000000014611517660044600157170ustar00rootroot00000000000000#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/000077500000000000000000000000001517660044600131345ustar00rootroot00000000000000src/public/images/000077500000000000000000000000001517660044600144015ustar00rootroot00000000000000src/public/images/256x256.png000066400000000000000000000303271517660044600160550ustar00rootroot00000000000000PNG  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/000077500000000000000000000000001517660044600127545ustar00rootroot00000000000000src/react/App.tsx000066400000000000000000000071421517660044600142400ustar00rootroot00000000000000import { 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/000077500000000000000000000000001517660044600151415ustar00rootroot00000000000000src/react/components/ConfirmDialog.tsx000066400000000000000000000046421517660044600204240ustar00rootroot00000000000000import { Button, IconButton, 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 && ( isWide ? ( ) : ( resolveConfirm?.(false)} sx={{ borderRadius: '4px' }}> ) )} {isWide ? ( ) : ( resolveConfirm?.(true)} sx={{ borderRadius: '4px' }}> )} } >
) } src/react/components/Overlay.tsx000066400000000000000000000027341517660044600173300ustar00rootroot00000000000000import { Box, Typography } from '@mui/material' import { useOverlayStore } from '../stores/overlay.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() if (!visible) return null return ( {message && ( <>

{message} )}
) } src/react/components/P3xrAccordion.tsx000066400000000000000000000075661517660044600203750ustar00rootroot00000000000000import { 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 */} {extended && ( {children} )} ) } src/react/components/P3xrButton.tsx000066400000000000000000000036671517660044600177450ustar00rootroot00000000000000import { ReactNode } from 'react' import { Button, IconButton, 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 ( ) } return ( {icon} ) } src/react/components/P3xrDialog.tsx000066400000000000000000000106471517660044600176650ustar00rootroot00000000000000import { 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.tsx000066400000000000000000000052011517660044600203000ustar00rootroot00000000000000import { useState, useEffect } from 'react' import { Button, IconButton, 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={ <> {isWide ? ( ) : ( resolvePrompt?.(null)} sx={{ borderRadius: '4px' }}> )} {isWide ? ( ) : ( )} } > setValue(e.target.value)} onKeyDown={e => e.key === 'Enter' && handleOk()} /> ) } src/react/components/Toast.tsx000066400000000000000000000016151517660044600167760ustar00rootroot00000000000000import { Snackbar, IconButton, Alert } from '@mui/material' import { Close } from '@mui/icons-material' import { useCommonStore } from '../stores/common.store' export default function Toast() { const { toastOpen, toastMessage, closeToast } = useCommonStore() return ( } sx={{ // Stack above other snackbars if multiple '& .MuiSnackbarContent-root': { flexWrap: 'nowrap', }, }} /> ) } src/react/dialogs/000077500000000000000000000000001517660044600143765ustar00rootroot00000000000000src/react/dialogs/AiSettingsDialog.tsx000066400000000000000000000071501517660044600203330ustar00rootroot00000000000000import { 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 }) try { await request({ action: 'validate-groq-api-key', payload: { apiKey: trimmedKey } }) } catch (e) { generalHandleError(e); return } finally { overlay.hide() } } await request({ action: '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.tsx000066400000000000000000000077011517660044600215620ustar00rootroot00000000000000import { useState, useEffect } from 'react' import { Button, IconButton, Tooltip, TextField, useMediaQuery, InputAdornment, } from '@mui/material' import { Done, Cancel, Visibility, VisibilityOff } 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 ( {isWide ? ( ) : ( )} {isWide ? ( ) : ( )} } > setUsername(e.target.value)} autoComplete="off" onKeyDown={e => e.key === 'Enter' && handleOk()} /> setPassword(e.target.value)} autoComplete="off" onKeyDown={e => e.key === 'Enter' && handleOk()} slotProps={{ input: { endAdornment: ( setPwVisible(!pwVisible)} size="small"> {pwVisible ? : } ), }, }} /> ) } src/react/dialogs/CommandPaletteDialog.tsx000066400000000000000000000135561517660044600211650ustar00rootroot00000000000000import { 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 || 'Command Palette'} 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 || 'No results'} )} ) } src/react/dialogs/ConnectionDialog.tsx000066400000000000000000000442661517660044600203710ustar00rootroot00000000000000import { 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: 'redis-test-connection', 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)} disabled={readonlyConnections} />} label={strings?.label?.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/JsonEditorDialog.tsx000066400000000000000000000214111517660044600203350ustar00rootroot00000000000000import { 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' 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 save = (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) 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} )} ) } src/react/dialogs/JsonViewDialog.tsx000066400000000000000000000175741517660044600200400ustar00rootroot00000000000000import { 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.tsx000066400000000000000000000124541517660044600202070ustar00rootroot00000000000000import { 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.tsx000066400000000000000000000472411517660044600204450ustar00rootroot00000000000000import { 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, TableChart, 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 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 } 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 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: '', }) const types = (() => { const base = ['string', 'list', 'hash', 'set', 'zset', 'stream'] if (hasTimeSeries) base.push('timeseries') if (hasReJSON) base.push('json') 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 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 } } 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} /> )} )} {/* Action buttons */} {model.type !== 'stream' && model.type !== 'timeseries' && ( )} {model.type !== 'timeseries' && ( <> )} {model.type !== 'timeseries' && ( 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 */} {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) }} /> ) } src/react/dialogs/TreeSettingsDialog.tsx000066400000000000000000000256271517660044600207120ustar00rootroot00000000000000import { 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 } 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, }) 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, }) 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') 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} /> ) } src/react/dialogs/TtlDialog.tsx000066400000000000000000000064531517660044600170310ustar00rootroot00000000000000import { 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.html000066400000000000000000000052771517660044600147640ustar00rootroot00000000000000 P3X Redis UI
src/react/layout/000077500000000000000000000000001517660044600142715ustar00rootroot00000000000000src/react/layout/Layout.tsx000066400000000000000000000722401517660044600163130ustar00rootroot00000000000000import { 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, } 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 { 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 { disconnect } = useMainCommandStore() const { askAuth } = useCommonStore() // Connect to a Redis connection (exact port of Angular LayoutComponent.connect) const connect = async (conn: any) => { const cloned = structuredClone(conn) try { const dbStorageKey = settings.getStorageKeyCurrentDatabase(cloned.id) let db: string | undefined try { db = localStorage.getItem(dbStorageKey) ?? undefined } catch {} if (cloned.askAuth === true) { try { const auth = await askAuth() cloned.username = auth.username || undefined cloned.password = auth.password || undefined } catch { return // user cancelled } } overlay.show({ message: strings?.title?.connectingRedis }) const response = await request({ action: 'connection-connect', payload: { connection: cloned, db }, }) // Update state const databaseIndexes: number[] = [] let i = 0 while (i < response.databases) databaseIndexes.push(i++) const commands: string[] = [] Object.keys(response.commands ?? {}).forEach(k => { commands.push(response.commands[k][0]) }) commands.sort() const modules = Array.isArray(response.modules) ? response.modules : [] useRedisStateStore.setState({ page: 1, monitor: false, dbsize: response.dbsize, databaseIndexes, connection: cloned, commands, commandsMeta: response.commandsMeta ?? {}, modules, hasReJSON: modules.some((m: any) => m.name === 'ReJSON'), hasRediSearch: modules.some((m: any) => m.name === 'search'), hasTimeSeries: modules.some((m: any) => m.name === 'timeseries' || m.name === 'Timeseries'), }) useCommonStore.getState().loadRedisInfoResponse({ response }) // Save last connection to localStorage try { localStorage.setItem(settings.connectInfoStorageKey, JSON.stringify(cloned)) } catch {} } catch (error) { try { localStorage.removeItem(settings.connectInfoStorageKey) } catch {} useRedisStateStore.setState({ connection: undefined }) generalHandleError(error) } finally { overlay.hide() } } // 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]) // Remove loading splash useEffect(() => { document.getElementById('p3xr-loading')?.remove() }, []) // Auto-connect from localStorage on startup useEffect(() => { try { const saved = localStorage.getItem(settings.connectInfoStorageKey) if (saved) { const conn = JSON.parse(saved) if (conn?.id) connect(conn) } } catch {} }, []) // Subscribe to redis disconnect → navigate to settings useEffect(() => { const unsub = onSocketEvent('redis-disconnected', () => { navigateTo('settings') }) return unsub }, []) // 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) useEffect(() => { const unsubDisconnect = onSocketEvent('disconnect', () => { overlay.show({ message: strings?.status?.socketDisconnected || 'Disconnected' }) }) const unsubError = onSocketEvent('socket-error', () => { overlay.show({ message: strings?.status?.socketError || 'Connection error' }) }) return () => { unsubDisconnect(); unsubError() } }, [strings]) // --- Responsive button helpers --- const activeSx = { bgcolor: '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')} /> )} } label={strings?.intention?.info} page="info" onClick={() => navigateTo('info')} /> } label={strings?.intention?.settings} page="settings" onClick={() => navigateTo('settings')} /> {/* Version overlay — inside AppBar so it inherits toolbar text color */} {/* ===== CONTENT ===== */} {/* ===== FOOTER ===== */} {/* Connection menu */} {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 */} {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' }} MenuListProps={{ autoFocus: false, autoFocusItem: false }} slotProps={{ paper: { sx: { minWidth: 320, maxWidth: '90vw', maxHeight: 400, overflow: 'hidden' } }, list: { sx: { pt: 0, overflow: 'auto', maxHeight: 400 } }, }}> 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 || 'Auto (system)'} {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.tsx000066400000000000000000000027011517660044600144400ustar00rootroot00000000000000import '@fontsource/roboto/300.css' import '@fontsource/roboto/400.css' import '@fontsource/roboto/500.css' import '@fontsource/roboto/700.css' import '@fontsource/roboto-mono/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/000077500000000000000000000000001517660044600140535ustar00rootroot00000000000000src/react/pages/console/000077500000000000000000000000001517660044600155155ustar00rootroot00000000000000src/react/pages/console/ConsoleComponent.tsx000066400000000000000000000705651517660044600215570ustar00rootroot00000000000000import { useState, useEffect, useRef, useCallback, useMemo } from 'react' import { Box, Toolbar, Tooltip, Popper, Paper, ClickAwayListener } from '@mui/material' import { CheckBox, CheckBoxOutlineBlank, Terminal, Backspace } 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 => { 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 modules = useRedisStateStore.getState().modules || [] const ctx: any = { indexes } if (info.redis_version) ctx.redisVersion = info.redis_version if (info.redis_mode) ctx.redisMode = info.redis_mode if (info.os) ctx.os = info.os if (info.connected_clients) ctx.connectedClients = info.connected_clients if (info.used_memory_human) ctx.usedMemory = info.used_memory_human const dbKeys = Object.keys(info).filter((k: string) => /^db\d+$/.test(k)) if (dbKeys.length > 0) ctx.databases = dbKeys.map((k: string) => `${k}: ${info[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 || 'AI query failed') + ': ' + 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: '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 && 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 && ( {currentHint} )}