From 8724f82d1a03e3ac95d5297c00588a24e3115482 Mon Sep 17 00:00:00 2001 From: Dushnilin Date: Thu, 13 Aug 2026 08:05:43 +0300 Subject: [PATCH] =?UTF-8?q?feat(ui):=20=D0=B4=D0=BE=D0=B1=D0=B0=D0=B2?= =?UTF-8?q?=D0=B8=D1=82=D1=8C=20=D0=BA=D0=BD=D0=BE=D0=BF=D0=BA=D1=83=20?= =?UTF-8?q?=D1=83=D1=81=D1=82=D0=B0=D0=BD=D0=BE=D0=B2=D0=BA=D0=B8=20=D0=BF?= =?UTF-8?q?=D1=80=D0=B8=20=D0=BD=D0=B0=D0=BB=D0=B8=D1=87=D0=B8=D0=B8=20?= =?UTF-8?q?=D0=BE=D0=B1=D0=BD=D0=BE=D0=B2=D0=BB=D0=B5=D0=BD=D0=B8=D1=8F=20?= =?UTF-8?q?=D0=B8=20=D0=B8=D1=81=D0=BF=D1=80=D0=B0=D0=B2=D0=B8=D1=82=D1=8C?= =?UTF-8?q?=20=D0=B7=D0=B0=D0=B2=D0=B8=D1=81=D0=B0=D0=BD=D0=B8=D0=B5=20?= =?UTF-8?q?=D0=BE=D0=BA=D0=BD=D0=B0=20=D1=83=D1=81=D1=82=D0=B0=D0=BD=D0=BE?= =?UTF-8?q?=D0=B2=D0=BA=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../tachyon/tabs/updates/initController.ts | 41 +++++- .../partials/renderUpdateProgressModal.ts | 47 +++++-- .../updates/tests/updateProgressModal.test.ts | 131 ++++++++++++++++++ .../resources/view/tachyon/main.js | 53 ++++++- 4 files changed, 255 insertions(+), 17 deletions(-) create mode 100644 fe-app-tachyon/src/tachyon/tabs/updates/tests/updateProgressModal.test.ts diff --git a/fe-app-tachyon/src/tachyon/tabs/updates/initController.ts b/fe-app-tachyon/src/tachyon/tabs/updates/initController.ts index ab9e5ad3..41eadca0 100644 --- a/fe-app-tachyon/src/tachyon/tabs/updates/initController.ts +++ b/fe-app-tachyon/src/tachyon/tabs/updates/initController.ts @@ -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, diff --git a/fe-app-tachyon/src/tachyon/tabs/updates/partials/renderUpdateProgressModal.ts b/fe-app-tachyon/src/tachyon/tabs/updates/partials/renderUpdateProgressModal.ts index 79ac666b..780518f2 100644 --- a/fe-app-tachyon/src/tachyon/tabs/updates/partials/renderUpdateProgressModal.ts +++ b/fe-app-tachyon/src/tachyon/tabs/updates/partials/renderUpdateProgressModal.ts @@ -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); diff --git a/fe-app-tachyon/src/tachyon/tabs/updates/tests/updateProgressModal.test.ts b/fe-app-tachyon/src/tachyon/tabs/updates/tests/updateProgressModal.test.ts new file mode 100644 index 00000000..c852ee0d --- /dev/null +++ b/fe-app-tachyon/src/tachyon/tabs/updates/tests/updateProgressModal.test.ts @@ -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, + 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(); + }); +}); diff --git a/luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/main.js b/luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/main.js index 19280904..50f60ce0 100644 --- a/luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/main.js +++ b/luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/main.js @@ -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 [];