Cleanup: удалить старые файлы forkop

This commit is contained in:
Dushnilin
2026-07-14 07:49:37 +03:00
parent 14df12a18d
commit 6e867ca2c4
285 changed files with 0 additions and 115347 deletions
-16
View File
@@ -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/
-8
View File
@@ -1,8 +0,0 @@
{
"printWidth": 80,
"tabWidth": 2,
"semi": true,
"singleQuote": true,
"trailingComma": "all",
"bracketSpacing": true
}
-38
View File
@@ -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);
});
-36
View File
@@ -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,
];
-75
View File
@@ -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}`);
-117
View File
@@ -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);
});
-91
View File
@@ -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
-40
View File
@@ -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"
}
}
-88
View File
@@ -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
-53
View File
@@ -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,
},
});
});
});
-30
View File
@@ -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