Forráskód Böngészése

fix(dashboard-v6): 心跳与通知角标轮询改为 5 分钟并加失败退避

2026-10-28 线上 php-fpm 宕机返回 502 期间,heartbeat 与 notification
出现每秒上百次的请求,压着后端恢复。两处轮询改为 setTimeout 链:
成功 5 分钟一次,连续失败 10 → 20 → 30 分钟封顶,±10% 抖动。

- 新增 utils/poll-backoff.ts,两处共用间隔计算
- NetworkStatus:online 事件触发时重排计时,保证只有一条定时链
- NotificationIcon:挂载时立即查询一次(间隔拉长后不能再等第一轮);
  跨标签页缓存 59 秒 → 4 分钟;补上 get() 抛出 HttpError 时的 catch,
  不再产生未捕获的 promise

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
visuddhinanda 17 órája
szülő
commit
a4505b5e67

+ 25 - 12
dashboard-v6/src/components/general/NetworkStatus.tsx

@@ -32,6 +32,7 @@ import {
 } from "@ant-design/icons";
 } from "@ant-design/icons";
 
 
 import { apiServerHealth } from "../../api/api-health";
 import { apiServerHealth } from "../../api/api-health";
+import { nextPollDelay } from "../../utils/poll-backoff";
 import {
 import {
   selectNetStatus,
   selectNetStatus,
   statusChange,
   statusChange,
@@ -43,9 +44,6 @@ const { Text } = Typography;
 
 
 // ─── 常量 ───────────────────────────────────────────────────────────────────
 // ─── 常量 ───────────────────────────────────────────────────────────────────
 
 
-/** API 轮询间隔(毫秒),默认 60 秒 */
-const POLL_INTERVAL_MS = 60_000;
-
 /** API 请求超时阈值(毫秒) */
 /** API 请求超时阈值(毫秒) */
 const API_TIMEOUT_MS = 8_000;
 const API_TIMEOUT_MS = 8_000;
 
 
@@ -283,11 +281,12 @@ export const NetworkStatus: React.FC = () => {
   const netStatus = useSelector(selectNetStatus);
   const netStatus = useSelector(selectNetStatus);
   const [open, setOpen] = useState(false);
   const [open, setOpen] = useState(false);
   const [refreshing, setRefreshing] = useState(false);
   const [refreshing, setRefreshing] = useState(false);
-  const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
+  const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
 
 
   // ── 核心检测逻辑 ──────────────────────────────────────────────────────────
   // ── 核心检测逻辑 ──────────────────────────────────────────────────────────
 
 
-  const runCheck = useCallback(async () => {
+  /** 返回 API 是否在线,供轮询决定下一次的间隔 */
+  const runCheck = useCallback(async (): Promise<boolean> => {
     // 标记:检测中
     // 标记:检测中
     dispatch(
     dispatch(
       statusChange({
       statusChange({
@@ -311,7 +310,7 @@ export const NetworkStatus: React.FC = () => {
           lastCheckedAt: new Date().toISOString(),
           lastCheckedAt: new Date().toISOString(),
         })
         })
       );
       );
-      return;
+      return false;
     }
     }
 
 
     // Step 2:API 健康检测(带超时)
     // Step 2:API 健康检测(带超时)
@@ -320,6 +319,7 @@ export const NetworkStatus: React.FC = () => {
     let finalStatus: ENetStatus = "api_error";
     let finalStatus: ENetStatus = "api_error";
 
 
     try {
     try {
+      // FIXME: controller.signal 没有传给 apiServerHealth(),这个超时从来没生效过
       const controller = new AbortController();
       const controller = new AbortController();
       const timeoutId = setTimeout(() => controller.abort(), API_TIMEOUT_MS);
       const timeoutId = setTimeout(() => controller.abort(), API_TIMEOUT_MS);
 
 
@@ -348,6 +348,7 @@ export const NetworkStatus: React.FC = () => {
         lastCheckedAt: new Date().toISOString(),
         lastCheckedAt: new Date().toISOString(),
       })
       })
     );
     );
+    return isApiOnline;
   }, [dispatch, netStatus.lastCheckedAt]);
   }, [dispatch, netStatus.lastCheckedAt]);
 
 
   // ── 手动刷新 ──────────────────────────────────────────────────────────────
   // ── 手动刷新 ──────────────────────────────────────────────────────────────
