Cleanup: удалить старые файлы forkop
This commit is contained in:
@@ -1,16 +0,0 @@
|
||||
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-forkop/htdocs/luci-static/resources/view/forkop
|
||||
REMOTE_DIR_FE=/www/luci-static/resources/view/forkop
|
||||
|
||||
LOCAL_DIR_BIN=../forkop/files/usr/bin/
|
||||
REMOTE_DIR_BIN=/usr/bin/
|
||||
|
||||
LOCAL_DIR_LIB=../forkop/files/usr/lib/
|
||||
REMOTE_DIR_LIB=/usr/lib/forkop/
|
||||
@@ -1,8 +0,0 @@
|
||||
{
|
||||
"printWidth": 80,
|
||||
"tabWidth": 2,
|
||||
"semi": true,
|
||||
"singleQuote": true,
|
||||
"trailingComma": "all",
|
||||
"bracketSpacing": true
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
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-forkop/po');
|
||||
|
||||
async function main() {
|
||||
const files = await fs.readdir(sourceDir);
|
||||
|
||||
for (const file of files) {
|
||||
const filePath = path.join(sourceDir, file);
|
||||
|
||||
if (file === 'forkop.pot') {
|
||||
const potTarget = path.join(targetRoot, 'templates', 'forkop.pot');
|
||||
await fs.mkdir(path.dirname(potTarget), { recursive: true });
|
||||
await fs.copyFile(filePath, potTarget);
|
||||
console.log(`✅ Copied POT: ${filePath} → ${potTarget}`);
|
||||
}
|
||||
|
||||
const match = file.match(/^forkop\.([a-zA-Z_]+)\.po$/);
|
||||
if (match) {
|
||||
const lang = match[1];
|
||||
const poTarget = path.join(targetRoot, lang, 'forkop.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);
|
||||
});
|
||||
@@ -1,36 +0,0 @@
|
||||
// eslint.config.js
|
||||
import js from '@eslint/js';
|
||||
import tseslint from 'typescript-eslint';
|
||||
import prettier from 'eslint-config-prettier';
|
||||
|
||||
export default [
|
||||
js.configs.recommended,
|
||||
...tseslint.configs.recommended,
|
||||
{
|
||||
ignores: ['node_modules', 'watch-upload.js'],
|
||||
},
|
||||
{
|
||||
files: ['src/**/*.test.js'],
|
||||
languageOptions: {
|
||||
globals: {
|
||||
Buffer: 'readonly',
|
||||
URL: 'readonly',
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
rules: {
|
||||
'no-console': 'off',
|
||||
'no-unused-vars': 'off',
|
||||
'@typescript-eslint/no-unused-vars': [
|
||||
'warn',
|
||||
{
|
||||
argsIgnorePattern: '^_',
|
||||
varsIgnorePattern: '^_',
|
||||
caughtErrorsIgnorePattern: '^_',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
prettier,
|
||||
];
|
||||
@@ -1,75 +0,0 @@
|
||||
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-forkop/htdocs/luci-static/resources/view/forkop/**/*.js',
|
||||
], {
|
||||
ignore: [
|
||||
'**/*.test.ts',
|
||||
'**/main.js',
|
||||
'../luci-app-forkop/htdocs/luci-static/resources/view/forkop/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).replaceAll('\\', '/');
|
||||
|
||||
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}`);
|
||||
@@ -1,117 +0,0 @@
|
||||
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/forkop.${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 FORKOP package.`,
|
||||
`# Copyright (C) ${now.getFullYear()} THE FORKOP COPYRIGHT HOLDER`,
|
||||
`# This file is distributed under the same license as the FORKOP package.`,
|
||||
`# ${translator}, ${now.getFullYear()}.`,
|
||||
'#',
|
||||
'msgid ""',
|
||||
'msgstr ""',
|
||||
`"Project-Id-Version: FORKOP\\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) => {
|
||||
process.exitCode = 1;
|
||||
console.error('Ошибка генерации PO файла:', err);
|
||||
});
|
||||
@@ -1,91 +0,0 @@
|
||||
import fs from 'fs/promises';
|
||||
import { execSync } from 'child_process';
|
||||
|
||||
const inputFile = 'locales/calls.json';
|
||||
const outputFile = 'locales/forkop.pot';
|
||||
const projectId = 'FORKOP';
|
||||
|
||||
function getGitUser() {
|
||||
try {
|
||||
const name = execSync('git config user.name', {
|
||||
stdio: ['ignore', 'pipe', 'ignore'],
|
||||
})
|
||||
.toString()
|
||||
.trim();
|
||||
const email = execSync('git config user.email', {
|
||||
stdio: ['ignore', 'pipe', 'ignore'],
|
||||
})
|
||||
.toString()
|
||||
.trim();
|
||||
|
||||
if (name && email) {
|
||||
return { name, email };
|
||||
}
|
||||
} catch (error) {
|
||||
// Fall through to deterministic defaults when git identity is not configured.
|
||||
}
|
||||
|
||||
return {
|
||||
name: 'Forkop',
|
||||
email: 'ushan0v@users.noreply.github.com',
|
||||
};
|
||||
}
|
||||
|
||||
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((error) => {
|
||||
console.error(error);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,40 +0,0 @@
|
||||
{
|
||||
"name": "fe-app-forkop",
|
||||
"version": "1.0.0",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"format": "prettier --write src",
|
||||
"format:js": "prettier --write ../luci-app-forkop/htdocs/luci-static/resources/view/forkop",
|
||||
"lint": "eslint src --ext .ts,.tsx",
|
||||
"lint:fix": "eslint src --ext .ts,.tsx --fix",
|
||||
"build": "tsc --noEmit && 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",
|
||||
"locales:extract-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:extract-calls && yarn locales:generate-pot && yarn locales:generate-po:ru && yarn locales:distribute"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/parser": "7.28.4",
|
||||
"@babel/traverse": "7.28.4",
|
||||
"@babel/types": "7.28.4",
|
||||
"@eslint/js": "9.36.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",
|
||||
"tsup": "8.5.0",
|
||||
"typescript": "5.9.3",
|
||||
"typescript-eslint": "8.45.0",
|
||||
"vitest": "3.2.4"
|
||||
}
|
||||
}
|
||||
@@ -1,88 +0,0 @@
|
||||
export const FORKOP_UCI_PACKAGE = 'forkop';
|
||||
export const FORKOP_LUCI_APP_VERSION = '__COMPILED_VERSION_VARIABLE__';
|
||||
export const FORKOP_ACTION_PROVIDERS_AVAILABILITY_EVENT =
|
||||
'forkop:action-providers-availability';
|
||||
export const FAKEIP_CHECK_DOMAIN = 'fakeip.podkop.fyi';
|
||||
export const IP_CHECK_DOMAIN = 'ip.podkop.fyi';
|
||||
export const DEFAULT_LATENCY_TEST_URL = 'https://www.gstatic.com/generate_204';
|
||||
export const LATENCY_TEST_URL_OPTIONS = [
|
||||
DEFAULT_LATENCY_TEST_URL,
|
||||
'https://cp.cloudflare.com/generate_204',
|
||||
'https://captive.apple.com',
|
||||
'https://connectivity-check.ubuntu.com',
|
||||
];
|
||||
|
||||
export const REGIONAL_OPTIONS = [
|
||||
'russia_inside',
|
||||
'russia_outside',
|
||||
'ukraine_inside',
|
||||
];
|
||||
|
||||
export const ALLOWED_WITH_RUSSIA_INSIDE = [
|
||||
'russia_inside',
|
||||
'meta',
|
||||
'twitter',
|
||||
'discord',
|
||||
'telegram',
|
||||
'cloudflare',
|
||||
'google_ai',
|
||||
'google_play',
|
||||
'hetzner',
|
||||
'ovh',
|
||||
'hodca',
|
||||
'roblox',
|
||||
'digitalocean',
|
||||
'cloudfront',
|
||||
];
|
||||
|
||||
export const DOMAIN_LIST_OPTIONS = {
|
||||
russia_inside: 'Russia inside',
|
||||
russia_outside: 'Russia outside',
|
||||
ukraine_inside: 'Ukraine',
|
||||
geoblock: 'Geo Block',
|
||||
block: 'Block',
|
||||
porn: 'Porn',
|
||||
news: 'News',
|
||||
anime: 'Anime',
|
||||
youtube: 'Youtube',
|
||||
discord: 'Discord',
|
||||
meta: 'Meta',
|
||||
twitter: 'Twitter (X)',
|
||||
hdrezka: 'HDRezka',
|
||||
tiktok: 'Tik-Tok',
|
||||
telegram: 'Telegram',
|
||||
cloudflare: 'Cloudflare',
|
||||
google_ai: 'Google AI',
|
||||
google_play: 'Google Play',
|
||||
hodca: 'H.O.D.C.A',
|
||||
roblox: 'Roblox',
|
||||
ads_hagezi_pro: 'Ads (Hagezi Pro)',
|
||||
supercell: 'Supercell',
|
||||
github: 'GitHub',
|
||||
hetzner: 'Hetzner ASN',
|
||||
ovh: 'OVH ASN',
|
||||
digitalocean: 'Digital Ocean ASN',
|
||||
cloudfront: 'CloudFront ASN',
|
||||
};
|
||||
|
||||
export const DNS_SERVER_OPTIONS = {
|
||||
'1.1.1.1': '1.1.1.1 (Cloudflare)',
|
||||
'8.8.8.8': '8.8.8.8 (Google)',
|
||||
'9.9.9.9': '9.9.9.9 (Quad9)',
|
||||
'dns.adguard-dns.com': 'dns.adguard-dns.com (AdGuard Default)',
|
||||
'unfiltered.adguard-dns.com':
|
||||
'unfiltered.adguard-dns.com (AdGuard Unfiltered)',
|
||||
'family.adguard-dns.com': 'family.adguard-dns.com (AdGuard Family)',
|
||||
};
|
||||
export const BOOTSTRAP_DNS_SERVER_OPTIONS = {
|
||||
'77.88.8.8': '77.88.8.8 (Yandex DNS)',
|
||||
'77.88.8.1': '77.88.8.1 (Yandex DNS)',
|
||||
'1.1.1.1': '1.1.1.1 (Cloudflare DNS)',
|
||||
'1.0.0.1': '1.0.0.1 (Cloudflare DNS)',
|
||||
'8.8.8.8': '8.8.8.8 (Google DNS)',
|
||||
'8.8.4.4': '8.8.4.4 (Google DNS)',
|
||||
'9.9.9.9': '9.9.9.9 (Quad9 DNS)',
|
||||
'9.9.9.11': '9.9.9.11 (Quad9 DNS)',
|
||||
};
|
||||
|
||||
export const COMMAND_TIMEOUT = 10000; // 10 seconds
|
||||
@@ -1,53 +0,0 @@
|
||||
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;
|
||||
};
|
||||
@@ -1,70 +0,0 @@
|
||||
import { ForkopShellMethods } from '../methods';
|
||||
import { logger } from '../services/logger.service';
|
||||
import { store } from '../services/store.service';
|
||||
import { refreshRuntimeUiState } from '../services/runtimeUiState.service';
|
||||
import { Forkop } from '../types';
|
||||
|
||||
let latestServicesInfoRequestId = 0;
|
||||
|
||||
function getSettledMethodResponse<T>(
|
||||
scope: string,
|
||||
result: PromiseSettledResult<Forkop.MethodResponse<T>>,
|
||||
): Forkop.MethodResponse<T> {
|
||||
if (result.status === 'fulfilled') {
|
||||
return result.value;
|
||||
}
|
||||
|
||||
logger.error('[SERVICES_INFO]', `${scope} failed`, result.reason);
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: result.reason instanceof Error ? result.reason.message : '',
|
||||
};
|
||||
}
|
||||
|
||||
export async function fetchServicesInfo() {
|
||||
const requestId = ++latestServicesInfoRequestId;
|
||||
const uiState = await refreshRuntimeUiState({ force: true });
|
||||
|
||||
if (requestId !== latestServicesInfoRequestId) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (uiState) {
|
||||
return uiState;
|
||||
}
|
||||
|
||||
const [forkopResult, singboxResult] = await Promise.allSettled([
|
||||
ForkopShellMethods.getStatus(),
|
||||
ForkopShellMethods.getSingBoxStatus(),
|
||||
]);
|
||||
|
||||
if (requestId !== latestServicesInfoRequestId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const forkop = getSettledMethodResponse('getStatus', forkopResult);
|
||||
const singbox = getSettledMethodResponse('getSingBoxStatus', singboxResult);
|
||||
const previousData = store.get().servicesInfoWidget.data;
|
||||
|
||||
store.set({
|
||||
servicesInfoWidget: {
|
||||
loading: false,
|
||||
failed: !forkop.success || !singbox.success,
|
||||
data: {
|
||||
singbox: singbox.success ? singbox.data.running : previousData.singbox,
|
||||
forkopRunning: forkop.success
|
||||
? forkop.data.running
|
||||
: previousData.forkopRunning,
|
||||
forkopEnabled: forkop.success
|
||||
? forkop.data.enabled
|
||||
: previousData.forkopEnabled,
|
||||
forkopStatus: forkop.success
|
||||
? forkop.data.status
|
||||
: previousData.forkopStatus,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return undefined;
|
||||
}
|
||||
@@ -1,121 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
executeShellCommand: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../../helpers', () => ({
|
||||
executeShellCommand: mocks.executeShellCommand,
|
||||
}));
|
||||
|
||||
import { store } from '../../services/store.service';
|
||||
import { fetchServicesInfo } from '../fetchServicesInfo';
|
||||
|
||||
describe('fetchServicesInfo', () => {
|
||||
beforeEach(() => {
|
||||
store.reset();
|
||||
mocks.executeShellCommand.mockReset();
|
||||
});
|
||||
|
||||
it('returns the fast UI state after applying it to the shared store', async () => {
|
||||
const uiState = {
|
||||
service: {
|
||||
forkop: {
|
||||
running: 1,
|
||||
enabled: 1,
|
||||
status: 'restarting',
|
||||
dns_configured: 1,
|
||||
},
|
||||
sing_box: {
|
||||
running: 1,
|
||||
enabled: 0,
|
||||
status: 'running but disabled',
|
||||
},
|
||||
},
|
||||
capabilities: {
|
||||
sing_box_extended: 1,
|
||||
sing_box_tiny: 0,
|
||||
sing_box_tailscale: 1,
|
||||
zapret_installed: 1,
|
||||
zapret2_installed: 0,
|
||||
byedpi_installed: 0,
|
||||
server_inbounds_enabled_count: 0,
|
||||
},
|
||||
actions: {
|
||||
service: [
|
||||
{
|
||||
success: true,
|
||||
running: true,
|
||||
kind: 'service',
|
||||
action: 'restart',
|
||||
job_id: 'service-1',
|
||||
},
|
||||
],
|
||||
latency: [],
|
||||
component: [],
|
||||
subscription: [],
|
||||
},
|
||||
};
|
||||
|
||||
mocks.executeShellCommand.mockResolvedValue({
|
||||
stdout: JSON.stringify(uiState),
|
||||
stderr: '',
|
||||
code: 0,
|
||||
});
|
||||
|
||||
await expect(fetchServicesInfo()).resolves.toEqual(uiState);
|
||||
|
||||
const state = store.get();
|
||||
|
||||
expect(state.servicesInfoWidget.data.forkopStatus).toBe('restarting');
|
||||
expect(state.diagnosticsActions.restart.loading).toBe(true);
|
||||
});
|
||||
|
||||
it('keeps the previous service state when the fallback status fetch fails', async () => {
|
||||
store.set({
|
||||
servicesInfoWidget: {
|
||||
loading: false,
|
||||
failed: false,
|
||||
data: {
|
||||
singbox: 1,
|
||||
forkopRunning: 1,
|
||||
forkopEnabled: 1,
|
||||
forkopStatus: 'running & enabled',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
mocks.executeShellCommand
|
||||
.mockResolvedValueOnce({
|
||||
stdout: '',
|
||||
stderr: 'get_ui_state failed',
|
||||
code: 1,
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
stdout: '',
|
||||
stderr: 'get_status failed',
|
||||
code: 1,
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
stdout: JSON.stringify({
|
||||
running: 0,
|
||||
enabled: 0,
|
||||
status: 'stopped but disabled',
|
||||
}),
|
||||
stderr: '',
|
||||
code: 0,
|
||||
});
|
||||
|
||||
await fetchServicesInfo();
|
||||
|
||||
const state = store.get();
|
||||
|
||||
expect(state.servicesInfoWidget.failed).toBe(true);
|
||||
expect(state.servicesInfoWidget.data).toEqual({
|
||||
singbox: 0,
|
||||
forkopRunning: 1,
|
||||
forkopEnabled: 1,
|
||||
forkopStatus: 'running & enabled',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,31 +0,0 @@
|
||||
import type { StoreType } from '../services/store.service';
|
||||
import type { Forkop } from '../types';
|
||||
|
||||
export type UpdatesActionKey = keyof StoreType['updatesActions'];
|
||||
|
||||
const componentActionKeyMap: Record<string, UpdatesActionKey> = {
|
||||
'forkop:check_update': 'forkopCheck',
|
||||
'forkop:install': 'forkopInstall',
|
||||
'sing_box:check_update': 'singBoxCheck',
|
||||
'sing_box:install': 'singBoxInstall',
|
||||
'sing_box:install_extended': 'singBoxInstallExtended',
|
||||
'sing_box:install_extended_compressed': 'singBoxInstallExtendedCompressed',
|
||||
'sing_box:install_tiny': 'singBoxInstallTiny',
|
||||
'sing_box:install_stable': 'singBoxInstallStable',
|
||||
'zapret:check_update': 'zapretCheck',
|
||||
'zapret:install': 'zapretInstall',
|
||||
'zapret:remove': 'zapretRemove',
|
||||
'zapret2:check_update': 'zapret2Check',
|
||||
'zapret2:install': 'zapret2Install',
|
||||
'zapret2:remove': 'zapret2Remove',
|
||||
'byedpi:check_update': 'byedpiCheck',
|
||||
'byedpi:install': 'byedpiInstall',
|
||||
'byedpi:remove': 'byedpiRemove',
|
||||
};
|
||||
|
||||
export function getComponentActionKey(
|
||||
component: Forkop.ComponentName,
|
||||
action: Forkop.ComponentAction,
|
||||
): UpdatesActionKey | undefined {
|
||||
return componentActionKeyMap[`${component}:${action}`];
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
export function isActiveLuciTab(tabId: string) {
|
||||
if (typeof document === 'undefined') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return Boolean(
|
||||
document.querySelector(
|
||||
`.cbi-tab[data-tab="${tabId}"]:not(.cbi-tab-disabled)`,
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
const TRANSIENT_RPC_ERROR_PATTERNS = [
|
||||
'no related rpc reply',
|
||||
'request aborted',
|
||||
'operation was aborted',
|
||||
];
|
||||
|
||||
export function isTransientRpcError(message?: string | null) {
|
||||
if (!message) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const normalized = message.toLowerCase();
|
||||
|
||||
return TRANSIENT_RPC_ERROR_PATTERNS.some((pattern) =>
|
||||
normalized.includes(pattern),
|
||||
);
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
export function shouldShowLoadingForRestoredAction(state: {
|
||||
running?: boolean;
|
||||
}) {
|
||||
return state.running === true;
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
export type ServiceAvailability =
|
||||
| 'loading'
|
||||
| 'running'
|
||||
| 'stopped'
|
||||
| 'unavailable';
|
||||
|
||||
export function getServiceAvailability({
|
||||
loading,
|
||||
failed,
|
||||
running,
|
||||
}: {
|
||||
loading: boolean;
|
||||
failed: boolean;
|
||||
running: boolean | number;
|
||||
}): ServiceAvailability {
|
||||
if (loading) {
|
||||
return 'loading';
|
||||
}
|
||||
|
||||
if (failed) {
|
||||
return 'unavailable';
|
||||
}
|
||||
|
||||
return running ? 'running' : 'stopped';
|
||||
}
|
||||
@@ -1,72 +0,0 @@
|
||||
type SingBoxVariantFields = {
|
||||
sing_box_version?: string;
|
||||
sing_box_extended?: number;
|
||||
sing_box_tiny?: number;
|
||||
sing_box_compressed?: number;
|
||||
sing_box_tailscale?: number;
|
||||
};
|
||||
|
||||
export function isExtendedSingBoxVersion(version?: string) {
|
||||
return String(version || '').includes('extended');
|
||||
}
|
||||
|
||||
function isVersionPlaceholder(version?: string) {
|
||||
const normalized = String(version || '')
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
|
||||
if (
|
||||
!normalized ||
|
||||
normalized === 'loading' ||
|
||||
normalized === 'unknown' ||
|
||||
normalized === 'not installed'
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return (
|
||||
(typeof _ === 'function' && normalized === _('unknown').toLowerCase()) ||
|
||||
(typeof _ === 'function' && normalized === _('Not installed').toLowerCase())
|
||||
);
|
||||
}
|
||||
|
||||
export function formatSingBoxVersion(value: SingBoxVariantFields) {
|
||||
const version = String(value.sing_box_version || '');
|
||||
|
||||
if (!version || version === 'not installed') {
|
||||
return _('Not installed');
|
||||
}
|
||||
|
||||
if (isVersionPlaceholder(version)) {
|
||||
return version;
|
||||
}
|
||||
|
||||
const normalizedValue = normalizeSingBoxVariantFields(value);
|
||||
let variant = '';
|
||||
|
||||
if (
|
||||
normalizedValue.sing_box_extended &&
|
||||
normalizedValue.sing_box_compressed
|
||||
) {
|
||||
variant = _('compressed');
|
||||
} else if (normalizedValue.sing_box_tiny) {
|
||||
variant = _('tiny');
|
||||
}
|
||||
|
||||
return variant ? `${version} (${variant})` : version;
|
||||
}
|
||||
|
||||
export function normalizeSingBoxVariantFields<T extends SingBoxVariantFields>(
|
||||
value: T,
|
||||
): T {
|
||||
const versionExtended = isExtendedSingBoxVersion(value.sing_box_version);
|
||||
const singBoxExtended = Boolean(value.sing_box_extended) || versionExtended;
|
||||
|
||||
return {
|
||||
...value,
|
||||
sing_box_extended: singBoxExtended ? 1 : 0,
|
||||
sing_box_tiny: singBoxExtended ? 0 : value.sing_box_tiny ? 1 : 0,
|
||||
sing_box_compressed: singBoxExtended && value.sing_box_compressed ? 1 : 0,
|
||||
sing_box_tailscale: singBoxExtended || value.sing_box_tailscale ? 1 : 0,
|
||||
} as T;
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { isTransientRpcError } from '../isTransientRpcError';
|
||||
|
||||
describe('isTransientRpcError', () => {
|
||||
it('detects LuCI RPC reply races caused by interrupted page requests', () => {
|
||||
expect(isTransientRpcError('No related RPC reply')).toBe(true);
|
||||
expect(isTransientRpcError('Request aborted while waiting for RPC')).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it('does not treat backend action failures as transient transport errors', () => {
|
||||
expect(
|
||||
isTransientRpcError('Another component action is already running'),
|
||||
).toBe(false);
|
||||
expect(isTransientRpcError('Component action job was not found')).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,11 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { shouldShowLoadingForRestoredAction } from '../restoredActionLoading';
|
||||
|
||||
describe('restored action loading', () => {
|
||||
it('shows loading only for actions that are still running', () => {
|
||||
expect(shouldShowLoadingForRestoredAction({ running: true })).toBe(true);
|
||||
expect(shouldShowLoadingForRestoredAction({ running: false })).toBe(false);
|
||||
expect(shouldShowLoadingForRestoredAction({})).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,25 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { getServiceAvailability } from '../serviceAvailability';
|
||||
|
||||
describe('getServiceAvailability', () => {
|
||||
it('does not treat an unresolved service status as stopped', () => {
|
||||
expect(
|
||||
getServiceAvailability({ loading: true, failed: false, running: false }),
|
||||
).toBe('loading');
|
||||
});
|
||||
|
||||
it('distinguishes a stopped service from an unavailable status request', () => {
|
||||
expect(
|
||||
getServiceAvailability({ loading: false, failed: false, running: 0 }),
|
||||
).toBe('stopped');
|
||||
expect(
|
||||
getServiceAvailability({ loading: false, failed: true, running: 0 }),
|
||||
).toBe('unavailable');
|
||||
});
|
||||
|
||||
it('accepts the numeric running flag returned by the backend', () => {
|
||||
expect(
|
||||
getServiceAvailability({ loading: false, failed: false, running: 1 }),
|
||||
).toBe('running');
|
||||
});
|
||||
});
|
||||
@@ -1,9 +0,0 @@
|
||||
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,8 +0,0 @@
|
||||
import { Forkop } from '../../types';
|
||||
import { FORKOP_UCI_PACKAGE } from '../../../constants';
|
||||
|
||||
export async function getConfigSections(): Promise<Forkop.ConfigSection[]> {
|
||||
return uci
|
||||
.load(FORKOP_UCI_PACKAGE)
|
||||
.then(() => uci.sections(FORKOP_UCI_PACKAGE));
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,9 +0,0 @@
|
||||
import { getConfigSections } from './getConfigSections';
|
||||
import { getDashboardSections } from './getDashboardSections';
|
||||
import { getClashApiSecret } from './getClashApiSecret';
|
||||
|
||||
export const CustomForkopMethods = {
|
||||
getConfigSections,
|
||||
getDashboardSections,
|
||||
getClashApiSecret,
|
||||
};
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,23 +0,0 @@
|
||||
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,
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
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,
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
import { getFakeIpCheck } from './getFakeIpCheck';
|
||||
import { getIpCheck } from './getIpCheck';
|
||||
|
||||
export const RemoteFakeIPMethods = {
|
||||
getFakeIpCheck,
|
||||
getIpCheck,
|
||||
};
|
||||
@@ -1,3 +0,0 @@
|
||||
export * from './custom';
|
||||
export * from './fakeip';
|
||||
export * from './shell';
|
||||
@@ -1,57 +0,0 @@
|
||||
import { executeShellCommand } from '../../../helpers';
|
||||
import { Forkop } from '../../types';
|
||||
|
||||
interface CallBaseMethodOptions {
|
||||
allowNonZeroWithStdout?: boolean;
|
||||
timeout?: number;
|
||||
}
|
||||
|
||||
export async function callBaseMethod<T>(
|
||||
method: Forkop.AvailableMethods,
|
||||
args: string[] = [],
|
||||
command: string = '/usr/bin/forkop',
|
||||
options: CallBaseMethodOptions = {},
|
||||
): Promise<Forkop.MethodResponse<T>> {
|
||||
try {
|
||||
const response = await executeShellCommand({
|
||||
command,
|
||||
args: [method as string, ...args],
|
||||
timeout: options.timeout ?? 15000,
|
||||
});
|
||||
const exitCode = response.code ?? 0;
|
||||
|
||||
if (
|
||||
exitCode !== 0 &&
|
||||
!(options.allowNonZeroWithStdout && response.stdout)
|
||||
) {
|
||||
return {
|
||||
success: false,
|
||||
error: response.stderr || response.stdout || '',
|
||||
};
|
||||
}
|
||||
|
||||
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: response.stderr || '',
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : '',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,765 +0,0 @@
|
||||
import { callBaseMethod } from './callBaseMethod';
|
||||
import { ClashAPI, Forkop } from '../../types';
|
||||
import { executeShellCommand } from '../../../helpers';
|
||||
import { isTransientRpcError } from '../../helpers/isTransientRpcError';
|
||||
|
||||
const SUBSCRIPTION_UPDATE_RPC_TIMEOUT_MS = 15000;
|
||||
const SUBSCRIPTION_UPDATE_POLL_INTERVAL_MS = 1500;
|
||||
const UI_ACTION_RPC_TIMEOUT_MS = 15000;
|
||||
const UI_ACTION_TRANSIENT_RPC_GRACE_MS = 30000;
|
||||
const SERVICE_ACTION_TIMEOUT_MS = 2 * 60 * 1000;
|
||||
const SERVICE_ACTION_POLL_INTERVAL_MS = 1000;
|
||||
const LATENCY_TEST_TIMEOUT_MS = 30 * 1000;
|
||||
const LATENCY_TEST_POLL_INTERVAL_MS = 1000;
|
||||
const COMPONENT_ACTION_RPC_TIMEOUT_MS = 15000;
|
||||
const COMPONENT_ACTION_POLL_INTERVAL_MS = 1500;
|
||||
const COMPONENT_ACTION_STATUS_REFRESH_INTERVAL_MS = 15000;
|
||||
const COMPONENT_ACTION_SELF_UPDATE_SETTLE_MS = 30000;
|
||||
const COMPONENT_ACTION_TRANSIENT_RPC_GRACE_MS = 30000;
|
||||
const COMPONENT_ACTION_STATE_DIR = '/var/run/forkop/component-actions';
|
||||
const GET_UI_STATE_RPC_TIMEOUT_MS = 3000;
|
||||
|
||||
function sleep(ms: number) {
|
||||
return new Promise<void>((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
function translate(message: string) {
|
||||
return typeof _ === 'function' ? _(message) : message;
|
||||
}
|
||||
|
||||
function parseJsonObjectOutput<T>(output: string): T | null {
|
||||
if (!output) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(output) as T;
|
||||
} catch (_error) {
|
||||
const jsonMatch = output.match(/(\{[\s\S]*\})\s*$/);
|
||||
|
||||
if (!jsonMatch) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(jsonMatch[1]) as T;
|
||||
} catch (_jsonError) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function parseComponentActionOutput(output: string) {
|
||||
return parseJsonObjectOutput<Forkop.ComponentActionResult>(output);
|
||||
}
|
||||
|
||||
function parseComponentActionResult(
|
||||
response: Awaited<ReturnType<typeof executeShellCommand>>,
|
||||
) {
|
||||
return parseComponentActionOutput(response.stdout);
|
||||
}
|
||||
|
||||
function parseComponentActionStartResult(
|
||||
response: Awaited<ReturnType<typeof executeShellCommand>>,
|
||||
) {
|
||||
const parsedResponse = parseComponentActionResult(response);
|
||||
|
||||
if (!parsedResponse) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return parsedResponse as unknown as Forkop.ComponentActionStartResult;
|
||||
}
|
||||
|
||||
function parseSubscriptionUpdateStartResult(
|
||||
response: Awaited<ReturnType<typeof executeShellCommand>>,
|
||||
) {
|
||||
return parseJsonObjectOutput<Forkop.SubscriptionUpdateStartResult>(
|
||||
response.stdout,
|
||||
);
|
||||
}
|
||||
|
||||
function parseSubscriptionUpdateJobState(
|
||||
response: Awaited<ReturnType<typeof executeShellCommand>>,
|
||||
) {
|
||||
return parseJsonObjectOutput<Forkop.SubscriptionUpdateJobState>(
|
||||
response.stdout,
|
||||
);
|
||||
}
|
||||
|
||||
function parseUiActionStartResult(
|
||||
response: Awaited<ReturnType<typeof executeShellCommand>>,
|
||||
) {
|
||||
return parseJsonObjectOutput<Forkop.UiActionStartResult>(response.stdout);
|
||||
}
|
||||
|
||||
function parseServiceActionState(
|
||||
response: Awaited<ReturnType<typeof executeShellCommand>>,
|
||||
) {
|
||||
return parseJsonObjectOutput<Forkop.ServiceActionState>(response.stdout);
|
||||
}
|
||||
|
||||
function parseLatencyActionState(
|
||||
response: Awaited<ReturnType<typeof executeShellCommand>>,
|
||||
) {
|
||||
return parseJsonObjectOutput<Forkop.LatencyActionState>(response.stdout);
|
||||
}
|
||||
|
||||
function isComponentActionJobId(jobId: string) {
|
||||
return /^[A-Za-z0-9._-]+$/.test(jobId) && jobId !== '.' && jobId !== '..';
|
||||
}
|
||||
|
||||
async function readComponentActionState(jobId: string) {
|
||||
if (!isComponentActionJobId(jobId)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return parseComponentActionOutput(
|
||||
await fs.read(`${COMPONENT_ACTION_STATE_DIR}/${jobId}.json`),
|
||||
);
|
||||
} catch (_error) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function readForkopVersion() {
|
||||
const response = await executeShellCommand({
|
||||
command: '/usr/bin/forkop',
|
||||
args: ['show_version'],
|
||||
timeout: COMPONENT_ACTION_RPC_TIMEOUT_MS,
|
||||
});
|
||||
|
||||
if ((response.code ?? 0) !== 0 || !response.stdout) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return response.stdout.trim();
|
||||
}
|
||||
|
||||
async function isComponentActionStillRunning(
|
||||
jobId: string,
|
||||
component: Forkop.ComponentName,
|
||||
action: Forkop.ComponentAction,
|
||||
) {
|
||||
const response = await callBaseMethod<Forkop.UiState>(
|
||||
Forkop.AvailableMethods.GET_UI_STATE,
|
||||
[],
|
||||
'/usr/bin/forkop',
|
||||
{ timeout: GET_UI_STATE_RPC_TIMEOUT_MS },
|
||||
);
|
||||
|
||||
return (
|
||||
response.success &&
|
||||
response.data.actions.component.some(
|
||||
(state) =>
|
||||
state.job_id === jobId &&
|
||||
state.component === component &&
|
||||
state.action === action &&
|
||||
state.running === true,
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function componentActionFailure(
|
||||
response: Awaited<ReturnType<typeof executeShellCommand>>,
|
||||
parsedResponse?: Pick<Forkop.ComponentActionResult, 'message'> | null,
|
||||
) {
|
||||
return {
|
||||
success: false,
|
||||
error: parsedResponse?.message || response.stderr || _('Failed to execute'),
|
||||
} as Forkop.MethodFailureResponse;
|
||||
}
|
||||
|
||||
function uiActionFailure(
|
||||
response: Awaited<ReturnType<typeof executeShellCommand>>,
|
||||
parsedResponse?: { message?: string } | null,
|
||||
fallback: string = _('Failed to execute'),
|
||||
) {
|
||||
return {
|
||||
success: false,
|
||||
error: parsedResponse?.message || response.stderr || fallback,
|
||||
} as Forkop.MethodFailureResponse;
|
||||
}
|
||||
|
||||
function createTransientRpcGraceTracker(graceMs: number) {
|
||||
let failureStartedAt = 0;
|
||||
|
||||
return {
|
||||
reset() {
|
||||
failureStartedAt = 0;
|
||||
},
|
||||
shouldContinue(error?: string) {
|
||||
if (!isTransientRpcError(error)) {
|
||||
failureStartedAt = 0;
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!failureStartedAt) {
|
||||
failureStartedAt = Date.now();
|
||||
}
|
||||
|
||||
return Date.now() - failureStartedAt < graceMs;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const ForkopShellMethods = {
|
||||
checkDNSAvailable: async () =>
|
||||
callBaseMethod<Forkop.DnsCheckResult>(
|
||||
Forkop.AvailableMethods.CHECK_DNS_AVAILABLE,
|
||||
),
|
||||
checkFakeIP: async () =>
|
||||
callBaseMethod<Forkop.FakeIPCheckResult>(
|
||||
Forkop.AvailableMethods.CHECK_FAKEIP,
|
||||
),
|
||||
checkNftRules: async () =>
|
||||
callBaseMethod<Forkop.NftRulesCheckResult>(
|
||||
Forkop.AvailableMethods.CHECK_NFT_RULES,
|
||||
),
|
||||
checkZapretRuntime: async () =>
|
||||
callBaseMethod<Forkop.ZapretCheckResult>(
|
||||
Forkop.AvailableMethods.CHECK_ZAPRET_RUNTIME,
|
||||
),
|
||||
checkZapret2Runtime: async () =>
|
||||
callBaseMethod<Forkop.Zapret2CheckResult>(
|
||||
Forkop.AvailableMethods.CHECK_ZAPRET2_RUNTIME,
|
||||
),
|
||||
checkByedpiRuntime: async () =>
|
||||
callBaseMethod<Forkop.ByedpiCheckResult>(
|
||||
Forkop.AvailableMethods.CHECK_BYEDPI_RUNTIME,
|
||||
),
|
||||
checkInboundsConfig: async () =>
|
||||
callBaseMethod<Forkop.InboundsConfigCheckResult>(
|
||||
Forkop.AvailableMethods.CHECK_INBOUNDS_CONFIG,
|
||||
),
|
||||
getStatus: async () =>
|
||||
callBaseMethod<Forkop.GetStatus>(Forkop.AvailableMethods.GET_STATUS),
|
||||
getOutboundMetadata: async (section: string) =>
|
||||
callBaseMethod<Forkop.GetOutboundMetadata>(
|
||||
Forkop.AvailableMethods.GET_OUTBOUND_METADATA,
|
||||
[section],
|
||||
),
|
||||
getSubscriptionMetadata: async (section: string) =>
|
||||
callBaseMethod<Forkop.SubscriptionMetadata | Forkop.SubscriptionMetadata[]>(
|
||||
Forkop.AvailableMethods.GET_SUBSCRIPTION_METADATA,
|
||||
[section],
|
||||
),
|
||||
checkSingBox: async () =>
|
||||
callBaseMethod<Forkop.SingBoxCheckResult>(
|
||||
Forkop.AvailableMethods.CHECK_SING_BOX,
|
||||
),
|
||||
checkInbounds: async () =>
|
||||
callBaseMethod<Forkop.InboundsCheckResult>(
|
||||
Forkop.AvailableMethods.CHECK_INBOUNDS,
|
||||
),
|
||||
getSingBoxStatus: async () =>
|
||||
callBaseMethod<Forkop.GetSingBoxStatus>(
|
||||
Forkop.AvailableMethods.GET_SING_BOX_STATUS,
|
||||
),
|
||||
getZapretStatus: async () =>
|
||||
callBaseMethod<Forkop.GetZapretStatus>(
|
||||
Forkop.AvailableMethods.GET_ZAPRET_STATUS,
|
||||
),
|
||||
getZapret2Status: async () =>
|
||||
callBaseMethod<Forkop.GetZapret2Status>(
|
||||
Forkop.AvailableMethods.GET_ZAPRET2_STATUS,
|
||||
),
|
||||
getByedpiStatus: async () =>
|
||||
callBaseMethod<Forkop.GetByedpiStatus>(
|
||||
Forkop.AvailableMethods.GET_BYEDPI_STATUS,
|
||||
),
|
||||
getClashApiProxies: async () =>
|
||||
callBaseMethod<ClashAPI.Proxies>(Forkop.AvailableMethods.CLASH_API, [
|
||||
Forkop.AvailableClashAPIMethods.GET_PROXIES,
|
||||
]),
|
||||
getClashApiConnections: async () =>
|
||||
callBaseMethod<unknown>(Forkop.AvailableMethods.CLASH_API, [
|
||||
Forkop.AvailableClashAPIMethods.GET_CONNECTIONS,
|
||||
]),
|
||||
getClashApiProxyLatency: async (tag: string, timeout = '5000') =>
|
||||
callBaseMethod<Forkop.GetClashApiProxyLatency>(
|
||||
Forkop.AvailableMethods.CLASH_API,
|
||||
[Forkop.AvailableClashAPIMethods.GET_PROXY_LATENCY, tag, timeout],
|
||||
),
|
||||
getClashApiProxyLatencies: async (tags: string[]) =>
|
||||
callBaseMethod<Forkop.GetClashApiProxyLatencies>(
|
||||
Forkop.AvailableMethods.CLASH_API,
|
||||
[
|
||||
Forkop.AvailableClashAPIMethods.GET_PROXY_LATENCIES,
|
||||
JSON.stringify(tags),
|
||||
'5000',
|
||||
],
|
||||
),
|
||||
getClashApiGroupLatency: async (tag: string) =>
|
||||
callBaseMethod<Forkop.GetClashApiGroupLatency>(
|
||||
Forkop.AvailableMethods.CLASH_API,
|
||||
[Forkop.AvailableClashAPIMethods.GET_GROUP_LATENCY, tag, '10000'],
|
||||
),
|
||||
setClashApiGroupProxy: async (group: string, proxy: string) =>
|
||||
callBaseMethod<unknown>(Forkop.AvailableMethods.CLASH_API, [
|
||||
Forkop.AvailableClashAPIMethods.SET_GROUP_PROXY,
|
||||
group,
|
||||
proxy,
|
||||
]),
|
||||
closeClashApiConnection: async (connectionId: string) =>
|
||||
callBaseMethod<unknown>(Forkop.AvailableMethods.CLASH_API, [
|
||||
Forkop.AvailableClashAPIMethods.CLOSE_CONNECTION,
|
||||
connectionId,
|
||||
]),
|
||||
closeAllClashApiConnections: async () =>
|
||||
callBaseMethod<unknown>(Forkop.AvailableMethods.CLASH_API, [
|
||||
Forkop.AvailableClashAPIMethods.CLOSE_ALL_CONNECTIONS,
|
||||
]),
|
||||
enable: async () =>
|
||||
callBaseMethod<unknown>(
|
||||
Forkop.AvailableMethods.ENABLE,
|
||||
[],
|
||||
'/etc/init.d/forkop',
|
||||
),
|
||||
disable: async () =>
|
||||
callBaseMethod<unknown>(
|
||||
Forkop.AvailableMethods.DISABLE,
|
||||
[],
|
||||
'/etc/init.d/forkop',
|
||||
),
|
||||
globalCheck: async (masked = true) =>
|
||||
callBaseMethod<unknown>(Forkop.AvailableMethods.GLOBAL_CHECK, [
|
||||
masked ? 'masked' : 'raw',
|
||||
]),
|
||||
showSingBoxConfig: async (masked = true) =>
|
||||
callBaseMethod<unknown>(Forkop.AvailableMethods.SHOW_SING_BOX_CONFIG, [
|
||||
masked ? 'masked' : 'raw',
|
||||
]),
|
||||
checkLogs: async () =>
|
||||
callBaseMethod<unknown>(Forkop.AvailableMethods.CHECK_LOGS),
|
||||
checkSingBoxLogs: async () =>
|
||||
callBaseMethod<unknown>(Forkop.AvailableMethods.CHECK_SING_BOX_LOGS),
|
||||
getSystemInfo: async () =>
|
||||
callBaseMethod<Forkop.GetSystemInfo>(
|
||||
Forkop.AvailableMethods.GET_SYSTEM_INFO,
|
||||
),
|
||||
getServerCapabilities: async () =>
|
||||
callBaseMethod<Forkop.GetServerCapabilities>(
|
||||
Forkop.AvailableMethods.GET_SERVER_CAPABILITIES,
|
||||
),
|
||||
getUiCapabilities: async () =>
|
||||
callBaseMethod<Forkop.GetUiCapabilities>(
|
||||
Forkop.AvailableMethods.GET_UI_CAPABILITIES,
|
||||
),
|
||||
getUiState: async () =>
|
||||
callBaseMethod<Forkop.UiState>(
|
||||
Forkop.AvailableMethods.GET_UI_STATE,
|
||||
[],
|
||||
'/usr/bin/forkop',
|
||||
{ timeout: GET_UI_STATE_RPC_TIMEOUT_MS },
|
||||
),
|
||||
serviceActionStart: async (action: Forkop.ServiceAction) => {
|
||||
const response = await executeShellCommand({
|
||||
command: '/usr/bin/forkop',
|
||||
args: [Forkop.AvailableMethods.SERVICE_ACTION_ASYNC, action],
|
||||
timeout: UI_ACTION_RPC_TIMEOUT_MS,
|
||||
});
|
||||
const parsedResponse = parseUiActionStartResult(response);
|
||||
|
||||
if (
|
||||
(response.code ?? 0) !== 0 ||
|
||||
!parsedResponse?.success ||
|
||||
!parsedResponse.job_id
|
||||
) {
|
||||
return uiActionFailure(
|
||||
response,
|
||||
parsedResponse,
|
||||
_('Service action failed'),
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: parsedResponse,
|
||||
} as Forkop.MethodSuccessResponse<Forkop.UiActionStartResult>;
|
||||
},
|
||||
serviceActionStatus: async (jobId: string) => {
|
||||
const response = await executeShellCommand({
|
||||
command: '/usr/bin/forkop',
|
||||
args: [Forkop.AvailableMethods.SERVICE_ACTION_STATUS, jobId],
|
||||
timeout: UI_ACTION_RPC_TIMEOUT_MS,
|
||||
});
|
||||
const parsedResponse = parseServiceActionState(response);
|
||||
|
||||
if ((response.code ?? 0) !== 0 || !parsedResponse) {
|
||||
return uiActionFailure(
|
||||
response,
|
||||
parsedResponse,
|
||||
_('Service action failed'),
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: parsedResponse,
|
||||
} as Forkop.MethodSuccessResponse<Forkop.ServiceActionState>;
|
||||
},
|
||||
waitServiceActionJob: async (jobId: string, startedAt = Date.now()) => {
|
||||
while (Date.now() - startedAt < SERVICE_ACTION_TIMEOUT_MS) {
|
||||
await sleep(SERVICE_ACTION_POLL_INTERVAL_MS);
|
||||
|
||||
const response = await ForkopShellMethods.serviceActionStatus(jobId);
|
||||
|
||||
if (!response.success) {
|
||||
return response;
|
||||
}
|
||||
|
||||
if (response.data.running) {
|
||||
continue;
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: _('Operation timed out'),
|
||||
} as Forkop.MethodFailureResponse;
|
||||
},
|
||||
latencyTestStart: async (
|
||||
latencyType: Forkop.LatencyActionState['latency_type'],
|
||||
section: string,
|
||||
tag: string,
|
||||
timeout?: string,
|
||||
) => {
|
||||
const response = await executeShellCommand({
|
||||
command: '/usr/bin/forkop',
|
||||
args: [
|
||||
Forkop.AvailableMethods.LATENCY_TEST_ASYNC,
|
||||
latencyType,
|
||||
section,
|
||||
tag,
|
||||
...(timeout ? [timeout] : []),
|
||||
],
|
||||
timeout: UI_ACTION_RPC_TIMEOUT_MS,
|
||||
});
|
||||
const parsedResponse = parseUiActionStartResult(response);
|
||||
|
||||
if (
|
||||
(response.code ?? 0) !== 0 ||
|
||||
!parsedResponse?.success ||
|
||||
!parsedResponse.job_id
|
||||
) {
|
||||
return uiActionFailure(
|
||||
response,
|
||||
parsedResponse,
|
||||
_('Latency test failed'),
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: parsedResponse,
|
||||
} as Forkop.MethodSuccessResponse<Forkop.UiActionStartResult>;
|
||||
},
|
||||
latencyTestStatus: async (jobId: string) => {
|
||||
const response = await executeShellCommand({
|
||||
command: '/usr/bin/forkop',
|
||||
args: [Forkop.AvailableMethods.LATENCY_TEST_STATUS, jobId],
|
||||
timeout: UI_ACTION_RPC_TIMEOUT_MS,
|
||||
});
|
||||
const parsedResponse = parseLatencyActionState(response);
|
||||
|
||||
if ((response.code ?? 0) !== 0 || !parsedResponse) {
|
||||
return uiActionFailure(
|
||||
response,
|
||||
parsedResponse,
|
||||
_('Latency test failed'),
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: parsedResponse,
|
||||
} as Forkop.MethodSuccessResponse<Forkop.LatencyActionState>;
|
||||
},
|
||||
waitLatencyTestJob: async (jobId: string, startedAt = Date.now()) => {
|
||||
const transientRpc = createTransientRpcGraceTracker(
|
||||
UI_ACTION_TRANSIENT_RPC_GRACE_MS,
|
||||
);
|
||||
|
||||
while (Date.now() - startedAt < LATENCY_TEST_TIMEOUT_MS) {
|
||||
await sleep(LATENCY_TEST_POLL_INTERVAL_MS);
|
||||
|
||||
const response = await ForkopShellMethods.latencyTestStatus(jobId);
|
||||
|
||||
if (!response.success) {
|
||||
if (transientRpc.shouldContinue(response.error)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
transientRpc.reset();
|
||||
if (response.data.running) {
|
||||
continue;
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: _('Operation timed out'),
|
||||
} as Forkop.MethodFailureResponse;
|
||||
},
|
||||
uiActionAck: async (
|
||||
kind: 'service' | 'latency' | 'component' | 'subscription',
|
||||
jobId: string,
|
||||
) => {
|
||||
const response = await executeShellCommand({
|
||||
command: '/usr/bin/forkop',
|
||||
args: [Forkop.AvailableMethods.UI_ACTION_ACK, kind, jobId],
|
||||
timeout: UI_ACTION_RPC_TIMEOUT_MS,
|
||||
});
|
||||
const parsedResponse = parseUiActionStartResult(response);
|
||||
|
||||
if ((response.code ?? 0) !== 0 || !parsedResponse?.success) {
|
||||
return uiActionFailure(response, parsedResponse);
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: parsedResponse,
|
||||
} as Forkop.MethodSuccessResponse<Forkop.UiActionStartResult>;
|
||||
},
|
||||
componentActionStart: async (
|
||||
component: Forkop.ComponentName,
|
||||
action: Forkop.ComponentAction,
|
||||
) => {
|
||||
const response = await executeShellCommand({
|
||||
command: '/usr/bin/forkop',
|
||||
args: [Forkop.AvailableMethods.COMPONENT_ACTION_ASYNC, component, action],
|
||||
timeout: COMPONENT_ACTION_RPC_TIMEOUT_MS,
|
||||
});
|
||||
const parsedResponse = parseComponentActionStartResult(response);
|
||||
|
||||
if (
|
||||
(response.code ?? 0) !== 0 ||
|
||||
!parsedResponse?.success ||
|
||||
!parsedResponse.job_id
|
||||
) {
|
||||
return componentActionFailure(response, parsedResponse);
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: parsedResponse,
|
||||
} as Forkop.MethodSuccessResponse<Forkop.ComponentActionStartResult>;
|
||||
},
|
||||
componentActionStatus: async (jobId: string) => {
|
||||
const response = await executeShellCommand({
|
||||
command: '/usr/bin/forkop',
|
||||
args: [Forkop.AvailableMethods.COMPONENT_ACTION_STATUS, jobId],
|
||||
timeout: COMPONENT_ACTION_RPC_TIMEOUT_MS,
|
||||
});
|
||||
const parsedResponse = parseComponentActionResult(response);
|
||||
|
||||
if ((response.code ?? 0) !== 0 || !parsedResponse) {
|
||||
return componentActionFailure(response, parsedResponse);
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: parsedResponse,
|
||||
} as Forkop.MethodSuccessResponse<Forkop.ComponentActionResult>;
|
||||
},
|
||||
componentUpdateCheckCache: async () =>
|
||||
callBaseMethod<Forkop.ComponentUpdateCheckCache>(
|
||||
Forkop.AvailableMethods.COMPONENT_UPDATE_CHECK_CACHE,
|
||||
),
|
||||
waitComponentActionJob: async (
|
||||
jobId: string,
|
||||
component: Forkop.ComponentName,
|
||||
action: Forkop.ComponentAction,
|
||||
expectedLatestVersion?: string,
|
||||
) => {
|
||||
let selfUpdateVersionMatchedAt = 0;
|
||||
let lastStatusRefreshAt = 0;
|
||||
const transientRpc = createTransientRpcGraceTracker(
|
||||
COMPONENT_ACTION_TRANSIENT_RPC_GRACE_MS,
|
||||
);
|
||||
|
||||
while (true) {
|
||||
await sleep(COMPONENT_ACTION_POLL_INTERVAL_MS);
|
||||
|
||||
const stateResponse = await readComponentActionState(jobId);
|
||||
|
||||
if (stateResponse) {
|
||||
if (!stateResponse.running) {
|
||||
transientRpc.reset();
|
||||
return {
|
||||
success: true,
|
||||
data: stateResponse,
|
||||
} as Forkop.MethodSuccessResponse<Forkop.ComponentActionResult>;
|
||||
}
|
||||
|
||||
if (
|
||||
Date.now() - lastStatusRefreshAt <
|
||||
COMPONENT_ACTION_STATUS_REFRESH_INTERVAL_MS
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
lastStatusRefreshAt = Date.now();
|
||||
const statusResponse = await executeShellCommand({
|
||||
command: '/usr/bin/forkop',
|
||||
args: [Forkop.AvailableMethods.COMPONENT_ACTION_STATUS, jobId],
|
||||
timeout: COMPONENT_ACTION_RPC_TIMEOUT_MS,
|
||||
});
|
||||
const parsedResponse = parseComponentActionResult(statusResponse);
|
||||
|
||||
if ((statusResponse.code ?? 0) !== 0 || !parsedResponse) {
|
||||
if (stateResponse?.running) {
|
||||
transientRpc.reset();
|
||||
continue;
|
||||
}
|
||||
|
||||
if (await isComponentActionStillRunning(jobId, component, action)) {
|
||||
transientRpc.reset();
|
||||
continue;
|
||||
}
|
||||
|
||||
const failure = componentActionFailure(statusResponse, parsedResponse);
|
||||
|
||||
if (transientRpc.shouldContinue(failure.error)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (component === 'forkop' && action === 'install') {
|
||||
const installedVersion = expectedLatestVersion
|
||||
? await readForkopVersion()
|
||||
: '';
|
||||
|
||||
if (
|
||||
expectedLatestVersion &&
|
||||
installedVersion === expectedLatestVersion
|
||||
) {
|
||||
if (!selfUpdateVersionMatchedAt) {
|
||||
selfUpdateVersionMatchedAt = Date.now();
|
||||
}
|
||||
|
||||
if (
|
||||
Date.now() - selfUpdateVersionMatchedAt >=
|
||||
COMPONENT_ACTION_SELF_UPDATE_SETTLE_MS
|
||||
) {
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
success: true,
|
||||
component,
|
||||
action,
|
||||
message: translate('Forkop has been installed'),
|
||||
current_version: installedVersion,
|
||||
latest_version: expectedLatestVersion,
|
||||
changed: true,
|
||||
status: 'latest',
|
||||
},
|
||||
} as Forkop.MethodSuccessResponse<Forkop.ComponentActionResult>;
|
||||
}
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
return failure;
|
||||
}
|
||||
|
||||
transientRpc.reset();
|
||||
if (parsedResponse.running) {
|
||||
continue;
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: parsedResponse,
|
||||
} as Forkop.MethodSuccessResponse<Forkop.ComponentActionResult>;
|
||||
}
|
||||
},
|
||||
subscriptionUpdateStart: async (section?: string, sourceIndex?: number) => {
|
||||
const startArgs = [
|
||||
Forkop.AvailableMethods.SUBSCRIPTION_UPDATE_ASYNC,
|
||||
...(section ? [section] : []),
|
||||
...(section && sourceIndex !== undefined ? [String(sourceIndex)] : []),
|
||||
];
|
||||
const response = await executeShellCommand({
|
||||
command: '/usr/bin/forkop',
|
||||
args: startArgs,
|
||||
timeout: SUBSCRIPTION_UPDATE_RPC_TIMEOUT_MS,
|
||||
});
|
||||
const parsedResponse = parseSubscriptionUpdateStartResult(response);
|
||||
|
||||
if (
|
||||
(response.code ?? 0) !== 0 ||
|
||||
!parsedResponse?.success ||
|
||||
!parsedResponse.job_id
|
||||
) {
|
||||
return {
|
||||
success: false,
|
||||
error:
|
||||
parsedResponse?.message ||
|
||||
response.stderr ||
|
||||
_('Subscription update failed'),
|
||||
} as Forkop.MethodFailureResponse;
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: parsedResponse,
|
||||
} as Forkop.MethodSuccessResponse<Forkop.SubscriptionUpdateStartResult>;
|
||||
},
|
||||
subscriptionUpdateStatus: async (jobId: string) => {
|
||||
const response = await executeShellCommand({
|
||||
command: '/usr/bin/forkop',
|
||||
args: [Forkop.AvailableMethods.SUBSCRIPTION_UPDATE_STATUS, jobId],
|
||||
timeout: SUBSCRIPTION_UPDATE_RPC_TIMEOUT_MS,
|
||||
});
|
||||
const parsedResponse = parseSubscriptionUpdateJobState(response);
|
||||
|
||||
if ((response.code ?? 0) !== 0 || !parsedResponse) {
|
||||
return {
|
||||
success: false,
|
||||
error: response.stderr || _('Subscription update failed'),
|
||||
} as Forkop.MethodFailureResponse;
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: parsedResponse,
|
||||
} as Forkop.MethodSuccessResponse<Forkop.SubscriptionUpdateJobState>;
|
||||
},
|
||||
waitSubscriptionUpdateJob: async (jobId: string) => {
|
||||
const transientRpc = createTransientRpcGraceTracker(
|
||||
UI_ACTION_TRANSIENT_RPC_GRACE_MS,
|
||||
);
|
||||
|
||||
while (true) {
|
||||
await sleep(SUBSCRIPTION_UPDATE_POLL_INTERVAL_MS);
|
||||
|
||||
const response = await ForkopShellMethods.subscriptionUpdateStatus(jobId);
|
||||
|
||||
if (!response.success) {
|
||||
if (transientRpc.shouldContinue(response.error)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
transientRpc.reset();
|
||||
if (response.data.running) {
|
||||
continue;
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -1,76 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
executeShellCommand: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../../../helpers', () => ({
|
||||
executeShellCommand: mocks.executeShellCommand,
|
||||
}));
|
||||
|
||||
import { Forkop } from '../../../types';
|
||||
import { callBaseMethod } from '../callBaseMethod';
|
||||
|
||||
describe('callBaseMethod', () => {
|
||||
beforeEach(() => {
|
||||
mocks.executeShellCommand.mockReset();
|
||||
});
|
||||
|
||||
it('fails on non-zero exit code even when stdout is present by default', async () => {
|
||||
mocks.executeShellCommand.mockResolvedValue({
|
||||
stdout: '{"ok":false}',
|
||||
stderr: 'command failed',
|
||||
code: 1,
|
||||
});
|
||||
|
||||
const response = await callBaseMethod(
|
||||
Forkop.AvailableMethods.CHECK_DNS_AVAILABLE,
|
||||
);
|
||||
|
||||
expect(response).toEqual({
|
||||
success: false,
|
||||
error: 'command failed',
|
||||
});
|
||||
});
|
||||
|
||||
it('can return stdout for diagnostic commands with non-zero exit code', async () => {
|
||||
mocks.executeShellCommand.mockResolvedValue({
|
||||
stdout: 'Global check run\n[FAIL] Sing-box process running',
|
||||
stderr: '',
|
||||
code: 1,
|
||||
});
|
||||
|
||||
const response = await callBaseMethod(
|
||||
Forkop.AvailableMethods.GLOBAL_CHECK,
|
||||
[],
|
||||
'/usr/bin/forkop',
|
||||
{ allowNonZeroWithStdout: true },
|
||||
);
|
||||
|
||||
expect(response).toEqual({
|
||||
success: true,
|
||||
data: 'Global check run\n[FAIL] Sing-box process running',
|
||||
});
|
||||
});
|
||||
|
||||
it('passes a custom timeout to long-running commands', async () => {
|
||||
mocks.executeShellCommand.mockResolvedValue({
|
||||
stdout: '{"ok":true}',
|
||||
stderr: '',
|
||||
code: 0,
|
||||
});
|
||||
|
||||
await callBaseMethod(
|
||||
Forkop.AvailableMethods.GLOBAL_CHECK,
|
||||
[],
|
||||
'/usr/bin/forkop',
|
||||
{ timeout: 60000 },
|
||||
);
|
||||
|
||||
expect(mocks.executeShellCommand).toHaveBeenCalledWith({
|
||||
command: '/usr/bin/forkop',
|
||||
args: ['global_check'],
|
||||
timeout: 60000,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,347 +0,0 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
executeShellCommand: vi.fn(),
|
||||
fsRead: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../../../helpers', () => ({
|
||||
executeShellCommand: mocks.executeShellCommand,
|
||||
}));
|
||||
|
||||
import { ForkopShellMethods } from '../index';
|
||||
|
||||
describe('ForkopShellMethods.componentAction', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
mocks.executeShellCommand.mockReset();
|
||||
mocks.fsRead.mockReset();
|
||||
vi.stubGlobal('fs', {
|
||||
read: mocks.fsRead,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('does not fail Forkop self-update when status polling disappears after package replacement', async () => {
|
||||
mocks.fsRead.mockRejectedValue(new Error('Access denied'));
|
||||
mocks.executeShellCommand.mockImplementation(({ args }) => {
|
||||
if (args[0] === 'component_action_status') {
|
||||
return Promise.resolve({
|
||||
stdout: '',
|
||||
stderr: 'Unknown command',
|
||||
code: 1,
|
||||
});
|
||||
}
|
||||
|
||||
if (args[0] === 'show_version') {
|
||||
return Promise.resolve({
|
||||
stdout: '0.7.17.11\n',
|
||||
stderr: '',
|
||||
code: 0,
|
||||
});
|
||||
}
|
||||
|
||||
return Promise.resolve({
|
||||
stdout: '',
|
||||
stderr: 'Unexpected command',
|
||||
code: 1,
|
||||
});
|
||||
});
|
||||
|
||||
const responsePromise = ForkopShellMethods.waitComponentActionJob(
|
||||
'job-1',
|
||||
'forkop',
|
||||
'install',
|
||||
'0.7.17.11',
|
||||
);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(33000);
|
||||
|
||||
await expect(responsePromise).resolves.toEqual({
|
||||
success: true,
|
||||
data: {
|
||||
success: true,
|
||||
component: 'forkop',
|
||||
action: 'install',
|
||||
message: 'Forkop has been installed',
|
||||
current_version: '0.7.17.11',
|
||||
latest_version: '0.7.17.11',
|
||||
changed: true,
|
||||
status: 'latest',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('returns the backend component action start error message', async () => {
|
||||
mocks.executeShellCommand.mockResolvedValue({
|
||||
stdout: JSON.stringify({
|
||||
success: false,
|
||||
message: 'Another component action is already running',
|
||||
}),
|
||||
stderr: '',
|
||||
code: 1,
|
||||
});
|
||||
|
||||
await expect(
|
||||
ForkopShellMethods.componentActionStart('zapret', 'install'),
|
||||
).resolves.toEqual({
|
||||
success: false,
|
||||
error: 'Another component action is already running',
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps following a component job after the former browser-side wait timeout while the backend reports it running', async () => {
|
||||
let stateReads = 0;
|
||||
|
||||
mocks.fsRead.mockImplementation(() =>
|
||||
Promise.resolve(
|
||||
JSON.stringify(
|
||||
stateReads++ < 401
|
||||
? {
|
||||
success: true,
|
||||
running: true,
|
||||
component: 'sing_box',
|
||||
action: 'check_update',
|
||||
message: 'Component action is running',
|
||||
job_id: 'job-1',
|
||||
}
|
||||
: {
|
||||
success: true,
|
||||
running: false,
|
||||
component: 'sing_box',
|
||||
action: 'check_update',
|
||||
message: 'sing-box is up to date',
|
||||
current_version: '1.13.12-extended-2.3.2',
|
||||
latest_version: '1.13.12-extended-2.3.2',
|
||||
changed: false,
|
||||
status: 'latest',
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
mocks.executeShellCommand.mockImplementation(({ args }) => {
|
||||
if (args[0] === 'component_action_status') {
|
||||
return Promise.resolve({
|
||||
stdout: JSON.stringify({
|
||||
success: true,
|
||||
running: true,
|
||||
component: 'sing_box',
|
||||
action: 'check_update',
|
||||
message: 'Component action is running',
|
||||
job_id: 'job-1',
|
||||
}),
|
||||
stderr: '',
|
||||
code: 0,
|
||||
});
|
||||
}
|
||||
|
||||
return Promise.resolve({
|
||||
stdout: '',
|
||||
stderr: 'Unexpected command',
|
||||
code: 1,
|
||||
});
|
||||
});
|
||||
|
||||
const responsePromise = ForkopShellMethods.waitComponentActionJob(
|
||||
'job-1',
|
||||
'sing_box',
|
||||
'check_update',
|
||||
);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(10 * 60 * 1000 + 3000);
|
||||
|
||||
await expect(responsePromise).resolves.toEqual({
|
||||
success: true,
|
||||
data: {
|
||||
success: true,
|
||||
running: false,
|
||||
component: 'sing_box',
|
||||
action: 'check_update',
|
||||
message: 'sing-box is up to date',
|
||||
current_version: '1.13.12-extended-2.3.2',
|
||||
latest_version: '1.13.12-extended-2.3.2',
|
||||
changed: false,
|
||||
status: 'latest',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps waiting when status polling briefly fails but UI state still reports the job running', async () => {
|
||||
mocks.fsRead
|
||||
.mockRejectedValueOnce(new Error('State is temporarily unavailable'))
|
||||
.mockResolvedValueOnce(
|
||||
JSON.stringify({
|
||||
success: true,
|
||||
running: false,
|
||||
component: 'sing_box',
|
||||
action: 'install_extended',
|
||||
message: 'sing-box-extended has been installed',
|
||||
current_version: '1.13.12-extended-2.3.2',
|
||||
latest_version: '1.13.12-extended-2.3.2',
|
||||
changed: true,
|
||||
status: 'latest',
|
||||
}),
|
||||
);
|
||||
|
||||
mocks.executeShellCommand.mockImplementation(({ args }) => {
|
||||
if (args[0] === 'component_action_status') {
|
||||
return Promise.resolve({
|
||||
stdout: '',
|
||||
stderr: 'Component action job was not found',
|
||||
code: 1,
|
||||
});
|
||||
}
|
||||
|
||||
if (args[0] === 'get_ui_state') {
|
||||
return Promise.resolve({
|
||||
stdout: JSON.stringify({
|
||||
service: {
|
||||
forkop: {
|
||||
running: 0,
|
||||
enabled: 1,
|
||||
status: 'stopped but enabled',
|
||||
dns_configured: 0,
|
||||
},
|
||||
sing_box: {
|
||||
running: 0,
|
||||
enabled: 1,
|
||||
status: 'stopped but enabled',
|
||||
},
|
||||
},
|
||||
capabilities: {
|
||||
sing_box_extended: 0,
|
||||
sing_box_tiny: 1,
|
||||
sing_box_compressed: 0,
|
||||
sing_box_tailscale: 0,
|
||||
zapret_installed: 1,
|
||||
zapret2_installed: 1,
|
||||
byedpi_installed: 0,
|
||||
server_inbounds_enabled_count: 0,
|
||||
},
|
||||
actions: {
|
||||
service: [],
|
||||
latency: [],
|
||||
component: [
|
||||
{
|
||||
success: true,
|
||||
running: true,
|
||||
component: 'sing_box',
|
||||
action: 'install_extended',
|
||||
message: 'Component action is running',
|
||||
job_id: 'job-1',
|
||||
},
|
||||
],
|
||||
subscription: [],
|
||||
},
|
||||
}),
|
||||
stderr: '',
|
||||
code: 0,
|
||||
});
|
||||
}
|
||||
|
||||
return Promise.resolve({
|
||||
stdout: '',
|
||||
stderr: 'Unexpected command',
|
||||
code: 1,
|
||||
});
|
||||
});
|
||||
|
||||
const responsePromise = ForkopShellMethods.waitComponentActionJob(
|
||||
'job-1',
|
||||
'sing_box',
|
||||
'install_extended',
|
||||
);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(3000);
|
||||
|
||||
await expect(responsePromise).resolves.toEqual({
|
||||
success: true,
|
||||
data: {
|
||||
success: true,
|
||||
running: false,
|
||||
component: 'sing_box',
|
||||
action: 'install_extended',
|
||||
message: 'sing-box-extended has been installed',
|
||||
current_version: '1.13.12-extended-2.3.2',
|
||||
latest_version: '1.13.12-extended-2.3.2',
|
||||
changed: true,
|
||||
status: 'latest',
|
||||
},
|
||||
});
|
||||
expect(mocks.executeShellCommand).toHaveBeenCalledWith({
|
||||
command: '/usr/bin/forkop',
|
||||
args: ['get_ui_state'],
|
||||
timeout: 3000,
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps waiting through a transient RPC reply loss until the action state settles', async () => {
|
||||
mocks.fsRead
|
||||
.mockRejectedValueOnce(new Error('State is temporarily unavailable'))
|
||||
.mockResolvedValueOnce(
|
||||
JSON.stringify({
|
||||
success: true,
|
||||
running: false,
|
||||
component: 'zapret',
|
||||
action: 'check_update',
|
||||
message: 'zapret is up to date',
|
||||
current_version: '70.2',
|
||||
latest_version: '70.2',
|
||||
changed: false,
|
||||
status: 'latest',
|
||||
}),
|
||||
);
|
||||
|
||||
mocks.executeShellCommand.mockImplementation(({ args }) => {
|
||||
if (args[0] === 'component_action_status') {
|
||||
return Promise.resolve({
|
||||
stdout: '',
|
||||
stderr: 'No related RPC reply',
|
||||
code: 1,
|
||||
});
|
||||
}
|
||||
|
||||
if (args[0] === 'get_ui_state') {
|
||||
return Promise.resolve({
|
||||
stdout: '',
|
||||
stderr: 'No related RPC reply',
|
||||
code: 1,
|
||||
});
|
||||
}
|
||||
|
||||
return Promise.resolve({
|
||||
stdout: '',
|
||||
stderr: 'Unexpected command',
|
||||
code: 1,
|
||||
});
|
||||
});
|
||||
|
||||
const responsePromise = ForkopShellMethods.waitComponentActionJob(
|
||||
'job-1',
|
||||
'zapret',
|
||||
'check_update',
|
||||
);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(3000);
|
||||
|
||||
await expect(responsePromise).resolves.toEqual({
|
||||
success: true,
|
||||
data: {
|
||||
success: true,
|
||||
running: false,
|
||||
component: 'zapret',
|
||||
action: 'check_update',
|
||||
message: 'zapret is up to date',
|
||||
current_version: '70.2',
|
||||
latest_version: '70.2',
|
||||
changed: false,
|
||||
status: 'latest',
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,92 +0,0 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
executeShellCommand: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../../../helpers', () => ({
|
||||
executeShellCommand: mocks.executeShellCommand,
|
||||
}));
|
||||
|
||||
import { ForkopShellMethods } from '../index';
|
||||
|
||||
describe('ForkopShellMethods.latencyAction', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
mocks.executeShellCommand.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('keeps waiting through a transient RPC reply loss', async () => {
|
||||
mocks.executeShellCommand.mockImplementation(({ args }) => {
|
||||
if (args[0] === 'latency_test_status') {
|
||||
if (mocks.executeShellCommand.mock.calls.length === 1) {
|
||||
return Promise.resolve({
|
||||
stdout: '',
|
||||
stderr: 'No related RPC reply',
|
||||
code: 1,
|
||||
});
|
||||
}
|
||||
|
||||
return Promise.resolve({
|
||||
stdout: JSON.stringify({
|
||||
success: true,
|
||||
running: false,
|
||||
message: 'Latency test completed',
|
||||
section: 'main',
|
||||
tag: 'proxy-1',
|
||||
exit_code: 0,
|
||||
}),
|
||||
stderr: '',
|
||||
code: 0,
|
||||
});
|
||||
}
|
||||
|
||||
return Promise.resolve({
|
||||
stdout: '',
|
||||
stderr: 'Unexpected command',
|
||||
code: 1,
|
||||
});
|
||||
});
|
||||
|
||||
const responsePromise = ForkopShellMethods.waitLatencyTestJob('job-1');
|
||||
|
||||
await vi.advanceTimersByTimeAsync(2000);
|
||||
|
||||
await expect(responsePromise).resolves.toEqual({
|
||||
success: true,
|
||||
data: {
|
||||
success: true,
|
||||
running: false,
|
||||
message: 'Latency test completed',
|
||||
section: 'main',
|
||||
tag: 'proxy-1',
|
||||
exit_code: 0,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('passes an optional timeout to latency jobs', async () => {
|
||||
mocks.executeShellCommand.mockResolvedValue({
|
||||
stdout: JSON.stringify({ success: true, job_id: 'latency-1' }),
|
||||
stderr: '',
|
||||
code: 0,
|
||||
});
|
||||
|
||||
await ForkopShellMethods.latencyTestStart(
|
||||
'proxy',
|
||||
'AWG',
|
||||
'AWG-out',
|
||||
'10000',
|
||||
);
|
||||
|
||||
expect(mocks.executeShellCommand).toHaveBeenCalledWith({
|
||||
command: '/usr/bin/forkop',
|
||||
args: ['latency_test_async', 'proxy', 'AWG', 'AWG-out', '10000'],
|
||||
timeout: 15000,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,81 +0,0 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
executeShellCommand: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../../../helpers', () => ({
|
||||
executeShellCommand: mocks.executeShellCommand,
|
||||
}));
|
||||
|
||||
import { ForkopShellMethods } from '../index';
|
||||
|
||||
describe('ForkopShellMethods.serviceAction', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
mocks.executeShellCommand.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('keeps failed finished service state available to low-level waiters', async () => {
|
||||
mocks.executeShellCommand.mockImplementation(({ args }) => {
|
||||
if (args[0] === 'service_action_status') {
|
||||
return Promise.resolve({
|
||||
stdout: JSON.stringify({
|
||||
success: false,
|
||||
running: false,
|
||||
kind: 'service',
|
||||
action: 'restart',
|
||||
message: 'Service restart failed',
|
||||
exit_code: 1,
|
||||
}),
|
||||
stderr: '',
|
||||
code: 0,
|
||||
});
|
||||
}
|
||||
|
||||
return Promise.resolve({
|
||||
stdout: '',
|
||||
stderr: 'Unexpected command',
|
||||
code: 1,
|
||||
});
|
||||
});
|
||||
|
||||
const responsePromise = ForkopShellMethods.waitServiceActionJob('job-1');
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
|
||||
await expect(responsePromise).resolves.toEqual({
|
||||
success: true,
|
||||
data: {
|
||||
success: false,
|
||||
running: false,
|
||||
kind: 'service',
|
||||
action: 'restart',
|
||||
message: 'Service restart failed',
|
||||
exit_code: 1,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('returns the backend service action start error message', async () => {
|
||||
mocks.executeShellCommand.mockResolvedValue({
|
||||
stdout: JSON.stringify({
|
||||
success: false,
|
||||
message: 'Another service action is already running',
|
||||
}),
|
||||
stderr: '',
|
||||
code: 1,
|
||||
});
|
||||
|
||||
await expect(
|
||||
ForkopShellMethods.serviceActionStart('restart'),
|
||||
).resolves.toEqual({
|
||||
success: false,
|
||||
error: 'Another service action is already running',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,258 +0,0 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
executeShellCommand: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../../../helpers', () => ({
|
||||
executeShellCommand: mocks.executeShellCommand,
|
||||
}));
|
||||
|
||||
import { ForkopShellMethods } from '../index';
|
||||
|
||||
describe('ForkopShellMethods.subscriptionUpdate', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
mocks.executeShellCommand.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('starts a targeted background subscription update and waits for its state', async () => {
|
||||
mocks.executeShellCommand.mockImplementation(({ args }) => {
|
||||
if (args[0] === 'subscription_update_async') {
|
||||
return Promise.resolve({
|
||||
stdout: JSON.stringify({
|
||||
success: true,
|
||||
job_id: 'job-1',
|
||||
message: 'Subscription update started',
|
||||
}),
|
||||
stderr: '',
|
||||
code: 0,
|
||||
});
|
||||
}
|
||||
|
||||
if (args[0] === 'subscription_update_status') {
|
||||
return Promise.resolve({
|
||||
stdout: JSON.stringify({
|
||||
success: true,
|
||||
running: false,
|
||||
message: 'Subscription update completed',
|
||||
exit_code: 0,
|
||||
}),
|
||||
stderr: '',
|
||||
code: 0,
|
||||
});
|
||||
}
|
||||
|
||||
return Promise.resolve({
|
||||
stdout: '',
|
||||
stderr: 'Unexpected command',
|
||||
code: 1,
|
||||
});
|
||||
});
|
||||
|
||||
await expect(
|
||||
ForkopShellMethods.subscriptionUpdateStart('main'),
|
||||
).resolves.toEqual({
|
||||
success: true,
|
||||
data: {
|
||||
success: true,
|
||||
job_id: 'job-1',
|
||||
message: 'Subscription update started',
|
||||
},
|
||||
});
|
||||
|
||||
const responsePromise =
|
||||
ForkopShellMethods.waitSubscriptionUpdateJob('job-1');
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1500);
|
||||
|
||||
await expect(responsePromise).resolves.toEqual({
|
||||
success: true,
|
||||
data: {
|
||||
success: true,
|
||||
running: false,
|
||||
message: 'Subscription update completed',
|
||||
exit_code: 0,
|
||||
},
|
||||
});
|
||||
expect(mocks.executeShellCommand).toHaveBeenNthCalledWith(1, {
|
||||
command: '/usr/bin/forkop',
|
||||
args: ['subscription_update_async', 'main'],
|
||||
timeout: 15000,
|
||||
});
|
||||
expect(mocks.executeShellCommand).toHaveBeenNthCalledWith(2, {
|
||||
command: '/usr/bin/forkop',
|
||||
args: ['subscription_update_status', 'job-1'],
|
||||
timeout: 15000,
|
||||
});
|
||||
});
|
||||
|
||||
it('returns the backend subscription start error message', async () => {
|
||||
mocks.executeShellCommand.mockResolvedValue({
|
||||
stdout: JSON.stringify({
|
||||
success: false,
|
||||
message: 'Subscription update is already running',
|
||||
}),
|
||||
stderr: '',
|
||||
code: 1,
|
||||
});
|
||||
|
||||
await expect(
|
||||
ForkopShellMethods.subscriptionUpdateStart('main'),
|
||||
).resolves.toEqual({
|
||||
success: false,
|
||||
error: 'Subscription update is already running',
|
||||
});
|
||||
});
|
||||
|
||||
it('returns failed finished job state from the low-level waiter for UI restoration', async () => {
|
||||
mocks.executeShellCommand.mockImplementation(({ args }) => {
|
||||
if (args[0] === 'subscription_update_status') {
|
||||
return Promise.resolve({
|
||||
stdout: JSON.stringify({
|
||||
success: false,
|
||||
running: false,
|
||||
message: 'Failed to download subscriptions',
|
||||
section: 'main',
|
||||
source_index: '',
|
||||
exit_code: 1,
|
||||
}),
|
||||
stderr: '',
|
||||
code: 0,
|
||||
});
|
||||
}
|
||||
|
||||
return Promise.resolve({
|
||||
stdout: '',
|
||||
stderr: 'Unexpected command',
|
||||
code: 1,
|
||||
});
|
||||
});
|
||||
|
||||
const responsePromise =
|
||||
ForkopShellMethods.waitSubscriptionUpdateJob('job-1');
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1500);
|
||||
|
||||
await expect(responsePromise).resolves.toEqual({
|
||||
success: true,
|
||||
data: {
|
||||
success: false,
|
||||
running: false,
|
||||
message: 'Failed to download subscriptions',
|
||||
section: 'main',
|
||||
source_index: '',
|
||||
exit_code: 1,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps following a subscription job after the former browser-side wait timeout while the backend reports it running', async () => {
|
||||
let statusCalls = 0;
|
||||
|
||||
mocks.executeShellCommand.mockImplementation(({ args }) => {
|
||||
if (args[0] === 'subscription_update_status') {
|
||||
return Promise.resolve({
|
||||
stdout: JSON.stringify(
|
||||
statusCalls++ < 401
|
||||
? {
|
||||
success: true,
|
||||
running: true,
|
||||
message: 'Subscription update is running',
|
||||
section: 'main',
|
||||
source_index: '',
|
||||
exit_code: null,
|
||||
}
|
||||
: {
|
||||
success: true,
|
||||
running: false,
|
||||
message: 'Subscription update completed',
|
||||
section: 'main',
|
||||
source_index: '',
|
||||
exit_code: 0,
|
||||
},
|
||||
),
|
||||
stderr: '',
|
||||
code: 0,
|
||||
});
|
||||
}
|
||||
|
||||
return Promise.resolve({
|
||||
stdout: '',
|
||||
stderr: 'Unexpected command',
|
||||
code: 1,
|
||||
});
|
||||
});
|
||||
|
||||
const responsePromise =
|
||||
ForkopShellMethods.waitSubscriptionUpdateJob('job-1');
|
||||
|
||||
await vi.advanceTimersByTimeAsync(10 * 60 * 1000 + 3000);
|
||||
|
||||
await expect(responsePromise).resolves.toEqual({
|
||||
success: true,
|
||||
data: {
|
||||
success: true,
|
||||
running: false,
|
||||
message: 'Subscription update completed',
|
||||
section: 'main',
|
||||
source_index: '',
|
||||
exit_code: 0,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps waiting through a transient RPC reply loss', async () => {
|
||||
mocks.executeShellCommand.mockImplementation(({ args }) => {
|
||||
if (args[0] === 'subscription_update_status') {
|
||||
if (mocks.executeShellCommand.mock.calls.length === 1) {
|
||||
return Promise.resolve({
|
||||
stdout: '',
|
||||
stderr: 'No related RPC reply',
|
||||
code: 1,
|
||||
});
|
||||
}
|
||||
|
||||
return Promise.resolve({
|
||||
stdout: JSON.stringify({
|
||||
success: true,
|
||||
running: false,
|
||||
message: 'Subscription update completed',
|
||||
section: 'main',
|
||||
source_index: '',
|
||||
exit_code: 0,
|
||||
}),
|
||||
stderr: '',
|
||||
code: 0,
|
||||
});
|
||||
}
|
||||
|
||||
return Promise.resolve({
|
||||
stdout: '',
|
||||
stderr: 'Unexpected command',
|
||||
code: 1,
|
||||
});
|
||||
});
|
||||
|
||||
const responsePromise =
|
||||
ForkopShellMethods.waitSubscriptionUpdateJob('job-1');
|
||||
|
||||
await vi.advanceTimersByTimeAsync(3000);
|
||||
|
||||
await expect(responsePromise).resolves.toEqual({
|
||||
success: true,
|
||||
data: {
|
||||
success: true,
|
||||
running: false,
|
||||
message: 'Subscription update completed',
|
||||
section: 'main',
|
||||
source_index: '',
|
||||
exit_code: 0,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,30 +0,0 @@
|
||||
const RESERVED_RUNTIME_TAGS = new Set([
|
||||
'dns-server',
|
||||
'fakeip-server',
|
||||
'bootstrap-dns-server',
|
||||
'fakeip-dns-rule-tag',
|
||||
'fakeip-ruleset-dns-rule-tag',
|
||||
'service-fakeip-dns-rule-tag',
|
||||
'tproxy-in',
|
||||
'tproxy6-in',
|
||||
'dns-in',
|
||||
'service-mixed-in',
|
||||
'direct-out',
|
||||
'bypass-out',
|
||||
]);
|
||||
|
||||
export function allocateRuntimeTag(base: string, postfix: string): string {
|
||||
let suffix = 1;
|
||||
let candidate = `${base}-${postfix}`;
|
||||
|
||||
while (RESERVED_RUNTIME_TAGS.has(candidate)) {
|
||||
candidate = `${base}-${postfix}-${suffix}`;
|
||||
suffix += 1;
|
||||
}
|
||||
|
||||
return candidate;
|
||||
}
|
||||
|
||||
export function getOutboundTagBySection(sectionName: string): string {
|
||||
return allocateRuntimeTag(sectionName, 'out');
|
||||
}
|
||||
@@ -1,118 +0,0 @@
|
||||
import { TabServiceInstance } from './tab.service';
|
||||
import { store } from './store.service';
|
||||
import { logger } from './logger.service';
|
||||
import { ForkopLogWatcher } from './forkopLogWatcher.service';
|
||||
import {
|
||||
getForkopLogNotification,
|
||||
LogNotificationDeduper,
|
||||
ForkopLogNotification,
|
||||
} from './logNotificationDeduper.service';
|
||||
import { ForkopShellMethods } from '../methods';
|
||||
import {
|
||||
registerRuntimeStateResumeRefresh,
|
||||
startRuntimeUiStatePolling,
|
||||
} from './runtimeUiState.service';
|
||||
|
||||
type CoreServiceOptions = {
|
||||
waitForLogWatcherStart?: () => Promise<unknown>;
|
||||
logWatcherStartDelayMs?: number;
|
||||
};
|
||||
|
||||
const LOG_WATCHER_INTERVAL_MS = 10000;
|
||||
const LOG_WATCHER_START_DELAY_MS = 5000;
|
||||
|
||||
function componentDisplayName(component: string) {
|
||||
const names: Record<string, string> = {
|
||||
forkop: 'Forkop',
|
||||
sing_box: 'sing-box',
|
||||
zapret: 'Zapret',
|
||||
zapret2: 'Zapret2',
|
||||
byedpi: 'ByeDPI',
|
||||
};
|
||||
|
||||
return names[component] || component;
|
||||
}
|
||||
|
||||
function showLogNotification(notification: ForkopLogNotification) {
|
||||
if (notification.kind === 'component-update') {
|
||||
const message = _('New version %s is available for %s')
|
||||
.replace('%s', notification.version)
|
||||
.replace('%s', componentDisplayName(notification.component));
|
||||
|
||||
ui.addNotification(
|
||||
_('Component update available'),
|
||||
E('div', {}, message),
|
||||
'warning',
|
||||
'fkp-component-update-notification',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
ui.addNotification(
|
||||
_('Forkop Error'),
|
||||
E('div', {}, notification.line),
|
||||
'error',
|
||||
'fkp-log-error-notification',
|
||||
);
|
||||
}
|
||||
|
||||
export function coreService(options: CoreServiceOptions = {}) {
|
||||
TabServiceInstance.onChange((activeId, tabs) => {
|
||||
logger.info('[TAB]', activeId);
|
||||
store.set({
|
||||
tabService: {
|
||||
current: activeId || '',
|
||||
all: tabs.map((tab) => tab.id),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
const watcher = ForkopLogWatcher.getInstance();
|
||||
const logNotificationDeduper = new LogNotificationDeduper();
|
||||
|
||||
watcher.init(
|
||||
async () => {
|
||||
const logs = await ForkopShellMethods.checkLogs();
|
||||
|
||||
if (logs.success) {
|
||||
return logs.data as string;
|
||||
}
|
||||
|
||||
return '';
|
||||
},
|
||||
{
|
||||
intervalMs: LOG_WATCHER_INTERVAL_MS,
|
||||
onNewLog: (line) => {
|
||||
if (logNotificationDeduper.shouldNotify(line)) {
|
||||
const notification = getForkopLogNotification(line);
|
||||
if (notification) {
|
||||
showLogNotification(notification);
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const startWatcher = async () => {
|
||||
if (options.waitForLogWatcherStart) {
|
||||
await Promise.resolve()
|
||||
.then(() => options.waitForLogWatcherStart?.())
|
||||
.catch(() => null);
|
||||
}
|
||||
|
||||
watcher.start();
|
||||
};
|
||||
const scheduleStartWatcher = () =>
|
||||
window.setTimeout(() => {
|
||||
void startWatcher();
|
||||
}, options.logWatcherStartDelayMs ?? LOG_WATCHER_START_DELAY_MS);
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
scheduleStartWatcher();
|
||||
} else {
|
||||
void startWatcher();
|
||||
}
|
||||
|
||||
registerRuntimeStateResumeRefresh();
|
||||
startRuntimeUiStatePolling();
|
||||
}
|
||||
@@ -1,142 +0,0 @@
|
||||
import { logger } from './logger.service';
|
||||
|
||||
type LogFetcher = () => Promise<string> | string;
|
||||
|
||||
interface ForkopLogWatcherOptions {
|
||||
intervalMs?: number;
|
||||
onNewLog?: (line: string) => void;
|
||||
maxTrackedLines?: number;
|
||||
}
|
||||
|
||||
export class ForkopLogWatcher {
|
||||
private static instance: ForkopLogWatcher;
|
||||
private fetcher?: LogFetcher;
|
||||
private onNewLog?: (line: string) => void;
|
||||
private intervalMs = 5000;
|
||||
private lastLines = new Set<string>();
|
||||
private maxTrackedLines = 500;
|
||||
private timer?: ReturnType<typeof setInterval>;
|
||||
private running = false;
|
||||
private paused = false;
|
||||
private checking = false;
|
||||
|
||||
private constructor() {
|
||||
if (typeof document !== 'undefined') {
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
if (document.hidden) this.pause();
|
||||
else this.resume();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
static getInstance(): ForkopLogWatcher {
|
||||
if (!ForkopLogWatcher.instance) {
|
||||
ForkopLogWatcher.instance = new ForkopLogWatcher();
|
||||
}
|
||||
return ForkopLogWatcher.instance;
|
||||
}
|
||||
|
||||
init(fetcher: LogFetcher, options?: ForkopLogWatcherOptions): void {
|
||||
this.fetcher = fetcher;
|
||||
this.onNewLog = options?.onNewLog;
|
||||
this.intervalMs = options?.intervalMs ?? 5000;
|
||||
this.maxTrackedLines = options?.maxTrackedLines ?? 500;
|
||||
this.lastLines = new Set();
|
||||
logger.info(
|
||||
'[ForkopLogWatcher]',
|
||||
`initialized (interval: ${this.intervalMs}ms)`,
|
||||
);
|
||||
}
|
||||
|
||||
private normalizeLines(raw: string): string[] {
|
||||
return raw.split('\n').filter(Boolean).slice(-this.maxTrackedLines);
|
||||
}
|
||||
|
||||
async checkOnce(): Promise<void> {
|
||||
if (!this.fetcher) {
|
||||
logger.warn('[ForkopLogWatcher]', 'fetcher not found');
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.paused) {
|
||||
logger.debug('[ForkopLogWatcher]', 'skipped check — tab not visible');
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.checking) {
|
||||
logger.debug(
|
||||
'[ForkopLogWatcher]',
|
||||
'skipped check — previous check is running',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
this.checking = true;
|
||||
|
||||
try {
|
||||
const raw = await this.fetcher();
|
||||
const lines = this.normalizeLines(raw);
|
||||
|
||||
for (const line of lines) {
|
||||
if (this.lastLines.has(line)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
this.lastLines.add(line);
|
||||
this.onNewLog?.(line);
|
||||
}
|
||||
|
||||
if (this.lastLines.size > this.maxTrackedLines) {
|
||||
this.lastLines = new Set(
|
||||
Array.from(this.lastLines).slice(-this.maxTrackedLines),
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error('[ForkopLogWatcher]', 'failed to read logs:', err);
|
||||
} finally {
|
||||
this.checking = false;
|
||||
}
|
||||
}
|
||||
|
||||
start(): void {
|
||||
if (this.running) return;
|
||||
if (!this.fetcher) {
|
||||
logger.warn('[ForkopLogWatcher]', 'attempted to start without fetcher');
|
||||
return;
|
||||
}
|
||||
|
||||
this.running = true;
|
||||
void this.checkOnce();
|
||||
this.timer = setInterval(() => this.checkOnce(), this.intervalMs);
|
||||
logger.info(
|
||||
'[ForkopLogWatcher]',
|
||||
`started (interval: ${this.intervalMs}ms)`,
|
||||
);
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
if (!this.running) return;
|
||||
this.running = false;
|
||||
if (this.timer) clearInterval(this.timer);
|
||||
logger.info('[ForkopLogWatcher]', 'stopped');
|
||||
}
|
||||
|
||||
pause(): void {
|
||||
if (!this.running || this.paused) return;
|
||||
this.paused = true;
|
||||
logger.info('[ForkopLogWatcher]', 'paused (tab not visible)');
|
||||
}
|
||||
|
||||
resume(): void {
|
||||
if (!this.running || !this.paused) return;
|
||||
this.paused = false;
|
||||
logger.info('[ForkopLogWatcher]', 'resumed (tab active)');
|
||||
void this.checkOnce();
|
||||
}
|
||||
|
||||
reset(): void {
|
||||
this.lastLines = new Set();
|
||||
this.checking = false;
|
||||
logger.info('[ForkopLogWatcher]', 'log history reset');
|
||||
}
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
export * from './tab.service';
|
||||
export * from './core.service';
|
||||
export * from './socket.service';
|
||||
export * from './store.service';
|
||||
export * from './logger.service';
|
||||
export * from './runtimeUiState.service';
|
||||
export * from './uiActionNotification.service';
|
||||
export * from './localActionOverlay.service';
|
||||
@@ -1,61 +0,0 @@
|
||||
import type { StoreType } from './store.service';
|
||||
|
||||
type UpdatesActionKey = keyof StoreType['updatesActions'];
|
||||
type ServiceActionKey = 'restart' | 'start' | 'stop';
|
||||
|
||||
const componentActions = new Set<UpdatesActionKey>();
|
||||
const subscriptionSections = new Set<string>();
|
||||
const latencySections = new Set<string>();
|
||||
const serviceActions = new Set<ServiceActionKey>();
|
||||
|
||||
function setMembership<T>(set: Set<T>, value: T, active: boolean) {
|
||||
if (active) {
|
||||
set.add(value);
|
||||
} else {
|
||||
set.delete(value);
|
||||
}
|
||||
}
|
||||
|
||||
export function setLocalComponentAction(
|
||||
action: UpdatesActionKey,
|
||||
active: boolean,
|
||||
) {
|
||||
setMembership(componentActions, action, active);
|
||||
}
|
||||
|
||||
export function setLocalSubscriptionAction(section: string, active: boolean) {
|
||||
if (section) {
|
||||
setMembership(subscriptionSections, section, active);
|
||||
}
|
||||
}
|
||||
|
||||
export function setLocalLatencyAction(section: string, active: boolean) {
|
||||
if (section) {
|
||||
setMembership(latencySections, section, active);
|
||||
}
|
||||
}
|
||||
|
||||
export function setLocalServiceAction(
|
||||
action: keyof StoreType['diagnosticsActions'],
|
||||
active: boolean,
|
||||
) {
|
||||
if (action === 'restart' || action === 'start' || action === 'stop') {
|
||||
setMembership(serviceActions, action, active);
|
||||
}
|
||||
}
|
||||
|
||||
export function getLocalActionOverlay() {
|
||||
return {
|
||||
componentActions: new Set(componentActions),
|
||||
subscriptionSections: new Set(subscriptionSections),
|
||||
latencySections: new Set(latencySections),
|
||||
serviceActions: new Set(serviceActions),
|
||||
};
|
||||
}
|
||||
|
||||
export function clearLocalActionOverlay() {
|
||||
componentActions.clear();
|
||||
subscriptionSections.clear();
|
||||
latencySections.clear();
|
||||
serviceActions.clear();
|
||||
}
|
||||
@@ -1,114 +0,0 @@
|
||||
const LOG_NOTIFICATION_STORAGE_KEY = 'forkop:shown-log-error-notifications:v1';
|
||||
const MAX_STORED_LOG_NOTIFICATIONS = 500;
|
||||
|
||||
export type ForkopLogNotification =
|
||||
| { kind: 'error'; line: string }
|
||||
| {
|
||||
kind: 'component-update';
|
||||
line: string;
|
||||
component: string;
|
||||
version: string;
|
||||
};
|
||||
|
||||
function getSessionStorage(): Storage | null {
|
||||
if (typeof window === 'undefined') {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return window.sessionStorage;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function readStoredKeys(storage: Storage | null): string[] {
|
||||
if (!storage) {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(
|
||||
storage.getItem(LOG_NOTIFICATION_STORAGE_KEY) || '[]',
|
||||
);
|
||||
|
||||
return Array.isArray(parsed)
|
||||
? parsed.filter((item): item is string => typeof item === 'string')
|
||||
: [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function writeStoredKeys(storage: Storage | null, keys: string[]) {
|
||||
if (!storage) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
storage.setItem(
|
||||
LOG_NOTIFICATION_STORAGE_KEY,
|
||||
JSON.stringify(keys.slice(-MAX_STORED_LOG_NOTIFICATIONS)),
|
||||
);
|
||||
} catch {
|
||||
// Notifications are still deduped in-memory when sessionStorage is blocked.
|
||||
}
|
||||
}
|
||||
|
||||
export function isErrorLogLine(line: string) {
|
||||
const lower = line.toLowerCase();
|
||||
return lower.includes('[error]') || lower.includes('[fatal]');
|
||||
}
|
||||
|
||||
export function getForkopLogNotification(
|
||||
line: string,
|
||||
): ForkopLogNotification | null {
|
||||
if (isErrorLogLine(line)) {
|
||||
return { kind: 'error', line };
|
||||
}
|
||||
|
||||
const update = line.match(
|
||||
/\[component-update\]\s+(forkop|sing_box|zapret|zapret2|byedpi)\s+(\S+)/i,
|
||||
);
|
||||
|
||||
if (!update) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
kind: 'component-update',
|
||||
line,
|
||||
component: update[1].toLowerCase(),
|
||||
version: update[2],
|
||||
};
|
||||
}
|
||||
|
||||
export function getLogNotificationKey(line: string) {
|
||||
return line.trim();
|
||||
}
|
||||
|
||||
export class LogNotificationDeduper {
|
||||
private readonly storage: Storage | null;
|
||||
private readonly seenKeys: Set<string>;
|
||||
|
||||
constructor(storage: Storage | null = getSessionStorage()) {
|
||||
this.storage = storage;
|
||||
this.seenKeys = new Set(readStoredKeys(storage));
|
||||
}
|
||||
|
||||
shouldNotify(line: string) {
|
||||
if (!getForkopLogNotification(line)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const key = getLogNotificationKey(line);
|
||||
|
||||
if (!key || this.seenKeys.has(key)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
this.seenKeys.add(key);
|
||||
writeStoredKeys(this.storage, Array.from(this.seenKeys));
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
import { downloadAsTxt } from '../../helpers/downloadAsTxt';
|
||||
|
||||
type LogLevel = 'debug' | 'info' | 'warn' | 'error';
|
||||
|
||||
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();
|
||||
@@ -1,166 +0,0 @@
|
||||
import { ForkopShellMethods } from '../methods';
|
||||
import { Forkop } from '../types';
|
||||
import { logger } from './logger.service';
|
||||
import { applyUiStateToStore } from './uiState.service';
|
||||
|
||||
const RUNTIME_UI_STATE_REFRESH_MIN_INTERVAL_MS = 500;
|
||||
const RUNTIME_UI_STATE_IDLE_POLL_INTERVAL_MS = 1000;
|
||||
const RUNTIME_UI_STATE_ACTIVE_POLL_INTERVAL_MS = 500;
|
||||
type RuntimeUiStateListener = (uiState: Forkop.UiState) => void;
|
||||
|
||||
let runtimeUiStateRefreshPromise: Promise<Forkop.UiState | undefined> | null =
|
||||
null;
|
||||
let lastRuntimeUiStateRefreshAt = 0;
|
||||
let lastRuntimeUiState: Forkop.UiState | undefined;
|
||||
let runtimeStateResumeRefreshRegistered = false;
|
||||
let runtimeStatePollTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let runtimeStatePollingStarted = false;
|
||||
let runtimeStateHasRunningAction = false;
|
||||
const runtimeUiStateListeners = new Set<RuntimeUiStateListener>();
|
||||
|
||||
function isDocumentVisible() {
|
||||
return (
|
||||
typeof document === 'undefined' ||
|
||||
!document.visibilityState ||
|
||||
document.visibilityState === 'visible'
|
||||
);
|
||||
}
|
||||
|
||||
function hasRunningAction(uiState: Forkop.UiState) {
|
||||
return Object.values(uiState.actions).some((actions) =>
|
||||
actions.some((action) => action.running),
|
||||
);
|
||||
}
|
||||
|
||||
function getNextPollDelay() {
|
||||
return runtimeStateHasRunningAction
|
||||
? RUNTIME_UI_STATE_ACTIVE_POLL_INTERVAL_MS
|
||||
: RUNTIME_UI_STATE_IDLE_POLL_INTERVAL_MS;
|
||||
}
|
||||
|
||||
function scheduleRuntimeUiStatePoll(delay = getNextPollDelay()) {
|
||||
if (
|
||||
!runtimeStatePollingStarted ||
|
||||
runtimeStatePollTimer ||
|
||||
typeof window === 'undefined'
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
runtimeStatePollTimer = window.setTimeout(() => {
|
||||
runtimeStatePollTimer = null;
|
||||
void refreshRuntimeUiState()
|
||||
.catch(() => undefined)
|
||||
.finally(() => {
|
||||
scheduleRuntimeUiStatePoll();
|
||||
});
|
||||
}, delay);
|
||||
}
|
||||
|
||||
function notifyRuntimeUiStateListeners(uiState: Forkop.UiState) {
|
||||
for (const listener of runtimeUiStateListeners) {
|
||||
try {
|
||||
listener(uiState);
|
||||
} catch (error) {
|
||||
logger.error('[RUNTIME_UI_STATE]', 'listener failed', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function refreshRuntimeUiState({
|
||||
force = false,
|
||||
}: { force?: boolean } = {}): Promise<Forkop.UiState | undefined> {
|
||||
if (!isDocumentVisible()) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (runtimeUiStateRefreshPromise) {
|
||||
return runtimeUiStateRefreshPromise;
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
if (
|
||||
!force &&
|
||||
now - lastRuntimeUiStateRefreshAt < RUNTIME_UI_STATE_REFRESH_MIN_INTERVAL_MS
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
lastRuntimeUiStateRefreshAt = now;
|
||||
|
||||
const promise = ForkopShellMethods.getUiState()
|
||||
.then((response) => {
|
||||
if (!response.success) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
applyUiStateToStore(response.data);
|
||||
lastRuntimeUiState = response.data;
|
||||
runtimeStateHasRunningAction = hasRunningAction(response.data);
|
||||
notifyRuntimeUiStateListeners(response.data);
|
||||
return response.data;
|
||||
})
|
||||
.catch((error) => {
|
||||
logger.error('[RUNTIME_UI_STATE]', 'refresh failed', error);
|
||||
return undefined;
|
||||
})
|
||||
.finally(() => {
|
||||
if (runtimeUiStateRefreshPromise === promise) {
|
||||
runtimeUiStateRefreshPromise = null;
|
||||
}
|
||||
});
|
||||
|
||||
runtimeUiStateRefreshPromise = promise;
|
||||
return runtimeUiStateRefreshPromise;
|
||||
}
|
||||
|
||||
export function subscribeRuntimeUiState(listener: RuntimeUiStateListener) {
|
||||
runtimeUiStateListeners.add(listener);
|
||||
|
||||
if (lastRuntimeUiState) {
|
||||
try {
|
||||
listener(lastRuntimeUiState);
|
||||
} catch (error) {
|
||||
logger.error('[RUNTIME_UI_STATE]', 'listener failed', error);
|
||||
}
|
||||
}
|
||||
|
||||
return () => {
|
||||
runtimeUiStateListeners.delete(listener);
|
||||
};
|
||||
}
|
||||
|
||||
export function getCachedRuntimeUiState() {
|
||||
return lastRuntimeUiState;
|
||||
}
|
||||
|
||||
export function registerRuntimeStateResumeRefresh() {
|
||||
if (runtimeStateResumeRefreshRegistered || typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
runtimeStateResumeRefreshRegistered = true;
|
||||
|
||||
const refreshOnResume = () => {
|
||||
if (!isDocumentVisible()) {
|
||||
return;
|
||||
}
|
||||
|
||||
void refreshRuntimeUiState({ force: true });
|
||||
};
|
||||
|
||||
document.addEventListener('visibilitychange', refreshOnResume);
|
||||
window.addEventListener('pageshow', refreshOnResume);
|
||||
window.addEventListener('focus', refreshOnResume);
|
||||
}
|
||||
|
||||
export function startRuntimeUiStatePolling() {
|
||||
if (runtimeStatePollingStarted || typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
runtimeStatePollingStarted = true;
|
||||
void refreshRuntimeUiState({ force: true }).finally(() => {
|
||||
scheduleRuntimeUiStatePoll();
|
||||
});
|
||||
}
|
||||
@@ -1,167 +0,0 @@
|
||||
import { logger } from './logger.service';
|
||||
|
||||
// eslint-disable-next-line
|
||||
type Listener = (data: any) => void;
|
||||
type ErrorListener = (error: Event | string) => void;
|
||||
|
||||
class SocketManager {
|
||||
private static instance: SocketManager;
|
||||
private sockets = new Map<string, WebSocket>();
|
||||
private listeners = new Map<string, Set<Listener>>();
|
||||
private connected = new Map<string, boolean>();
|
||||
private errorListeners = new Map<string, Set<ErrorListener>>();
|
||||
|
||||
private constructor() {}
|
||||
|
||||
static getInstance(): SocketManager {
|
||||
if (!SocketManager.instance) {
|
||||
SocketManager.instance = new 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;
|
||||
|
||||
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);
|
||||
if (!this.listeners.has(url)) this.listeners.set(url, new Set());
|
||||
if (!this.errorListeners.has(url)) this.errorListeners.set(url, new Set());
|
||||
|
||||
ws.addEventListener('open', () => {
|
||||
this.connected.set(url, true);
|
||||
logger.info('[SOCKET]', 'Connected to', url);
|
||||
});
|
||||
|
||||
ws.addEventListener('message', (event) => {
|
||||
const handlers = this.listeners.get(url);
|
||||
if (handlers) {
|
||||
for (const handler of handlers) {
|
||||
try {
|
||||
handler(event.data);
|
||||
} catch (err) {
|
||||
logger.error('[SOCKET]', `Handler error for ${url}:`, err);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
ws.addEventListener('close', () => {
|
||||
this.connected.set(url, false);
|
||||
logger.warn('[SOCKET]', `Disconnected: ${url}`);
|
||||
this.triggerError(url, 'Connection closed');
|
||||
});
|
||||
|
||||
ws.addEventListener('error', (err) => {
|
||||
logger.error('[SOCKET]', `Socket error for ${url}:`, err);
|
||||
this.triggerError(url, err);
|
||||
});
|
||||
}
|
||||
|
||||
subscribe(url: 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);
|
||||
}
|
||||
|
||||
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 {
|
||||
this.listeners.get(url)?.delete(listener);
|
||||
if (onError) {
|
||||
this.errorListeners.get(url)?.delete(onError);
|
||||
}
|
||||
}
|
||||
|
||||
// eslint-disable-next-line
|
||||
send(url: string, data: any): void {
|
||||
const ws = this.sockets.get(url);
|
||||
if (ws && this.connected.get(url)) {
|
||||
ws.send(typeof data === 'string' ? data : JSON.stringify(data));
|
||||
} else {
|
||||
logger.warn('[SOCKET]', `Cannot send: not connected to ${url}`);
|
||||
this.triggerError(url, 'Not connected');
|
||||
}
|
||||
}
|
||||
|
||||
disconnect(url: string): void {
|
||||
const ws = this.sockets.get(url);
|
||||
if (ws) {
|
||||
ws.close();
|
||||
this.sockets.delete(url);
|
||||
this.listeners.delete(url);
|
||||
this.errorListeners.delete(url);
|
||||
this.connected.delete(url);
|
||||
}
|
||||
}
|
||||
|
||||
disconnectAll(): void {
|
||||
for (const url of this.sockets.keys()) {
|
||||
this.disconnect(url);
|
||||
}
|
||||
}
|
||||
|
||||
private triggerError(url: string, err: Event | string): void {
|
||||
const handlers = this.errorListeners.get(url);
|
||||
if (handlers) {
|
||||
for (const cb of handlers) {
|
||||
try {
|
||||
cb(err);
|
||||
} catch (e) {
|
||||
logger.error('[SOCKET]', `Error handler threw for ${url}:`, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const socket = SocketManager.getInstance();
|
||||
@@ -1,282 +0,0 @@
|
||||
import { Forkop } from '../types';
|
||||
import { initialDiagnosticStore } from '../tabs/diagnostic/diagnostic.store';
|
||||
|
||||
function jsonStableStringify<T, V>(obj: T): string {
|
||||
return JSON.stringify(obj, (_, value) => {
|
||||
if (value && typeof value === 'object' && !Array.isArray(value)) {
|
||||
return Object.keys(value)
|
||||
.sort()
|
||||
.reduce(
|
||||
(acc, key) => {
|
||||
acc[key] = value[key];
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, V>,
|
||||
);
|
||||
}
|
||||
return value;
|
||||
});
|
||||
}
|
||||
|
||||
function jsonEqual<A, B>(a: A, b: B): boolean {
|
||||
try {
|
||||
return jsonStableStringify(a) === jsonStableStringify(b);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
type Listener<T> = (next: T, prev: T, diff: Partial<T>) => void;
|
||||
|
||||
// eslint-disable-next-line
|
||||
export class StoreService<T extends Record<string, any>> {
|
||||
private value: T;
|
||||
private readonly initial: T;
|
||||
private listeners = new Set<Listener<T>>();
|
||||
|
||||
constructor(initial: T) {
|
||||
this.value = initial;
|
||||
this.initial = structuredClone(initial);
|
||||
}
|
||||
|
||||
get(): T {
|
||||
return this.value;
|
||||
}
|
||||
|
||||
set(next: Partial<T>): void {
|
||||
const prev = this.value;
|
||||
const diff: Partial<T> = {};
|
||||
|
||||
for (const key in next) {
|
||||
if (!jsonEqual(next[key], prev[key])) diff[key] = next[key];
|
||||
}
|
||||
|
||||
if (Object.keys(diff).length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const merged = { ...prev, ...next };
|
||||
this.value = merged;
|
||||
this.listeners.forEach((cb) => cb(this.value, prev, diff));
|
||||
}
|
||||
|
||||
reset<K extends keyof T>(keys?: K[]): void {
|
||||
const prev = this.value;
|
||||
const next = structuredClone(this.value);
|
||||
|
||||
if (keys && keys.length > 0) {
|
||||
keys.forEach((key) => {
|
||||
next[key] = structuredClone(this.initial[key]);
|
||||
});
|
||||
} else {
|
||||
Object.assign(next, structuredClone(this.initial));
|
||||
}
|
||||
|
||||
if (jsonEqual(prev, next)) return;
|
||||
|
||||
this.value = next;
|
||||
|
||||
const diff: Partial<T> = {};
|
||||
for (const key in next) {
|
||||
if (!jsonEqual(next[key], prev[key])) diff[key] = next[key];
|
||||
}
|
||||
|
||||
this.listeners.forEach((cb) => cb(this.value, prev, diff));
|
||||
}
|
||||
|
||||
subscribe(cb: Listener<T>): () => void {
|
||||
this.listeners.add(cb);
|
||||
cb(this.value, this.value, {});
|
||||
return () => this.listeners.delete(cb);
|
||||
}
|
||||
|
||||
unsubscribe(cb: Listener<T>): void {
|
||||
this.listeners.delete(cb);
|
||||
}
|
||||
|
||||
patch<K extends keyof T>(key: K, value: T[K]): void {
|
||||
this.set({ [key]: value } as unknown as Partial<T>);
|
||||
}
|
||||
|
||||
getKey<K extends keyof T>(key: K): T[K] {
|
||||
return this.value[key];
|
||||
}
|
||||
|
||||
subscribeKey<K extends keyof T>(
|
||||
key: K,
|
||||
cb: (value: T[K]) => void,
|
||||
): () => void {
|
||||
let prev = this.value[key];
|
||||
const wrapper: Listener<T> = (val) => {
|
||||
if (!jsonEqual(val[key], prev)) {
|
||||
prev = val[key];
|
||||
cb(val[key]);
|
||||
}
|
||||
};
|
||||
this.listeners.add(wrapper);
|
||||
return () => this.listeners.delete(wrapper);
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
all: string[];
|
||||
};
|
||||
bandwidthWidget: {
|
||||
loading: boolean;
|
||||
failed: boolean;
|
||||
data: { up: number; down: number };
|
||||
};
|
||||
trafficTotalWidget: {
|
||||
loading: boolean;
|
||||
failed: boolean;
|
||||
data: { downloadTotal: number; uploadTotal: number };
|
||||
};
|
||||
systemInfoWidget: {
|
||||
loading: boolean;
|
||||
failed: boolean;
|
||||
data: { connections: number; memory: number };
|
||||
};
|
||||
servicesInfoWidget: {
|
||||
loading: boolean;
|
||||
failed: boolean;
|
||||
data: {
|
||||
singbox: number;
|
||||
forkopRunning: number;
|
||||
forkopEnabled: number;
|
||||
forkopStatus: string;
|
||||
};
|
||||
};
|
||||
sectionsWidget: {
|
||||
loading: boolean;
|
||||
failed: boolean;
|
||||
data: Forkop.OutboundGroup[];
|
||||
latencyFetchingSections: Record<string, boolean>;
|
||||
latencyProgressSections: Record<string, Forkop.LatencyActionProgress>;
|
||||
selectorSwitchingSections: Record<string, string>;
|
||||
subscriptionUpdatingSections: Record<string, 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;
|
||||
loaded: boolean;
|
||||
providerInfoLoaded: boolean;
|
||||
forkop_version: string;
|
||||
forkop_latest_version: string;
|
||||
luci_app_version: string;
|
||||
sing_box_version: string;
|
||||
sing_box_extended: number;
|
||||
sing_box_tiny: number;
|
||||
sing_box_compressed: number;
|
||||
sing_box_tailscale: number;
|
||||
zapret_version: string;
|
||||
zapret_installed: number;
|
||||
zapret2_version: string;
|
||||
zapret2_installed: number;
|
||||
byedpi_version: string;
|
||||
byedpi_installed: number;
|
||||
server_inbounds_enabled_count: number;
|
||||
openwrt_version: string;
|
||||
device_model: string;
|
||||
};
|
||||
updatesActions: {
|
||||
forkopCheck: { loading: boolean };
|
||||
forkopInstall: { loading: boolean };
|
||||
singBoxCheck: { loading: boolean };
|
||||
singBoxInstall: { loading: boolean };
|
||||
singBoxInstallExtended: { loading: boolean };
|
||||
singBoxInstallExtendedCompressed: { loading: boolean };
|
||||
singBoxInstallTiny: { loading: boolean };
|
||||
singBoxInstallStable: { loading: boolean };
|
||||
zapretCheck: { loading: boolean };
|
||||
zapretInstall: { loading: boolean };
|
||||
zapretRemove: { loading: boolean };
|
||||
zapret2Check: { loading: boolean };
|
||||
zapret2Install: { loading: boolean };
|
||||
zapret2Remove: { loading: boolean };
|
||||
byedpiCheck: { loading: boolean };
|
||||
byedpiInstall: { loading: boolean };
|
||||
byedpiRemove: { loading: boolean };
|
||||
};
|
||||
updatesChecks: Record<
|
||||
Forkop.ComponentName,
|
||||
{
|
||||
status: 'latest' | 'outdated' | 'dev' | null;
|
||||
latest_version: string;
|
||||
release_url: string;
|
||||
}
|
||||
>;
|
||||
}
|
||||
|
||||
const initialStore: StoreType = {
|
||||
tabService: {
|
||||
current: '',
|
||||
all: [],
|
||||
},
|
||||
bandwidthWidget: {
|
||||
loading: true,
|
||||
failed: false,
|
||||
data: { up: 0, down: 0 },
|
||||
},
|
||||
trafficTotalWidget: {
|
||||
loading: true,
|
||||
failed: false,
|
||||
data: { downloadTotal: 0, uploadTotal: 0 },
|
||||
},
|
||||
systemInfoWidget: {
|
||||
loading: true,
|
||||
failed: false,
|
||||
data: { connections: 0, memory: 0 },
|
||||
},
|
||||
servicesInfoWidget: {
|
||||
loading: true,
|
||||
failed: false,
|
||||
data: {
|
||||
singbox: 0,
|
||||
forkopRunning: 0,
|
||||
forkopEnabled: 0,
|
||||
forkopStatus: '',
|
||||
},
|
||||
},
|
||||
sectionsWidget: {
|
||||
loading: true,
|
||||
failed: false,
|
||||
latencyFetchingSections: {},
|
||||
latencyProgressSections: {},
|
||||
selectorSwitchingSections: {},
|
||||
subscriptionUpdatingSections: {},
|
||||
data: [],
|
||||
},
|
||||
...initialDiagnosticStore,
|
||||
};
|
||||
|
||||
export const store = new StoreService<StoreType>(initialStore);
|
||||
@@ -1,129 +0,0 @@
|
||||
import { ForkopShellMethods } from '../methods';
|
||||
import { normalizeSingBoxVariantFields } from '../helpers/singBoxVariant';
|
||||
import { logger } from './logger.service';
|
||||
import { store, StoreType } from './store.service';
|
||||
|
||||
const UNKNOWN_SYSTEM_INFO: StoreType['diagnosticsSystemInfo'] = {
|
||||
loading: false,
|
||||
loaded: false,
|
||||
providerInfoLoaded: false,
|
||||
forkop_version: _('unknown'),
|
||||
forkop_latest_version: _('unknown'),
|
||||
luci_app_version: _('unknown'),
|
||||
sing_box_version: _('unknown'),
|
||||
sing_box_extended: 0,
|
||||
sing_box_tiny: 0,
|
||||
sing_box_compressed: 0,
|
||||
sing_box_tailscale: 1,
|
||||
zapret_version: _('unknown'),
|
||||
zapret_installed: 0,
|
||||
zapret2_version: _('unknown'),
|
||||
zapret2_installed: 0,
|
||||
byedpi_version: _('unknown'),
|
||||
byedpi_installed: 0,
|
||||
server_inbounds_enabled_count: -1,
|
||||
openwrt_version: _('unknown'),
|
||||
device_model: _('unknown'),
|
||||
};
|
||||
|
||||
let systemInfoPromise: Promise<StoreType['diagnosticsSystemInfo']> | null =
|
||||
null;
|
||||
let latestSystemInfoRequestId = 0;
|
||||
|
||||
function hasLoadedSystemInfo() {
|
||||
const systemInfo = store.get().diagnosticsSystemInfo;
|
||||
|
||||
return Boolean(systemInfo.loaded) && !systemInfo.loading;
|
||||
}
|
||||
|
||||
export async function ensureSystemInfo({
|
||||
force = false,
|
||||
silent = false,
|
||||
}: {
|
||||
force?: boolean;
|
||||
silent?: boolean;
|
||||
} = {}) {
|
||||
if (!force && hasLoadedSystemInfo()) {
|
||||
return store.get().diagnosticsSystemInfo;
|
||||
}
|
||||
|
||||
if (systemInfoPromise && !force) {
|
||||
return systemInfoPromise;
|
||||
}
|
||||
|
||||
const requestId = ++latestSystemInfoRequestId;
|
||||
const currentSystemInfo = store.get().diagnosticsSystemInfo;
|
||||
|
||||
if (!silent) {
|
||||
store.set({
|
||||
diagnosticsSystemInfo: {
|
||||
...currentSystemInfo,
|
||||
loading: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const promise = (async () => {
|
||||
try {
|
||||
const systemInfo = await ForkopShellMethods.getSystemInfo();
|
||||
|
||||
if (requestId !== latestSystemInfoRequestId) {
|
||||
return store.get().diagnosticsSystemInfo;
|
||||
}
|
||||
|
||||
if (systemInfo.success) {
|
||||
const nextSystemInfo: StoreType['diagnosticsSystemInfo'] =
|
||||
normalizeSingBoxVariantFields({
|
||||
...UNKNOWN_SYSTEM_INFO,
|
||||
loading: false,
|
||||
loaded: true,
|
||||
providerInfoLoaded: true,
|
||||
server_inbounds_enabled_count:
|
||||
currentSystemInfo.server_inbounds_enabled_count,
|
||||
...systemInfo.data,
|
||||
});
|
||||
|
||||
store.set({
|
||||
diagnosticsSystemInfo: nextSystemInfo,
|
||||
});
|
||||
|
||||
return nextSystemInfo;
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('[SYSTEM_INFO]', 'ensureSystemInfo failed', error);
|
||||
}
|
||||
|
||||
if (requestId === latestSystemInfoRequestId && !silent) {
|
||||
const latestSystemInfo = store.get().diagnosticsSystemInfo;
|
||||
const nextSystemInfo = {
|
||||
...UNKNOWN_SYSTEM_INFO,
|
||||
loading: false,
|
||||
loaded: false,
|
||||
providerInfoLoaded: latestSystemInfo.providerInfoLoaded,
|
||||
zapret_installed: latestSystemInfo.zapret_installed,
|
||||
zapret2_installed: latestSystemInfo.zapret2_installed,
|
||||
byedpi_installed: latestSystemInfo.byedpi_installed,
|
||||
server_inbounds_enabled_count:
|
||||
latestSystemInfo.server_inbounds_enabled_count,
|
||||
};
|
||||
|
||||
store.set({
|
||||
diagnosticsSystemInfo: nextSystemInfo,
|
||||
});
|
||||
|
||||
return nextSystemInfo;
|
||||
}
|
||||
|
||||
return store.get().diagnosticsSystemInfo;
|
||||
})();
|
||||
|
||||
systemInfoPromise = promise;
|
||||
|
||||
try {
|
||||
return await promise;
|
||||
} finally {
|
||||
if (systemInfoPromise === promise) {
|
||||
systemInfoPromise = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,79 +0,0 @@
|
||||
type TabInfo = {
|
||||
el: HTMLElement;
|
||||
id: string;
|
||||
active: boolean;
|
||||
};
|
||||
|
||||
type TabChangeCallback = (activeId: string | null, allTabs: TabInfo[]) => void;
|
||||
|
||||
class TabService {
|
||||
private static instance: TabService;
|
||||
private observer: MutationObserver | null = null;
|
||||
private callback?: TabChangeCallback;
|
||||
private lastActiveId: string | null = null;
|
||||
|
||||
private constructor() {
|
||||
this.init();
|
||||
}
|
||||
|
||||
public static getInstance(): TabService {
|
||||
if (!TabService.instance) {
|
||||
TabService.instance = new TabService();
|
||||
}
|
||||
return TabService.instance;
|
||||
}
|
||||
|
||||
private init() {
|
||||
this.observer = new MutationObserver(() => this.handleMutations());
|
||||
this.observer.observe(document.body, {
|
||||
subtree: true,
|
||||
childList: true,
|
||||
attributes: true,
|
||||
attributeFilter: ['class'],
|
||||
});
|
||||
|
||||
// initial check
|
||||
this.notify();
|
||||
}
|
||||
|
||||
private handleMutations() {
|
||||
this.notify();
|
||||
}
|
||||
|
||||
private getTabsInfo(): TabInfo[] {
|
||||
const tabs = Array.from(
|
||||
document.querySelectorAll<HTMLElement>('.cbi-tab, .cbi-tab-disabled'),
|
||||
);
|
||||
return tabs.map((el) => ({
|
||||
el,
|
||||
id: el.dataset.tab || '',
|
||||
active:
|
||||
el.classList.contains('cbi-tab') &&
|
||||
!el.classList.contains('cbi-tab-disabled'),
|
||||
}));
|
||||
}
|
||||
|
||||
private getActiveTabId(): string | null {
|
||||
const active = document.querySelector<HTMLElement>(
|
||||
'.cbi-tab:not(.cbi-tab-disabled)',
|
||||
);
|
||||
return active?.dataset.tab || null;
|
||||
}
|
||||
|
||||
private notify() {
|
||||
const tabs = this.getTabsInfo();
|
||||
const activeId = this.getActiveTabId();
|
||||
|
||||
if (activeId !== this.lastActiveId) {
|
||||
this.lastActiveId = activeId;
|
||||
this.callback?.(activeId, tabs);
|
||||
}
|
||||
}
|
||||
|
||||
public onChange(callback: TabChangeCallback) {
|
||||
this.callback = callback;
|
||||
this.notify();
|
||||
}
|
||||
}
|
||||
|
||||
export const TabServiceInstance = TabService.getInstance();
|
||||
@@ -1,66 +0,0 @@
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import { ForkopLogWatcher } from '../forkopLogWatcher.service';
|
||||
|
||||
describe('ForkopLogWatcher', () => {
|
||||
const watcher = ForkopLogWatcher.getInstance();
|
||||
let rawLogs = '';
|
||||
let seenLines: string[] = [];
|
||||
|
||||
beforeEach(() => {
|
||||
watcher.stop();
|
||||
watcher.reset();
|
||||
rawLogs = '';
|
||||
seenLines = [];
|
||||
});
|
||||
|
||||
it('emits the initial logread snapshot', async () => {
|
||||
watcher.init(() => rawLogs, {
|
||||
onNewLog: (line) => seenLines.push(line),
|
||||
});
|
||||
|
||||
rawLogs = 'old info\nold error';
|
||||
await watcher.checkOnce();
|
||||
|
||||
expect(seenLines).toEqual(['old info', 'old error']);
|
||||
});
|
||||
|
||||
it('does not emit the same log line twice', async () => {
|
||||
watcher.init(() => rawLogs, {
|
||||
onNewLog: (line) => seenLines.push(line),
|
||||
});
|
||||
|
||||
rawLogs = 'line 1\nline 2';
|
||||
await watcher.checkOnce();
|
||||
await watcher.checkOnce();
|
||||
|
||||
rawLogs = 'line 1\nline 2\nline 3';
|
||||
await watcher.checkOnce();
|
||||
|
||||
expect(seenLines).toEqual(['line 1', 'line 2', 'line 3']);
|
||||
});
|
||||
|
||||
it('forgets old tracked lines only after the tracked window is exceeded', async () => {
|
||||
watcher.init(() => rawLogs, {
|
||||
maxTrackedLines: 3,
|
||||
onNewLog: (line) => seenLines.push(line),
|
||||
});
|
||||
|
||||
rawLogs = 'line 1\nline 2\nline 3';
|
||||
await watcher.checkOnce();
|
||||
|
||||
rawLogs = 'line 1\nline 2\nline 3\nline 4';
|
||||
await watcher.checkOnce();
|
||||
|
||||
rawLogs = 'line 1\nline 2\nline 3\nline 4\nline 5';
|
||||
await watcher.checkOnce();
|
||||
|
||||
expect(seenLines).toEqual([
|
||||
'line 1',
|
||||
'line 2',
|
||||
'line 3',
|
||||
'line 4',
|
||||
'line 5',
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -1,80 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
getLogNotificationKey,
|
||||
getForkopLogNotification,
|
||||
isErrorLogLine,
|
||||
LogNotificationDeduper,
|
||||
} from '../logNotificationDeduper.service';
|
||||
|
||||
class MemoryStorage implements Storage {
|
||||
private values = new Map<string, string>();
|
||||
|
||||
get length() {
|
||||
return this.values.size;
|
||||
}
|
||||
|
||||
clear() {
|
||||
this.values.clear();
|
||||
}
|
||||
|
||||
getItem(key: string) {
|
||||
return this.values.get(key) ?? null;
|
||||
}
|
||||
|
||||
key(index: number) {
|
||||
return Array.from(this.values.keys())[index] ?? null;
|
||||
}
|
||||
|
||||
removeItem(key: string) {
|
||||
this.values.delete(key);
|
||||
}
|
||||
|
||||
setItem(key: string, value: string) {
|
||||
this.values.set(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
describe('LogNotificationDeduper', () => {
|
||||
it('accepts error, fatal, and component update log lines', () => {
|
||||
expect(isErrorLogLine('forkop: [info] ok')).toBe(false);
|
||||
expect(isErrorLogLine('forkop: [error] failed')).toBe(true);
|
||||
expect(isErrorLogLine('forkop: [fatal] failed')).toBe(true);
|
||||
expect(
|
||||
getForkopLogNotification(
|
||||
'forkop: [info] [component-update] zapret2 v1.2.3',
|
||||
),
|
||||
).toEqual({
|
||||
kind: 'component-update',
|
||||
line: 'forkop: [info] [component-update] zapret2 v1.2.3',
|
||||
component: 'zapret2',
|
||||
version: 'v1.2.3',
|
||||
});
|
||||
expect(getForkopLogNotification('forkop: [info] ok')).toBeNull();
|
||||
});
|
||||
|
||||
it('dedupes already shown log lines through session storage', () => {
|
||||
const storage = new MemoryStorage();
|
||||
const first = new LogNotificationDeduper(storage);
|
||||
|
||||
expect(first.shouldNotify('forkop: [error] failed')).toBe(true);
|
||||
expect(first.shouldNotify('forkop: [error] failed')).toBe(false);
|
||||
expect(first.shouldNotify('forkop: [error] another failure')).toBe(true);
|
||||
expect(
|
||||
first.shouldNotify('forkop: [info] [component-update] forkop 1.2.3'),
|
||||
).toBe(true);
|
||||
|
||||
const afterReload = new LogNotificationDeduper(storage);
|
||||
|
||||
expect(afterReload.shouldNotify('forkop: [error] failed')).toBe(false);
|
||||
expect(afterReload.shouldNotify('forkop: [fatal] fatal failure')).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps the full log line as the replay key', () => {
|
||||
expect(getLogNotificationKey(' Jun 06 forkop: [error] failed ')).toBe(
|
||||
'Jun 06 forkop: [error] failed',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,178 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
executeShellCommand: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../../helpers', () => ({
|
||||
executeShellCommand: mocks.executeShellCommand,
|
||||
}));
|
||||
|
||||
import { Forkop } from '../../types';
|
||||
import { store } from '../store.service';
|
||||
import {
|
||||
getCachedRuntimeUiState,
|
||||
refreshRuntimeUiState,
|
||||
subscribeRuntimeUiState,
|
||||
} from '../runtimeUiState.service';
|
||||
|
||||
function createUiState(
|
||||
status = 'running & enabled',
|
||||
running = 1,
|
||||
): Forkop.UiState {
|
||||
return {
|
||||
service: {
|
||||
forkop: {
|
||||
running,
|
||||
enabled: 1,
|
||||
status,
|
||||
dns_configured: running,
|
||||
},
|
||||
sing_box: {
|
||||
running,
|
||||
enabled: 0,
|
||||
status: running ? 'running but disabled' : 'stopped & disabled',
|
||||
},
|
||||
},
|
||||
capabilities: {
|
||||
sing_box_extended: 1,
|
||||
sing_box_tiny: 0,
|
||||
sing_box_compressed: 0,
|
||||
sing_box_tailscale: 1,
|
||||
zapret_installed: 1,
|
||||
zapret2_installed: 1,
|
||||
byedpi_installed: 0,
|
||||
server_inbounds_enabled_count: 0,
|
||||
},
|
||||
actions: {
|
||||
service: [],
|
||||
latency: [],
|
||||
component: [],
|
||||
subscription: [],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe('refreshRuntimeUiState', () => {
|
||||
beforeEach(() => {
|
||||
store.reset();
|
||||
mocks.executeShellCommand.mockReset();
|
||||
});
|
||||
|
||||
it('applies current UI state to the shared store', async () => {
|
||||
const uiState = createUiState('stopped but enabled', 0);
|
||||
|
||||
mocks.executeShellCommand.mockResolvedValue({
|
||||
stdout: JSON.stringify(uiState),
|
||||
stderr: '',
|
||||
code: 0,
|
||||
});
|
||||
|
||||
await expect(refreshRuntimeUiState({ force: true })).resolves.toEqual(
|
||||
uiState,
|
||||
);
|
||||
|
||||
expect(mocks.executeShellCommand).toHaveBeenCalledWith({
|
||||
command: '/usr/bin/forkop',
|
||||
args: ['get_ui_state'],
|
||||
timeout: 3000,
|
||||
});
|
||||
expect(store.get().servicesInfoWidget.data).toMatchObject({
|
||||
forkopRunning: 0,
|
||||
forkopEnabled: 1,
|
||||
forkopStatus: 'stopped but enabled',
|
||||
});
|
||||
});
|
||||
|
||||
it('coalesces concurrent refreshes into one RPC call', async () => {
|
||||
const uiState = createUiState();
|
||||
let resolveRpc: (value: {
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
code: number;
|
||||
}) => void = () => undefined;
|
||||
|
||||
mocks.executeShellCommand.mockReturnValue(
|
||||
new Promise((resolve) => {
|
||||
resolveRpc = resolve;
|
||||
}),
|
||||
);
|
||||
|
||||
const firstRefresh = refreshRuntimeUiState({ force: true });
|
||||
const secondRefresh = refreshRuntimeUiState({ force: true });
|
||||
|
||||
expect(mocks.executeShellCommand).toHaveBeenCalledTimes(1);
|
||||
|
||||
resolveRpc({
|
||||
stdout: JSON.stringify(uiState),
|
||||
stderr: '',
|
||||
code: 0,
|
||||
});
|
||||
|
||||
await expect(Promise.all([firstRefresh, secondRefresh])).resolves.toEqual([
|
||||
uiState,
|
||||
uiState,
|
||||
]);
|
||||
});
|
||||
|
||||
it('notifies subscribers after applying fresh state', async () => {
|
||||
const uiState = createUiState('stopped but enabled', 0);
|
||||
const listener = vi.fn();
|
||||
const unsubscribe = subscribeRuntimeUiState(listener);
|
||||
listener.mockClear();
|
||||
|
||||
mocks.executeShellCommand.mockResolvedValue({
|
||||
stdout: JSON.stringify(uiState),
|
||||
stderr: '',
|
||||
code: 0,
|
||||
});
|
||||
|
||||
await refreshRuntimeUiState({ force: true });
|
||||
|
||||
expect(listener).toHaveBeenCalledWith(uiState);
|
||||
|
||||
unsubscribe();
|
||||
listener.mockClear();
|
||||
|
||||
await refreshRuntimeUiState({ force: true });
|
||||
|
||||
expect(listener).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('replays cached state to a new subscriber without another RPC', async () => {
|
||||
const uiState = createUiState('starting', 0);
|
||||
|
||||
mocks.executeShellCommand.mockResolvedValue({
|
||||
stdout: JSON.stringify(uiState),
|
||||
stderr: '',
|
||||
code: 0,
|
||||
});
|
||||
|
||||
await refreshRuntimeUiState({ force: true });
|
||||
|
||||
const listener = vi.fn();
|
||||
const unsubscribe = subscribeRuntimeUiState(listener);
|
||||
|
||||
expect(listener).toHaveBeenCalledTimes(1);
|
||||
expect(listener).toHaveBeenCalledWith(uiState);
|
||||
expect(mocks.executeShellCommand).toHaveBeenCalledTimes(1);
|
||||
|
||||
unsubscribe();
|
||||
});
|
||||
|
||||
it('exposes the cached state without another RPC', async () => {
|
||||
const uiState = createUiState('running & enabled', 1);
|
||||
|
||||
mocks.executeShellCommand.mockResolvedValue({
|
||||
stdout: JSON.stringify(uiState),
|
||||
stderr: '',
|
||||
code: 0,
|
||||
});
|
||||
|
||||
await refreshRuntimeUiState({ force: true });
|
||||
mocks.executeShellCommand.mockClear();
|
||||
|
||||
expect(getCachedRuntimeUiState()).toEqual(uiState);
|
||||
expect(mocks.executeShellCommand).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,52 +0,0 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { socket } from '../socket.service';
|
||||
|
||||
class FakeWebSocket {
|
||||
static CONNECTING = 0;
|
||||
static OPEN = 1;
|
||||
static instances: FakeWebSocket[] = [];
|
||||
|
||||
readyState = FakeWebSocket.CONNECTING;
|
||||
private listeners = new Map<string, Array<(event: Event) => void>>();
|
||||
|
||||
constructor(_url: string) {
|
||||
FakeWebSocket.instances.push(this);
|
||||
}
|
||||
|
||||
addEventListener(type: string, listener: (event: Event) => void) {
|
||||
const listeners = this.listeners.get(type) || [];
|
||||
listeners.push(listener);
|
||||
this.listeners.set(type, listeners);
|
||||
}
|
||||
|
||||
emit(type: string) {
|
||||
for (const listener of this.listeners.get(type) || []) {
|
||||
listener(new Event(type));
|
||||
}
|
||||
}
|
||||
|
||||
close() {}
|
||||
send() {}
|
||||
}
|
||||
|
||||
describe('socket service', () => {
|
||||
beforeEach(() => {
|
||||
socket.resetAll();
|
||||
FakeWebSocket.instances = [];
|
||||
vi.stubGlobal('WebSocket', FakeWebSocket);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
socket.resetAll();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('keeps the initial subscriber when the first connection fails', () => {
|
||||
const onError = vi.fn();
|
||||
|
||||
socket.subscribe('ws://router.test', vi.fn(), onError);
|
||||
FakeWebSocket.instances[0].emit('error');
|
||||
|
||||
expect(onError).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
@@ -1,35 +0,0 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { StoreService } from '../store.service';
|
||||
|
||||
describe('StoreService', () => {
|
||||
it('diffs only keys provided to set', () => {
|
||||
const store = new StoreService({
|
||||
light: { value: 1 },
|
||||
heavy: { items: [{ id: 1 }] },
|
||||
});
|
||||
const listener = vi.fn();
|
||||
|
||||
store.subscribe(listener);
|
||||
listener.mockClear();
|
||||
|
||||
store.set({ light: { value: 2 } });
|
||||
|
||||
expect(listener).toHaveBeenCalledTimes(1);
|
||||
expect(listener.mock.calls[0][2]).toEqual({ light: { value: 2 } });
|
||||
});
|
||||
|
||||
it('does not notify when provided values are unchanged', () => {
|
||||
const store = new StoreService({
|
||||
light: { value: 1 },
|
||||
heavy: { items: [{ id: 1 }] },
|
||||
});
|
||||
const listener = vi.fn();
|
||||
|
||||
store.subscribe(listener);
|
||||
listener.mockClear();
|
||||
|
||||
store.set({ light: { value: 1 } });
|
||||
|
||||
expect(listener).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,65 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { UiActionNotificationTracker } from '../uiActionNotification.service';
|
||||
|
||||
class MemoryStorage implements Storage {
|
||||
private values = new Map<string, string>();
|
||||
|
||||
get length() {
|
||||
return this.values.size;
|
||||
}
|
||||
|
||||
clear() {
|
||||
this.values.clear();
|
||||
}
|
||||
|
||||
getItem(key: string) {
|
||||
return this.values.get(key) ?? null;
|
||||
}
|
||||
|
||||
key(index: number) {
|
||||
return Array.from(this.values.keys())[index] ?? null;
|
||||
}
|
||||
|
||||
removeItem(key: string) {
|
||||
this.values.delete(key);
|
||||
}
|
||||
|
||||
setItem(key: string, value: string) {
|
||||
this.values.set(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
describe('UiActionNotificationTracker', () => {
|
||||
it('does not notify jobs that were not started by this UI session', () => {
|
||||
const tracker = new UiActionNotificationTracker(new MemoryStorage());
|
||||
|
||||
expect(tracker.shouldNotify('component', 'component-1')).toBe(false);
|
||||
});
|
||||
|
||||
it('notifies an owned job exactly once', () => {
|
||||
const tracker = new UiActionNotificationTracker(new MemoryStorage());
|
||||
|
||||
tracker.markOwned('subscription', 'subscription-1');
|
||||
|
||||
expect(tracker.shouldNotify('subscription', 'subscription-1')).toBe(true);
|
||||
expect(tracker.shouldNotify('subscription', 'subscription-1')).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps ownership and notification state across reloads', () => {
|
||||
const storage = new MemoryStorage();
|
||||
const beforeReload = new UiActionNotificationTracker(storage);
|
||||
|
||||
beforeReload.markOwned('component', 'component-1');
|
||||
|
||||
const afterReload = new UiActionNotificationTracker(storage);
|
||||
|
||||
expect(afterReload.shouldNotify('component', 'component-1')).toBe(true);
|
||||
|
||||
const afterNotification = new UiActionNotificationTracker(storage);
|
||||
|
||||
expect(afterNotification.shouldNotify('component', 'component-1')).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,429 +0,0 @@
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
import { applyUiStateToStore } from '../uiState.service';
|
||||
import { store } from '../store.service';
|
||||
import { Forkop } from '../../types';
|
||||
import {
|
||||
clearLocalActionOverlay,
|
||||
setLocalComponentAction,
|
||||
setLocalLatencyAction,
|
||||
setLocalServiceAction,
|
||||
setLocalSubscriptionAction,
|
||||
} from '../localActionOverlay.service';
|
||||
|
||||
function createUiState(
|
||||
actions: Partial<Forkop.UiState['actions']> = {},
|
||||
capabilities: Partial<Forkop.UiState['capabilities']> = {},
|
||||
): Forkop.UiState {
|
||||
return {
|
||||
service: {
|
||||
forkop: {
|
||||
running: 0,
|
||||
enabled: 1,
|
||||
status: 'starting',
|
||||
dns_configured: 0,
|
||||
},
|
||||
sing_box: {
|
||||
running: 0,
|
||||
enabled: 1,
|
||||
status: 'stopped but enabled',
|
||||
},
|
||||
},
|
||||
capabilities: {
|
||||
sing_box_extended: 1,
|
||||
sing_box_tiny: 0,
|
||||
sing_box_compressed: 0,
|
||||
sing_box_tailscale: 1,
|
||||
zapret_installed: 1,
|
||||
zapret2_installed: 0,
|
||||
byedpi_installed: 1,
|
||||
server_inbounds_enabled_count: 0,
|
||||
...capabilities,
|
||||
},
|
||||
actions: {
|
||||
service: [],
|
||||
latency: [],
|
||||
component: [],
|
||||
subscription: [],
|
||||
...actions,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe('applyUiStateToStore', () => {
|
||||
beforeEach(() => {
|
||||
clearLocalActionOverlay();
|
||||
store.reset();
|
||||
});
|
||||
|
||||
it('applies service, capability, and running action state before first render', () => {
|
||||
applyUiStateToStore(
|
||||
createUiState({
|
||||
service: [
|
||||
{
|
||||
success: true,
|
||||
running: true,
|
||||
kind: 'service',
|
||||
action: 'start',
|
||||
job_id: 'service-1',
|
||||
},
|
||||
],
|
||||
subscription: [
|
||||
{
|
||||
success: true,
|
||||
running: true,
|
||||
job_id: 'subscription-1',
|
||||
section: 'main',
|
||||
message: 'Subscription update is running',
|
||||
},
|
||||
],
|
||||
latency: [
|
||||
{
|
||||
success: true,
|
||||
running: true,
|
||||
kind: 'latency',
|
||||
latency_type: 'group',
|
||||
section: 'main',
|
||||
tag: 'AUTO',
|
||||
job_id: 'latency-1',
|
||||
progress: {
|
||||
completed: 2,
|
||||
total: 5,
|
||||
failed: 1,
|
||||
},
|
||||
},
|
||||
],
|
||||
component: [
|
||||
{
|
||||
success: true,
|
||||
running: true,
|
||||
job_id: 'component-1',
|
||||
component: 'zapret',
|
||||
action: 'install',
|
||||
message: 'Install is running',
|
||||
current_version: '',
|
||||
latest_version: '',
|
||||
changed: false,
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
const state = store.get();
|
||||
|
||||
expect(state.servicesInfoWidget).toMatchObject({
|
||||
loading: false,
|
||||
failed: false,
|
||||
data: {
|
||||
forkopEnabled: 1,
|
||||
forkopRunning: 0,
|
||||
forkopStatus: 'starting',
|
||||
},
|
||||
});
|
||||
expect(state.diagnosticsSystemInfo).toMatchObject({
|
||||
providerInfoLoaded: true,
|
||||
sing_box_extended: 1,
|
||||
zapret_installed: 1,
|
||||
zapret2_installed: 0,
|
||||
byedpi_installed: 1,
|
||||
server_inbounds_enabled_count: 0,
|
||||
});
|
||||
expect(state.diagnosticsActions.start.loading).toBe(true);
|
||||
expect(state.sectionsWidget.subscriptionUpdatingSections).toEqual({
|
||||
main: true,
|
||||
});
|
||||
expect(state.sectionsWidget.latencyFetchingSections).toEqual({
|
||||
main: true,
|
||||
});
|
||||
expect(state.sectionsWidget.latencyProgressSections).toEqual({
|
||||
main: {
|
||||
completed: 2,
|
||||
total: 5,
|
||||
failed: 1,
|
||||
},
|
||||
});
|
||||
expect(state.updatesActions.zapretInstall.loading).toBe(true);
|
||||
});
|
||||
|
||||
it('clears finished persisted action flags while preserving local enable actions', () => {
|
||||
store.set({
|
||||
diagnosticsActions: {
|
||||
...store.get().diagnosticsActions,
|
||||
start: { loading: true },
|
||||
enable: { loading: true },
|
||||
},
|
||||
sectionsWidget: {
|
||||
...store.get().sectionsWidget,
|
||||
subscriptionUpdatingSections: { main: true },
|
||||
latencyFetchingSections: { main: true },
|
||||
latencyProgressSections: {
|
||||
main: {
|
||||
completed: 1,
|
||||
total: 2,
|
||||
failed: 0,
|
||||
},
|
||||
},
|
||||
},
|
||||
updatesActions: {
|
||||
...store.get().updatesActions,
|
||||
zapretInstall: { loading: true },
|
||||
},
|
||||
});
|
||||
|
||||
applyUiStateToStore(createUiState());
|
||||
|
||||
const state = store.get();
|
||||
|
||||
expect(state.diagnosticsActions.start.loading).toBe(false);
|
||||
expect(state.diagnosticsActions.enable.loading).toBe(true);
|
||||
expect(state.sectionsWidget.subscriptionUpdatingSections).toEqual({});
|
||||
expect(state.sectionsWidget.latencyFetchingSections).toEqual({});
|
||||
expect(state.sectionsWidget.latencyProgressSections).toEqual({});
|
||||
expect(state.updatesActions.zapretInstall.loading).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps locally started actions loading until their owner clears them', () => {
|
||||
setLocalComponentAction('zapretInstall', true);
|
||||
setLocalSubscriptionAction('proxy', true);
|
||||
setLocalLatencyAction('gemini', true);
|
||||
setLocalServiceAction('restart', true);
|
||||
|
||||
applyUiStateToStore(createUiState());
|
||||
|
||||
expect(store.get().updatesActions.zapretInstall.loading).toBe(true);
|
||||
expect(store.get().sectionsWidget.subscriptionUpdatingSections).toEqual({
|
||||
proxy: true,
|
||||
});
|
||||
expect(store.get().sectionsWidget.latencyFetchingSections).toEqual({
|
||||
gemini: true,
|
||||
});
|
||||
expect(store.get().diagnosticsActions.restart.loading).toBe(true);
|
||||
|
||||
clearLocalActionOverlay();
|
||||
applyUiStateToStore(createUiState());
|
||||
|
||||
expect(store.get().updatesActions.zapretInstall.loading).toBe(false);
|
||||
expect(store.get().sectionsWidget.subscriptionUpdatingSections).toEqual({});
|
||||
expect(store.get().sectionsWidget.latencyFetchingSections).toEqual({});
|
||||
expect(store.get().diagnosticsActions.restart.loading).toBe(false);
|
||||
});
|
||||
|
||||
it('preserves local latency progress while waiting for runtime progress', () => {
|
||||
setLocalLatencyAction('gemini', true);
|
||||
store.set({
|
||||
sectionsWidget: {
|
||||
...store.get().sectionsWidget,
|
||||
latencyFetchingSections: { gemini: true },
|
||||
latencyProgressSections: {
|
||||
gemini: {
|
||||
completed: 0,
|
||||
total: 3,
|
||||
failed: 0,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
applyUiStateToStore(createUiState());
|
||||
|
||||
expect(store.get().sectionsWidget.latencyFetchingSections).toEqual({
|
||||
gemini: true,
|
||||
});
|
||||
expect(store.get().sectionsWidget.latencyProgressSections).toEqual({
|
||||
gemini: {
|
||||
completed: 0,
|
||||
total: 3,
|
||||
failed: 0,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('preserves latency progress for running runtime actions without progress', () => {
|
||||
store.set({
|
||||
sectionsWidget: {
|
||||
...store.get().sectionsWidget,
|
||||
latencyFetchingSections: { main: true },
|
||||
latencyProgressSections: {
|
||||
main: {
|
||||
completed: 2,
|
||||
total: 5,
|
||||
failed: 0,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
applyUiStateToStore(
|
||||
createUiState({
|
||||
latency: [
|
||||
{
|
||||
success: true,
|
||||
running: true,
|
||||
kind: 'latency',
|
||||
latency_type: 'proxy_list',
|
||||
section: 'main',
|
||||
tag: '["one","two"]',
|
||||
job_id: 'latency-1',
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(store.get().sectionsWidget.latencyFetchingSections).toEqual({
|
||||
main: true,
|
||||
});
|
||||
expect(store.get().sectionsWidget.latencyProgressSections).toEqual({
|
||||
main: {
|
||||
completed: 2,
|
||||
total: 5,
|
||||
failed: 0,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('maps a running reload action to the restart control', () => {
|
||||
applyUiStateToStore(
|
||||
createUiState({
|
||||
service: [
|
||||
{
|
||||
success: true,
|
||||
running: true,
|
||||
kind: 'service',
|
||||
action: 'reload',
|
||||
job_id: 'service-reload',
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(store.get().diagnosticsActions.restart.loading).toBe(true);
|
||||
});
|
||||
|
||||
it('does not combine a stale extended version with tiny capabilities', () => {
|
||||
store.set({
|
||||
diagnosticsSystemInfo: {
|
||||
...store.get().diagnosticsSystemInfo,
|
||||
sing_box_version: '1.13.12-extended-2.3.2',
|
||||
sing_box_extended: 1,
|
||||
sing_box_tiny: 0,
|
||||
},
|
||||
});
|
||||
|
||||
applyUiStateToStore(
|
||||
createUiState(undefined, {
|
||||
sing_box_extended: 0,
|
||||
sing_box_tiny: 1,
|
||||
sing_box_compressed: 0,
|
||||
sing_box_tailscale: 0,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(store.get().diagnosticsSystemInfo).toMatchObject({
|
||||
sing_box_version: '1.13.12-extended-2.3.2',
|
||||
sing_box_extended: 1,
|
||||
sing_box_tiny: 0,
|
||||
sing_box_tailscale: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps current sing-box variant while a different sing-box install action is running', () => {
|
||||
store.set({
|
||||
diagnosticsSystemInfo: {
|
||||
...store.get().diagnosticsSystemInfo,
|
||||
providerInfoLoaded: true,
|
||||
sing_box_version: '1.12.25',
|
||||
sing_box_extended: 0,
|
||||
sing_box_tiny: 1,
|
||||
sing_box_compressed: 0,
|
||||
sing_box_tailscale: 1,
|
||||
zapret_installed: 0,
|
||||
zapret2_installed: 0,
|
||||
byedpi_installed: 1,
|
||||
server_inbounds_enabled_count: 0,
|
||||
},
|
||||
});
|
||||
|
||||
applyUiStateToStore(
|
||||
createUiState(
|
||||
{
|
||||
component: [
|
||||
{
|
||||
success: true,
|
||||
running: true,
|
||||
job_id: 'sing-box-install',
|
||||
component: 'sing_box',
|
||||
action: 'install_extended_compressed',
|
||||
message: 'Install is running',
|
||||
current_version: '',
|
||||
latest_version: '',
|
||||
changed: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
sing_box_extended: 0,
|
||||
sing_box_tiny: 1,
|
||||
sing_box_compressed: 0,
|
||||
sing_box_tailscale: 0,
|
||||
zapret_installed: 1,
|
||||
zapret2_installed: 1,
|
||||
byedpi_installed: 0,
|
||||
server_inbounds_enabled_count: 2,
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
expect(store.get().diagnosticsSystemInfo).toMatchObject({
|
||||
providerInfoLoaded: true,
|
||||
sing_box_version: '1.12.25',
|
||||
sing_box_extended: 0,
|
||||
sing_box_tiny: 1,
|
||||
sing_box_compressed: 0,
|
||||
sing_box_tailscale: 0,
|
||||
zapret_installed: 1,
|
||||
zapret2_installed: 1,
|
||||
byedpi_installed: 0,
|
||||
server_inbounds_enabled_count: 2,
|
||||
});
|
||||
expect(
|
||||
store.get().updatesActions.singBoxInstallExtendedCompressed.loading,
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('marks the running sing-box install action without pretending it is installed', () => {
|
||||
applyUiStateToStore(
|
||||
createUiState(
|
||||
{
|
||||
component: [
|
||||
{
|
||||
success: true,
|
||||
running: true,
|
||||
job_id: 'sing-box-install',
|
||||
component: 'sing_box',
|
||||
action: 'install_extended',
|
||||
message: 'Install is running',
|
||||
current_version: '',
|
||||
latest_version: '',
|
||||
changed: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
sing_box_extended: 0,
|
||||
sing_box_tiny: 1,
|
||||
sing_box_compressed: 0,
|
||||
sing_box_tailscale: 0,
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
expect(store.get().diagnosticsSystemInfo).toMatchObject({
|
||||
sing_box_extended: 0,
|
||||
sing_box_tiny: 1,
|
||||
sing_box_compressed: 0,
|
||||
sing_box_tailscale: 0,
|
||||
});
|
||||
expect(store.get().updatesActions.singBoxInstallExtended.loading).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,165 +0,0 @@
|
||||
const UI_ACTION_NOTIFICATION_STORAGE_KEY =
|
||||
'forkop:owned-ui-action-notifications:v1';
|
||||
const MAX_STORED_UI_ACTION_NOTIFICATIONS = 100;
|
||||
|
||||
export type UiActionNotificationKind = 'component' | 'subscription';
|
||||
|
||||
interface StoredUiActionNotification {
|
||||
kind: UiActionNotificationKind;
|
||||
jobId: string;
|
||||
notified: boolean;
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
function getSessionStorage(): Storage | null {
|
||||
if (typeof window === 'undefined') {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return window.sessionStorage;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function getNotificationKey(kind: UiActionNotificationKind, jobId: string) {
|
||||
return `${kind}:${jobId}`;
|
||||
}
|
||||
|
||||
function isStoredNotification(
|
||||
value: unknown,
|
||||
): value is StoredUiActionNotification {
|
||||
if (!value || typeof value !== 'object') {
|
||||
return false;
|
||||
}
|
||||
|
||||
const candidate = value as Partial<StoredUiActionNotification>;
|
||||
|
||||
return (
|
||||
(candidate.kind === 'component' || candidate.kind === 'subscription') &&
|
||||
typeof candidate.jobId === 'string' &&
|
||||
typeof candidate.notified === 'boolean' &&
|
||||
typeof candidate.updatedAt === 'number'
|
||||
);
|
||||
}
|
||||
|
||||
function readStoredNotifications(
|
||||
storage: Storage | null,
|
||||
): StoredUiActionNotification[] {
|
||||
if (!storage) {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(
|
||||
storage.getItem(UI_ACTION_NOTIFICATION_STORAGE_KEY) || '[]',
|
||||
);
|
||||
|
||||
return Array.isArray(parsed) ? parsed.filter(isStoredNotification) : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function writeStoredNotifications(
|
||||
storage: Storage | null,
|
||||
notifications: StoredUiActionNotification[],
|
||||
) {
|
||||
if (!storage) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
storage.setItem(
|
||||
UI_ACTION_NOTIFICATION_STORAGE_KEY,
|
||||
JSON.stringify(
|
||||
notifications
|
||||
.sort((a, b) => a.updatedAt - b.updatedAt)
|
||||
.slice(-MAX_STORED_UI_ACTION_NOTIFICATIONS),
|
||||
),
|
||||
);
|
||||
} catch {
|
||||
// In-memory ownership still prevents duplicate notifications this session.
|
||||
}
|
||||
}
|
||||
|
||||
export class UiActionNotificationTracker {
|
||||
private readonly storage: Storage | null;
|
||||
private readonly notifications = new Map<
|
||||
string,
|
||||
StoredUiActionNotification
|
||||
>();
|
||||
|
||||
constructor(storage: Storage | null = getSessionStorage()) {
|
||||
this.storage = storage;
|
||||
|
||||
for (const notification of readStoredNotifications(storage)) {
|
||||
this.notifications.set(
|
||||
getNotificationKey(notification.kind, notification.jobId),
|
||||
notification,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
markOwned(kind: UiActionNotificationKind, jobId: string) {
|
||||
if (!jobId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const key = getNotificationKey(kind, jobId);
|
||||
const current = this.notifications.get(key);
|
||||
|
||||
this.notifications.set(key, {
|
||||
kind,
|
||||
jobId,
|
||||
notified: current?.notified ?? false,
|
||||
updatedAt: Date.now(),
|
||||
});
|
||||
this.persist();
|
||||
}
|
||||
|
||||
shouldNotify(kind: UiActionNotificationKind, jobId: string) {
|
||||
if (!jobId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const key = getNotificationKey(kind, jobId);
|
||||
const current = this.notifications.get(key);
|
||||
|
||||
if (!current || current.notified) {
|
||||
return false;
|
||||
}
|
||||
|
||||
this.notifications.set(key, {
|
||||
...current,
|
||||
notified: true,
|
||||
updatedAt: Date.now(),
|
||||
});
|
||||
this.persist();
|
||||
return true;
|
||||
}
|
||||
|
||||
private persist() {
|
||||
writeStoredNotifications(
|
||||
this.storage,
|
||||
Array.from(this.notifications.values()),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const uiActionNotifications = new UiActionNotificationTracker();
|
||||
|
||||
export function markUiActionOwned(
|
||||
kind: UiActionNotificationKind,
|
||||
jobId: string,
|
||||
) {
|
||||
uiActionNotifications.markOwned(kind, jobId);
|
||||
}
|
||||
|
||||
export function shouldNotifyOwnedUiAction(
|
||||
kind: UiActionNotificationKind,
|
||||
jobId: string,
|
||||
) {
|
||||
return uiActionNotifications.shouldNotify(kind, jobId);
|
||||
}
|
||||
@@ -1,194 +0,0 @@
|
||||
import { getComponentActionKey } from '../helpers/getComponentActionKey';
|
||||
import { normalizeSingBoxVariantFields } from '../helpers/singBoxVariant';
|
||||
import type { Forkop } from '../types';
|
||||
import { getLocalActionOverlay } from './localActionOverlay.service';
|
||||
import { store } from './store.service';
|
||||
import type { StoreType } from './store.service';
|
||||
|
||||
type UiActionMap = Partial<Forkop.UiState['actions']>;
|
||||
|
||||
function isRunningAction(state: { running?: boolean }) {
|
||||
return state.running === true;
|
||||
}
|
||||
|
||||
function getEmptyUpdatesActions(): StoreType['updatesActions'] {
|
||||
return {
|
||||
forkopCheck: { loading: false },
|
||||
forkopInstall: { loading: false },
|
||||
singBoxCheck: { loading: false },
|
||||
singBoxInstall: { loading: false },
|
||||
singBoxInstallExtended: { loading: false },
|
||||
singBoxInstallExtendedCompressed: { loading: false },
|
||||
singBoxInstallTiny: { loading: false },
|
||||
singBoxInstallStable: { loading: false },
|
||||
zapretCheck: { loading: false },
|
||||
zapretInstall: { loading: false },
|
||||
zapretRemove: { loading: false },
|
||||
zapret2Check: { loading: false },
|
||||
zapret2Install: { loading: false },
|
||||
zapret2Remove: { loading: false },
|
||||
byedpiCheck: { loading: false },
|
||||
byedpiInstall: { loading: false },
|
||||
byedpiRemove: { loading: false },
|
||||
};
|
||||
}
|
||||
|
||||
function getEmptyDiagnosticsActions(): StoreType['diagnosticsActions'] {
|
||||
return {
|
||||
...store.get().diagnosticsActions,
|
||||
restart: { loading: false },
|
||||
start: { loading: false },
|
||||
stop: { loading: false },
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeLatencyProgress(
|
||||
progress?: Forkop.LatencyActionProgress,
|
||||
): Forkop.LatencyActionProgress | undefined {
|
||||
const total = Math.trunc(Number(progress?.total ?? 0));
|
||||
|
||||
if (!Number.isFinite(total) || total <= 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const completedValue = Number(progress?.completed ?? 0);
|
||||
const failedValue = Number(progress?.failed ?? 0);
|
||||
const completed = Number.isFinite(completedValue)
|
||||
? Math.trunc(completedValue)
|
||||
: 0;
|
||||
const failed = Number.isFinite(failedValue) ? Math.trunc(failedValue) : 0;
|
||||
|
||||
return {
|
||||
completed: Math.min(Math.max(0, completed), total),
|
||||
total,
|
||||
failed: Math.max(0, failed),
|
||||
};
|
||||
}
|
||||
|
||||
function applyServiceState(uiState: Forkop.UiState) {
|
||||
const currentSystemInfo = store.get().diagnosticsSystemInfo;
|
||||
const nextSystemInfo = {
|
||||
...currentSystemInfo,
|
||||
providerInfoLoaded: true,
|
||||
zapret_installed: uiState.capabilities.zapret_installed,
|
||||
zapret2_installed: uiState.capabilities.zapret2_installed,
|
||||
byedpi_installed: uiState.capabilities.byedpi_installed,
|
||||
server_inbounds_enabled_count:
|
||||
uiState.capabilities.server_inbounds_enabled_count,
|
||||
};
|
||||
|
||||
nextSystemInfo.sing_box_extended = uiState.capabilities.sing_box_extended;
|
||||
nextSystemInfo.sing_box_tiny = uiState.capabilities.sing_box_tiny;
|
||||
nextSystemInfo.sing_box_compressed = uiState.capabilities.sing_box_compressed;
|
||||
nextSystemInfo.sing_box_tailscale = uiState.capabilities.sing_box_tailscale;
|
||||
|
||||
store.set({
|
||||
servicesInfoWidget: {
|
||||
loading: false,
|
||||
failed: false,
|
||||
data: {
|
||||
singbox: uiState.service.sing_box.running,
|
||||
forkopRunning: uiState.service.forkop.running,
|
||||
forkopEnabled: uiState.service.forkop.enabled,
|
||||
forkopStatus: uiState.service.forkop.status,
|
||||
},
|
||||
},
|
||||
diagnosticsSystemInfo: normalizeSingBoxVariantFields(nextSystemInfo),
|
||||
});
|
||||
}
|
||||
|
||||
function applyActionState(actions: UiActionMap = {}) {
|
||||
const current = store.get();
|
||||
const localOverlay = getLocalActionOverlay();
|
||||
const currentLatencyProgressSections =
|
||||
current.sectionsWidget.latencyProgressSections;
|
||||
const subscriptionUpdatingSections: Record<string, boolean> = {};
|
||||
const latencyFetchingSections: Record<string, boolean> = {};
|
||||
const latencyProgressSections: Record<string, Forkop.LatencyActionProgress> =
|
||||
{};
|
||||
const updatesActions = getEmptyUpdatesActions();
|
||||
const diagnosticsActions = getEmptyDiagnosticsActions();
|
||||
|
||||
for (const state of actions.subscription || []) {
|
||||
if (isRunningAction(state) && state.section) {
|
||||
subscriptionUpdatingSections[state.section] = true;
|
||||
}
|
||||
}
|
||||
|
||||
for (const state of actions.latency || []) {
|
||||
if (isRunningAction(state) && state.section) {
|
||||
latencyFetchingSections[state.section] = true;
|
||||
const progress = normalizeLatencyProgress(state.progress);
|
||||
if (progress) {
|
||||
latencyProgressSections[state.section] = progress;
|
||||
} else if (currentLatencyProgressSections[state.section]) {
|
||||
latencyProgressSections[state.section] =
|
||||
currentLatencyProgressSections[state.section];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const state of actions.component || []) {
|
||||
if (!isRunningAction(state)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const key = getComponentActionKey(state.component, state.action);
|
||||
if (key) {
|
||||
updatesActions[key] = { loading: true };
|
||||
}
|
||||
}
|
||||
|
||||
for (const state of actions.service || []) {
|
||||
if (!isRunningAction(state)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (state.action === 'start') {
|
||||
diagnosticsActions.start = { loading: true };
|
||||
} else if (state.action === 'stop') {
|
||||
diagnosticsActions.stop = { loading: true };
|
||||
} else if (state.action === 'restart' || state.action === 'reload') {
|
||||
diagnosticsActions.restart = { loading: true };
|
||||
}
|
||||
}
|
||||
|
||||
for (const section of localOverlay.subscriptionSections) {
|
||||
subscriptionUpdatingSections[section] = true;
|
||||
}
|
||||
|
||||
for (const section of localOverlay.latencySections) {
|
||||
latencyFetchingSections[section] = true;
|
||||
if (
|
||||
!latencyProgressSections[section] &&
|
||||
currentLatencyProgressSections[section]
|
||||
) {
|
||||
latencyProgressSections[section] =
|
||||
currentLatencyProgressSections[section];
|
||||
}
|
||||
}
|
||||
|
||||
for (const key of localOverlay.componentActions) {
|
||||
updatesActions[key] = { loading: true };
|
||||
}
|
||||
|
||||
for (const action of localOverlay.serviceActions) {
|
||||
diagnosticsActions[action] = { loading: true };
|
||||
}
|
||||
|
||||
store.set({
|
||||
sectionsWidget: {
|
||||
...current.sectionsWidget,
|
||||
subscriptionUpdatingSections,
|
||||
latencyFetchingSections,
|
||||
latencyProgressSections,
|
||||
},
|
||||
updatesActions,
|
||||
diagnosticsActions,
|
||||
});
|
||||
}
|
||||
|
||||
export function applyUiStateToStore(uiState: Forkop.UiState) {
|
||||
applyServiceState(uiState);
|
||||
applyActionState(uiState.actions);
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
import { render } from './render';
|
||||
import { initController } from './initController';
|
||||
import { styles } from './styles';
|
||||
|
||||
export const DashboardTab = {
|
||||
render,
|
||||
initController,
|
||||
styles,
|
||||
};
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,2 +0,0 @@
|
||||
export * from './renderSections';
|
||||
export * from './renderWidget';
|
||||
@@ -1,508 +0,0 @@
|
||||
import {
|
||||
renderLoaderCircleIcon24,
|
||||
renderCopyIcon24,
|
||||
renderLinkIcon24,
|
||||
renderInfoIcon24,
|
||||
} from '../../../../icons';
|
||||
import { isCopyableProxyLink, svgEl } from '../../../../helpers';
|
||||
import { prettyBytes } from '../../../../helpers/prettyBytes';
|
||||
import { Forkop } from '../../../types';
|
||||
|
||||
interface IRenderSectionsProps {
|
||||
loading: boolean;
|
||||
failed: boolean;
|
||||
section: Forkop.OutboundGroup;
|
||||
onTestLatency: (tag: string | string[]) => void;
|
||||
onChooseOutbound: (
|
||||
sectionName: string,
|
||||
selector: string,
|
||||
tag: string,
|
||||
) => void;
|
||||
onCopyOutbound: (
|
||||
section: Forkop.OutboundGroup,
|
||||
outbound: Forkop.Outbound,
|
||||
) => void;
|
||||
onShowUrlTestInfo: (outbound: Forkop.Outbound) => void;
|
||||
onShowPriorityInfo: (outbound: Forkop.Outbound) => void;
|
||||
onUpdateSubscription: (section: Forkop.OutboundGroup) => void;
|
||||
latencyFetching: boolean;
|
||||
latencyProgress?: Forkop.LatencyActionProgress;
|
||||
subscriptionUpdating: boolean;
|
||||
selectorSwitchingTag?: string;
|
||||
}
|
||||
|
||||
function renderFailedState() {
|
||||
return E(
|
||||
'div',
|
||||
{
|
||||
class: 'fkp_dashboard-page__outbound-section centered',
|
||||
style: 'height: 127px',
|
||||
},
|
||||
E('span', {}, [E('span', {}, _('Dashboard currently unavailable'))]),
|
||||
);
|
||||
}
|
||||
|
||||
function renderLoadingState() {
|
||||
return E('div', {
|
||||
id: 'dashboard-sections-grid-skeleton',
|
||||
class: 'fkp_dashboard-page__outbound-section skeleton',
|
||||
style: 'height: 127px',
|
||||
});
|
||||
}
|
||||
|
||||
function isValidHttpUrl(url?: string) {
|
||||
return Boolean(url && /^https?:\/\/\S+$/i.test(url));
|
||||
}
|
||||
|
||||
function formatBytes(value?: number) {
|
||||
if (typeof value !== 'number' || !Number.isFinite(value) || value < 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return prettyBytes(value);
|
||||
}
|
||||
|
||||
function formatDate(seconds?: number) {
|
||||
if (
|
||||
typeof seconds !== 'number' ||
|
||||
!Number.isFinite(seconds) ||
|
||||
seconds <= 0
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const date = new Date(seconds * 1000);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return date.toLocaleDateString(undefined, {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
function renderMetadataAction(label: string, url?: string) {
|
||||
if (!isValidHttpUrl(url)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return E(
|
||||
'a',
|
||||
{
|
||||
class: 'btn fkp_dashboard-page__subscription-meta__action',
|
||||
href: url,
|
||||
target: '_blank',
|
||||
rel: 'noopener noreferrer',
|
||||
title: label,
|
||||
'aria-label': label,
|
||||
},
|
||||
renderLinkIcon24(),
|
||||
);
|
||||
}
|
||||
|
||||
function renderSubscriptionMetadata(
|
||||
metadata: Forkop.SubscriptionMetadata | undefined,
|
||||
) {
|
||||
if (!metadata || Object.keys(metadata).length <= 1) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const title = metadata.title || metadata.fileName;
|
||||
const traffic = metadata.traffic;
|
||||
const used = formatBytes(traffic?.used) || '0 B';
|
||||
const total = traffic?.isUnlimited
|
||||
? '∞'
|
||||
: formatBytes(traffic?.total) || '0 B';
|
||||
const expire = formatDate(metadata.expire);
|
||||
const refillDate = formatDate(metadata.refillDate);
|
||||
|
||||
const rows = [
|
||||
traffic
|
||||
? {
|
||||
label: _('Traffic'),
|
||||
value: `${used} / ${total}`,
|
||||
}
|
||||
: undefined,
|
||||
expire ? { label: _('Expires'), value: expire } : undefined,
|
||||
refillDate ? { label: _('Refill'), value: refillDate } : undefined,
|
||||
].filter(Boolean) as { label: string; value: string }[];
|
||||
|
||||
const actions = [
|
||||
renderMetadataAction('Profile', metadata.webPageUrl),
|
||||
renderMetadataAction('Support', metadata.supportUrl),
|
||||
renderMetadataAction('More details', metadata.announceUrl),
|
||||
].filter(Boolean) as HTMLElement[];
|
||||
|
||||
return E('div', { class: 'fkp_dashboard-page__subscription-meta' }, [
|
||||
E('div', { class: 'fkp_dashboard-page__subscription-meta__main' }, [
|
||||
E(
|
||||
'div',
|
||||
{ class: 'fkp_dashboard-page__subscription-meta__heading' },
|
||||
_('Subscription info:'),
|
||||
),
|
||||
title
|
||||
? E(
|
||||
'div',
|
||||
{ class: 'fkp_dashboard-page__subscription-meta__title' },
|
||||
title,
|
||||
)
|
||||
: '',
|
||||
rows.length
|
||||
? E(
|
||||
'div',
|
||||
{ class: 'fkp_dashboard-page__subscription-meta__facts' },
|
||||
rows.map((row) =>
|
||||
E(
|
||||
'div',
|
||||
{ class: 'fkp_dashboard-page__subscription-meta__fact' },
|
||||
[
|
||||
E(
|
||||
'span',
|
||||
{
|
||||
class: 'fkp_dashboard-page__subscription-meta__fact-key',
|
||||
},
|
||||
row.label,
|
||||
),
|
||||
E(
|
||||
'span',
|
||||
{
|
||||
class:
|
||||
'fkp_dashboard-page__subscription-meta__fact-value',
|
||||
},
|
||||
row.value,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
)
|
||||
: '',
|
||||
actions.length
|
||||
? E(
|
||||
'div',
|
||||
{ class: 'fkp_dashboard-page__subscription-meta__actions' },
|
||||
actions,
|
||||
)
|
||||
: '',
|
||||
]),
|
||||
metadata.announce
|
||||
? E(
|
||||
'blockquote',
|
||||
{ class: 'fkp_dashboard-page__subscription-meta__announce' },
|
||||
metadata.announce,
|
||||
)
|
||||
: '',
|
||||
]);
|
||||
}
|
||||
|
||||
function renderSubscriptionUpdateAction(
|
||||
section: Forkop.OutboundGroup,
|
||||
subscriptionUpdating: boolean,
|
||||
onUpdateSubscription: (section: Forkop.OutboundGroup) => void,
|
||||
) {
|
||||
if (!section.subscriptionSourceCount) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return E(
|
||||
'button',
|
||||
{
|
||||
type: 'button',
|
||||
class: 'btn fkp_dashboard-page__outbound-section__subscription-update',
|
||||
'aria-label': _('Update subscriptions'),
|
||||
disabled: subscriptionUpdating ? true : undefined,
|
||||
click: (event: MouseEvent) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
if (subscriptionUpdating) {
|
||||
return;
|
||||
}
|
||||
|
||||
onUpdateSubscription(section);
|
||||
},
|
||||
},
|
||||
subscriptionUpdating
|
||||
? [renderLoaderCircleIcon24(), _('Update subscriptions')]
|
||||
: _('Update subscriptions'),
|
||||
);
|
||||
}
|
||||
|
||||
export function getLatencyTestLabel(
|
||||
latencyProgress?: Forkop.LatencyActionProgress,
|
||||
) {
|
||||
const total = Math.trunc(Number(latencyProgress?.total ?? 0));
|
||||
if (!Number.isFinite(total) || total <= 0) {
|
||||
return _('Test latency');
|
||||
}
|
||||
|
||||
const completedValue = Number(latencyProgress?.completed ?? 0);
|
||||
const completed = Number.isFinite(completedValue)
|
||||
? Math.trunc(completedValue)
|
||||
: 0;
|
||||
|
||||
return `${_('Test latency')}: ${Math.min(
|
||||
Math.max(0, completed),
|
||||
total,
|
||||
)}/${total}`;
|
||||
}
|
||||
|
||||
function renderDefaultState({
|
||||
section,
|
||||
onChooseOutbound,
|
||||
onCopyOutbound,
|
||||
onShowUrlTestInfo,
|
||||
onShowPriorityInfo,
|
||||
onTestLatency,
|
||||
onUpdateSubscription,
|
||||
latencyFetching,
|
||||
latencyProgress,
|
||||
subscriptionUpdating,
|
||||
selectorSwitchingTag,
|
||||
}: IRenderSectionsProps) {
|
||||
function testLatency() {
|
||||
if (section.withTagSelect) {
|
||||
return onTestLatency(
|
||||
section.latencyTestCodes?.length
|
||||
? section.latencyTestCodes
|
||||
: section.latencyTestCode || section.code,
|
||||
);
|
||||
}
|
||||
|
||||
if (section.outbounds.length) {
|
||||
return onTestLatency(section.outbounds[0].code);
|
||||
}
|
||||
}
|
||||
|
||||
function renderOutbound(outbound: Forkop.Outbound) {
|
||||
function getLatencyClass() {
|
||||
if (!outbound.latency) {
|
||||
return 'fkp_dashboard-page__outbound-grid__item__latency--empty';
|
||||
}
|
||||
|
||||
if (outbound.latency < 800) {
|
||||
return 'fkp_dashboard-page__outbound-grid__item__latency--green';
|
||||
}
|
||||
|
||||
if (outbound.latency < 1500) {
|
||||
return 'fkp_dashboard-page__outbound-grid__item__latency--yellow';
|
||||
}
|
||||
|
||||
return 'fkp_dashboard-page__outbound-grid__item__latency--red';
|
||||
}
|
||||
|
||||
const canCopyLink =
|
||||
Boolean(outbound.canCopyLink) || isCopyableProxyLink(outbound.link);
|
||||
const selectorSwitching = Boolean(selectorSwitchingTag);
|
||||
const outboundSwitching = selectorSwitchingTag === outbound.code;
|
||||
const canChooseOutbound =
|
||||
section.withTagSelect &&
|
||||
outbound.runtimeAvailable !== false &&
|
||||
!selectorSwitching &&
|
||||
!outbound.selected;
|
||||
const className = [
|
||||
'fkp_dashboard-page__outbound-grid__item',
|
||||
outbound.selected
|
||||
? 'fkp_dashboard-page__outbound-grid__item--active'
|
||||
: '',
|
||||
canChooseOutbound
|
||||
? 'fkp_dashboard-page__outbound-grid__item--selectable'
|
||||
: '',
|
||||
section.withTagSelect && !canChooseOutbound
|
||||
? 'fkp_dashboard-page__outbound-grid__item--disabled'
|
||||
: '',
|
||||
outboundSwitching
|
||||
? 'fkp_dashboard-page__outbound-grid__item--switching'
|
||||
: '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
return E(
|
||||
'div',
|
||||
{
|
||||
class: className,
|
||||
'aria-busy': outboundSwitching ? 'true' : undefined,
|
||||
'aria-disabled':
|
||||
section.withTagSelect && !canChooseOutbound ? 'true' : undefined,
|
||||
click: () =>
|
||||
canChooseOutbound &&
|
||||
onChooseOutbound(section.sectionName, section.code, outbound.code),
|
||||
},
|
||||
[
|
||||
...(outboundSwitching
|
||||
? [
|
||||
svgEl(
|
||||
'svg',
|
||||
{ class: 'fkp_dashboard-page__outbound-grid__item__snake' },
|
||||
[
|
||||
svgEl('rect', {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
fill: 'none',
|
||||
rx: 4,
|
||||
ry: 4,
|
||||
pathLength: 100,
|
||||
}),
|
||||
],
|
||||
),
|
||||
]
|
||||
: []),
|
||||
E('div', { class: 'fkp_dashboard-page__outbound-grid__item__header' }, [
|
||||
E('b', {}, outbound.displayName),
|
||||
...(canCopyLink
|
||||
? [
|
||||
E(
|
||||
'button',
|
||||
{
|
||||
type: 'button',
|
||||
class:
|
||||
'btn fkp_dashboard-page__outbound-grid__item__copy-button',
|
||||
title: _('Copy proxy link'),
|
||||
'aria-label': _('Copy proxy link'),
|
||||
click: (event: MouseEvent) => {
|
||||
event.stopPropagation();
|
||||
onCopyOutbound(section, outbound);
|
||||
},
|
||||
},
|
||||
renderCopyIcon24(),
|
||||
),
|
||||
]
|
||||
: []),
|
||||
...(outbound.urlTestInfo
|
||||
? [
|
||||
E(
|
||||
'button',
|
||||
{
|
||||
type: 'button',
|
||||
class:
|
||||
'btn fkp_dashboard-page__outbound-grid__item__copy-button',
|
||||
title: _('URLTest details'),
|
||||
'aria-label': _('URLTest details'),
|
||||
click: (event: MouseEvent) => {
|
||||
event.stopPropagation();
|
||||
onShowUrlTestInfo(outbound);
|
||||
},
|
||||
},
|
||||
renderInfoIcon24(),
|
||||
),
|
||||
]
|
||||
: []),
|
||||
...(outbound.priorityInfo
|
||||
? [
|
||||
E(
|
||||
'button',
|
||||
{
|
||||
type: 'button',
|
||||
class:
|
||||
'btn fkp_dashboard-page__outbound-grid__item__copy-button',
|
||||
title: _('Priority details'),
|
||||
'aria-label': _('Priority details'),
|
||||
click: (event: MouseEvent) => {
|
||||
event.stopPropagation();
|
||||
onShowPriorityInfo(outbound);
|
||||
},
|
||||
},
|
||||
renderInfoIcon24(),
|
||||
),
|
||||
]
|
||||
: []),
|
||||
]),
|
||||
E('div', { class: 'fkp_dashboard-page__outbound-grid__item__footer' }, [
|
||||
E(
|
||||
'div',
|
||||
{ class: 'fkp_dashboard-page__outbound-grid__item__type' },
|
||||
[outbound.type].filter(Boolean),
|
||||
),
|
||||
E(
|
||||
'div',
|
||||
{ class: getLatencyClass() },
|
||||
outbound.latency ? `${outbound.latency}ms` : 'N/A',
|
||||
),
|
||||
]),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
const metadataNodes = (section.subscriptionMetadata || [])
|
||||
.map((metadata) => renderSubscriptionMetadata(metadata))
|
||||
.filter(Boolean) as HTMLElement[];
|
||||
const subscriptionUpdateAction = renderSubscriptionUpdateAction(
|
||||
section,
|
||||
subscriptionUpdating,
|
||||
onUpdateSubscription,
|
||||
);
|
||||
|
||||
return E('div', { class: 'fkp_dashboard-page__outbound-section' }, [
|
||||
// Title with test latency
|
||||
E('div', { class: 'fkp_dashboard-page__outbound-section__title-section' }, [
|
||||
E(
|
||||
'div',
|
||||
{
|
||||
class: 'fkp_dashboard-page__outbound-section__title-section__title',
|
||||
},
|
||||
section.displayName,
|
||||
),
|
||||
E(
|
||||
'div',
|
||||
{
|
||||
class: 'fkp_dashboard-page__outbound-section__title-section__actions',
|
||||
},
|
||||
[
|
||||
...(subscriptionUpdateAction ? [subscriptionUpdateAction] : []),
|
||||
E(
|
||||
'button',
|
||||
{
|
||||
type: 'button',
|
||||
class: 'btn dashboard-sections-grid-item-test-latency',
|
||||
'data-latency-section': section.sectionName,
|
||||
disabled: latencyFetching ? true : undefined,
|
||||
click: (event: MouseEvent) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
if (latencyFetching) {
|
||||
return;
|
||||
}
|
||||
|
||||
testLatency();
|
||||
},
|
||||
},
|
||||
latencyFetching
|
||||
? [
|
||||
renderLoaderCircleIcon24(),
|
||||
E(
|
||||
'span',
|
||||
{
|
||||
class: 'dashboard-sections-grid-item-test-latency__label',
|
||||
},
|
||||
getLatencyTestLabel(latencyProgress),
|
||||
),
|
||||
]
|
||||
: E(
|
||||
'span',
|
||||
{
|
||||
class: 'dashboard-sections-grid-item-test-latency__label',
|
||||
},
|
||||
_('Test latency'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
]),
|
||||
E('div', { class: 'fkp_dashboard-page__outbound-grid' }, [
|
||||
...metadataNodes,
|
||||
...section.outbounds.map((outbound) => renderOutbound(outbound)),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
|
||||
export function renderSections(props: IRenderSectionsProps) {
|
||||
if (props.failed) {
|
||||
return renderFailedState();
|
||||
}
|
||||
|
||||
if (props.loading) {
|
||||
return renderLoadingState();
|
||||
}
|
||||
|
||||
return renderDefaultState(props);
|
||||
}
|
||||
@@ -1,78 +0,0 @@
|
||||
interface IRenderWidgetProps {
|
||||
loading: boolean;
|
||||
failed: boolean;
|
||||
title: string;
|
||||
items: Array<{
|
||||
key: string;
|
||||
value: string;
|
||||
attributes?: {
|
||||
class?: string;
|
||||
};
|
||||
}>;
|
||||
}
|
||||
|
||||
function renderFailedState() {
|
||||
return E(
|
||||
'div',
|
||||
{
|
||||
id: '',
|
||||
style: 'height: 78px',
|
||||
class: 'fkp_dashboard-page__widgets-section__item centered',
|
||||
},
|
||||
_('Currently unavailable'),
|
||||
);
|
||||
}
|
||||
|
||||
function renderLoadingState() {
|
||||
return E(
|
||||
'div',
|
||||
{
|
||||
id: '',
|
||||
style: 'height: 78px',
|
||||
class: 'fkp_dashboard-page__widgets-section__item skeleton',
|
||||
},
|
||||
'',
|
||||
);
|
||||
}
|
||||
|
||||
function renderDefaultState({ title, items }: IRenderWidgetProps) {
|
||||
return E('div', { class: 'fkp_dashboard-page__widgets-section__item' }, [
|
||||
E(
|
||||
'b',
|
||||
{ class: 'fkp_dashboard-page__widgets-section__item__title' },
|
||||
title,
|
||||
),
|
||||
...items.map((item) =>
|
||||
E(
|
||||
'div',
|
||||
{
|
||||
class: `fkp_dashboard-page__widgets-section__item__row ${item?.attributes?.class || ''}`,
|
||||
},
|
||||
[
|
||||
E(
|
||||
'span',
|
||||
{ class: 'fkp_dashboard-page__widgets-section__item__row__key' },
|
||||
`${item.key}: `,
|
||||
),
|
||||
E(
|
||||
'span',
|
||||
{ class: 'fkp_dashboard-page__widgets-section__item__row__value' },
|
||||
item.value,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
]);
|
||||
}
|
||||
|
||||
export function renderWidget(props: IRenderWidgetProps) {
|
||||
if (props.loading) {
|
||||
return renderLoadingState();
|
||||
}
|
||||
|
||||
if (props.failed) {
|
||||
return renderFailedState();
|
||||
}
|
||||
|
||||
return renderDefaultState(props);
|
||||
}
|
||||
@@ -1,94 +0,0 @@
|
||||
import { renderSections, renderWidget } from './partials';
|
||||
|
||||
export function render() {
|
||||
return E(
|
||||
'div',
|
||||
{
|
||||
id: 'dashboard-status',
|
||||
class: 'fkp_dashboard-page',
|
||||
},
|
||||
[
|
||||
E(
|
||||
'div',
|
||||
{
|
||||
class: 'fkp_dashboard-page__service-stopped',
|
||||
role: 'status',
|
||||
},
|
||||
_(
|
||||
'Forkop service is stopped. Start the service to display the dashboard.',
|
||||
),
|
||||
),
|
||||
E('div', { class: 'fkp_dashboard-page__content' }, [
|
||||
// Widgets section
|
||||
E('div', { class: 'fkp_dashboard-page__widgets-section' }, [
|
||||
E(
|
||||
'div',
|
||||
{ id: 'dashboard-widget-traffic' },
|
||||
renderWidget({
|
||||
loading: true,
|
||||
failed: false,
|
||||
title: '',
|
||||
items: [],
|
||||
}),
|
||||
),
|
||||
E(
|
||||
'div',
|
||||
{ id: 'dashboard-widget-traffic-total' },
|
||||
renderWidget({
|
||||
loading: true,
|
||||
failed: false,
|
||||
title: '',
|
||||
items: [],
|
||||
}),
|
||||
),
|
||||
E(
|
||||
'div',
|
||||
{ id: 'dashboard-widget-system-info' },
|
||||
renderWidget({
|
||||
loading: true,
|
||||
failed: false,
|
||||
title: '',
|
||||
items: [],
|
||||
}),
|
||||
),
|
||||
E(
|
||||
'div',
|
||||
{ id: 'dashboard-widget-service-info' },
|
||||
renderWidget({
|
||||
loading: true,
|
||||
failed: false,
|
||||
title: '',
|
||||
items: [],
|
||||
}),
|
||||
),
|
||||
]),
|
||||
// All outbounds
|
||||
E(
|
||||
'div',
|
||||
{ id: 'dashboard-sections-grid' },
|
||||
renderSections({
|
||||
loading: true,
|
||||
failed: false,
|
||||
section: {
|
||||
code: '',
|
||||
sectionName: '',
|
||||
displayName: '',
|
||||
outbounds: [],
|
||||
withTagSelect: false,
|
||||
},
|
||||
onTestLatency: () => {},
|
||||
onChooseOutbound: () => {},
|
||||
onCopyOutbound: () => {},
|
||||
onShowUrlTestInfo: () => {},
|
||||
onShowPriorityInfo: () => {},
|
||||
onUpdateSubscription: () => {},
|
||||
latencyFetching: false,
|
||||
latencyProgress: undefined,
|
||||
subscriptionUpdating: false,
|
||||
selectorSwitchingTag: undefined,
|
||||
}),
|
||||
),
|
||||
]),
|
||||
],
|
||||
);
|
||||
}
|
||||
@@ -1,678 +0,0 @@
|
||||
// language=CSS
|
||||
import { FORKOP_UCI_PACKAGE as FORKOP_CBI_PREFIX } from '../../../constants';
|
||||
|
||||
export const styles = `
|
||||
#cbi-${FORKOP_CBI_PREFIX}-dashboard-_mount_node > .cbi-value-title {
|
||||
display: none;
|
||||
}
|
||||
|
||||
#cbi-${FORKOP_CBI_PREFIX}-dashboard-_mount_node > .cbi-value-field {
|
||||
margin-left: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
#cbi-${FORKOP_CBI_PREFIX}-dashboard-_mount_node > div {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
#cbi-${FORKOP_CBI_PREFIX}-dashboard > h3 {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.fkp_dashboard-page {
|
||||
width: 100%;
|
||||
--dashboard-grid-columns: 4;
|
||||
--dashboard-grid-min-width: 180px;
|
||||
}
|
||||
|
||||
.fkp_dashboard-page__service-stopped {
|
||||
display: none;
|
||||
width: 100%;
|
||||
min-height: 180px;
|
||||
margin-top: 10px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 20px;
|
||||
box-sizing: border-box;
|
||||
border: 1px dashed var(--border-color-high, #555);
|
||||
border-radius: 6px;
|
||||
color: var(--text-color-medium, #888);
|
||||
background: transparent;
|
||||
font-family: inherit;
|
||||
font-size: inherit;
|
||||
font-weight: inherit;
|
||||
line-height: inherit;
|
||||
font-style: italic;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.fkp_dashboard-page--service-stopped {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(var(--dashboard-grid-columns), minmax(var(--dashboard-grid-min-width), 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.fkp_dashboard-page--service-stopped .fkp_dashboard-page__service-stopped {
|
||||
display: flex;
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.fkp_dashboard-page--service-stopped .fkp_dashboard-page__content {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.fkp_dashboard-page {
|
||||
--dashboard-grid-columns: 2;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 560px) {
|
||||
.fkp_dashboard-page {
|
||||
--dashboard-grid-columns: 1;
|
||||
--dashboard-grid-min-width: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.fkp_dashboard-page__widgets-section {
|
||||
margin-top: 10px;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(var(--dashboard-grid-columns), minmax(var(--dashboard-grid-min-width), 1fr));
|
||||
grid-gap: 10px;
|
||||
}
|
||||
|
||||
.fkp_dashboard-page__widgets-section__item {
|
||||
border: 2px var(--background-color-low, lightgray) solid;
|
||||
border-radius: 4px;
|
||||
padding: 10px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.fkp_dashboard-page__widgets-section__item__title {}
|
||||
|
||||
.fkp_dashboard-page__widgets-section__item__row {}
|
||||
|
||||
.fkp_dashboard-page__widgets-section__item__row--success .fkp_dashboard-page__widgets-section__item__row__value {
|
||||
color: var(--success-color-medium, green);
|
||||
}
|
||||
|
||||
.fkp_dashboard-page__widgets-section__item__row--error .fkp_dashboard-page__widgets-section__item__row__value {
|
||||
color: var(--error-color-medium, red);
|
||||
}
|
||||
|
||||
.fkp_dashboard-page__widgets-section__item__row__key {}
|
||||
|
||||
.fkp_dashboard-page__widgets-section__item__row__value {}
|
||||
|
||||
.fkp_dashboard-page__outbound-section {
|
||||
margin-top: 10px;
|
||||
border: 2px var(--background-color-low, lightgray) solid;
|
||||
border-radius: 4px;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.fkp_dashboard-page__outbound-section__title-section {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px 10px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.fkp_dashboard-page__outbound-section__title-section__title {
|
||||
color: var(--text-color-high);
|
||||
font-weight: 700;
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.fkp_dashboard-page__outbound-section__title-section__actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 6px;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.fkp_dashboard-page .btn.fkp_dashboard-page__outbound-section__subscription-update {
|
||||
min-width: 130px;
|
||||
min-height: 28px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.fkp_dashboard-page__outbound-section__subscription-update svg {
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
display: block;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.fkp_dashboard-page__outbound-section__subscription-update[disabled] {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.65;
|
||||
}
|
||||
|
||||
.fkp_dashboard-page .btn.dashboard-sections-grid-item-test-latency {
|
||||
min-width: 99px;
|
||||
min-height: 28px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.fkp_dashboard-page .btn.dashboard-sections-grid-item-test-latency svg {
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
display: block;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.fkp_dashboard-page .btn.dashboard-sections-grid-item-test-latency[disabled] {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.65;
|
||||
}
|
||||
|
||||
.fkp_dashboard-page__outbound-grid {
|
||||
margin-top: 5px;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(var(--dashboard-grid-columns), minmax(var(--dashboard-grid-min-width), 1fr));
|
||||
grid-gap: 10px;
|
||||
}
|
||||
|
||||
.fkp_dashboard-page__subscription-meta {
|
||||
--subscription-meta-action-size: 28px;
|
||||
--subscription-meta-action-gap: 6px;
|
||||
grid-column: 1 / -1;
|
||||
border: 2px var(--background-color-low, lightgray) solid;
|
||||
border-radius: 4px;
|
||||
padding: 8px 10px;
|
||||
background: var(--background-color-high, transparent);
|
||||
}
|
||||
|
||||
.fkp_dashboard-page__subscription-meta__main {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px 10px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.fkp_dashboard-page__subscription-meta__heading {
|
||||
flex: 0 0 auto;
|
||||
color: var(--text-color-high);
|
||||
font-weight: 700;
|
||||
line-height: 1.25;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.fkp_dashboard-page__subscription-meta__title {
|
||||
flex: 0 1 auto;
|
||||
width: max-content;
|
||||
max-width: min(28ch, 30%);
|
||||
min-width: min-content;
|
||||
color: var(--text-color-high);
|
||||
font-weight: 700;
|
||||
line-height: 1.25;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.fkp_dashboard-page__subscription-meta__facts {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 5px 12px;
|
||||
}
|
||||
|
||||
.fkp_dashboard-page__subscription-meta__fact {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.fkp_dashboard-page__subscription-meta__fact-key {
|
||||
color: var(--text-color-medium);
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.fkp_dashboard-page__subscription-meta__fact-value {
|
||||
color: var(--text-color-high);
|
||||
font-weight: 600;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.fkp_dashboard-page__subscription-meta__actions {
|
||||
flex: 0 0 auto;
|
||||
margin-left: auto;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: var(--subscription-meta-action-gap);
|
||||
}
|
||||
|
||||
.fkp_dashboard-page .btn.fkp_dashboard-page__subscription-meta__action {
|
||||
width: var(--subscription-meta-action-size);
|
||||
height: var(--subscription-meta-action-size);
|
||||
min-width: var(--subscription-meta-action-size);
|
||||
min-height: var(--subscription-meta-action-size);
|
||||
padding: 2px;
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: 0 0 auto;
|
||||
line-height: 1;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.fkp_dashboard-page__subscription-meta__action svg {
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
display: block;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.fkp_dashboard-page__subscription-meta__announce {
|
||||
margin: 6px 0 0;
|
||||
border-left: 3px solid var(--primary-color-medium, dodgerblue);
|
||||
padding: 4px 8px;
|
||||
background: var(--background-color-low, rgba(0, 0, 0, 0.04));
|
||||
color: var(--text-color-medium);
|
||||
font-style: italic;
|
||||
line-height: 1.25;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
@media (max-width: 700px) {
|
||||
.fkp_dashboard-page__subscription-meta__main {
|
||||
align-items: flex-start;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.fkp_dashboard-page__subscription-meta__heading,
|
||||
.fkp_dashboard-page__subscription-meta__title {
|
||||
order: 1;
|
||||
}
|
||||
|
||||
.fkp_dashboard-page__subscription-meta__actions {
|
||||
order: 2;
|
||||
}
|
||||
|
||||
.fkp_dashboard-page__subscription-meta__facts {
|
||||
order: 3;
|
||||
flex-basis: 100%;
|
||||
}
|
||||
|
||||
.fkp_dashboard-page__subscription-meta__title {
|
||||
max-width: calc(100% - 42px);
|
||||
}
|
||||
}
|
||||
|
||||
.fkp_dashboard-page__outbound-grid__item {
|
||||
border: 2px var(--background-color-low, lightgray) solid;
|
||||
border-radius: 4px;
|
||||
padding: 10px;
|
||||
transition: border 0.2s ease;
|
||||
min-width: 0;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.fkp_dashboard-page__outbound-grid__item--selectable {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.fkp_dashboard-page__outbound-grid__item--selectable:hover {
|
||||
border-color: var(--primary-color-high, dodgerblue);
|
||||
}
|
||||
|
||||
.fkp_dashboard-page__outbound-grid__item--active {
|
||||
border-color: var(--success-color-medium, green);
|
||||
}
|
||||
|
||||
.fkp_dashboard-page__outbound-grid__item--disabled {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.fkp_dashboard-page__outbound-grid__item--switching {
|
||||
border-color: transparent !important;
|
||||
overflow: hidden;
|
||||
cursor: wait;
|
||||
}
|
||||
|
||||
.fkp_dashboard-page__outbound-grid__item__snake {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
pointer-events: none;
|
||||
z-index: 9999;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.fkp_dashboard-page__outbound-grid__item__snake rect {
|
||||
stroke: var(--primary-color-high, dodgerblue);
|
||||
stroke-width: 4;
|
||||
animation: fkp-dashboard-selector-snake-svg 1.2s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes fkp-dashboard-selector-snake-svg {
|
||||
0% {
|
||||
stroke-dasharray: 30 70;
|
||||
stroke-dashoffset: 100;
|
||||
}
|
||||
100% {
|
||||
stroke-dasharray: 30 70;
|
||||
stroke-dashoffset: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.fkp_dashboard-page__outbound-grid__item__header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.fkp_dashboard-page__outbound-grid__item__header b {
|
||||
min-width: 0;
|
||||
line-height: 1.25;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.fkp_dashboard-page .btn.fkp_dashboard-page__outbound-grid__item__copy-button {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
min-width: 22px;
|
||||
min-height: 22px;
|
||||
padding: 1px;
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: 0 0 auto;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.fkp_dashboard-page__outbound-grid__item__copy-button svg {
|
||||
width: 13px;
|
||||
height: 13px;
|
||||
display: block;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.fkp_dashboard-page__outbound-grid__item__footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.fkp_dashboard-page__outbound-grid__item__type {
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.fkp_dashboard-page__outbound-grid__item__latency--empty {
|
||||
color: var(--primary-color-low, lightgray);
|
||||
}
|
||||
|
||||
.fkp_dashboard-page__outbound-grid__item__latency--green {
|
||||
color: var(--success-color-medium, green);
|
||||
}
|
||||
|
||||
.fkp_dashboard-page__outbound-grid__item__latency--yellow {
|
||||
color: var(--warn-color-medium, orange);
|
||||
}
|
||||
|
||||
.fkp_dashboard-page__outbound-grid__item__latency--red {
|
||||
color: var(--error-color-medium, red);
|
||||
}
|
||||
|
||||
.fkp_dashboard-page__urltest-details {
|
||||
box-sizing: border-box;
|
||||
width: min(760px, calc(100vw - 56px));
|
||||
max-width: 100%;
|
||||
padding-top: 10px;
|
||||
}
|
||||
|
||||
.fkp_dashboard-page__urltest-details__params {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(120px, max-content) minmax(0, 1fr);
|
||||
gap: 8px 16px;
|
||||
margin: 0 0 18px;
|
||||
}
|
||||
|
||||
.fkp_dashboard-page__urltest-details__param {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
.fkp_dashboard-page__urltest-details__param dt {
|
||||
color: var(--text-color-medium, #666);
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.fkp_dashboard-page__urltest-details__param dd {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.fkp_dashboard-page__urltest-details__param dd span {
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.fkp_dashboard-page__urltest-details__url {
|
||||
min-width: 0;
|
||||
color: var(--primary-color-high, #337ab7);
|
||||
text-decoration: none;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.fkp_dashboard-page__urltest-details__url:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.fkp_dashboard-page__urltest-details__selected-value {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
max-width: 100%;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
color: inherit;
|
||||
background: transparent;
|
||||
box-sizing: border-box;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.fkp_dashboard-page__urltest-details__selected-name {
|
||||
min-width: 0;
|
||||
font-weight: 600;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.fkp_dashboard-page__urltest-details__selected-type {
|
||||
color: var(--text-color-medium, #666);
|
||||
}
|
||||
|
||||
.fkp_dashboard-page__urltest-details__outbounds-title {
|
||||
margin-bottom: 8px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.fkp_dashboard-page__urltest-details__table {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
width: calc(100% + 14px);
|
||||
box-sizing: border-box;
|
||||
max-height: min(46vh, 460px);
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
padding-right: 14px;
|
||||
scrollbar-gutter: auto;
|
||||
}
|
||||
|
||||
.fkp_dashboard-page__urltest-details__row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(54px, max-content) 20px;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
padding: 7px 8px;
|
||||
box-sizing: border-box;
|
||||
border: 1px solid transparent;
|
||||
border-bottom: 1px solid var(--border-color-low, #eee);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.fkp_dashboard-page__urltest-details__row--active {
|
||||
border-color: var(--success-color-low, #2d7d46);
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.fkp_dashboard-page__urltest-details__row-name,
|
||||
.fkp_dashboard-page__urltest-details__row-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.fkp_dashboard-page__urltest-details__row-name {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.fkp_dashboard-page__urltest-details__row-name b {
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.fkp_dashboard-page__urltest-details__priority-name {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 2px 0;
|
||||
}
|
||||
|
||||
.fkp_dashboard-page__urltest-details__priority-number {
|
||||
margin-right: 6px;
|
||||
color: var(--text-color-medium, #aaa);
|
||||
font-family: monospace;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.fkp_dashboard-page__urltest-details__priority-level {
|
||||
margin-right: 8px;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
color: var(--text-color-medium, #aaa);
|
||||
background: rgba(128, 128, 128, 0.15);
|
||||
font-size: 11px;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.fkp_dashboard-page__urltest-details__country-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
user-select: none;
|
||||
margin-right: 6px;
|
||||
padding: 2px 4px;
|
||||
border: 1px solid rgba(128, 128, 128, 0.25);
|
||||
border-radius: 4px;
|
||||
background: rgba(128, 128, 128, 0.15);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.fkp_dashboard-page__urltest-details__priority-node {
|
||||
color: var(--text-color-high, #fff);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.fkp_dashboard-page__urltest-details__row-type,
|
||||
.fkp_dashboard-page__urltest-details__row-meta {
|
||||
color: var(--text-color-medium, #666);
|
||||
}
|
||||
|
||||
.fkp_dashboard-page__urltest-details__row-type {
|
||||
white-space: nowrap;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.fkp_dashboard-page__urltest-details__row-meta {
|
||||
justify-content: flex-end;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.fkp_dashboard-page__urltest-details__copy-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: 0 0 20px;
|
||||
width: 20px;
|
||||
min-width: 20px;
|
||||
height: 20px;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.fkp_dashboard-page__urltest-details__copy-button svg {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
}
|
||||
|
||||
.fkp_dashboard-page__urltest-details__copy-placeholder {
|
||||
display: block;
|
||||
width: 20px;
|
||||
min-width: 20px;
|
||||
height: 1px;
|
||||
}
|
||||
|
||||
.fkp_dashboard-page__urltest-details__empty {
|
||||
margin-top: 4px;
|
||||
padding: 24px 0;
|
||||
border: 1px dashed var(--border-color-high, #555);
|
||||
border-radius: 4px;
|
||||
color: var(--text-color-medium, #888);
|
||||
background: rgba(128, 128, 128, 0.02);
|
||||
font-style: italic;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.fkp_dashboard-page__urltest-details__footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
@media (max-width: 560px) {
|
||||
.fkp_dashboard-page__urltest-details__params {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.fkp_dashboard-page__urltest-details__row {
|
||||
grid-template-columns: minmax(0, 1fr) 20px;
|
||||
}
|
||||
|
||||
.fkp_dashboard-page__urltest-details__row-meta {
|
||||
grid-column: 1 / -1;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
}
|
||||
|
||||
`;
|
||||
@@ -1,64 +0,0 @@
|
||||
import { getCheckTitle } from '../helpers/getCheckTitle';
|
||||
|
||||
export enum DIAGNOSTICS_CHECKS {
|
||||
DNS = 'DNS',
|
||||
SINGBOX = 'SINGBOX',
|
||||
NFT = 'NFT',
|
||||
ZAPRET = 'ZAPRET',
|
||||
ZAPRET2 = 'ZAPRET2',
|
||||
BYEDPI = 'BYEDPI',
|
||||
FAKEIP = 'FAKEIP',
|
||||
OUTBOUNDS = 'OUTBOUNDS',
|
||||
INBOUNDS = 'INBOUNDS',
|
||||
}
|
||||
|
||||
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: 4,
|
||||
title: getCheckTitle('Nftables'),
|
||||
code: DIAGNOSTICS_CHECKS.NFT,
|
||||
},
|
||||
[DIAGNOSTICS_CHECKS.ZAPRET]: {
|
||||
order: 5,
|
||||
title: getCheckTitle('Zapret'),
|
||||
code: DIAGNOSTICS_CHECKS.ZAPRET,
|
||||
},
|
||||
[DIAGNOSTICS_CHECKS.BYEDPI]: {
|
||||
order: 7,
|
||||
title: getCheckTitle('ByeDPI'),
|
||||
code: DIAGNOSTICS_CHECKS.BYEDPI,
|
||||
},
|
||||
[DIAGNOSTICS_CHECKS.ZAPRET2]: {
|
||||
order: 6,
|
||||
title: getCheckTitle('Zapret2'),
|
||||
code: DIAGNOSTICS_CHECKS.ZAPRET2,
|
||||
},
|
||||
[DIAGNOSTICS_CHECKS.OUTBOUNDS]: {
|
||||
order: 8,
|
||||
title: getCheckTitle('Outbounds'),
|
||||
code: DIAGNOSTICS_CHECKS.OUTBOUNDS,
|
||||
},
|
||||
[DIAGNOSTICS_CHECKS.FAKEIP]: {
|
||||
order: 9,
|
||||
title: getCheckTitle('FakeIP'),
|
||||
code: DIAGNOSTICS_CHECKS.FAKEIP,
|
||||
},
|
||||
[DIAGNOSTICS_CHECKS.INBOUNDS]: {
|
||||
order: 3,
|
||||
title: getCheckTitle('Inbounds'),
|
||||
code: DIAGNOSTICS_CHECKS.INBOUNDS,
|
||||
},
|
||||
};
|
||||
@@ -1,25 +0,0 @@
|
||||
import { IDiagnosticsChecksItem } from '../../../services';
|
||||
|
||||
export function getCheckItemsMeta(items: IDiagnosticsChecksItem[]): {
|
||||
description: string;
|
||||
state: 'warning' | 'success' | 'error';
|
||||
} {
|
||||
if (items.some((item) => item.state === 'error')) {
|
||||
return {
|
||||
state: 'error',
|
||||
description: _('Checks failed'),
|
||||
};
|
||||
}
|
||||
|
||||
if (items.some((item) => item.state === 'warning')) {
|
||||
return {
|
||||
state: 'warning',
|
||||
description: _('Issues detected'),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
state: 'success',
|
||||
description: _('Checks passed'),
|
||||
};
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
import type { Forkop } from '../../../types';
|
||||
import { getMeta } from '../helpers/getMeta';
|
||||
|
||||
type DnsCheckState = 'error' | 'success' | 'warning';
|
||||
|
||||
export function getDnsCheckPresentation(data: Forkop.DnsCheckResult) {
|
||||
const dhcpManagedManually = Boolean(data.dont_touch_dhcp);
|
||||
const dhcpCheckOk = dhcpManagedManually || Boolean(data.dhcp_config_status);
|
||||
|
||||
const allGood =
|
||||
Boolean(data.dns_on_router) &&
|
||||
dhcpCheckOk &&
|
||||
Boolean(data.bootstrap_dns_status) &&
|
||||
Boolean(data.dns_status);
|
||||
|
||||
const atLeastOneGood =
|
||||
Boolean(data.dns_on_router) ||
|
||||
dhcpCheckOk ||
|
||||
Boolean(data.bootstrap_dns_status) ||
|
||||
Boolean(data.dns_status);
|
||||
|
||||
const meta = getMeta({ atLeastOneGood, allGood });
|
||||
const state: DnsCheckState =
|
||||
dhcpManagedManually && meta.state === 'success' ? 'warning' : meta.state;
|
||||
const description =
|
||||
dhcpManagedManually && meta.state === 'success'
|
||||
? _('Checks passed with manual DHCP')
|
||||
: meta.description;
|
||||
|
||||
const dhcpItemState: DnsCheckState = dhcpManagedManually
|
||||
? 'warning'
|
||||
: data.dhcp_config_status
|
||||
? 'success'
|
||||
: 'error';
|
||||
const dhcpItemKey = dhcpManagedManually
|
||||
? _('DHCP is managed manually')
|
||||
: _('DHCP has DNS server');
|
||||
|
||||
return {
|
||||
state,
|
||||
description,
|
||||
dhcpItemState,
|
||||
dhcpItemKey,
|
||||
};
|
||||
}
|
||||
@@ -1,137 +0,0 @@
|
||||
import { DIAGNOSTICS_CHECKS_MAP } from './contstants';
|
||||
import { ForkopShellMethods } from '../../../methods';
|
||||
import { updateCheckStore } from './updateCheckStore';
|
||||
import { IDiagnosticsChecksItem } from '../../../services';
|
||||
import { getCheckItemsMeta } from './getCheckItemsMeta';
|
||||
|
||||
export async function runByedpiCheck() {
|
||||
const { order, title, code } = DIAGNOSTICS_CHECKS_MAP.BYEDPI;
|
||||
|
||||
updateCheckStore({
|
||||
order,
|
||||
code,
|
||||
title,
|
||||
description: _('Checking, please wait'),
|
||||
state: 'loading',
|
||||
items: [],
|
||||
});
|
||||
|
||||
const byedpiStatus = await ForkopShellMethods.getByedpiStatus();
|
||||
|
||||
if (!byedpiStatus.success) {
|
||||
updateCheckStore({
|
||||
order,
|
||||
code,
|
||||
title,
|
||||
description: _('Cannot receive checks result'),
|
||||
state: 'error',
|
||||
items: [],
|
||||
});
|
||||
|
||||
throw new Error('ByeDPI checks failed');
|
||||
}
|
||||
|
||||
const data = byedpiStatus.data;
|
||||
const providerAvailable = Boolean(data.provider_available ?? data.installed);
|
||||
const packageInstalled = Boolean(data.package_installed);
|
||||
const hasByedpiRules = Number(data.enabled_rule_count || 0) > 0;
|
||||
const expectedProcesses = Number(data.expected_process_count || 0);
|
||||
const runningProcesses = Number(data.running_process_count || 0);
|
||||
const supervisorProcesses = Number(data.supervisor_process_count || 0);
|
||||
const restartCount = Number(data.restart_count || 0);
|
||||
const runtimeUnstable = Boolean(data.runtime_unstable);
|
||||
const forkopRuntimeReady =
|
||||
!hasByedpiRules ||
|
||||
(runningProcesses === expectedProcesses &&
|
||||
supervisorProcesses === expectedProcesses);
|
||||
const unexpectedRuntime =
|
||||
!hasByedpiRules && (runningProcesses > 0 || supervisorProcesses > 0);
|
||||
const outboundsConfigured = Boolean(data.outbounds_configured);
|
||||
const standaloneServiceEnabled = Boolean(data.standalone_service_enabled);
|
||||
const standaloneServiceRunning = Boolean(data.standalone_service_running);
|
||||
const standaloneConflict = hasByedpiRules && standaloneServiceRunning;
|
||||
const standaloneAutostartRisk =
|
||||
hasByedpiRules && standaloneServiceEnabled && !standaloneServiceRunning;
|
||||
|
||||
const items: Array<IDiagnosticsChecksItem> = [
|
||||
{
|
||||
state: providerAvailable
|
||||
? 'success'
|
||||
: hasByedpiRules
|
||||
? 'error'
|
||||
: 'warning',
|
||||
key: providerAvailable
|
||||
? _('ByeDPI provider binary is available')
|
||||
: _('ByeDPI provider binary is not available'),
|
||||
value: data.provider_path || '',
|
||||
},
|
||||
{
|
||||
state: packageInstalled ? 'success' : 'warning',
|
||||
key: packageInstalled
|
||||
? _('ByeDPI package is installed')
|
||||
: _('ByeDPI package is not installed'),
|
||||
value: '',
|
||||
},
|
||||
{
|
||||
state: hasByedpiRules && !providerAvailable ? 'error' : 'success',
|
||||
key: hasByedpiRules
|
||||
? _('There are rules using ByeDPI')
|
||||
: _('No rules use ByeDPI'),
|
||||
value: '',
|
||||
},
|
||||
{
|
||||
state:
|
||||
unexpectedRuntime || !forkopRuntimeReady
|
||||
? 'error'
|
||||
: runtimeUnstable
|
||||
? 'warning'
|
||||
: 'success',
|
||||
key: hasByedpiRules
|
||||
? runtimeUnstable
|
||||
? _('Forkop-managed ciadpi runtime has restarted')
|
||||
: forkopRuntimeReady
|
||||
? _('Forkop-managed ciadpi runtime is ready')
|
||||
: _('Forkop-managed ciadpi runtime is not ready')
|
||||
: unexpectedRuntime
|
||||
? _('Unexpected Forkop-managed ciadpi runtime is running')
|
||||
: _('Forkop-managed ciadpi runtime is not running'),
|
||||
value: hasByedpiRules
|
||||
? runtimeUnstable
|
||||
? `${restartCount}`
|
||||
: `${runningProcesses}/${expectedProcesses}`
|
||||
: '',
|
||||
},
|
||||
{
|
||||
state: !hasByedpiRules || outboundsConfigured ? 'success' : 'error',
|
||||
key: outboundsConfigured
|
||||
? _('ByeDPI sing-box outbound is configured')
|
||||
: _('ByeDPI sing-box outbound is not configured'),
|
||||
value: `${data.listen_address}:${Number(data.port_base || 0)}`,
|
||||
},
|
||||
{
|
||||
state: standaloneConflict
|
||||
? 'error'
|
||||
: standaloneAutostartRisk
|
||||
? 'warning'
|
||||
: 'success',
|
||||
key: standaloneServiceRunning
|
||||
? hasByedpiRules
|
||||
? _('Standalone ByeDPI is active together with Forkop ByeDPI rules')
|
||||
: _('Standalone ByeDPI service is active')
|
||||
: standaloneAutostartRisk
|
||||
? _('Standalone ByeDPI autostart is enabled')
|
||||
: _('Standalone ByeDPI service is inactive'),
|
||||
value: '',
|
||||
},
|
||||
];
|
||||
const { state, description } = getCheckItemsMeta(items);
|
||||
|
||||
updateCheckStore({
|
||||
order,
|
||||
code,
|
||||
title,
|
||||
description,
|
||||
state,
|
||||
items,
|
||||
});
|
||||
}
|
||||
@@ -1,89 +0,0 @@
|
||||
import { insertIf } from '../../../../helpers';
|
||||
import { DIAGNOSTICS_CHECKS_MAP } from './contstants';
|
||||
import { ForkopShellMethods } from '../../../methods';
|
||||
import type { IDiagnosticsChecksItem } from '../../../services';
|
||||
import { updateCheckStore } from './updateCheckStore';
|
||||
import { getDnsCheckPresentation } from './getDnsCheckPresentation';
|
||||
|
||||
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 ForkopShellMethods.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 { state, description, dhcpItemState, dhcpItemKey } =
|
||||
getDnsCheckPresentation(data);
|
||||
|
||||
updateCheckStore({
|
||||
order,
|
||||
code,
|
||||
title,
|
||||
description,
|
||||
state,
|
||||
items: [
|
||||
...insertIf<IDiagnosticsChecksItem>(
|
||||
data.dns_type === 'doh' ||
|
||||
data.dns_type === 'dot' ||
|
||||
data.bootstrap_dns_server_count > 1 ||
|
||||
!data.bootstrap_dns_status,
|
||||
[
|
||||
{
|
||||
state: data.bootstrap_dns_status ? 'success' : 'error',
|
||||
key:
|
||||
data.bootstrap_dns_server_count > 1
|
||||
? _('Active Bootstrap DNS')
|
||||
: _('Bootstrap DNS'),
|
||||
value:
|
||||
data.bootstrap_dns_server_count > 1
|
||||
? `${data.bootstrap_dns_server} (${data.bootstrap_dns_server_index + 1}/${data.bootstrap_dns_server_count})`
|
||||
: data.bootstrap_dns_server,
|
||||
},
|
||||
],
|
||||
),
|
||||
{
|
||||
state: data.dns_status ? 'success' : 'error',
|
||||
key: data.dns_server_count > 1 ? _('Active Main DNS') : _('Main DNS'),
|
||||
value:
|
||||
data.dns_server_count > 1
|
||||
? `${data.dns_server} [${data.dns_type}] (${data.dns_server_index + 1}/${data.dns_server_count})`
|
||||
: `${data.dns_server} [${data.dns_type}]`,
|
||||
},
|
||||
{
|
||||
state: data.dns_on_router ? 'success' : 'error',
|
||||
key: _('DNS on router'),
|
||||
value: '',
|
||||
},
|
||||
{
|
||||
state: dhcpItemState,
|
||||
key: dhcpItemKey,
|
||||
value: '',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
if (state === 'error') {
|
||||
throw new Error('DNS checks failed');
|
||||
}
|
||||
}
|
||||
@@ -1,98 +0,0 @@
|
||||
import { insertIf } from '../../../../helpers';
|
||||
import { DIAGNOSTICS_CHECKS_MAP } from './contstants';
|
||||
import { ForkopShellMethods, RemoteFakeIPMethods } from '../../../methods';
|
||||
import type { 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 ForkopShellMethods.checkFakeIP();
|
||||
const checkFakeIPResponse = await RemoteFakeIPMethods.getFakeIpCheck();
|
||||
const checkIPResponse = await RemoteFakeIPMethods.getIpCheck();
|
||||
const browserFakeIPCheckUnavailable = !checkFakeIPResponse.success;
|
||||
const browserFakeIPCheckMessage = checkFakeIPResponse.success
|
||||
? ''
|
||||
: checkFakeIPResponse.message;
|
||||
|
||||
const checks = {
|
||||
singBoxFakeIP:
|
||||
routerFakeIPResponse.success && routerFakeIPResponse.data.fakeip,
|
||||
browserFakeIP:
|
||||
checkFakeIPResponse.success && checkFakeIPResponse.data.fakeip,
|
||||
canComparePublicIP: checkFakeIPResponse.success && checkIPResponse.success,
|
||||
differentIP:
|
||||
checkFakeIPResponse.success &&
|
||||
checkIPResponse.success &&
|
||||
checkFakeIPResponse.data.IP !== checkIPResponse.data.IP,
|
||||
};
|
||||
|
||||
const fakeIPWorks = checks.singBoxFakeIP && checks.browserFakeIP;
|
||||
const { state, description } = fakeIPWorks
|
||||
? checks.differentIP
|
||||
? { state: 'success' as const, description: _('Checks passed') }
|
||||
: {
|
||||
state: 'warning' as const,
|
||||
description: _('FakeIP works; public IP comparison is inconclusive'),
|
||||
}
|
||||
: browserFakeIPCheckUnavailable && checks.singBoxFakeIP
|
||||
? {
|
||||
state: 'warning' as const,
|
||||
description: _('Browser FakeIP check could not be completed'),
|
||||
}
|
||||
: getMeta({
|
||||
allGood: false,
|
||||
atLeastOneGood: checks.singBoxFakeIP || checks.browserFakeIP,
|
||||
});
|
||||
|
||||
updateCheckStore({
|
||||
order,
|
||||
code,
|
||||
title,
|
||||
description,
|
||||
state,
|
||||
items: [
|
||||
{
|
||||
state: checks.singBoxFakeIP ? 'success' : 'error',
|
||||
key: checks.singBoxFakeIP
|
||||
? _('Sing-box FakeIP DNS works')
|
||||
: _('Sing-box FakeIP DNS does not work'),
|
||||
value: routerFakeIPResponse.success ? routerFakeIPResponse.data.IP : '',
|
||||
},
|
||||
{
|
||||
state: browserFakeIPCheckUnavailable
|
||||
? 'warning'
|
||||
: checks.browserFakeIP
|
||||
? 'success'
|
||||
: 'error',
|
||||
key: browserFakeIPCheckUnavailable
|
||||
? _('Browser FakeIP check could not be completed')
|
||||
: checks.browserFakeIP
|
||||
? _('Browser is using FakeIP correctly')
|
||||
: _('Browser is not using FakeIP'),
|
||||
value: browserFakeIPCheckMessage,
|
||||
},
|
||||
...insertIf<IDiagnosticsChecksItem>(checks.browserFakeIP, [
|
||||
{
|
||||
state: checks.differentIP ? 'success' : 'warning',
|
||||
key: !checks.canComparePublicIP
|
||||
? _('Could not compare FakeIP and control public IPs')
|
||||
: checks.differentIP
|
||||
? _('FakeIP and control checks use different public IPs')
|
||||
: _('FakeIP and control checks use the same public IP'),
|
||||
value: '',
|
||||
},
|
||||
]),
|
||||
],
|
||||
});
|
||||
}
|
||||
@@ -1,223 +0,0 @@
|
||||
import { DIAGNOSTICS_CHECKS_MAP } from './contstants';
|
||||
import { ForkopShellMethods } from '../../../methods';
|
||||
import { updateCheckStore } from './updateCheckStore';
|
||||
import { getMeta } from '../helpers/getMeta';
|
||||
import { IDiagnosticsChecksItem } from '../../../services';
|
||||
import { Forkop } from '../../../types';
|
||||
|
||||
function serverPrefix(item: Forkop.InboundCheckItem) {
|
||||
return `${item.label}:`;
|
||||
}
|
||||
|
||||
function formatListen(item: Forkop.InboundCheckItem) {
|
||||
return `${item.listen}:${Number(item.listen_port || 0)} [${item.required_proto}]`;
|
||||
}
|
||||
|
||||
function getPortConflictItem(
|
||||
item: Forkop.InboundCheckItem,
|
||||
): IDiagnosticsChecksItem {
|
||||
const key = `${serverPrefix(item)} ${_('Port conflict')}`;
|
||||
|
||||
if (item.port_conflict === 1) {
|
||||
return {
|
||||
state: 'error',
|
||||
key,
|
||||
value: `${_('Used by')}: ${item.port_conflict_owners || _('unknown')}`,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
state: 'success',
|
||||
key,
|
||||
value: _('No conflict detected'),
|
||||
};
|
||||
}
|
||||
|
||||
function getPublicHostItem(
|
||||
item: Forkop.InboundCheckItem,
|
||||
wanIp: string,
|
||||
): IDiagnosticsChecksItem {
|
||||
const key = `${serverPrefix(item)} ${_('Public host')}`;
|
||||
|
||||
if (!item.public_host) {
|
||||
return {
|
||||
state: 'warning',
|
||||
key,
|
||||
value: _('Not configured'),
|
||||
};
|
||||
}
|
||||
|
||||
if (item.public_host_resolved === 0) {
|
||||
return {
|
||||
state: 'warning',
|
||||
key,
|
||||
value: `${item.public_host} (${_('Does not resolve')})`,
|
||||
};
|
||||
}
|
||||
|
||||
if (item.public_host_public === 0) {
|
||||
return {
|
||||
state: 'warning',
|
||||
key,
|
||||
value: `${item.public_host} (${_('Not public')})`,
|
||||
};
|
||||
}
|
||||
|
||||
if (item.public_host_matches_wan === 0) {
|
||||
return {
|
||||
state: 'warning',
|
||||
key,
|
||||
value: `${item.public_host_ips || item.public_host} / ${_('WAN')}: ${wanIp || _('Not detected')}`,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
state: 'success',
|
||||
key,
|
||||
value: item.public_host,
|
||||
};
|
||||
}
|
||||
|
||||
function getServerItems(
|
||||
item: Forkop.InboundCheckItem,
|
||||
wanIp: string,
|
||||
): IDiagnosticsChecksItem[] {
|
||||
const prefix = serverPrefix(item);
|
||||
const items: IDiagnosticsChecksItem[] = [
|
||||
{
|
||||
state: item.runtime_ok ? 'success' : 'error',
|
||||
key: `${prefix} ${_('Generated inbound')}`,
|
||||
value: `${item.tag} [${item.protocol}]`,
|
||||
},
|
||||
];
|
||||
|
||||
if (item.protocol === 'tailscale') {
|
||||
items.push(
|
||||
{
|
||||
state: 'success',
|
||||
key: `${prefix} ${_('Tailscale endpoint')}`,
|
||||
value: _('No public firewall port required'),
|
||||
},
|
||||
{
|
||||
state: item.routes_configured ? 'success' : 'warning',
|
||||
key: `${prefix} ${_('Routing rules')}`,
|
||||
value: item.routing_mode,
|
||||
},
|
||||
);
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
if (item.protocol === 'json_inbound') {
|
||||
items.push({
|
||||
state: item.routes_configured ? 'success' : 'warning',
|
||||
key: `${prefix} ${_('Routing rules')}`,
|
||||
value: item.routing_mode,
|
||||
});
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
items.push(
|
||||
{
|
||||
state:
|
||||
item.listening === 1 && item.port_conflict !== 1 ? 'success' : 'error',
|
||||
key: `${prefix} ${_('Listening port')}`,
|
||||
value: formatListen(item),
|
||||
},
|
||||
getPortConflictItem(item),
|
||||
{
|
||||
state:
|
||||
item.firewall_required === 0
|
||||
? 'warning'
|
||||
: item.firewall_open === 1
|
||||
? 'success'
|
||||
: 'error',
|
||||
key: `${prefix} ${_('Firewall WAN port')}`,
|
||||
value:
|
||||
item.firewall_required === 0
|
||||
? _('Not required for this listen address')
|
||||
: `${item.required_proto}/${Number(item.listen_port || 0)}`,
|
||||
},
|
||||
{
|
||||
state: item.routes_configured ? 'success' : 'warning',
|
||||
key: `${prefix} ${_('Routing rules')}`,
|
||||
value: item.routing_mode,
|
||||
},
|
||||
getPublicHostItem(item, wanIp),
|
||||
);
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
export async function runInboundsCheck() {
|
||||
const { order, title, code } = DIAGNOSTICS_CHECKS_MAP.INBOUNDS;
|
||||
|
||||
updateCheckStore({
|
||||
order,
|
||||
code,
|
||||
title,
|
||||
description: _('Checking, please wait'),
|
||||
state: 'loading',
|
||||
items: [],
|
||||
});
|
||||
|
||||
const inboundsChecks = await ForkopShellMethods.checkInbounds();
|
||||
|
||||
if (!inboundsChecks.success) {
|
||||
updateCheckStore({
|
||||
order,
|
||||
code,
|
||||
title,
|
||||
description: _('Cannot receive checks result'),
|
||||
state: 'error',
|
||||
items: [],
|
||||
});
|
||||
|
||||
throw new Error('Inbounds checks failed');
|
||||
}
|
||||
|
||||
const data = inboundsChecks.data;
|
||||
|
||||
if (!data.enabled_count) {
|
||||
updateCheckStore({
|
||||
order,
|
||||
code,
|
||||
title,
|
||||
description: _('No enabled server inbounds configured'),
|
||||
state: 'skipped',
|
||||
items: [],
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const items: IDiagnosticsChecksItem[] = [
|
||||
{
|
||||
state: data.wan_public ? 'success' : 'warning',
|
||||
key: _('WAN public IP'),
|
||||
value: data.wan_ip || _('Not detected'),
|
||||
},
|
||||
];
|
||||
|
||||
data.items.forEach((item) => {
|
||||
items.push(...getServerItems(item, data.wan_ip));
|
||||
});
|
||||
|
||||
const allGood = items.every((item) => item.state === 'success');
|
||||
const atLeastOneGood = items.some((item) => item.state !== 'error');
|
||||
const { state, description } = getMeta({ atLeastOneGood, allGood });
|
||||
|
||||
updateCheckStore({
|
||||
order,
|
||||
code,
|
||||
title,
|
||||
description,
|
||||
state,
|
||||
items,
|
||||
});
|
||||
|
||||
if (!atLeastOneGood) {
|
||||
throw new Error('Inbounds checks failed');
|
||||
}
|
||||
}
|
||||
@@ -1,115 +0,0 @@
|
||||
import { DIAGNOSTICS_CHECKS_MAP } from './contstants';
|
||||
import { RemoteFakeIPMethods, ForkopShellMethods } 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 ForkopShellMethods.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');
|
||||
}
|
||||
}
|
||||
@@ -1,182 +0,0 @@
|
||||
import { DIAGNOSTICS_CHECKS_MAP } from './contstants';
|
||||
import { ForkopShellMethods } from '../../../methods';
|
||||
import { updateCheckStore } from './updateCheckStore';
|
||||
import { getMeta } from '../helpers/getMeta';
|
||||
import { getDashboardSections } from '../../../methods/custom/getDashboardSections';
|
||||
import { IDiagnosticsChecksItem } from '../../../services';
|
||||
|
||||
type SectionCheckState = IDiagnosticsChecksItem['state'];
|
||||
|
||||
function getSubscriptionLatencyState(
|
||||
latencyValues: unknown[],
|
||||
): SectionCheckState {
|
||||
const hasAvailableLatency = latencyValues.some((item) => Boolean(item));
|
||||
const hasUnavailableLatency = latencyValues.some((item) => !item);
|
||||
|
||||
if (!hasAvailableLatency) {
|
||||
return 'error';
|
||||
}
|
||||
|
||||
if (hasUnavailableLatency) {
|
||||
return 'warning';
|
||||
}
|
||||
|
||||
return 'success';
|
||||
}
|
||||
|
||||
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({
|
||||
includeSubscriptionCopyState: false,
|
||||
});
|
||||
|
||||
if (!sections.success) {
|
||||
updateCheckStore({
|
||||
order,
|
||||
code,
|
||||
title,
|
||||
description: _('Cannot receive checks result'),
|
||||
state: 'error',
|
||||
items: [],
|
||||
});
|
||||
|
||||
throw new Error('Rule outbounds checks failed');
|
||||
}
|
||||
|
||||
const items: Array<IDiagnosticsChecksItem> = [];
|
||||
|
||||
for (const section of sections.data) {
|
||||
async function getLatency(): Promise<{
|
||||
state: SectionCheckState;
|
||||
latency: string;
|
||||
}> {
|
||||
if (section.withTagSelect) {
|
||||
const selectedOutbound =
|
||||
section.outbounds.find((item) => item.selected) ??
|
||||
section.outbounds.find(
|
||||
(item) => item.type?.toLowerCase() === 'urltest',
|
||||
) ??
|
||||
section.outbounds[0];
|
||||
|
||||
const isSubscription = section.proxyConfigType === 'subscription';
|
||||
|
||||
if (selectedOutbound?.code) {
|
||||
const latencyProxy = await ForkopShellMethods.getClashApiProxyLatency(
|
||||
selectedOutbound.code,
|
||||
section.latencyTestTimeout,
|
||||
);
|
||||
const proxySuccess =
|
||||
latencyProxy.success && !latencyProxy.data.message;
|
||||
|
||||
if (proxySuccess) {
|
||||
return {
|
||||
state: 'success',
|
||||
latency: `[${selectedOutbound.displayName ?? ''}] ${latencyProxy.data.delay}ms`,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
state: 'error',
|
||||
latency: `[${selectedOutbound.displayName ?? ''}] ${_('Not responding')}`,
|
||||
};
|
||||
}
|
||||
|
||||
const latencyGroup = await ForkopShellMethods.getClashApiGroupLatency(
|
||||
section.code,
|
||||
);
|
||||
const success = latencyGroup.success && !latencyGroup.data.message;
|
||||
|
||||
if (success) {
|
||||
const latencyValues = Object.values(latencyGroup.data);
|
||||
const sectionState = isSubscription
|
||||
? getSubscriptionLatencyState(latencyValues)
|
||||
: 'success';
|
||||
|
||||
const selectedProxyDelay =
|
||||
latencyGroup.data?.[selectedOutbound?.code ?? ''];
|
||||
|
||||
if (selectedProxyDelay) {
|
||||
return {
|
||||
state: sectionState,
|
||||
latency: `[${selectedOutbound?.displayName ?? ''}] ${selectedProxyDelay}ms`,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
state: 'error',
|
||||
latency: `[${selectedOutbound?.displayName ?? ''}] ${_('Not responding')}`,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
state: 'error',
|
||||
latency: _('Not responding'),
|
||||
};
|
||||
}
|
||||
|
||||
const selectedOutbound = section.outbounds[0];
|
||||
const latencyProxy = await ForkopShellMethods.getClashApiProxyLatency(
|
||||
section.code,
|
||||
section.latencyTestTimeout,
|
||||
);
|
||||
|
||||
const success = latencyProxy.success && !latencyProxy.data.message;
|
||||
|
||||
if (success) {
|
||||
return {
|
||||
state: 'success',
|
||||
latency: `${latencyProxy.data.delay} ms`,
|
||||
};
|
||||
}
|
||||
|
||||
if (section.action === 'vpn' && selectedOutbound?.runtimeAvailable) {
|
||||
return {
|
||||
state: 'warning',
|
||||
latency: `[${selectedOutbound.displayName || section.code}] ${_('Connectivity probe failed')}`,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
state: 'error',
|
||||
latency: _('Not responding'),
|
||||
};
|
||||
}
|
||||
|
||||
const { latency, state } = await getLatency();
|
||||
|
||||
items.push({
|
||||
state,
|
||||
key: section.displayName,
|
||||
value: latency,
|
||||
});
|
||||
}
|
||||
|
||||
const allGood = items.every((item) => item.state === 'success');
|
||||
|
||||
const atLeastOneGood = items.some((item) => item.state !== 'error');
|
||||
|
||||
const { state, description } = getMeta({ atLeastOneGood, allGood });
|
||||
|
||||
updateCheckStore({
|
||||
order,
|
||||
code,
|
||||
title,
|
||||
description,
|
||||
state,
|
||||
items,
|
||||
});
|
||||
|
||||
if (!atLeastOneGood) {
|
||||
throw new Error('Rule outbounds checks failed');
|
||||
}
|
||||
}
|
||||
@@ -1,96 +0,0 @@
|
||||
import { DIAGNOSTICS_CHECKS_MAP } from './contstants';
|
||||
import { ForkopShellMethods } 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 ForkopShellMethods.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');
|
||||
}
|
||||
}
|
||||
@@ -1,136 +0,0 @@
|
||||
import { DIAGNOSTICS_CHECKS_MAP } from './contstants';
|
||||
import { ForkopShellMethods } from '../../../methods';
|
||||
import { updateCheckStore } from './updateCheckStore';
|
||||
import { IDiagnosticsChecksItem } from '../../../services';
|
||||
import { getCheckItemsMeta } from './getCheckItemsMeta';
|
||||
|
||||
export async function runZapret2Check() {
|
||||
const { order, title, code } = DIAGNOSTICS_CHECKS_MAP.ZAPRET2;
|
||||
|
||||
updateCheckStore({
|
||||
order,
|
||||
code,
|
||||
title,
|
||||
description: _('Checking, please wait'),
|
||||
state: 'loading',
|
||||
items: [],
|
||||
});
|
||||
|
||||
const zapret2Status = await ForkopShellMethods.getZapret2Status();
|
||||
|
||||
if (!zapret2Status.success) {
|
||||
updateCheckStore({
|
||||
order,
|
||||
code,
|
||||
title,
|
||||
description: _('Cannot receive checks result'),
|
||||
state: 'error',
|
||||
items: [],
|
||||
});
|
||||
|
||||
throw new Error('Zapret2 checks failed');
|
||||
}
|
||||
|
||||
const data = zapret2Status.data;
|
||||
const providerAvailable = Boolean(data.provider_available ?? data.installed);
|
||||
const packageInstalled = Boolean(data.package_installed);
|
||||
const hasZapret2Rules = Number(data.enabled_rule_count || 0) > 0;
|
||||
const queueOverlap = Boolean(data.queue_overlap);
|
||||
const expectedProcesses = Number(data.expected_process_count || 0);
|
||||
const runningProcesses = Number(data.running_process_count || 0);
|
||||
const supervisorProcesses = Number(data.supervisor_process_count || 0);
|
||||
const forkopRuntimeReady =
|
||||
!hasZapret2Rules ||
|
||||
(runningProcesses === expectedProcesses &&
|
||||
supervisorProcesses === expectedProcesses);
|
||||
const unexpectedRuntime =
|
||||
!hasZapret2Rules && (runningProcesses > 0 || supervisorProcesses > 0);
|
||||
const outboundsConfigured = Boolean(data.outbounds_configured);
|
||||
const standaloneServiceEnabled = Boolean(data.standalone_service_enabled);
|
||||
const standaloneServiceRunning = Boolean(data.standalone_service_running);
|
||||
const standaloneConflict = hasZapret2Rules && standaloneServiceRunning;
|
||||
const standaloneAutostartRisk =
|
||||
hasZapret2Rules && standaloneServiceEnabled && !standaloneServiceRunning;
|
||||
|
||||
const items: Array<IDiagnosticsChecksItem> = [
|
||||
{
|
||||
state: providerAvailable
|
||||
? 'success'
|
||||
: hasZapret2Rules
|
||||
? 'error'
|
||||
: 'warning',
|
||||
key: providerAvailable
|
||||
? _('Zapret2 provider binary is available')
|
||||
: _('Zapret2 provider binary is not available'),
|
||||
value: data.provider_path || '',
|
||||
},
|
||||
{
|
||||
state: packageInstalled
|
||||
? 'success'
|
||||
: hasZapret2Rules
|
||||
? 'error'
|
||||
: 'warning',
|
||||
key: packageInstalled
|
||||
? _('Zapret2 package is installed')
|
||||
: _('Zapret2 package is not installed'),
|
||||
value: '',
|
||||
},
|
||||
{
|
||||
state: hasZapret2Rules && !providerAvailable ? 'error' : 'success',
|
||||
key: hasZapret2Rules
|
||||
? _('There are rules using Zapret2')
|
||||
: _('No rules use Zapret2'),
|
||||
value: '',
|
||||
},
|
||||
{
|
||||
state: unexpectedRuntime || !forkopRuntimeReady ? 'error' : 'success',
|
||||
key: hasZapret2Rules
|
||||
? forkopRuntimeReady
|
||||
? _('Forkop-managed nfqws2 runtime is ready')
|
||||
: _('Forkop-managed nfqws2 runtime is not ready')
|
||||
: unexpectedRuntime
|
||||
? _('Unexpected Forkop-managed nfqws2 runtime is running')
|
||||
: _('Forkop-managed nfqws2 runtime is not running'),
|
||||
value: hasZapret2Rules ? `${runningProcesses}/${expectedProcesses}` : '',
|
||||
},
|
||||
{
|
||||
state: queueOverlap ? 'error' : 'success',
|
||||
key: queueOverlap
|
||||
? _('NFQUEUE range overlaps with another rule')
|
||||
: _('NFQUEUE range is available'),
|
||||
value: `${Number(data.queue_base || 0)}-${Number(data.queue_range_end || 0)}`,
|
||||
},
|
||||
{
|
||||
state: !hasZapret2Rules || outboundsConfigured ? 'success' : 'error',
|
||||
key: outboundsConfigured
|
||||
? _('Zapret2 sing-box outbound is configured')
|
||||
: _('Zapret2 sing-box outbound is not configured'),
|
||||
value: '',
|
||||
},
|
||||
{
|
||||
state: standaloneConflict
|
||||
? 'error'
|
||||
: standaloneAutostartRisk
|
||||
? 'warning'
|
||||
: 'success',
|
||||
key: standaloneServiceRunning
|
||||
? hasZapret2Rules
|
||||
? _('Standalone Zapret2 is active together with Forkop Zapret2 rules')
|
||||
: _('Standalone Zapret2 service is active')
|
||||
: standaloneAutostartRisk
|
||||
? _('Standalone Zapret2 autostart is enabled')
|
||||
: _('Standalone Zapret2 service is inactive'),
|
||||
value: '',
|
||||
},
|
||||
];
|
||||
const { state, description } = getCheckItemsMeta(items);
|
||||
|
||||
updateCheckStore({
|
||||
order,
|
||||
code,
|
||||
title,
|
||||
description,
|
||||
state,
|
||||
items,
|
||||
});
|
||||
}
|
||||
@@ -1,127 +0,0 @@
|
||||
import { DIAGNOSTICS_CHECKS_MAP } from './contstants';
|
||||
import { ForkopShellMethods } from '../../../methods';
|
||||
import { updateCheckStore } from './updateCheckStore';
|
||||
import { IDiagnosticsChecksItem } from '../../../services';
|
||||
import { getCheckItemsMeta } from './getCheckItemsMeta';
|
||||
|
||||
export async function runZapretCheck() {
|
||||
const { order, title, code } = DIAGNOSTICS_CHECKS_MAP.ZAPRET;
|
||||
|
||||
updateCheckStore({
|
||||
order,
|
||||
code,
|
||||
title,
|
||||
description: _('Checking, please wait'),
|
||||
state: 'loading',
|
||||
items: [],
|
||||
});
|
||||
|
||||
const zapretStatus = await ForkopShellMethods.getZapretStatus();
|
||||
|
||||
if (!zapretStatus.success) {
|
||||
updateCheckStore({
|
||||
order,
|
||||
code,
|
||||
title,
|
||||
description: _('Cannot receive checks result'),
|
||||
state: 'error',
|
||||
items: [],
|
||||
});
|
||||
|
||||
throw new Error('Zapret checks failed');
|
||||
}
|
||||
|
||||
const data = zapretStatus.data;
|
||||
const providerAvailable = Boolean(data.provider_available ?? data.installed);
|
||||
const packageInstalled = Boolean(data.package_installed);
|
||||
const hasZapretRules = Number(data.enabled_rule_count || 0) > 0;
|
||||
const queueOverlap = Boolean(data.queue_overlap);
|
||||
const standaloneServiceRunning = Boolean(data.standalone_service_running);
|
||||
const standaloneConflict = hasZapretRules && standaloneServiceRunning;
|
||||
const expectedProcesses = Number(data.expected_process_count || 0);
|
||||
const runningProcesses = Number(data.running_process_count || 0);
|
||||
const supervisorProcesses = Number(data.supervisor_process_count || 0);
|
||||
const forkopRuntimeReady =
|
||||
!hasZapretRules ||
|
||||
(runningProcesses === expectedProcesses &&
|
||||
supervisorProcesses === expectedProcesses);
|
||||
const unexpectedRuntime =
|
||||
!hasZapretRules && (runningProcesses > 0 || supervisorProcesses > 0);
|
||||
const outboundsConfigured = Boolean(data.outbounds_configured);
|
||||
|
||||
const items: Array<IDiagnosticsChecksItem> = [
|
||||
{
|
||||
state: providerAvailable
|
||||
? 'success'
|
||||
: hasZapretRules
|
||||
? 'error'
|
||||
: 'warning',
|
||||
key: providerAvailable
|
||||
? _('Zapret provider binary is available')
|
||||
: _('Zapret provider binary is not available'),
|
||||
value: data.provider_path || '',
|
||||
},
|
||||
{
|
||||
state: packageInstalled
|
||||
? 'success'
|
||||
: hasZapretRules
|
||||
? 'error'
|
||||
: 'warning',
|
||||
key: packageInstalled
|
||||
? _('Zapret package is installed')
|
||||
: _('Zapret package is not installed'),
|
||||
value: '',
|
||||
},
|
||||
{
|
||||
state: hasZapretRules && !providerAvailable ? 'error' : 'success',
|
||||
key: hasZapretRules
|
||||
? _('There are rules using Zapret')
|
||||
: _('No rules use Zapret'),
|
||||
value: '',
|
||||
},
|
||||
{
|
||||
state: unexpectedRuntime || !forkopRuntimeReady ? 'error' : 'success',
|
||||
key: hasZapretRules
|
||||
? forkopRuntimeReady
|
||||
? _('Forkop-managed nfqws runtime is ready')
|
||||
: _('Forkop-managed nfqws runtime is not ready')
|
||||
: unexpectedRuntime
|
||||
? _('Unexpected Forkop-managed nfqws runtime is running')
|
||||
: _('Forkop-managed nfqws runtime is not running'),
|
||||
value: hasZapretRules ? `${runningProcesses}/${expectedProcesses}` : '',
|
||||
},
|
||||
{
|
||||
state: queueOverlap ? 'error' : 'success',
|
||||
key: queueOverlap
|
||||
? _('NFQUEUE range overlaps with another rule')
|
||||
: _('NFQUEUE range is available'),
|
||||
value: `${Number(data.queue_base || 0)}-${Number(data.queue_range_end || 0)}`,
|
||||
},
|
||||
{
|
||||
state: !hasZapretRules || outboundsConfigured ? 'success' : 'error',
|
||||
key: outboundsConfigured
|
||||
? _('Zapret sing-box outbound is configured')
|
||||
: _('Zapret sing-box outbound is not configured'),
|
||||
value: '',
|
||||
},
|
||||
{
|
||||
state: standaloneConflict ? 'warning' : 'success',
|
||||
key: standaloneServiceRunning
|
||||
? hasZapretRules
|
||||
? _('Standalone Zapret is active together with Forkop Zapret rules')
|
||||
: _('Standalone Zapret service is active')
|
||||
: _('Standalone Zapret service is inactive'),
|
||||
value: '',
|
||||
},
|
||||
];
|
||||
const { state, description } = getCheckItemsMeta(items);
|
||||
|
||||
updateCheckStore({
|
||||
order,
|
||||
code,
|
||||
title,
|
||||
description,
|
||||
state,
|
||||
items,
|
||||
});
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { getDnsCheckPresentation } from '../getDnsCheckPresentation';
|
||||
|
||||
const baseDnsResult = {
|
||||
dns_type: 'udp' as const,
|
||||
dns_server: '77.88.8.8',
|
||||
dns_server_index: 0,
|
||||
dns_server_count: 1,
|
||||
dns_status: 1 as const,
|
||||
dns_on_router: 1 as const,
|
||||
bootstrap_dns_server: '77.88.8.8',
|
||||
bootstrap_dns_server_index: 0,
|
||||
bootstrap_dns_server_count: 1,
|
||||
bootstrap_dns_status: 1 as const,
|
||||
dhcp_config_status: 0 as const,
|
||||
dont_touch_dhcp: 0 as const,
|
||||
};
|
||||
|
||||
describe('getDnsCheckPresentation', () => {
|
||||
it('keeps manual DHCP mode as a warning instead of a DNS failure', () => {
|
||||
expect(
|
||||
getDnsCheckPresentation({
|
||||
...baseDnsResult,
|
||||
dont_touch_dhcp: 1,
|
||||
}),
|
||||
).toMatchObject({
|
||||
state: 'warning',
|
||||
description: 'Checks passed with manual DHCP',
|
||||
dhcpItemState: 'warning',
|
||||
dhcpItemKey: 'DHCP is managed manually',
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps a mismatched managed DHCP config as an error item', () => {
|
||||
expect(getDnsCheckPresentation(baseDnsResult)).toMatchObject({
|
||||
state: 'warning',
|
||||
description: 'Issues detected',
|
||||
dhcpItemState: 'error',
|
||||
dhcpItemKey: 'DHCP has DNS server',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,186 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
getZapretStatus: vi.fn(),
|
||||
getZapret2Status: vi.fn(),
|
||||
getByedpiStatus: vi.fn(),
|
||||
updateCheckStore: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../../../methods', () => ({
|
||||
ForkopShellMethods: {
|
||||
getZapretStatus: mocks.getZapretStatus,
|
||||
getZapret2Status: mocks.getZapret2Status,
|
||||
getByedpiStatus: mocks.getByedpiStatus,
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../updateCheckStore', () => ({
|
||||
updateCheckStore: mocks.updateCheckStore,
|
||||
}));
|
||||
|
||||
import { runByedpiCheck } from '../runByedpiCheck';
|
||||
import { runZapret2Check } from '../runZapret2Check';
|
||||
import { runZapretCheck } from '../runZapretCheck';
|
||||
|
||||
const zapretOkData = {
|
||||
provider_available: true,
|
||||
package_installed: true,
|
||||
enabled_rule_count: 1,
|
||||
expected_process_count: 1,
|
||||
running_process_count: 1,
|
||||
supervisor_process_count: 1,
|
||||
queue_overlap: false,
|
||||
queue_base: 200,
|
||||
queue_range_end: 200,
|
||||
standalone_service_running: false,
|
||||
outbounds_configured: true,
|
||||
routes_configured: false,
|
||||
ready: false,
|
||||
};
|
||||
|
||||
describe('provider diagnostics checks', () => {
|
||||
beforeEach(() => {
|
||||
mocks.getZapretStatus.mockReset();
|
||||
mocks.getZapret2Status.mockReset();
|
||||
mocks.getByedpiStatus.mockReset();
|
||||
mocks.updateCheckStore.mockReset();
|
||||
});
|
||||
|
||||
it('shows Zapret provider path and does not fail on hidden route status', async () => {
|
||||
mocks.getZapretStatus.mockResolvedValue({
|
||||
success: true,
|
||||
data: {
|
||||
...zapretOkData,
|
||||
provider_path: '/usr/bin/nfqws',
|
||||
},
|
||||
});
|
||||
|
||||
await expect(runZapretCheck()).resolves.toBeUndefined();
|
||||
|
||||
const result = mocks.updateCheckStore.mock.calls.slice(-1)[0]?.[0];
|
||||
|
||||
expect(result).toMatchObject({
|
||||
state: 'success',
|
||||
description: 'Checks passed',
|
||||
});
|
||||
expect(result.items).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
key: 'Zapret provider binary is available',
|
||||
value: '/usr/bin/nfqws',
|
||||
}),
|
||||
expect.objectContaining({
|
||||
state: 'success',
|
||||
key: 'Zapret sing-box outbound is configured',
|
||||
}),
|
||||
]),
|
||||
);
|
||||
expect(
|
||||
result.items.some((item: { key: string }) =>
|
||||
item.key.includes('route rules'),
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps Zapret outbound failures visible and red', async () => {
|
||||
mocks.getZapretStatus.mockResolvedValue({
|
||||
success: true,
|
||||
data: {
|
||||
...zapretOkData,
|
||||
provider_path: '/usr/bin/nfqws',
|
||||
outbounds_configured: false,
|
||||
},
|
||||
});
|
||||
|
||||
await expect(runZapretCheck()).resolves.toBeUndefined();
|
||||
|
||||
const result = mocks.updateCheckStore.mock.calls.slice(-1)[0]?.[0];
|
||||
|
||||
expect(result).toMatchObject({
|
||||
state: 'error',
|
||||
description: 'Checks failed',
|
||||
});
|
||||
expect(result.items).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
state: 'error',
|
||||
key: 'Zapret sing-box outbound is not configured',
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it('removes Zapret2 route checks while keeping provider path visible', async () => {
|
||||
mocks.getZapret2Status.mockResolvedValue({
|
||||
success: true,
|
||||
data: {
|
||||
...zapretOkData,
|
||||
provider_path: '/usr/bin/nfqws2',
|
||||
},
|
||||
});
|
||||
|
||||
await expect(runZapret2Check()).resolves.toBeUndefined();
|
||||
|
||||
const result = mocks.updateCheckStore.mock.calls.slice(-1)[0]?.[0];
|
||||
|
||||
expect(result).toMatchObject({
|
||||
state: 'success',
|
||||
description: 'Checks passed',
|
||||
});
|
||||
expect(result.items).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
key: 'Zapret2 provider binary is available',
|
||||
value: '/usr/bin/nfqws2',
|
||||
}),
|
||||
expect.objectContaining({
|
||||
state: 'success',
|
||||
key: 'Zapret2 sing-box outbound is configured',
|
||||
}),
|
||||
]),
|
||||
);
|
||||
expect(
|
||||
result.items.some((item: { key: string }) =>
|
||||
item.key.includes('route rules'),
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('removes ByeDPI route checks from the visible and aggregate status', async () => {
|
||||
mocks.getByedpiStatus.mockResolvedValue({
|
||||
success: true,
|
||||
data: {
|
||||
provider_available: true,
|
||||
package_installed: true,
|
||||
enabled_rule_count: 1,
|
||||
expected_process_count: 1,
|
||||
running_process_count: 1,
|
||||
supervisor_process_count: 1,
|
||||
restart_count: 0,
|
||||
runtime_unstable: false,
|
||||
standalone_service_enabled: false,
|
||||
standalone_service_running: false,
|
||||
listen_address: '127.0.0.1',
|
||||
port_base: 1080,
|
||||
outbounds_configured: true,
|
||||
routes_configured: false,
|
||||
ready: false,
|
||||
},
|
||||
});
|
||||
|
||||
await expect(runByedpiCheck()).resolves.toBeUndefined();
|
||||
|
||||
const result = mocks.updateCheckStore.mock.calls.slice(-1)[0]?.[0];
|
||||
|
||||
expect(result).toMatchObject({
|
||||
state: 'success',
|
||||
description: 'Checks passed',
|
||||
});
|
||||
expect(
|
||||
result.items.some((item: { key: string }) =>
|
||||
item.key.includes('route rules'),
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,201 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
getDashboardSections: vi.fn(),
|
||||
getClashApiProxyLatency: vi.fn(),
|
||||
getClashApiGroupLatency: vi.fn(),
|
||||
updateCheckStore: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../../../methods/custom/getDashboardSections', () => ({
|
||||
getDashboardSections: mocks.getDashboardSections,
|
||||
}));
|
||||
|
||||
vi.mock('../../../../methods', () => ({
|
||||
ForkopShellMethods: {
|
||||
getClashApiProxyLatency: mocks.getClashApiProxyLatency,
|
||||
getClashApiGroupLatency: mocks.getClashApiGroupLatency,
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../updateCheckStore', () => ({
|
||||
updateCheckStore: mocks.updateCheckStore,
|
||||
}));
|
||||
|
||||
import { runSectionsCheck } from '../runSectionsCheck';
|
||||
|
||||
describe('runSectionsCheck', () => {
|
||||
beforeEach(() => {
|
||||
mocks.getDashboardSections.mockReset();
|
||||
mocks.getClashApiProxyLatency.mockReset();
|
||||
mocks.getClashApiGroupLatency.mockReset();
|
||||
mocks.updateCheckStore.mockReset();
|
||||
});
|
||||
|
||||
it('keeps VPN interface probe failures as warnings when the runtime outbound exists', async () => {
|
||||
mocks.getDashboardSections.mockResolvedValue({
|
||||
success: true,
|
||||
data: [
|
||||
{
|
||||
withTagSelect: false,
|
||||
code: 'AWG-out',
|
||||
sectionName: 'AWG',
|
||||
displayName: 'AWG',
|
||||
action: 'vpn',
|
||||
latencyTestTimeout: '10000',
|
||||
outbounds: [
|
||||
{
|
||||
code: 'AWG-out',
|
||||
displayName: 'awg1',
|
||||
latency: 0,
|
||||
type: 'Direct',
|
||||
selected: true,
|
||||
runtimeAvailable: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
mocks.getClashApiProxyLatency.mockResolvedValue({
|
||||
success: true,
|
||||
data: { message: 'context deadline exceeded' },
|
||||
});
|
||||
|
||||
await expect(runSectionsCheck()).resolves.toBeUndefined();
|
||||
|
||||
expect(mocks.getClashApiProxyLatency).toHaveBeenCalledWith(
|
||||
'AWG-out',
|
||||
'10000',
|
||||
);
|
||||
expect(mocks.updateCheckStore).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({
|
||||
state: 'warning',
|
||||
description: 'Issues detected',
|
||||
items: [
|
||||
{
|
||||
state: 'warning',
|
||||
key: 'AWG',
|
||||
value: '[awg1] Connectivity probe failed',
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('checks only the selected concrete outbound for selectable proxy sections', async () => {
|
||||
mocks.getDashboardSections.mockResolvedValue({
|
||||
success: true,
|
||||
data: [
|
||||
{
|
||||
withTagSelect: true,
|
||||
code: 'main-out',
|
||||
sectionName: 'main',
|
||||
displayName: 'Main',
|
||||
latencyTestTimeout: '7000',
|
||||
outbounds: [
|
||||
{
|
||||
code: 'main-1-out',
|
||||
displayName: 'Selected',
|
||||
latency: 0,
|
||||
type: 'VLESS',
|
||||
selected: true,
|
||||
},
|
||||
{
|
||||
code: 'main-2-out',
|
||||
displayName: 'Other',
|
||||
latency: 0,
|
||||
type: 'VLESS',
|
||||
selected: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
mocks.getClashApiProxyLatency.mockResolvedValue({
|
||||
success: true,
|
||||
data: { delay: 123 },
|
||||
});
|
||||
|
||||
await expect(runSectionsCheck()).resolves.toBeUndefined();
|
||||
|
||||
expect(mocks.getClashApiProxyLatency).toHaveBeenCalledWith(
|
||||
'main-1-out',
|
||||
'7000',
|
||||
);
|
||||
expect(mocks.getClashApiGroupLatency).not.toHaveBeenCalled();
|
||||
expect(mocks.updateCheckStore).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({
|
||||
state: 'success',
|
||||
items: [
|
||||
{
|
||||
state: 'success',
|
||||
key: 'Main',
|
||||
value: '[Selected] 123ms',
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('checks the selected URLTest outbound directly', async () => {
|
||||
mocks.getDashboardSections.mockResolvedValue({
|
||||
success: true,
|
||||
data: [
|
||||
{
|
||||
withTagSelect: true,
|
||||
code: 'main-out',
|
||||
sectionName: 'main',
|
||||
displayName: 'Main',
|
||||
proxyConfigType: 'selector',
|
||||
outbounds: [
|
||||
{
|
||||
code: 'main-urltest-out',
|
||||
displayName: 'Fastest',
|
||||
latency: 10,
|
||||
type: 'URLTest',
|
||||
selected: true,
|
||||
},
|
||||
{
|
||||
code: 'main-1-out',
|
||||
displayName: 'One',
|
||||
latency: 0,
|
||||
type: 'VLESS',
|
||||
selected: false,
|
||||
},
|
||||
{
|
||||
code: 'main-2-out',
|
||||
displayName: 'Two',
|
||||
latency: 0,
|
||||
type: 'VLESS',
|
||||
selected: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
mocks.getClashApiProxyLatency.mockResolvedValue({
|
||||
success: true,
|
||||
data: { delay: 10 },
|
||||
});
|
||||
|
||||
await expect(runSectionsCheck()).resolves.toBeUndefined();
|
||||
|
||||
expect(mocks.getClashApiProxyLatency).toHaveBeenCalledWith(
|
||||
'main-urltest-out',
|
||||
undefined,
|
||||
);
|
||||
expect(mocks.getClashApiGroupLatency).not.toHaveBeenCalled();
|
||||
expect(mocks.updateCheckStore).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({
|
||||
state: 'success',
|
||||
items: [
|
||||
{
|
||||
state: 'success',
|
||||
key: 'Main',
|
||||
value: '[Fastest] 10ms',
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,20 +0,0 @@
|
||||
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],
|
||||
});
|
||||
}
|
||||
@@ -1,152 +0,0 @@
|
||||
import {
|
||||
DIAGNOSTICS_CHECKS,
|
||||
DIAGNOSTICS_CHECKS_MAP,
|
||||
} from './checks/contstants';
|
||||
import { IDiagnosticsChecksStoreItem, StoreType } from '../../services';
|
||||
|
||||
export interface DiagnosticsProviderOptions {
|
||||
includeZapret?: boolean;
|
||||
includeZapret2?: boolean;
|
||||
includeByedpi?: boolean;
|
||||
includeInbounds?: boolean;
|
||||
}
|
||||
|
||||
function createDiagnosticCheck(
|
||||
code: DIAGNOSTICS_CHECKS,
|
||||
description: string,
|
||||
): IDiagnosticsChecksStoreItem {
|
||||
const meta = DIAGNOSTICS_CHECKS_MAP[code];
|
||||
|
||||
return {
|
||||
code,
|
||||
title: meta.title,
|
||||
order: meta.order,
|
||||
description,
|
||||
items: [],
|
||||
state: 'skipped',
|
||||
};
|
||||
}
|
||||
|
||||
export function getDiagnosticsChecks(
|
||||
description: string,
|
||||
options: DiagnosticsProviderOptions = {},
|
||||
): Array<IDiagnosticsChecksStoreItem> {
|
||||
const checks = [DIAGNOSTICS_CHECKS.DNS, DIAGNOSTICS_CHECKS.SINGBOX];
|
||||
|
||||
if (options.includeInbounds === true) {
|
||||
checks.push(DIAGNOSTICS_CHECKS.INBOUNDS);
|
||||
}
|
||||
|
||||
checks.push(DIAGNOSTICS_CHECKS.NFT);
|
||||
|
||||
if (options.includeZapret) {
|
||||
checks.push(DIAGNOSTICS_CHECKS.ZAPRET);
|
||||
}
|
||||
|
||||
if (options.includeZapret2) {
|
||||
checks.push(DIAGNOSTICS_CHECKS.ZAPRET2);
|
||||
}
|
||||
|
||||
if (options.includeByedpi) {
|
||||
checks.push(DIAGNOSTICS_CHECKS.BYEDPI);
|
||||
}
|
||||
|
||||
checks.push(DIAGNOSTICS_CHECKS.OUTBOUNDS, DIAGNOSTICS_CHECKS.FAKEIP);
|
||||
|
||||
return checks.map((code) => createDiagnosticCheck(code, description));
|
||||
}
|
||||
|
||||
export function getLoadingDiagnosticsChecks(
|
||||
options: DiagnosticsProviderOptions = {},
|
||||
): Pick<StoreType, 'diagnosticsChecks'> {
|
||||
return {
|
||||
diagnosticsChecks: getDiagnosticsChecks(_('Pending'), options),
|
||||
};
|
||||
}
|
||||
|
||||
export const initialDiagnosticStore: Pick<
|
||||
StoreType,
|
||||
| 'diagnosticsChecks'
|
||||
| 'diagnosticsRunAction'
|
||||
| 'diagnosticsActions'
|
||||
| 'diagnosticsSystemInfo'
|
||||
| 'updatesActions'
|
||||
| 'updatesChecks'
|
||||
> = {
|
||||
diagnosticsSystemInfo: {
|
||||
loading: true,
|
||||
loaded: false,
|
||||
providerInfoLoaded: false,
|
||||
forkop_version: 'loading',
|
||||
forkop_latest_version: 'loading',
|
||||
luci_app_version: 'loading',
|
||||
sing_box_version: 'loading',
|
||||
sing_box_extended: 0,
|
||||
sing_box_tiny: 0,
|
||||
sing_box_compressed: 0,
|
||||
sing_box_tailscale: 1,
|
||||
zapret_version: 'loading',
|
||||
zapret_installed: 0,
|
||||
zapret2_version: 'loading',
|
||||
zapret2_installed: 0,
|
||||
byedpi_version: 'loading',
|
||||
byedpi_installed: 0,
|
||||
server_inbounds_enabled_count: -1,
|
||||
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: getDiagnosticsChecks(_('Not running')),
|
||||
updatesActions: {
|
||||
forkopCheck: { loading: false },
|
||||
forkopInstall: { loading: false },
|
||||
singBoxCheck: { loading: false },
|
||||
singBoxInstall: { loading: false },
|
||||
singBoxInstallExtended: { loading: false },
|
||||
singBoxInstallExtendedCompressed: { loading: false },
|
||||
singBoxInstallTiny: { loading: false },
|
||||
singBoxInstallStable: { loading: false },
|
||||
zapretCheck: { loading: false },
|
||||
zapretInstall: { loading: false },
|
||||
zapretRemove: { loading: false },
|
||||
zapret2Check: { loading: false },
|
||||
zapret2Install: { loading: false },
|
||||
zapret2Remove: { loading: false },
|
||||
byedpiCheck: { loading: false },
|
||||
byedpiInstall: { loading: false },
|
||||
byedpiRemove: { loading: false },
|
||||
},
|
||||
updatesChecks: {
|
||||
forkop: { status: null, latest_version: '', release_url: '' },
|
||||
sing_box: { status: null, latest_version: '', release_url: '' },
|
||||
zapret: { status: null, latest_version: '', release_url: '' },
|
||||
zapret2: { status: null, latest_version: '', release_url: '' },
|
||||
byedpi: { status: null, latest_version: '', release_url: '' },
|
||||
},
|
||||
};
|
||||
@@ -1,155 +0,0 @@
|
||||
import type { StoreType } from '../../services';
|
||||
import type { DiagnosticsProviderOptions } from './diagnostic.store';
|
||||
import { DIAGNOSTICS_CHECKS } from './checks/contstants';
|
||||
|
||||
const DIAGNOSTIC_RUN_STORAGE_KEY = 'forkop:diagnostic-run:v1';
|
||||
const DIAGNOSTIC_RUN_TTL_MS = 30 * 60 * 1000;
|
||||
const CHECK_STATES = ['loading', 'warning', 'success', 'error', 'skipped'];
|
||||
const CHECK_ITEM_STATES = ['error', 'warning', 'success'];
|
||||
|
||||
export interface PersistedDiagnosticRun {
|
||||
nextRunnerIndex: number;
|
||||
providerOptions: DiagnosticsProviderOptions;
|
||||
diagnosticsChecks: StoreType['diagnosticsChecks'];
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
function getSessionStorage(): Storage | null {
|
||||
if (typeof window === 'undefined') {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return window.sessionStorage;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return Boolean(value) && typeof value === 'object';
|
||||
}
|
||||
|
||||
function isOptionalBoolean(value: unknown) {
|
||||
return value === undefined || typeof value === 'boolean';
|
||||
}
|
||||
|
||||
function isDiagnosticsProviderOptions(
|
||||
value: unknown,
|
||||
): value is DiagnosticsProviderOptions {
|
||||
if (!isRecord(value)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (
|
||||
isOptionalBoolean(value.includeZapret) &&
|
||||
isOptionalBoolean(value.includeZapret2) &&
|
||||
isOptionalBoolean(value.includeByedpi) &&
|
||||
isOptionalBoolean(value.includeInbounds)
|
||||
);
|
||||
}
|
||||
|
||||
function isDiagnosticCheckItem(value: unknown) {
|
||||
return (
|
||||
isRecord(value) &&
|
||||
CHECK_ITEM_STATES.includes(String(value.state)) &&
|
||||
typeof value.key === 'string' &&
|
||||
typeof value.value === 'string'
|
||||
);
|
||||
}
|
||||
|
||||
function isDiagnosticCheck(value: unknown) {
|
||||
return (
|
||||
isRecord(value) &&
|
||||
Number.isFinite(value.order) &&
|
||||
Object.values(DIAGNOSTICS_CHECKS).includes(
|
||||
value.code as DIAGNOSTICS_CHECKS,
|
||||
) &&
|
||||
typeof value.title === 'string' &&
|
||||
typeof value.description === 'string' &&
|
||||
CHECK_STATES.includes(String(value.state)) &&
|
||||
Array.isArray(value.items) &&
|
||||
value.items.every(isDiagnosticCheckItem)
|
||||
);
|
||||
}
|
||||
|
||||
function isPersistedDiagnosticRun(
|
||||
value: unknown,
|
||||
): value is PersistedDiagnosticRun {
|
||||
if (!isRecord(value)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (
|
||||
typeof value.nextRunnerIndex === 'number' &&
|
||||
Number.isInteger(value.nextRunnerIndex) &&
|
||||
value.nextRunnerIndex >= 0 &&
|
||||
isDiagnosticsProviderOptions(value.providerOptions) &&
|
||||
Array.isArray(value.diagnosticsChecks) &&
|
||||
value.diagnosticsChecks.every(isDiagnosticCheck) &&
|
||||
Number.isFinite(value.updatedAt)
|
||||
);
|
||||
}
|
||||
|
||||
function isExpired(run: PersistedDiagnosticRun, now = Date.now()) {
|
||||
return now - run.updatedAt > DIAGNOSTIC_RUN_TTL_MS;
|
||||
}
|
||||
|
||||
export function readPersistedDiagnosticRun(
|
||||
storage: Storage | null = getSessionStorage(),
|
||||
): PersistedDiagnosticRun | null {
|
||||
if (!storage) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(
|
||||
storage.getItem(DIAGNOSTIC_RUN_STORAGE_KEY) || 'null',
|
||||
);
|
||||
|
||||
if (!isPersistedDiagnosticRun(parsed) || isExpired(parsed)) {
|
||||
storage.removeItem(DIAGNOSTIC_RUN_STORAGE_KEY);
|
||||
return null;
|
||||
}
|
||||
|
||||
return parsed;
|
||||
} catch {
|
||||
storage.removeItem(DIAGNOSTIC_RUN_STORAGE_KEY);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function savePersistedDiagnosticRun(
|
||||
run: Omit<PersistedDiagnosticRun, 'updatedAt'>,
|
||||
storage: Storage | null = getSessionStorage(),
|
||||
) {
|
||||
if (!storage) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
storage.setItem(
|
||||
DIAGNOSTIC_RUN_STORAGE_KEY,
|
||||
JSON.stringify({
|
||||
...run,
|
||||
updatedAt: Date.now(),
|
||||
}),
|
||||
);
|
||||
} catch {
|
||||
// Diagnostics still runs normally; persistence is only for reload recovery.
|
||||
}
|
||||
}
|
||||
|
||||
export function clearPersistedDiagnosticRun(
|
||||
storage: Storage | null = getSessionStorage(),
|
||||
) {
|
||||
if (!storage) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
storage.removeItem(DIAGNOSTIC_RUN_STORAGE_KEY);
|
||||
} catch {
|
||||
// Ignore storage failures.
|
||||
}
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
export function getCheckTitle(name: string) {
|
||||
return `${name} ${_('checks')}`;
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
interface IGetMetaProps {
|
||||
allGood: boolean;
|
||||
atLeastOneGood: boolean;
|
||||
}
|
||||
|
||||
export function getMeta({ allGood, atLeastOneGood }: IGetMetaProps): {
|
||||
description: string;
|
||||
state: 'warning' | 'success' | 'error';
|
||||
} {
|
||||
if (allGood) {
|
||||
return {
|
||||
state: 'success',
|
||||
description: _('Checks passed'),
|
||||
};
|
||||
}
|
||||
|
||||
if (atLeastOneGood) {
|
||||
return {
|
||||
state: 'warning',
|
||||
description: _('Issues detected'),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
state: 'error',
|
||||
description: _('Checks failed'),
|
||||
};
|
||||
}
|
||||
@@ -1,235 +0,0 @@
|
||||
const MASKED_VALUE = 'MASKED';
|
||||
|
||||
const SING_BOX_MASKED_KEYS = new Set([
|
||||
'auth_key',
|
||||
'control_url',
|
||||
'exit_node',
|
||||
'hostname',
|
||||
'listen',
|
||||
'listen_port',
|
||||
'username',
|
||||
'uuid',
|
||||
'server',
|
||||
'server_name',
|
||||
'secret',
|
||||
'password',
|
||||
'private_key',
|
||||
'public_key',
|
||||
'short_id',
|
||||
'fingerprint',
|
||||
'server_port',
|
||||
'server_ports',
|
||||
'advertise_routes',
|
||||
'domain',
|
||||
'domain_suffix',
|
||||
'domain_keyword',
|
||||
'domain_regex',
|
||||
'ip_cidr',
|
||||
'source_ip_cidr',
|
||||
]);
|
||||
|
||||
const FORKOP_MASK_AFTER_TOKEN = [
|
||||
'option proxy_string',
|
||||
'option hwid',
|
||||
'option subscription_url',
|
||||
'list subscription_urls',
|
||||
'list urltest_proxy_links',
|
||||
'list selector_proxy_links',
|
||||
'list server_users',
|
||||
'option server_uuid',
|
||||
'option server_username',
|
||||
'option server_password',
|
||||
'option mtproto_secret',
|
||||
'option hysteria2_obfs_password',
|
||||
'option reality_private_key',
|
||||
'option reality_public_key',
|
||||
'option reality_short_id',
|
||||
'list reality_short_id',
|
||||
'option yacd_secret_key',
|
||||
];
|
||||
|
||||
const FORKOP_MASK_AFTER_TOKEN_SPACE = [
|
||||
'option outbound_json',
|
||||
'list domain',
|
||||
'list domain_suffix',
|
||||
'list domain_keyword',
|
||||
'list domain_regex',
|
||||
'list ip_cidr',
|
||||
'list source_ip_cidr',
|
||||
'list fully_routed_ips',
|
||||
'option dns_server',
|
||||
'option bootstrap_dns_server',
|
||||
'list dns_server',
|
||||
'list bootstrap_dns_server',
|
||||
'option listen',
|
||||
'option listen_port',
|
||||
'option public_host',
|
||||
'option mtproto_faketls',
|
||||
'option mtproto_domain_fronting_ip',
|
||||
'option tls_server_name',
|
||||
'option reality_handshake_server',
|
||||
'option reality_handshake_server_port',
|
||||
'option transport_host',
|
||||
'list transport_hosts',
|
||||
'option tailscale_auth_key',
|
||||
'option tailscale_control_url',
|
||||
'option tailscale_hostname',
|
||||
'list tailscale_advertise_routes',
|
||||
'option tailscale_ephemeral',
|
||||
'option tailscale_exit_node',
|
||||
'option tailscale_exit_node_allow_lan_access',
|
||||
'option mixed_proxy_username',
|
||||
'option mixed_proxy_password',
|
||||
'option ipaddr',
|
||||
'option netmask',
|
||||
'option gateway',
|
||||
'option username',
|
||||
'option password',
|
||||
];
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function isSpaceChar(value: string) {
|
||||
return value === ' ' || value === '\t' || value === '\r' || value === '\n';
|
||||
}
|
||||
|
||||
function maskAfterToken(line: string, token: string) {
|
||||
const position = line.indexOf(token);
|
||||
|
||||
return position < 0
|
||||
? line
|
||||
: `${line.slice(0, position)}${token} '${MASKED_VALUE}'`;
|
||||
}
|
||||
|
||||
function maskAfterTokenSpace(line: string, token: string) {
|
||||
const position = line.indexOf(token);
|
||||
|
||||
if (position < 0) {
|
||||
return line;
|
||||
}
|
||||
|
||||
const spacePosition = position + token.length;
|
||||
|
||||
if (
|
||||
spacePosition >= line.length ||
|
||||
!isSpaceChar(line.slice(spacePosition, spacePosition + 1))
|
||||
) {
|
||||
return line;
|
||||
}
|
||||
|
||||
return `${line.slice(0, spacePosition + 1)}'${MASKED_VALUE}'`;
|
||||
}
|
||||
|
||||
function maskOptionPath(line: string, token: string) {
|
||||
const position = line.indexOf(token);
|
||||
|
||||
if (position < 0) {
|
||||
return line;
|
||||
}
|
||||
|
||||
const slashOffset = line.slice(position + token.length).indexOf('/');
|
||||
|
||||
if (slashOffset < 0) {
|
||||
return line;
|
||||
}
|
||||
|
||||
const slash = slashOffset + position + token.length;
|
||||
const quoteOffset = line.slice(slash + 1).indexOf("'");
|
||||
|
||||
if (quoteOffset < 0) {
|
||||
return line;
|
||||
}
|
||||
|
||||
const quote = quoteOffset + slash + 1;
|
||||
|
||||
return `${line.slice(0, slash)}/MASKED'${line.slice(quote + 1)}`;
|
||||
}
|
||||
|
||||
function maskGlobalCheckLine(line: string) {
|
||||
let maskedLine = line;
|
||||
|
||||
for (const token of FORKOP_MASK_AFTER_TOKEN) {
|
||||
maskedLine = maskAfterToken(maskedLine, token);
|
||||
}
|
||||
|
||||
for (const token of FORKOP_MASK_AFTER_TOKEN_SPACE) {
|
||||
maskedLine = maskAfterTokenSpace(maskedLine, token);
|
||||
}
|
||||
|
||||
maskedLine = maskOptionPath(maskedLine, "option dns_server '");
|
||||
maskedLine = maskOptionPath(maskedLine, "list dns_server '");
|
||||
return maskedLine;
|
||||
}
|
||||
|
||||
function maskMultilineContinuation(line: string) {
|
||||
const leadingSpace = line.match(/^\s*/)?.[0] ?? '';
|
||||
const hasClosingQuote = line.includes("'");
|
||||
|
||||
return `${leadingSpace}${MASKED_VALUE}${hasClosingQuote ? "'" : ''}`;
|
||||
}
|
||||
|
||||
export function maskSingBoxConfigValue(value: unknown): unknown {
|
||||
if (Array.isArray(value)) {
|
||||
return value.map((item) => maskSingBoxConfigValue(item));
|
||||
}
|
||||
|
||||
if (isRecord(value)) {
|
||||
return Object.fromEntries(
|
||||
Object.entries(value).map(([key, item]) => [
|
||||
key,
|
||||
SING_BOX_MASKED_KEYS.has(key)
|
||||
? MASKED_VALUE
|
||||
: maskSingBoxConfigValue(item),
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
export function stringifySingBoxConfig(value: unknown) {
|
||||
return typeof value === 'string' ? value : JSON.stringify(value, null, 2);
|
||||
}
|
||||
|
||||
export function formatMaskedSingBoxConfig(value: unknown) {
|
||||
if (typeof value === 'string') {
|
||||
try {
|
||||
return JSON.stringify(maskSingBoxConfigValue(JSON.parse(value)), null, 2);
|
||||
} catch (_error) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
return JSON.stringify(maskSingBoxConfigValue(value), null, 2);
|
||||
}
|
||||
|
||||
export function maskGlobalCheckText(text: string = '') {
|
||||
let inMaskedMultiline = false;
|
||||
|
||||
return `${text}`
|
||||
.split('\n')
|
||||
.map((line) => {
|
||||
if (inMaskedMultiline) {
|
||||
if (line.includes("'")) {
|
||||
inMaskedMultiline = false;
|
||||
}
|
||||
|
||||
return maskMultilineContinuation(line);
|
||||
}
|
||||
|
||||
const maskedLine = maskGlobalCheckLine(line);
|
||||
|
||||
if (line.includes('option outbound_json')) {
|
||||
const firstQuote = line.indexOf("'");
|
||||
|
||||
if (firstQuote >= 0 && line.slice(firstQuote + 1).indexOf("'") < 0) {
|
||||
inMaskedMultiline = true;
|
||||
}
|
||||
}
|
||||
|
||||
return maskedLine;
|
||||
})
|
||||
.join('\n');
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
import { render } from './renderDiagnostic';
|
||||
import { initController } from './initController';
|
||||
import { styles } from './styles';
|
||||
|
||||
export const DiagnosticTab = {
|
||||
render,
|
||||
initController,
|
||||
styles,
|
||||
};
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,4 +0,0 @@
|
||||
export * from './renderAvailableActions';
|
||||
export * from './renderCheckSection';
|
||||
export * from './renderRunAction';
|
||||
export * from './renderSystemInfo';
|
||||
@@ -1,122 +0,0 @@
|
||||
import { renderButton } from '../../../../partials';
|
||||
import {
|
||||
renderCircleCheckBigIcon24,
|
||||
renderCirclePlayIcon24,
|
||||
renderCircleStopIcon24,
|
||||
renderCogIcon24,
|
||||
renderPauseIcon24,
|
||||
renderPlayIcon24,
|
||||
renderRotateCcwIcon24,
|
||||
renderSquareChartGanttIcon24,
|
||||
} from '../../../../icons';
|
||||
import { insertIf } from '../../../../helpers';
|
||||
|
||||
interface ActionProps {
|
||||
loading: boolean;
|
||||
visible: boolean;
|
||||
disabled: boolean;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
interface IRenderAvailableActionsProps {
|
||||
restart: ActionProps;
|
||||
start: ActionProps;
|
||||
stop: ActionProps;
|
||||
enable: ActionProps;
|
||||
disable: ActionProps;
|
||||
globalCheck: ActionProps;
|
||||
viewLogs: ActionProps;
|
||||
showSingBoxConfig: ActionProps;
|
||||
}
|
||||
|
||||
export function renderAvailableActions({
|
||||
restart,
|
||||
start,
|
||||
stop,
|
||||
enable,
|
||||
disable,
|
||||
globalCheck,
|
||||
viewLogs,
|
||||
showSingBoxConfig,
|
||||
}: IRenderAvailableActionsProps) {
|
||||
return E('div', { class: 'fkp_diagnostic-page__right-bar__actions' }, [
|
||||
E('b', {}, _('Available actions')),
|
||||
...insertIf(restart.visible, [
|
||||
renderButton({
|
||||
classNames: ['cbi-button-apply'],
|
||||
onClick: restart.onClick,
|
||||
icon: renderRotateCcwIcon24,
|
||||
text: _('Restart Forkop'),
|
||||
loading: restart.loading,
|
||||
disabled: restart.disabled,
|
||||
}),
|
||||
]),
|
||||
...insertIf(stop.visible, [
|
||||
renderButton({
|
||||
classNames: ['cbi-button-remove'],
|
||||
onClick: stop.onClick,
|
||||
icon: renderCircleStopIcon24,
|
||||
text: _('Stop Forkop'),
|
||||
loading: stop.loading,
|
||||
disabled: stop.disabled,
|
||||
}),
|
||||
]),
|
||||
...insertIf(start.visible, [
|
||||
renderButton({
|
||||
classNames: ['cbi-button-save'],
|
||||
onClick: start.onClick,
|
||||
icon: renderCirclePlayIcon24,
|
||||
text: _('Start Forkop'),
|
||||
loading: start.loading,
|
||||
disabled: start.disabled,
|
||||
}),
|
||||
]),
|
||||
...insertIf(disable.visible, [
|
||||
renderButton({
|
||||
classNames: ['cbi-button-remove'],
|
||||
onClick: disable.onClick,
|
||||
icon: renderPauseIcon24,
|
||||
text: _('Disable autostart'),
|
||||
loading: disable.loading,
|
||||
disabled: disable.disabled,
|
||||
}),
|
||||
]),
|
||||
...insertIf(enable.visible, [
|
||||
renderButton({
|
||||
classNames: ['cbi-button-save'],
|
||||
onClick: enable.onClick,
|
||||
icon: renderPlayIcon24,
|
||||
text: _('Enable autostart'),
|
||||
loading: enable.loading,
|
||||
disabled: enable.disabled,
|
||||
}),
|
||||
]),
|
||||
...insertIf(globalCheck.visible, [
|
||||
renderButton({
|
||||
onClick: globalCheck.onClick,
|
||||
icon: renderCircleCheckBigIcon24,
|
||||
text: _('Get global check'),
|
||||
loading: globalCheck.loading,
|
||||
disabled: globalCheck.disabled,
|
||||
}),
|
||||
]),
|
||||
...insertIf(viewLogs.visible, [
|
||||
renderButton({
|
||||
onClick: viewLogs.onClick,
|
||||
icon: renderSquareChartGanttIcon24,
|
||||
text: _('View logs'),
|
||||
loading: viewLogs.loading,
|
||||
disabled: viewLogs.disabled,
|
||||
}),
|
||||
]),
|
||||
...insertIf(showSingBoxConfig.visible, [
|
||||
renderButton({
|
||||
onClick: showSingBoxConfig.onClick,
|
||||
icon: renderCogIcon24,
|
||||
text: _('Show sing-box config'),
|
||||
loading: showSingBoxConfig.loading,
|
||||
disabled: showSingBoxConfig.disabled,
|
||||
}),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
@@ -1,190 +0,0 @@
|
||||
import {
|
||||
renderCheckIcon24,
|
||||
renderCircleAlertIcon24,
|
||||
renderCircleCheckIcon24,
|
||||
renderCircleSlashIcon24,
|
||||
renderCircleXIcon24,
|
||||
renderLoaderCircleIcon24,
|
||||
renderTriangleAlertIcon24,
|
||||
renderXIcon24,
|
||||
} from '../../../../icons';
|
||||
import { IDiagnosticsChecksStoreItem } from '../../../services';
|
||||
|
||||
type IRenderCheckSectionProps = IDiagnosticsChecksStoreItem;
|
||||
|
||||
function renderCheckSummary(items: IRenderCheckSectionProps['items']) {
|
||||
if (!items.length) {
|
||||
return E('div', {}, '');
|
||||
}
|
||||
|
||||
const renderedItems = items.map((item) => {
|
||||
function getIcon() {
|
||||
const iconWrap = E('span', {
|
||||
class: 'fkp_diagnostic_alert__summary__item__icon',
|
||||
});
|
||||
|
||||
if (item.state === 'success') {
|
||||
iconWrap.appendChild(renderCheckIcon24());
|
||||
}
|
||||
|
||||
if (item.state === 'warning') {
|
||||
iconWrap.appendChild(renderTriangleAlertIcon24());
|
||||
}
|
||||
|
||||
if (item.state === 'error') {
|
||||
iconWrap.appendChild(renderXIcon24());
|
||||
}
|
||||
|
||||
return iconWrap;
|
||||
}
|
||||
|
||||
return E(
|
||||
'div',
|
||||
{
|
||||
class: `fkp_diagnostic_alert__summary__item fkp_diagnostic_alert__summary__item--${item.state}`,
|
||||
},
|
||||
[getIcon(), E('b', {}, item.key), E('div', {}, item.value)],
|
||||
);
|
||||
});
|
||||
|
||||
return E('div', { class: 'fkp_diagnostic_alert__summary' }, renderedItems);
|
||||
}
|
||||
|
||||
function renderLoadingState(props: IRenderCheckSectionProps) {
|
||||
const iconWrap = E('span', { class: 'fkp_diagnostic_alert__icon' });
|
||||
iconWrap.appendChild(renderLoaderCircleIcon24());
|
||||
|
||||
return E(
|
||||
'div',
|
||||
{ class: 'fkp_diagnostic_alert fkp_diagnostic_alert--loading' },
|
||||
[
|
||||
iconWrap,
|
||||
E('div', { class: 'fkp_diagnostic_alert__content' }, [
|
||||
E('b', { class: 'fkp_diagnostic_alert__title' }, props.title),
|
||||
E(
|
||||
'div',
|
||||
{ class: 'fkp_diagnostic_alert__description' },
|
||||
props.description,
|
||||
),
|
||||
]),
|
||||
E('div', {}, ''),
|
||||
renderCheckSummary(props.items),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
function renderWarningState(props: IRenderCheckSectionProps) {
|
||||
const iconWrap = E('span', { class: 'fkp_diagnostic_alert__icon' });
|
||||
iconWrap.appendChild(renderCircleAlertIcon24());
|
||||
|
||||
return E(
|
||||
'div',
|
||||
{ class: 'fkp_diagnostic_alert fkp_diagnostic_alert--warning' },
|
||||
[
|
||||
iconWrap,
|
||||
E('div', { class: 'fkp_diagnostic_alert__content' }, [
|
||||
E('b', { class: 'fkp_diagnostic_alert__title' }, props.title),
|
||||
E(
|
||||
'div',
|
||||
{ class: 'fkp_diagnostic_alert__description' },
|
||||
props.description,
|
||||
),
|
||||
]),
|
||||
E('div', {}, ''),
|
||||
renderCheckSummary(props.items),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
function renderErrorState(props: IRenderCheckSectionProps) {
|
||||
const iconWrap = E('span', { class: 'fkp_diagnostic_alert__icon' });
|
||||
iconWrap.appendChild(renderCircleXIcon24());
|
||||
|
||||
return E(
|
||||
'div',
|
||||
{ class: 'fkp_diagnostic_alert fkp_diagnostic_alert--error' },
|
||||
[
|
||||
iconWrap,
|
||||
E('div', { class: 'fkp_diagnostic_alert__content' }, [
|
||||
E('b', { class: 'fkp_diagnostic_alert__title' }, props.title),
|
||||
E(
|
||||
'div',
|
||||
{ class: 'fkp_diagnostic_alert__description' },
|
||||
props.description,
|
||||
),
|
||||
]),
|
||||
E('div', {}, ''),
|
||||
renderCheckSummary(props.items),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
function renderSuccessState(props: IRenderCheckSectionProps) {
|
||||
const iconWrap = E('span', { class: 'fkp_diagnostic_alert__icon' });
|
||||
iconWrap.appendChild(renderCircleCheckIcon24());
|
||||
|
||||
return E(
|
||||
'div',
|
||||
{ class: 'fkp_diagnostic_alert fkp_diagnostic_alert--success' },
|
||||
[
|
||||
iconWrap,
|
||||
E('div', { class: 'fkp_diagnostic_alert__content' }, [
|
||||
E('b', { class: 'fkp_diagnostic_alert__title' }, props.title),
|
||||
E(
|
||||
'div',
|
||||
{ class: 'fkp_diagnostic_alert__description' },
|
||||
props.description,
|
||||
),
|
||||
]),
|
||||
E('div', {}, ''),
|
||||
renderCheckSummary(props.items),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
function renderSkippedState(props: IRenderCheckSectionProps) {
|
||||
const iconWrap = E('span', { class: 'fkp_diagnostic_alert__icon' });
|
||||
iconWrap.appendChild(renderCircleSlashIcon24());
|
||||
|
||||
return E(
|
||||
'div',
|
||||
{ class: 'fkp_diagnostic_alert fkp_diagnostic_alert--skipped' },
|
||||
[
|
||||
iconWrap,
|
||||
E('div', { class: 'fkp_diagnostic_alert__content' }, [
|
||||
E('b', { class: 'fkp_diagnostic_alert__title' }, props.title),
|
||||
E(
|
||||
'div',
|
||||
{ class: 'fkp_diagnostic_alert__description' },
|
||||
props.description,
|
||||
),
|
||||
]),
|
||||
E('div', {}, ''),
|
||||
renderCheckSummary(props.items),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
export function renderCheckSection(props: IRenderCheckSectionProps) {
|
||||
if (props.state === 'loading') {
|
||||
return renderLoadingState(props);
|
||||
}
|
||||
|
||||
if (props.state === 'warning') {
|
||||
return renderWarningState(props);
|
||||
}
|
||||
|
||||
if (props.state === 'error') {
|
||||
return renderErrorState(props);
|
||||
}
|
||||
|
||||
if (props.state === 'success') {
|
||||
return renderSuccessState(props);
|
||||
}
|
||||
|
||||
if (props.state === 'skipped') {
|
||||
return renderSkippedState(props);
|
||||
}
|
||||
|
||||
return E('div', {}, _('Not implement yet'));
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
import { renderButton } from '../../../../partials';
|
||||
import { renderSearchIcon24 } from '../../../../icons';
|
||||
|
||||
interface IRenderDiagnosticRunActionProps {
|
||||
loading: boolean;
|
||||
disabled?: boolean;
|
||||
click: () => void;
|
||||
}
|
||||
|
||||
export function renderRunAction({
|
||||
loading,
|
||||
disabled,
|
||||
click,
|
||||
}: IRenderDiagnosticRunActionProps) {
|
||||
return E('div', { class: 'fkp_diagnostic-page__run_check_wrapper' }, [
|
||||
renderButton({
|
||||
text: _('Run Diagnostic'),
|
||||
onClick: click,
|
||||
icon: renderSearchIcon24,
|
||||
loading,
|
||||
disabled,
|
||||
classNames: ['cbi-button-apply'],
|
||||
}),
|
||||
]);
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
import { insertIf } from '../../../../helpers';
|
||||
|
||||
interface IRenderSystemInfoRow {
|
||||
key: string;
|
||||
value: string;
|
||||
tag?: {
|
||||
label: string;
|
||||
kind: 'neutral' | 'warning' | 'success';
|
||||
};
|
||||
}
|
||||
|
||||
interface IRenderSystemInfoProps {
|
||||
items: Array<IRenderSystemInfoRow>;
|
||||
}
|
||||
|
||||
export function renderSystemInfo({ items }: IRenderSystemInfoProps) {
|
||||
return E('div', { class: 'fkp_diagnostic-page__right-bar__system-info' }, [
|
||||
E(
|
||||
'b',
|
||||
{ class: 'fkp_diagnostic-page__right-bar__system-info__title' },
|
||||
_('System information'),
|
||||
),
|
||||
...items.map((item) => {
|
||||
const tagClass = [
|
||||
'fkp_diagnostic-page__right-bar__system-info__row__tag',
|
||||
...insertIf(item.tag?.kind === 'neutral', [
|
||||
'fkp_diagnostic-page__right-bar__system-info__row__tag--neutral',
|
||||
]),
|
||||
...insertIf(item.tag?.kind === 'warning', [
|
||||
'fkp_diagnostic-page__right-bar__system-info__row__tag--warning',
|
||||
]),
|
||||
...insertIf(item.tag?.kind === 'success', [
|
||||
'fkp_diagnostic-page__right-bar__system-info__row__tag--success',
|
||||
]),
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
|
||||
return E(
|
||||
'div',
|
||||
{ class: 'fkp_diagnostic-page__right-bar__system-info__row' },
|
||||
[
|
||||
E('b', {}, item.key),
|
||||
E('div', {}, [
|
||||
E('span', {}, item.value),
|
||||
E('span', { class: tagClass }, item?.tag?.label),
|
||||
]),
|
||||
],
|
||||
);
|
||||
}),
|
||||
]);
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
import { renderBookOpenTextIcon24 } from '../../../../icons';
|
||||
import { renderButton } from '../../../../partials';
|
||||
import { insertIf } from '../../../../helpers';
|
||||
|
||||
export function renderWikiDisclaimer(kind: 'default' | 'error' | 'warning') {
|
||||
const iconWrap = E('span', {
|
||||
class: 'fkp_diagnostic-page__right-bar__wiki__icon',
|
||||
});
|
||||
iconWrap.appendChild(renderBookOpenTextIcon24());
|
||||
|
||||
const className = [
|
||||
'fkp_diagnostic-page__right-bar__wiki',
|
||||
...insertIf(kind === 'error', [
|
||||
'fkp_diagnostic-page__right-bar__wiki--error',
|
||||
]),
|
||||
...insertIf(kind === 'warning', [
|
||||
'fkp_diagnostic-page__right-bar__wiki--warning',
|
||||
]),
|
||||
].join(' ');
|
||||
|
||||
return E('div', { class: className }, [
|
||||
E('div', { class: 'fkp_diagnostic-page__right-bar__wiki__content' }, [
|
||||
iconWrap,
|
||||
E('div', { class: 'fkp_diagnostic-page__right-bar__wiki__texts' }, [
|
||||
E('b', {}, _('Troubleshooting')),
|
||||
E('div', {}, _('Do not panic, everything can be fixed, just...')),
|
||||
]),
|
||||
]),
|
||||
renderButton({
|
||||
classNames: ['cbi-button-save'],
|
||||
text: _('Open Project Page'),
|
||||
onClick: () =>
|
||||
window.open(
|
||||
'https://github.com/ushan0v/forkop#readme',
|
||||
'_blank',
|
||||
'noopener,noreferrer',
|
||||
),
|
||||
}),
|
||||
]);
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
export function render() {
|
||||
return E('div', { id: 'diagnostic-status', class: 'fkp_diagnostic-page' }, [
|
||||
E('div', { class: 'fkp_diagnostic-page__left-bar' }, [
|
||||
E('div', { id: 'fkp_diagnostic-page-run-check' }),
|
||||
E('div', {
|
||||
class: 'fkp_diagnostic-page__checks',
|
||||
id: 'fkp_diagnostic-page-checks',
|
||||
}),
|
||||
]),
|
||||
E('div', { class: 'fkp_diagnostic-page__right-bar' }, [
|
||||
E('div', { id: 'fkp_diagnostic-page-wiki' }),
|
||||
E('div', { id: 'fkp_diagnostic-page-actions' }),
|
||||
E('div', { id: 'fkp_diagnostic-page-system-info' }),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
@@ -1,142 +0,0 @@
|
||||
type LoadingActionState = {
|
||||
loading: boolean;
|
||||
};
|
||||
|
||||
type DiagnosticServiceActions = {
|
||||
restart: LoadingActionState;
|
||||
start: LoadingActionState;
|
||||
stop: LoadingActionState;
|
||||
enable: LoadingActionState;
|
||||
disable: LoadingActionState;
|
||||
};
|
||||
|
||||
type ComponentActions = Record<string, LoadingActionState>;
|
||||
|
||||
export function isServiceTransitionStatus(status: string) {
|
||||
return ['starting', 'stopping', 'restarting', 'reloading'].includes(status);
|
||||
}
|
||||
|
||||
export function getServiceTransition(status: string) {
|
||||
return {
|
||||
starting: status === 'starting',
|
||||
stopping: status === 'stopping',
|
||||
restarting: status === 'restarting' || status === 'reloading',
|
||||
};
|
||||
}
|
||||
|
||||
export function hasLocalMutatingServiceActionLoading(
|
||||
actions: DiagnosticServiceActions,
|
||||
) {
|
||||
return (
|
||||
actions.restart.loading ||
|
||||
actions.start.loading ||
|
||||
actions.stop.loading ||
|
||||
actions.enable.loading ||
|
||||
actions.disable.loading
|
||||
);
|
||||
}
|
||||
|
||||
export function shouldSkipServicesInfoAutoRefresh({
|
||||
force,
|
||||
localMutatingActionLoading,
|
||||
}: {
|
||||
force: boolean;
|
||||
localMutatingActionLoading: boolean;
|
||||
}) {
|
||||
return !force && localMutatingActionLoading;
|
||||
}
|
||||
|
||||
export function shouldResetDiagnosticsChecks({
|
||||
resetChecks,
|
||||
diagnosticsRunLoading,
|
||||
}: {
|
||||
resetChecks: boolean;
|
||||
diagnosticsRunLoading: boolean;
|
||||
}) {
|
||||
return resetChecks && !diagnosticsRunLoading;
|
||||
}
|
||||
|
||||
export function shouldDisableDiagnosticRunAction({
|
||||
providerInfoLoaded,
|
||||
servicesInfoLoading,
|
||||
forkopRunning,
|
||||
mutatingServiceActionLoading,
|
||||
}: {
|
||||
providerInfoLoaded: boolean;
|
||||
servicesInfoLoading: boolean;
|
||||
forkopRunning: boolean;
|
||||
mutatingServiceActionLoading: boolean;
|
||||
}) {
|
||||
return (
|
||||
!providerInfoLoaded ||
|
||||
servicesInfoLoading ||
|
||||
!forkopRunning ||
|
||||
mutatingServiceActionLoading
|
||||
);
|
||||
}
|
||||
|
||||
export function hasComponentActionLoading(actions: ComponentActions) {
|
||||
return Object.values(actions).some((action) => action.loading);
|
||||
}
|
||||
|
||||
export function getAvailableActionsDisabledState({
|
||||
servicesInfoLoading,
|
||||
mutatingServiceActionLoading,
|
||||
componentActionLoading,
|
||||
}: {
|
||||
servicesInfoLoading: boolean;
|
||||
mutatingServiceActionLoading: boolean;
|
||||
componentActionLoading: boolean;
|
||||
}) {
|
||||
return {
|
||||
serviceControlsDisabled:
|
||||
servicesInfoLoading ||
|
||||
mutatingServiceActionLoading ||
|
||||
componentActionLoading,
|
||||
utilityActionsDisabled:
|
||||
mutatingServiceActionLoading || componentActionLoading,
|
||||
viewLogsDisabled: false,
|
||||
};
|
||||
}
|
||||
|
||||
export function shouldShowRestartAction({
|
||||
forkopRunning,
|
||||
restartLoading,
|
||||
startLoading,
|
||||
stopLoading,
|
||||
}: {
|
||||
forkopRunning: boolean;
|
||||
restartLoading: boolean;
|
||||
startLoading: boolean;
|
||||
stopLoading: boolean;
|
||||
}) {
|
||||
return restartLoading || (forkopRunning && !startLoading && !stopLoading);
|
||||
}
|
||||
|
||||
export function shouldShowStartAction({
|
||||
forkopRunning,
|
||||
restartLoading,
|
||||
startLoading,
|
||||
stopLoading,
|
||||
}: {
|
||||
forkopRunning: boolean;
|
||||
restartLoading: boolean;
|
||||
startLoading: boolean;
|
||||
stopLoading: boolean;
|
||||
}) {
|
||||
return startLoading || (!restartLoading && !forkopRunning && !stopLoading);
|
||||
}
|
||||
|
||||
export function shouldShowStopAction({
|
||||
forkopRunning,
|
||||
restartLoading,
|
||||
startLoading,
|
||||
stopLoading,
|
||||
}: {
|
||||
forkopRunning: boolean;
|
||||
restartLoading: boolean;
|
||||
startLoading: boolean;
|
||||
stopLoading: boolean;
|
||||
}) {
|
||||
return stopLoading || restartLoading || (forkopRunning && !startLoading);
|
||||
}
|
||||
@@ -1,203 +0,0 @@
|
||||
// language=CSS
|
||||
import { FORKOP_UCI_PACKAGE as FORKOP_CBI_PREFIX } from '../../../constants';
|
||||
|
||||
export const styles = `
|
||||
|
||||
#cbi-${FORKOP_CBI_PREFIX}-diagnostic-_mount_node > div {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
#cbi-${FORKOP_CBI_PREFIX}-diagnostic > h3 {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.fkp_diagnostic-page {
|
||||
display: grid;
|
||||
grid-template-columns: 2fr 1fr;
|
||||
grid-column-gap: 10px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
@media (max-width: 800px) {
|
||||
.fkp_diagnostic-page {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.fkp_diagnostic-page__right-bar {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
grid-row-gap: 10px;
|
||||
}
|
||||
|
||||
.fkp_diagnostic-page__right-bar__wiki {
|
||||
border: 2px var(--background-color-low, lightgray) solid;
|
||||
border-radius: 4px;
|
||||
padding: 10px;
|
||||
|
||||
display: grid;
|
||||
grid-template-columns: auto;
|
||||
grid-row-gap: 10px;
|
||||
}
|
||||
|
||||
.fkp_diagnostic-page__right-bar__wiki--warning {
|
||||
border: 2px var(--warn-color-medium, orange) solid;
|
||||
}
|
||||
.fkp_diagnostic-page__right-bar__wiki--error {
|
||||
border: 2px var(--error-color-medium, red) solid;
|
||||
}
|
||||
|
||||
.fkp_diagnostic-page__right-bar__wiki__content {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 5fr;
|
||||
grid-column-gap: 10px;
|
||||
}
|
||||
|
||||
.fkp_diagnostic-page__right-bar__wiki__texts {}
|
||||
|
||||
.fkp_diagnostic-page__right-bar__actions {
|
||||
border: 2px var(--background-color-low, lightgray) solid;
|
||||
border-radius: 4px;
|
||||
padding: 10px;
|
||||
|
||||
display: grid;
|
||||
grid-template-columns: auto;
|
||||
grid-row-gap: 10px;
|
||||
|
||||
}
|
||||
|
||||
.fkp_diagnostic-page__right-bar__actions > .fkp-partial-button {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
.fkp_diagnostic-page__right-bar__system-info {
|
||||
border: 2px var(--background-color-low, lightgray) solid;
|
||||
border-radius: 4px;
|
||||
padding: 10px;
|
||||
|
||||
display: grid;
|
||||
grid-template-columns: auto;
|
||||
grid-row-gap: 10px;
|
||||
}
|
||||
|
||||
.fkp_diagnostic-page__right-bar__system-info__title {
|
||||
|
||||
}
|
||||
|
||||
.fkp_diagnostic-page__right-bar__system-info__row {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
grid-column-gap: 5px;
|
||||
}
|
||||
|
||||
.fkp_diagnostic-page__right-bar__system-info__row__tag {
|
||||
padding: 2px 4px;
|
||||
border: 1px transparent solid;
|
||||
border-radius: 4px;
|
||||
margin-left: 5px;
|
||||
}
|
||||
|
||||
.fkp_diagnostic-page__right-bar__system-info__row__tag--neutral {
|
||||
border: 1px var(--background-color-high, gray) solid;
|
||||
color: var(--text-color-medium, gray);
|
||||
}
|
||||
|
||||
.fkp_diagnostic-page__right-bar__system-info__row__tag--warning {
|
||||
border: 1px var(--warn-color-medium, orange) solid;
|
||||
color: var(--warn-color-medium, orange);
|
||||
}
|
||||
|
||||
.fkp_diagnostic-page__right-bar__system-info__row__tag--success {
|
||||
border: 1px var(--success-color-medium, green) solid;
|
||||
color: var(--success-color-medium, green);
|
||||
}
|
||||
|
||||
.fkp_diagnostic-page__left-bar {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
grid-row-gap: 10px;
|
||||
}
|
||||
|
||||
.fkp_diagnostic-page__run_check_wrapper {}
|
||||
|
||||
.fkp_diagnostic-page__run_check_wrapper button {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.fkp_diagnostic-page__checks {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
grid-row-gap: 10px;
|
||||
}
|
||||
|
||||
.fkp_diagnostic_alert {
|
||||
border: 2px var(--background-color-low, lightgray) solid;
|
||||
border-radius: 4px;
|
||||
|
||||
display: grid;
|
||||
grid-template-columns: 24px 1fr;
|
||||
grid-column-gap: 10px;
|
||||
align-items: center;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.fkp_diagnostic_alert--loading {
|
||||
border: 2px var(--primary-color-high, dodgerblue) solid;
|
||||
}
|
||||
|
||||
.fkp_diagnostic_alert--warning {
|
||||
border: 2px var(--warn-color-medium, orange) solid;
|
||||
color: var(--warn-color-medium, orange);
|
||||
}
|
||||
|
||||
.fkp_diagnostic_alert--error {
|
||||
border: 2px var(--error-color-medium, red) solid;
|
||||
color: var(--error-color-medium, red);
|
||||
}
|
||||
|
||||
.fkp_diagnostic_alert--success {
|
||||
border: 2px var(--success-color-medium, green) solid;
|
||||
color: var(--success-color-medium, green);
|
||||
}
|
||||
|
||||
.fkp_diagnostic_alert--skipped {}
|
||||
|
||||
.fkp_diagnostic_alert__icon {}
|
||||
|
||||
.fkp_diagnostic_alert__content {}
|
||||
|
||||
.fkp_diagnostic_alert__title {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.fkp_diagnostic_alert__description {}
|
||||
|
||||
.fkp_diagnostic_alert__summary {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.fkp_diagnostic_alert__summary__item {
|
||||
display: grid;
|
||||
grid-template-columns: 16px auto 1fr;
|
||||
grid-column-gap: 10px;
|
||||
}
|
||||
|
||||
.fkp_diagnostic_alert__summary__item--error {
|
||||
color: var(--error-color-medium, red);
|
||||
}
|
||||
|
||||
.fkp_diagnostic_alert__summary__item--warning {
|
||||
color: var(--warn-color-medium, orange);
|
||||
}
|
||||
|
||||
.fkp_diagnostic_alert__summary__item--success {
|
||||
color: var(--success-color-medium, green);
|
||||
}
|
||||
|
||||
.fkp_diagnostic_alert__summary__item__icon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
`;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user