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
{ 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