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]
