Merge pull request #222 from itdoginfo/rc/7.x.x

0.7.0
This commit is contained in:
Kirill Sobakin
2025-10-23 14:50:55 +03:00
committed by GitHub
133 changed files with 14787 additions and 6134 deletions

49
.github/workflows/shellcheck.yml vendored Normal file
View File

@@ -0,0 +1,49 @@
name: Differential ShellCheck
on:
push:
branches:
- main
- 'rc/**'
paths:
- 'install.sh'
- 'podkop/files/usr/bin/**'
- 'podkop/files/usr/lib/**'
- '.github/workflows/shellcheck.yml'
pull_request:
branches:
- main
- 'rc/**'
paths:
- 'install.sh'
- 'podkop/files/usr/bin/**'
- 'podkop/files/usr/lib/**'
- '.github/workflows/shellcheck.yml'
permissions:
contents: read
jobs:
shellcheck:
name: Differential ShellCheck
runs-on: ubuntu-24.04
permissions:
contents: read
security-events: write
steps:
- name: Checkout code
uses: actions/checkout@v5.0.0
with:
fetch-depth: 0
- name: Differential ShellCheck
uses: redhat-plumbers-in-action/differential-shellcheck@v5.5.5
with:
severity: error
include-path: |
podkop/files/usr/bin/podkop
podkop/files/usr/lib/**.sh
install.sh
token: ${{ secrets.GITHUB_TOKEN }}

1
.gitignore vendored
View File

@@ -1,3 +1,4 @@
.idea
fe-app-podkop/node_modules
fe-app-podkop/.env
.DS_Store

View File

@@ -24,6 +24,24 @@ https://podkop.net/
sh <(wget -O - https://raw.githubusercontent.com/itdoginfo/podkop/refs/heads/main/install.sh)
```
## Изменения 0.7.0
Начиная с версии 0.7.0 изменена структура конфига `/etc/config/podkop`. Старые значения несовместимы с новыми. Нужно заново настроить Podkop.
Скрипт установки обнаружит старую версию и предупредит вас об этом. Если вы согласитесь, то он сделает автоматически написанное ниже.
При обновлении вручную нужно:
0. Не ныть в issue и чатик.
1. Забэкапить старый конфиг:
```
mv /etc/config/podkop /etc/config/podkop-070
```
2. Стянуть новый дефолтный конфиг:
```
wget -O /etc/config/podkop https://raw.githubusercontent.com/itdoginfo/podkop/refs/heads/main/podkop/files/etc/config/podkop
```
3. Настроить заново ваш Podkop через Luci или UCI.
# ToDo
> [!IMPORTANT]

View File

@@ -1,3 +1,11 @@
## Socks
```
socks4://127.0.0.1:1080
socks4a://127.0.0.1:1080
socks5://127.0.0.1:1080
socks5://username:password@127.0.0.1:1080
```
## Shadowsocks
```
ss://MjAyMi1ibGFrZTMtYWVzLTI1Ni1nY206ZG1DbHkvWmgxNVd3OStzK0dGWGlGVElrcHc3Yy9xQ0lTYUJyYWk3V2hoWT0@127.0.0.1:25144?type=tcp#shadowsocks-no-client

View File

@@ -0,0 +1,16 @@
SFTP_HOST=192.168.160.129
SFTP_PORT=22
SFTP_USER=root
SFTP_PASS=
# you can use key if needed
# SFTP_PRIVATE_KEY=~/.ssh/id_rsa
LOCAL_DIR_FE=../luci-app-podkop/htdocs/luci-static/resources/view/podkop
REMOTE_DIR_FE=/www/luci-static/resources/view/podkop
LOCAL_DIR_BIN=../podkop/files/usr/bin/
REMOTE_DIR_BIN=/usr/bin/
LOCAL_DIR_LIB=../podkop/files/usr/lib/
REMOTE_DIR_LIB=/usr/lib/podkop/

View File

@@ -0,0 +1,38 @@
import fs from 'fs/promises';
import path from 'path';
import { fileURLToPath } from 'url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const sourceDir = path.resolve(__dirname, 'locales');
const targetRoot = path.resolve(__dirname, '../luci-app-podkop/po');
async function main() {
const files = await fs.readdir(sourceDir);
for (const file of files) {
const filePath = path.join(sourceDir, file);
if (file === 'podkop.pot') {
const potTarget = path.join(targetRoot, 'templates', 'podkop.pot');
await fs.mkdir(path.dirname(potTarget), { recursive: true });
await fs.copyFile(filePath, potTarget);
console.log(`✅ Copied POT: ${filePath}${potTarget}`);
}
const match = file.match(/^podkop\.([a-zA-Z_]+)\.po$/);
if (match) {
const lang = match[1];
const poTarget = path.join(targetRoot, lang, 'podkop.po');
await fs.mkdir(path.dirname(poTarget), { recursive: true });
await fs.copyFile(filePath, poTarget);
console.log(`✅ Copied ${lang.toUpperCase()}: ${filePath}${poTarget}`);
}
}
}
main().catch((err) => {
console.error('❌ Ошибка при распространении переводов:', err);
process.exit(1);
});

View File

@@ -0,0 +1,75 @@
import fs from 'fs/promises';
import path from 'path';
import glob from 'fast-glob';
import { parse } from '@babel/parser';
import traverse from '@babel/traverse';
import * as t from '@babel/types';
import { fileURLToPath } from 'url';
import { dirname } from 'path';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
function stripIllegalReturn(code) {
return code.replace(/^\s*return\s+[^;]+;\s*$/gm, (match, offset, input) => {
const after = input.slice(offset + match.length).trim();
return after === '' ? '' : match;
});
}
const files = await glob([
'src/**/*.ts',
'../luci-app-podkop/htdocs/luci-static/resources/view/podkop/**/*.js',
], {
ignore: [
'**/*.test.ts',
'**/main.js',
'../luci-app-podkop/htdocs/luci-static/resources/view/podkop/main.js',
],
absolute: true,
});
const results = {};
for (const file of files) {
const contentRaw = await fs.readFile(file, 'utf8');
const content = stripIllegalReturn(contentRaw);
const relativePath = path.relative(process.cwd(), file);
let ast;
try {
ast = parse(content, {
sourceType: 'module',
plugins: file.endsWith('.ts') ? ['typescript'] : [],
});
} catch (e) {
console.warn(`⚠️ Parse error in ${relativePath}, skipping`);
continue;
}
traverse.default(ast, {
CallExpression(path) {
if (t.isIdentifier(path.node.callee, { name: '_' })) {
const arg = path.node.arguments[0];
if (t.isStringLiteral(arg)) {
const key = arg.value.trim();
if (!key) return; // ❌ пропустить пустые ключи
const location = `${relativePath}:${path.node.loc?.start.line ?? '?'}`;
if (!results[key]) {
results[key] = { call: key, key, places: [] };
}
results[key].places.push(location);
}
}
},
});
}
const outFile = 'locales/calls.json';
const sorted = Object.values(results).sort((a, b) => a.key.localeCompare(b.key)); // 🔤 сортировка по ключу
await fs.mkdir(path.dirname(outFile), { recursive: true });
await fs.writeFile(outFile, JSON.stringify(sorted, null, 2), 'utf8');
console.log(`✅ Extracted ${sorted.length} translations to ${outFile}`);

View File

@@ -0,0 +1,113 @@
import fs from 'fs/promises';
import { execSync } from 'child_process';
const lang = process.argv[2];
if (!lang) {
console.error('❌ Укажи язык, например: node generate-po.js ru');
process.exit(1);
}
const callsPath = 'locales/calls.json';
const poPath = `locales/podkop.${lang}.po`;
function getGitUser() {
try {
return execSync('git config user.name').toString().trim();
} catch {
return 'Automatically generated';
}
}
function getHeader(lang) {
const now = new Date();
const date = now.toISOString().split('T')[0];
const time = now.toTimeString().split(' ')[0].slice(0, 5);
const tzOffset = (() => {
const offset = -now.getTimezoneOffset();
const sign = offset >= 0 ? '+' : '-';
const hours = String(Math.floor(Math.abs(offset) / 60)).padStart(2, '0');
const minutes = String(Math.abs(offset) % 60).padStart(2, '0');
return `${sign}${hours}${minutes}`;
})();
const translator = getGitUser();
const pluralForms = lang === 'ru'
? 'nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2);'
: 'nplurals=2; plural=(n != 1);';
return [
`# ${lang.toUpperCase()} translations for PODKOP package.`,
`# Copyright (C) ${now.getFullYear()} THE PODKOP'S COPYRIGHT HOLDER`,
`# This file is distributed under the same license as the PODKOP package.`,
`# ${translator}, ${now.getFullYear()}.`,
'#',
'msgid ""',
'msgstr ""',
`"Project-Id-Version: PODKOP\\n"`,
`"Report-Msgid-Bugs-To: \\n"`,
`"POT-Creation-Date: ${date} ${time}${tzOffset}\\n"`,
`"PO-Revision-Date: ${date} ${time}${tzOffset}\\n"`,
`"Last-Translator: ${translator}\\n"`,
`"Language-Team: none\\n"`,
`"Language: ${lang}\\n"`,
`"MIME-Version: 1.0\\n"`,
`"Content-Type: text/plain; charset=UTF-8\\n"`,
`"Content-Transfer-Encoding: 8bit\\n"`,
`"Plural-Forms: ${pluralForms}\\n"`,
'',
];
}
function parsePo(content) {
const lines = content.split('\n');
const translations = new Map();
let msgid = null;
let msgstr = null;
for (const line of lines) {
if (line.startsWith('msgid ')) {
msgid = JSON.parse(line.slice(6));
} else if (line.startsWith('msgstr ') && msgid !== null) {
msgstr = JSON.parse(line.slice(7));
translations.set(msgid, msgstr);
msgid = null;
msgstr = null;
}
}
return translations;
}
function escapePoString(str) {
return str.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
}
async function generatePo() {
const [callsRaw, oldPoRaw] = await Promise.all([
fs.readFile(callsPath, 'utf8'),
fs.readFile(poPath, 'utf8').catch(() => ''),
]);
const calls = JSON.parse(callsRaw);
const oldTranslations = parsePo(oldPoRaw);
const header = getHeader(lang);
const body = calls
.map(({ key }) => {
const msgid = key;
const msgstr = oldTranslations.get(msgid) || '';
return [
`msgid "${escapePoString(msgid)}"`,
`msgstr "${escapePoString(msgstr)}"`,
''
].join('\n');
})
.join('\n');
const finalPo = header.join('\n') + '\n' + body;
await fs.writeFile(poPath, finalPo, 'utf8');
console.log(`✅ Файл ${poPath} успешно сгенерирован. Переведено ${[...oldTranslations.keys()].length}/${calls.length}`);
}
generatePo().catch((err) => {
console.error('Ошибка генерации PO файла:', err);
});

View File

