From c75dd3e78bb0f55392a9cc90d49fd2dc17a23625 Mon Sep 17 00:00:00 2001 From: divocat Date: Sun, 5 Oct 2025 16:09:26 +0300 Subject: [PATCH 01/15] 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 02/15] 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 03/15] 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 04/15] 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 05/15] 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 06/15] 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 07/15] 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 08/15] 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 09/15] 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 10/15] 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 11/15] 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 12/15] 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 13/15] 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 14/15] 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 15/15] 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 ""