.babelrc000066400000000000000000000003501520126411500124440ustar00rootroot00000000000000{ "presets": [ [ "@babel/preset-env", { "targets": { "node": "current" } } ] ], "plugins": ["angularjs-annotate"] } .github/000077500000000000000000000000001520126411500124135ustar00rootroot00000000000000.github/workflows/000077500000000000000000000000001520126411500144505ustar00rootroot00000000000000.github/workflows/build.yml000066400000000000000000000011321520126411500162670ustar00rootroot00000000000000name: 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 .gitignore000066400000000000000000000004341520126411500130440ustar00rootroot00000000000000/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.npmignore000066400000000000000000000004751520126411500130600ustar00rootroot00000000000000/.babelrc /.github /.idea /.vscode /.travis.yml /.scrutinizer.yml /AGENTS.* /agents /artifacts /build /corifeus-boot.json /coverage /Gruntfile.js /node_modules /playwright-report /playwright*.* /secure /src/**/* /test /test-results /tests /tsconfig.json /*.iml /*.ipr /*.iws /*.lock *.log npm-debug.log* yarn-*.log* Gruntfile.js000066400000000000000000000031221520126411500133460ustar00rootroot00000000000000const 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) } }) } LICENSE000066400000000000000000000020131520126411500120540ustar00rootroot00000000000000MIT 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.md000066400000000000000000000106231520126411500123340ustar00rootroot00000000000000# 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) --- # 💿 The p3x-redis-ui-material web interface that connects to the p3x-redis-ui-server via http and socket.io v2026.4.399 🌌 **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 is the `p3x-redis-ui-material` web gui, that uses the `p3x-redis-ui-server`. It is based on Socket.IO and Angular with Angular Material, uses themes light/dark schema and internationalization (21 languages). # For development standalone 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. Then: ```bash npm install npm run dev ``` The frontend is available @ http://localhost:8080 [//]: #@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.399 [![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/000077500000000000000000000000001520126411500130335ustar00rootroot00000000000000artifacts/reduce-bundle-size.txt000066400000000000000000000022451520126411500172650ustar00rootroot000000000000002020 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.json000066400000000000000000000117531520126411500133500ustar00rootroot00000000000000{ "name": "p3x-redis-ui-material", "version": "2026.4.399", "description": "💿 The p3x-redis-ui-material web interface that connects to the p3x-redis-ui-server via http and socket.io", "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.3", "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.mjs000066400000000000000000000015741520126411500152330ustar00rootroot00000000000000import { 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.ts000066400000000000000000000026541520126411500150700ustar00rootroot00000000000000import { 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; export default defineConfig({ testDir: './tests', timeout: 30000, use: { baseURL: process.env.PLAYWRIGHT_BASE_URL || 'http://localhost:8080/ng', headless: true, httpCredentials: productionHttpCredentials, viewport: { width: 1280, height: 900 }, screenshot: 'only-on-failure', }, projects: [ { name: 'chromium', use: { browserName: 'chromium' }, }, ], }); scripts/000077500000000000000000000000001520126411500125425ustar00rootroot00000000000000scripts/screenshots.mjs000066400000000000000000000106721520126411500156230ustar00rootroot00000000000000import { 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/000077500000000000000000000000001520126411500116425ustar00rootroot00000000000000src/builder/000077500000000000000000000000001520126411500132705ustar00rootroot00000000000000src/builder/webpack.config.js000066400000000000000000000207651520126411500165200ustar00rootroot00000000000000const 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/000077500000000000000000000000001520126411500125725ustar00rootroot00000000000000src/core/translation-loader.js000066400000000000000000000147331520126411500167420ustar00rootroot00000000000000/** * 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.html000066400000000000000000000124521520126411500136430ustar00rootroot00000000000000 P3X Redis UI
src/main-development.js000066400000000000000000000003741520126411500154500ustar00rootroot00000000000000global.p3xrDevMode = true console.log('-------------------------------------') console.log(' development mode ') console.log('-------------------------------------') if (module.hot) { module.hot.accept() } require('./main') src/main.js000066400000000000000000000037161520126411500131330ustar00rootroot00000000000000require('./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.scss000066400000000000000000000003531520126411500134640ustar00rootroot00000000000000@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/000077500000000000000000000000001520126411500122465ustar00rootroot00000000000000src/ng/app.routes.ts000066400000000000000000000063321520126411500147220ustar00rootroot00000000000000import { 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: 'main', redirectTo: 'database', pathMatch: 'prefix', }, { path: '', redirectTo: 'settings', pathMatch: 'full', }, { path: '**', redirectTo: 'settings', }, ]; src/ng/components/000077500000000000000000000000001520126411500144335ustar00rootroot00000000000000src/ng/components/confirm-dialog.component.ts000066400000000000000000000042261520126411500217020ustar00rootroot00000000000000import { 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.ts000066400000000000000000000036731520126411500230100ustar00rootroot00000000000000import { 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.ts000066400000000000000000000202741520126411500207170ustar00rootroot00000000000000import { 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.ts000066400000000000000000000117461520126411500216500ustar00rootroot00000000000000import { 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.ts000066400000000000000000000075201520126411500212150ustar00rootroot00000000000000import { 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.ts000066400000000000000000000061121520126411500210350ustar00rootroot00000000000000import { 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/000077500000000000000000000000001520126411500136705ustar00rootroot00000000000000src/ng/dialogs/ai-settings-dialog.component.ts000066400000000000000000000117461520126411500217360ustar00rootroot00000000000000import { 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.ts000066400000000000000000000014541520126411500213670ustar00rootroot00000000000000import { 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.ts000066400000000000000000000063631520126411500231620ustar00rootroot00000000000000import { 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.ts000066400000000000000000000022211520126411500226050ustar00rootroot00000000000000import { 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.ts000066400000000000000000000122471520126411500225560ustar00rootroot00000000000000import { 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.ts000066400000000000000000000015731520126411500222140ustar00rootroot00000000000000import { 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.ts000066400000000000000000001073011520126411500216370ustar00rootroot00000000000000import { 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.ts000066400000000000000000000033001520126411500212670ustar00rootroot00000000000000import { 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.ts000066400000000000000000000051641520126411500166460ustar00rootroot00000000000000import { 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.ts000066400000000000000000000275021520126411500217410ustar00rootroot00000000000000import { 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.ts000066400000000000000000000032251520126411500213730ustar00rootroot00000000000000import { 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.ts000066400000000000000000000077521520126411500214320ustar00rootroot00000000000000import { 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.ts000066400000000000000000000017371520126411500210650ustar00rootroot00000000000000import { 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.ts000066400000000000000000000121271520126411500216010ustar00rootroot00000000000000import { 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.ts000066400000000000000000000020731520126411500212360ustar00rootroot00000000000000import { 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.ts000066400000000000000000000650311520126411500222710ustar00rootroot00000000000000import { 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.ts000066400000000000000000000023461520126411500217270ustar00rootroot00000000000000import { 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.ts000066400000000000000000000065611520126411500210270ustar00rootroot00000000000000import { 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.ts000066400000000000000000000431171520126411500237020ustar00rootroot00000000000000import { 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.ts000066400000000000000000000020551520126411500233340ustar00rootroot00000000000000import { 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.ts000066400000000000000000000121461520126411500203050ustar00rootroot00000000000000import { 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.ts000066400000000000000000000022131520126411500177350ustar00rootroot00000000000000import { 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/000077500000000000000000000000001520126411500135635ustar00rootroot00000000000000src/ng/layout/layout.component.html000066400000000000000000000303051520126411500177700ustar00rootroot00000000000000
@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.scss000066400000000000000000000047461520126411500200110ustar00rootroot00000000000000// 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.ts000066400000000000000000000520771520126411500174640ustar00rootroot00000000000000import { 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.ts000066400000000000000000000035211520126411500135430ustar00rootroot00000000000000import '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/000077500000000000000000000000001520126411500133455ustar00rootroot00000000000000src/ng/pages/console/000077500000000000000000000000001520126411500150075ustar00rootroot00000000000000src/ng/pages/console/console.component.html000066400000000000000000000124661520126411500213510ustar00rootroot00000000000000
@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.scss000066400000000000000000000124211520126411500213470ustar00rootroot00000000000000p3xr-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.ts000066400000000000000000000721631520126411500210330ustar00rootroot00000000000000import { 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/000077500000000000000000000000001520126411500151115ustar00rootroot00000000000000src/ng/pages/database/database-header.component.ts000066400000000000000000000256331520126411500224650ustar00rootroot00000000000000import { 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.html000066400000000000000000000207561520126411500223440ustar00rootroot00000000000000@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.scss000066400000000000000000000056131520126411500223460ustar00rootroot00000000000000.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.ts000066400000000000000000000357201520126411500220230ustar00rootroot00000000000000import { 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.refresh(); 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.refresh(); 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.html000066400000000000000000000115361520126411500225070ustar00rootroot00000000000000
@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.scss000066400000000000000000000064031520126411500225130ustar00rootroot00000000000000// 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.ts000066400000000000000000000447071520126411500221770ustar00rootroot00000000000000import { 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.html000066400000000000000000000177201520126411500257520ustar00rootroot00000000000000
@if (treeDividers.length > 0) { @for (divider of treeDividers; track divider) { } }
@if (pages > 1) { / {{ pages }} } @else { {{ keyCountText() }}  } src/ng/pages/database/database-treecontrol-controls.component.scss000066400000000000000000000111101520126411500257440ustar00rootroot00000000000000: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.ts000066400000000000000000000366421520126411500254400ustar00rootroot00000000000000import { 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.html000066400000000000000000000044751520126411500215560ustar00rootroot00000000000000
{{ strings().title?.main }}
src/ng/pages/database/database.component.scss000066400000000000000000000046111520126411500215550ustar00rootroot00000000000000@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.ts000066400000000000000000000443541520126411500212400ustar00rootroot00000000000000import { 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/000077500000000000000000000000001520126411500157015ustar00rootroot00000000000000src/ng/pages/database/key/key-hash.component.html000066400000000000000000000037501520126411500223060ustar00rootroot00000000000000
{{ 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.ts000066400000000000000000000112411520126411500217620ustar00rootroot00000000000000import { 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.html000066400000000000000000000075621520126411500223410ustar00rootroot00000000000000
@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.ts000066400000000000000000000115231520126411500220130ustar00rootroot00000000000000import { 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.html000066400000000000000000000036561520126411500223430ustar00rootroot00000000000000
{{ 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.ts000066400000000000000000000111751520126411500220200ustar00rootroot00000000000000import { 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.ts000066400000000000000000000074211520126411500234160ustar00rootroot00000000000000import { 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.ts000066400000000000000000000027731520126411500203150ustar00rootroot00000000000000import { 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.html000066400000000000000000000032601520126411500221520ustar00rootroot00000000000000
{{ 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.ts000066400000000000000000000111101520126411500216250ustar00rootroot00000000000000import { 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.html000066400000000000000000000046571520126411500226650ustar00rootroot00000000000000
{{ 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.ts000066400000000000000000000222451520126411500223400ustar00rootroot00000000000000import { 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.html000066400000000000000000000174331520126411500226740ustar00rootroot00000000000000 @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.ts000066400000000000000000000166471520126411500223640ustar00rootroot00000000000000import { 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.html000066400000000000000000000312441520126411500235330ustar00rootroot00000000000000

@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.ts000066400000000000000000000562701520126411500232230ustar00rootroot00000000000000import { 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.ts000066400000000000000000000132121520126411500207270ustar00rootroot00000000000000import { 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.scss000066400000000000000000000103501520126411500205270ustar00rootroot00000000000000// 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.html000066400000000000000000000035541520126411500223520ustar00rootroot00000000000000
{{ 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.ts000066400000000000000000000116341520126411500220320ustar00rootroot00000000000000import { 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.html000066400000000000000000000030421520126411500221710ustar00rootroot00000000000000 @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.scss000066400000000000000000000035131520126411500222030ustar00rootroot00000000000000// 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.ts000066400000000000000000000135061520126411500216610ustar00rootroot00000000000000import { 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.ts000066400000000000000000000170441520126411500166570ustar00rootroot00000000000000import { 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/000077500000000000000000000000001520126411500155325ustar00rootroot00000000000000src/ng/pages/monitoring/memory-analysis.component.html000066400000000000000000000253101520126411500235530ustar00rootroot00000000000000@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.scss000066400000000000000000000011131520126411500235550ustar00rootroot00000000000000p3xr-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.ts000066400000000000000000000323341520126411500232410ustar00rootroot00000000000000import { 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.ts000066400000000000000000000155251520126411500226450ustar00rootroot00000000000000import { 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.scss000066400000000000000000000010111520126411500237130ustar00rootroot00000000000000@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.ts000066400000000000000000000106451520126411500234030ustar00rootroot00000000000000import { 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.html000066400000000000000000000365001520126411500226120ustar00rootroot00000000000000@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.scss000066400000000000000000000034571520126411500226260ustar00rootroot00000000000000p3xr-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.ts000066400000000000000000001312371520126411500222770ustar00rootroot00000000000000import { 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/000077500000000000000000000000001520126411500151675ustar00rootroot00000000000000src/ng/pages/profiler/profiler.component.html000066400000000000000000000013001520126411500216720ustar00rootroot00000000000000
src/ng/pages/profiler/profiler.component.ts000066400000000000000000000131471520126411500213700ustar00rootroot00000000000000import { 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.html000066400000000000000000000017411520126411500213610ustar00rootroot00000000000000
Pattern
src/ng/pages/profiler/pubsub.component.ts000066400000000000000000000137661520126411500210550ustar00rootroot00000000000000import { 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/000077500000000000000000000000001520126411500146125ustar00rootroot00000000000000src/ng/pages/search/search.component.html000066400000000000000000000231131520126411500207460ustar00rootroot00000000000000
@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.scss000066400000000000000000000023101520126411500207510ustar00rootroot00000000000000: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.ts000066400000000000000000000242361520126411500204370ustar00rootroot00000000000000import { 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.ts000066400000000000000000001311451520126411500175630ustar00rootroot00000000000000import { 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 { 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: `
@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().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'; 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, ) { 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/000077500000000000000000000000001520126411500140715ustar00rootroot00000000000000src/ng/services/common.service.ts000066400000000000000000000215141520126411500173730ustar00rootroot00000000000000import { 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.ts000066400000000000000000000115461520126411500166660ustar00rootroot00000000000000import { Injectable, signal, computed, effect } from '@angular/core'; const merge = require('lodash/merge'); const { getTranslations, loadTranslation: loadTranslationChunk } = require('../../core/translation-loader'); /** * 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'; /** * 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); }); /** * List of missing translation keys in the current language (for development). */ readonly missingKeys = computed(() => { const translations = this.getTranslations(); const en = translations['en'] || {}; const current = translations[this.currentLang()] || {}; const missing: string[] = []; const isObject = (v: any) => v && typeof v === 'object' && !Array.isArray(v); const diffKeys = (base: any, target: any, path: string = '') => { Object.keys(base || {}).forEach((k) => { const nextPath = path ? `${path}.${k}` : k; if (!(target && Object.prototype.hasOwnProperty.call(target, k))) { missing.push(nextPath); } else if (isObject(base[k]) && isObject(target[k])) { diffKeys(base[k], target[k], nextPath); } }); }; try { diffKeys(en, current); } catch (e) { /* noop */ } return missing; }); constructor() { // Persist language changes to localStorage and sync with AngularJS effect(() => { const lang = this.currentLang(); this.setStorageItem(I18nService.STORAGE_KEY, lang); 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: lang }, '*'); } } catch { /* not in iframe */ } // Log missing keys in development const missing = this.missingKeys(); if (missing.length > 0) { console.warn(`[i18n] Missing translation keys for '${lang}':`, missing); } }); } /** * Switch the active language. Lazily loads the translation chunk if not yet * cached, then triggers recomputation of the strings signal. */ setLanguage(lang: string): void { const nextLanguage = lang || 'en'; loadTranslationChunk(nextLanguage).then( () => this.currentLang.set(nextLanguage), () => this.currentLang.set(nextLanguage), ); } /** * Get available language codes. */ getAvailableLanguages(): string[] { return Object.keys(this.getTranslations()); } // --- Private helpers --- private getTranslations(): Record { return getTranslations(); } private detectInitialLanguage(): string { // Try localStorage first const storedLang = this.readStorageItem(I18nService.STORAGE_KEY); if (storedLang) return storedLang; // Auto-detect from browser (same logic as AngularJS boot.js) try { const navLang = (navigator.language || '').toLowerCase(); if (navLang.startsWith('zh')) return 'zn'; if (navLang.startsWith('ru')) return 'ru'; } catch (e) { /* noop */ } 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.ts000066400000000000000000000164441520126411500204510ustar00rootroot00000000000000import { 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.ts000066400000000000000000000040461520126411500202430ustar00rootroot00000000000000import { 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/overlay.service.ts000066400000000000000000000016161520126411500175650ustar00rootroot00000000000000import { 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.ts000066400000000000000000000162761520126411500205140ustar00rootroot00000000000000import { 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.ts000066400000000000000000000076021520126411500203310ustar00rootroot00000000000000import { 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.ts000066400000000000000000000214711520126411500177450ustar00rootroot00000000000000import { Injectable, 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 { return prettyBytesFn(value, { locale: this.language() }); } 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', }; getHumanizeDurationOptions(): { language: string; languages: Record } { const lang = this.language(); return { language: this.humanizeDurationLanguageMap[lang] || lang || 'en', languages: this.humanizeDurationCustomLanguages, }; } constructor() { // 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.ts000066400000000000000000000133431520126411500201420ustar00rootroot00000000000000import { 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.ts000066400000000000000000000206001520126411500173660ustar00rootroot00000000000000import { 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'; 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, ) { 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); 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); } 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.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.ts000066400000000000000000000164461520126411500172150ustar00rootroot00000000000000import { 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 --- private getInitialTheme(): string { const stored = this.readStorageItem(ThemeService.STORAGE_KEY); if (!stored) return ThemeService.AUTO_THEME; return stored; } private applyTheme(themeName: string): void { const dark = ThemeService.DARK_THEMES.includes(themeName); this.setStorageItem(ThemeService.STORAGE_KEY, this.isAuto() ? ThemeService.AUTO_THEME : 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.ts000066400000000000000000000237011520126411500204660ustar00rootroot00000000000000import { 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/000077500000000000000000000000001520126411500135335ustar00rootroot00000000000000src/ng/themes/_theme-custom.scss000066400000000000000000000375311520126411500172120ustar00rootroot00000000000000// 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.scss000066400000000000000000000133361520126411500202100ustar00rootroot00000000000000// 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.scss000066400000000000000000001462551520126411500211550ustar00rootroot00000000000000// 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/000077500000000000000000000000001520126411500133235ustar00rootroot00000000000000src/overlay/overlay.scss000066400000000000000000000014611520126411500157030ustar00rootroot00000000000000#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/000077500000000000000000000000001520126411500131205ustar00rootroot00000000000000src/public/images/000077500000000000000000000000001520126411500143655ustar00rootroot00000000000000src/public/images/256x256.png000066400000000000000000000303271520126411500160410ustar00rootroot00000000000000PNG  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/000077500000000000000000000000001520126411500127405ustar00rootroot00000000000000src/react/App.tsx000066400000000000000000000064111520126411500142220ustar00rootroot00000000000000import { 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 { 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' 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 theme = useMemo(() => themes[themeKey] || themes.enterprise , [themeKey]) return ( }> } /> } /> } /> }> } /> } /> } /> }> } /> } /> } /> } /> ) } export default App src/react/components/000077500000000000000000000000001520126411500151255ustar00rootroot00000000000000src/react/components/ConfirmDialog.tsx000066400000000000000000000046421520126411500204100ustar00rootroot00000000000000import { 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.tsx000066400000000000000000000027341520126411500173140ustar00rootroot00000000000000import { 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.tsx000066400000000000000000000075661520126411500203610ustar00rootroot00000000000000import { 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.tsx000066400000000000000000000036671520126411500177310ustar00rootroot00000000000000import { 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.tsx000066400000000000000000000106471520126411500176510ustar00rootroot00000000000000import { 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.tsx000066400000000000000000000052011520126411500202640ustar00rootroot00000000000000import { 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.tsx000066400000000000000000000016151520126411500167620ustar00rootroot00000000000000import { 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/000077500000000000000000000000001520126411500143625ustar00rootroot00000000000000src/react/dialogs/AiSettingsDialog.tsx000066400000000000000000000071501520126411500203170ustar00rootroot00000000000000import { 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/ConnectionDialog.tsx000066400000000000000000000424061520126411500203470ustar00rootroot00000000000000import { 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 testConnection = async () => { try { overlay.show({ message: strings?.title?.connectingRedis }) await request({ action: 'redis-test-connection', payload: { model: structuredClone(model) } }) toast(strings?.status?.redisConnected) } catch (e) { generalHandleError(e) } finally { overlay.hide() } } const submit = async () => { if (!model.name?.trim()) { toast(strings?.form?.error?.invalid); 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.tsx000066400000000000000000000214111520126411500203210ustar00rootroot00000000000000import { 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.tsx000066400000000000000000000175741520126411500200240ustar00rootroot00000000000000import { 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.tsx000066400000000000000000000124541520126411500201730ustar00rootroot00000000000000import { 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.tsx000066400000000000000000000471101520126411500204240ustar00rootroot00000000000000import { 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 { 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), }, }) 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.tsx000066400000000000000000000256271520126411500206760ustar00rootroot00000000000000import { 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.tsx000066400000000000000000000064531520126411500170150ustar00rootroot00000000000000import { 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.html000066400000000000000000000036551520126411500147460ustar00rootroot00000000000000 P3X Redis UI
src/react/layout/000077500000000000000000000000001520126411500142555ustar00rootroot00000000000000src/react/layout/Layout.tsx000066400000000000000000000662511520126411500163040ustar00rootroot00000000000000import { 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 { 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 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() // 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 {} 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) // Close language menu on resize to avoid stale positioning useEffect(() => { const onResize = () => { if (languageAnchor) setLanguageAnchor(null) } window.addEventListener('resize', onResize) return () => window.removeEventListener('resize', onResize) }, [languageAnchor]) 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) } } const timer = setTimeout(() => window.addEventListener('message', handler), 3000) return () => { clearTimeout(timer); 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 }, []) // --- Responsive button helpers --- const activeSx = { bgcolor: 'rgba(255,255,255,0.1)' } const NavBtn = ({ icon, label, page, onClick }: { icon: React.ReactNode, label: 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} onClick={() => navigateTo('database.statistics')} /> {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 */} {!isElectron && version && isWide && ( {version} )} {/* ===== 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" anchorOrigin={{ vertical: 'top', horizontal: 'left' }} transformOrigin={{ vertical: 'bottom', horizontal: 'left' }} 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, }, }} /> {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.tsx000066400000000000000000000027011520126411500144240ustar00rootroot00000000000000import '@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/000077500000000000000000000000001520126411500140375ustar00rootroot00000000000000src/react/pages/console/000077500000000000000000000000001520126411500155015ustar00rootroot00000000000000src/react/pages/console/ConsoleComponent.tsx000066400000000000000000000700741520126411500215360ustar00rootroot00000000000000import { 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 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 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} )}