@@ -0,0 +1,73 @@
import fs from 'fs/promises';
import { execSync } from 'child_process';
const inputFile = 'locales/calls.json';
const outputFile = 'locales/podkop.pot';
const projectId = 'PODKOP';
function getGitUser() {
const name = execSync('git config user.name').toString().trim();
const email = execSync('git config user.email').toString().trim();
return { name, email };
}
function getPotHeader({ name, email }) {
const now = new Date();
const date = now.toISOString().replace('T', ' ').slice(0, 16);
const offset = -now.getTimezoneOffset();
const sign = offset >= 0 ? '+' : '-';
const hours = String(Math.floor(Math.abs(offset) / 60)).padStart(2, '0');
const minutes = String(Math.abs(offset) % 60).padStart(2, '0');
const timezone = `${sign}${hours}${minutes}`;
return [
'# SOME DESCRIPTIVE TITLE.',
`# Copyright (C) ${now.getFullYear()} THE PACKAGE'S COPYRIGHT HOLDER`,
`# This file is distributed under the same license as the ${projectId} package.`,
`# ${name} <${email}>, ${now.getFullYear()}.`,
'#, fuzzy',
'msgid ""',
'msgstr ""',
`"Project-Id-Version: ${projectId}\\n"`,
`"Report-Msgid-Bugs-To: \\n"`,
`"POT-Creation-Date: ${date}${timezone}\\n"`,
`"PO-Revision-Date: ${date}${timezone}\\n"`,
`"Last-Translator: ${name} <${email}>\\n"`,
`"Language-Team: LANGUAGE <LL@li.org>\\n"`,
`"Language: \\n"`,
`"MIME-Version: 1.0\\n"`,
`"Content-Type: text/plain; charset=UTF-8\\n"`,
`"Content-Transfer-Encoding: 8bit\\n"`,
'',
].join('\n');
}
function escapePoString(str) {
return str.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
}
function generateEntry(item) {
const locations = item.places.map(loc => `#: ${loc}`).join('\n');
const msgid = escapePoString(item.key);
return [
locations,
`msgid "${msgid}"`,
`msgstr ""`,
''
].join('\n');
}
async function generatePot() {
const gitUser = getGitUser();
const raw = await fs.readFile(inputFile, 'utf8');
const entries = JSON.parse(raw);
const header = getPotHeader(gitUser);
const body = entries.map(generateEntry).join('\n');
await fs.writeFile(outputFile, `${header}\n${body}`, 'utf8');
console.log(`✅ POT-файл успешно создан: ${outputFile}`);
}
generatePot().catch(console.error);

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,984 @@
# SOME DESCRIPTIVE TITLE.
# Copyright (C) 2025 THE PACKAGE'S COPYRIGHT HOLDER
# This file is distributed under the same license as the PODKOP package.
# divocat <divocatt@gmail.com>, 2025.
#, fuzzy
msgid ""
msgstr ""
"Project-Id-Version: PODKOP\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-10-21 20:02+0300\n"
"PO-Revision-Date: 2025-10-21 20:02+0300\n"
"Last-Translator: divocat <divocatt@gmail.com>\n"
"Language-Team: LANGUAGE <LL@li.org>\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:342
msgid "✔ Enabled"
msgstr ""
#: src/podkop/tabs/dashboard/initController.ts:353
msgid "✔ Running"
msgstr ""
#: src/podkop/tabs/dashboard/initController.ts:343
msgid "✘ Disabled"
msgstr ""
#: src/podkop/tabs/dashboard/initController.ts:354
msgid "✘ Stopped"
msgstr ""
#: src/podkop/tabs/dashboard/initController.ts:304
msgid "Active Connections"
msgstr ""
#: src/podkop/tabs/diagnostic/checks/runNftCheck.ts:117
msgid "Additional marking rules found"
msgstr ""
#: ../luci-app-podkop/htdocs/luci-static/resources/view/podkop/section.js:111
msgid "Applicable for SOCKS and Shadowsocks proxy"
msgstr ""
#: ../luci-app-podkop/htdocs/luci-static/resources/view/podkop/section.js:356
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:437
msgid "At least one valid subnet or IP must be specified. Comments-only content is not allowed."
msgstr ""
#: src/podkop/tabs/diagnostic/checks/runDnsCheck.ts:72
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:81
msgid "Browser is not using FakeIP"
msgstr ""
#: src/podkop/tabs/diagnostic/checks/runFakeIPCheck.ts:80
msgid "Browser is using FakeIP correctly"
msgstr ""
#: ../luci-app-podkop/htdocs/luci-static/resources/view/podkop/settings.js:322
msgid "Cache File Path"
msgstr ""
#: ../luci-app-podkop/htdocs/luci-static/resources/view/podkop/settings.js:336
msgid "Cache file path cannot be empty"
msgstr ""
#: src/podkop/tabs/diagnostic/checks/runDnsCheck.ts:26
msgid "Cannot receive DNS checks result"
msgstr ""
#: src/podkop/tabs/diagnostic/checks/runNftCheck.ts:27
msgid "Cannot receive nftables checks result"
msgstr ""
#: src/podkop/tabs/diagnostic/checks/runSingBoxCheck.ts:24
msgid "Cannot receive Sing-box checks result"
msgstr ""
#: src/podkop/tabs/diagnostic/checks/runDnsCheck.ts:14
msgid "Checking dns, please wait"
msgstr ""
#: src/podkop/tabs/diagnostic/checks/runFakeIPCheck.ts:14
msgid "Checking FakeIP, please wait"
msgstr ""
#: src/podkop/tabs/diagnostic/checks/runNftCheck.ts:12
msgid "Checking nftables, please wait"
msgstr ""
#: src/podkop/tabs/diagnostic/checks/runSingBoxCheck.ts:12
msgid "Checking sing-box, please wait"
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:211
msgid "Community Lists"
msgstr ""
#: ../luci-app-podkop/htdocs/luci-static/resources/view/podkop/settings.js:309
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:22
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:25
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:215
msgid "Delay in milliseconds before reloading podkop after interface UP"
msgstr ""
#: ../luci-app-podkop/htdocs/luci-static/resources/view/podkop/settings.js:222
msgid "Delay value cannot be empty"
msgstr ""
#: src/podkop/tabs/diagnostic/checks/runDnsCheck.ts:89
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:239
msgid "Disable QUIC"
msgstr ""
#: ../luci-app-podkop/htdocs/luci-static/resources/view/podkop/settings.js:240
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:302
#: ../luci-app-podkop/htdocs/luci-static/resources/view/podkop/section.js:382
msgid "Disabled"
msgstr ""
#: src/podkop/tabs/diagnostic/checks/contstants.ts:14
msgid "DNS checks"
msgstr ""
#: src/podkop/tabs/diagnostic/checks/runDnsCheck.ts:64
msgid "DNS checks passed"
msgstr ""
#: src/podkop/tabs/diagnostic/checks/runDnsCheck.ts:84
msgid "DNS on router"
msgstr ""
#: ../luci-app-podkop/htdocs/luci-static/resources/view/podkop/section.js:179
#: ../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:180
#: ../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:176
#: ../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:189
#: ../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 ""
#: ../luci-app-podkop/htdocs/luci-static/resources/view/podkop/section.js:166
msgid "Domain Resolver"
msgstr ""
#: ../luci-app-podkop/htdocs/luci-static/resources/view/podkop/settings.js:300
msgid "Dont Touch My DHCP!"
msgstr ""
#: src/podkop/tabs/dashboard/initController.ts:238
#: src/podkop/tabs/dashboard/initController.ts:272
msgid "Downlink"
msgstr ""
#: src/partials/modal/renderModal.ts:15
msgid "Download"
msgstr ""
#: ../luci-app-podkop/htdocs/luci-static/resources/view/podkop/settings.js:262
msgid "Download Lists via Proxy/VPN"
msgstr ""
#: ../luci-app-podkop/htdocs/luci-static/resources/view/podkop/settings.js:271
msgid "Download Lists via specific proxy section"
msgstr ""
#: ../luci-app-podkop/htdocs/luci-static/resources/view/podkop/settings.js:263
msgid "Downloading all lists via main Proxy/VPN"
msgstr ""
#: ../luci-app-podkop/htdocs/luci-static/resources/view/podkop/settings.js:272
msgid "Downloading all lists via specific Proxy/VPN"
msgstr ""
#: ../luci-app-podkop/htdocs/luci-static/resources/view/podkop/section.js:303
#: ../luci-app-podkop/htdocs/luci-static/resources/view/podkop/section.js:383
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:167
msgid "Enable built-in DNS resolver for domains handled by this section"
msgstr ""
#: ../luci-app-podkop/htdocs/luci-static/resources/view/podkop/section.js:575
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:576
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:230
msgid "Enable YACD"
msgstr ""
#: ../luci-app-podkop/htdocs/luci-static/resources/view/podkop/section.js:65
msgid "Enter complete outbound configuration in JSON format"
msgstr ""
#: ../luci-app-podkop/htdocs/luci-static/resources/view/podkop/section.js:338
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:312
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:392
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/settings.js:358
msgid "Exclude NTP"
msgstr ""
#: ../luci-app-podkop/htdocs/luci-static/resources/view/podkop/settings.js:359
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/checks/contstants.ts:29
msgid "FakeIP checks"
msgstr ""
#: src/podkop/tabs/diagnostic/checks/runFakeIPCheck.ts:57
msgid "FakeIP checks failed"
msgstr ""
#: src/podkop/tabs/diagnostic/checks/runFakeIPCheck.ts:51
msgid "FakeIP checks partially passed"
msgstr ""
#: src/podkop/tabs/diagnostic/checks/runFakeIPCheck.ts:44
msgid "FakeIP checks passed"
msgstr ""
#: src/podkop/methods/custom/getDashboardSections.ts:117
msgid "Fastest"
msgstr ""
#: ../luci-app-podkop/htdocs/luci-static/resources/view/podkop/section.js:550
msgid "Fully Routed IPs"
msgstr ""
#: src/podkop/tabs/diagnostic/partials/renderAvailableActions.ts:98
msgid "Get global check"
msgstr ""
#: src/podkop/tabs/diagnostic/initController.ts:218
msgid "Global check"
msgstr ""
#: src/podkop/api.ts:27
msgid "HTTP error"
msgstr ""
#: ../luci-app-podkop/htdocs/luci-static/resources/view/podkop/settings.js:182
msgid "Interface Monitoring"
msgstr ""
#: ../luci-app-podkop/htdocs/luci-static/resources/view/podkop/settings.js:214
msgid "Interface Monitoring Delay"
msgstr ""
#: ../luci-app-podkop/htdocs/luci-static/resources/view/podkop/settings.js:183
msgid "Interface monitoring for Bad WAN"
msgstr ""
#: src/validators/validateDns.ts:20
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/validateIp.ts:11
msgid "Invalid IP address"
msgstr ""
#: src/validators/validateOutboundJson.ts:19
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:18
msgid "Invalid URL format"
msgstr ""
#: src/validators/validateVlessUrl.ts:109
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/initController.ts:404
msgid "Latest"
msgstr ""
#: ../luci-app-podkop/htdocs/luci-static/resources/view/podkop/settings.js:250
msgid "List Update Frequency"
msgstr ""
#: ../luci-app-podkop/htdocs/luci-static/resources/view/podkop/section.js:458
msgid "Local Domain Lists"
msgstr ""
#: ../luci-app-podkop/htdocs/luci-static/resources/view/podkop/section.js:481
msgid "Local Subnet Lists"
msgstr ""
#: src/podkop/tabs/diagnostic/checks/runDnsCheck.ts:79
msgid "Main DNS"
msgstr ""
#: src/podkop/tabs/dashboard/initController.ts:308
msgid "Memory Usage"
msgstr ""
#: ../luci-app-podkop/htdocs/luci-static/resources/view/podkop/section.js:586
msgid "Mixed Proxy Port"
msgstr ""
#: ../luci-app-podkop/htdocs/luci-static/resources/view/podkop/settings.js:191
msgid "Monitored Interfaces"
msgstr ""
#: ../luci-app-podkop/htdocs/luci-static/resources/view/podkop/section.js:120
msgid "Network Interface"
msgstr ""
#: src/podkop/tabs/diagnostic/checks/contstants.ts:24
msgid "Nftables checks"
msgstr ""
#: src/podkop/tabs/diagnostic/checks/runNftCheck.ts:75
msgid "Nftables checks partially passed"
msgstr ""
#: src/podkop/tabs/diagnostic/checks/runNftCheck.ts:74
msgid "Nftables checks passed"
msgstr ""
#: src/podkop/tabs/diagnostic/checks/runNftCheck.ts:116
msgid "No other marking rules found"
msgstr ""
#: src/podkop/tabs/diagnostic/partials/renderCheckSection.ts:189
msgid "Not implement yet"
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
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:26
msgid "Outbound Config"
msgstr ""
#: ../luci-app-podkop/htdocs/luci-static/resources/view/podkop/section.js:64
msgid "Outbound Configuration"
msgstr ""
#: src/validators/validateOutboundJson.ts:11
msgid "Outbound JSON must contain at least \"type\", \"server\" and \"server_port\" fields"
msgstr ""
#: src/podkop/tabs/diagnostic/initController.ts:394
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:340
msgid "Path must be absolute (start with /)"
msgstr ""
#: ../luci-app-podkop/htdocs/luci-static/resources/view/podkop/settings.js:349
msgid "Path must contain at least one directory (like /tmp/cache.db)"
msgstr ""
#: ../luci-app-podkop/htdocs/luci-static/resources/view/podkop/settings.js:344
msgid "Path must end with cache.db"
msgstr ""
#: src/podkop/tabs/dashboard/initController.ts:340
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:301
msgid "Podkop will not modify your DHCP configuration"
msgstr ""
#: ../luci-app-podkop/htdocs/luci-static/resources/view/podkop/section.js:34
msgid "Proxy Configuration URL"
msgstr ""
#: src/podkop/tabs/diagnostic/checks/runFakeIPCheck.ts:89
msgid "Proxy traffic is not routed via FakeIP"
msgstr ""
#: src/podkop/tabs/diagnostic/checks/runFakeIPCheck.ts:88
msgid "Proxy traffic is routed via FakeIP"
msgstr ""
#: src/podkop/tabs/diagnostic/diagnostic.store.ts:95
#: src/podkop/tabs/diagnostic/diagnostic.store.ts:103
#: src/podkop/tabs/diagnostic/diagnostic.store.ts:111
#: src/podkop/tabs/diagnostic/diagnostic.store.ts:119
msgid "Queued"
msgstr ""
#: ../luci-app-podkop/htdocs/luci-static/resources/view/podkop/section.js:245
msgid "Regional options cannot be used together"
msgstr ""
#: ../luci-app-podkop/htdocs/luci-static/resources/view/podkop/section.js:504
msgid "Remote Domain Lists"
msgstr ""
#: ../luci-app-podkop/htdocs/luci-static/resources/view/podkop/section.js:527
msgid "Remote Subnet Lists"
msgstr ""
#: src/podkop/tabs/diagnostic/partials/renderAvailableActions.ts:49
msgid "Restart podkop"
msgstr ""
#: src/podkop/tabs/diagnostic/checks/runFakeIPCheck.ts:74
msgid "Router DNS is not routed through sing-box"
msgstr ""
#: src/podkop/tabs/diagnostic/checks/runFakeIPCheck.ts:73
msgid "Router DNS is routed through sing-box"
msgstr ""
#: ../luci-app-podkop/htdocs/luci-static/resources/view/podkop/settings.js:369
msgid "Routing Excluded IPs"
msgstr ""
#: src/podkop/tabs/diagnostic/checks/runNftCheck.ts:90
msgid "Rules mangle counters"
msgstr ""
#: src/podkop/tabs/diagnostic/checks/runNftCheck.ts:85
msgid "Rules mangle exist"
msgstr ""
#: src/podkop/tabs/diagnostic/checks/runNftCheck.ts:100
msgid "Rules mangle output counters"
msgstr ""
#: src/podkop/tabs/diagnostic/checks/runNftCheck.ts:95
msgid "Rules mangle output exist"
msgstr ""
#: src/podkop/tabs/diagnostic/checks/runNftCheck.ts:110
msgid "Rules proxy counters"
msgstr ""
#: src/podkop/tabs/diagnostic/checks/runNftCheck.ts:105
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:264
msgid "Russia inside restrictions"
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:212
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:251
msgid "Select how often the domain or subnet lists are updated automatically"
msgstr ""
#: ../luci-app-podkop/htdocs/luci-static/resources/view/podkop/section.js:23
msgid "Select how to configure the proxy"
msgstr ""
#: ../luci-app-podkop/htdocs/luci-static/resources/view/podkop/section.js:121
msgid "Select network interface for VPN connection"
msgstr ""
#: ../luci-app-podkop/htdocs/luci-static/resources/view/podkop/section.js:190
#: ../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:323
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:310
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:177
msgid "Select the DNS protocol type for the domain resolver"
msgstr ""
#: ../luci-app-podkop/htdocs/luci-static/resources/view/podkop/section.js:300
msgid "Select the list type for adding custom domains"
msgstr ""
#: ../luci-app-podkop/htdocs/luci-static/resources/view/podkop/section.js:380
msgid "Select the list type for adding custom subnets"
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:192
msgid "Select the WAN interfaces to be monitored"
msgstr ""
#: src/podkop/tabs/dashboard/initController.ts:337
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:278
#: src/podkop/tabs/diagnostic/partials/renderAvailableActions.ts:116
msgid "Show sing-box config"
msgstr ""
#: src/podkop/tabs/dashboard/initController.ts:351
msgid "Sing-box"
msgstr ""
#: src/podkop/tabs/diagnostic/checks/runSingBoxCheck.ts:86
msgid "Sing-box autostart disabled"
msgstr ""
#: src/podkop/tabs/diagnostic/checks/contstants.ts:19
msgid "Sing-box checks"
msgstr ""
#: src/podkop/tabs/diagnostic/checks/runSingBoxCheck.ts:66
msgid "Sing-box checks passed"
msgstr ""
#: src/podkop/tabs/diagnostic/checks/runSingBoxCheck.ts:71
msgid "Sing-box installed"
msgstr ""
#: src/podkop/tabs/diagnostic/checks/runSingBoxCheck.ts:96
msgid "Sing-box listening ports"
msgstr ""
#: src/podkop/tabs/diagnostic/checks/runSingBoxCheck.ts:91
msgid "Sing-box process running"
msgstr ""
#: src/podkop/tabs/diagnostic/checks/runSingBoxCheck.ts:81
msgid "Sing-box service exist"
msgstr ""
#: src/podkop/tabs/diagnostic/checks/runSingBoxCheck.ts:76
msgid "Sing-box version >= 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:370
msgid "Specify a local IP address to be excluded from routing"
msgstr ""
#: ../luci-app-podkop/htdocs/luci-static/resources/view/podkop/section.js:551
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:505
msgid "Specify remote URLs to download and use domain lists"
msgstr ""
#: ../luci-app-podkop/htdocs/luci-static/resources/view/podkop/section.js:528
msgid "Specify remote URLs to download and use subnet lists"
msgstr ""
#: ../luci-app-podkop/htdocs/luci-static/resources/view/podkop/section.js:459
#: ../luci-app-podkop/htdocs/luci-static/resources/view/podkop/section.js:482
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:301
msgid "System info"
msgstr ""
#: src/podkop/tabs/diagnostic/checks/runNftCheck.ts:80
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:304
msgid "Text List"
msgstr ""
#: ../luci-app-podkop/htdocs/luci-static/resources/view/podkop/section.js:384
msgid "Text List (comma/space/newline separated)"
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/settings.js:69
msgid "Time in seconds for DNS record caching (default: 60)"
msgstr ""
#: src/podkop/tabs/dashboard/initController.ts:235
msgid "Traffic"
msgstr ""
#: src/podkop/tabs/dashboard/initController.ts:265
msgid "Traffic Total"
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:181
#: ../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:110
msgid "UDP over TCP"
msgstr ""
#: src/podkop/tabs/diagnostic/initController.ts:34
#: src/podkop/tabs/diagnostic/initController.ts:35
#: src/podkop/tabs/diagnostic/initController.ts:36
#: src/podkop/tabs/diagnostic/initController.ts:37
#: src/podkop/tabs/diagnostic/initController.ts:38
#: src/podkop/tabs/diagnostic/initController.ts:39
#: src/podkop/tabs/diagnostic/initController.ts:373
msgid "unknown"
msgstr ""
#: src/podkop/api.ts:40
msgid "Unknown error"
msgstr ""
#: src/podkop/tabs/dashboard/initController.ts:237
#: src/podkop/tabs/dashboard/initController.ts:268
msgid "Uplink"
msgstr ""
#: src/validators/validateProxyUrl.ts:27
msgid "URL must start with vless://, ss://, trojan://, or socks4/5://"
msgstr ""
#: src/validators/validateUrl.ts:13
msgid "URL must use one of the following protocols:"
msgstr ""
#: ../luci-app-podkop/htdocs/luci-static/resources/view/podkop/section.js:27
msgid "URLTest"
msgstr ""
#: ../luci-app-podkop/htdocs/luci-static/resources/view/podkop/section.js:87
msgid "URLTest Proxy Links"
msgstr ""
#: ../luci-app-podkop/htdocs/luci-static/resources/view/podkop/section.js:299
msgid "User Domain List Type"
msgstr ""
#: ../luci-app-podkop/htdocs/luci-static/resources/view/podkop/section.js:311
msgid "User Domains"
msgstr ""
#: ../luci-app-podkop/htdocs/luci-static/resources/view/podkop/section.js:337
msgid "User Domains List"
msgstr ""
#: ../luci-app-podkop/htdocs/luci-static/resources/view/podkop/section.js:379
msgid "User Subnet List Type"
msgstr ""
#: ../luci-app-podkop/htdocs/luci-static/resources/view/podkop/section.js:391
msgid "User Subnets"
msgstr ""
#: ../luci-app-podkop/htdocs/luci-static/resources/view/podkop/section.js:417
msgid "User Subnets List"
msgstr ""
#: src/validators/validateDns.ts:11
#: src/validators/validateDns.ts:15
#: src/validators/validateDomain.ts:13
#: src/validators/validateDomain.ts:30
#: src/validators/validateIp.ts:8
#: src/validators/validateOutboundJson.ts:17
#: 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:16
#: src/validators/validateVlessUrl.ts:107
msgid "Valid"
msgstr ""
#: ../luci-app-podkop/htdocs/luci-static/resources/view/podkop/section.js:370
#: ../luci-app-podkop/htdocs/luci-static/resources/view/podkop/section.js:449
msgid "Validation errors:"
msgstr ""
#: src/podkop/tabs/diagnostic/initController.ts:248
#: src/podkop/tabs/diagnostic/partials/renderAvailableActions.ts:107
msgid "View logs"
msgstr ""
#: ../luci-app-podkop/htdocs/luci-static/resources/view/podkop/section.js:247
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:266
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:127
msgid "You can select Output Network Interface, by default autodetect"
msgstr ""

