diff --git a/public/sw.js b/public/sw.js index 20aa3a58..3c366ad1 100644 --- a/public/sw.js +++ b/public/sw.js @@ -1,72 +1,31 @@ -const CACHE_NAME = 'pushy-admin-v2'; -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( - 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'); - -// 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.match(request).then((cached) => { - 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; - }); - }), + (async () => { + 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. + } + })(), ); }); diff --git a/src/components/admin-route.tsx b/src/components/admin-route.tsx index c397a107..da4dceca 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/error-boundary.tsx b/src/components/error-boundary.tsx index 9d1e079e..36abce46 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 04bf8682..b08f68bf 100644 --- a/src/components/switch-endpoint-modal.tsx +++ b/src/components/switch-endpoint-modal.tsx @@ -1,11 +1,16 @@ 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 { + isEndpointSelectionUnchanged, + normalizeEndpointUrl, setCustomBaseUrl, testEndpointStatus, useCustomBaseUrl, } from '@/utils/endpoint'; +import { queryClient } from '@/utils/queryClient'; interface SwitchEndpointModalProps { onClose: () => void; @@ -27,25 +32,40 @@ export function SwitchEndpointModal({ } }, [open, currentCustomUrl]); - const handleSave = async () => { - const trimmed = urlInput.trim(); - if (!trimmed) { - handleReset(); + const applyEndpointChange = ( + nextUrl: string | null, + successMessage: string, + ) => { + if (isEndpointSelectionUnchanged(currentCustomUrl, 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 +77,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/components/user-detail-drawer.tsx b/src/components/user-detail-drawer.tsx index 67765efa..2a4f40f8 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/index.tsx b/src/index.tsx index bbed2ab8..65c1b222 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() { diff --git a/src/pages/admin-metrics.logic.test.ts b/src/pages/admin-metrics.logic.test.ts index 009f1f58..e74c35d7 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 a671610e..7246d94b 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 cee1487e..348242c3 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/services/admin-api.ts b/src/services/admin-api.ts index 4718e62d..8881330a 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; + Omit & { + checkCount: number | null; packagesCount: number; } >; diff --git a/src/services/mutation-cache.test.ts b/src/services/mutation-cache.test.ts new file mode 100644 index 00000000..60459bb4 --- /dev/null +++ b/src/services/mutation-cache.test.ts @@ -0,0 +1,45 @@ +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 00000000..845d9821 --- /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 51e8565a..ef413ea7 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 = () => diff --git a/src/utils/charts.test.ts b/src/utils/charts.test.ts index 70a4c750..7e8d2489 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 a13e803c..736febcf 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: [ diff --git a/src/utils/chunk-recovery.test.ts b/src/utils/chunk-recovery.test.ts new file mode 100644 index 00000000..8035ce11 --- /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 00000000..74d8cc22 --- /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 00000000..31114ae6 --- /dev/null +++ b/src/utils/endpoint.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, test } from 'bun:test'; +import { isEndpointSelectionUnchanged, 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(); + }); +}); + +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, + ); + }); +}); diff --git a/src/utils/endpoint.ts b/src/utils/endpoint.ts index 4410769b..2db8b6ef 100644 --- a/src/utils/endpoint.ts +++ b/src/utils/endpoint.ts @@ -2,8 +2,61 @@ 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; + } +} + +/** + * 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; @@ -62,18 +115,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); } } diff --git a/src/utils/service-worker-retirement.test.ts b/src/utils/service-worker-retirement.test.ts new file mode 100644 index 00000000..138a44b9 --- /dev/null +++ b/src/utils/service-worker-retirement.test.ts @@ -0,0 +1,117 @@ +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 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 () => [ + { + active: { scriptURL: `${PAGE_ORIGIN}/sw.js` }, + unregister: unregisterLegacy, + }, + { + active: { scriptURL: `${PAGE_ORIGIN}/other-sw.js` }, + unregister: unregisterUnrelated, + }, + ], + }, + cacheStorage: { + keys: async () => [ + `${LEGACY_PUSHY_CACHE_PREFIX}v2`, + 'unrelated-cache', + `${LEGACY_PUSHY_CACHE_PREFIX}v3`, + ], + delete: deleteCache, + }, + }); + + expect(unregisterLegacy).toHaveBeenCalledTimes(1); + expect(unregisterUnrelated).not.toHaveBeenCalled(); + 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({ + pageOrigin: PAGE_ORIGIN, + 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'); + }); +}); diff --git a/src/utils/service-worker-retirement.ts b/src/utils/service-worker-retirement.ts new file mode 100644 index 00000000..6ea5e63e --- /dev/null +++ b/src/utils/service-worker-retirement.ts @@ -0,0 +1,119 @@ +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; +} + +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; + +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 + * the management console from starting. + */ +export async function retireLegacyPwaState({ + serviceWorker = getServiceWorkerContainer(), + cacheStorage = getCacheStorage(), + pageOrigin = getPageOrigin(), +}: { + serviceWorker?: ServiceWorkerContainerLike; + cacheStorage?: CacheStorageLike; + pageOrigin?: string; +} = {}): Promise { + const cleanupTasks: Promise[] = []; + + if (serviceWorker) { + cleanupTasks.push( + serviceWorker + .getRegistrations() + .then((registrations) => + Promise.allSettled( + registrations + .filter((registration) => + isLegacyPushyRegistration(registration, pageOrigin), + ) + .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); +}