Add latency test progress feedback

This commit is contained in:
ushan0v
2026-07-11 00:10:55 +03:00
parent 6ecfc75f8f
commit 5181218788
13 changed files with 639 additions and 27 deletions
@@ -168,6 +168,7 @@ export interface StoreType {
failed: boolean;
data: Podkop.OutboundGroup[];
latencyFetchingSections: Record<string, boolean>;
latencyProgressSections: Record<string, Podkop.LatencyActionProgress>;
selectorSwitchingSections: Record<string, string>;
subscriptionUpdatingSections: Record<string, boolean>;
};
@@ -270,6 +271,7 @@ const initialStore: StoreType = {
loading: true,
failed: false,
latencyFetchingSections: {},
latencyProgressSections: {},
selectorSwitchingSections: {},
subscriptionUpdatingSections: {},
data: [],
@@ -85,6 +85,11 @@ describe('applyUiStateToStore', () => {
section: 'main',
tag: 'AUTO',
job_id: 'latency-1',
progress: {
completed: 2,
total: 5,
failed: 1,
},
},
],
component: [
@@ -129,6 +134,13 @@ describe('applyUiStateToStore', () => {
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);
});
@@ -143,6 +155,13 @@ describe('applyUiStateToStore', () => {
...store.get().sectionsWidget,
subscriptionUpdatingSections: { main: true },
latencyFetchingSections: { main: true },
latencyProgressSections: {
main: {
completed: 1,
total: 2,
failed: 0,
},
},
},
updatesActions: {
...store.get().updatesActions,
@@ -158,6 +177,7 @@ describe('applyUiStateToStore', () => {
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);
});
@@ -187,6 +207,79 @@ describe('applyUiStateToStore', () => {
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({
@@ -42,6 +42,29 @@ function getEmptyDiagnosticsActions(): StoreType['diagnosticsActions'] {
};
}
function normalizeLatencyProgress(
progress?: Podkop.LatencyActionProgress,
): Podkop.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: Podkop.UiState) {
const currentSystemInfo = store.get().diagnosticsSystemInfo;
const nextSystemInfo = {
@@ -56,8 +79,7 @@ function applyServiceState(uiState: Podkop.UiState) {
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_compressed = uiState.capabilities.sing_box_compressed;
nextSystemInfo.sing_box_tailscale = uiState.capabilities.sing_box_tailscale;
store.set({
@@ -78,8 +100,12 @@ function applyServiceState(uiState: Podkop.UiState) {
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, Podkop.LatencyActionProgress> =
{};
const updatesActions = getEmptyUpdatesActions();
const diagnosticsActions = getEmptyDiagnosticsActions();
@@ -92,6 +118,13 @@ function applyActionState(actions: UiActionMap = {}) {
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];
}
}
}
@@ -126,6 +159,13 @@ function applyActionState(actions: UiActionMap = {}) {
for (const section of localOverlay.latencySections) {
latencyFetchingSections[section] = true;
if (
!latencyProgressSections[section] &&
currentLatencyProgressSections[section]
) {
latencyProgressSections[section] =
currentLatencyProgressSections[section];
}
}
for (const key of localOverlay.componentActions) {
@@ -141,6 +181,7 @@ function applyActionState(actions: UiActionMap = {}) {
...current.sectionsWidget,
subscriptionUpdatingSections,
latencyFetchingSections,
latencyProgressSections,
},
updatesActions,
diagnosticsActions,
@@ -19,7 +19,7 @@ import {
store,
StoreType,
} from '../../services';
import { renderSections, renderWidget } from './partials';
import { getLatencyTestLabel, renderSections, renderWidget } from './partials';
import { fetchServicesInfo } from '../../fetchers/fetchServicesInfo';
import { getClashApiSecret } from '../../methods/custom/getClashApiSecret';
import { Podkop } from '../../types';
@@ -33,6 +33,9 @@ import { isTransientRpcError } from '../../helpers/isTransientRpcError';
import { shouldShowLoadingForRestoredAction } from '../../helpers/restoredActionLoading';
const SECTIONS_REFRESH_INTERVAL_MS = 10000;
const LATENCY_TEST_BUTTON_CLASS = 'dashboard-sections-grid-item-test-latency';
const LATENCY_TEST_BUTTON_LABEL_CLASS =
'dashboard-sections-grid-item-test-latency__label';
let sectionsRefreshTimer: ReturnType<typeof setInterval> | null = null;
let sectionsRefreshPromise: Promise<boolean> | null = null;
let sectionsRefreshQueued = false;
@@ -201,6 +204,7 @@ function setLatencyFetching(
sectionName: string,
fetching: boolean,
local = false,
progress?: Podkop.LatencyActionProgress,
) {
if (local || !fetching) {
setLocalLatencyAction(sectionName, fetching && local);
@@ -210,17 +214,25 @@ function setLatencyFetching(
const latencyFetchingSections = {
...sectionsWidget.latencyFetchingSections,
};
const latencyProgressSections = {
...sectionsWidget.latencyProgressSections,
};
if (fetching) {
latencyFetchingSections[sectionName] = true;
if (progress) {
latencyProgressSections[sectionName] = progress;
}
} else {
delete latencyFetchingSections[sectionName];
delete latencyProgressSections[sectionName];
}
store.set({
sectionsWidget: {
...sectionsWidget,
latencyFetchingSections,
latencyProgressSections,
},
});
}
@@ -532,6 +544,30 @@ async function handleChooseOutbound(
}
}
function getInitialLatencyProgress(
latencyType: Podkop.LatencyActionState['latency_type'],
tag: string,
): Podkop.LatencyActionProgress | undefined {
if (latencyType !== 'proxy_list') {
return undefined;
}
try {
const tags = JSON.parse(tag);
if (!Array.isArray(tags)) {
return undefined;
}
const total = tags.filter(
(item) => typeof item === 'string' && item.length > 0,
).length;
return total > 0 ? { completed: 0, total, failed: 0 } : undefined;
} catch {
return undefined;
}
}
async function handleTestLatency(
latencyType: Podkop.LatencyActionState['latency_type'],
sectionName: string,
@@ -542,7 +578,12 @@ async function handleTestLatency(
return;
}
setLatencyFetching(sectionName, true, true);
setLatencyFetching(
sectionName,
true,
true,
getInitialLatencyProgress(latencyType, tag),
);
let jobId = '';
let ownsJobFollow = false;
let completed = false;
@@ -899,6 +940,86 @@ async function handleUpdateSubscription(section: Podkop.OutboundGroup) {
}
}
function shallowRecordEqual<T>(
left: Record<string, T>,
right: Record<string, T>,
) {
const leftKeys = Object.keys(left);
const rightKeys = Object.keys(right);
if (leftKeys.length !== rightKeys.length) {
return false;
}
return leftKeys.every((key) => left[key] === right[key]);
}
function canUpdateLatencyProgressInline(
prev: StoreType['sectionsWidget'],
next: StoreType['sectionsWidget'],
) {
return (
prev.loading === next.loading &&
prev.failed === next.failed &&
prev.data === next.data &&
shallowRecordEqual(
prev.latencyFetchingSections,
next.latencyFetchingSections,
) &&
shallowRecordEqual(
prev.subscriptionUpdatingSections,
next.subscriptionUpdatingSections,
) &&
shallowRecordEqual(
prev.selectorSwitchingSections,
next.selectorSwitchingSections,
)
);
}
function findLatencyTestButton(container: HTMLElement, sectionName: string) {
return Array.from(
container.querySelectorAll<HTMLButtonElement>(
`.${LATENCY_TEST_BUTTON_CLASS}`,
),
).find((button) => button.dataset.latencySection === sectionName);
}
function updateLatencyProgressInline(
sectionsWidget: StoreType['sectionsWidget'],
) {
const container = document.getElementById('dashboard-sections-grid');
if (!container) {
return false;
}
for (const section of sectionsWidget.data) {
if (!sectionsWidget.latencyFetchingSections[section.sectionName]) {
continue;
}
const button = findLatencyTestButton(container, section.sectionName);
const label = button?.querySelector<HTMLElement>(
`.${LATENCY_TEST_BUTTON_LABEL_CLASS}`,
);
if (!label) {
return false;
}
const text = getLatencyTestLabel(
sectionsWidget.latencyProgressSections[section.sectionName],
);
if (label.textContent !== text) {
label.textContent = text;
}
}
return true;
}
// Renderer
async function renderSectionsWidget() {
@@ -927,6 +1048,7 @@ async function renderSectionsWidget() {
onShowUrlTestInfo: () => {},
onUpdateSubscription: () => {},
latencyFetching: false,
latencyProgress: undefined,
subscriptionUpdating: false,
selectorSwitchingTag: undefined,
});
@@ -944,6 +1066,8 @@ async function renderSectionsWidget() {
latencyFetching: Boolean(
sectionsWidget.latencyFetchingSections[section.sectionName],
),
latencyProgress:
sectionsWidget.latencyProgressSections[section.sectionName],
subscriptionUpdating: Boolean(
sectionsWidget.subscriptionUpdatingSections[section.sectionName],
),
@@ -1158,12 +1282,20 @@ async function renderServicesInfoWidget() {
}
async function onStoreUpdate(
_next: StoreType,
_prev: StoreType,
next: StoreType,
prev: StoreType,
diff: Partial<StoreType>,
) {
if (diff.sectionsWidget) {
renderSectionsWidget();
const inlineUpdated =
canUpdateLatencyProgressInline(
prev.sectionsWidget,
next.sectionsWidget,
) && updateLatencyProgressInline(next.sectionsWidget);
if (!inlineUpdated) {
renderSectionsWidget();
}
}
if (diff.bandwidthWidget) {
@@ -28,6 +28,7 @@ interface IRenderSectionsProps {
) => void;
onUpdateSubscription: (section: Podkop.OutboundGroup) => void;
latencyFetching: boolean;
latencyProgress?: Podkop.LatencyActionProgress;
subscriptionUpdating: boolean;
selectorSwitchingTag?: string;
}
@@ -324,6 +325,25 @@ function renderSubscriptionUpdateAction(
);
}
export function getLatencyTestLabel(
latencyProgress?: Podkop.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,
@@ -332,6 +352,7 @@ function renderDefaultState({
onTestLatency,
onUpdateSubscription,
latencyFetching,
latencyProgress,
subscriptionUpdating,
selectorSwitchingTag,
}: IRenderSectionsProps) {
@@ -515,6 +536,7 @@ function renderDefaultState({
{
type: 'button',
class: 'btn dashboard-sections-grid-item-test-latency',
'data-latency-section': section.sectionName,
disabled: latencyFetching ? true : undefined,
click: (event: MouseEvent) => {
event.preventDefault();
@@ -527,8 +549,23 @@ function renderDefaultState({
},
},
latencyFetching
? [renderLoaderCircleIcon24(), _('Test latency')]
: _('Test latency'),
? [
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'),
),
),
],
),
@@ -51,6 +51,7 @@ export function render() {
onShowUrlTestInfo: () => {},
onUpdateSubscription: () => {},
latencyFetching: false,
latencyProgress: undefined,
subscriptionUpdating: false,
selectorSwitchingTag: undefined,
}),
+7
View File
@@ -155,6 +155,12 @@ export namespace Podkop {
outbounds: Outbound[];
}
export interface LatencyActionProgress {
completed: number;
total: number;
failed?: number;
}
interface SubscriptionTraffic {
upload?: number;
download?: number;
@@ -473,6 +479,7 @@ export namespace Podkop {
latency_type: 'group' | 'proxy' | 'proxy_list';
section: string;
tag: string;
progress?: LatencyActionProgress;
}
export interface UiState {
@@ -2049,6 +2049,18 @@ function renderSubscriptionUpdateAction(section, subscriptionUpdating, onUpdateS
subscriptionUpdating ? [renderLoaderCircleIcon24(), _("Update subscriptions")] : _("Update subscriptions")
);
}
function getLatencyTestLabel(latencyProgress) {
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,
@@ -2057,6 +2069,7 @@ function renderDefaultState({
onTestLatency,
onUpdateSubscription,
latencyFetching,
latencyProgress,
subscriptionUpdating,
selectorSwitchingTag
}) {
@@ -2199,6 +2212,7 @@ function renderDefaultState({
{
type: "button",
class: "btn dashboard-sections-grid-item-test-latency",
"data-latency-section": section.sectionName,
disabled: latencyFetching ? true : void 0,
click: (event) => {
event.preventDefault();
@@ -2209,7 +2223,22 @@ function renderDefaultState({
testLatency();
}
},
latencyFetching ? [renderLoaderCircleIcon24(), _("Test latency")] : _("Test latency")
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")
)
)
]
)
@@ -2349,6 +2378,7 @@ function render() {
onUpdateSubscription: () => {
},
latencyFetching: false,
latencyProgress: void 0,
subscriptionUpdating: false,
selectorSwitchingTag: void 0
})
@@ -4278,6 +4308,7 @@ var initialStore = {
loading: true,
failed: false,
latencyFetchingSections: {},
latencyProgressSections: {},
selectorSwitchingSections: {},
subscriptionUpdatingSections: {},
data: []
@@ -4598,6 +4629,21 @@ function getEmptyDiagnosticsActions() {
stop: { loading: false }
};
}
function normalizeLatencyProgress(progress) {
const total = Math.trunc(Number(progress?.total ?? 0));
if (!Number.isFinite(total) || total <= 0) {
return void 0;
}
const completedValue = Number(progress?.completed ?? 0);
const failedValue = Number(progress?.failed ?? 0);
const completed = Number.isFinite(completedValue) ? Math.trunc(completedValue) : 0;
const failed2 = Number.isFinite(failedValue) ? Math.trunc(failedValue) : 0;
return {
completed: Math.min(Math.max(0, completed), total),
total,
failed: Math.max(0, failed2)
};
}
function applyServiceState(uiState) {
const currentSystemInfo = store.get().diagnosticsSystemInfo;
const nextSystemInfo = {
@@ -4629,8 +4675,10 @@ function applyServiceState(uiState) {
function applyActionState(actions = {}) {
const current = store.get();
const localOverlay = getLocalActionOverlay();
const currentLatencyProgressSections = current.sectionsWidget.latencyProgressSections;
const subscriptionUpdatingSections = {};
const latencyFetchingSections = {};
const latencyProgressSections = {};
const updatesActions = getEmptyUpdatesActions();
const diagnosticsActions = getEmptyDiagnosticsActions();
for (const state of actions.subscription || []) {
@@ -4641,6 +4689,12 @@ function applyActionState(actions = {}) {
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 || []) {
@@ -4669,6 +4723,9 @@ function applyActionState(actions = {}) {
}
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 };
@@ -4680,7 +4737,8 @@ function applyActionState(actions = {}) {
sectionsWidget: {
...current.sectionsWidget,
subscriptionUpdatingSections,
latencyFetchingSections
latencyFetchingSections,
latencyProgressSections
},
updatesActions,
diagnosticsActions
@@ -5178,6 +5236,8 @@ function shouldShowLoadingForRestoredAction(state) {
// src/podkop/tabs/dashboard/initController.ts
var SECTIONS_REFRESH_INTERVAL_MS = 1e4;
var LATENCY_TEST_BUTTON_CLASS = "dashboard-sections-grid-item-test-latency";
var LATENCY_TEST_BUTTON_LABEL_CLASS = "dashboard-sections-grid-item-test-latency__label";
var sectionsRefreshTimer = null;
var sectionsRefreshPromise = null;
var sectionsRefreshQueued = false;
@@ -5304,7 +5364,7 @@ function setSelectorSwitching(sectionName, tag) {
}
});
}
function setLatencyFetching(sectionName, fetching, local = false) {
function setLatencyFetching(sectionName, fetching, local = false, progress) {
if (local || !fetching) {
setLocalLatencyAction(sectionName, fetching && local);
}
@@ -5312,15 +5372,23 @@ function setLatencyFetching(sectionName, fetching, local = false) {
const latencyFetchingSections = {
...sectionsWidget.latencyFetchingSections
};
const latencyProgressSections = {
...sectionsWidget.latencyProgressSections
};
if (fetching) {
latencyFetchingSections[sectionName] = true;
if (progress) {
latencyProgressSections[sectionName] = progress;
}
} else {
delete latencyFetchingSections[sectionName];
delete latencyProgressSections[sectionName];
}
store.set({
sectionsWidget: {
...sectionsWidget,
latencyFetchingSections
latencyFetchingSections,
latencyProgressSections
}
});
}
@@ -5563,11 +5631,33 @@ async function handleChooseOutbound(sectionName, selector, tag) {
setSelectorSwitching(sectionName);
}
}
function getInitialLatencyProgress(latencyType, tag) {
if (latencyType !== "proxy_list") {
return void 0;
}
try {
const tags = JSON.parse(tag);
if (!Array.isArray(tags)) {
return void 0;
}
const total = tags.filter(
(item) => typeof item === "string" && item.length > 0
).length;
return total > 0 ? { completed: 0, total, failed: 0 } : void 0;
} catch {
return void 0;
}
}
async function handleTestLatency(latencyType, sectionName, tag, timeout) {
if (store.get().sectionsWidget.latencyFetchingSections[sectionName]) {
return;
}
setLatencyFetching(sectionName, true, true);
setLatencyFetching(
sectionName,
true,
true,
getInitialLatencyProgress(latencyType, tag)
);
let jobId = "";
let ownsJobFollow = false;
let completed = false;
@@ -5849,6 +5939,58 @@ async function handleUpdateSubscription(section) {
}
}
}
function shallowRecordEqual(left, right) {
const leftKeys = Object.keys(left);
const rightKeys = Object.keys(right);
if (leftKeys.length !== rightKeys.length) {
return false;
}
return leftKeys.every((key) => left[key] === right[key]);
}
function canUpdateLatencyProgressInline(prev, next) {
return prev.loading === next.loading && prev.failed === next.failed && prev.data === next.data && shallowRecordEqual(
prev.latencyFetchingSections,
next.latencyFetchingSections
) && shallowRecordEqual(
prev.subscriptionUpdatingSections,
next.subscriptionUpdatingSections
) && shallowRecordEqual(
prev.selectorSwitchingSections,
next.selectorSwitchingSections
);
}
function findLatencyTestButton(container, sectionName) {
return Array.from(
container.querySelectorAll(
`.${LATENCY_TEST_BUTTON_CLASS}`
)
).find((button) => button.dataset.latencySection === sectionName);
}
function updateLatencyProgressInline(sectionsWidget) {
const container = document.getElementById("dashboard-sections-grid");
if (!container) {
return false;
}
for (const section of sectionsWidget.data) {
if (!sectionsWidget.latencyFetchingSections[section.sectionName]) {
continue;
}
const button = findLatencyTestButton(container, section.sectionName);
const label = button?.querySelector(
`.${LATENCY_TEST_BUTTON_LABEL_CLASS}`
);
if (!label) {
return false;
}
const text = getLatencyTestLabel(
sectionsWidget.latencyProgressSections[section.sectionName]
);
if (label.textContent !== text) {
label.textContent = text;
}
}
return true;
}
async function renderSectionsWidget() {
logger.debug("[DASHBOARD]", "renderSectionsWidget");
const sectionsWidget = store.get().sectionsWidget;
@@ -5878,6 +6020,7 @@ async function renderSectionsWidget() {
onUpdateSubscription: () => {
},
latencyFetching: false,
latencyProgress: void 0,
subscriptionUpdating: false,
selectorSwitchingTag: void 0
});
@@ -5893,6 +6036,7 @@ async function renderSectionsWidget() {
latencyFetching: Boolean(
sectionsWidget.latencyFetchingSections[section.sectionName]
),
latencyProgress: sectionsWidget.latencyProgressSections[section.sectionName],
subscriptionUpdating: Boolean(
sectionsWidget.subscriptionUpdatingSections[section.sectionName]
),
@@ -6065,9 +6209,15 @@ async function renderServicesInfoWidget() {
});
container.replaceChildren(renderedWidget);
}
async function onStoreUpdate(_next, _prev, diff) {
async function onStoreUpdate(next, prev, diff) {
if (diff.sectionsWidget) {
renderSectionsWidget();
const inlineUpdated = canUpdateLatencyProgressInline(
prev.sectionsWidget,
next.sectionsWidget
) && updateLatencyProgressInline(next.sectionsWidget);
if (!inlineUpdated) {
renderSectionsWidget();
}
}
if (diff.bandwidthWidget) {
renderBandwidthWidget();
+1 -1
View File
@@ -156,7 +156,7 @@ function command_spec(command) {
check_zapret2_runtime: [ "diagnostics/runtime.uc", "check-zapret2-runtime", 0 ],
check_byedpi_runtime: [ "diagnostics/runtime.uc", "check-byedpi-runtime", 0 ],
neutralize_zapret_defaults: [ "diagnostics/runtime.uc", "neutralize-zapret-defaults", 0 ],
clash_api: [ "diagnostics/runtime.uc", "clash-api", 3 ],
clash_api: [ "diagnostics/runtime.uc", "clash-api", 4 ],
show_config: [ "diagnostics/runtime.uc", "show-config", 1 ],
show_version: [ "diagnostics/runtime.uc", "show-version", 0 ],
show_sing_box_config: [ "diagnostics/runtime.uc", "show-sing-box-config", 1 ],
+19 -8
View File
@@ -1667,7 +1667,7 @@ function latency_test_url() {
return value == "" ? DEFAULT_LATENCY_TEST_URL : value;
}
function clash_api(action, arg1, arg2) {
function clash_api(action, arg1, arg2, arg3) {
let base_url = clash_api_url();
let test_url = latency_test_url();
let auth = clash_auth_args();
@@ -1704,13 +1704,22 @@ function clash_api(action, arg1, arg2) {
let tags = status_capture([ "clash-proxy-tags-lines", arg1 ], null);
if (tags.status != 0)
return clash_json_error("proxy_tags_json must be a JSON array of non-empty strings");
let count = 0;
let failed = 0;
let proxy_types = clash_proxy_type_map(base_url, auth);
let proxy_tags = [];
for (let proxy_tag in split(tags.output, "\n")) {
proxy_tag = as_string(proxy_tag);
if (proxy_tag == "")
continue;
if (proxy_tag != "")
push(proxy_tags, proxy_tag);
}
let count = 0;
let failed = 0;
let progress_path = as_string(arg3);
let total = length(proxy_tags);
if (progress_path != "")
module_success(SERVICE_UI_UC, [ "latency-progress-state", progress_path, count, total, failed ]);
let proxy_types = clash_proxy_type_map(base_url, auth);
for (let proxy_tag in proxy_tags) {
let args = [ "curl", "-G", "-s", clash_latency_endpoint(base_url, proxy_tag, proxy_types[proxy_tag]) ];
for (let item in auth) push(args, item);
push(args, "--data-urlencode");
@@ -1718,8 +1727,10 @@ function clash_api(action, arg1, arg2) {
push(args, "--data-urlencode");
push(args, "timeout=" + as_string(arg2 || "5000"));
if (status_capture([ "stdin-json" ], command_output(command_from_args(args))).status != 0)
failed = 1;
failed++;
count++;
if (progress_path != "")
module_success(SERVICE_UI_UC, [ "latency-progress-state", progress_path, count, total, failed ]);
}
let result = status_capture([ "clash-proxy-latencies-result", count, failed ], null);
if (result.output != "")
@@ -1953,7 +1964,7 @@ else if (mode == "check-byedpi-runtime")
else if (mode == "neutralize-zapret-defaults")
exit(neutralize_zapret_defaults());
else if (mode == "clash-api")
exit(clash_api(ARGV[1], ARGV[2], ARGV[3]));
exit(clash_api(ARGV[1], ARGV[2], ARGV[3], ARGV[4]));
else if (mode == "show-config")
exit(show_config(ARGV[1] || "masked"));
else if (mode == "show-version")
+85 -2
View File
@@ -55,6 +55,15 @@ function read_json_file(path) {
}
}
function parse_json_or_null(value) {
try {
return json(as_string(value));
}
catch (e) {
return null;
}
}
function write_json(value) {
print(sprintf("%J", value), "\n");
}
@@ -184,6 +193,11 @@ function arg_number(value) {
return int(value);
}
function non_negative_number(value) {
let number = arg_number(value);
return number < 0 ? 0 : number;
}
function unsigned_number(value) {
value = as_string(value);
if (value == "" || match(value, /[^0-9]/) != null)
@@ -532,8 +546,42 @@ function running_service_action_value(action, source, started_at) {
};
}
function running_latency_action_value(latency_type, section, tag, started_at) {
function latency_progress_value(completed, total, failed) {
let progress_total = non_negative_number(total);
let progress_completed = non_negative_number(completed);
if (progress_total > 0 && progress_completed > progress_total)
progress_completed = progress_total;
return {
completed: progress_completed,
total: progress_total,
failed: non_negative_number(failed)
};
}
function proxy_list_total(tag) {
let value = parse_json_or_null(tag);
if (type(value) != "array")
return 0;
let total = 0;
for (let proxy_tag in value) {
if (type(proxy_tag) == "string" && proxy_tag != "")
total++;
}
return total;
}
function initial_latency_progress(latency_type, tag) {
if (as_string(latency_type) != "proxy_list")
return null;
return latency_progress_value(0, proxy_list_total(tag), 0);
}
function running_latency_action_value(latency_type, section, tag, started_at) {
let value = {
success: true,
running: true,
kind: "latency",
@@ -546,6 +594,39 @@ function running_latency_action_value(latency_type, section, tag, started_at) {
updated_at: null,
exit_code: null
};
let progress = initial_latency_progress(latency_type, tag);
if (type(progress) == "object")
value.progress = progress;
return value;
}
function latency_action_path_allowed(path) {
path = as_string(path);
let prefix = LATENCY_ACTION_DIR + "/";
let suffix = ".json";
return path != "" &&
substr(path, 0, length(prefix)) == prefix &&
length(path) > length(prefix) + length(suffix) &&
substr(path, length(path) - length(suffix)) == suffix;
}
function update_latency_progress_state(path, completed, total, failed) {
if (!latency_action_path_allowed(path))
return false;
let value = read_json_file(path);
if (type(value) != "object" || value.kind != "latency" || value.running !== true)
return false;
value.progress = latency_progress_value(completed, total, failed);
value.updated_at = now_seconds();
return write_state_file(path, value);
}
function update_latency_progress_state_mode(path, completed, total, failed) {
exit(update_latency_progress_state(path, completed, total, failed) ? 0 : 1);
}
function finished_action_state_value(path, success, message, exit_code, updated_at) {
@@ -1170,7 +1251,7 @@ function latency_clash_method(latency_type) {
function latency_worker(path, latency_type, tag, timeout) {
let method = latency_clash_method(latency_type).method;
let status = command_status(command_from_args([ BIN_PATH, "clash_api", method, tag, timeout ]) + " >/dev/null 2>&1");
let status = command_status(command_from_args([ BIN_PATH, "clash_api", method, tag, timeout, path ]) + " >/dev/null 2>&1");
if (status == 0)
write_finished_action_state(path, true, "Latency test completed", status);
else
@@ -1318,6 +1399,8 @@ else if (mode == "service-action-update-pid")
update_service_action_pid_mode(ARGV[1], ARGV[2]);
else if (mode == "service-action-finish")
finish_service_action_mode(ARGV[1], ARGV[2], ARGV[3], ARGV[4]);
else if (mode == "latency-progress-state")
update_latency_progress_state_mode(ARGV[1], ARGV[2], ARGV[3], ARGV[4]);
else if (mode == "service-action-finish-after-command")
finish_service_action_after_command_mode(ARGV[1], ARGV[2], ARGV[3]);
else if (mode == "service-action-wait-worker")
@@ -102,6 +102,26 @@ PATH="$fake_bin:$PATH" \
grep -Fq "url=https://latency.example/generate_204" "$WORK_DIR/fake-curl.log" ||
fail "clash-api latency check must use settings.latency_test_url"
latency_action_dir="$WORK_DIR/ui-state/latency-actions"
mkdir -p "$latency_action_dir"
latency_state="$latency_action_dir/latency-1.json"
printf '%s\n' '{"success":true,"running":true,"kind":"latency","latency_type":"proxy_list","section":"main","tag":"[]","started_at":100}' >"$latency_state"
FAKE_CURL_LOG="$WORK_DIR/fake-curl-latencies.log" \
PODKOP_UCI_STATE_FILE="$uci_state" \
PODKOP_LIB="$PODKOP_LIB" \
PODKOP_UI_LATENCY_ACTION_DIR="$latency_action_dir" \
PATH="$fake_bin:$PATH" \
ucode -L "$PODKOP_LIB" "$DIAGNOSTICS_RUNTIME" clash-api get_proxy_latencies '["proxy-a","proxy-b"]' 5000 "$latency_state" >/dev/null ||
fail "clash-api get_proxy_latencies should update latency progress"
JOB_STATE="$latency_state" node - <<'NODE'
const fs = require("fs");
const value = JSON.parse(fs.readFileSync(process.env.JOB_STATE, "utf8"));
if (!value.progress || value.progress.completed !== 2 || value.progress.total !== 2 || value.progress.failed !== 0) {
console.error("latency progress after proxy list mismatch");
process.exit(1);
}
NODE
firewall_rules="$(cat <<'EOF'
firewall.@rule[0]=rule
firewall.@rule[0].enabled='1'
@@ -67,6 +67,7 @@ for mode in \
service-action-begin-if-idle \
service-action-update-pid \
service-action-finish-after-command \
latency-progress-state \
service-action-async \
service-action-status \
latency-test-async \
@@ -107,6 +108,22 @@ if ui_ucode latency-type-valid invalid >/dev/null 2>&1; then
fail "invalid latency type should be rejected"
fi
latency_action_dir="$WORK_DIR/latency-actions"
mkdir -p "$latency_action_dir"
latency_state="$latency_action_dir/latency-1.json"
printf '%s\n' '{"success":true,"running":true,"kind":"latency","latency_type":"proxy_list","section":"main","tag":"[]","started_at":100}' >"$latency_state"
PODKOP_UI_LATENCY_ACTION_DIR="$latency_action_dir" \
ui_ucode latency-progress-state "$latency_state" 2 5 1 >/dev/null ||
fail "latency-progress-state should update running latency jobs"
JOB_STATE="$latency_state" node - <<'NODE'
const fs = require("fs");
const value = JSON.parse(fs.readFileSync(process.env.JOB_STATE, "utf8"));
if (!value.progress || value.progress.completed !== 2 || value.progress.total !== 5 || value.progress.failed !== 1) {
console.error("latency progress state mismatch");
process.exit(1);
}
NODE
assert_eq "running & enabled" \
"$(ui_ucode service-status-text 1 1)" \
"running enabled status"
@@ -182,6 +199,24 @@ export PODKOP_UI_LATENCY_ACTION_DIR="$PODKOP_UI_STATE_DIR/latency-actions"
export PODKOP_UI_COMPONENT_ACTION_DIR="$PODKOP_UI_STATE_DIR/component-actions"
export PODKOP_UI_SUBSCRIPTION_ACTION_DIR="$PODKOP_UI_STATE_DIR/subscription-actions"
latency_start="$(
PODKOP_BIN=/bin/true \
PODKOP_LIB="$PODKOP_FILES/usr/lib" \
ui_ucode latency-test-async proxy_list main '["proxy-a","proxy-b"]' 5000
)"
latency_job_id="$(printf '%s' "$latency_start" | sed -n 's/.*"job_id"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p')"
[ -n "$latency_job_id" ] || fail "latency-test-async should return a job id"
latency_async_state="$PODKOP_UI_LATENCY_ACTION_DIR/$latency_job_id.json"
[ -f "$latency_async_state" ] || fail "latency-test-async should create a state file"
JOB_STATE="$latency_async_state" node - <<'NODE'
const fs = require("fs");
const value = JSON.parse(fs.readFileSync(process.env.JOB_STATE, "utf8"));
if (!value.progress || value.progress.completed !== 0 || value.progress.total !== 2 || value.progress.failed !== 0) {
console.error("initial latency progress state mismatch");
process.exit(1);
}
NODE
job_id="$(ui_ucode service-action-begin-if-idle reload test)"
[ -n "$job_id" ] || fail "service-action-begin-if-idle should create a job"
service_state="$PODKOP_UI_SERVICE_ACTION_DIR/$job_id.json"