View File

@@ -0,0 +1,714 @@
# RU translations for PODKOP package.
# Copyright (C) 2025 THE PODKOP'S COPYRIGHT HOLDER
# This file is distributed under the same license as the PODKOP package.
# divocat, 2025.
#
msgid ""
msgstr ""
"Project-Id-Version: PODKOP\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-10-21 23:02+0300\n"
"PO-Revision-Date: 2025-10-21 23:02+0300\n"
"Last-Translator: divocat\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 "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 "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 DNS checks result"
msgstr "Не удалось получить результаты проверки DNS"
msgid "Cannot receive nftables checks result"
msgstr "Не удалось получить результаты проверки nftables"
msgid "Cannot receive Sing-box checks result"
msgstr "Не удалось получить результаты проверки Sing-box"
msgid "Checking dns, please wait"
msgstr "Проверка dns, пожалуйста подождите"
msgid "Checking FakeIP, please wait"
msgstr "Проверка FakeIP, пожалуйста подождите"
msgid "Checking nftables, please wait"
msgstr "Проверка nftables, пожалуйста подождите"
msgid "Checking sing-box, please wait"
msgstr "Проверка sing-box, пожалуйста подождите"
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 checks"
msgstr "DNS проверки"
msgid "DNS checks passed"
msgstr "DNS проверки успешно завершены"
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 "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 main Proxy/VPN"
msgstr "Загрузка всех списков через основной прокси/VPN"
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 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 "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 "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 "FakeIP checks"
msgstr "Проверка FakeIP"
msgid "FakeIP checks failed"
msgstr "Проверки FakeIP не пройдены"
msgid "FakeIP checks partially passed"
msgstr "Проверка FakeIP частично пройдена"
msgid "FakeIP checks passed"
msgstr "Проверки FakeIP пройдены"
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 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 "Latest"
msgstr "Последняя"
msgid "List Update Frequency"
msgstr "Частота обновления списков"
msgid "Local Domain Lists"
msgstr "Локальные списки доменов"
msgid "Local Subnet Lists"
msgstr "Локальные списки подсетей"
msgid "Main DNS"
msgstr "Основной DNS"
msgid "Memory Usage"
msgstr "Использование памяти"
msgid "Mixed Proxy Port"
msgstr "Порт смешанного прокси"
msgid "Monitored Interfaces"
msgstr "Наблюдаемые интерфейсы"
msgid "Network Interface"
msgstr "Сетевой интерфейс"
msgid "Nftables checks"
msgstr "Проверки Nftables"
msgid "Nftables checks partially passed"
msgstr "Проверки Nftables частично пройдена"
msgid "Nftables checks passed"
msgstr "Nftables проверки успешно завершены"
msgid "No other marking rules found"
msgstr "Другие правила маркировки не найдены"
msgid "Not implement yet"
msgstr "Ещё не реализовано"
msgid "Not running"
msgstr "Не запущено"
msgid "Operation timed out"
msgstr "Время ожидания истекло"
msgid "Outbound Config"
msgstr "Конфигурация Outbound"
msgid "Outbound Configuration"
msgstr "Конфигурация исходящего соединения"
msgid "Outbound JSON must contain at least \"type\", \"server\" and \"server_port\" fields"
msgstr "JSON должен содержать поля \"type\", \"server\" и \"server_port\""
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 "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 "Queued"
msgstr "В очереди"
msgid "Regional options cannot be used together"
msgstr "Нельзя использовать несколько региональных опций одновременно"
msgid "Remote Domain Lists"
msgstr "Удалённые списки доменов"
msgid "Remote Subnet Lists"
msgstr "Удалённые списки подсетей"
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 "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 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 "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 checks"
msgstr "Sing-box проверки"
msgid "Sing-box checks passed"
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 >= 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 "Table exist"
msgstr "Таблица существует"
msgid "Test latency"
msgstr "Измерить задержки"
msgid "Text List"
msgstr "Текстовый список"
msgid "Text List (comma/space/newline separated)"
msgstr "Текстовый список (через запятую, пробел или новую строку)"
msgid "The DNS server used to look up the IP address of an upstream DNS server"
msgstr "DNS-сервер, используемый для поиска IP-адреса вышестоящего DNS-сервера"
msgid "Time in seconds for DNS record caching (default: 60)"
msgstr "Время в секундах для кэширования DNS записей (по умолчанию: 60)"
msgid "Traffic"
msgstr "Трафик"
msgid "Traffic Total"
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://, or socks4/5://"
msgstr "URL должен начинаться с vless://, ss://, trojan:// или socks4/5://"
msgid "URL must use one of the following protocols:"
msgstr "URL должен использовать один из следующих протоколов:"
msgid "URLTest"
msgstr "URLTest"
msgid "URLTest Proxy Links"
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 "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 "You can select Output Network Interface, by default autodetect"
msgstr "Вы можете выбрать выходной сетевой интерфейс, по умолчанию он определяется автоматически."

