Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
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
27 changes: 18 additions & 9 deletions public/sw.js
Original file line number Diff line number Diff line change
@@ -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);

Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
}),
);
});
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
44 changes: 32 additions & 12 deletions src/components/switch-endpoint-modal.tsx
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -27,25 +31,43 @@ 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'));
}
Expand All @@ -57,10 +79,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
18 changes: 15 additions & 3 deletions src/pages/admin-metrics.logic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
DEFAULT_RANGE_HOURS,
formatTooltipItem,
getCategoryPrefix,
getDistributionCategoryOrder,
getMetricsTotal,
type MetricsResponse,
parseDateRange,
Expand Down Expand Up @@ -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', () => {
Expand All @@ -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',
Expand All @@ -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 不再计入
{
Expand All @@ -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',
Expand Down
Loading