@@ -362,11 +363,22 @@ export const NetworkStatus: React.FC = () => {
   // ── 轮询 & 网络事件监听 ───────────────────────────────────────────────────
   // ── 轮询 & 网络事件监听 ───────────────────────────────────────────────────
 
 
   useEffect(() => {
   useEffect(() => {
-    // 首次立即检测
-    runCheck();
+    let disposed = false;
+    let failures = 0;
+
+    // 定时轮询:用 setTimeout 链而不是 setInterval,每次按上一次的结果
+    // 决定下一次的间隔——成功 5 分钟,连续失败逐步退避(见 poll-backoff.ts)
+    const check = async () => {
+      const ok = await runCheck();
+      if (disposed) return;
+      failures = ok ? 0 : failures + 1;
+      // online 事件可能在上一轮未完成时又触发一轮,先清掉旧的,保证只有一条链
+      if (timerRef.current) clearTimeout(timerRef.current);
+      timerRef.current = setTimeout(check, nextPollDelay(failures));
+    };
 
 
-    // 定时轮询
-    timerRef.current = setInterval(runCheck, POLL_INTERVAL_MS);
+    // 首次立即检测
+    check();
 
 
     // 监听浏览器网络事件(离线时立即触发)
     // 监听浏览器网络事件(离线时立即触发)
     const handleOffline = () => {
     const handleOffline = () => {
@@ -380,13 +392,14 @@ export const NetworkStatus: React.FC = () => {
         })
         })
       );
       );
     };
     };
-    const handleOnline = () => runCheck();
+    const handleOnline = () => check();
 
 
     window.addEventListener("offline", handleOffline);
     window.addEventListener("offline", handleOffline);
     window.addEventListener("online", handleOnline);
     window.addEventListener("online", handleOnline);
 
 
     return () => {
     return () => {
-      if (timerRef.current) clearInterval(timerRef.current);
+      disposed = true;
+      if (timerRef.current) clearTimeout(timerRef.current);
       window.removeEventListener("offline", handleOffline);
       window.removeEventListener("offline", handleOffline);
       window.removeEventListener("online", handleOnline);
       window.removeEventListener("online", handleOnline);
     };
     };

+ 82 - 43
dashboard-v6/src/components/notification/NotificationIcon.tsx

@@ -7,16 +7,24 @@ import { NotificationIcon } from "../../assets/icon";
 import NotificationList from "./NotificationList";
 import NotificationList from "./NotificationList";
 import { useAppSelector } from "../../hooks";
 import { useAppSelector } from "../../hooks";
 import { currentUser, type IUser } from "../../reducers/current-user";
 import { currentUser, type IUser } from "../../reducers/current-user";