View File

@@ -5,21 +5,30 @@
"type": "module",
"scripts": {
"format": "prettier --write src",
"format:js": "prettier --write ../luci-app-podkop/htdocs/luci-static/resources/view/podkop",
"lint": "eslint src --ext .ts,.tsx",
"lint:fix": "eslint src --ext .ts,.tsx --fix",
"build": "tsup src/main.ts",
"dev": "tsup src/main.ts --watch",
"test": "vitest",
"ci": "yarn format && yarn lint --max-warnings=0 && yarn test --run && yarn build",
"watch:sftp": "node watch-upload.js"
"watch:sftp": "node watch-upload.js",
"locales:exctract-calls": "node extract-calls.js",
"locales:generate-pot": "node generate-pot.js",
"locales:generate-po:ru": "node generate-po.js ru",
"locales:distribute": "node distribute-locales.js",
"locales:actualize": "yarn locales:exctract-calls && yarn locales:generate-pot && yarn locales:generate-po:ru && yarn locales:distribute"
},
"devDependencies": {
"@babel/parser": "7.28.4",
"@babel/traverse": "7.28.4",
"@typescript-eslint/eslint-plugin": "8.45.0",
"@typescript-eslint/parser": "8.45.0",
"chokidar": "4.0.3",
"dotenv": "17.2.3",
"eslint": "9.36.0",
"eslint-config-prettier": "10.1.8",
"fast-glob": "3.3.3",
"glob": "11.0.3",
"prettier": "3.6.2",
"ssh2-sftp-client": "12.0.1",

View File

@@ -1,2 +0,0 @@
export * from './types';
export * from './methods';

View File

@@ -1,28 +0,0 @@
import { IBaseApiResponse } from '../types';
export async function createBaseApiRequest<T>(
fetchFn: () => Promise<Response>,
): Promise<IBaseApiResponse<T>> {
try {
const response = await fetchFn();
if (!response.ok) {
return {
success: false as const,
message: `${_('HTTP error')} ${response.status}: ${response.statusText}`,
};
}
const data: T = await response.json();
return {
success: true as const,
data,
};
} catch (e) {
return {
success: false as const,
message: e instanceof Error ? e.message : _('Unknown error'),
};
}
}

View File

@@ -1,14 +0,0 @@
import { ClashAPI, IBaseApiResponse } from '../types';
import { createBaseApiRequest } from './createBaseApiRequest';
import { getClashApiUrl } from '../../helpers';
export async function getClashConfig(): Promise<
IBaseApiResponse<ClashAPI.Config>
> {
return createBaseApiRequest<ClashAPI.Config>(() =>
fetch(`${getClashApiUrl()}/configs`, {
method: 'GET',
headers: { 'Content-Type': 'application/json' },
}),
);
}

View File

@@ -1,20 +0,0 @@
import { ClashAPI, IBaseApiResponse } from '../types';
import { createBaseApiRequest } from './createBaseApiRequest';
import { getClashApiUrl } from '../../helpers';
export async function getClashGroupDelay(
group: string,
url = 'https://www.gstatic.com/generate_204',
timeout = 2000,
): Promise<IBaseApiResponse<ClashAPI.Delays>> {
const endpoint = `${getClashApiUrl()}/group/${group}/delay?url=${encodeURIComponent(
url,
)}&timeout=${timeout}`;
return createBaseApiRequest<ClashAPI.Delays>(() =>
fetch(endpoint, {
method: 'GET',
headers: { 'Content-Type': 'application/json' },
}),
);
}

View File

@@ -1,14 +0,0 @@
import { ClashAPI, IBaseApiResponse } from '../types';
import { createBaseApiRequest } from './createBaseApiRequest';
import { getClashApiUrl } from '../../helpers';
export async function getClashProxies(): Promise<
IBaseApiResponse<ClashAPI.Proxies>
> {
return createBaseApiRequest<ClashAPI.Proxies>(() =>
fetch(`${getClashApiUrl()}/proxies`, {
method: 'GET',
headers: { 'Content-Type': 'application/json' },
}),
);
}

View File

@@ -1,14 +0,0 @@
import { ClashAPI, IBaseApiResponse } from '../types';
import { createBaseApiRequest } from './createBaseApiRequest';
import { getClashApiUrl } from '../../helpers';
export async function getClashVersion(): Promise<
IBaseApiResponse<ClashAPI.Version>
> {
return createBaseApiRequest<ClashAPI.Version>(() =>
fetch(`${getClashApiUrl()}/version`, {
method: 'GET',
headers: { 'Content-Type': 'application/json' },
}),
);
}

View File

@@ -1,7 +0,0 @@
export * from './createBaseApiRequest';
export * from './getConfig';
export * from './getGroupDelay';
export * from './getProxies';
export * from './getVersion';
export * from './triggerProxySelector';
export * from './triggerLatencyTest';

View File

@@ -1,35 +0,0 @@
import { IBaseApiResponse } from '../types';
import { createBaseApiRequest } from './createBaseApiRequest';
import { getClashApiUrl } from '../../helpers';
export async function triggerLatencyGroupTest(
tag: string,
timeout: number = 5000,
url: string = 'https://www.gstatic.com/generate_204',
): Promise<IBaseApiResponse<void>> {
return createBaseApiRequest<void>(() =>
fetch(
`${getClashApiUrl()}/group/${tag}/delay?url=${encodeURIComponent(url)}&timeout=${timeout}`,
{
method: 'GET',
headers: { 'Content-Type': 'application/json' },
},
),
);
}
export async function triggerLatencyProxyTest(
tag: string,
timeout: number = 2000,
url: string = 'https://www.gstatic.com/generate_204',
): Promise<IBaseApiResponse<void>> {
return createBaseApiRequest<void>(() =>
fetch(
`${getClashApiUrl()}/proxies/${tag}/delay?url=${encodeURIComponent(url)}&timeout=${timeout}`,
{
method: 'GET',
headers: { 'Content-Type': 'application/json' },
},
),
);
}

View File

@@ -1,16 +0,0 @@
import { IBaseApiResponse } from '../types';
import { createBaseApiRequest } from './createBaseApiRequest';
import { getClashApiUrl } from '../../helpers';
export async function triggerProxySelector(
selector: string,
outbound: string,
): Promise<IBaseApiResponse<void>> {
return createBaseApiRequest<void>(() =>
fetch(`${getClashApiUrl()}/proxies/${selector}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: outbound }),
}),
);
}

View File

@@ -1,53 +0,0 @@
export type IBaseApiResponse<T> =
| {
success: true;
data: T;
}
| {
success: false;
message: string;
};
// eslint-disable-next-line @typescript-eslint/no-namespace
export namespace ClashAPI {
export interface Version {
meta: boolean;
premium: boolean;
version: string;
}
export interface Config {
port: number;
'socks-port': number;
'redir-port': number;
'tproxy-port': number;
'mixed-port': number;
'allow-lan': boolean;
'bind-address': string;
mode: 'Rule' | 'Global' | 'Direct';
'mode-list': string[];
'log-level': 'debug' | 'info' | 'warn' | 'error';
ipv6: boolean;
tun: null | Record<string, unknown>;
}
export interface ProxyHistoryEntry {
time: string;
delay: number;
}
export interface ProxyBase {
type: string;
name: string;
udp: boolean;
history: ProxyHistoryEntry[];
now?: string;
all?: string[];
}
export interface Proxies {
proxies: Record<string, ProxyBase>;
}
export type Delays = Record<string, number>;
}

View File

@@ -0,0 +1,16 @@
import { showToast } from './showToast';
export function copyToClipboard(text: string) {
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);
}

View File

@@ -0,0 +1,15 @@
export function downloadAsTxt(text: string, filename: string) {
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);
}

View File

@@ -1,4 +0,0 @@
export function getBaseUrl(): string {
const { protocol, hostname } = window.location;
return `${protocol}//${hostname}`;
}

View File

@@ -1,9 +1,3 @@
export function getClashApiUrl(): string {
const { hostname } = window.location;
return `http://${hostname}:9090`;
}
export function getClashWsUrl(): string {
const { hostname } = window.location;

View File

@@ -1,4 +1,3 @@
export * from './getBaseUrl';
export * from './parseValueList';
export * from './injectGlobalStyles';
export * from './withTimeout';
@@ -10,3 +9,5 @@ export * from './getClashApiUrl';
export * from './splitProxyString';
export * from './preserveScrollForPage';
export * from './parseQueryString';
export * from './svgEl';
export * from './insertIf';

View File

@@ -0,0 +1,7 @@
export function insertIf<T>(condition: boolean, elements: Array<T>) {
return condition ? elements : ([] as Array<T>);
}
export function insertIfObj<T>(condition: boolean, object: T) {
return condition ? object : ({} as T);
}

View File

@@ -0,0 +1,7 @@
export function normalizeCompiledVersion(version: string) {
if (version.includes('COMPILED')) {
return 'dev';
}
return version;
}

View File

@@ -0,0 +1,24 @@
export function showToast(
message: string,
type: 'success' | 'error',
duration: number = 3000,
) {
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);
}

View File

@@ -0,0 +1,18 @@
export function svgEl<K extends keyof SVGElementTagNameMap>(
tag: K,
attrs: Partial<Record<string, string | number>> = {},
children: (SVGElement | null | undefined)[] = [],
): SVGElementTagNameMap[K] {
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 as SVGElement));
return el;
}

View File

@@ -1,3 +1,5 @@
import { logger } from '../podkop';
export async function withTimeout<T>(
promise: Promise<T>,
timeoutMs: number,
@@ -16,6 +18,6 @@ export async function withTimeout<T>(
} finally {
clearTimeout(timeoutId);
const elapsed = performance.now() - start;
console.log(`[${operationName}] Execution time: ${elapsed.toFixed(2)} ms`);
logger.info('[SHELL]', `[${operationName}] took ${elapsed.toFixed(2)} ms`);
}
}

View File

@@ -0,0 +1,17 @@
export * from './renderLoaderCircleIcon24';
export * from './renderCircleAlertIcon24';
export * from './renderCircleCheckIcon24';
export * from './renderCircleSlashIcon24';
export * from './renderCircleXIcon24';
export * from './renderCheckIcon24';
export * from './renderXIcon24';
export * from './renderTriangleAlertIcon24';
export * from './renderPauseIcon24';
export * from './renderPlayIcon24';
export * from './renderRotateCcwIcon24';
export * from './renderCircleStopIcon24';
export * from './renderCirclePlayIcon24';
export * from './renderCircleCheckBigIcon24';
export * from './renderSquareChartGanttIcon24';
export * from './renderCogIcon24';
export * from './renderSearchIcon24';

View File

@@ -0,0 +1,23 @@
import { svgEl } from '../helpers';
export 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',
}),
],
);
}

