This is an automated email from the ASF dual-hosted git repository.

zqr10159 pushed a commit to branch 2.0.0
in repository https://gitbox.apache.org/repos/asf/hertzbeat.git

commit 584fca5f0d852c573d8add771cc06591b097ccb6
Author: Logic <[email protected]>
AuthorDate: Fri May 29 00:08:52 2026 +0800

    feat(web-next): add client runtime foundation
---
 web-next/app/layout.tsx                            |  13 +-
 .../components/providers/query-provider.test.tsx   |  25 +++
 web-next/components/providers/query-provider.tsx   |  23 +++
 .../components/workbench/client-workbench.test.tsx | 191 ++++++++++++++++++++-
 web-next/components/workbench/client-workbench.tsx |  70 +++++++-
 web-next/lib/api-client.test.ts                    | 137 +++++++++++++++
 web-next/lib/api-client.ts                         | 111 +++++++-----
 web-next/lib/i18n-runtime-messages.ts              |   4 +
 web-next/lib/query-keys.test.ts                    |  40 +++++
 web-next/lib/query-keys.ts                         |  50 ++++++
 web-next/lib/session-client.test.ts                |  86 ++++++++++
 web-next/lib/session-client.ts                     |  96 +++++++++++
 web-next/lib/workbench-load-cache.test.ts          |  31 ++++
 web-next/lib/workbench-load-cache.ts               |  62 ++++++-
 14 files changed, 863 insertions(+), 76 deletions(-)

diff --git a/web-next/app/layout.tsx b/web-next/app/layout.tsx
index 6dc6e96655..2b478d76ae 100644
--- a/web-next/app/layout.tsx
+++ b/web-next/app/layout.tsx
@@ -2,11 +2,12 @@ import 'react-datepicker/dist/react-datepicker.css';
 import './globals.css';
 import type { Metadata } from 'next';
 import { I18nProvider } from '@/components/providers/i18n-provider';
