Files
NeoTachyon/fe-app-podkop/src/podkop/tabs/diagnostic/initController.ts
T

832 lines
21 KiB
TypeScript

import { onMount, preserveScrollForPage } from '../../../helpers';
import { runDnsCheck } from './checks/runDnsCheck';
import { runSingBoxCheck } from './checks/runSingBoxCheck';
import { runNftCheck } from './checks/runNftCheck';
import { runFakeIPCheck } from './checks/runFakeIPCheck';
import { runZapretCheck } from './checks/runZapretCheck';
import { runByedpiCheck } from './checks/runByedpiCheck';
import {
DiagnosticsProviderOptions,
getDiagnosticsChecks,
getLoadingDiagnosticsChecks,
} from './diagnostic.store';
import { logger, store, StoreType } from '../../services';
import { ensureSystemInfo } from '../../services/systemInfo.service';
import {
renderAvailableActions,
renderCheckSection,
renderRunAction,
renderSystemInfo,
} from './partials';
import { PodkopShellMethods } from '../../methods';
import { fetchServicesInfo } from '../../fetchers';
import { normalizeCompiledVersion } from '../../../helpers/normalizeCompiledVersion';
import { renderModal } from '../../../partials';
import { PODKOP_LUCI_APP_VERSION } from '../../../constants';
import { showToast } from '../../../helpers/showToast';
import { renderWikiDisclaimer } from './partials/renderWikiDisclaimer';
import { runSectionsCheck } from './checks/runSectionsCheck';
const SERVICE_STATUS_REFRESH_INTERVAL_MS = 2000;
const SERVICE_ACTION_STATUS_TIMEOUT_MS = 45000;
let latestProviderInfoRequestId = 0;
let diagnosticLifecycleRegistered = false;
let diagnosticControllerInitialized = false;
let diagnosticMounted = false;
let diagnosticMountId = 0;
let servicesInfoRefreshTimer: ReturnType<typeof setInterval> | null = null;
let servicesInfoRefreshPromise: Promise<void> | null = null;
type ServiceRuntimeAction = 'restart' | 'start' | 'stop';
function sleep(ms: number) {
return new Promise<void>((resolve) => setTimeout(resolve, ms));
}
function getDiagnosticsProviderOptions(
systemInfo: Pick<
StoreType['diagnosticsSystemInfo'],
'zapret_installed' | 'byedpi_installed'
> = store.get().diagnosticsSystemInfo,
): DiagnosticsProviderOptions {
return {
includeZapret: Boolean(systemInfo.zapret_installed),
includeByedpi: Boolean(systemInfo.byedpi_installed),
};
}
function getNotRunningDiagnosticsChecks() {
return getDiagnosticsChecks(
_('Not running'),
getDiagnosticsProviderOptions(),
);
}
function resetDiagnosticsChecks() {
store.set({
diagnosticsChecks: getNotRunningDiagnosticsChecks(),
});
}
function setDiagnosticActionLoading(
action: keyof StoreType['diagnosticsActions'],
loading: boolean,
) {
const diagnosticsActions = store.get().diagnosticsActions;
store.set({
diagnosticsActions: {
...diagnosticsActions,
[action]: { loading },
},
});
}
function isDiagnosticMountActive(mountId = diagnosticMountId) {
return diagnosticMounted && diagnosticMountId === mountId;
}
function isMutatingServiceActionLoading() {
const actions = store.get().diagnosticsActions;
return (
actions.restart.loading ||
actions.start.loading ||
actions.stop.loading ||
actions.enable.loading ||
actions.disable.loading
);
}
function getPodkopStatusText(running: boolean, enabled: boolean) {
if (running) {
return enabled ? 'running & enabled' : 'running but disabled';
}
return enabled ? 'stopped but enabled' : 'stopped & disabled';
}
function setDisplayedPodkopRunning(running: boolean) {
const servicesInfoWidget = store.get().servicesInfoWidget;
const enabled = Boolean(servicesInfoWidget.data.podkopEnabled);
store.set({
servicesInfoWidget: {
...servicesInfoWidget,
loading: false,
data: {
...servicesInfoWidget.data,
podkopRunning: running ? 1 : 0,
podkopStatus: getPodkopStatusText(running, enabled),
},
},
});
}
async function refreshDiagnosticServicesInfo({
force = false,
mountId = diagnosticMountId,
allowInactive = false,
}: {
force?: boolean;
mountId?: number;
allowInactive?: boolean;
} = {}) {
if (!allowInactive && !isDiagnosticMountActive(mountId)) {
return;
}
if (!force && isMutatingServiceActionLoading()) {
return;
}
if (servicesInfoRefreshPromise) {
return servicesInfoRefreshPromise;
}
const promise = fetchServicesInfo()
.catch((error) => {
logger.error(
'[DIAGNOSTIC]',
'refreshDiagnosticServicesInfo failed',
error,
);
})
.finally(() => {
if (servicesInfoRefreshPromise === promise) {
servicesInfoRefreshPromise = null;
}
});
servicesInfoRefreshPromise = promise;
return promise;
}
async function waitForPodkopRunningState(expectedRunning: boolean) {
const startedAt = Date.now();
while (Date.now() - startedAt < SERVICE_ACTION_STATUS_TIMEOUT_MS) {
await refreshDiagnosticServicesInfo({ force: true, allowInactive: true });
const podkopRunning = Boolean(
store.get().servicesInfoWidget.data.podkopRunning,
);
if (podkopRunning === expectedRunning) {
return true;
}
await sleep(SERVICE_STATUS_REFRESH_INTERVAL_MS);
}
return false;
}
function startServicesInfoRefreshTimer() {
if (servicesInfoRefreshTimer) {
clearInterval(servicesInfoRefreshTimer);
}
servicesInfoRefreshTimer = setInterval(() => {
void refreshDiagnosticServicesInfo();
}, SERVICE_STATUS_REFRESH_INTERVAL_MS);
}
function stopServicesInfoRefreshTimer() {
if (!servicesInfoRefreshTimer) {
return;
}
clearInterval(servicesInfoRefreshTimer);
servicesInfoRefreshTimer = null;
}
async function fetchSystemInfo() {
const systemInfo = await ensureSystemInfo();
store.set({
diagnosticsChecks: getDiagnosticsChecks(
_('Not running'),
getDiagnosticsProviderOptions(systemInfo),
),
});
}
async function fetchDiagnosticsProviderInfo() {
const requestId = ++latestProviderInfoRequestId;
try {
const [zapretRuntime, byedpiRuntime] = await Promise.all([
PodkopShellMethods.checkZapretRuntime(),
PodkopShellMethods.checkByedpiRuntime(),
]);
if (requestId !== latestProviderInfoRequestId) {
return;
}
const currentSystemInfo = store.get().diagnosticsSystemInfo;
const nextSystemInfo = {
...currentSystemInfo,
providerInfoLoaded: true,
zapret_installed: zapretRuntime.success
? zapretRuntime.data.zapret_installed
: currentSystemInfo.zapret_installed,
byedpi_installed: byedpiRuntime.success
? byedpiRuntime.data.byedpi_installed
: currentSystemInfo.byedpi_installed,
};
if (!zapretRuntime.success) {
logger.error('[DIAGNOSTIC]', 'fetchZapretRuntime failed', zapretRuntime);
}
if (!byedpiRuntime.success) {
logger.error('[DIAGNOSTIC]', 'fetchByedpiRuntime failed', byedpiRuntime);
}
if (!nextSystemInfo.zapret_installed) {
nextSystemInfo.zapret_version = 'not installed';
}
if (!nextSystemInfo.byedpi_installed) {
nextSystemInfo.byedpi_version = 'not installed';
}
store.set({
diagnosticsSystemInfo: nextSystemInfo,
diagnosticsChecks: getDiagnosticsChecks(
_('Not running'),
getDiagnosticsProviderOptions(nextSystemInfo),
),
});
} catch (error) {
logger.error('[DIAGNOSTIC]', 'fetchDiagnosticsProviderInfo failed', error);
if (requestId === latestProviderInfoRequestId) {
const currentSystemInfo = store.get().diagnosticsSystemInfo;
store.set({
diagnosticsSystemInfo: {
...currentSystemInfo,
providerInfoLoaded: true,
},
});
}
}
}
async function ensureDiagnosticsProviderInfo() {
if (store.get().diagnosticsSystemInfo.providerInfoLoaded) {
return;
}
await fetchDiagnosticsProviderInfo();
}
function renderDiagnosticsChecks() {
logger.debug('[DIAGNOSTIC]', 'renderDiagnosticsChecks');
const diagnosticsChecks = [...store.get().diagnosticsChecks].sort(
(a, b) => a.order - b.order,
);
const container = document.getElementById('pdk_diagnostic-page-checks');
const renderedDiagnosticsChecks = diagnosticsChecks.map((check) =>
renderCheckSection(check),
);
return preserveScrollForPage(() => {
container!.replaceChildren(...renderedDiagnosticsChecks);
});
}
function renderDiagnosticRunActionWidget() {
logger.debug('[DIAGNOSTIC]', 'renderDiagnosticRunActionWidget');
const { loading } = store.get().diagnosticsRunAction;
const providerInfoLoaded =
store.get().diagnosticsSystemInfo.providerInfoLoaded;
const container = document.getElementById('pdk_diagnostic-page-run-check');
const renderedAction = renderRunAction({
loading,
disabled: !providerInfoLoaded,
click: () => runChecks(),
});
return preserveScrollForPage(() => {
container!.replaceChildren(renderedAction);
});
}
async function handleServiceRuntimeAction({
action,
command,
expectedRunning,
optimisticRunning,
}: {
action: ServiceRuntimeAction;
command: () => Promise<unknown>;
expectedRunning: boolean;
optimisticRunning?: boolean;
}) {
const actionMountId = diagnosticMountId;
setDiagnosticActionLoading(action, true);
if (optimisticRunning !== undefined) {
setDisplayedPodkopRunning(optimisticRunning);
}
try {
await command();
const reachedExpectedState =
await waitForPodkopRunningState(expectedRunning);
if (!reachedExpectedState && isDiagnosticMountActive(actionMountId)) {
logger.error(
'[DIAGNOSTIC]',
`${action} did not reach expected running state`,
);
showToast(_('Failed to execute!'), 'error');
}
} catch (e) {
logger.error('[DIAGNOSTIC]', `handleServiceRuntimeAction(${action})`, e);
} finally {
setDiagnosticActionLoading(action, false);
resetDiagnosticsChecks();
}
}
async function handleRestart() {
await handleServiceRuntimeAction({
action: 'restart',
command: PodkopShellMethods.restart,
expectedRunning: true,
optimisticRunning: false,
});
}
async function handleStart() {
await handleServiceRuntimeAction({
action: 'start',
command: PodkopShellMethods.start,
expectedRunning: true,
});
}
async function handleStop() {
await handleServiceRuntimeAction({
action: 'stop',
command: PodkopShellMethods.stop,
expectedRunning: false,
});
}
async function handleEnable() {
setDiagnosticActionLoading('enable', true);
try {
await PodkopShellMethods.enable();
} catch (e) {
logger.error('[DIAGNOSTIC]', 'handleEnable - e', e);
} finally {
await refreshDiagnosticServicesInfo({
force: true,
allowInactive: true,
});
setDiagnosticActionLoading('enable', false);
}
}
async function handleDisable() {
setDiagnosticActionLoading('disable', true);
try {
await PodkopShellMethods.disable();
} catch (e) {
logger.error('[DIAGNOSTIC]', 'handleDisable - e', e);
} finally {
await refreshDiagnosticServicesInfo({
force: true,
allowInactive: true,
});
setDiagnosticActionLoading('disable', false);
}
}
async function handleShowGlobalCheck() {
setDiagnosticActionLoading('globalCheck', true);
try {
const globalCheck = await PodkopShellMethods.globalCheck();
if (globalCheck.success) {
ui.showModal(
_('Global check'),
renderModal(globalCheck.data as string, 'global_check'),
);
} else {
logger.error('[DIAGNOSTIC]', 'handleShowGlobalCheck - e', globalCheck);
showToast(_('Failed to execute!'), 'error');
}
} catch (e) {
logger.error('[DIAGNOSTIC]', 'handleShowGlobalCheck - e', e);
showToast(_('Failed to execute!'), 'error');
} finally {
setDiagnosticActionLoading('globalCheck', false);
}
}
async function handleViewLogs() {
setDiagnosticActionLoading('viewLogs', true);
try {
const viewLogs = await PodkopShellMethods.checkLogs();
if (viewLogs.success) {
const getLatestLogs = async () => {
const latestLogs = await PodkopShellMethods.checkLogs();
if (!latestLogs.success) {
throw latestLogs;
}
return (latestLogs.data as string) ?? '';
};
ui.showModal(
_('View logs'),
renderModal(viewLogs.data as string, 'view_logs', {
getText: getLatestLogs,
refreshMs: 250,
initialAutoRefresh: true,
showAutoRefreshToggle: true,
startAtEnd: true,
}),
);
} else {
logger.error('[DIAGNOSTIC]', 'handleViewLogs - e', viewLogs);
showToast(_('Failed to execute!'), 'error');
}
} catch (e) {
logger.error('[DIAGNOSTIC]', 'handleViewLogs - e', e);
showToast(_('Failed to execute!'), 'error');
} finally {
setDiagnosticActionLoading('viewLogs', false);
}
}
async function handleShowSingBoxConfig() {
setDiagnosticActionLoading('showSingBoxConfig', true);
try {
const showSingBoxConfig = await PodkopShellMethods.showSingBoxConfig();
if (showSingBoxConfig.success) {
ui.showModal(
_('Show sing-box config'),
renderModal(
JSON.stringify(showSingBoxConfig.data, null, 2),
'show_sing_box_config',
),
);
} else {
logger.error(
'[DIAGNOSTIC]',
'handleShowSingBoxConfig - e',
showSingBoxConfig,
);
showToast(_('Failed to execute!'), 'error');
}
} catch (e) {
logger.error('[DIAGNOSTIC]', 'handleShowSingBoxConfig - e', e);
showToast(_('Failed to execute!'), 'error');
} finally {
setDiagnosticActionLoading('showSingBoxConfig', false);
}
}
function renderWikiDisclaimerWidget() {
const diagnosticsChecks = store.get().diagnosticsChecks;
function getWikiKind() {
const allResults = diagnosticsChecks.map((check) => check.state);
if (allResults.includes('error')) {
return 'error';
}
if (allResults.includes('warning')) {
return 'warning';
}
return 'default';
}
const container = document.getElementById('pdk_diagnostic-page-wiki');
return preserveScrollForPage(() => {
container!.replaceChildren(renderWikiDisclaimer(getWikiKind()));
});
}
function renderDiagnosticAvailableActionsWidget() {
const diagnosticsActions = store.get().diagnosticsActions;
const servicesInfoWidget = store.get().servicesInfoWidget;
logger.debug('[DIAGNOSTIC]', 'renderDiagnosticAvailableActionsWidget');
const podkopEnabled = Boolean(servicesInfoWidget.data.podkopEnabled);
const podkopRunning = Boolean(servicesInfoWidget.data.podkopRunning);
const restartLoading = diagnosticsActions.restart.loading;
const atLeastOneMutatingActionLoading =
restartLoading ||
diagnosticsActions.start.loading ||
diagnosticsActions.stop.loading ||
diagnosticsActions.enable.loading ||
diagnosticsActions.disable.loading;
const serviceControlsDisabled =
servicesInfoWidget.loading || atLeastOneMutatingActionLoading;
const utilityActionsDisabled = atLeastOneMutatingActionLoading;
const container = document.getElementById('pdk_diagnostic-page-actions');
const renderedActions = renderAvailableActions({
restart: {
loading: restartLoading,
visible: true,
onClick: handleRestart,
disabled: serviceControlsDisabled,
},
start: {
loading: diagnosticsActions.start.loading,
visible: !podkopRunning,
onClick: handleStart,
disabled: serviceControlsDisabled,
},
stop: {
loading: diagnosticsActions.stop.loading,
visible: podkopRunning,
onClick: handleStop,
disabled: serviceControlsDisabled,
},
enable: {
loading: diagnosticsActions.enable.loading,
visible: !podkopEnabled,
onClick: handleEnable,
disabled: serviceControlsDisabled,
},
disable: {
loading: diagnosticsActions.disable.loading,
visible: podkopEnabled,
onClick: handleDisable,
disabled: serviceControlsDisabled,
},
globalCheck: {
loading: diagnosticsActions.globalCheck.loading,
visible: true,
onClick: handleShowGlobalCheck,
disabled: utilityActionsDisabled,
},
viewLogs: {
loading: diagnosticsActions.viewLogs.loading,
visible: true,
onClick: handleViewLogs,
disabled: false,
},
showSingBoxConfig: {
loading: diagnosticsActions.showSingBoxConfig.loading,
visible: true,
onClick: handleShowSingBoxConfig,
disabled: utilityActionsDisabled,
},
});
return preserveScrollForPage(() => {
container!.replaceChildren(renderedActions);
});
}
function renderDiagnosticSystemInfoWidget() {
logger.debug('[DIAGNOSTIC]', 'renderDiagnosticSystemInfoWidget');
const diagnosticsSystemInfo = store.get().diagnosticsSystemInfo;
const container = document.getElementById('pdk_diagnostic-page-system-info');
const items = [
{
key: 'Podkop Plus',
value: normalizeCompiledVersion(diagnosticsSystemInfo.podkop_version),
},
{
key: 'Luci App',
value: normalizeCompiledVersion(PODKOP_LUCI_APP_VERSION),
},
{
key: 'Sing-box',
value: diagnosticsSystemInfo.sing_box_version,
},
];
if (diagnosticsSystemInfo.zapret_installed) {
items.push({
key: 'Zapret',
value: diagnosticsSystemInfo.zapret_version,
});
}
if (diagnosticsSystemInfo.byedpi_installed) {
items.push({
key: 'ByeDPI',
value: diagnosticsSystemInfo.byedpi_version,
});
}
items.push(
{
key: 'OS',
value: diagnosticsSystemInfo.openwrt_version,
},
{
key: 'Device',
value: diagnosticsSystemInfo.device_model,
},
);
const renderedSystemInfo = renderSystemInfo({
items,
});
return preserveScrollForPage(() => {
container!.replaceChildren(renderedSystemInfo);
});
}
async function onStoreUpdate(
next: StoreType,
prev: StoreType,
diff: Partial<StoreType>,
) {
if (diff.diagnosticsChecks) {
renderDiagnosticsChecks();
renderWikiDisclaimerWidget();
}
if (diff.diagnosticsRunAction) {
renderDiagnosticRunActionWidget();
}
if (diff.diagnosticsActions || diff.servicesInfoWidget) {
renderDiagnosticAvailableActionsWidget();
}
if (diff.diagnosticsSystemInfo) {
renderDiagnosticSystemInfoWidget();
renderDiagnosticRunActionWidget();
}
}
async function runChecks() {
try {
await ensureDiagnosticsProviderInfo();
const providerOptions = getDiagnosticsProviderOptions();
const runners = [
runDnsCheck,
runSingBoxCheck,
runNftCheck,
...(providerOptions.includeZapret ? [runZapretCheck] : []),
...(providerOptions.includeByedpi ? [runByedpiCheck] : []),
runSectionsCheck,
runFakeIPCheck,
];
store.set({
diagnosticsRunAction: { loading: true },
diagnosticsChecks:
getLoadingDiagnosticsChecks(providerOptions).diagnosticsChecks,
});
for (const runner of runners) {
try {
await runner();
} catch (e) {
logger.error('[DIAGNOSTIC]', `runChecks - ${runner.name} failed`, e);
}
}
} catch (e) {
logger.error('[DIAGNOSTIC]', 'runChecks - e', e);
} finally {
store.set({ diagnosticsRunAction: { loading: false } });
}
}
async function loadInitialDiagnosticData() {
const diagnosticStatus = document.getElementById('diagnostic-status');
if (diagnosticStatus?.isConnected && diagnosticStatus.offsetParent !== null) {
await fetchSystemInfo();
await ensureDiagnosticsProviderInfo();
}
}
function onPageMount() {
// Cleanup before mount
onPageUnmount();
diagnosticMounted = true;
diagnosticMountId += 1;
// Add new listener
store.subscribe(onStoreUpdate);
// Initial checks render
renderDiagnosticsChecks();
// Initial run checks action render
renderDiagnosticRunActionWidget();
// Initial available actions render
renderDiagnosticAvailableActionsWidget();
// Initial system info render
renderDiagnosticSystemInfoWidget();
// Initial Wiki disclaimer render
renderWikiDisclaimerWidget();
void refreshDiagnosticServicesInfo({ force: true });
startServicesInfoRefreshTimer();
void loadInitialDiagnosticData();
}
function onPageUnmount() {
diagnosticMounted = false;
diagnosticMountId += 1;
stopServicesInfoRefreshTimer();
servicesInfoRefreshPromise = null;
// Remove old listener
store.unsubscribe(onStoreUpdate);
// Clear store
store.reset(['diagnosticsRunAction']);
resetDiagnosticsChecks();
}
function registerLifecycleListeners() {
if (diagnosticLifecycleRegistered) {
return;
}
diagnosticLifecycleRegistered = true;
store.subscribe((next, prev, diff) => {
if (
diff.tabService &&
next.tabService.current !== prev.tabService.current
) {
logger.debug(
'[DIAGNOSTIC]',
'active tab diff event, active tab:',
diff.tabService.current,
);
const isDIAGNOSTICVisible = next.tabService.current === 'diagnostic';
if (isDIAGNOSTICVisible) {
logger.debug(
'[DIAGNOSTIC]',
'registerLifecycleListeners',
'onPageMount',
);
return onPageMount();
}
if (!isDIAGNOSTICVisible) {
logger.debug(
'[DIAGNOSTIC]',
'registerLifecycleListeners',
'onPageUnmount',
);
return onPageUnmount();
}
}
});
}
export async function initController(): Promise<void> {
if (diagnosticControllerInitialized) {
return;
}
diagnosticControllerInitialized = true;
onMount('diagnostic-status').then(() => {
logger.debug('[DIAGNOSTIC]', 'initController', 'onMount');
onPageMount();
registerLifecycleListeners();
});
}