View File

@@ -0,0 +1,39 @@
import { svgEl } from '../helpers';
export 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',
}),
],
);
}

View File

@@ -0,0 +1,26 @@
import { svgEl } from '../helpers';
export 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',
}),
],
);
}

View File

@@ -0,0 +1,30 @@
import { svgEl } from '../helpers';
export 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',
}),
],
);
}

View File

@@ -0,0 +1,28 @@
import { svgEl } from '../helpers';
export 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',
}),
],
);
}

View File

@@ -0,0 +1,33 @@
import { svgEl } from '../helpers';
export 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',
}),
],
);
}

View File

@@ -0,0 +1,32 @@
import { svgEl } from '../helpers';
export 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',
}),
],
);
}

View File

@@ -0,0 +1,33 @@
import { svgEl } from '../helpers';
export 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',
}),
],
);
}

View File

@@ -0,0 +1,34 @@
import { svgEl } from '../helpers';
export 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' }),
],
);
}

View File

@@ -0,0 +1,32 @@
import { svgEl } from '../helpers';
export 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',
}),
],
);
}

View File

@@ -0,0 +1,34 @@
import { svgEl } from '../helpers';
export 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',
}),
],
);
}

View File

@@ -0,0 +1,23 @@
import { svgEl } from '../helpers';
export 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',
}),
],
);
}

View File

@@ -0,0 +1,26 @@
import { svgEl } from '../helpers';
export 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',
}),
],
);
}

View File

@@ -0,0 +1,22 @@
import { svgEl } from '../helpers';
export 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' }),
],
);
}

View File

@@ -0,0 +1,30 @@
import { svgEl } from '../helpers';
export 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' }),
],
);
}

View File

@@ -0,0 +1,25 @@
import { svgEl } from '../helpers';
export 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' }),
],
);
}

View File

@@ -0,0 +1,19 @@
import { svgEl } from '../helpers';
export 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' })],
);
}

View File

@@ -35,6 +35,16 @@ declare global {
};
const _ = (_key: string) => string;
const ui = {
showModal: (_title: stirng, _content: HtmlElement) => undefined,
hideModal: () => undefined,
addNotification: (
_title: string,
_children: HtmlElement | HtmlElement[],
_className?: string,
) => undefined,
};
}
export {};

View File

@@ -2,9 +2,9 @@
'require baseclass';
'require fs';
'require uci';
'require ui';
export * from './validators';
export * from './helpers';
export * from './clash';
export * from './podkop';
export * from './constants';

View File

@@ -0,0 +1,69 @@
import { insertIf } from '../../helpers';
import { renderLoaderCircleIcon24 } from '../../icons';
interface IRenderButtonProps {
classNames?: string[];
disabled?: boolean;
loading?: boolean;
icon?: () => SVGSVGElement;
onClick: () => void;
text: string;
}
export function renderButton({
classNames = [],
disabled,
loading,
onClick,
text,
icon,
}: IRenderButtonProps) {
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 undefined;
}
return E(
'button',
{ class: getClass(), disabled: getDisabled(), click: onClick },
[...insertIf(hasIcon, [getWrappedIcon()]), E('span', {}, text)],
);
}

View File

@@ -0,0 +1,33 @@
// language=CSS
export const styles = `
.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;
}
`;

View File

@@ -0,0 +1,10 @@
import { styles as ButtonStyles } from './button/styles';
import { styles as ModalStyles } from './modal/styles';
export * from './button/renderButton';
export * from './modal/renderModal';
export const PartialStyles = `
${ButtonStyles}
${ModalStyles}
`;

View File

@@ -0,0 +1,32 @@
import { renderButton } from '../button/renderButton';
import { copyToClipboard } from '../../helpers/copyToClipboard';
import { downloadAsTxt } from '../../helpers/downloadAsTxt';
export function renderModal(text: string, name: string) {
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} \n ${text} \n \`\`\``),
}),
renderButton({
classNames: ['cbi-button-remove'],
text: _('Close'),
onClick: ui.hideModal,
}),
]),
]),
);
}

View File

@@ -0,0 +1,20 @@
// language=CSS
export const styles = `
.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;
}
`;

View File

@@ -0,0 +1,53 @@
import { withTimeout } from '../helpers';
export async function createBaseApiRequest<T>(
fetchFn: () => Promise<Response>,
options?: {
timeoutMs?: number;
operationName?: string;
timeoutMessage?: string;
},
): Promise<IBaseApiResponse<T>> {
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 as const,
message: `${_('HTTP error')} ${response.status}: ${response.statusText}`,
};
}
const data: T = await response.json();
return {
success: true as const,
data,
};
} catch (e) {
return {
success: false as const,
message: e instanceof Error ? e.message : _('Unknown error'),
};
}
}
export type IBaseApiResponse<T> =
| {
success: true;
data: T;
}
| {
success: false;
message: string;
};

View File

@@ -0,0 +1,29 @@
import { PodkopShellMethods } from '../methods';
import { store } from '../services';
export 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 },
},
});
}
}

View File

@@ -0,0 +1 @@
export * from './fetchServicesInfo';

View File

