diff --git a/public/sw.js b/public/sw.js
index 20aa3a58..c4f0d1c5 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/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
{ 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..ea280630
--- /dev/null
+++ b/src/utils/endpoint.test.ts
@@ -0,0 +1,33 @@
+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 4410769b..a37c8d76 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