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 e403551b18751a377b5159696e60c2164274241e Author: Logic <[email protected]> AuthorDate: Fri May 29 00:45:37 2026 +0800 feat(web-next): restore overview dashboard parity --- .../app/overview/{page.tsx => overview-page.tsx} | 139 ++++++- web-next/app/overview/page.test.tsx | 173 ++++++++- web-next/app/overview/page.tsx | 416 +-------------------- .../components/overview/overview-console.test.tsx | 26 +- web-next/components/overview/overview-console.tsx | 35 +- .../overview/overview-detail-dialog.test.tsx | 26 ++ .../components/overview/overview-detail-dialog.tsx | 33 +- web-next/lib/overview/navigation.test.ts | 24 +- web-next/lib/overview/navigation.ts | 9 + web-next/lib/overview/view-model.test.ts | 62 +++ web-next/lib/overview/view-model.ts | 27 +- 11 files changed, 496 insertions(+), 474 deletions(-) diff --git a/web-next/app/overview/page.tsx b/web-next/app/overview/overview-page.tsx similarity index 79% copy from web-next/app/overview/page.tsx copy to web-next/app/overview/overview-page.tsx index c70cc0b294..deb0761bd4 100644 --- a/web-next/app/overview/page.tsx +++ b/web-next/app/overview/overview-page.tsx @@ -2,6 +2,7 @@ import React from 'react'; import Link from 'next/link'; +import { useQueryClient } from '@tanstack/react-query'; import { StageSection, SupportPanel } from '@/components/observability'; import { ClientWorkbench } from '@/components/workbench/client-workbench'; import { useI18n } from '@/components/providers/i18n-provider'; @@ -19,35 +20,131 @@ import { type OverviewImpactedItem, type OverviewSummaryItem } from '@/components/overview/overview-console'; -import { apiMessageGet } from '@/lib/api-client'; +import { api } from '@/lib/api-facade'; import { buildOverviewConsoleViewModel } from '@/lib/overview/view-model'; +import { queryKeys } from '@/lib/query-keys'; import type { DashboardSummary, PageResult, SingleAlert } from '@/lib/types'; import { OverviewDetailDialog } from '../../components/overview/overview-detail-dialog'; import { OverviewProblemFocusDialog } from '../../components/overview/overview-problem-focus-dialog'; import { ThreeSignalDeskShell } from '../../components/pages/three-signal-desk-shell'; import { buildOverviewSignalDeskHref } from '../../lib/overview/navigation'; -type OverviewData = { +export type OverviewData = { summary: DashboardSummary; alerts: PageResult<SingleAlert>; + summaryFailed?: boolean; + alertsFailed?: boolean; }; -function resolveProblemFocusBadgeVariant(severity: string) { - if (severity === 'critical' || severity === 'error') { +export type OverviewRenderDataState = { + renderData: OverviewData; + nextReadyData: OverviewData | null; + retainedReadyData: boolean; +}; + +const OVERVIEW_SUMMARY_URL = '/summary'; +const OVERVIEW_ALERT_LIST_URL = '/alerts?pageIndex=0&pageSize=6&sort=gmtUpdate&order=desc'; +const OVERVIEW_ALERT_LIST_QUERY = { pageIndex: 0, pageSize: 6, sort: 'gmtUpdate', order: 'desc' } as const; +const OVERVIEW_SETTLED_CACHE_TTL_MS = 10_000; +const EMPTY_OVERVIEW_SUMMARY: DashboardSummary = { apps: [] }; +const EMPTY_OVERVIEW_ALERTS: PageResult<SingleAlert> = { + content: [], + totalElements: 0, + pageIndex: OVERVIEW_ALERT_LIST_QUERY.pageIndex, + pageSize: OVERVIEW_ALERT_LIST_QUERY.pageSize +}; + +type OverviewRequestState<T> = { + data: T; + failed: boolean; +}; + +async function loadOverviewRequest<T>(read: () => Promise<T>, fallback: T): Promise<OverviewRequestState<T>> { + try { + return { data: await read(), failed: false }; + } catch { + return { data: fallback, failed: true }; + } +} + +async function loadOverviewConsoleData(): Promise<OverviewData> { + const [summary, alerts] = await Promise.all([ + loadOverviewRequest(() => api.overview.summary(), EMPTY_OVERVIEW_SUMMARY), + loadOverviewRequest(() => api.overview.alerts(OVERVIEW_ALERT_LIST_QUERY), EMPTY_OVERVIEW_ALERTS) + ]); + + return { + summary: summary.data, + alerts: alerts.data, + summaryFailed: summary.failed, + alertsFailed: alerts.failed + }; +} + +export function hasOverviewReadyContext(data: OverviewData): boolean { + return (data.summary.apps || []).length > 0 || (data.alerts.content || []).length > 0; +} + +export function resolveOverviewRenderData(data: OverviewData, previousReadyData: OverviewData | null): OverviewRenderDataState { + if (hasOverviewReadyContext(data)) { + return { + renderData: data, + nextReadyData: data, + retainedReadyData: false + }; + } + + if (previousReadyData) { + return { + renderData: previousReadyData, + nextReadyData: previousReadyData, + retainedReadyData: true + }; + } + + return { + renderData: data, + nextReadyData: null, + retainedReadyData: false + }; +} + +function problemFocusBadgeVariant(severityTone: 'default' | 'success' | 'warning' | 'danger') { + if (severityTone === 'danger') { return 'danger' as const; } - if (severity === 'healthy') { + if (severityTone === 'success') { return 'success' as const; } - return 'accent' as const; + if (severityTone === 'warning') { + return 'accent' as const; + } + return 'default' as const; } export default function OverviewPage() { const { t } = useI18n(); + const queryClient = useQueryClient(); const [refreshNonce, setRefreshNonce] = React.useState(0); const [problemFocusDialogOpen, setProblemFocusDialogOpen] = React.useState(false); const [selectedSummaryCard, setSelectedSummaryCard] = React.useState<OverviewSummaryItem | null>(null); const [selectedImpactedEntity, setSelectedImpactedEntity] = React.useState<OverviewImpactedItem | null>(null); + const lastReadyOverviewDataRef = React.useRef<OverviewData | null>(null); + const overviewCacheKey = React.useMemo( + () => ['overview', OVERVIEW_SUMMARY_URL, OVERVIEW_ALERT_LIST_URL, refreshNonce].join(':'), + [refreshNonce] + ); + const load = React.useCallback(async (): Promise<OverviewData> => { + return queryClient.fetchQuery({ + queryKey: queryKeys.overview.console({ + summary: OVERVIEW_SUMMARY_URL, + alerts: OVERVIEW_ALERT_LIST_URL, + refreshNonce + }), + queryFn: loadOverviewConsoleData, + staleTime: 5000 + }); + }, [queryClient, refreshNonce]); function openProblemFocusDialog() { setSelectedSummaryCard(null); @@ -70,18 +167,17 @@ export default function OverviewPage() { return ( <ClientWorkbench key={refreshNonce} - load={async (): Promise<OverviewData> => { - const [summary, alerts] = await Promise.all([ - apiMessageGet<DashboardSummary>('/summary'), - apiMessageGet<PageResult<SingleAlert>>('/alerts?pageIndex=0&pageSize=6&sort=gmtUpdate&order=desc') - ]); - return { summary, alerts }; - }} + load={load} loadingCopy={t('overview.loading')} + cacheKey={overviewCacheKey} + cacheSettledTtlMs={OVERVIEW_SETTLED_CACHE_TTL_MS} > {data => { - const apps = data.summary.apps || []; - const alerts = data.alerts.content || []; + const { renderData, nextReadyData, retainedReadyData } = resolveOverviewRenderData(data, lastReadyOverviewDataRef.current); + lastReadyOverviewDataRef.current = nextReadyData; + const apps = renderData.summary.apps || []; + const alerts = renderData.alerts.content || []; + const overviewReadPartiallyFailed = Boolean(data.summaryFailed || data.alertsFailed); const viewModel = buildOverviewConsoleViewModel(apps, alerts, t); const topAlert = alerts[0]; const setupRoute = buildOverviewSignalDeskHref('/ingestion/otlp?signal=logs', topAlert); @@ -188,7 +284,11 @@ export default function OverviewPage() { </> } main={ - <div className="grid gap-3"> + <div + className="grid gap-3" + data-overview-request-fallback={overviewReadPartiallyFailed ? 'angular-partial-request-fallback' : undefined} + data-overview-stale-ready-retained={retainedReadyData ? 'angular-has-ready-overview-retains-last-data' : undefined} + > <OverviewStatusGrid title={t('dashboard.home.status.title')} description={t('dashboard.home.status.copy')} @@ -229,7 +329,10 @@ export default function OverviewPage() { chrome="plain" compact actions={ - <Badge variant={resolveProblemFocusBadgeVariant(viewModel.problemFocus.severity)}> + <Badge + variant={problemFocusBadgeVariant(viewModel.problemFocus.severityTone)} + data-overview-problem-focus-severity-tone={viewModel.problemFocus.severityTone} + > {viewModel.problemFocus.severityLabel} </Badge> } @@ -396,7 +499,7 @@ export default function OverviewPage() { title={selectedImpactedEntity ? `${selectedImpactedEntity.name} ${selectedImpactedEntity.type}` : ''} subtitle={t('dashboard.affected.drawer-subtitle')} description={selectedImpactedEntity?.lastIssue || ''} - status={selectedImpactedEntity?.severity} + statusTone={selectedImpactedEntity?.severityTone} statusLabel={selectedImpactedEntity?.severityLabel} sections={ selectedImpactedEntity diff --git a/web-next/app/overview/page.test.tsx b/web-next/app/overview/page.test.tsx index 3454fecb1a..7861ccf4bb 100644 --- a/web-next/app/overview/page.test.tsx +++ b/web-next/app/overview/page.test.tsx @@ -1,3 +1,5 @@ +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; import React from 'react'; import { renderToStaticMarkup } from 'react-dom/server'; import { beforeEach, describe, expect, it, vi } from 'vitest'; @@ -53,6 +55,7 @@ function createOverviewViewModel() { title: 'checkout latency spike', severity: 'critical', severityLabel: 'Critical', + severityTone: 'danger', entity: 'checkout', owner: 'Platform', summary: 'Latency high' @@ -61,7 +64,7 @@ function createOverviewViewModel() { { label: 'Alert Trend', value: '1', insight: 'Check alert pressure.', tone: 'danger' } ], impactedEntities: [ - { name: 'checkout', type: 'service', severity: 'critical', severityLabel: 'Critical', owner: 'Platform', status: 'impacted', statusLabel: 'Impacted', lastIssue: 'Latency high' } + { name: 'checkout', type: 'service', severity: 'critical', severityLabel: 'Critical', severityTone: 'danger', owner: 'Platform', status: 'impacted', statusLabel: 'Impacted', lastIssue: 'Latency high' } ], activityItems: [ { title: 'checkout latency spike', detail: 'Platform · checkout', timestamp: '2026-04-16 22:00:00', tone: 'danger', tag: 'Firing' } @@ -103,7 +106,10 @@ function createOverviewViewModel() { const mockState = vi.hoisted(() => ({ lastLoad: null as null | (() => Promise<unknown>), clientData: createClientData(), - viewModel: createOverviewViewModel() + viewModel: createOverviewViewModel(), + queryClient: { + fetchQuery: vi.fn(async ({ queryFn }: { queryFn: () => Promise<unknown> }) => queryFn()) + } })); const apiMessageGet = vi.fn(); @@ -119,16 +125,28 @@ vi.mock('@/components/providers/i18n-provider', () => ({ })); vi.mock('@/components/workbench/client-workbench', () => ({ - ClientWorkbench: ({ children, load }: { children: (data: any) => React.ReactNode; load: () => Promise<unknown> }) => { + ClientWorkbench: ({ + children, + load, + loadingCopy + }: { + children: (data: any) => React.ReactNode; + load: () => Promise<unknown>; + loadingCopy?: string; + }) => { mockState.lastLoad = load; return ( - <div data-client-workbench="true"> + <div data-client-workbench="true" data-loading-copy={loadingCopy}> {children(mockState.clientData)} </div> ); } })); +vi.mock('@tanstack/react-query', () => ({ + useQueryClient: () => mockState.queryClient +})); + vi.mock('../../components/workbench/primitives', () => ({ WorkbenchPanel: ({ as: Component = 'div', children, ...props }: any) => <Component {...props}>{children}</Component> })); @@ -272,10 +290,27 @@ beforeEach(() => { mockState.lastLoad = null; mockState.clientData = createClientData(); mockState.viewModel = createOverviewViewModel(); + mockState.queryClient.fetchQuery.mockReset(); + mockState.queryClient.fetchQuery.mockImplementation(async ({ queryFn }: { queryFn: () => Promise<unknown> }) => queryFn()); apiMessageGet.mockReset(); }); describe('overview page', () => { + it('keeps overview remounts on a short settled cache window without bypassing refresh keys', () => { + const source = readFileSync(resolve(process.cwd(), 'app/overview/overview-page.tsx'), 'utf8'); + + expect(source).toContain('OVERVIEW_SETTLED_CACHE_TTL_MS = 10_000'); + expect(source).toContain('cacheSettledTtlMs={OVERVIEW_SETTLED_CACHE_TTL_MS}'); + expect(source).toContain('key={refreshNonce}'); + expect(source).toContain('cacheKey={overviewCacheKey}'); + expect(source).toContain('queryKeys.overview.console'); + expect(source).toContain('api.overview.summary()'); + expect(source).toContain('api.overview.alerts(OVERVIEW_ALERT_LIST_QUERY)'); + expect(source).toContain('resolveOverviewRenderData(data, lastReadyOverviewDataRef.current)'); + expect(source).toContain('data-overview-stale-ready-retained'); + expect(source).not.toContain("import { apiMessageGet } from '@/lib/api-client'"); + }); + it('renders the HertzBeat overview shell and keeps the expected data loader contract', async () => { apiMessageGet .mockResolvedValueOnce({ apps: [] }) @@ -287,6 +322,7 @@ describe('overview page', () => { expect(html).toContain('data-workspace-shell="true"'); expect(html).toContain('data-workspace-shell-rail-width="wide"'); + expect(html).toContain('data-loading-copy="Loading overview console"'); expect(html).toContain('checkout latency spike'); expect(html).toContain('Refresh'); expect(html).toContain('Alerts'); @@ -317,6 +353,131 @@ describe('overview page', () => { ['/summary'], ['/alerts?pageIndex=0&pageSize=6&sort=gmtUpdate&order=desc'] ]); + expect(mockState.queryClient.fetchQuery).toHaveBeenCalledWith( + expect.objectContaining({ + queryKey: ['overview', 'console', { + summary: '/summary', + alerts: '/alerts?pageIndex=0&pageSize=6&sort=gmtUpdate&order=desc', + refreshNonce: 0 + }], + staleTime: 5000 + }) + ); + }, 60000); + + it('keeps Angular forkJoin-style summary fallback when alerts still load', async () => { + apiMessageGet + .mockRejectedValueOnce(new Error('summary unavailable')) + .mockResolvedValueOnce({ + content: [ + { + id: 7, + fingerprint: 'alert-7', + content: 'collector timeout', + status: 'firing', + labels: { severity: 'warning', service: 'collector' } + } + ], + totalElements: 1, + pageIndex: 0, + pageSize: 6 + }); + + const { default: OverviewPage } = await import('./page'); + renderToStaticMarkup(<OverviewPage />); + + await expect(mockState.lastLoad?.()).resolves.toMatchObject({ + summary: { apps: [] }, + alerts: { content: [{ fingerprint: 'alert-7' }] }, + summaryFailed: true, + alertsFailed: false + }); + expect(apiMessageGet.mock.calls).toEqual([ + ['/summary'], + ['/alerts?pageIndex=0&pageSize=6&sort=gmtUpdate&order=desc'] + ]); + }, 60000); + + it('keeps Angular forkJoin-style alert fallback when summary still loads', async () => { + apiMessageGet + .mockResolvedValueOnce({ + apps: [ + { app: 'api', category: 'service', size: 2, availableSize: 2, unAvailableSize: 0, unManageSize: 0 } + ] + }) + .mockRejectedValueOnce(new Error('alerts unavailable')); + + const { default: OverviewPage } = await import('./page'); + renderToStaticMarkup(<OverviewPage />); + + await expect(mockState.lastLoad?.()).resolves.toMatchObject({ + summary: { apps: [{ app: 'api' }] }, + alerts: { content: [], totalElements: 0, pageIndex: 0, pageSize: 6 }, + summaryFailed: false, + alertsFailed: true + }); + }, 60000); + + it('marks partial overview read fallback without changing the route shell', async () => { + mockState.clientData = { + ...createClientData(), + summaryFailed: true + }; + + const { default: OverviewPage } = await import('./page'); + const html = renderToStaticMarkup(<OverviewPage />); + + expect(html).toContain('data-overview-request-fallback="angular-partial-request-fallback"'); + expect(html).toContain('data-workspace-shell="true"'); + }, 60000); + + it('retains the previous ready overview data when a later refresh returns empty fallback data', async () => { + const { hasOverviewReadyContext, resolveOverviewRenderData } = await import('./overview-page'); + const readyData = { + summary: { + apps: [ + { app: 'checkout', category: 'service', size: 4, availableSize: 3, unAvailableSize: 1, unManageSize: 0 } + ] + }, + alerts: { + content: [ + { + id: 7, + fingerprint: 'alert-7', + content: 'collector timeout', + status: 'firing', + labels: { severity: 'warning', service: 'collector' } + } + ], + totalElements: 1, + pageIndex: 0, + pageSize: 6 + } + }; + const emptyFallbackData = { + summary: { apps: [] }, + alerts: { content: [], totalElements: 0, pageIndex: 0, pageSize: 6 }, + summaryFailed: true, + alertsFailed: true + }; + + expect(hasOverviewReadyContext(readyData)).toBe(true); + expect(hasOverviewReadyContext(emptyFallbackData)).toBe(false); + expect(resolveOverviewRenderData(readyData, null)).toEqual({ + renderData: readyData, + nextReadyData: readyData, + retainedReadyData: false + }); + expect(resolveOverviewRenderData(emptyFallbackData, readyData)).toEqual({ + renderData: readyData, + nextReadyData: readyData, + retainedReadyData: true + }); + expect(resolveOverviewRenderData(emptyFallbackData, null)).toEqual({ + renderData: emptyFallbackData, + nextReadyData: null, + retainedReadyData: false + }); }, 60000); it('keeps the populated overview rail focused on next-step guidance instead of rendering the duplicated workbench recap panel', async () => { @@ -410,6 +571,7 @@ describe('overview page', () => { type: 'service', severity: 'healthy', severityLabel: 'Healthy', + severityTone: 'success', owner: 'Platform', status: 'healthy', statusLabel: 'Stable', @@ -433,6 +595,7 @@ describe('overview page', () => { title: 'No issue needs attention right now', severity: 'healthy', severityLabel: 'Healthy', + severityTone: 'success', entity: 'Overall environment', owner: 'Platform on call', summary: 'There is no issue requiring immediate escalation right now. Start with the trend and affected items.' @@ -468,6 +631,7 @@ describe('overview page', () => { title: 'No issue needs attention right now', severity: 'healthy', severityLabel: 'Healthy', + severityTone: 'success', entity: 'Overall environment', owner: 'Platform on call', summary: 'There is no issue requiring immediate escalation right now. Start with the trend and affected items.' @@ -483,6 +647,7 @@ describe('overview page', () => { type: 'service', severity: 'warning', severityLabel: 'Warning', + severityTone: 'warning', owner: 'Platform', status: 'impacted', statusLabel: 'Impacted', diff --git a/web-next/app/overview/page.tsx b/web-next/app/overview/page.tsx index c70cc0b294..5fe10f88d6 100644 --- a/web-next/app/overview/page.tsx +++ b/web-next/app/overview/page.tsx @@ -1,417 +1,7 @@ -'use client'; - import React from 'react'; -import Link from 'next/link'; -import { StageSection, SupportPanel } from '@/components/observability'; -import { ClientWorkbench } from '@/components/workbench/client-workbench'; -import { useI18n } from '@/components/providers/i18n-provider'; -import { Badge } from '@/components/ui/badge'; -import { Button, buttonVariants } from '@/components/ui/button'; -import { - OverviewActivityTimeline, - OverviewChecklist, - OverviewGuidancePanel, - OverviewImpactedList, - OverviewQuickEntryGrid, - OverviewSectionAction, - OverviewStatusGrid, - OverviewSummaryGrid, - type OverviewImpactedItem, - type OverviewSummaryItem -} from '@/components/overview/overview-console'; -import { apiMessageGet } from '@/lib/api-client'; -import { buildOverviewConsoleViewModel } from '@/lib/overview/view-model'; -import type { DashboardSummary, PageResult, SingleAlert } from '@/lib/types'; -import { OverviewDetailDialog } from '../../components/overview/overview-detail-dialog'; -import { OverviewProblemFocusDialog } from '../../components/overview/overview-problem-focus-dialog'; -import { ThreeSignalDeskShell } from '../../components/pages/three-signal-desk-shell'; -import { buildOverviewSignalDeskHref } from '../../lib/overview/navigation'; - -type OverviewData = { - summary: DashboardSummary; - alerts: PageResult<SingleAlert>; -}; - -function resolveProblemFocusBadgeVariant(severity: string) { - if (severity === 'critical' || severity === 'error') { - return 'danger' as const; - } - if (severity === 'healthy') { - return 'success' as const; - } - return 'accent' as const; -} - -export default function OverviewPage() { - const { t } = useI18n(); - const [refreshNonce, setRefreshNonce] = React.useState(0); - const [problemFocusDialogOpen, setProblemFocusDialogOpen] = React.useState(false); - const [selectedSummaryCard, setSelectedSummaryCard] = React.useState<OverviewSummaryItem | null>(null); - const [selectedImpactedEntity, setSelectedImpactedEntity] = React.useState<OverviewImpactedItem | null>(null); - - function openProblemFocusDialog() { - setSelectedSummaryCard(null); - setSelectedImpactedEntity(null); - setProblemFocusDialogOpen(true); - } - - function openSummaryCardDialog(card: OverviewSummaryItem) { - setProblemFocusDialogOpen(false); - setSelectedImpactedEntity(null); - setSelectedSummaryCard(card); - } - - function openImpactedEntityDialog(entity: OverviewImpactedItem) { - setProblemFocusDialogOpen(false); - setSelectedSummaryCard(null); - setSelectedImpactedEntity(entity); - } - - return ( - <ClientWorkbench - key={refreshNonce} - load={async (): Promise<OverviewData> => { - const [summary, alerts] = await Promise.all([ - apiMessageGet<DashboardSummary>('/summary'), - apiMessageGet<PageResult<SingleAlert>>('/alerts?pageIndex=0&pageSize=6&sort=gmtUpdate&order=desc') - ]); - return { summary, alerts }; - }} - loadingCopy={t('overview.loading')} - > - {data => { - const apps = data.summary.apps || []; - const alerts = data.alerts.content || []; - const viewModel = buildOverviewConsoleViewModel(apps, alerts, t); - const topAlert = alerts[0]; - const setupRoute = buildOverviewSignalDeskHref('/ingestion/otlp?signal=logs', topAlert); - const statusActionHref = '/entities'; - const statusActionLabel = t('dashboard.home.status.action.entities'); - const quickEntryItems = viewModel.quickEntryItems.map(item => ({ - ...item, - route: buildOverviewSignalDeskHref(item.route, topAlert) - })); - const guidanceNextLinks = viewModel.guidanceNextLinks.map(item => ({ - ...item, - route: buildOverviewSignalDeskHref(item.route, topAlert) - })); - const guidanceLinkLabels = new Set(guidanceNextLinks.map(item => item.label)); - const condensedQuickEntryItems = quickEntryItems.filter(item => !guidanceLinkLabels.has(item.label)); - const actionableImpactedEntities = viewModel.impactedEntities.filter( - item => item.status !== 'healthy' || item.severity !== 'healthy' - ); - const healthyOverviewPlaceholder = !viewModel.showSetupGuide && viewModel.problemFocus.severity === 'healthy'; - const statusItems = healthyOverviewPlaceholder - ? [ - { - key: 'workspace' as const, - label: t('dashboard.home.status.workspace'), - value: t('dashboard.home.status.ready'), - ready: true - }, - { - key: 'ingestion' as const, - label: t('dashboard.home.status.ingestion'), - value: t('dashboard.home.status.pending'), - ready: false - }, - { - key: 'entities' as const, - label: t('dashboard.home.status.entities'), - value: t('dashboard.home.status.pending'), - ready: false - }, - { - key: 'alerts' as const, - label: t('dashboard.home.status.alerts'), - value: t('dashboard.home.status.pending'), - ready: false - } - ] - : viewModel.workspaceStatusItems; - const railChecklistItems = healthyOverviewPlaceholder - ? viewModel.checklistItems.map(item => ({ - ...item, - ready: false - })) - : viewModel.checklistItems; - const setupLikeGuidance = viewModel.showSetupGuide || healthyOverviewPlaceholder; - const railGuidanceHeadline = setupLikeGuidance - ? t('dashboard.guidance.setup.headline') - : viewModel.guidanceHeadline; - const railGuidanceDescription = setupLikeGuidance - ? t('dashboard.guidance.setup.description') - : viewModel.guidanceDescription; - const railGuidanceReasons = setupLikeGuidance - ? [ - { label: t('dashboard.setup.status.logs'), value: t('dashboard.setup.status.pending') }, - { label: t('dashboard.setup.status.traces'), value: t('dashboard.setup.status.pending') }, - { label: t('dashboard.setup.status.metrics'), value: t('dashboard.setup.status.pending') } - ] - : viewModel.guidanceReasons; - const railGuidanceNextLinks = setupLikeGuidance - ? [] - : guidanceNextLinks.map(item => ({ - label: item.label, - description: item.description, - href: item.route - })); - - return ( - <> - <ThreeSignalDeskShell - kicker={t('dashboard.darkops.kicker')} - title={t('dashboard.darkops.title')} - subtitle={t('dashboard.darkops.subtitle')} - showRail - showFooter - railWidth="wide" - actions={ - <> - <Button - size="sm" - variant="default" - onClick={() => { - setProblemFocusDialogOpen(false); - setSelectedSummaryCard(null); - setSelectedImpactedEntity(null); - setRefreshNonce(current => current + 1); - }} - > - {t('dashboard.darkops.action.refresh')} - </Button> - {!viewModel.showSetupGuide && !healthyOverviewPlaceholder ? ( - <Link href="/alerts" className={buttonVariants({ variant: 'primary', size: 'sm' })}> - {t('dashboard.darkops.action.review-alerts')} - </Link> - ) : null} - </> - } - main={ - <div className="grid gap-3"> - <OverviewStatusGrid - title={t('dashboard.home.status.title')} - description={t('dashboard.home.status.copy')} - items={statusItems} - density={healthyOverviewPlaceholder ? 'compact' : 'default'} - action={ - !viewModel.showSetupGuide && !healthyOverviewPlaceholder ? ( - <OverviewSectionAction href={statusActionHref} label={statusActionLabel} /> - ) : undefined - } - /> - - {viewModel.showSetupGuide ? ( - <SupportPanel title={t('dashboard.empty.title')} subtitle={t('dashboard.empty.copy')} chrome="plain" expanded> - <div className="text-[11px] font-semibold uppercase tracking-[0.08em] text-[var(--ops-text-tertiary)]"> - {t('dashboard.empty.title')} - </div> - </SupportPanel> - ) : ( - <> - {healthyOverviewPlaceholder ? ( - <SupportPanel - title={t('dashboard.empty.title')} - subtitle={t('dashboard.empty.copy')} - chrome="default" - tone="default" - expanded - > - <div className="min-h-[48px]" /> - </SupportPanel> - ) : null} - - {!healthyOverviewPlaceholder ? ( - <> - <StageSection - title={viewModel.problemFocus.title} - description={viewModel.problemFocus.summary} - chrome="plain" - compact - actions={ - <Badge variant={resolveProblemFocusBadgeVariant(viewModel.problemFocus.severity)}> - {viewModel.problemFocus.severityLabel} - </Badge> - } - > - <div className="text-[11px] font-semibold uppercase tracking-[0.08em] text-[var(--ops-text-tertiary)]"> - {t('dashboard.problem-focus.kicker')} - </div> - - <div className="mt-3 flex flex-wrap gap-4"> - <div className="grid gap-1 border-r border-[var(--ops-border-color)] pr-4"> - <div className="text-[11px] font-semibold uppercase tracking-[0.06em] text-[var(--ops-text-tertiary)]"> - {t('dashboard.problem-focus.entity')} - </div> - <div className="text-[13px] font-semibold text-[var(--ops-text-primary)]"> - {viewModel.problemFocus.entity} - </div> - </div> - <div className="grid gap-1"> - <div className="text-[11px] font-semibold uppercase tracking-[0.06em] text-[var(--ops-text-tertiary)]"> - {t('dashboard.problem-focus.owner')} - </div> - <div className="text-[13px] font-semibold text-[var(--ops-text-primary)]"> - {viewModel.problemFocus.owner} - </div> - </div> - </div> - - <div className="mt-3 flex flex-wrap gap-2"> - <OverviewSectionAction - onClick={openProblemFocusDialog} - label={t('dashboard.problem-focus.open-context')} - variant="primary" - /> - <OverviewSectionAction - href="/alerts" - label={t('dashboard.problem-focus.review-alerts')} - /> - </div> - </StageSection> - - <OverviewSummaryGrid - items={viewModel.summaryCards} - onSelect={openSummaryCardDialog} - /> - </> - ) : null} - - {!healthyOverviewPlaceholder ? ( - <OverviewQuickEntryGrid - kicker={t('dashboard.quick-entry.kicker')} - title={t('dashboard.quick-entry.title')} - items={condensedQuickEntryItems} - /> - ) : null} - {!healthyOverviewPlaceholder && actionableImpactedEntities.length > 0 ? ( - <OverviewImpactedList - kicker={t('dashboard.affected.kicker')} - title={t('dashboard.affected.title')} - action={ - <OverviewSectionAction - href="/entities" - label={t('dashboard.affected.browse-all')} - /> - } - items={actionableImpactedEntities} - onOpenItem={openImpactedEntityDialog} - /> - ) : null} - </> - )} - </div> - } - rail={ - <div className="grid gap-3"> - <OverviewGuidancePanel - headline={railGuidanceHeadline} - description={railGuidanceDescription} - density={healthyOverviewPlaceholder ? 'compact' : 'default'} - compactReasons={healthyOverviewPlaceholder} - reasonDensity={healthyOverviewPlaceholder ? 'compact' : 'default'} - primaryAction={ - setupLikeGuidance ? ( - <OverviewSectionAction - href={setupRoute} - label={t('dashboard.guidance.setup.action')} - variant="primary" - /> - ) : ( - <OverviewSectionAction - onClick={openProblemFocusDialog} - label={t('dashboard.problem-focus.open-context')} - variant="primary" - /> - ) - } - secondaryAction={ - !setupLikeGuidance ? ( - <OverviewSectionAction - href="/alerts" - label={t('dashboard.problem-focus.review-alerts')} - /> - ) : undefined - } - reasons={railGuidanceReasons} - nextLinks={railGuidanceNextLinks} - startLabel={t('workspace.guidance.start')} - reasonsLabel={t('workspace.guidance.reasons')} - nextLabel={t('workspace.guidance.next')} - /> +import OverviewPage from './overview-page'; - <OverviewChecklist - title={t('dashboard.setup.checklist.title')} - items={railChecklistItems} - density={healthyOverviewPlaceholder ? 'compact' : 'default'} - /> - </div> - } - footer={ - <OverviewActivityTimeline - title={t('dashboard.activity.title')} - items={viewModel.activityItems} - emptyText={t('dashboard.activity.empty')} - /> - } - /> - {!viewModel.showSetupGuide ? ( - <OverviewProblemFocusDialog - open={problemFocusDialogOpen} - onClose={() => setProblemFocusDialogOpen(false)} - kicker={t('dashboard.problem-focus.kicker')} - title={viewModel.problemFocus.title} - summary={viewModel.problemFocus.summary} - subtitle={`${viewModel.problemFocus.entity} · ${viewModel.problemFocus.severityLabel}`} - ownerLabel={t('dashboard.problem-focus.owner')} - owner={viewModel.problemFocus.owner} - entityLabel={t('dashboard.problem-focus.entity')} - entity={viewModel.problemFocus.entity} - closeLabel={t('common.button.cancel')} - /> - ) : null} - {!viewModel.showSetupGuide ? ( - <OverviewDetailDialog - open={selectedSummaryCard !== null} - onClose={() => setSelectedSummaryCard(null)} - title={selectedSummaryCard?.label || ''} - subtitle={t('dashboard.summary.drawer-subtitle')} - description={selectedSummaryCard?.hint || ''} - sections={ - selectedSummaryCard - ? [ - { label: t('dashboard.summary.value'), value: selectedSummaryCard.value }, - { label: t('dashboard.summary.delta'), value: selectedSummaryCard.delta } - ] - : [] - } - closeLabel={t('common.button.cancel')} - /> - ) : null} - {!viewModel.showSetupGuide ? ( - <OverviewDetailDialog - open={selectedImpactedEntity !== null} - onClose={() => setSelectedImpactedEntity(null)} - title={selectedImpactedEntity ? `${selectedImpactedEntity.name} ${selectedImpactedEntity.type}` : ''} - subtitle={t('dashboard.affected.drawer-subtitle')} - description={selectedImpactedEntity?.lastIssue || ''} - status={selectedImpactedEntity?.severity} - statusLabel={selectedImpactedEntity?.severityLabel} - sections={ - selectedImpactedEntity - ? [ - { label: t('dashboard.problem-focus.owner'), value: selectedImpactedEntity.owner }, - { label: t('dashboard.affected.status-label'), value: selectedImpactedEntity.statusLabel } - ] - : [] - } - closeLabel={t('common.button.cancel')} - /> - ) : null} - </> - ); - }} - </ClientWorkbench> - ); +export default function OverviewRoutePage() { + return <OverviewPage />; } diff --git a/web-next/components/overview/overview-console.test.tsx b/web-next/components/overview/overview-console.test.tsx index 87d1634b0c..7d2e430616 100644 --- a/web-next/components/overview/overview-console.test.tsx +++ b/web-next/components/overview/overview-console.test.tsx @@ -60,6 +60,18 @@ describe('overview console primitives', () => { /> ); + const customGuidanceHtml = renderToStaticMarkup( + <OverviewGuidancePanel + headline="Next: preserve caller-owned labels" + description="Custom labels are still passed through by callers that own this workflow language." + reasons={[{ label: 'Source', value: 'Caller' }]} + nextLinks={[{ label: 'Review', href: '/overview' }]} + startLabel="Custom start" + reasonsLabel="Custom reasons" + nextLabel="Custom next" + /> + ); + expect(summaryHtml).toContain('data-overview-summary-grid="true"'); expect(summaryHtml).toContain('data-overview-summary-item="true"'); expect(summaryHtml).toContain('data-overview-summary-item-chrome="flat"'); @@ -67,8 +79,11 @@ describe('overview console primitives', () => { expect(summaryHtml).toContain('Critical pressure is still active'); expect(summaryHtml).not.toContain('rounded-[10px] border border-[var(--ops-border-color)]'); expect(guidanceHtml).toContain('data-overview-guidance="true"'); + expect(guidanceHtml).toContain('>Next<'); expect(guidanceHtml).toContain('Next: work the most important issue first'); + expect(guidanceHtml).toContain('>Reasons<'); expect(guidanceHtml).toContain('Entities in scope'); + expect(guidanceHtml).toContain('>After that<'); expect(guidanceHtml).toContain('href="/log/manage"'); expect(compactGuidanceHtml).toContain('data-overview-guidance-reasons-layout="pill-row"'); expect(compactGuidanceHtml).toContain('data-overview-guidance-reasons-density="compact"'); @@ -96,6 +111,11 @@ describe('overview console primitives', () => { expect(compactGuidanceHtml).toContain('text-[10px] leading-[1.25]'); expect(compactGuidanceHtml).toContain('Logs'); expect(compactGuidanceHtml).toContain('Pending'); + expect(customGuidanceHtml).toContain('Custom start'); + expect(customGuidanceHtml).toContain('Custom reasons'); + expect(customGuidanceHtml).toContain('Custom next'); + expect(customGuidanceHtml).not.toContain('>Reasons<'); + expect(customGuidanceHtml).not.toContain('>After that<'); }); it('switches summary cards and impacted rows to button-backed actions when overview needs drawer-first posture', () => { @@ -125,6 +145,7 @@ describe('overview console primitives', () => { type: 'service', severity: 'critical', severityLabel: 'Critical', + severityTone: 'danger', owner: 'Platform', statusLabel: 'Impacted', lastIssue: 'Latency high' @@ -140,6 +161,7 @@ describe('overview console primitives', () => { expect(impactedHtml).toContain('<button'); expect(impactedHtml).toContain('type="button"'); expect(impactedHtml).toContain('checkout'); + expect(impactedHtml).toContain('data-overview-impacted-severity-tone="danger"'); expect(impactedHtml).not.toContain('href="/entities?app=checkout"'); }); @@ -168,7 +190,7 @@ describe('overview console primitives', () => { <OverviewChecklist title="Next Steps" items={[ - { key: 'logs', label: 'Review logs', ready: false }, + { key: 'logs', label: 'Review logs', ready: true }, { key: 'traces', label: 'Review traces', ready: false }, { key: 'metrics', label: 'Review metrics', ready: false }, { key: 'alerts', label: 'Create an alert', ready: false } @@ -184,6 +206,8 @@ describe('overview console primitives', () => { expect(checklistHtml).toContain('gap-0 py-1'); expect(checklistHtml).toContain('text-[10px] leading-[1.25]'); expect(checklistHtml).toContain('text-[9px] leading-[1.2]'); + expect(checklistHtml).toContain('>Ready<'); + expect(checklistHtml).toContain('>Pending<'); expect(checklistHtml).not.toContain('gap-1.5'); expect(checklistHtml).not.toContain('py-1.5'); }); diff --git a/web-next/components/overview/overview-console.tsx b/web-next/components/overview/overview-console.tsx index 3654276c0b..e055bf912b 100644 --- a/web-next/components/overview/overview-console.tsx +++ b/web-next/components/overview/overview-console.tsx @@ -2,6 +2,7 @@ import Link from 'next/link'; import * as React from 'react'; +import { SUPPLEMENTAL_MESSAGES } from '../../lib/i18n-runtime-messages'; import { WorkbenchPanel } from '../../components/workbench/primitives'; import { Badge } from '../ui/badge'; import { buttonVariants } from '../ui/button'; @@ -36,6 +37,7 @@ export type OverviewImpactedItem = { type: string; severity: string; severityLabel: string; + severityTone: Tone; owner: string; statusLabel: string; lastIssue: string; @@ -111,6 +113,12 @@ function timelineDotClass(tone: Tone) { } } +const DEFAULT_OVERVIEW_GUIDANCE_START_LABEL = SUPPLEMENTAL_MESSAGES['en-US']?.['overview.guidance.default.start'] ?? 'overview.guidance.default.start'; +const DEFAULT_OVERVIEW_GUIDANCE_REASONS_LABEL = SUPPLEMENTAL_MESSAGES['en-US']?.['overview.guidance.default.reasons'] ?? 'overview.guidance.default.reasons'; +const DEFAULT_OVERVIEW_GUIDANCE_NEXT_LABEL = SUPPLEMENTAL_MESSAGES['en-US']?.['overview.guidance.default.next'] ?? 'overview.guidance.default.next'; +const OVERVIEW_CHECKLIST_READY_LABEL = SUPPLEMENTAL_MESSAGES['en-US']?.['overview.checklist.status.ready'] ?? 'overview.checklist.status.ready'; +const OVERVIEW_CHECKLIST_PENDING_LABEL = SUPPLEMENTAL_MESSAGES['en-US']?.['overview.checklist.status.pending'] ?? 'overview.checklist.status.pending'; + export function OverviewStatusGrid({ title, description, @@ -271,7 +279,10 @@ export function OverviewImpactedList({ <span className="text-[12px] text-[var(--ops-text-secondary)]">{item.type} · {item.owner}</span> </span> <span className="grid justify-items-end gap-1"> - <span className={cn('text-[12px] font-semibold uppercase', toneTextClass(resolveSeverityTone(item.severity)))}> + <span + className={cn('text-[12px] font-semibold uppercase', toneTextClass(item.severityTone))} + data-overview-impacted-severity-tone={item.severityTone} + > {item.severityLabel} </span> <span className="text-[12px] text-[var(--ops-text-secondary)]">{item.statusLabel}</span> @@ -322,9 +333,9 @@ export function OverviewGuidancePanel({ density = 'default', compactReasons = false, reasonDensity = 'default', - startLabel = 'Next', - reasonsLabel = 'Reasons', - nextLabel = 'After that' + startLabel = DEFAULT_OVERVIEW_GUIDANCE_START_LABEL, + reasonsLabel = DEFAULT_OVERVIEW_GUIDANCE_REASONS_LABEL, + nextLabel = DEFAULT_OVERVIEW_GUIDANCE_NEXT_LABEL }: { headline: string; description: string; @@ -531,7 +542,7 @@ export function OverviewChecklist({ density === 'compact' ? 'text-[9px] leading-[1.2]' : 'text-[11px] leading-[1.45]' )} > - {item.ready ? 'Ready' : 'Pending'} + {item.ready ? OVERVIEW_CHECKLIST_READY_LABEL : OVERVIEW_CHECKLIST_PENDING_LABEL} </div> </div> ))} @@ -666,17 +677,3 @@ export function OverviewSectionAction({ </Link> ); } - -function resolveSeverityTone(severity: string): Tone { - switch (severity.toLowerCase()) { - case 'critical': - case 'error': - return 'danger'; - case 'warning': - return 'warning'; - case 'healthy': - return 'success'; - default: - return 'default'; - } -} diff --git a/web-next/components/overview/overview-detail-dialog.test.tsx b/web-next/components/overview/overview-detail-dialog.test.tsx new file mode 100644 index 0000000000..3347d4bbcf --- /dev/null +++ b/web-next/components/overview/overview-detail-dialog.test.tsx @@ -0,0 +1,26 @@ +import * as React from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { describe, expect, it } from 'vitest'; +import { OverviewDetailDialog } from './overview-detail-dialog'; + +describe('OverviewDetailDialog', () => { + it('renders the status badge tone supplied by the caller', () => { + const html = renderToStaticMarkup( + <OverviewDetailDialog + open + onClose={() => undefined} + title="checkout-service service" + subtitle="Affected item" + description="Latency threshold breached" + statusTone="danger" + statusLabel="Critical" + sections={[{ label: 'Owner', value: 'Platform' }]} + closeLabel="Close" + /> + ); + + expect(html).toContain('data-overview-detail-dialog="true"'); + expect(html).toContain('data-overview-detail-status-tone="danger"'); + expect(html).toContain('Critical'); + }); +}); diff --git a/web-next/components/overview/overview-detail-dialog.tsx b/web-next/components/overview/overview-detail-dialog.tsx index c2c2ae5447..bb9049dc19 100644 --- a/web-next/components/overview/overview-detail-dialog.tsx +++ b/web-next/components/overview/overview-detail-dialog.tsx @@ -10,18 +10,19 @@ type OverviewDetailSection = { value: string; }; -function resolveBadgeVariant(severity?: string) { - switch (`${severity || ''}`.toLowerCase()) { - case 'critical': - case 'error': - return 'danger' as const; - case 'healthy': - return 'success' as const; - case 'warning': - return 'accent' as const; - default: - return 'default' as const; +type OverviewDetailBadgeTone = 'default' | 'success' | 'warning' | 'danger'; + +function overviewDetailBadgeVariant(tone: OverviewDetailBadgeTone) { + if (tone === 'danger') { + return 'danger' as const; + } + if (tone === 'success') { + return 'success' as const; + } + if (tone === 'warning') { + return 'accent' as const; } + return 'default' as const; } export function OverviewDetailDialog({ @@ -31,7 +32,7 @@ export function OverviewDetailDialog({ title, subtitle, description, - status, + statusTone = 'default', statusLabel, sections, closeLabel @@ -42,7 +43,7 @@ export function OverviewDetailDialog({ title: string; subtitle: string; description: string; - status?: string; + statusTone?: OverviewDetailBadgeTone; statusLabel?: string; sections: OverviewDetailSection[]; closeLabel: string; @@ -68,7 +69,11 @@ export function OverviewDetailDialog({ <div className="text-[12px] font-semibold uppercase tracking-[0.08em] text-[var(--ops-text-tertiary)]">{subtitle}</div> <div className="text-[13px] leading-[1.6] text-[var(--ops-text-secondary)]">{description}</div> </div> - {statusLabel ? <Badge variant={resolveBadgeVariant(status)}>{statusLabel}</Badge> : null} + {statusLabel ? ( + <Badge variant={overviewDetailBadgeVariant(statusTone)} data-overview-detail-status-tone={statusTone}> + {statusLabel} + </Badge> + ) : null} </div> <div className="grid gap-3 sm:grid-cols-2"> {sections.map(section => ( diff --git a/web-next/lib/overview/navigation.test.ts b/web-next/lib/overview/navigation.test.ts index 355d041148..69fe58a81c 100644 --- a/web-next/lib/overview/navigation.test.ts +++ b/web-next/lib/overview/navigation.test.ts @@ -1,7 +1,7 @@ import { readFileSync } from 'node:fs'; import { resolve } from 'node:path'; import { describe, expect, it } from 'vitest'; -import { buildOverviewSignalDeskHref } from './navigation'; +import { buildOverviewCompatRouteUrl, buildOverviewSignalDeskHref } from './navigation'; const topAlert = { labels: { @@ -14,9 +14,29 @@ const topAlert = { } as const; describe('overview navigation', () => { + it('builds overview compatibility redirects with normalized machine query context', () => { + const target = buildOverviewCompatRouteUrl({ + source: 'root', + serviceName: 'checkout', + returnTo: '/entities?returnLabel=Catalog', + returnLabel: 'Overview', + start: '1700000000000', + environment: ['prod', 'ignored'] + }); + const url = new URL(target, 'http://127.0.0.1'); + + expect(url.pathname).toBe('/overview'); + expect(url.searchParams.get('source')).toBe('root'); + expect(url.searchParams.get('serviceName')).toBe('checkout'); + expect(url.searchParams.get('environment')).toBe('prod'); + expect(url.searchParams.get('returnTo')).toBe('/entities'); + expect(url.searchParams.get('returnLabel')).toBeNull(); + expect(url.searchParams.get('start')).toBe('1700000000000'); + }); + it('keeps overview and monitor-editor navigation machine-only without display-label fields', () => { const overviewNavigationSource = readFileSync(resolve(process.cwd(), 'lib/overview/navigation.ts'), 'utf8'); - const overviewPageSource = readFileSync(resolve(process.cwd(), 'app/overview/page.tsx'), 'utf8'); + const overviewPageSource = readFileSync(resolve(process.cwd(), 'app/overview/overview-page.tsx'), 'utf8'); const monitorEditorTestSource = readFileSync(resolve(process.cwd(), 'lib/monitor-editor/navigation.test.ts'), 'utf8'); expect(overviewNavigationSource).not.toContain('returnLabel: string'); diff --git a/web-next/lib/overview/navigation.ts b/web-next/lib/overview/navigation.ts index 2fbe210233..d9c970f464 100644 --- a/web-next/lib/overview/navigation.ts +++ b/web-next/lib/overview/navigation.ts @@ -1,6 +1,11 @@ +import { buildCompatRedirectTarget, type SearchParamsRecord } from '../compat/search-params'; import type { SingleAlert } from '../types'; import { stripReturnLabelFromHref } from '../signal-route-context'; +export type { SearchParamsRecord }; + +export const OVERVIEW_ROUTE = '/overview'; + const OVERVIEW_SIGNAL_DESK_PATHS = new Set([ '/log/manage', '/trace/manage', @@ -28,6 +33,10 @@ function setIfMissing(params: URLSearchParams, key: string, value: string | unde params.set(key, value); } +export function buildOverviewCompatRouteUrl(searchParams?: SearchParamsRecord) { + return buildCompatRedirectTarget(OVERVIEW_ROUTE, searchParams); +} + export function buildOverviewSignalDeskHref( href: string, alert: Pick<SingleAlert, 'labels'> | null | undefined diff --git a/web-next/lib/overview/view-model.test.ts b/web-next/lib/overview/view-model.test.ts index cd0dd09b6d..f960fc87ad 100644 --- a/web-next/lib/overview/view-model.test.ts +++ b/web-next/lib/overview/view-model.test.ts @@ -7,6 +7,7 @@ import { import { createTranslatorMock } from '../../test/i18n-test-helper'; const t = createTranslatorMock({ locale: 'zh-CN' }); +const enT = createTranslatorMock({ locale: 'en-US' }); describe('overview view model', () => { it('builds entity and alert metrics from app summary data', () => { @@ -51,6 +52,61 @@ describe('overview view model', () => { expect(lanes.find(item => item.href === '/log/manage')?.stat).toBe('无链路 ID 时优先'); }); + it('localizes overview investigation lane titles, copy, stats, and actions', () => { + const lanes = buildInvestigationLanes( + { + totalEntities: 7, + appCount: 2, + topAlertHasTraceId: false + }, + t + ); + + expect(lanes.find(item => item.href === '/entities')).toMatchObject({ + title: '实体目录', + eyebrow: '实体优先', + copy: '先从监控实体和服务归属定位影响范围,再进入信号排查。', + stat: '已纳管 7 个实体', + action: '打开实体目录' + }); + expect(lanes.find(item => item.href === '/log/manage')).toMatchObject({ + title: '日志', + eyebrow: '运行证据', + copy: '用相同服务、实体和时间上下文查看日志事件。', + stat: '日志可用', + action: '打开日志' + }); + expect(lanes.find(item => item.href === '/trace/manage')).toMatchObject({ + title: '链路', + eyebrow: '请求路径', + copy: '存在链路上下文时跟进跨度,再回到实体证据。', + stat: '分布式链路就绪', + action: '打开链路' + }); + expect(lanes.find(item => item.href === '/ingestion/otlp/metrics')).toMatchObject({ + title: 'OTLP 指标', + eyebrow: '三信号接入', + copy: '在私有部署的 HertzBeat 工作区查看进入的指标序列。', + stat: '已接入 2 个监控应用', + action: '打开指标' + }); + }); + + it('keeps English log lane recommendation copy in English', () => { + const lanes = buildInvestigationLanes( + { + totalEntities: 7, + appCount: 2, + topAlertHasTraceId: true + }, + enT + ); + const logStat = lanes.find(item => item.href === '/log/manage')?.stat; + + expect(logStat).toBe('Prioritize when trace ID is missing'); + expect(logStat).not.toMatch(/[\u4e00-\u9fff]/); + }); + it('builds the HertzBeat overview console structure from app and alert data', () => { const viewModel = buildOverviewConsoleViewModel( [ @@ -87,8 +143,14 @@ describe('overview view model', () => { expect(viewModel.problemFocus).toMatchObject({ title: 'checkout latency spike', severity: 'critical', + severityTone: 'danger', owner: 'Platform' }); + expect(viewModel.impactedEntities[0]).toMatchObject({ + name: 'checkout', + severity: 'critical', + severityTone: 'danger' + }); expect(viewModel.quickEntryItems.map(item => item.route)).toEqual([ '/entities', '/log/manage', diff --git a/web-next/lib/overview/view-model.ts b/web-next/lib/overview/view-model.ts index 7305e48e76..9a959c1129 100644 --- a/web-next/lib/overview/view-model.ts +++ b/web-next/lib/overview/view-model.ts @@ -2,6 +2,8 @@ import type { AlertSummary, AppCount, SingleAlert } from '@/lib/types'; type Translator = (key: string, params?: Record<string, string | number | null | undefined>) => string; +type OverviewTone = 'default' | 'success' | 'warning' | 'danger'; + type OverviewMetrics = { totalEntities: number; healthyEntities: number; @@ -27,13 +29,14 @@ type OverviewSummaryCard = { value: string; hint: string; delta: string; - tone: 'default' | 'success' | 'warning' | 'danger'; + tone: OverviewTone; }; type OverviewProblemFocus = { title: string; severity: string; severityLabel: string; + severityTone: OverviewTone; entity: string; owner: string; summary: string; @@ -43,7 +46,7 @@ type OverviewTrendCard = { label: string; value: string; insight: string; - tone: 'default' | 'success' | 'warning' | 'danger'; + tone: OverviewTone; }; type OverviewImpactedEntity = { @@ -51,6 +54,7 @@ type OverviewImpactedEntity = { type: string; severity: string; severityLabel: string; + severityTone: OverviewTone; owner: string; status: string; statusLabel: string; @@ -61,7 +65,7 @@ type OverviewActivityItem = { title: string; detail: string; timestamp: string; - tone: 'default' | 'success' | 'warning' | 'danger'; + tone: OverviewTone; tag: string; }; @@ -332,6 +336,7 @@ function buildProblemFocus(alerts: SingleAlert[], t: Translator): OverviewProble title: t('dashboard.problem-focus.empty.title'), severity: 'healthy', severityLabel: resolveSeverityLabel('healthy', t), + severityTone: overviewSeverityTone('healthy'), entity: t('dashboard.problem-focus.empty.entity'), owner: t('dashboard.problem-focus.empty.owner'), summary: t('dashboard.problem-focus.empty.summary') @@ -343,6 +348,7 @@ function buildProblemFocus(alerts: SingleAlert[], t: Translator): OverviewProble title: focus.content || focus.annotations?.summary || t('dashboard.problem-focus.default-title'), severity, severityLabel: resolveSeverityLabel(severity, t), + severityTone: overviewSeverityTone(severity), entity: focus.labels?.service || focus.labels?.job || focus.labels?.instance || t('dashboard.problem-focus.default-entity'), owner: getAlertOwnerLabel(focus, t), summary: focus.annotations?.summary || focus.content || t('dashboard.problem-focus.default-summary') @@ -401,6 +407,7 @@ function buildImpactedEntities(appCounts: AppCount[], alerts: SingleAlert[], t: type: item.category || 'service', severity, severityLabel: resolveSeverityLabel(severity, t), + severityTone: overviewSeverityTone(severity), owner: getAlertOwnerLabel(linkedAlert, t), status: degraded > 0 ? 'impacted' : 'healthy', statusLabel: degraded > 0 @@ -451,6 +458,20 @@ function getAlertSeverity(alert?: SingleAlert) { return `${alert?.labels?.severity || alert?.annotations?.severity || 'warning'}`.toLowerCase(); } +function overviewSeverityTone(severity: string): OverviewTone { + switch (severity.toLowerCase()) { + case 'critical': + case 'error': + return 'danger'; + case 'warning': + return 'warning'; + case 'healthy': + return 'success'; + default: + return 'default'; + } +} + function getAlertOwnerLabel(alert: SingleAlert | undefined, t: Translator) { return alert?.labels?.owner || alert?.annotations?.owner || t('dashboard.owner.unassigned'); } --------------------------------------------------------------------- To unsubscribe, e-mail: [email protected] For additional commands, e-mail: [email protected]
