feat(ui): добавить кнопку установки при наличии обновления и исправить зависание окна установки

This commit is contained in:
Dushnilin
2026-08-13 08:05:43 +03:00
parent 901c4ea1c1
commit 8724f82d1a
4 changed files with 255 additions and 17 deletions
@@ -509,6 +509,8 @@ async function applyCompletedComponentAction({
}
const status = result.status || null;
const hasUpdate =
status === 'outdated' || status === 'outdated_same_release';
if (
status === 'latest' ||
@@ -535,7 +537,19 @@ async function applyCompletedComponentAction({
if (notify) {
showToast(getCheckToastMessage(status), 'success');
}
modalController?.completeSuccess(getCheckToastMessage(status));
if (hasUpdate) {
const installButton = getComponentInstallAction(result.component);
modalController?.completeSuccess(getCheckToastMessage(status), {
autoCloseMs: 0,
installText: installButton.text,
onInstall: () => {
void handleComponentAction(installButton);
},
});
} else {
modalController?.completeSuccess(getCheckToastMessage(status));
}
return;
}
@@ -907,6 +921,31 @@ function getInstallAction(
};
}
function getComponentInstallKey(
component: Tachyon.ComponentName,
): UpdatesActionKey {
switch (component) {
case 'tachyon':
return 'tachyonInstall';
case 'sing_box':
return 'singBoxInstall';
case 'zapret':
return 'zapretInstall';
case 'zapret2':
return 'zapret2Install';
case 'byedpi':
return 'byedpiInstall';
}
}
function getComponentInstallAction(
component: Tachyon.ComponentName,
): ComponentActionButton {
const isInstalled = !isNotInstalled(getComponentCurrentVersion(component));
const key = getComponentInstallKey(component);
return getInstallAction(component, key, isInstalled);
}
function getInstalledUpdateActions(
component: Tachyon.ComponentName,
checkKey: UpdatesActionKey,
@@ -1,4 +1,3 @@
import { showToast } from '../../../../helpers/showToast';
import { renderCheckIcon24, renderXIcon24 } from '../../../../icons';
import { renderButton } from '../../../../partials';
import { Tachyon } from '../../../types';
@@ -13,6 +12,13 @@ export interface UpdateProgressModalOptions {
targetSha?: string;
}
export interface CompleteSuccessOptions {
autoCloseMs?: number;
reloadPage?: boolean;
onInstall?: () => void;
installText?: string;
}
export interface UpdateProgressModalController {
updateStep: (stepIndex: number, statusText?: string) => void;
updateStatus: (statusText: string) => void;
@@ -22,10 +28,7 @@ export interface UpdateProgressModalController {
currentSha?: string;
targetSha?: string;
}) => void;
completeSuccess: (
message?: string,
options?: { autoCloseMs?: number; reloadPage?: boolean },
) => void;
completeSuccess: (message?: string, options?: CompleteSuccessOptions) => void;
completeError: (errorMessage: string) => void;
close: () => void;
}
@@ -338,10 +341,7 @@ export function showUpdateProgressModal(
};
titleBadgeEl.textContent = renderVersionBadgeText(currentModalVersions);
},
completeSuccess: (
message?: string,
opts?: { autoCloseMs?: number; reloadPage?: boolean },
) => {
completeSuccess: (message?: string, opts?: CompleteSuccessOptions) => {
isCompleted = true;
cleanupTimers();
@@ -397,6 +397,20 @@ export function showUpdateProgressModal(
}
}, 1000);
} else {
const actionButtons: HTMLElement[] = [];
if (opts?.onInstall) {
const installBtn = renderButton({
classNames: ['cbi-button-save'],
text: opts.installText || _('Install'),
onClick: () => {
controller.close();
opts.onInstall!();
},
});
actionButtons.push(installBtn);
}
const closeBtn = renderButton({
classNames: ['cbi-button-apply'],
text: _('Close'),
@@ -404,13 +418,22 @@ export function showUpdateProgressModal(
controller.close();
},
});
actionButtons.push(closeBtn);
actionButtonContainer.replaceChildren(closeBtn);
actionButtonContainer.replaceChildren(...actionButtons);
const autoCloseMs = opts?.autoCloseMs ?? (isCheckAction ? 1000 : 2200);
const defaultAutoCloseMs = opts?.onInstall
? 0
: isCheckAction
? 1200
: 1200;
const autoCloseMs = opts?.autoCloseMs ?? defaultAutoCloseMs;
if (autoCloseMs > 0) {
setTimeout(() => {
if (activeModalController === controller) {
if (
activeModalController === controller ||
activeModalController === null
) {
controller.close();
}
}, autoCloseMs);
@@ -0,0 +1,131 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { describe, expect, it, vi } from 'vitest';
class MockMutationObserver {
observe() {}
disconnect() {}
takeRecords() {
return [];
}
}
globalThis.MutationObserver = MockMutationObserver as any;
function createDummyElement(tag: string) {
return {
tagName: tag.toUpperCase(),
children: [] as any[],
style: {} as Record<string, string>,
setAttribute() {},
getAttribute() {
return null;
},
querySelectorAll: () => [],
querySelector: () => null,
replaceChildren: function (...nodes: any[]) {
(this as any).children = nodes;
},
appendChild: function (node: any) {
(this as any).children.push(node);
},
classList: {
add() {},
remove() {},
},
};
}
globalThis.document = {
body: {} as any,
createElement: (tag: string) => createDummyElement(tag) as any,
createElementNS: (_ns: string, tag: string) => createDummyElement(tag) as any,
getElementById: () => null,
querySelector: () => null,
querySelectorAll: () => [],
} as any;
(globalThis as any).rpc = { declare: vi.fn() };
(globalThis as any).uci = { sections: vi.fn().mockResolvedValue([]) };
(globalThis as any).localStorage = { getItem: vi.fn(), setItem: vi.fn() };
(globalThis as any).E = (tag: string, attrs?: any, children?: any) => {
const el = (globalThis as any).document.createElement(tag);
if (attrs) {
const { style, ...rest } = attrs;
Object.assign(el, rest);
if (typeof style === 'string') {
el.style.cssText = style;
} else if (style && typeof style === 'object') {
Object.assign(el.style, style);
}
}
if (children) {
el.children = Array.isArray(children) ? children : [children];
}
return el;
};
const mockUi = {
showModal: vi.fn(),
hideModal: vi.fn(),
};
(globalThis as any).ui = mockUi;
(globalThis as any)._ = (str: string) => str;
describe('renderUpdateProgressModal', () => {
it('renders install button and does not auto-close when onInstall is provided', async () => {
vi.useFakeTimers();
mockUi.showModal.mockReset();
mockUi.hideModal.mockReset();
const { showUpdateProgressModal } = await import(
'../partials/renderUpdateProgressModal'
);
const onInstall = vi.fn();
const controller = showUpdateProgressModal({
component: 'sing_box',
action: 'check_update',
componentTitle: 'sing-box',
});
controller.completeSuccess('Update is available', {
autoCloseMs: 0,
installText: 'Update',
onInstall,
});
// Verify modal element passed to showModal contains Install/Update button
const modalContent = mockUi.showModal.mock.calls[0]?.[1] as any;
expect(modalContent).toBeDefined();
// Advance time to verify auto-close did NOT fire
vi.advanceTimersByTime(5000);
expect(mockUi.hideModal).not.toHaveBeenCalled();
vi.useRealTimers();
});
it('auto-closes after completed installation without hanging', async () => {
vi.useFakeTimers();
mockUi.showModal.mockReset();
mockUi.hideModal.mockReset();
const { showUpdateProgressModal } = await import(
'../partials/renderUpdateProgressModal'
);
const controller = showUpdateProgressModal({
component: 'sing_box',
action: 'install',
componentTitle: 'sing-box',
});
controller.completeSuccess('Update completed successfully!');
// Advance timer past autoCloseMs (1200ms)
vi.advanceTimersByTime(1500);
expect(mockUi.hideModal).toHaveBeenCalled();
vi.useRealTimers();
});
});
@@ -15387,6 +15387,18 @@ function showUpdateProgressModal(options) {
}
}, 1e3);
} else {
const actionButtons = [];
if (opts?.onInstall) {
const installBtn = renderButton({
classNames: ["cbi-button-save"],
text: opts.installText || _("Install"),
onClick: () => {
controller.close();
opts.onInstall();
}
});
actionButtons.push(installBtn);
}
const closeBtn = renderButton({
classNames: ["cbi-button-apply"],
text: _("Close"),
@@ -15394,11 +15406,13 @@ function showUpdateProgressModal(options) {
controller.close();
}
});
actionButtonContainer.replaceChildren(closeBtn);
const autoCloseMs = opts?.autoCloseMs ?? (isCheckAction ? 1e3 : 2200);
actionButtons.push(closeBtn);
actionButtonContainer.replaceChildren(...actionButtons);
const defaultAutoCloseMs = opts?.onInstall ? 0 : isCheckAction ? 1200 : 1200;
const autoCloseMs = opts?.autoCloseMs ?? defaultAutoCloseMs;
if (autoCloseMs > 0) {
setTimeout(() => {
if (activeModalController === controller) {
if (activeModalController === controller || activeModalController === null) {
controller.close();
}
}, autoCloseMs);
@@ -15760,6 +15774,7 @@ async function applyCompletedComponentAction({
preserveCheckResultsOnNextMount = true;
}
const status = result.status || null;
const hasUpdate = status === "outdated" || status === "outdated_same_release";
if (status === "latest" || status === "outdated" || status === "dev" || status === "outdated_same_release") {
setCheckResult(
result.component,
@@ -15779,7 +15794,18 @@ async function applyCompletedComponentAction({
if (notify) {
showToast(getCheckToastMessage(status), "success");
}
modalController?.completeSuccess(getCheckToastMessage(status));
if (hasUpdate) {
const installButton = getComponentInstallAction(result.component);
modalController?.completeSuccess(getCheckToastMessage(status), {
autoCloseMs: 0,
installText: installButton.text,
onInstall: () => {
void handleComponentAction(installButton);
}
});
} else {
modalController?.completeSuccess(getCheckToastMessage(status));
}
return;
}
if (result.action === "install" || result.action === "reinstall" || result.action.startsWith("install_")) {
@@ -16067,6 +16093,25 @@ function getInstallAction(component, key, installed) {
action: "install"
};
}
function getComponentInstallKey(component) {
switch (component) {
case "tachyon":
return "tachyonInstall";
case "sing_box":
return "singBoxInstall";
case "zapret":
return "zapretInstall";
case "zapret2":
return "zapret2Install";
case "byedpi":
return "byedpiInstall";
}
}
function getComponentInstallAction(component) {
const isInstalled = !isNotInstalled(getComponentCurrentVersion(component));
const key = getComponentInstallKey(component);
return getInstallAction(component, key, isInstalled);
}
function getInstalledUpdateActions(component, checkKey, installKey, installed = true) {
if (!installed) {
return [];