diff --git a/src/frontend/.gitignore b/src/frontend/.gitignore new file mode 100644 index 0000000..d98184b --- /dev/null +++ b/src/frontend/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +dist/ +.vite/ +.env diff --git a/src/frontend/.keep b/src/frontend/.keep deleted file mode 100644 index e69de29..0000000 diff --git a/src/frontend/package.json b/src/frontend/package.json new file mode 100644 index 0000000..e4a6824 --- /dev/null +++ b/src/frontend/package.json @@ -0,0 +1,12 @@ +{ + "name": "podkop-frontend", + "version": "0.8.0", + "private": true, + "scripts": { + "dev:sandbox": "cd sandbox/docker && ./up.sh", + "sandbox:down": "cd sandbox/docker && docker compose down", + "sandbox:logs": "cd sandbox/docker && docker compose logs -f", + "sandbox:shell": "docker exec -it podkop-dev-router sh", + "sandbox:scenario": "cd sandbox/docker && ./scenario.sh" + } +} diff --git a/src/frontend/sandbox/Makefile b/src/frontend/sandbox/Makefile new file mode 100644 index 0000000..55c9977 --- /dev/null +++ b/src/frontend/sandbox/Makefile @@ -0,0 +1,32 @@ +include $(TOPDIR)/rules.mk + +PKG_NAME:=luci-app-podkop + +PKG_VERSION := $(if $(PODKOP_VERSION),$(PODKOP_VERSION),0.$(shell date +%d%m%Y)) + +PKG_RELEASE:=1 + +LUCI_TITLE:=LuCI podkop app +# +podkop dropped while the backend is rewritten in Go; the frontend runs +# against a stub (docker/files/usr/bin/podkop). Restore before release. +LUCI_DEPENDS:=+luci-base +LUCI_PKGARCH:=all +LUCI_LANG.ru:=Русский (Russian) +LUCI_LANG.en:=English + +PKG_LICENSE:=GPL-2.0-or-later +PKG_MAINTAINER:=ITDog + +LUCI_LANGUAGES:=en ru + +include $(TOPDIR)/feeds/luci/luci.mk + +define Package/$(PKG_NAME)/install + $(INSTALL_DIR) $(1)$(HTDOCS) + $(CP) $(PKG_BUILD_DIR)/htdocs/* $(1)$(HTDOCS)/ + $(INSTALL_DIR) $(1)/ + $(CP) $(PKG_BUILD_DIR)/root/* $(1)/ + sed -i -e 's/__COMPILED_VERSION_VARIABLE__/$(PKG_VERSION)/g' $(1)$(HTDOCS)/luci-static/resources/view/podkop/main.js || true +endef + +$(eval $(call BuildPackage,$(PKG_NAME))) \ No newline at end of file diff --git a/src/frontend/sandbox/docker/Dockerfile b/src/frontend/sandbox/docker/Dockerfile new file mode 100644 index 0000000..0ad8771 --- /dev/null +++ b/src/frontend/sandbox/docker/Dockerfile @@ -0,0 +1,26 @@ +# Dev stand for luci-app-podkop: stock OpenWrt userland + real LuCI. +# +# The app is loaded by LuCI's own require loader and reaches the system through +# rpcd with real ACL checks — neither is exercised by a JS-level mock. +# No backend: /usr/bin/podkop is a shell stub serving fixtures. +ARG OPENWRT_TAG=x86-64-25.12.4 +FROM openwrt/rootfs:${OPENWRT_TAG} + +# Absent from the rootfs tarball; on a router procd creates them under the /var +# tmpfs. apk needs them earlier. +RUN mkdir -p /var/lock /var/run /var/log /var/state + +# firewall/opkg are not about podkop — a page in an empty menu shows nothing +# about how the app sits next to its neighbours. +RUN set -eu; \ + apk update; \ + for p in luci luci-app-firewall luci-app-opkg; do \ + apk add "$p" || echo "skipped (not in feed): $p"; \ + done + +COPY files/ / +RUN chmod +x /usr/bin/podkop /etc/init.d/podkop /usr/lib/podkop-dev/entrypoint.sh + +EXPOSE 80 +ENTRYPOINT ["/usr/lib/podkop-dev/entrypoint.sh"] +CMD ["/sbin/init"] diff --git a/src/frontend/sandbox/docker/README.md b/src/frontend/sandbox/docker/README.md new file mode 100644 index 0000000..21f0403 --- /dev/null +++ b/src/frontend/sandbox/docker/README.md @@ -0,0 +1,73 @@ +# Dev stand for luci-app-podkop + +Real OpenWrt 25.12 with real LuCI in a container. The app is mounted from the +host, so the loop is: edit js, hit F5. + +## Run + +```sh +yarn dev:sandbox # from src/frontend +./up.sh # or directly from here +``` + +UI: → **Services → Podkop** (`root`, empty password). +Stop with `docker compose down`. + +Use `up.sh`, not `docker compose up`: `openwrt/rootfs` has no multiarch +manifest, and the arm image declares `aarch64_generic` rather than `arm64`. +`up.sh` derives both the tag and the platform from `uname -m`. + +## Why a container instead of a browser mock + +A JS mock only exercises our own code. Here everything below the app is real: + +| mocked in the browser | real here | +| --- | --- | +| `fs.exec` | rpcd + **ACL checks** from `acl.d/luci-app-podkop.json` | +| `uci` | uci over `/etc/config/podkop` | +| `_()` | translations loaded from `.po` | +| theme | `cascade.css` served by LuCI | +| "the file parses" | **LuCI's require loader** | + +The last two are the point. An ACL mistake returns 403 only on a router. + +## What is not here + +**The backend.** It is being rewritten in Go; `/usr/bin/podkop` and +`/etc/init.d/podkop` are shell stubs serving fixtures. The mock boundary is the +binary, not the JS, so the whole frontend above it stays real. + +**Traffic interception.** `kmod-nft-tproxy` is built against OpenWrt's kernel +and cannot load in a container. `check_nft_rules` reports some rules missing on +purpose — that is the stand's honest state. + +**The luci-app version.** The package Makefile substitutes it at build time; +here the js is mounted as-is, so diagnostics shows +`__COMPILED_VERSION_VARIABLE__`. + +## Scenarios + +```sh +./scenario.sh # current +./scenario.sh ok # everything works +./scenario.sh fail # commands fail — exercises failed states +./scenario.sh stopped # services down, Clash unreachable +``` + +Reload the page afterwards. + +## Layout + +| | | +| --- | --- | +| `Dockerfile` | OpenWrt 25.12.4 + luci (+ firewall/opkg for menu neighbours) | +| `compose.yml` | `router` (8080) and `clash-mock` (9090) | +| `files/usr/bin/podkop` | backend stub | +| `files/etc/init.d/podkop` | service stub for Start/Stop/Restart | +| `files/etc/config/podkop` | uci fixture, one section per parse branch | +| `files/etc/rc.local` | seeds runtime state after boot | +| `clash-mock/server.mjs` | `/traffic` and `/connections` websockets | + +`/etc/config/podkop` is required: `menu.d` declares +`"depends": { "uci": { "podkop": true } }`, so without it LuCI hides the menu +entry entirely. diff --git a/src/frontend/sandbox/docker/clash-mock/server.mjs b/src/frontend/sandbox/docker/clash-mock/server.mjs new file mode 100644 index 0000000..ab8c5b2 --- /dev/null +++ b/src/frontend/sandbox/docker/clash-mock/server.mjs @@ -0,0 +1,109 @@ +/** + * Clash API metrics for the dev stand: /traffic and /connections on :9090. + * + * Handshake and framing are written by hand so node:22-alpine needs no + * npm install. + */ +import { createHash } from 'node:crypto'; +import { createServer } from 'node:http'; + +const PORT = 9090; +const GUID = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11'; + +/** Text frame: FIN + opcode 1, unmasked (servers do not mask). */ +function encodeFrame(text) { + const payload = Buffer.from(text, 'utf8'); + const length = payload.length; + + let header; + + if (length < 126) { + header = Buffer.from([0x81, length]); + } else if (length < 65536) { + header = Buffer.alloc(4); + header[0] = 0x81; + header[1] = 126; + header.writeUInt16BE(length, 2); + } else { + header = Buffer.alloc(10); + header[0] = 0x81; + header[1] = 127; + header.writeBigUInt64BE(BigInt(length), 2); + } + + return Buffer.concat([header, payload]); +} + +const state = { + uploadTotal: 4_800_000_000, + downloadTotal: 51_200_000_000, +}; + +function trafficFrame() { + return { + up: Math.floor(20_000 + Math.random() * 900_000), + down: Math.floor(80_000 + Math.random() * 6_000_000), + }; +} + +function connectionsFrame() { + state.uploadTotal += Math.floor(Math.random() * 900_000); + state.downloadTotal += Math.floor(Math.random() * 6_000_000); + + return { + uploadTotal: state.uploadTotal, + downloadTotal: state.downloadTotal, + memory: Math.floor(28_000_000 + Math.random() * 12_000_000), + connections: Array.from( + { length: 8 + Math.floor(Math.random() * 24) }, + (_unused, index) => ({ id: `conn-${index}` }), + ), + }; +} + +const server = createServer((req, res) => { + // The dashboard only uses the websocket; GET just needs to say something + res.writeHead(200, { 'content-type': 'application/json' }); + res.end(JSON.stringify({ hello: 'podkop dev clash mock', path: req.url })); +}); + +server.on('upgrade', (req, socket) => { + const key = req.headers['sec-websocket-key']; + + if (!key) { + socket.destroy(); + return; + } + + const accept = createHash('sha1') + .update(key + GUID) + .digest('base64'); + + socket.write( + 'HTTP/1.1 101 Switching Protocols\r\n' + + 'Upgrade: websocket\r\n' + + 'Connection: Upgrade\r\n' + + `Sec-WebSocket-Accept: ${accept}\r\n\r\n`, + ); + + const path = (req.url || '').split('?')[0]; + const build = path === '/traffic' ? trafficFrame : connectionsFrame; + + console.log(`[clash-mock] connected: ${path}`); + + const timer = setInterval(() => { + socket.write(encodeFrame(JSON.stringify(build()))); + }, 1000); + + const stop = () => { + clearInterval(timer); + socket.destroy(); + }; + + socket.on('close', stop); + socket.on('error', stop); +}); + +server.listen(PORT, '0.0.0.0', () => { + console.log(`[clash-mock] listening on :${PORT} (/traffic, /connections)`); +}); diff --git a/src/frontend/sandbox/docker/compose.yml b/src/frontend/sandbox/docker/compose.yml new file mode 100644 index 0000000..c2c65fb --- /dev/null +++ b/src/frontend/sandbox/docker/compose.yml @@ -0,0 +1,37 @@ +name: podkop-dev + +services: + router: + build: + context: . + dockerfile: Dockerfile + args: + # No multiarch manifest — one tag per arch. Set by ./up.sh. + OPENWRT_TAG: ${OPENWRT_TAG:-x86-64-25.12.4} + # The arm image declares OpenWrt's own arch name (aarch64_generic), + # which Docker does not match to linux/arm64 on its own. + platform: ${OPENWRT_PLATFORM:-linux/amd64} + container_name: podkop-dev-router + hostname: OpenWrt + privileged: true # procd as PID 1 + init: false + restart: unless-stopped + ports: + - '8080:80' + volumes: + # Mounted from the host: edit js, hit F5, no rebuild. + - ../htdocs/luci-static/resources/view/podkop:/www/luci-static/resources/view/podkop:ro + - ../root/usr/share/luci/menu.d/luci-app-podkop.json:/usr/share/luci/menu.d/luci-app-podkop.json:ro + - ../root/usr/share/rpcd/acl.d/luci-app-podkop.json:/usr/share/rpcd/acl.d/luci-app-podkop.json:ro + + # Nothing in the OpenWrt container can serve a websocket (sing-box is + # deliberately absent), so Clash API metrics live in their own service. + clash-mock: + image: node:22-alpine + container_name: podkop-dev-clash + restart: unless-stopped + ports: + - '9090:9090' + volumes: + - ./clash-mock:/app:ro + command: ['node', '/app/server.mjs'] diff --git a/src/frontend/sandbox/docker/files/etc/config/podkop b/src/frontend/sandbox/docker/files/etc/config/podkop new file mode 100644 index 0000000..3baa47a --- /dev/null +++ b/src/frontend/sandbox/docker/files/etc/config/podkop @@ -0,0 +1,47 @@ +# uci fixture for the dev stand. +# +# Required: menu.d declares "depends": { "uci": { "podkop": true } }, so LuCI +# hides the menu entry until this file exists. +# +# Sections cover every parse branch on the dashboard — url / outbound / +# selector / urltest / vpn — plus block and exclusion, which must be filtered out. + +config settings 'settings' + option enable_yacd '1' + option yacd_secret_key 'dev-stand-secret' + option dns_type 'doh' + option dns_server 'dns.adguard-dns.com' + +config section 'youtube' + option connection_type 'proxy' + option proxy_config_type 'url' + option proxy_string 'vless://uuid@example.com:443?type=tcp#YouTube%20VLESS' + list domain_list 'youtube' + +config section 'netflix' + option connection_type 'proxy' + option proxy_config_type 'outbound' + option outbound_json '{"tag":"Netflix%20Shadowsocks","type":"shadowsocks"}' + +config section 'media' + option connection_type 'proxy' + option proxy_config_type 'selector' + list selector_proxy_links 'vless://uuid@nl.example.com:443#Netherlands' + list selector_proxy_links 'vless://uuid@de.example.com:443#Germany' + list selector_proxy_links 'trojan://uuid@fi.example.com:443#Finland' + +config section 'fast' + option connection_type 'proxy' + option proxy_config_type 'urltest' + list urltest_proxy_links 'vless://uuid@se.example.com:443#Sweden' + list urltest_proxy_links 'vless://uuid@pl.example.com:443#Poland' + +config section 'home_vpn' + option connection_type 'vpn' + option interface 'wg0' + +config section 'ads' + option connection_type 'block' + +config section 'local' + option connection_type 'exclusion' diff --git a/src/frontend/sandbox/docker/files/etc/config/system b/src/frontend/sandbox/docker/files/etc/config/system new file mode 100644 index 0000000..fd3aa23 --- /dev/null +++ b/src/frontend/sandbox/docker/files/etc/config/system @@ -0,0 +1,9 @@ +config system + option hostname 'OpenWrt' + option timezone 'UTC' + option ttylogin '0' + option log_size '64' + option urandom_seed '0' + +config timeserver 'ntp' + option enabled '0' diff --git a/src/frontend/sandbox/docker/files/etc/init.d/podkop b/src/frontend/sandbox/docker/files/etc/init.d/podkop new file mode 100755 index 0000000..e9b03c3 --- /dev/null +++ b/src/frontend/sandbox/docker/files/etc/init.d/podkop @@ -0,0 +1,32 @@ +#!/bin/sh /etc/rc.common +# Service stub. No daemon behind it — just enough for the Start/Stop/Restart/ +# Enable/Disable buttons to go through rpcd and change observable state. +START=99 +USE_PROCD=0 + +STATE=/tmp/podkop-service-state + +start() { + echo running >"$STATE" + echo '{"result":"started"}' +} + +stop() { + echo stopped >"$STATE" + echo '{"result":"stopped"}' +} + +restart() { + echo running >"$STATE" + echo '{"result":"restarted"}' +} + +enable() { + touch /etc/rc.d/S99podkop 2>/dev/null + echo '{"result":"enabled"}' +} + +disable() { + rm -f /etc/rc.d/S99podkop 2>/dev/null + echo '{"result":"disabled"}' +} diff --git a/src/frontend/sandbox/docker/files/etc/rc.local b/src/frontend/sandbox/docker/files/etc/rc.local new file mode 100644 index 0000000..d67a73a --- /dev/null +++ b/src/frontend/sandbox/docker/files/etc/rc.local @@ -0,0 +1,9 @@ +# Seed runtime state for the backend stub. +# +# Not in the entrypoint: that runs before procd, which mounts a tmpfs over /tmp +# and wipes anything written there earlier. + +echo ok >/tmp/podkop-scenario +echo running >/tmp/podkop-service-state + +exit 0 diff --git a/src/frontend/sandbox/docker/files/usr/bin/podkop b/src/frontend/sandbox/docker/files/usr/bin/podkop new file mode 100755 index 0000000..f12983f --- /dev/null +++ b/src/frontend/sandbox/docker/files/usr/bin/podkop @@ -0,0 +1,203 @@ +#!/bin/sh +# Backend stub for the dev stand. The real backend is being rewritten in Go. +# +# The mock boundary sits here rather than in JS, so rpcd, ACL, fs.exec, uci and +# LuCI's require loader all stay real. +# +# Commands mirror what main.js actually calls, nothing more. +# +# Scenario is read from a file (see docker/scenario.sh): +# ok everything works (default) +# fail commands fail with empty stdout +# stopped services down, Clash unreachable +SCENARIO_FILE=/tmp/podkop-scenario +SCENARIO=$(cat "$SCENARIO_FILE" 2>/dev/null || echo ok) + +# Latency tests and node selection must persist between calls +LATENCY_DIR=/tmp/podkop-latency +SELECTED_DIR=/tmp/podkop-selected +mkdir -p "$LATENCY_DIR" "$SELECTED_DIR" + +# Empty stdout is how the frontend detects failure +die_if_failing() { + [ "$SCENARIO" = "fail" ] && exit 1 + return 0 +} + +# 40..2000 ms. Not `date +%s%N` (%N is not portable, emits a literal "N") and +# not `od` (absent from the OpenWrt image; empty output pinned every node to 40). +rand_latency() { + _r="$RANDOM" + + if [ -z "$_r" ]; then + _r=$(awk 'BEGIN{srand();print int(rand()*32768)}') + fi + + echo $((_r % 1960 + 40)) +} + +latency_of() { # $1=code $2=fallback + if [ -f "$LATENCY_DIR/$1" ]; then cat "$LATENCY_DIR/$1"; else echo "$2"; fi +} + +selected_of() { # $1=group $2=fallback + if [ -f "$SELECTED_DIR/$1" ]; then cat "$SELECTED_DIR/$1"; else echo "$2"; fi +} + +history_of() { # $1=delay + if [ "$1" -eq 0 ]; then + echo '[]' + else + echo "[{\"time\":\"2026-07-20T10:00:00Z\",\"delay\":$1}]" + fi +} + +proxy_json() { # $1=name $2=type $3=delay + printf '"%s":{"type":"%s","name":"%s","udp":true,"history":%s}' \ + "$1" "$2" "$1" "$(history_of "$3")" +} + +group_json() { # $1=name $2=type $3=now $4=all(json) $5=delay + printf '"%s":{"type":"%s","name":"%s","udp":false,"now":"%s","all":%s,"history":%s}' \ + "$1" "$2" "$1" "$3" "$4" "$(history_of "$5")" +} + +# Mirrors the sections in /etc/config/podkop; covers every parse branch on the +# dashboard: url / outbound / selector / urltest / vpn. +emit_proxies() { + printf '{"proxies":{' + proxy_json 'youtube-out' 'vless' "$(latency_of youtube-out 142)" + printf ',' + proxy_json 'netflix-out' 'shadowsocks' "$(latency_of netflix-out 980)" + printf ',' + group_json 'media-out' 'Selector' "$(selected_of media-out 'media-2-out')" \ + '["media-1-out","media-2-out","media-3-out"]' 0 + printf ',' + proxy_json 'media-1-out' 'vless' "$(latency_of media-1-out 210)" + printf ',' + proxy_json 'media-2-out' 'vless' "$(latency_of media-2-out 1320)" + printf ',' + proxy_json 'media-3-out' 'trojan' "$(latency_of media-3-out 2400)" + printf ',' + group_json 'fast-out' 'Selector' "$(selected_of fast-out 'fast-urltest-out')" \ + '["fast-urltest-out","fast-1-out","fast-2-out"]' 0 + printf ',' + group_json 'fast-urltest-out' 'URLTest' '' \ + '["fast-1-out","fast-2-out"]' "$(latency_of fast-urltest-out 96)" + printf ',' + proxy_json 'fast-1-out' 'vless' "$(latency_of fast-1-out 96)" + printf ',' + proxy_json 'fast-2-out' 'vless' "$(latency_of fast-2-out 0)" + printf ',' + proxy_json 'home_vpn-out' 'wireguard' "$(latency_of home_vpn-out 18)" + printf '}}\n' +} + +members_of() { + case "$1" in + media-out) echo 'media-1-out media-2-out media-3-out' ;; + fast-out) echo 'fast-urltest-out fast-1-out fast-2-out' ;; + fast-urltest-out) echo 'fast-1-out fast-2-out' ;; + *) echo '' ;; + esac +} + +clash_api() { + [ "$SCENARIO" = "stopped" ] && exit 1 + + case "$1" in + get_proxies) + emit_proxies + ;; + get_proxy_latency) + delay=$(rand_latency) + echo "$delay" >"$LATENCY_DIR/$2" + echo "{\"delay\":$delay}" + ;; + get_group_latency) + i=0 + printf '{' + for member in $(members_of "$2"); do + i=$((i + 1)) + delay=$(rand_latency) + echo "$delay" >"$LATENCY_DIR/$member" + [ "$i" -gt 1 ] && printf ',' + printf '"%s":%s' "$member" "$delay" + done + printf '}\n' + ;; + set_group_proxy) + echo "$3" >"$SELECTED_DIR/$2" + echo '{"ok":true}' + ;; + *) + echo "{\"error\":\"unknown clash_api method: $1\"}" + ;; + esac +} + +die_if_failing + +case "$1" in +get_status) + # Reads the autostart flag so Enable/Disable have a visible effect + if [ "$SCENARIO" = "stopped" ] || [ ! -e /etc/rc.d/S99podkop ]; then + echo '{"enabled":0,"status":"disabled"}' + else + echo '{"enabled":1,"status":"enabled"}' + fi + ;; + +get_sing_box_status) + # Same for Start/Stop/Restart + if [ "$SCENARIO" = "stopped" ] || + [ "$(cat /tmp/podkop-service-state 2>/dev/null)" = "stopped" ]; then + echo '{"running":0,"enabled":0,"status":"stopped"}' + else + echo '{"running":1,"enabled":1,"status":"running"}' + fi + ;; + +get_system_info) + echo '{"podkop_version":"0.8.1-dev","podkop_latest_version":"0.8.2","luci_app_version":"0.8.1-dev","sing_box_version":"1.11.4","openwrt_version":"OpenWrt 25.12.4","device_model":"Docker dev stand"}' + ;; + +check_dns_available) + echo '{"dns_type":"doh","dns_server":"dns.adguard-dns.com","dns_status":1,"dns_on_router":1,"bootstrap_dns_server":"1.1.1.1","bootstrap_dns_status":1,"dhcp_config_status":1}' + ;; + +check_nft_rules) + # No tproxy here: OpenWrt kernel modules cannot load in a container, so some + # rules are reported missing on purpose. + echo '{"table_exist":1,"rules_mangle_exist":1,"rules_mangle_counters":1,"rules_mangle_output_exist":1,"rules_mangle_output_counters":0,"rules_proxy_exist":1,"rules_proxy_counters":0,"rules_other_mark_exist":0}' + ;; + +check_sing_box) + echo '{"sing_box_installed":1,"sing_box_version_ok":1,"sing_box_service_exist":1,"sing_box_autostart_disabled":1,"sing_box_process_running":1,"sing_box_ports_listening":1}' + ;; + +check_fakeip) + echo '{"fakeip":true,"IP":"198.18.0.1"}' + ;; + +check_logs) + echo "$(date '+%a %b %e %H:%M:%S %Y') [info] podkop dev stand: backend stub" + echo "$(date '+%a %b %e %H:%M:%S %Y') [info] scenario: $SCENARIO" + ;; + +global_check) + echo '{"result":"ok","note":"dev stand"}' + ;; + +show_sing_box_config) + echo '{"log":{"level":"info"},"experimental":{"clash_api":{"external_controller":"0.0.0.0:9090"}},"note":"dev stand stub"}' + ;; + +clash_api) + clash_api "$2" "$3" "$4" + ;; + +*) + echo "{\"error\":\"unknown method: $1\"}" + ;; +esac diff --git a/src/frontend/sandbox/docker/files/usr/lib/podkop-dev/entrypoint.sh b/src/frontend/sandbox/docker/files/usr/lib/podkop-dev/entrypoint.sh new file mode 100755 index 0000000..64a2f8f --- /dev/null +++ b/src/frontend/sandbox/docker/files/usr/lib/podkop-dev/entrypoint.sh @@ -0,0 +1,58 @@ +#!/bin/sh +# Prepare the stand, then hand over to procd. +# +# Networking has to happen HERE, before `exec /sbin/init`: config_generate +# writes its own /etc/config/network from board.json (br-lan, hardcoded +# 192.168.1.1), netifd applies it and Docker's address is gone. By the time +# uci-defaults run, eth0 is already down and flushed. +# +# config_generate's guard is `[ -s /etc/config/network -a -s /etc/config/system ]`, +# so both files must exist — hence /etc/config/system in the image. +# +# The interface is named 'lan' because OpenWrt's default firewall allows input +# in that zone; that is what makes port 80 reachable from the host. +# +# Approach borrowed from luci-theme-footstrap. +set -e + +addr="$(ip -4 -o addr show dev eth0 2>/dev/null | awk '{print $4; exit}')" +gw="$(ip -4 route show default 2>/dev/null | awk '{print $3; exit}')" +dns="${PODKOP_DEV_DNS:-1.1.1.1 8.8.8.8}" + +# Docker's resolv.conf points at 127.0.0.11, which works via NAT rules that +# OpenWrt's boot flushes. +for ns in $dns; do echo "nameserver $ns"; done >/etc/resolv.conf + +if [ -n "$addr" ]; then + netmask="$(ipcalc.sh "$addr" | sed -n 's/^NETMASK=//p')" + cat >/etc/config/network <<-EOF + config interface 'loopback' + option device 'lo' + option proto 'static' + option ipaddr '127.0.0.1' + option netmask '255.0.0.0' + + config globals 'globals' + + config interface 'lan' + option device 'eth0' + option proto 'static' + option ipaddr '${addr%/*}' + option netmask '$netmask' + option gateway '$gw' + option dns '$dns' + option delegate '0' + EOF +else + echo "podkop-dev: eth0 has no IPv4 — /etc/config/network left alone" >&2 +fi + +# Autostart on by default; get_status reads this. /etc survives boot, /tmp does +# not — runtime state is seeded from /etc/rc.local instead. +mkdir -p /etc/rc.d +touch /etc/rc.d/S99podkop + +# Until the cache is dropped, the mounted menu.d/acl.d stay invisible. +rm -f /tmp/luci-indexcache* /var/luci-indexcache* 2>/dev/null || true + +exec "$@" diff --git a/src/frontend/sandbox/docker/scenario.sh b/src/frontend/sandbox/docker/scenario.sh new file mode 100755 index 0000000..5cd87e1 --- /dev/null +++ b/src/frontend/sandbox/docker/scenario.sh @@ -0,0 +1,32 @@ +#!/bin/sh +# Switch the backend stub's scenario on a running stand. +# +# ./scenario.sh show current +# ./scenario.sh ok everything works +# ./scenario.sh fail commands fail (empty stdout) +# ./scenario.sh stopped services down, Clash unreachable +# +# Reload the page afterwards. +set -e + +CONTAINER=podkop-dev-router + +if [ $# -eq 0 ]; then + # A missing file is fine — the stub falls back to ok + current=$(docker exec "$CONTAINER" cat /tmp/podkop-scenario 2>/dev/null || true) + echo "scenario: ${current:-ok (default)}" + exit 0 +fi + +case "$1" in +ok | fail | stopped) ;; +*) + echo "unknown scenario: $1 (ok | fail | stopped)" >&2 + exit 1 + ;; +esac + +docker exec "$CONTAINER" sh -c "echo '$1' > /tmp/podkop-scenario" +# Drop accumulated state so the scenario starts clean +docker exec "$CONTAINER" sh -c 'rm -rf /tmp/podkop-latency /tmp/podkop-selected' +echo "scenario: $1 — reload the page" diff --git a/src/frontend/sandbox/docker/up.sh b/src/frontend/sandbox/docker/up.sh new file mode 100755 index 0000000..5e0411e --- /dev/null +++ b/src/frontend/sandbox/docker/up.sh @@ -0,0 +1,36 @@ +#!/bin/sh +# Start the stand, picking the OpenWrt image for the host arch. +# +# openwrt/rootfs has no multiarch manifest, and the arm image declares +# aarch64_generic rather than arm64 — without both the tag and the platform, +# the build fails with "no match for platform in manifest". +# +# Override: OPENWRT_TAG=... ./up.sh +set -e + +OPENWRT_RELEASE=25.12.4 + +if [ -z "$OPENWRT_TAG" ]; then + case "$(uname -m)" in + arm64 | aarch64) + OPENWRT_TAG="armsr-armv8-$OPENWRT_RELEASE" + OPENWRT_PLATFORM="linux/aarch64_generic" + ;; + x86_64 | amd64) + OPENWRT_TAG="x86-64-$OPENWRT_RELEASE" + OPENWRT_PLATFORM="linux/amd64" + ;; + *) + echo "unknown arch $(uname -m); set OPENWRT_TAG manually" >&2 + exit 1 + ;; + esac +fi + +echo "image: openwrt/rootfs:$OPENWRT_TAG ($OPENWRT_PLATFORM)" +OPENWRT_TAG="$OPENWRT_TAG" OPENWRT_PLATFORM="$OPENWRT_PLATFORM" \ + docker compose up -d --build "$@" + +echo +echo "ui: http://localhost:8080 -> Services -> Podkop (root, empty password)" +echo "scenarios: ./scenario.sh ok | fail | stopped" diff --git a/src/frontend/sandbox/htdocs/luci-static/resources/view/podkop/dashboard.js b/src/frontend/sandbox/htdocs/luci-static/resources/view/podkop/dashboard.js new file mode 100644 index 0000000..6fd97cf --- /dev/null +++ b/src/frontend/sandbox/htdocs/luci-static/resources/view/podkop/dashboard.js @@ -0,0 +1,22 @@ +"use strict"; +"require baseclass"; +"require form"; +"require ui"; +"require uci"; +"require fs"; +"require view.podkop.main as main"; + +function createDashboardContent(section) { + const o = section.option(form.DummyValue, "_mount_node"); + o.rawhtml = true; + o.cfgvalue = () => { + main.DashboardTab.initController(); + return main.DashboardTab.render(); + }; +} + +const EntryPoint = { + createDashboardContent, +}; + +return baseclass.extend(EntryPoint); diff --git a/src/frontend/sandbox/htdocs/luci-static/resources/view/podkop/diagnostic.js b/src/frontend/sandbox/htdocs/luci-static/resources/view/podkop/diagnostic.js new file mode 100644 index 0000000..e6ac146 --- /dev/null +++ b/src/frontend/sandbox/htdocs/luci-static/resources/view/podkop/diagnostic.js @@ -0,0 +1,22 @@ +"use strict"; +"require baseclass"; +"require form"; +"require ui"; +"require uci"; +"require fs"; +"require view.podkop.main as main"; + +function createDiagnosticContent(section) { + const o = section.option(form.DummyValue, "_mount_node"); + o.rawhtml = true; + o.cfgvalue = () => { + main.DiagnosticTab.initController(); + return main.DiagnosticTab.render(); + }; +} + +const EntryPoint = { + createDiagnosticContent, +}; + +return baseclass.extend(EntryPoint); diff --git a/src/frontend/sandbox/htdocs/luci-static/resources/view/podkop/main.js b/src/frontend/sandbox/htdocs/luci-static/resources/view/podkop/main.js new file mode 100644 index 0000000..009c1ae --- /dev/null +++ b/src/frontend/sandbox/htdocs/luci-static/resources/view/podkop/main.js @@ -0,0 +1,4942 @@ +// This file is autogenerated, please don't change manually +"use strict"; +"require baseclass"; +"require fs"; +"require uci"; +"require ui"; + +// src/validators/validateIp.ts +function validateIPV4(ip) { + const ipRegex = /^(?:(25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])\.){3}(25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])$/; + if (ipRegex.test(ip)) { + return { valid: true, message: _("Valid") }; + } + return { valid: false, message: _("Invalid IP address") }; +} + +// src/validators/validateDomain.ts +function validateDomain(domain, allowDotTLD = false) { + 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 (allowDotTLD) { + const dotTLD = /^\.[a-zA-Z]{2,}$/; + if (dotTLD.test(domain)) { + return { valid: true, message: _("Valid") }; + } + } + if (!domainRegex.test(domain)) { + return { valid: false, message: _("Invalid domain address") }; + } + const hostname = domain.split("/")[0]; + const parts = hostname.split("."); + const atLeastOneInvalidPart = parts.some((part) => part.length > 63); + if (atLeastOneInvalidPart) { + return { valid: false, message: _("Invalid domain address") }; + } + return { valid: true, message: _("Valid") }; +} + +// src/validators/validateDns.ts +function validateDNS(value) { + if (!value) { + return { valid: false, message: _("DNS server address cannot be empty") }; + } + const cleanedValueWithoutPort = value.replace(/:(\d+)(?=\/|$)/, ""); + const cleanedIpWithoutPath = cleanedValueWithoutPort.split("/")[0]; + if (validateIPV4(cleanedIpWithoutPath).valid) { + return { valid: true, message: _("Valid") }; + } + if (validateDomain(cleanedValueWithoutPort).valid) { + return { valid: true, message: _("Valid") }; + } + return { + valid: false, + message: _( + "Invalid DNS server format. Examples: 8.8.8.8 or dns.example.com or dns.example.com/nicedns for DoH" + ) + }; +} + +// src/validators/validateUrl.ts +function validateUrl(url, protocols = ["http:", "https:"]) { + if (!url.length) { + return { valid: false, message: _("Invalid URL format") }; + } + const hasValidProtocol = protocols.some((p) => url.indexOf(p + "//") === 0); + if (!hasValidProtocol) + return { + valid: false, + message: _("URL must use one of the following protocols:") + " " + protocols.join(", ") + }; + const regex = new RegExp( + `^(?:${protocols.map((p) => p.replace(":", "")).join("|")})://(?:[A-Za-z0-9-]+\\.)+[A-Za-z]{2,}(?::\\d+)?(?:/[^\\s]*)?$` + ); + if (regex.test(url)) { + return { valid: true, message: _("Valid") }; + } + return { valid: false, message: _("Invalid URL format") }; +} + +// src/validators/validatePath.ts +function validatePath(value) { + if (!value) { + return { + valid: false, + message: _("Path cannot be empty") + }; + } + const pathRegex = /^\/[a-zA-Z0-9_\-/.]+$/; + if (pathRegex.test(value)) { + return { + valid: true, + message: _("Valid") + }; + } + return { + valid: false, + message: _( + 'Invalid path format. Path must start with "/" and contain valid characters' + ) + }; +} + +// src/validators/validateSubnet.ts +function validateSubnet(value) { + const subnetRegex = /^(\d{1,3}\.){3}\d{1,3}(?:\/\d{1,2})?$/; + if (!subnetRegex.test(value)) { + return { + valid: false, + message: _("Invalid format. Use X.X.X.X or X.X.X.X/Y") + }; + } + const [ip, cidr] = value.split("/"); + if (ip === "0.0.0.0") { + return { valid: false, message: _("IP address 0.0.0.0 is not allowed") }; + } + const ipCheck = validateIPV4(ip); + if (!ipCheck.valid) { + return ipCheck; + } + if (cidr) { + const cidrNum = parseInt(cidr, 10); + if (cidrNum < 0 || cidrNum > 32) { + return { + valid: false, + message: _("CIDR must be between 0 and 32") + }; + } + } + return { valid: true, message: _("Valid") }; +} + +// src/validators/bulkValidate.ts +function bulkValidate(values, validate) { + const results = values.map((value) => ({ ...validate(value), value })); + return { + valid: results.every((r) => r.valid), + results + }; +} + +// src/validators/validateShadowsocksUrl.ts +function validateShadowsocksUrl(url) { + if (!url.startsWith("ss://")) { + return { + valid: false, + 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") + }; + } + const mainPart = url.includes("?") ? url.split("?")[0] : url.split("#")[0]; + const encryptedPart = mainPart.split("/")[2]?.split("@")[0]; + if (!encryptedPart) { + return { + valid: false, + message: _("Invalid Shadowsocks URL: missing credentials") + }; + } + try { + const decoded = atob(encryptedPart); + if (!decoded.includes(":")) { + return { + valid: false, + message: _( + "Invalid Shadowsocks URL: decoded credentials must contain method:password" + ) + }; + } + } catch (_e) { + if (!encryptedPart.includes(":") && !encryptedPart.includes("-")) { + return { + valid: false, + message: _( + 'Invalid Shadowsocks URL: missing method and password separator ":"' + ) + }; + } + } + const serverPart = url.split("@")[1]; + if (!serverPart) { + return { + valid: false, + message: _("Invalid Shadowsocks URL: missing server address") + }; + } + const [server, portAndRest] = serverPart.split(":"); + if (!server) { + return { + valid: false, + message: _("Invalid Shadowsocks URL: missing server") + }; + } + const port = portAndRest ? portAndRest.split(/[?#]/)[0] : null; + if (!port) { + return { + valid: false, + message: _("Invalid Shadowsocks URL: missing port") + }; + } + const portNum = parseInt(port, 10); + if (isNaN(portNum) || portNum < 1 || portNum > 65535) { + return { + valid: false, + message: _("Invalid port number. Must be between 1 and 65535") + }; + } + } catch (_e) { + return { + valid: false, + message: _("Invalid Shadowsocks URL: parsing failed") + }; + } + return { valid: true, message: _("Valid") }; +} + +// src/helpers/parseQueryString.ts +function parseQueryString(query) { + const clean = query.startsWith("?") ? query.slice(1) : query; + return clean.split("&").filter(Boolean).reduce( + (acc, pair) => { + const [rawKey, rawValue = ""] = pair.split("="); + if (!rawKey) { + return acc; + } + const key = decodeURIComponent(rawKey); + const value = decodeURIComponent(rawValue); + return { ...acc, [key]: value }; + }, + {} + ); +} + +// src/validators/validateVlessUrl.ts +function validateVlessUrl(url) { + try { + if (!url.startsWith("vless://")) + return { + valid: false, + message: "Invalid VLESS URL: must start with vless://" + }; + if (/\s/.test(url)) + return { + valid: false, + message: "Invalid VLESS URL: must not contain spaces" + }; + const body = url.slice("vless://".length); + const [mainPart] = body.split("#"); + const [userHostPort, queryString] = mainPart.split("?"); + if (!userHostPort) + return { + valid: false, + message: "Invalid VLESS URL: missing host and UUID" + }; + const [userPart, hostPortPart] = userHostPort.split("@"); + if (!userPart) + return { valid: false, message: "Invalid VLESS URL: missing UUID" }; + if (!hostPortPart) + return { valid: false, message: "Invalid VLESS URL: missing server" }; + const [host, port] = hostPortPart.split(":"); + if (!host) + return { valid: false, message: "Invalid VLESS URL: missing hostname" }; + if (!port) + return { valid: false, message: "Invalid VLESS URL: missing port" }; + const cleanedPort = port.replace("/", ""); + const portNum = Number(cleanedPort); + if (!Number.isInteger(portNum) || portNum < 1 || portNum > 65535) + return { + valid: false, + message: "Invalid VLESS URL: invalid port number" + }; + if (!queryString) + return { + valid: false, + message: "Invalid VLESS URL: missing query parameters" + }; + const params = parseQueryString(queryString); + const validTypes = [ + "tcp", + "raw", + "udp", + "grpc", + "http", + "httpupgrade", + "xhttp", + "ws", + "kcp" + ]; + const validSecurities = ["tls", "reality", "none"]; + if (!params.type || !validTypes.includes(params.type)) + return { + valid: false, + message: "Invalid VLESS URL: unsupported or missing type" + }; + if (!params.security || !validSecurities.includes(params.security)) + return { + valid: false, + message: "Invalid VLESS URL: unsupported or missing security" + }; + if (params.security === "reality") { + if (!params.pbk) + return { + valid: false, + message: "Invalid VLESS URL: missing pbk for reality" + }; + if (!params.fp) + return { + valid: false, + message: "Invalid VLESS URL: missing fp for reality" + }; + } + if (params.flow === "xtls-rprx-vision-udp443") { + return { + valid: false, + message: "Invalid VLESS URL: flow xtls-rprx-vision-udp443 is not supported" + }; + } + return { valid: true, message: _("Valid") }; + } catch (_e) { + return { valid: false, message: _("Invalid VLESS URL: parsing failed") }; + } +} + +// src/validators/validateOutboundJson.ts +function validateOutboundJson(value) { + try { + JSON.parse(value); + return { valid: true, message: _("Valid") }; + } catch { + return { valid: false, message: _("Invalid JSON format") }; + } +} + +// src/validators/validateTrojanUrl.ts +function validateTrojanUrl(url) { + try { + if (!url.startsWith("trojan://")) { + return { + valid: false, + message: _("Invalid Trojan URL: must start with trojan://") + }; + } + if (!url || /\s/.test(url)) { + return { + valid: false, + message: _("Invalid Trojan URL: must not contain spaces") + }; + } + const body = url.slice("trojan://".length); + const [mainPart] = body.split("#"); + const [userHostPort] = mainPart.split("?"); + const [userPart, hostPortPart] = userHostPort.split("@"); + if (!userHostPort) + return { + valid: false, + message: "Invalid Trojan URL: missing credentials and host" + }; + if (!userPart) + return { valid: false, message: "Invalid Trojan URL: missing password" }; + if (!hostPortPart) + return { + valid: false, + message: "Invalid Trojan URL: missing hostname and port" + }; + const [host, port] = hostPortPart.split(":"); + if (!host) + return { valid: false, message: "Invalid Trojan URL: missing hostname" }; + if (!port) + return { valid: false, message: "Invalid Trojan URL: missing port" }; + const portNum = Number(port); + if (!Number.isInteger(portNum) || portNum < 1 || portNum > 65535) + return { + valid: false, + message: "Invalid Trojan URL: invalid port number" + }; + } catch (_e) { + return { valid: false, message: _("Invalid Trojan URL: parsing failed") }; + } + return { valid: true, message: _("Valid") }; +} + +// src/validators/validateSocksUrl.ts +function validateSocksUrl(url) { + try { + if (!/^socks(4|4a|5):\/\//.test(url)) { + return { + valid: false, + message: _( + "Invalid SOCKS URL: must start with socks4://, socks4a://, or socks5://" + ) + }; + } + if (!url || /\s/.test(url)) { + return { + valid: false, + message: _("Invalid SOCKS URL: must not contain spaces") + }; + } + const body = url.replace(/^socks(4|4a|5):\/\//, ""); + const [authAndHost] = body.split("#"); + const [credentials, hostPortPart] = authAndHost.includes("@") ? authAndHost.split("@") : [null, authAndHost]; + if (credentials) { + const [username, _password] = credentials.split(":"); + if (!username) { + return { + valid: false, + message: _("Invalid SOCKS URL: missing username") + }; + } + } + if (!hostPortPart) { + return { + valid: false, + message: _("Invalid SOCKS URL: missing host and port") + }; + } + const [host, port] = hostPortPart.split(":"); + if (!host) { + return { + valid: false, + message: _("Invalid SOCKS URL: missing hostname or IP") + }; + } + if (!port) { + return { valid: false, message: _("Invalid SOCKS URL: missing port") }; + } + const portNum = Number(port); + if (!Number.isInteger(portNum) || portNum < 1 || portNum > 65535) { + return { + valid: false, + message: _("Invalid SOCKS URL: invalid port number") + }; + } + const ipv4Result = validateIPV4(host); + const domainResult = validateDomain(host); + if (!ipv4Result.valid && !domainResult.valid) { + return { + valid: false, + message: _("Invalid SOCKS URL: invalid host format") + }; + } + } catch (_e) { + return { valid: false, message: _("Invalid SOCKS URL: parsing failed") }; + } + return { valid: true, message: _("Valid") }; +} + +// src/validators/validateHysteriaUrl.ts +function validateHysteria2Url(url) { + try { + const isHY2 = url.startsWith("hysteria2://"); + const isHY2Short = url.startsWith("hy2://"); + if (!isHY2 && !isHY2Short) + return { + valid: false, + message: _("Invalid HY2 URL: must start with hysteria2:// or hy2://") + }; + if (/\s/.test(url)) + return { + valid: false, + message: _("Invalid HY2 URL: must not contain spaces") + }; + const prefix = isHY2 ? "hysteria2://" : "hy2://"; + const body = url.slice(prefix.length); + const [mainPart] = body.split("#"); + const [authHostPort, queryString] = mainPart.split("?"); + if (!authHostPort) + return { + valid: false, + message: _("Invalid HY2 URL: missing credentials/server") + }; + const [passwordPart, hostPortPart] = authHostPort.split("@"); + if (!passwordPart) + return { valid: false, message: _("Invalid HY2 URL: missing password") }; + if (!hostPortPart) + return { + valid: false, + message: _("Invalid HY2 URL: missing host & port") + }; + const [host, port] = hostPortPart.split(":"); + if (!host) + return { valid: false, message: _("Invalid HY2 URL: missing host") }; + if (!port) + return { valid: false, message: _("Invalid HY2 URL: missing port") }; + const cleanedPort = port.replace("/", ""); + const portEntries = cleanedPort.split(","); + const isValidPortNumber = (value) => { + if (!/^\d+$/.test(value)) return false; + const num = Number(value); + return num >= 1 && num <= 65535; + }; + const isValidPortEntry = (entry) => { + if (!entry) return false; + if (!entry.includes("-")) return isValidPortNumber(entry); + const rangeParts = entry.split("-"); + if (rangeParts.length !== 2) return false; + const [start, end] = rangeParts; + if (!isValidPortNumber(start) || !isValidPortNumber(end)) return false; + return Number(start) <= Number(end); + }; + if (!portEntries.every(isValidPortEntry)) + return { + valid: false, + message: _("Invalid HY2 URL: invalid port number") + }; + if (queryString) { + const params = parseQueryString(queryString); + const paramsKeys = Object.keys(params); + if (paramsKeys.includes("insecure") && !["0", "1"].includes(params.insecure)) + return { + valid: false, + message: _("Invalid HY2 URL: insecure must be 0 or 1") + }; + const validObfsTypes = ["none", "salamander"]; + if (paramsKeys.includes("obfs") && !validObfsTypes.includes(params.obfs)) + return { + valid: false, + message: _("Invalid HY2 URL: unsupported obfs type") + }; + if (paramsKeys.includes("obfs") && params.obfs !== "none" && !params["obfs-password"]) + return { + valid: false, + message: _( + "Invalid HY2 URL: obfs-password required when obfs is set" + ) + }; + if (paramsKeys.includes("sni") && !params.sni) + return { + valid: false, + message: _("Invalid HY2 URL: sni cannot be empty") + }; + } + return { valid: true, message: _("Valid") }; + } catch (_e) { + return { valid: false, message: _("Invalid HY2 URL: parsing failed") }; + } +} + +// src/validators/validateProxyUrl.ts +function validateProxyUrl(url) { + const trimmedUrl = url.trim(); + if (trimmedUrl.startsWith("ss://")) { + return validateShadowsocksUrl(trimmedUrl); + } + if (trimmedUrl.startsWith("vless://")) { + return validateVlessUrl(trimmedUrl); + } + if (trimmedUrl.startsWith("trojan://")) { + return validateTrojanUrl(trimmedUrl); + } + if (/^socks(4|4a|5):\/\//.test(trimmedUrl)) { + return validateSocksUrl(trimmedUrl); + } + if (trimmedUrl.startsWith("hysteria2://") || trimmedUrl.startsWith("hy2://")) { + return validateHysteria2Url(trimmedUrl); + } + return { + valid: false, + message: _( + "URL must start with vless://, ss://, trojan://, socks4/5://, or hysteria2://hy2://" + ) + }; +} + +// src/helpers/parseValueList.ts +function parseValueList(value) { + return value.split(/\n/).map((line) => line.split("//")[0]).join(" ").split(/[,\s]+/).map((s) => s.trim()).filter(Boolean); +} + +// src/podkop/methods/custom/getConfigSections.ts +async function getConfigSections() { + return uci.load("podkop").then(() => uci.sections("podkop")); +} + +// src/podkop/methods/shell/callBaseMethod.ts +async function callBaseMethod(method, args = [], command = "/usr/bin/podkop") { + const response = await executeShellCommand({ + command, + args: [method, ...args], + timeout: 15e3 + }); + if (response.stdout) { + try { + return { + success: true, + data: JSON.parse(response.stdout) + }; + } catch (_e) { + return { + success: true, + data: response.stdout + }; + } + } + return { + success: false, + error: "" + }; +} + +// src/podkop/types.ts +var Podkop; +((Podkop2) => { + let AvailableMethods; + ((AvailableMethods2) => { + AvailableMethods2["CHECK_DNS_AVAILABLE"] = "check_dns_available"; + AvailableMethods2["CHECK_FAKEIP"] = "check_fakeip"; + AvailableMethods2["CHECK_NFT_RULES"] = "check_nft_rules"; + AvailableMethods2["GET_STATUS"] = "get_status"; + AvailableMethods2["CHECK_SING_BOX"] = "check_sing_box"; + AvailableMethods2["GET_SING_BOX_STATUS"] = "get_sing_box_status"; + AvailableMethods2["CLASH_API"] = "clash_api"; + AvailableMethods2["RESTART"] = "restart"; + AvailableMethods2["START"] = "start"; + AvailableMethods2["STOP"] = "stop"; + AvailableMethods2["ENABLE"] = "enable"; + AvailableMethods2["DISABLE"] = "disable"; + AvailableMethods2["GLOBAL_CHECK"] = "global_check"; + AvailableMethods2["SHOW_SING_BOX_CONFIG"] = "show_sing_box_config"; + AvailableMethods2["CHECK_LOGS"] = "check_logs"; + AvailableMethods2["GET_SYSTEM_INFO"] = "get_system_info"; + })(AvailableMethods = Podkop2.AvailableMethods || (Podkop2.AvailableMethods = {})); + let AvailableClashAPIMethods; + ((AvailableClashAPIMethods2) => { + AvailableClashAPIMethods2["GET_PROXIES"] = "get_proxies"; + AvailableClashAPIMethods2["GET_PROXY_LATENCY"] = "get_proxy_latency"; + AvailableClashAPIMethods2["GET_GROUP_LATENCY"] = "get_group_latency"; + AvailableClashAPIMethods2["SET_GROUP_PROXY"] = "set_group_proxy"; + })(AvailableClashAPIMethods = Podkop2.AvailableClashAPIMethods || (Podkop2.AvailableClashAPIMethods = {})); +})(Podkop || (Podkop = {})); + +// src/podkop/methods/shell/index.ts +var PodkopShellMethods = { + checkDNSAvailable: async () => callBaseMethod( + Podkop.AvailableMethods.CHECK_DNS_AVAILABLE + ), + checkFakeIP: async () => callBaseMethod( + Podkop.AvailableMethods.CHECK_FAKEIP + ), + checkNftRules: async () => callBaseMethod( + Podkop.AvailableMethods.CHECK_NFT_RULES + ), + getStatus: async () => callBaseMethod(Podkop.AvailableMethods.GET_STATUS), + checkSingBox: async () => callBaseMethod( + Podkop.AvailableMethods.CHECK_SING_BOX + ), + getSingBoxStatus: async () => callBaseMethod( + Podkop.AvailableMethods.GET_SING_BOX_STATUS + ), + getClashApiProxies: async () => callBaseMethod(Podkop.AvailableMethods.CLASH_API, [ + Podkop.AvailableClashAPIMethods.GET_PROXIES + ]), + getClashApiProxyLatency: async (tag) => callBaseMethod( + Podkop.AvailableMethods.CLASH_API, + [Podkop.AvailableClashAPIMethods.GET_PROXY_LATENCY, tag, "5000"] + ), + getClashApiGroupLatency: async (tag) => callBaseMethod( + Podkop.AvailableMethods.CLASH_API, + [Podkop.AvailableClashAPIMethods.GET_GROUP_LATENCY, tag, "10000"] + ), + setClashApiGroupProxy: async (group, proxy) => callBaseMethod(Podkop.AvailableMethods.CLASH_API, [ + Podkop.AvailableClashAPIMethods.SET_GROUP_PROXY, + group, + proxy + ]), + restart: async () => callBaseMethod( + Podkop.AvailableMethods.RESTART, + [], + "/etc/init.d/podkop" + ), + start: async () => callBaseMethod( + Podkop.AvailableMethods.START, + [], + "/etc/init.d/podkop" + ), + stop: async () => callBaseMethod( + Podkop.AvailableMethods.STOP, + [], + "/etc/init.d/podkop" + ), + enable: async () => callBaseMethod( + Podkop.AvailableMethods.ENABLE, + [], + "/etc/init.d/podkop" + ), + disable: async () => callBaseMethod( + Podkop.AvailableMethods.DISABLE, + [], + "/etc/init.d/podkop" + ), + globalCheck: async () => callBaseMethod(Podkop.AvailableMethods.GLOBAL_CHECK), + showSingBoxConfig: async () => callBaseMethod(Podkop.AvailableMethods.SHOW_SING_BOX_CONFIG), + checkLogs: async () => callBaseMethod(Podkop.AvailableMethods.CHECK_LOGS), + getSystemInfo: async () => callBaseMethod( + Podkop.AvailableMethods.GET_SYSTEM_INFO + ) +}; + +// src/podkop/methods/custom/getDashboardSections.ts +async function getDashboardSections() { + const configSections = await getConfigSections(); + const clashProxies = await PodkopShellMethods.getClashApiProxies(); + if (!clashProxies.success) { + return { + success: false, + data: [] + }; + } + const proxies = Object.entries(clashProxies.data.proxies).map( + ([key, value]) => ({ + code: key, + value + }) + ); + const data = configSections.filter( + (section) => section.connection_type !== "block" && section.connection_type !== "exclusion" && section[".type"] !== "settings" + ).map((section) => { + if (section.connection_type === "proxy") { + if (section.proxy_config_type === "url") { + const outbound = proxies.find( + (proxy) => proxy.code === `${section[".name"]}-out` + ); + const activeConfigs = splitProxyString(section.proxy_string); + const proxyDisplayName = getProxyUrlName(activeConfigs?.[0]) || outbound?.value?.name || ""; + return { + withTagSelect: false, + code: outbound?.code || section[".name"], + displayName: section[".name"], + outbounds: [ + { + code: outbound?.code || section[".name"], + displayName: proxyDisplayName, + 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` + ); + const parsedOutbound = JSON.parse(section.outbound_json); + const parsedTag = parsedOutbound?.tag ? decodeURIComponent(parsedOutbound?.tag) : void 0; + const proxyDisplayName = parsedTag || outbound?.value?.name || ""; + return { + withTagSelect: false, + code: outbound?.code || section[".name"], + displayName: section[".name"], + outbounds: [ + { + code: outbound?.code || section[".name"], + displayName: proxyDisplayName, + latency: outbound?.value?.history?.[0]?.delay || 0, + type: outbound?.value?.type || "", + selected: true + } + ] + }; + } + if (section.proxy_config_type === "selector") { + const selector = proxies.find( + (proxy) => proxy.code === `${section[".name"]}-out` + ); + const links = section.selector_proxy_links ?? []; + const outbounds = links.map((link, index) => ({ + link, + outbound: proxies.find( + (item) => item.code === `${section[".name"]}-${index + 1}-out` + ) + })).map((item) => ({ + code: item?.outbound?.code || "", + displayName: getProxyUrlName(item.link) || item?.outbound?.value?.name || "", + latency: item?.outbound?.value?.history?.[0]?.delay || 0, + type: item?.outbound?.value?.type || "", + selected: selector?.value?.now === item?.outbound?.code + })); + return { + withTagSelect: true, + code: selector?.code || section[".name"], + displayName: section[".name"], + outbounds + }; + } + 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 { + withTagSelect: true, + code: selector?.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 + ] + }; + } + } + if (section.connection_type === "vpn") { + const outbound = proxies.find( + (proxy) => proxy.code === `${section[".name"]}-out` + ); + return { + withTagSelect: false, + code: outbound?.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 { + withTagSelect: false, + code: section[".name"], + displayName: section[".name"], + outbounds: [] + }; + }); + return { + success: true, + data + }; +} + +// src/podkop/methods/custom/getClashApiSecret.ts +async function getClashApiSecret() { + const sections = await getConfigSections(); + const settings = sections.find((section) => section[".type"] === "settings"); + return settings?.yacd_secret_key || ""; +} + +// src/podkop/methods/custom/index.ts +var CustomPodkopMethods = { + getConfigSections, + getDashboardSections, + getClashApiSecret +}; + +// src/constants.ts +var STATUS_COLORS = { + SUCCESS: "#4caf50", + ERROR: "#f44336", + WARNING: "#ff9800" +}; +var PODKOP_LUCI_APP_VERSION = "__COMPILED_VERSION_VARIABLE__"; +var FAKEIP_CHECK_DOMAIN = "fakeip.podkop.fyi"; +var IP_CHECK_DOMAIN = "ip.podkop.fyi"; +var REGIONAL_OPTIONS = [ + "russia_inside", + "russia_outside", + "ukraine_inside" +]; +var ALLOWED_WITH_RUSSIA_INSIDE = [ + "russia_inside", + "meta", + "twitter", + "discord", + "telegram", + "cloudflare", + "google_ai", + "google_play", + "hetzner", + "ovh", + "hodca", + "roblox", + "digitalocean", + "cloudfront" +]; +var DOMAIN_LIST_OPTIONS = { + russia_inside: "Russia inside", + russia_outside: "Russia outside", + ukraine_inside: "Ukraine", + geoblock: "Geo Block", + block: "Block", + porn: "Porn", + news: "News", + anime: "Anime", + youtube: "Youtube", + discord: "Discord", + meta: "Meta", + twitter: "Twitter (X)", + hdrezka: "HDRezka", + tiktok: "Tik-Tok", + telegram: "Telegram", + cloudflare: "Cloudflare", + google_ai: "Google AI", + google_play: "Google Play", + hodca: "H.O.D.C.A", + roblox: "Roblox", + hetzner: "Hetzner ASN", + ovh: "OVH ASN", + digitalocean: "Digital Ocean ASN", + cloudfront: "CloudFront ASN" +}; +var UPDATE_INTERVAL_OPTIONS = { + "1h": "Every hour", + "3h": "Every 3 hours", + "12h": "Every 12 hours", + "1d": "Every day", + "3d": "Every 3 days" +}; +var DNS_SERVER_OPTIONS = { + "1.1.1.1": "1.1.1.1 (Cloudflare)", + "8.8.8.8": "8.8.8.8 (Google)", + "9.9.9.9": "9.9.9.9 (Quad9)", + "dns.adguard-dns.com": "dns.adguard-dns.com (AdGuard Default)", + "unfiltered.adguard-dns.com": "unfiltered.adguard-dns.com (AdGuard Unfiltered)", + "family.adguard-dns.com": "family.adguard-dns.com (AdGuard Family)" +}; +var BOOTSTRAP_DNS_SERVER_OPTIONS = { + "77.88.8.8": "77.88.8.8 (Yandex DNS)", + "77.88.8.1": "77.88.8.1 (Yandex DNS)", + "1.1.1.1": "1.1.1.1 (Cloudflare DNS)", + "1.0.0.1": "1.0.0.1 (Cloudflare DNS)", + "8.8.8.8": "8.8.8.8 (Google DNS)", + "8.8.4.4": "8.8.4.4 (Google DNS)", + "9.9.9.9": "9.9.9.9 (Quad9 DNS)", + "9.9.9.11": "9.9.9.11 (Quad9 DNS)" +}; +var DIAGNOSTICS_UPDATE_INTERVAL = 1e4; +var CACHE_TIMEOUT = DIAGNOSTICS_UPDATE_INTERVAL - 1e3; +var ERROR_POLL_INTERVAL = 1e4; +var COMMAND_TIMEOUT = 1e4; +var FETCH_TIMEOUT = 1e4; +var BUTTON_FEEDBACK_TIMEOUT = 1e3; +var DIAGNOSTICS_INITIAL_DELAY = 100; +var COMMAND_SCHEDULING = { + P0_PRIORITY: 0, + // Highest priority (no delay) + P1_PRIORITY: 100, + // Very high priority + P2_PRIORITY: 300, + // High priority + P3_PRIORITY: 500, + // Above average + P4_PRIORITY: 700, + // Standard priority + P5_PRIORITY: 900, + // Below average + P6_PRIORITY: 1100, + // Low priority + P7_PRIORITY: 1300, + // Very low priority + P8_PRIORITY: 1500, + // Background execution + P9_PRIORITY: 1700, + // Idle mode execution + P10_PRIORITY: 1900 + // Lowest priority +}; + +// src/podkop/api.ts +async function createBaseApiRequest(fetchFn, options) { + const wrappedFn = () => options?.timeoutMs && options?.operationName ? withTimeout( + fetchFn(), + options.timeoutMs, + options.operationName, + options.timeoutMessage + ) : fetchFn(); + try { + const response = await wrappedFn(); + 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/podkop/methods/fakeip/getFakeIpCheck.ts +async function getFakeIpCheck() { + return createBaseApiRequest( + () => fetch(`https://${FAKEIP_CHECK_DOMAIN}/check`, { + method: "GET", + headers: { "Content-Type": "application/json" } + }), + { + operationName: "getFakeIpCheck", + timeoutMs: 5e3 + } + ); +} + +// src/podkop/methods/fakeip/getIpCheck.ts +async function getIpCheck() { + return createBaseApiRequest( + () => fetch(`https://${IP_CHECK_DOMAIN}/check`, { + method: "GET", + headers: { "Content-Type": "application/json" } + }), + { + operationName: "getIpCheck", + timeoutMs: 5e3 + } + ); +} + +// src/podkop/methods/fakeip/index.ts +var RemoteFakeIPMethods = { + getFakeIpCheck, + getIpCheck +}; + +// 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/podkop/tabs/diagnostic/helpers/getCheckTitle.ts +function getCheckTitle(name) { + return `${name} ${_("checks")}`; +} + +// src/podkop/tabs/diagnostic/checks/contstants.ts +var DIAGNOSTICS_CHECKS_MAP = { + ["DNS" /* DNS */]: { + order: 1, + title: getCheckTitle("DNS"), + code: "DNS" /* DNS */ + }, + ["SINGBOX" /* SINGBOX */]: { + order: 2, + title: getCheckTitle("Sing-box"), + code: "SINGBOX" /* SINGBOX */ + }, + ["NFT" /* NFT */]: { + order: 3, + title: getCheckTitle("Nftables"), + code: "NFT" /* NFT */ + }, + ["OUTBOUNDS" /* OUTBOUNDS */]: { + order: 4, + title: getCheckTitle("Outbounds"), + code: "OUTBOUNDS" /* OUTBOUNDS */ + }, + ["FAKEIP" /* FAKEIP */]: { + order: 5, + title: getCheckTitle("FakeIP"), + code: "FAKEIP" /* FAKEIP */ + } +}; + +// src/podkop/tabs/diagnostic/diagnostic.store.ts +var initialDiagnosticStore = { + diagnosticsSystemInfo: { + loading: true, + podkop_version: "loading", + podkop_latest_version: "loading", + luci_app_version: "loading", + sing_box_version: "loading", + openwrt_version: "loading", + device_model: "loading" + }, + diagnosticsActions: { + restart: { + loading: false + }, + start: { + loading: false + }, + stop: { + loading: false + }, + enable: { + loading: false + }, + disable: { + loading: false + }, + globalCheck: { + loading: false + }, + viewLogs: { + loading: false + }, + showSingBoxConfig: { + loading: false + } + }, + diagnosticsRunAction: { loading: false }, + diagnosticsChecks: [ + { + code: "DNS" /* DNS */, + title: DIAGNOSTICS_CHECKS_MAP.DNS.title, + order: DIAGNOSTICS_CHECKS_MAP.DNS.order, + description: _("Not running"), + items: [], + state: "skipped" + }, + { + code: "SINGBOX" /* SINGBOX */, + title: DIAGNOSTICS_CHECKS_MAP.SINGBOX.title, + order: DIAGNOSTICS_CHECKS_MAP.SINGBOX.order, + description: _("Not running"), + items: [], + state: "skipped" + }, + { + code: "NFT" /* NFT */, + title: DIAGNOSTICS_CHECKS_MAP.NFT.title, + order: DIAGNOSTICS_CHECKS_MAP.NFT.order, + description: _("Not running"), + items: [], + state: "skipped" + }, + { + code: "OUTBOUNDS" /* OUTBOUNDS */, + title: DIAGNOSTICS_CHECKS_MAP.OUTBOUNDS.title, + order: DIAGNOSTICS_CHECKS_MAP.OUTBOUNDS.order, + description: _("Not running"), + items: [], + state: "skipped" + }, + { + code: "FAKEIP" /* FAKEIP */, + title: DIAGNOSTICS_CHECKS_MAP.FAKEIP.title, + order: DIAGNOSTICS_CHECKS_MAP.FAKEIP.order, + description: _("Not running"), + items: [], + state: "skipped" + } + ] +}; +var loadingDiagnosticsChecksStore = { + diagnosticsChecks: [ + { + code: "DNS" /* DNS */, + title: DIAGNOSTICS_CHECKS_MAP.DNS.title, + order: DIAGNOSTICS_CHECKS_MAP.DNS.order, + description: _("Pending"), + items: [], + state: "skipped" + }, + { + code: "SINGBOX" /* SINGBOX */, + title: DIAGNOSTICS_CHECKS_MAP.SINGBOX.title, + order: DIAGNOSTICS_CHECKS_MAP.SINGBOX.order, + description: _("Pending"), + items: [], + state: "skipped" + }, + { + code: "NFT" /* NFT */, + title: DIAGNOSTICS_CHECKS_MAP.NFT.title, + order: DIAGNOSTICS_CHECKS_MAP.NFT.order, + description: _("Pending"), + items: [], + state: "skipped" + }, + { + code: "OUTBOUNDS" /* OUTBOUNDS */, + title: DIAGNOSTICS_CHECKS_MAP.OUTBOUNDS.title, + order: DIAGNOSTICS_CHECKS_MAP.OUTBOUNDS.order, + description: _("Pending"), + items: [], + state: "skipped" + }, + { + code: "FAKEIP" /* FAKEIP */, + title: DIAGNOSTICS_CHECKS_MAP.FAKEIP.title, + order: DIAGNOSTICS_CHECKS_MAP.FAKEIP.order, + description: _("Pending"), + items: [], + state: "skipped" + } + ] +}; + +// src/podkop/services/store.service.ts +function jsonStableStringify(obj) { + return JSON.stringify(obj, (_2, 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 StoreService = 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(keys) { + const prev = this.value; + const next = structuredClone(this.value); + if (keys && keys.length > 0) { + keys.forEach((key) => { + next[key] = structuredClone(this.initial[key]); + }); + } else { + Object.assign(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: [] + }, + bandwidthWidget: { + loading: true, + failed: false, + data: { up: 0, down: 0 } + }, + 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, + latencyFetching: false, + data: [] + }, + ...initialDiagnosticStore +}; +var store = new StoreService(initialStore); + +// src/helpers/downloadAsTxt.ts +function downloadAsTxt(text, filename) { + const blob = new Blob([text], { type: "text/plain;charset=utf-8" }); + const link = document.createElement("a"); + link.href = URL.createObjectURL(blob); + const safeName = filename.endsWith(".txt") ? filename : `${filename}.txt`; + link.download = safeName; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + URL.revokeObjectURL(link.href); +} + +// src/podkop/services/logger.service.ts +var Logger = class { + constructor() { + this.logs = []; + this.levels = ["debug", "info", "warn", "error"]; + } + format(level, ...args) { + return `[${level.toUpperCase()}] ${args.join(" ")}`; + } + push(level, ...args) { + if (!this.levels.includes(level)) level = "info"; + const message = this.format(level, ...args); + this.logs.push(message); + switch (level) { + case "error": + console.error(message); + break; + case "warn": + console.warn(message); + break; + case "info": + console.info(message); + break; + default: + console.log(message); + } + } + debug(...args) { + this.push("debug", ...args); + } + info(...args) { + this.push("info", ...args); + } + warn(...args) { + this.push("warn", ...args); + } + error(...args) { + this.push("error", ...args); + } + clear() { + this.logs = []; + } + getLogs() { + return this.logs.join("\n"); + } + download(filename = "logs.txt") { + if (typeof document === "undefined") { + console.warn("Logger.download() \u0434\u043E\u0441\u0442\u0443\u043F\u0435\u043D \u0442\u043E\u043B\u044C\u043A\u043E \u0432 \u0431\u0440\u0430\u0443\u0437\u0435\u0440\u0435"); + return; + } + downloadAsTxt(this.getLogs(), filename); + } +}; +var logger = new Logger(); + +// src/podkop/services/podkopLogWatcher.service.ts +var PodkopLogWatcher = class _PodkopLogWatcher { + constructor() { + this.intervalMs = 5e3; + this.lastLines = /* @__PURE__ */ new Set(); + this.running = false; + this.paused = false; + if (typeof document !== "undefined") { + document.addEventListener("visibilitychange", () => { + if (document.hidden) this.pause(); + else this.resume(); + }); + } + } + static getInstance() { + if (!_PodkopLogWatcher.instance) { + _PodkopLogWatcher.instance = new _PodkopLogWatcher(); + } + return _PodkopLogWatcher.instance; + } + init(fetcher, options) { + this.fetcher = fetcher; + this.onNewLog = options?.onNewLog; + this.intervalMs = options?.intervalMs ?? 5e3; + logger.info( + "[PodkopLogWatcher]", + `initialized (interval: ${this.intervalMs}ms)` + ); + } + async checkOnce() { + if (!this.fetcher) { + logger.warn("[PodkopLogWatcher]", "fetcher not found"); + return; + } + if (this.paused) { + logger.debug("[PodkopLogWatcher]", "skipped check \u2014 tab not visible"); + return; + } + try { + const raw = await this.fetcher(); + const lines = raw.split("\n").filter(Boolean); + for (const line of lines) { + if (!this.lastLines.has(line)) { + this.lastLines.add(line); + this.onNewLog?.(line); + } + } + if (this.lastLines.size > 500) { + const arr = Array.from(this.lastLines); + this.lastLines = new Set(arr.slice(-500)); + } + } catch (err) { + logger.error("[PodkopLogWatcher]", "failed to read logs:", err); + } + } + start() { + if (this.running) return; + if (!this.fetcher) { + logger.warn("[PodkopLogWatcher]", "attempted to start without fetcher"); + return; + } + this.running = true; + this.timer = setInterval(() => this.checkOnce(), this.intervalMs); + logger.info( + "[PodkopLogWatcher]", + `started (interval: ${this.intervalMs}ms)` + ); + } + stop() { + if (!this.running) return; + this.running = false; + if (this.timer) clearInterval(this.timer); + logger.info("[PodkopLogWatcher]", "stopped"); + } + pause() { + if (!this.running || this.paused) return; + this.paused = true; + logger.info("[PodkopLogWatcher]", "paused (tab not visible)"); + } + resume() { + if (!this.running || !this.paused) return; + this.paused = false; + logger.info("[PodkopLogWatcher]", "resumed (tab active)"); + this.checkOnce(); + } + reset() { + this.lastLines.clear(); + logger.info("[PodkopLogWatcher]", "log history reset"); + } +}; + +// src/podkop/services/core.service.ts +function coreService() { + TabServiceInstance.onChange((activeId, tabs) => { + logger.info("[TAB]", activeId); + store.set({ + tabService: { + current: activeId || "", + all: tabs.map((tab) => tab.id) + } + }); + }); + const watcher = PodkopLogWatcher.getInstance(); + watcher.init( + async () => { + const logs = await PodkopShellMethods.checkLogs(); + if (logs.success) { + return logs.data; + } + return ""; + }, + { + intervalMs: 3e3, + onNewLog: (line) => { + if (line.toLowerCase().includes("[error]") || line.toLowerCase().includes("[fatal]")) { + ui.addNotification("Podkop Error", E("div", {}, line), "error"); + } + } + } + ); + watcher.start(); +} + +// src/podkop/services/socket.service.ts +var SocketManager = class _SocketManager { + constructor() { + 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) { + _SocketManager.instance = new _SocketManager(); + } + return _SocketManager.instance; + } + resetAll() { + for (const [url, ws] of this.sockets.entries()) { + try { + if (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING) { + ws.close(); + } + } catch (err) { + logger.error( + "[SOCKET]", + `resetAll: failed to close socket ${url}`, + err + ); + } + } + this.sockets.clear(); + this.listeners.clear(); + this.errorListeners.clear(); + this.connected.clear(); + logger.info("[SOCKET]", "All connections and state have been reset."); + } + connect(url) { + if (this.sockets.has(url)) return; + let ws; + try { + ws = new WebSocket(url); + } catch (err) { + logger.error( + "[SOCKET]", + `failed to construct WebSocket for ${url}:`, + err + ); + this.triggerError(url, err instanceof Event ? err : String(err)); + return; + } + 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); + logger.info("[SOCKET]", "Connected to", url); + }); + ws.addEventListener("message", (event) => { + const handlers = this.listeners.get(url); + if (handlers) { + for (const handler of handlers) { + try { + handler(event.data); + } catch (err) { + logger.error("[SOCKET]", `Handler error for ${url}:`, err); + } + } + } + }); + ws.addEventListener("close", () => { + this.connected.set(url, false); + logger.warn("[SOCKET]", `Disconnected: ${url}`); + this.triggerError(url, "Connection closed"); + }); + ws.addEventListener("error", (err) => { + logger.error("[SOCKET]", `Socket error for ${url}:`, err); + this.triggerError(url, err); + }); + } + subscribe(url, listener, onError) { + if (!this.errorListeners.has(url)) { + this.errorListeners.set(url, /* @__PURE__ */ new Set()); + } + if (onError) { + this.errorListeners.get(url)?.add(onError); + } + if (!this.sockets.has(url)) { + this.connect(url); + } + if (!this.listeners.has(url)) { + this.listeners.set(url, /* @__PURE__ */ new Set()); + } + this.listeners.get(url)?.add(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) { + const ws = this.sockets.get(url); + if (ws && this.connected.get(url)) { + ws.send(typeof data === "string" ? data : JSON.stringify(data)); + } else { + logger.warn("[SOCKET]", `Cannot send: not connected to ${url}`); + this.triggerError(url, "Not connected"); + } + } + disconnect(url) { + const ws = this.sockets.get(url); + if (ws) { + ws.close(); + this.sockets.delete(url); + this.listeners.delete(url); + this.errorListeners.delete(url); + this.connected.delete(url); + } + } + disconnectAll() { + for (const url of this.sockets.keys()) { + this.disconnect(url); + } + } + triggerError(url, err) { + const handlers = this.errorListeners.get(url); + if (handlers) { + for (const cb of handlers) { + try { + cb(err); + } catch (e) { + logger.error("[SOCKET]", `Error handler threw for ${url}:`, e); + } + } + } + } +}; +var socket = SocketManager.getInstance(); + +// src/podkop/tabs/dashboard/partials/renderSections.ts +function renderFailedState() { + return E( + "div", + { + class: "pdk_dashboard-page__outbound-section centered", + style: "height: 127px" + }, + E("span", {}, [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" + }); +} +function renderDefaultState({ + section, + onChooseOutbound, + onTestLatency, + latencyFetching +}) { + 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) { + return "pdk_dashboard-page__outbound-grid__item__latency--empty"; + } + if (outbound.latency < 800) { + return "pdk_dashboard-page__outbound-grid__item__latency--green"; + } + if (outbound.latency < 1500) { + return "pdk_dashboard-page__outbound-grid__item__latency--yellow"; + } + return "pdk_dashboard-page__outbound-grid__item__latency--red"; + } + return E( + "div", + { + 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), + 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: getLatencyClass() }, + 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" + }, + section.displayName + ), + latencyFetching ? E("div", { class: "skeleton", style: "width: 99px; height: 28px" }) : E( + "button", + { + class: "btn dashboard-sections-grid-item-test-latency", + click: () => testLatency() + }, + _("Test latency") + ) + ]), + E( + "div", + { class: "pdk_dashboard-page__outbound-grid" }, + section.outbounds.map((outbound) => renderOutbound(outbound)) + ) + ]); +} +function renderSections(props) { + if (props.failed) { + return renderFailedState(); + } + if (props.loading) { + return renderLoadingState(); + } + return renderDefaultState(props); +} + +// src/podkop/tabs/dashboard/partials/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", + { 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 + ) + ] + ) + ) + ]); +} +function renderWidget(props) { + if (props.loading) { + return renderLoadingState2(); + } + if (props.failed) { + return renderFailedState2(); + } + return renderDefaultState2(props); +} + +// src/podkop/tabs/dashboard/render.ts +function render() { + 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: () => { + }, + latencyFetching: false + }) + ) + ] + ); +} + +// 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/podkop/fetchers/fetchServicesInfo.ts +async function fetchServicesInfo() { + const [podkop, singbox] = await Promise.all([ + PodkopShellMethods.getStatus(), + PodkopShellMethods.getSingBoxStatus() + ]); + if (!podkop.success || !singbox.success) { + store.set({ + servicesInfoWidget: { + loading: false, + failed: true, + data: { singbox: 0, podkop: 0 } + } + }); + } + if (podkop.success && singbox.success) { + store.set({ + servicesInfoWidget: { + loading: false, + failed: false, + data: { singbox: singbox.data.running, podkop: podkop.data.enabled } + } + }); + } +} + +// src/podkop/tabs/dashboard/initController.ts +async function fetchDashboardSections() { + const prev = store.get().sectionsWidget; + store.set({ + sectionsWidget: { + ...prev, + failed: false + } + }); + const { data, success } = await CustomPodkopMethods.getDashboardSections(); + if (!success) { + logger.error("[DASHBOARD]", "fetchDashboardSections: failed to fetch"); + } + store.set({ + sectionsWidget: { + latencyFetching: false, + loading: false, + failed: !success, + data + } + }); +} +async function connectToClashSockets() { + const clashApiSecret = await getClashApiSecret(); + socket.subscribe( + `${getClashWsUrl()}/traffic?token=${clashApiSecret}`, + (msg) => { + const parsedMsg = JSON.parse(msg); + store.set({ + bandwidthWidget: { + loading: false, + failed: false, + data: { up: parsedMsg.up, down: parsedMsg.down } + } + }); + }, + (_err) => { + logger.error( + "[DASHBOARD]", + "connectToClashSockets - traffic: failed to connect to", + getClashWsUrl() + ); + store.set({ + bandwidthWidget: { + loading: false, + failed: true, + data: { up: 0, down: 0 } + } + }); + } + ); + socket.subscribe( + `${getClashWsUrl()}/connections?token=${clashApiSecret}`, + (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) => { + logger.error( + "[DASHBOARD]", + "connectToClashSockets - connections: failed to connect to", + getClashWsUrl() + ); + 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 PodkopShellMethods.setClashApiGroupProxy(selector, tag); + await fetchDashboardSections(); +} +async function handleTestGroupLatency(tag) { + store.set({ + sectionsWidget: { + ...store.get().sectionsWidget, + latencyFetching: true + } + }); + await PodkopShellMethods.getClashApiGroupLatency(tag); + await fetchDashboardSections(); + store.set({ + sectionsWidget: { + ...store.get().sectionsWidget, + latencyFetching: false + } + }); +} +async function handleTestProxyLatency(tag) { + store.set({ + sectionsWidget: { + ...store.get().sectionsWidget, + latencyFetching: true + } + }); + await PodkopShellMethods.getClashApiProxyLatency(tag); + await fetchDashboardSections(); + store.set({ + sectionsWidget: { + ...store.get().sectionsWidget, + latencyFetching: false + } + }); +} +async function renderSectionsWidget() { + logger.debug("[DASHBOARD]", "renderSectionsWidget"); + const sectionsWidget = store.get().sectionsWidget; + const container = document.getElementById("dashboard-sections-grid"); + if (sectionsWidget.loading || sectionsWidget.failed) { + const renderedWidget = renderSections({ + loading: sectionsWidget.loading, + failed: sectionsWidget.failed, + section: { + code: "", + displayName: "", + outbounds: [], + withTagSelect: false + }, + onTestLatency: () => { + }, + onChooseOutbound: () => { + }, + latencyFetching: sectionsWidget.latencyFetching + }); + return preserveScrollForPage(() => { + container.replaceChildren(renderedWidget); + }); + } + const renderedWidgets = sectionsWidget.data.map( + (section) => renderSections({ + loading: sectionsWidget.loading, + failed: sectionsWidget.failed, + section, + latencyFetching: sectionsWidget.latencyFetching, + onTestLatency: (tag) => { + if (section.withTagSelect) { + return handleTestGroupLatency(tag); + } + return handleTestProxyLatency(tag); + }, + onChooseOutbound: (selector, tag) => { + handleChooseOutbound(selector, tag); + } + }) + ); + return preserveScrollForPage(() => { + container.replaceChildren(...renderedWidgets); + }); +} +async function renderBandwidthWidget() { + logger.debug("[DASHBOARD]", "renderBandwidthWidget"); + const traffic = store.get().bandwidthWidget; + const container = document.getElementById("dashboard-widget-traffic"); + 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.data.up)}/s` }, + { key: _("Downlink"), value: `${prettyBytes(traffic.data.down)}/s` } + ] + }); + container.replaceChildren(renderedWidget); +} +async function renderTrafficTotalWidget() { + logger.debug("[DASHBOARD]", "renderTrafficTotalWidget"); + const trafficTotalWidget = store.get().trafficTotalWidget; + const container = document.getElementById("dashboard-widget-traffic-total"); + 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(trafficTotalWidget.data.uploadTotal)) + }, + { + key: _("Downlink"), + value: String(prettyBytes(trafficTotalWidget.data.downloadTotal)) + } + ] + }); + container.replaceChildren(renderedWidget); +} +async function renderSystemInfoWidget() { + logger.debug("[DASHBOARD]", "renderSystemInfoWidget"); + const systemInfoWidget = store.get().systemInfoWidget; + const container = document.getElementById("dashboard-widget-system-info"); + 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(systemInfoWidget.data.connections) + }, + { + key: _("Memory Usage"), + value: String(prettyBytes(systemInfoWidget.data.memory)) + } + ] + }); + container.replaceChildren(renderedWidget); +} +async function renderServicesInfoWidget() { + logger.debug("[DASHBOARD]", "renderServicesInfoWidget"); + const servicesInfoWidget = store.get().servicesInfoWidget; + const container = document.getElementById("dashboard-widget-service-info"); + 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: 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"), + attributes: { + class: servicesInfoWidget.data.singbox ? "pdk_dashboard-page__widgets-section__item__row--success" : "pdk_dashboard-page__widgets-section__item__row--error" + } + } + ] + }); + container.replaceChildren(renderedWidget); +} +async function onStoreUpdate(next, prev, diff) { + if (diff.sectionsWidget) { + renderSectionsWidget(); + } + if (diff.bandwidthWidget) { + renderBandwidthWidget(); + } + if (diff.trafficTotalWidget) { + renderTrafficTotalWidget(); + } + if (diff.systemInfoWidget) { + renderSystemInfoWidget(); + } + if (diff.servicesInfoWidget) { + renderServicesInfoWidget(); + } +} +async function onPageMount() { + onPageUnmount(); + store.subscribe(onStoreUpdate); + await fetchDashboardSections(); + await fetchServicesInfo(); + await connectToClashSockets(); +} +function onPageUnmount() { + store.unsubscribe(onStoreUpdate); + store.reset([ + "bandwidthWidget", + "trafficTotalWidget", + "systemInfoWidget", + "servicesInfoWidget", + "sectionsWidget" + ]); + socket.resetAll(); +} +function registerLifecycleListeners() { + store.subscribe((next, prev, diff) => { + if (diff.tabService && next.tabService.current !== prev.tabService.current) { + logger.debug( + "[DASHBOARD]", + "active tab diff event, active tab:", + diff.tabService.current + ); + const isDashboardVisible = next.tabService.current === "dashboard"; + if (isDashboardVisible) { + logger.debug( + "[DASHBOARD]", + "registerLifecycleListeners", + "onPageMount" + ); + return onPageMount(); + } + if (!isDashboardVisible) { + logger.debug( + "[DASHBOARD]", + "registerLifecycleListeners", + "onPageUnmount" + ); + return onPageUnmount(); + } + } + }); +} +async function initController() { + onMount("dashboard-status").then(() => { + logger.debug("[DASHBOARD]", "initController", "onMount"); + onPageMount(); + registerLifecycleListeners(); + }); +} + +// src/podkop/tabs/dashboard/styles.ts +var styles = ` +#cbi-podkop-dashboard-_mount_node > div { + width: 100%; +} + +#cbi-podkop-dashboard > h3 { + display: none; +} + +.pdk_dashboard-page { + width: 100%; + --dashboard-grid-columns: 4; +} + +@media (max-width: 900px) { + .pdk_dashboard-page { + --dashboard-grid-columns: 2; + } +} + +.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, lightgray) solid; + border-radius: 4px; + 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, green); +} + +.pdk_dashboard-page__widgets-section__item__row--error .pdk_dashboard-page__widgets-section__item__row__value { + color: var(--error-color-medium, red); +} + +.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, lightgray) 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 { + border: 2px var(--background-color-low, lightgray) solid; + border-radius: 4px; + padding: 10px; + transition: border 0.2s ease; +} + +.pdk_dashboard-page__outbound-grid__item--selectable { + cursor: pointer; +} + +.pdk_dashboard-page__outbound-grid__item--selectable:hover { + border-color: var(--primary-color-high, dodgerblue); +} + +.pdk_dashboard-page__outbound-grid__item--active { + border-color: var(--success-color-medium, green); +} + +.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--empty { + color: var(--primary-color-low, lightgray); +} + +.pdk_dashboard-page__outbound-grid__item__latency--green { + color: var(--success-color-medium, green); +} + +.pdk_dashboard-page__outbound-grid__item__latency--yellow { + color: var(--warn-color-medium, orange); +} + +.pdk_dashboard-page__outbound-grid__item__latency--red { + color: var(--error-color-medium, red); +} + +`; + +// src/podkop/tabs/dashboard/index.ts +var DashboardTab = { + render, + initController, + styles +}; + +// src/podkop/tabs/diagnostic/renderDiagnostic.ts +function render2() { + return E("div", { id: "diagnostic-status", class: "pdk_diagnostic-page" }, [ + E("div", { class: "pdk_diagnostic-page__left-bar" }, [ + E("div", { id: "pdk_diagnostic-page-run-check" }), + E("div", { + class: "pdk_diagnostic-page__checks", + id: "pdk_diagnostic-page-checks" + }) + ]), + E("div", { class: "pdk_diagnostic-page__right-bar" }, [ + E("div", { id: "pdk_diagnostic-page-wiki" }), + E("div", { id: "pdk_diagnostic-page-actions" }), + E("div", { id: "pdk_diagnostic-page-system-info" }) + ]) + ]); +} + +// src/podkop/tabs/diagnostic/checks/updateCheckStore.ts +function updateCheckStore(check, minified) { + const diagnosticsChecks = store.get().diagnosticsChecks; + const other = diagnosticsChecks.filter((item) => item.code !== check.code); + const smallCheck = { + ...check, + items: check.items.filter((item) => item.state !== "success") + }; + const targetCheck = minified ? smallCheck : check; + store.set({ + diagnosticsChecks: [...other, targetCheck] + }); +} + +// src/podkop/tabs/diagnostic/helpers/getMeta.ts +function getMeta({ allGood, atLeastOneGood }) { + if (allGood) { + return { + state: "success", + description: _("Checks passed") + }; + } + if (atLeastOneGood) { + return { + state: "warning", + description: _("Issues detected") + }; + } + return { + state: "error", + description: _("Checks failed") + }; +} + +// src/podkop/tabs/diagnostic/checks/runDnsCheck.ts +async function runDnsCheck() { + const { order, title, code } = DIAGNOSTICS_CHECKS_MAP.DNS; + updateCheckStore({ + order, + code, + title, + description: _("Checking, please wait"), + state: "loading", + items: [] + }); + const dnsChecks = await PodkopShellMethods.checkDNSAvailable(); + if (!dnsChecks.success) { + updateCheckStore({ + order, + code, + title, + description: _("Cannot receive checks result"), + state: "error", + items: [] + }); + throw new Error("DNS checks failed"); + } + const data = dnsChecks.data; + const allGood = Boolean(data.dns_on_router) && Boolean(data.dhcp_config_status) && Boolean(data.bootstrap_dns_status) && Boolean(data.dns_status); + const atLeastOneGood = Boolean(data.dns_on_router) || Boolean(data.dhcp_config_status) || Boolean(data.bootstrap_dns_status) || Boolean(data.dns_status); + const { state, description } = getMeta({ atLeastOneGood, allGood }); + updateCheckStore({ + order, + code, + title, + description, + state, + items: [ + ...insertIf( + data.dns_type === "doh" || data.dns_type === "dot" || !data.bootstrap_dns_status, + [ + { + state: data.bootstrap_dns_status ? "success" : "error", + key: _("Bootsrap DNS"), + value: data.bootstrap_dns_server + } + ] + ), + { + state: data.dns_status ? "success" : "error", + key: _("Main DNS"), + value: `${data.dns_server} [${data.dns_type}]` + }, + { + state: data.dns_on_router ? "success" : "error", + key: _("DNS on router"), + value: "" + }, + { + state: data.dhcp_config_status ? "success" : "error", + key: _("DHCP has DNS server"), + value: "" + } + ] + }); + if (!atLeastOneGood) { + throw new Error("DNS checks failed"); + } +} + +// src/podkop/tabs/diagnostic/checks/runSingBoxCheck.ts +async function runSingBoxCheck() { + const { order, title, code } = DIAGNOSTICS_CHECKS_MAP.SINGBOX; + updateCheckStore({ + order, + code, + title, + description: _("Checking, please wait"), + state: "loading", + items: [] + }); + const singBoxChecks = await PodkopShellMethods.checkSingBox(); + if (!singBoxChecks.success) { + updateCheckStore({ + order, + code, + title, + description: _("Cannot receive checks result"), + state: "error", + items: [] + }); + throw new Error("Sing-box checks failed"); + } + const data = singBoxChecks.data; + const allGood = Boolean(data.sing_box_installed) && Boolean(data.sing_box_version_ok) && Boolean(data.sing_box_service_exist) && Boolean(data.sing_box_autostart_disabled) && Boolean(data.sing_box_process_running) && Boolean(data.sing_box_ports_listening); + const atLeastOneGood = Boolean(data.sing_box_installed) || Boolean(data.sing_box_version_ok) || Boolean(data.sing_box_service_exist) || Boolean(data.sing_box_autostart_disabled) || Boolean(data.sing_box_process_running) || Boolean(data.sing_box_ports_listening); + const { state, description } = getMeta({ atLeastOneGood, allGood }); + updateCheckStore({ + order, + code, + title, + description, + state, + items: [ + { + state: data.sing_box_installed ? "success" : "error", + key: _("Sing-box installed"), + value: "" + }, + { + state: data.sing_box_version_ok ? "success" : "error", + key: _("Sing-box version is compatible (newer than 1.12.4)"), + value: "" + }, + { + state: data.sing_box_service_exist ? "success" : "error", + key: _("Sing-box service exist"), + value: "" + }, + { + state: data.sing_box_autostart_disabled ? "success" : "error", + key: _("Sing-box autostart disabled"), + value: "" + }, + { + state: data.sing_box_process_running ? "success" : "error", + key: _("Sing-box process running"), + value: "" + }, + { + state: data.sing_box_ports_listening ? "success" : "error", + key: _("Sing-box listening ports"), + value: "" + } + ] + }); + if (!atLeastOneGood || !data.sing_box_process_running) { + throw new Error("Sing-box checks failed"); + } +} + +// src/podkop/tabs/diagnostic/checks/runNftCheck.ts +async function runNftCheck() { + const { order, title, code } = DIAGNOSTICS_CHECKS_MAP.NFT; + updateCheckStore({ + order, + code, + title, + description: _("Checking, please wait"), + state: "loading", + items: [] + }); + await RemoteFakeIPMethods.getFakeIpCheck(); + await RemoteFakeIPMethods.getIpCheck(); + const nftablesChecks = await PodkopShellMethods.checkNftRules(); + if (!nftablesChecks.success) { + updateCheckStore({ + order, + code, + title, + description: _("Cannot receive checks result"), + state: "error", + items: [] + }); + throw new Error("Nftables checks failed"); + } + const data = nftablesChecks.data; + const allGood = Boolean(data.table_exist) && Boolean(data.rules_mangle_exist) && Boolean(data.rules_mangle_counters) && Boolean(data.rules_mangle_output_exist) && Boolean(data.rules_mangle_output_counters) && Boolean(data.rules_proxy_exist) && Boolean(data.rules_proxy_counters) && !data.rules_other_mark_exist; + const atLeastOneGood = Boolean(data.table_exist) || Boolean(data.rules_mangle_exist) || Boolean(data.rules_mangle_counters) || Boolean(data.rules_mangle_output_exist) || Boolean(data.rules_mangle_output_counters) || Boolean(data.rules_proxy_exist) || Boolean(data.rules_proxy_counters) || !data.rules_other_mark_exist; + const { state, description } = getMeta({ atLeastOneGood, allGood }); + updateCheckStore({ + order, + code, + title, + description, + state, + items: [ + { + state: data.table_exist ? "success" : "error", + key: _("Table exist"), + value: "" + }, + { + state: data.rules_mangle_exist ? "success" : "error", + key: _("Rules mangle exist"), + value: "" + }, + { + state: data.rules_mangle_counters ? "success" : "error", + key: _("Rules mangle counters"), + value: "" + }, + { + state: data.rules_mangle_output_exist ? "success" : "error", + key: _("Rules mangle output exist"), + value: "" + }, + { + state: data.rules_mangle_output_counters ? "success" : "error", + key: _("Rules mangle output counters"), + value: "" + }, + { + state: data.rules_proxy_exist ? "success" : "error", + key: _("Rules proxy exist"), + value: "" + }, + { + state: data.rules_proxy_counters ? "success" : "error", + key: _("Rules proxy counters"), + value: "" + }, + { + state: !data.rules_other_mark_exist ? "success" : "warning", + key: !data.rules_other_mark_exist ? _("No other marking rules found") : _("Additional marking rules found"), + value: "" + } + ] + }); + if (!atLeastOneGood) { + throw new Error("Nftables checks failed"); + } +} + +// src/podkop/tabs/diagnostic/checks/runFakeIPCheck.ts +async function runFakeIPCheck() { + const { order, title, code } = DIAGNOSTICS_CHECKS_MAP.FAKEIP; + updateCheckStore({ + order, + code, + title, + description: _("Checking, please wait"), + state: "loading", + items: [] + }); + const routerFakeIPResponse = await PodkopShellMethods.checkFakeIP(); + const checkFakeIPResponse = await RemoteFakeIPMethods.getFakeIpCheck(); + const checkIPResponse = await RemoteFakeIPMethods.getIpCheck(); + const checks = { + router: routerFakeIPResponse.success && routerFakeIPResponse.data.fakeip, + browserFakeIP: checkFakeIPResponse.success && checkFakeIPResponse.data.fakeip, + differentIP: checkFakeIPResponse.success && checkIPResponse.success && checkFakeIPResponse.data.IP !== checkIPResponse.data.IP + }; + const allGood = checks.router || checks.browserFakeIP || checks.differentIP; + const atLeastOneGood = checks.router && checks.browserFakeIP && checks.differentIP; + const { state, description } = getMeta({ atLeastOneGood, allGood }); + updateCheckStore({ + order, + code, + title, + description, + state, + items: [ + { + state: checks.router ? "success" : "warning", + key: checks.router ? _("Router DNS is routed through sing-box") : _("Router DNS is not routed through sing-box"), + value: "" + }, + { + state: checks.browserFakeIP ? "success" : "error", + key: checks.browserFakeIP ? _("Browser is using FakeIP correctly") : _("Browser is not using FakeIP"), + value: "" + }, + ...insertIf(checks.browserFakeIP, [ + { + state: checks.differentIP ? "success" : "error", + key: checks.differentIP ? _("Proxy traffic is routed via FakeIP") : _("Proxy traffic is not routed via FakeIP"), + value: "" + } + ]) + ] + }); +} + +// src/partials/button/styles.ts +var styles2 = ` +.pdk-partial-button { + text-align: center; +} + +.pdk-partial-button--with-icon { + display: flex; + align-items: center; + justify-content: center; +} + +.pdk-partial-button--loading { +} + +.pdk-partial-button--disabled { +} + +.pdk-partial-button__icon { + margin-right: 5px; +} + +.pdk-partial-button__icon { + display: flex; + align-items: center; + justify-content: center; +} + +.pdk-partial-button__icon svg { + width: 16px; + height: 16px; +} +`; + +// src/partials/modal/styles.ts +var styles3 = ` + +.pdk-partial-modal__body {} + +.pdk-partial-modal__content { + max-height: 70vh; + overflow: scroll; + border-radius: 4px; +} + +.pdk-partial-modal__footer { + display: flex; + justify-content: flex-end; +} + +.pdk-partial-modal__footer button { + margin-left: 10px; +} +`; + +// src/icons/renderLoaderCircleIcon24.ts +function renderLoaderCircleIcon24() { + const NS = "http://www.w3.org/2000/svg"; + return svgEl( + "svg", + { + xmlns: NS, + viewBox: "0 0 24 24", + fill: "none", + stroke: "currentColor", + "stroke-width": "2", + "stroke-linecap": "round", + "stroke-linejoin": "round", + class: "lucide lucide-loader-circle rotate" + }, + [ + svgEl("path", { + d: "M21 12a9 9 0 1 1-6.219-8.56" + }), + svgEl("animateTransform", { + attributeName: "transform", + attributeType: "XML", + type: "rotate", + from: "0 12 12", + to: "360 12 12", + dur: "1s", + repeatCount: "indefinite" + }) + ] + ); +} + +// src/icons/renderCircleAlertIcon24.ts +function renderCircleAlertIcon24() { + const NS = "http://www.w3.org/2000/svg"; + return svgEl( + "svg", + { + xmlns: NS, + width: "24", + height: "24", + viewBox: "0 0 24 24", + fill: "none", + stroke: "currentColor", + "stroke-width": "2", + "stroke-linecap": "round", + "stroke-linejoin": "round", + class: "lucide lucide-circle-alert-icon lucide-circle-alert" + }, + [ + svgEl("circle", { + cx: "12", + cy: "12", + r: "10" + }), + svgEl("line", { + x1: "12", + y1: "8", + x2: "12", + y2: "12" + }), + svgEl("line", { + x1: "12", + y1: "16", + x2: "12.01", + y2: "16" + }) + ] + ); +} + +// src/icons/renderCircleCheckIcon24.ts +function renderCircleCheckIcon24() { + const NS = "http://www.w3.org/2000/svg"; + return svgEl( + "svg", + { + xmlns: NS, + width: "24", + height: "24", + viewBox: "0 0 24 24", + fill: "none", + stroke: "currentColor", + "stroke-width": "2", + "stroke-linecap": "round", + "stroke-linejoin": "round", + class: "lucide lucide-circle-check-icon lucide-circle-check" + }, + [ + svgEl("circle", { + cx: "12", + cy: "12", + r: "10" + }), + svgEl("path", { + d: "M9 12l2 2 4-4" + }) + ] + ); +} + +// src/icons/renderCircleSlashIcon24.ts +function renderCircleSlashIcon24() { + const NS = "http://www.w3.org/2000/svg"; + return svgEl( + "svg", + { + xmlns: NS, + width: "24", + height: "24", + viewBox: "0 0 24 24", + fill: "none", + stroke: "currentColor", + "stroke-width": "2", + "stroke-linecap": "round", + "stroke-linejoin": "round", + class: "lucide lucide-circle-slash-icon lucide-circle-slash" + }, + [ + svgEl("circle", { + cx: "12", + cy: "12", + r: "10" + }), + svgEl("line", { + x1: "9", + y1: "15", + x2: "15", + y2: "9" + }) + ] + ); +} + +// src/icons/renderCircleXIcon24.ts +function renderCircleXIcon24() { + const NS = "http://www.w3.org/2000/svg"; + return svgEl( + "svg", + { + xmlns: NS, + width: "24", + height: "24", + viewBox: "0 0 24 24", + fill: "none", + stroke: "currentColor", + "stroke-width": "2", + "stroke-linecap": "round", + "stroke-linejoin": "round", + class: "lucide lucide-circle-x-icon lucide-circle-x" + }, + [ + svgEl("circle", { + cx: "12", + cy: "12", + r: "10" + }), + svgEl("path", { + d: "M15 9L9 15" + }), + svgEl("path", { + d: "M9 9L15 15" + }) + ] + ); +} + +// src/icons/renderCheckIcon24.ts +function renderCheckIcon24() { + const NS = "http://www.w3.org/2000/svg"; + return svgEl( + "svg", + { + xmlns: NS, + viewBox: "0 0 24 24", + fill: "none", + stroke: "currentColor", + "stroke-width": "2", + "stroke-linecap": "round", + "stroke-linejoin": "round", + class: "lucide lucide-check-icon lucide-check" + }, + [ + svgEl("path", { + d: "M20 6 9 17l-5-5" + }) + ] + ); +} + +// src/icons/renderXIcon24.ts +function renderXIcon24() { + const NS = "http://www.w3.org/2000/svg"; + return svgEl( + "svg", + { + xmlns: NS, + viewBox: "0 0 24 24", + fill: "none", + stroke: "currentColor", + "stroke-width": "2", + "stroke-linecap": "round", + "stroke-linejoin": "round", + class: "lucide lucide-x-icon lucide-x" + }, + [svgEl("path", { d: "M18 6 6 18" }), svgEl("path", { d: "m6 6 12 12" })] + ); +} + +// src/icons/renderTriangleAlertIcon24.ts +function renderTriangleAlertIcon24() { + const NS = "http://www.w3.org/2000/svg"; + return svgEl( + "svg", + { + xmlns: NS, + viewBox: "0 0 24 24", + fill: "none", + stroke: "currentColor", + "stroke-width": "2", + "stroke-linecap": "round", + "stroke-linejoin": "round", + class: "lucide lucide-triangle-alert-icon lucide-triangle-alert" + }, + [ + svgEl("path", { + d: "m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3" + }), + svgEl("path", { d: "M12 9v4" }), + svgEl("path", { d: "M12 17h.01" }) + ] + ); +} + +// src/icons/renderPauseIcon24.ts +function renderPauseIcon24() { + const NS = "http://www.w3.org/2000/svg"; + return svgEl( + "svg", + { + xmlns: NS, + viewBox: "0 0 24 24", + fill: "none", + stroke: "currentColor", + "stroke-width": "2", + "stroke-linecap": "round", + "stroke-linejoin": "round", + class: "lucide lucide-pause-icon lucide-pause" + }, + [ + svgEl("rect", { + x: "14", + y: "3", + width: "5", + height: "18", + rx: "1" + }), + svgEl("rect", { + x: "5", + y: "3", + width: "5", + height: "18", + rx: "1" + }) + ] + ); +} + +// src/icons/renderPlayIcon24.ts +function renderPlayIcon24() { + const NS = "http://www.w3.org/2000/svg"; + return svgEl( + "svg", + { + xmlns: NS, + viewBox: "0 0 24 24", + fill: "none", + stroke: "currentColor", + "stroke-width": "2", + "stroke-linecap": "round", + "stroke-linejoin": "round", + class: "lucide lucide-play-icon lucide-play" + }, + [ + svgEl("path", { + d: "M5 5a2 2 0 0 1 3.008-1.728l11.997 6.998a2 2 0 0 1 .003 3.458l-12 7A2 2 0 0 1 5 19z" + }) + ] + ); +} + +// src/icons/renderRotateCcwIcon24.ts +function renderRotateCcwIcon24() { + const NS = "http://www.w3.org/2000/svg"; + return svgEl( + "svg", + { + xmlns: NS, + viewBox: "0 0 24 24", + fill: "none", + stroke: "currentColor", + "stroke-width": "2", + "stroke-linecap": "round", + "stroke-linejoin": "round", + class: "lucide lucide-rotate-ccw-icon lucide-rotate-ccw" + }, + [ + svgEl("path", { + d: "M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8" + }), + svgEl("path", { + d: "M3 3v5h5" + }) + ] + ); +} + +// src/icons/renderCircleStopIcon24.ts +function renderCircleStopIcon24() { + const NS = "http://www.w3.org/2000/svg"; + return svgEl( + "svg", + { + xmlns: NS, + viewBox: "0 0 24 24", + fill: "none", + stroke: "currentColor", + "stroke-width": "2", + "stroke-linecap": "round", + "stroke-linejoin": "round", + class: "lucide lucide-circle-stop-icon lucide-circle-stop" + }, + [ + svgEl("circle", { + cx: "12", + cy: "12", + r: "10" + }), + svgEl("rect", { + x: "9", + y: "9", + width: "6", + height: "6", + rx: "1" + }) + ] + ); +} + +// src/icons/renderCirclePlayIcon24.ts +function renderCirclePlayIcon24() { + const NS = "http://www.w3.org/2000/svg"; + return svgEl( + "svg", + { + xmlns: NS, + viewBox: "0 0 24 24", + fill: "none", + stroke: "currentColor", + "stroke-width": "2", + "stroke-linecap": "round", + "stroke-linejoin": "round", + class: "lucide lucide-circle-play-icon lucide-circle-play" + }, + [ + svgEl("path", { + d: "M9 9.003a1 1 0 0 1 1.517-.859l4.997 2.997a1 1 0 0 1 0 1.718l-4.997 2.997A1 1 0 0 1 9 14.996z" + }), + svgEl("circle", { + cx: "12", + cy: "12", + r: "10" + }) + ] + ); +} + +// src/icons/renderCircleCheckBigIcon24.ts +function renderCircleCheckBigIcon24() { + const NS = "http://www.w3.org/2000/svg"; + return svgEl( + "svg", + { + xmlns: NS, + viewBox: "0 0 24 24", + fill: "none", + stroke: "currentColor", + "stroke-width": "2", + "stroke-linecap": "round", + "stroke-linejoin": "round", + class: "lucide lucide-circle-check-big-icon lucide-circle-check-big" + }, + [ + svgEl("path", { + d: "M21.801 10A10 10 0 1 1 17 3.335" + }), + svgEl("path", { + d: "m9 11 3 3L22 4" + }) + ] + ); +} + +// src/icons/renderSquareChartGanttIcon24.ts +function renderSquareChartGanttIcon24() { + const NS = "http://www.w3.org/2000/svg"; + return svgEl( + "svg", + { + xmlns: NS, + viewBox: "0 0 24 24", + fill: "none", + stroke: "currentColor", + "stroke-width": "2", + "stroke-linecap": "round", + "stroke-linejoin": "round", + class: "lucide lucide-square-chart-gantt-icon lucide-square-chart-gantt" + }, + [ + svgEl("rect", { + width: "18", + height: "18", + x: "3", + y: "3", + rx: "2" + }), + svgEl("path", { d: "M9 8h7" }), + svgEl("path", { d: "M8 12h6" }), + svgEl("path", { d: "M11 16h5" }) + ] + ); +} + +// src/icons/renderCogIcon24.ts +function renderCogIcon24() { + const NS = "http://www.w3.org/2000/svg"; + return svgEl( + "svg", + { + xmlns: NS, + viewBox: "0 0 24 24", + fill: "none", + stroke: "currentColor", + "stroke-width": "2", + "stroke-linecap": "round", + "stroke-linejoin": "round", + class: "lucide lucide-cog-icon lucide-cog" + }, + [ + svgEl("path", { d: "M11 10.27 7 3.34" }), + svgEl("path", { d: "m11 13.73-4 6.93" }), + svgEl("path", { d: "M12 22v-2" }), + svgEl("path", { d: "M12 2v2" }), + svgEl("path", { d: "M14 12h8" }), + svgEl("path", { d: "m17 20.66-1-1.73" }), + svgEl("path", { d: "m17 3.34-1 1.73" }), + svgEl("path", { d: "M2 12h2" }), + svgEl("path", { d: "m20.66 17-1.73-1" }), + svgEl("path", { d: "m20.66 7-1.73 1" }), + svgEl("path", { d: "m3.34 17 1.73-1" }), + svgEl("path", { d: "m3.34 7 1.73 1" }), + svgEl("circle", { cx: "12", cy: "12", r: "2" }), + svgEl("circle", { cx: "12", cy: "12", r: "8" }) + ] + ); +} + +// src/icons/renderSearchIcon24.ts +function renderSearchIcon24() { + const NS = "http://www.w3.org/2000/svg"; + return svgEl( + "svg", + { + xmlns: NS, + viewBox: "0 0 24 24", + fill: "none", + stroke: "currentColor", + "stroke-width": "2", + "stroke-linecap": "round", + "stroke-linejoin": "round", + class: "lucide lucide-search-icon lucide-search" + }, + [ + svgEl("path", { d: "m21 21-4.34-4.34" }), + svgEl("circle", { cx: "11", cy: "11", r: "8" }) + ] + ); +} + +// src/icons/renderBookOpenTextIcon24.ts +function renderBookOpenTextIcon24() { + const NS = "http://www.w3.org/2000/svg"; + return svgEl( + "svg", + { + xmlns: NS, + viewBox: "0 0 24 24", + fill: "none", + stroke: "currentColor", + "stroke-width": "2", + "stroke-linecap": "round", + "stroke-linejoin": "round", + class: "lucide lucide-book-open-text-icon lucide-book-open-text" + }, + [ + svgEl("path", { d: "M12 7v14" }), + svgEl("path", { d: "M16 12h2" }), + svgEl("path", { d: "M16 8h2" }), + svgEl("path", { + d: "M3 18a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1h5a4 4 0 0 1 4 4 4 4 0 0 1 4-4h5a1 1 0 0 1 1 1v13a1 1 0 0 1-1 1h-6a3 3 0 0 0-3 3 3 3 0 0 0-3-3z" + }), + svgEl("path", { d: "M6 12h2" }), + svgEl("path", { d: "M6 8h2" }) + ] + ); +} + +// src/partials/button/renderButton.ts +function renderButton({ + classNames = [], + disabled, + loading, + onClick, + text, + icon +}) { + const hasIcon = !!loading || !!icon; + function getWrappedIcon() { + const iconWrap = E("span", { + class: "pdk-partial-button__icon" + }); + if (loading) { + iconWrap.appendChild(renderLoaderCircleIcon24()); + return iconWrap; + } + if (icon) { + iconWrap.appendChild(icon()); + return iconWrap; + } + return iconWrap; + } + function getClass() { + return [ + "btn", + "pdk-partial-button", + ...insertIf(Boolean(disabled), ["pdk-partial-button--disabled"]), + ...insertIf(Boolean(loading), ["pdk-partial-button--loading"]), + ...insertIf(Boolean(hasIcon), ["pdk-partial-button--with-icon"]), + ...classNames + ].filter(Boolean).join(" "); + } + function getDisabled() { + if (loading || disabled) { + return true; + } + return void 0; + } + return E( + "button", + { class: getClass(), disabled: getDisabled(), click: onClick }, + [...insertIf(hasIcon, [getWrappedIcon()]), E("span", {}, text)] + ); +} + +// src/helpers/showToast.ts +function showToast(message, type, duration = 3e3) { + let container = document.querySelector(".toast-container"); + if (!container) { + container = document.createElement("div"); + container.className = "toast-container"; + document.body.appendChild(container); + } + const toast = document.createElement("div"); + toast.className = `toast toast-${type}`; + toast.textContent = message; + container.appendChild(toast); + setTimeout(() => toast.classList.add("visible"), 100); + setTimeout(() => { + toast.classList.remove("visible"); + setTimeout(() => toast.remove(), 300); + }, duration); +} + +// 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"); + showToast(_("Successfully copied!"), "success"); + } catch (_err) { + showToast(_("Failed to copy!"), "error"); + console.error("copyToClipboard - e", _err); + } + document.body.removeChild(textarea); +} + +// src/partials/modal/renderModal.ts +function renderModal(text, name) { + return E( + "div", + { class: "pdk-partial-modal__body" }, + E("div", {}, [ + E("pre", { class: "pdk-partial-modal__content" }, E("code", {}, text)), + E("div", { class: "pdk-partial-modal__footer" }, [ + renderButton({ + classNames: ["cbi-button-apply"], + text: _("Download"), + onClick: () => downloadAsTxt(text, name) + }), + renderButton({ + classNames: ["cbi-button-apply"], + text: _("Copy"), + onClick: () => copyToClipboard(` \`\`\`${name} + ${text} + \`\`\``) + }), + renderButton({ + classNames: ["cbi-button-remove"], + text: _("Close"), + onClick: ui.hideModal + }) + ]) + ]) + ); +} + +// src/partials/index.ts +var PartialStyles = ` +${styles2} +${styles3} +`; + +// src/podkop/tabs/diagnostic/partials/renderAvailableActions.ts +function renderAvailableActions({ + restart, + start, + stop, + enable, + disable, + globalCheck, + viewLogs, + showSingBoxConfig +}) { + return E("div", { class: "pdk_diagnostic-page__right-bar__actions" }, [ + E("b", {}, _("Available actions")), + ...insertIf(restart.visible, [ + renderButton({ + classNames: ["cbi-button-apply"], + onClick: restart.onClick, + icon: renderRotateCcwIcon24, + text: _("Restart podkop"), + loading: restart.loading, + disabled: restart.disabled + }) + ]), + ...insertIf(stop.visible, [ + renderButton({ + classNames: ["cbi-button-remove"], + onClick: stop.onClick, + icon: renderCircleStopIcon24, + text: _("Stop podkop"), + loading: stop.loading, + disabled: stop.disabled + }) + ]), + ...insertIf(start.visible, [ + renderButton({ + classNames: ["cbi-button-save"], + onClick: start.onClick, + icon: renderCirclePlayIcon24, + text: _("Start podkop"), + loading: start.loading, + disabled: start.disabled + }) + ]), + ...insertIf(disable.visible, [ + renderButton({ + classNames: ["cbi-button-remove"], + onClick: disable.onClick, + icon: renderPauseIcon24, + text: _("Disable autostart"), + loading: disable.loading, + disabled: disable.disabled + }) + ]), + ...insertIf(enable.visible, [ + renderButton({ + classNames: ["cbi-button-save"], + onClick: enable.onClick, + icon: renderPlayIcon24, + text: _("Enable autostart"), + loading: enable.loading, + disabled: enable.disabled + }) + ]), + ...insertIf(globalCheck.visible, [ + renderButton({ + onClick: globalCheck.onClick, + icon: renderCircleCheckBigIcon24, + text: _("Get global check"), + loading: globalCheck.loading, + disabled: globalCheck.disabled + }) + ]), + ...insertIf(viewLogs.visible, [ + renderButton({ + onClick: viewLogs.onClick, + icon: renderSquareChartGanttIcon24, + text: _("View logs"), + loading: viewLogs.loading, + disabled: viewLogs.disabled + }) + ]), + ...insertIf(showSingBoxConfig.visible, [ + renderButton({ + onClick: showSingBoxConfig.onClick, + icon: renderCogIcon24, + text: _("Show sing-box config"), + loading: showSingBoxConfig.loading, + disabled: showSingBoxConfig.disabled + }) + ]) + ]); +} + +// src/podkop/tabs/diagnostic/partials/renderCheckSection.ts +function renderCheckSummary(items) { + if (!items.length) { + return E("div", {}, ""); + } + const renderedItems = items.map((item) => { + function getIcon() { + const iconWrap = E("span", { + class: "pdk_diagnostic_alert__summary__item__icon" + }); + if (item.state === "success") { + iconWrap.appendChild(renderCheckIcon24()); + } + if (item.state === "warning") { + iconWrap.appendChild(renderTriangleAlertIcon24()); + } + if (item.state === "error") { + iconWrap.appendChild(renderXIcon24()); + } + return iconWrap; + } + return E( + "div", + { + class: `pdk_diagnostic_alert__summary__item pdk_diagnostic_alert__summary__item--${item.state}` + }, + [getIcon(), E("b", {}, item.key), E("div", {}, item.value)] + ); + }); + return E("div", { class: "pdk_diagnostic_alert__summary" }, renderedItems); +} +function renderLoadingState3(props) { + const iconWrap = E("span", { class: "pdk_diagnostic_alert__icon" }); + iconWrap.appendChild(renderLoaderCircleIcon24()); + return E( + "div", + { class: "pdk_diagnostic_alert pdk_diagnostic_alert--loading" }, + [ + iconWrap, + E("div", { class: "pdk_diagnostic_alert__content" }, [ + E("b", { class: "pdk_diagnostic_alert__title" }, props.title), + E( + "div", + { class: "pdk_diagnostic_alert__description" }, + props.description + ) + ]), + E("div", {}, ""), + renderCheckSummary(props.items) + ] + ); +} +function renderWarningState(props) { + const iconWrap = E("span", { class: "pdk_diagnostic_alert__icon" }); + iconWrap.appendChild(renderCircleAlertIcon24()); + return E( + "div", + { class: "pdk_diagnostic_alert pdk_diagnostic_alert--warning" }, + [ + iconWrap, + E("div", { class: "pdk_diagnostic_alert__content" }, [ + E("b", { class: "pdk_diagnostic_alert__title" }, props.title), + E( + "div", + { class: "pdk_diagnostic_alert__description" }, + props.description + ) + ]), + E("div", {}, ""), + renderCheckSummary(props.items) + ] + ); +} +function renderErrorState(props) { + const iconWrap = E("span", { class: "pdk_diagnostic_alert__icon" }); + iconWrap.appendChild(renderCircleXIcon24()); + return E( + "div", + { class: "pdk_diagnostic_alert pdk_diagnostic_alert--error" }, + [ + iconWrap, + E("div", { class: "pdk_diagnostic_alert__content" }, [ + E("b", { class: "pdk_diagnostic_alert__title" }, props.title), + E( + "div", + { class: "pdk_diagnostic_alert__description" }, + props.description + ) + ]), + E("div", {}, ""), + renderCheckSummary(props.items) + ] + ); +} +function renderSuccessState(props) { + const iconWrap = E("span", { class: "pdk_diagnostic_alert__icon" }); + iconWrap.appendChild(renderCircleCheckIcon24()); + return E( + "div", + { class: "pdk_diagnostic_alert pdk_diagnostic_alert--success" }, + [ + iconWrap, + E("div", { class: "pdk_diagnostic_alert__content" }, [ + E("b", { class: "pdk_diagnostic_alert__title" }, props.title), + E( + "div", + { class: "pdk_diagnostic_alert__description" }, + props.description + ) + ]), + E("div", {}, ""), + renderCheckSummary(props.items) + ] + ); +} +function renderSkippedState(props) { + const iconWrap = E("span", { class: "pdk_diagnostic_alert__icon" }); + iconWrap.appendChild(renderCircleSlashIcon24()); + return E( + "div", + { class: "pdk_diagnostic_alert pdk_diagnostic_alert--skipped" }, + [ + iconWrap, + E("div", { class: "pdk_diagnostic_alert__content" }, [ + E("b", { class: "pdk_diagnostic_alert__title" }, props.title), + E( + "div", + { class: "pdk_diagnostic_alert__description" }, + props.description + ) + ]), + E("div", {}, ""), + renderCheckSummary(props.items) + ] + ); +} +function renderCheckSection(props) { + if (props.state === "loading") { + return renderLoadingState3(props); + } + if (props.state === "warning") { + return renderWarningState(props); + } + if (props.state === "error") { + return renderErrorState(props); + } + if (props.state === "success") { + return renderSuccessState(props); + } + if (props.state === "skipped") { + return renderSkippedState(props); + } + return E("div", {}, _("Not implement yet")); +} + +// src/podkop/tabs/diagnostic/partials/renderRunAction.ts +function renderRunAction({ + loading, + click +}) { + return E("div", { class: "pdk_diagnostic-page__run_check_wrapper" }, [ + renderButton({ + text: _("Run Diagnostic"), + onClick: click, + icon: renderSearchIcon24, + loading, + classNames: ["cbi-button-apply"] + }) + ]); +} + +// src/podkop/tabs/diagnostic/partials/renderSystemInfo.ts +function renderSystemInfo({ items }) { + return E("div", { class: "pdk_diagnostic-page__right-bar__system-info" }, [ + E( + "b", + { class: "pdk_diagnostic-page__right-bar__system-info__title" }, + _("System information") + ), + ...items.map((item) => { + const tagClass = [ + "pdk_diagnostic-page__right-bar__system-info__row__tag", + ...insertIf(item.tag?.kind === "warning", [ + "pdk_diagnostic-page__right-bar__system-info__row__tag--warning" + ]), + ...insertIf(item.tag?.kind === "success", [ + "pdk_diagnostic-page__right-bar__system-info__row__tag--success" + ]) + ].filter(Boolean).join(" "); + return E( + "div", + { class: "pdk_diagnostic-page__right-bar__system-info__row" }, + [ + E("b", {}, item.key), + E("div", {}, [ + E("span", {}, item.value), + E("span", { class: tagClass }, item?.tag?.label) + ]) + ] + ); + }) + ]); +} + +// src/helpers/normalizeCompiledVersion.ts +function normalizeCompiledVersion(version) { + if (version.includes("COMPILED")) { + return "dev"; + } + return version; +} + +// src/podkop/tabs/diagnostic/partials/renderWikiDisclaimer.ts +function renderWikiDisclaimer(kind) { + const iconWrap = E("span", { + class: "pdk_diagnostic-page__right-bar__wiki__icon" + }); + iconWrap.appendChild(renderBookOpenTextIcon24()); + const className = [ + "pdk_diagnostic-page__right-bar__wiki", + ...insertIf(kind === "error", [ + "pdk_diagnostic-page__right-bar__wiki--error" + ]), + ...insertIf(kind === "warning", [ + "pdk_diagnostic-page__right-bar__wiki--warning" + ]) + ].join(" "); + return E("div", { class: className }, [ + E("div", { class: "pdk_diagnostic-page__right-bar__wiki__content" }, [ + iconWrap, + E("div", { class: "pdk_diagnostic-page__right-bar__wiki__texts" }, [ + E("b", {}, _("Troubleshooting")), + E("div", {}, _("Do not panic, everything can be fixed, just...")) + ]) + ]), + renderButton({ + classNames: ["cbi-button-save"], + text: _("Visit Wiki"), + onClick: () => window.open( + "https://podkop.net/docs/troubleshooting/?utm_source=podkop", + "_blank", + "noopener,noreferrer" + ) + }) + ]); +} + +// src/podkop/tabs/diagnostic/checks/runSectionsCheck.ts +async function runSectionsCheck() { + const { order, title, code } = DIAGNOSTICS_CHECKS_MAP.OUTBOUNDS; + updateCheckStore({ + order, + code, + title, + description: _("Checking, please wait"), + state: "loading", + items: [] + }); + const sections = await getDashboardSections(); + if (!sections.success) { + updateCheckStore({ + order, + code, + title, + description: _("Cannot receive checks result"), + state: "error", + items: [] + }); + throw new Error("Sections checks failed"); + } + const items = await Promise.all( + sections.data.map(async (section) => { + async function getLatency() { + if (section.withTagSelect) { + const latencyGroup = await PodkopShellMethods.getClashApiGroupLatency( + section.code + ); + const selectedOutbound = section.outbounds.find( + (item) => item.selected + ); + const isUrlTest = selectedOutbound?.type === "URLTest"; + const success3 = latencyGroup.success && !latencyGroup.data.message; + if (success3) { + if (isUrlTest) { + const latency2 = Object.values(latencyGroup.data).map((item) => item ? `${item}ms` : "n/a").join(" / "); + return { + success: true, + latency: `[${_("Fastest")}] ${latency2}` + }; + } + const selectedProxyDelay = latencyGroup.data?.[selectedOutbound?.code ?? ""]; + if (selectedProxyDelay) { + return { + success: true, + latency: `[${selectedOutbound?.displayName ?? ""}] ${selectedProxyDelay}ms` + }; + } + return { + success: false, + latency: `[${selectedOutbound?.displayName ?? ""}] ${_("Not responding")}` + }; + } + return { + success: false, + latency: _("Not responding") + }; + } + const latencyProxy = await PodkopShellMethods.getClashApiProxyLatency( + section.code + ); + const success2 = latencyProxy.success && !latencyProxy.data.message; + if (success2) { + return { + success: true, + latency: `${latencyProxy.data.delay} ms` + }; + } + return { + success: false, + latency: _("Not responding") + }; + } + const { latency, success } = await getLatency(); + return { + state: success ? "success" : "error", + key: section.displayName, + value: latency + }; + }) + ); + const allGood = items.every((item) => item.state === "success"); + const atLeastOneGood = items.some((item) => item.state === "success"); + const { state, description } = getMeta({ atLeastOneGood, allGood }); + updateCheckStore({ + order, + code, + title, + description, + state, + items + }); + if (!atLeastOneGood) { + throw new Error("Sections checks failed"); + } +} + +// src/helpers/removeVersionPrefix.ts +function removeVersionPrefix(version) { + return version.replace(/^v/, ""); +} + +// src/podkop/tabs/diagnostic/helpers/getPodkopVersionRow.ts +function isUnknownVersion(version) { + return version === "unknown" || version === _("unknown"); +} +function getPodkopVersionRow(diagnosticsSystemInfo) { + const loading = diagnosticsSystemInfo.loading; + const unknown = isUnknownVersion(diagnosticsSystemInfo.podkop_version); + const hasActualVersion = Boolean(diagnosticsSystemInfo.podkop_latest_version) && !isUnknownVersion(diagnosticsSystemInfo.podkop_latest_version); + const version = normalizeCompiledVersion( + diagnosticsSystemInfo.podkop_version + ); + const isDevVersion = version === "dev"; + if (loading || unknown || !hasActualVersion || isDevVersion) { + return { + key: "Podkop", + value: version + }; + } + if (removeVersionPrefix(version) !== removeVersionPrefix(diagnosticsSystemInfo.podkop_latest_version)) { + return { + key: "Podkop", + value: version, + tag: { + label: _("Outdated"), + kind: "warning" + } + }; + } + return { + key: "Podkop", + value: version, + tag: { + label: _("Latest"), + kind: "success" + } + }; +} + +// src/podkop/tabs/diagnostic/initController.ts +async function fetchSystemInfo() { + const systemInfo = await PodkopShellMethods.getSystemInfo(); + if (systemInfo.success) { + store.set({ + diagnosticsSystemInfo: { + loading: false, + ...systemInfo.data + } + }); + } else { + store.set({ + diagnosticsSystemInfo: { + loading: false, + podkop_version: _("unknown"), + podkop_latest_version: _("unknown"), + luci_app_version: _("unknown"), + sing_box_version: _("unknown"), + openwrt_version: _("unknown"), + device_model: _("unknown") + } + }); + } +} +function renderDiagnosticsChecks() { + logger.debug("[DIAGNOSTIC]", "renderDiagnosticsChecks"); + const diagnosticsChecks = store.get().diagnosticsChecks.sort((a, b) => a.order - b.order); + const container = document.getElementById("pdk_diagnostic-page-checks"); + const renderedDiagnosticsChecks = diagnosticsChecks.map( + (check) => renderCheckSection(check) + ); + return preserveScrollForPage(() => { + container.replaceChildren(...renderedDiagnosticsChecks); + }); +} +function renderDiagnosticRunActionWidget() { + logger.debug("[DIAGNOSTIC]", "renderDiagnosticRunActionWidget"); + const { loading } = store.get().diagnosticsRunAction; + const container = document.getElementById("pdk_diagnostic-page-run-check"); + const renderedAction = renderRunAction({ + loading, + click: () => runChecks() + }); + return preserveScrollForPage(() => { + container.replaceChildren(renderedAction); + }); +} +async function handleRestart() { + const diagnosticsActions = store.get().diagnosticsActions; + store.set({ + diagnosticsActions: { + ...diagnosticsActions, + restart: { loading: true } + } + }); + try { + await PodkopShellMethods.restart(); + } catch (e) { + logger.error("[DIAGNOSTIC]", "handleRestart - e", e); + } finally { + setTimeout(async () => { + await fetchServicesInfo(); + store.set({ + diagnosticsActions: { + ...diagnosticsActions, + restart: { loading: false } + } + }); + store.reset(["diagnosticsChecks"]); + }, 5e3); + } +} +async function handleStop() { + const diagnosticsActions = store.get().diagnosticsActions; + store.set({ + diagnosticsActions: { + ...diagnosticsActions, + stop: { loading: true } + } + }); + try { + await PodkopShellMethods.stop(); + } catch (e) { + logger.error("[DIAGNOSTIC]", "handleStop - e", e); + } finally { + await fetchServicesInfo(); + store.set({ + diagnosticsActions: { + ...diagnosticsActions, + stop: { loading: false } + } + }); + store.reset(["diagnosticsChecks"]); + } +} +async function handleStart() { + const diagnosticsActions = store.get().diagnosticsActions; + store.set({ + diagnosticsActions: { + ...diagnosticsActions, + start: { loading: true } + } + }); + try { + await PodkopShellMethods.start(); + } catch (e) { + logger.error("[DIAGNOSTIC]", "handleStart - e", e); + } finally { + setTimeout(async () => { + await fetchServicesInfo(); + store.set({ + diagnosticsActions: { + ...diagnosticsActions, + start: { loading: false } + } + }); + store.reset(["diagnosticsChecks"]); + }, 5e3); + } +} +async function handleEnable() { + const diagnosticsActions = store.get().diagnosticsActions; + store.set({ + diagnosticsActions: { + ...diagnosticsActions, + enable: { loading: true } + } + }); + try { + await PodkopShellMethods.enable(); + } catch (e) { + logger.error("[DIAGNOSTIC]", "handleEnable - e", e); + } finally { + await fetchServicesInfo(); + store.set({ + diagnosticsActions: { + ...diagnosticsActions, + enable: { loading: false } + } + }); + } +} +async function handleDisable() { + const diagnosticsActions = store.get().diagnosticsActions; + store.set({ + diagnosticsActions: { + ...diagnosticsActions, + disable: { loading: true } + } + }); + try { + await PodkopShellMethods.disable(); + } catch (e) { + logger.error("[DIAGNOSTIC]", "handleDisable - e", e); + } finally { + await fetchServicesInfo(); + store.set({ + diagnosticsActions: { + ...diagnosticsActions, + disable: { loading: false } + } + }); + } +} +async function handleShowGlobalCheck() { + const diagnosticsActions = store.get().diagnosticsActions; + store.set({ + diagnosticsActions: { + ...diagnosticsActions, + globalCheck: { loading: true } + } + }); + try { + const globalCheck = await PodkopShellMethods.globalCheck(); + if (globalCheck.success) { + ui.showModal( + _("Global check"), + renderModal(globalCheck.data, "global_check") + ); + } else { + logger.error("[DIAGNOSTIC]", "handleShowGlobalCheck - e", globalCheck); + showToast(_("Failed to execute!"), "error"); + } + } catch (e) { + logger.error("[DIAGNOSTIC]", "handleShowGlobalCheck - e", e); + showToast(_("Failed to execute!"), "error"); + } finally { + store.set({ + diagnosticsActions: { + ...diagnosticsActions, + globalCheck: { loading: false } + } + }); + } +} +async function handleViewLogs() { + const diagnosticsActions = store.get().diagnosticsActions; + store.set({ + diagnosticsActions: { + ...diagnosticsActions, + viewLogs: { loading: true } + } + }); + try { + const viewLogs = await PodkopShellMethods.checkLogs(); + if (viewLogs.success) { + ui.showModal( + _("View logs"), + renderModal(viewLogs.data, "view_logs") + ); + } else { + logger.error("[DIAGNOSTIC]", "handleViewLogs - e", viewLogs); + showToast(_("Failed to execute!"), "error"); + } + } catch (e) { + logger.error("[DIAGNOSTIC]", "handleViewLogs - e", e); + showToast(_("Failed to execute!"), "error"); + } finally { + store.set({ + diagnosticsActions: { + ...diagnosticsActions, + viewLogs: { loading: false } + } + }); + } +} +async function handleShowSingBoxConfig() { + const diagnosticsActions = store.get().diagnosticsActions; + store.set({ + diagnosticsActions: { + ...diagnosticsActions, + showSingBoxConfig: { loading: true } + } + }); + try { + const showSingBoxConfig = await PodkopShellMethods.showSingBoxConfig(); + if (showSingBoxConfig.success) { + ui.showModal( + _("Show sing-box config"), + renderModal( + JSON.stringify(showSingBoxConfig.data, null, 2), + "show_sing_box_config" + ) + ); + } else { + logger.error( + "[DIAGNOSTIC]", + "handleShowSingBoxConfig - e", + showSingBoxConfig + ); + showToast(_("Failed to execute!"), "error"); + } + } catch (e) { + logger.error("[DIAGNOSTIC]", "handleShowSingBoxConfig - e", e); + showToast(_("Failed to execute!"), "error"); + } finally { + store.set({ + diagnosticsActions: { + ...diagnosticsActions, + showSingBoxConfig: { loading: false } + } + }); + } +} +function renderWikiDisclaimerWidget() { + const diagnosticsChecks = store.get().diagnosticsChecks; + function getWikiKind() { + const allResults = diagnosticsChecks.map((check) => check.state); + if (allResults.includes("error")) { + return "error"; + } + if (allResults.includes("warning")) { + return "warning"; + } + return "default"; + } + const container = document.getElementById("pdk_diagnostic-page-wiki"); + return preserveScrollForPage(() => { + container.replaceChildren(renderWikiDisclaimer(getWikiKind())); + }); +} +function renderDiagnosticAvailableActionsWidget() { + const diagnosticsActions = store.get().diagnosticsActions; + const servicesInfoWidget = store.get().servicesInfoWidget; + logger.debug("[DIAGNOSTIC]", "renderDiagnosticAvailableActionsWidget"); + const podkopEnabled = Boolean(servicesInfoWidget.data.podkop); + const singBoxRunning = Boolean(servicesInfoWidget.data.singbox); + const atLeastOneServiceCommandLoading = servicesInfoWidget.loading || diagnosticsActions.restart.loading || diagnosticsActions.start.loading || diagnosticsActions.stop.loading; + const container = document.getElementById("pdk_diagnostic-page-actions"); + const renderedActions = renderAvailableActions({ + restart: { + loading: diagnosticsActions.restart.loading, + visible: true, + onClick: handleRestart, + disabled: atLeastOneServiceCommandLoading + }, + start: { + loading: diagnosticsActions.start.loading, + visible: !singBoxRunning, + onClick: handleStart, + disabled: atLeastOneServiceCommandLoading + }, + stop: { + loading: diagnosticsActions.stop.loading, + visible: singBoxRunning, + onClick: handleStop, + disabled: atLeastOneServiceCommandLoading + }, + enable: { + loading: diagnosticsActions.enable.loading, + visible: !podkopEnabled, + onClick: handleEnable, + disabled: atLeastOneServiceCommandLoading + }, + disable: { + loading: diagnosticsActions.disable.loading, + visible: podkopEnabled, + onClick: handleDisable, + disabled: atLeastOneServiceCommandLoading + }, + globalCheck: { + loading: diagnosticsActions.globalCheck.loading, + visible: true, + onClick: handleShowGlobalCheck, + disabled: atLeastOneServiceCommandLoading + }, + viewLogs: { + loading: diagnosticsActions.viewLogs.loading, + visible: true, + onClick: handleViewLogs, + disabled: atLeastOneServiceCommandLoading + }, + showSingBoxConfig: { + loading: diagnosticsActions.showSingBoxConfig.loading, + visible: true, + onClick: handleShowSingBoxConfig, + disabled: atLeastOneServiceCommandLoading + } + }); + return preserveScrollForPage(() => { + container.replaceChildren(renderedActions); + }); +} +function renderDiagnosticSystemInfoWidget() { + logger.debug("[DIAGNOSTIC]", "renderDiagnosticSystemInfoWidget"); + const diagnosticsSystemInfo = store.get().diagnosticsSystemInfo; + const container = document.getElementById("pdk_diagnostic-page-system-info"); + const renderedSystemInfo = renderSystemInfo({ + items: [ + getPodkopVersionRow(diagnosticsSystemInfo), + { + key: "Luci App", + value: normalizeCompiledVersion(PODKOP_LUCI_APP_VERSION) + }, + { + key: "Sing-box", + value: diagnosticsSystemInfo.sing_box_version + }, + { + key: "OS", + value: diagnosticsSystemInfo.openwrt_version + }, + { + key: "Device", + value: diagnosticsSystemInfo.device_model + } + ] + }); + return preserveScrollForPage(() => { + container.replaceChildren(renderedSystemInfo); + }); +} +async function onStoreUpdate2(next, prev, diff) { + if (diff.diagnosticsChecks) { + renderDiagnosticsChecks(); + renderWikiDisclaimerWidget(); + } + if (diff.diagnosticsRunAction) { + renderDiagnosticRunActionWidget(); + } + if (diff.diagnosticsActions || diff.servicesInfoWidget) { + renderDiagnosticAvailableActionsWidget(); + } + if (diff.diagnosticsSystemInfo) { + renderDiagnosticSystemInfoWidget(); + } +} +async function runChecks() { + try { + store.set({ + diagnosticsRunAction: { loading: true }, + diagnosticsChecks: loadingDiagnosticsChecksStore.diagnosticsChecks + }); + await runDnsCheck(); + await runSingBoxCheck(); + await runNftCheck(); + await runSectionsCheck(); + await runFakeIPCheck(); + } catch (e) { + logger.error("[DIAGNOSTIC]", "runChecks - e", e); + } finally { + store.set({ diagnosticsRunAction: { loading: false } }); + } +} +function onPageMount2() { + onPageUnmount2(); + store.subscribe(onStoreUpdate2); + renderDiagnosticsChecks(); + renderDiagnosticRunActionWidget(); + renderDiagnosticAvailableActionsWidget(); + renderDiagnosticSystemInfoWidget(); + renderWikiDisclaimerWidget(); + fetchServicesInfo(); + fetchSystemInfo(); +} +function onPageUnmount2() { + store.unsubscribe(onStoreUpdate2); + store.reset([ + "diagnosticsActions", + "diagnosticsSystemInfo", + "diagnosticsChecks", + "diagnosticsRunAction" + ]); +} +function registerLifecycleListeners2() { + store.subscribe((next, prev, diff) => { + if (diff.tabService && next.tabService.current !== prev.tabService.current) { + logger.debug( + "[DIAGNOSTIC]", + "active tab diff event, active tab:", + diff.tabService.current + ); + const isDIAGNOSTICVisible = next.tabService.current === "diagnostic"; + if (isDIAGNOSTICVisible) { + logger.debug( + "[DIAGNOSTIC]", + "registerLifecycleListeners", + "onPageMount" + ); + return onPageMount2(); + } + if (!isDIAGNOSTICVisible) { + logger.debug( + "[DIAGNOSTIC]", + "registerLifecycleListeners", + "onPageUnmount" + ); + return onPageUnmount2(); + } + } + }); +} +async function initController2() { + onMount("diagnostic-status").then(() => { + logger.debug("[DIAGNOSTIC]", "initController", "onMount"); + onPageMount2(); + registerLifecycleListeners2(); + }); +} + +// src/podkop/tabs/diagnostic/styles.ts +var styles4 = ` + +#cbi-podkop-diagnostic-_mount_node > div { + width: 100%; +} + +#cbi-podkop-diagnostic > h3 { + display: none; +} + +.pdk_diagnostic-page { + display: grid; + grid-template-columns: 2fr 1fr; + grid-column-gap: 10px; + align-items: start; +} + +@media (max-width: 800px) { + .pdk_diagnostic-page { + grid-template-columns: 1fr; + } +} + +.pdk_diagnostic-page__right-bar { + display: grid; + grid-template-columns: 1fr; + grid-row-gap: 10px; +} + +.pdk_diagnostic-page__right-bar__wiki { + border: 2px var(--background-color-low, lightgray) solid; + border-radius: 4px; + padding: 10px; + + display: grid; + grid-template-columns: auto; + grid-row-gap: 10px; +} + +.pdk_diagnostic-page__right-bar__wiki--warning { + border: 2px var(--warn-color-medium, orange) solid; +} +.pdk_diagnostic-page__right-bar__wiki--error { + border: 2px var(--error-color-medium, red) solid; +} + +.pdk_diagnostic-page__right-bar__wiki__content { + display: grid; + grid-template-columns: 1fr 5fr; + grid-column-gap: 10px; +} + +.pdk_diagnostic-page__right-bar__wiki__texts {} + +.pdk_diagnostic-page__right-bar__actions { + border: 2px var(--background-color-low, lightgray) solid; + border-radius: 4px; + padding: 10px; + + display: grid; + grid-template-columns: auto; + grid-row-gap: 10px; + +} + +.pdk_diagnostic-page__right-bar__system-info { + border: 2px var(--background-color-low, lightgray) solid; + border-radius: 4px; + padding: 10px; + + display: grid; + grid-template-columns: auto; + grid-row-gap: 10px; +} + +.pdk_diagnostic-page__right-bar__system-info__title { + +} + +.pdk_diagnostic-page__right-bar__system-info__row { + display: grid; + grid-template-columns: auto 1fr; + grid-column-gap: 5px; +} + +.pdk_diagnostic-page__right-bar__system-info__row__tag { + padding: 2px 4px; + border: 1px transparent solid; + border-radius: 4px; + margin-left: 5px; +} + +.pdk_diagnostic-page__right-bar__system-info__row__tag--warning { + border: 1px var(--warn-color-medium, orange) solid; + color: var(--warn-color-medium, orange); +} + +.pdk_diagnostic-page__right-bar__system-info__row__tag--success { + border: 1px var(--success-color-medium, green) solid; + color: var(--success-color-medium, green); +} + +.pdk_diagnostic-page__left-bar { + display: grid; + grid-template-columns: 1fr; + grid-row-gap: 10px; +} + +.pdk_diagnostic-page__run_check_wrapper {} + +.pdk_diagnostic-page__run_check_wrapper button { + width: 100%; +} + +.pdk_diagnostic-page__checks { + display: grid; + grid-template-columns: 1fr; + grid-row-gap: 10px; +} + +.pdk_diagnostic_alert { + border: 2px var(--background-color-low, lightgray) solid; + border-radius: 4px; + + display: grid; + grid-template-columns: 24px 1fr; + grid-column-gap: 10px; + align-items: center; + padding: 10px; +} + +.pdk_diagnostic_alert--loading { + border: 2px var(--primary-color-high, dodgerblue) solid; +} + +.pdk_diagnostic_alert--warning { + border: 2px var(--warn-color-medium, orange) solid; + color: var(--warn-color-medium, orange); +} + +.pdk_diagnostic_alert--error { + border: 2px var(--error-color-medium, red) solid; + color: var(--error-color-medium, red); +} + +.pdk_diagnostic_alert--success { + border: 2px var(--success-color-medium, green) solid; + color: var(--success-color-medium, green); +} + +.pdk_diagnostic_alert--skipped {} + +.pdk_diagnostic_alert__icon {} + +.pdk_diagnostic_alert__content {} + +.pdk_diagnostic_alert__title { + display: block; +} + +.pdk_diagnostic_alert__description {} + +.pdk_diagnostic_alert__summary { + margin-top: 10px; +} + +.pdk_diagnostic_alert__summary__item { + display: grid; + grid-template-columns: 16px auto 1fr; + grid-column-gap: 10px; +} + +.pdk_diagnostic_alert__summary__item--error { + color: var(--error-color-medium, red); +} + +.pdk_diagnostic_alert__summary__item--warning { + color: var(--warn-color-medium, orange); +} + +.pdk_diagnostic_alert__summary__item--success { + color: var(--success-color-medium, green); +} + +.pdk_diagnostic_alert__summary__item__icon { + width: 16px; + height: 16px; +} +`; + +// src/podkop/tabs/diagnostic/index.ts +var DiagnosticTab = { + render: render2, + initController: initController2, + styles: styles4 +}; + +// src/styles.ts +var GlobalStyles = ` +${DashboardTab.styles} +${DiagnosticTab.styles} +${PartialStyles} + + +/* Hide extra H3 for settings tab */ +#cbi-podkop-settings > h3 { + display: none; +} + +/* Hide extra H3 for sections tab */ +#cbi-podkop-section > h3:nth-child(1) { + display: none; +} + +/* Vertical align for remove section action button */ +#cbi-podkop-section > .cbi-section-remove { + margin-bottom: -32px; +} + +/* Centered class helper */ +.centered { + display: flex; + align-items: center; + justify-content: center; +} + +/* Rotate class helper */ +.rotate { + animation: spin 1s linear infinite; +} + +@keyframes spin { + from { transform: rotate(0deg); } + to { transform: rotate(360deg); } +} + +/* 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%; + } +} +/* Toast */ +.toast-container { + position: fixed; + bottom: 30px; + left: 50%; + transform: translateX(-50%); + display: flex; + flex-direction: column; + align-items: center; + gap: 10px; + z-index: 9999; + font-family: system-ui, sans-serif; +} + +.toast { + opacity: 0; + transform: translateY(10px); + transition: opacity 0.3s ease, transform 0.3s ease; + padding: 10px 16px; + border-radius: 6px; + color: #fff; + font-size: 14px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2); + min-width: 220px; + max-width: 340px; + text-align: center; +} + +.toast-success { + background-color: #28a745; +} + +.toast-error { + background-color: #dc3545; +} + +.toast.visible { + opacity: 1; + transform: translateY(0); +} +`; + +// src/helpers/injectGlobalStyles.ts +function injectGlobalStyles() { + document.head.insertAdjacentHTML( + "beforeend", + ` + + ` + ); +} + +// src/helpers/withTimeout.ts +async function withTimeout(promise, timeoutMs, operationName, timeoutMessage = _("Operation timed out")) { + let timeoutId; + const start = performance.now(); + const timeoutPromise = new Promise((_2, reject) => { + timeoutId = setTimeout(() => reject(new Error(timeoutMessage)), timeoutMs); + }); + try { + return await Promise.race([promise, timeoutPromise]); + } finally { + clearTimeout(timeoutId); + const elapsed = performance.now() - start; + logger.info("[SHELL]", `[${operationName}] took ${elapsed.toFixed(2)} ms`); + } +} + +// src/helpers/executeShellCommand.ts +async function executeShellCommand({ + command, + args, + timeout = COMMAND_TIMEOUT +}) { + try { + return withTimeout( + fs.exec(command, args), + timeout, + [command, ...args].join(" ") + ); + } catch (err) { + const error = err; + return { stdout: "", stderr: error?.message, code: 0 }; + } +} + +// src/helpers/maskIP.ts +function maskIP(ip = "") { + const ipv4Regex = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/; + return ip.replace(ipv4Regex, (_match, _p1, _p2, _p3, p4) => `XX.XX.XX.${p4}`); +} + +// 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 getClashWsUrl() { + const { hostname } = window.location; + return `ws://${hostname}:9090`; +} +function getClashUIUrl() { + const { hostname } = window.location; + return `http://${hostname}:9090/ui`; +} + +// src/helpers/splitProxyString.ts +function splitProxyString(str) { + return str.split("\n").map((line) => line.trim()).filter((line) => !line.startsWith("//")).filter(Boolean); +} + +// src/helpers/preserveScrollForPage.ts +function preserveScrollForPage(renderFn) { + const scrollY = window.scrollY; + renderFn(); + requestAnimationFrame(() => { + window.scrollTo({ top: scrollY }); + }); +} + +// src/helpers/svgEl.ts +function svgEl(tag, attrs = {}, children = []) { + const NS = "http://www.w3.org/2000/svg"; + const el = document.createElementNS(NS, tag); + for (const [k, v] of Object.entries(attrs)) { + if (v != null) el.setAttribute(k, String(v)); + } + (Array.isArray(children) ? children : [children]).filter(Boolean).forEach((ch) => el.appendChild(ch)); + return el; +} + +// src/helpers/insertIf.ts +function insertIf(condition, elements) { + return condition ? elements : []; +} +function insertIfObj(condition, object) { + return condition ? object : {}; +} + +// src/main.ts +if (typeof structuredClone !== "function") + globalThis.structuredClone = (obj) => JSON.parse(JSON.stringify(obj)); +return baseclass.extend({ + ALLOWED_WITH_RUSSIA_INSIDE, + BOOTSTRAP_DNS_SERVER_OPTIONS, + BUTTON_FEEDBACK_TIMEOUT, + CACHE_TIMEOUT, + COMMAND_SCHEDULING, + COMMAND_TIMEOUT, + CustomPodkopMethods, + DIAGNOSTICS_INITIAL_DELAY, + DIAGNOSTICS_UPDATE_INTERVAL, + DNS_SERVER_OPTIONS, + DOMAIN_LIST_OPTIONS, + DashboardTab, + DiagnosticTab, + ERROR_POLL_INTERVAL, + FAKEIP_CHECK_DOMAIN, + FETCH_TIMEOUT, + IP_CHECK_DOMAIN, + Logger, + PODKOP_LUCI_APP_VERSION, + PodkopShellMethods, + REGIONAL_OPTIONS, + RemoteFakeIPMethods, + STATUS_COLORS, + TabService, + TabServiceInstance, + UPDATE_INTERVAL_OPTIONS, + bulkValidate, + coreService, + executeShellCommand, + getClashUIUrl, + getClashWsUrl, + getProxyUrlName, + injectGlobalStyles, + insertIf, + insertIfObj, + logger, + maskIP, + onMount, + parseQueryString, + parseValueList, + preserveScrollForPage, + socket, + splitProxyString, + store, + svgEl, + validateDNS, + validateDomain, + validateIPV4, + validateOutboundJson, + validatePath, + validateProxyUrl, + validateShadowsocksUrl, + validateSocksUrl, + validateSubnet, + validateTrojanUrl, + validateUrl, + validateVlessUrl, + withTimeout +}); diff --git a/src/frontend/sandbox/htdocs/luci-static/resources/view/podkop/podkop.js b/src/frontend/sandbox/htdocs/luci-static/resources/view/podkop/podkop.js new file mode 100644 index 0000000..f699e17 --- /dev/null +++ b/src/frontend/sandbox/htdocs/luci-static/resources/view/podkop/podkop.js @@ -0,0 +1,98 @@ +"use strict"; +"require view"; +"require form"; +"require baseclass"; +"require network"; +"require view.podkop.main as main"; + +// Settings content +"require view.podkop.settings as settings"; + +// Sections content +"require view.podkop.section as section"; + +// Dashboard content +"require view.podkop.dashboard as dashboard"; + +// Diagnostic content +"require view.podkop.diagnostic as diagnostic"; + +const EntryPoint = { + async render() { + main.injectGlobalStyles(); + + const podkopMap = new form.Map( + "podkop", + _("Podkop Settings"), + _("Configuration for Podkop service"), + ); + // Enable tab views + podkopMap.tabbed = true; + + // Sections tab + const sectionsSection = podkopMap.section( + form.TypedSection, + "section", + _("Sections"), + ); + sectionsSection.anonymous = false; + sectionsSection.addremove = true; + sectionsSection.template = "cbi/simpleform"; + + // Render section content + section.createSectionContent(sectionsSection); + + // Settings tab + const settingsSection = podkopMap.section( + form.TypedSection, + "settings", + _("Settings"), + ); + settingsSection.anonymous = true; + settingsSection.addremove = false; + // Make it named [ config settings 'settings' ] + settingsSection.cfgsections = function () { + return ["settings"]; + }; + + // Render settings content + settings.createSettingsContent(settingsSection); + + // Diagnostic tab + const diagnosticSection = podkopMap.section( + form.TypedSection, + "diagnostic", + _("Diagnostics"), + ); + diagnosticSection.anonymous = true; + diagnosticSection.addremove = false; + diagnosticSection.cfgsections = function () { + return ["diagnostic"]; + }; + + // Render diagnostic content + diagnostic.createDiagnosticContent(diagnosticSection); + + // Dashboard tab + const dashboardSection = podkopMap.section( + form.TypedSection, + "dashboard", + _("Dashboard"), + ); + dashboardSection.anonymous = true; + dashboardSection.addremove = false; + dashboardSection.cfgsections = function () { + return ["dashboard"]; + }; + + // Render dashboard content + dashboard.createDashboardContent(dashboardSection); + + // Inject core service + main.coreService(); + + return podkopMap.render(); + }, +}; + +return view.extend(EntryPoint); diff --git a/src/frontend/sandbox/htdocs/luci-static/resources/view/podkop/section.js b/src/frontend/sandbox/htdocs/luci-static/resources/view/podkop/section.js new file mode 100644 index 0000000..7dc8ccb --- /dev/null +++ b/src/frontend/sandbox/htdocs/luci-static/resources/view/podkop/section.js @@ -0,0 +1,703 @@ +"use strict"; +"require form"; +"require baseclass"; +"require ui"; +"require tools.widgets as widgets"; +"require view.podkop.main as main"; + +function createSectionContent(section) { + let o = section.option( + form.ListValue, + "connection_type", + _("Connection Type"), + _("Select between VPN and Proxy connection methods for traffic routing"), + ); + o.value("proxy", "Proxy"); + o.value("vpn", "VPN"); + o.value("block", "Block"); + o.value("exclusion", "Exclusion"); + + o = section.option( + form.ListValue, + "proxy_config_type", + _("Configuration Type"), + _("Select how to configure the proxy"), + ); + o.value("url", _("Connection URL")); + o.value("selector", _("Selector")); + o.value("urltest", _("URLTest")); + o.value("outbound", _("Outbound Config")); + o.default = "url"; + o.depends("connection_type", "proxy"); + + o = section.option( + form.TextValue, + "proxy_string", + _("Proxy Configuration URL"), + _("vless://, ss://, trojan://, socks4/5://, hy2/hysteria2:// links") + ); + o.depends("proxy_config_type", "url"); + o.rows = 5; + // Enable soft wrapping for multi-line proxy URLs (e.g., for URLTest proxy links) + o.wrap = "soft"; + // Render as a textarea to allow multiple proxy URLs/configs + o.textarea = true; + o.rmempty = false; + o.sectionDescriptions = new Map(); + o.validate = function (section_id, value) { + // Optional + if (!value || value.length === 0) { + return true; + } + + const validation = main.validateProxyUrl(value); + + if (validation.valid) { + return true; + } + + return validation.message; + }; + + o = section.option( + form.TextValue, + "outbound_json", + _("Outbound Configuration"), + _("Enter complete outbound configuration in JSON format"), + ); + o.depends("proxy_config_type", "outbound"); + o.rows = 10; + o.validate = function (section_id, value) { + // Optional + if (!value || value.length === 0) { + return true; + } + + const validation = main.validateOutboundJson(value); + + if (validation.valid) { + return true; + } + + return validation.message; + }; + + o = section.option( + form.DynamicList, + "selector_proxy_links", + _("Selector Proxy Links"), + _("vless://, ss://, trojan://, socks4/5://, hy2/hysteria2:// links") + ); + o.depends("proxy_config_type", "selector"); + o.rmempty = false; + o.validate = function (section_id, value) { + // Optional + if (!value || value.length === 0) { + return true; + } + + const validation = main.validateProxyUrl(value); + + if (validation.valid) { + return true; + } + + return validation.message; + }; + + o = section.option( + form.DynamicList, + "urltest_proxy_links", + _("URLTest Proxy Links"), + _("vless://, ss://, trojan://, socks4/5://, hy2/hysteria2:// links") + ); + o.depends("proxy_config_type", "urltest"); + o.rmempty = false; + o.validate = function (section_id, value) { + // Optional + if (!value || value.length === 0) { + return true; + } + + const validation = main.validateProxyUrl(value); + + if (validation.valid) { + return true; + } + + return validation.message; + }; + + o = section.option( + form.ListValue, + "urltest_check_interval", + _("URLTest Check Interval"), + _("The interval between connectivity tests") + ); + o.value("30s", _("Every 30 seconds")); + o.value("1m", _("Every 1 minute")); + o.value("3m", _("Every 3 minutes")); + o.value("5m", _("Every 5 minutes")); + o.default = "3m"; + o.depends("proxy_config_type", "urltest"); + + o = section.option( + form.Value, + "urltest_tolerance", + _("URLTest Tolerance"), + _("The maximum difference in response times (ms) allowed when comparing servers") + ); + o.default = "50"; + o.rmempty = false; + o.depends("proxy_config_type", "urltest"); + o.validate = function (section_id, value) { + if (!value || value.length === 0) { + return true; + } + + const parsed = parseFloat(value); + + if (/^[0-9]+$/.test(value) && !isNaN(parsed) && isFinite(parsed) && parsed >= 50 && parsed <= 1000) { + return true; + } + + return _('Must be a number in the range of 50 - 1000'); + }; + + o = section.option( + form.Value, + "urltest_testing_url", + _("URLTest Testing URL"), + _("The URL used to test server connectivity") + ); + o.value("https://www.gstatic.com/generate_204", "https://www.gstatic.com/generate_204 (Google)"); + o.value("https://cp.cloudflare.com/generate_204", "https://cp.cloudflare.com/generate_204 (Cloudflare)"); + o.value("https://captive.apple.com", "https://captive.apple.com (Apple)"); + o.value("https://connectivity-check.ubuntu.com", "https://connectivity-check.ubuntu.com (Ubuntu)") + o.default = "https://www.gstatic.com/generate_204"; + o.rmempty = false; + o.depends("proxy_config_type", "urltest"); + + o.validate = function (section_id, value) { + if (!value || value.length === 0) { + return true; + } + + const validation = main.validateUrl(value); + + if (validation.valid) { + return true; + } + + return validation.message; + }; + + o = section.option( + form.Flag, + "enable_udp_over_tcp", + _("UDP over TCP"), + _("Applicable for SOCKS and Shadowsocks proxy"), + ); + o.default = "0"; + o.depends("connection_type", "proxy"); + o.rmempty = false; + + o = section.option( + widgets.DeviceSelect, + "interface", + _("Network Interface"), + _("Select network interface for VPN connection"), + ); + o.depends("connection_type", "vpn"); + o.noaliases = true; + o.nobridges = false; + o.noinactive = false; + o.filter = function (section_id, value) { + // Blocked interface names that should never be selectable + const blockedInterfaces = [ + "br-lan", + "eth0", + "eth1", + "wan", + "phy0-ap0", + "phy1-ap0", + "pppoe-wan", + "lan", + ]; + + // Reject immediately if the value matches any blocked interface + if (blockedInterfaces.includes(value)) { + return false; + } + + // Try to find the device object with the given name + const device = this.devices.find((dev) => dev.getName() === value); + + // If no device is found, allow the value + if (!device) { + return true; + } + + // Get the device type (e.g., "wifi", "ethernet", etc.) + const type = device.getType(); + + // Reject wireless-related devices + const isWireless = + type === "wifi" || type === "wireless" || type.includes("wlan"); + + return !isWireless; + }; + + o = section.option( + form.Flag, + "domain_resolver_enabled", + _("Domain Resolver"), + _("Enable built-in DNS resolver for domains handled by this section"), + ); + o.default = "0"; + o.rmempty = false; + o.depends("connection_type", "vpn"); + + o = section.option( + form.ListValue, + "domain_resolver_dns_type", + _("DNS Protocol Type"), + _("Select the DNS protocol type for the domain resolver"), + ); + o.value("doh", _("DNS over HTTPS (DoH)")); + o.value("dot", _("DNS over TLS (DoT)")); + o.value("udp", _("UDP (Unprotected DNS)")); + o.default = "udp"; + o.rmempty = false; + o.depends("domain_resolver_enabled", "1"); + + o = section.option( + form.Value, + "domain_resolver_dns_server", + _("DNS Server"), + _("Select or enter DNS server address"), + ); + Object.entries(main.DNS_SERVER_OPTIONS).forEach(([key, label]) => { + o.value(key, _(label)); + }); + o.default = "8.8.8.8"; + o.rmempty = false; + o.depends("domain_resolver_enabled", "1"); + o.validate = function (section_id, value) { + const validation = main.validateDNS(value); + + if (validation.valid) { + return true; + } + + return validation.message; + }; + + o = section.option( + form.DynamicList, + "community_lists", + _("Community Lists"), + _("Select a predefined list for routing") + + ' github.com/itdoginfo/allow-domains', + ); + o.placeholder = "Service list"; + Object.entries(main.DOMAIN_LIST_OPTIONS).forEach(([key, label]) => { + o.value(key, _(label)); + }); + o.rmempty = true; + let lastValues = []; + let isProcessing = false; + + o.onchange = function (ev, section_id, value) { + if (isProcessing) return; + isProcessing = true; + + try { + const values = Array.isArray(value) ? value : [value]; + let newValues = [...values]; + let notifications = []; + + const selectedRegionalOptions = main.REGIONAL_OPTIONS.filter((opt) => + newValues.includes(opt), + ); + + if (selectedRegionalOptions.length > 1) { + const lastSelected = + selectedRegionalOptions[selectedRegionalOptions.length - 1]; + const removedRegions = selectedRegionalOptions.slice(0, -1); + newValues = newValues.filter( + (v) => v === lastSelected || !main.REGIONAL_OPTIONS.includes(v), + ); + notifications.push( + E("p", {}, [ + E("strong", {}, _("Regional options cannot be used together")), + E("br"), + _( + "Warning: %s cannot be used together with %s. Previous selections have been removed.", + ).format(removedRegions.join(", "), lastSelected), + ]), + ); + } + + if (newValues.includes("russia_inside")) { + const removedServices = newValues.filter( + (v) => !main.ALLOWED_WITH_RUSSIA_INSIDE.includes(v), + ); + if (removedServices.length > 0) { + newValues = newValues.filter((v) => + main.ALLOWED_WITH_RUSSIA_INSIDE.includes(v), + ); + notifications.push( + E("p", { class: "alert-message warning" }, [ + E("strong", {}, _("Russia inside restrictions")), + E("br"), + _( + "Warning: Russia inside can only be used with %s. %s already in Russia inside and have been removed from selection.", + ).format( + main.ALLOWED_WITH_RUSSIA_INSIDE.map( + (key) => main.DOMAIN_LIST_OPTIONS[key], + ) + .filter((label) => label !== "Russia inside") + .join(", "), + removedServices.join(", "), + ), + ]), + ); + } + } + + if (JSON.stringify(newValues.sort()) !== JSON.stringify(values.sort())) { + this.getUIElement(section_id).setValue(newValues); + } + + notifications.forEach((notification) => + ui.addNotification(null, notification), + ); + lastValues = newValues; + } catch (e) { + console.error("Error in onchange handler:", e); + } finally { + isProcessing = false; + } + }; + + o = section.option( + form.ListValue, + "user_domain_list_type", + _("User Domain List Type"), + _("Select the list type for adding custom domains"), + ); + o.value("disabled", _("Disabled")); + o.value("dynamic", _("Dynamic List")); + o.value("text", _("Text List")); + o.default = "disabled"; + o.rmempty = false; + + o = section.option( + form.DynamicList, + "user_domains", + _("User Domains"), + _( + "Enter domain names without protocols, e.g. example.com or sub.example.com", + ), + ); + o.placeholder = "Domains list"; + o.depends("user_domain_list_type", "dynamic"); + o.rmempty = false; + o.validate = function (section_id, value) { + // Optional + if (!value || value.length === 0) { + return true; + } + + const validation = main.validateDomain(value, true); + + if (validation.valid) { + return true; + } + + return validation.message; + }; + + o = section.option( + form.TextValue, + "user_domains_text", + _("User Domains List"), + _( + "Enter domain names separated by commas, spaces, or newlines. You can add comments using //", + ), + ); + o.placeholder = + "example.com, sub.example.com\n// Social networks\ndomain.com test.com // personal domains"; + o.depends("user_domain_list_type", "text"); + o.rows = 8; + o.rmempty = false; + o.validate = function (section_id, value) { + // Optional + if (!value || value.length === 0) { + return true; + } + + const domains = main.parseValueList(value); + + if (!domains.length) { + return _( + "At least one valid domain must be specified. Comments-only content is not allowed.", + ); + } + + const { valid, results } = main.bulkValidate(domains, (row) => + main.validateDomain(row, true), + ); + + if (!valid) { + const errors = results + .filter((validation) => !validation.valid) // Leave only failed validations + .map((validation) => `${validation.value}: ${validation.message}`); // Collect validation errors + + return [_("Validation errors:"), ...errors].join("\n"); + } + + return true; + }; + + o = section.option( + form.ListValue, + "user_subnet_list_type", + _("User Subnet List Type"), + _("Select the list type for adding custom subnets"), + ); + o.value("disabled", _("Disabled")); + o.value("dynamic", _("Dynamic List")); + o.value("text", _("Text List")); + o.default = "disabled"; + o.rmempty = false; + + o = section.option( + form.DynamicList, + "user_subnets", + _("User Subnets"), + _( + "Enter subnets in CIDR notation (e.g. 103.21.244.0/22) or single IP addresses", + ), + ); + o.placeholder = "IP or subnet"; + o.depends("user_subnet_list_type", "dynamic"); + o.rmempty = false; + o.validate = function (section_id, value) { + // Optional + if (!value || value.length === 0) { + return true; + } + + const validation = main.validateSubnet(value); + + if (validation.valid) { + return true; + } + + return validation.message; + }; + + o = section.option( + form.TextValue, + "user_subnets_text", + _("User Subnets List"), + _( + "Enter subnets in CIDR notation or single IP addresses, separated by commas, spaces, or newlines. " + + "You can add comments using //", + ), + ); + o.placeholder = + "103.21.244.0/22\n// Google DNS\n8.8.8.8\n1.1.1.1/32, 9.9.9.9 // Cloudflare and Quad9"; + o.depends("user_subnet_list_type", "text"); + o.rows = 10; + o.rmempty = false; + o.validate = function (section_id, value) { + // Optional + if (!value || value.length === 0) { + return true; + } + + const subnets = main.parseValueList(value); + + if (!subnets.length) { + return _( + "At least one valid subnet or IP must be specified. Comments-only content is not allowed.", + ); + } + + const { valid, results } = main.bulkValidate(subnets, main.validateSubnet); + + if (!valid) { + const errors = results + .filter((validation) => !validation.valid) // Leave only failed validations + .map((validation) => `${validation.value}: ${validation.message}`); // Collect validation errors + + return [_("Validation errors:"), ...errors].join("\n"); + } + + return true; + }; + + o = section.option( + form.DynamicList, + "local_domain_lists", + _("Local Domain Lists"), + _("Specify the path to the list file located on the router filesystem"), + ); + o.placeholder = "/path/file.lst"; + o.rmempty = true; + o.validate = function (section_id, value) { + // Optional + if (!value || value.length === 0) { + return true; + } + + const validation = main.validatePath(value); + + if (validation.valid) { + return true; + } + + return validation.message; + }; + + o = section.option( + form.DynamicList, + "local_subnet_lists", + _("Local Subnet Lists"), + _("Specify the path to the list file located on the router filesystem"), + ); + o.placeholder = "/path/file.lst"; + o.rmempty = true; + o.validate = function (section_id, value) { + // Optional + if (!value || value.length === 0) { + return true; + } + + const validation = main.validatePath(value); + + if (validation.valid) { + return true; + } + + return validation.message; + }; + + o = section.option( + form.DynamicList, + "remote_domain_lists", + _("Remote Domain Lists"), + _("Specify remote URLs to download and use domain lists"), + ); + o.placeholder = "https://example.com/domains.srs"; + o.rmempty = true; + o.validate = function (section_id, value) { + // Optional + if (!value || value.length === 0) { + return true; + } + + const validation = main.validateUrl(value); + + if (validation.valid) { + return true; + } + + return validation.message; + }; + + o = section.option( + form.DynamicList, + "remote_subnet_lists", + _("Remote Subnet Lists"), + _("Specify remote URLs to download and use subnet lists"), + ); + o.placeholder = "https://example.com/subnets.srs"; + o.rmempty = true; + o.validate = function (section_id, value) { + // Optional + if (!value || value.length === 0) { + return true; + } + + const validation = main.validateUrl(value); + + if (validation.valid) { + return true; + } + + return validation.message; + }; + + o = section.option( + form.DynamicList, + "fully_routed_ips", + _("Fully Routed IPs"), + _( + "Specify local IP addresses or subnets whose traffic will always be routed through the configured route", + ), + ); + o.placeholder = "192.168.1.2 or 192.168.1.0/24"; + o.rmempty = true; + o.depends("connection_type", "proxy"); + o.depends("connection_type", "vpn"); + o.validate = function (section_id, value) { + // Optional + if (!value || value.length === 0) { + return true; + } + + const validation = main.validateSubnet(value); + + if (validation.valid) { + return true; + } + + return validation.message; + }; + + o = section.option( + form.Flag, + "mixed_proxy_enabled", + _("Enable Mixed Proxy"), + _( + "Enable the mixed proxy, allowing this section to route traffic through both HTTP and SOCKS proxies", + ), + ); + o.default = "0"; + o.rmempty = false; + o.depends("connection_type", "proxy"); + o.depends("connection_type", "vpn"); + + o = section.option( + form.Value, + "mixed_proxy_port", + _("Mixed Proxy Port"), + _( + "Specify the port number on which the mixed proxy will run for this section. " + + "Make sure the selected port is not used by another service", + ), + ); + o.rmempty = false; + o.depends("mixed_proxy_enabled", "1"); + + o = section.option( + form.Flag, + "resolve_real_ip_for_routing", + _("Resolve real IP for routing"), + _("Enable DNS resolve to get real IP when routing"), + ); + o.default = "0"; + o.rmempty = false; + o.depends("connection_type", "proxy"); + o.depends("connection_type", "vpn"); +} + +const EntryPoint = { + createSectionContent, +}; + +return baseclass.extend(EntryPoint); diff --git a/src/frontend/sandbox/htdocs/luci-static/resources/view/podkop/settings.js b/src/frontend/sandbox/htdocs/luci-static/resources/view/podkop/settings.js new file mode 100644 index 0000000..efe3c29 --- /dev/null +++ b/src/frontend/sandbox/htdocs/luci-static/resources/view/podkop/settings.js @@ -0,0 +1,438 @@ +"use strict"; +"require form"; +"require uci"; +"require baseclass"; +"require tools.widgets as widgets"; +"require view.podkop.main as main"; + +function createSettingsContent(section) { + let o = section.option( + form.ListValue, + "dns_type", + _("DNS Protocol Type"), + _("Select DNS protocol to use"), + ); + o.value("doh", _("DNS over HTTPS (DoH)")); + o.value("dot", _("DNS over TLS (DoT)")); + o.value("udp", _("UDP (Unprotected DNS)")); + o.default = "udp"; + o.rmempty = false; + + o = section.option( + form.Value, + "dns_server", + _("DNS Server"), + _("Select or enter DNS server address"), + ); + Object.entries(main.DNS_SERVER_OPTIONS).forEach(([key, label]) => { + o.value(key, _(label)); + }); + o.default = "8.8.8.8"; + o.rmempty = false; + o.validate = function (section_id, value) { + const validation = main.validateDNS(value); + + if (validation.valid) { + return true; + } + + return validation.message; + }; + + o = section.option( + form.Value, + "bootstrap_dns_server", + _("Bootstrap DNS server"), + _( + "The DNS server used to look up the IP address of an upstream DNS server", + ), + ); + Object.entries(main.BOOTSTRAP_DNS_SERVER_OPTIONS).forEach(([key, label]) => { + o.value(key, _(label)); + }); + o.default = "77.88.8.8"; + o.rmempty = false; + o.validate = function (section_id, value) { + const validation = main.validateDNS(value); + + if (validation.valid) { + return true; + } + + return validation.message; + }; + + o = section.option( + form.Value, + "dns_rewrite_ttl", + _("DNS Rewrite TTL"), + _("Time in seconds for DNS record caching (default: 60)"), + ); + o.default = "60"; + o.rmempty = false; + o.validate = function (section_id, value) { + if (!value) { + return _("TTL value cannot be empty"); + } + + const ttl = parseInt(value); + if (isNaN(ttl) || ttl < 0) { + return _("TTL must be a positive number"); + } + + return true; + }; + + o = section.option( + widgets.DeviceSelect, + "source_network_interfaces", + _("Source Network Interface"), + _("Select the network interface from which the traffic will originate"), + ); + o.default = "br-lan"; + o.noaliases = true; + o.nobridges = false; + o.noinactive = false; + o.multiple = true; + o.filter = function (section_id, value) { + // Block specific interface names from being selectable + const blocked = ["wan", "phy0-ap0", "phy1-ap0", "pppoe-wan"]; + if (blocked.includes(value)) { + return false; + } + + // Try to find the device object by its name + const device = this.devices.find((dev) => dev.getName() === value); + + // If no device is found, allow the value + if (!device) { + return true; + } + + // Check the type of the device + const type = device.getType(); + + // Consider any Wi-Fi / wireless / wlan device as invalid + const isWireless = + type === "wifi" || type === "wireless" || type.includes("wlan"); + + // Allow only non-wireless devices + return !isWireless; + }; + + o = section.option( + form.Flag, + "enable_output_network_interface", + _("Enable Output Network Interface"), + _("You can select Output Network Interface, by default autodetect"), + ); + o.default = "0"; + o.rmempty = false; + + o = section.option( + widgets.DeviceSelect, + "output_network_interface", + _("Output Network Interface"), + _("Select the network interface to which the traffic will originate"), + ); + o.noaliases = true; + o.multiple = false; + o.depends("enable_output_network_interface", "1"); + o.filter = function (section_id, value) { + // Blocked interface names that should never be selectable + const blockedInterfaces = ["br-lan"]; + + // Reject immediately if the value matches any blocked interface + if (blockedInterfaces.includes(value)) { + return false; + } + + // Reject lan* + if ( + value.startsWith("lan") + ) { + return false; + } + + // Reject tun*, wg*, vpn*, awg*, oc* + if ( + value.startsWith("tun") || + value.startsWith("wg") || + value.startsWith("vpn") || + value.startsWith("awg") || + value.startsWith("oc") + ) { + return false; + } + + // Try to find the device object with the given name + const device = this.devices.find((dev) => dev.getName() === value); + + // If no device is found, allow the value + if (!device) { + return true; + } + + // Get the device type (e.g., "wifi", "ethernet", etc.) + const type = device.getType(); + + // Reject wireless-related devices + const isWireless = + type === "wifi" || type === "wireless" || type.includes("wlan"); + + return !isWireless; + }; + + o = section.option( + form.Flag, + "enable_badwan_interface_monitoring", + _("Interface Monitoring"), + _("Interface monitoring for Bad WAN"), + ); + o.default = "0"; + o.rmempty = false; + + o = section.option( + widgets.NetworkSelect, + "badwan_monitored_interfaces", + _("Monitored Interfaces"), + _("Select the WAN interfaces to be monitored"), + ); + o.depends("enable_badwan_interface_monitoring", "1"); + o.multiple = true; + o.filter = function (section_id, value) { + // Reject if the value is in the blocked list ['lan', 'loopback'] + if (["lan", "loopback"].includes(value)) { + return false; + } + + // Reject if the value starts with '@' (means it's an alias/reference) + if (value.startsWith("@")) { + return false; + } + + // Otherwise allow it + return true; + }; + + o = section.option( + form.Value, + "badwan_reload_delay", + _("Interface Monitoring Delay"), + _("Delay in milliseconds before reloading podkop after interface UP"), + ); + o.depends("enable_badwan_interface_monitoring", "1"); + o.default = "2000"; + o.rmempty = false; + o.validate = function (section_id, value) { + if (!value) { + return _("Delay value cannot be empty"); + } + return true; + }; + + o = section.option( + form.Flag, + "enable_yacd", + _("Enable YACD"), + `${main.getClashUIUrl()}`, + ); + o.default = "0"; + o.rmempty = false; + + o = section.option( + form.Flag, + "enable_yacd_wan_access", + _("Enable YACD WAN Access"), + _("Allows access to YACD from the WAN. Make sure to open the appropriate port in your firewall."), + ); + o.depends("enable_yacd", "1"); + o.default = "0"; + o.rmempty = false; + + o = section.option( + form.Value, + "yacd_secret_key", + _("YACD Secret Key"), + _("Secret key for authenticating remote access to YACD when WAN access is enabled."), + ); + o.depends("enable_yacd_wan_access", "1"); + o.rmempty = false; + + o = section.option( + form.Flag, + "disable_quic", + _("Disable QUIC"), + _( + "Disable the QUIC protocol to improve compatibility or fix issues with video streaming", + ), + ); + o.default = "0"; + o.rmempty = false; + + o = section.option( + form.ListValue, + "update_interval", + _("List Update Frequency"), + _("Select how often the domain or subnet lists are updated automatically"), + ); + Object.entries(main.UPDATE_INTERVAL_OPTIONS).forEach(([key, label]) => { + o.value(key, _(label)); + }); + o.default = "1d"; + o.rmempty = false; + + o = section.option( + form.Flag, + "download_lists_via_proxy", + _("Download Lists via Proxy/VPN"), + _("Downloading all lists via specific Proxy/VPN"), + ); + o.default = "0"; + o.rmempty = false; + + o = section.option( + form.ListValue, + "download_lists_via_proxy_section", + _("Download Lists via specific proxy section"), + _("Downloading all lists via specific Proxy/VPN"), + ); + + o.rmempty = false; + o.depends("download_lists_via_proxy", "1"); + o.cfgvalue = function (section_id) { + return uci.get("podkop", section_id, "download_lists_via_proxy_section"); + }; + o.load = function () { + const sections = this.map?.data?.state?.values?.podkop ?? {}; + + this.keylist = []; + this.vallist = []; + + for (const secName in sections) { + const sec = sections[secName]; + if (sec[".type"] === "section" && sec['connection_type'] !== 'block' && sec['connection_type'] !== 'exclusion') { + this.keylist.push(secName); + this.vallist.push(secName); + } + } + + return Promise.resolve(); + }; + + o = section.option( + form.Flag, + "dont_touch_dhcp", + _("Dont Touch My DHCP!"), + _("Podkop will not modify your DHCP configuration"), + ); + o.default = "0"; + o.rmempty = false; + + o = section.option( + form.ListValue, + "config_path", + _("Config File Path"), + _( + "Select path for sing-box config file. Change this ONLY if you know what you are doing", + ), + ); + o.value("/etc/sing-box/config.json", "Flash (/etc/sing-box/config.json)"); + o.value("/tmp/sing-box/config.json", "RAM (/tmp/sing-box/config.json)"); + o.default = "/etc/sing-box/config.json"; + o.rmempty = false; + + o = section.option( + form.Value, + "cache_path", + _("Cache File Path"), + _( + "Select or enter path for sing-box cache file. Change this ONLY if you know what you are doing", + ), + ); + o.value("/tmp/sing-box/cache.db", "RAM (/tmp/sing-box/cache.db)"); + o.value( + "/usr/share/sing-box/cache.db", + "Flash (/usr/share/sing-box/cache.db)", + ); + o.default = "/tmp/sing-box/cache.db"; + o.rmempty = false; + o.validate = function (section_id, value) { + if (!value) { + return _("Cache file path cannot be empty"); + } + + if (!value.startsWith("/")) { + return _("Path must be absolute (start with /)"); + } + + if (!value.endsWith("cache.db")) { + return _("Path must end with cache.db"); + } + + const parts = value.split("/").filter(Boolean); + if (parts.length < 2) { + return _("Path must contain at least one directory (like /tmp/cache.db)"); + } + + return true; + }; + + o = section.option( + form.ListValue, + "log_level", + _("Log Level"), + _( + "Select the log level for sing-box", + ), + ); + o.value("trace", "Trace"); + o.value("debug", "Debug"); + o.value("info", "Info"); + o.value("warn", "Warn"); + o.value("error", "Error"); + o.value("fatal", "Fatal"); + o.value("panic", "Panic"); + o.default = "warn"; + o.rmempty = false; + + o = section.option( + form.Flag, + "exclude_ntp", + _("Exclude NTP"), + _( + "Exclude NTP protocol traffic from the tunnel to prevent it from being routed through the proxy or VPN", + ), + ); + o.default = "0"; + o.rmempty = false; + + o = section.option( + form.DynamicList, + "routing_excluded_ips", + _("Routing Excluded IPs"), + _("Specify a local IP address to be excluded from routing"), + ); + o.placeholder = "IP"; + o.rmempty = true; + o.validate = function (section_id, value) { + // Optional + if (!value || value.length === 0) { + return true; + } + + const validation = main.validateIPV4(value); + + if (validation.valid) { + return true; + } + + return validation.message; + }; +} + +const EntryPoint = { + createSettingsContent, +}; + +return baseclass.extend(EntryPoint); diff --git a/src/frontend/sandbox/msgmerge.sh b/src/frontend/sandbox/msgmerge.sh new file mode 100644 index 0000000..06e5706 --- /dev/null +++ b/src/frontend/sandbox/msgmerge.sh @@ -0,0 +1,30 @@ +#!/usr/bin/env bash +set -euo pipefail + +PODIR="po" +POTFILE="$PODIR/templates/podkop.pot" +WIDTH=120 + +if [ $# -ne 1 ]; then + echo "Usage: $0 (e.g., ru, de, fr)" + exit 1 +fi + +LANG="$1" +POFILE="$PODIR/$LANG/podkop.po" + +if [ ! -f "$POTFILE" ]; then + echo "Template $POTFILE not found. Run xgettext first." + exit 1 +fi + +if [ -f "$POFILE" ]; then + echo "Updating $POFILE" + msgmerge --update --width="$WIDTH" --no-location "$POFILE" "$POTFILE" +else + echo "Creating new $POFILE using msginit" + mkdir -p "$PODIR/$LANG" + msginit --no-translator --no-location --locale="$LANG" --width="$WIDTH" --input="$POTFILE" --output-file="$POFILE" +fi + +echo "Translation file for $LANG updated." \ No newline at end of file diff --git a/src/frontend/sandbox/po/ru/podkop.po b/src/frontend/sandbox/po/ru/podkop.po new file mode 100644 index 0000000..77a33a4 --- /dev/null +++ b/src/frontend/sandbox/po/ru/podkop.po @@ -0,0 +1,795 @@ +# RU translations for PODKOP package. +# Copyright (C) 2026 THE PODKOP'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PODKOP package. +# divocatt, 2026. +# +msgid "" +msgstr "" +"Project-Id-Version: PODKOP\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2026-05-29 16:40+0300\n" +"PO-Revision-Date: 2026-05-29 16:40+0300\n" +"Last-Translator: divocatt\n" +"Language-Team: none\n" +"Language: ru\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"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 "✔ Enabled" +msgstr "✔ Включено" + +msgid "✔ Running" +msgstr "✔ Работает" + +msgid "✘ Disabled" +msgstr "✘ Отключено" + +msgid "✘ Stopped" +msgstr "✘ Остановлен" + +msgid "Active Connections" +msgstr "Активные соединения" + +msgid "Additional marking rules found" +msgstr "Найдены дополнительные правила маркировки" + +msgid "Allows access to YACD from the WAN. Make sure to open the appropriate port in your firewall." +msgstr "Обеспечивает доступ к YACD из WAN. Убедитесь, что в брандмауэре открыт соответствующий порт." + +msgid "Applicable for SOCKS and Shadowsocks proxy" +msgstr "Применимо для SOCKS и Shadowsocks прокси" + +msgid "At least one valid domain must be specified. Comments-only content is not allowed." +msgstr "Необходимо указать хотя бы один действительный домен. Содержимое только из комментариев не допускается." + +msgid "At least one valid subnet or IP must be specified. Comments-only content is not allowed." +msgstr "Необходимо указать хотя бы одну действительную подсеть или IP. Только комментарии недопустимы." + +msgid "Available actions" +msgstr "Доступные действия" + +msgid "Bootsrap DNS" +msgstr "Bootstrap DNS" + +msgid "Bootstrap DNS server" +msgstr "Bootstrap DNS-сервер" + +msgid "Browser is not using FakeIP" +msgstr "Браузер не использует FakeIP" + +msgid "Browser is using FakeIP correctly" +msgstr "Браузер использует FakeIP" + +msgid "Cache File Path" +msgstr "Путь к файлу кэша" + +msgid "Cache file path cannot be empty" +msgstr "Путь к файлу кэша не может быть пустым" + +msgid "Cannot receive checks result" +msgstr "Не удалось получить результаты проверки" + +msgid "Checking, please wait" +msgstr "Проверяем, пожалуйста подождите" + +msgid "checks" +msgstr "проверки" + +msgid "Checks failed" +msgstr "Проверки не выполнены" + +msgid "Checks passed" +msgstr "Проверки пройдены" + +msgid "CIDR must be between 0 and 32" +msgstr "CIDR должен быть между 0 и 32" + +msgid "Close" +msgstr "Закрыть" + +msgid "Community Lists" +msgstr "Списки сообщества" + +msgid "Config File Path" +msgstr "Путь к файлу конфигурации" + +msgid "Configuration for Podkop service" +msgstr "Настройки сервиса Podkop" + +msgid "Configuration Type" +msgstr "Тип конфигурации" + +msgid "Connection Type" +msgstr "Тип подключения" + +msgid "Connection URL" +msgstr "URL подключения" + +msgid "Copy" +msgstr "Копировать" + +msgid "Currently unavailable" +msgstr "Временно недоступно" + +msgid "Dashboard" +msgstr "Дашборд" + +msgid "Dashboard currently unavailable" +msgstr "Дашборд сейчас недоступен" + +msgid "Delay in milliseconds before reloading podkop after interface UP" +msgstr "Задержка в миллисекундах перед перезагрузкой podkop после поднятия интерфейса" + +msgid "Delay value cannot be empty" +msgstr "Значение задержки не может быть пустым" + +msgid "DHCP has DNS server" +msgstr "DHCP содержит DNS сервер" + +msgid "Diagnostics" +msgstr "Диагностика" + +msgid "Disable autostart" +msgstr "Отключить автостарт" + +msgid "Disable QUIC" +msgstr "Отключить QUIC" + +msgid "Disable the QUIC protocol to improve compatibility or fix issues with video streaming" +msgstr "Отключить QUIC протокол для улучшения совместимости или исправления видео стриминга" + +msgid "Disabled" +msgstr "Отключено" + +msgid "DNS on router" +msgstr "DNS на роутере" + +msgid "DNS over HTTPS (DoH)" +msgstr "DNS через HTTPS (DoH)" + +msgid "DNS over TLS (DoT)" +msgstr "DNS через TLS (DoT)" + +msgid "DNS Protocol Type" +msgstr "Тип протокола DNS" + +msgid "DNS Rewrite TTL" +msgstr "Перезапись TTL для DNS" + +msgid "DNS Server" +msgstr "DNS-сервер" + +msgid "DNS server address cannot be empty" +msgstr "Адрес DNS-сервера не может быть пустым" + +msgid "Do not panic, everything can be fixed, just..." +msgstr "Не паникуйте, всё можно исправить, просто..." + +msgid "Domain Resolver" +msgstr "Резолвер доменов" + +msgid "Dont Touch My DHCP!" +msgstr "Dont Touch My DHCP!" + +msgid "Downlink" +msgstr "Входящий" + +msgid "Download" +msgstr "Скачать" + +msgid "Download Lists via Proxy/VPN" +msgstr "Скачивать списки через Proxy/VPN" + +msgid "Download Lists via specific proxy section" +msgstr "Скачивать списки через выбранную секцию" + +msgid "Downloading all lists via specific Proxy/VPN" +msgstr "Загрузка всех списков через указанный прокси/VPN" + +msgid "Dynamic List" +msgstr "Динамический список" + +msgid "Enable autostart" +msgstr "Включить автостарт" + +msgid "Enable built-in DNS resolver for domains handled by this section" +msgstr "Включить встроенный DNS-резолвер для доменов, обрабатываемых в этом разделе" + +msgid "Enable DNS resolve to get real IP when routing" +msgstr "Разрешать домены в реальные IP-адреса перед маршрутизацией в outbound" + +msgid "Enable Mixed Proxy" +msgstr "Включить смешанный прокси" + +msgid "Enable Output Network Interface" +msgstr "Включить выходной сетевой интерфейс" + +msgid "Enable the mixed proxy, allowing this section to route traffic through both HTTP and SOCKS proxies" +msgstr "Включить смешанный прокси-сервер, разрешив этому разделу маршрутизировать трафик как через HTTP, так и через SOCKS-прокси." + +msgid "Enable YACD" +msgstr "Включить YACD" + +msgid "Enable YACD WAN Access" +msgstr "Включить доступ YACD WAN" + +msgid "Enter complete outbound configuration in JSON format" +msgstr "Введите полную конфигурацию исходящего соединения в формате JSON" + +msgid "Enter domain names separated by commas, spaces, or newlines. You can add comments using //" +msgstr "Введите доменные имена, разделяя их запятыми, пробелами или переносами строк. Вы можете добавлять комментарии, используя //" + +msgid "Enter domain names without protocols, e.g. example.com or sub.example.com" +msgstr "Введите доменные имена без протоколов, например example.com или sub.example.com" + +msgid "Enter subnets in CIDR notation (e.g. 103.21.244.0/22) or single IP addresses" +msgstr "Введите подсети в нотации CIDR (например, 103.21.244.0/22) или отдельные IP-адреса" + +msgid "Every 1 minute" +msgstr "Каждую минуту" + +msgid "Every 3 minutes" +msgstr "Каждые 3 минуты" + +msgid "Every 30 seconds" +msgstr "Каждые 30 секунд" + +msgid "Every 5 minutes" +msgstr "Каждые 5 минут" + +msgid "Exclude NTP" +msgstr "Исключить NTP" + +msgid "Exclude NTP protocol traffic from the tunnel to prevent it from being routed through the proxy or VPN" +msgstr "Исключите трафик протокола NTP из туннеля, чтобы предотвратить его маршрутизацию через прокси-сервер или VPN." + +msgid "Failed to copy!" +msgstr "Не удалось скопировать!" + +msgid "Failed to execute!" +msgstr "Не удалось выполнить!" + +msgid "Fastest" +msgstr "Самый быстрый" + +msgid "Fully Routed IPs" +msgstr "Полностью маршрутизированные IP-адреса" + +msgid "Get global check" +msgstr "Получить глобальную проверку" + +msgid "Global check" +msgstr "Глобальная проверка" + +msgid "HTTP error" +msgstr "Ошибка HTTP" + +msgid "Interface Monitoring" +msgstr "Мониторинг интерфейса" + +msgid "Interface Monitoring Delay" +msgstr "Задержка при мониторинге интерфейсов" + +msgid "Interface monitoring for Bad WAN" +msgstr "Мониторинг интерфейса для Bad WAN" + +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 "Invalid domain address" +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 "Invalid HY2 URL: insecure must be 0 or 1" +msgstr "Неверный URL Hysteria2: параметр insecure должен быть 0 или 1" + +msgid "Invalid HY2 URL: invalid port number" +msgstr "Неверный URL Hysteria2: неверный номер порта" + +msgid "Invalid HY2 URL: missing credentials/server" +msgstr "Неверный URL Hysteria2: отсутствуют учетные данные/сервер" + +msgid "Invalid HY2 URL: missing host" +msgstr "Неверный URL Hysteria2: отсутствует хост" + +msgid "Invalid HY2 URL: missing host & port" +msgstr "Неверный URL Hysteria2: отсутствуют хост и порт" + +msgid "Invalid HY2 URL: missing password" +msgstr "Неверный URL Hysteria2: отсутствует пароль" + +msgid "Invalid HY2 URL: missing port" +msgstr "Неверный URL Hysteria2: отсутствует порт" + +msgid "Invalid HY2 URL: must not contain spaces" +msgstr "Неверный URL Hysteria2: не должен содержать пробелов" + +msgid "Invalid HY2 URL: must start with hysteria2:// or hy2://" +msgstr "Неверный URL Hysteria2: должен начинаться с hysteria2:// или hy2://" + +msgid "Invalid HY2 URL: obfs-password required when obfs is set" +msgstr "Неверный URL Hysteria2: требуется obfs-password, когда установлен obfs" + +msgid "Invalid HY2 URL: parsing failed" +msgstr "Неверный URL Hysteria2: ошибка разбора" + +msgid "Invalid HY2 URL: sni cannot be empty" +msgstr "Неверный URL Hysteria2: sni не может быть пустым" + +msgid "Invalid HY2 URL: unsupported obfs type" +msgstr "Неверный URL Hysteria2: неподдерживаемый тип obfs" + +msgid "Invalid IP address" +msgstr "Неверный IP-адрес" + +msgid "Invalid JSON format" +msgstr "Неверный формат JSON" + +msgid "Invalid path format. Path must start with \"/\" and contain valid characters" +msgstr "Неверный формат пути. Путь должен начинаться с \"/\" и содержать допустимые символы" + +msgid "Invalid port number. Must be between 1 and 65535" +msgstr "Неверный номер порта. Допустимо от 1 до 65535" + +msgid "Invalid Shadowsocks URL: decoded credentials must contain method:password" +msgstr "Неверный URL Shadowsocks: декодированные данные должны содержать method:password" + +msgid "Invalid Shadowsocks URL: missing credentials" +msgstr "Неверный URL Shadowsocks: отсутствуют учетные данные" + +msgid "Invalid Shadowsocks URL: missing method and password separator \":\"" +msgstr "Неверный URL Shadowsocks: отсутствует разделитель метода и пароля \":\"" + +msgid "Invalid Shadowsocks URL: missing port" +msgstr "Неверный URL Shadowsocks: отсутствует порт" + +msgid "Invalid Shadowsocks URL: missing server" +msgstr "Неверный URL Shadowsocks: отсутствует сервер" + +msgid "Invalid Shadowsocks URL: missing server address" +msgstr "Неверный URL Shadowsocks: отсутствует адрес сервера" + +msgid "Invalid Shadowsocks URL: must not contain spaces" +msgstr "Неверный URL Shadowsocks: не должен содержать пробелов" + +msgid "Invalid Shadowsocks URL: must start with ss://" +msgstr "Неверный URL Shadowsocks: должен начинаться с ss://" + +msgid "Invalid Shadowsocks URL: parsing failed" +msgstr "Неверный URL Shadowsocks: ошибка разбора" + +msgid "Invalid SOCKS URL: invalid host format" +msgstr "Неверный URL SOCKS: неверный формат хоста" + +msgid "Invalid SOCKS URL: invalid port number" +msgstr "Неверный URL SOCKS: неверный номер порта" + +msgid "Invalid SOCKS URL: missing host and port" +msgstr "Неверный URL SOCKS: отсутствует хост и порт" + +msgid "Invalid SOCKS URL: missing hostname or IP" +msgstr "Неверный URL SOCKS: отсутствует имя хоста или IP-адрес" + +msgid "Invalid SOCKS URL: missing port" +msgstr "Неверный URL SOCKS: отсутствует порт" + +msgid "Invalid SOCKS URL: missing username" +msgstr "Неверный URL SOCKS: отсутствует имя пользователя" + +msgid "Invalid SOCKS URL: must not contain spaces" +msgstr "Неверный URL SOCKS: не должен содержать пробелов" + +msgid "Invalid SOCKS URL: must start with socks4://, socks4a://, or socks5://" +msgstr "Неверный URL-адрес SOCKS: должен начинаться с socks4://, socks4a:// или socks5://" + +msgid "Invalid SOCKS URL: parsing failed" +msgstr "Неверный URL SOCKS: парсинг не удался" + +msgid "Invalid Trojan URL: must not contain spaces" +msgstr "Неверный URL Trojan: не должен содержать пробелов" + +msgid "Invalid Trojan URL: must start with trojan://" +msgstr "Неверный URL Trojan: должен начинаться с trojan://" + +msgid "Invalid Trojan URL: parsing failed" +msgstr "Неверный URL Trojan: ошибка разбора" + +msgid "Invalid URL format" +msgstr "Неверный формат URL" + +msgid "Invalid VLESS URL: parsing failed" +msgstr "Неверный URL VLESS: ошибка разбора" + +msgid "IP address 0.0.0.0 is not allowed" +msgstr "IP-адрес 0.0.0.0 не допускается" + +msgid "Issues detected" +msgstr "Обнаружены проблемы" + +msgid "Latest" +msgstr "Последняя" + +msgid "List Update Frequency" +msgstr "Частота обновления списков" + +msgid "Local Domain Lists" +msgstr "Локальные списки доменов" + +msgid "Local Subnet Lists" +msgstr "Локальные списки подсетей" + +msgid "Log Level" +msgstr "Уровень логов" + +msgid "Main DNS" +msgstr "Основной DNS" + +msgid "Memory Usage" +msgstr "Использование памяти" + +msgid "Mixed Proxy Port" +msgstr "Порт смешанного прокси" + +msgid "Monitored Interfaces" +msgstr "Наблюдаемые интерфейсы" + +msgid "Must be a number in the range of 50 - 1000" +msgstr "Должно быть числом от 50 до 1000" + +msgid "Network Interface" +msgstr "Сетевой интерфейс" + +msgid "No other marking rules found" +msgstr "Другие правила маркировки не найдены" + +msgid "Not implement yet" +msgstr "Ещё не реализовано" + +msgid "Not responding" +msgstr "Не отвечает" + +msgid "Not running" +msgstr "Не запущено" + +msgid "Operation timed out" +msgstr "Время ожидания истекло" + +msgid "Outbound Config" +msgstr "Конфигурация Outbound" + +msgid "Outbound Configuration" +msgstr "Конфигурация исходящего соединения" + +msgid "Outdated" +msgstr "Устаревшая" + +msgid "Output Network Interface" +msgstr "Выходной сетевой интерфейс" + +msgid "Path cannot be empty" +msgstr "Путь не может быть пустым" + +msgid "Path must be absolute (start with /)" +msgstr "Путь должен быть абсолютным (начинаться с /)" + +msgid "Path must contain at least one directory (like /tmp/cache.db)" +msgstr "Путь должен содержать хотя бы одну директорию (например /tmp/cache.db)" + +msgid "Path must end with cache.db" +msgstr "Путь должен заканчиваться на cache.db" + +msgid "Pending" +msgstr "Ожидает запуска" + +msgid "Podkop" +msgstr "Podkop" + +msgid "Podkop Settings" +msgstr "Настройки podkop" + +msgid "Podkop will not modify your DHCP configuration" +msgstr "Podkop не будет изменять вашу конфигурацию DHCP." + +msgid "Proxy Configuration URL" +msgstr "URL конфигурации прокси" + +msgid "Proxy traffic is not routed via FakeIP" +msgstr "Прокси-трафик не маршрутизируется через FakeIP" + +msgid "Proxy traffic is routed via FakeIP" +msgstr "Прокси-трафик направляется через FakeIP" + +msgid "Regional options cannot be used together" +msgstr "Нельзя использовать несколько региональных опций одновременно" + +msgid "Remote Domain Lists" +msgstr "Внешние списки доменов" + +msgid "Remote Subnet Lists" +msgstr "Внешние списки подсетей" + +msgid "Resolve real IP for routing" +msgstr "Разрешение реальных IP-адресов" + +msgid "Restart podkop" +msgstr "Перезапустить Podkop" + +msgid "Router DNS is not routed through sing-box" +msgstr "DNS роутера не проходит через sing-box" + +msgid "Router DNS is routed through sing-box" +msgstr "DNS роутера проходит через sing-box" + +msgid "Routing Excluded IPs" +msgstr "Исключённые из маршрутизации IP-адреса" + +msgid "Rules mangle counters" +msgstr "Счётчики правил mangle" + +msgid "Rules mangle exist" +msgstr "Правила mangle существуют" + +msgid "Rules mangle output counters" +msgstr "Счётчики правил mangle output" + +msgid "Rules mangle output exist" +msgstr "Правила mangle output существуют" + +msgid "Rules proxy counters" +msgstr "Счётчики правил proxy" + +msgid "Rules proxy exist" +msgstr "Правила прокси существуют" + +msgid "Run Diagnostic" +msgstr "Запустить диагностику" + +msgid "Russia inside restrictions" +msgstr "Ограничения Russia inside" + +msgid "Secret key for authenticating remote access to YACD when WAN access is enabled." +msgstr "Секретный ключ для аутентификации удаленного доступа к YACD при включенном доступе через WAN." + +msgid "Sections" +msgstr "Секции" + +msgid "Select a predefined list for routing" +msgstr "Выберите предопределенный список для маршрутизации" + +msgid "Select between VPN and Proxy connection methods for traffic routing" +msgstr "Выберите между VPN и Proxy методами для маршрутизации трафика" + +msgid "Select DNS protocol to use" +msgstr "Выберите протокол DNS" + +msgid "Select how often the domain or subnet lists are updated automatically" +msgstr "Выберите частоту автоматического обновления списков доменов или подсетей." + +msgid "Select how to configure the proxy" +msgstr "Выберите способ настройки прокси" + +msgid "Select network interface for VPN connection" +msgstr "Выберите сетевой интерфейс для VPN подключения" + +msgid "Select or enter DNS server address" +msgstr "Выберите или введите адрес DNS-сервера" + +msgid "Select or enter path for sing-box cache file. Change this ONLY if you know what you are doing" +msgstr "Выберите или введите путь к файлу кеша sing-box. Изменяйте это, ТОЛЬКО если вы знаете, что делаете" + +msgid "Select path for sing-box config file. Change this ONLY if you know what you are doing" +msgstr "Выберите путь к файлу конфигурации sing-box. Изменяйте это, ТОЛЬКО если вы знаете, что делаете" + +msgid "Select the DNS protocol type for the domain resolver" +msgstr "Выберите тип протокола DNS для резолвера доменов" + +msgid "Select the list type for adding custom domains" +msgstr "Выберите тип списка для добавления пользовательских доменов" + +msgid "Select the list type for adding custom subnets" +msgstr "Выберите тип списка для добавления пользовательских подсетей" + +msgid "Select the log level for sing-box" +msgstr "Выберите уровень логов для sing-box" + +msgid "Select the network interface from which the traffic will originate" +msgstr "Выберите сетевой интерфейс, с которого будет исходить трафик" + +msgid "Select the network interface to which the traffic will originate" +msgstr "Выберите сетевой интерфейс, на который будет поступать трафик." + +msgid "Select the WAN interfaces to be monitored" +msgstr "Выберите WAN интерфейсы для мониторинга" + +msgid "Selector" +msgstr "Selector" + +msgid "Selector Proxy Links" +msgstr "Ссылки прокси для Selector" + +msgid "Services info" +msgstr "Информация о сервисах" + +msgid "Settings" +msgstr "Настройки" + +msgid "Show sing-box config" +msgstr "Показать sing-box конфигурацию" + +msgid "Sing-box" +msgstr "Sing-box" + +msgid "Sing-box autostart disabled" +msgstr "Автостарт sing-box отключен" + +msgid "Sing-box installed" +msgstr "Sing-box установлен" + +msgid "Sing-box listening ports" +msgstr "Sing-box слушает порты" + +msgid "Sing-box process running" +msgstr "Процесс sing-box запущен" + +msgid "Sing-box service exist" +msgstr "Сервис sing-box существует" + +msgid "Sing-box version is compatible (newer than 1.12.4)" +msgstr "Версия Sing-box совместима (новее 1.12.4)" + +msgid "Source Network Interface" +msgstr "Сетевой интерфейс источника" + +msgid "Specify a local IP address to be excluded from routing" +msgstr "Укажите локальный IP-адрес, который следует исключить из маршрутизации." + +msgid "Specify local IP addresses or subnets whose traffic will always be routed through the configured route" +msgstr "Укажите локальные IP-адреса или подсети, трафик которых всегда будет направляться через настроенный маршрут." + +msgid "Specify remote URLs to download and use domain lists" +msgstr "Укажите URL-адреса для загрузки и использования списков доменов." + +msgid "Specify remote URLs to download and use subnet lists" +msgstr "Укажите URL-адреса для загрузки и использования списков подсетей." + +msgid "Specify the path to the list file located on the router filesystem" +msgstr "Укажите путь к файлу списка, расположенному в файловой системе маршрутизатора." + +msgid "Start podkop" +msgstr "Запустить podkop" + +msgid "Stop podkop" +msgstr "Остановить podkop" + +msgid "Successfully copied!" +msgstr "Успешно скопировано!" + +msgid "System info" +msgstr "Системная информация" + +msgid "System information" +msgstr "Системная информация" + +msgid "Table exist" +msgstr "Таблица существует" + +msgid "Test latency" +msgstr "Тестирование задержки" + +msgid "Text List" +msgstr "Текстовый список" + +msgid "The DNS server used to look up the IP address of an upstream DNS server" +msgstr "DNS-сервер, используемый для поиска IP-адреса вышестоящего DNS-сервера" + +msgid "The interval between connectivity tests" +msgstr "Интервал между тестами подключения" + +msgid "The maximum difference in response times (ms) allowed when comparing servers" +msgstr "Максимально допустимая разница во времени отклика (мс) при сравнении серверов" + +msgid "The URL used to test server connectivity" +msgstr "URL-адрес, используемый для проверки подключения к серверу" + +msgid "Time in seconds for DNS record caching (default: 60)" +msgstr "Время в секундах для кэширования DNS записей (по умолчанию: 60)" + +msgid "Traffic" +msgstr "Трафик" + +msgid "Traffic Total" +msgstr "Всего трафика" + +msgid "Troubleshooting" +msgstr "Устранение неполадок" + +msgid "TTL must be a positive number" +msgstr "TTL должно быть положительным числом" + +msgid "TTL value cannot be empty" +msgstr "Значение TTL не может быть пустым" + +msgid "UDP (Unprotected DNS)" +msgstr "UDP (Незащищённый DNS)" + +msgid "UDP over TCP" +msgstr "UDP через TCP" + +msgid "unknown" +msgstr "неизвестно" + +msgid "Unknown error" +msgstr "Неизвестная ошибка" + +msgid "Uplink" +msgstr "Исходящий" + +msgid "URL must start with vless://, ss://, trojan://, socks4/5://, or hysteria2://hy2://" +msgstr "URL должен начинаться с vless://, ss://, trojan://, socks4/5:// или hysteria2:// hy2://" + +msgid "URL must use one of the following protocols:" +msgstr "URL должен использовать один из следующих протоколов:" + +msgid "URLTest" +msgstr "URLTest" + +msgid "URLTest Check Interval" +msgstr "Интервал проверки URLTest" + +msgid "URLTest Proxy Links" +msgstr "Ссылки прокси для URLTest" + +msgid "URLTest Testing URL" +msgstr "URLTest ссылка для проверки" + +msgid "URLTest Tolerance" +msgstr "URLTest допустимое отклонение" + +msgid "User Domain List Type" +msgstr "Тип пользовательского списка доменов" + +msgid "User Domains" +msgstr "Пользовательские домены" + +msgid "User Domains List" +msgstr "Список пользовательских доменов" + +msgid "User Subnet List Type" +msgstr "Тип пользовательского списка подсетей" + +msgid "User Subnets" +msgstr "Пользовательские подсети" + +msgid "User Subnets List" +msgstr "Список пользовательских подсетей" + +msgid "Valid" +msgstr "Валидно" + +msgid "Validation errors:" +msgstr "Ошибки валидации:" + +msgid "View logs" +msgstr "Посмотреть логи" + +msgid "Visit Wiki" +msgstr "Перейти в wiki" + +msgid "vless://, ss://, trojan://, socks4/5://, hy2/hysteria2:// links" +msgstr "" + +msgid "Warning: %s cannot be used together with %s. Previous selections have been removed." +msgstr "Предупреждение: %s нельзя использовать вместе с %s. Предыдущие варианты были удалены." + +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 "YACD Secret Key" +msgstr "Секретный ключ YACD" + +msgid "You can select Output Network Interface, by default autodetect" +msgstr "Вы можете выбрать выходной сетевой интерфейс, по умолчанию он определяется автоматически." diff --git a/src/frontend/sandbox/po/templates/podkop.pot b/src/frontend/sandbox/po/templates/podkop.pot new file mode 100644 index 0000000..d96b948 --- /dev/null +++ b/src/frontend/sandbox/po/templates/podkop.pot @@ -0,0 +1,1115 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) 2026 THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PODKOP package. +# divocatt <210179590+divocatt@users.noreply.github.com>, 2026. +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: PODKOP\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2026-05-29 13:40+0300\n" +"PO-Revision-Date: 2026-05-29 13:40+0300\n" +"Last-Translator: divocatt <210179590+divocatt@users.noreply.github.com>\n" +"Language-Team: LANGUAGE \n" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" + +#: src/podkop/tabs/dashboard/initController.ts:345 +msgid "✔ Enabled" +msgstr "" + +#: src/podkop/tabs/dashboard/initController.ts:356 +msgid "✔ Running" +msgstr "" + +#: src/podkop/tabs/dashboard/initController.ts:346 +msgid "✘ Disabled" +msgstr "" + +#: src/podkop/tabs/dashboard/initController.ts:357 +msgid "✘ Stopped" +msgstr "" + +#: src/podkop/tabs/dashboard/initController.ts:307 +msgid "Active Connections" +msgstr "" + +#: src/podkop/tabs/diagnostic/checks/runNftCheck.ts:106 +msgid "Additional marking rules found" +msgstr "" + +#: ../luci-app-podkop/htdocs/luci-static/resources/view/podkop/settings.js:247 +msgid "Allows access to YACD from the WAN. Make sure to open the appropriate port in your firewall." +msgstr "" + +#: ../luci-app-podkop/htdocs/luci-static/resources/view/podkop/section.js:199 +msgid "Applicable for SOCKS and Shadowsocks proxy" +msgstr "" + +#: ../luci-app-podkop/htdocs/luci-static/resources/view/podkop/section.js:444 +msgid "At least one valid domain must be specified. Comments-only content is not allowed." +msgstr "" + +#: ../luci-app-podkop/htdocs/luci-static/resources/view/podkop/section.js:525 +msgid "At least one valid subnet or IP must be specified. Comments-only content is not allowed." +msgstr "" + +#: src/podkop/tabs/diagnostic/partials/renderAvailableActions.ts:43 +msgid "Available actions" +msgstr "" + +#: src/podkop/tabs/diagnostic/checks/runDnsCheck.ts:65 +msgid "Bootsrap DNS" +msgstr "" + +#: ../luci-app-podkop/htdocs/luci-static/resources/view/podkop/settings.js:45 +msgid "Bootstrap DNS server" +msgstr "" + +#: src/podkop/tabs/diagnostic/checks/runFakeIPCheck.ts:58 +msgid "Browser is not using FakeIP" +msgstr "" + +#: src/podkop/tabs/diagnostic/checks/runFakeIPCheck.ts:57 +msgid "Browser is using FakeIP correctly" +msgstr "" + +#: ../luci-app-podkop/htdocs/luci-static/resources/view/podkop/settings.js:348 +msgid "Cache File Path" +msgstr "" + +#: ../luci-app-podkop/htdocs/luci-static/resources/view/podkop/settings.js:362 +msgid "Cache file path cannot be empty" +msgstr "" + +#: src/podkop/tabs/diagnostic/checks/runDnsCheck.ts:27 +#: src/podkop/tabs/diagnostic/checks/runNftCheck.ts:28 +#: src/podkop/tabs/diagnostic/checks/runSectionsCheck.ts:27 +#: src/podkop/tabs/diagnostic/checks/runSingBoxCheck.ts:25 +msgid "Cannot receive checks result" +msgstr "" + +#: src/podkop/tabs/diagnostic/checks/runDnsCheck.ts:15 +#: src/podkop/tabs/diagnostic/checks/runFakeIPCheck.ts:15 +#: src/podkop/tabs/diagnostic/checks/runNftCheck.ts:13 +#: src/podkop/tabs/diagnostic/checks/runSectionsCheck.ts:15 +#: src/podkop/tabs/diagnostic/checks/runSingBoxCheck.ts:13 +msgid "Checking, please wait" +msgstr "" + +#: src/podkop/tabs/diagnostic/helpers/getCheckTitle.ts:2 +msgid "checks" +msgstr "" + +#: src/podkop/tabs/diagnostic/helpers/getMeta.ts:26 +msgid "Checks failed" +msgstr "" + +#: src/podkop/tabs/diagnostic/helpers/getMeta.ts:13 +msgid "Checks passed" +msgstr "" + +#: src/validators/validateSubnet.ts:33 +msgid "CIDR must be between 0 and 32" +msgstr "" + +#: src/partials/modal/renderModal.ts:26 +msgid "Close" +msgstr "" + +#: ../luci-app-podkop/htdocs/luci-static/resources/view/podkop/section.js:299 +msgid "Community Lists" +msgstr "" + +#: ../luci-app-podkop/htdocs/luci-static/resources/view/podkop/settings.js:335 +msgid "Config File Path" +msgstr "" + +#: ../luci-app-podkop/htdocs/luci-static/resources/view/podkop/podkop.js:27 +msgid "Configuration for Podkop service" +msgstr "" + +#: ../luci-app-podkop/htdocs/luci-static/resources/view/podkop/section.js:23 +msgid "Configuration Type" +msgstr "" + +#: ../luci-app-podkop/htdocs/luci-static/resources/view/podkop/section.js:12 +msgid "Connection Type" +msgstr "" + +#: ../luci-app-podkop/htdocs/luci-static/resources/view/podkop/section.js:26 +msgid "Connection URL" +msgstr "" + +#: src/partials/modal/renderModal.ts:20 +msgid "Copy" +msgstr "" + +#: src/podkop/tabs/dashboard/partials/renderWidget.ts:22 +msgid "Currently unavailable" +msgstr "" + +#: ../luci-app-podkop/htdocs/luci-static/resources/view/podkop/podkop.js:80 +msgid "Dashboard" +msgstr "" + +#: src/podkop/tabs/dashboard/partials/renderSections.ts:19 +msgid "Dashboard currently unavailable" +msgstr "" + +#: ../luci-app-podkop/htdocs/luci-static/resources/view/podkop/settings.js:222 +msgid "Delay in milliseconds before reloading podkop after interface UP" +msgstr "" + +#: ../luci-app-podkop/htdocs/luci-static/resources/view/podkop/settings.js:229 +msgid "Delay value cannot be empty" +msgstr "" + +#: src/podkop/tabs/diagnostic/checks/runDnsCheck.ts:82 +msgid "DHCP has DNS server" +msgstr "" + +#: ../luci-app-podkop/htdocs/luci-static/resources/view/podkop/podkop.js:65 +msgid "Diagnostics" +msgstr "" + +#: src/podkop/tabs/diagnostic/partials/renderAvailableActions.ts:79 +msgid "Disable autostart" +msgstr "" + +#: ../luci-app-podkop/htdocs/luci-static/resources/view/podkop/settings.js:265 +msgid "Disable QUIC" +msgstr "" + +#: ../luci-app-podkop/htdocs/luci-static/resources/view/podkop/settings.js:266 +msgid "Disable the QUIC protocol to improve compatibility or fix issues with video streaming" +msgstr "" + +#: ../luci-app-podkop/htdocs/luci-static/resources/view/podkop/section.js:390 +#: ../luci-app-podkop/htdocs/luci-static/resources/view/podkop/section.js:470 +msgid "Disabled" +msgstr "" + +#: src/podkop/tabs/diagnostic/checks/runDnsCheck.ts:77 +msgid "DNS on router" +msgstr "" + +#: ../luci-app-podkop/htdocs/luci-static/resources/view/podkop/section.js:267 +#: ../luci-app-podkop/htdocs/luci-static/resources/view/podkop/settings.js:15 +msgid "DNS over HTTPS (DoH)" +msgstr "" + +#: ../luci-app-podkop/htdocs/luci-static/resources/view/podkop/section.js:268 +#: ../luci-app-podkop/htdocs/luci-static/resources/view/podkop/settings.js:16 +msgid "DNS over TLS (DoT)" +msgstr "" + +#: ../luci-app-podkop/htdocs/luci-static/resources/view/podkop/section.js:264 +#: ../luci-app-podkop/htdocs/luci-static/resources/view/podkop/settings.js:12 +msgid "DNS Protocol Type" +msgstr "" + +#: ../luci-app-podkop/htdocs/luci-static/resources/view/podkop/settings.js:68 +msgid "DNS Rewrite TTL" +msgstr "" + +#: ../luci-app-podkop/htdocs/luci-static/resources/view/podkop/section.js:277 +#: ../luci-app-podkop/htdocs/luci-static/resources/view/podkop/settings.js:24 +msgid "DNS Server" +msgstr "" + +#: src/validators/validateDns.ts:7 +msgid "DNS server address cannot be empty" +msgstr "" + +#: src/podkop/tabs/diagnostic/partials/renderWikiDisclaimer.ts:26 +msgid "Do not panic, everything can be fixed, just..." +msgstr "" + +#: ../luci-app-podkop/htdocs/luci-static/resources/view/podkop/section.js:254 +msgid "Domain Resolver" +msgstr "" + +#: ../luci-app-podkop/htdocs/luci-static/resources/view/podkop/settings.js:326 +msgid "Dont Touch My DHCP!" +msgstr "" + +#: src/podkop/tabs/dashboard/initController.ts:241 +#: src/podkop/tabs/dashboard/initController.ts:275 +msgid "Downlink" +msgstr "" + +#: src/partials/modal/renderModal.ts:15 +msgid "Download" +msgstr "" + +#: ../luci-app-podkop/htdocs/luci-static/resources/view/podkop/settings.js:288 +msgid "Download Lists via Proxy/VPN" +msgstr "" + +#: ../luci-app-podkop/htdocs/luci-static/resources/view/podkop/settings.js:297 +msgid "Download Lists via specific proxy section" +msgstr "" + +#: ../luci-app-podkop/htdocs/luci-static/resources/view/podkop/settings.js:289 +#: ../luci-app-podkop/htdocs/luci-static/resources/view/podkop/settings.js:298 +msgid "Downloading all lists via specific Proxy/VPN" +msgstr "" + +#: ../luci-app-podkop/htdocs/luci-static/resources/view/podkop/section.js:391 +#: ../luci-app-podkop/htdocs/luci-static/resources/view/podkop/section.js:471 +msgid "Dynamic List" +msgstr "" + +#: src/podkop/tabs/diagnostic/partials/renderAvailableActions.ts:89 +msgid "Enable autostart" +msgstr "" + +#: ../luci-app-podkop/htdocs/luci-static/resources/view/podkop/section.js:255 +msgid "Enable built-in DNS resolver for domains handled by this section" +msgstr "" + +#: ../luci-app-podkop/htdocs/luci-static/resources/view/podkop/section.js:691 +msgid "Enable DNS resolve to get real IP when routing" +msgstr "" + +#: ../luci-app-podkop/htdocs/luci-static/resources/view/podkop/section.js:665 +msgid "Enable Mixed Proxy" +msgstr "" + +#: ../luci-app-podkop/htdocs/luci-static/resources/view/podkop/settings.js:126 +msgid "Enable Output Network Interface" +msgstr "" + +#: ../luci-app-podkop/htdocs/luci-static/resources/view/podkop/section.js:666 +msgid "Enable the mixed proxy, allowing this section to route traffic through both HTTP and SOCKS proxies" +msgstr "" + +#: ../luci-app-podkop/htdocs/luci-static/resources/view/podkop/settings.js:237 +msgid "Enable YACD" +msgstr "" + +#: ../luci-app-podkop/htdocs/luci-static/resources/view/podkop/settings.js:246 +msgid "Enable YACD WAN Access" +msgstr "" + +#: ../luci-app-podkop/htdocs/luci-static/resources/view/podkop/section.js:66 +msgid "Enter complete outbound configuration in JSON format" +msgstr "" + +#: ../luci-app-podkop/htdocs/luci-static/resources/view/podkop/section.js:426 +msgid "Enter domain names separated by commas, spaces, or newlines. You can add comments using //" +msgstr "" + +#: ../luci-app-podkop/htdocs/luci-static/resources/view/podkop/section.js:400 +msgid "Enter domain names without protocols, e.g. example.com or sub.example.com" +msgstr "" + +#: ../luci-app-podkop/htdocs/luci-static/resources/view/podkop/section.js:480 +msgid "Enter subnets in CIDR notation (e.g. 103.21.244.0/22) or single IP addresses" +msgstr "" + +#: ../luci-app-podkop/htdocs/luci-static/resources/view/podkop/section.js:138 +msgid "Every 1 minute" +msgstr "" + +#: ../luci-app-podkop/htdocs/luci-static/resources/view/podkop/section.js:139 +msgid "Every 3 minutes" +msgstr "" + +#: ../luci-app-podkop/htdocs/luci-static/resources/view/podkop/section.js:137 +msgid "Every 30 seconds" +msgstr "" + +#: ../luci-app-podkop/htdocs/luci-static/resources/view/podkop/section.js:140 +msgid "Every 5 minutes" +msgstr "" + +#: ../luci-app-podkop/htdocs/luci-static/resources/view/podkop/settings.js:402 +msgid "Exclude NTP" +msgstr "" + +#: ../luci-app-podkop/htdocs/luci-static/resources/view/podkop/settings.js:403 +msgid "Exclude NTP protocol traffic from the tunnel to prevent it from being routed through the proxy or VPN" +msgstr "" + +#: src/helpers/copyToClipboard.ts:12 +msgid "Failed to copy!" +msgstr "" + +#: src/podkop/tabs/diagnostic/initController.ts:227 +#: src/podkop/tabs/diagnostic/initController.ts:231 +#: src/podkop/tabs/diagnostic/initController.ts:261 +#: src/podkop/tabs/diagnostic/initController.ts:265 +#: src/podkop/tabs/diagnostic/initController.ts:302 +#: src/podkop/tabs/diagnostic/initController.ts:306 +msgid "Failed to execute!" +msgstr "" + +#: src/podkop/methods/custom/getDashboardSections.ts:148 +#: src/podkop/tabs/diagnostic/checks/runSectionsCheck.ts:59 +msgid "Fastest" +msgstr "" + +#: ../luci-app-podkop/htdocs/luci-static/resources/view/podkop/section.js:638 +msgid "Fully Routed IPs" +msgstr "" + +#: src/podkop/tabs/diagnostic/partials/renderAvailableActions.ts:98 +msgid "Get global check" +msgstr "" + +#: src/podkop/tabs/diagnostic/initController.ts:222 +msgid "Global check" +msgstr "" + +#: src/podkop/api.ts:27 +msgid "HTTP error" +msgstr "" + +#: ../luci-app-podkop/htdocs/luci-static/resources/view/podkop/settings.js:189 +msgid "Interface Monitoring" +msgstr "" + +#: ../luci-app-podkop/htdocs/luci-static/resources/view/podkop/settings.js:221 +msgid "Interface Monitoring Delay" +msgstr "" + +#: ../luci-app-podkop/htdocs/luci-static/resources/view/podkop/settings.js:190 +msgid "Interface monitoring for Bad WAN" +msgstr "" + +#: src/validators/validateDns.ts:23 +msgid "Invalid DNS server format. Examples: 8.8.8.8 or dns.example.com or dns.example.com/nicedns for DoH" +msgstr "" + +#: src/validators/validateDomain.ts:18 +#: src/validators/validateDomain.ts:27 +msgid "Invalid domain address" +msgstr "" + +#: src/validators/validateSubnet.ts:11 +msgid "Invalid format. Use X.X.X.X or X.X.X.X/Y" +msgstr "" + +#: src/validators/validateHysteriaUrl.ts:90 +msgid "Invalid HY2 URL: insecure must be 0 or 1" +msgstr "" + +#: src/validators/validateHysteriaUrl.ts:77 +msgid "Invalid HY2 URL: invalid port number" +msgstr "" + +#: src/validators/validateHysteriaUrl.ts:30 +msgid "Invalid HY2 URL: missing credentials/server" +msgstr "" + +#: src/validators/validateHysteriaUrl.ts:47 +msgid "Invalid HY2 URL: missing host" +msgstr "" + +#: src/validators/validateHysteriaUrl.ts:41 +msgid "Invalid HY2 URL: missing host & port" +msgstr "" + +#: src/validators/validateHysteriaUrl.ts:36 +msgid "Invalid HY2 URL: missing password" +msgstr "" + +#: src/validators/validateHysteriaUrl.ts:50 +msgid "Invalid HY2 URL: missing port" +msgstr "" + +#: src/validators/validateHysteriaUrl.ts:18 +msgid "Invalid HY2 URL: must not contain spaces" +msgstr "" + +#: src/validators/validateHysteriaUrl.ts:12 +msgid "Invalid HY2 URL: must start with hysteria2:// or hy2://" +msgstr "" + +#: src/validators/validateHysteriaUrl.ts:108 +msgid "Invalid HY2 URL: obfs-password required when obfs is set" +msgstr "" + +#: src/validators/validateHysteriaUrl.ts:122 +msgid "Invalid HY2 URL: parsing failed" +msgstr "" + +#: src/validators/validateHysteriaUrl.ts:116 +msgid "Invalid HY2 URL: sni cannot be empty" +msgstr "" + +#: src/validators/validateHysteriaUrl.ts:98 +msgid "Invalid HY2 URL: unsupported obfs type" +msgstr "" + +#: src/validators/validateIp.ts:11 +msgid "Invalid IP address" +msgstr "" + +#: src/validators/validateOutboundJson.ts:9 +msgid "Invalid JSON format" +msgstr "" + +#: src/validators/validatePath.ts:22 +msgid "Invalid path format. Path must start with \"/\" and contain valid characters" +msgstr "" + +#: src/validators/validateShadowsocksUrl.ts:85 +msgid "Invalid port number. Must be between 1 and 65535" +msgstr "" + +#: src/validators/validateShadowsocksUrl.ts:37 +msgid "Invalid Shadowsocks URL: decoded credentials must contain method:password" +msgstr "" + +#: src/validators/validateShadowsocksUrl.ts:27 +msgid "Invalid Shadowsocks URL: missing credentials" +msgstr "" + +#: src/validators/validateShadowsocksUrl.ts:46 +msgid "Invalid Shadowsocks URL: missing method and password separator \":\"" +msgstr "" + +#: src/validators/validateShadowsocksUrl.ts:76 +msgid "Invalid Shadowsocks URL: missing port" +msgstr "" + +#: src/validators/validateShadowsocksUrl.ts:67 +msgid "Invalid Shadowsocks URL: missing server" +msgstr "" + +#: src/validators/validateShadowsocksUrl.ts:58 +msgid "Invalid Shadowsocks URL: missing server address" +msgstr "" + +#: src/validators/validateShadowsocksUrl.ts:16 +msgid "Invalid Shadowsocks URL: must not contain spaces" +msgstr "" + +#: src/validators/validateShadowsocksUrl.ts:8 +msgid "Invalid Shadowsocks URL: must start with ss://" +msgstr "" + +#: src/validators/validateShadowsocksUrl.ts:91 +msgid "Invalid Shadowsocks URL: parsing failed" +msgstr "" + +#: src/validators/validateSocksUrl.ts:73 +msgid "Invalid SOCKS URL: invalid host format" +msgstr "" + +#: src/validators/validateSocksUrl.ts:63 +msgid "Invalid SOCKS URL: invalid port number" +msgstr "" + +#: src/validators/validateSocksUrl.ts:42 +msgid "Invalid SOCKS URL: missing host and port" +msgstr "" + +#: src/validators/validateSocksUrl.ts:51 +msgid "Invalid SOCKS URL: missing hostname or IP" +msgstr "" + +#: src/validators/validateSocksUrl.ts:56 +msgid "Invalid SOCKS URL: missing port" +msgstr "" + +#: src/validators/validateSocksUrl.ts:34 +msgid "Invalid SOCKS URL: missing username" +msgstr "" + +#: src/validators/validateSocksUrl.ts:19 +msgid "Invalid SOCKS URL: must not contain spaces" +msgstr "" + +#: src/validators/validateSocksUrl.ts:10 +msgid "Invalid SOCKS URL: must start with socks4://, socks4a://, or socks5://" +msgstr "" + +#: src/validators/validateSocksUrl.ts:77 +msgid "Invalid SOCKS URL: parsing failed" +msgstr "" + +#: src/validators/validateTrojanUrl.ts:15 +msgid "Invalid Trojan URL: must not contain spaces" +msgstr "" + +#: src/validators/validateTrojanUrl.ts:8 +msgid "Invalid Trojan URL: must start with trojan://" +msgstr "" + +#: src/validators/validateTrojanUrl.ts:56 +msgid "Invalid Trojan URL: parsing failed" +msgstr "" + +#: src/validators/validateUrl.ts:8 +#: src/validators/validateUrl.ts:31 +msgid "Invalid URL format" +msgstr "" + +#: src/validators/validateVlessUrl.ts:110 +msgid "Invalid VLESS URL: parsing failed" +msgstr "" + +#: src/validators/validateSubnet.ts:18 +msgid "IP address 0.0.0.0 is not allowed" +msgstr "" + +#: src/podkop/tabs/diagnostic/helpers/getMeta.ts:20 +msgid "Issues detected" +msgstr "" + +#: src/podkop/tabs/diagnostic/helpers/getPodkopVersionRow.ts:48 +msgid "Latest" +msgstr "" + +#: ../luci-app-podkop/htdocs/luci-static/resources/view/podkop/settings.js:276 +msgid "List Update Frequency" +msgstr "" + +#: ../luci-app-podkop/htdocs/luci-static/resources/view/podkop/section.js:546 +msgid "Local Domain Lists" +msgstr "" + +#: ../luci-app-podkop/htdocs/luci-static/resources/view/podkop/section.js:569 +msgid "Local Subnet Lists" +msgstr "" + +#: ../luci-app-podkop/htdocs/luci-static/resources/view/podkop/settings.js:384 +msgid "Log Level" +msgstr "" + +#: src/podkop/tabs/diagnostic/checks/runDnsCheck.ts:72 +msgid "Main DNS" +msgstr "" + +#: src/podkop/tabs/dashboard/initController.ts:311 +msgid "Memory Usage" +msgstr "" + +#: ../luci-app-podkop/htdocs/luci-static/resources/view/podkop/section.js:678 +msgid "Mixed Proxy Port" +msgstr "" + +#: ../luci-app-podkop/htdocs/luci-static/resources/view/podkop/settings.js:198 +msgid "Monitored Interfaces" +msgstr "" + +#: ../luci-app-podkop/htdocs/luci-static/resources/view/podkop/section.js:164 +msgid "Must be a number in the range of 50 - 1000" +msgstr "" + +#: ../luci-app-podkop/htdocs/luci-static/resources/view/podkop/section.js:208 +msgid "Network Interface" +msgstr "" + +#: src/podkop/tabs/diagnostic/checks/runNftCheck.ts:105 +msgid "No other marking rules found" +msgstr "" + +#: src/podkop/tabs/diagnostic/partials/renderCheckSection.ts:189 +msgid "Not implement yet" +msgstr "" + +#: src/podkop/tabs/diagnostic/checks/runSectionsCheck.ts:75 +#: src/podkop/tabs/diagnostic/checks/runSectionsCheck.ts:81 +#: src/podkop/tabs/diagnostic/checks/runSectionsCheck.ts:100 +msgid "Not responding" +msgstr "" + +#: src/podkop/tabs/diagnostic/diagnostic.store.ts:55 +#: src/podkop/tabs/diagnostic/diagnostic.store.ts:63 +#: src/podkop/tabs/diagnostic/diagnostic.store.ts:71 +#: src/podkop/tabs/diagnostic/diagnostic.store.ts:79 +#: src/podkop/tabs/diagnostic/diagnostic.store.ts:87 +msgid "Not running" +msgstr "" + +#: src/helpers/withTimeout.ts:7 +msgid "Operation timed out" +msgstr "" + +#: ../luci-app-podkop/htdocs/luci-static/resources/view/podkop/section.js:29 +msgid "Outbound Config" +msgstr "" + +#: ../luci-app-podkop/htdocs/luci-static/resources/view/podkop/section.js:65 +msgid "Outbound Configuration" +msgstr "" + +#: src/podkop/tabs/diagnostic/helpers/getPodkopVersionRow.ts:38 +msgid "Outdated" +msgstr "" + +#: ../luci-app-podkop/htdocs/luci-static/resources/view/podkop/settings.js:135 +msgid "Output Network Interface" +msgstr "" + +#: src/validators/validatePath.ts:7 +msgid "Path cannot be empty" +msgstr "" + +#: ../luci-app-podkop/htdocs/luci-static/resources/view/podkop/settings.js:366 +msgid "Path must be absolute (start with /)" +msgstr "" + +#: ../luci-app-podkop/htdocs/luci-static/resources/view/podkop/settings.js:375 +msgid "Path must contain at least one directory (like /tmp/cache.db)" +msgstr "" + +#: ../luci-app-podkop/htdocs/luci-static/resources/view/podkop/settings.js:370 +msgid "Path must end with cache.db" +msgstr "" + +#: src/podkop/tabs/diagnostic/diagnostic.store.ts:103 +#: src/podkop/tabs/diagnostic/diagnostic.store.ts:111 +#: src/podkop/tabs/diagnostic/diagnostic.store.ts:119 +#: src/podkop/tabs/diagnostic/diagnostic.store.ts:127 +#: src/podkop/tabs/diagnostic/diagnostic.store.ts:135 +msgid "Pending" +msgstr "" + +#: src/podkop/tabs/dashboard/initController.ts:343 +msgid "Podkop" +msgstr "" + +#: ../luci-app-podkop/htdocs/luci-static/resources/view/podkop/podkop.js:26 +msgid "Podkop Settings" +msgstr "" + +#: ../luci-app-podkop/htdocs/luci-static/resources/view/podkop/settings.js:327 +msgid "Podkop will not modify your DHCP configuration" +msgstr "" + +#: ../luci-app-podkop/htdocs/luci-static/resources/view/podkop/section.js:36 +msgid "Proxy Configuration URL" +msgstr "" + +#: src/podkop/tabs/diagnostic/checks/runFakeIPCheck.ts:66 +msgid "Proxy traffic is not routed via FakeIP" +msgstr "" + +#: src/podkop/tabs/diagnostic/checks/runFakeIPCheck.ts:65 +msgid "Proxy traffic is routed via FakeIP" +msgstr "" + +#: ../luci-app-podkop/htdocs/luci-static/resources/view/podkop/section.js:333 +msgid "Regional options cannot be used together" +msgstr "" + +#: ../luci-app-podkop/htdocs/luci-static/resources/view/podkop/section.js:592 +msgid "Remote Domain Lists" +msgstr "" + +#: ../luci-app-podkop/htdocs/luci-static/resources/view/podkop/section.js:615 +msgid "Remote Subnet Lists" +msgstr "" + +#: ../luci-app-podkop/htdocs/luci-static/resources/view/podkop/section.js:690 +msgid "Resolve real IP for routing" +msgstr "" + +#: src/podkop/tabs/diagnostic/partials/renderAvailableActions.ts:49 +msgid "Restart podkop" +msgstr "" + +#: src/podkop/tabs/diagnostic/checks/runFakeIPCheck.ts:51 +msgid "Router DNS is not routed through sing-box" +msgstr "" + +#: src/podkop/tabs/diagnostic/checks/runFakeIPCheck.ts:50 +msgid "Router DNS is routed through sing-box" +msgstr "" + +#: ../luci-app-podkop/htdocs/luci-static/resources/view/podkop/settings.js:413 +msgid "Routing Excluded IPs" +msgstr "" + +#: src/podkop/tabs/diagnostic/checks/runNftCheck.ts:79 +msgid "Rules mangle counters" +msgstr "" + +#: src/podkop/tabs/diagnostic/checks/runNftCheck.ts:74 +msgid "Rules mangle exist" +msgstr "" + +#: src/podkop/tabs/diagnostic/checks/runNftCheck.ts:89 +msgid "Rules mangle output counters" +msgstr "" + +#: src/podkop/tabs/diagnostic/checks/runNftCheck.ts:84 +msgid "Rules mangle output exist" +msgstr "" + +#: src/podkop/tabs/diagnostic/checks/runNftCheck.ts:99 +msgid "Rules proxy counters" +msgstr "" + +#: src/podkop/tabs/diagnostic/checks/runNftCheck.ts:94 +msgid "Rules proxy exist" +msgstr "" + +#: src/podkop/tabs/diagnostic/partials/renderRunAction.ts:15 +msgid "Run Diagnostic" +msgstr "" + +#: ../luci-app-podkop/htdocs/luci-static/resources/view/podkop/section.js:352 +msgid "Russia inside restrictions" +msgstr "" + +#: ../luci-app-podkop/htdocs/luci-static/resources/view/podkop/settings.js:257 +msgid "Secret key for authenticating remote access to YACD when WAN access is enabled." +msgstr "" + +#: ../luci-app-podkop/htdocs/luci-static/resources/view/podkop/podkop.js:36 +msgid "Sections" +msgstr "" + +#: ../luci-app-podkop/htdocs/luci-static/resources/view/podkop/section.js:300 +msgid "Select a predefined list for routing" +msgstr "" + +#: ../luci-app-podkop/htdocs/luci-static/resources/view/podkop/section.js:13 +msgid "Select between VPN and Proxy connection methods for traffic routing" +msgstr "" + +#: ../luci-app-podkop/htdocs/luci-static/resources/view/podkop/settings.js:13 +msgid "Select DNS protocol to use" +msgstr "" + +#: ../luci-app-podkop/htdocs/luci-static/resources/view/podkop/settings.js:277 +msgid "Select how often the domain or subnet lists are updated automatically" +msgstr "" + +#: ../luci-app-podkop/htdocs/luci-static/resources/view/podkop/section.js:24 +msgid "Select how to configure the proxy" +msgstr "" + +#: ../luci-app-podkop/htdocs/luci-static/resources/view/podkop/section.js:209 +msgid "Select network interface for VPN connection" +msgstr "" + +#: ../luci-app-podkop/htdocs/luci-static/resources/view/podkop/section.js:278 +#: ../luci-app-podkop/htdocs/luci-static/resources/view/podkop/settings.js:25 +msgid "Select or enter DNS server address" +msgstr "" + +#: ../luci-app-podkop/htdocs/luci-static/resources/view/podkop/settings.js:349 +msgid "Select or enter path for sing-box cache file. Change this ONLY if you know what you are doing" +msgstr "" + +#: ../luci-app-podkop/htdocs/luci-static/resources/view/podkop/settings.js:336 +msgid "Select path for sing-box config file. Change this ONLY if you know what you are doing" +msgstr "" + +#: ../luci-app-podkop/htdocs/luci-static/resources/view/podkop/section.js:265 +msgid "Select the DNS protocol type for the domain resolver" +msgstr "" + +#: ../luci-app-podkop/htdocs/luci-static/resources/view/podkop/section.js:388 +msgid "Select the list type for adding custom domains" +msgstr "" + +#: ../luci-app-podkop/htdocs/luci-static/resources/view/podkop/section.js:468 +msgid "Select the list type for adding custom subnets" +msgstr "" + +#: ../luci-app-podkop/htdocs/luci-static/resources/view/podkop/settings.js:385 +msgid "Select the log level for sing-box" +msgstr "" + +#: ../luci-app-podkop/htdocs/luci-static/resources/view/podkop/settings.js:90 +msgid "Select the network interface from which the traffic will originate" +msgstr "" + +#: ../luci-app-podkop/htdocs/luci-static/resources/view/podkop/settings.js:136 +msgid "Select the network interface to which the traffic will originate" +msgstr "" + +#: ../luci-app-podkop/htdocs/luci-static/resources/view/podkop/settings.js:199 +msgid "Select the WAN interfaces to be monitored" +msgstr "" + +#: ../luci-app-podkop/htdocs/luci-static/resources/view/podkop/section.js:27 +msgid "Selector" +msgstr "" + +#: ../luci-app-podkop/htdocs/luci-static/resources/view/podkop/section.js:88 +msgid "Selector Proxy Links" +msgstr "" + +#: src/podkop/tabs/dashboard/initController.ts:340 +msgid "Services info" +msgstr "" + +#: ../luci-app-podkop/htdocs/luci-static/resources/view/podkop/podkop.js:49 +msgid "Settings" +msgstr "" + +#: src/podkop/tabs/diagnostic/initController.ts:290 +#: src/podkop/tabs/diagnostic/partials/renderAvailableActions.ts:116 +msgid "Show sing-box config" +msgstr "" + +#: src/podkop/tabs/dashboard/initController.ts:354 +msgid "Sing-box" +msgstr "" + +#: src/podkop/tabs/diagnostic/checks/runSingBoxCheck.ts:77 +msgid "Sing-box autostart disabled" +msgstr "" + +#: src/podkop/tabs/diagnostic/checks/runSingBoxCheck.ts:62 +msgid "Sing-box installed" +msgstr "" + +#: src/podkop/tabs/diagnostic/checks/runSingBoxCheck.ts:87 +msgid "Sing-box listening ports" +msgstr "" + +#: src/podkop/tabs/diagnostic/checks/runSingBoxCheck.ts:82 +msgid "Sing-box process running" +msgstr "" + +#: src/podkop/tabs/diagnostic/checks/runSingBoxCheck.ts:72 +msgid "Sing-box service exist" +msgstr "" + +#: src/podkop/tabs/diagnostic/checks/runSingBoxCheck.ts:67 +msgid "Sing-box version is compatible (newer than 1.12.4)" +msgstr "" + +#: ../luci-app-podkop/htdocs/luci-static/resources/view/podkop/settings.js:89 +msgid "Source Network Interface" +msgstr "" + +#: ../luci-app-podkop/htdocs/luci-static/resources/view/podkop/settings.js:414 +msgid "Specify a local IP address to be excluded from routing" +msgstr "" + +#: ../luci-app-podkop/htdocs/luci-static/resources/view/podkop/section.js:639 +msgid "Specify local IP addresses or subnets whose traffic will always be routed through the configured route" +msgstr "" + +#: ../luci-app-podkop/htdocs/luci-static/resources/view/podkop/section.js:593 +msgid "Specify remote URLs to download and use domain lists" +msgstr "" + +#: ../luci-app-podkop/htdocs/luci-static/resources/view/podkop/section.js:616 +msgid "Specify remote URLs to download and use subnet lists" +msgstr "" + +#: ../luci-app-podkop/htdocs/luci-static/resources/view/podkop/section.js:547 +#: ../luci-app-podkop/htdocs/luci-static/resources/view/podkop/section.js:570 +msgid "Specify the path to the list file located on the router filesystem" +msgstr "" + +#: src/podkop/tabs/diagnostic/partials/renderAvailableActions.ts:69 +msgid "Start podkop" +msgstr "" + +#: src/podkop/tabs/diagnostic/partials/renderAvailableActions.ts:59 +msgid "Stop podkop" +msgstr "" + +#: src/helpers/copyToClipboard.ts:10 +msgid "Successfully copied!" +msgstr "" + +#: src/podkop/tabs/dashboard/initController.ts:304 +msgid "System info" +msgstr "" + +#: src/podkop/tabs/diagnostic/partials/renderSystemInfo.ts:21 +msgid "System information" +msgstr "" + +#: src/podkop/tabs/diagnostic/checks/runNftCheck.ts:69 +msgid "Table exist" +msgstr "" + +#: src/podkop/tabs/dashboard/partials/renderSections.ts:108 +msgid "Test latency" +msgstr "" + +#: ../luci-app-podkop/htdocs/luci-static/resources/view/podkop/section.js:392 +#: ../luci-app-podkop/htdocs/luci-static/resources/view/podkop/section.js:472 +msgid "Text List" +msgstr "" + +#: ../luci-app-podkop/htdocs/luci-static/resources/view/podkop/settings.js:46 +msgid "The DNS server used to look up the IP address of an upstream DNS server" +msgstr "" + +#: ../luci-app-podkop/htdocs/luci-static/resources/view/podkop/section.js:135 +msgid "The interval between connectivity tests" +msgstr "" + +#: ../luci-app-podkop/htdocs/luci-static/resources/view/podkop/section.js:148 +msgid "The maximum difference in response times (ms) allowed when comparing servers" +msgstr "" + +#: ../luci-app-podkop/htdocs/luci-static/resources/view/podkop/section.js:171 +msgid "The URL used to test server connectivity" +msgstr "" + +#: ../luci-app-podkop/htdocs/luci-static/resources/view/podkop/settings.js:69 +msgid "Time in seconds for DNS record caching (default: 60)" +msgstr "" + +#: src/podkop/tabs/dashboard/initController.ts:238 +msgid "Traffic" +msgstr "" + +#: src/podkop/tabs/dashboard/initController.ts:268 +msgid "Traffic Total" +msgstr "" + +#: src/podkop/tabs/diagnostic/partials/renderWikiDisclaimer.ts:25 +msgid "Troubleshooting" +msgstr "" + +#: ../luci-app-podkop/htdocs/luci-static/resources/view/podkop/settings.js:80 +msgid "TTL must be a positive number" +msgstr "" + +#: ../luci-app-podkop/htdocs/luci-static/resources/view/podkop/settings.js:75 +msgid "TTL value cannot be empty" +msgstr "" + +#: ../luci-app-podkop/htdocs/luci-static/resources/view/podkop/section.js:269 +#: ../luci-app-podkop/htdocs/luci-static/resources/view/podkop/settings.js:17 +msgid "UDP (Unprotected DNS)" +msgstr "" + +#: ../luci-app-podkop/htdocs/luci-static/resources/view/podkop/section.js:198 +msgid "UDP over TCP" +msgstr "" + +#: src/podkop/tabs/diagnostic/initController.ts:38 +#: src/podkop/tabs/diagnostic/initController.ts:39 +#: src/podkop/tabs/diagnostic/initController.ts:40 +#: src/podkop/tabs/diagnostic/initController.ts:41 +#: src/podkop/tabs/diagnostic/initController.ts:42 +#: src/podkop/tabs/diagnostic/initController.ts:43 +#: src/podkop/tabs/diagnostic/helpers/getPodkopVersionRow.ts:7 +msgid "unknown" +msgstr "" + +#: src/podkop/api.ts:40 +msgid "Unknown error" +msgstr "" + +#: src/podkop/tabs/dashboard/initController.ts:240 +#: src/podkop/tabs/dashboard/initController.ts:271 +msgid "Uplink" +msgstr "" + +#: src/validators/validateProxyUrl.ts:37 +msgid "URL must start with vless://, ss://, trojan://, socks4/5://, or hysteria2://hy2://" +msgstr "" + +#: src/validators/validateUrl.ts:17 +msgid "URL must use one of the following protocols:" +msgstr "" + +#: ../luci-app-podkop/htdocs/luci-static/resources/view/podkop/section.js:28 +msgid "URLTest" +msgstr "" + +#: ../luci-app-podkop/htdocs/luci-static/resources/view/podkop/section.js:134 +msgid "URLTest Check Interval" +msgstr "" + +#: ../luci-app-podkop/htdocs/luci-static/resources/view/podkop/section.js:111 +msgid "URLTest Proxy Links" +msgstr "" + +#: ../luci-app-podkop/htdocs/luci-static/resources/view/podkop/section.js:170 +msgid "URLTest Testing URL" +msgstr "" + +#: ../luci-app-podkop/htdocs/luci-static/resources/view/podkop/section.js:147 +msgid "URLTest Tolerance" +msgstr "" + +#: ../luci-app-podkop/htdocs/luci-static/resources/view/podkop/section.js:387 +msgid "User Domain List Type" +msgstr "" + +#: ../luci-app-podkop/htdocs/luci-static/resources/view/podkop/section.js:399 +msgid "User Domains" +msgstr "" + +#: ../luci-app-podkop/htdocs/luci-static/resources/view/podkop/section.js:425 +msgid "User Domains List" +msgstr "" + +#: ../luci-app-podkop/htdocs/luci-static/resources/view/podkop/section.js:467 +msgid "User Subnet List Type" +msgstr "" + +#: ../luci-app-podkop/htdocs/luci-static/resources/view/podkop/section.js:479 +msgid "User Subnets" +msgstr "" + +#: ../luci-app-podkop/htdocs/luci-static/resources/view/podkop/section.js:505 +msgid "User Subnets List" +msgstr "" + +#: src/validators/validateDns.ts:14 +#: src/validators/validateDns.ts:18 +#: src/validators/validateDomain.ts:13 +#: src/validators/validateDomain.ts:30 +#: src/validators/validateHysteriaUrl.ts:120 +#: src/validators/validateIp.ts:8 +#: src/validators/validateOutboundJson.ts:7 +#: src/validators/validatePath.ts:16 +#: src/validators/validateShadowsocksUrl.ts:95 +#: src/validators/validateSocksUrl.ts:80 +#: src/validators/validateSubnet.ts:38 +#: src/validators/validateTrojanUrl.ts:59 +#: src/validators/validateUrl.ts:28 +#: src/validators/validateVlessUrl.ts:108 +msgid "Valid" +msgstr "" + +#: ../luci-app-podkop/htdocs/luci-static/resources/view/podkop/section.js:458 +#: ../luci-app-podkop/htdocs/luci-static/resources/view/podkop/section.js:537 +msgid "Validation errors:" +msgstr "" + +#: src/podkop/tabs/diagnostic/initController.ts:256 +#: src/podkop/tabs/diagnostic/partials/renderAvailableActions.ts:107 +msgid "View logs" +msgstr "" + +#: src/podkop/tabs/diagnostic/partials/renderWikiDisclaimer.ts:31 +msgid "Visit Wiki" +msgstr "" + +#: ../luci-app-podkop/htdocs/luci-static/resources/view/podkop/section.js:37 +#: ../luci-app-podkop/htdocs/luci-static/resources/view/podkop/section.js:89 +#: ../luci-app-podkop/htdocs/luci-static/resources/view/podkop/section.js:112 +msgid "vless://, ss://, trojan://, socks4/5://, hy2/hysteria2:// links" +msgstr "" + +#: ../luci-app-podkop/htdocs/luci-static/resources/view/podkop/section.js:335 +msgid "Warning: %s cannot be used together with %s. Previous selections have been removed." +msgstr "" + +#: ../luci-app-podkop/htdocs/luci-static/resources/view/podkop/section.js:354 +msgid "Warning: Russia inside can only be used with %s. %s already in Russia inside and have been removed from selection." +msgstr "" + +#: ../luci-app-podkop/htdocs/luci-static/resources/view/podkop/settings.js:256 +msgid "YACD Secret Key" +msgstr "" + +#: ../luci-app-podkop/htdocs/luci-static/resources/view/podkop/settings.js:127 +msgid "You can select Output Network Interface, by default autodetect" +msgstr "" diff --git a/src/frontend/sandbox/root/etc/uci-defaults/50_luci-podkop b/src/frontend/sandbox/root/etc/uci-defaults/50_luci-podkop new file mode 100644 index 0000000..519c358 --- /dev/null +++ b/src/frontend/sandbox/root/etc/uci-defaults/50_luci-podkop @@ -0,0 +1,10 @@ +#!/bin/sh + +rm -f /var/luci-indexcache* +rm -f /tmp/luci-indexcache* + +[ -x /etc/init.d/rpcd ] && /etc/init.d/rpcd reload + +logger -t "podkop" "$timestamp uci-defaults script executed" + +exit 0 \ No newline at end of file diff --git a/src/frontend/sandbox/root/usr/share/luci/menu.d/luci-app-podkop.json b/src/frontend/sandbox/root/usr/share/luci/menu.d/luci-app-podkop.json new file mode 100644 index 0000000..e7e6ffa --- /dev/null +++ b/src/frontend/sandbox/root/usr/share/luci/menu.d/luci-app-podkop.json @@ -0,0 +1,14 @@ +{ + "admin/services/podkop": { + "title": "Podkop", + "order": 42, + "action": { + "type": "view", + "path": "podkop/podkop" + }, + "depends": { + "acl": [ "luci-app-podkop" ], + "uci": { "podkop": true } + } + } +} \ No newline at end of file diff --git a/src/frontend/sandbox/root/usr/share/rpcd/acl.d/luci-app-podkop.json b/src/frontend/sandbox/root/usr/share/rpcd/acl.d/luci-app-podkop.json new file mode 100644 index 0000000..6d0eabc --- /dev/null +++ b/src/frontend/sandbox/root/usr/share/rpcd/acl.d/luci-app-podkop.json @@ -0,0 +1,28 @@ +{ + "luci-app-podkop": { + "description": "Grant UCI and RPC access to LuCI app podkop", + "read": { + "file": { + "/etc/init.d/podkop": [ + "exec" + ], + "/usr/bin/podkop": [ + "exec" + ] + }, + "ubus": { + "service": [ + "list" + ] + }, + "uci": [ + "podkop" + ] + }, + "write": { + "uci": [ + "podkop" + ] + } + } +} \ No newline at end of file diff --git a/src/frontend/sandbox/xgettext.sh b/src/frontend/sandbox/xgettext.sh new file mode 100644 index 0000000..0db78fb --- /dev/null +++ b/src/frontend/sandbox/xgettext.sh @@ -0,0 +1,27 @@ +#!/usr/bin/env bash +set -euo pipefail + +SRC_DIR="htdocs/luci-static/resources/view/podkop" +OUT_POT="po/templates/podkop.pot" +ENCODING="UTF-8" +WIDTH=120 + +mapfile -t FILES < <(find "$SRC_DIR" -type f -name "*.js") +if [ ${#FILES[@]} -eq 0 ]; then + echo "No JS files found in $SRC_DIR" + exit 1 +fi + +mapfile -t FILES < <(printf '%s\n' "${FILES[@]}" | sort) +mkdir -p "$(dirname "$OUT_POT")" + +echo "Generating POT template from JS files in $SRC_DIR" +xgettext --language=JavaScript \ + --keyword=_ \ + --from-code="$ENCODING" \ + --output="$OUT_POT" \ + --width="$WIDTH" \ + --package-name="PODKOP" \ + "${FILES[@]}" + +echo "POT template generated: $OUT_POT" \ No newline at end of file