From feb7c52b5a41c16792b53cb45ee960498b11192b Mon Sep 17 00:00:00 2001 From: Sunny Luo Date: Mon, 31 Aug 2026 11:56:52 +0800 Subject: [PATCH 01/20] fix(admin): tolerate unavailable statistics and load failures Treat Redis-backed user metrics as nullable, render missing values as dashes, and replace the admin-route infinite spinner with an explicit retry state. --- src/components/admin-route.tsx | 59 ++++++++++++++++++++++++--- src/components/user-detail-drawer.tsx | 42 +++++++++---------- src/services/admin-api.ts | 8 ++-- 3 files changed, 76 insertions(+), 33 deletions(-) diff --git a/src/components/admin-route.tsx b/src/components/admin-route.tsx index c397a10..da4dcec 100644 --- a/src/components/admin-route.tsx +++ b/src/components/admin-route.tsx @@ -1,15 +1,37 @@ -import { Spin } from 'antd'; +import { useQuery } from '@tanstack/react-query'; +import { Button, Result, Spin } from 'antd'; +import { useTranslation } from 'react-i18next'; import { Navigate, Outlet } from 'react-router-dom'; -import { useUserInfo } from '@/utils/hooks'; +import { rootRouterPath } from '@/router'; +import { api } from '@/services/api'; +import { hasSession } from '@/services/request'; +import { userKeys } from '@/utils/query-keys'; /** * 管理员路由的门控:子路由用 react-router 自带的 `lazy` 按需加载, - * 这里只负责在用户信息就绪前占位、非管理员时跳走。 + * 这里只负责等待用户信息、处理读取失败并拦截非管理员。 */ export function AdminRoute() { - const { isLoading, user } = useUserInfo(); + const { t } = useTranslation(); + const sessionAvailable = hasSession(); + const { + data: user, + error, + isError, + isFetching, + isLoading, + refetch, + } = useQuery({ + queryKey: userKeys.info(), + queryFn: api.me, + enabled: sessionAvailable, + }); - if (isLoading || user === undefined) { + if (!sessionAvailable) { + return ; + } + + if (isLoading || (!isError && user === undefined)) { return (
@@ -17,8 +39,33 @@ export function AdminRoute() { ); } + if (isError) { + return ( +
+ void refetch()} + > + {t('error_boundary.retry')} + + } + /> +
+ ); + } + if (!user?.admin) { - return ; + return ; } return ; diff --git a/src/components/user-detail-drawer.tsx b/src/components/user-detail-drawer.tsx index 67765ef..2a4f40f 100644 --- a/src/components/user-detail-drawer.tsx +++ b/src/components/user-detail-drawer.tsx @@ -97,6 +97,8 @@ export const UserDetailDrawer = ({ }); const translate = (key: string) => t(key); + const renderChecks = (value: number | null | undefined) => + value == null ? '-' : t('admin_users.checks_value', { value }); const detail = data; @@ -201,41 +203,35 @@ export const UserDetailDrawer = ({ column={2} > - {t('admin_users.checks_value', { - value: detail.quotaDetail.limit.pv, - })} + {renderChecks(detail.quotaDetail.limit.pv)} - {t('admin_users.checks_value', { - value: detail.quotaDetail.todayUsed, - })} + {renderChecks(detail.quotaDetail.todayUsed)} - {t('admin_users.checks_value', { - value: detail.quotaDetail.todayRemaining, - })} + {renderChecks(detail.quotaDetail.todayRemaining)} - {t('admin_users.checks_value', { - value: detail.quotaDetail.last7Days.avg, - })} + {renderChecks(detail.quotaDetail.last7Days?.avg)} - {detail.quotaDetail.last7Days.counts - .slice() - .reverse() - .map((c, i) => ( - // biome-ignore lint/suspicious/noArrayIndexKey: fixed-length ordered day list, index is the stable identity - - {t('admin_users.day_label', { day: i + 1 })}:{' '} - {c} - - ))} + {detail.quotaDetail.last7Days + ? detail.quotaDetail.last7Days.counts + .slice() + .reverse() + .map((count, index) => ( + // biome-ignore lint/suspicious/noArrayIndexKey: fixed-length ordered day list, index is the stable identity + + {t('admin_users.day_label', { day: index + 1 })}:{' '} + {count} + + )) + : '-'} {detail.apps.length} / {detail.quotaDetail.limit.app} @@ -265,7 +261,7 @@ export const UserDetailDrawer = ({ - PV: {app.checkCount} + PV: {app.checkCount ?? '-'} {translate('admin_users.packages_count')}:{' '} diff --git a/src/services/admin-api.ts b/src/services/admin-api.ts index 4718e62..d6e1144 100644 --- a/src/services/admin-api.ts +++ b/src/services/admin-api.ts @@ -209,16 +209,16 @@ export const adminApi = { }; quotaDetail: { limit: Quota; - todayRemaining: number; - todayUsed: number; + todayRemaining: number | null; + todayUsed: number | null; last7Days: { counts: number[]; avg: number; - }; + } | null; }; apps: Array< AdminApp & { - checkCount: number; + checkCount: number | null; packagesCount: number; } >; From 003eb54e6d25241c8c4a70ff194999185f06499c Mon Sep 17 00:00:00 2001 From: Sunny Luo Date: Mon, 31 Aug 2026 11:57:47 +0800 Subject: [PATCH 02/20] fix(cache): avoid incomplete app cache entries Keep missing list/detail caches undefined, refetch canonical app data after create/update/delete, and cover the cache updater behavior with unit tests. --- src/services/mutation-cache.test.ts | 41 +++++++++++++++++++++ src/services/mutation-cache.ts | 36 ++++++++++++++++++ src/services/mutations.ts | 57 +++++++++++++++-------------- 3 files changed, 107 insertions(+), 27 deletions(-) create mode 100644 src/services/mutation-cache.test.ts create mode 100644 src/services/mutation-cache.ts diff --git a/src/services/mutation-cache.test.ts b/src/services/mutation-cache.test.ts new file mode 100644 index 0000000..69e3270 --- /dev/null +++ b/src/services/mutation-cache.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, test } from 'bun:test'; +import type { App } from '@/types'; +import { + removeAppFromListCache, + updateAppDetailCache, + updateAppInListCache, +} from './mutation-cache'; + +const apps: App[] = [ + { id: 1, name: 'one', platform: 'android', appKey: 'key-1' }, + { id: 2, name: 'two', platform: 'ios', appKey: 'key-2' }, +]; + +describe('app mutation cache updaters', () => { + test('leave missing caches missing instead of synthesizing partial data', () => { + expect(removeAppFromListCache(undefined, 1)).toBeUndefined(); + expect(updateAppInListCache(undefined, 1, { name: 'renamed' })).toBeUndefined(); + expect(updateAppDetailCache(undefined, { name: 'renamed' })).toBeUndefined(); + }); + + test('remove only the requested app from an existing list', () => { + expect(removeAppFromListCache({ data: apps }, 1)).toEqual({ + data: [apps[1]], + }); + }); + + test('update existing list and detail entries without mutating the source', () => { + const list = { data: apps }; + const detail = apps[0]; + + expect(updateAppInListCache(list, 1, { name: 'renamed' })).toEqual({ + data: [{ ...apps[0], name: 'renamed' }, apps[1]], + }); + expect(updateAppDetailCache(detail, { name: 'renamed' })).toEqual({ + ...detail, + name: 'renamed', + }); + expect(list.data?.[0]?.name).toBe('one'); + expect(detail?.name).toBe('one'); + }); +}); diff --git a/src/services/mutation-cache.ts b/src/services/mutation-cache.ts new file mode 100644 index 0000000..845d982 --- /dev/null +++ b/src/services/mutation-cache.ts @@ -0,0 +1,36 @@ +import type { App } from '@/types'; + +export type AppListCache = { data?: App[] }; + +/** Do not create a synthetic empty list when the list query was never loaded. */ +export const removeAppFromListCache = ( + old: AppListCache | undefined, + appId: number, +): AppListCache | undefined => + old + ? { + ...old, + data: (old.data ?? []).filter((app) => app.id !== appId), + } + : old; + +/** Apply an update only to an existing detail cache entry. */ +export const updateAppDetailCache = ( + old: App | undefined, + params: Partial, +): App | undefined => (old ? { ...old, ...params } : old); + +/** Apply an update only to an existing list cache entry. */ +export const updateAppInListCache = ( + old: AppListCache | undefined, + appId: number, + params: Partial, +): AppListCache | undefined => + old + ? { + ...old, + data: (old.data ?? []).map((app) => + app.id === appId ? { ...app, ...params } : app, + ), + } + : old; diff --git a/src/services/mutations.ts b/src/services/mutations.ts index 51e8565..ef413ea 100644 --- a/src/services/mutations.ts +++ b/src/services/mutations.ts @@ -8,6 +8,11 @@ import { } from '@/utils/query-keys'; import { queryClient } from '@/utils/queryClient'; import { api } from './api'; +import { + removeAppFromListCache, + updateAppDetailCache, + updateAppInListCache, +} from './mutation-cache'; type UpdateAppParams = Omit; @@ -20,37 +25,24 @@ type UpdatePackageParams = { // --- cache updaters (single place that knows the cache shapes) --- const removeAppFromList = (appId: number) => { - queryClient.setQueryData( - appKeys.list(), - (old?: { data?: App[] } | undefined) => ({ - data: old?.data?.filter((i) => i.id !== appId) ?? [], - }), - ); -}; - -const addAppToList = (app: { id: number; name: string; platform: string }) => { - queryClient.setQueryData( - appKeys.list(), - (old?: { data?: App[] } | undefined) => ({ - data: [...(old?.data || []), app], - }), + queryClient.setQueryData(appKeys.list(), (old?: { data?: App[] }) => + removeAppFromListCache(old, appId), ); + queryClient.removeQueries({ queryKey: appKeys.detail(appId), exact: true }); }; const applyAppUpdate = (appId: number, params: UpdateAppParams) => { - queryClient.setQueryData(appKeys.detail(appId), (old: App | undefined) => ({ - ...old, - ...params, - })); - queryClient.setQueryData( - appKeys.list(), - (old?: { data?: App[] } | undefined) => ({ - data: - old?.data?.map((i) => (i.id === appId ? { ...i, ...params } : i)) ?? [], - }), + queryClient.setQueryData(appKeys.detail(appId), (old: App | undefined) => + updateAppDetailCache(old, params), + ); + queryClient.setQueryData(appKeys.list(), (old?: { data?: App[] }) => + updateAppInListCache(old, appId, params), ); }; +const revalidateAppList = () => + queryClient.invalidateQueries({ queryKey: appKeys.list() }); + const applyPackageUpdate = ( appId: number, packageId: number, @@ -147,7 +139,9 @@ const removeBindingFromList = (appId: number, bindingId: number) => { export const createApp = async (params: { name: string; platform: string }) => { const id = await api.createApp(params); - addAppToList({ ...params, id }); + // The create endpoint returns only an id. Refetch the canonical entity list + // instead of inserting an object without appKey/status into a fresh cache. + await revalidateAppList(); return id; }; @@ -156,7 +150,10 @@ export const createApp = async (params: { name: string; platform: string }) => { export const useDeleteApp = () => useMutation({ mutationFn: (appId: number) => api.deleteApp(appId), - onSuccess: (_data, appId) => removeAppFromList(appId), + onSuccess: async (_data, appId) => { + removeAppFromList(appId); + await revalidateAppList(); + }, }); export const useUpdateApp = () => @@ -168,7 +165,13 @@ export const useUpdateApp = () => appId: number; params: UpdateAppParams; }) => api.updateApp(appId, params), - onSuccess: (_data, { appId, params }) => applyAppUpdate(appId, params), + onSuccess: async (_data, { appId, params }) => { + applyAppUpdate(appId, params); + await Promise.all([ + revalidateAppList(), + queryClient.invalidateQueries({ queryKey: appKeys.detail(appId) }), + ]); + }, }); export const useUpdatePackage = () => From f55609403b337704a8943dc253cc0f75778ffc2c Mon Sep 17 00:00:00 2001 From: Sunny Luo Date: Mon, 31 Aug 2026 11:59:31 +0800 Subject: [PATCH 03/20] fix(runtime): isolate endpoint changes and recover stale chunks Canonicalize custom endpoints, clear credentials/workspace/cache before switching servers, bound the service-worker cache, and allow one stale-chunk reload per UI build. --- public/sw.js | 27 ++++++++----- src/components/error-boundary.tsx | 17 +++++--- src/components/switch-endpoint-modal.tsx | 47 ++++++++++++++++------ src/utils/chunk-recovery.test.ts | 10 +++++ src/utils/chunk-recovery.ts | 11 +++++ src/utils/endpoint.test.ts | 31 ++++++++++++++ src/utils/endpoint.ts | 51 +++++++++++++++++++++--- 7 files changed, 161 insertions(+), 33 deletions(-) create mode 100644 src/utils/chunk-recovery.test.ts create mode 100644 src/utils/chunk-recovery.ts create mode 100644 src/utils/endpoint.test.ts diff --git a/public/sw.js b/public/sw.js index 20aa3a5..c4f0d1c 100644 --- a/public/sw.js +++ b/public/sw.js @@ -1,4 +1,5 @@ -const CACHE_NAME = 'pushy-admin-v2'; +const CACHE_NAME = 'pushy-admin-v3'; +const MAX_CACHE_ENTRIES = 80; const LOCAL_HOSTNAMES = new Set(['localhost', '127.0.0.1', '0.0.0.0', '::1']); const IS_LOCAL_HOST = LOCAL_HOSTNAMES.has(self.location.hostname); @@ -28,6 +29,13 @@ const isNavigationRequest = (request) => request.mode === 'navigate' || (request.headers.get('accept') || '').includes('text/html'); +const trimCache = async (cache) => { + const keys = await cache.keys(); + const excess = keys.length - MAX_CACHE_ENTRIES; + if (excess <= 0) return; + await Promise.all(keys.slice(0, excess).map((request) => cache.delete(request))); +}; + // Fetch: keep HTML/API fresh; cache only fingerprinted static assets. self.addEventListener('fetch', (event) => { const { request } = event; @@ -58,15 +66,16 @@ self.addEventListener('fetch', (event) => { } event.respondWith( - caches.match(request).then((cached) => { + caches.open(CACHE_NAME).then(async (cache) => { + const cached = await cache.match(request); if (cached) return cached; - return fetch(request).then((response) => { - if (response.ok && url.origin === self.location.origin) { - const clone = response.clone(); - caches.open(CACHE_NAME).then((cache) => cache.put(request, clone)); - } - return response; - }); + + const response = await fetch(request); + if (response.ok && url.origin === self.location.origin) { + await cache.put(request, response.clone()); + await trimCache(cache); + } + return response; }), ); }); diff --git a/src/components/error-boundary.tsx b/src/components/error-boundary.tsx index 9d1e079..36abce4 100644 --- a/src/components/error-boundary.tsx +++ b/src/components/error-boundary.tsx @@ -2,13 +2,15 @@ import { Button, Result } from 'antd'; import { useEffect } from 'react'; import { useTranslation } from 'react-i18next'; import { useNavigate, useRouteError } from 'react-router-dom'; +import { + CHUNK_ERROR_RELOAD_KEY, + shouldReloadChunkError, +} from '@/utils/chunk-recovery'; interface ChunkError extends Error { __webpack_chunkName?: string; } -const CHUNK_ERROR_RELOAD_KEY = 'pushy_chunk_error_reload_attempted'; - const isLocalHost = () => { const { hostname } = window.location; return ( @@ -34,22 +36,25 @@ export function ErrorBoundary() { useEffect(() => { if (!isChunkError) { - window.sessionStorage.removeItem(CHUNK_ERROR_RELOAD_KEY); return; } + const currentVersion = process.env.PUBLIC_UI_VERSION || 'unknown'; + const attemptedVersion = window.sessionStorage.getItem( + CHUNK_ERROR_RELOAD_KEY, + ); if ( process.env.NODE_ENV === 'production' && !isLocalHost() && - !window.sessionStorage.getItem(CHUNK_ERROR_RELOAD_KEY) + shouldReloadChunkError(attemptedVersion, currentVersion) ) { - window.sessionStorage.setItem(CHUNK_ERROR_RELOAD_KEY, '1'); + window.sessionStorage.setItem(CHUNK_ERROR_RELOAD_KEY, currentVersion); window.location.reload(); } }, [isChunkError]); const handleRetry = () => { - navigate(-1); + window.location.reload(); }; const handleGoHome = () => { diff --git a/src/components/switch-endpoint-modal.tsx b/src/components/switch-endpoint-modal.tsx index 04bf868..914a16b 100644 --- a/src/components/switch-endpoint-modal.tsx +++ b/src/components/switch-endpoint-modal.tsx @@ -1,11 +1,15 @@ import { Button, Form, Input, Modal, message, Tag } from 'antd'; import { useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; +import { clearSession } from '@/services/request'; +import { clearWorkspace } from '@/services/workspace'; import { + normalizeEndpointUrl, setCustomBaseUrl, testEndpointStatus, useCustomBaseUrl, } from '@/utils/endpoint'; +import { queryClient } from '@/utils/queryClient'; interface SwitchEndpointModalProps { onClose: () => void; @@ -27,25 +31,46 @@ export function SwitchEndpointModal({ } }, [open, currentCustomUrl]); - const handleSave = async () => { - const trimmed = urlInput.trim(); - if (!trimmed) { - handleReset(); + const applyEndpointChange = ( + nextUrl: string | null, + successMessage: string, + ) => { + const currentNormalized = currentCustomUrl + ? normalizeEndpointUrl(currentCustomUrl) + : null; + if (currentNormalized === nextUrl) { + message.success(successMessage); + onClose(); return; } - if (!/^https?:\/\//i.test(trimmed)) { + // An API origin is an authentication boundary. Drop the old token, + // workspace and cached responses before publishing the new endpoint so no + // request can carry credentials or data across servers. + clearSession(); + clearWorkspace(); + queryClient.clear(); + setCustomBaseUrl(nextUrl); + message.success(successMessage); + onClose(); + window.location.reload(); + }; + + const handleSave = async () => { + const normalizedUrl = normalizeEndpointUrl(urlInput); + if (!normalizedUrl) { message.error(t('admin_endpoint.invalid_url')); return; } setTesting(true); try { - const ok = await testEndpointStatus(trimmed); + const ok = await testEndpointStatus(normalizedUrl); if (ok) { - setCustomBaseUrl(trimmed); - message.success(t('admin_endpoint.test_success')); - onClose(); + applyEndpointChange( + normalizedUrl, + t('admin_endpoint.test_success'), + ); } else { message.error(t('admin_endpoint.test_failed')); } @@ -57,10 +82,8 @@ export function SwitchEndpointModal({ }; const handleReset = () => { - setCustomBaseUrl(null); setUrlInput(''); - message.success(t('admin_endpoint.reset_success')); - onClose(); + applyEndpointChange(null, t('admin_endpoint.reset_success')); }; return ( diff --git a/src/utils/chunk-recovery.test.ts b/src/utils/chunk-recovery.test.ts new file mode 100644 index 0000000..8035ce1 --- /dev/null +++ b/src/utils/chunk-recovery.test.ts @@ -0,0 +1,10 @@ +import { describe, expect, test } from 'bun:test'; +import { shouldReloadChunkError } from './chunk-recovery'; + +describe('shouldReloadChunkError', () => { + test('allows one reload for each UI build', () => { + expect(shouldReloadChunkError(null, '2026.8.31-a')).toBe(true); + expect(shouldReloadChunkError('2026.8.31-a', '2026.8.31-a')).toBe(false); + expect(shouldReloadChunkError('2026.8.31-a', '2026.9.1-b')).toBe(true); + }); +}); diff --git a/src/utils/chunk-recovery.ts b/src/utils/chunk-recovery.ts new file mode 100644 index 0000000..74d8cc2 --- /dev/null +++ b/src/utils/chunk-recovery.ts @@ -0,0 +1,11 @@ +export const CHUNK_ERROR_RELOAD_KEY = 'pushy_chunk_error_reload_attempted'; + +/** + * Retry a stale chunk once per UI build. A later deployment has a different + * build id and therefore gets its own recovery attempt even if the previous + * marker is still present in the tab's session storage. + */ +export const shouldReloadChunkError = ( + attemptedVersion: string | null, + currentVersion: string, +) => attemptedVersion !== currentVersion; diff --git a/src/utils/endpoint.test.ts b/src/utils/endpoint.test.ts new file mode 100644 index 0000000..0f59e41 --- /dev/null +++ b/src/utils/endpoint.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, test } from 'bun:test'; +import { normalizeEndpointUrl } from './endpoint'; + +describe('normalizeEndpointUrl', () => { + test('canonicalizes HTTPS endpoints and preserves an API path', () => { + expect(normalizeEndpointUrl(' https://example.com/api/ ')).toBe( + 'https://example.com/api', + ); + expect(normalizeEndpointUrl('https://example.com/')).toBe( + 'https://example.com', + ); + }); + + test('allows plain HTTP only for local development hosts', () => { + expect(normalizeEndpointUrl('http://localhost:9000/api')).toBe( + 'http://localhost:9000/api', + ); + expect(normalizeEndpointUrl('http://127.0.0.1:9000')).toBe( + 'http://127.0.0.1:9000', + ); + expect(normalizeEndpointUrl('http://example.com/api')).toBeNull(); + }); + + test('rejects embedded credentials, query strings, fragments and non-http URLs', () => { + expect(normalizeEndpointUrl('https://user:pass@example.com/api')).toBeNull(); + expect(normalizeEndpointUrl('https://example.com/api?token=1')).toBeNull(); + expect(normalizeEndpointUrl('https://example.com/api#section')).toBeNull(); + expect(normalizeEndpointUrl('ftp://example.com/api')).toBeNull(); + expect(normalizeEndpointUrl('not a url')).toBeNull(); + }); +}); diff --git a/src/utils/endpoint.ts b/src/utils/endpoint.ts index 4410769..a37c8d7 100644 --- a/src/utils/endpoint.ts +++ b/src/utils/endpoint.ts @@ -2,8 +2,43 @@ import { useEffect, useState } from 'react'; import { safeStorage } from '@/utils/storage'; const CUSTOM_BASE_URL_STORAGE_KEY = 'pushy_custom_base_url'; +const LOCAL_HOSTNAMES = new Set([ + 'localhost', + '127.0.0.1', + '0.0.0.0', + '::1', + '[::1]', +]); export const customBaseUrlChangeEvent = 'pushy-custom-base-url-change'; +/** + * Canonicalize a custom API base URL and reject values that would either be + * blocked as mixed content or conceal credentials/query data in the endpoint. + * Plain HTTP remains available for local development only. + */ +export function normalizeEndpointUrl(value: string): string | null { + try { + const url = new URL(value.trim()); + const isLocal = LOCAL_HOSTNAMES.has(url.hostname); + const protocolAllowed = + url.protocol === 'https:' || (url.protocol === 'http:' && isLocal); + if ( + !protocolAllowed || + url.username || + url.password || + url.search || + url.hash + ) { + return null; + } + + const pathname = url.pathname.replace(/\/+$/, ''); + return `${url.origin}${pathname}`; + } catch { + return null; + } +} + export function getCustomBaseUrl(): string | null { if (typeof window === 'undefined') { return null; @@ -62,18 +97,22 @@ export function useCustomBaseUrl(): string | null { } export async function testEndpointStatus(baseUrl: string): Promise { + const normalizedUrl = normalizeEndpointUrl(baseUrl); + if (!normalizedUrl) { + return false; + } + + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 5000); try { - const cleanUrl = baseUrl.trim().replace(/\/$/, ''); - const testUrl = `${cleanUrl}/status`; - const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), 5000); - const response = await fetch(testUrl, { + const response = await fetch(`${normalizedUrl}/status`, { method: 'GET', signal: controller.signal, }); - clearTimeout(timeoutId); return response.ok; } catch { return false; + } finally { + clearTimeout(timeoutId); } } From e0f3ba0cd350fd7adb0b62e6d01c39c70cbed85b Mon Sep 17 00:00:00 2001 From: Sunny Luo Date: Mon, 31 Aug 2026 12:01:53 +0800 Subject: [PATCH 04/20] fix(metrics): rank distributions by real volume Select default legend categories from cumulative request counts rather than equal-weight daily percentages, and format daily chart tooltips without a meaningless midnight time. --- src/pages/admin-metrics.logic.test.ts | 18 +++++++++++++++--- src/pages/admin-metrics.logic.ts | 20 ++++++++++++++++++++ src/pages/admin-metrics.tsx | 7 ++++++- src/utils/charts.test.ts | 4 +++- src/utils/charts.ts | 7 +++++-- 5 files changed, 49 insertions(+), 7 deletions(-) diff --git a/src/pages/admin-metrics.logic.test.ts b/src/pages/admin-metrics.logic.test.ts index 009f1f5..e74c35d 100644 --- a/src/pages/admin-metrics.logic.test.ts +++ b/src/pages/admin-metrics.logic.test.ts @@ -7,6 +7,7 @@ import { DEFAULT_RANGE_HOURS, formatTooltipItem, getCategoryPrefix, + getDistributionCategoryOrder, getMetricsTotal, type MetricsResponse, parseDateRange, @@ -50,6 +51,17 @@ describe('distribution tabs and points', () => { }, ]); }); + + test('ranks legend categories by real volume instead of equal-weight daily share', () => { + const points = buildDistributionPoints([ + { date: '2026-08-10', values: { x: 1 } }, + { date: '2026-08-11', values: { x: 100, y: 900 } }, + ]); + + // Percentage sums would rank x first (100% + 10% versus y's 90%), but + // the actual window volumes are y=900 and x=101. + expect(getDistributionCategoryOrder(points)).toEqual(['y', 'x']); + }); }); describe('getCategoryPrefix', () => { @@ -72,7 +84,7 @@ describe('getMetricsTotal', () => { test('sums every category when no _total is present', () => { const metrics: MetricsResponse = { - dict: ['rn0.72', 'rn0.73'], + dict: ['rn\u001f0.72', 'rn\u001f0.73'], data: [ { time: 't1', @@ -89,7 +101,7 @@ describe('getMetricsTotal', () => { test('a _total entry overrides the running sum for its bucket', () => { const metrics: MetricsResponse = { - dict: ['rn0.72', '_total', 'rn0.73'], + dict: ['rn\u001f0.72', '_total', 'rn\u001f0.73'], data: [ // 前面已累加 3,遇到 _total 后以 10 为准,后面的 100 不再计入 { @@ -114,7 +126,7 @@ describe('buildChartPoints', () => { test('splits dict keys on the separator and skips _total', () => { const metrics: MetricsResponse = { - dict: ['rn0.72', '_total', 'os', 'plain'], + dict: ['rn\u001f0.72', '_total', 'os\u001f', 'plain'], data: [ { time: 't1', diff --git a/src/pages/admin-metrics.logic.ts b/src/pages/admin-metrics.logic.ts index a671610..7246d94 100644 --- a/src/pages/admin-metrics.logic.ts +++ b/src/pages/admin-metrics.logic.ts @@ -140,6 +140,26 @@ export const buildDistributionPoints = ( return points; }; +/** + * 图例 Top N 按窗口内真实请求数排序,而不是把每天的百分比等权相加。 + * 后者会让低流量日的 100% 压过高流量日的大类,和“累计流量最高”文案不符。 + */ +export const getDistributionCategoryOrder = ( + points: readonly DistributionPoint[], +): string[] => { + const totals = new Map(); + for (const point of points) { + totals.set(point.category, (totals.get(point.category) ?? 0) + point.count); + } + return Array.from(totals.entries()) + .sort(([leftCategory, leftCount], [rightCategory, rightCount]) => + rightCount === leftCount + ? leftCategory.localeCompare(rightCategory) + : rightCount - leftCount, + ) + .map(([category]) => category); +}; + export const formatDistributionTooltip = (point: DistributionPoint) => `${point.value.toFixed(1)}% (${point.count.toLocaleString()})`; diff --git a/src/pages/admin-metrics.tsx b/src/pages/admin-metrics.tsx index cee1487..348242c 100644 --- a/src/pages/admin-metrics.tsx +++ b/src/pages/admin-metrics.tsx @@ -36,6 +36,7 @@ import { formatDistributionTooltip, formatTooltipItem, getCategoryPrefix, + getDistributionCategoryOrder, getMetricsTotal, getModeLabels, type MetricMode, @@ -62,7 +63,10 @@ const DistributionPanel = ({ const { isDark } = useThemeMode(); const legendValuesRef = useRef([]); const points = useMemo(() => buildDistributionPoints(rows), [rows]); - const { sortedCategories } = useMemo(() => aggregateSeries(points), [points]); + const sortedCategories = useMemo( + () => getDistributionCategoryOrder(points), + [points], + ); const { defaultLegendValues, colorDomain } = useMemo( () => buildLegendDefaults(sortedCategories), [sortedCategories], @@ -76,6 +80,7 @@ const DistributionPanel = ({ xTitle: t('admin_metrics.time'), yTitle: t('admin_metrics.share_percent'), axisTimeFormat: 'MM/DD', + tooltipTimeFormat: 'MM/DD', formatTooltipValue: formatDistributionTooltip, colorDomain, legendValuesRef, diff --git a/src/utils/charts.test.ts b/src/utils/charts.test.ts index 70a4c75..7e8d248 100644 --- a/src/utils/charts.test.ts +++ b/src/utils/charts.test.ts @@ -37,7 +37,7 @@ describe('buildTimeSeriesLineConfig', () => { ).toBe('classicDark'); }); - test('formats the x axis with the requested time format', () => { + test('formats the x axis and tooltip with their requested time formats', () => { const local = dayjs(data[0]!.time); const config = buildTimeSeriesLineConfig({ data, @@ -52,6 +52,7 @@ describe('buildTimeSeriesLineConfig', () => { isDark: false, height: 1, axisTimeFormat: 'HH:mm', + tooltipTimeFormat: 'MM/DD', }); expect(short.axis.x.labelFormatter(data[0]!.time)).toBe( local.format('HH:mm'), @@ -59,6 +60,7 @@ describe('buildTimeSeriesLineConfig', () => { // 无法解析的刻度原样返回 expect(config.axis.x.labelFormatter('not a date')).toBe('not a date'); expect(config.tooltip.title(data[0]!)).toBe(local.format('MM/DD HH:mm')); + expect(short.tooltip.title(data[0]!)).toBe(local.format('MM/DD')); }); test('wires titles, tooltip formatter and color domain', () => { diff --git a/src/utils/charts.ts b/src/utils/charts.ts index a13e803..736febc 100644 --- a/src/utils/charts.ts +++ b/src/utils/charts.ts @@ -24,6 +24,8 @@ export interface TimeSeriesLineOptions

{ yTitle?: string; /** x 轴刻度的时间格式,默认 'MM/DD HH:mm';节点面板只看当天用 'HH:mm'。 */ axisTimeFormat?: string; + /** tooltip 标题的时间格式,默认 'MM/DD HH:mm';日级图可只显示日期。 */ + tooltipTimeFormat?: string; /** tooltip 单项的值文本;不传则交给 G2 默认渲染。 */ formatTooltipValue?: (point: P) => string; /** 多条线同时命中时合并到一个 tooltip,默认开启。 */ @@ -38,7 +40,7 @@ export interface TimeSeriesLineOptions

{ } const DEFAULT_AXIS_TIME_FORMAT = 'MM/DD HH:mm'; -const TOOLTIP_TIME_FORMAT = 'MM/DD HH:mm'; +const DEFAULT_TOOLTIP_TIME_FORMAT = 'MM/DD HH:mm'; /** * 各指标页共用的时间序列折线图配置:主题跟随暗色模式、x 轴按时间格式化、 @@ -51,6 +53,7 @@ export function buildTimeSeriesLineConfig

({ xTitle, yTitle, axisTimeFormat = DEFAULT_AXIS_TIME_FORMAT, + tooltipTimeFormat = DEFAULT_TOOLTIP_TIME_FORMAT, formatTooltipValue, sharedTooltip = true, colorDomain, @@ -79,7 +82,7 @@ export function buildTimeSeriesLineConfig

({ y: yTitle === undefined ? {} : { title: yTitle }, }, tooltip: { - title: (point: P) => dayjs(point.time).format(TOOLTIP_TIME_FORMAT), + title: (point: P) => dayjs(point.time).format(tooltipTimeFormat), ...(formatTooltipValue ? { items: [ From 96ee9a065136c5ea4d6c7a1b1e34a2021a43078a Mon Sep 17 00:00:00 2001 From: Sunny Luo Date: Mon, 31 Aug 2026 12:03:38 +0800 Subject: [PATCH 05/20] style: apply Biome formatting Apply the formatter output reported by CI. --- src/components/switch-endpoint-modal.tsx | 5 +---- src/services/mutation-cache.test.ts | 8 ++++++-- src/utils/endpoint.test.ts | 4 +++- 3 files changed, 10 insertions(+), 7 deletions(-) diff --git a/src/components/switch-endpoint-modal.tsx b/src/components/switch-endpoint-modal.tsx index 914a16b..4d96b0e 100644 --- a/src/components/switch-endpoint-modal.tsx +++ b/src/components/switch-endpoint-modal.tsx @@ -67,10 +67,7 @@ export function SwitchEndpointModal({ try { const ok = await testEndpointStatus(normalizedUrl); if (ok) { - applyEndpointChange( - normalizedUrl, - t('admin_endpoint.test_success'), - ); + applyEndpointChange(normalizedUrl, t('admin_endpoint.test_success')); } else { message.error(t('admin_endpoint.test_failed')); } diff --git a/src/services/mutation-cache.test.ts b/src/services/mutation-cache.test.ts index 69e3270..60459bb 100644 --- a/src/services/mutation-cache.test.ts +++ b/src/services/mutation-cache.test.ts @@ -14,8 +14,12 @@ const apps: App[] = [ describe('app mutation cache updaters', () => { test('leave missing caches missing instead of synthesizing partial data', () => { expect(removeAppFromListCache(undefined, 1)).toBeUndefined(); - expect(updateAppInListCache(undefined, 1, { name: 'renamed' })).toBeUndefined(); - expect(updateAppDetailCache(undefined, { name: 'renamed' })).toBeUndefined(); + expect( + updateAppInListCache(undefined, 1, { name: 'renamed' }), + ).toBeUndefined(); + expect( + updateAppDetailCache(undefined, { name: 'renamed' }), + ).toBeUndefined(); }); test('remove only the requested app from an existing list', () => { diff --git a/src/utils/endpoint.test.ts b/src/utils/endpoint.test.ts index 0f59e41..ea28063 100644 --- a/src/utils/endpoint.test.ts +++ b/src/utils/endpoint.test.ts @@ -22,7 +22,9 @@ describe('normalizeEndpointUrl', () => { }); test('rejects embedded credentials, query strings, fragments and non-http URLs', () => { - expect(normalizeEndpointUrl('https://user:pass@example.com/api')).toBeNull(); + expect( + normalizeEndpointUrl('https://user:pass@example.com/api'), + ).toBeNull(); expect(normalizeEndpointUrl('https://example.com/api?token=1')).toBeNull(); expect(normalizeEndpointUrl('https://example.com/api#section')).toBeNull(); expect(normalizeEndpointUrl('ftp://example.com/api')).toBeNull(); From 52c0967edc26a189b3fd68e14dd6b34a2c8862af Mon Sep 17 00:00:00 2001 From: Sunny Luo Date: Mon, 31 Aug 2026 13:34:37 +0800 Subject: [PATCH 06/20] refactor(pwa): stop registering the service worker --- src/index.tsx | 51 ++++++--------------------------------------------- 1 file changed, 6 insertions(+), 45 deletions(-) diff --git a/src/index.tsx b/src/index.tsx index bbed2ab..65c1b22 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -15,6 +15,7 @@ import { router } from './router'; import { themeConfig } from './theme'; import { showNotices } from './utils/notice'; import { queryClient } from './utils/queryClient'; +import { retireLegacyPwaState } from './utils/service-worker-retirement'; import { ThemeModeProvider, useThemeMode } from './utils/theme-mode'; const antdLocaleMap: Record = { @@ -22,53 +23,13 @@ const antdLocaleMap: Record = { 'zh-CN': zhCN, }; -const isLocalHost = () => { - const { hostname } = window.location; - return ( - hostname === 'localhost' || - hostname === '127.0.0.1' || - hostname === '0.0.0.0' || - hostname === '::1' - ); -}; - -const shouldEnablePwa = process.env.NODE_ENV === 'production' && !isLocalHost(); - -const hasServiceWorker = () => - typeof navigator !== 'undefined' && 'serviceWorker' in navigator; - -const clearLocalPwaState = () => { - if (hasServiceWorker()) { - navigator.serviceWorker - .getRegistrations() - .then((registrations) => - Promise.all( - registrations.map((registration) => registration.unregister()), - ), - ) - .catch(() => { - // SW cleanup failed, app continues normally. - }); - } - - if (typeof caches !== 'undefined') { - caches - .keys() - .then((keys) => Promise.all(keys.map((key) => caches.delete(key)))) - .catch(() => { - // Cache cleanup failed, app continues normally. - }); - } -}; - -if (hasServiceWorker() && shouldEnablePwa) { +// pushy-admin does not offer a useful offline mode. Keep removing registrations +// and caches left by older releases instead of maintaining a second cache layer +// on top of fingerprinted HTTP assets. +if (typeof window !== 'undefined') { window.addEventListener('load', () => { - navigator.serviceWorker.register('/sw.js').catch(() => { - // SW registration failed, app continues normally - }); + void retireLegacyPwaState(); }); -} else if (isLocalHost()) { - window.addEventListener('load', clearLocalPwaState); } function ThemedApp() { From 70c9601a374395b3cea8840f83e17997a8c4f293 Mon Sep 17 00:00:00 2001 From: Sunny Luo Date: Mon, 31 Aug 2026 13:34:45 +0800 Subject: [PATCH 07/20] refactor(pwa): retire legacy service workers --- public/sw.js | 83 ++++++++-------------------------------------------- 1 file changed, 12 insertions(+), 71 deletions(-) diff --git a/public/sw.js b/public/sw.js index c4f0d1c..7927352 100644 --- a/public/sw.js +++ b/public/sw.js @@ -1,81 +1,22 @@ -const CACHE_NAME = 'pushy-admin-v3'; -const MAX_CACHE_ENTRIES = 80; -const LOCAL_HOSTNAMES = new Set(['localhost', '127.0.0.1', '0.0.0.0', '::1']); -const IS_LOCAL_HOST = LOCAL_HOSTNAMES.has(self.location.hostname); +const LEGACY_CACHE_PREFIX = 'pushy-admin-'; -// Install: activate this worker immediately without precaching the app shell. +// Tombstone worker for browsers that still have an older pushy-admin service +// worker registered. It deliberately has no fetch handler: fingerprinted assets +// use the browser's normal HTTP cache, while every navigation stays network-led. self.addEventListener('install', () => { self.skipWaiting(); }); -// Activate: clean old caches self.addEventListener('activate', (event) => { event.waitUntil( - caches.keys().then((keys) => - Promise.all( + (async () => { + const keys = await caches.keys(); + await Promise.allSettled( keys - .filter((k) => IS_LOCAL_HOST || k !== CACHE_NAME) - .map((k) => caches.delete(k)), - ), - ), - ); - if (IS_LOCAL_HOST) { - event.waitUntil(self.registration.unregister()); - } - self.clients.claim(); -}); - -const isNavigationRequest = (request) => - request.mode === 'navigate' || - (request.headers.get('accept') || '').includes('text/html'); - -const trimCache = async (cache) => { - const keys = await cache.keys(); - const excess = keys.length - MAX_CACHE_ENTRIES; - if (excess <= 0) return; - await Promise.all(keys.slice(0, excess).map((request) => cache.delete(request))); -}; - -// Fetch: keep HTML/API fresh; cache only fingerprinted static assets. -self.addEventListener('fetch', (event) => { - const { request } = event; - const url = new URL(request.url); - - // Skip non-GET requests - if (request.method !== 'GET') return; - - if (IS_LOCAL_HOST) { - event.respondWith(fetch(request)); - return; - } - - if ( - url.origin !== self.location.origin || - isNavigationRequest(request) || - url.pathname === '/index.html' || - url.pathname === '/sw.js' || - url.pathname === '/manifest.json' || - url.pathname.startsWith('/api') - ) { - event.respondWith(fetch(request)); - return; - } - - if (!url.pathname.startsWith('/static/')) { - return; - } - - event.respondWith( - caches.open(CACHE_NAME).then(async (cache) => { - const cached = await cache.match(request); - if (cached) return cached; - - const response = await fetch(request); - if (response.ok && url.origin === self.location.origin) { - await cache.put(request, response.clone()); - await trimCache(cache); - } - return response; - }), + .filter((key) => key.startsWith(LEGACY_CACHE_PREFIX)) + .map((key) => caches.delete(key)), + ); + await self.registration.unregister(); + })(), ); }); From 627aa417f1d47a8b319d5687317fe82ca9a036af Mon Sep 17 00:00:00 2001 From: Sunny Luo Date: Mon, 31 Aug 2026 13:34:57 +0800 Subject: [PATCH 08/20] test(pwa): add legacy service worker cleanup helper --- src/utils/service-worker-retirement.ts | 65 ++++++++++++++++++++++++++ 1 file changed, 65 insertions(+) create mode 100644 src/utils/service-worker-retirement.ts diff --git a/src/utils/service-worker-retirement.ts b/src/utils/service-worker-retirement.ts new file mode 100644 index 0000000..e25af45 --- /dev/null +++ b/src/utils/service-worker-retirement.ts @@ -0,0 +1,65 @@ +export const LEGACY_PUSHY_CACHE_PREFIX = 'pushy-admin-'; + +interface ServiceWorkerRegistrationLike { + unregister: () => Promise; +} + +interface ServiceWorkerContainerLike { + getRegistrations: () => Promise; +} + +interface CacheStorageLike { + delete: (cacheName: string) => Promise; + keys: () => Promise; +} + +const getServiceWorkerContainer = (): ServiceWorkerContainerLike | undefined => { + if (typeof navigator === 'undefined' || !('serviceWorker' in navigator)) { + return undefined; + } + return navigator.serviceWorker; +}; + +const getCacheStorage = (): CacheStorageLike | undefined => + typeof caches === 'undefined' ? undefined : caches; + +/** + * Remove registrations and runtime caches left by the retired PWA layer. + * Every branch is best-effort so a browser implementation error cannot block + * the management console from starting. + */ +export async function retireLegacyPwaState({ + serviceWorker = getServiceWorkerContainer(), + cacheStorage = getCacheStorage(), +}: { + serviceWorker?: ServiceWorkerContainerLike; + cacheStorage?: CacheStorageLike; +} = {}): Promise { + const cleanupTasks: Promise[] = []; + + if (serviceWorker) { + cleanupTasks.push( + serviceWorker + .getRegistrations() + .then((registrations) => + Promise.allSettled( + registrations.map((registration) => registration.unregister()), + ), + ), + ); + } + + if (cacheStorage) { + cleanupTasks.push( + cacheStorage.keys().then((keys) => + Promise.allSettled( + keys + .filter((key) => key.startsWith(LEGACY_PUSHY_CACHE_PREFIX)) + .map((key) => cacheStorage.delete(key)), + ), + ), + ); + } + + await Promise.allSettled(cleanupTasks); +} From 328af978e874fe9f664b3782fdb915c9d6089736 Mon Sep 17 00:00:00 2001 From: Sunny Luo Date: Mon, 31 Aug 2026 13:35:12 +0800 Subject: [PATCH 09/20] test(pwa): cover legacy service worker cleanup --- src/utils/service-worker-retirement.test.ts | 57 +++++++++++++++++++++ 1 file changed, 57 insertions(+) create mode 100644 src/utils/service-worker-retirement.test.ts diff --git a/src/utils/service-worker-retirement.test.ts b/src/utils/service-worker-retirement.test.ts new file mode 100644 index 0000000..2cad4a9 --- /dev/null +++ b/src/utils/service-worker-retirement.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, mock, test } from 'bun:test'; +import { + LEGACY_PUSHY_CACHE_PREFIX, + retireLegacyPwaState, +} from './service-worker-retirement'; + +describe('retireLegacyPwaState', () => { + test('unregisters workers and deletes only pushy-admin caches', async () => { + const unregisterA = mock(async () => true); + const unregisterB = mock(async () => true); + const deleteCache = mock(async () => true); + + await retireLegacyPwaState({ + serviceWorker: { + getRegistrations: async () => [ + { unregister: unregisterA }, + { unregister: unregisterB }, + ], + }, + cacheStorage: { + keys: async () => [ + `${LEGACY_PUSHY_CACHE_PREFIX}v2`, + 'unrelated-cache', + `${LEGACY_PUSHY_CACHE_PREFIX}v3`, + ], + delete: deleteCache, + }, + }); + + expect(unregisterA).toHaveBeenCalledTimes(1); + expect(unregisterB).toHaveBeenCalledTimes(1); + expect(deleteCache).toHaveBeenCalledTimes(2); + expect(deleteCache).toHaveBeenCalledWith('pushy-admin-v2'); + expect(deleteCache).toHaveBeenCalledWith('pushy-admin-v3'); + expect(deleteCache).not.toHaveBeenCalledWith('unrelated-cache'); + }); + + test('keeps cleaning other state when one operation fails', async () => { + const deleteCache = mock(async () => true); + + await expect( + retireLegacyPwaState({ + serviceWorker: { + getRegistrations: async () => { + throw new Error('registration lookup failed'); + }, + }, + cacheStorage: { + keys: async () => ['pushy-admin-v2'], + delete: deleteCache, + }, + }), + ).resolves.toBeUndefined(); + + expect(deleteCache).toHaveBeenCalledWith('pushy-admin-v2'); + }); +}); From 978a07ef2d3183c0f8049f1076b2b78474990bf9 Mon Sep 17 00:00:00 2001 From: Sunny Luo Date: Mon, 31 Aug 2026 13:35:41 +0800 Subject: [PATCH 10/20] fix(pwa): accept readonly browser registrations --- src/utils/service-worker-retirement.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/utils/service-worker-retirement.ts b/src/utils/service-worker-retirement.ts index e25af45..e981ec9 100644 --- a/src/utils/service-worker-retirement.ts +++ b/src/utils/service-worker-retirement.ts @@ -5,7 +5,7 @@ interface ServiceWorkerRegistrationLike { } interface ServiceWorkerContainerLike { - getRegistrations: () => Promise; + getRegistrations: () => Promise; } interface CacheStorageLike { From 28571a5e0d20b9e83068926d09d5c6c541d09b16 Mon Sep 17 00:00:00 2001 From: Sunny Luo Date: Mon, 31 Aug 2026 13:36:19 +0800 Subject: [PATCH 11/20] style(pwa): apply Biome formatting --- src/utils/service-worker-retirement.ts | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/src/utils/service-worker-retirement.ts b/src/utils/service-worker-retirement.ts index e981ec9..4d5feaa 100644 --- a/src/utils/service-worker-retirement.ts +++ b/src/utils/service-worker-retirement.ts @@ -13,7 +13,9 @@ interface CacheStorageLike { keys: () => Promise; } -const getServiceWorkerContainer = (): ServiceWorkerContainerLike | undefined => { +const getServiceWorkerContainer = (): + | ServiceWorkerContainerLike + | undefined => { if (typeof navigator === 'undefined' || !('serviceWorker' in navigator)) { return undefined; } @@ -51,13 +53,15 @@ export async function retireLegacyPwaState({ if (cacheStorage) { cleanupTasks.push( - cacheStorage.keys().then((keys) => - Promise.allSettled( - keys - .filter((key) => key.startsWith(LEGACY_PUSHY_CACHE_PREFIX)) - .map((key) => cacheStorage.delete(key)), + cacheStorage + .keys() + .then((keys) => + Promise.allSettled( + keys + .filter((key) => key.startsWith(LEGACY_PUSHY_CACHE_PREFIX)) + .map((key) => cacheStorage.delete(key)), + ), ), - ), ); } From c62bad7c926b4377a305dce526a4356637a932ee Mon Sep 17 00:00:00 2001 From: Sunny Luo Date: Mon, 31 Aug 2026 13:46:35 +0800 Subject: [PATCH 12/20] fix(endpoint): reset invalid persisted endpoints --- src/utils/endpoint.ts | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/src/utils/endpoint.ts b/src/utils/endpoint.ts index a37c8d7..2db8b6e 100644 --- a/src/utils/endpoint.ts +++ b/src/utils/endpoint.ts @@ -39,6 +39,24 @@ export function normalizeEndpointUrl(value: string): string | null { } } +/** + * Compare a normalized endpoint choice with the persisted selection. An + * invalid, non-null legacy value is deliberately not equal to the default + * selection so resetting still removes it from storage and clears old state. + */ +export function isEndpointSelectionUnchanged( + currentCustomUrl: string | null, + nextUrl: string | null, +): boolean { + if (nextUrl === null) { + return currentCustomUrl === null; + } + return ( + currentCustomUrl !== null && + normalizeEndpointUrl(currentCustomUrl) === nextUrl + ); +} + export function getCustomBaseUrl(): string | null { if (typeof window === 'undefined') { return null; From 2e68f1db904b9bfdb291519294d9321e439b0bf2 Mon Sep 17 00:00:00 2001 From: Sunny Luo Date: Mon, 31 Aug 2026 13:46:45 +0800 Subject: [PATCH 13/20] test(endpoint): cover invalid legacy reset --- src/utils/endpoint.test.ts | 33 ++++++++++++++++++++++++++++++++- 1 file changed, 32 insertions(+), 1 deletion(-) diff --git a/src/utils/endpoint.test.ts b/src/utils/endpoint.test.ts index ea28063..3c1ae60 100644 --- a/src/utils/endpoint.test.ts +++ b/src/utils/endpoint.test.ts @@ -1,5 +1,8 @@ import { describe, expect, test } from 'bun:test'; -import { normalizeEndpointUrl } from './endpoint'; +import { + isEndpointSelectionUnchanged, + normalizeEndpointUrl, +} from './endpoint'; describe('normalizeEndpointUrl', () => { test('canonicalizes HTTPS endpoints and preserves an API path', () => { @@ -31,3 +34,31 @@ describe('normalizeEndpointUrl', () => { expect(normalizeEndpointUrl('not a url')).toBeNull(); }); }); + +describe('isEndpointSelectionUnchanged', () => { + test('treats the existing default and the same canonical endpoint as unchanged', () => { + expect(isEndpointSelectionUnchanged(null, null)).toBe(true); + expect( + isEndpointSelectionUnchanged( + 'https://example.com/api/', + 'https://example.com/api', + ), + ).toBe(true); + }); + + test('requires reset for an invalid non-null endpoint left by an older release', () => { + expect( + isEndpointSelectionUnchanged('http://api.example.com', null), + ).toBe(false); + expect(isEndpointSelectionUnchanged('not a url', null)).toBe(false); + }); + + test('detects real changes between default and custom endpoints', () => { + expect( + isEndpointSelectionUnchanged(null, 'https://example.com/api'), + ).toBe(false); + expect( + isEndpointSelectionUnchanged('https://example.com/api', null), + ).toBe(false); + }); +}); From 9b16e6a6d14be09d2faf0e3aa4036afde12bd8b4 Mon Sep 17 00:00:00 2001 From: Sunny Luo Date: Mon, 31 Aug 2026 13:47:06 +0800 Subject: [PATCH 14/20] fix(endpoint): apply validated selection comparison --- src/components/switch-endpoint-modal.tsx | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/components/switch-endpoint-modal.tsx b/src/components/switch-endpoint-modal.tsx index 4d96b0e..b08f68b 100644 --- a/src/components/switch-endpoint-modal.tsx +++ b/src/components/switch-endpoint-modal.tsx @@ -4,6 +4,7 @@ import { useTranslation } from 'react-i18next'; import { clearSession } from '@/services/request'; import { clearWorkspace } from '@/services/workspace'; import { + isEndpointSelectionUnchanged, normalizeEndpointUrl, setCustomBaseUrl, testEndpointStatus, @@ -35,10 +36,7 @@ export function SwitchEndpointModal({ nextUrl: string | null, successMessage: string, ) => { - const currentNormalized = currentCustomUrl - ? normalizeEndpointUrl(currentCustomUrl) - : null; - if (currentNormalized === nextUrl) { + if (isEndpointSelectionUnchanged(currentCustomUrl, nextUrl)) { message.success(successMessage); onClose(); return; From 10baa1f6991eee3e7a1e08a4de2567ed938ecd8f Mon Sep 17 00:00:00 2001 From: Sunny Luo Date: Mon, 31 Aug 2026 13:47:45 +0800 Subject: [PATCH 15/20] fix(types): preserve nullable admin app metrics --- src/services/admin-api.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/services/admin-api.ts b/src/services/admin-api.ts index d6e1144..8881330 100644 --- a/src/services/admin-api.ts +++ b/src/services/admin-api.ts @@ -217,7 +217,7 @@ export const adminApi = { } | null; }; apps: Array< - AdminApp & { + Omit & { checkCount: number | null; packagesCount: number; } From e99a64e336b7d4c6c70889fe592cb6aeee2b6c91 Mon Sep 17 00:00:00 2001 From: Sunny Luo Date: Mon, 31 Aug 2026 13:49:23 +0800 Subject: [PATCH 16/20] fix(pwa): limit retirement to the legacy worker --- src/utils/service-worker-retirement.ts | 51 +++++++++++++++++++++++++- 1 file changed, 50 insertions(+), 1 deletion(-) diff --git a/src/utils/service-worker-retirement.ts b/src/utils/service-worker-retirement.ts index 4d5feaa..0e4ed2b 100644 --- a/src/utils/service-worker-retirement.ts +++ b/src/utils/service-worker-retirement.ts @@ -1,6 +1,13 @@ export const LEGACY_PUSHY_CACHE_PREFIX = 'pushy-admin-'; +interface ServiceWorkerLike { + scriptURL: string; +} + interface ServiceWorkerRegistrationLike { + active?: ServiceWorkerLike | null; + installing?: ServiceWorkerLike | null; + waiting?: ServiceWorkerLike | null; unregister: () => Promise; } @@ -25,6 +32,42 @@ const getServiceWorkerContainer = (): const getCacheStorage = (): CacheStorageLike | undefined => typeof caches === 'undefined' ? undefined : caches; +const getPageOrigin = (): string | undefined => + typeof window === 'undefined' ? undefined : window.location.origin; + +/** + * Identify the retired root-level Pushy worker without touching registrations + * owned by another application that happens to share the same origin. + */ +export function isLegacyPushyRegistration( + registration: ServiceWorkerRegistrationLike, + pageOrigin: string | undefined, +): boolean { + if (!pageOrigin) { + return false; + } + + let normalizedOrigin: string; + try { + normalizedOrigin = new URL(pageOrigin).origin; + } catch { + return false; + } + + return [registration.active, registration.waiting, registration.installing] + .filter((worker): worker is ServiceWorkerLike => Boolean(worker?.scriptURL)) + .some((worker) => { + try { + const scriptUrl = new URL(worker.scriptURL, normalizedOrigin); + return ( + scriptUrl.origin === normalizedOrigin && scriptUrl.pathname === '/sw.js' + ); + } catch { + return false; + } + }); +} + /** * Remove registrations and runtime caches left by the retired PWA layer. * Every branch is best-effort so a browser implementation error cannot block @@ -33,9 +76,11 @@ const getCacheStorage = (): CacheStorageLike | undefined => export async function retireLegacyPwaState({ serviceWorker = getServiceWorkerContainer(), cacheStorage = getCacheStorage(), + pageOrigin = getPageOrigin(), }: { serviceWorker?: ServiceWorkerContainerLike; cacheStorage?: CacheStorageLike; + pageOrigin?: string; } = {}): Promise { const cleanupTasks: Promise[] = []; @@ -45,7 +90,11 @@ export async function retireLegacyPwaState({ .getRegistrations() .then((registrations) => Promise.allSettled( - registrations.map((registration) => registration.unregister()), + registrations + .filter((registration) => + isLegacyPushyRegistration(registration, pageOrigin), + ) + .map((registration) => registration.unregister()), ), ), ); From 711ff031272b550c1c1490e33c02e02ad4ce8367 Mon Sep 17 00:00:00 2001 From: Sunny Luo Date: Mon, 31 Aug 2026 13:49:38 +0800 Subject: [PATCH 17/20] test(pwa): preserve unrelated service workers --- src/utils/service-worker-retirement.test.ts | 74 +++++++++++++++++++-- 1 file changed, 67 insertions(+), 7 deletions(-) diff --git a/src/utils/service-worker-retirement.test.ts b/src/utils/service-worker-retirement.test.ts index 2cad4a9..138a44b 100644 --- a/src/utils/service-worker-retirement.test.ts +++ b/src/utils/service-worker-retirement.test.ts @@ -1,20 +1,79 @@ import { describe, expect, mock, test } from 'bun:test'; import { + isLegacyPushyRegistration, LEGACY_PUSHY_CACHE_PREFIX, retireLegacyPwaState, } from './service-worker-retirement'; +const PAGE_ORIGIN = 'https://admin.example.com'; + +describe('isLegacyPushyRegistration', () => { + test('matches the retired root worker through any registration slot', () => { + const unregister = mock(async () => true); + + expect( + isLegacyPushyRegistration( + { + active: { scriptURL: `${PAGE_ORIGIN}/sw.js` }, + unregister, + }, + PAGE_ORIGIN, + ), + ).toBe(true); + expect( + isLegacyPushyRegistration( + { + waiting: { scriptURL: `${PAGE_ORIGIN}/sw.js?build=old` }, + unregister, + }, + PAGE_ORIGIN, + ), + ).toBe(true); + }); + + test('rejects unrelated paths, origins and unknown registrations', () => { + const unregister = mock(async () => true); + + expect( + isLegacyPushyRegistration( + { + active: { scriptURL: `${PAGE_ORIGIN}/other-sw.js` }, + unregister, + }, + PAGE_ORIGIN, + ), + ).toBe(false); + expect( + isLegacyPushyRegistration( + { + active: { scriptURL: 'https://other.example.com/sw.js' }, + unregister, + }, + PAGE_ORIGIN, + ), + ).toBe(false); + expect(isLegacyPushyRegistration({ unregister }, PAGE_ORIGIN)).toBe(false); + }); +}); + describe('retireLegacyPwaState', () => { - test('unregisters workers and deletes only pushy-admin caches', async () => { - const unregisterA = mock(async () => true); - const unregisterB = mock(async () => true); + test('unregisters only the legacy worker and deletes only pushy-admin caches', async () => { + const unregisterLegacy = mock(async () => true); + const unregisterUnrelated = mock(async () => true); const deleteCache = mock(async () => true); await retireLegacyPwaState({ + pageOrigin: PAGE_ORIGIN, serviceWorker: { getRegistrations: async () => [ - { unregister: unregisterA }, - { unregister: unregisterB }, + { + active: { scriptURL: `${PAGE_ORIGIN}/sw.js` }, + unregister: unregisterLegacy, + }, + { + active: { scriptURL: `${PAGE_ORIGIN}/other-sw.js` }, + unregister: unregisterUnrelated, + }, ], }, cacheStorage: { @@ -27,8 +86,8 @@ describe('retireLegacyPwaState', () => { }, }); - expect(unregisterA).toHaveBeenCalledTimes(1); - expect(unregisterB).toHaveBeenCalledTimes(1); + expect(unregisterLegacy).toHaveBeenCalledTimes(1); + expect(unregisterUnrelated).not.toHaveBeenCalled(); expect(deleteCache).toHaveBeenCalledTimes(2); expect(deleteCache).toHaveBeenCalledWith('pushy-admin-v2'); expect(deleteCache).toHaveBeenCalledWith('pushy-admin-v3'); @@ -40,6 +99,7 @@ describe('retireLegacyPwaState', () => { await expect( retireLegacyPwaState({ + pageOrigin: PAGE_ORIGIN, serviceWorker: { getRegistrations: async () => { throw new Error('registration lookup failed'); From f13ad2d53597905da3aa997e46e65d33683409dd Mon Sep 17 00:00:00 2001 From: Sunny Luo Date: Mon, 31 Aug 2026 13:49:47 +0800 Subject: [PATCH 18/20] fix(pwa): always unregister the tombstone worker --- public/sw.js | 23 ++++++++++++++++------- 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/public/sw.js b/public/sw.js index 7927352..3c366ad 100644 --- a/public/sw.js +++ b/public/sw.js @@ -10,13 +10,22 @@ self.addEventListener('install', () => { self.addEventListener('activate', (event) => { event.waitUntil( (async () => { - const keys = await caches.keys(); - await Promise.allSettled( - keys - .filter((key) => key.startsWith(LEGACY_CACHE_PREFIX)) - .map((key) => caches.delete(key)), - ); - await self.registration.unregister(); + try { + const keys = await caches.keys(); + await Promise.allSettled( + keys + .filter((key) => key.startsWith(LEGACY_CACHE_PREFIX)) + .map((key) => caches.delete(key)), + ); + } catch { + // Cache enumeration is best-effort; retirement must still continue. + } + + try { + await self.registration.unregister(); + } catch { + // A later page load will retry retirement through the application code. + } })(), ); }); From b401e467389c442cf13de497bfb77f48287f930f Mon Sep 17 00:00:00 2001 From: Sunny Luo Date: Mon, 31 Aug 2026 13:50:41 +0800 Subject: [PATCH 19/20] style: format CodeRabbit regression tests --- src/utils/endpoint.test.ts | 23 ++++++++++------------- 1 file changed, 10 insertions(+), 13 deletions(-) diff --git a/src/utils/endpoint.test.ts b/src/utils/endpoint.test.ts index 3c1ae60..31114ae 100644 --- a/src/utils/endpoint.test.ts +++ b/src/utils/endpoint.test.ts @@ -1,8 +1,5 @@ import { describe, expect, test } from 'bun:test'; -import { - isEndpointSelectionUnchanged, - normalizeEndpointUrl, -} from './endpoint'; +import { isEndpointSelectionUnchanged, normalizeEndpointUrl } from './endpoint'; describe('normalizeEndpointUrl', () => { test('canonicalizes HTTPS endpoints and preserves an API path', () => { @@ -47,18 +44,18 @@ describe('isEndpointSelectionUnchanged', () => { }); test('requires reset for an invalid non-null endpoint left by an older release', () => { - expect( - isEndpointSelectionUnchanged('http://api.example.com', null), - ).toBe(false); + expect(isEndpointSelectionUnchanged('http://api.example.com', null)).toBe( + false, + ); expect(isEndpointSelectionUnchanged('not a url', null)).toBe(false); }); test('detects real changes between default and custom endpoints', () => { - expect( - isEndpointSelectionUnchanged(null, 'https://example.com/api'), - ).toBe(false); - expect( - isEndpointSelectionUnchanged('https://example.com/api', null), - ).toBe(false); + expect(isEndpointSelectionUnchanged(null, 'https://example.com/api')).toBe( + false, + ); + expect(isEndpointSelectionUnchanged('https://example.com/api', null)).toBe( + false, + ); }); }); From 460fbc1b4adfbcf42818b8f9572cf7e2664baae0 Mon Sep 17 00:00:00 2001 From: Sunny Luo Date: Mon, 31 Aug 2026 13:50:54 +0800 Subject: [PATCH 20/20] style: format service worker filtering --- src/utils/service-worker-retirement.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/utils/service-worker-retirement.ts b/src/utils/service-worker-retirement.ts index 0e4ed2b..6ea5e63 100644 --- a/src/utils/service-worker-retirement.ts +++ b/src/utils/service-worker-retirement.ts @@ -60,7 +60,8 @@ export function isLegacyPushyRegistration( try { const scriptUrl = new URL(worker.scriptURL, normalizedOrigin); return ( - scriptUrl.origin === normalizedOrigin && scriptUrl.pathname === '/sw.js' + scriptUrl.origin === normalizedOrigin && + scriptUrl.pathname === '/sw.js' ); } catch { return false;