+import { nextPollDelay } from "../../utils/poll-backoff";
+
+/**
+ * 其他标签页(或本页上一轮)这么久之内刚查过,就直接用 localStorage 里的结果。
+ * 要短于最短的轮询间隔(5 分钟 -10% 抖动 = 4.5 分钟),否则本页会一直读到自己上一轮的缓存。
+ */
+const CACHE_MS = 4 * 60_000;
 
 
 const NotificationIconWidget = () => {
 const NotificationIconWidget = () => {
   const [count, setCount] = useState<number>();
   const [count, setCount] = useState<number>();
   const currUser = useAppSelector(currentUser);
   const currUser = useAppSelector(currentUser);
   const [mute, setMute] = useState(false);
   const [mute, setMute] = useState(false);
 
 
-  const queryNotification = (user?: IUser) => {
+  /** 返回本轮是否成功,供轮询决定下一次的间隔 */
+  const queryNotification = async (user?: IUser): Promise<boolean> => {
     if (!user) {
     if (!user) {
       console.debug("未登录 不查询 notification");
       console.debug("未登录 不查询 notification");
-      return;
+      return true;
     }
     }
     const isMute = localStorage.getItem("notification/mute");
     const isMute = localStorage.getItem("notification/mute");
     if (isMute && isMute === "true") {
     if (isMute && isMute === "true") {
@@ -29,67 +37,98 @@ const NotificationIconWidget = () => {
       "notification/updatedAt"
       "notification/updatedAt"
     );
     );
     if (notificationUpdatedAt) {
     if (notificationUpdatedAt) {
-      if (now.getTime() - parseInt(notificationUpdatedAt) < 59000) {
+      if (now.getTime() - parseInt(notificationUpdatedAt) < CACHE_MS) {
         const notificationCount = localStorage.getItem("notification/count");
         const notificationCount = localStorage.getItem("notification/count");
         if (notificationCount !== null) {
         if (notificationCount !== null) {
           setCount(parseInt(notificationCount));
           setCount(parseInt(notificationCount));
           console.debug("has notification count");
           console.debug("has notification count");
-          return;
+          return true;
         }
         }
       }
       }
     }
     }
 
 
     const url = `/api/v2/notification?view=to&limit=1`;
     const url = `/api/v2/notification?view=to&limit=1`;
     console.info("notification api request", url);
     console.info("notification api request", url);
-    get<INotificationListResponse>(url).then((json) => {
-      if (json.ok) {
-        console.debug("notification fetch ok ", json.data.unread);
-        localStorage.setItem(
-          "notification/updatedAt",
-          now.getTime().toString()
-        );
-        localStorage.setItem("notification/count", json.data.unread.toString());
-        setCount(json.data.unread);
-        if (json.data.count > 0) {
-          const newMessageTime = json.data.rows[0].created_at;
-          const lastTime = localStorage.getItem("notification/new");
-          if (lastTime === null || lastTime !== newMessageTime) {
-            localStorage.setItem("notification/new", newMessageTime);
+    return get<INotificationListResponse>(url)
+      .then((json) => {
+        if (json.ok) {
+          console.debug("notification fetch ok ", json.data.unread);
+          localStorage.setItem(
+            "notification/updatedAt",
+            now.getTime().toString()
+          );
+          localStorage.setItem(
+            "notification/count",
+            json.data.unread.toString()
+          );
+          setCount(json.data.unread);
+          if (json.data.count > 0) {
+            const newMessageTime = json.data.rows[0].created_at;
+            const lastTime = localStorage.getItem("notification/new");
+            if (lastTime === null || lastTime !== newMessageTime) {
+              localStorage.setItem("notification/new", newMessageTime);
 
 
-            const title = json.data.rows[0].res_type;
-            const content = json.data.rows[0].content;
-            localStorage.setItem(
-              "notification/message",
-              JSON.stringify({ title: title, content: content })
-            );
-            // 发送通知
-            if (!isMute || isMute !== "true") {
-              if (window.Notification && Notification.permission !== "denied") {
-                Notification.requestPermission(function () {
-                  const notification = new Notification(title, {
-                    body: content,
-                    icon: import.meta.env.BASE_URL + "logo192.png",
-                    tag: json.data.rows[0].id,
+              const title = json.data.rows[0].res_type;
+              const content = json.data.rows[0].content;
+              localStorage.setItem(
+                "notification/message",
+                JSON.stringify({ title: title, content: content })
+              );
+              // 发送通知
+              if (!isMute || isMute !== "true") {
+                if (
+                  window.Notification &&
+                  Notification.permission !== "denied"
+                ) {
+                  Notification.requestPermission(function () {
+                    const notification = new Notification(title, {
+                      body: content,
+                      icon: import.meta.env.BASE_URL + "logo192.png",
+                      tag: json.data.rows[0].id,
+                    });
+                    notification.onclick = (event) => {
+                      event.preventDefault(); // 阻止浏览器聚焦于 Notification 的标签页
+                      window.open(json.data.rows[0].url, "_blank");
+                    };
                   });
                   });
-                  notification.onclick = (event) => {
-                    event.preventDefault(); // 阻止浏览器聚焦于 Notification 的标签页
-                    window.open(json.data.rows[0].url, "_blank");
-                  };
-                });
+                }
               }
               }
             }
             }
           }
           }
+          return true;
+        } else {
+          console.error(json.message);
+          return false;
         }
         }
-      } else {
-        console.error(json.message);
-      }
-    });
+      })
+      .catch((e: unknown) => {
+        // 502 等非 2xx 时 get() 会抛 HttpError,原来没接住,成了未捕获的 promise
+        console.error(e);
+        return false;
+      });
   };
   };
 
 
   useEffect(() => {
   useEffect(() => {
-    const timer = setInterval(() => queryNotification(currUser), 1000 * 60);
+    if (!currUser) return;
+    let disposed = false;
+    let failures = 0;
+    let timer: ReturnType<typeof setTimeout> | undefined;
+
+    // setTimeout 链:成功 5 分钟一次,连续失败逐步退避(见 poll-backoff.ts)
+    const tick = async () => {
+      const ok = await queryNotification(currUser);
+      if (disposed) return;
+      failures = ok ? 0 : failures + 1;
+      timer = setTimeout(tick, nextPollDelay(failures));
+    };
+
+    // 间隔拉长到 5 分钟后,不能再等第一轮才出角标;
+    // 有 CACHE_MS 的缓存挡着,多开标签页或重新挂载不会重复请求
+    tick();
+
     return () => {
     return () => {
-      clearInterval(timer);
+      disposed = true;
+      clearTimeout(timer);
     };
     };
   }, [currUser]);
   }, [currUser]);
 
 

+ 24 - 0
dashboard-v6/src/utils/poll-backoff.ts

@@ -0,0 +1,24 @@
+// src/utils/poll-backoff.ts
+//
+// 后台轮询(心跳、通知角标)共用的间隔计算。
+//
+// 后端过载或宕机时,轮询失败越快、重发越密,就越压着后端起不来
+// (2026-10-28 线上 502 期间出现过每秒上百次的心跳 + 通知请求)。
+// 所以失败后按指数拉长间隔,成功一次就回到基础间隔。
+
+/** 基础轮询间隔:5 分钟 */
+export const POLL_BASE_MS = 5 * 60_000;
+
+/** 失败退避的上限:30 分钟 */
+export const POLL_MAX_MS = 30 * 60_000;
+
+/**
+ * 连续失败 `failures` 次后,距下一次轮询的等待时间。
+ *
+ * 0 次 → 5 分钟,1 次 → 10 分钟,2 次 → 20 分钟,之后封顶 30 分钟。
+ * 加 ±10% 抖动,避免多个标签页、多个用户在后端恢复的同一刻一起打上来。
+ */
+export const nextPollDelay = (failures: number): number => {
+  const delay = Math.min(POLL_BASE_MS * 2 ** failures, POLL_MAX_MS);
+  return delay * (0.9 + Math.random() * 0.2);
+};