mirror of
https://github.com/itdoginfo/podkop.git
synced 2025-12-06 11:36:50 +03:00
Compare commits
195 Commits
0.6.2
...
5b3421498e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5b3421498e | ||
|
|
6a48a060e1 | ||
|
|
14f704fcb8 | ||
|
|
ff43f477e9 | ||
|
|
576e58fd17 | ||
|
|
7a497f1e31 | ||
|
|
d52f6e26ae | ||
|
|
68c61aed50 | ||
|
|
626ac981eb | ||
|
|
352d10a047 | ||
|
|
031c419ffb | ||
|
|
c13fdf5785 | ||
|
|
1b7ab606ba | ||
|
|
2bf208ecac | ||
|
|
e256e4bee5 | ||
|
|
32c385b309 | ||
|
|
56829c74c8 | ||
|
|
9d78cd2ce4 | ||
|
|
d9ce3b361e | ||
|
|
c67aadf267 | ||
|
|
ac4d7570f3 | ||
|
|
86897fd0af | ||
|
|
230ffbce46 | ||
|
|
dd5ddd1a14 | ||
|
|
cc947f9734 | ||
|
|
f8510cd828 | ||
|
|
23cbe7be4a | ||
|
|
f168fb7e31 | ||
|
|
fe84b3154f | ||
|
|
d09fdc0b95 | ||
|
|
835cd85970 | ||
|
|
8a3b41ec9c | ||
|
|
10d7617739 | ||
|
|
68010ed5f7 | ||
|
|
557e3666eb | ||
|
|
01bff8ccfb | ||
|
|
675a6af89c | ||
|
|
f1a6ff3469 | ||
|
|
d4b3377d68 | ||
|
|
d2ef640d76 | ||
|
|
47457f2c27 | ||
|
|
8a29e176f2 | ||
|
|
9653310208 | ||
|
|
3540610c78 | ||
|
|
fb54d62a7f | ||
|
|
288b8d4cc2 | ||
|
|
e014396ae2 | ||
|
|
694e4ca35a | ||
|
|
788c539e16 | ||
|
|
743cba8936 | ||
|
|
d1d703764c | ||
|
|
2efd415305 | ||
|
|
407b19b3ed | ||
|
|
c3fac995d5 | ||
|
|
21ecfbbeca | ||
|
|
2918487845 | ||
|
|
ac258c53c0 | ||
|
|
9a389c47bf | ||
|
|
7cd70468c5 | ||
|
|
13d27dab21 | ||
|
|
9f8f032dce | ||
|
|
8301f4c271 | ||
|
|
c4078c8242 | ||
|
|
e0d149f03a | ||
|
|
0f77867ca2 | ||
|
|
fb5ae9c1e8 | ||
|
|
9e9bd5a2bd | ||
|
|
005574a01f | ||
|
|
a4bddeb430 | ||
|
|
d335d59f1b | ||
|
|
272ce012d7 | ||
|
|
64aa28f4e4 | ||
|
|
e89f89ea96 | ||
|
|
8fb8aad53b | ||
|
|
c1311fdd4b | ||
|
|
2cbaa888b2 | ||
|
|
25bb2355aa | ||
|
|
a2eac6f103 | ||
|
|
b5eec292e0 | ||
|
|
5573fce1b1 | ||
|
|
a3ac01478f | ||
|
|
2fb38286bd | ||
|
|
ac82cc1770 | ||
|
|
e8a3725948 | ||
|
|
686841c2a1 | ||
|
|
3379764ada | ||
|
|
1acdbe67a2 | ||
|
|
3bccf8d617 | ||
|
|
8384e18a22 | ||
|
|
b78682919a | ||
|
|
e8a5d3d5cc | ||
|
|
ed7b7e9c6d | ||
|
|
f4be831b5e | ||
|
|
4186292aa7 | ||
|
|
ef70f4e53d | ||
|
|
f0290fcc9e | ||
|
|
49dd1d608f | ||
|
|
9c01c8e2dd | ||
|
|
d0b06dd829 | ||
|
|
024c258d92 | ||
|
|
33b44fd9b3 | ||
|
|
8ff9562dcf | ||
|
|
9d5cdc3e90 | ||
|
|
72ad10d737 | ||
|
|
e7f3d15bce | ||
|
|
c0e3e256e3 | ||
|
|
08615b6f04 | ||
|
|
9d4c37b9a2 | ||
|
|
13f15dcf11 | ||
|
|
213b4603b7 | ||
|
|
f6e347af78 | ||
|
|
7ab0384e0b | ||
|
|
4d4164ae6f | ||
|
|
f155d6a118 | ||
|
|
96039f92a9 | ||
|
|
fd64eb5bcb | ||
|
|
d7235e8c06 | ||
|
|
30b30dcca6 | ||
|
|
97ab638b31 | ||
|
|
7dd3f33284 | ||
|
|
02a49ed067 | ||
|
|
af36cf3026 | ||
|
|
cfb821974f | ||
|
|
40dac07b29 | ||
|
|
d8b7e12c4d | ||
|
|
c0b35c865d | ||
|
|
c35a174708 | ||
|
|
b2a6971700 | ||
|
|
46ec79e003 | ||
|
|
d51ac63c94 | ||
|
|
53b71ec4b0 | ||
|
|
5087be83d3 | ||
|
|
6772b83861 | ||
|
|
b8ccb4abfa | ||
|
|
739e0d2ba7 | ||
|
|
ffa0073441 | ||
|
|
7cd32910d9 | ||
|
|
67ec5f3090 | ||
|
|
33dfb8c3f0 | ||
|
|
de3e67f999 | ||
|
|
a9fdf286e0 | ||
|
|
dbf7e39599 | ||
|
|
fa152c3abf | ||
|
|
661ba64879 | ||
|
|
953b669520 | ||
|
|
3f6f03c8d1 | ||
|
|
d39ee3a666 | ||
|
|
45bd2d0499 | ||
|
|
85b1dc75f5 | ||
|
|
f7517e6794 | ||
|
|
2e257e4adf | ||
|
|
74edbcf07f | ||
|
|
aea6fd9453 | ||
|
|
0fba31c10a | ||
|
|
a7150f7143 | ||
|
|
44894f3257 | ||
|
|
f20e205b72 | ||
|
|
7a2868b630 | ||
|
|
55df0f283d | ||
|
|
e3e0b2d4e4 | ||
|
|
4334643e8e | ||
|
|
5486dfb0a4 | ||
|
|
fd0b981186 | ||
|
|
d041334d88 | ||
|
|
791cc1c945 | ||
|
|
63d56e736d | ||
|
|
a33b53743f | ||
|
|
3d12327868 | ||
|
|
1bdd49e198 | ||
|
|
b90f520c68 | ||
|
|
7bfb673b49 | ||
|
|
ee93c26098 | ||
|
|
f95d801d44 | ||
|
|
ca5a3a79fe | ||
|
|
f128bc4ec7 | ||
|
|
458fd9251a | ||
|
|
35d9441837 | ||
|
|
e3557f374e | ||
|
|
1e6b555bfa | ||
|
|
036808917d | ||
|
|
687334bf8d | ||
|
|
095b3c6fa9 | ||
|
|
ba69e3eacc | ||
|
|
9be0eb3e57 | ||
|
|
d3847db313 | ||
|
|
ba91c180e8 | ||
|
|
8a80df9dc0 | ||
|
|
d2f0de39d9 | ||
|
|
e662f25f53 | ||
|
|
3042a86412 | ||
|
|
9f1505db48 | ||
|
|
34404f6e40 | ||
|
|
9e0135983f | ||
|
|
d176f24a7f | ||
|
|
acd1ca1bcb |
49
.github/workflows/shellcheck.yml
vendored
Normal file
49
.github/workflows/shellcheck.yml
vendored
Normal 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
1
.gitignore
vendored
@@ -1,3 +1,4 @@
|
||||
.idea
|
||||
fe-app-podkop/node_modules
|
||||
fe-app-podkop/.env
|
||||
.DS_Store
|
||||
|
||||
46
README.md
46
README.md
@@ -1,16 +1,17 @@
|
||||
# Вещи, которые вам нужно знать перед установкой
|
||||
|
||||
- Это бета-версия, которая находится в активной разработке. Из версии в версию что-то может меняться.
|
||||
- При возникновении проблем, нужен технически грамотный фидбэк в чат.
|
||||
- При возникновении проблем, нужен технически грамотный фидбэк в чат. Ознакомьтесь с закрепом в топике.
|
||||
- При обновлении **обязательно** [сбрасывайте кэш LuCI](https://podkop.net/docs/clear-browser-cache/).
|
||||
- Также при обновлении всегда заходите в конфигурацию и проверяйте свои настройки. Конфигурация может измениться.
|
||||
- Необходимо минимум 25МБ свободного места на роутере. Роутеры с флешками на 16МБ сразу мимо.
|
||||
- При старте программы редактируется конфиг Dnsmasq.
|
||||
- Podkop редактирует конфиг sing-box. Обязательно сохраните ваш конфиг sing-box перед установкой, если он вам нужен.
|
||||
- Информация здесь может быть устаревшей. Все изменения фиксируются в [телеграм-чате](https://t.me/itdogchat/81758/420321).
|
||||
- [Если у вас не что-то не работает.](https://podkop.net/docs/diagnostics/)
|
||||
- [Если у вас что-то не работает.](https://podkop.net/docs/diagnostics/)
|
||||
- Если у вас установлен Getdomains, [его следует удалить](https://github.com/itdoginfo/domain-routing-openwrt?tab=readme-ov-file#%D1%81%D0%BA%D1%80%D0%B8%D0%BF%D1%82-%D0%B4%D0%BB%D1%8F-%D1%83%D0%B4%D0%B0%D0%BB%D0%B5%D0%BD%D0%B8%D1%8F).
|
||||
- Требуется версия OpenWrt 24.10.
|
||||
- Dashboard доступен, если вы заходите по http (из-за особенностей clash api). И не будет работать, если вы заходите по https и/или домену.
|
||||
|
||||
# Документация
|
||||
https://podkop.net/
|
||||
@@ -23,33 +24,38 @@ 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
|
||||
Этот раздел не означает задачи, которые нужно брать и делать. Это общий список хотелок. Если вы хотите помочь, пожалуйста, спросите сначала в телеграмме.
|
||||
|
||||
Основные задачи в issues.
|
||||
|
||||
## Рефактор
|
||||
- [x] Очевидные повторения в `/usr/bin/podkop` загнать в переменые
|
||||
- [x] Возможно поменять структуру
|
||||
|
||||
## Списки
|
||||
- [x] CloudFront
|
||||
- [x] DO
|
||||
- [x] HODCA
|
||||
> [!IMPORTANT]
|
||||
> PR принимаются только по issues, у которых стоит label "enhancement". Либо по согласованию с авторами в ТГ-чате. Остальные PR на данный момент не рассматриваются.
|
||||
|
||||
## Будущее
|
||||
- [ ] [Подписка](https://github.com/itdoginfo/podkop/issues/118). Здесь нужна реализация, чтоб для каждой секции помимо ручного выбора, был выбор фильтрации по тегу. Например, для main выбираем ключевые слова NL, DE, FI. А для extra секции фильтруем по RU. И создаётся outbound c urltest в которых перечислены outbound из фильтров.
|
||||
- [x] Опция, когда все запросы (с роутера в первую очередь), а не только br-lan идут в прокси. С этим связана #95. Требуется много переделать для nftables.
|
||||
- [ ] Весь трафик в Proxy\VPN. Вопрос, что делать с экстрасекциями в этом случае. FakeIP здесь скорее не нужен, а значит только main секция остаётся. Всё что касается fakeip проверок, придётся выключать в этом режиме.
|
||||
- [x] Поддержка Source format. Нужна расшифровка в json и если присуствуют подсети, заносить их в custom subnet nftset.
|
||||
- [x] Переделывание функции формирования кастомных списков в JSON. Обрабатывать сразу скопом, а не по одному.
|
||||
- [ ] При успешном запуске переходит в фоновый режим и следит за состоянием sing-box. Если вдруг идёт exit 1, выполняется dnsmasq restore и снова следит за состоянием. Вопрос в том, как это искусcтвенно провернуть. Попробовать положить прокси и посмотреть, останется ли работать DNS в этом случае. И здесь, вероятно, можно обойтись триггером в init.d. [Issue](https://github.com/itdoginfo/podkop/issues/111)
|
||||
- [x] Формирование конфига sing-box в /tmp
|
||||
- [ ] Весь трафик в sing-box и маршрутизация полностью на его уровне.
|
||||
- [ ] При успешном запуске переходит в фоновый режим и следит за состоянием sing-box. Если вдруг идёт exit 1, выполняется dnsmasq restore и снова следит за состоянием. Вопрос в том, как это искусственно провернуть. Попробовать положить прокси и посмотреть, останется ли работать DNS в этом случае. И здесь, вероятно, можно обойтись триггером в init.d. [Issue](https://github.com/itdoginfo/podkop/issues/111)
|
||||
- [ ] Галочка, которая режет доступ к doh серверам.
|
||||
- [ ] IPv6. Только после наполнения Wiki.
|
||||
|
||||
## Тесты
|
||||
- [ ] Unit тесты (BATS)
|
||||
- [ ] Интеграционые тесты бекенда (OpenWrt rootfs + BATS)
|
||||
- [ ] Интеграционные тесты бекенда (OpenWrt rootfs + BATS)
|
||||
|
||||
[](https://deepwiki.com/itdoginfo/podkop)
|
||||
@@ -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
|
||||
@@ -73,4 +81,38 @@ trojan://ou8pLSyx9N@127.0.0.1:17737?type=httpupgrade&path=%2Fhttpupgradepath&hos
|
||||
|
||||
# XHTTP
|
||||
trojan://VEetltxLtw@127.0.0.1:59072?type=xhttp&path=%2Fxhttppath&host=google.com&mode=auto&security=none#trojan-xhttp
|
||||
```
|
||||
|
||||
## Hysteria2
|
||||
|
||||
hysteria2://
|
||||
```
|
||||
# With password
|
||||
hysteria2://password@example.com:443/#hysteria2-password
|
||||
hysteria2://password@example.com:443/?insecure=1#hysteria2-password-insecure
|
||||
|
||||
# With SNI
|
||||
hysteria2://password@example.com:443/?sni=example.com#hysteria2-password-sni
|
||||
|
||||
# With obfuscation
|
||||
hysteria2://password@example.com:443/?obfs=salamander&obfs-password=obfspassword#hysteria2-obfs
|
||||
|
||||
# All parameters combined
|
||||
hysteria2://mypassword@example.com:8443/?sni=example.com&obfs=salamander&obfs-password=obfspass&insecure=1#hysteria2-all-params
|
||||
```
|
||||
|
||||
hy2://
|
||||
```
|
||||
# With password
|
||||
hy2://password@example.com:443/#hysteria2-password
|
||||
hy2://password@example.com:443/?insecure=1#hysteria2-password-insecure
|
||||
|
||||
# With SNI
|
||||
hy2://password@example.com:443/?sni=example.com#hysteria2-password-sni
|
||||
|
||||
# With obfuscation
|
||||
hy2://password@example.com:443/?obfs=salamander&obfs-password=obfspassword#hysteria2-obfs
|
||||
|
||||
# All parameters combined
|
||||
hy2://mypassword@example.com:8443/?sni=example.com&obfs=salamander&obfs-password=obfspass&insecure=1#hysteria2-all-params
|
||||
```
|
||||
16
fe-app-podkop/.env.example
Normal file
16
fe-app-podkop/.env.example
Normal 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/
|
||||
38
fe-app-podkop/distribute-locales.js
Normal file
38
fe-app-podkop/distribute-locales.js
Normal 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);
|
||||
});
|
||||
75
fe-app-podkop/extract-calls.js
Normal file
75
fe-app-podkop/extract-calls.js
Normal 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}`);
|
||||
113
fe-app-podkop/generate-po.js
Normal file
113
fe-app-podkop/generate-po.js
Normal 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);
|
||||
});
|
||||
73
fe-app-podkop/generate-pot.js
Normal file
73
fe-app-podkop/generate-pot.js
Normal 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);
|
||||
1740
fe-app-podkop/locales/calls.json
Normal file
1740
fe-app-podkop/locales/calls.json
Normal file
File diff suppressed because it is too large
Load Diff
1035
fe-app-podkop/locales/podkop.pot
Normal file
1035
fe-app-podkop/locales/podkop.pot
Normal file
File diff suppressed because it is too large
Load Diff
738
fe-app-podkop/locales/podkop.ru.po
Normal file
738
fe-app-podkop/locales/podkop.ru.po
Normal file
@@ -0,0 +1,738 @@
|
||||
# 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-11-06 16:19+0200\n"
|
||||
"PO-Revision-Date: 2025-11-06 16:19+0200\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 "Allows access to YACD from the WAN. Make sure to open the appropriate port in your firewall."
|
||||
msgstr "Обеспечивает доступ к YACD из WAN. Убедитесь, что в брандмауэре открыт соответствующий порт."
|
||||
|
||||
msgid "Applicable for SOCKS and Shadowsocks proxy"
|
||||
msgstr "Применимо для SOCKS и Shadowsocks прокси"
|
||||
|
||||
msgid "At least one valid domain must be specified. Comments-only content is not allowed."
|
||||
msgstr "Необходимо указать хотя бы один действительный домен. Содержимое только из комментариев не допускается."
|
||||
|
||||
msgid "At least one valid subnet or IP must be specified. Comments-only content is not allowed."
|
||||
msgstr "Необходимо указать хотя бы одну действительную подсеть или IP. Только комментарии недопустимы."
|
||||
|
||||
msgid "Available actions"
|
||||
msgstr "Доступные действия"
|
||||
|
||||
msgid "Bootsrap DNS"
|
||||
msgstr "Bootstrap DNS"
|
||||
|
||||
msgid "Bootstrap DNS server"
|
||||
msgstr "Bootstrap DNS-сервер"
|
||||
|
||||
msgid "Browser is not using FakeIP"
|
||||
msgstr "Браузер не использует FakeIP"
|
||||
|
||||
msgid "Browser is using FakeIP correctly"
|
||||
msgstr "Браузер использует FakeIP"
|
||||
|
||||
msgid "Cache File Path"
|
||||
msgstr "Путь к файлу кэша"
|
||||
|
||||
msgid "Cache file path cannot be empty"
|
||||
msgstr "Путь к файлу кэша не может быть пустым"
|
||||
|
||||
msgid "Cannot receive checks result"
|
||||
msgstr "Не удалось получить результаты проверки"
|
||||
|
||||
msgid "Checking, please wait"
|
||||
msgstr "Проверяем, пожалуйста подождите"
|
||||
|
||||
msgid "checks"
|
||||
msgstr "проверки"
|
||||
|
||||
msgid "Checks failed"
|
||||
msgstr "Проверки не выполнены"
|
||||
|
||||
msgid "Checks passed"
|
||||
msgstr "Проверки пройдены"
|
||||
|
||||
msgid "CIDR must be between 0 and 32"
|
||||
msgstr "CIDR должен быть между 0 и 32"
|
||||
|
||||
msgid "Close"
|
||||
msgstr "Закрыть"
|
||||
|
||||
msgid "Community Lists"
|
||||
msgstr "Списки сообщества"
|
||||
|
||||
msgid "Config File Path"
|
||||
msgstr "Путь к файлу конфигурации"
|
||||
|
||||
msgid "Configuration for Podkop service"
|
||||
msgstr "Настройки сервиса Podkop"
|
||||
|
||||
msgid "Configuration Type"
|
||||
msgstr "Тип конфигурации"
|
||||
|
||||
msgid "Connection Type"
|
||||
msgstr "Тип подключения"
|
||||
|
||||
msgid "Connection URL"
|
||||
msgstr "URL подключения"
|
||||
|
||||
msgid "Copy"
|
||||
msgstr "Копировать"
|
||||
|
||||
msgid "Currently unavailable"
|
||||
msgstr "Временно недоступно"
|
||||
|
||||
msgid "Dashboard"
|
||||
msgstr "Дашборд"
|
||||
|
||||
msgid "Dashboard currently unavailable"
|
||||
msgstr "Дашборд сейчас недоступен"
|
||||
|
||||
msgid "Delay in milliseconds before reloading podkop after interface UP"
|
||||
msgstr "Задержка в миллисекундах перед перезагрузкой podkop после поднятия интерфейса"
|
||||
|
||||
msgid "Delay value cannot be empty"
|
||||
msgstr "Значение задержки не может быть пустым"
|
||||
|
||||
msgid "DHCP has DNS server"
|
||||
msgstr "DHCP содержит DNS сервер"
|
||||
|
||||
msgid "Diagnostics"
|
||||
msgstr "Диагностика"
|
||||
|
||||
msgid "Disable autostart"
|
||||
msgstr "Отключить автостарт"
|
||||
|
||||
msgid "Disable QUIC"
|
||||
msgstr "Отключить QUIC"
|
||||
|
||||
msgid "Disable the QUIC protocol to improve compatibility or fix issues with video streaming"
|
||||
msgstr "Отключить QUIC протокол для улучшения совместимости или исправления видео стриминга"
|
||||
|
||||
msgid "Disabled"
|
||||
msgstr "Отключено"
|
||||
|
||||
msgid "DNS on router"
|
||||
msgstr "DNS на роутере"
|
||||
|
||||
msgid "DNS over HTTPS (DoH)"
|
||||
msgstr "DNS через HTTPS (DoH)"
|
||||
|
||||
msgid "DNS over TLS (DoT)"
|
||||
msgstr "DNS через TLS (DoT)"
|
||||
|
||||
msgid "DNS Protocol Type"
|
||||
msgstr "Тип протокола DNS"
|
||||
|
||||
msgid "DNS Rewrite TTL"
|
||||
msgstr "Перезапись TTL для DNS"
|
||||
|
||||
msgid "DNS Server"
|
||||
msgstr "DNS-сервер"
|
||||
|
||||
msgid "DNS server address cannot be empty"
|
||||
msgstr "Адрес DNS-сервера не может быть пустым"
|
||||
|
||||
msgid "Do not panic, everything can be fixed, just..."
|
||||
msgstr "Не паникуйте, всё можно исправить, просто..."
|
||||
|
||||
msgid "Domain Resolver"
|
||||
msgstr "Резолвер доменов"
|
||||
|
||||
msgid "Dont Touch My DHCP!"
|
||||
msgstr "Dont Touch My DHCP!"
|
||||
|
||||
msgid "Downlink"
|
||||
msgstr "Входящий"
|
||||
|
||||
msgid "Download"
|
||||
msgstr "Скачать"
|
||||
|
||||
msgid "Download Lists via Proxy/VPN"
|
||||
msgstr "Скачивать списки через Proxy/VPN"
|
||||
|
||||
msgid "Download Lists via specific proxy section"
|
||||
msgstr "Скачивать списки через выбранную секцию"
|
||||
|
||||
msgid "Downloading all lists via specific Proxy/VPN"
|
||||
msgstr "Загрузка всех списков через указанный прокси/VPN"
|
||||
|
||||
msgid "Dynamic List"
|
||||
msgstr "Динамический список"
|
||||
|
||||
msgid "Enable autostart"
|
||||
msgstr "Включить автостарт"
|
||||
|
||||
msgid "Enable built-in DNS resolver for domains handled by this section"
|
||||
msgstr "Включить встроенный DNS-резолвер для доменов, обрабатываемых в этом разделе"
|
||||
|
||||
msgid "Enable Mixed Proxy"
|
||||
msgstr "Включить смешанный прокси"
|
||||
|
||||
msgid "Enable Output Network Interface"
|
||||
msgstr "Включить выходной сетевой интерфейс"
|
||||
|
||||
msgid "Enable the mixed proxy, allowing this section to route traffic through both HTTP and SOCKS proxies"
|
||||
msgstr "Включить смешанный прокси-сервер, разрешив этому разделу маршрутизировать трафик как через HTTP, так и через SOCKS-прокси."
|
||||
|
||||
msgid "Enable YACD"
|
||||
msgstr "Включить YACD"
|
||||
|
||||
msgid "Enable YACD WAN Access"
|
||||
msgstr "Включить доступ YACD WAN"
|
||||
|
||||
msgid "Enter complete outbound configuration in JSON format"
|
||||
msgstr "Введите полную конфигурацию исходящего соединения в формате JSON"
|
||||
|
||||
msgid "Enter domain names separated by commas, spaces, or newlines. You can add comments using //"
|
||||
msgstr "Введите доменные имена, разделяя их запятыми, пробелами или переносами строк. Вы можете добавлять комментарии, используя //"
|
||||
|
||||
msgid "Enter domain names without protocols, e.g. example.com or sub.example.com"
|
||||
msgstr "Введите доменные имена без протоколов, например example.com или sub.example.com"
|
||||
|
||||
msgid "Enter subnets in CIDR notation (e.g. 103.21.244.0/22) or single IP addresses"
|
||||
msgstr "Введите подсети в нотации CIDR (например, 103.21.244.0/22) или отдельные IP-адреса"
|
||||
|
||||
msgid "Every 1 minute"
|
||||
msgstr "Каждую минуту"
|
||||
|
||||
msgid "Every 3 minutes"
|
||||
msgstr "Каждые 3 минуты"
|
||||
|
||||
msgid "Every 30 seconds"
|
||||
msgstr "Каждые 30 секунд"
|
||||
|
||||
msgid "Every 5 minutes"
|
||||
msgstr "Каждые 5 минут"
|
||||
|
||||
msgid "Exclude NTP"
|
||||
msgstr "Исключить NTP"
|
||||
|
||||
msgid "Exclude NTP protocol traffic from the tunnel to prevent it from being routed through the proxy or VPN"
|
||||
msgstr "Исключите трафик протокола NTP из туннеля, чтобы предотвратить его маршрутизацию через прокси-сервер или VPN."
|
||||
|
||||
msgid "Failed to copy!"
|
||||
msgstr "Не удалось скопировать!"
|
||||
|
||||
msgid "Failed to execute!"
|
||||
msgstr "Не удалось выполнить!"
|
||||
|
||||
msgid "Fastest"
|
||||
msgstr "Самый быстрый"
|
||||
|
||||
msgid "Fully Routed IPs"
|
||||
msgstr "Полностью маршрутизированные IP-адреса"
|
||||
|
||||
msgid "Get global check"
|
||||
msgstr "Получить глобальную проверку"
|
||||
|
||||
msgid "Global check"
|
||||
msgstr "Глобальная проверка"
|
||||
|
||||
msgid "HTTP error"
|
||||
msgstr "Ошибка HTTP"
|
||||
|
||||
msgid "Interface Monitoring"
|
||||
msgstr "Мониторинг интерфейса"
|
||||
|
||||
msgid "Interface Monitoring Delay"
|
||||
msgstr "Задержка при мониторинге интерфейсов"
|
||||
|
||||
msgid "Interface monitoring for Bad WAN"
|
||||
msgstr "Мониторинг интерфейса для Bad WAN"
|
||||
|
||||
msgid "Invalid DNS server format. Examples: 8.8.8.8 or dns.example.com or dns.example.com/nicedns for DoH"
|
||||
msgstr "Неверный формат DNS-сервера. Примеры: 8.8.8.8, dns.example.com или dns.example.com/nicedns для DoH"
|
||||
|
||||
msgid "Invalid domain address"
|
||||
msgstr "Неверный домен"
|
||||
|
||||
msgid "Invalid format. Use X.X.X.X or X.X.X.X/Y"
|
||||
msgstr "Неверный формат. Используйте X.X.X.X или X.X.X.X/Y"
|
||||
|
||||
msgid "Invalid IP address"
|
||||
msgstr "Неверный IP-адрес"
|
||||
|
||||
msgid "Invalid JSON format"
|
||||
msgstr "Неверный формат JSON"
|
||||
|
||||
msgid "Invalid path format. Path must start with \"/\" and contain valid characters"
|
||||
msgstr "Неверный формат пути. Путь должен начинаться с \"/\" и содержать допустимые символы"
|
||||
|
||||
msgid "Invalid port number. Must be between 1 and 65535"
|
||||
msgstr "Неверный номер порта. Допустимо от 1 до 65535"
|
||||
|
||||
msgid "Invalid Shadowsocks URL: decoded credentials must contain method:password"
|
||||
msgstr "Неверный URL Shadowsocks: декодированные данные должны содержать method:password"
|
||||
|
||||
msgid "Invalid Shadowsocks URL: missing credentials"
|
||||
msgstr "Неверный URL Shadowsocks: отсутствуют учетные данные"
|
||||
|
||||
msgid "Invalid Shadowsocks URL: missing method and password separator \":\""
|
||||
msgstr "Неверный URL Shadowsocks: отсутствует разделитель метода и пароля \":\""
|
||||
|
||||
msgid "Invalid Shadowsocks URL: missing port"
|
||||
msgstr "Неверный URL Shadowsocks: отсутствует порт"
|
||||
|
||||
msgid "Invalid Shadowsocks URL: missing server"
|
||||
msgstr "Неверный URL Shadowsocks: отсутствует сервер"
|
||||
|
||||
msgid "Invalid Shadowsocks URL: missing server address"
|
||||
msgstr "Неверный URL Shadowsocks: отсутствует адрес сервера"
|
||||
|
||||
msgid "Invalid Shadowsocks URL: must not contain spaces"
|
||||
msgstr "Неверный URL Shadowsocks: не должен содержать пробелов"
|
||||
|
||||
msgid "Invalid Shadowsocks URL: must start with ss://"
|
||||
msgstr "Неверный URL Shadowsocks: должен начинаться с ss://"
|
||||
|
||||
msgid "Invalid Shadowsocks URL: parsing failed"
|
||||
msgstr "Неверный URL Shadowsocks: ошибка разбора"
|
||||
|
||||
msgid "Invalid SOCKS URL: invalid host format"
|
||||
msgstr "Неверный URL SOCKS: неверный формат хоста"
|
||||
|
||||
msgid "Invalid SOCKS URL: invalid port number"
|
||||
msgstr "Неверный URL SOCKS: неверный номер порта"
|
||||
|
||||
msgid "Invalid SOCKS URL: missing host and port"
|
||||
msgstr "Неверный URL SOCKS: отсутствует хост и порт"
|
||||
|
||||
msgid "Invalid SOCKS URL: missing hostname or IP"
|
||||
msgstr "Неверный URL SOCKS: отсутствует имя хоста или IP-адрес"
|
||||
|
||||
msgid "Invalid SOCKS URL: missing port"
|
||||
msgstr "Неверный URL SOCKS: отсутствует порт"
|
||||
|
||||
msgid "Invalid SOCKS URL: missing username"
|
||||
msgstr "Неверный URL SOCKS: отсутствует имя пользователя"
|
||||
|
||||
msgid "Invalid SOCKS URL: must not contain spaces"
|
||||
msgstr "Неверный URL SOCKS: не должен содержать пробелов"
|
||||
|
||||
msgid "Invalid SOCKS URL: must start with socks4://, socks4a://, or socks5://"
|
||||
msgstr "Неверный URL-адрес SOCKS: должен начинаться с socks4://, socks4a:// или socks5://"
|
||||
|
||||
msgid "Invalid SOCKS URL: parsing failed"
|
||||
msgstr "Неверный URL SOCKS: парсинг не удался"
|
||||
|
||||
msgid "Invalid Trojan URL: must not contain spaces"
|
||||
msgstr "Неверный URL Trojan: не должен содержать пробелов"
|
||||
|
||||
msgid "Invalid Trojan URL: must start with trojan://"
|
||||
msgstr "Неверный URL Trojan: должен начинаться с trojan://"
|
||||
|
||||
msgid "Invalid Trojan URL: parsing failed"
|
||||
msgstr "Неверный URL Trojan: ошибка разбора"
|
||||
|
||||
msgid "Invalid URL format"
|
||||
msgstr "Неверный формат URL"
|
||||
|
||||
msgid "Invalid VLESS URL: parsing failed"
|
||||
msgstr "Неверный URL VLESS: ошибка разбора"
|
||||
|
||||
msgid "IP address 0.0.0.0 is not allowed"
|
||||
msgstr "IP-адрес 0.0.0.0 не допускается"
|
||||
|
||||
msgid "Issues detected"
|
||||
msgstr "Обнаружены проблемы"
|
||||
|
||||
msgid "Latest"
|
||||
msgstr "Последняя"
|
||||
|
||||
msgid "List Update Frequency"
|
||||
msgstr "Частота обновления списков"
|
||||
|
||||
msgid "Local Domain Lists"
|
||||
msgstr "Локальные списки доменов"
|
||||
|
||||
msgid "Local Subnet Lists"
|
||||
msgstr "Локальные списки подсетей"
|
||||
|
||||
msgid "Main DNS"
|
||||
msgstr "Основной DNS"
|
||||
|
||||
msgid "Memory Usage"
|
||||
msgstr "Использование памяти"
|
||||
|
||||
msgid "Mixed Proxy Port"
|
||||
msgstr "Порт смешанного прокси"
|
||||
|
||||
msgid "Monitored Interfaces"
|
||||
msgstr "Наблюдаемые интерфейсы"
|
||||
|
||||
msgid "Must be a number in the range of 50 - 1000"
|
||||
msgstr "Должно быть числом от 50 до 1000"
|
||||
|
||||
msgid "Network Interface"
|
||||
msgstr "Сетевой интерфейс"
|
||||
|
||||
msgid "No other marking rules found"
|
||||
msgstr "Другие правила маркировки не найдены"
|
||||
|
||||
msgid "Not implement yet"
|
||||
msgstr "Ещё не реализовано"
|
||||
|
||||
msgid "Not responding"
|
||||
msgstr "Не отвечает"
|
||||
|
||||
msgid "Not running"
|
||||
msgstr "Не запущено"
|
||||
|
||||
msgid "Operation timed out"
|
||||
msgstr "Время ожидания истекло"
|
||||
|
||||
msgid "Outbound Config"
|
||||
msgstr "Конфигурация Outbound"
|
||||
|
||||
msgid "Outbound Configuration"
|
||||
msgstr "Конфигурация исходящего соединения"
|
||||
|
||||
msgid "Outdated"
|
||||
msgstr "Устаревшая"
|
||||
|
||||
msgid "Output Network Interface"
|
||||
msgstr "Выходной сетевой интерфейс"
|
||||
|
||||
msgid "Path cannot be empty"
|
||||
msgstr "Путь не может быть пустым"
|
||||
|
||||
msgid "Path must be absolute (start with /)"
|
||||
msgstr "Путь должен быть абсолютным (начинаться с /)"
|
||||
|
||||
msgid "Path must contain at least one directory (like /tmp/cache.db)"
|
||||
msgstr "Путь должен содержать хотя бы одну директорию (например /tmp/cache.db)"
|
||||
|
||||
msgid "Path must end with cache.db"
|
||||
msgstr "Путь должен заканчиваться на cache.db"
|
||||
|
||||
msgid "Pending"
|
||||
msgstr "Ожидает запуска"
|
||||
|
||||
msgid "Podkop"
|
||||
msgstr "Podkop"
|
||||
|
||||
msgid "Podkop Settings"
|
||||
msgstr "Настройки podkop"
|
||||
|
||||
msgid "Podkop will not modify your DHCP configuration"
|
||||
msgstr "Podkop не будет изменять вашу конфигурацию DHCP."
|
||||
|
||||
msgid "Proxy Configuration URL"
|
||||
msgstr "URL конфигурации прокси"
|
||||
|
||||
msgid "Proxy traffic is not routed via FakeIP"
|
||||
msgstr "Прокси-трафик не маршрутизируется через FakeIP"
|
||||
|
||||
msgid "Proxy traffic is routed via FakeIP"
|
||||
msgstr "Прокси-трафик направляется через FakeIP"
|
||||
|
||||
msgid "Regional options cannot be used together"
|
||||
msgstr "Нельзя использовать несколько региональных опций одновременно"
|
||||
|
||||
msgid "Remote Domain Lists"
|
||||
msgstr "Внешние списки доменов"
|
||||
|
||||
msgid "Remote Subnet Lists"
|
||||
msgstr "Внешние списки подсетей"
|
||||
|
||||
msgid "Restart podkop"
|
||||
msgstr "Перезапустить Podkop"
|
||||
|
||||
msgid "Router DNS is not routed through sing-box"
|
||||
msgstr "DNS роутера не проходит через sing-box"
|
||||
|
||||
msgid "Router DNS is routed through sing-box"
|
||||
msgstr "DNS роутера проходит через sing-box"
|
||||
|
||||
msgid "Routing Excluded IPs"
|
||||
msgstr "Исключённые из маршрутизации IP-адреса"
|
||||
|
||||
msgid "Rules mangle counters"
|
||||
msgstr "Счётчики правил mangle"
|
||||
|
||||
msgid "Rules mangle exist"
|
||||
msgstr "Правила mangle существуют"
|
||||
|
||||
msgid "Rules mangle output counters"
|
||||
msgstr "Счётчики правил mangle output"
|
||||
|
||||
msgid "Rules mangle output exist"
|
||||
msgstr "Правила mangle output существуют"
|
||||
|
||||
msgid "Rules proxy counters"
|
||||
msgstr "Счётчики правил proxy"
|
||||
|
||||
msgid "Rules proxy exist"
|
||||
msgstr "Правила прокси существуют"
|
||||
|
||||
msgid "Run Diagnostic"
|
||||
msgstr "Запустить диагностику"
|
||||
|
||||
msgid "Russia inside restrictions"
|
||||
msgstr "Ограничения Russia inside"
|
||||
|
||||
msgid "Secret key for authenticating remote access to YACD when WAN access is enabled."
|
||||
msgstr "Секретный ключ для аутентификации удаленного доступа к YACD при включенном доступе через WAN."
|
||||
|
||||
msgid "Sections"
|
||||
msgstr "Секции"
|
||||
|
||||
msgid "Select a predefined list for routing"
|
||||
msgstr "Выберите предопределенный список для маршрутизации"
|
||||
|
||||
msgid "Select between VPN and Proxy connection methods for traffic routing"
|
||||
msgstr "Выберите между VPN и Proxy методами для маршрутизации трафика"
|
||||
|
||||
msgid "Select DNS protocol to use"
|
||||
msgstr "Выберите протокол DNS"
|
||||
|
||||
msgid "Select how often the domain or subnet lists are updated automatically"
|
||||
msgstr "Выберите частоту автоматического обновления списков доменов или подсетей."
|
||||
|
||||
msgid "Select how to configure the proxy"
|
||||
msgstr "Выберите способ настройки прокси"
|
||||
|
||||
msgid "Select network interface for VPN connection"
|
||||
msgstr "Выберите сетевой интерфейс для VPN подключения"
|
||||
|
||||
msgid "Select or enter DNS server address"
|
||||
msgstr "Выберите или введите адрес DNS-сервера"
|
||||
|
||||
msgid "Select or enter path for sing-box cache file. Change this ONLY if you know what you are doing"
|
||||
msgstr "Выберите или введите путь к файлу кеша sing-box. Изменяйте это, ТОЛЬКО если вы знаете, что делаете"
|
||||
|
||||
msgid "Select path for sing-box config file. Change this ONLY if you know what you are doing"
|
||||
msgstr "Выберите путь к файлу конфигурации sing-box. Изменяйте это, ТОЛЬКО если вы знаете, что делаете"
|
||||
|
||||
msgid "Select the DNS protocol type for the domain resolver"
|
||||
msgstr "Выберите тип протокола DNS для резолвера доменов"
|
||||
|
||||
msgid "Select the list type for adding custom domains"
|
||||
msgstr "Выберите тип списка для добавления пользовательских доменов"
|
||||
|
||||
msgid "Select the list type for adding custom subnets"
|
||||
msgstr "Выберите тип списка для добавления пользовательских подсетей"
|
||||
|
||||
msgid "Select the 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 installed"
|
||||
msgstr "Sing-box установлен"
|
||||
|
||||
msgid "Sing-box listening ports"
|
||||
msgstr "Sing-box слушает порты"
|
||||
|
||||
msgid "Sing-box process running"
|
||||
msgstr "Процесс sing-box запущен"
|
||||
|
||||
msgid "Sing-box service exist"
|
||||
msgstr "Сервис sing-box существует"
|
||||
|
||||
msgid "Sing-box version is compatible (newer than 1.12.4)"
|
||||
msgstr "Версия Sing-box совместима (новее 1.12.4)"
|
||||
|
||||
msgid "Source Network Interface"
|
||||
msgstr "Сетевой интерфейс источника"
|
||||
|
||||
msgid "Specify a local IP address to be excluded from routing"
|
||||
msgstr "Укажите локальный IP-адрес, который следует исключить из маршрутизации."
|
||||
|
||||
msgid "Specify local IP addresses or subnets whose traffic will always be routed through the configured route"
|
||||
msgstr "Укажите локальные IP-адреса или подсети, трафик которых всегда будет направляться через настроенный маршрут."
|
||||
|
||||
msgid "Specify remote URLs to download and use domain lists"
|
||||
msgstr "Укажите URL-адреса для загрузки и использования списков доменов."
|
||||
|
||||
msgid "Specify remote URLs to download and use subnet lists"
|
||||
msgstr "Укажите URL-адреса для загрузки и использования списков подсетей."
|
||||
|
||||
msgid "Specify the path to the list file located on the router filesystem"
|
||||
msgstr "Укажите путь к файлу списка, расположенному в файловой системе маршрутизатора."
|
||||
|
||||
msgid "Start podkop"
|
||||
msgstr "Запустить podkop"
|
||||
|
||||
msgid "Stop podkop"
|
||||
msgstr "Остановить podkop"
|
||||
|
||||
msgid "Successfully copied!"
|
||||
msgstr "Успешно скопировано!"
|
||||
|
||||
msgid "System info"
|
||||
msgstr "Системная информация"
|
||||
|
||||
msgid "System information"
|
||||
msgstr "Системная информация"
|
||||
|
||||
msgid "Table exist"
|
||||
msgstr "Таблица существует"
|
||||
|
||||
msgid "Test latency"
|
||||
msgstr "Тестирование задержки"
|
||||
|
||||
msgid "Text List"
|
||||
msgstr "Текстовый список"
|
||||
|
||||
msgid "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 "The interval between connectivity tests"
|
||||
msgstr "Интервал между тестами подключения"
|
||||
|
||||
msgid "The maximum difference in response times (ms) allowed when comparing servers"
|
||||
msgstr "Максимально допустимая разница во времени отклика (мс) при сравнении серверов"
|
||||
|
||||
msgid "The URL used to test server connectivity"
|
||||
msgstr "URL-адрес, используемый для проверки подключения к серверу"
|
||||
|
||||
msgid "Time in seconds for DNS record caching (default: 60)"
|
||||
msgstr "Время в секундах для кэширования DNS записей (по умолчанию: 60)"
|
||||
|
||||
msgid "Traffic"
|
||||
msgstr "Трафик"
|
||||
|
||||
msgid "Traffic Total"
|
||||
msgstr "Всего трафика"
|
||||
|
||||
msgid "Troubleshooting"
|
||||
msgstr "Устранение неполадок"
|
||||
|
||||
msgid "TTL must be a positive number"
|
||||
msgstr "TTL должно быть положительным числом"
|
||||
|
||||
msgid "TTL value cannot be empty"
|
||||
msgstr "Значение TTL не может быть пустым"
|
||||
|
||||
msgid "UDP (Unprotected DNS)"
|
||||
msgstr "UDP (Незащищённый DNS)"
|
||||
|
||||
msgid "UDP over TCP"
|
||||
msgstr "UDP через TCP"
|
||||
|
||||
msgid "unknown"
|
||||
msgstr "неизвестно"
|
||||
|
||||
msgid "Unknown error"
|
||||
msgstr "Неизвестная ошибка"
|
||||
|
||||
msgid "Uplink"
|
||||
msgstr "Исходящий"
|
||||
|
||||
msgid "URL must start with vless://, ss://, trojan://, 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 Check Interval"
|
||||
msgstr "Интервал проверки URLTest"
|
||||
|
||||
msgid "URLTest Proxy Links"
|
||||
msgstr "Ссылки прокси для URLTest"
|
||||
|
||||
msgid "URLTest Testing URL"
|
||||
msgstr "URLTest ссылка для проверки"
|
||||
|
||||
msgid "URLTest Tolerance"
|
||||
msgstr "URLTest допустимое отклонение"
|
||||
|
||||
msgid "User Domain List Type"
|
||||
msgstr "Тип пользовательского списка доменов"
|
||||
|
||||
msgid "User Domains"
|
||||
msgstr "Пользовательские домены"
|
||||
|
||||
msgid "User Domains List"
|
||||
msgstr "Список пользовательских доменов"
|
||||
|
||||
msgid "User Subnet List Type"
|
||||
msgstr "Тип пользовательского списка подсетей"
|
||||
|
||||
msgid "User Subnets"
|
||||
msgstr "Пользовательские подсети"
|
||||
|
||||
msgid "User Subnets List"
|
||||
msgstr "Список пользовательских подсетей"
|
||||
|
||||
msgid "Valid"
|
||||
msgstr "Валидно"
|
||||
|
||||
msgid "Validation errors:"
|
||||
msgstr "Ошибки валидации:"
|
||||
|
||||
msgid "View logs"
|
||||
msgstr "Посмотреть логи"
|
||||
|
||||
msgid "Visit Wiki"
|
||||
msgstr "Перейти в wiki"
|
||||
|
||||
msgid "Warning: %s cannot be used together with %s. Previous selections have been removed."
|
||||
msgstr "Предупреждение: %s нельзя использовать вместе с %s. Предыдущие варианты были удалены."
|
||||
|
||||
msgid "Warning: Russia inside can only be used with %s. %s already in Russia inside and have been removed from selection."
|
||||
msgstr "Предупреждение: Russia inside может быть использован только с %s. %s уже есть в Russia inside и будет удален из выбранных."
|
||||
|
||||
msgid "YACD Secret Key"
|
||||
msgstr "Секретный ключ YACD"
|
||||
|
||||
msgid "You can select Output Network Interface, by default autodetect"
|
||||
msgstr "Вы можете выбрать выходной сетевой интерфейс, по умолчанию он определяется автоматически."
|
||||
@@ -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",
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
export * from './types';
|
||||
export * from './methods';
|
||||
@@ -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'),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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' },
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -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' },
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -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' },
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -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' },
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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' },
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -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 }),
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
16
fe-app-podkop/src/helpers/copyToClipboard.ts
Normal file
16
fe-app-podkop/src/helpers/copyToClipboard.ts
Normal 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);
|
||||
}
|
||||
15
fe-app-podkop/src/helpers/downloadAsTxt.ts
Normal file
15
fe-app-podkop/src/helpers/downloadAsTxt.ts
Normal 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);
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
export function getBaseUrl(): string {
|
||||
const { protocol, hostname } = window.location;
|
||||
return `${protocol}//${hostname}`;
|
||||
}
|
||||
@@ -1,9 +1,3 @@
|
||||
export function getClashApiUrl(): string {
|
||||
const { hostname } = window.location;
|
||||
|
||||
return `http://${hostname}:9090`;
|
||||
}
|
||||
|
||||
export function getClashWsUrl(): string {
|
||||
const { hostname } = window.location;
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
7
fe-app-podkop/src/helpers/insertIf.ts
Normal file
7
fe-app-podkop/src/helpers/insertIf.ts
Normal 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);
|
||||
}
|
||||
7
fe-app-podkop/src/helpers/normalizeCompiledVersion.ts
Normal file
7
fe-app-podkop/src/helpers/normalizeCompiledVersion.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export function normalizeCompiledVersion(version: string) {
|
||||
if (version.includes('COMPILED')) {
|
||||
return 'dev';
|
||||
}
|
||||
|
||||
return version;
|
||||
}
|
||||
24
fe-app-podkop/src/helpers/showToast.ts
Normal file
24
fe-app-podkop/src/helpers/showToast.ts
Normal 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);
|
||||
}
|
||||
18
fe-app-podkop/src/helpers/svgEl.ts
Normal file
18
fe-app-podkop/src/helpers/svgEl.ts
Normal 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;
|
||||
}
|
||||
@@ -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`);
|
||||
}
|
||||
}
|
||||
|
||||
18
fe-app-podkop/src/icons/index.ts
Normal file
18
fe-app-podkop/src/icons/index.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
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';
|
||||
export * from './renderBookOpenTextIcon24';
|
||||
28
fe-app-podkop/src/icons/renderBookOpenTextIcon24.ts
Normal file
28
fe-app-podkop/src/icons/renderBookOpenTextIcon24.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { svgEl } from '../helpers';
|
||||
|
||||
export function renderBookOpenTextIcon24() {
|
||||
const NS = 'http://www.w3.org/2000/svg';
|
||||
return svgEl(
|
||||
'svg',
|
||||
{
|
||||
xmlns: NS,
|
||||
viewBox: '0 0 24 24',
|
||||
fill: 'none',
|
||||
stroke: 'currentColor',
|
||||
'stroke-width': '2',
|
||||
'stroke-linecap': 'round',
|
||||
'stroke-linejoin': 'round',
|
||||
class: 'lucide lucide-book-open-text-icon lucide-book-open-text',
|
||||
},
|
||||
[
|
||||
svgEl('path', { d: 'M12 7v14' }),
|
||||
svgEl('path', { d: 'M16 12h2' }),
|
||||
svgEl('path', { d: 'M16 8h2' }),
|
||||
svgEl('path', {
|
||||
d: 'M3 18a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1h5a4 4 0 0 1 4 4 4 4 0 0 1 4-4h5a1 1 0 0 1 1 1v13a1 1 0 0 1-1 1h-6a3 3 0 0 0-3 3 3 3 0 0 0-3-3z',
|
||||
}),
|
||||
svgEl('path', { d: 'M6 12h2' }),
|
||||
svgEl('path', { d: 'M6 8h2' }),
|
||||
],
|
||||
);
|
||||
}
|
||||
23
fe-app-podkop/src/icons/renderCheckIcon24.ts
Normal file
23
fe-app-podkop/src/icons/renderCheckIcon24.ts
Normal 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',
|
||||
}),
|
||||
],
|
||||
);
|
||||
}
|
||||
39
fe-app-podkop/src/icons/renderCircleAlertIcon24.ts
Normal file
39
fe-app-podkop/src/icons/renderCircleAlertIcon24.ts
Normal 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',
|
||||
}),
|
||||
],
|
||||
);
|
||||
}
|
||||
26
fe-app-podkop/src/icons/renderCircleCheckBigIcon24.ts
Normal file
26
fe-app-podkop/src/icons/renderCircleCheckBigIcon24.ts
Normal 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',
|
||||
}),
|
||||
],
|
||||
);
|
||||
}
|
||||
30
fe-app-podkop/src/icons/renderCircleCheckIcon24.ts
Normal file
30
fe-app-podkop/src/icons/renderCircleCheckIcon24.ts
Normal 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',
|
||||
}),
|
||||
],
|
||||
);
|
||||
}
|
||||
28
fe-app-podkop/src/icons/renderCirclePlayIcon24.ts
Normal file
28
fe-app-podkop/src/icons/renderCirclePlayIcon24.ts
Normal 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',
|
||||
}),
|
||||
],
|
||||
);
|
||||
}
|
||||
33
fe-app-podkop/src/icons/renderCircleSlashIcon24.ts
Normal file
33
fe-app-podkop/src/icons/renderCircleSlashIcon24.ts
Normal 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',
|
||||
}),
|
||||
],
|
||||
);
|
||||
}
|
||||
32
fe-app-podkop/src/icons/renderCircleStopIcon24.ts
Normal file
32
fe-app-podkop/src/icons/renderCircleStopIcon24.ts
Normal 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',
|
||||
}),
|
||||
],
|
||||
);
|
||||
}
|
||||
33
fe-app-podkop/src/icons/renderCircleXIcon24.ts
Normal file
33
fe-app-podkop/src/icons/renderCircleXIcon24.ts
Normal 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',
|
||||
}),
|
||||
],
|
||||
);
|
||||
}
|
||||
34
fe-app-podkop/src/icons/renderCogIcon24.ts
Normal file
34
fe-app-podkop/src/icons/renderCogIcon24.ts
Normal 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' }),
|
||||
],
|
||||
);
|
||||
}
|
||||
32
fe-app-podkop/src/icons/renderLoaderCircleIcon24.ts
Normal file
32
fe-app-podkop/src/icons/renderLoaderCircleIcon24.ts
Normal 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',
|
||||
}),
|
||||
],
|
||||
);
|
||||
}
|
||||
34
fe-app-podkop/src/icons/renderPauseIcon24.ts
Normal file
34
fe-app-podkop/src/icons/renderPauseIcon24.ts
Normal 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',
|
||||
}),
|
||||
],
|
||||
);
|
||||
}
|
||||
23
fe-app-podkop/src/icons/renderPlayIcon24.ts
Normal file
23
fe-app-podkop/src/icons/renderPlayIcon24.ts
Normal 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',
|
||||
}),
|
||||
],
|
||||
);
|
||||
}
|
||||
26
fe-app-podkop/src/icons/renderRotateCcwIcon24.ts
Normal file
26
fe-app-podkop/src/icons/renderRotateCcwIcon24.ts
Normal 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',
|
||||
}),
|
||||
],
|
||||
);
|
||||
}
|
||||
22
fe-app-podkop/src/icons/renderSearchIcon24.ts
Normal file
22
fe-app-podkop/src/icons/renderSearchIcon24.ts
Normal 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' }),
|
||||
],
|
||||
);
|
||||
}
|
||||
30
fe-app-podkop/src/icons/renderSquareChartGanttIcon24.ts
Normal file
30
fe-app-podkop/src/icons/renderSquareChartGanttIcon24.ts
Normal 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' }),
|
||||
],
|
||||
);
|
||||
}
|
||||
25
fe-app-podkop/src/icons/renderTriangleAlertIcon24.ts
Normal file
25
fe-app-podkop/src/icons/renderTriangleAlertIcon24.ts
Normal 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' }),
|
||||
],
|
||||
);
|
||||
}
|
||||
19
fe-app-podkop/src/icons/renderXIcon24.ts
Normal file
19
fe-app-podkop/src/icons/renderXIcon24.ts
Normal 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' })],
|
||||
);
|
||||
}
|
||||
10
fe-app-podkop/src/luci.d.ts
vendored
10
fe-app-podkop/src/luci.d.ts
vendored
@@ -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 {};
|
||||
|
||||
@@ -2,9 +2,12 @@
|
||||
'require baseclass';
|
||||
'require fs';
|
||||
'require uci';
|
||||
'require ui';
|
||||
|
||||
if (typeof structuredClone !== 'function')
|
||||
globalThis.structuredClone = (obj) => JSON.parse(JSON.stringify(obj));
|
||||
|
||||
export * from './validators';
|
||||
export * from './helpers';
|
||||
export * from './clash';
|
||||
export * from './podkop';
|
||||
export * from './constants';
|
||||
|
||||
69
fe-app-podkop/src/partials/button/renderButton.ts
Normal file
69
fe-app-podkop/src/partials/button/renderButton.ts
Normal 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)],
|
||||
);
|
||||
}
|
||||
33
fe-app-podkop/src/partials/button/styles.ts
Normal file
33
fe-app-podkop/src/partials/button/styles.ts
Normal 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;
|
||||
}
|
||||
`;
|
||||
10
fe-app-podkop/src/partials/index.ts
Normal file
10
fe-app-podkop/src/partials/index.ts
Normal 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}
|
||||
`;
|
||||
32
fe-app-podkop/src/partials/modal/renderModal.ts
Normal file
32
fe-app-podkop/src/partials/modal/renderModal.ts
Normal 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,
|
||||
}),
|
||||
]),
|
||||
]),
|
||||
);
|
||||
}
|
||||
20
fe-app-podkop/src/partials/modal/styles.ts
Normal file
20
fe-app-podkop/src/partials/modal/styles.ts
Normal 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;
|
||||
}
|
||||
`;
|
||||
53
fe-app-podkop/src/podkop/api.ts
Normal file
53
fe-app-podkop/src/podkop/api.ts
Normal 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;
|
||||
};
|
||||
29
fe-app-podkop/src/podkop/fetchers/fetchServicesInfo.ts
Normal file
29
fe-app-podkop/src/podkop/fetchers/fetchServicesInfo.ts
Normal 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 },
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
1
fe-app-podkop/src/podkop/fetchers/index.ts
Normal file
1
fe-app-podkop/src/podkop/fetchers/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './fetchServicesInfo';
|
||||
@@ -0,0 +1,9 @@
|
||||
import { getConfigSections } from './getConfigSections';
|
||||
|
||||
export async function getClashApiSecret() {
|
||||
const sections = await getConfigSections();
|
||||
|
||||
const settings = sections.find((section) => section['.type'] === 'settings');
|
||||
|
||||
return settings?.yacd_secret_key || '';
|
||||
}
|
||||
@@ -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'));
|
||||
@@ -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`,
|
||||
);
|
||||
9
fe-app-podkop/src/podkop/methods/custom/index.ts
Normal file
9
fe-app-podkop/src/podkop/methods/custom/index.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { getConfigSections } from './getConfigSections';
|
||||
import { getDashboardSections } from './getDashboardSections';
|
||||
import { getClashApiSecret } from './getClashApiSecret';
|
||||
|
||||
export const CustomPodkopMethods = {
|
||||
getConfigSections,
|
||||
getDashboardSections,
|
||||
getClashApiSecret,
|
||||
};
|
||||
23
fe-app-podkop/src/podkop/methods/fakeip/getFakeIpCheck.ts
Normal file
23
fe-app-podkop/src/podkop/methods/fakeip/getFakeIpCheck.ts
Normal 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,
|
||||
},
|
||||
);
|
||||
}
|
||||
23
fe-app-podkop/src/podkop/methods/fakeip/getIpCheck.ts
Normal file
23
fe-app-podkop/src/podkop/methods/fakeip/getIpCheck.ts
Normal 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,
|
||||
},
|
||||
);
|
||||
}
|
||||
7
fe-app-podkop/src/podkop/methods/fakeip/index.ts
Normal file
7
fe-app-podkop/src/podkop/methods/fakeip/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { getFakeIpCheck } from './getFakeIpCheck';
|
||||
import { getIpCheck } from './getIpCheck';
|
||||
|
||||
export const RemoteFakeIPMethods = {
|
||||
getFakeIpCheck,
|
||||
getIpCheck,
|
||||
};
|
||||
@@ -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' };
|
||||
}
|
||||
@@ -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' };
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
33
fe-app-podkop/src/podkop/methods/shell/callBaseMethod.ts
Normal file
33
fe-app-podkop/src/podkop/methods/shell/callBaseMethod.ts
Normal 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: 15000,
|
||||
});
|
||||
|
||||
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: '',
|
||||
};
|
||||
}
|
||||
87
fe-app-podkop/src/podkop/methods/shell/index.ts
Normal file
87
fe-app-podkop/src/podkop/methods/shell/index.ts
Normal 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<Podkop.GetClashApiProxyLatency>(
|
||||
Podkop.AvailableMethods.CLASH_API,
|
||||
[Podkop.AvailableClashAPIMethods.GET_PROXY_LATENCY, tag, '5000'],
|
||||
),
|
||||
getClashApiGroupLatency: async (tag: string) =>
|
||||
callBaseMethod<Podkop.GetClashApiGroupLatency>(
|
||||
Podkop.AvailableMethods.CLASH_API,
|
||||
[Podkop.AvailableClashAPIMethods.GET_GROUP_LATENCY, tag, '10000'],
|
||||
),
|
||||
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,
|
||||
),
|
||||
};
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -1,2 +1,5 @@
|
||||
export * from './tab.service';
|
||||
export * from './core.service';
|
||||
export * from './socket.service';
|
||||
export * from './store.service';
|
||||
export * from './logger.service';
|
||||
|
||||
66
fe-app-podkop/src/podkop/services/logger.service.ts
Normal file
66
fe-app-podkop/src/podkop/services/logger.service.ts
Normal 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();
|
||||
116
fe-app-podkop/src/podkop/services/podkopLogWatcher.service.ts
Normal file
116
fe-app-podkop/src/podkop/services/podkopLogWatcher.service.ts
Normal 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');
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -1,24 +1,14 @@
|
||||
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';
|
||||
import { getClashApiSecret } from '../../methods/custom/getClashApiSecret';
|
||||
|
||||
// Fetchers
|
||||
|
||||
@@ -32,10 +22,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,36 +38,11 @@ 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() {
|
||||
const clashApiSecret = await getClashApiSecret();
|
||||
|
||||
socket.subscribe(
|
||||
`${getClashWsUrl()}/traffic?token=`,
|
||||
`${getClashWsUrl()}/traffic?token=${clashApiSecret}`,
|
||||
(msg) => {
|
||||
const parsedMsg = JSON.parse(msg);
|
||||
|
||||
@@ -90,8 +55,9 @@ async function connectToClashSockets() {
|
||||
});
|
||||
},
|
||||
(_err) => {
|
||||
console.log(
|
||||
'[fetchDashboardSections]: failed to connect',
|
||||
logger.error(
|
||||
'[DASHBOARD]',
|
||||
'connectToClashSockets - traffic: failed to connect to',
|
||||
getClashWsUrl(),
|
||||
);
|
||||
store.set({
|
||||
@@ -105,7 +71,7 @@ async function connectToClashSockets() {
|
||||
);
|
||||
|
||||
socket.subscribe(
|
||||
`${getClashWsUrl()}/connections?token=`,
|
||||
`${getClashWsUrl()}/connections?token=${clashApiSecret}`,
|
||||
(msg) => {
|
||||
const parsedMsg = JSON.parse(msg);
|
||||
|
||||
@@ -129,8 +95,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 +122,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 +134,7 @@ async function handleTestGroupLatency(tag: string) {
|
||||
},
|
||||
});
|
||||
|
||||
await triggerLatencyGroupTest(tag);
|
||||
await PodkopShellMethods.getClashApiGroupLatency(tag);
|
||||
await fetchDashboardSections();
|
||||
|
||||
store.set({
|
||||
@@ -186,7 +153,7 @@ async function handleTestProxyLatency(tag: string) {
|
||||
},
|
||||
});
|
||||
|
||||
await triggerLatencyProxyTest(tag);
|
||||
await PodkopShellMethods.getClashApiProxyLatency(tag);
|
||||
await fetchDashboardSections();
|
||||
|
||||
store.set({
|
||||
@@ -200,7 +167,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 +216,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 +246,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 +282,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 +318,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 +393,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();
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './renderSections';
|
||||
export * from './renderWidget';
|
||||
@@ -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'))]),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
),
|
||||
],
|
||||
120
fe-app-podkop/src/podkop/tabs/dashboard/styles.ts
Normal file
120
fe-app-podkop/src/podkop/tabs/dashboard/styles.ts
Normal 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);
|
||||
}
|
||||
|
||||
`;
|
||||
@@ -0,0 +1,40 @@
|
||||
import { getCheckTitle } from '../helpers/getCheckTitle';
|
||||
|
||||
export enum DIAGNOSTICS_CHECKS {
|
||||
DNS = 'DNS',
|
||||
SINGBOX = 'SINGBOX',
|
||||
NFT = 'NFT',
|
||||
FAKEIP = 'FAKEIP',
|
||||
OUTBOUNDS = 'OUTBOUNDS',
|
||||
}
|
||||
|
||||
export const DIAGNOSTICS_CHECKS_MAP: Record<
|
||||
DIAGNOSTICS_CHECKS,
|
||||
{ order: number; title: string; code: DIAGNOSTICS_CHECKS }
|
||||
> = {
|
||||
[DIAGNOSTICS_CHECKS.DNS]: {
|
||||
order: 1,
|
||||
title: getCheckTitle('DNS'),
|
||||
code: DIAGNOSTICS_CHECKS.DNS,
|
||||
},
|
||||
[DIAGNOSTICS_CHECKS.SINGBOX]: {
|
||||
order: 2,
|
||||
title: getCheckTitle('Sing-box'),
|
||||
code: DIAGNOSTICS_CHECKS.SINGBOX,
|
||||
},
|
||||
[DIAGNOSTICS_CHECKS.NFT]: {
|
||||
order: 3,
|
||||
title: getCheckTitle('Nftables'),
|
||||
code: DIAGNOSTICS_CHECKS.NFT,
|
||||
},
|
||||
[DIAGNOSTICS_CHECKS.OUTBOUNDS]: {
|
||||
order: 4,
|
||||
title: getCheckTitle('Outbounds'),
|
||||
code: DIAGNOSTICS_CHECKS.OUTBOUNDS,
|
||||
},
|
||||
[DIAGNOSTICS_CHECKS.FAKEIP]: {
|
||||
order: 5,
|
||||
title: getCheckTitle('FakeIP'),
|
||||
code: DIAGNOSTICS_CHECKS.FAKEIP,
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,91 @@
|
||||
import { insertIf } from '../../../../helpers';
|
||||
import { DIAGNOSTICS_CHECKS_MAP } from './contstants';
|
||||
import { PodkopShellMethods } from '../../../methods';
|
||||
import { IDiagnosticsChecksItem } from '../../../services';
|
||||
import { updateCheckStore } from './updateCheckStore';
|
||||
import { getMeta } from '../helpers/getMeta';
|
||||
|
||||
export async function runDnsCheck() {
|
||||
const { order, title, code } = DIAGNOSTICS_CHECKS_MAP.DNS;
|
||||
|
||||
updateCheckStore({
|
||||
order,
|
||||
code,
|
||||
title,
|
||||
description: _('Checking, please wait'),
|
||||
state: 'loading',
|
||||
items: [],
|
||||
});
|
||||
|
||||
const dnsChecks = await PodkopShellMethods.checkDNSAvailable();
|
||||
|
||||
if (!dnsChecks.success) {
|
||||
updateCheckStore({
|
||||
order,
|
||||
code,
|
||||
title,
|
||||
description: _('Cannot receive checks result'),
|
||||
state: 'error',
|
||||
items: [],
|
||||
});
|
||||
|
||||
throw new Error('DNS checks failed');
|
||||
}
|
||||
|
||||
const data = dnsChecks.data;
|
||||
|
||||
const allGood =
|
||||
Boolean(data.dns_on_router) &&
|
||||
Boolean(data.dhcp_config_status) &&
|
||||
Boolean(data.bootstrap_dns_status) &&
|
||||
Boolean(data.dns_status);
|
||||
|
||||
const atLeastOneGood =
|
||||
Boolean(data.dns_on_router) ||
|
||||
Boolean(data.dhcp_config_status) ||
|
||||
Boolean(data.bootstrap_dns_status) ||
|
||||
Boolean(data.dns_status);
|
||||
|
||||
const { state, description } = getMeta({ atLeastOneGood, allGood });
|
||||
|
||||
updateCheckStore({
|
||||
order,
|
||||
code,
|
||||
title,
|
||||
description,
|
||||
state,
|
||||
items: [
|
||||
...insertIf<IDiagnosticsChecksItem>(
|
||||
data.dns_type === 'doh' ||
|
||||
data.dns_type === 'dot' ||
|
||||
!data.bootstrap_dns_status,
|
||||
[
|
||||
{
|
||||
state: data.bootstrap_dns_status ? 'success' : 'error',
|
||||
key: _('Bootsrap DNS'),
|
||||
value: data.bootstrap_dns_server,
|
||||
},
|
||||
],
|
||||
),
|
||||
{
|
||||
state: data.dns_status ? 'success' : 'error',
|
||||
key: _('Main DNS'),
|
||||
value: `${data.dns_server} [${data.dns_type}]`,
|
||||
},
|
||||
{
|
||||
state: data.dns_on_router ? 'success' : 'error',
|
||||
key: _('DNS on router'),
|
||||
value: '',
|
||||
},
|
||||
{
|
||||
state: data.dhcp_config_status ? 'success' : 'error',
|
||||
key: _('DHCP has DNS server'),
|
||||
value: '',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
if (!atLeastOneGood) {
|
||||
throw new Error('DNS checks failed');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { insertIf } from '../../../../helpers';
|
||||
import { DIAGNOSTICS_CHECKS_MAP } from './contstants';
|
||||
import { PodkopShellMethods, RemoteFakeIPMethods } from '../../../methods';
|
||||
import { IDiagnosticsChecksItem } from '../../../services';
|
||||
import { updateCheckStore } from './updateCheckStore';
|
||||
import { getMeta } from '../helpers/getMeta';
|
||||
|
||||
export async function runFakeIPCheck() {
|
||||
const { order, title, code } = DIAGNOSTICS_CHECKS_MAP.FAKEIP;
|
||||
|
||||
updateCheckStore({
|
||||
order,
|
||||
code,
|
||||
title,
|
||||
description: _('Checking, please wait'),
|
||||
state: 'loading',
|
||||
items: [],
|
||||
});
|
||||
|
||||
const routerFakeIPResponse = await PodkopShellMethods.checkFakeIP();
|
||||
const checkFakeIPResponse = await RemoteFakeIPMethods.getFakeIpCheck();
|
||||
const checkIPResponse = await RemoteFakeIPMethods.getIpCheck();
|
||||
|
||||
const checks = {
|
||||
router: routerFakeIPResponse.success && routerFakeIPResponse.data.fakeip,
|
||||
browserFakeIP:
|
||||
checkFakeIPResponse.success && checkFakeIPResponse.data.fakeip,
|
||||
differentIP:
|
||||
checkFakeIPResponse.success &&
|
||||
checkIPResponse.success &&
|
||||
checkFakeIPResponse.data.IP !== checkIPResponse.data.IP,
|
||||
};
|
||||
|
||||
const allGood = checks.router || checks.browserFakeIP || checks.differentIP;
|
||||
const atLeastOneGood =
|
||||
checks.router && checks.browserFakeIP && checks.differentIP;
|
||||
|
||||
const { state, description } = getMeta({ atLeastOneGood, allGood });
|
||||
|
||||
updateCheckStore({
|
||||
order,
|
||||
code,
|
||||
title,
|
||||
description,
|
||||
state,
|
||||
items: [
|
||||
{
|
||||
state: checks.router ? 'success' : 'warning',
|
||||
key: checks.router
|
||||
? _('Router DNS is routed through sing-box')
|
||||
: _('Router DNS is not routed through sing-box'),
|
||||
value: '',
|
||||
},
|
||||
{
|
||||
state: checks.browserFakeIP ? 'success' : 'error',
|
||||
key: checks.browserFakeIP
|
||||
? _('Browser is using FakeIP correctly')
|
||||
: _('Browser is not using FakeIP'),
|
||||
value: '',
|
||||
},
|
||||
...insertIf<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: '',
|
||||
},
|
||||
]),
|
||||
],
|
||||
});
|
||||
}
|
||||
115
fe-app-podkop/src/podkop/tabs/diagnostic/checks/runNftCheck.ts
Normal file
115
fe-app-podkop/src/podkop/tabs/diagnostic/checks/runNftCheck.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
import { DIAGNOSTICS_CHECKS_MAP } from './contstants';
|
||||
import { RemoteFakeIPMethods, PodkopShellMethods } from '../../../methods';
|
||||
import { updateCheckStore } from './updateCheckStore';
|
||||
import { getMeta } from '../helpers/getMeta';
|
||||
|
||||
export async function runNftCheck() {
|
||||
const { order, title, code } = DIAGNOSTICS_CHECKS_MAP.NFT;
|
||||
|
||||
updateCheckStore({
|
||||
order,
|
||||
code,
|
||||
title,
|
||||
description: _('Checking, please wait'),
|
||||
state: 'loading',
|
||||
items: [],
|
||||
});
|
||||
|
||||
await RemoteFakeIPMethods.getFakeIpCheck();
|
||||
await RemoteFakeIPMethods.getIpCheck();
|
||||
|
||||
const nftablesChecks = await PodkopShellMethods.checkNftRules();
|
||||
|
||||
if (!nftablesChecks.success) {
|
||||
updateCheckStore({
|
||||
order,
|
||||
code,
|
||||
title,
|
||||
description: _('Cannot receive checks result'),
|
||||
state: 'error',
|
||||
items: [],
|
||||
});
|
||||
|
||||
throw new Error('Nftables checks failed');
|
||||
}
|
||||
|
||||
const data = nftablesChecks.data;
|
||||
|
||||
const allGood =
|
||||
Boolean(data.table_exist) &&
|
||||
Boolean(data.rules_mangle_exist) &&
|
||||
Boolean(data.rules_mangle_counters) &&
|
||||
Boolean(data.rules_mangle_output_exist) &&
|
||||
Boolean(data.rules_mangle_output_counters) &&
|
||||
Boolean(data.rules_proxy_exist) &&
|
||||
Boolean(data.rules_proxy_counters) &&
|
||||
!data.rules_other_mark_exist;
|
||||
|
||||
const atLeastOneGood =
|
||||
Boolean(data.table_exist) ||
|
||||
Boolean(data.rules_mangle_exist) ||
|
||||
Boolean(data.rules_mangle_counters) ||
|
||||
Boolean(data.rules_mangle_output_exist) ||
|
||||
Boolean(data.rules_mangle_output_counters) ||
|
||||
Boolean(data.rules_proxy_exist) ||
|
||||
Boolean(data.rules_proxy_counters) ||
|
||||
!data.rules_other_mark_exist;
|
||||
|
||||
const { state, description } = getMeta({ atLeastOneGood, allGood });
|
||||
|
||||
updateCheckStore({
|
||||
order,
|
||||
code,
|
||||
title,
|
||||
description,
|
||||
state,
|
||||
items: [
|
||||
{
|
||||
state: data.table_exist ? 'success' : 'error',
|
||||
key: _('Table exist'),
|
||||
value: '',
|
||||
},
|
||||
{
|
||||
state: data.rules_mangle_exist ? 'success' : 'error',
|
||||
key: _('Rules mangle exist'),
|
||||
value: '',
|
||||
},
|
||||
{
|
||||
state: data.rules_mangle_counters ? 'success' : 'error',
|
||||
key: _('Rules mangle counters'),
|
||||
value: '',
|
||||
},
|
||||
{
|
||||
state: data.rules_mangle_output_exist ? 'success' : 'error',
|
||||
key: _('Rules mangle output exist'),
|
||||
value: '',
|
||||
},
|
||||
{
|
||||
state: data.rules_mangle_output_counters ? 'success' : 'error',
|
||||
key: _('Rules mangle output counters'),
|
||||
value: '',
|
||||
},
|
||||
{
|
||||
state: data.rules_proxy_exist ? 'success' : 'error',
|
||||
key: _('Rules proxy exist'),
|
||||
value: '',
|
||||
},
|
||||
{
|
||||
state: data.rules_proxy_counters ? 'success' : 'error',
|
||||
key: _('Rules proxy counters'),
|
||||
value: '',
|
||||
},
|
||||
{
|
||||
state: !data.rules_other_mark_exist ? 'success' : 'warning',
|
||||
key: !data.rules_other_mark_exist
|
||||
? _('No other marking rules found')
|
||||
: _('Additional marking rules found'),
|
||||
value: '',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
if (!atLeastOneGood) {
|
||||
throw new Error('Nftables checks failed');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
import { DIAGNOSTICS_CHECKS_MAP } from './contstants';
|
||||
import { PodkopShellMethods } from '../../../methods';
|
||||
import { updateCheckStore } from './updateCheckStore';
|
||||
import { getMeta } from '../helpers/getMeta';
|
||||
import { getDashboardSections } from '../../../methods/custom/getDashboardSections';
|
||||
import { IDiagnosticsChecksItem } from '../../../services';
|
||||
|
||||
export async function runSectionsCheck() {
|
||||
const { order, title, code } = DIAGNOSTICS_CHECKS_MAP.OUTBOUNDS;
|
||||
|
||||
updateCheckStore({
|
||||
order,
|
||||
code,
|
||||
title,
|
||||
description: _('Checking, please wait'),
|
||||
state: 'loading',
|
||||
items: [],
|
||||
});
|
||||
|
||||
const sections = await getDashboardSections();
|
||||
|
||||
if (!sections.success) {
|
||||
updateCheckStore({
|
||||
order,
|
||||
code,
|
||||
title,
|
||||
description: _('Cannot receive checks result'),
|
||||
state: 'error',
|
||||
items: [],
|
||||
});
|
||||
|
||||
throw new Error('Sections checks failed');
|
||||
}
|
||||
|
||||
const items = (await Promise.all(
|
||||
sections.data.map(async (section) => {
|
||||
async function getLatency() {
|
||||
if (section.withTagSelect) {
|
||||
const latencyGroup = await PodkopShellMethods.getClashApiGroupLatency(
|
||||
section.code,
|
||||
);
|
||||
|
||||
const selectedOutbound = section.outbounds.find(
|
||||
(item) => item.selected,
|
||||
);
|
||||
|
||||
const isUrlTest = selectedOutbound?.type === 'URLTest';
|
||||
|
||||
const success = latencyGroup.success && !latencyGroup.data.message;
|
||||
|
||||
if (success) {
|
||||
if (isUrlTest) {
|
||||
const latency = Object.values(latencyGroup.data)
|
||||
.map((item) => (item ? `${item}ms` : 'n/a'))
|
||||
.join(' / ');
|
||||
|
||||
return {
|
||||
success: true,
|
||||
latency: `[${_('Fastest')}] ${latency}`,
|
||||
};
|
||||
}
|
||||
|
||||
const selectedProxyDelay =
|
||||
latencyGroup.data?.[selectedOutbound?.code ?? ''];
|
||||
|
||||
if (selectedProxyDelay) {
|
||||
return {
|
||||
success: true,
|
||||
latency: `[${selectedOutbound?.displayName ?? ''}] ${selectedProxyDelay}ms`,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
latency: `[${selectedOutbound?.displayName ?? ''}] ${_('Not responding')}`,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
latency: _('Not responding'),
|
||||
};
|
||||
}
|
||||
|
||||
const latencyProxy = await PodkopShellMethods.getClashApiProxyLatency(
|
||||
section.code,
|
||||
);
|
||||
|
||||
const success = latencyProxy.success && !latencyProxy.data.message;
|
||||
|
||||
if (success) {
|
||||
return {
|
||||
success: true,
|
||||
latency: `${latencyProxy.data.delay} ms`,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
latency: _('Not responding'),
|
||||
};
|
||||
}
|
||||
|
||||
const { latency, success } = await getLatency();
|
||||
|
||||
return {
|
||||
state: success ? 'success' : 'error',
|
||||
key: section.displayName,
|
||||
value: latency,
|
||||
};
|
||||
}),
|
||||
)) as Array<IDiagnosticsChecksItem>;
|
||||
|
||||
const allGood = items.every((item) => item.state === 'success');
|
||||
|
||||
const atLeastOneGood = items.some((item) => item.state === 'success');
|
||||
|
||||
const { state, description } = getMeta({ atLeastOneGood, allGood });
|
||||
|
||||
updateCheckStore({
|
||||
order,
|
||||
code,
|
||||
title,
|
||||
description,
|
||||
state,
|
||||
items,
|
||||
});
|
||||
|
||||
if (!atLeastOneGood) {
|
||||
throw new Error('Sections checks failed');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import { DIAGNOSTICS_CHECKS_MAP } from './contstants';
|
||||
import { PodkopShellMethods } from '../../../methods';
|
||||
import { updateCheckStore } from './updateCheckStore';
|
||||
import { getMeta } from '../helpers/getMeta';
|
||||
|
||||
export async function runSingBoxCheck() {
|
||||
const { order, title, code } = DIAGNOSTICS_CHECKS_MAP.SINGBOX;
|
||||
|
||||
updateCheckStore({
|
||||
order,
|
||||
code,
|
||||
title,
|
||||
description: _('Checking, please wait'),
|
||||
state: 'loading',
|
||||
items: [],
|
||||
});
|
||||
|
||||
const singBoxChecks = await PodkopShellMethods.checkSingBox();
|
||||
|
||||
if (!singBoxChecks.success) {
|
||||
updateCheckStore({
|
||||
order,
|
||||
code,
|
||||
title,
|
||||
description: _('Cannot receive checks result'),
|
||||
state: 'error',
|
||||
items: [],
|
||||
});
|
||||
|
||||
throw new Error('Sing-box checks failed');
|
||||
}
|
||||
|
||||
const data = singBoxChecks.data;
|
||||
|
||||
const allGood =
|
||||
Boolean(data.sing_box_installed) &&
|
||||
Boolean(data.sing_box_version_ok) &&
|
||||
Boolean(data.sing_box_service_exist) &&
|
||||
Boolean(data.sing_box_autostart_disabled) &&
|
||||
Boolean(data.sing_box_process_running) &&
|
||||
Boolean(data.sing_box_ports_listening);
|
||||
|
||||
const atLeastOneGood =
|
||||
Boolean(data.sing_box_installed) ||
|
||||
Boolean(data.sing_box_version_ok) ||
|
||||
Boolean(data.sing_box_service_exist) ||
|
||||
Boolean(data.sing_box_autostart_disabled) ||
|
||||
Boolean(data.sing_box_process_running) ||
|
||||
Boolean(data.sing_box_ports_listening);
|
||||
|
||||
const { state, description } = getMeta({ atLeastOneGood, allGood });
|
||||
|
||||
updateCheckStore({
|
||||
order,
|
||||
code,
|
||||
title,
|
||||
description,
|
||||
state,
|
||||
items: [
|
||||
{
|
||||
state: data.sing_box_installed ? 'success' : 'error',
|
||||
key: _('Sing-box installed'),
|
||||
value: '',
|
||||
},
|
||||
{
|
||||
state: data.sing_box_version_ok ? 'success' : 'error',
|
||||
key: _('Sing-box version is compatible (newer than 1.12.4)'),
|
||||
value: '',
|
||||
},
|
||||
{
|
||||
state: data.sing_box_service_exist ? 'success' : 'error',
|
||||
key: _('Sing-box service exist'),
|
||||
value: '',
|
||||
},
|
||||
{
|
||||
state: data.sing_box_autostart_disabled ? 'success' : 'error',
|
||||
key: _('Sing-box autostart disabled'),
|
||||
value: '',
|
||||
},
|
||||
{
|
||||
state: data.sing_box_process_running ? 'success' : 'error',
|
||||
key: _('Sing-box process running'),
|
||||
value: '',
|
||||
},
|
||||
{
|
||||
state: data.sing_box_ports_listening ? 'success' : 'error',
|
||||
key: _('Sing-box listening ports'),
|
||||
value: '',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
if (!atLeastOneGood || !data.sing_box_process_running) {
|
||||
throw new Error('Sing-box checks failed');
|
||||
}
|
||||
}
|
||||
@@ -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],
|
||||
});
|
||||
}
|
||||
140
fe-app-podkop/src/podkop/tabs/diagnostic/diagnostic.store.ts
Normal file
140
fe-app-podkop/src/podkop/tabs/diagnostic/diagnostic.store.ts
Normal file
@@ -0,0 +1,140 @@
|
||||
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.OUTBOUNDS,
|
||||
title: DIAGNOSTICS_CHECKS_MAP.OUTBOUNDS.title,
|
||||
order: DIAGNOSTICS_CHECKS_MAP.OUTBOUNDS.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: _('Pending'),
|
||||
items: [],
|
||||
state: 'skipped',
|
||||
},
|
||||
{
|
||||
code: DIAGNOSTICS_CHECKS.SINGBOX,
|
||||
title: DIAGNOSTICS_CHECKS_MAP.SINGBOX.title,
|
||||
order: DIAGNOSTICS_CHECKS_MAP.SINGBOX.order,
|
||||
description: _('Pending'),
|
||||
items: [],
|
||||
state: 'skipped',
|
||||
},
|
||||
{
|
||||
code: DIAGNOSTICS_CHECKS.NFT,
|
||||
title: DIAGNOSTICS_CHECKS_MAP.NFT.title,
|
||||
order: DIAGNOSTICS_CHECKS_MAP.NFT.order,
|
||||
description: _('Pending'),
|
||||
items: [],
|
||||
state: 'skipped',
|
||||
},
|
||||
{
|
||||
code: DIAGNOSTICS_CHECKS.OUTBOUNDS,
|
||||
title: DIAGNOSTICS_CHECKS_MAP.OUTBOUNDS.title,
|
||||
order: DIAGNOSTICS_CHECKS_MAP.OUTBOUNDS.order,
|
||||
description: _('Pending'),
|
||||
items: [],
|
||||
state: 'skipped',
|
||||
},
|
||||
{
|
||||
code: DIAGNOSTICS_CHECKS.FAKEIP,
|
||||
title: DIAGNOSTICS_CHECKS_MAP.FAKEIP.title,
|
||||
order: DIAGNOSTICS_CHECKS_MAP.FAKEIP.order,
|
||||
description: _('Pending'),
|
||||
items: [],
|
||||
state: 'skipped',
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,3 @@
|
||||
export function getCheckTitle(name: string) {
|
||||
return `${name} ${_('checks')}`;
|
||||
}
|
||||
28
fe-app-podkop/src/podkop/tabs/diagnostic/helpers/getMeta.ts
Normal file
28
fe-app-podkop/src/podkop/tabs/diagnostic/helpers/getMeta.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
interface IGetMetaProps {
|
||||
allGood: boolean;
|
||||
atLeastOneGood: boolean;
|
||||
}
|
||||
|
||||
export function getMeta({ allGood, atLeastOneGood }: IGetMetaProps): {
|
||||
description: string;
|
||||
state: 'loading' | 'warning' | 'success' | 'error' | 'skipped';
|
||||
} {
|
||||
if (allGood) {
|
||||
return {
|
||||
state: 'success',
|
||||
description: _('Checks passed'),
|
||||
};
|
||||
}
|
||||
|
||||
if (atLeastOneGood) {
|
||||
return {
|
||||
state: 'warning',
|
||||
description: _('Issues detected'),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
state: 'error',
|
||||
description: _('Checks failed'),
|
||||
};
|
||||
}
|
||||
9
fe-app-podkop/src/podkop/tabs/diagnostic/index.ts
Normal file
9
fe-app-podkop/src/podkop/tabs/diagnostic/index.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { render } from './renderDiagnostic';
|
||||
import { initController } from './initController';
|
||||
import { styles } from './styles';
|
||||
|
||||
export const DiagnosticTab = {
|
||||
render,
|
||||
initController,
|
||||
styles,
|
||||
};
|
||||
614
fe-app-podkop/src/podkop/tabs/diagnostic/initController.ts
Normal file
614
fe-app-podkop/src/podkop/tabs/diagnostic/initController.ts
Normal file
@@ -0,0 +1,614 @@
|
||||
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';
|
||||
import { PODKOP_LUCI_APP_VERSION } from '../../../constants';
|
||||
import { showToast } from '../../../helpers/showToast';
|
||||
import { renderWikiDisclaimer } from './partials/renderWikiDisclaimer';
|
||||
import { runSectionsCheck } from './checks/runSectionsCheck';
|
||||
|
||||
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'),
|
||||
);
|
||||
} else {
|
||||
logger.error('[DIAGNOSTIC]', 'handleShowGlobalCheck - e', globalCheck);
|
||||
showToast(_('Failed to execute!'), 'error');
|
||||
}
|
||||
} catch (e) {
|
||||
logger.error('[DIAGNOSTIC]', 'handleShowGlobalCheck - e', e);
|
||||
showToast(_('Failed to execute!'), 'error');
|
||||
} finally {
|
||||
store.set({
|
||||
diagnosticsActions: {
|
||||
...diagnosticsActions,
|
||||
globalCheck: { loading: false },
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function handleViewLogs() {
|
||||
const diagnosticsActions = store.get().diagnosticsActions;
|
||||
store.set({
|
||||
diagnosticsActions: {
|
||||
...diagnosticsActions,
|
||||
viewLogs: { loading: true },
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
const viewLogs = await PodkopShellMethods.checkLogs();
|
||||
|
||||
if (viewLogs.success) {
|
||||
ui.showModal(
|
||||
_('View logs'),
|
||||
renderModal(viewLogs.data as string, 'view_logs'),
|
||||
);
|
||||
} else {
|
||||
logger.error('[DIAGNOSTIC]', 'handleViewLogs - e', viewLogs);
|
||||
showToast(_('Failed to execute!'), 'error');
|
||||
}
|
||||
} catch (e) {
|
||||
logger.error('[DIAGNOSTIC]', 'handleViewLogs - e', e);
|
||||
showToast(_('Failed to execute!'), 'error');
|
||||
} finally {
|
||||
store.set({
|
||||
diagnosticsActions: {
|
||||
...diagnosticsActions,
|
||||
viewLogs: { loading: false },
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function handleShowSingBoxConfig() {
|
||||
const diagnosticsActions = store.get().diagnosticsActions;
|
||||
store.set({
|
||||
diagnosticsActions: {
|
||||
...diagnosticsActions,
|
||||
showSingBoxConfig: { loading: true },
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
const showSingBoxConfig = await PodkopShellMethods.showSingBoxConfig();
|
||||
|
||||
if (showSingBoxConfig.success) {
|
||||
ui.showModal(
|
||||
_('Show sing-box config'),
|
||||
renderModal(showSingBoxConfig.data as string, 'show_sing_box_config'),
|
||||
);
|
||||
} else {
|
||||
logger.error(
|
||||
'[DIAGNOSTIC]',
|
||||
'handleShowSingBoxConfig - e',
|
||||
showSingBoxConfig,
|
||||
);
|
||||
showToast(_('Failed to execute!'), 'error');
|
||||
}
|
||||
} catch (e) {
|
||||
logger.error('[DIAGNOSTIC]', 'handleShowSingBoxConfig - e', e);
|
||||
showToast(_('Failed to execute!'), 'error');
|
||||
} finally {
|
||||
store.set({
|
||||
diagnosticsActions: {
|
||||
...diagnosticsActions,
|
||||
showSingBoxConfig: { loading: false },
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function renderWikiDisclaimerWidget() {
|
||||
const diagnosticsChecks = store.get().diagnosticsChecks;
|
||||
|
||||
function getWikiKind() {
|
||||
const allResults = diagnosticsChecks.map((check) => check.state);
|
||||
|
||||
if (allResults.includes('error')) {
|
||||
return 'error';
|
||||
}
|
||||
|
||||
if (allResults.includes('warning')) {
|
||||
return 'warning';
|
||||
}
|
||||
|
||||
return 'default';
|
||||
}
|
||||
|
||||
const container = document.getElementById('pdk_diagnostic-page-wiki');
|
||||
|
||||
return preserveScrollForPage(() => {
|
||||
container!.replaceChildren(renderWikiDisclaimer(getWikiKind()));
|
||||
});
|
||||
}
|
||||
|
||||
function renderDiagnosticAvailableActionsWidget() {
|
||||
const diagnosticsActions = store.get().diagnosticsActions;
|
||||
const servicesInfoWidget = store.get().servicesInfoWidget;
|
||||
logger.debug('[DIAGNOSTIC]', 'renderDiagnosticAvailableActionsWidget');
|
||||
|
||||
const podkopEnabled = Boolean(servicesInfoWidget.data.podkop);
|
||||
const singBoxRunning = Boolean(servicesInfoWidget.data.singbox);
|
||||
const atLeastOneServiceCommandLoading =
|
||||
servicesInfoWidget.loading ||
|
||||
diagnosticsActions.restart.loading ||
|
||||
diagnosticsActions.start.loading ||
|
||||
diagnosticsActions.stop.loading;
|
||||
|
||||
const container = document.getElementById('pdk_diagnostic-page-actions');
|
||||
|
||||
const renderedActions = renderAvailableActions({
|
||||
restart: {
|
||||
loading: diagnosticsActions.restart.loading,
|
||||
visible: true,
|
||||
onClick: handleRestart,
|
||||
disabled: atLeastOneServiceCommandLoading,
|
||||
},
|
||||
start: {
|
||||
loading: diagnosticsActions.start.loading,
|
||||
visible: !singBoxRunning,
|
||||
onClick: handleStart,
|
||||
disabled: atLeastOneServiceCommandLoading,
|
||||
},
|
||||
stop: {
|
||||
loading: diagnosticsActions.stop.loading,
|
||||
visible: singBoxRunning,
|
||||
onClick: handleStop,
|
||||
disabled: atLeastOneServiceCommandLoading,
|
||||
},
|
||||
enable: {
|
||||
loading: diagnosticsActions.enable.loading,
|
||||
visible: !podkopEnabled,
|
||||
onClick: handleEnable,
|
||||
disabled: atLeastOneServiceCommandLoading,
|
||||
},
|
||||
disable: {
|
||||
loading: diagnosticsActions.disable.loading,
|
||||
visible: podkopEnabled,
|
||||
onClick: handleDisable,
|
||||
disabled: atLeastOneServiceCommandLoading,
|
||||
},
|
||||
globalCheck: {
|
||||
loading: diagnosticsActions.globalCheck.loading,
|
||||
visible: true,
|
||||
onClick: handleShowGlobalCheck,
|
||||
disabled: atLeastOneServiceCommandLoading,
|
||||
},
|
||||
viewLogs: {
|
||||
loading: diagnosticsActions.viewLogs.loading,
|
||||
visible: true,
|
||||
onClick: handleViewLogs,
|
||||
disabled: atLeastOneServiceCommandLoading,
|
||||
},
|
||||
showSingBoxConfig: {
|
||||
loading: diagnosticsActions.showSingBoxConfig.loading,
|
||||
visible: true,
|
||||
onClick: handleShowSingBoxConfig,
|
||||
disabled: atLeastOneServiceCommandLoading,
|
||||
},
|
||||
});
|
||||
|
||||
return preserveScrollForPage(() => {
|
||||
container!.replaceChildren(renderedActions);
|
||||
});
|
||||
}
|
||||
|
||||
function renderDiagnosticSystemInfoWidget() {
|
||||
logger.debug('[DIAGNOSTIC]', 'renderDiagnosticSystemInfoWidget');
|
||||
const diagnosticsSystemInfo = store.get().diagnosticsSystemInfo;
|
||||
|
||||
const container = document.getElementById('pdk_diagnostic-page-system-info');
|
||||
|
||||
function getPodkopVersionRow(): IRenderSystemInfoRow {
|
||||
const loading = diagnosticsSystemInfo.loading;
|
||||
const unknown = diagnosticsSystemInfo.podkop_version === _('unknown');
|
||||
const hasActualVersion =
|
||||
Boolean(diagnosticsSystemInfo.podkop_latest_version) &&
|
||||
diagnosticsSystemInfo.podkop_latest_version !== 'unknown';
|
||||
const version = normalizeCompiledVersion(
|
||||
diagnosticsSystemInfo.podkop_version,
|
||||
);
|
||||
const isDevVersion = version === 'dev';
|
||||
|
||||
if (loading || unknown || !hasActualVersion || isDevVersion) {
|
||||
return {
|
||||
key: 'Podkop',
|
||||
value: version,
|
||||
};
|
||||
}
|
||||
|
||||
if (version !== `v${diagnosticsSystemInfo.podkop_latest_version}`) {
|
||||
logger.debug(
|
||||
'[DIAGNOSTIC]',
|
||||
'diagnosticsSystemInfo',
|
||||
diagnosticsSystemInfo,
|
||||
);
|
||||
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(PODKOP_LUCI_APP_VERSION),
|
||||
},
|
||||
{
|
||||
key: 'Sing-box',
|
||||
value: diagnosticsSystemInfo.sing_box_version,
|
||||
},
|
||||
{
|
||||
key: 'OS',
|
||||
value: diagnosticsSystemInfo.openwrt_version,
|
||||
},
|
||||
{
|
||||
key: 'Device',
|
||||
value: diagnosticsSystemInfo.device_model,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
return preserveScrollForPage(() => {
|
||||
container!.replaceChildren(renderedSystemInfo);
|
||||
});
|
||||
}
|
||||
|
||||
async function onStoreUpdate(
|
||||
next: StoreType,
|
||||
prev: StoreType,
|
||||
diff: Partial<StoreType>,
|
||||
) {
|
||||
if (diff.diagnosticsChecks) {
|
||||
renderDiagnosticsChecks();
|
||||
renderWikiDisclaimerWidget();
|
||||
}
|
||||
|
||||
if (diff.diagnosticsRunAction) {
|
||||
renderDiagnosticRunActionWidget();
|
||||
}
|
||||
|
||||
if (diff.diagnosticsActions || diff.servicesInfoWidget) {
|
||||
renderDiagnosticAvailableActionsWidget();
|
||||
}
|
||||
|
||||
if (diff.diagnosticsSystemInfo) {
|
||||
renderDiagnosticSystemInfoWidget();
|
||||
}
|
||||
}
|
||||
|
||||
async function runChecks() {
|
||||
try {
|
||||
store.set({
|
||||
diagnosticsRunAction: { loading: true },
|
||||
diagnosticsChecks: loadingDiagnosticsChecksStore.diagnosticsChecks,
|
||||
});
|
||||
|
||||
await runDnsCheck();
|
||||
|
||||
await runSingBoxCheck();
|
||||
|
||||
await runNftCheck();
|
||||
|
||||
await runSectionsCheck();
|
||||
|
||||
await runFakeIPCheck();
|
||||
} catch (e) {
|
||||
logger.error('[DIAGNOSTIC]', 'runChecks - e', e);
|
||||
} finally {
|
||||
store.set({ diagnosticsRunAction: { loading: false } });
|
||||
}
|
||||
}
|
||||
|
||||
function 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 Wiki disclaimer render
|
||||
renderWikiDisclaimerWidget();
|
||||
|
||||
// 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();
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export * from './renderAvailableActions';
|
||||
export * from './renderCheckSection';
|
||||
export * from './renderRunAction';
|
||||
export * from './renderSystemInfo';
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user