From 294cb21e9105c2f54727c7692eb45550a3172634 Mon Sep 17 00:00:00 2001 From: divocat Date: Thu, 2 Oct 2025 21:40:16 +0300 Subject: [PATCH 01/49] feat: Introduce fe modular build system --- .gitignore | 3 +- fe-app-podkop/.prettierrc | 8 + fe-app-podkop/eslint.config.js | 18 + fe-app-podkop/package.json | 25 + fe-app-podkop/src/main.ts | 4 + fe-app-podkop/src/validators/index.ts | 4 + .../src/validators/tests/validateDns.test.js | 24 + .../validators/tests/validateDomain.test.js | 46 + .../src/validators/tests/validateIp.test.js | 38 + .../src/validators/tests/validateUrl.test.js | 40 + fe-app-podkop/src/validators/types.ts | 4 + fe-app-podkop/src/validators/validateDns.ts | 23 + .../src/validators/validateDomain.ts | 21 + fe-app-podkop/src/validators/validateIp.ts | 12 + fe-app-podkop/src/validators/validateUrl.ts | 20 + fe-app-podkop/tsconfig.json | 13 + fe-app-podkop/tsup.config.ts | 35 + fe-app-podkop/vitest.config.js | 8 + fe-app-podkop/yarn.lock | 1857 +++++++++++++++++ .../resources/view/podkop/configSection.js | 42 +- .../luci-static/resources/view/podkop/main.js | 66 + 21 files changed, 2294 insertions(+), 17 deletions(-) create mode 100644 fe-app-podkop/.prettierrc create mode 100644 fe-app-podkop/eslint.config.js create mode 100644 fe-app-podkop/package.json create mode 100644 fe-app-podkop/src/main.ts create mode 100644 fe-app-podkop/src/validators/index.ts create mode 100644 fe-app-podkop/src/validators/tests/validateDns.test.js create mode 100644 fe-app-podkop/src/validators/tests/validateDomain.test.js create mode 100644 fe-app-podkop/src/validators/tests/validateIp.test.js create mode 100644 fe-app-podkop/src/validators/tests/validateUrl.test.js create mode 100644 fe-app-podkop/src/validators/types.ts create mode 100644 fe-app-podkop/src/validators/validateDns.ts create mode 100644 fe-app-podkop/src/validators/validateDomain.ts create mode 100644 fe-app-podkop/src/validators/validateIp.ts create mode 100644 fe-app-podkop/src/validators/validateUrl.ts create mode 100644 fe-app-podkop/tsconfig.json create mode 100644 fe-app-podkop/tsup.config.ts create mode 100644 fe-app-podkop/vitest.config.js create mode 100644 fe-app-podkop/yarn.lock create mode 100644 luci-app-podkop/htdocs/luci-static/resources/view/podkop/main.js diff --git a/.gitignore b/.gitignore index 723ef36..703db4d 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ -.idea \ No newline at end of file +.idea +fe-app-podkop/node_modules diff --git a/fe-app-podkop/.prettierrc b/fe-app-podkop/.prettierrc new file mode 100644 index 0000000..7d30bd5 --- /dev/null +++ b/fe-app-podkop/.prettierrc @@ -0,0 +1,8 @@ +{ + "printWidth": 80, + "tabWidth": 2, + "semi": true, + "singleQuote": true, + "trailingComma": "all", + "bracketSpacing": true +} diff --git a/fe-app-podkop/eslint.config.js b/fe-app-podkop/eslint.config.js new file mode 100644 index 0000000..e30b66d --- /dev/null +++ b/fe-app-podkop/eslint.config.js @@ -0,0 +1,18 @@ +// eslint.config.js +import js from '@eslint/js'; +import tseslint from 'typescript-eslint'; +import prettier from 'eslint-config-prettier'; + +export default [ + js.configs.recommended, + ...tseslint.configs.recommended, + { + ignores: ['dist', 'node_modules'], + }, + { + rules: { + 'no-console': 'warn', + }, + }, + prettier, +]; diff --git a/fe-app-podkop/package.json b/fe-app-podkop/package.json new file mode 100644 index 0000000..59d91b0 --- /dev/null +++ b/fe-app-podkop/package.json @@ -0,0 +1,25 @@ +{ + "name": "fe-app-podkop", + "version": "1.0.0", + "license": "MIT", + "type": "module", + "scripts": { + "format": "prettier --write src", + "lint": "eslint src --ext .ts,.tsx", + "lint:fix": "eslint src --ext .ts,.tsx --fix", + "build": "tsup src/main.ts", + "dev": "tsup src/main.ts --watch", + "test": "vitest" + }, + "devDependencies": { + "@typescript-eslint/eslint-plugin": "8.45.0", + "@typescript-eslint/parser": "8.45.0", + "eslint": "9.36.0", + "eslint-config-prettier": "10.1.8", + "prettier": "3.6.2", + "tsup": "8.5.0", + "typescript": "5.9.3", + "typescript-eslint": "8.45.0", + "vitest": "3.2.4" + } +} diff --git a/fe-app-podkop/src/main.ts b/fe-app-podkop/src/main.ts new file mode 100644 index 0000000..88d79ca --- /dev/null +++ b/fe-app-podkop/src/main.ts @@ -0,0 +1,4 @@ +'use strict'; +'require baseclass'; + +export * from './validators'; diff --git a/fe-app-podkop/src/validators/index.ts b/fe-app-podkop/src/validators/index.ts new file mode 100644 index 0000000..2adb0bd --- /dev/null +++ b/fe-app-podkop/src/validators/index.ts @@ -0,0 +1,4 @@ +export * from './validateIp'; +export * from './validateDomain'; +export * from './validateDns'; +export * from './validateUrl'; diff --git a/fe-app-podkop/src/validators/tests/validateDns.test.js b/fe-app-podkop/src/validators/tests/validateDns.test.js new file mode 100644 index 0000000..e0543ab --- /dev/null +++ b/fe-app-podkop/src/validators/tests/validateDns.test.js @@ -0,0 +1,24 @@ +import { describe, expect, it } from 'vitest'; +import { validateDNS } from '../validateDns.js'; +import { invalidIPs, validIPs } from './validateIp.test'; +import { invalidDomains, validDomains } from './validateDomain.test'; + +const validDns = [...validIPs, ...validDomains]; + +const invalidDns = [...invalidIPs, ...invalidDomains]; + +describe('validateDns', () => { + describe.each(validDns)('Valid dns: %s', (_desc, domain) => { + it(`returns valid=true for "${domain}"`, () => { + const res = validateDNS(domain); + expect(res.valid).toBe(true); + }); + }); + + describe.each(invalidDns)('Invalid dns: %s', (_desc, domain) => { + it(`returns valid=false for "${domain}"`, () => { + const res = validateDNS(domain); + expect(res.valid).toBe(false); + }); + }); +}); diff --git a/fe-app-podkop/src/validators/tests/validateDomain.test.js b/fe-app-podkop/src/validators/tests/validateDomain.test.js new file mode 100644 index 0000000..a2c312a --- /dev/null +++ b/fe-app-podkop/src/validators/tests/validateDomain.test.js @@ -0,0 +1,46 @@ +import { describe, expect, it } from 'vitest'; +import { validateDomain } from '../validateDomain'; + +export const validDomains = [ + ['Simple domain', 'example.com'], + ['Subdomain', 'sub.example.com'], + ['With dash', 'my-site.org'], + ['With numbers', 'site123.net'], + ['Deep subdomain', 'a.b.c.example.co.uk'], + ['With path', 'example.com/path/to/resource'], + ['Punycode RU', 'xn--d1acufc.xn--p1ai'], + ['Adguard dns', 'dns.adguard-dns.com'], + ['Nextdns dns', 'dns.nextdns.io/xxxxxxx'], + ['Long domain (63 chars in label)', 'a'.repeat(63) + '.com'], +]; + +export const invalidDomains = [ + ['No TLD', 'localhost'], + ['Only TLD', '.com'], + ['Double dot', 'example..com'], + ['Illegal chars', 'exa!mple.com'], + ['Space inside', 'exa mple.com'], + ['Ending with dash', 'example-.com'], + ['Starting with dash', '-example.com'], + ['Trailing dot', 'example.com.'], + ['Too short TLD', 'example.c'], + ['With protocol (not allowed)', 'http://example.com'], + ['Too long label (>63 chars)', 'a'.repeat(64) + '.com'], + ['Too long domain (>253 chars)', Array(40).fill('abcdef').join('.') + '.com'], +]; + +describe('validateDomain', () => { + describe.each(validDomains)('Valid domain: %s', (_desc, domain) => { + it(`returns valid=true for "${domain}"`, () => { + const res = validateDomain(domain); + expect(res.valid).toBe(true); + }); + }); + + describe.each(invalidDomains)('Invalid domain: %s', (_desc, domain) => { + it(`returns valid=false for "${domain}"`, () => { + const res = validateDomain(domain); + expect(res.valid).toBe(false); + }); + }); +}); diff --git a/fe-app-podkop/src/validators/tests/validateIp.test.js b/fe-app-podkop/src/validators/tests/validateIp.test.js new file mode 100644 index 0000000..c2f0f0d --- /dev/null +++ b/fe-app-podkop/src/validators/tests/validateIp.test.js @@ -0,0 +1,38 @@ +import { describe, it, expect } from 'vitest'; +import { validateIPV4 } from '../validateIp'; + +export const validIPs = [ + ['Private LAN', '192.168.1.1'], + ['All zeros', '0.0.0.0'], + ['Broadcast', '255.255.255.255'], + ['Simple', '1.2.3.4'], + ['Loopback', '127.0.0.1'], +]; + +export const invalidIPs = [ + ['Octet too large', '256.0.0.1'], + ['Too few octets', '192.168.1'], + ['Too many octets', '1.2.3.4.5'], + ['Leading zero (1st octet)', '01.2.3.4'], + ['Leading zero (2nd octet)', '1.02.3.4'], + ['Leading zero (3rd octet)', '1.2.003.4'], + ['Leading zero (4th octet)', '1.2.3.004'], + ['Four digits in octet', '1.2.3.0004'], + ['Trailing dot', '1.2.3.'], +]; + +describe('validateIPV4', () => { + describe.each(validIPs)('Valid IP: %s', (_desc, ip) => { + it(`returns {valid:true for "${ip}"`, () => { + const res = validateIPV4(ip); + expect(res.valid).toBe(true); + }); + }); + + describe.each(invalidIPs)('Invalid IP: %s', (_desc, ip) => { + it(`returns {valid:false for "${ip}"`, () => { + const res = validateIPV4(ip); + expect(res.valid).toBe(false); + }); + }); +}); diff --git a/fe-app-podkop/src/validators/tests/validateUrl.test.js b/fe-app-podkop/src/validators/tests/validateUrl.test.js new file mode 100644 index 0000000..7d13833 --- /dev/null +++ b/fe-app-podkop/src/validators/tests/validateUrl.test.js @@ -0,0 +1,40 @@ +import { describe, it, expect } from 'vitest'; +import { validateUrl } from '../validateUrl'; + +const validUrls = [ + ['Simple HTTP', 'http://example.com'], + ['Simple HTTPS', 'https://example.com'], + ['With path', 'https://example.com/path/to/page'], + ['With query', 'https://example.com/?q=test'], + ['With port', 'http://example.com:8080'], + ['With subdomain', 'https://sub.example.com'], +]; + +const invalidUrls = [ + ['Invalid format', 'not a url'], + ['Missing protocol', 'example.com'], + ['Unsupported protocol (ftp)', 'ftp://example.com'], + ['Unsupported protocol (ws)', 'ws://example.com'], + ['Empty string', ''], +]; + +describe('validateUrl', () => { + describe.each(validUrls)('Valid URL: %s', (_desc, url) => { + it(`returns valid=true for "${url}"`, () => { + const res = validateUrl(url); + expect(res.valid).toBe(true); + }); + }); + + describe.each(invalidUrls)('Invalid URL: %s', (_desc, url) => { + it(`returns valid=false for "${url}"`, () => { + const res = validateUrl(url); + expect(res.valid).toBe(false); + }); + }); + + it('allows custom protocol list (ftp)', () => { + const res = validateUrl('ftp://example.com', ['ftp:']); + expect(res.valid).toBe(true); + }); +}); diff --git a/fe-app-podkop/src/validators/types.ts b/fe-app-podkop/src/validators/types.ts new file mode 100644 index 0000000..c2308c6 --- /dev/null +++ b/fe-app-podkop/src/validators/types.ts @@ -0,0 +1,4 @@ +export interface ValidationResult { + valid: boolean; + message: string; +} diff --git a/fe-app-podkop/src/validators/validateDns.ts b/fe-app-podkop/src/validators/validateDns.ts new file mode 100644 index 0000000..ea1fec7 --- /dev/null +++ b/fe-app-podkop/src/validators/validateDns.ts @@ -0,0 +1,23 @@ +import { validateDomain } from './validateDomain'; +import { validateIPV4 } from './validateIp'; +import { ValidationResult } from './types.js'; + +export function validateDNS(value: string): ValidationResult { + if (!value) { + return { valid: false, message: 'DNS server address cannot be empty' }; + } + + if (validateIPV4(value).valid) { + return { valid: true, message: 'Valid' }; + } + + if (validateDomain(value).valid) { + return { valid: true, message: 'Valid' }; + } + + return { + valid: false, + message: + 'Invalid DNS server format. Examples: 8.8.8.8 or dns.example.com or dns.example.com/nicedns for DoH', + }; +} diff --git a/fe-app-podkop/src/validators/validateDomain.ts b/fe-app-podkop/src/validators/validateDomain.ts new file mode 100644 index 0000000..ae90296 --- /dev/null +++ b/fe-app-podkop/src/validators/validateDomain.ts @@ -0,0 +1,21 @@ +import { ValidationResult } from './types.js'; + +export function validateDomain(domain: string): ValidationResult { + const domainRegex = + /^(?=.{1,253}(?:\/|$))(?:(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)\.)+(?:[a-zA-Z]{2,}|xn--[a-zA-Z0-9-]{1,59}[a-zA-Z0-9])(?:\/[^\s]*)?$/; + + if (!domainRegex.test(domain)) { + return { valid: false, message: 'Invalid domain address' }; + } + + const hostname = domain.split('/')[0]; + const parts = hostname.split('.'); + + const atLeastOneInvalidPart = parts.some((part) => part.length > 63); + + if (atLeastOneInvalidPart) { + return { valid: false, message: 'Invalid domain address' }; + } + + return { valid: true, message: 'Valid' }; +} diff --git a/fe-app-podkop/src/validators/validateIp.ts b/fe-app-podkop/src/validators/validateIp.ts new file mode 100644 index 0000000..4d1967b --- /dev/null +++ b/fe-app-podkop/src/validators/validateIp.ts @@ -0,0 +1,12 @@ +import { ValidationResult } from './types.js'; + +export function validateIPV4(ip: string): ValidationResult { + const ipRegex = + /^(?:(25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])\.){3}(25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])$/; + + if (ipRegex.test(ip)) { + return { valid: true, message: 'Valid' }; + } + + return { valid: false, message: 'Invalid IP address' }; +} diff --git a/fe-app-podkop/src/validators/validateUrl.ts b/fe-app-podkop/src/validators/validateUrl.ts new file mode 100644 index 0000000..865af9d --- /dev/null +++ b/fe-app-podkop/src/validators/validateUrl.ts @@ -0,0 +1,20 @@ +import { ValidationResult } from './types.js'; + +export function validateUrl( + url: string, + protocols: string[] = ['http:', 'https:'], +): ValidationResult { + try { + const parsedUrl = new URL(url); + + if (!protocols.includes(parsedUrl.protocol)) { + return { + valid: false, + message: `URL must use one of the following protocols: ${protocols.join(', ')}`, + }; + } + return { valid: true, message: 'Valid' }; + } catch (e) { + return { valid: false, message: 'Invalid URL format' }; + } +} diff --git a/fe-app-podkop/tsconfig.json b/fe-app-podkop/tsconfig.json new file mode 100644 index 0000000..3c1f371 --- /dev/null +++ b/fe-app-podkop/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "ESNext", + "strict": true, + "moduleResolution": "node", + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "outDir": "dist" + }, + "include": ["src"] +} diff --git a/fe-app-podkop/tsup.config.ts b/fe-app-podkop/tsup.config.ts new file mode 100644 index 0000000..6caf479 --- /dev/null +++ b/fe-app-podkop/tsup.config.ts @@ -0,0 +1,35 @@ +import { defineConfig } from 'tsup'; +import fs from 'fs'; +import path from 'path'; + +export default defineConfig({ + entry: ['src/main.ts'], + format: ['esm'], // пусть tsup генерит export {...} + outDir: '../luci-app-podkop/htdocs/luci-static/resources/view/podkop', + outExtension: () => ({ js: '.js' }), + dts: false, + clean: false, + sourcemap: false, + banner: { + js: `// This file is autogenerated, please don't change manually \n"use strict";`, + }, + esbuildOptions(options) { + options.legalComments = 'none'; + }, + onSuccess: () => { + const outDir = + '../luci-app-podkop/htdocs/luci-static/resources/view/podkop'; + const file = path.join(outDir, 'main.js'); + let code = fs.readFileSync(file, 'utf8'); + + code = code.replace( + /export\s*{([\s\S]*?)}/, + (match, group) => { + return `return baseclass.extend({${group}})`; + } + ); + + fs.writeFileSync(file, code, 'utf8'); + console.log(`✅ Patched LuCI build: ${file}`); + }, +}); diff --git a/fe-app-podkop/vitest.config.js b/fe-app-podkop/vitest.config.js new file mode 100644 index 0000000..adbf725 --- /dev/null +++ b/fe-app-podkop/vitest.config.js @@ -0,0 +1,8 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + globals: true, + environment: 'node', + }, +}); diff --git a/fe-app-podkop/yarn.lock b/fe-app-podkop/yarn.lock new file mode 100644 index 0000000..6791013 --- /dev/null +++ b/fe-app-podkop/yarn.lock @@ -0,0 +1,1857 @@ +# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. +# yarn lockfile v1 + + +"@esbuild/aix-ppc64@0.25.10": + version "0.25.10" + resolved "https://registry.npmmirror.com/@esbuild/aix-ppc64/-/aix-ppc64-0.25.10.tgz#ee6b7163a13528e099ecf562b972f2bcebe0aa97" + integrity sha512-0NFWnA+7l41irNuaSVlLfgNT12caWJVLzp5eAVhZ0z1qpxbockccEt3s+149rE64VUI3Ml2zt8Nv5JVc4QXTsw== + +"@esbuild/android-arm64@0.25.10": + version "0.25.10" + resolved "https://registry.npmmirror.com/@esbuild/android-arm64/-/android-arm64-0.25.10.tgz#115fc76631e82dd06811bfaf2db0d4979c16e2cb" + integrity sha512-LSQa7eDahypv/VO6WKohZGPSJDq5OVOo3UoFR1E4t4Gj1W7zEQMUhI+lo81H+DtB+kP+tDgBp+M4oNCwp6kffg== + +"@esbuild/android-arm@0.25.10": + version "0.25.10" + resolved "https://registry.npmmirror.com/@esbuild/android-arm/-/android-arm-0.25.10.tgz#8d5811912da77f615398611e5bbc1333fe321aa9" + integrity sha512-dQAxF1dW1C3zpeCDc5KqIYuZ1tgAdRXNoZP7vkBIRtKZPYe2xVr/d3SkirklCHudW1B45tGiUlz2pUWDfbDD4w== + +"@esbuild/android-x64@0.25.10": + version "0.25.10" + resolved "https://registry.npmmirror.com/@esbuild/android-x64/-/android-x64-0.25.10.tgz#e3e96516b2d50d74105bb92594c473e30ddc16b1" + integrity sha512-MiC9CWdPrfhibcXwr39p9ha1x0lZJ9KaVfvzA0Wxwz9ETX4v5CHfF09bx935nHlhi+MxhA63dKRRQLiVgSUtEg== + +"@esbuild/darwin-arm64@0.25.10": + version "0.25.10" + resolved "https://registry.npmmirror.com/@esbuild/darwin-arm64/-/darwin-arm64-0.25.10.tgz#6af6bb1d05887dac515de1b162b59dc71212ed76" + integrity sha512-JC74bdXcQEpW9KkV326WpZZjLguSZ3DfS8wrrvPMHgQOIEIG/sPXEN/V8IssoJhbefLRcRqw6RQH2NnpdprtMA== + +"@esbuild/darwin-x64@0.25.10": + version "0.25.10" + resolved "https://registry.npmmirror.com/@esbuild/darwin-x64/-/darwin-x64-0.25.10.tgz#99ae82347fbd336fc2d28ffd4f05694e6e5b723d" + integrity sha512-tguWg1olF6DGqzws97pKZ8G2L7Ig1vjDmGTwcTuYHbuU6TTjJe5FXbgs5C1BBzHbJ2bo1m3WkQDbWO2PvamRcg== + +"@esbuild/freebsd-arm64@0.25.10": + version "0.25.10" + resolved "https://registry.npmmirror.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.10.tgz#0c6d5558a6322b0bdb17f7025c19bd7d2359437d" + integrity sha512-3ZioSQSg1HT2N05YxeJWYR+Libe3bREVSdWhEEgExWaDtyFbbXWb49QgPvFH8u03vUPX10JhJPcz7s9t9+boWg== + +"@esbuild/freebsd-x64@0.25.10": + version "0.25.10" + resolved "https://registry.npmmirror.com/@esbuild/freebsd-x64/-/freebsd-x64-0.25.10.tgz#8c35873fab8c0857a75300a3dcce4324ca0b9844" + integrity sha512-LLgJfHJk014Aa4anGDbh8bmI5Lk+QidDmGzuC2D+vP7mv/GeSN+H39zOf7pN5N8p059FcOfs2bVlrRr4SK9WxA== + +"@esbuild/linux-arm64@0.25.10": + version "0.25.10" + resolved "https://registry.npmmirror.com/@esbuild/linux-arm64/-/linux-arm64-0.25.10.tgz#3edc2f87b889a15b4cedaf65f498c2bed7b16b90" + integrity sha512-5luJWN6YKBsawd5f9i4+c+geYiVEw20FVW5x0v1kEMWNq8UctFjDiMATBxLvmmHA4bf7F6hTRaJgtghFr9iziQ== + +"@esbuild/linux-arm@0.25.10": + version "0.25.10" + resolved "https://registry.npmmirror.com/@esbuild/linux-arm/-/linux-arm-0.25.10.tgz#86501cfdfb3d110176d80c41b27ed4611471cde7" + integrity sha512-oR31GtBTFYCqEBALI9r6WxoU/ZofZl962pouZRTEYECvNF/dtXKku8YXcJkhgK/beU+zedXfIzHijSRapJY3vg== + +"@esbuild/linux-ia32@0.25.10": + version "0.25.10" + resolved "https://registry.npmmirror.com/@esbuild/linux-ia32/-/linux-ia32-0.25.10.tgz#e6589877876142537c6864680cd5d26a622b9d97" + integrity sha512-NrSCx2Kim3EnnWgS4Txn0QGt0Xipoumb6z6sUtl5bOEZIVKhzfyp/Lyw4C1DIYvzeW/5mWYPBFJU3a/8Yr75DQ== + +"@esbuild/linux-loong64@0.25.10": + version "0.25.10" + resolved "https://registry.npmmirror.com/@esbuild/linux-loong64/-/linux-loong64-0.25.10.tgz#11119e18781f136d8083ea10eb6be73db7532de8" + integrity sha512-xoSphrd4AZda8+rUDDfD9J6FUMjrkTz8itpTITM4/xgerAZZcFW7Dv+sun7333IfKxGG8gAq+3NbfEMJfiY+Eg== + +"@esbuild/linux-mips64el@0.25.10": + version "0.25.10" + resolved "https://registry.npmmirror.com/@esbuild/linux-mips64el/-/linux-mips64el-0.25.10.tgz#3052f5436b0c0c67a25658d5fc87f045e7def9e6" + integrity sha512-ab6eiuCwoMmYDyTnyptoKkVS3k8fy/1Uvq7Dj5czXI6DF2GqD2ToInBI0SHOp5/X1BdZ26RKc5+qjQNGRBelRA== + +"@esbuild/linux-ppc64@0.25.10": + version "0.25.10" + resolved "https://registry.npmmirror.com/@esbuild/linux-ppc64/-/linux-ppc64-0.25.10.tgz#2f098920ee5be2ce799f35e367b28709925a8744" + integrity sha512-NLinzzOgZQsGpsTkEbdJTCanwA5/wozN9dSgEl12haXJBzMTpssebuXR42bthOF3z7zXFWH1AmvWunUCkBE4EA== + +"@esbuild/linux-riscv64@0.25.10": + version "0.25.10" + resolved "https://registry.npmmirror.com/@esbuild/linux-riscv64/-/linux-riscv64-0.25.10.tgz#fa51d7fd0a22a62b51b4b94b405a3198cf7405dd" + integrity sha512-FE557XdZDrtX8NMIeA8LBJX3dC2M8VGXwfrQWU7LB5SLOajfJIxmSdyL/gU1m64Zs9CBKvm4UAuBp5aJ8OgnrA== + +"@esbuild/linux-s390x@0.25.10": + version "0.25.10" + resolved "https://registry.npmmirror.com/@esbuild/linux-s390x/-/linux-s390x-0.25.10.tgz#a27642e36fc282748fdb38954bd3ef4f85791e8a" + integrity sha512-3BBSbgzuB9ajLoVZk0mGu+EHlBwkusRmeNYdqmznmMc9zGASFjSsxgkNsqmXugpPk00gJ0JNKh/97nxmjctdew== + +"@esbuild/linux-x64@0.25.10": + version "0.25.10" + resolved "https://registry.npmmirror.com/@esbuild/linux-x64/-/linux-x64-0.25.10.tgz#9d9b09c0033d17529570ced6d813f98315dfe4e9" + integrity sha512-QSX81KhFoZGwenVyPoberggdW1nrQZSvfVDAIUXr3WqLRZGZqWk/P4T8p2SP+de2Sr5HPcvjhcJzEiulKgnxtA== + +"@esbuild/netbsd-arm64@0.25.10": + version "0.25.10" + resolved "https://registry.npmmirror.com/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.10.tgz#25c09a659c97e8af19e3f2afd1c9190435802151" + integrity sha512-AKQM3gfYfSW8XRk8DdMCzaLUFB15dTrZfnX8WXQoOUpUBQ+NaAFCP1kPS/ykbbGYz7rxn0WS48/81l9hFl3u4A== + +"@esbuild/netbsd-x64@0.25.10": + version "0.25.10" + resolved "https://registry.npmmirror.com/@esbuild/netbsd-x64/-/netbsd-x64-0.25.10.tgz#7fa5f6ffc19be3a0f6f5fd32c90df3dc2506937a" + integrity sha512-7RTytDPGU6fek/hWuN9qQpeGPBZFfB4zZgcz2VK2Z5VpdUxEI8JKYsg3JfO0n/Z1E/6l05n0unDCNc4HnhQGig== + +"@esbuild/openbsd-arm64@0.25.10": + version "0.25.10" + resolved "https://registry.npmmirror.com/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.10.tgz#8faa6aa1afca0c6d024398321d6cb1c18e72a1c3" + integrity sha512-5Se0VM9Wtq797YFn+dLimf2Zx6McttsH2olUBsDml+lm0GOCRVebRWUvDtkY4BWYv/3NgzS8b/UM3jQNh5hYyw== + +"@esbuild/openbsd-x64@0.25.10": + version "0.25.10" + resolved "https://registry.npmmirror.com/@esbuild/openbsd-x64/-/openbsd-x64-0.25.10.tgz#a42979b016f29559a8453d32440d3c8cd420af5e" + integrity sha512-XkA4frq1TLj4bEMB+2HnI0+4RnjbuGZfet2gs/LNs5Hc7D89ZQBHQ0gL2ND6Lzu1+QVkjp3x1gIcPKzRNP8bXw== + +"@esbuild/openharmony-arm64@0.25.10": + version "0.25.10" + resolved "https://registry.npmmirror.com/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.10.tgz#fd87bfeadd7eeb3aa384bbba907459ffa3197cb1" + integrity sha512-AVTSBhTX8Y/Fz6OmIVBip9tJzZEUcY8WLh7I59+upa5/GPhh2/aM6bvOMQySspnCCHvFi79kMtdJS1w0DXAeag== + +"@esbuild/sunos-x64@0.25.10": + version "0.25.10" + resolved "https://registry.npmmirror.com/@esbuild/sunos-x64/-/sunos-x64-0.25.10.tgz#3a18f590e36cb78ae7397976b760b2b8c74407f4" + integrity sha512-fswk3XT0Uf2pGJmOpDB7yknqhVkJQkAQOcW/ccVOtfx05LkbWOaRAtn5SaqXypeKQra1QaEa841PgrSL9ubSPQ== + +"@esbuild/win32-arm64@0.25.10": + version "0.25.10" + resolved "https://registry.npmmirror.com/@esbuild/win32-arm64/-/win32-arm64-0.25.10.tgz#e71741a251e3fd971408827a529d2325551f530c" + integrity sha512-ah+9b59KDTSfpaCg6VdJoOQvKjI33nTaQr4UluQwW7aEwZQsbMCfTmfEO4VyewOxx4RaDT/xCy9ra2GPWmO7Kw== + +"@esbuild/win32-ia32@0.25.10": + version "0.25.10" + resolved "https://registry.npmmirror.com/@esbuild/win32-ia32/-/win32-ia32-0.25.10.tgz#c6f010b5d3b943d8901a0c87ea55f93b8b54bf94" + integrity sha512-QHPDbKkrGO8/cz9LKVnJU22HOi4pxZnZhhA2HYHez5Pz4JeffhDjf85E57Oyco163GnzNCVkZK0b/n4Y0UHcSw== + +"@esbuild/win32-x64@0.25.10": + version "0.25.10" + resolved "https://registry.npmmirror.com/@esbuild/win32-x64/-/win32-x64-0.25.10.tgz#e4b3e255a1b4aea84f6e1d2ae0b73f826c3785bd" + integrity sha512-9KpxSVFCu0iK1owoez6aC/s/EdUQLDN3adTxGCqxMVhrPDj6bt5dbrHDXUuq+Bs2vATFBBrQS5vdQ/Ed2P+nbw== + +"@eslint-community/eslint-utils@^4.7.0", "@eslint-community/eslint-utils@^4.8.0": + version "4.9.0" + resolved "https://registry.npmmirror.com/@eslint-community/eslint-utils/-/eslint-utils-4.9.0.tgz#7308df158e064f0dd8b8fdb58aa14fa2a7f913b3" + integrity sha512-ayVFHdtZ+hsq1t2Dy24wCmGXGe4q9Gu3smhLYALJrr473ZH27MsnSL+LKUlimp4BWJqMDMLmPpx/Q9R3OAlL4g== + dependencies: + eslint-visitor-keys "^3.4.3" + +"@eslint-community/regexpp@^4.10.0", "@eslint-community/regexpp@^4.12.1": + version "4.12.1" + resolved "https://registry.npmmirror.com/@eslint-community/regexpp/-/regexpp-4.12.1.tgz#cfc6cffe39df390a3841cde2abccf92eaa7ae0e0" + integrity sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ== + +"@eslint/config-array@^0.21.0": + version "0.21.0" + resolved "https://registry.npmmirror.com/@eslint/config-array/-/config-array-0.21.0.tgz#abdbcbd16b124c638081766392a4d6b509f72636" + integrity sha512-ENIdc4iLu0d93HeYirvKmrzshzofPw6VkZRKQGe9Nv46ZnWUzcF1xV01dcvEg/1wXUR61OmmlSfyeyO7EvjLxQ== + dependencies: + "@eslint/object-schema" "^2.1.6" + debug "^4.3.1" + minimatch "^3.1.2" + +"@eslint/config-helpers@^0.3.1": + version "0.3.1" + resolved "https://registry.npmmirror.com/@eslint/config-helpers/-/config-helpers-0.3.1.tgz#d316e47905bd0a1a931fa50e669b9af4104d1617" + integrity sha512-xR93k9WhrDYpXHORXpxVL5oHj3Era7wo6k/Wd8/IsQNnZUTzkGS29lyn3nAT05v6ltUuTFVCCYDEGfy2Or/sPA== + +"@eslint/core@^0.15.2": + version "0.15.2" + resolved "https://registry.npmmirror.com/@eslint/core/-/core-0.15.2.tgz#59386327d7862cc3603ebc7c78159d2dcc4a868f" + integrity sha512-78Md3/Rrxh83gCxoUc0EiciuOHsIITzLy53m3d9UyiW8y9Dj2D29FeETqyKA+BRK76tnTp6RXWb3pCay8Oyomg== + dependencies: + "@types/json-schema" "^7.0.15" + +"@eslint/eslintrc@^3.3.1": + version "3.3.1" + resolved "https://registry.npmmirror.com/@eslint/eslintrc/-/eslintrc-3.3.1.tgz#e55f7f1dd400600dd066dbba349c4c0bac916964" + integrity sha512-gtF186CXhIl1p4pJNGZw8Yc6RlshoePRvE0X91oPGb3vZ8pM3qOS9W9NGPat9LziaBV7XrJWGylNQXkGcnM3IQ== + dependencies: + ajv "^6.12.4" + debug "^4.3.2" + espree "^10.0.1" + globals "^14.0.0" + ignore "^5.2.0" + import-fresh "^3.2.1" + js-yaml "^4.1.0" + minimatch "^3.1.2" + strip-json-comments "^3.1.1" + +"@eslint/js@9.36.0": + version "9.36.0" + resolved "https://registry.npmmirror.com/@eslint/js/-/js-9.36.0.tgz#b1a3893dd6ce2defed5fd49de805ba40368e8fef" + integrity sha512-uhCbYtYynH30iZErszX78U+nR3pJU3RHGQ57NXy5QupD4SBVwDeU8TNBy+MjMngc1UyIW9noKqsRqfjQTBU2dw== + +"@eslint/object-schema@^2.1.6": + version "2.1.6" + resolved "https://registry.npmmirror.com/@eslint/object-schema/-/object-schema-2.1.6.tgz#58369ab5b5b3ca117880c0f6c0b0f32f6950f24f" + integrity sha512-RBMg5FRL0I0gs51M/guSAj5/e14VQ4tpZnQNWwuDT66P14I43ItmPfIZRhO9fUVIPOAQXU47atlywZ/czoqFPA== + +"@eslint/plugin-kit@^0.3.5": + version "0.3.5" + resolved "https://registry.npmmirror.com/@eslint/plugin-kit/-/plugin-kit-0.3.5.tgz#fd8764f0ee79c8ddab4da65460c641cefee017c5" + integrity sha512-Z5kJ+wU3oA7MMIqVR9tyZRtjYPr4OC004Q4Rw7pgOKUOKkJfZ3O24nz3WYfGRpMDNmcOi3TwQOmgm7B7Tpii0w== + dependencies: + "@eslint/core" "^0.15.2" + levn "^0.4.1" + +"@humanfs/core@^0.19.1": + version "0.19.1" + resolved "https://registry.npmmirror.com/@humanfs/core/-/core-0.19.1.tgz#17c55ca7d426733fe3c561906b8173c336b40a77" + integrity sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA== + +"@humanfs/node@^0.16.6": + version "0.16.7" + resolved "https://registry.npmmirror.com/@humanfs/node/-/node-0.16.7.tgz#822cb7b3a12c5a240a24f621b5a2413e27a45f26" + integrity sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ== + dependencies: + "@humanfs/core" "^0.19.1" + "@humanwhocodes/retry" "^0.4.0" + +"@humanwhocodes/module-importer@^1.0.1": + version "1.0.1" + resolved "https://registry.npmmirror.com/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz#af5b2691a22b44be847b0ca81641c5fb6ad0172c" + integrity sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA== + +"@humanwhocodes/retry@^0.4.0", "@humanwhocodes/retry@^0.4.2": + version "0.4.3" + resolved "https://registry.npmmirror.com/@humanwhocodes/retry/-/retry-0.4.3.tgz#c2b9d2e374ee62c586d3adbea87199b1d7a7a6ba" + integrity sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ== + +"@isaacs/cliui@^8.0.2": + version "8.0.2" + resolved "https://registry.npmmirror.com/@isaacs/cliui/-/cliui-8.0.2.tgz#b37667b7bc181c168782259bab42474fbf52b550" + integrity sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA== + dependencies: + string-width "^5.1.2" + string-width-cjs "npm:string-width@^4.2.0" + strip-ansi "^7.0.1" + strip-ansi-cjs "npm:strip-ansi@^6.0.1" + wrap-ansi "^8.1.0" + wrap-ansi-cjs "npm:wrap-ansi@^7.0.0" + +"@jridgewell/gen-mapping@^0.3.2": + version "0.3.13" + resolved "https://registry.npmmirror.com/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz#6342a19f44347518c93e43b1ac69deb3c4656a1f" + integrity sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA== + dependencies: + "@jridgewell/sourcemap-codec" "^1.5.0" + "@jridgewell/trace-mapping" "^0.3.24" + +"@jridgewell/resolve-uri@^3.1.0": + version "3.1.2" + resolved "https://registry.npmmirror.com/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz#7a0ee601f60f99a20c7c7c5ff0c80388c1189bd6" + integrity sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw== + +"@jridgewell/sourcemap-codec@^1.4.14", "@jridgewell/sourcemap-codec@^1.5.0", "@jridgewell/sourcemap-codec@^1.5.5": + version "1.5.5" + resolved "https://registry.npmmirror.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz#6912b00d2c631c0d15ce1a7ab57cd657f2a8f8ba" + integrity sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og== + +"@jridgewell/trace-mapping@^0.3.24": + version "0.3.31" + resolved "https://registry.npmmirror.com/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz#db15d6781c931f3a251a3dac39501c98a6082fd0" + integrity sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw== + dependencies: + "@jridgewell/resolve-uri" "^3.1.0" + "@jridgewell/sourcemap-codec" "^1.4.14" + +"@nodelib/fs.scandir@2.1.5": + version "2.1.5" + resolved "https://registry.npmmirror.com/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz#7619c2eb21b25483f6d167548b4cfd5a7488c3d5" + integrity sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g== + dependencies: + "@nodelib/fs.stat" "2.0.5" + run-parallel "^1.1.9" + +"@nodelib/fs.stat@2.0.5", "@nodelib/fs.stat@^2.0.2": + version "2.0.5" + resolved "https://registry.npmmirror.com/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz#5bd262af94e9d25bd1e71b05deed44876a222e8b" + integrity sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A== + +"@nodelib/fs.walk@^1.2.3": + version "1.2.8" + resolved "https://registry.npmmirror.com/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz#e95737e8bb6746ddedf69c556953494f196fe69a" + integrity sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg== + dependencies: + "@nodelib/fs.scandir" "2.1.5" + fastq "^1.6.0" + +"@pkgjs/parseargs@^0.11.0": + version "0.11.0" + resolved "https://registry.npmmirror.com/@pkgjs/parseargs/-/parseargs-0.11.0.tgz#a77ea742fab25775145434eb1d2328cf5013ac33" + integrity sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg== + +"@rollup/rollup-android-arm-eabi@4.52.3": + version "4.52.3" + resolved "https://registry.npmmirror.com/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.52.3.tgz#7050c2acdc1214a730058e21f613ab0e1fe1ced9" + integrity sha512-h6cqHGZ6VdnwliFG1NXvMPTy/9PS3h8oLh7ImwR+kl+oYnQizgjxsONmmPSb2C66RksfkfIxEVtDSEcJiO0tqw== + +"@rollup/rollup-android-arm64@4.52.3": + version "4.52.3" + resolved "https://registry.npmmirror.com/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.52.3.tgz#3f5b2afbfcbe9021649701cf6ff0d54b1fb7e4a5" + integrity sha512-wd+u7SLT/u6knklV/ifG7gr5Qy4GUbH2hMWcDauPFJzmCZUAJ8L2bTkVXC2niOIxp8lk3iH/QX8kSrUxVZrOVw== + +"@rollup/rollup-darwin-arm64@4.52.3": + version "4.52.3" + resolved "https://registry.npmmirror.com/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.52.3.tgz#70a1679fb4393ba7bafb730ee56a5278cbcdafb0" + integrity sha512-lj9ViATR1SsqycwFkJCtYfQTheBdvlWJqzqxwc9f2qrcVrQaF/gCuBRTiTolkRWS6KvNxSk4KHZWG7tDktLgjg== + +"@rollup/rollup-darwin-x64@4.52.3": + version "4.52.3" + resolved "https://registry.npmmirror.com/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.52.3.tgz#ae75aec88fa72069de9bca3a3ec22bf4e6a962bf" + integrity sha512-+Dyo7O1KUmIsbzx1l+4V4tvEVnVQqMOIYtrxK7ncLSknl1xnMHLgn7gddJVrYPNZfEB8CIi3hK8gq8bDhb3h5A== + +"@rollup/rollup-freebsd-arm64@4.52.3": + version "4.52.3" + resolved "https://registry.npmmirror.com/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.52.3.tgz#8a2bda997faa1d7e335ce1961ce71d1a76ac6288" + integrity sha512-u9Xg2FavYbD30g3DSfNhxgNrxhi6xVG4Y6i9Ur1C7xUuGDW3banRbXj+qgnIrwRN4KeJ396jchwy9bCIzbyBEQ== + +"@rollup/rollup-freebsd-x64@4.52.3": + version "4.52.3" + resolved "https://registry.npmmirror.com/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.52.3.tgz#fc287bcc39b9a9c0df97336d68fd5f4458f87977" + integrity sha512-5M8kyi/OX96wtD5qJR89a/3x5x8x5inXBZO04JWhkQb2JWavOWfjgkdvUqibGJeNNaz1/Z1PPza5/tAPXICI6A== + +"@rollup/rollup-linux-arm-gnueabihf@4.52.3": + version "4.52.3" + resolved "https://registry.npmmirror.com/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.52.3.tgz#5b5a2a55dffaa64d7c7a231e80e491219e33d4f3" + integrity sha512-IoerZJ4l1wRMopEHRKOO16e04iXRDyZFZnNZKrWeNquh5d6bucjezgd+OxG03mOMTnS1x7hilzb3uURPkJ0OfA== + +"@rollup/rollup-linux-arm-musleabihf@4.52.3": + version "4.52.3" + resolved "https://registry.npmmirror.com/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.52.3.tgz#979eab95003c21837ea0fdd8a721aa3e69fa4aa3" + integrity sha512-ZYdtqgHTDfvrJHSh3W22TvjWxwOgc3ThK/XjgcNGP2DIwFIPeAPNsQxrJO5XqleSlgDux2VAoWQ5iJrtaC1TbA== + +"@rollup/rollup-linux-arm64-gnu@4.52.3": + version "4.52.3" + resolved "https://registry.npmmirror.com/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.52.3.tgz#53b89f1289cbeca5ed9b6ca1602a6fe1a29dd4e2" + integrity sha512-NcViG7A0YtuFDA6xWSgmFb6iPFzHlf5vcqb2p0lGEbT+gjrEEz8nC/EeDHvx6mnGXnGCC1SeVV+8u+smj0CeGQ== + +"@rollup/rollup-linux-arm64-musl@4.52.3": + version "4.52.3" + resolved "https://registry.npmmirror.com/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.52.3.tgz#3bbcf5e13c09d0c4c55bd9c75ec6a7aeee56fe28" + integrity sha512-d3pY7LWno6SYNXRm6Ebsq0DJGoiLXTb83AIPCXl9fmtIQs/rXoS8SJxxUNtFbJ5MiOvs+7y34np77+9l4nfFMw== + +"@rollup/rollup-linux-loong64-gnu@4.52.3": + version "4.52.3" + resolved "https://registry.npmmirror.com/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.52.3.tgz#1cc71838465a8297f92ccc5cc9c29756b71f6e73" + integrity sha512-3y5GA0JkBuirLqmjwAKwB0keDlI6JfGYduMlJD/Rl7fvb4Ni8iKdQs1eiunMZJhwDWdCvrcqXRY++VEBbvk6Eg== + +"@rollup/rollup-linux-ppc64-gnu@4.52.3": + version "4.52.3" + resolved "https://registry.npmmirror.com/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.52.3.tgz#fe3fdf2ef57dc2d58fedd4f1e0678660772c843a" + integrity sha512-AUUH65a0p3Q0Yfm5oD2KVgzTKgwPyp9DSXc3UA7DtxhEb/WSPfbG4wqXeSN62OG5gSo18em4xv6dbfcUGXcagw== + +"@rollup/rollup-linux-riscv64-gnu@4.52.3": + version "4.52.3" + resolved "https://registry.npmmirror.com/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.52.3.tgz#eebc99e75832891d58532501879ca749b1592f93" + integrity sha512-1makPhFFVBqZE+XFg3Dkq+IkQ7JvmUrwwqaYBL2CE+ZpxPaqkGaiWFEWVGyvTwZace6WLJHwjVh/+CXbKDGPmg== + +"@rollup/rollup-linux-riscv64-musl@4.52.3": + version "4.52.3" + resolved "https://registry.npmmirror.com/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.52.3.tgz#9a2df234d61763a44601eba17c36844a18f20539" + integrity sha512-OOFJa28dxfl8kLOPMUOQBCO6z3X2SAfzIE276fwT52uXDWUS178KWq0pL7d6p1kz7pkzA0yQwtqL0dEPoVcRWg== + +"@rollup/rollup-linux-s390x-gnu@4.52.3": + version "4.52.3" + resolved "https://registry.npmmirror.com/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.52.3.tgz#f0e45ea7e41ee473c85458b1ec8fab9572cc1834" + integrity sha512-jMdsML2VI5l+V7cKfZx3ak+SLlJ8fKvLJ0Eoa4b9/vCUrzXKgoKxvHqvJ/mkWhFiyp88nCkM5S2v6nIwRtPcgg== + +"@rollup/rollup-linux-x64-gnu@4.52.3": + version "4.52.3" + resolved "https://registry.npmmirror.com/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.52.3.tgz#ed63dec576799fa5571eee5b2040f65faa82b49b" + integrity sha512-tPgGd6bY2M2LJTA1uGq8fkSPK8ZLYjDjY+ZLK9WHncCnfIz29LIXIqUgzCR0hIefzy6Hpbe8Th5WOSwTM8E7LA== + +"@rollup/rollup-linux-x64-musl@4.52.3": + version "4.52.3" + resolved "https://registry.npmmirror.com/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.52.3.tgz#755c56ac79b17fbdf0359bce7e2293a11de30ad0" + integrity sha512-BCFkJjgk+WFzP+tcSMXq77ymAPIxsX9lFJWs+2JzuZTLtksJ2o5hvgTdIcZ5+oKzUDMwI0PfWzRBYAydAHF2Mw== + +"@rollup/rollup-openharmony-arm64@4.52.3": + version "4.52.3" + resolved "https://registry.npmmirror.com/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.52.3.tgz#84b4170fe28c2b41e406add6ccf8513bf91195ea" + integrity sha512-KTD/EqjZF3yvRaWUJdD1cW+IQBk4fbQaHYJUmP8N4XoKFZilVL8cobFSTDnjTtxWJQ3JYaMgF4nObY/+nYkumA== + +"@rollup/rollup-win32-arm64-msvc@4.52.3": + version "4.52.3" + resolved "https://registry.npmmirror.com/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.52.3.tgz#4fb0cd004183da819bec804eba70f1ef6936ccbf" + integrity sha512-+zteHZdoUYLkyYKObGHieibUFLbttX2r+58l27XZauq0tcWYYuKUwY2wjeCN9oK1Um2YgH2ibd6cnX/wFD7DuA== + +"@rollup/rollup-win32-ia32-msvc@4.52.3": + version "4.52.3" + resolved "https://registry.npmmirror.com/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.52.3.tgz#1788ba80313477a31e6214390906201604ee38eb" + integrity sha512-of1iHkTQSo3kr6dTIRX6t81uj/c/b15HXVsPcEElN5sS859qHrOepM5p9G41Hah+CTqSh2r8Bm56dL2z9UQQ7g== + +"@rollup/rollup-win32-x64-gnu@4.52.3": + version "4.52.3" + resolved "https://registry.npmmirror.com/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.52.3.tgz#867222f288a9557487900c7836998123ebbadc9d" + integrity sha512-s0hybmlHb56mWVZQj8ra9048/WZTPLILKxcvcq+8awSZmyiSUZjjem1AhU3Tf4ZKpYhK4mg36HtHDOe8QJS5PQ== + +"@rollup/rollup-win32-x64-msvc@4.52.3": + version "4.52.3" + resolved "https://registry.npmmirror.com/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.52.3.tgz#3f55b6e8fe809a7d29959d6bc686cce1804581f0" + integrity sha512-zGIbEVVXVtauFgl3MRwGWEN36P5ZGenHRMgNw88X5wEhEBpq0XrMEZwOn07+ICrwM17XO5xfMZqh0OldCH5VTA== + +"@types/chai@^5.2.2": + version "5.2.2" + resolved "https://registry.npmmirror.com/@types/chai/-/chai-5.2.2.tgz#6f14cea18180ffc4416bc0fd12be05fdd73bdd6b" + integrity sha512-8kB30R7Hwqf40JPiKhVzodJs2Qc1ZJ5zuT3uzw5Hq/dhNCl3G3l83jfpdI1e20BP348+fV7VIL/+FxaXkqBmWg== + dependencies: + "@types/deep-eql" "*" + +"@types/deep-eql@*": + version "4.0.2" + resolved "https://registry.npmmirror.com/@types/deep-eql/-/deep-eql-4.0.2.tgz#334311971d3a07121e7eb91b684a605e7eea9cbd" + integrity sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw== + +"@types/estree@1.0.8", "@types/estree@^1.0.0", "@types/estree@^1.0.6": + version "1.0.8" + resolved "https://registry.npmmirror.com/@types/estree/-/estree-1.0.8.tgz#958b91c991b1867ced318bedea0e215ee050726e" + integrity sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w== + +"@types/json-schema@^7.0.15": + version "7.0.15" + resolved "https://registry.npmmirror.com/@types/json-schema/-/json-schema-7.0.15.tgz#596a1747233694d50f6ad8a7869fcb6f56cf5841" + integrity sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA== + +"@typescript-eslint/eslint-plugin@8.45.0": + version "8.45.0" + resolved "https://registry.npmmirror.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.45.0.tgz#9f251d4e85ec5089e7cccb09257ce93dbf0d7744" + integrity sha512-HC3y9CVuevvWCl/oyZuI47dOeDF9ztdMEfMH8/DW/Mhwa9cCLnK1oD7JoTVGW/u7kFzNZUKUoyJEqkaJh5y3Wg== + dependencies: + "@eslint-community/regexpp" "^4.10.0" + "@typescript-eslint/scope-manager" "8.45.0" + "@typescript-eslint/type-utils" "8.45.0" + "@typescript-eslint/utils" "8.45.0" + "@typescript-eslint/visitor-keys" "8.45.0" + graphemer "^1.4.0" + ignore "^7.0.0" + natural-compare "^1.4.0" + ts-api-utils "^2.1.0" + +"@typescript-eslint/parser@8.45.0": + version "8.45.0" + resolved "https://registry.npmmirror.com/@typescript-eslint/parser/-/parser-8.45.0.tgz#571660c98824aefb4a6ec3b3766655d1348520a4" + integrity sha512-TGf22kon8KW+DeKaUmOibKWktRY8b2NSAZNdtWh798COm1NWx8+xJ6iFBtk3IvLdv6+LGLJLRlyhrhEDZWargQ== + dependencies: + "@typescript-eslint/scope-manager" "8.45.0" + "@typescript-eslint/types" "8.45.0" + "@typescript-eslint/typescript-estree" "8.45.0" + "@typescript-eslint/visitor-keys" "8.45.0" + debug "^4.3.4" + +"@typescript-eslint/project-service@8.45.0": + version "8.45.0" + resolved "https://registry.npmmirror.com/@typescript-eslint/project-service/-/project-service-8.45.0.tgz#f83dda1bca31dae2fd6821f9131daf1edebfd46c" + integrity sha512-3pcVHwMG/iA8afdGLMuTibGR7pDsn9RjDev6CCB+naRsSYs2pns5QbinF4Xqw6YC/Sj3lMrm/Im0eMfaa61WUg== + dependencies: + "@typescript-eslint/tsconfig-utils" "^8.45.0" + "@typescript-eslint/types" "^8.45.0" + debug "^4.3.4" + +"@typescript-eslint/scope-manager@8.45.0": + version "8.45.0" + resolved "https://registry.npmmirror.com/@typescript-eslint/scope-manager/-/scope-manager-8.45.0.tgz#59615ba506a9e3479d1efb0d09d6ab52f2a19142" + integrity sha512-clmm8XSNj/1dGvJeO6VGH7EUSeA0FMs+5au/u3lrA3KfG8iJ4u8ym9/j2tTEoacAffdW1TVUzXO30W1JTJS7dA== + dependencies: + "@typescript-eslint/types" "8.45.0" + "@typescript-eslint/visitor-keys" "8.45.0" + +"@typescript-eslint/tsconfig-utils@8.45.0", "@typescript-eslint/tsconfig-utils@^8.45.0": + version "8.45.0" + resolved "https://registry.npmmirror.com/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.45.0.tgz#63d38282790a2566c571bad423e7c1cad1f3d64c" + integrity sha512-aFdr+c37sc+jqNMGhH+ajxPXwjv9UtFZk79k8pLoJ6p4y0snmYpPA52GuWHgt2ZF4gRRW6odsEj41uZLojDt5w== + +"@typescript-eslint/type-utils@8.45.0": + version "8.45.0" + resolved "https://registry.npmmirror.com/@typescript-eslint/type-utils/-/type-utils-8.45.0.tgz#04004bdf2598844faa29fb936fb6b0ee10d6d3f3" + integrity sha512-bpjepLlHceKgyMEPglAeULX1vixJDgaKocp0RVJ5u4wLJIMNuKtUXIczpJCPcn2waII0yuvks/5m5/h3ZQKs0A== + dependencies: + "@typescript-eslint/types" "8.45.0" + "@typescript-eslint/typescript-estree" "8.45.0" + "@typescript-eslint/utils" "8.45.0" + debug "^4.3.4" + ts-api-utils "^2.1.0" + +"@typescript-eslint/types@8.45.0", "@typescript-eslint/types@^8.45.0": + version "8.45.0" + resolved "https://registry.npmmirror.com/@typescript-eslint/types/-/types-8.45.0.tgz#fc01cd2a4690b9713b02f895e82fb43f7d960684" + integrity sha512-WugXLuOIq67BMgQInIxxnsSyRLFxdkJEJu8r4ngLR56q/4Q5LrbfkFRH27vMTjxEK8Pyz7QfzuZe/G15qQnVRA== + +"@typescript-eslint/typescript-estree@8.45.0": + version "8.45.0" + resolved "https://registry.npmmirror.com/@typescript-eslint/typescript-estree/-/typescript-estree-8.45.0.tgz#3498500f109a89b104d2770497c707e56dfe062d" + integrity sha512-GfE1NfVbLam6XQ0LcERKwdTTPlLvHvXXhOeUGC1OXi4eQBoyy1iVsW+uzJ/J9jtCz6/7GCQ9MtrQ0fml/jWCnA== + dependencies: + "@typescript-eslint/project-service" "8.45.0" + "@typescript-eslint/tsconfig-utils" "8.45.0" + "@typescript-eslint/types" "8.45.0" + "@typescript-eslint/visitor-keys" "8.45.0" + debug "^4.3.4" + fast-glob "^3.3.2" + is-glob "^4.0.3" + minimatch "^9.0.4" + semver "^7.6.0" + ts-api-utils "^2.1.0" + +"@typescript-eslint/utils@8.45.0": + version "8.45.0" + resolved "https://registry.npmmirror.com/@typescript-eslint/utils/-/utils-8.45.0.tgz#6e68e92d99019fdf56018d0e6664c76a70470c95" + integrity sha512-bxi1ht+tLYg4+XV2knz/F7RVhU0k6VrSMc9sb8DQ6fyCTrGQLHfo7lDtN0QJjZjKkLA2ThrKuCdHEvLReqtIGg== + dependencies: + "@eslint-community/eslint-utils" "^4.7.0" + "@typescript-eslint/scope-manager" "8.45.0" + "@typescript-eslint/types" "8.45.0" + "@typescript-eslint/typescript-estree" "8.45.0" + +"@typescript-eslint/visitor-keys@8.45.0": + version "8.45.0" + resolved "https://registry.npmmirror.com/@typescript-eslint/visitor-keys/-/visitor-keys-8.45.0.tgz#4e3bcc55da64ac61069ebfe62ca240567ac7d784" + integrity sha512-qsaFBA3e09MIDAGFUrTk+dzqtfv1XPVz8t8d1f0ybTzrCY7BKiMC5cjrl1O/P7UmHsNyW90EYSkU/ZWpmXelag== + dependencies: + "@typescript-eslint/types" "8.45.0" + eslint-visitor-keys "^4.2.1" + +"@vitest/expect@3.2.4": + version "3.2.4" + resolved "https://registry.npmmirror.com/@vitest/expect/-/expect-3.2.4.tgz#8362124cd811a5ee11c5768207b9df53d34f2433" + integrity sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig== + dependencies: + "@types/chai" "^5.2.2" + "@vitest/spy" "3.2.4" + "@vitest/utils" "3.2.4" + chai "^5.2.0" + tinyrainbow "^2.0.0" + +"@vitest/mocker@3.2.4": + version "3.2.4" + resolved "https://registry.npmmirror.com/@vitest/mocker/-/mocker-3.2.4.tgz#4471c4efbd62db0d4fa203e65cc6b058a85cabd3" + integrity sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ== + dependencies: + "@vitest/spy" "3.2.4" + estree-walker "^3.0.3" + magic-string "^0.30.17" + +"@vitest/pretty-format@3.2.4", "@vitest/pretty-format@^3.2.4": + version "3.2.4" + resolved "https://registry.npmmirror.com/@vitest/pretty-format/-/pretty-format-3.2.4.tgz#3c102f79e82b204a26c7a5921bf47d534919d3b4" + integrity sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA== + dependencies: + tinyrainbow "^2.0.0" + +"@vitest/runner@3.2.4": + version "3.2.4" + resolved "https://registry.npmmirror.com/@vitest/runner/-/runner-3.2.4.tgz#5ce0274f24a971f6500f6fc166d53d8382430766" + integrity sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ== + dependencies: + "@vitest/utils" "3.2.4" + pathe "^2.0.3" + strip-literal "^3.0.0" + +"@vitest/snapshot@3.2.4": + version "3.2.4" + resolved "https://registry.npmmirror.com/@vitest/snapshot/-/snapshot-3.2.4.tgz#40a8bc0346ac0aee923c0eefc2dc005d90bc987c" + integrity sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ== + dependencies: + "@vitest/pretty-format" "3.2.4" + magic-string "^0.30.17" + pathe "^2.0.3" + +"@vitest/spy@3.2.4": + version "3.2.4" + resolved "https://registry.npmmirror.com/@vitest/spy/-/spy-3.2.4.tgz#cc18f26f40f3f028da6620046881f4e4518c2599" + integrity sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw== + dependencies: + tinyspy "^4.0.3" + +"@vitest/utils@3.2.4": + version "3.2.4" + resolved "https://registry.npmmirror.com/@vitest/utils/-/utils-3.2.4.tgz#c0813bc42d99527fb8c5b138c7a88516bca46fea" + integrity sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA== + dependencies: + "@vitest/pretty-format" "3.2.4" + loupe "^3.1.4" + tinyrainbow "^2.0.0" + +acorn-jsx@^5.3.2: + version "5.3.2" + resolved "https://registry.npmmirror.com/acorn-jsx/-/acorn-jsx-5.3.2.tgz#7ed5bb55908b3b2f1bc55c6af1653bada7f07937" + integrity sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ== + +acorn@^8.15.0: + version "8.15.0" + resolved "https://registry.npmmirror.com/acorn/-/acorn-8.15.0.tgz#a360898bc415edaac46c8241f6383975b930b816" + integrity sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg== + +ajv@^6.12.4: + version "6.12.6" + resolved "https://registry.npmmirror.com/ajv/-/ajv-6.12.6.tgz#baf5a62e802b07d977034586f8c3baf5adf26df4" + integrity sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g== + dependencies: + fast-deep-equal "^3.1.1" + fast-json-stable-stringify "^2.0.0" + json-schema-traverse "^0.4.1" + uri-js "^4.2.2" + +ansi-regex@^5.0.1: + version "5.0.1" + resolved "https://registry.npmmirror.com/ansi-regex/-/ansi-regex-5.0.1.tgz#082cb2c89c9fe8659a311a53bd6a4dc5301db304" + integrity sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ== + +ansi-regex@^6.0.1: + version "6.2.2" + resolved "https://registry.npmmirror.com/ansi-regex/-/ansi-regex-6.2.2.tgz#60216eea464d864597ce2832000738a0589650c1" + integrity sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg== + +ansi-styles@^4.0.0, ansi-styles@^4.1.0: + version "4.3.0" + resolved "https://registry.npmmirror.com/ansi-styles/-/ansi-styles-4.3.0.tgz#edd803628ae71c04c85ae7a0906edad34b648937" + integrity sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg== + dependencies: + color-convert "^2.0.1" + +ansi-styles@^6.1.0: + version "6.2.3" + resolved "https://registry.npmmirror.com/ansi-styles/-/ansi-styles-6.2.3.tgz#c044d5dcc521a076413472597a1acb1f103c4041" + integrity sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg== + +any-promise@^1.0.0: + version "1.3.0" + resolved "https://registry.npmmirror.com/any-promise/-/any-promise-1.3.0.tgz#abc6afeedcea52e809cdc0376aed3ce39635d17f" + integrity sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A== + +argparse@^2.0.1: + version "2.0.1" + resolved "https://registry.npmmirror.com/argparse/-/argparse-2.0.1.tgz#246f50f3ca78a3240f6c997e8a9bd1eac49e4b38" + integrity sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q== + +assertion-error@^2.0.1: + version "2.0.1" + resolved "https://registry.npmmirror.com/assertion-error/-/assertion-error-2.0.1.tgz#f641a196b335690b1070bf00b6e7593fec190bf7" + integrity sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA== + +balanced-match@^1.0.0: + version "1.0.2" + resolved "https://registry.npmmirror.com/balanced-match/-/balanced-match-1.0.2.tgz#e83e3a7e3f300b34cb9d87f615fa0cbf357690ee" + integrity sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw== + +brace-expansion@^1.1.7: + version "1.1.12" + resolved "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-1.1.12.tgz#ab9b454466e5a8cc3a187beaad580412a9c5b843" + integrity sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg== + dependencies: + balanced-match "^1.0.0" + concat-map "0.0.1" + +brace-expansion@^2.0.1: + version "2.0.2" + resolved "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-2.0.2.tgz#54fc53237a613d854c7bd37463aad17df87214e7" + integrity sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ== + dependencies: + balanced-match "^1.0.0" + +braces@^3.0.3: + version "3.0.3" + resolved "https://registry.npmmirror.com/braces/-/braces-3.0.3.tgz#490332f40919452272d55a8480adc0c441358789" + integrity sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA== + dependencies: + fill-range "^7.1.1" + +bundle-require@^5.1.0: + version "5.1.0" + resolved "https://registry.npmmirror.com/bundle-require/-/bundle-require-5.1.0.tgz#8db66f41950da3d77af1ef3322f4c3e04009faee" + integrity sha512-3WrrOuZiyaaZPWiEt4G3+IffISVC9HYlWueJEBWED4ZH4aIAC2PnkdnuRrR94M+w6yGWn4AglWtJtBI8YqvgoA== + dependencies: + load-tsconfig "^0.2.3" + +cac@^6.7.14: + version "6.7.14" + resolved "https://registry.npmmirror.com/cac/-/cac-6.7.14.tgz#804e1e6f506ee363cb0e3ccbb09cad5dd9870959" + integrity sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ== + +callsites@^3.0.0: + version "3.1.0" + resolved "https://registry.npmmirror.com/callsites/-/callsites-3.1.0.tgz#b3630abd8943432f54b3f0519238e33cd7df2f73" + integrity sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ== + +chai@^5.2.0: + version "5.3.3" + resolved "https://registry.npmmirror.com/chai/-/chai-5.3.3.tgz#dd3da955e270916a4bd3f625f4b919996ada7e06" + integrity sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw== + dependencies: + assertion-error "^2.0.1" + check-error "^2.1.1" + deep-eql "^5.0.1" + loupe "^3.1.0" + pathval "^2.0.0" + +chalk@^4.0.0: + version "4.1.2" + resolved "https://registry.npmmirror.com/chalk/-/chalk-4.1.2.tgz#aac4e2b7734a740867aeb16bf02aad556a1e7a01" + integrity sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA== + dependencies: + ansi-styles "^4.1.0" + supports-color "^7.1.0" + +check-error@^2.1.1: + version "2.1.1" + resolved "https://registry.npmmirror.com/check-error/-/check-error-2.1.1.tgz#87eb876ae71ee388fa0471fe423f494be1d96ccc" + integrity sha512-OAlb+T7V4Op9OwdkjmguYRqncdlx5JiofwOAUkmTF+jNdHwzTaTs4sRAGpzLF3oOz5xAyDGrPgeIDFQmDOTiJw== + +chokidar@^4.0.3: + version "4.0.3" + resolved "https://registry.npmmirror.com/chokidar/-/chokidar-4.0.3.tgz#7be37a4c03c9aee1ecfe862a4a23b2c70c205d30" + integrity sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA== + dependencies: + readdirp "^4.0.1" + +color-convert@^2.0.1: + version "2.0.1" + resolved "https://registry.npmmirror.com/color-convert/-/color-convert-2.0.1.tgz#72d3a68d598c9bdb3af2ad1e84f21d896abd4de3" + integrity sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ== + dependencies: + color-name "~1.1.4" + +color-name@~1.1.4: + version "1.1.4" + resolved "https://registry.npmmirror.com/color-name/-/color-name-1.1.4.tgz#c2a09a87acbde69543de6f63fa3995c826c536a2" + integrity sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA== + +commander@^4.0.0: + version "4.1.1" + resolved "https://registry.npmmirror.com/commander/-/commander-4.1.1.tgz#9fd602bd936294e9e9ef46a3f4d6964044b18068" + integrity sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA== + +concat-map@0.0.1: + version "0.0.1" + resolved "https://registry.npmmirror.com/concat-map/-/concat-map-0.0.1.tgz#d8a96bd77fd68df7793a73036a3ba0d5405d477b" + integrity sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg== + +confbox@^0.1.8: + version "0.1.8" + resolved "https://registry.npmmirror.com/confbox/-/confbox-0.1.8.tgz#820d73d3b3c82d9bd910652c5d4d599ef8ff8b06" + integrity sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w== + +consola@^3.4.0: + version "3.4.2" + resolved "https://registry.npmmirror.com/consola/-/consola-3.4.2.tgz#5af110145397bb67afdab77013fdc34cae590ea7" + integrity sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA== + +cross-spawn@^7.0.6: + version "7.0.6" + resolved "https://registry.npmmirror.com/cross-spawn/-/cross-spawn-7.0.6.tgz#8a58fe78f00dcd70c370451759dfbfaf03e8ee9f" + integrity sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA== + dependencies: + path-key "^3.1.0" + shebang-command "^2.0.0" + which "^2.0.1" + +debug@^4.3.1, debug@^4.3.2, debug@^4.3.4, debug@^4.4.0, debug@^4.4.1: + version "4.4.3" + resolved "https://registry.npmmirror.com/debug/-/debug-4.4.3.tgz#c6ae432d9bd9662582fce08709b038c58e9e3d6a" + integrity sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA== + dependencies: + ms "^2.1.3" + +deep-eql@^5.0.1: + version "5.0.2" + resolved "https://registry.npmmirror.com/deep-eql/-/deep-eql-5.0.2.tgz#4b756d8d770a9257300825d52a2c2cff99c3a341" + integrity sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q== + +deep-is@^0.1.3: + version "0.1.4" + resolved "https://registry.npmmirror.com/deep-is/-/deep-is-0.1.4.tgz#a6f2dce612fadd2ef1f519b73551f17e85199831" + integrity sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ== + +eastasianwidth@^0.2.0: + version "0.2.0" + resolved "https://registry.npmmirror.com/eastasianwidth/-/eastasianwidth-0.2.0.tgz#696ce2ec0aa0e6ea93a397ffcf24aa7840c827cb" + integrity sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA== + +emoji-regex@^8.0.0: + version "8.0.0" + resolved "https://registry.npmmirror.com/emoji-regex/-/emoji-regex-8.0.0.tgz#e818fd69ce5ccfcb404594f842963bf53164cc37" + integrity sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A== + +emoji-regex@^9.2.2: + version "9.2.2" + resolved "https://registry.npmmirror.com/emoji-regex/-/emoji-regex-9.2.2.tgz#840c8803b0d8047f4ff0cf963176b32d4ef3ed72" + integrity sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg== + +es-module-lexer@^1.7.0: + version "1.7.0" + resolved "https://registry.npmmirror.com/es-module-lexer/-/es-module-lexer-1.7.0.tgz#9159601561880a85f2734560a9099b2c31e5372a" + integrity sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA== + +esbuild@^0.25.0: + version "0.25.10" + resolved "https://registry.npmmirror.com/esbuild/-/esbuild-0.25.10.tgz#37f5aa5cd14500f141be121c01b096ca83ac34a9" + integrity sha512-9RiGKvCwaqxO2owP61uQ4BgNborAQskMR6QusfWzQqv7AZOg5oGehdY2pRJMTKuwxd1IDBP4rSbI5lHzU7SMsQ== + optionalDependencies: + "@esbuild/aix-ppc64" "0.25.10" + "@esbuild/android-arm" "0.25.10" + "@esbuild/android-arm64" "0.25.10" + "@esbuild/android-x64" "0.25.10" + "@esbuild/darwin-arm64" "0.25.10" + "@esbuild/darwin-x64" "0.25.10" + "@esbuild/freebsd-arm64" "0.25.10" + "@esbuild/freebsd-x64" "0.25.10" + "@esbuild/linux-arm" "0.25.10" + "@esbuild/linux-arm64" "0.25.10" + "@esbuild/linux-ia32" "0.25.10" + "@esbuild/linux-loong64" "0.25.10" + "@esbuild/linux-mips64el" "0.25.10" + "@esbuild/linux-ppc64" "0.25.10" + "@esbuild/linux-riscv64" "0.25.10" + "@esbuild/linux-s390x" "0.25.10" + "@esbuild/linux-x64" "0.25.10" + "@esbuild/netbsd-arm64" "0.25.10" + "@esbuild/netbsd-x64" "0.25.10" + "@esbuild/openbsd-arm64" "0.25.10" + "@esbuild/openbsd-x64" "0.25.10" + "@esbuild/openharmony-arm64" "0.25.10" + "@esbuild/sunos-x64" "0.25.10" + "@esbuild/win32-arm64" "0.25.10" + "@esbuild/win32-ia32" "0.25.10" + "@esbuild/win32-x64" "0.25.10" + +escape-string-regexp@^4.0.0: + version "4.0.0" + resolved "https://registry.npmmirror.com/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz#14ba83a5d373e3d311e5afca29cf5bfad965bf34" + integrity sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA== + +eslint-config-prettier@10.1.8: + version "10.1.8" + resolved "https://registry.npmmirror.com/eslint-config-prettier/-/eslint-config-prettier-10.1.8.tgz#15734ce4af8c2778cc32f0b01b37b0b5cd1ecb97" + integrity sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w== + +eslint-scope@^8.4.0: + version "8.4.0" + resolved "https://registry.npmmirror.com/eslint-scope/-/eslint-scope-8.4.0.tgz#88e646a207fad61436ffa39eb505147200655c82" + integrity sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg== + dependencies: + esrecurse "^4.3.0" + estraverse "^5.2.0" + +eslint-visitor-keys@^3.4.3: + version "3.4.3" + resolved "https://registry.npmmirror.com/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz#0cd72fe8550e3c2eae156a96a4dddcd1c8ac5800" + integrity sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag== + +eslint-visitor-keys@^4.2.1: + version "4.2.1" + resolved "https://registry.npmmirror.com/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz#4cfea60fe7dd0ad8e816e1ed026c1d5251b512c1" + integrity sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ== + +eslint@9.36.0: + version "9.36.0" + resolved "https://registry.npmmirror.com/eslint/-/eslint-9.36.0.tgz#9cc5cbbfb9c01070425d9bfed81b4e79a1c09088" + integrity sha512-hB4FIzXovouYzwzECDcUkJ4OcfOEkXTv2zRY6B9bkwjx/cprAq0uvm1nl7zvQ0/TsUk0zQiN4uPfJpB9m+rPMQ== + dependencies: + "@eslint-community/eslint-utils" "^4.8.0" + "@eslint-community/regexpp" "^4.12.1" + "@eslint/config-array" "^0.21.0" + "@eslint/config-helpers" "^0.3.1" + "@eslint/core" "^0.15.2" + "@eslint/eslintrc" "^3.3.1" + "@eslint/js" "9.36.0" + "@eslint/plugin-kit" "^0.3.5" + "@humanfs/node" "^0.16.6" + "@humanwhocodes/module-importer" "^1.0.1" + "@humanwhocodes/retry" "^0.4.2" + "@types/estree" "^1.0.6" + "@types/json-schema" "^7.0.15" + ajv "^6.12.4" + chalk "^4.0.0" + cross-spawn "^7.0.6" + debug "^4.3.2" + escape-string-regexp "^4.0.0" + eslint-scope "^8.4.0" + eslint-visitor-keys "^4.2.1" + espree "^10.4.0" + esquery "^1.5.0" + esutils "^2.0.2" + fast-deep-equal "^3.1.3" + file-entry-cache "^8.0.0" + find-up "^5.0.0" + glob-parent "^6.0.2" + ignore "^5.2.0" + imurmurhash "^0.1.4" + is-glob "^4.0.0" + json-stable-stringify-without-jsonify "^1.0.1" + lodash.merge "^4.6.2" + minimatch "^3.1.2" + natural-compare "^1.4.0" + optionator "^0.9.3" + +espree@^10.0.1, espree@^10.4.0: + version "10.4.0" + resolved "https://registry.npmmirror.com/espree/-/espree-10.4.0.tgz#d54f4949d4629005a1fa168d937c3ff1f7e2a837" + integrity sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ== + dependencies: + acorn "^8.15.0" + acorn-jsx "^5.3.2" + eslint-visitor-keys "^4.2.1" + +esquery@^1.5.0: + version "1.6.0" + resolved "https://registry.npmmirror.com/esquery/-/esquery-1.6.0.tgz#91419234f804d852a82dceec3e16cdc22cf9dae7" + integrity sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg== + dependencies: + estraverse "^5.1.0" + +esrecurse@^4.3.0: + version "4.3.0" + resolved "https://registry.npmmirror.com/esrecurse/-/esrecurse-4.3.0.tgz#7ad7964d679abb28bee72cec63758b1c5d2c9921" + integrity sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag== + dependencies: + estraverse "^5.2.0" + +estraverse@^5.1.0, estraverse@^5.2.0: + version "5.3.0" + resolved "https://registry.npmmirror.com/estraverse/-/estraverse-5.3.0.tgz#2eea5290702f26ab8fe5370370ff86c965d21123" + integrity sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA== + +estree-walker@^3.0.3: + version "3.0.3" + resolved "https://registry.npmmirror.com/estree-walker/-/estree-walker-3.0.3.tgz#67c3e549ec402a487b4fc193d1953a524752340d" + integrity sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g== + dependencies: + "@types/estree" "^1.0.0" + +esutils@^2.0.2: + version "2.0.3" + resolved "https://registry.npmmirror.com/esutils/-/esutils-2.0.3.tgz#74d2eb4de0b8da1293711910d50775b9b710ef64" + integrity sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g== + +expect-type@^1.2.1: + version "1.2.2" + resolved "https://registry.npmmirror.com/expect-type/-/expect-type-1.2.2.tgz#c030a329fb61184126c8447585bc75a7ec6fbff3" + integrity sha512-JhFGDVJ7tmDJItKhYgJCGLOWjuK9vPxiXoUFLwLDc99NlmklilbiQJwoctZtt13+xMw91MCk/REan6MWHqDjyA== + +fast-deep-equal@^3.1.1, fast-deep-equal@^3.1.3: + version "3.1.3" + resolved "https://registry.npmmirror.com/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz#3a7d56b559d6cbc3eb512325244e619a65c6c525" + integrity sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q== + +fast-glob@^3.3.2: + version "3.3.3" + resolved "https://registry.npmmirror.com/fast-glob/-/fast-glob-3.3.3.tgz#d06d585ce8dba90a16b0505c543c3ccfb3aeb818" + integrity sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg== + dependencies: + "@nodelib/fs.stat" "^2.0.2" + "@nodelib/fs.walk" "^1.2.3" + glob-parent "^5.1.2" + merge2 "^1.3.0" + micromatch "^4.0.8" + +fast-json-stable-stringify@^2.0.0: + version "2.1.0" + resolved "https://registry.npmmirror.com/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz#874bf69c6f404c2b5d99c481341399fd55892633" + integrity sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw== + +fast-levenshtein@^2.0.6: + version "2.0.6" + resolved "https://registry.npmmirror.com/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz#3d8a5c66883a16a30ca8643e851f19baa7797917" + integrity sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw== + +fastq@^1.6.0: + version "1.19.1" + resolved "https://registry.npmmirror.com/fastq/-/fastq-1.19.1.tgz#d50eaba803c8846a883c16492821ebcd2cda55f5" + integrity sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ== + dependencies: + reusify "^1.0.4" + +fdir@^6.5.0: + version "6.5.0" + resolved "https://registry.npmmirror.com/fdir/-/fdir-6.5.0.tgz#ed2ab967a331ade62f18d077dae192684d50d350" + integrity sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg== + +file-entry-cache@^8.0.0: + version "8.0.0" + resolved "https://registry.npmmirror.com/file-entry-cache/-/file-entry-cache-8.0.0.tgz#7787bddcf1131bffb92636c69457bbc0edd6d81f" + integrity sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ== + dependencies: + flat-cache "^4.0.0" + +fill-range@^7.1.1: + version "7.1.1" + resolved "https://registry.npmmirror.com/fill-range/-/fill-range-7.1.1.tgz#44265d3cac07e3ea7dc247516380643754a05292" + integrity sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg== + dependencies: + to-regex-range "^5.0.1" + +find-up@^5.0.0: + version "5.0.0" + resolved "https://registry.npmmirror.com/find-up/-/find-up-5.0.0.tgz#4c92819ecb7083561e4f4a240a86be5198f536fc" + integrity sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng== + dependencies: + locate-path "^6.0.0" + path-exists "^4.0.0" + +fix-dts-default-cjs-exports@^1.0.0: + version "1.0.1" + resolved "https://registry.npmmirror.com/fix-dts-default-cjs-exports/-/fix-dts-default-cjs-exports-1.0.1.tgz#955cb6b3d519691c57828b078adadf2cb92e9549" + integrity sha512-pVIECanWFC61Hzl2+oOCtoJ3F17kglZC/6N94eRWycFgBH35hHx0Li604ZIzhseh97mf2p0cv7vVrOZGoqhlEg== + dependencies: + magic-string "^0.30.17" + mlly "^1.7.4" + rollup "^4.34.8" + +flat-cache@^4.0.0: + version "4.0.1" + resolved "https://registry.npmmirror.com/flat-cache/-/flat-cache-4.0.1.tgz#0ece39fcb14ee012f4b0410bd33dd9c1f011127c" + integrity sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw== + dependencies: + flatted "^3.2.9" + keyv "^4.5.4" + +flatted@^3.2.9: + version "3.3.3" + resolved "https://registry.npmmirror.com/flatted/-/flatted-3.3.3.tgz#67c8fad95454a7c7abebf74bb78ee74a44023358" + integrity sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg== + +foreground-child@^3.1.0: + version "3.3.1" + resolved "https://registry.npmmirror.com/foreground-child/-/foreground-child-3.3.1.tgz#32e8e9ed1b68a3497befb9ac2b6adf92a638576f" + integrity sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw== + dependencies: + cross-spawn "^7.0.6" + signal-exit "^4.0.1" + +fsevents@~2.3.2, fsevents@~2.3.3: + version "2.3.3" + resolved "https://registry.npmmirror.com/fsevents/-/fsevents-2.3.3.tgz#cac6407785d03675a2a5e1a5305c697b347d90d6" + integrity sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw== + +glob-parent@^5.1.2: + version "5.1.2" + resolved "https://registry.npmmirror.com/glob-parent/-/glob-parent-5.1.2.tgz#869832c58034fe68a4093c17dc15e8340d8401c4" + integrity sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow== + dependencies: + is-glob "^4.0.1" + +glob-parent@^6.0.2: + version "6.0.2" + resolved "https://registry.npmmirror.com/glob-parent/-/glob-parent-6.0.2.tgz#6d237d99083950c79290f24c7642a3de9a28f9e3" + integrity sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A== + dependencies: + is-glob "^4.0.3" + +glob@^10.3.10: + version "10.4.5" + resolved "https://registry.npmmirror.com/glob/-/glob-10.4.5.tgz#f4d9f0b90ffdbab09c9d77f5f29b4262517b0956" + integrity sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg== + dependencies: + foreground-child "^3.1.0" + jackspeak "^3.1.2" + minimatch "^9.0.4" + minipass "^7.1.2" + package-json-from-dist "^1.0.0" + path-scurry "^1.11.1" + +globals@^14.0.0: + version "14.0.0" + resolved "https://registry.npmmirror.com/globals/-/globals-14.0.0.tgz#898d7413c29babcf6bafe56fcadded858ada724e" + integrity sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ== + +graphemer@^1.4.0: + version "1.4.0" + resolved "https://registry.npmmirror.com/graphemer/-/graphemer-1.4.0.tgz#fb2f1d55e0e3a1849aeffc90c4fa0dd53a0e66c6" + integrity sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag== + +has-flag@^4.0.0: + version "4.0.0" + resolved "https://registry.npmmirror.com/has-flag/-/has-flag-4.0.0.tgz#944771fd9c81c81265c4d6941860da06bb59479b" + integrity sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ== + +ignore@^5.2.0: + version "5.3.2" + resolved "https://registry.npmmirror.com/ignore/-/ignore-5.3.2.tgz#3cd40e729f3643fd87cb04e50bf0eb722bc596f5" + integrity sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g== + +ignore@^7.0.0: + version "7.0.5" + resolved "https://registry.npmmirror.com/ignore/-/ignore-7.0.5.tgz#4cb5f6cd7d4c7ab0365738c7aea888baa6d7efd9" + integrity sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg== + +import-fresh@^3.2.1: + version "3.3.1" + resolved "https://registry.npmmirror.com/import-fresh/-/import-fresh-3.3.1.tgz#9cecb56503c0ada1f2741dbbd6546e4b13b57ccf" + integrity sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ== + dependencies: + parent-module "^1.0.0" + resolve-from "^4.0.0" + +imurmurhash@^0.1.4: + version "0.1.4" + resolved "https://registry.npmmirror.com/imurmurhash/-/imurmurhash-0.1.4.tgz#9218b9b2b928a238b13dc4fb6b6d576f231453ea" + integrity sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA== + +is-extglob@^2.1.1: + version "2.1.1" + resolved "https://registry.npmmirror.com/is-extglob/-/is-extglob-2.1.1.tgz#a88c02535791f02ed37c76a1b9ea9773c833f8c2" + integrity sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ== + +is-fullwidth-code-point@^3.0.0: + version "3.0.0" + resolved "https://registry.npmmirror.com/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz#f116f8064fe90b3f7844a38997c0b75051269f1d" + integrity sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg== + +is-glob@^4.0.0, is-glob@^4.0.1, is-glob@^4.0.3: + version "4.0.3" + resolved "https://registry.npmmirror.com/is-glob/-/is-glob-4.0.3.tgz#64f61e42cbbb2eec2071a9dac0b28ba1e65d5084" + integrity sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg== + dependencies: + is-extglob "^2.1.1" + +is-number@^7.0.0: + version "7.0.0" + resolved "https://registry.npmmirror.com/is-number/-/is-number-7.0.0.tgz#7535345b896734d5f80c4d06c50955527a14f12b" + integrity sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng== + +isexe@^2.0.0: + version "2.0.0" + resolved "https://registry.npmmirror.com/isexe/-/isexe-2.0.0.tgz#e8fbf374dc556ff8947a10dcb0572d633f2cfa10" + integrity sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw== + +jackspeak@^3.1.2: + version "3.4.3" + resolved "https://registry.npmmirror.com/jackspeak/-/jackspeak-3.4.3.tgz#8833a9d89ab4acde6188942bd1c53b6390ed5a8a" + integrity sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw== + dependencies: + "@isaacs/cliui" "^8.0.2" + optionalDependencies: + "@pkgjs/parseargs" "^0.11.0" + +joycon@^3.1.1: + version "3.1.1" + resolved "https://registry.npmmirror.com/joycon/-/joycon-3.1.1.tgz#bce8596d6ae808f8b68168f5fc69280996894f03" + integrity sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw== + +js-tokens@^9.0.1: + version "9.0.1" + resolved "https://registry.npmmirror.com/js-tokens/-/js-tokens-9.0.1.tgz#2ec43964658435296f6761b34e10671c2d9527f4" + integrity sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ== + +js-yaml@^4.1.0: + version "4.1.0" + resolved "https://registry.npmmirror.com/js-yaml/-/js-yaml-4.1.0.tgz#c1fb65f8f5017901cdd2c951864ba18458a10602" + integrity sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA== + dependencies: + argparse "^2.0.1" + +json-buffer@3.0.1: + version "3.0.1" + resolved "https://registry.npmmirror.com/json-buffer/-/json-buffer-3.0.1.tgz#9338802a30d3b6605fbe0613e094008ca8c05a13" + integrity sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ== + +json-schema-traverse@^0.4.1: + version "0.4.1" + resolved "https://registry.npmmirror.com/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz#69f6a87d9513ab8bb8fe63bdb0979c448e684660" + integrity sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg== + +json-stable-stringify-without-jsonify@^1.0.1: + version "1.0.1" + resolved "https://registry.npmmirror.com/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz#9db7b59496ad3f3cfef30a75142d2d930ad72651" + integrity sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw== + +keyv@^4.5.4: + version "4.5.4" + resolved "https://registry.npmmirror.com/keyv/-/keyv-4.5.4.tgz#a879a99e29452f942439f2a405e3af8b31d4de93" + integrity sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw== + dependencies: + json-buffer "3.0.1" + +levn@^0.4.1: + version "0.4.1" + resolved "https://registry.npmmirror.com/levn/-/levn-0.4.1.tgz#ae4562c007473b932a6200d403268dd2fffc6ade" + integrity sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ== + dependencies: + prelude-ls "^1.2.1" + type-check "~0.4.0" + +lilconfig@^3.1.1: + version "3.1.3" + resolved "https://registry.npmmirror.com/lilconfig/-/lilconfig-3.1.3.tgz#a1bcfd6257f9585bf5ae14ceeebb7b559025e4c4" + integrity sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw== + +lines-and-columns@^1.1.6: + version "1.2.4" + resolved "https://registry.npmmirror.com/lines-and-columns/-/lines-and-columns-1.2.4.tgz#eca284f75d2965079309dc0ad9255abb2ebc1632" + integrity sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg== + +load-tsconfig@^0.2.3: + version "0.2.5" + resolved "https://registry.npmmirror.com/load-tsconfig/-/load-tsconfig-0.2.5.tgz#453b8cd8961bfb912dea77eb6c168fe8cca3d3a1" + integrity sha512-IXO6OCs9yg8tMKzfPZ1YmheJbZCiEsnBdcB03l0OcfK9prKnJb96siuHCr5Fl37/yo9DnKU+TLpxzTUspw9shg== + +locate-path@^6.0.0: + version "6.0.0" + resolved "https://registry.npmmirror.com/locate-path/-/locate-path-6.0.0.tgz#55321eb309febbc59c4801d931a72452a681d286" + integrity sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw== + dependencies: + p-locate "^5.0.0" + +lodash.merge@^4.6.2: + version "4.6.2" + resolved "https://registry.npmmirror.com/lodash.merge/-/lodash.merge-4.6.2.tgz#558aa53b43b661e1925a0afdfa36a9a1085fe57a" + integrity sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ== + +lodash.sortby@^4.7.0: + version "4.7.0" + resolved "https://registry.npmmirror.com/lodash.sortby/-/lodash.sortby-4.7.0.tgz#edd14c824e2cc9c1e0b0a1b42bb5210516a42438" + integrity sha512-HDWXG8isMntAyRF5vZ7xKuEvOhT4AhlRt/3czTSjvGUxjYCBVRQY48ViDHyfYz9VIoBkW4TMGQNapx+l3RUwdA== + +loupe@^3.1.0, loupe@^3.1.4: + version "3.2.1" + resolved "https://registry.npmmirror.com/loupe/-/loupe-3.2.1.tgz#0095cf56dc5b7a9a7c08ff5b1a8796ec8ad17e76" + integrity sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ== + +lru-cache@^10.2.0: + version "10.4.3" + resolved "https://registry.npmmirror.com/lru-cache/-/lru-cache-10.4.3.tgz#410fc8a17b70e598013df257c2446b7f3383f119" + integrity sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ== + +magic-string@^0.30.17: + version "0.30.19" + resolved "https://registry.npmmirror.com/magic-string/-/magic-string-0.30.19.tgz#cebe9f104e565602e5d2098c5f2e79a77cc86da9" + integrity sha512-2N21sPY9Ws53PZvsEpVtNuSW+ScYbQdp4b9qUaL+9QkHUrGFKo56Lg9Emg5s9V/qrtNBmiR01sYhUOwu3H+VOw== + dependencies: + "@jridgewell/sourcemap-codec" "^1.5.5" + +merge2@^1.3.0: + version "1.4.1" + resolved "https://registry.npmmirror.com/merge2/-/merge2-1.4.1.tgz#4368892f885e907455a6fd7dc55c0c9d404990ae" + integrity sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg== + +micromatch@^4.0.8: + version "4.0.8" + resolved "https://registry.npmmirror.com/micromatch/-/micromatch-4.0.8.tgz#d66fa18f3a47076789320b9b1af32bd86d9fa202" + integrity sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA== + dependencies: + braces "^3.0.3" + picomatch "^2.3.1" + +minimatch@^3.1.2: + version "3.1.2" + resolved "https://registry.npmmirror.com/minimatch/-/minimatch-3.1.2.tgz#19cd194bfd3e428f049a70817c038d89ab4be35b" + integrity sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw== + dependencies: + brace-expansion "^1.1.7" + +minimatch@^9.0.4: + version "9.0.5" + resolved "https://registry.npmmirror.com/minimatch/-/minimatch-9.0.5.tgz#d74f9dd6b57d83d8e98cfb82133b03978bc929e5" + integrity sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow== + dependencies: + brace-expansion "^2.0.1" + +"minipass@^5.0.0 || ^6.0.2 || ^7.0.0", minipass@^7.1.2: + version "7.1.2" + resolved "https://registry.npmmirror.com/minipass/-/minipass-7.1.2.tgz#93a9626ce5e5e66bd4db86849e7515e92340a707" + integrity sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw== + +mlly@^1.7.4: + version "1.8.0" + resolved "https://registry.npmmirror.com/mlly/-/mlly-1.8.0.tgz#e074612b938af8eba1eaf43299cbc89cb72d824e" + integrity sha512-l8D9ODSRWLe2KHJSifWGwBqpTZXIXTeo8mlKjY+E2HAakaTeNpqAyBZ8GSqLzHgw4XmHmC8whvpjJNMbFZN7/g== + dependencies: + acorn "^8.15.0" + pathe "^2.0.3" + pkg-types "^1.3.1" + ufo "^1.6.1" + +ms@^2.1.3: + version "2.1.3" + resolved "https://registry.npmmirror.com/ms/-/ms-2.1.3.tgz#574c8138ce1d2b5861f0b44579dbadd60c6615b2" + integrity sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA== + +mz@^2.7.0: + version "2.7.0" + resolved "https://registry.npmmirror.com/mz/-/mz-2.7.0.tgz#95008057a56cafadc2bc63dde7f9ff6955948e32" + integrity sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q== + dependencies: + any-promise "^1.0.0" + object-assign "^4.0.1" + thenify-all "^1.0.0" + +nanoid@^3.3.11: + version "3.3.11" + resolved "https://registry.npmmirror.com/nanoid/-/nanoid-3.3.11.tgz#4f4f112cefbe303202f2199838128936266d185b" + integrity sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w== + +natural-compare@^1.4.0: + version "1.4.0" + resolved "https://registry.npmmirror.com/natural-compare/-/natural-compare-1.4.0.tgz#4abebfeed7541f2c27acfb29bdbbd15c8d5ba4f7" + integrity sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw== + +object-assign@^4.0.1: + version "4.1.1" + resolved "https://registry.npmmirror.com/object-assign/-/object-assign-4.1.1.tgz#2109adc7965887cfc05cbbd442cac8bfbb360863" + integrity sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg== + +optionator@^0.9.3: + version "0.9.4" + resolved "https://registry.npmmirror.com/optionator/-/optionator-0.9.4.tgz#7ea1c1a5d91d764fb282139c88fe11e182a3a734" + integrity sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g== + dependencies: + deep-is "^0.1.3" + fast-levenshtein "^2.0.6" + levn "^0.4.1" + prelude-ls "^1.2.1" + type-check "^0.4.0" + word-wrap "^1.2.5" + +p-limit@^3.0.2: + version "3.1.0" + resolved "https://registry.npmmirror.com/p-limit/-/p-limit-3.1.0.tgz#e1daccbe78d0d1388ca18c64fea38e3e57e3706b" + integrity sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ== + dependencies: + yocto-queue "^0.1.0" + +p-locate@^5.0.0: + version "5.0.0" + resolved "https://registry.npmmirror.com/p-locate/-/p-locate-5.0.0.tgz#83c8315c6785005e3bd021839411c9e110e6d834" + integrity sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw== + dependencies: + p-limit "^3.0.2" + +package-json-from-dist@^1.0.0: + version "1.0.1" + resolved "https://registry.npmmirror.com/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz#4f1471a010827a86f94cfd9b0727e36d267de505" + integrity sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw== + +parent-module@^1.0.0: + version "1.0.1" + resolved "https://registry.npmmirror.com/parent-module/-/parent-module-1.0.1.tgz#691d2709e78c79fae3a156622452d00762caaaa2" + integrity sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g== + dependencies: + callsites "^3.0.0" + +path-exists@^4.0.0: + version "4.0.0" + resolved "https://registry.npmmirror.com/path-exists/-/path-exists-4.0.0.tgz#513bdbe2d3b95d7762e8c1137efa195c6c61b5b3" + integrity sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w== + +path-key@^3.1.0: + version "3.1.1" + resolved "https://registry.npmmirror.com/path-key/-/path-key-3.1.1.tgz#581f6ade658cbba65a0d3380de7753295054f375" + integrity sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q== + +path-scurry@^1.11.1: + version "1.11.1" + resolved "https://registry.npmmirror.com/path-scurry/-/path-scurry-1.11.1.tgz#7960a668888594a0720b12a911d1a742ab9f11d2" + integrity sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA== + dependencies: + lru-cache "^10.2.0" + minipass "^5.0.0 || ^6.0.2 || ^7.0.0" + +pathe@^2.0.1, pathe@^2.0.3: + version "2.0.3" + resolved "https://registry.npmmirror.com/pathe/-/pathe-2.0.3.tgz#3ecbec55421685b70a9da872b2cff3e1cbed1716" + integrity sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w== + +pathval@^2.0.0: + version "2.0.1" + resolved "https://registry.npmmirror.com/pathval/-/pathval-2.0.1.tgz#8855c5a2899af072d6ac05d11e46045ad0dc605d" + integrity sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ== + +picocolors@^1.1.1: + version "1.1.1" + resolved "https://registry.npmmirror.com/picocolors/-/picocolors-1.1.1.tgz#3d321af3eab939b083c8f929a1d12cda81c26b6b" + integrity sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA== + +picomatch@^2.3.1: + version "2.3.1" + resolved "https://registry.npmmirror.com/picomatch/-/picomatch-2.3.1.tgz#3ba3833733646d9d3e4995946c1365a67fb07a42" + integrity sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA== + +picomatch@^4.0.2, picomatch@^4.0.3: + version "4.0.3" + resolved "https://registry.npmmirror.com/picomatch/-/picomatch-4.0.3.tgz#796c76136d1eead715db1e7bad785dedd695a042" + integrity sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q== + +pirates@^4.0.1: + version "4.0.7" + resolved "https://registry.npmmirror.com/pirates/-/pirates-4.0.7.tgz#643b4a18c4257c8a65104b73f3049ce9a0a15e22" + integrity sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA== + +pkg-types@^1.3.1: + version "1.3.1" + resolved "https://registry.npmmirror.com/pkg-types/-/pkg-types-1.3.1.tgz#bd7cc70881192777eef5326c19deb46e890917df" + integrity sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ== + dependencies: + confbox "^0.1.8" + mlly "^1.7.4" + pathe "^2.0.1" + +postcss-load-config@^6.0.1: + version "6.0.1" + resolved "https://registry.npmmirror.com/postcss-load-config/-/postcss-load-config-6.0.1.tgz#6fd7dcd8ae89badcf1b2d644489cbabf83aa8096" + integrity sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g== + dependencies: + lilconfig "^3.1.1" + +postcss@^8.5.6: + version "8.5.6" + resolved "https://registry.npmmirror.com/postcss/-/postcss-8.5.6.tgz#2825006615a619b4f62a9e7426cc120b349a8f3c" + integrity sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg== + dependencies: + nanoid "^3.3.11" + picocolors "^1.1.1" + source-map-js "^1.2.1" + +prelude-ls@^1.2.1: + version "1.2.1" + resolved "https://registry.npmmirror.com/prelude-ls/-/prelude-ls-1.2.1.tgz#debc6489d7a6e6b0e7611888cec880337d316396" + integrity sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g== + +prettier@3.6.2: + version "3.6.2" + resolved "https://registry.npmmirror.com/prettier/-/prettier-3.6.2.tgz#ccda02a1003ebbb2bfda6f83a074978f608b9393" + integrity sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ== + +punycode@^2.1.0: + version "2.3.1" + resolved "https://registry.npmmirror.com/punycode/-/punycode-2.3.1.tgz#027422e2faec0b25e1549c3e1bd8309b9133b6e5" + integrity sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg== + +queue-microtask@^1.2.2: + version "1.2.3" + resolved "https://registry.npmmirror.com/queue-microtask/-/queue-microtask-1.2.3.tgz#4929228bbc724dfac43e0efb058caf7b6cfb6243" + integrity sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A== + +readdirp@^4.0.1: + version "4.1.2" + resolved "https://registry.npmmirror.com/readdirp/-/readdirp-4.1.2.tgz#eb85801435fbf2a7ee58f19e0921b068fc69948d" + integrity sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg== + +resolve-from@^4.0.0: + version "4.0.0" + resolved "https://registry.npmmirror.com/resolve-from/-/resolve-from-4.0.0.tgz#4abcd852ad32dd7baabfe9b40e00a36db5f392e6" + integrity sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g== + +resolve-from@^5.0.0: + version "5.0.0" + resolved "https://registry.npmmirror.com/resolve-from/-/resolve-from-5.0.0.tgz#c35225843df8f776df21c57557bc087e9dfdfc69" + integrity sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw== + +reusify@^1.0.4: + version "1.1.0" + resolved "https://registry.npmmirror.com/reusify/-/reusify-1.1.0.tgz#0fe13b9522e1473f51b558ee796e08f11f9b489f" + integrity sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw== + +rollup@^4.34.8, rollup@^4.43.0: + version "4.52.3" + resolved "https://registry.npmmirror.com/rollup/-/rollup-4.52.3.tgz#cc5c28d772b022ce48b235a97b347ccd9d88c1a3" + integrity sha512-RIDh866U8agLgiIcdpB+COKnlCreHJLfIhWC3LVflku5YHfpnsIKigRZeFfMfCc4dVcqNVfQQ5gO/afOck064A== + dependencies: + "@types/estree" "1.0.8" + optionalDependencies: + "@rollup/rollup-android-arm-eabi" "4.52.3" + "@rollup/rollup-android-arm64" "4.52.3" + "@rollup/rollup-darwin-arm64" "4.52.3" + "@rollup/rollup-darwin-x64" "4.52.3" + "@rollup/rollup-freebsd-arm64" "4.52.3" + "@rollup/rollup-freebsd-x64" "4.52.3" + "@rollup/rollup-linux-arm-gnueabihf" "4.52.3" + "@rollup/rollup-linux-arm-musleabihf" "4.52.3" + "@rollup/rollup-linux-arm64-gnu" "4.52.3" + "@rollup/rollup-linux-arm64-musl" "4.52.3" + "@rollup/rollup-linux-loong64-gnu" "4.52.3" + "@rollup/rollup-linux-ppc64-gnu" "4.52.3" + "@rollup/rollup-linux-riscv64-gnu" "4.52.3" + "@rollup/rollup-linux-riscv64-musl" "4.52.3" + "@rollup/rollup-linux-s390x-gnu" "4.52.3" + "@rollup/rollup-linux-x64-gnu" "4.52.3" + "@rollup/rollup-linux-x64-musl" "4.52.3" + "@rollup/rollup-openharmony-arm64" "4.52.3" + "@rollup/rollup-win32-arm64-msvc" "4.52.3" + "@rollup/rollup-win32-ia32-msvc" "4.52.3" + "@rollup/rollup-win32-x64-gnu" "4.52.3" + "@rollup/rollup-win32-x64-msvc" "4.52.3" + fsevents "~2.3.2" + +run-parallel@^1.1.9: + version "1.2.0" + resolved "https://registry.npmmirror.com/run-parallel/-/run-parallel-1.2.0.tgz#66d1368da7bdf921eb9d95bd1a9229e7f21a43ee" + integrity sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA== + dependencies: + queue-microtask "^1.2.2" + +semver@^7.6.0: + version "7.7.2" + resolved "https://registry.npmmirror.com/semver/-/semver-7.7.2.tgz#67d99fdcd35cec21e6f8b87a7fd515a33f982b58" + integrity sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA== + +shebang-command@^2.0.0: + version "2.0.0" + resolved "https://registry.npmmirror.com/shebang-command/-/shebang-command-2.0.0.tgz#ccd0af4f8835fbdc265b82461aaf0c36663f34ea" + integrity sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA== + dependencies: + shebang-regex "^3.0.0" + +shebang-regex@^3.0.0: + version "3.0.0" + resolved "https://registry.npmmirror.com/shebang-regex/-/shebang-regex-3.0.0.tgz#ae16f1644d873ecad843b0307b143362d4c42172" + integrity sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A== + +siginfo@^2.0.0: + version "2.0.0" + resolved "https://registry.npmmirror.com/siginfo/-/siginfo-2.0.0.tgz#32e76c70b79724e3bb567cb9d543eb858ccfaf30" + integrity sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g== + +signal-exit@^4.0.1: + version "4.1.0" + resolved "https://registry.npmmirror.com/signal-exit/-/signal-exit-4.1.0.tgz#952188c1cbd546070e2dd20d0f41c0ae0530cb04" + integrity sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw== + +source-map-js@^1.2.1: + version "1.2.1" + resolved "https://registry.npmmirror.com/source-map-js/-/source-map-js-1.2.1.tgz#1ce5650fddd87abc099eda37dcff024c2667ae46" + integrity sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA== + +source-map@0.8.0-beta.0: + version "0.8.0-beta.0" + resolved "https://registry.npmmirror.com/source-map/-/source-map-0.8.0-beta.0.tgz#d4c1bb42c3f7ee925f005927ba10709e0d1d1f11" + integrity sha512-2ymg6oRBpebeZi9UUNsgQ89bhx01TcTkmNTGnNO88imTmbSgy4nfujrgVEFKWpMTEGA11EDkTt7mqObTPdigIA== + dependencies: + whatwg-url "^7.0.0" + +stackback@0.0.2: + version "0.0.2" + resolved "https://registry.npmmirror.com/stackback/-/stackback-0.0.2.tgz#1ac8a0d9483848d1695e418b6d031a3c3ce68e3b" + integrity sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw== + +std-env@^3.9.0: + version "3.9.0" + resolved "https://registry.npmmirror.com/std-env/-/std-env-3.9.0.tgz#1a6f7243b339dca4c9fd55e1c7504c77ef23e8f1" + integrity sha512-UGvjygr6F6tpH7o2qyqR6QYpwraIjKSdtzyBdyytFOHmPZY917kwdwLG0RbOjWOnKmnm3PeHjaoLLMie7kPLQw== + +"string-width-cjs@npm:string-width@^4.2.0": + version "4.2.3" + resolved "https://registry.npmmirror.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" + integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== + dependencies: + emoji-regex "^8.0.0" + is-fullwidth-code-point "^3.0.0" + strip-ansi "^6.0.1" + +string-width@^4.1.0: + version "4.2.3" + resolved "https://registry.npmmirror.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" + integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== + dependencies: + emoji-regex "^8.0.0" + is-fullwidth-code-point "^3.0.0" + strip-ansi "^6.0.1" + +string-width@^5.0.1, string-width@^5.1.2: + version "5.1.2" + resolved "https://registry.npmmirror.com/string-width/-/string-width-5.1.2.tgz#14f8daec6d81e7221d2a357e668cab73bdbca794" + integrity sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA== + dependencies: + eastasianwidth "^0.2.0" + emoji-regex "^9.2.2" + strip-ansi "^7.0.1" + +"strip-ansi-cjs@npm:strip-ansi@^6.0.1": + version "6.0.1" + resolved "https://registry.npmmirror.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" + integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== + dependencies: + ansi-regex "^5.0.1" + +strip-ansi@^6.0.0, strip-ansi@^6.0.1: + version "6.0.1" + resolved "https://registry.npmmirror.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" + integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== + dependencies: + ansi-regex "^5.0.1" + +strip-ansi@^7.0.1: + version "7.1.2" + resolved "https://registry.npmmirror.com/strip-ansi/-/strip-ansi-7.1.2.tgz#132875abde678c7ea8d691533f2e7e22bb744dba" + integrity sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA== + dependencies: + ansi-regex "^6.0.1" + +strip-json-comments@^3.1.1: + version "3.1.1" + resolved "https://registry.npmmirror.com/strip-json-comments/-/strip-json-comments-3.1.1.tgz#31f1281b3832630434831c310c01cccda8cbe006" + integrity sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig== + +strip-literal@^3.0.0: + version "3.1.0" + resolved "https://registry.npmmirror.com/strip-literal/-/strip-literal-3.1.0.tgz#222b243dd2d49c0bcd0de8906adbd84177196032" + integrity sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg== + dependencies: + js-tokens "^9.0.1" + +sucrase@^3.35.0: + version "3.35.0" + resolved "https://registry.npmmirror.com/sucrase/-/sucrase-3.35.0.tgz#57f17a3d7e19b36d8995f06679d121be914ae263" + integrity sha512-8EbVDiu9iN/nESwxeSxDKe0dunta1GOlHufmSSXxMD2z2/tMZpDMpvXQGsc+ajGo8y2uYUmixaSRUc/QPoQ0GA== + dependencies: + "@jridgewell/gen-mapping" "^0.3.2" + commander "^4.0.0" + glob "^10.3.10" + lines-and-columns "^1.1.6" + mz "^2.7.0" + pirates "^4.0.1" + ts-interface-checker "^0.1.9" + +supports-color@^7.1.0: + version "7.2.0" + resolved "https://registry.npmmirror.com/supports-color/-/supports-color-7.2.0.tgz#1b7dcdcb32b8138801b3e478ba6a51caa89648da" + integrity sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw== + dependencies: + has-flag "^4.0.0" + +thenify-all@^1.0.0: + version "1.6.0" + resolved "https://registry.npmmirror.com/thenify-all/-/thenify-all-1.6.0.tgz#1a1918d402d8fc3f98fbf234db0bcc8cc10e9726" + integrity sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA== + dependencies: + thenify ">= 3.1.0 < 4" + +"thenify@>= 3.1.0 < 4": + version "3.3.1" + resolved "https://registry.npmmirror.com/thenify/-/thenify-3.3.1.tgz#8932e686a4066038a016dd9e2ca46add9838a95f" + integrity sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw== + dependencies: + any-promise "^1.0.0" + +tinybench@^2.9.0: + version "2.9.0" + resolved "https://registry.npmmirror.com/tinybench/-/tinybench-2.9.0.tgz#103c9f8ba6d7237a47ab6dd1dcff77251863426b" + integrity sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg== + +tinyexec@^0.3.2: + version "0.3.2" + resolved "https://registry.npmmirror.com/tinyexec/-/tinyexec-0.3.2.tgz#941794e657a85e496577995c6eef66f53f42b3d2" + integrity sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA== + +tinyglobby@^0.2.11, tinyglobby@^0.2.14, tinyglobby@^0.2.15: + version "0.2.15" + resolved "https://registry.npmmirror.com/tinyglobby/-/tinyglobby-0.2.15.tgz#e228dd1e638cea993d2fdb4fcd2d4602a79951c2" + integrity sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ== + dependencies: + fdir "^6.5.0" + picomatch "^4.0.3" + +tinypool@^1.1.1: + version "1.1.1" + resolved "https://registry.npmmirror.com/tinypool/-/tinypool-1.1.1.tgz#059f2d042bd37567fbc017d3d426bdd2a2612591" + integrity sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg== + +tinyrainbow@^2.0.0: + version "2.0.0" + resolved "https://registry.npmmirror.com/tinyrainbow/-/tinyrainbow-2.0.0.tgz#9509b2162436315e80e3eee0fcce4474d2444294" + integrity sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw== + +tinyspy@^4.0.3: + version "4.0.4" + resolved "https://registry.npmmirror.com/tinyspy/-/tinyspy-4.0.4.tgz#d77a002fb53a88aa1429b419c1c92492e0c81f78" + integrity sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q== + +to-regex-range@^5.0.1: + version "5.0.1" + resolved "https://registry.npmmirror.com/to-regex-range/-/to-regex-range-5.0.1.tgz#1648c44aae7c8d988a326018ed72f5b4dd0392e4" + integrity sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ== + dependencies: + is-number "^7.0.0" + +tr46@^1.0.1: + version "1.0.1" + resolved "https://registry.npmmirror.com/tr46/-/tr46-1.0.1.tgz#a8b13fd6bfd2489519674ccde55ba3693b706d09" + integrity sha512-dTpowEjclQ7Kgx5SdBkqRzVhERQXov8/l9Ft9dVM9fmg0W0KQSVaXX9T4i6twCPNtYiZM53lpSSUAwJbFPOHxA== + dependencies: + punycode "^2.1.0" + +tree-kill@^1.2.2: + version "1.2.2" + resolved "https://registry.npmmirror.com/tree-kill/-/tree-kill-1.2.2.tgz#4ca09a9092c88b73a7cdc5e8a01b507b0790a0cc" + integrity sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A== + +ts-api-utils@^2.1.0: + version "2.1.0" + resolved "https://registry.npmmirror.com/ts-api-utils/-/ts-api-utils-2.1.0.tgz#595f7094e46eed364c13fd23e75f9513d29baf91" + integrity sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ== + +ts-interface-checker@^0.1.9: + version "0.1.13" + resolved "https://registry.npmmirror.com/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz#784fd3d679722bc103b1b4b8030bcddb5db2a699" + integrity sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA== + +tsup@8.5.0: + version "8.5.0" + resolved "https://registry.npmmirror.com/tsup/-/tsup-8.5.0.tgz#4b1e25b1a8f4e4f89b764207bf37cfe2d7411d31" + integrity sha512-VmBp77lWNQq6PfuMqCHD3xWl22vEoWsKajkF8t+yMBawlUS8JzEI+vOVMeuNZIuMML8qXRizFKi9oD5glKQVcQ== + dependencies: + bundle-require "^5.1.0" + cac "^6.7.14" + chokidar "^4.0.3" + consola "^3.4.0" + debug "^4.4.0" + esbuild "^0.25.0" + fix-dts-default-cjs-exports "^1.0.0" + joycon "^3.1.1" + picocolors "^1.1.1" + postcss-load-config "^6.0.1" + resolve-from "^5.0.0" + rollup "^4.34.8" + source-map "0.8.0-beta.0" + sucrase "^3.35.0" + tinyexec "^0.3.2" + tinyglobby "^0.2.11" + tree-kill "^1.2.2" + +type-check@^0.4.0, type-check@~0.4.0: + version "0.4.0" + resolved "https://registry.npmmirror.com/type-check/-/type-check-0.4.0.tgz#07b8203bfa7056c0657050e3ccd2c37730bab8f1" + integrity sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew== + dependencies: + prelude-ls "^1.2.1" + +typescript-eslint@8.45.0: + version "8.45.0" + resolved "https://registry.npmmirror.com/typescript-eslint/-/typescript-eslint-8.45.0.tgz#98ab164234dc04c112747ec0a4ae29a94efe123b" + integrity sha512-qzDmZw/Z5beNLUrXfd0HIW6MzIaAV5WNDxmMs9/3ojGOpYavofgNAAD/nC6tGV2PczIi0iw8vot2eAe/sBn7zg== + dependencies: + "@typescript-eslint/eslint-plugin" "8.45.0" + "@typescript-eslint/parser" "8.45.0" + "@typescript-eslint/typescript-estree" "8.45.0" + "@typescript-eslint/utils" "8.45.0" + +typescript@5.9.3: + version "5.9.3" + resolved "https://registry.npmmirror.com/typescript/-/typescript-5.9.3.tgz#5b4f59e15310ab17a216f5d6cf53ee476ede670f" + integrity sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw== + +ufo@^1.6.1: + version "1.6.1" + resolved "https://registry.npmmirror.com/ufo/-/ufo-1.6.1.tgz#ac2db1d54614d1b22c1d603e3aef44a85d8f146b" + integrity sha512-9a4/uxlTWJ4+a5i0ooc1rU7C7YOw3wT+UGqdeNNHWnOF9qcMBgLRS+4IYUqbczewFx4mLEig6gawh7X6mFlEkA== + +uri-js@^4.2.2: + version "4.4.1" + resolved "https://registry.npmmirror.com/uri-js/-/uri-js-4.4.1.tgz#9b1a52595225859e55f669d928f88c6c57f2a77e" + integrity sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg== + dependencies: + punycode "^2.1.0" + +vite-node@3.2.4: + version "3.2.4" + resolved "https://registry.npmmirror.com/vite-node/-/vite-node-3.2.4.tgz#f3676d94c4af1e76898c162c92728bca65f7bb07" + integrity sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg== + dependencies: + cac "^6.7.14" + debug "^4.4.1" + es-module-lexer "^1.7.0" + pathe "^2.0.3" + vite "^5.0.0 || ^6.0.0 || ^7.0.0-0" + +"vite@^5.0.0 || ^6.0.0 || ^7.0.0-0": + version "7.1.8" + resolved "https://registry.npmmirror.com/vite/-/vite-7.1.8.tgz#ff208eee01658c5b407b6a1afa9b92b25750832e" + integrity sha512-oBXvfSHEOL8jF+R9Am7h59Up07kVVGH1NrFGFoEG6bPDZP3tGpQhvkBpy5x7U6+E6wZCu9OihsWgJqDbQIm8LQ== + dependencies: + esbuild "^0.25.0" + fdir "^6.5.0" + picomatch "^4.0.3" + postcss "^8.5.6" + rollup "^4.43.0" + tinyglobby "^0.2.15" + optionalDependencies: + fsevents "~2.3.3" + +vitest@3.2.4: + version "3.2.4" + resolved "https://registry.npmmirror.com/vitest/-/vitest-3.2.4.tgz#0637b903ad79d1539a25bc34c0ed54b5c67702ea" + integrity sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A== + dependencies: + "@types/chai" "^5.2.2" + "@vitest/expect" "3.2.4" + "@vitest/mocker" "3.2.4" + "@vitest/pretty-format" "^3.2.4" + "@vitest/runner" "3.2.4" + "@vitest/snapshot" "3.2.4" + "@vitest/spy" "3.2.4" + "@vitest/utils" "3.2.4" + chai "^5.2.0" + debug "^4.4.1" + expect-type "^1.2.1" + magic-string "^0.30.17" + pathe "^2.0.3" + picomatch "^4.0.2" + std-env "^3.9.0" + tinybench "^2.9.0" + tinyexec "^0.3.2" + tinyglobby "^0.2.14" + tinypool "^1.1.1" + tinyrainbow "^2.0.0" + vite "^5.0.0 || ^6.0.0 || ^7.0.0-0" + vite-node "3.2.4" + why-is-node-running "^2.3.0" + +webidl-conversions@^4.0.2: + version "4.0.2" + resolved "https://registry.npmmirror.com/webidl-conversions/-/webidl-conversions-4.0.2.tgz#a855980b1f0b6b359ba1d5d9fb39ae941faa63ad" + integrity sha512-YQ+BmxuTgd6UXZW3+ICGfyqRyHXVlD5GtQr5+qjiNW7bF0cqrzX500HVXPBOvgXb5YnzDd+h0zqyv61KUD7+Sg== + +whatwg-url@^7.0.0: + version "7.1.0" + resolved "https://registry.npmmirror.com/whatwg-url/-/whatwg-url-7.1.0.tgz#c2c492f1eca612988efd3d2266be1b9fc6170d06" + integrity sha512-WUu7Rg1DroM7oQvGWfOiAK21n74Gg+T4elXEQYkOhtyLeWiJFoOGLXPKI/9gzIie9CtwVLm8wtw6YJdKyxSjeg== + dependencies: + lodash.sortby "^4.7.0" + tr46 "^1.0.1" + webidl-conversions "^4.0.2" + +which@^2.0.1: + version "2.0.2" + resolved "https://registry.npmmirror.com/which/-/which-2.0.2.tgz#7c6a8dd0a636a0327e10b59c9286eee93f3f51b1" + integrity sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA== + dependencies: + isexe "^2.0.0" + +why-is-node-running@^2.3.0: + version "2.3.0" + resolved "https://registry.npmmirror.com/why-is-node-running/-/why-is-node-running-2.3.0.tgz#a3f69a97107f494b3cdc3bdddd883a7d65cebf04" + integrity sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w== + dependencies: + siginfo "^2.0.0" + stackback "0.0.2" + +word-wrap@^1.2.5: + version "1.2.5" + resolved "https://registry.npmmirror.com/word-wrap/-/word-wrap-1.2.5.tgz#d2c45c6dd4fbce621a66f136cbe328afd0410b34" + integrity sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA== + +"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0": + version "7.0.0" + resolved "https://registry.npmmirror.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" + integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== + dependencies: + ansi-styles "^4.0.0" + string-width "^4.1.0" + strip-ansi "^6.0.0" + +wrap-ansi@^8.1.0: + version "8.1.0" + resolved "https://registry.npmmirror.com/wrap-ansi/-/wrap-ansi-8.1.0.tgz#56dc22368ee570face1b49819975d9b9a5ead214" + integrity sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ== + dependencies: + ansi-styles "^6.1.0" + string-width "^5.0.1" + strip-ansi "^7.0.1" + +yocto-queue@^0.1.0: + version "0.1.0" + resolved "https://registry.npmmirror.com/yocto-queue/-/yocto-queue-0.1.0.tgz#0294eb3dee05028d31ee1a5fa2c556a6aaf10a1b" + integrity sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q== diff --git a/luci-app-podkop/htdocs/luci-static/resources/view/podkop/configSection.js b/luci-app-podkop/htdocs/luci-static/resources/view/podkop/configSection.js index 63867f2..4cc28da 100644 --- a/luci-app-podkop/htdocs/luci-static/resources/view/podkop/configSection.js +++ b/luci-app-podkop/htdocs/luci-static/resources/view/podkop/configSection.js @@ -4,19 +4,9 @@ 'require ui'; 'require network'; 'require view.podkop.constants as constants'; +'require view.podkop.main as main'; 'require tools.widgets as widgets'; -function validateUrl(url, protocols = ['http:', 'https:']) { - try { - const parsedUrl = new URL(url); - if (!protocols.includes(parsedUrl.protocol)) { - return _('URL must use one of the following protocols: ') + protocols.join(', '); - } - return true; - } catch (e) { - return _('Invalid URL format'); - } -} function createConfigSection(section, map, network) { const s = section; @@ -438,8 +428,18 @@ function createConfigSection(section, map, network) { o.rmempty = false; o.ucisection = s.section; o.validate = function (section_id, value) { - if (!value || value.length === 0) return true; - return validateUrl(value); + // Optional + if (!value || value.length === 0) { + return true + } + + const validation = main.validateUrl(url); + + if (validation.valid) { + return true; + } + + return _(validation.message) }; o = s.taboption('basic', form.Flag, 'local_subnet_lists_enabled', _('Local Subnet Lists'), _('Use the list from the router filesystem')); @@ -562,8 +562,18 @@ function createConfigSection(section, map, network) { o.rmempty = false; o.ucisection = s.section; o.validate = function (section_id, value) { - if (!value || value.length === 0) return true; - return validateUrl(value); + // Optional + if (!value || value.length === 0) { + return true + } + + const validation = main.validateUrl(url); + + if (validation.valid) { + return true; + } + + return _(validation.message) }; o = s.taboption('basic', form.Flag, 'all_traffic_from_ip_enabled', _('IP for full redirection'), _('Specify local IP addresses whose traffic will always use the configured route')); @@ -591,4 +601,4 @@ function createConfigSection(section, map, network) { return baseclass.extend({ createConfigSection -}); \ No newline at end of file +}); diff --git a/luci-app-podkop/htdocs/luci-static/resources/view/podkop/main.js b/luci-app-podkop/htdocs/luci-static/resources/view/podkop/main.js new file mode 100644 index 0000000..df15d3c --- /dev/null +++ b/luci-app-podkop/htdocs/luci-static/resources/view/podkop/main.js @@ -0,0 +1,66 @@ +// This file is autogenerated, please don't change manually +"use strict"; +"require baseclass"; + +// src/validators/validateIp.ts +function validateIPV4(ip) { + const ipRegex = /^(?:(25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])\.){3}(25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])$/; + if (ipRegex.test(ip)) { + return { valid: true, message: "Valid" }; + } + return { valid: false, message: "Invalid IP address" }; +} + +// src/validators/validateDomain.ts +function validateDomain(domain) { + const domainRegex = /^(?=.{1,253}(?:\/|$))(?:(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)\.)+(?:[a-zA-Z]{2,}|xn--[a-zA-Z0-9-]{1,59}[a-zA-Z0-9])(?:\/[^\s]*)?$/; + if (!domainRegex.test(domain)) { + return { valid: false, message: "Invalid domain address" }; + } + const hostname = domain.split("/")[0]; + const parts = hostname.split("."); + const atLeastOneInvalidPart = parts.some((part) => part.length > 63); + if (atLeastOneInvalidPart) { + return { valid: false, message: "Invalid domain address" }; + } + return { valid: true, message: "Valid" }; +} + +// src/validators/validateDns.ts +function validateDNS(value) { + if (!value) { + return { valid: false, message: "DNS server address cannot be empty" }; + } + if (validateIPV4(value).valid) { + return { valid: true, message: "Valid" }; + } + if (validateDomain(value).valid) { + return { valid: true, message: "Valid" }; + } + return { + valid: false, + message: "Invalid DNS server format. Examples: 8.8.8.8 or dns.example.com or dns.example.com/nicedns for DoH" + }; +} + +// src/validators/validateUrl.ts +function validateUrl(url, protocols = ["http:", "https:"]) { + try { + const parsedUrl = new URL(url); + if (!protocols.includes(parsedUrl.protocol)) { + return { + valid: false, + message: `URL must use one of the following protocols: ${protocols.join(", ")}` + }; + } + return { valid: true, message: "Valid" }; + } catch (e) { + return { valid: false, message: "Invalid URL format" }; + } +} +return baseclass.extend({ + validateDNS, + validateDomain, + validateIPV4, + validateUrl +}); From 14eec8e6004bbfbd900fe79eb30091f4e3b0f326 Mon Sep 17 00:00:00 2001 From: divocat Date: Thu, 2 Oct 2025 21:57:40 +0300 Subject: [PATCH 02/49] feat: migrate some validation places of config sections to modular --- .../resources/view/podkop/configSection.js | 54 ++++++++++--------- 1 file changed, 29 insertions(+), 25 deletions(-) diff --git a/luci-app-podkop/htdocs/luci-static/resources/view/podkop/configSection.js b/luci-app-podkop/htdocs/luci-static/resources/view/podkop/configSection.js index 4cc28da..59653fc 100644 --- a/luci-app-podkop/htdocs/luci-static/resources/view/podkop/configSection.js +++ b/luci-app-podkop/htdocs/luci-static/resources/view/podkop/configSection.js @@ -254,18 +254,13 @@ function createConfigSection(section, map, network) { o.depends('domain_resolver_enabled', '1'); o.ucisection = s.section; o.validate = function (section_id, value) { - if (!value) { - return _('DNS server address cannot be empty'); + const validation = main.validateDNS(value); + + if (validation.valid) { + return true; } - const ipRegex = /^((25[0-5]|(2[0-4]|1\d|[1-9]|)\d)\.?\b){4}(:[0-9]{1,5})?$/; - const domainRegex = /^(?:https:\/\/)?([a-zA-Z0-9]+(-[a-zA-Z0-9]+)*\.)+[a-zA-Z]{2,63}(:[0-9]{1,5})?(\/[^?#\s]*)?$/; - - if (!ipRegex.test(value) && !domainRegex.test(value)) { - return _('Invalid DNS server format. Examples: 8.8.8.8 or dns.example.com or dns.example.com/nicedns for DoH'); - } - - return true; + return _(validation.message) }; o = s.taboption('basic', form.Flag, 'community_lists_enabled', _('Community Lists')); @@ -349,12 +344,18 @@ function createConfigSection(section, map, network) { o.rmempty = false; o.ucisection = s.section; o.validate = function (section_id, value) { - if (!value || value.length === 0) return true; - const domainRegex = /^(?!-)[A-Za-z0-9-]+([-.][A-Za-z0-9-]+)*(\.[A-Za-z]{2,})?$/; - if (!domainRegex.test(value)) { - return _('Invalid domain format. Enter domain without protocol (example: sub.example.com or ru)'); + // Optional + if (!value || value.length === 0) { + return true } - return true; + + const validation = main.validateDomain(value); + + if (validation.valid) { + return true; + } + + return _(validation.message) }; o = s.taboption('basic', form.TextValue, 'user_domains_text', _('User Domains List'), _('Enter domain names separated by comma, space or newline. You can add comments after //')); @@ -433,7 +434,7 @@ function createConfigSection(section, map, network) { return true } - const validation = main.validateUrl(url); + const validation = main.validateUrl(value); if (validation.valid) { return true; @@ -567,7 +568,7 @@ function createConfigSection(section, map, network) { return true } - const validation = main.validateUrl(url); + const validation = main.validateUrl(value); if (validation.valid) { return true; @@ -587,15 +588,18 @@ function createConfigSection(section, map, network) { o.rmempty = false; o.ucisection = s.section; o.validate = function (section_id, value) { - if (!value || value.length === 0) return true; - const ipRegex = /^(\d{1,3}\.){3}\d{1,3}$/; - if (!ipRegex.test(value)) return _('Invalid IP format. Use format: X.X.X.X (like 192.168.1.1)'); - const ipParts = value.split('.'); - for (const part of ipParts) { - const num = parseInt(part); - if (num < 0 || num > 255) return _('IP address parts must be between 0 and 255'); + // Optional + if (!value || value.length === 0) { + return true } - return true; + + const validation = main.validateIPV4(value); + + if (validation.valid) { + return true; + } + + return _(validation.message) }; } From df9400514b50ed4125d7bef63cdf67858e5e53ca Mon Sep 17 00:00:00 2001 From: divocat Date: Thu, 2 Oct 2025 22:16:19 +0300 Subject: [PATCH 03/49] feat: migrate some validation places of additional tab to modular --- .../resources/view/podkop/additionalTab.js | 53 +++++++++---------- 1 file changed, 24 insertions(+), 29 deletions(-) diff --git a/luci-app-podkop/htdocs/luci-static/resources/view/podkop/additionalTab.js b/luci-app-podkop/htdocs/luci-static/resources/view/podkop/additionalTab.js index 26af68b..3fc6170 100644 --- a/luci-app-podkop/htdocs/luci-static/resources/view/podkop/additionalTab.js +++ b/luci-app-podkop/htdocs/luci-static/resources/view/podkop/additionalTab.js @@ -3,8 +3,9 @@ 'require baseclass'; 'require view.podkop.constants as constants'; 'require tools.widgets as widgets'; +'require view.podkop.main as main'; -function createAdditionalSection(mainSection, network) { +function createAdditionalSection(mainSection) { let o = mainSection.tab('additional', _('Additional Settings')); o = mainSection.taboption('additional', form.Flag, 'yacd', _('Yacd enable'), 'openwrt.lan:9090/ui'); @@ -46,18 +47,13 @@ function createAdditionalSection(mainSection, network) { o.rmempty = false; o.ucisection = 'main'; o.validate = function (section_id, value) { - if (!value) { - return _('DNS server address cannot be empty'); + const validation = main.validateDNS(value); + + if (validation.valid) { + return true; } - const ipRegex = /^((25[0-5]|(2[0-4]|1\d|[1-9]|)\d)\.?\b){4}(:[0-9]{1,5})?$/; - const domainRegex = /^(?:https:\/\/)?([a-zA-Z0-9]+(-[a-zA-Z0-9]+)*\.)+[a-zA-Z]{2,63}(:[0-9]{1,5})?(\/[^?#\s]*)?$/; - - if (!ipRegex.test(value) && !domainRegex.test(value)) { - return _('Invalid DNS server format. Examples: 8.8.8.8 or dns.example.com or dns.example.com/nicedns for DoH'); - } - - return true; + return _(validation.message); }; o = mainSection.taboption('additional', form.Value, 'bootstrap_dns_server', _('Bootstrap DNS server'), _('The DNS server used to look up the IP address of an upstream DNS server')); @@ -73,17 +69,13 @@ function createAdditionalSection(mainSection, network) { o.rmempty = false; o.ucisection = 'main'; o.validate = function (section_id, value) { - if (!value) { - return _('DNS server address cannot be empty'); + const validation = main.validateDNS(value); + + if (validation.valid) { + return true; } - const ipRegex = /^((25[0-5]|(2[0-4]|1\d|[1-9]|)\d)\.?\b){4}(:[0-9]{1,5})?$/; - - if (!ipRegex.test(value)) { - return _('Invalid DNS server format. Example: 8.8.8.8'); - } - - return true; + return _(validation.message); }; o = mainSection.taboption('additional', form.Value, 'dns_rewrite_ttl', _('DNS Rewrite TTL'), _('Time in seconds for DNS record caching (default: 60)')); @@ -208,15 +200,18 @@ function createAdditionalSection(mainSection, network) { o.rmempty = false; o.ucisection = 'main'; o.validate = function (section_id, value) { - if (!value || value.length === 0) return true; - const ipRegex = /^(\d{1,3}\.){3}\d{1,3}$/; - if (!ipRegex.test(value)) return _('Invalid IP format. Use format: X.X.X.X (like 192.168.1.1)'); - const ipParts = value.split('.'); - for (const part of ipParts) { - const num = parseInt(part); - if (num < 0 || num > 255) return _('IP address parts must be between 0 and 255'); + // Optional + if (!value || value.length === 0) { + return true } - return true; + + const validation = main.validateIPV4(value); + + if (validation.valid) { + return true; + } + + return _(validation.message) }; o = mainSection.taboption('basic', form.Flag, 'socks5', _('Mixed enable'), _('Browser port: 2080')); @@ -227,4 +222,4 @@ function createAdditionalSection(mainSection, network) { return baseclass.extend({ createAdditionalSection -}); \ No newline at end of file +}); From 5e95148492ee8a7d611d7d3ad9b1808a3d12c8f6 Mon Sep 17 00:00:00 2001 From: divocat Date: Thu, 2 Oct 2025 22:23:41 +0300 Subject: [PATCH 04/49] feat: migrate constants to modular --- fe-app-podkop/src/constants.ts | 91 ++++++++++++++ fe-app-podkop/src/main.ts | 1 + .../resources/view/podkop/additionalTab.js | 5 +- .../resources/view/podkop/configSection.js | 15 ++- .../resources/view/podkop/constants.js | 113 ------------------ .../resources/view/podkop/diagnosticTab.js | 66 +++++----- .../luci-static/resources/view/podkop/main.js | 112 +++++++++++++++++ .../resources/view/podkop/utils.js | 14 +-- 8 files changed, 253 insertions(+), 164 deletions(-) create mode 100644 fe-app-podkop/src/constants.ts delete mode 100644 luci-app-podkop/htdocs/luci-static/resources/view/podkop/constants.js diff --git a/fe-app-podkop/src/constants.ts b/fe-app-podkop/src/constants.ts new file mode 100644 index 0000000..b0ccded --- /dev/null +++ b/fe-app-podkop/src/constants.ts @@ -0,0 +1,91 @@ +export const STATUS_COLORS = { + SUCCESS: '#4caf50', + ERROR: '#f44336', + WARNING: '#ff9800' +}; + +export const FAKEIP_CHECK_DOMAIN = 'fakeip.podkop.fyi'; +export const IP_CHECK_DOMAIN = 'ip.podkop.fyi'; + +export const REGIONAL_OPTIONS = ['russia_inside', 'russia_outside', 'ukraine_inside']; +export const ALLOWED_WITH_RUSSIA_INSIDE = [ + 'russia_inside', + 'meta', + 'twitter', + 'discord', + 'telegram', + 'cloudflare', + 'google_ai', + 'google_play', + 'hetzner', + 'ovh', + 'hodca', + 'digitalocean', + 'cloudfront' +]; + +export const DOMAIN_LIST_OPTIONS = { + russia_inside: 'Russia inside', + russia_outside: 'Russia outside', + ukraine_inside: 'Ukraine', + geoblock: 'Geo Block', + block: 'Block', + porn: 'Porn', + news: 'News', + anime: 'Anime', + youtube: 'Youtube', + discord: 'Discord', + meta: 'Meta', + twitter: 'Twitter (X)', + hdrezka: 'HDRezka', + tiktok: 'Tik-Tok', + telegram: 'Telegram', + cloudflare: 'Cloudflare', + google_ai: 'Google AI', + google_play: 'Google Play', + hodca: 'H.O.D.C.A', + hetzner: 'Hetzner ASN', + ovh: 'OVH ASN', + digitalocean: 'Digital Ocean ASN', + cloudfront: 'CloudFront ASN' +}; + +export const UPDATE_INTERVAL_OPTIONS = { + '1h': 'Every hour', + '3h': 'Every 3 hours', + '12h': 'Every 12 hours', + '1d': 'Every day', + '3d': 'Every 3 days' +}; + +export const DNS_SERVER_OPTIONS = { + '1.1.1.1': '1.1.1.1 (Cloudflare)', + '8.8.8.8': '8.8.8.8 (Google)', + '9.9.9.9': '9.9.9.9 (Quad9)', + 'dns.adguard-dns.com': 'dns.adguard-dns.com (AdGuard Default)', + 'unfiltered.adguard-dns.com': 'unfiltered.adguard-dns.com (AdGuard Unfiltered)', + 'family.adguard-dns.com': 'family.adguard-dns.com (AdGuard Family)' +}; + +export const DIAGNOSTICS_UPDATE_INTERVAL = 10000; // 10 seconds +export const CACHE_TIMEOUT = DIAGNOSTICS_UPDATE_INTERVAL - 1000; // 9 seconds +export const ERROR_POLL_INTERVAL = 10000; // 10 seconds +export const COMMAND_TIMEOUT = 10000; // 10 seconds +export const FETCH_TIMEOUT = 10000; // 10 seconds +export const BUTTON_FEEDBACK_TIMEOUT = 1000; // 1 second +export const DIAGNOSTICS_INITIAL_DELAY = 100; // 100 milliseconds + +// Command scheduling intervals in diagnostics (in milliseconds) +export const COMMAND_SCHEDULING = { + P0_PRIORITY: 0, // Highest priority (no delay) + P1_PRIORITY: 100, // Very high priority + P2_PRIORITY: 300, // High priority + P3_PRIORITY: 500, // Above average + P4_PRIORITY: 700, // Standard priority + P5_PRIORITY: 900, // Below average + P6_PRIORITY: 1100, // Low priority + P7_PRIORITY: 1300, // Very low priority + P8_PRIORITY: 1500, // Background execution + P9_PRIORITY: 1700, // Idle mode execution + P10_PRIORITY: 1900 // Lowest priority +}; diff --git a/fe-app-podkop/src/main.ts b/fe-app-podkop/src/main.ts index 88d79ca..bc2665e 100644 --- a/fe-app-podkop/src/main.ts +++ b/fe-app-podkop/src/main.ts @@ -2,3 +2,4 @@ 'require baseclass'; export * from './validators'; +export * from './constants' diff --git a/luci-app-podkop/htdocs/luci-static/resources/view/podkop/additionalTab.js b/luci-app-podkop/htdocs/luci-static/resources/view/podkop/additionalTab.js index 3fc6170..397a0d7 100644 --- a/luci-app-podkop/htdocs/luci-static/resources/view/podkop/additionalTab.js +++ b/luci-app-podkop/htdocs/luci-static/resources/view/podkop/additionalTab.js @@ -1,7 +1,6 @@ 'use strict'; 'require form'; 'require baseclass'; -'require view.podkop.constants as constants'; 'require tools.widgets as widgets'; 'require view.podkop.main as main'; @@ -24,7 +23,7 @@ function createAdditionalSection(mainSection) { o.ucisection = 'main'; o = mainSection.taboption('additional', form.ListValue, 'update_interval', _('List Update Frequency'), _('Select how often the lists will be updated')); - Object.entries(constants.UPDATE_INTERVAL_OPTIONS).forEach(([key, label]) => { + Object.entries(main.UPDATE_INTERVAL_OPTIONS).forEach(([key, label]) => { o.value(key, _(label)); }); o.default = '1d'; @@ -40,7 +39,7 @@ function createAdditionalSection(mainSection) { o.ucisection = 'main'; o = mainSection.taboption('additional', form.Value, 'dns_server', _('DNS Server'), _('Select or enter DNS server address')); - Object.entries(constants.DNS_SERVER_OPTIONS).forEach(([key, label]) => { + Object.entries(main.DNS_SERVER_OPTIONS).forEach(([key, label]) => { o.value(key, _(label)); }); o.default = '8.8.8.8'; diff --git a/luci-app-podkop/htdocs/luci-static/resources/view/podkop/configSection.js b/luci-app-podkop/htdocs/luci-static/resources/view/podkop/configSection.js index 59653fc..cdca7be 100644 --- a/luci-app-podkop/htdocs/luci-static/resources/view/podkop/configSection.js +++ b/luci-app-podkop/htdocs/luci-static/resources/view/podkop/configSection.js @@ -3,7 +3,6 @@ 'require form'; 'require ui'; 'require network'; -'require view.podkop.constants as constants'; 'require view.podkop.main as main'; 'require tools.widgets as widgets'; @@ -246,7 +245,7 @@ function createConfigSection(section, map, network) { o.ucisection = s.section; o = s.taboption('basic', form.Value, 'domain_resolver_dns_server', _('DNS Server'), _('Select or enter DNS server address')); - Object.entries(constants.DNS_SERVER_OPTIONS).forEach(([key, label]) => { + Object.entries(main.DNS_SERVER_OPTIONS).forEach(([key, label]) => { o.value(key, _(label)); }); o.default = '8.8.8.8'; @@ -270,7 +269,7 @@ function createConfigSection(section, map, network) { o = s.taboption('basic', form.DynamicList, 'community_lists', _('Service List'), _('Select predefined service for routing') + ' github.com/itdoginfo/allow-domains'); o.placeholder = 'Service list'; - Object.entries(constants.DOMAIN_LIST_OPTIONS).forEach(([key, label]) => { + Object.entries(main.DOMAIN_LIST_OPTIONS).forEach(([key, label]) => { o.value(key, _(label)); }); o.depends('community_lists_enabled', '1'); @@ -289,12 +288,12 @@ function createConfigSection(section, map, network) { let newValues = [...values]; let notifications = []; - const selectedRegionalOptions = constants.REGIONAL_OPTIONS.filter(opt => newValues.includes(opt)); + const selectedRegionalOptions = main.REGIONAL_OPTIONS.filter(opt => newValues.includes(opt)); if (selectedRegionalOptions.length > 1) { const lastSelected = selectedRegionalOptions[selectedRegionalOptions.length - 1]; const removedRegions = selectedRegionalOptions.slice(0, -1); - newValues = newValues.filter(v => v === lastSelected || !constants.REGIONAL_OPTIONS.includes(v)); + newValues = newValues.filter(v => v === lastSelected || !main.REGIONAL_OPTIONS.includes(v)); notifications.push(E('p', { class: 'alert-message warning' }, [ E('strong', {}, _('Regional options cannot be used together')), E('br'), _('Warning: %s cannot be used together with %s. Previous selections have been removed.') @@ -303,14 +302,14 @@ function createConfigSection(section, map, network) { } if (newValues.includes('russia_inside')) { - const removedServices = newValues.filter(v => !constants.ALLOWED_WITH_RUSSIA_INSIDE.includes(v)); + const removedServices = newValues.filter(v => !main.ALLOWED_WITH_RUSSIA_INSIDE.includes(v)); if (removedServices.length > 0) { - newValues = newValues.filter(v => constants.ALLOWED_WITH_RUSSIA_INSIDE.includes(v)); + newValues = newValues.filter(v => main.ALLOWED_WITH_RUSSIA_INSIDE.includes(v)); notifications.push(E('p', { class: 'alert-message warning' }, [ E('strong', {}, _('Russia inside restrictions')), E('br'), _('Warning: Russia inside can only be used with %s. %s already in Russia inside and have been removed from selection.') .format( - constants.ALLOWED_WITH_RUSSIA_INSIDE.map(key => constants.DOMAIN_LIST_OPTIONS[key]).filter(label => label !== 'Russia inside').join(', '), + main.ALLOWED_WITH_RUSSIA_INSIDE.map(key => main.DOMAIN_LIST_OPTIONS[key]).filter(label => label !== 'Russia inside').join(', '), removedServices.join(', ') ) ])); diff --git a/luci-app-podkop/htdocs/luci-static/resources/view/podkop/constants.js b/luci-app-podkop/htdocs/luci-static/resources/view/podkop/constants.js deleted file mode 100644 index d6c5858..0000000 --- a/luci-app-podkop/htdocs/luci-static/resources/view/podkop/constants.js +++ /dev/null @@ -1,113 +0,0 @@ -'use strict'; -'require baseclass'; - -const STATUS_COLORS = { - SUCCESS: '#4caf50', - ERROR: '#f44336', - WARNING: '#ff9800' -}; - -const FAKEIP_CHECK_DOMAIN = 'fakeip.podkop.fyi'; -const IP_CHECK_DOMAIN = 'ip.podkop.fyi'; - -const REGIONAL_OPTIONS = ['russia_inside', 'russia_outside', 'ukraine_inside']; -const ALLOWED_WITH_RUSSIA_INSIDE = [ - 'russia_inside', - 'meta', - 'twitter', - 'discord', - 'telegram', - 'cloudflare', - 'google_ai', - 'google_play', - 'hetzner', - 'ovh', - 'hodca', - 'digitalocean', - 'cloudfront' -]; - -const DOMAIN_LIST_OPTIONS = { - russia_inside: 'Russia inside', - russia_outside: 'Russia outside', - ukraine_inside: 'Ukraine', - geoblock: 'Geo Block', - block: 'Block', - porn: 'Porn', - news: 'News', - anime: 'Anime', - youtube: 'Youtube', - discord: 'Discord', - meta: 'Meta', - twitter: 'Twitter (X)', - hdrezka: 'HDRezka', - tiktok: 'Tik-Tok', - telegram: 'Telegram', - cloudflare: 'Cloudflare', - google_ai: 'Google AI', - google_play: 'Google Play', - hodca: 'H.O.D.C.A', - hetzner: 'Hetzner ASN', - ovh: 'OVH ASN', - digitalocean: 'Digital Ocean ASN', - cloudfront: 'CloudFront ASN' -}; - -const UPDATE_INTERVAL_OPTIONS = { - '1h': 'Every hour', - '3h': 'Every 3 hours', - '12h': 'Every 12 hours', - '1d': 'Every day', - '3d': 'Every 3 days' -}; - -const DNS_SERVER_OPTIONS = { - '1.1.1.1': '1.1.1.1 (Cloudflare)', - '8.8.8.8': '8.8.8.8 (Google)', - '9.9.9.9': '9.9.9.9 (Quad9)', - 'dns.adguard-dns.com': 'dns.adguard-dns.com (AdGuard Default)', - 'unfiltered.adguard-dns.com': 'unfiltered.adguard-dns.com (AdGuard Unfiltered)', - 'family.adguard-dns.com': 'family.adguard-dns.com (AdGuard Family)' -}; - -const DIAGNOSTICS_UPDATE_INTERVAL = 10000; // 10 seconds -const CACHE_TIMEOUT = DIAGNOSTICS_UPDATE_INTERVAL - 1000; // 9 seconds -const ERROR_POLL_INTERVAL = 10000; // 10 seconds -const COMMAND_TIMEOUT = 10000; // 10 seconds -const FETCH_TIMEOUT = 10000; // 10 seconds -const BUTTON_FEEDBACK_TIMEOUT = 1000; // 1 second -const DIAGNOSTICS_INITIAL_DELAY = 100; // 100 milliseconds - -// Интервалы планирования команд в диагностике (в миллисекундах) -const COMMAND_SCHEDULING = { - P0_PRIORITY: 0, // Наивысший приоритет (без задержки) - P1_PRIORITY: 100, // Очень высокий приоритет - P2_PRIORITY: 300, // Высокий приоритет - P3_PRIORITY: 500, // Выше среднего - P4_PRIORITY: 700, // Стандартный приоритет - P5_PRIORITY: 900, // Ниже среднего - P6_PRIORITY: 1100, // Низкий приоритет - P7_PRIORITY: 1300, // Очень низкий приоритет - P8_PRIORITY: 1500, // Фоновое выполнение - P9_PRIORITY: 1700, // Выполнение в режиме простоя - P10_PRIORITY: 1900 // Наименьший приоритет -}; - -return baseclass.extend({ - STATUS_COLORS, - FAKEIP_CHECK_DOMAIN, - IP_CHECK_DOMAIN, - REGIONAL_OPTIONS, - ALLOWED_WITH_RUSSIA_INSIDE, - DOMAIN_LIST_OPTIONS, - UPDATE_INTERVAL_OPTIONS, - DNS_SERVER_OPTIONS, - DIAGNOSTICS_UPDATE_INTERVAL, - ERROR_POLL_INTERVAL, - COMMAND_TIMEOUT, - FETCH_TIMEOUT, - BUTTON_FEEDBACK_TIMEOUT, - DIAGNOSTICS_INITIAL_DELAY, - COMMAND_SCHEDULING, - CACHE_TIMEOUT -}); diff --git a/luci-app-podkop/htdocs/luci-static/resources/view/podkop/diagnosticTab.js b/luci-app-podkop/htdocs/luci-static/resources/view/podkop/diagnosticTab.js index 4ca06f5..a316a15 100644 --- a/luci-app-podkop/htdocs/luci-static/resources/view/podkop/diagnosticTab.js +++ b/luci-app-podkop/htdocs/luci-static/resources/view/podkop/diagnosticTab.js @@ -4,8 +4,8 @@ 'require ui'; 'require uci'; 'require fs'; -'require view.podkop.constants as constants'; 'require view.podkop.utils as utils'; +'require view.podkop.main as main'; // Cache system for network requests const fetchCache = {}; @@ -16,7 +16,7 @@ async function cachedFetch(url, options = {}) { const currentTime = Date.now(); // If we have a valid cached response, return it - if (fetchCache[cacheKey] && currentTime - fetchCache[cacheKey].timestamp < constants.CACHE_TIMEOUT) { + if (fetchCache[cacheKey] && currentTime - fetchCache[cacheKey].timestamp < main.CACHE_TIMEOUT) { console.log(`Using cached response for ${url}`); return Promise.resolve(fetchCache[cacheKey].response.clone()); } @@ -38,18 +38,18 @@ async function cachedFetch(url, options = {}) { } // Helper functions for command execution with prioritization - Using from utils.js now -function safeExec(command, args, priority, callback, timeout = constants.COMMAND_TIMEOUT) { +function safeExec(command, args, priority, callback, timeout = main.COMMAND_TIMEOUT) { return utils.safeExec(command, args, priority, callback, timeout); } // Helper functions for handling checks function runCheck(checkFunction, priority, callback) { // Default to highest priority execution if priority is not provided or invalid - let schedulingDelay = constants.COMMAND_SCHEDULING.P0_PRIORITY; + let schedulingDelay = main.COMMAND_SCHEDULING.P0_PRIORITY; // If priority is a string, try to get the corresponding delay value - if (typeof priority === 'string' && constants.COMMAND_SCHEDULING[priority] !== undefined) { - schedulingDelay = constants.COMMAND_SCHEDULING[priority]; + if (typeof priority === 'string' && main.COMMAND_SCHEDULING[priority] !== undefined) { + schedulingDelay = main.COMMAND_SCHEDULING[priority]; } const executeCheck = async () => { @@ -77,11 +77,11 @@ function runCheck(checkFunction, priority, callback) { function runAsyncTask(taskFunction, priority) { // Default to highest priority execution if priority is not provided or invalid - let schedulingDelay = constants.COMMAND_SCHEDULING.P0_PRIORITY; + let schedulingDelay = main.COMMAND_SCHEDULING.P0_PRIORITY; // If priority is a string, try to get the corresponding delay value - if (typeof priority === 'string' && constants.COMMAND_SCHEDULING[priority] !== undefined) { - schedulingDelay = constants.COMMAND_SCHEDULING[priority]; + if (typeof priority === 'string' && main.COMMAND_SCHEDULING[priority] !== undefined) { + schedulingDelay = main.COMMAND_SCHEDULING[priority]; } setTimeout(async () => { @@ -98,7 +98,7 @@ function createStatus(state, message, color) { return { state, message: _(message), - color: constants.STATUS_COLORS[color] + color: main.STATUS_COLORS[color] }; } @@ -119,7 +119,7 @@ function copyToClipboard(text, button) { document.execCommand('copy'); const originalText = button.textContent; button.textContent = _('Copied!'); - setTimeout(() => button.textContent = originalText, constants.BUTTON_FEEDBACK_TIMEOUT); + setTimeout(() => button.textContent = originalText, main.BUTTON_FEEDBACK_TIMEOUT); } catch (err) { ui.addNotification(null, E('p', {}, _('Failed to copy: ') + err.message)); } @@ -138,10 +138,10 @@ function maskIP(ip) { async function checkFakeIP() { try { const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), constants.FETCH_TIMEOUT); + const timeoutId = setTimeout(() => controller.abort(), main.FETCH_TIMEOUT); try { - const response = await cachedFetch(`https://${constants.FAKEIP_CHECK_DOMAIN}/check`, { signal: controller.signal }); + const response = await cachedFetch(`https://${main.FAKEIP_CHECK_DOMAIN}/check`, { signal: controller.signal }); const data = await response.json(); clearTimeout(timeoutId); @@ -163,7 +163,7 @@ async function checkFakeIP() { async function checkFakeIPCLI() { try { return new Promise((resolve) => { - safeExec('nslookup', ['-timeout=2', constants.FAKEIP_CHECK_DOMAIN, '127.0.0.42'], 'P0_PRIORITY', result => { + safeExec('nslookup', ['-timeout=2', main.FAKEIP_CHECK_DOMAIN, '127.0.0.42'], 'P0_PRIORITY', result => { if (result.stdout && result.stdout.includes('198.18')) { resolve(createStatus('working', 'working on router', 'SUCCESS')); } else { @@ -221,13 +221,13 @@ function checkDNSAvailability() { async function checkBypass() { try { const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), constants.FETCH_TIMEOUT); + const timeoutId = setTimeout(() => controller.abort(), main.FETCH_TIMEOUT); try { - const response1 = await cachedFetch(`https://${constants.FAKEIP_CHECK_DOMAIN}/check`, { signal: controller.signal }); + const response1 = await cachedFetch(`https://${main.FAKEIP_CHECK_DOMAIN}/check`, { signal: controller.signal }); const data1 = await response1.json(); - const response2 = await cachedFetch(`https://${constants.IP_CHECK_DOMAIN}/check`, { signal: controller.signal }); + const response2 = await cachedFetch(`https://${main.IP_CHECK_DOMAIN}/check`, { signal: controller.signal }); const data2 = await response2.json(); clearTimeout(timeoutId); @@ -327,9 +327,9 @@ function showConfigModal(command, title) { try { const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), constants.FETCH_TIMEOUT); + const timeoutId = setTimeout(() => controller.abort(), main.FETCH_TIMEOUT); - cachedFetch(`https://${constants.FAKEIP_CHECK_DOMAIN}/check`, { signal: controller.signal }) + cachedFetch(`https://${main.FAKEIP_CHECK_DOMAIN}/check`, { signal: controller.signal }) .then(response => response.json()) .then(data => { clearTimeout(timeoutId); @@ -343,10 +343,10 @@ function showConfigModal(command, title) { } // Bypass check - cachedFetch(`https://${constants.FAKEIP_CHECK_DOMAIN}/check`, { signal: controller.signal }) + cachedFetch(`https://${main.FAKEIP_CHECK_DOMAIN}/check`, { signal: controller.signal }) .then(bypassResponse => bypassResponse.json()) .then(bypassData => { - cachedFetch(`https://${constants.IP_CHECK_DOMAIN}/check`, { signal: controller.signal }) + cachedFetch(`https://${main.IP_CHECK_DOMAIN}/check`, { signal: controller.signal }) .then(bypassResponse2 => bypassResponse2.json()) .then(bypassData2 => { formattedOutput += '━━━━━━━━━━━━━━━━━━━━━━━━━━━\n'; @@ -602,7 +602,7 @@ function startDiagnosticsUpdates() { updateDiagnostics(); // Then set up periodic updates - diagnosticsUpdateTimer = setInterval(updateDiagnostics, constants.DIAGNOSTICS_UPDATE_INTERVAL); + diagnosticsUpdateTimer = setInterval(updateDiagnostics, main.DIAGNOSTICS_UPDATE_INTERVAL); } function stopDiagnosticsUpdates() { @@ -630,7 +630,7 @@ async function updateDiagnostics() { // Update Podkop status text updateTextElement('podkop-status-text', E('span', { - 'style': `color: ${parsedPodkopStatus.enabled ? constants.STATUS_COLORS.SUCCESS : constants.STATUS_COLORS.ERROR}` + 'style': `color: ${parsedPodkopStatus.enabled ? main.STATUS_COLORS.SUCCESS : main.STATUS_COLORS.ERROR}` }, [ parsedPodkopStatus.enabled ? '✔ Autostart enabled' : '✘ Autostart disabled' ]) @@ -661,7 +661,7 @@ async function updateDiagnostics() { } } catch (error) { updateTextElement('podkop-status-text', - E('span', { 'style': `color: ${constants.STATUS_COLORS.ERROR}` }, '✘ Error') + E('span', { 'style': `color: ${main.STATUS_COLORS.ERROR}` }, '✘ Error') ); } }); @@ -675,7 +675,7 @@ async function updateDiagnostics() { updateTextElement('singbox-status-text', E('span', { 'style': `color: ${parsedSingboxStatus.running && !parsedSingboxStatus.enabled ? - constants.STATUS_COLORS.SUCCESS : constants.STATUS_COLORS.ERROR}` + main.STATUS_COLORS.SUCCESS : main.STATUS_COLORS.ERROR}` }, [ parsedSingboxStatus.running && !parsedSingboxStatus.enabled ? '✔ running' : '✘ ' + parsedSingboxStatus.status @@ -683,7 +683,7 @@ async function updateDiagnostics() { ); } catch (error) { updateTextElement('singbox-status-text', - E('span', { 'style': `color: ${constants.STATUS_COLORS.ERROR}` }, '✘ Error') + E('span', { 'style': `color: ${main.STATUS_COLORS.ERROR}` }, '✘ Error') ); } }); @@ -724,7 +724,7 @@ async function updateDiagnostics() { // FakeIP and DNS status checks runCheck(checkFakeIP, 'P3_PRIORITY', result => { updateTextElement('fakeip-browser-status', - E('span', { style: `color: ${result.error ? constants.STATUS_COLORS.WARNING : result.color}` }, [ + E('span', { style: `color: ${result.error ? main.STATUS_COLORS.WARNING : result.color}` }, [ result.error ? '! ' : result.state === 'working' ? '✔ ' : result.state === 'not_working' ? '✘ ' : '! ', result.error ? 'check error' : result.state === 'working' ? _('works in browser') : _('does not work in browser') ]) @@ -733,7 +733,7 @@ async function updateDiagnostics() { runCheck(checkFakeIPCLI, 'P8_PRIORITY', result => { updateTextElement('fakeip-router-status', - E('span', { style: `color: ${result.error ? constants.STATUS_COLORS.WARNING : result.color}` }, [ + E('span', { style: `color: ${result.error ? main.STATUS_COLORS.WARNING : result.color}` }, [ result.error ? '! ' : result.state === 'working' ? '✔ ' : result.state === 'not_working' ? '✘ ' : '! ', result.error ? 'check error' : result.state === 'working' ? _('works on router') : _('does not work on router') ]) @@ -743,10 +743,10 @@ async function updateDiagnostics() { runCheck(checkDNSAvailability, 'P4_PRIORITY', result => { if (result.error) { updateTextElement('dns-remote-status', - E('span', { style: `color: ${constants.STATUS_COLORS.WARNING}` }, '! DNS check error') + E('span', { style: `color: ${main.STATUS_COLORS.WARNING}` }, '! DNS check error') ); updateTextElement('dns-local-status', - E('span', { style: `color: ${constants.STATUS_COLORS.WARNING}` }, '! DNS check error') + E('span', { style: `color: ${main.STATUS_COLORS.WARNING}` }, '! DNS check error') ); } else { updateTextElement('dns-remote-status', @@ -767,7 +767,7 @@ async function updateDiagnostics() { runCheck(checkBypass, 'P1_PRIORITY', result => { updateTextElement('bypass-status', - E('span', { style: `color: ${result.error ? constants.STATUS_COLORS.WARNING : result.color}` }, [ + E('span', { style: `color: ${result.error ? main.STATUS_COLORS.WARNING : result.color}` }, [ result.error ? '! ' : result.state === 'working' ? '✔ ' : result.state === 'not_working' ? '✘ ' : '! ', result.error ? 'check error' : result.message ]) @@ -875,7 +875,7 @@ function setupDiagnosticsEventHandlers(node) { } }); } - }, constants.DIAGNOSTICS_INITIAL_DELAY); + }, main.DIAGNOSTICS_INITIAL_DELAY); node.classList.add('fade-in'); return node; @@ -884,4 +884,4 @@ function setupDiagnosticsEventHandlers(node) { return baseclass.extend({ createDiagnosticsSection, setupDiagnosticsEventHandlers -}); \ No newline at end of file +}); diff --git a/luci-app-podkop/htdocs/luci-static/resources/view/podkop/main.js b/luci-app-podkop/htdocs/luci-static/resources/view/podkop/main.js index df15d3c..6b4953a 100644 --- a/luci-app-podkop/htdocs/luci-static/resources/view/podkop/main.js +++ b/luci-app-podkop/htdocs/luci-static/resources/view/podkop/main.js @@ -58,7 +58,119 @@ function validateUrl(url, protocols = ["http:", "https:"]) { return { valid: false, message: "Invalid URL format" }; } } + +// src/constants.ts +var STATUS_COLORS = { + SUCCESS: "#4caf50", + ERROR: "#f44336", + WARNING: "#ff9800" +}; +var FAKEIP_CHECK_DOMAIN = "fakeip.podkop.fyi"; +var IP_CHECK_DOMAIN = "ip.podkop.fyi"; +var REGIONAL_OPTIONS = ["russia_inside", "russia_outside", "ukraine_inside"]; +var ALLOWED_WITH_RUSSIA_INSIDE = [ + "russia_inside", + "meta", + "twitter", + "discord", + "telegram", + "cloudflare", + "google_ai", + "google_play", + "hetzner", + "ovh", + "hodca", + "digitalocean", + "cloudfront" +]; +var DOMAIN_LIST_OPTIONS = { + russia_inside: "Russia inside", + russia_outside: "Russia outside", + ukraine_inside: "Ukraine", + geoblock: "Geo Block", + block: "Block", + porn: "Porn", + news: "News", + anime: "Anime", + youtube: "Youtube", + discord: "Discord", + meta: "Meta", + twitter: "Twitter (X)", + hdrezka: "HDRezka", + tiktok: "Tik-Tok", + telegram: "Telegram", + cloudflare: "Cloudflare", + google_ai: "Google AI", + google_play: "Google Play", + hodca: "H.O.D.C.A", + hetzner: "Hetzner ASN", + ovh: "OVH ASN", + digitalocean: "Digital Ocean ASN", + cloudfront: "CloudFront ASN" +}; +var UPDATE_INTERVAL_OPTIONS = { + "1h": "Every hour", + "3h": "Every 3 hours", + "12h": "Every 12 hours", + "1d": "Every day", + "3d": "Every 3 days" +}; +var DNS_SERVER_OPTIONS = { + "1.1.1.1": "1.1.1.1 (Cloudflare)", + "8.8.8.8": "8.8.8.8 (Google)", + "9.9.9.9": "9.9.9.9 (Quad9)", + "dns.adguard-dns.com": "dns.adguard-dns.com (AdGuard Default)", + "unfiltered.adguard-dns.com": "unfiltered.adguard-dns.com (AdGuard Unfiltered)", + "family.adguard-dns.com": "family.adguard-dns.com (AdGuard Family)" +}; +var DIAGNOSTICS_UPDATE_INTERVAL = 1e4; +var CACHE_TIMEOUT = DIAGNOSTICS_UPDATE_INTERVAL - 1e3; +var ERROR_POLL_INTERVAL = 1e4; +var COMMAND_TIMEOUT = 1e4; +var FETCH_TIMEOUT = 1e4; +var BUTTON_FEEDBACK_TIMEOUT = 1e3; +var DIAGNOSTICS_INITIAL_DELAY = 100; +var COMMAND_SCHEDULING = { + P0_PRIORITY: 0, + // Highest priority (no delay) + P1_PRIORITY: 100, + // Very high priority + P2_PRIORITY: 300, + // High priority + P3_PRIORITY: 500, + // Above average + P4_PRIORITY: 700, + // Standard priority + P5_PRIORITY: 900, + // Below average + P6_PRIORITY: 1100, + // Low priority + P7_PRIORITY: 1300, + // Very low priority + P8_PRIORITY: 1500, + // Background execution + P9_PRIORITY: 1700, + // Idle mode execution + P10_PRIORITY: 1900 + // Lowest priority +}; return baseclass.extend({ + ALLOWED_WITH_RUSSIA_INSIDE, + BUTTON_FEEDBACK_TIMEOUT, + CACHE_TIMEOUT, + COMMAND_SCHEDULING, + COMMAND_TIMEOUT, + DIAGNOSTICS_INITIAL_DELAY, + DIAGNOSTICS_UPDATE_INTERVAL, + DNS_SERVER_OPTIONS, + DOMAIN_LIST_OPTIONS, + ERROR_POLL_INTERVAL, + FAKEIP_CHECK_DOMAIN, + FETCH_TIMEOUT, + IP_CHECK_DOMAIN, + REGIONAL_OPTIONS, + STATUS_COLORS, + UPDATE_INTERVAL_OPTIONS, validateDNS, validateDomain, validateIPV4, diff --git a/luci-app-podkop/htdocs/luci-static/resources/view/podkop/utils.js b/luci-app-podkop/htdocs/luci-static/resources/view/podkop/utils.js index 164afb9..46cb086 100644 --- a/luci-app-podkop/htdocs/luci-static/resources/view/podkop/utils.js +++ b/luci-app-podkop/htdocs/luci-static/resources/view/podkop/utils.js @@ -2,7 +2,7 @@ 'require baseclass'; 'require ui'; 'require fs'; -'require view.podkop.constants as constants'; +'require view.podkop.main as main'; // Flag to track if this is the first error check let isInitialCheck = true; @@ -39,13 +39,13 @@ function showErrorNotification(error, isMultiple = false) { } // Helper function for command execution with prioritization -function safeExec(command, args, priority, callback, timeout = constants.COMMAND_TIMEOUT) { +function safeExec(command, args, priority, callback, timeout = main.COMMAND_TIMEOUT) { // Default to highest priority execution if priority is not provided or invalid - let schedulingDelay = constants.COMMAND_SCHEDULING.P0_PRIORITY; + let schedulingDelay = main.COMMAND_SCHEDULING.P0_PRIORITY; // If priority is a string, try to get the corresponding delay value - if (typeof priority === 'string' && constants.COMMAND_SCHEDULING[priority] !== undefined) { - schedulingDelay = constants.COMMAND_SCHEDULING[priority]; + if (typeof priority === 'string' && main.COMMAND_SCHEDULING[priority] !== undefined) { + schedulingDelay = main.COMMAND_SCHEDULING[priority]; } const executeCommand = async () => { @@ -133,7 +133,7 @@ function startErrorPolling() { checkForCriticalErrors(); // Then set up periodic checks - errorPollTimer = setInterval(checkForCriticalErrors, constants.ERROR_POLL_INTERVAL); + errorPollTimer = setInterval(checkForCriticalErrors, main.ERROR_POLL_INTERVAL); } // Stop polling for errors @@ -149,4 +149,4 @@ return baseclass.extend({ stopErrorPolling, checkForCriticalErrors, safeExec -}); \ No newline at end of file +}); From f58472a53d33210753b81e8f7f3a35e5453bd157 Mon Sep 17 00:00:00 2001 From: divocat Date: Thu, 2 Oct 2025 22:37:36 +0300 Subject: [PATCH 05/49] feat: migrate validatePath to modular --- fe-app-podkop/src/constants.ts | 130 +++++++++--------- fe-app-podkop/src/main.ts | 2 +- fe-app-podkop/src/validators/index.ts | 1 + .../src/validators/tests/validatePath.test.js | 39 ++++++ fe-app-podkop/src/validators/validatePath.ts | 25 ++++ .../resources/view/podkop/configSection.js | 32 +++-- .../luci-static/resources/view/podkop/main.js | 28 +++- 7 files changed, 183 insertions(+), 74 deletions(-) create mode 100644 fe-app-podkop/src/validators/tests/validatePath.test.js create mode 100644 fe-app-podkop/src/validators/validatePath.ts diff --git a/fe-app-podkop/src/constants.ts b/fe-app-podkop/src/constants.ts index b0ccded..130089d 100644 --- a/fe-app-podkop/src/constants.ts +++ b/fe-app-podkop/src/constants.ts @@ -1,70 +1,76 @@ export const STATUS_COLORS = { - SUCCESS: '#4caf50', - ERROR: '#f44336', - WARNING: '#ff9800' + SUCCESS: '#4caf50', + ERROR: '#f44336', + WARNING: '#ff9800', }; export const FAKEIP_CHECK_DOMAIN = 'fakeip.podkop.fyi'; export const IP_CHECK_DOMAIN = 'ip.podkop.fyi'; -export const REGIONAL_OPTIONS = ['russia_inside', 'russia_outside', 'ukraine_inside']; +export const REGIONAL_OPTIONS = [ + 'russia_inside', + 'russia_outside', + 'ukraine_inside', +]; + export const ALLOWED_WITH_RUSSIA_INSIDE = [ - 'russia_inside', - 'meta', - 'twitter', - 'discord', - 'telegram', - 'cloudflare', - 'google_ai', - 'google_play', - 'hetzner', - 'ovh', - 'hodca', - 'digitalocean', - 'cloudfront' + 'russia_inside', + 'meta', + 'twitter', + 'discord', + 'telegram', + 'cloudflare', + 'google_ai', + 'google_play', + 'hetzner', + 'ovh', + 'hodca', + 'digitalocean', + 'cloudfront', ]; export const DOMAIN_LIST_OPTIONS = { - russia_inside: 'Russia inside', - russia_outside: 'Russia outside', - ukraine_inside: 'Ukraine', - geoblock: 'Geo Block', - block: 'Block', - porn: 'Porn', - news: 'News', - anime: 'Anime', - youtube: 'Youtube', - discord: 'Discord', - meta: 'Meta', - twitter: 'Twitter (X)', - hdrezka: 'HDRezka', - tiktok: 'Tik-Tok', - telegram: 'Telegram', - cloudflare: 'Cloudflare', - google_ai: 'Google AI', - google_play: 'Google Play', - hodca: 'H.O.D.C.A', - hetzner: 'Hetzner ASN', - ovh: 'OVH ASN', - digitalocean: 'Digital Ocean ASN', - cloudfront: 'CloudFront ASN' + russia_inside: 'Russia inside', + russia_outside: 'Russia outside', + ukraine_inside: 'Ukraine', + geoblock: 'Geo Block', + block: 'Block', + porn: 'Porn', + news: 'News', + anime: 'Anime', + youtube: 'Youtube', + discord: 'Discord', + meta: 'Meta', + twitter: 'Twitter (X)', + hdrezka: 'HDRezka', + tiktok: 'Tik-Tok', + telegram: 'Telegram', + cloudflare: 'Cloudflare', + google_ai: 'Google AI', + google_play: 'Google Play', + hodca: 'H.O.D.C.A', + hetzner: 'Hetzner ASN', + ovh: 'OVH ASN', + digitalocean: 'Digital Ocean ASN', + cloudfront: 'CloudFront ASN', }; export const UPDATE_INTERVAL_OPTIONS = { - '1h': 'Every hour', - '3h': 'Every 3 hours', - '12h': 'Every 12 hours', - '1d': 'Every day', - '3d': 'Every 3 days' + '1h': 'Every hour', + '3h': 'Every 3 hours', + '12h': 'Every 12 hours', + '1d': 'Every day', + '3d': 'Every 3 days', }; export const DNS_SERVER_OPTIONS = { - '1.1.1.1': '1.1.1.1 (Cloudflare)', - '8.8.8.8': '8.8.8.8 (Google)', - '9.9.9.9': '9.9.9.9 (Quad9)', - 'dns.adguard-dns.com': 'dns.adguard-dns.com (AdGuard Default)', - 'unfiltered.adguard-dns.com': 'unfiltered.adguard-dns.com (AdGuard Unfiltered)', - 'family.adguard-dns.com': 'family.adguard-dns.com (AdGuard Family)' + '1.1.1.1': '1.1.1.1 (Cloudflare)', + '8.8.8.8': '8.8.8.8 (Google)', + '9.9.9.9': '9.9.9.9 (Quad9)', + 'dns.adguard-dns.com': 'dns.adguard-dns.com (AdGuard Default)', + 'unfiltered.adguard-dns.com': + 'unfiltered.adguard-dns.com (AdGuard Unfiltered)', + 'family.adguard-dns.com': 'family.adguard-dns.com (AdGuard Family)', }; export const DIAGNOSTICS_UPDATE_INTERVAL = 10000; // 10 seconds @@ -77,15 +83,15 @@ export const DIAGNOSTICS_INITIAL_DELAY = 100; // 100 milliseconds // Command scheduling intervals in diagnostics (in milliseconds) export const COMMAND_SCHEDULING = { - P0_PRIORITY: 0, // Highest priority (no delay) - P1_PRIORITY: 100, // Very high priority - P2_PRIORITY: 300, // High priority - P3_PRIORITY: 500, // Above average - P4_PRIORITY: 700, // Standard priority - P5_PRIORITY: 900, // Below average - P6_PRIORITY: 1100, // Low priority - P7_PRIORITY: 1300, // Very low priority - P8_PRIORITY: 1500, // Background execution - P9_PRIORITY: 1700, // Idle mode execution - P10_PRIORITY: 1900 // Lowest priority + P0_PRIORITY: 0, // Highest priority (no delay) + P1_PRIORITY: 100, // Very high priority + P2_PRIORITY: 300, // High priority + P3_PRIORITY: 500, // Above average + P4_PRIORITY: 700, // Standard priority + P5_PRIORITY: 900, // Below average + P6_PRIORITY: 1100, // Low priority + P7_PRIORITY: 1300, // Very low priority + P8_PRIORITY: 1500, // Background execution + P9_PRIORITY: 1700, // Idle mode execution + P10_PRIORITY: 1900, // Lowest priority }; diff --git a/fe-app-podkop/src/main.ts b/fe-app-podkop/src/main.ts index bc2665e..fad5419 100644 --- a/fe-app-podkop/src/main.ts +++ b/fe-app-podkop/src/main.ts @@ -2,4 +2,4 @@ 'require baseclass'; export * from './validators'; -export * from './constants' +export * from './constants'; diff --git a/fe-app-podkop/src/validators/index.ts b/fe-app-podkop/src/validators/index.ts index 2adb0bd..5d0851d 100644 --- a/fe-app-podkop/src/validators/index.ts +++ b/fe-app-podkop/src/validators/index.ts @@ -2,3 +2,4 @@ export * from './validateIp'; export * from './validateDomain'; export * from './validateDns'; export * from './validateUrl'; +export * from './validatePath'; diff --git a/fe-app-podkop/src/validators/tests/validatePath.test.js b/fe-app-podkop/src/validators/tests/validatePath.test.js new file mode 100644 index 0000000..5f43be4 --- /dev/null +++ b/fe-app-podkop/src/validators/tests/validatePath.test.js @@ -0,0 +1,39 @@ +import { describe, expect, it } from 'vitest'; +import { validatePath } from '../validatePath'; + +export const validPaths = [ + ['Single level', '/etc'], + ['Nested path', '/usr/local/bin'], + ['With dash', '/var/log/nginx-access'], + ['With underscore', '/opt/my_app/config'], + ['With numbers', '/data123/files'], + ['With dots', '/home/user/.config'], + ['Deep nested', '/a/b/c/d/e/f/g'], +]; + +export const invalidPaths = [ + ['Empty string', ''], + ['Missing starting slash', 'usr/local'], + ['Only dot', '.'], + ['Space inside', '/path with space'], + ['Illegal char', '/path$'], + ['Backslash not allowed', '\\windows\\path'], + ['Relative path ./', './relative'], + ['Relative path ../', '../parent'], +]; + +describe('validatePath', () => { + describe.each(validPaths)('Valid path: %s', (_desc, path) => { + it(`returns valid=true for "${path}"`, () => { + const res = validatePath(path); + expect(res.valid).toBe(true); + }); + }); + + describe.each(invalidPaths)('Invalid path: %s', (_desc, path) => { + it(`returns valid=false for "${path}"`, () => { + const res = validatePath(path); + expect(res.valid).toBe(false); + }); + }); +}); diff --git a/fe-app-podkop/src/validators/validatePath.ts b/fe-app-podkop/src/validators/validatePath.ts new file mode 100644 index 0000000..9da07ba --- /dev/null +++ b/fe-app-podkop/src/validators/validatePath.ts @@ -0,0 +1,25 @@ +import { ValidationResult } from './types'; + +export function validatePath(value: string): ValidationResult { + if (!value) { + return { + valid: false, + message: 'Path cannot be empty', + }; + } + + const pathRegex = /^\/[a-zA-Z0-9_\-/.]+$/; + + if (pathRegex.test(value)) { + return { + valid: true, + message: 'Valid', + }; + } + + return { + valid: false, + message: + 'Invalid path format. Path must start with "/" and contain valid characters', + }; +} diff --git a/luci-app-podkop/htdocs/luci-static/resources/view/podkop/configSection.js b/luci-app-podkop/htdocs/luci-static/resources/view/podkop/configSection.js index cdca7be..3fe88c7 100644 --- a/luci-app-podkop/htdocs/luci-static/resources/view/podkop/configSection.js +++ b/luci-app-podkop/htdocs/luci-static/resources/view/podkop/configSection.js @@ -409,12 +409,18 @@ function createConfigSection(section, map, network) { o.rmempty = false; o.ucisection = s.section; o.validate = function (section_id, value) { - if (!value || value.length === 0) return true; - const pathRegex = /^\/[a-zA-Z0-9_\-\/\.]+$/; - if (!pathRegex.test(value)) { - return _('Invalid path format. Path must start with "/" and contain valid characters'); + // Optional + if (!value || value.length === 0) { + return true } - return true; + + const validation = main.validatePath(value); + + if (validation.valid) { + return true; + } + + return _(validation.message) }; o = s.taboption('basic', form.Flag, 'remote_domain_lists_enabled', _('Remote Domain Lists'), _('Download and use domain lists from remote URLs')); @@ -453,12 +459,18 @@ function createConfigSection(section, map, network) { o.rmempty = false; o.ucisection = s.section; o.validate = function (section_id, value) { - if (!value || value.length === 0) return true; - const pathRegex = /^\/[a-zA-Z0-9_\-\/\.]+$/; - if (!pathRegex.test(value)) { - return _('Invalid path format. Path must start with "/" and contain valid characters'); + // Optional + if (!value || value.length === 0) { + return true } - return true; + + const validation = main.validatePath(value); + + if (validation.valid) { + return true; + } + + return _(validation.message) }; o = s.taboption('basic', form.ListValue, 'user_subnet_list_type', _('User Subnet List Type'), _('Select how to add your custom subnets')); diff --git a/luci-app-podkop/htdocs/luci-static/resources/view/podkop/main.js b/luci-app-podkop/htdocs/luci-static/resources/view/podkop/main.js index 6b4953a..36bfda0 100644 --- a/luci-app-podkop/htdocs/luci-static/resources/view/podkop/main.js +++ b/luci-app-podkop/htdocs/luci-static/resources/view/podkop/main.js @@ -59,6 +59,27 @@ function validateUrl(url, protocols = ["http:", "https:"]) { } } +// src/validators/validatePath.ts +function validatePath(value) { + if (!value) { + return { + valid: false, + message: "Path cannot be empty" + }; + } + const pathRegex = /^\/[a-zA-Z0-9_\-/.]+$/; + if (pathRegex.test(value)) { + return { + valid: true, + message: "Valid" + }; + } + return { + valid: false, + message: 'Invalid path format. Path must start with "/" and contain valid characters' + }; +} + // src/constants.ts var STATUS_COLORS = { SUCCESS: "#4caf50", @@ -67,7 +88,11 @@ var STATUS_COLORS = { }; var FAKEIP_CHECK_DOMAIN = "fakeip.podkop.fyi"; var IP_CHECK_DOMAIN = "ip.podkop.fyi"; -var REGIONAL_OPTIONS = ["russia_inside", "russia_outside", "ukraine_inside"]; +var REGIONAL_OPTIONS = [ + "russia_inside", + "russia_outside", + "ukraine_inside" +]; var ALLOWED_WITH_RUSSIA_INSIDE = [ "russia_inside", "meta", @@ -174,5 +199,6 @@ return baseclass.extend({ validateDNS, validateDomain, validateIPV4, + validatePath, validateUrl }); From cd133838cb29f82ff73b0a330866bf90c0ae76fe Mon Sep 17 00:00:00 2001 From: divocat Date: Thu, 2 Oct 2025 22:45:50 +0300 Subject: [PATCH 06/49] feat: add BOOTSTRAP_DNS_SERVER_OPTIONS to constants --- fe-app-podkop/src/constants.ts | 10 ++++++++++ .../resources/view/podkop/additionalTab.js | 11 +++-------- .../htdocs/luci-static/resources/view/podkop/main.js | 11 +++++++++++ 3 files changed, 24 insertions(+), 8 deletions(-) diff --git a/fe-app-podkop/src/constants.ts b/fe-app-podkop/src/constants.ts index 130089d..7f39c3f 100644 --- a/fe-app-podkop/src/constants.ts +++ b/fe-app-podkop/src/constants.ts @@ -72,6 +72,16 @@ export const DNS_SERVER_OPTIONS = { 'unfiltered.adguard-dns.com (AdGuard Unfiltered)', 'family.adguard-dns.com': 'family.adguard-dns.com (AdGuard Family)', }; +export const BOOTSTRAP_DNS_SERVER_OPTIONS = { + '77.88.8.8': '77.88.8.8 (Yandex DNS)', + '77.88.8.1': '77.88.8.1 (Yandex DNS)', + '1.1.1.1': '1.1.1.1 (Cloudflare DNS)', + '1.0.0.1': '1.0.0.1 (Cloudflare DNS)', + '8.8.8.8': '8.8.8.8 (Google DNS)', + '8.8.4.4': '8.8.4.4 (Google DNS)', + '9.9.9.9': '9.9.9.9 (Quad9 DNS)', + '9.9.9.11': '9.9.9.11 (Quad9 DNS)', +}; export const DIAGNOSTICS_UPDATE_INTERVAL = 10000; // 10 seconds export const CACHE_TIMEOUT = DIAGNOSTICS_UPDATE_INTERVAL - 1000; // 9 seconds diff --git a/luci-app-podkop/htdocs/luci-static/resources/view/podkop/additionalTab.js b/luci-app-podkop/htdocs/luci-static/resources/view/podkop/additionalTab.js index 397a0d7..d16b68b 100644 --- a/luci-app-podkop/htdocs/luci-static/resources/view/podkop/additionalTab.js +++ b/luci-app-podkop/htdocs/luci-static/resources/view/podkop/additionalTab.js @@ -56,14 +56,9 @@ function createAdditionalSection(mainSection) { }; o = mainSection.taboption('additional', form.Value, 'bootstrap_dns_server', _('Bootstrap DNS server'), _('The DNS server used to look up the IP address of an upstream DNS server')); - o.value('77.88.8.8', '77.88.8.8 (Yandex DNS)'); - o.value('77.88.8.1', '77.88.8.1 (Yandex DNS)'); - o.value('1.1.1.1', '1.1.1.1 (Cloudflare DNS)'); - o.value('1.0.0.1', '1.0.0.1 (Cloudflare DNS)'); - o.value('8.8.8.8', '8.8.8.8 (Google DNS)'); - o.value('8.8.4.4', '8.8.4.4 (Google DNS)'); - o.value('9.9.9.9', '9.9.9.9 (Quad9 DNS)'); - o.value('9.9.9.11', '9.9.9.11 (Quad9 DNS)'); + Object.entries(main.BOOTSTRAP_DNS_SERVER_OPTIONS).forEach(([key, label]) => { + o.value(key, _(label)); + }); o.default = '77.88.8.8'; o.rmempty = false; o.ucisection = 'main'; diff --git a/luci-app-podkop/htdocs/luci-static/resources/view/podkop/main.js b/luci-app-podkop/htdocs/luci-static/resources/view/podkop/main.js index 36bfda0..de2c75a 100644 --- a/luci-app-podkop/htdocs/luci-static/resources/view/podkop/main.js +++ b/luci-app-podkop/htdocs/luci-static/resources/view/podkop/main.js @@ -148,6 +148,16 @@ var DNS_SERVER_OPTIONS = { "unfiltered.adguard-dns.com": "unfiltered.adguard-dns.com (AdGuard Unfiltered)", "family.adguard-dns.com": "family.adguard-dns.com (AdGuard Family)" }; +var BOOTSTRAP_DNS_SERVER_OPTIONS = { + "77.88.8.8": "77.88.8.8 (Yandex DNS)", + "77.88.8.1": "77.88.8.1 (Yandex DNS)", + "1.1.1.1": "1.1.1.1 (Cloudflare DNS)", + "1.0.0.1": "1.0.0.1 (Cloudflare DNS)", + "8.8.8.8": "8.8.8.8 (Google DNS)", + "8.8.4.4": "8.8.4.4 (Google DNS)", + "9.9.9.9": "9.9.9.9 (Quad9 DNS)", + "9.9.9.11": "9.9.9.11 (Quad9 DNS)" +}; var DIAGNOSTICS_UPDATE_INTERVAL = 1e4; var CACHE_TIMEOUT = DIAGNOSTICS_UPDATE_INTERVAL - 1e3; var ERROR_POLL_INTERVAL = 1e4; @@ -181,6 +191,7 @@ var COMMAND_SCHEDULING = { }; return baseclass.extend({ ALLOWED_WITH_RUSSIA_INSIDE, + BOOTSTRAP_DNS_SERVER_OPTIONS, BUTTON_FEEDBACK_TIMEOUT, CACHE_TIMEOUT, COMMAND_SCHEDULING, From 3988588c9fc43f804122aa680ec26a6e6211170f Mon Sep 17 00:00:00 2001 From: divocat Date: Thu, 2 Oct 2025 23:25:14 +0300 Subject: [PATCH 07/49] feat: migrate yacd url to dynamic --- fe-app-podkop/src/helpers/getBaseUrl.ts | 4 ++++ fe-app-podkop/src/helpers/index.ts | 1 + fe-app-podkop/src/main.ts | 1 + .../luci-static/resources/view/podkop/additionalTab.js | 2 +- .../htdocs/luci-static/resources/view/podkop/main.js | 7 +++++++ 5 files changed, 14 insertions(+), 1 deletion(-) create mode 100644 fe-app-podkop/src/helpers/getBaseUrl.ts create mode 100644 fe-app-podkop/src/helpers/index.ts diff --git a/fe-app-podkop/src/helpers/getBaseUrl.ts b/fe-app-podkop/src/helpers/getBaseUrl.ts new file mode 100644 index 0000000..88b82ff --- /dev/null +++ b/fe-app-podkop/src/helpers/getBaseUrl.ts @@ -0,0 +1,4 @@ +export function getBaseUrl(): string { + const { protocol, hostname } = window.location; + return `${protocol}//${hostname}`; +} diff --git a/fe-app-podkop/src/helpers/index.ts b/fe-app-podkop/src/helpers/index.ts new file mode 100644 index 0000000..b3fe842 --- /dev/null +++ b/fe-app-podkop/src/helpers/index.ts @@ -0,0 +1 @@ +export * from './getBaseUrl'; diff --git a/fe-app-podkop/src/main.ts b/fe-app-podkop/src/main.ts index fad5419..5a2e1ba 100644 --- a/fe-app-podkop/src/main.ts +++ b/fe-app-podkop/src/main.ts @@ -3,3 +3,4 @@ export * from './validators'; export * from './constants'; +export * from './helpers'; diff --git a/luci-app-podkop/htdocs/luci-static/resources/view/podkop/additionalTab.js b/luci-app-podkop/htdocs/luci-static/resources/view/podkop/additionalTab.js index d16b68b..aa07a8a 100644 --- a/luci-app-podkop/htdocs/luci-static/resources/view/podkop/additionalTab.js +++ b/luci-app-podkop/htdocs/luci-static/resources/view/podkop/additionalTab.js @@ -7,7 +7,7 @@ function createAdditionalSection(mainSection) { let o = mainSection.tab('additional', _('Additional Settings')); - o = mainSection.taboption('additional', form.Flag, 'yacd', _('Yacd enable'), 'openwrt.lan:9090/ui'); + o = mainSection.taboption('additional', form.Flag, 'yacd', _('Yacd enable'), `${main.getBaseUrl()}:9090/ui`); o.default = '0'; o.rmempty = false; o.ucisection = 'main'; diff --git a/luci-app-podkop/htdocs/luci-static/resources/view/podkop/main.js b/luci-app-podkop/htdocs/luci-static/resources/view/podkop/main.js index de2c75a..6e7f3e7 100644 --- a/luci-app-podkop/htdocs/luci-static/resources/view/podkop/main.js +++ b/luci-app-podkop/htdocs/luci-static/resources/view/podkop/main.js @@ -189,6 +189,12 @@ var COMMAND_SCHEDULING = { P10_PRIORITY: 1900 // Lowest priority }; + +// src/helpers/getBaseUrl.ts +function getBaseUrl() { + const { protocol, hostname } = window.location; + return `${protocol}//${hostname}`; +} return baseclass.extend({ ALLOWED_WITH_RUSSIA_INSIDE, BOOTSTRAP_DNS_SERVER_OPTIONS, @@ -207,6 +213,7 @@ return baseclass.extend({ REGIONAL_OPTIONS, STATUS_COLORS, UPDATE_INTERVAL_OPTIONS, + getBaseUrl, validateDNS, validateDomain, validateIPV4, From b71c7b379d423a4e3ccea1400d723fbcf2e7e110 Mon Sep 17 00:00:00 2001 From: divocat Date: Thu, 2 Oct 2025 23:30:28 +0300 Subject: [PATCH 08/49] refactor: change Source Network Interface filter logic --- .../resources/view/podkop/additionalTab.js | 25 +++++++++++++------ 1 file changed, 17 insertions(+), 8 deletions(-) diff --git a/luci-app-podkop/htdocs/luci-static/resources/view/podkop/additionalTab.js b/luci-app-podkop/htdocs/luci-static/resources/view/podkop/additionalTab.js index aa07a8a..eca4b4d 100644 --- a/luci-app-podkop/htdocs/luci-static/resources/view/podkop/additionalTab.js +++ b/luci-app-podkop/htdocs/luci-static/resources/view/podkop/additionalTab.js @@ -131,20 +131,29 @@ function createAdditionalSection(mainSection) { o.noinactive = false; o.multiple = true; o.filter = function (section_id, value) { - if (['wan', 'phy0-ap0', 'phy1-ap0', 'pppoe-wan'].indexOf(value) !== -1) { + // Block specific interface names from being selectable + const blocked = ['wan', 'phy0-ap0', 'phy1-ap0', 'pppoe-wan']; + if (blocked.includes(value)) { return false; } - var device = this.devices.filter(function (dev) { - return dev.getName() === value; - })[0]; + // Try to find the device object by its name + const device = this.devices.find(dev => dev.getName() === value); - if (device) { - var type = device.getType(); - return type !== 'wifi' && type !== 'wireless' && !type.includes('wlan'); + // If no device is found, allow the value + if (!device) { + return true; } - return true; + // Check the type of the device + const type = device.getType(); + + // Consider any Wi-Fi / wireless / wlan device as invalid + const isWireless = + type === 'wifi' || type === 'wireless' || type.includes('wlan'); + + // Allow only non-wireless devices + return !isWireless; }; o = mainSection.taboption('additional', form.Flag, 'mon_restart_ifaces', _('Interface monitoring'), _('Interface monitoring for bad WAN')); From e84233a10c596af5a70a32099cd7ee0dcc80fa2f Mon Sep 17 00:00:00 2001 From: divocat Date: Thu, 2 Oct 2025 23:32:49 +0300 Subject: [PATCH 09/49] refactor: change Interface for monitoring filter logic --- .../resources/view/podkop/additionalTab.js | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/luci-app-podkop/htdocs/luci-static/resources/view/podkop/additionalTab.js b/luci-app-podkop/htdocs/luci-static/resources/view/podkop/additionalTab.js index eca4b4d..501de00 100644 --- a/luci-app-podkop/htdocs/luci-static/resources/view/podkop/additionalTab.js +++ b/luci-app-podkop/htdocs/luci-static/resources/view/podkop/additionalTab.js @@ -166,7 +166,18 @@ function createAdditionalSection(mainSection) { o.depends('mon_restart_ifaces', '1'); o.multiple = true; o.filter = function (section_id, value) { - return ['lan', 'loopback'].indexOf(value) === -1 && !value.startsWith('@'); + // Reject if the value is in the blocked list ['lan', 'loopback'] + if (['lan', 'loopback'].includes(value)) { + return false; + } + + // Reject if the value starts with '@' (means it's an alias/reference) + if (value.startsWith('@')) { + return false; + } + + // Otherwise allow it + return true; }; o = mainSection.taboption('additional', form.Value, 'procd_reload_delay', _('Interface Monitoring Delay'), _('Delay in milliseconds before reloading podkop after interface UP')); From cfc5d995a8cba5969c7a8bd988ddbacebd47a3dc Mon Sep 17 00:00:00 2001 From: divocat Date: Thu, 2 Oct 2025 23:35:43 +0300 Subject: [PATCH 10/49] refactor: change Network Interface filter logic --- .../resources/view/podkop/configSection.js | 35 ++++++++++++++----- 1 file changed, 27 insertions(+), 8 deletions(-) diff --git a/luci-app-podkop/htdocs/luci-static/resources/view/podkop/configSection.js b/luci-app-podkop/htdocs/luci-static/resources/view/podkop/configSection.js index 3fe88c7..ec1c8be 100644 --- a/luci-app-podkop/htdocs/luci-static/resources/view/podkop/configSection.js +++ b/luci-app-podkop/htdocs/luci-static/resources/view/podkop/configSection.js @@ -213,20 +213,39 @@ function createConfigSection(section, map, network) { o.nobridges = false; o.noinactive = false; o.filter = function (section_id, value) { - if (['br-lan', 'eth0', 'eth1', 'wan', 'phy0-ap0', 'phy1-ap0', 'pppoe-wan', 'lan'].indexOf(value) !== -1) { + // Blocked interface names that should never be selectable + const blockedInterfaces = [ + 'br-lan', + 'eth0', + 'eth1', + 'wan', + 'phy0-ap0', + 'phy1-ap0', + 'pppoe-wan', + 'lan', + ]; + + // Reject immediately if the value matches any blocked interface + if (blockedInterfaces.includes(value)) { return false; } - var device = this.devices.filter(function (dev) { - return dev.getName() === value; - })[0]; + // Try to find the device object with the given name + const device = this.devices.find(dev => dev.getName() === value); - if (device) { - var type = device.getType(); - return type !== 'wifi' && type !== 'wireless' && !type.includes('wlan'); + // If no device is found, allow the value + if (!device) { + return true; } - return true; + // Get the device type (e.g., "wifi", "ethernet", etc.) + const type = device.getType(); + + // Reject wireless-related devices + const isWireless = + type === 'wifi' || type === 'wireless' || type.includes('wlan'); + + return !isWireless; }; o = s.taboption('basic', form.Flag, 'domain_resolver_enabled', _('Domain Resolver'), _('Enable built-in DNS resolver for domains handled by this section')); From 77e141b305d609e64fafc9526cdf7ae0cc1d1e49 Mon Sep 17 00:00:00 2001 From: divocat Date: Fri, 3 Oct 2025 00:29:58 +0300 Subject: [PATCH 11/49] feat: add soft wrap to Proxy Configuration URL textarea --- .../htdocs/luci-static/resources/view/podkop/configSection.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/luci-app-podkop/htdocs/luci-static/resources/view/podkop/configSection.js b/luci-app-podkop/htdocs/luci-static/resources/view/podkop/configSection.js index ec1c8be..b5a256b 100644 --- a/luci-app-podkop/htdocs/luci-static/resources/view/podkop/configSection.js +++ b/luci-app-podkop/htdocs/luci-static/resources/view/podkop/configSection.js @@ -29,6 +29,8 @@ function createConfigSection(section, map, network) { o = s.taboption('basic', form.TextValue, 'proxy_string', _('Proxy Configuration URL'), ''); o.depends('proxy_config_type', 'url'); o.rows = 5; + o.wrap = 'soft'; + o.textarea = true; o.rmempty = false; o.ucisection = s.section; o.sectionDescriptions = new Map(); From 547feb0e060b54b2b90673b4a72812b6f230b3cb Mon Sep 17 00:00:00 2001 From: divocat Date: Fri, 3 Oct 2025 00:59:24 +0300 Subject: [PATCH 12/49] feat: implement validateSubnet --- fe-app-podkop/src/validators/index.ts | 1 + .../validators/tests/validateSubnet.test.js | 41 +++++++++++++++++++ .../src/validators/validateSubnet.ts | 39 ++++++++++++++++++ .../resources/view/podkop/configSection.js | 25 +++++------ .../luci-static/resources/view/podkop/main.js | 30 ++++++++++++++ 5 files changed, 121 insertions(+), 15 deletions(-) create mode 100644 fe-app-podkop/src/validators/tests/validateSubnet.test.js create mode 100644 fe-app-podkop/src/validators/validateSubnet.ts diff --git a/fe-app-podkop/src/validators/index.ts b/fe-app-podkop/src/validators/index.ts index 5d0851d..b7ee5af 100644 --- a/fe-app-podkop/src/validators/index.ts +++ b/fe-app-podkop/src/validators/index.ts @@ -3,3 +3,4 @@ export * from './validateDomain'; export * from './validateDns'; export * from './validateUrl'; export * from './validatePath'; +export * from './validateSubnet'; diff --git a/fe-app-podkop/src/validators/tests/validateSubnet.test.js b/fe-app-podkop/src/validators/tests/validateSubnet.test.js new file mode 100644 index 0000000..13621b7 --- /dev/null +++ b/fe-app-podkop/src/validators/tests/validateSubnet.test.js @@ -0,0 +1,41 @@ +import { describe, it, expect } from 'vitest'; +import { validateSubnet } from '../validateSubnet'; + +export const validSubnets = [ + ['Simple IP', '192.168.1.1'], + ['With CIDR /24', '192.168.1.1/24'], + ['CIDR /0', '10.0.0.1/0'], + ['CIDR /32', '172.16.0.1/32'], + ['Loopback', '127.0.0.1'], + ['Broadcast with mask', '255.255.255.255/32'], +]; + +export const invalidSubnets = [ + ['Empty string', ''], + ['Bad format letters', 'abc.def.ghi.jkl'], + ['Octet too large', '300.1.1.1'], + ['Negative octet', '-1.2.3.4'], + ['Too many octets', '1.2.3.4.5'], + ['Not enough octets', '192.168.1'], + ['Leading zero octet', '01.2.3.4'], + ['Invalid CIDR (too high)', '192.168.1.1/33'], + ['Invalid CIDR (negative)', '192.168.1.1/-1'], + ['CIDR not number', '192.168.1.1/abc'], + ['Forbidden 0.0.0.0', '0.0.0.0'], +]; + +describe('validateSubnet', () => { + describe.each(validSubnets)('Valid subnet: %s', (_desc, subnet) => { + it(`returns {valid:true} for "${subnet}"`, () => { + const res = validateSubnet(subnet); + expect(res.valid).toBe(true); + }); + }); + + describe.each(invalidSubnets)('Invalid subnet: %s', (_desc, subnet) => { + it(`returns {valid:false} for "${subnet}"`, () => { + const res = validateSubnet(subnet); + expect(res.valid).toBe(false); + }); + }); +}); diff --git a/fe-app-podkop/src/validators/validateSubnet.ts b/fe-app-podkop/src/validators/validateSubnet.ts new file mode 100644 index 0000000..f0022c0 --- /dev/null +++ b/fe-app-podkop/src/validators/validateSubnet.ts @@ -0,0 +1,39 @@ +import { ValidationResult } from './types.js'; +import { validateIPV4 } from './validateIp'; + +export function validateSubnet(value: string): ValidationResult { + // Must be in form X.X.X.X or X.X.X.X/Y + const subnetRegex = /^(\d{1,3}\.){3}\d{1,3}(?:\/\d{1,2})?$/; + + if (!subnetRegex.test(value)) { + return { + valid: false, + message: 'Invalid format. Use X.X.X.X or X.X.X.X/Y', + }; + } + + const [ip, cidr] = value.split('/'); + + if (ip === '0.0.0.0') { + return { valid: false, message: 'IP address 0.0.0.0 is not allowed' }; + } + + const ipCheck = validateIPV4(ip); + if (!ipCheck.valid) { + return ipCheck; + } + + // Validate CIDR if present + if (cidr) { + const cidrNum = parseInt(cidr, 10); + + if (cidrNum < 0 || cidrNum > 32) { + return { + valid: false, + message: 'CIDR must be between 0 and 32', + }; + } + } + + return { valid: true, message: 'Valid' }; +} diff --git a/luci-app-podkop/htdocs/luci-static/resources/view/podkop/configSection.js b/luci-app-podkop/htdocs/luci-static/resources/view/podkop/configSection.js index b5a256b..12e7e65 100644 --- a/luci-app-podkop/htdocs/luci-static/resources/view/podkop/configSection.js +++ b/luci-app-podkop/htdocs/luci-static/resources/view/podkop/configSection.js @@ -508,23 +508,18 @@ function createConfigSection(section, map, network) { o.rmempty = false; o.ucisection = s.section; o.validate = function (section_id, value) { - if (!value || value.length === 0) return true; - const subnetRegex = /^(\d{1,3}\.){3}\d{1,3}(\/\d{1,2})?$/; - if (!subnetRegex.test(value)) return _('Invalid format. Use format: X.X.X.X or X.X.X.X/Y'); - const [ip, cidr] = value.split('/'); - if (ip === "0.0.0.0") { - return _('IP address 0.0.0.0 is not allowed'); + // Optional + if (!value || value.length === 0) { + return true } - const ipParts = ip.split('.'); - for (const part of ipParts) { - const num = parseInt(part); - if (num < 0 || num > 255) return _('IP address parts must be between 0 and 255'); + + const validation = main.validateSubnet(value); + + if (validation.valid) { + return true; } - if (cidr !== undefined) { - const cidrNum = parseInt(cidr); - if (cidrNum < 0 || cidrNum > 32) return _('CIDR must be between 0 and 32'); - } - return true; + + return _(validation.message) }; o = s.taboption('basic', form.TextValue, 'user_subnets_text', _('User Subnets List'), _('Enter subnets in CIDR notation or single IP addresses, separated by comma, space or newline. You can add comments after //')); diff --git a/luci-app-podkop/htdocs/luci-static/resources/view/podkop/main.js b/luci-app-podkop/htdocs/luci-static/resources/view/podkop/main.js index 6e7f3e7..18912a3 100644 --- a/luci-app-podkop/htdocs/luci-static/resources/view/podkop/main.js +++ b/luci-app-podkop/htdocs/luci-static/resources/view/podkop/main.js @@ -80,6 +80,35 @@ function validatePath(value) { }; } +// src/validators/validateSubnet.ts +function validateSubnet(value) { + const subnetRegex = /^(\d{1,3}\.){3}\d{1,3}(?:\/\d{1,2})?$/; + if (!subnetRegex.test(value)) { + return { + valid: false, + message: "Invalid format. Use X.X.X.X or X.X.X.X/Y" + }; + } + const [ip, cidr] = value.split("/"); + if (ip === "0.0.0.0") { + return { valid: false, message: "IP address 0.0.0.0 is not allowed" }; + } + const ipCheck = validateIPV4(ip); + if (!ipCheck.valid) { + return ipCheck; + } + if (cidr) { + const cidrNum = parseInt(cidr, 10); + if (cidrNum < 0 || cidrNum > 32) { + return { + valid: false, + message: "CIDR must be between 0 and 32" + }; + } + } + return { valid: true, message: "Valid" }; +} + // src/constants.ts var STATUS_COLORS = { SUCCESS: "#4caf50", @@ -218,5 +247,6 @@ return baseclass.extend({ validateDomain, validateIPV4, validatePath, + validateSubnet, validateUrl }); From df9dba9742a84dfe06cdfe03e64684c2b999303b Mon Sep 17 00:00:00 2001 From: divocat Date: Fri, 3 Oct 2025 01:53:03 +0300 Subject: [PATCH 13/49] feat: implement bulk validate --- fe-app-podkop/src/validators/bulkValidate.ts | 13 +++++++++++++ fe-app-podkop/src/validators/index.ts | 1 + fe-app-podkop/src/validators/types.ts | 9 +++++++++ .../luci-static/resources/view/podkop/main.js | 10 ++++++++++ 4 files changed, 33 insertions(+) create mode 100644 fe-app-podkop/src/validators/bulkValidate.ts diff --git a/fe-app-podkop/src/validators/bulkValidate.ts b/fe-app-podkop/src/validators/bulkValidate.ts new file mode 100644 index 0000000..fce12c2 --- /dev/null +++ b/fe-app-podkop/src/validators/bulkValidate.ts @@ -0,0 +1,13 @@ +import { BulkValidationResult, ValidationResult } from './types'; + +export function bulkValidate( + values: T[], + validate: (value: T) => ValidationResult, +): BulkValidationResult { + const results = values.map((value) => ({ ...validate(value), value })); + + return { + valid: results.every((r) => r.valid), + results, + }; +} diff --git a/fe-app-podkop/src/validators/index.ts b/fe-app-podkop/src/validators/index.ts index b7ee5af..291795e 100644 --- a/fe-app-podkop/src/validators/index.ts +++ b/fe-app-podkop/src/validators/index.ts @@ -4,3 +4,4 @@ export * from './validateDns'; export * from './validateUrl'; export * from './validatePath'; export * from './validateSubnet'; +export * from './bulkValidate'; diff --git a/fe-app-podkop/src/validators/types.ts b/fe-app-podkop/src/validators/types.ts index c2308c6..b9263b3 100644 --- a/fe-app-podkop/src/validators/types.ts +++ b/fe-app-podkop/src/validators/types.ts @@ -2,3 +2,12 @@ export interface ValidationResult { valid: boolean; message: string; } + +export interface BulkValidationResultItem extends ValidationResult { + value: T; +} + +export interface BulkValidationResult { + valid: boolean; + results: BulkValidationResultItem[]; +} diff --git a/luci-app-podkop/htdocs/luci-static/resources/view/podkop/main.js b/luci-app-podkop/htdocs/luci-static/resources/view/podkop/main.js index 18912a3..1e5db3c 100644 --- a/luci-app-podkop/htdocs/luci-static/resources/view/podkop/main.js +++ b/luci-app-podkop/htdocs/luci-static/resources/view/podkop/main.js @@ -109,6 +109,15 @@ function validateSubnet(value) { return { valid: true, message: "Valid" }; } +// src/validators/bulkValidate.ts +function bulkValidate(values, validate) { + const results = values.map((value) => ({ ...validate(value), value })); + return { + valid: results.every((r) => r.valid), + results + }; +} + // src/constants.ts var STATUS_COLORS = { SUCCESS: "#4caf50", @@ -242,6 +251,7 @@ return baseclass.extend({ REGIONAL_OPTIONS, STATUS_COLORS, UPDATE_INTERVAL_OPTIONS, + bulkValidate, getBaseUrl, validateDNS, validateDomain, From 260b7b95586353b7fab09ae42fb72294cf1fd89f Mon Sep 17 00:00:00 2001 From: divocat Date: Fri, 3 Oct 2025 01:54:13 +0300 Subject: [PATCH 14/49] refactor: migrate User Subnets List validation to modular --- .../resources/view/podkop/configSection.js | 68 +++++++------------ 1 file changed, 25 insertions(+), 43 deletions(-) diff --git a/luci-app-podkop/htdocs/luci-static/resources/view/podkop/configSection.js b/luci-app-podkop/htdocs/luci-static/resources/view/podkop/configSection.js index 12e7e65..33f53a2 100644 --- a/luci-app-podkop/htdocs/luci-static/resources/view/podkop/configSection.js +++ b/luci-app-podkop/htdocs/luci-static/resources/view/podkop/configSection.js @@ -529,51 +529,33 @@ function createConfigSection(section, map, network) { o.rmempty = false; o.ucisection = s.section; o.validate = function (section_id, value) { - if (!value || value.length === 0) return true; - - const subnetRegex = /^(\d{1,3}\.){3}\d{1,3}(\/\d{1,2})?$/; - const lines = value.split(/\n/).map(line => line.trim()); - let hasValidSubnet = false; - - for (const line of lines) { - // Skip empty lines - if (!line) continue; - - // Extract subnet part (before any //) - const subnetPart = line.split('//')[0].trim(); - - // Skip if line is empty after removing comments - if (!subnetPart) continue; - - // Process each subnet in the line (separated by comma or space) - const subnets = subnetPart.split(/[,\s]+/).map(s => s.trim()).filter(s => s.length > 0); - - for (const subnet of subnets) { - if (!subnetRegex.test(subnet)) { - return _('Invalid format: %s. Use format: X.X.X.X or X.X.X.X/Y').format(subnet); - } - - const [ip, cidr] = subnet.split('/'); - const ipParts = ip.split('.'); - for (const part of ipParts) { - const num = parseInt(part); - if (num < 0 || num > 255) { - return _('IP parts must be between 0 and 255 in: %s').format(subnet); - } - } - - if (cidr !== undefined) { - const cidrNum = parseInt(cidr); - if (cidrNum < 0 || cidrNum > 32) { - return _('CIDR must be between 0 and 32 in: %s').format(subnet); - } - } - hasValidSubnet = true; - } + // Optional + if (!value || value.length === 0) { + return true } - if (!hasValidSubnet) { - return _('At least one valid subnet or IP must be specified. Comments-only content is not allowed.'); + const subnets = value + .split(/\n/) // Split to array by newline separator + .map(line => line.split('//')[0]) // Remove comments + .join(' ') // Build clean string + .split(/[,\s]+/) // Split to subnets array by comma and space + .map(s => s.trim()) // Remove extra spaces + .filter(Boolean); + + if (!subnets.length) { + return _( + 'At least one valid subnet or IP must be specified. Comments-only content is not allowed.' + ); + } + + const { valid, results } = main.bulkValidate(subnets, main.validateSubnet); + + if (!valid) { + const errors = results + .filter(subnetValidation => !subnetValidation.valid) // Leave only failed validations + .map((subnetValidation) => _(`${subnetValidation.value}: ${subnetValidation.message}`)) // Collect validation errors + + return [_('Validation errors:'), ...errors].join('\n'); } return true; From 327c3d2b68052175376b263ea0ba4fa253540d16 Mon Sep 17 00:00:00 2001 From: divocat Date: Fri, 3 Oct 2025 02:01:06 +0300 Subject: [PATCH 15/49] feat: implement parseValueList helper --- fe-app-podkop/src/helpers/index.ts | 1 + fe-app-podkop/src/helpers/parseValueList.ts | 9 +++++++++ .../htdocs/luci-static/resources/view/podkop/main.js | 6 ++++++ 3 files changed, 16 insertions(+) create mode 100644 fe-app-podkop/src/helpers/parseValueList.ts diff --git a/fe-app-podkop/src/helpers/index.ts b/fe-app-podkop/src/helpers/index.ts index b3fe842..053e45e 100644 --- a/fe-app-podkop/src/helpers/index.ts +++ b/fe-app-podkop/src/helpers/index.ts @@ -1 +1,2 @@ export * from './getBaseUrl'; +export * from './parseValueList'; diff --git a/fe-app-podkop/src/helpers/parseValueList.ts b/fe-app-podkop/src/helpers/parseValueList.ts new file mode 100644 index 0000000..78d0b93 --- /dev/null +++ b/fe-app-podkop/src/helpers/parseValueList.ts @@ -0,0 +1,9 @@ +export function parseValueList(value: string): string[] { + return value + .split(/\n/) // Split to array by newline separator + .map((line) => line.split('//')[0]) // Remove comments + .join(' ') // Build clean string + .split(/[,\s]+/) // Split to array by comma and space + .map((s) => s.trim()) // Remove extra spaces + .filter(Boolean); // Leave nonempty items +} diff --git a/luci-app-podkop/htdocs/luci-static/resources/view/podkop/main.js b/luci-app-podkop/htdocs/luci-static/resources/view/podkop/main.js index 1e5db3c..3586973 100644 --- a/luci-app-podkop/htdocs/luci-static/resources/view/podkop/main.js +++ b/luci-app-podkop/htdocs/luci-static/resources/view/podkop/main.js @@ -233,6 +233,11 @@ function getBaseUrl() { const { protocol, hostname } = window.location; return `${protocol}//${hostname}`; } + +// src/helpers/parseValueList.ts +function parseValueList(value) { + return value.split(/\n/).map((line) => line.split("//")[0]).join(" ").split(/[,\s]+/).map((s) => s.trim()).filter(Boolean); +} return baseclass.extend({ ALLOWED_WITH_RUSSIA_INSIDE, BOOTSTRAP_DNS_SERVER_OPTIONS, @@ -253,6 +258,7 @@ return baseclass.extend({ UPDATE_INTERVAL_OPTIONS, bulkValidate, getBaseUrl, + parseValueList, validateDNS, validateDomain, validateIPV4, From 8f19f31e7a0cb159685fcdc87eab0dfd4b24ea8a Mon Sep 17 00:00:00 2001 From: divocat Date: Fri, 3 Oct 2025 03:00:50 +0300 Subject: [PATCH 16/49] refactor: migrate User Domains List validation to modular --- .../resources/view/podkop/configSection.js | 58 +++++++------------ 1 file changed, 22 insertions(+), 36 deletions(-) diff --git a/luci-app-podkop/htdocs/luci-static/resources/view/podkop/configSection.js b/luci-app-podkop/htdocs/luci-static/resources/view/podkop/configSection.js index 33f53a2..43c5f10 100644 --- a/luci-app-podkop/htdocs/luci-static/resources/view/podkop/configSection.js +++ b/luci-app-podkop/htdocs/luci-static/resources/view/podkop/configSection.js @@ -385,35 +385,27 @@ function createConfigSection(section, map, network) { o.rmempty = false; o.ucisection = s.section; o.validate = function (section_id, value) { - if (!value || value.length === 0) return true; - - const domainRegex = /^(?!-)[A-Za-z0-9-]+([-.][A-Za-z0-9-]+)*(\.[A-Za-z]{2,})?$/; - const lines = value.split(/\n/).map(line => line.trim()); - let hasValidDomain = false; - - for (const line of lines) { - // Skip empty lines - if (!line) continue; - - // Extract domain part (before any //) - const domainPart = line.split('//')[0].trim(); - - // Skip if line is empty after removing comments - if (!domainPart) continue; - - // Process each domain in the line (separated by comma or space) - const domains = domainPart.split(/[,\s]+/).map(d => d.trim()).filter(d => d.length > 0); - - for (const domain of domains) { - if (!domainRegex.test(domain)) { - return _('Invalid domain format: %s. Enter domain without protocol').format(domain); - } - hasValidDomain = true; - } + // Optional + if (!value || value.length === 0) { + return true } - if (!hasValidDomain) { - return _('At least one valid domain must be specified. Comments-only content is not allowed.'); + const subnets = main.parseValueList(value); + + if (!subnets.length) { + return _( + 'At least one valid domain must be specified. Comments-only content is not allowed.' + ); + } + + const { valid, results } = main.bulkValidate(subnets, main.validateDomain); + + if (!valid) { + const errors = results + .filter(validation => !validation.valid) // Leave only failed validations + .map((validation) => _(`${validation.value}: ${validation.message}`)) // Collect validation errors + + return [_('Validation errors:'), ...errors].join('\n'); } return true; @@ -534,13 +526,7 @@ function createConfigSection(section, map, network) { return true } - const subnets = value - .split(/\n/) // Split to array by newline separator - .map(line => line.split('//')[0]) // Remove comments - .join(' ') // Build clean string - .split(/[,\s]+/) // Split to subnets array by comma and space - .map(s => s.trim()) // Remove extra spaces - .filter(Boolean); + const subnets = main.parseValueList(value); if (!subnets.length) { return _( @@ -552,8 +538,8 @@ function createConfigSection(section, map, network) { if (!valid) { const errors = results - .filter(subnetValidation => !subnetValidation.valid) // Leave only failed validations - .map((subnetValidation) => _(`${subnetValidation.value}: ${subnetValidation.message}`)) // Collect validation errors + .filter(validation => !validation.valid) // Leave only failed validations + .map((validation) => _(`${validation.value}: ${validation.message}`)) // Collect validation errors return [_('Validation errors:'), ...errors].join('\n'); } From b99116fbf3c4710674060bafb6bfb8a4144035b5 Mon Sep 17 00:00:00 2001 From: divocat Date: Fri, 3 Oct 2025 03:20:40 +0300 Subject: [PATCH 17/49] feat: implement ss/vless validations --- fe-app-podkop/src/main.ts | 2 +- fe-app-podkop/src/validators/index.ts | 2 + .../src/validators/validateShadowsocksUrl.ts | 81 ++++++++++++++ .../src/validators/validateVlessUrl.ts | 102 ++++++++++++++++++ 4 files changed, 186 insertions(+), 1 deletion(-) create mode 100644 fe-app-podkop/src/validators/validateShadowsocksUrl.ts create mode 100644 fe-app-podkop/src/validators/validateVlessUrl.ts diff --git a/fe-app-podkop/src/main.ts b/fe-app-podkop/src/main.ts index 5a2e1ba..138d3fc 100644 --- a/fe-app-podkop/src/main.ts +++ b/fe-app-podkop/src/main.ts @@ -2,5 +2,5 @@ 'require baseclass'; export * from './validators'; -export * from './constants'; export * from './helpers'; +export * from './constants'; diff --git a/fe-app-podkop/src/validators/index.ts b/fe-app-podkop/src/validators/index.ts index 291795e..bedc361 100644 --- a/fe-app-podkop/src/validators/index.ts +++ b/fe-app-podkop/src/validators/index.ts @@ -5,3 +5,5 @@ export * from './validateUrl'; export * from './validatePath'; export * from './validateSubnet'; export * from './bulkValidate'; +export * from './validateShadowsocksUrl'; +export * from './validateVlessUrl'; diff --git a/fe-app-podkop/src/validators/validateShadowsocksUrl.ts b/fe-app-podkop/src/validators/validateShadowsocksUrl.ts new file mode 100644 index 0000000..8c2adb1 --- /dev/null +++ b/fe-app-podkop/src/validators/validateShadowsocksUrl.ts @@ -0,0 +1,81 @@ +import { ValidationResult } from './types.js'; + +// TODO refactor current validation and add tests +export function validateShadowsocksUrl(url: string): ValidationResult { + if (!url.startsWith('ss://')) { + return { + valid: false, + message: 'Invalid Shadowsocks URL: must start with ss://', + }; + } + + try { + const mainPart = url.includes('?') ? url.split('?')[0] : url.split('#')[0]; + + const encryptedPart = mainPart.split('/')[2]?.split('@')[0]; + + if (!encryptedPart) { + return { + valid: false, + message: 'Invalid Shadowsocks URL: missing credentials', + }; + } + + try { + const decoded = atob(encryptedPart); + + if (!decoded.includes(':')) { + return { + valid: false, + message: + 'Invalid Shadowsocks URL: decoded credentials must contain method:password', + }; + } + } catch (e) { + if (!encryptedPart.includes(':') && !encryptedPart.includes('-')) { + return { + valid: false, + message: + 'Invalid Shadowsocks URL: missing method and password separator ":"', + }; + } + } + + const serverPart = url.split('@')[1]; + + if (!serverPart) { + return { + valid: false, + message: 'Invalid Shadowsocks URL: missing server address', + }; + } + + const [server, portAndRest] = serverPart.split(':'); + + if (!server) { + return { + valid: false, + message: 'Invalid Shadowsocks URL: missing server', + }; + } + + const port = portAndRest ? portAndRest.split(/[?#]/)[0] : null; + + if (!port) { + return { valid: false, message: 'Invalid Shadowsocks URL: missing port' }; + } + + const portNum = parseInt(port, 10); + + if (isNaN(portNum) || portNum < 1 || portNum > 65535) { + return { + valid: false, + message: 'Invalid port number. Must be between 1 and 65535', + }; + } + } catch (e) { + return { valid: false, message: 'Invalid Shadowsocks URL: parsing failed' }; + } + + return { valid: true, message: 'Valid' }; +} diff --git a/fe-app-podkop/src/validators/validateVlessUrl.ts b/fe-app-podkop/src/validators/validateVlessUrl.ts new file mode 100644 index 0000000..56aeb09 --- /dev/null +++ b/fe-app-podkop/src/validators/validateVlessUrl.ts @@ -0,0 +1,102 @@ +import { ValidationResult } from './types'; + +// TODO refactor current validation and add tests +export function validateVlessUrl(url: string): ValidationResult { + if (!url.startsWith('vless://')) { + return { + valid: false, + message: 'Invalid VLESS URL: must start with vless://', + }; + } + + try { + const uuid = url.split('/')[2]?.split('@')[0]; + + if (!uuid) { + return { valid: false, message: 'Invalid VLESS URL: missing UUID' }; + } + + const serverPart = url.split('@')[1]; + + if (!serverPart) { + return { + valid: false, + message: 'Invalid VLESS URL: missing server address', + }; + } + + const [server, portAndRest] = serverPart.split(':'); + + if (!server) { + return { valid: false, message: 'Invalid VLESS URL: missing server' }; + } + + const port = portAndRest ? portAndRest.split(/[/?#]/)[0] : null; + + if (!port) { + return { valid: false, message: 'Invalid VLESS URL: missing port' }; + } + + const portNum = parseInt(port, 10); + + if (isNaN(portNum) || portNum < 1 || portNum > 65535) { + return { + valid: false, + message: 'Invalid port number. Must be between 1 and 65535', + }; + } + + const queryString = url.split('?')[1]; + + if (!queryString) { + return { + valid: false, + message: 'Invalid VLESS URL: missing query parameters', + }; + } + + const params = new URLSearchParams(queryString.split('#')[0]); + const type = params.get('type'); + const validTypes = ['tcp', 'raw', 'udp', 'grpc', 'http', 'ws']; + + if (!type || !validTypes.includes(type)) { + return { + valid: false, + message: + 'Invalid VLESS URL: type must be one of tcp, raw, udp, grpc, http, ws', + }; + } + + const security = params.get('security'); + const validSecurities = ['tls', 'reality', 'none']; + + if (!security || !validSecurities.includes(security)) { + return { + valid: false, + message: + 'Invalid VLESS URL: security must be one of tls, reality, none', + }; + } + + if (security === 'reality') { + if (!params.get('pbk')) { + return { + valid: false, + message: + 'Invalid VLESS URL: missing pbk parameter for reality security', + }; + } + if (!params.get('fp')) { + return { + valid: false, + message: + 'Invalid VLESS URL: missing fp parameter for reality security', + }; + } + } + } catch (e) { + return { valid: false, message: 'Invalid VLESS URL: parsing failed' }; + } + + return { valid: true, message: 'Valid' }; +} From 65d3a9253ffa76306942ca511d6a29278304c79c Mon Sep 17 00:00:00 2001 From: divocat Date: Fri, 3 Oct 2025 03:21:18 +0300 Subject: [PATCH 18/49] refactor: migrate Proxy Configuration URL validation to modular --- .../resources/view/podkop/configSection.js | 89 ++------- .../luci-static/resources/view/podkop/main.js | 169 ++++++++++++++++-- 2 files changed, 171 insertions(+), 87 deletions(-) diff --git a/luci-app-podkop/htdocs/luci-static/resources/view/podkop/configSection.js b/luci-app-podkop/htdocs/luci-static/resources/view/podkop/configSection.js index 43c5f10..7a88eed 100644 --- a/luci-app-podkop/htdocs/luci-static/resources/view/podkop/configSection.js +++ b/luci-app-podkop/htdocs/luci-static/resources/view/podkop/configSection.js @@ -78,8 +78,9 @@ function createConfigSection(section, map, network) { }; o.validate = function (section_id, value) { + // Optional if (!value || value.length === 0) { - return true; + return true } try { @@ -91,92 +92,30 @@ function createConfigSection(section, map, network) { return _('No active configuration found. At least one non-commented line is required.'); } - if (!activeConfig.startsWith('vless://') && !activeConfig.startsWith('ss://')) { - return _('URL must start with vless:// or ss://'); - } - if (activeConfig.startsWith('ss://')) { - let encrypted_part; - try { - let mainPart = activeConfig.includes('?') ? activeConfig.split('?')[0] : activeConfig.split('#')[0]; - encrypted_part = mainPart.split('/')[2].split('@')[0]; - try { - let decoded = atob(encrypted_part); - if (!decoded.includes(':')) { - if (!encrypted_part.includes(':') && !encrypted_part.includes('-')) { - return _('Invalid Shadowsocks URL format: missing method and password separator ":"'); - } - } - } catch (e) { - if (!encrypted_part.includes(':') && !encrypted_part.includes('-')) { - return _('Invalid Shadowsocks URL format: missing method and password separator ":"'); - } - } - } catch (e) { - return _('Invalid Shadowsocks URL format'); + const validation = main.validateShadowsocksUrl(activeConfig); + + if (validation.valid) { + return true; } - try { - let serverPart = activeConfig.split('@')[1]; - if (!serverPart) return _('Invalid Shadowsocks URL: missing server address'); - let [server, portAndRest] = serverPart.split(':'); - if (!server) return _('Invalid Shadowsocks URL: missing server'); - let port = portAndRest ? portAndRest.split(/[?#]/)[0] : null; - if (!port) return _('Invalid Shadowsocks URL: missing port'); - let portNum = parseInt(port); - if (isNaN(portNum) || portNum < 1 || portNum > 65535) { - return _('Invalid port number. Must be between 1 and 65535'); - } - } catch (e) { - return _('Invalid Shadowsocks URL: missing or invalid server/port format'); - } + return _(validation.message) } if (activeConfig.startsWith('vless://')) { - let uuid = activeConfig.split('/')[2].split('@')[0]; - if (!uuid || uuid.length === 0) return _('Invalid VLESS URL: missing UUID'); + const validation = main.validateVlessUrl(activeConfig); - try { - let serverPart = activeConfig.split('@')[1]; - if (!serverPart) return _('Invalid VLESS URL: missing server address'); - let [server, portAndRest] = serverPart.split(':'); - if (!server) return _('Invalid VLESS URL: missing server'); - let port = portAndRest ? portAndRest.split(/[/?#]/)[0] : null; - if (!port) return _('Invalid VLESS URL: missing port'); - let portNum = parseInt(port); - if (isNaN(portNum) || portNum < 1 || portNum > 65535) { - return _('Invalid port number. Must be between 1 and 65535'); - } - } catch (e) { - return _('Invalid VLESS URL: missing or invalid server/port format'); + if (validation.valid) { + return true; } - let queryString = activeConfig.split('?')[1]; - if (!queryString) return _('Invalid VLESS URL: missing query parameters'); - - let params = new URLSearchParams(queryString.split('#')[0]); - let type = params.get('type'); - const validTypes = ['tcp', 'raw', 'udp', 'grpc', 'http', 'ws']; - if (!type || !validTypes.includes(type)) { - return _('Invalid VLESS URL: type must be one of tcp, raw, udp, grpc, http, ws'); - } - - let security = params.get('security'); - const validSecurities = ['tls', 'reality', 'none']; - if (!security || !validSecurities.includes(security)) { - return _('Invalid VLESS URL: security must be one of tls, reality, none'); - } - - if (security === 'reality') { - if (!params.get('pbk')) return _('Invalid VLESS URL: missing pbk parameter for reality security'); - if (!params.get('fp')) return _('Invalid VLESS URL: missing fp parameter for reality security'); - } + return _(validation.message) } - return true; + return _('URL must start with vless:// or ss://') + } catch (e) { - console.error('Validation error:', e); - return _('Invalid URL format: ') + e.message; + return `${_('Invalid URL format:')} ${e?.message}`; } }; diff --git a/luci-app-podkop/htdocs/luci-static/resources/view/podkop/main.js b/luci-app-podkop/htdocs/luci-static/resources/view/podkop/main.js index 3586973..1752146 100644 --- a/luci-app-podkop/htdocs/luci-static/resources/view/podkop/main.js +++ b/luci-app-podkop/htdocs/luci-static/resources/view/podkop/main.js @@ -118,6 +118,160 @@ function bulkValidate(values, validate) { }; } +// src/validators/validateShadowsocksUrl.ts +function validateShadowsocksUrl(url) { + if (!url.startsWith("ss://")) { + return { + valid: false, + message: "Invalid Shadowsocks URL: must start with ss://" + }; + } + try { + const mainPart = url.includes("?") ? url.split("?")[0] : url.split("#")[0]; + const encryptedPart = mainPart.split("/")[2]?.split("@")[0]; + if (!encryptedPart) { + return { + valid: false, + message: "Invalid Shadowsocks URL: missing credentials" + }; + } + try { + const decoded = atob(encryptedPart); + if (!decoded.includes(":")) { + return { + valid: false, + message: "Invalid Shadowsocks URL: decoded credentials must contain method:password" + }; + } + } catch (e) { + if (!encryptedPart.includes(":") && !encryptedPart.includes("-")) { + return { + valid: false, + message: 'Invalid Shadowsocks URL: missing method and password separator ":"' + }; + } + } + const serverPart = url.split("@")[1]; + if (!serverPart) { + return { + valid: false, + message: "Invalid Shadowsocks URL: missing server address" + }; + } + const [server, portAndRest] = serverPart.split(":"); + if (!server) { + return { + valid: false, + message: "Invalid Shadowsocks URL: missing server" + }; + } + const port = portAndRest ? portAndRest.split(/[?#]/)[0] : null; + if (!port) { + return { valid: false, message: "Invalid Shadowsocks URL: missing port" }; + } + const portNum = parseInt(port, 10); + if (isNaN(portNum) || portNum < 1 || portNum > 65535) { + return { + valid: false, + message: "Invalid port number. Must be between 1 and 65535" + }; + } + } catch (e) { + return { valid: false, message: "Invalid Shadowsocks URL: parsing failed" }; + } + return { valid: true, message: "Valid" }; +} + +// src/validators/validateVlessUrl.ts +function validateVlessUrl(url) { + if (!url.startsWith("vless://")) { + return { + valid: false, + message: "Invalid VLESS URL: must start with vless://" + }; + } + try { + const uuid = url.split("/")[2]?.split("@")[0]; + if (!uuid) { + return { valid: false, message: "Invalid VLESS URL: missing UUID" }; + } + const serverPart = url.split("@")[1]; + if (!serverPart) { + return { + valid: false, + message: "Invalid VLESS URL: missing server address" + }; + } + const [server, portAndRest] = serverPart.split(":"); + if (!server) { + return { valid: false, message: "Invalid VLESS URL: missing server" }; + } + const port = portAndRest ? portAndRest.split(/[/?#]/)[0] : null; + if (!port) { + return { valid: false, message: "Invalid VLESS URL: missing port" }; + } + const portNum = parseInt(port, 10); + if (isNaN(portNum) || portNum < 1 || portNum > 65535) { + return { + valid: false, + message: "Invalid port number. Must be between 1 and 65535" + }; + } + const queryString = url.split("?")[1]; + if (!queryString) { + return { + valid: false, + message: "Invalid VLESS URL: missing query parameters" + }; + } + const params = new URLSearchParams(queryString.split("#")[0]); + const type = params.get("type"); + const validTypes = ["tcp", "raw", "udp", "grpc", "http", "ws"]; + if (!type || !validTypes.includes(type)) { + return { + valid: false, + message: "Invalid VLESS URL: type must be one of tcp, raw, udp, grpc, http, ws" + }; + } + const security = params.get("security"); + const validSecurities = ["tls", "reality", "none"]; + if (!security || !validSecurities.includes(security)) { + return { + valid: false, + message: "Invalid VLESS URL: security must be one of tls, reality, none" + }; + } + if (security === "reality") { + if (!params.get("pbk")) { + return { + valid: false, + message: "Invalid VLESS URL: missing pbk parameter for reality security" + }; + } + if (!params.get("fp")) { + return { + valid: false, + message: "Invalid VLESS URL: missing fp parameter for reality security" + }; + } + } + } catch (e) { + return { valid: false, message: "Invalid VLESS URL: parsing failed" }; + } + return { valid: true, message: "Valid" }; +} + +// src/helpers/getBaseUrl.ts +function getBaseUrl() { + const { protocol, hostname } = window.location; + return `${protocol}//${hostname}`; +} + +// src/helpers/parseValueList.ts +function parseValueList(value) { + return value.split(/\n/).map((line) => line.split("//")[0]).join(" ").split(/[,\s]+/).map((s) => s.trim()).filter(Boolean); +} + // src/constants.ts var STATUS_COLORS = { SUCCESS: "#4caf50", @@ -227,17 +381,6 @@ var COMMAND_SCHEDULING = { P10_PRIORITY: 1900 // Lowest priority }; - -// src/helpers/getBaseUrl.ts -function getBaseUrl() { - const { protocol, hostname } = window.location; - return `${protocol}//${hostname}`; -} - -// src/helpers/parseValueList.ts -function parseValueList(value) { - return value.split(/\n/).map((line) => line.split("//")[0]).join(" ").split(/[,\s]+/).map((s) => s.trim()).filter(Boolean); -} return baseclass.extend({ ALLOWED_WITH_RUSSIA_INSIDE, BOOTSTRAP_DNS_SERVER_OPTIONS, @@ -263,6 +406,8 @@ return baseclass.extend({ validateDomain, validateIPV4, validatePath, + validateShadowsocksUrl, validateSubnet, - validateUrl + validateUrl, + validateVlessUrl }); From 8f9bff9a647af8a96e0fb9c0263236684085ed12 Mon Sep 17 00:00:00 2001 From: divocat Date: Fri, 3 Oct 2025 03:26:02 +0300 Subject: [PATCH 19/49] refactor: migrate Outbound Configuration validation to modular --- fe-app-podkop/src/validators/index.ts | 1 + fe-app-podkop/src/validators/validateDns.ts | 2 +- .../src/validators/validateDomain.ts | 2 +- fe-app-podkop/src/validators/validateIp.ts | 2 +- .../src/validators/validateOutboundJson.ts | 20 +++++++++++++++++++ .../src/validators/validateShadowsocksUrl.ts | 2 +- .../src/validators/validateSubnet.ts | 2 +- fe-app-podkop/src/validators/validateUrl.ts | 2 +- .../resources/view/podkop/configSection.js | 20 ++++++++++--------- .../luci-static/resources/view/podkop/main.js | 17 ++++++++++++++++ 10 files changed, 55 insertions(+), 15 deletions(-) create mode 100644 fe-app-podkop/src/validators/validateOutboundJson.ts diff --git a/fe-app-podkop/src/validators/index.ts b/fe-app-podkop/src/validators/index.ts index bedc361..8c6c5e1 100644 --- a/fe-app-podkop/src/validators/index.ts +++ b/fe-app-podkop/src/validators/index.ts @@ -7,3 +7,4 @@ export * from './validateSubnet'; export * from './bulkValidate'; export * from './validateShadowsocksUrl'; export * from './validateVlessUrl'; +export * from './validateOutboundJson'; diff --git a/fe-app-podkop/src/validators/validateDns.ts b/fe-app-podkop/src/validators/validateDns.ts index ea1fec7..f779d22 100644 --- a/fe-app-podkop/src/validators/validateDns.ts +++ b/fe-app-podkop/src/validators/validateDns.ts @@ -1,6 +1,6 @@ import { validateDomain } from './validateDomain'; import { validateIPV4 } from './validateIp'; -import { ValidationResult } from './types.js'; +import { ValidationResult } from './types'; export function validateDNS(value: string): ValidationResult { if (!value) { diff --git a/fe-app-podkop/src/validators/validateDomain.ts b/fe-app-podkop/src/validators/validateDomain.ts index ae90296..343fd86 100644 --- a/fe-app-podkop/src/validators/validateDomain.ts +++ b/fe-app-podkop/src/validators/validateDomain.ts @@ -1,4 +1,4 @@ -import { ValidationResult } from './types.js'; +import { ValidationResult } from './types'; export function validateDomain(domain: string): ValidationResult { const domainRegex = diff --git a/fe-app-podkop/src/validators/validateIp.ts b/fe-app-podkop/src/validators/validateIp.ts index 4d1967b..88ab1f0 100644 --- a/fe-app-podkop/src/validators/validateIp.ts +++ b/fe-app-podkop/src/validators/validateIp.ts @@ -1,4 +1,4 @@ -import { ValidationResult } from './types.js'; +import { ValidationResult } from './types'; export function validateIPV4(ip: string): ValidationResult { const ipRegex = diff --git a/fe-app-podkop/src/validators/validateOutboundJson.ts b/fe-app-podkop/src/validators/validateOutboundJson.ts new file mode 100644 index 0000000..c768543 --- /dev/null +++ b/fe-app-podkop/src/validators/validateOutboundJson.ts @@ -0,0 +1,20 @@ +import { ValidationResult } from './types'; + +// TODO refactor current validation and add tests +export function validateOutboundJson(value: string): ValidationResult { + try { + const parsed = JSON.parse(value); + + if (!parsed.type || !parsed.server || !parsed.server_port) { + return { + valid: false, + message: + 'Outbound JSON must contain at least "type", "server" and "server_port" fields', + }; + } + + return { valid: true, message: 'Valid' }; + } catch { + return { valid: false, message: 'Invalid JSON format' }; + } +} diff --git a/fe-app-podkop/src/validators/validateShadowsocksUrl.ts b/fe-app-podkop/src/validators/validateShadowsocksUrl.ts index 8c2adb1..b52a624 100644 --- a/fe-app-podkop/src/validators/validateShadowsocksUrl.ts +++ b/fe-app-podkop/src/validators/validateShadowsocksUrl.ts @@ -1,4 +1,4 @@ -import { ValidationResult } from './types.js'; +import { ValidationResult } from './types'; // TODO refactor current validation and add tests export function validateShadowsocksUrl(url: string): ValidationResult { diff --git a/fe-app-podkop/src/validators/validateSubnet.ts b/fe-app-podkop/src/validators/validateSubnet.ts index f0022c0..6f3e2b9 100644 --- a/fe-app-podkop/src/validators/validateSubnet.ts +++ b/fe-app-podkop/src/validators/validateSubnet.ts @@ -1,4 +1,4 @@ -import { ValidationResult } from './types.js'; +import { ValidationResult } from './types'; import { validateIPV4 } from './validateIp'; export function validateSubnet(value: string): ValidationResult { diff --git a/fe-app-podkop/src/validators/validateUrl.ts b/fe-app-podkop/src/validators/validateUrl.ts index 865af9d..57e883a 100644 --- a/fe-app-podkop/src/validators/validateUrl.ts +++ b/fe-app-podkop/src/validators/validateUrl.ts @@ -1,4 +1,4 @@ -import { ValidationResult } from './types.js'; +import { ValidationResult } from './types'; export function validateUrl( url: string, diff --git a/luci-app-podkop/htdocs/luci-static/resources/view/podkop/configSection.js b/luci-app-podkop/htdocs/luci-static/resources/view/podkop/configSection.js index 7a88eed..4c8a2ae 100644 --- a/luci-app-podkop/htdocs/luci-static/resources/view/podkop/configSection.js +++ b/luci-app-podkop/htdocs/luci-static/resources/view/podkop/configSection.js @@ -124,16 +124,18 @@ function createConfigSection(section, map, network) { o.rows = 10; o.ucisection = s.section; o.validate = function (section_id, value) { - if (!value || value.length === 0) return true; - try { - const parsed = JSON.parse(value); - if (!parsed.type || !parsed.server || !parsed.server_port) { - return _('JSON must contain at least type, server and server_port fields'); - } - return true; - } catch (e) { - return _('Invalid JSON format'); + // Optional + if (!value || value.length === 0) { + return true } + + const validation = main.validateOutboundJson(value); + + if (validation.valid) { + return true; + } + + return _(validation.message) }; o = s.taboption('basic', form.DynamicList, 'urltest_proxy_links', _('URLTest Proxy Links')); diff --git a/luci-app-podkop/htdocs/luci-static/resources/view/podkop/main.js b/luci-app-podkop/htdocs/luci-static/resources/view/podkop/main.js index 1752146..f97049d 100644 --- a/luci-app-podkop/htdocs/luci-static/resources/view/podkop/main.js +++ b/luci-app-podkop/htdocs/luci-static/resources/view/podkop/main.js @@ -261,6 +261,22 @@ function validateVlessUrl(url) { return { valid: true, message: "Valid" }; } +// src/validators/validateOutboundJson.ts +function validateOutboundJson(value) { + try { + const parsed = JSON.parse(value); + if (!parsed.type || !parsed.server || !parsed.server_port) { + return { + valid: false, + message: 'Outbound JSON must contain at least "type", "server" and "server_port" fields' + }; + } + return { valid: true, message: "Valid" }; + } catch { + return { valid: false, message: "Invalid JSON format" }; + } +} + // src/helpers/getBaseUrl.ts function getBaseUrl() { const { protocol, hostname } = window.location; @@ -405,6 +421,7 @@ return baseclass.extend({ validateDNS, validateDomain, validateIPV4, + validateOutboundJson, validatePath, validateShadowsocksUrl, validateSubnet, From eb0617eef1e56c42de216b42c400282efd0037af Mon Sep 17 00:00:00 2001 From: divocat Date: Fri, 3 Oct 2025 03:34:04 +0300 Subject: [PATCH 20/49] fix: corrent naming for User Domains List validation --- .../luci-static/resources/view/podkop/configSection.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/luci-app-podkop/htdocs/luci-static/resources/view/podkop/configSection.js b/luci-app-podkop/htdocs/luci-static/resources/view/podkop/configSection.js index 4c8a2ae..e3af1b9 100644 --- a/luci-app-podkop/htdocs/luci-static/resources/view/podkop/configSection.js +++ b/luci-app-podkop/htdocs/luci-static/resources/view/podkop/configSection.js @@ -331,15 +331,15 @@ function createConfigSection(section, map, network) { return true } - const subnets = main.parseValueList(value); + const domains = main.parseValueList(value); - if (!subnets.length) { + if (!domains.length) { return _( 'At least one valid domain must be specified. Comments-only content is not allowed.' ); } - const { valid, results } = main.bulkValidate(subnets, main.validateDomain); + const { valid, results } = main.bulkValidate(domains, main.validateDomain); if (!valid) { const errors = results From db8e8e82987481678f0f076f6c6d07deba292d54 Mon Sep 17 00:00:00 2001 From: divocat Date: Fri, 3 Oct 2025 03:52:12 +0300 Subject: [PATCH 21/49] refactor: migrate global styles to injectGlobalStyles --- fe-app-podkop/src/helpers/index.ts | 1 + .../src/helpers/injectGlobalStyles.ts | 12 ++++++ fe-app-podkop/src/styles.ts | 26 ++++++++++++ .../luci-static/resources/view/podkop/main.js | 40 +++++++++++++++++++ .../resources/view/podkop/podkop.js | 31 ++------------ 5 files changed, 82 insertions(+), 28 deletions(-) create mode 100644 fe-app-podkop/src/helpers/injectGlobalStyles.ts create mode 100644 fe-app-podkop/src/styles.ts diff --git a/fe-app-podkop/src/helpers/index.ts b/fe-app-podkop/src/helpers/index.ts index 053e45e..073c58a 100644 --- a/fe-app-podkop/src/helpers/index.ts +++ b/fe-app-podkop/src/helpers/index.ts @@ -1,2 +1,3 @@ export * from './getBaseUrl'; export * from './parseValueList'; +export * from './injectGlobalStyles'; diff --git a/fe-app-podkop/src/helpers/injectGlobalStyles.ts b/fe-app-podkop/src/helpers/injectGlobalStyles.ts new file mode 100644 index 0000000..47a9f07 --- /dev/null +++ b/fe-app-podkop/src/helpers/injectGlobalStyles.ts @@ -0,0 +1,12 @@ +import { GlobalStyles } from '../styles'; + +export function injectGlobalStyles() { + document.head.insertAdjacentHTML( + 'beforeend', + ` + + `, + ); +} diff --git a/fe-app-podkop/src/styles.ts b/fe-app-podkop/src/styles.ts new file mode 100644 index 0000000..6613f9b --- /dev/null +++ b/fe-app-podkop/src/styles.ts @@ -0,0 +1,26 @@ +// language=CSS +export const GlobalStyles = ` +.cbi-value { + margin-bottom: 10px !important; +} + +#diagnostics-status .table > div { + background: var(--background-color-primary); + border: 1px solid var(--border-color-medium); + border-radius: var(--border-radius); +} + +#diagnostics-status .table > div pre, +#diagnostics-status .table > div div[style*="monospace"] { + color: var(--color-text-primary); +} + +#diagnostics-status .alert-message { + background: var(--background-color-primary); + border-color: var(--border-color-medium); +} + +#cbi-podkop:has(.cbi-tab-disabled[data-tab="basic"]) #cbi-podkop-extra { + display: none; +} +`; diff --git a/luci-app-podkop/htdocs/luci-static/resources/view/podkop/main.js b/luci-app-podkop/htdocs/luci-static/resources/view/podkop/main.js index f97049d..d837b30 100644 --- a/luci-app-podkop/htdocs/luci-static/resources/view/podkop/main.js +++ b/luci-app-podkop/htdocs/luci-static/resources/view/podkop/main.js @@ -288,6 +288,45 @@ function parseValueList(value) { return value.split(/\n/).map((line) => line.split("//")[0]).join(" ").split(/[,\s]+/).map((s) => s.trim()).filter(Boolean); } +// src/styles.ts +var GlobalStyles = ` +.cbi-value { + margin-bottom: 10px !important; +} + +#diagnostics-status .table > div { + background: var(--background-color-primary); + border: 1px solid var(--border-color-medium); + border-radius: var(--border-radius); +} + +#diagnostics-status .table > div pre, +#diagnostics-status .table > div div[style*="monospace"] { + color: var(--color-text-primary); +} + +#diagnostics-status .alert-message { + background: var(--background-color-primary); + border-color: var(--border-color-medium); +} + +#cbi-podkop:has(.cbi-tab-disabled[data-tab="basic"]) #cbi-podkop-extra { + display: none; +} +`; + +// src/helpers/injectGlobalStyles.ts +function injectGlobalStyles() { + document.head.insertAdjacentHTML( + "beforeend", + ` + + ` + ); +} + // src/constants.ts var STATUS_COLORS = { SUCCESS: "#4caf50", @@ -417,6 +456,7 @@ return baseclass.extend({ UPDATE_INTERVAL_OPTIONS, bulkValidate, getBaseUrl, + injectGlobalStyles, parseValueList, validateDNS, validateDomain, diff --git a/luci-app-podkop/htdocs/luci-static/resources/view/podkop/podkop.js b/luci-app-podkop/htdocs/luci-static/resources/view/podkop/podkop.js index 35713c9..64ba228 100644 --- a/luci-app-podkop/htdocs/luci-static/resources/view/podkop/podkop.js +++ b/luci-app-podkop/htdocs/luci-static/resources/view/podkop/podkop.js @@ -6,36 +6,11 @@ 'require view.podkop.diagnosticTab as diagnosticTab'; 'require view.podkop.additionalTab as additionalTab'; 'require view.podkop.utils as utils'; +'require view.podkop.main as main'; return view.extend({ async render() { - document.head.insertAdjacentHTML('beforeend', ` - - `); + main.injectGlobalStyles(); const m = new form.Map('podkop', '', null, ['main', 'extra']); @@ -90,4 +65,4 @@ return view.extend({ return map_promise; } -}); \ No newline at end of file +}); From 96bcc36cf16fc507c21ccc72dad9ade8f1f0aad5 Mon Sep 17 00:00:00 2001 From: divocat Date: Fri, 3 Oct 2025 04:15:41 +0300 Subject: [PATCH 22/49] refactor: remove unused variables --- .../resources/view/podkop/configSection.js | 2 +- .../resources/view/podkop/diagnosticTab.js | 27 ------------------- .../resources/view/podkop/podkop.js | 22 ++++++++------- 3 files changed, 13 insertions(+), 38 deletions(-) diff --git a/luci-app-podkop/htdocs/luci-static/resources/view/podkop/configSection.js b/luci-app-podkop/htdocs/luci-static/resources/view/podkop/configSection.js index e3af1b9..0d5862b 100644 --- a/luci-app-podkop/htdocs/luci-static/resources/view/podkop/configSection.js +++ b/luci-app-podkop/htdocs/luci-static/resources/view/podkop/configSection.js @@ -7,7 +7,7 @@ 'require tools.widgets as widgets'; -function createConfigSection(section, map, network) { +function createConfigSection(section) { const s = section; let o = s.tab('basic', _('Basic Settings')); diff --git a/luci-app-podkop/htdocs/luci-static/resources/view/podkop/diagnosticTab.js b/luci-app-podkop/htdocs/luci-static/resources/view/podkop/diagnosticTab.js index a316a15..1768b43 100644 --- a/luci-app-podkop/htdocs/luci-static/resources/view/podkop/diagnosticTab.js +++ b/luci-app-podkop/htdocs/luci-static/resources/view/podkop/diagnosticTab.js @@ -251,33 +251,6 @@ async function checkBypass() { } } -// Modal Functions -function createModalContent(title, content) { - return [ - E('div', { - 'class': 'panel-body', - style: 'max-height: 70vh; overflow-y: auto; margin: 1em 0; padding: 1.5em; ' + - 'font-family: monospace; white-space: pre-wrap; word-wrap: break-word; ' + - 'line-height: 1.5; font-size: 14px;' - }, [ - E('pre', { style: 'margin: 0;' }, content) - ]), - E('div', { - 'class': 'right', - style: 'margin-top: 1em;' - }, [ - E('button', { - 'class': 'btn', - 'click': ev => copyToClipboard('```txt\n' + content + '\n```', ev.target) - }, _('Copy to Clipboard')), - E('button', { - 'class': 'btn', - 'click': ui.hideModal - }, _('Close')) - ]) - ]; -} - function showConfigModal(command, title) { // Create and show modal immediately with loading state const modalContent = E('div', { 'class': 'panel-body' }, [ diff --git a/luci-app-podkop/htdocs/luci-static/resources/view/podkop/podkop.js b/luci-app-podkop/htdocs/luci-static/resources/view/podkop/podkop.js index 64ba228..0b1e6c6 100644 --- a/luci-app-podkop/htdocs/luci-static/resources/view/podkop/podkop.js +++ b/luci-app-podkop/htdocs/luci-static/resources/view/podkop/podkop.js @@ -8,23 +8,23 @@ 'require view.podkop.utils as utils'; 'require view.podkop.main as main'; -return view.extend({ +const EntryNode = { async render() { main.injectGlobalStyles(); - const m = new form.Map('podkop', '', null, ['main', 'extra']); + const podkopFormMap = new form.Map('podkop', '', null, ['main', 'extra']); // Main Section - const mainSection = m.section(form.TypedSection, 'main'); + const mainSection = podkopFormMap.section(form.TypedSection, 'main'); mainSection.anonymous = true; - configSection.createConfigSection(mainSection, m, network); + configSection.createConfigSection(mainSection); // Additional Settings Tab (main section) - additionalTab.createAdditionalSection(mainSection, network); + additionalTab.createAdditionalSection(mainSection); // Diagnostics Tab (main section) diagnosticTab.createDiagnosticsSection(mainSection); - const map_promise = m.render().then(node => { + const podkopFormMapPromise = podkopFormMap.render().then(node => { // Set up diagnostics event handlers diagnosticTab.setupDiagnosticsEventHandlers(node); @@ -56,13 +56,15 @@ return view.extend({ }); // Extra Section - const extraSection = m.section(form.TypedSection, 'extra', _('Extra configurations')); + const extraSection = podkopFormMap.section(form.TypedSection, 'extra', _('Extra configurations')); extraSection.anonymous = false; extraSection.addremove = true; extraSection.addbtntitle = _('Add Section'); extraSection.multiple = true; - configSection.createConfigSection(extraSection, m, network); + configSection.createConfigSection(extraSection); - return map_promise; + return podkopFormMapPromise; } -}); +} + +return view.extend(EntryNode); From 7b06f422afd7384a1509fac591868e1700190a1f Mon Sep 17 00:00:00 2001 From: divocat Date: Fri, 3 Oct 2025 14:32:17 +0300 Subject: [PATCH 23/49] feat: add trojan link support to Proxy Configuration URL validation --- fe-app-podkop/src/validators/index.ts | 1 + .../src/validators/validateTrojanUrl.ts | 26 +++++++++++++++++++ .../resources/view/podkop/configSection.js | 14 ++++++++-- .../luci-static/resources/view/podkop/main.js | 23 ++++++++++++++++ 4 files changed, 62 insertions(+), 2 deletions(-) create mode 100644 fe-app-podkop/src/validators/validateTrojanUrl.ts diff --git a/fe-app-podkop/src/validators/index.ts b/fe-app-podkop/src/validators/index.ts index 8c6c5e1..fd9e2cc 100644 --- a/fe-app-podkop/src/validators/index.ts +++ b/fe-app-podkop/src/validators/index.ts @@ -8,3 +8,4 @@ export * from './bulkValidate'; export * from './validateShadowsocksUrl'; export * from './validateVlessUrl'; export * from './validateOutboundJson'; +export * from './validateTrojanUrl'; diff --git a/fe-app-podkop/src/validators/validateTrojanUrl.ts b/fe-app-podkop/src/validators/validateTrojanUrl.ts new file mode 100644 index 0000000..03f53cd --- /dev/null +++ b/fe-app-podkop/src/validators/validateTrojanUrl.ts @@ -0,0 +1,26 @@ +import { ValidationResult } from './types'; + +// TODO refactor current validation and add tests +export function validateTrojanUrl(url: string): ValidationResult { + if (!url.startsWith('trojan://')) { + return { + valid: false, + message: 'Invalid Trojan URL: must start with trojan://', + }; + } + + try { + const parsedUrl = new URL(url); + + if (!parsedUrl.username || !parsedUrl.hostname || !parsedUrl.port) { + return { + valid: false, + message: 'Invalid Trojan URL: must contain username, hostname and port', + }; + } + } catch (e) { + return { valid: false, message: 'Invalid Trojan URL: parsing failed' }; + } + + return { valid: true, message: 'Valid' }; +} diff --git a/luci-app-podkop/htdocs/luci-static/resources/view/podkop/configSection.js b/luci-app-podkop/htdocs/luci-static/resources/view/podkop/configSection.js index 0d5862b..dd8ff31 100644 --- a/luci-app-podkop/htdocs/luci-static/resources/view/podkop/configSection.js +++ b/luci-app-podkop/htdocs/luci-static/resources/view/podkop/configSection.js @@ -34,7 +34,7 @@ function createConfigSection(section) { o.rmempty = false; o.ucisection = s.section; o.sectionDescriptions = new Map(); - o.placeholder = 'vless://uuid@server:port?type=tcp&security=tls#main\n// backup ss://method:pass@server:port\n// backup2 vless://uuid@server:port?type=grpc&security=reality#alt'; + o.placeholder = 'vless://uuid@server:port?type=tcp&security=tls#main\n// backup ss://method:pass@server:port\n// backup2 vless://uuid@server:port?type=grpc&security=reality#alt\n// backup3 trojan://04agAQapcl@127.0.0.1:33641?type=tcp&security=none#trojan-tcp-none'; o.renderWidget = function (section_id, option_index, cfgvalue) { const original = form.TextValue.prototype.renderWidget.apply(this, [section_id, option_index, cfgvalue]); @@ -112,7 +112,17 @@ function createConfigSection(section) { return _(validation.message) } - return _('URL must start with vless:// or ss://') + if (activeConfig.startsWith('trojan://')) { + const validation = main.validateTrojanUrl(activeConfig); + + if (validation.valid) { + return true; + } + + return _(validation.message) + } + + return _('URL must start with vless:// or ss:// or trojan://') } catch (e) { return `${_('Invalid URL format:')} ${e?.message}`; diff --git a/luci-app-podkop/htdocs/luci-static/resources/view/podkop/main.js b/luci-app-podkop/htdocs/luci-static/resources/view/podkop/main.js index d837b30..ae3ee34 100644 --- a/luci-app-podkop/htdocs/luci-static/resources/view/podkop/main.js +++ b/luci-app-podkop/htdocs/luci-static/resources/view/podkop/main.js @@ -277,6 +277,28 @@ function validateOutboundJson(value) { } } +// src/validators/validateTrojanUrl.ts +function validateTrojanUrl(url) { + if (!url.startsWith("trojan://")) { + return { + valid: false, + message: "Invalid Trojan URL: must start with trojan://" + }; + } + try { + const parsedUrl = new URL(url); + if (!parsedUrl.username || !parsedUrl.hostname || !parsedUrl.port) { + return { + valid: false, + message: "Invalid Trojan URL: must contain username, hostname and port" + }; + } + } catch (e) { + return { valid: false, message: "Invalid Trojan URL: parsing failed" }; + } + return { valid: true, message: "Valid" }; +} + // src/helpers/getBaseUrl.ts function getBaseUrl() { const { protocol, hostname } = window.location; @@ -465,6 +487,7 @@ return baseclass.extend({ validatePath, validateShadowsocksUrl, validateSubnet, + validateTrojanUrl, validateUrl, validateVlessUrl }); From 3f4a0cf0942a6aeba7e600184e2b63b0f1c6ca51 Mon Sep 17 00:00:00 2001 From: divocat Date: Fri, 3 Oct 2025 21:16:38 +0300 Subject: [PATCH 24/49] feat: make URLTest Proxy Links options textarea --- .../htdocs/luci-static/resources/view/podkop/configSection.js | 3 +++ 1 file changed, 3 insertions(+) diff --git a/luci-app-podkop/htdocs/luci-static/resources/view/podkop/configSection.js b/luci-app-podkop/htdocs/luci-static/resources/view/podkop/configSection.js index dd8ff31..a842d46 100644 --- a/luci-app-podkop/htdocs/luci-static/resources/view/podkop/configSection.js +++ b/luci-app-podkop/htdocs/luci-static/resources/view/podkop/configSection.js @@ -152,6 +152,9 @@ function createConfigSection(section) { o.depends('proxy_config_type', 'urltest'); o.placeholder = 'vless:// or ss:// link'; o.rmempty = false; + o.textarea = true; + o.rows = 3; + o.wrap = 'soft'; o = s.taboption('basic', form.Flag, 'ss_uot', _('Shadowsocks UDP over TCP'), _('Apply for SS2022')); o.default = '0'; From eb52d52eb4af5623a033f1f66b3b021bb782237a Mon Sep 17 00:00:00 2001 From: divocat Date: Fri, 3 Oct 2025 21:44:42 +0300 Subject: [PATCH 25/49] feat: implement validateProxyUrl validation --- fe-app-podkop/src/constants.ts | 2 +- fe-app-podkop/src/validators/index.ts | 1 + .../src/validators/validateProxyUrl.ts | 24 +++++++++ .../resources/view/podkop/configSection.js | 49 +++++++------------ .../luci-static/resources/view/podkop/main.js | 18 +++++++ 5 files changed, 63 insertions(+), 31 deletions(-) create mode 100644 fe-app-podkop/src/validators/validateProxyUrl.ts diff --git a/fe-app-podkop/src/constants.ts b/fe-app-podkop/src/constants.ts index 7f39c3f..bdfe8f8 100644 --- a/fe-app-podkop/src/constants.ts +++ b/fe-app-podkop/src/constants.ts @@ -104,4 +104,4 @@ export const COMMAND_SCHEDULING = { P8_PRIORITY: 1500, // Background execution P9_PRIORITY: 1700, // Idle mode execution P10_PRIORITY: 1900, // Lowest priority -}; +} as const; diff --git a/fe-app-podkop/src/validators/index.ts b/fe-app-podkop/src/validators/index.ts index fd9e2cc..88e6b03 100644 --- a/fe-app-podkop/src/validators/index.ts +++ b/fe-app-podkop/src/validators/index.ts @@ -9,3 +9,4 @@ export * from './validateShadowsocksUrl'; export * from './validateVlessUrl'; export * from './validateOutboundJson'; export * from './validateTrojanUrl'; +export * from './validateProxyUrl'; diff --git a/fe-app-podkop/src/validators/validateProxyUrl.ts b/fe-app-podkop/src/validators/validateProxyUrl.ts new file mode 100644 index 0000000..b9ef593 --- /dev/null +++ b/fe-app-podkop/src/validators/validateProxyUrl.ts @@ -0,0 +1,24 @@ +import { ValidationResult } from './types'; +import { validateShadowsocksUrl } from './validateShadowsocksUrl'; +import { validateVlessUrl } from './validateVlessUrl'; +import { validateTrojanUrl } from './validateTrojanUrl'; + +// TODO refactor current validation and add tests +export function validateProxyUrl(url: string): ValidationResult { + if (url.startsWith('ss://')) { + return validateShadowsocksUrl(url); + } + + if (url.startsWith('vless://')) { + return validateVlessUrl(url); + } + + if (url.startsWith('trojan://')) { + return validateTrojanUrl(url); + } + + return { + valid: false, + message: 'URL must start with vless:// or ss:// or trojan://', + }; +} diff --git a/luci-app-podkop/htdocs/luci-static/resources/view/podkop/configSection.js b/luci-app-podkop/htdocs/luci-static/resources/view/podkop/configSection.js index a842d46..0c215c6 100644 --- a/luci-app-podkop/htdocs/luci-static/resources/view/podkop/configSection.js +++ b/luci-app-podkop/htdocs/luci-static/resources/view/podkop/configSection.js @@ -92,38 +92,13 @@ function createConfigSection(section) { return _('No active configuration found. At least one non-commented line is required.'); } - if (activeConfig.startsWith('ss://')) { - const validation = main.validateShadowsocksUrl(activeConfig); + const validation = main.validateProxyUrl(activeConfig); - if (validation.valid) { - return true; - } - - return _(validation.message) + if (validation.valid) { + return true; } - if (activeConfig.startsWith('vless://')) { - const validation = main.validateVlessUrl(activeConfig); - - if (validation.valid) { - return true; - } - - return _(validation.message) - } - - if (activeConfig.startsWith('trojan://')) { - const validation = main.validateTrojanUrl(activeConfig); - - if (validation.valid) { - return true; - } - - return _(validation.message) - } - - return _('URL must start with vless:// or ss:// or trojan://') - + return _(validation.message) } catch (e) { return `${_('Invalid URL format:')} ${e?.message}`; } @@ -150,11 +125,25 @@ function createConfigSection(section) { o = s.taboption('basic', form.DynamicList, 'urltest_proxy_links', _('URLTest Proxy Links')); o.depends('proxy_config_type', 'urltest'); - o.placeholder = 'vless:// or ss:// link'; + o.placeholder = 'vless://, ss://, trojan:// links'; o.rmempty = false; o.textarea = true; o.rows = 3; o.wrap = 'soft'; + o.validate = function (section_id, value) { + // Optional + if (!value || value.length === 0) { + return true + } + + const validation = main.validateProxyUrl(value); + + if (validation.valid) { + return true; + } + + return _(validation.message) + }; o = s.taboption('basic', form.Flag, 'ss_uot', _('Shadowsocks UDP over TCP'), _('Apply for SS2022')); o.default = '0'; diff --git a/luci-app-podkop/htdocs/luci-static/resources/view/podkop/main.js b/luci-app-podkop/htdocs/luci-static/resources/view/podkop/main.js index ae3ee34..130c9fb 100644 --- a/luci-app-podkop/htdocs/luci-static/resources/view/podkop/main.js +++ b/luci-app-podkop/htdocs/luci-static/resources/view/podkop/main.js @@ -299,6 +299,23 @@ function validateTrojanUrl(url) { return { valid: true, message: "Valid" }; } +// src/validators/validateProxyUrl.ts +function validateProxyUrl(url) { + if (url.startsWith("ss://")) { + return validateShadowsocksUrl(url); + } + if (url.startsWith("vless://")) { + return validateVlessUrl(url); + } + if (url.startsWith("trojan://")) { + return validateTrojanUrl(url); + } + return { + valid: false, + message: "URL must start with vless:// or ss:// or trojan://" + }; +} + // src/helpers/getBaseUrl.ts function getBaseUrl() { const { protocol, hostname } = window.location; @@ -485,6 +502,7 @@ return baseclass.extend({ validateIPV4, validateOutboundJson, validatePath, + validateProxyUrl, validateShadowsocksUrl, validateSubnet, validateTrojanUrl, From d9a4f50f626d525ad5e69f012a4fcb7e00eff6a7 Mon Sep 17 00:00:00 2001 From: divocat Date: Sat, 4 Oct 2025 01:15:12 +0300 Subject: [PATCH 26/49] feat: finalize first modular pack --- fe-app-podkop/eslint.config.js | 13 ++- fe-app-podkop/package.json | 3 +- fe-app-podkop/src/helpers/copyToClipboard.ts | 29 +++++++ .../src/helpers/executeShellCommand.ts | 32 ++++++++ fe-app-podkop/src/helpers/index.ts | 4 + fe-app-podkop/src/helpers/maskIP.ts | 5 ++ .../src/helpers/tests/maskIp.test.js | 42 ++++++++++ fe-app-podkop/src/helpers/withTimeout.ts | 21 +++++ fe-app-podkop/src/luci.d.ts | 15 ++++ fe-app-podkop/src/main.ts | 1 + .../src/validators/validateShadowsocksUrl.ts | 4 +- .../src/validators/validateTrojanUrl.ts | 2 +- fe-app-podkop/src/validators/validateUrl.ts | 2 +- .../src/validators/validateVlessUrl.ts | 2 +- .../luci-static/resources/view/podkop/main.js | 80 +++++++++++++++++-- 15 files changed, 241 insertions(+), 14 deletions(-) create mode 100644 fe-app-podkop/src/helpers/copyToClipboard.ts create mode 100644 fe-app-podkop/src/helpers/executeShellCommand.ts create mode 100644 fe-app-podkop/src/helpers/maskIP.ts create mode 100644 fe-app-podkop/src/helpers/tests/maskIp.test.js create mode 100644 fe-app-podkop/src/helpers/withTimeout.ts create mode 100644 fe-app-podkop/src/luci.d.ts diff --git a/fe-app-podkop/eslint.config.js b/fe-app-podkop/eslint.config.js index e30b66d..859f377 100644 --- a/fe-app-podkop/eslint.config.js +++ b/fe-app-podkop/eslint.config.js @@ -7,11 +7,20 @@ export default [ js.configs.recommended, ...tseslint.configs.recommended, { - ignores: ['dist', 'node_modules'], + ignores: ['node_modules'], }, { rules: { - 'no-console': 'warn', + 'no-console': 'off', + 'no-unused-vars': 'off', + '@typescript-eslint/no-unused-vars': [ + 'warn', + { + argsIgnorePattern: '^_', + varsIgnorePattern: '^_', + caughtErrorsIgnorePattern: '^_', + }, + ], }, }, prettier, diff --git a/fe-app-podkop/package.json b/fe-app-podkop/package.json index 59d91b0..92b4f18 100644 --- a/fe-app-podkop/package.json +++ b/fe-app-podkop/package.json @@ -9,7 +9,8 @@ "lint:fix": "eslint src --ext .ts,.tsx --fix", "build": "tsup src/main.ts", "dev": "tsup src/main.ts --watch", - "test": "vitest" + "test": "vitest", + "ci": "yarn format && yarn lint --max-warnings=0 && yarn test --run && yarn build" }, "devDependencies": { "@typescript-eslint/eslint-plugin": "8.45.0", diff --git a/fe-app-podkop/src/helpers/copyToClipboard.ts b/fe-app-podkop/src/helpers/copyToClipboard.ts new file mode 100644 index 0000000..154f4c5 --- /dev/null +++ b/fe-app-podkop/src/helpers/copyToClipboard.ts @@ -0,0 +1,29 @@ +interface CopyToClipboardResponse { + success: boolean; + message: string; +} + +export function copyToClipboard(text: string): CopyToClipboardResponse { + const textarea = document.createElement('textarea'); + textarea.value = text; + document.body.appendChild(textarea); + textarea.select(); + + try { + document.execCommand('copy'); + + return { + success: true, + message: 'Copied!', + }; + } catch (err) { + const error = err as Error; + + return { + success: false, + message: `Failed to copy: ${error.message}`, + }; + } finally { + document.body.removeChild(textarea); + } +} diff --git a/fe-app-podkop/src/helpers/executeShellCommand.ts b/fe-app-podkop/src/helpers/executeShellCommand.ts new file mode 100644 index 0000000..4fc1547 --- /dev/null +++ b/fe-app-podkop/src/helpers/executeShellCommand.ts @@ -0,0 +1,32 @@ +import { COMMAND_TIMEOUT } from '../constants'; +import { withTimeout } from './withTimeout'; + +interface ExecuteShellCommandParams { + command: string; + args: string[]; + timeout?: number; +} + +interface ExecuteShellCommandResponse { + stdout: string; + stderr: string; + code?: number; +} + +export async function executeShellCommand({ + command, + args, + timeout = COMMAND_TIMEOUT, +}: ExecuteShellCommandParams): Promise { + try { + return withTimeout( + fs.exec(command, args), + timeout, + [command, ...args].join(' '), + ); + } catch (err) { + const error = err as Error; + + return { stdout: '', stderr: error?.message, code: 0 }; + } +} diff --git a/fe-app-podkop/src/helpers/index.ts b/fe-app-podkop/src/helpers/index.ts index 073c58a..5569d6e 100644 --- a/fe-app-podkop/src/helpers/index.ts +++ b/fe-app-podkop/src/helpers/index.ts @@ -1,3 +1,7 @@ export * from './getBaseUrl'; export * from './parseValueList'; export * from './injectGlobalStyles'; +export * from './withTimeout'; +export * from './executeShellCommand'; +export * from './copyToClipboard'; +export * from './maskIP'; diff --git a/fe-app-podkop/src/helpers/maskIP.ts b/fe-app-podkop/src/helpers/maskIP.ts new file mode 100644 index 0000000..572b5de --- /dev/null +++ b/fe-app-podkop/src/helpers/maskIP.ts @@ -0,0 +1,5 @@ +export function maskIP(ip: string = ''): string { + const ipv4Regex = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/; + + return ip.replace(ipv4Regex, (_match, _p1, _p2, _p3, p4) => `XX.XX.XX.${p4}`); +} diff --git a/fe-app-podkop/src/helpers/tests/maskIp.test.js b/fe-app-podkop/src/helpers/tests/maskIp.test.js new file mode 100644 index 0000000..f9b030c --- /dev/null +++ b/fe-app-podkop/src/helpers/tests/maskIp.test.js @@ -0,0 +1,42 @@ +import { describe, expect, it } from 'vitest'; +import { maskIP } from '../maskIP'; + +export const validIPs = [ + ['Standard private IP', '192.168.0.1', 'XX.XX.XX.1'], + ['Public IP', '8.8.8.8', 'XX.XX.XX.8'], + ['Mixed digits', '10.0.255.99', 'XX.XX.XX.99'], + ['Edge values', '255.255.255.255', 'XX.XX.XX.255'], + ['Zeros', '0.0.0.0', 'XX.XX.XX.0'], +]; + +export const invalidIPs = [ + ['Empty string', '', ''], + ['Missing octets', '192.168.1', '192.168.1'], + ['Extra octets', '1.2.3.4.5', '1.2.3.4.5'], + ['Letters inside', 'abc.def.ghi.jkl', 'abc.def.ghi.jkl'], + ['Spaces inside', '1. 2.3.4', '1. 2.3.4'], + ['Just dots', '...', '...'], + ['IP with port', '127.0.0.1:8080', '127.0.0.1:8080'], + ['IP with text', 'ip=192.168.0.1', 'ip=192.168.0.1'], +]; + +describe('maskIP', () => { + describe.each(validIPs)('Valid IPv4: %s', (_desc, ip, expected) => { + it(`masks "${ip}" → "${expected}"`, () => { + expect(maskIP(ip)).toBe(expected); + }); + }); + + describe.each(invalidIPs)( + 'Invalid or malformed IP: %s', + (_desc, ip, expected) => { + it(`returns original string for "${ip}"`, () => { + expect(maskIP(ip)).toBe(expected); + }); + }, + ); + + it('defaults to empty string if no param passed', () => { + expect(maskIP()).toBe(''); + }); +}); diff --git a/fe-app-podkop/src/helpers/withTimeout.ts b/fe-app-podkop/src/helpers/withTimeout.ts new file mode 100644 index 0000000..4475a55 --- /dev/null +++ b/fe-app-podkop/src/helpers/withTimeout.ts @@ -0,0 +1,21 @@ +export async function withTimeout( + promise: Promise, + timeoutMs: number, + operationName: string, + timeoutMessage = 'Operation timed out', +): Promise { + let timeoutId; + const start = performance.now(); + + const timeoutPromise = new Promise((_, reject) => { + timeoutId = setTimeout(() => reject(new Error(timeoutMessage)), timeoutMs); + }); + + try { + return await Promise.race([promise, timeoutPromise]); + } finally { + clearTimeout(timeoutId); + const elapsed = performance.now() - start; + console.log(`[${operationName}] Execution time: ${elapsed.toFixed(2)} ms`); + } +} diff --git a/fe-app-podkop/src/luci.d.ts b/fe-app-podkop/src/luci.d.ts new file mode 100644 index 0000000..2b942de --- /dev/null +++ b/fe-app-podkop/src/luci.d.ts @@ -0,0 +1,15 @@ +declare global { + const fs: { + exec( + command: string, + args?: string[], + env?: Record, + ): Promise<{ + stdout: string; + stderr: string; + code?: number; + }>; + }; +} + +export {}; diff --git a/fe-app-podkop/src/main.ts b/fe-app-podkop/src/main.ts index 138d3fc..f3656c5 100644 --- a/fe-app-podkop/src/main.ts +++ b/fe-app-podkop/src/main.ts @@ -1,5 +1,6 @@ 'use strict'; 'require baseclass'; +'require fs'; export * from './validators'; export * from './helpers'; diff --git a/fe-app-podkop/src/validators/validateShadowsocksUrl.ts b/fe-app-podkop/src/validators/validateShadowsocksUrl.ts index b52a624..ca9e40e 100644 --- a/fe-app-podkop/src/validators/validateShadowsocksUrl.ts +++ b/fe-app-podkop/src/validators/validateShadowsocksUrl.ts @@ -31,7 +31,7 @@ export function validateShadowsocksUrl(url: string): ValidationResult { 'Invalid Shadowsocks URL: decoded credentials must contain method:password', }; } - } catch (e) { + } catch (_e) { if (!encryptedPart.includes(':') && !encryptedPart.includes('-')) { return { valid: false, @@ -73,7 +73,7 @@ export function validateShadowsocksUrl(url: string): ValidationResult { message: 'Invalid port number. Must be between 1 and 65535', }; } - } catch (e) { + } catch (_e) { return { valid: false, message: 'Invalid Shadowsocks URL: parsing failed' }; } diff --git a/fe-app-podkop/src/validators/validateTrojanUrl.ts b/fe-app-podkop/src/validators/validateTrojanUrl.ts index 03f53cd..49b85b6 100644 --- a/fe-app-podkop/src/validators/validateTrojanUrl.ts +++ b/fe-app-podkop/src/validators/validateTrojanUrl.ts @@ -18,7 +18,7 @@ export function validateTrojanUrl(url: string): ValidationResult { message: 'Invalid Trojan URL: must contain username, hostname and port', }; } - } catch (e) { + } catch (_e) { return { valid: false, message: 'Invalid Trojan URL: parsing failed' }; } diff --git a/fe-app-podkop/src/validators/validateUrl.ts b/fe-app-podkop/src/validators/validateUrl.ts index 57e883a..5b6d522 100644 --- a/fe-app-podkop/src/validators/validateUrl.ts +++ b/fe-app-podkop/src/validators/validateUrl.ts @@ -14,7 +14,7 @@ export function validateUrl( }; } return { valid: true, message: 'Valid' }; - } catch (e) { + } catch (_e) { return { valid: false, message: 'Invalid URL format' }; } } diff --git a/fe-app-podkop/src/validators/validateVlessUrl.ts b/fe-app-podkop/src/validators/validateVlessUrl.ts index 56aeb09..22189ab 100644 --- a/fe-app-podkop/src/validators/validateVlessUrl.ts +++ b/fe-app-podkop/src/validators/validateVlessUrl.ts @@ -94,7 +94,7 @@ export function validateVlessUrl(url: string): ValidationResult { }; } } - } catch (e) { + } catch (_e) { return { valid: false, message: 'Invalid VLESS URL: parsing failed' }; } diff --git a/luci-app-podkop/htdocs/luci-static/resources/view/podkop/main.js b/luci-app-podkop/htdocs/luci-static/resources/view/podkop/main.js index 130c9fb..b3a46ff 100644 --- a/luci-app-podkop/htdocs/luci-static/resources/view/podkop/main.js +++ b/luci-app-podkop/htdocs/luci-static/resources/view/podkop/main.js @@ -1,6 +1,7 @@ // This file is autogenerated, please don't change manually "use strict"; "require baseclass"; +"require fs"; // src/validators/validateIp.ts function validateIPV4(ip) { @@ -54,7 +55,7 @@ function validateUrl(url, protocols = ["http:", "https:"]) { }; } return { valid: true, message: "Valid" }; - } catch (e) { + } catch (_e) { return { valid: false, message: "Invalid URL format" }; } } @@ -143,7 +144,7 @@ function validateShadowsocksUrl(url) { message: "Invalid Shadowsocks URL: decoded credentials must contain method:password" }; } - } catch (e) { + } catch (_e) { if (!encryptedPart.includes(":") && !encryptedPart.includes("-")) { return { valid: false, @@ -176,7 +177,7 @@ function validateShadowsocksUrl(url) { message: "Invalid port number. Must be between 1 and 65535" }; } - } catch (e) { + } catch (_e) { return { valid: false, message: "Invalid Shadowsocks URL: parsing failed" }; } return { valid: true, message: "Valid" }; @@ -255,7 +256,7 @@ function validateVlessUrl(url) { }; } } - } catch (e) { + } catch (_e) { return { valid: false, message: "Invalid VLESS URL: parsing failed" }; } return { valid: true, message: "Valid" }; @@ -293,7 +294,7 @@ function validateTrojanUrl(url) { message: "Invalid Trojan URL: must contain username, hostname and port" }; } - } catch (e) { + } catch (_e) { return { valid: false, message: "Invalid Trojan URL: parsing failed" }; } return { valid: true, message: "Valid" }; @@ -366,6 +367,22 @@ function injectGlobalStyles() { ); } +// src/helpers/withTimeout.ts +async function withTimeout(promise, timeoutMs, operationName, timeoutMessage = "Operation timed out") { + let timeoutId; + const start = performance.now(); + const timeoutPromise = new Promise((_, reject) => { + timeoutId = setTimeout(() => reject(new Error(timeoutMessage)), timeoutMs); + }); + try { + return await Promise.race([promise, timeoutPromise]); + } finally { + clearTimeout(timeoutId); + const elapsed = performance.now() - start; + console.log(`[${operationName}] Execution time: ${elapsed.toFixed(2)} ms`); + } +} + // src/constants.ts var STATUS_COLORS = { SUCCESS: "#4caf50", @@ -475,6 +492,53 @@ var COMMAND_SCHEDULING = { P10_PRIORITY: 1900 // Lowest priority }; + +// src/helpers/executeShellCommand.ts +async function executeShellCommand({ + command, + args, + timeout = COMMAND_TIMEOUT +}) { + try { + return withTimeout( + fs.exec(command, args), + timeout, + [command, ...args].join(" ") + ); + } catch (err) { + const error = err; + return { stdout: "", stderr: error?.message, code: 0 }; + } +} + +// src/helpers/copyToClipboard.ts +function copyToClipboard(text) { + const textarea = document.createElement("textarea"); + textarea.value = text; + document.body.appendChild(textarea); + textarea.select(); + try { + document.execCommand("copy"); + return { + success: true, + message: "Copied!" + }; + } catch (err) { + const error = err; + return { + success: false, + message: `Failed to copy: ${error.message}` + }; + } finally { + document.body.removeChild(textarea); + } +} + +// src/helpers/maskIP.ts +function maskIP(ip = "") { + const ipv4Regex = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/; + return ip.replace(ipv4Regex, (_match, _p1, _p2, _p3, p4) => `XX.XX.XX.${p4}`); +} return baseclass.extend({ ALLOWED_WITH_RUSSIA_INSIDE, BOOTSTRAP_DNS_SERVER_OPTIONS, @@ -494,8 +558,11 @@ return baseclass.extend({ STATUS_COLORS, UPDATE_INTERVAL_OPTIONS, bulkValidate, + copyToClipboard, + executeShellCommand, getBaseUrl, injectGlobalStyles, + maskIP, parseValueList, validateDNS, validateDomain, @@ -507,5 +574,6 @@ return baseclass.extend({ validateSubnet, validateTrojanUrl, validateUrl, - validateVlessUrl + validateVlessUrl, + withTimeout }); From f69e3478c8828595c50d087600c20bfc5b6ae214 Mon Sep 17 00:00:00 2001 From: itdoginfo Date: Sat, 4 Oct 2025 12:47:39 +0300 Subject: [PATCH 27/49] CI: Add frontend workflow --- .github/workflows/frontend-ci.yml | 77 +++++++++++++++++++++++++++++++ 1 file changed, 77 insertions(+) create mode 100644 .github/workflows/frontend-ci.yml diff --git a/.github/workflows/frontend-ci.yml b/.github/workflows/frontend-ci.yml new file mode 100644 index 0000000..6a9878c --- /dev/null +++ b/.github/workflows/frontend-ci.yml @@ -0,0 +1,77 @@ +name: Frontend CI + +on: + pull_request: + paths: + - 'fe-app-podkop/**' + - '.github/workflows/frontend-ci.yml' + +jobs: + frontend-checks: + name: Frontend Quality Checks + runs-on: ubuntu-24.04 + defaults: + run: + working-directory: fe-app-podkop + + steps: + - name: Checkout code + uses: actions/checkout@v5.0.0 + + - name: Setup Node.js + uses: actions/setup-node@v5.0.0 + with: + node-version: '22' + + - name: Enable Corepack + run: corepack enable + + - name: Get yarn cache directory path + id: yarn-cache-dir-path + working-directory: fe-app-podkop + run: echo "dir=$(yarn config get cacheFolder)" >> $GITHUB_OUTPUT + + - name: Cache yarn dependencies + uses: actions/cache@v4.3.0 + with: + path: ${{ steps.yarn-cache-dir-path.outputs.dir }} + key: ${{ runner.os }}-yarn-${{ hashFiles('fe-app-podkop/yarn.lock') }} + restore-keys: | + ${{ runner.os }}-yarn- + + - name: Install dependencies + run: yarn install --frozen-lockfile + + - name: Check formatting + id: format + run: | + yarn format + if ! git diff --exit-code; then + echo "::error::Code is not formatted. Run 'yarn format' locally." + exit 1 + fi + + - name: Run linter + run: yarn lint --max-warnings=0 + + - name: Run tests + run: yarn test --run + + - name: Build project + id: build + run: | + yarn build + if ! git diff --exit-code; then + echo "::error::Build generated changes. Check build output." + exit 1 + fi + + - name: Summary + if: always() + run: | + echo "## Frontend CI Results" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "- ✅ Format check: ${{ steps.format.outcome }}" >> $GITHUB_STEP_SUMMARY + echo "- ✅ Lint check: ${{ job.status }}" >> $GITHUB_STEP_SUMMARY + echo "- ✅ Tests: ${{ job.status }}" >> $GITHUB_STEP_SUMMARY + echo "- ✅ Build: ${{ steps.build.outcome }}" >> $GITHUB_STEP_SUMMARY From 08f5b31d58fb4fc16388500d3c4184732c17236f Mon Sep 17 00:00:00 2001 From: itdoginfo Date: Sat, 4 Oct 2025 13:19:03 +0300 Subject: [PATCH 28/49] CI: Add great succces to summary --- .github/workflows/frontend-ci.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/frontend-ci.yml b/.github/workflows/frontend-ci.yml index 6a9878c..e1d1f39 100644 --- a/.github/workflows/frontend-ci.yml +++ b/.github/workflows/frontend-ci.yml @@ -75,3 +75,4 @@ jobs: echo "- ✅ Lint check: ${{ job.status }}" >> $GITHUB_STEP_SUMMARY echo "- ✅ Tests: ${{ job.status }}" >> $GITHUB_STEP_SUMMARY echo "- ✅ Build: ${{ steps.build.outcome }}" >> $GITHUB_STEP_SUMMARY + echo "![Success](https://cdn2.combot.org/boratbrat/webp/6xf09f988f.webp)" >> $GITHUB_STEP_SUMMARY \ No newline at end of file From eb60e6edec39a220c77da811e858f95248a110ed Mon Sep 17 00:00:00 2001 From: divocat Date: Sun, 5 Oct 2025 16:12:56 +0300 Subject: [PATCH 29/49] fix: run prettier for luci app js assets --- .../resources/view/podkop/additionalTab.js | 501 +++-- .../resources/view/podkop/configSection.js | 1191 ++++++----- .../resources/view/podkop/diagnosticTab.js | 1803 ++++++++++------- .../resources/view/podkop/utils.js | 205 +- 4 files changed, 2216 insertions(+), 1484 deletions(-) diff --git a/luci-app-podkop/htdocs/luci-static/resources/view/podkop/additionalTab.js b/luci-app-podkop/htdocs/luci-static/resources/view/podkop/additionalTab.js index 501de00..2686fb0 100644 --- a/luci-app-podkop/htdocs/luci-static/resources/view/podkop/additionalTab.js +++ b/luci-app-podkop/htdocs/luci-static/resources/view/podkop/additionalTab.js @@ -5,235 +5,358 @@ 'require view.podkop.main as main'; function createAdditionalSection(mainSection) { - let o = mainSection.tab('additional', _('Additional Settings')); + let o = mainSection.tab('additional', _('Additional Settings')); - o = mainSection.taboption('additional', form.Flag, 'yacd', _('Yacd enable'), `${main.getBaseUrl()}:9090/ui`); - o.default = '0'; - o.rmempty = false; - o.ucisection = 'main'; + o = mainSection.taboption( + 'additional', + form.Flag, + 'yacd', + _('Yacd enable'), + `${main.getBaseUrl()}:9090/ui`, + ); + o.default = '0'; + o.rmempty = false; + o.ucisection = 'main'; - o = mainSection.taboption('additional', form.Flag, 'exclude_ntp', _('Exclude NTP'), _('Allows you to exclude NTP protocol traffic from the tunnel')); - o.default = '0'; - o.rmempty = false; - o.ucisection = 'main'; + o = mainSection.taboption( + 'additional', + form.Flag, + 'exclude_ntp', + _('Exclude NTP'), + _('Allows you to exclude NTP protocol traffic from the tunnel'), + ); + o.default = '0'; + o.rmempty = false; + o.ucisection = 'main'; - o = mainSection.taboption('additional', form.Flag, 'quic_disable', _('QUIC disable'), _('For issues with the video stream')); - o.default = '0'; - o.rmempty = false; - o.ucisection = 'main'; + o = mainSection.taboption( + 'additional', + form.Flag, + 'quic_disable', + _('QUIC disable'), + _('For issues with the video stream'), + ); + o.default = '0'; + o.rmempty = false; + o.ucisection = 'main'; - o = mainSection.taboption('additional', form.ListValue, 'update_interval', _('List Update Frequency'), _('Select how often the lists will be updated')); - Object.entries(main.UPDATE_INTERVAL_OPTIONS).forEach(([key, label]) => { - o.value(key, _(label)); - }); - o.default = '1d'; - o.rmempty = false; - o.ucisection = 'main'; + o = mainSection.taboption( + 'additional', + form.ListValue, + 'update_interval', + _('List Update Frequency'), + _('Select how often the lists will be updated'), + ); + Object.entries(main.UPDATE_INTERVAL_OPTIONS).forEach(([key, label]) => { + o.value(key, _(label)); + }); + o.default = '1d'; + o.rmempty = false; + o.ucisection = 'main'; - o = mainSection.taboption('additional', form.ListValue, 'dns_type', _('DNS Protocol Type'), _('Select DNS protocol to use')); - o.value('doh', _('DNS over HTTPS (DoH)')); - o.value('dot', _('DNS over TLS (DoT)')); - o.value('udp', _('UDP (Unprotected DNS)')); - o.default = 'udp'; - o.rmempty = false; - o.ucisection = 'main'; + o = mainSection.taboption( + 'additional', + form.ListValue, + 'dns_type', + _('DNS Protocol Type'), + _('Select DNS protocol to use'), + ); + o.value('doh', _('DNS over HTTPS (DoH)')); + o.value('dot', _('DNS over TLS (DoT)')); + o.value('udp', _('UDP (Unprotected DNS)')); + o.default = 'udp'; + o.rmempty = false; + o.ucisection = 'main'; - o = mainSection.taboption('additional', form.Value, 'dns_server', _('DNS Server'), _('Select or enter DNS server address')); - Object.entries(main.DNS_SERVER_OPTIONS).forEach(([key, label]) => { - o.value(key, _(label)); - }); - o.default = '8.8.8.8'; - o.rmempty = false; - o.ucisection = 'main'; - o.validate = function (section_id, value) { - const validation = main.validateDNS(value); + o = mainSection.taboption( + 'additional', + form.Value, + 'dns_server', + _('DNS Server'), + _('Select or enter DNS server address'), + ); + Object.entries(main.DNS_SERVER_OPTIONS).forEach(([key, label]) => { + o.value(key, _(label)); + }); + o.default = '8.8.8.8'; + o.rmempty = false; + o.ucisection = 'main'; + o.validate = function (section_id, value) { + const validation = main.validateDNS(value); - if (validation.valid) { - return true; - } + if (validation.valid) { + return true; + } - return _(validation.message); - }; + return _(validation.message); + }; - o = mainSection.taboption('additional', form.Value, 'bootstrap_dns_server', _('Bootstrap DNS server'), _('The DNS server used to look up the IP address of an upstream DNS server')); - Object.entries(main.BOOTSTRAP_DNS_SERVER_OPTIONS).forEach(([key, label]) => { - o.value(key, _(label)); - }); - o.default = '77.88.8.8'; - o.rmempty = false; - o.ucisection = 'main'; - o.validate = function (section_id, value) { - const validation = main.validateDNS(value); + o = mainSection.taboption( + 'additional', + form.Value, + 'bootstrap_dns_server', + _('Bootstrap DNS server'), + _( + 'The DNS server used to look up the IP address of an upstream DNS server', + ), + ); + Object.entries(main.BOOTSTRAP_DNS_SERVER_OPTIONS).forEach(([key, label]) => { + o.value(key, _(label)); + }); + o.default = '77.88.8.8'; + o.rmempty = false; + o.ucisection = 'main'; + o.validate = function (section_id, value) { + const validation = main.validateDNS(value); - if (validation.valid) { - return true; - } + if (validation.valid) { + return true; + } - return _(validation.message); - }; + return _(validation.message); + }; - o = mainSection.taboption('additional', form.Value, 'dns_rewrite_ttl', _('DNS Rewrite TTL'), _('Time in seconds for DNS record caching (default: 60)')); - o.default = '60'; - o.rmempty = false; - o.ucisection = 'main'; - o.validate = function (section_id, value) { - if (!value) { - return _('TTL value cannot be empty'); - } + o = mainSection.taboption( + 'additional', + form.Value, + 'dns_rewrite_ttl', + _('DNS Rewrite TTL'), + _('Time in seconds for DNS record caching (default: 60)'), + ); + o.default = '60'; + o.rmempty = false; + o.ucisection = 'main'; + o.validate = function (section_id, value) { + if (!value) { + return _('TTL value cannot be empty'); + } - const ttl = parseInt(value); - if (isNaN(ttl) || ttl < 0) { - return _('TTL must be a positive number'); - } + const ttl = parseInt(value); + if (isNaN(ttl) || ttl < 0) { + return _('TTL must be a positive number'); + } - return true; - }; + return true; + }; - o = mainSection.taboption('additional', form.ListValue, 'config_path', _('Config File Path'), _('Select path for sing-box config file. Change this ONLY if you know what you are doing')); - o.value('/etc/sing-box/config.json', 'Flash (/etc/sing-box/config.json)'); - o.value('/tmp/sing-box/config.json', 'RAM (/tmp/sing-box/config.json)'); - o.default = '/etc/sing-box/config.json'; - o.rmempty = false; - o.ucisection = 'main'; + o = mainSection.taboption( + 'additional', + form.ListValue, + 'config_path', + _('Config File Path'), + _( + 'Select path for sing-box config file. Change this ONLY if you know what you are doing', + ), + ); + o.value('/etc/sing-box/config.json', 'Flash (/etc/sing-box/config.json)'); + o.value('/tmp/sing-box/config.json', 'RAM (/tmp/sing-box/config.json)'); + o.default = '/etc/sing-box/config.json'; + o.rmempty = false; + o.ucisection = 'main'; - o = mainSection.taboption('additional', form.Value, 'cache_path', _('Cache File Path'), _('Select or enter path for sing-box cache file. Change this ONLY if you know what you are doing')); - o.value('/tmp/sing-box/cache.db', 'RAM (/tmp/sing-box/cache.db)'); - o.value('/usr/share/sing-box/cache.db', 'Flash (/usr/share/sing-box/cache.db)'); - o.default = '/tmp/sing-box/cache.db'; - o.rmempty = false; - o.ucisection = 'main'; - o.validate = function (section_id, value) { - if (!value) { - return _('Cache file path cannot be empty'); - } + o = mainSection.taboption( + 'additional', + form.Value, + 'cache_path', + _('Cache File Path'), + _( + 'Select or enter path for sing-box cache file. Change this ONLY if you know what you are doing', + ), + ); + o.value('/tmp/sing-box/cache.db', 'RAM (/tmp/sing-box/cache.db)'); + o.value( + '/usr/share/sing-box/cache.db', + 'Flash (/usr/share/sing-box/cache.db)', + ); + o.default = '/tmp/sing-box/cache.db'; + o.rmempty = false; + o.ucisection = 'main'; + o.validate = function (section_id, value) { + if (!value) { + return _('Cache file path cannot be empty'); + } - if (!value.startsWith('/')) { - return _('Path must be absolute (start with /)'); - } + if (!value.startsWith('/')) { + return _('Path must be absolute (start with /)'); + } - if (!value.endsWith('cache.db')) { - return _('Path must end with cache.db'); - } + if (!value.endsWith('cache.db')) { + return _('Path must end with cache.db'); + } - const parts = value.split('/').filter(Boolean); - if (parts.length < 2) { - return _('Path must contain at least one directory (like /tmp/cache.db)'); - } + const parts = value.split('/').filter(Boolean); + if (parts.length < 2) { + return _('Path must contain at least one directory (like /tmp/cache.db)'); + } - return true; - }; + return true; + }; - o = mainSection.taboption('additional', widgets.DeviceSelect, 'iface', _('Source Network Interface'), _('Select the network interface from which the traffic will originate')); - o.ucisection = 'main'; - o.default = 'br-lan'; - o.noaliases = true; - o.nobridges = false; - o.noinactive = false; - o.multiple = true; - o.filter = function (section_id, value) { - // Block specific interface names from being selectable - const blocked = ['wan', 'phy0-ap0', 'phy1-ap0', 'pppoe-wan']; - if (blocked.includes(value)) { - return false; - } + o = mainSection.taboption( + 'additional', + widgets.DeviceSelect, + 'iface', + _('Source Network Interface'), + _('Select the network interface from which the traffic will originate'), + ); + o.ucisection = 'main'; + o.default = 'br-lan'; + o.noaliases = true; + o.nobridges = false; + o.noinactive = false; + o.multiple = true; + o.filter = function (section_id, value) { + // Block specific interface names from being selectable + const blocked = ['wan', 'phy0-ap0', 'phy1-ap0', 'pppoe-wan']; + if (blocked.includes(value)) { + return false; + } - // Try to find the device object by its name - const device = this.devices.find(dev => dev.getName() === value); + // Try to find the device object by its name + const device = this.devices.find((dev) => dev.getName() === value); - // If no device is found, allow the value - if (!device) { - return true; - } + // If no device is found, allow the value + if (!device) { + return true; + } - // Check the type of the device - const type = device.getType(); + // Check the type of the device + const type = device.getType(); - // Consider any Wi-Fi / wireless / wlan device as invalid - const isWireless = - type === 'wifi' || type === 'wireless' || type.includes('wlan'); + // Consider any Wi-Fi / wireless / wlan device as invalid + const isWireless = + type === 'wifi' || type === 'wireless' || type.includes('wlan'); - // Allow only non-wireless devices - return !isWireless; - }; + // Allow only non-wireless devices + return !isWireless; + }; - o = mainSection.taboption('additional', form.Flag, 'mon_restart_ifaces', _('Interface monitoring'), _('Interface monitoring for bad WAN')); - o.default = '0'; - o.rmempty = false; - o.ucisection = 'main'; + o = mainSection.taboption( + 'additional', + form.Flag, + 'mon_restart_ifaces', + _('Interface monitoring'), + _('Interface monitoring for bad WAN'), + ); + o.default = '0'; + o.rmempty = false; + o.ucisection = 'main'; - o = mainSection.taboption('additional', widgets.NetworkSelect, 'restart_ifaces', _('Interface for monitoring'), _('Select the WAN interfaces to be monitored')); - o.ucisection = 'main'; - o.depends('mon_restart_ifaces', '1'); - o.multiple = true; - o.filter = function (section_id, value) { - // Reject if the value is in the blocked list ['lan', 'loopback'] - if (['lan', 'loopback'].includes(value)) { - return false; - } + o = mainSection.taboption( + 'additional', + widgets.NetworkSelect, + 'restart_ifaces', + _('Interface for monitoring'), + _('Select the WAN interfaces to be monitored'), + ); + o.ucisection = 'main'; + o.depends('mon_restart_ifaces', '1'); + o.multiple = true; + o.filter = function (section_id, value) { + // Reject if the value is in the blocked list ['lan', 'loopback'] + if (['lan', 'loopback'].includes(value)) { + return false; + } - // Reject if the value starts with '@' (means it's an alias/reference) - if (value.startsWith('@')) { - return false; - } + // Reject if the value starts with '@' (means it's an alias/reference) + if (value.startsWith('@')) { + return false; + } - // Otherwise allow it - return true; - }; + // Otherwise allow it + return true; + }; - o = mainSection.taboption('additional', form.Value, 'procd_reload_delay', _('Interface Monitoring Delay'), _('Delay in milliseconds before reloading podkop after interface UP')); - o.ucisection = 'main'; - o.depends('mon_restart_ifaces', '1'); - o.default = '2000'; - o.rmempty = false; - o.validate = function (section_id, value) { - if (!value) { - return _('Delay value cannot be empty'); - } - return true; - }; + o = mainSection.taboption( + 'additional', + form.Value, + 'procd_reload_delay', + _('Interface Monitoring Delay'), + _('Delay in milliseconds before reloading podkop after interface UP'), + ); + o.ucisection = 'main'; + o.depends('mon_restart_ifaces', '1'); + o.default = '2000'; + o.rmempty = false; + o.validate = function (section_id, value) { + if (!value) { + return _('Delay value cannot be empty'); + } + return true; + }; - o = mainSection.taboption('additional', form.Flag, 'dont_touch_dhcp', _('Dont touch my DHCP!'), _('Podkop will not change the DHCP config')); - o.default = '0'; - o.rmempty = false; - o.ucisection = 'main'; + o = mainSection.taboption( + 'additional', + form.Flag, + 'dont_touch_dhcp', + _('Dont touch my DHCP!'), + _('Podkop will not change the DHCP config'), + ); + o.default = '0'; + o.rmempty = false; + o.ucisection = 'main'; - o = mainSection.taboption('additional', form.Flag, 'detour', _('Proxy download of lists'), _('Downloading all lists via main Proxy/VPN')); - o.default = '0'; - o.rmempty = false; - o.ucisection = 'main'; + o = mainSection.taboption( + 'additional', + form.Flag, + 'detour', + _('Proxy download of lists'), + _('Downloading all lists via main Proxy/VPN'), + ); + o.default = '0'; + o.rmempty = false; + o.ucisection = 'main'; - // Extra IPs and exclusions (main section) - o = mainSection.taboption('basic', form.Flag, 'exclude_from_ip_enabled', _('IP for exclusion'), _('Specify local IP addresses that will never use the configured route')); - o.default = '0'; - o.rmempty = false; - o.ucisection = 'main'; + // Extra IPs and exclusions (main section) + o = mainSection.taboption( + 'basic', + form.Flag, + 'exclude_from_ip_enabled', + _('IP for exclusion'), + _('Specify local IP addresses that will never use the configured route'), + ); + o.default = '0'; + o.rmempty = false; + o.ucisection = 'main'; - o = mainSection.taboption('basic', form.DynamicList, 'exclude_traffic_ip', _('Local IPs'), _('Enter valid IPv4 addresses')); - o.placeholder = 'IP'; - o.depends('exclude_from_ip_enabled', '1'); - o.rmempty = false; - o.ucisection = 'main'; - o.validate = function (section_id, value) { - // Optional - if (!value || value.length === 0) { - return true - } + o = mainSection.taboption( + 'basic', + form.DynamicList, + 'exclude_traffic_ip', + _('Local IPs'), + _('Enter valid IPv4 addresses'), + ); + o.placeholder = 'IP'; + o.depends('exclude_from_ip_enabled', '1'); + o.rmempty = false; + o.ucisection = 'main'; + o.validate = function (section_id, value) { + // Optional + if (!value || value.length === 0) { + return true; + } - const validation = main.validateIPV4(value); + const validation = main.validateIPV4(value); - if (validation.valid) { - return true; - } + if (validation.valid) { + return true; + } - return _(validation.message) - }; + return _(validation.message); + }; - o = mainSection.taboption('basic', form.Flag, 'socks5', _('Mixed enable'), _('Browser port: 2080')); - o.default = '0'; - o.rmempty = false; - o.ucisection = 'main'; + o = mainSection.taboption( + 'basic', + form.Flag, + 'socks5', + _('Mixed enable'), + _('Browser port: 2080'), + ); + o.default = '0'; + o.rmempty = false; + o.ucisection = 'main'; } return baseclass.extend({ - createAdditionalSection + createAdditionalSection, }); diff --git a/luci-app-podkop/htdocs/luci-static/resources/view/podkop/configSection.js b/luci-app-podkop/htdocs/luci-static/resources/view/podkop/configSection.js index 0c215c6..08d8fae 100644 --- a/luci-app-podkop/htdocs/luci-static/resources/view/podkop/configSection.js +++ b/luci-app-podkop/htdocs/luci-static/resources/view/podkop/configSection.js @@ -6,541 +6,772 @@ 'require view.podkop.main as main'; 'require tools.widgets as widgets'; - function createConfigSection(section) { - const s = section; + const s = section; - let o = s.tab('basic', _('Basic Settings')); + let o = s.tab('basic', _('Basic Settings')); - o = s.taboption('basic', form.ListValue, 'mode', _('Connection Type'), _('Select between VPN and Proxy connection methods for traffic routing')); - o.value('proxy', ('Proxy')); - o.value('vpn', ('VPN')); - o.value('block', ('Block')); - o.ucisection = s.section; + o = s.taboption( + 'basic', + form.ListValue, + 'mode', + _('Connection Type'), + _('Select between VPN and Proxy connection methods for traffic routing'), + ); + o.value('proxy', 'Proxy'); + o.value('vpn', 'VPN'); + o.value('block', 'Block'); + o.ucisection = s.section; - o = s.taboption('basic', form.ListValue, 'proxy_config_type', _('Configuration Type'), _('Select how to configure the proxy')); - o.value('url', _('Connection URL')); - o.value('outbound', _('Outbound Config')); - o.value('urltest', _('URLTest')); - o.default = 'url'; - o.depends('mode', 'proxy'); - o.ucisection = s.section; + o = s.taboption( + 'basic', + form.ListValue, + 'proxy_config_type', + _('Configuration Type'), + _('Select how to configure the proxy'), + ); + o.value('url', _('Connection URL')); + o.value('outbound', _('Outbound Config')); + o.value('urltest', _('URLTest')); + o.default = 'url'; + o.depends('mode', 'proxy'); + o.ucisection = s.section; - o = s.taboption('basic', form.TextValue, 'proxy_string', _('Proxy Configuration URL'), ''); - o.depends('proxy_config_type', 'url'); - o.rows = 5; - o.wrap = 'soft'; - o.textarea = true; - o.rmempty = false; - o.ucisection = s.section; - o.sectionDescriptions = new Map(); - o.placeholder = 'vless://uuid@server:port?type=tcp&security=tls#main\n// backup ss://method:pass@server:port\n// backup2 vless://uuid@server:port?type=grpc&security=reality#alt\n// backup3 trojan://04agAQapcl@127.0.0.1:33641?type=tcp&security=none#trojan-tcp-none'; + o = s.taboption( + 'basic', + form.TextValue, + 'proxy_string', + _('Proxy Configuration URL'), + '', + ); + o.depends('proxy_config_type', 'url'); + o.rows = 5; + o.wrap = 'soft'; + o.textarea = true; + o.rmempty = false; + o.ucisection = s.section; + o.sectionDescriptions = new Map(); + o.placeholder = + 'vless://uuid@server:port?type=tcp&security=tls#main\n// backup ss://method:pass@server:port\n// backup2 vless://uuid@server:port?type=grpc&security=reality#alt\n// backup3 trojan://04agAQapcl@127.0.0.1:33641?type=tcp&security=none#trojan-tcp-none'; - o.renderWidget = function (section_id, option_index, cfgvalue) { - const original = form.TextValue.prototype.renderWidget.apply(this, [section_id, option_index, cfgvalue]); - const container = E('div', {}); - container.appendChild(original); + o.renderWidget = function (section_id, option_index, cfgvalue) { + const original = form.TextValue.prototype.renderWidget.apply(this, [ + section_id, + option_index, + cfgvalue, + ]); + const container = E('div', {}); + container.appendChild(original); - if (cfgvalue) { - try { - const activeConfig = cfgvalue.split('\n') - .map(line => line.trim()) - .find(line => line && !line.startsWith('//')); + if (cfgvalue) { + try { + const activeConfig = cfgvalue + .split('\n') + .map((line) => line.trim()) + .find((line) => line && !line.startsWith('//')); - if (activeConfig) { - if (activeConfig.includes('#')) { - const label = activeConfig.split('#').pop(); - if (label && label.trim()) { - const decodedLabel = decodeURIComponent(label); - const descDiv = E('div', { 'class': 'cbi-value-description' }, _('Current config: ') + decodedLabel); - container.appendChild(descDiv); - } else { - const descDiv = E('div', { 'class': 'cbi-value-description' }, _('Config without description')); - container.appendChild(descDiv); - } - } else { - const descDiv = E('div', { 'class': 'cbi-value-description' }, _('Config without description')); - container.appendChild(descDiv); - } - } - } catch (e) { - console.error('Error parsing config label:', e); - const descDiv = E('div', { 'class': 'cbi-value-description' }, _('Config without description')); - container.appendChild(descDiv); + if (activeConfig) { + if (activeConfig.includes('#')) { + const label = activeConfig.split('#').pop(); + if (label && label.trim()) { + const decodedLabel = decodeURIComponent(label); + const descDiv = E( + 'div', + { class: 'cbi-value-description' }, + _('Current config: ') + decodedLabel, + ); + container.appendChild(descDiv); + } else { + const descDiv = E( + 'div', + { class: 'cbi-value-description' }, + _('Config without description'), + ); + container.appendChild(descDiv); } - } else { - const defaultDesc = E('div', { 'class': 'cbi-value-description' }, - _('Enter connection string starting with vless:// or ss:// for proxy configuration. Add comments with // for backup configs')); - container.appendChild(defaultDesc); - } - - return container; - }; - - o.validate = function (section_id, value) { - // Optional - if (!value || value.length === 0) { - return true - } - - try { - const activeConfig = value.split('\n') - .map(line => line.trim()) - .find(line => line && !line.startsWith('//')); - - if (!activeConfig) { - return _('No active configuration found. At least one non-commented line is required.'); - } - - const validation = main.validateProxyUrl(activeConfig); - - if (validation.valid) { - return true; - } - - return _(validation.message) - } catch (e) { - return `${_('Invalid URL format:')} ${e?.message}`; - } - }; - - o = s.taboption('basic', form.TextValue, 'outbound_json', _('Outbound Configuration'), _('Enter complete outbound configuration in JSON format')); - o.depends('proxy_config_type', 'outbound'); - o.rows = 10; - o.ucisection = s.section; - o.validate = function (section_id, value) { - // Optional - if (!value || value.length === 0) { - return true - } - - const validation = main.validateOutboundJson(value); - - if (validation.valid) { - return true; - } - - return _(validation.message) - }; - - o = s.taboption('basic', form.DynamicList, 'urltest_proxy_links', _('URLTest Proxy Links')); - o.depends('proxy_config_type', 'urltest'); - o.placeholder = 'vless://, ss://, trojan:// links'; - o.rmempty = false; - o.textarea = true; - o.rows = 3; - o.wrap = 'soft'; - o.validate = function (section_id, value) { - // Optional - if (!value || value.length === 0) { - return true - } - - const validation = main.validateProxyUrl(value); - - if (validation.valid) { - return true; - } - - return _(validation.message) - }; - - o = s.taboption('basic', form.Flag, 'ss_uot', _('Shadowsocks UDP over TCP'), _('Apply for SS2022')); - o.default = '0'; - o.depends('mode', 'proxy'); - o.rmempty = false; - o.ucisection = s.section; - - o = s.taboption('basic', widgets.DeviceSelect, 'interface', _('Network Interface'), _('Select network interface for VPN connection')); - o.depends('mode', 'vpn'); - o.ucisection = s.section; - o.noaliases = true; - o.nobridges = false; - o.noinactive = false; - o.filter = function (section_id, value) { - // Blocked interface names that should never be selectable - const blockedInterfaces = [ - 'br-lan', - 'eth0', - 'eth1', - 'wan', - 'phy0-ap0', - 'phy1-ap0', - 'pppoe-wan', - 'lan', - ]; - - // Reject immediately if the value matches any blocked interface - if (blockedInterfaces.includes(value)) { - return false; - } - - // Try to find the device object with the given name - const device = this.devices.find(dev => dev.getName() === value); - - // If no device is found, allow the value - if (!device) { - return true; - } - - // Get the device type (e.g., "wifi", "ethernet", etc.) - const type = device.getType(); - - // Reject wireless-related devices - const isWireless = - type === 'wifi' || type === 'wireless' || type.includes('wlan'); - - return !isWireless; - }; - - o = s.taboption('basic', form.Flag, 'domain_resolver_enabled', _('Domain Resolver'), _('Enable built-in DNS resolver for domains handled by this section')); - o.default = '0'; - o.rmempty = false; - o.depends('mode', 'vpn'); - o.ucisection = s.section; - - o = s.taboption('basic', form.ListValue, 'domain_resolver_dns_type', _('DNS Protocol Type'), _('Select the DNS protocol type for the domain resolver')); - o.value('doh', _('DNS over HTTPS (DoH)')); - o.value('dot', _('DNS over TLS (DoT)')); - o.value('udp', _('UDP (Unprotected DNS)')); - o.default = 'udp'; - o.rmempty = false; - o.depends('domain_resolver_enabled', '1'); - o.ucisection = s.section; - - o = s.taboption('basic', form.Value, 'domain_resolver_dns_server', _('DNS Server'), _('Select or enter DNS server address')); - Object.entries(main.DNS_SERVER_OPTIONS).forEach(([key, label]) => { - o.value(key, _(label)); - }); - o.default = '8.8.8.8'; - o.rmempty = false; - o.depends('domain_resolver_enabled', '1'); - o.ucisection = s.section; - o.validate = function (section_id, value) { - const validation = main.validateDNS(value); - - if (validation.valid) { - return true; - } - - return _(validation.message) - }; - - o = s.taboption('basic', form.Flag, 'community_lists_enabled', _('Community Lists')); - o.default = '0'; - o.rmempty = false; - o.ucisection = s.section; - - o = s.taboption('basic', form.DynamicList, 'community_lists', _('Service List'), _('Select predefined service for routing') + ' github.com/itdoginfo/allow-domains'); - o.placeholder = 'Service list'; - Object.entries(main.DOMAIN_LIST_OPTIONS).forEach(([key, label]) => { - o.value(key, _(label)); - }); - o.depends('community_lists_enabled', '1'); - o.rmempty = false; - o.ucisection = s.section; - - let lastValues = []; - let isProcessing = false; - - o.onchange = function (ev, section_id, value) { - if (isProcessing) return; - isProcessing = true; - - try { - const values = Array.isArray(value) ? value : [value]; - let newValues = [...values]; - let notifications = []; - - const selectedRegionalOptions = main.REGIONAL_OPTIONS.filter(opt => newValues.includes(opt)); - - if (selectedRegionalOptions.length > 1) { - const lastSelected = selectedRegionalOptions[selectedRegionalOptions.length - 1]; - const removedRegions = selectedRegionalOptions.slice(0, -1); - newValues = newValues.filter(v => v === lastSelected || !main.REGIONAL_OPTIONS.includes(v)); - notifications.push(E('p', { class: 'alert-message warning' }, [ - E('strong', {}, _('Regional options cannot be used together')), E('br'), - _('Warning: %s cannot be used together with %s. Previous selections have been removed.') - .format(removedRegions.join(', '), lastSelected) - ])); - } - - if (newValues.includes('russia_inside')) { - const removedServices = newValues.filter(v => !main.ALLOWED_WITH_RUSSIA_INSIDE.includes(v)); - if (removedServices.length > 0) { - newValues = newValues.filter(v => main.ALLOWED_WITH_RUSSIA_INSIDE.includes(v)); - notifications.push(E('p', { class: 'alert-message warning' }, [ - E('strong', {}, _('Russia inside restrictions')), E('br'), - _('Warning: Russia inside can only be used with %s. %s already in Russia inside and have been removed from selection.') - .format( - main.ALLOWED_WITH_RUSSIA_INSIDE.map(key => main.DOMAIN_LIST_OPTIONS[key]).filter(label => label !== 'Russia inside').join(', '), - removedServices.join(', ') - ) - ])); - } - } - - if (JSON.stringify(newValues.sort()) !== JSON.stringify(values.sort())) { - this.getUIElement(section_id).setValue(newValues); - } - - notifications.forEach(notification => ui.addNotification(null, notification)); - lastValues = newValues; - } catch (e) { - console.error('Error in onchange handler:', e); - } finally { - isProcessing = false; - } - }; - - o = s.taboption('basic', form.ListValue, 'user_domain_list_type', _('User Domain List Type'), _('Select how to add your custom domains')); - o.value('disabled', _('Disabled')); - o.value('dynamic', _('Dynamic List')); - o.value('text', _('Text List')); - o.default = 'disabled'; - o.rmempty = false; - o.ucisection = s.section; - - o = s.taboption('basic', form.DynamicList, 'user_domains', _('User Domains'), _('Enter domain names without protocols (example: sub.example.com or example.com)')); - o.placeholder = 'Domains list'; - o.depends('user_domain_list_type', 'dynamic'); - o.rmempty = false; - o.ucisection = s.section; - o.validate = function (section_id, value) { - // Optional - if (!value || value.length === 0) { - return true - } - - const validation = main.validateDomain(value); - - if (validation.valid) { - return true; - } - - return _(validation.message) - }; - - o = s.taboption('basic', form.TextValue, 'user_domains_text', _('User Domains List'), _('Enter domain names separated by comma, space or newline. You can add comments after //')); - o.placeholder = 'example.com, sub.example.com\n// Social networks\ndomain.com test.com // personal domains'; - o.depends('user_domain_list_type', 'text'); - o.rows = 8; - o.rmempty = false; - o.ucisection = s.section; - o.validate = function (section_id, value) { - // Optional - if (!value || value.length === 0) { - return true - } - - const domains = main.parseValueList(value); - - if (!domains.length) { - return _( - 'At least one valid domain must be specified. Comments-only content is not allowed.' + } else { + const descDiv = E( + 'div', + { class: 'cbi-value-description' }, + _('Config without description'), ); + container.appendChild(descDiv); + } } + } catch (e) { + console.error('Error parsing config label:', e); + const descDiv = E( + 'div', + { class: 'cbi-value-description' }, + _('Config without description'), + ); + container.appendChild(descDiv); + } + } else { + const defaultDesc = E( + 'div', + { class: 'cbi-value-description' }, + _( + 'Enter connection string starting with vless:// or ss:// for proxy configuration. Add comments with // for backup configs', + ), + ); + container.appendChild(defaultDesc); + } - const { valid, results } = main.bulkValidate(domains, main.validateDomain); + return container; + }; - if (!valid) { - const errors = results - .filter(validation => !validation.valid) // Leave only failed validations - .map((validation) => _(`${validation.value}: ${validation.message}`)) // Collect validation errors + o.validate = function (section_id, value) { + // Optional + if (!value || value.length === 0) { + return true; + } - return [_('Validation errors:'), ...errors].join('\n'); - } + try { + const activeConfig = value + .split('\n') + .map((line) => line.trim()) + .find((line) => line && !line.startsWith('//')); + if (!activeConfig) { + return _( + 'No active configuration found. At least one non-commented line is required.', + ); + } + + const validation = main.validateProxyUrl(activeConfig); + + if (validation.valid) { return true; - }; + } - o = s.taboption('basic', form.Flag, 'local_domain_lists_enabled', _('Local Domain Lists'), _('Use the list from the router filesystem')); - o.default = '0'; - o.rmempty = false; - o.ucisection = s.section; + return _(validation.message); + } catch (e) { + return `${_('Invalid URL format:')} ${e?.message}`; + } + }; - o = s.taboption('basic', form.DynamicList, 'local_domain_lists', _('Local Domain List Paths'), _('Enter the list file path')); - o.placeholder = '/path/file.lst'; - o.depends('local_domain_lists_enabled', '1'); - o.rmempty = false; - o.ucisection = s.section; - o.validate = function (section_id, value) { - // Optional - if (!value || value.length === 0) { - return true + o = s.taboption( + 'basic', + form.TextValue, + 'outbound_json', + _('Outbound Configuration'), + _('Enter complete outbound configuration in JSON format'), + ); + o.depends('proxy_config_type', 'outbound'); + o.rows = 10; + o.ucisection = s.section; + o.validate = function (section_id, value) { + // Optional + if (!value || value.length === 0) { + return true; + } + + const validation = main.validateOutboundJson(value); + + if (validation.valid) { + return true; + } + + return _(validation.message); + }; + + o = s.taboption( + 'basic', + form.DynamicList, + 'urltest_proxy_links', + _('URLTest Proxy Links'), + ); + o.depends('proxy_config_type', 'urltest'); + o.placeholder = 'vless://, ss://, trojan:// links'; + o.rmempty = false; + o.textarea = true; + o.rows = 3; + o.wrap = 'soft'; + o.validate = function (section_id, value) { + // Optional + if (!value || value.length === 0) { + return true; + } + + const validation = main.validateProxyUrl(value); + + if (validation.valid) { + return true; + } + + return _(validation.message); + }; + + o = s.taboption( + 'basic', + form.Flag, + 'ss_uot', + _('Shadowsocks UDP over TCP'), + _('Apply for SS2022'), + ); + o.default = '0'; + o.depends('mode', 'proxy'); + o.rmempty = false; + o.ucisection = s.section; + + o = s.taboption( + 'basic', + widgets.DeviceSelect, + 'interface', + _('Network Interface'), + _('Select network interface for VPN connection'), + ); + o.depends('mode', 'vpn'); + o.ucisection = s.section; + o.noaliases = true; + o.nobridges = false; + o.noinactive = false; + o.filter = function (section_id, value) { + // Blocked interface names that should never be selectable + const blockedInterfaces = [ + 'br-lan', + 'eth0', + 'eth1', + 'wan', + 'phy0-ap0', + 'phy1-ap0', + 'pppoe-wan', + 'lan', + ]; + + // Reject immediately if the value matches any blocked interface + if (blockedInterfaces.includes(value)) { + return false; + } + + // Try to find the device object with the given name + const device = this.devices.find((dev) => dev.getName() === value); + + // If no device is found, allow the value + if (!device) { + return true; + } + + // Get the device type (e.g., "wifi", "ethernet", etc.) + const type = device.getType(); + + // Reject wireless-related devices + const isWireless = + type === 'wifi' || type === 'wireless' || type.includes('wlan'); + + return !isWireless; + }; + + o = s.taboption( + 'basic', + form.Flag, + 'domain_resolver_enabled', + _('Domain Resolver'), + _('Enable built-in DNS resolver for domains handled by this section'), + ); + o.default = '0'; + o.rmempty = false; + o.depends('mode', 'vpn'); + o.ucisection = s.section; + + o = s.taboption( + 'basic', + form.ListValue, + 'domain_resolver_dns_type', + _('DNS Protocol Type'), + _('Select the DNS protocol type for the domain resolver'), + ); + o.value('doh', _('DNS over HTTPS (DoH)')); + o.value('dot', _('DNS over TLS (DoT)')); + o.value('udp', _('UDP (Unprotected DNS)')); + o.default = 'udp'; + o.rmempty = false; + o.depends('domain_resolver_enabled', '1'); + o.ucisection = s.section; + + o = s.taboption( + 'basic', + form.Value, + 'domain_resolver_dns_server', + _('DNS Server'), + _('Select or enter DNS server address'), + ); + Object.entries(main.DNS_SERVER_OPTIONS).forEach(([key, label]) => { + o.value(key, _(label)); + }); + o.default = '8.8.8.8'; + o.rmempty = false; + o.depends('domain_resolver_enabled', '1'); + o.ucisection = s.section; + o.validate = function (section_id, value) { + const validation = main.validateDNS(value); + + if (validation.valid) { + return true; + } + + return _(validation.message); + }; + + o = s.taboption( + 'basic', + form.Flag, + 'community_lists_enabled', + _('Community Lists'), + ); + o.default = '0'; + o.rmempty = false; + o.ucisection = s.section; + + o = s.taboption( + 'basic', + form.DynamicList, + 'community_lists', + _('Service List'), + _('Select predefined service for routing') + + ' github.com/itdoginfo/allow-domains', + ); + o.placeholder = 'Service list'; + Object.entries(main.DOMAIN_LIST_OPTIONS).forEach(([key, label]) => { + o.value(key, _(label)); + }); + o.depends('community_lists_enabled', '1'); + o.rmempty = false; + o.ucisection = s.section; + + let lastValues = []; + let isProcessing = false; + + o.onchange = function (ev, section_id, value) { + if (isProcessing) return; + isProcessing = true; + + try { + const values = Array.isArray(value) ? value : [value]; + let newValues = [...values]; + let notifications = []; + + const selectedRegionalOptions = main.REGIONAL_OPTIONS.filter((opt) => + newValues.includes(opt), + ); + + if (selectedRegionalOptions.length > 1) { + const lastSelected = + selectedRegionalOptions[selectedRegionalOptions.length - 1]; + const removedRegions = selectedRegionalOptions.slice(0, -1); + newValues = newValues.filter( + (v) => v === lastSelected || !main.REGIONAL_OPTIONS.includes(v), + ); + notifications.push( + E('p', { class: 'alert-message warning' }, [ + E('strong', {}, _('Regional options cannot be used together')), + E('br'), + _( + 'Warning: %s cannot be used together with %s. Previous selections have been removed.', + ).format(removedRegions.join(', '), lastSelected), + ]), + ); + } + + if (newValues.includes('russia_inside')) { + const removedServices = newValues.filter( + (v) => !main.ALLOWED_WITH_RUSSIA_INSIDE.includes(v), + ); + if (removedServices.length > 0) { + newValues = newValues.filter((v) => + main.ALLOWED_WITH_RUSSIA_INSIDE.includes(v), + ); + notifications.push( + E('p', { class: 'alert-message warning' }, [ + E('strong', {}, _('Russia inside restrictions')), + E('br'), + _( + 'Warning: Russia inside can only be used with %s. %s already in Russia inside and have been removed from selection.', + ).format( + main.ALLOWED_WITH_RUSSIA_INSIDE.map( + (key) => main.DOMAIN_LIST_OPTIONS[key], + ) + .filter((label) => label !== 'Russia inside') + .join(', '), + removedServices.join(', '), + ), + ]), + ); } + } - const validation = main.validatePath(value); + if (JSON.stringify(newValues.sort()) !== JSON.stringify(values.sort())) { + this.getUIElement(section_id).setValue(newValues); + } - if (validation.valid) { - return true; - } + notifications.forEach((notification) => + ui.addNotification(null, notification), + ); + lastValues = newValues; + } catch (e) { + console.error('Error in onchange handler:', e); + } finally { + isProcessing = false; + } + }; - return _(validation.message) - }; + o = s.taboption( + 'basic', + form.ListValue, + 'user_domain_list_type', + _('User Domain List Type'), + _('Select how to add your custom domains'), + ); + o.value('disabled', _('Disabled')); + o.value('dynamic', _('Dynamic List')); + o.value('text', _('Text List')); + o.default = 'disabled'; + o.rmempty = false; + o.ucisection = s.section; - o = s.taboption('basic', form.Flag, 'remote_domain_lists_enabled', _('Remote Domain Lists'), _('Download and use domain lists from remote URLs')); - o.default = '0'; - o.rmempty = false; - o.ucisection = s.section; + o = s.taboption( + 'basic', + form.DynamicList, + 'user_domains', + _('User Domains'), + _( + 'Enter domain names without protocols (example: sub.example.com or example.com)', + ), + ); + o.placeholder = 'Domains list'; + o.depends('user_domain_list_type', 'dynamic'); + o.rmempty = false; + o.ucisection = s.section; + o.validate = function (section_id, value) { + // Optional + if (!value || value.length === 0) { + return true; + } - o = s.taboption('basic', form.DynamicList, 'remote_domain_lists', _('Remote Domain URLs'), _('Enter full URLs starting with http:// or https://')); - o.placeholder = 'URL'; - o.depends('remote_domain_lists_enabled', '1'); - o.rmempty = false; - o.ucisection = s.section; - o.validate = function (section_id, value) { - // Optional - if (!value || value.length === 0) { - return true - } + const validation = main.validateDomain(value); - const validation = main.validateUrl(value); + if (validation.valid) { + return true; + } - if (validation.valid) { - return true; - } + return _(validation.message); + }; - return _(validation.message) - }; + o = s.taboption( + 'basic', + form.TextValue, + 'user_domains_text', + _('User Domains List'), + _( + 'Enter domain names separated by comma, space or newline. You can add comments after //', + ), + ); + o.placeholder = + 'example.com, sub.example.com\n// Social networks\ndomain.com test.com // personal domains'; + o.depends('user_domain_list_type', 'text'); + o.rows = 8; + o.rmempty = false; + o.ucisection = s.section; + o.validate = function (section_id, value) { + // Optional + if (!value || value.length === 0) { + return true; + } - o = s.taboption('basic', form.Flag, 'local_subnet_lists_enabled', _('Local Subnet Lists'), _('Use the list from the router filesystem')); - o.default = '0'; - o.rmempty = false; - o.ucisection = s.section; + const domains = main.parseValueList(value); - o = s.taboption('basic', form.DynamicList, 'local_subnet_lists', _('Local Subnet List Paths'), _('Enter the list file path')); - o.placeholder = '/path/file.lst'; - o.depends('local_subnet_lists_enabled', '1'); - o.rmempty = false; - o.ucisection = s.section; - o.validate = function (section_id, value) { - // Optional - if (!value || value.length === 0) { - return true - } + if (!domains.length) { + return _( + 'At least one valid domain must be specified. Comments-only content is not allowed.', + ); + } - const validation = main.validatePath(value); + const { valid, results } = main.bulkValidate(domains, main.validateDomain); - if (validation.valid) { - return true; - } + if (!valid) { + const errors = results + .filter((validation) => !validation.valid) // Leave only failed validations + .map((validation) => _(`${validation.value}: ${validation.message}`)); // Collect validation errors - return _(validation.message) - }; + return [_('Validation errors:'), ...errors].join('\n'); + } - o = s.taboption('basic', form.ListValue, 'user_subnet_list_type', _('User Subnet List Type'), _('Select how to add your custom subnets')); - o.value('disabled', _('Disabled')); - o.value('dynamic', _('Dynamic List')); - o.value('text', _('Text List (comma/space/newline separated)')); - o.default = 'disabled'; - o.rmempty = false; - o.ucisection = s.section; + return true; + }; - o = s.taboption('basic', form.DynamicList, 'user_subnets', _('User Subnets'), _('Enter subnets in CIDR notation (example: 103.21.244.0/22) or single IP addresses')); - o.placeholder = 'IP or subnet'; - o.depends('user_subnet_list_type', 'dynamic'); - o.rmempty = false; - o.ucisection = s.section; - o.validate = function (section_id, value) { - // Optional - if (!value || value.length === 0) { - return true - } + o = s.taboption( + 'basic', + form.Flag, + 'local_domain_lists_enabled', + _('Local Domain Lists'), + _('Use the list from the router filesystem'), + ); + o.default = '0'; + o.rmempty = false; + o.ucisection = s.section; - const validation = main.validateSubnet(value); + o = s.taboption( + 'basic', + form.DynamicList, + 'local_domain_lists', + _('Local Domain List Paths'), + _('Enter the list file path'), + ); + o.placeholder = '/path/file.lst'; + o.depends('local_domain_lists_enabled', '1'); + o.rmempty = false; + o.ucisection = s.section; + o.validate = function (section_id, value) { + // Optional + if (!value || value.length === 0) { + return true; + } - if (validation.valid) { - return true; - } + const validation = main.validatePath(value); - return _(validation.message) - }; + if (validation.valid) { + return true; + } - o = s.taboption('basic', form.TextValue, 'user_subnets_text', _('User Subnets List'), _('Enter subnets in CIDR notation or single IP addresses, separated by comma, space or newline. You can add comments after //')); - o.placeholder = '103.21.244.0/22\n// Google DNS\n8.8.8.8\n1.1.1.1/32, 9.9.9.9 // Cloudflare and Quad9'; - o.depends('user_subnet_list_type', 'text'); - o.rows = 10; - o.rmempty = false; - o.ucisection = s.section; - o.validate = function (section_id, value) { - // Optional - if (!value || value.length === 0) { - return true - } + return _(validation.message); + }; - const subnets = main.parseValueList(value); + o = s.taboption( + 'basic', + form.Flag, + 'remote_domain_lists_enabled', + _('Remote Domain Lists'), + _('Download and use domain lists from remote URLs'), + ); + o.default = '0'; + o.rmempty = false; + o.ucisection = s.section; - if (!subnets.length) { - return _( - 'At least one valid subnet or IP must be specified. Comments-only content is not allowed.' - ); - } + o = s.taboption( + 'basic', + form.DynamicList, + 'remote_domain_lists', + _('Remote Domain URLs'), + _('Enter full URLs starting with http:// or https://'), + ); + o.placeholder = 'URL'; + o.depends('remote_domain_lists_enabled', '1'); + o.rmempty = false; + o.ucisection = s.section; + o.validate = function (section_id, value) { + // Optional + if (!value || value.length === 0) { + return true; + } - const { valid, results } = main.bulkValidate(subnets, main.validateSubnet); + const validation = main.validateUrl(value); - if (!valid) { - const errors = results - .filter(validation => !validation.valid) // Leave only failed validations - .map((validation) => _(`${validation.value}: ${validation.message}`)) // Collect validation errors + if (validation.valid) { + return true; + } - return [_('Validation errors:'), ...errors].join('\n'); - } + return _(validation.message); + }; - return true; - }; + o = s.taboption( + 'basic', + form.Flag, + 'local_subnet_lists_enabled', + _('Local Subnet Lists'), + _('Use the list from the router filesystem'), + ); + o.default = '0'; + o.rmempty = false; + o.ucisection = s.section; - o = s.taboption('basic', form.Flag, 'remote_subnet_lists_enabled', _('Remote Subnet Lists'), _('Download and use subnet lists from remote URLs')); - o.default = '0'; - o.rmempty = false; - o.ucisection = s.section; + o = s.taboption( + 'basic', + form.DynamicList, + 'local_subnet_lists', + _('Local Subnet List Paths'), + _('Enter the list file path'), + ); + o.placeholder = '/path/file.lst'; + o.depends('local_subnet_lists_enabled', '1'); + o.rmempty = false; + o.ucisection = s.section; + o.validate = function (section_id, value) { + // Optional + if (!value || value.length === 0) { + return true; + } - o = s.taboption('basic', form.DynamicList, 'remote_subnet_lists', _('Remote Subnet URLs'), _('Enter full URLs starting with http:// or https://')); - o.placeholder = 'URL'; - o.depends('remote_subnet_lists_enabled', '1'); - o.rmempty = false; - o.ucisection = s.section; - o.validate = function (section_id, value) { - // Optional - if (!value || value.length === 0) { - return true - } + const validation = main.validatePath(value); - const validation = main.validateUrl(value); + if (validation.valid) { + return true; + } - if (validation.valid) { - return true; - } + return _(validation.message); + }; - return _(validation.message) - }; + o = s.taboption( + 'basic', + form.ListValue, + 'user_subnet_list_type', + _('User Subnet List Type'), + _('Select how to add your custom subnets'), + ); + o.value('disabled', _('Disabled')); + o.value('dynamic', _('Dynamic List')); + o.value('text', _('Text List (comma/space/newline separated)')); + o.default = 'disabled'; + o.rmempty = false; + o.ucisection = s.section; - o = s.taboption('basic', form.Flag, 'all_traffic_from_ip_enabled', _('IP for full redirection'), _('Specify local IP addresses whose traffic will always use the configured route')); - o.default = '0'; - o.rmempty = false; - o.ucisection = s.section; + o = s.taboption( + 'basic', + form.DynamicList, + 'user_subnets', + _('User Subnets'), + _( + 'Enter subnets in CIDR notation (example: 103.21.244.0/22) or single IP addresses', + ), + ); + o.placeholder = 'IP or subnet'; + o.depends('user_subnet_list_type', 'dynamic'); + o.rmempty = false; + o.ucisection = s.section; + o.validate = function (section_id, value) { + // Optional + if (!value || value.length === 0) { + return true; + } - o = s.taboption('basic', form.DynamicList, 'all_traffic_ip', _('Local IPs'), _('Enter valid IPv4 addresses')); - o.placeholder = 'IP'; - o.depends('all_traffic_from_ip_enabled', '1'); - o.rmempty = false; - o.ucisection = s.section; - o.validate = function (section_id, value) { - // Optional - if (!value || value.length === 0) { - return true - } + const validation = main.validateSubnet(value); - const validation = main.validateIPV4(value); + if (validation.valid) { + return true; + } - if (validation.valid) { - return true; - } + return _(validation.message); + }; - return _(validation.message) - }; + o = s.taboption( + 'basic', + form.TextValue, + 'user_subnets_text', + _('User Subnets List'), + _( + 'Enter subnets in CIDR notation or single IP addresses, separated by comma, space or newline. You can add comments after //', + ), + ); + o.placeholder = + '103.21.244.0/22\n// Google DNS\n8.8.8.8\n1.1.1.1/32, 9.9.9.9 // Cloudflare and Quad9'; + o.depends('user_subnet_list_type', 'text'); + o.rows = 10; + o.rmempty = false; + o.ucisection = s.section; + o.validate = function (section_id, value) { + // Optional + if (!value || value.length === 0) { + return true; + } + + const subnets = main.parseValueList(value); + + if (!subnets.length) { + return _( + 'At least one valid subnet or IP must be specified. Comments-only content is not allowed.', + ); + } + + const { valid, results } = main.bulkValidate(subnets, main.validateSubnet); + + if (!valid) { + const errors = results + .filter((validation) => !validation.valid) // Leave only failed validations + .map((validation) => _(`${validation.value}: ${validation.message}`)); // Collect validation errors + + return [_('Validation errors:'), ...errors].join('\n'); + } + + return true; + }; + + o = s.taboption( + 'basic', + form.Flag, + 'remote_subnet_lists_enabled', + _('Remote Subnet Lists'), + _('Download and use subnet lists from remote URLs'), + ); + o.default = '0'; + o.rmempty = false; + o.ucisection = s.section; + + o = s.taboption( + 'basic', + form.DynamicList, + 'remote_subnet_lists', + _('Remote Subnet URLs'), + _('Enter full URLs starting with http:// or https://'), + ); + o.placeholder = 'URL'; + o.depends('remote_subnet_lists_enabled', '1'); + o.rmempty = false; + o.ucisection = s.section; + o.validate = function (section_id, value) { + // Optional + if (!value || value.length === 0) { + return true; + } + + const validation = main.validateUrl(value); + + if (validation.valid) { + return true; + } + + return _(validation.message); + }; + + o = s.taboption( + 'basic', + form.Flag, + 'all_traffic_from_ip_enabled', + _('IP for full redirection'), + _( + 'Specify local IP addresses whose traffic will always use the configured route', + ), + ); + o.default = '0'; + o.rmempty = false; + o.ucisection = s.section; + + o = s.taboption( + 'basic', + form.DynamicList, + 'all_traffic_ip', + _('Local IPs'), + _('Enter valid IPv4 addresses'), + ); + o.placeholder = 'IP'; + o.depends('all_traffic_from_ip_enabled', '1'); + o.rmempty = false; + o.ucisection = s.section; + o.validate = function (section_id, value) { + // Optional + if (!value || value.length === 0) { + return true; + } + + const validation = main.validateIPV4(value); + + if (validation.valid) { + return true; + } + + return _(validation.message); + }; } return baseclass.extend({ - createConfigSection + createConfigSection, }); diff --git a/luci-app-podkop/htdocs/luci-static/resources/view/podkop/diagnosticTab.js b/luci-app-podkop/htdocs/luci-static/resources/view/podkop/diagnosticTab.js index 1768b43..5b5b2cf 100644 --- a/luci-app-podkop/htdocs/luci-static/resources/view/podkop/diagnosticTab.js +++ b/luci-app-podkop/htdocs/luci-static/resources/view/podkop/diagnosticTab.js @@ -12,553 +12,770 @@ const fetchCache = {}; // Helper function to fetch with cache async function cachedFetch(url, options = {}) { - const cacheKey = url; - const currentTime = Date.now(); + const cacheKey = url; + const currentTime = Date.now(); - // If we have a valid cached response, return it - if (fetchCache[cacheKey] && currentTime - fetchCache[cacheKey].timestamp < main.CACHE_TIMEOUT) { - console.log(`Using cached response for ${url}`); - return Promise.resolve(fetchCache[cacheKey].response.clone()); - } + // If we have a valid cached response, return it + if ( + fetchCache[cacheKey] && + currentTime - fetchCache[cacheKey].timestamp < main.CACHE_TIMEOUT + ) { + console.log(`Using cached response for ${url}`); + return Promise.resolve(fetchCache[cacheKey].response.clone()); + } - // Otherwise, make a new request - try { - const response = await fetch(url, options); + // Otherwise, make a new request + try { + const response = await fetch(url, options); - // Cache the response - fetchCache[cacheKey] = { - response: response.clone(), - timestamp: currentTime - }; + // Cache the response + fetchCache[cacheKey] = { + response: response.clone(), + timestamp: currentTime, + }; - return response; - } catch (error) { - throw error; - } + return response; + } catch (error) { + throw error; + } } // Helper functions for command execution with prioritization - Using from utils.js now -function safeExec(command, args, priority, callback, timeout = main.COMMAND_TIMEOUT) { - return utils.safeExec(command, args, priority, callback, timeout); +function safeExec( + command, + args, + priority, + callback, + timeout = main.COMMAND_TIMEOUT, +) { + return utils.safeExec(command, args, priority, callback, timeout); } // Helper functions for handling checks function runCheck(checkFunction, priority, callback) { - // Default to highest priority execution if priority is not provided or invalid - let schedulingDelay = main.COMMAND_SCHEDULING.P0_PRIORITY; + // Default to highest priority execution if priority is not provided or invalid + let schedulingDelay = main.COMMAND_SCHEDULING.P0_PRIORITY; - // If priority is a string, try to get the corresponding delay value - if (typeof priority === 'string' && main.COMMAND_SCHEDULING[priority] !== undefined) { - schedulingDelay = main.COMMAND_SCHEDULING[priority]; + // If priority is a string, try to get the corresponding delay value + if ( + typeof priority === 'string' && + main.COMMAND_SCHEDULING[priority] !== undefined + ) { + schedulingDelay = main.COMMAND_SCHEDULING[priority]; + } + + const executeCheck = async () => { + try { + const result = await checkFunction(); + if (callback && typeof callback === 'function') { + callback(result); + } + return result; + } catch (error) { + if (callback && typeof callback === 'function') { + callback({ error }); + } + return { error }; } + }; - const executeCheck = async () => { - try { - const result = await checkFunction(); - if (callback && typeof callback === 'function') { - callback(result); - } - return result; - } catch (error) { - if (callback && typeof callback === 'function') { - callback({ error }); - } - return { error }; - } - }; - - if (callback && typeof callback === 'function') { - setTimeout(executeCheck, schedulingDelay); - return; - } else { - return executeCheck(); - } + if (callback && typeof callback === 'function') { + setTimeout(executeCheck, schedulingDelay); + return; + } else { + return executeCheck(); + } } function runAsyncTask(taskFunction, priority) { - // Default to highest priority execution if priority is not provided or invalid - let schedulingDelay = main.COMMAND_SCHEDULING.P0_PRIORITY; + // Default to highest priority execution if priority is not provided or invalid + let schedulingDelay = main.COMMAND_SCHEDULING.P0_PRIORITY; - // If priority is a string, try to get the corresponding delay value - if (typeof priority === 'string' && main.COMMAND_SCHEDULING[priority] !== undefined) { - schedulingDelay = main.COMMAND_SCHEDULING[priority]; + // If priority is a string, try to get the corresponding delay value + if ( + typeof priority === 'string' && + main.COMMAND_SCHEDULING[priority] !== undefined + ) { + schedulingDelay = main.COMMAND_SCHEDULING[priority]; + } + + setTimeout(async () => { + try { + await taskFunction(); + } catch (error) { + console.error('Async task error:', error); } - - setTimeout(async () => { - try { - await taskFunction(); - } catch (error) { - console.error('Async task error:', error); - } - }, schedulingDelay); + }, schedulingDelay); } // Helper Functions for UI and formatting function createStatus(state, message, color) { - return { - state, - message: _(message), - color: main.STATUS_COLORS[color] - }; + return { + state, + message: _(message), + color: main.STATUS_COLORS[color], + }; } function formatDiagnosticOutput(output) { - if (typeof output !== 'string') return ''; - return output.trim() - .replace(/\x1b\[[0-9;]*m/g, '') - .replace(/\r\n/g, '\n') - .replace(/\r/g, '\n'); + if (typeof output !== 'string') return ''; + return output + .trim() + .replace(/\x1b\[[0-9;]*m/g, '') + .replace(/\r\n/g, '\n') + .replace(/\r/g, '\n'); } function copyToClipboard(text, button) { - const textarea = document.createElement('textarea'); - textarea.value = text; - document.body.appendChild(textarea); - textarea.select(); - try { - document.execCommand('copy'); - const originalText = button.textContent; - button.textContent = _('Copied!'); - setTimeout(() => button.textContent = originalText, main.BUTTON_FEEDBACK_TIMEOUT); - } catch (err) { - ui.addNotification(null, E('p', {}, _('Failed to copy: ') + err.message)); - } - document.body.removeChild(textarea); + const textarea = document.createElement('textarea'); + textarea.value = text; + document.body.appendChild(textarea); + textarea.select(); + try { + document.execCommand('copy'); + const originalText = button.textContent; + button.textContent = _('Copied!'); + setTimeout( + () => (button.textContent = originalText), + main.BUTTON_FEEDBACK_TIMEOUT, + ); + } catch (err) { + ui.addNotification(null, E('p', {}, _('Failed to copy: ') + err.message)); + } + document.body.removeChild(textarea); } // IP masking function function maskIP(ip) { - if (!ip) return ''; - const parts = ip.split('.'); - if (parts.length !== 4) return ip; - return ['XX', 'XX', 'XX', parts[3]].join('.'); + if (!ip) return ''; + const parts = ip.split('.'); + if (parts.length !== 4) return ip; + return ['XX', 'XX', 'XX', parts[3]].join('.'); } // Status Check Functions async function checkFakeIP() { + try { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), main.FETCH_TIMEOUT); + try { - const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), main.FETCH_TIMEOUT); + const response = await cachedFetch( + `https://${main.FAKEIP_CHECK_DOMAIN}/check`, + { signal: controller.signal }, + ); + const data = await response.json(); + clearTimeout(timeoutId); - try { - const response = await cachedFetch(`https://${main.FAKEIP_CHECK_DOMAIN}/check`, { signal: controller.signal }); - const data = await response.json(); - clearTimeout(timeoutId); - - if (data.fakeip === true) { - return createStatus('working', 'working', 'SUCCESS'); - } else { - return createStatus('not_working', 'not working', 'ERROR'); - } - } catch (fetchError) { - clearTimeout(timeoutId); - const message = fetchError.name === 'AbortError' ? 'timeout' : 'check error'; - return createStatus('error', message, 'WARNING'); - } - } catch (error) { - return createStatus('error', 'check error', 'WARNING'); + if (data.fakeip === true) { + return createStatus('working', 'working', 'SUCCESS'); + } else { + return createStatus('not_working', 'not working', 'ERROR'); + } + } catch (fetchError) { + clearTimeout(timeoutId); + const message = + fetchError.name === 'AbortError' ? 'timeout' : 'check error'; + return createStatus('error', message, 'WARNING'); } + } catch (error) { + return createStatus('error', 'check error', 'WARNING'); + } } async function checkFakeIPCLI() { - try { - return new Promise((resolve) => { - safeExec('nslookup', ['-timeout=2', main.FAKEIP_CHECK_DOMAIN, '127.0.0.42'], 'P0_PRIORITY', result => { - if (result.stdout && result.stdout.includes('198.18')) { - resolve(createStatus('working', 'working on router', 'SUCCESS')); - } else { - resolve(createStatus('not_working', 'not working on router', 'ERROR')); - } - }); - }); - } catch (error) { - return createStatus('error', 'CLI check error', 'WARNING'); - } + try { + return new Promise((resolve) => { + safeExec( + 'nslookup', + ['-timeout=2', main.FAKEIP_CHECK_DOMAIN, '127.0.0.42'], + 'P0_PRIORITY', + (result) => { + if (result.stdout && result.stdout.includes('198.18')) { + resolve(createStatus('working', 'working on router', 'SUCCESS')); + } else { + resolve( + createStatus('not_working', 'not working on router', 'ERROR'), + ); + } + }, + ); + }); + } catch (error) { + return createStatus('error', 'CLI check error', 'WARNING'); + } } function checkDNSAvailability() { - return new Promise(async (resolve) => { - try { - safeExec('/usr/bin/podkop', ['check_dns_available'], 'P0_PRIORITY', dnsStatusResult => { - if (!dnsStatusResult || !dnsStatusResult.stdout) { - return resolve({ - remote: createStatus('error', 'DNS check timeout', 'WARNING'), - local: createStatus('error', 'DNS check timeout', 'WARNING') - }); - } - - try { - const dnsStatus = JSON.parse(dnsStatusResult.stdout); - - const remoteStatus = dnsStatus.is_available ? - createStatus('available', `${dnsStatus.dns_type.toUpperCase()} (${dnsStatus.dns_server}) available`, 'SUCCESS') : - createStatus('unavailable', `${dnsStatus.dns_type.toUpperCase()} (${dnsStatus.dns_server}) unavailable`, 'ERROR'); - - const localStatus = dnsStatus.local_dns_working ? - createStatus('available', 'Router DNS working', 'SUCCESS') : - createStatus('unavailable', 'Router DNS not working', 'ERROR'); - - return resolve({ - remote: remoteStatus, - local: localStatus - }); - } catch (parseError) { - return resolve({ - remote: createStatus('error', 'DNS check parse error', 'WARNING'), - local: createStatus('error', 'DNS check parse error', 'WARNING') - }); - } - }); - } catch (error) { + return new Promise(async (resolve) => { + try { + safeExec( + '/usr/bin/podkop', + ['check_dns_available'], + 'P0_PRIORITY', + (dnsStatusResult) => { + if (!dnsStatusResult || !dnsStatusResult.stdout) { return resolve({ - remote: createStatus('error', 'DNS check error', 'WARNING'), - local: createStatus('error', 'DNS check error', 'WARNING') + remote: createStatus('error', 'DNS check timeout', 'WARNING'), + local: createStatus('error', 'DNS check timeout', 'WARNING'), }); - } - }); + } + + try { + const dnsStatus = JSON.parse(dnsStatusResult.stdout); + + const remoteStatus = dnsStatus.is_available + ? createStatus( + 'available', + `${dnsStatus.dns_type.toUpperCase()} (${dnsStatus.dns_server}) available`, + 'SUCCESS', + ) + : createStatus( + 'unavailable', + `${dnsStatus.dns_type.toUpperCase()} (${dnsStatus.dns_server}) unavailable`, + 'ERROR', + ); + + const localStatus = dnsStatus.local_dns_working + ? createStatus('available', 'Router DNS working', 'SUCCESS') + : createStatus('unavailable', 'Router DNS not working', 'ERROR'); + + return resolve({ + remote: remoteStatus, + local: localStatus, + }); + } catch (parseError) { + return resolve({ + remote: createStatus('error', 'DNS check parse error', 'WARNING'), + local: createStatus('error', 'DNS check parse error', 'WARNING'), + }); + } + }, + ); + } catch (error) { + return resolve({ + remote: createStatus('error', 'DNS check error', 'WARNING'), + local: createStatus('error', 'DNS check error', 'WARNING'), + }); + } + }); } async function checkBypass() { + try { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), main.FETCH_TIMEOUT); + try { - const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), main.FETCH_TIMEOUT); + const response1 = await cachedFetch( + `https://${main.FAKEIP_CHECK_DOMAIN}/check`, + { signal: controller.signal }, + ); + const data1 = await response1.json(); - try { - const response1 = await cachedFetch(`https://${main.FAKEIP_CHECK_DOMAIN}/check`, { signal: controller.signal }); - const data1 = await response1.json(); + const response2 = await cachedFetch( + `https://${main.IP_CHECK_DOMAIN}/check`, + { signal: controller.signal }, + ); + const data2 = await response2.json(); - const response2 = await cachedFetch(`https://${main.IP_CHECK_DOMAIN}/check`, { signal: controller.signal }); - const data2 = await response2.json(); + clearTimeout(timeoutId); - clearTimeout(timeoutId); - - if (data1.IP && data2.IP) { - if (data1.IP !== data2.IP) { - return createStatus('working', 'working', 'SUCCESS'); - } else { - return createStatus('not_working', 'same IP for both domains', 'ERROR'); - } - } else { - return createStatus('error', 'check error (no IP)', 'WARNING'); - } - } catch (fetchError) { - clearTimeout(timeoutId); - const message = fetchError.name === 'AbortError' ? 'timeout' : 'check error'; - return createStatus('error', message, 'WARNING'); + if (data1.IP && data2.IP) { + if (data1.IP !== data2.IP) { + return createStatus('working', 'working', 'SUCCESS'); + } else { + return createStatus( + 'not_working', + 'same IP for both domains', + 'ERROR', + ); } - } catch (error) { - return createStatus('error', 'check error', 'WARNING'); + } else { + return createStatus('error', 'check error (no IP)', 'WARNING'); + } + } catch (fetchError) { + clearTimeout(timeoutId); + const message = + fetchError.name === 'AbortError' ? 'timeout' : 'check error'; + return createStatus('error', message, 'WARNING'); } + } catch (error) { + return createStatus('error', 'check error', 'WARNING'); + } } function showConfigModal(command, title) { - // Create and show modal immediately with loading state - const modalContent = E('div', { 'class': 'panel-body' }, [ - E('div', { - 'class': 'panel-body', - style: 'max-height: 70vh; overflow-y: auto; margin: 1em 0; padding: 1.5em; ' + - 'font-family: monospace; white-space: pre-wrap; word-wrap: break-word; ' + - 'line-height: 1.5; font-size: 14px;' - }, [ - E('pre', { - 'id': 'modal-content-pre', - style: 'margin: 0;' - }, _('Loading...')) - ]), - E('div', { - 'class': 'right', - style: 'margin-top: 1em;' - }, [ - E('button', { - 'class': 'btn', - 'id': 'copy-button', - 'click': ev => copyToClipboard('```txt\n' + document.getElementById('modal-content-pre').innerText + '\n```', ev.target) - }, _('Copy to Clipboard')), - E('button', { - 'class': 'btn', - 'click': ui.hideModal - }, _('Close')) - ]) - ]); + // Create and show modal immediately with loading state + const modalContent = E('div', { class: 'panel-body' }, [ + E( + 'div', + { + class: 'panel-body', + style: + 'max-height: 70vh; overflow-y: auto; margin: 1em 0; padding: 1.5em; ' + + 'font-family: monospace; white-space: pre-wrap; word-wrap: break-word; ' + + 'line-height: 1.5; font-size: 14px;', + }, + [ + E( + 'pre', + { + id: 'modal-content-pre', + style: 'margin: 0;', + }, + _('Loading...'), + ), + ], + ), + E( + 'div', + { + class: 'right', + style: 'margin-top: 1em;', + }, + [ + E( + 'button', + { + class: 'btn', + id: 'copy-button', + click: (ev) => + copyToClipboard( + '```txt\n' + + document.getElementById('modal-content-pre').innerText + + '\n```', + ev.target, + ), + }, + _('Copy to Clipboard'), + ), + E( + 'button', + { + class: 'btn', + click: ui.hideModal, + }, + _('Close'), + ), + ], + ), + ]); - ui.showModal(_(title), modalContent); + ui.showModal(_(title), modalContent); - // Function to update modal content - const updateModalContent = (content) => { - const pre = document.getElementById('modal-content-pre'); - if (pre) { - pre.textContent = content; - } - }; - - try { - let formattedOutput = ''; - - if (command === 'global_check') { - safeExec('/usr/bin/podkop', [command], 'P0_PRIORITY', res => { - formattedOutput = formatDiagnosticOutput(res.stdout || _('No output')); - - try { - const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), main.FETCH_TIMEOUT); - - cachedFetch(`https://${main.FAKEIP_CHECK_DOMAIN}/check`, { signal: controller.signal }) - .then(response => response.json()) - .then(data => { - clearTimeout(timeoutId); - - if (data.fakeip === true) { - formattedOutput += '\n✅ ' + _('FakeIP is working in browser!') + '\n'; - } else { - formattedOutput += '\n❌ ' + _('FakeIP is not working in browser') + '\n'; - formattedOutput += _('Check DNS server on current device (PC, phone)') + '\n'; - formattedOutput += _('Its must be router!') + '\n'; - } - - // Bypass check - cachedFetch(`https://${main.FAKEIP_CHECK_DOMAIN}/check`, { signal: controller.signal }) - .then(bypassResponse => bypassResponse.json()) - .then(bypassData => { - cachedFetch(`https://${main.IP_CHECK_DOMAIN}/check`, { signal: controller.signal }) - .then(bypassResponse2 => bypassResponse2.json()) - .then(bypassData2 => { - formattedOutput += '━━━━━━━━━━━━━━━━━━━━━━━━━━━\n'; - - if (bypassData.IP && bypassData2.IP && bypassData.IP !== bypassData2.IP) { - formattedOutput += '✅ ' + _('Proxy working correctly') + '\n'; - formattedOutput += _('Direct IP: ') + maskIP(bypassData.IP) + '\n'; - formattedOutput += _('Proxy IP: ') + maskIP(bypassData2.IP) + '\n'; - } else if (bypassData.IP === bypassData2.IP) { - formattedOutput += '❌ ' + _('Proxy is not working - same IP for both domains') + '\n'; - formattedOutput += _('IP: ') + maskIP(bypassData.IP) + '\n'; - } else { - formattedOutput += '❌ ' + _('Proxy check failed') + '\n'; - } - - updateModalContent(formattedOutput); - }) - .catch(error => { - formattedOutput += '\n❌ ' + _('Check failed: ') + (error.name === 'AbortError' ? _('timeout') : error.message) + '\n'; - updateModalContent(formattedOutput); - }); - }) - .catch(error => { - formattedOutput += '\n❌ ' + _('Check failed: ') + (error.name === 'AbortError' ? _('timeout') : error.message) + '\n'; - updateModalContent(formattedOutput); - }); - }) - .catch(error => { - formattedOutput += '\n❌ ' + _('Check failed: ') + (error.name === 'AbortError' ? _('timeout') : error.message) + '\n'; - updateModalContent(formattedOutput); - }); - } catch (error) { - formattedOutput += '\n❌ ' + _('Check failed: ') + error.message + '\n'; - updateModalContent(formattedOutput); - } - }); - } else { - safeExec('/usr/bin/podkop', [command], 'P0_PRIORITY', res => { - formattedOutput = formatDiagnosticOutput(res.stdout || _('No output')); - updateModalContent(formattedOutput); - }); - } - } catch (error) { - updateModalContent(_('Error: ') + error.message); + // Function to update modal content + const updateModalContent = (content) => { + const pre = document.getElementById('modal-content-pre'); + if (pre) { + pre.textContent = content; } + }; + + try { + let formattedOutput = ''; + + if (command === 'global_check') { + safeExec('/usr/bin/podkop', [command], 'P0_PRIORITY', (res) => { + formattedOutput = formatDiagnosticOutput(res.stdout || _('No output')); + + try { + const controller = new AbortController(); + const timeoutId = setTimeout( + () => controller.abort(), + main.FETCH_TIMEOUT, + ); + + cachedFetch(`https://${main.FAKEIP_CHECK_DOMAIN}/check`, { + signal: controller.signal, + }) + .then((response) => response.json()) + .then((data) => { + clearTimeout(timeoutId); + + if (data.fakeip === true) { + formattedOutput += + '\n✅ ' + _('FakeIP is working in browser!') + '\n'; + } else { + formattedOutput += + '\n❌ ' + _('FakeIP is not working in browser') + '\n'; + formattedOutput += + _('Check DNS server on current device (PC, phone)') + '\n'; + formattedOutput += _('Its must be router!') + '\n'; + } + + // Bypass check + cachedFetch(`https://${main.FAKEIP_CHECK_DOMAIN}/check`, { + signal: controller.signal, + }) + .then((bypassResponse) => bypassResponse.json()) + .then((bypassData) => { + cachedFetch(`https://${main.IP_CHECK_DOMAIN}/check`, { + signal: controller.signal, + }) + .then((bypassResponse2) => bypassResponse2.json()) + .then((bypassData2) => { + formattedOutput += '━━━━━━━━━━━━━━━━━━━━━━━━━━━\n'; + + if ( + bypassData.IP && + bypassData2.IP && + bypassData.IP !== bypassData2.IP + ) { + formattedOutput += + '✅ ' + _('Proxy working correctly') + '\n'; + formattedOutput += + _('Direct IP: ') + maskIP(bypassData.IP) + '\n'; + formattedOutput += + _('Proxy IP: ') + maskIP(bypassData2.IP) + '\n'; + } else if (bypassData.IP === bypassData2.IP) { + formattedOutput += + '❌ ' + + _('Proxy is not working - same IP for both domains') + + '\n'; + formattedOutput += + _('IP: ') + maskIP(bypassData.IP) + '\n'; + } else { + formattedOutput += + '❌ ' + _('Proxy check failed') + '\n'; + } + + updateModalContent(formattedOutput); + }) + .catch((error) => { + formattedOutput += + '\n❌ ' + + _('Check failed: ') + + (error.name === 'AbortError' + ? _('timeout') + : error.message) + + '\n'; + updateModalContent(formattedOutput); + }); + }) + .catch((error) => { + formattedOutput += + '\n❌ ' + + _('Check failed: ') + + (error.name === 'AbortError' + ? _('timeout') + : error.message) + + '\n'; + updateModalContent(formattedOutput); + }); + }) + .catch((error) => { + formattedOutput += + '\n❌ ' + + _('Check failed: ') + + (error.name === 'AbortError' ? _('timeout') : error.message) + + '\n'; + updateModalContent(formattedOutput); + }); + } catch (error) { + formattedOutput += + '\n❌ ' + _('Check failed: ') + error.message + '\n'; + updateModalContent(formattedOutput); + } + }); + } else { + safeExec('/usr/bin/podkop', [command], 'P0_PRIORITY', (res) => { + formattedOutput = formatDiagnosticOutput(res.stdout || _('No output')); + updateModalContent(formattedOutput); + }); + } + } catch (error) { + updateModalContent(_('Error: ') + error.message); + } } // Button Factory const ButtonFactory = { - createButton: function (config) { - return E('button', { - 'class': `btn ${config.additionalClass || ''}`.trim(), - 'click': config.onClick, - 'style': config.style || '' - }, _(config.label)); - }, + createButton: function (config) { + return E( + 'button', + { + class: `btn ${config.additionalClass || ''}`.trim(), + click: config.onClick, + style: config.style || '', + }, + _(config.label), + ); + }, - createActionButton: function (config) { - return this.createButton({ - label: config.label, - additionalClass: `cbi-button-${config.type || ''}`, - onClick: () => safeExec('/usr/bin/podkop', [config.action], 'P0_PRIORITY') - .then(() => config.reload && location.reload()), - style: config.style - }); - }, + createActionButton: function (config) { + return this.createButton({ + label: config.label, + additionalClass: `cbi-button-${config.type || ''}`, + onClick: () => + safeExec('/usr/bin/podkop', [config.action], 'P0_PRIORITY').then( + () => config.reload && location.reload(), + ), + style: config.style, + }); + }, - createInitActionButton: function (config) { - return this.createButton({ - label: config.label, - additionalClass: `cbi-button-${config.type || ''}`, - onClick: () => safeExec('/etc/init.d/podkop', [config.action], 'P0_PRIORITY') - .then(() => config.reload && location.reload()), - style: config.style - }); - }, + createInitActionButton: function (config) { + return this.createButton({ + label: config.label, + additionalClass: `cbi-button-${config.type || ''}`, + onClick: () => + safeExec('/etc/init.d/podkop', [config.action], 'P0_PRIORITY').then( + () => config.reload && location.reload(), + ), + style: config.style, + }); + }, - createModalButton: function (config) { - return this.createButton({ - label: config.label, - onClick: () => showConfigModal(config.command, config.title), - additionalClass: `cbi-button-${config.type || ''}`, - style: config.style - }); - } + createModalButton: function (config) { + return this.createButton({ + label: config.label, + onClick: () => showConfigModal(config.command, config.title), + additionalClass: `cbi-button-${config.type || ''}`, + style: config.style, + }); + }, }; // Create a loading placeholder for status text function createLoadingStatusText() { - return E('span', { 'class': 'loading-indicator' }, _('Loading...')); + return E('span', { class: 'loading-indicator' }, _('Loading...')); } // Create the status section with buttons loaded immediately but status indicators loading asynchronously let createStatusSection = async function () { - // Get initial podkop status - let initialPodkopStatus = { enabled: false }; - try { - const result = await fs.exec('/usr/bin/podkop', ['get_status']); - if (result && result.stdout) { - const status = JSON.parse(result.stdout); - initialPodkopStatus.enabled = status.enabled === 1; - } - } catch (e) { - console.error('Error getting initial podkop status:', e); + // Get initial podkop status + let initialPodkopStatus = { enabled: false }; + try { + const result = await fs.exec('/usr/bin/podkop', ['get_status']); + if (result && result.stdout) { + const status = JSON.parse(result.stdout); + initialPodkopStatus.enabled = status.enabled === 1; } + } catch (e) { + console.error('Error getting initial podkop status:', e); + } - return E('div', { 'class': 'cbi-section' }, [ - E('div', { 'class': 'table', style: 'display: flex; gap: 20px;' }, [ - // Podkop Status Panel - E('div', { 'id': 'podkop-status-panel', 'class': 'panel', 'style': 'flex: 1; padding: 15px;' }, [ - E('div', { 'class': 'panel-heading' }, [ - E('strong', {}, _('Podkop Status')), - E('br'), - E('span', { 'id': 'podkop-status-text' }, createLoadingStatusText()) - ]), - E('div', { 'class': 'panel-body', 'style': 'display: flex; flex-direction: column; gap: 8px;' }, [ - ButtonFactory.createActionButton({ - label: 'Restart Podkop', - type: 'apply', - action: 'restart', - reload: true - }), - ButtonFactory.createActionButton({ - label: 'Stop Podkop', - type: 'apply', - action: 'stop', - reload: true - }), - // Autostart button - create with initial state - ButtonFactory.createInitActionButton({ - label: initialPodkopStatus.enabled ? 'Disable Autostart' : 'Enable Autostart', - type: initialPodkopStatus.enabled ? 'remove' : 'apply', - action: initialPodkopStatus.enabled ? 'disable' : 'enable', - reload: true - }), - ButtonFactory.createModalButton({ - label: _('Global check'), - command: 'global_check', - title: _('Click here for all the info') - }), - ButtonFactory.createModalButton({ - label: 'View Logs', - command: 'check_logs', - title: 'Podkop Logs' - }), - ButtonFactory.createModalButton({ - label: _('Update Lists'), - command: 'list_update', - title: _('Lists Update Results') - }) - ]) - ]), + return E('div', { class: 'cbi-section' }, [ + E('div', { class: 'table', style: 'display: flex; gap: 20px;' }, [ + // Podkop Status Panel + E( + 'div', + { + id: 'podkop-status-panel', + class: 'panel', + style: 'flex: 1; padding: 15px;', + }, + [ + E('div', { class: 'panel-heading' }, [ + E('strong', {}, _('Podkop Status')), + E('br'), + E('span', { id: 'podkop-status-text' }, createLoadingStatusText()), + ]), + E( + 'div', + { + class: 'panel-body', + style: 'display: flex; flex-direction: column; gap: 8px;', + }, + [ + ButtonFactory.createActionButton({ + label: 'Restart Podkop', + type: 'apply', + action: 'restart', + reload: true, + }), + ButtonFactory.createActionButton({ + label: 'Stop Podkop', + type: 'apply', + action: 'stop', + reload: true, + }), + // Autostart button - create with initial state + ButtonFactory.createInitActionButton({ + label: initialPodkopStatus.enabled + ? 'Disable Autostart' + : 'Enable Autostart', + type: initialPodkopStatus.enabled ? 'remove' : 'apply', + action: initialPodkopStatus.enabled ? 'disable' : 'enable', + reload: true, + }), + ButtonFactory.createModalButton({ + label: _('Global check'), + command: 'global_check', + title: _('Click here for all the info'), + }), + ButtonFactory.createModalButton({ + label: 'View Logs', + command: 'check_logs', + title: 'Podkop Logs', + }), + ButtonFactory.createModalButton({ + label: _('Update Lists'), + command: 'list_update', + title: _('Lists Update Results'), + }), + ], + ), + ], + ), - // Sing-box Status Panel - E('div', { 'id': 'singbox-status-panel', 'class': 'panel', 'style': 'flex: 1; padding: 15px;' }, [ - E('div', { 'class': 'panel-heading' }, [ - E('strong', {}, _('Sing-box Status')), - E('br'), - E('span', { 'id': 'singbox-status-text' }, createLoadingStatusText()) - ]), - E('div', { 'class': 'panel-body', 'style': 'display: flex; flex-direction: column; gap: 8px;' }, [ - ButtonFactory.createModalButton({ - label: 'Show Config', - command: 'show_sing_box_config', - title: 'Sing-box Configuration' - }), - ButtonFactory.createModalButton({ - label: 'View Logs', - command: 'check_sing_box_logs', - title: 'Sing-box Logs' - }), - ButtonFactory.createModalButton({ - label: 'Check Connections', - command: 'check_sing_box_connections', - title: 'Active Connections' - }), - ButtonFactory.createModalButton({ - label: _('Check NFT Rules'), - command: 'check_nft', - title: _('NFT Rules') - }), - ButtonFactory.createModalButton({ - label: _('Check DNSMasq'), - command: 'check_dnsmasq', - title: _('DNSMasq Configuration') - }) - ]) - ]), + // Sing-box Status Panel + E( + 'div', + { + id: 'singbox-status-panel', + class: 'panel', + style: 'flex: 1; padding: 15px;', + }, + [ + E('div', { class: 'panel-heading' }, [ + E('strong', {}, _('Sing-box Status')), + E('br'), + E('span', { id: 'singbox-status-text' }, createLoadingStatusText()), + ]), + E( + 'div', + { + class: 'panel-body', + style: 'display: flex; flex-direction: column; gap: 8px;', + }, + [ + ButtonFactory.createModalButton({ + label: 'Show Config', + command: 'show_sing_box_config', + title: 'Sing-box Configuration', + }), + ButtonFactory.createModalButton({ + label: 'View Logs', + command: 'check_sing_box_logs', + title: 'Sing-box Logs', + }), + ButtonFactory.createModalButton({ + label: 'Check Connections', + command: 'check_sing_box_connections', + title: 'Active Connections', + }), + ButtonFactory.createModalButton({ + label: _('Check NFT Rules'), + command: 'check_nft', + title: _('NFT Rules'), + }), + ButtonFactory.createModalButton({ + label: _('Check DNSMasq'), + command: 'check_dnsmasq', + title: _('DNSMasq Configuration'), + }), + ], + ), + ], + ), - // FakeIP Status Panel - E('div', { 'id': 'fakeip-status-panel', 'class': 'panel', 'style': 'flex: 1; padding: 15px;' }, [ - E('div', { 'class': 'panel-heading' }, [ - E('strong', {}, _('FakeIP Status')) + // FakeIP Status Panel + E( + 'div', + { + id: 'fakeip-status-panel', + class: 'panel', + style: 'flex: 1; padding: 15px;', + }, + [ + E('div', { class: 'panel-heading' }, [ + E('strong', {}, _('FakeIP Status')), + ]), + E( + 'div', + { + class: 'panel-body', + style: 'display: flex; flex-direction: column; gap: 8px;', + }, + [ + E('div', { style: 'margin-bottom: 5px;' }, [ + E('div', {}, [ + E( + 'span', + { id: 'fakeip-browser-status' }, + createLoadingStatusText(), + ), ]), - E('div', { 'class': 'panel-body', 'style': 'display: flex; flex-direction: column; gap: 8px;' }, [ - E('div', { style: 'margin-bottom: 5px;' }, [ - E('div', {}, [ - E('span', { 'id': 'fakeip-browser-status' }, createLoadingStatusText()) - ]), - E('div', {}, [ - E('span', { 'id': 'fakeip-router-status' }, createLoadingStatusText()) - ]) - ]), - E('div', { style: 'margin-bottom: 5px;' }, [ - E('div', {}, [ - E('strong', {}, _('DNS Status')), - E('br'), - E('span', { 'id': 'dns-remote-status' }, createLoadingStatusText()), - E('br'), - E('span', { 'id': 'dns-local-status' }, createLoadingStatusText()) - ]) - ]), - E('div', { style: 'margin-bottom: 5px;' }, [ - E('div', {}, [ - E('strong', { 'id': 'config-name-text' }, _('Main config')), - E('br'), - E('span', { 'id': 'bypass-status' }, createLoadingStatusText()) - ]) - ]) - ]) - ]), + E('div', {}, [ + E( + 'span', + { id: 'fakeip-router-status' }, + createLoadingStatusText(), + ), + ]), + ]), + E('div', { style: 'margin-bottom: 5px;' }, [ + E('div', {}, [ + E('strong', {}, _('DNS Status')), + E('br'), + E( + 'span', + { id: 'dns-remote-status' }, + createLoadingStatusText(), + ), + E('br'), + E( + 'span', + { id: 'dns-local-status' }, + createLoadingStatusText(), + ), + ]), + ]), + E('div', { style: 'margin-bottom: 5px;' }, [ + E('div', {}, [ + E('strong', { id: 'config-name-text' }, _('Main config')), + E('br'), + E('span', { id: 'bypass-status' }, createLoadingStatusText()), + ]), + ]), + ], + ), + ], + ), - // Version Information Panel - E('div', { 'id': 'version-info-panel', 'class': 'panel', 'style': 'flex: 1; padding: 15px;' }, [ - E('div', { 'class': 'panel-heading' }, [ - E('strong', {}, _('Version Information')) - ]), - E('div', { 'class': 'panel-body' }, [ - E('div', { 'style': 'margin-top: 10px; font-family: monospace; white-space: pre-wrap;' }, [ - E('strong', {}, _('Podkop: ')), E('span', { 'id': 'podkop-version' }, _('Loading...')), '\n', - E('strong', {}, _('LuCI App: ')), E('span', { 'id': 'luci-version' }, _('Loading...')), '\n', - E('strong', {}, _('Sing-box: ')), E('span', { 'id': 'singbox-version' }, _('Loading...')), '\n', - E('strong', {}, _('OpenWrt Version: ')), E('span', { 'id': 'openwrt-version' }, _('Loading...')), '\n', - E('strong', {}, _('Device Model: ')), E('span', { 'id': 'device-model' }, _('Loading...')) - ]) - ]) - ]) - ]) - ]); + // Version Information Panel + E( + 'div', + { + id: 'version-info-panel', + class: 'panel', + style: 'flex: 1; padding: 15px;', + }, + [ + E('div', { class: 'panel-heading' }, [ + E('strong', {}, _('Version Information')), + ]), + E('div', { class: 'panel-body' }, [ + E( + 'div', + { + style: + 'margin-top: 10px; font-family: monospace; white-space: pre-wrap;', + }, + [ + E('strong', {}, _('Podkop: ')), + E('span', { id: 'podkop-version' }, _('Loading...')), + '\n', + E('strong', {}, _('LuCI App: ')), + E('span', { id: 'luci-version' }, _('Loading...')), + '\n', + E('strong', {}, _('Sing-box: ')), + E('span', { id: 'singbox-version' }, _('Loading...')), + '\n', + E('strong', {}, _('OpenWrt Version: ')), + E('span', { id: 'openwrt-version' }, _('Loading...')), + '\n', + E('strong', {}, _('Device Model: ')), + E('span', { id: 'device-model' }, _('Loading...')), + ], + ), + ]), + ], + ), + ]), + ]); }; // Global variables for tracking state @@ -567,294 +784,444 @@ let isInitialCheck = true; showConfigModal.busy = false; function startDiagnosticsUpdates() { - if (diagnosticsUpdateTimer) { - clearInterval(diagnosticsUpdateTimer); - } + if (diagnosticsUpdateTimer) { + clearInterval(diagnosticsUpdateTimer); + } - // Immediately update when started - updateDiagnostics(); + // Immediately update when started + updateDiagnostics(); - // Then set up periodic updates - diagnosticsUpdateTimer = setInterval(updateDiagnostics, main.DIAGNOSTICS_UPDATE_INTERVAL); + // Then set up periodic updates + diagnosticsUpdateTimer = setInterval( + updateDiagnostics, + main.DIAGNOSTICS_UPDATE_INTERVAL, + ); } function stopDiagnosticsUpdates() { - if (diagnosticsUpdateTimer) { - clearInterval(diagnosticsUpdateTimer); - diagnosticsUpdateTimer = null; - } + if (diagnosticsUpdateTimer) { + clearInterval(diagnosticsUpdateTimer); + diagnosticsUpdateTimer = null; + } } // Update individual text element with new content function updateTextElement(elementId, content) { - const element = document.getElementById(elementId); - if (element) { - element.innerHTML = ''; - element.appendChild(content); - } + const element = document.getElementById(elementId); + if (element) { + element.innerHTML = ''; + element.appendChild(content); + } } async function updateDiagnostics() { - // Podkop Status check - safeExec('/usr/bin/podkop', ['get_status'], 'P0_PRIORITY', result => { - try { - const parsedPodkopStatus = JSON.parse(result.stdout || '{"enabled":0,"status":"error"}'); + // Podkop Status check + safeExec('/usr/bin/podkop', ['get_status'], 'P0_PRIORITY', (result) => { + try { + const parsedPodkopStatus = JSON.parse( + result.stdout || '{"enabled":0,"status":"error"}', + ); - // Update Podkop status text - updateTextElement('podkop-status-text', - E('span', { - 'style': `color: ${parsedPodkopStatus.enabled ? main.STATUS_COLORS.SUCCESS : main.STATUS_COLORS.ERROR}` - }, [ - parsedPodkopStatus.enabled ? '✔ Autostart enabled' : '✘ Autostart disabled' - ]) - ); + // Update Podkop status text + updateTextElement( + 'podkop-status-text', + E( + 'span', + { + style: `color: ${parsedPodkopStatus.enabled ? main.STATUS_COLORS.SUCCESS : main.STATUS_COLORS.ERROR}`, + }, + [ + parsedPodkopStatus.enabled + ? '✔ Autostart enabled' + : '✘ Autostart disabled', + ], + ), + ); - // Update autostart button - const autostartButton = parsedPodkopStatus.enabled ? - ButtonFactory.createInitActionButton({ - label: 'Disable Autostart', - type: 'remove', - action: 'disable', - reload: true - }) : - ButtonFactory.createInitActionButton({ - label: 'Enable Autostart', - type: 'apply', - action: 'enable', - reload: true - }); + // Update autostart button + const autostartButton = parsedPodkopStatus.enabled + ? ButtonFactory.createInitActionButton({ + label: 'Disable Autostart', + type: 'remove', + action: 'disable', + reload: true, + }) + : ButtonFactory.createInitActionButton({ + label: 'Enable Autostart', + type: 'apply', + action: 'enable', + reload: true, + }); - // Find the autostart button and replace it - const panel = document.getElementById('podkop-status-panel'); - if (panel) { - const buttons = panel.querySelectorAll('.cbi-button'); - if (buttons.length >= 3) { - buttons[2].parentNode.replaceChild(autostartButton, buttons[2]); - } + // Find the autostart button and replace it + const panel = document.getElementById('podkop-status-panel'); + if (panel) { + const buttons = panel.querySelectorAll('.cbi-button'); + if (buttons.length >= 3) { + buttons[2].parentNode.replaceChild(autostartButton, buttons[2]); + } + } + } catch (error) { + updateTextElement( + 'podkop-status-text', + E('span', { style: `color: ${main.STATUS_COLORS.ERROR}` }, '✘ Error'), + ); + } + }); + + // Sing-box Status check + safeExec( + '/usr/bin/podkop', + ['get_sing_box_status'], + 'P0_PRIORITY', + (result) => { + try { + const parsedSingboxStatus = JSON.parse( + result.stdout || '{"running":0,"enabled":0,"status":"error"}', + ); + + // Update Sing-box status text + updateTextElement( + 'singbox-status-text', + E( + 'span', + { + style: `color: ${ + parsedSingboxStatus.running && !parsedSingboxStatus.enabled + ? main.STATUS_COLORS.SUCCESS + : main.STATUS_COLORS.ERROR + }`, + }, + [ + parsedSingboxStatus.running && !parsedSingboxStatus.enabled + ? '✔ running' + : '✘ ' + parsedSingboxStatus.status, + ], + ), + ); + } catch (error) { + updateTextElement( + 'singbox-status-text', + E('span', { style: `color: ${main.STATUS_COLORS.ERROR}` }, '✘ Error'), + ); + } + }, + ); + + // Version Information checks + safeExec('/usr/bin/podkop', ['show_version'], 'P2_PRIORITY', (result) => { + updateTextElement( + 'podkop-version', + document.createTextNode( + result.stdout ? result.stdout.trim() : _('Unknown'), + ), + ); + }); + + safeExec( + '/usr/bin/podkop', + ['show_luci_version'], + 'P2_PRIORITY', + (result) => { + updateTextElement( + 'luci-version', + document.createTextNode( + result.stdout ? result.stdout.trim() : _('Unknown'), + ), + ); + }, + ); + + safeExec( + '/usr/bin/podkop', + ['show_sing_box_version'], + 'P2_PRIORITY', + (result) => { + updateTextElement( + 'singbox-version', + document.createTextNode( + result.stdout ? result.stdout.trim() : _('Unknown'), + ), + ); + }, + ); + + safeExec('/usr/bin/podkop', ['show_system_info'], 'P2_PRIORITY', (result) => { + if (result.stdout) { + updateTextElement( + 'openwrt-version', + document.createTextNode(result.stdout.split('\n')[1].trim()), + ); + updateTextElement( + 'device-model', + document.createTextNode(result.stdout.split('\n')[4].trim()), + ); + } else { + updateTextElement( + 'openwrt-version', + document.createTextNode(_('Unknown')), + ); + updateTextElement('device-model', document.createTextNode(_('Unknown'))); + } + }); + + // FakeIP and DNS status checks + runCheck(checkFakeIP, 'P3_PRIORITY', (result) => { + updateTextElement( + 'fakeip-browser-status', + E( + 'span', + { + style: `color: ${result.error ? main.STATUS_COLORS.WARNING : result.color}`, + }, + [ + result.error + ? '! ' + : result.state === 'working' + ? '✔ ' + : result.state === 'not_working' + ? '✘ ' + : '! ', + result.error + ? 'check error' + : result.state === 'working' + ? _('works in browser') + : _('does not work in browser'), + ], + ), + ); + }); + + runCheck(checkFakeIPCLI, 'P8_PRIORITY', (result) => { + updateTextElement( + 'fakeip-router-status', + E( + 'span', + { + style: `color: ${result.error ? main.STATUS_COLORS.WARNING : result.color}`, + }, + [ + result.error + ? '! ' + : result.state === 'working' + ? '✔ ' + : result.state === 'not_working' + ? '✘ ' + : '! ', + result.error + ? 'check error' + : result.state === 'working' + ? _('works on router') + : _('does not work on router'), + ], + ), + ); + }); + + runCheck(checkDNSAvailability, 'P4_PRIORITY', (result) => { + if (result.error) { + updateTextElement( + 'dns-remote-status', + E( + 'span', + { style: `color: ${main.STATUS_COLORS.WARNING}` }, + '! DNS check error', + ), + ); + updateTextElement( + 'dns-local-status', + E( + 'span', + { style: `color: ${main.STATUS_COLORS.WARNING}` }, + '! DNS check error', + ), + ); + } else { + updateTextElement( + 'dns-remote-status', + E('span', { style: `color: ${result.remote.color}` }, [ + result.remote.state === 'available' + ? '✔ ' + : result.remote.state === 'unavailable' + ? '✘ ' + : '! ', + result.remote.message, + ]), + ); + + updateTextElement( + 'dns-local-status', + E('span', { style: `color: ${result.local.color}` }, [ + result.local.state === 'available' + ? '✔ ' + : result.local.state === 'unavailable' + ? '✘ ' + : '! ', + result.local.message, + ]), + ); + } + }); + + runCheck( + checkBypass, + 'P1_PRIORITY', + (result) => { + updateTextElement( + 'bypass-status', + E( + 'span', + { + style: `color: ${result.error ? main.STATUS_COLORS.WARNING : result.color}`, + }, + [ + result.error + ? '! ' + : result.state === 'working' + ? '✔ ' + : result.state === 'not_working' + ? '✘ ' + : '! ', + result.error ? 'check error' : result.message, + ], + ), + ); + }, + 'P1_PRIORITY', + ); + + // Config name + runAsyncTask(async () => { + try { + let configName = _('Main config'); + const data = await uci.load('podkop'); + const proxyString = uci.get('podkop', 'main', 'proxy_string'); + + if (proxyString) { + const activeConfig = proxyString + .split('\n') + .map((line) => line.trim()) + .find((line) => line && !line.startsWith('//')); + + if (activeConfig) { + if (activeConfig.includes('#')) { + const label = activeConfig.split('#').pop(); + if (label && label.trim()) { + configName = _('Config: ') + decodeURIComponent(label); } - } catch (error) { - updateTextElement('podkop-status-text', - E('span', { 'style': `color: ${main.STATUS_COLORS.ERROR}` }, '✘ Error') - ); + } } - }); + } - // Sing-box Status check - safeExec('/usr/bin/podkop', ['get_sing_box_status'], 'P0_PRIORITY', result => { - try { - const parsedSingboxStatus = JSON.parse(result.stdout || '{"running":0,"enabled":0,"status":"error"}'); - - // Update Sing-box status text - updateTextElement('singbox-status-text', - E('span', { - 'style': `color: ${parsedSingboxStatus.running && !parsedSingboxStatus.enabled ? - main.STATUS_COLORS.SUCCESS : main.STATUS_COLORS.ERROR}` - }, [ - parsedSingboxStatus.running && !parsedSingboxStatus.enabled ? - '✔ running' : '✘ ' + parsedSingboxStatus.status - ]) - ); - } catch (error) { - updateTextElement('singbox-status-text', - E('span', { 'style': `color: ${main.STATUS_COLORS.ERROR}` }, '✘ Error') - ); - } - }); - - // Version Information checks - safeExec('/usr/bin/podkop', ['show_version'], 'P2_PRIORITY', result => { - updateTextElement('podkop-version', - document.createTextNode(result.stdout ? result.stdout.trim() : _('Unknown')) - ); - }); - - safeExec('/usr/bin/podkop', ['show_luci_version'], 'P2_PRIORITY', result => { - updateTextElement('luci-version', - document.createTextNode(result.stdout ? result.stdout.trim() : _('Unknown')) - ); - }); - - safeExec('/usr/bin/podkop', ['show_sing_box_version'], 'P2_PRIORITY', result => { - updateTextElement('singbox-version', - document.createTextNode(result.stdout ? result.stdout.trim() : _('Unknown')) - ); - }); - - safeExec('/usr/bin/podkop', ['show_system_info'], 'P2_PRIORITY', result => { - if (result.stdout) { - updateTextElement('openwrt-version', - document.createTextNode(result.stdout.split('\n')[1].trim()) - ); - updateTextElement('device-model', - document.createTextNode(result.stdout.split('\n')[4].trim()) - ); - } else { - updateTextElement('openwrt-version', document.createTextNode(_('Unknown'))); - updateTextElement('device-model', document.createTextNode(_('Unknown'))); - } - }); - - // FakeIP and DNS status checks - runCheck(checkFakeIP, 'P3_PRIORITY', result => { - updateTextElement('fakeip-browser-status', - E('span', { style: `color: ${result.error ? main.STATUS_COLORS.WARNING : result.color}` }, [ - result.error ? '! ' : result.state === 'working' ? '✔ ' : result.state === 'not_working' ? '✘ ' : '! ', - result.error ? 'check error' : result.state === 'working' ? _('works in browser') : _('does not work in browser') - ]) - ); - }); - - runCheck(checkFakeIPCLI, 'P8_PRIORITY', result => { - updateTextElement('fakeip-router-status', - E('span', { style: `color: ${result.error ? main.STATUS_COLORS.WARNING : result.color}` }, [ - result.error ? '! ' : result.state === 'working' ? '✔ ' : result.state === 'not_working' ? '✘ ' : '! ', - result.error ? 'check error' : result.state === 'working' ? _('works on router') : _('does not work on router') - ]) - ); - }); - - runCheck(checkDNSAvailability, 'P4_PRIORITY', result => { - if (result.error) { - updateTextElement('dns-remote-status', - E('span', { style: `color: ${main.STATUS_COLORS.WARNING}` }, '! DNS check error') - ); - updateTextElement('dns-local-status', - E('span', { style: `color: ${main.STATUS_COLORS.WARNING}` }, '! DNS check error') - ); - } else { - updateTextElement('dns-remote-status', - E('span', { style: `color: ${result.remote.color}` }, [ - result.remote.state === 'available' ? '✔ ' : result.remote.state === 'unavailable' ? '✘ ' : '! ', - result.remote.message - ]) - ); - - updateTextElement('dns-local-status', - E('span', { style: `color: ${result.local.color}` }, [ - result.local.state === 'available' ? '✔ ' : result.local.state === 'unavailable' ? '✘ ' : '! ', - result.local.message - ]) - ); - } - }); - - runCheck(checkBypass, 'P1_PRIORITY', result => { - updateTextElement('bypass-status', - E('span', { style: `color: ${result.error ? main.STATUS_COLORS.WARNING : result.color}` }, [ - result.error ? '! ' : result.state === 'working' ? '✔ ' : result.state === 'not_working' ? '✘ ' : '! ', - result.error ? 'check error' : result.message - ]) - ); - }, 'P1_PRIORITY'); - - // Config name - runAsyncTask(async () => { - try { - let configName = _('Main config'); - const data = await uci.load('podkop'); - const proxyString = uci.get('podkop', 'main', 'proxy_string'); - - if (proxyString) { - const activeConfig = proxyString.split('\n') - .map(line => line.trim()) - .find(line => line && !line.startsWith('//')); - - if (activeConfig) { - if (activeConfig.includes('#')) { - const label = activeConfig.split('#').pop(); - if (label && label.trim()) { - configName = _('Config: ') + decodeURIComponent(label); - } - } - } - } - - updateTextElement('config-name-text', document.createTextNode(configName)); - } catch (e) { - console.error('Error getting config name from UCI:', e); - } - }, 'P1_PRIORITY'); + updateTextElement( + 'config-name-text', + document.createTextNode(configName), + ); + } catch (e) { + console.error('Error getting config name from UCI:', e); + } + }, 'P1_PRIORITY'); } function createDiagnosticsSection(mainSection) { - let o = mainSection.tab('diagnostics', _('Diagnostics')); + let o = mainSection.tab('diagnostics', _('Diagnostics')); - o = mainSection.taboption('diagnostics', form.DummyValue, '_status'); - o.rawhtml = true; - o.cfgvalue = () => E('div', { - id: 'diagnostics-status', - 'data-loading': 'true' + o = mainSection.taboption('diagnostics', form.DummyValue, '_status'); + o.rawhtml = true; + o.cfgvalue = () => + E('div', { + id: 'diagnostics-status', + 'data-loading': 'true', }); } function setupDiagnosticsEventHandlers(node) { - const titleDiv = E('h2', { 'class': 'cbi-map-title' }, _('Podkop')); - node.insertBefore(titleDiv, node.firstChild); + const titleDiv = E('h2', { class: 'cbi-map-title' }, _('Podkop')); + node.insertBefore(titleDiv, node.firstChild); - // Function to initialize diagnostics - function initDiagnostics(container) { - if (container && container.hasAttribute('data-loading')) { - container.innerHTML = ''; - showConfigModal.busy = false; - createStatusSection().then(section => { - container.appendChild(section); - startDiagnosticsUpdates(); - // Start error polling when diagnostics tab is active - utils.startErrorPolling(); - }); - } + // Function to initialize diagnostics + function initDiagnostics(container) { + if (container && container.hasAttribute('data-loading')) { + container.innerHTML = ''; + showConfigModal.busy = false; + createStatusSection().then((section) => { + container.appendChild(section); + startDiagnosticsUpdates(); + // Start error polling when diagnostics tab is active + utils.startErrorPolling(); + }); + } + } + + document.addEventListener('visibilitychange', function () { + const diagnosticsContainer = document.getElementById('diagnostics-status'); + const diagnosticsTab = document.querySelector( + '.cbi-tab[data-tab="diagnostics"]', + ); + + if ( + document.hidden || + !diagnosticsTab || + !diagnosticsTab.classList.contains('cbi-tab-active') + ) { + stopDiagnosticsUpdates(); + // Don't stop error polling here - it's managed in podkop.js for all tabs + } else if ( + diagnosticsContainer && + diagnosticsContainer.hasAttribute('data-loading') + ) { + startDiagnosticsUpdates(); + // Ensure error polling is running when diagnostics tab is active + utils.startErrorPolling(); + } + }); + + setTimeout(() => { + const diagnosticsContainer = document.getElementById('diagnostics-status'); + const diagnosticsTab = document.querySelector( + '.cbi-tab[data-tab="diagnostics"]', + ); + const otherTabs = document.querySelectorAll( + '.cbi-tab:not([data-tab="diagnostics"])', + ); + + // Check for direct page load case + const noActiveTabsExist = !Array.from(otherTabs).some((tab) => + tab.classList.contains('cbi-tab-active'), + ); + + if ( + diagnosticsContainer && + diagnosticsTab && + (diagnosticsTab.classList.contains('cbi-tab-active') || noActiveTabsExist) + ) { + initDiagnostics(diagnosticsContainer); } - document.addEventListener('visibilitychange', function () { - const diagnosticsContainer = document.getElementById('diagnostics-status'); - const diagnosticsTab = document.querySelector('.cbi-tab[data-tab="diagnostics"]'); - - if (document.hidden || !diagnosticsTab || !diagnosticsTab.classList.contains('cbi-tab-active')) { + const tabs = node.querySelectorAll('.cbi-tabmenu'); + if (tabs.length > 0) { + tabs[0].addEventListener('click', function (e) { + const tab = e.target.closest('.cbi-tab'); + if (tab) { + const tabName = tab.getAttribute('data-tab'); + if (tabName === 'diagnostics') { + const container = document.getElementById('diagnostics-status'); + container.setAttribute('data-loading', 'true'); + initDiagnostics(container); + } else { stopDiagnosticsUpdates(); - // Don't stop error polling here - it's managed in podkop.js for all tabs - } else if (diagnosticsContainer && diagnosticsContainer.hasAttribute('data-loading')) { - startDiagnosticsUpdates(); - // Ensure error polling is running when diagnostics tab is active - utils.startErrorPolling(); + // Don't stop error polling - it should continue on all tabs + } } - }); + }); + } + }, main.DIAGNOSTICS_INITIAL_DELAY); - setTimeout(() => { - const diagnosticsContainer = document.getElementById('diagnostics-status'); - const diagnosticsTab = document.querySelector('.cbi-tab[data-tab="diagnostics"]'); - const otherTabs = document.querySelectorAll('.cbi-tab:not([data-tab="diagnostics"])'); - - // Check for direct page load case - const noActiveTabsExist = !Array.from(otherTabs).some(tab => tab.classList.contains('cbi-tab-active')); - - if (diagnosticsContainer && diagnosticsTab && (diagnosticsTab.classList.contains('cbi-tab-active') || noActiveTabsExist)) { - initDiagnostics(diagnosticsContainer); - } - - const tabs = node.querySelectorAll('.cbi-tabmenu'); - if (tabs.length > 0) { - tabs[0].addEventListener('click', function (e) { - const tab = e.target.closest('.cbi-tab'); - if (tab) { - const tabName = tab.getAttribute('data-tab'); - if (tabName === 'diagnostics') { - const container = document.getElementById('diagnostics-status'); - container.setAttribute('data-loading', 'true'); - initDiagnostics(container); - } else { - stopDiagnosticsUpdates(); - // Don't stop error polling - it should continue on all tabs - } - } - }); - } - }, main.DIAGNOSTICS_INITIAL_DELAY); - - node.classList.add('fade-in'); - return node; + node.classList.add('fade-in'); + return node; } return baseclass.extend({ - createDiagnosticsSection, - setupDiagnosticsEventHandlers + createDiagnosticsSection, + setupDiagnosticsEventHandlers, }); diff --git a/luci-app-podkop/htdocs/luci-static/resources/view/podkop/utils.js b/luci-app-podkop/htdocs/luci-static/resources/view/podkop/utils.js index 46cb086..f358670 100644 --- a/luci-app-podkop/htdocs/luci-static/resources/view/podkop/utils.js +++ b/luci-app-podkop/htdocs/luci-static/resources/view/podkop/utils.js @@ -15,138 +15,149 @@ let errorPollTimer = null; // Helper function to fetch errors from the podkop command async function getPodkopErrors() { - return new Promise(resolve => { - safeExec('/usr/bin/podkop', ['check_logs'], 'P0_PRIORITY', result => { - if (!result || !result.stdout) return resolve([]); + return new Promise((resolve) => { + safeExec('/usr/bin/podkop', ['check_logs'], 'P0_PRIORITY', (result) => { + if (!result || !result.stdout) return resolve([]); - const logs = result.stdout.split('\n'); - const errors = logs.filter(log => - log.includes('[critical]') - ); + const logs = result.stdout.split('\n'); + const errors = logs.filter((log) => log.includes('[critical]')); - resolve(errors); - }); + resolve(errors); }); + }); } // Show error notification to the user function showErrorNotification(error, isMultiple = false) { - const notificationContent = E('div', { 'class': 'alert-message error' }, [ - E('pre', { 'class': 'error-log' }, error) - ]); + const notificationContent = E('div', { class: 'alert-message error' }, [ + E('pre', { class: 'error-log' }, error), + ]); - ui.addNotification(null, notificationContent); + ui.addNotification(null, notificationContent); } // Helper function for command execution with prioritization -function safeExec(command, args, priority, callback, timeout = main.COMMAND_TIMEOUT) { - // Default to highest priority execution if priority is not provided or invalid - let schedulingDelay = main.COMMAND_SCHEDULING.P0_PRIORITY; +function safeExec( + command, + args, + priority, + callback, + timeout = main.COMMAND_TIMEOUT, +) { + // Default to highest priority execution if priority is not provided or invalid + let schedulingDelay = main.COMMAND_SCHEDULING.P0_PRIORITY; - // If priority is a string, try to get the corresponding delay value - if (typeof priority === 'string' && main.COMMAND_SCHEDULING[priority] !== undefined) { - schedulingDelay = main.COMMAND_SCHEDULING[priority]; + // If priority is a string, try to get the corresponding delay value + if ( + typeof priority === 'string' && + main.COMMAND_SCHEDULING[priority] !== undefined + ) { + schedulingDelay = main.COMMAND_SCHEDULING[priority]; + } + + const executeCommand = async () => { + try { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), timeout); + + const result = await Promise.race([ + fs.exec(command, args), + new Promise((_, reject) => { + controller.signal.addEventListener('abort', () => { + reject(new Error('Command execution timed out')); + }); + }), + ]); + + clearTimeout(timeoutId); + + if (callback && typeof callback === 'function') { + callback(result); + } + + return result; + } catch (error) { + console.warn( + `Command execution failed or timed out: ${command} ${args.join(' ')}`, + ); + const errorResult = { stdout: '', stderr: error.message, error: error }; + + if (callback && typeof callback === 'function') { + callback(errorResult); + } + + return errorResult; } + }; - const executeCommand = async () => { - try { - const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), timeout); - - const result = await Promise.race([ - fs.exec(command, args), - new Promise((_, reject) => { - controller.signal.addEventListener('abort', () => { - reject(new Error('Command execution timed out')); - }); - }) - ]); - - clearTimeout(timeoutId); - - if (callback && typeof callback === 'function') { - callback(result); - } - - return result; - } catch (error) { - console.warn(`Command execution failed or timed out: ${command} ${args.join(' ')}`); - const errorResult = { stdout: '', stderr: error.message, error: error }; - - if (callback && typeof callback === 'function') { - callback(errorResult); - } - - return errorResult; - } - }; - - if (callback && typeof callback === 'function') { - setTimeout(executeCommand, schedulingDelay); - return; - } - else { - return executeCommand(); - } + if (callback && typeof callback === 'function') { + setTimeout(executeCommand, schedulingDelay); + return; + } else { + return executeCommand(); + } } // Check for critical errors and show notifications async function checkForCriticalErrors() { - try { - const errors = await getPodkopErrors(); + try { + const errors = await getPodkopErrors(); - if (errors && errors.length > 0) { - // Filter out errors we've already seen - const newErrors = errors.filter(error => !lastErrorsSet.has(error)); + if (errors && errors.length > 0) { + // Filter out errors we've already seen + const newErrors = errors.filter((error) => !lastErrorsSet.has(error)); - if (newErrors.length > 0) { - // On initial check, just store errors without showing notifications - if (!isInitialCheck) { - // Show each new error as a notification - newErrors.forEach(error => { - showErrorNotification(error, newErrors.length > 1); - }); - } - - // Add new errors to our set of seen errors - newErrors.forEach(error => lastErrorsSet.add(error)); - } + if (newErrors.length > 0) { + // On initial check, just store errors without showing notifications + if (!isInitialCheck) { + // Show each new error as a notification + newErrors.forEach((error) => { + showErrorNotification(error, newErrors.length > 1); + }); } - // After first check, mark as no longer initial - isInitialCheck = false; - } catch (error) { - console.error('Error checking for critical messages:', error); + // Add new errors to our set of seen errors + newErrors.forEach((error) => lastErrorsSet.add(error)); + } } + + // After first check, mark as no longer initial + isInitialCheck = false; + } catch (error) { + console.error('Error checking for critical messages:', error); + } } // Start polling for errors at regular intervals function startErrorPolling() { - if (errorPollTimer) { - clearInterval(errorPollTimer); - } + if (errorPollTimer) { + clearInterval(errorPollTimer); + } - // Reset initial check flag to make sure we show errors - isInitialCheck = false; + // Reset initial check flag to make sure we show errors + isInitialCheck = false; - // Immediately check for errors on start - checkForCriticalErrors(); + // Immediately check for errors on start + checkForCriticalErrors(); - // Then set up periodic checks - errorPollTimer = setInterval(checkForCriticalErrors, main.ERROR_POLL_INTERVAL); + // Then set up periodic checks + errorPollTimer = setInterval( + checkForCriticalErrors, + main.ERROR_POLL_INTERVAL, + ); } // Stop polling for errors function stopErrorPolling() { - if (errorPollTimer) { - clearInterval(errorPollTimer); - errorPollTimer = null; - } + if (errorPollTimer) { + clearInterval(errorPollTimer); + errorPollTimer = null; + } } return baseclass.extend({ - startErrorPolling, - stopErrorPolling, - checkForCriticalErrors, - safeExec + startErrorPolling, + stopErrorPolling, + checkForCriticalErrors, + safeExec, }); From 99c8ead148941b9559bee32b99168176edf2ae7b Mon Sep 17 00:00:00 2001 From: divocat Date: Sun, 5 Oct 2025 16:17:35 +0300 Subject: [PATCH 30/49] fix: correct output format for test Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- fe-app-podkop/src/validators/tests/validateIp.test.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/fe-app-podkop/src/validators/tests/validateIp.test.js b/fe-app-podkop/src/validators/tests/validateIp.test.js index c2f0f0d..7b4f036 100644 --- a/fe-app-podkop/src/validators/tests/validateIp.test.js +++ b/fe-app-podkop/src/validators/tests/validateIp.test.js @@ -23,7 +23,7 @@ export const invalidIPs = [ describe('validateIPV4', () => { describe.each(validIPs)('Valid IP: %s', (_desc, ip) => { - it(`returns {valid:true for "${ip}"`, () => { + it(`returns {valid:true} for "${ip}"`, () => { const res = validateIPV4(ip); expect(res.valid).toBe(true); }); From d50b6dbab6a765c3ee9ed15f2e2157f351b2e674 Mon Sep 17 00:00:00 2001 From: divocat Date: Sun, 5 Oct 2025 16:37:56 +0300 Subject: [PATCH 31/49] fix: correct output format for test --- fe-app-podkop/src/validators/tests/validateIp.test.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/fe-app-podkop/src/validators/tests/validateIp.test.js b/fe-app-podkop/src/validators/tests/validateIp.test.js index 7b4f036..fe86c1c 100644 --- a/fe-app-podkop/src/validators/tests/validateIp.test.js +++ b/fe-app-podkop/src/validators/tests/validateIp.test.js @@ -30,7 +30,7 @@ describe('validateIPV4', () => { }); describe.each(invalidIPs)('Invalid IP: %s', (_desc, ip) => { - it(`returns {valid:false for "${ip}"`, () => { + it(`returns {valid:false} for "${ip}"`, () => { const res = validateIPV4(ip); expect(res.valid).toBe(false); }); From c5e19a0f2da2944ac49f92dcb6014ba96e5e5611 Mon Sep 17 00:00:00 2001 From: divocat Date: Sun, 5 Oct 2025 16:59:02 +0300 Subject: [PATCH 32/49] fix: remove unused params for url test string --- .../htdocs/luci-static/resources/view/podkop/configSection.js | 3 --- 1 file changed, 3 deletions(-) diff --git a/luci-app-podkop/htdocs/luci-static/resources/view/podkop/configSection.js b/luci-app-podkop/htdocs/luci-static/resources/view/podkop/configSection.js index 08d8fae..84107e6 100644 --- a/luci-app-podkop/htdocs/luci-static/resources/view/podkop/configSection.js +++ b/luci-app-podkop/htdocs/luci-static/resources/view/podkop/configSection.js @@ -185,9 +185,6 @@ function createConfigSection(section) { o.depends('proxy_config_type', 'urltest'); o.placeholder = 'vless://, ss://, trojan:// links'; o.rmempty = false; - o.textarea = true; - o.rows = 3; - o.wrap = 'soft'; o.validate = function (section_id, value) { // Optional if (!value || value.length === 0) { From 341f260fcfb430f67e7cc0ea57d2e783745973b4 Mon Sep 17 00:00:00 2001 From: divocat Date: Sun, 5 Oct 2025 18:13:19 +0300 Subject: [PATCH 33/49] refactor: change vless validation logic --- .../validators/tests/validateVlessUrl.test.js | 100 ++++ .../src/validators/validateShadowsocksUrl.ts | 7 + .../src/validators/validateTrojanUrl.ts | 7 + .../src/validators/validateVlessUrl.ts | 76 +-- .../resources/view/podkop/configSection.js | 431 +++++++++--------- .../luci-static/resources/view/podkop/main.js | 71 +-- 6 files changed, 418 insertions(+), 274 deletions(-) create mode 100644 fe-app-podkop/src/validators/tests/validateVlessUrl.test.js diff --git a/fe-app-podkop/src/validators/tests/validateVlessUrl.test.js b/fe-app-podkop/src/validators/tests/validateVlessUrl.test.js new file mode 100644 index 0000000..114cb22 --- /dev/null +++ b/fe-app-podkop/src/validators/tests/validateVlessUrl.test.js @@ -0,0 +1,100 @@ +import { describe, it, expect } from 'vitest'; +import { validateVlessUrl } from '../validateVlessUrl'; + +const validUrls = [ + // TCP + [ + 'tcp + none', + 'vless://94792286-7bbe-4f33-8b36-18d1bbf70723@127.0.0.1:34520?type=tcp&encryption=none&security=none#vless-tcp-none', + ], + [ + 'tcp + reality', + 'vless://e95163dc-905e-480a-afe5-20b146288679@127.0.0.1:16399?type=tcp&encryption=none&security=reality&pbk=tqhSkeDR6jsqC-BYCnZWBrdL33g705ba8tV5-ZboWTM&fp=chrome&sni=google.com&sid=f6&spx=%2F#vless-tcp-reality', + ], + [ + 'tcp + tls', + 'vless://2e9e8288-060e-4da2-8b9f-a1c81826feb7@127.0.0.1:19316?type=tcp&encryption=none&security=tls&fp=chrome&alpn=h2%2Chttp%2F1.1&sni=google.com#vless-tcp-tls', + ], + // mKCP + [ + 'mKCP + none', + 'vless://72e201d7-7841-4a32-b266-4aa3eb776d51@127.0.0.1:17270?type=kcp&encryption=none&headerType=none&seed=AirziWi4ng&security=none#vless-mKCP', + ], + // WebSocket + [ + 'ws + none', + 'vless://d86daef7-565b-4ecd-a9ee-bac847ad38e6@127.0.0.1:12928?type=ws&encryption=none&path=%2Fwspath&host=google.com&security=none#vless-websocket-none', + ], + [ + 'ws + tls', + 'vless://fe0f0941-09a9-4e46-bc69-e00190d7bb9c@127.0.0.1:10156?type=ws&encryption=none&path=%2Fwspath&host=google.com&security=tls&fp=chrome&sni=google.com#vless-websocket-tls', + ], + // gRPC + [ + 'grpc + none', + 'vless://974b39e3-f7bf-42b9-933c-16699c635e77@127.0.0.1:15633?type=grpc&encryption=none&serviceName=TunService&security=none#vless-gRPC-none', + ], + [ + 'grpc + reality', + 'vless://651e7eca-5152-46f1-baf2-d502e0af7b27@127.0.0.1:28535?type=grpc&encryption=none&serviceName=TunService&security=reality&pbk=nhZ7NiKfcqESa5ZeBFfsq9o18W-OWOAHLln9UmuVXSk&fp=chrome&sni=google.com&sid=11cbaeaa&spx=%2F#vless-gRPC-reality', + ], + // HTTPUpgrade + [ + 'httpupgrade + none', + 'vless://2b98f144-847f-42f7-8798-e1a32d27bdc7@127.0.0.1:47154?type=httpupgrade&encryption=none&path=%2Fhttpupgradepath&host=google.com&security=none#vless-httpupgrade-none', + ], + [ + 'httpupgrade + tls', + 'vless://76dbd0ff-1a35-4f0c-a9ba-3c5890b7dea6@127.0.0.1:50639?type=httpupgrade&encryption=none&path=%2Fhttpupgradepath&host=google.com&security=tls&sni=google.com#vless-httpupgrade-tls', + ], + // XHTTP + [ + 'xhttp + none', + 'vless://c2841505-ec32-4b8d-b6dd-3e19d648c321@127.0.0.1:45507?type=xhttp&encryption=none&path=%2Fxhttppath&host=xhttp&mode=auto&security=none#vless-xhttp', + ], +]; + +const invalidUrls = [ + ['No prefix', 'uuid@host:443?type=tcp&security=tls'], + ['No uuid', 'vless://@127.0.0.1:443?type=tcp&security=tls'], + ['No host', 'vless://uuid@:443?type=tcp&security=tls'], + ['No port', 'vless://uuid@127.0.0.1?type=tcp&security=tls'], + ['Invalid port', 'vless://uuid@127.0.0.1:abc?type=tcp&security=tls'], + ['Missing type', 'vless://uuid@127.0.0.1:443?security=tls'], + ['Missing security', 'vless://uuid@127.0.0.1:443?type=tcp'], + [ + 'reality without pbk', + 'vless://uuid@127.0.0.1:443?type=tcp&security=reality&fp=chrome', + ], + [ + 'reality without fp', + 'vless://uuid@127.0.0.1:443?type=tcp&security=reality&pbk=abc', + ], + [ + 'tcp + reality + unexpected spaces', + 'vless://e95163dc-905e-480a-afe5-20b146288679@127.0.0.1:16399?type=tcp&encryption=none&security=reality&pbk=tqhSkeDR6jsqC-BYCnZWBrdL33g705ba8tV5-ZboWTM&fp=chrome&sni= google.com&sid=f6&spx=%2F#vless-tcp-reality', + ], +]; + +describe('validateVlessUrl', () => { + describe.each(validUrls)('Valid URL: %s', (_desc, url) => { + it(`returns valid=true for "${url}"`, () => { + const res = validateVlessUrl(url); + expect(res.valid).toBe(true); + }); + }); + + describe.each(invalidUrls)('Invalid URL: %s', (_desc, url) => { + it(`returns valid=false for "${url}"`, () => { + const res = validateVlessUrl(url); + expect(res.valid).toBe(false); + }); + }); + + it('detects invalid port range', () => { + const res = validateVlessUrl( + 'vless://uuid@127.0.0.1:99999?type=tcp&security=tls', + ); + expect(res.valid).toBe(false); + }); +}); diff --git a/fe-app-podkop/src/validators/validateShadowsocksUrl.ts b/fe-app-podkop/src/validators/validateShadowsocksUrl.ts index ca9e40e..68081a7 100644 --- a/fe-app-podkop/src/validators/validateShadowsocksUrl.ts +++ b/fe-app-podkop/src/validators/validateShadowsocksUrl.ts @@ -10,6 +10,13 @@ export function validateShadowsocksUrl(url: string): ValidationResult { } try { + if (!url || /\s/.test(url)) { + return { + valid: false, + message: 'Invalid Shadowsocks URL: must not contain spaces', + }; + } + const mainPart = url.includes('?') ? url.split('?')[0] : url.split('#')[0]; const encryptedPart = mainPart.split('/')[2]?.split('@')[0]; diff --git a/fe-app-podkop/src/validators/validateTrojanUrl.ts b/fe-app-podkop/src/validators/validateTrojanUrl.ts index 49b85b6..f79536c 100644 --- a/fe-app-podkop/src/validators/validateTrojanUrl.ts +++ b/fe-app-podkop/src/validators/validateTrojanUrl.ts @@ -9,6 +9,13 @@ export function validateTrojanUrl(url: string): ValidationResult { }; } + if (!url || /\s/.test(url)) { + return { + valid: false, + message: 'Invalid Trojan URL: must not contain spaces', + }; + } + try { const parsedUrl = new URL(url); diff --git a/fe-app-podkop/src/validators/validateVlessUrl.ts b/fe-app-podkop/src/validators/validateVlessUrl.ts index 22189ab..e74ffa6 100644 --- a/fe-app-podkop/src/validators/validateVlessUrl.ts +++ b/fe-app-podkop/src/validators/validateVlessUrl.ts @@ -2,62 +2,68 @@ import { ValidationResult } from './types'; // TODO refactor current validation and add tests export function validateVlessUrl(url: string): ValidationResult { - if (!url.startsWith('vless://')) { - return { - valid: false, - message: 'Invalid VLESS URL: must start with vless://', - }; - } - try { - const uuid = url.split('/')[2]?.split('@')[0]; + const parsedUrl = new URL(url); - if (!uuid) { + if (!url || /\s/.test(url)) { + return { + valid: false, + message: 'Invalid VLESS URL: must not contain spaces', + }; + } + + if (parsedUrl.protocol !== 'vless:') { + return { + valid: false, + message: 'Invalid VLESS URL: must start with vless://', + }; + } + + if (!parsedUrl.username) { return { valid: false, message: 'Invalid VLESS URL: missing UUID' }; } - const serverPart = url.split('@')[1]; - - if (!serverPart) { - return { - valid: false, - message: 'Invalid VLESS URL: missing server address', - }; - } - - const [server, portAndRest] = serverPart.split(':'); - - if (!server) { + if (!parsedUrl.hostname) { return { valid: false, message: 'Invalid VLESS URL: missing server' }; } - const port = portAndRest ? portAndRest.split(/[/?#]/)[0] : null; - - if (!port) { + if (!parsedUrl.port) { return { valid: false, message: 'Invalid VLESS URL: missing port' }; } - const portNum = parseInt(port, 10); - - if (isNaN(portNum) || portNum < 1 || portNum > 65535) { + if ( + isNaN(+parsedUrl.port) || + +parsedUrl.port < 1 || + +parsedUrl.port > 65535 + ) { return { valid: false, - message: 'Invalid port number. Must be between 1 and 65535', + message: + 'Invalid VLESS URL: invalid port number. Must be between 1 and 65535', }; } - const queryString = url.split('?')[1]; - - if (!queryString) { + if (!parsedUrl.search) { return { valid: false, message: 'Invalid VLESS URL: missing query parameters', }; } - const params = new URLSearchParams(queryString.split('#')[0]); + const params = new URLSearchParams(parsedUrl.search); + const type = params.get('type'); - const validTypes = ['tcp', 'raw', 'udp', 'grpc', 'http', 'ws']; + const validTypes = [ + 'tcp', + 'raw', + 'udp', + 'grpc', + 'http', + 'httpupgrade', + 'xhttp', + 'ws', + 'kcp', + ]; if (!type || !validTypes.includes(type)) { return { @@ -94,9 +100,9 @@ export function validateVlessUrl(url: string): ValidationResult { }; } } + + return { valid: true, message: 'Valid' }; } catch (_e) { return { valid: false, message: 'Invalid VLESS URL: parsing failed' }; } - - return { valid: true, message: 'Valid' }; } diff --git a/luci-app-podkop/htdocs/luci-static/resources/view/podkop/configSection.js b/luci-app-podkop/htdocs/luci-static/resources/view/podkop/configSection.js index 84107e6..6ce0f09 100644 --- a/luci-app-podkop/htdocs/luci-static/resources/view/podkop/configSection.js +++ b/luci-app-podkop/htdocs/luci-static/resources/view/podkop/configSection.js @@ -12,11 +12,11 @@ function createConfigSection(section) { let o = s.tab('basic', _('Basic Settings')); o = s.taboption( - 'basic', - form.ListValue, - 'mode', - _('Connection Type'), - _('Select between VPN and Proxy connection methods for traffic routing'), + 'basic', + form.ListValue, + 'mode', + _('Connection Type'), + _('Select between VPN and Proxy connection methods for traffic routing'), ); o.value('proxy', 'Proxy'); o.value('vpn', 'VPN'); @@ -24,11 +24,11 @@ function createConfigSection(section) { o.ucisection = s.section; o = s.taboption( - 'basic', - form.ListValue, - 'proxy_config_type', - _('Configuration Type'), - _('Select how to configure the proxy'), + 'basic', + form.ListValue, + 'proxy_config_type', + _('Configuration Type'), + _('Select how to configure the proxy'), ); o.value('url', _('Connection URL')); o.value('outbound', _('Outbound Config')); @@ -38,11 +38,11 @@ function createConfigSection(section) { o.ucisection = s.section; o = s.taboption( - 'basic', - form.TextValue, - 'proxy_string', - _('Proxy Configuration URL'), - '', + 'basic', + form.TextValue, + 'proxy_string', + _('Proxy Configuration URL'), + '', ); o.depends('proxy_config_type', 'url'); o.rows = 5; @@ -52,7 +52,7 @@ function createConfigSection(section) { o.ucisection = s.section; o.sectionDescriptions = new Map(); o.placeholder = - 'vless://uuid@server:port?type=tcp&security=tls#main\n// backup ss://method:pass@server:port\n// backup2 vless://uuid@server:port?type=grpc&security=reality#alt\n// backup3 trojan://04agAQapcl@127.0.0.1:33641?type=tcp&security=none#trojan-tcp-none'; + 'vless://uuid@server:port?type=tcp&security=tls#main\n// backup ss://method:pass@server:port\n// backup2 vless://uuid@server:port?type=grpc&security=reality#alt\n// backup3 trojan://04agAQapcl@127.0.0.1:33641?type=tcp&security=none#trojan-tcp-none'; o.renderWidget = function (section_id, option_index, cfgvalue) { const original = form.TextValue.prototype.renderWidget.apply(this, [ @@ -66,9 +66,9 @@ function createConfigSection(section) { if (cfgvalue) { try { const activeConfig = cfgvalue - .split('\n') - .map((line) => line.trim()) - .find((line) => line && !line.startsWith('//')); + .split('\n') + .map((line) => line.trim()) + .find((line) => line && !line.startsWith('//')); if (activeConfig) { if (activeConfig.includes('#')) { @@ -76,24 +76,24 @@ function createConfigSection(section) { if (label && label.trim()) { const decodedLabel = decodeURIComponent(label); const descDiv = E( - 'div', - { class: 'cbi-value-description' }, - _('Current config: ') + decodedLabel, + 'div', + { class: 'cbi-value-description' }, + _('Current config: ') + decodedLabel, ); container.appendChild(descDiv); } else { const descDiv = E( - 'div', - { class: 'cbi-value-description' }, - _('Config without description'), + 'div', + { class: 'cbi-value-description' }, + _('Config without description'), ); container.appendChild(descDiv); } } else { const descDiv = E( - 'div', - { class: 'cbi-value-description' }, - _('Config without description'), + 'div', + { class: 'cbi-value-description' }, + _('Config without description'), ); container.appendChild(descDiv); } @@ -101,19 +101,19 @@ function createConfigSection(section) { } catch (e) { console.error('Error parsing config label:', e); const descDiv = E( - 'div', - { class: 'cbi-value-description' }, - _('Config without description'), + 'div', + { class: 'cbi-value-description' }, + _('Config without description'), ); container.appendChild(descDiv); } } else { const defaultDesc = E( - 'div', - { class: 'cbi-value-description' }, - _( - 'Enter connection string starting with vless:// or ss:// for proxy configuration. Add comments with // for backup configs', - ), + 'div', + { class: 'cbi-value-description' }, + _( + 'Enter connection string starting with vless:// or ss:// for proxy configuration. Add comments with // for backup configs', + ), ); container.appendChild(defaultDesc); } @@ -128,18 +128,25 @@ function createConfigSection(section) { } try { - const activeConfig = value - .split('\n') - .map((line) => line.trim()) - .find((line) => line && !line.startsWith('//')); + const activeConfigs = value + .split('\n') + .map((line) => line.trim()) + .filter((line) => !line.startsWith('//')) + .filter(Boolean); - if (!activeConfig) { + if (!activeConfigs.length) { return _( - 'No active configuration found. At least one non-commented line is required.', + 'No active configuration found. One configuration is required.', ); } - const validation = main.validateProxyUrl(activeConfig); + if (activeConfigs.length > 1) { + return _( + 'Multiply active configurations found. Please leave one configuration.', + ); + } + + const validation = main.validateProxyUrl(activeConfigs[0]); if (validation.valid) { return true; @@ -152,11 +159,11 @@ function createConfigSection(section) { }; o = s.taboption( - 'basic', - form.TextValue, - 'outbound_json', - _('Outbound Configuration'), - _('Enter complete outbound configuration in JSON format'), + 'basic', + form.TextValue, + 'outbound_json', + _('Outbound Configuration'), + _('Enter complete outbound configuration in JSON format'), ); o.depends('proxy_config_type', 'outbound'); o.rows = 10; @@ -177,10 +184,10 @@ function createConfigSection(section) { }; o = s.taboption( - 'basic', - form.DynamicList, - 'urltest_proxy_links', - _('URLTest Proxy Links'), + 'basic', + form.DynamicList, + 'urltest_proxy_links', + _('URLTest Proxy Links'), ); o.depends('proxy_config_type', 'urltest'); o.placeholder = 'vless://, ss://, trojan:// links'; @@ -201,11 +208,11 @@ function createConfigSection(section) { }; o = s.taboption( - 'basic', - form.Flag, - 'ss_uot', - _('Shadowsocks UDP over TCP'), - _('Apply for SS2022'), + 'basic', + form.Flag, + 'ss_uot', + _('Shadowsocks UDP over TCP'), + _('Apply for SS2022'), ); o.default = '0'; o.depends('mode', 'proxy'); @@ -213,11 +220,11 @@ function createConfigSection(section) { o.ucisection = s.section; o = s.taboption( - 'basic', - widgets.DeviceSelect, - 'interface', - _('Network Interface'), - _('Select network interface for VPN connection'), + 'basic', + widgets.DeviceSelect, + 'interface', + _('Network Interface'), + _('Select network interface for VPN connection'), ); o.depends('mode', 'vpn'); o.ucisection = s.section; @@ -255,17 +262,17 @@ function createConfigSection(section) { // Reject wireless-related devices const isWireless = - type === 'wifi' || type === 'wireless' || type.includes('wlan'); + type === 'wifi' || type === 'wireless' || type.includes('wlan'); return !isWireless; }; o = s.taboption( - 'basic', - form.Flag, - 'domain_resolver_enabled', - _('Domain Resolver'), - _('Enable built-in DNS resolver for domains handled by this section'), + 'basic', + form.Flag, + 'domain_resolver_enabled', + _('Domain Resolver'), + _('Enable built-in DNS resolver for domains handled by this section'), ); o.default = '0'; o.rmempty = false; @@ -273,11 +280,11 @@ function createConfigSection(section) { o.ucisection = s.section; o = s.taboption( - 'basic', - form.ListValue, - 'domain_resolver_dns_type', - _('DNS Protocol Type'), - _('Select the DNS protocol type for the domain resolver'), + 'basic', + form.ListValue, + 'domain_resolver_dns_type', + _('DNS Protocol Type'), + _('Select the DNS protocol type for the domain resolver'), ); o.value('doh', _('DNS over HTTPS (DoH)')); o.value('dot', _('DNS over TLS (DoT)')); @@ -288,11 +295,11 @@ function createConfigSection(section) { o.ucisection = s.section; o = s.taboption( - 'basic', - form.Value, - 'domain_resolver_dns_server', - _('DNS Server'), - _('Select or enter DNS server address'), + 'basic', + form.Value, + 'domain_resolver_dns_server', + _('DNS Server'), + _('Select or enter DNS server address'), ); Object.entries(main.DNS_SERVER_OPTIONS).forEach(([key, label]) => { o.value(key, _(label)); @@ -312,21 +319,21 @@ function createConfigSection(section) { }; o = s.taboption( - 'basic', - form.Flag, - 'community_lists_enabled', - _('Community Lists'), + 'basic', + form.Flag, + 'community_lists_enabled', + _('Community Lists'), ); o.default = '0'; o.rmempty = false; o.ucisection = s.section; o = s.taboption( - 'basic', - form.DynamicList, - 'community_lists', - _('Service List'), - _('Select predefined service for routing') + + 'basic', + form.DynamicList, + 'community_lists', + _('Service List'), + _('Select predefined service for routing') + ' github.com/itdoginfo/allow-domains', ); o.placeholder = 'Service list'; @@ -350,50 +357,50 @@ function createConfigSection(section) { let notifications = []; const selectedRegionalOptions = main.REGIONAL_OPTIONS.filter((opt) => - newValues.includes(opt), + newValues.includes(opt), ); if (selectedRegionalOptions.length > 1) { const lastSelected = - selectedRegionalOptions[selectedRegionalOptions.length - 1]; + selectedRegionalOptions[selectedRegionalOptions.length - 1]; const removedRegions = selectedRegionalOptions.slice(0, -1); newValues = newValues.filter( - (v) => v === lastSelected || !main.REGIONAL_OPTIONS.includes(v), + (v) => v === lastSelected || !main.REGIONAL_OPTIONS.includes(v), ); notifications.push( - E('p', { class: 'alert-message warning' }, [ - E('strong', {}, _('Regional options cannot be used together')), - E('br'), - _( - 'Warning: %s cannot be used together with %s. Previous selections have been removed.', - ).format(removedRegions.join(', '), lastSelected), - ]), + E('p', { class: 'alert-message warning' }, [ + E('strong', {}, _('Regional options cannot be used together')), + E('br'), + _( + 'Warning: %s cannot be used together with %s. Previous selections have been removed.', + ).format(removedRegions.join(', '), lastSelected), + ]), ); } if (newValues.includes('russia_inside')) { const removedServices = newValues.filter( - (v) => !main.ALLOWED_WITH_RUSSIA_INSIDE.includes(v), + (v) => !main.ALLOWED_WITH_RUSSIA_INSIDE.includes(v), ); if (removedServices.length > 0) { newValues = newValues.filter((v) => - main.ALLOWED_WITH_RUSSIA_INSIDE.includes(v), + main.ALLOWED_WITH_RUSSIA_INSIDE.includes(v), ); notifications.push( - E('p', { class: 'alert-message warning' }, [ - E('strong', {}, _('Russia inside restrictions')), - E('br'), - _( - 'Warning: Russia inside can only be used with %s. %s already in Russia inside and have been removed from selection.', - ).format( - main.ALLOWED_WITH_RUSSIA_INSIDE.map( - (key) => main.DOMAIN_LIST_OPTIONS[key], - ) - .filter((label) => label !== 'Russia inside') - .join(', '), - removedServices.join(', '), - ), - ]), + E('p', { class: 'alert-message warning' }, [ + E('strong', {}, _('Russia inside restrictions')), + E('br'), + _( + 'Warning: Russia inside can only be used with %s. %s already in Russia inside and have been removed from selection.', + ).format( + main.ALLOWED_WITH_RUSSIA_INSIDE.map( + (key) => main.DOMAIN_LIST_OPTIONS[key], + ) + .filter((label) => label !== 'Russia inside') + .join(', '), + removedServices.join(', '), + ), + ]), ); } } @@ -403,7 +410,7 @@ function createConfigSection(section) { } notifications.forEach((notification) => - ui.addNotification(null, notification), + ui.addNotification(null, notification), ); lastValues = newValues; } catch (e) { @@ -414,11 +421,11 @@ function createConfigSection(section) { }; o = s.taboption( - 'basic', - form.ListValue, - 'user_domain_list_type', - _('User Domain List Type'), - _('Select how to add your custom domains'), + 'basic', + form.ListValue, + 'user_domain_list_type', + _('User Domain List Type'), + _('Select how to add your custom domains'), ); o.value('disabled', _('Disabled')); o.value('dynamic', _('Dynamic List')); @@ -428,13 +435,13 @@ function createConfigSection(section) { o.ucisection = s.section; o = s.taboption( - 'basic', - form.DynamicList, - 'user_domains', - _('User Domains'), - _( - 'Enter domain names without protocols (example: sub.example.com or example.com)', - ), + 'basic', + form.DynamicList, + 'user_domains', + _('User Domains'), + _( + 'Enter domain names without protocols (example: sub.example.com or example.com)', + ), ); o.placeholder = 'Domains list'; o.depends('user_domain_list_type', 'dynamic'); @@ -456,16 +463,16 @@ function createConfigSection(section) { }; o = s.taboption( - 'basic', - form.TextValue, - 'user_domains_text', - _('User Domains List'), - _( - 'Enter domain names separated by comma, space or newline. You can add comments after //', - ), + 'basic', + form.TextValue, + 'user_domains_text', + _('User Domains List'), + _( + 'Enter domain names separated by comma, space or newline. You can add comments after //', + ), ); o.placeholder = - 'example.com, sub.example.com\n// Social networks\ndomain.com test.com // personal domains'; + 'example.com, sub.example.com\n// Social networks\ndomain.com test.com // personal domains'; o.depends('user_domain_list_type', 'text'); o.rows = 8; o.rmempty = false; @@ -480,7 +487,7 @@ function createConfigSection(section) { if (!domains.length) { return _( - 'At least one valid domain must be specified. Comments-only content is not allowed.', + 'At least one valid domain must be specified. Comments-only content is not allowed.', ); } @@ -488,8 +495,8 @@ function createConfigSection(section) { if (!valid) { const errors = results - .filter((validation) => !validation.valid) // Leave only failed validations - .map((validation) => _(`${validation.value}: ${validation.message}`)); // Collect validation errors + .filter((validation) => !validation.valid) // Leave only failed validations + .map((validation) => _(`${validation.value}: ${validation.message}`)); // Collect validation errors return [_('Validation errors:'), ...errors].join('\n'); } @@ -498,22 +505,22 @@ function createConfigSection(section) { }; o = s.taboption( - 'basic', - form.Flag, - 'local_domain_lists_enabled', - _('Local Domain Lists'), - _('Use the list from the router filesystem'), + 'basic', + form.Flag, + 'local_domain_lists_enabled', + _('Local Domain Lists'), + _('Use the list from the router filesystem'), ); o.default = '0'; o.rmempty = false; o.ucisection = s.section; o = s.taboption( - 'basic', - form.DynamicList, - 'local_domain_lists', - _('Local Domain List Paths'), - _('Enter the list file path'), + 'basic', + form.DynamicList, + 'local_domain_lists', + _('Local Domain List Paths'), + _('Enter the list file path'), ); o.placeholder = '/path/file.lst'; o.depends('local_domain_lists_enabled', '1'); @@ -535,22 +542,22 @@ function createConfigSection(section) { }; o = s.taboption( - 'basic', - form.Flag, - 'remote_domain_lists_enabled', - _('Remote Domain Lists'), - _('Download and use domain lists from remote URLs'), + 'basic', + form.Flag, + 'remote_domain_lists_enabled', + _('Remote Domain Lists'), + _('Download and use domain lists from remote URLs'), ); o.default = '0'; o.rmempty = false; o.ucisection = s.section; o = s.taboption( - 'basic', - form.DynamicList, - 'remote_domain_lists', - _('Remote Domain URLs'), - _('Enter full URLs starting with http:// or https://'), + 'basic', + form.DynamicList, + 'remote_domain_lists', + _('Remote Domain URLs'), + _('Enter full URLs starting with http:// or https://'), ); o.placeholder = 'URL'; o.depends('remote_domain_lists_enabled', '1'); @@ -572,22 +579,22 @@ function createConfigSection(section) { }; o = s.taboption( - 'basic', - form.Flag, - 'local_subnet_lists_enabled', - _('Local Subnet Lists'), - _('Use the list from the router filesystem'), + 'basic', + form.Flag, + 'local_subnet_lists_enabled', + _('Local Subnet Lists'), + _('Use the list from the router filesystem'), ); o.default = '0'; o.rmempty = false; o.ucisection = s.section; o = s.taboption( - 'basic', - form.DynamicList, - 'local_subnet_lists', - _('Local Subnet List Paths'), - _('Enter the list file path'), + 'basic', + form.DynamicList, + 'local_subnet_lists', + _('Local Subnet List Paths'), + _('Enter the list file path'), ); o.placeholder = '/path/file.lst'; o.depends('local_subnet_lists_enabled', '1'); @@ -609,11 +616,11 @@ function createConfigSection(section) { }; o = s.taboption( - 'basic', - form.ListValue, - 'user_subnet_list_type', - _('User Subnet List Type'), - _('Select how to add your custom subnets'), + 'basic', + form.ListValue, + 'user_subnet_list_type', + _('User Subnet List Type'), + _('Select how to add your custom subnets'), ); o.value('disabled', _('Disabled')); o.value('dynamic', _('Dynamic List')); @@ -623,13 +630,13 @@ function createConfigSection(section) { o.ucisection = s.section; o = s.taboption( - 'basic', - form.DynamicList, - 'user_subnets', - _('User Subnets'), - _( - 'Enter subnets in CIDR notation (example: 103.21.244.0/22) or single IP addresses', - ), + 'basic', + form.DynamicList, + 'user_subnets', + _('User Subnets'), + _( + 'Enter subnets in CIDR notation (example: 103.21.244.0/22) or single IP addresses', + ), ); o.placeholder = 'IP or subnet'; o.depends('user_subnet_list_type', 'dynamic'); @@ -651,16 +658,16 @@ function createConfigSection(section) { }; o = s.taboption( - 'basic', - form.TextValue, - 'user_subnets_text', - _('User Subnets List'), - _( - 'Enter subnets in CIDR notation or single IP addresses, separated by comma, space or newline. You can add comments after //', - ), + 'basic', + form.TextValue, + 'user_subnets_text', + _('User Subnets List'), + _( + 'Enter subnets in CIDR notation or single IP addresses, separated by comma, space or newline. You can add comments after //', + ), ); o.placeholder = - '103.21.244.0/22\n// Google DNS\n8.8.8.8\n1.1.1.1/32, 9.9.9.9 // Cloudflare and Quad9'; + '103.21.244.0/22\n// Google DNS\n8.8.8.8\n1.1.1.1/32, 9.9.9.9 // Cloudflare and Quad9'; o.depends('user_subnet_list_type', 'text'); o.rows = 10; o.rmempty = false; @@ -675,7 +682,7 @@ function createConfigSection(section) { if (!subnets.length) { return _( - 'At least one valid subnet or IP must be specified. Comments-only content is not allowed.', + 'At least one valid subnet or IP must be specified. Comments-only content is not allowed.', ); } @@ -683,8 +690,8 @@ function createConfigSection(section) { if (!valid) { const errors = results - .filter((validation) => !validation.valid) // Leave only failed validations - .map((validation) => _(`${validation.value}: ${validation.message}`)); // Collect validation errors + .filter((validation) => !validation.valid) // Leave only failed validations + .map((validation) => _(`${validation.value}: ${validation.message}`)); // Collect validation errors return [_('Validation errors:'), ...errors].join('\n'); } @@ -693,22 +700,22 @@ function createConfigSection(section) { }; o = s.taboption( - 'basic', - form.Flag, - 'remote_subnet_lists_enabled', - _('Remote Subnet Lists'), - _('Download and use subnet lists from remote URLs'), + 'basic', + form.Flag, + 'remote_subnet_lists_enabled', + _('Remote Subnet Lists'), + _('Download and use subnet lists from remote URLs'), ); o.default = '0'; o.rmempty = false; o.ucisection = s.section; o = s.taboption( - 'basic', - form.DynamicList, - 'remote_subnet_lists', - _('Remote Subnet URLs'), - _('Enter full URLs starting with http:// or https://'), + 'basic', + form.DynamicList, + 'remote_subnet_lists', + _('Remote Subnet URLs'), + _('Enter full URLs starting with http:// or https://'), ); o.placeholder = 'URL'; o.depends('remote_subnet_lists_enabled', '1'); @@ -730,24 +737,24 @@ function createConfigSection(section) { }; o = s.taboption( - 'basic', - form.Flag, - 'all_traffic_from_ip_enabled', - _('IP for full redirection'), - _( - 'Specify local IP addresses whose traffic will always use the configured route', - ), + 'basic', + form.Flag, + 'all_traffic_from_ip_enabled', + _('IP for full redirection'), + _( + 'Specify local IP addresses whose traffic will always use the configured route', + ), ); o.default = '0'; o.rmempty = false; o.ucisection = s.section; o = s.taboption( - 'basic', - form.DynamicList, - 'all_traffic_ip', - _('Local IPs'), - _('Enter valid IPv4 addresses'), + 'basic', + form.DynamicList, + 'all_traffic_ip', + _('Local IPs'), + _('Enter valid IPv4 addresses'), ); o.placeholder = 'IP'; o.depends('all_traffic_from_ip_enabled', '1'); diff --git a/luci-app-podkop/htdocs/luci-static/resources/view/podkop/main.js b/luci-app-podkop/htdocs/luci-static/resources/view/podkop/main.js index b3a46ff..ec52014 100644 --- a/luci-app-podkop/htdocs/luci-static/resources/view/podkop/main.js +++ b/luci-app-podkop/htdocs/luci-static/resources/view/podkop/main.js @@ -128,6 +128,12 @@ function validateShadowsocksUrl(url) { }; } try { + if (!url || /\s/.test(url)) { + return { + valid: false, + message: "Invalid Shadowsocks URL: must not contain spaces" + }; + } const mainPart = url.includes("?") ? url.split("?")[0] : url.split("#")[0]; const encryptedPart = mainPart.split("/")[2]?.split("@")[0]; if (!encryptedPart) { @@ -185,49 +191,54 @@ function validateShadowsocksUrl(url) { // src/validators/validateVlessUrl.ts function validateVlessUrl(url) { - if (!url.startsWith("vless://")) { - return { - valid: false, - message: "Invalid VLESS URL: must start with vless://" - }; - } try { - const uuid = url.split("/")[2]?.split("@")[0]; - if (!uuid) { + const parsedUrl = new URL(url); + if (!url || /\s/.test(url)) { + return { + valid: false, + message: "Invalid VLESS URL: must not contain spaces" + }; + } + if (parsedUrl.protocol !== "vless:") { + return { + valid: false, + message: "Invalid VLESS URL: must start with vless://" + }; + } + if (!parsedUrl.username) { return { valid: false, message: "Invalid VLESS URL: missing UUID" }; } - const serverPart = url.split("@")[1]; - if (!serverPart) { - return { - valid: false, - message: "Invalid VLESS URL: missing server address" - }; - } - const [server, portAndRest] = serverPart.split(":"); - if (!server) { + if (!parsedUrl.hostname) { return { valid: false, message: "Invalid VLESS URL: missing server" }; } - const port = portAndRest ? portAndRest.split(/[/?#]/)[0] : null; - if (!port) { + if (!parsedUrl.port) { return { valid: false, message: "Invalid VLESS URL: missing port" }; } - const portNum = parseInt(port, 10); - if (isNaN(portNum) || portNum < 1 || portNum > 65535) { + if (isNaN(+parsedUrl.port) || +parsedUrl.port < 1 || +parsedUrl.port > 65535) { return { valid: false, - message: "Invalid port number. Must be between 1 and 65535" + message: "Invalid VLESS URL: invalid port number. Must be between 1 and 65535" }; } - const queryString = url.split("?")[1]; - if (!queryString) { + if (!parsedUrl.search) { return { valid: false, message: "Invalid VLESS URL: missing query parameters" }; } - const params = new URLSearchParams(queryString.split("#")[0]); + const params = new URLSearchParams(parsedUrl.search); const type = params.get("type"); - const validTypes = ["tcp", "raw", "udp", "grpc", "http", "ws"]; + const validTypes = [ + "tcp", + "raw", + "udp", + "grpc", + "http", + "httpupgrade", + "xhttp", + "ws", + "kcp" + ]; if (!type || !validTypes.includes(type)) { return { valid: false, @@ -256,10 +267,10 @@ function validateVlessUrl(url) { }; } } + return { valid: true, message: "Valid" }; } catch (_e) { return { valid: false, message: "Invalid VLESS URL: parsing failed" }; } - return { valid: true, message: "Valid" }; } // src/validators/validateOutboundJson.ts @@ -286,6 +297,12 @@ function validateTrojanUrl(url) { message: "Invalid Trojan URL: must start with trojan://" }; } + if (!url || /\s/.test(url)) { + return { + valid: false, + message: "Invalid Trojan URL: must not contain spaces" + }; + } try { const parsedUrl = new URL(url); if (!parsedUrl.username || !parsedUrl.hostname || !parsedUrl.port) { From c75dd3e78bb0f55392a9cc90d49fd2dc17a23625 Mon Sep 17 00:00:00 2001 From: divocat Date: Sun, 5 Oct 2025 16:09:26 +0300 Subject: [PATCH 34/49] feat: add base clash api methods --- fe-app-podkop/src/clash/index.ts | 2 + .../src/clash/methods/createBaseApiRequest.ts | 28 ++++++++ fe-app-podkop/src/clash/methods/getConfig.ts | 13 ++++ .../src/clash/methods/getGroupDelay.ts | 19 +++++ fe-app-podkop/src/clash/methods/getProxies.ts | 13 ++++ fe-app-podkop/src/clash/methods/getVersion.ts | 13 ++++ fe-app-podkop/src/clash/methods/index.ts | 5 ++ fe-app-podkop/src/clash/types.ts | 53 ++++++++++++++ fe-app-podkop/src/main.ts | 1 + .../luci-static/resources/view/podkop/main.js | 71 +++++++++++++++++++ .../resources/view/podkop/podkop.js | 15 ++++ 11 files changed, 233 insertions(+) create mode 100644 fe-app-podkop/src/clash/index.ts create mode 100644 fe-app-podkop/src/clash/methods/createBaseApiRequest.ts create mode 100644 fe-app-podkop/src/clash/methods/getConfig.ts create mode 100644 fe-app-podkop/src/clash/methods/getGroupDelay.ts create mode 100644 fe-app-podkop/src/clash/methods/getProxies.ts create mode 100644 fe-app-podkop/src/clash/methods/getVersion.ts create mode 100644 fe-app-podkop/src/clash/methods/index.ts create mode 100644 fe-app-podkop/src/clash/types.ts diff --git a/fe-app-podkop/src/clash/index.ts b/fe-app-podkop/src/clash/index.ts new file mode 100644 index 0000000..c3b7574 --- /dev/null +++ b/fe-app-podkop/src/clash/index.ts @@ -0,0 +1,2 @@ +export * from './types'; +export * from './methods'; diff --git a/fe-app-podkop/src/clash/methods/createBaseApiRequest.ts b/fe-app-podkop/src/clash/methods/createBaseApiRequest.ts new file mode 100644 index 0000000..b63516a --- /dev/null +++ b/fe-app-podkop/src/clash/methods/createBaseApiRequest.ts @@ -0,0 +1,28 @@ +import { IBaseApiResponse } from '../types'; + +export async function createBaseApiRequest( + fetchFn: () => Promise, +): Promise> { + try { + const response = await fetchFn(); + + if (!response.ok) { + return { + success: false as const, + message: `HTTP error ${response.status}: ${response.statusText}`, + }; + } + + const data: T = await response.json(); + + return { + success: true as const, + data, + }; + } catch (e) { + return { + success: false as const, + message: e instanceof Error ? e.message : 'Unknown error', + }; + } +} diff --git a/fe-app-podkop/src/clash/methods/getConfig.ts b/fe-app-podkop/src/clash/methods/getConfig.ts new file mode 100644 index 0000000..a782ba1 --- /dev/null +++ b/fe-app-podkop/src/clash/methods/getConfig.ts @@ -0,0 +1,13 @@ +import { ClashAPI, IBaseApiResponse } from '../types'; +import { createBaseApiRequest } from './createBaseApiRequest'; + +export async function getClashConfig(): Promise< + IBaseApiResponse +> { + return createBaseApiRequest(() => + fetch('http://192.168.160.129:9090/configs', { + method: 'GET', + headers: { 'Content-Type': 'application/json' }, + }), + ); +} diff --git a/fe-app-podkop/src/clash/methods/getGroupDelay.ts b/fe-app-podkop/src/clash/methods/getGroupDelay.ts new file mode 100644 index 0000000..bbad3f2 --- /dev/null +++ b/fe-app-podkop/src/clash/methods/getGroupDelay.ts @@ -0,0 +1,19 @@ +import { ClashAPI, IBaseApiResponse } from '../types'; +import { createBaseApiRequest } from './createBaseApiRequest'; + +export async function getClashGroupDelay( + group: string, + url = 'https://www.gstatic.com/generate_204', + timeout = 2000, +): Promise> { + const endpoint = `http://192.168.160.129:9090/group/${group}/delay?url=${encodeURIComponent( + url, + )}&timeout=${timeout}`; + + return createBaseApiRequest(() => + fetch(endpoint, { + method: 'GET', + headers: { 'Content-Type': 'application/json' }, + }), + ); +} diff --git a/fe-app-podkop/src/clash/methods/getProxies.ts b/fe-app-podkop/src/clash/methods/getProxies.ts new file mode 100644 index 0000000..c431b2e --- /dev/null +++ b/fe-app-podkop/src/clash/methods/getProxies.ts @@ -0,0 +1,13 @@ +import { ClashAPI, IBaseApiResponse } from '../types'; +import { createBaseApiRequest } from './createBaseApiRequest'; + +export async function getClashProxies(): Promise< + IBaseApiResponse +> { + return createBaseApiRequest(() => + fetch('http://192.168.160.129:9090/proxies', { + method: 'GET', + headers: { 'Content-Type': 'application/json' }, + }), + ); +} diff --git a/fe-app-podkop/src/clash/methods/getVersion.ts b/fe-app-podkop/src/clash/methods/getVersion.ts new file mode 100644 index 0000000..0f99ede --- /dev/null +++ b/fe-app-podkop/src/clash/methods/getVersion.ts @@ -0,0 +1,13 @@ +import { ClashAPI, IBaseApiResponse } from '../types'; +import { createBaseApiRequest } from './createBaseApiRequest'; + +export async function getClashVersion(): Promise< + IBaseApiResponse +> { + return createBaseApiRequest(() => + fetch('http://192.168.160.129:9090/version', { + method: 'GET', + headers: { 'Content-Type': 'application/json' }, + }), + ); +} diff --git a/fe-app-podkop/src/clash/methods/index.ts b/fe-app-podkop/src/clash/methods/index.ts new file mode 100644 index 0000000..bce1a71 --- /dev/null +++ b/fe-app-podkop/src/clash/methods/index.ts @@ -0,0 +1,5 @@ +export * from './createBaseApiRequest'; +export * from './getConfig'; +export * from './getGroupDelay'; +export * from './getProxies'; +export * from './getVersion'; diff --git a/fe-app-podkop/src/clash/types.ts b/fe-app-podkop/src/clash/types.ts new file mode 100644 index 0000000..a54a55f --- /dev/null +++ b/fe-app-podkop/src/clash/types.ts @@ -0,0 +1,53 @@ +export type IBaseApiResponse = + | { + success: true; + data: T; + } + | { + success: false; + message: string; + }; + +// eslint-disable-next-line @typescript-eslint/no-namespace +export namespace ClashAPI { + export interface Version { + meta: boolean; + premium: boolean; + version: string; + } + + export interface Config { + port: number; + 'socks-port': number; + 'redir-port': number; + 'tproxy-port': number; + 'mixed-port': number; + 'allow-lan': boolean; + 'bind-address': string; + mode: 'Rule' | 'Global' | 'Direct'; + 'mode-list': string[]; + 'log-level': 'debug' | 'info' | 'warn' | 'error'; + ipv6: boolean; + tun: null | Record; + } + + export interface ProxyHistoryEntry { + time: string; + delay: number; + } + + export interface ProxyBase { + type: string; + name: string; + udp: boolean; + history: ProxyHistoryEntry[]; + now?: string; + all?: string[]; + } + + export interface Proxies { + proxies: Record; + } + + export type Delays = Record; +} diff --git a/fe-app-podkop/src/main.ts b/fe-app-podkop/src/main.ts index f3656c5..34b2c09 100644 --- a/fe-app-podkop/src/main.ts +++ b/fe-app-podkop/src/main.ts @@ -4,4 +4,5 @@ export * from './validators'; export * from './helpers'; +export * from './clash'; export * from './constants'; diff --git a/luci-app-podkop/htdocs/luci-static/resources/view/podkop/main.js b/luci-app-podkop/htdocs/luci-static/resources/view/podkop/main.js index ec52014..cc1c318 100644 --- a/luci-app-podkop/htdocs/luci-static/resources/view/podkop/main.js +++ b/luci-app-podkop/htdocs/luci-static/resources/view/podkop/main.js @@ -556,6 +556,72 @@ function maskIP(ip = "") { const ipv4Regex = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/; return ip.replace(ipv4Regex, (_match, _p1, _p2, _p3, p4) => `XX.XX.XX.${p4}`); } + +// src/clash/methods/createBaseApiRequest.ts +async function createBaseApiRequest(fetchFn) { + try { + const response = await fetchFn(); + if (!response.ok) { + return { + success: false, + message: `HTTP error ${response.status}: ${response.statusText}` + }; + } + const data = await response.json(); + return { + success: true, + data + }; + } catch (e) { + return { + success: false, + message: e instanceof Error ? e.message : "Unknown error" + }; + } +} + +// src/clash/methods/getConfig.ts +async function getClashConfig() { + return createBaseApiRequest( + () => fetch("http://192.168.160.129:9090/configs", { + method: "GET", + headers: { "Content-Type": "application/json" } + }) + ); +} + +// src/clash/methods/getGroupDelay.ts +async function getClashGroupDelay(group, url = "https://www.gstatic.com/generate_204", timeout = 2e3) { + const endpoint = `http://192.168.160.129:9090/group/${group}/delay?url=${encodeURIComponent( + url + )}&timeout=${timeout}`; + return createBaseApiRequest( + () => fetch(endpoint, { + method: "GET", + headers: { "Content-Type": "application/json" } + }) + ); +} + +// src/clash/methods/getProxies.ts +async function getClashProxies() { + return createBaseApiRequest( + () => fetch("http://192.168.160.129:9090/proxies", { + method: "GET", + headers: { "Content-Type": "application/json" } + }) + ); +} + +// src/clash/methods/getVersion.ts +async function getClashVersion() { + return createBaseApiRequest( + () => fetch("http://192.168.160.129:9090/version", { + method: "GET", + headers: { "Content-Type": "application/json" } + }) + ); +} return baseclass.extend({ ALLOWED_WITH_RUSSIA_INSIDE, BOOTSTRAP_DNS_SERVER_OPTIONS, @@ -576,8 +642,13 @@ return baseclass.extend({ UPDATE_INTERVAL_OPTIONS, bulkValidate, copyToClipboard, + createBaseApiRequest, executeShellCommand, getBaseUrl, + getClashConfig, + getClashGroupDelay, + getClashProxies, + getClashVersion, injectGlobalStyles, maskIP, parseValueList, diff --git a/luci-app-podkop/htdocs/luci-static/resources/view/podkop/podkop.js b/luci-app-podkop/htdocs/luci-static/resources/view/podkop/podkop.js index 0b1e6c6..7607ab2 100644 --- a/luci-app-podkop/htdocs/luci-static/resources/view/podkop/podkop.js +++ b/luci-app-podkop/htdocs/luci-static/resources/view/podkop/podkop.js @@ -12,6 +12,21 @@ const EntryNode = { async render() { main.injectGlobalStyles(); + main.getClashVersion() + .then(result => console.log('getClashVersion - then', result)) + .catch(err => console.log('getClashVersion - err', err)) + .finally(() => console.log('getClashVersion - finish')); + + main.getClashConfig() + .then(result => console.log('getClashConfig - then', result)) + .catch(err => console.log('getClashConfig - err', err)) + .finally(() => console.log('getClashConfig - finish')); + + main.getClashProxies() + .then(result => console.log('getClashProxies - then', result)) + .catch(err => console.log('getClashProxies - err', err)) + .finally(() => console.log('getClashProxies - finish')); + const podkopFormMap = new form.Map('podkop', '', null, ['main', 'extra']); // Main Section From aad6d8c002d0b530cae1c1e726fb8228942b053d Mon Sep 17 00:00:00 2001 From: divocat Date: Mon, 6 Oct 2025 03:43:55 +0300 Subject: [PATCH 35/49] feat: implement dashboard prototype --- .gitignore | 1 + fe-app-podkop/eslint.config.js | 2 +- fe-app-podkop/package.json | 7 +- fe-app-podkop/src/clash/methods/getConfig.ts | 3 +- .../src/clash/methods/getGroupDelay.ts | 3 +- fe-app-podkop/src/clash/methods/getProxies.ts | 3 +- fe-app-podkop/src/clash/methods/getVersion.ts | 3 +- fe-app-podkop/src/clash/methods/index.ts | 1 + .../src/clash/methods/triggerProxySelector.ts | 16 + fe-app-podkop/src/dashboard/index.ts | 2 + .../src/dashboard/initDashboardController.ts | 174 ++++ .../src/dashboard/renderDashboard.ts | 78 ++ .../dashboard/renderer/renderOutboundGroup.ts | 49 ++ .../src/dashboard/renderer/renderWidget.ts | 16 + fe-app-podkop/src/helpers/getClashApiUrl.ts | 11 + fe-app-podkop/src/helpers/getProxyUrlName.ts | 13 + fe-app-podkop/src/helpers/index.ts | 3 + fe-app-podkop/src/helpers/onMount.ts | 30 + fe-app-podkop/src/helpers/prettyBytes.ts | 12 + fe-app-podkop/src/luci.d.ts | 23 + fe-app-podkop/src/main.ts | 2 + .../src/podkop/methods/getConfigSections.ts | 5 + .../podkop/methods/getDashboardSections.ts | 115 +++ .../src/podkop/methods/getPodkopStatus.ts | 21 + .../src/podkop/methods/getSingboxStatus.ts | 23 + fe-app-podkop/src/podkop/methods/index.ts | 4 + fe-app-podkop/src/podkop/types.ts | 55 ++ fe-app-podkop/src/socket.ts | 93 +++ fe-app-podkop/src/store.ts | 82 ++ fe-app-podkop/src/styles.ts | 135 ++++ fe-app-podkop/watch-upload.js | 84 ++ fe-app-podkop/yarn.lock | 172 +++- .../resources/view/podkop/dashboardTab.js | 22 + .../luci-static/resources/view/podkop/main.js | 743 +++++++++++++++++- .../resources/view/podkop/podkop.js | 34 +- 35 files changed, 2014 insertions(+), 26 deletions(-) create mode 100644 fe-app-podkop/src/clash/methods/triggerProxySelector.ts create mode 100644 fe-app-podkop/src/dashboard/index.ts create mode 100644 fe-app-podkop/src/dashboard/initDashboardController.ts create mode 100644 fe-app-podkop/src/dashboard/renderDashboard.ts create mode 100644 fe-app-podkop/src/dashboard/renderer/renderOutboundGroup.ts create mode 100644 fe-app-podkop/src/dashboard/renderer/renderWidget.ts create mode 100644 fe-app-podkop/src/helpers/getClashApiUrl.ts create mode 100644 fe-app-podkop/src/helpers/getProxyUrlName.ts create mode 100644 fe-app-podkop/src/helpers/onMount.ts create mode 100644 fe-app-podkop/src/helpers/prettyBytes.ts create mode 100644 fe-app-podkop/src/podkop/methods/getConfigSections.ts create mode 100644 fe-app-podkop/src/podkop/methods/getDashboardSections.ts create mode 100644 fe-app-podkop/src/podkop/methods/getPodkopStatus.ts create mode 100644 fe-app-podkop/src/podkop/methods/getSingboxStatus.ts create mode 100644 fe-app-podkop/src/podkop/methods/index.ts create mode 100644 fe-app-podkop/src/podkop/types.ts create mode 100644 fe-app-podkop/src/socket.ts create mode 100644 fe-app-podkop/src/store.ts create mode 100644 fe-app-podkop/watch-upload.js create mode 100644 luci-app-podkop/htdocs/luci-static/resources/view/podkop/dashboardTab.js diff --git a/.gitignore b/.gitignore index 703db4d..ff06e12 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ .idea fe-app-podkop/node_modules +fe-app-podkop/.env diff --git a/fe-app-podkop/eslint.config.js b/fe-app-podkop/eslint.config.js index 859f377..8ec3a34 100644 --- a/fe-app-podkop/eslint.config.js +++ b/fe-app-podkop/eslint.config.js @@ -7,7 +7,7 @@ export default [ js.configs.recommended, ...tseslint.configs.recommended, { - ignores: ['node_modules'], + ignores: ['node_modules', 'watch-upload.js'], }, { rules: { diff --git a/fe-app-podkop/package.json b/fe-app-podkop/package.json index 92b4f18..6241ec2 100644 --- a/fe-app-podkop/package.json +++ b/fe-app-podkop/package.json @@ -10,14 +10,19 @@ "build": "tsup src/main.ts", "dev": "tsup src/main.ts --watch", "test": "vitest", - "ci": "yarn format && yarn lint --max-warnings=0 && yarn test --run && yarn build" + "ci": "yarn format && yarn lint --max-warnings=0 && yarn test --run && yarn build", + "watch:sftp": "node watch-upload.js" }, "devDependencies": { "@typescript-eslint/eslint-plugin": "8.45.0", "@typescript-eslint/parser": "8.45.0", + "chokidar": "4.0.3", + "dotenv": "17.2.3", "eslint": "9.36.0", "eslint-config-prettier": "10.1.8", + "glob": "11.0.3", "prettier": "3.6.2", + "ssh2-sftp-client": "12.0.1", "tsup": "8.5.0", "typescript": "5.9.3", "typescript-eslint": "8.45.0", diff --git a/fe-app-podkop/src/clash/methods/getConfig.ts b/fe-app-podkop/src/clash/methods/getConfig.ts index a782ba1..8f7135a 100644 --- a/fe-app-podkop/src/clash/methods/getConfig.ts +++ b/fe-app-podkop/src/clash/methods/getConfig.ts @@ -1,11 +1,12 @@ import { ClashAPI, IBaseApiResponse } from '../types'; import { createBaseApiRequest } from './createBaseApiRequest'; +import { getClashApiUrl } from '../../helpers'; export async function getClashConfig(): Promise< IBaseApiResponse > { return createBaseApiRequest(() => - fetch('http://192.168.160.129:9090/configs', { + fetch(`${getClashApiUrl()}/configs`, { method: 'GET', headers: { 'Content-Type': 'application/json' }, }), diff --git a/fe-app-podkop/src/clash/methods/getGroupDelay.ts b/fe-app-podkop/src/clash/methods/getGroupDelay.ts index bbad3f2..f160bec 100644 --- a/fe-app-podkop/src/clash/methods/getGroupDelay.ts +++ b/fe-app-podkop/src/clash/methods/getGroupDelay.ts @@ -1,12 +1,13 @@ import { ClashAPI, IBaseApiResponse } from '../types'; import { createBaseApiRequest } from './createBaseApiRequest'; +import { getClashApiUrl } from '../../helpers'; export async function getClashGroupDelay( group: string, url = 'https://www.gstatic.com/generate_204', timeout = 2000, ): Promise> { - const endpoint = `http://192.168.160.129:9090/group/${group}/delay?url=${encodeURIComponent( + const endpoint = `${getClashApiUrl()}/group/${group}/delay?url=${encodeURIComponent( url, )}&timeout=${timeout}`; diff --git a/fe-app-podkop/src/clash/methods/getProxies.ts b/fe-app-podkop/src/clash/methods/getProxies.ts index c431b2e..e465c58 100644 --- a/fe-app-podkop/src/clash/methods/getProxies.ts +++ b/fe-app-podkop/src/clash/methods/getProxies.ts @@ -1,11 +1,12 @@ import { ClashAPI, IBaseApiResponse } from '../types'; import { createBaseApiRequest } from './createBaseApiRequest'; +import { getClashApiUrl } from '../../helpers'; export async function getClashProxies(): Promise< IBaseApiResponse > { return createBaseApiRequest(() => - fetch('http://192.168.160.129:9090/proxies', { + fetch(`${getClashApiUrl()}/proxies`, { method: 'GET', headers: { 'Content-Type': 'application/json' }, }), diff --git a/fe-app-podkop/src/clash/methods/getVersion.ts b/fe-app-podkop/src/clash/methods/getVersion.ts index 0f99ede..119db9f 100644 --- a/fe-app-podkop/src/clash/methods/getVersion.ts +++ b/fe-app-podkop/src/clash/methods/getVersion.ts @@ -1,11 +1,12 @@ import { ClashAPI, IBaseApiResponse } from '../types'; import { createBaseApiRequest } from './createBaseApiRequest'; +import { getClashApiUrl } from '../../helpers'; export async function getClashVersion(): Promise< IBaseApiResponse > { return createBaseApiRequest(() => - fetch('http://192.168.160.129:9090/version', { + fetch(`${getClashApiUrl()}/version`, { method: 'GET', headers: { 'Content-Type': 'application/json' }, }), diff --git a/fe-app-podkop/src/clash/methods/index.ts b/fe-app-podkop/src/clash/methods/index.ts index bce1a71..77f254b 100644 --- a/fe-app-podkop/src/clash/methods/index.ts +++ b/fe-app-podkop/src/clash/methods/index.ts @@ -3,3 +3,4 @@ export * from './getConfig'; export * from './getGroupDelay'; export * from './getProxies'; export * from './getVersion'; +export * from './triggerProxySelector'; diff --git a/fe-app-podkop/src/clash/methods/triggerProxySelector.ts b/fe-app-podkop/src/clash/methods/triggerProxySelector.ts new file mode 100644 index 0000000..16d1f55 --- /dev/null +++ b/fe-app-podkop/src/clash/methods/triggerProxySelector.ts @@ -0,0 +1,16 @@ +import { IBaseApiResponse } from '../types'; +import { createBaseApiRequest } from './createBaseApiRequest'; +import { getClashApiUrl } from '../../helpers'; + +export async function triggerProxySelector( + selector: string, + outbound: string, +): Promise> { + return createBaseApiRequest(() => + fetch(`${getClashApiUrl()}/proxies/${selector}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: outbound }), + }), + ); +} diff --git a/fe-app-podkop/src/dashboard/index.ts b/fe-app-podkop/src/dashboard/index.ts new file mode 100644 index 0000000..898949a --- /dev/null +++ b/fe-app-podkop/src/dashboard/index.ts @@ -0,0 +1,2 @@ +export * from './renderDashboard'; +export * from './initDashboardController'; diff --git a/fe-app-podkop/src/dashboard/initDashboardController.ts b/fe-app-podkop/src/dashboard/initDashboardController.ts new file mode 100644 index 0000000..a6e3808 --- /dev/null +++ b/fe-app-podkop/src/dashboard/initDashboardController.ts @@ -0,0 +1,174 @@ +import { + getDashboardSections, + getPodkopStatus, + getSingboxStatus, +} from '../podkop/methods'; +import { renderOutboundGroup } from './renderer/renderOutboundGroup'; +import { getClashWsUrl, onMount } from '../helpers'; +import { store } from '../store'; +import { socket } from '../socket'; +import { renderDashboardWidget } from './renderer/renderWidget'; +import { prettyBytes } from '../helpers/prettyBytes'; + +// Fetchers + +async function fetchDashboardSections() { + const sections = await getDashboardSections(); + + store.set({ sections }); +} + +async function fetchServicesInfo() { + const podkop = await getPodkopStatus(); + const singbox = await getSingboxStatus(); + + console.log('podkop', podkop); + console.log('singbox', singbox); + store.set({ + services: { + singbox: singbox.running ? '✔ Enabled' : singbox.status, + podkop: podkop.status ? '✔ Enabled' : podkop.status, + }, + }); +} + +async function connectToClashSockets() { + socket.subscribe(`${getClashWsUrl()}/traffic?token=`, (msg) => { + const parsedMsg = JSON.parse(msg); + + store.set({ + traffic: { up: parsedMsg.up, down: parsedMsg.down }, + }); + }); + + socket.subscribe(`${getClashWsUrl()}/connections?token=`, (msg) => { + const parsedMsg = JSON.parse(msg); + + store.set({ + connections: { + connections: parsedMsg.connections, + downloadTotal: parsedMsg.downloadTotal, + uploadTotal: parsedMsg.uploadTotal, + memory: parsedMsg.memory, + }, + }); + }); + + socket.subscribe(`${getClashWsUrl()}/memory?token=`, (msg) => { + store.set({ + memory: { inuse: msg.inuse, oslimit: msg.oslimit }, + }); + }); +} + +// Renderer + +async function renderDashboardSections() { + const sections = store.get().sections; + console.log('render dashboard sections group'); + const container = document.getElementById('dashboard-sections-grid'); + const renderedOutboundGroups = sections.map(renderOutboundGroup); + + container!.replaceChildren(...renderedOutboundGroups); +} + +async function renderTrafficWidget() { + const traffic = store.get().traffic; + console.log('render dashboard traffic widget'); + const container = document.getElementById('dashboard-widget-traffic'); + const renderedWidget = renderDashboardWidget({ + title: 'Traffic', + items: [ + { key: 'Uplink', value: `${prettyBytes(traffic.up)}/s` }, + { key: 'Downlink', value: `${prettyBytes(traffic.down)}/s` }, + ], + }); + + container!.replaceChildren(renderedWidget); +} + +async function renderTrafficTotalWidget() { + const connections = store.get().connections; + console.log('render dashboard traffic total widget'); + const container = document.getElementById('dashboard-widget-traffic-total'); + const renderedWidget = renderDashboardWidget({ + title: 'Traffic Total', + items: [ + { key: 'Uplink', value: String(prettyBytes(connections.uploadTotal)) }, + { + key: 'Downlink', + value: String(prettyBytes(connections.downloadTotal)), + }, + ], + }); + + container!.replaceChildren(renderedWidget); +} + +async function renderSystemInfoWidget() { + const connections = store.get().connections; + console.log('render dashboard system info widget'); + const container = document.getElementById('dashboard-widget-system-info'); + const renderedWidget = renderDashboardWidget({ + title: 'System info', + items: [ + { + key: 'Active Connections', + value: String(connections.connections.length), + }, + { key: 'Memory Usage', value: String(prettyBytes(connections.memory)) }, + ], + }); + + container!.replaceChildren(renderedWidget); +} + +async function renderServiceInfoWidget() { + const services = store.get().services; + console.log('render dashboard service info widget'); + const container = document.getElementById('dashboard-widget-service-info'); + const renderedWidget = renderDashboardWidget({ + title: 'Services info', + items: [ + { + key: 'Podkop', + value: String(services.podkop), + }, + { key: 'Sing-box', value: String(services.singbox) }, + ], + }); + + container!.replaceChildren(renderedWidget); +} + +export async function initDashboardController(): Promise { + store.subscribe((next, prev, diff) => { + console.log('Store changed', { prev, next, diff }); + + // Update sections render + if (diff?.sections) { + renderDashboardSections(); + } + + if (diff?.traffic) { + renderTrafficWidget(); + } + + if (diff?.connections) { + renderTrafficTotalWidget(); + renderSystemInfoWidget(); + } + + if (diff?.services) { + renderServiceInfoWidget(); + } + }); + + onMount('dashboard-status').then(() => { + console.log('Mounting dashboard'); + // Initial sections fetch + fetchDashboardSections(); + fetchServicesInfo(); + connectToClashSockets(); + }); +} diff --git a/fe-app-podkop/src/dashboard/renderDashboard.ts b/fe-app-podkop/src/dashboard/renderDashboard.ts new file mode 100644 index 0000000..f160ce4 --- /dev/null +++ b/fe-app-podkop/src/dashboard/renderDashboard.ts @@ -0,0 +1,78 @@ +export function renderDashboard() { + return E( + 'div', + { + id: 'dashboard-status', + class: 'pdk_dashboard-page', + }, + [ + // Title section + E('div', { class: 'pdk_dashboard-page__title-section' }, [ + E( + 'h3', + { class: 'pdk_dashboard-page__title-section__title' }, + 'Overall (alpha)', + ), + E('label', {}, [ + E('input', { type: 'checkbox', disabled: true, checked: true }), + ' Runtime', + ]), + ]), + // Widgets section + E('div', { class: 'pdk_dashboard-page__widgets-section' }, [ + E('div', { id: 'dashboard-widget-traffic' }, [ + E( + 'div', + { + id: '', + style: 'height: 78px', + class: 'pdk_dashboard-page__widgets-section__item skeleton', + }, + '', + ), + ]), + E('div', { id: 'dashboard-widget-traffic-total' }, [ + E( + 'div', + { + id: '', + style: 'height: 78px', + class: 'pdk_dashboard-page__widgets-section__item skeleton', + }, + '', + ), + ]), + E('div', { id: 'dashboard-widget-system-info' }, [ + E( + 'div', + { + id: '', + style: 'height: 78px', + class: 'pdk_dashboard-page__widgets-section__item skeleton', + }, + '', + ), + ]), + E('div', { id: 'dashboard-widget-service-info' }, [ + E( + 'div', + { + id: '', + style: 'height: 78px', + class: 'pdk_dashboard-page__widgets-section__item skeleton', + }, + '', + ), + ]), + ]), + // All outbounds + E('div', { id: 'dashboard-sections-grid' }, [ + E('div', { + id: 'dashboard-sections-grid-skeleton', + class: 'pdk_dashboard-page__outbound-section skeleton', + style: 'height: 127px', + }), + ]), + ], + ); +} diff --git a/fe-app-podkop/src/dashboard/renderer/renderOutboundGroup.ts b/fe-app-podkop/src/dashboard/renderer/renderOutboundGroup.ts new file mode 100644 index 0000000..865dc58 --- /dev/null +++ b/fe-app-podkop/src/dashboard/renderer/renderOutboundGroup.ts @@ -0,0 +1,49 @@ +import { Podkop } from '../../podkop/types'; + +export function renderOutboundGroup({ + outbounds, + displayName, +}: Podkop.OutboundGroup) { + function renderOutbound(outbound: Podkop.Outbound) { + return E( + 'div', + { + class: `pdk_dashboard-page__outbound-grid__item ${outbound.selected ? 'pdk_dashboard-page__outbound-grid__item--active' : ''}`, + }, + [ + E('b', {}, outbound.displayName), + E('div', { class: 'pdk_dashboard-page__outbound-grid__item__footer' }, [ + E( + 'div', + { class: 'pdk_dashboard-page__outbound-grid__item__type' }, + outbound.type, + ), + E( + 'div', + { class: 'pdk_dashboard-page__outbound-grid__item__latency' }, + outbound.latency ? `${outbound.latency}ms` : 'N/A', + ), + ]), + ], + ); + } + + return E('div', { class: 'pdk_dashboard-page__outbound-section' }, [ + // Title with test latency + E('div', { class: 'pdk_dashboard-page__outbound-section__title-section' }, [ + E( + 'div', + { + class: 'pdk_dashboard-page__outbound-section__title-section__title', + }, + displayName, + ), + E('button', { class: 'btn' }, 'Test latency'), + ]), + E( + 'div', + { class: 'pdk_dashboard-page__outbound-grid' }, + outbounds.map((outbound) => renderOutbound(outbound)), + ), + ]); +} diff --git a/fe-app-podkop/src/dashboard/renderer/renderWidget.ts b/fe-app-podkop/src/dashboard/renderer/renderWidget.ts new file mode 100644 index 0000000..850e263 --- /dev/null +++ b/fe-app-podkop/src/dashboard/renderer/renderWidget.ts @@ -0,0 +1,16 @@ +interface IRenderWidgetParams { + title: string; + items: Array<{ + key: string; + value: string; + }>; +} + +export function renderDashboardWidget({ title, items }: IRenderWidgetParams) { + return E('div', { class: 'pdk_dashboard-page__widgets-section__item' }, [ + E('b', {}, title), + ...items.map((item) => + E('div', {}, [E('span', {}, `${item.key}: `), E('span', {}, item.value)]), + ), + ]); +} diff --git a/fe-app-podkop/src/helpers/getClashApiUrl.ts b/fe-app-podkop/src/helpers/getClashApiUrl.ts new file mode 100644 index 0000000..df52bec --- /dev/null +++ b/fe-app-podkop/src/helpers/getClashApiUrl.ts @@ -0,0 +1,11 @@ +export function getClashApiUrl(): string { + const { protocol, hostname } = window.location; + + return `${protocol}//${hostname}:9090`; +} + +export function getClashWsUrl(): string { + const { hostname } = window.location; + + return `ws://${hostname}:9090`; +} diff --git a/fe-app-podkop/src/helpers/getProxyUrlName.ts b/fe-app-podkop/src/helpers/getProxyUrlName.ts new file mode 100644 index 0000000..f903429 --- /dev/null +++ b/fe-app-podkop/src/helpers/getProxyUrlName.ts @@ -0,0 +1,13 @@ +export function getProxyUrlName(url: string) { + try { + const [_link, hash] = url.split('#'); + + if (!hash) { + return ''; + } + + return decodeURIComponent(hash); + } catch { + return ''; + } +} diff --git a/fe-app-podkop/src/helpers/index.ts b/fe-app-podkop/src/helpers/index.ts index 5569d6e..a38f0b5 100644 --- a/fe-app-podkop/src/helpers/index.ts +++ b/fe-app-podkop/src/helpers/index.ts @@ -5,3 +5,6 @@ export * from './withTimeout'; export * from './executeShellCommand'; export * from './copyToClipboard'; export * from './maskIP'; +export * from './getProxyUrlName'; +export * from './onMount'; +export * from './getClashApiUrl'; diff --git a/fe-app-podkop/src/helpers/onMount.ts b/fe-app-podkop/src/helpers/onMount.ts new file mode 100644 index 0000000..48ce5e8 --- /dev/null +++ b/fe-app-podkop/src/helpers/onMount.ts @@ -0,0 +1,30 @@ +export async function onMount(id: string): Promise { + return new Promise((resolve) => { + const el = document.getElementById(id); + + if (el && el.offsetParent !== null) { + return resolve(el); + } + + const observer = new MutationObserver(() => { + const target = document.getElementById(id); + if (target) { + const io = new IntersectionObserver((entries) => { + const visible = entries.some((e) => e.isIntersecting); + if (visible) { + observer.disconnect(); + io.disconnect(); + resolve(target); + } + }); + + io.observe(target); + } + }); + + observer.observe(document.body, { + childList: true, + subtree: true, + }); + }); +} diff --git a/fe-app-podkop/src/helpers/prettyBytes.ts b/fe-app-podkop/src/helpers/prettyBytes.ts new file mode 100644 index 0000000..5572ccb --- /dev/null +++ b/fe-app-podkop/src/helpers/prettyBytes.ts @@ -0,0 +1,12 @@ +// steal from https://github.com/sindresorhus/pretty-bytes/blob/master/index.js +export function prettyBytes(n: number) { + const UNITS = ['B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']; + + if (n < 1000) { + return n + ' B'; + } + const exponent = Math.min(Math.floor(Math.log10(n) / 3), UNITS.length - 1); + n = Number((n / Math.pow(1000, exponent)).toPrecision(3)); + const unit = UNITS[exponent]; + return n + ' ' + unit; +} diff --git a/fe-app-podkop/src/luci.d.ts b/fe-app-podkop/src/luci.d.ts index 2b942de..9b6762e 100644 --- a/fe-app-podkop/src/luci.d.ts +++ b/fe-app-podkop/src/luci.d.ts @@ -1,3 +1,15 @@ +type HtmlTag = keyof HTMLElementTagNameMap; + +type HtmlElement = HTMLElementTagNameMap[T]; + +type HtmlAttributes = Partial< + Omit, 'style' | 'children'> & { + style?: string | Partial; + class?: string; + onclick?: (event: MouseEvent) => void; + } +>; + declare global { const fs: { exec( @@ -10,6 +22,17 @@ declare global { code?: number; }>; }; + + const E: ( + type: T, + attr?: HtmlAttributes | null, + children?: (Node | string)[] | Node | string, + ) => HTMLElementTagNameMap[T]; + + const uci: { + load: (packages: string | string[]) => Promise; + sections: (conf: string, type?: string, cb?: () => void) => Promise; + }; } export {}; diff --git a/fe-app-podkop/src/main.ts b/fe-app-podkop/src/main.ts index 34b2c09..f130254 100644 --- a/fe-app-podkop/src/main.ts +++ b/fe-app-podkop/src/main.ts @@ -1,8 +1,10 @@ 'use strict'; 'require baseclass'; 'require fs'; +'require uci'; export * from './validators'; export * from './helpers'; export * from './clash'; +export * from './dashboard'; export * from './constants'; diff --git a/fe-app-podkop/src/podkop/methods/getConfigSections.ts b/fe-app-podkop/src/podkop/methods/getConfigSections.ts new file mode 100644 index 0000000..d8883d4 --- /dev/null +++ b/fe-app-podkop/src/podkop/methods/getConfigSections.ts @@ -0,0 +1,5 @@ +import { Podkop } from '../types'; + +export async function getConfigSections(): Promise { + return uci.load('podkop').then(() => uci.sections('podkop')); +} diff --git a/fe-app-podkop/src/podkop/methods/getDashboardSections.ts b/fe-app-podkop/src/podkop/methods/getDashboardSections.ts new file mode 100644 index 0000000..1ea8886 --- /dev/null +++ b/fe-app-podkop/src/podkop/methods/getDashboardSections.ts @@ -0,0 +1,115 @@ +import { Podkop } from '../types'; +import { getConfigSections } from './getConfigSections'; +import { getClashProxies } from '../../clash'; +import { getProxyUrlName } from '../../helpers'; + +export async function getDashboardSections(): Promise { + const configSections = await getConfigSections(); + const clashProxies = await getClashProxies(); + + const clashProxiesData = clashProxies.success + ? clashProxies.data + : { proxies: [] }; + + const proxies = Object.entries(clashProxiesData.proxies).map( + ([key, value]) => ({ + code: key, + value, + }), + ); + + return configSections + .filter((section) => section.mode !== 'block') + .map((section) => { + if (section.mode === 'proxy') { + if (section.proxy_config_type === 'url') { + const outbound = proxies.find( + (proxy) => proxy.code === `${section['.name']}-out`, + ); + + return { + code: section['.name'], + displayName: section['.name'], + outbounds: [ + { + code: outbound?.code || section['.name'], + displayName: + getProxyUrlName(section.proxy_string) || + outbound?.value?.name || + '', + latency: outbound?.value?.history?.[0]?.delay || 0, + type: outbound?.value?.type || '', + selected: true, + }, + ], + }; + } + + if (section.proxy_config_type === 'outbound') { + const outbound = proxies.find( + (proxy) => proxy.code === `${section['.name']}-out`, + ); + + return { + code: section['.name'], + displayName: section['.name'], + outbounds: [ + { + code: outbound?.code || section['.name'], + displayName: + decodeURIComponent(JSON.parse(section.outbound_json)?.tag) || + outbound?.value?.name || + '', + latency: outbound?.value?.history?.[0]?.delay || 0, + type: outbound?.value?.type || '', + selected: true, + }, + ], + }; + } + + if (section.proxy_config_type === 'urltest') { + const selector = proxies.find( + (proxy) => proxy.code === `${section['.name']}-out`, + ); + const outbound = proxies.find( + (proxy) => proxy.code === `${section['.name']}-urltest-out`, + ); + + const outbounds = (outbound?.value?.all ?? []) + .map((code) => proxies.find((item) => item.code === code)) + .map((item, index) => ({ + code: item?.code || '', + displayName: + getProxyUrlName(section.urltest_proxy_links?.[index]) || + item?.value?.name || + '', + latency: item?.value?.history?.[0]?.delay || 0, + type: item?.value?.type || '', + selected: selector?.value?.now === item?.code, + })); + + return { + code: section['.name'], + displayName: section['.name'], + outbounds: [ + { + code: outbound?.code || '', + displayName: 'Fastest', + latency: outbound?.value?.history?.[0]?.delay || 0, + type: outbound?.value?.type || '', + selected: selector?.value?.now === outbound?.code, + }, + ...outbounds, + ], + }; + } + } + + return { + code: section['.name'], + displayName: section['.name'], + outbounds: [], + }; + }); +} diff --git a/fe-app-podkop/src/podkop/methods/getPodkopStatus.ts b/fe-app-podkop/src/podkop/methods/getPodkopStatus.ts new file mode 100644 index 0000000..9286dda --- /dev/null +++ b/fe-app-podkop/src/podkop/methods/getPodkopStatus.ts @@ -0,0 +1,21 @@ +import { executeShellCommand } from '../../helpers'; + +export async function getPodkopStatus(): Promise<{ + enabled: number; + status: string; +}> { + const response = await executeShellCommand({ + command: '/usr/bin/podkop', + args: ['get_status'], + timeout: 1000, + }); + + if (response.stdout) { + return JSON.parse(response.stdout.replace(/\n/g, '')) as { + enabled: number; + status: string; + }; + } + + return { enabled: 0, status: 'unknown' }; +} diff --git a/fe-app-podkop/src/podkop/methods/getSingboxStatus.ts b/fe-app-podkop/src/podkop/methods/getSingboxStatus.ts new file mode 100644 index 0000000..d65221e --- /dev/null +++ b/fe-app-podkop/src/podkop/methods/getSingboxStatus.ts @@ -0,0 +1,23 @@ +import { executeShellCommand } from '../../helpers'; + +export async function getSingboxStatus(): Promise<{ + running: number; + enabled: number; + status: string; +}> { + const response = await executeShellCommand({ + command: '/usr/bin/podkop', + args: ['get_sing_box_status'], + timeout: 1000, + }); + + if (response.stdout) { + return JSON.parse(response.stdout.replace(/\n/g, '')) as { + running: number; + enabled: number; + status: string; + }; + } + + return { running: 0, enabled: 0, status: 'unknown' }; +} diff --git a/fe-app-podkop/src/podkop/methods/index.ts b/fe-app-podkop/src/podkop/methods/index.ts new file mode 100644 index 0000000..6b2c1f3 --- /dev/null +++ b/fe-app-podkop/src/podkop/methods/index.ts @@ -0,0 +1,4 @@ +export * from './getConfigSections'; +export * from './getDashboardSections'; +export * from './getPodkopStatus'; +export * from './getSingboxStatus'; diff --git a/fe-app-podkop/src/podkop/types.ts b/fe-app-podkop/src/podkop/types.ts new file mode 100644 index 0000000..c715b61 --- /dev/null +++ b/fe-app-podkop/src/podkop/types.ts @@ -0,0 +1,55 @@ +// eslint-disable-next-line @typescript-eslint/no-namespace +export namespace Podkop { + export interface Outbound { + code: string; + displayName: string; + latency: number; + type: string; + selected: boolean; + } + + export interface OutboundGroup { + code: string; + displayName: string; + outbounds: Outbound[]; + } + + export interface ConfigProxyUrlTestSection { + mode: 'proxy'; + proxy_config_type: 'urltest'; + urltest_proxy_links: string[]; + } + + export interface ConfigProxyUrlSection { + mode: 'proxy'; + proxy_config_type: 'url'; + proxy_string: string; + } + + export interface ConfigProxyOutboundSection { + mode: 'proxy'; + proxy_config_type: 'outbound'; + outbound_json: string; + } + + export interface ConfigVpnSection { + mode: 'vpn'; + interface: string; + } + + export interface ConfigBlockSection { + mode: 'block'; + } + + export type ConfigBaseSection = + | ConfigProxyUrlTestSection + | ConfigProxyUrlSection + | ConfigProxyOutboundSection + | ConfigVpnSection + | ConfigBlockSection; + + export type ConfigSection = ConfigBaseSection & { + '.name': string; + '.type': 'main' | 'extra'; + }; +} diff --git a/fe-app-podkop/src/socket.ts b/fe-app-podkop/src/socket.ts new file mode 100644 index 0000000..ea9aba1 --- /dev/null +++ b/fe-app-podkop/src/socket.ts @@ -0,0 +1,93 @@ +// eslint-disable-next-line +type Listener = (data: any) => void; + +class SocketManager { + private static instance: SocketManager; + private sockets = new Map(); + private listeners = new Map>(); + private connected = new Map(); + + private constructor() {} + + static getInstance(): SocketManager { + if (!SocketManager.instance) { + SocketManager.instance = new SocketManager(); + } + return SocketManager.instance; + } + + connect(url: string): void { + if (this.sockets.has(url)) return; + + const ws = new WebSocket(url); + this.sockets.set(url, ws); + this.connected.set(url, false); + this.listeners.set(url, new Set()); + + ws.addEventListener('open', () => { + this.connected.set(url, true); + console.log(`✅ Connected: ${url}`); + }); + + ws.addEventListener('message', (event) => { + const handlers = this.listeners.get(url); + if (handlers) { + for (const handler of handlers) { + try { + handler(event.data); + } catch (err) { + console.error(`Handler error for ${url}:`, err); + } + } + } + }); + + ws.addEventListener('close', () => { + this.connected.set(url, false); + console.warn(`⚠️ Disconnected: ${url}`); + }); + + ws.addEventListener('error', (err) => { + console.error(`❌ Socket error for ${url}:`, err); + }); + } + + subscribe(url: string, listener: Listener): void { + if (!this.sockets.has(url)) { + this.connect(url); + } + this.listeners.get(url)?.add(listener); + } + + unsubscribe(url: string, listener: Listener): void { + this.listeners.get(url)?.delete(listener); + } + + // eslint-disable-next-line + send(url: string, data: any): void { + const ws = this.sockets.get(url); + if (ws && this.connected.get(url)) { + ws.send(typeof data === 'string' ? data : JSON.stringify(data)); + } else { + console.warn(`⚠️ Cannot send: not connected to ${url}`); + } + } + + disconnect(url: string): void { + const ws = this.sockets.get(url); + if (ws) { + ws.close(); + this.sockets.delete(url); + this.listeners.delete(url); + this.connected.delete(url); + } + } + + disconnectAll(): void { + for (const url of this.sockets.keys()) { + this.disconnect(url); + } + } +} + +export const socket = SocketManager.getInstance(); diff --git a/fe-app-podkop/src/store.ts b/fe-app-podkop/src/store.ts new file mode 100644 index 0000000..7770631 --- /dev/null +++ b/fe-app-podkop/src/store.ts @@ -0,0 +1,82 @@ +import { Podkop } from './podkop/types'; + +type Listener = (next: T, prev: T, diff: Partial) => void; + +// eslint-disable-next-line +class Store> { + private value: T; + private listeners = new Set>(); + + constructor(initial: T) { + this.value = initial; + } + + get(): T { + return this.value; + } + + set(next: Partial): void { + const prev = this.value; + const merged = { ...this.value, ...next }; + if (Object.is(prev, merged)) return; + + this.value = merged; + + const diff: Partial = {}; + for (const key in merged) { + if (merged[key] !== prev[key]) diff[key] = merged[key]; + } + + this.listeners.forEach((cb) => cb(this.value, prev, diff)); + } + + subscribe(cb: Listener): () => void { + this.listeners.add(cb); + cb(this.value, this.value, {}); // первый вызов без diff + return () => this.listeners.delete(cb); + } + + patch(key: K, value: T[K]): void { + this.set({ ...this.value, [key]: value }); + } + + getKey(key: K): T[K] { + return this.value[key]; + } + + subscribeKey( + key: K, + cb: (value: T[K]) => void, + ): () => void { + let prev = this.value[key]; + const unsub = this.subscribe((val) => { + if (val[key] !== prev) { + prev = val[key]; + cb(val[key]); + } + }); + return unsub; + } +} + +export const store = new Store<{ + sections: Podkop.OutboundGroup[]; + traffic: { up: number; down: number }; + memory: { inuse: number; oslimit: number }; + connections: { + connections: unknown[]; + downloadTotal: number; + memory: number; + uploadTotal: number; + }; + services: { + singbox: string; + podkop: string; + }; +}>({ + sections: [], + traffic: { up: 0, down: 0 }, + memory: { inuse: 0, oslimit: 0 }, + connections: { connections: [], memory: 0, downloadTotal: 0, uploadTotal: 0 }, + services: { singbox: '', podkop: '' }, +}); diff --git a/fe-app-podkop/src/styles.ts b/fe-app-podkop/src/styles.ts index 6613f9b..9ab5ed5 100644 --- a/fe-app-podkop/src/styles.ts +++ b/fe-app-podkop/src/styles.ts @@ -23,4 +23,139 @@ export const GlobalStyles = ` #cbi-podkop:has(.cbi-tab-disabled[data-tab="basic"]) #cbi-podkop-extra { display: none; } + +#cbi-podkop-main-_status > div { + width: 100%; +} + +.pdk_dashboard-page { + width: 100%; + --dashboard-grid-columns: 4; +} + +@media (max-width: 900px) { + .pdk_dashboard-page { + --dashboard-grid-columns: 2; + } +} + +/*@media (max-width: 440px) {*/ +/* .pdk_dashboard-page {*/ +/* --dashboard-grid-columns: 1;*/ +/* }*/ +/*}*/ + +.pdk_dashboard-page__title-section { + display: flex; + align-items: center; + justify-content: space-between; + border: 2px var(--background-color-low) solid; + border-radius: 4px; + padding: 0 10px; +} + +.pdk_dashboard-page__title-section__title { + color: var(--text-color-high); + font-weight: 700; +} + +.pdk_dashboard-page__widgets-section { + margin-top: 10px; + display: grid; + grid-template-columns: repeat(var(--dashboard-grid-columns), 1fr); + grid-gap: 10px; +} + +.pdk_dashboard-page__widgets-section__item { + border: 2px var(--background-color-low) solid; + border-radius: 4px; + padding: 10px; +} + +.pdk_dashboard-page__outbound-section { + margin-top: 10px; + border: 2px var(--background-color-low) solid; + border-radius: 4px; + padding: 10px; +} + +.pdk_dashboard-page__outbound-section__title-section { + display: flex; + align-items: center; + justify-content: space-between; +} + +.pdk_dashboard-page__outbound-section__title-section__title { + color: var(--text-color-high); + font-weight: 700; +} + +.pdk_dashboard-page__outbound-grid { + margin-top: 5px; + display: grid; + grid-template-columns: repeat(var(--dashboard-grid-columns), 1fr); + grid-gap: 10px; +} + +.pdk_dashboard-page__outbound-grid__item { + cursor: pointer; + border: 2px var(--background-color-low) solid; + border-radius: 4px; + padding: 10px; + transition: border 0.2s ease; +} +.pdk_dashboard-page__outbound-grid__item:hover { + border-color: var(--primary-color-high); +} + +.pdk_dashboard-page__outbound-grid__item--active { + border-color: var(--success-color-medium); +} + +.pdk_dashboard-page__outbound-grid__item__footer { + display: flex; + align-items: center; + justify-content: space-between; + margin-top: 10px; +} + +.pdk_dashboard-page__outbound-grid__item__type { + +} + +.pdk_dashboard-page__outbound-grid__item__latency { + +} + + + +/* Skeleton styles*/ +.skeleton { + background-color: var(--background-color-low, #e0e0e0); + border-radius: 4px; + position: relative; + overflow: hidden; +} + +.skeleton::after { + content: ''; + position: absolute; + top: 0; + left: -150%; + width: 150%; + height: 100%; + background: linear-gradient( + 90deg, + transparent, + rgba(255, 255, 255, 0.4), + transparent + ); + animation: skeleton-shimmer 1.6s infinite; +} + +@keyframes skeleton-shimmer { + 100% { + left: 150%; + } +} `; diff --git a/fe-app-podkop/watch-upload.js b/fe-app-podkop/watch-upload.js new file mode 100644 index 0000000..9bdd821 --- /dev/null +++ b/fe-app-podkop/watch-upload.js @@ -0,0 +1,84 @@ +import 'dotenv/config'; +import chokidar from 'chokidar'; +import SFTPClient from 'ssh2-sftp-client'; +import path from 'path'; +import fs from 'fs'; +import { glob } from 'glob'; + +const sftp = new SFTPClient(); + +const config = { + host: process.env.SFTP_HOST, + port: Number(process.env.SFTP_PORT || 22), + username: process.env.SFTP_USER, + ...(process.env.SFTP_PRIVATE_KEY + ? { privateKey: fs.readFileSync(process.env.SFTP_PRIVATE_KEY) } + : { password: process.env.SFTP_PASS }), +}; + +const localDir = path.resolve(process.env.LOCAL_DIR || './dist'); +const remoteDir = process.env.REMOTE_DIR || '/www/luci-static/mypkg'; + +async function uploadFile(filePath) { + const relativePath = path.relative(localDir, filePath); + const remotePath = path.posix.join(remoteDir, relativePath); + + console.log(`⬆️ Uploading: ${relativePath} -> ${remotePath}`); + try { + await sftp.fastPut(filePath, remotePath); + console.log(`✅ Uploaded: ${relativePath}`); + } catch (err) { + console.error(`❌ Failed: ${relativePath}: ${err.message}`); + } +} + +async function deleteFile(filePath) { + const relativePath = path.relative(localDir, filePath); + const remotePath = path.posix.join(remoteDir, relativePath); + + console.log(`🗑 Removing: ${relativePath}`); + try { + await sftp.delete(remotePath); + console.log(`✅ Removed: ${relativePath}`); + } catch (err) { + console.warn(`⚠️ Could not delete ${relativePath}: ${err.message}`); + } +} + +async function uploadAllFiles() { + console.log('🚀 Uploading all files from', localDir); + + const files = await glob(`${localDir}/**/*`, { nodir: true }); + for (const file of files) { + await uploadFile(file); + } + + console.log('✅ Initial upload complete!'); +} + +async function main() { + await sftp.connect(config); + console.log(`✅ Connected to ${config.host}`); + + // 🔹 Загрузить всё при старте + await uploadAllFiles(); + + // 🔹 Затем следить за изменениями + chokidar + .watch(localDir, { ignoreInitial: true }) + .on('all', async (event, filePath) => { + if (event === 'add' || event === 'change') { + await uploadFile(filePath); + } else if (event === 'unlink') { + await deleteFile(filePath); + } + }); + + process.on('SIGINT', async () => { + console.log('🔌 Disconnecting...'); + await sftp.end(); + process.exit(); + }); +} + +main().catch(console.error); diff --git a/fe-app-podkop/yarn.lock b/fe-app-podkop/yarn.lock index 6791013..93738ea 100644 --- a/fe-app-podkop/yarn.lock +++ b/fe-app-podkop/yarn.lock @@ -221,6 +221,18 @@ resolved "https://registry.npmmirror.com/@humanwhocodes/retry/-/retry-0.4.3.tgz#c2b9d2e374ee62c586d3adbea87199b1d7a7a6ba" integrity sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ== +"@isaacs/balanced-match@^4.0.1": + version "4.0.1" + resolved "https://registry.npmmirror.com/@isaacs/balanced-match/-/balanced-match-4.0.1.tgz#3081dadbc3460661b751e7591d7faea5df39dd29" + integrity sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ== + +"@isaacs/brace-expansion@^5.0.0": + version "5.0.0" + resolved "https://registry.npmmirror.com/@isaacs/brace-expansion/-/brace-expansion-5.0.0.tgz#4b3dabab7d8e75a429414a96bd67bf4c1d13e0f3" + integrity sha512-ZT55BDLV0yv0RBm2czMiZ+SqCGO7AvmOM3G/w2xhVPH+te0aKgFjmBvGlL1dH+ql2tgGO3MVrbb3jCKyvpgnxA== + dependencies: + "@isaacs/balanced-match" "^4.0.1" + "@isaacs/cliui@^8.0.2": version "8.0.2" resolved "https://registry.npmmirror.com/@isaacs/cliui/-/cliui-8.0.2.tgz#b37667b7bc181c168782259bab42474fbf52b550" @@ -628,6 +640,13 @@ argparse@^2.0.1: resolved "https://registry.npmmirror.com/argparse/-/argparse-2.0.1.tgz#246f50f3ca78a3240f6c997e8a9bd1eac49e4b38" integrity sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q== +asn1@^0.2.6: + version "0.2.6" + resolved "https://registry.npmmirror.com/asn1/-/asn1-0.2.6.tgz#0d3a7bb6e64e02a90c0303b31f292868ea09a08d" + integrity sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ== + dependencies: + safer-buffer "~2.1.0" + assertion-error@^2.0.1: version "2.0.1" resolved "https://registry.npmmirror.com/assertion-error/-/assertion-error-2.0.1.tgz#f641a196b335690b1070bf00b6e7593fec190bf7" @@ -638,6 +657,13 @@ balanced-match@^1.0.0: resolved "https://registry.npmmirror.com/balanced-match/-/balanced-match-1.0.2.tgz#e83e3a7e3f300b34cb9d87f615fa0cbf357690ee" integrity sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw== +bcrypt-pbkdf@^1.0.2: + version "1.0.2" + resolved "https://registry.npmmirror.com/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz#a4301d389b6a43f9b67ff3ca11a3f6637e360e9e" + integrity sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w== + dependencies: + tweetnacl "^0.14.3" + brace-expansion@^1.1.7: version "1.1.12" resolved "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-1.1.12.tgz#ab9b454466e5a8cc3a187beaad580412a9c5b843" @@ -660,6 +686,16 @@ braces@^3.0.3: dependencies: fill-range "^7.1.1" +buffer-from@^1.0.0: + version "1.1.2" + resolved "https://registry.npmmirror.com/buffer-from/-/buffer-from-1.1.2.tgz#2b146a6fd72e80b4f55d255f35ed59a3a9a41bd5" + integrity sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ== + +buildcheck@~0.0.6: + version "0.0.6" + resolved "https://registry.npmmirror.com/buildcheck/-/buildcheck-0.0.6.tgz#89aa6e417cfd1e2196e3f8fe915eb709d2fe4238" + integrity sha512-8f9ZJCUXyT1M35Jx7MkBgmBMo3oHTTBIPLiY9xyL0pl3T5RwcPEY8cUHr5LBNfu/fk6c2T4DJZuVM/8ZZT2D2A== + bundle-require@^5.1.0: version "5.1.0" resolved "https://registry.npmmirror.com/bundle-require/-/bundle-require-5.1.0.tgz#8db66f41950da3d77af1ef3322f4c3e04009faee" @@ -701,7 +737,7 @@ check-error@^2.1.1: resolved "https://registry.npmmirror.com/check-error/-/check-error-2.1.1.tgz#87eb876ae71ee388fa0471fe423f494be1d96ccc" integrity sha512-OAlb+T7V4Op9OwdkjmguYRqncdlx5JiofwOAUkmTF+jNdHwzTaTs4sRAGpzLF3oOz5xAyDGrPgeIDFQmDOTiJw== -chokidar@^4.0.3: +chokidar@4.0.3, chokidar@^4.0.3: version "4.0.3" resolved "https://registry.npmmirror.com/chokidar/-/chokidar-4.0.3.tgz#7be37a4c03c9aee1ecfe862a4a23b2c70c205d30" integrity sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA== @@ -730,6 +766,16 @@ concat-map@0.0.1: resolved "https://registry.npmmirror.com/concat-map/-/concat-map-0.0.1.tgz#d8a96bd77fd68df7793a73036a3ba0d5405d477b" integrity sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg== +concat-stream@^2.0.0: + version "2.0.0" + resolved "https://registry.npmmirror.com/concat-stream/-/concat-stream-2.0.0.tgz#414cf5af790a48c60ab9be4527d56d5e41133cb1" + integrity sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A== + dependencies: + buffer-from "^1.0.0" + inherits "^2.0.3" + readable-stream "^3.0.2" + typedarray "^0.0.6" + confbox@^0.1.8: version "0.1.8" resolved "https://registry.npmmirror.com/confbox/-/confbox-0.1.8.tgz#820d73d3b3c82d9bd910652c5d4d599ef8ff8b06" @@ -740,6 +786,14 @@ consola@^3.4.0: resolved "https://registry.npmmirror.com/consola/-/consola-3.4.2.tgz#5af110145397bb67afdab77013fdc34cae590ea7" integrity sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA== +cpu-features@~0.0.10: + version "0.0.10" + resolved "https://registry.npmmirror.com/cpu-features/-/cpu-features-0.0.10.tgz#9aae536db2710c7254d7ed67cb3cbc7d29ad79c5" + integrity sha512-9IkYqtX3YHPCzoVg1Py+o9057a3i0fp7S530UWokCSaFVTc7CwXPRiOjRjBQQ18ZCNafx78YfnG+HALxtVmOGA== + dependencies: + buildcheck "~0.0.6" + nan "^2.19.0" + cross-spawn@^7.0.6: version "7.0.6" resolved "https://registry.npmmirror.com/cross-spawn/-/cross-spawn-7.0.6.tgz#8a58fe78f00dcd70c370451759dfbfaf03e8ee9f" @@ -766,6 +820,11 @@ deep-is@^0.1.3: resolved "https://registry.npmmirror.com/deep-is/-/deep-is-0.1.4.tgz#a6f2dce612fadd2ef1f519b73551f17e85199831" integrity sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ== +dotenv@17.2.3: + version "17.2.3" + resolved "https://registry.npmmirror.com/dotenv/-/dotenv-17.2.3.tgz#ad995d6997f639b11065f419a22fabf567cdb9a2" + integrity sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w== + eastasianwidth@^0.2.0: version "0.2.0" resolved "https://registry.npmmirror.com/eastasianwidth/-/eastasianwidth-0.2.0.tgz#696ce2ec0aa0e6ea93a397ffcf24aa7840c827cb" @@ -1014,7 +1073,7 @@ flatted@^3.2.9: resolved "https://registry.npmmirror.com/flatted/-/flatted-3.3.3.tgz#67c8fad95454a7c7abebf74bb78ee74a44023358" integrity sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg== -foreground-child@^3.1.0: +foreground-child@^3.1.0, foreground-child@^3.3.1: version "3.3.1" resolved "https://registry.npmmirror.com/foreground-child/-/foreground-child-3.3.1.tgz#32e8e9ed1b68a3497befb9ac2b6adf92a638576f" integrity sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw== @@ -1041,6 +1100,18 @@ glob-parent@^6.0.2: dependencies: is-glob "^4.0.3" +glob@11.0.3: + version "11.0.3" + resolved "https://registry.npmmirror.com/glob/-/glob-11.0.3.tgz#9d8087e6d72ddb3c4707b1d2778f80ea3eaefcd6" + integrity sha512-2Nim7dha1KVkaiF4q6Dj+ngPPMdfvLJEOpZk/jKiUAkqKebpGAWQXAq9z1xu9HKu5lWfqw/FASuccEjyznjPaA== + dependencies: + foreground-child "^3.3.1" + jackspeak "^4.1.1" + minimatch "^10.0.3" + minipass "^7.1.2" + package-json-from-dist "^1.0.0" + path-scurry "^2.0.0" + glob@^10.3.10: version "10.4.5" resolved "https://registry.npmmirror.com/glob/-/glob-10.4.5.tgz#f4d9f0b90ffdbab09c9d77f5f29b4262517b0956" @@ -1091,6 +1162,11 @@ imurmurhash@^0.1.4: resolved "https://registry.npmmirror.com/imurmurhash/-/imurmurhash-0.1.4.tgz#9218b9b2b928a238b13dc4fb6b6d576f231453ea" integrity sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA== +inherits@^2.0.3: + version "2.0.4" + resolved "https://registry.npmmirror.com/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c" + integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ== + is-extglob@^2.1.1: version "2.1.1" resolved "https://registry.npmmirror.com/is-extglob/-/is-extglob-2.1.1.tgz#a88c02535791f02ed37c76a1b9ea9773c833f8c2" @@ -1127,6 +1203,13 @@ jackspeak@^3.1.2: optionalDependencies: "@pkgjs/parseargs" "^0.11.0" +jackspeak@^4.1.1: + version "4.1.1" + resolved "https://registry.npmmirror.com/jackspeak/-/jackspeak-4.1.1.tgz#96876030f450502047fc7e8c7fcf8ce8124e43ae" + integrity sha512-zptv57P3GpL+O0I7VdMJNBZCu+BPHVQUk55Ft8/QCJjTVxrnJHuVuX/0Bl2A6/+2oyR/ZMEuFKwmzqqZ/U5nPQ== + dependencies: + "@isaacs/cliui" "^8.0.2" + joycon@^3.1.1: version "3.1.1" resolved "https://registry.npmmirror.com/joycon/-/joycon-3.1.1.tgz#bce8596d6ae808f8b68168f5fc69280996894f03" @@ -1216,6 +1299,11 @@ lru-cache@^10.2.0: resolved "https://registry.npmmirror.com/lru-cache/-/lru-cache-10.4.3.tgz#410fc8a17b70e598013df257c2446b7f3383f119" integrity sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ== +lru-cache@^11.0.0: + version "11.2.2" + resolved "https://registry.npmmirror.com/lru-cache/-/lru-cache-11.2.2.tgz#40fd37edffcfae4b2940379c0722dc6eeaa75f24" + integrity sha512-F9ODfyqML2coTIsQpSkRHnLSZMtkU8Q+mSfcaIyKwy58u+8k5nvAYeiNhsyMARvzNcXJ9QfWVrcPsC9e9rAxtg== + magic-string@^0.30.17: version "0.30.19" resolved "https://registry.npmmirror.com/magic-string/-/magic-string-0.30.19.tgz#cebe9f104e565602e5d2098c5f2e79a77cc86da9" @@ -1236,6 +1324,13 @@ micromatch@^4.0.8: braces "^3.0.3" picomatch "^2.3.1" +minimatch@^10.0.3: + version "10.0.3" + resolved "https://registry.npmmirror.com/minimatch/-/minimatch-10.0.3.tgz#cf7a0314a16c4d9ab73a7730a0e8e3c3502d47aa" + integrity sha512-IPZ167aShDZZUMdRk66cyQAW3qr0WzbHkPdMYa8bzZhlHhO3jALbKdxcaak7W9FfT2rZNpQuUu4Od7ILEpXSaw== + dependencies: + "@isaacs/brace-expansion" "^5.0.0" + minimatch@^3.1.2: version "3.1.2" resolved "https://registry.npmmirror.com/minimatch/-/minimatch-3.1.2.tgz#19cd194bfd3e428f049a70817c038d89ab4be35b" @@ -1279,6 +1374,11 @@ mz@^2.7.0: object-assign "^4.0.1" thenify-all "^1.0.0" +nan@^2.19.0, nan@^2.23.0: + version "2.23.0" + resolved "https://registry.npmmirror.com/nan/-/nan-2.23.0.tgz#24aa4ddffcc37613a2d2935b97683c1ec96093c6" + integrity sha512-1UxuyYGdoQHcGg87Lkqm3FzefucTa0NAiOcuRsDmysep3c1LVCRK2krrUDafMWtjSG04htvAmvg96+SDknOmgQ== + nanoid@^3.3.11: version "3.3.11" resolved "https://registry.npmmirror.com/nanoid/-/nanoid-3.3.11.tgz#4f4f112cefbe303202f2199838128936266d185b" @@ -1350,6 +1450,14 @@ path-scurry@^1.11.1: lru-cache "^10.2.0" minipass "^5.0.0 || ^6.0.2 || ^7.0.0" +path-scurry@^2.0.0: + version "2.0.0" + resolved "https://registry.npmmirror.com/path-scurry/-/path-scurry-2.0.0.tgz#9f052289f23ad8bf9397a2a0425e7b8615c58580" + integrity sha512-ypGJsmGtdXUOeM5u93TyeIEfEhM6s+ljAhrk5vAvSx8uyY/02OvrZnA0YNGUrPXfpJMgI1ODd3nwz8Npx4O4cg== + dependencies: + lru-cache "^11.0.0" + minipass "^7.1.2" + pathe@^2.0.1, pathe@^2.0.3: version "2.0.3" resolved "https://registry.npmmirror.com/pathe/-/pathe-2.0.3.tgz#3ecbec55421685b70a9da872b2cff3e1cbed1716" @@ -1425,6 +1533,15 @@ queue-microtask@^1.2.2: resolved "https://registry.npmmirror.com/queue-microtask/-/queue-microtask-1.2.3.tgz#4929228bbc724dfac43e0efb058caf7b6cfb6243" integrity sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A== +readable-stream@^3.0.2: + version "3.6.2" + resolved "https://registry.npmmirror.com/readable-stream/-/readable-stream-3.6.2.tgz#56a9b36ea965c00c5a93ef31eb111a0f11056967" + integrity sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA== + dependencies: + inherits "^2.0.3" + string_decoder "^1.1.1" + util-deprecate "^1.0.1" + readdirp@^4.0.1: version "4.1.2" resolved "https://registry.npmmirror.com/readdirp/-/readdirp-4.1.2.tgz#eb85801435fbf2a7ee58f19e0921b068fc69948d" @@ -1483,6 +1600,16 @@ run-parallel@^1.1.9: dependencies: queue-microtask "^1.2.2" +safe-buffer@~5.2.0: + version "5.2.1" + resolved "https://registry.npmmirror.com/safe-buffer/-/safe-buffer-5.2.1.tgz#1eaf9fa9bdb1fdd4ec75f58f9cdb4e6b7827eec6" + integrity sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ== + +safer-buffer@~2.1.0: + version "2.1.2" + resolved "https://registry.npmmirror.com/safer-buffer/-/safer-buffer-2.1.2.tgz#44fa161b0187b9549dd84bb91802f9bd8385cd6a" + integrity sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg== + semver@^7.6.0: version "7.7.2" resolved "https://registry.npmmirror.com/semver/-/semver-7.7.2.tgz#67d99fdcd35cec21e6f8b87a7fd515a33f982b58" @@ -1522,6 +1649,25 @@ source-map@0.8.0-beta.0: dependencies: whatwg-url "^7.0.0" +ssh2-sftp-client@12.0.1: + version "12.0.1" + resolved "https://registry.npmmirror.com/ssh2-sftp-client/-/ssh2-sftp-client-12.0.1.tgz#926764878954dbed85f6f9233ce7980bfc94fdd4" + integrity sha512-ICJ1L2PmBel2Q2ctbyxzTFZCPKSHYYD6s2TFZv7NXmZDrDNGk8lHBb/SK2WgXLMXNANH78qoumeJzxlWZqSqWg== + dependencies: + concat-stream "^2.0.0" + ssh2 "^1.16.0" + +ssh2@^1.16.0: + version "1.17.0" + resolved "https://registry.npmmirror.com/ssh2/-/ssh2-1.17.0.tgz#dc686e8e3abdbd4ad95d46fa139615903c12258c" + integrity sha512-wPldCk3asibAjQ/kziWQQt1Wh3PgDFpC0XpwclzKcdT1vql6KeYxf5LIt4nlFkUeR8WuphYMKqUA56X4rjbfgQ== + dependencies: + asn1 "^0.2.6" + bcrypt-pbkdf "^1.0.2" + optionalDependencies: + cpu-features "~0.0.10" + nan "^2.23.0" + stackback@0.0.2: version "0.0.2" resolved "https://registry.npmmirror.com/stackback/-/stackback-0.0.2.tgz#1ac8a0d9483848d1695e418b6d031a3c3ce68e3b" @@ -1559,6 +1705,13 @@ string-width@^5.0.1, string-width@^5.1.2: emoji-regex "^9.2.2" strip-ansi "^7.0.1" +string_decoder@^1.1.1: + version "1.3.0" + resolved "https://registry.npmmirror.com/string_decoder/-/string_decoder-1.3.0.tgz#42f114594a46cf1a8e30b0a84f56c78c3edac21e" + integrity sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA== + dependencies: + safe-buffer "~5.2.0" + "strip-ansi-cjs@npm:strip-ansi@^6.0.1": version "6.0.1" resolved "https://registry.npmmirror.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" @@ -1711,6 +1864,11 @@ tsup@8.5.0: tinyglobby "^0.2.11" tree-kill "^1.2.2" +tweetnacl@^0.14.3: + version "0.14.5" + resolved "https://registry.npmmirror.com/tweetnacl/-/tweetnacl-0.14.5.tgz#5ae68177f192d4456269d108afa93ff8743f4f64" + integrity sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA== + type-check@^0.4.0, type-check@~0.4.0: version "0.4.0" resolved "https://registry.npmmirror.com/type-check/-/type-check-0.4.0.tgz#07b8203bfa7056c0657050e3ccd2c37730bab8f1" @@ -1718,6 +1876,11 @@ type-check@^0.4.0, type-check@~0.4.0: dependencies: prelude-ls "^1.2.1" +typedarray@^0.0.6: + version "0.0.6" + resolved "https://registry.npmmirror.com/typedarray/-/typedarray-0.0.6.tgz#867ac74e3864187b1d3d47d996a78ec5c8830777" + integrity sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA== + typescript-eslint@8.45.0: version "8.45.0" resolved "https://registry.npmmirror.com/typescript-eslint/-/typescript-eslint-8.45.0.tgz#98ab164234dc04c112747ec0a4ae29a94efe123b" @@ -1745,6 +1908,11 @@ uri-js@^4.2.2: dependencies: punycode "^2.1.0" +util-deprecate@^1.0.1: + version "1.0.2" + resolved "https://registry.npmmirror.com/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf" + integrity sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw== + vite-node@3.2.4: version "3.2.4" resolved "https://registry.npmmirror.com/vite-node/-/vite-node-3.2.4.tgz#f3676d94c4af1e76898c162c92728bca65f7bb07" diff --git a/luci-app-podkop/htdocs/luci-static/resources/view/podkop/dashboardTab.js b/luci-app-podkop/htdocs/luci-static/resources/view/podkop/dashboardTab.js new file mode 100644 index 0000000..7a7eff1 --- /dev/null +++ b/luci-app-podkop/htdocs/luci-static/resources/view/podkop/dashboardTab.js @@ -0,0 +1,22 @@ +'use strict'; +'require baseclass'; +'require form'; +'require ui'; +'require uci'; +'require fs'; +'require view.podkop.utils as utils'; +'require view.podkop.main as main'; + +function createDashboardSection(mainSection) { + let o = mainSection.tab('dashboard', _('Dashboard')); + + o = mainSection.taboption('dashboard', form.DummyValue, '_status'); + o.rawhtml = true; + o.cfgvalue = () => main.renderDashboard(); +} + +const EntryPoint = { + createDashboardSection, +} + +return baseclass.extend(EntryPoint); diff --git a/luci-app-podkop/htdocs/luci-static/resources/view/podkop/main.js b/luci-app-podkop/htdocs/luci-static/resources/view/podkop/main.js index cc1c318..8f3a45c 100644 --- a/luci-app-podkop/htdocs/luci-static/resources/view/podkop/main.js +++ b/luci-app-podkop/htdocs/luci-static/resources/view/podkop/main.js @@ -2,6 +2,7 @@ "use strict"; "require baseclass"; "require fs"; +"require uci"; // src/validators/validateIp.ts function validateIPV4(ip) { @@ -370,6 +371,141 @@ var GlobalStyles = ` #cbi-podkop:has(.cbi-tab-disabled[data-tab="basic"]) #cbi-podkop-extra { display: none; } + +#cbi-podkop-main-_status > div { + width: 100%; +} + +.pdk_dashboard-page { + width: 100%; + --dashboard-grid-columns: 4; +} + +@media (max-width: 900px) { + .pdk_dashboard-page { + --dashboard-grid-columns: 2; + } +} + +/*@media (max-width: 440px) {*/ +/* .pdk_dashboard-page {*/ +/* --dashboard-grid-columns: 1;*/ +/* }*/ +/*}*/ + +.pdk_dashboard-page__title-section { + display: flex; + align-items: center; + justify-content: space-between; + border: 2px var(--background-color-low) solid; + border-radius: 4px; + padding: 0 10px; +} + +.pdk_dashboard-page__title-section__title { + color: var(--text-color-high); + font-weight: 700; +} + +.pdk_dashboard-page__widgets-section { + margin-top: 10px; + display: grid; + grid-template-columns: repeat(var(--dashboard-grid-columns), 1fr); + grid-gap: 10px; +} + +.pdk_dashboard-page__widgets-section__item { + border: 2px var(--background-color-low) solid; + border-radius: 4px; + padding: 10px; +} + +.pdk_dashboard-page__outbound-section { + margin-top: 10px; + border: 2px var(--background-color-low) solid; + border-radius: 4px; + padding: 10px; +} + +.pdk_dashboard-page__outbound-section__title-section { + display: flex; + align-items: center; + justify-content: space-between; +} + +.pdk_dashboard-page__outbound-section__title-section__title { + color: var(--text-color-high); + font-weight: 700; +} + +.pdk_dashboard-page__outbound-grid { + margin-top: 5px; + display: grid; + grid-template-columns: repeat(var(--dashboard-grid-columns), 1fr); + grid-gap: 10px; +} + +.pdk_dashboard-page__outbound-grid__item { + cursor: pointer; + border: 2px var(--background-color-low) solid; + border-radius: 4px; + padding: 10px; + transition: border 0.2s ease; +} +.pdk_dashboard-page__outbound-grid__item:hover { + border-color: var(--primary-color-high); +} + +.pdk_dashboard-page__outbound-grid__item--active { + border-color: var(--success-color-medium); +} + +.pdk_dashboard-page__outbound-grid__item__footer { + display: flex; + align-items: center; + justify-content: space-between; + margin-top: 10px; +} + +.pdk_dashboard-page__outbound-grid__item__type { + +} + +.pdk_dashboard-page__outbound-grid__item__latency { + +} + + + +/* Skeleton styles*/ +.skeleton { + background-color: var(--background-color-low, #e0e0e0); + border-radius: 4px; + position: relative; + overflow: hidden; +} + +.skeleton::after { + content: ''; + position: absolute; + top: 0; + left: -150%; + width: 150%; + height: 100%; + background: linear-gradient( + 90deg, + transparent, + rgba(255, 255, 255, 0.4), + transparent + ); + animation: skeleton-shimmer 1.6s infinite; +} + +@keyframes skeleton-shimmer { + 100% { + left: 150%; + } +} `; // src/helpers/injectGlobalStyles.ts @@ -557,6 +693,57 @@ function maskIP(ip = "") { return ip.replace(ipv4Regex, (_match, _p1, _p2, _p3, p4) => `XX.XX.XX.${p4}`); } +// src/helpers/getProxyUrlName.ts +function getProxyUrlName(url) { + try { + const [_link, hash] = url.split("#"); + if (!hash) { + return ""; + } + return decodeURIComponent(hash); + } catch { + return ""; + } +} + +// src/helpers/onMount.ts +async function onMount(id) { + return new Promise((resolve) => { + const el = document.getElementById(id); + if (el && el.offsetParent !== null) { + return resolve(el); + } + const observer = new MutationObserver(() => { + const target = document.getElementById(id); + if (target) { + const io = new IntersectionObserver((entries) => { + const visible = entries.some((e) => e.isIntersecting); + if (visible) { + observer.disconnect(); + io.disconnect(); + resolve(target); + } + }); + io.observe(target); + } + }); + observer.observe(document.body, { + childList: true, + subtree: true + }); + }); +} + +// src/helpers/getClashApiUrl.ts +function getClashApiUrl() { + const { protocol, hostname } = window.location; + return `${protocol}//${hostname}:9090`; +} +function getClashWsUrl() { + const { hostname } = window.location; + return `ws://${hostname}:9090`; +} + // src/clash/methods/createBaseApiRequest.ts async function createBaseApiRequest(fetchFn) { try { @@ -583,7 +770,7 @@ async function createBaseApiRequest(fetchFn) { // src/clash/methods/getConfig.ts async function getClashConfig() { return createBaseApiRequest( - () => fetch("http://192.168.160.129:9090/configs", { + () => fetch(`${getClashApiUrl()}/configs`, { method: "GET", headers: { "Content-Type": "application/json" } }) @@ -592,7 +779,7 @@ async function getClashConfig() { // src/clash/methods/getGroupDelay.ts async function getClashGroupDelay(group, url = "https://www.gstatic.com/generate_204", timeout = 2e3) { - const endpoint = `http://192.168.160.129:9090/group/${group}/delay?url=${encodeURIComponent( + const endpoint = `${getClashApiUrl()}/group/${group}/delay?url=${encodeURIComponent( url )}&timeout=${timeout}`; return createBaseApiRequest( @@ -606,7 +793,7 @@ async function getClashGroupDelay(group, url = "https://www.gstatic.com/generate // src/clash/methods/getProxies.ts async function getClashProxies() { return createBaseApiRequest( - () => fetch("http://192.168.160.129:9090/proxies", { + () => fetch(`${getClashApiUrl()}/proxies`, { method: "GET", headers: { "Content-Type": "application/json" } }) @@ -616,12 +803,553 @@ async function getClashProxies() { // src/clash/methods/getVersion.ts async function getClashVersion() { return createBaseApiRequest( - () => fetch("http://192.168.160.129:9090/version", { + () => fetch(`${getClashApiUrl()}/version`, { method: "GET", headers: { "Content-Type": "application/json" } }) ); } + +// src/clash/methods/triggerProxySelector.ts +async function triggerProxySelector(selector, outbound) { + return createBaseApiRequest( + () => fetch(`${getClashApiUrl()}/proxies/${selector}`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name: outbound }) + }) + ); +} + +// src/dashboard/renderDashboard.ts +function renderDashboard() { + return E( + "div", + { + id: "dashboard-status", + class: "pdk_dashboard-page" + }, + [ + // Title section + E("div", { class: "pdk_dashboard-page__title-section" }, [ + E( + "h3", + { class: "pdk_dashboard-page__title-section__title" }, + "Overall (alpha)" + ), + E("label", {}, [ + E("input", { type: "checkbox", disabled: true, checked: true }), + " Runtime" + ]) + ]), + // Widgets section + E("div", { class: "pdk_dashboard-page__widgets-section" }, [ + E("div", { id: "dashboard-widget-traffic" }, [ + E( + "div", + { + id: "", + style: "height: 78px", + class: "pdk_dashboard-page__widgets-section__item skeleton" + }, + "" + ) + ]), + E("div", { id: "dashboard-widget-traffic-total" }, [ + E( + "div", + { + id: "", + style: "height: 78px", + class: "pdk_dashboard-page__widgets-section__item skeleton" + }, + "" + ) + ]), + E("div", { id: "dashboard-widget-system-info" }, [ + E( + "div", + { + id: "", + style: "height: 78px", + class: "pdk_dashboard-page__widgets-section__item skeleton" + }, + "" + ) + ]), + E("div", { id: "dashboard-widget-service-info" }, [ + E( + "div", + { + id: "", + style: "height: 78px", + class: "pdk_dashboard-page__widgets-section__item skeleton" + }, + "" + ) + ]) + ]), + // All outbounds + E("div", { id: "dashboard-sections-grid" }, [ + E("div", { + id: "dashboard-sections-grid-skeleton", + class: "pdk_dashboard-page__outbound-section skeleton", + style: "height: 127px" + }) + ]) + ] + ); +} + +// src/podkop/methods/getConfigSections.ts +async function getConfigSections() { + return uci.load("podkop").then(() => uci.sections("podkop")); +} + +// src/podkop/methods/getDashboardSections.ts +async function getDashboardSections() { + const configSections = await getConfigSections(); + const clashProxies = await getClashProxies(); + const clashProxiesData = clashProxies.success ? clashProxies.data : { proxies: [] }; + const proxies = Object.entries(clashProxiesData.proxies).map( + ([key, value]) => ({ + code: key, + value + }) + ); + return configSections.filter((section) => section.mode !== "block").map((section) => { + if (section.mode === "proxy") { + if (section.proxy_config_type === "url") { + const outbound = proxies.find( + (proxy) => proxy.code === `${section[".name"]}-out` + ); + return { + code: section[".name"], + displayName: section[".name"], + outbounds: [ + { + code: outbound?.code || section[".name"], + displayName: getProxyUrlName(section.proxy_string) || outbound?.value?.name || "", + latency: outbound?.value?.history?.[0]?.delay || 0, + type: outbound?.value?.type || "", + selected: true + } + ] + }; + } + if (section.proxy_config_type === "outbound") { + const outbound = proxies.find( + (proxy) => proxy.code === `${section[".name"]}-out` + ); + return { + code: section[".name"], + displayName: section[".name"], + outbounds: [ + { + code: outbound?.code || section[".name"], + displayName: decodeURIComponent(JSON.parse(section.outbound_json)?.tag) || outbound?.value?.name || "", + latency: outbound?.value?.history?.[0]?.delay || 0, + type: outbound?.value?.type || "", + selected: true + } + ] + }; + } + if (section.proxy_config_type === "urltest") { + const selector = proxies.find( + (proxy) => proxy.code === `${section[".name"]}-out` + ); + const outbound = proxies.find( + (proxy) => proxy.code === `${section[".name"]}-urltest-out` + ); + const outbounds = (outbound?.value?.all ?? []).map((code) => proxies.find((item) => item.code === code)).map((item, index) => ({ + code: item?.code || "", + displayName: getProxyUrlName(section.urltest_proxy_links?.[index]) || item?.value?.name || "", + latency: item?.value?.history?.[0]?.delay || 0, + type: item?.value?.type || "", + selected: selector?.value?.now === item?.code + })); + return { + code: section[".name"], + displayName: section[".name"], + outbounds: [ + { + code: outbound?.code || "", + displayName: "Fastest", + latency: outbound?.value?.history?.[0]?.delay || 0, + type: outbound?.value?.type || "", + selected: selector?.value?.now === outbound?.code + }, + ...outbounds + ] + }; + } + } + return { + code: section[".name"], + displayName: section[".name"], + outbounds: [] + }; + }); +} + +// src/podkop/methods/getPodkopStatus.ts +async function getPodkopStatus() { + const response = await executeShellCommand({ + command: "/usr/bin/podkop", + args: ["get_status"], + timeout: 1e3 + }); + if (response.stdout) { + return JSON.parse(response.stdout.replace(/\n/g, "")); + } + return { enabled: 0, status: "unknown" }; +} + +// src/podkop/methods/getSingboxStatus.ts +async function getSingboxStatus() { + const response = await executeShellCommand({ + command: "/usr/bin/podkop", + args: ["get_sing_box_status"], + timeout: 1e3 + }); + if (response.stdout) { + return JSON.parse(response.stdout.replace(/\n/g, "")); + } + return { running: 0, enabled: 0, status: "unknown" }; +} + +// src/dashboard/renderer/renderOutboundGroup.ts +function renderOutboundGroup({ + outbounds, + displayName +}) { + function renderOutbound(outbound) { + return E( + "div", + { + class: `pdk_dashboard-page__outbound-grid__item ${outbound.selected ? "pdk_dashboard-page__outbound-grid__item--active" : ""}` + }, + [ + E("b", {}, outbound.displayName), + E("div", { class: "pdk_dashboard-page__outbound-grid__item__footer" }, [ + E( + "div", + { class: "pdk_dashboard-page__outbound-grid__item__type" }, + outbound.type + ), + E( + "div", + { class: "pdk_dashboard-page__outbound-grid__item__latency" }, + outbound.latency ? `${outbound.latency}ms` : "N/A" + ) + ]) + ] + ); + } + return E("div", { class: "pdk_dashboard-page__outbound-section" }, [ + // Title with test latency + E("div", { class: "pdk_dashboard-page__outbound-section__title-section" }, [ + E( + "div", + { + class: "pdk_dashboard-page__outbound-section__title-section__title" + }, + displayName + ), + E("button", { class: "btn" }, "Test latency") + ]), + E( + "div", + { class: "pdk_dashboard-page__outbound-grid" }, + outbounds.map((outbound) => renderOutbound(outbound)) + ) + ]); +} + +// src/store.ts +var Store = class { + constructor(initial) { + this.listeners = /* @__PURE__ */ new Set(); + this.value = initial; + } + get() { + return this.value; + } + set(next) { + const prev = this.value; + const merged = { ...this.value, ...next }; + if (Object.is(prev, merged)) return; + this.value = merged; + const diff = {}; + for (const key in merged) { + if (merged[key] !== prev[key]) diff[key] = merged[key]; + } + this.listeners.forEach((cb) => cb(this.value, prev, diff)); + } + subscribe(cb) { + this.listeners.add(cb); + cb(this.value, this.value, {}); + return () => this.listeners.delete(cb); + } + patch(key, value) { + this.set({ ...this.value, [key]: value }); + } + getKey(key) { + return this.value[key]; + } + subscribeKey(key, cb) { + let prev = this.value[key]; + const unsub = this.subscribe((val) => { + if (val[key] !== prev) { + prev = val[key]; + cb(val[key]); + } + }); + return unsub; + } +}; +var store = new Store({ + sections: [], + traffic: { up: 0, down: 0 }, + memory: { inuse: 0, oslimit: 0 }, + connections: { connections: [], memory: 0, downloadTotal: 0, uploadTotal: 0 }, + services: { singbox: "", podkop: "" } +}); + +// src/socket.ts +var SocketManager = class _SocketManager { + constructor() { + this.sockets = /* @__PURE__ */ new Map(); + this.listeners = /* @__PURE__ */ new Map(); + this.connected = /* @__PURE__ */ new Map(); + } + static getInstance() { + if (!_SocketManager.instance) { + _SocketManager.instance = new _SocketManager(); + } + return _SocketManager.instance; + } + connect(url) { + if (this.sockets.has(url)) return; + const ws = new WebSocket(url); + this.sockets.set(url, ws); + this.connected.set(url, false); + this.listeners.set(url, /* @__PURE__ */ new Set()); + ws.addEventListener("open", () => { + this.connected.set(url, true); + console.log(`\u2705 Connected: ${url}`); + }); + ws.addEventListener("message", (event) => { + const handlers = this.listeners.get(url); + if (handlers) { + for (const handler of handlers) { + try { + handler(event.data); + } catch (err) { + console.error(`Handler error for ${url}:`, err); + } + } + } + }); + ws.addEventListener("close", () => { + this.connected.set(url, false); + console.warn(`\u26A0\uFE0F Disconnected: ${url}`); + }); + ws.addEventListener("error", (err) => { + console.error(`\u274C Socket error for ${url}:`, err); + }); + } + subscribe(url, listener) { + if (!this.sockets.has(url)) { + this.connect(url); + } + this.listeners.get(url)?.add(listener); + } + unsubscribe(url, listener) { + this.listeners.get(url)?.delete(listener); + } + // eslint-disable-next-line + send(url, data) { + const ws = this.sockets.get(url); + if (ws && this.connected.get(url)) { + ws.send(typeof data === "string" ? data : JSON.stringify(data)); + } else { + console.warn(`\u26A0\uFE0F Cannot send: not connected to ${url}`); + } + } + disconnect(url) { + const ws = this.sockets.get(url); + if (ws) { + ws.close(); + this.sockets.delete(url); + this.listeners.delete(url); + this.connected.delete(url); + } + } + disconnectAll() { + for (const url of this.sockets.keys()) { + this.disconnect(url); + } + } +}; +var socket = SocketManager.getInstance(); + +// src/dashboard/renderer/renderWidget.ts +function renderDashboardWidget({ title, items }) { + return E("div", { class: "pdk_dashboard-page__widgets-section__item" }, [ + E("b", {}, title), + ...items.map( + (item) => E("div", {}, [E("span", {}, `${item.key}: `), E("span", {}, item.value)]) + ) + ]); +} + +// src/helpers/prettyBytes.ts +function prettyBytes(n) { + const UNITS = ["B", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"]; + if (n < 1e3) { + return n + " B"; + } + const exponent = Math.min(Math.floor(Math.log10(n) / 3), UNITS.length - 1); + n = Number((n / Math.pow(1e3, exponent)).toPrecision(3)); + const unit = UNITS[exponent]; + return n + " " + unit; +} + +// src/dashboard/initDashboardController.ts +async function fetchDashboardSections() { + const sections = await getDashboardSections(); + store.set({ sections }); +} +async function fetchServicesInfo() { + const podkop = await getPodkopStatus(); + const singbox = await getSingboxStatus(); + console.log("podkop", podkop); + console.log("singbox", singbox); + store.set({ + services: { + singbox: singbox.running ? "\u2714 Enabled" : singbox.status, + podkop: podkop.status ? "\u2714 Enabled" : podkop.status + } + }); +} +async function connectToClashSockets() { + socket.subscribe(`${getClashWsUrl()}/traffic?token=`, (msg) => { + const parsedMsg = JSON.parse(msg); + store.set({ + traffic: { up: parsedMsg.up, down: parsedMsg.down } + }); + }); + socket.subscribe(`${getClashWsUrl()}/connections?token=`, (msg) => { + const parsedMsg = JSON.parse(msg); + store.set({ + connections: { + connections: parsedMsg.connections, + downloadTotal: parsedMsg.downloadTotal, + uploadTotal: parsedMsg.uploadTotal, + memory: parsedMsg.memory + } + }); + }); + socket.subscribe(`${getClashWsUrl()}/memory?token=`, (msg) => { + store.set({ + memory: { inuse: msg.inuse, oslimit: msg.oslimit } + }); + }); +} +async function renderDashboardSections() { + const sections = store.get().sections; + console.log("render dashboard sections group"); + const container = document.getElementById("dashboard-sections-grid"); + const renderedOutboundGroups = sections.map(renderOutboundGroup); + container.replaceChildren(...renderedOutboundGroups); +} +async function renderTrafficWidget() { + const traffic = store.get().traffic; + console.log("render dashboard traffic widget"); + const container = document.getElementById("dashboard-widget-traffic"); + const renderedWidget = renderDashboardWidget({ + title: "Traffic", + items: [ + { key: "Uplink", value: `${prettyBytes(traffic.up)}/s` }, + { key: "Downlink", value: `${prettyBytes(traffic.down)}/s` } + ] + }); + container.replaceChildren(renderedWidget); +} +async function renderTrafficTotalWidget() { + const connections = store.get().connections; + console.log("render dashboard traffic total widget"); + const container = document.getElementById("dashboard-widget-traffic-total"); + const renderedWidget = renderDashboardWidget({ + title: "Traffic Total", + items: [ + { key: "Uplink", value: String(prettyBytes(connections.uploadTotal)) }, + { + key: "Downlink", + value: String(prettyBytes(connections.downloadTotal)) + } + ] + }); + container.replaceChildren(renderedWidget); +} +async function renderSystemInfoWidget() { + const connections = store.get().connections; + console.log("render dashboard system info widget"); + const container = document.getElementById("dashboard-widget-system-info"); + const renderedWidget = renderDashboardWidget({ + title: "System info", + items: [ + { + key: "Active Connections", + value: String(connections.connections.length) + }, + { key: "Memory Usage", value: String(prettyBytes(connections.memory)) } + ] + }); + container.replaceChildren(renderedWidget); +} +async function renderServiceInfoWidget() { + const services = store.get().services; + console.log("render dashboard service info widget"); + const container = document.getElementById("dashboard-widget-service-info"); + const renderedWidget = renderDashboardWidget({ + title: "Services info", + items: [ + { + key: "Podkop", + value: String(services.podkop) + }, + { key: "Sing-box", value: String(services.singbox) } + ] + }); + container.replaceChildren(renderedWidget); +} +async function initDashboardController() { + store.subscribe((next, prev, diff) => { + console.log("Store changed", { prev, next, diff }); + if (diff?.sections) { + renderDashboardSections(); + } + if (diff?.traffic) { + renderTrafficWidget(); + } + if (diff?.connections) { + renderTrafficTotalWidget(); + renderSystemInfoWidget(); + } + if (diff?.services) { + renderServiceInfoWidget(); + } + }); + onMount("dashboard-status").then(() => { + console.log("Mounting dashboard"); + fetchDashboardSections(); + fetchServicesInfo(); + connectToClashSockets(); + }); +} return baseclass.extend({ ALLOWED_WITH_RUSSIA_INSIDE, BOOTSTRAP_DNS_SERVER_OPTIONS, @@ -645,13 +1373,20 @@ return baseclass.extend({ createBaseApiRequest, executeShellCommand, getBaseUrl, + getClashApiUrl, getClashConfig, getClashGroupDelay, getClashProxies, getClashVersion, + getClashWsUrl, + getProxyUrlName, + initDashboardController, injectGlobalStyles, maskIP, + onMount, parseValueList, + renderDashboard, + triggerProxySelector, validateDNS, validateDomain, validateIPV4, diff --git a/luci-app-podkop/htdocs/luci-static/resources/view/podkop/podkop.js b/luci-app-podkop/htdocs/luci-static/resources/view/podkop/podkop.js index 7607ab2..3472a86 100644 --- a/luci-app-podkop/htdocs/luci-static/resources/view/podkop/podkop.js +++ b/luci-app-podkop/htdocs/luci-static/resources/view/podkop/podkop.js @@ -5,6 +5,7 @@ 'require view.podkop.configSection as configSection'; 'require view.podkop.diagnosticTab as diagnosticTab'; 'require view.podkop.additionalTab as additionalTab'; +'require view.podkop.dashboardTab as dashboardTab'; 'require view.podkop.utils as utils'; 'require view.podkop.main as main'; @@ -12,26 +13,31 @@ const EntryNode = { async render() { main.injectGlobalStyles(); - main.getClashVersion() - .then(result => console.log('getClashVersion - then', result)) - .catch(err => console.log('getClashVersion - err', err)) - .finally(() => console.log('getClashVersion - finish')); - - main.getClashConfig() - .then(result => console.log('getClashConfig - then', result)) - .catch(err => console.log('getClashConfig - err', err)) - .finally(() => console.log('getClashConfig - finish')); - - main.getClashProxies() - .then(result => console.log('getClashProxies - then', result)) - .catch(err => console.log('getClashProxies - err', err)) - .finally(() => console.log('getClashProxies - finish')); + // main.getClashVersion() + // .then(result => console.log('getClashVersion - then', result)) + // .catch(err => console.log('getClashVersion - err', err)) + // .finally(() => console.log('getClashVersion - finish')); + // + // main.getClashConfig() + // .then(result => console.log('getClashConfig - then', result)) + // .catch(err => console.log('getClashConfig - err', err)) + // .finally(() => console.log('getClashConfig - finish')); + // + // main.getClashProxies() + // .then(result => console.log('getClashProxies - then', result)) + // .catch(err => console.log('getClashProxies - err', err)) + // .finally(() => console.log('getClashProxies - finish')); const podkopFormMap = new form.Map('podkop', '', null, ['main', 'extra']); // Main Section const mainSection = podkopFormMap.section(form.TypedSection, 'main'); mainSection.anonymous = true; + + dashboardTab.createDashboardSection(mainSection); + + main.initDashboardController(); + configSection.createConfigSection(mainSection); // Additional Settings Tab (main section) From b2a473573bff61331ae7b988572d0a4601ba0ffc Mon Sep 17 00:00:00 2001 From: divocat Date: Mon, 6 Oct 2025 15:13:55 +0300 Subject: [PATCH 36/49] feat: add vpn section outbound displaying --- .../podkop/methods/getDashboardSections.ts | 20 +++++++++++++++++++ .../luci-static/resources/view/podkop/main.js | 18 +++++++++++++++++ 2 files changed, 38 insertions(+) diff --git a/fe-app-podkop/src/podkop/methods/getDashboardSections.ts b/fe-app-podkop/src/podkop/methods/getDashboardSections.ts index 1ea8886..aaa0c52 100644 --- a/fe-app-podkop/src/podkop/methods/getDashboardSections.ts +++ b/fe-app-podkop/src/podkop/methods/getDashboardSections.ts @@ -106,6 +106,26 @@ export async function getDashboardSections(): Promise { } } + if (section.mode === 'vpn') { + const outbound = proxies.find( + (proxy) => proxy.code === `${section['.name']}-out`, + ); + + return { + code: section['.name'], + displayName: section['.name'], + outbounds: [ + { + code: outbound?.code || section['.name'], + displayName: section.interface || outbound?.value?.name || '', + latency: outbound?.value?.history?.[0]?.delay || 0, + type: outbound?.value?.type || '', + selected: true, + }, + ], + }; + } + return { code: section['.name'], displayName: section['.name'], diff --git a/luci-app-podkop/htdocs/luci-static/resources/view/podkop/main.js b/luci-app-podkop/htdocs/luci-static/resources/view/podkop/main.js index 8f3a45c..e869094 100644 --- a/luci-app-podkop/htdocs/luci-static/resources/view/podkop/main.js +++ b/luci-app-podkop/htdocs/luci-static/resources/view/podkop/main.js @@ -985,6 +985,24 @@ async function getDashboardSections() { }; } } + if (section.mode === "vpn") { + const outbound = proxies.find( + (proxy) => proxy.code === `${section[".name"]}-out` + ); + return { + code: section[".name"], + displayName: section[".name"], + outbounds: [ + { + code: outbound?.code || section[".name"], + displayName: section.interface || outbound?.value?.name || "", + latency: outbound?.value?.history?.[0]?.delay || 0, + type: outbound?.value?.type || "", + selected: true + } + ] + }; + } return { code: section[".name"], displayName: section[".name"], From 31b09cc3d278eb4a1be7e4b62bdcc379cd0cae94 Mon Sep 17 00:00:00 2001 From: Andrey Petelin Date: Mon, 6 Oct 2025 15:40:21 +0500 Subject: [PATCH 37/49] feat: conditionally include external_ui in clash_api config if external_ui path is provided --- podkop/files/usr/lib/sing_box_config_manager.sh | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/podkop/files/usr/lib/sing_box_config_manager.sh b/podkop/files/usr/lib/sing_box_config_manager.sh index 33f8703..ce66424 100644 --- a/podkop/files/usr/lib/sing_box_config_manager.sh +++ b/podkop/files/usr/lib/sing_box_config_manager.sh @@ -1335,8 +1335,8 @@ sing_box_cm_configure_cache_file() { # Configure the experimental clash_api section of a sing-box JSON configuration. # Arguments: # config: JSON configuration (string) -# external_controller: string, URL or path for the external controller -# external_ui: string, URL or path for the external UI +# external_controller: API listening address; Clash API will be disabled if empty +# external_ui: Optional path to static web resources to serve at http://{{external-controller}}/ui # Outputs: # Writes updated JSON configuration to stdout # Example: @@ -1352,8 +1352,8 @@ sing_box_cm_configure_clash_api() { --arg external_ui "$external_ui" \ '.experimental.clash_api = { external_controller: $external_controller, - external_ui: $external_ui - }' + } + + (if $external_ui != "" then { external_ui: $external_ui } else {} end)' } ####################################### From 5418187dd3d825ebd3554061059dd04757c1b2e3 Mon Sep 17 00:00:00 2001 From: Andrey Petelin Date: Mon, 6 Oct 2025 15:41:40 +0500 Subject: [PATCH 38/49] feat: enable Clash API with YACD or online mode in podkop configuration --- podkop/files/usr/bin/podkop | 11 ++++++----- podkop/files/usr/lib/constants.sh | 2 ++ 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/podkop/files/usr/bin/podkop b/podkop/files/usr/bin/podkop index 7682190..462e303 100755 --- a/podkop/files/usr/bin/podkop +++ b/podkop/files/usr/bin/podkop @@ -1123,15 +1123,16 @@ sing_box_configure_experimental() { config_get cache_file "main" "cache_path" "/tmp/sing-box/cache.db" config=$(sing_box_cm_configure_cache_file "$config" true "$cache_file" true) - local yacd_enabled + local yacd_enabled external_controller_ui config_get_bool yacd_enabled "main" "yacd" 0 + log "Configuring Clash API" if [ "$yacd_enabled" -eq 1 ]; then - log "Configuring Clash API (yacd)" - local external_controller="0.0.0.0:9090" + log "YACD is enabled, enabling Clash API with downloadable YACD" "debug" local external_controller_ui="ui" - config=$(sing_box_cm_configure_clash_api "$config" "$external_controller" "$external_controller_ui") + config=$(sing_box_cm_configure_clash_api "$config" "$SB_CLASH_API_CONTROLLER" "$external_controller_ui") else - log "Clash API (yacd) is disabled, skipping configuration." + log "YACD is disabled, enabling Clash API in online mode" "debug" + config=$(sing_box_cm_configure_clash_api "$config" "$SB_CLASH_API_CONTROLLER") fi } diff --git a/podkop/files/usr/lib/constants.sh b/podkop/files/usr/lib/constants.sh index 3710e6d..4745434 100644 --- a/podkop/files/usr/lib/constants.sh +++ b/podkop/files/usr/lib/constants.sh @@ -48,6 +48,8 @@ SB_DIRECT_OUTBOUND_TAG="direct-out" SB_MAIN_OUTBOUND_TAG="main-out" # Route SB_REJECT_RULE_TAG="reject-rule-tag" +# Experimental +SB_CLASH_API_CONTROLLER="0.0.0.0:9090" ## Lists GITHUB_RAW_URL="https://raw.githubusercontent.com/itdoginfo/allow-domains/main" From 6117b0ef9b513a6dce6aa3200e73edae69fd08fa Mon Sep 17 00:00:00 2001 From: divocat Date: Mon, 6 Oct 2025 19:56:11 +0300 Subject: [PATCH 39/49] feat: colorize status ans latency --- .../src/dashboard/initDashboardController.ts | 21 +++- .../dashboard/renderer/renderOutboundGroup.ts | 19 +++- .../src/dashboard/renderer/renderWidget.ts | 28 +++++- fe-app-podkop/src/store.ts | 6 +- fe-app-podkop/src/styles.ts | 38 +++++++- .../luci-static/resources/view/podkop/main.js | 96 +++++++++++++++++-- 6 files changed, 186 insertions(+), 22 deletions(-) diff --git a/fe-app-podkop/src/dashboard/initDashboardController.ts b/fe-app-podkop/src/dashboard/initDashboardController.ts index a6e3808..a85a130 100644 --- a/fe-app-podkop/src/dashboard/initDashboardController.ts +++ b/fe-app-podkop/src/dashboard/initDashboardController.ts @@ -26,8 +26,8 @@ async function fetchServicesInfo() { console.log('singbox', singbox); store.set({ services: { - singbox: singbox.running ? '✔ Enabled' : singbox.status, - podkop: podkop.status ? '✔ Enabled' : podkop.status, + singbox: singbox.running, + podkop: podkop.enabled, }, }); } @@ -132,9 +132,22 @@ async function renderServiceInfoWidget() { items: [ { key: 'Podkop', - value: String(services.podkop), + value: services.podkop ? '✔ Enabled' : '✘ Disabled', + attributes: { + class: services.podkop + ? 'pdk_dashboard-page__widgets-section__item__row--success' + : 'pdk_dashboard-page__widgets-section__item__row--error', + }, + }, + { + key: 'Sing-box', + value: services.singbox ? '✔ Running' : '✘ Stopped', + attributes: { + class: services.singbox + ? 'pdk_dashboard-page__widgets-section__item__row--success' + : 'pdk_dashboard-page__widgets-section__item__row--error', + }, }, - { key: 'Sing-box', value: String(services.singbox) }, ], }); diff --git a/fe-app-podkop/src/dashboard/renderer/renderOutboundGroup.ts b/fe-app-podkop/src/dashboard/renderer/renderOutboundGroup.ts index 865dc58..0b8ad3f 100644 --- a/fe-app-podkop/src/dashboard/renderer/renderOutboundGroup.ts +++ b/fe-app-podkop/src/dashboard/renderer/renderOutboundGroup.ts @@ -5,6 +5,23 @@ export function renderOutboundGroup({ displayName, }: Podkop.OutboundGroup) { function renderOutbound(outbound: Podkop.Outbound) { + function getLatencyClass() { + + if (!outbound.latency) { + return 'pdk_dashboard-page__outbound-grid__item__latency--empty'; + } + + if (outbound.latency < 200) { + return 'pdk_dashboard-page__outbound-grid__item__latency--green'; + } + + if (outbound.latency < 400) { + return 'pdk_dashboard-page__outbound-grid__item__latency--yellow'; + } + + return 'pdk_dashboard-page__outbound-grid__item__latency--red'; + } + return E( 'div', { @@ -20,7 +37,7 @@ export function renderOutboundGroup({ ), E( 'div', - { class: 'pdk_dashboard-page__outbound-grid__item__latency' }, + { class: getLatencyClass() }, outbound.latency ? `${outbound.latency}ms` : 'N/A', ), ]), diff --git a/fe-app-podkop/src/dashboard/renderer/renderWidget.ts b/fe-app-podkop/src/dashboard/renderer/renderWidget.ts index 850e263..5575cc3 100644 --- a/fe-app-podkop/src/dashboard/renderer/renderWidget.ts +++ b/fe-app-podkop/src/dashboard/renderer/renderWidget.ts @@ -3,14 +3,38 @@ interface IRenderWidgetParams { items: Array<{ key: string; value: string; + attributes?: { + class?: string; + }; }>; } export function renderDashboardWidget({ title, items }: IRenderWidgetParams) { return E('div', { class: 'pdk_dashboard-page__widgets-section__item' }, [ - E('b', {}, title), + E( + 'b', + { class: 'pdk_dashboard-page__widgets-section__item__title' }, + title, + ), ...items.map((item) => - E('div', {}, [E('span', {}, `${item.key}: `), E('span', {}, item.value)]), + E( + 'div', + { + class: `pdk_dashboard-page__widgets-section__item__row ${item?.attributes?.class || ''}`, + }, + [ + E( + 'span', + { class: 'pdk_dashboard-page__widgets-section__item__row__key' }, + `${item.key}: `, + ), + E( + 'span', + { class: 'pdk_dashboard-page__widgets-section__item__row__value' }, + item.value, + ), + ], + ), ), ]); } diff --git a/fe-app-podkop/src/store.ts b/fe-app-podkop/src/store.ts index 7770631..b920e4b 100644 --- a/fe-app-podkop/src/store.ts +++ b/fe-app-podkop/src/store.ts @@ -70,13 +70,13 @@ export const store = new Store<{ uploadTotal: number; }; services: { - singbox: string; - podkop: string; + singbox: number; + podkop: number; }; }>({ sections: [], traffic: { up: 0, down: 0 }, memory: { inuse: 0, oslimit: 0 }, connections: { connections: [], memory: 0, downloadTotal: 0, uploadTotal: 0 }, - services: { singbox: '', podkop: '' }, + services: { singbox: -1, podkop: -1 }, }); diff --git a/fe-app-podkop/src/styles.ts b/fe-app-podkop/src/styles.ts index 9ab5ed5..836baab 100644 --- a/fe-app-podkop/src/styles.ts +++ b/fe-app-podkop/src/styles.ts @@ -72,6 +72,30 @@ export const GlobalStyles = ` padding: 10px; } +.pdk_dashboard-page__widgets-section__item__title { + +} + +.pdk_dashboard-page__widgets-section__item__row { + +} + +.pdk_dashboard-page__widgets-section__item__row--success .pdk_dashboard-page__widgets-section__item__row__value { + color: var(--success-color-medium); +} + +.pdk_dashboard-page__widgets-section__item__row--error .pdk_dashboard-page__widgets-section__item__row__value { + color: var(--error-color-medium); +} + +.pdk_dashboard-page__widgets-section__item__row__key { + +} + +.pdk_dashboard-page__widgets-section__item__row__value { + +} + .pdk_dashboard-page__outbound-section { margin-top: 10px; border: 2px var(--background-color-low) solid; @@ -123,11 +147,21 @@ export const GlobalStyles = ` } -.pdk_dashboard-page__outbound-grid__item__latency { - +.pdk_dashboard-page__outbound-grid__item__latency--empty { + color: var(--primary-color-low); } +.pdk_dashboard-page__outbound-grid__item__latency--green { + color: var(--success-color-medium); +} +.pdk_dashboard-page__outbound-grid__item__latency--yellow { + color: var(--warn-color-medium); +} + +.pdk_dashboard-page__outbound-grid__item__latency--red { + color: var(--error-color-medium); +} /* Skeleton styles*/ .skeleton { diff --git a/luci-app-podkop/htdocs/luci-static/resources/view/podkop/main.js b/luci-app-podkop/htdocs/luci-static/resources/view/podkop/main.js index e869094..3fc7fc3 100644 --- a/luci-app-podkop/htdocs/luci-static/resources/view/podkop/main.js +++ b/luci-app-podkop/htdocs/luci-static/resources/view/podkop/main.js @@ -420,6 +420,30 @@ var GlobalStyles = ` padding: 10px; } +.pdk_dashboard-page__widgets-section__item__title { + +} + +.pdk_dashboard-page__widgets-section__item__row { + +} + +.pdk_dashboard-page__widgets-section__item__row--success .pdk_dashboard-page__widgets-section__item__row__value { + color: var(--success-color-medium); +} + +.pdk_dashboard-page__widgets-section__item__row--error .pdk_dashboard-page__widgets-section__item__row__value { + color: var(--error-color-medium); +} + +.pdk_dashboard-page__widgets-section__item__row__key { + +} + +.pdk_dashboard-page__widgets-section__item__row__value { + +} + .pdk_dashboard-page__outbound-section { margin-top: 10px; border: 2px var(--background-color-low) solid; @@ -471,11 +495,21 @@ var GlobalStyles = ` } -.pdk_dashboard-page__outbound-grid__item__latency { - +.pdk_dashboard-page__outbound-grid__item__latency--empty { + color: var(--primary-color-low); } +.pdk_dashboard-page__outbound-grid__item__latency--green { + color: var(--success-color-medium); +} +.pdk_dashboard-page__outbound-grid__item__latency--yellow { + color: var(--warn-color-medium); +} + +.pdk_dashboard-page__outbound-grid__item__latency--red { + color: var(--error-color-medium); +} /* Skeleton styles*/ .skeleton { @@ -1043,6 +1077,18 @@ function renderOutboundGroup({ displayName }) { function renderOutbound(outbound) { + function getLatencyClass() { + if (!outbound.latency) { + return "pdk_dashboard-page__outbound-grid__item__latency--empty"; + } + if (outbound.latency < 200) { + return "pdk_dashboard-page__outbound-grid__item__latency--green"; + } + if (outbound.latency < 400) { + return "pdk_dashboard-page__outbound-grid__item__latency--yellow"; + } + return "pdk_dashboard-page__outbound-grid__item__latency--red"; + } return E( "div", { @@ -1058,7 +1104,7 @@ function renderOutboundGroup({ ), E( "div", - { class: "pdk_dashboard-page__outbound-grid__item__latency" }, + { class: getLatencyClass() }, outbound.latency ? `${outbound.latency}ms` : "N/A" ) ]) @@ -1132,7 +1178,7 @@ var store = new Store({ traffic: { up: 0, down: 0 }, memory: { inuse: 0, oslimit: 0 }, connections: { connections: [], memory: 0, downloadTotal: 0, uploadTotal: 0 }, - services: { singbox: "", podkop: "" } + services: { singbox: -1, podkop: -1 } }); // src/socket.ts @@ -1216,9 +1262,30 @@ var socket = SocketManager.getInstance(); // src/dashboard/renderer/renderWidget.ts function renderDashboardWidget({ title, items }) { return E("div", { class: "pdk_dashboard-page__widgets-section__item" }, [ - E("b", {}, title), + E( + "b", + { class: "pdk_dashboard-page__widgets-section__item__title" }, + title + ), ...items.map( - (item) => E("div", {}, [E("span", {}, `${item.key}: `), E("span", {}, item.value)]) + (item) => E( + "div", + { + class: `pdk_dashboard-page__widgets-section__item__row ${item?.attributes?.class || ""}` + }, + [ + E( + "span", + { class: "pdk_dashboard-page__widgets-section__item__row__key" }, + `${item.key}: ` + ), + E( + "span", + { class: "pdk_dashboard-page__widgets-section__item__row__value" }, + item.value + ) + ] + ) ) ]); } @@ -1247,8 +1314,8 @@ async function fetchServicesInfo() { console.log("singbox", singbox); store.set({ services: { - singbox: singbox.running ? "\u2714 Enabled" : singbox.status, - podkop: podkop.status ? "\u2714 Enabled" : podkop.status + singbox: singbox.running, + podkop: podkop.enabled } }); } @@ -1337,9 +1404,18 @@ async function renderServiceInfoWidget() { items: [ { key: "Podkop", - value: String(services.podkop) + value: services.podkop ? "\u2714 Enabled" : "\u2718 Disabled", + attributes: { + class: services.podkop ? "pdk_dashboard-page__widgets-section__item__row--success" : "pdk_dashboard-page__widgets-section__item__row--error" + } }, - { key: "Sing-box", value: String(services.singbox) } + { + key: "Sing-box", + value: services.singbox ? "\u2714 Running" : "\u2718 Stopped", + attributes: { + class: services.singbox ? "pdk_dashboard-page__widgets-section__item__row--success" : "pdk_dashboard-page__widgets-section__item__row--error" + } + } ] }); container.replaceChildren(renderedWidget); From caf82b096fef333a9eca963a38d507cc1d3ee416 Mon Sep 17 00:00:00 2001 From: divocat Date: Mon, 6 Oct 2025 20:58:55 +0300 Subject: [PATCH 40/49] feat: add test latency & select tag functionality --- fe-app-podkop/src/clash/methods/index.ts | 1 + .../src/clash/methods/triggerLatencyTest.ts | 35 +++++++ .../src/dashboard/initDashboardController.ts | 36 ++++++- .../dashboard/renderer/renderOutboundGroup.ts | 35 +++++-- .../podkop/methods/getDashboardSections.ts | 13 ++- fe-app-podkop/src/podkop/types.ts | 1 + fe-app-podkop/src/styles.ts | 8 +- .../luci-static/resources/view/podkop/main.js | 96 ++++++++++++++++--- 8 files changed, 197 insertions(+), 28 deletions(-) create mode 100644 fe-app-podkop/src/clash/methods/triggerLatencyTest.ts diff --git a/fe-app-podkop/src/clash/methods/index.ts b/fe-app-podkop/src/clash/methods/index.ts index 77f254b..1feccdb 100644 --- a/fe-app-podkop/src/clash/methods/index.ts +++ b/fe-app-podkop/src/clash/methods/index.ts @@ -4,3 +4,4 @@ export * from './getGroupDelay'; export * from './getProxies'; export * from './getVersion'; export * from './triggerProxySelector'; +export * from './triggerLatencyTest'; diff --git a/fe-app-podkop/src/clash/methods/triggerLatencyTest.ts b/fe-app-podkop/src/clash/methods/triggerLatencyTest.ts new file mode 100644 index 0000000..94bf335 --- /dev/null +++ b/fe-app-podkop/src/clash/methods/triggerLatencyTest.ts @@ -0,0 +1,35 @@ +import { IBaseApiResponse } from '../types'; +import { createBaseApiRequest } from './createBaseApiRequest'; +import { getClashApiUrl } from '../../helpers'; + +export async function triggerLatencyGroupTest( + tag: string, + timeout: number = 2000, + url: string = 'https://www.gstatic.com/generate_204', +): Promise> { + return createBaseApiRequest(() => + fetch( + `${getClashApiUrl()}/group/${tag}/delay?url=${encodeURIComponent(url)}&timeout=${timeout}`, + { + method: 'GET', + headers: { 'Content-Type': 'application/json' }, + }, + ), + ); +} + +export async function triggerLatencyProxyTest( + tag: string, + timeout: number = 2000, + url: string = 'https://www.gstatic.com/generate_204', +): Promise> { + return createBaseApiRequest(() => + fetch( + `${getClashApiUrl()}/proxies/${tag}/delay?url=${encodeURIComponent(url)}&timeout=${timeout}`, + { + method: 'GET', + headers: { 'Content-Type': 'application/json' }, + }, + ), + ); +} diff --git a/fe-app-podkop/src/dashboard/initDashboardController.ts b/fe-app-podkop/src/dashboard/initDashboardController.ts index a85a130..aa457df 100644 --- a/fe-app-podkop/src/dashboard/initDashboardController.ts +++ b/fe-app-podkop/src/dashboard/initDashboardController.ts @@ -9,6 +9,11 @@ import { store } from '../store'; import { socket } from '../socket'; import { renderDashboardWidget } from './renderer/renderWidget'; import { prettyBytes } from '../helpers/prettyBytes'; +import { + triggerLatencyGroupTest, + triggerLatencyProxyTest, + triggerProxySelector, +} from '../clash'; // Fetchers @@ -63,11 +68,40 @@ async function connectToClashSockets() { // Renderer +async function handleChooseOutbound(selector: string, tag: string) { + await triggerProxySelector(selector, tag); + await fetchDashboardSections(); +} + +async function handleTestGroupLatency(tag: string) { + await triggerLatencyGroupTest(tag); + await fetchDashboardSections(); +} + +async function handleTestProxyLatency(tag: string) { + await triggerLatencyProxyTest(tag); + await fetchDashboardSections(); +} + async function renderDashboardSections() { const sections = store.get().sections; console.log('render dashboard sections group'); const container = document.getElementById('dashboard-sections-grid'); - const renderedOutboundGroups = sections.map(renderOutboundGroup); + const renderedOutboundGroups = sections.map((section) => + renderOutboundGroup({ + section, + onTestLatency: (tag) => { + if (section.withTagSelect) { + return handleTestGroupLatency(tag); + } + + return handleTestProxyLatency(tag); + }, + onChooseOutbound: (selector, tag) => { + handleChooseOutbound(selector, tag); + }, + }), + ); container!.replaceChildren(...renderedOutboundGroups); } diff --git a/fe-app-podkop/src/dashboard/renderer/renderOutboundGroup.ts b/fe-app-podkop/src/dashboard/renderer/renderOutboundGroup.ts index 0b8ad3f..b982dd9 100644 --- a/fe-app-podkop/src/dashboard/renderer/renderOutboundGroup.ts +++ b/fe-app-podkop/src/dashboard/renderer/renderOutboundGroup.ts @@ -1,12 +1,28 @@ import { Podkop } from '../../podkop/types'; +interface IRenderOutboundGroupProps { + section: Podkop.OutboundGroup; + onTestLatency: (tag: string) => void; + onChooseOutbound: (selector: string, tag: string) => void; +} + export function renderOutboundGroup({ - outbounds, - displayName, -}: Podkop.OutboundGroup) { + section, + onTestLatency, + onChooseOutbound, +}: IRenderOutboundGroupProps) { + function testLatency() { + if (section.withTagSelect) { + return onTestLatency(section.code); + } + + if (section.outbounds.length) { + return onTestLatency(section.outbounds[0].code); + } + } + function renderOutbound(outbound: Podkop.Outbound) { function getLatencyClass() { - if (!outbound.latency) { return 'pdk_dashboard-page__outbound-grid__item__latency--empty'; } @@ -25,7 +41,10 @@ export function renderOutboundGroup({ return E( 'div', { - class: `pdk_dashboard-page__outbound-grid__item ${outbound.selected ? 'pdk_dashboard-page__outbound-grid__item--active' : ''}`, + class: `pdk_dashboard-page__outbound-grid__item ${outbound.selected ? 'pdk_dashboard-page__outbound-grid__item--active' : ''} ${section.withTagSelect ? 'pdk_dashboard-page__outbound-grid__item--selectable' : ''}`, + click: () => + section.withTagSelect && + onChooseOutbound(section.code, outbound.code), }, [ E('b', {}, outbound.displayName), @@ -53,14 +72,14 @@ export function renderOutboundGroup({ { class: 'pdk_dashboard-page__outbound-section__title-section__title', }, - displayName, + section.displayName, ), - E('button', { class: 'btn' }, 'Test latency'), + E('button', { class: 'btn', click: () => testLatency() }, 'Test latency'), ]), E( 'div', { class: 'pdk_dashboard-page__outbound-grid' }, - outbounds.map((outbound) => renderOutbound(outbound)), + section.outbounds.map((outbound) => renderOutbound(outbound)), ), ]); } diff --git a/fe-app-podkop/src/podkop/methods/getDashboardSections.ts b/fe-app-podkop/src/podkop/methods/getDashboardSections.ts index aaa0c52..5f57512 100644 --- a/fe-app-podkop/src/podkop/methods/getDashboardSections.ts +++ b/fe-app-podkop/src/podkop/methods/getDashboardSections.ts @@ -28,7 +28,8 @@ export async function getDashboardSections(): Promise { ); return { - code: section['.name'], + withTagSelect: false, + code: outbound?.code || section['.name'], displayName: section['.name'], outbounds: [ { @@ -51,7 +52,8 @@ export async function getDashboardSections(): Promise { ); return { - code: section['.name'], + withTagSelect: false, + code: outbound?.code || section['.name'], displayName: section['.name'], outbounds: [ { @@ -90,7 +92,8 @@ export async function getDashboardSections(): Promise { })); return { - code: section['.name'], + withTagSelect: true, + code: selector?.code || section['.name'], displayName: section['.name'], outbounds: [ { @@ -112,7 +115,8 @@ export async function getDashboardSections(): Promise { ); return { - code: section['.name'], + withTagSelect: false, + code: outbound?.code || section['.name'], displayName: section['.name'], outbounds: [ { @@ -127,6 +131,7 @@ export async function getDashboardSections(): Promise { } return { + withTagSelect: false, code: section['.name'], displayName: section['.name'], outbounds: [], diff --git a/fe-app-podkop/src/podkop/types.ts b/fe-app-podkop/src/podkop/types.ts index c715b61..531f648 100644 --- a/fe-app-podkop/src/podkop/types.ts +++ b/fe-app-podkop/src/podkop/types.ts @@ -9,6 +9,7 @@ export namespace Podkop { } export interface OutboundGroup { + withTagSelect: boolean; code: string; displayName: string; outbounds: Outbound[]; diff --git a/fe-app-podkop/src/styles.ts b/fe-app-podkop/src/styles.ts index 836baab..5337c10 100644 --- a/fe-app-podkop/src/styles.ts +++ b/fe-app-podkop/src/styles.ts @@ -122,13 +122,17 @@ export const GlobalStyles = ` } .pdk_dashboard-page__outbound-grid__item { - cursor: pointer; border: 2px var(--background-color-low) solid; border-radius: 4px; padding: 10px; transition: border 0.2s ease; } -.pdk_dashboard-page__outbound-grid__item:hover { + +.pdk_dashboard-page__outbound-grid__item--selectable { + cursor: pointer; +} + +.pdk_dashboard-page__outbound-grid__item--selectable:hover { border-color: var(--primary-color-high); } diff --git a/luci-app-podkop/htdocs/luci-static/resources/view/podkop/main.js b/luci-app-podkop/htdocs/luci-static/resources/view/podkop/main.js index 3fc7fc3..197deb7 100644 --- a/luci-app-podkop/htdocs/luci-static/resources/view/podkop/main.js +++ b/luci-app-podkop/htdocs/luci-static/resources/view/podkop/main.js @@ -470,13 +470,17 @@ var GlobalStyles = ` } .pdk_dashboard-page__outbound-grid__item { - cursor: pointer; border: 2px var(--background-color-low) solid; border-radius: 4px; padding: 10px; transition: border 0.2s ease; } -.pdk_dashboard-page__outbound-grid__item:hover { + +.pdk_dashboard-page__outbound-grid__item--selectable { + cursor: pointer; +} + +.pdk_dashboard-page__outbound-grid__item--selectable:hover { border-color: var(--primary-color-high); } @@ -855,6 +859,30 @@ async function triggerProxySelector(selector, outbound) { ); } +// src/clash/methods/triggerLatencyTest.ts +async function triggerLatencyGroupTest(tag, timeout = 2e3, url = "https://www.gstatic.com/generate_204") { + return createBaseApiRequest( + () => fetch( + `${getClashApiUrl()}/group/${tag}/delay?url=${encodeURIComponent(url)}&timeout=${timeout}`, + { + method: "GET", + headers: { "Content-Type": "application/json" } + } + ) + ); +} +async function triggerLatencyProxyTest(tag, timeout = 2e3, url = "https://www.gstatic.com/generate_204") { + return createBaseApiRequest( + () => fetch( + `${getClashApiUrl()}/proxies/${tag}/delay?url=${encodeURIComponent(url)}&timeout=${timeout}`, + { + method: "GET", + headers: { "Content-Type": "application/json" } + } + ) + ); +} + // src/dashboard/renderDashboard.ts function renderDashboard() { return E( @@ -958,7 +986,8 @@ async function getDashboardSections() { (proxy) => proxy.code === `${section[".name"]}-out` ); return { - code: section[".name"], + withTagSelect: false, + code: outbound?.code || section[".name"], displayName: section[".name"], outbounds: [ { @@ -976,7 +1005,8 @@ async function getDashboardSections() { (proxy) => proxy.code === `${section[".name"]}-out` ); return { - code: section[".name"], + withTagSelect: false, + code: outbound?.code || section[".name"], displayName: section[".name"], outbounds: [ { @@ -1004,7 +1034,8 @@ async function getDashboardSections() { selected: selector?.value?.now === item?.code })); return { - code: section[".name"], + withTagSelect: true, + code: selector?.code || section[".name"], displayName: section[".name"], outbounds: [ { @@ -1024,7 +1055,8 @@ async function getDashboardSections() { (proxy) => proxy.code === `${section[".name"]}-out` ); return { - code: section[".name"], + withTagSelect: false, + code: outbound?.code || section[".name"], displayName: section[".name"], outbounds: [ { @@ -1038,6 +1070,7 @@ async function getDashboardSections() { }; } return { + withTagSelect: false, code: section[".name"], displayName: section[".name"], outbounds: [] @@ -1073,9 +1106,18 @@ async function getSingboxStatus() { // src/dashboard/renderer/renderOutboundGroup.ts function renderOutboundGroup({ - outbounds, - displayName + section, + onTestLatency, + onChooseOutbound }) { + function testLatency() { + if (section.withTagSelect) { + return onTestLatency(section.code); + } + if (section.outbounds.length) { + return onTestLatency(section.outbounds[0].code); + } + } function renderOutbound(outbound) { function getLatencyClass() { if (!outbound.latency) { @@ -1092,7 +1134,8 @@ function renderOutboundGroup({ return E( "div", { - class: `pdk_dashboard-page__outbound-grid__item ${outbound.selected ? "pdk_dashboard-page__outbound-grid__item--active" : ""}` + class: `pdk_dashboard-page__outbound-grid__item ${outbound.selected ? "pdk_dashboard-page__outbound-grid__item--active" : ""} ${section.withTagSelect ? "pdk_dashboard-page__outbound-grid__item--selectable" : ""}`, + click: () => section.withTagSelect && onChooseOutbound(section.code, outbound.code) }, [ E("b", {}, outbound.displayName), @@ -1119,14 +1162,14 @@ function renderOutboundGroup({ { class: "pdk_dashboard-page__outbound-section__title-section__title" }, - displayName + section.displayName ), - E("button", { class: "btn" }, "Test latency") + E("button", { class: "btn", click: () => testLatency() }, "Test latency") ]), E( "div", { class: "pdk_dashboard-page__outbound-grid" }, - outbounds.map((outbound) => renderOutbound(outbound)) + section.outbounds.map((outbound) => renderOutbound(outbound)) ) ]); } @@ -1343,11 +1386,36 @@ async function connectToClashSockets() { }); }); } +async function handleChooseOutbound(selector, tag) { + await triggerProxySelector(selector, tag); + await fetchDashboardSections(); +} +async function handleTestGroupLatency(tag) { + await triggerLatencyGroupTest(tag); + await fetchDashboardSections(); +} +async function handleTestProxyLatency(tag) { + await triggerLatencyProxyTest(tag); + await fetchDashboardSections(); +} async function renderDashboardSections() { const sections = store.get().sections; console.log("render dashboard sections group"); const container = document.getElementById("dashboard-sections-grid"); - const renderedOutboundGroups = sections.map(renderOutboundGroup); + const renderedOutboundGroups = sections.map( + (section) => renderOutboundGroup({ + section, + onTestLatency: (tag) => { + if (section.withTagSelect) { + return handleTestGroupLatency(tag); + } + return handleTestProxyLatency(tag); + }, + onChooseOutbound: (selector, tag) => { + handleChooseOutbound(selector, tag); + } + }) + ); container.replaceChildren(...renderedOutboundGroups); } async function renderTrafficWidget() { @@ -1480,6 +1548,8 @@ return baseclass.extend({ onMount, parseValueList, renderDashboard, + triggerLatencyGroupTest, + triggerLatencyProxyTest, triggerProxySelector, validateDNS, validateDomain, From 1e4cda9400d5f99c7cacf02660faf7aeec721c2b Mon Sep 17 00:00:00 2001 From: divocat Date: Mon, 6 Oct 2025 21:15:01 +0300 Subject: [PATCH 41/49] feat: add loaders to test latency buttons --- .../src/dashboard/initDashboardController.ts | 16 +++++++++++++++- .../dashboard/renderer/renderOutboundGroup.ts | 2 +- .../luci-static/resources/view/podkop/main.js | 12 +++++++++++- 3 files changed, 27 insertions(+), 3 deletions(-) diff --git a/fe-app-podkop/src/dashboard/initDashboardController.ts b/fe-app-podkop/src/dashboard/initDashboardController.ts index aa457df..ecba535 100644 --- a/fe-app-podkop/src/dashboard/initDashboardController.ts +++ b/fe-app-podkop/src/dashboard/initDashboardController.ts @@ -66,7 +66,7 @@ async function connectToClashSockets() { }); } -// Renderer +// Handlers async function handleChooseOutbound(selector: string, tag: string) { await triggerProxySelector(selector, tag); @@ -83,6 +83,18 @@ async function handleTestProxyLatency(tag: string) { await fetchDashboardSections(); } +function replaceTestLatencyButtonsWithSkeleton () { + document.querySelectorAll('.dashboard-sections-grid-item-test-latency').forEach(el => { + const newDiv = document.createElement('div'); + newDiv.className = 'skeleton'; + newDiv.style.width = '99px'; + newDiv.style.height = '28px'; + el.replaceWith(newDiv); + }); +} + +// Renderer + async function renderDashboardSections() { const sections = store.get().sections; console.log('render dashboard sections group'); @@ -91,6 +103,8 @@ async function renderDashboardSections() { renderOutboundGroup({ section, onTestLatency: (tag) => { + replaceTestLatencyButtonsWithSkeleton(); + if (section.withTagSelect) { return handleTestGroupLatency(tag); } diff --git a/fe-app-podkop/src/dashboard/renderer/renderOutboundGroup.ts b/fe-app-podkop/src/dashboard/renderer/renderOutboundGroup.ts index b982dd9..1ac5ebe 100644 --- a/fe-app-podkop/src/dashboard/renderer/renderOutboundGroup.ts +++ b/fe-app-podkop/src/dashboard/renderer/renderOutboundGroup.ts @@ -74,7 +74,7 @@ export function renderOutboundGroup({ }, section.displayName, ), - E('button', { class: 'btn', click: () => testLatency() }, 'Test latency'), + E('button', { class: 'btn dashboard-sections-grid-item-test-latency', click: () => testLatency() }, 'Test latency'), ]), E( 'div', diff --git a/luci-app-podkop/htdocs/luci-static/resources/view/podkop/main.js b/luci-app-podkop/htdocs/luci-static/resources/view/podkop/main.js index 197deb7..d005a9a 100644 --- a/luci-app-podkop/htdocs/luci-static/resources/view/podkop/main.js +++ b/luci-app-podkop/htdocs/luci-static/resources/view/podkop/main.js @@ -1164,7 +1164,7 @@ function renderOutboundGroup({ }, section.displayName ), - E("button", { class: "btn", click: () => testLatency() }, "Test latency") + E("button", { class: "btn dashboard-sections-grid-item-test-latency", click: () => testLatency() }, "Test latency") ]), E( "div", @@ -1398,6 +1398,15 @@ async function handleTestProxyLatency(tag) { await triggerLatencyProxyTest(tag); await fetchDashboardSections(); } +function replaceTestLatencyButtonsWithSkeleton() { + document.querySelectorAll(".dashboard-sections-grid-item-test-latency").forEach((el) => { + const newDiv = document.createElement("div"); + newDiv.className = "skeleton"; + newDiv.style.width = "99px"; + newDiv.style.height = "28px"; + el.replaceWith(newDiv); + }); +} async function renderDashboardSections() { const sections = store.get().sections; console.log("render dashboard sections group"); @@ -1406,6 +1415,7 @@ async function renderDashboardSections() { (section) => renderOutboundGroup({ section, onTestLatency: (tag) => { + replaceTestLatencyButtonsWithSkeleton(); if (section.withTagSelect) { return handleTestGroupLatency(tag); } From 7cb43ffb657a6eaffbf29bf1891365bb8388c363 Mon Sep 17 00:00:00 2001 From: divocat Date: Tue, 7 Oct 2025 00:36:36 +0300 Subject: [PATCH 42/49] feat: implement dashboard tab --- fe-app-podkop/src/main.ts | 2 +- fe-app-podkop/src/podkop/index.ts | 3 + .../podkop/methods/getDashboardSections.ts | 25 +- .../src/podkop/services/core.service.ts | 13 + fe-app-podkop/src/podkop/services/index.ts | 2 + .../src/podkop/services/tab.service.ts | 92 +++ .../src/{ => podkop/tabs}/dashboard/index.ts | 0 .../dashboard/initDashboardController.ts | 119 ++-- .../tabs}/dashboard/renderDashboard.ts | 12 - .../renderer/renderEmptyOutboundGroup.ts | 10 + .../dashboard/renderer/renderOutboundGroup.ts | 11 +- .../tabs}/dashboard/renderer/renderWidget.ts | 0 fe-app-podkop/src/podkop/tabs/index.ts | 1 + fe-app-podkop/src/socket.ts | 1 - fe-app-podkop/src/store.ts | 111 +++- fe-app-podkop/src/styles.ts | 6 + .../resources/view/podkop/dashboardTab.js | 6 +- .../luci-static/resources/view/podkop/main.js | 556 ++++++++++++------ .../resources/view/podkop/podkop.js | 14 +- 19 files changed, 697 insertions(+), 287 deletions(-) create mode 100644 fe-app-podkop/src/podkop/index.ts create mode 100644 fe-app-podkop/src/podkop/services/core.service.ts create mode 100644 fe-app-podkop/src/podkop/services/index.ts create mode 100644 fe-app-podkop/src/podkop/services/tab.service.ts rename fe-app-podkop/src/{ => podkop/tabs}/dashboard/index.ts (100%) rename fe-app-podkop/src/{ => podkop/tabs}/dashboard/initDashboardController.ts (73%) rename fe-app-podkop/src/{ => podkop/tabs}/dashboard/renderDashboard.ts (82%) create mode 100644 fe-app-podkop/src/podkop/tabs/dashboard/renderer/renderEmptyOutboundGroup.ts rename fe-app-podkop/src/{ => podkop/tabs}/dashboard/renderer/renderOutboundGroup.ts (91%) rename fe-app-podkop/src/{ => podkop/tabs}/dashboard/renderer/renderWidget.ts (100%) create mode 100644 fe-app-podkop/src/podkop/tabs/index.ts diff --git a/fe-app-podkop/src/main.ts b/fe-app-podkop/src/main.ts index f130254..7d7e2b7 100644 --- a/fe-app-podkop/src/main.ts +++ b/fe-app-podkop/src/main.ts @@ -6,5 +6,5 @@ export * from './validators'; export * from './helpers'; export * from './clash'; -export * from './dashboard'; +export * from './podkop'; export * from './constants'; diff --git a/fe-app-podkop/src/podkop/index.ts b/fe-app-podkop/src/podkop/index.ts new file mode 100644 index 0000000..59309df --- /dev/null +++ b/fe-app-podkop/src/podkop/index.ts @@ -0,0 +1,3 @@ +export * from './methods'; +export * from './services'; +export * from './tabs'; diff --git a/fe-app-podkop/src/podkop/methods/getDashboardSections.ts b/fe-app-podkop/src/podkop/methods/getDashboardSections.ts index 5f57512..931a4f5 100644 --- a/fe-app-podkop/src/podkop/methods/getDashboardSections.ts +++ b/fe-app-podkop/src/podkop/methods/getDashboardSections.ts @@ -3,22 +3,30 @@ import { getConfigSections } from './getConfigSections'; import { getClashProxies } from '../../clash'; import { getProxyUrlName } from '../../helpers'; -export async function getDashboardSections(): Promise { +interface IGetDashboardSectionsResponse { + success: boolean; + data: Podkop.OutboundGroup[]; +} + +export async function getDashboardSections(): Promise { const configSections = await getConfigSections(); const clashProxies = await getClashProxies(); - const clashProxiesData = clashProxies.success - ? clashProxies.data - : { proxies: [] }; + if (!clashProxies.success) { + return { + success: false, + data: [], + }; + } - const proxies = Object.entries(clashProxiesData.proxies).map( + const proxies = Object.entries(clashProxies.data.proxies).map( ([key, value]) => ({ code: key, value, }), ); - return configSections + const data = configSections .filter((section) => section.mode !== 'block') .map((section) => { if (section.mode === 'proxy') { @@ -137,4 +145,9 @@ export async function getDashboardSections(): Promise { outbounds: [], }; }); + + return { + success: true, + data, + }; } diff --git a/fe-app-podkop/src/podkop/services/core.service.ts b/fe-app-podkop/src/podkop/services/core.service.ts new file mode 100644 index 0000000..4b7d827 --- /dev/null +++ b/fe-app-podkop/src/podkop/services/core.service.ts @@ -0,0 +1,13 @@ +import { TabServiceInstance } from './tab.service'; +import { store } from '../../store'; + +export function coreService() { + TabServiceInstance.onChange((activeId, tabs) => { + store.set({ + tabService: { + current: activeId || '', + all: tabs.map((tab) => tab.id), + }, + }); + }); +} diff --git a/fe-app-podkop/src/podkop/services/index.ts b/fe-app-podkop/src/podkop/services/index.ts new file mode 100644 index 0000000..4b776d2 --- /dev/null +++ b/fe-app-podkop/src/podkop/services/index.ts @@ -0,0 +1,2 @@ +export * from './tab.service'; +export * from './core.service'; diff --git a/fe-app-podkop/src/podkop/services/tab.service.ts b/fe-app-podkop/src/podkop/services/tab.service.ts new file mode 100644 index 0000000..88614ff --- /dev/null +++ b/fe-app-podkop/src/podkop/services/tab.service.ts @@ -0,0 +1,92 @@ +type TabInfo = { + el: HTMLElement; + id: string; + active: boolean; +}; + +type TabChangeCallback = (activeId: string | null, allTabs: TabInfo[]) => void; + +export class TabService { + private static instance: TabService; + private observer: MutationObserver | null = null; + private callback?: TabChangeCallback; + private lastActiveId: string | null = null; + + private constructor() { + this.init(); + } + + public static getInstance(): TabService { + if (!TabService.instance) { + TabService.instance = new TabService(); + } + return TabService.instance; + } + + private init() { + this.observer = new MutationObserver(() => this.handleMutations()); + this.observer.observe(document.body, { + subtree: true, + childList: true, + attributes: true, + attributeFilter: ['class'], + }); + + // initial check + this.notify(); + } + + private handleMutations() { + this.notify(); + } + + private getTabsInfo(): TabInfo[] { + const tabs = Array.from( + document.querySelectorAll('.cbi-tab, .cbi-tab-disabled'), + ); + return tabs.map((el) => ({ + el, + id: el.dataset.tab || '', + active: + el.classList.contains('cbi-tab') && + !el.classList.contains('cbi-tab-disabled'), + })); + } + + private getActiveTabId(): string | null { + const active = document.querySelector( + '.cbi-tab:not(.cbi-tab-disabled)', + ); + return active?.dataset.tab || null; + } + + private notify() { + const tabs = this.getTabsInfo(); + const activeId = this.getActiveTabId(); + + if (activeId !== this.lastActiveId) { + this.lastActiveId = activeId; + this.callback?.(activeId, tabs); + } + } + + public onChange(callback: TabChangeCallback) { + this.callback = callback; + this.notify(); + } + + public getAllTabs(): TabInfo[] { + return this.getTabsInfo(); + } + + public getActiveTab(): string | null { + return this.getActiveTabId(); + } + + public disconnect() { + this.observer?.disconnect(); + this.observer = null; + } +} + +export const TabServiceInstance = TabService.getInstance(); diff --git a/fe-app-podkop/src/dashboard/index.ts b/fe-app-podkop/src/podkop/tabs/dashboard/index.ts similarity index 100% rename from fe-app-podkop/src/dashboard/index.ts rename to fe-app-podkop/src/podkop/tabs/dashboard/index.ts diff --git a/fe-app-podkop/src/dashboard/initDashboardController.ts b/fe-app-podkop/src/podkop/tabs/dashboard/initDashboardController.ts similarity index 73% rename from fe-app-podkop/src/dashboard/initDashboardController.ts rename to fe-app-podkop/src/podkop/tabs/dashboard/initDashboardController.ts index ecba535..17f08ad 100644 --- a/fe-app-podkop/src/dashboard/initDashboardController.ts +++ b/fe-app-podkop/src/podkop/tabs/dashboard/initDashboardController.ts @@ -2,33 +2,40 @@ import { getDashboardSections, getPodkopStatus, getSingboxStatus, -} from '../podkop/methods'; +} from '../../methods'; import { renderOutboundGroup } from './renderer/renderOutboundGroup'; -import { getClashWsUrl, onMount } from '../helpers'; -import { store } from '../store'; -import { socket } from '../socket'; +import { getClashWsUrl, onMount } from '../../../helpers'; import { renderDashboardWidget } from './renderer/renderWidget'; -import { prettyBytes } from '../helpers/prettyBytes'; import { triggerLatencyGroupTest, triggerLatencyProxyTest, triggerProxySelector, -} from '../clash'; +} from '../../../clash'; +import { store, StoreType } from '../../../store'; +import { socket } from '../../../socket'; +import { prettyBytes } from '../../../helpers/prettyBytes'; +import { renderEmptyOutboundGroup } from './renderer/renderEmptyOutboundGroup'; // Fetchers async function fetchDashboardSections() { - const sections = await getDashboardSections(); + store.set({ + dashboardSections: { + ...store.get().dashboardSections, + failed: false, + loading: true, + }, + }); - store.set({ sections }); + const { data, success } = await getDashboardSections(); + + store.set({ dashboardSections: { loading: false, data, failed: !success } }); } async function fetchServicesInfo() { const podkop = await getPodkopStatus(); const singbox = await getSingboxStatus(); - console.log('podkop', podkop); - console.log('singbox', singbox); store.set({ services: { singbox: singbox.running, @@ -83,23 +90,31 @@ async function handleTestProxyLatency(tag: string) { await fetchDashboardSections(); } -function replaceTestLatencyButtonsWithSkeleton () { - document.querySelectorAll('.dashboard-sections-grid-item-test-latency').forEach(el => { - const newDiv = document.createElement('div'); - newDiv.className = 'skeleton'; - newDiv.style.width = '99px'; - newDiv.style.height = '28px'; - el.replaceWith(newDiv); - }); +function replaceTestLatencyButtonsWithSkeleton() { + document + .querySelectorAll('.dashboard-sections-grid-item-test-latency') + .forEach((el) => { + const newDiv = document.createElement('div'); + newDiv.className = 'skeleton'; + newDiv.style.width = '99px'; + newDiv.style.height = '28px'; + el.replaceWith(newDiv); + }); } // Renderer async function renderDashboardSections() { - const sections = store.get().sections; - console.log('render dashboard sections group'); + const dashboardSections = store.get().dashboardSections; const container = document.getElementById('dashboard-sections-grid'); - const renderedOutboundGroups = sections.map((section) => + + if (dashboardSections.failed) { + const rendered = renderEmptyOutboundGroup(); + + return container!.replaceChildren(rendered); + } + + const renderedOutboundGroups = dashboardSections.data.map((section) => renderOutboundGroup({ section, onTestLatency: (tag) => { @@ -122,7 +137,7 @@ async function renderDashboardSections() { async function renderTrafficWidget() { const traffic = store.get().traffic; - console.log('render dashboard traffic widget'); + const container = document.getElementById('dashboard-widget-traffic'); const renderedWidget = renderDashboardWidget({ title: 'Traffic', @@ -137,7 +152,7 @@ async function renderTrafficWidget() { async function renderTrafficTotalWidget() { const connections = store.get().connections; - console.log('render dashboard traffic total widget'); + const container = document.getElementById('dashboard-widget-traffic-total'); const renderedWidget = renderDashboardWidget({ title: 'Traffic Total', @@ -155,7 +170,7 @@ async function renderTrafficTotalWidget() { async function renderSystemInfoWidget() { const connections = store.get().connections; - console.log('render dashboard system info widget'); + const container = document.getElementById('dashboard-widget-system-info'); const renderedWidget = renderDashboardWidget({ title: 'System info', @@ -173,7 +188,7 @@ async function renderSystemInfoWidget() { async function renderServiceInfoWidget() { const services = store.get().services; - console.log('render dashboard service info widget'); + const container = document.getElementById('dashboard-widget-service-info'); const renderedWidget = renderDashboardWidget({ title: 'Services info', @@ -202,31 +217,39 @@ async function renderServiceInfoWidget() { container!.replaceChildren(renderedWidget); } +async function onStoreUpdate( + next: StoreType, + prev: StoreType, + diff: Partial, +) { + if (diff?.dashboardSections) { + renderDashboardSections(); + } + + if (diff?.traffic) { + renderTrafficWidget(); + } + + if (diff?.connections) { + renderTrafficTotalWidget(); + renderSystemInfoWidget(); + } + + if (diff?.services) { + renderServiceInfoWidget(); + } +} + export async function initDashboardController(): Promise { - store.subscribe((next, prev, diff) => { - console.log('Store changed', { prev, next, diff }); - - // Update sections render - if (diff?.sections) { - renderDashboardSections(); - } - - if (diff?.traffic) { - renderTrafficWidget(); - } - - if (diff?.connections) { - renderTrafficTotalWidget(); - renderSystemInfoWidget(); - } - - if (diff?.services) { - renderServiceInfoWidget(); - } - }); - onMount('dashboard-status').then(() => { - console.log('Mounting dashboard'); + // Remove old listener + store.unsubscribe(onStoreUpdate); + // Clear store + store.reset(); + + // Add new listener + store.subscribe(onStoreUpdate); + // Initial sections fetch fetchDashboardSections(); fetchServicesInfo(); diff --git a/fe-app-podkop/src/dashboard/renderDashboard.ts b/fe-app-podkop/src/podkop/tabs/dashboard/renderDashboard.ts similarity index 82% rename from fe-app-podkop/src/dashboard/renderDashboard.ts rename to fe-app-podkop/src/podkop/tabs/dashboard/renderDashboard.ts index f160ce4..d3feafc 100644 --- a/fe-app-podkop/src/dashboard/renderDashboard.ts +++ b/fe-app-podkop/src/podkop/tabs/dashboard/renderDashboard.ts @@ -6,18 +6,6 @@ export function renderDashboard() { class: 'pdk_dashboard-page', }, [ - // Title section - E('div', { class: 'pdk_dashboard-page__title-section' }, [ - E( - 'h3', - { class: 'pdk_dashboard-page__title-section__title' }, - 'Overall (alpha)', - ), - E('label', {}, [ - E('input', { type: 'checkbox', disabled: true, checked: true }), - ' Runtime', - ]), - ]), // Widgets section E('div', { class: 'pdk_dashboard-page__widgets-section' }, [ E('div', { id: 'dashboard-widget-traffic' }, [ diff --git a/fe-app-podkop/src/podkop/tabs/dashboard/renderer/renderEmptyOutboundGroup.ts b/fe-app-podkop/src/podkop/tabs/dashboard/renderer/renderEmptyOutboundGroup.ts new file mode 100644 index 0000000..f8739c0 --- /dev/null +++ b/fe-app-podkop/src/podkop/tabs/dashboard/renderer/renderEmptyOutboundGroup.ts @@ -0,0 +1,10 @@ +export function renderEmptyOutboundGroup() { + return E( + 'div', + { + class: 'pdk_dashboard-page__outbound-section centered', + style: 'height: 127px', + }, + E('span', {}, 'Dashboard currently unavailable'), + ); +} diff --git a/fe-app-podkop/src/dashboard/renderer/renderOutboundGroup.ts b/fe-app-podkop/src/podkop/tabs/dashboard/renderer/renderOutboundGroup.ts similarity index 91% rename from fe-app-podkop/src/dashboard/renderer/renderOutboundGroup.ts rename to fe-app-podkop/src/podkop/tabs/dashboard/renderer/renderOutboundGroup.ts index 1ac5ebe..7541e26 100644 --- a/fe-app-podkop/src/dashboard/renderer/renderOutboundGroup.ts +++ b/fe-app-podkop/src/podkop/tabs/dashboard/renderer/renderOutboundGroup.ts @@ -1,4 +1,4 @@ -import { Podkop } from '../../podkop/types'; +import { Podkop } from '../../../types'; interface IRenderOutboundGroupProps { section: Podkop.OutboundGroup; @@ -74,7 +74,14 @@ export function renderOutboundGroup({ }, section.displayName, ), - E('button', { class: 'btn dashboard-sections-grid-item-test-latency', click: () => testLatency() }, 'Test latency'), + E( + 'button', + { + class: 'btn dashboard-sections-grid-item-test-latency', + click: () => testLatency(), + }, + 'Test latency', + ), ]), E( 'div', diff --git a/fe-app-podkop/src/dashboard/renderer/renderWidget.ts b/fe-app-podkop/src/podkop/tabs/dashboard/renderer/renderWidget.ts similarity index 100% rename from fe-app-podkop/src/dashboard/renderer/renderWidget.ts rename to fe-app-podkop/src/podkop/tabs/dashboard/renderer/renderWidget.ts diff --git a/fe-app-podkop/src/podkop/tabs/index.ts b/fe-app-podkop/src/podkop/tabs/index.ts new file mode 100644 index 0000000..b58b6c9 --- /dev/null +++ b/fe-app-podkop/src/podkop/tabs/index.ts @@ -0,0 +1 @@ +export * from './dashboard'; diff --git a/fe-app-podkop/src/socket.ts b/fe-app-podkop/src/socket.ts index ea9aba1..0f6a4fb 100644 --- a/fe-app-podkop/src/socket.ts +++ b/fe-app-podkop/src/socket.ts @@ -26,7 +26,6 @@ class SocketManager { ws.addEventListener('open', () => { this.connected.set(url, true); - console.log(`✅ Connected: ${url}`); }); ws.addEventListener('message', (event) => { diff --git a/fe-app-podkop/src/store.ts b/fe-app-podkop/src/store.ts index b920e4b..8a2a750 100644 --- a/fe-app-podkop/src/store.ts +++ b/fe-app-podkop/src/store.ts @@ -1,14 +1,43 @@ import { Podkop } from './podkop/types'; +function jsonStableStringify(obj: T): string { + return JSON.stringify(obj, (_, value) => { + if (value && typeof value === 'object' && !Array.isArray(value)) { + return Object.keys(value) + .sort() + .reduce( + (acc, key) => { + acc[key] = value[key]; + return acc; + }, + {} as Record, + ); + } + return value; + }); +} + +function jsonEqual(a: A, b: B): boolean { + try { + return jsonStableStringify(a) === jsonStableStringify(b); + } catch { + return false; + } +} + type Listener = (next: T, prev: T, diff: Partial) => void; // eslint-disable-next-line class Store> { private value: T; + private readonly initial: T; private listeners = new Set>(); + private lastHash = ''; constructor(initial: T) { this.value = initial; + this.initial = structuredClone(initial); + this.lastHash = jsonStableStringify(initial); } get(): T { @@ -17,14 +46,33 @@ class Store> { set(next: Partial): void { const prev = this.value; - const merged = { ...this.value, ...next }; - if (Object.is(prev, merged)) return; + const merged = { ...prev, ...next }; + + if (jsonEqual(prev, merged)) return; this.value = merged; + this.lastHash = jsonStableStringify(merged); const diff: Partial = {}; for (const key in merged) { - if (merged[key] !== prev[key]) diff[key] = merged[key]; + if (!jsonEqual(merged[key], prev[key])) diff[key] = merged[key]; + } + + this.listeners.forEach((cb) => cb(this.value, prev, diff)); + } + + reset(): void { + const prev = this.value; + const next = structuredClone(this.initial); + + if (jsonEqual(prev, next)) return; + + this.value = next; + this.lastHash = jsonStableStringify(next); + + const diff: Partial = {}; + for (const key in next) { + if (!jsonEqual(next[key], prev[key])) diff[key] = next[key]; } this.listeners.forEach((cb) => cb(this.value, prev, diff)); @@ -32,12 +80,16 @@ class Store> { subscribe(cb: Listener): () => void { this.listeners.add(cb); - cb(this.value, this.value, {}); // первый вызов без diff + cb(this.value, this.value, {}); return () => this.listeners.delete(cb); } + unsubscribe(cb: Listener): void { + this.listeners.delete(cb); + } + patch(key: K, value: T[K]): void { - this.set({ ...this.value, [key]: value }); + this.set({ [key]: value } as unknown as Partial); } getKey(key: K): T[K] { @@ -49,18 +101,27 @@ class Store> { cb: (value: T[K]) => void, ): () => void { let prev = this.value[key]; - const unsub = this.subscribe((val) => { - if (val[key] !== prev) { + const wrapper: Listener = (val) => { + if (!jsonEqual(val[key], prev)) { prev = val[key]; cb(val[key]); } - }); - return unsub; + }; + this.listeners.add(wrapper); + return () => this.listeners.delete(wrapper); } } -export const store = new Store<{ - sections: Podkop.OutboundGroup[]; +export interface StoreType { + tabService: { + current: string; + all: string[]; + }; + dashboardSections: { + loading: boolean; + data: Podkop.OutboundGroup[]; + failed: boolean; + }; traffic: { up: number; down: number }; memory: { inuse: number; oslimit: number }; connections: { @@ -73,10 +134,26 @@ export const store = new Store<{ singbox: number; podkop: number; }; -}>({ - sections: [], - traffic: { up: 0, down: 0 }, - memory: { inuse: 0, oslimit: 0 }, - connections: { connections: [], memory: 0, downloadTotal: 0, uploadTotal: 0 }, +} + +const initialStore: StoreType = { + tabService: { + current: '', + all: [], + }, + dashboardSections: { + data: [], + loading: true, + }, + traffic: { up: -1, down: -1 }, + memory: { inuse: -1, oslimit: -1 }, + connections: { + connections: [], + memory: -1, + downloadTotal: -1, + uploadTotal: -1, + }, services: { singbox: -1, podkop: -1 }, -}); +}; + +export const store = new Store(initialStore); diff --git a/fe-app-podkop/src/styles.ts b/fe-app-podkop/src/styles.ts index 5337c10..69c6be0 100644 --- a/fe-app-podkop/src/styles.ts +++ b/fe-app-podkop/src/styles.ts @@ -167,6 +167,12 @@ export const GlobalStyles = ` color: var(--error-color-medium); } +.centered { + display: flex; + align-items: center; + justify-content: center; +} + /* Skeleton styles*/ .skeleton { background-color: var(--background-color-low, #e0e0e0); diff --git a/luci-app-podkop/htdocs/luci-static/resources/view/podkop/dashboardTab.js b/luci-app-podkop/htdocs/luci-static/resources/view/podkop/dashboardTab.js index 7a7eff1..f1659eb 100644 --- a/luci-app-podkop/htdocs/luci-static/resources/view/podkop/dashboardTab.js +++ b/luci-app-podkop/htdocs/luci-static/resources/view/podkop/dashboardTab.js @@ -12,7 +12,11 @@ function createDashboardSection(mainSection) { o = mainSection.taboption('dashboard', form.DummyValue, '_status'); o.rawhtml = true; - o.cfgvalue = () => main.renderDashboard(); + o.cfgvalue = () => { + main.initDashboardController() + + return main.renderDashboard() + }; } const EntryPoint = { diff --git a/luci-app-podkop/htdocs/luci-static/resources/view/podkop/main.js b/luci-app-podkop/htdocs/luci-static/resources/view/podkop/main.js index d005a9a..502c013 100644 --- a/luci-app-podkop/htdocs/luci-static/resources/view/podkop/main.js +++ b/luci-app-podkop/htdocs/luci-static/resources/view/podkop/main.js @@ -515,6 +515,12 @@ var GlobalStyles = ` color: var(--error-color-medium); } +.centered { + display: flex; + align-items: center; + justify-content: center; +} + /* Skeleton styles*/ .skeleton { background-color: var(--background-color-low, #e0e0e0); @@ -883,86 +889,6 @@ async function triggerLatencyProxyTest(tag, timeout = 2e3, url = "https://www.gs ); } -// src/dashboard/renderDashboard.ts -function renderDashboard() { - return E( - "div", - { - id: "dashboard-status", - class: "pdk_dashboard-page" - }, - [ - // Title section - E("div", { class: "pdk_dashboard-page__title-section" }, [ - E( - "h3", - { class: "pdk_dashboard-page__title-section__title" }, - "Overall (alpha)" - ), - E("label", {}, [ - E("input", { type: "checkbox", disabled: true, checked: true }), - " Runtime" - ]) - ]), - // Widgets section - E("div", { class: "pdk_dashboard-page__widgets-section" }, [ - E("div", { id: "dashboard-widget-traffic" }, [ - E( - "div", - { - id: "", - style: "height: 78px", - class: "pdk_dashboard-page__widgets-section__item skeleton" - }, - "" - ) - ]), - E("div", { id: "dashboard-widget-traffic-total" }, [ - E( - "div", - { - id: "", - style: "height: 78px", - class: "pdk_dashboard-page__widgets-section__item skeleton" - }, - "" - ) - ]), - E("div", { id: "dashboard-widget-system-info" }, [ - E( - "div", - { - id: "", - style: "height: 78px", - class: "pdk_dashboard-page__widgets-section__item skeleton" - }, - "" - ) - ]), - E("div", { id: "dashboard-widget-service-info" }, [ - E( - "div", - { - id: "", - style: "height: 78px", - class: "pdk_dashboard-page__widgets-section__item skeleton" - }, - "" - ) - ]) - ]), - // All outbounds - E("div", { id: "dashboard-sections-grid" }, [ - E("div", { - id: "dashboard-sections-grid-skeleton", - class: "pdk_dashboard-page__outbound-section skeleton", - style: "height: 127px" - }) - ]) - ] - ); -} - // src/podkop/methods/getConfigSections.ts async function getConfigSections() { return uci.load("podkop").then(() => uci.sections("podkop")); @@ -972,14 +898,19 @@ async function getConfigSections() { async function getDashboardSections() { const configSections = await getConfigSections(); const clashProxies = await getClashProxies(); - const clashProxiesData = clashProxies.success ? clashProxies.data : { proxies: [] }; - const proxies = Object.entries(clashProxiesData.proxies).map( + if (!clashProxies.success) { + return { + success: false, + data: [] + }; + } + const proxies = Object.entries(clashProxies.data.proxies).map( ([key, value]) => ({ code: key, value }) ); - return configSections.filter((section) => section.mode !== "block").map((section) => { + const data = configSections.filter((section) => section.mode !== "block").map((section) => { if (section.mode === "proxy") { if (section.proxy_config_type === "url") { const outbound = proxies.find( @@ -1076,6 +1007,10 @@ async function getDashboardSections() { outbounds: [] }; }); + return { + success: true, + data + }; } // src/podkop/methods/getPodkopStatus.ts @@ -1104,7 +1039,258 @@ async function getSingboxStatus() { return { running: 0, enabled: 0, status: "unknown" }; } -// src/dashboard/renderer/renderOutboundGroup.ts +// src/podkop/services/tab.service.ts +var TabService = class _TabService { + constructor() { + this.observer = null; + this.lastActiveId = null; + this.init(); + } + static getInstance() { + if (!_TabService.instance) { + _TabService.instance = new _TabService(); + } + return _TabService.instance; + } + init() { + this.observer = new MutationObserver(() => this.handleMutations()); + this.observer.observe(document.body, { + subtree: true, + childList: true, + attributes: true, + attributeFilter: ["class"] + }); + this.notify(); + } + handleMutations() { + this.notify(); + } + getTabsInfo() { + const tabs = Array.from( + document.querySelectorAll(".cbi-tab, .cbi-tab-disabled") + ); + return tabs.map((el) => ({ + el, + id: el.dataset.tab || "", + active: el.classList.contains("cbi-tab") && !el.classList.contains("cbi-tab-disabled") + })); + } + getActiveTabId() { + const active = document.querySelector( + ".cbi-tab:not(.cbi-tab-disabled)" + ); + return active?.dataset.tab || null; + } + notify() { + const tabs = this.getTabsInfo(); + const activeId = this.getActiveTabId(); + if (activeId !== this.lastActiveId) { + this.lastActiveId = activeId; + this.callback?.(activeId, tabs); + } + } + onChange(callback) { + this.callback = callback; + this.notify(); + } + getAllTabs() { + return this.getTabsInfo(); + } + getActiveTab() { + return this.getActiveTabId(); + } + disconnect() { + this.observer?.disconnect(); + this.observer = null; + } +}; +var TabServiceInstance = TabService.getInstance(); + +// src/store.ts +function jsonStableStringify(obj) { + return JSON.stringify(obj, (_, value) => { + if (value && typeof value === "object" && !Array.isArray(value)) { + return Object.keys(value).sort().reduce( + (acc, key) => { + acc[key] = value[key]; + return acc; + }, + {} + ); + } + return value; + }); +} +function jsonEqual(a, b) { + try { + return jsonStableStringify(a) === jsonStableStringify(b); + } catch { + return false; + } +} +var Store = class { + constructor(initial) { + this.listeners = /* @__PURE__ */ new Set(); + this.lastHash = ""; + this.value = initial; + this.initial = structuredClone(initial); + this.lastHash = jsonStableStringify(initial); + } + get() { + return this.value; + } + set(next) { + const prev = this.value; + const merged = { ...prev, ...next }; + if (jsonEqual(prev, merged)) return; + this.value = merged; + this.lastHash = jsonStableStringify(merged); + const diff = {}; + for (const key in merged) { + if (!jsonEqual(merged[key], prev[key])) diff[key] = merged[key]; + } + this.listeners.forEach((cb) => cb(this.value, prev, diff)); + } + reset() { + const prev = this.value; + const next = structuredClone(this.initial); + if (jsonEqual(prev, next)) return; + this.value = next; + this.lastHash = jsonStableStringify(next); + const diff = {}; + for (const key in next) { + if (!jsonEqual(next[key], prev[key])) diff[key] = next[key]; + } + this.listeners.forEach((cb) => cb(this.value, prev, diff)); + } + subscribe(cb) { + this.listeners.add(cb); + cb(this.value, this.value, {}); + return () => this.listeners.delete(cb); + } + unsubscribe(cb) { + this.listeners.delete(cb); + } + patch(key, value) { + this.set({ [key]: value }); + } + getKey(key) { + return this.value[key]; + } + subscribeKey(key, cb) { + let prev = this.value[key]; + const wrapper = (val) => { + if (!jsonEqual(val[key], prev)) { + prev = val[key]; + cb(val[key]); + } + }; + this.listeners.add(wrapper); + return () => this.listeners.delete(wrapper); + } +}; +var initialStore = { + tabService: { + current: "", + all: [] + }, + dashboardSections: { + data: [], + loading: true + }, + traffic: { up: -1, down: -1 }, + memory: { inuse: -1, oslimit: -1 }, + connections: { + connections: [], + memory: -1, + downloadTotal: -1, + uploadTotal: -1 + }, + services: { singbox: -1, podkop: -1 } +}; +var store = new Store(initialStore); + +// src/podkop/services/core.service.ts +function coreService() { + TabServiceInstance.onChange((activeId, tabs) => { + store.set({ + tabService: { + current: activeId || "", + all: tabs.map((tab) => tab.id) + } + }); + }); +} + +// src/podkop/tabs/dashboard/renderDashboard.ts +function renderDashboard() { + return E( + "div", + { + id: "dashboard-status", + class: "pdk_dashboard-page" + }, + [ + // Widgets section + E("div", { class: "pdk_dashboard-page__widgets-section" }, [ + E("div", { id: "dashboard-widget-traffic" }, [ + E( + "div", + { + id: "", + style: "height: 78px", + class: "pdk_dashboard-page__widgets-section__item skeleton" + }, + "" + ) + ]), + E("div", { id: "dashboard-widget-traffic-total" }, [ + E( + "div", + { + id: "", + style: "height: 78px", + class: "pdk_dashboard-page__widgets-section__item skeleton" + }, + "" + ) + ]), + E("div", { id: "dashboard-widget-system-info" }, [ + E( + "div", + { + id: "", + style: "height: 78px", + class: "pdk_dashboard-page__widgets-section__item skeleton" + }, + "" + ) + ]), + E("div", { id: "dashboard-widget-service-info" }, [ + E( + "div", + { + id: "", + style: "height: 78px", + class: "pdk_dashboard-page__widgets-section__item skeleton" + }, + "" + ) + ]) + ]), + // All outbounds + E("div", { id: "dashboard-sections-grid" }, [ + E("div", { + id: "dashboard-sections-grid-skeleton", + class: "pdk_dashboard-page__outbound-section skeleton", + style: "height: 127px" + }) + ]) + ] + ); +} + +// src/podkop/tabs/dashboard/renderer/renderOutboundGroup.ts function renderOutboundGroup({ section, onTestLatency, @@ -1164,7 +1350,14 @@ function renderOutboundGroup({ }, section.displayName ), - E("button", { class: "btn dashboard-sections-grid-item-test-latency", click: () => testLatency() }, "Test latency") + E( + "button", + { + class: "btn dashboard-sections-grid-item-test-latency", + click: () => testLatency() + }, + "Test latency" + ) ]), E( "div", @@ -1174,55 +1367,36 @@ function renderOutboundGroup({ ]); } -// src/store.ts -var Store = class { - constructor(initial) { - this.listeners = /* @__PURE__ */ new Set(); - this.value = initial; - } - get() { - return this.value; - } - set(next) { - const prev = this.value; - const merged = { ...this.value, ...next }; - if (Object.is(prev, merged)) return; - this.value = merged; - const diff = {}; - for (const key in merged) { - if (merged[key] !== prev[key]) diff[key] = merged[key]; - } - this.listeners.forEach((cb) => cb(this.value, prev, diff)); - } - subscribe(cb) { - this.listeners.add(cb); - cb(this.value, this.value, {}); - return () => this.listeners.delete(cb); - } - patch(key, value) { - this.set({ ...this.value, [key]: value }); - } - getKey(key) { - return this.value[key]; - } - subscribeKey(key, cb) { - let prev = this.value[key]; - const unsub = this.subscribe((val) => { - if (val[key] !== prev) { - prev = val[key]; - cb(val[key]); - } - }); - return unsub; - } -}; -var store = new Store({ - sections: [], - traffic: { up: 0, down: 0 }, - memory: { inuse: 0, oslimit: 0 }, - connections: { connections: [], memory: 0, downloadTotal: 0, uploadTotal: 0 }, - services: { singbox: -1, podkop: -1 } -}); +// src/podkop/tabs/dashboard/renderer/renderWidget.ts +function renderDashboardWidget({ title, items }) { + return E("div", { class: "pdk_dashboard-page__widgets-section__item" }, [ + E( + "b", + { class: "pdk_dashboard-page__widgets-section__item__title" }, + title + ), + ...items.map( + (item) => E( + "div", + { + class: `pdk_dashboard-page__widgets-section__item__row ${item?.attributes?.class || ""}` + }, + [ + E( + "span", + { class: "pdk_dashboard-page__widgets-section__item__row__key" }, + `${item.key}: ` + ), + E( + "span", + { class: "pdk_dashboard-page__widgets-section__item__row__value" }, + item.value + ) + ] + ) + ) + ]); +} // src/socket.ts var SocketManager = class _SocketManager { @@ -1245,7 +1419,6 @@ var SocketManager = class _SocketManager { this.listeners.set(url, /* @__PURE__ */ new Set()); ws.addEventListener("open", () => { this.connected.set(url, true); - console.log(`\u2705 Connected: ${url}`); }); ws.addEventListener("message", (event) => { const handlers = this.listeners.get(url); @@ -1302,37 +1475,6 @@ var SocketManager = class _SocketManager { }; var socket = SocketManager.getInstance(); -// src/dashboard/renderer/renderWidget.ts -function renderDashboardWidget({ title, items }) { - return E("div", { class: "pdk_dashboard-page__widgets-section__item" }, [ - E( - "b", - { class: "pdk_dashboard-page__widgets-section__item__title" }, - title - ), - ...items.map( - (item) => E( - "div", - { - class: `pdk_dashboard-page__widgets-section__item__row ${item?.attributes?.class || ""}` - }, - [ - E( - "span", - { class: "pdk_dashboard-page__widgets-section__item__row__key" }, - `${item.key}: ` - ), - E( - "span", - { class: "pdk_dashboard-page__widgets-section__item__row__value" }, - item.value - ) - ] - ) - ) - ]); -} - // src/helpers/prettyBytes.ts function prettyBytes(n) { const UNITS = ["B", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"]; @@ -1345,16 +1487,33 @@ function prettyBytes(n) { return n + " " + unit; } -// src/dashboard/initDashboardController.ts +// src/podkop/tabs/dashboard/renderer/renderEmptyOutboundGroup.ts +function renderEmptyOutboundGroup() { + return E( + "div", + { + class: "pdk_dashboard-page__outbound-section centered", + style: "height: 127px" + }, + E("span", {}, "Dashboard currently unavailable") + ); +} + +// src/podkop/tabs/dashboard/initDashboardController.ts async function fetchDashboardSections() { - const sections = await getDashboardSections(); - store.set({ sections }); + store.set({ + dashboardSections: { + ...store.get().dashboardSections, + failed: false, + loading: true + } + }); + const { data, success } = await getDashboardSections(); + store.set({ dashboardSections: { loading: false, data, failed: !success } }); } async function fetchServicesInfo() { const podkop = await getPodkopStatus(); const singbox = await getSingboxStatus(); - console.log("podkop", podkop); - console.log("singbox", singbox); store.set({ services: { singbox: singbox.running, @@ -1408,10 +1567,13 @@ function replaceTestLatencyButtonsWithSkeleton() { }); } async function renderDashboardSections() { - const sections = store.get().sections; - console.log("render dashboard sections group"); + const dashboardSections = store.get().dashboardSections; const container = document.getElementById("dashboard-sections-grid"); - const renderedOutboundGroups = sections.map( + if (dashboardSections.failed) { + const rendered = renderEmptyOutboundGroup(); + return container.replaceChildren(rendered); + } + const renderedOutboundGroups = dashboardSections.data.map( (section) => renderOutboundGroup({ section, onTestLatency: (tag) => { @@ -1430,7 +1592,6 @@ async function renderDashboardSections() { } async function renderTrafficWidget() { const traffic = store.get().traffic; - console.log("render dashboard traffic widget"); const container = document.getElementById("dashboard-widget-traffic"); const renderedWidget = renderDashboardWidget({ title: "Traffic", @@ -1443,7 +1604,6 @@ async function renderTrafficWidget() { } async function renderTrafficTotalWidget() { const connections = store.get().connections; - console.log("render dashboard traffic total widget"); const container = document.getElementById("dashboard-widget-traffic-total"); const renderedWidget = renderDashboardWidget({ title: "Traffic Total", @@ -1459,7 +1619,6 @@ async function renderTrafficTotalWidget() { } async function renderSystemInfoWidget() { const connections = store.get().connections; - console.log("render dashboard system info widget"); const container = document.getElementById("dashboard-widget-system-info"); const renderedWidget = renderDashboardWidget({ title: "System info", @@ -1475,7 +1634,6 @@ async function renderSystemInfoWidget() { } async function renderServiceInfoWidget() { const services = store.get().services; - console.log("render dashboard service info widget"); const container = document.getElementById("dashboard-widget-service-info"); const renderedWidget = renderDashboardWidget({ title: "Services info", @@ -1498,25 +1656,26 @@ async function renderServiceInfoWidget() { }); container.replaceChildren(renderedWidget); } +async function onStoreUpdate(next, prev, diff) { + if (diff?.dashboardSections) { + renderDashboardSections(); + } + if (diff?.traffic) { + renderTrafficWidget(); + } + if (diff?.connections) { + renderTrafficTotalWidget(); + renderSystemInfoWidget(); + } + if (diff?.services) { + renderServiceInfoWidget(); + } +} async function initDashboardController() { - store.subscribe((next, prev, diff) => { - console.log("Store changed", { prev, next, diff }); - if (diff?.sections) { - renderDashboardSections(); - } - if (diff?.traffic) { - renderTrafficWidget(); - } - if (diff?.connections) { - renderTrafficTotalWidget(); - renderSystemInfoWidget(); - } - if (diff?.services) { - renderServiceInfoWidget(); - } - }); onMount("dashboard-status").then(() => { - console.log("Mounting dashboard"); + store.unsubscribe(onStoreUpdate); + store.reset(); + store.subscribe(onStoreUpdate); fetchDashboardSections(); fetchServicesInfo(); connectToClashSockets(); @@ -1539,9 +1698,12 @@ return baseclass.extend({ IP_CHECK_DOMAIN, REGIONAL_OPTIONS, STATUS_COLORS, + TabService, + TabServiceInstance, UPDATE_INTERVAL_OPTIONS, bulkValidate, copyToClipboard, + coreService, createBaseApiRequest, executeShellCommand, getBaseUrl, @@ -1551,7 +1713,11 @@ return baseclass.extend({ getClashProxies, getClashVersion, getClashWsUrl, + getConfigSections, + getDashboardSections, + getPodkopStatus, getProxyUrlName, + getSingboxStatus, initDashboardController, injectGlobalStyles, maskIP, diff --git a/luci-app-podkop/htdocs/luci-static/resources/view/podkop/podkop.js b/luci-app-podkop/htdocs/luci-static/resources/view/podkop/podkop.js index 3472a86..ce5222d 100644 --- a/luci-app-podkop/htdocs/luci-static/resources/view/podkop/podkop.js +++ b/luci-app-podkop/htdocs/luci-static/resources/view/podkop/podkop.js @@ -34,10 +34,6 @@ const EntryNode = { const mainSection = podkopFormMap.section(form.TypedSection, 'main'); mainSection.anonymous = true; - dashboardTab.createDashboardSection(mainSection); - - main.initDashboardController(); - configSection.createConfigSection(mainSection); // Additional Settings Tab (main section) @@ -84,6 +80,16 @@ const EntryNode = { extraSection.multiple = true; configSection.createConfigSection(extraSection); + + // Initial dashboard render + dashboardTab.createDashboardSection(mainSection); + + // Inject dashboard actualizer logic + // main.initDashboardController(); + + // Inject core service + main.coreService(); + return podkopFormMapPromise; } } From c78f97d64fa6dcdc6cbb8ad4d4b432237acd3e75 Mon Sep 17 00:00:00 2001 From: divocat Date: Tue, 7 Oct 2025 00:50:39 +0300 Subject: [PATCH 43/49] fix: run prettier & remove unused fragments --- .../src/validators/validateVlessUrl.ts | 1 - fe-app-podkop/watch-upload.js | 22 +- .../resources/view/podkop/configSection.js | 422 +++++++++--------- .../resources/view/podkop/dashboardTab.js | 18 +- .../resources/view/podkop/podkop.js | 121 +++-- 5 files changed, 283 insertions(+), 301 deletions(-) diff --git a/fe-app-podkop/src/validators/validateVlessUrl.ts b/fe-app-podkop/src/validators/validateVlessUrl.ts index e74ffa6..45ea65a 100644 --- a/fe-app-podkop/src/validators/validateVlessUrl.ts +++ b/fe-app-podkop/src/validators/validateVlessUrl.ts @@ -1,6 +1,5 @@ import { ValidationResult } from './types'; -// TODO refactor current validation and add tests export function validateVlessUrl(url: string): ValidationResult { try { const parsedUrl = new URL(url); diff --git a/fe-app-podkop/watch-upload.js b/fe-app-podkop/watch-upload.js index 9bdd821..db0b1ea 100644 --- a/fe-app-podkop/watch-upload.js +++ b/fe-app-podkop/watch-upload.js @@ -23,12 +23,12 @@ async function uploadFile(filePath) { const relativePath = path.relative(localDir, filePath); const remotePath = path.posix.join(remoteDir, relativePath); - console.log(`⬆️ Uploading: ${relativePath} -> ${remotePath}`); + console.log(`Uploading: ${relativePath} -> ${remotePath}`); try { await sftp.fastPut(filePath, remotePath); - console.log(`✅ Uploaded: ${relativePath}`); + console.log(`Uploaded: ${relativePath}`); } catch (err) { - console.error(`❌ Failed: ${relativePath}: ${err.message}`); + console.error(`Failed: ${relativePath}: ${err.message}`); } } @@ -36,34 +36,32 @@ async function deleteFile(filePath) { const relativePath = path.relative(localDir, filePath); const remotePath = path.posix.join(remoteDir, relativePath); - console.log(`🗑 Removing: ${relativePath}`); + console.log(`Removing: ${relativePath}`); try { await sftp.delete(remotePath); - console.log(`✅ Removed: ${relativePath}`); + console.log(`Removed: ${relativePath}`); } catch (err) { - console.warn(`⚠️ Could not delete ${relativePath}: ${err.message}`); + console.warn(`Could not delete ${relativePath}: ${err.message}`); } } async function uploadAllFiles() { - console.log('🚀 Uploading all files from', localDir); + console.log('Uploading all files from', localDir); const files = await glob(`${localDir}/**/*`, { nodir: true }); for (const file of files) { await uploadFile(file); } - console.log('✅ Initial upload complete!'); + console.log('Initial upload complete!'); } async function main() { await sftp.connect(config); - console.log(`✅ Connected to ${config.host}`); + console.log(`Connected to ${config.host}`); - // 🔹 Загрузить всё при старте await uploadAllFiles(); - // 🔹 Затем следить за изменениями chokidar .watch(localDir, { ignoreInitial: true }) .on('all', async (event, filePath) => { @@ -75,7 +73,7 @@ async function main() { }); process.on('SIGINT', async () => { - console.log('🔌 Disconnecting...'); + console.log('Disconnecting...'); await sftp.end(); process.exit(); }); diff --git a/luci-app-podkop/htdocs/luci-static/resources/view/podkop/configSection.js b/luci-app-podkop/htdocs/luci-static/resources/view/podkop/configSection.js index 6ce0f09..f1a225b 100644 --- a/luci-app-podkop/htdocs/luci-static/resources/view/podkop/configSection.js +++ b/luci-app-podkop/htdocs/luci-static/resources/view/podkop/configSection.js @@ -12,11 +12,11 @@ function createConfigSection(section) { let o = s.tab('basic', _('Basic Settings')); o = s.taboption( - 'basic', - form.ListValue, - 'mode', - _('Connection Type'), - _('Select between VPN and Proxy connection methods for traffic routing'), + 'basic', + form.ListValue, + 'mode', + _('Connection Type'), + _('Select between VPN and Proxy connection methods for traffic routing'), ); o.value('proxy', 'Proxy'); o.value('vpn', 'VPN'); @@ -24,11 +24,11 @@ function createConfigSection(section) { o.ucisection = s.section; o = s.taboption( - 'basic', - form.ListValue, - 'proxy_config_type', - _('Configuration Type'), - _('Select how to configure the proxy'), + 'basic', + form.ListValue, + 'proxy_config_type', + _('Configuration Type'), + _('Select how to configure the proxy'), ); o.value('url', _('Connection URL')); o.value('outbound', _('Outbound Config')); @@ -38,11 +38,11 @@ function createConfigSection(section) { o.ucisection = s.section; o = s.taboption( - 'basic', - form.TextValue, - 'proxy_string', - _('Proxy Configuration URL'), - '', + 'basic', + form.TextValue, + 'proxy_string', + _('Proxy Configuration URL'), + '', ); o.depends('proxy_config_type', 'url'); o.rows = 5; @@ -52,7 +52,7 @@ function createConfigSection(section) { o.ucisection = s.section; o.sectionDescriptions = new Map(); o.placeholder = - 'vless://uuid@server:port?type=tcp&security=tls#main\n// backup ss://method:pass@server:port\n// backup2 vless://uuid@server:port?type=grpc&security=reality#alt\n// backup3 trojan://04agAQapcl@127.0.0.1:33641?type=tcp&security=none#trojan-tcp-none'; + 'vless://uuid@server:port?type=tcp&security=tls#main\n// backup ss://method:pass@server:port\n// backup2 vless://uuid@server:port?type=grpc&security=reality#alt\n// backup3 trojan://04agAQapcl@127.0.0.1:33641?type=tcp&security=none#trojan-tcp-none'; o.renderWidget = function (section_id, option_index, cfgvalue) { const original = form.TextValue.prototype.renderWidget.apply(this, [ @@ -66,9 +66,9 @@ function createConfigSection(section) { if (cfgvalue) { try { const activeConfig = cfgvalue - .split('\n') - .map((line) => line.trim()) - .find((line) => line && !line.startsWith('//')); + .split('\n') + .map((line) => line.trim()) + .find((line) => line && !line.startsWith('//')); if (activeConfig) { if (activeConfig.includes('#')) { @@ -76,24 +76,24 @@ function createConfigSection(section) { if (label && label.trim()) { const decodedLabel = decodeURIComponent(label); const descDiv = E( - 'div', - { class: 'cbi-value-description' }, - _('Current config: ') + decodedLabel, + 'div', + { class: 'cbi-value-description' }, + _('Current config: ') + decodedLabel, ); container.appendChild(descDiv); } else { const descDiv = E( - 'div', - { class: 'cbi-value-description' }, - _('Config without description'), + 'div', + { class: 'cbi-value-description' }, + _('Config without description'), ); container.appendChild(descDiv); } } else { const descDiv = E( - 'div', - { class: 'cbi-value-description' }, - _('Config without description'), + 'div', + { class: 'cbi-value-description' }, + _('Config without description'), ); container.appendChild(descDiv); } @@ -101,19 +101,19 @@ function createConfigSection(section) { } catch (e) { console.error('Error parsing config label:', e); const descDiv = E( - 'div', - { class: 'cbi-value-description' }, - _('Config without description'), + 'div', + { class: 'cbi-value-description' }, + _('Config without description'), ); container.appendChild(descDiv); } } else { const defaultDesc = E( - 'div', - { class: 'cbi-value-description' }, - _( - 'Enter connection string starting with vless:// or ss:// for proxy configuration. Add comments with // for backup configs', - ), + 'div', + { class: 'cbi-value-description' }, + _( + 'Enter connection string starting with vless:// or ss:// for proxy configuration. Add comments with // for backup configs', + ), ); container.appendChild(defaultDesc); } @@ -129,20 +129,20 @@ function createConfigSection(section) { try { const activeConfigs = value - .split('\n') - .map((line) => line.trim()) - .filter((line) => !line.startsWith('//')) - .filter(Boolean); + .split('\n') + .map((line) => line.trim()) + .filter((line) => !line.startsWith('//')) + .filter(Boolean); if (!activeConfigs.length) { return _( - 'No active configuration found. One configuration is required.', + 'No active configuration found. One configuration is required.', ); } if (activeConfigs.length > 1) { return _( - 'Multiply active configurations found. Please leave one configuration.', + 'Multiply active configurations found. Please leave one configuration.', ); } @@ -159,11 +159,11 @@ function createConfigSection(section) { }; o = s.taboption( - 'basic', - form.TextValue, - 'outbound_json', - _('Outbound Configuration'), - _('Enter complete outbound configuration in JSON format'), + 'basic', + form.TextValue, + 'outbound_json', + _('Outbound Configuration'), + _('Enter complete outbound configuration in JSON format'), ); o.depends('proxy_config_type', 'outbound'); o.rows = 10; @@ -184,10 +184,10 @@ function createConfigSection(section) { }; o = s.taboption( - 'basic', - form.DynamicList, - 'urltest_proxy_links', - _('URLTest Proxy Links'), + 'basic', + form.DynamicList, + 'urltest_proxy_links', + _('URLTest Proxy Links'), ); o.depends('proxy_config_type', 'urltest'); o.placeholder = 'vless://, ss://, trojan:// links'; @@ -208,11 +208,11 @@ function createConfigSection(section) { }; o = s.taboption( - 'basic', - form.Flag, - 'ss_uot', - _('Shadowsocks UDP over TCP'), - _('Apply for SS2022'), + 'basic', + form.Flag, + 'ss_uot', + _('Shadowsocks UDP over TCP'), + _('Apply for SS2022'), ); o.default = '0'; o.depends('mode', 'proxy'); @@ -220,11 +220,11 @@ function createConfigSection(section) { o.ucisection = s.section; o = s.taboption( - 'basic', - widgets.DeviceSelect, - 'interface', - _('Network Interface'), - _('Select network interface for VPN connection'), + 'basic', + widgets.DeviceSelect, + 'interface', + _('Network Interface'), + _('Select network interface for VPN connection'), ); o.depends('mode', 'vpn'); o.ucisection = s.section; @@ -262,17 +262,17 @@ function createConfigSection(section) { // Reject wireless-related devices const isWireless = - type === 'wifi' || type === 'wireless' || type.includes('wlan'); + type === 'wifi' || type === 'wireless' || type.includes('wlan'); return !isWireless; }; o = s.taboption( - 'basic', - form.Flag, - 'domain_resolver_enabled', - _('Domain Resolver'), - _('Enable built-in DNS resolver for domains handled by this section'), + 'basic', + form.Flag, + 'domain_resolver_enabled', + _('Domain Resolver'), + _('Enable built-in DNS resolver for domains handled by this section'), ); o.default = '0'; o.rmempty = false; @@ -280,11 +280,11 @@ function createConfigSection(section) { o.ucisection = s.section; o = s.taboption( - 'basic', - form.ListValue, - 'domain_resolver_dns_type', - _('DNS Protocol Type'), - _('Select the DNS protocol type for the domain resolver'), + 'basic', + form.ListValue, + 'domain_resolver_dns_type', + _('DNS Protocol Type'), + _('Select the DNS protocol type for the domain resolver'), ); o.value('doh', _('DNS over HTTPS (DoH)')); o.value('dot', _('DNS over TLS (DoT)')); @@ -295,11 +295,11 @@ function createConfigSection(section) { o.ucisection = s.section; o = s.taboption( - 'basic', - form.Value, - 'domain_resolver_dns_server', - _('DNS Server'), - _('Select or enter DNS server address'), + 'basic', + form.Value, + 'domain_resolver_dns_server', + _('DNS Server'), + _('Select or enter DNS server address'), ); Object.entries(main.DNS_SERVER_OPTIONS).forEach(([key, label]) => { o.value(key, _(label)); @@ -319,21 +319,21 @@ function createConfigSection(section) { }; o = s.taboption( - 'basic', - form.Flag, - 'community_lists_enabled', - _('Community Lists'), + 'basic', + form.Flag, + 'community_lists_enabled', + _('Community Lists'), ); o.default = '0'; o.rmempty = false; o.ucisection = s.section; o = s.taboption( - 'basic', - form.DynamicList, - 'community_lists', - _('Service List'), - _('Select predefined service for routing') + + 'basic', + form.DynamicList, + 'community_lists', + _('Service List'), + _('Select predefined service for routing') + ' github.com/itdoginfo/allow-domains', ); o.placeholder = 'Service list'; @@ -357,50 +357,50 @@ function createConfigSection(section) { let notifications = []; const selectedRegionalOptions = main.REGIONAL_OPTIONS.filter((opt) => - newValues.includes(opt), + newValues.includes(opt), ); if (selectedRegionalOptions.length > 1) { const lastSelected = - selectedRegionalOptions[selectedRegionalOptions.length - 1]; + selectedRegionalOptions[selectedRegionalOptions.length - 1]; const removedRegions = selectedRegionalOptions.slice(0, -1); newValues = newValues.filter( - (v) => v === lastSelected || !main.REGIONAL_OPTIONS.includes(v), + (v) => v === lastSelected || !main.REGIONAL_OPTIONS.includes(v), ); notifications.push( - E('p', { class: 'alert-message warning' }, [ - E('strong', {}, _('Regional options cannot be used together')), - E('br'), - _( - 'Warning: %s cannot be used together with %s. Previous selections have been removed.', - ).format(removedRegions.join(', '), lastSelected), - ]), + E('p', { class: 'alert-message warning' }, [ + E('strong', {}, _('Regional options cannot be used together')), + E('br'), + _( + 'Warning: %s cannot be used together with %s. Previous selections have been removed.', + ).format(removedRegions.join(', '), lastSelected), + ]), ); } if (newValues.includes('russia_inside')) { const removedServices = newValues.filter( - (v) => !main.ALLOWED_WITH_RUSSIA_INSIDE.includes(v), + (v) => !main.ALLOWED_WITH_RUSSIA_INSIDE.includes(v), ); if (removedServices.length > 0) { newValues = newValues.filter((v) => - main.ALLOWED_WITH_RUSSIA_INSIDE.includes(v), + main.ALLOWED_WITH_RUSSIA_INSIDE.includes(v), ); notifications.push( - E('p', { class: 'alert-message warning' }, [ - E('strong', {}, _('Russia inside restrictions')), - E('br'), - _( - 'Warning: Russia inside can only be used with %s. %s already in Russia inside and have been removed from selection.', - ).format( - main.ALLOWED_WITH_RUSSIA_INSIDE.map( - (key) => main.DOMAIN_LIST_OPTIONS[key], - ) - .filter((label) => label !== 'Russia inside') - .join(', '), - removedServices.join(', '), - ), - ]), + E('p', { class: 'alert-message warning' }, [ + E('strong', {}, _('Russia inside restrictions')), + E('br'), + _( + 'Warning: Russia inside can only be used with %s. %s already in Russia inside and have been removed from selection.', + ).format( + main.ALLOWED_WITH_RUSSIA_INSIDE.map( + (key) => main.DOMAIN_LIST_OPTIONS[key], + ) + .filter((label) => label !== 'Russia inside') + .join(', '), + removedServices.join(', '), + ), + ]), ); } } @@ -410,7 +410,7 @@ function createConfigSection(section) { } notifications.forEach((notification) => - ui.addNotification(null, notification), + ui.addNotification(null, notification), ); lastValues = newValues; } catch (e) { @@ -421,11 +421,11 @@ function createConfigSection(section) { }; o = s.taboption( - 'basic', - form.ListValue, - 'user_domain_list_type', - _('User Domain List Type'), - _('Select how to add your custom domains'), + 'basic', + form.ListValue, + 'user_domain_list_type', + _('User Domain List Type'), + _('Select how to add your custom domains'), ); o.value('disabled', _('Disabled')); o.value('dynamic', _('Dynamic List')); @@ -435,13 +435,13 @@ function createConfigSection(section) { o.ucisection = s.section; o = s.taboption( - 'basic', - form.DynamicList, - 'user_domains', - _('User Domains'), - _( - 'Enter domain names without protocols (example: sub.example.com or example.com)', - ), + 'basic', + form.DynamicList, + 'user_domains', + _('User Domains'), + _( + 'Enter domain names without protocols (example: sub.example.com or example.com)', + ), ); o.placeholder = 'Domains list'; o.depends('user_domain_list_type', 'dynamic'); @@ -463,16 +463,16 @@ function createConfigSection(section) { }; o = s.taboption( - 'basic', - form.TextValue, - 'user_domains_text', - _('User Domains List'), - _( - 'Enter domain names separated by comma, space or newline. You can add comments after //', - ), + 'basic', + form.TextValue, + 'user_domains_text', + _('User Domains List'), + _( + 'Enter domain names separated by comma, space or newline. You can add comments after //', + ), ); o.placeholder = - 'example.com, sub.example.com\n// Social networks\ndomain.com test.com // personal domains'; + 'example.com, sub.example.com\n// Social networks\ndomain.com test.com // personal domains'; o.depends('user_domain_list_type', 'text'); o.rows = 8; o.rmempty = false; @@ -487,7 +487,7 @@ function createConfigSection(section) { if (!domains.length) { return _( - 'At least one valid domain must be specified. Comments-only content is not allowed.', + 'At least one valid domain must be specified. Comments-only content is not allowed.', ); } @@ -495,8 +495,8 @@ function createConfigSection(section) { if (!valid) { const errors = results - .filter((validation) => !validation.valid) // Leave only failed validations - .map((validation) => _(`${validation.value}: ${validation.message}`)); // Collect validation errors + .filter((validation) => !validation.valid) // Leave only failed validations + .map((validation) => _(`${validation.value}: ${validation.message}`)); // Collect validation errors return [_('Validation errors:'), ...errors].join('\n'); } @@ -505,22 +505,22 @@ function createConfigSection(section) { }; o = s.taboption( - 'basic', - form.Flag, - 'local_domain_lists_enabled', - _('Local Domain Lists'), - _('Use the list from the router filesystem'), + 'basic', + form.Flag, + 'local_domain_lists_enabled', + _('Local Domain Lists'), + _('Use the list from the router filesystem'), ); o.default = '0'; o.rmempty = false; o.ucisection = s.section; o = s.taboption( - 'basic', - form.DynamicList, - 'local_domain_lists', - _('Local Domain List Paths'), - _('Enter the list file path'), + 'basic', + form.DynamicList, + 'local_domain_lists', + _('Local Domain List Paths'), + _('Enter the list file path'), ); o.placeholder = '/path/file.lst'; o.depends('local_domain_lists_enabled', '1'); @@ -542,22 +542,22 @@ function createConfigSection(section) { }; o = s.taboption( - 'basic', - form.Flag, - 'remote_domain_lists_enabled', - _('Remote Domain Lists'), - _('Download and use domain lists from remote URLs'), + 'basic', + form.Flag, + 'remote_domain_lists_enabled', + _('Remote Domain Lists'), + _('Download and use domain lists from remote URLs'), ); o.default = '0'; o.rmempty = false; o.ucisection = s.section; o = s.taboption( - 'basic', - form.DynamicList, - 'remote_domain_lists', - _('Remote Domain URLs'), - _('Enter full URLs starting with http:// or https://'), + 'basic', + form.DynamicList, + 'remote_domain_lists', + _('Remote Domain URLs'), + _('Enter full URLs starting with http:// or https://'), ); o.placeholder = 'URL'; o.depends('remote_domain_lists_enabled', '1'); @@ -579,22 +579,22 @@ function createConfigSection(section) { }; o = s.taboption( - 'basic', - form.Flag, - 'local_subnet_lists_enabled', - _('Local Subnet Lists'), - _('Use the list from the router filesystem'), + 'basic', + form.Flag, + 'local_subnet_lists_enabled', + _('Local Subnet Lists'), + _('Use the list from the router filesystem'), ); o.default = '0'; o.rmempty = false; o.ucisection = s.section; o = s.taboption( - 'basic', - form.DynamicList, - 'local_subnet_lists', - _('Local Subnet List Paths'), - _('Enter the list file path'), + 'basic', + form.DynamicList, + 'local_subnet_lists', + _('Local Subnet List Paths'), + _('Enter the list file path'), ); o.placeholder = '/path/file.lst'; o.depends('local_subnet_lists_enabled', '1'); @@ -616,11 +616,11 @@ function createConfigSection(section) { }; o = s.taboption( - 'basic', - form.ListValue, - 'user_subnet_list_type', - _('User Subnet List Type'), - _('Select how to add your custom subnets'), + 'basic', + form.ListValue, + 'user_subnet_list_type', + _('User Subnet List Type'), + _('Select how to add your custom subnets'), ); o.value('disabled', _('Disabled')); o.value('dynamic', _('Dynamic List')); @@ -630,13 +630,13 @@ function createConfigSection(section) { o.ucisection = s.section; o = s.taboption( - 'basic', - form.DynamicList, - 'user_subnets', - _('User Subnets'), - _( - 'Enter subnets in CIDR notation (example: 103.21.244.0/22) or single IP addresses', - ), + 'basic', + form.DynamicList, + 'user_subnets', + _('User Subnets'), + _( + 'Enter subnets in CIDR notation (example: 103.21.244.0/22) or single IP addresses', + ), ); o.placeholder = 'IP or subnet'; o.depends('user_subnet_list_type', 'dynamic'); @@ -658,16 +658,16 @@ function createConfigSection(section) { }; o = s.taboption( - 'basic', - form.TextValue, - 'user_subnets_text', - _('User Subnets List'), - _( - 'Enter subnets in CIDR notation or single IP addresses, separated by comma, space or newline. You can add comments after //', - ), + 'basic', + form.TextValue, + 'user_subnets_text', + _('User Subnets List'), + _( + 'Enter subnets in CIDR notation or single IP addresses, separated by comma, space or newline. You can add comments after //', + ), ); o.placeholder = - '103.21.244.0/22\n// Google DNS\n8.8.8.8\n1.1.1.1/32, 9.9.9.9 // Cloudflare and Quad9'; + '103.21.244.0/22\n// Google DNS\n8.8.8.8\n1.1.1.1/32, 9.9.9.9 // Cloudflare and Quad9'; o.depends('user_subnet_list_type', 'text'); o.rows = 10; o.rmempty = false; @@ -682,7 +682,7 @@ function createConfigSection(section) { if (!subnets.length) { return _( - 'At least one valid subnet or IP must be specified. Comments-only content is not allowed.', + 'At least one valid subnet or IP must be specified. Comments-only content is not allowed.', ); } @@ -690,8 +690,8 @@ function createConfigSection(section) { if (!valid) { const errors = results - .filter((validation) => !validation.valid) // Leave only failed validations - .map((validation) => _(`${validation.value}: ${validation.message}`)); // Collect validation errors + .filter((validation) => !validation.valid) // Leave only failed validations + .map((validation) => _(`${validation.value}: ${validation.message}`)); // Collect validation errors return [_('Validation errors:'), ...errors].join('\n'); } @@ -700,22 +700,22 @@ function createConfigSection(section) { }; o = s.taboption( - 'basic', - form.Flag, - 'remote_subnet_lists_enabled', - _('Remote Subnet Lists'), - _('Download and use subnet lists from remote URLs'), + 'basic', + form.Flag, + 'remote_subnet_lists_enabled', + _('Remote Subnet Lists'), + _('Download and use subnet lists from remote URLs'), ); o.default = '0'; o.rmempty = false; o.ucisection = s.section; o = s.taboption( - 'basic', - form.DynamicList, - 'remote_subnet_lists', - _('Remote Subnet URLs'), - _('Enter full URLs starting with http:// or https://'), + 'basic', + form.DynamicList, + 'remote_subnet_lists', + _('Remote Subnet URLs'), + _('Enter full URLs starting with http:// or https://'), ); o.placeholder = 'URL'; o.depends('remote_subnet_lists_enabled', '1'); @@ -737,24 +737,24 @@ function createConfigSection(section) { }; o = s.taboption( - 'basic', - form.Flag, - 'all_traffic_from_ip_enabled', - _('IP for full redirection'), - _( - 'Specify local IP addresses whose traffic will always use the configured route', - ), + 'basic', + form.Flag, + 'all_traffic_from_ip_enabled', + _('IP for full redirection'), + _( + 'Specify local IP addresses whose traffic will always use the configured route', + ), ); o.default = '0'; o.rmempty = false; o.ucisection = s.section; o = s.taboption( - 'basic', - form.DynamicList, - 'all_traffic_ip', - _('Local IPs'), - _('Enter valid IPv4 addresses'), + 'basic', + form.DynamicList, + 'all_traffic_ip', + _('Local IPs'), + _('Enter valid IPv4 addresses'), ); o.placeholder = 'IP'; o.depends('all_traffic_from_ip_enabled', '1'); diff --git a/luci-app-podkop/htdocs/luci-static/resources/view/podkop/dashboardTab.js b/luci-app-podkop/htdocs/luci-static/resources/view/podkop/dashboardTab.js index f1659eb..a5056dc 100644 --- a/luci-app-podkop/htdocs/luci-static/resources/view/podkop/dashboardTab.js +++ b/luci-app-podkop/htdocs/luci-static/resources/view/podkop/dashboardTab.js @@ -8,19 +8,19 @@ 'require view.podkop.main as main'; function createDashboardSection(mainSection) { - let o = mainSection.tab('dashboard', _('Dashboard')); + let o = mainSection.tab('dashboard', _('Dashboard')); - o = mainSection.taboption('dashboard', form.DummyValue, '_status'); - o.rawhtml = true; - o.cfgvalue = () => { - main.initDashboardController() + o = mainSection.taboption('dashboard', form.DummyValue, '_status'); + o.rawhtml = true; + o.cfgvalue = () => { + main.initDashboardController(); - return main.renderDashboard() - }; + return main.renderDashboard(); + }; } const EntryPoint = { - createDashboardSection, -} + createDashboardSection, +}; return baseclass.extend(EntryPoint); diff --git a/luci-app-podkop/htdocs/luci-static/resources/view/podkop/podkop.js b/luci-app-podkop/htdocs/luci-static/resources/view/podkop/podkop.js index ce5222d..c84ff91 100644 --- a/luci-app-podkop/htdocs/luci-static/resources/view/podkop/podkop.js +++ b/luci-app-podkop/htdocs/luci-static/resources/view/podkop/podkop.js @@ -10,88 +10,73 @@ 'require view.podkop.main as main'; const EntryNode = { - async render() { - main.injectGlobalStyles(); + async render() { + main.injectGlobalStyles(); - // main.getClashVersion() - // .then(result => console.log('getClashVersion - then', result)) - // .catch(err => console.log('getClashVersion - err', err)) - // .finally(() => console.log('getClashVersion - finish')); - // - // main.getClashConfig() - // .then(result => console.log('getClashConfig - then', result)) - // .catch(err => console.log('getClashConfig - err', err)) - // .finally(() => console.log('getClashConfig - finish')); - // - // main.getClashProxies() - // .then(result => console.log('getClashProxies - then', result)) - // .catch(err => console.log('getClashProxies - err', err)) - // .finally(() => console.log('getClashProxies - finish')); + const podkopFormMap = new form.Map('podkop', '', null, ['main', 'extra']); - const podkopFormMap = new form.Map('podkop', '', null, ['main', 'extra']); + // Main Section + const mainSection = podkopFormMap.section(form.TypedSection, 'main'); + mainSection.anonymous = true; - // Main Section - const mainSection = podkopFormMap.section(form.TypedSection, 'main'); - mainSection.anonymous = true; + configSection.createConfigSection(mainSection); - configSection.createConfigSection(mainSection); + // Additional Settings Tab (main section) + additionalTab.createAdditionalSection(mainSection); - // Additional Settings Tab (main section) - additionalTab.createAdditionalSection(mainSection); + // Diagnostics Tab (main section) + diagnosticTab.createDiagnosticsSection(mainSection); + const podkopFormMapPromise = podkopFormMap.render().then((node) => { + // Set up diagnostics event handlers + diagnosticTab.setupDiagnosticsEventHandlers(node); - // Diagnostics Tab (main section) - diagnosticTab.createDiagnosticsSection(mainSection); - const podkopFormMapPromise = podkopFormMap.render().then(node => { - // Set up diagnostics event handlers - diagnosticTab.setupDiagnosticsEventHandlers(node); + // Start critical error polling for all tabs + utils.startErrorPolling(); - // Start critical error polling for all tabs + // Add event listener to keep error polling active when switching tabs + const tabs = node.querySelectorAll('.cbi-tabmenu'); + if (tabs.length > 0) { + tabs[0].addEventListener('click', function (e) { + const tab = e.target.closest('.cbi-tab'); + if (tab) { + // Ensure error polling continues when switching tabs utils.startErrorPolling(); - - // Add event listener to keep error polling active when switching tabs - const tabs = node.querySelectorAll('.cbi-tabmenu'); - if (tabs.length > 0) { - tabs[0].addEventListener('click', function (e) { - const tab = e.target.closest('.cbi-tab'); - if (tab) { - // Ensure error polling continues when switching tabs - utils.startErrorPolling(); - } - }); - } - - // Add visibility change handler to manage error polling - document.addEventListener('visibilitychange', function () { - if (document.hidden) { - utils.stopErrorPolling(); - } else { - utils.startErrorPolling(); - } - }); - - return node; + } }); + } - // Extra Section - const extraSection = podkopFormMap.section(form.TypedSection, 'extra', _('Extra configurations')); - extraSection.anonymous = false; - extraSection.addremove = true; - extraSection.addbtntitle = _('Add Section'); - extraSection.multiple = true; - configSection.createConfigSection(extraSection); + // Add visibility change handler to manage error polling + document.addEventListener('visibilitychange', function () { + if (document.hidden) { + utils.stopErrorPolling(); + } else { + utils.startErrorPolling(); + } + }); + return node; + }); - // Initial dashboard render - dashboardTab.createDashboardSection(mainSection); + // Extra Section + const extraSection = podkopFormMap.section( + form.TypedSection, + 'extra', + _('Extra configurations'), + ); + extraSection.anonymous = false; + extraSection.addremove = true; + extraSection.addbtntitle = _('Add Section'); + extraSection.multiple = true; + configSection.createConfigSection(extraSection); - // Inject dashboard actualizer logic - // main.initDashboardController(); + // Initial dashboard render + dashboardTab.createDashboardSection(mainSection); - // Inject core service - main.coreService(); + // Inject core service + main.coreService(); - return podkopFormMapPromise; - } -} + return podkopFormMapPromise; + }, +}; return view.extend(EntryNode); From c8c00254701f79ca4e3deb97e366451c9d700f70 Mon Sep 17 00:00:00 2001 From: divocat Date: Tue, 7 Oct 2025 00:52:53 +0300 Subject: [PATCH 44/49] feat: set clash delay timeout to 5s --- fe-app-podkop/src/clash/methods/triggerLatencyTest.ts | 2 +- .../htdocs/luci-static/resources/view/podkop/main.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/fe-app-podkop/src/clash/methods/triggerLatencyTest.ts b/fe-app-podkop/src/clash/methods/triggerLatencyTest.ts index 94bf335..b7fffd9 100644 --- a/fe-app-podkop/src/clash/methods/triggerLatencyTest.ts +++ b/fe-app-podkop/src/clash/methods/triggerLatencyTest.ts @@ -4,7 +4,7 @@ import { getClashApiUrl } from '../../helpers'; export async function triggerLatencyGroupTest( tag: string, - timeout: number = 2000, + timeout: number = 5000, url: string = 'https://www.gstatic.com/generate_204', ): Promise> { return createBaseApiRequest(() => diff --git a/luci-app-podkop/htdocs/luci-static/resources/view/podkop/main.js b/luci-app-podkop/htdocs/luci-static/resources/view/podkop/main.js index 502c013..bb8b1ad 100644 --- a/luci-app-podkop/htdocs/luci-static/resources/view/podkop/main.js +++ b/luci-app-podkop/htdocs/luci-static/resources/view/podkop/main.js @@ -866,7 +866,7 @@ async function triggerProxySelector(selector, outbound) { } // src/clash/methods/triggerLatencyTest.ts -async function triggerLatencyGroupTest(tag, timeout = 2e3, url = "https://www.gstatic.com/generate_204") { +async function triggerLatencyGroupTest(tag, timeout = 5e3, url = "https://www.gstatic.com/generate_204") { return createBaseApiRequest( () => fetch( `${getClashApiUrl()}/group/${tag}/delay?url=${encodeURIComponent(url)}&timeout=${timeout}`, From 1e6c827f2b50a741dfdd08c74dcec13435c1b732 Mon Sep 17 00:00:00 2001 From: divocat Date: Tue, 7 Oct 2025 01:05:49 +0300 Subject: [PATCH 45/49] fix: cleanup global styles --- fe-app-podkop/src/styles.ts | 64 ++++++------------- .../luci-static/resources/view/podkop/main.js | 64 ++++++------------- 2 files changed, 36 insertions(+), 92 deletions(-) diff --git a/fe-app-podkop/src/styles.ts b/fe-app-podkop/src/styles.ts index 69c6be0..b135ef5 100644 --- a/fe-app-podkop/src/styles.ts +++ b/fe-app-podkop/src/styles.ts @@ -28,6 +28,8 @@ export const GlobalStyles = ` width: 100%; } +/* Dashboard styles */ + .pdk_dashboard-page { width: 100%; --dashboard-grid-columns: 4; @@ -39,26 +41,6 @@ export const GlobalStyles = ` } } -/*@media (max-width: 440px) {*/ -/* .pdk_dashboard-page {*/ -/* --dashboard-grid-columns: 1;*/ -/* }*/ -/*}*/ - -.pdk_dashboard-page__title-section { - display: flex; - align-items: center; - justify-content: space-between; - border: 2px var(--background-color-low) solid; - border-radius: 4px; - padding: 0 10px; -} - -.pdk_dashboard-page__title-section__title { - color: var(--text-color-high); - font-weight: 700; -} - .pdk_dashboard-page__widgets-section { margin-top: 10px; display: grid; @@ -67,38 +49,30 @@ export const GlobalStyles = ` } .pdk_dashboard-page__widgets-section__item { - border: 2px var(--background-color-low) solid; + border: 2px var(--background-color-low, lightgray) solid; border-radius: 4px; padding: 10px; } -.pdk_dashboard-page__widgets-section__item__title { - -} +.pdk_dashboard-page__widgets-section__item__title {} -.pdk_dashboard-page__widgets-section__item__row { - -} +.pdk_dashboard-page__widgets-section__item__row {} .pdk_dashboard-page__widgets-section__item__row--success .pdk_dashboard-page__widgets-section__item__row__value { - color: var(--success-color-medium); + color: var(--success-color-medium, green); } .pdk_dashboard-page__widgets-section__item__row--error .pdk_dashboard-page__widgets-section__item__row__value { - color: var(--error-color-medium); + color: var(--error-color-medium, red); } -.pdk_dashboard-page__widgets-section__item__row__key { - -} +.pdk_dashboard-page__widgets-section__item__row__key {} -.pdk_dashboard-page__widgets-section__item__row__value { - -} +.pdk_dashboard-page__widgets-section__item__row__value {} .pdk_dashboard-page__outbound-section { margin-top: 10px; - border: 2px var(--background-color-low) solid; + border: 2px var(--background-color-low, lightgray) solid; border-radius: 4px; padding: 10px; } @@ -122,7 +96,7 @@ export const GlobalStyles = ` } .pdk_dashboard-page__outbound-grid__item { - border: 2px var(--background-color-low) solid; + border: 2px var(--background-color-low, lightgray) solid; border-radius: 4px; padding: 10px; transition: border 0.2s ease; @@ -133,11 +107,11 @@ export const GlobalStyles = ` } .pdk_dashboard-page__outbound-grid__item--selectable:hover { - border-color: var(--primary-color-high); + border-color: var(--primary-color-high, dodgerblue); } .pdk_dashboard-page__outbound-grid__item--active { - border-color: var(--success-color-medium); + border-color: var(--success-color-medium, green); } .pdk_dashboard-page__outbound-grid__item__footer { @@ -147,24 +121,22 @@ export const GlobalStyles = ` margin-top: 10px; } -.pdk_dashboard-page__outbound-grid__item__type { - -} +.pdk_dashboard-page__outbound-grid__item__type {} .pdk_dashboard-page__outbound-grid__item__latency--empty { - color: var(--primary-color-low); + color: var(--primary-color-low, lightgray); } .pdk_dashboard-page__outbound-grid__item__latency--green { - color: var(--success-color-medium); + color: var(--success-color-medium, green); } .pdk_dashboard-page__outbound-grid__item__latency--yellow { - color: var(--warn-color-medium); + color: var(--warn-color-medium, orange); } .pdk_dashboard-page__outbound-grid__item__latency--red { - color: var(--error-color-medium); + color: var(--error-color-medium, red); } .centered { diff --git a/luci-app-podkop/htdocs/luci-static/resources/view/podkop/main.js b/luci-app-podkop/htdocs/luci-static/resources/view/podkop/main.js index bb8b1ad..6701014 100644 --- a/luci-app-podkop/htdocs/luci-static/resources/view/podkop/main.js +++ b/luci-app-podkop/htdocs/luci-static/resources/view/podkop/main.js @@ -376,6 +376,8 @@ var GlobalStyles = ` width: 100%; } +/* Dashboard styles */ + .pdk_dashboard-page { width: 100%; --dashboard-grid-columns: 4; @@ -387,26 +389,6 @@ var GlobalStyles = ` } } -/*@media (max-width: 440px) {*/ -/* .pdk_dashboard-page {*/ -/* --dashboard-grid-columns: 1;*/ -/* }*/ -/*}*/ - -.pdk_dashboard-page__title-section { - display: flex; - align-items: center; - justify-content: space-between; - border: 2px var(--background-color-low) solid; - border-radius: 4px; - padding: 0 10px; -} - -.pdk_dashboard-page__title-section__title { - color: var(--text-color-high); - font-weight: 700; -} - .pdk_dashboard-page__widgets-section { margin-top: 10px; display: grid; @@ -415,38 +397,30 @@ var GlobalStyles = ` } .pdk_dashboard-page__widgets-section__item { - border: 2px var(--background-color-low) solid; + border: 2px var(--background-color-low, lightgray) solid; border-radius: 4px; padding: 10px; } -.pdk_dashboard-page__widgets-section__item__title { - -} +.pdk_dashboard-page__widgets-section__item__title {} -.pdk_dashboard-page__widgets-section__item__row { - -} +.pdk_dashboard-page__widgets-section__item__row {} .pdk_dashboard-page__widgets-section__item__row--success .pdk_dashboard-page__widgets-section__item__row__value { - color: var(--success-color-medium); + color: var(--success-color-medium, green); } .pdk_dashboard-page__widgets-section__item__row--error .pdk_dashboard-page__widgets-section__item__row__value { - color: var(--error-color-medium); + color: var(--error-color-medium, red); } -.pdk_dashboard-page__widgets-section__item__row__key { - -} +.pdk_dashboard-page__widgets-section__item__row__key {} -.pdk_dashboard-page__widgets-section__item__row__value { - -} +.pdk_dashboard-page__widgets-section__item__row__value {} .pdk_dashboard-page__outbound-section { margin-top: 10px; - border: 2px var(--background-color-low) solid; + border: 2px var(--background-color-low, lightgray) solid; border-radius: 4px; padding: 10px; } @@ -470,7 +444,7 @@ var GlobalStyles = ` } .pdk_dashboard-page__outbound-grid__item { - border: 2px var(--background-color-low) solid; + border: 2px var(--background-color-low, lightgray) solid; border-radius: 4px; padding: 10px; transition: border 0.2s ease; @@ -481,11 +455,11 @@ var GlobalStyles = ` } .pdk_dashboard-page__outbound-grid__item--selectable:hover { - border-color: var(--primary-color-high); + border-color: var(--primary-color-high, dodgerblue); } .pdk_dashboard-page__outbound-grid__item--active { - border-color: var(--success-color-medium); + border-color: var(--success-color-medium, green); } .pdk_dashboard-page__outbound-grid__item__footer { @@ -495,24 +469,22 @@ var GlobalStyles = ` margin-top: 10px; } -.pdk_dashboard-page__outbound-grid__item__type { - -} +.pdk_dashboard-page__outbound-grid__item__type {} .pdk_dashboard-page__outbound-grid__item__latency--empty { - color: var(--primary-color-low); + color: var(--primary-color-low, lightgray); } .pdk_dashboard-page__outbound-grid__item__latency--green { - color: var(--success-color-medium); + color: var(--success-color-medium, green); } .pdk_dashboard-page__outbound-grid__item__latency--yellow { - color: var(--warn-color-medium); + color: var(--warn-color-medium, orange); } .pdk_dashboard-page__outbound-grid__item__latency--red { - color: var(--error-color-medium); + color: var(--error-color-medium, red); } .centered { From e0874c3775e0aa90a53183e7ec63a2e4f9ecd80b Mon Sep 17 00:00:00 2001 From: divocat Date: Tue, 7 Oct 2025 16:26:06 +0300 Subject: [PATCH 46/49] refactor: make dashboard widgets reactive --- .../tabs/dashboard/initDashboardController.ts | 273 ++++++--- .../podkop/tabs/dashboard/renderDashboard.ts | 88 ++- ...nderOutboundGroup.ts => renderSections.ts} | 43 +- .../dashboard/{renderer => }/renderWidget.ts | 42 +- .../renderer/renderEmptyOutboundGroup.ts | 10 - fe-app-podkop/src/socket.ts | 39 +- fe-app-podkop/src/store.ts | 64 ++- .../luci-static/resources/view/podkop/main.js | 517 ++++++++++++------ 8 files changed, 748 insertions(+), 328 deletions(-) rename fe-app-podkop/src/podkop/tabs/dashboard/{renderer/renderOutboundGroup.ts => renderSections.ts} (75%) rename fe-app-podkop/src/podkop/tabs/dashboard/{renderer => }/renderWidget.ts (52%) delete mode 100644 fe-app-podkop/src/podkop/tabs/dashboard/renderer/renderEmptyOutboundGroup.ts diff --git a/fe-app-podkop/src/podkop/tabs/dashboard/initDashboardController.ts b/fe-app-podkop/src/podkop/tabs/dashboard/initDashboardController.ts index 17f08ad..8b2ea6a 100644 --- a/fe-app-podkop/src/podkop/tabs/dashboard/initDashboardController.ts +++ b/fe-app-podkop/src/podkop/tabs/dashboard/initDashboardController.ts @@ -3,9 +3,7 @@ import { getPodkopStatus, getSingboxStatus, } from '../../methods'; -import { renderOutboundGroup } from './renderer/renderOutboundGroup'; import { getClashWsUrl, onMount } from '../../../helpers'; -import { renderDashboardWidget } from './renderer/renderWidget'; import { triggerLatencyGroupTest, triggerLatencyProxyTest, @@ -14,63 +12,114 @@ import { import { store, StoreType } from '../../../store'; import { socket } from '../../../socket'; import { prettyBytes } from '../../../helpers/prettyBytes'; -import { renderEmptyOutboundGroup } from './renderer/renderEmptyOutboundGroup'; +import { renderSections } from './renderSections'; +import { renderWidget } from './renderWidget'; // Fetchers async function fetchDashboardSections() { + const prev = store.get().sectionsWidget; + store.set({ - dashboardSections: { - ...store.get().dashboardSections, + sectionsWidget: { + ...prev, failed: false, - loading: true, }, }); const { data, success } = await getDashboardSections(); - store.set({ dashboardSections: { loading: false, data, failed: !success } }); + store.set({ + sectionsWidget: { + loading: false, + failed: !success, + data, + }, + }); } async function fetchServicesInfo() { - const podkop = await getPodkopStatus(); - const singbox = await getSingboxStatus(); + const [podkop, singbox] = await Promise.all([ + getPodkopStatus(), + getSingboxStatus(), + ]); store.set({ - services: { - singbox: singbox.running, - podkop: podkop.enabled, + servicesInfoWidget: { + loading: false, + failed: false, + data: { singbox: singbox.running, podkop: podkop.enabled }, }, }); } async function connectToClashSockets() { - socket.subscribe(`${getClashWsUrl()}/traffic?token=`, (msg) => { - const parsedMsg = JSON.parse(msg); + socket.subscribe( + `${getClashWsUrl()}/traffic?token=`, + (msg) => { + const parsedMsg = JSON.parse(msg); - store.set({ - traffic: { up: parsedMsg.up, down: parsedMsg.down }, - }); - }); + store.set({ + bandwidthWidget: { + loading: false, + failed: false, + data: { up: parsedMsg.up, down: parsedMsg.down }, + }, + }); + }, + (_err) => { + store.set({ + bandwidthWidget: { + loading: false, + failed: true, + data: { up: 0, down: 0 }, + }, + }); + }, + ); - socket.subscribe(`${getClashWsUrl()}/connections?token=`, (msg) => { - const parsedMsg = JSON.parse(msg); + socket.subscribe( + `${getClashWsUrl()}/connections?token=`, + (msg) => { + const parsedMsg = JSON.parse(msg); - store.set({ - connections: { - connections: parsedMsg.connections, - downloadTotal: parsedMsg.downloadTotal, - uploadTotal: parsedMsg.uploadTotal, - memory: parsedMsg.memory, - }, - }); - }); - - socket.subscribe(`${getClashWsUrl()}/memory?token=`, (msg) => { - store.set({ - memory: { inuse: msg.inuse, oslimit: msg.oslimit }, - }); - }); + store.set({ + trafficTotalWidget: { + loading: false, + failed: false, + data: { + downloadTotal: parsedMsg.downloadTotal, + uploadTotal: parsedMsg.uploadTotal, + }, + }, + systemInfoWidget: { + loading: false, + failed: false, + data: { + connections: parsedMsg.connections?.length, + memory: parsedMsg.memory, + }, + }, + }); + }, + (_err) => { + store.set({ + trafficTotalWidget: { + loading: false, + failed: true, + data: { downloadTotal: 0, uploadTotal: 0 }, + }, + systemInfoWidget: { + loading: false, + failed: true, + data: { + connections: 0, + memory: 0, + }, + }, + }); + }, + ); } // Handlers @@ -104,18 +153,31 @@ function replaceTestLatencyButtonsWithSkeleton() { // Renderer -async function renderDashboardSections() { - const dashboardSections = store.get().dashboardSections; +async function renderSectionsWidget() { + console.log('renderSectionsWidget'); + const sectionsWidget = store.get().sectionsWidget; const container = document.getElementById('dashboard-sections-grid'); - if (dashboardSections.failed) { - const rendered = renderEmptyOutboundGroup(); - - return container!.replaceChildren(rendered); + if (sectionsWidget.loading || sectionsWidget.failed) { + const renderedWidget = renderSections({ + loading: sectionsWidget.loading, + failed: sectionsWidget.failed, + section: { + code: '', + displayName: '', + outbounds: [], + withTagSelect: false, + }, + onTestLatency: () => {}, + onChooseOutbound: () => {}, + }); + return container!.replaceChildren(renderedWidget); } - const renderedOutboundGroups = dashboardSections.data.map((section) => - renderOutboundGroup({ + const renderedWidgets = sectionsWidget.data.map((section) => + renderSections({ + loading: sectionsWidget.loading, + failed: sectionsWidget.failed, section, onTestLatency: (tag) => { replaceTestLatencyButtonsWithSkeleton(); @@ -132,18 +194,33 @@ async function renderDashboardSections() { }), ); - container!.replaceChildren(...renderedOutboundGroups); + return container!.replaceChildren(...renderedWidgets); } -async function renderTrafficWidget() { - const traffic = store.get().traffic; +async function renderBandwidthWidget() { + console.log('renderBandwidthWidget'); + const traffic = store.get().bandwidthWidget; const container = document.getElementById('dashboard-widget-traffic'); - const renderedWidget = renderDashboardWidget({ + + if (traffic.loading || traffic.failed) { + const renderedWidget = renderWidget({ + loading: traffic.loading, + failed: traffic.failed, + title: '', + items: [], + }); + + return container!.replaceChildren(renderedWidget); + } + + const renderedWidget = renderWidget({ + loading: traffic.loading, + failed: traffic.failed, title: 'Traffic', items: [ - { key: 'Uplink', value: `${prettyBytes(traffic.up)}/s` }, - { key: 'Downlink', value: `${prettyBytes(traffic.down)}/s` }, + { key: 'Uplink', value: `${prettyBytes(traffic.data.up)}/s` }, + { key: 'Downlink', value: `${prettyBytes(traffic.data.down)}/s` }, ], }); @@ -151,16 +228,34 @@ async function renderTrafficWidget() { } async function renderTrafficTotalWidget() { - const connections = store.get().connections; + console.log('renderTrafficTotalWidget'); + const trafficTotalWidget = store.get().trafficTotalWidget; const container = document.getElementById('dashboard-widget-traffic-total'); - const renderedWidget = renderDashboardWidget({ + + if (trafficTotalWidget.loading || trafficTotalWidget.failed) { + const renderedWidget = renderWidget({ + loading: trafficTotalWidget.loading, + failed: trafficTotalWidget.failed, + title: '', + items: [], + }); + + return container!.replaceChildren(renderedWidget); + } + + const renderedWidget = renderWidget({ + loading: trafficTotalWidget.loading, + failed: trafficTotalWidget.failed, title: 'Traffic Total', items: [ - { key: 'Uplink', value: String(prettyBytes(connections.uploadTotal)) }, + { + key: 'Uplink', + value: String(prettyBytes(trafficTotalWidget.data.uploadTotal)), + }, { key: 'Downlink', - value: String(prettyBytes(connections.downloadTotal)), + value: String(prettyBytes(trafficTotalWidget.data.downloadTotal)), }, ], }); @@ -169,44 +264,77 @@ async function renderTrafficTotalWidget() { } async function renderSystemInfoWidget() { - const connections = store.get().connections; + console.log('renderSystemInfoWidget'); + const systemInfoWidget = store.get().systemInfoWidget; const container = document.getElementById('dashboard-widget-system-info'); - const renderedWidget = renderDashboardWidget({ + + if (systemInfoWidget.loading || systemInfoWidget.failed) { + const renderedWidget = renderWidget({ + loading: systemInfoWidget.loading, + failed: systemInfoWidget.failed, + title: '', + items: [], + }); + + return container!.replaceChildren(renderedWidget); + } + + const renderedWidget = renderWidget({ + loading: systemInfoWidget.loading, + failed: systemInfoWidget.failed, title: 'System info', items: [ { key: 'Active Connections', - value: String(connections.connections.length), + value: String(systemInfoWidget.data.connections), + }, + { + key: 'Memory Usage', + value: String(prettyBytes(systemInfoWidget.data.memory)), }, - { key: 'Memory Usage', value: String(prettyBytes(connections.memory)) }, ], }); container!.replaceChildren(renderedWidget); } -async function renderServiceInfoWidget() { - const services = store.get().services; +async function renderServicesInfoWidget() { + console.log('renderServicesInfoWidget'); + const servicesInfoWidget = store.get().servicesInfoWidget; const container = document.getElementById('dashboard-widget-service-info'); - const renderedWidget = renderDashboardWidget({ + + if (servicesInfoWidget.loading || servicesInfoWidget.failed) { + const renderedWidget = renderWidget({ + loading: servicesInfoWidget.loading, + failed: servicesInfoWidget.failed, + title: '', + items: [], + }); + + return container!.replaceChildren(renderedWidget); + } + + const renderedWidget = renderWidget({ + loading: servicesInfoWidget.loading, + failed: servicesInfoWidget.failed, title: 'Services info', items: [ { key: 'Podkop', - value: services.podkop ? '✔ Enabled' : '✘ Disabled', + value: servicesInfoWidget.data.podkop ? '✔ Enabled' : '✘ Disabled', attributes: { - class: services.podkop + class: servicesInfoWidget.data.podkop ? 'pdk_dashboard-page__widgets-section__item__row--success' : 'pdk_dashboard-page__widgets-section__item__row--error', }, }, { key: 'Sing-box', - value: services.singbox ? '✔ Running' : '✘ Stopped', + value: servicesInfoWidget.data.singbox ? '✔ Running' : '✘ Stopped', attributes: { - class: services.singbox + class: servicesInfoWidget.data.singbox ? 'pdk_dashboard-page__widgets-section__item__row--success' : 'pdk_dashboard-page__widgets-section__item__row--error', }, @@ -222,21 +350,24 @@ async function onStoreUpdate( prev: StoreType, diff: Partial, ) { - if (diff?.dashboardSections) { - renderDashboardSections(); + if (diff.sectionsWidget) { + renderSectionsWidget(); } - if (diff?.traffic) { - renderTrafficWidget(); + if (diff.bandwidthWidget) { + renderBandwidthWidget(); } - if (diff?.connections) { + if (diff.trafficTotalWidget) { renderTrafficTotalWidget(); + } + + if (diff.systemInfoWidget) { renderSystemInfoWidget(); } - if (diff?.services) { - renderServiceInfoWidget(); + if (diff.servicesInfoWidget) { + renderServicesInfoWidget(); } } diff --git a/fe-app-podkop/src/podkop/tabs/dashboard/renderDashboard.ts b/fe-app-podkop/src/podkop/tabs/dashboard/renderDashboard.ts index d3feafc..b4151e2 100644 --- a/fe-app-podkop/src/podkop/tabs/dashboard/renderDashboard.ts +++ b/fe-app-podkop/src/podkop/tabs/dashboard/renderDashboard.ts @@ -1,3 +1,6 @@ +import { renderSections } from './renderSections'; +import { renderWidget } from './renderWidget'; + export function renderDashboard() { return E( 'div', @@ -8,59 +11,44 @@ export function renderDashboard() { [ // Widgets section E('div', { class: 'pdk_dashboard-page__widgets-section' }, [ - E('div', { id: 'dashboard-widget-traffic' }, [ - E( - 'div', - { - id: '', - style: 'height: 78px', - class: 'pdk_dashboard-page__widgets-section__item skeleton', - }, - '', - ), - ]), - E('div', { id: 'dashboard-widget-traffic-total' }, [ - E( - 'div', - { - id: '', - style: 'height: 78px', - class: 'pdk_dashboard-page__widgets-section__item skeleton', - }, - '', - ), - ]), - E('div', { id: 'dashboard-widget-system-info' }, [ - E( - 'div', - { - id: '', - style: 'height: 78px', - class: 'pdk_dashboard-page__widgets-section__item skeleton', - }, - '', - ), - ]), - E('div', { id: 'dashboard-widget-service-info' }, [ - E( - 'div', - { - id: '', - style: 'height: 78px', - class: 'pdk_dashboard-page__widgets-section__item skeleton', - }, - '', - ), - ]), + E( + 'div', + { id: 'dashboard-widget-traffic' }, + renderWidget({ loading: true, failed: false, title: '', items: [] }), + ), + E( + 'div', + { id: 'dashboard-widget-traffic-total' }, + renderWidget({ loading: true, failed: false, title: '', items: [] }), + ), + E( + 'div', + { id: 'dashboard-widget-system-info' }, + renderWidget({ loading: true, failed: false, title: '', items: [] }), + ), + E( + 'div', + { id: 'dashboard-widget-service-info' }, + renderWidget({ loading: true, failed: false, title: '', items: [] }), + ), ]), // All outbounds - E('div', { id: 'dashboard-sections-grid' }, [ - E('div', { - id: 'dashboard-sections-grid-skeleton', - class: 'pdk_dashboard-page__outbound-section skeleton', - style: 'height: 127px', + E( + 'div', + { id: 'dashboard-sections-grid' }, + renderSections({ + loading: true, + failed: false, + section: { + code: '', + displayName: '', + outbounds: [], + withTagSelect: false, + }, + onTestLatency: () => {}, + onChooseOutbound: () => {}, }), - ]), + ), ], ); } diff --git a/fe-app-podkop/src/podkop/tabs/dashboard/renderer/renderOutboundGroup.ts b/fe-app-podkop/src/podkop/tabs/dashboard/renderSections.ts similarity index 75% rename from fe-app-podkop/src/podkop/tabs/dashboard/renderer/renderOutboundGroup.ts rename to fe-app-podkop/src/podkop/tabs/dashboard/renderSections.ts index 7541e26..d3336c2 100644 --- a/fe-app-podkop/src/podkop/tabs/dashboard/renderer/renderOutboundGroup.ts +++ b/fe-app-podkop/src/podkop/tabs/dashboard/renderSections.ts @@ -1,16 +1,37 @@ -import { Podkop } from '../../../types'; +import { Podkop } from '../../types'; -interface IRenderOutboundGroupProps { +interface IRenderSectionsProps { + loading: boolean; + failed: boolean; section: Podkop.OutboundGroup; onTestLatency: (tag: string) => void; onChooseOutbound: (selector: string, tag: string) => void; } -export function renderOutboundGroup({ +function renderFailedState() { + return E( + 'div', + { + class: 'pdk_dashboard-page__outbound-section centered', + style: 'height: 127px', + }, + E('span', {}, 'Dashboard currently unavailable'), + ); +} + +function renderLoadingState() { + return E('div', { + id: 'dashboard-sections-grid-skeleton', + class: 'pdk_dashboard-page__outbound-section skeleton', + style: 'height: 127px', + }); +} + +export function renderDefaultState({ section, - onTestLatency, onChooseOutbound, -}: IRenderOutboundGroupProps) { + onTestLatency, +}: IRenderSectionsProps) { function testLatency() { if (section.withTagSelect) { return onTestLatency(section.code); @@ -90,3 +111,15 @@ export function renderOutboundGroup({ ), ]); } + +export function renderSections(props: IRenderSectionsProps) { + if (props.failed) { + return renderFailedState(); + } + + if (props.loading) { + return renderLoadingState(); + } + + return renderDefaultState(props); +} diff --git a/fe-app-podkop/src/podkop/tabs/dashboard/renderer/renderWidget.ts b/fe-app-podkop/src/podkop/tabs/dashboard/renderWidget.ts similarity index 52% rename from fe-app-podkop/src/podkop/tabs/dashboard/renderer/renderWidget.ts rename to fe-app-podkop/src/podkop/tabs/dashboard/renderWidget.ts index 5575cc3..8ca257c 100644 --- a/fe-app-podkop/src/podkop/tabs/dashboard/renderer/renderWidget.ts +++ b/fe-app-podkop/src/podkop/tabs/dashboard/renderWidget.ts @@ -1,4 +1,6 @@ -interface IRenderWidgetParams { +interface IRenderWidgetProps { + loading: boolean; + failed: boolean; title: string; items: Array<{ key: string; @@ -9,7 +11,31 @@ interface IRenderWidgetParams { }>; } -export function renderDashboardWidget({ title, items }: IRenderWidgetParams) { +function renderFailedState() { + return E( + 'div', + { + id: '', + style: 'height: 78px', + class: 'pdk_dashboard-page__widgets-section__item centered', + }, + 'Currently unavailable', + ); +} + +function renderLoadingState() { + return E( + 'div', + { + id: '', + style: 'height: 78px', + class: 'pdk_dashboard-page__widgets-section__item skeleton', + }, + '', + ); +} + +function renderDefaultState({ title, items }: IRenderWidgetProps) { return E('div', { class: 'pdk_dashboard-page__widgets-section__item' }, [ E( 'b', @@ -38,3 +64,15 @@ export function renderDashboardWidget({ title, items }: IRenderWidgetParams) { ), ]); } + +export function renderWidget(props: IRenderWidgetProps) { + if (props.loading) { + return renderLoadingState(); + } + + if (props.failed) { + return renderFailedState(); + } + + return renderDefaultState(props); +} diff --git a/fe-app-podkop/src/podkop/tabs/dashboard/renderer/renderEmptyOutboundGroup.ts b/fe-app-podkop/src/podkop/tabs/dashboard/renderer/renderEmptyOutboundGroup.ts deleted file mode 100644 index f8739c0..0000000 --- a/fe-app-podkop/src/podkop/tabs/dashboard/renderer/renderEmptyOutboundGroup.ts +++ /dev/null @@ -1,10 +0,0 @@ -export function renderEmptyOutboundGroup() { - return E( - 'div', - { - class: 'pdk_dashboard-page__outbound-section centered', - style: 'height: 127px', - }, - E('span', {}, 'Dashboard currently unavailable'), - ); -} diff --git a/fe-app-podkop/src/socket.ts b/fe-app-podkop/src/socket.ts index 0f6a4fb..5a401b8 100644 --- a/fe-app-podkop/src/socket.ts +++ b/fe-app-podkop/src/socket.ts @@ -1,11 +1,13 @@ // eslint-disable-next-line type Listener = (data: any) => void; +type ErrorListener = (error: Event | string) => void; class SocketManager { private static instance: SocketManager; private sockets = new Map(); private listeners = new Map>(); private connected = new Map(); + private errorListeners = new Map>(); private constructor() {} @@ -23,9 +25,11 @@ class SocketManager { this.sockets.set(url, ws); this.connected.set(url, false); this.listeners.set(url, new Set()); + this.errorListeners.set(url, new Set()); ws.addEventListener('open', () => { this.connected.set(url, true); + console.info(`Connected: ${url}`); }); ws.addEventListener('message', (event) => { @@ -43,23 +47,33 @@ class SocketManager { ws.addEventListener('close', () => { this.connected.set(url, false); - console.warn(`⚠️ Disconnected: ${url}`); + console.warn(`Disconnected: ${url}`); + this.triggerError(url, 'Connection closed'); }); ws.addEventListener('error', (err) => { - console.error(`❌ Socket error for ${url}:`, err); + console.error(`Socket error for ${url}:`, err); + this.triggerError(url, err); }); } - subscribe(url: string, listener: Listener): void { + subscribe(url: string, listener: Listener, onError?: ErrorListener): void { if (!this.sockets.has(url)) { this.connect(url); } + this.listeners.get(url)?.add(listener); + + if (onError) { + this.errorListeners.get(url)?.add(onError); + } } - unsubscribe(url: string, listener: Listener): void { + unsubscribe(url: string, listener: Listener, onError?: ErrorListener): void { this.listeners.get(url)?.delete(listener); + if (onError) { + this.errorListeners.get(url)?.delete(onError); + } } // eslint-disable-next-line @@ -68,7 +82,8 @@ class SocketManager { if (ws && this.connected.get(url)) { ws.send(typeof data === 'string' ? data : JSON.stringify(data)); } else { - console.warn(`⚠️ Cannot send: not connected to ${url}`); + console.warn(`Cannot send: not connected to ${url}`); + this.triggerError(url, 'Not connected'); } } @@ -78,6 +93,7 @@ class SocketManager { ws.close(); this.sockets.delete(url); this.listeners.delete(url); + this.errorListeners.delete(url); this.connected.delete(url); } } @@ -87,6 +103,19 @@ class SocketManager { this.disconnect(url); } } + + private triggerError(url: string, err: Event | string): void { + const handlers = this.errorListeners.get(url); + if (handlers) { + for (const cb of handlers) { + try { + cb(err); + } catch (e) { + console.error(`Error handler threw for ${url}:`, e); + } + } + } + } } export const socket = SocketManager.getInstance(); diff --git a/fe-app-podkop/src/store.ts b/fe-app-podkop/src/store.ts index 8a2a750..4f5f4e8 100644 --- a/fe-app-podkop/src/store.ts +++ b/fe-app-podkop/src/store.ts @@ -117,22 +117,30 @@ export interface StoreType { current: string; all: string[]; }; - dashboardSections: { + bandwidthWidget: { loading: boolean; - data: Podkop.OutboundGroup[]; failed: boolean; + data: { up: number; down: number }; }; - traffic: { up: number; down: number }; - memory: { inuse: number; oslimit: number }; - connections: { - connections: unknown[]; - downloadTotal: number; - memory: number; - uploadTotal: number; + trafficTotalWidget: { + loading: boolean; + failed: boolean; + data: { downloadTotal: number; uploadTotal: number }; }; - services: { - singbox: number; - podkop: number; + systemInfoWidget: { + loading: boolean; + failed: boolean; + data: { connections: number; memory: number }; + }; + servicesInfoWidget: { + loading: boolean; + failed: boolean; + data: { singbox: number; podkop: number }; + }; + sectionsWidget: { + loading: boolean; + failed: boolean; + data: Podkop.OutboundGroup[]; }; } @@ -141,19 +149,31 @@ const initialStore: StoreType = { current: '', all: [], }, - dashboardSections: { - data: [], + bandwidthWidget: { loading: true, + failed: false, + data: { up: 0, down: 0 }, }, - traffic: { up: -1, down: -1 }, - memory: { inuse: -1, oslimit: -1 }, - connections: { - connections: [], - memory: -1, - downloadTotal: -1, - uploadTotal: -1, + trafficTotalWidget: { + loading: true, + failed: false, + data: { downloadTotal: 0, uploadTotal: 0 }, + }, + systemInfoWidget: { + loading: true, + failed: false, + data: { connections: 0, memory: 0 }, + }, + servicesInfoWidget: { + loading: true, + failed: false, + data: { singbox: 0, podkop: 0 }, + }, + sectionsWidget: { + loading: true, + failed: false, + data: [], }, - services: { singbox: -1, podkop: -1 }, }; export const store = new Store(initialStore); diff --git a/luci-app-podkop/htdocs/luci-static/resources/view/podkop/main.js b/luci-app-podkop/htdocs/luci-static/resources/view/podkop/main.js index 6701014..a8f101b 100644 --- a/luci-app-podkop/htdocs/luci-static/resources/view/podkop/main.js +++ b/luci-app-podkop/htdocs/luci-static/resources/view/podkop/main.js @@ -1166,19 +1166,31 @@ var initialStore = { current: "", all: [] }, - dashboardSections: { - data: [], - loading: true + bandwidthWidget: { + loading: true, + failed: false, + data: { up: 0, down: 0 } }, - traffic: { up: -1, down: -1 }, - memory: { inuse: -1, oslimit: -1 }, - connections: { - connections: [], - memory: -1, - downloadTotal: -1, - uploadTotal: -1 + trafficTotalWidget: { + loading: true, + failed: false, + data: { downloadTotal: 0, uploadTotal: 0 } }, - services: { singbox: -1, podkop: -1 } + systemInfoWidget: { + loading: true, + failed: false, + data: { connections: 0, memory: 0 } + }, + servicesInfoWidget: { + loading: true, + failed: false, + data: { singbox: 0, podkop: 0 } + }, + sectionsWidget: { + loading: true, + failed: false, + data: [] + } }; var store = new Store(initialStore); @@ -1194,79 +1206,28 @@ function coreService() { }); } -// src/podkop/tabs/dashboard/renderDashboard.ts -function renderDashboard() { +// src/podkop/tabs/dashboard/renderSections.ts +function renderFailedState() { return E( "div", { - id: "dashboard-status", - class: "pdk_dashboard-page" + class: "pdk_dashboard-page__outbound-section centered", + style: "height: 127px" }, - [ - // Widgets section - E("div", { class: "pdk_dashboard-page__widgets-section" }, [ - E("div", { id: "dashboard-widget-traffic" }, [ - E( - "div", - { - id: "", - style: "height: 78px", - class: "pdk_dashboard-page__widgets-section__item skeleton" - }, - "" - ) - ]), - E("div", { id: "dashboard-widget-traffic-total" }, [ - E( - "div", - { - id: "", - style: "height: 78px", - class: "pdk_dashboard-page__widgets-section__item skeleton" - }, - "" - ) - ]), - E("div", { id: "dashboard-widget-system-info" }, [ - E( - "div", - { - id: "", - style: "height: 78px", - class: "pdk_dashboard-page__widgets-section__item skeleton" - }, - "" - ) - ]), - E("div", { id: "dashboard-widget-service-info" }, [ - E( - "div", - { - id: "", - style: "height: 78px", - class: "pdk_dashboard-page__widgets-section__item skeleton" - }, - "" - ) - ]) - ]), - // All outbounds - E("div", { id: "dashboard-sections-grid" }, [ - E("div", { - id: "dashboard-sections-grid-skeleton", - class: "pdk_dashboard-page__outbound-section skeleton", - style: "height: 127px" - }) - ]) - ] + E("span", {}, "Dashboard currently unavailable") ); } - -// src/podkop/tabs/dashboard/renderer/renderOutboundGroup.ts -function renderOutboundGroup({ +function renderLoadingState() { + return E("div", { + id: "dashboard-sections-grid-skeleton", + class: "pdk_dashboard-page__outbound-section skeleton", + style: "height: 127px" + }); +} +function renderDefaultState({ section, - onTestLatency, - onChooseOutbound + onChooseOutbound, + onTestLatency }) { function testLatency() { if (section.withTagSelect) { @@ -1338,9 +1299,40 @@ function renderOutboundGroup({ ) ]); } +function renderSections(props) { + if (props.failed) { + return renderFailedState(); + } + if (props.loading) { + return renderLoadingState(); + } + return renderDefaultState(props); +} -// src/podkop/tabs/dashboard/renderer/renderWidget.ts -function renderDashboardWidget({ title, items }) { +// src/podkop/tabs/dashboard/renderWidget.ts +function renderFailedState2() { + return E( + "div", + { + id: "", + style: "height: 78px", + class: "pdk_dashboard-page__widgets-section__item centered" + }, + "Currently unavailable" + ); +} +function renderLoadingState2() { + return E( + "div", + { + id: "", + style: "height: 78px", + class: "pdk_dashboard-page__widgets-section__item skeleton" + }, + "" + ); +} +function renderDefaultState2({ title, items }) { return E("div", { class: "pdk_dashboard-page__widgets-section__item" }, [ E( "b", @@ -1369,6 +1361,70 @@ function renderDashboardWidget({ title, items }) { ) ]); } +function renderWidget(props) { + if (props.loading) { + return renderLoadingState2(); + } + if (props.failed) { + return renderFailedState2(); + } + return renderDefaultState2(props); +} + +// src/podkop/tabs/dashboard/renderDashboard.ts +function renderDashboard() { + return E( + "div", + { + id: "dashboard-status", + class: "pdk_dashboard-page" + }, + [ + // Widgets section + E("div", { class: "pdk_dashboard-page__widgets-section" }, [ + E( + "div", + { id: "dashboard-widget-traffic" }, + renderWidget({ loading: true, failed: false, title: "", items: [] }) + ), + E( + "div", + { id: "dashboard-widget-traffic-total" }, + renderWidget({ loading: true, failed: false, title: "", items: [] }) + ), + E( + "div", + { id: "dashboard-widget-system-info" }, + renderWidget({ loading: true, failed: false, title: "", items: [] }) + ), + E( + "div", + { id: "dashboard-widget-service-info" }, + renderWidget({ loading: true, failed: false, title: "", items: [] }) + ) + ]), + // All outbounds + E( + "div", + { id: "dashboard-sections-grid" }, + renderSections({ + loading: true, + failed: false, + section: { + code: "", + displayName: "", + outbounds: [], + withTagSelect: false + }, + onTestLatency: () => { + }, + onChooseOutbound: () => { + } + }) + ) + ] + ); +} // src/socket.ts var SocketManager = class _SocketManager { @@ -1376,6 +1432,7 @@ var SocketManager = class _SocketManager { this.sockets = /* @__PURE__ */ new Map(); this.listeners = /* @__PURE__ */ new Map(); this.connected = /* @__PURE__ */ new Map(); + this.errorListeners = /* @__PURE__ */ new Map(); } static getInstance() { if (!_SocketManager.instance) { @@ -1389,8 +1446,10 @@ var SocketManager = class _SocketManager { this.sockets.set(url, ws); this.connected.set(url, false); this.listeners.set(url, /* @__PURE__ */ new Set()); + this.errorListeners.set(url, /* @__PURE__ */ new Set()); ws.addEventListener("open", () => { this.connected.set(url, true); + console.info(`Connected: ${url}`); }); ws.addEventListener("message", (event) => { const handlers = this.listeners.get(url); @@ -1406,20 +1465,28 @@ var SocketManager = class _SocketManager { }); ws.addEventListener("close", () => { this.connected.set(url, false); - console.warn(`\u26A0\uFE0F Disconnected: ${url}`); + console.warn(`Disconnected: ${url}`); + this.triggerError(url, "Connection closed"); }); ws.addEventListener("error", (err) => { - console.error(`\u274C Socket error for ${url}:`, err); + console.error(`Socket error for ${url}:`, err); + this.triggerError(url, err); }); } - subscribe(url, listener) { + subscribe(url, listener, onError) { if (!this.sockets.has(url)) { this.connect(url); } this.listeners.get(url)?.add(listener); + if (onError) { + this.errorListeners.get(url)?.add(onError); + } } - unsubscribe(url, listener) { + unsubscribe(url, listener, onError) { this.listeners.get(url)?.delete(listener); + if (onError) { + this.errorListeners.get(url)?.delete(onError); + } } // eslint-disable-next-line send(url, data) { @@ -1427,7 +1494,8 @@ var SocketManager = class _SocketManager { if (ws && this.connected.get(url)) { ws.send(typeof data === "string" ? data : JSON.stringify(data)); } else { - console.warn(`\u26A0\uFE0F Cannot send: not connected to ${url}`); + console.warn(`Cannot send: not connected to ${url}`); + this.triggerError(url, "Not connected"); } } disconnect(url) { @@ -1436,6 +1504,7 @@ var SocketManager = class _SocketManager { ws.close(); this.sockets.delete(url); this.listeners.delete(url); + this.errorListeners.delete(url); this.connected.delete(url); } } @@ -1444,6 +1513,18 @@ var SocketManager = class _SocketManager { this.disconnect(url); } } + triggerError(url, err) { + const handlers = this.errorListeners.get(url); + if (handlers) { + for (const cb of handlers) { + try { + cb(err); + } catch (e) { + console.error(`Error handler threw for ${url}:`, e); + } + } + } + } }; var socket = SocketManager.getInstance(); @@ -1459,63 +1540,101 @@ function prettyBytes(n) { return n + " " + unit; } -// src/podkop/tabs/dashboard/renderer/renderEmptyOutboundGroup.ts -function renderEmptyOutboundGroup() { - return E( - "div", - { - class: "pdk_dashboard-page__outbound-section centered", - style: "height: 127px" - }, - E("span", {}, "Dashboard currently unavailable") - ); -} - // src/podkop/tabs/dashboard/initDashboardController.ts async function fetchDashboardSections() { + const prev = store.get().sectionsWidget; store.set({ - dashboardSections: { - ...store.get().dashboardSections, - failed: false, - loading: true + sectionsWidget: { + ...prev, + failed: false } }); const { data, success } = await getDashboardSections(); - store.set({ dashboardSections: { loading: false, data, failed: !success } }); + store.set({ + sectionsWidget: { + loading: false, + failed: !success, + data + } + }); } async function fetchServicesInfo() { - const podkop = await getPodkopStatus(); - const singbox = await getSingboxStatus(); + const [podkop, singbox] = await Promise.all([ + getPodkopStatus(), + getSingboxStatus() + ]); store.set({ - services: { - singbox: singbox.running, - podkop: podkop.enabled + servicesInfoWidget: { + loading: false, + failed: false, + data: { singbox: singbox.running, podkop: podkop.enabled } } }); } async function connectToClashSockets() { - socket.subscribe(`${getClashWsUrl()}/traffic?token=`, (msg) => { - const parsedMsg = JSON.parse(msg); - store.set({ - traffic: { up: parsedMsg.up, down: parsedMsg.down } - }); - }); - socket.subscribe(`${getClashWsUrl()}/connections?token=`, (msg) => { - const parsedMsg = JSON.parse(msg); - store.set({ - connections: { - connections: parsedMsg.connections, - downloadTotal: parsedMsg.downloadTotal, - uploadTotal: parsedMsg.uploadTotal, - memory: parsedMsg.memory - } - }); - }); - socket.subscribe(`${getClashWsUrl()}/memory?token=`, (msg) => { - store.set({ - memory: { inuse: msg.inuse, oslimit: msg.oslimit } - }); - }); + socket.subscribe( + `${getClashWsUrl()}/traffic?token=`, + (msg) => { + const parsedMsg = JSON.parse(msg); + store.set({ + bandwidthWidget: { + loading: false, + failed: false, + data: { up: parsedMsg.up, down: parsedMsg.down } + } + }); + }, + (_err) => { + store.set({ + bandwidthWidget: { + loading: false, + failed: true, + data: { up: 0, down: 0 } + } + }); + } + ); + socket.subscribe( + `${getClashWsUrl()}/connections?token=`, + (msg) => { + const parsedMsg = JSON.parse(msg); + store.set({ + trafficTotalWidget: { + loading: false, + failed: false, + data: { + downloadTotal: parsedMsg.downloadTotal, + uploadTotal: parsedMsg.uploadTotal + } + }, + systemInfoWidget: { + loading: false, + failed: false, + data: { + connections: parsedMsg.connections?.length, + memory: parsedMsg.memory + } + } + }); + }, + (_err) => { + store.set({ + trafficTotalWidget: { + loading: false, + failed: true, + data: { downloadTotal: 0, uploadTotal: 0 } + }, + systemInfoWidget: { + loading: false, + failed: true, + data: { + connections: 0, + memory: 0 + } + } + }); + } + ); } async function handleChooseOutbound(selector, tag) { await triggerProxySelector(selector, tag); @@ -1538,15 +1657,31 @@ function replaceTestLatencyButtonsWithSkeleton() { el.replaceWith(newDiv); }); } -async function renderDashboardSections() { - const dashboardSections = store.get().dashboardSections; +async function renderSectionsWidget() { + console.log("renderSectionsWidget"); + const sectionsWidget = store.get().sectionsWidget; const container = document.getElementById("dashboard-sections-grid"); - if (dashboardSections.failed) { - const rendered = renderEmptyOutboundGroup(); - return container.replaceChildren(rendered); + if (sectionsWidget.loading || sectionsWidget.failed) { + const renderedWidget = renderSections({ + loading: sectionsWidget.loading, + failed: sectionsWidget.failed, + section: { + code: "", + displayName: "", + outbounds: [], + withTagSelect: false + }, + onTestLatency: () => { + }, + onChooseOutbound: () => { + } + }); + return container.replaceChildren(renderedWidget); } - const renderedOutboundGroups = dashboardSections.data.map( - (section) => renderOutboundGroup({ + const renderedWidgets = sectionsWidget.data.map( + (section) => renderSections({ + loading: sectionsWidget.loading, + failed: sectionsWidget.failed, section, onTestLatency: (tag) => { replaceTestLatencyButtonsWithSkeleton(); @@ -1560,68 +1695,122 @@ async function renderDashboardSections() { } }) ); - container.replaceChildren(...renderedOutboundGroups); + return container.replaceChildren(...renderedWidgets); } -async function renderTrafficWidget() { - const traffic = store.get().traffic; +async function renderBandwidthWidget() { + console.log("renderBandwidthWidget"); + const traffic = store.get().bandwidthWidget; const container = document.getElementById("dashboard-widget-traffic"); - const renderedWidget = renderDashboardWidget({ + if (traffic.loading || traffic.failed) { + const renderedWidget2 = renderWidget({ + loading: traffic.loading, + failed: traffic.failed, + title: "", + items: [] + }); + return container.replaceChildren(renderedWidget2); + } + const renderedWidget = renderWidget({ + loading: traffic.loading, + failed: traffic.failed, title: "Traffic", items: [ - { key: "Uplink", value: `${prettyBytes(traffic.up)}/s` }, - { key: "Downlink", value: `${prettyBytes(traffic.down)}/s` } + { key: "Uplink", value: `${prettyBytes(traffic.data.up)}/s` }, + { key: "Downlink", value: `${prettyBytes(traffic.data.down)}/s` } ] }); container.replaceChildren(renderedWidget); } async function renderTrafficTotalWidget() { - const connections = store.get().connections; + console.log("renderTrafficTotalWidget"); + const trafficTotalWidget = store.get().trafficTotalWidget; const container = document.getElementById("dashboard-widget-traffic-total"); - const renderedWidget = renderDashboardWidget({ + if (trafficTotalWidget.loading || trafficTotalWidget.failed) { + const renderedWidget2 = renderWidget({ + loading: trafficTotalWidget.loading, + failed: trafficTotalWidget.failed, + title: "", + items: [] + }); + return container.replaceChildren(renderedWidget2); + } + const renderedWidget = renderWidget({ + loading: trafficTotalWidget.loading, + failed: trafficTotalWidget.failed, title: "Traffic Total", items: [ - { key: "Uplink", value: String(prettyBytes(connections.uploadTotal)) }, + { + key: "Uplink", + value: String(prettyBytes(trafficTotalWidget.data.uploadTotal)) + }, { key: "Downlink", - value: String(prettyBytes(connections.downloadTotal)) + value: String(prettyBytes(trafficTotalWidget.data.downloadTotal)) } ] }); container.replaceChildren(renderedWidget); } async function renderSystemInfoWidget() { - const connections = store.get().connections; + console.log("renderSystemInfoWidget"); + const systemInfoWidget = store.get().systemInfoWidget; const container = document.getElementById("dashboard-widget-system-info"); - const renderedWidget = renderDashboardWidget({ + if (systemInfoWidget.loading || systemInfoWidget.failed) { + const renderedWidget2 = renderWidget({ + loading: systemInfoWidget.loading, + failed: systemInfoWidget.failed, + title: "", + items: [] + }); + return container.replaceChildren(renderedWidget2); + } + const renderedWidget = renderWidget({ + loading: systemInfoWidget.loading, + failed: systemInfoWidget.failed, title: "System info", items: [ { key: "Active Connections", - value: String(connections.connections.length) + value: String(systemInfoWidget.data.connections) }, - { key: "Memory Usage", value: String(prettyBytes(connections.memory)) } + { + key: "Memory Usage", + value: String(prettyBytes(systemInfoWidget.data.memory)) + } ] }); container.replaceChildren(renderedWidget); } -async function renderServiceInfoWidget() { - const services = store.get().services; +async function renderServicesInfoWidget() { + console.log("renderServicesInfoWidget"); + const servicesInfoWidget = store.get().servicesInfoWidget; const container = document.getElementById("dashboard-widget-service-info"); - const renderedWidget = renderDashboardWidget({ + if (servicesInfoWidget.loading || servicesInfoWidget.failed) { + const renderedWidget2 = renderWidget({ + loading: servicesInfoWidget.loading, + failed: servicesInfoWidget.failed, + title: "", + items: [] + }); + return container.replaceChildren(renderedWidget2); + } + const renderedWidget = renderWidget({ + loading: servicesInfoWidget.loading, + failed: servicesInfoWidget.failed, title: "Services info", items: [ { key: "Podkop", - value: services.podkop ? "\u2714 Enabled" : "\u2718 Disabled", + value: servicesInfoWidget.data.podkop ? "\u2714 Enabled" : "\u2718 Disabled", attributes: { - class: services.podkop ? "pdk_dashboard-page__widgets-section__item__row--success" : "pdk_dashboard-page__widgets-section__item__row--error" + class: servicesInfoWidget.data.podkop ? "pdk_dashboard-page__widgets-section__item__row--success" : "pdk_dashboard-page__widgets-section__item__row--error" } }, { key: "Sing-box", - value: services.singbox ? "\u2714 Running" : "\u2718 Stopped", + value: servicesInfoWidget.data.singbox ? "\u2714 Running" : "\u2718 Stopped", attributes: { - class: services.singbox ? "pdk_dashboard-page__widgets-section__item__row--success" : "pdk_dashboard-page__widgets-section__item__row--error" + class: servicesInfoWidget.data.singbox ? "pdk_dashboard-page__widgets-section__item__row--success" : "pdk_dashboard-page__widgets-section__item__row--error" } } ] @@ -1629,18 +1818,20 @@ async function renderServiceInfoWidget() { container.replaceChildren(renderedWidget); } async function onStoreUpdate(next, prev, diff) { - if (diff?.dashboardSections) { - renderDashboardSections(); + if (diff.sectionsWidget) { + renderSectionsWidget(); } - if (diff?.traffic) { - renderTrafficWidget(); + if (diff.bandwidthWidget) { + renderBandwidthWidget(); } - if (diff?.connections) { + if (diff.trafficTotalWidget) { renderTrafficTotalWidget(); + } + if (diff.systemInfoWidget) { renderSystemInfoWidget(); } - if (diff?.services) { - renderServiceInfoWidget(); + if (diff.servicesInfoWidget) { + renderServicesInfoWidget(); } } async function initDashboardController() { From 9a72785fa78309e7cf71fd84b68d69cd11182bad Mon Sep 17 00:00:00 2001 From: divocat Date: Tue, 7 Oct 2025 16:55:50 +0300 Subject: [PATCH 47/49] feat: migrate to _ locales handler --- .../src/clash/methods/createBaseApiRequest.ts | 4 +- fe-app-podkop/src/helpers/copyToClipboard.ts | 29 --- fe-app-podkop/src/helpers/index.ts | 1 - fe-app-podkop/src/helpers/withTimeout.ts | 2 +- fe-app-podkop/src/luci.d.ts | 2 + .../podkop/methods/getDashboardSections.ts | 2 +- .../tabs/dashboard/initDashboardController.ts | 32 +-- .../podkop/tabs/dashboard/renderSections.ts | 2 +- .../src/podkop/tabs/dashboard/renderWidget.ts | 2 +- fe-app-podkop/src/validators/validateDns.ts | 9 +- .../src/validators/validateDomain.ts | 6 +- fe-app-podkop/src/validators/validateIp.ts | 4 +- .../src/validators/validateOutboundJson.ts | 7 +- fe-app-podkop/src/validators/validatePath.ts | 5 +- .../src/validators/validateProxyUrl.ts | 2 +- .../src/validators/validateShadowsocksUrl.ts | 30 ++- .../src/validators/validateSubnet.ts | 8 +- .../src/validators/validateTrojanUrl.ts | 12 +- fe-app-podkop/src/validators/validateUrl.ts | 6 +- .../src/validators/validateVlessUrl.ts | 31 +-- fe-app-podkop/tests/setup/global-mocks.ts | 2 + fe-app-podkop/vitest.config.js | 1 + .../resources/view/podkop/additionalTab.js | 6 +- .../resources/view/podkop/configSection.js | 22 +- .../luci-static/resources/view/podkop/main.js | 198 +++++++++--------- 25 files changed, 213 insertions(+), 212 deletions(-) delete mode 100644 fe-app-podkop/src/helpers/copyToClipboard.ts create mode 100644 fe-app-podkop/tests/setup/global-mocks.ts diff --git a/fe-app-podkop/src/clash/methods/createBaseApiRequest.ts b/fe-app-podkop/src/clash/methods/createBaseApiRequest.ts index b63516a..601a433 100644 --- a/fe-app-podkop/src/clash/methods/createBaseApiRequest.ts +++ b/fe-app-podkop/src/clash/methods/createBaseApiRequest.ts @@ -9,7 +9,7 @@ export async function createBaseApiRequest( if (!response.ok) { return { success: false as const, - message: `HTTP error ${response.status}: ${response.statusText}`, + message: `${_('HTTP error')} ${response.status}: ${response.statusText}`, }; } @@ -22,7 +22,7 @@ export async function createBaseApiRequest( } catch (e) { return { success: false as const, - message: e instanceof Error ? e.message : 'Unknown error', + message: e instanceof Error ? e.message : _('Unknown error'), }; } } diff --git a/fe-app-podkop/src/helpers/copyToClipboard.ts b/fe-app-podkop/src/helpers/copyToClipboard.ts deleted file mode 100644 index 154f4c5..0000000 --- a/fe-app-podkop/src/helpers/copyToClipboard.ts +++ /dev/null @@ -1,29 +0,0 @@ -interface CopyToClipboardResponse { - success: boolean; - message: string; -} - -export function copyToClipboard(text: string): CopyToClipboardResponse { - const textarea = document.createElement('textarea'); - textarea.value = text; - document.body.appendChild(textarea); - textarea.select(); - - try { - document.execCommand('copy'); - - return { - success: true, - message: 'Copied!', - }; - } catch (err) { - const error = err as Error; - - return { - success: false, - message: `Failed to copy: ${error.message}`, - }; - } finally { - document.body.removeChild(textarea); - } -} diff --git a/fe-app-podkop/src/helpers/index.ts b/fe-app-podkop/src/helpers/index.ts index a38f0b5..242f2e7 100644 --- a/fe-app-podkop/src/helpers/index.ts +++ b/fe-app-podkop/src/helpers/index.ts @@ -3,7 +3,6 @@ export * from './parseValueList'; export * from './injectGlobalStyles'; export * from './withTimeout'; export * from './executeShellCommand'; -export * from './copyToClipboard'; export * from './maskIP'; export * from './getProxyUrlName'; export * from './onMount'; diff --git a/fe-app-podkop/src/helpers/withTimeout.ts b/fe-app-podkop/src/helpers/withTimeout.ts index 4475a55..f06108a 100644 --- a/fe-app-podkop/src/helpers/withTimeout.ts +++ b/fe-app-podkop/src/helpers/withTimeout.ts @@ -2,7 +2,7 @@ export async function withTimeout( promise: Promise, timeoutMs: number, operationName: string, - timeoutMessage = 'Operation timed out', + timeoutMessage = _('Operation timed out'), ): Promise { let timeoutId; const start = performance.now(); diff --git a/fe-app-podkop/src/luci.d.ts b/fe-app-podkop/src/luci.d.ts index 9b6762e..01ff833 100644 --- a/fe-app-podkop/src/luci.d.ts +++ b/fe-app-podkop/src/luci.d.ts @@ -33,6 +33,8 @@ declare global { load: (packages: string | string[]) => Promise; sections: (conf: string, type?: string, cb?: () => void) => Promise; }; + + const _ = (_key: string) => string; } export {}; diff --git a/fe-app-podkop/src/podkop/methods/getDashboardSections.ts b/fe-app-podkop/src/podkop/methods/getDashboardSections.ts index 931a4f5..c101926 100644 --- a/fe-app-podkop/src/podkop/methods/getDashboardSections.ts +++ b/fe-app-podkop/src/podkop/methods/getDashboardSections.ts @@ -106,7 +106,7 @@ export async function getDashboardSections(): Promise part.length > 63); if (atLeastOneInvalidPart) { - return { valid: false, message: 'Invalid domain address' }; + return { valid: false, message: _('Invalid domain address') }; } - return { valid: true, message: 'Valid' }; + return { valid: true, message: _('Valid') }; } diff --git a/fe-app-podkop/src/validators/validateIp.ts b/fe-app-podkop/src/validators/validateIp.ts index 88ab1f0..78c154d 100644 --- a/fe-app-podkop/src/validators/validateIp.ts +++ b/fe-app-podkop/src/validators/validateIp.ts @@ -5,8 +5,8 @@ export function validateIPV4(ip: string): ValidationResult { /^(?:(25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])\.){3}(25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])$/; if (ipRegex.test(ip)) { - return { valid: true, message: 'Valid' }; + return { valid: true, message: _('Valid') }; } - return { valid: false, message: 'Invalid IP address' }; + return { valid: false, message: _('Invalid IP address') }; } diff --git a/fe-app-podkop/src/validators/validateOutboundJson.ts b/fe-app-podkop/src/validators/validateOutboundJson.ts index c768543..822662b 100644 --- a/fe-app-podkop/src/validators/validateOutboundJson.ts +++ b/fe-app-podkop/src/validators/validateOutboundJson.ts @@ -8,13 +8,14 @@ export function validateOutboundJson(value: string): ValidationResult { if (!parsed.type || !parsed.server || !parsed.server_port) { return { valid: false, - message: + message: _( 'Outbound JSON must contain at least "type", "server" and "server_port" fields', + ), }; } - return { valid: true, message: 'Valid' }; + return { valid: true, message: _('Valid') }; } catch { - return { valid: false, message: 'Invalid JSON format' }; + return { valid: false, message: _('Invalid JSON format') }; } } diff --git a/fe-app-podkop/src/validators/validatePath.ts b/fe-app-podkop/src/validators/validatePath.ts index 9da07ba..045601e 100644 --- a/fe-app-podkop/src/validators/validatePath.ts +++ b/fe-app-podkop/src/validators/validatePath.ts @@ -4,7 +4,7 @@ export function validatePath(value: string): ValidationResult { if (!value) { return { valid: false, - message: 'Path cannot be empty', + message: _('Path cannot be empty'), }; } @@ -19,7 +19,8 @@ export function validatePath(value: string): ValidationResult { return { valid: false, - message: + message: _( 'Invalid path format. Path must start with "/" and contain valid characters', + ), }; } diff --git a/fe-app-podkop/src/validators/validateProxyUrl.ts b/fe-app-podkop/src/validators/validateProxyUrl.ts index b9ef593..ec3fe47 100644 --- a/fe-app-podkop/src/validators/validateProxyUrl.ts +++ b/fe-app-podkop/src/validators/validateProxyUrl.ts @@ -19,6 +19,6 @@ export function validateProxyUrl(url: string): ValidationResult { return { valid: false, - message: 'URL must start with vless:// or ss:// or trojan://', + message: _('URL must start with vless:// or ss:// or trojan://'), }; } diff --git a/fe-app-podkop/src/validators/validateShadowsocksUrl.ts b/fe-app-podkop/src/validators/validateShadowsocksUrl.ts index 68081a7..29bd193 100644 --- a/fe-app-podkop/src/validators/validateShadowsocksUrl.ts +++ b/fe-app-podkop/src/validators/validateShadowsocksUrl.ts @@ -5,7 +5,7 @@ export function validateShadowsocksUrl(url: string): ValidationResult { if (!url.startsWith('ss://')) { return { valid: false, - message: 'Invalid Shadowsocks URL: must start with ss://', + message: _('Invalid Shadowsocks URL: must start with ss://'), }; } @@ -13,7 +13,7 @@ export function validateShadowsocksUrl(url: string): ValidationResult { if (!url || /\s/.test(url)) { return { valid: false, - message: 'Invalid Shadowsocks URL: must not contain spaces', + message: _('Invalid Shadowsocks URL: must not contain spaces'), }; } @@ -24,7 +24,7 @@ export function validateShadowsocksUrl(url: string): ValidationResult { if (!encryptedPart) { return { valid: false, - message: 'Invalid Shadowsocks URL: missing credentials', + message: _('Invalid Shadowsocks URL: missing credentials'), }; } @@ -34,16 +34,18 @@ export function validateShadowsocksUrl(url: string): ValidationResult { if (!decoded.includes(':')) { return { valid: false, - message: + message: _( 'Invalid Shadowsocks URL: decoded credentials must contain method:password', + ), }; } } catch (_e) { if (!encryptedPart.includes(':') && !encryptedPart.includes('-')) { return { valid: false, - message: + message: _( 'Invalid Shadowsocks URL: missing method and password separator ":"', + ), }; } } @@ -53,7 +55,7 @@ export function validateShadowsocksUrl(url: string): ValidationResult { if (!serverPart) { return { valid: false, - message: 'Invalid Shadowsocks URL: missing server address', + message: _('Invalid Shadowsocks URL: missing server address'), }; } @@ -62,14 +64,17 @@ export function validateShadowsocksUrl(url: string): ValidationResult { if (!server) { return { valid: false, - message: 'Invalid Shadowsocks URL: missing server', + message: _('Invalid Shadowsocks URL: missing server'), }; } const port = portAndRest ? portAndRest.split(/[?#]/)[0] : null; if (!port) { - return { valid: false, message: 'Invalid Shadowsocks URL: missing port' }; + return { + valid: false, + message: _('Invalid Shadowsocks URL: missing port'), + }; } const portNum = parseInt(port, 10); @@ -77,12 +82,15 @@ export function validateShadowsocksUrl(url: string): ValidationResult { if (isNaN(portNum) || portNum < 1 || portNum > 65535) { return { valid: false, - message: 'Invalid port number. Must be between 1 and 65535', + message: _('Invalid port number. Must be between 1 and 65535'), }; } } catch (_e) { - return { valid: false, message: 'Invalid Shadowsocks URL: parsing failed' }; + return { + valid: false, + message: _('Invalid Shadowsocks URL: parsing failed'), + }; } - return { valid: true, message: 'Valid' }; + return { valid: true, message: _('Valid') }; } diff --git a/fe-app-podkop/src/validators/validateSubnet.ts b/fe-app-podkop/src/validators/validateSubnet.ts index 6f3e2b9..e7974a2 100644 --- a/fe-app-podkop/src/validators/validateSubnet.ts +++ b/fe-app-podkop/src/validators/validateSubnet.ts @@ -8,14 +8,14 @@ export function validateSubnet(value: string): ValidationResult { if (!subnetRegex.test(value)) { return { valid: false, - message: 'Invalid format. Use X.X.X.X or X.X.X.X/Y', + message: _('Invalid format. Use X.X.X.X or X.X.X.X/Y'), }; } const [ip, cidr] = value.split('/'); if (ip === '0.0.0.0') { - return { valid: false, message: 'IP address 0.0.0.0 is not allowed' }; + return { valid: false, message: _('IP address 0.0.0.0 is not allowed') }; } const ipCheck = validateIPV4(ip); @@ -30,10 +30,10 @@ export function validateSubnet(value: string): ValidationResult { if (cidrNum < 0 || cidrNum > 32) { return { valid: false, - message: 'CIDR must be between 0 and 32', + message: _('CIDR must be between 0 and 32'), }; } } - return { valid: true, message: 'Valid' }; + return { valid: true, message: _('Valid') }; } diff --git a/fe-app-podkop/src/validators/validateTrojanUrl.ts b/fe-app-podkop/src/validators/validateTrojanUrl.ts index f79536c..8e9e627 100644 --- a/fe-app-podkop/src/validators/validateTrojanUrl.ts +++ b/fe-app-podkop/src/validators/validateTrojanUrl.ts @@ -5,14 +5,14 @@ export function validateTrojanUrl(url: string): ValidationResult { if (!url.startsWith('trojan://')) { return { valid: false, - message: 'Invalid Trojan URL: must start with trojan://', + message: _('Invalid Trojan URL: must start with trojan://'), }; } if (!url || /\s/.test(url)) { return { valid: false, - message: 'Invalid Trojan URL: must not contain spaces', + message: _('Invalid Trojan URL: must not contain spaces'), }; } @@ -22,12 +22,14 @@ export function validateTrojanUrl(url: string): ValidationResult { if (!parsedUrl.username || !parsedUrl.hostname || !parsedUrl.port) { return { valid: false, - message: 'Invalid Trojan URL: must contain username, hostname and port', + message: _( + 'Invalid Trojan URL: must contain username, hostname and port', + ), }; } } catch (_e) { - return { valid: false, message: 'Invalid Trojan URL: parsing failed' }; + return { valid: false, message: _('Invalid Trojan URL: parsing failed') }; } - return { valid: true, message: 'Valid' }; + return { valid: true, message: _('Valid') }; } diff --git a/fe-app-podkop/src/validators/validateUrl.ts b/fe-app-podkop/src/validators/validateUrl.ts index 5b6d522..dd2c88e 100644 --- a/fe-app-podkop/src/validators/validateUrl.ts +++ b/fe-app-podkop/src/validators/validateUrl.ts @@ -10,11 +10,11 @@ export function validateUrl( if (!protocols.includes(parsedUrl.protocol)) { return { valid: false, - message: `URL must use one of the following protocols: ${protocols.join(', ')}`, + message: `${_('URL must use one of the following protocols:')} ${protocols.join(', ')}`, }; } - return { valid: true, message: 'Valid' }; + return { valid: true, message: _('Valid') }; } catch (_e) { - return { valid: false, message: 'Invalid URL format' }; + return { valid: false, message: _('Invalid URL format') }; } } diff --git a/fe-app-podkop/src/validators/validateVlessUrl.ts b/fe-app-podkop/src/validators/validateVlessUrl.ts index 45ea65a..73746e4 100644 --- a/fe-app-podkop/src/validators/validateVlessUrl.ts +++ b/fe-app-podkop/src/validators/validateVlessUrl.ts @@ -7,27 +7,27 @@ export function validateVlessUrl(url: string): ValidationResult { if (!url || /\s/.test(url)) { return { valid: false, - message: 'Invalid VLESS URL: must not contain spaces', + message: _('Invalid VLESS URL: must not contain spaces'), }; } if (parsedUrl.protocol !== 'vless:') { return { valid: false, - message: 'Invalid VLESS URL: must start with vless://', + message: _('Invalid VLESS URL: must start with vless://'), }; } if (!parsedUrl.username) { - return { valid: false, message: 'Invalid VLESS URL: missing UUID' }; + return { valid: false, message: _('Invalid VLESS URL: missing UUID') }; } if (!parsedUrl.hostname) { - return { valid: false, message: 'Invalid VLESS URL: missing server' }; + return { valid: false, message: _('Invalid VLESS URL: missing server') }; } if (!parsedUrl.port) { - return { valid: false, message: 'Invalid VLESS URL: missing port' }; + return { valid: false, message: _('Invalid VLESS URL: missing port') }; } if ( @@ -37,15 +37,16 @@ export function validateVlessUrl(url: string): ValidationResult { ) { return { valid: false, - message: + message: _( 'Invalid VLESS URL: invalid port number. Must be between 1 and 65535', + ), }; } if (!parsedUrl.search) { return { valid: false, - message: 'Invalid VLESS URL: missing query parameters', + message: _('Invalid VLESS URL: missing query parameters'), }; } @@ -67,8 +68,9 @@ export function validateVlessUrl(url: string): ValidationResult { if (!type || !validTypes.includes(type)) { return { valid: false, - message: + message: _( 'Invalid VLESS URL: type must be one of tcp, raw, udp, grpc, http, ws', + ), }; } @@ -78,8 +80,9 @@ export function validateVlessUrl(url: string): ValidationResult { if (!security || !validSecurities.includes(security)) { return { valid: false, - message: + message: _( 'Invalid VLESS URL: security must be one of tls, reality, none', + ), }; } @@ -87,21 +90,23 @@ export function validateVlessUrl(url: string): ValidationResult { if (!params.get('pbk')) { return { valid: false, - message: + message: _( 'Invalid VLESS URL: missing pbk parameter for reality security', + ), }; } if (!params.get('fp')) { return { valid: false, - message: + message: _( 'Invalid VLESS URL: missing fp parameter for reality security', + ), }; } } - return { valid: true, message: 'Valid' }; + return { valid: true, message: _('Valid') }; } catch (_e) { - return { valid: false, message: 'Invalid VLESS URL: parsing failed' }; + return { valid: false, message: _('Invalid VLESS URL: parsing failed') }; } } diff --git a/fe-app-podkop/tests/setup/global-mocks.ts b/fe-app-podkop/tests/setup/global-mocks.ts new file mode 100644 index 0000000..8f93270 --- /dev/null +++ b/fe-app-podkop/tests/setup/global-mocks.ts @@ -0,0 +1,2 @@ +// tests/setup/global-mocks.ts +globalThis._ = (key: string) => key; diff --git a/fe-app-podkop/vitest.config.js b/fe-app-podkop/vitest.config.js index adbf725..a8eb868 100644 --- a/fe-app-podkop/vitest.config.js +++ b/fe-app-podkop/vitest.config.js @@ -4,5 +4,6 @@ export default defineConfig({ test: { globals: true, environment: 'node', + setupFiles: ['./tests/setup/global-mocks.ts'], }, }); diff --git a/luci-app-podkop/htdocs/luci-static/resources/view/podkop/additionalTab.js b/luci-app-podkop/htdocs/luci-static/resources/view/podkop/additionalTab.js index 2686fb0..d317048 100644 --- a/luci-app-podkop/htdocs/luci-static/resources/view/podkop/additionalTab.js +++ b/luci-app-podkop/htdocs/luci-static/resources/view/podkop/additionalTab.js @@ -88,7 +88,7 @@ function createAdditionalSection(mainSection) { return true; } - return _(validation.message); + return validation.message; }; o = mainSection.taboption( @@ -113,7 +113,7 @@ function createAdditionalSection(mainSection) { return true; } - return _(validation.message); + return validation.message; }; o = mainSection.taboption( @@ -342,7 +342,7 @@ function createAdditionalSection(mainSection) { return true; } - return _(validation.message); + return validation.message; }; o = mainSection.taboption( diff --git a/luci-app-podkop/htdocs/luci-static/resources/view/podkop/configSection.js b/luci-app-podkop/htdocs/luci-static/resources/view/podkop/configSection.js index f1a225b..b4f3b17 100644 --- a/luci-app-podkop/htdocs/luci-static/resources/view/podkop/configSection.js +++ b/luci-app-podkop/htdocs/luci-static/resources/view/podkop/configSection.js @@ -152,7 +152,7 @@ function createConfigSection(section) { return true; } - return _(validation.message); + return validation.message; } catch (e) { return `${_('Invalid URL format:')} ${e?.message}`; } @@ -180,7 +180,7 @@ function createConfigSection(section) { return true; } - return _(validation.message); + return validation.message; }; o = s.taboption( @@ -204,7 +204,7 @@ function createConfigSection(section) { return true; } - return _(validation.message); + return validation.message; }; o = s.taboption( @@ -315,7 +315,7 @@ function createConfigSection(section) { return true; } - return _(validation.message); + return validation.message; }; o = s.taboption( @@ -459,7 +459,7 @@ function createConfigSection(section) { return true; } - return _(validation.message); + return validation.message; }; o = s.taboption( @@ -538,7 +538,7 @@ function createConfigSection(section) { return true; } - return _(validation.message); + return validation.message; }; o = s.taboption( @@ -575,7 +575,7 @@ function createConfigSection(section) { return true; } - return _(validation.message); + return validation.message; }; o = s.taboption( @@ -612,7 +612,7 @@ function createConfigSection(section) { return true; } - return _(validation.message); + return validation.message; }; o = s.taboption( @@ -654,7 +654,7 @@ function createConfigSection(section) { return true; } - return _(validation.message); + return validation.message; }; o = s.taboption( @@ -733,7 +733,7 @@ function createConfigSection(section) { return true; } - return _(validation.message); + return validation.message; }; o = s.taboption( @@ -772,7 +772,7 @@ function createConfigSection(section) { return true; } - return _(validation.message); + return validation.message; }; } diff --git a/luci-app-podkop/htdocs/luci-static/resources/view/podkop/main.js b/luci-app-podkop/htdocs/luci-static/resources/view/podkop/main.js index a8f101b..34cde9e 100644 --- a/luci-app-podkop/htdocs/luci-static/resources/view/podkop/main.js +++ b/luci-app-podkop/htdocs/luci-static/resources/view/podkop/main.js @@ -8,40 +8,42 @@ function validateIPV4(ip) { const ipRegex = /^(?:(25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])\.){3}(25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])$/; if (ipRegex.test(ip)) { - return { valid: true, message: "Valid" }; + return { valid: true, message: _("Valid") }; } - return { valid: false, message: "Invalid IP address" }; + return { valid: false, message: _("Invalid IP address") }; } // src/validators/validateDomain.ts function validateDomain(domain) { const domainRegex = /^(?=.{1,253}(?:\/|$))(?:(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)\.)+(?:[a-zA-Z]{2,}|xn--[a-zA-Z0-9-]{1,59}[a-zA-Z0-9])(?:\/[^\s]*)?$/; if (!domainRegex.test(domain)) { - return { valid: false, message: "Invalid domain address" }; + return { valid: false, message: _("Invalid domain address") }; } const hostname = domain.split("/")[0]; const parts = hostname.split("."); const atLeastOneInvalidPart = parts.some((part) => part.length > 63); if (atLeastOneInvalidPart) { - return { valid: false, message: "Invalid domain address" }; + return { valid: false, message: _("Invalid domain address") }; } - return { valid: true, message: "Valid" }; + return { valid: true, message: _("Valid") }; } // src/validators/validateDns.ts function validateDNS(value) { if (!value) { - return { valid: false, message: "DNS server address cannot be empty" }; + return { valid: false, message: _("DNS server address cannot be empty") }; } if (validateIPV4(value).valid) { - return { valid: true, message: "Valid" }; + return { valid: true, message: _("Valid") }; } if (validateDomain(value).valid) { - return { valid: true, message: "Valid" }; + return { valid: true, message: _("Valid") }; } return { valid: false, - message: "Invalid DNS server format. Examples: 8.8.8.8 or dns.example.com or dns.example.com/nicedns for DoH" + message: _( + "Invalid DNS server format. Examples: 8.8.8.8 or dns.example.com or dns.example.com/nicedns for DoH" + ) }; } @@ -52,12 +54,12 @@ function validateUrl(url, protocols = ["http:", "https:"]) { if (!protocols.includes(parsedUrl.protocol)) { return { valid: false, - message: `URL must use one of the following protocols: ${protocols.join(", ")}` + message: `${_("URL must use one of the following protocols:")} ${protocols.join(", ")}` }; } - return { valid: true, message: "Valid" }; + return { valid: true, message: _("Valid") }; } catch (_e) { - return { valid: false, message: "Invalid URL format" }; + return { valid: false, message: _("Invalid URL format") }; } } @@ -66,7 +68,7 @@ function validatePath(value) { if (!value) { return { valid: false, - message: "Path cannot be empty" + message: _("Path cannot be empty") }; } const pathRegex = /^\/[a-zA-Z0-9_\-/.]+$/; @@ -78,7 +80,9 @@ function validatePath(value) { } return { valid: false, - message: 'Invalid path format. Path must start with "/" and contain valid characters' + message: _( + 'Invalid path format. Path must start with "/" and contain valid characters' + ) }; } @@ -88,12 +92,12 @@ function validateSubnet(value) { if (!subnetRegex.test(value)) { return { valid: false, - message: "Invalid format. Use X.X.X.X or X.X.X.X/Y" + message: _("Invalid format. Use X.X.X.X or X.X.X.X/Y") }; } const [ip, cidr] = value.split("/"); if (ip === "0.0.0.0") { - return { valid: false, message: "IP address 0.0.0.0 is not allowed" }; + return { valid: false, message: _("IP address 0.0.0.0 is not allowed") }; } const ipCheck = validateIPV4(ip); if (!ipCheck.valid) { @@ -104,11 +108,11 @@ function validateSubnet(value) { if (cidrNum < 0 || cidrNum > 32) { return { valid: false, - message: "CIDR must be between 0 and 32" + message: _("CIDR must be between 0 and 32") }; } } - return { valid: true, message: "Valid" }; + return { valid: true, message: _("Valid") }; } // src/validators/bulkValidate.ts @@ -125,14 +129,14 @@ function validateShadowsocksUrl(url) { if (!url.startsWith("ss://")) { return { valid: false, - message: "Invalid Shadowsocks URL: must start with ss://" + message: _("Invalid Shadowsocks URL: must start with ss://") }; } try { if (!url || /\s/.test(url)) { return { valid: false, - message: "Invalid Shadowsocks URL: must not contain spaces" + message: _("Invalid Shadowsocks URL: must not contain spaces") }; } const mainPart = url.includes("?") ? url.split("?")[0] : url.split("#")[0]; @@ -140,7 +144,7 @@ function validateShadowsocksUrl(url) { if (!encryptedPart) { return { valid: false, - message: "Invalid Shadowsocks URL: missing credentials" + message: _("Invalid Shadowsocks URL: missing credentials") }; } try { @@ -148,14 +152,18 @@ function validateShadowsocksUrl(url) { if (!decoded.includes(":")) { return { valid: false, - message: "Invalid Shadowsocks URL: decoded credentials must contain method:password" + message: _( + "Invalid Shadowsocks URL: decoded credentials must contain method:password" + ) }; } } catch (_e) { if (!encryptedPart.includes(":") && !encryptedPart.includes("-")) { return { valid: false, - message: 'Invalid Shadowsocks URL: missing method and password separator ":"' + message: _( + 'Invalid Shadowsocks URL: missing method and password separator ":"' + ) }; } } @@ -163,31 +171,37 @@ function validateShadowsocksUrl(url) { if (!serverPart) { return { valid: false, - message: "Invalid Shadowsocks URL: missing server address" + message: _("Invalid Shadowsocks URL: missing server address") }; } const [server, portAndRest] = serverPart.split(":"); if (!server) { return { valid: false, - message: "Invalid Shadowsocks URL: missing server" + message: _("Invalid Shadowsocks URL: missing server") }; } const port = portAndRest ? portAndRest.split(/[?#]/)[0] : null; if (!port) { - return { valid: false, message: "Invalid Shadowsocks URL: missing port" }; + return { + valid: false, + message: _("Invalid Shadowsocks URL: missing port") + }; } const portNum = parseInt(port, 10); if (isNaN(portNum) || portNum < 1 || portNum > 65535) { return { valid: false, - message: "Invalid port number. Must be between 1 and 65535" + message: _("Invalid port number. Must be between 1 and 65535") }; } } catch (_e) { - return { valid: false, message: "Invalid Shadowsocks URL: parsing failed" }; + return { + valid: false, + message: _("Invalid Shadowsocks URL: parsing failed") + }; } - return { valid: true, message: "Valid" }; + return { valid: true, message: _("Valid") }; } // src/validators/validateVlessUrl.ts @@ -197,34 +211,36 @@ function validateVlessUrl(url) { if (!url || /\s/.test(url)) { return { valid: false, - message: "Invalid VLESS URL: must not contain spaces" + message: _("Invalid VLESS URL: must not contain spaces") }; } if (parsedUrl.protocol !== "vless:") { return { valid: false, - message: "Invalid VLESS URL: must start with vless://" + message: _("Invalid VLESS URL: must start with vless://") }; } if (!parsedUrl.username) { - return { valid: false, message: "Invalid VLESS URL: missing UUID" }; + return { valid: false, message: _("Invalid VLESS URL: missing UUID") }; } if (!parsedUrl.hostname) { - return { valid: false, message: "Invalid VLESS URL: missing server" }; + return { valid: false, message: _("Invalid VLESS URL: missing server") }; } if (!parsedUrl.port) { - return { valid: false, message: "Invalid VLESS URL: missing port" }; + return { valid: false, message: _("Invalid VLESS URL: missing port") }; } if (isNaN(+parsedUrl.port) || +parsedUrl.port < 1 || +parsedUrl.port > 65535) { return { valid: false, - message: "Invalid VLESS URL: invalid port number. Must be between 1 and 65535" + message: _( + "Invalid VLESS URL: invalid port number. Must be between 1 and 65535" + ) }; } if (!parsedUrl.search) { return { valid: false, - message: "Invalid VLESS URL: missing query parameters" + message: _("Invalid VLESS URL: missing query parameters") }; } const params = new URLSearchParams(parsedUrl.search); @@ -243,7 +259,9 @@ function validateVlessUrl(url) { if (!type || !validTypes.includes(type)) { return { valid: false, - message: "Invalid VLESS URL: type must be one of tcp, raw, udp, grpc, http, ws" + message: _( + "Invalid VLESS URL: type must be one of tcp, raw, udp, grpc, http, ws" + ) }; } const security = params.get("security"); @@ -251,26 +269,32 @@ function validateVlessUrl(url) { if (!security || !validSecurities.includes(security)) { return { valid: false, - message: "Invalid VLESS URL: security must be one of tls, reality, none" + message: _( + "Invalid VLESS URL: security must be one of tls, reality, none" + ) }; } if (security === "reality") { if (!params.get("pbk")) { return { valid: false, - message: "Invalid VLESS URL: missing pbk parameter for reality security" + message: _( + "Invalid VLESS URL: missing pbk parameter for reality security" + ) }; } if (!params.get("fp")) { return { valid: false, - message: "Invalid VLESS URL: missing fp parameter for reality security" + message: _( + "Invalid VLESS URL: missing fp parameter for reality security" + ) }; } } - return { valid: true, message: "Valid" }; + return { valid: true, message: _("Valid") }; } catch (_e) { - return { valid: false, message: "Invalid VLESS URL: parsing failed" }; + return { valid: false, message: _("Invalid VLESS URL: parsing failed") }; } } @@ -281,12 +305,14 @@ function validateOutboundJson(value) { if (!parsed.type || !parsed.server || !parsed.server_port) { return { valid: false, - message: 'Outbound JSON must contain at least "type", "server" and "server_port" fields' + message: _( + 'Outbound JSON must contain at least "type", "server" and "server_port" fields' + ) }; } - return { valid: true, message: "Valid" }; + return { valid: true, message: _("Valid") }; } catch { - return { valid: false, message: "Invalid JSON format" }; + return { valid: false, message: _("Invalid JSON format") }; } } @@ -295,13 +321,13 @@ function validateTrojanUrl(url) { if (!url.startsWith("trojan://")) { return { valid: false, - message: "Invalid Trojan URL: must start with trojan://" + message: _("Invalid Trojan URL: must start with trojan://") }; } if (!url || /\s/.test(url)) { return { valid: false, - message: "Invalid Trojan URL: must not contain spaces" + message: _("Invalid Trojan URL: must not contain spaces") }; } try { @@ -309,13 +335,15 @@ function validateTrojanUrl(url) { if (!parsedUrl.username || !parsedUrl.hostname || !parsedUrl.port) { return { valid: false, - message: "Invalid Trojan URL: must contain username, hostname and port" + message: _( + "Invalid Trojan URL: must contain username, hostname and port" + ) }; } } catch (_e) { - return { valid: false, message: "Invalid Trojan URL: parsing failed" }; + return { valid: false, message: _("Invalid Trojan URL: parsing failed") }; } - return { valid: true, message: "Valid" }; + return { valid: true, message: _("Valid") }; } // src/validators/validateProxyUrl.ts @@ -331,7 +359,7 @@ function validateProxyUrl(url) { } return { valid: false, - message: "URL must start with vless:// or ss:// or trojan://" + message: _("URL must start with vless:// or ss:// or trojan://") }; } @@ -537,10 +565,10 @@ function injectGlobalStyles() { } // src/helpers/withTimeout.ts -async function withTimeout(promise, timeoutMs, operationName, timeoutMessage = "Operation timed out") { +async function withTimeout(promise, timeoutMs, operationName, timeoutMessage = _("Operation timed out")) { let timeoutId; const start = performance.now(); - const timeoutPromise = new Promise((_, reject) => { + const timeoutPromise = new Promise((_2, reject) => { timeoutId = setTimeout(() => reject(new Error(timeoutMessage)), timeoutMs); }); try { @@ -680,29 +708,6 @@ async function executeShellCommand({ } } -// src/helpers/copyToClipboard.ts -function copyToClipboard(text) { - const textarea = document.createElement("textarea"); - textarea.value = text; - document.body.appendChild(textarea); - textarea.select(); - try { - document.execCommand("copy"); - return { - success: true, - message: "Copied!" - }; - } catch (err) { - const error = err; - return { - success: false, - message: `Failed to copy: ${error.message}` - }; - } finally { - document.body.removeChild(textarea); - } -} - // src/helpers/maskIP.ts function maskIP(ip = "") { const ipv4Regex = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/; @@ -767,7 +772,7 @@ async function createBaseApiRequest(fetchFn) { if (!response.ok) { return { success: false, - message: `HTTP error ${response.status}: ${response.statusText}` + message: `${_("HTTP error")} ${response.status}: ${response.statusText}` }; } const data = await response.json(); @@ -778,7 +783,7 @@ async function createBaseApiRequest(fetchFn) { } catch (e) { return { success: false, - message: e instanceof Error ? e.message : "Unknown error" + message: e instanceof Error ? e.message : _("Unknown error") }; } } @@ -943,7 +948,7 @@ async function getDashboardSections() { outbounds: [ { code: outbound?.code || "", - displayName: "Fastest", + displayName: _("Fastest"), latency: outbound?.value?.history?.[0]?.delay || 0, type: outbound?.value?.type || "", selected: selector?.value?.now === outbound?.code @@ -1080,7 +1085,7 @@ var TabServiceInstance = TabService.getInstance(); // src/store.ts function jsonStableStringify(obj) { - return JSON.stringify(obj, (_, value) => { + return JSON.stringify(obj, (_2, value) => { if (value && typeof value === "object" && !Array.isArray(value)) { return Object.keys(value).sort().reduce( (acc, key) => { @@ -1214,7 +1219,7 @@ function renderFailedState() { class: "pdk_dashboard-page__outbound-section centered", style: "height: 127px" }, - E("span", {}, "Dashboard currently unavailable") + E("span", {}, _("Dashboard currently unavailable")) ); } function renderLoadingState() { @@ -1318,7 +1323,7 @@ function renderFailedState2() { style: "height: 78px", class: "pdk_dashboard-page__widgets-section__item centered" }, - "Currently unavailable" + _("Currently unavailable") ); } function renderLoadingState2() { @@ -1713,10 +1718,10 @@ async function renderBandwidthWidget() { const renderedWidget = renderWidget({ loading: traffic.loading, failed: traffic.failed, - title: "Traffic", + title: _("Traffic"), items: [ - { key: "Uplink", value: `${prettyBytes(traffic.data.up)}/s` }, - { key: "Downlink", value: `${prettyBytes(traffic.data.down)}/s` } + { key: _("Uplink"), value: `${prettyBytes(traffic.data.up)}/s` }, + { key: _("Downlink"), value: `${prettyBytes(traffic.data.down)}/s` } ] }); container.replaceChildren(renderedWidget); @@ -1737,14 +1742,14 @@ async function renderTrafficTotalWidget() { const renderedWidget = renderWidget({ loading: trafficTotalWidget.loading, failed: trafficTotalWidget.failed, - title: "Traffic Total", + title: _("Traffic Total"), items: [ { - key: "Uplink", + key: _("Uplink"), value: String(prettyBytes(trafficTotalWidget.data.uploadTotal)) }, { - key: "Downlink", + key: _("Downlink"), value: String(prettyBytes(trafficTotalWidget.data.downloadTotal)) } ] @@ -1767,14 +1772,14 @@ async function renderSystemInfoWidget() { const renderedWidget = renderWidget({ loading: systemInfoWidget.loading, failed: systemInfoWidget.failed, - title: "System info", + title: _("System info"), items: [ { - key: "Active Connections", + key: _("Active Connections"), value: String(systemInfoWidget.data.connections) }, { - key: "Memory Usage", + key: _("Memory Usage"), value: String(prettyBytes(systemInfoWidget.data.memory)) } ] @@ -1797,18 +1802,18 @@ async function renderServicesInfoWidget() { const renderedWidget = renderWidget({ loading: servicesInfoWidget.loading, failed: servicesInfoWidget.failed, - title: "Services info", + title: _("Services info"), items: [ { - key: "Podkop", - value: servicesInfoWidget.data.podkop ? "\u2714 Enabled" : "\u2718 Disabled", + key: _("Podkop"), + value: servicesInfoWidget.data.podkop ? _("\u2714 Enabled") : _("\u2718 Disabled"), attributes: { class: servicesInfoWidget.data.podkop ? "pdk_dashboard-page__widgets-section__item__row--success" : "pdk_dashboard-page__widgets-section__item__row--error" } }, { - key: "Sing-box", - value: servicesInfoWidget.data.singbox ? "\u2714 Running" : "\u2718 Stopped", + key: _("Sing-box"), + value: servicesInfoWidget.data.singbox ? _("\u2714 Running") : _("\u2718 Stopped"), attributes: { class: servicesInfoWidget.data.singbox ? "pdk_dashboard-page__widgets-section__item__row--success" : "pdk_dashboard-page__widgets-section__item__row--error" } @@ -1865,7 +1870,6 @@ return baseclass.extend({ TabServiceInstance, UPDATE_INTERVAL_OPTIONS, bulkValidate, - copyToClipboard, coreService, createBaseApiRequest, executeShellCommand, From 7b2e5d283888b82f4c68e036a7d5e67e87841e63 Mon Sep 17 00:00:00 2001 From: divocat Date: Tue, 7 Oct 2025 17:14:28 +0300 Subject: [PATCH 48/49] feat: add missing locales --- luci-app-podkop/po/ru/podkop.po | 553 +++++----- luci-app-podkop/po/templates/podkop.pot | 1350 ++++++++++++----------- 2 files changed, 976 insertions(+), 927 deletions(-) diff --git a/luci-app-podkop/po/ru/podkop.po b/luci-app-podkop/po/ru/podkop.po index 0567231..ef1d859 100644 --- a/luci-app-podkop/po/ru/podkop.po +++ b/luci-app-podkop/po/ru/podkop.po @@ -7,8 +7,8 @@ msgid "" msgstr "" "Project-Id-Version: PODKOP\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2025-10-02 19:37+0500\n" -"PO-Revision-Date: 2025-09-30 15:18+0500\n" +"POT-Creation-Date: 2025-10-07 16:55+0300\n" +"PO-Revision-Date: 2025-10-07 23:45+0300\n" "Last-Translator: Automatically generated\n" "Language-Team: none\n" "Language: ru\n" @@ -17,171 +17,6 @@ msgstr "" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2);\n" -msgid "Additional Settings" -msgstr "Дополнительные настройки" - -msgid "Yacd enable" -msgstr "Включить Yacd" - -msgid "Exclude NTP" -msgstr "Исключить NTP" - -msgid "Allows you to exclude NTP protocol traffic from the tunnel" -msgstr "Позволяет исключить направление трафика NTP-протокола в туннель" - -msgid "QUIC disable" -msgstr "Отключить QUIC" - -msgid "For issues with the video stream" -msgstr "Для проблем с видеопотоком" - -msgid "List Update Frequency" -msgstr "Частота обновления списков" - -msgid "Select how often the lists will be updated" -msgstr "Выберите как часто будут обновляться списки" - -msgid "DNS Protocol Type" -msgstr "Тип DNS протокола" - -msgid "Select DNS protocol to use" -msgstr "Выберите протокол DNS" - -msgid "DNS over HTTPS (DoH)" -msgstr "DNS через HTTPS (DoH)" - -msgid "DNS over TLS (DoT)" -msgstr "DNS через TLS (DoT)" - -msgid "UDP (Unprotected DNS)" -msgstr "UDP (Незащищённый DNS)" - -msgid "DNS Server" -msgstr "DNS-сервер" - -msgid "Select or enter DNS server address" -msgstr "Выберите или введите адрес DNS-сервера" - -msgid "DNS server address cannot be empty" -msgstr "Адрес DNS-сервера не может быть пустым" - -msgid "Invalid DNS server format. Examples: 8.8.8.8 or dns.example.com or dns.example.com/nicedns for DoH" -msgstr "Неверный формат DNS-сервера. Примеры: 8.8.8.8 или dns.example.com или dns.example.com/nicedns для DoH" - -msgid "Bootstrap DNS server" -msgstr "Bootstrap DNS-сервер" - -msgid "The DNS server used to look up the IP address of an upstream DNS server" -msgstr "DNS-сервер, используемый для поиска IP-адреса вышестоящего DNS-сервера" - -msgid "Invalid DNS server format. Example: 8.8.8.8" -msgstr "Неверный формат DNS-сервера. Пример: 8.8.8.8" - -msgid "DNS Rewrite TTL" -msgstr "Перезапись TTL для DNS" - -msgid "Time in seconds for DNS record caching (default: 60)" -msgstr "Время в секундах для кэширования DNS записей (по умолчанию: 60)" - -msgid "TTL value cannot be empty" -msgstr "Значение TTL не может быть пустым" - -msgid "TTL must be a positive number" -msgstr "TTL должно быть положительным числом" - -msgid "Config File Path" -msgstr "Путь к файлу конфигурации" - -msgid "Select path for sing-box config file. Change this ONLY if you know what you are doing" -msgstr "Выберите путь к файлу конфигурации sing-box. Изменяйте это, ТОЛЬКО если вы знаете, что делаете" - -msgid "Cache File Path" -msgstr "Путь к файлу кэша" - -msgid "Select or enter path for sing-box cache file. Change this ONLY if you know what you are doing" -msgstr "Выберите или введите путь к файлу кеша sing-box. Изменяйте это, ТОЛЬКО если вы знаете, что делаете" - -msgid "Cache file path cannot be empty" -msgstr "Путь к файлу кэша не может быть пустым" - -msgid "Path must be absolute (start with /)" -msgstr "Путь должен быть абсолютным (начинаться с /)" - -msgid "Path must end with cache.db" -msgstr "Путь должен заканчиваться на cache.db" - -msgid "Path must contain at least one directory (like /tmp/cache.db)" -msgstr "Путь должен содержать хотя бы одну директорию (например /tmp/cache.db)" - -msgid "Source Network Interface" -msgstr "Сетевой интерфейс источника" - -msgid "Select the network interface from which the traffic will originate" -msgstr "Выберите сетевой интерфейс, с которого будет исходить трафик" - -msgid "Interface monitoring" -msgstr "Мониторинг интерфейсов" - -msgid "Interface monitoring for bad WAN" -msgstr "Мониторинг интерфейсов для плохого WAN" - -msgid "Interface for monitoring" -msgstr "Интерфейс для мониторинга" - -msgid "Select the WAN interfaces to be monitored" -msgstr "Выберите WAN интерфейсы для мониторинга" - -msgid "Interface Monitoring Delay" -msgstr "Задержка при мониторинге интерфейсов" - -msgid "Delay in milliseconds before reloading podkop after interface UP" -msgstr "Задержка в миллисекундах перед перезагрузкой podkop после поднятия интерфейса" - -msgid "Delay value cannot be empty" -msgstr "Значение задержки не может быть пустым" - -msgid "Dont touch my DHCP!" -msgstr "Не трогать мой DHCP!" - -msgid "Podkop will not change the DHCP config" -msgstr "Podkop не будет изменять конфигурацию DHCP" - -msgid "Proxy download of lists" -msgstr "Загрузка списков через прокси" - -msgid "Downloading all lists via main Proxy/VPN" -msgstr "Загрузка всех списков через основной прокси/VPN" - -msgid "IP for exclusion" -msgstr "IP для исключения" - -msgid "Specify local IP addresses that will never use the configured route" -msgstr "Укажите локальные IP-адреса, которые никогда не будут использовать настроенный маршрут" - -msgid "Local IPs" -msgstr "Локальные IP адреса" - -msgid "Enter valid IPv4 addresses" -msgstr "Введите действительные IPv4-адреса" - -msgid "Invalid IP format. Use format: X.X.X.X (like 192.168.1.1)" -msgstr "Неверный формат IP. Используйте формат: X.X.X.X (например: 192.168.1.1)" - -msgid "IP address parts must be between 0 and 255" -msgstr "Части IP-адреса должны быть между 0 и 255" - -msgid "Mixed enable" -msgstr "Включить смешанный режим" - -msgid "Browser port: 2080" -msgstr "Порт браузера: 2080" - -msgid "URL must use one of the following protocols: " -msgstr "URL должен использовать один из следующих протоколов: " - -msgid "Invalid URL format" -msgstr "Неверный формат URL" - msgid "Basic Settings" msgstr "Основные настройки" @@ -216,71 +51,18 @@ msgid "Config without description" msgstr "Конфигурация без описания" msgid "" -"Enter connection string starting with vless:// or ss:// for proxy configuration. Add comments with // for backup " -"configs" +"Enter connection string starting with vless:// or ss:// for proxy configuration. Add comments with // for backup configs" msgstr "" -"Введите строку подключения, начинающуюся с vless:// или ss:// для настройки прокси. Добавляйте комментарии с // для " -"сохранения других конфигураций" +"Введите строку подключения, начинающуюся с vless:// или ss:// для настройки прокси. Добавляйте комментарии с // для резервных конфигураций" -msgid "No active configuration found. At least one non-commented line is required." +msgid "No active configuration found. One configuration is required." msgstr "Активная конфигурация не найдена. Требуется хотя бы одна незакомментированная строка." -msgid "URL must start with vless:// or ss://" -msgstr "URL должен начинаться с vless:// или ss://" +msgid "Multiply active configurations found. Please leave one configuration." +msgstr "Найдено несколько активных конфигураций. Оставьте только одну." -msgid "Invalid Shadowsocks URL format: missing method and password separator \":\"" -msgstr "Неверный формат URL Shadowsocks: отсутствует разделитель метода и пароля \":\"" - -msgid "Invalid Shadowsocks URL format" -msgstr "Неверный формат URL Shadowsocks" - -msgid "Invalid Shadowsocks URL: missing server address" -msgstr "Неверный URL Shadowsocks: отсутствует адрес сервера" - -msgid "Invalid Shadowsocks URL: missing server" -msgstr "Неверный URL Shadowsocks: отсутствует сервер" - -msgid "Invalid Shadowsocks URL: missing port" -msgstr "Неверный URL Shadowsocks: отсутствует порт" - -msgid "Invalid port number. Must be between 1 and 65535" -msgstr "Неверный номер порта. Должен быть между 1 и 65535" - -msgid "Invalid Shadowsocks URL: missing or invalid server/port format" -msgstr "Неверный URL Shadowsocks: отсутствует или неверный формат сервера/порта" - -msgid "Invalid VLESS URL: missing UUID" -msgstr "Неверный URL VLESS: отсутствует UUID" - -msgid "Invalid VLESS URL: missing server address" -msgstr "Неверный URL VLESS: отсутствует адрес сервера" - -msgid "Invalid VLESS URL: missing server" -msgstr "Неверный URL VLESS: отсутствует сервер" - -msgid "Invalid VLESS URL: missing port" -msgstr "Неверный URL VLESS: отсутствует порт" - -msgid "Invalid VLESS URL: missing or invalid server/port format" -msgstr "Неверный URL VLESS: отсутствует или неверный формат сервера/порта" - -msgid "Invalid VLESS URL: missing query parameters" -msgstr "Неверный URL VLESS: отсутствуют параметры запроса" - -msgid "Invalid VLESS URL: type must be one of tcp, raw, udp, grpc, http, ws" -msgstr "Неверный URL VLESS: тип должен быть одним из tcp, raw, udp, grpc, http, ws" - -msgid "Invalid VLESS URL: security must be one of tls, reality, none" -msgstr "Неверный URL VLESS: security должен быть одним из tls, reality, none" - -msgid "Invalid VLESS URL: missing pbk parameter for reality security" -msgstr "Неверный URL VLESS: отсутствует параметр pbk для security reality" - -msgid "Invalid VLESS URL: missing fp parameter for reality security" -msgstr "Неверный URL VLESS: отсутствует параметр fp для security reality" - -msgid "Invalid URL format: " -msgstr "Неверный формат URL: " +msgid "Invalid URL format:" +msgstr "Неверный формат URL:" msgid "Outbound Configuration" msgstr "Конфигурация исходящего соединения" @@ -288,12 +70,6 @@ msgstr "Конфигурация исходящего соединения" msgid "Enter complete outbound configuration in JSON format" msgstr "Введите полную конфигурацию исходящего соединения в формате JSON" -msgid "JSON must contain at least type, server and server_port fields" -msgstr "JSON должен содержать как минимум поля type, server и server_port" - -msgid "Invalid JSON format" -msgstr "Неверный формат JSON" - msgid "URLTest Proxy Links" msgstr "Ссылки прокси для URLTest" @@ -315,8 +91,26 @@ msgstr "Резолвер доменов" msgid "Enable built-in DNS resolver for domains handled by this section" msgstr "Включить встроенный DNS-резолвер для доменов, обрабатываемых в этом разделе" +msgid "DNS Protocol Type" +msgstr "Тип протокола DNS" + msgid "Select the DNS protocol type for the domain resolver" -msgstr "Выберите протокол DNS для резолвера доменов" +msgstr "Выберите тип протокола DNS для резолвера доменов" + +msgid "DNS over HTTPS (DoH)" +msgstr "DNS через HTTPS (DoH)" + +msgid "DNS over TLS (DoT)" +msgstr "DNS через TLS (DoT)" + +msgid "UDP (Unprotected DNS)" +msgstr "UDP (Незащищённый DNS)" + +msgid "DNS Server" +msgstr "DNS-сервер" + +msgid "Select or enter DNS server address" +msgstr "Выберите или введите адрес DNS-сервера" msgid "Community Lists" msgstr "Списки сообщества" @@ -328,21 +122,16 @@ msgid "Select predefined service for routing" msgstr "Выберите предустановленные сервисы для маршрутизации" msgid "Regional options cannot be used together" -msgstr "Нельзя использовать несколько региональных опций" +msgstr "Нельзя использовать несколько региональных опций одновременно" -#, javascript-format msgid "Warning: %s cannot be used together with %s. Previous selections have been removed." msgstr "Предупреждение: %s нельзя использовать вместе с %s. Предыдущие варианты были удалены." msgid "Russia inside restrictions" msgstr "Ограничения Russia inside" -#, javascript-format -msgid "" -"Warning: Russia inside can only be used with %s. %s already in Russia inside and have been removed from selection." -msgstr "" -"Внимание: \"Russia inside\" может использоваться только с %s. %s уже находится в \"Russia inside\" и был удален из " -"выбора." +msgid "Warning: Russia inside can only be used with %s. %s already in Russia inside and have been removed from selection." +msgstr "Внимание: «Russia inside» может использоваться только с %s. %s уже находится в «Russia inside» и был удалён из выбора." msgid "User Domain List Type" msgstr "Тип пользовательского списка доменов" @@ -363,25 +152,19 @@ msgid "User Domains" msgstr "Пользовательские домены" msgid "Enter domain names without protocols (example: sub.example.com or example.com)" -msgstr "Введите доменные имена без указания протоколов (например: sub.example.com или example.com)" - -msgid "Invalid domain format. Enter domain without protocol (example: sub.example.com or ru)" -msgstr "Введите имена доменов без протоколов (пример: sub.example.com или example.com)" +msgstr "Введите доменные имена без протоколов (например: sub.example.com или example.com)" msgid "User Domains List" msgstr "Список пользовательских доменов" msgid "Enter domain names separated by comma, space or newline. You can add comments after //" -msgstr "" -"Введите имена доменов, разделяя их запятой, пробелом или с новой строки. Вы можете добавлять комментарии после //" - -#, javascript-format -msgid "Invalid domain format: %s. Enter domain without protocol" -msgstr "Неверный формат домена: %s. Введите домен без протокола" +msgstr "Введите домены через запятую, пробел или с новой строки. Можно добавлять комментарии после //" msgid "At least one valid domain must be specified. Comments-only content is not allowed." -msgstr "" -"Должен быть указан хотя бы один действительный домен. Содержимое, состоящее только из комментариев, не допускается." +msgstr "Необходимо указать хотя бы один действительный домен. Содержимое только из комментариев не допускается." + +msgid "Validation errors:" +msgstr "Ошибки валидации:" msgid "Local Domain Lists" msgstr "Локальные списки доменов" @@ -395,17 +178,14 @@ msgstr "Пути к локальным спискам доменов" msgid "Enter the list file path" msgstr "Введите путь к файлу списка" -msgid "Invalid path format. Path must start with \"/\" and contain valid characters" -msgstr "Неверный формат пути. Путь должен начинаться с \"/\" и содержать допустимые символы" - msgid "Remote Domain Lists" -msgstr "Удаленные списки доменов" +msgstr "Удалённые списки доменов" msgid "Download and use domain lists from remote URLs" -msgstr "Загрузка и использование списков доменов с удаленных URL" +msgstr "Загружать и использовать списки доменов с удалённых URL" msgid "Remote Domain URLs" -msgstr "URL удаленных доменов" +msgstr "URL удалённых доменов" msgid "Enter full URLs starting with http:// or https://" msgstr "Введите полные URL, начинающиеся с http:// или https://" @@ -423,58 +203,31 @@ msgid "Select how to add your custom subnets" msgstr "Выберите способ добавления пользовательских подсетей" msgid "Text List (comma/space/newline separated)" -msgstr "Текстовый список (разделенный запятыми/пробелами/новыми строками)" +msgstr "Текстовый список (через запятую, пробел или новую строку)" msgid "User Subnets" msgstr "Пользовательские подсети" msgid "Enter subnets in CIDR notation (example: 103.21.244.0/22) or single IP addresses" -msgstr "Введите подсети в нотации CIDR (пример: 103.21.244.0/22) или отдельные IP-адреса" - -msgid "Invalid format. Use format: X.X.X.X or X.X.X.X/Y" -msgstr "Неверный формат. Используйте формат: X.X.X.X или X.X.X.X/Y" - -msgid "IP address 0.0.0.0 is not allowed" -msgstr "IP адрес не может быть 0.0.0.0" - -msgid "CIDR must be between 0 and 32" -msgstr "CIDR должен быть между 0 и 32" +msgstr "Введите подсети в нотации CIDR (например: 103.21.244.0/22) или отдельные IP-адреса" msgid "User Subnets List" msgstr "Список пользовательских подсетей" -msgid "" -"Enter subnets in CIDR notation or single IP addresses, separated by comma, space or newline. You can add comments " -"after //" -msgstr "" -"Введите подсети в нотации CIDR или отдельные IP-адреса, разделенные запятой, пробелом или новой строкой. Вы можете " -"добавлять комментарии после //" - -#, javascript-format -msgid "Invalid format: %s. Use format: X.X.X.X or X.X.X.X/Y" -msgstr "Неверный формат: %s. Используйте формат: X.X.X.X или X.X.X.X/Y" - -#, javascript-format -msgid "IP parts must be between 0 and 255 in: %s" -msgstr "Части IP-адреса должны быть между 0 и 255 в: %s" - -#, javascript-format -msgid "CIDR must be between 0 and 32 in: %s" -msgstr "CIDR должен быть между 0 и 32 в: %s" +msgid "Enter subnets in CIDR notation or single IP addresses, separated by comma, space or newline. You can add comments after //" +msgstr "Введите подсети в нотации CIDR или IP-адреса через запятую, пробел или новую строку. Можно добавлять комментарии после //" msgid "At least one valid subnet or IP must be specified. Comments-only content is not allowed." -msgstr "" -"Должна быть указана хотя бы одна действительная подсеть или IP. Содержимое, состоящее только из комментариев, не " -"допускается." +msgstr "Необходимо указать хотя бы одну действительную подсеть или IP. Только комментарии недопустимы." msgid "Remote Subnet Lists" -msgstr "Удаленные списки подсетей" +msgstr "Удалённые списки подсетей" msgid "Download and use subnet lists from remote URLs" -msgstr "Загрузка и использование списков подсетей с удаленных URL" +msgstr "Загружать и использовать списки подсетей с удалённых URL" msgid "Remote Subnet URLs" -msgstr "URL удаленных подсетей" +msgstr "URL удалённых подсетей" msgid "IP for full redirection" msgstr "IP для полного перенаправления" @@ -482,21 +235,219 @@ msgstr "IP для полного перенаправления" msgid "Specify local IP addresses whose traffic will always use the configured route" msgstr "Укажите локальные IP-адреса, трафик которых всегда будет использовать настроенный маршрут" +msgid "Local IPs" +msgstr "Локальные IP-адреса" + +msgid "Enter valid IPv4 addresses" +msgstr "Введите действительные IPv4-адреса" + +msgid "Extra configurations" +msgstr "Дополнительные конфигурации" + +msgid "Add Section" +msgstr "Добавить раздел" + +msgid "Dashboard" +msgstr "Дашборд" + +msgid "Valid" +msgstr "Валидно" + +msgid "Invalid IP address" +msgstr "Неверный IP-адрес" + +msgid "Invalid domain address" +msgstr "Неверный домен" + +msgid "DNS server address cannot be empty" +msgstr "Адрес DNS-сервера не может быть пустым" + +msgid "Invalid DNS server format. Examples: 8.8.8.8 or dns.example.com or dns.example.com/nicedns for DoH" +msgstr "Неверный формат DNS-сервера. Примеры: 8.8.8.8, dns.example.com или dns.example.com/nicedns для DoH" + +msgid "URL must use one of the following protocols:" +msgstr "URL должен использовать один из следующих протоколов:" + +msgid "Invalid URL format" +msgstr "Неверный формат URL" + +msgid "Path cannot be empty" +msgstr "Путь не может быть пустым" + +msgid "Invalid path format. Path must start with \"/\" and contain valid characters" +msgstr "Неверный формат пути. Путь должен начинаться с \"/\" и содержать допустимые символы" + +msgid "Invalid format. Use X.X.X.X or X.X.X.X/Y" +msgstr "Неверный формат. Используйте X.X.X.X или X.X.X.X/Y" + +msgid "IP address 0.0.0.0 is not allowed" +msgstr "IP-адрес 0.0.0.0 не допускается" + +msgid "CIDR must be between 0 and 32" +msgstr "CIDR должен быть между 0 и 32" + +msgid "Invalid Shadowsocks URL: must start with ss://" +msgstr "Неверный URL Shadowsocks: должен начинаться с ss://" + +msgid "Invalid Shadowsocks URL: must not contain spaces" +msgstr "Неверный URL Shadowsocks: не должен содержать пробелов" + +msgid "Invalid Shadowsocks URL: missing credentials" +msgstr "Неверный URL Shadowsocks: отсутствуют учетные данные" + +msgid "Invalid Shadowsocks URL: decoded credentials must contain method:password" +msgstr "Неверный URL Shadowsocks: декодированные данные должны содержать method:password" + +msgid "Invalid Shadowsocks URL: missing method and password separator \":\"" +msgstr "Неверный URL Shadowsocks: отсутствует разделитель метода и пароля \":\"" + +msgid "Invalid Shadowsocks URL: missing server address" +msgstr "Неверный URL Shadowsocks: отсутствует адрес сервера" + +msgid "Invalid Shadowsocks URL: missing server" +msgstr "Неверный URL Shadowsocks: отсутствует сервер" + +msgid "Invalid Shadowsocks URL: missing port" +msgstr "Неверный URL Shadowsocks: отсутствует порт" + +msgid "Invalid port number. Must be between 1 and 65535" +msgstr "Неверный номер порта. Допустимо от 1 до 65535" + +msgid "Invalid Shadowsocks URL: parsing failed" +msgstr "Неверный URL Shadowsocks: ошибка разбора" + +msgid "Invalid VLESS URL: must not contain spaces" +msgstr "Неверный URL VLESS: не должен содержать пробелов" + +msgid "Invalid VLESS URL: must start with vless://" +msgstr "Неверный URL VLESS: должен начинаться с vless://" + +msgid "Invalid VLESS URL: missing UUID" +msgstr "Неверный URL VLESS: отсутствует UUID" + +msgid "Invalid VLESS URL: missing server" +msgstr "Неверный URL VLESS: отсутствует сервер" + +msgid "Invalid VLESS URL: missing port" +msgstr "Неверный URL VLESS: отсутствует порт" + +msgid "Invalid VLESS URL: invalid port number. Must be between 1 and 65535" +msgstr "Неверный URL VLESS: недопустимый порт (1–65535)" + +msgid "Invalid VLESS URL: missing query parameters" +msgstr "Неверный URL VLESS: отсутствуют параметры запроса" + +msgid "Invalid VLESS URL: type must be one of tcp, raw, udp, grpc, http, ws" +msgstr "Неверный URL VLESS: тип должен быть tcp, raw, udp, grpc, http или ws" + +msgid "Invalid VLESS URL: security must be one of tls, reality, none" +msgstr "Неверный URL VLESS: параметр security должен быть tls, reality или none" + +msgid "Invalid VLESS URL: missing pbk parameter for reality security" +msgstr "Неверный URL VLESS: отсутствует параметр pbk для security=reality" + +msgid "Invalid VLESS URL: missing fp parameter for reality security" +msgstr "Неверный URL VLESS: отсутствует параметр fp для security=reality" + +msgid "Invalid VLESS URL: parsing failed" +msgstr "Неверный URL VLESS: ошибка разбора" + +msgid "Outbound JSON must contain at least \"type\", \"server\" and \"server_port\" fields" +msgstr "JSON должен содержать поля \"type\", \"server\" и \"server_port\"" + +msgid "Invalid JSON format" +msgstr "Неверный формат JSON" + +msgid "Invalid Trojan URL: must start with trojan://" +msgstr "Неверный URL Trojan: должен начинаться с trojan://" + +msgid "Invalid Trojan URL: must not contain spaces" +msgstr "Неверный URL Trojan: не должен содержать пробелов" + +msgid "Invalid Trojan URL: must contain username, hostname and port" +msgstr "Неверный URL Trojan: должен содержать имя пользователя, хост и порт" + +msgid "Invalid Trojan URL: parsing failed" +msgstr "Неверный URL Trojan: ошибка разбора" + +msgid "URL must start with vless:// or ss:// or trojan://" +msgstr "URL должен начинаться с vless://, ss:// или trojan://" + +msgid "Operation timed out" +msgstr "Время ожидания истекло" + +msgid "HTTP error" +msgstr "Ошибка HTTP" + +msgid "Unknown error" +msgstr "Неизвестная ошибка" + +msgid "Fastest" +msgstr "Самый быстрый" + +msgid "Dashboard currently unavailable" +msgstr "Дашборд сейчас недоступен" + +msgid "Currently unavailable" +msgstr "Временно недоступно" + +msgid "Traffic" +msgstr "Трафик" + +msgid "Uplink" +msgstr "Исходящий" + +msgid "Downlink" +msgstr "Входящий" + +msgid "Traffic Total" +msgstr "Всего трафика" + +msgid "System info" +msgstr "Системная информация" + +msgid "Active Connections" +msgstr "Активные соединения" + +msgid "Memory Usage" +msgstr "Использование памяти" + +msgid "Services info" +msgstr "Информация о сервисах" + +msgid "Podkop" +msgstr "Podkop" + +msgid "✔ Enabled" +msgstr "✔ Включено" + +msgid "✘ Disabled" +msgstr "✘ Отключено" + +msgid "Sing-box" +msgstr "Sing-box" + +msgid "✔ Running" +msgstr "✔ Работает" + +msgid "✘ Stopped" +msgstr "✘ Остановлен" + msgid "Copied!" msgstr "Скопировано!" msgid "Failed to copy: " msgstr "Не удалось скопировать: " +msgid "Loading..." +msgstr "Загрузка..." + msgid "Copy to Clipboard" -msgstr "Копировать в буфер обмена" +msgstr "Копировать в буфер" msgid "Close" msgstr "Закрыть" -msgid "Loading..." -msgstr "Загрузка..." - msgid "No output" msgstr "Нет вывода" @@ -507,7 +458,7 @@ msgid "FakeIP is not working in browser" msgstr "FakeIP не работает в браузере" msgid "Check DNS server on current device (PC, phone)" -msgstr "Проверьте DNS сервер на текущем устройстве (ПК, телефон)" +msgstr "Проверьте DNS-сервер на текущем устройстве (ПК, телефон)" msgid "Its must be router!" msgstr "Это должен быть роутер!" @@ -522,7 +473,7 @@ msgid "Proxy IP: " msgstr "Прокси IP: " msgid "Proxy is not working - same IP for both domains" -msgstr "Прокси не работает - одинаковый IP для обоих доменов" +msgstr "Прокси не работает — одинаковый IP для обоих доменов" msgid "IP: " msgstr "IP: " diff --git a/luci-app-podkop/po/templates/podkop.pot b/luci-app-podkop/po/templates/podkop.pot index e52267d..778c412 100644 --- a/luci-app-podkop/po/templates/podkop.pot +++ b/luci-app-podkop/po/templates/podkop.pot @@ -8,266 +8,32 @@ msgid "" msgstr "" "Project-Id-Version: PODKOP\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2025-10-02 19:37+0500\n" +"POT-Creation-Date: 2025-10-07 16:55+0300\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" "Language: \n" "MIME-Version: 1.0\n" -"Content-Type: text/plain; charset=CHARSET\n" +"Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" -#: htdocs/luci-static/resources/view/podkop/additionalTab.js:8 -msgid "Additional Settings" -msgstr "" - -#: htdocs/luci-static/resources/view/podkop/additionalTab.js:10 -msgid "Yacd enable" -msgstr "" - -#: htdocs/luci-static/resources/view/podkop/additionalTab.js:15 -msgid "Exclude NTP" -msgstr "" - -#: htdocs/luci-static/resources/view/podkop/additionalTab.js:15 -msgid "Allows you to exclude NTP protocol traffic from the tunnel" -msgstr "" - -#: htdocs/luci-static/resources/view/podkop/additionalTab.js:20 -msgid "QUIC disable" -msgstr "" - -#: htdocs/luci-static/resources/view/podkop/additionalTab.js:20 -msgid "For issues with the video stream" -msgstr "" - -#: htdocs/luci-static/resources/view/podkop/additionalTab.js:25 -msgid "List Update Frequency" -msgstr "" - -#: htdocs/luci-static/resources/view/podkop/additionalTab.js:25 -msgid "Select how often the lists will be updated" -msgstr "" - -#: htdocs/luci-static/resources/view/podkop/additionalTab.js:33 -#: htdocs/luci-static/resources/view/podkop/configSection.js:249 -msgid "DNS Protocol Type" -msgstr "" - -#: htdocs/luci-static/resources/view/podkop/additionalTab.js:33 -msgid "Select DNS protocol to use" -msgstr "" - -#: htdocs/luci-static/resources/view/podkop/additionalTab.js:34 -#: htdocs/luci-static/resources/view/podkop/configSection.js:250 -msgid "DNS over HTTPS (DoH)" -msgstr "" - -#: htdocs/luci-static/resources/view/podkop/additionalTab.js:35 -#: htdocs/luci-static/resources/view/podkop/configSection.js:251 -msgid "DNS over TLS (DoT)" -msgstr "" - -#: htdocs/luci-static/resources/view/podkop/additionalTab.js:36 -#: htdocs/luci-static/resources/view/podkop/configSection.js:252 -msgid "UDP (Unprotected DNS)" -msgstr "" - -#: htdocs/luci-static/resources/view/podkop/additionalTab.js:41 -#: htdocs/luci-static/resources/view/podkop/configSection.js:258 -msgid "DNS Server" -msgstr "" - -#: htdocs/luci-static/resources/view/podkop/additionalTab.js:41 -#: htdocs/luci-static/resources/view/podkop/configSection.js:258 -msgid "Select or enter DNS server address" -msgstr "" - -#: htdocs/luci-static/resources/view/podkop/additionalTab.js:50 -#: htdocs/luci-static/resources/view/podkop/additionalTab.js:77 -#: htdocs/luci-static/resources/view/podkop/configSection.js:268 -msgid "DNS server address cannot be empty" -msgstr "" - -#: htdocs/luci-static/resources/view/podkop/additionalTab.js:57 -#: htdocs/luci-static/resources/view/podkop/configSection.js:275 -msgid "Invalid DNS server format. Examples: 8.8.8.8 or dns.example.com or dns.example.com/nicedns for DoH" -msgstr "" - -#: htdocs/luci-static/resources/view/podkop/additionalTab.js:63 -msgid "Bootstrap DNS server" -msgstr "" - -#: htdocs/luci-static/resources/view/podkop/additionalTab.js:63 -msgid "The DNS server used to look up the IP address of an upstream DNS server" -msgstr "" - -#: htdocs/luci-static/resources/view/podkop/additionalTab.js:83 -msgid "Invalid DNS server format. Example: 8.8.8.8" -msgstr "" - -#: htdocs/luci-static/resources/view/podkop/additionalTab.js:89 -msgid "DNS Rewrite TTL" -msgstr "" - -#: htdocs/luci-static/resources/view/podkop/additionalTab.js:89 -msgid "Time in seconds for DNS record caching (default: 60)" -msgstr "" - -#: htdocs/luci-static/resources/view/podkop/additionalTab.js:95 -msgid "TTL value cannot be empty" -msgstr "" - -#: htdocs/luci-static/resources/view/podkop/additionalTab.js:100 -msgid "TTL must be a positive number" -msgstr "" - -#: htdocs/luci-static/resources/view/podkop/additionalTab.js:106 -msgid "Config File Path" -msgstr "" - -#: htdocs/luci-static/resources/view/podkop/additionalTab.js:106 -msgid "Select path for sing-box config file. Change this ONLY if you know what you are doing" -msgstr "" - -#: htdocs/luci-static/resources/view/podkop/additionalTab.js:113 -msgid "Cache File Path" -msgstr "" - -#: htdocs/luci-static/resources/view/podkop/additionalTab.js:113 -msgid "Select or enter path for sing-box cache file. Change this ONLY if you know what you are doing" -msgstr "" - -#: htdocs/luci-static/resources/view/podkop/additionalTab.js:121 -msgid "Cache file path cannot be empty" -msgstr "" - -#: htdocs/luci-static/resources/view/podkop/additionalTab.js:125 -msgid "Path must be absolute (start with /)" -msgstr "" - -#: htdocs/luci-static/resources/view/podkop/additionalTab.js:129 -msgid "Path must end with cache.db" -msgstr "" - -#: htdocs/luci-static/resources/view/podkop/additionalTab.js:134 -msgid "Path must contain at least one directory (like /tmp/cache.db)" -msgstr "" - -#: htdocs/luci-static/resources/view/podkop/additionalTab.js:140 -msgid "Source Network Interface" -msgstr "" - -#: htdocs/luci-static/resources/view/podkop/additionalTab.js:140 -msgid "Select the network interface from which the traffic will originate" -msgstr "" - -#: htdocs/luci-static/resources/view/podkop/additionalTab.js:164 -msgid "Interface monitoring" -msgstr "" - -#: htdocs/luci-static/resources/view/podkop/additionalTab.js:164 -msgid "Interface monitoring for bad WAN" -msgstr "" - -#: htdocs/luci-static/resources/view/podkop/additionalTab.js:169 -msgid "Interface for monitoring" -msgstr "" - -#: htdocs/luci-static/resources/view/podkop/additionalTab.js:169 -msgid "Select the WAN interfaces to be monitored" -msgstr "" - -#: htdocs/luci-static/resources/view/podkop/additionalTab.js:177 -msgid "Interface Monitoring Delay" -msgstr "" - -#: htdocs/luci-static/resources/view/podkop/additionalTab.js:177 -msgid "Delay in milliseconds before reloading podkop after interface UP" -msgstr "" - -#: htdocs/luci-static/resources/view/podkop/additionalTab.js:184 -msgid "Delay value cannot be empty" -msgstr "" - -#: htdocs/luci-static/resources/view/podkop/additionalTab.js:189 -msgid "Dont touch my DHCP!" -msgstr "" - -#: htdocs/luci-static/resources/view/podkop/additionalTab.js:189 -msgid "Podkop will not change the DHCP config" -msgstr "" - -#: htdocs/luci-static/resources/view/podkop/additionalTab.js:194 -msgid "Proxy download of lists" -msgstr "" - -#: htdocs/luci-static/resources/view/podkop/additionalTab.js:194 -msgid "Downloading all lists via main Proxy/VPN" -msgstr "" - -#: htdocs/luci-static/resources/view/podkop/additionalTab.js:200 -msgid "IP for exclusion" -msgstr "" - -#: htdocs/luci-static/resources/view/podkop/additionalTab.js:200 -msgid "Specify local IP addresses that will never use the configured route" -msgstr "" - -#: htdocs/luci-static/resources/view/podkop/additionalTab.js:205 -#: htdocs/luci-static/resources/view/podkop/configSection.js:574 -msgid "Local IPs" -msgstr "" - -#: htdocs/luci-static/resources/view/podkop/additionalTab.js:205 -#: htdocs/luci-static/resources/view/podkop/configSection.js:574 -msgid "Enter valid IPv4 addresses" -msgstr "" - -#: htdocs/luci-static/resources/view/podkop/additionalTab.js:213 -#: htdocs/luci-static/resources/view/podkop/configSection.js:582 -msgid "Invalid IP format. Use format: X.X.X.X (like 192.168.1.1)" -msgstr "" - -#: htdocs/luci-static/resources/view/podkop/additionalTab.js:217 -#: htdocs/luci-static/resources/view/podkop/configSection.js:488 -#: htdocs/luci-static/resources/view/podkop/configSection.js:586 -msgid "IP address parts must be between 0 and 255" -msgstr "" - -#: htdocs/luci-static/resources/view/podkop/additionalTab.js:222 -msgid "Mixed enable" -msgstr "" - -#: htdocs/luci-static/resources/view/podkop/additionalTab.js:222 -msgid "Browser port: 2080" -msgstr "" - -#: htdocs/luci-static/resources/view/podkop/configSection.js:13 -msgid "URL must use one of the following protocols: " -msgstr "" - -#: htdocs/luci-static/resources/view/podkop/configSection.js:17 -msgid "Invalid URL format" -msgstr "" - -#: htdocs/luci-static/resources/view/podkop/configSection.js:24 +#: htdocs/luci-static/resources/view/podkop/configSection.js:12 msgid "Basic Settings" msgstr "" -#: htdocs/luci-static/resources/view/podkop/configSection.js:26 +#: htdocs/luci-static/resources/view/podkop/configSection.js:18 msgid "Connection Type" msgstr "" -#: htdocs/luci-static/resources/view/podkop/configSection.js:26 +#: htdocs/luci-static/resources/view/podkop/configSection.js:19 msgid "Select between VPN and Proxy connection methods for traffic routing" msgstr "" -#: htdocs/luci-static/resources/view/podkop/configSection.js:32 +#: htdocs/luci-static/resources/view/podkop/configSection.js:30 msgid "Configuration Type" msgstr "" -#: htdocs/luci-static/resources/view/podkop/configSection.js:32 +#: htdocs/luci-static/resources/view/podkop/configSection.js:31 msgid "Select how to configure the proxy" msgstr "" @@ -283,125 +49,47 @@ msgstr "" msgid "URLTest" msgstr "" -#: htdocs/luci-static/resources/view/podkop/configSection.js:40 +#: htdocs/luci-static/resources/view/podkop/configSection.js:44 msgid "Proxy Configuration URL" msgstr "" -#: htdocs/luci-static/resources/view/podkop/configSection.js:64 +#: htdocs/luci-static/resources/view/podkop/configSection.js:81 msgid "Current config: " msgstr "" -#: htdocs/luci-static/resources/view/podkop/configSection.js:67 -#: htdocs/luci-static/resources/view/podkop/configSection.js:71 -#: htdocs/luci-static/resources/view/podkop/configSection.js:77 +#: htdocs/luci-static/resources/view/podkop/configSection.js:88 +#: htdocs/luci-static/resources/view/podkop/configSection.js:96 +#: htdocs/luci-static/resources/view/podkop/configSection.js:106 msgid "Config without description" msgstr "" -#: htdocs/luci-static/resources/view/podkop/configSection.js:82 +#: htdocs/luci-static/resources/view/podkop/configSection.js:115 msgid "" "Enter connection string starting with vless:// or ss:// for proxy configuration. Add comments with // for backup " "configs" msgstr "" -#: htdocs/luci-static/resources/view/podkop/configSection.js:100 -msgid "No active configuration found. At least one non-commented line is required." +#: htdocs/luci-static/resources/view/podkop/configSection.js:139 +msgid "No active configuration found. One configuration is required." msgstr "" -#: htdocs/luci-static/resources/view/podkop/configSection.js:104 -msgid "URL must start with vless:// or ss://" +#: htdocs/luci-static/resources/view/podkop/configSection.js:145 +msgid "Multiply active configurations found. Please leave one configuration." msgstr "" -#: htdocs/luci-static/resources/view/podkop/configSection.js:116 -#: htdocs/luci-static/resources/view/podkop/configSection.js:121 -msgid "Invalid Shadowsocks URL format: missing method and password separator \":\"" -msgstr "" - -#: htdocs/luci-static/resources/view/podkop/configSection.js:125 -msgid "Invalid Shadowsocks URL format" -msgstr "" - -#: htdocs/luci-static/resources/view/podkop/configSection.js:130 -msgid "Invalid Shadowsocks URL: missing server address" -msgstr "" - -#: htdocs/luci-static/resources/view/podkop/configSection.js:132 -msgid "Invalid Shadowsocks URL: missing server" -msgstr "" - -#: htdocs/luci-static/resources/view/podkop/configSection.js:134 -msgid "Invalid Shadowsocks URL: missing port" -msgstr "" - -#: htdocs/luci-static/resources/view/podkop/configSection.js:137 #: htdocs/luci-static/resources/view/podkop/configSection.js:157 -msgid "Invalid port number. Must be between 1 and 65535" +msgid "Invalid URL format:" msgstr "" -#: htdocs/luci-static/resources/view/podkop/configSection.js:140 -msgid "Invalid Shadowsocks URL: missing or invalid server/port format" -msgstr "" - -#: htdocs/luci-static/resources/view/podkop/configSection.js:146 -msgid "Invalid VLESS URL: missing UUID" -msgstr "" - -#: htdocs/luci-static/resources/view/podkop/configSection.js:150 -msgid "Invalid VLESS URL: missing server address" -msgstr "" - -#: htdocs/luci-static/resources/view/podkop/configSection.js:152 -msgid "Invalid VLESS URL: missing server" -msgstr "" - -#: htdocs/luci-static/resources/view/podkop/configSection.js:154 -msgid "Invalid VLESS URL: missing port" -msgstr "" - -#: htdocs/luci-static/resources/view/podkop/configSection.js:160 -msgid "Invalid VLESS URL: missing or invalid server/port format" -msgstr "" - -#: htdocs/luci-static/resources/view/podkop/configSection.js:164 -msgid "Invalid VLESS URL: missing query parameters" -msgstr "" - -#: htdocs/luci-static/resources/view/podkop/configSection.js:170 -msgid "Invalid VLESS URL: type must be one of tcp, raw, udp, grpc, http, ws" -msgstr "" - -#: htdocs/luci-static/resources/view/podkop/configSection.js:176 -msgid "Invalid VLESS URL: security must be one of tls, reality, none" -msgstr "" - -#: htdocs/luci-static/resources/view/podkop/configSection.js:180 -msgid "Invalid VLESS URL: missing pbk parameter for reality security" -msgstr "" - -#: htdocs/luci-static/resources/view/podkop/configSection.js:181 -msgid "Invalid VLESS URL: missing fp parameter for reality security" -msgstr "" - -#: htdocs/luci-static/resources/view/podkop/configSection.js:188 -msgid "Invalid URL format: " -msgstr "" - -#: htdocs/luci-static/resources/view/podkop/configSection.js:192 +#: htdocs/luci-static/resources/view/podkop/configSection.js:165 msgid "Outbound Configuration" msgstr "" -#: htdocs/luci-static/resources/view/podkop/configSection.js:192 +#: htdocs/luci-static/resources/view/podkop/configSection.js:166 msgid "Enter complete outbound configuration in JSON format" msgstr "" -#: htdocs/luci-static/resources/view/podkop/configSection.js:201 -msgid "JSON must contain at least type, server and server_port fields" -msgstr "" - -#: htdocs/luci-static/resources/view/podkop/configSection.js:205 -msgid "Invalid JSON format" -msgstr "" - -#: htdocs/luci-static/resources/view/podkop/configSection.js:209 +#: htdocs/luci-static/resources/view/podkop/configSection.js:190 msgid "URLTest Proxy Links" msgstr "" @@ -409,448 +97,858 @@ msgstr "" msgid "Shadowsocks UDP over TCP" msgstr "" -#: htdocs/luci-static/resources/view/podkop/configSection.js:214 +#: htdocs/luci-static/resources/view/podkop/configSection.js:215 msgid "Apply for SS2022" msgstr "" -#: htdocs/luci-static/resources/view/podkop/configSection.js:220 +#: htdocs/luci-static/resources/view/podkop/configSection.js:226 msgid "Network Interface" msgstr "" -#: htdocs/luci-static/resources/view/podkop/configSection.js:220 +#: htdocs/luci-static/resources/view/podkop/configSection.js:227 msgid "Select network interface for VPN connection" msgstr "" -#: htdocs/luci-static/resources/view/podkop/configSection.js:243 +#: htdocs/luci-static/resources/view/podkop/configSection.js:274 msgid "Domain Resolver" msgstr "" -#: htdocs/luci-static/resources/view/podkop/configSection.js:243 +#: htdocs/luci-static/resources/view/podkop/configSection.js:275 msgid "Enable built-in DNS resolver for domains handled by this section" msgstr "" -#: htdocs/luci-static/resources/view/podkop/configSection.js:249 +#: htdocs/luci-static/resources/view/podkop/configSection.js:286 +#: htdocs/luci-static/resources/view/podkop/additionalTab.js:61 +msgid "DNS Protocol Type" +msgstr "" + +#: htdocs/luci-static/resources/view/podkop/configSection.js:287 msgid "Select the DNS protocol type for the domain resolver" msgstr "" -#: htdocs/luci-static/resources/view/podkop/configSection.js:281 +#: htdocs/luci-static/resources/view/podkop/configSection.js:289 +#: htdocs/luci-static/resources/view/podkop/additionalTab.js:64 +msgid "DNS over HTTPS (DoH)" +msgstr "" + +#: htdocs/luci-static/resources/view/podkop/configSection.js:290 +#: htdocs/luci-static/resources/view/podkop/additionalTab.js:65 +msgid "DNS over TLS (DoT)" +msgstr "" + +#: htdocs/luci-static/resources/view/podkop/configSection.js:291 +#: htdocs/luci-static/resources/view/podkop/additionalTab.js:66 +msgid "UDP (Unprotected DNS)" +msgstr "" + +#: htdocs/luci-static/resources/view/podkop/configSection.js:301 +#: htdocs/luci-static/resources/view/podkop/additionalTab.js:75 +msgid "DNS Server" +msgstr "" + +#: htdocs/luci-static/resources/view/podkop/configSection.js:302 +#: htdocs/luci-static/resources/view/podkop/additionalTab.js:76 +msgid "Select or enter DNS server address" +msgstr "" + +#: htdocs/luci-static/resources/view/podkop/configSection.js:325 msgid "Community Lists" msgstr "" -#: htdocs/luci-static/resources/view/podkop/configSection.js:286 +#: htdocs/luci-static/resources/view/podkop/configSection.js:335 msgid "Service List" msgstr "" -#: htdocs/luci-static/resources/view/podkop/configSection.js:286 +#: htdocs/luci-static/resources/view/podkop/configSection.js:336 msgid "Select predefined service for routing" msgstr "" -#: htdocs/luci-static/resources/view/podkop/configSection.js:314 +#: htdocs/luci-static/resources/view/podkop/configSection.js:372 msgid "Regional options cannot be used together" msgstr "" -#: htdocs/luci-static/resources/view/podkop/configSection.js:315 +#: htdocs/luci-static/resources/view/podkop/configSection.js:375 #, javascript-format msgid "Warning: %s cannot be used together with %s. Previous selections have been removed." msgstr "" -#: htdocs/luci-static/resources/view/podkop/configSection.js:325 +#: htdocs/luci-static/resources/view/podkop/configSection.js:391 msgid "Russia inside restrictions" msgstr "" -#: htdocs/luci-static/resources/view/podkop/configSection.js:326 +#: htdocs/luci-static/resources/view/podkop/configSection.js:394 #, javascript-format msgid "" "Warning: Russia inside can only be used with %s. %s already in Russia inside and have been removed from selection." msgstr "" -#: htdocs/luci-static/resources/view/podkop/configSection.js:348 +#: htdocs/luci-static/resources/view/podkop/configSection.js:427 msgid "User Domain List Type" msgstr "" -#: htdocs/luci-static/resources/view/podkop/configSection.js:348 +#: htdocs/luci-static/resources/view/podkop/configSection.js:428 msgid "Select how to add your custom domains" msgstr "" -#: htdocs/luci-static/resources/view/podkop/configSection.js:349 -#: htdocs/luci-static/resources/view/podkop/configSection.js:465 +#: htdocs/luci-static/resources/view/podkop/configSection.js:430 +#: htdocs/luci-static/resources/view/podkop/configSection.js:625 msgid "Disabled" msgstr "" -#: htdocs/luci-static/resources/view/podkop/configSection.js:350 -#: htdocs/luci-static/resources/view/podkop/configSection.js:466 +#: htdocs/luci-static/resources/view/podkop/configSection.js:431 +#: htdocs/luci-static/resources/view/podkop/configSection.js:626 msgid "Dynamic List" msgstr "" -#: htdocs/luci-static/resources/view/podkop/configSection.js:351 +#: htdocs/luci-static/resources/view/podkop/configSection.js:432 msgid "Text List" msgstr "" -#: htdocs/luci-static/resources/view/podkop/configSection.js:356 +#: htdocs/luci-static/resources/view/podkop/configSection.js:441 msgid "User Domains" msgstr "" -#: htdocs/luci-static/resources/view/podkop/configSection.js:356 +#: htdocs/luci-static/resources/view/podkop/configSection.js:443 msgid "Enter domain names without protocols (example: sub.example.com or example.com)" msgstr "" -#: htdocs/luci-static/resources/view/podkop/configSection.js:365 -msgid "Invalid domain format. Enter domain without protocol (example: sub.example.com or ru)" -msgstr "" - -#: htdocs/luci-static/resources/view/podkop/configSection.js:370 +#: htdocs/luci-static/resources/view/podkop/configSection.js:469 msgid "User Domains List" msgstr "" -#: htdocs/luci-static/resources/view/podkop/configSection.js:370 +#: htdocs/luci-static/resources/view/podkop/configSection.js:471 msgid "Enter domain names separated by comma, space or newline. You can add comments after //" msgstr "" -#: htdocs/luci-static/resources/view/podkop/configSection.js:398 -#, javascript-format -msgid "Invalid domain format: %s. Enter domain without protocol" -msgstr "" - -#: htdocs/luci-static/resources/view/podkop/configSection.js:405 +#: htdocs/luci-static/resources/view/podkop/configSection.js:490 msgid "At least one valid domain must be specified. Comments-only content is not allowed." msgstr "" -#: htdocs/luci-static/resources/view/podkop/configSection.js:411 +#: htdocs/luci-static/resources/view/podkop/configSection.js:501 +#: htdocs/luci-static/resources/view/podkop/configSection.js:696 +msgid "Validation errors:" +msgstr "" + +#: htdocs/luci-static/resources/view/podkop/configSection.js:511 msgid "Local Domain Lists" msgstr "" -#: htdocs/luci-static/resources/view/podkop/configSection.js:411 -#: htdocs/luci-static/resources/view/podkop/configSection.js:445 +#: htdocs/luci-static/resources/view/podkop/configSection.js:512 +#: htdocs/luci-static/resources/view/podkop/configSection.js:586 msgid "Use the list from the router filesystem" msgstr "" -#: htdocs/luci-static/resources/view/podkop/configSection.js:416 +#: htdocs/luci-static/resources/view/podkop/configSection.js:522 msgid "Local Domain List Paths" msgstr "" -#: htdocs/luci-static/resources/view/podkop/configSection.js:416 -#: htdocs/luci-static/resources/view/podkop/configSection.js:450 +#: htdocs/luci-static/resources/view/podkop/configSection.js:523 +#: htdocs/luci-static/resources/view/podkop/configSection.js:597 msgid "Enter the list file path" msgstr "" -#: htdocs/luci-static/resources/view/podkop/configSection.js:425 -#: htdocs/luci-static/resources/view/podkop/configSection.js:459 -msgid "Invalid path format. Path must start with \"/\" and contain valid characters" -msgstr "" - -#: htdocs/luci-static/resources/view/podkop/configSection.js:430 +#: htdocs/luci-static/resources/view/podkop/configSection.js:548 msgid "Remote Domain Lists" msgstr "" -#: htdocs/luci-static/resources/view/podkop/configSection.js:430 +#: htdocs/luci-static/resources/view/podkop/configSection.js:549 msgid "Download and use domain lists from remote URLs" msgstr "" -#: htdocs/luci-static/resources/view/podkop/configSection.js:435 +#: htdocs/luci-static/resources/view/podkop/configSection.js:559 msgid "Remote Domain URLs" msgstr "" -#: htdocs/luci-static/resources/view/podkop/configSection.js:435 -#: htdocs/luci-static/resources/view/podkop/configSection.js:559 +#: htdocs/luci-static/resources/view/podkop/configSection.js:560 +#: htdocs/luci-static/resources/view/podkop/configSection.js:718 msgid "Enter full URLs starting with http:// or https://" msgstr "" -#: htdocs/luci-static/resources/view/podkop/configSection.js:445 +#: htdocs/luci-static/resources/view/podkop/configSection.js:585 msgid "Local Subnet Lists" msgstr "" -#: htdocs/luci-static/resources/view/podkop/configSection.js:450 +#: htdocs/luci-static/resources/view/podkop/configSection.js:596 msgid "Local Subnet List Paths" msgstr "" -#: htdocs/luci-static/resources/view/podkop/configSection.js:464 +#: htdocs/luci-static/resources/view/podkop/configSection.js:622 msgid "User Subnet List Type" msgstr "" -#: htdocs/luci-static/resources/view/podkop/configSection.js:464 +#: htdocs/luci-static/resources/view/podkop/configSection.js:623 msgid "Select how to add your custom subnets" msgstr "" -#: htdocs/luci-static/resources/view/podkop/configSection.js:467 +#: htdocs/luci-static/resources/view/podkop/configSection.js:627 msgid "Text List (comma/space/newline separated)" msgstr "" -#: htdocs/luci-static/resources/view/podkop/configSection.js:472 +#: htdocs/luci-static/resources/view/podkop/configSection.js:636 msgid "User Subnets" msgstr "" -#: htdocs/luci-static/resources/view/podkop/configSection.js:472 +#: htdocs/luci-static/resources/view/podkop/configSection.js:638 msgid "Enter subnets in CIDR notation (example: 103.21.244.0/22) or single IP addresses" msgstr "" -#: htdocs/luci-static/resources/view/podkop/configSection.js:480 -msgid "Invalid format. Use format: X.X.X.X or X.X.X.X/Y" -msgstr "" - -#: htdocs/luci-static/resources/view/podkop/configSection.js:483 -msgid "IP address 0.0.0.0 is not allowed" -msgstr "" - -#: htdocs/luci-static/resources/view/podkop/configSection.js:492 -msgid "CIDR must be between 0 and 32" -msgstr "" - -#: htdocs/luci-static/resources/view/podkop/configSection.js:497 +#: htdocs/luci-static/resources/view/podkop/configSection.js:664 msgid "User Subnets List" msgstr "" -#: htdocs/luci-static/resources/view/podkop/configSection.js:497 +#: htdocs/luci-static/resources/view/podkop/configSection.js:666 msgid "" "Enter subnets in CIDR notation or single IP addresses, separated by comma, space or newline. You can add comments " "after //" msgstr "" -#: htdocs/luci-static/resources/view/podkop/configSection.js:525 -#, javascript-format -msgid "Invalid format: %s. Use format: X.X.X.X or X.X.X.X/Y" -msgstr "" - -#: htdocs/luci-static/resources/view/podkop/configSection.js:533 -#, javascript-format -msgid "IP parts must be between 0 and 255 in: %s" -msgstr "" - -#: htdocs/luci-static/resources/view/podkop/configSection.js:540 -#, javascript-format -msgid "CIDR must be between 0 and 32 in: %s" -msgstr "" - -#: htdocs/luci-static/resources/view/podkop/configSection.js:548 +#: htdocs/luci-static/resources/view/podkop/configSection.js:685 msgid "At least one valid subnet or IP must be specified. Comments-only content is not allowed." msgstr "" -#: htdocs/luci-static/resources/view/podkop/configSection.js:554 +#: htdocs/luci-static/resources/view/podkop/configSection.js:706 msgid "Remote Subnet Lists" msgstr "" -#: htdocs/luci-static/resources/view/podkop/configSection.js:554 +#: htdocs/luci-static/resources/view/podkop/configSection.js:707 msgid "Download and use subnet lists from remote URLs" msgstr "" -#: htdocs/luci-static/resources/view/podkop/configSection.js:559 +#: htdocs/luci-static/resources/view/podkop/configSection.js:717 msgid "Remote Subnet URLs" msgstr "" -#: htdocs/luci-static/resources/view/podkop/configSection.js:569 +#: htdocs/luci-static/resources/view/podkop/configSection.js:743 msgid "IP for full redirection" msgstr "" -#: htdocs/luci-static/resources/view/podkop/configSection.js:569 +#: htdocs/luci-static/resources/view/podkop/configSection.js:745 msgid "Specify local IP addresses whose traffic will always use the configured route" msgstr "" -#: htdocs/luci-static/resources/view/podkop/diagnosticTab.js:121 -msgid "Copied!" +#: htdocs/luci-static/resources/view/podkop/configSection.js:756 +#: htdocs/luci-static/resources/view/podkop/additionalTab.js:326 +msgid "Local IPs" msgstr "" -#: htdocs/luci-static/resources/view/podkop/diagnosticTab.js:124 -msgid "Failed to copy: " +#: htdocs/luci-static/resources/view/podkop/configSection.js:757 +#: htdocs/luci-static/resources/view/podkop/additionalTab.js:327 +msgid "Enter valid IPv4 addresses" msgstr "" -#: htdocs/luci-static/resources/view/podkop/diagnosticTab.js:272 -#: htdocs/luci-static/resources/view/podkop/diagnosticTab.js:303 -msgid "Copy to Clipboard" -msgstr "" - -#: htdocs/luci-static/resources/view/podkop/diagnosticTab.js:276 -#: htdocs/luci-static/resources/view/podkop/diagnosticTab.js:307 -msgid "Close" -msgstr "" - -#: htdocs/luci-static/resources/view/podkop/diagnosticTab.js:293 -#: htdocs/luci-static/resources/view/podkop/diagnosticTab.js:439 -#: htdocs/luci-static/resources/view/podkop/diagnosticTab.js:579 -#: htdocs/luci-static/resources/view/podkop/diagnosticTab.js:580 -#: htdocs/luci-static/resources/view/podkop/diagnosticTab.js:581 -#: htdocs/luci-static/resources/view/podkop/diagnosticTab.js:582 -#: htdocs/luci-static/resources/view/podkop/diagnosticTab.js:583 -msgid "Loading..." -msgstr "" - -#: htdocs/luci-static/resources/view/podkop/diagnosticTab.js:326 -#: htdocs/luci-static/resources/view/podkop/diagnosticTab.js:388 -msgid "No output" -msgstr "" - -#: htdocs/luci-static/resources/view/podkop/diagnosticTab.js:338 -msgid "FakeIP is working in browser!" -msgstr "" - -#: htdocs/luci-static/resources/view/podkop/diagnosticTab.js:340 -msgid "FakeIP is not working in browser" -msgstr "" - -#: htdocs/luci-static/resources/view/podkop/diagnosticTab.js:341 -msgid "Check DNS server on current device (PC, phone)" -msgstr "" - -#: htdocs/luci-static/resources/view/podkop/diagnosticTab.js:342 -msgid "Its must be router!" -msgstr "" - -#: htdocs/luci-static/resources/view/podkop/diagnosticTab.js:355 -msgid "Proxy working correctly" -msgstr "" - -#: htdocs/luci-static/resources/view/podkop/diagnosticTab.js:356 -msgid "Direct IP: " -msgstr "" - -#: htdocs/luci-static/resources/view/podkop/diagnosticTab.js:357 -msgid "Proxy IP: " -msgstr "" - -#: htdocs/luci-static/resources/view/podkop/diagnosticTab.js:359 -msgid "Proxy is not working - same IP for both domains" -msgstr "" - -#: htdocs/luci-static/resources/view/podkop/diagnosticTab.js:360 -msgid "IP: " -msgstr "" - -#: htdocs/luci-static/resources/view/podkop/diagnosticTab.js:362 -msgid "Proxy check failed" -msgstr "" - -#: htdocs/luci-static/resources/view/podkop/diagnosticTab.js:368 -#: htdocs/luci-static/resources/view/podkop/diagnosticTab.js:373 -#: htdocs/luci-static/resources/view/podkop/diagnosticTab.js:378 -#: htdocs/luci-static/resources/view/podkop/diagnosticTab.js:382 -msgid "Check failed: " -msgstr "" - -#: htdocs/luci-static/resources/view/podkop/diagnosticTab.js:368 -#: htdocs/luci-static/resources/view/podkop/diagnosticTab.js:373 -#: htdocs/luci-static/resources/view/podkop/diagnosticTab.js:378 -msgid "timeout" -msgstr "" - -#: htdocs/luci-static/resources/view/podkop/diagnosticTab.js:393 -msgid "Error: " -msgstr "" - -#: htdocs/luci-static/resources/view/podkop/diagnosticTab.js:461 -msgid "Podkop Status" -msgstr "" - -#: htdocs/luci-static/resources/view/podkop/diagnosticTab.js:486 -msgid "Global check" -msgstr "" - -#: htdocs/luci-static/resources/view/podkop/diagnosticTab.js:488 -msgid "Click here for all the info" -msgstr "" - -#: htdocs/luci-static/resources/view/podkop/diagnosticTab.js:496 -msgid "Update Lists" -msgstr "" - -#: htdocs/luci-static/resources/view/podkop/diagnosticTab.js:498 -msgid "Lists Update Results" -msgstr "" - -#: htdocs/luci-static/resources/view/podkop/diagnosticTab.js:506 -msgid "Sing-box Status" -msgstr "" - -#: htdocs/luci-static/resources/view/podkop/diagnosticTab.js:527 -msgid "Check NFT Rules" -msgstr "" - -#: htdocs/luci-static/resources/view/podkop/diagnosticTab.js:529 -msgid "NFT Rules" -msgstr "" - -#: htdocs/luci-static/resources/view/podkop/diagnosticTab.js:532 -msgid "Check DNSMasq" -msgstr "" - -#: htdocs/luci-static/resources/view/podkop/diagnosticTab.js:534 -msgid "DNSMasq Configuration" -msgstr "" - -#: htdocs/luci-static/resources/view/podkop/diagnosticTab.js:542 -msgid "FakeIP Status" -msgstr "" - -#: htdocs/luci-static/resources/view/podkop/diagnosticTab.js:555 -msgid "DNS Status" -msgstr "" - -#: htdocs/luci-static/resources/view/podkop/diagnosticTab.js:564 -#: htdocs/luci-static/resources/view/podkop/diagnosticTab.js:780 -msgid "Main config" -msgstr "" - -#: htdocs/luci-static/resources/view/podkop/diagnosticTab.js:575 -msgid "Version Information" -msgstr "" - -#: htdocs/luci-static/resources/view/podkop/diagnosticTab.js:579 -msgid "Podkop: " -msgstr "" - -#: htdocs/luci-static/resources/view/podkop/diagnosticTab.js:580 -msgid "LuCI App: " -msgstr "" - -#: htdocs/luci-static/resources/view/podkop/diagnosticTab.js:581 -msgid "Sing-box: " -msgstr "" - -#: htdocs/luci-static/resources/view/podkop/diagnosticTab.js:582 -msgid "OpenWrt Version: " -msgstr "" - -#: htdocs/luci-static/resources/view/podkop/diagnosticTab.js:583 -msgid "Device Model: " -msgstr "" - -#: htdocs/luci-static/resources/view/podkop/diagnosticTab.js:694 -#: htdocs/luci-static/resources/view/podkop/diagnosticTab.js:700 -#: htdocs/luci-static/resources/view/podkop/diagnosticTab.js:706 -#: htdocs/luci-static/resources/view/podkop/diagnosticTab.js:719 -#: htdocs/luci-static/resources/view/podkop/diagnosticTab.js:720 -msgid "Unknown" -msgstr "" - -#: htdocs/luci-static/resources/view/podkop/diagnosticTab.js:729 -msgid "works in browser" -msgstr "" - -#: htdocs/luci-static/resources/view/podkop/diagnosticTab.js:729 -msgid "does not work in browser" -msgstr "" - -#: htdocs/luci-static/resources/view/podkop/diagnosticTab.js:738 -msgid "works on router" -msgstr "" - -#: htdocs/luci-static/resources/view/podkop/diagnosticTab.js:738 -msgid "does not work on router" -msgstr "" - -#: htdocs/luci-static/resources/view/podkop/diagnosticTab.js:793 -msgid "Config: " -msgstr "" - -#: htdocs/luci-static/resources/view/podkop/diagnosticTab.js:807 -msgid "Diagnostics" -msgstr "" - -#: htdocs/luci-static/resources/view/podkop/diagnosticTab.js:818 -msgid "Podkop" -msgstr "" - -#: htdocs/luci-static/resources/view/podkop/podkop.js:84 +#: htdocs/luci-static/resources/view/podkop/podkop.js:64 msgid "Extra configurations" msgstr "" -#: htdocs/luci-static/resources/view/podkop/podkop.js:87 +#: htdocs/luci-static/resources/view/podkop/podkop.js:68 msgid "Add Section" msgstr "" + +#: htdocs/luci-static/resources/view/podkop/dashboardTab.js:11 +msgid "Dashboard" +msgstr "" + +#: htdocs/luci-static/resources/view/podkop/main.js:11 htdocs/luci-static/resources/view/podkop/main.js:28 +#: htdocs/luci-static/resources/view/podkop/main.js:37 htdocs/luci-static/resources/view/podkop/main.js:40 +#: htdocs/luci-static/resources/view/podkop/main.js:60 htdocs/luci-static/resources/view/podkop/main.js:115 +#: htdocs/luci-static/resources/view/podkop/main.js:204 htdocs/luci-static/resources/view/podkop/main.js:295 +#: htdocs/luci-static/resources/view/podkop/main.js:313 htdocs/luci-static/resources/view/podkop/main.js:346 +msgid "Valid" +msgstr "" + +#: htdocs/luci-static/resources/view/podkop/main.js:13 +msgid "Invalid IP address" +msgstr "" + +#: htdocs/luci-static/resources/view/podkop/main.js:20 htdocs/luci-static/resources/view/podkop/main.js:26 +msgid "Invalid domain address" +msgstr "" + +#: htdocs/luci-static/resources/view/podkop/main.js:34 +msgid "DNS server address cannot be empty" +msgstr "" + +#: htdocs/luci-static/resources/view/podkop/main.js:45 +msgid "Invalid DNS server format. Examples: 8.8.8.8 or dns.example.com or dns.example.com/nicedns for DoH" +msgstr "" + +#: htdocs/luci-static/resources/view/podkop/main.js:57 +msgid "URL must use one of the following protocols:" +msgstr "" + +#: htdocs/luci-static/resources/view/podkop/main.js:62 +msgid "Invalid URL format" +msgstr "" + +#: htdocs/luci-static/resources/view/podkop/main.js:71 +msgid "Path cannot be empty" +msgstr "" + +#: htdocs/luci-static/resources/view/podkop/main.js:84 +msgid "Invalid path format. Path must start with \"/\" and contain valid characters" +msgstr "" + +#: htdocs/luci-static/resources/view/podkop/main.js:95 +msgid "Invalid format. Use X.X.X.X or X.X.X.X/Y" +msgstr "" + +#: htdocs/luci-static/resources/view/podkop/main.js:100 +msgid "IP address 0.0.0.0 is not allowed" +msgstr "" + +#: htdocs/luci-static/resources/view/podkop/main.js:111 +msgid "CIDR must be between 0 and 32" +msgstr "" + +#: htdocs/luci-static/resources/view/podkop/main.js:132 +msgid "Invalid Shadowsocks URL: must start with ss://" +msgstr "" + +#: htdocs/luci-static/resources/view/podkop/main.js:139 +msgid "Invalid Shadowsocks URL: must not contain spaces" +msgstr "" + +#: htdocs/luci-static/resources/view/podkop/main.js:147 +msgid "Invalid Shadowsocks URL: missing credentials" +msgstr "" + +#: htdocs/luci-static/resources/view/podkop/main.js:156 +msgid "Invalid Shadowsocks URL: decoded credentials must contain method:password" +msgstr "" + +#: htdocs/luci-static/resources/view/podkop/main.js:165 +msgid "Invalid Shadowsocks URL: missing method and password separator \":\"" +msgstr "" + +#: htdocs/luci-static/resources/view/podkop/main.js:174 +msgid "Invalid Shadowsocks URL: missing server address" +msgstr "" + +#: htdocs/luci-static/resources/view/podkop/main.js:181 +msgid "Invalid Shadowsocks URL: missing server" +msgstr "" + +#: htdocs/luci-static/resources/view/podkop/main.js:188 +msgid "Invalid Shadowsocks URL: missing port" +msgstr "" + +#: htdocs/luci-static/resources/view/podkop/main.js:195 +msgid "Invalid port number. Must be between 1 and 65535" +msgstr "" + +#: htdocs/luci-static/resources/view/podkop/main.js:201 +msgid "Invalid Shadowsocks URL: parsing failed" +msgstr "" + +#: htdocs/luci-static/resources/view/podkop/main.js:214 +msgid "Invalid VLESS URL: must not contain spaces" +msgstr "" + +#: htdocs/luci-static/resources/view/podkop/main.js:220 +msgid "Invalid VLESS URL: must start with vless://" +msgstr "" + +#: htdocs/luci-static/resources/view/podkop/main.js:224 +msgid "Invalid VLESS URL: missing UUID" +msgstr "" + +#: htdocs/luci-static/resources/view/podkop/main.js:227 +msgid "Invalid VLESS URL: missing server" +msgstr "" + +#: htdocs/luci-static/resources/view/podkop/main.js:230 +msgid "Invalid VLESS URL: missing port" +msgstr "" + +#: htdocs/luci-static/resources/view/podkop/main.js:236 +msgid "Invalid VLESS URL: invalid port number. Must be between 1 and 65535" +msgstr "" + +#: htdocs/luci-static/resources/view/podkop/main.js:243 +msgid "Invalid VLESS URL: missing query parameters" +msgstr "" + +#: htdocs/luci-static/resources/view/podkop/main.js:263 +msgid "Invalid VLESS URL: type must be one of tcp, raw, udp, grpc, http, ws" +msgstr "" + +#: htdocs/luci-static/resources/view/podkop/main.js:273 +msgid "Invalid VLESS URL: security must be one of tls, reality, none" +msgstr "" + +#: htdocs/luci-static/resources/view/podkop/main.js:282 +msgid "Invalid VLESS URL: missing pbk parameter for reality security" +msgstr "" + +#: htdocs/luci-static/resources/view/podkop/main.js:290 +msgid "Invalid VLESS URL: missing fp parameter for reality security" +msgstr "" + +#: htdocs/luci-static/resources/view/podkop/main.js:297 +msgid "Invalid VLESS URL: parsing failed" +msgstr "" + +#: htdocs/luci-static/resources/view/podkop/main.js:309 +msgid "Outbound JSON must contain at least \"type\", \"server\" and \"server_port\" fields" +msgstr "" + +#: htdocs/luci-static/resources/view/podkop/main.js:315 +msgid "Invalid JSON format" +msgstr "" + +#: htdocs/luci-static/resources/view/podkop/main.js:324 +msgid "Invalid Trojan URL: must start with trojan://" +msgstr "" + +#: htdocs/luci-static/resources/view/podkop/main.js:330 +msgid "Invalid Trojan URL: must not contain spaces" +msgstr "" + +#: htdocs/luci-static/resources/view/podkop/main.js:339 +msgid "Invalid Trojan URL: must contain username, hostname and port" +msgstr "" + +#: htdocs/luci-static/resources/view/podkop/main.js:344 +msgid "Invalid Trojan URL: parsing failed" +msgstr "" + +#: htdocs/luci-static/resources/view/podkop/main.js:362 +msgid "URL must start with vless:// or ss:// or trojan://" +msgstr "" + +#: htdocs/luci-static/resources/view/podkop/main.js:568 +msgid "Operation timed out" +msgstr "" + +#: htdocs/luci-static/resources/view/podkop/main.js:775 +msgid "HTTP error" +msgstr "" + +#: htdocs/luci-static/resources/view/podkop/main.js:786 +msgid "Unknown error" +msgstr "" + +#: htdocs/luci-static/resources/view/podkop/main.js:951 +msgid "Fastest" +msgstr "" + +#: htdocs/luci-static/resources/view/podkop/main.js:1222 +msgid "Dashboard currently unavailable" +msgstr "" + +#: htdocs/luci-static/resources/view/podkop/main.js:1326 +msgid "Currently unavailable" +msgstr "" + +#: htdocs/luci-static/resources/view/podkop/main.js:1721 +msgid "Traffic" +msgstr "" + +#: htdocs/luci-static/resources/view/podkop/main.js:1723 htdocs/luci-static/resources/view/podkop/main.js:1748 +msgid "Uplink" +msgstr "" + +#: htdocs/luci-static/resources/view/podkop/main.js:1724 htdocs/luci-static/resources/view/podkop/main.js:1752 +msgid "Downlink" +msgstr "" + +#: htdocs/luci-static/resources/view/podkop/main.js:1745 +msgid "Traffic Total" +msgstr "" + +#: htdocs/luci-static/resources/view/podkop/main.js:1775 +msgid "System info" +msgstr "" + +#: htdocs/luci-static/resources/view/podkop/main.js:1778 +msgid "Active Connections" +msgstr "" + +#: htdocs/luci-static/resources/view/podkop/main.js:1782 +msgid "Memory Usage" +msgstr "" + +#: htdocs/luci-static/resources/view/podkop/main.js:1805 +msgid "Services info" +msgstr "" + +#: htdocs/luci-static/resources/view/podkop/main.js:1808 htdocs/luci-static/resources/view/podkop/diagnosticTab.js:1139 +msgid "Podkop" +msgstr "" + +#: htdocs/luci-static/resources/view/podkop/main.js:1809 +msgid "✔ Enabled" +msgstr "" + +#: htdocs/luci-static/resources/view/podkop/main.js:1809 +msgid "✘ Disabled" +msgstr "" + +#: htdocs/luci-static/resources/view/podkop/main.js:1815 +msgid "Sing-box" +msgstr "" + +#: htdocs/luci-static/resources/view/podkop/main.js:1816 +msgid "✔ Running" +msgstr "" + +#: htdocs/luci-static/resources/view/podkop/main.js:1816 +msgid "✘ Stopped" +msgstr "" + +#: htdocs/luci-static/resources/view/podkop/diagnosticTab.js:137 +msgid "Copied!" +msgstr "" + +#: htdocs/luci-static/resources/view/podkop/diagnosticTab.js:143 +msgid "Failed to copy: " +msgstr "" + +#: htdocs/luci-static/resources/view/podkop/diagnosticTab.js:327 +#: htdocs/luci-static/resources/view/podkop/diagnosticTab.js:542 +#: htdocs/luci-static/resources/view/podkop/diagnosticTab.js:759 +#: htdocs/luci-static/resources/view/podkop/diagnosticTab.js:762 +#: htdocs/luci-static/resources/view/podkop/diagnosticTab.js:765 +#: htdocs/luci-static/resources/view/podkop/diagnosticTab.js:768 +#: htdocs/luci-static/resources/view/podkop/diagnosticTab.js:771 +msgid "Loading..." +msgstr "" + +#: htdocs/luci-static/resources/view/podkop/diagnosticTab.js:351 +msgid "Copy to Clipboard" +msgstr "" + +#: htdocs/luci-static/resources/view/podkop/diagnosticTab.js:359 +msgid "Close" +msgstr "" + +#: htdocs/luci-static/resources/view/podkop/diagnosticTab.js:380 +#: htdocs/luci-static/resources/view/podkop/diagnosticTab.js:483 +msgid "No output" +msgstr "" + +#: htdocs/luci-static/resources/view/podkop/diagnosticTab.js:398 +msgid "FakeIP is working in browser!" +msgstr "" + +#: htdocs/luci-static/resources/view/podkop/diagnosticTab.js:401 +msgid "FakeIP is not working in browser" +msgstr "" + +#: htdocs/luci-static/resources/view/podkop/diagnosticTab.js:403 +msgid "Check DNS server on current device (PC, phone)" +msgstr "" + +#: htdocs/luci-static/resources/view/podkop/diagnosticTab.js:404 +msgid "Its must be router!" +msgstr "" + +#: htdocs/luci-static/resources/view/podkop/diagnosticTab.js:426 +msgid "Proxy working correctly" +msgstr "" + +#: htdocs/luci-static/resources/view/podkop/diagnosticTab.js:428 +msgid "Direct IP: " +msgstr "" + +#: htdocs/luci-static/resources/view/podkop/diagnosticTab.js:430 +msgid "Proxy IP: " +msgstr "" + +#: htdocs/luci-static/resources/view/podkop/diagnosticTab.js:434 +msgid "Proxy is not working - same IP for both domains" +msgstr "" + +#: htdocs/luci-static/resources/view/podkop/diagnosticTab.js:437 +msgid "IP: " +msgstr "" + +#: htdocs/luci-static/resources/view/podkop/diagnosticTab.js:440 +msgid "Proxy check failed" +msgstr "" + +#: htdocs/luci-static/resources/view/podkop/diagnosticTab.js:448 +#: htdocs/luci-static/resources/view/podkop/diagnosticTab.js:459 +#: htdocs/luci-static/resources/view/podkop/diagnosticTab.js:470 +#: htdocs/luci-static/resources/view/podkop/diagnosticTab.js:477 +msgid "Check failed: " +msgstr "" + +#: htdocs/luci-static/resources/view/podkop/diagnosticTab.js:450 +#: htdocs/luci-static/resources/view/podkop/diagnosticTab.js:461 +#: htdocs/luci-static/resources/view/podkop/diagnosticTab.js:471 +msgid "timeout" +msgstr "" + +#: htdocs/luci-static/resources/view/podkop/diagnosticTab.js:488 +msgid "Error: " +msgstr "" + +#: htdocs/luci-static/resources/view/podkop/diagnosticTab.js:571 +msgid "Podkop Status" +msgstr "" + +#: htdocs/luci-static/resources/view/podkop/diagnosticTab.js:604 +msgid "Global check" +msgstr "" + +#: htdocs/luci-static/resources/view/podkop/diagnosticTab.js:606 +msgid "Click here for all the info" +msgstr "" + +#: htdocs/luci-static/resources/view/podkop/diagnosticTab.js:614 +msgid "Update Lists" +msgstr "" + +#: htdocs/luci-static/resources/view/podkop/diagnosticTab.js:616 +msgid "Lists Update Results" +msgstr "" + +#: htdocs/luci-static/resources/view/podkop/diagnosticTab.js:633 +msgid "Sing-box Status" +msgstr "" + +#: htdocs/luci-static/resources/view/podkop/diagnosticTab.js:660 +msgid "Check NFT Rules" +msgstr "" + +#: htdocs/luci-static/resources/view/podkop/diagnosticTab.js:662 +msgid "NFT Rules" +msgstr "" + +#: htdocs/luci-static/resources/view/podkop/diagnosticTab.js:665 +msgid "Check DNSMasq" +msgstr "" + +#: htdocs/luci-static/resources/view/podkop/diagnosticTab.js:667 +msgid "DNSMasq Configuration" +msgstr "" + +#: htdocs/luci-static/resources/view/podkop/diagnosticTab.js:684 +msgid "FakeIP Status" +msgstr "" + +#: htdocs/luci-static/resources/view/podkop/diagnosticTab.js:711 +msgid "DNS Status" +msgstr "" + +#: htdocs/luci-static/resources/view/podkop/diagnosticTab.js:728 +#: htdocs/luci-static/resources/view/podkop/diagnosticTab.js:1096 +msgid "Main config" +msgstr "" + +#: htdocs/luci-static/resources/view/podkop/diagnosticTab.js:748 +msgid "Version Information" +msgstr "" + +#: htdocs/luci-static/resources/view/podkop/diagnosticTab.js:758 +msgid "Podkop: " +msgstr "" + +#: htdocs/luci-static/resources/view/podkop/diagnosticTab.js:761 +msgid "LuCI App: " +msgstr "" + +#: htdocs/luci-static/resources/view/podkop/diagnosticTab.js:764 +msgid "Sing-box: " +msgstr "" + +#: htdocs/luci-static/resources/view/podkop/diagnosticTab.js:767 +msgid "OpenWrt Version: " +msgstr "" + +#: htdocs/luci-static/resources/view/podkop/diagnosticTab.js:770 +msgid "Device Model: " +msgstr "" + +#: htdocs/luci-static/resources/view/podkop/diagnosticTab.js:916 +#: htdocs/luci-static/resources/view/podkop/diagnosticTab.js:929 +#: htdocs/luci-static/resources/view/podkop/diagnosticTab.js:943 +#: htdocs/luci-static/resources/view/podkop/diagnosticTab.js:962 +#: htdocs/luci-static/resources/view/podkop/diagnosticTab.js:964 +msgid "Unknown" +msgstr "" + +#: htdocs/luci-static/resources/view/podkop/diagnosticTab.js:988 +msgid "works in browser" +msgstr "" + +#: htdocs/luci-static/resources/view/podkop/diagnosticTab.js:989 +msgid "does not work in browser" +msgstr "" + +#: htdocs/luci-static/resources/view/podkop/diagnosticTab.js:1014 +msgid "works on router" +msgstr "" + +#: htdocs/luci-static/resources/view/podkop/diagnosticTab.js:1015 +msgid "does not work on router" +msgstr "" + +#: htdocs/luci-static/resources/view/podkop/diagnosticTab.js:1110 +msgid "Config: " +msgstr "" + +#: htdocs/luci-static/resources/view/podkop/diagnosticTab.js:1127 +msgid "Diagnostics" +msgstr "" + +#: htdocs/luci-static/resources/view/podkop/additionalTab.js:8 +msgid "Additional Settings" +msgstr "" + +#: htdocs/luci-static/resources/view/podkop/additionalTab.js:14 +msgid "Yacd enable" +msgstr "" + +#: htdocs/luci-static/resources/view/podkop/additionalTab.js:25 +msgid "Exclude NTP" +msgstr "" + +#: htdocs/luci-static/resources/view/podkop/additionalTab.js:26 +msgid "Allows you to exclude NTP protocol traffic from the tunnel" +msgstr "" + +#: htdocs/luci-static/resources/view/podkop/additionalTab.js:36 +msgid "QUIC disable" +msgstr "" + +#: htdocs/luci-static/resources/view/podkop/additionalTab.js:37 +msgid "For issues with the video stream" +msgstr "" + +#: htdocs/luci-static/resources/view/podkop/additionalTab.js:47 +msgid "List Update Frequency" +msgstr "" + +#: htdocs/luci-static/resources/view/podkop/additionalTab.js:48 +msgid "Select how often the lists will be updated" +msgstr "" + +#: htdocs/luci-static/resources/view/podkop/additionalTab.js:62 +msgid "Select DNS protocol to use" +msgstr "" + +#: htdocs/luci-static/resources/view/podkop/additionalTab.js:98 +msgid "Bootstrap DNS server" +msgstr "" + +#: htdocs/luci-static/resources/view/podkop/additionalTab.js:100 +msgid "The DNS server used to look up the IP address of an upstream DNS server" +msgstr "" + +#: htdocs/luci-static/resources/view/podkop/additionalTab.js:123 +msgid "DNS Rewrite TTL" +msgstr "" + +#: htdocs/luci-static/resources/view/podkop/additionalTab.js:124 +msgid "Time in seconds for DNS record caching (default: 60)" +msgstr "" + +#: htdocs/luci-static/resources/view/podkop/additionalTab.js:131 +msgid "TTL value cannot be empty" +msgstr "" + +#: htdocs/luci-static/resources/view/podkop/additionalTab.js:136 +msgid "TTL must be a positive number" +msgstr "" + +#: htdocs/luci-static/resources/view/podkop/additionalTab.js:146 +msgid "Config File Path" +msgstr "" + +#: htdocs/luci-static/resources/view/podkop/additionalTab.js:148 +msgid "Select path for sing-box config file. Change this ONLY if you know what you are doing" +msgstr "" + +#: htdocs/luci-static/resources/view/podkop/additionalTab.js:161 +msgid "Cache File Path" +msgstr "" + +#: htdocs/luci-static/resources/view/podkop/additionalTab.js:163 +msgid "Select or enter path for sing-box cache file. Change this ONLY if you know what you are doing" +msgstr "" + +#: htdocs/luci-static/resources/view/podkop/additionalTab.js:176 +msgid "Cache file path cannot be empty" +msgstr "" + +#: htdocs/luci-static/resources/view/podkop/additionalTab.js:180 +msgid "Path must be absolute (start with /)" +msgstr "" + +#: htdocs/luci-static/resources/view/podkop/additionalTab.js:184 +msgid "Path must end with cache.db" +msgstr "" + +#: htdocs/luci-static/resources/view/podkop/additionalTab.js:189 +msgid "Path must contain at least one directory (like /tmp/cache.db)" +msgstr "" + +#: htdocs/luci-static/resources/view/podkop/additionalTab.js:199 +msgid "Source Network Interface" +msgstr "" + +#: htdocs/luci-static/resources/view/podkop/additionalTab.js:200 +msgid "Select the network interface from which the traffic will originate" +msgstr "" + +#: htdocs/luci-static/resources/view/podkop/additionalTab.js:238 +msgid "Interface monitoring" +msgstr "" + +#: htdocs/luci-static/resources/view/podkop/additionalTab.js:239 +msgid "Interface monitoring for bad WAN" +msgstr "" + +#: htdocs/luci-static/resources/view/podkop/additionalTab.js:249 +msgid "Interface for monitoring" +msgstr "" + +#: htdocs/luci-static/resources/view/podkop/additionalTab.js:250 +msgid "Select the WAN interfaces to be monitored" +msgstr "" + +#: htdocs/luci-static/resources/view/podkop/additionalTab.js:274 +msgid "Interface Monitoring Delay" +msgstr "" + +#: htdocs/luci-static/resources/view/podkop/additionalTab.js:275 +msgid "Delay in milliseconds before reloading podkop after interface UP" +msgstr "" + +#: htdocs/luci-static/resources/view/podkop/additionalTab.js:283 +msgid "Delay value cannot be empty" +msgstr "" + +#: htdocs/luci-static/resources/view/podkop/additionalTab.js:292 +msgid "Dont touch my DHCP!" +msgstr "" + +#: htdocs/luci-static/resources/view/podkop/additionalTab.js:293 +msgid "Podkop will not change the DHCP config" +msgstr "" + +#: htdocs/luci-static/resources/view/podkop/additionalTab.js:303 +msgid "Proxy download of lists" +msgstr "" + +#: htdocs/luci-static/resources/view/podkop/additionalTab.js:304 +msgid "Downloading all lists via main Proxy/VPN" +msgstr "" + +#: htdocs/luci-static/resources/view/podkop/additionalTab.js:315 +msgid "IP for exclusion" +msgstr "" + +#: htdocs/luci-static/resources/view/podkop/additionalTab.js:316 +msgid "Specify local IP addresses that will never use the configured route" +msgstr "" + +#: htdocs/luci-static/resources/view/podkop/additionalTab.js:352 +msgid "Mixed enable" +msgstr "" + +#: htdocs/luci-static/resources/view/podkop/additionalTab.js:353 +msgid "Browser port: 2080" +msgstr "" From 5d0f8ce5bf711ccfe4c6e1f3b942aa93292cbb30 Mon Sep 17 00:00:00 2001 From: divocat Date: Tue, 7 Oct 2025 17:23:26 +0300 Subject: [PATCH 49/49] fix: resolve copilot suggestions --- fe-app-podkop/src/validators/validatePath.ts | 2 +- .../htdocs/luci-static/resources/view/podkop/configSection.js | 2 ++ .../htdocs/luci-static/resources/view/podkop/main.js | 2 +- 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/fe-app-podkop/src/validators/validatePath.ts b/fe-app-podkop/src/validators/validatePath.ts index 045601e..d08773f 100644 --- a/fe-app-podkop/src/validators/validatePath.ts +++ b/fe-app-podkop/src/validators/validatePath.ts @@ -13,7 +13,7 @@ export function validatePath(value: string): ValidationResult { if (pathRegex.test(value)) { return { valid: true, - message: 'Valid', + message: _('Valid'), }; } diff --git a/luci-app-podkop/htdocs/luci-static/resources/view/podkop/configSection.js b/luci-app-podkop/htdocs/luci-static/resources/view/podkop/configSection.js index b4f3b17..cde7914 100644 --- a/luci-app-podkop/htdocs/luci-static/resources/view/podkop/configSection.js +++ b/luci-app-podkop/htdocs/luci-static/resources/view/podkop/configSection.js @@ -46,7 +46,9 @@ function createConfigSection(section) { ); o.depends('proxy_config_type', 'url'); o.rows = 5; + // Enable soft wrapping for multi-line proxy URLs (e.g., for URLTest proxy links) o.wrap = 'soft'; + // Render as a textarea to allow multiple proxy URLs/configs o.textarea = true; o.rmempty = false; o.ucisection = s.section; diff --git a/luci-app-podkop/htdocs/luci-static/resources/view/podkop/main.js b/luci-app-podkop/htdocs/luci-static/resources/view/podkop/main.js index 34cde9e..dae6acf 100644 --- a/luci-app-podkop/htdocs/luci-static/resources/view/podkop/main.js +++ b/luci-app-podkop/htdocs/luci-static/resources/view/podkop/main.js @@ -75,7 +75,7 @@ function validatePath(value) { if (pathRegex.test(value)) { return { valid: true, - message: "Valid" + message: _("Valid") }; } return {