+import { HertzBeatQueryProvider } from '@/components/providers/query-provider';
 import { AppFrame } from '@/components/shell/app-frame';
 
 export const metadata: Metadata = {
-  title: 'HertzBeat Workbench Next Pilot',
-  description: 'Mixed Angular/Next.js observability workbench pilot for 
HertzBeat.',
+  title: 'HertzBeat Observability Workbench',
+  description: 'Private-deployable operations observability for monitors, OTLP 
signals, alerts, topology, and safe automation.',
   icons: {
     icon: '/assets/logo.svg',
     shortcut: '/assets/logo.svg'
@@ -17,9 +18,11 @@ export default function RootLayout({ children }: { children: 
React.ReactNode })
   return (
     <html lang="zh-CN">
       <body data-theme="dark-ops">
-        <I18nProvider>
-          <AppFrame>{children}</AppFrame>
-        </I18nProvider>
+        <HertzBeatQueryProvider>
+          <I18nProvider>
+            <AppFrame>{children}</AppFrame>
+          </I18nProvider>
+        </HertzBeatQueryProvider>
       </body>
     </html>
   );
diff --git a/web-next/components/providers/query-provider.test.tsx 
b/web-next/components/providers/query-provider.test.tsx
new file mode 100644
index 0000000000..7f6412d83c
--- /dev/null
+++ b/web-next/components/providers/query-provider.test.tsx
@@ -0,0 +1,25 @@
+import React from 'react';
+import { renderToStaticMarkup } from 'react-dom/server';
+import { describe, expect, it } from 'vitest';
+
+import { hertzBeatQueryDefaults, HertzBeatQueryProvider } from 
'./query-provider';
+
+describe('HertzBeatQueryProvider', () => {
+  it('uses Horizon-style short stale query defaults', () => {
+    expect(hertzBeatQueryDefaults.queries).toMatchObject({
+      staleTime: 5000,
+      refetchOnWindowFocus: true,
+      retry: 1
+    });
+  });
+
+  it('renders children through the shared QueryClientProvider', () => {
+    const html = renderToStaticMarkup(
+      <HertzBeatQueryProvider>
+        <span data-query-provider-child="true">ready</span>
+      </HertzBeatQueryProvider>
+    );
+
+    expect(html).toContain('data-query-provider-child="true"');
+  });
+});
diff --git a/web-next/components/providers/query-provider.tsx 
b/web-next/components/providers/query-provider.tsx
new file mode 100644
index 0000000000..f072dab83c
--- /dev/null
+++ b/web-next/components/providers/query-provider.tsx
@@ -0,0 +1,23 @@
+'use client';
+
+import * as React from 'react';
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
+
+export const hertzBeatQueryDefaults = {
+  queries: {
+    staleTime: 5000,
+    refetchOnWindowFocus: true,
+    retry: 1
+  }
+} as const;
+
+function createHertzBeatQueryClient() {
+  return new QueryClient({
+    defaultOptions: hertzBeatQueryDefaults
+  });
+}
+
+export function HertzBeatQueryProvider({ children }: { children: 
React.ReactNode }) {
+  const [queryClient] = React.useState(createHertzBeatQueryClient);
+  return <QueryClientProvider 
client={queryClient}>{children}</QueryClientProvider>;
+}
diff --git a/web-next/components/workbench/client-workbench.test.tsx 
b/web-next/components/workbench/client-workbench.test.tsx
index 57f1b58fe3..06be8dfb4e 100644
--- a/web-next/components/workbench/client-workbench.test.tsx
+++ b/web-next/components/workbench/client-workbench.test.tsx
@@ -1,11 +1,21 @@
+// @vitest-environment jsdom
+
 import React from 'react';
+import { readFileSync } from 'node:fs';
+import { resolve } from 'node:path';
+import { act } from 'react';
+import { createRoot, type Root } from 'react-dom/client';
 import { renderToStaticMarkup } from 'react-dom/server';
-import { describe, expect, it, vi } from 'vitest';
+import { afterEach, describe, expect, it, vi } from 'vitest';
 import { ClientWorkbench } from './client-workbench';
 
+(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean 
}).IS_REACT_ACT_ENVIRONMENT = true;
+
+const mockTranslate = (key: string) => key;
+
 vi.mock('@/components/providers/i18n-provider', () => ({
   useI18n: () => ({
-    t: (key: string) => key
+    t: mockTranslate
   })
 }));
 
@@ -36,19 +46,184 @@ vi.mock('@/lib/workbench-load-cache', () => ({
 }));
 
 describe('ClientWorkbench', () => {
-  it('renders a global visible pending state so slow workbenches do not look 
blank', () => {
+  let root: Root | null = null;
+  let container: HTMLDivElement | null = null;
+
+  afterEach(() => {
+    if (root) {
+      act(() => {
+        root?.unmount();
+      });
+    }
+    root = null;
+    container?.remove();
+    container = null;
+    vi.useRealTimers();
+  });
+
+  it('defers the global visible pending state so quick route hops do not flash 
Loading workspace', () => {
     const html = renderToStaticMarkup(
       <ClientWorkbench load={() => Promise.resolve({ ready: true })} 
loadingTitle="链路工作台" loadingCopy="正在加载链路数据">
         {() => <div>ready</div>}
       </ClientWorkbench>
     );
 
-    expect(html).toContain('data-client-workbench-loading="global-spinner"');
-    expect(html).toContain('data-client-workbench-loading-spinner="true"');
-    expect(html).toContain('role="status"');
+    expect(html).toContain('data-client-workbench-loading="deferred"');
     expect(html).toContain('aria-busy="true"');
-    expect(html).toContain('链路工作台');
-    expect(html).toContain('正在加载链路数据');
+    
expect(html).not.toContain('data-client-workbench-loading="global-spinner"');
+    expect(html).not.toContain('data-client-workbench-loading-spinner="true"');
+    expect(html).not.toContain('role="status"');
+    expect(html).not.toContain('链路工作台');
+    expect(html).not.toContain('正在加载链路数据');
     expect(html).not.toContain('ready');
   });
+
+  it('reveals the route loading copy only when the load remains pending beyond 
the defer window', async () => {
+    let resolveLoad: ((value: { ready: boolean }) => void) | null = null;
+    const load = vi.fn(() => new Promise<{ ready: boolean }>(resolve => {
+      resolveLoad = resolve;
+    }));
+    container = document.createElement('div');
+    document.body.appendChild(container);
+    root = createRoot(container);
+
+    await act(async () => {
+      root?.render(
+        <ClientWorkbench load={load} loadingTitle="链路工作台" 
loadingCopy="正在加载链路数据" loadingDelayMs={10}>
+          {() => <div>ready</div>}
+        </ClientWorkbench>
+      );
+      await Promise.resolve();
+    });
+
+    expect(load).toHaveBeenCalledTimes(1);
+    expect(container.textContent).not.toContain('链路工作台');
+    
expect(container.querySelector('[data-client-workbench-loading="deferred"]')).not.toBeNull();
+    
expect(container.querySelector('[data-client-workbench-loading="global-spinner"]')).toBeNull();
+
+    await act(async () => {
+      await new Promise(resolve => window.setTimeout(resolve, 15));
+    });
+
+    
expect(container.querySelector('[data-client-workbench-loading="global-spinner"]')).not.toBeNull();
+    expect(container.textContent).toContain('链路工作台');
+    expect(container.textContent).toContain('正在加载链路数据');
+
+    await act(async () => {
+      resolveLoad?.({ ready: true });
+      await Promise.resolve();
+    });
+
+    expect(container.textContent).toContain('ready');
+    
expect(container.querySelector('[data-client-workbench-loading="global-spinner"]')).toBeNull();
+  });
+
+  it('keeps shared workbench loading copy hidden during the deferred first 
paint', () => {
+    const html = renderToStaticMarkup(
+      <ClientWorkbench load={() => Promise.resolve({ ready: true })}>
+        {() => <div>ready</div>}
+      </ClientWorkbench>
+    );
+
+    expect(html).toContain('data-client-workbench-loading="deferred"');
+    expect(html).not.toContain('common.workbench.loading.title');
+    expect(html).not.toContain('common.workbench.loading.copy');
+    expect(html).not.toContain('common.loading');
+    expect(html).not.toContain('ready');
+  });
+
+  it('forwards settled cache TTL options through the shared workbench cache', 
() => {
+    const source = readFileSync(resolve(process.cwd(), 
'components/workbench/client-workbench.tsx'), 'utf8');
+
+    expect(source).toContain('cacheSettledTtlMs?: number');
+    expect(source).toContain('consumeWorkbenchLoad(cacheKey, load, { 
settledTtlMs: cacheSettledTtlMs })');
+    expect(source).toContain('[cacheKey, cacheSettledTtlMs, load, 
loadingDelayMs, reloadKey, t]');
+  });
+
+  it('lets route islands render a custom recoverable error state and retry the 
loader', async () => {
+    const load = vi.fn()
+      .mockRejectedValueOnce(new Error('monitor missing'))
+      .mockResolvedValueOnce({ ready: true });
+    container = document.createElement('div');
+    document.body.appendChild(container);
+    root = createRoot(container);
+
+    await act(async () => {
+      root?.render(
+        <ClientWorkbench
+          load={load}
+          renderError={(message, retry) => (
+            <section data-custom-error-state="true">
+              <span>{message}</span>
+              <button type="button" onClick={retry}>Retry</button>
+            </section>
+          )}
+        >
+          {() => <div>ready</div>}
+        </ClientWorkbench>
+      );
+      await Promise.resolve();
+      await Promise.resolve();
+    });
+
+    expect(load).toHaveBeenCalledTimes(1);
+    expect(container.textContent).toContain('load failed');
+    
expect(container.querySelector('[data-custom-error-state="true"]')).not.toBeNull();
+
+    await act(async () => {
+      container.querySelector('button')?.dispatchEvent(new MouseEvent('click', 
{ bubbles: true }));
+      await Promise.resolve();
+      await Promise.resolve();
+    });
+
+    expect(load).toHaveBeenCalledTimes(2);
+    expect(container.textContent).toContain('ready');
+    
expect(container.querySelector('[data-custom-error-state="true"]')).toBeNull();
+  });
+
+  it('lets route islands render custom loading chrome after the defer window', 
async () => {
+    let resolveLoad: ((value: { ready: boolean }) => void) | null = null;
+    const load = vi.fn(() => new Promise<{ ready: boolean }>(resolve => {
+      resolveLoad = resolve;
+    }));
+    container = document.createElement('div');
+    document.body.appendChild(container);
+    root = createRoot(container);
+
+    await act(async () => {
+      root?.render(
+        <ClientWorkbench
+          load={load}
+          loadingDelayMs={10}
+          renderLoading={visible => (
+            <section data-custom-loading-state={visible ? 'visible' : 
'deferred'}>
+              {visible ? 'route loading' : null}
+            </section>
+          )}
+        >
+          {() => <div>ready</div>}
+        </ClientWorkbench>
+      );
+      await Promise.resolve();
+    });
+
+    expect(load).toHaveBeenCalledTimes(1);
+    
expect(container.querySelector('[data-custom-loading-state="deferred"]')).not.toBeNull();
+    expect(container.textContent).not.toContain('route loading');
+
+    await act(async () => {
+      await new Promise(resolve => window.setTimeout(resolve, 15));
+    });
+
+    
expect(container.querySelector('[data-custom-loading-state="visible"]')).not.toBeNull();
+    expect(container.textContent).toContain('route loading');
+
+    await act(async () => {
+      resolveLoad?.({ ready: true });
+      await Promise.resolve();
+    });
+
+    expect(container.textContent).toContain('ready');
+    expect(container.querySelector('[data-custom-loading-state]')).toBeNull();
+  });
 });