@@ -1,4 +1,4 @@
import { Podkop } from '../types';
import { Podkop } from '../../types';
export async function getConfigSections(): Promise<Podkop.ConfigSection[]> {
return uci.load('podkop').then(() => uci.sections('podkop'));

View File

@@ -1,7 +1,7 @@
import { Podkop } from '../types';
import { getConfigSections } from './getConfigSections';
import { getClashProxies } from '../../clash';
import { getProxyUrlName, splitProxyString } from '../../helpers';
import { Podkop } from '../../types';
import { getProxyUrlName, splitProxyString } from '../../../helpers';
import { PodkopShellMethods } from '../shell';
interface IGetDashboardSectionsResponse {
success: boolean;
@@ -10,7 +10,7 @@ interface IGetDashboardSectionsResponse {
export async function getDashboardSections(): Promise<IGetDashboardSectionsResponse> {
const configSections = await getConfigSections();
const clashProxies = await getClashProxies();
const clashProxies = await PodkopShellMethods.getClashApiProxies();
if (!clashProxies.success) {
return {
@@ -27,9 +27,12 @@ export async function getDashboardSections(): Promise<IGetDashboardSectionsRespo
);
const data = configSections
.filter((section) => section.mode !== 'block')
.filter(
(section) =>
section.connection_type !== 'block' && section['.type'] !== 'settings',
)
.map((section) => {
if (section.mode === 'proxy') {
if (section.connection_type === 'proxy') {
if (section.proxy_config_type === 'url') {
const outbound = proxies.find(
(proxy) => proxy.code === `${section['.name']}-out`,
@@ -122,7 +125,7 @@ export async function getDashboardSections(): Promise<IGetDashboardSectionsRespo
}
}
if (section.mode === 'vpn') {
if (section.connection_type === 'vpn') {
const outbound = proxies.find(
(proxy) => proxy.code === `${section['.name']}-out`,
);

View File

@@ -0,0 +1,7 @@
import { getConfigSections } from './getConfigSections';
import { getDashboardSections } from './getDashboardSections';
export const CustomPodkopMethods = {
getConfigSections,
getDashboardSections,
};

View File

@@ -0,0 +1,23 @@
import { FAKEIP_CHECK_DOMAIN } from '../../../constants';
import { createBaseApiRequest, IBaseApiResponse } from '../../api';
interface IGetFakeIpCheckResponse {
fakeip: boolean;
IP: string;
}
export async function getFakeIpCheck(): Promise<
IBaseApiResponse<IGetFakeIpCheckResponse>
> {
return createBaseApiRequest<IGetFakeIpCheckResponse>(
() =>
fetch(`https://${FAKEIP_CHECK_DOMAIN}/check`, {
method: 'GET',
headers: { 'Content-Type': 'application/json' },
}),
{
operationName: 'getFakeIpCheck',
timeoutMs: 5000,
},
);
}

View File

@@ -0,0 +1,23 @@
import { IP_CHECK_DOMAIN } from '../../../constants';
import { createBaseApiRequest, IBaseApiResponse } from '../../api';
interface IGetIpCheckResponse {
fakeip: boolean;
IP: string;
}
export async function getIpCheck(): Promise<
IBaseApiResponse<IGetIpCheckResponse>
> {
return createBaseApiRequest<IGetIpCheckResponse>(
() =>
fetch(`https://${IP_CHECK_DOMAIN}/check`, {
method: 'GET',
headers: { 'Content-Type': 'application/json' },
}),
{
operationName: 'getIpCheck',
timeoutMs: 5000,
},
);
}

View File

@@ -0,0 +1,7 @@
import { getFakeIpCheck } from './getFakeIpCheck';
import { getIpCheck } from './getIpCheck';
export const RemoteFakeIPMethods = {
getFakeIpCheck,
getIpCheck,
};

View File

@@ -1,21 +0,0 @@
import { executeShellCommand } from '../../helpers';
export async function getPodkopStatus(): Promise<{
enabled: number;
status: string;
}> {
const response = await executeShellCommand({
command: '/usr/bin/podkop',
args: ['get_status'],
timeout: 10000,
});
if (response.stdout) {
return JSON.parse(response.stdout.replace(/\n/g, '')) as {
enabled: number;
status: string;
};
}
return { enabled: 0, status: 'unknown' };
}

View File

@@ -1,23 +0,0 @@
import { executeShellCommand } from '../../helpers';
export async function getSingboxStatus(): Promise<{
running: number;
enabled: number;
status: string;
}> {
const response = await executeShellCommand({
command: '/usr/bin/podkop',
args: ['get_sing_box_status'],
timeout: 10000,
});
if (response.stdout) {
return JSON.parse(response.stdout.replace(/\n/g, '')) as {
running: number;
enabled: number;
status: string;
};
}
return { running: 0, enabled: 0, status: 'unknown' };
}

View File

@@ -1,4 +1,3 @@
export * from './getConfigSections';
export * from './getDashboardSections';
export * from './getPodkopStatus';
export * from './getSingboxStatus';
export * from './custom';
export * from './fakeip';
export * from './shell';

View File

@@ -0,0 +1,33 @@
import { executeShellCommand } from '../../../helpers';
import { Podkop } from '../../types';
export async function callBaseMethod<T>(
method: Podkop.AvailableMethods,
args: string[] = [],
command: string = '/usr/bin/podkop',
): Promise<Podkop.MethodResponse<T>> {
const response = await executeShellCommand({
command,
args: [method as string, ...args],
timeout: 10000,
});
if (response.stdout) {
try {
return {
success: true,
data: JSON.parse(response.stdout) as T,
};
} catch (_e) {
return {
success: true,
data: response.stdout as T,
};
}
}
return {
success: false,
error: '',
};
}

View File

@@ -0,0 +1,87 @@
import { callBaseMethod } from './callBaseMethod';
import { ClashAPI, Podkop } from '../../types';
export const PodkopShellMethods = {
checkDNSAvailable: async () =>
callBaseMethod<Podkop.DnsCheckResult>(
Podkop.AvailableMethods.CHECK_DNS_AVAILABLE,
),
checkFakeIP: async () =>
callBaseMethod<Podkop.FakeIPCheckResult>(
Podkop.AvailableMethods.CHECK_FAKEIP,
),
checkNftRules: async () =>
callBaseMethod<Podkop.NftRulesCheckResult>(
Podkop.AvailableMethods.CHECK_NFT_RULES,
),
getStatus: async () =>
callBaseMethod<Podkop.GetStatus>(Podkop.AvailableMethods.GET_STATUS),
checkSingBox: async () =>
callBaseMethod<Podkop.SingBoxCheckResult>(
Podkop.AvailableMethods.CHECK_SING_BOX,
),
getSingBoxStatus: async () =>
callBaseMethod<Podkop.GetSingBoxStatus>(
Podkop.AvailableMethods.GET_SING_BOX_STATUS,
),
getClashApiProxies: async () =>
callBaseMethod<ClashAPI.Proxies>(Podkop.AvailableMethods.CLASH_API, [
Podkop.AvailableClashAPIMethods.GET_PROXIES,
]),
getClashApiProxyLatency: async (tag: string) =>
callBaseMethod<unknown>(Podkop.AvailableMethods.CLASH_API, [
Podkop.AvailableClashAPIMethods.GET_PROXY_LATENCY,
tag,
]),
getClashApiGroupLatency: async (tag: string) =>
callBaseMethod<unknown>(Podkop.AvailableMethods.CLASH_API, [
Podkop.AvailableClashAPIMethods.GET_GROUP_LATENCY,
tag,
]),
setClashApiGroupProxy: async (group: string, proxy: string) =>
callBaseMethod<unknown>(Podkop.AvailableMethods.CLASH_API, [
Podkop.AvailableClashAPIMethods.SET_GROUP_PROXY,
group,
proxy,
]),
restart: async () =>
callBaseMethod<unknown>(
Podkop.AvailableMethods.RESTART,
[],
'/etc/init.d/podkop',
),
start: async () =>
callBaseMethod<unknown>(
Podkop.AvailableMethods.START,
[],
'/etc/init.d/podkop',
),
stop: async () =>
callBaseMethod<unknown>(
Podkop.AvailableMethods.STOP,
[],
'/etc/init.d/podkop',
),
enable: async () =>
callBaseMethod<unknown>(
Podkop.AvailableMethods.ENABLE,
[],
'/etc/init.d/podkop',
),
disable: async () =>
callBaseMethod<unknown>(
Podkop.AvailableMethods.DISABLE,
[],
'/etc/init.d/podkop',
),
globalCheck: async () =>
callBaseMethod<unknown>(Podkop.AvailableMethods.GLOBAL_CHECK),
showSingBoxConfig: async () =>
callBaseMethod<unknown>(Podkop.AvailableMethods.SHOW_SING_BOX_CONFIG),
checkLogs: async () =>
callBaseMethod<unknown>(Podkop.AvailableMethods.CHECK_LOGS),
getSystemInfo: async () =>
callBaseMethod<Podkop.GetSystemInfo>(
Podkop.AvailableMethods.GET_SYSTEM_INFO,
),
};

View File

@@ -1,8 +1,12 @@
import { TabServiceInstance } from './tab.service';
import { store } from '../../store';
import { store } from './store.service';
import { logger } from './logger.service';
import { PodkopLogWatcher } from './podkopLogWatcher.service';
import { PodkopShellMethods } from '../methods';
export function coreService() {
TabServiceInstance.onChange((activeId, tabs) => {
logger.info('[TAB]', activeId);
store.set({
tabService: {
current: activeId || '',
@@ -10,4 +14,31 @@ export function coreService() {
},
});
});
const watcher = PodkopLogWatcher.getInstance();
watcher.init(
async () => {
const logs = await PodkopShellMethods.checkLogs();
if (logs.success) {
return logs.data as string;
}
return '';
},
{
intervalMs: 3000,
onNewLog: (line) => {
if (
line.toLowerCase().includes('[error]') ||
line.toLowerCase().includes('[fatal]')
) {
ui.addNotification('Podkop Error', E('div', {}, line), 'error');
}
},
},
);
watcher.start();
}

View File

@@ -1,2 +1,5 @@
export * from './tab.service';
export * from './core.service';
export * from './socket.service';
export * from './store.service';
export * from './logger.service';

View File

@@ -0,0 +1,66 @@
import { downloadAsTxt } from '../../helpers/downloadAsTxt';
export type LogLevel = 'debug' | 'info' | 'warn' | 'error';
export class Logger {
private logs: string[] = [];
private readonly levels: LogLevel[] = ['debug', 'info', 'warn', 'error'];
private format(level: LogLevel, ...args: unknown[]): string {
return `[${level.toUpperCase()}] ${args.join(' ')}`;
}
private push(level: LogLevel, ...args: unknown[]): void {
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: unknown[]): void {
this.push('debug', ...args);
}
info(...args: unknown[]): void {
this.push('info', ...args);
}
warn(...args: unknown[]): void {
this.push('warn', ...args);
}
error(...args: unknown[]): void {
this.push('error', ...args);
}
clear(): void {
this.logs = [];
}
getLogs(): string {
return this.logs.join('\n');
}
download(filename = 'logs.txt'): void {
if (typeof document === 'undefined') {
console.warn('Logger.download() доступен только в браузере');
return;
}
downloadAsTxt(this.getLogs(), filename);
}
}
export const logger = new Logger();

View File

@@ -0,0 +1,116 @@
import { logger } from './logger.service';
export type LogFetcher = () => Promise<string> | string;
export interface PodkopLogWatcherOptions {
intervalMs?: number;
onNewLog?: (line: string) => void;
}
export class PodkopLogWatcher {
private static instance: PodkopLogWatcher;
private fetcher?: LogFetcher;
private onNewLog?: (line: string) => void;
private intervalMs = 5000;
private lastLines = new Set<string>();
private timer?: ReturnType<typeof setInterval>;
private running = false;
private paused = false;
private constructor() {
if (typeof document !== 'undefined') {
document.addEventListener('visibilitychange', () => {
if (document.hidden) this.pause();
else this.resume();
});
}
}
static getInstance(): PodkopLogWatcher {
if (!PodkopLogWatcher.instance) {
PodkopLogWatcher.instance = new PodkopLogWatcher();
}
return PodkopLogWatcher.instance;
}
init(fetcher: LogFetcher, options?: PodkopLogWatcherOptions): void {
this.fetcher = fetcher;
this.onNewLog = options?.onNewLog;
this.intervalMs = options?.intervalMs ?? 5000;
logger.info(
'[PodkopLogWatcher]',
`initialized (interval: ${this.intervalMs}ms)`,
);
}
async checkOnce(): Promise<void> {
if (!this.fetcher) {
logger.warn('[PodkopLogWatcher]', 'fetcher not found');
return;
}
if (this.paused) {
logger.debug('[PodkopLogWatcher]', 'skipped check — 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(): void {
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(): void {
if (!this.running) return;
this.running = false;
if (this.timer) clearInterval(this.timer);
logger.info('[PodkopLogWatcher]', 'stopped');
}
pause(): void {
if (!this.running || this.paused) return;
this.paused = true;
logger.info('[PodkopLogWatcher]', 'paused (tab not visible)');
}
resume(): void {
if (!this.running || !this.paused) return;
this.paused = false;
logger.info('[PodkopLogWatcher]', 'resumed (tab active)');
this.checkOnce(); // сразу проверить, не появились ли новые логи
}
reset(): void {
this.lastLines.clear();
logger.info('[PodkopLogWatcher]', 'log history reset');
}
}

View File

@@ -1,3 +1,5 @@
import { logger } from './logger.service';
// eslint-disable-next-line
type Listener = (data: any) => void;
type ErrorListener = (error: Event | string) => void;
@@ -18,10 +20,48 @@ class SocketManager {
return SocketManager.instance;
}
resetAll(): void {
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: string): void {
if (this.sockets.has(url)) return;
const ws = new WebSocket(url);
let ws: WebSocket;
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, new Set());
@@ -29,7 +69,7 @@ class SocketManager {
ws.addEventListener('open', () => {
this.connected.set(url, true);
console.info(`Connected: ${url}`);
logger.info('[SOCKET]', 'Connected to', url);
});
ws.addEventListener('message', (event) => {
@@ -39,7 +79,7 @@ class SocketManager {
try {
handler(event.data);
} catch (err) {
console.error(`Handler error for ${url}:`, err);
logger.error('[SOCKET]', `Handler error for ${url}:`, err);
}
}
}
@@ -47,26 +87,32 @@ class SocketManager {
ws.addEventListener('close', () => {
this.connected.set(url, false);
console.warn(`Disconnected: ${url}`);
logger.warn('[SOCKET]', `Disconnected: ${url}`);
this.triggerError(url, 'Connection closed');
});
ws.addEventListener('error', (err) => {
console.error(`Socket error for ${url}:`, err);
logger.error('[SOCKET]', `Socket error for ${url}:`, err);
this.triggerError(url, err);
});
}
subscribe(url: string, listener: Listener, onError?: ErrorListener): void {
if (!this.errorListeners.has(url)) {
this.errorListeners.set(url, new Set());
}
if (onError) {
this.errorListeners.get(url)?.add(onError);
}
if (!this.sockets.has(url)) {
this.connect(url);
}
this.listeners.get(url)?.add(listener);
if (onError) {
this.errorListeners.get(url)?.add(onError);
if (!this.listeners.has(url)) {
this.listeners.set(url, new Set());
}
this.listeners.get(url)?.add(listener);
}
unsubscribe(url: string, listener: Listener, onError?: ErrorListener): void {
@@ -82,7 +128,7 @@ class SocketManager {
if (ws && this.connected.get(url)) {
ws.send(typeof data === 'string' ? data : JSON.stringify(data));
} else {
console.warn(`Cannot send: not connected to ${url}`);
logger.warn('[SOCKET]', `Cannot send: not connected to ${url}`);
this.triggerError(url, 'Not connected');
}
}
@@ -111,7 +157,7 @@ class SocketManager {
try {
cb(err);
} catch (e) {
console.error(`Error handler threw for ${url}:`, e);
logger.error('[SOCKET]', `Error handler threw for ${url}:`, e);
}
}
}

View File

@@ -1,4 +1,5 @@
import { Podkop } from './podkop/types';
import { Podkop } from '../types';
import { initialDiagnosticStore } from '../tabs/diagnostic/diagnostic.store';
function jsonStableStringify<T, V>(obj: T): string {
return JSON.stringify(obj, (_, value) => {
@@ -28,7 +29,7 @@ function jsonEqual<A, B>(a: A, b: B): boolean {
type Listener<T> = (next: T, prev: T, diff: Partial<T>) => void;
// eslint-disable-next-line
class Store<T extends Record<string, any>> {
class StoreService<T extends Record<string, any>> {
private value: T;
private readonly initial: T;
private listeners = new Set<Listener<T>>();
@@ -61,9 +62,17 @@ class Store<T extends Record<string, any>> {
this.listeners.forEach((cb) => cb(this.value, prev, diff));
}
reset(): void {
reset<K extends keyof T>(keys?: K[]): void {
const prev = this.value;
const next = structuredClone(this.initial);
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;
@@ -112,6 +121,21 @@ class Store<T extends Record<string, any>> {
}
}
export interface IDiagnosticsChecksItem {
state: 'error' | 'warning' | 'success';
key: string;
value: string;
}
export interface IDiagnosticsChecksStoreItem {
order: number;
code: string;
title: string;
description: string;
state: 'loading' | 'warning' | 'success' | 'error' | 'skipped';
items: Array<IDiagnosticsChecksItem>;
}
export interface StoreType {
tabService: {
current: string;
@@ -143,6 +167,29 @@ export interface StoreType {
data: Podkop.OutboundGroup[];
latencyFetching: boolean;
};
diagnosticsRunAction: {
loading: boolean;
};
diagnosticsChecks: Array<IDiagnosticsChecksStoreItem>;
diagnosticsActions: {
restart: { loading: boolean };
start: { loading: boolean };
stop: { loading: boolean };
enable: { loading: boolean };
disable: { loading: boolean };
globalCheck: { loading: boolean };
viewLogs: { loading: boolean };
showSingBoxConfig: { loading: boolean };
};
diagnosticsSystemInfo: {
loading: boolean;
podkop_version: string;
podkop_latest_version: string;
luci_app_version: string;
sing_box_version: string;
openwrt_version: string;
device_model: string;
};
}
const initialStore: StoreType = {
@@ -176,6 +223,7 @@ const initialStore: StoreType = {
latencyFetching: false,
data: [],
},
...initialDiagnosticStore,
};
export const store = new Store<StoreType>(initialStore);
export const store = new StoreService<StoreType>(initialStore);

View File

@@ -1,2 +1,9 @@
export * from './renderDashboard';
export * from './initDashboardController';
import { render } from './render';
import { initController } from './initController';
import { styles } from './styles';
export const DashboardTab = {
render,
initController,
styles,
};

View File

@@ -1,24 +1,13 @@
import {
getDashboardSections,
getPodkopStatus,
getSingboxStatus,
} from '../../methods';
import {
getClashApiUrl,
getClashWsUrl,
onMount,
preserveScrollForPage,
} from '../../../helpers';
import {
triggerLatencyGroupTest,
triggerLatencyProxyTest,
triggerProxySelector,
} from '../../../clash';
import { store, StoreType } from '../../../store';
import { socket } from '../../../socket';
import { prettyBytes } from '../../../helpers/prettyBytes';
import { renderSections } from './renderSections';
import { renderWidget } from './renderWidget';
import { CustomPodkopMethods, PodkopShellMethods } from '../../methods';
import { logger, socket, store, StoreType } from '../../services';
import { renderSections, renderWidget } from './partials';
import { fetchServicesInfo } from '../../fetchers';
// Fetchers
@@ -32,10 +21,10 @@ async function fetchDashboardSections() {
},
});
const { data, success } = await getDashboardSections();
const { data, success } = await CustomPodkopMethods.getDashboardSections();
if (!success) {
console.log('[fetchDashboardSections]: failed to fetch', getClashApiUrl());
logger.error('[DASHBOARD]', 'fetchDashboardSections: failed to fetch');
}
store.set({
@@ -48,33 +37,6 @@ async function fetchDashboardSections() {
});
}
async function fetchServicesInfo() {
try {
const [podkop, singbox] = await Promise.all([
getPodkopStatus(),
getSingboxStatus(),
]);
store.set({
servicesInfoWidget: {
loading: false,
failed: false,
data: { singbox: singbox.running, podkop: podkop.enabled },
},
});
} catch (err) {
console.log('[fetchServicesInfo]: failed to fetchServices', err);
store.set({
servicesInfoWidget: {
loading: false,
failed: true,
data: { singbox: 0, podkop: 0 },
},
});
}
}
async function connectToClashSockets() {
socket.subscribe(
`${getClashWsUrl()}/traffic?token=`,
@@ -90,8 +52,9 @@ async function connectToClashSockets() {
});
},
(_err) => {
console.log(
'[fetchDashboardSections]: failed to connect',
logger.error(
'[DASHBOARD]',
'connectToClashSockets - traffic: failed to connect to',
getClashWsUrl(),
);
store.set({
@@ -129,8 +92,9 @@ async function connectToClashSockets() {
});
},
(_err) => {
console.log(
'[fetchDashboardSections]: failed to connect',
logger.error(
'[DASHBOARD]',
'connectToClashSockets - connections: failed to connect to',
getClashWsUrl(),
);
store.set({
@@ -155,7 +119,7 @@ async function connectToClashSockets() {
// Handlers
async function handleChooseOutbound(selector: string, tag: string) {
await triggerProxySelector(selector, tag);
await PodkopShellMethods.setClashApiGroupProxy(selector, tag);
await fetchDashboardSections();
}
@@ -167,7 +131,7 @@ async function handleTestGroupLatency(tag: string) {
},
});
await triggerLatencyGroupTest(tag);
await PodkopShellMethods.getClashApiGroupLatency(tag);
await fetchDashboardSections();
store.set({
@@ -186,7 +150,7 @@ async function handleTestProxyLatency(tag: string) {
},
});
await triggerLatencyProxyTest(tag);
await PodkopShellMethods.getClashApiProxyLatency(tag);
await fetchDashboardSections();
store.set({
@@ -200,7 +164,7 @@ async function handleTestProxyLatency(tag: string) {
// Renderer
async function renderSectionsWidget() {
console.log('renderSectionsWidget');
logger.debug('[DASHBOARD]', 'renderSectionsWidget');
const sectionsWidget = store.get().sectionsWidget;
const container = document.getElementById('dashboard-sections-grid');
@@ -249,7 +213,7 @@ async function renderSectionsWidget() {
}
async function renderBandwidthWidget() {
console.log('renderBandwidthWidget');
logger.debug('[DASHBOARD]', 'renderBandwidthWidget');
const traffic = store.get().bandwidthWidget;
const container = document.getElementById('dashboard-widget-traffic');
@@ -279,7 +243,7 @@ async function renderBandwidthWidget() {
}
async function renderTrafficTotalWidget() {
console.log('renderTrafficTotalWidget');
logger.debug('[DASHBOARD]', 'renderTrafficTotalWidget');
const trafficTotalWidget = store.get().trafficTotalWidget;
const container = document.getElementById('dashboard-widget-traffic-total');
@@ -315,7 +279,7 @@ async function renderTrafficTotalWidget() {
}
async function renderSystemInfoWidget() {
console.log('renderSystemInfoWidget');
logger.debug('[DASHBOARD]', 'renderSystemInfoWidget');
const systemInfoWidget = store.get().systemInfoWidget;
const container = document.getElementById('dashboard-widget-system-info');
@@ -351,7 +315,7 @@ async function renderSystemInfoWidget() {
}
async function renderServicesInfoWidget() {
console.log('renderServicesInfoWidget');
logger.debug('[DASHBOARD]', 'renderServicesInfoWidget');
const servicesInfoWidget = store.get().servicesInfoWidget;
const container = document.getElementById('dashboard-widget-service-info');
@@ -426,19 +390,71 @@ async function onStoreUpdate(
}
}
export async function initDashboardController(): Promise<void> {
onMount('dashboard-status').then(() => {
// Remove old listener
store.unsubscribe(onStoreUpdate);
// Clear store
store.reset();
async function onPageMount() {
// Cleanup before mount
onPageUnmount();
// Add new listener
store.subscribe(onStoreUpdate);
// Add new listener
store.subscribe(onStoreUpdate);
// Initial sections fetch
fetchDashboardSections();
fetchServicesInfo();
connectToClashSockets();
// Initial sections fetch
await fetchDashboardSections();
await fetchServicesInfo();
await connectToClashSockets();
}
function onPageUnmount() {
// Remove old listener
store.unsubscribe(onStoreUpdate);
// Clear store
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();
}
}
});
}
export async function initController(): Promise<void> {
onMount('dashboard-status').then(() => {
logger.debug('[DASHBOARD]', 'initController', 'onMount');
onPageMount();
registerLifecycleListeners();
});
}

View File

@@ -0,0 +1,2 @@
export * from './renderSections';
export * from './renderWidget';

View File

@@ -1,5 +1,4 @@
import { Podkop } from '../../types';
import { getClashApiUrl } from '../../../helpers';
import { Podkop } from '../../../types';
interface IRenderSectionsProps {
loading: boolean;
@@ -17,10 +16,7 @@ function renderFailedState() {
class: 'pdk_dashboard-page__outbound-section centered',
style: 'height: 127px',
},
E('span', {}, [
E('span', {}, _('Dashboard currently unavailable')),
E('div', { style: 'text-align: center;' }, `API: ${getClashApiUrl()}`),
]),
E('span', {}, [E('span', {}, _('Dashboard currently unavailable'))]),
);
}

View File

@@ -1,7 +1,6 @@
import { renderSections } from './renderSections';
import { renderWidget } from './renderWidget';
import { renderSections, renderWidget } from './partials';
export function renderDashboard() {
export function render() {
return E(
'div',
{
@@ -47,6 +46,7 @@ export function renderDashboard() {
},
onTestLatency: () => {},
onChooseOutbound: () => {},
latencyFetching: false,
}),
),
],

View File

@@ -0,0 +1,120 @@
// language=CSS
export const 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);
}
`;

View File

@@ -0,0 +1,32 @@
export enum DIAGNOSTICS_CHECKS {
DNS = 'DNS',
SINGBOX = 'SINGBOX',
NFT = 'NFT',
FAKEIP = 'FAKEIP',
}
export const DIAGNOSTICS_CHECKS_MAP: Record<
DIAGNOSTICS_CHECKS,
{ order: number; title: string; code: DIAGNOSTICS_CHECKS }
> = {
[DIAGNOSTICS_CHECKS.DNS]: {
order: 1,
title: _('DNS checks'),
code: DIAGNOSTICS_CHECKS.DNS,
},
[DIAGNOSTICS_CHECKS.SINGBOX]: {
order: 2,
title: _('Sing-box checks'),
code: DIAGNOSTICS_CHECKS.SINGBOX,
},
[DIAGNOSTICS_CHECKS.NFT]: {
order: 3,
title: _('Nftables checks'),
code: DIAGNOSTICS_CHECKS.NFT,
},
[DIAGNOSTICS_CHECKS.FAKEIP]: {
order: 4,
title: _('FakeIP checks'),
code: DIAGNOSTICS_CHECKS.FAKEIP,
},
};

View File

@@ -0,0 +1,98 @@
import { insertIf } from '../../../../helpers';
import { DIAGNOSTICS_CHECKS_MAP } from './contstants';
import { PodkopShellMethods } from '../../../methods';
import { IDiagnosticsChecksItem } from '../../../services';
import { updateCheckStore } from './updateCheckStore';
export async function runDnsCheck() {
const { order, title, code } = DIAGNOSTICS_CHECKS_MAP.DNS;
updateCheckStore({
order,
code,
title,
description: _('Checking dns, please wait'),
state: 'loading',
items: [],
});
const dnsChecks = await PodkopShellMethods.checkDNSAvailable();
if (!dnsChecks.success) {
updateCheckStore({
order,
code,
title,
description: _('Cannot receive DNS 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);
function getStatus() {
if (allGood) {
return 'success';
}
if (atLeastOneGood) {
return 'warning';
}
return 'error';
}
updateCheckStore({
order,
code,
title,
description: _('DNS checks passed'),
state: getStatus(),
items: [
...insertIf<IDiagnosticsChecksItem>(
data.dns_type === 'doh' || data.dns_type === 'dot',
[
{
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');
}
}

View File

@@ -0,0 +1,95 @@
import { insertIf } from '../../../../helpers';
import { DIAGNOSTICS_CHECKS_MAP } from './contstants';
import { PodkopShellMethods, RemoteFakeIPMethods } from '../../../methods';
import { IDiagnosticsChecksItem } from '../../../services';
import { updateCheckStore } from './updateCheckStore';
export async function runFakeIPCheck() {
const { order, title, code } = DIAGNOSTICS_CHECKS_MAP.FAKEIP;
updateCheckStore({
order,
code,
title,
description: _('Checking FakeIP, 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;
function getMeta(): {
description: string;
state: 'loading' | 'warning' | 'success' | 'error' | 'skipped';
} {
if (allGood) {
return {
state: 'success',
description: _('FakeIP checks passed'),
};
}
if (atLeastOneGood) {
return {
state: 'warning',
description: _('FakeIP checks partially passed'),
};
}
return {
state: 'error',
description: _('FakeIP checks failed'),
};
}
const { state, description } = getMeta();
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<IDiagnosticsChecksItem>(checks.browserFakeIP, [
{
state: checks.differentIP ? 'success' : 'error',
key: checks.differentIP
? _('Proxy traffic is routed via FakeIP')
: _('Proxy traffic is not routed via FakeIP'),
value: '',
},
]),
],
});
}

View File

@@ -0,0 +1,126 @@
import { DIAGNOSTICS_CHECKS_MAP } from './contstants';
import { RemoteFakeIPMethods, PodkopShellMethods } from '../../../methods';
import { updateCheckStore } from './updateCheckStore';
export async function runNftCheck() {
const { order, title, code } = DIAGNOSTICS_CHECKS_MAP.NFT;
updateCheckStore({
order,
code,
title,
description: _('Checking nftables, 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 nftables 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;
function getStatus() {
if (allGood) {
return 'success';
}
if (atLeastOneGood) {
return 'warning';
}
return 'error';
}
updateCheckStore({
order,
code,
title,
description: allGood
? _('Nftables checks passed')
: _('Nftables checks partially passed'),
state: getStatus(),
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');
}
}

View File

@@ -0,0 +1,105 @@
import { DIAGNOSTICS_CHECKS_MAP } from './contstants';
import { PodkopShellMethods } from '../../../methods';
import { updateCheckStore } from './updateCheckStore';
export async function runSingBoxCheck() {
const { order, title, code } = DIAGNOSTICS_CHECKS_MAP.SINGBOX;
updateCheckStore({
order,
code,
title,
description: _('Checking sing-box, please wait'),
state: 'loading',
items: [],
});
const singBoxChecks = await PodkopShellMethods.checkSingBox();
if (!singBoxChecks.success) {
updateCheckStore({
order,
code,
title,
description: _('Cannot receive Sing-box 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);
function getStatus() {
if (allGood) {
return 'success';
}
if (atLeastOneGood) {
return 'warning';
}
return 'error';
}
updateCheckStore({
order,
code,
title,
description: _('Sing-box checks passed'),
state: getStatus(),
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 >= 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');
}
}

View File

@@ -0,0 +1,20 @@
import { IDiagnosticsChecksStoreItem, store } from '../../../services';
export function updateCheckStore(
check: IDiagnosticsChecksStoreItem,
minified?: boolean,
) {
const diagnosticsChecks = store.get().diagnosticsChecks;
const other = diagnosticsChecks.filter((item) => item.code !== check.code);
const smallCheck: IDiagnosticsChecksStoreItem = {
...check,
items: check.items.filter((item) => item.state !== 'success'),
};
const targetCheck = minified ? smallCheck : check;
store.set({
diagnosticsChecks: [...other, targetCheck],
});
}

View File

@@ -0,0 +1,124 @@
import {
DIAGNOSTICS_CHECKS,
DIAGNOSTICS_CHECKS_MAP,
} from './checks/contstants';
import { StoreType } from '../../services';
export const initialDiagnosticStore: Pick<
StoreType,
| 'diagnosticsChecks'
| 'diagnosticsRunAction'
| 'diagnosticsActions'
| 'diagnosticsSystemInfo'
> = {
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: DIAGNOSTICS_CHECKS.DNS,
title: DIAGNOSTICS_CHECKS_MAP.DNS.title,
order: DIAGNOSTICS_CHECKS_MAP.DNS.order,
description: _('Not running'),
items: [],
state: 'skipped',
},
{
code: DIAGNOSTICS_CHECKS.SINGBOX,
title: DIAGNOSTICS_CHECKS_MAP.SINGBOX.title,
order: DIAGNOSTICS_CHECKS_MAP.SINGBOX.order,
description: _('Not running'),
items: [],
state: 'skipped',
},
{
code: DIAGNOSTICS_CHECKS.NFT,
title: DIAGNOSTICS_CHECKS_MAP.NFT.title,
order: DIAGNOSTICS_CHECKS_MAP.NFT.order,
description: _('Not running'),
items: [],
state: 'skipped',
},
{
code: DIAGNOSTICS_CHECKS.FAKEIP,
title: DIAGNOSTICS_CHECKS_MAP.FAKEIP.title,
order: DIAGNOSTICS_CHECKS_MAP.FAKEIP.order,
description: _('Not running'),
items: [],
state: 'skipped',
},
],
};
export const loadingDiagnosticsChecksStore: Pick<
StoreType,
'diagnosticsChecks'
> = {
diagnosticsChecks: [
{
code: DIAGNOSTICS_CHECKS.DNS,
title: DIAGNOSTICS_CHECKS_MAP.DNS.title,
order: DIAGNOSTICS_CHECKS_MAP.DNS.order,
description: _('Queued'),
items: [],
state: 'skipped',
},
{
code: DIAGNOSTICS_CHECKS.SINGBOX,
title: DIAGNOSTICS_CHECKS_MAP.SINGBOX.title,
order: DIAGNOSTICS_CHECKS_MAP.SINGBOX.order,
description: _('Queued'),
items: [],
state: 'skipped',
},
{
code: DIAGNOSTICS_CHECKS.NFT,
title: DIAGNOSTICS_CHECKS_MAP.NFT.title,
order: DIAGNOSTICS_CHECKS_MAP.NFT.order,
description: _('Queued'),
items: [],
state: 'skipped',
},
{
code: DIAGNOSTICS_CHECKS.FAKEIP,
title: DIAGNOSTICS_CHECKS_MAP.FAKEIP.title,
order: DIAGNOSTICS_CHECKS_MAP.FAKEIP.order,
description: _('Queued'),
items: [],
state: 'skipped',
},
],
};

View File

@@ -0,0 +1,9 @@
import { render } from './renderDiagnostic';
import { initController } from './initController';
import { styles } from './styles';
export const DiagnosticTab = {
render,
initController,
styles,
};

View File

@@ -0,0 +1,559 @@
import { onMount, preserveScrollForPage } from '../../../helpers';
import { runDnsCheck } from './checks/runDnsCheck';
import { runSingBoxCheck } from './checks/runSingBoxCheck';
import { runNftCheck } from './checks/runNftCheck';
import { runFakeIPCheck } from './checks/runFakeIPCheck';
import { loadingDiagnosticsChecksStore } from './diagnostic.store';
import { logger, store, StoreType } from '../../services';
import {
IRenderSystemInfoRow,
renderAvailableActions,
renderCheckSection,
renderRunAction,
renderSystemInfo,
} from './partials';
import { PodkopShellMethods } from '../../methods';
import { fetchServicesInfo } from '../../fetchers';
import { normalizeCompiledVersion } from '../../../helpers/normalizeCompiledVersion';
import { renderModal } from '../../../partials';
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']);
}, 5000);
}
}
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']);
}, 5000);
}
}
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 as string, 'global_check'),
);
}
} catch (e) {
logger.error('[DIAGNOSTIC]', 'handleShowGlobalCheck - e', e);
} 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 as string, 'view_logs'),
);
}
} catch (e) {
logger.error('[DIAGNOSTIC]', 'handleViewLogs - e', e);
} 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(showSingBoxConfig.data as string, 'show_sing_box_config'),
);
}
} catch (e) {
logger.error('[DIAGNOSTIC]', 'handleShowSingBoxConfig - e', e);
} finally {
store.set({
diagnosticsActions: {
...diagnosticsActions,
showSingBoxConfig: { loading: false },
},
});
}
}
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');
function getPodkopVersionRow(): IRenderSystemInfoRow {
const loading = diagnosticsSystemInfo.loading;
const unknown = diagnosticsSystemInfo.podkop_version === _('unknown');
const hasActualVersion = Boolean(
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 (version !== diagnosticsSystemInfo.podkop_latest_version) {
return {
key: 'Podkop',
value: version,
tag: {
label: _('Outdated'),
kind: 'warning',
},
};
}
return {
key: 'Podkop',
value: version,
tag: {
label: _('Latest'),
kind: 'success',
},
};
}
const renderedSystemInfo = renderSystemInfo({
items: [
getPodkopVersionRow(),
{
key: 'Luci App',
value: normalizeCompiledVersion(diagnosticsSystemInfo.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 onStoreUpdate(
next: StoreType,
prev: StoreType,
diff: Partial<StoreType>,
) {
if (diff.diagnosticsChecks) {
renderDiagnosticsChecks();
}
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 runFakeIPCheck();
} catch (e) {
logger.error('[DIAGNOSTIC]', 'runChecks - e', e);
} finally {
store.set({ diagnosticsRunAction: { loading: false } });
}
}
function onPageMount() {
// Cleanup before mount
onPageUnmount();
// Add new listener
store.subscribe(onStoreUpdate);
// Initial checks render
renderDiagnosticsChecks();
// Initial run checks action render
renderDiagnosticRunActionWidget();
// Initial available actions render
renderDiagnosticAvailableActionsWidget();
// Initial system info render
renderDiagnosticSystemInfoWidget();
// Initial services info fetch
fetchServicesInfo();
// Initial system info fetch
fetchSystemInfo();
}
function onPageUnmount() {
// Remove old listener
store.unsubscribe(onStoreUpdate);
// Clear store
store.reset([
'diagnosticsActions',
'diagnosticsSystemInfo',
'diagnosticsChecks',
'diagnosticsRunAction',
]);
}
function registerLifecycleListeners() {
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 onPageMount();
}
if (!isDIAGNOSTICVisible) {
logger.debug(
'[DIAGNOSTIC]',
'registerLifecycleListeners',
'onPageUnmount',
);
return onPageUnmount();
}
}
});
}
export async function initController(): Promise<void> {
onMount('diagnostic-status').then(() => {
logger.debug('[DIAGNOSTIC]', 'initController', 'onMount');
onPageMount();
registerLifecycleListeners();
});
}

View File

@@ -0,0 +1,4 @@
export * from './renderAvailableActions';
export * from './renderCheckSection';
export * from './renderRunAction';
export * from './renderSystemInfo';

View File

@@ -0,0 +1,122 @@
import { renderButton } from '../../../../partials';
import {
renderCircleCheckBigIcon24,
renderCirclePlayIcon24,
renderCircleStopIcon24,
renderCogIcon24,
renderPauseIcon24,
renderPlayIcon24,
renderRotateCcwIcon24,
renderSquareChartGanttIcon24,
} from '../../../../icons';
import { insertIf } from '../../../../helpers';
interface ActionProps {
loading: boolean;
visible: boolean;
disabled: boolean;
onClick: () => void;
}
interface IRenderAvailableActionsProps {
restart: ActionProps;
start: ActionProps;
stop: ActionProps;
enable: ActionProps;
disable: ActionProps;
globalCheck: ActionProps;
viewLogs: ActionProps;
showSingBoxConfig: ActionProps;
}
export function renderAvailableActions({
restart,
start,
stop,
enable,
disable,
globalCheck,
viewLogs,
showSingBoxConfig,
}: IRenderAvailableActionsProps) {
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,
}),
]),
]);
}

View File

@@ -0,0 +1,190 @@
import {
renderCheckIcon24,
renderCircleAlertIcon24,
renderCircleCheckIcon24,
renderCircleSlashIcon24,
renderCircleXIcon24,
renderLoaderCircleIcon24,
renderTriangleAlertIcon24,
renderXIcon24,
} from '../../../../icons';
import { IDiagnosticsChecksStoreItem } from '../../../services';
type IRenderCheckSectionProps = IDiagnosticsChecksStoreItem;
function renderCheckSummary(items: IRenderCheckSectionProps['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 renderLoadingState(props: IRenderCheckSectionProps) {
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: IRenderCheckSectionProps) {
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: IRenderCheckSectionProps) {
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: IRenderCheckSectionProps) {
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: IRenderCheckSectionProps) {
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),
],
);
}
export function renderCheckSection(props: IRenderCheckSectionProps) {
if (props.state === 'loading') {
return renderLoadingState(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'));
}

View File

@@ -0,0 +1,22 @@
import { renderButton } from '../../../../partials';
import { renderSearchIcon24 } from '../../../../icons';
interface IRenderDiagnosticRunActionProps {
loading: boolean;
click: () => void;
}
export function renderRunAction({
loading,
click,
}: IRenderDiagnosticRunActionProps) {
return E('div', { class: 'pdk_diagnostic-page__run_check_wrapper' }, [
renderButton({
text: _('Run Diagnostic'),
onClick: click,
icon: renderSearchIcon24,
loading,
classNames: ['cbi-button-apply'],
}),
]);
}

View File

@@ -0,0 +1,49 @@
import { insertIf } from '../../../../helpers';
export interface IRenderSystemInfoRow {
key: string;
value: string;
tag?: {
label: string;
kind: 'warning' | 'success';
};
}
interface IRenderSystemInfoProps {
items: Array<IRenderSystemInfoRow>;
}
export function renderSystemInfo({ items }: IRenderSystemInfoProps) {
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),
]),
],
);
}),
]);
}

View File

@@ -0,0 +1,15 @@
export function render() {
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-actions' }),
E('div', { id: 'pdk_diagnostic-page-system-info' }),
]),
]);
}

Some files were not shown because too many files have changed in this diff Show More