Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
feb7c52
fix(admin): tolerate unavailable statistics and load failures
sunnylqm Aug 31, 2026
003eb54
fix(cache): avoid incomplete app cache entries
sunnylqm Aug 31, 2026
f556094
fix(runtime): isolate endpoint changes and recover stale chunks
sunnylqm Aug 31, 2026
e0f3ba0
fix(metrics): rank distributions by real volume
sunnylqm Aug 31, 2026
96ee9a0
style: apply Biome formatting
sunnylqm Aug 31, 2026
52c0967
refactor(pwa): stop registering the service worker
sunnylqm Aug 31, 2026
70c9601
refactor(pwa): retire legacy service workers
sunnylqm Aug 31, 2026
627aa41
test(pwa): add legacy service worker cleanup helper
sunnylqm Aug 31, 2026
328af97
test(pwa): cover legacy service worker cleanup
sunnylqm Aug 31, 2026
978a07e
fix(pwa): accept readonly browser registrations
sunnylqm Aug 31, 2026
28571a5
style(pwa): apply Biome formatting
sunnylqm Aug 31, 2026
c62bad7
fix(endpoint): reset invalid persisted endpoints
sunnylqm Aug 31, 2026
2e68f1d
test(endpoint): cover invalid legacy reset
sunnylqm Aug 31, 2026
9b16e6a
fix(endpoint): apply validated selection comparison
sunnylqm Aug 31, 2026
10baa1f
fix(types): preserve nullable admin app metrics
sunnylqm Aug 31, 2026
e99a64e
fix(pwa): limit retirement to the legacy worker
sunnylqm Aug 31, 2026
711ff03
test(pwa): preserve unrelated service workers
sunnylqm Aug 31, 2026
f13ad2d
fix(pwa): always unregister the tombstone worker
sunnylqm Aug 31, 2026
b401e46
style: format CodeRabbit regression tests
sunnylqm Aug 31, 2026
460fbc1
style: format service worker filtering
sunnylqm Aug 31, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
85 changes: 22 additions & 63 deletions public/sw.js
Original file line number Diff line number Diff line change
@@ -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.
}
})(),
);
});
59 changes: 53 additions & 6 deletions src/components/admin-route.tsx
Original file line number Diff line number Diff line change
@@ -1,24 +1,71 @@
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 <Navigate replace to={rootRouterPath.login} />;
}

if (isLoading || (!isError && user === undefined)) {
return (
<div className="page-section flex min-h-64 items-center justify-center">
<Spin />
</div>
);
}

if (isError) {
return (
<div className="page-section">
<Result
status="error"
title={t('error_boundary.title')}
subTitle={
error instanceof Error && error.message
? error.message
: t('error_boundary.unknown_error')
}
extra={
<Button
loading={isFetching}
type="primary"
onClick={() => void refetch()}
>
{t('error_boundary.retry')}
</Button>
}
/>
</div>
);
}

if (!user?.admin) {
return <Navigate replace to="/apps" />;
return <Navigate replace to={rootRouterPath.apps} />;
}

return <Outlet />;
Expand Down
17 changes: 11 additions & 6 deletions src/components/error-boundary.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand All @@ -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 = () => {
Expand Down
42 changes: 30 additions & 12 deletions src/components/switch-endpoint-modal.tsx
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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'));
}
Expand All @@ -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 (
Expand Down
42 changes: 19 additions & 23 deletions src/components/user-detail-drawer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -201,41 +203,35 @@ export const UserDetailDrawer = ({
column={2}
>
<Descriptions.Item label={translate('admin_users.pv_limit')}>
{t('admin_users.checks_value', {
value: detail.quotaDetail.limit.pv,
})}
{renderChecks(detail.quotaDetail.limit.pv)}
</Descriptions.Item>
<Descriptions.Item label={translate('admin_users.today_used')}>
{t('admin_users.checks_value', {
value: detail.quotaDetail.todayUsed,
})}
{renderChecks(detail.quotaDetail.todayUsed)}
</Descriptions.Item>
<Descriptions.Item
label={translate('admin_users.today_remaining')}
>
{t('admin_users.checks_value', {
value: detail.quotaDetail.todayRemaining,
})}
{renderChecks(detail.quotaDetail.todayRemaining)}
</Descriptions.Item>
<Descriptions.Item label={translate('admin_users.avg_7_days')}>
{t('admin_users.checks_value', {
value: detail.quotaDetail.last7Days.avg,
})}
{renderChecks(detail.quotaDetail.last7Days?.avg)}
</Descriptions.Item>
<Descriptions.Item
label={translate('admin_users.last_7_days_details')}
span={2}
>
{detail.quotaDetail.last7Days.counts
.slice()
.reverse()
.map((c, i) => (
// biome-ignore lint/suspicious/noArrayIndexKey: fixed-length ordered day list, index is the stable identity
<span key={i} className="mr-3 inline-block">
{t('admin_users.day_label', { day: i + 1 })}:{' '}
<strong>{c}</strong>
</span>
))}
{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
<span key={index} className="mr-3 inline-block">
{t('admin_users.day_label', { day: index + 1 })}:{' '}
<strong>{count}</strong>
</span>
))
: '-'}
</Descriptions.Item>
<Descriptions.Item label={translate('admin_users.app_limit')}>
{detail.apps.length} / {detail.quotaDetail.limit.app}
Expand Down Expand Up @@ -265,7 +261,7 @@ export const UserDetailDrawer = ({
</span>
<Space size="middle">
<span>
PV: <strong>{app.checkCount}</strong>
PV: <strong>{app.checkCount ?? '-'}</strong>
</span>
<span>
{translate('admin_users.packages_count')}:{' '}
Expand Down
Loading