diff --git a/web-next/components/workbench/client-workbench.tsx 
b/web-next/components/workbench/client-workbench.tsx
index 21be141272..226c2084ba 100644
--- a/web-next/components/workbench/client-workbench.tsx
+++ b/web-next/components/workbench/client-workbench.tsx
@@ -4,27 +4,39 @@ import React, { useEffect, useRef, useState } from 'react';
 import { Loader2 } from 'lucide-react';
 import { ObservabilityStatusState } from '@/components/observability';
 import { useI18n } from '@/components/providers/i18n-provider';
-import { getAuthorizationToken } from '@/lib/api-client';
 import { resolveWorkbenchError } from '@/lib/client-workbench-state';
 import { buildLoginRedirectHref, buildLoginReturnTo } from 
'@/lib/passport-login/controller';
+import { readClientSessionState } from '@/lib/session-client';
 import { consumeWorkbenchLoad } from '@/lib/workbench-load-cache';
 
+const CLIENT_WORKBENCH_LOADING_DELAY_MS = 650;
+
 export function ClientWorkbench<T>({
   load,
   children,
+  renderError,
+  renderLoading,
   loadingTitle,
   loadingCopy,
-  cacheKey
+  cacheKey,
+  cacheSettledTtlMs,
+  loadingDelayMs = CLIENT_WORKBENCH_LOADING_DELAY_MS
 }: {
   load: () => Promise<T>;
   children: (data: T) => React.ReactNode;
+  renderError?: (message: string, retry: () => void) => React.ReactNode;
+  renderLoading?: (visible: boolean) => React.ReactNode;
   loadingTitle?: string;
   loadingCopy?: string;
   cacheKey?: string;
+  cacheSettledTtlMs?: number;
+  loadingDelayMs?: number;
 }) {
   const { t } = useI18n();
   const [data, setData] = useState<T | null>(null);
   const [error, setError] = useState<string | null>(null);
+  const [showPendingState, setShowPendingState] = useState(false);
+  const [reloadKey, setReloadKey] = useState(0);
   const loadRef = useRef<{
     load: (() => Promise<T>) | null;
     promise: Promise<T> | null;
@@ -32,8 +44,26 @@ export function ClientWorkbench<T>({
     load: null,
     promise: null
   });
+  const retry = () => {
+    loadRef.current = {
+      load: null,
+      promise: null
+    };
+    setData(null);
+    setError(null);
+    setShowPendingState(false);
+    setReloadKey(key => key + 1);
+  };
+
   useEffect(() => {
     let cancelled = false;
+    setShowPendingState(false);
+    const pendingDelay = Math.max(0, loadingDelayMs);
+    const pendingTimer = window.setTimeout(() => {
+      if (!cancelled) {
+        setShowPendingState(true);
+      }
+    }, pendingDelay);
     if (loadRef.current.load !== load) {
       loadRef.current = {
         load,
@@ -42,7 +72,7 @@ export function ClientWorkbench<T>({
     }
     if (!loadRef.current.promise) {
       loadRef.current.promise = cacheKey
-        ? consumeWorkbenchLoad(cacheKey, load)
+        ? consumeWorkbenchLoad(cacheKey, load, { settledTtlMs: 
cacheSettledTtlMs })
         : load().finally(() => {
             if (loadRef.current.load === load) {
               loadRef.current.promise = null;
@@ -52,33 +82,55 @@ export function ClientWorkbench<T>({
     loadRef.current.promise
       .then(result => {
         if (!cancelled) {
+          window.clearTimeout(pendingTimer);
           setData(result);
           setError(null);
         }
       })
       .catch(err => {
         if (!cancelled) {
-          const { redirectToLogin, message } = resolveWorkbenchError(err, 
Boolean(getAuthorizationToken()), t);
+          window.clearTimeout(pendingTimer);
+          const { redirectToLogin, message } = resolveWorkbenchError(err, 
false, t);
           if (redirectToLogin) {
-            const returnTo = buildLoginReturnTo(window.location);
-            window.location.href = buildLoginRedirectHref(returnTo, 
process.env.NEXT_PUBLIC_LOGIN_PATH);
-            return;
+            void readClientSessionState().then(session => {
+              if (cancelled || session.authenticated) return;
+              const returnTo = buildLoginReturnTo(window.location);
+              window.location.href = buildLoginRedirectHref(returnTo, 
process.env.NEXT_PUBLIC_LOGIN_PATH);
+            });
           }
           setError(message);
         }
       });
     return () => {
       cancelled = true;
+      window.clearTimeout(pendingTimer);
     };
-  }, [cacheKey, load, t]);
+  }, [cacheKey, cacheSettledTtlMs, load, loadingDelayMs, reloadKey, t]);
 
   if (error) {
+    if (renderError) {
+      return <>{renderError(error, retry)}</>;
+    }
     return <ObservabilityStatusState title={t('common.load-failed')} 
copy={error} tone="danger" />;
   }
 
   if (!data) {
+    if (renderLoading) {
+      return <>{renderLoading(showPendingState)}</>;
+    }
+
+    if (!showPendingState) {
+      return (
+        <section
+          data-client-workbench-loading="deferred"
+          aria-busy="true"
+          className="min-h-[260px]"
+        />
+      );
+    }
+
     const pendingTitle = loadingTitle ?? t('common.workbench.loading.title');
-    const pendingCopy = loadingCopy ?? t('common.loading');
+    const pendingCopy = loadingCopy ?? t('common.workbench.loading.copy');
 
     return (
       <section
diff --git a/web-next/lib/api-client.test.ts b/web-next/lib/api-client.test.ts
new file mode 100644
index 0000000000..37489775da
--- /dev/null
+++ b/web-next/lib/api-client.test.ts
@@ -0,0 +1,137 @@
+import { afterEach, describe, expect, it, vi } from 'vitest';
+
+import { apiDownload, apiGet, apiMessageDelete, apiMessageGet, apiMessagePost, 
apiMessagePut } from './api-client';
+
+const fetchMock = vi.fn<typeof fetch>();
+
+function mockApiMessagePayload(payload: unknown) {
+  fetchMock.mockResolvedValueOnce(
+    new Response(JSON.stringify(payload), {
+      status: 200,
+      headers: { 'Content-Type': 'application/json' }
+    })
+  );
+}
+
+function mockHttpStatus(status: number) {
+  fetchMock.mockResolvedValueOnce(new Response('unavailable', { status }));
+}
+
+describe('api client message helpers', () => {
+  afterEach(() => {
+    fetchMock.mockReset();
+    vi.unstubAllGlobals();
+  });
+
+  it('uses runtime fallback copy when the backend omits a non-zero message', 
async () => {
+    vi.stubGlobal('fetch', fetchMock);
+    mockApiMessagePayload({ code: 500, data: null });
+
+    await expect(apiMessageGet('/bad')).rejects.toThrow('API message returned 
non-zero code');
+  });
+
+  it('preserves backend-provided message text for non-zero payloads', async () 
=> {
+    vi.stubGlobal('fetch', fetchMock);
+    mockApiMessagePayload({ code: 500, msg: 'Backend said no', data: null });
+
+    await expect(apiMessagePost('/bad', {})).rejects.toThrow('Backend said 
no');
+  });
+
+  it('preserves backend-provided message text for write helpers used by 
monitor detect and save', async () => {
+    vi.stubGlobal('fetch', fetchMock);
+    mockApiMessagePayload({ code: 1, msg: 'Detect failed from backend', data: 
null });
+    mockApiMessagePayload({ code: 2, msg: 'Save failed from backend', data: 
null });
+
+    await expect(apiMessagePost('/monitor/detect', { monitor: { app: 'website' 
} })).rejects.toThrow(
+      'Detect failed from backend'
+    );
+    await expect(apiMessagePut('/monitor/1001', { monitor: { app: 'website' } 
})).rejects.toThrow(
+      'Save failed from backend'
+    );
+  });
+
+  it('posts FormData bodies without forcing a JSON content type', async () => {
+    vi.stubGlobal('fetch', fetchMock);
+    mockApiMessagePayload({ code: 0, data: { ok: true } });
+
+    const formData = new FormData();
+    formData.append('name', 'smtp');
+    await expect(apiMessagePost('/plugin', formData)).resolves.toEqual({ ok: 
true });
+
+    expect(fetchMock).toHaveBeenCalledWith(
+      '/api/plugin',
+      expect.objectContaining({
+        method: 'POST',
+        body: formData
+      })
+    );
+    expect(JSON.stringify(fetchMock.mock.calls[0]?.[1]?.headers ?? 
{})).not.toContain('Content-Type');
+  });
+
+  it('attaches backend non-zero codes to thrown message errors', async () => {
+    vi.stubGlobal('fetch', fetchMock);
+    mockApiMessagePayload({ code: 3, msg: 'Monitor disappeared', data: null });
+
+    await expect(apiMessageGet('/monitors/manage')).rejects.toMatchObject({ 
code: 3 });
+  });
+
+  it('shares the non-zero fallback across write helpers', async () => {
+    vi.stubGlobal('fetch', fetchMock);
+    mockApiMessagePayload({ code: 500, data: null });
+    mockApiMessagePayload({ code: 500, data: null });
+
+    await expect(apiMessagePut('/bad', {})).rejects.toThrow('API message 
returned non-zero code');
+    await expect(apiMessageDelete('/bad')).rejects.toThrow('API message 
returned non-zero code');
+  });
+
+  it('uses runtime fallback copy for failed HTTP status responses', async () 
=> {
+    vi.stubGlobal('fetch', fetchMock);
+    mockHttpStatus(503);
+
+    await expect(apiGet('/offline')).rejects.toThrow('API request failed: 
503');
+  });
+
+  it('attaches HTTP status codes to thrown request errors', async () => {
+    vi.stubGlobal('fetch', fetchMock);
+    mockHttpStatus(404);
+
+    await expect(apiGet('/offline')).rejects.toMatchObject({ status: 404 });
+  });
+
+  it('forwards abort signals through message reads so topology timeouts can 
cancel fetch work', async () => {
+    vi.stubGlobal('fetch', fetchMock);
+    mockApiMessagePayload({ code: 0, data: { ok: true } });
+
+    const controller = new AbortController();
+    await expect(apiMessageGet('/topology', { signal: controller.signal 
})).resolves.toEqual({ ok: true });
+
+    expect(fetchMock).toHaveBeenCalledWith(
+      '/api/topology',
+      expect.objectContaining({
+        signal: controller.signal
+      })
+    );
+  });
+
+  it('returns raw download responses so export handlers can inspect headers 
and JSON error bodies', async () => {
+    vi.stubGlobal('fetch', fetchMock);
+    fetchMock.mockResolvedValueOnce(
+      new Response(JSON.stringify({ code: 1, msg: 'export denied' }), {
+        status: 200,
+        headers: {
+          'Content-Disposition': 'attachment; filename=monitors.json',
+          'Content-Type': 'application/json'
+        }
+      })
+    );
+
+    const response = await apiDownload('/monitors/export?ids=42&type=JSON');
+
+    expect(response.headers.get('Content-Disposition')).toBe('attachment; 
filename=monitors.json');
+    expect(response.headers.get('Content-Type')).toContain('application/json');
+    expect(fetchMock).toHaveBeenCalledWith(
+      '/api/monitors/export?ids=42&type=JSON',
+      expect.objectContaining({ credentials: 'same-origin', cache: 'no-store' 
})
+    );
+  });
+});
diff --git a/web-next/lib/api-client.ts b/web-next/lib/api-client.ts
index 121125a329..bd72fb54e8 100644
--- a/web-next/lib/api-client.ts
+++ b/web-next/lib/api-client.ts
@@ -1,11 +1,20 @@
-export function getAuthorizationToken(): string | null {
-  if (typeof window === 'undefined') return null;
-  return window.localStorage.getItem('Authorization');
+import { SUPPLEMENTAL_MESSAGES } from './i18n-runtime-messages';
+import { clearClientSessionMarker } from './session-client';
+
+const API_MESSAGE_NON_ZERO_FALLBACK = 
SUPPLEMENTAL_MESSAGES['en-US']?.['common.api.message-nonzero'] ?? 
'common.api.message-nonzero';
+const API_REQUEST_FAILED_STATUS_FALLBACK = 
SUPPLEMENTAL_MESSAGES['en-US']?.['common.api.request-failed-status'] ?? 
'common.api.request-failed-status';
+
+function formatApiRequestFailedStatus(status: number): string {
+  return API_REQUEST_FAILED_STATUS_FALLBACK.replace('{{status}}', 
String(status));
 }
 
-export function getRefreshToken(): string | null {
-  if (typeof window === 'undefined') return null;
-  return window.localStorage.getItem('refresh-token');
+export type ApiClientError = Error & {
+  code?: number;
+  status?: number;
+};
+
+export function getAuthorizationToken(): string | null {
+  return null;
 }
 
 export function getCurrentLocale(): string | null {
@@ -13,59 +22,37 @@ export function getCurrentLocale(): string | null {
   return window.localStorage.getItem('hb.lang') || 
window.localStorage.getItem('layout.lang');
 }
 
-function setTokens(token?: string | null, refreshToken?: string | null) {
-  if (typeof window === 'undefined') return;
-  if (token) {
-    window.localStorage.setItem('Authorization', token);
-  }
-  if (refreshToken) {
-    window.localStorage.setItem('refresh-token', refreshToken);
-  }
-}
-
-function clearTokens() {
-  if (typeof window === 'undefined') return;
-  window.localStorage.removeItem('Authorization');
-  window.localStorage.removeItem('refresh-token');
-}
-
 function buildHeaders(extra?: Record<string, string>) {
-  const token = getAuthorizationToken();
   const locale = getCurrentLocale();
   return {
-    ...(token ? { Authorization: `Bearer ${token}` } : {}),
     ...(locale ? { 'Accept-Language': locale } : {}),
     ...(extra || {})
   };
 }
 
 async function refreshAuthorizationToken(): Promise<boolean> {
-  const refreshToken = getRefreshToken();
-  if (!refreshToken) return false;
   try {
     const response = await fetch('/api/account/auth/refresh', {
       method: 'POST',
-      headers: { 'Content-Type': 'application/json' },
-      body: JSON.stringify({ token: refreshToken }),
+      credentials: 'same-origin',
       cache: 'no-store'
     });
     if (!response.ok) {
-      clearTokens();
+      clearClientSessionMarker();
       return false;
     }
     const payload = (await response.json()) as {
       code: number;
       msg?: string;
-      data?: { token?: string; refreshToken?: string };
+      data?: { authenticated?: boolean };
     };
-    if (payload.code !== 0 || !payload.data?.token) {
-      clearTokens();
+    if (payload.code !== 0 || !payload.data?.authenticated) {
+      clearClientSessionMarker();
       return false;
     }
-    setTokens(payload.data.token, payload.data.refreshToken ?? refreshToken);
     return true;
   } catch {
-    clearTokens();
+    clearClientSessionMarker();
     return false;
   }
 }
@@ -74,6 +61,7 @@ async function apiFetch(path: string, init: RequestInit = {}, 
retryOn401 = true)
   const response = await fetch(path.startsWith('/api') ? path : `/api${path}`, 
{
     ...init,
     headers: buildHeaders(init.headers as Record<string, string> | undefined),
+    credentials: 'same-origin',
     cache: 'no-store'
   });
 
@@ -85,21 +73,46 @@ async function apiFetch(path: string, init: RequestInit = 
{}, retryOn401 = true)
   }
 
   if (!response.ok) {
-    throw new Error(`API request failed: ${response.status}`);
+    const error = new Error(formatApiRequestFailedStatus(response.status)) as 
ApiClientError;
+    error.status = response.status;
+    throw error;
+  }
+  return response;
+}
+
+export async function apiDownload(path: string, init: RequestInit = {}, 
retryOn401 = true): Promise<Response> {
+  const response = await fetch(path.startsWith('/api') ? path : `/api${path}`, 
{
+    ...init,
+    headers: buildHeaders(init.headers as Record<string, string> | undefined),
+    credentials: 'same-origin',
+    cache: 'no-store'
+  });
+
+  if (response.status === 401 && retryOn401) {
+    const refreshed = await refreshAuthorizationToken();
+    if (refreshed) {
+      return apiDownload(path, init, false);
+    }
   }
+
   return response;
 }
 
-export async function apiGet<T>(path: string): Promise<T> {
-  const response = await apiFetch(path);
+export async function apiGet<T>(path: string, init: RequestInit = {}): 
Promise<T> {
+  const response = await apiFetch(path, init);
   return response.json() as Promise<T>;
 }
 
 export async function apiPost<T>(path: string, body: unknown): Promise<T> {
+  const isFormDataBody = typeof FormData !== 'undefined' && body instanceof 
FormData;
   const response = await apiFetch(path, {
     method: 'POST',
-    headers: { 'Content-Type': 'application/json' },
-    body: JSON.stringify(body),
+    ...(isFormDataBody
+      ? { body }
+      : {
+          headers: { 'Content-Type': 'application/json' },
+          body: JSON.stringify(body),
+        }),
   });
   return response.json() as Promise<T>;
 }
@@ -120,10 +133,12 @@ export async function apiDelete<T>(path: string): 
Promise<T> {
   return response.json() as Promise<T>;
 }
 
-export async function apiMessageGet<T>(path: string): Promise<T> {
-  const payload = await apiGet<{ code: number; msg?: string; data: T }>(path);
+export async function apiMessageGet<T>(path: string, init: RequestInit = {}): 
Promise<T> {
+  const payload = await apiGet<{ code: number; msg?: string; data: T }>(path, 
init);
   if (payload.code !== 0) {
-    throw new Error(payload.msg || 'API message returned non-zero code');
+    const error = new Error(payload.msg || API_MESSAGE_NON_ZERO_FALLBACK) as 
ApiClientError;
+    error.code = payload.code;
+    throw error;
   }
   return payload.data;
 }
@@ -131,7 +146,9 @@ export async function apiMessageGet<T>(path: string): 
Promise<T> {
 export async function apiMessagePost<T>(path: string, body: unknown): 
Promise<T> {
   const payload = await apiPost<{ code: number; msg?: string; data: T }>(path, 
body);
   if (payload.code !== 0) {
-    throw new Error(payload.msg || 'API message returned non-zero code');
+    const error = new Error(payload.msg || API_MESSAGE_NON_ZERO_FALLBACK) as 
ApiClientError;
+    error.code = payload.code;
+    throw error;
   }
   return payload.data;
 }
@@ -139,7 +156,9 @@ export async function apiMessagePost<T>(path: string, body: 
unknown): Promise<T>
 export async function apiMessagePut<T>(path: string, body: unknown): 
Promise<T> {
   const payload = await apiPut<{ code: number; msg?: string; data: T }>(path, 
body);
   if (payload.code !== 0) {
-    throw new Error(payload.msg || 'API message returned non-zero code');
+    const error = new Error(payload.msg || API_MESSAGE_NON_ZERO_FALLBACK) as 
ApiClientError;
+    error.code = payload.code;
+    throw error;
   }
   return payload.data;
 }
@@ -147,7 +166,9 @@ export async function apiMessagePut<T>(path: string, body: 
unknown): Promise<T>
 export async function apiMessageDelete<T>(path: string): Promise<T> {
   const payload = await apiDelete<{ code: number; msg?: string; data: T 
}>(path);
   if (payload.code !== 0) {
-    throw new Error(payload.msg || 'API message returned non-zero code');
+    const error = new Error(payload.msg || API_MESSAGE_NON_ZERO_FALLBACK) as 
ApiClientError;
+    error.code = payload.code;
+    throw error;
   }
   return payload.data;
 }
diff --git a/web-next/lib/i18n-runtime-messages.ts 
b/web-next/lib/i18n-runtime-messages.ts
index 328b432448..0fa9350fda 100644
--- a/web-next/lib/i18n-runtime-messages.ts
+++ b/web-next/lib/i18n-runtime-messages.ts
@@ -11,6 +11,8 @@ export const SUPPLEMENTAL_MESSAGES: 
Partial<Record<LocaleCode, Messages>> = {
   'en-US': {
     'common.active': 'Active',
     'common.annotation.bind': 'Annotations',
+    'common.api.message-nonzero': 'API message returned non-zero code',
+    'common.api.request-failed-status': 'API request failed: {{status}}',
     'common.attention': 'attention',
     'common.app': 'App',
     'common.application': 'application',
@@ -1061,6 +1063,8 @@ export const SUPPLEMENTAL_MESSAGES: 
Partial<Record<LocaleCode, Messages>> = {
   'zh-CN': {
     'common.active': '活跃',
     'common.annotation.bind': '注解',
+    'common.api.message-nonzero': 'API 消息返回非零状态码',
+    'common.api.request-failed-status': 'API 请求失败:{{status}}',
     'common.attention': '关注',
     'common.app': '应用',
     'common.application': 'application',
diff --git a/web-next/lib/query-keys.test.ts b/web-next/lib/query-keys.test.ts
new file mode 100644
index 0000000000..ecfe6278fd
--- /dev/null
+++ b/web-next/lib/query-keys.test.ts
@@ -0,0 +1,40 @@
+import { describe, expect, it } from 'vitest';
+
+import { queryKeys } from './query-keys';
+
+describe('query keys', () => {
+  it('uses stable domain-scoped monitor keys', () => {
+    expect(queryKeys.monitors.list({ pageIndex: 0, pageSize: 8, status: '' 
})).toEqual([
+      'monitors',
+      'list',
+      { pageIndex: 0, pageSize: 8 }
+    ]);
+    expect(queryKeys.monitors.detail(42)).toEqual(['monitors', 'detail', 
'42']);
+  });
+
+  it('preserves topology context without empty values', () => {
+    expect(queryKeys.topology.graph({ entityId: '501', sourceKind: 
'otlp-trace-call', environment: '' })).toEqual([
+      'topology',
+      'graph',
+      { entityId: '501', sourceKind: 'otlp-trace-call' }
+    ]);
+  });
+
+  it('uses a stable overview console key and strips empty refresh dimensions', 
() => {
+    expect(queryKeys.overview.console({ summary: '/summary', alerts: 
'/alerts', refreshNonce: 0, empty: '' })).toEqual([
+      'overview',
+      'console',
+      { summary: '/summary', alerts: '/alerts', refreshNonce: 0 }
+    ]);
+  });
+
+  it('uses stable monitor history keys from the monitor YAML metric catalog', 
() => {
+    expect(queryKeys.monitors.history(501, 'summary.responseTime', { history: 
'1h', interval: false, empty: '' })).toEqual([
+      'monitors',
+      'history',
+      '501',
+      'summary.responseTime',
+      { history: '1h', interval: false }
+    ]);
+  });
+});
diff --git a/web-next/lib/query-keys.ts b/web-next/lib/query-keys.ts
new file mode 100644
index 0000000000..cad58a6f0b
--- /dev/null
+++ b/web-next/lib/query-keys.ts
@@ -0,0 +1,50 @@
+import type { TopologyRouteContext } from './topology-surface/view-model';
+
+type QueryRecord = Record<string, string | number | boolean | null | 
undefined>;
+
+function compactRecord(record: QueryRecord = {}) {
+  return Object.fromEntries(Object.entries(record).filter(([, value]) => value 
!= null && value !== ''));
+}
+
+export const queryKeys = {
+  session: {
+    current: ['session', 'current'] as const
+  },
+  monitors: {
+    all: ['monitors'] as const,
+    list: (query: QueryRecord = {}) => ['monitors', 'list', 
compactRecord(query)] as const,
+    detail: (monitorId: string | number) => ['monitors', 'detail', 
String(monitorId)] as const,
+    history: (monitorId: string | number, metric: string, query: QueryRecord = 
{}) =>
+      ['monitors', 'history', String(monitorId), metric, compactRecord(query)] 
as const
+  },
+  entities: {
+    all: ['entities'] as const,
+    detail: (entityId: string | number) => ['entities', 'detail', 
String(entityId)] as const
+  },
+  metrics: {
+    all: ['metrics'] as const,
+    monitorRealtime: (monitorId: string | number, metricName: string) =>
+      ['metrics', 'monitor-realtime', String(monitorId), metricName] as const
+  },
+  logs: {
+    all: ['logs'] as const,
+    list: (query: QueryRecord = {}) => ['logs', 'list', compactRecord(query)] 
as const
+  },
+  traces: {
+    all: ['traces'] as const,
+    detail: (traceId: string) => ['traces', 'detail', traceId] as const,
+    spans: (traceId: string) => ['traces', 'spans', traceId] as const
+  },
+  topology: {
+    all: ['topology'] as const,
+    graph: (context: TopologyRouteContext = {}) => ['topology', 'graph', 
compactRecord(context as QueryRecord)] as const
+  },
+  overview: {
+    all: ['overview'] as const,
+    console: (query: QueryRecord = {}) => ['overview', 'console', 
compactRecord(query)] as const
+  },
+  alerts: {
+    all: ['alerts'] as const,
+    list: (query: QueryRecord = {}) => ['alerts', 'list', 
compactRecord(query)] as const
+  }
+} as const;
diff --git a/web-next/lib/session-client.test.ts 
b/web-next/lib/session-client.test.ts
new file mode 100644
index 0000000000..12a50a989f
--- /dev/null
+++ b/web-next/lib/session-client.test.ts
@@ -0,0 +1,86 @@
+// @vitest-environment jsdom
+
+import { afterEach, describe, expect, it, vi } from 'vitest';
+import {
+  HB_UI_SESSION_USER_KEY,
+  clearClientSession,
+  clearClientSessionUserSnapshot,
+  readClientSessionUserSnapshot,
+  writeClientSessionUserSnapshot
+} from './session-client';
+
+describe('session client helpers', () => {
+  afterEach(() => {
+    window.sessionStorage.clear();
+    vi.unstubAllGlobals();
+  });
+
+  it('stores and reads the Angular-style post-login user snapshot without 
token material', () => {
+    writeClientSessionUserSnapshot({
+      name: 'ops-admin',
+      avatar: './assets/img/avatar.svg',
+      email: 'administrator',
+      role: 'ADMIN'
+    });
+
+    expect(readClientSessionUserSnapshot()).toEqual({
+      name: 'ops-admin',
+      avatar: './assets/img/avatar.svg',
+      email: 'administrator',
+      role: 'ADMIN'
+    });
+    
expect(window.sessionStorage.getItem(HB_UI_SESSION_USER_KEY)).not.toContain('token');
+    
expect(window.sessionStorage.getItem(HB_UI_SESSION_USER_KEY)).not.toContain('refresh');
+  });
+
+  it('clears stale user snapshots during logout cleanup', async () => {
+    writeClientSessionUserSnapshot({
+      name: 'ops-admin',
+      avatar: './assets/img/avatar.svg',
+      email: 'administrator'
+    });
+    vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response('{}', { 
status: 200 })));
+
+    await clearClientSession();
+
+    expect(readClientSessionUserSnapshot()).toBeNull();
+    expect(global.fetch).toHaveBeenCalledWith('/api/account/session', {
+      method: 'DELETE',
+      credentials: 'same-origin',
+      cache: 'no-store'
+    });
+  });
+
+  it('clears local session markers even when logout cleanup request fails', 
async () => {
+    writeClientSessionUserSnapshot({
+      name: 'ops-admin',
+      avatar: './assets/img/avatar.svg',
+      email: 'administrator'
+    });
+    Object.defineProperty(document, 'cookie', {
+      value: 'hb_ui_session=1',
+      writable: true,
+      configurable: true
+    });
+    vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('offline')));
+
+    await expect(clearClientSession()).rejects.toThrow('offline');
+
+    expect(readClientSessionUserSnapshot()).toBeNull();
+    expect(document.cookie).toContain('hb_ui_session=');
+    expect(global.fetch).toHaveBeenCalledWith('/api/account/session', {
+      method: 'DELETE',
+      credentials: 'same-origin',
+      cache: 'no-store'
+    });
+  });
+
+  it('drops malformed user snapshots instead of crashing the shell', () => {
+    window.sessionStorage.setItem(HB_UI_SESSION_USER_KEY, '{bad-json');
+
+    expect(readClientSessionUserSnapshot()).toBeNull();
+
+    clearClientSessionUserSnapshot();
+    expect(window.sessionStorage.getItem(HB_UI_SESSION_USER_KEY)).toBeNull();
+  });
+});
diff --git a/web-next/lib/session-client.ts b/web-next/lib/session-client.ts
new file mode 100644
index 0000000000..5f5a12b81f
--- /dev/null
+++ b/web-next/lib/session-client.ts
@@ -0,0 +1,96 @@
+export type ClientSessionState = {
+  authenticated: boolean;
+};
+
+export type ClientSessionUserSnapshot = {
+  name: string;
+  avatar: string;
+  email: string;
+  role?: string;
+};
+
+export const HB_UI_SESSION_USER_KEY = 'HB_UI_SESSION_USER';
+
+function readSessionStorage() {
+  if (typeof window === 'undefined') return null;
+  try {
+    return window.sessionStorage;
+  } catch {
+    return null;
+  }
+}
+
+export function readClientSessionUserSnapshot(): ClientSessionUserSnapshot | 
null {
+  const storage = readSessionStorage();
+  if (!storage) return null;
+  try {
+    const value = storage.getItem(HB_UI_SESSION_USER_KEY);
+    if (!value) return null;
+    const parsed = JSON.parse(value) as Partial<ClientSessionUserSnapshot>;
+    if (!parsed.name || typeof parsed.name !== 'string') return null;
+    return {
+      name: parsed.name,
+      avatar: typeof parsed.avatar === 'string' ? parsed.avatar : 
'./assets/img/avatar.svg',
+      email: typeof parsed.email === 'string' ? parsed.email : 'administrator',
+      ...(typeof parsed.role === 'string' && parsed.role ? { role: parsed.role 
} : {})
+    };
+  } catch {
+    return null;
+  }
+}
+
+export function writeClientSessionUserSnapshot(user: 
ClientSessionUserSnapshot) {
+  const storage = readSessionStorage();
+  if (!storage) return;
+  try {
+    storage.setItem(HB_UI_SESSION_USER_KEY, JSON.stringify(user));
+  } catch {
+    // The user snapshot is an Angular parity convenience, not an auth 
boundary.
+  }
+}
+
+export function clearClientSessionUserSnapshot() {
+  const storage = readSessionStorage();
+  if (!storage) return;
+  try {
+    storage.removeItem(HB_UI_SESSION_USER_KEY);
+  } catch {
+    // Ignore storage failures during logout/session cleanup.
+  }
+}
+
+export function clearClientSessionMarker() {
+  if (typeof document === 'undefined') return;
+  document.cookie = 'hb_ui_session=; Max-Age=0; path=/; SameSite=Lax';
+}
+
+export async function readClientSessionState(): Promise<ClientSessionState> {
+  try {
+    const response = await fetch('/api/account/session', {
+      credentials: 'same-origin',
+      cache: 'no-store'
+    });
+    if (!response.ok) {
+      clearClientSessionMarker();
+      return { authenticated: false };
+    }
+    const payload = (await response.json()) as ClientSessionState;
+    return { authenticated: Boolean(payload.authenticated) };
+  } catch {
+    clearClientSessionMarker();
+    return { authenticated: false };
+  }
+}
+
+export async function clearClientSession() {
+  try {
+    await fetch('/api/account/session', {
+      method: 'DELETE',
+      credentials: 'same-origin',
+      cache: 'no-store'
+    });
+  } finally {
+    clearClientSessionMarker();
+    clearClientSessionUserSnapshot();
+  }
+}
diff --git a/web-next/lib/workbench-load-cache.test.ts 
b/web-next/lib/workbench-load-cache.test.ts
index 8e551333e1..0a04667fce 100644
--- a/web-next/lib/workbench-load-cache.test.ts
+++ b/web-next/lib/workbench-load-cache.test.ts
@@ -35,6 +35,37 @@ describe('workbench load cache', () => {
     expect(load).toHaveBeenCalledTimes(2);
   });
 
+  it('can keep a settled result for a short ttl when shared chrome needs the 
same state', async () => {
+    let now = 1_000;
+    const load = vi.fn()
+      .mockResolvedValueOnce('first')
+      .mockResolvedValueOnce('second');
+
+    await expect(
+      consumeWorkbenchLoad('app-frame:header-state:zh-CN', load, {
+        settledTtlMs: 500,
+        now: () => now,
+      })
+    ).resolves.toBe('first');
+    await expect(
+      consumeWorkbenchLoad('app-frame:header-state:zh-CN', load, {
+        settledTtlMs: 500,
+        now: () => now,
+      })
+    ).resolves.toBe('first');
+
+    now = 1_501;
+
+    await expect(
+      consumeWorkbenchLoad('app-frame:header-state:zh-CN', load, {
+        settledTtlMs: 500,
+        now: () => now,
+      })
+    ).resolves.toBe('second');
+
+    expect(load).toHaveBeenCalledTimes(2);
+  });
+
   it('clears the cache after a rejection so the next attempt can retry', async 
() => {
     const load = vi.fn()
       .mockRejectedValueOnce(new Error('boom'))
diff --git a/web-next/lib/workbench-load-cache.ts 
b/web-next/lib/workbench-load-cache.ts
index 4480e94b14..4b6ae1ef6f 100644
--- a/web-next/lib/workbench-load-cache.ts
+++ b/web-next/lib/workbench-load-cache.ts
@@ -1,18 +1,62 @@
-const workbenchLoadCache = new Map<string, Promise<unknown>>();
+type WorkbenchLoadCacheOptions = {
+  settledTtlMs?: number;
+  now?: () => number;
+};
 
-export function consumeWorkbenchLoad<T>(cacheKey: string, load: () => 
Promise<T>) {
-  const existing = workbenchLoadCache.get(cacheKey) as Promise<T> | undefined;
+type WorkbenchLoadCacheEntry<T> = {
+  promise: Promise<T>;
+  settled: boolean;
+  expiresAt: number;
+};
+
+const workbenchLoadCache = new Map<string, WorkbenchLoadCacheEntry<unknown>>();
+
+function resolveNow(options: WorkbenchLoadCacheOptions) {
+  return options.now ? options.now() : Date.now();
+}
+
+export function consumeWorkbenchLoad<T>(
+  cacheKey: string,
+  load: () => Promise<T>,
+  options: WorkbenchLoadCacheOptions = {}
+) {
+  const now = resolveNow(options);
+  const existing = workbenchLoadCache.get(cacheKey) as 
WorkbenchLoadCacheEntry<T> | undefined;
+  if (existing && (!existing.settled || existing.expiresAt > now)) {
+    return existing.promise;
+  }
   if (existing) {
-    return existing;
+    workbenchLoadCache.delete(cacheKey);
   }
 
-  const promise = load().finally(() => {
-    if (workbenchLoadCache.get(cacheKey) === promise) {
-      workbenchLoadCache.delete(cacheKey);
+  const settledTtlMs = Math.max(0, options.settledTtlMs ?? 0);
+  let entry: WorkbenchLoadCacheEntry<T> | null = null;
+  const promise = load().then(
+    value => {
+      if (!entry) return value;
+      entry.settled = true;
+      if (settledTtlMs > 0) {
+        entry.expiresAt = resolveNow(options) + settledTtlMs;
+      } else if (workbenchLoadCache.get(cacheKey) === entry) {
+        workbenchLoadCache.delete(cacheKey);
+      }
+      return value;
+    },
+    error => {
+      if (entry && workbenchLoadCache.get(cacheKey) === entry) {
+        workbenchLoadCache.delete(cacheKey);
+      }
+      throw error;
     }
-  });
+  );
+
+  entry = {
+    promise,
+    settled: false,
+    expiresAt: Number.POSITIVE_INFINITY,
+  };
 
-  workbenchLoadCache.set(cacheKey, promise);
+  workbenchLoadCache.set(cacheKey, entry);
   return promise;
 }
 


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]


Reply via email to