diff --git a/web-next/.dockerignore b/web-next/.dockerignore deleted file mode 100644 index 6e3484f71a..0000000000 --- a/web-next/.dockerignore +++ /dev/null @@ -1,5 +0,0 @@ -.next -.next-dev -node_modules -npm-debug.log -Dockerfile diff --git a/web-next/.eslintrc.json b/web-next/.eslintrc.json deleted file mode 100644 index 957cd1545e..0000000000 --- a/web-next/.eslintrc.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "extends": ["next/core-web-vitals"] -} diff --git a/web-next/.gitignore b/web-next/.gitignore deleted file mode 100644 index 72d3c5609a..0000000000 --- a/web-next/.gitignore +++ /dev/null @@ -1,11 +0,0 @@ -.next -.next-build -.next-dev -.next-dev-* -.next-review -node_modules -out -.env.local -npm-debug.log* -next-env.d.ts -tsconfig.tsbuildinfo diff --git a/web-next/Dockerfile b/web-next/Dockerfile deleted file mode 100644 index 997b258830..0000000000 --- a/web-next/Dockerfile +++ /dev/null @@ -1,31 +0,0 @@ -FROM node:22-alpine AS deps -WORKDIR /app -COPY web-next/package.json web-next/package-lock.json ./ -RUN npm ci - -FROM node:22-alpine AS builder -WORKDIR /app -ENV NEXT_TELEMETRY_DISABLED=1 -ENV NEXT_OUTPUT_FILE_TRACING_ROOT=/app -COPY --from=deps /app/node_modules ./node_modules -COPY web-next ./ -RUN npm run build - -FROM node:22-alpine AS runner -WORKDIR /app -ARG HERTZBEAT_RELEASE_VERSION=dev -ENV NODE_ENV=production -ENV NEXT_TELEMETRY_DISABLED=1 -ENV PORT=4200 -ENV HOSTNAME=0.0.0.0 -LABEL org.opencontainers.image.title="Apache HertzBeat Web Next" -LABEL org.opencontainers.image.version="${HERTZBEAT_RELEASE_VERSION}" -RUN addgroup --system --gid 1001 nodejs && adduser --system --uid 1001 nextjs -COPY --from=builder /app/.next/standalone ./ -COPY --from=builder /app/.next/static ./.next/static -COPY --from=builder /app/public ./public -RUN chown -R nextjs:nodejs /app -USER nextjs -EXPOSE 4200 -HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 CMD wget -q -O - "http://127.0.0.1:${PORT}/overview" > /dev/null || exit 1 -CMD ["node", "server.js"] diff --git a/web-next/app/actions/actions-page.tsx b/web-next/app/actions/actions-page.tsx deleted file mode 100644 index eff0744bc0..0000000000 --- a/web-next/app/actions/actions-page.tsx +++ /dev/null @@ -1,308 +0,0 @@ -'use client'; - -import React from 'react'; -import { HzActionWorkbench } from '@hertzbeat/ui'; -import { useI18n } from '../../components/providers/i18n-provider'; -import { buildActionsPlaceholderState } from '../../lib/actions-surface/view-model'; -import type { ActionSuggestionContext } from '../../lib/actions-surface/model'; -import { hzOpsCatalogVisual } from '../../lib/hz-ops-visual'; - -type ApprovalDraftResult = { - draftId: string; - state: string; - executionState: string; - actionId?: string; - catalogId?: string; - adapterOwner?: string; - managerBacked?: boolean; -}; - -type ApprovalDecisionResult = { - draftId: string; - decision: string; - state: string; - executionState: string; - adapterOwner?: string; - managerBacked?: boolean; -}; - -type ActionCatalogResult = { - state: string; - adapterOwner: string; - managerBacked: boolean; - items: Array<{ - catalogId?: string; - name?: string; - risk?: string; - status?: string; - executionMode?: string; - adapterOwner?: string; - }>; -}; - -type ApprovalDraftQueueResult = { - state: string; - adapterOwner: string; - managerBacked: boolean; - drafts: ApprovalDraftResult[]; -}; - -function buildApprovalDecisionRequestPreview(draftId: string, decision?: string) { - return JSON.stringify({ - draftId, - decision: decision === 'approved' || decision === 'rejected' ? decision : 'manual-choice-required', - executionAllowed: false - }); -} - -export default function ActionsPage({ suggestionContext }: { suggestionContext?: ActionSuggestionContext } = {}) { - const { t } = useI18n(); - const state = buildActionsPlaceholderState(t, suggestionContext); - const coldOpsVisual = hzOpsCatalogVisual; - const [approvalDraftStatus, setApprovalDraftStatus] = React.useState<'ready' | 'submitting' | 'created' | 'failed' | 'blocked'>( - state.approvalDraft.state === 'ready' ? 'ready' : 'blocked' - ); - const [approvalDraftResult, setApprovalDraftResult] = React.useState(); - const [approvalDraftError, setApprovalDraftError] = React.useState(); - const [approvalDecisionStatus, setApprovalDecisionStatus] = React.useState<'blocked' | 'ready' | 'submitting' | 'decided' | 'failed'>('blocked'); - const [approvalDecisionResult, setApprovalDecisionResult] = React.useState(); - const [approvalDecisionError, setApprovalDecisionError] = React.useState(); - const [catalogResult, setCatalogResult] = React.useState({ - state: state.catalogAdapter.state, - adapterOwner: state.catalogAdapter.adapterOwner, - managerBacked: state.catalogAdapter.managerBacked, - items: [] - }); - const [approvalDraftQueueResult, setApprovalDraftQueueResult] = React.useState({ - state: state.approvalDraftQueue.state, - adapterOwner: state.approvalDraftQueue.adapterOwner, - managerBacked: state.approvalDraftQueue.managerBacked, - drafts: [] - }); - - const loadApprovalDraftQueue = React.useCallback(async () => { - try { - const response = await fetch(state.approvalDraftQueue.endpoint, { method: state.approvalDraftQueue.method }); - const payload = await response.json() as ApprovalDraftQueueResult & { message?: string }; - if (!response.ok) throw new Error(payload.message || 'approval draft queue read failed'); - setApprovalDraftQueueResult({ - state: payload.state, - adapterOwner: payload.adapterOwner, - managerBacked: Boolean(payload.managerBacked), - drafts: Array.isArray(payload.drafts) ? payload.drafts : [] - }); - } catch { - setApprovalDraftQueueResult({ - state: 'failed', - adapterOwner: 'next-actions-approval-draft-bff', - managerBacked: false, - drafts: [] - }); - } - }, [state.approvalDraftQueue.endpoint, state.approvalDraftQueue.method]); - - React.useEffect(() => { - let active = true; - async function loadCatalog() { - try { - const response = await fetch(state.catalogAdapter.endpoint, { method: state.catalogAdapter.method }); - const payload = await response.json() as ActionCatalogResult & { message?: string }; - if (!response.ok) throw new Error(payload.message || 'catalog read failed'); - if (active) { - setCatalogResult({ - state: payload.state, - adapterOwner: payload.adapterOwner, - managerBacked: Boolean(payload.managerBacked), - items: Array.isArray(payload.items) ? payload.items : [] - }); - } - } catch { - if (active) { - setCatalogResult({ - state: 'failed', - adapterOwner: 'next-actions-catalog-bff', - managerBacked: false, - items: [] - }); - } - } - } - void loadCatalog(); - return () => { - active = false; - }; - }, [state.catalogAdapter.endpoint, state.catalogAdapter.method]); - - React.useEffect(() => { - void loadApprovalDraftQueue(); - }, [loadApprovalDraftQueue]); - - React.useEffect(() => { - if (approvalDraftResult?.draftId) { - setApprovalDecisionStatus('ready'); - setApprovalDecisionResult(undefined); - setApprovalDecisionError(undefined); - } else { - setApprovalDecisionStatus('blocked'); - } - }, [approvalDraftResult?.draftId]); - - async function createApprovalDraft() { - if (!state.approvalDraft.request) return; - setApprovalDraftStatus('submitting'); - setApprovalDraftError(undefined); - try { - const response = await fetch(state.approvalDraft.endpoint, { - method: state.approvalDraft.method, - headers: { 'content-type': 'application/json' }, - body: JSON.stringify(state.approvalDraft.request) - }); - const payload = await response.json() as ApprovalDraftResult & { message?: string }; - if (!response.ok) throw new Error(payload.message || 'approval draft failed'); - setApprovalDraftResult({ - draftId: payload.draftId, - state: payload.state, - executionState: payload.executionState, - actionId: payload.actionId, - catalogId: payload.catalogId, - adapterOwner: payload.adapterOwner, - managerBacked: payload.managerBacked - }); - setApprovalDraftStatus('created'); - void loadApprovalDraftQueue(); - } catch (error) { - setApprovalDraftError(error instanceof Error ? error.message : 'approval draft failed'); - setApprovalDraftStatus('failed'); - } - } - - async function decideApprovalDraft(decision: 'approved' | 'rejected') { - if (!approvalDraftResult?.draftId) return; - const endpoint = state.approvalDecision.endpointTemplate.replace( - ':draftId', - encodeURIComponent(approvalDraftResult.draftId) - ); - setApprovalDecisionStatus('submitting'); - setApprovalDecisionError(undefined); - try { - const response = await fetch(endpoint, { - method: state.approvalDecision.method, - headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ - decision, - reviewer: 'hertzbeat-ui-operator', - reason: 'manual approval decision from actions workbench', - executionMode: state.approvalDecision.executionMode, - executionAllowed: false - }) - }); - const payload = await response.json() as ApprovalDecisionResult & { message?: string }; - if (!response.ok) throw new Error(payload.message || 'approval decision failed'); - setApprovalDecisionResult({ - draftId: payload.draftId, - decision: payload.decision, - state: payload.state, - executionState: payload.executionState, - adapterOwner: payload.adapterOwner, - managerBacked: payload.managerBacked - }); - setApprovalDecisionStatus('decided'); - void loadApprovalDraftQueue(); - } catch (error) { - setApprovalDecisionError(error instanceof Error ? error.message : 'approval decision failed'); - setApprovalDecisionStatus('failed'); - } - } - - const approvalDecisionEndpoint = approvalDraftResult?.draftId - ? state.approvalDecision.endpointTemplate.replace(':draftId', encodeURIComponent(approvalDraftResult.draftId)) - : state.approvalDecision.endpointTemplate; - const approvalDecisionRequestPreview = approvalDraftResult?.draftId - ? buildApprovalDecisionRequestPreview(approvalDraftResult.draftId, approvalDecisionResult?.decision) - : state.approvalDecision.requestPreview; - const canDecideApprovalDraft = Boolean(approvalDraftResult?.draftId) && approvalDecisionStatus === 'ready'; - - return ( -
-
- ({ - catalogId: item.catalogId || 'unknown-catalog-item', - name: item.name || item.catalogId || 'unknown catalog item', - risk: item.risk || 'unknown', - status: item.status, - executionMode: item.executionMode, - adapterOwner: item.adapterOwner - })) - }} - approvalDraft={{ - ...state.approvalDraft, - status: approvalDraftStatus, - onCreate: state.approvalDraft.state === 'ready' ? createApprovalDraft : undefined, - result: approvalDraftResult, - error: approvalDraftError - }} - approvalDraftQueue={{ - ...state.approvalDraftQueue, - state: approvalDraftQueueResult.state, - adapterOwner: approvalDraftQueueResult.adapterOwner, - managerBacked: approvalDraftQueueResult.managerBacked, - drafts: approvalDraftQueueResult.drafts.map(draft => ({ - draftId: draft.draftId, - state: draft.state, - actionId: draft.actionId, - catalogId: draft.catalogId, - executionState: draft.executionState, - adapterOwner: draft.adapterOwner - })) - }} - approvalDecision={{ - ...state.approvalDecision, - state: approvalDraftResult?.draftId ? 'ready' : state.approvalDecision.state, - status: approvalDecisionStatus, - endpoint: approvalDecisionEndpoint, - managerBacked: Boolean(approvalDecisionResult?.managerBacked), - requestPreview: approvalDraftResult?.draftId - ? approvalDecisionRequestPreview - : state.approvalDecision.requestPreview, - onApprove: canDecideApprovalDraft ? () => void decideApprovalDraft('approved') : undefined, - onReject: canDecideApprovalDraft ? () => void decideApprovalDraft('rejected') : undefined, - result: approvalDecisionResult, - error: approvalDecisionError - }} - checklistTitle={state.checklistTitle} - checklist={state.checklist} - suggestedActions={state.suggestedActions} - suggestedTitle={t('actions.entry.suggested.title')} - suggestedCopy={t('actions.entry.suggested.copy')} - suggestedEvidenceLabel={t('actions.entry.suggested.evidence')} - suggestedConfirmLabel={t('actions.entry.suggested.confirm')} - emptyTitle={state.empty.title} - emptyCopy={state.empty.copy} - /> -
-
- ); -} diff --git a/web-next/app/actions/page.test.tsx b/web-next/app/actions/page.test.tsx deleted file mode 100644 index 7cb7c161ab..0000000000 --- a/web-next/app/actions/page.test.tsx +++ /dev/null @@ -1,201 +0,0 @@ -import { readFileSync } from 'node:fs'; -import { resolve } from 'node:path'; -import React from 'react'; -import { renderToStaticMarkup } from 'react-dom/server'; -import { describe, expect, it, vi } from 'vitest'; -import { createTranslatorMock } from '../../test/i18n-test-helper'; - -const t = createTranslatorMock({ locale: 'zh-CN' }); - -vi.mock('../../components/providers/i18n-provider', () => ({ - useI18n: () => ({ t }) -})); - -describe('actions page', () => { - it('renders the OTLP hertzbeat-ui entry shell without the previous placeholder stack', async () => { - const routeSource = readFileSync(resolve(process.cwd(), 'app/actions/page.tsx'), 'utf8'); - const source = readFileSync(resolve(process.cwd(), 'app/actions/actions-page.tsx'), 'utf8'); - const { default: ActionsPage } = await import('./actions-page'); - const html = renderToStaticMarkup(); - - expect(routeSource).toContain("import ActionsPage from './actions-page'"); - expect(routeSource).toContain("import { readActionsSuggestionContext, type ActionsSearchParams } from '../../lib/actions-surface/query-state'"); - expect(routeSource).toContain('const resolvedSearchParams = await searchParams'); - expect(routeSource).toContain('const suggestionContext = readActionsSuggestionContext(resolvedSearchParams)'); - expect(routeSource).toContain('return '); - expect(html).toContain('data-actions-route="otlp-hertzbeat-ui-ops-entry"'); - expect(html).toContain('data-actions-style-baseline="hertzbeat-ui-matte"'); - expect(html).toContain('data-actions-placeholder-replacement="api-backed-workbench"'); - expect(html).toContain('data-actions-legacy-open-context="adapter-boundary-panel"'); - expect(html).toContain('data-actions-legacy-entity-handoff="/entities"'); - expect(html).toContain('data-actions-shared-workbench="hertzbeat-ui"'); - expect(html).toContain('data-hz-ui="action-workbench"'); - expect(html).toContain('data-hz-action-workbench-owner="hertzbeat-ui-action-workbench"'); - expect(html).toContain('data-hz-action-workbench-density="operator-compact"'); - expect(html).toContain('data-hz-action-workbench-style="hertzbeat-ui-matte-hard-edge"'); - expect(html).toContain('data-actions-shell-panel="hertzbeat-ui-ops-shell-panel"'); - expect(html).toContain('data-actions-launch-checklist="hertzbeat-ui-ops-static-rail"'); - expect(html).toContain('data-actions-adapter-boundary="adapter-pending"'); - expect(html).toContain('data-actions-catalog="manual-action-catalog-api"'); - expect(html).toContain('data-actions-catalog-state="loading"'); - expect(html).toContain('data-actions-catalog-owner="next-actions-catalog-bff"'); - expect(html).toContain('data-actions-catalog-endpoint="/api/actions/catalog?limit=8"'); - expect(html).toContain('data-actions-catalog-manager-backed="false"'); - expect(html).toContain('data-actions-catalog-execution-mode="manual-approval-draft-only"'); - expect(html).toContain('data-actions-catalog-execution-allowed="false"'); - expect(html).toContain('data-actions-catalog-item-count="0"'); - expect(html).toContain('data-actions-approval-draft="manual-approval-draft-api"'); - expect(html).toContain('data-actions-approval-draft-state="awaiting-context"'); - expect(html).toContain('data-actions-approval-draft-owner="next-actions-approval-draft-bff"'); - expect(html).toContain('data-actions-approval-draft-endpoint="/api/actions/approval-drafts"'); - expect(html).toContain('data-actions-approval-draft-execution-mode="manual-approval-draft-only"'); - expect(html).toContain('data-actions-approval-draft-execution-allowed="false"'); - expect(html).toContain('data-actions-approval-draft-queue="manual-approval-draft-read-api"'); - expect(html).toContain('data-actions-approval-draft-queue-state="loading"'); - expect(html).toContain('data-actions-approval-draft-queue-owner="next-actions-approval-draft-bff"'); - expect(html).toContain('data-actions-approval-draft-queue-endpoint="/api/actions/approval-drafts?limit=8"'); - expect(html).toContain('data-actions-approval-draft-queue-execution-mode="manual-approval-draft-only"'); - expect(html).toContain('data-actions-approval-draft-queue-execution-allowed="false"'); - expect(html).toContain('data-actions-approval-decision="manual-approval-decision-api"'); - expect(html).toContain('data-actions-approval-decision-state="awaiting-draft"'); - expect(html).toContain('data-actions-approval-decision-owner="next-actions-approval-decision-bff"'); - expect(html).toContain('data-actions-approval-decision-endpoint="/api/actions/approval-drafts/:draftId/decision"'); - expect(html).toContain('data-actions-approval-decision-execution-mode="manual-approval-draft-only"'); - expect(html).toContain('data-actions-approval-decision-execution-allowed="false"'); - expect(html).toContain('data-actions-empty-state="hertzbeat-ui-ops-domain-adapter"'); - expect(html).toContain(t('actions.entry.title')); - expect(html).toContain(t('actions.entry.subtitle')); - expect(html).toContain(t('actions.entry.shell.eyebrow')); - expect(html).toContain(t('actions.adapter-boundary.label')); - expect(html).toContain(t('actions.adapter-boundary.copy')); - expect(html).toContain(t('actions.adapter-boundary.roadmap.workflow-automation')); - expect(html).toContain(t('actions.adapter-boundary.roadmap.runbook-orchestration')); - expect(html).not.toContain('workflow-automation'); - expect(html).not.toContain('runbook-orchestration'); - expect(html).toContain(t('actions.entry.empty.title')); - expect(html).toContain(t('actions.entry.action.overview')); - expect(html).toContain(t('actions.entry.action.entities')); - expect(html).toContain(t('actions.entry.chip.catalog')); - expect(html).toContain(t('actions.entry.chip.risk')); - expect(html).toContain(t('actions.entry.chip.approval')); - expect(html).toContain(t('actions.entry.checklist.context.title')); - expect(html).toContain(t('actions.entry.checklist.adapter.title')); - expect(html).toContain(t('actions.entry.checklist.evidence.title')); - expect(html).not.toContain('angular-dark-ops-placeholder'); - expect(html).not.toContain('DARK OPS'); - expect(html).not.toContain('V1 SHELL IS LIVE'); - expect(html).not.toContain('Domain adapter comes next'); - expect(source).toContain('hzOpsCatalogVisual'); - expect(source).toContain('HzActionWorkbench'); - expect(source).toContain('data-actions-placeholder-replacement="api-backed-workbench"'); - expect(source).toContain('data-actions-legacy-open-context="adapter-boundary-panel"'); - expect(source).toContain('data-actions-legacy-entity-handoff="/entities"'); - expect(source).toContain('fetch(state.catalogAdapter.endpoint'); - expect(source).toContain('fetch(state.approvalDraft.endpoint'); - expect(source).toContain('fetch(state.approvalDraftQueue.endpoint'); - expect(source).toContain('fetch(endpoint'); - expect(source).toContain('buildApprovalDecisionRequestPreview'); - expect(source).toContain("approvalDecisionResult?.decision"); - expect(source).not.toContain("decision: 'approved',"); - expect(source).not.toContain('rounded-[16px]'); - expect(source).not.toContain('rounded-[14px]'); - expect(source).not.toContain('#4f6cff'); - expect(source).not.toContain('#101c31'); - expect(html).not.toContain('Restart checkout deployment'); - expect(html).not.toContain('Catalog posture'); - expect(html).not.toContain('data-summary-metric-grid'); - expect(source).not.toContain('WorkbenchPage'); - expect(source).not.toContain('StageSection'); - expect(source).not.toContain('SummaryMetricGrid'); - expect(source).not.toContain('DrawerSection'); - }); - - it('renders alert-context suggested remediation actions as human-confirmed suggestions only', async () => { - const { default: ActionsPage } = await import('./actions-page'); - const html = renderToStaticMarkup( - - ); - - expect(html).toContain('data-actions-suggested-remediation="alert-context-human-confirmation"'); - expect(html).toContain('data-hz-action-workbench-owner="hertzbeat-ui-action-workbench"'); - expect(html).toContain('data-actions-adapter-boundary="adapter-pending"'); - expect(html).toContain(t('actions.entry.suggested.title')); - expect(html).toContain(t('actions.entry.suggested.copy')); - expect(html).toContain('data-actions-suggested-action="suggest-restart-checkout"'); - expect(html).toContain(t('actions.suggestion.restart.title', { target: 'checkout-api' })); - expect(html).toContain(`${t('actions.risk.high')} · ${t('actions.catalog.restart.name')}`); - expect(html).toContain(`${t('actions.risk.medium')} · ${t('actions.catalog.mute.name')}`); - expect(html).not.toContain('high risk · restart-checkout'); - expect(html).not.toContain('medium risk · mute-edge-alerts'); - expect(html).toContain(t('actions.suggestion.evidence.source', { value: t('actions.suggestion.source.alert') })); - expect(html).toContain(t('actions.suggestion.evidence.signal', { value: t('actions.suggestion.signal.traces') })); - expect(html).not.toContain(t('actions.suggestion.evidence.source', { value: 'alert' })); - expect(html).not.toContain(t('actions.suggestion.evidence.signal', { value: 'traces' })); - expect(html).toContain('data-actions-suggested-action-confirm="manual-required"'); - expect(html).toContain('data-actions-approval-draft-state="ready"'); - expect(html).toContain('data-actions-approval-draft-status="ready"'); - expect(html).toContain('data-actions-approval-draft-request="preview"'); - expect(html).toContain('"actionId":"suggest-restart-checkout"'); - expect(html).toContain('"executionAllowed":false'); - expect(html).toContain(t('actions.entry.suggested.confirm')); - expect(html).toContain('data-actions-suggested-action-evidence="suggest-restart-checkout"'); - expect(html).toContain('/alert?status=firing'); - expect(html).toContain('traceId=trace-123'); - expect(html).not.toContain('data-actions-auto-execute'); - expect(html).not.toContain('/actions/run'); - }); - - it('does not enable suggested actions from route tracking source params alone', async () => { - const { default: ActionsPage } = await import('./actions-page'); - const html = renderToStaticMarkup( - - ); - - expect(html).not.toContain('data-actions-suggested-remediation="alert-context-human-confirmation"'); - expect(html).toContain('data-actions-approval-draft-state="awaiting-context"'); - expect(html).toContain('data-actions-approval-draft-status="blocked"'); - expect(html).toContain(t('actions.approval-draft.disabled')); - expect(html).not.toContain('"actionId":"suggest-restart-checkout"'); - }); - - it('renders entity-id-only suggested remediation targets with localized fallback copy', async () => { - const { default: ActionsPage } = await import('./actions-page'); - const html = renderToStaticMarkup( - - ); - - expect(html).toContain(t('actions.suggestion.restart.title', { - target: t('actions.suggestion.target.entity-id', { entityId: 'service:commerce/checkout' }) - })); - expect(html).not.toContain(t('actions.suggestion.restart.title', { target: 'service:commerce/checkout' })); - expect(html).toContain('entityId=service%3Acommerce%2Fcheckout'); - expect(html).not.toContain('returnLabel='); - }); -}); diff --git a/web-next/app/actions/page.tsx b/web-next/app/actions/page.tsx deleted file mode 100644 index 829bdf18c0..0000000000 --- a/web-next/app/actions/page.tsx +++ /dev/null @@ -1,13 +0,0 @@ -import React from 'react'; -import ActionsPage from './actions-page'; -import { readActionsSuggestionContext, type ActionsSearchParams } from '../../lib/actions-surface/query-state'; - -export default async function ActionsRoutePage({ - searchParams -}: { - searchParams?: Promise; -}) { - const resolvedSearchParams = await searchParams; - const suggestionContext = readActionsSuggestionContext(resolvedSearchParams); - return ; -} diff --git a/web-next/app/alert-family.chrome.test.ts b/web-next/app/alert-family.chrome.test.ts deleted file mode 100644 index f73530dada..0000000000 --- a/web-next/app/alert-family.chrome.test.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { readFileSync } from 'node:fs'; -import { resolve } from 'node:path'; -import { describe, expect, it } from 'vitest'; - -describe('alert family cold-matte routes', () => { - it('keeps the alert integration provider route on the HertzBeat cold source-doc shell', () => { - const integrationSource = readFileSync(resolve(process.cwd(), 'app/alert/integration/[source]/page.tsx'), 'utf8'); - const sourceDocShellSource = readFileSync(resolve(process.cwd(), 'packages/hertzbeat-ui/src/source-doc-shell.tsx'), 'utf8'); - - expect(integrationSource).toContain('AlertIntegrationMarkdown'); - expect(integrationSource).toContain('HzSourceDocShell'); - expect(integrationSource).toContain('data-alert-integration-surface="hertzbeat-ui-source-doc"'); - expect(integrationSource).toContain('data-alert-integration-shell-owner="hertzbeat-ui-source-doc-shell"'); - expect(sourceDocShellSource).toContain('data-hz-source-doc-rail-owner="hertzbeat-ui-source-doc-shell"'); - expect(sourceDocShellSource).toContain('data-hz-source-doc-panel-owner="hertzbeat-ui-source-doc-shell"'); - expect(integrationSource).not.toContain('components/observability'); - expect(integrationSource).not.toContain('components/workbench/primitives'); - expect(integrationSource).not.toContain('components/observability/code-pane'); - }); - - it('keeps the alert notice route on the HertzBeat notification-closure workbench shell', () => { - const noticeSource = readFileSync(resolve(process.cwd(), 'app/alert/notice/page.tsx'), 'utf8'); - const noticePageSource = readFileSync(resolve(process.cwd(), 'app/alert/notice/alert-notice-page.tsx'), 'utf8'); - const noticeShellSource = readFileSync(resolve(process.cwd(), 'components/pages/alert-notice-console-shell.tsx'), 'utf8'); - - expect(noticeSource).toContain('AlertNoticePage'); - expect(noticePageSource).toContain('AlertNoticeConsoleShell'); - expect(noticePageSource).toContain('hzOpsCatalogVisual'); - expect(noticePageSource).toContain('data-alert-notice-surface="otlp-hertzbeat-ui-notice-console"'); - expect(noticePageSource).toContain('data-alert-notice-header="hertzbeat-ui-compact-header"'); - expect(noticePageSource).toContain('HzConfirmDialog'); - expect(noticeShellSource).toContain('data-alert-notice-workbench-panel="hertzbeat-ui-tabbed-table-panel"'); - expect(noticeShellSource).toContain('data-alert-notice-global-panel="hertzbeat-ui-matte-tabbed-table"'); - expect(noticeSource).not.toContain('components/observability'); - expect(noticeSource).not.toContain('components/workbench/primitives'); - }); -}); diff --git a/web-next/app/alert/alert-center-page.tsx b/web-next/app/alert/alert-center-page.tsx deleted file mode 100644 index 59ecb3fe0d..0000000000 --- a/web-next/app/alert/alert-center-page.tsx +++ /dev/null @@ -1,271 +0,0 @@ -'use client'; - -import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { useRouter } from 'next/navigation'; -import { AlertCenterSurface, type AlertEntityResponseResult } from '../../components/pages/alert-center-surface'; -import { useI18n } from '../../components/providers/i18n-provider'; -import { ClientWorkbench } from '../../components/workbench/client-workbench'; -import { api } from '../../lib/alert-api-facade'; -import { createAlertInhibitFromFacade, type AlertInhibitFormDraft } from '../../lib/alert-inhibit/controller'; -import { - applyAlertClosureOperationFromFacade, - buildAlertQueryAfterClosureOperation, - clampAlertCenterPageIndexAfterDelete, - loadAlertCenterDataFromFacade, - type AlertClosureOperationAction, - type AlertPageData -} from '../../lib/alert-manage/controller'; -import { - ALERT_CENTER_PAGE_SIZE_OPTIONS, - buildAlertCenterRouteUrl, - buildAlertListUrl, - hasAlertEntityContext, - type AlertCenterRouteState, - type AlertQueryState -} from '../../lib/alert-manage/query-state'; -import { - buildAlertClosureOperationFailureFeedback, - buildAlertClosureOperationFeedback, - type AlertRuleDialogMode -} from '../../lib/alert-manage/view-model'; -import { createAlertSilenceFromFacade, type AlertSilenceFormDraft } from '../../lib/alert-silence/controller'; -import { HEADER_ALERT_EVENT_TYPE, HEADER_ALERT_SSE_URL, parseHeaderSseJson } from '../../lib/shell/header-realtime'; -import type { GroupAlert } from '../../lib/types'; - -const EMPTY_ALERT_QUERY: AlertQueryState = { - search: '', - status: '', - severity: '', - pageIndex: 0, - pageSize: ALERT_CENTER_PAGE_SIZE_OPTIONS[0], - entityId: '', - entityName: '', - returnTo: '' -}; -const EMPTY_ALERT_CENTER_ROUTE_STATE: AlertCenterRouteState = { - initialQuery: EMPTY_ALERT_QUERY, - cleanUrl: '/alert', - shouldCleanUrl: false -}; -const ALERT_CENTER_SETTLED_CACHE_TTL_MS = 10_000; - -function normalizeEntityResponseAction(action: AlertClosureOperationAction): NonNullable['action'] { - return action === 'recover' ? 'resolve' : action; -} - -export function resolveRealtimeGroupId(alert: Pick | null | undefined): number | null { - if (!alert) { - return null; - } - const numericId = Number(alert.id); - if (Number.isFinite(numericId) && numericId > 0) { - return numericId; - } - const numericGroupKey = Number(alert.groupKey); - return Number.isFinite(numericGroupKey) && numericGroupKey > 0 ? numericGroupKey : null; -} - -export default function AlertCenterPage({ initialRouteState }: { initialRouteState?: AlertCenterRouteState } = {}) { - const { t } = useI18n(); - const router = useRouter(); - const alertCenterRouteState = initialRouteState ?? EMPTY_ALERT_CENTER_ROUTE_STATE; - const initialQuery = alertCenterRouteState.initialQuery; - const [draft, setDraft] = useState(initialQuery); - const [query, setQuery] = useState(initialQuery); - const [refreshNonce, setRefreshNonce] = useState(0); - const [operationFeedback, setOperationFeedback] = useState<{ tone: 'success' | 'danger'; copy: string } | null>(null); - const [selectedGroupIds, setSelectedGroupIds] = useState([]); - const [entityResponseResult, setEntityResponseResult] = useState(null); - const [realtimeEventCount, setRealtimeEventCount] = useState(0); - const [realtimeGroupIds, setRealtimeGroupIds] = useState([]); - const realtimeHighlightTimers = useRef([]); - const alertListUrl = useMemo(() => buildAlertListUrl(query), [query]); - const alertCenterCacheKey = useMemo( - () => ['alert-center', alertListUrl, refreshNonce].join('|'), - [alertListUrl, refreshNonce] - ); - - useEffect(() => { - setDraft(initialQuery); - setQuery(initialQuery); - setSelectedGroupIds([]); - setEntityResponseResult(null); - }, [initialQuery]); - - useEffect(() => { - if (alertCenterRouteState.shouldCleanUrl) { - router.replace(alertCenterRouteState.cleanUrl); - } - }, [alertCenterRouteState.cleanUrl, alertCenterRouteState.shouldCleanUrl, router]); - - useEffect(() => { - if (typeof window === 'undefined' || !window.EventSource) { - return undefined; - } - - const eventSource = new window.EventSource(HEADER_ALERT_SSE_URL); - const handleAlertEvent = (event: MessageEvent) => { - const alert = parseHeaderSseJson(event.data); - if (!alert) { - return; - } - const realtimeGroupId = resolveRealtimeGroupId(alert); - setRealtimeEventCount(current => current + 1); - if (realtimeGroupId) { - setRealtimeGroupIds(current => [realtimeGroupId, ...current.filter(groupId => groupId !== realtimeGroupId)].slice(0, 8)); - const timer = window.setTimeout(() => { - setRealtimeGroupIds(current => current.filter(groupId => groupId !== realtimeGroupId)); - }, 1000); - realtimeHighlightTimers.current.push(timer); - } - setRefreshNonce(current => current + 1); - }; - eventSource.addEventListener(HEADER_ALERT_EVENT_TYPE, handleAlertEvent); - eventSource.onerror = () => { - eventSource.close(); - }; - - return () => { - eventSource.removeEventListener(HEADER_ALERT_EVENT_TYPE, handleAlertEvent); - eventSource.close(); - }; - }, []); - - useEffect(() => () => { - realtimeHighlightTimers.current.forEach(timer => window.clearTimeout(timer)); - realtimeHighlightTimers.current = []; - }, []); - - const load = useCallback(async (): Promise => { - return loadAlertCenterDataFromFacade(api, query); - }, [query]); - - const refreshQuery = useCallback(() => { - const nextQuery = { ...draft, pageIndex: 0, pageSize: draft.pageSize ?? query.pageSize ?? ALERT_CENTER_PAGE_SIZE_OPTIONS[0] }; - setDraft(nextQuery); - setQuery(nextQuery); - setSelectedGroupIds([]); - setRefreshNonce(current => current + 1); - }, [draft, query.pageSize]); - - const handlePageIndexChange = useCallback((nextPageIndex: number) => { - const normalizedPageIndex = Math.max(0, Math.floor(nextPageIndex)); - const nextQuery = { ...query, pageIndex: normalizedPageIndex }; - setDraft(current => ({ ...current, pageIndex: normalizedPageIndex })); - setQuery(nextQuery); - router.replace(buildAlertCenterRouteUrl(nextQuery)); - setSelectedGroupIds([]); - }, [query, router]); - - const handlePageSizeChange = useCallback((nextPageSize: number) => { - const normalizedPageSize = ALERT_CENTER_PAGE_SIZE_OPTIONS.includes(nextPageSize as (typeof ALERT_CENTER_PAGE_SIZE_OPTIONS)[number]) - ? nextPageSize - : ALERT_CENTER_PAGE_SIZE_OPTIONS[0]; - const nextQuery = { ...query, pageIndex: 0, pageSize: normalizedPageSize }; - setDraft(current => ({ ...current, pageIndex: 0, pageSize: normalizedPageSize })); - setQuery(nextQuery); - router.replace(buildAlertCenterRouteUrl(nextQuery)); - setSelectedGroupIds([]); - }, [query, router]); - - const handleClosureAction = useCallback(async (action: AlertClosureOperationAction, groupId: number | number[], totalElements = 0) => { - setOperationFeedback(null); - try { - await applyAlertClosureOperationFromFacade(api.alerts, action, groupId); - setOperationFeedback({ tone: 'success', copy: buildAlertClosureOperationFeedback(action, t) }); - const affectedCount = Array.isArray(groupId) ? groupId.length : 1; - if (hasAlertEntityContext(query)) { - setEntityResponseResult({ action: normalizeEntityResponseAction(action), count: affectedCount }); - } - const buildPostActionQuery = (current: AlertQueryState) => { - const nextQuery = buildAlertQueryAfterClosureOperation(current, action); - return action === 'close' || action === 'delete' - ? clampAlertCenterPageIndexAfterDelete(nextQuery, totalElements, affectedCount) - : nextQuery; - }; - const nextRouteQuery = buildPostActionQuery(query); - setDraft(current => buildPostActionQuery(current)); - setQuery(current => buildPostActionQuery(current)); - router.replace(buildAlertCenterRouteUrl(nextRouteQuery)); - setSelectedGroupIds([]); - setRefreshNonce(current => current + 1); - } catch (error) { - setOperationFeedback({ - tone: 'danger', - copy: buildAlertClosureOperationFailureFeedback(action, t) - }); - } - }, [query, router, t]); - - const handleRuleQuickCreate = useCallback(async ( - mode: AlertRuleDialogMode, - ruleDraft: AlertSilenceFormDraft | AlertInhibitFormDraft, - count: number - ) => { - setOperationFeedback(null); - try { - if (mode === 'silence') { - await createAlertSilenceFromFacade(api.alertSilences.create, ruleDraft as AlertSilenceFormDraft); - } else { - await createAlertInhibitFromFacade(api.alertInhibits.create, ruleDraft as AlertInhibitFormDraft); - } - setOperationFeedback({ tone: 'success', copy: t('common.notify.new-success') }); - if (hasAlertEntityContext(query) && count > 0) { - setEntityResponseResult({ action: mode, count }); - } - setSelectedGroupIds([]); - setRefreshNonce(current => current + 1); - } catch (error) { - setOperationFeedback({ - tone: 'danger', - copy: t('common.notify.new-fail') - }); - throw error; - } - }, [query, t]); - - return ( - - {data => ( - { - const cleared = { - ...draft, - search: '', - severity: '', - status: hasAlertEntityContext(draft) ? 'firing' : '', - pageIndex: 0, - pageSize: draft.pageSize ?? query.pageSize ?? ALERT_CENTER_PAGE_SIZE_OPTIONS[0] - }; - setDraft(cleared); - setQuery(cleared); - setSelectedGroupIds([]); - setEntityResponseResult(null); - router.replace(buildAlertCenterRouteUrl(cleared)); - }} - operationFeedback={operationFeedback} - entityResponseResult={entityResponseResult} - realtimeEventCount={realtimeEventCount} - realtimeGroupIds={realtimeGroupIds} - pageSizeOptions={[...ALERT_CENTER_PAGE_SIZE_OPTIONS]} - onPageIndexChange={handlePageIndexChange} - onPageSizeChange={handlePageSizeChange} - selectedGroupIds={selectedGroupIds} - onSelectedGroupIdsChange={setSelectedGroupIds} - onClosureAction={(action, groupId) => void handleClosureAction(action, groupId, data.groupAlerts.totalElements)} - onRuleQuickCreate={handleRuleQuickCreate} - /> - )} - - ); -} diff --git a/web-next/app/alert/alert-label-options.guard.test.ts b/web-next/app/alert/alert-label-options.guard.test.ts deleted file mode 100644 index 49ebd3f550..0000000000 --- a/web-next/app/alert/alert-label-options.guard.test.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { readFileSync } from 'node:fs'; -import { resolve } from 'node:path'; -import { describe, expect, it } from 'vitest'; - -const alertPages = [ - 'app/alert/silence/alert-silence-page.tsx', - 'app/alert/group/alert-group-page.tsx', - 'app/alert/inhibit/alert-inhibit-page.tsx', - 'app/alert/notice/alert-notice-page.tsx' -]; - -describe('alert label search option wiring', () => { - it('loads shared label options for alert authoring routes that expose searchable label fields', () => { - for (const pagePath of alertPages) { - const source = readFileSync(resolve(process.cwd(), pagePath), 'utf8'); - expect(source, pagePath).toContain('loadAlertLabelOptions'); - expect(source, pagePath).toContain('labelOptions'); - } - }); -}); diff --git a/web-next/app/alert/alert-modal-feedback.guard.test.ts b/web-next/app/alert/alert-modal-feedback.guard.test.ts deleted file mode 100644 index b91dc572a9..0000000000 --- a/web-next/app/alert/alert-modal-feedback.guard.test.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { readFileSync } from 'node:fs'; -import { resolve } from 'node:path'; -import { describe, expect, it } from 'vitest'; - -const alertPageFiles = [ - 'app/alert/silence/alert-silence-page.tsx', - 'app/alert/group/alert-group-page.tsx', - 'app/alert/inhibit/alert-inhibit-page.tsx', - 'app/alert/notice/alert-notice-page.tsx', - 'app/alert/setting/alert-setting-page.tsx' -]; - -describe('alert modal feedback guard', () => { - it('uses the shared cold confirm dialog instead of browser-native confirm on alert management pages', () => { - for (const file of alertPageFiles) { - const source = readFileSync(resolve(process.cwd(), file), 'utf8'); - - expect(source, file).not.toContain('window.confirm'); - expect(source, file).not.toContain('confirm('); - expect(source, file).not.toContain('window.alert'); - expect(source, file).not.toContain('alert('); - expect(source, file).toContain('HzConfirmDialog'); - expect(source, file).toContain('data-alert-delete-confirm'); - } - }); -}); diff --git a/web-next/app/alert/center/page.test.ts b/web-next/app/alert/center/page.test.ts deleted file mode 100644 index 9abfdad315..0000000000 --- a/web-next/app/alert/center/page.test.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { describe, expect, it, vi } from 'vitest'; - -const redirect = vi.fn(); - -vi.mock('next/navigation', () => ({ - redirect -})); - -describe('alert center alias route', () => { - it('redirects alert center compatibility traffic to the main alert workbench', async () => { - redirect.mockImplementation((target: string) => { - throw new Error(`redirect:${target}`); - }); - - const { default: AlertCenterAliasPage } = await import('./page'); - - await expect(AlertCenterAliasPage()).rejects.toThrow('redirect:/alert'); - expect(redirect).toHaveBeenCalledWith('/alert'); - }); - - it('preserves incoming query context when redirecting the alert center alias', async () => { - redirect.mockImplementation((target: string) => { - throw new Error(`redirect:${target}`); - }); - - const { default: AlertCenterAliasPage } = await import('./page'); - - await expect( - AlertCenterAliasPage({ - searchParams: Promise.resolve({ - status: 'acknowledged', - severity: 'warning', - entityId: '42', - returnTo: '/entities/42' - }) - }) - ).rejects.toThrow('redirect:/alert?status=acknowledged&severity=warning&entityId=42&returnTo=%2Fentities%2F42'); - expect(redirect).toHaveBeenLastCalledWith('/alert?status=acknowledged&severity=warning&entityId=42&returnTo=%2Fentities%2F42'); - }); - - it('strips display return labels when redirecting alert center aliases', async () => { - redirect.mockImplementation((target: string) => { - throw new Error(`redirect:${target}`); - }); - - const { default: AlertCenterAliasPage } = await import('./page'); - - await expect( - AlertCenterAliasPage({ - searchParams: Promise.resolve({ - content: ' checkout ', - status: 'ACKNOWLEDGED', - severity: 'Warning', - entityId: '42', - entityName: 'Checkout API', - returnTo: '/entities/42?returnLabel=Checkout', - returnLabel: 'Checkout' - }) - }) - ).rejects.toThrow( - 'redirect:/alert?search=checkout&status=acknowledged&severity=warning&entityId=42&entityName=Checkout+API&returnTo=%2Fentities%2F42' - ); - expect(redirect).toHaveBeenLastCalledWith( - '/alert?search=checkout&status=acknowledged&severity=warning&entityId=42&entityName=Checkout+API&returnTo=%2Fentities%2F42' - ); - }); -}); diff --git a/web-next/app/alert/center/page.tsx b/web-next/app/alert/center/page.tsx deleted file mode 100644 index 201cbfc2ab..0000000000 --- a/web-next/app/alert/center/page.tsx +++ /dev/null @@ -1,9 +0,0 @@ -import { redirect } from 'next/navigation'; -import { buildAlertCompatRouteUrlFromSearchParams, type SearchParamsRecord } from '../../../lib/alert-manage/query-state'; - -export default async function AlertCenterAliasPage(props: { - searchParams?: Promise; -}) { - const resolvedSearchParams = await props?.searchParams; - redirect(buildAlertCompatRouteUrlFromSearchParams(resolvedSearchParams)); -} diff --git a/web-next/app/alert/group/alert-group-page.tsx b/web-next/app/alert/group/alert-group-page.tsx deleted file mode 100644 index 9273e5d47c..0000000000 --- a/web-next/app/alert/group/alert-group-page.tsx +++ /dev/null @@ -1,486 +0,0 @@ -'use client'; - -import React, { useCallback, useEffect, useMemo, useState } from 'react'; -import { useRouter, useSearchParams } from 'next/navigation'; -import { ClientWorkbench } from '../../../components/workbench/client-workbench'; -import { useI18n } from '../../../components/providers/i18n-provider'; -import { AlertGroupSurface } from '../../../components/pages/alert-group-surface'; -import { HzConfirmDialog } from '../../../components/ui/hz-confirm-dialog'; -import { api } from '../../../lib/alert-api-facade'; -import { - createAlertGroupFromFacade, - deleteAlertGroupFromFacade, - deleteAlertGroupsFromFacade, - loadAlertGroupDataFromFacade, - loadAlertGroupDetailFromFacade, - updateAlertGroupEnabledFromFacade, - updateAlertGroupFromFacade, - type AlertGroupFormDraft -} from '../../../lib/alert-group/controller'; -import { ALERT_GROUP_PAGE_SIZE_OPTIONS, buildAlertGroupUrl, type AlertGroupRouteState } from '../../../lib/alert-group/query-state'; -import { - buildAlertGroupEvidenceContext, - buildAlertGroupFormDraft, - getAlertGroupValidationField, - validateAlertGroupForm, - type AlertGroupValidationField -} from '../../../lib/alert-group/view-model'; -import { DEFAULT_ALERT_LABEL_OPTIONS, loadAlertLabelOptionsFromFacade } from '../../../lib/alert-label-options'; -import { formatTime } from '../../../lib/format'; -import type { AlertGroupConverge } from '../../../lib/types'; - -type GroupDeleteRequest = { - kind: 'single' | 'batch'; - ids: number[]; -}; - -type AlertGroupErrorContract = 'save' | 'enable' | 'delete'; - -const ALERT_GROUP_SETTLED_CACHE_TTL_MS = 10_000; -const EMPTY_ALERT_GROUP_ROUTE_STATE: AlertGroupRouteState = { - signal: null, - signalContext: {} -}; -const ALERT_GROUP_ROUTE_PATH = '/alert/group'; -const ALERT_GROUP_EDITOR_FOCUS_SELECTORS: Record = { - name: 'input[name="alert_group_name"]', - 'group-labels': '[data-alert-group-label-selector] input[data-hz-tag-input-control="draft"]', - 'group-wait': 'input[name="alert_group_wait"]', - 'group-interval': 'input[name="alert_group_interval"]', - 'repeat-interval': 'input[name="alert_group_repeat_interval"]' -}; -const ALERT_GROUP_DRAFT_FINGERPRINT_FIELDS: Array = [ - 'id', - 'name', - 'enable', - 'groupLabelsText', - 'groupWait', - 'groupInterval', - 'repeatInterval' -]; - -type AlertGroupListRouteState = { - search: string; - pageIndex: number; - pageSize: number; -}; - -function parseAlertGroupRouteInteger(value: string | null, fallback: number, minimum = 0) { - if (!value) return fallback; - const parsed = Number.parseInt(value, 10); - return Number.isFinite(parsed) && parsed >= minimum ? parsed : fallback; -} - -function focusAlertGroupEditorField(field: AlertGroupValidationField) { - if (typeof document === 'undefined') return; - const selector = ALERT_GROUP_EDITOR_FOCUS_SELECTORS[field]; - window.requestAnimationFrame(() => { - const target = document.querySelector(selector); - target?.focus(); - target?.scrollIntoView({ block: 'center', inline: 'nearest' }); - }); -} - -function serializeAlertGroupDraft(draft: AlertGroupFormDraft) { - return JSON.stringify( - ALERT_GROUP_DRAFT_FINGERPRINT_FIELDS.map(field => [field, draft[field] == null ? '' : String(draft[field]).trim()]) - ); -} - -export default function AlertGroupPage({ initialRouteState }: { initialRouteState?: AlertGroupRouteState } = {}) { - const { t } = useI18n(); - const router = useRouter(); - const searchParams = useSearchParams(); - const routeSearchParamString = searchParams.toString(); - const routeSearch = searchParams.get('search') ?? ''; - const routePageIndex = parseAlertGroupRouteInteger(searchParams.get('pageIndex'), 0); - const routePageSize = parseAlertGroupRouteInteger(searchParams.get('pageSize'), ALERT_GROUP_PAGE_SIZE_OPTIONS[0], 1); - const routeListState = useMemo(() => ({ - search: routeSearch, - pageIndex: routePageIndex, - pageSize: routePageSize - }), [routePageIndex, routePageSize, routeSearch]); - const alertGroupRouteState = initialRouteState ?? EMPTY_ALERT_GROUP_ROUTE_STATE; - const { signal, signalContext } = alertGroupRouteState; - const groupEvidenceContext = useMemo( - () => buildAlertGroupEvidenceContext(signal, signalContext, t), - [signal, signalContext, t] - ); - const [search, setSearch] = useState(routeListState.search); - const [query, setQuery] = useState(routeListState.search); - const [pageIndex, setPageIndex] = useState(routeListState.pageIndex); - const [pageSize, setPageSize] = useState(routeListState.pageSize); - const [selectedId, setSelectedId] = useState(null); - const [editorOpen, setEditorOpen] = useState(false); - const [editorLoading, setEditorLoading] = useState(false); - const [editorSaving, setEditorSaving] = useState(false); - const [editorMessage, setEditorMessage] = useState(null); - const [editorError, setEditorError] = useState(null); - const [editorErrorDetail, setEditorErrorDetail] = useState(null); - const [editorErrorContract, setEditorErrorContract] = useState(null); - const [draft, setDraft] = useState(() => buildAlertGroupFormDraft(null, groupEvidenceContext?.draftPatch)); - const [editorInitialFingerprint, setEditorInitialFingerprint] = useState(() => ( - serializeAlertGroupDraft(buildAlertGroupFormDraft(null, groupEvidenceContext?.draftPatch)) - )); - const [editorDiscardDialogOpen, setEditorDiscardDialogOpen] = useState(false); - const [refreshTick, setRefreshTick] = useState(0); - const [checkedIds, setCheckedIds] = useState([]); - const [deleteRequest, setDeleteRequest] = useState(null); - const [deletePending, setDeletePending] = useState(false); - const alertGroupListUrl = useMemo(() => buildAlertGroupUrl({ search: query, pageIndex, pageSize }), [pageIndex, pageSize, query]); - const alertGroupCacheKey = useMemo( - () => ['alert-group', alertGroupListUrl, refreshTick].join('|'), - [alertGroupListUrl, refreshTick] - ); - const editorDraftFingerprint = useMemo(() => serializeAlertGroupDraft(draft), [draft]); - const shouldConfirmEditorDiscard = Boolean(editorOpen && editorDraftFingerprint !== editorInitialFingerprint && !editorSaving); - - useEffect(() => { - setSearch(routeListState.search); - setQuery(routeListState.search); - setPageIndex(routeListState.pageIndex); - setPageSize(routeListState.pageSize); - setSelectedId(null); - setCheckedIds([]); - }, [routeListState]); - - const replaceRouteQuery = useCallback((nextState: AlertGroupListRouteState) => { - const nextParams = new URLSearchParams(routeSearchParamString); - const cleanSearch = nextState.search.trim(); - if (cleanSearch) { - nextParams.set('search', cleanSearch); - } else { - nextParams.delete('search'); - } - - if (nextState.pageIndex > 0) { - nextParams.set('pageIndex', String(nextState.pageIndex)); - } else { - nextParams.delete('pageIndex'); - } - - if (nextState.pageSize !== ALERT_GROUP_PAGE_SIZE_OPTIONS[0]) { - nextParams.set('pageSize', String(nextState.pageSize)); - } else { - nextParams.delete('pageSize'); - } - - const nextParamString = nextParams.toString(); - const nextUrl = nextParamString ? `${ALERT_GROUP_ROUTE_PATH}?${nextParamString}` : ALERT_GROUP_ROUTE_PATH; - const currentUrl = routeSearchParamString ? `${ALERT_GROUP_ROUTE_PATH}?${routeSearchParamString}` : ALERT_GROUP_ROUTE_PATH; - if (nextUrl !== currentUrl) { - router.replace(nextUrl, { scroll: false }); - } - }, [routeSearchParamString, router]); - - const load = useCallback(async () => { - const data = await loadAlertGroupDataFromFacade( - { - list: api.alertGroups.list, - labelOptions: () => loadAlertLabelOptionsFromFacade(api.alertLabels.list) - }, - { search: query, pageIndex, pageSize } - ); - return { ...data, refreshTick }; - }, [pageIndex, pageSize, query, refreshTick]); - - return ( - - {data => { - const labelOptions = data.labelOptions ?? DEFAULT_ALERT_LABEL_OPTIONS; - const selected = data.list.content.find(item => item.id === selectedId) ?? data.list.content[0] ?? null; - - async function handleNew() { - const nextDraft = buildAlertGroupFormDraft(null, groupEvidenceContext?.draftPatch); - setDraft(nextDraft); - setEditorInitialFingerprint(serializeAlertGroupDraft(nextDraft)); - setEditorDiscardDialogOpen(false); - setEditorError(null); - setEditorErrorDetail(null); - setEditorErrorContract(null); - setEditorMessage(null); - setEditorOpen(true); - } - - async function handleEdit(groupId?: number) { - const targetId = groupId ?? selected?.id; - if (!targetId) return; - setEditorLoading(true); - setEditorError(null); - setEditorErrorDetail(null); - setEditorErrorContract(null); - setEditorMessage(null); - try { - const detail = await loadAlertGroupDetailFromFacade(api.alertGroups.detail, targetId); - const nextDraft = buildAlertGroupFormDraft(detail); - setDraft(nextDraft); - setEditorInitialFingerprint(serializeAlertGroupDraft(nextDraft)); - setEditorDiscardDialogOpen(false); - setEditorOpen(true); - } catch (error) { - setEditorError(error instanceof Error ? error.message : t('common.notify.edit-fail')); - setEditorErrorDetail(null); - setEditorErrorContract(null); - } finally { - setEditorLoading(false); - } - } - - async function handleSave() { - const validationError = validateAlertGroupForm(draft, t); - if (validationError) { - const validationField = getAlertGroupValidationField(draft); - setEditorError(validationError); - setEditorErrorDetail(null); - setEditorErrorContract(null); - if (validationField) { - focusAlertGroupEditorField(validationField); - } - return; - } - const isEdit = Boolean(draft.id); - setEditorSaving(true); - setEditorError(null); - setEditorErrorDetail(null); - setEditorErrorContract(null); - setEditorMessage(null); - try { - if (isEdit) { - await updateAlertGroupFromFacade(api.alertGroups.update, draft); - } else { - await createAlertGroupFromFacade(api.alertGroups.create, draft); - } - setEditorInitialFingerprint(serializeAlertGroupDraft(draft)); - setEditorMessage(t(isEdit ? 'common.notify.edit-success' : 'common.notify.new-success')); - setEditorOpen(false); - setEditorDiscardDialogOpen(false); - setRefreshTick(value => value + 1); - } catch (error) { - setEditorError(t(isEdit ? 'common.notify.edit-fail' : 'common.notify.new-fail')); - setEditorErrorDetail(error instanceof Error ? error.message : null); - setEditorErrorContract('save'); - } finally { - setEditorSaving(false); - } - } - - async function handleToggleEnabled(group?: AlertGroupConverge | null) { - const target = group ?? selected; - if (!target) return; - try { - await updateAlertGroupEnabledFromFacade(api.alertGroups.update, target, !(target.enable ?? true)); - setEditorMessage(t('common.notify.edit-success')); - setEditorError(null); - setEditorErrorDetail(null); - setEditorErrorContract(null); - setRefreshTick(value => value + 1); - } catch (error) { - setEditorError(t('common.notify.edit-fail')); - setEditorErrorDetail(error instanceof Error ? error.message : null); - setEditorErrorContract('enable'); - } - } - - async function handleDelete(groupId?: number) { - const targetId = groupId ?? selected?.id; - if (!targetId) return; - setDeleteRequest({ kind: 'single', ids: [targetId] }); - } - - async function handleConfirmedDelete() { - const request = deleteRequest; - if (!request || request.ids.length === 0) return; - setDeletePending(true); - try { - if (request.kind === 'batch') { - await deleteAlertGroupsFromFacade(api.alertGroups.delete, request.ids); - setCheckedIds([]); - } else { - await deleteAlertGroupFromFacade(api.alertGroups.delete, request.ids[0]); - } - const nextTotal = Math.max((data.list.totalElements || 0) - request.ids.length, 0); - const nextLastPageIndex = Math.max(0, Math.ceil(nextTotal / pageSize) - 1); - setPageIndex(value => Math.min(value, nextLastPageIndex)); - setSelectedId(null); - setEditorOpen(false); - setEditorMessage(t('common.notify.delete-success')); - setEditorError(null); - setEditorErrorDetail(null); - setEditorErrorContract(null); - setDeleteRequest(null); - setRefreshTick(value => value + 1); - } catch (error) { - setEditorError(t('common.notify.delete-fail')); - setEditorErrorDetail(error instanceof Error ? error.message : null); - setEditorErrorContract('delete'); - } finally { - setDeletePending(false); - } - } - - function handleRefresh() { - setRefreshTick(value => value + 1); - } - - async function handleDeleteSelected() { - if (checkedIds.length === 0) { - setEditorError(t('common.notify.no-select-delete')); - setEditorErrorDetail(null); - setEditorErrorContract(null); - setEditorMessage(null); - return; - } - setEditorError(null); - setEditorErrorDetail(null); - setEditorErrorContract(null); - setDeleteRequest({ kind: 'batch', ids: checkedIds }); - } - - function handleApplyFilter() { - const nextSearch = search.trim(); - const nextState = { search: nextSearch, pageIndex: 0, pageSize }; - setSearch(nextSearch); - setQuery(nextSearch); - setPageIndex(0); - setSelectedId(null); - setCheckedIds([]); - replaceRouteQuery(nextState); - } - - function handleClearFilter() { - const nextState = { search: '', pageIndex: 0, pageSize }; - setSearch(''); - setQuery(''); - setPageIndex(0); - setSelectedId(null); - setCheckedIds([]); - replaceRouteQuery(nextState); - } - - function handlePageIndexChange(nextPageIndex: number) { - setPageIndex(nextPageIndex); - setSelectedId(null); - setCheckedIds([]); - replaceRouteQuery({ search: query, pageIndex: nextPageIndex, pageSize }); - } - - function handlePageSizeChange(nextPageSize: number) { - const nextState = { search: query, pageIndex: 0, pageSize: nextPageSize }; - setPageSize(nextPageSize); - setPageIndex(0); - setSelectedId(null); - setCheckedIds([]); - replaceRouteQuery(nextState); - } - - function handleCloseEditor() { - setEditorOpen(false); - setEditorError(null); - setEditorErrorDetail(null); - setEditorErrorContract(null); - setEditorMessage(null); - setEditorDiscardDialogOpen(false); - } - - function requestCloseEditor() { - if (shouldConfirmEditorDiscard) { - setEditorDiscardDialogOpen(true); - return; - } - handleCloseEditor(); - } - - const deleteTargetNames = deleteRequest?.ids - .map(id => data.list.content.find(item => item.id === id)?.name?.trim()) - .filter((name): name is string => Boolean(name)) ?? []; - const missingDeleteTargetCount = deleteRequest - ? Math.max(deleteRequest.ids.length - deleteTargetNames.length, 0) - : 0; - const deleteConfirmCopy = [ - deleteRequest?.kind === 'batch' - ? t('alert.group.delete.confirm.batch', { count: deleteRequest.ids.length }) - : t('alert.group.delete.confirm.single'), - deleteTargetNames.length > 0 - ? t('alert.group.delete.confirm.targets', { names: deleteTargetNames.join(', ') }) - : null, - missingDeleteTargetCount > 0 - ? t('alert.group.delete.confirm.targets-more', { count: missingDeleteTargetCount }) - : null - ].filter(Boolean).join('\n'); - - return ( - <> - void handleNew()} - onSave={() => void handleSave()} - onToggleEnabled={group => void handleToggleEnabled(group)} - onEdit={groupId => void handleEdit(groupId)} - onDelete={groupId => void handleDelete(groupId)} - onDeleteSelected={() => void handleDeleteSelected()} - onCloseEditor={requestCloseEditor} - onDraftChange={setDraft} - /> -
- setEditorDiscardDialogOpen(false)} - onConfirm={handleCloseEditor} - /> -
-
- setDeleteRequest(null)} - onConfirm={() => void handleConfirmedDelete()} - /> -
- - ); - }} -
- ); -} diff --git a/web-next/app/alert/group/page.test.tsx b/web-next/app/alert/group/page.test.tsx deleted file mode 100644 index c419ecad65..0000000000 --- a/web-next/app/alert/group/page.test.tsx +++ /dev/null @@ -1,439 +0,0 @@ -// @vitest-environment jsdom - -import { readFileSync } from 'node:fs'; -import { resolve } from 'node:path'; -import React from 'react'; -import { act } from 'react'; -import { createRoot, type Root } from 'react-dom/client'; -import { renderToStaticMarkup } from 'react-dom/server'; -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { createTranslatorMock } from '../../../test/i18n-test-helper'; -import type { AlertGroupRouteState } from '../../../lib/alert-group/query-state'; - -const mockState = vi.hoisted(() => ({ - lastLoad: null as null | (() => Promise), - lastSurfaceProps: null as null | Record, - currentSearchParams: '', - routerReplace: vi.fn(), - renderData: { - list: { - totalElements: 1, - content: [ - { - id: 7, - name: 'ops-group', - enable: true, - groupLabels: ['alertname', 'service'], - groupWait: 30, - groupInterval: 300, - repeatInterval: 14400, - gmtUpdate: 1713200000000 - } - ], - pageIndex: 0, - pageSize: 8 - }, - labelOptions: { - keys: ['alertname', 'service', 'severity'], - valuesByKey: { - severity: ['critical', 'warning'] - } - } - } -})); - -const apiMessageGet = vi.hoisted(() => vi.fn()); - -(globalThis as { React?: typeof React }).React = React; -(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; - -vi.mock('next/navigation', () => ({ - useRouter: () => ({ - replace: mockState.routerReplace - }), - useSearchParams: () => new URLSearchParams(mockState.currentSearchParams) -})); - -vi.mock('../../../components/providers/i18n-provider', () => ({ - useI18n: () => ({ - t: createTranslatorMock() - }) -})); - -vi.mock('../../../components/workbench/client-workbench', () => ({ - ClientWorkbench: ({ - children, - load, - loadingCopy - }: { - children: (data: any) => React.ReactNode; - load: () => Promise; - loadingCopy?: string; - }) => { - mockState.lastLoad = load; - return ( -
- {children(mockState.renderData)} -
- ); - } -})); - -vi.mock('../../../components/pages/alert-group-surface', () => ({ - AlertGroupSurface: (props: any) => { - const { - data, - labelOptions, - evidenceContext, - draft, - pageSizeOptions, - search - } = props; - mockState.lastSurfaceProps = props; - return ( -
- ); - } -})); - -vi.mock('../../../lib/api-client', () => ({ - apiMessageDelete: vi.fn(), - apiMessageGet, - apiMessagePost: vi.fn(), - apiMessagePut: vi.fn() -})); - -const EMPTY_ROUTE_STATE: AlertGroupRouteState = { - signal: null, - signalContext: {} -}; - -async function renderAlertGroupPage(initialRouteState: AlertGroupRouteState = EMPTY_ROUTE_STATE) { - const { default: AlertGroupPage } = await import('./alert-group-page'); - return renderToStaticMarkup(); -} - -describe('alert group page', () => { - let interactionContainer: HTMLDivElement | null = null; - let interactionRoot: Root | null = null; - - afterEach(() => { - if (interactionRoot) { - act(() => { - interactionRoot?.unmount(); - }); - } - interactionRoot = null; - interactionContainer?.remove(); - interactionContainer = null; - }); - - beforeEach(() => { - mockState.lastLoad = null; - mockState.lastSurfaceProps = null; - mockState.currentSearchParams = ''; - mockState.routerReplace.mockReset(); - apiMessageGet.mockClear().mockResolvedValue(mockState.renderData.list); - }); - - it('loads the group workspace through the shared query and surface contracts', async () => { - const html = await renderAlertGroupPage(); - - expect(html).toContain('data-alert-group-surface="true"'); - expect(html).toContain('data-label-options="alertname|service|severity"'); - expect(html).toContain('data-page-size-options="8|15|25"'); - expect(html).toContain('data-search=""'); - expect(html).toContain('data-loading-copy="Loading group rules"'); - - await mockState.lastLoad?.(); - - expect(apiMessageGet).toHaveBeenCalledWith('/alert/groups?pageIndex=0&pageSize=8&sort=id&order=desc'); - expect(apiMessageGet).toHaveBeenCalledWith('/label?pageIndex=0&pageSize=9999'); - }); - - it('preserves three-signal evidence context into new grouping authoring', async () => { - const initialRouteState: AlertGroupRouteState = { - signal: 'metrics', - signalContext: { - source: 'otlp', - entityId: 'service:commerce/checkout', - entityName: 'checkout-api', - serviceName: 'checkout', - serviceNamespace: 'commerce', - environment: 'prod', - traceId: 'trace-123', - spanId: 'span-456', - collector: 'edge-collector-a', - template: 'java-service', - alertQueryType: 'metrics', - returnTo: '/metrics/manage?entityId=service%3Acommerce%2Fcheckout' - } - }; - - const html = await renderAlertGroupPage(initialRouteState); - - expect(html).toContain('data-alert-group-evidence-context="signal-route"'); - expect(html).toContain('data-alert-group-evidence-signal="metrics"'); - expect(html).toContain('data-alert-group-evidence-return="/metrics/manage?entityId=service%3Acommerce%2Fcheckout"'); - expect(html).toContain('data-alert-group-prefill-labels="hertzbeat.signal, hertzbeat.entity.id, service.name, service.namespace, deployment.environment, hertzbeat.source, hertzbeat.collector, hertzbeat.alert.query_type"'); - expect(html).toContain('data-alert-group-draft-labels="hertzbeat.signal, hertzbeat.entity.id, service.name, service.namespace, deployment.environment, hertzbeat.source, hertzbeat.collector, hertzbeat.alert.query_type"'); - expect(html).not.toContain('returnLabel='); - expect(html).not.toContain('trace_id'); - expect(html).not.toContain('span_id'); - const source = readFileSync(resolve(process.cwd(), 'app/alert/group/alert-group-page.tsx'), 'utf8'); - expect(source).toContain("import { useRouter, useSearchParams } from 'next/navigation';"); - expect(source).not.toContain('readSignalRouteContext(searchParams)'); - expect(source).toContain('const alertGroupRouteState = initialRouteState ?? EMPTY_ALERT_GROUP_ROUTE_STATE'); - }); - - it('keeps alert group remounts on a short settled cache window with refresh-tick invalidation', () => { - const source = readFileSync(resolve(process.cwd(), 'app/alert/group/alert-group-page.tsx'), 'utf8'); - - expect(source).toContain('ALERT_GROUP_SETTLED_CACHE_TTL_MS = 10_000'); - expect(source).toContain('const [refreshTick, setRefreshTick] = useState(0)'); - expect(source).toContain('const [pageIndex, setPageIndex] = useState(routeListState.pageIndex)'); - expect(source).toContain('const [pageSize, setPageSize] = useState(routeListState.pageSize)'); - expect(source).toContain("['alert-group', alertGroupListUrl, refreshTick].join('|')"); - expect(source).toContain('buildAlertGroupUrl({ search: query, pageIndex, pageSize })'); - expect(source).toContain('[pageIndex, pageSize, query]'); - expect(source).toContain('[alertGroupListUrl, refreshTick]'); - expect(source).toContain('loadAlertGroupDataFromFacade'); - expect(source).toContain('list: api.alertGroups.list'); - expect(source).toContain('labelOptions: () => loadAlertLabelOptionsFromFacade(api.alertLabels.list)'); - expect(source).not.toContain('apiMessageGet>(alertGroupListUrl)'); - expect(source).toContain('return { ...data, refreshTick };'); - expect(source.match(/setRefreshTick\(value => value \+ 1\)/g)?.length).toBeGreaterThanOrEqual(4); - expect(source).toContain('cacheKey={alertGroupCacheKey}'); - expect(source).toContain('cacheSettledTtlMs={ALERT_GROUP_SETTLED_CACHE_TTL_MS}'); - expect(source).toContain('pageSizeOptions={[...ALERT_GROUP_PAGE_SIZE_OPTIONS]}'); - expect(source).toContain('function handlePageIndexChange(nextPageIndex: number)'); - expect(source).toContain('function handlePageSizeChange(nextPageSize: number)'); - expect(source).toContain('setPageIndex(0);'); - }); - - it('initializes alert group list state from the route and preserves URL state during search and pagination', async () => { - mockState.currentSearchParams = 'search=ops&pageIndex=2&pageSize=15&signal=metrics&entityId=7'; - const { default: AlertGroupPage } = await import('./alert-group-page'); - interactionContainer = document.createElement('div'); - document.body.appendChild(interactionContainer); - interactionRoot = createRoot(interactionContainer); - - await act(async () => { - interactionRoot?.render(); - await Promise.resolve(); - }); - - expect(mockState.lastSurfaceProps?.search).toBe('ops'); - expect(mockState.lastSurfaceProps?.requestedPageSize).toBe(15); - await act(async () => { - await mockState.lastLoad?.(); - }); - expect(apiMessageGet).toHaveBeenCalledWith('/alert/groups?pageIndex=2&pageSize=15&sort=id&order=desc&search=ops'); - - await act(async () => { - mockState.lastSurfaceProps?.onSearchChange('checkout'); - await Promise.resolve(); - }); - await act(async () => { - mockState.lastSurfaceProps?.onApplyFilter(); - await Promise.resolve(); - }); - - expect(mockState.routerReplace).toHaveBeenLastCalledWith('/alert/group?search=checkout&pageSize=15&signal=metrics&entityId=7', { scroll: false }); - - mockState.currentSearchParams = 'search=checkout&pageSize=15&signal=metrics&entityId=7'; - await act(async () => { - interactionRoot?.render(); - await Promise.resolve(); - }); - await act(async () => { - mockState.lastSurfaceProps?.onPageIndexChange(3); - await Promise.resolve(); - }); - - expect(mockState.routerReplace).toHaveBeenLastCalledWith('/alert/group?search=checkout&pageSize=15&signal=metrics&entityId=7&pageIndex=3', { scroll: false }); - - mockState.currentSearchParams = 'search=checkout&pageSize=15&signal=metrics&entityId=7'; - await act(async () => { - interactionRoot?.render(); - await Promise.resolve(); - }); - await act(async () => { - mockState.lastSurfaceProps?.onPageSizeChange(8); - await Promise.resolve(); - }); - - expect(mockState.routerReplace).toHaveBeenLastCalledWith('/alert/group?search=checkout&signal=metrics&entityId=7', { scroll: false }); - - mockState.currentSearchParams = 'search=checkout&pageSize=15&signal=metrics&entityId=7'; - await act(async () => { - interactionRoot?.render(); - await Promise.resolve(); - }); - await act(async () => { - mockState.lastSurfaceProps?.onClearFilter(); - await Promise.resolve(); - }); - - expect(mockState.routerReplace).toHaveBeenLastCalledWith('/alert/group?signal=metrics&entityId=7', { scroll: false }); - }); - - it('keeps Angular no-selection batch delete warning before opening confirm', () => { - const source = readFileSync(resolve(process.cwd(), 'app/alert/group/alert-group-page.tsx'), 'utf8'); - - expect(source).toContain('if (checkedIds.length === 0)'); - expect(source).toContain("setEditorError(t('common.notify.no-select-delete'))"); - expect(source).toContain('setEditorMessage(null)'); - expect(source).toContain("setDeleteRequest({ kind: 'batch', ids: checkedIds })"); - }); - - it('uses Angular edit notifications for enable toggle feedback', () => { - const source = readFileSync(resolve(process.cwd(), 'app/alert/group/alert-group-page.tsx'), 'utf8'); - - expect(source).toContain('async function handleToggleEnabled'); - expect(source).toContain('updateAlertGroupEnabledFromFacade(api.alertGroups.update'); - expect(source).toContain("setEditorMessage(t('common.notify.edit-success'))"); - expect(source).toContain("setEditorError(t('common.notify.edit-fail'))"); - expect(source).toContain("setEditorErrorContract('enable')"); - expect(source).not.toContain("setEditorMessage(t('common.save-success'));\n setEditorError(null);\n setRefreshTick(value => value + 1);\n } catch (error) {\n setEditorError(error instanceof Error ? error.message : t('common.save-failed'))"); - }); - - it('uses Angular create/edit notifications for editor save feedback', () => { - const source = readFileSync(resolve(process.cwd(), 'app/alert/group/alert-group-page.tsx'), 'utf8'); - - expect(source).toContain('const isEdit = Boolean(draft.id)'); - expect(source).toContain('updateAlertGroupFromFacade(api.alertGroups.update, draft)'); - expect(source).toContain('createAlertGroupFromFacade(api.alertGroups.create, draft)'); - expect(source).toContain("setEditorMessage(t(isEdit ? 'common.notify.edit-success' : 'common.notify.new-success'))"); - expect(source).toContain("setEditorError(t(isEdit ? 'common.notify.edit-fail' : 'common.notify.new-fail'))"); - expect(source).toContain('setEditorErrorDetail(error instanceof Error ? error.message : null)'); - expect(source).not.toContain("setEditorMessage(t('common.save-success'))"); - expect(source).not.toContain("t('common.save-failed')"); - }); - - it('clears stale editor validation when the group editor is closed', () => { - const source = readFileSync(resolve(process.cwd(), 'app/alert/group/alert-group-page.tsx'), 'utf8'); - - expect(source).toContain('function handleCloseEditor()'); - expect(source).toContain('setEditorOpen(false);'); - expect(source).toContain('setEditorError(null);'); - expect(source).toContain('setEditorErrorDetail(null);'); - expect(source).toContain('setEditorErrorContract(null);'); - expect(source).toContain('setEditorMessage(null);'); - expect(source).toContain('function requestCloseEditor()'); - expect(source).toContain('setEditorDiscardDialogOpen(true)'); - expect(source).toContain('onCloseEditor={requestCloseEditor}'); - expect(source).not.toContain('onCloseEditor={() => setEditorOpen(false)}'); - }); - - it('asks for confirmation before closing a dirty group editor draft', async () => { - const { default: AlertGroupPage } = await import('./alert-group-page'); - interactionContainer = document.createElement('div'); - document.body.appendChild(interactionContainer); - interactionRoot = createRoot(interactionContainer); - - await act(async () => { - interactionRoot?.render(); - await Promise.resolve(); - }); - - await act(async () => { - mockState.lastSurfaceProps?.onNew(); - await Promise.resolve(); - }); - - const draft = mockState.lastSurfaceProps?.draft; - expect(mockState.lastSurfaceProps?.editorOpen).toBe(true); - - await act(async () => { - mockState.lastSurfaceProps?.onDraftChange({ ...draft, name: 'Unsaved group draft' }); - await Promise.resolve(); - }); - - await act(async () => { - mockState.lastSurfaceProps?.onCloseEditor(); - await Promise.resolve(); - }); - - expect(mockState.lastSurfaceProps?.editorOpen).toBe(true); - expect(interactionContainer.innerHTML).toContain('data-alert-group-unsaved-cancel-state="open"'); - expect(interactionContainer.innerHTML).toContain('Discard unsaved grouping changes?'); - }); - - it('uses Angular edit failure notifications when editor detail loading fails', () => { - const source = readFileSync(resolve(process.cwd(), 'app/alert/group/alert-group-page.tsx'), 'utf8'); - - expect(source).toContain('async function handleEdit'); - expect(source).toContain('const detail = await loadAlertGroupDetailFromFacade(api.alertGroups.detail, targetId)'); - expect(source).toContain("setEditorError(error instanceof Error ? error.message : t('common.notify.edit-fail'))"); - expect(source).not.toContain('loadAlertGroupDetail(apiMessageGet, targetId)'); - expect(source).not.toContain("t('common.load-failed')"); - }); - - it('validates Angular required timer fields before saving alert groups', () => { - const source = readFileSync(resolve(process.cwd(), 'lib/alert-group/view-model.ts'), 'utf8'); - - expect(source).toContain('if (!draft.groupWait.trim())'); - expect(source).toContain("t('alert.group.validation.group-wait')"); - expect(source).toContain('if (!draft.groupInterval.trim())'); - expect(source).toContain("t('alert.group.validation.group-interval')"); - expect(source).toContain('if (!draft.repeatInterval.trim())'); - expect(source).toContain("t('alert.group.validation.repeat-interval')"); - }); - - it('focuses the first invalid editor field after a failed novice save attempt', () => { - const source = readFileSync(resolve(process.cwd(), 'app/alert/group/alert-group-page.tsx'), 'utf8'); - - expect(source).toContain('ALERT_GROUP_EDITOR_FOCUS_SELECTORS'); - expect(source).toContain("'group-labels': '[data-alert-group-label-selector] input[data-hz-tag-input-control=\"draft\"]'"); - expect(source).toContain('function focusAlertGroupEditorField(field: AlertGroupValidationField)'); - expect(source).toContain('const validationField = getAlertGroupValidationField(draft)'); - expect(source).toContain('focusAlertGroupEditorField(validationField)'); - }); - - it('validates Angular non-negative timer fields before saving alert groups', () => { - const source = readFileSync(resolve(process.cwd(), 'lib/alert-group/view-model.ts'), 'utf8'); - - expect(source).toContain('function isNonNegativeTimer(value: string)'); - expect(source).toContain('Number.parseInt(value, 10)'); - expect(source).toContain('return Number.isFinite(parsed) && parsed >= 0'); - expect(source).toContain("t('alert.group.validation.group-wait-non-negative')"); - expect(source).toContain("t('alert.group.validation.group-interval-non-negative')"); - expect(source).toContain("t('alert.group.validation.repeat-interval-non-negative')"); - }); - - it('uses Angular delete notifications for confirmed delete feedback', () => { - const source = readFileSync(resolve(process.cwd(), 'app/alert/group/alert-group-page.tsx'), 'utf8'); - - expect(source).toContain('async function handleConfirmedDelete()'); - expect(source).toContain('deleteAlertGroupsFromFacade(api.alertGroups.delete, request.ids)'); - expect(source).toContain('deleteAlertGroupFromFacade(api.alertGroups.delete, request.ids[0])'); - expect(source).toContain("setEditorMessage(t('common.notify.delete-success'))"); - expect(source).toContain("setEditorError(t('common.notify.delete-fail'))"); - expect(source).toContain("setEditorErrorContract('delete')"); - expect(source).not.toContain("setEditorMessage(t('common.delete-success'))"); - expect(source).not.toContain("t('common.delete-failed')"); - }); - - it('names the target and destructive action in group delete confirmation', () => { - const source = readFileSync(resolve(process.cwd(), 'app/alert/group/alert-group-page.tsx'), 'utf8'); - - expect(source).toContain('const deleteTargetNames = deleteRequest?.ids'); - expect(source).toContain("t('alert.group.delete.confirm.targets', { names: deleteTargetNames.join(', ') })"); - expect(source).toContain("t('alert.group.delete.confirm.targets-more', { count: missingDeleteTargetCount })"); - expect(source).toContain("confirmLabel={t('alert.group.delete.confirm.action')}"); - expect(source).not.toContain("confirmLabel={t('common.button.ok')}"); - }); -}); diff --git a/web-next/app/alert/group/page.tsx b/web-next/app/alert/group/page.tsx deleted file mode 100644 index 116e421429..0000000000 --- a/web-next/app/alert/group/page.tsx +++ /dev/null @@ -1,14 +0,0 @@ -import React from 'react'; - -import AlertGroupPage from './alert-group-page'; -import { readAlertGroupRouteState, type AlertGroupSearchParams } from '../../../lib/alert-group/query-state'; - -export default async function AlertGroupRoutePage({ - searchParams -}: { - searchParams?: Promise; -}) { - const resolvedSearchParams = await searchParams; - const routeState = readAlertGroupRouteState(resolvedSearchParams); - return ; -} diff --git a/web-next/app/alert/inhibit/alert-inhibit-page.tsx b/web-next/app/alert/inhibit/alert-inhibit-page.tsx deleted file mode 100644 index 7b21acec93..0000000000 --- a/web-next/app/alert/inhibit/alert-inhibit-page.tsx +++ /dev/null @@ -1,664 +0,0 @@ -'use client'; - -import React from 'react'; -import { useCallback, useEffect, useMemo, useState } from 'react'; -import { useRouter, useSearchParams } from 'next/navigation'; -import { ClientWorkbench } from '../../../components/workbench/client-workbench'; -import { useI18n } from '../../../components/providers/i18n-provider'; -import { AlertInhibitSurface } from '../../../components/pages/alert-inhibit-surface'; -import { HzConfirmDialog } from '../../../components/ui/hz-confirm-dialog'; -import { api } from '../../../lib/alert-api-facade'; -import { - buildAlertInhibitEntityPrefillFromFacade, - buildAlertInhibitFormDraft, - createAlertInhibitFromFacade, - deleteAlertInhibitFromFacade, - deleteAlertInhibitsFromFacade, - loadAlertInhibitDataFromFacade, - loadAlertInhibitDetailFromFacade, - loadMatchedAlertInhibitsFromFacade, - updateAlertInhibitEnabledFromFacade, - updateAlertInhibitFromFacade, - type AlertInhibitFormDraft -} from '../../../lib/alert-inhibit/controller'; -import { ALERT_INHIBIT_PAGE_SIZE_OPTIONS, buildAlertInhibitUrl, type AlertInhibitRouteState } from '../../../lib/alert-inhibit/query-state'; -import { - buildAlertInhibitEvidenceContext, - getAlertInhibitValidationField, - validateAlertInhibitForm, - type AlertInhibitValidationField -} from '../../../lib/alert-inhibit/view-model'; -import { clearAlertInhibitEqualLabels, clearAlertInhibitTarget, copyAlertInhibitSourceToTarget, dropSeverityFromAlertInhibitTarget } from '../../../lib/alert-manage/view-model'; -import { DEFAULT_ALERT_LABEL_OPTIONS, loadAlertLabelOptionsFromFacade } from '../../../lib/alert-label-options'; -import { formatTime } from '../../../lib/format'; -import type { AlertInhibit, PageResult } from '../../../lib/types'; -import type { AlertInhibitManagementContext } from '../../../lib/alert-inhibit/query-state'; -import type { SignalRouteContext } from '../../../lib/signal-route-context'; - -type InhibitDeleteRequest = { - kind: 'single' | 'batch'; - ids: number[]; -}; - -const ALERT_INHIBIT_SETTLED_CACHE_TTL_MS = 10_000; -const ALERT_INHIBIT_LABEL_OPTIONS_TIMEOUT_MS = 2_500; -const ALERT_INHIBIT_ROUTE_PATH = '/alert/inhibit'; -const ALERT_INHIBIT_EDITOR_FOCUS_SELECTORS: Record = { - name: 'input[name="inhibit_name"]', - 'source-labels': '[data-alert-inhibit-source-label-selector] [data-hz-label-selector-draft-row="true"] input[data-hz-label-selector-key-input="searchable-key"]', - 'target-labels': '[data-alert-inhibit-target-label-selector] [data-hz-label-selector-draft-row="true"] input[data-hz-label-selector-key-input="searchable-key"]', - 'equal-labels': '[data-alert-inhibit-equal-label-selector] input[data-hz-tag-input-control="draft"]' -}; -const ALERT_INHIBIT_DRAFT_FINGERPRINT_FIELDS: Array = [ - 'id', - 'name', - 'enable', - 'sourceLabelsText', - 'targetLabelsText', - 'equalLabelsText' -]; -const EMPTY_ALERT_INHIBIT_ROUTE_STATE: AlertInhibitRouteState = { - returnContext: { - search: '', - status: '', - severity: '', - entityId: '', - entityName: '', - returnTo: '' - }, - signal: null, - signalContext: {}, - managementContext: { - entityId: '', - entityName: '', - returnTo: '', - returnLabel: '', - matchMode: '', - matchingRuleType: '', - matchingRuleIds: [], - matchedViewEnabled: false - } -}; - -type AlertInhibitListRouteState = { - search: string; - pageIndex: number; - pageSize: number; -}; - -function parseAlertInhibitRouteInteger(value: string | null, fallback: number, minimum = 0) { - if (!value) return fallback; - const parsed = Number.parseInt(value, 10); - return Number.isFinite(parsed) && parsed >= minimum ? parsed : fallback; -} - -function filterMatchedInhibitsBySearch(inhibits: AlertInhibit[], search: string): AlertInhibit[] { - const keyword = search.trim().toLowerCase(); - if (!keyword) return inhibits; - return inhibits.filter(inhibit => { - if (inhibit.name?.toLowerCase().includes(keyword)) { - return true; - } - const sourceHit = Object.entries(inhibit.sourceLabels || {}).some(([key, value]) => `${key}:${value}`.toLowerCase().includes(keyword)); - const targetHit = Object.entries(inhibit.targetLabels || {}).some(([key, value]) => `${key}:${value}`.toLowerCase().includes(keyword)); - const equalHit = (inhibit.equalLabels || []).some(label => label.toLowerCase().includes(keyword)); - return sourceHit || targetHit || equalHit; - }); -} - -function paginateMatchedInhibits(inhibits: AlertInhibit[], pageIndex: number, pageSize: number): PageResult { - const normalizedPageSize = Math.max(1, pageSize); - const lastPageIndex = Math.max(0, Math.ceil(inhibits.length / normalizedPageSize) - 1); - const normalizedPageIndex = Math.min(Math.max(0, pageIndex), lastPageIndex); - const start = normalizedPageIndex * normalizedPageSize; - return { - content: inhibits.slice(start, start + normalizedPageSize), - totalElements: inhibits.length, - pageIndex: normalizedPageIndex, - pageSize: normalizedPageSize - }; -} - -function shouldUseMatchedInhibitView(context: AlertInhibitManagementContext, matchedViewEnabled: boolean) { - return matchedViewEnabled && context.matchMode === 'entity-noise-controls'; -} - -function withTimeoutFallback(promise: Promise, fallback: T, timeoutMs: number): Promise { - return new Promise(resolve => { - const timer = setTimeout(() => resolve(fallback), timeoutMs); - promise.then( - value => { - clearTimeout(timer); - resolve(value); - }, - () => { - clearTimeout(timer); - resolve(fallback); - } - ); - }); -} - -function focusAlertInhibitEditorField(field: AlertInhibitValidationField) { - if (typeof document === 'undefined') return; - const selector = ALERT_INHIBIT_EDITOR_FOCUS_SELECTORS[field]; - window.requestAnimationFrame(() => { - const target = document.querySelector(selector); - target?.focus(); - target?.scrollIntoView({ block: 'center', inline: 'nearest' }); - }); -} - -function serializeAlertInhibitDraft(draft: AlertInhibitFormDraft) { - return JSON.stringify( - ALERT_INHIBIT_DRAFT_FINGERPRINT_FIELDS.map(field => [field, draft[field] == null ? '' : String(draft[field]).trim()]) - ); -} - -function buildAlertInhibitReturnEvidenceContext(returnContext: AlertInhibitRouteState['returnContext']): SignalRouteContext { - return { - entityId: returnContext.entityId, - entityName: returnContext.entityName, - returnTo: returnContext.returnTo, - serviceName: returnContext.serviceName, - serviceNamespace: returnContext.serviceNamespace, - environment: returnContext.environment, - timeRange: returnContext.timeRange, - start: returnContext.start, - end: returnContext.end, - refresh: returnContext.refresh, - live: returnContext.live, - tz: returnContext.tz, - source: returnContext.source, - monitorId: returnContext.monitorId, - monitorName: returnContext.monitorName, - monitorApp: returnContext.monitorApp, - monitorInstance: returnContext.monitorInstance, - traceId: returnContext.traceId, - spanId: returnContext.spanId, - collector: returnContext.collector, - template: returnContext.template - }; -} - -export default function AlertInhibitPage({ initialRouteState }: { initialRouteState?: AlertInhibitRouteState } = {}) { - const { t } = useI18n(); - const router = useRouter(); - const searchParams = useSearchParams(); - const routeSearchParamString = searchParams.toString(); - const routeSearch = searchParams.get('search') ?? ''; - const routePageIndex = parseAlertInhibitRouteInteger(searchParams.get('pageIndex'), 0); - const routePageSize = parseAlertInhibitRouteInteger(searchParams.get('pageSize'), ALERT_INHIBIT_PAGE_SIZE_OPTIONS[0], 1); - const routeListState = useMemo(() => ({ - search: routeSearch, - pageIndex: routePageIndex, - pageSize: routePageSize - }), [routePageIndex, routePageSize, routeSearch]); - const alertInhibitRouteState = initialRouteState ?? EMPTY_ALERT_INHIBIT_ROUTE_STATE; - const { returnContext, signal, signalContext, managementContext } = alertInhibitRouteState; - const inhibitEvidenceRouteContext = useMemo( - () => signal ? signalContext : buildAlertInhibitReturnEvidenceContext(returnContext), - [returnContext, signal, signalContext] - ); - const inhibitEvidenceContext = useMemo( - () => buildAlertInhibitEvidenceContext(signal, inhibitEvidenceRouteContext, t), - [signal, inhibitEvidenceRouteContext, t] - ); - const [search, setSearch] = useState(routeListState.search); - const [query, setQuery] = useState(routeListState.search); - const [pageIndex, setPageIndex] = useState(routeListState.pageIndex); - const [pageSize, setPageSize] = useState(routeListState.pageSize); - const [selectedId, setSelectedId] = useState(null); - const [editorOpen, setEditorOpen] = useState(false); - const [editorLoading, setEditorLoading] = useState(false); - const [editorSaving, setEditorSaving] = useState(false); - const [editorMessage, setEditorMessage] = useState(null); - const [editorError, setEditorError] = useState(null); - const [editorErrorDetail, setEditorErrorDetail] = useState(null); - const [editorErrorContract, setEditorErrorContract] = useState<'save' | 'enable' | 'delete' | null>(null); - const [draft, setDraft] = useState(() => buildAlertInhibitFormDraft(null, inhibitEvidenceContext?.draftPatch)); - const [editorInitialFingerprint, setEditorInitialFingerprint] = useState(() => ( - serializeAlertInhibitDraft(buildAlertInhibitFormDraft(null, inhibitEvidenceContext?.draftPatch)) - )); - const [editorDiscardDialogOpen, setEditorDiscardDialogOpen] = useState(false); - const [refreshTick, setRefreshTick] = useState(0); - const [checkedIds, setCheckedIds] = useState([]); - const [deleteRequest, setDeleteRequest] = useState(null); - const [deletePending, setDeletePending] = useState(false); - const [matchedViewEnabled, setMatchedViewEnabled] = useState(managementContext.matchedViewEnabled); - const [createdOutsideMatchedViewNotice, setCreatedOutsideMatchedViewNotice] = useState(false); - const [entityPrefillSource, setEntityPrefillSource] = useState<'alerts-common-labels' | 'none'>('none'); - const [entityPrefillWarning, setEntityPrefillWarning] = useState(null); - const alertInhibitListUrl = useMemo(() => buildAlertInhibitUrl({ search: query, pageIndex, pageSize }), [pageIndex, pageSize, query]); - const matchedRuleIdsKey = managementContext.matchingRuleIds.join(','); - const useMatchedView = shouldUseMatchedInhibitView(managementContext, matchedViewEnabled); - const alertInhibitCacheKey = useMemo( - () => ['alert-inhibit', useMatchedView ? `matched:${matchedRuleIdsKey}` : alertInhibitListUrl, refreshTick].join('|'), - [alertInhibitListUrl, matchedRuleIdsKey, refreshTick, useMatchedView] - ); - const editorDraftFingerprint = useMemo(() => serializeAlertInhibitDraft(draft), [draft]); - const shouldConfirmEditorDiscard = Boolean(editorOpen && editorDraftFingerprint !== editorInitialFingerprint && !editorSaving); - - useEffect(() => { - setSearch(routeListState.search); - setQuery(routeListState.search); - setPageIndex(routeListState.pageIndex); - setPageSize(routeListState.pageSize); - setSelectedId(null); - setCheckedIds([]); - }, [routeListState]); - - const replaceRouteQuery = useCallback((nextState: AlertInhibitListRouteState) => { - const nextParams = new URLSearchParams(routeSearchParamString); - const cleanSearch = nextState.search.trim(); - if (cleanSearch) { - nextParams.set('search', cleanSearch); - } else { - nextParams.delete('search'); - } - - if (nextState.pageIndex > 0) { - nextParams.set('pageIndex', String(nextState.pageIndex)); - } else { - nextParams.delete('pageIndex'); - } - - if (nextState.pageSize !== ALERT_INHIBIT_PAGE_SIZE_OPTIONS[0]) { - nextParams.set('pageSize', String(nextState.pageSize)); - } else { - nextParams.delete('pageSize'); - } - - const nextParamString = nextParams.toString(); - const nextUrl = nextParamString ? `${ALERT_INHIBIT_ROUTE_PATH}?${nextParamString}` : ALERT_INHIBIT_ROUTE_PATH; - const currentUrl = routeSearchParamString ? `${ALERT_INHIBIT_ROUTE_PATH}?${routeSearchParamString}` : ALERT_INHIBIT_ROUTE_PATH; - if (nextUrl !== currentUrl) { - router.replace(nextUrl, { scroll: false }); - } - }, [routeSearchParamString, router]); - - const load = useCallback(async () => { - const labelOptionsPromise = withTimeoutFallback( - loadAlertLabelOptionsFromFacade(api.alertLabels.list), - DEFAULT_ALERT_LABEL_OPTIONS, - ALERT_INHIBIT_LABEL_OPTIONS_TIMEOUT_MS - ); - if (useMatchedView) { - const [matchedResult, labelOptions] = await Promise.all([ - loadMatchedAlertInhibitsFromFacade(api.alertInhibits.detail, managementContext.matchingRuleIds), - labelOptionsPromise - ]); - const filtered = filterMatchedInhibitsBySearch(matchedResult.matched, query); - return { - list: paginateMatchedInhibits(filtered, pageIndex, pageSize), - labelOptions, - refreshTick, - missingMatchedRuleCount: matchedResult.missingMatchedRuleCount - }; - } - - const data = await loadAlertInhibitDataFromFacade( - { - list: api.alertInhibits.list, - labelOptions: () => labelOptionsPromise - }, - { search: query, pageIndex, pageSize } - ); - return { ...data, refreshTick, missingMatchedRuleCount: 0 }; - }, [managementContext.matchingRuleIds, pageIndex, pageSize, query, refreshTick, useMatchedView]); - - return ( - - {data => { - const labelOptions = data.labelOptions ?? DEFAULT_ALERT_LABEL_OPTIONS; - const selected = data.list.content.find(item => item.id === selectedId) ?? data.list.content[0] ?? null; - - async function handleNew() { - const displayName = managementContext.entityName || managementContext.returnLabel || managementContext.entityId || 'entity'; - const entityContextDraft = managementContext.entityId || managementContext.entityName || managementContext.returnTo - ? { name: `${displayName} inhibit` } - : {}; - const baseDraftPatch = inhibitEvidenceContext?.draftPatch ?? entityContextDraft; - setEntityPrefillSource('none'); - setEntityPrefillWarning(null); - if (managementContext.entityId || managementContext.entityName || managementContext.returnTo) { - setEditorLoading(true); - try { - const prefill = await buildAlertInhibitEntityPrefillFromFacade( - entityId => api.entities.alerts(entityId, { pageIndex: 0, pageSize: 20, status: 'firing' }), - managementContext.entityId, - t('entity.noise-controls.authoring.inhibit.prefill-warning'), - t('entity.noise-controls.authoring.prefill-warning.no-entity-id') - ); - setEntityPrefillSource(prefill.source); - setEntityPrefillWarning(prefill.warning); - const nextDraft = buildAlertInhibitFormDraft(null, { ...baseDraftPatch, ...prefill.draftPatch }); - setDraft(nextDraft); - setEditorInitialFingerprint(serializeAlertInhibitDraft(nextDraft)); - } finally { - setEditorLoading(false); - } - } else { - const nextDraft = buildAlertInhibitFormDraft(null, baseDraftPatch); - setDraft(nextDraft); - setEditorInitialFingerprint(serializeAlertInhibitDraft(nextDraft)); - } - setEditorDiscardDialogOpen(false); - setEditorError(null); - setEditorErrorDetail(null); - setEditorErrorContract(null); - setEditorMessage(null); - setCreatedOutsideMatchedViewNotice(false); - setEditorOpen(true); - } - - async function handleEdit(inhibitId?: number) { - const targetId = inhibitId ?? selected?.id; - if (!targetId) return; - setEditorLoading(true); - setEditorError(null); - setEditorErrorDetail(null); - setEditorErrorContract(null); - setEditorMessage(null); - try { - const detail = await loadAlertInhibitDetailFromFacade(api.alertInhibits.detail, targetId); - const nextDraft = buildAlertInhibitFormDraft(detail); - setDraft(nextDraft); - setEditorInitialFingerprint(serializeAlertInhibitDraft(nextDraft)); - setEditorDiscardDialogOpen(false); - setEditorOpen(true); - } catch (error) { - setEditorError(error instanceof Error ? error.message : t('common.notify.edit-fail')); - setEditorErrorDetail(null); - setEditorErrorContract(null); - } finally { - setEditorLoading(false); - } - } - - async function handleSave() { - const validationError = validateAlertInhibitForm(draft, t); - if (validationError) { - const validationField = getAlertInhibitValidationField(draft); - setEditorError(validationError); - setEditorErrorDetail(null); - setEditorErrorContract(null); - if (validationField) { - focusAlertInhibitEditorField(validationField); - } - return; - } - setEditorSaving(true); - setEditorError(null); - setEditorErrorDetail(null); - setEditorErrorContract(null); - setEditorMessage(null); - const isEdit = Boolean(draft.id); - try { - if (isEdit) { - await updateAlertInhibitFromFacade(api.alertInhibits.update, draft); - } else { - await createAlertInhibitFromFacade(api.alertInhibits.create, draft); - } - setEditorInitialFingerprint(serializeAlertInhibitDraft(draft)); - setEditorMessage(t(isEdit ? 'common.notify.edit-success' : 'common.notify.new-success')); - setEditorOpen(false); - setEditorDiscardDialogOpen(false); - setCreatedOutsideMatchedViewNotice(!isEdit && useMatchedView); - setRefreshTick(value => value + 1); - } catch (error) { - setEditorError(t(isEdit ? 'common.notify.edit-fail' : 'common.notify.new-fail')); - setEditorErrorDetail(error instanceof Error ? error.message : null); - setEditorErrorContract('save'); - } finally { - setEditorSaving(false); - } - } - - function handleCloseEditor() { - setEditorOpen(false); - setEditorError(null); - setEditorErrorDetail(null); - setEditorErrorContract(null); - setEditorDiscardDialogOpen(false); - } - - function requestCloseEditor() { - if (shouldConfirmEditorDiscard) { - setEditorDiscardDialogOpen(true); - return; - } - handleCloseEditor(); - } - - async function handleToggleEnabled(inhibit?: AlertInhibit) { - const target = inhibit ?? selected; - if (!target) return; - try { - await updateAlertInhibitEnabledFromFacade(api.alertInhibits.update, target, !(target.enable ?? true)); - setEditorMessage(t('common.notify.edit-success')); - setEditorError(null); - setEditorErrorDetail(null); - setEditorErrorContract(null); - setRefreshTick(value => value + 1); - } catch (error) { - setEditorError(t('common.notify.edit-fail')); - setEditorErrorDetail(error instanceof Error ? error.message : null); - setEditorErrorContract('enable'); - } - } - - async function handleDelete(inhibitId?: number) { - const targetId = inhibitId ?? selected?.id; - if (!targetId) return; - setDeleteRequest({ kind: 'single', ids: [targetId] }); - } - - async function handleConfirmedDelete() { - const request = deleteRequest; - if (!request || request.ids.length === 0) return; - setDeletePending(true); - try { - if (request.kind === 'batch') { - await deleteAlertInhibitsFromFacade(api.alertInhibits.delete, request.ids); - setCheckedIds([]); - } else { - await deleteAlertInhibitFromFacade(api.alertInhibits.delete, request.ids[0]); - } - const nextTotal = Math.max((data.list.totalElements || 0) - request.ids.length, 0); - const nextLastPageIndex = Math.max(0, Math.ceil(nextTotal / pageSize) - 1); - setPageIndex(value => Math.min(value, nextLastPageIndex)); - setSelectedId(null); - setEditorOpen(false); - setEditorMessage(t('common.notify.delete-success')); - setEditorError(null); - setEditorErrorDetail(null); - setEditorErrorContract(null); - setDeleteRequest(null); - setRefreshTick(value => value + 1); - } catch (error) { - setEditorError(t('common.notify.delete-fail')); - setEditorErrorDetail(error instanceof Error ? error.message : null); - setEditorErrorContract('delete'); - } finally { - setDeletePending(false); - } - } - - function handleRefresh() { - setRefreshTick(value => value + 1); - } - - async function handleDeleteSelected() { - if (checkedIds.length === 0) { - setEditorError(t('common.notify.no-select-delete')); - setEditorErrorDetail(null); - setEditorErrorContract(null); - setEditorMessage(null); - return; - } - setEditorError(null); - setEditorErrorDetail(null); - setEditorErrorContract(null); - setDeleteRequest({ kind: 'batch', ids: checkedIds }); - } - - function handleApplyFilter() { - const nextSearch = search.trim(); - const nextState = { search: nextSearch, pageIndex: 0, pageSize }; - setSearch(nextSearch); - setQuery(nextSearch); - setPageIndex(0); - setSelectedId(null); - setCheckedIds([]); - replaceRouteQuery(nextState); - } - - function handleClearFilter() { - const nextState = { search: '', pageIndex: 0, pageSize }; - setSearch(''); - setQuery(''); - setPageIndex(0); - setSelectedId(null); - setCheckedIds([]); - replaceRouteQuery(nextState); - } - - function handlePageIndexChange(nextPageIndex: number) { - setPageIndex(nextPageIndex); - setSelectedId(null); - setCheckedIds([]); - replaceRouteQuery({ search: query, pageIndex: nextPageIndex, pageSize }); - } - - function handlePageSizeChange(nextPageSize: number) { - const nextState = { search: query, pageIndex: 0, pageSize: nextPageSize }; - setPageSize(nextPageSize); - setPageIndex(0); - setSelectedId(null); - setCheckedIds([]); - replaceRouteQuery(nextState); - } - - return ( - <> - {(() => { - const deleteTargetNames = deleteRequest?.ids - .map(id => data.list.content.find(item => item.id === id)?.name?.trim()) - .filter((name): name is string => Boolean(name)) ?? []; - const missingDeleteTargetCount = deleteRequest - ? Math.max(deleteRequest.ids.length - deleteTargetNames.length, 0) - : 0; - const deleteConfirmCopy = [ - deleteRequest?.kind === 'batch' - ? t('alert.inhibit.delete.confirm.batch', { count: deleteRequest.ids.length }) - : t('alert.inhibit.delete.confirm.single'), - deleteTargetNames.length > 0 - ? t('alert.inhibit.delete.confirm.targets', { names: deleteTargetNames.join(', ') }) - : null, - missingDeleteTargetCount > 0 - ? t('alert.inhibit.delete.confirm.targets-more', { count: missingDeleteTargetCount }) - : null - ].filter(Boolean).join('\n'); - - return ( -
- setDeleteRequest(null)} - onConfirm={() => void handleConfirmedDelete()} - /> -
- ); - })()} - { - setMatchedViewEnabled(false); - setCreatedOutsideMatchedViewNotice(false); - setPageIndex(0); - setSelectedId(null); - setCheckedIds([]); - }} - onViewMatchedRules={() => { - setMatchedViewEnabled(true); - setPageIndex(0); - setSelectedId(null); - setCheckedIds([]); - }} - onNew={() => void handleNew()} - onEdit={inhibitId => void handleEdit(inhibitId)} - onSave={() => void handleSave()} - onToggleEnabled={inhibit => void handleToggleEnabled(inhibit)} - onDelete={inhibitId => void handleDelete(inhibitId)} - onDeleteSelected={() => void handleDeleteSelected()} - onCloseEditor={requestCloseEditor} - onDraftChange={setDraft} - onCopySourceToTarget={() => setDraft(prev => copyAlertInhibitSourceToTarget(prev))} - onDropSeverity={() => setDraft(prev => dropSeverityFromAlertInhibitTarget(prev))} - onClearTarget={() => setDraft(prev => clearAlertInhibitTarget(prev))} - onClearEqual={() => setDraft(prev => clearAlertInhibitEqualLabels(prev))} - /> -
- setEditorDiscardDialogOpen(false)} - onConfirm={handleCloseEditor} - /> -
- - ); - }} -
- ); -} diff --git a/web-next/app/alert/inhibit/page.test.tsx b/web-next/app/alert/inhibit/page.test.tsx deleted file mode 100644 index d0c9bbc594..0000000000 --- a/web-next/app/alert/inhibit/page.test.tsx +++ /dev/null @@ -1,650 +0,0 @@ -// @vitest-environment jsdom - -import { readFileSync } from 'node:fs'; -import { resolve } from 'node:path'; -import React from 'react'; -import { act } from 'react'; -import { createRoot, type Root } from 'react-dom/client'; -import { renderToStaticMarkup } from 'react-dom/server'; -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { createTranslatorMock } from '../../../test/i18n-test-helper'; -import type { AlertInhibitRouteState } from '../../../lib/alert-inhibit/query-state'; - -const mockState = vi.hoisted(() => ({ - lastLoad: null as null | (() => Promise), - lastSurfaceProps: null as null | Record, - currentSearchParams: '', - routerReplace: vi.fn(), - renderData: { - list: { - totalElements: 1, - content: [ - { - id: 7, - name: 'db-inhibit', - enable: true, - sourceLabels: { service: 'checkout' }, - targetLabels: { severity: 'warning' }, - equalLabels: ['cluster'], - gmtUpdate: 1713200000000 - } - ], - pageIndex: 0, - pageSize: 8 - } - } -})); - -const apiMessageGet = vi.hoisted(() => vi.fn()); - -(globalThis as { React?: typeof React }).React = React; -(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; - -vi.mock('next/navigation', () => ({ - useRouter: () => ({ - replace: mockState.routerReplace - }), - useSearchParams: () => new URLSearchParams(mockState.currentSearchParams) -})); - -vi.mock('../../../components/providers/i18n-provider', () => ({ - useI18n: () => ({ - t: createTranslatorMock() - }) -})); - -vi.mock('../../../components/workbench/client-workbench', () => ({ - ClientWorkbench: ({ - children, - load, - loadingCopy - }: { - children: (data: any) => React.ReactNode; - load: () => Promise; - loadingCopy?: string; - }) => { - mockState.lastLoad = load; - return ( -
- {children(mockState.renderData)} -
- ); - } -})); - -vi.mock('../../../components/pages/alert-inhibit-surface', () => ({ - AlertInhibitSurface: (props: any) => { - const { - data, - returnContext, - managementContext, - matchedViewEnabled, - missingMatchedRuleCount, - createdOutsideMatchedViewNotice, - entityPrefillSource, - entityPrefillWarning, - evidenceContext, - draft, - pageSizeOptions, - search - } = props; - mockState.lastSurfaceProps = props; - return ( -
- ); - } -})); - -vi.mock('../../../lib/api-client', () => ({ - apiMessageDelete: vi.fn(), - apiMessageGet, - apiMessagePost: vi.fn(), - apiMessagePut: vi.fn() -})); - -const EMPTY_ROUTE_STATE: AlertInhibitRouteState = { - returnContext: { - search: '', - status: '', - severity: '', - entityId: '', - entityName: '', - returnTo: '' - }, - signal: null, - signalContext: {}, - managementContext: { - entityId: '', - entityName: '', - returnTo: '', - returnLabel: '', - matchMode: '', - matchingRuleType: '', - matchingRuleIds: [], - matchedViewEnabled: false - } -}; - -async function renderAlertInhibitPage(initialRouteState: AlertInhibitRouteState = EMPTY_ROUTE_STATE) { - const { default: AlertInhibitPage } = await import('./alert-inhibit-page'); - return renderToStaticMarkup(); -} - -describe('alert inhibit page', () => { - let interactionContainer: HTMLDivElement | null = null; - let interactionRoot: Root | null = null; - - afterEach(() => { - if (interactionRoot) { - act(() => { - interactionRoot?.unmount(); - }); - } - interactionRoot = null; - interactionContainer?.remove(); - interactionContainer = null; - }); - - beforeEach(() => { - mockState.lastLoad = null; - mockState.lastSurfaceProps = null; - mockState.currentSearchParams = ''; - mockState.routerReplace.mockReset(); - apiMessageGet.mockClear().mockResolvedValue(mockState.renderData.list); - }); - - it('loads the inhibit workbench through the shared query and surface contracts', async () => { - const html = await renderAlertInhibitPage(); - - expect(html).toContain('data-alert-inhibit-surface="true"'); - expect(html).toContain('data-page-size-options="8|15|25"'); - expect(html).toContain('data-search=""'); - expect(html).toContain('data-loading-copy="Loading inhibit rules"'); - - await mockState.lastLoad?.(); - - expect(apiMessageGet).toHaveBeenCalledWith('/alert/inhibits?pageIndex=0&pageSize=8&sort=id&order=desc'); - }); - - it('passes topology edge return context into the inhibit surface', async () => { - const returnTo = - '/topology?viewMode=resource-dependency&sourceKind=database-middleware-connection&edgeId=svc-checkout--res-orders-db&environment=prod&timeRange=last-1h'; - const initialRouteState: AlertInhibitRouteState = { - returnContext: { - search: 'checkout-api', - status: 'firing', - severity: '', - entityId: 'service:commerce/checkout', - entityName: 'checkout-api', - returnTo, - serviceName: 'checkout-api', - serviceNamespace: 'commerce', - environment: 'prod', - timeRange: 'last-1h', - source: 'topology', - viewMode: 'resource-dependency', - sourceKind: 'database-middleware-connection', - edgeId: 'svc-checkout--res-orders-db' - }, - signal: null, - managementContext: { - entityId: '', - entityName: '', - returnTo: '', - returnLabel: '', - matchMode: '', - matchingRuleType: '', - matchingRuleIds: [], - matchedViewEnabled: false - }, - signalContext: { - entityId: 'service:commerce/checkout', - entityName: 'checkout-api', - serviceName: 'checkout-api', - serviceNamespace: 'commerce', - environment: 'prod', - timeRange: 'last-1h', - source: 'topology', - returnTo - } - }; - - const html = await renderAlertInhibitPage(initialRouteState); - - expect(html).toContain('"edgeId":"svc-checkout--res-orders-db"'); - expect(html).toContain('"viewMode":"resource-dependency"'); - expect(html).toContain('"sourceKind":"database-middleware-connection"'); - expect(html).toContain('"returnTo":"/topology?viewMode=resource-dependency&sourceKind=database-middleware-connection&edgeId=svc-checkout--res-orders-db'); - expect(html).not.toContain('returnLabel='); - expect(html).not.toContain('HertzBeat operations topology'); - }); - - it('preserves three-signal evidence context into new inhibit authoring', async () => { - const initialRouteState: AlertInhibitRouteState = { - signal: 'traces', - returnContext: { - search: 'checkout', - status: 'firing', - severity: '', - entityId: 'service:commerce/checkout', - entityName: 'checkout-api', - returnTo: '/trace/manage?traceId=trace-123', - serviceName: 'checkout', - serviceNamespace: 'commerce', - environment: 'prod', - source: 'otlp', - signal: 'traces', - traceId: 'trace-123', - spanId: 'span-456', - collector: 'edge-collector-a', - template: 'java-service' - }, - managementContext: { - entityId: '', - entityName: '', - returnTo: '', - returnLabel: '', - matchMode: '', - matchingRuleType: '', - matchingRuleIds: [], - matchedViewEnabled: false - }, - signalContext: { - entityId: 'service:commerce/checkout', - entityName: 'checkout-api', - serviceName: 'checkout', - serviceNamespace: 'commerce', - environment: 'prod', - source: 'otlp', - traceId: 'trace-123', - spanId: 'span-456', - collector: 'edge-collector-a', - template: 'java-service', - returnTo: '/trace/manage?traceId=trace-123' - } - }; - - const html = await renderAlertInhibitPage(initialRouteState); - - expect(html).toContain('data-alert-inhibit-evidence-context="signal-route"'); - expect(html).toContain('data-alert-inhibit-evidence-signal="traces"'); - expect(html).toContain('data-alert-inhibit-evidence-return="/trace/manage?traceId=trace-123"'); - expect(html).toContain('hertzbeat.signal:traces'); - expect(html).toContain('service.name:checkout'); - expect(html).toContain('trace_id:trace-123'); - expect(html).toContain('span_id:span-456'); - expect(html).toContain('hertzbeat.collector:edge-collector-a'); - expect(html).toContain('data-alert-inhibit-draft-source-labels="hertzbeat.signal:traces'); - expect(html).toContain('data-alert-inhibit-draft-target-labels="hertzbeat.signal:traces'); - expect(html).toContain('data-alert-inhibit-draft-equal-labels="hertzbeat.entity.id, service.name, service.namespace, deployment.environment"'); - expect(html).not.toContain('returnLabel='); - const source = readFileSync(resolve(process.cwd(), 'app/alert/inhibit/alert-inhibit-page.tsx'), 'utf8'); - expect(source).toContain("import { useRouter, useSearchParams } from 'next/navigation';"); - expect(source).not.toContain('queryStateFromParams(searchParams)'); - expect(source).not.toContain('readSignalRouteContext(searchParams)'); - expect(source).toContain('const alertInhibitRouteState = initialRouteState ?? EMPTY_ALERT_INHIBIT_ROUTE_STATE'); - }); - - it('keeps alert inhibit remounts on a short settled cache window with refresh-tick invalidation', () => { - const source = readFileSync(resolve(process.cwd(), 'app/alert/inhibit/alert-inhibit-page.tsx'), 'utf8'); - - expect(source).toContain('ALERT_INHIBIT_SETTLED_CACHE_TTL_MS = 10_000'); - expect(source).toContain('ALERT_INHIBIT_LABEL_OPTIONS_TIMEOUT_MS = 2_500'); - expect(source).toContain('withTimeoutFallback('); - expect(source).toContain('const [refreshTick, setRefreshTick] = useState(0)'); - expect(source).toContain('const [pageIndex, setPageIndex] = useState(routeListState.pageIndex)'); - expect(source).toContain('const [pageSize, setPageSize] = useState(routeListState.pageSize)'); - expect(source).toContain("['alert-inhibit', useMatchedView ? `matched:${matchedRuleIdsKey}` : alertInhibitListUrl, refreshTick].join('|')"); - expect(source).toContain('buildAlertInhibitUrl({ search: query, pageIndex, pageSize })'); - expect(source).toContain('[pageIndex, pageSize, query]'); - expect(source).toContain('[alertInhibitListUrl, matchedRuleIdsKey, refreshTick, useMatchedView]'); - expect(source).toContain('loadAlertInhibitDataFromFacade'); - expect(source).toContain('list: api.alertInhibits.list'); - expect(source).toContain('loadAlertLabelOptionsFromFacade(api.alertLabels.list)'); - expect(source).toContain('loadMatchedAlertInhibitsFromFacade(api.alertInhibits.detail, managementContext.matchingRuleIds)'); - expect(source).not.toContain('apiMessageGet>(alertInhibitListUrl)'); - expect(source).toContain('return { ...data, refreshTick, missingMatchedRuleCount: 0 };'); - expect(source.match(/setRefreshTick\(value => value \+ 1\)/g)?.length).toBeGreaterThanOrEqual(4); - expect(source).toContain('cacheKey={alertInhibitCacheKey}'); - expect(source).toContain('cacheSettledTtlMs={ALERT_INHIBIT_SETTLED_CACHE_TTL_MS}'); - expect(source).toContain('pageSizeOptions={[...ALERT_INHIBIT_PAGE_SIZE_OPTIONS]}'); - expect(source).toContain('function handlePageIndexChange(nextPageIndex: number)'); - expect(source).toContain('function handlePageSizeChange(nextPageSize: number)'); - expect(source).toContain('setPageIndex(0);'); - }); - - it('initializes alert inhibit list state from the route and preserves URL state during search and pagination', async () => { - mockState.currentSearchParams = 'search=ops&pageIndex=2&pageSize=15&signal=metrics&entityId=7'; - const { default: AlertInhibitPage } = await import('./alert-inhibit-page'); - interactionContainer = document.createElement('div'); - document.body.appendChild(interactionContainer); - interactionRoot = createRoot(interactionContainer); - - await act(async () => { - interactionRoot?.render(); - await Promise.resolve(); - }); - - expect(mockState.lastSurfaceProps?.search).toBe('ops'); - expect(mockState.lastSurfaceProps?.requestedPageSize).toBe(15); - await act(async () => { - await mockState.lastLoad?.(); - }); - expect(apiMessageGet).toHaveBeenCalledWith('/alert/inhibits?pageIndex=2&pageSize=15&sort=id&order=desc&search=ops'); - - await act(async () => { - mockState.lastSurfaceProps?.onSearchChange('checkout'); - await Promise.resolve(); - }); - await act(async () => { - mockState.lastSurfaceProps?.onApplyFilter(); - await Promise.resolve(); - }); - - expect(mockState.routerReplace).toHaveBeenLastCalledWith('/alert/inhibit?search=checkout&pageSize=15&signal=metrics&entityId=7', { scroll: false }); - - mockState.currentSearchParams = 'search=checkout&pageSize=15&signal=metrics&entityId=7'; - await act(async () => { - interactionRoot?.render(); - await Promise.resolve(); - }); - await act(async () => { - mockState.lastSurfaceProps?.onPageIndexChange(3); - await Promise.resolve(); - }); - - expect(mockState.routerReplace).toHaveBeenLastCalledWith('/alert/inhibit?search=checkout&pageSize=15&signal=metrics&entityId=7&pageIndex=3', { scroll: false }); - - mockState.currentSearchParams = 'search=checkout&pageSize=15&signal=metrics&entityId=7'; - await act(async () => { - interactionRoot?.render(); - await Promise.resolve(); - }); - await act(async () => { - mockState.lastSurfaceProps?.onPageSizeChange(8); - await Promise.resolve(); - }); - - expect(mockState.routerReplace).toHaveBeenLastCalledWith('/alert/inhibit?search=checkout&signal=metrics&entityId=7', { scroll: false }); - - mockState.currentSearchParams = 'search=checkout&pageSize=15&signal=metrics&entityId=7'; - await act(async () => { - interactionRoot?.render(); - await Promise.resolve(); - }); - await act(async () => { - mockState.lastSurfaceProps?.onClearFilter(); - await Promise.resolve(); - }); - - expect(mockState.routerReplace).toHaveBeenLastCalledWith('/alert/inhibit?signal=metrics&entityId=7', { scroll: false }); - }); - - it('clears local editor validation when canceling an empty new inhibit draft', async () => { - const { default: AlertInhibitPage } = await import('./alert-inhibit-page'); - interactionContainer = document.createElement('div'); - document.body.appendChild(interactionContainer); - interactionRoot = createRoot(interactionContainer); - - await act(async () => { - interactionRoot?.render(); - await Promise.resolve(); - }); - - await act(async () => { - mockState.lastSurfaceProps?.onSave(); - await Promise.resolve(); - }); - - expect(mockState.lastSurfaceProps?.editorError).toBe(createTranslatorMock()('alert.inhibit.validation.name')); - - await act(async () => { - mockState.lastSurfaceProps?.onCloseEditor(); - await Promise.resolve(); - }); - - expect(mockState.lastSurfaceProps?.editorOpen).toBe(false); - expect(mockState.lastSurfaceProps?.editorError).toBeNull(); - expect(mockState.lastSurfaceProps?.editorErrorDetail).toBeNull(); - expect(mockState.lastSurfaceProps?.editorErrorContract).toBeNull(); - }); - - it('asks for confirmation before closing a dirty inhibit editor draft', async () => { - const { default: AlertInhibitPage } = await import('./alert-inhibit-page'); - interactionContainer = document.createElement('div'); - document.body.appendChild(interactionContainer); - interactionRoot = createRoot(interactionContainer); - - await act(async () => { - interactionRoot?.render(); - await Promise.resolve(); - }); - - await act(async () => { - mockState.lastSurfaceProps?.onNew(); - await Promise.resolve(); - }); - - const draft = mockState.lastSurfaceProps?.draft; - expect(mockState.lastSurfaceProps?.editorOpen).toBe(true); - - await act(async () => { - mockState.lastSurfaceProps?.onDraftChange({ ...draft, name: 'Unsaved inhibit draft' }); - await Promise.resolve(); - }); - - await act(async () => { - mockState.lastSurfaceProps?.onCloseEditor(); - await Promise.resolve(); - }); - - expect(mockState.lastSurfaceProps?.editorOpen).toBe(true); - expect(interactionContainer.innerHTML).toContain('data-alert-inhibit-unsaved-cancel-state="open"'); - expect(interactionContainer.innerHTML).toContain('Discard unsaved inhibit changes?'); - }); - - it('focuses the first invalid inhibit editor field after local validation fails', () => { - const source = readFileSync(resolve(process.cwd(), 'app/alert/inhibit/alert-inhibit-page.tsx'), 'utf8'); - - expect(source).toContain('function focusAlertInhibitEditorField(field: AlertInhibitValidationField)'); - expect(source).toContain('const validationField = getAlertInhibitValidationField(draft)'); - expect(source).toContain('focusAlertInhibitEditorField(validationField)'); - expect(source).toContain('input[name="inhibit_name"]'); - expect(source).toContain('[data-alert-inhibit-source-label-selector] [data-hz-label-selector-draft-row="true"] input[data-hz-label-selector-key-input="searchable-key"]'); - expect(source).toContain('[data-alert-inhibit-target-label-selector] [data-hz-label-selector-draft-row="true"] input[data-hz-label-selector-key-input="searchable-key"]'); - expect(source).toContain('[data-alert-inhibit-equal-label-selector] input[data-hz-tag-input-control="draft"]'); - }); - - it('loads Angular entity-noise-control matched inhibit rules by id', async () => { - const initialRouteState: AlertInhibitRouteState = { - returnContext: { - search: '', - status: '', - severity: '', - entityId: '42', - entityName: 'checkout-api', - returnTo: '/entities/42?tab=alerts' - }, - signal: null, - signalContext: {}, - managementContext: { - entityId: '42', - entityName: 'checkout-api', - returnTo: '/entities/42?tab=alerts', - returnLabel: 'checkout-api', - matchMode: 'entity-noise-controls', - matchingRuleType: 'inhibit', - matchingRuleIds: [11, 12], - matchedViewEnabled: true - } - }; - apiMessageGet.mockImplementation(async (url: string) => { - if (url === '/alert/inhibit/11') { - return { - id: 11, - name: 'checkout inhibit', - enable: true, - sourceLabels: { service: 'checkout' }, - targetLabels: { service: 'orders' }, - equalLabels: ['cluster'] - }; - } - if (url === '/alert/inhibit/12') { - throw new Error('missing'); - } - return mockState.renderData.list; - }); - - const html = await renderAlertInhibitPage(initialRouteState); - const result = (await mockState.lastLoad?.()) as any; - - expect(html).toContain('data-alert-inhibit-match-view="matched"'); - expect(html).toContain('"matchMode":"entity-noise-controls"'); - expect(apiMessageGet).toHaveBeenCalledWith('/alert/inhibit/11'); - expect(apiMessageGet).toHaveBeenCalledWith('/alert/inhibit/12'); - expect(apiMessageGet).not.toHaveBeenCalledWith('/alert/inhibits?pageIndex=0&pageSize=8&sort=id&order=desc'); - expect(result.list.content.map((item: any) => item.id)).toEqual([11]); - expect(result.missingMatchedRuleCount).toBe(1); - }); - - it('keeps Angular no-selection batch delete warning before opening confirm', () => { - const source = readFileSync(resolve(process.cwd(), 'app/alert/inhibit/alert-inhibit-page.tsx'), 'utf8'); - const surfaceSource = readFileSync(resolve(process.cwd(), 'components/pages/alert-inhibit-surface.tsx'), 'utf8'); - - expect(source).toContain('if (checkedIds.length === 0)'); - expect(source).toContain("setEditorError(t('common.notify.no-select-delete'))"); - expect(source).toContain('setEditorMessage(null)'); - expect(source).toContain("setDeleteRequest({ kind: 'batch', ids: checkedIds })"); - expect(surfaceSource).toContain('data-alert-inhibit-delete-selected="toolbar"'); - expect(surfaceSource).toContain('data-alert-inhibit-action-feedback-owner="hertzbeat-ui-inline-feedback"'); - expect(surfaceSource).not.toContain('disabled={selectedCount === 0}'); - }); - - it('keeps Angular created-outside-matched notice state for matched-view authoring', () => { - const source = readFileSync(resolve(process.cwd(), 'app/alert/inhibit/alert-inhibit-page.tsx'), 'utf8'); - const surfaceSource = readFileSync(resolve(process.cwd(), 'components/pages/alert-inhibit-surface.tsx'), 'utf8'); - - expect(source).toContain('const [createdOutsideMatchedViewNotice, setCreatedOutsideMatchedViewNotice] = useState(false)'); - expect(source).toContain('setCreatedOutsideMatchedViewNotice(false)'); - expect(source).toContain('createdOutsideMatchedViewNotice={createdOutsideMatchedViewNotice}'); - expect(surfaceSource).toContain('data-alert-inhibit-created-outside-matched="angular-authoring-notice"'); - expect(surfaceSource).toContain('data-alert-inhibit-created-outside-matched-owner="hertzbeat-ui-inline-feedback"'); - expect(surfaceSource).toContain('data-alert-inhibit-created-outside-matched-action="view-all"'); - }); - - it('keeps Angular entity-alert authoring prefill for entity noise-control inhibits', () => { - const source = readFileSync(resolve(process.cwd(), 'app/alert/inhibit/alert-inhibit-page.tsx'), 'utf8'); - const surfaceSource = readFileSync(resolve(process.cwd(), 'components/pages/alert-inhibit-surface.tsx'), 'utf8'); - - expect(source).toContain('buildAlertInhibitEntityPrefillFromFacade('); - expect(source).toContain("entityId => api.entities.alerts(entityId, { pageIndex: 0, pageSize: 20, status: 'firing' })"); - expect(source).not.toContain("from '../../../lib/api-client'"); - expect(source).toContain("t('entity.noise-controls.authoring.inhibit.prefill-warning')"); - expect(source).toContain("t('entity.noise-controls.authoring.prefill-warning.no-entity-id')"); - expect(source).toContain('entityPrefillSource={entityPrefillSource}'); - expect(source).toContain('entityPrefillWarning={entityPrefillWarning}'); - expect(source).toContain("name: `${displayName} inhibit`"); - expect(surfaceSource).toContain('data-alert-inhibit-entity-prefill={entityPrefillSource ==='); - expect(surfaceSource).toContain("t('entity.noise-controls.authoring.inhibit.title')"); - expect(surfaceSource).toContain("t('entity.noise-controls.authoring.inhibit.prefill-success')"); - expect(surfaceSource).toContain('prefillWarning={entityPrefillWarning}'); - }); - - it('uses Angular delete notifications for confirmed delete feedback', () => { - const source = readFileSync(resolve(process.cwd(), 'app/alert/inhibit/alert-inhibit-page.tsx'), 'utf8'); - - expect(source).toContain('async function handleConfirmedDelete()'); - expect(source).toContain('deleteAlertInhibitsFromFacade(api.alertInhibits.delete, request.ids)'); - expect(source).toContain('deleteAlertInhibitFromFacade(api.alertInhibits.delete, request.ids[0])'); - expect(source).toContain("setEditorMessage(t('common.notify.delete-success'))"); - expect(source).toContain("setEditorError(t('common.notify.delete-fail'))"); - expect(source).toContain('setEditorErrorDetail(error instanceof Error ? error.message : null)'); - expect(source).toContain("setEditorErrorContract('delete')"); - expect(source).not.toContain('apiMessageDelete'); - expect(source).not.toContain("setEditorMessage(t('common.delete-success'))"); - expect(source).not.toContain("t('common.delete-failed')"); - }); - - it('names the target and destructive action in inhibit delete confirmation', () => { - const source = readFileSync(resolve(process.cwd(), 'app/alert/inhibit/alert-inhibit-page.tsx'), 'utf8'); - - expect(source).toContain('const deleteTargetNames = deleteRequest?.ids'); - expect(source).toContain("t('alert.inhibit.delete.confirm.targets', { names: deleteTargetNames.join(', ') })"); - expect(source).toContain("t('alert.inhibit.delete.confirm.targets-more', { count: missingDeleteTargetCount })"); - expect(source).toContain("confirmLabel={t('alert.inhibit.delete.confirm.action')}"); - expect(source).not.toContain("confirmLabel={t('common.button.ok')}"); - }); - - it('uses Angular edit notifications and facade persistence for enable-toggle feedback', () => { - const source = readFileSync(resolve(process.cwd(), 'app/alert/inhibit/alert-inhibit-page.tsx'), 'utf8'); - const toggleSource = source.slice( - source.indexOf('async function handleToggleEnabled(inhibit?: AlertInhibit)'), - source.indexOf('async function handleDelete(inhibitId?: number)') - ); - - expect(toggleSource).toContain('async function handleToggleEnabled(inhibit?: AlertInhibit)'); - expect(toggleSource).toContain('updateAlertInhibitEnabledFromFacade(api.alertInhibits.update, target, !(target.enable ?? true))'); - expect(toggleSource).toContain("setEditorMessage(t('common.notify.edit-success'))"); - expect(toggleSource).toContain("setEditorError(t('common.notify.edit-fail'))"); - expect(toggleSource).toContain('setEditorErrorDetail(error instanceof Error ? error.message : null)'); - expect(toggleSource).toContain("setEditorErrorContract('enable')"); - expect(toggleSource).not.toContain('apiMessagePut'); - expect(toggleSource).not.toContain("setEditorMessage(t('common.save-success'))"); - expect(toggleSource).not.toContain("t('common.save-failed')"); - }); - - it('uses Angular create/edit notifications and facade persistence for editor save feedback', () => { - const source = readFileSync(resolve(process.cwd(), 'app/alert/inhibit/alert-inhibit-page.tsx'), 'utf8'); - const saveSource = source.slice( - source.indexOf('async function handleSave()'), - source.indexOf('async function handleToggleEnabled(inhibit?: AlertInhibit)') - ); - - expect(saveSource).toContain('const isEdit = Boolean(draft.id)'); - expect(saveSource).toContain('updateAlertInhibitFromFacade(api.alertInhibits.update, draft)'); - expect(saveSource).toContain('createAlertInhibitFromFacade(api.alertInhibits.create, draft)'); - expect(saveSource).toContain("setEditorMessage(t(isEdit ? 'common.notify.edit-success' : 'common.notify.new-success'))"); - expect(saveSource).toContain('setCreatedOutsideMatchedViewNotice(!isEdit && useMatchedView)'); - expect(saveSource).toContain("setEditorError(t(isEdit ? 'common.notify.edit-fail' : 'common.notify.new-fail'))"); - expect(saveSource).toContain('setEditorErrorDetail(error instanceof Error ? error.message : null)'); - expect(saveSource).toContain("setEditorErrorContract('save')"); - expect(saveSource).not.toContain('apiMessagePost'); - expect(saveSource).not.toContain('apiMessagePut'); - expect(saveSource).not.toContain("setEditorMessage(t('common.save-success'))"); - expect(saveSource).not.toContain("t('common.save-failed')"); - }); - - it('uses Angular edit failure notification and facade detail loading for edit detail fallback', () => { - const source = readFileSync(resolve(process.cwd(), 'app/alert/inhibit/alert-inhibit-page.tsx'), 'utf8'); - const editSource = source.slice( - source.indexOf('async function handleEdit(inhibitId?: number)'), - source.indexOf('async function handleSave()') - ); - - expect(editSource).toContain('async function handleEdit(inhibitId?: number)'); - expect(editSource).toContain('loadAlertInhibitDetailFromFacade(api.alertInhibits.detail, targetId)'); - expect(editSource).toContain("setEditorError(error instanceof Error ? error.message : t('common.notify.edit-fail'))"); - expect(editSource).not.toContain('apiMessageGet'); - expect(editSource).not.toContain("t('common.load-failed')"); - }); -}); diff --git a/web-next/app/alert/inhibit/page.tsx b/web-next/app/alert/inhibit/page.tsx deleted file mode 100644 index 79939114ce..0000000000 --- a/web-next/app/alert/inhibit/page.tsx +++ /dev/null @@ -1,14 +0,0 @@ -import React from 'react'; - -import AlertInhibitPage from './alert-inhibit-page'; -import { readAlertInhibitRouteState, type AlertInhibitSearchParams } from '../../../lib/alert-inhibit/query-state'; - -export default async function AlertInhibitRoutePage({ - searchParams -}: { - searchParams?: Promise; -}) { - const resolvedSearchParams = await searchParams; - const routeState = readAlertInhibitRouteState(resolvedSearchParams); - return ; -} diff --git a/web-next/app/alert/integration/[source]/alert-integration-source-redirect.tsx b/web-next/app/alert/integration/[source]/alert-integration-source-redirect.tsx deleted file mode 100644 index 5c8b3b17b5..0000000000 --- a/web-next/app/alert/integration/[source]/alert-integration-source-redirect.tsx +++ /dev/null @@ -1,17 +0,0 @@ -'use client'; - -import React from 'react'; -import { useEffect } from 'react'; - -export function AlertIntegrationSourceRedirect({ href }: { href: string }) { - useEffect(() => { - window.location.replace(href); - }, [href]); - - return ( -
- ); -} diff --git a/web-next/app/alert/integration/[source]/page.test.tsx b/web-next/app/alert/integration/[source]/page.test.tsx deleted file mode 100644 index cbfb55ae97..0000000000 --- a/web-next/app/alert/integration/[source]/page.test.tsx +++ /dev/null @@ -1,238 +0,0 @@ -import { readFileSync } from 'node:fs'; -import { resolve } from 'node:path'; -import React from 'react'; -import { renderToStaticMarkup } from 'react-dom/server'; -import { describe, expect, it, vi } from 'vitest'; - -const headers = vi.fn(); - -const mockLoadIntegrationDoc = vi.hoisted(() => - vi.fn( - async () => `# Webhook guide - -Use this provider. - -\`\`\`json -{"status":"ok"} -\`\`\` - -\`\`\`mermaid -graph LR - A[External alert] --> B[Webhook] -\`\`\` - -1. Check configuration - \`\`\`bash - curl http://localhost:9090/api/v1/rules - \`\`\` -` - ) -); - -vi.mock('next/link', () => ({ - default: ({ href, children, ...props }: any) => ( - - {children} - - ) -})); - -vi.mock('next/headers', () => ({ - headers -})); - -vi.mock('@/components/workbench/workbench-page', () => ({ - WorkbenchPage: ({ title, subtitle, facts, actions, main, side, tone }: any) => ( -
-

{title}

-

{subtitle}

-
{facts.map((fact: any) => `${fact.label}:${fact.value}`).join('|')}
-
{actions}
-
{main}
-
{side}
-
- ), - RowList: ({ rows }: any) =>
{rows.map((row: any) => `${row.title}||${row.copy}||${row.meta}`).join('|')}
-})); - -vi.mock('@/components/observability', () => ({ - DrawerCodePreview: ({ children }: any) =>
{children}
, - DrawerSection: ({ title, children }: any) => ( - - ), - StageSection: ({ title, description, children }: any) => ( -
-

{title}

- {description ?

{description}

: null} - {children} -
- ) -})); - -vi.mock('@/components/ui/button', () => ({ - Button: ({ children, ...props }: any) => -})); - -vi.mock('@/lib/utils', () => ({ - cn: (...inputs: Array) => inputs.filter(Boolean).join(' ') -})); - -vi.mock('@/lib/alert-integration/controller', () => ({ - fallbackDocCopy: 'No integration guide is available for this provider yet.', - getAlertIntegrationFallbackDocCopy: () => 'No integration guide is available for this alert source yet.', - loadIntegrationDoc: mockLoadIntegrationDoc -})); - -vi.mock('@/lib/alert-integration/view-model', () => ({ - DATA_SOURCES: [ - { id: 'webhook', name: 'Default Webhook', icon: '/assets/logo.svg' }, - { id: 'prometheus', name: 'Prometheus', icon: '/assets/img/integration/prometheus.svg' } - ], - buildAlertIntegrationSourceHref: (source: any) => `/alert/integration/${source.id}`, - getIntegrationSource: (source: string) => - [ - { id: 'webhook', name: 'Default Webhook', icon: '/assets/logo.svg' }, - { id: 'prometheus', name: 'Prometheus', icon: '/assets/img/integration/prometheus.svg' } - ].find(item => item.id === source) ?? { id: 'webhook', name: 'Default Webhook', icon: '/assets/logo.svg' }, - createAlertIntegrationTranslator: () => (key: string) => - ({ - 'alert.integration.kicker': 'Alert integration', - 'alert.integration.sources': 'Integration alert sources', - 'alert.integration.token.manage': 'Manage tokens' - })[key] ?? key, - getIntegrationSourceName: (item: any) => item.name ?? item.id, - translateAlertIntegration: (key: string) => - ({ - 'alert.integration.kicker': 'Alert integration', - 'alert.integration.sources': 'Integration alert sources', - 'alert.integration.token.manage': 'Manage tokens' - })[key] ?? key, - buildIntegrationFacts: (source: string, hasDoc: boolean) => [ - { label: 'Alert integration', value: `alert/integration/${source}` }, - { label: 'Integration alert sources', value: source }, - { label: 'Document status', value: hasDoc ? 'Loaded' : 'Fallback copy' } - ], - buildIntegrationSourceRows: (source: string) => [ - { title: 'Default Webhook', copy: 'webhook', meta: source === 'webhook' ? 'selected' : '/alert/integration/webhook' }, - { title: 'Prometheus', copy: 'prometheus', meta: source === 'prometheus' ? 'selected' : '/alert/integration/prometheus' } - ], - buildIntegrationPostureRows: (source: string, hasDoc: boolean) => [ - { title: 'doc source', copy: `web-next/public/assets/doc/alert-integration/${source}.*.md`, meta: 'existing asset' }, - { title: 'fallback behavior', copy: hasDoc ? 'provider doc loaded' : 'show fallback copy when no provider doc exists', meta: 'behavior preserved' }, - { title: 'token management', copy: 'Continue to use the current token management entry point.', meta: '/setting/settings/token' } - ] -})); - -describe('alert integration page', () => { - it('renders the shared HertzBeat UI source rail plus markdown document shell for the selected integration source', async () => { - headers.mockResolvedValue(new Headers({ 'accept-language': 'zh-CN,zh;q=0.9' })); - const source = readFileSync(resolve(process.cwd(), 'app/alert/integration/[source]/page.tsx'), 'utf8'); - const { default: AlertIntegrationPage } = await import('./page'); - const html = renderToStaticMarkup( - await AlertIntegrationPage({ - params: Promise.resolve({ source: 'webhook' }) - }) - ); - - expect(html).toContain('data-alert-integration-surface="hertzbeat-ui-source-doc"'); - expect(html).toContain('data-alert-integration-shell-owner="hertzbeat-ui-source-doc-shell"'); - expect(html).toContain('data-hz-ui="source-doc-shell"'); - expect(html).toContain('data-hz-source-doc-shell-owner="hertzbeat-ui-source-doc-shell"'); - expect(html).toContain('data-hz-source-doc-rail-owner="hertzbeat-ui-source-doc-shell"'); - expect(html).toContain('data-hz-source-doc-panel-owner="hertzbeat-ui-source-doc-shell"'); - expect(html).toContain('data-alert-integration-token-action-owner="hertzbeat-ui-button-link"'); - expect(html).toContain('data-alert-integration-source-item="webhook"'); - expect(html).toContain('data-alert-integration-source-selected="true"'); - expect(html).toContain('aria-current="page"'); - expect(html).toContain('data-alert-integration-source-icon="webhook"'); - expect(html).toContain('data-hz-source-doc-item-owner="hertzbeat-ui-source-doc-shell"'); - expect(html).toContain('data-hz-source-doc-item-icon-owner="hertzbeat-ui-source-doc-shell"'); - expect(html).toContain('src="/assets/logo.svg"'); - expect(html).toContain('src="/assets/img/integration/prometheus.svg"'); - expect(html).toContain('data-hz-source-doc-panel-scroll-owner="hertzbeat-ui-scroll-viewport"'); - expect(html).toContain('data-alert-integration-markdown="rendered"'); - expect(html).toContain('data-alert-integration-code-block="json"'); - expect(html).toContain('data-alert-integration-code-block="bash"'); - expect(html).toContain('data-alert-integration-mermaid="structured-flow"'); - expect(html).toContain('data-alert-integration-diagram-runtime="semantic-html"'); - expect(html).toContain('Alert integration'); - expect(html).toContain('Integration alert sources'); - expect(html).toContain('Default Webhook'); - expect(html).toContain('Manage tokens'); - expect(html).toContain('Webhook guide'); - expect(html).toContain('curl http://localhost:9090/api/v1/rules'); - expect(mockLoadIntegrationDoc).toHaveBeenLastCalledWith(expect.stringContaining('alert-integration'), 'webhook', 'zh-CN'); - expect(html).not.toContain('# Webhook guide'); - expect(html).not.toContain('```json'); - expect(html).not.toContain('```bash'); - expect(html).not.toContain('```mermaid'); - expect(html).not.toContain('graph LR'); - expect(html).not.toContain('data-language="mermaid"'); - expect(html).not.toContain('angular-source-doc'); - expect(html).not.toContain('angular-source-list'); - expect(html).not.toContain('angular-markdown-doc'); - expect(html).not.toContain('angular-token-action'); - expect(html).not.toContain('data-workbench-page="true"'); - expect(html).not.toContain('Integration · Webhook'); - expect(html).not.toContain('Workspace:alert/integration/webhook'); - expect(html).not.toContain('Open Token Management'); - expect(html).not.toContain('Integration guide'); - expect(html).not.toContain('Integration posture'); - expect(html).not.toContain('Sources'); - expect(html).not.toContain('Navigation'); - - expect(source).toContain("from '@hertzbeat/ui/source-doc-shell'"); - expect(source).toContain('HzSourceDocShell'); - expect(source).toContain("from 'next/headers'"); - expect(source).toContain('createAlertIntegrationTranslator(locale)'); - expect(source).toContain('loadIntegrationDoc(baseDir, selectedSource.id, locale)'); - expect(source).toContain('data-alert-integration-surface="hertzbeat-ui-source-doc"'); - expect(source).toContain('data-alert-integration-shell-owner="hertzbeat-ui-source-doc-shell"'); - expect(source).toContain("'aria-current': item.id === selectedSource.id ? ('page' as const) : undefined"); - expect(source).not.toContain('h-[calc(100vh-242px)]'); - expect(source).toContain("'data-alert-integration-source-icon': item.id"); - expect(source).toContain('AlertIntegrationMarkdown'); - expect(source).not.toContain('No integration guide is available for this provider yet.'); - expect(source).not.toContain('angular-source-doc'); - expect(source).not.toContain('angular-source-list'); - expect(source).not.toContain('angular-markdown-doc'); - expect(source).not.toContain('angular-token-action'); - expect(source).not.toContain('rounded-[10px]'); - expect(source).not.toContain('rounded-[8px]'); - expect(source).not.toContain('rounded-[16px]'); - expect(source).not.toContain('hzOpsCatalogVisual'); - expect(source).not.toContain("from '@/components/ui/button'"); - expect(source).not.toContain("from '@/components/observability'"); - expect(source).not.toContain('StageSection'); - expect(source).not.toContain('DrawerSection'); - expect(source).not.toContain('DrawerCodePreview'); - expect(source).not.toContain('buildIntegrationFacts'); - expect(source).not.toContain('buildIntegrationPostureRows'); - expect(source).not.toContain("from '@/components/workbench/primitives'"); - expect(source).not.toContain("from '@/components/observability/code-pane'"); - }); - - it('uses a client canonical redirect for unknown source params so production hydration does not strand an empty shell', async () => { - headers.mockResolvedValue(new Headers({ 'accept-language': 'zh-CN' })); - const source = readFileSync(resolve(process.cwd(), 'app/alert/integration/[source]/page.tsx'), 'utf8'); - const redirectSource = readFileSync(resolve(process.cwd(), 'app/alert/integration/[source]/alert-integration-source-redirect.tsx'), 'utf8'); - - const { default: AlertIntegrationPage } = await import('./page'); - - const html = renderToStaticMarkup( - await AlertIntegrationPage({ - params: Promise.resolve({ source: 'unknown-provider' }) - }) - ); - - expect(html).toContain('data-alert-integration-canonical-redirect="pending"'); - expect(source).toContain('AlertIntegrationSourceRedirect'); - expect(source).toContain('return '); - expect(source).not.toContain("from 'next/navigation'"); - expect(redirectSource).toContain('window.location.replace(href)'); - expect(redirectSource).toContain('data-alert-integration-canonical-redirect="pending"'); - }); -}); diff --git a/web-next/app/alert/integration/[source]/page.tsx b/web-next/app/alert/integration/[source]/page.tsx deleted file mode 100644 index 8965f7f46c..0000000000 --- a/web-next/app/alert/integration/[source]/page.tsx +++ /dev/null @@ -1,83 +0,0 @@ -import path from 'node:path'; -import Link from 'next/link'; -import { headers } from 'next/headers'; -import React from 'react'; -import { Settings } from 'lucide-react'; -import { HzSourceDocShell } from '@hertzbeat/ui/source-doc-shell'; -import { getAlertIntegrationFallbackDocCopy, loadIntegrationDoc } from '@/lib/alert-integration/controller'; -import { - buildAlertIntegrationSourceHref, - createAlertIntegrationTranslator, - DATA_SOURCES, - getIntegrationSource, - getIntegrationSourceName -} from '@/lib/alert-integration/view-model'; -import { normalizeLocale } from '@/lib/i18n'; -import { AlertIntegrationMarkdown } from '../../../../components/pages/alert-integration-markdown'; -import { AlertIntegrationSourceRedirect } from './alert-integration-source-redirect'; - -export default async function AlertIntegrationPage({ params }: { params: Promise<{ source: string }> }) { - const { source } = await params; - const selectedSource = getIntegrationSource(source.trim()); - if (selectedSource.id !== source) { - return ; - } - const requestHeaders = await headers(); - const locale = normalizeLocale(requestHeaders.get('accept-language')); - const t = createAlertIntegrationTranslator(locale); - const selectedSourceName = getIntegrationSourceName(selectedSource, t); - const sourceRailLabel = t('alert.integration.sources'); - const baseDir = path.join(process.cwd(), 'public', 'assets', 'doc', 'alert-integration'); - const doc = await loadIntegrationDoc(baseDir, selectedSource.id, locale); - const fallbackDocCopy = getAlertIntegrationFallbackDocCopy(locale); - const sourceItems = DATA_SOURCES.map(item => { - const sourceName = getIntegrationSourceName(item, t); - return { - id: item.id, - href: buildAlertIntegrationSourceHref(item), - label: sourceName, - iconSrc: item.icon, - iconAlt: sourceName, - selected: item.id === selectedSource.id, - itemProps: { - 'data-alert-integration-source-item': item.id, - 'data-alert-integration-source-selected': item.id === selectedSource.id ? 'true' : undefined, - 'aria-current': item.id === selectedSource.id ? ('page' as const) : undefined - }, - iconProps: { - 'data-alert-integration-source-icon': item.id - } - }; - }); - - return ( - - - ); -} diff --git a/web-next/app/alert/notice/alert-notice-page.tsx b/web-next/app/alert/notice/alert-notice-page.tsx deleted file mode 100644 index 565a754c37..0000000000 --- a/web-next/app/alert/notice/alert-notice-page.tsx +++ /dev/null @@ -1,2497 +0,0 @@ -'use client'; - -import React, { useCallback, useEffect, useMemo, useState } from 'react'; -import { useRouter, useSearchParams } from 'next/navigation'; -import { ArrowLeft, CircleHelp, Eye, Inbox, MoreHorizontal, Pencil, Plus, RefreshCw, Trash2 } from 'lucide-react'; -import { HzConfirmDialog, HzPaginationBar } from '@hertzbeat/ui'; -import { AlertNoticeConsoleShell, type AlertNoticeConsoleTabKey } from '../../../components/pages/alert-notice-console-shell'; -import { ClientWorkbench } from '../../../components/workbench/client-workbench'; -import { useI18n } from '../../../components/providers/i18n-provider'; -import { AlertNoticeReceiverFields } from '../../../components/pages/alert-notice-receiver-fields'; -import { AlertNoticeRuleFields, type NoticeRuleValidationIssue } from '../../../components/pages/alert-notice-rule-fields'; -import { AlertNoticeTemplateFields } from '../../../components/pages/alert-notice-template-fields'; -import { Button } from '../../../components/ui/button'; -import { SearchRow } from '../../../components/ui/search-row'; -import { Select } from '../../../components/ui/select'; -import { OverlayDialog } from '../../../components/workbench/overlay-dialog'; -import { - AlertSurfaceTable, - AlertSurfaceTableHead, - AlertSurfaceTableShell, - AlertSurfaceValuePill -} from '../../../components/pages/alert-surface-primitives'; -import { api } from '../../../lib/alert-api-facade'; -import { buildNoticeListUrl, buildNoticeRuleDisplayNames, buildNoticeRuleDraft, buildNoticeTemplateListUrl, loadAlertNoticeDataFromFacade, type NoticeReceiverDraft, type NoticeRuleDraft, type NoticeTemplateDraft } from '../../../lib/alert-notice/controller'; -import type { AlertNoticeRouteState } from '../../../lib/alert-notice/query-state'; -import { buildAlertNoticeEvidenceContext, buildNoticeReceiverDraft, buildNoticeReceiverValidationIssues, buildNoticeTemplateDraft, buildNoticeTemplateValidationIssues, getAlertNoticeProductCopy, validateNoticeRuleDraft, type NoticeReceiverValidationIssue, type NoticeTemplateValidationIssue } from '../../../lib/alert-notice/view-model'; -import { DEFAULT_ALERT_LABEL_OPTIONS, loadAlertLabelOptionsFromFacade, type AlertLabelOptions } from '../../../lib/alert-label-options'; -import { hzOpsCatalogVisual } from '../../../lib/hz-ops-visual'; -import { formatTime } from '../../../lib/format'; -import type { NoticeReceiver, NoticeRule, NoticeTemplate, PageResult } from '../../../lib/types'; - -type NoticePageData = { - receivers: PageResult; - receiverOptions: PageResult; - rules: PageResult; - templates: PageResult; - templateOptions: PageResult; - labelOptions: AlertLabelOptions; -}; - -type NoticeDeleteRequest = { - kind: 'receiver' | 'rule' | 'template'; - id: number; - name?: string; -}; - -type AlertNoticeActionHelpCopy = { - label: string; - body: string; - impact?: string; -}; -export type AlertNoticeRouteTab = 'receiver' | 'rule' | 'template'; -export type AlertNoticeListRouteState = { - selectedTab: AlertNoticeRouteTab; - receiverSearch: string; - receiverPageIndex: number; - receiverPageSize: number; - ruleSearch: string; - rulePageIndex: number; - rulePageSize: number; - templateSearch: string; - templatePresetFilter: boolean; - templatePageIndex: number; - templatePageSize: number; -}; - -const NOTICE_PAGE_SIZE_OPTIONS = [8, 15, 25]; -type Translator = ReturnType['t']; -const coldNoticeVisual = hzOpsCatalogVisual; -const coldToolbarClass = 'flex flex-wrap items-center gap-2 border-b border-[#252b34] bg-[#0b0c0e] px-3 py-2'; - -function resolveNoticeRuleValidationIssues( - validationError: string | null, - t: Translator -): NoticeRuleValidationIssue[] { - if (!validationError) return []; - const validationMap: Array<{ field: NoticeRuleValidationIssue['field']; key: string }> = [ - { field: 'name', key: 'alert.notice.rule.validation.name' }, - { field: 'receiver', key: 'alert.notice.rule.validation.receivers' }, - { field: 'labels', key: 'alert.notice.rule.validation.labels' }, - { field: 'days', key: 'alert.notice.rule.validation.days' }, - { field: 'time', key: 'alert.notice.rule.validation.period' } - ]; - const matchedIssue = validationMap.find(issue => t(issue.key) === validationError); - return matchedIssue ? [{ field: matchedIssue.field, message: validationError }] : []; -} -const coldSelectClass = - 'h-8 w-[132px] rounded-[3px] border border-[#2b3039] bg-[#101217] px-2 text-[12px] font-semibold text-[#eef2f7] outline-none focus:border-[#4e74f8]'; -const coldTableShellClass = 'rounded-[4px] border border-[#252b34] bg-[#0b0c0e]'; -const coldTextPrimaryClass = 'text-[#f2f5f8]'; -const coldButtonClassName = - 'h-8 min-w-[104px] rounded-[3px] border-[#2b3039] bg-[#101217] px-3 text-[12px] font-semibold text-[#dbe4f0] shadow-[inset_0_1px_0_rgba(255,255,255,0.03)] hover:border-[#4e74f8] hover:bg-[#151b28] hover:text-white'; -const coldPrimaryButtonClassName = - 'h-8 min-w-[104px] rounded-[3px] border-[#31405c] bg-[#182238] px-3 text-[12px] font-semibold text-[#d8e4ff] shadow-[inset_0_1px_0_rgba(255,255,255,0.04)] hover:border-[#4e74f8] hover:bg-[#202a42] hover:text-white'; -const coldCommandButtonClass = `${coldButtonClassName} w-[124px] min-w-[124px]`; -const coldPrimaryCommandButtonClass = `${coldPrimaryButtonClassName} w-[124px] min-w-[124px]`; -const coldIconButtonClass = - 'h-8 w-8 min-w-0 rounded-[3px] border-[#2b3039] bg-[#101217] text-[#dbe4f0] hover:border-[#4e74f8] hover:bg-[#151b28] hover:text-white'; -const coldStickyActionHeaderClass = - 'sticky right-0 z-10 w-[168px] border-l border-[#252b34] bg-[#101217] px-3 py-3 text-center shadow-[-12px_0_18px_rgba(0,0,0,0.28)]'; -const coldStickyActionCellClass = - 'sticky right-0 z-10 border-l border-[#252b34] bg-[#0b0c0e] px-3 py-3 text-center shadow-[-12px_0_18px_rgba(0,0,0,0.28)]'; -const coldNoticeStatusMessageClass = 'px-3 py-2 text-sm text-[#b9c6d8]'; -const coldNoticeTestStatusMessageClass = - 'rounded-[3px] border border-[#2f3a4a] bg-[#111722] px-3 py-2 text-[12px] font-semibold leading-5 text-[#c6d4e6]'; -const ALERT_NOTICE_SETTLED_CACHE_TTL_MS = 10_000; -const RECEIVER_TEST_SEND_TIMEOUT_MS = 15_000; -const NOTICE_RECEIVER_DRAFT_FINGERPRINT_FIELDS: Array = [ - 'id', - 'name', - 'type', - 'email', - 'phone', - 'hookUrl', - 'hookAuthType', - 'hookAuthToken', - 'wechatId', - 'accessToken', - 'tgBotToken', - 'tgUserId', - 'tgMessageThreadId', - 'larkReceiveType', - 'userId', - 'chatId', - 'slackWebHookUrl', - 'corpId', - 'agentId', - 'appSecret', - 'partyId', - 'tagId', - 'discordChannelId', - 'discordBotToken', - 'smnAk', - 'smnSk', - 'smnProjectId', - 'smnRegion', - 'smnTopicUrn', - 'serverChanToken', - 'gotifyToken', - 'appId' -]; -const NOTICE_RULE_DRAFT_FINGERPRINT_FIELDS: Array = [ - 'id', - 'name', - 'receiverIdsText', - 'templateId', - 'enable', - 'filterAll', - 'labelsText', - 'daysText', - 'periodLimit', - 'periodStart', - 'periodEnd' -]; -const NOTICE_TEMPLATE_DRAFT_FINGERPRINT_FIELDS: Array = [ - 'id', - 'name', - 'type', - 'preset', - 'content' -]; -const EMPTY_ALERT_NOTICE_ROUTE_STATE: AlertNoticeRouteState = { - signal: null, - signalContext: {} -}; - -function serializeNoticeReceiverDraft(draft: NoticeReceiverDraft) { - return JSON.stringify( - NOTICE_RECEIVER_DRAFT_FINGERPRINT_FIELDS.map(field => [field, draft[field] == null ? '' : String(draft[field]).trim()]) - ); -} - -function serializeNoticeRuleDraft(draft: NoticeRuleDraft) { - return JSON.stringify( - NOTICE_RULE_DRAFT_FINGERPRINT_FIELDS.map(field => [field, draft[field] == null ? '' : String(draft[field]).trim()]) - ); -} - -function serializeNoticeTemplateDraft(draft: NoticeTemplateDraft) { - return JSON.stringify( - NOTICE_TEMPLATE_DRAFT_FINGERPRINT_FIELDS.map(field => [field, draft[field] == null ? '' : String(draft[field]).trim()]) - ); -} - -async function withNoticeReceiverTestTimeout(task: Promise, error: Error) { - let timeoutId: ReturnType | null = null; - try { - return await Promise.race([ - task, - new Promise((_, reject) => { - timeoutId = setTimeout(() => reject(error), RECEIVER_TEST_SEND_TIMEOUT_MS); - }) - ]); - } finally { - if (timeoutId) clearTimeout(timeoutId); - } -} - -function parseAlertNoticeRouteInteger(value: string | null, fallback: number, minimum = 0) { - if (!value) return fallback; - const parsed = Number.parseInt(value, 10); - return Number.isFinite(parsed) && parsed >= minimum ? parsed : fallback; -} - -function normalizeAlertNoticeTab(value: string | null, fallback: AlertNoticeRouteTab): AlertNoticeRouteTab { - return value === 'receiver' || value === 'rule' || value === 'template' ? value : fallback; -} - -function writeAlertNoticeRouteParam(params: URLSearchParams, key: string, value: string) { - const cleanValue = value.trim(); - if (cleanValue) { - params.set(key, cleanValue); - } else { - params.delete(key); - } -} - -function writeAlertNoticeRouteIntegerParam( - params: URLSearchParams, - currentParams: URLSearchParams, - key: string, - value: number, - defaultValue: number -) { - const nextValue = String(value); - if (value !== defaultValue || currentParams.get(key) === nextValue) { - params.set(key, nextValue); - } else { - params.delete(key); - } -} - -export function buildAlertNoticeListRouteUrl( - routeSearchParamString: string, - defaultSelectedTab: AlertNoticeRouteTab, - nextState: AlertNoticeListRouteState -) { - const currentParams = new URLSearchParams(routeSearchParamString); - const nextParams = new URLSearchParams(routeSearchParamString); - if (nextState.selectedTab !== defaultSelectedTab || currentParams.get('tab') === nextState.selectedTab) { - nextParams.set('tab', nextState.selectedTab); - } else { - nextParams.delete('tab'); - } - writeAlertNoticeRouteParam(nextParams, 'receiverSearch', nextState.receiverSearch); - writeAlertNoticeRouteParam(nextParams, 'ruleSearch', nextState.ruleSearch); - writeAlertNoticeRouteParam(nextParams, 'templateSearch', nextState.templateSearch); - - writeAlertNoticeRouteIntegerParam(nextParams, currentParams, 'receiverPageIndex', nextState.receiverPageIndex, 0); - writeAlertNoticeRouteIntegerParam(nextParams, currentParams, 'receiverPageSize', nextState.receiverPageSize, 8); - writeAlertNoticeRouteIntegerParam(nextParams, currentParams, 'rulePageIndex', nextState.rulePageIndex, 0); - writeAlertNoticeRouteIntegerParam(nextParams, currentParams, 'rulePageSize', nextState.rulePageSize, 8); - writeAlertNoticeRouteIntegerParam(nextParams, currentParams, 'templatePageIndex', nextState.templatePageIndex, 0); - writeAlertNoticeRouteIntegerParam(nextParams, currentParams, 'templatePageSize', nextState.templatePageSize, 8); - if (!nextState.templatePresetFilter || currentParams.get('templatePreset') === 'true') nextParams.set('templatePreset', String(nextState.templatePresetFilter)); - else nextParams.delete('templatePreset'); - - const nextParamString = nextParams.toString(); - return nextParamString ? `/alert/notice?${nextParamString}` : '/alert/notice'; -} - -function alertNoticeActionHelp(t: Translator, id: string): AlertNoticeActionHelpCopy { - const impactKey = `alert.notice.action.${id}.impact`; - const impact = t(impactKey); - return { - label: t('alert.notice.action.help-aria', { action: t(`alert.notice.action.${id}.label`) }), - body: t(`alert.notice.action.${id}.help`), - impact: impact === impactKey ? undefined : impact - }; -} - -function AlertNoticeActionHelp({ - id, - label, - body, - impact -}: AlertNoticeActionHelpCopy & { - id: string; -}) { - return ( - - - - {body} - {impact ? {impact} : null} - - - ); -} - -function isApiMessageBusinessError(error: unknown) { - return typeof error === 'object' && error !== null && typeof (error as { code?: unknown }).code === 'number'; -} - -function clampNoticePageIndexAfterDelete(pageIndex: number, pageSize: number, totalElements: number, deleteCount = 1) { - const safePageSize = Math.max(1, pageSize); - const nextTotal = Math.max(0, totalElements - deleteCount); - const lastPageIndex = Math.max(0, Math.ceil(nextTotal / safePageSize) - 1); - return Math.min(pageIndex, lastPageIndex); -} - -const NOTICE_TYPE_LABEL_KEYS: Record = { - '0': 'alert.notice.type.sms', - '1': 'alert.notice.type.email', - '2': 'alert.notice.type.url', - '3': 'alert.notice.type.wechat', - '4': 'alert.notice.type.WeCom-robot', - '5': 'alert.notice.type.ding', - '6': 'alert.notice.type.fei-shu', - '7': 'alert.notice.type.telegram-bot', - '8': 'alert.notice.type.slack', - '9': 'alert.notice.type.discord', - '10': 'alert.notice.type.WeComApp', - '11': 'alert.notice.type.smn', - '12': 'alert.notice.type.serverchan', - '13': 'alert.notice.type.gotify', - '14': 'alert.notice.type.lark-app' -}; - -const NOTICE_TEMPLATE_TYPE_LABEL_KEYS: Record = { - ...NOTICE_TYPE_LABEL_KEYS, - '7': 'alert.notice.type.telegram' -}; - -function getNoticeTypeLabel(type: number | string | null | undefined, t: Translator, emptyValue: string) { - if (type == null) return emptyValue; - const normalized = String(type).trim(); - if (!normalized) return emptyValue; - const key = NOTICE_TYPE_LABEL_KEYS[normalized]; - return key ? t(key) : normalized; -} - -function getNoticeTemplateTypeLabel(type: number | string | null | undefined, t: Translator, emptyValue: string) { - if (type == null) return emptyValue; - const normalized = String(type).trim(); - if (!normalized) return emptyValue; - const key = NOTICE_TEMPLATE_TYPE_LABEL_KEYS[normalized]; - return key ? t(key) : normalized; -} - -function formatReceiverSettingValue(value: string | null | undefined, emptyValue: string) { - return value?.trim() || emptyValue; -} - -function formatReceiverSettingParts( - values: Array, - emptyValue: string -) { - const text = values.map(value => String(value ?? '').trim()).filter(Boolean).join(' / '); - return text || emptyValue; -} - -function getReceiverSetting(receiver: NoticeReceiver, emptyValue: string) { - const type = String(receiver.type ?? ''); - switch (type) { - case '0': - return formatReceiverSettingValue(receiver.phone, emptyValue); - case '1': - return formatReceiverSettingValue(receiver.email, emptyValue); - case '2': - return formatReceiverSettingValue(receiver.hookUrl, emptyValue); - case '3': - case '4': - return formatReceiverSettingValue(receiver.wechatId, emptyValue); - case '5': - return formatReceiverSettingValue(receiver.accessToken, emptyValue); - case '6': - return formatReceiverSettingParts([receiver.wechatId, receiver.accessToken], emptyValue); - case '7': - return formatReceiverSettingParts([receiver.tgBotToken, receiver.tgUserId], emptyValue); - case '8': - return formatReceiverSettingValue(receiver.slackWebHookUrl, emptyValue); - case '9': - return formatReceiverSettingParts([receiver.discordChannelId, receiver.discordBotToken], emptyValue); - case '10': - return formatReceiverSettingParts([receiver.corpId, receiver.agentId, receiver.appSecret], emptyValue); - case '11': - return formatReceiverSettingValue(receiver.smnAk, emptyValue); - case '12': - return formatReceiverSettingValue(receiver.serverChanToken, emptyValue); - case '13': - return formatReceiverSettingValue(receiver.gotifyToken, emptyValue); - case '14': - return formatReceiverSettingValue(receiver.appId, emptyValue); - default: - return formatReceiverSettingParts([receiver.email, receiver.phone, receiver.hookUrl], emptyValue); - } -} - -function formatNoticeRuleReceivers(receiverNames: string[] | null | undefined, fallback: string) { - const text = (receiverNames || []).map(name => name.trim()).filter(Boolean).join(','); - return text || fallback; -} - -function NoticePagination({ - t, - pageIndex, - pageSize, - totalElements, - visibleCount, - testIdPrefix, - onPageIndexChange, - onPageSizeChange -}: { - t: Translator; - pageIndex: number; - pageSize: number; - totalElements: number; - visibleCount: number; - testIdPrefix: string; - onPageIndexChange: (pageIndex: number) => void; - onPageSizeChange: (pageSize: number) => void; -}) { - const totalPages = Math.max(1, Math.ceil(totalElements / Math.max(1, pageSize))); - const currentPageIndex = Math.min(Math.max(0, pageIndex), totalPages - 1); - const currentPage = currentPageIndex + 1; - const pageStart = totalElements === 0 || visibleCount === 0 ? 0 : currentPageIndex * pageSize + 1; - const pageEnd = totalElements === 0 ? 0 : Math.min(totalElements, currentPageIndex * pageSize + visibleCount); - const paginationSummary = t('alert.notice.pagination.summary', { - page: currentPage, - totalPages, - from: pageStart, - to: pageEnd, - total: totalElements - }); - - function handlePageJumpChange(value: string) { - const parsed = Number.parseInt(value, 10); - if (!Number.isFinite(parsed)) return; - onPageIndexChange(Math.min(Math.max(parsed, 1), totalPages) - 1); - } - - return ( -
- ({ value: String(option), label: String(option) }))} - pageJumpLabel={t('alert.notice.pagination.page')} - pageJumpValue={String(currentPage)} - pageJumpMax={totalPages} - previousLabel={t('common.previous-page')} - nextLabel={t('common.next-page')} - previousDisabled={currentPageIndex <= 0} - nextDisabled={currentPage >= totalPages} - onPrevious={() => onPageIndexChange(Math.max(0, currentPageIndex - 1))} - onNext={() => onPageIndexChange(Math.min(totalPages - 1, currentPageIndex + 1))} - onPageSizeChange={value => onPageSizeChange(Math.max(1, Number.parseInt(value, 10) || pageSize))} - onPageJumpChange={handlePageJumpChange} - pageJumpInputProps={ - { - 'data-alert-notice-pagination-page-jump-owner': 'hertzbeat-ui-input', - 'data-alert-notice-pagination-page-jump-scope': testIdPrefix - } as React.ComponentProps['pageJumpInputProps'] - } - pageSizeSelectProps={ - { - 'data-alert-notice-pagination-page-size-owner': 'hertzbeat-ui-select', - 'data-alert-notice-pagination-page-size-scope': testIdPrefix - } as React.ComponentProps['pageSizeSelectProps'] - } - className="border-x-0" - /> -
- ); -} - -function NoticeTableEmptyRow({ - t, - colSpan, - prefix, - action -}: { - t: Translator; - colSpan: number; - prefix: 'receiver' | 'rule' | 'template'; - action?: { - label: string; - onClick: () => void; - }; -}) { - return ( - - -
- - -
{t('common.no-data')}
- {action ? ( - - ) : null} -
- - - ); -} - -function NoticeTableSwitch({ - checked, - field, - label, - pending, - commandAction, - onChange -}: { - checked: boolean; - field: 'filter-all' | 'enable'; - label: string; - pending?: boolean; - commandAction?: string; - onChange: (checked: boolean) => void; -}) { - return ( - - ); -} - -export default function AlertNoticePage({ initialRouteState }: { initialRouteState?: AlertNoticeRouteState } = {}) { - const { t, locale } = useI18n(); - const router = useRouter(); - const searchParams = useSearchParams(); - const routeSearchParamString = searchParams.toString(); - const alertNoticeRouteState = initialRouteState ?? EMPTY_ALERT_NOTICE_ROUTE_STATE; - const { signal, signalContext } = alertNoticeRouteState; - const noticeEvidenceContext = useMemo( - () => buildAlertNoticeEvidenceContext(signal, signalContext, t), - [signal, signalContext, t] - ); - const [editingReceiver, setEditingReceiver] = useState(false); - const [receiverDraft, setReceiverDraft] = useState(() => buildNoticeReceiverDraft(null)); - const [receiverInitialFingerprint, setReceiverInitialFingerprint] = useState(() => serializeNoticeReceiverDraft(buildNoticeReceiverDraft(null))); - const [receiverDiscardDialogOpen, setReceiverDiscardDialogOpen] = useState(false); - const [selectedReceiverId, setSelectedReceiverId] = useState(null); - const defaultSelectedTab: AlertNoticeRouteTab = noticeEvidenceContext ? 'rule' : 'receiver'; - const routeListState = useMemo(() => { - const routeParams = new URLSearchParams(routeSearchParamString); - return { - selectedTab: normalizeAlertNoticeTab(routeParams.get('tab'), defaultSelectedTab), - receiverSearch: routeParams.get('receiverSearch') ?? '', - receiverPageIndex: parseAlertNoticeRouteInteger(routeParams.get('receiverPageIndex'), 0), - receiverPageSize: parseAlertNoticeRouteInteger(routeParams.get('receiverPageSize'), 8, 1), - ruleSearch: routeParams.get('ruleSearch') ?? '', - rulePageIndex: parseAlertNoticeRouteInteger(routeParams.get('rulePageIndex'), 0), - rulePageSize: parseAlertNoticeRouteInteger(routeParams.get('rulePageSize'), 8, 1), - templateSearch: routeParams.get('templateSearch') ?? '', - templatePresetFilter: routeParams.get('templatePreset') === 'false' ? false : true, - templatePageIndex: parseAlertNoticeRouteInteger(routeParams.get('templatePageIndex'), 0), - templatePageSize: parseAlertNoticeRouteInteger(routeParams.get('templatePageSize'), 8, 1) - }; - }, [defaultSelectedTab, routeSearchParamString]); - const [receiverSearchDraft, setReceiverSearchDraft] = useState(routeListState.receiverSearch); - const [receiverSearch, setReceiverSearch] = useState(routeListState.receiverSearch); - const [receiverPageIndex, setReceiverPageIndex] = useState(routeListState.receiverPageIndex); - const [receiverPageSize, setReceiverPageSize] = useState(routeListState.receiverPageSize); - const [savingReceiver, setSavingReceiver] = useState(false); - const [testingReceiver, setTestingReceiver] = useState(false); - const [receiverMessage, setReceiverMessage] = useState(null); - const [receiverError, setReceiverError] = useState(null); - const [receiverErrorDetail, setReceiverErrorDetail] = useState(null); - const [receiverValidationIssues, setReceiverValidationIssues] = useState([]); - const [editingTemplate, setEditingTemplate] = useState(false); - const [templateDraft, setTemplateDraft] = useState(() => buildNoticeTemplateDraft(null)); - const [templateInitialFingerprint, setTemplateInitialFingerprint] = useState(() => serializeNoticeTemplateDraft(buildNoticeTemplateDraft(null))); - const [templateDiscardDialogOpen, setTemplateDiscardDialogOpen] = useState(false); - const [templateReadOnly, setTemplateReadOnly] = useState(false); - const [selectedTemplateId, setSelectedTemplateId] = useState(null); - const [savingTemplate, setSavingTemplate] = useState(false); - const [templateMessage, setTemplateMessage] = useState(null); - const [templateError, setTemplateError] = useState(null); - const [templateErrorDetail, setTemplateErrorDetail] = useState(null); - const [templateValidationIssues, setTemplateValidationIssues] = useState([]); - const [templateSearchDraft, setTemplateSearchDraft] = useState(routeListState.templateSearch); - const [templateSearch, setTemplateSearch] = useState(routeListState.templateSearch); - const [templatePresetFilter, setTemplatePresetFilter] = useState(routeListState.templatePresetFilter); - const [templatePageIndex, setTemplatePageIndex] = useState(routeListState.templatePageIndex); - const [templatePageSize, setTemplatePageSize] = useState(routeListState.templatePageSize); - const [selectedTab, setSelectedTab] = useState(() => routeListState.selectedTab); - const [editingRule, setEditingRule] = useState(false); - const [ruleDraft, setRuleDraft] = useState(() => buildNoticeRuleDraft(null)); - const [ruleInitialFingerprint, setRuleInitialFingerprint] = useState(() => serializeNoticeRuleDraft(buildNoticeRuleDraft(null))); - const [ruleDiscardDialogOpen, setRuleDiscardDialogOpen] = useState(false); - const [selectedRuleId, setSelectedRuleId] = useState(null); - const [ruleSearchDraft, setRuleSearchDraft] = useState(routeListState.ruleSearch); - const [ruleSearch, setRuleSearch] = useState(routeListState.ruleSearch); - const [rulePageIndex, setRulePageIndex] = useState(routeListState.rulePageIndex); - const [rulePageSize, setRulePageSize] = useState(routeListState.rulePageSize); - const [savingRule, setSavingRule] = useState(false); - const [ruleMessage, setRuleMessage] = useState(null); - const [ruleError, setRuleError] = useState(null); - const [ruleErrorDetail, setRuleErrorDetail] = useState(null); - const [ruleSwitchPending, setRuleSwitchPending] = useState(null); - const [refreshTick, setRefreshTick] = useState(0); - const [deleteRequest, setDeleteRequest] = useState(null); - const [deletePending, setDeletePending] = useState(false); - const alertNoticeReceiverListUrl = useMemo(() => buildNoticeListUrl('/notice/receivers', { search: receiverSearch, pageIndex: receiverPageIndex, pageSize: receiverPageSize }), [receiverPageIndex, receiverPageSize, receiverSearch]); - const alertNoticeRuleListUrl = useMemo(() => buildNoticeListUrl('/notice/rules', { search: ruleSearch, pageIndex: rulePageIndex, pageSize: rulePageSize }), [rulePageIndex, rulePageSize, ruleSearch]); - const alertNoticeTemplateListUrl = useMemo( - () => buildNoticeTemplateListUrl({ search: templateSearch, preset: templatePresetFilter, pageIndex: templatePageIndex, pageSize: templatePageSize }), - [templatePageIndex, templatePageSize, templatePresetFilter, templateSearch] - ); - const receiverDraftFingerprint = useMemo(() => serializeNoticeReceiverDraft(receiverDraft), [receiverDraft]); - const shouldConfirmReceiverDiscard = Boolean(editingReceiver && receiverDraftFingerprint !== receiverInitialFingerprint && !savingReceiver); - const ruleDraftFingerprint = useMemo(() => serializeNoticeRuleDraft(ruleDraft), [ruleDraft]); - const shouldConfirmRuleDiscard = Boolean(editingRule && ruleDraftFingerprint !== ruleInitialFingerprint && !savingRule); - const templateDraftFingerprint = useMemo(() => serializeNoticeTemplateDraft(templateDraft), [templateDraft]); - const shouldConfirmTemplateDiscard = Boolean( - editingTemplate && !templateReadOnly && templateDraftFingerprint !== templateInitialFingerprint && !savingTemplate - ); - const alertNoticeLoadQuery = useMemo( - () => ({ - receivers: { - search: receiverSearch, - pageIndex: receiverPageIndex, - pageSize: receiverPageSize - }, - rules: { - search: ruleSearch, - pageIndex: rulePageIndex, - pageSize: rulePageSize - }, - templates: { - search: templateSearch, - preset: templatePresetFilter, - pageIndex: templatePageIndex, - pageSize: templatePageSize - } - }), - [receiverPageIndex, receiverPageSize, receiverSearch, rulePageIndex, rulePageSize, ruleSearch, templatePageIndex, templatePageSize, templatePresetFilter, templateSearch] - ); - const alertNoticeCacheKey = useMemo( - () => ['alert-notice', alertNoticeReceiverListUrl, alertNoticeRuleListUrl, alertNoticeTemplateListUrl, refreshTick].join('|'), - [alertNoticeReceiverListUrl, alertNoticeRuleListUrl, alertNoticeTemplateListUrl, refreshTick] - ); - const currentListRouteState = useMemo(() => ({ - selectedTab: selectedTab as AlertNoticeRouteTab, - receiverSearch, - receiverPageIndex, - receiverPageSize, - ruleSearch, - rulePageIndex, - rulePageSize, - templateSearch, - templatePresetFilter, - templatePageIndex, - templatePageSize - }), [receiverPageIndex, receiverPageSize, receiverSearch, rulePageIndex, rulePageSize, ruleSearch, selectedTab, templatePageIndex, templatePageSize, templatePresetFilter, templateSearch]); - - useEffect(() => { - setSelectedTab(routeListState.selectedTab); - setReceiverSearchDraft(routeListState.receiverSearch); - setReceiverSearch(routeListState.receiverSearch); - setReceiverPageIndex(routeListState.receiverPageIndex); - setReceiverPageSize(routeListState.receiverPageSize); - setRuleSearchDraft(routeListState.ruleSearch); - setRuleSearch(routeListState.ruleSearch); - setRulePageIndex(routeListState.rulePageIndex); - setRulePageSize(routeListState.rulePageSize); - setTemplateSearchDraft(routeListState.templateSearch); - setTemplateSearch(routeListState.templateSearch); - setTemplatePresetFilter(routeListState.templatePresetFilter); - setTemplatePageIndex(routeListState.templatePageIndex); - setTemplatePageSize(routeListState.templatePageSize); - }, [routeListState]); - - const replaceListRouteState = useCallback((nextState: AlertNoticeListRouteState) => { - const nextUrl = buildAlertNoticeListRouteUrl(routeSearchParamString, defaultSelectedTab, nextState); - const currentUrl = routeSearchParamString ? `/alert/notice?${routeSearchParamString}` : '/alert/notice'; - if (nextUrl !== currentUrl) { - router.replace(nextUrl, { scroll: false }); - } - }, [defaultSelectedTab, routeSearchParamString, router]); - - const load = useCallback(async (): Promise => { - void refreshTick; - const [noticeData, labelOptions] = await Promise.all([ - loadAlertNoticeDataFromFacade( - { - receivers: api.alertNotice.receivers.list, - rules: api.alertNotice.rules.list, - receiverOptions: api.alertNotice.receivers.options, - templates: api.alertNotice.templates.list, - templateOptions: api.alertNotice.templates.options - }, - alertNoticeLoadQuery - ), - loadAlertLabelOptionsFromFacade(api.alertLabels.list).catch(() => DEFAULT_ALERT_LABEL_OPTIONS) - ]); - return { ...noticeData, labelOptions }; - }, [alertNoticeLoadQuery, refreshTick]); - - const productCopy = getAlertNoticeProductCopy(t); - const emptyValue = t('common.none'); - - return ( - - {data => { - const selectedReceiver = data.receivers.content.find(item => item.id === selectedReceiverId) ?? data.receivers.content[0] ?? null; - const selectedRule = data.rules.content.find(item => item.id === selectedRuleId) ?? data.rules.content[0] ?? null; - const labelOptions = data.labelOptions ?? DEFAULT_ALERT_LABEL_OPTIONS; - const selectedTemplate = - data.templates.content.find(item => item.id === selectedTemplateId) ?? - data.templates.content.find(item => !item.preset) ?? - data.templates.content[0] ?? - null; - const selectedTemplateIsCustom = selectedTemplate ? !selectedTemplate.preset : false; - const templateTotalElements = data.templates.totalElements ?? data.templates.content.length; - const normalizedTemplatePageIndex = data.templates.pageIndex ?? templatePageIndex; - const templateVisibleRows = data.templates.content; - const receiverOptionRows = data.receiverOptions?.content?.length ? data.receiverOptions.content : data.receivers.content; - const receiverOptions = receiverOptionRows.map(receiver => ({ - value: String(receiver.id), - label: `${receiver.name || productCopy.receiverFallback}-${getNoticeTypeLabel(receiver.type, t, emptyValue)}`, - type: receiver.type - })); - const templateOptions = [ - { value: '-1', label: t('alert.notice.template.preset.true') }, - ...(data.templateOptions?.content ?? data.templates.content) - .filter(template => template.id != null) - .map(template => ({ - value: String(template.id), - label: template.name || productCopy.templateFallback, - type: template.type - })) - ]; - const receiverTotal = data.receivers.totalElements ?? data.receivers.content.length; - const ruleTotal = data.rules.totalElements ?? data.rules.content.length; - const templateTotal = data.templates.totalElements ?? data.templates.content.length; - - function closeReceiverEditor() { - setEditingReceiver(false); - setReceiverDiscardDialogOpen(false); - setReceiverMessage(null); - setReceiverError(null); - setReceiverErrorDetail(null); - setReceiverValidationIssues([]); - } - - function requestCloseReceiverEditor() { - if (shouldConfirmReceiverDiscard) { - setReceiverDiscardDialogOpen(true); - return; - } - closeReceiverEditor(); - } - - function closeTemplateEditor() { - setEditingTemplate(false); - setTemplateReadOnly(false); - setTemplateDiscardDialogOpen(false); - setTemplateMessage(null); - setTemplateError(null); - setTemplateErrorDetail(null); - setTemplateValidationIssues([]); - } - - function requestCloseTemplateEditor() { - if (shouldConfirmTemplateDiscard) { - setTemplateDiscardDialogOpen(true); - return; - } - closeTemplateEditor(); - } - - function closeRuleEditor() { - setEditingRule(false); - setRuleDiscardDialogOpen(false); - setRuleMessage(null); - setRuleError(null); - setRuleErrorDetail(null); - } - - function requestCloseRuleEditor() { - if (shouldConfirmRuleDiscard) { - setRuleDiscardDialogOpen(true); - return; - } - closeRuleEditor(); - } - - async function handleNewReceiver() { - setSelectedTab('receiver'); - const nextDraft = buildNoticeReceiverDraft(null); - setReceiverDraft(nextDraft); - setReceiverInitialFingerprint(serializeNoticeReceiverDraft(nextDraft)); - setReceiverMessage(null); - setReceiverError(null); - setReceiverErrorDetail(null); - setReceiverValidationIssues([]); - setEditingReceiver(true); - } - - async function handleEditReceiver(receiver = selectedReceiver) { - if (!receiver?.id) return; - try { - setSelectedTab('receiver'); - setSelectedReceiverId(receiver.id); - const detail = await api.alertNotice.receivers.detail(receiver.id); - const nextDraft = buildNoticeReceiverDraft(detail); - setReceiverDraft(nextDraft); - setReceiverInitialFingerprint(serializeNoticeReceiverDraft(nextDraft)); - setReceiverMessage(null); - setReceiverError(null); - setReceiverErrorDetail(null); - setReceiverValidationIssues([]); - setEditingReceiver(true); - } catch (error) { - setReceiverError(error instanceof Error ? error.message : t('common.notify.edit-fail')); - setReceiverErrorDetail(null); - } - } - - function focusReceiverValidationField(field: NoticeReceiverValidationIssue['field']) { - window.setTimeout(() => { - document.querySelector(`[data-testid="notice-receiver-field-${String(field)}"]`)?.focus(); - }, 0); - } - - function handleReceiverDraftChange(nextDraft: React.SetStateAction) { - setReceiverDraft(nextDraft); - setReceiverValidationIssues([]); - setReceiverError(null); - setReceiverErrorDetail(null); - } - - async function handleSaveReceiver() { - const isEdit = Boolean(receiverDraft.id); - const validationIssues = buildNoticeReceiverValidationIssues(receiverDraft, t); - if (validationIssues.length > 0) { - setReceiverValidationIssues(validationIssues); - setReceiverMessage(null); - setReceiverError(validationIssues.map(issue => issue.message).join(', ')); - setReceiverErrorDetail(null); - focusReceiverValidationField(validationIssues[0].field); - return; - } - setReceiverValidationIssues([]); - setSavingReceiver(true); - setReceiverMessage(null); - setReceiverError(null); - setReceiverErrorDetail(null); - try { - if (receiverDraft.id) { - await api.alertNotice.receivers.update(receiverDraft); - } else { - await api.alertNotice.receivers.create(receiverDraft); - } - setReceiverInitialFingerprint(serializeNoticeReceiverDraft(receiverDraft)); - setReceiverMessage([t(isEdit ? 'common.notify.edit-success' : 'common.notify.new-success'), t('alert.notice.receiver.next')].join(' ')); - setEditingReceiver(false); - setRefreshTick(value => value + 1); - } catch (error) { - setReceiverError(t(isEdit ? 'common.notify.edit-fail' : 'common.notify.new-fail')); - setReceiverErrorDetail(error instanceof Error ? error.message : null); - if (!isApiMessageBusinessError(error)) { - setEditingReceiver(false); - } - } finally { - setSavingReceiver(false); - } - } - - async function handleDeleteReceiver() { - if (!selectedReceiver?.id) return; - await handleDeleteReceiverById(selectedReceiver.id, selectedReceiver.name); - } - - async function handleDeleteReceiverById(receiverId: number, receiverName?: string | null) { - setDeleteRequest({ kind: 'receiver', id: receiverId, name: receiverName?.trim() || undefined }); - } - - async function handleConfirmedDelete() { - const request = deleteRequest; - if (!request) return; - setDeletePending(true); - try { - if (request.kind === 'receiver') { - await api.alertNotice.receivers.delete(request.id); - setSelectedReceiverId(null); - setReceiverPageIndex(pageIndex => clampNoticePageIndexAfterDelete(pageIndex, receiverPageSize, receiverTotal, 1)); - setReceiverMessage(t('common.notify.delete-success')); - setReceiverError(null); - setReceiverErrorDetail(null); - setEditingReceiver(false); - } else if (request.kind === 'template') { - await api.alertNotice.templates.delete(request.id); - setSelectedTemplateId(null); - setTemplatePageIndex(pageIndex => clampNoticePageIndexAfterDelete(pageIndex, templatePageSize, templateTotal, 1)); - setTemplateMessage(t('common.notify.delete-success')); - setTemplateError(null); - setTemplateErrorDetail(null); - setTemplateReadOnly(false); - setEditingTemplate(false); - } else { - await api.alertNotice.rules.delete(request.id); - setSelectedRuleId(null); - setRulePageIndex(pageIndex => clampNoticePageIndexAfterDelete(pageIndex, rulePageSize, ruleTotal, 1)); - setRuleMessage(t('common.notify.delete-success')); - setRuleError(null); - setRuleErrorDetail(null); - setEditingRule(false); - } - setDeleteRequest(null); - setRefreshTick(value => value + 1); - } catch (error) { - const fallback = error instanceof Error ? error.message : t('common.notify.delete-fail'); - if (request.kind === 'receiver') { - setReceiverError(fallback); - setReceiverErrorDetail(null); - } else if (request.kind === 'template') { - setTemplateError(fallback); - setTemplateErrorDetail(null); - } else { - setRuleError(fallback); - setRuleErrorDetail(null); - } - } finally { - setDeletePending(false); - } - } - - async function handleTestSend() { - setTestingReceiver(true); - setReceiverMessage(null); - setReceiverError(null); - setReceiverErrorDetail(null); - try { - await withNoticeReceiverTestTimeout( - api.alertNotice.receivers.sendTest(receiverDraft), - new Error(t('alert.notice.send-test.timeout.detail')) - ); - setReceiverMessage(t('alert.notice.send-test.notify.success')); - setReceiverError(null); - setReceiverErrorDetail(null); - } catch (error) { - setReceiverMessage(null); - setReceiverError(t('alert.notice.send-test.notify.failed')); - setReceiverErrorDetail(error instanceof Error ? error.message : null); - } finally { - setTestingReceiver(false); - } - } - - async function handleNewTemplate() { - setSelectedTab('template'); - const nextDraft = buildNoticeTemplateDraft(null); - setTemplateDraft(nextDraft); - setTemplateInitialFingerprint(serializeNoticeTemplateDraft(nextDraft)); - setTemplateReadOnly(false); - setTemplateMessage(null); - setTemplateError(null); - setTemplateErrorDetail(null); - setTemplateValidationIssues([]); - setEditingTemplate(true); - } - - async function handleEditTemplate(template = selectedTemplate) { - if (!template?.id || template.preset) return; - try { - setSelectedTab('template'); - setSelectedTemplateId(template.id); - setTemplateReadOnly(false); - const detail = await api.alertNotice.templates.detail(template.id); - const nextDraft = buildNoticeTemplateDraft(detail); - setTemplateDraft(nextDraft); - setTemplateInitialFingerprint(serializeNoticeTemplateDraft(nextDraft)); - setTemplateMessage(null); - setTemplateError(null); - setTemplateErrorDetail(null); - setTemplateValidationIssues([]); - setEditingTemplate(true); - } catch (error) { - setTemplateError(error instanceof Error ? error.message : t('common.notify.edit-fail')); - setTemplateErrorDetail(null); - } - } - - async function handleViewTemplate(template: NoticeTemplate) { - const templateId = typeof template.id === 'number' && Number.isFinite(template.id) ? template.id : null; - const rowDraft = buildNoticeTemplateDraft(template); - setSelectedTab('template'); - setSelectedTemplateId(templateId); - setTemplateReadOnly(true); - setTemplateDraft(rowDraft); - setTemplateInitialFingerprint(serializeNoticeTemplateDraft(rowDraft)); - setTemplateMessage(null); - setTemplateError(null); - setTemplateErrorDetail(null); - setTemplateValidationIssues([]); - setEditingTemplate(true); - - if (templateId == null) return; - - try { - const detail = await api.alertNotice.templates.detail(templateId); - const nextDraft = buildNoticeTemplateDraft(detail); - setTemplateDraft(nextDraft); - setTemplateInitialFingerprint(serializeNoticeTemplateDraft(nextDraft)); - } catch (error) { - setTemplateError(null); - setTemplateErrorDetail(null); - } - } - - function focusTemplateValidationField(field: NoticeTemplateValidationIssue['field']) { - window.setTimeout(() => { - document.querySelector(`[data-testid="notice-template-field-${String(field)}"]`)?.focus(); - }, 0); - } - - function handleTemplateDraftChange(nextDraft: React.SetStateAction) { - setTemplateDraft(nextDraft); - setTemplateValidationIssues([]); - setTemplateError(null); - setTemplateErrorDetail(null); - } - - async function handleSaveTemplate() { - const isEdit = Boolean(templateDraft.id); - const validationIssues = buildNoticeTemplateValidationIssues(templateDraft, t); - if (validationIssues.length > 0) { - setTemplateValidationIssues(validationIssues); - setTemplateMessage(null); - setTemplateError(validationIssues.map(issue => issue.message).join(', ')); - setTemplateErrorDetail(null); - focusTemplateValidationField(validationIssues[0].field); - return; - } - setTemplateValidationIssues([]); - setSavingTemplate(true); - setTemplateMessage(null); - setTemplateError(null); - setTemplateErrorDetail(null); - try { - if (templateDraft.id) { - await api.alertNotice.templates.update(templateDraft); - } else { - await api.alertNotice.templates.create(templateDraft); - } - setTemplateInitialFingerprint(serializeNoticeTemplateDraft(templateDraft)); - setTemplateMessage(t(isEdit ? 'common.notify.edit-success' : 'common.notify.new-success')); - setEditingTemplate(false); - if (!isEdit) { - const nextTemplateSearch = templateDraft.name.trim(); - setTemplateSearchDraft(nextTemplateSearch); - setTemplateSearch(nextTemplateSearch); - setTemplatePresetFilter(false); - setTemplatePageIndex(0); - replaceListRouteState({ - ...currentListRouteState, - selectedTab: 'template', - templateSearch: nextTemplateSearch, - templatePresetFilter: false, - templatePageIndex: 0 - }); - } - setRefreshTick(value => value + 1); - } catch (error) { - setTemplateError(t(isEdit ? 'common.notify.edit-fail' : 'common.notify.new-fail')); - setTemplateErrorDetail(error instanceof Error ? error.message : null); - } finally { - setSavingTemplate(false); - } - } - - async function handleDeleteTemplate() { - if (!selectedTemplate?.id || selectedTemplate.preset) return; - await handleDeleteTemplateById(selectedTemplate.id, selectedTemplate.name); - } - - async function handleDeleteTemplateById(templateId: number, templateName?: string | null) { - setDeleteRequest({ kind: 'template', id: templateId, name: templateName?.trim() || undefined }); - } - - function commitTemplateSearch() { - const nextSearch = templateSearchDraft.trim(); - setTemplateSearch(nextSearch); - setTemplatePageIndex(0); - replaceListRouteState({ - ...currentListRouteState, - templateSearch: nextSearch, - templatePageIndex: 0 - }); - } - - function resetTemplateSearch() { - setTemplateSearchDraft(''); - setTemplateSearch(''); - setTemplatePageIndex(0); - replaceListRouteState({ - ...currentListRouteState, - templateSearch: '', - templatePageIndex: 0 - }); - } - - async function handleNewRule() { - setSelectedTab('rule'); - const nextDraft = buildNoticeRuleDraft(null, noticeEvidenceContext?.ruleDraftPatch); - setRuleDraft(nextDraft); - setRuleInitialFingerprint(serializeNoticeRuleDraft(nextDraft)); - setRuleMessage(null); - setRuleError(null); - setRuleErrorDetail(null); - setEditingRule(true); - } - - async function handleEditRule(rule = selectedRule) { - const ruleId = rule?.id; - if (!ruleId) return; - try { - setSelectedTab('rule'); - setSelectedRuleId(ruleId); - const detail = await api.alertNotice.rules.detail(ruleId); - const nextDraft = buildNoticeRuleDraft(detail); - setRuleDraft(nextDraft); - setRuleInitialFingerprint(serializeNoticeRuleDraft(nextDraft)); - setRuleMessage(null); - setRuleError(null); - setRuleErrorDetail(null); - setEditingRule(true); - } catch (error) { - setRuleError(error instanceof Error ? error.message : t('common.notify.edit-fail')); - setRuleErrorDetail(null); - } - } - - function handleRuleDraftChange(nextDraft: React.SetStateAction) { - setRuleDraft(nextDraft); - setRuleMessage(null); - setRuleError(null); - setRuleErrorDetail(null); - } - - async function handleSaveRule() { - const isEdit = Boolean(ruleDraft.id); - const validationError = validateNoticeRuleDraft(ruleDraft, t); - if (validationError) { - setRuleMessage(null); - setRuleError(validationError); - setRuleErrorDetail(null); - return; - } - setSavingRule(true); - setRuleMessage(null); - setRuleError(null); - setRuleErrorDetail(null); - try { - const displayNames = buildNoticeRuleDisplayNames(ruleDraft, receiverOptions, templateOptions); - if (ruleDraft.id) { - await api.alertNotice.rules.update(ruleDraft, displayNames); - } else { - await api.alertNotice.rules.create(ruleDraft, displayNames); - } - setRuleInitialFingerprint(serializeNoticeRuleDraft(ruleDraft)); - setRuleMessage(t(isEdit ? 'common.notify.edit-success' : 'common.notify.new-success')); - setEditingRule(false); - setRefreshTick(value => value + 1); - } catch (error) { - setRuleError(t(isEdit ? 'common.notify.edit-fail' : 'common.notify.new-fail')); - setRuleErrorDetail(error instanceof Error ? error.message : null); - } finally { - setSavingRule(false); - } - } - - async function handleDeleteRule() { - if (!selectedRule?.id) return; - await handleDeleteRuleById(selectedRule.id, selectedRule.name); - } - - async function handleDeleteRuleById(ruleId: number, ruleName?: string | null) { - setDeleteRequest({ kind: 'rule', id: ruleId, name: ruleName?.trim() || undefined }); - } - - async function handleToggleRuleSwitch(rule: NoticeRule, field: 'filterAll' | 'enable', checked: boolean) { - if (!rule.id) return; - const pendingKey = `${rule.id}:${field}`; - setRuleSwitchPending(pendingKey); - setRuleMessage(null); - setRuleError(null); - setRuleErrorDetail(null); - try { - await api.alertNotice.rules.update( - buildNoticeRuleDraft({ ...rule, [field]: checked }), - { - receiverName: rule.receiverName ?? [], - templateName: rule.templateId ? rule.templateName ?? null : null - } - ); - setRuleMessage(t('common.notify.edit-success')); - setRefreshTick(value => value + 1); - } catch (error) { - setRuleError(error instanceof Error ? error.message : t('common.notify.edit-fail')); - setRuleErrorDetail(null); - } finally { - setRuleSwitchPending(current => (current === pendingKey ? null : current)); - } - } - - function commitReceiverSearch() { - const nextSearch = receiverSearchDraft.trim(); - setReceiverSearch(nextSearch); - setReceiverPageIndex(0); - replaceListRouteState({ - ...currentListRouteState, - receiverSearch: nextSearch, - receiverPageIndex: 0 - }); - } - - function resetReceiverSearch() { - setReceiverSearchDraft(''); - setReceiverSearch(''); - setReceiverPageIndex(0); - replaceListRouteState({ - ...currentListRouteState, - receiverSearch: '', - receiverPageIndex: 0 - }); - } - - function commitRuleSearch() { - const nextSearch = ruleSearchDraft.trim(); - setRuleSearch(nextSearch); - setRulePageIndex(0); - replaceListRouteState({ - ...currentListRouteState, - ruleSearch: nextSearch, - rulePageIndex: 0 - }); - } - - function resetRuleSearch() { - setRuleSearchDraft(''); - setRuleSearch(''); - setRulePageIndex(0); - replaceListRouteState({ - ...currentListRouteState, - ruleSearch: '', - rulePageIndex: 0 - }); - } - - const receiverPanel = ( -
-
-
- - - - - - - - - - - - -
- -
- - - - - {t('alert.notice.receiver.people')} - {t('alert.notice.receiver.type')} - {t('alert.notice.receiver.setting')} - {t('common.edit-time')} - {t('common.edit')} - - - - {data.receivers.content.length > 0 ? data.receivers.content.map(receiver => ( - setSelectedReceiverId(receiver.id)} - > - - {receiver.name || t('alert.notice.receiver.people')} - - -
- {getNoticeTypeLabel(receiver.type, t, emptyValue)} -
- - {getReceiverSetting(receiver, emptyValue)} - {formatTime(receiver.gmtUpdate || receiver.gmtCreate || null)} - event.stopPropagation()}> -
- - - - - - - - -
- - - )) : ( - void handleNewReceiver() - }} - /> - )} - -
-
- {(data.receivers.totalElements || 0) > 0 ? ( - { - setReceiverPageIndex(nextPageIndex); - replaceListRouteState({ - ...currentListRouteState, - receiverPageIndex: nextPageIndex - }); - }} - onPageSizeChange={nextPageSize => { - setReceiverPageSize(nextPageSize); - setReceiverPageIndex(0); - replaceListRouteState({ - ...currentListRouteState, - receiverPageIndex: 0, - receiverPageSize: nextPageSize - }); - }} - /> - ) : null} - {!editingReceiver && receiverMessage ?
{receiverMessage}
: null} - {!editingReceiver && receiverError ? ( -
- {receiverError} - {receiverErrorDetail ? {receiverErrorDetail} : null} -
- ) : null} -
- ); - - const rulePanel = ( -
-
-
- - - - - - - - - - - - -
- -
- - - - - {t('alert.notice.rule.name')} - {t('alert.notice.receiver.people')} - {t('alert.notice.template.name')} - {t('alert.notice.rule.all')} - {t('common.enable')} - {t('common.edit-time')} - {t('common.edit')} - - - - {data.rules.content.length > 0 ? data.rules.content.map(rule => ( - setSelectedRuleId(rule.id)} - > - {rule.name || t('alert.notice.rule')} - {formatNoticeRuleReceivers(rule.receiverName, productCopy.ruleNoReceiver)} - {rule.templateId ? rule.templateName || t('alert.notice.template.preset.true') : t('alert.notice.template.preset.true')} - event.stopPropagation()}> - - void handleToggleRuleSwitch(rule, 'filterAll', checked)} - /> - - - - event.stopPropagation()}> - - void handleToggleRuleSwitch(rule, 'enable', checked)} - /> - - - - {formatTime(rule.gmtUpdate || rule.gmtCreate || null)} - event.stopPropagation()}> -
- - - - - - - - -
- - - )) : ( - void handleNewRule() - }} - /> - )} - -
-
- {(data.rules.totalElements || 0) > 0 ? ( - { - setRulePageIndex(nextPageIndex); - replaceListRouteState({ - ...currentListRouteState, - rulePageIndex: nextPageIndex - }); - }} - onPageSizeChange={nextPageSize => { - setRulePageSize(nextPageSize); - setRulePageIndex(0); - replaceListRouteState({ - ...currentListRouteState, - rulePageIndex: 0, - rulePageSize: nextPageSize - }); - }} - /> - ) : null} - {ruleMessage ?
{ruleMessage}
: null} - {!editingRule && ruleError ? ( -
{ruleError}
- ) : null} -
- ); - - const templatePanel = ( -
-
-
- - - - - - - - - - - - -
-
- - -
-
- - - - - {t('alert.notice.template.name')} - {t('alert.notice.template.type')} - {t('alert.notice.template.preset')} - {t('common.edit-time')} - {t('common.edit')} - - - - {templateVisibleRows.length > 0 ? templateVisibleRows.map((template, index) => ( - setSelectedTemplateId(typeof template.id === 'number' ? template.id : null)} - > - {template.name || t('alert.notice.template')} - -
- {getNoticeTemplateTypeLabel(template.type, t, emptyValue)} -
- - -
- {template.preset ? t('alert.notice.template.preset.true') : t('alert.notice.template.preset.false')} -
- - {formatTime(template.gmtUpdate || template.gmtCreate || null)} - event.stopPropagation()}> -
- {template.preset ? ( - - - - - ) : ( - <> - - - - - - - - - - )} -
- - - )) : ( - void handleNewTemplate() - }} - /> - )} - -
-
- {templateTotalElements > 0 ? ( - { - setTemplatePageIndex(nextPageIndex); - replaceListRouteState({ - ...currentListRouteState, - templatePageIndex: nextPageIndex - }); - }} - onPageSizeChange={nextPageSize => { - setTemplatePageSize(nextPageSize); - setTemplatePageIndex(0); - replaceListRouteState({ - ...currentListRouteState, - templatePageIndex: 0, - templatePageSize: nextPageSize - }); - }} - /> - ) : null} - {templateMessage ?
{templateMessage}
: null} - {!editingTemplate && templateError ? ( -
{templateError}
- ) : null} -
- ); - - const receiverEditorDialog = ( - - - - - - - - - - - - - -
- } - > -
- - - ); - - const ruleEditorDialog = ( - - {noticeEvidenceContext?.returnHref ? ( - - - ) : null} - - - - - - - - -
- } - > -
- {editingRule && ruleError ? ( -
- {ruleError} - {ruleErrorDetail ? {ruleErrorDetail} : null} -
- ) : null} - -
- - ); - - const templateEditorDialog = ( - - - - - - {!templateReadOnly ? ( - - - - - ) : null} -
- } - > - {templateReadOnly ? ( -
- -
- ) : ( -
- {editingTemplate && templateError ? ( -
0 ? String(templateValidationIssues.length) : undefined} - className="rounded-[3px] border border-[#6f3141] bg-[#1b1014] px-3 py-2 text-[12px] font-semibold leading-5 text-[#ffb4c1]" - > - {templateError} - {templateErrorDetail ? {templateErrorDetail} : null} -
- ) : null} - -
- )} - - ); - const deleteConfirmCopy = [ - deleteRequest?.kind === 'receiver' - ? t('alert.notice.delete.confirm.receiver') - : deleteRequest?.kind === 'template' - ? t('alert.notice.delete.confirm.template') - : t('alert.notice.delete.confirm.rule'), - deleteRequest?.name ? t('alert.notice.delete.confirm.target', { name: deleteRequest.name }) : null - ].filter(Boolean).join(' '); - const deleteConfirmActionLabel = - deleteRequest?.kind === 'receiver' - ? t('alert.notice.delete.confirm.receiver-action') - : deleteRequest?.kind === 'template' - ? t('alert.notice.delete.confirm.template-action') - : t('alert.notice.delete.confirm.rule-action'); - - return ( - <> -
-
-
-
-
-
-
-

{t('menu.alert.dispatch')}

-

- {t('alert.notice.copy')} -

-
- - - -
-
-
- {[ - { label: t('alert.notice.receiver'), value: receiverTotal }, - { label: t('alert.notice.rule'), value: ruleTotal }, - { label: t('alert.notice.template'), value: templateTotal } - ].map(item => ( -
- {item.label} - {item.value} -
- ))} -
-
-
-
- { - setSelectedTab(nextTab); - replaceListRouteState({ - ...currentListRouteState, - selectedTab: nextTab - }); - }} - receiverContent={receiverPanel} - ruleContent={rulePanel} - templateContent={templatePanel} - /> - {noticeEvidenceContext ? ( -
-
-
-

{noticeEvidenceContext.title}

-

{noticeEvidenceContext.copy}

-
- {noticeEvidenceContext.returnHref ? ( - - {t('alert.rule.evidence.return')} - - ) : null} -
-
- {noticeEvidenceContext.labelsText || emptyValue} -
-
- {noticeEvidenceContext.rows.map(row => ( -
-

{row.label}

-

{row.value}

-

{row.meta}

-
- ))} -
-
- ) : null} -
-
-
- {receiverEditorDialog} - {ruleEditorDialog} - {templateEditorDialog} -
- setTemplateDiscardDialogOpen(false)} - onConfirm={closeTemplateEditor} - data-alert-notice-template-unsaved-cancel-dialog="hertzbeat-ui-confirm-dialog" - cancelButtonProps={ - { - type: 'button', - 'data-alert-notice-template-unsaved-cancel-keep-editing': 'true' - } as React.ComponentProps['cancelButtonProps'] - } - confirmButtonProps={ - { - type: 'button', - 'data-alert-notice-template-unsaved-cancel-confirm': 'true' - } as React.ComponentProps['confirmButtonProps'] - } - > -

- {t('alert.notice.template.unsaved-cancel.copy')} -

-
-
-
- setRuleDiscardDialogOpen(false)} - onConfirm={closeRuleEditor} - data-alert-notice-rule-unsaved-cancel-dialog="hertzbeat-ui-confirm-dialog" - cancelButtonProps={ - { - type: 'button', - 'data-alert-notice-rule-unsaved-cancel-keep-editing': 'true' - } as React.ComponentProps['cancelButtonProps'] - } - confirmButtonProps={ - { - type: 'button', - 'data-alert-notice-rule-unsaved-cancel-confirm': 'true' - } as React.ComponentProps['confirmButtonProps'] - } - > -

- {t('alert.notice.rule.unsaved-cancel.copy')} -

-
-
-
- setReceiverDiscardDialogOpen(false)} - onConfirm={closeReceiverEditor} - data-alert-notice-receiver-unsaved-cancel-dialog="hertzbeat-ui-confirm-dialog" - cancelButtonProps={ - { - type: 'button', - 'data-alert-notice-receiver-unsaved-cancel-keep-editing': 'true' - } as React.ComponentProps['cancelButtonProps'] - } - confirmButtonProps={ - { - type: 'button', - 'data-alert-notice-receiver-unsaved-cancel-confirm': 'true' - } as React.ComponentProps['confirmButtonProps'] - } - > -

- {t('alert.notice.receiver.unsaved-cancel.copy')} -

-
-
-
- setDeleteRequest(null)} - onConfirm={() => void handleConfirmedDelete()} - data-alert-notice-delete-confirm-dialog="angular-modal-confirm" - confirmButtonProps={ - { - 'data-alert-notice-delete-confirm-ok': 'angular-modal-confirm' - } as React.ComponentProps['confirmButtonProps'] - } - cancelButtonProps={ - { - disabled: deletePending, - 'data-alert-notice-delete-confirm-cancel': 'angular-modal-confirm' - } as React.ComponentProps['cancelButtonProps'] - } - > -

- {deleteConfirmCopy} -

-
-
- - ) - }} - - ); -} diff --git a/web-next/app/alert/notice/page.test.tsx b/web-next/app/alert/notice/page.test.tsx deleted file mode 100644 index 6f82607b67..0000000000 --- a/web-next/app/alert/notice/page.test.tsx +++ /dev/null @@ -1,1730 +0,0 @@ -import { readFileSync } from 'node:fs'; -import { resolve } from 'node:path'; -import Link from 'next/link'; -import React from 'react'; -import { renderToStaticMarkup } from 'react-dom/server'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; -import type { AlertNoticeRouteState } from '../../../lib/alert-notice/query-state'; -import { createTranslatorMock } from '../../../test/i18n-test-helper'; - -const mockState = vi.hoisted(() => ({ - lastLoad: null as null | (() => Promise), - currentSearchParams: '', - routerReplace: vi.fn(), - renderData: { - receivers: { - content: [ - { - id: 7, - name: 'Receiver page 0', - email: 'ops@example.com', - type: 1, - gmtUpdate: 1712730000000 - } - ], - totalElements: 17, - pageIndex: 0, - pageSize: 8 - }, - receiverOptions: { - content: [ - { - id: 7, - name: 'Receiver page 0', - email: 'ops@example.com', - type: 1, - gmtUpdate: 1712730000000 - }, - { - id: 99, - name: 'Receiver outside current page', - hookUrl: 'https://hooks.example', - type: 2, - gmtUpdate: 1712730000000 - } - ], - totalElements: 2, - pageIndex: 0, - pageSize: 1000 - }, - rules: { - content: [ - { - id: 5, - name: 'Rule page 0', - enable: true, - receiverName: ['Receiver page'], - templateName: 'WebhookTemplate', - gmtUpdate: 1712730000000 - } - ], - totalElements: 17, - pageIndex: 0, - pageSize: 8 - }, - templates: { - content: [ - { - id: 9, - name: 'WebhookTemplate', - preset: true, - content: - '{"title":"<#if status??>${status!\\"UNKNOWN\\"}","summary":"<h1>Alert Summary</h1> ### ${commonLabels.severity} > ${msg!\\"Disk full\\"}"}', - gmtUpdate: 1712730000000 - }, - { - id: 10, - name: 'CustomTemplate', - preset: false, - content: 'Custom template body', - gmtUpdate: 1712730000000 - } - ], - totalElements: 4, - pageIndex: 0, - pageSize: 8 - }, - templateOptions: { - content: [ - { - id: 9, - name: 'WebhookTemplate', - preset: true, - type: 2, - content: 'Preset template body', - gmtUpdate: 1712730000000 - }, - { - id: 10, - name: 'CustomTemplate', - preset: false, - type: 2, - content: 'Custom template body', - gmtUpdate: 1712730000000 - } - ], - totalElements: 2, - pageIndex: 0, - pageSize: 2 - } - } -})); - -const mockLoadAlertNoticeData = vi.hoisted(() => vi.fn(async () => mockState.renderData)); - -vi.mock('next/link', () => ({ - default: ({ href, children, ...props }: any) => ( - - {children} - - ) -})); - -vi.mock('next/navigation', () => ({ - useRouter: () => ({ - replace: mockState.routerReplace - }), - useSearchParams: () => new URLSearchParams(mockState.currentSearchParams) -})); - -vi.mock('../../../components/providers/i18n-provider', () => ({ - useI18n: () => ({ - t: createTranslatorMock({ - locale: 'zh-CN' - }), - locale: 'zh-CN' - }) -})); - -vi.mock('../../../components/workbench/client-workbench', () => ({ - ClientWorkbench: ({ - load, - loadingCopy, - children - }: { - load: () => Promise; - loadingCopy?: string; - children: (data: any) => React.ReactNode; - }) => { - mockState.lastLoad = load; - return
{children(mockState.renderData)}
; - } -})); - -vi.mock('../../../components/observability', () => ({ - StageSection: ({ title, children }: any) => ( -
-

{title}

- {children} -
- ) -})); - -vi.mock('../../../components/observability/selectable-evidence-list', () => ({ - SelectableEvidenceList: ({ rows }: any) => ( -
{rows.map((row: any) => `${row.title}||${row.copy}||${row.meta}`).join('|')}
- ) -})); - -vi.mock('../../../components/workbench/workbench-page', () => ({ - WorkbenchPage: ({ kicker, title, subtitle, facts, actions, main, side, tone }: any) => ( -
-
{kicker}
-

{title}

-

{subtitle}

-
{actions}
-
{facts.map((fact: any) => `${fact.label}:${fact.value}`).join('|')}
-
{main}
-
{side}
-
- ), - RowList: ({ rows }: any) => ( -
{rows.map((row: any) => `${row.title}||${row.copy}||${row.meta}`).join('|')}
- ) -})); - -vi.mock('../../../components/ui/button', () => ({ - Button: ({ children, ...props }: any) => -})); - -vi.mock('../../../components/ui/input', () => ({ - Input: (props: any) => -})); - -vi.mock('../../../components/ui/select', () => ({ - Select: ({ children, containerClassName: _containerClassName, ...props }: any) => -})); - -vi.mock('../../../components/pages/alert-notice-receiver-fields', () => ({ - AlertNoticeReceiverFields: () =>
receiver-fields
-})); - -vi.mock('../../../components/pages/alert-notice-rule-fields', () => ({ - AlertNoticeRuleFields: () =>
rule-fields
-})); - -vi.mock('../../../components/pages/alert-notice-template-fields', () => ({ - AlertNoticeTemplateFields: () =>
template-fields
-})); - -vi.mock('../../../components/pages/alert-notice-console-shell', () => ({ - AlertNoticeConsoleShell: ({ selectedTab, receiverContent, ruleContent, templateContent }: any) => ( -
-
-
-
receiver-tab
-
rule-tab
-
template-tab
-
-
- {selectedTab === 'receiver' ? receiverContent : null} - {selectedTab === 'rule' ? ruleContent : null} - {selectedTab === 'template' ? templateContent : null} -
-
-
- ) -})); - -vi.mock('../../../lib/alert-notice/controller', () => ({ - buildNoticeListUrl: (path: '/notice/receivers' | '/notice/rules', query: { search?: string; pageIndex?: number; pageSize?: number } = {}) => { - const params = new URLSearchParams({ - pageIndex: String(query.pageIndex ?? 0), - pageSize: String(query.pageSize ?? 8) - }); - const search = query.search?.trim(); - if (search) { - params.set('name', search); - } - return `${path}?${params.toString()}`; - }, - buildNoticeTemplateListUrl: (query: { search?: string; pageIndex?: number; pageSize?: number; preset?: boolean } = {}) => { - const params = new URLSearchParams({ - pageIndex: String(query.pageIndex ?? 0), - pageSize: String(query.pageSize ?? 8) - }); - const search = query.search?.trim(); - if (search) { - params.set('name', search); - } - params.set('preset', String(query.preset ?? true)); - return `/notice/templates?${params.toString()}`; - }, - buildNoticeRuleDraft: () => ({ - name: '', - receiverIdsText: '', - templateId: '-1', - enable: true, - filterAll: true, - labelsText: '', - daysText: '1,2,3,4,5', - periodStart: '09:00', - periodEnd: '18:00' - }), - buildNoticeRuleDisplayNames: vi.fn(() => ({ - receiverName: ['Receiver page 0-Email'], - templateName: 'WebhookTemplate' - })), - createNoticeReceiver: vi.fn(), - createNoticeRule: vi.fn(), - createNoticeTemplate: vi.fn(), - deleteNoticeReceiver: vi.fn(), - deleteNoticeRule: vi.fn(), - deleteNoticeTemplate: vi.fn(), - loadAlertNoticeData: mockLoadAlertNoticeData, - loadAlertNoticeDataFromFacade: mockLoadAlertNoticeData, - loadNoticeReceiverDetail: vi.fn(), - loadNoticeRuleDetail: vi.fn(), - loadNoticeTemplateDetail: vi.fn(), - sendNoticeReceiverTest: vi.fn(), - updateNoticeReceiver: vi.fn(), - updateNoticeRule: vi.fn(), - updateNoticeTemplate: vi.fn(), - buildNoticeReceiverDraft: () => ({ - name: '', - type: '1', - email: '', - phone: '', - hookUrl: '', - hookAuthType: 'None', - hookAuthToken: '', - wechatId: '', - accessToken: '', - tgBotToken: '', - tgUserId: '', - tgMessageThreadId: '', - larkReceiveType: '0', - userId: '', - chatId: '', - slackWebHookUrl: '', - corpId: '', - agentId: '', - appSecret: '', - partyId: '', - tagId: '', - discordChannelId: '', - discordBotToken: '', - smnAk: '', - smnSk: '', - smnProjectId: '', - smnRegion: '', - smnTopicUrn: '', - serverChanToken: '', - gotifyToken: '', - appId: '' - }), - buildNoticeTemplateDraft: () => ({ name: '', type: '1', preset: false, content: '' }) -})); - -vi.mock('../../../lib/api-client', () => ({ - apiMessageDelete: vi.fn(), - apiMessageGet: vi.fn(), - apiMessagePost: vi.fn(), - apiMessagePut: vi.fn() -})); - -vi.mock('../../../lib/alert-label-options', () => ({ - DEFAULT_ALERT_LABEL_OPTIONS: { - keys: ['alertname', 'severity', 'service'], - valuesByKey: { severity: ['critical', 'warning'], service: ['checkout'] } - }, - loadAlertLabelOptions: vi.fn(async () => ({ - keys: ['alertname', 'severity', 'service'], - valuesByKey: { severity: ['critical', 'warning'], service: ['checkout'] } - })), - loadAlertLabelOptionsFromFacade: vi.fn(async () => ({ - keys: ['alertname', 'severity', 'service'], - valuesByKey: { severity: ['critical', 'warning'], service: ['checkout'] } - })) -})); - -vi.mock('../../../lib/format', () => ({ - formatTime: () => '2026-04-10 18:00:00' -})); - -const EMPTY_ROUTE_STATE: AlertNoticeRouteState = { - signal: null, - signalContext: {} -}; - -async function renderAlertNoticePage(initialRouteState: AlertNoticeRouteState = EMPTY_ROUTE_STATE) { - const { default: AlertNoticePage } = await import('./alert-notice-page'); - return renderToStaticMarkup(); -} - -describe('alert notice page', () => { - beforeEach(() => { - mockState.lastLoad = null; - mockState.currentSearchParams = ''; - mockState.routerReplace.mockReset(); - mockLoadAlertNoticeData.mockClear().mockResolvedValue(mockState.renderData); - }); - - it('renders the OTLP cold notice tab shell with the receiver console selected by default', async () => { - const t = createTranslatorMock({ locale: 'zh-CN' }); - const source = readFileSync(resolve(process.cwd(), 'app/alert/notice/alert-notice-page.tsx'), 'utf8'); - const receiverPanelSource = source.slice(source.indexOf('const receiverPanel = ('), source.indexOf('const rulePanel = (')); - const html = await renderAlertNoticePage(); - - expect(html).toContain(t('alert.notice.title')); - expect(html).toContain('data-alert-notice-console="true"'); - expect(html).toContain(`data-loading-copy="${t('alert.notice.loading')}"`); - expect(html).toContain('data-selected-tab="receiver"'); - expect(html).toContain('data-tab="receiver"'); - expect(html).toContain('data-tab="rule"'); - expect(html).toContain('data-tab="template"'); - expect(html).toContain('data-alert-notice-surface="otlp-hertzbeat-ui-notice-console"'); - expect(html).toContain('data-alert-notice-style-baseline="hertzbeat-ui-matte"'); - expect(html).toContain('data-alert-notice-page-overflow="route-contained-horizontal"'); - expect(html).toContain('overflow-x-hidden'); - expect(html).toContain('data-alert-notice-header="hertzbeat-ui-compact-header"'); - expect(html).toContain('data-alert-notice-header-nesting-contract="flat-page-introduction"'); - expect(html).toContain('data-alert-notice-admin-layout="full-width-admin-list"'); - expect(html).toContain('class="p-0"'); - expect(html).toContain('data-alert-notice-inline-metrics="hertzbeat-ui-inline-counts"'); - expect(html).toContain('data-alert-notice-command-bar="standard-equal-buttons"'); - expect(html).toContain('data-alert-notice-workbench-panel="cold-tabbed-table-panel"'); - expect(html).toContain('data-alert-notice-tabs="hertzbeat-ui-segmented-tabs"'); - expect(html).toContain('data-alert-notice-receiver-toolbar="hertzbeat-ui-query-toolbar"'); - expect(html).toContain('data-alert-notice-receiver-toolbar-layout="compact-inline-actions-query"'); - expect(html).toContain('data-alert-notice-receiver-search="shared-compact"'); - expect(html).toContain('data-alert-notice-receiver-search-submit="angular-enter-and-clear"'); - expect(html).toContain('data-alert-notice-receiver-search-submit-owner="hertzbeat-ui-search-row"'); - expect(html).toContain('data-alert-notice-receiver-sync="angular-load-table"'); - expect(html).toContain('data-alert-notice-receiver-sync-owner="route-refresh-contract"'); - expect(html).toContain('data-hz-search-row-owner="hertzbeat-ui-search-row"'); - expect(html).toContain('data-hz-search-layout="compact-detached-button"'); - expect(html).toContain('data-hz-search-input="fixed-width-direct"'); - expect(html).toContain('data-hz-search-control="direct-input"'); - expect(html).toContain('data-hz-search-chrome="no-extra-input-shell"'); - expect(html).not.toContain('data-hz-search-input-shell'); - expect(html).toContain('data-hz-search-action="submit"'); - expect(html).toContain('data-alert-notice-receiver-table-shell="hertzbeat-ui-dense-table"'); - expect(html).toContain('data-alert-notice-receiver-table-layout="viewport-contained-visible-actions"'); - expect(html).toContain('class="min-w-full border-collapse text-sm text-[var(--ops-text-secondary)] w-full table-fixed text-center"'); - expect(receiverPanelSource).toContain('data-alert-notice-receiver-table-layout="viewport-contained-visible-actions"'); - expect(receiverPanelSource).not.toContain('className="min-w-[1240px] text-center"'); - expect(html).toContain('data-alert-notice-pagination="hertzbeat-ui-dense-pagination"'); - expect(html).toContain('data-alert-notice-pagination-owner="hertzbeat-ui-pagination-bar"'); - expect(html).toContain('data-hz-ui="pagination-bar"'); - expect(html).toContain('data-hz-pagination-page-size="select-menu"'); - expect(html).toContain('data-hz-pagination-page-jump="number-input"'); - expect(html).toContain('data-alert-notice-pagination-page-size-owner="hertzbeat-ui-select"'); - expect(html).toContain('data-alert-notice-pagination-page-jump-owner="hertzbeat-ui-input"'); - expect(html).toContain(t('alert.notice.pagination.summary', { page: 1, totalPages: 3, from: 1, to: 1, total: 17 })); - expect(html).toContain(t('alert.notice.pagination.page-size')); - expect(html).toContain(t('alert.notice.pagination.page')); - expect(html).toContain('data-alert-notice-receiver-row="7"'); - expect(html).toContain(t('alert.notice.receiver.new')); - expect(html).toContain(t('alert.notice.receiver.people.name')); - expect(html).toContain(t('alert.notice.receiver.people')); - expect(html).toContain(t('alert.notice.receiver.type')); - expect(html).toContain(t('alert.notice.receiver.setting')); - expect(html).toContain(t('common.edit-time')); - expect(html).toContain(t('common.edit')); - expect(html).toContain('Receiver page 0'); - expect(html).toContain(t('alert.notice.type.email')); - expect(html).toContain('ops@example.com'); - expect(html).toContain('2026-04-10 18:00:00'); - expect(html).not.toContain(`data-stage-section="${t('alert.notice.receivers.title')}"`); - expect(html).not.toContain('data-evidence-list="true"'); - expect(html).not.toContain('WebhookTemplate||'); - expect(html).not.toContain('data-workbench-page="true"'); - expect(html).not.toContain('data-tone="operator"'); - expect(html).not.toContain('notice/receivers'); - expect(html).not.toContain('${status'); - expect(html).not.toContain('<#if'); - expect(html).not.toContain('<h1>'); - - expect(source).toContain('hzOpsCatalogVisual'); - expect(source).toContain('data-alert-notice-style-baseline={coldNoticeVisual.canvasName}'); - expect(source).toContain('data-alert-notice-page-overflow="route-contained-horizontal"'); - expect(source).toContain('className={`${coldNoticeVisual.canvas.root} overflow-x-hidden`}'); - expect(source).toContain('style={coldNoticeVisual.canvas.backgroundStyle}'); - expect(source).toContain('
'); - expect(source).toContain('data-alert-notice-header-nesting-contract="flat-page-introduction"'); - expect(source).toContain('className="p-0"'); - expect(source).not.toContain('className={coldNoticeVisual.panel.hero}'); - expect(source).toContain('data-alert-notice-command-bar-mobile="two-column-wrap"'); - expect(source).toContain('className="mt-6 grid grid-cols-2 items-center gap-2 sm:flex sm:flex-wrap"'); - expect(source).toContain('coldButtonClassName'); - expect(source).toContain('coldPrimaryButtonClassName'); - expect(source).toContain('coldCommandButtonClass'); - expect(source).toContain("from '@hertzbeat/ui'"); - expect(source).toContain('HzConfirmDialog'); - expect(source).toContain('HzPaginationBar'); - expect(source).toContain("from '../../../components/ui/search-row'"); - expect(source).toContain("from '../../../lib/alert-label-options'"); - expect(source).toContain('loadAlertLabelOptionsFromFacade(api.alertLabels.list)'); - expect(source).toContain('loadAlertNoticeDataFromFacade'); - expect(source).toContain("import { useRouter, useSearchParams } from 'next/navigation';"); - expect(source).toContain("receiverSearch: routeParams.get('receiverSearch') ?? ''"); - expect(source).toContain("ruleSearch: routeParams.get('ruleSearch') ?? ''"); - expect(source).toContain("templateSearch: routeParams.get('templateSearch') ?? ''"); - expect(source).toContain("if (!nextState.templatePresetFilter || currentParams.get('templatePreset') === 'true') nextParams.set('templatePreset', String(nextState.templatePresetFilter));"); - expect(source).toContain('const nextTemplateSearch = templateDraft.name.trim();'); - expect(source).toContain('setTemplatePresetFilter(false);'); - expect(source).toContain("templatePresetFilter: false"); - expect(source).toContain("templateSearch: nextTemplateSearch"); - expect(source).toContain('return nextParamString ? `/alert/notice?${nextParamString}` : \'/alert/notice\';'); - expect(source).toContain('router.replace(nextUrl, { scroll: false });'); - expect(source).toContain('receivers: api.alertNotice.receivers.list'); - expect(source).toContain('rules: api.alertNotice.rules.list'); - expect(source).toContain('receiverOptions: api.alertNotice.receivers.options'); - expect(source).toContain('templates: api.alertNotice.templates.list'); - expect(source).toContain('templateOptions: api.alertNotice.templates.options'); - expect(source).not.toContain('loadAlertNoticeData(apiMessageGet, alertNoticeLoadQuery)'); - expect(source).not.toContain('loadAlertLabelOptions(apiMessageGet)'); - expect(source).not.toContain("from '../../../lib/api-client'"); - expect(source).toContain('labelOptions={labelOptions}'); - expect(source).toContain('data-alert-notice-receiver-toolbar="hertzbeat-ui-query-toolbar"'); - expect(source).toContain('data-alert-notice-rule-toolbar="hertzbeat-ui-query-toolbar"'); - expect(source).toContain('data-alert-notice-template-toolbar="hertzbeat-ui-query-toolbar"'); - expect(source).toContain('data-alert-notice-receiver-sync="angular-load-table"'); - expect(source).toContain('data-alert-notice-receiver-sync-owner="route-refresh-contract"'); - expect(source).toContain('data-alert-notice-rule-sync="angular-load-table"'); - expect(source).toContain('data-alert-notice-rule-sync-owner="route-refresh-contract"'); - expect(source).toContain('data-alert-notice-template-sync="angular-load-table"'); - expect(source).toContain('data-alert-notice-template-sync-owner="route-refresh-contract"'); - expect(source).toContain('data-alert-notice-receiver-search="shared-compact"'); - expect(source).toContain('data-alert-notice-receiver-search-submit="angular-enter-and-clear"'); - expect(source).toContain('data-alert-notice-receiver-search-submit-owner="hertzbeat-ui-search-row"'); - expect(source).toContain('data-alert-notice-rule-search="shared-compact"'); - expect(source).toContain('data-alert-notice-template-search="shared-compact"'); - expect(source).toContain('data-alert-notice-template-search-submit="angular-enter-and-clear"'); - expect(source).toContain('data-alert-notice-template-search-submit-owner="hertzbeat-ui-search-row"'); - expect(source).not.toContain('data-alert-notice-receiver-toolbar="cold-table-toolbar"'); - expect(source).not.toContain('data-alert-notice-rule-toolbar="cold-table-toolbar"'); - expect(source).not.toContain('data-alert-notice-template-toolbar="cold-table-toolbar"'); - expect(source).not.toContain('className="mb-0 ml-auto"'); - expect(source).not.toContain('className="ml-auto flex min-w-0 flex-wrap items-center justify-end gap-2"'); - expect(source).not.toContain('data-testid="notice-receiver-search-input"'); - expect(source).not.toContain('data-testid="notice-receiver-search-button"'); - expect(source).not.toContain('className="min-h-screen bg-[#0b0c0e] px-6 py-5 text-[#f2f5f8]"'); - expect(source).not.toContain('className="rounded-[4px] border border-[#252b34] bg-[#0b0c0e] px-5 py-5"'); - expect(source).not.toContain('bg-[#14213a]'); - expect(source).not.toContain('lg:grid-cols-[minmax(0,1fr)_360px]'); - expect(source).not.toContain('className="grid gap-2"'); - expect(source).not.toContain("from '../../../components/workbench/workbench-page'"); - expect(source).not.toContain('angular-table-toolbar'); - expect(source).not.toContain('angular-table"'); - expect(source).not.toContain('angular-select'); - expect(source).not.toContain('angular-table-pagination'); - expect(source).not.toContain('angular-table-empty'); - expect(source).not.toContain('angular-empty-box'); - expect(source).not.toContain("from '../../../components/observability'"); - expect(source).not.toContain('StageSection'); - expect(source).not.toContain('SelectableEvidenceList'); - expect(source).not.toContain('NoticeListToolbar'); - expect(source).not.toContain('buildNoticeFacts'); - expect(source).not.toContain("from '../../../components/workbench/primitives'"); - }, 60_000); - - it('initializes notice tab and list queries from URL state', async () => { - mockState.currentSearchParams = [ - 'tab=template', - 'receiverSearch=ops', - 'receiverPageIndex=2', - 'receiverPageSize=15', - 'ruleSearch=severity', - 'rulePageIndex=1', - 'rulePageSize=25', - 'templateSearch=webhook', - 'templatePreset=false', - 'templatePageIndex=3', - 'templatePageSize=15' - ].join('&'); - - const html = await renderAlertNoticePage(); - - expect(html).toContain('data-selected-tab="template"'); - expect(html).toContain('value="webhook"'); - expect(html).toContain('value="false" selected=""'); - - await mockState.lastLoad?.(); - - expect(mockLoadAlertNoticeData).toHaveBeenLastCalledWith(expect.anything(), { - receivers: { - search: 'ops', - pageIndex: 2, - pageSize: 15 - }, - rules: { - search: 'severity', - pageIndex: 1, - pageSize: 25 - }, - templates: { - search: 'webhook', - preset: false, - pageIndex: 3, - pageSize: 15 - } - }); - }, 30_000); - - it('keeps explicitly supplied default notice list URL params during route sync', async () => { - const { buildAlertNoticeListRouteUrl } = await import('./alert-notice-page'); - const nextUrl = buildAlertNoticeListRouteUrl( - [ - 'tab=receiver', - 'receiverSearch=uv_notice_receiver_webhook_token_help_1082', - 'receiverPageIndex=0', - 'receiverPageSize=8', - 'source=notice-receiver-webhook-token-help-1082', - 'returnTo=%2Falert%2Fsetting%3Fsource%3Dnotice-receiver-webhook-token-help-return-1082%26probe%3Dnotice-receiver-webhook-token-help-1082', - 'timeRange=last-30m', - 'live=false', - 'probe=notice-receiver-webhook-token-help-1082', - 'templatePreset=true' - ].join('&'), - 'receiver', - { - selectedTab: 'receiver', - receiverSearch: 'uv_notice_receiver_webhook_token_help_1082', - receiverPageIndex: 0, - receiverPageSize: 8, - ruleSearch: '', - rulePageIndex: 0, - rulePageSize: 8, - templateSearch: '', - templatePresetFilter: true, - templatePageIndex: 0, - templatePageSize: 8 - } - ); - - expect(nextUrl).toContain('tab=receiver'); - expect(nextUrl).toContain('receiverSearch=uv_notice_receiver_webhook_token_help_1082'); - expect(nextUrl).toContain('receiverPageIndex=0'); - expect(nextUrl).toContain('receiverPageSize=8'); - expect(nextUrl).toContain('templatePreset=true'); - expect(nextUrl).toContain('source=notice-receiver-webhook-token-help-1082'); - expect(nextUrl).toContain('timeRange=last-30m'); - expect(nextUrl).toContain('live=false'); - expect(nextUrl).toContain('probe=notice-receiver-webhook-token-help-1082'); - expect(decodeURIComponent(nextUrl)).toContain('/alert/setting?source=notice-receiver-webhook-token-help-return-1082&probe=notice-receiver-webhook-token-help-1082'); - }, 30_000); - - it('drops stale non-default notice list URL params when operators switch back to defaults', async () => { - const { buildAlertNoticeListRouteUrl } = await import('./alert-notice-page'); - const nextUrl = buildAlertNoticeListRouteUrl( - 'tab=template&receiverPageSize=15&templatePreset=false&source=manual', - 'receiver', - { - selectedTab: 'receiver', - receiverSearch: '', - receiverPageIndex: 0, - receiverPageSize: 8, - ruleSearch: '', - rulePageIndex: 0, - rulePageSize: 8, - templateSearch: '', - templatePresetFilter: true, - templatePageIndex: 0, - templatePageSize: 8 - } - ); - - expect(nextUrl).toBe('/alert/notice?source=manual'); - }, 30_000); - - it('explains notification lane actions before operators create or delete delivery paths', async () => { - const t = createTranslatorMock({ locale: 'zh-CN' }); - const source = readFileSync(resolve(process.cwd(), 'app/alert/notice/alert-notice-page.tsx'), 'utf8'); - const receiverHtml = await renderAlertNoticePage(); - const ruleHtml = await renderAlertNoticePage({ - signal: 'logs', - signalContext: { - serviceName: 'checkout', - environment: 'prod' - } - }); - mockState.currentSearchParams = 'tab=template'; - const templateHtml = await renderAlertNoticePage(); - - expect(source).toContain('function alertNoticeActionHelp'); - expect(source).toContain('function AlertNoticeActionHelp'); - for (const id of [ - 'receiver-refresh', - 'receiver-new', - 'receiver-delete', - 'receiver-row-edit', - 'receiver-row-delete', - 'receiver-cancel', - 'receiver-test', - 'receiver-save', - 'rule-refresh', - 'rule-new', - 'rule-delete', - 'rule-row-filter-all', - 'rule-row-enable', - 'rule-row-edit', - 'rule-row-delete', - 'rule-cancel', - 'rule-save', - 'template-refresh', - 'template-new', - 'template-delete', - 'template-row-view', - 'template-row-edit', - 'template-row-delete', - 'template-save' - ]) { - expect(source).toContain(`id="${id}"`); - expect(source).toContain(`alertNoticeActionHelp(t, '${id}')`); - } - - expect(receiverHtml).toContain('data-alert-notice-action-help="receiver-refresh"'); - expect(receiverHtml).toContain('data-alert-notice-command-action="receiver-refresh"'); - expect(receiverHtml).toContain('data-alert-notice-action-help="receiver-new"'); - expect(receiverHtml).toContain('data-alert-notice-command-action="receiver-new"'); - expect(receiverHtml).toContain('data-alert-notice-action-help="receiver-delete"'); - expect(receiverHtml).toContain('data-alert-notice-command-action="receiver-delete"'); - expect(receiverHtml).toContain('data-alert-notice-action-help="receiver-row-edit"'); - expect(receiverHtml).toContain('data-alert-notice-command-action="receiver-row-edit"'); - expect(receiverHtml).toContain('data-alert-notice-action-help="receiver-row-delete"'); - expect(receiverHtml).toContain('data-alert-notice-command-action="receiver-row-delete"'); - expect(receiverHtml).toContain('data-alert-notice-action-help-trigger="hertzbeat-ui-action-help"'); - expect(receiverHtml).toContain('data-alert-notice-action-help-style="icon-after-action"'); - expect(receiverHtml).toContain('data-alert-notice-action-help-visual="circle-help-icon"'); - expect(receiverHtml).toContain('data-alert-notice-action-help-icon="lucide-circle-help"'); - expect(receiverHtml).toContain('lucide-circle-help'); - expect(receiverHtml).toContain('data-alert-notice-action-help-tooltip="receiver-delete"'); - expect(receiverHtml).not.toContain(''); - expect(receiverHtml).toContain(t('alert.notice.action.receiver-new.help')); - expect(receiverHtml).toContain(t('alert.notice.action.receiver-delete.impact')); - expect(receiverHtml).toContain(t('alert.notice.action.receiver-row-edit.help')); - expect(receiverHtml).toContain(t('alert.notice.action.receiver-row-delete.impact')); - - expect(ruleHtml).toContain('data-selected-tab="rule"'); - expect(ruleHtml).toContain('data-alert-notice-rule-table-actions="sticky-visible-actions"'); - expect(ruleHtml).toContain('data-alert-notice-action-help="rule-refresh"'); - expect(ruleHtml).toContain('data-alert-notice-command-action="rule-refresh"'); - expect(ruleHtml).toContain('data-alert-notice-action-help="rule-new"'); - expect(ruleHtml).toContain('data-alert-notice-command-action="rule-new"'); - expect(ruleHtml).toContain('data-alert-notice-action-help="rule-delete"'); - expect(ruleHtml).toContain('data-alert-notice-command-action="rule-delete"'); - expect(ruleHtml).toContain('data-alert-notice-action-help="rule-row-filter-all"'); - expect(ruleHtml).toContain('data-alert-notice-command-action="rule-row-filter-all"'); - expect(ruleHtml).toContain('data-alert-notice-action-help="rule-row-enable"'); - expect(ruleHtml).toContain('data-alert-notice-command-action="rule-row-enable"'); - expect(ruleHtml).toContain('data-alert-notice-action-help="rule-row-edit"'); - expect(ruleHtml).toContain('data-alert-notice-command-action="rule-row-edit"'); - expect(ruleHtml).toContain('data-alert-notice-action-help="rule-row-delete"'); - expect(ruleHtml).toContain('data-alert-notice-command-action="rule-row-delete"'); - expect(ruleHtml).toContain(t('alert.notice.action.rule-new.impact')); - expect(ruleHtml).toContain(t('alert.notice.action.rule-row-enable.help')); - - expect(templateHtml).toContain('data-selected-tab="template"'); - expect(templateHtml).toContain('data-alert-notice-template-table-actions="sticky-visible-actions"'); - expect(templateHtml).toContain('data-alert-notice-action-help="template-refresh"'); - expect(templateHtml).toContain('data-alert-notice-command-action="template-refresh"'); - expect(templateHtml).toContain('data-alert-notice-action-help="template-new"'); - expect(templateHtml).toContain('data-alert-notice-command-action="template-new"'); - expect(templateHtml).toContain('data-alert-notice-action-help="template-delete"'); - expect(templateHtml).toContain('data-alert-notice-command-action="template-delete"'); - expect(templateHtml).toContain('data-alert-notice-command-action="template-filter-preset"'); - expect(templateHtml).toContain('data-alert-notice-command-action="template-row-view"'); - expect(templateHtml).toContain('data-alert-notice-command-action="template-row-edit"'); - expect(templateHtml).toContain('data-alert-notice-command-action="template-row-delete"'); - - expect(source).toContain('data-alert-notice-action-help={id}'); - expect(source).toContain('data-alert-notice-command-action={commandAction}'); - expect(source).toContain('CircleHelp'); - expect(source).toContain('data-alert-notice-action-help-style="icon-after-action"'); - expect(source).toContain('data-alert-notice-action-help-visual="circle-help-icon"'); - expect(source).toContain('data-alert-notice-action-help-icon="lucide-circle-help"'); - expect(source).toContain('data-alert-notice-action-help-tooltip={id}'); - expect(source).toContain("alertNoticeActionHelp(t, 'receiver-cancel')"); - expect(source).toContain('data-alert-notice-command-action="receiver-cancel"'); - expect(source).toContain("alertNoticeActionHelp(t, 'receiver-test')"); - expect(source).toContain('data-alert-notice-command-action="receiver-test"'); - expect(source).toContain("alertNoticeActionHelp(t, 'receiver-save')"); - expect(source).toContain('data-alert-notice-command-action="receiver-save"'); - expect(source).toContain("alertNoticeActionHelp(t, 'rule-cancel')"); - expect(source).toContain('data-alert-notice-command-action="rule-cancel"'); - expect(source).toContain("alertNoticeActionHelp(t, 'rule-save')"); - expect(source).toContain('data-alert-notice-command-action="rule-save"'); - expect(source).toContain('data-alert-notice-command-action="rule-return-to-evidence"'); - expect(source).toContain("templateReadOnly ? 'template-return' : 'template-cancel'"); - expect(source).toContain("data-alert-notice-command-action={templateReadOnly ? 'template-return' : 'template-cancel'}"); - expect(source).toContain("alertNoticeActionHelp(t, templateReadOnly ? 'template-return' : 'template-cancel')"); - expect(source).toContain("alertNoticeActionHelp(t, 'template-save')"); - expect(source).toContain('data-alert-notice-command-action="template-save"'); - expect(source).not.toContain('data-alert-notice-action-help-style="literal-question-after-action"'); - expect(source).not.toContain('data-alert-notice-action-help-visual="borderless-question"'); - expect(source).not.toContain(''); - expect(source).toContain('id="template-refresh"'); - expect(source).toContain('id="template-new"'); - expect(source).toContain('id="template-delete"'); - expect(source).toContain('id="template-row-view"'); - expect(source).toContain('id="template-row-edit"'); - expect(source).toContain('id="template-row-delete"'); - }, 30_000); - - it('renders missing receiver table settings with the localized empty fallback', async () => { - const originalRenderData = mockState.renderData; - mockState.renderData = { - ...mockState.renderData, - receivers: { - ...mockState.renderData.receivers, - content: [ - { - id: 8, - name: 'Receiver missing setting', - email: ' ', - type: 1, - gmtUpdate: 1712730000000 - } - ], - totalElements: 1 - } - }; - - try { - const html = await renderAlertNoticePage(); - const t = createTranslatorMock({ locale: 'zh-CN' }); - - expect(html).toContain('Receiver missing setting'); - expect(html).toContain(`title="${t('common.none')}"`); - expect(html).not.toContain('title="-"'); - } finally { - mockState.renderData = originalRenderData; - } - }, 30_000); - - it('renders missing receiver and template type badges with the localized empty fallback', async () => { - const originalRenderData = mockState.renderData; - mockState.renderData = { - ...mockState.renderData, - receivers: { - ...mockState.renderData.receivers, - content: [ - { - id: 9, - name: 'Receiver missing type', - email: 'ops@example.com', - type: ' ', - gmtUpdate: 1712730000000 - } - ], - totalElements: 1 - }, - templates: { - ...mockState.renderData.templates, - content: [ - { - id: 11, - name: 'Template missing type', - type: null, - preset: true, - content: 'Template body', - gmtUpdate: 1712730000000 - } - ], - totalElements: 1 - } - }; - - try { - const html = await renderAlertNoticePage(); - const t = createTranslatorMock({ locale: 'zh-CN' }); - const source = readFileSync(resolve(process.cwd(), 'app/alert/notice/alert-notice-page.tsx'), 'utf8'); - - expect(html).toContain('Receiver missing type'); - expect(html).toContain(`>${t('common.none')}`); - expect(html).not.toContain('>-'); - expect(source).toContain('getNoticeTemplateTypeLabel(template.type, t, emptyValue)'); - } finally { - mockState.renderData = originalRenderData; - } - }, 30_000); - - it('keeps template Telegram type labels separate from receiver Telegram bot copy', async () => { - const originalRenderData = mockState.renderData; - mockState.renderData = { - ...mockState.renderData, - templates: { - ...mockState.renderData.templates, - content: [ - { - id: 17, - name: 'TelegramTemplate', - type: 7, - preset: true, - content: 'Telegram template body', - gmtUpdate: 1712730000000 - } - ], - totalElements: 1 - } - }; - - try { - const html = await renderAlertNoticePage(); - const source = readFileSync(resolve(process.cwd(), 'app/alert/notice/alert-notice-page.tsx'), 'utf8'); - - expect(html).toContain('data-selected-tab="receiver"'); - expect(source).toContain('const NOTICE_TEMPLATE_TYPE_LABEL_KEYS'); - expect(source).toContain("'7': 'alert.notice.type.telegram'"); - expect(source).toContain("'7': 'alert.notice.type.telegram-bot'"); - expect(source).toContain('getNoticeTemplateTypeLabel(template.type, t, emptyValue)'); - expect(source).toContain('data-alert-notice-template-telegram-label="angular-template-telegram"'); - expect(source).toContain('data-alert-notice-template-telegram-label-owner="route-i18n-contract"'); - expect(source).toContain('{getNoticeTemplateTypeLabel(template.type, t, emptyValue)}'); - expect(source).not.toContain('{getNoticeTypeLabel(template.type, t, emptyValue)}'); - } finally { - mockState.renderData = originalRenderData; - } - }, 30_000); - - it('keeps the rule tab on the OTLP cold table contract when the console switches tabs', async () => { - const source = readFileSync(resolve(process.cwd(), 'app/alert/notice/alert-notice-page.tsx'), 'utf8'); - - expect(source).toContain('data-alert-notice-rule-panel="true"'); - expect(source).toContain('data-alert-notice-rule-template-display="angular-template-id-fallback"'); - expect(source).toContain('data-alert-notice-rule-template-display-owner="route-table-contract"'); - expect(source).toContain('data-alert-notice-rule-receiver-display="angular-array-interpolation"'); - expect(source).toContain('data-alert-notice-rule-receiver-display-owner="route-table-contract"'); - expect(source).toContain('data-alert-notice-rule-toolbar="hertzbeat-ui-query-toolbar"'); - expect(source).toContain('data-alert-notice-rule-toolbar-layout="compact-inline-actions-query"'); - expect(source).toContain('data-alert-notice-rule-search-submit="angular-enter-and-clear"'); - expect(source).toContain('data-alert-notice-rule-search-submit-owner="hertzbeat-ui-search-row"'); - expect(source).toContain('data-alert-notice-rule-empty-action={prefix === \'rule\' ? \'new\' : undefined}'); - expect(source).toContain('w-[min(560px,70vw)]'); - expect(source).toContain("label: t('alert.notice.rule.new')"); - expect(source).toContain('onClick: () => void handleNewRule()'); - expect(source).toContain('onSearch={commitRuleSearch}'); - expect(source).toContain('onClear={ruleSearchDraft || ruleSearch ? resetRuleSearch : undefined}'); - expect(source).toContain('setRulePageIndex(0);'); - expect(source).toContain('data-alert-notice-rule-table-shell="hertzbeat-ui-dense-table"'); - expect(source).toContain('data-alert-notice-rule-table-actions="sticky-visible-actions"'); - expect(source).toContain('const coldStickyActionHeaderClass ='); - expect(source).toContain('const coldStickyActionCellClass ='); - expect(source).toContain("{t('common.edit')}"); - expect(source).toContain(' event.stopPropagation()}>'); - expect(source).toContain('function NoticeTableSwitch'); - expect(source).toContain('role="switch"'); - expect(source).toContain('data-alert-notice-rule-table-switch={field}'); - expect(source).toContain('data-alert-notice-rule-table-switch-update="angular-edit-notify"'); - expect(source).toContain('data-alert-notice-rule-table-switch-update-owner="route-action-feedback-contract"'); - expect(source).toContain('handleToggleRuleSwitch'); - expect(source).toContain('receiverName: rule.receiverName ?? []'); - expect(source).toContain('templateName: rule.templateId ? rule.templateName ?? null : null'); - expect(source).toContain("{rule.templateId ? rule.templateName || t('alert.notice.template.preset.true') : t('alert.notice.template.preset.true')}"); - expect(source).toContain("setRuleMessage(t('common.notify.edit-success'))"); - expect(source).toContain("t('common.notify.edit-fail')"); - expect(source).not.toContain("t('common.save-failed')"); - expect(source).toContain('async function handleEditRule(rule = selectedRule)'); - expect(source).toContain('const ruleId = rule?.id;'); - expect(source).toContain('const detail = await api.alertNotice.rules.detail(ruleId);'); - expect(source).toContain('setSelectedRuleId(ruleId);'); - expect(source).toContain('const nextDraft = buildNoticeRuleDraft(detail);'); - expect(source).toContain('setRuleDraft(nextDraft);'); - expect(source).toContain('onClick={() => void handleEditRule(rule)}'); - expect(source).toContain('field="filter-all"'); - expect(source).toContain('field="enable"'); - expect(source).toContain("t('alert.notice.rule.name')"); - expect(source).toContain("t('alert.notice.receiver.people')"); - expect(source).toContain("t('alert.notice.template.name')"); - expect(source).toContain("t('alert.notice.rule.all')"); - expect(source).toContain("t('common.enable')"); - expect(source).toContain("filter(Boolean).join(',')"); - expect(source).not.toContain("filter(Boolean).join(', ')"); - expect(source).not.toContain('accent-[var(--ops-primary)]'); - expect(source).not.toContain(' { - const originalRenderData = mockState.renderData; - mockState.renderData = { - ...mockState.renderData, - rules: { - ...mockState.renderData.rules, - content: [ - { - id: 8, - name: 'Multi receiver rule', - enable: true, - filterAll: true, - receiverName: ['ops-email', 'pager-webhook'], - templateId: 9, - templateName: 'Default', - gmtUpdate: 1712730000000 - } - ], - totalElements: 1 - } - }; - - try { - const html = await renderAlertNoticePage({ - signal: 'metrics', - signalContext: {} - }); - - expect(html).toContain('data-alert-notice-rule-receiver-display="angular-array-interpolation"'); - expect(html).toContain('ops-email,pager-webhook'); - expect(html).not.toContain('ops-email, pager-webhook'); - } finally { - mockState.renderData = originalRenderData; - } - }, 30_000); - - it('renders missing rule receiver cells with the localized no-receiver fallback', async () => { - const originalRenderData = mockState.renderData; - mockState.renderData = { - ...mockState.renderData, - rules: { - ...mockState.renderData.rules, - content: [ - { - id: 6, - name: 'Rule without receivers', - enable: true, - filterAll: false, - receiverName: [' ', ''], - templateId: null, - templateName: '', - gmtUpdate: 1712730000000 - } - ], - totalElements: 1 - } - }; - - try { - const t = createTranslatorMock({ locale: 'zh-CN' }); - const html = await renderAlertNoticePage({ - signal: 'metrics', - signalContext: {} - }); - - expect(html).toContain('data-selected-tab="rule"'); - expect(html).toContain('Rule without receivers'); - expect(html).toContain(t('alert.notice.row.no-receiver')); - expect(html).toContain(t('alert.notice.template.preset.true')); - expect(html).not.toContain('-'); - } finally { - mockState.renderData = originalRenderData; - } - }, 30_000); - - it('renders preset template fallback from templateId instead of stale templateName', async () => { - const originalRenderData = mockState.renderData; - mockState.renderData = { - ...mockState.renderData, - rules: { - ...mockState.renderData.rules, - content: [ - { - id: 7, - name: 'Preset template rule', - enable: true, - filterAll: true, - receiverName: ['ops-email'], - templateId: null, - templateName: 'Stale template', - gmtUpdate: 1712730000000 - } - ], - totalElements: 1 - } - }; - - try { - const t = createTranslatorMock({ locale: 'zh-CN' }); - const html = await renderAlertNoticePage({ - signal: 'metrics', - signalContext: {} - }); - - expect(html).toContain('data-alert-notice-rule-template-display="angular-template-id-fallback"'); - expect(html).toContain('Preset template rule'); - expect(html).toContain(t('alert.notice.template.preset.true')); - expect(html).not.toContain('Stale template'); - } finally { - mockState.renderData = originalRenderData; - } - }, 30_000); - - it('keeps the template tab on the OTLP cold table contract when the console switches tabs', async () => { - const source = readFileSync(resolve(process.cwd(), 'app/alert/notice/alert-notice-page.tsx'), 'utf8'); - - expect(source).toContain('data-alert-notice-template-panel="true"'); - expect(source).toContain('data-alert-notice-template-query-owner="backend-paginated"'); - expect(source).toContain('data-alert-notice-template-query-url={alertNoticeTemplateListUrl}'); - expect(source).toContain('data-alert-notice-template-preset-query="server-param"'); - expect(source).toContain('data-alert-notice-template-toolbar="hertzbeat-ui-query-toolbar"'); - expect(source).toContain('data-alert-notice-template-toolbar-layout="compact-inline-actions-query"'); - expect(source).toContain('data-alert-notice-template-preset-filter="hertzbeat-ui-select"'); - expect(source).toContain('data-alert-notice-template-empty-action={prefix === \'template\' ? \'new\' : undefined}'); - expect(source).toContain("label: t('alert.notice.template.new')"); - expect(source).toContain('onClick: () => void handleNewTemplate()'); - expect(source).toContain('onSearch={commitTemplateSearch}'); - expect(source).toContain('onClear={templateSearchDraft || templateSearch ? resetTemplateSearch : undefined}'); - expect(source).toContain('setTemplatePageIndex(0);'); - expect(source).toContain('data-alert-notice-template-table-shell="hertzbeat-ui-dense-table"'); - expect(source).toContain('data-alert-notice-pagination="hertzbeat-ui-dense-pagination"'); - expect(source).toContain('data-alert-notice-pagination-owner="hertzbeat-ui-pagination-bar"'); - expect(source).toContain('data-alert-notice-pagination-page-jump-owner'); - expect(source).toContain('data-alert-notice-pagination-page-size-owner'); - expect(source).toContain('testIdPrefix="notice-template"'); - expect(source).not.toContain('ChevronLeft'); - expect(source).not.toContain('ChevronRight'); - expect(source).toContain("t('alert.notice.pagination.summary'"); - expect(source).toContain("t('alert.notice.pagination.page-size')"); - expect(source).toContain("t('alert.notice.pagination.page')"); - expect(source).toContain("t('alert.notice.template.name')"); - expect(source).toContain("t('alert.notice.template.type')"); - expect(source).toContain("t('alert.notice.template.preset')"); - expect(source).toContain("t('alert.notice.template.preset.true')"); - expect(source).toContain("t('alert.notice.template.preset.false')"); - expect(source).toContain("t('common.edit-time')"); - expect(source).toContain("t('common.edit')"); - expect(source).not.toContain('buildNoticeTemplateRows'); - }, 30_000); - - it('keeps all notice authoring flows in cold modal dialogs instead of inline cards', () => { - const source = readFileSync(resolve(process.cwd(), 'app/alert/notice/alert-notice-page.tsx'), 'utf8'); - - expect(source).toContain("from '../../../components/workbench/overlay-dialog'"); - expect(source).toContain(''); - expect(source).not.toContain(''); - expect(source).not.toContain(''); - }); - - it('wires notice policy and template modals to Angular-parity selectors and template viewing', () => { - const source = readFileSync(resolve(process.cwd(), 'app/alert/notice/alert-notice-page.tsx'), 'utf8'); - - expect(source).toContain('const receiverOptionRows = data.receiverOptions?.content?.length ? data.receiverOptions.content : data.receivers.content;'); - expect(source).toContain('const receiverOptions = receiverOptionRows.map'); - expect(source).toContain('const templateOptions = ['); - expect(source).toContain('type: receiver.type'); - expect(source).toContain('.filter(template => template.id != null)'); - expect(source).toContain('type: template.type'); - expect(source).toContain('receiverOptions={receiverOptions}'); - expect(source).toContain('templateOptions={templateOptions}'); - expect(source).toContain('templateReadOnly'); - expect(source).toContain('handleViewTemplate'); - expect(source).toContain('const rowDraft = buildNoticeTemplateDraft(template);'); - expect(source).toContain('setTemplateDraft(rowDraft);'); - expect(source).toContain('api.alertNotice.templates.detail(template.id)'); - expect(source).toContain('data-alert-notice-template-view-trigger="hertzbeat-ui-modal-viewer-trigger"'); - expect(source).toContain('data-alert-notice-template-viewer-dialog="hertzbeat-ui-modal-viewer"'); - expect(source).toContain('readOnly={templateReadOnly}'); - expect(source).toContain('onClick={() => void handleViewTemplate(template)}'); - expect(source).toContain('onClick={() => void handleEditTemplate(template)}'); - expect(source).toContain("title={templateReadOnly ? t('alert.notice.template.content') : templateDraft.id ? t('alert.notice.template.edit') : t('alert.notice.template.new')}"); - expect(source).not.toContain("onClick={() => setSelectedTemplateId(template.id)} title={t('alert.notice.template.show')}"); - expect(source).not.toContain('async function handleViewTemplate(template: NoticeTemplate) {\n if (!template.id) return;'); - }); - - it('keeps Angular notice-rule receiver/template names in the save payload', () => { - const source = readFileSync(resolve(process.cwd(), 'app/alert/notice/alert-notice-page.tsx'), 'utf8'); - const controllerSource = readFileSync(resolve(process.cwd(), 'lib/alert-notice/controller.ts'), 'utf8'); - - expect(source).toContain('buildNoticeRuleDisplayNames(ruleDraft, receiverOptions, templateOptions)'); - expect(source).toContain('await api.alertNotice.rules.update(ruleDraft, displayNames)'); - expect(source).toContain('await api.alertNotice.rules.create(ruleDraft, displayNames)'); - expect(source).toContain('data-alert-notice-rule-display-names="angular-save-payload"'); - expect(source).toContain('data-alert-notice-rule-display-names-owner="route-payload-contract"'); - expect(source).toContain('data-alert-notice-rule-edit-display-names="angular-detail-options"'); - expect(source).toContain('data-alert-notice-rule-edit-display-names-owner="route-payload-contract"'); - expect(controllerSource).toContain('receiverName: string[]'); - expect(controllerSource).toContain('templateName: string | null'); - expect(controllerSource).toContain('receiverOptions'); - expect(controllerSource).toContain('templateOptions.find'); - expect(controllerSource).toContain('draft.receiverName'); - expect(controllerSource).toContain('draft.templateName'); - expect(controllerSource).toContain('...(displayNames ? displayNames : {})'); - }); - - it('keeps Angular notice-rule row edit loading the full detail before opening the modal', () => { - const source = readFileSync(resolve(process.cwd(), 'app/alert/notice/alert-notice-page.tsx'), 'utf8'); - const handleEditRuleSource = source.slice(source.indexOf('async function handleEditRule('), source.indexOf('async function handleSaveRule()')); - - expect(source).toContain('data-alert-notice-rule-edit-detail="angular-detail-fetch"'); - expect(source).toContain('data-alert-notice-rule-edit-detail-owner="route-detail-fetch-contract"'); - expect(source).toContain('onClick={() => void handleEditRule(rule)}'); - expect(handleEditRuleSource).toContain('const detail = await api.alertNotice.rules.detail(ruleId)'); - expect(handleEditRuleSource).toContain('const nextDraft = buildNoticeRuleDraft(detail)'); - expect(handleEditRuleSource).toContain('setRuleDraft(nextDraft)'); - expect(handleEditRuleSource).not.toContain('buildNoticeRuleDraft(rule)'); - }); - - it('keeps Angular notice-rule modal OK loading wired to the save request', () => { - const source = readFileSync(resolve(process.cwd(), 'app/alert/notice/alert-notice-page.tsx'), 'utf8'); - const handleSaveRuleSource = source.slice(source.indexOf('async function handleSaveRule()'), source.indexOf('async function handleDeleteRule()')); - - expect(source).toContain('data-alert-notice-rule-save-loading="angular-nz-ok-loading"'); - expect(source).toContain('data-alert-notice-rule-save-loading-owner="route-modal-ok-contract"'); - expect(source).toContain("data-alert-notice-rule-save-loading-state={savingRule ? 'true' : 'false'}"); - expect(source).toContain('aria-busy={savingRule}'); - expect(source).toContain('disabled={savingRule}'); - expect(source).toContain("{savingRule ? t('common.saving') : t('common.save')}"); - expect(handleSaveRuleSource).toContain('setSavingRule(true);'); - expect(handleSaveRuleSource).toContain('setSavingRule(false);'); - }); - - it('keeps Angular notice-rule save failure title separate from backend detail', () => { - const source = readFileSync(resolve(process.cwd(), 'app/alert/notice/alert-notice-page.tsx'), 'utf8'); - const handleSaveRuleSource = source.slice(source.indexOf('async function handleSaveRule()'), source.indexOf('async function handleDeleteRule()')); - - expect(source).toContain('const [ruleErrorDetail, setRuleErrorDetail] = useState(null);'); - expect(source).toContain('data-alert-notice-rule-save-failure={ruleErrorDetail ? \'angular-notify-title-detail\' : undefined}'); - expect(source).toContain('data-alert-notice-rule-save-failure-owner={ruleErrorDetail ? \'route-action-feedback-contract\' : undefined}'); - expect(source).toContain('data-alert-notice-rule-save-failure-title={ruleErrorDetail ? ruleError : undefined}'); - expect(source).toContain('data-alert-notice-rule-save-failure-detail={ruleErrorDetail ?? undefined}'); - expect(handleSaveRuleSource).toContain("setRuleError(t(isEdit ? 'common.notify.edit-fail' : 'common.notify.new-fail'))"); - expect(handleSaveRuleSource).toContain('setRuleErrorDetail(error instanceof Error ? error.message : null)'); - expect(handleSaveRuleSource).not.toContain("setRuleError(error instanceof Error ? error.message : t(isEdit ? 'common.notify.edit-fail' : 'common.notify.new-fail'))"); - }); - - it('clears notice-rule authoring feedback when the draft changes', () => { - const source = readFileSync(resolve(process.cwd(), 'app/alert/notice/alert-notice-page.tsx'), 'utf8'); - const handleRuleDraftChangeSource = source.slice(source.indexOf('function handleRuleDraftChange('), source.indexOf('async function handleSaveRule()')); - - expect(handleRuleDraftChangeSource).toContain('setRuleDraft(nextDraft);'); - expect(handleRuleDraftChangeSource).toContain('setRuleMessage(null);'); - expect(handleRuleDraftChangeSource).toContain('setRuleError(null);'); - expect(handleRuleDraftChangeSource).toContain('setRuleErrorDetail(null);'); - expect(source).toContain('onDraftChange={handleRuleDraftChange}'); - expect(source).not.toContain('onDraftChange={setRuleDraft}'); - }); - - it('keeps Angular notice-template save failure title separate from backend detail', () => { - const source = readFileSync(resolve(process.cwd(), 'app/alert/notice/alert-notice-page.tsx'), 'utf8'); - const handleSaveTemplateSource = source.slice(source.indexOf('async function handleSaveTemplate()'), source.indexOf('async function handleDeleteTemplate()')); - - expect(source).toContain('const [templateErrorDetail, setTemplateErrorDetail] = useState(null);'); - expect(source).toContain('data-alert-notice-template-save-failure={templateErrorDetail ? \'angular-notify-title-detail\' : undefined}'); - expect(source).toContain('data-alert-notice-template-save-failure-owner={templateErrorDetail ? \'route-action-feedback-contract\' : undefined}'); - expect(source).toContain('data-alert-notice-template-save-failure-title={templateErrorDetail ? templateError : undefined}'); - expect(source).toContain('data-alert-notice-template-save-failure-detail={templateErrorDetail ?? undefined}'); - expect(handleSaveTemplateSource).toContain("setTemplateError(t(isEdit ? 'common.notify.edit-fail' : 'common.notify.new-fail'))"); - expect(handleSaveTemplateSource).toContain('setTemplateErrorDetail(error instanceof Error ? error.message : null)'); - expect(handleSaveTemplateSource).not.toContain("setTemplateError(error instanceof Error ? error.message : t(isEdit ? 'common.notify.edit-fail' : 'common.notify.new-fail'))"); - }); - - it('keeps new notice templates on Angular required type selection before save', () => { - const viewModelSource = readFileSync(resolve(process.cwd(), 'lib/alert-notice/view-model.ts'), 'utf8'); - const fieldsSource = readFileSync(resolve(process.cwd(), 'components/pages/alert-notice-template-fields.tsx'), 'utf8'); - const routeSource = readFileSync(resolve(process.cwd(), 'app/alert/notice/alert-notice-page.tsx'), 'utf8'); - - expect(viewModelSource).toContain("type: source?.type == null ? '' : String(source.type)"); - expect(viewModelSource).toContain("return t('alert.notice.template.validation.type')"); - expect(fieldsSource).toContain("const typeValue = draft.type || '';"); - expect(fieldsSource).toContain("t('alert.notice.receiver.type.placeholder')"); - expect(fieldsSource).toContain('data-alert-notice-template-type-required="angular-required-select"'); - expect(fieldsSource).toContain('data-alert-notice-template-type-required-owner="route-validation-contract"'); - expect(routeSource).toContain(''); - }); - - it('keeps notice-rule single Boolean switches unframed inside editor rows', () => { - const fieldsSource = readFileSync(resolve(process.cwd(), 'components/pages/alert-notice-rule-fields.tsx'), 'utf8'); - - expect(fieldsSource).toContain('data-alert-notice-rule-single-switch-frame="none"'); - expect(fieldsSource).toContain('data-alert-notice-rule-single-switch-frame-owner="route-form-contract"'); - expect(fieldsSource).toContain('data-alert-notice-rule-template-type-filter="angular-selected-receiver-type"'); - expect(fieldsSource).toContain('data-alert-notice-rule-template-type-filter-owner="route-form-contract"'); - expect(fieldsSource).toContain('data-alert-notice-rule-template-active-type="angular-switch-receiver"'); - expect(fieldsSource).toContain('data-alert-notice-rule-template-active-type-owner="route-form-contract"'); - expect(fieldsSource).toContain('data-alert-notice-rule-optional-period-time="angular-form-validity"'); - expect(fieldsSource).toContain('data-alert-notice-rule-optional-period-time-owner="route-validation-contract"'); - expect(fieldsSource).toContain('data-alert-notice-rule-period-limit-state="angular-independent-isLimit"'); - expect(fieldsSource).toContain('data-alert-notice-rule-period-limit-state-owner="route-form-contract"'); - expect(fieldsSource).toContain('data-alert-notice-rule-edit-option-seeding="angular-detail-options"'); - expect(fieldsSource).toContain('data-alert-notice-rule-edit-option-seeding-owner="route-form-contract"'); - expect(fieldsSource).toContain('export function AlertNoticeRuleSwitch'); - expect(fieldsSource).toContain('aria-label={label}'); - expect(fieldsSource).toContain('hover:border-[#5f7df6]'); - expect(fieldsSource).toContain('data-alert-notice-rule-switch={row}'); - expect(fieldsSource).not.toContain('hover:text-white'); - expect(fieldsSource).not.toContain('inline-flex h-8 items-center gap-2 rounded-[3px] border border-[#2b3039] bg-[#101217] px-2'); - }); - - it('keeps notice editor validation feedback inside the active cold editor dialogs', () => { - const source = readFileSync(resolve(process.cwd(), 'app/alert/notice/alert-notice-page.tsx'), 'utf8'); - - expect(source).toContain('data-alert-notice-receiver-validation="hertzbeat-ui-validation-feedback"'); - expect(source).toContain('data-alert-notice-rule-validation="hertzbeat-ui-validation-feedback"'); - expect(source).toContain('data-alert-notice-template-validation="hertzbeat-ui-validation-feedback"'); - expect(source.match(/role="alert"/g)?.length ?? 0).toBeGreaterThanOrEqual(3); - expect(source).toContain('editingReceiver && receiverError'); - expect(source).toContain('editingRule && ruleError'); - expect(source).toContain('editingTemplate && templateError'); - expect(source).toContain('!editingReceiver && receiverError'); - expect(source).toContain('!editingRule && ruleError'); - expect(source).toContain('!editingTemplate && templateError'); - }); - - it('clears receiver editor feedback when operators cancel or close the receiver dialog', () => { - const source = readFileSync(resolve(process.cwd(), 'app/alert/notice/alert-notice-page.tsx'), 'utf8'); - const closeReceiverEditorSource = source.slice(source.indexOf('function closeReceiverEditor()'), source.indexOf('async function handleNewReceiver()')); - - expect(closeReceiverEditorSource).toContain('setEditingReceiver(false)'); - expect(closeReceiverEditorSource).toContain('setReceiverMessage(null)'); - expect(closeReceiverEditorSource).toContain('setReceiverError(null)'); - expect(closeReceiverEditorSource).toContain('setReceiverErrorDetail(null)'); - expect(source).toContain('onClose={requestCloseReceiverEditor}'); - expect(source).toContain('data-testid="notice-receiver-cancel"'); - expect(source).toContain('onClick={requestCloseReceiverEditor}'); - expect(source).not.toContain('data-testid="notice-receiver-cancel" className={coldButtonClassName} size="sm" variant="default" onClick={() => setEditingReceiver(false)}'); - }); - - it('clears template editor feedback when operators cancel or close the template dialog', () => { - const source = readFileSync(resolve(process.cwd(), 'app/alert/notice/alert-notice-page.tsx'), 'utf8'); - const closeTemplateEditorSource = source.slice(source.indexOf('function closeTemplateEditor()'), source.indexOf('async function handleNewTemplate()')); - - expect(closeTemplateEditorSource).toContain('setEditingTemplate(false)'); - expect(closeTemplateEditorSource).toContain('setTemplateReadOnly(false)'); - expect(closeTemplateEditorSource).toContain('setTemplateDiscardDialogOpen(false)'); - expect(closeTemplateEditorSource).toContain('setTemplateMessage(null)'); - expect(closeTemplateEditorSource).toContain('setTemplateError(null)'); - expect(closeTemplateEditorSource).toContain('setTemplateErrorDetail(null)'); - expect(source).toContain('onClose={requestCloseTemplateEditor}'); - expect(source).toContain('onClick={requestCloseTemplateEditor}'); - expect(source).not.toContain('onClick={() => {\\n setEditingTemplate(false);\\n setTemplateReadOnly(false);\\n }}'); - }); - - it('clears notice rule editor feedback when operators cancel or close the rule dialog', () => { - const source = readFileSync(resolve(process.cwd(), 'app/alert/notice/alert-notice-page.tsx'), 'utf8'); - const closeRuleEditorSource = source.slice(source.indexOf('function closeRuleEditor()'), source.indexOf('async function handleNewRule()')); - - expect(closeRuleEditorSource).toContain('setEditingRule(false)'); - expect(closeRuleEditorSource).toContain('setRuleDiscardDialogOpen(false)'); - expect(closeRuleEditorSource).toContain('setRuleMessage(null)'); - expect(closeRuleEditorSource).toContain('setRuleError(null)'); - expect(closeRuleEditorSource).toContain('setRuleErrorDetail(null)'); - expect(source).toContain('onClose={requestCloseRuleEditor}'); - expect(source).toContain('onClick={requestCloseRuleEditor}'); - expect(source).not.toContain('onClose={() => setEditingRule(false)}'); - expect(source).not.toContain('onClick={() => setEditingRule(false)}'); - }); - - it('keeps Angular create and edit notification keys for all notice save dialogs', () => { - const source = readFileSync(resolve(process.cwd(), 'app/alert/notice/alert-notice-page.tsx'), 'utf8'); - const handleSaveReceiverSource = source.slice(source.indexOf('async function handleSaveReceiver()'), source.indexOf('async function handleDeleteReceiver()')); - const handleSaveTemplateSource = source.slice(source.indexOf('async function handleSaveTemplate()'), source.indexOf('async function handleDeleteTemplate()')); - const handleSaveRuleSource = source.slice(source.indexOf('async function handleSaveRule()'), source.indexOf('async function handleDeleteRule()')); - const saveSources = [handleSaveReceiverSource, handleSaveTemplateSource, handleSaveRuleSource]; - - expect(source).toContain('data-alert-notice-save-feedback="angular-new-edit-notify"'); - expect(source).toContain('data-alert-notice-save-feedback-owner="route-action-feedback-contract"'); - for (const saveSource of saveSources) { - expect(saveSource).toContain('const isEdit = Boolean('); - expect(saveSource).toContain("isEdit ? 'common.notify.edit-success' : 'common.notify.new-success'"); - expect(saveSource).toContain("isEdit ? 'common.notify.edit-fail' : 'common.notify.new-fail'"); - expect(saveSource).not.toContain('common.save-success'); - expect(saveSource).not.toContain('common.save-failed'); - } - }); - - it('keeps Angular receiver save next-step copy on create and edit success', () => { - const source = readFileSync(resolve(process.cwd(), 'app/alert/notice/alert-notice-page.tsx'), 'utf8'); - const handleSaveReceiverSource = source.slice(source.indexOf('async function handleSaveReceiver()'), source.indexOf('async function handleDeleteReceiver()')); - const handleSaveTemplateSource = source.slice(source.indexOf('async function handleSaveTemplate()'), source.indexOf('async function handleDeleteTemplate()')); - const handleSaveRuleSource = source.slice(source.indexOf('async function handleSaveRule()'), source.indexOf('async function handleDeleteRule()')); - - expect(source).toContain('data-alert-notice-receiver-success-next="angular-policy-next"'); - expect(source).toContain('data-alert-notice-receiver-success-next-owner="route-action-feedback-contract"'); - expect(handleSaveReceiverSource).toContain("t('alert.notice.receiver.next')"); - expect(handleSaveReceiverSource).toContain("setReceiverMessage([t(isEdit ? 'common.notify.edit-success' : 'common.notify.new-success'), t('alert.notice.receiver.next')].join(' '))"); - expect(handleSaveTemplateSource).not.toContain('alert.notice.receiver.next'); - expect(handleSaveRuleSource).not.toContain('alert.notice.receiver.next'); - }); - - it('keeps Angular receiver save transport-error close behavior distinct from business failures', () => { - const source = readFileSync(resolve(process.cwd(), 'app/alert/notice/alert-notice-page.tsx'), 'utf8'); - const handleSaveReceiverSource = source.slice(source.indexOf('async function handleSaveReceiver()'), source.indexOf('async function handleDeleteReceiver()')); - const handleSaveTemplateSource = source.slice(source.indexOf('async function handleSaveTemplate()'), source.indexOf('async function handleDeleteTemplate()')); - - expect(source).toContain('function isApiMessageBusinessError(error: unknown)'); - expect(source).toContain('data-alert-notice-receiver-save-failure-close="angular-transport-error-close"'); - expect(source).toContain('data-alert-notice-receiver-save-failure-close-owner="route-action-feedback-contract"'); - expect(handleSaveReceiverSource).toContain('if (!isApiMessageBusinessError(error))'); - expect(handleSaveReceiverSource).toContain('setEditingReceiver(false);'); - expect(handleSaveTemplateSource).not.toContain('isApiMessageBusinessError'); - }); - - it('keeps Angular receiver save failure title separate from backend detail while preserving transport close', () => { - const source = readFileSync(resolve(process.cwd(), 'app/alert/notice/alert-notice-page.tsx'), 'utf8'); - const handleSaveReceiverSource = source.slice(source.indexOf('async function handleSaveReceiver()'), source.indexOf('async function handleDeleteReceiver()')); - - expect(source).toContain('const [receiverErrorDetail, setReceiverErrorDetail] = useState(null);'); - expect(source).toContain('data-alert-notice-receiver-save-failure={receiverErrorDetail ? \'angular-notify-title-detail\' : undefined}'); - expect(source).toContain('data-alert-notice-receiver-save-failure-owner={receiverErrorDetail ? \'route-action-feedback-contract\' : undefined}'); - expect(source).toContain('data-alert-notice-receiver-save-failure-title={receiverErrorDetail ? receiverError : undefined}'); - expect(source).toContain('data-alert-notice-receiver-save-failure-detail={receiverErrorDetail ?? undefined}'); - expect(handleSaveReceiverSource).toContain("setReceiverError(t(isEdit ? 'common.notify.edit-fail' : 'common.notify.new-fail'))"); - expect(handleSaveReceiverSource).toContain('setReceiverErrorDetail(error instanceof Error ? error.message : null)'); - expect(handleSaveReceiverSource).toContain('if (!isApiMessageBusinessError(error))'); - expect(handleSaveReceiverSource).not.toContain("setReceiverError(error instanceof Error ? error.message : t(isEdit ? 'common.notify.edit-fail' : 'common.notify.new-fail'))"); - }); - - it('keeps Angular edit-fail fallback keys for all notice detail loads', () => { - const source = readFileSync(resolve(process.cwd(), 'app/alert/notice/alert-notice-page.tsx'), 'utf8'); - const handleEditReceiverSource = source.slice(source.indexOf('async function handleEditReceiver('), source.indexOf('async function handleSaveReceiver()')); - const handleEditTemplateSource = source.slice(source.indexOf('async function handleEditTemplate('), source.indexOf('async function handleViewTemplate(')); - const handleEditRuleSource = source.slice(source.indexOf('async function handleEditRule('), source.indexOf('async function handleSaveRule()')); - const editSources = [handleEditReceiverSource, handleEditTemplateSource, handleEditRuleSource]; - - expect(source).toContain('data-alert-notice-edit-load-feedback="angular-edit-fail"'); - expect(source).toContain('data-alert-notice-edit-load-feedback-owner="route-action-feedback-contract"'); - for (const editSource of editSources) { - expect(editSource).toContain("t('common.notify.edit-fail')"); - expect(editSource).not.toContain('common.load-failed'); - } - }); - - it('keeps Angular receiver row edit loading the full detail before opening the modal', () => { - const source = readFileSync(resolve(process.cwd(), 'app/alert/notice/alert-notice-page.tsx'), 'utf8'); - const handleEditReceiverSource = source.slice(source.indexOf('async function handleEditReceiver('), source.indexOf('async function handleSaveReceiver()')); - - expect(source).toContain('data-alert-notice-receiver-edit-detail="angular-detail-fetch"'); - expect(source).toContain('data-alert-notice-receiver-edit-detail-owner="route-detail-fetch-contract"'); - expect(source).toContain('onClick={() => void handleEditReceiver(receiver)}'); - expect(handleEditReceiverSource).toContain('async function handleEditReceiver(receiver = selectedReceiver)'); - expect(handleEditReceiverSource).toContain('await api.alertNotice.receivers.detail(receiver.id)'); - expect(handleEditReceiverSource).toContain('const nextDraft = buildNoticeReceiverDraft(detail);'); - expect(handleEditReceiverSource).toContain('setReceiverDraft(nextDraft);'); - expect(handleEditReceiverSource).toContain('setReceiverInitialFingerprint(serializeNoticeReceiverDraft(nextDraft));'); - expect(handleEditReceiverSource).not.toContain('buildNoticeReceiverDraft(receiver)'); - }); - - it('keeps Angular custom template row edit loading the full detail before opening the modal', () => { - const source = readFileSync(resolve(process.cwd(), 'app/alert/notice/alert-notice-page.tsx'), 'utf8'); - const handleEditTemplateSource = source.slice(source.indexOf('async function handleEditTemplate('), source.indexOf('async function handleViewTemplate(')); - - expect(source).toContain('data-alert-notice-template-edit-detail="angular-detail-fetch"'); - expect(source).toContain('data-alert-notice-template-edit-detail-owner="route-detail-fetch-contract"'); - expect(source).toContain('onClick={() => void handleEditTemplate(template)}'); - expect(handleEditTemplateSource).toContain('const detail = await api.alertNotice.templates.detail(template.id)'); - expect(handleEditTemplateSource).toContain('const nextDraft = buildNoticeTemplateDraft(detail)'); - expect(handleEditTemplateSource).toContain('setTemplateDraft(nextDraft)'); - expect(handleEditTemplateSource).not.toContain('buildNoticeTemplateDraft(template)'); - }); - - it('keeps Angular preset template viewer footer using return and no OK action', () => { - const source = readFileSync(resolve(process.cwd(), 'app/alert/notice/alert-notice-page.tsx'), 'utf8'); - const templateDialogStart = source.indexOf('const templateEditorDialog = ('); - const templateEditorDialogSource = source.slice(templateDialogStart, source.indexOf(' return (', templateDialogStart)); - - expect(source).toContain('data-alert-notice-template-viewer-return={templateReadOnly ? \'angular-cancel-return\' : undefined}'); - expect(source).toContain('data-alert-notice-template-viewer-return-owner={templateReadOnly ? \'route-modal-footer-contract\' : undefined}'); - expect(source).toContain("data-alert-notice-template-unsaved-cancel-trigger={!templateReadOnly ? shouldConfirmTemplateDiscard ? 'dirty' : 'clean' : undefined}"); - expect(source).toContain('data-alert-notice-template-viewer-ok="none"'); - expect(source).toContain('data-alert-notice-template-viewer-ok-owner="route-modal-footer-contract"'); - expect(templateEditorDialogSource).toContain("templateReadOnly ? t('common.button.return') : t('common.cancel')"); - expect(templateEditorDialogSource).toContain('{!templateReadOnly ? ('); - }); - - it('keeps Angular delete page-index clamping for all notice tabs', () => { - const source = readFileSync(resolve(process.cwd(), 'app/alert/notice/alert-notice-page.tsx'), 'utf8'); - const handleConfirmedDeleteSource = source.slice(source.indexOf('async function handleConfirmedDelete()'), source.indexOf('async function handleTestSend()')); - - expect(source).toContain('function clampNoticePageIndexAfterDelete'); - expect(source).toContain('Math.ceil(nextTotal / safePageSize) - 1'); - expect(source).toContain('setReceiverPageIndex(pageIndex => clampNoticePageIndexAfterDelete(pageIndex, receiverPageSize, receiverTotal, 1))'); - expect(source).toContain('setRulePageIndex(pageIndex => clampNoticePageIndexAfterDelete(pageIndex, rulePageSize, ruleTotal, 1))'); - expect(source).toContain('setTemplatePageIndex(pageIndex => clampNoticePageIndexAfterDelete(pageIndex, templatePageSize, templateTotal, 1))'); - expect(source).toContain('data-alert-notice-delete-page-clamp="angular-update-page-index"'); - expect(source).toContain('data-alert-notice-delete-page-clamp-owner="route-state-contract"'); - expect(source).toContain('data-alert-notice-delete-confirm="angular-modal-confirm"'); - expect(source).toContain('data-alert-notice-delete-confirm-owner="hertzbeat-ui-confirm-dialog"'); - expect(source).toContain('data-alert-notice-delete-confirm-dialog="angular-modal-confirm"'); - expect(source).toContain('data-alert-notice-delete-confirm-ok'); - expect(source).toContain('data-alert-notice-delete-confirm-cancel'); - expect(source).toContain('data-alert-notice-delete-feedback="angular-delete-notify"'); - expect(source).toContain('data-alert-notice-delete-feedback-owner="route-action-feedback-contract"'); - expect(source).toContain('name?: string;'); - expect(source).toContain("setDeleteRequest({ kind: 'receiver', id: receiverId, name: receiverName?.trim() || undefined })"); - expect(source).toContain("setDeleteRequest({ kind: 'rule', id: ruleId, name: ruleName?.trim() || undefined })"); - expect(source).toContain("setDeleteRequest({ kind: 'template', id: templateId, name: templateName?.trim() || undefined })"); - expect(source).toContain("t('alert.notice.delete.confirm.target', { name: deleteRequest.name })"); - expect(source).toContain("t('alert.notice.delete.confirm.receiver-action')"); - expect(source).toContain("t('alert.notice.delete.confirm.template-action')"); - expect(source).toContain("t('alert.notice.delete.confirm.rule-action')"); - expect(source).toContain('confirmLabel={deleteConfirmActionLabel}'); - expect(source).not.toContain("confirmLabel={t('common.button.ok')}"); - expect(source).not.toContain("from '../../../components/ui/hz-confirm-dialog'"); - expect(handleConfirmedDeleteSource.match(/common.notify.delete-success/g)?.length).toBe(3); - expect(handleConfirmedDeleteSource).toContain("t('common.notify.delete-fail')"); - expect(handleConfirmedDeleteSource).not.toContain('common.delete-success'); - expect(handleConfirmedDeleteSource).not.toContain('common.delete-failed'); - }); - - it('keeps the old Angular receiver test-send loading guard inside the editor dialog', () => { - const source = readFileSync(resolve(process.cwd(), 'app/alert/notice/alert-notice-page.tsx'), 'utf8'); - const receiverFieldsSource = readFileSync(resolve(process.cwd(), 'components/pages/alert-notice-receiver-fields.tsx'), 'utf8'); - const handleTestSendSource = source.slice(source.indexOf('async function handleTestSend()'), source.indexOf('async function handleNewTemplate()')); - - expect(source).toContain('const [testingReceiver, setTestingReceiver] = useState(false)'); - expect(source).toContain('const RECEIVER_TEST_SEND_TIMEOUT_MS = 15_000;'); - expect(source).toContain('async function withNoticeReceiverTestTimeout(task: Promise, error: Error)'); - expect(source).toContain('setTestingReceiver(true);'); - expect(source).toContain('setTestingReceiver(false);'); - expect(source).toContain('buildNoticeTemplateListUrl'); - expect(source).toContain('alertNoticeTemplateListUrl'); - expect(source).toContain('data.templateOptions?.content ?? data.templates.content'); - expect(source).not.toContain('const filteredTemplates = data.templates.content.filter'); - expect(source).toContain('data-alert-notice-receiver-test-loading={testingReceiver ? \'true\' : \'false\'}'); - expect(source).toContain('data-alert-notice-receiver-test-validation="angular-backend-owned"'); - expect(source).toContain('data-alert-notice-receiver-test-validation-owner="route-mutation-contract"'); - expect(source).toContain("data-alert-notice-receiver-save-blocked-by-test={testingReceiver ? 'true' : undefined}"); - expect(receiverFieldsSource).toContain('data-alert-notice-receiver-default-type="angular-email"'); - expect(receiverFieldsSource).toContain('data-alert-notice-receiver-default-type-owner="route-form-contract"'); - expect(source).toContain('aria-busy={testingReceiver}'); - expect(source).toContain('disabled={testingReceiver || savingReceiver}'); - expect(source).toContain('data-alert-notice-receiver-test-feedback="hertzbeat-ui-test-feedback"'); - expect(source).toContain('const coldNoticeStatusMessageClass ='); - expect(source).toContain('const coldNoticeTestStatusMessageClass ='); - expect(source).toContain('
'); - expect(source).toContain('className={coldNoticeTestStatusMessageClass}'); - expect(source).not.toContain('text-emerald-300'); - expect(source).not.toContain('border border-[#24563d] bg-[#0d1a14]'); - expect(source).toContain('data-alert-notice-receiver-test-preview="signal-route"'); - expect(source).toContain('data-alert-notice-receiver-test-preview-owner="signal-alert-handoff"'); - expect(source).toContain('noticeEvidenceContext.receiverTestPreview.labelsPreviewText'); - expect(source).toContain('data-alert-notice-receiver-test-preview-labels-total={noticeEvidenceContext.receiverTestPreview.labelsTotal}'); - expect(source).toContain('data-alert-notice-receiver-test-preview-labels-rendered={noticeEvidenceContext.receiverTestPreview.labelsRendered}'); - expect(source).toContain('data-alert-notice-receiver-test-preview-labels-limit={noticeEvidenceContext.receiverTestPreview.labelsLimit}'); - expect(source).toContain('data-alert-notice-receiver-test-preview-labels-overflow={noticeEvidenceContext.receiverTestPreview.labelsOverflow}'); - expect(source).toContain('data-alert-notice-receiver-test-preview-payload="sample-alert"'); - expect(source).toContain('data-alert-notice-receiver-test-preview-payload-owner="signal-alert-handoff"'); - expect(source).toContain('noticeEvidenceContext.receiverTestPreview.payloadRows.map'); - expect(source).toContain('data-alert-notice-receiver-test-preview-payload-message="sample-rendered"'); - expect(source).toContain('editingReceiver && receiverMessage'); - expect(source).toContain('!editingReceiver && receiverMessage'); - expect(handleTestSendSource).toContain('await withNoticeReceiverTestTimeout('); - expect(handleTestSendSource).toContain('api.alertNotice.receivers.sendTest(receiverDraft)'); - expect(handleTestSendSource).toContain("new Error(t('alert.notice.send-test.timeout.detail'))"); - expect(handleTestSendSource).toContain("setReceiverError(t('alert.notice.send-test.notify.failed'))"); - expect(handleTestSendSource).toContain('setReceiverErrorDetail(error instanceof Error ? error.message : null)'); - expect(handleTestSendSource).not.toContain("setReceiverError(error instanceof Error ? error.message : t('alert.notice.send-test.notify.failed'))"); - expect(handleTestSendSource).not.toContain('noticeEvidenceContext'); - expect(handleTestSendSource).not.toContain('validateNoticeReceiverDraft(receiverDraft, t)'); - }); - - it('guards dirty receiver cancel with a discard confirmation', () => { - const source = readFileSync(resolve(process.cwd(), 'app/alert/notice/alert-notice-page.tsx'), 'utf8'); - const receiverDialogSource = source.slice(source.indexOf('const receiverEditorDialog = ('), source.indexOf('const ruleEditorDialog = (')); - - expect(source).toContain('const NOTICE_RECEIVER_DRAFT_FINGERPRINT_FIELDS: Array'); - expect(source).toContain('function serializeNoticeReceiverDraft(draft: NoticeReceiverDraft)'); - expect(source).toContain('const [receiverInitialFingerprint, setReceiverInitialFingerprint] = useState'); - expect(source).toContain('const [receiverDiscardDialogOpen, setReceiverDiscardDialogOpen] = useState(false)'); - expect(source).toContain('const receiverDraftFingerprint = useMemo(() => serializeNoticeReceiverDraft(receiverDraft), [receiverDraft]);'); - expect(source).toContain('const shouldConfirmReceiverDiscard = Boolean(editingReceiver && receiverDraftFingerprint !== receiverInitialFingerprint && !savingReceiver);'); - expect(source).toContain('function requestCloseReceiverEditor()'); - expect(source).toContain('setReceiverDiscardDialogOpen(true);'); - expect(source).toContain('setReceiverInitialFingerprint(serializeNoticeReceiverDraft(nextDraft));'); - expect(receiverDialogSource).toContain('onClose={requestCloseReceiverEditor}'); - expect(receiverDialogSource).toContain('data-alert-notice-receiver-unsaved-cancel-trigger={shouldConfirmReceiverDiscard ? \'dirty\' : \'clean\'}'); - expect(source).toContain('data-alert-notice-receiver-unsaved-cancel="hertzbeat-ui-confirm-dialog"'); - expect(source).toContain('data-alert-notice-receiver-unsaved-cancel-state={receiverDiscardDialogOpen ? \'open\' : \'closed\'}'); - expect(source).toContain("title={t('alert.notice.receiver.unsaved-cancel.title')}"); - expect(source).toContain("cancelLabel={t('alert.notice.receiver.unsaved-cancel.keep-editing')}"); - expect(source).toContain("confirmLabel={t('alert.notice.receiver.unsaved-cancel.discard')}"); - expect(source).toContain('data-alert-notice-receiver-unsaved-cancel-keep-editing'); - expect(source).toContain('data-alert-notice-receiver-unsaved-cancel-confirm'); - }); - - it('guards dirty notice-rule cancel with a discard confirmation', () => { - const source = readFileSync(resolve(process.cwd(), 'app/alert/notice/alert-notice-page.tsx'), 'utf8'); - const ruleDialogSource = source.slice(source.indexOf('const ruleEditorDialog = ('), source.indexOf('const templateEditorDialog = (')); - - expect(source).toContain('const NOTICE_RULE_DRAFT_FINGERPRINT_FIELDS: Array'); - expect(source).toContain('function serializeNoticeRuleDraft(draft: NoticeRuleDraft)'); - expect(source).toContain('const [ruleInitialFingerprint, setRuleInitialFingerprint] = useState'); - expect(source).toContain('const [ruleDiscardDialogOpen, setRuleDiscardDialogOpen] = useState(false)'); - expect(source).toContain('const ruleDraftFingerprint = useMemo(() => serializeNoticeRuleDraft(ruleDraft), [ruleDraft]);'); - expect(source).toContain('const shouldConfirmRuleDiscard = Boolean(editingRule && ruleDraftFingerprint !== ruleInitialFingerprint && !savingRule);'); - expect(source).toContain('function requestCloseRuleEditor()'); - expect(source).toContain('setRuleDiscardDialogOpen(true);'); - expect(source).toContain('setRuleInitialFingerprint(serializeNoticeRuleDraft(nextDraft));'); - expect(source).toContain('setRuleInitialFingerprint(serializeNoticeRuleDraft(ruleDraft));'); - expect(ruleDialogSource).toContain('onClose={requestCloseRuleEditor}'); - expect(ruleDialogSource).toContain('data-alert-notice-rule-unsaved-cancel-trigger={shouldConfirmRuleDiscard ? \'dirty\' : \'clean\'}'); - expect(source).toContain('data-alert-notice-rule-unsaved-cancel="hertzbeat-ui-confirm-dialog"'); - expect(source).toContain('data-alert-notice-rule-unsaved-cancel-state={ruleDiscardDialogOpen ? \'open\' : \'closed\'}'); - expect(source).toContain("title={t('alert.notice.rule.unsaved-cancel.title')}"); - expect(source).toContain("cancelLabel={t('alert.notice.rule.unsaved-cancel.keep-editing')}"); - expect(source).toContain("confirmLabel={t('alert.notice.rule.unsaved-cancel.discard')}"); - expect(source).toContain('data-alert-notice-rule-unsaved-cancel-keep-editing'); - expect(source).toContain('data-alert-notice-rule-unsaved-cancel-confirm'); - }); - - it('guards dirty notice-template cancel with a discard confirmation', () => { - const source = readFileSync(resolve(process.cwd(), 'app/alert/notice/alert-notice-page.tsx'), 'utf8'); - const templateDialogStart = source.indexOf('const templateEditorDialog = ('); - const templateDialogSource = source.slice(templateDialogStart, source.indexOf(' const deleteConfirmCopy = [', templateDialogStart)); - - expect(source).toContain('const NOTICE_TEMPLATE_DRAFT_FINGERPRINT_FIELDS: Array'); - expect(source).toContain('function serializeNoticeTemplateDraft(draft: NoticeTemplateDraft)'); - expect(source).toContain('const [templateInitialFingerprint, setTemplateInitialFingerprint] = useState'); - expect(source).toContain('const [templateDiscardDialogOpen, setTemplateDiscardDialogOpen] = useState(false)'); - expect(source).toContain('const templateDraftFingerprint = useMemo(() => serializeNoticeTemplateDraft(templateDraft), [templateDraft]);'); - expect(source).toContain('editingTemplate && !templateReadOnly && templateDraftFingerprint !== templateInitialFingerprint && !savingTemplate'); - expect(source).toContain('function requestCloseTemplateEditor()'); - expect(source).toContain('setTemplateDiscardDialogOpen(true);'); - expect(source).toContain('setTemplateInitialFingerprint(serializeNoticeTemplateDraft(nextDraft));'); - expect(source).toContain('setTemplateInitialFingerprint(serializeNoticeTemplateDraft(templateDraft));'); - expect(templateDialogSource).toContain('onClose={requestCloseTemplateEditor}'); - expect(templateDialogSource).toContain("data-alert-notice-template-unsaved-cancel-trigger={!templateReadOnly ? shouldConfirmTemplateDiscard ? 'dirty' : 'clean' : undefined}"); - expect(templateDialogSource).toContain('onClick={requestCloseTemplateEditor}'); - expect(source).toContain('data-alert-notice-template-unsaved-cancel="hertzbeat-ui-confirm-dialog"'); - expect(source).toContain('data-alert-notice-template-unsaved-cancel-state={templateDiscardDialogOpen ? \'open\' : \'closed\'}'); - expect(source).toContain("title={t('alert.notice.template.unsaved-cancel.title')}"); - expect(source).toContain("cancelLabel={t('alert.notice.template.unsaved-cancel.keep-editing')}"); - expect(source).toContain("confirmLabel={t('alert.notice.template.unsaved-cancel.discard')}"); - expect(source).toContain('data-alert-notice-template-unsaved-cancel-keep-editing'); - expect(source).toContain('data-alert-notice-template-unsaved-cancel-confirm'); - }); - - it('loads alert notice data through the default receiver and rule query contract', async () => { - await renderAlertNoticePage(); - - await mockState.lastLoad?.(); - - expect(mockLoadAlertNoticeData).toHaveBeenCalledWith(expect.anything(), { - receivers: { search: '', pageIndex: 0, pageSize: 8 }, - rules: { search: '', pageIndex: 0, pageSize: 8 }, - templates: { search: '', preset: true, pageIndex: 0, pageSize: 8 } - }); - }, 30_000); - - it('opens notice policy context when routed from a three-signal alert investigation', async () => { - const initialRouteState: AlertNoticeRouteState = { - signal: 'logs', - signalContext: { - entityId: '7', - entityName: 'Checkout API', - serviceName: 'checkout', - environment: 'prod', - timeRange: 'last-1h', - source: 'otlp', - traceId: 'trace-123', - spanId: 'span-456', - returnTo: '/log/manage?traceId=trace-123' - } - }; - - const html = await renderAlertNoticePage(initialRouteState); - const source = readFileSync(resolve(process.cwd(), 'app/alert/notice/alert-notice-page.tsx'), 'utf8'); - - expect(html).toContain('data-selected-tab="rule"'); - expect(html).toContain('data-alert-notice-evidence-context="signal-route"'); - expect(html).toContain('data-alert-notice-evidence-layering="flat-context-band"'); - expect(html).toContain('data-alert-notice-evidence-signal="logs"'); - expect(html).toContain('data-alert-notice-prefill-labels="hertzbeat.signal:logs'); - expect(html).toContain('hertzbeat.entity.id:7'); - expect(html).toContain('service.name:checkout'); - expect(html).toContain('trace_id:trace-123'); - expect(html).toContain( - createTranslatorMock({ locale: 'zh-CN' })('alert.rule.evidence.notice.title', { - signal: createTranslatorMock({ locale: 'zh-CN' })('alert.rule.signal.logs') - }) - ); - expect(html).toContain(createTranslatorMock({ locale: 'zh-CN' })('alert.rule.evidence.notice.copy')); - expect(html).toContain('data-alert-notice-evidence-return="true"'); - expect(html).toContain('href="/log/manage?traceId=trace-123"'); - expect(source).not.toContain('readSignalRouteContext(searchParams)'); - expect(source).toContain('const alertNoticeRouteState = initialRouteState ?? EMPTY_ALERT_NOTICE_ROUTE_STATE'); - expect(source).toContain('buildAlertNoticeEvidenceContext'); - expect(source).toContain('data-alert-notice-evidence-layering="flat-context-band"'); - expect(source).not.toContain('className="mt-5 rounded-[4px] border border-[#27303c] bg-[#0b0f15] px-4 py-3 shadow-[0_18px_48px_rgba(0,0,0,0.24)]"'); - expect(source).toContain('data-alert-notice-rule-editor-return="evidence-context"'); - expect(source).toContain('noticeEvidenceContext?.returnHref'); - expect(source).toContain('buildNoticeRuleDraft(null, noticeEvidenceContext?.ruleDraftPatch)'); - expect(source).toContain('sourceLabelsText={noticeEvidenceContext?.labelsText}'); - expect(source).toContain('sourceSignal={noticeEvidenceContext?.signal}'); - expect(source).toContain('noticeEvidenceContext?.receiverTestPreview'); - expect(source).toContain('data-alert-notice-receiver-test-preview="signal-route"'); - expect(source).toContain('data-alert-notice-receiver-test-preview-owner="signal-alert-handoff"'); - expect(source).toContain('data-alert-notice-receiver-test-preview-signal={noticeEvidenceContext.signal}'); - expect(source).toContain('data-alert-notice-receiver-test-preview-labels-text="signal-route"'); - expect(source).toContain('data-alert-notice-receiver-test-preview-labels-total={noticeEvidenceContext.receiverTestPreview.labelsTotal}'); - expect(source).toContain('data-alert-notice-receiver-test-preview-labels-rendered={noticeEvidenceContext.receiverTestPreview.labelsRendered}'); - expect(source).toContain('data-alert-notice-receiver-test-preview-labels-limit={noticeEvidenceContext.receiverTestPreview.labelsLimit}'); - expect(source).toContain('data-alert-notice-receiver-test-preview-labels-overflow={noticeEvidenceContext.receiverTestPreview.labelsOverflow}'); - expect(source).toContain('noticeEvidenceContext.receiverTestPreview.labelsPreviewText'); - expect(source).toContain('data-alert-notice-receiver-test-preview-payload="sample-alert"'); - expect(source).toContain('data-alert-notice-receiver-test-preview-payload-row={row.key}'); - expect(source).toContain('data-alert-notice-receiver-test-preview-payload-message="sample-rendered"'); - }, 30_000); - - it('renders missing evidence labels with the localized empty fallback', async () => { - const html = await renderAlertNoticePage({ - signal: null, - signalContext: { - returnTo: '/alert?source=logs' - } - }); - - expect(html).toContain('data-selected-tab="rule"'); - expect(html).toContain('data-alert-notice-evidence-context="signal-route"'); - expect(html).toContain('data-alert-notice-evidence-layering="flat-context-band"'); - expect(html).toContain('data-alert-notice-prefill-labels=""'); - expect(html).toContain('data-alert-notice-evidence-labels="generated-labels"'); - expect(html).toContain(`>${createTranslatorMock({ locale: 'zh-CN' })('common.none')}
`); - expect(html).not.toContain('data-alert-notice-prefill-labels="-"'); - expect(html).not.toContain('>-'); - }, 30_000); - - it('keeps the receiver empty state close to the OTLP cold table baseline', async () => { - const previousData = mockState.renderData; - mockState.renderData = { - ...previousData, - receivers: { - content: [], - totalElements: 0, - pageIndex: 0, - pageSize: 8 - } - }; - - try { - const html = await renderAlertNoticePage(); - const t = createTranslatorMock({ locale: 'zh-CN' }); - - expect(html).toContain('data-alert-notice-receiver-empty-state="hertzbeat-ui-empty-state"'); - expect(html).toContain('data-alert-notice-receiver-empty-icon="hertzbeat-ui-empty-icon"'); - expect(html).toContain('data-alert-notice-receiver-empty-action="new"'); - expect(html).toContain(t('common.no-data')); - expect(html).toContain(t('alert.notice.receiver.new')); - } finally { - mockState.renderData = previousData; - } - }, 30_000); - - it('keeps alert notice remounts on a short settled cache window with refresh-tick invalidation', () => { - const source = readFileSync(resolve(process.cwd(), 'app/alert/notice/alert-notice-page.tsx'), 'utf8'); - - expect(source).toContain('ALERT_NOTICE_SETTLED_CACHE_TTL_MS = 10_000'); - expect(source).toContain('const [refreshTick, setRefreshTick] = useState(0)'); - expect(source).toContain("['alert-notice', alertNoticeReceiverListUrl, alertNoticeRuleListUrl, alertNoticeTemplateListUrl, refreshTick].join('|')"); - expect(source).toContain('[alertNoticeReceiverListUrl, alertNoticeRuleListUrl, alertNoticeTemplateListUrl, refreshTick]'); - expect(source).toContain('void refreshTick'); - expect(source).toContain('[alertNoticeLoadQuery, refreshTick]'); - expect(source.match(/setRefreshTick\(value => value \+ 1\)/g)?.length).toBeGreaterThanOrEqual(6); - expect(source).toContain('data-alert-notice-receiver-sync="angular-load-table"'); - expect(source).toContain('data-alert-notice-rule-sync="angular-load-table"'); - expect(source).toContain('data-alert-notice-template-sync="angular-load-table"'); - expect(source).toContain('cacheKey={alertNoticeCacheKey}'); - expect(source).toContain('cacheSettledTtlMs={ALERT_NOTICE_SETTLED_CACHE_TTL_MS}'); - }); -}); diff --git a/web-next/app/alert/notice/page.tsx b/web-next/app/alert/notice/page.tsx deleted file mode 100644 index c97e0f59c8..0000000000 --- a/web-next/app/alert/notice/page.tsx +++ /dev/null @@ -1,14 +0,0 @@ -import React from 'react'; - -import AlertNoticePage from './alert-notice-page'; -import { readAlertNoticeRouteState, type AlertNoticeSearchParams } from '../../../lib/alert-notice/query-state'; - -export default async function AlertNoticeRoutePage({ - searchParams -}: { - searchParams?: Promise; -}) { - const resolvedSearchParams = await searchParams; - const routeState = readAlertNoticeRouteState(resolvedSearchParams); - return ; -} diff --git a/web-next/app/alert/page.test.tsx b/web-next/app/alert/page.test.tsx deleted file mode 100644 index 90abb881f1..0000000000 --- a/web-next/app/alert/page.test.tsx +++ /dev/null @@ -1,464 +0,0 @@ -import React from 'react'; -import { readFileSync } from 'node:fs'; -import { resolve } from 'node:path'; -import { renderToStaticMarkup } from 'react-dom/server'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { createTranslatorMock } from '../../test/i18n-test-helper'; -import { - readAlertCenterRouteState, - type AlertCenterRouteState, - type AlertCenterSearchParams -} from '../../lib/alert-manage/query-state'; - -const mockState = vi.hoisted(() => ({ - replace: vi.fn(), - lastLoad: null as null | (() => Promise), - lastSurfaceProps: null as null | Record, - renderData: { - summary: { - total: 3, - dealNum: 1, - rate: 33, - priorityWarningNum: 1, - priorityCriticalNum: 1, - priorityEmergencyNum: 0 - }, - groupAlerts: { - content: [], - totalElements: 0, - pageIndex: 0, - pageSize: 8 - } - } -})); - -const loadAlertCenterDataFromFacade = vi.hoisted(() => vi.fn(async () => mockState.renderData)); -const applyAlertClosureOperationFromFacade = vi.hoisted(() => vi.fn(async () => undefined)); -const alertFacade = vi.hoisted(() => ({ - summary: vi.fn(async () => undefined), - groupAlerts: vi.fn(async () => undefined), - groupStatus: vi.fn(async () => undefined), - groupClose: vi.fn(async () => undefined) -})); -const entityFacade = vi.hoisted(() => ({ - detail: vi.fn(async () => undefined) -})); -const alertSilenceFacade = vi.hoisted(() => ({ - create: vi.fn(async () => undefined) -})); -const alertInhibitFacade = vi.hoisted(() => ({ - create: vi.fn(async () => undefined) -})); - -vi.mock('next/navigation', () => ({ - useRouter: () => ({ - replace: mockState.replace - }) -})); - -vi.mock('../../components/providers/i18n-provider', () => ({ - useI18n: () => ({ - t: createTranslatorMock() - }) -})); - -vi.mock('../../components/workbench/client-workbench', () => ({ - ClientWorkbench: ({ - children, - load, - loadingCopy - }: { - children: (data: any) => React.ReactNode; - load: () => Promise; - loadingCopy?: string; - }) => { - mockState.lastLoad = load; - return
{children(mockState.renderData)}
; - } -})); - -vi.mock('../../components/pages/alert-center-surface', () => ({ - AlertCenterSurface: (props: any) => { - mockState.lastSurfaceProps = props; - return ( -
- ); - } -})); - -vi.mock('../../lib/api-client', () => ({ - apiMessageDelete: vi.fn(), - apiMessageGet: vi.fn(), - apiMessagePost: vi.fn(), - apiMessagePut: vi.fn() -})); - -vi.mock('../../lib/alert-api-facade', () => ({ - api: { - alerts: alertFacade, - entities: entityFacade, - alertSilences: alertSilenceFacade, - alertInhibits: alertInhibitFacade - } -})); - -vi.mock('../../lib/alert-manage/controller', () => ({ - applyAlertClosureOperationFromFacade, - loadAlertCenterDataFromFacade -})); - -function buildAlertCenterRouteState(searchParams: AlertCenterSearchParams = {}): AlertCenterRouteState { - return readAlertCenterRouteState(searchParams); -} - -async function renderAlertCenterPage(initialRouteState: AlertCenterRouteState = buildAlertCenterRouteState()) { - const { default: AlertCenterPage } = await import('./alert-center-page'); - return renderToStaticMarkup(); -} - -describe('alert center page', () => { - beforeEach(() => { - mockState.replace.mockClear(); - mockState.lastLoad = null; - mockState.lastSurfaceProps = null; - loadAlertCenterDataFromFacade.mockClear().mockResolvedValue(mockState.renderData); - applyAlertClosureOperationFromFacade.mockClear().mockResolvedValue(undefined); - alertFacade.summary.mockClear().mockResolvedValue(undefined); - alertFacade.groupAlerts.mockClear().mockResolvedValue(undefined); - alertFacade.groupStatus.mockClear().mockResolvedValue(undefined); - alertFacade.groupClose.mockClear().mockResolvedValue(undefined); - entityFacade.detail.mockClear().mockResolvedValue(undefined); - alertSilenceFacade.create.mockClear().mockResolvedValue(undefined); - alertInhibitFacade.create.mockClear().mockResolvedValue(undefined); - }); - - it('keeps alert center remounts on a short settled cache window while refresh and closure operations invalidate it', () => { - const source = readFileSync(resolve(process.cwd(), 'app/alert/alert-center-page.tsx'), 'utf8'); - - expect(source).toContain('ALERT_CENTER_SETTLED_CACHE_TTL_MS = 10_000'); - expect(source).toContain("['alert-center', alertListUrl, refreshNonce].join('|')"); - expect(source).toContain('const refreshQuery = useCallback('); - expect(source).toContain('setRefreshNonce(current => current + 1)'); - expect(source).toContain('onRefresh={refreshQuery}'); - expect(source).toContain('cacheSettledTtlMs={ALERT_CENTER_SETTLED_CACHE_TTL_MS}'); - }); - - it('loads the alert center through the shared query/controller contract', async () => { - const html = await renderAlertCenterPage( - buildAlertCenterRouteState({ search: 'checkout', status: 'acknowledged', severity: 'warning' }) - ); - - expect(html).toContain('data-alert-center-surface="otlp-cold-center-console"'); - expect(html).toContain('data-alert-center-style-baseline="hertzbeat-ui-matte"'); - expect(html).toContain('data-loading-copy="Loading alert center"'); - - await mockState.lastLoad?.(); - - expect(loadAlertCenterDataFromFacade).toHaveBeenCalledWith(expect.objectContaining({ - alerts: alertFacade, - entities: entityFacade - }), { - search: 'checkout', - status: 'acknowledged', - severity: 'warning', - pageIndex: 0, - pageSize: 8, - entityId: '', - entityName: '', - returnTo: '' - }); - }); - - it('keeps machine entity context in shared query state and drops display labels', async () => { - await renderAlertCenterPage( - buildAlertCenterRouteState({ - entityId: '42', - entityName: 'Checkout API', - returnTo: '/entities/42?returnLabel=Checkout', - returnLabel: 'Checkout' - }) - ); - - await mockState.lastLoad?.(); - - expect(loadAlertCenterDataFromFacade).toHaveBeenCalledWith(expect.objectContaining({ - alerts: alertFacade, - entities: entityFacade - }), { - search: '', - status: 'firing', - severity: '', - pageIndex: 0, - pageSize: 8, - entityId: '42', - entityName: 'Checkout API', - returnTo: '/entities/42' - }); - }); - - it('keeps topology edge context when opened from the topology alert-impact action', async () => { - const returnTo = - '/topology?viewMode=resource-dependency&sourceKind=database-middleware-connection&edgeId=svc-checkout--res-orders-db&environment=prod&timeRange=last-1h'; - await renderAlertCenterPage( - buildAlertCenterRouteState({ - source: 'topology', - viewMode: 'resource-dependency', - sourceKind: 'database-middleware-connection', - edgeId: 'svc-checkout--res-orders-db', - entityId: 'service:commerce/checkout', - entityName: 'checkout-api', - serviceName: 'checkout-api', - serviceNamespace: 'commerce', - environment: 'prod', - timeRange: 'last-1h', - returnTo: `${returnTo}&returnLabel=HertzBeat%20operations%20topology`, - returnLabel: 'HertzBeat operations topology' - }) - ); - - await mockState.lastLoad?.(); - - expect(loadAlertCenterDataFromFacade).toHaveBeenCalledWith(expect.objectContaining({ - alerts: alertFacade, - entities: entityFacade - }), { - search: 'checkout-api', - status: 'firing', - severity: '', - pageIndex: 0, - pageSize: 8, - entityId: 'service:commerce/checkout', - entityName: 'checkout-api', - serviceName: 'checkout-api', - serviceNamespace: 'commerce', - environment: 'prod', - timeRange: 'last-1h', - source: 'topology', - viewMode: 'resource-dependency', - sourceKind: 'database-middleware-connection', - edgeId: 'svc-checkout--res-orders-db', - returnTo - }); - }); - - it('keeps three-signal evidence context when opened from OTLP workbenches', async () => { - await renderAlertCenterPage( - buildAlertCenterRouteState({ - signal: 'logs', - search: 'checkout', - status: 'firing', - entityId: '42', - entityName: 'Checkout API', - serviceName: 'checkout', - serviceNamespace: 'payments', - environment: 'prod', - timeRange: 'last-1h', - source: 'otlp', - traceId: 'trace-123', - spanId: 'span-456', - collector: 'collector-a', - template: 'spring-boot', - returnTo: '/log/manage?traceId=trace-123&returnLabel=Logs' - }) - ); - - await mockState.lastLoad?.(); - - expect(loadAlertCenterDataFromFacade).toHaveBeenCalledWith(expect.objectContaining({ - alerts: alertFacade, - entities: entityFacade - }), { - search: 'checkout', - status: 'firing', - severity: '', - pageIndex: 0, - pageSize: 8, - entityId: '42', - entityName: 'Checkout API', - serviceName: 'checkout', - serviceNamespace: 'payments', - environment: 'prod', - timeRange: 'last-1h', - source: 'otlp', - signal: 'logs', - traceId: 'trace-123', - spanId: 'span-456', - collector: 'collector-a', - template: 'spring-boot', - returnTo: '/log/manage?traceId=trace-123' - }); - }); - - it('canonicalizes dirty alert entry urls without display labels after hydration', () => { - const source = readFileSync(resolve(process.cwd(), 'app/alert/alert-center-page.tsx'), 'utf8'); - - expect(source).toContain('if (alertCenterRouteState.shouldCleanUrl)'); - expect(source).toContain('router.replace(alertCenterRouteState.cleanUrl)'); - expect(source).toContain('buildAlertCenterRouteUrl(cleared)'); - expect(source).toContain('router.replace(buildAlertCenterRouteUrl(cleared))'); - expect(source).not.toContain("searchParams.has('returnLabel')"); - expect(source).not.toContain("searchParams.get('returnTo')?.includes('returnLabel')"); - }); - - it('wires evidence-closure direct operations to the alert mutation controller', () => { - const source = readFileSync(resolve(process.cwd(), 'app/alert/alert-center-page.tsx'), 'utf8'); - - expect(source).toContain('api.alerts'); - expect(source).toContain('applyAlertClosureOperationFromFacade'); - expect(source).not.toContain('apiMessagePut'); - expect(source).not.toContain('apiMessageDelete'); - expect(source).toContain('onClosureAction={(action, groupId) => void handleClosureAction(action, groupId, data.groupAlerts.totalElements)}'); - }); - - it('surfaces success and failure feedback after alert closure operations', () => { - const source = readFileSync(resolve(process.cwd(), 'app/alert/alert-center-page.tsx'), 'utf8'); - - expect(source).toContain('operationFeedback'); - expect(source).toContain('setOperationFeedback'); - expect(source).toContain('buildAlertClosureOperationFeedback(action, t)'); - expect(source).toContain('buildAlertClosureOperationFailureFeedback(action, t)'); - expect(source).toContain('operationFeedback={operationFeedback}'); - }); - - it('subscribes the alert center list to Angular ALERT_EVENT SSE refreshes', async () => { - const html = await renderAlertCenterPage(); - const source = readFileSync(resolve(process.cwd(), 'app/alert/alert-center-page.tsx'), 'utf8'); - - expect(html).toContain('data-realtime-event-count="0"'); - expect(source).toContain('HEADER_ALERT_SSE_URL'); - expect(source).toContain('HEADER_ALERT_EVENT_TYPE'); - expect(source).toContain('parseHeaderSseJson(event.data)'); - expect(source).toContain('new window.EventSource(HEADER_ALERT_SSE_URL)'); - expect(source).toContain('eventSource.addEventListener(HEADER_ALERT_EVENT_TYPE, handleAlertEvent)'); - expect(source).toContain('resolveRealtimeGroupId(alert)'); - expect(source).toContain('setRealtimeEventCount(current => current + 1)'); - expect(source).toContain('setRealtimeGroupIds(current => [realtimeGroupId, ...current.filter(groupId => groupId !== realtimeGroupId)].slice(0, 8))'); - expect(source).toContain('realtimeEventCount={realtimeEventCount}'); - expect(source).toContain('realtimeGroupIds={realtimeGroupIds}'); - expect(source).toContain('eventSource.onerror = () =>'); - }); - - it('normalizes realtime alert group ids for the Angular new-alert highlight contract', async () => { - const { resolveRealtimeGroupId } = await import('./alert-center-page'); - - expect(resolveRealtimeGroupId({ id: 42 })).toBe(42); - expect(resolveRealtimeGroupId({ id: 0, groupKey: '43' })).toBe(43); - expect(resolveRealtimeGroupId({ id: 0, groupKey: 'service:checkout' })).toBeNull(); - expect(resolveRealtimeGroupId(null)).toBeNull(); - }); - - it('refreshes alert evidence with the post-closure query instead of dropping signal context', () => { - const source = readFileSync(resolve(process.cwd(), 'app/alert/alert-center-page.tsx'), 'utf8'); - - expect(source).toContain('buildAlertQueryAfterClosureOperation'); - expect(source).toContain('clampAlertCenterPageIndexAfterDelete'); - expect(source).toContain('buildAlertClosureOperationFeedback'); - expect(source).toContain('buildAlertClosureOperationFeedback(action, t)'); - expect(source).toContain("return action === 'close' || action === 'delete'"); - expect(source).toContain('clampAlertCenterPageIndexAfterDelete(nextQuery, totalElements, affectedCount)'); - expect(source).toContain('const nextRouteQuery = buildPostActionQuery(query)'); - expect(source).toContain('setDraft(current => buildPostActionQuery(current))'); - expect(source).toContain('setQuery(current => buildPostActionQuery(current))'); - expect(source).toContain('router.replace(buildAlertCenterRouteUrl(nextRouteQuery))'); - expect(source).toContain('handleClosureAction(action, groupId, data.groupAlerts.totalElements)'); - }); - - it('keeps alert center pagination and page-size changes reflected in the route url', async () => { - await renderAlertCenterPage( - buildAlertCenterRouteState({ - search: 'checkout', - status: 'firing', - severity: 'critical', - pageIndex: 0, - pageSize: 25, - source: 'alert-center-route-1513' - }) - ); - - mockState.lastSurfaceProps?.onPageIndexChange(1); - - expect(mockState.replace).toHaveBeenLastCalledWith( - '/alert?search=checkout&status=firing&severity=critical&pageIndex=1&pageSize=25&source=alert-center-route-1513' - ); - - mockState.lastSurfaceProps?.onPageSizeChange(15); - - expect(mockState.replace).toHaveBeenLastCalledWith( - '/alert?search=checkout&status=firing&severity=critical&pageIndex=0&pageSize=15&source=alert-center-route-1513' - ); - }); - - it('wires alert-center quick rule dialogs to create APIs and refreshes the workbench', async () => { - const html = await renderAlertCenterPage( - buildAlertCenterRouteState({ entityId: '42', entityName: 'Checkout API', returnTo: '/entities/42' }) - ); - const onRuleQuickCreate = mockState.lastSurfaceProps?.onRuleQuickCreate; - - expect(html).toContain('data-rule-create="function"'); - expect(onRuleQuickCreate).toEqual(expect.any(Function)); - - await onRuleQuickCreate('silence', { - name: 'Checkout API silence', - enable: true, - matchAll: false, - type: '0', - labelsText: 'service:checkout', - daysText: '1,2,3', - periodStart: '2026-05-25T10:00', - periodEnd: '2026-05-25T16:00' - }); - - expect(alertSilenceFacade.create).toHaveBeenCalledWith(expect.objectContaining({ - name: 'Checkout API silence', - enable: true, - matchAll: false, - type: 0, - labels: { service: 'checkout' } - })); - - await onRuleQuickCreate('inhibit', { - name: 'Checkout API inhibit', - enable: true, - sourceLabelsText: 'service:checkout', - targetLabelsText: 'service:checkout', - equalLabelsText: 'service' - }); - - expect(alertInhibitFacade.create).toHaveBeenCalledWith(expect.objectContaining({ - name: 'Checkout API inhibit', - enable: true, - sourceLabels: { service: 'checkout' }, - targetLabels: { service: 'checkout' }, - equalLabels: ['service'] - })); - - const source = readFileSync(resolve(process.cwd(), 'app/alert/alert-center-page.tsx'), 'utf8'); - expect(source).toContain('createAlertSilenceFromFacade(api.alertSilences.create'); - expect(source).toContain('createAlertInhibitFromFacade(api.alertInhibits.create'); - expect(source).not.toContain('apiMessagePost'); - expect(source).toContain('onRuleQuickCreate={handleRuleQuickCreate}'); - expect(source).toContain("setOperationFeedback({ tone: 'success', copy: t('common.notify.new-success') })"); - expect(source).toContain("copy: t('common.notify.new-fail')"); - expect(source).not.toContain("setOperationFeedback({ tone: 'success', copy: t('common.save-success') })"); - expect(source).toContain('setSelectedGroupIds([])'); - expect(source).toContain('setRefreshNonce(current => current + 1)'); - }); - - it('remembers entity response result state for return links after alert operations', () => { - const source = readFileSync(resolve(process.cwd(), 'app/alert/alert-center-page.tsx'), 'utf8'); - - expect(source).toContain('normalizeEntityResponseAction'); - expect(source).toContain('useState(null)'); - expect(source).toContain('setEntityResponseResult({ action: normalizeEntityResponseAction(action), count: affectedCount })'); - expect(source).toContain('setEntityResponseResult({ action: mode, count })'); - expect(source).toContain('entityResponseResult={entityResponseResult}'); - expect(source).toContain('setEntityResponseResult(null)'); - }); -}); diff --git a/web-next/app/alert/page.tsx b/web-next/app/alert/page.tsx deleted file mode 100644 index 22319fc6f0..0000000000 --- a/web-next/app/alert/page.tsx +++ /dev/null @@ -1,14 +0,0 @@ -import React from 'react'; - -import AlertCenterPage from './alert-center-page'; -import { readAlertCenterRouteState, type AlertCenterSearchParams } from '../../lib/alert-manage/query-state'; - -export default async function AlertCenterRoutePage({ - searchParams -}: { - searchParams?: Promise; -}) { - const resolvedSearchParams = await searchParams; - const routeState = readAlertCenterRouteState(resolvedSearchParams); - return ; -} diff --git a/web-next/app/alert/setting/alert-setting-page.tsx b/web-next/app/alert/setting/alert-setting-page.tsx deleted file mode 100644 index 485f5721b0..0000000000 --- a/web-next/app/alert/setting/alert-setting-page.tsx +++ /dev/null @@ -1,832 +0,0 @@ -'use client'; - -import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { useRouter, useSearchParams } from 'next/navigation'; -import { - HzExportTypeDialog, - HzFileInput, - type HzExportTypeDialogType, - type HzStatusTone -} from '@hertzbeat/ui'; -import { ClientWorkbench } from '../../../components/workbench/client-workbench'; -import { AlertSettingSurface } from '../../../components/pages/alert-setting-surface'; -import { - AlertSettingCreateDialog, - buildAlertSettingDraftFromDefine, - createDefaultAlertSettingDraft, - type AlertSettingCreateDraft, - type AlertSettingCreateKind, - type AlertSettingCreateMode, - type AlertSettingCreatePayload, - type AlertSettingCreatePreviewFeedback -} from '../../../components/pages/alert-setting-create-dialog'; -import { HzConfirmDialog } from '../../../components/ui/hz-confirm-dialog'; -import { useI18n } from '../../../components/providers/i18n-provider'; -import { getCurrentLocale } from '../../../lib/api-client'; -import { api, type AlertDefinePreviewRow } from '../../../lib/alert-api-facade'; -import { - buildAlertDefineExportUrl, - buildAlertDefineImportUrl, - deleteAlertDefineFromFacade, - deleteAlertDefinesFromFacade, - createAlertDefineFromFacade, - loadAlertDefineDetailFromFacade, - loadAlertSettingDataFromFacade, - updateAlertDefineEnabledFromFacade, - updateAlertDefineFromFacade -} from '../../../lib/alert-setting/controller'; -import { buildAlertSettingEvidenceContext } from '../../../lib/alert-setting/view-model'; -import { - buildAlertSettingAppEntries, - buildDefineListUrl, - type AlertSettingRouteState -} from '../../../lib/alert-setting/query-state'; -import { formatTime } from '../../../lib/format'; -import type { SignalRouteContext } from '../../../lib/signal-route-context'; - -type SettingDeleteRequest = { - kind: 'single' | 'batch'; - ids: number[]; -}; - -type AlertSettingActionFeedback = { - tone: HzStatusTone; - title: string; - description?: string; - contract?: - | 'delete' - | 'enable' - | 'export-fail' - | 'no-select-delete' - | 'no-select-export' - | 'import-success' - | 'import-fail' - | 'delete-success' - | 'enable-success' - | 'save-success'; - deletedCount?: number; - toggledRule?: { - id: number; - name: string; - enabled: boolean; - }; - savedRule?: { - name: string; - type: string; - expr: string; - enabled: boolean; - intent: 'create' | 'edit'; - }; -}; - -type AlertSettingSaveFeedback = { - tone: HzStatusTone; - title: string; - description?: string; - contract: 'create' | 'edit'; -}; - -const ALERT_SETTING_SETTLED_CACHE_TTL_MS = 10_000; -export const ALERT_SETTING_PREVIEW_SAMPLE_LIMIT = 3; -const EMPTY_ALERT_SETTING_ROUTE_STATE: AlertSettingRouteState = { - signal: null, - createIntent: null, - signalContext: {} -}; - -type AlertSettingPreviewTranslator = (key: string, values?: Record) => string; - -export function buildAlertSettingPreviewSuccessFeedback( - rows: AlertDefinePreviewRow[], - t: AlertSettingPreviewTranslator -): AlertSettingCreatePreviewFeedback { - const sampleRows = rows.slice(0, ALERT_SETTING_PREVIEW_SAMPLE_LIMIT); - if (rows.length === 0) { - return { - tone: 'warning', - title: t('alert.setting.preview.empty.title'), - description: t('alert.setting.preview.empty.description'), - rows: sampleRows, - totalRows: rows.length, - sampleLimit: ALERT_SETTING_PREVIEW_SAMPLE_LIMIT, - contract: 'empty' - }; - } - return { - tone: 'success', - title: t('alert.setting.preview.success.title', { count: rows.length }), - description: t('alert.setting.preview.success.description'), - rows: sampleRows, - totalRows: rows.length, - sampleLimit: ALERT_SETTING_PREVIEW_SAMPLE_LIMIT, - contract: 'success' - }; -} -const ALERT_SETTING_ROUTE_PATH = '/alert/setting'; - -type AlertSettingListRouteState = { - search: string; - pageIndex: number; - pageSize: number; -}; - -function parseAlertSettingRouteInteger(value: string | null, fallback: number, minimum = 0) { - if (!value) return fallback; - const parsed = Number.parseInt(value, 10); - return Number.isFinite(parsed) && parsed >= minimum ? parsed : fallback; -} - -export function buildAlertSettingCreateDraftSeed( - signal: string | null | undefined, - labelsText: string, - context: SignalRouteContext = {} -): Partial { - const seed: Partial = { labelsText }; - const name = context.alertName?.trim(); - const expression = context.alertExpression?.trim(); - const datasource = context.alertDatasource?.trim(); - const template = context.alertTemplate?.trim(); - if (name) seed.name = name; - if (expression) seed.expr = expression; - if (datasource) seed.datasource = datasource; - if (template) seed.template = template; - if (signal === 'logs') { - return { ...seed, kind: 'realtime', dataType: 'log' }; - } - if (signal === 'metrics') { - return { ...seed, kind: 'realtime', dataType: 'metric' }; - } - if (signal === 'traces') { - return { ...seed, kind: 'periodic', dataType: 'trace' }; - } - return seed; -} - -export function resolveAlertSettingInitialCreateMode( - signal: string | null | undefined, - createIntent: AlertSettingRouteState['createIntent'], - draftSeed: Partial -): AlertSettingCreateMode | 'closed' { - if (createIntent !== 'create') { - return 'closed'; - } - if ((signal === 'metrics' || signal === 'logs' || signal === 'traces') && draftSeed.expr?.trim()) { - return 'authoring'; - } - return 'type'; -} - -function resolveDownloadFilename(contentDisposition: string | null, fallbackName: string) { - const match = contentDisposition?.match(/filename\*?=(?:UTF-8'')?("?)([^";]+)\1/i); - if (!match?.[2]) return fallbackName; - try { - return decodeURIComponent(match[2]); - } catch { - return match[2]; - } -} - -const ALERT_DEFINE_IMPORT_FILE_ACCEPT = '.json,.yaml,.yml,.xlsx'; - -function isAlertDefineImportFile(file: File) { - const normalizedName = file.name.trim().toLowerCase(); - return ( - normalizedName.endsWith('.json') || - normalizedName.endsWith('.yaml') || - normalizedName.endsWith('.yml') || - normalizedName.endsWith('.xlsx') - ); -} - -export default function AlertSettingPage({ initialRouteState }: { initialRouteState?: AlertSettingRouteState } = {}) { - const { t } = useI18n(); - const router = useRouter(); - const searchParams = useSearchParams(); - const routeSearchParamString = searchParams.toString(); - const routeSearch = searchParams.get('search') ?? ''; - const routePageIndex = parseAlertSettingRouteInteger(searchParams.get('pageIndex'), 0); - const routePageSize = parseAlertSettingRouteInteger(searchParams.get('pageSize'), 8, 1); - const routeListState = useMemo(() => ({ - search: routeSearch, - pageIndex: routePageIndex, - pageSize: routePageSize - }), [routePageIndex, routePageSize, routeSearch]); - const alertSettingRouteState = initialRouteState ?? EMPTY_ALERT_SETTING_ROUTE_STATE; - const { signal, createIntent, signalContext } = alertSettingRouteState; - const evidenceContext = useMemo( - () => buildAlertSettingEvidenceContext(signal, signalContext, t), - [signal, signalContext, t] - ); - const initialCreateDraftSeed = useMemo( - () => buildAlertSettingCreateDraftSeed(signal, evidenceContext?.labelsText || '', signalContext), - [signal, evidenceContext, signalContext] - ); - const [search, setSearch] = useState(routeListState.search); - const [query, setQuery] = useState(routeListState.search); - const [pageIndex, setPageIndex] = useState(routeListState.pageIndex); - const [pageSize, setPageSize] = useState(routeListState.pageSize); - const [refreshKey, setRefreshKey] = useState(0); - const [checkedIds, setCheckedIds] = useState([]); - const [createMode, setCreateMode] = useState( - () => resolveAlertSettingInitialCreateMode(signal, createIntent, initialCreateDraftSeed) - ); - const [createDraft, setCreateDraft] = useState(() => createDefaultAlertSettingDraft(initialCreateDraftSeed.kind || 'realtime', initialCreateDraftSeed)); - const [creating, setCreating] = useState(false); - const [previewing, setPreviewing] = useState(false); - const [previewFeedback, setPreviewFeedback] = useState(null); - const [deleteRequest, setDeleteRequest] = useState(null); - const [deletePending, setDeletePending] = useState(false); - const [exportDialogOpen, setExportDialogOpen] = useState(false); - const [pendingExportType, setPendingExportType] = useState(null); - const [pendingActionId, setPendingActionId] = useState(null); - const [actionFeedback, setActionFeedback] = useState(null); - const [saveFeedback, setSaveFeedback] = useState(null); - const importInputRef = useRef(null); - const alertSettingListUrl = useMemo(() => buildDefineListUrl(query, pageIndex, pageSize), [query, pageIndex, pageSize]); - const alertSettingCacheKey = useMemo( - () => ['alert-setting', alertSettingListUrl, refreshKey].join('|'), - [alertSettingListUrl, refreshKey] - ); - - useEffect(() => { - setSearch(routeListState.search); - setQuery(routeListState.search); - setPageIndex(routeListState.pageIndex); - setPageSize(routeListState.pageSize); - setCheckedIds([]); - }, [routeListState]); - - const replaceRouteQuery = useCallback((nextState: AlertSettingListRouteState) => { - const nextParams = new URLSearchParams(routeSearchParamString); - const cleanSearch = nextState.search.trim(); - if (cleanSearch) { - nextParams.set('search', cleanSearch); - } else { - nextParams.delete('search'); - } - - if (nextState.pageIndex > 0) { - nextParams.set('pageIndex', String(nextState.pageIndex)); - } else { - nextParams.delete('pageIndex'); - } - - if (nextState.pageSize !== 8) { - nextParams.set('pageSize', String(nextState.pageSize)); - } else { - nextParams.delete('pageSize'); - } - - const nextParamString = nextParams.toString(); - const nextUrl = nextParamString ? `${ALERT_SETTING_ROUTE_PATH}?${nextParamString}` : ALERT_SETTING_ROUTE_PATH; - const currentUrl = routeSearchParamString ? `${ALERT_SETTING_ROUTE_PATH}?${routeSearchParamString}` : ALERT_SETTING_ROUTE_PATH; - if (nextUrl !== currentUrl) { - router.replace(nextUrl, { scroll: false }); - } - }, [routeSearchParamString, router]); - - const clearCreateIntentFromRoute = useCallback(() => { - if (!routeSearchParamString) return; - const nextParams = new URLSearchParams(routeSearchParamString); - if (nextParams.get('intent') !== 'create') return; - nextParams.delete('intent'); - const nextParamString = nextParams.toString(); - const nextUrl = nextParamString ? `${ALERT_SETTING_ROUTE_PATH}?${nextParamString}` : ALERT_SETTING_ROUTE_PATH; - const currentUrl = `${ALERT_SETTING_ROUTE_PATH}?${routeSearchParamString}`; - if (nextUrl !== currentUrl) { - router.replace(nextUrl, { scroll: false }); - } - }, [routeSearchParamString, router]); - - const load = useCallback(async () => { - void refreshKey; - const appMap = await api.alertSettings.appDefines(getCurrentLocale()).catch(() => null); - const appEntries = buildAlertSettingAppEntries(appMap); - return loadAlertSettingDataFromFacade( - { - list: api.alertSettings.list, - datasourceStatus: api.alertSettings.datasourceStatus - }, - query, - pageIndex, - pageSize, - appEntries - ); - }, [query, pageIndex, pageSize, refreshKey]); - - function openTypeSelection() { - setSaveFeedback(null); - setPreviewFeedback(null); - setActionFeedback(null); - const draftSeed = buildAlertSettingCreateDraftSeed(signal, evidenceContext?.labelsText || '', signalContext); - setCreateDraft(createDefaultAlertSettingDraft(draftSeed.kind || 'realtime', draftSeed)); - setCreateMode('type'); - } - - function openRealtimeAuthoring() { - setSaveFeedback(null); - setPreviewFeedback(null); - setActionFeedback(null); - const draftSeed = buildAlertSettingCreateDraftSeed(signal, evidenceContext?.labelsText || '', signalContext); - setCreateDraft(createDefaultAlertSettingDraft('realtime', { ...draftSeed, kind: 'realtime' })); - setCreateMode('authoring'); - } - - function closeCreateFlow() { - setCreateMode('closed'); - setCreating(false); - setSaveFeedback(null); - setPreviewing(false); - setPreviewFeedback(null); - clearCreateIntentFromRoute(); - } - - function selectCreateType(kind: AlertSettingCreateKind) { - setSaveFeedback(null); - setPreviewFeedback(null); - setActionFeedback(null); - setCreateDraft(current => createDefaultAlertSettingDraft(kind, current)); - setCreateMode('authoring'); - } - - function updateCreateDraft(nextDraft: AlertSettingCreateDraft) { - setPreviewFeedback(null); - setCreateDraft(nextDraft); - } - - async function previewCreate(payload: AlertSettingCreatePayload) { - setSaveFeedback(null); - const supportsPreview = payload.type.startsWith('periodic_') || payload.type === 'realtime_log'; - if (!supportsPreview) { - setPreviewFeedback({ - tone: 'warning', - title: t('alert.setting.preview.unsupported.title'), - description: t('alert.setting.preview.unsupported.description'), - contract: 'unsupported' - }); - return; - } - setPreviewing(true); - setPreviewFeedback(null); - try { - const rows = await api.alertSettings.preview(payload.datasource, payload.type, payload.expr); - setPreviewFeedback(buildAlertSettingPreviewSuccessFeedback(rows, t)); - } catch (error) { - setPreviewFeedback({ - tone: 'critical', - title: t('alert.setting.preview.failed.title'), - description: error instanceof Error ? error.message : undefined, - contract: 'failed' - }); - } finally { - setPreviewing(false); - } - } - - async function submitCreate(payload: AlertSettingCreatePayload) { - const isEdit = typeof payload.id === 'number'; - setCreating(true); - setSaveFeedback(null); - try { - if (isEdit) { - await updateAlertDefineFromFacade(api.alertSettings.update, payload); - } else { - await createAlertDefineFromFacade(api.alertSettings.create, payload); - } - setActionFeedback({ - tone: 'success', - title: t('alert.setting.save.success.title', { name: payload.name }), - description: t(payload.enable ? 'alert.setting.save.success.enabled' : 'alert.setting.save.success.disabled'), - contract: 'save-success', - savedRule: { - name: payload.name, - type: payload.type, - expr: payload.expr, - enabled: payload.enable, - intent: isEdit ? 'edit' : 'create' - } - }); - closeCreateFlow(); - setCheckedIds([]); - setRefreshKey(value => value + 1); - } catch (error) { - setSaveFeedback({ - tone: 'critical', - title: t(isEdit ? 'common.notify.edit-fail' : 'common.notify.new-fail'), - description: error instanceof Error ? error.message : undefined, - contract: isEdit ? 'edit' : 'create' - }); - } finally { - setCreating(false); - } - } - - function requestSingleDelete(defineId: number) { - setDeleteRequest({ kind: 'single', ids: [defineId] }); - } - - function requestBatchDelete() { - if (checkedIds.length === 0) { - setActionFeedback({ - tone: 'warning', - title: t('alert.setting.notify.no-select-delete'), - contract: 'no-select-delete' - }); - return; - } - setActionFeedback(null); - setDeleteRequest({ kind: 'batch', ids: checkedIds }); - } - - async function confirmDelete() { - const request = deleteRequest; - if (!request || request.ids.length === 0) return; - setDeletePending(true); - setActionFeedback(null); - try { - if (request.kind === 'batch') { - await deleteAlertDefinesFromFacade(api.alertSettings.delete, request.ids); - } else { - await deleteAlertDefineFromFacade(api.alertSettings.delete, request.ids[0]); - } - const deletedCount = request.ids.length; - setCheckedIds([]); - setDeleteRequest(null); - setRefreshKey(value => value + 1); - setActionFeedback({ - tone: 'success', - title: t('alert.setting.delete.success.title', { count: deletedCount }), - description: t('alert.setting.delete.success.description'), - contract: 'delete-success', - deletedCount - }); - } catch (error) { - setActionFeedback({ - tone: 'critical', - title: t('common.notify.delete-fail'), - description: error instanceof Error ? error.message : undefined, - contract: 'delete' - }); - } finally { - setDeletePending(false); - } - } - - async function downloadAlertDefineExport(type: HzExportTypeDialogType) { - const locale = getCurrentLocale(); - const response = await fetch(`/api${buildAlertDefineExportUrl(checkedIds, type)}`, { - headers: { - ...(locale ? { 'Accept-Language': locale } : {}) - }, - credentials: 'same-origin', - cache: 'no-store' - }); - if (!response.ok) { - throw new Error(t('common.notify.export-fail-status', { status: response.status })); - } - const contentType = response.headers.get('Content-Type') || ''; - if (contentType.includes('application/json')) { - throw new Error(''); - } - const blob = await response.blob(); - const url = window.URL.createObjectURL(blob); - const anchor = document.createElement('a'); - anchor.href = url; - anchor.download = resolveDownloadFilename(response.headers.get('Content-Disposition'), type === 'JSON' ? 'alert-defines.json' : 'alert-defines.xlsx'); - anchor.click(); - window.URL.revokeObjectURL(url); - } - - async function uploadAlertDefineImport(file: File) { - const locale = getCurrentLocale(); - const formData = new FormData(); - formData.append('file', file); - const response = await fetch(`/api${buildAlertDefineImportUrl()}`, { - method: 'POST', - headers: { - ...(locale ? { 'Accept-Language': locale } : {}) - }, - credentials: 'same-origin', - body: formData - }); - if (!response.ok) { - throw new Error(t('common.notify.import-fail-status', { status: response.status })); - } - const payload = (await response.json()) as { code: number; msg?: string }; - if (payload.code !== 0) { - throw new Error(payload.msg || t('common.notify.import-fail')); - } - } - - async function runSettingAction( - actionId: string, - pendingTitle: string, - task: () => Promise, - fallbackTitle: string, - contracts: { success?: AlertSettingActionFeedback['contract']; failure?: AlertSettingActionFeedback['contract'] } = {} - ) { - setPendingActionId(actionId); - setActionFeedback({ tone: 'info', title: pendingTitle }); - try { - const successTitle = await task(); - setActionFeedback({ tone: 'success', title: successTitle, contract: contracts.success }); - return true; - } catch (error) { - setActionFeedback({ - tone: 'critical', - title: fallbackTitle, - description: error instanceof Error ? error.message : t('common.failed'), - contract: contracts.failure - }); - return false; - } finally { - setPendingActionId(current => (current === actionId ? null : current)); - } - } - - function openExportDialog() { - if (checkedIds.length === 0) { - setActionFeedback({ - tone: 'warning', - title: t('alert.setting.notify.no-select-export'), - contract: 'no-select-export' - }); - return; - } - setActionFeedback(null); - setExportDialogOpen(true); - } - - async function handleExportDialogSelect(type: HzExportTypeDialogType) { - if (checkedIds.length === 0) { - setActionFeedback({ - tone: 'warning', - title: t('alert.setting.notify.no-select-export'), - contract: 'no-select-export' - }); - setExportDialogOpen(false); - return; - } - setPendingActionId('export'); - setPendingExportType(type); - setActionFeedback(null); - try { - await downloadAlertDefineExport(type); - setExportDialogOpen(false); - } catch (error) { - setActionFeedback({ - tone: 'critical', - title: t('common.notify.export-fail'), - description: error instanceof Error && error.message ? error.message : undefined, - contract: 'export-fail' - }); - } finally { - setPendingActionId(current => (current === 'export' ? null : current)); - setPendingExportType(null); - } - } - - function handleImportClick() { - if (pendingActionId) return; - importInputRef.current?.click(); - } - - async function handleImportChange(event: React.ChangeEvent) { - const file = event.target.files?.[0]; - event.target.value = ''; - if (!file) return; - if (pendingActionId) return; - if (!isAlertDefineImportFile(file)) { - setActionFeedback({ - tone: 'warning', - title: t('common.notify.import-fail'), - description: t('common.notify.import-invalid-file'), - contract: 'import-fail' - }); - return; - } - await runSettingAction( - 'import', - t('common.notify.import-submitted', { taskName: file.name }), - async () => { - await uploadAlertDefineImport(file); - setCheckedIds([]); - setRefreshKey(value => value + 1); - return t('common.notify.import-success'); - }, - t('common.notify.import-fail'), - { success: 'import-success', failure: 'import-fail' } - ); - } - - return ( - - {data => { - async function handleToggleEnabled(defineId: number, enabled: boolean) { - const target = data.list.content.find(item => item.id === defineId); - if (!target) return; - setActionFeedback(null); - try { - await updateAlertDefineEnabledFromFacade(api.alertSettings.update, target, enabled); - const targetName = target.name || String(defineId); - setActionFeedback({ - tone: 'success', - title: t('alert.setting.enable.success.title', { name: targetName }), - description: t(enabled ? 'alert.setting.enable.success.enabled' : 'alert.setting.enable.success.disabled'), - contract: 'enable-success', - toggledRule: { - id: defineId, - name: targetName, - enabled - } - }); - setRefreshKey(value => value + 1); - } catch (error) { - setActionFeedback({ - tone: 'critical', - title: t('common.notify.edit-fail'), - description: error instanceof Error ? error.message : undefined, - contract: 'enable' - }); - } - } - - async function handleEdit(defineId: number) { - const define = await loadAlertDefineDetailFromFacade(api.alertSettings.detail, defineId); - setSaveFeedback(null); - setPreviewFeedback(null); - setCreateDraft(buildAlertSettingDraftFromDefine(define)); - setCreateMode('authoring'); - } - - const deleteTargetIds = deleteRequest?.ids ?? []; - const deleteTargetNames = deleteTargetIds - .map(id => { - const target = data.list.content.find(item => item.id === id); - return target ? target.name || String(id) : undefined; - }) - .filter((name): name is string => Boolean(name)) - .slice(0, 5); - const hiddenDeleteTargetCount = Math.max(deleteTargetIds.length - deleteTargetNames.length, 0); - const deleteBaseCopy = deleteRequest?.kind === 'batch' - ? t('alert.setting.delete.confirm.batch', { count: deleteTargetIds.length }) - : t('alert.setting.delete.confirm.single'); - const deleteConfirmCopy = [ - deleteBaseCopy, - deleteTargetNames.length > 0 - ? t('alert.setting.delete.confirm.targets', { names: deleteTargetNames.join(', ') }) - : null, - hiddenDeleteTargetCount > 0 - ? t('alert.setting.delete.confirm.targets-more', { count: hiddenDeleteTargetCount }) - : null - ].filter(Boolean).join(' '); - - return ( - <> - { - const nextSearch = search.trim(); - const nextState = { search: nextSearch, pageIndex: 0, pageSize }; - setSearch(nextSearch); - setQuery(nextSearch); - setPageIndex(nextState.pageIndex); - setCheckedIds([]); - replaceRouteQuery(nextState); - }} - onClearFilter={() => { - const nextState = { search: '', pageIndex: 0, pageSize }; - setSearch(''); - setQuery(''); - setPageIndex(nextState.pageIndex); - setCheckedIds([]); - replaceRouteQuery(nextState); - }} - onRefresh={() => { - setRefreshKey(value => value + 1); - setCheckedIds([]); - }} - onNew={openTypeSelection} - onNewRealtime={openRealtimeAuthoring} - onDeleteSelected={requestBatchDelete} - onExport={openExportDialog} - onImport={handleImportClick} - onToggleEnabled={(defineId, enabled) => void handleToggleEnabled(defineId, enabled)} - onEdit={defineId => void handleEdit(defineId)} - onDelete={requestSingleDelete} - onCheckedIdsChange={setCheckedIds} - requestedPageSize={pageSize} - onPageIndexChange={nextPageIndex => { - setPageIndex(nextPageIndex); - setCheckedIds([]); - replaceRouteQuery({ search: query, pageIndex: nextPageIndex, pageSize }); - }} - onPageSizeChange={nextPageSize => { - const nextState = { search: query, pageIndex: 0, pageSize: nextPageSize }; - setPageIndex(0); - setPageSize(nextPageSize); - setCheckedIds([]); - replaceRouteQuery(nextState); - }} - pendingActionId={pendingActionId} - actionFeedback={actionFeedback} - /> - void handleImportChange(event)} - /> - { - setPreviewFeedback(null); - setCreateMode('type'); - }} - onSubmit={submitCreate} - onPreview={previewCreate} - /> -
- setDeleteRequest(null)} - onConfirm={() => void confirmDelete()} - /> -
- setExportDialogOpen(false)} - onSelect={type => void handleExportDialogSelect(type)} - jsonBusy={pendingExportType === 'JSON'} - excelBusy={pendingExportType === 'EXCEL'} - jsonDescription={t('alert.export.use-type', { type: 'JSON' })} - excelDescription={t('alert.export.use-type', { type: 'EXCEL' })} - data-alert-setting-export-type-dialog-owner="hertzbeat-ui-export-type-dialog" - data-alert-setting-export-type-dialog={exportDialogOpen ? 'open' : 'closed'} - data-alert-setting-export-success-contract="angular-download-closes-dialog-no-toast" - data-alert-setting-export-success-owner="route-action-feedback-contract" - data-alert-setting-export-loading-contract="angular-selected-type-only" - data-alert-setting-export-loading-owner="route-action-feedback-contract" - jsonButtonProps={ - { - 'data-alert-setting-export-type-option-owner': 'hertzbeat-ui-export-type-dialog', - 'data-alert-setting-export-type-option': 'json', - 'data-alert-setting-export-loading': 'json-selected-only' - } as React.ComponentProps['jsonButtonProps'] - } - excelButtonProps={ - { - 'data-alert-setting-export-type-option-owner': 'hertzbeat-ui-export-type-dialog', - 'data-alert-setting-export-type-option': 'excel', - 'data-alert-setting-export-loading': 'excel-selected-only' - } as React.ComponentProps['excelButtonProps'] - } - /> - - ); - }} -
- ); -} diff --git a/web-next/app/alert/setting/page.test.tsx b/web-next/app/alert/setting/page.test.tsx deleted file mode 100644 index f9cea545ea..0000000000 --- a/web-next/app/alert/setting/page.test.tsx +++ /dev/null @@ -1,1023 +0,0 @@ -// @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 { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { createTranslatorMock } from '../../../test/i18n-test-helper'; -import type { AlertSettingRouteState } from '../../../lib/alert-setting/query-state'; - -const mockState = vi.hoisted(() => ({ - lastLoad: null as null | (() => Promise), - lastSurfaceProps: null as null | Record, - lastOnNew: null as null | (() => void), - lastOnNewRealtime: null as null | (() => void), - lastOnClose: null as null | (() => void), - lastOnSubmit: null as null | ((payload: unknown) => Promise), - lastOnPreview: null as null | ((payload: unknown) => Promise), - lastPreviewFeedback: null as null | Record, - lastOnToggleEnabled: null as null | ((defineId: number, enabled: boolean) => void), - lastOnEdit: null as null | ((defineId: number) => Promise | void), - lastOnExport: null as null | (() => void), - lastOnImport: null as null | (() => void), - currentSearchParams: '', - routerReplace: vi.fn(), - push: vi.fn(), - renderData: { - list: { - totalElements: 2, - pageIndex: 0, - pageSize: 8, - content: [ - { - id: 7, - name: 'cpu threshold', - type: 'realtime_metric', - datasource: 'promql', - expr: 'cpu_usage > 80', - template: 'OpsTemplate', - labels: { severity: 'warning', team: 'core' }, - enable: true, - gmtUpdate: 1713200000000 - } - ] - }, - datasourceStatus: { - code: 0, - data: { promql: true } - } - } -})); - -const apiGet = vi.hoisted(() => vi.fn()); -const apiMessageGet = vi.hoisted(() => vi.fn()); -const apiMessageDelete = vi.hoisted(() => vi.fn()); -const apiMessagePut = vi.hoisted(() => vi.fn()); -const apiMessagePost = vi.hoisted(() => vi.fn()); - -(globalThis as { React?: typeof React }).React = React; -(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; - -vi.mock('next/navigation', () => ({ - useRouter: () => ({ - replace: mockState.routerReplace - }), - useSearchParams: () => new URLSearchParams(mockState.currentSearchParams) -})); - -vi.mock('../../../components/providers/i18n-provider', () => ({ - useI18n: () => ({ - t: createTranslatorMock() - }) -})); - -vi.mock('../../../components/workbench/client-workbench', () => ({ - ClientWorkbench: ({ - children, - load, - loadingCopy - }: { - children: (data: any) => React.ReactNode; - load: () => Promise; - loadingCopy?: string; - }) => { - mockState.lastLoad = load; - return
{children(mockState.renderData)}
; - } -})); - -vi.mock('../../../components/pages/alert-setting-surface', () => ({ - AlertSettingSurface: (props: any) => { - const { - data, - search, - evidenceContext, - onNew, - onNewRealtime, - onExport, - onImport, - onToggleEnabled, - onEdit - } = props; - mockState.lastSurfaceProps = props; - mockState.lastOnNew = onNew; - mockState.lastOnNewRealtime = onNewRealtime; - mockState.lastOnExport = onExport; - mockState.lastOnImport = onImport; - mockState.lastOnToggleEnabled = onToggleEnabled; - mockState.lastOnEdit = onEdit; - return ( -
- ); - } -})); - -vi.mock('../../../components/pages/alert-setting-create-dialog', () => ({ - AlertSettingCreateDialog: ({ open, mode, draft, evidenceReturnHref, previewFeedback, previewing, saveFeedback, onClose, onSubmit, onPreview }: any) => { - mockState.lastOnClose = onClose; - mockState.lastOnSubmit = onSubmit; - mockState.lastOnPreview = onPreview; - mockState.lastPreviewFeedback = previewFeedback; - return ( -
- ); - }, - createDefaultAlertSettingDraft: (kind = 'realtime', previous: any = {}) => ({ - id: previous.id, - name: previous.name || '', - kind, - dataType: previous.dataType || 'metric', - datasource: previous.datasource || 'promql', - expr: previous.expr || '', - template: previous.template || '', - labelsText: previous.labelsText || '', - enable: previous.enable ?? true, - period: previous.period || '300', - times: previous.times || '3', - priority: previous.priority || '2' - }), - buildAlertSettingDraftFromDefine: (define: any) => ({ - id: define.id, - name: define.name || '', - kind: define.type?.startsWith('periodic_') ? 'periodic' : 'realtime', - dataType: define.type?.endsWith('_trace') ? 'trace' : define.type?.endsWith('_log') ? 'log' : 'metric', - datasource: define.datasource || 'promql', - expr: define.expr || '', - template: define.template || '', - labelsText: Object.entries(define.labels || {}).map(([key, value]) => `${key}:${value}`).join(', '), - enable: define.enable ?? true, - period: String(define.period || 300), - times: String(define.times || 3), - priority: String(define.priority ?? 2) - }), - buildAlertSettingCreatePayload: (draft: any) => draft -})); - -vi.mock('../../../lib/api-client', () => ({ - apiGet, - apiMessageGet, - apiMessageDelete, - apiMessagePut, - apiMessagePost, - getCurrentLocale: () => null -})); - -async function renderAlertSettingPage(initialRouteState?: AlertSettingRouteState) { - const { default: AlertSettingPage } = await import('./alert-setting-page'); - return renderToStaticMarkup(); -} - -describe('alert setting page', () => { - let interactionContainer: HTMLDivElement | null = null; - let interactionRoot: Root | null = null; - - afterEach(() => { - if (interactionRoot) { - act(() => { - interactionRoot?.unmount(); - }); - } - interactionRoot = null; - interactionContainer?.remove(); - interactionContainer = null; - }); - - beforeEach(() => { - mockState.lastLoad = null; - mockState.lastSurfaceProps = null; - mockState.lastOnNew = null; - mockState.lastOnNewRealtime = null; - mockState.lastOnSubmit = null; - mockState.lastOnPreview = null; - mockState.lastPreviewFeedback = null; - mockState.lastOnToggleEnabled = null; - mockState.lastOnEdit = null; - mockState.lastOnExport = null; - mockState.lastOnImport = null; - mockState.currentSearchParams = ''; - mockState.routerReplace.mockReset(); - mockState.push.mockReset(); - apiGet.mockReset().mockResolvedValue(mockState.renderData.datasourceStatus); - apiMessageGet.mockReset().mockResolvedValue(mockState.renderData.list); - apiMessageDelete.mockReset().mockResolvedValue(undefined); - apiMessagePut.mockReset().mockResolvedValue(undefined); - apiMessagePost.mockReset().mockResolvedValue(undefined); - }); - - it('loads the alert-define console through the shared route and surface contracts', async () => { - const source = readFileSync(resolve(process.cwd(), 'app/alert/setting/alert-setting-page.tsx'), 'utf8'); - const html = await renderAlertSettingPage(); - - expect(html).toContain('data-alert-setting-surface="true"'); - expect(html).toContain('data-total="2"'); - expect(html).toContain('data-search=""'); - expect(html).toContain('data-loading-copy="Loading alert settings"'); - - await mockState.lastLoad?.(); - - expect(apiMessageGet).toHaveBeenCalledWith('/apps/defines?lang=en_US'); - expect(apiMessageGet).toHaveBeenCalledWith('/alert/defines?pageIndex=0&pageSize=8&sort=id&order=desc'); - expect(apiGet).toHaveBeenCalledWith('/alert/define/datasource/status'); - expect(source).toContain('loadAlertSettingDataFromFacade'); - expect(source).toContain('buildAlertSettingAppEntries'); - expect(source).toContain('api.alertSettings.appDefines'); - expect(source).toContain('api.alertSettings.list'); - expect(source).toContain('api.alertSettings.datasourceStatus'); - expect(source).not.toContain('loadAlertSettingData(apiGet, apiMessageGet'); - }, 30_000); - - it('maps alert setting search through Angular app-define translations before list reads', () => { - const source = readFileSync(resolve(process.cwd(), 'app/alert/setting/alert-setting-page.tsx'), 'utf8'); - - expect(source).toContain('const appMap = await api.alertSettings.appDefines(getCurrentLocale()).catch(() => null)'); - expect(source).toContain('const appEntries = buildAlertSettingAppEntries(appMap)'); - expect(source).toContain('pageSize,'); - expect(source).toContain('appEntries'); - }); - - it('keeps alert setting list pagination on the Angular server-side page index and page size contract', () => { - const source = readFileSync(resolve(process.cwd(), 'app/alert/setting/alert-setting-page.tsx'), 'utf8'); - - expect(source).toContain('const [pageIndex, setPageIndex] = useState(routeListState.pageIndex)'); - expect(source).toContain('const [pageSize, setPageSize] = useState(routeListState.pageSize)'); - expect(source).toContain('buildDefineListUrl(query, pageIndex, pageSize)'); - expect(source).toContain('setPageIndex(0);'); - expect(source).toContain('onPageIndexChange={nextPageIndex =>'); - expect(source).toContain('onPageSizeChange={nextPageSize =>'); - expect(source).toContain('setPageSize(nextPageSize)'); - }); - - it('initializes alert setting list state from the route and preserves URL state during search and pagination', async () => { - mockState.currentSearchParams = 'search=cpu&pageIndex=2&pageSize=15&signal=metrics&intent=create'; - const { default: AlertSettingPage } = await import('./alert-setting-page'); - interactionContainer = document.createElement('div'); - document.body.appendChild(interactionContainer); - interactionRoot = createRoot(interactionContainer); - - await act(async () => { - interactionRoot?.render(); - await Promise.resolve(); - }); - - expect(mockState.lastSurfaceProps?.search).toBe('cpu'); - expect(mockState.lastSurfaceProps?.requestedPageSize).toBe(15); - await act(async () => { - await mockState.lastLoad?.(); - }); - expect(apiMessageGet).toHaveBeenLastCalledWith('/alert/defines?pageIndex=2&pageSize=15&sort=id&order=desc&search=%5B%22cpu%22%5D'); - - await act(async () => { - mockState.lastSurfaceProps?.onSearchChange('memory'); - await Promise.resolve(); - }); - await act(async () => { - mockState.lastSurfaceProps?.onApplyFilter(); - await Promise.resolve(); - }); - - expect(mockState.routerReplace).toHaveBeenLastCalledWith('/alert/setting?search=memory&pageSize=15&signal=metrics&intent=create', { scroll: false }); - - mockState.currentSearchParams = 'search=memory&pageSize=15&signal=metrics&intent=create'; - await act(async () => { - interactionRoot?.render(); - await Promise.resolve(); - }); - await act(async () => { - mockState.lastSurfaceProps?.onPageIndexChange(3); - await Promise.resolve(); - }); - - expect(mockState.routerReplace).toHaveBeenLastCalledWith('/alert/setting?search=memory&pageSize=15&signal=metrics&intent=create&pageIndex=3', { scroll: false }); - - mockState.currentSearchParams = 'search=memory&pageSize=15&signal=metrics&intent=create'; - await act(async () => { - interactionRoot?.render(); - await Promise.resolve(); - }); - await act(async () => { - mockState.lastSurfaceProps?.onPageSizeChange(8); - await Promise.resolve(); - }); - - expect(mockState.routerReplace).toHaveBeenLastCalledWith('/alert/setting?search=memory&signal=metrics&intent=create', { scroll: false }); - - mockState.currentSearchParams = 'search=memory&pageSize=15&signal=metrics&intent=create'; - await act(async () => { - interactionRoot?.render(); - await Promise.resolve(); - }); - await act(async () => { - mockState.lastSurfaceProps?.onClearFilter(); - await Promise.resolve(); - }); - - expect(mockState.routerReplace).toHaveBeenLastCalledWith('/alert/setting?signal=metrics&intent=create', { scroll: false }); - }); - - it('opens the local threshold create flow instead of routing to monitor define', async () => { - const source = readFileSync(resolve(process.cwd(), 'app/alert/setting/alert-setting-page.tsx'), 'utf8'); - await renderAlertSettingPage(); - - expect(mockState.lastOnNew).toBeTypeOf('function'); - - mockState.lastOnNew?.(); - - expect(mockState.push).not.toHaveBeenCalled(); - expect(source).not.toContain("router.push('/setting/define')"); - expect(source).toContain('AlertSettingCreateDialog'); - expect(source).toContain('createAlertDefineFromFacade'); - expect(source).toContain('updateAlertDefineFromFacade'); - expect(source).toContain('api.alertSettings.create'); - expect(source).toContain('api.alertSettings.update'); - }); - - it('opens realtime authoring directly from the empty-state realtime create action', async () => { - const { default: AlertSettingPage } = await import('./alert-setting-page'); - interactionContainer = document.createElement('div'); - document.body.appendChild(interactionContainer); - interactionRoot = createRoot(interactionContainer); - - await act(async () => { - interactionRoot?.render(); - await Promise.resolve(); - }); - - expect(mockState.lastOnNewRealtime).toBeTypeOf('function'); - - await act(async () => { - mockState.lastOnNewRealtime?.(); - await Promise.resolve(); - }); - - expect(interactionContainer.querySelector('[data-alert-setting-create-dialog]')?.getAttribute('data-alert-setting-create-dialog')).toBe('authoring'); - }); - - it('clears the one-shot create intent when users cancel the threshold handoff flow', async () => { - mockState.currentSearchParams = 'signal=metrics&intent=create&serviceName=checkout&environment=prod&returnTo=%2Fingestion%2Fotlp'; - const { default: AlertSettingPage } = await import('./alert-setting-page'); - interactionContainer = document.createElement('div'); - document.body.appendChild(interactionContainer); - interactionRoot = createRoot(interactionContainer); - - await act(async () => { - interactionRoot?.render( - - ); - await Promise.resolve(); - }); - - expect(interactionContainer.querySelector('[data-alert-setting-create-dialog]')?.getAttribute('data-alert-setting-create-dialog')).not.toBe('closed'); - - await act(async () => { - mockState.lastOnClose?.(); - await Promise.resolve(); - }); - - expect(mockState.routerReplace).toHaveBeenLastCalledWith( - '/alert/setting?signal=metrics&serviceName=checkout&environment=prod&returnTo=%2Fingestion%2Fotlp', - { scroll: false } - ); - }); - - it('announces a save success contract after create or edit writes complete', () => { - const source = readFileSync(resolve(process.cwd(), 'app/alert/setting/alert-setting-page.tsx'), 'utf8'); - - expect(source).toContain("contract: 'save-success'"); - expect(source).toContain("title: t('alert.setting.save.success.title', { name: payload.name })"); - expect(source).toContain("payload.enable ? 'alert.setting.save.success.enabled' : 'alert.setting.save.success.disabled'"); - expect(source).toContain('savedRule: {'); - expect(source).toContain("intent: isEdit ? 'edit' : 'create'"); - }); - - it('keeps the create authoring draft open with backend detail when alert save fails', async () => { - apiMessagePost.mockRejectedValueOnce(new Error('backend refused alert expression')); - const { default: AlertSettingPage } = await import('./alert-setting-page'); - interactionContainer = document.createElement('div'); - document.body.appendChild(interactionContainer); - interactionRoot = createRoot(interactionContainer); - - await act(async () => { - interactionRoot?.render( - 10', - alertDatasource: 'promql', - alertTemplate: 'Checkout traffic is above threshold' - } - }} - /> - ); - await Promise.resolve(); - }); - - expect(interactionContainer.querySelector('[data-alert-setting-create-dialog]')?.getAttribute('data-alert-setting-create-dialog')).toBe('authoring'); - - await act(async () => { - await mockState.lastOnSubmit?.({ - name: 'checkout saturation', - type: 'realtime_metric', - datasource: 'promql', - expr: 'rate(http_server_requests_seconds_count[5m]) > 10', - template: 'Checkout traffic is above threshold', - labels: {}, - annotations: {}, - enable: true, - period: 300, - times: 3, - priority: 2 - }); - await Promise.resolve(); - }); - - expect(apiMessagePost).toHaveBeenCalledWith('/alert/define', expect.objectContaining({ - name: 'checkout saturation', - expr: 'rate(http_server_requests_seconds_count[5m]) > 10', - template: 'Checkout traffic is above threshold' - })); - expect(interactionContainer.querySelector('[data-alert-setting-create-dialog]')?.getAttribute('data-alert-setting-create-dialog')).toBe('authoring'); - expect(interactionContainer.querySelector('[data-alert-setting-create-name]')?.getAttribute('data-alert-setting-create-name')).toBe('checkout saturation'); - expect(interactionContainer.querySelector('[data-alert-setting-create-expr]')?.getAttribute('data-alert-setting-create-expr')).toBe('rate(http_server_requests_seconds_count[5m]) > 10'); - expect(interactionContainer.querySelector('[data-alert-setting-create-template]')?.getAttribute('data-alert-setting-create-template')).toBe('Checkout traffic is above threshold'); - expect(interactionContainer.querySelector('[data-save-feedback]')?.getAttribute('data-save-feedback')).toBe('create'); - expect(interactionContainer.querySelector('[data-save-feedback-title]')?.getAttribute('data-save-feedback-title')).toBe('Add Failed!'); - expect(interactionContainer.querySelector('[data-save-feedback-description]')?.getAttribute('data-save-feedback-description')).toBe('backend refused alert expression'); - expect(mockState.routerReplace).not.toHaveBeenCalledWith('/alert/setting?signal=metrics&serviceName=checkout&returnTo=%2Fingestion%2Fotlp%2Fmetrics%3Fquery%3Dup', { scroll: false }); - }); - - it('opens the threshold type choice when trace handoff carries create intent without a metrics expression', async () => { - const html = await renderAlertSettingPage({ - signal: 'traces', - createIntent: 'create', - signalContext: { - serviceName: 'checkout', - traceId: 'trace-123', - returnTo: '/trace/manage?traceId=trace-123' - } - }); - const source = readFileSync(resolve(process.cwd(), 'app/alert/setting/alert-setting-page.tsx'), 'utf8'); - - expect(html).toContain('data-alert-setting-create-dialog="type"'); - expect(html).toContain('data-evidence-return-href="/trace/manage?traceId=trace-123"'); - expect(source).toContain('resolveAlertSettingInitialCreateMode(signal, createIntent, initialCreateDraftSeed)'); - expect(source).toContain('buildAlertSettingCreateDraftSeed(signal'); - }); - - it('opens metrics alert authoring directly when explorer handoff carries a panel expression', async () => { - const html = await renderAlertSettingPage({ - signal: 'metrics', - createIntent: 'create', - signalContext: { - serviceName: 'checkout', - returnTo: '/ingestion/otlp/metrics?query=http.server.duration', - alertName: 'checkout latency', - alertExpression: 'rate(http.server.duration[5m]) > 0', - alertDatasource: 'promql', - alertTemplate: 'Latency is high' - } - }); - - expect(html).toContain('data-alert-setting-create-dialog="authoring"'); - expect(html).toContain('data-alert-setting-create-name="checkout latency"'); - expect(html).toContain('data-alert-setting-create-expr="rate(http.server.duration[5m]) > 0"'); - expect(html).toContain('data-alert-setting-create-template="Latency is high"'); - expect(html).toContain('data-evidence-return-href="/ingestion/otlp/metrics?query=http.server.duration"'); - }); - - it('opens log alert authoring directly when explorer handoff carries a safe realtime expression', async () => { - const html = await renderAlertSettingPage({ - signal: 'logs', - createIntent: 'create', - signalContext: { - serviceName: 'checkout', - returnTo: '/log/manage?severityText=ERROR', - alertName: 'checkout log alert', - alertExpression: "log.severityText == 'ERROR'", - alertTemplate: 'Log severity matched: {{log.body}}' - } - }); - - expect(html).toContain('data-alert-setting-create-dialog="authoring"'); - expect(html).toContain('data-alert-setting-create-name="checkout log alert"'); - expect(html).toContain("data-alert-setting-create-expr=\"log.severityText == 'ERROR'\""); - expect(html).toContain('data-alert-setting-create-template="Log severity matched: {{log.body}}"'); - expect(html).toContain('data-evidence-return-href="/log/manage?severityText=ERROR"'); - }); - - it('opens trace alert authoring directly when explorer handoff carries periodic SQL', async () => { - const sql = "SELECT service_name, operation, span_kind, SUM(error_total) / NULLIF(SUM(calls_total), 0) AS __value__ FROM hertzbeat_apm_red_1m WHERE service_name = 'checkout' AND time_window >= NOW() - INTERVAL '5 minutes' GROUP BY service_name, operation, span_kind HAVING __value__ > 0"; - const html = await renderAlertSettingPage({ - signal: 'traces', - createIntent: 'create', - signalContext: { - serviceName: 'checkout', - returnTo: '/trace/manage?serviceName=checkout&errorOnly=true', - alertName: 'checkout trace alert', - alertExpression: sql, - alertDatasource: 'sql', - alertTemplate: 'Trace error rate detected ${service_name} ${operation}: ${__value__}' - } - }); - - expect(html).toContain('data-alert-setting-create-dialog="authoring"'); - expect(html).toContain('data-alert-setting-create-name="checkout trace alert"'); - expect(html).toContain('FROM hertzbeat_apm_red_1m'); - expect(html).toContain('data-alert-setting-create-template="Trace error rate detected ${service_name} ${operation}: ${__value__}"'); - expect(html).toContain('data-evidence-return-href="/trace/manage?serviceName=checkout&errorOnly=true"'); - }); - - it('uses the shared cold delete confirmation instead of leaving threshold delete actions as no-ops', () => { - const source = readFileSync(resolve(process.cwd(), 'app/alert/setting/alert-setting-page.tsx'), 'utf8'); - - expect(source).toContain('HzConfirmDialog'); - expect(source).toContain('deleteAlertDefineFromFacade'); - expect(source).toContain('deleteAlertDefinesFromFacade'); - expect(source).toContain('api.alertSettings.delete'); - expect(source).not.toContain('apiMessageDelete'); - expect(source).toContain('data-alert-delete-confirm'); - expect(source).toContain("kicker={t('common.confirm.operation')}"); - expect(source).toContain("t('alert.setting.delete.confirm.targets'"); - expect(source).toContain("confirmLabel={t('alert.setting.delete.confirm.action')}"); - expect(source).not.toContain("confirmLabel={t('common.button.ok')}"); - expect(source).not.toContain('onDeleteSelected={() => {}}'); - expect(source).not.toContain('onDelete={() => {}}'); - expect(source).not.toContain('window.confirm'); - expect(source).not.toContain('confirm('); - expect(source).not.toContain('window.alert'); - expect(source).not.toContain('alert('); - }); - - it('names the selected threshold rule in the delete confirmation before destructive writes', async () => { - const { default: AlertSettingPage } = await import('./alert-setting-page'); - interactionContainer = document.createElement('div'); - document.body.appendChild(interactionContainer); - interactionRoot = createRoot(interactionContainer); - - await act(async () => { - interactionRoot?.render(); - await Promise.resolve(); - }); - - await act(async () => { - mockState.lastSurfaceProps?.onDelete(7); - await Promise.resolve(); - }); - - expect(interactionContainer.textContent).toContain('Delete the selected threshold rule. This cannot be undone.'); - expect(interactionContainer.textContent).toContain('Rules: cpu threshold.'); - expect(interactionContainer.textContent).toContain('Delete threshold rule'); - }); - - it('maps threshold delete failures to the Angular notify title plus backend detail', () => { - const source = readFileSync(resolve(process.cwd(), 'app/alert/setting/alert-setting-page.tsx'), 'utf8'); - - expect(source).toContain("title: t('common.notify.delete-fail')"); - expect(source).toContain('description: error instanceof Error ? error.message : undefined'); - expect(source).toContain("contract: 'delete'"); - expect(source).not.toContain("t('common.delete-failed')"); - }); - - it('maps successful threshold deletes to inline confirmation with the deleted rule count', () => { - const source = readFileSync(resolve(process.cwd(), 'app/alert/setting/alert-setting-page.tsx'), 'utf8'); - - expect(source).toContain('const deletedCount = request.ids.length'); - expect(source).toContain("title: t('alert.setting.delete.success.title', { count: deletedCount })"); - expect(source).toContain("description: t('alert.setting.delete.success.description')"); - expect(source).toContain("contract: 'delete-success'"); - expect(source).toContain('deletedCount'); - }); - - it('maps threshold save failures to the Angular create/edit notify title plus backend detail', () => { - const source = readFileSync(resolve(process.cwd(), 'app/alert/setting/alert-setting-page.tsx'), 'utf8'); - - expect(source).toContain("title: t(isEdit ? 'common.notify.edit-fail' : 'common.notify.new-fail')"); - expect(source).toContain('description: error instanceof Error ? error.message : undefined'); - expect(source).toContain("contract: isEdit ? 'edit' : 'create'"); - expect(source).toContain('saveFeedback={saveFeedback}'); - expect(source).not.toContain("t('common.save-failed')"); - }); - - it('previews periodic threshold expressions through the alert define preview endpoint before save', async () => { - const source = readFileSync(resolve(process.cwd(), 'app/alert/setting/alert-setting-page.tsx'), 'utf8'); - await renderAlertSettingPage(); - - apiMessageGet.mockResolvedValueOnce([{ __value__: 0.92, service_name: 'checkout' }]); - - expect(mockState.lastOnPreview).toBeTypeOf('function'); - - await mockState.lastOnPreview?.({ - name: 'checkout trace alert', - type: 'periodic_trace', - datasource: 'sql', - expr: 'SELECT 1 AS __value__ FROM hertzbeat_apm_red_1m', - template: 'Trace error rate', - labels: {}, - annotations: {}, - enable: true, - period: 300, - times: 3, - priority: 2 - }); - - expect(apiMessageGet).toHaveBeenCalledWith( - '/alert/define/preview/sql?type=periodic_trace&expr=SELECT%201%20AS%20__value__%20FROM%20hertzbeat_apm_red_1m' - ); - expect(source).toContain('api.alertSettings.preview(payload.datasource, payload.type, payload.expr)'); - expect(source).toContain('buildAlertSettingPreviewSuccessFeedback(rows, t)'); - expect(source).toContain("t('alert.setting.preview.success.title'"); - expect(source).toContain("contract: 'success'"); - }); - - it('keeps large alert preview responses bounded while preserving total evidence count', async () => { - const { buildAlertSettingPreviewSuccessFeedback, ALERT_SETTING_PREVIEW_SAMPLE_LIMIT } = await import('./alert-setting-page'); - const t = createTranslatorMock(); - const previewRows = Array.from({ length: 40 }, (_, index) => ({ - __value__: index, - service_name: `checkout-${index}` - })); - const feedback = buildAlertSettingPreviewSuccessFeedback(previewRows, t); - - expect(ALERT_SETTING_PREVIEW_SAMPLE_LIMIT).toBe(3); - expect(feedback.contract).toBe('success'); - expect(feedback.rows).toHaveLength(3); - expect(feedback.rows?.[0]).toEqual({ __value__: 0, service_name: 'checkout-0' }); - expect(feedback.rows?.[2]).toEqual({ __value__: 2, service_name: 'checkout-2' }); - expect(feedback.totalRows).toBe(40); - expect(feedback.sampleLimit).toBe(3); - }); - - it('previews realtime log expressions through the alert define preview endpoint before save', async () => { - const source = readFileSync(resolve(process.cwd(), 'app/alert/setting/alert-setting-page.tsx'), 'utf8'); - await renderAlertSettingPage(); - - apiMessageGet.mockResolvedValueOnce([ - { preview_mode: 'log_sample', type: 'realtime_log', severityText: 'ERROR', body: 'checkout timeout' } - ]); - - expect(mockState.lastOnPreview).toBeTypeOf('function'); - - await mockState.lastOnPreview?.({ - name: 'checkout log alert', - type: 'realtime_log', - datasource: 'promql', - expr: "log.severityText == 'ERROR'", - template: 'Log severity matched', - labels: {}, - annotations: {}, - enable: true, - period: 300, - times: 3, - priority: 2 - }); - - expect(apiMessageGet).toHaveBeenCalledWith( - "/alert/define/preview/promql?type=realtime_log&expr=log.severityText%20%3D%3D%20'ERROR'" - ); - expect(source).toContain("payload.type === 'realtime_log'"); - expect(source).toContain('buildAlertSettingPreviewSuccessFeedback(rows, t)'); - }); - - it('keeps unsupported realtime metric preview honest instead of calling the preview endpoint', async () => { - const source = readFileSync(resolve(process.cwd(), 'app/alert/setting/alert-setting-page.tsx'), 'utf8'); - await renderAlertSettingPage(); - - expect(mockState.lastOnPreview).toBeTypeOf('function'); - - await mockState.lastOnPreview?.({ - name: 'checkout metric alert', - type: 'realtime_metric', - datasource: 'promql', - expr: 'cpu > 80', - template: 'Metric matched', - labels: {}, - annotations: {}, - enable: true, - period: 300, - times: 3, - priority: 2 - }); - - expect(apiMessageGet).not.toHaveBeenCalledWith(expect.stringContaining('/alert/define/preview')); - expect(source).toContain("const supportsPreview = payload.type.startsWith('periodic_') || payload.type === 'realtime_log'"); - expect(source).toContain("t('alert.setting.preview.unsupported.title')"); - expect(source).toContain("contract: 'unsupported'"); - }); - - it('maps threshold enable toggle failures to the Angular edit notify title plus backend detail', () => { - const source = readFileSync(resolve(process.cwd(), 'app/alert/setting/alert-setting-page.tsx'), 'utf8'); - - expect(source).toContain('updateAlertDefineEnabledFromFacade'); - expect(source).toContain("title: t('common.notify.edit-fail')"); - expect(source).toContain('description: error instanceof Error ? error.message : undefined'); - expect(source).toContain("contract: 'enable'"); - expect(source).not.toContain("t('common.enable-failed')"); - }); - - it('announces successful threshold enable toggles with the updated rule state', async () => { - const { default: AlertSettingPage } = await import('./alert-setting-page'); - interactionContainer = document.createElement('div'); - document.body.appendChild(interactionContainer); - interactionRoot = createRoot(interactionContainer); - - await act(async () => { - interactionRoot?.render(); - await Promise.resolve(); - }); - - await act(async () => { - await mockState.lastSurfaceProps?.onToggleEnabled(7, false); - await Promise.resolve(); - }); - - expect(apiMessagePut).toHaveBeenCalledWith( - '/alert/define', - expect.objectContaining({ - id: 7, - name: 'cpu threshold', - enable: false - }) - ); - expect(mockState.lastSurfaceProps?.actionFeedback).toMatchObject({ - tone: 'success', - title: 'Updated threshold rule cpu threshold', - description: 'The rule is disabled and will not create new alerts until enabled again.', - contract: 'enable-success', - toggledRule: { - id: 7, - name: 'cpu threshold', - enabled: false - } - }); - }); - - it('keeps threshold batch delete clickable and warns when no rows are selected', () => { - const source = readFileSync(resolve(process.cwd(), 'app/alert/setting/alert-setting-page.tsx'), 'utf8'); - - expect(source).toContain("title: t('alert.setting.notify.no-select-delete')"); - expect(source).toContain("contract: 'no-select-delete'"); - expect(source).not.toContain('if (checkedIds.length === 0) return;'); - }); - - it('uses threshold-specific copy for the no-select export warning', () => { - const source = readFileSync(resolve(process.cwd(), 'app/alert/setting/alert-setting-page.tsx'), 'utf8'); - - expect(source).toContain("title: t('alert.setting.notify.no-select-export')"); - expect(source).toContain("contract: 'no-select-export'"); - expect(source).not.toContain("t('common.notify.no-select-export')"); - }); - - it('wires Angular-compatible threshold import and export actions instead of placeholders', async () => { - const source = readFileSync(resolve(process.cwd(), 'app/alert/setting/alert-setting-page.tsx'), 'utf8'); - await renderAlertSettingPage(); - - expect(mockState.lastOnExport).toBeTypeOf('function'); - expect(mockState.lastOnImport).toBeTypeOf('function'); - expect(source).toContain('buildAlertDefineExportUrl'); - expect(source).toContain('buildAlertDefineImportUrl'); - expect(source).toContain('HzExportTypeDialog'); - expect(source).toContain('HzFileInput'); - expect(source).toContain("const ALERT_DEFINE_IMPORT_FILE_ACCEPT = '.json,.yaml,.yml,.xlsx';"); - expect(source).toContain('accept={ALERT_DEFINE_IMPORT_FILE_ACCEPT}'); - expect(source).toContain('data-alert-setting-import-file-input="true"'); - expect(source).toContain('data-alert-setting-import-input-owner="hertzbeat-ui-file-input"'); - expect(source).toContain('data-alert-setting-export-type-dialog-owner="hertzbeat-ui-export-type-dialog"'); - expect(source).toContain('data-alert-setting-export-type-option-owner'); - expect(source).toContain('fetch(`/api${buildAlertDefineExportUrl(checkedIds, type)}`'); - expect(source).toContain('fetch(`/api${buildAlertDefineImportUrl()}`'); - expect(source).not.toContain('onExport={() => {}}'); - expect(source).not.toContain('onImport={() => {}}'); - }); - - it('maps threshold import results to Angular success and failure notifications', () => { - const source = readFileSync(resolve(process.cwd(), 'app/alert/setting/alert-setting-page.tsx'), 'utf8'); - - expect(source).toContain("return t('common.notify.import-success')"); - expect(source).toContain("t('common.notify.import-fail')"); - expect(source).toContain("success: 'import-success'"); - expect(source).toContain("failure: 'import-fail'"); - expect(source).not.toContain("return t('common.notify.import-success-detail'"); - }); - - it('keeps threshold import upload lifecycle on the Angular single-file reload contract', () => { - const source = readFileSync(resolve(process.cwd(), 'app/alert/setting/alert-setting-page.tsx'), 'utf8'); - - expect(source).toContain('if (pendingActionId) return;'); - expect(source).toContain('isAlertDefineImportFile(file)'); - expect(source).toContain("normalizedName.endsWith('.yaml')"); - expect(source).toContain("normalizedName.endsWith('.yml')"); - expect(source).toContain("description: t('common.notify.import-invalid-file')"); - expect(source).toContain('return;'); - expect(source).toContain('multiple={false}'); - expect(source).toContain('data-alert-setting-import-upload-contract="angular-nz-upload-limit-one-no-list"'); - expect(source).toContain('data-alert-setting-import-show-list="false"'); - expect(source).toContain('data-alert-setting-import-refresh-contract="angular-success-refresh"'); - expect(source).toContain('data-alert-setting-import-failure-refresh-contract="angular-failure-no-refresh"'); - expect(source).toContain('setRefreshKey(value => value + 1)'); - expect(source).not.toContain('multiple={true}'); - }); - - it('keeps threshold export success silent while mapping failures to the Angular notify title', () => { - const source = readFileSync(resolve(process.cwd(), 'app/alert/setting/alert-setting-page.tsx'), 'utf8'); - - expect(source).toContain("title: t('common.notify.export-fail')"); - expect(source).toContain("contract: 'export-fail'"); - expect(source).toContain('data-alert-setting-export-success-contract="angular-download-closes-dialog-no-toast"'); - expect(source).toContain('setActionFeedback(null);'); - expect(source).toContain("throw new Error('')"); - expect(source).not.toContain("return t('common.notify.export-success')"); - }); - - it('keeps threshold export loading scoped to the selected Angular export type', () => { - const source = readFileSync(resolve(process.cwd(), 'app/alert/setting/alert-setting-page.tsx'), 'utf8'); - - expect(source).toContain('const [pendingExportType, setPendingExportType]'); - expect(source).toContain('setPendingExportType(type)'); - expect(source).toContain("jsonBusy={pendingExportType === 'JSON'}"); - expect(source).toContain("excelBusy={pendingExportType === 'EXCEL'}"); - expect(source).toContain('data-alert-setting-export-loading-contract="angular-selected-type-only"'); - expect(source).toContain("setPendingExportType(null)"); - expect(source).not.toContain("jsonBusy={pendingActionId === 'export'}"); - expect(source).not.toContain("excelBusy={pendingActionId === 'export'}"); - }); - - it('persists threshold enable toggles instead of leaving the enable checkbox as a no-op', async () => { - const source = readFileSync(resolve(process.cwd(), 'app/alert/setting/alert-setting-page.tsx'), 'utf8'); - await renderAlertSettingPage(); - - expect(mockState.lastOnToggleEnabled).toBeTypeOf('function'); - - await mockState.lastOnToggleEnabled?.(7, false); - - expect(apiMessagePut).toHaveBeenCalledWith( - '/alert/define', - expect.objectContaining({ - id: 7, - name: 'cpu threshold', - enable: false - }) - ); - expect(source).toContain('updateAlertDefineEnabled'); - expect(source).toContain('api.alertSettings.update'); - expect(source).not.toContain('apiMessagePut'); - expect(source).not.toContain('onToggleEnabled={() => {}}'); - }); - - it('loads threshold detail into the shared authoring dialog instead of leaving edit as a no-op', async () => { - const source = readFileSync(resolve(process.cwd(), 'app/alert/setting/alert-setting-page.tsx'), 'utf8'); - await renderAlertSettingPage(); - - apiMessageGet.mockResolvedValueOnce({ - id: 7, - name: 'cpu threshold', - type: 'periodic_metric', - datasource: 'promql', - expr: 'cpu_usage > 80', - template: 'CPU high', - labels: { severity: 'warning' }, - enable: true, - period: 300, - times: 3, - priority: 2 - }); - - expect(mockState.lastOnEdit).toBeTypeOf('function'); - - await mockState.lastOnEdit?.(7); - - expect(apiMessageGet).toHaveBeenCalledWith('/alert/define/7'); - expect(source).toContain('loadAlertDefineDetailFromFacade'); - expect(source).toContain('api.alertSettings.detail'); - expect(source).not.toContain('apiMessageGet as any'); - expect(source).toContain('buildAlertSettingDraftFromDefine'); - expect(source).toContain("setCreateMode('authoring')"); - expect(source).not.toContain('onEdit={() => {}}'); - }); - - it('keeps three-signal route context on the alert-rule workspace entry', async () => { - const initialRouteState: AlertSettingRouteState = { - signal: 'logs', - signalContext: { - signal: 'logs', - entityId: '7', - entityName: 'Checkout API', - serviceName: 'checkout', - environment: 'prod', - timeRange: 'last-1h', - source: 'otlp', - traceId: 'trace-123', - spanId: 'span-456', - returnTo: '/log/manage?traceId=trace-123' - } - }; - - const html = await renderAlertSettingPage(initialRouteState); - const source = readFileSync(resolve(process.cwd(), 'app/alert/setting/alert-setting-page.tsx'), 'utf8'); - - expect(html).toContain('data-evidence-signal="logs"'); - expect(html).toContain('hertzbeat.signal:logs'); - expect(html).toContain('hertzbeat.entity.id:7'); - expect(html).toContain('service.name:checkout'); - expect(html).toContain('trace_id:trace-123'); - expect(source).toContain("import { useRouter, useSearchParams } from 'next/navigation';"); - expect(source).not.toContain('readSignalRouteContext'); - expect(source).toContain('buildAlertSettingEvidenceContext'); - expect(source).toContain('evidenceContext={evidenceContext}'); - expect(source).toContain('evidenceReturnHref={evidenceContext?.returnHref}'); - expect(source).toContain('const alertSettingRouteState = initialRouteState ?? EMPTY_ALERT_SETTING_ROUTE_STATE'); - }); - - it('seeds alert-rule authoring type from the incoming three-signal route context', async () => { - const { buildAlertSettingCreateDraftSeed, resolveAlertSettingInitialCreateMode } = await import('./alert-setting-page'); - const labelsText = 'hertzbeat.signal:logs, service.name:checkout'; - - expect(buildAlertSettingCreateDraftSeed('logs', labelsText)).toEqual({ - labelsText, - kind: 'realtime', - dataType: 'log' - }); - expect(buildAlertSettingCreateDraftSeed('metrics', labelsText)).toEqual({ - labelsText, - kind: 'realtime', - dataType: 'metric' - }); - expect(buildAlertSettingCreateDraftSeed('traces', labelsText)).toEqual({ - labelsText, - kind: 'periodic', - dataType: 'trace' - }); - expect(buildAlertSettingCreateDraftSeed(null, labelsText)).toEqual({ - labelsText - }); - expect(buildAlertSettingCreateDraftSeed('metrics', labelsText, { - alertName: 'checkout latency', - alertExpression: 'rate(http.server.duration[5m])', - alertDatasource: 'promql', - alertTemplate: 'Latency is high' - })).toEqual({ - labelsText, - name: 'checkout latency', - expr: 'rate(http.server.duration[5m])', - datasource: 'promql', - template: 'Latency is high', - kind: 'realtime', - dataType: 'metric' - }); - expect(resolveAlertSettingInitialCreateMode('metrics', 'create', { expr: 'rate(up[5m])' })).toBe('authoring'); - expect(resolveAlertSettingInitialCreateMode('logs', 'create', { expr: "log.severityText == 'ERROR'" })).toBe('authoring'); - expect(resolveAlertSettingInitialCreateMode('traces', 'create', { expr: 'SELECT service_name, 1 AS __value__ FROM hertzbeat_apm_red_1m' })).toBe('authoring'); - expect(resolveAlertSettingInitialCreateMode('traces', 'create', {})).toBe('type'); - expect(resolveAlertSettingInitialCreateMode('metrics', null, { expr: 'rate(up[5m])' })).toBe('closed'); - }); - - it('keeps alert setting remounts on a short settled cache window with refresh-key invalidation', () => { - const source = readFileSync(resolve(process.cwd(), 'app/alert/setting/alert-setting-page.tsx'), 'utf8'); - - expect(source).toContain('ALERT_SETTING_SETTLED_CACHE_TTL_MS = 10_000'); - expect(source).toContain('const [refreshKey, setRefreshKey] = useState(0)'); - expect(source).toContain("['alert-setting', alertSettingListUrl, refreshKey].join('|')"); - expect(source).toContain('[alertSettingListUrl, refreshKey]'); - expect(source).toContain('void refreshKey'); - expect(source).toContain('[query, pageIndex, pageSize, refreshKey]'); - expect(source.match(/setRefreshKey\(value => value \+ 1\)/g)?.length).toBeGreaterThanOrEqual(4); - expect(source).toContain('cacheKey={alertSettingCacheKey}'); - expect(source).toContain('cacheSettledTtlMs={ALERT_SETTING_SETTLED_CACHE_TTL_MS}'); - }); -}); diff --git a/web-next/app/alert/setting/page.tsx b/web-next/app/alert/setting/page.tsx deleted file mode 100644 index 9bf7f50470..0000000000 --- a/web-next/app/alert/setting/page.tsx +++ /dev/null @@ -1,14 +0,0 @@ -import React from 'react'; - -import AlertSettingPage from './alert-setting-page'; -import { readAlertSettingRouteState, type AlertSettingSearchParams } from '../../../lib/alert-setting/query-state'; - -export default async function AlertSettingRoutePage({ - searchParams -}: { - searchParams?: Promise; -}) { - const resolvedSearchParams = await searchParams; - const routeState = readAlertSettingRouteState(resolvedSearchParams); - return ; -} diff --git a/web-next/app/alert/silence/alert-silence-page.tsx b/web-next/app/alert/silence/alert-silence-page.tsx deleted file mode 100644 index 24d9741183..0000000000 --- a/web-next/app/alert/silence/alert-silence-page.tsx +++ /dev/null @@ -1,623 +0,0 @@ -'use client'; - -import React from 'react'; -import { useCallback, useEffect, useMemo, useState } from 'react'; -import { useRouter, useSearchParams } from 'next/navigation'; -import { ClientWorkbench } from '../../../components/workbench/client-workbench'; -import { useI18n } from '../../../components/providers/i18n-provider'; -import { AlertSilenceSurface } from '../../../components/pages/alert-silence-surface'; -import { HzConfirmDialog } from '../../../components/ui/hz-confirm-dialog'; -import { api } from '../../../lib/alert-api-facade'; -import { - buildAlertSilenceEntityPrefillFromFacade, - buildAlertSilenceFormDraft, - createAlertSilenceFromFacade, - deleteAlertSilenceFromFacade, - deleteAlertSilencesFromFacade, - loadAlertSilenceDataFromFacade, - loadAlertSilenceDetailFromFacade, - loadMatchedAlertSilencesFromFacade, - updateAlertSilenceEnabledFromFacade, - updateAlertSilenceFromFacade, - type AlertSilenceFormDraft -} from '../../../lib/alert-silence/controller'; -import { ALERT_SILENCE_PAGE_SIZE_OPTIONS, buildAlertSilenceUrl, type AlertSilenceRouteState } from '../../../lib/alert-silence/query-state'; -import { - buildAlertSilenceEvidenceContext, - getAlertSilenceValidationField, - validateAlertSilenceForm, - type AlertSilenceValidationField -} from '../../../lib/alert-silence/view-model'; -import { DEFAULT_ALERT_LABEL_OPTIONS, loadAlertLabelOptionsFromFacade } from '../../../lib/alert-label-options'; -import { formatTime } from '../../../lib/format'; -import type { AlertSilence, PageResult } from '../../../lib/types'; -import type { AlertSilenceManagementContext } from '../../../lib/alert-silence/query-state'; - -type SilenceDeleteRequest = { - kind: 'single' | 'batch'; - ids: number[]; -}; - -const ALERT_SILENCE_SETTLED_CACHE_TTL_MS = 10_000; -const ALERT_SILENCE_LABEL_OPTIONS_TIMEOUT_MS = 2_500; -const ALERT_SILENCE_ROUTE_PATH = '/alert/silence'; -const ALERT_SILENCE_EDITOR_FOCUS_SELECTORS: Record = { - name: 'input[name="silence_name"]', - labels: '[data-alert-silence-label-selector] [data-hz-label-selector-draft-row="true"] input[data-hz-label-selector-key-input="searchable-key"]', - days: 'input[name="silence_days[]"]', - time: 'input[name="silence_period_start"]' -}; -const ALERT_SILENCE_DRAFT_FINGERPRINT_FIELDS: Array = [ - 'id', - 'name', - 'enable', - 'matchAll', - 'type', - 'labelsText', - 'daysText', - 'periodStart', - 'periodEnd' -]; -const EMPTY_ALERT_SILENCE_ROUTE_STATE: AlertSilenceRouteState = { - returnContext: { - search: '', - status: '', - severity: '', - entityId: '', - entityName: '', - returnTo: '' - }, - signal: null, - signalContext: {}, - managementContext: { - entityId: '', - entityName: '', - returnTo: '', - returnLabel: '', - matchMode: '', - matchingRuleType: '', - matchingRuleIds: [], - matchedViewEnabled: false - } -}; - -type AlertSilenceListRouteState = { - search: string; - pageIndex: number; - pageSize: number; -}; - -function parseAlertSilenceRouteInteger(value: string | null, fallback: number, minimum = 0) { - if (!value) return fallback; - const parsed = Number.parseInt(value, 10); - return Number.isFinite(parsed) && parsed >= minimum ? parsed : fallback; -} - -function filterMatchedSilencesBySearch(silences: AlertSilence[], search: string): AlertSilence[] { - const keyword = search.trim().toLowerCase(); - if (!keyword) return silences; - return silences.filter(silence => { - if (silence.name?.toLowerCase().includes(keyword)) { - return true; - } - return Object.entries(silence.labels || {}).some(([key, value]) => `${key}:${value}`.toLowerCase().includes(keyword)); - }); -} - -function paginateMatchedSilences(silences: AlertSilence[], pageIndex: number, pageSize: number): PageResult { - const normalizedPageSize = Math.max(1, pageSize); - const lastPageIndex = Math.max(0, Math.ceil(silences.length / normalizedPageSize) - 1); - const normalizedPageIndex = Math.min(Math.max(0, pageIndex), lastPageIndex); - const start = normalizedPageIndex * normalizedPageSize; - return { - content: silences.slice(start, start + normalizedPageSize), - totalElements: silences.length, - pageIndex: normalizedPageIndex, - pageSize: normalizedPageSize - }; -} - -function shouldUseMatchedSilenceView(context: AlertSilenceManagementContext, matchedViewEnabled: boolean) { - return matchedViewEnabled && context.matchMode === 'entity-noise-controls'; -} - -function withTimeoutFallback(promise: Promise, fallback: T, timeoutMs: number): Promise { - return new Promise(resolve => { - const timer = setTimeout(() => resolve(fallback), timeoutMs); - promise.then( - value => { - clearTimeout(timer); - resolve(value); - }, - () => { - clearTimeout(timer); - resolve(fallback); - } - ); - }); -} - -function focusAlertSilenceEditorField(field: AlertSilenceValidationField) { - if (typeof document === 'undefined') return; - const selector = ALERT_SILENCE_EDITOR_FOCUS_SELECTORS[field]; - window.requestAnimationFrame(() => { - const target = document.querySelector(selector); - target?.focus(); - target?.scrollIntoView({ block: 'center', inline: 'nearest' }); - }); -} - -function serializeAlertSilenceDraft(draft: AlertSilenceFormDraft) { - return JSON.stringify( - ALERT_SILENCE_DRAFT_FINGERPRINT_FIELDS.map(field => [field, draft[field] == null ? '' : String(draft[field]).trim()]) - ); -} - -export default function AlertSilencePage({ initialRouteState }: { initialRouteState?: AlertSilenceRouteState } = {}) { - const { t } = useI18n(); - const router = useRouter(); - const searchParams = useSearchParams(); - const routeSearchParamString = searchParams.toString(); - const routeSearch = searchParams.get('search') ?? ''; - const routePageIndex = parseAlertSilenceRouteInteger(searchParams.get('pageIndex'), 0); - const routePageSize = parseAlertSilenceRouteInteger(searchParams.get('pageSize'), ALERT_SILENCE_PAGE_SIZE_OPTIONS[0], 1); - const routeListState = useMemo(() => ({ - search: routeSearch, - pageIndex: routePageIndex, - pageSize: routePageSize - }), [routePageIndex, routePageSize, routeSearch]); - const alertSilenceRouteState = initialRouteState ?? EMPTY_ALERT_SILENCE_ROUTE_STATE; - const { returnContext, signal, signalContext, managementContext } = alertSilenceRouteState; - const silenceEvidenceContext = useMemo( - () => buildAlertSilenceEvidenceContext(signal, signalContext, t), - [signal, signalContext, t] - ); - const [search, setSearch] = useState(routeListState.search); - const [query, setQuery] = useState(routeListState.search); - const [pageIndex, setPageIndex] = useState(routeListState.pageIndex); - const [pageSize, setPageSize] = useState(routeListState.pageSize); - const [selectedId, setSelectedId] = useState(null); - const [editorOpen, setEditorOpen] = useState(false); - const [editorLoading, setEditorLoading] = useState(false); - const [editorSaving, setEditorSaving] = useState(false); - const [editorMessage, setEditorMessage] = useState(null); - const [editorError, setEditorError] = useState(null); - const [editorErrorDetail, setEditorErrorDetail] = useState(null); - const [editorErrorContract, setEditorErrorContract] = useState<'save' | 'enable' | 'delete' | null>(null); - const [draft, setDraft] = useState(() => buildAlertSilenceFormDraft(null, silenceEvidenceContext?.draftPatch)); - const [editorInitialFingerprint, setEditorInitialFingerprint] = useState(() => ( - serializeAlertSilenceDraft(buildAlertSilenceFormDraft(null, silenceEvidenceContext?.draftPatch)) - )); - const [editorDiscardDialogOpen, setEditorDiscardDialogOpen] = useState(false); - const [refreshTick, setRefreshTick] = useState(0); - const [checkedIds, setCheckedIds] = useState([]); - const [deleteRequest, setDeleteRequest] = useState(null); - const [deletePending, setDeletePending] = useState(false); - const [matchedViewEnabled, setMatchedViewEnabled] = useState(managementContext.matchedViewEnabled); - const [createdOutsideMatchedViewNotice, setCreatedOutsideMatchedViewNotice] = useState(false); - const [entityPrefillSource, setEntityPrefillSource] = useState<'alerts-common-labels' | 'none'>('none'); - const [entityPrefillWarning, setEntityPrefillWarning] = useState(null); - const alertSilenceListUrl = useMemo(() => buildAlertSilenceUrl({ search: query, pageIndex, pageSize }), [pageIndex, pageSize, query]); - const matchedRuleIdsKey = managementContext.matchingRuleIds.join(','); - const useMatchedView = shouldUseMatchedSilenceView(managementContext, matchedViewEnabled); - const alertSilenceCacheKey = useMemo( - () => ['alert-silence', useMatchedView ? `matched:${matchedRuleIdsKey}` : alertSilenceListUrl, refreshTick].join('|'), - [alertSilenceListUrl, matchedRuleIdsKey, refreshTick, useMatchedView] - ); - const editorDraftFingerprint = useMemo(() => serializeAlertSilenceDraft(draft), [draft]); - const shouldConfirmEditorDiscard = Boolean(editorOpen && editorDraftFingerprint !== editorInitialFingerprint && !editorSaving); - - useEffect(() => { - setSearch(routeListState.search); - setQuery(routeListState.search); - setPageIndex(routeListState.pageIndex); - setPageSize(routeListState.pageSize); - setSelectedId(null); - setCheckedIds([]); - }, [routeListState]); - - const replaceRouteQuery = useCallback((nextState: AlertSilenceListRouteState) => { - const nextParams = new URLSearchParams(routeSearchParamString); - const cleanSearch = nextState.search.trim(); - if (cleanSearch) { - nextParams.set('search', cleanSearch); - } else { - nextParams.delete('search'); - } - - if (nextState.pageIndex > 0) { - nextParams.set('pageIndex', String(nextState.pageIndex)); - } else { - nextParams.delete('pageIndex'); - } - - if (nextState.pageSize !== ALERT_SILENCE_PAGE_SIZE_OPTIONS[0]) { - nextParams.set('pageSize', String(nextState.pageSize)); - } else { - nextParams.delete('pageSize'); - } - - const nextParamString = nextParams.toString(); - const nextUrl = nextParamString ? `${ALERT_SILENCE_ROUTE_PATH}?${nextParamString}` : ALERT_SILENCE_ROUTE_PATH; - const currentUrl = routeSearchParamString ? `${ALERT_SILENCE_ROUTE_PATH}?${routeSearchParamString}` : ALERT_SILENCE_ROUTE_PATH; - if (nextUrl !== currentUrl) { - router.replace(nextUrl, { scroll: false }); - } - }, [routeSearchParamString, router]); - - const load = useCallback(async () => { - const labelOptionsPromise = withTimeoutFallback( - loadAlertLabelOptionsFromFacade(api.alertLabels.list), - DEFAULT_ALERT_LABEL_OPTIONS, - ALERT_SILENCE_LABEL_OPTIONS_TIMEOUT_MS - ); - if (useMatchedView) { - const [matchedResult, labelOptions] = await Promise.all([ - loadMatchedAlertSilencesFromFacade(api.alertSilences.detail, managementContext.matchingRuleIds), - labelOptionsPromise - ]); - const filtered = filterMatchedSilencesBySearch(matchedResult.matched, query); - return { - list: paginateMatchedSilences(filtered, pageIndex, pageSize), - labelOptions, - refreshTick, - missingMatchedRuleCount: matchedResult.missingMatchedRuleCount - }; - } - - const data = await loadAlertSilenceDataFromFacade( - { - list: api.alertSilences.list, - labelOptions: () => labelOptionsPromise - }, - { search: query, pageIndex, pageSize } - ); - return { ...data, refreshTick, missingMatchedRuleCount: 0 }; - }, [managementContext.matchingRuleIds, pageIndex, pageSize, query, refreshTick, useMatchedView]); - - return ( - - {data => { - const labelOptions = data.labelOptions ?? DEFAULT_ALERT_LABEL_OPTIONS; - const selected = data.list.content.find(item => item.id === selectedId) ?? data.list.content[0] ?? null; - - function handleCloseEditor() { - setEditorOpen(false); - setEditorError(null); - setEditorErrorDetail(null); - setEditorErrorContract(null); - setEditorDiscardDialogOpen(false); - } - - async function handleNew() { - const displayName = managementContext.entityName || managementContext.returnLabel || managementContext.entityId || 'entity'; - const entityContextDraft = managementContext.entityId || managementContext.entityName || managementContext.returnTo - ? { name: `${displayName} silence` } - : {}; - const baseDraftPatch = silenceEvidenceContext?.draftPatch ?? entityContextDraft; - setEntityPrefillSource('none'); - setEntityPrefillWarning(null); - if (managementContext.entityId || managementContext.entityName || managementContext.returnTo) { - setEditorLoading(true); - try { - const prefill = await buildAlertSilenceEntityPrefillFromFacade( - entityId => api.entities.alerts(entityId, { pageIndex: 0, pageSize: 20, status: 'firing' }), - managementContext.entityId, - t('entity.noise-controls.authoring.silence.prefill-warning'), - t('entity.noise-controls.authoring.prefill-warning.no-entity-id') - ); - setEntityPrefillSource(prefill.source); - setEntityPrefillWarning(prefill.warning); - const nextDraft = buildAlertSilenceFormDraft(null, { ...baseDraftPatch, ...prefill.draftPatch }); - setDraft(nextDraft); - setEditorInitialFingerprint(serializeAlertSilenceDraft(nextDraft)); - } finally { - setEditorLoading(false); - } - } else { - const nextDraft = buildAlertSilenceFormDraft(null, baseDraftPatch); - setDraft(nextDraft); - setEditorInitialFingerprint(serializeAlertSilenceDraft(nextDraft)); - } - setEditorDiscardDialogOpen(false); - setEditorError(null); - setEditorErrorDetail(null); - setEditorErrorContract(null); - setEditorMessage(null); - setCreatedOutsideMatchedViewNotice(false); - setEditorOpen(true); - } - - async function handleEdit(silenceId?: number) { - const targetId = silenceId ?? selected?.id; - if (!targetId) return; - setEditorLoading(true); - setEditorError(null); - setEditorErrorDetail(null); - setEditorErrorContract(null); - setEditorMessage(null); - try { - const detail = await loadAlertSilenceDetailFromFacade(api.alertSilences.detail, targetId); - const nextDraft = buildAlertSilenceFormDraft(detail); - setDraft(nextDraft); - setEditorInitialFingerprint(serializeAlertSilenceDraft(nextDraft)); - setEditorDiscardDialogOpen(false); - setEditorOpen(true); - } catch (error) { - setEditorError(error instanceof Error ? error.message : t('common.notify.edit-fail')); - setEditorErrorDetail(null); - setEditorErrorContract(null); - } finally { - setEditorLoading(false); - } - } - - async function handleSave() { - const validationError = validateAlertSilenceForm(draft, t); - if (validationError) { - const validationField = getAlertSilenceValidationField(draft); - setEditorError(validationError); - setEditorErrorDetail(null); - setEditorErrorContract(null); - if (validationField) { - focusAlertSilenceEditorField(validationField); - } - return; - } - setEditorSaving(true); - setEditorError(null); - setEditorErrorDetail(null); - setEditorErrorContract(null); - setEditorMessage(null); - const isEdit = Boolean(draft.id); - try { - if (isEdit) { - await updateAlertSilenceFromFacade(api.alertSilences.update, draft); - } else { - await createAlertSilenceFromFacade(api.alertSilences.create, draft); - } - setEditorInitialFingerprint(serializeAlertSilenceDraft(draft)); - setEditorMessage(t(isEdit ? 'common.notify.edit-success' : 'common.notify.new-success')); - setEditorOpen(false); - setEditorDiscardDialogOpen(false); - setCreatedOutsideMatchedViewNotice(!isEdit && useMatchedView); - setRefreshTick(value => value + 1); - } catch (error) { - setEditorError(t(isEdit ? 'common.notify.edit-fail' : 'common.notify.new-fail')); - setEditorErrorDetail(error instanceof Error ? error.message : null); - setEditorErrorContract('save'); - } finally { - setEditorSaving(false); - } - } - - async function handleToggleEnabled(silence?: AlertSilence) { - const target = silence ?? selected; - if (!target) return; - try { - await updateAlertSilenceEnabledFromFacade(api.alertSilences.update, target, !(target.enable ?? true)); - setEditorMessage(t('common.notify.edit-success')); - setEditorError(null); - setEditorErrorDetail(null); - setEditorErrorContract(null); - setRefreshTick(value => value + 1); - } catch (error) { - setEditorError(t('common.notify.edit-fail')); - setEditorErrorDetail(error instanceof Error ? error.message : null); - setEditorErrorContract('enable'); - } - } - - function requestCloseEditor() { - if (shouldConfirmEditorDiscard) { - setEditorDiscardDialogOpen(true); - return; - } - handleCloseEditor(); - } - - async function handleDelete(silenceId?: number) { - const targetId = silenceId ?? selected?.id; - if (!targetId) return; - setDeleteRequest({ kind: 'single', ids: [targetId] }); - } - - async function handleConfirmedDelete() { - const request = deleteRequest; - if (!request || request.ids.length === 0) return; - setDeletePending(true); - try { - if (request.kind === 'batch') { - await deleteAlertSilencesFromFacade(api.alertSilences.delete, request.ids); - setCheckedIds([]); - } else { - await deleteAlertSilenceFromFacade(api.alertSilences.delete, request.ids[0]); - } - const nextTotal = Math.max((data.list.totalElements || 0) - request.ids.length, 0); - const nextLastPageIndex = Math.max(0, Math.ceil(nextTotal / pageSize) - 1); - setPageIndex(value => Math.min(value, nextLastPageIndex)); - setSelectedId(null); - setEditorOpen(false); - setEditorMessage(t('common.notify.delete-success')); - setEditorError(null); - setEditorErrorDetail(null); - setEditorErrorContract(null); - setDeleteRequest(null); - setRefreshTick(value => value + 1); - } catch (error) { - setEditorError(t('common.notify.delete-fail')); - setEditorErrorDetail(error instanceof Error ? error.message : null); - setEditorErrorContract('delete'); - } finally { - setDeletePending(false); - } - } - - function handleRefresh() { - setRefreshTick(value => value + 1); - } - - async function handleDeleteSelected() { - if (checkedIds.length === 0) { - setEditorError(t('common.notify.no-select-delete')); - setEditorErrorDetail(null); - setEditorErrorContract(null); - setEditorMessage(null); - return; - } - setDeleteRequest({ kind: 'batch', ids: checkedIds }); - } - - function handleApplyFilter() { - const nextSearch = search.trim(); - const nextState = { search: nextSearch, pageIndex: 0, pageSize }; - setSearch(nextSearch); - setQuery(nextSearch); - setPageIndex(0); - setSelectedId(null); - setCheckedIds([]); - replaceRouteQuery(nextState); - } - - function handleClearFilter() { - const nextState = { search: '', pageIndex: 0, pageSize }; - setSearch(''); - setQuery(''); - setPageIndex(0); - setSelectedId(null); - setCheckedIds([]); - replaceRouteQuery(nextState); - } - - function handlePageIndexChange(nextPageIndex: number) { - setPageIndex(nextPageIndex); - setSelectedId(null); - setCheckedIds([]); - replaceRouteQuery({ search: query, pageIndex: nextPageIndex, pageSize }); - } - - function handlePageSizeChange(nextPageSize: number) { - const nextState = { search: query, pageIndex: 0, pageSize: nextPageSize }; - setPageSize(nextPageSize); - setPageIndex(0); - setSelectedId(null); - setCheckedIds([]); - replaceRouteQuery(nextState); - } - - return ( - <> - {(() => { - const deleteTargetNames = deleteRequest?.ids - .map(id => data.list.content.find(item => item.id === id)?.name?.trim()) - .filter((name): name is string => Boolean(name)) ?? []; - const missingDeleteTargetCount = deleteRequest - ? Math.max(deleteRequest.ids.length - deleteTargetNames.length, 0) - : 0; - const deleteConfirmCopy = [ - deleteRequest?.kind === 'batch' - ? t('alert.silence.delete.confirm.batch', { count: deleteRequest.ids.length }) - : t('alert.silence.delete.confirm.single'), - deleteTargetNames.length > 0 - ? t('alert.silence.delete.confirm.targets', { names: deleteTargetNames.join(', ') }) - : null, - missingDeleteTargetCount > 0 - ? t('alert.silence.delete.confirm.targets-more', { count: missingDeleteTargetCount }) - : null - ].filter(Boolean).join('\n'); - - return ( -
- setDeleteRequest(null)} - onConfirm={() => void handleConfirmedDelete()} - /> -
- ); - })()} - { - setMatchedViewEnabled(false); - setCreatedOutsideMatchedViewNotice(false); - setPageIndex(0); - setCheckedIds([]); - }} - onViewMatchedRules={() => { - setMatchedViewEnabled(true); - setPageIndex(0); - setCheckedIds([]); - }} - onNew={() => void handleNew()} - onEdit={silenceId => void handleEdit(silenceId)} - onSave={() => void handleSave()} - onToggleEnabled={silence => void handleToggleEnabled(silence)} - onDelete={silenceId => void handleDelete(silenceId)} - onDeleteSelected={() => void handleDeleteSelected()} - onCloseEditor={requestCloseEditor} - onDraftChange={setDraft} - /> -
- setEditorDiscardDialogOpen(false)} - onConfirm={handleCloseEditor} - /> -
- - ); - }} -
- ); -} diff --git a/web-next/app/alert/silence/page.test.tsx b/web-next/app/alert/silence/page.test.tsx deleted file mode 100644 index f82d706f33..0000000000 --- a/web-next/app/alert/silence/page.test.tsx +++ /dev/null @@ -1,644 +0,0 @@ -// @vitest-environment jsdom - -import { readFileSync } from 'node:fs'; -import { resolve } from 'node:path'; -import React from 'react'; -import { act } from 'react'; -import { createRoot, type Root } from 'react-dom/client'; -import { renderToStaticMarkup } from 'react-dom/server'; -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { createTranslatorMock } from '../../../test/i18n-test-helper'; -import type { AlertSilenceRouteState } from '../../../lib/alert-silence/query-state'; - -const mockState = vi.hoisted(() => ({ - lastLoad: null as null | (() => Promise), - lastSurfaceProps: null as null | Record, - currentSearchParams: '', - routerReplace: vi.fn(), - renderData: { - list: { - totalElements: 1, - content: [ - { - id: 7, - name: 'weekday', - enable: true, - matchAll: false, - labels: { service: 'checkout' }, - type: 1, - days: [1, 2, 3, 4, 5], - times: 2 - } - ], - pageIndex: 0, - pageSize: 8 - } - } -})); - -const apiMessageGet = vi.hoisted(() => vi.fn()); - -(globalThis as { React?: typeof React }).React = React; -(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; - -vi.mock('next/navigation', () => ({ - useRouter: () => ({ - replace: mockState.routerReplace - }), - useSearchParams: () => new URLSearchParams(mockState.currentSearchParams) -})); - -vi.mock('../../../components/providers/i18n-provider', () => ({ - useI18n: () => ({ - t: createTranslatorMock() - }) -})); - -vi.mock('../../../components/workbench/client-workbench', () => ({ - ClientWorkbench: ({ - children, - load, - loadingCopy - }: { - children: (data: any) => React.ReactNode; - load: () => Promise; - loadingCopy?: string; - }) => { - mockState.lastLoad = load; - return ( -
- {children(mockState.renderData)} -
- ); - } -})); - -vi.mock('../../../components/pages/alert-silence-surface', () => ({ - AlertSilenceSurface: (props: any) => { - const { - data, - returnContext, - managementContext, - matchedViewEnabled, - missingMatchedRuleCount, - createdOutsideMatchedViewNotice, - entityPrefillSource, - entityPrefillWarning, - evidenceContext, - draft, - pageSizeOptions, - search - } = props; - mockState.lastSurfaceProps = props; - return ( -
- ); - } -})); - -vi.mock('../../../lib/api-client', () => ({ - apiMessageDelete: vi.fn(), - apiMessageGet, - apiMessagePost: vi.fn(), - apiMessagePut: vi.fn() -})); - -const EMPTY_ROUTE_STATE: AlertSilenceRouteState = { - returnContext: { - search: '', - status: '', - severity: '', - entityId: '', - entityName: '', - returnTo: '' - }, - signal: null, - signalContext: {}, - managementContext: { - entityId: '', - entityName: '', - returnTo: '', - returnLabel: '', - matchMode: '', - matchingRuleType: '', - matchingRuleIds: [], - matchedViewEnabled: false - } -}; - -async function renderAlertSilencePage(initialRouteState: AlertSilenceRouteState = EMPTY_ROUTE_STATE) { - const { default: AlertSilencePage } = await import('./alert-silence-page'); - return renderToStaticMarkup(); -} - -describe('alert silence page', () => { - let interactionContainer: HTMLDivElement | null = null; - let interactionRoot: Root | null = null; - - afterEach(() => { - if (interactionRoot) { - act(() => { - interactionRoot?.unmount(); - }); - } - interactionRoot = null; - interactionContainer?.remove(); - interactionContainer = null; - }); - - beforeEach(() => { - mockState.lastLoad = null; - mockState.lastSurfaceProps = null; - mockState.currentSearchParams = ''; - mockState.routerReplace.mockReset(); - apiMessageGet.mockClear().mockResolvedValue(mockState.renderData.list); - }); - - it('loads the silence workbench through the shared query and surface contracts', async () => { - const html = await renderAlertSilencePage(); - - expect(html).toContain('data-alert-silence-surface="true"'); - expect(html).toContain('data-page-size-options="8|15|25"'); - expect(html).toContain('data-search=""'); - expect(html).toContain('data-loading-copy="Loading silence rules"'); - - await mockState.lastLoad?.(); - - expect(apiMessageGet).toHaveBeenCalledWith('/alert/silences?pageIndex=0&pageSize=8&sort=id&order=desc'); - }, 30_000); - - it('passes topology edge return context into the silence surface', async () => { - const returnTo = - '/topology?viewMode=resource-dependency&sourceKind=database-middleware-connection&edgeId=svc-checkout--res-orders-db&environment=prod&timeRange=last-1h'; - const initialRouteState: AlertSilenceRouteState = { - returnContext: { - search: 'checkout-api', - status: 'firing', - severity: '', - entityId: 'service:commerce/checkout', - entityName: 'checkout-api', - returnTo, - serviceName: 'checkout-api', - serviceNamespace: 'commerce', - environment: 'prod', - timeRange: 'last-1h', - source: 'topology', - viewMode: 'resource-dependency', - sourceKind: 'database-middleware-connection', - edgeId: 'svc-checkout--res-orders-db' - }, - signal: null, - signalContext: { - entityId: 'service:commerce/checkout', - entityName: 'checkout-api', - serviceName: 'checkout-api', - serviceNamespace: 'commerce', - environment: 'prod', - timeRange: 'last-1h', - source: 'topology', - returnTo - }, - managementContext: { - entityId: 'service:commerce/checkout', - entityName: 'checkout-api', - returnTo, - returnLabel: '', - matchMode: '', - matchingRuleType: '', - matchingRuleIds: [], - matchedViewEnabled: false - } - }; - - const html = await renderAlertSilencePage(initialRouteState); - - expect(html).toContain('"edgeId":"svc-checkout--res-orders-db"'); - expect(html).toContain('"viewMode":"resource-dependency"'); - expect(html).toContain('"sourceKind":"database-middleware-connection"'); - expect(html).toContain('"returnTo":"/topology?viewMode=resource-dependency&sourceKind=database-middleware-connection&edgeId=svc-checkout--res-orders-db'); - expect(html).not.toContain('returnLabel='); - expect(html).not.toContain('HertzBeat operations topology'); - }, 30_000); - - it('preserves three-signal evidence context into new silence authoring', async () => { - const initialRouteState: AlertSilenceRouteState = { - signal: 'logs', - returnContext: { - search: 'checkout', - status: 'firing', - severity: '', - entityId: 'service:commerce/checkout', - entityName: 'checkout-api', - returnTo: '/log/manage?view=list&traceId=trace-123', - serviceName: 'checkout', - serviceNamespace: 'commerce', - environment: 'prod', - source: 'otlp', - signal: 'logs', - traceId: 'trace-123', - spanId: 'span-456', - collector: 'edge-collector-a', - template: 'java-service' - }, - signalContext: { - entityId: 'service:commerce/checkout', - entityName: 'checkout-api', - serviceName: 'checkout', - serviceNamespace: 'commerce', - environment: 'prod', - source: 'otlp', - traceId: 'trace-123', - spanId: 'span-456', - collector: 'edge-collector-a', - template: 'java-service', - returnTo: '/log/manage?view=list&traceId=trace-123' - }, - managementContext: { - entityId: 'service:commerce/checkout', - entityName: 'checkout-api', - returnTo: '/log/manage?view=list&traceId=trace-123', - returnLabel: '', - matchMode: '', - matchingRuleType: '', - matchingRuleIds: [], - matchedViewEnabled: false - } - }; - - const html = await renderAlertSilencePage(initialRouteState); - - expect(html).toContain('data-alert-silence-evidence-context="signal-route"'); - expect(html).toContain('data-alert-silence-evidence-signal="logs"'); - expect(html).toContain('data-alert-silence-evidence-return="/log/manage?view=list&traceId=trace-123"'); - expect(html).toContain('hertzbeat.signal:logs'); - expect(html).toContain('service.name:checkout'); - expect(html).toContain('trace_id:trace-123'); - expect(html).toContain('span_id:span-456'); - expect(html).toContain('hertzbeat.collector:edge-collector-a'); - expect(html).toContain('data-alert-silence-draft-labels="hertzbeat.signal:logs'); - expect(html).not.toContain('returnLabel='); - const source = readFileSync(resolve(process.cwd(), 'app/alert/silence/alert-silence-page.tsx'), 'utf8'); - expect(source).toContain("import { useRouter, useSearchParams } from 'next/navigation';"); - expect(source).not.toContain('queryStateFromParams(searchParams)'); - expect(source).not.toContain('readSignalRouteContext(searchParams)'); - expect(source).toContain('const alertSilenceRouteState = initialRouteState ?? EMPTY_ALERT_SILENCE_ROUTE_STATE'); - }, 30_000); - - it('loads Angular entity-noise-control matched silence rules by id', async () => { - const returnTo = '/entities/42?tab=alerts'; - const initialRouteState: AlertSilenceRouteState = { - returnContext: { - search: 'checkout', - status: 'firing', - severity: '', - entityId: '42', - entityName: 'checkout-api', - returnTo - }, - signal: null, - signalContext: { - entityId: '42', - entityName: 'checkout-api', - returnTo - }, - managementContext: { - entityId: '42', - entityName: 'checkout-api', - returnTo, - returnLabel: 'checkout-api', - matchMode: 'entity-noise-controls', - matchingRuleType: 'silence', - matchingRuleIds: [11, 12], - matchedViewEnabled: true - } - }; - apiMessageGet.mockImplementation(async (url: string) => { - if (url === '/alert/silence/11') { - return { id: 11, name: 'checkout silence', enable: true, matchAll: false, labels: { service: 'checkout' }, type: 0 }; - } - if (url === '/alert/silence/12') { - throw new Error('missing'); - } - return mockState.renderData.list; - }); - - const html = await renderAlertSilencePage(initialRouteState); - const loaded = await mockState.lastLoad?.() as any; - - expect(html).toContain('data-alert-silence-match-mode="entity-noise-controls"'); - expect(html).toContain('data-alert-silence-match-view="matched"'); - expect(html).toContain('data-alert-silence-matching-rule-ids="11,12"'); - expect(apiMessageGet).toHaveBeenCalledWith('/alert/silence/11'); - expect(apiMessageGet).toHaveBeenCalledWith('/alert/silence/12'); - expect(apiMessageGet).not.toHaveBeenCalledWith('/alert/silences?pageIndex=0&pageSize=8&sort=id&order=desc'); - expect(loaded.list.content.map((item: any) => item.id)).toEqual([11]); - expect(loaded.missingMatchedRuleCount).toBe(1); - }, 30_000); - - it('keeps alert silence remounts on a short settled cache window with refresh-tick invalidation', () => { - const source = readFileSync(resolve(process.cwd(), 'app/alert/silence/alert-silence-page.tsx'), 'utf8'); - - expect(source).toContain('ALERT_SILENCE_SETTLED_CACHE_TTL_MS = 10_000'); - expect(source).toContain('ALERT_SILENCE_LABEL_OPTIONS_TIMEOUT_MS = 2_500'); - expect(source).toContain('withTimeoutFallback('); - expect(source).toContain('const [refreshTick, setRefreshTick] = useState(0)'); - expect(source).toContain('const [pageIndex, setPageIndex] = useState(routeListState.pageIndex)'); - expect(source).toContain('const [pageSize, setPageSize] = useState(routeListState.pageSize)'); - expect(source).toContain("['alert-silence', useMatchedView ? `matched:${matchedRuleIdsKey}` : alertSilenceListUrl, refreshTick].join('|')"); - expect(source).toContain('buildAlertSilenceUrl({ search: query, pageIndex, pageSize })'); - expect(source).toContain('[pageIndex, pageSize, query]'); - expect(source).toContain('[alertSilenceListUrl, matchedRuleIdsKey, refreshTick, useMatchedView]'); - expect(source).toContain('loadAlertSilenceDataFromFacade'); - expect(source).toContain('list: api.alertSilences.list'); - expect(source).toContain('loadAlertLabelOptionsFromFacade(api.alertLabels.list)'); - expect(source).toContain('loadMatchedAlertSilencesFromFacade(api.alertSilences.detail, managementContext.matchingRuleIds)'); - expect(source).not.toContain('apiMessageGet>(alertSilenceListUrl)'); - expect(source).toContain('return { ...data, refreshTick, missingMatchedRuleCount: 0 };'); - expect(source.match(/setRefreshTick\(value => value \+ 1\)/g)?.length).toBeGreaterThanOrEqual(4); - expect(source).toContain('cacheKey={alertSilenceCacheKey}'); - expect(source).toContain('cacheSettledTtlMs={ALERT_SILENCE_SETTLED_CACHE_TTL_MS}'); - expect(source).toContain('pageSizeOptions={[...ALERT_SILENCE_PAGE_SIZE_OPTIONS]}'); - expect(source).toContain('function handlePageIndexChange(nextPageIndex: number)'); - expect(source).toContain('function handlePageSizeChange(nextPageSize: number)'); - expect(source).toContain('setPageIndex(0);'); - }); - - it('initializes alert silence list state from the route and preserves URL state during search and pagination', async () => { - mockState.currentSearchParams = 'search=ops&pageIndex=2&pageSize=15&signal=logs&entityId=7'; - const { default: AlertSilencePage } = await import('./alert-silence-page'); - interactionContainer = document.createElement('div'); - document.body.appendChild(interactionContainer); - interactionRoot = createRoot(interactionContainer); - - await act(async () => { - interactionRoot?.render(); - await Promise.resolve(); - }); - - expect(mockState.lastSurfaceProps?.search).toBe('ops'); - expect(mockState.lastSurfaceProps?.requestedPageSize).toBe(15); - await act(async () => { - await mockState.lastLoad?.(); - }); - expect(apiMessageGet).toHaveBeenCalledWith('/alert/silences?pageIndex=2&pageSize=15&sort=id&order=desc&search=ops'); - - await act(async () => { - mockState.lastSurfaceProps?.onSearchChange('checkout'); - await Promise.resolve(); - }); - await act(async () => { - mockState.lastSurfaceProps?.onApplyFilter(); - await Promise.resolve(); - }); - - expect(mockState.routerReplace).toHaveBeenLastCalledWith('/alert/silence?search=checkout&pageSize=15&signal=logs&entityId=7', { scroll: false }); - - mockState.currentSearchParams = 'search=checkout&pageSize=15&signal=logs&entityId=7'; - await act(async () => { - interactionRoot?.render(); - await Promise.resolve(); - }); - await act(async () => { - mockState.lastSurfaceProps?.onPageIndexChange(3); - await Promise.resolve(); - }); - - expect(mockState.routerReplace).toHaveBeenLastCalledWith('/alert/silence?search=checkout&pageSize=15&signal=logs&entityId=7&pageIndex=3', { scroll: false }); - - mockState.currentSearchParams = 'search=checkout&pageSize=15&signal=logs&entityId=7'; - await act(async () => { - interactionRoot?.render(); - await Promise.resolve(); - }); - await act(async () => { - mockState.lastSurfaceProps?.onPageSizeChange(8); - await Promise.resolve(); - }); - - expect(mockState.routerReplace).toHaveBeenLastCalledWith('/alert/silence?search=checkout&signal=logs&entityId=7', { scroll: false }); - - mockState.currentSearchParams = 'search=checkout&pageSize=15&signal=logs&entityId=7'; - await act(async () => { - interactionRoot?.render(); - await Promise.resolve(); - }); - await act(async () => { - mockState.lastSurfaceProps?.onClearFilter(); - await Promise.resolve(); - }); - - expect(mockState.routerReplace).toHaveBeenLastCalledWith('/alert/silence?signal=logs&entityId=7', { scroll: false }); - }); - - it('clears local editor validation when canceling an empty new silence draft', async () => { - const { default: AlertSilencePage } = await import('./alert-silence-page'); - interactionContainer = document.createElement('div'); - document.body.appendChild(interactionContainer); - interactionRoot = createRoot(interactionContainer); - - await act(async () => { - interactionRoot?.render(); - await Promise.resolve(); - }); - - await act(async () => { - mockState.lastSurfaceProps?.onSave(); - await Promise.resolve(); - }); - - expect(mockState.lastSurfaceProps?.editorError).toBe(createTranslatorMock()('alert.silence.validation.name')); - - await act(async () => { - mockState.lastSurfaceProps?.onCloseEditor(); - await Promise.resolve(); - }); - - expect(mockState.lastSurfaceProps?.editorOpen).toBe(false); - expect(mockState.lastSurfaceProps?.editorError).toBeNull(); - expect(mockState.lastSurfaceProps?.editorErrorDetail).toBeNull(); - expect(mockState.lastSurfaceProps?.editorErrorContract).toBeNull(); - }); - - it('asks for confirmation before closing a dirty silence editor draft', async () => { - const { default: AlertSilencePage } = await import('./alert-silence-page'); - interactionContainer = document.createElement('div'); - document.body.appendChild(interactionContainer); - interactionRoot = createRoot(interactionContainer); - - await act(async () => { - interactionRoot?.render(); - await Promise.resolve(); - }); - - await act(async () => { - mockState.lastSurfaceProps?.onNew(); - await Promise.resolve(); - }); - - const draft = mockState.lastSurfaceProps?.draft; - expect(mockState.lastSurfaceProps?.editorOpen).toBe(true); - - await act(async () => { - mockState.lastSurfaceProps?.onDraftChange({ ...draft, name: 'Unsaved silence draft' }); - await Promise.resolve(); - }); - - await act(async () => { - mockState.lastSurfaceProps?.onCloseEditor(); - await Promise.resolve(); - }); - - expect(mockState.lastSurfaceProps?.editorOpen).toBe(true); - expect(interactionContainer.innerHTML).toContain('data-alert-silence-unsaved-cancel-state="open"'); - expect(interactionContainer.innerHTML).toContain('Discard unsaved silence changes?'); - }); - - it('focuses the first invalid silence editor field after local validation fails', () => { - const source = readFileSync(resolve(process.cwd(), 'app/alert/silence/alert-silence-page.tsx'), 'utf8'); - - expect(source).toContain('function focusAlertSilenceEditorField(field: AlertSilenceValidationField)'); - expect(source).toContain('const validationField = getAlertSilenceValidationField(draft)'); - expect(source).toContain('focusAlertSilenceEditorField(validationField)'); - expect(source).toContain('input[name="silence_name"]'); - expect(source).toContain('[data-alert-silence-label-selector] [data-hz-label-selector-draft-row="true"] input[data-hz-label-selector-key-input="searchable-key"]'); - }); - - it('keeps Angular no-selection batch delete warning before opening confirm', () => { - const source = readFileSync(resolve(process.cwd(), 'app/alert/silence/alert-silence-page.tsx'), 'utf8'); - const surfaceSource = readFileSync(resolve(process.cwd(), 'components/pages/alert-silence-surface.tsx'), 'utf8'); - - expect(source).toContain('if (checkedIds.length === 0)'); - expect(source).toContain("setEditorError(t('common.notify.no-select-delete'))"); - expect(source).toContain('setEditorMessage(null)'); - expect(source).toContain("setDeleteRequest({ kind: 'batch', ids: checkedIds })"); - expect(surfaceSource).toContain('data-alert-silence-delete-selected="toolbar"'); - expect(surfaceSource).toContain('data-alert-silence-action-feedback-owner="hertzbeat-ui-inline-feedback"'); - expect(surfaceSource).not.toContain('disabled={selectedCount === 0}'); - }); - - it('uses Angular delete notifications for confirmed delete feedback', () => { - const source = readFileSync(resolve(process.cwd(), 'app/alert/silence/alert-silence-page.tsx'), 'utf8'); - - expect(source).toContain('async function handleConfirmedDelete()'); - expect(source).toContain('deleteAlertSilencesFromFacade(api.alertSilences.delete, request.ids)'); - expect(source).toContain('deleteAlertSilenceFromFacade(api.alertSilences.delete, request.ids[0])'); - expect(source).toContain("setEditorMessage(t('common.notify.delete-success'))"); - expect(source).toContain("setEditorError(t('common.notify.delete-fail'))"); - expect(source).toContain('setEditorErrorDetail(error instanceof Error ? error.message : null)'); - expect(source).toContain("setEditorErrorContract('delete')"); - expect(source).not.toContain('apiMessageDelete'); - expect(source).not.toContain("setEditorMessage(t('common.delete-success'))"); - expect(source).not.toContain("t('common.delete-failed')"); - }); - - it('names the target and destructive action in silence delete confirmation', () => { - const source = readFileSync(resolve(process.cwd(), 'app/alert/silence/alert-silence-page.tsx'), 'utf8'); - - expect(source).toContain('const deleteTargetNames = deleteRequest?.ids'); - expect(source).toContain("t('alert.silence.delete.confirm.targets', { names: deleteTargetNames.join(', ') })"); - expect(source).toContain("t('alert.silence.delete.confirm.targets-more', { count: missingDeleteTargetCount })"); - expect(source).toContain("confirmLabel={t('alert.silence.delete.confirm.action')}"); - expect(source).not.toContain("confirmLabel={t('common.button.ok')}"); - }); - - it('uses Angular edit notifications for enable-toggle feedback', () => { - const source = readFileSync(resolve(process.cwd(), 'app/alert/silence/alert-silence-page.tsx'), 'utf8'); - const toggleSource = source.slice( - source.indexOf('async function handleToggleEnabled(silence?: AlertSilence)'), - source.indexOf('async function handleDelete(silenceId?: number)') - ); - - expect(toggleSource).toContain('async function handleToggleEnabled(silence?: AlertSilence)'); - expect(toggleSource).toContain('updateAlertSilenceEnabledFromFacade(api.alertSilences.update, target, !(target.enable ?? true))'); - expect(toggleSource).toContain("setEditorMessage(t('common.notify.edit-success'))"); - expect(toggleSource).toContain("setEditorError(t('common.notify.edit-fail'))"); - expect(toggleSource).toContain('setEditorErrorDetail(error instanceof Error ? error.message : null)'); - expect(toggleSource).toContain("setEditorErrorContract('enable')"); - expect(toggleSource).not.toContain('apiMessagePut'); - expect(toggleSource).not.toContain("setEditorMessage(t('common.save-success'))"); - expect(toggleSource).not.toContain("t('common.save-failed')"); - }); - - it('uses Angular create/edit notifications for editor save feedback', () => { - const source = readFileSync(resolve(process.cwd(), 'app/alert/silence/alert-silence-page.tsx'), 'utf8'); - const saveSource = source.slice( - source.indexOf('async function handleSave()'), - source.indexOf('async function handleToggleEnabled(silence?: AlertSilence)') - ); - - expect(saveSource).toContain('const isEdit = Boolean(draft.id)'); - expect(saveSource).toContain('updateAlertSilenceFromFacade(api.alertSilences.update, draft)'); - expect(saveSource).toContain('createAlertSilenceFromFacade(api.alertSilences.create, draft)'); - expect(saveSource).toContain("setEditorMessage(t(isEdit ? 'common.notify.edit-success' : 'common.notify.new-success'))"); - expect(saveSource).toContain('setCreatedOutsideMatchedViewNotice(!isEdit && useMatchedView)'); - expect(saveSource).toContain("setEditorError(t(isEdit ? 'common.notify.edit-fail' : 'common.notify.new-fail'))"); - expect(saveSource).toContain('setEditorErrorDetail(error instanceof Error ? error.message : null)'); - expect(saveSource).toContain("setEditorErrorContract('save')"); - expect(saveSource).not.toContain('apiMessagePost'); - expect(saveSource).not.toContain('apiMessagePut'); - expect(saveSource).not.toContain("setEditorMessage(t('common.save-success'))"); - expect(saveSource).not.toContain("t('common.save-failed')"); - }); - - it('keeps Angular created-outside-matched notice state for matched-view authoring', () => { - const source = readFileSync(resolve(process.cwd(), 'app/alert/silence/alert-silence-page.tsx'), 'utf8'); - const surfaceSource = readFileSync(resolve(process.cwd(), 'components/pages/alert-silence-surface.tsx'), 'utf8'); - - expect(source).toContain('const [createdOutsideMatchedViewNotice, setCreatedOutsideMatchedViewNotice] = useState(false)'); - expect(source).toContain('setCreatedOutsideMatchedViewNotice(false)'); - expect(source).toContain('createdOutsideMatchedViewNotice={createdOutsideMatchedViewNotice}'); - expect(surfaceSource).toContain('data-alert-silence-created-outside-matched="angular-authoring-notice"'); - expect(surfaceSource).toContain('data-alert-silence-created-outside-matched-owner="hertzbeat-ui-inline-feedback"'); - expect(surfaceSource).toContain('data-alert-silence-created-outside-matched-action="view-all"'); - }); - - it('keeps Angular entity alert common-label prefill for new silence authoring', () => { - const source = readFileSync(resolve(process.cwd(), 'app/alert/silence/alert-silence-page.tsx'), 'utf8'); - const surfaceSource = readFileSync(resolve(process.cwd(), 'components/pages/alert-silence-surface.tsx'), 'utf8'); - - expect(source).toContain('buildAlertSilenceEntityPrefillFromFacade('); - expect(source).toContain("entityId => api.entities.alerts(entityId, { pageIndex: 0, pageSize: 20, status: 'firing' })"); - expect(source).not.toContain("from '../../../lib/api-client'"); - expect(source).toContain("t('entity.noise-controls.authoring.silence.prefill-warning')"); - expect(source).toContain("t('entity.noise-controls.authoring.prefill-warning.no-entity-id')"); - expect(source).toContain('entityPrefillSource={entityPrefillSource}'); - expect(source).toContain('entityPrefillWarning={entityPrefillWarning}'); - expect(surfaceSource).toContain('data-alert-silence-entity-prefill='); - expect(surfaceSource).toContain("t('entity.noise-controls.authoring.silence.title')"); - expect(surfaceSource).toContain("t('entity.noise-controls.authoring.silence.prefill-success')"); - expect(surfaceSource).toContain('prefillWarning={entityPrefillWarning}'); - }); - - it('uses Angular edit failure notification for edit detail load fallback', () => { - const source = readFileSync(resolve(process.cwd(), 'app/alert/silence/alert-silence-page.tsx'), 'utf8'); - const editSource = source.slice( - source.indexOf('async function handleEdit(silenceId?: number)'), - source.indexOf('async function handleSave()') - ); - - expect(editSource).toContain('async function handleEdit(silenceId?: number)'); - expect(editSource).toContain('loadAlertSilenceDetailFromFacade(api.alertSilences.detail, targetId)'); - expect(editSource).toContain("setEditorError(error instanceof Error ? error.message : t('common.notify.edit-fail'))"); - expect(editSource).not.toContain('apiMessageGet'); - expect(editSource).not.toContain("t('common.load-failed')"); - }); -}); diff --git a/web-next/app/alert/silence/page.tsx b/web-next/app/alert/silence/page.tsx deleted file mode 100644 index 1010b9a744..0000000000 --- a/web-next/app/alert/silence/page.tsx +++ /dev/null @@ -1,14 +0,0 @@ -import React from 'react'; - -import AlertSilencePage from './alert-silence-page'; -import { readAlertSilenceRouteState, type AlertSilenceSearchParams } from '../../../lib/alert-silence/query-state'; - -export default async function AlertSilenceRoutePage({ - searchParams -}: { - searchParams?: Promise; -}) { - const resolvedSearchParams = await searchParams; - const routeState = readAlertSilenceRouteState(resolvedSearchParams); - return ; -} diff --git a/web-next/app/alerts/page.test.ts b/web-next/app/alerts/page.test.ts deleted file mode 100644 index 57d520da93..0000000000 --- a/web-next/app/alerts/page.test.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { describe, expect, it, vi } from 'vitest'; - -const redirect = vi.fn(); - -vi.mock('next/navigation', () => ({ - redirect -})); - -describe('alerts alias route', () => { - it('redirects alerts compatibility traffic to the main alert workbench', async () => { - redirect.mockImplementation((target: string) => { - throw new Error(`redirect:${target}`); - }); - - const { default: AlertsAliasPage } = await import('./page'); - - await expect(AlertsAliasPage({ searchParams: Promise.resolve({}) })).rejects.toThrow('redirect:/alert'); - expect(redirect).toHaveBeenCalledWith('/alert'); - }, 20000); - - it('preserves alert filters and machine context while stripping display-only labels', async () => { - redirect.mockImplementation((target: string) => { - throw new Error(`redirect:${target}`); - }); - - const { default: AlertsAliasPage } = await import('./page'); - - await expect( - AlertsAliasPage({ - searchParams: Promise.resolve({ - content: ' checkout ', - status: 'ACKNOWLEDGED', - severity: 'Warning', - entityId: '42', - entityName: 'Checkout API', - returnTo: '/entities/42?returnLabel=Checkout', - returnLabel: 'Checkout', - signal: 'logs' - }) - }) - ).rejects.toThrow( - 'redirect:/alert?search=checkout&status=acknowledged&severity=warning&entityId=42&entityName=Checkout+API&returnTo=%2Fentities%2F42&signal=logs' - ); - expect(redirect).toHaveBeenLastCalledWith( - '/alert?search=checkout&status=acknowledged&severity=warning&entityId=42&entityName=Checkout+API&returnTo=%2Fentities%2F42&signal=logs' - ); - }, 20000); -}); diff --git a/web-next/app/alerts/page.tsx b/web-next/app/alerts/page.tsx deleted file mode 100644 index d5f331ddf7..0000000000 --- a/web-next/app/alerts/page.tsx +++ /dev/null @@ -1,11 +0,0 @@ -import { redirect } from 'next/navigation'; -import { buildAlertCompatRouteUrlFromSearchParams, type SearchParamsRecord } from '../../lib/alert-manage/query-state'; - -export default async function AlertsAliasPage({ - searchParams -}: { - searchParams?: Promise; -}) { - const resolvedSearchParams = await searchParams; - redirect(buildAlertCompatRouteUrlFromSearchParams(resolvedSearchParams)); -} diff --git a/web-next/app/api/[...path]/route.test.ts b/web-next/app/api/[...path]/route.test.ts deleted file mode 100644 index bad114afd0..0000000000 --- a/web-next/app/api/[...path]/route.test.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { describe, expect, it, vi } from 'vitest'; - -const proxyBackendApiRequest = vi.hoisted(() => vi.fn()); - -vi.mock('@/lib/session-bff', () => ({ - proxyBackendApiRequest -})); - -import { DELETE, GET } from './route'; - -describe('catch-all API proxy route', () => { - it('preserves encoded reserved characters in backend paths', async () => { - proxyBackendApiRequest.mockResolvedValue(new Response(null, { status: 204 })); - const request = { - url: 'http://127.0.0.1:4200/api/signal/dashboard/signals%3Akey%2Fwith%3Freserved' - }; - - await DELETE(request as any, { - params: Promise.resolve({ - path: ['signal', 'dashboard', 'signals:key/with?reserved'] - }) - }); - - expect(proxyBackendApiRequest).toHaveBeenCalledWith( - request, - '/signal/dashboard/signals%3Akey%2Fwith%3Freserved' - ); - }); - - it('keeps query strings out of the proxied backend path', async () => { - proxyBackendApiRequest.mockResolvedValue(new Response(null, { status: 200 })); - const request = { - url: 'http://127.0.0.1:4200/api/logs/list?traceId=trace-1' - }; - - await GET(request as any, { - params: Promise.resolve({ - path: ['logs', 'list'] - }) - }); - - expect(proxyBackendApiRequest).toHaveBeenCalledWith(request, '/logs/list'); - }); -}); diff --git a/web-next/app/api/[...path]/route.ts b/web-next/app/api/[...path]/route.ts deleted file mode 100644 index a4e0380233..0000000000 --- a/web-next/app/api/[...path]/route.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { NextRequest } from 'next/server'; -import { proxyBackendApiRequest } from '@/lib/session-bff'; - -export const dynamic = 'force-dynamic'; - -type RouteContext = { - params: Promise<{ path?: string[] }>; -}; - -function backendPathFromRequest(request: NextRequest) { - const pathname = new URL(request.url).pathname; - const apiPrefix = '/api'; - if (pathname === apiPrefix) { - return '/'; - } - if (pathname.startsWith(`${apiPrefix}/`)) { - return pathname.slice(apiPrefix.length); - } - return pathname; -} - -async function proxy(request: NextRequest, context: RouteContext) { - await context.params; - return proxyBackendApiRequest(request, backendPathFromRequest(request)); -} - -export const GET = proxy; -export const POST = proxy; -export const PUT = proxy; -export const PATCH = proxy; -export const DELETE = proxy; diff --git a/web-next/app/api/account/auth/form/route.ts b/web-next/app/api/account/auth/form/route.ts deleted file mode 100644 index a61090e34f..0000000000 --- a/web-next/app/api/account/auth/form/route.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { NextRequest, NextResponse } from 'next/server'; -import { - applySessionCookies, - buildBackendApiUrl, - clearSessionCookies, - readJsonPayload, - sanitizeSessionPayload -} from '@/lib/session-bff'; - -export const dynamic = 'force-dynamic'; - -export async function POST(request: NextRequest) { - let response: NextResponse; - - try { - const upstream = await fetch(buildBackendApiUrl('/account/auth/form'), { - method: 'POST', - headers: { - 'Content-Type': request.headers.get('Content-Type') || 'application/json', - ...(request.headers.get('Accept-Language') ? { 'Accept-Language': request.headers.get('Accept-Language') as string } : {}) - }, - body: await request.text(), - cache: 'no-store' - }); - const payload = await readJsonPayload(upstream); - response = NextResponse.json(sanitizeSessionPayload(payload), { status: upstream.status }); - - if (upstream.ok && payload.code === 0 && payload.data) { - applySessionCookies(response, { - token: typeof payload.data.token === 'string' ? payload.data.token : undefined, - refreshToken: typeof payload.data.refreshToken === 'string' ? payload.data.refreshToken : undefined - }, request); - } else { - clearSessionCookies(response, request); - } - } catch { - response = NextResponse.json({ code: 503, data: null }, { status: 503 }); - clearSessionCookies(response, request); - } - - return response; -} diff --git a/web-next/app/api/account/auth/refresh/route.ts b/web-next/app/api/account/auth/refresh/route.ts deleted file mode 100644 index d156bc16dc..0000000000 --- a/web-next/app/api/account/auth/refresh/route.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { NextRequest, NextResponse } from 'next/server'; -import { - HB_UI_REFRESH_COOKIE, - applySessionCookies, - buildBackendApiUrl, - clearSessionCookies, - readJsonPayload, - readSessionCookieValue, - sanitizeSessionPayload -} from '@/lib/session-bff'; - -export const dynamic = 'force-dynamic'; - -export async function POST(request: NextRequest) { - const refreshToken = readSessionCookieValue(request, HB_UI_REFRESH_COOKIE); - if (!refreshToken) { - const response = NextResponse.json({ code: 401, msg: 'Missing refresh session', data: null }, { status: 401 }); - clearSessionCookies(response, request); - return response; - } - - const upstream = await fetch(buildBackendApiUrl('/account/auth/refresh'), { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - ...(request.headers.get('Accept-Language') ? { 'Accept-Language': request.headers.get('Accept-Language') as string } : {}) - }, - body: JSON.stringify({ token: refreshToken }), - cache: 'no-store' - }); - const payload = await readJsonPayload(upstream); - const response = NextResponse.json(sanitizeSessionPayload(payload), { status: upstream.status }); - - if (upstream.ok && payload.code === 0 && payload.data) { - applySessionCookies(response, { - token: typeof payload.data.token === 'string' ? payload.data.token : undefined, - refreshToken: typeof payload.data.refreshToken === 'string' ? payload.data.refreshToken : refreshToken - }, request); - } else { - clearSessionCookies(response, request); - } - - return response; -} diff --git a/web-next/app/api/account/session/route.ts b/web-next/app/api/account/session/route.ts deleted file mode 100644 index 668e889000..0000000000 --- a/web-next/app/api/account/session/route.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { NextRequest, NextResponse } from 'next/server'; -import { - HB_UI_ACCESS_COOKIE, - HB_UI_REFRESH_COOKIE, - clearSessionCookies, - readSessionCookieValue -} from '@/lib/session-bff'; - -export const dynamic = 'force-dynamic'; - -export async function GET(request: NextRequest) { - return NextResponse.json({ - authenticated: Boolean( - readSessionCookieValue(request, HB_UI_ACCESS_COOKIE) || - readSessionCookieValue(request, HB_UI_REFRESH_COOKIE) - ) - }); -} - -export async function DELETE(request: NextRequest) { - const response = NextResponse.json({ authenticated: false }); - clearSessionCookies(response, request); - return response; -} diff --git a/web-next/app/api/actions/approval-drafts/[draftId]/decision/route.test.ts b/web-next/app/api/actions/approval-drafts/[draftId]/decision/route.test.ts deleted file mode 100644 index bbe2025bd1..0000000000 --- a/web-next/app/api/actions/approval-drafts/[draftId]/decision/route.test.ts +++ /dev/null @@ -1,106 +0,0 @@ -import { NextRequest } from 'next/server'; -import { afterEach, describe, expect, it, vi } from 'vitest'; -import { POST } from './route'; -import { resetFallbackApprovalDraftsForTest } from '../../../../../../lib/actions-approval-draft-fallback-store'; - -function decisionRequest(body: unknown) { - return new NextRequest('http://localhost/api/actions/approval-drafts/draft-1/decision', { - method: 'POST', - body: JSON.stringify(body), - headers: { - 'content-type': 'application/json', - cookie: 'hb_ui_access=session-token' - } - }); -} - -function params(draftId = 'draft-1') { - return { params: Promise.resolve({ draftId }) }; -} - -afterEach(() => { - vi.restoreAllMocks(); - resetFallbackApprovalDraftsForTest(); -}); - -describe('actions approval draft decision API', () => { - it('records a manager-backed non-executing approval decision when the manager contract is live', async () => { - vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(JSON.stringify({ - code: 0, - data: { - draftId: 'draft-1', - decision: 'approved', - reviewer: 'ops-lead', - state: 'approval-draft-approved', - executionState: 'not-executed', - executionAllowed: false, - adapterOwner: 'manager-action-approval-draft' - } - }), { status: 200, headers: { 'content-type': 'application/json' } })); - - const response = await POST(decisionRequest({ - decision: 'approved', - reviewer: 'ops-lead', - reason: 'reviewed rollback evidence', - executionMode: 'manual-approval-draft-only', - executionAllowed: false - }), params()); - const payload = await response.json(); - - expect(response.status).toBe(202); - expect(payload).toMatchObject({ - draftId: 'draft-1', - decision: 'approved', - state: 'approval-draft-approved', - executionState: 'not-executed', - executionAllowed: false, - adapterOwner: 'manager-action-approval-draft', - managerBacked: true - }); - expect(globalThis.fetch).toHaveBeenCalledWith( - 'http://127.0.0.1:1157/api/actions/approval-drafts/draft-1/decision', - expect.objectContaining({ method: 'POST', cache: 'no-store' }) - ); - }); - - it('falls back to a local non-executing rejected decision when the manager route is unavailable', async () => { - vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response('missing', { status: 404 })); - - const response = await POST(decisionRequest({ - decision: 'rejected', - reviewer: 'ops-lead', - reason: 'risk too high', - executionMode: 'manual-approval-draft-only', - executionAllowed: false - }), params()); - const payload = await response.json(); - - expect(response.status).toBe(202); - expect(payload).toMatchObject({ - draftId: 'draft-1', - decision: 'rejected', - reviewer: 'ops-lead', - reason: 'risk too high', - state: 'approval-draft-rejected', - executionState: 'not-executed', - executionAllowed: false, - adapterOwner: 'next-actions-approval-decision-bff', - managerBacked: false - }); - }); - - it('rejects decisions that try to execute an action', async () => { - const response = await POST(decisionRequest({ - decision: 'approved', - executionMode: 'manual-approval-draft-only', - executionAllowed: true - }), params()); - const payload = await response.json(); - - expect(response.status).toBe(409); - expect(payload).toEqual({ - state: 'blocked', - message: 'approval decisions must stay manual and non-executing' - }); - }); -}); diff --git a/web-next/app/api/actions/approval-drafts/[draftId]/decision/route.ts b/web-next/app/api/actions/approval-drafts/[draftId]/decision/route.ts deleted file mode 100644 index 8acddbf9ff..0000000000 --- a/web-next/app/api/actions/approval-drafts/[draftId]/decision/route.ts +++ /dev/null @@ -1,120 +0,0 @@ -import { NextRequest, NextResponse } from 'next/server'; -import { buildBackendApiUrl, HB_UI_ACCESS_COOKIE, readJsonPayload, readSessionCookieValue } from '../../../../../../lib/session-bff'; -import { decideFallbackApprovalDraft } from '../../../../../../lib/actions-approval-draft-fallback-store'; - -export const dynamic = 'force-dynamic'; - -type ApprovalDecisionPayload = { - decision?: unknown; - reviewer?: unknown; - reason?: unknown; - executionMode?: unknown; - executionAllowed?: unknown; -}; - -type ManagerMessagePayload = { - code?: number; - msg?: string; - data?: Record | null; -}; - -function text(value: unknown) { - return typeof value === 'string' && value.trim() ? value.trim() : undefined; -} - -function validateDecisionPayload(payload: ApprovalDecisionPayload) { - const decision = text(payload.decision); - if (decision !== 'approved' && decision !== 'rejected') { - return { error: 'approval decision must be approved or rejected' }; - } - if (payload.executionAllowed !== false || payload.executionMode !== 'manual-approval-draft-only') { - return { error: 'approval decisions must stay manual and non-executing', blocked: true }; - } - return { decision }; -} - -function managerHeaders(request: NextRequest) { - const headers = new Headers(); - headers.set('content-type', 'application/json'); - const accessToken = readSessionCookieValue(request, HB_UI_ACCESS_COOKIE); - if (accessToken) { - headers.set('authorization', `Bearer ${accessToken}`); - } - const acceptLanguage = request.headers.get('accept-language'); - if (acceptLanguage) { - headers.set('accept-language', acceptLanguage); - } - return headers; -} - -function normalizeManagerDecision(data: Record) { - return { - ...data, - executionAllowed: false, - executionState: text(data.executionState) || 'not-executed', - adapterOwner: text(data.adapterOwner) || 'manager-action-approval-draft', - managerBacked: true - }; -} - -function fallbackDecision(draftId: string, payload: ApprovalDecisionPayload, decision: string) { - return decideFallbackApprovalDraft( - draftId, - decision, - text(payload.reviewer) || 'hertzbeat-ui-operator', - text(payload.reason) || 'manual approval decision from actions workbench' - ); -} - -async function postManagerDecision(request: NextRequest, draftId: string, payload: ApprovalDecisionPayload) { - try { - const upstream = await fetch( - buildBackendApiUrl(`/actions/approval-drafts/${encodeURIComponent(draftId)}/decision`), - { - method: 'POST', - headers: managerHeaders(request), - body: JSON.stringify(payload), - cache: 'no-store' - } - ); - if (upstream.status === 404) return null; - const managerPayload = await readJsonPayload(upstream) as ManagerMessagePayload; - if (!upstream.ok || managerPayload.code !== 0 || !managerPayload.data) { - return null; - } - return normalizeManagerDecision(managerPayload.data); - } catch { - return null; - } -} - -export async function POST( - request: NextRequest, - { params }: { params: Promise<{ draftId?: string }> } -) { - const { draftId } = await params; - if (!draftId) { - return NextResponse.json({ state: 'invalid-request', message: 'draftId is required' }, { status: 400 }); - } - - let payload: ApprovalDecisionPayload; - try { - payload = await request.json() as ApprovalDecisionPayload; - } catch { - return NextResponse.json({ state: 'invalid-request', message: 'invalid approval decision payload' }, { status: 400 }); - } - - const validation = validateDecisionPayload(payload); - if (validation.error) { - return NextResponse.json( - { state: validation.blocked ? 'blocked' : 'invalid-request', message: validation.error }, - { status: validation.blocked ? 409 : 400 } - ); - } - - const managerDecision = await postManagerDecision(request, draftId, payload); - return NextResponse.json( - managerDecision || fallbackDecision(draftId, payload, validation.decision as string), - { status: 202 } - ); -} diff --git a/web-next/app/api/actions/approval-drafts/route.test.ts b/web-next/app/api/actions/approval-drafts/route.test.ts deleted file mode 100644 index 0422130264..0000000000 --- a/web-next/app/api/actions/approval-drafts/route.test.ts +++ /dev/null @@ -1,258 +0,0 @@ -import { NextRequest } from 'next/server'; -import { afterEach, describe, expect, it, vi } from 'vitest'; -import { GET, POST } from './route'; -import { POST as POST_DECISION } from './[draftId]/decision/route'; -import { resetFallbackApprovalDraftsForTest } from '../../../../lib/actions-approval-draft-fallback-store'; - -function approvalDraftRequest(body: unknown) { - return new NextRequest('http://localhost/api/actions/approval-drafts', { - method: 'POST', - body: JSON.stringify(body), - headers: { 'content-type': 'application/json' } - }); -} - -function approvalDraftListRequest() { - return new NextRequest('http://localhost/api/actions/approval-drafts?limit=8', { - method: 'GET', - headers: { cookie: 'hb_ui_access=session-token' } - }); -} - -function approvalDraftDecisionRequest(draftId: string) { - return new NextRequest(`http://localhost/api/actions/approval-drafts/${draftId}/decision`, { - method: 'POST', - body: JSON.stringify({ - decision: 'approved', - reviewer: 'ops-lead', - reason: 'reviewed fallback lifecycle evidence', - executionMode: 'manual-approval-draft-only', - executionAllowed: false - }), - headers: { - 'content-type': 'application/json', - cookie: 'hb_ui_access=session-token' - } - }); -} - -function approvalDraftDecisionParams(draftId: string) { - return { params: Promise.resolve({ draftId }) }; -} - -afterEach(() => { - vi.restoreAllMocks(); - resetFallbackApprovalDraftsForTest(); -}); - -describe('actions approval draft API', () => { - it('creates a manager-backed non-executing approval draft when the manager contract is live', async () => { - vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(JSON.stringify({ - code: 0, - data: { - draftId: 'manager-draft-1', - state: 'approval-draft-created', - executionState: 'not-executed', - executionAllowed: false, - adapterOwner: 'manager-action-approval-draft', - actionId: 'suggest-restart-checkout', - catalogId: 'restart-checkout' - } - }), { status: 200, headers: { 'content-type': 'application/json' } })); - - const response = await POST(approvalDraftRequest({ - actionId: 'suggest-restart-checkout', - catalogId: 'restart-checkout', - confirmation: 'manual-required', - executionMode: 'manual-approval-draft-only', - executionAllowed: false, - context: { - source: 'alert', - entityId: 'service:commerce/checkout', - traceId: 'trace-123' - }, - evidenceHref: '/alert?status=firing' - })); - const payload = await response.json(); - - expect(response.status).toBe(202); - expect(payload).toMatchObject({ - draftId: 'manager-draft-1', - state: 'approval-draft-created', - executionState: 'not-executed', - executionAllowed: false, - adapterOwner: 'manager-action-approval-draft', - managerBacked: true, - actionId: 'suggest-restart-checkout', - catalogId: 'restart-checkout' - }); - expect(globalThis.fetch).toHaveBeenCalledWith( - 'http://127.0.0.1:1157/api/actions/approval-drafts', - expect.objectContaining({ method: 'POST', cache: 'no-store' }) - ); - }); - - it('falls back to a local non-executing approval draft when the manager route is unavailable', async () => { - vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response('missing', { status: 404 })); - - const response = await POST(approvalDraftRequest({ - actionId: 'suggest-restart-checkout', - catalogId: 'restart-checkout', - confirmation: 'manual-required', - executionMode: 'manual-approval-draft-only', - executionAllowed: false, - context: { - source: 'alert', - entityId: 'service:commerce/checkout', - traceId: 'trace-123' - }, - evidenceHref: '/alert?status=firing' - })); - const payload = await response.json(); - - expect(response.status).toBe(202); - expect(payload).toMatchObject({ - state: 'approval-draft-created', - executionState: 'not-executed', - executionAllowed: false, - adapterOwner: 'next-actions-approval-draft-bff', - managerBacked: false, - actionId: 'suggest-restart-checkout', - catalogId: 'restart-checkout' - }); - expect(payload.draftId).toMatch(/^approval-draft-suggest-restart-checkout-/); - }); - - it('lists locally created fallback approval drafts until the manager read contract is live', async () => { - vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response('missing', { status: 404 })); - - const createResponse = await POST(approvalDraftRequest({ - actionId: 'suggest-restart-checkout', - catalogId: 'restart-checkout', - confirmation: 'manual-required', - executionMode: 'manual-approval-draft-only', - executionAllowed: false, - context: { - source: 'alert', - entityId: 'service:commerce/checkout', - traceId: 'trace-123' - }, - evidenceHref: '/alert?status=firing' - })); - const created = await createResponse.json(); - const listResponse = await GET(approvalDraftListRequest()); - const listed = await listResponse.json(); - - expect(listed).toMatchObject({ - state: 'fallback-local-drafts', - adapterOwner: 'next-actions-approval-draft-bff', - managerBacked: false, - drafts: [ - { - draftId: created.draftId, - state: 'approval-draft-created', - executionState: 'not-executed', - executionAllowed: false, - actionId: 'suggest-restart-checkout', - catalogId: 'restart-checkout' - } - ] - }); - }); - - it('lists locally approved fallback approval drafts from the shared runtime store', async () => { - vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response('missing', { status: 404 })); - - const createResponse = await POST(approvalDraftRequest({ - actionId: 'suggest-restart-checkout', - catalogId: 'restart-checkout', - confirmation: 'manual-required', - executionMode: 'manual-approval-draft-only', - executionAllowed: false, - context: { - source: 'alert', - entityId: 'service:commerce/checkout', - traceId: 'trace-123' - }, - evidenceHref: '/alert?status=firing' - })); - const created = await createResponse.json(); - await POST_DECISION( - approvalDraftDecisionRequest(created.draftId), - approvalDraftDecisionParams(created.draftId) - ); - - const listResponse = await GET(approvalDraftListRequest()); - const listed = await listResponse.json(); - - expect(listed).toMatchObject({ - state: 'fallback-local-drafts', - adapterOwner: 'next-actions-approval-draft-bff', - managerBacked: false, - drafts: [ - { - draftId: created.draftId, - state: 'approval-draft-approved', - executionState: 'not-executed', - executionAllowed: false, - adapterOwner: 'next-actions-approval-decision-bff', - actionId: 'suggest-restart-checkout', - catalogId: 'restart-checkout' - } - ] - }); - }); - - it('rejects payloads that try to bypass manual non-execution guardrails', async () => { - const response = await POST(approvalDraftRequest({ - actionId: 'suggest-restart-checkout', - catalogId: 'restart-checkout', - confirmation: 'manual-required', - executionMode: 'execute-now', - executionAllowed: true - })); - const payload = await response.json(); - - expect(response.status).toBe(409); - expect(payload).toEqual({ - state: 'blocked', - message: 'approval drafts must stay manual and non-executing' - }); - }); - - it('lists manager-backed approval drafts when the manager read contract is live', async () => { - vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(JSON.stringify({ - code: 0, - data: [ - { - draftId: 'manager-draft-1', - state: 'approval-draft-created', - executionState: 'not-executed', - executionAllowed: false, - adapterOwner: 'manager-action-approval-draft' - } - ] - }), { status: 200, headers: { 'content-type': 'application/json' } })); - - const response = await GET(approvalDraftListRequest()); - const payload = await response.json(); - - expect(payload).toMatchObject({ - state: 'manager-approval-drafts', - adapterOwner: 'manager-action-approval-draft', - managerBacked: true, - drafts: [ - { - draftId: 'manager-draft-1', - executionState: 'not-executed', - executionAllowed: false, - managerBacked: true - } - ] - }); - expect(globalThis.fetch).toHaveBeenCalledWith( - 'http://127.0.0.1:1157/api/actions/approval-drafts?limit=8', - expect.objectContaining({ method: 'GET', cache: 'no-store' }) - ); - }); -}); diff --git a/web-next/app/api/actions/approval-drafts/route.ts b/web-next/app/api/actions/approval-drafts/route.ts deleted file mode 100644 index e4c328a417..0000000000 --- a/web-next/app/api/actions/approval-drafts/route.ts +++ /dev/null @@ -1,162 +0,0 @@ -import { NextRequest, NextResponse } from 'next/server'; -import { buildBackendApiUrl, HB_UI_ACCESS_COOKIE, readJsonPayload, readSessionCookieValue } from '../../../../lib/session-bff'; -import { listFallbackApprovalDrafts, saveFallbackApprovalDraft } from '../../../../lib/actions-approval-draft-fallback-store'; - -export const dynamic = 'force-dynamic'; - -type ApprovalDraftPayload = { - draftId?: unknown; - actionId?: unknown; - catalogId?: unknown; - confirmation?: unknown; - executionMode?: unknown; - executionAllowed?: unknown; - context?: unknown; - evidenceHref?: unknown; -}; - -type ManagerMessagePayload = { - code?: number; - msg?: string; - data?: Record | Record[] | null; -}; - -function stableDraftSuffix(value: string) { - let hash = 0; - for (let index = 0; index < value.length; index += 1) { - hash = (hash * 31 + value.charCodeAt(index)) % 100000; - } - return String(hash).padStart(5, '0'); -} - -function text(value: unknown) { - return typeof value === 'string' && value.trim() ? value.trim() : undefined; -} - -function validatePayload(payload: ApprovalDraftPayload) { - const actionId = text(payload.actionId); - const catalogId = text(payload.catalogId); - if (!actionId || !catalogId) { - return { error: 'actionId and catalogId are required' }; - } - - if (payload.confirmation !== 'manual-required' || payload.executionMode !== 'manual-approval-draft-only' || payload.executionAllowed !== false) { - return { error: 'approval drafts must stay manual and non-executing', blocked: true }; - } - - return { actionId, catalogId }; -} - -function fallbackDraft(payload: ApprovalDraftPayload, actionId: string, catalogId: string) { - const context = payload.context && typeof payload.context === 'object' ? payload.context : {}; - const fingerprint = stableDraftSuffix(JSON.stringify({ actionId, catalogId, context, evidenceHref: payload.evidenceHref })); - - return saveFallbackApprovalDraft({ - draftId: text(payload.draftId) || `approval-draft-${actionId}-${fingerprint}`, - state: 'approval-draft-created', - executionState: 'not-executed', - executionAllowed: false, - adapterOwner: 'next-actions-approval-draft-bff', - managerBacked: false, - actionId, - catalogId - }); -} - -function managerHeaders(request: NextRequest) { - const headers = new Headers(); - headers.set('content-type', 'application/json'); - const accessToken = readSessionCookieValue(request, HB_UI_ACCESS_COOKIE); - if (accessToken) { - headers.set('authorization', `Bearer ${accessToken}`); - } - const acceptLanguage = request.headers.get('accept-language'); - if (acceptLanguage) { - headers.set('accept-language', acceptLanguage); - } - return headers; -} - -function normalizeManagerDraft(data: Record) { - return { - ...data, - executionAllowed: false, - executionState: text(data.executionState) || 'not-executed', - adapterOwner: text(data.adapterOwner) || 'manager-action-approval-draft', - managerBacked: true - }; -} - -async function postManagerApprovalDraft(request: NextRequest, payload: ApprovalDraftPayload) { - try { - const upstream = await fetch(buildBackendApiUrl('/actions/approval-drafts'), { - method: 'POST', - headers: managerHeaders(request), - body: JSON.stringify(payload), - cache: 'no-store' - }); - if (upstream.status === 404) return null; - const managerPayload = await readJsonPayload(upstream) as ManagerMessagePayload; - if (!upstream.ok || managerPayload.code !== 0 || !managerPayload.data || Array.isArray(managerPayload.data)) { - return null; - } - return normalizeManagerDraft(managerPayload.data); - } catch { - return null; - } -} - -async function getManagerApprovalDrafts(request: NextRequest) { - const requestUrl = new URL(request.url); - try { - const upstream = await fetch(buildBackendApiUrl('/actions/approval-drafts', requestUrl.search), { - method: 'GET', - headers: managerHeaders(request), - cache: 'no-store' - }); - if (upstream.status === 404) return null; - const managerPayload = await readJsonPayload(upstream) as ManagerMessagePayload; - if (!upstream.ok || managerPayload.code !== 0 || !Array.isArray(managerPayload.data)) { - return null; - } - return managerPayload.data.map(item => normalizeManagerDraft(item)); - } catch { - return null; - } -} - -export async function POST(request: NextRequest) { - let payload: ApprovalDraftPayload; - try { - payload = await request.json() as ApprovalDraftPayload; - } catch { - return NextResponse.json({ state: 'invalid-request', message: 'invalid approval draft payload' }, { status: 400 }); - } - - const validation = validatePayload(payload); - if (validation.error) { - return NextResponse.json( - { state: validation.blocked ? 'blocked' : 'invalid-request', message: validation.error }, - { status: validation.blocked ? 409 : 400 } - ); - } - - const managerDraft = await postManagerApprovalDraft(request, payload); - return NextResponse.json( - managerDraft || fallbackDraft(payload, validation.actionId as string, validation.catalogId as string), - { status: 202 } - ); -} - -export async function GET(request: NextRequest) { - const managerDrafts = await getManagerApprovalDrafts(request); - const requestUrl = new URL(request.url); - const limit = Number(requestUrl.searchParams.get('limit') || '8'); - const fallbackDrafts = managerDrafts ? [] : listFallbackApprovalDrafts(Number.isFinite(limit) ? limit : 8); - return NextResponse.json({ - state: managerDrafts ? 'manager-approval-drafts' : fallbackDrafts.length > 0 ? 'fallback-local-drafts' : 'fallback-empty', - adapterOwner: managerDrafts ? 'manager-action-approval-draft' : 'next-actions-approval-draft-bff', - managerBacked: Boolean(managerDrafts), - drafts: managerDrafts || fallbackDrafts - }); -} diff --git a/web-next/app/api/actions/catalog/route.test.ts b/web-next/app/api/actions/catalog/route.test.ts deleted file mode 100644 index c3aebd9fb9..0000000000 --- a/web-next/app/api/actions/catalog/route.test.ts +++ /dev/null @@ -1,155 +0,0 @@ -import { NextRequest } from 'next/server'; -import { afterEach, describe, expect, it, vi } from 'vitest'; -import { GET, POST } from './route'; - -function catalogPostRequest(body: unknown) { - return new NextRequest('http://localhost/api/actions/catalog', { - method: 'POST', - body: JSON.stringify(body), - headers: { - 'content-type': 'application/json', - cookie: 'hb_ui_access=session-token' - } - }); -} - -function catalogListRequest() { - return new NextRequest('http://localhost/api/actions/catalog?limit=8', { - method: 'GET', - headers: { cookie: 'hb_ui_access=session-token' } - }); -} - -afterEach(() => { - vi.restoreAllMocks(); -}); - -describe('actions catalog API', () => { - it('saves a manager-backed manual-only catalog item when the manager contract is live', async () => { - vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(JSON.stringify({ - code: 0, - data: { - catalogId: 'restart-checkout', - name: 'Restart checkout service', - risk: 'high', - executionMode: 'manual-approval-draft-only', - executionAllowed: false, - adapterOwner: 'manager-action-catalog', - status: 'catalog-item-ready' - } - }), { status: 200, headers: { 'content-type': 'application/json' } })); - - const response = await POST(catalogPostRequest({ - catalogId: 'restart-checkout', - name: 'Restart checkout service', - risk: 'high', - executionMode: 'manual-approval-draft-only', - executionAllowed: false, - metadata: { playbook: 'checkout-restart' } - })); - const payload = await response.json(); - - expect(response.status).toBe(200); - expect(payload).toMatchObject({ - catalogId: 'restart-checkout', - name: 'Restart checkout service', - risk: 'high', - executionMode: 'manual-approval-draft-only', - executionAllowed: false, - adapterOwner: 'manager-action-catalog', - status: 'catalog-item-ready', - managerBacked: true - }); - expect(globalThis.fetch).toHaveBeenCalledWith( - 'http://127.0.0.1:1157/api/actions/catalog', - expect.objectContaining({ method: 'POST', cache: 'no-store' }) - ); - }); - - it('falls back to a local non-executing non-persisted catalog item when manager is unavailable', async () => { - vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response('missing', { status: 404 })); - - const response = await POST(catalogPostRequest({ - catalogId: 'restart-checkout', - name: 'Restart checkout service', - risk: 'high', - category: 'remediation', - scope: 'service:commerce/checkout', - owner: 'sre', - executionAllowed: false, - metadata: { playbook: 'checkout-restart' } - })); - const payload = await response.json(); - - expect(response.status).toBe(202); - expect(payload).toMatchObject({ - catalogId: 'restart-checkout', - name: 'Restart checkout service', - risk: 'high', - category: 'remediation', - scope: 'service:commerce/checkout', - owner: 'sre', - executionMode: 'manual-approval-draft-only', - executionAllowed: false, - adapterOwner: 'next-actions-catalog-bff', - status: 'catalog-item-fallback-not-persisted', - managerBacked: false, - metadata: { playbook: 'checkout-restart' } - }); - }); - - it('rejects catalog payloads that try to enable direct execution', async () => { - const response = await POST(catalogPostRequest({ - catalogId: 'restart-checkout', - name: 'Restart checkout service', - risk: 'high', - executionMode: 'execute-now', - executionAllowed: true - })); - const payload = await response.json(); - - expect(response.status).toBe(409); - expect(payload).toEqual({ - state: 'blocked', - message: 'action catalog items must stay manual and non-executing' - }); - }); - - it('lists manager-backed catalog items when the manager read contract is live', async () => { - vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(JSON.stringify({ - code: 0, - data: [ - { - catalogId: 'restart-checkout', - name: 'Restart checkout service', - risk: 'high', - executionMode: 'manual-approval-draft-only', - executionAllowed: false, - adapterOwner: 'manager-action-catalog', - status: 'catalog-item-ready' - } - ] - }), { status: 200, headers: { 'content-type': 'application/json' } })); - - const response = await GET(catalogListRequest()); - const payload = await response.json(); - - expect(payload).toMatchObject({ - state: 'manager-action-catalog', - adapterOwner: 'manager-action-catalog', - managerBacked: true, - items: [ - { - catalogId: 'restart-checkout', - executionMode: 'manual-approval-draft-only', - executionAllowed: false, - managerBacked: true - } - ] - }); - expect(globalThis.fetch).toHaveBeenCalledWith( - 'http://127.0.0.1:1157/api/actions/catalog?limit=8', - expect.objectContaining({ method: 'GET', cache: 'no-store' }) - ); - }); -}); diff --git a/web-next/app/api/actions/catalog/route.ts b/web-next/app/api/actions/catalog/route.ts deleted file mode 100644 index 58ee2ce4db..0000000000 --- a/web-next/app/api/actions/catalog/route.ts +++ /dev/null @@ -1,165 +0,0 @@ -import { NextRequest, NextResponse } from 'next/server'; -import { buildBackendApiUrl, HB_UI_ACCESS_COOKIE, readJsonPayload, readSessionCookieValue } from '../../../../lib/session-bff'; - -export const dynamic = 'force-dynamic'; - -type ActionCatalogPayload = { - catalogId?: unknown; - name?: unknown; - category?: unknown; - scope?: unknown; - owner?: unknown; - risk?: unknown; - executionMode?: unknown; - executionAllowed?: unknown; - metadata?: unknown; -}; - -type ManagerMessagePayload = { - code?: number; - msg?: string; - data?: Record | Record[] | null; -}; - -function text(value: unknown) { - return typeof value === 'string' && value.trim() ? value.trim() : undefined; -} - -function validatePayload(payload: ActionCatalogPayload) { - const catalogId = text(payload.catalogId); - const name = text(payload.name); - const risk = text(payload.risk); - if (!catalogId || !name || !risk) { - return { error: 'catalogId, name and risk are required' }; - } - - const executionMode = text(payload.executionMode); - if ((executionMode && executionMode !== 'manual-approval-draft-only') || payload.executionAllowed === true) { - return { error: 'action catalog items must stay manual and non-executing', blocked: true }; - } - - return { catalogId, name, risk }; -} - -function managerHeaders(request: NextRequest) { - const headers = new Headers(); - headers.set('content-type', 'application/json'); - const accessToken = readSessionCookieValue(request, HB_UI_ACCESS_COOKIE); - if (accessToken) { - headers.set('authorization', `Bearer ${accessToken}`); - } - const acceptLanguage = request.headers.get('accept-language'); - if (acceptLanguage) { - headers.set('accept-language', acceptLanguage); - } - return headers; -} - -function normalizeManagerCatalogItem(data: Record) { - return { - ...data, - executionAllowed: false, - executionMode: text(data.executionMode) || 'manual-approval-draft-only', - adapterOwner: text(data.adapterOwner) || 'manager-action-catalog', - status: text(data.status) || 'catalog-item-ready', - managerBacked: true - }; -} - -function fallbackCatalogItem(payload: ActionCatalogPayload, catalogId: string, name: string, risk: string) { - const metadata = payload.metadata && typeof payload.metadata === 'object' ? payload.metadata : {}; - return { - catalogId, - name, - risk, - category: text(payload.category), - scope: text(payload.scope), - owner: text(payload.owner), - executionMode: 'manual-approval-draft-only', - executionAllowed: false, - adapterOwner: 'next-actions-catalog-bff', - status: 'catalog-item-fallback-not-persisted', - managerBacked: false, - metadata - }; -} - -async function postManagerCatalogItem(request: NextRequest, payload: ActionCatalogPayload) { - try { - const upstream = await fetch(buildBackendApiUrl('/actions/catalog'), { - method: 'POST', - headers: managerHeaders(request), - body: JSON.stringify({ - ...payload, - executionMode: 'manual-approval-draft-only', - executionAllowed: false - }), - cache: 'no-store' - }); - if (upstream.status === 404) return null; - const managerPayload = await readJsonPayload(upstream) as ManagerMessagePayload; - if (!upstream.ok || managerPayload.code !== 0 || !managerPayload.data || Array.isArray(managerPayload.data)) { - return null; - } - return normalizeManagerCatalogItem(managerPayload.data); - } catch { - return null; - } -} - -async function getManagerCatalogItems(request: NextRequest) { - const requestUrl = new URL(request.url); - try { - const upstream = await fetch(buildBackendApiUrl('/actions/catalog', requestUrl.search), { - method: 'GET', - headers: managerHeaders(request), - cache: 'no-store' - }); - if (upstream.status === 404) return null; - const managerPayload = await readJsonPayload(upstream) as ManagerMessagePayload; - if (!upstream.ok || managerPayload.code !== 0 || !Array.isArray(managerPayload.data)) { - return null; - } - return managerPayload.data.map(item => normalizeManagerCatalogItem(item)); - } catch { - return null; - } -} - -export async function POST(request: NextRequest) { - let payload: ActionCatalogPayload; - try { - payload = await request.json() as ActionCatalogPayload; - } catch { - return NextResponse.json({ state: 'invalid-request', message: 'invalid action catalog payload' }, { status: 400 }); - } - - const validation = validatePayload(payload); - if (validation.error) { - return NextResponse.json( - { state: validation.blocked ? 'blocked' : 'invalid-request', message: validation.error }, - { status: validation.blocked ? 409 : 400 } - ); - } - - const managerItem = await postManagerCatalogItem(request, payload); - return NextResponse.json( - managerItem || fallbackCatalogItem( - payload, - validation.catalogId as string, - validation.name as string, - validation.risk as string - ), - { status: managerItem ? 200 : 202 } - ); -} - -export async function GET(request: NextRequest) { - const managerItems = await getManagerCatalogItems(request); - return NextResponse.json({ - state: managerItems ? 'manager-action-catalog' : 'fallback-empty', - adapterOwner: managerItems ? 'manager-action-catalog' : 'next-actions-catalog-bff', - managerBacked: Boolean(managerItems), - items: managerItems || [] - }); -} diff --git a/web-next/app/bulletin/bulletin-page.tsx b/web-next/app/bulletin/bulletin-page.tsx deleted file mode 100644 index 8883fb27ed..0000000000 --- a/web-next/app/bulletin/bulletin-page.tsx +++ /dev/null @@ -1,37 +0,0 @@ -'use client'; - -import React, { useCallback, useMemo, useState } from 'react'; -import { BulletinCenterSurface, type BulletinCenterData } from '../../components/pages/bulletin-center-surface'; -import { useI18n } from '../../components/providers/i18n-provider'; -import { ClientWorkbench } from '../../components/workbench/client-workbench'; -import { apiMessageGet } from '../../lib/api-client'; -import { loadBulletinData } from '../../lib/bulletin-center/controller'; -import { buildBulletinListUrl } from '../../lib/bulletin-center/query-state'; - -const BULLETIN_CENTER_SETTLED_CACHE_TTL_MS = 10_000; - -export default function BulletinPage() { - const { t } = useI18n(); - const [refreshTick, setRefreshTick] = useState(0); - const bulletinListSearch = ' '.repeat(refreshTick); - const bulletinListUrl = useMemo(() => buildBulletinListUrl(bulletinListSearch), [bulletinListSearch]); - const bulletinCenterCacheKey = useMemo( - () => ['bulletin-center', bulletinListUrl, refreshTick].join(':'), - [bulletinListUrl, refreshTick] - ); - - const load = useCallback(async (): Promise => { - return loadBulletinData(apiMessageGet, bulletinListSearch); - }, [bulletinListSearch]); - - return ( - - {data => setRefreshTick(value => value + 1)} />} - - ); -} diff --git a/web-next/app/bulletin/page.test.tsx b/web-next/app/bulletin/page.test.tsx deleted file mode 100644 index 1b342fb474..0000000000 --- a/web-next/app/bulletin/page.test.tsx +++ /dev/null @@ -1,105 +0,0 @@ -import React from 'react'; -import { readFileSync } from 'node:fs'; -import { resolve } from 'node:path'; -import { renderToStaticMarkup } from 'react-dom/server'; -import { describe, expect, it, vi } from 'vitest'; -import { createTranslatorMock } from '../../test/i18n-test-helper'; - -const loadState = vi.hoisted(() => ({ - lastLoad: null as null | (() => Promise) -})); - -const apiMessageGet = vi.fn(); -const loadBulletinData = vi.fn(async () => ({ - list: { - totalElements: 2, - content: [ - { id: 7, name: 'Ops board', app: 'website', monitorIds: [1, 2], creator: 'ops' }, - { id: 8, name: 'DB board', app: 'website', monitorIds: [3], creator: 'ops' } - ] - } -})); - -vi.mock('../../components/providers/i18n-provider', () => ({ - useI18n: () => ({ - t: createTranslatorMock({ locale: 'en-US' }), - locale: 'en-US' - }) -})); - -vi.mock('../../components/workbench/client-workbench', () => ({ - ClientWorkbench: ({ - children, - load, - loadingCopy - }: { - children: (data: any) => React.ReactNode; - load: () => Promise; - loadingCopy?: string; - }) => { - loadState.lastLoad = load; - return ( -
- {children({ - list: { - totalElements: 2, - content: [ - { id: 7, name: 'Ops board', app: 'website', monitorIds: [1, 2], creator: 'ops' }, - { id: 8, name: 'DB board', app: 'website', monitorIds: [3], creator: 'ops' } - ] - } - })} -
- ); - } -})); - -vi.mock('@/components/pages/bulletin-center-surface', () => ({ - BulletinCenterSurface: ({ refreshTick }: { refreshTick: number }) => ( -
- {refreshTick} - Ops board -
- ) -})); - -vi.mock('../../lib/api-client', () => ({ - apiMessageGet -})); - -vi.mock('../../lib/bulletin-center/controller', async () => { - const actual = await import('../../lib/bulletin-center/controller'); - return { - ...actual, - loadBulletinData - }; -}); - -describe('bulletin page', () => { - it('renders the shared bulletin center surface and keeps the controller-backed load path', async () => { - loadBulletinData.mockClear(); - apiMessageGet.mockReset(); - loadState.lastLoad = null; - - const { default: BulletinPage } = await import('./page'); - const html = renderToStaticMarkup(); - const lastLoad = loadState.lastLoad as (() => Promise) | null; - await lastLoad?.(); - - expect(html).toContain('data-client-workbench="true"'); - expect(html).toContain('data-bulletin-center-surface="true"'); - expect(html).toContain('data-loading-copy="Loading bulletin center"'); - expect(html).toContain('Ops board'); - expect(loadBulletinData).toHaveBeenCalledWith(apiMessageGet, ''); - }, 15000); - - it('keeps bulletin center remounts on a short settled cache window while reload invalidates it', () => { - const source = readFileSync(resolve(process.cwd(), 'app/bulletin/bulletin-page.tsx'), 'utf8'); - - expect(source).toContain('BULLETIN_CENTER_SETTLED_CACHE_TTL_MS = 10_000'); - expect(source).toContain("['bulletin-center', bulletinListUrl, refreshTick].join(':')"); - expect(source).toContain('[bulletinListUrl, refreshTick]'); - expect(source).toContain('onReload={() => setRefreshTick(value => value + 1)}'); - expect(source).toContain('cacheSettledTtlMs={BULLETIN_CENTER_SETTLED_CACHE_TTL_MS}'); - }); -}); diff --git a/web-next/app/bulletin/page.tsx b/web-next/app/bulletin/page.tsx deleted file mode 100644 index 4f2f021fb0..0000000000 --- a/web-next/app/bulletin/page.tsx +++ /dev/null @@ -1,7 +0,0 @@ -import React from 'react'; - -import BulletinPage from './bulletin-page'; - -export default function BulletinRoutePage() { - return ; -} diff --git a/web-next/app/compatibility-entrypoints.chrome.test.ts b/web-next/app/compatibility-entrypoints.chrome.test.ts deleted file mode 100644 index a95fd469a2..0000000000 --- a/web-next/app/compatibility-entrypoints.chrome.test.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { readFileSync } from 'node:fs'; -import { resolve } from 'node:path'; -import { describe, expect, it } from 'vitest'; - -describe('compatibility entrypoint posture', () => { - it('keeps compatibility routes on their shared owners', () => { - const dashboardSource = readFileSync(resolve(process.cwd(), 'app/dashboard/page.tsx'), 'utf8'); - const alertsSource = readFileSync(resolve(process.cwd(), 'app/alerts/page.tsx'), 'utf8'); - const alertCenterSource = readFileSync(resolve(process.cwd(), 'app/alert/center/page.tsx'), 'utf8'); - const eventsSource = readFileSync(resolve(process.cwd(), 'app/events/page.tsx'), 'utf8'); - const loginSource = readFileSync(resolve(process.cwd(), 'app/login/page.tsx'), 'utf8'); - const statusPublicSource = readFileSync(resolve(process.cwd(), 'app/status/public/page.tsx'), 'utf8'); - const settingSource = readFileSync(resolve(process.cwd(), 'app/setting/page.tsx'), 'utf8'); - const settingSettingsSource = readFileSync(resolve(process.cwd(), 'app/setting/settings/page.tsx'), 'utf8'); - const logStreamSource = readFileSync(resolve(process.cwd(), 'app/log/stream/page.tsx'), 'utf8'); - const logIntegrationSource = readFileSync(resolve(process.cwd(), 'app/log/integration/page.tsx'), 'utf8'); - const logIntegrationSourceAlias = readFileSync(resolve(process.cwd(), 'app/log/integration/[source]/page.tsx'), 'utf8'); - - expect(dashboardSource).toContain('DashboardDraftWorkspace'); - expect(dashboardSource).not.toContain('redirect('); - expect(dashboardSource).not.toContain("from '../overview/page'"); - expect(alertsSource).toContain('buildAlertCompatRouteUrlFromSearchParams'); - expect(alertsSource).not.toContain('createCompatSearchParamReader'); - expect(alertsSource).toContain('redirect('); - expect(alertCenterSource).toContain('buildAlertCompatRouteUrlFromSearchParams'); - expect(alertCenterSource).not.toContain("from '../../../lib/compat/search-params'"); - expect(eventsSource).toContain('buildLogCompatRouteUrlFromSearchParams'); - expect(eventsSource).not.toContain('createCompatSearchParamReader'); - expect(eventsSource).toContain("view: 'list'"); - expect(eventsSource).toContain('redirect('); - expect(loginSource).toContain('buildLoginCompatRouteUrl'); - expect(loginSource).not.toContain("from '../../lib/compat/search-params'"); - expect(statusPublicSource).toContain('buildPublicStatusCompatRouteUrl'); - expect(statusPublicSource).not.toContain("from '../../../lib/compat/search-params'"); - expect(settingSource).toContain('buildSettingsCompatRouteUrl'); - expect(settingSource).not.toContain("from '../../lib/compat/search-params'"); - expect(settingSettingsSource).toContain('buildSettingsCompatRouteUrl'); - expect(settingSettingsSource).not.toContain("from '../../../lib/compat/search-params'"); - expect(alertsSource).not.toContain('function createSearchParamReader'); - expect(alertsSource).not.toContain("from '../alert/page'"); - expect(eventsSource).not.toContain('function createSearchParamReader'); - expect(eventsSource).not.toContain("from '../log/manage/log-manage-page'"); - expect(eventsSource).not.toContain('forcedView="explorer"'); - expect(loginSource).not.toContain('function buildSearchParams'); - expect(alertCenterSource).not.toContain("redirect('/alert')"); - expect(statusPublicSource).not.toContain("redirect('/status')"); - expect(settingSource).not.toContain("redirect('/setting/settings')"); - expect(settingSettingsSource).not.toContain("redirect('/setting/settings/config')"); - expect(logStreamSource).toContain('data-log-stream-canonical-live-route="log-manage-stream"'); - expect(logStreamSource).toContain('forcedView="stream"'); - expect(logStreamSource).toContain('showViewToggle={false}'); - expect(logStreamSource).not.toContain('data-log-stream-surface="angular-log-stream"'); - expect(logStreamSource).not.toContain('data-log-stream-toolbar="angular-actions"'); - expect(logStreamSource).not.toContain('buildLogCompatRouteUrl'); - expect(logStreamSource).not.toContain('redirect('); - expect(logStreamSource).toContain('LogManagePage'); - expect(logIntegrationSource).toContain('buildLogIntegrationIngestionHref'); - expect(logIntegrationSource).toContain('createSearchParamReader'); - expect(logIntegrationSource).toContain('redirect('); - expect(logIntegrationSource).not.toContain('LogIntegrationRedirectShell'); - expect(logIntegrationSourceAlias).toContain('buildLogIntegrationIngestionHref'); - expect(logIntegrationSourceAlias).toContain('createSearchParamReader(resolvedSearchParams)'); - expect(logIntegrationSourceAlias).not.toContain('resolved.source'); - expect(logIntegrationSourceAlias).toContain('redirect('); - }); -}); diff --git a/web-next/app/dashboard/dashboard-draft-workspace.tsx b/web-next/app/dashboard/dashboard-draft-workspace.tsx deleted file mode 100644 index 29b40e621d..0000000000 --- a/web-next/app/dashboard/dashboard-draft-workspace.tsx +++ /dev/null @@ -1,2925 +0,0 @@ -'use client'; - -import React, { useEffect, useMemo, useState } from 'react'; -import { ArrowDown, ArrowLeft, ArrowRight, ArrowUp, Copy, ExternalLink, LayoutDashboard, Maximize2, Minimize2, Pencil, RefreshCw, Save, Trash2, X } from 'lucide-react'; -import { - HzButton, - HzButtonLink, - HzDataCellStack, - HzDataCellText, - HzDataMetaText, - HzDataTable, - HzEmptyState, - HzExplorerFrame, - HzInput, - HzPanelSurface, - HzSelect, - HzStatusBadge, - type HzDataColumn -} from '@hertzbeat/ui'; -import { useI18n } from '../../components/providers/i18n-provider'; -import { - applySignalDashboardTimeRange, - buildSignalDashboardCompositionFromDrafts, - buildSignalDashboardExecutionPlans, - buildSignalDashboardPanelRuntimeRenderDescriptor, - buildSignalDashboardRuntimeEvidenceSourceHandoff, - buildSignalDashboardRuntimeEvidenceFilters, - buildSignalDashboardRuntimeEvidenceFilterSuggestions, - buildSignalDashboardRuntimeMetricsTooltip, - buildSignalDashboardPanelEditHref, - buildSignalDashboardRuntimeSyncCrosshair, - buildSignalDashboardRuntimeSyncTooltip, - buildSignalDashboardVariableOptions, - buildSignalOperationDrilldownDashboard, - buildSignalServiceOverviewDashboard, - createSignalDashboardPanelDraftsFromFilterSelection, - createSignalDashboardPanelDraftFromRuntimeBreakout, - createSignalDashboardPanelDraftFromRuntimeEvidence, - deleteSignalDashboard, - executeSignalDashboardPanelPlan, - filterSignalDashboardVariableOptions, - loadSignalDashboards, - mergeSignalDashboardDraftsIntoComposition, - normalizeSignalDashboardKey, - parseSignalDashboardVariables, - resolveSignalDashboardRefreshState, - resolveSignalDashboardTimeRange, - resolveSignalDashboardPreviewPanels, - saveSignalDashboard, - selectSignalDashboardVariableOption, - readSignalDashboardWidgetPanelEditMetadata, - summarizeSignalDashboardPanelRuntime, - updateSignalDashboardPanelLayout, - updateSignalDashboardVariables, - type SignalDashboard, - type SignalDashboardPanelExecutionResult, - type SignalDashboardLayoutPatch, - type SignalDashboardPanelRuntimeRenderDescriptor, - type SignalDashboardTimeRange, - type SignalDashboardVariable, - type SignalDashboardVariableType -} from '../../lib/signal-dashboards'; -import { - deleteSignalDashboardPanelDraft, - duplicateSignalDashboardPanelDraft, - loadAllSignalDashboardPanelDrafts, - saveSignalDashboardPanelDraft, - type SignalDashboardPanelDraft, - type SignalDashboardPanelDraftSignal, - type SignalDashboardPanelVisualization -} from '../../lib/signal-dashboard-panel-drafts'; -import { - createSignalDashboardPanelDraftFromSavedView, - deleteSignalSavedQueryView, - loadAllSignalSavedQueryViewsWithDiagnostics, - saveSignalSavedQueryView, - type SignalSavedViewSignal, - type SignalSavedQueryViewWithSignal -} from '../../lib/signal-saved-views'; -import { - buildDashboardDeepLinkHref, - buildDashboardReturnHref, - buildDashboardTimeRangeDeepLinkHref, - buildDashboardVariableDeepLinkHref, - readDashboardVariableUrlOverrides, - type SearchParamsRecord -} from '../../lib/dashboard/navigation'; - -type DraftLoadState = 'loading' | 'ready' | 'empty' | 'error'; -type SavedViewLoadState = DraftLoadState | 'partial'; -type CompositionState = 'loading' | 'ready' | 'empty' | 'saving' | 'saved' | 'error'; -type SavedViewDraftFields = { - label: string; - description: string; -}; - -const SIGNALS: SignalDashboardPanelDraftSignal[] = ['logs', 'traces', 'metrics', 'alerts']; -const VARIABLE_TYPE_OPTIONS: { value: SignalDashboardVariableType; labelKey: string }[] = [ - { value: 'custom', labelKey: 'dashboard.composition.variable.type.custom' }, - { value: 'textbox', labelKey: 'dashboard.composition.variable.type.textbox' }, - { value: 'query', labelKey: 'dashboard.composition.variable.type.query' }, - { value: 'dynamic', labelKey: 'dashboard.composition.variable.type.dynamic' } -]; - -type DashboardRuntimeSyncProps = { - syncTimestamp: string; - pinnedSyncTimestamp: string; - onSyncTimestamp: (timestamp: string) => void; - onPinSyncTimestamp: (timestamp: string) => void; -}; - -function firstParamValue(value: string | string[] | undefined) { - if (Array.isArray(value)) return value[0]; - return value; -} - -function replaceDashboardDeepLink(dashboardKey: string) { - if (typeof window === 'undefined') return; - window.history.replaceState( - window.history.state, - '', - buildDashboardDeepLinkHref(window.location.href, dashboardKey) - ); -} - -function replaceDashboardVariableDeepLink(variableName: string, value: string) { - if (typeof window === 'undefined') return; - window.history.replaceState( - window.history.state, - '', - buildDashboardVariableDeepLinkHref(window.location.href, variableName, value) - ); -} - -function replaceDashboardTimeRangeDeepLink(timeRange: SignalDashboardTimeRange) { - if (typeof window === 'undefined') return; - window.history.replaceState( - window.history.state, - '', - buildDashboardTimeRangeDeepLinkHref(window.location.href, timeRange) - ); -} - -function applyVariableUrlOverridesToDashboards( - dashboards: SignalDashboard[], - overrides: Record -) { - const overrideEntries = Object.entries(overrides); - if (overrideEntries.length === 0) return dashboards; - return dashboards.map(dashboard => { - let changed = false; - const variables = parseSignalDashboardVariables(dashboard).map(variable => { - if (!Object.prototype.hasOwnProperty.call(overrides, variable.name)) return variable; - changed = true; - return { - ...variable, - value: overrides[variable.name] - }; - }); - return changed ? updateSignalDashboardVariables(dashboard, variables) : dashboard; - }); -} - -function countBySignal(drafts: SignalDashboardPanelDraft[], signal: SignalDashboardPanelDraftSignal) { - return drafts.filter(draft => draft.signal === signal).length; -} - -function savedViewRowKey(view: SignalSavedQueryViewWithSignal) { - return `${view.signal}:${view.id}`; -} - -function formatUpdatedAt(value: string | undefined, locale: string) { - if (!value) return '-'; - const timestamp = Date.parse(value); - if (Number.isNaN(timestamp)) return value; - return new Intl.DateTimeFormat(locale, { - dateStyle: 'medium', - timeStyle: 'short' - }).format(timestamp); -} - -function visualizationLabel(visualization: SignalDashboardPanelVisualization | string) { - if (visualization === 'time-series') return 'Time series'; - return visualization.charAt(0).toUpperCase() + visualization.slice(1); -} - -function parseWidgetCount(dashboard: SignalDashboard) { - try { - const widgets = JSON.parse(dashboard.widgets); - return Array.isArray(widgets) ? widgets.length : 0; - } catch { - return 0; - } -} - -function readPanelDraftSourceSummary(draft: SignalDashboardPanelDraft) { - if (!draft.payload) return ''; - try { - const payload = JSON.parse(draft.payload) as { savedViewRouteSummaryText?: unknown }; - return typeof payload.savedViewRouteSummaryText === 'string' ? payload.savedViewRouteSummaryText : ''; - } catch { - return ''; - } -} - -const BUILTIN_SIGNAL_OVERVIEW_DASHBOARD_KEY = 'signals-overview'; -const BUILTIN_SIGNAL_OVERVIEW_DASHBOARD_TITLE = 'Signals overview'; -const BUILTIN_SIGNAL_OVERVIEW_DASHBOARD_DESCRIPTIONS = new Set([ - 'Dashboard composed from logs, traces, and metrics panel drafts.', - 'Dashboard composed from signal panel drafts.' -]); - -type DashboardExplorerHandoffScope = 'saved-views' | 'panel-drafts'; - -function DashboardExplorerHandoffActions({ - scope, - t -}: { - scope: DashboardExplorerHandoffScope; - t: ReturnType['t']; -}) { - const actions = [ - { key: 'logs', href: '/log/manage', label: t('dashboard.empty-action.logs') }, - { key: 'traces', href: '/trace/manage', label: t('dashboard.empty-action.traces') }, - { key: 'metrics', href: '/ingestion/otlp/metrics', label: t('dashboard.empty-action.metrics') } - ]; - - return ( - <> - {actions.map(action => ( - - - {action.label} - - ))} - - ); -} - -export function resolveDashboardDisplayText( - dashboard: Pick, - defaults: { title: string; description: string } -) { - const title = dashboard.title.trim(); - const description = dashboard.description.trim(); - const isBuiltinSignalOverview = dashboard.dashboardKey === BUILTIN_SIGNAL_OVERVIEW_DASHBOARD_KEY; - return { - title: isBuiltinSignalOverview && (!title || title === BUILTIN_SIGNAL_OVERVIEW_DASHBOARD_TITLE) ? defaults.title : title || defaults.title, - description: - isBuiltinSignalOverview && (!description || BUILTIN_SIGNAL_OVERVIEW_DASHBOARD_DESCRIPTIONS.has(description)) - ? defaults.description - : description || defaults.description - }; -} - -function DashboardRuntimeStatePanel({ - runtimeRenderer -}: { - runtimeRenderer: SignalDashboardPanelRuntimeRenderDescriptor; -}) { - return ( -
- {runtimeRenderer.rows.map(row => ( -
- {row.title} - {row.copy} - {row.meta ? ( - {row.meta} - ) : null} -
- ))} -
- ); -} - -function DashboardRuntimeTable({ - runtimeRenderer, - tableKind, - labels, - syncTimestamp, - pinnedSyncTimestamp, - onSyncTimestamp, - onPinSyncTimestamp -}: { - runtimeRenderer: SignalDashboardPanelRuntimeRenderDescriptor; - tableKind: 'logs' | 'traces'; - labels: { - time: string; - service: string; - status: string; - message: string; - trace: string; - span: string; - duration: string; - }; -} & DashboardRuntimeSyncProps) { - const markerName = tableKind === 'logs' - ? 'data-dashboard-runtime-logs-table' - : 'data-dashboard-runtime-trace-table'; - const contextLabel = tableKind === 'logs' ? labels.span : labels.duration; - return ( -
-
- {labels.time} - {labels.service} - {labels.status} - {labels.message} - {labels.trace} - {contextLabel} -
-
- {runtimeRenderer.tableRows.length > 0 ? runtimeRenderer.tableRows.map(row => { - const syncSelected = row.observedAt !== '-' && row.observedAt === syncTimestamp; - const syncPinned = row.observedAt !== '-' && row.observedAt === pinnedSyncTimestamp; - return ( -
row.observedAt !== '-' && onSyncTimestamp(row.observedAt)} - onMouseEnter={() => row.observedAt !== '-' && onSyncTimestamp(row.observedAt)} - onClick={() => row.observedAt !== '-' && onPinSyncTimestamp(row.observedAt)} - data-dashboard-runtime-table-row={row.key} - data-dashboard-runtime-table-row-observed-at={row.observedAt} - data-dashboard-runtime-table-row-service={row.service} - data-dashboard-runtime-table-row-status={row.status} - data-dashboard-runtime-table-row-trace={row.traceId} - data-dashboard-runtime-table-row-span={row.spanId} - data-dashboard-runtime-table-row-duration={row.duration} - data-dashboard-runtime-sync-publisher="table-row" - data-dashboard-runtime-sync-timestamp={row.observedAt} - data-dashboard-runtime-sync-selected={syncSelected ? 'true' : 'false'} - data-dashboard-runtime-sync-pinned={syncPinned ? 'true' : 'false'} - data-dashboard-runtime-sync-pin-action="toggle" - > - {row.observedAt} - {row.service} - {row.status} - {row.message || row.name} - {row.traceId} - {tableKind === 'logs' ? row.spanId : row.duration} -
- ); - }) : runtimeRenderer.rows.map(row => ( -
- {row.title} - {row.copy} -
- ))} -
-
- ); -} - -function DashboardRuntimeTraceWaterfall({ - runtimeRenderer, - syncTimestamp, - pinnedSyncTimestamp, - onSyncTimestamp, - onPinSyncTimestamp -}: { - runtimeRenderer: SignalDashboardPanelRuntimeRenderDescriptor; -} & DashboardRuntimeSyncProps) { - if (runtimeRenderer.traceWaterfallRows.length === 0) return null; - const source = runtimeRenderer.traceWaterfallRows[0]?.source || 'list-roots'; - return ( -
- {runtimeRenderer.traceWaterfallRows.slice(0, 4).map(row => { - const syncSelected = row.observedAt !== '-' && row.observedAt === syncTimestamp; - const syncPinned = row.observedAt !== '-' && row.observedAt === pinnedSyncTimestamp; - return ( -
row.observedAt !== '-' && onSyncTimestamp(row.observedAt)} - onMouseEnter={() => row.observedAt !== '-' && onSyncTimestamp(row.observedAt)} - onClick={() => row.observedAt !== '-' && onPinSyncTimestamp(row.observedAt)} - data-dashboard-runtime-trace-waterfall-row={row.key} - data-dashboard-runtime-trace-waterfall-row-source={row.source} - data-dashboard-runtime-trace-waterfall-row-depth={row.depth} - data-dashboard-runtime-trace-waterfall-row-service={row.service} - data-dashboard-runtime-trace-waterfall-row-span={row.spanId} - data-dashboard-runtime-trace-waterfall-row-trace={row.traceId} - data-dashboard-runtime-trace-waterfall-row-duration={row.duration} - data-dashboard-runtime-sync-publisher="trace-waterfall-row" - data-dashboard-runtime-sync-timestamp={row.observedAt} - data-dashboard-runtime-sync-selected={syncSelected ? 'true' : 'false'} - data-dashboard-runtime-sync-pinned={syncPinned ? 'true' : 'false'} - data-dashboard-runtime-sync-pin-action="toggle" - > -
- {row.name} - {`${row.service} · ${row.status}`} -
-
-
-
-
- ); - })} -
- ); -} - -function DashboardRuntimeBarChart({ - runtimeRenderer, - chartKind -}: { - runtimeRenderer: SignalDashboardPanelRuntimeRenderDescriptor; - chartKind: 'log-trend' | 'metrics'; -}) { - const markerName = chartKind === 'log-trend' - ? 'data-dashboard-runtime-log-trend-chart' - : 'data-dashboard-runtime-metrics-chart'; - return ( -
-
- {runtimeRenderer.bars.map(bar => ( -
-
- {bar.label} -
- ))} -
-
- ); -} - -const METRICS_SERIES_COLORS = ['#42d19f', '#f6c343', '#ff7a70', '#7aa8ff', '#c084fc', '#4dd0e1']; - -function DashboardRuntimeMetricsChart({ - runtimeRenderer, - syncTimestamp, - pinnedSyncTimestamp, - onSyncTimestamp, - onPinSyncTimestamp -}: { - runtimeRenderer: SignalDashboardPanelRuntimeRenderDescriptor; -} & DashboardRuntimeSyncProps) { - const chart = runtimeRenderer.metricsChart; - if (!chart || chart.series.length === 0) { - return ; - } - const syncPoints = syncTimestamp - ? chart.series.flatMap(series => series.points.filter(point => String(point.timestamp) === syncTimestamp)) - : []; - const syncCrosshairX = syncPoints.length > 0 - ? syncPoints.reduce((sum, point) => sum + point.xPct, 0) / syncPoints.length - : null; - const metricsTooltip = buildSignalDashboardRuntimeMetricsTooltip(chart, syncTimestamp); - const syncTooltipAlign = syncCrosshairX != null && syncCrosshairX > 62 ? 'right' : 'left'; - return ( -
-
-
-
- -
- {syncCrosshairX == null ? null : ( - - )} -
- {syncCrosshairX == null || metricsTooltip.state !== 'sync' ? null : ( -
- - {metricsTooltip.timestamp} - - {metricsTooltip.rows.slice(0, 3).map(row => ( -
- {row.title} - {row.copy} -
- ))} -
- )} - {chart.series.map((series, seriesIndex) => ( -
- {series.points.map(point => { - const pointTimestamp = String(point.timestamp); - const syncSelected = pointTimestamp === syncTimestamp; - const syncPinned = pointTimestamp === pinnedSyncTimestamp; - return ( - onSyncTimestamp(pointTimestamp)} - onMouseEnter={() => onSyncTimestamp(pointTimestamp)} - onClick={() => onPinSyncTimestamp(pointTimestamp)} - data-dashboard-runtime-metrics-chart-point={point.key} - data-dashboard-runtime-metrics-chart-point-x={point.xPct} - data-dashboard-runtime-metrics-chart-point-y={point.yPct} - data-dashboard-runtime-metrics-chart-point-timestamp={point.timestamp} - data-dashboard-runtime-metrics-chart-point-value={point.value} - data-dashboard-runtime-sync-publisher="metrics-point" - data-dashboard-runtime-sync-timestamp={pointTimestamp} - data-dashboard-runtime-sync-selected={syncSelected ? 'true' : 'false'} - data-dashboard-runtime-sync-pinned={syncPinned ? 'true' : 'false'} - data-dashboard-runtime-sync-pin-action="toggle" - /> - ); - })} -
- ))} -
-
- {chart.xMinLabel} - {`${chart.yMinLabel} / ${chart.yMaxLabel}`} - {chart.xMaxLabel} -
-
- {metricsTooltip.rows.map(row => ( -
- {row.title} - {row.copy} -
- ))} -
-
- ); -} - -function DashboardRuntimeTraceOverview({ - runtimeRenderer -}: { - runtimeRenderer: SignalDashboardPanelRuntimeRenderDescriptor; -}) { - return ( -
- {runtimeRenderer.rows.map(row => ( -
- {row.title} - {row.copy} -
- ))} -
- ); -} - -function DashboardRuntimeObjectPanel({ - runtimeRenderer -}: { - runtimeRenderer: SignalDashboardPanelRuntimeRenderDescriptor; -}) { - const { t } = useI18n(); - return ( -
- {runtimeRenderer.rows.map(row => ( -
- {row.title} - {row.copy} - {row.relatedHandoffHref ? ( - - - ) : ( -
- ))} -
- ); -} - -function DashboardRuntimePanelRenderer({ - runtimeRenderer, - tableLabels, - syncTimestamp, - pinnedSyncTimestamp, - onSyncTimestamp, - onPinSyncTimestamp -}: { - runtimeRenderer: SignalDashboardPanelRuntimeRenderDescriptor; - tableLabels: React.ComponentProps['labels']; -} & DashboardRuntimeSyncProps) { - if (runtimeRenderer.renderer === 'logs-table') { - return ( - - ); - } - if (runtimeRenderer.renderer === 'trace-table') { - return ( - <> - - - - ); - } - if (runtimeRenderer.renderer === 'log-trend-chart') { - return ; - } - if (runtimeRenderer.renderer === 'metrics-chart') { - return ( - - ); - } - if (runtimeRenderer.renderer === 'trace-overview') { - return ; - } - if (runtimeRenderer.renderer === 'object-panel') { - return ; - } - return ; -} - -export default function DashboardDraftWorkspace({ - initialContext -}: { - initialContext: SearchParamsRecord; -}) { - const { locale, t } = useI18n(); - const [drafts, setDrafts] = useState([]); - const [savedViews, setSavedViews] = useState([]); - const [dashboards, setDashboards] = useState([]); - const [loadState, setLoadState] = useState('loading'); - const [savedViewLoadState, setSavedViewLoadState] = useState('loading'); - const [savedViewFailedSignals, setSavedViewFailedSignals] = useState([]); - const [compositionState, setCompositionState] = useState('loading'); - const [deletingDraftKey, setDeletingDraftKey] = useState(null); - const [duplicatingDraftKey, setDuplicatingDraftKey] = useState(null); - const [savingRuntimeEvidenceKey, setSavingRuntimeEvidenceKey] = useState(null); - const [savingFilterPanelKey, setSavingFilterPanelKey] = useState(null); - const [promotingSavedViewKey, setPromotingSavedViewKey] = useState(null); - const [savingSavedViewKey, setSavingSavedViewKey] = useState(null); - const [deletingSavedViewKey, setDeletingSavedViewKey] = useState(null); - const [deletingDashboardKey, setDeletingDashboardKey] = useState(null); - const [savedViewDrafts, setSavedViewDrafts] = useState>({}); - const requestedDashboardParam = firstParamValue(initialContext.dashboard); - const hasRequestedDashboardKey = Boolean(requestedDashboardParam?.trim()); - const requestedDashboardKey = normalizeSignalDashboardKey(requestedDashboardParam || 'signals-overview'); - const [dashboardKeyDraft, setDashboardKeyDraft] = useState(requestedDashboardKey); - const [dashboardTitleDraft, setDashboardTitleDraft] = useState(''); - const [dashboardDescriptionDraft, setDashboardDescriptionDraft] = useState(''); - const [savingPreviewLayout, setSavingPreviewLayout] = useState(false); - const [savingVariables, setSavingVariables] = useState(false); - const [savingServiceOverview, setSavingServiceOverview] = useState(false); - const [savingOperationDrilldown, setSavingOperationDrilldown] = useState(false); - const [variableNameDraft, setVariableNameDraft] = useState('service.name'); - const [variableTypeDraft, setVariableTypeDraft] = useState('textbox'); - const [variableValueDraft, setVariableValueDraft] = useState(''); - const [variableDescriptionDraft, setVariableDescriptionDraft] = useState(''); - const [variableOptionsDraft, setVariableOptionsDraft] = useState(''); - const [timeRangeStartDraft, setTimeRangeStartDraft] = useState(() => firstParamValue(initialContext.start) || firstParamValue(initialContext.from) || ''); - const [timeRangeEndDraft, setTimeRangeEndDraft] = useState(() => firstParamValue(initialContext.end) || firstParamValue(initialContext.to) || ''); - const [timeRangePresetDraft, setTimeRangePresetDraft] = useState(() => firstParamValue(initialContext.timeRange) || ''); - const [timeRangeRefreshDraft, setTimeRangeRefreshDraft] = useState(() => firstParamValue(initialContext.refresh) || ''); - const [timeRangeLiveDraft, setTimeRangeLiveDraft] = useState(() => firstParamValue(initialContext.live) || ''); - const [refreshTick, setRefreshTick] = useState(0); - const [filterOptionSearchDrafts, setFilterOptionSearchDrafts] = useState>({}); - const [runtimeSyncHoverTimestamp, setRuntimeSyncHoverTimestamp] = useState(''); - const [runtimePinnedSyncTimestamp, setRuntimePinnedSyncTimestamp] = useState(''); - const [panelExecutionResults, setPanelExecutionResults] = useState>({}); - const contextSource = firstParamValue(initialContext.source) || firstParamValue(initialContext.returnTo) || 'dashboard'; - const initialVariableUrlOverrides = useMemo( - () => readDashboardVariableUrlOverrides(initialContext), - [initialContext] - ); - const serviceOverviewContext = useMemo(() => { - const serviceName = firstParamValue(initialContext.serviceName)?.trim() || ''; - if (!serviceName) return null; - return { - serviceName, - serviceNamespace: firstParamValue(initialContext.serviceNamespace)?.trim() || undefined, - environment: firstParamValue(initialContext.environment)?.trim() || undefined, - entityId: firstParamValue(initialContext.entityId)?.trim() || undefined, - entityType: firstParamValue(initialContext.entityType)?.trim() || undefined, - entityName: firstParamValue(initialContext.entityName)?.trim() || undefined, - source: firstParamValue(initialContext.source)?.trim() || undefined, - collector: firstParamValue(initialContext.collector)?.trim() || undefined, - template: firstParamValue(initialContext.template)?.trim() || undefined - }; - }, [initialContext]); - const operationDrilldownContext = useMemo(() => { - const operationName = firstParamValue(initialContext.operationName)?.trim() || ''; - if (!serviceOverviewContext || !operationName) return null; - return { - ...serviceOverviewContext, - operationName - }; - }, [initialContext, serviceOverviewContext]); - const defaultDashboardTitle = t('dashboard.composition.default-title'); - const defaultDashboardDescription = t('dashboard.composition.default-description'); - const runtimeTableLabels = useMemo(() => ({ - time: t('dashboard.runtime.table.time'), - service: t('dashboard.runtime.table.service'), - status: t('dashboard.runtime.table.status'), - message: t('dashboard.runtime.table.message'), - trace: t('dashboard.runtime.table.trace'), - span: t('dashboard.runtime.table.span'), - duration: t('dashboard.runtime.table.duration') - }), [t]); - - useEffect(() => { - setDashboardTitleDraft(current => current || defaultDashboardTitle); - setDashboardDescriptionDraft(current => current || defaultDashboardDescription); - }, [defaultDashboardDescription, defaultDashboardTitle]); - - useEffect(() => { - let cancelled = false; - setLoadState('loading'); - - loadAllSignalDashboardPanelDrafts() - .then(nextDrafts => { - if (cancelled) return; - setDrafts(nextDrafts); - setLoadState(nextDrafts.length > 0 ? 'ready' : 'empty'); - }) - .catch(() => { - if (cancelled) return; - setLoadState('error'); - }); - - return () => { - cancelled = true; - }; - }, []); - - useEffect(() => { - let cancelled = false; - setSavedViewLoadState('loading'); - - loadAllSignalSavedQueryViewsWithDiagnostics() - .then(({ views: nextSavedViews, failedSignals }) => { - if (cancelled) return; - setSavedViews(nextSavedViews); - setSavedViewFailedSignals(failedSignals); - setSavedViewDrafts(nextSavedViews.reduce>((draftsByKey, view) => { - draftsByKey[savedViewRowKey(view)] = { - label: view.label, - description: view.description - }; - return draftsByKey; - }, {})); - setSavedViewLoadState(failedSignals.length > 0 && nextSavedViews.length > 0 ? 'partial' : nextSavedViews.length > 0 ? 'ready' : failedSignals.length > 0 ? 'error' : 'empty'); - }) - .catch(() => { - if (cancelled) return; - setSavedViewFailedSignals(['logs', 'traces', 'metrics']); - setSavedViewLoadState('error'); - }); - - return () => { - cancelled = true; - }; - }, []); - - useEffect(() => { - let cancelled = false; - setCompositionState('loading'); - - loadSignalDashboards() - .then(nextDashboards => { - if (cancelled) return; - const nextDashboardsWithUrlVariables = applyVariableUrlOverridesToDashboards(nextDashboards, initialVariableUrlOverrides); - setDashboards(nextDashboardsWithUrlVariables); - const firstDashboard = nextDashboardsWithUrlVariables[0]; - const requestedDashboard = nextDashboardsWithUrlVariables.find(dashboard => dashboard.dashboardKey === requestedDashboardKey); - const initialDashboard = requestedDashboard || firstDashboard; - if (initialDashboard) { - const initialDashboardText = resolveDashboardDisplayText(initialDashboard, { - title: defaultDashboardTitle, - description: defaultDashboardDescription - }); - setDashboardKeyDraft(current => !hasRequestedDashboardKey && current === requestedDashboardKey ? initialDashboard.dashboardKey : current); - setDashboardTitleDraft(current => current === defaultDashboardTitle || current.length === 0 ? initialDashboardText.title : current); - setDashboardDescriptionDraft(current => - current === defaultDashboardDescription || current.length === 0 - ? initialDashboardText.description - : current - ); - } - setCompositionState(nextDashboardsWithUrlVariables.length > 0 ? 'ready' : 'empty'); - }) - .catch(() => { - if (cancelled) return; - setCompositionState('error'); - }); - - return () => { - cancelled = true; - }; - }, [defaultDashboardDescription, defaultDashboardTitle, hasRequestedDashboardKey, initialVariableUrlOverrides, requestedDashboardKey]); - - const metrics = useMemo( - () => [ - { key: 'total', label: t('dashboard.panel-drafts.metric.total'), value: drafts.length }, - { key: 'saved-views', label: t('dashboard.saved-views.metric.total'), value: savedViews.length }, - { key: 'dashboards', label: t('dashboard.composition.metric.dashboards'), value: dashboards.length }, - ...SIGNALS.map(signal => ({ - key: signal, - label: t(`dashboard.add-panel.signal.${signal}`), - value: countBySignal(drafts, signal) - })) - ], - [dashboards.length, drafts, savedViews.length, t] - ); - const selectedDashboard = useMemo( - () => dashboards.find(dashboard => dashboard.dashboardKey === dashboardKeyDraft) || null, - [dashboardKeyDraft, dashboards] - ); - const selectedDashboardText = useMemo( - () => selectedDashboard ? resolveDashboardDisplayText(selectedDashboard, { - title: defaultDashboardTitle, - description: defaultDashboardDescription - }) : null, - [defaultDashboardDescription, defaultDashboardTitle, selectedDashboard] - ); - const selectedDashboardKey = selectedDashboard?.dashboardKey || ''; - const selectedDashboardTitle = selectedDashboardText?.title || ''; - const selectedDashboardDescription = selectedDashboardText?.description || ''; - useEffect(() => { - if (!selectedDashboardKey || !selectedDashboardTitle) return; - setDashboardTitleDraft(selectedDashboardTitle); - setDashboardDescriptionDraft(selectedDashboardDescription); - }, [selectedDashboardDescription, selectedDashboardKey, selectedDashboardTitle]); - const dashboardTimeRange = useMemo(() => { - const start = timeRangeStartDraft.trim(); - const end = timeRangeEndDraft.trim(); - const timeRange = timeRangePresetDraft.trim(); - const refresh = timeRangeRefreshDraft.trim(); - const live = timeRangeLiveDraft.trim(); - const refreshContext = { - ...(refresh ? { refresh } : {}), - ...(live ? { live } : {}) - }; - if (start || end) { - return { - ...(start ? { start } : {}), - ...(end ? { end } : {}), - ...refreshContext - }; - } - return { - ...(timeRange ? { timeRange } : {}), - ...refreshContext - }; - }, [timeRangeEndDraft, timeRangeLiveDraft, timeRangePresetDraft, timeRangeRefreshDraft, timeRangeStartDraft]); - useEffect(() => { - replaceDashboardTimeRangeDeepLink(dashboardTimeRange); - }, [dashboardTimeRange]); - const dashboardTimeRangeMode = dashboardTimeRange.start || dashboardTimeRange.end - ? 'absolute' - : dashboardTimeRange.timeRange - ? 'relative' - : 'panel'; - const dashboardExecutionTimeRange = useMemo( - () => resolveSignalDashboardTimeRange(dashboardTimeRange), - [dashboardTimeRange] - ); - const dashboardRefreshState = useMemo( - () => resolveSignalDashboardRefreshState(dashboardTimeRange), - [dashboardTimeRange] - ); - const previewPanels = useMemo( - () => selectedDashboard ? resolveSignalDashboardPreviewPanels(selectedDashboard, { timeRange: dashboardExecutionTimeRange }) : [], - [dashboardExecutionTimeRange, selectedDashboard] - ); - const executionPlans = useMemo( - () => selectedDashboard ? buildSignalDashboardExecutionPlans(selectedDashboard, { timeRange: dashboardExecutionTimeRange }) : [], - [dashboardExecutionTimeRange, selectedDashboard] - ); - const executionPlanByPanelId = useMemo( - () => new Map(executionPlans.map(plan => [plan.panelId, plan])), - [executionPlans] - ); - const runtimeRenderers = useMemo( - () => executionPlans.map(plan => buildSignalDashboardPanelRuntimeRenderDescriptor(plan, panelExecutionResults[plan.panelId])), - [executionPlans, panelExecutionResults] - ); - const runtimeRendererByPanelId = useMemo( - () => new Map(runtimeRenderers.map(renderer => [renderer.panelId, renderer])), - [runtimeRenderers] - ); - const dashboardVariables = useMemo( - () => selectedDashboard ? parseSignalDashboardVariables(selectedDashboard) : [], - [selectedDashboard] - ); - const dashboardReturnHref = useMemo( - () => selectedDashboard - ? buildDashboardReturnHref({ - dashboardKey: selectedDashboard.dashboardKey, - timeRange: dashboardExecutionTimeRange, - variables: dashboardVariables - }) - : applySignalDashboardTimeRange('/dashboard', dashboardExecutionTimeRange), - [dashboardExecutionTimeRange, dashboardVariables, selectedDashboard] - ); - const runtimeSyncTimestamp = runtimePinnedSyncTimestamp || runtimeSyncHoverTimestamp; - const runtimeSyncTooltip = useMemo( - () => buildSignalDashboardRuntimeSyncTooltip(runtimeRenderers, runtimeSyncTimestamp, { - timeRange: dashboardExecutionTimeRange, - returnTo: dashboardReturnHref - }), - [dashboardExecutionTimeRange, dashboardReturnHref, runtimeRenderers, runtimeSyncTimestamp] - ); - const runtimeSyncCrosshair = useMemo( - () => buildSignalDashboardRuntimeSyncCrosshair(runtimeRenderers, runtimeSyncTimestamp), - [runtimeRenderers, runtimeSyncTimestamp] - ); - const dashboardVariableOptions = useMemo( - () => buildSignalDashboardVariableOptions(dashboardVariables, executionPlans, panelExecutionResults), - [dashboardVariables, executionPlans, panelExecutionResults] - ); - const dashboardVariableOptionCount = useMemo( - () => Object.values(dashboardVariableOptions).reduce((sum, options) => sum + options.length, 0), - [dashboardVariableOptions] - ); - const pinRuntimeSyncTimestamp = (timestamp: string) => { - setRuntimeSyncHoverTimestamp(timestamp); - setRuntimePinnedSyncTimestamp(current => current === timestamp ? '' : timestamp); - }; - - useEffect(() => { - if (dashboardRefreshState.mode !== 'auto' || dashboardRefreshState.tickMs <= 0 || executionPlans.length === 0) { - return undefined; - } - const intervalId = window.setInterval(() => { - setRefreshTick(current => current + 1); - }, dashboardRefreshState.tickMs); - return () => { - window.clearInterval(intervalId); - }; - }, [dashboardRefreshState.mode, dashboardRefreshState.tickMs, executionPlans.length]); - - useEffect(() => { - let cancelled = false; - if (executionPlans.length === 0) { - setPanelExecutionResults({}); - return () => { - cancelled = true; - }; - } - - const initialResults = executionPlans.reduce>((results, plan) => { - results[plan.panelId] = plan.state === 'ready' && plan.primaryUrl - ? { - panelId: plan.panelId, - state: 'loading', - primaryUrl: plan.primaryUrl, - apiUrl: plan.primaryUrl - } - : { - panelId: plan.panelId, - state: 'unsupported', - primaryUrl: plan.primaryUrl, - apiUrl: plan.primaryUrl, - errorMessage: plan.unsupportedReason || 'unsupported-panel' - }; - return results; - }, {}); - setPanelExecutionResults(initialResults); - - executionPlans - .filter(plan => plan.state === 'ready' && plan.primaryUrl) - .forEach(plan => { - void executeSignalDashboardPanelPlan(plan).then(result => { - if (cancelled) return; - setPanelExecutionResults(current => ({ - ...current, - [result.panelId]: result - })); - }); - }); - - return () => { - cancelled = true; - }; - }, [executionPlans, refreshTick]); - - const saveLayout = async () => { - if (drafts.length === 0) return; - setCompositionState('saving'); - try { - const dashboardKey = normalizeSignalDashboardKey(dashboardKeyDraft || dashboardTitleDraft); - const dashboard = buildSignalDashboardCompositionFromDrafts({ - dashboardKey, - title: dashboardTitleDraft.trim() || defaultDashboardTitle, - description: dashboardDescriptionDraft.trim() || defaultDashboardDescription, - tags: SIGNALS.map(signal => t(`dashboard.add-panel.signal.${signal}`)), - drafts - }); - const saved = await saveSignalDashboard(dashboard); - const savedText = resolveDashboardDisplayText(saved, { - title: defaultDashboardTitle, - description: defaultDashboardDescription - }); - setDashboardKeyDraft(saved.dashboardKey); - setDashboardTitleDraft(savedText.title); - setDashboardDescriptionDraft(savedText.description); - replaceDashboardDeepLink(saved.dashboardKey); - setDashboards(current => [saved, ...current.filter(item => item.dashboardKey !== saved.dashboardKey)]); - setCompositionState('saved'); - } catch { - setCompositionState('error'); - } - }; - - const updatePreviewLayout = (widgetId: string, patch: SignalDashboardLayoutPatch) => { - if (!selectedDashboard) return; - setDashboards(current => current.map(dashboard => - dashboard.dashboardKey === selectedDashboard.dashboardKey - ? updateSignalDashboardPanelLayout(dashboard, widgetId, patch) - : dashboard - )); - setCompositionState(current => current === 'error' ? current : 'ready'); - }; - - const savePreviewLayout = async () => { - if (!selectedDashboard || previewPanels.length === 0) return; - setSavingPreviewLayout(true); - setCompositionState('saving'); - try { - const saved = await saveSignalDashboard(selectedDashboard); - setDashboards(current => [saved, ...current.filter(item => item.dashboardKey !== saved.dashboardKey)]); - setCompositionState('saved'); - } catch { - setCompositionState('error'); - } finally { - setSavingPreviewLayout(false); - } - }; - - const saveServiceOverviewDashboard = async () => { - if (!serviceOverviewContext) return; - setSavingServiceOverview(true); - setCompositionState('saving'); - try { - const dashboard = buildSignalServiceOverviewDashboard({ - ...serviceOverviewContext, - ...dashboardTimeRange - }); - const saved = await saveSignalDashboard(dashboard); - const savedText = resolveDashboardDisplayText(saved, { - title: defaultDashboardTitle, - description: defaultDashboardDescription - }); - setDashboardKeyDraft(saved.dashboardKey); - setDashboardTitleDraft(savedText.title); - setDashboardDescriptionDraft(savedText.description); - replaceDashboardDeepLink(saved.dashboardKey); - setDashboards(current => [saved, ...current.filter(item => item.dashboardKey !== saved.dashboardKey)]); - setCompositionState('saved'); - } catch { - setCompositionState('error'); - } finally { - setSavingServiceOverview(false); - } - }; - - const saveOperationDrilldownDashboard = async () => { - if (!operationDrilldownContext) return; - setSavingOperationDrilldown(true); - setCompositionState('saving'); - try { - const dashboard = buildSignalOperationDrilldownDashboard({ - ...operationDrilldownContext, - ...dashboardTimeRange - }); - const saved = await saveSignalDashboard(dashboard); - const savedText = resolveDashboardDisplayText(saved, { - title: defaultDashboardTitle, - description: defaultDashboardDescription - }); - setDashboardKeyDraft(saved.dashboardKey); - setDashboardTitleDraft(savedText.title); - setDashboardDescriptionDraft(savedText.description); - replaceDashboardDeepLink(saved.dashboardKey); - setDashboards(current => [saved, ...current.filter(item => item.dashboardKey !== saved.dashboardKey)]); - setCompositionState('saved'); - } catch { - setCompositionState('error'); - } finally { - setSavingOperationDrilldown(false); - } - }; - - const replaceSelectedDashboardVariables = (variables: SignalDashboardVariable[]) => { - if (!selectedDashboard) return; - setDashboards(current => current.map(dashboard => - dashboard.dashboardKey === selectedDashboard.dashboardKey - ? updateSignalDashboardVariables(dashboard, variables) - : dashboard - )); - setCompositionState(current => current === 'error' ? current : 'ready'); - }; - - const addVariable = () => { - if (!selectedDashboard) return; - const options = variableOptionsDraft.split(',').map(option => option.trim()).filter(Boolean); - const nextVariable: SignalDashboardVariable = { - name: variableNameDraft, - type: variableTypeDraft, - value: variableValueDraft, - description: variableDescriptionDraft || undefined, - options, - multi: variableTypeDraft === 'custom' && options.length > 1 - }; - replaceSelectedDashboardVariables([ - nextVariable, - ...dashboardVariables.filter(variable => variable.name !== nextVariable.name) - ]); - replaceDashboardVariableDeepLink(nextVariable.name, nextVariable.value); - }; - - const deleteVariable = (name: string) => { - replaceSelectedDashboardVariables(dashboardVariables.filter(variable => variable.name !== name)); - replaceDashboardVariableDeepLink(name, ''); - }; - - const selectVariableOption = (name: string, value: string) => { - const nextVariables = selectSignalDashboardVariableOption(dashboardVariables, name, value); - replaceSelectedDashboardVariables(nextVariables); - replaceDashboardVariableDeepLink( - name, - nextVariables.find(variable => variable.name === name)?.value || '' - ); - }; - - const addEvidenceFilterVariable = (variableName: string, variableType: SignalDashboardVariableType, value: string) => { - const nextVariable: SignalDashboardVariable = { - name: variableName, - type: variableType, - value, - description: undefined, - options: variableType === 'custom' ? [value] : [], - multi: false - }; - replaceSelectedDashboardVariables([ - nextVariable, - ...dashboardVariables.filter(variable => variable.name !== variableName) - ]); - replaceDashboardVariableDeepLink(nextVariable.name, nextVariable.value); - }; - - const addRuntimeEvidencePanelDraft = async ( - row: Parameters[0]['row'], - route: string - ) => { - const draft = createSignalDashboardPanelDraftFromRuntimeEvidence({ - row, - route, - titlePrefix: t('dashboard.runtime.sync.evidence-panel-title-prefix') - }); - if (!draft) return; - setSavingRuntimeEvidenceKey(row.key); - try { - const saved = await saveSignalDashboardPanelDraft(draft); - setDrafts(current => [ - saved, - ...current.filter(item => `${item.signal}:${item.draftKey}` !== `${saved.signal}:${saved.draftKey}`) - ]); - setLoadState('ready'); - } catch { - setLoadState('error'); - } finally { - setSavingRuntimeEvidenceKey(null); - } - }; - - const addRuntimeBreakoutPanelDraft = async ( - row: Parameters[0]['row'], - route: string, - attribute: Parameters[0]['attribute'] - ) => { - const draft = createSignalDashboardPanelDraftFromRuntimeBreakout({ - row, - route, - attribute, - titlePrefix: t('dashboard.runtime.sync.breakout-panel-title-prefix') - }); - if (!draft) return; - const savingKey = `${row.key}:breakout:${attribute.name}`; - setSavingRuntimeEvidenceKey(savingKey); - try { - const saved = await saveSignalDashboardPanelDraft(draft); - setDrafts(current => [ - saved, - ...current.filter(item => `${item.signal}:${item.draftKey}` !== `${saved.signal}:${saved.draftKey}`) - ]); - setLoadState('ready'); - } catch { - setLoadState('error'); - } finally { - setSavingRuntimeEvidenceKey(null); - } - }; - - const findFilterPanelExecutionPlan = (variable: SignalDashboardVariable) => { - const variableName = variable.name.trim(); - const variableValue = variable.value.trim(); - if (!variableName || !variableValue) return null; - return executionPlans.find(plan => { - if (plan.state !== 'ready' || !plan.resolvedRoute) return false; - return plan.sourceRoute.includes(`$${variableName}`) || plan.resolvedRoute.includes(variableValue); - }) || null; - }; - - const addFilterSelectionPanelDraft = async (variable: SignalDashboardVariable) => { - const executionPlan = findFilterPanelExecutionPlan(variable); - const filterKey = `${variable.name}:${variable.value}`; - const nextDrafts = executionPlan ? createSignalDashboardPanelDraftsFromFilterSelection({ - variable, - signal: executionPlan.signal, - sourcePanelId: executionPlan.panelId, - route: executionPlan.resolvedRoute, - titlePrefix: t('dashboard.composition.filter-toolbar.panel-title-prefix') - }) : []; - if (nextDrafts.length === 0) return; - setSavingFilterPanelKey(filterKey); - try { - const savedDrafts: SignalDashboardPanelDraft[] = []; - for (const draft of nextDrafts) { - savedDrafts.push(await saveSignalDashboardPanelDraft(draft)); - } - const savedKeys = new Set(savedDrafts.map(item => `${item.signal}:${item.draftKey}`)); - setDrafts(current => [ - ...savedDrafts, - ...current.filter(item => !savedKeys.has(`${item.signal}:${item.draftKey}`)) - ]); - setLoadState('ready'); - if (selectedDashboard) { - const nextDashboard = mergeSignalDashboardDraftsIntoComposition(selectedDashboard, savedDrafts); - const savedDashboard = await saveSignalDashboard(nextDashboard); - const savedDashboardText = resolveDashboardDisplayText(savedDashboard, { - title: defaultDashboardTitle, - description: defaultDashboardDescription - }); - setDashboards(current => [savedDashboard, ...current.filter(item => item.dashboardKey !== savedDashboard.dashboardKey)]); - setDashboardTitleDraft(savedDashboardText.title); - setDashboardDescriptionDraft(savedDashboardText.description); - setCompositionState('saved'); - } - } catch { - setLoadState('error'); - setCompositionState('error'); - } finally { - setSavingFilterPanelKey(null); - } - }; - - const saveVariables = async () => { - if (!selectedDashboard) return; - setSavingVariables(true); - setCompositionState('saving'); - try { - const saved = await saveSignalDashboard(selectedDashboard); - setDashboards(current => [saved, ...current.filter(item => item.dashboardKey !== saved.dashboardKey)]); - setCompositionState('saved'); - } catch { - setCompositionState('error'); - } finally { - setSavingVariables(false); - } - }; - - const selectDashboard = (dashboard: SignalDashboard) => { - const dashboardText = resolveDashboardDisplayText(dashboard, { - title: defaultDashboardTitle, - description: defaultDashboardDescription - }); - setDashboardKeyDraft(dashboard.dashboardKey); - setDashboardTitleDraft(dashboardText.title); - setDashboardDescriptionDraft(dashboardText.description); - replaceDashboardDeepLink(dashboard.dashboardKey); - }; - - const deleteDashboard = async (dashboard: SignalDashboard) => { - setDeletingDashboardKey(dashboard.dashboardKey); - try { - await deleteSignalDashboard(dashboard.dashboardKey); - setDashboards(current => { - const nextDashboards = current.filter(item => item.dashboardKey !== dashboard.dashboardKey); - const nextSelected = nextDashboards[0]; - if (dashboardKeyDraft === dashboard.dashboardKey) { - const nextDashboardKey = nextSelected?.dashboardKey || 'signals-overview'; - const nextSelectedText = nextSelected - ? resolveDashboardDisplayText(nextSelected, { - title: defaultDashboardTitle, - description: defaultDashboardDescription - }) - : null; - setDashboardKeyDraft(nextDashboardKey); - setDashboardTitleDraft(nextSelectedText?.title || defaultDashboardTitle); - setDashboardDescriptionDraft(nextSelectedText?.description || defaultDashboardDescription); - replaceDashboardDeepLink(nextDashboardKey); - } - setCompositionState(nextDashboards.length > 0 ? 'ready' : 'empty'); - return nextDashboards; - }); - } catch { - setCompositionState('error'); - } finally { - setDeletingDashboardKey(null); - } - }; - - const deleteDraft = async (draft: SignalDashboardPanelDraft) => { - const rowKey = `${draft.signal}:${draft.draftKey}`; - setDeletingDraftKey(rowKey); - try { - await deleteSignalDashboardPanelDraft(draft.signal, draft.draftKey); - setDrafts(current => { - const nextDrafts = current.filter(item => `${item.signal}:${item.draftKey}` !== rowKey); - return nextDrafts; - }); - setLoadState(current => current === 'error' ? current : drafts.length > 1 ? 'ready' : 'empty'); - } catch { - setLoadState('error'); - } finally { - setDeletingDraftKey(null); - } - }; - - const duplicateDraft = async (draft: SignalDashboardPanelDraft) => { - const rowKey = `${draft.signal}:${draft.draftKey}`; - setDuplicatingDraftKey(rowKey); - try { - const duplicate = duplicateSignalDashboardPanelDraft(draft, { - titleSuffix: t('dashboard.add-panel.duplicate-title-suffix') - }); - const saved = await saveSignalDashboardPanelDraft(duplicate); - setDrafts(current => [ - saved, - ...current.filter(item => `${item.signal}:${item.draftKey}` !== `${saved.signal}:${saved.draftKey}`) - ]); - setLoadState('ready'); - } catch { - setLoadState('error'); - } finally { - setDuplicatingDraftKey(null); - } - }; - - const addSavedViewAsPanelDraft = async (view: SignalSavedQueryViewWithSignal) => { - const rowKey = savedViewRowKey(view); - setPromotingSavedViewKey(rowKey); - setSavedViewLoadState(current => current === 'error' ? current : 'ready'); - try { - const draft = createSignalDashboardPanelDraftFromSavedView(view.signal, view); - const saved = await saveSignalDashboardPanelDraft(draft); - setDrafts(current => [ - saved, - ...current.filter(item => `${item.signal}:${item.draftKey}` !== `${saved.signal}:${saved.draftKey}`) - ]); - setLoadState('ready'); - } catch { - setSavedViewLoadState('error'); - } finally { - setPromotingSavedViewKey(null); - } - }; - - const deleteSavedView = async (view: SignalSavedQueryViewWithSignal) => { - const rowKey = savedViewRowKey(view); - setDeletingSavedViewKey(rowKey); - try { - await deleteSignalSavedQueryView(view.signal, view.id); - setSavedViews(current => { - const nextSavedViews = current.filter(item => savedViewRowKey(item) !== rowKey); - setSavedViewLoadState(state => state === 'error' ? state : nextSavedViews.length > 0 ? 'ready' : 'empty'); - return nextSavedViews; - }); - setSavedViewDrafts(current => { - const nextDrafts = { ...current }; - delete nextDrafts[rowKey]; - return nextDrafts; - }); - } catch { - setSavedViewLoadState('error'); - } finally { - setDeletingSavedViewKey(null); - } - }; - - const updateSavedViewDraft = (view: SignalSavedQueryViewWithSignal, patch: Partial) => { - const rowKey = savedViewRowKey(view); - setSavedViewDrafts(current => ({ - ...current, - [rowKey]: { - label: current[rowKey]?.label ?? view.label, - description: current[rowKey]?.description ?? view.description, - ...patch - } - })); - }; - - const saveSavedViewMetadata = async (view: SignalSavedQueryViewWithSignal) => { - const rowKey = savedViewRowKey(view); - const draft = savedViewDrafts[rowKey] || { label: view.label, description: view.description }; - setSavingSavedViewKey(rowKey); - try { - const saved = await saveSignalSavedQueryView(view.signal, { - ...view, - label: draft.label.trim() || view.label, - description: draft.description - }); - const nextView: SignalSavedQueryViewWithSignal = { ...saved, signal: view.signal }; - setSavedViews(current => current.map(item => savedViewRowKey(item) === rowKey ? nextView : item)); - setSavedViewDrafts(current => ({ - ...current, - [rowKey]: { - label: nextView.label, - description: nextView.description - } - })); - setSavedViewLoadState(current => current === 'error' ? current : 'ready'); - } catch { - setSavedViewLoadState('error'); - } finally { - setSavingSavedViewKey(null); - } - }; - - const columns: HzDataColumn[] = [ - { - key: 'panel', - header: t('dashboard.panel-drafts.column.panel'), - render: draft => { - const sourceSummary = readPanelDraftSourceSummary(draft); - return ( - - {draft.title} - {draft.description || draft.draftKey} - {sourceSummary ? ( - - {sourceSummary} - - ) : null} - - ); - } - }, - { - key: 'signal', - header: t('dashboard.panel-drafts.column.signal'), - width: '112px', - render: draft => {t(`dashboard.add-panel.signal.${draft.signal}`)} - }, - { - key: 'visualization', - header: t('dashboard.panel-drafts.column.visualization'), - width: '132px', - render: draft => {visualizationLabel(draft.visualization)} - }, - { - key: 'route', - header: t('dashboard.panel-drafts.column.route'), - render: draft => {draft.route} - }, - { - key: 'updated', - header: t('dashboard.panel-drafts.column.updated'), - width: '172px', - render: draft => {formatUpdatedAt(draft.updateTime || draft.createTime, locale)} - }, - { - key: 'actions', - header: t('dashboard.panel-drafts.column.actions'), - width: '210px', - render: draft => { - const rowKey = `${draft.signal}:${draft.draftKey}`; - return ( -
- - - {t('dashboard.add-panel.action.open-explorer')} - - void duplicateDraft(draft)} - data-dashboard-panel-draft-action="duplicate" - > - - {t('dashboard.add-panel.action.duplicate-draft')} - - void deleteDraft(draft)} - data-dashboard-panel-draft-action="delete" - > - - {t('dashboard.add-panel.action.delete-draft')} - -
- ); - } - } - ]; - - const savedViewColumns: HzDataColumn[] = [ - { - key: 'view', - header: t('dashboard.saved-views.column.view'), - render: view => { - const rowKey = savedViewRowKey(view); - const draft = savedViewDrafts[rowKey] || { label: view.label, description: view.description }; - return ( - - - - {view.id} - - ); - } - }, - { - key: 'signal', - header: t('dashboard.saved-views.column.signal'), - width: '112px', - render: view => {t(`dashboard.add-panel.signal.${view.signal}`)} - }, - { - key: 'route', - header: t('dashboard.saved-views.column.route'), - render: view => {view.route} - }, - { - key: 'updated', - header: t('dashboard.saved-views.column.created'), - width: '172px', - render: view => {new Intl.DateTimeFormat(locale, { - dateStyle: 'medium', - timeStyle: 'short' - }).format(view.createdAt)} - }, - { - key: 'actions', - header: t('dashboard.saved-views.column.actions'), - width: '240px', - render: view => { - const rowKey = savedViewRowKey(view); - return ( -
- void saveSavedViewMetadata(view)} - data-dashboard-saved-view-action="update" - > - - {t('dashboard.saved-views.action.update')} - - - - {t('dashboard.add-panel.action.open-explorer')} - - void addSavedViewAsPanelDraft(view)} - data-dashboard-saved-view-action="add-panel" - > - - {t('dashboard.saved-views.action.add-panel')} - - void deleteSavedView(view)} - data-dashboard-saved-view-action="delete" - > - - {t('dashboard.saved-views.action.delete')} - -
- ); - } - } - ]; - - const statusCopy = t(`dashboard.panel-drafts.status.${loadState}`); - const savedViewStatusCopy = savedViewLoadState === 'partial' - ? t('dashboard.saved-views.status.partial', { - signals: savedViewFailedSignals.map(signal => t(`dashboard.add-panel.signal.${signal}`)).join(', ') - }) - : t(`dashboard.saved-views.status.${savedViewLoadState}`); - - return ( -
- - void saveLayout()} - data-dashboard-save-layout-action="signal-dashboard" - > - - {t('dashboard.composition.action.save-layout')} - - - - {t('dashboard.panel-drafts.action.overview')} - - - } - metricStrip={ -
-
- {metrics.map(metric => ( - - {metric.label} -
{metric.value}
-
- ))} -
-
- } - > -
- -
-
- {t('dashboard.composition.target-title')} - - {normalizeSignalDashboardKey(dashboardKeyDraft || dashboardTitleDraft)} - -
- - - -
-
- -
-
-

{t('dashboard.composition.variables-title')}

-

- {selectedDashboard ? t('dashboard.composition.variables-copy') : t('dashboard.composition.variables-empty-copy')} -

-
- void saveVariables()} - data-dashboard-composition-variables-action="save" - > - - {t('dashboard.composition.action.save-variables')} - -
- {selectedDashboard ? ( -
-
- - - - - -
- - {t('dashboard.composition.action.add-variable')} - -
-
- {dashboardVariables.length > 0 ? ( -
- {dashboardVariables.map(variable => { - const variableOptions = dashboardVariableOptions[variable.name] || []; - const runtimeOptionCount = variableOptions.filter(option => option.source === 'runtime').length; - const staticOptionCount = variableOptions.filter(option => option.source === 'static').length; - const selectedVariableValues = variable.value.split(',').map(value => value.trim()).filter(Boolean); - return ( -
- - {`$${variable.name}`} - - {variable.description || t('dashboard.composition.variable.description-empty')} - - - {t(`dashboard.composition.variable.type.${variable.type}`)} -
- - {variable.options && variable.options.length > 0 ? variable.options.join(',') : variable.value || '-'} - - {variableOptions.length > 0 ? ( -
- {variableOptions.slice(0, 6).map(option => { - const selected = variable.multi - ? selectedVariableValues.includes(option.value) - : variable.value.trim() === option.value; - return ( - - ); - })} -
- ) : null} -
- deleteVariable(variable.name)} - data-dashboard-composition-variables-action="delete" - > - - {t('dashboard.composition.action.delete-variable')} - -
- ); - })} -
- ) : ( - - )} -
- ) : ( - - )} -
- -
-
-

{t('dashboard.composition.preview-title')}

-

- {selectedDashboard - ? t('dashboard.composition.preview-copy') - : t('dashboard.composition.preview-empty-copy')} -

-
- 0 ? 'success' : 'warning'} - label={t('dashboard.composition.widget-count')} - value={previewPanels.length} - /> - - - void savePreviewLayout()} - data-dashboard-composition-preview-action="save-layout" - > - - {t('dashboard.composition.action.save-preview-layout')} - - void saveServiceOverviewDashboard()} - data-dashboard-service-overview-action="save" - data-dashboard-service-overview-action-state={serviceOverviewContext ? 'ready' : 'missing'} - data-dashboard-service-overview-action-service={serviceOverviewContext?.serviceName || ''} - > - - {t('dashboard.composition.action.save-service-overview')} - - void saveOperationDrilldownDashboard()} - data-dashboard-operation-drilldown-action="save" - data-dashboard-operation-drilldown-action-state={operationDrilldownContext ? 'ready' : 'missing'} - data-dashboard-operation-drilldown-action-service={operationDrilldownContext?.serviceName || ''} - data-dashboard-operation-drilldown-action-operation={operationDrilldownContext?.operationName || ''} - > - - {t('dashboard.composition.action.save-operation-drilldown')} - -
-
-
- {t('dashboard.runtime.sync.tooltip')} -
- - {runtimeSyncTooltip.timestamp || t('dashboard.runtime.sync.idle')} - - setRuntimePinnedSyncTimestamp('')} - data-dashboard-composition-runtime-sync-action="clear-pin" - data-dashboard-composition-runtime-sync-action-state={runtimePinnedSyncTimestamp ? 'enabled' : 'disabled'} - > - - {t('dashboard.runtime.sync.clear-pin')} - -
-
- {runtimeSyncTooltip.rows.length > 0 ? ( -
- {runtimeSyncTooltip.rows.slice(0, 6).map(row => { - const rowExecutionPlan = executionPlanByPanelId.get(row.panelId); - const rowHandoffHref = buildSignalDashboardRuntimeEvidenceSourceHandoff(rowExecutionPlan?.resolvedRoute, row, { - timeRange: dashboardExecutionTimeRange, - returnTo: dashboardReturnHref - }); - const rowFilterCandidates = buildSignalDashboardRuntimeEvidenceFilters(dashboardVariables, row); - const rowFilterSuggestions = buildSignalDashboardRuntimeEvidenceFilterSuggestions(dashboardVariables, row); - return ( -
- {row.signal} - {row.source} - {row.label} - {row.value} - - {rowFilterCandidates.slice(0, 2).map(candidate => ( - selectVariableOption(candidate.variableName, candidate.value)} - data-dashboard-composition-runtime-sync-tooltip-row-action="apply-filter" - data-dashboard-composition-runtime-sync-tooltip-row-action-variable={candidate.variableName} - data-dashboard-composition-runtime-sync-tooltip-row-action-value={candidate.value} - data-dashboard-composition-runtime-sync-tooltip-row-action-filter-source={candidate.source} - > - {t('dashboard.runtime.sync.apply-filter')} - - ))} - {rowFilterSuggestions.slice(0, 2).map(suggestion => ( - addEvidenceFilterVariable(suggestion.variableName, suggestion.variableType, suggestion.value)} - data-dashboard-composition-runtime-sync-tooltip-row-action="add-filter-variable" - data-dashboard-composition-runtime-sync-tooltip-row-action-variable={suggestion.variableName} - data-dashboard-composition-runtime-sync-tooltip-row-action-value={suggestion.value} - data-dashboard-composition-runtime-sync-tooltip-row-action-filter-source={suggestion.source} - data-dashboard-composition-runtime-sync-tooltip-row-action-variable-type={suggestion.variableType} - > - {t('dashboard.runtime.sync.add-filter')} - - ))} - {(row.breakoutAttributes || []).slice(0, 2).map(attribute => { - const savingKey = `${row.key}:breakout:${attribute.name}`; - return ( - void addRuntimeBreakoutPanelDraft(row, rowHandoffHref, attribute)} - data-dashboard-composition-runtime-sync-tooltip-row-action="breakout-panel-draft" - data-dashboard-composition-runtime-sync-tooltip-row-action-panel={row.panelId} - data-dashboard-composition-runtime-sync-tooltip-row-action-attribute={attribute.name} - data-dashboard-composition-runtime-sync-tooltip-row-action-value={attribute.value} - data-dashboard-composition-runtime-sync-tooltip-row-action-href={rowHandoffHref} - > - - {t('dashboard.runtime.sync.breakout-panel-draft')} - - ); - })} - {row.relatedHandoffHref ? ( - - - {t('dashboard.runtime.sync.open-related')} - - ) : null} - {rowHandoffHref ? ( - void addRuntimeEvidencePanelDraft(row, rowHandoffHref)} - data-dashboard-composition-runtime-sync-tooltip-row-action="add-panel-draft" - data-dashboard-composition-runtime-sync-tooltip-row-action-panel={row.panelId} - data-dashboard-composition-runtime-sync-tooltip-row-action-href={rowHandoffHref} - > - - {t('dashboard.runtime.sync.add-panel-draft')} - - ) : null} - {rowHandoffHref ? ( - - - {t('dashboard.runtime.sync.open-source')} - - ) : null} - -
- ); - })} -
- ) : null} -
-
-
- {t('dashboard.composition.filter-toolbar.title')} - 0 ? 'info' : 'neutral'} - label={t('dashboard.composition.filter-toolbar.variables')} - value={dashboardVariables.length} - /> -
- {selectedDashboard && dashboardVariables.length > 0 ? ( -
- {dashboardVariables.map(variable => { - const variableOptions = dashboardVariableOptions[variable.name] || []; - const optionSearch = filterOptionSearchDrafts[variable.name] || ''; - const visibleVariableOptions = filterSignalDashboardVariableOptions(variableOptions, optionSearch); - const selectedVariableValues = variable.value.split(',').map(value => value.trim()).filter(Boolean); - const filterPanelExecutionPlan = findFilterPanelExecutionPlan(variable); - const filterPanelDraftTemplates = filterPanelExecutionPlan ? createSignalDashboardPanelDraftsFromFilterSelection({ - variable, - signal: filterPanelExecutionPlan.signal, - sourcePanelId: filterPanelExecutionPlan.panelId, - route: filterPanelExecutionPlan.resolvedRoute, - titlePrefix: t('dashboard.composition.filter-toolbar.panel-title-prefix') - }) : []; - const filterPanelKey = `${variable.name}:${variable.value}`; - const filterSelectOptions = [ - { value: '', label: t('dashboard.composition.filter-toolbar.any') }, - ...(variable.value && !visibleVariableOptions.some(option => option.value === variable.value) - ? [{ value: variable.value, label: variable.value }] - : []), - ...visibleVariableOptions.map(option => ({ value: option.value, label: option.label })) - ]; - return ( -
0 ? 'true' : 'false'} - > - {`$${variable.name}`} - - {variable.value || t('dashboard.composition.filter-toolbar.any')} - - setFilterOptionSearchDrafts(current => ({ - ...current, - [variable.name]: event.target.value - }))} - placeholder={t('dashboard.composition.filter-toolbar.search')} - data-dashboard-composition-filter-search={variable.name} - data-dashboard-composition-filter-search-value={optionSearch} - data-dashboard-composition-filter-search-results={visibleVariableOptions.length} - /> - selectVariableOption(variable.name, event.target.value)} - disabled={variableOptions.length === 0} - data-dashboard-composition-filter-select={variable.name} - data-dashboard-composition-filter-select-value={variable.multi ? '' : variable.value.trim()} - data-dashboard-composition-filter-select-options={filterSelectOptions.length} - aria-label={`${t('dashboard.composition.filter-toolbar.title')} ${variable.name}`} - /> - {visibleVariableOptions.slice(0, 5).map(option => { - const selected = variable.multi - ? selectedVariableValues.includes(option.value) - : variable.value.trim() === option.value; - return ( - - ); - })} - {filterPanelExecutionPlan ? ( - void addFilterSelectionPanelDraft(variable)} - data-dashboard-composition-filter-variable-action="add-panel-draft" - data-dashboard-composition-filter-variable-action-variable={variable.name} - data-dashboard-composition-filter-variable-action-value={variable.value} - data-dashboard-composition-filter-variable-action-panel={filterPanelExecutionPlan.panelId} - data-dashboard-composition-filter-variable-action-templates={filterPanelDraftTemplates.length} - data-dashboard-composition-filter-variable-action-compose={selectedDashboard ? 'dashboard' : 'drafts'} - > - - {t('dashboard.composition.filter-toolbar.add-panel-draft')} - - ) : null} -
- ); - })} -
- ) : null} -
-
- - - - - -
- setRefreshTick(current => current + 1)} - data-dashboard-composition-time-range-action="refresh" - > - - {t('common.refresh')} - -
-
- {selectedDashboard && previewPanels.length > 0 ? ( -
- {previewPanels.map(panel => { - const signal = SIGNALS.includes(panel.widget.signal as SignalDashboardPanelDraftSignal) - ? panel.widget.signal as SignalDashboardPanelDraftSignal - : 'metrics'; - const panelEditMetadata = readSignalDashboardWidgetPanelEditMetadata(panel.widget); - const panelEditHref = buildSignalDashboardPanelEditHref({ - route: panel.resolvedRoute, - dashboardKey: selectedDashboard.dashboardKey, - panelId: panel.widget.id, - draftKey: panel.widget.draftKey, - returnTo: dashboardReturnHref, - returnLabel: selectedDashboard.title || t('menu.dashboard') - }); - const executionPlan = executionPlanByPanelId.get(panel.widget.id); - const executionResult = panelExecutionResults[panel.widget.id]; - const runtimeSummary = executionPlan - ? summarizeSignalDashboardPanelRuntime(executionPlan, executionResult) - : null; - const runtimeRenderer = runtimeRendererByPanelId.get(panel.widget.id) || null; - return ( -
-
-
- {t(`dashboard.add-panel.signal.${signal}`)} - {visualizationLabel(panel.widget.visualization)} -
- {panel.widget.title} - - {panel.widget.description || panel.widget.draftKey || panel.widget.id} - - - {panel.resolvedQuerySnapshot} - - - {executionPlan?.primaryUrl || panel.resolvedRoute} - - {runtimeRenderer ? ( -
- -
- ) : null} -
-
- - {`${panel.layout.w}x${panel.layout.h}`} - -
- updatePreviewLayout(panel.widget.id, { dx: -1 })} - data-dashboard-composition-layout-action="move-left" - > - - {t('dashboard.composition.layout.move-left')} - - updatePreviewLayout(panel.widget.id, { dx: 1 })} - data-dashboard-composition-layout-action="move-right" - > - - {t('dashboard.composition.layout.move-right')} - - updatePreviewLayout(panel.widget.id, { dy: -1 })} - data-dashboard-composition-layout-action="move-up" - > - - {t('dashboard.composition.layout.move-up')} - - updatePreviewLayout(panel.widget.id, { dy: 1 })} - data-dashboard-composition-layout-action="move-down" - > - - {t('dashboard.composition.layout.move-down')} - - updatePreviewLayout(panel.widget.id, { dw: 1 })} - data-dashboard-composition-layout-action="wider" - > - - {t('dashboard.composition.layout.wider')} - - updatePreviewLayout(panel.widget.id, { dw: -1 })} - data-dashboard-composition-layout-action="narrower" - > - - {t('dashboard.composition.layout.narrower')} - - updatePreviewLayout(panel.widget.id, { dh: 1 })} - data-dashboard-composition-layout-action="taller" - > - - {t('dashboard.composition.layout.taller')} - - updatePreviewLayout(panel.widget.id, { dh: -1 })} - data-dashboard-composition-layout-action="shorter" - > - - {t('dashboard.composition.layout.shorter')} - -
- - - {t('dashboard.composition.action.edit-panel')} - - - - {t('dashboard.add-panel.action.open-explorer')} - -
-
- ); - })} -
- ) : ( - - )} -
- -
-
-

{t('dashboard.composition.saved-title')}

-

{t(`dashboard.composition.status.${compositionState}`)}

-
- - {t(`dashboard.composition.status-label.${compositionState}`)} - -
- {dashboards.length > 0 ? ( -
- {dashboards.map(dashboard => { - const dashboardText = resolveDashboardDisplayText(dashboard, { - title: defaultDashboardTitle, - description: defaultDashboardDescription - }); - return ( -
- - {dashboardText.title} - - {dashboardText.description || dashboard.dashboardKey} - - - - {formatUpdatedAt(dashboard.updateTime || dashboard.createTime, locale)} -
- selectDashboard(dashboard)} - data-dashboard-composition-action="select" - > - - {t('dashboard.composition.action.select-dashboard')} - - void deleteDashboard(dashboard)} - data-dashboard-composition-action="delete" - > - - {t('dashboard.composition.action.delete-dashboard')} - -
-
- ); - })} -
- ) : ( - - )} -
- -
-
-

{t('dashboard.saved-views.title')}

-

{savedViewStatusCopy}

-
- - {t(`dashboard.panel-drafts.status-label.${savedViewLoadState}`)} - -
- {savedViewLoadState === 'loading' || savedViewLoadState === 'error' || savedViewLoadState === 'empty' ? ( - : undefined} - layout="table-panel" - data-dashboard-saved-views-empty-state={savedViewLoadState} - /> - ) : null} - - columns={savedViewColumns} - rows={savedViews} - getRowKey={view => `${view.signal}:${view.id}`} - emptyLabel={t('dashboard.saved-views.status.empty')} - getRowProps={view => ({ - 'data-dashboard-saved-view-row': view.id, - 'data-dashboard-saved-view-label': view.label, - 'data-dashboard-saved-view-description': view.description, - 'data-dashboard-saved-view-signal': view.signal, - 'data-dashboard-saved-view-route': view.route - })} - /> -
- -
-
-

{t('dashboard.add-panel.saved-title')}

-

{statusCopy}

-
- - {t(`dashboard.panel-drafts.status-label.${loadState}`)} - -
- {loadState === 'loading' || loadState === 'error' || loadState === 'empty' ? ( - : undefined} - layout="table-panel" - data-dashboard-panel-drafts-empty-state={loadState} - /> - ) : null} - - columns={columns} - rows={drafts} - getRowKey={draft => `${draft.signal}:${draft.draftKey}`} - emptyLabel={t('dashboard.panel-drafts.status.empty')} - getRowProps={draft => ({ - 'data-dashboard-panel-draft-row': draft.draftKey, - 'data-dashboard-panel-draft-signal': draft.signal, - 'data-dashboard-panel-draft-visualization': draft.visualization, - 'data-dashboard-panel-draft-route': draft.route, - 'data-dashboard-panel-draft-source-summary': readPanelDraftSourceSummary(draft) - })} - /> -
-
-
-
- ); -} diff --git a/web-next/app/dashboard/page.test.ts b/web-next/app/dashboard/page.test.ts deleted file mode 100644 index 25ecffa8a3..0000000000 --- a/web-next/app/dashboard/page.test.ts +++ /dev/null @@ -1,486 +0,0 @@ -import { readFileSync } from 'node:fs'; -import { resolve } from 'node:path'; -import React from 'react'; -import { renderToStaticMarkup } from 'react-dom/server'; -import { describe, expect, it, vi } from 'vitest'; - -vi.mock('../../components/providers/i18n-provider', () => ({ - useI18n: () => ({ - locale: 'en-US', - t: (key: string) => key - }) -})); - -describe('dashboard panel draft workspace route', () => { - it('renders a real dashboard workspace instead of redirecting to overview', async () => { - const { default: DashboardPage } = await import('./page'); - - const element = await DashboardPage({ - searchParams: Promise.resolve({ - source: 'explorer', - entityId: '7', - serviceName: 'checkout', - start: '100', - end: '200', - refresh: '30', - live: 'true' - }) - }); - const html = renderToStaticMarkup(element); - - expect(html).toContain('data-dashboard-workspace="signal-panel-drafts"'); - expect(html).toContain('data-dashboard-panel-draft-source="hertzbeat-api"'); - expect(html).toContain('data-dashboard-composition-source="hertzbeat-api"'); - expect(html).toContain('data-dashboard-panel-drafts-context-source="explorer"'); - expect(html).toContain('data-dashboard-panel-drafts-state="loading"'); - expect(html).toContain('data-dashboard-composition-state="loading"'); - expect(html).toContain('data-hz-ui="explorer-frame"'); - expect(html).toContain('app.frame.skip-to-workbench'); - expect(html).not.toContain('Skip to workbench'); - expect(html).toContain('data-hz-ui="data-table"'); - expect(html).toContain('data-dashboard-composition-target-owner="hertzbeat-ui-panel-surface"'); - expect(html).toContain('data-dashboard-composition-target-key="signals-overview"'); - expect(html).toContain('data-dashboard-composition-title-input="true"'); - expect(html).toContain('data-dashboard-composition-description-input="true"'); - expect(html).toContain('data-dashboard-composition-key-input="true"'); - expect(html).toContain('data-dashboard-composition-preview-owner="hertzbeat-ui-panel-surface"'); - expect(html).toContain('data-dashboard-composition-preview-key="signals-overview"'); - expect(html).toContain('data-dashboard-composition-preview-panels="0"'); - expect(html).toContain('data-dashboard-composition-time-range-mode="absolute"'); - expect(html).toContain('data-dashboard-composition-time-range-start="100"'); - expect(html).toContain('data-dashboard-composition-time-range-end="200"'); - expect(html).toContain('data-dashboard-composition-time-range-execution-start="100"'); - expect(html).toContain('data-dashboard-composition-time-range-execution-end="200"'); - expect(html).toContain('data-dashboard-composition-refresh-mode="auto"'); - expect(html).toContain('data-dashboard-composition-refresh-interval="30"'); - expect(html).toContain('data-dashboard-composition-refresh-tick="0"'); - expect(html).toContain('data-dashboard-composition-refresh-live="true"'); - expect(html).toContain('data-dashboard-composition-runtime-sync-timestamp=""'); - expect(html).toContain('data-dashboard-composition-runtime-sync-hover-timestamp=""'); - expect(html).toContain('data-dashboard-composition-runtime-sync-pinned-timestamp=""'); - expect(html).toContain('data-dashboard-composition-runtime-sync-state="idle"'); - expect(html).toContain('data-dashboard-composition-runtime-sync-pin-state="idle"'); - expect(html).toContain('data-dashboard-composition-runtime-sync-tooltip-state="idle"'); - expect(html).toContain('data-dashboard-composition-runtime-sync-tooltip-rows="0"'); - expect(html).toContain('data-dashboard-composition-runtime-sync-action="clear-pin"'); - expect(html).toContain('data-dashboard-composition-runtime-sync-action-state="disabled"'); - expect(html).toContain('data-dashboard-composition-filter-toolbar="true"'); - expect(html).toContain('data-dashboard-composition-filter-toolbar-state="no-selection"'); - expect(html).toContain('data-dashboard-composition-filter-toolbar-variables="0"'); - expect(html).toContain('data-dashboard-composition-filter-toolbar-options="0"'); - expect(html).toContain('data-dashboard-composition-time-range-control="true"'); - expect(html).toContain('data-dashboard-composition-preview-empty-state="no-selection"'); - expect(html).toContain('data-dashboard-composition-variables-owner="hertzbeat-ui-panel-surface"'); - expect(html).toContain('data-dashboard-composition-variables-count="0"'); - expect(html).toContain('data-dashboard-composition-variable-options-count="0"'); - expect(html).toContain('data-dashboard-composition-variables-state="no-selection"'); - expect(html).toContain('data-dashboard-composition-variables-empty-state="no-selection"'); - expect(html).toContain('data-dashboard-save-layout-action="signal-dashboard"'); - expect(html).toContain('data-dashboard-composition-list-owner="hertzbeat-ui-panel-surface"'); - }); - - it('uses the dashboard query key as the selected dashboard draft for deep links', async () => { - const { default: DashboardPage } = await import('./page'); - - const element = await DashboardPage({ - searchParams: Promise.resolve({ - dashboard: 'checkout-latency', - timeRange: 'last-1h' - }) - }); - const html = renderToStaticMarkup(element); - - expect(html).toContain('data-dashboard-composition-target-key="checkout-latency"'); - expect(html).toContain('data-dashboard-composition-preview-key="checkout-latency"'); - expect(html).toContain('data-dashboard-composition-time-range-mode="relative"'); - expect(html).toContain('data-dashboard-composition-time-range-preset="last-1h"'); - }); - - it('localizes only the built-in default dashboard display text', async () => { - const { resolveDashboardDisplayText } = await import('./dashboard-draft-workspace'); - const localizedDefaults = { - title: 'Signal Uebersicht', - description: 'Lokalisierte Standardbeschreibung' - }; - - expect(resolveDashboardDisplayText({ - dashboardKey: 'signals-overview', - title: 'Signals overview', - description: 'Dashboard composed from logs, traces, and metrics panel drafts.' - }, localizedDefaults)).toEqual(localizedDefaults); - expect(resolveDashboardDisplayText({ - dashboardKey: 'signals-overview', - title: 'Checkout SLO dashboard', - description: 'Team-owned dashboard' - }, localizedDefaults)).toEqual({ - title: 'Checkout SLO dashboard', - description: 'Team-owned dashboard' - }); - }); - - it('keeps dashboard route ownership as a primary page in the route catalog', () => { - const navSource = readFileSync(resolve(process.cwd(), '../web-next/lib/nav.ts'), 'utf8'); - - expect(navSource).toContain("key: 'dashboard'"); - expect(navSource).toContain("href: '/dashboard'"); - expect(navSource).toContain("routeKind: 'primary'"); - expect(navSource).not.toContain("key: 'dashboard',\n labelKey: 'menu.overview'"); - expect(navSource).not.toContain("redirectTo: '/overview'\n },\n {\n key: 'otlp'"); - }); - - it('loads and manages server-backed signal panel drafts from the client workspace', () => { - const pageSource = readFileSync(resolve(process.cwd(), '../web-next/app/dashboard/page.tsx'), 'utf8'); - const workspaceSource = readFileSync(resolve(process.cwd(), '../web-next/app/dashboard/dashboard-draft-workspace.tsx'), 'utf8'); - - expect(pageSource).not.toContain('redirect('); - expect(pageSource).toContain('DashboardDraftWorkspace'); - expect(workspaceSource).toContain('loadAllSignalDashboardPanelDrafts'); - expect(workspaceSource).toContain('loadAllSignalSavedQueryViewsWithDiagnostics'); - expect(workspaceSource).toContain("type SavedViewLoadState = DraftLoadState | 'partial'"); - expect(workspaceSource).toContain('setSavedViewFailedSignals(failedSignals)'); - expect(workspaceSource).toContain("setSavedViewLoadState(failedSignals.length > 0 && nextSavedViews.length > 0 ? 'partial'"); - expect(workspaceSource).toContain('data-dashboard-saved-views-failed-signals={savedViewFailedSignals.join'); - expect(workspaceSource).toContain("dashboard.saved-views.status.partial"); - expect(workspaceSource).toContain('createSignalDashboardPanelDraftFromSavedView'); - expect(workspaceSource).toContain('saveSignalSavedQueryView'); - expect(workspaceSource).toContain('deleteSignalSavedQueryView'); - expect(workspaceSource).toContain('saveSignalDashboardPanelDraft'); - expect(workspaceSource).toContain('deleteSignalDashboardPanelDraft'); - expect(workspaceSource).toContain('duplicateSignalDashboardPanelDraft'); - expect(workspaceSource).toContain('loadSignalDashboards'); - expect(workspaceSource).toContain('saveSignalDashboard'); - expect(workspaceSource).toContain('deleteSignalDashboard'); - expect(workspaceSource).toContain('buildSignalOperationDrilldownDashboard'); - expect(workspaceSource).toContain('buildSignalServiceOverviewDashboard'); - expect(workspaceSource).toContain('const serviceOverviewContext = useMemo('); - expect(workspaceSource).toContain('const serviceName = firstParamValue(initialContext.serviceName)?.trim()'); - expect(workspaceSource).toContain('const operationDrilldownContext = useMemo('); - expect(workspaceSource).toContain('const operationName = firstParamValue(initialContext.operationName)?.trim()'); - expect(workspaceSource).toContain('const saveServiceOverviewDashboard = async () =>'); - expect(workspaceSource).toContain('buildSignalServiceOverviewDashboard({'); - expect(workspaceSource).toContain('const saveOperationDrilldownDashboard = async () =>'); - expect(workspaceSource).toContain('buildSignalOperationDrilldownDashboard({'); - expect(workspaceSource).toContain('data-dashboard-service-overview-context'); - expect(workspaceSource).toContain('data-dashboard-service-overview-service'); - expect(workspaceSource).toContain('data-dashboard-service-overview-action="save"'); - expect(workspaceSource).toContain('data-dashboard-service-overview-action-state'); - expect(workspaceSource).toContain('dashboard.composition.action.save-service-overview'); - expect(workspaceSource).toContain('data-dashboard-operation-drilldown-context'); - expect(workspaceSource).toContain('data-dashboard-operation-drilldown-operation'); - expect(workspaceSource).toContain('data-dashboard-operation-drilldown-action="save"'); - expect(workspaceSource).toContain('data-dashboard-operation-drilldown-action-state'); - expect(workspaceSource).toContain('dashboard.composition.action.save-operation-drilldown'); - expect(workspaceSource).toContain('normalizeSignalDashboardKey'); - expect(workspaceSource).toContain('buildDashboardVariableDeepLinkHref'); - expect(workspaceSource).toContain('buildDashboardTimeRangeDeepLinkHref'); - expect(workspaceSource).toContain('buildDashboardReturnHref'); - expect(workspaceSource).toContain('readDashboardVariableUrlOverrides'); - expect(workspaceSource).toContain('function replaceDashboardDeepLink(dashboardKey: string)'); - expect(workspaceSource).toContain('buildDashboardDeepLinkHref(window.location.href, dashboardKey)'); - expect(workspaceSource).toContain('function replaceDashboardVariableDeepLink(variableName: string, value: string)'); - expect(workspaceSource).toContain('buildDashboardVariableDeepLinkHref(window.location.href, variableName, value)'); - expect(workspaceSource).toContain('function replaceDashboardTimeRangeDeepLink(timeRange: SignalDashboardTimeRange)'); - expect(workspaceSource).toContain('buildDashboardTimeRangeDeepLinkHref(window.location.href, timeRange)'); - expect(workspaceSource).toContain('function applyVariableUrlOverridesToDashboards('); - expect(workspaceSource).toContain('const initialVariableUrlOverrides = useMemo('); - expect(workspaceSource).toContain('applyVariableUrlOverridesToDashboards(nextDashboards, initialVariableUrlOverrides)'); - expect(workspaceSource).toContain('const requestedDashboardParam = firstParamValue(initialContext.dashboard)'); - expect(workspaceSource).toContain('const requestedDashboardKey = normalizeSignalDashboardKey(requestedDashboardParam || \'signals-overview\')'); - expect(workspaceSource).toContain('const requestedDashboard = nextDashboardsWithUrlVariables.find(dashboard => dashboard.dashboardKey === requestedDashboardKey)'); - expect(workspaceSource).toContain('const initialDashboard = requestedDashboard || firstDashboard'); - expect(workspaceSource).toContain('!hasRequestedDashboardKey && current === requestedDashboardKey ? initialDashboard.dashboardKey : current'); - expect(workspaceSource).toContain('const selectedDashboardKey = selectedDashboard?.dashboardKey || \'\''); - expect(workspaceSource).toContain('if (!selectedDashboardKey || !selectedDashboardTitle) return;'); - expect(workspaceSource).toContain('setDashboardTitleDraft(selectedDashboardTitle)'); - expect(workspaceSource).toContain('buildDashboardReturnHref({'); - expect(workspaceSource).toContain('dashboardKey: selectedDashboard.dashboardKey'); - expect(workspaceSource).toContain('variables: dashboardVariables'); - expect(workspaceSource).toContain('replaceDashboardTimeRangeDeepLink(dashboardTimeRange)'); - expect(workspaceSource).toContain('replaceDashboardDeepLink(saved.dashboardKey)'); - expect(workspaceSource).toContain('replaceDashboardDeepLink(dashboard.dashboardKey)'); - expect(workspaceSource).toContain('replaceDashboardDeepLink(nextDashboardKey)'); - expect(workspaceSource).toContain('replaceDashboardVariableDeepLink(nextVariable.name, nextVariable.value)'); - expect(workspaceSource).toContain("replaceDashboardVariableDeepLink(name, '')"); - expect(workspaceSource).toContain('buildSignalDashboardRuntimeEvidenceSourceHandoff(rowExecutionPlan?.resolvedRoute, row'); - expect(workspaceSource).toContain('buildSignalDashboardExecutionPlans'); - expect(workspaceSource).toContain('mergeSignalDashboardDraftsIntoComposition'); - expect(workspaceSource).toContain('buildSignalDashboardPanelRuntimeRenderDescriptor'); - expect(workspaceSource).toContain('buildSignalDashboardRuntimeEvidenceSourceHandoff'); - expect(workspaceSource).toContain('buildSignalDashboardRuntimeEvidenceFilters'); - expect(workspaceSource).toContain('buildSignalDashboardRuntimeEvidenceFilterSuggestions'); - expect(workspaceSource).toContain('buildSignalDashboardRuntimeMetricsTooltip'); - expect(workspaceSource).toContain('buildSignalDashboardRuntimeSyncTooltip'); - expect(workspaceSource).toContain('createSignalDashboardPanelDraftFromRuntimeEvidence'); - expect(workspaceSource).toContain('createSignalDashboardPanelDraftsFromFilterSelection'); - expect(workspaceSource).toContain('returnTo: dashboardReturnHref'); - expect(workspaceSource).toContain('buildSignalDashboardRuntimeSyncCrosshair'); - expect(workspaceSource).toContain('buildSignalDashboardVariableOptions'); - expect(workspaceSource).toContain('readSignalDashboardWidgetPanelEditMetadata'); - expect(workspaceSource).toContain('buildSignalDashboardPanelEditHref'); - expect(workspaceSource).toContain('executionPlanByPanelId'); - expect(workspaceSource).toContain('filterSignalDashboardVariableOptions'); - expect(workspaceSource).toContain('executeSignalDashboardPanelPlan'); - expect(workspaceSource).toContain('summarizeSignalDashboardPanelRuntime'); - expect(workspaceSource).toContain('resolveSignalDashboardPreviewPanels'); - expect(workspaceSource).toContain('resolveSignalDashboardTimeRange'); - expect(workspaceSource).toContain('resolveSignalDashboardRefreshState'); - expect(workspaceSource).toContain('setRefreshTick'); - expect(workspaceSource).toContain('setRuntimeSyncHoverTimestamp'); - expect(workspaceSource).toContain('setRuntimePinnedSyncTimestamp'); - expect(workspaceSource).toContain('pinRuntimeSyncTimestamp'); - expect(workspaceSource).toContain('selectSignalDashboardVariableOption'); - expect(workspaceSource).toContain('selectVariableOption'); - expect(workspaceSource).toContain('addEvidenceFilterVariable'); - expect(workspaceSource).toContain('parseSignalDashboardVariables'); - expect(workspaceSource).toContain('updateSignalDashboardVariables'); - expect(workspaceSource).toContain('updateSignalDashboardPanelLayout'); - expect(workspaceSource).toContain('buildSignalDashboardCompositionFromDrafts'); - expect(workspaceSource).toContain('SignalDashboardTimeRange'); - expect(workspaceSource).toContain('DashboardRuntimePanelRenderer'); - expect(workspaceSource).toContain('DashboardRuntimeTable'); - expect(workspaceSource).toContain('DashboardRuntimeBarChart'); - expect(workspaceSource).toContain('DashboardRuntimeMetricsChart'); - expect(workspaceSource).toContain('DashboardRuntimeTraceOverview'); - expect(workspaceSource).toContain('DashboardRuntimeTraceWaterfall'); - expect(workspaceSource).toContain('DashboardRuntimeStatePanel'); - expect(workspaceSource).toContain('runtimeRenderer.tableRows'); - expect(workspaceSource).toContain('runtimeRenderer.traceWaterfallRows'); - expect(workspaceSource).toContain('runtimeRenderer.metricsChart'); - expect(workspaceSource).toContain('dashboard.runtime.table.time'); - expect(workspaceSource).toContain('data-dashboard-panel-draft-row'); - expect(workspaceSource).toContain('data-dashboard-panel-draft-signal'); - expect(workspaceSource).toContain('data-dashboard-panel-draft-visualization'); - expect(workspaceSource).toContain('data-dashboard-panel-draft-route'); - expect(workspaceSource).toContain('readPanelDraftSourceSummary'); - expect(workspaceSource).toContain('data-dashboard-panel-draft-source-summary'); - expect(workspaceSource).toContain('data-dashboard-panel-draft-action="duplicate"'); - expect(workspaceSource).toContain('dashboard.add-panel.action.duplicate-draft'); - expect(workspaceSource).toContain('dashboard.add-panel.duplicate-title-suffix'); - expect(workspaceSource).toContain('data-dashboard-saved-view-row'); - expect(workspaceSource).toContain('data-dashboard-saved-view-label'); - expect(workspaceSource).toContain('data-dashboard-saved-view-description'); - expect(workspaceSource).toContain('data-dashboard-saved-view-signal'); - expect(workspaceSource).toContain('data-dashboard-saved-view-route'); - expect(workspaceSource).toContain('data-dashboard-saved-view-label-input'); - expect(workspaceSource).toContain('data-dashboard-saved-view-description-input'); - expect(workspaceSource).toContain('data-dashboard-saved-view-action="update"'); - expect(workspaceSource).toContain('data-dashboard-saved-view-action="add-panel"'); - expect(workspaceSource).toContain('data-dashboard-saved-view-action="delete"'); - expect(workspaceSource).toContain('data-dashboard-saved-views-state'); - expect(workspaceSource).toContain('dashboard.saved-views.title'); - expect(workspaceSource).toContain('dashboard.saved-views.action.update'); - expect(workspaceSource).toContain('dashboard.saved-views.action.add-panel'); - expect(workspaceSource).toContain('dashboard.saved-views.action.delete'); - expect(workspaceSource).toContain('function DashboardExplorerHandoffActions'); - expect(workspaceSource).toContain("actions={savedViewLoadState === 'empty'"); - expect(workspaceSource).toContain("actions={loadState === 'empty'"); - expect(workspaceSource).toContain("href: '/log/manage'"); - expect(workspaceSource).toContain("href: '/trace/manage'"); - expect(workspaceSource).toContain("href: '/ingestion/otlp/metrics'"); - expect(workspaceSource).toContain('data-dashboard-empty-action={action.key}'); - expect(workspaceSource).toContain('data-dashboard-empty-action-scope={scope}'); - expect(workspaceSource).toContain('data-dashboard-composition-row'); - expect(workspaceSource).toContain('data-dashboard-composition-action="select"'); - expect(workspaceSource).toContain('data-dashboard-composition-action="delete"'); - expect(workspaceSource).toContain('data-dashboard-composition-time-range-mode'); - expect(workspaceSource).toContain('data-dashboard-composition-time-range-start'); - expect(workspaceSource).toContain('data-dashboard-composition-time-range-end'); - expect(workspaceSource).toContain('data-dashboard-composition-time-range-preset'); - expect(workspaceSource).toContain('data-dashboard-composition-time-range-execution-start'); - expect(workspaceSource).toContain('data-dashboard-composition-time-range-execution-end'); - expect(workspaceSource).toContain('data-dashboard-composition-time-range-control'); - expect(workspaceSource).toContain('data-dashboard-composition-time-range-control-execution-start'); - expect(workspaceSource).toContain('data-dashboard-composition-time-range-control-execution-end'); - expect(workspaceSource).toContain('data-dashboard-composition-refresh-mode'); - expect(workspaceSource).toContain('data-dashboard-composition-refresh-interval'); - expect(workspaceSource).toContain('data-dashboard-composition-refresh-tick'); - expect(workspaceSource).toContain('data-dashboard-composition-refresh-live'); - expect(workspaceSource).toContain('data-dashboard-composition-filter-toolbar'); - expect(workspaceSource).toContain('data-dashboard-composition-filter-toolbar-state'); - expect(workspaceSource).toContain('data-dashboard-composition-filter-toolbar-variables'); - expect(workspaceSource).toContain('data-dashboard-composition-filter-toolbar-options'); - expect(workspaceSource).toContain('data-dashboard-composition-filter-variable-list'); - expect(workspaceSource).toContain('data-dashboard-composition-filter-variable='); - expect(workspaceSource).toContain('data-dashboard-composition-filter-variable-current'); - expect(workspaceSource).toContain('data-dashboard-composition-filter-variable-visible-options'); - expect(workspaceSource).toContain('data-dashboard-composition-filter-variable-search'); - expect(workspaceSource).toContain('data-dashboard-composition-filter-variable-selectable'); - expect(workspaceSource).toContain('data-dashboard-composition-filter-search'); - expect(workspaceSource).toContain('data-dashboard-composition-filter-search-value'); - expect(workspaceSource).toContain('data-dashboard-composition-filter-search-results'); - expect(workspaceSource).toContain('data-dashboard-composition-filter-select'); - expect(workspaceSource).toContain('data-dashboard-composition-filter-select-value'); - expect(workspaceSource).toContain('data-dashboard-composition-filter-select-options'); - expect(workspaceSource).toContain('data-dashboard-composition-filter-option-source'); - expect(workspaceSource).toContain('data-dashboard-composition-filter-option-selected'); - expect(workspaceSource).toContain('data-dashboard-composition-filter-option-action="select"'); - expect(workspaceSource).toContain('data-dashboard-composition-filter-variable-action="add-panel-draft"'); - expect(workspaceSource).toContain('data-dashboard-composition-filter-variable-action-templates'); - expect(workspaceSource).toContain('data-dashboard-composition-filter-variable-action-compose'); - expect(workspaceSource).toContain('dashboard.composition.filter-toolbar.add-panel-draft'); - expect(workspaceSource).toContain('dashboard.composition.filter-toolbar.panel-title-prefix'); - expect(workspaceSource).toContain('data-dashboard-composition-time-range-input="start"'); - expect(workspaceSource).toContain('data-dashboard-composition-time-range-input="end"'); - expect(workspaceSource).toContain('data-dashboard-composition-time-range-input="preset"'); - expect(workspaceSource).toContain('data-dashboard-composition-time-range-input="refresh"'); - expect(workspaceSource).toContain('data-dashboard-composition-time-range-input="live"'); - expect(workspaceSource).toContain('data-dashboard-composition-time-range-action="refresh"'); - expect(workspaceSource).toContain('data-dashboard-composition-preview-grid'); - expect(workspaceSource).toContain('data-dashboard-composition-preview-panel'); - expect(workspaceSource).toContain('data-dashboard-composition-preview-draft={panel.widget.draftKey || \'\'}'); - expect(workspaceSource).toContain('data-dashboard-composition-preview-raw-route'); - expect(workspaceSource).toContain('data-dashboard-composition-preview-query'); - expect(workspaceSource).toContain('data-dashboard-composition-preview-edit-href'); - expect(workspaceSource).toContain('data-dashboard-composition-preview-edit-intent="edit-panel"'); - expect(workspaceSource).toContain('const panelEditMetadata = readSignalDashboardWidgetPanelEditMetadata(panel.widget)'); - expect(workspaceSource).toContain('data-dashboard-composition-preview-edit-source={panelEditMetadata?.intent || \'none\'}'); - expect(workspaceSource).toContain('data-dashboard-composition-preview-edit-source-dashboard={panelEditMetadata?.dashboardKey || \'\'}'); - expect(workspaceSource).toContain('data-dashboard-composition-preview-edit-source-panel={panelEditMetadata?.panelId || \'\'}'); - expect(workspaceSource).toContain('data-dashboard-composition-preview-edit-source-draft={panelEditMetadata?.draftKey || \'\'}'); - expect(workspaceSource).toContain('data-dashboard-composition-preview-edit-source-return={panelEditMetadata?.returnTo || \'\'}'); - expect(workspaceSource).toContain('data-dashboard-composition-preview-action="edit-source"'); - expect(workspaceSource).toContain('data-dashboard-composition-preview-action-intent="edit-panel"'); - expect(workspaceSource).toContain('data-dashboard-composition-preview-action-panel={panel.widget.id}'); - expect(workspaceSource).toContain('data-dashboard-composition-preview-action-draft={panel.widget.draftKey}'); - expect(workspaceSource).toContain('data-dashboard-composition-preview-action-dashboard={selectedDashboard.dashboardKey}'); - expect(workspaceSource).toContain('data-dashboard-composition-preview-action-return={dashboardReturnHref}'); - expect(workspaceSource).toContain("t('dashboard.composition.action.edit-panel')"); - expect(workspaceSource).toContain('data-dashboard-composition-execution-state'); - expect(workspaceSource).toContain('data-dashboard-composition-execution-primary-url'); - expect(workspaceSource).toContain('data-dashboard-composition-execution-endpoints'); - expect(workspaceSource).toContain('data-dashboard-composition-execution-result-state'); - expect(workspaceSource).toContain('data-dashboard-composition-execution-result-url'); - expect(workspaceSource).toContain('data-dashboard-composition-execution-result-error'); - expect(workspaceSource).toContain('data-dashboard-composition-execution-data-ready'); - expect(workspaceSource).toContain('data-dashboard-composition-runtime-summary-kind'); - expect(workspaceSource).toContain('data-dashboard-composition-runtime-summary-items'); - expect(workspaceSource).toContain('data-dashboard-composition-runtime-summary-series'); - expect(workspaceSource).toContain('data-dashboard-composition-runtime-summary-samples'); - expect(workspaceSource).toContain('data-dashboard-composition-runtime-sync-timestamp'); - expect(workspaceSource).toContain('data-dashboard-composition-runtime-sync-hover-timestamp'); - expect(workspaceSource).toContain('data-dashboard-composition-runtime-sync-pinned-timestamp'); - expect(workspaceSource).toContain('data-dashboard-composition-runtime-sync-state'); - expect(workspaceSource).toContain('data-dashboard-composition-runtime-sync-pin-state'); - expect(workspaceSource).toContain('data-dashboard-composition-runtime-sync-tooltip'); - expect(workspaceSource).toContain('data-dashboard-composition-runtime-sync-tooltip-state'); - expect(workspaceSource).toContain('data-dashboard-composition-runtime-sync-tooltip-rows'); - expect(workspaceSource).toContain('data-dashboard-composition-runtime-sync-tooltip-row'); - expect(workspaceSource).toContain('data-dashboard-composition-runtime-sync-tooltip-row-source'); - expect(workspaceSource).toContain('data-dashboard-composition-runtime-sync-tooltip-row-handoff'); - expect(workspaceSource).toContain('data-dashboard-composition-runtime-sync-tooltip-row-handoff-state'); - expect(workspaceSource).toContain('data-dashboard-composition-runtime-sync-tooltip-row-handoff-kind'); - expect(workspaceSource).toContain('data-dashboard-composition-runtime-sync-tooltip-row-handoff-trace'); - expect(workspaceSource).toContain('data-dashboard-composition-runtime-sync-tooltip-row-handoff-span'); - expect(workspaceSource).toContain('data-dashboard-composition-runtime-sync-tooltip-row-related-signal'); - expect(workspaceSource).toContain('data-dashboard-composition-runtime-sync-tooltip-row-related-handoff'); - expect(workspaceSource).toContain('data-dashboard-composition-runtime-sync-tooltip-row-related-handoff-state'); - expect(workspaceSource).toContain('data-dashboard-composition-runtime-sync-tooltip-row-filter-candidates'); - expect(workspaceSource).toContain('data-dashboard-composition-runtime-sync-tooltip-row-filter-suggestions'); - expect(workspaceSource).toContain('data-dashboard-composition-runtime-sync-tooltip-row-action="apply-filter"'); - expect(workspaceSource).toContain('data-dashboard-composition-runtime-sync-tooltip-row-action="add-filter-variable"'); - expect(workspaceSource).toContain('data-dashboard-composition-runtime-sync-tooltip-row-action="add-panel-draft"'); - expect(workspaceSource).toContain('dashboard.runtime.sync.add-panel-draft'); - expect(workspaceSource).toContain('dashboard.runtime.sync.evidence-panel-title-prefix'); - expect(workspaceSource).toContain('data-dashboard-composition-runtime-sync-tooltip-row-action-variable'); - expect(workspaceSource).toContain('data-dashboard-composition-runtime-sync-tooltip-row-action-value'); - expect(workspaceSource).toContain('data-dashboard-composition-runtime-sync-tooltip-row-action-filter-source'); - expect(workspaceSource).toContain('data-dashboard-composition-runtime-sync-tooltip-row-action-variable-type'); - expect(workspaceSource).toContain('data-dashboard-composition-runtime-sync-tooltip-row-action="open-related"'); - expect(workspaceSource).toContain('data-dashboard-composition-runtime-sync-tooltip-row-action-signal'); - expect(workspaceSource).toContain('data-dashboard-composition-runtime-sync-tooltip-row-action="open-source"'); - expect(workspaceSource).toContain('data-dashboard-composition-runtime-sync-tooltip-row-action-panel'); - expect(workspaceSource).toContain('data-dashboard-composition-runtime-sync-tooltip-row-action-href'); - expect(workspaceSource).not.toContain("rowExecutionPlan?.primaryUrl || rowExecutionPlan?.resolvedRoute"); - expect(workspaceSource).toContain('data-dashboard-composition-runtime-sync-crosshair-state'); - expect(workspaceSource).toContain('data-dashboard-composition-runtime-sync-crosshair-panels'); - expect(workspaceSource).toContain('data-dashboard-composition-runtime-sync-crosshair-points'); - expect(workspaceSource).toContain('data-dashboard-runtime-sync-publisher'); - expect(workspaceSource).toContain('data-dashboard-runtime-sync-timestamp'); - expect(workspaceSource).toContain('data-dashboard-runtime-sync-selected'); - expect(workspaceSource).toContain('data-dashboard-runtime-sync-pinned'); - expect(workspaceSource).toContain('data-dashboard-runtime-sync-pin-action="toggle"'); - expect(workspaceSource).toContain('data-dashboard-composition-runtime-sync-action="clear-pin"'); - expect(workspaceSource).toContain('data-dashboard-composition-runtime-sync-action-state'); - expect(workspaceSource).toContain('data-dashboard-composition-runtime-renderer'); - expect(workspaceSource).toContain('data-dashboard-composition-runtime-renderer-mode'); - expect(workspaceSource).toContain('data-dashboard-composition-runtime-renderer-items'); - expect(workspaceSource).toContain('data-dashboard-composition-runtime-preview-mode'); - expect(workspaceSource).toContain('data-dashboard-composition-runtime-preview-rows'); - expect(workspaceSource).toContain('data-dashboard-composition-runtime-preview-bars'); - expect(workspaceSource).toContain('data-dashboard-composition-runtime-preview-bar'); - expect(workspaceSource).toContain('data-dashboard-composition-runtime-preview-row'); - expect(workspaceSource).toContain('data-dashboard-runtime-logs-table'); - expect(workspaceSource).toContain('data-dashboard-runtime-trace-table'); - expect(workspaceSource).toContain('data-dashboard-runtime-log-trend-chart'); - expect(workspaceSource).toContain('data-dashboard-runtime-metrics-chart'); - expect(workspaceSource).toContain('data-dashboard-runtime-metrics-chart-series-count'); - expect(workspaceSource).toContain('data-dashboard-runtime-metrics-chart-sample-count'); - expect(workspaceSource).toContain('data-dashboard-runtime-metrics-chart-x-min'); - expect(workspaceSource).toContain('data-dashboard-runtime-metrics-chart-x-max'); - expect(workspaceSource).toContain('data-dashboard-runtime-metrics-chart-y-min'); - expect(workspaceSource).toContain('data-dashboard-runtime-metrics-chart-y-max'); - expect(workspaceSource).toContain('data-dashboard-runtime-metrics-chart-plot'); - expect(workspaceSource).toContain('data-dashboard-runtime-metrics-chart-svg'); - expect(workspaceSource).toContain('data-dashboard-runtime-metrics-chart-line'); - expect(workspaceSource).toContain('data-dashboard-runtime-metrics-chart-line-path'); - expect(workspaceSource).toContain('data-dashboard-runtime-metrics-chart-crosshair-layer'); - expect(workspaceSource).toContain('data-dashboard-runtime-metrics-chart-crosshair-state'); - expect(workspaceSource).toContain('data-dashboard-runtime-metrics-chart-crosshair-points'); - expect(workspaceSource).toContain('data-dashboard-runtime-metrics-chart-crosshair-x'); - expect(workspaceSource).toContain('data-dashboard-runtime-metrics-chart-crosshair-timestamp'); - expect(workspaceSource).toContain('data-dashboard-runtime-metrics-chart-series'); - expect(workspaceSource).toContain('data-dashboard-runtime-metrics-chart-point'); - expect(workspaceSource).toContain('data-dashboard-runtime-metrics-chart-axis-labels'); - expect(workspaceSource).toContain('data-dashboard-runtime-metrics-chart-axis-x-min-label'); - expect(workspaceSource).toContain('data-dashboard-runtime-metrics-chart-axis-x-max-label'); - expect(workspaceSource).toContain('data-dashboard-runtime-metrics-chart-axis-y-min-label'); - expect(workspaceSource).toContain('data-dashboard-runtime-metrics-chart-axis-y-max-label'); - expect(workspaceSource).toContain('data-dashboard-runtime-metrics-chart-tooltip'); - expect(workspaceSource).toContain('data-dashboard-runtime-metrics-chart-tooltip-state'); - expect(workspaceSource).toContain('data-dashboard-runtime-metrics-chart-tooltip-timestamp'); - expect(workspaceSource).toContain('data-dashboard-runtime-metrics-chart-tooltip-rows'); - expect(workspaceSource).toContain('data-dashboard-runtime-metrics-chart-tooltip-row'); - expect(workspaceSource).toContain('data-dashboard-runtime-metrics-chart-floating-tooltip'); - expect(workspaceSource).toContain('data-dashboard-runtime-metrics-chart-floating-tooltip-state'); - expect(workspaceSource).toContain('data-dashboard-runtime-metrics-chart-floating-tooltip-align'); - expect(workspaceSource).toContain('data-dashboard-runtime-metrics-chart-floating-tooltip-timestamp'); - expect(workspaceSource).toContain('data-dashboard-runtime-metrics-chart-floating-tooltip-rows'); - expect(workspaceSource).toContain('data-dashboard-runtime-metrics-chart-floating-tooltip-row'); - expect(workspaceSource).toContain('data-dashboard-runtime-trace-overview'); - expect(workspaceSource).toContain('data-dashboard-runtime-state-panel'); - expect(workspaceSource).toContain('data-dashboard-runtime-table-fields'); - expect(workspaceSource).toContain('data-dashboard-runtime-table-row-observed-at'); - expect(workspaceSource).toContain('data-dashboard-runtime-table-row-service'); - expect(workspaceSource).toContain('data-dashboard-runtime-table-row-status'); - expect(workspaceSource).toContain('data-dashboard-runtime-table-row-trace'); - expect(workspaceSource).toContain('data-dashboard-runtime-trace-waterfall'); - expect(workspaceSource).toContain('data-dashboard-runtime-trace-waterfall-source'); - expect(workspaceSource).toContain('data-dashboard-runtime-trace-waterfall-row-depth'); - expect(workspaceSource).toContain('data-dashboard-runtime-trace-waterfall-bar-width'); - expect(workspaceSource).toContain('data-dashboard-composition-preview-action="save-layout"'); - expect(workspaceSource).toContain('data-dashboard-composition-preview-action="open-source"'); - expect(workspaceSource).toContain('data-dashboard-composition-layout-action="move-left"'); - expect(workspaceSource).toContain('data-dashboard-composition-layout-action="move-right"'); - expect(workspaceSource).toContain('data-dashboard-composition-layout-action="move-up"'); - expect(workspaceSource).toContain('data-dashboard-composition-layout-action="move-down"'); - expect(workspaceSource).toContain('data-dashboard-composition-layout-action="wider"'); - expect(workspaceSource).toContain('data-dashboard-composition-layout-action="narrower"'); - expect(workspaceSource).toContain('data-dashboard-composition-layout-action="taller"'); - expect(workspaceSource).toContain('data-dashboard-composition-layout-action="shorter"'); - expect(workspaceSource).toContain('data-dashboard-composition-variable-editor'); - expect(workspaceSource).toContain('data-dashboard-composition-variable-input="name"'); - expect(workspaceSource).toContain('data-dashboard-composition-variable-input="type"'); - expect(workspaceSource).toContain('data-dashboard-composition-variable-input="value"'); - expect(workspaceSource).toContain('data-dashboard-composition-variable-options-count'); - expect(workspaceSource).toContain('data-dashboard-composition-variable-runtime-options-count'); - expect(workspaceSource).toContain('data-dashboard-composition-variable-static-options-count'); - expect(workspaceSource).toContain('data-dashboard-composition-variable-options'); - expect(workspaceSource).toContain('data-dashboard-composition-variable-option-source'); - expect(workspaceSource).toContain('data-dashboard-composition-variable-option-count'); - expect(workspaceSource).toContain('data-dashboard-composition-variable-option-selected'); - expect(workspaceSource).toContain('data-dashboard-composition-variable-option-action="select"'); - expect(workspaceSource).toContain('data-dashboard-composition-variables-action="save"'); - expect(workspaceSource).toContain('data-dashboard-composition-variables-action="add"'); - expect(workspaceSource).toContain('data-dashboard-composition-variables-action="delete"'); - }); -}); diff --git a/web-next/app/dashboard/page.tsx b/web-next/app/dashboard/page.tsx deleted file mode 100644 index a6b82df939..0000000000 --- a/web-next/app/dashboard/page.tsx +++ /dev/null @@ -1,12 +0,0 @@ -import React from 'react'; -import DashboardDraftWorkspace from './dashboard-draft-workspace'; -import type { SearchParamsRecord } from '../../lib/dashboard/navigation'; - -export default async function DashboardPage({ - searchParams -}: { - searchParams?: Promise; -}) { - const resolvedSearchParams = await searchParams; - return ; -} diff --git a/web-next/app/entities/[...rest]/page.test.ts b/web-next/app/entities/[...rest]/page.test.ts deleted file mode 100644 index 85a9e26a0b..0000000000 --- a/web-next/app/entities/[...rest]/page.test.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { describe, expect, it, vi } from 'vitest'; - -const redirect = vi.fn(); - -vi.mock('next/navigation', () => ({ - redirect -})); - -describe('entities catch-all route', () => { - it('redirects unknown nested entity paths back to the entities list', async () => { - redirect.mockImplementation((target: string) => { - throw new Error(`redirect:${target}`); - }); - - const { default: EntityUnknownRoutePage } = await import('./page'); - - await expect(EntityUnknownRoutePage()).rejects.toThrow('redirect:/entities'); - expect(redirect).toHaveBeenCalledWith('/entities'); - }); - - it('preserves entity catalog query context when redirecting unknown nested paths', async () => { - redirect.mockImplementation((target: string) => { - throw new Error(`redirect:${target}`); - }); - - const { default: EntityUnknownRoutePage } = await import('./page'); - - await expect( - EntityUnknownRoutePage({ - searchParams: Promise.resolve({ - search: 'checkout', - type: 'service', - status: 'review', - source: 'otlp', - returnTo: '/trace/manage?returnLabel=Trace' - }) - }) - ).rejects.toThrow( - 'redirect:/entities?search=checkout&type=service&status=review&source=otlp&returnTo=%2Ftrace%2Fmanage' - ); - expect(redirect).toHaveBeenLastCalledWith( - '/entities?search=checkout&type=service&status=review&source=otlp&returnTo=%2Ftrace%2Fmanage' - ); - }); -}); diff --git a/web-next/app/entities/[...rest]/page.tsx b/web-next/app/entities/[...rest]/page.tsx deleted file mode 100644 index dcd4cb9fc0..0000000000 --- a/web-next/app/entities/[...rest]/page.tsx +++ /dev/null @@ -1,9 +0,0 @@ -import { redirect } from 'next/navigation'; -import { buildEntityListCompatRouteUrl, type SearchParamsRecord } from '../../../lib/entity-manage/query-state'; - -export default async function EntityUnknownRoutePage(props: { - searchParams?: Promise; -}) { - const resolvedSearchParams = await props?.searchParams; - redirect(buildEntityListCompatRouteUrl(resolvedSearchParams)); -} diff --git a/web-next/app/entities/[entityId]/definition/entity-definition-page.tsx b/web-next/app/entities/[entityId]/definition/entity-definition-page.tsx deleted file mode 100644 index d8de92dac1..0000000000 --- a/web-next/app/entities/[entityId]/definition/entity-definition-page.tsx +++ /dev/null @@ -1,68 +0,0 @@ -'use client'; - -import React, { useCallback } from 'react'; -import { ClientWorkbench } from '@/components/workbench/client-workbench'; -import { useI18n } from '@/components/providers/i18n-provider'; -import { EntityDefinitionWorkspaceSurface } from '@/components/pages/entity-definition-workspace-surface'; -import { api } from '@/lib/api-facade'; -import { - buildEntityDefinitionActivitiesUrl, - buildEntityDefinitionTemplatesUrl, - buildEntityDefinitionUrl, - loadEntityDefinitionPageDataFromFacade -} from '@/lib/entity-definition/controller'; -import type { SignalRouteContext } from '@/lib/signal-route-context'; - -type DefinitionPageData = Awaited>; - -const ENTITY_DEFINITION_SETTLED_CACHE_TTL_MS = 10_000; - -export default function EntityDefinitionPage({ - entityId, - routeContext -}: { - entityId: string; - routeContext?: SignalRouteContext; -}) { - const { t } = useI18n(); - const entityDefinitionUrl = React.useMemo(() => buildEntityDefinitionUrl(entityId, 'yaml'), [entityId]); - const entityDefinitionActivitiesUrl = React.useMemo(() => buildEntityDefinitionActivitiesUrl(entityId), [entityId]); - const entityDefinitionTemplatesUrl = React.useMemo(() => buildEntityDefinitionTemplatesUrl(), []); - const entityDefinitionCacheKey = React.useMemo( - () => ['entity-definition', entityDefinitionUrl, entityDefinitionActivitiesUrl, entityDefinitionTemplatesUrl].join(':'), - [entityDefinitionActivitiesUrl, entityDefinitionTemplatesUrl, entityDefinitionUrl] - ); - const load = useCallback(async (): Promise => { - return loadEntityDefinitionPageDataFromFacade( - { - definition: api.entities.definition, - activities: api.entities.definitionActivities, - templates: api.entities.definitionTemplates - }, - entityId, - 'yaml' - ); - }, [entityId]); - - return ( - - {data => ( - - )} - - ); -} diff --git a/web-next/app/entities/[entityId]/definition/page.test.tsx b/web-next/app/entities/[entityId]/definition/page.test.tsx deleted file mode 100644 index f987227076..0000000000 --- a/web-next/app/entities/[entityId]/definition/page.test.tsx +++ /dev/null @@ -1,156 +0,0 @@ -import React from 'react'; -import { renderToStaticMarkup } from 'react-dom/server'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; -import EntityDefinitionPage from './entity-definition-page'; -import { createTranslatorMock } from '../../../../test/i18n-test-helper'; - -const mockState = vi.hoisted(() => ({ - lastLoad: null as null | (() => Promise), - renderData: { - entityId: '42', - definition: 'kind: service', - templates: [{ id: '1', name: 'base-template', format: 'yaml', content: 'kind: service' }], - activities: [{ id: 1, summary: 'definition updated', status: 'success', activityType: 'update' }] - } -})); - -const readEntityDefinition = vi.hoisted(() => vi.fn(async () => mockState.renderData.definition)); -const readEntityDefinitionActivities = vi.hoisted(() => vi.fn(async () => mockState.renderData.activities)); -const readEntityDefinitionTemplates = vi.hoisted(() => vi.fn(async () => mockState.renderData.templates)); -const loadEntityDefinitionPageDataFromFacade = vi.hoisted(() => vi.fn(async () => mockState.renderData)); - -vi.mock('@/components/providers/i18n-provider', () => ({ - useI18n: () => ({ - t: createTranslatorMock({ - locale: 'zh-CN' - }) - }) -})); - -const expectedT = createTranslatorMock({ locale: 'zh-CN' }); - -vi.mock('@/components/workbench/client-workbench', () => ({ - ClientWorkbench: ({ - children, - load, - loadingCopy, - cacheKey, - cacheSettledTtlMs - }: { - children: (data: any) => React.ReactNode; - load: () => Promise; - loadingCopy?: string; - cacheKey?: string; - cacheSettledTtlMs?: number; - }) => { - mockState.lastLoad = load; - return ( -
- {children(mockState.renderData)} -
- ); - } -})); - -vi.mock('@/components/pages/entity-definition-workspace-surface', () => ({ - EntityDefinitionWorkspaceSurface: ({ mode, entityId, initialContent, initialMessage, routeContext, templates, activities }: any) => ( -
- {initialContent} / {templates.length} templates / {activities.length} activities - {initialMessage ? ` / ${initialMessage}` : ''} -
- ) -})); - -vi.mock('@/lib/api-facade', () => ({ - api: { - entities: { - definition: readEntityDefinition, - definitionActivities: readEntityDefinitionActivities, - definitionTemplates: readEntityDefinitionTemplates - } - } -})); - -vi.mock('@/lib/entity-definition/controller', () => ({ - buildEntityDefinitionActivitiesUrl: (entityId: string) => `/entities/definition-activities?entityId=${entityId}&limit=8`, - buildEntityDefinitionTemplatesUrl: () => '/entities/definition/templates?limit=8', - buildEntityDefinitionUrl: (entityId: string, format: string) => `/entities/${entityId}/definition?format=${format}`, - loadEntityDefinitionPageDataFromFacade -})); - -describe('EntityDefinitionPage', () => { - beforeEach(() => { - mockState.lastLoad = null; - readEntityDefinition.mockClear().mockResolvedValue(mockState.renderData.definition); - readEntityDefinitionActivities.mockClear().mockResolvedValue(mockState.renderData.activities); - readEntityDefinitionTemplates.mockClear().mockResolvedValue(mockState.renderData.templates); - loadEntityDefinitionPageDataFromFacade.mockClear().mockResolvedValue(mockState.renderData); - }); - - it('loads definition workspace data and renders the shared definition workspace surface', async () => { - const html = renderToStaticMarkup(); - - expect(html).toContain('data-entity-definition-workspace="definition"'); - expect(html).toContain(`data-loading-copy="${expectedT('entities.definition.loading')}"`); - expect(html).toContain( - 'data-cache-key="entity-definition:/entities/42/definition?format=yaml:/entities/definition-activities?entityId=42&limit=8:/entities/definition/templates?limit=8"' - ); - expect(html).toContain('data-cache-settled-ttl="10000"'); - expect(html).toContain('kind: service / 1 templates / 1 activities'); - - await mockState.lastLoad?.(); - - expect(loadEntityDefinitionPageDataFromFacade).toHaveBeenCalledWith( - { - definition: readEntityDefinition, - activities: readEntityDefinitionActivities, - templates: readEntityDefinitionTemplates - }, - '42', - 'yaml' - ); - }); - - it('forwards recoverable load messages into the definition surface for empty-error parity', () => { - mockState.renderData = { - entityId: '1', - definition: '', - loadMessage: 'Entity not exist.', - templates: [], - activities: [] - } as any; - - const html = renderToStaticMarkup(); - - expect(html).toContain('data-entity-definition-workspace="definition"'); - expect(html).toContain('Entity not exist.'); - }); - - it('passes inherited investigation context into the definition workspace', () => { - const html = renderToStaticMarkup( - - ); - - expect(html).toContain('data-definition-route-monitor-id="632051474676992"'); - expect(html).toContain('data-definition-route-time-range="last-45m"'); - expect(html).toContain('data-definition-route-return-to="/entities/42"'); - }); -}); diff --git a/web-next/app/entities/[entityId]/definition/page.tsx b/web-next/app/entities/[entityId]/definition/page.tsx deleted file mode 100644 index 298d27ebcc..0000000000 --- a/web-next/app/entities/[entityId]/definition/page.tsx +++ /dev/null @@ -1,17 +0,0 @@ -import React from 'react'; - -import EntityDefinitionPage from './entity-definition-page'; -import { readEntityDetailRouteContext, type EntityDetailSearchParams } from '../../../../lib/entity-detail/query-state'; - -export default async function EntityDefinitionRoutePage({ - params, - searchParams -}: { - params: Promise<{ entityId: string }>; - searchParams?: Promise; -}) { - const { entityId } = await params; - const resolvedSearchParams = await searchParams; - const routeContext = readEntityDetailRouteContext(resolvedSearchParams); - return ; -} diff --git a/web-next/app/entities/[entityId]/edit/entity-edit-page.tsx b/web-next/app/entities/[entityId]/edit/entity-edit-page.tsx deleted file mode 100644 index c5a984359a..0000000000 --- a/web-next/app/entities/[entityId]/edit/entity-edit-page.tsx +++ /dev/null @@ -1,119 +0,0 @@ -'use client'; - -import React from 'react'; -import { useCallback } from 'react'; -import { useRouter } from 'next/navigation'; -import { HzButton, HzInlineFeedback } from '@hertzbeat/ui'; -import { ClientWorkbench } from '@/components/workbench/client-workbench'; -import { useI18n } from '@/components/providers/i18n-provider'; -import { EntityEditorSurface } from '@/components/pages/entity-editor-surface'; -import { api } from '@/lib/api-facade'; -import { appendSignalRouteContext, stripReturnLabelFromHref, type SignalRouteContext } from '@/lib/signal-route-context'; -import type { EntityCatalogSuggestions, EntityDto } from '@/lib/types'; -import { - buildEntityEditorCatalogSuggestionsUrl, - buildEntityEditorEntityUrl, - loadEntityEditorCatalogSuggestionsFromFacade, - loadEntityEditorEntityFromFacade -} from '../../../../lib/entity-editor/controller'; - -const ENTITY_EDIT_SETTLED_CACHE_TTL_MS = 10_000; - -export function buildEntityEditorListReturnHref(routeContext?: SignalRouteContext) { - const normalizedReturnTo = stripReturnLabelFromHref(routeContext?.returnTo); - if (normalizedReturnTo?.startsWith('/entities') && !normalizedReturnTo.startsWith('//')) { - return normalizedReturnTo; - } - - const params = new URLSearchParams(); - appendSignalRouteContext(params, routeContext ?? {}); - params.delete('returnTo'); - const query = params.toString(); - return query ? `/entities?${query}` : '/entities'; -} - -export default function EntityEditPage({ - entityId, - routeContext -}: { - entityId: string; - routeContext?: SignalRouteContext; -}) { - const { t } = useI18n(); - const router = useRouter(); - const entityEditorEntityUrl = React.useMemo(() => buildEntityEditorEntityUrl(entityId), [entityId]); - const entityEditorCatalogUrl = React.useMemo(() => buildEntityEditorCatalogSuggestionsUrl(), []); - const listReturnHref = React.useMemo(() => buildEntityEditorListReturnHref(routeContext), [routeContext]); - const entityEditCacheKey = React.useMemo( - () => ['entity-edit', entityEditorEntityUrl, entityEditorCatalogUrl].join(':'), - [entityEditorEntityUrl, entityEditorCatalogUrl] - ); - const load = useCallback(async (): Promise<{ entityId: string; dto: EntityDto; catalogSuggestions: EntityCatalogSuggestions }> => { - const [dto, catalogSuggestions] = await Promise.all([ - loadEntityEditorEntityFromFacade(api.entities.editorEntity, entityId), - loadEntityEditorCatalogSuggestionsFromFacade(api.entities.catalogSuggestions) - ]); - return { entityId, dto, catalogSuggestions }; - }, [entityId]); - - return ( - ( -
- - {message} - {t('entities.editor.action.all-entities.help')} - - } - variant="embedded" - data-entity-editor-route-state-feedback="error" - /> -
- - {t('common.button.retry')} - - router.push(listReturnHref)} - data-entity-editor-route-state-list-return="true" - data-entity-editor-route-state-list-return-owner="hertzbeat-ui-button" - data-entity-editor-route-state-list-return-target={listReturnHref} - > - {t('entities.detail.action.all-entities')} - -
-
- )} - > - {data => ( - - )} -
- ); -} diff --git a/web-next/app/entities/[entityId]/edit/page.test.tsx b/web-next/app/entities/[entityId]/edit/page.test.tsx deleted file mode 100644 index c213191d76..0000000000 --- a/web-next/app/entities/[entityId]/edit/page.test.tsx +++ /dev/null @@ -1,235 +0,0 @@ -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'; -import EntityEditPage, { buildEntityEditorListReturnHref } from './entity-edit-page'; -import { createTranslatorMock } from '../../../../test/i18n-test-helper'; - -const mockState = vi.hoisted(() => ({ - lastLoad: null as null | (() => Promise), - renderError: false, - push: vi.fn(), - renderData: { - entityId: '42', - dto: { - entity: { type: 'service', name: 'checkout-api' }, - identities: [], - monitorBinds: [], - relations: [] - }, - catalogSuggestions: { - owners: ['platform'] - } - } -})); - -const readEntityEditorEntity = vi.hoisted(() => vi.fn(async () => mockState.renderData.dto)); -const readEntityCatalogSuggestions = vi.hoisted(() => vi.fn(async () => mockState.renderData.catalogSuggestions)); - -vi.mock('@/components/providers/i18n-provider', () => ({ - useI18n: () => ({ - t: createTranslatorMock() - }) -})); - -vi.mock('next/navigation', () => ({ - useRouter: () => ({ - push: mockState.push - }) -})); - -vi.mock('@hertzbeat/ui', () => ({ - HzButton: ({ children, ...props }: any) => , - HzInlineFeedback: ({ title, description, ...props }: any) => ( -
-

{title}

-

{description}

-
- ) -})); - -vi.mock('@/components/workbench/client-workbench', () => ({ - ClientWorkbench: ({ - children, - load, - cacheKey, - cacheSettledTtlMs, - loadingCopy, - renderError - }: { - children: (data: any) => React.ReactNode; - load: () => Promise; - cacheKey?: string; - cacheSettledTtlMs?: number; - loadingCopy?: string; - renderError?: (message: string, retry: () => void) => React.ReactNode; - }) => { - mockState.lastLoad = load; - if (mockState.renderError) { - return
{renderError?.('Entity not exist.', () => undefined)}
; - } - return ( -
- {children(mockState.renderData)} -
- ); - } -})); - -vi.mock('@/components/pages/entity-editor-surface', () => ({ - EntityEditorSurface: ({ mode, entityId, initial, routeContext }: any) => ( -
- {initial.entity.name} -
- ) -})); - -vi.mock('@/lib/api-facade', () => ({ - api: { - entities: { - editorEntity: readEntityEditorEntity, - catalogSuggestions: readEntityCatalogSuggestions - } - } -})); - -describe('EntityEditPage', () => { - beforeEach(() => { - mockState.lastLoad = null; - mockState.renderError = false; - mockState.push.mockReset(); - readEntityEditorEntity.mockClear().mockResolvedValue(mockState.renderData.dto); - readEntityCatalogSuggestions.mockClear().mockResolvedValue(mockState.renderData.catalogSuggestions); - }); - - it('loads entity detail plus catalog suggestions and renders the shared editor surface in edit mode', async () => { - const html = renderToStaticMarkup(); - - expect(html).toContain('data-entity-editor-surface="edit"'); - expect(html).toContain('data-entity-id="42"'); - expect(html).toContain('data-cache-key="entity-edit:/entities/42:/entities/catalog-suggestions?limit=120"'); - expect(html).toContain('data-cache-settled-ttl="10000"'); - expect(html).toContain('data-loading-copy="Loading entity editor"'); - expect(html).toContain('checkout-api'); - - await mockState.lastLoad?.(); - - expect(readEntityEditorEntity).toHaveBeenCalledWith('42'); - expect(readEntityCatalogSuggestions).toHaveBeenCalledWith(120); - }); - - it('passes inherited monitor context into the shared editor surface', () => { - const html = renderToStaticMarkup( - - ); - - expect(html).toContain('data-entity-editor-surface="edit"'); - expect(html).toContain('data-route-monitor-id="658094606003456"'); - }); - - it('falls back to an empty catalog suggestion payload when the shared catalog endpoint is missing', async () => { - readEntityCatalogSuggestions.mockRejectedValueOnce(new Error('GET /entities/catalog-suggestions?limit=120 failed with 404')); - renderToStaticMarkup(); - - await expect(mockState.lastLoad?.()).resolves.toEqual({ - entityId: '42', - dto: mockState.renderData.dto, - catalogSuggestions: { - owners: [], - namespaces: [], - environments: [], - systems: [], - lifecycles: [], - tiers: [], - inheritFromRefs: [], - entityRefs: [], - languages: [], - linkProviders: [] - } - }); - }); - - it('falls back to a shared draft entity when the entity detail endpoint is missing', async () => { - readEntityEditorEntity.mockRejectedValueOnce(new Error('GET /entities/42 failed with 404')); - renderToStaticMarkup(); - - await expect(mockState.lastLoad?.()).resolves.toEqual({ - entityId: '42', - dto: { - entity: { - id: 42, - type: 'service', - name: 'entity-42', - displayName: 'Entity 42', - owner: 'platform', - system: 'catalog', - environment: 'prod', - lifecycle: 'production', - source: 'manual', - labels: {}, - tags: [], - additionalOwners: [], - links: [], - contacts: [], - componentOf: [], - components: [], - implementedBy: [], - languages: [] - }, - identities: [], - monitorBinds: [], - relations: [] - }, - catalogSuggestions: mockState.renderData.catalogSuggestions - }); - }); - - it('returns missing editor route states to the inherited entity list context', () => { - const source = readFileSync(resolve(process.cwd(), 'app/entities/[entityId]/edit/entity-edit-page.tsx'), 'utf8'); - - expect(buildEntityEditorListReturnHref({ returnTo: '/entities?search=checkout&source=pd-1218&pageSize=8' })).toBe( - '/entities?search=checkout&source=pd-1218&pageSize=8' - ); - expect(buildEntityEditorListReturnHref({ returnTo: 'https://example.invalid/entities', source: 'pd-1218', pageSize: '8' })).toBe( - '/entities?pageSize=8&source=pd-1218' - ); - expect(source).toContain('data-entity-editor-route-state-list-return="true"'); - expect(source).toContain('data-entity-editor-route-state-list-return-target={listReturnHref}'); - expect(source).toContain("renderError={(message, retry) => ("); - }); - - it('renders retry and entity-list recovery actions for missing edit loads', () => { - mockState.renderError = true; - - const html = renderToStaticMarkup( - - ); - - expect(html).toContain('data-entity-editor-route-state="error"'); - expect(html).toContain('data-entity-editor-edit-error-state="missing-entity"'); - expect(html).toContain('data-entity-editor-route-state-retry="true"'); - expect(html).toContain('data-entity-editor-route-state-list-return="true"'); - expect(html).toContain('data-entity-editor-route-state-list-return-target="/entities?search=checkout&source=pd-1218&pageSize=8"'); - expect(html).toContain('Entity not exist.'); - }); -}); diff --git a/web-next/app/entities/[entityId]/edit/page.tsx b/web-next/app/entities/[entityId]/edit/page.tsx deleted file mode 100644 index 55c3f0ad9f..0000000000 --- a/web-next/app/entities/[entityId]/edit/page.tsx +++ /dev/null @@ -1,17 +0,0 @@ -import React from 'react'; - -import EntityEditPage from './entity-edit-page'; -import { readEntityDetailRouteContext, type EntityDetailSearchParams } from '../../../../lib/entity-detail/query-state'; - -export default async function EntityEditRoutePage({ - params, - searchParams -}: { - params: Promise<{ entityId: string }>; - searchParams?: Promise; -}) { - const { entityId } = await params; - const resolvedSearchParams = await searchParams; - const routeContext = readEntityDetailRouteContext(resolvedSearchParams); - return ; -} diff --git a/web-next/app/entities/[entityId]/entity-detail-page.tsx b/web-next/app/entities/[entityId]/entity-detail-page.tsx deleted file mode 100644 index d164c4924f..0000000000 --- a/web-next/app/entities/[entityId]/entity-detail-page.tsx +++ /dev/null @@ -1,128 +0,0 @@ -'use client'; - -import React from 'react'; -import { useCallback, useState, useTransition } from 'react'; -import { useRouter } from 'next/navigation'; -import { EntityDetailSurface } from '@/components/pages/entity-detail-surface'; -import { useI18n } from '@/components/providers/i18n-provider'; -import { ClientWorkbench } from '@/components/workbench/client-workbench'; -import { apiMessageDelete } from '@/lib/api-client'; -import { api } from '@/lib/api-facade'; -import { buildEntityDetailUrl, loadEntityDetailFromFacade } from '@/lib/entity-detail/controller'; -import { appendSignalRouteContext, stripReturnLabelFromHref, type SignalRouteContext } from '@/lib/signal-route-context'; -import type { EntityDetailDto } from '@/lib/types'; -import { resetWorkbenchLoadCache } from '@/lib/workbench-load-cache'; - -const ENTITY_DETAIL_SETTLED_CACHE_TTL_MS = 10_000; -const ENTITY_DETAIL_LOAD_TIMEOUT_MS = 15_000; - -export function buildEntityDetailDeleteReturnHref(routeContext?: SignalRouteContext, currentEntityId?: string | number | null) { - const normalizedReturnTo = stripReturnLabelFromHref(routeContext?.returnTo); - if (normalizedReturnTo?.startsWith('/') && !normalizedReturnTo.startsWith('//')) { - const normalizedEntityId = currentEntityId == null ? null : String(currentEntityId); - const returnPath = normalizedReturnTo.split(/[?#]/, 1)[0]; - const currentEntityPath = normalizedEntityId ? `/entities/${encodeURIComponent(normalizedEntityId)}` : null; - const isCurrentEntityRoute = currentEntityPath != null && (returnPath === currentEntityPath || returnPath.startsWith(`${currentEntityPath}/`)); - if (!isCurrentEntityRoute) { - return normalizedReturnTo; - } - // After deletion the current entity route is stale, so fall back to the list while preserving inherited context below. - } - - const params = new URLSearchParams(); - appendSignalRouteContext(params, routeContext ?? {}); - params.delete('returnTo'); - const query = params.toString(); - return query ? `/entities?${query}` : '/entities'; -} - -export function buildEntityDetailDeleteSuccessHref(returnHref: string, deletedEntityId?: string | number | null) { - const url = new URL(returnHref, 'http://hertzbeat.local'); - url.searchParams.set('deleteResult', 'success'); - if (deletedEntityId != null) { - url.searchParams.set('deletedEntity', String(deletedEntityId)); - } - return `${url.pathname}${url.search}${url.hash}`; -} - -export default function EntityDetailPage({ - createdResult = false, - updatedResult = false, - entityId, - routeContext -}: { - createdResult?: boolean; - updatedResult?: boolean; - entityId: string; - routeContext?: SignalRouteContext; -}) { - const router = useRouter(); - const { t } = useI18n(); - const [reloadNonce, setReloadNonce] = useState(0); - const [actionError, setActionError] = useState(null); - const [isPending, startTransition] = useTransition(); - const entityDetailUrl = React.useMemo(() => buildEntityDetailUrl(entityId), [entityId]); - const deleteReturnHref = React.useMemo(() => buildEntityDetailDeleteReturnHref(routeContext, entityId), [entityId, routeContext]); - const entityDetailCacheKey = React.useMemo( - () => ['entity-detail', entityDetailUrl, reloadNonce].join(':'), - [entityDetailUrl, reloadNonce] - ); - const load = useCallback(async (): Promise => { - void reloadNonce; - return loadEntityDetailFromFacade(api.entities.detail, entityId, t); - }, [entityId, reloadNonce, t]); - - const handleRefresh = useCallback(() => { - setActionError(null); - setReloadNonce(current => current + 1); - router.refresh(); - }, [router]); - - const handleDelete = useCallback( - async (entityId: string | number | null | undefined) => { - if (entityId == null) { - return; - } - - setActionError(null); - - try { - await apiMessageDelete(`/entities/${entityId}`); - resetWorkbenchLoadCache(); - startTransition(() => { - router.push(buildEntityDetailDeleteSuccessHref(deleteReturnHref, entityId)); - router.refresh(); - }); - } catch (error) { - setActionError(error instanceof Error ? error.message : t('entities.detail.delete.failed')); - } - }, - [deleteReturnHref, router, t] - ); - - return ( - - {detail => ( - - )} - - ); -} diff --git a/web-next/app/entities/[entityId]/page.test.tsx b/web-next/app/entities/[entityId]/page.test.tsx deleted file mode 100644 index 10a9ca5587..0000000000 --- a/web-next/app/entities/[entityId]/page.test.tsx +++ /dev/null @@ -1,410 +0,0 @@ -// @vitest-environment jsdom -import { readFileSync } from 'node:fs'; -import { resolve } from 'node:path'; -import React from 'react'; -import { act } from 'react'; -import { createRoot, type Root } from 'react-dom/client'; -import { renderToStaticMarkup } from 'react-dom/server'; -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { apiMessageDelete } from '@/lib/api-client'; -import EntityDetailPage, { buildEntityDetailDeleteReturnHref, buildEntityDetailDeleteSuccessHref } from './entity-detail-page'; -import { createTranslatorMock } from '../../../test/i18n-test-helper'; - -(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; - -const mockState = vi.hoisted(() => ({ - lastLoad: null as null | (() => Promise), - renderData: { - entity: { - entity: { - id: 42, - name: 'checkout-api', - displayName: 'Checkout API', - type: 'service', - status: 'healthy', - owner: 'platform', - environment: 'prod', - system: 'payments', - description: 'Checkout service' - } - }, - evidenceSummary: { - downMonitorCount: 1 - }, - monitorSummary: { - totalBoundMonitors: 2 - }, - logSummary: { - hintCount: 3, - preferredQueryTitle: 'checkout errors' - }, - traceSummary: { - recentTraceCount: 4, - recentErrorTraceCount: 1 - }, - nextActions: [ - { - title: 'Open monitors', - summary: 'Inspect abnormal monitors first.', - actionLabel: 'Open monitors' - } - ] - }, - refresh: vi.fn(), - push: vi.fn() -})); - -const loadEntityDetail = vi.hoisted(() => vi.fn(async () => mockState.renderData)); - -vi.mock('next/link', () => ({ - default: ({ href, children, ...props }: any) => ( - - {children} - - ) -})); - -vi.mock('next/navigation', () => ({ - useRouter: () => ({ - refresh: mockState.refresh, - push: mockState.push - }) -})); - -vi.mock('@/components/providers/i18n-provider', () => ({ - useI18n: () => ({ - t: createTranslatorMock() - }) -})); - -vi.mock('@/components/workbench/client-workbench', () => ({ - ClientWorkbench: ({ - children, - load, - loadingTitle, - loadingCopy, - loadTimeoutMs, - loadingDelayMs - }: { - children: (data: any) => React.ReactNode; - load: () => Promise; - loadingTitle?: string; - loadingCopy?: string; - loadTimeoutMs?: number; - loadingDelayMs?: number; - }) => { - mockState.lastLoad = load; - return ( -
- {children(mockState.renderData)} -
- ); - } -})); - -vi.mock('@/components/pages/entity-detail-surface', () => ({ - EntityDetailSurface: ({ actionError, createdResult, detail, isPending, onDelete, routeContext }: any) => { - const surfaceT = createTranslatorMock({ locale: 'zh-CN' }); - const entity = detail.entity?.entity || {}; - return ( -
-
- {surfaceT('entities.detail.header.badge')} - {surfaceT('entities.detail.header.kicker')} -
- {surfaceT('entities.detail.action.all-entities')} - - {surfaceT('entities.detail.action.edit-definition')} - - {surfaceT('entities.detail.action.edit')} -
-
- {actionError ?
{actionError}
: null} -
-
-
{surfaceT('entities.detail.panel.overview.title')}
-
{surfaceT('entities.detail.panel.related.title')}
-
{surfaceT('entities.detail.panel.next.title')}
-
{surfaceT('entities.detail.panel.drilldown.title')}
-
-
- ); - } -})); - -vi.mock('@/components/ui/button', () => ({ - Button: ({ children, ...props }: any) => , - buttonVariants: () => 'btn' -})); - -vi.mock('@/lib/api-client', () => ({ - apiMessageDelete: vi.fn() -})); - -vi.mock('@/lib/api-facade', () => ({ - api: { - entities: { - detail: vi.fn() - } - } -})); - -vi.mock('@/lib/entity-detail/controller', async importOriginal => { - const actual = await importOriginal(); - return { - ...actual, - loadEntityDetailFromFacade: loadEntityDetail - }; -}); - -describe('EntityDetailPage', () => { - let container: HTMLDivElement | null = null; - let root: Root | null = null; - - beforeEach(() => { - mockState.lastLoad = null; - mockState.refresh.mockReset(); - mockState.push.mockReset(); - loadEntityDetail.mockClear().mockResolvedValue(mockState.renderData); - vi.mocked(apiMessageDelete).mockReset(); - }); - - afterEach(() => { - if (root) { - act(() => { - root?.unmount(); - }); - } - container?.remove(); - root = null; - container = null; - }); - - it('renders the cold full-width entity detail workbench without old Workbench side panels', async () => { - const loadingT = createTranslatorMock(); - const expectedT = createTranslatorMock({ locale: 'zh-CN' }); - const source = readFileSync(resolve(process.cwd(), 'app/entities/[entityId]/entity-detail-page.tsx'), 'utf8'); - const html = renderToStaticMarkup(); - - expect(html).toContain(`data-loading-title="${loadingT('entities.detail.loading.title')}"`); - expect(html).toContain(`data-loading-copy="${loadingT('entities.detail.loading.copy')}"`); - expect(html).toContain('data-load-timeout-ms="15000"'); - expect(html).toContain('data-loading-delay-ms="150"'); - expect(html).toContain('data-entity-detail-surface="otlp-hertzbeat-ui-entity-detail"'); - expect(html).toContain('data-entity-detail-style-baseline="hertzbeat-ui-matte"'); - expect(html).toContain('data-entity-detail-layout="full-width-workbench"'); - expect(html).toContain('data-entity-detail-header="hertzbeat-ui-compact-header"'); - expect(html).toContain('data-entity-detail-header-nesting-contract="flat-page-introduction"'); - expect(html).toContain('data-entity-detail-command-row="standard-equal-buttons"'); - expect(html).toContain('data-entity-detail-count-strip="hertzbeat-ui-inline-counts"'); - expect(html).toContain('data-entity-detail-signal-grid="hertzbeat-ui-detail-grid"'); - expect(html).toContain('data-entity-detail-overview-panel="hertzbeat-ui-overview-panel"'); - expect(html).toContain('data-entity-detail-related-panel="hertzbeat-ui-related-panel"'); - expect(html).toContain('data-entity-detail-next-panel="hertzbeat-ui-next-panel"'); - expect(html).toContain('data-entity-detail-drilldown-panel="hertzbeat-ui-drilldown-panel"'); - expect(html).toContain(expectedT('entities.detail.header.badge')); - expect(html).toContain(expectedT('entities.detail.header.kicker')); - expect(html).toContain(expectedT('entities.detail.action.all-entities')); - expect(html).toContain(expectedT('common.refresh')); - expect(html).toContain(expectedT('entities.detail.action.edit-definition')); - expect(html).toContain(expectedT('entities.detail.action.delete')); - expect(html).toContain(expectedT('entities.detail.action.edit')); - expect(html).toContain(expectedT('entities.detail.panel.overview.title')); - expect(html).toContain(expectedT('entities.detail.panel.related.title')); - expect(html).toContain(expectedT('entities.detail.panel.next.title')); - expect(html).toContain(expectedT('entities.detail.panel.drilldown.title')); - expect(html).toContain('/entities/42/definition'); - expect(html).not.toContain('Refresh'); - expect(html).not.toContain('Edit definition'); - expect(html).not.toContain('Next steps'); - expect(html).not.toContain('Review the summary first'); - expect(html).not.toContain('data-workbench-actions'); - expect(html).not.toContain('data-stage-section'); - expect(html).not.toContain('data-drawer-section'); - - expect(source).toContain("from '@/components/pages/entity-detail-surface'"); - expect(source).not.toContain('window.confirm'); - expect(source).not.toContain('confirm('); - expect(source).not.toContain("from '@/components/observability'"); - expect(source).not.toContain("from '@/components/workbench/workbench-page'"); - expect(source).not.toContain('WorkbenchPage'); - expect(source).not.toContain('StageSection'); - expect(source).not.toContain('DrawerSection'); - expect(source).not.toContain('ObservabilityStatusState'); - expect(source).not.toContain("from '@/components/workbench/primitives'"); - - await mockState.lastLoad?.(); - - expect(loadEntityDetail).toHaveBeenCalledWith(expect.any(Function), '42', expect.any(Function)); - }); - - it('passes inherited time and monitor context into the entity detail surface', () => { - const routeContext = { - timeRange: 'last-45m', - start: '1713200000000', - end: '1713202700000', - refresh: '30', - live: 'false', - tz: 'Asia/Shanghai', - source: 'monitor', - monitorId: '632051474676992', - monitorName: 'checkout-http', - monitorApp: 'website', - monitorInstance: 'example.com:443' - }; - - const html = renderToStaticMarkup(); - - expect(html).toContain('data-entity-detail-route-monitor-id="632051474676992"'); - expect(html).toContain('data-entity-detail-route-time-range="last-45m"'); - }); - - it('passes post-create result into the entity detail surface without requiring a route context source', () => { - const html = renderToStaticMarkup(); - - expect(html).toContain('data-entity-detail-created-result="true"'); - }); - - it('keeps the novice post-create detail route tied to the originating entity list context', () => { - const html = renderToStaticMarkup( - - ); - - expect(html).toContain('data-entity-detail-created-result="true"'); - expect(html).toContain( - 'data-entity-detail-route-return-to="/entities?search=checkout&pageSize=50&source=product-design-1719"' - ); - expect(html).toContain('data-entity-detail-surface="otlp-hertzbeat-ui-entity-detail"'); - }); - - it('returns to the safe entity list context after delete instead of dropping query parameters', () => { - expect( - buildEntityDetailDeleteReturnHref({ - returnTo: '/entities?search=checkout&source=entity-create-return&pageSize=8', - source: 'entity-create-return' - }, '42') - ).toBe('/entities?search=checkout&source=entity-create-return&pageSize=8'); - - expect( - buildEntityDetailDeleteReturnHref({ - returnTo: 'https://example.invalid/entities', - timeRange: 'last-30m', - source: 'monitor' - }, '42') - ).toBe('/entities?timeRange=last-30m&source=monitor'); - - expect( - buildEntityDetailDeleteReturnHref({ - returnTo: '/entities/42?source=entity-edit-return', - timeRange: 'last-30m', - source: 'entity-edit-return' - }, '42') - ).toBe('/entities?timeRange=last-30m&source=entity-edit-return'); - - expect( - buildEntityDetailDeleteReturnHref({ - returnTo: '/entities/42/edit?source=entity-edit-return', - timeRange: 'last-30m', - source: 'entity-edit-return' - }, 42) - ).toBe('/entities?timeRange=last-30m&source=entity-edit-return'); - }); - - it('adds a list-visible success result to the post-delete return URL', () => { - expect(buildEntityDetailDeleteSuccessHref('/entities?source=entity-delete-return&pageSize=8', '42')).toBe( - '/entities?source=entity-delete-return&pageSize=8&deleteResult=success&deletedEntity=42' - ); - expect(buildEntityDetailDeleteSuccessHref('/entities', null)).toBe('/entities?deleteResult=success'); - }); - - it('keeps novice entity detail recoverable when delete fails', async () => { - vi.mocked(apiMessageDelete).mockRejectedValueOnce(new Error('delete denied by relation guard')); - container = document.createElement('div'); - document.body.appendChild(container); - root = createRoot(container); - - await act(async () => { - root?.render( - - ); - }); - - const deleteButton = container.querySelector('[data-entity-detail-delete-confirm-action="route-mock"]'); - expect(deleteButton).not.toBeNull(); - - await act(async () => { - deleteButton?.click(); - }); - - expect(apiMessageDelete).toHaveBeenCalledTimes(1); - expect(apiMessageDelete).toHaveBeenCalledWith('/entities/42'); - expect(container.innerHTML).toContain('data-entity-detail-error="hertzbeat-ui-inline-error"'); - expect(container.textContent).toContain('delete denied by relation guard'); - expect(container.innerHTML).toContain('data-entity-detail-surface="otlp-hertzbeat-ui-entity-detail"'); - expect(container.innerHTML).toContain('data-entity-detail-route-return-to="/entities?search=checkout&pageSize=50&source=product-design-delete-failure"'); - expect(mockState.push).not.toHaveBeenCalled(); - expect(mockState.refresh).not.toHaveBeenCalled(); - }); - - it('keeps entity detail remounts on a short settled cache window with refresh invalidation', () => { - const source = readFileSync(resolve(process.cwd(), 'app/entities/[entityId]/entity-detail-page.tsx'), 'utf8'); - - expect(source).toContain('ENTITY_DETAIL_SETTLED_CACHE_TTL_MS = 10_000'); - expect(source).toContain('ENTITY_DETAIL_LOAD_TIMEOUT_MS = 15_000'); - expect(source).toContain('key={entityDetailCacheKey}'); - expect(source).toContain("loadingTitle={t('entities.detail.loading.title')}"); - expect(source).toContain("loadingCopy={t('entities.detail.loading.copy')}"); - expect(source).toContain('loadTimeoutMs={ENTITY_DETAIL_LOAD_TIMEOUT_MS}'); - expect(source).toContain('loadingDelayMs={150}'); - expect(source).toContain('const [reloadNonce, setReloadNonce] = useState(0)'); - expect(source).toContain("['entity-detail', entityDetailUrl, reloadNonce].join(':')"); - expect(source).toContain('[entityDetailUrl, reloadNonce]'); - expect(source).toContain('void reloadNonce'); - expect(source).toContain("import { api } from '@/lib/api-facade';"); - expect(source).toContain('return loadEntityDetailFromFacade(api.entities.detail, entityId, t);'); - expect(source).toContain("import { resetWorkbenchLoadCache } from '@/lib/workbench-load-cache';"); - expect(source).toContain('resetWorkbenchLoadCache();'); - expect(source).toContain('router.push(buildEntityDetailDeleteSuccessHref(deleteReturnHref, entityId));'); - expect(source).not.toContain('return loadEntityDetail(apiMessageGet, entityId, t);'); - expect(source).toContain('setReloadNonce(current => current + 1)'); - expect(source).toContain('router.refresh();'); - expect(source).toContain('cacheKey={entityDetailCacheKey}'); - expect(source).toContain('cacheSettledTtlMs={ENTITY_DETAIL_SETTLED_CACHE_TTL_MS}'); - }); -}); diff --git a/web-next/app/entities/[entityId]/page.tsx b/web-next/app/entities/[entityId]/page.tsx deleted file mode 100644 index 74ec64fafe..0000000000 --- a/web-next/app/entities/[entityId]/page.tsx +++ /dev/null @@ -1,24 +0,0 @@ -import React from 'react'; - -import EntityDetailPage from './entity-detail-page'; -import { - readEntityDetailCreatedResult, - readEntityDetailRouteContext, - readEntityDetailUpdatedResult, - type EntityDetailSearchParams -} from '../../../lib/entity-detail/query-state'; - -export default async function EntityDetailRoutePage({ - params, - searchParams -}: { - params: Promise<{ entityId: string }>; - searchParams?: Promise; -}) { - const { entityId } = await params; - const resolvedSearchParams = await searchParams; - const routeContext = readEntityDetailRouteContext(resolvedSearchParams); - const createdResult = readEntityDetailCreatedResult(resolvedSearchParams); - const updatedResult = readEntityDetailUpdatedResult(resolvedSearchParams); - return ; -} diff --git a/web-next/app/entities/discovery/entity-discovery-page.tsx b/web-next/app/entities/discovery/entity-discovery-page.tsx deleted file mode 100644 index 1edaa1b624..0000000000 --- a/web-next/app/entities/discovery/entity-discovery-page.tsx +++ /dev/null @@ -1,72 +0,0 @@ -'use client'; - -import React, { useCallback } from 'react'; -import { useSearchParams } from 'next/navigation'; -import { ClientWorkbench } from '@/components/workbench/client-workbench'; -import { useI18n } from '@/components/providers/i18n-provider'; -import { EntityDiscoverySurface } from '@/components/pages/entity-discovery-surface'; -import { api } from '@/lib/api-facade'; -import { - buildDiscoveryCatalogSuggestionsUrl, - buildDiscoveryGovernanceActivitiesUrl, - buildDiscoveryGovernancePresetsUrl, - loadDiscoveryDataFromFacade -} from '@/lib/entity-discovery/controller'; -import { resolveDiscoveryCandidateContext } from '@/lib/entity-discovery/search-state'; - -const ENTITY_DISCOVERY_SETTLED_CACHE_TTL_MS = 10_000; - -type DiscoveryData = Awaited>; - -export default function EntityDiscoveryPage() { - const { t } = useI18n(); - const searchParams = useSearchParams(); - const entityDiscoveryPresetsUrl = React.useMemo(() => buildDiscoveryGovernancePresetsUrl(), []); - const entityDiscoveryActivitiesUrl = React.useMemo(() => buildDiscoveryGovernanceActivitiesUrl(), []); - const entityDiscoveryCatalogUrl = React.useMemo(() => buildDiscoveryCatalogSuggestionsUrl(), []); - const candidateContext = React.useMemo(() => resolveDiscoveryCandidateContext(searchParams), [searchParams]); - const initialSearch = React.useMemo(() => searchParams.get('search')?.trim() || null, [searchParams]); - const initialSource = React.useMemo(() => searchParams.get('source')?.trim() || null, [searchParams]); - const deleteSuccess = React.useMemo(() => searchParams.get('deleteResult')?.trim() === 'success', [searchParams]); - const deletedEntity = React.useMemo(() => searchParams.get('deletedEntity')?.trim() || null, [searchParams]); - const initialPageIndex = React.useMemo(() => { - const raw = Number(searchParams.get('pageIndex') || 0); - return Number.isFinite(raw) ? Math.max(0, Math.floor(raw)) : 0; - }, [searchParams]); - const entityDiscoveryCacheKey = React.useMemo( - () => ['entity-discovery', entityDiscoveryPresetsUrl, entityDiscoveryActivitiesUrl, entityDiscoveryCatalogUrl].join(':'), - [entityDiscoveryActivitiesUrl, entityDiscoveryCatalogUrl, entityDiscoveryPresetsUrl] - ); - const load = useCallback( - async (): Promise => - loadDiscoveryDataFromFacade({ - presets: api.entities.discoveryGovernancePresets, - activities: api.entities.discoveryGovernanceActivities, - catalogSuggestions: api.entities.catalogSuggestions - }), - [] - ); - - return ( - - {data => ( - - )} - - ); -} diff --git a/web-next/app/entities/discovery/page.test.tsx b/web-next/app/entities/discovery/page.test.tsx deleted file mode 100644 index b8e251b326..0000000000 --- a/web-next/app/entities/discovery/page.test.tsx +++ /dev/null @@ -1,144 +0,0 @@ -import React from 'react'; -import { renderToStaticMarkup } from 'react-dom/server'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; -import EntityDiscoveryPage from './entity-discovery-page'; -import { createTranslatorMock } from '../../../test/i18n-test-helper'; - -const mockState = vi.hoisted(() => ({ - lastLoad: null as null | (() => Promise), - renderData: { - presets: [{ id: '1', name: 'default-preset' }], - activities: [{ id: '2', summary: 'preset synced' }], - catalog: { owners: ['platform'] } - } -})); - -const readDiscoveryPresets = vi.hoisted(() => vi.fn(async () => mockState.renderData.presets)); -const readDiscoveryActivities = vi.hoisted(() => vi.fn(async () => mockState.renderData.activities)); -const readCatalogSuggestions = vi.hoisted(() => vi.fn(async () => mockState.renderData.catalog)); -const loadDiscoveryDataFromFacade = vi.hoisted(() => vi.fn(async () => mockState.renderData)); - -vi.mock('@/components/providers/i18n-provider', () => ({ - useI18n: () => ({ - t: createTranslatorMock({ - locale: 'zh-CN' - }) - }) -})); - -const expectedT = createTranslatorMock({ locale: 'zh-CN' }); - -vi.mock('next/navigation', () => ({ - useSearchParams: () => - new URLSearchParams( - 'search=Codex%20PD%201315&pageIndex=1&identityKey=service.name&identityValue=billing&serviceName=billing-api&serviceNamespace=commerce&environment=prod&source=product-design-1335' - ) -})); - -vi.mock('@/components/workbench/client-workbench', () => ({ - ClientWorkbench: ({ - children, - load, - loadingCopy, - cacheKey, - cacheSettledTtlMs - }: { - children: (data: any) => React.ReactNode; - load: () => Promise; - loadingCopy?: string; - cacheKey?: string; - cacheSettledTtlMs?: number; - }) => { - mockState.lastLoad = load; - return ( -
- {children(mockState.renderData)} -
- ); - } -})); - -vi.mock('@/components/pages/entity-discovery-surface', () => ({ - EntityDiscoverySurface: ({ presets, activities, catalog, candidateContext, initialSearch, initialSource, initialPageIndex, deleteSuccess, deletedEntity }: any) => ( -
- {candidateContext ? ( - - ) : null} - {presets.length} presets / {activities.length} activities / {catalog.owners.length} owners -
- ) -})); - -vi.mock('@/lib/api-facade', () => ({ - api: { - entities: { - discoveryGovernancePresets: readDiscoveryPresets, - discoveryGovernanceActivities: readDiscoveryActivities, - catalogSuggestions: readCatalogSuggestions - } - } -})); - -vi.mock('@/lib/entity-discovery/controller', () => ({ - buildDiscoveryCatalogSuggestionsUrl: () => '/entities/catalog-suggestions?limit=120', - buildDiscoveryGovernanceActivitiesUrl: () => '/entities/discovery/governance-activities?limit=8', - buildDiscoveryGovernancePresetsUrl: () => '/entities/discovery/governance-presets?limit=8', - loadDiscoveryDataFromFacade -})); - -describe('EntityDiscoveryPage', () => { - beforeEach(() => { - mockState.lastLoad = null; - readDiscoveryPresets.mockClear().mockResolvedValue(mockState.renderData.presets); - readDiscoveryActivities.mockClear().mockResolvedValue(mockState.renderData.activities); - readCatalogSuggestions.mockClear().mockResolvedValue(mockState.renderData.catalog); - loadDiscoveryDataFromFacade.mockClear().mockResolvedValue(mockState.renderData); - }); - - it('loads discovery workspace data and renders the shared discovery surface', async () => { - const html = renderToStaticMarkup(); - - expect(html).toContain('data-entity-discovery-surface="otlp-hertzbeat-ui-discovery-console"'); - expect(html).toContain(`data-loading-copy="${expectedT('entities.discovery.loading')}"`); - expect(html).toContain( - 'data-cache-key="entity-discovery:/entities/discovery/governance-presets?limit=8:/entities/discovery/governance-activities?limit=8:/entities/catalog-suggestions?limit=120"' - ); - expect(html).toContain('data-cache-settled-ttl="10000"'); - expect(html).toContain('1 presets / 1 activities / 1 owners'); - expect(html).toContain('data-page-candidate-source="otlp-candidate"'); - expect(html).toContain('data-page-candidate-identity="service.name:billing"'); - expect(html).toContain('data-page-candidate-service="billing-api"'); - expect(html).toContain('data-page-candidate-namespace="commerce"'); - expect(html).toContain('data-page-candidate-environment="prod"'); - expect(html).toContain('data-page-initial-search="Codex PD 1315"'); - expect(html).toContain('data-page-initial-source="product-design-1335"'); - expect(html).toContain('data-page-delete-success="false"'); - expect(html).toContain('data-page-initial-page-index="1"'); - - await mockState.lastLoad?.(); - - expect(loadDiscoveryDataFromFacade).toHaveBeenCalledWith({ - presets: readDiscoveryPresets, - activities: readDiscoveryActivities, - catalogSuggestions: readCatalogSuggestions - }); - }); -}); diff --git a/web-next/app/entities/discovery/page.tsx b/web-next/app/entities/discovery/page.tsx deleted file mode 100644 index 6e8b21225a..0000000000 --- a/web-next/app/entities/discovery/page.tsx +++ /dev/null @@ -1,7 +0,0 @@ -import React from 'react'; - -import EntityDiscoveryPage from './entity-discovery-page'; - -export default function EntityDiscoveryRoutePage() { - return ; -} diff --git a/web-next/app/entities/entity-list-page.tsx b/web-next/app/entities/entity-list-page.tsx deleted file mode 100644 index a45705998a..0000000000 --- a/web-next/app/entities/entity-list-page.tsx +++ /dev/null @@ -1,253 +0,0 @@ -'use client'; - -import { useRouter } from 'next/navigation'; -import React, { useCallback, useEffect, useMemo, useState } from 'react'; -import { ClientWorkbench } from '@/components/workbench/client-workbench'; -import { EntityListSurface } from '../../components/pages/entity-list-surface'; -import { useI18n } from '@/components/providers/i18n-provider'; -import { api } from '@/lib/api-facade'; -import { loadEntityListFromFacade, type EntityListPageTrim } from '@/lib/entity-manage/controller'; -import { entityEnvironmentLabel, entityStatusLabel, entityTypeLabel } from '@/lib/entity-manage/display-mapping'; -import { formatTime } from '@/lib/format'; -import { - buildEntityListRouteUrl, - buildEntityUrl, - isSupportedEntityListPageSize, - normalizeEntityListPageIndex, - normalizeEntityListPageSize, - type EntityQueryState -} from '@/lib/entity-manage/query-state'; -import { buildEntityTableRows, isEntityAbnormalStatus, isEntityPendingEvidenceStatus } from '@/lib/entity-manage/view-model'; -import type { EntitySummaryInfo, PageResult } from '@/lib/types'; - -const ENTITY_LIST_SETTLED_CACHE_TTL_MS = 10_000; -const EMPTY_ENTITY_QUERY: EntityQueryState = { - search: '', - type: '', - status: '', - pageIndex: '', - source: '', - returnTo: '', - pageSize: '', - timeRange: '', - start: '', - end: '', - refresh: '', - live: '', - tz: '', - probe: '', - monitorId: '', - monitorName: '', - monitorApp: '', - monitorInstance: '', - deleteResult: '', - deletedEntity: '' -}; - -type EntityListPageOutOfRange = { - requestedPage: number; - displayedPage: number; - totalPages: number; -}; - -type EntityListLoadResult = { - list: PageResult; - pageOutOfRange?: EntityListPageOutOfRange; -}; - -type EntityListPageSizeAdjustment = { - requested: string; - applied: string; -}; - -function clearEntityListTransientFeedback(query: EntityQueryState): EntityQueryState { - return { ...query, deleteResult: '', deletedEntity: '' }; -} - -function normalizeEntityListQueryForRuntime(query: EntityQueryState): EntityQueryState { - return { - ...query, - pageIndex: query.pageIndex?.trim() ? normalizeEntityListPageIndex(query.pageIndex) : '', - pageSize: query.pageSize?.trim() ? normalizeEntityListPageSize(query.pageSize) : '' - }; -} - -function detectEntityListPageSizeAdjustment(query: EntityQueryState): EntityListPageSizeAdjustment | undefined { - const requested = query.pageSize?.trim(); - if (!requested || isSupportedEntityListPageSize(requested)) return undefined; - return { - requested, - applied: normalizeEntityListPageSize(requested) - }; -} - -function resolveEntityListPageOutOfRange(query: EntityQueryState, list: PageResult): EntityListPageOutOfRange | null { - const total = list.totalElements || 0; - if (total <= 0 || (list.content?.length || 0) > 0) return null; - - const requestedPageIndex = Number.parseInt(normalizeEntityListPageIndex(query.pageIndex), 10); - const pageSize = list.pageSize || Number.parseInt(normalizeEntityListPageSize(query.pageSize), 10); - const totalPages = Math.max(1, Math.ceil(total / pageSize)); - const lastPageIndex = totalPages - 1; - if (!Number.isFinite(requestedPageIndex) || requestedPageIndex <= lastPageIndex) return null; - - return { - requestedPage: requestedPageIndex + 1, - displayedPage: totalPages, - totalPages - }; -} - -export default function EntityListPage({ initialQuery }: { initialQuery?: EntityQueryState } = {}) { - const { t } = useI18n(); - const router = useRouter(); - const initialEntityQuery = initialQuery ?? EMPTY_ENTITY_QUERY; - const [draft, setDraft] = useState(() => normalizeEntityListQueryForRuntime(initialEntityQuery)); - const [query, setQuery] = useState(() => normalizeEntityListQueryForRuntime(initialEntityQuery)); - const [pageSizeAdjustment, setPageSizeAdjustment] = useState(() => - detectEntityListPageSizeAdjustment(initialEntityQuery) - ); - const [refreshNonce, setRefreshNonce] = useState(0); - const entityListUrl = useMemo(() => buildEntityUrl(query), [query]); - const entityListCacheKey = useMemo( - () => ['entity-list', entityListUrl, refreshNonce].join(':'), - [entityListUrl, refreshNonce] - ); - - const load = useCallback(async (): Promise => { - const list = await loadEntityListFromFacade(api.entities.list, query); - const pageOutOfRange = resolveEntityListPageOutOfRange(query, list); - if (!pageOutOfRange) { - return { list }; - } - - const clampedQuery = { ...query, pageIndex: String(pageOutOfRange.totalPages - 1) }; - const clampedList = await loadEntityListFromFacade(api.entities.list, clampedQuery); - return { list: clampedList, pageOutOfRange }; - }, [query]); - - useEffect(() => { - if (pageSizeAdjustment) { - router.replace(buildEntityListRouteUrl(query), { scroll: false }); - } - }, [pageSizeAdjustment, query, router]); - - const applyRouteQuery = useCallback((nextQuery: EntityQueryState) => { - const normalizedQuery = normalizeEntityListQueryForRuntime(nextQuery); - setPageSizeAdjustment(detectEntityListPageSizeAdjustment(nextQuery)); - setDraft(normalizedQuery); - setQuery(normalizedQuery); - router.replace(buildEntityListRouteUrl(normalizedQuery), { scroll: false }); - }, [router]); - - const applyQuery = (submittedSearch?: string) => { - const nextQuery = clearEntityListTransientFeedback({ ...draft, search: submittedSearch ?? draft.search, pageIndex: '0' }); - applyRouteQuery(nextQuery); - }; - const refreshQuery = () => { - const nextQuery = normalizeEntityListQueryForRuntime(clearEntityListTransientFeedback({ ...draft })); - setPageSizeAdjustment(undefined); - setDraft(nextQuery); - setQuery(nextQuery); - setRefreshNonce(current => current + 1); - }; - const resetQuery = () => { - const empty = { ...EMPTY_ENTITY_QUERY }; - applyRouteQuery(empty); - }; - const changePageIndex = (pageIndex: number) => { - const nextQuery = clearEntityListTransientFeedback({ ...draft, pageIndex: normalizeEntityListPageIndex(pageIndex) }); - applyRouteQuery(nextQuery); - }; - const changePageSize = (pageSize: number) => { - const nextQuery = clearEntityListTransientFeedback({ ...draft, pageIndex: '0', pageSize: normalizeEntityListPageSize(pageSize) }); - applyRouteQuery(nextQuery); - }; - - return ( - - {data => { - const effectiveQuery = data.pageOutOfRange - ? { ...query, pageIndex: String(data.pageOutOfRange.displayedPage - 1) } - : query; - const effectiveEntityListRouteUrl = buildEntityListRouteUrl(effectiveQuery); - const pageIndex = data.pageOutOfRange - ? data.pageOutOfRange.displayedPage - 1 - : data.list.pageIndex ?? Number.parseInt(normalizeEntityListPageIndex(query.pageIndex), 10); - const pageSize = data.list.pageSize || Number.parseInt(normalizeEntityListPageSize(query.pageSize), 10); - const rawContent = Array.isArray(data.list.content) ? data.list.content : []; - const visibleContent = rawContent.slice(0, pageSize); - const controllerPayloadTrim = (data.list as PageResult & { contentTrim?: EntityListPageTrim }).contentTrim; - const payloadTrim = controllerPayloadTrim ?? (rawContent.length > pageSize - ? { received: rawContent.length, rendered: visibleContent.length } - : undefined); - const rows = buildEntityTableRows( - visibleContent, - t, - value => entityTypeLabel(value, t), - value => entityEnvironmentLabel(value, t), - value => entityStatusLabel(value, t), - formatTime, - { - returnTo: effectiveEntityListRouteUrl, - source: effectiveQuery.source, - timeRange: effectiveQuery.timeRange, - start: effectiveQuery.start, - end: effectiveQuery.end, - refresh: effectiveQuery.refresh, - live: effectiveQuery.live, - tz: effectiveQuery.tz, - probe: effectiveQuery.probe, - monitorId: effectiveQuery.monitorId, - monitorName: effectiveQuery.monitorName, - monitorApp: effectiveQuery.monitorApp, - monitorInstance: effectiveQuery.monitorInstance - } - ); - const total = data.list.totalElements || rows.length; - const rangeFrom = total > 0 ? pageIndex * pageSize + 1 : 0; - const rangeTo = total > 0 ? Math.min(pageIndex * pageSize + rows.length, total) : 0; - const abnormalCount = visibleContent.filter(item => isEntityAbnormalStatus(item.entity?.status)).length; - const healthPendingCount = visibleContent.filter(item => isEntityPendingEvidenceStatus(item.entity?.status)).length; - const alertingCount = rows.filter(row => Number(row.activeAlertCount) > 0).length; - const linkedCount = rows.filter(row => Number(row.relationCount) > 0).length; - - return ( - setDraft(prev => ({ ...prev, ...patch }))} - onSearch={applyQuery} - onRefresh={refreshQuery} - onReset={resetQuery} - onPageIndexChange={changePageIndex} - onPageSizeChange={changePageSize} - /> - ); - }} - - ); -} diff --git a/web-next/app/entities/import/entity-import-page.tsx b/web-next/app/entities/import/entity-import-page.tsx deleted file mode 100644 index 11795e60a5..0000000000 --- a/web-next/app/entities/import/entity-import-page.tsx +++ /dev/null @@ -1,62 +0,0 @@ -'use client'; - -import React, { useCallback } from 'react'; -import { ClientWorkbench } from '@/components/workbench/client-workbench'; -import { useI18n } from '@/components/providers/i18n-provider'; -import { EntityImportSurface } from '@/components/pages/entity-import-surface'; -import { api } from '@/lib/api-facade'; -import { buildImportActivitiesUrl, buildImportTemplatesUrl, loadImportDataFromFacade } from '@/lib/entity-import/controller'; -import type { SignalRouteContext } from '@/lib/signal-route-context'; - -const ENTITY_IMPORT_SETTLED_CACHE_TTL_MS = 10_000; - -type ImportData = Awaited>; - -export default function EntityImportPage({ - deletedEntity, - deleteResult, - routeContext -}: { - deletedEntity?: string | null; - deleteResult?: 'success' | null; - routeContext?: SignalRouteContext; -}) { - const { t } = useI18n(); - const entityImportTemplatesUrl = React.useMemo(() => buildImportTemplatesUrl(), []); - const entityImportActivitiesUrl = React.useMemo(() => buildImportActivitiesUrl(), []); - const entityImportCacheKey = React.useMemo( - () => ['entity-import', entityImportTemplatesUrl, entityImportActivitiesUrl].join(':'), - [entityImportActivitiesUrl, entityImportTemplatesUrl] - ); - const load = useCallback( - async (): Promise => - loadImportDataFromFacade({ - templates: api.entities.importTemplates, - activities: api.entities.importActivities - }), - [] - ); - - return ( - - {data => ( - - )} - - ); -} diff --git a/web-next/app/entities/import/page.test.tsx b/web-next/app/entities/import/page.test.tsx deleted file mode 100644 index b8c57c4c96..0000000000 --- a/web-next/app/entities/import/page.test.tsx +++ /dev/null @@ -1,161 +0,0 @@ -import React from 'react'; -import { renderToStaticMarkup } from 'react-dom/server'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; -import EntityImportPage from './entity-import-page'; -import EntityImportRoutePage from './page'; -import { createTranslatorMock } from '../../../test/i18n-test-helper'; - -const mockState = vi.hoisted(() => ({ - lastLoad: null as null | (() => Promise), - renderData: { - templates: [{ id: '1', name: 'base-template', format: 'yaml', content: 'kind: service' }], - activities: [{ id: 1, summary: 'bundle previewed', status: 'success', activityType: 'preview' }] - } -})); - -const readImportTemplates = vi.hoisted(() => vi.fn(async () => mockState.renderData.templates)); -const readImportActivities = vi.hoisted(() => vi.fn(async () => mockState.renderData.activities)); -const loadImportDataFromFacade = vi.hoisted(() => vi.fn(async () => mockState.renderData)); - -vi.mock('@/components/providers/i18n-provider', () => ({ - useI18n: () => ({ - t: createTranslatorMock({ - locale: 'zh-CN' - }) - }) -})); - -vi.mock('@/components/workbench/client-workbench', () => ({ - ClientWorkbench: ({ - children, - load, - loadingCopy, - cacheKey, - cacheSettledTtlMs - }: { - children: (data: any) => React.ReactNode; - load: () => Promise; - loadingCopy: string; - cacheKey?: string; - cacheSettledTtlMs?: number; - }) => { - mockState.lastLoad = load; - return ( -
- {children(mockState.renderData)} -
- ); - } -})); - -vi.mock('@/components/pages/entity-import-surface', () => ({ - EntityImportSurface: ({ initialMessage, initialMessageTone, routeContext, templates, activities }: any) => ( -
- {templates.length} templates / {activities.length} activities -
- ) -})); - -vi.mock('@/lib/api-facade', () => ({ - api: { - entities: { - importTemplates: readImportTemplates, - importActivities: readImportActivities - } - } -})); - -vi.mock('@/lib/entity-import/controller', () => ({ - buildImportActivitiesUrl: () => '/entities/definition-activities?limit=8', - buildImportTemplatesUrl: () => '/entities/definition/templates?limit=8', - loadImportDataFromFacade -})); - -describe('EntityImportPage', () => { - beforeEach(() => { - mockState.lastLoad = null; - readImportTemplates.mockClear().mockResolvedValue(mockState.renderData.templates); - readImportActivities.mockClear().mockResolvedValue(mockState.renderData.activities); - loadImportDataFromFacade.mockClear().mockResolvedValue(mockState.renderData); - }); - - it('loads shared import data and renders the shared import surface', async () => { - const expectedT = createTranslatorMock({ locale: 'zh-CN' }); - const html = renderToStaticMarkup(); - - expect(html).toContain('data-entity-import-surface="true"'); - expect(html).toContain(`data-loading-copy="${expectedT('entities.import.loading')}"`); - expect(html).toContain( - 'data-cache-key="entity-import:/entities/definition/templates?limit=8:/entities/definition-activities?limit=8"' - ); - expect(html).toContain('data-cache-settled-ttl="10000"'); - expect(html).toContain('1 templates / 1 activities'); - - await mockState.lastLoad?.(); - - expect(loadImportDataFromFacade).toHaveBeenCalledWith({ - templates: readImportTemplates, - activities: readImportActivities - }); - }); - - it('passes route context into the import surface', () => { - const html = renderToStaticMarkup( - - ); - - expect(html).toContain('data-route-source="product-design-1330"'); - expect(html).toContain('data-route-return-to="/entities?source=product-design-1330&pageSize=50"'); - expect(html).toContain('data-route-time-range="last-30m"'); - }); - - it('reads route context from import route search params', async () => { - const html = renderToStaticMarkup( - await EntityImportRoutePage({ - searchParams: Promise.resolve({ - source: 'product-design-1330', - returnTo: '/entities?source=product-design-1330&pageSize=50', - timeRange: 'last-30m' - }) - }) - ); - - expect(html).toContain('data-route-source="product-design-1330"'); - expect(html).toContain('data-route-return-to="/entities?source=product-design-1330&pageSize=50"'); - expect(html).toContain('data-route-time-range="last-30m"'); - }); - - it('shows delete success feedback when entity detail returns to import', async () => { - const expectedT = createTranslatorMock({ locale: 'zh-CN' }); - const html = renderToStaticMarkup( - await EntityImportRoutePage({ - searchParams: Promise.resolve({ - deleteResult: 'success', - deletedEntity: '658679066385664', - source: 'product-design-1499' - }) - }) - ); - - expect(html).toContain('data-initial-message-tone="success"'); - expect(html).toContain(expectedT('entities.import.delete-success', { id: '658679066385664' })); - }); -}); diff --git a/web-next/app/entities/import/page.tsx b/web-next/app/entities/import/page.tsx deleted file mode 100644 index f91fa2119c..0000000000 --- a/web-next/app/entities/import/page.tsx +++ /dev/null @@ -1,22 +0,0 @@ -import React from 'react'; - -import EntityImportPage from './entity-import-page'; -import { createCompatSearchParamReader } from '../../../lib/compat/search-params'; -import { readEntityDetailRouteContext, type EntityDetailSearchParams } from '../../../lib/entity-detail/query-state'; - -export default async function EntityImportRoutePage({ - searchParams -}: { - searchParams?: Promise; -}) { - const resolvedSearchParams = await searchParams; - const routeContext = readEntityDetailRouteContext(resolvedSearchParams); - const reader = createCompatSearchParamReader(resolvedSearchParams); - return ( - - ); -} diff --git a/web-next/app/entities/legacy/page.test.ts b/web-next/app/entities/legacy/page.test.ts deleted file mode 100644 index d456c53949..0000000000 --- a/web-next/app/entities/legacy/page.test.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { describe, expect, it, vi } from 'vitest'; - -const redirect = vi.fn(); - -vi.mock('next/navigation', () => ({ - redirect -})); - -describe('legacy entities route', () => { - it('redirects the obsolete single-segment route to the entity catalog with query context', async () => { - redirect.mockImplementation((target: string) => { - throw new Error(`redirect:${target}`); - }); - - const { default: LegacyEntitiesPage } = await import('./page'); - - await expect( - LegacyEntitiesPage({ - searchParams: Promise.resolve({ - search: 'checkout', - source: 'product-design-audit' - }) - }) - ).rejects.toThrow( - 'redirect:/entities?search=checkout&source=product-design-audit' - ); - expect(redirect).toHaveBeenCalledWith( - '/entities?search=checkout&source=product-design-audit' - ); - }); -}); diff --git a/web-next/app/entities/legacy/page.tsx b/web-next/app/entities/legacy/page.tsx deleted file mode 100644 index 39392188ba..0000000000 --- a/web-next/app/entities/legacy/page.tsx +++ /dev/null @@ -1,12 +0,0 @@ -import { redirect } from 'next/navigation'; -import { - buildEntityListCompatRouteUrl, - type SearchParamsRecord -} from '../../../lib/entity-manage/query-state'; - -export default async function LegacyEntitiesPage(props: { - searchParams?: Promise; -}) { - const resolvedSearchParams = await props.searchParams; - redirect(buildEntityListCompatRouteUrl(resolvedSearchParams)); -} diff --git a/web-next/app/entities/new/entity-new-page.tsx b/web-next/app/entities/new/entity-new-page.tsx deleted file mode 100644 index 452c1d9723..0000000000 --- a/web-next/app/entities/new/entity-new-page.tsx +++ /dev/null @@ -1,105 +0,0 @@ -'use client'; - -import React, { useCallback } from 'react'; -import { ClientWorkbench } from '@/components/workbench/client-workbench'; -import { useI18n } from '@/components/providers/i18n-provider'; -import { EntityEditorSurface } from '@/components/pages/entity-editor-surface'; -import { api } from '@/lib/api-facade'; -import { - buildEntityEditorCatalogSuggestionsUrl, - buildEntityEditorNewDraftFromFacade, - buildEntityEditorSeedMonitorUrl, - loadEntityEditorCatalogSuggestionsFromFacade, - type EntityEditorNewDraftSeed -} from '@/lib/entity-editor/controller'; -import type { SignalRouteContext } from '@/lib/signal-route-context'; - -const ENTITY_NEW_SETTLED_CACHE_TTL_MS = 10_000; -const EMPTY_ENTITY_NEW_SEED: EntityEditorNewDraftSeed = { source: null, monitorId: null }; - -function trimmedEntityNewContextValue(value: string | null | undefined) { - const trimmed = value?.trim(); - return trimmed || undefined; -} - -export default function EntityNewPage({ initialSeed }: { initialSeed?: EntityEditorNewDraftSeed } = {}) { - const { t } = useI18n(); - const entityNewSeed = initialSeed ?? EMPTY_ENTITY_NEW_SEED; - const { - source, - monitorId, - monitorName, - monitorApp, - monitorInstance, - returnTo, - identityKey, - identityValue, - serviceName, - serviceNamespace, - environment - } = entityNewSeed; - const entityNewRouteContext = React.useMemo(() => { - const nextContext: SignalRouteContext = { - source: trimmedEntityNewContextValue(source), - monitorId: trimmedEntityNewContextValue(monitorId), - monitorName: trimmedEntityNewContextValue(monitorName), - monitorApp: trimmedEntityNewContextValue(monitorApp), - monitorInstance: trimmedEntityNewContextValue(monitorInstance), - returnTo: trimmedEntityNewContextValue(returnTo), - serviceName: trimmedEntityNewContextValue(serviceName), - serviceNamespace: trimmedEntityNewContextValue(serviceNamespace), - environment: trimmedEntityNewContextValue(environment) - }; - if (!Object.values(nextContext).some(Boolean)) { - return undefined; - } - return nextContext; - }, [environment, monitorApp, monitorId, monitorInstance, monitorName, returnTo, serviceName, serviceNamespace, source]); - const entityNewCatalogUrl = React.useMemo(() => buildEntityEditorCatalogSuggestionsUrl(), []); - const entityNewSeedUrl = React.useMemo(() => { - if ((source === 'telemetry' || source === 'discovery-candidate') && monitorId) { - return buildEntityEditorSeedMonitorUrl(monitorId); - } - if (source === 'otlp-candidate' && identityKey && identityValue) { - return [ - 'otlp-candidate', - identityKey, - identityValue, - serviceName ?? 'none', - serviceNamespace ?? 'none', - environment ?? 'none' - ].join(':'); - } - return ['manual', source ?? 'none', monitorId ?? 'none'].join(':'); - }, [environment, identityKey, identityValue, monitorId, serviceName, serviceNamespace, source]); - const entityNewCacheKey = React.useMemo( - () => ['entity-new', entityNewSeedUrl, entityNewCatalogUrl].join(':'), - [entityNewCatalogUrl, entityNewSeedUrl] - ); - - const load = useCallback(async () => { - const [initial, catalogSuggestions] = await Promise.all([ - buildEntityEditorNewDraftFromFacade(api.monitors.detail, entityNewSeed), - loadEntityEditorCatalogSuggestionsFromFacade(api.entities.catalogSuggestions) - ]); - return { initial, catalogSuggestions }; - }, [entityNewSeed]); - - return ( - - {data => ( - - )} - - ); -} diff --git a/web-next/app/entities/new/page.test.tsx b/web-next/app/entities/new/page.test.tsx deleted file mode 100644 index afc6f38d7e..0000000000 --- a/web-next/app/entities/new/page.test.tsx +++ /dev/null @@ -1,247 +0,0 @@ -import React from 'react'; -import { renderToStaticMarkup } from 'react-dom/server'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { createTranslatorMock } from '../../../test/i18n-test-helper'; - -const mockState = vi.hoisted(() => ({ - lastLoad: null as null | (() => Promise), - renderData: { - initial: { - entity: { type: 'service', name: 'checkout-api' }, - identities: [], - monitorBinds: [], - relations: [] - }, - catalogSuggestions: { - owners: ['platform'] - } - } -})); - -const readSeedMonitor = vi.hoisted(() => vi.fn(async () => ({ id: 42, app: 'website', name: 'checkout', instance: 'example.com' }))); -const readEntityCatalogSuggestions = vi.hoisted(() => vi.fn(async () => mockState.renderData.catalogSuggestions)); -const buildEntityEditorNewDraftFromFacade = vi.hoisted(() => vi.fn(async () => mockState.renderData.initial)); -const loadEntityEditorCatalogSuggestionsFromFacade = vi.hoisted(() => - vi.fn(async (readCatalogSuggestions: () => Promise) => readCatalogSuggestions()) -); - -vi.mock('@/components/providers/i18n-provider', () => ({ - useI18n: () => ({ - t: createTranslatorMock() - }) -})); - -vi.mock('@/components/workbench/client-workbench', () => ({ - ClientWorkbench: ({ - children, - load, - cacheKey, - cacheSettledTtlMs, - loadingCopy - }: { - children: (data: any) => React.ReactNode; - load: () => Promise; - cacheKey?: string; - cacheSettledTtlMs?: number; - loadingCopy?: string; - }) => { - mockState.lastLoad = load; - return ( -
- {children(mockState.renderData)} -
- ); - } -})); - -vi.mock('@/components/pages/entity-editor-surface', () => ({ - EntityEditorSurface: ({ mode, entityId, initial, routeContext }: any) => ( -
- {initial.entity.name} -
- ) -})); - -vi.mock('@/lib/api-facade', () => ({ - api: { - monitors: { - detail: readSeedMonitor - }, - entities: { - catalogSuggestions: readEntityCatalogSuggestions - } - } -})); - -vi.mock('@/lib/entity-editor/controller', () => ({ - buildEntityEditorCatalogSuggestionsUrl: () => '/entities/catalog-suggestions?limit=120', - buildEntityEditorNewDraftFromFacade, - buildEntityEditorSeedMonitorUrl: (monitorId: string) => `/monitor/${monitorId}`, - loadEntityEditorCatalogSuggestionsFromFacade -})); - -describe('EntityNewPage', () => { - beforeEach(() => { - mockState.lastLoad = null; - readSeedMonitor.mockClear().mockResolvedValue({ id: 42, app: 'website', name: 'checkout', instance: 'example.com' }); - readEntityCatalogSuggestions.mockClear().mockResolvedValue(mockState.renderData.catalogSuggestions); - buildEntityEditorNewDraftFromFacade.mockClear().mockResolvedValue(mockState.renderData.initial); - loadEntityEditorCatalogSuggestionsFromFacade.mockClear().mockImplementation(async readCatalogSuggestions => readCatalogSuggestions()); - }); - - it('loads catalog suggestions and renders the shared editor surface in create mode', async () => { - const { default: EntityNewPage } = await import('./entity-new-page'); - const html = renderToStaticMarkup(); - - expect(html).toContain('data-entity-editor-surface="new"'); - expect(html).toContain('data-cache-key="entity-new:manual:none:none:/entities/catalog-suggestions?limit=120"'); - expect(html).toContain('data-cache-settled-ttl="10000"'); - expect(html).toContain('data-loading-copy="Loading entity draft"'); - expect(html).toContain('checkout-api'); - - await mockState.lastLoad?.(); - - expect(buildEntityEditorNewDraftFromFacade).toHaveBeenCalledWith(readSeedMonitor, { - source: null, - monitorId: null - }); - expect(loadEntityEditorCatalogSuggestionsFromFacade).toHaveBeenCalledWith(readEntityCatalogSuggestions); - expect(readEntityCatalogSuggestions).toHaveBeenCalled(); - }); - - it('falls back to an empty catalog suggestion payload when the shared catalog endpoint is missing', async () => { - loadEntityEditorCatalogSuggestionsFromFacade.mockResolvedValueOnce({ - owners: [], - namespaces: [], - environments: [], - systems: [], - lifecycles: [], - tiers: [], - inheritFromRefs: [], - entityRefs: [], - languages: [], - linkProviders: [] - }); - const { default: EntityNewPage } = await import('./entity-new-page'); - renderToStaticMarkup(); - - await expect(mockState.lastLoad?.()).resolves.toEqual({ - initial: mockState.renderData.initial, - catalogSuggestions: { - owners: [], - namespaces: [], - environments: [], - systems: [], - lifecycles: [], - tiers: [], - inheritFromRefs: [], - entityRefs: [], - languages: [], - linkProviders: [] - } - }); - }); - - it('passes telemetry handoff query state into the shared new-draft loader', async () => { - const { default: EntityNewPage } = await import('./entity-new-page'); - const initialSeed = { source: 'telemetry', monitorId: '42' }; - const html = renderToStaticMarkup(); - - expect(html).toContain('data-cache-key="entity-new:/monitor/42:/entities/catalog-suggestions?limit=120"'); - expect(html).toContain('data-route-source="telemetry"'); - expect(html).toContain('data-route-monitor-id="42"'); - - await mockState.lastLoad?.(); - - expect(buildEntityEditorNewDraftFromFacade).toHaveBeenCalledWith(readSeedMonitor, { - source: 'telemetry', - monitorId: '42' - }); - }); - - it('passes discovery candidate monitor context into the shared new-draft loader', async () => { - const { default: EntityNewPage } = await import('./entity-new-page'); - const initialSeed = { - source: 'discovery-candidate', - monitorId: '42', - monitorName: 'checkout-discovery-monitor', - monitorApp: 'website', - monitorInstance: 'checkout.example.com:443', - returnTo: '/entities/discovery?search=checkout' - }; - const html = renderToStaticMarkup(); - - expect(html).toContain('data-cache-key="entity-new:/monitor/42:/entities/catalog-suggestions?limit=120"'); - expect(html).toContain('data-route-source="discovery-candidate"'); - expect(html).toContain('data-route-monitor-id="42"'); - expect(html).toContain('data-route-monitor-name="checkout-discovery-monitor"'); - expect(html).toContain('data-route-monitor-app="website"'); - expect(html).toContain('data-route-monitor-instance="checkout.example.com:443"'); - expect(html).toContain('data-route-return-to="/entities/discovery?search=checkout"'); - - await mockState.lastLoad?.(); - - expect(buildEntityEditorNewDraftFromFacade).toHaveBeenCalledWith(readSeedMonitor, { - source: 'discovery-candidate', - monitorId: '42', - monitorName: 'checkout-discovery-monitor', - monitorApp: 'website', - monitorInstance: 'checkout.example.com:443', - returnTo: '/entities/discovery?search=checkout' - }); - }); - - it('passes OTLP candidate query state into the shared new-draft loader and cache key', async () => { - const { default: EntityNewPage } = await import('./entity-new-page'); - const initialSeed = { - source: 'otlp-candidate', - monitorId: null, - identityKey: 'service.name', - identityValue: 'billing', - serviceName: 'billing-api', - serviceNamespace: 'commerce', - environment: 'prod', - returnTo: '/trace/manage?traceId=trace-1227' - }; - const html = renderToStaticMarkup(); - - expect(html).toContain( - 'data-cache-key="entity-new:otlp-candidate:service.name:billing:billing-api:commerce:prod:/entities/catalog-suggestions?limit=120"' - ); - expect(html).toContain('data-route-source="otlp-candidate"'); - expect(html).toContain('data-route-return-to="/trace/manage?traceId=trace-1227"'); - expect(html).toContain('data-route-service-name="billing-api"'); - expect(html).toContain('data-route-service-namespace="commerce"'); - expect(html).toContain('data-route-environment="prod"'); - - await mockState.lastLoad?.(); - - expect(buildEntityEditorNewDraftFromFacade).toHaveBeenCalledWith(readSeedMonitor, { - source: 'otlp-candidate', - monitorId: null, - identityKey: 'service.name', - identityValue: 'billing', - serviceName: 'billing-api', - serviceNamespace: 'commerce', - environment: 'prod', - returnTo: '/trace/manage?traceId=trace-1227' - }); - }); -}); diff --git a/web-next/app/entities/new/page.tsx b/web-next/app/entities/new/page.tsx deleted file mode 100644 index a6972c56c8..0000000000 --- a/web-next/app/entities/new/page.tsx +++ /dev/null @@ -1,14 +0,0 @@ -import React from 'react'; - -import EntityNewPage from './entity-new-page'; -import { readEntityNewDraftSeed, type EntityNewSearchParams } from '../../../lib/entity-editor/query-state'; - -export default async function EntityNewRoutePage({ - searchParams -}: { - searchParams?: Promise; -}) { - const resolvedSearchParams = await searchParams; - const initialSeed = readEntityNewDraftSeed(resolvedSearchParams); - return ; -} diff --git a/web-next/app/entities/not-found.test.ts b/web-next/app/entities/not-found.test.ts deleted file mode 100644 index ef67192beb..0000000000 --- a/web-next/app/entities/not-found.test.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { describe, expect, it, vi } from 'vitest'; - -const redirect = vi.fn(); - -vi.mock('next/navigation', () => ({ - redirect -})); - -describe('entities not-found boundary', () => { - it('redirects unknown nested entity paths back to the entities list', async () => { - redirect.mockImplementation((target: string) => { - throw new Error(`redirect:${target}`); - }); - - const { default: EntitiesNotFound } = await import('./not-found'); - - expect(() => EntitiesNotFound()).toThrow('redirect:/entities'); - expect(redirect).toHaveBeenCalledWith('/entities'); - }); -}); diff --git a/web-next/app/entities/not-found.tsx b/web-next/app/entities/not-found.tsx deleted file mode 100644 index 50075b99a7..0000000000 --- a/web-next/app/entities/not-found.tsx +++ /dev/null @@ -1,6 +0,0 @@ -import { redirect } from 'next/navigation'; -import { buildEntityListCompatRouteUrl } from '../../lib/entity-manage/query-state'; - -export default function EntitiesNotFound() { - redirect(buildEntityListCompatRouteUrl()); -} diff --git a/web-next/app/entities/page.test.tsx b/web-next/app/entities/page.test.tsx deleted file mode 100644 index 8de0841b65..0000000000 --- a/web-next/app/entities/page.test.tsx +++ /dev/null @@ -1,782 +0,0 @@ -// @vitest-environment jsdom - -import { readFileSync } from 'node:fs'; -import { resolve } from 'node:path'; -import React, { act } from 'react'; -import { createRoot, type Root } from 'react-dom/client'; -import { renderToStaticMarkup } from 'react-dom/server'; -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import EntityListPage from './entity-list-page'; -import { createTranslatorMock } from '../../test/i18n-test-helper'; -import { buildEntityTableRows } from '@/lib/entity-manage/view-model'; - -(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; - -let interactionContainer: HTMLDivElement | null = null; -let interactionRoot: Root | null = null; - -const mockState = vi.hoisted(() => ({ - lastLoad: null as null | (() => Promise), - replace: vi.fn(), - renderData: { - list: { - content: [], - totalElements: 0 - } - } -})); - -const loadEntityList = vi.hoisted(() => vi.fn(async () => mockState.renderData.list)); - -vi.mock('next/link', () => ({ - default: ({ href, children, ...props }: any) => ( - - {children} - - ) -})); - -vi.mock('next/navigation', () => ({ - useRouter: () => ({ - replace: mockState.replace - }) -})); - -vi.mock('@/components/providers/i18n-provider', () => ({ - useI18n: () => ({ - t: createTranslatorMock({ locale: 'zh-CN' }) - }) -})); - -vi.mock('@/components/workbench/client-workbench', () => ({ - ClientWorkbench: ({ - children, - load, - loadingTitle, - loadingCopy, - loadingDelayMs - }: { - children: (data: any) => React.ReactNode; - load: () => Promise; - loadingTitle?: string; - loadingCopy?: string; - loadingDelayMs?: number; - }) => { - mockState.lastLoad = load; - return ( -
- {children(mockState.renderData)} -
- ); - } -})); - -vi.mock('@/components/observability', () => ({ - DrawerCodePreview: ({ children }: any) =>
{children}
, - DrawerSection: ({ title, children }: any) => ( -
-

{title}

- {children} -
- ), - ObservabilityStatusState: ({ title, copy }: any) => ( -
- {title} - {copy} -
- ), - StageSection: ({ title, children }: any) => ( -
-

{title}

- {children} -
- ), - SelectableEvidenceList: ({ rows }: any) =>
{rows.map((row: any) => row.title).join('|')}
, - SummaryMetricGrid: ({ items }: any) =>
{items.map((item: any) => item.label).join('|')}
, - ToolbarField: ({ label, children }: any) => ( - - ), - ToolbarRow: ({ children }: any) =>
{children}
-})); - -vi.mock('@/components/workbench/primitives', () => ({ - WorkbenchStack: ({ children }: any) =>
{children}
-})); - -vi.mock('@/components/workbench/workbench-page', () => ({ - RowList: ({ rows }: any) =>
{rows.map((row: any) => row.title).join('|')}
, - WorkbenchPage: ({ title, subtitle, actions, main, side }: any) => ( -
-

{title}

-

{subtitle}

-
{actions}
-
{main}
- -
- ) -})); - -vi.mock('@/components/ui/button', () => ({ - Button: ({ children, ...props }: any) => -})); - -vi.mock('@/components/ui/input', () => ({ - Input: (props: any) => -})); - -vi.mock('@/lib/api-client', () => ({ -})); - -vi.mock('@/lib/api-facade', () => ({ - api: { - entities: { - list: vi.fn() - } - } -})); - -vi.mock('@/lib/entity-manage/controller', async importOriginal => { - const actual = await importOriginal(); - return { - ...actual, - loadEntityListFromFacade: loadEntityList - }; -}); - -vi.mock('@/lib/entity-manage/display-mapping', () => ({ - entityEnvironmentLabel: (value: string) => value, - entityStatusLabel: (value: string) => value, - entityTypeLabel: (value: string) => value -})); - -vi.mock('@/lib/entity-manage/query-state', () => ({ - buildEntityListRouteUrl: (query: Record) => { - const queryString = new URLSearchParams( - Object.entries(query).filter(([, value]) => value) - ).toString(); - return queryString ? `/entities?${queryString}` : '/entities'; - }, - buildEntityUrl: vi.fn(), - normalizeEntityListPageIndex: (value?: string | number | null) => { - const parsed = Number.parseInt(String(value ?? ''), 10); - return String(Number.isFinite(parsed) && parsed > 0 ? parsed : 0); - }, - normalizeEntityListPageSize: (value?: string | number | null) => { - const parsed = Number.parseInt(String(value ?? ''), 10); - return ['8', '20', '50'].includes(String(parsed)) ? String(parsed) : '8'; - }, - isSupportedEntityListPageSize: (value?: string | number | null) => { - const parsed = Number.parseInt(String(value ?? ''), 10); - return ['8', '20', '50'].includes(String(parsed)); - }, - queryStateToQueryString: (query: Record) => - new URLSearchParams( - Object.entries(query).filter(([, value]) => value) - ).toString() -})); - -vi.mock('@/lib/entity-manage/view-model', () => ({ - isEntityHealthyStatus: (status: string | null | undefined) => - ['healthy', 'up', 'normal'].includes(String(status || '').toLowerCase().replace(/[\s-]+/g, '_')), - isEntityPendingEvidenceStatus: (status: string | null | undefined) => { - const normalized = String(status || '').toLowerCase().replace(/[\s-]+/g, '_'); - return !normalized || normalized === 'unknown' || normalized === 'paused'; - }, - isEntityAbnormalStatus: (status: string | null | undefined) => - ['abnormal', 'critical', 'degraded', 'down', 'offline', 'unhealthy', 'warning'].includes(String(status || '').toLowerCase().replace(/[\s-]+/g, '_')), - buildEntityTableRows: vi.fn(() => { - const t = createTranslatorMock({ locale: 'zh-CN' }); - return [ - { - key: '1', - name: 'checkout-service', - type: t('entities.list.type.service'), - environment: t('entities.list.environment.local'), - status: 'healthy', - monitorCount: '1', - activeAlertCount: '0', - relationCount: '2', - updatedAt: 'now', - href: '/entities/1', - ownerHref: '/entities/1/edit', - metricHref: '/ingestion/otlp/metrics?entityId=1', - logHref: '/log/manage?entityId=1', - traceHref: '/trace/manage?entityId=1' - } - ]; - }) -})); - -vi.mock('@/lib/format', () => ({ - formatTime: vi.fn(() => 'now') -})); - -describe('EntityListPage', () => { - beforeEach(() => { - mockState.lastLoad = null; - mockState.replace.mockClear(); - loadEntityList.mockClear().mockResolvedValue(mockState.renderData.list); - }); - - afterEach(() => { - if (interactionRoot && interactionContainer) { - act(() => { - interactionRoot?.unmount(); - }); - interactionRoot = null; - interactionContainer.remove(); - interactionContainer = null; - } - }); - - it('keeps entity catalog remounts on a short settled cache window while refresh invalidates that cache', () => { - const source = readFileSync(resolve(process.cwd(), 'app/entities/entity-list-page.tsx'), 'utf8'); - - expect(source).toContain('ENTITY_LIST_SETTLED_CACHE_TTL_MS = 10_000'); - expect(source).toContain("['entity-list', entityListUrl, refreshNonce].join(':')"); - expect(source).toContain('setRefreshNonce(current => current + 1)'); - expect(source).toContain('onRefresh={refreshQuery}'); - expect(source).toContain('cacheSettledTtlMs={ENTITY_LIST_SETTLED_CACHE_TTL_MS}'); - }); - - it('loads the entity list through the shared controller contract', async () => { - const expectedT = createTranslatorMock({ locale: 'zh-CN' }); - const source = readFileSync(resolve(process.cwd(), 'app/entities/entity-list-page.tsx'), 'utf8'); - const initialQuery = { search: 'checkout', type: 'service', status: 'healthy', pageIndex: '2', pageSize: '20' }; - const html = renderToStaticMarkup(); - - expect(html).toContain(`data-loading-title="${expectedT('entities.list.loading.title')}"`); - expect(html).toContain(`data-loading-copy="${expectedT('entities.list.loading.copy')}"`); - expect(html).toContain('data-loading-delay-ms="150"'); - expect(html).toContain('data-entity-list-surface="otlp-hertzbeat-ui-entity-console"'); - expect(html).toContain('data-entity-list-style-baseline="hertzbeat-ui-matte"'); - expect(html).toContain('data-entity-list-header="hertzbeat-ui-compact-header"'); - expect(html).toContain('data-entity-list-header-nesting-contract="flat-page-introduction"'); - expect(html).toContain('data-entity-list-command-row="standard-equal-buttons"'); - expect(html).toContain('data-entity-list-admin-layout="full-width-admin-list"'); - expect(html).toContain('data-entity-list-count-strip="hertzbeat-ui-inline-counts"'); - expect(html).toContain('data-entity-list-toolbar="hertzbeat-ui-table-toolbar"'); - expect(html).toContain('data-hz-search-row-owner="hertzbeat-ui-search-row"'); - expect(html).toContain('data-hz-search-input="fixed-width-direct"'); - expect(html).toContain('data-hz-search-control="direct-input"'); - expect(html).toContain('data-hz-search-chrome="no-extra-input-shell"'); - expect(html).toContain('data-hz-search-enter-submit="direct-input"'); - expect(html).toContain('data-hz-search-action="submit"'); - expect(html).toContain('data-entity-list-command-action="refresh"'); - expect(html).toContain('data-entity-list-command-action="clear-filters"'); - expect(html).toContain('data-entity-list-command-action="create"'); - expect(html).toContain('data-entity-list-command-action="discovery"'); - expect(html).toContain('data-entity-list-command-action="import"'); - expect(html).toContain('data-entity-list-refresh-action="search-row-secondary"'); - expect(html).toContain('data-entity-list-clear-action="search-row-secondary"'); - expect(html).toContain('data-entity-list-table-shell="hertzbeat-ui-dense-table"'); - expect(html).toContain('data-entity-list-table="hertzbeat-ui-entity-table"'); - expect(html).toContain('data-entity-list-pagination-owner="hertzbeat-ui-pagination-bar"'); - expect(html).toContain('data-hz-pagination-page-size="select-menu"'); - expect(html).toContain('data-hz-pagination-page-jump="number-input"'); - expect(html).toContain('data-entity-list-row-actions="hertzbeat-ui-inline-actions"'); - expect(html).toContain('data-entity-list-action-help-trigger="hertzbeat-ui-action-help"'); - expect(html).toContain('data-entity-list-action-help-style="icon-after-action"'); - expect(html).toContain('data-entity-list-action-help-visual="circle-help-icon"'); - expect(html).toContain('data-entity-list-action-help-icon="lucide-circle-help"'); - expect(html).toContain('data-entity-list-action-help="create"'); - expect(html).toContain('data-entity-list-action-help="row-actions"'); - expect(html).toContain('data-entity-list-row-action-help-contract="single-header-help"'); - expect(html).not.toContain('data-entity-list-action-help="row-owner"'); - expect(html).not.toContain('data-entity-list-action-help="row-metrics"'); - expect(html).not.toContain('data-entity-list-action-help="row-logs"'); - expect(html).not.toContain('data-entity-list-action-help="row-traces"'); - expect((html.match(/data-entity-list-action-help-trigger="hertzbeat-ui-action-help"/g) || []).length).toBe(7); - expect((html.match(/data-entity-list-action-help-style="icon-after-action"/g) || []).length).toBe(7); - expect((html.match(/data-entity-list-action-help-visual="circle-help-icon"/g) || []).length).toBe(7); - expect((html.match(/data-entity-list-action-help-icon="lucide-circle-help"/g) || []).length).toBe(7); - expect(html).not.toContain(''); - expect(html).toContain(expectedT('entities.list.action-help.create.body')); - expect(html).toContain(expectedT('entities.list.action-help.row-metrics.body')); - expect(html).toContain(expectedT('entities.list.action-help.row-logs.body')); - expect(html).toContain(expectedT('entities.list.kicker')); - expect(html).toContain(expectedT('entities.list.title')); - expect(html).toContain(expectedT('entities.list.subtitle')); - expect(html).toContain(expectedT('entities.list.metric.total')); - expect(html).toContain(expectedT('entities.list.metric.pending-evidence')); - expect(html).not.toContain(expectedT('entities.list.metric.abnormal')); - expect(html).toContain(expectedT('entities.list.search.placeholder')); - expect(html).toContain(expectedT('common.search')); - expect(html).toContain(expectedT('common.refresh')); - expect(html).toContain(expectedT('entities.list.action.create')); - expect(html).toContain(expectedT('entities.list.action.discovery')); - expect(html).toContain(expectedT('entities.list.action.import')); - expect(html).toContain(expectedT('entities.list.column.object')); - expect(html).toContain(expectedT('entities.list.column.owner')); - expect(html).toContain(expectedT('entities.list.column.progress')); - expect(html).toContain(expectedT('entities.list.column.evidence')); - expect(html).toContain(expectedT('entities.list.column.next-action')); - expect(html).toContain(expectedT('entities.list.column.status')); - expect(html).toContain(expectedT('entities.list.row.evidence.alerts', { count: 0 })); - expect(html).toContain(expectedT('entities.list.row.evidence.monitors', { count: 1 })); - expect(html).toContain(expectedT('entities.list.row.action.owner')); - expect(html).toContain(expectedT('entities.list.row.action.metrics')); - expect(html).toContain(expectedT('entities.list.row.action.logs')); - expect(html).toContain('data-entity-list-command-action="open-detail"'); - expect(html).toContain('data-entity-list-command-action="edit-owner"'); - expect(html).toContain('data-entity-list-command-action="open-metrics"'); - expect(html).toContain('data-entity-list-command-action="open-logs"'); - expect(html).toContain('data-entity-list-command-action="open-traces"'); - expect(html).toContain('checkout-service'); - expect(html).toContain('href="/entities/1"'); - expect(html).toContain('href="/entities/1/edit"'); - expect(html).not.toContain(expectedT('entities.editor.attribution.owner.missing-meta')); - expect(html).not.toContain(`${expectedT('entities.list.environment.select')} · ${expectedT('entities.list.environment.all')}`); - expect(html).not.toContain('data-cold-search-input-shell'); - expect(html).not.toContain('data-entity-list-rail='); - expect(html).not.toContain('data-entity-list-action-panel='); - expect(html).not.toContain('signoz-services-table'); - expect(html).not.toContain('signoz-services-rail'); - expect(html).not.toContain('angular-sidebar-flush'); - expect(source).toContain("components/pages/entity-list-surface"); - expect(source).not.toContain("from '@/components/observability'"); - expect(source).not.toContain('WorkbenchPage'); - expect(source).not.toContain('SummaryMetricGrid'); - expect(source).not.toContain('StageSection'); - expect(source).not.toContain('DrawerSection'); - expect(source).not.toContain('DrawerCodePreview'); - expect(source).not.toContain('SelectableEvidenceList'); - expect(source).not.toContain("from '@/components/workbench/primitives'"); - expect(source).not.toContain("from '@/components/workbench/toolbar'"); - expect(source).not.toContain('signoz-services-table'); - expect(source).not.toContain('signoz-services-rail'); - expect(source).not.toContain('angular-sidebar-flush'); - expect(source).toContain("import { api } from '@/lib/api-facade';"); - expect(source).toContain('const list = await loadEntityListFromFacade(api.entities.list, query);'); - expect(source).toContain('contentTrim?: EntityListPageTrim'); - expect(source).toContain('const controllerPayloadTrim ='); - expect(source).toContain('const payloadTrim = controllerPayloadTrim ??'); - expect(source).not.toContain('const list = await loadEntityList(apiMessageGet, query);'); - - await mockState.lastLoad?.(); - - expect(loadEntityList).toHaveBeenCalledWith(expect.any(Function), { - search: 'checkout', - type: 'service', - status: 'healthy', - pageIndex: '2', - pageSize: '20' - }); - }); - - it('passes the current entity list route as row navigation return context', () => { - const source = readFileSync(resolve(process.cwd(), 'app/entities/entity-list-page.tsx'), 'utf8'); - - expect(source).toContain("import { useRouter } from 'next/navigation';"); - expect(source).toContain('const router = useRouter();'); - expect(source).toContain('key={entityListCacheKey}'); - expect(source).toContain("loadingTitle={t('entities.list.loading.title')}"); - expect(source).toContain("loadingCopy={t('entities.list.loading.copy')}"); - expect(source).toContain('loadingDelayMs={150}'); - expect(source).toContain('router.replace(buildEntityListRouteUrl(normalizedQuery), { scroll: false });'); - expect(source).toContain('router.replace(buildEntityListRouteUrl(query), { scroll: false });'); - expect(source).toContain("const nextQuery = clearEntityListTransientFeedback({ ...draft, pageIndex: '0', pageSize: normalizeEntityListPageSize(pageSize) });"); - expect(source).toContain('function clearEntityListTransientFeedback'); - expect(source).toContain("return { ...query, deleteResult: '', deletedEntity: '' };"); - expect(source).toContain('effectiveEntityListRouteUrl'); - expect(source).toContain('returnTo: effectiveEntityListRouteUrl'); - expect(source).toContain('source: effectiveQuery.source'); - expect(source).toContain('monitorId: effectiveQuery.monitorId'); - expect(source).toContain('monitorName: effectiveQuery.monitorName'); - expect(source).toContain('monitorApp: effectiveQuery.monitorApp'); - expect(source).toContain('monitorInstance: effectiveQuery.monitorInstance'); - expect(source).toContain('onPageIndexChange={changePageIndex}'); - expect(source).toContain('onPageSizeChange={changePageSize}'); - }); - - it('loads the last available page when a large entity catalog URL requests an out-of-range page', async () => { - loadEntityList - .mockResolvedValueOnce({ - content: [], - totalElements: 1993, - pageIndex: 999, - pageSize: 50 - }) - .mockResolvedValueOnce({ - content: [ - { - entity: { - id: 1993, - name: 'hb-mix-last-page', - type: 'service', - environment: 'prod', - status: 'unknown' - }, - identityCount: 1, - monitorCount: 0, - activeAlertCount: 0, - relationCount: 0 - } - ], - totalElements: 1993, - pageIndex: 39, - pageSize: 50 - }); - - renderToStaticMarkup( - - ); - - const result = await mockState.lastLoad?.(); - - expect(loadEntityList).toHaveBeenNthCalledWith(1, expect.any(Function), { - search: 'hb-mix', - type: '', - status: '', - pageIndex: '999', - source: 'product-design-1475', - pageSize: '50' - }); - expect(loadEntityList).toHaveBeenNthCalledWith(2, expect.any(Function), { - search: 'hb-mix', - type: '', - status: '', - pageIndex: '39', - source: 'product-design-1475', - pageSize: '50' - }); - expect(result).toEqual({ - list: expect.objectContaining({ - pageIndex: 39, - totalElements: 1993 - }), - pageOutOfRange: { - requestedPage: 1000, - displayedPage: 40, - totalPages: 40 - } - }); - }); - - it('normalizes unsupported page-size URLs before loading the entity catalog and explains the adjustment', async () => { - const expectedT = createTranslatorMock({ locale: 'zh-CN' }); - mockState.renderData = { - list: { - content: [ - { - entity: { - id: 646566130001493, - name: 'hb-mix-page-size-adjusted', - type: 'service', - environment: 'prod', - status: 'unknown' - }, - identityCount: 1, - monitorCount: 0, - activeAlertCount: 0, - relationCount: 0 - } - ], - totalElements: 1993, - pageIndex: 0, - pageSize: 8 - } - }; - - const html = renderToStaticMarkup( - - ); - await mockState.lastLoad?.(); - - expect(loadEntityList).toHaveBeenCalledWith(expect.any(Function), { - search: 'hb-mix', - type: '', - status: '', - pageIndex: '0', - source: 'product-design-1493', - pageSize: '8' - }); - expect(html).toContain('data-entity-list-page-size-adjusted="unsupported-page-size"'); - expect(html).toContain(expectedT('entities.list.pagination.page-size-adjusted.title', { requested: '100', applied: '8' })); - expect(html).toContain(expectedT('entities.list.pagination.page-size-adjusted.description')); - expect(html).toContain(expectedT('entities.list.pagination.summary', { page: 1, totalPages: 250, from: 1, to: 1, total: 1993 })); - }); - - it('caps oversized backend entity payloads to the selected page size before rendering rows', () => { - const expectedT = createTranslatorMock({ locale: 'zh-CN' }); - const oversizedContent = Array.from({ length: 75 }, (_, index) => ({ - entity: { - id: 7000 + index, - name: `hb-scale-${index}`, - type: 'service', - environment: 'prod', - status: index % 2 === 0 ? 'unknown' : 'healthy' - }, - identityCount: 1, - monitorCount: 0, - activeAlertCount: 0, - relationCount: 0 - })); - vi.mocked(buildEntityTableRows).mockImplementationOnce((items: any[]) => - items.map((item, index) => ({ - key: String(item.entity.id), - name: item.entity.name, - type: item.entity.type, - environment: item.entity.environment, - status: item.entity.status, - statusTone: 'neutral', - monitorCount: '0', - activeAlertCount: '0', - identityCount: '1', - relationCount: '0', - owner: 'platform', - updatedAt: 'now', - href: `/entities/${item.entity.id}`, - ownerHref: `/entities/${item.entity.id}/edit`, - metricHref: `/ingestion/otlp/metrics?entityId=${item.entity.id}`, - logHref: `/log/manage?entityId=${item.entity.id}`, - traceHref: `/trace/manage?entityId=${item.entity.id}`, - ...(index === 0 ? { identityName: 'first-rendered' } : {}) - })) - ); - mockState.renderData = { - list: { - content: oversizedContent, - totalElements: 1993, - pageIndex: 0, - pageSize: 50 - } - }; - - const html = renderToStaticMarkup( - - ); - - expect(buildEntityTableRows).toHaveBeenCalledWith( - expect.arrayContaining([expect.objectContaining({ entity: expect.objectContaining({ name: 'hb-scale-0' }) })]), - expect.any(Function), - expect.any(Function), - expect.any(Function), - expect.any(Function), - expect.any(Function), - expect.objectContaining({ - source: 'product-design-1679', - returnTo: '/entities?search=hb-scale&pageIndex=0&source=product-design-1679&pageSize=50' - }) - ); - expect(vi.mocked(buildEntityTableRows).mock.calls.at(-1)?.[0]).toHaveLength(50); - expect(html).toContain('data-entity-list-payload-trimmed="page-size-guard"'); - expect(html).toContain('data-entity-list-payload-trimmed-owner="hertzbeat-ui-inline-feedback"'); - expect(html).toContain(expectedT('entities.list.pagination.payload-trimmed.title', { received: 75, rendered: 50 })); - expect(html).toContain(expectedT('entities.list.pagination.payload-trimmed.description')); - expect(html).toContain(expectedT('entities.list.table.range', { from: 1, to: 50, total: 1993 })); - expect(html).toContain(expectedT('entities.list.pagination.summary', { page: 1, totalPages: 40, from: 1, to: 50, total: 1993 })); - expect(html).toContain('hb-scale-49'); - expect(html).not.toContain('hb-scale-50'); - expect(html).not.toContain('hb-scale-74'); - }); - - it('keeps a 5000-entity catalog page bounded to the current page payload', () => { - const expectedT = createTranslatorMock({ locale: 'zh-CN' }); - const currentPageContent = Array.from({ length: 50 }, (_, index) => ({ - entity: { - id: 9000 + index, - name: `hb-5k-${index}`, - type: 'service', - environment: 'prod', - status: index === 0 ? 'warning' : 'unknown' - }, - identityCount: index % 3, - monitorCount: index % 5, - activeAlertCount: index === 0 ? 2 : 0, - relationCount: index % 7 - })); - vi.mocked(buildEntityTableRows).mockImplementationOnce((items: any[]) => - items.map(item => ({ - key: String(item.entity.id), - name: item.entity.name, - type: item.entity.type, - environment: item.entity.environment, - status: item.entity.status, - statusTone: item.entity.status === 'warning' ? 'warning' : 'neutral', - monitorCount: String(item.monitorCount || 0), - activeAlertCount: String(item.activeAlertCount || 0), - identityCount: String(item.identityCount || 0), - relationCount: String(item.relationCount || 0), - owner: 'platform', - updatedAt: 'now', - href: `/entities/${item.entity.id}`, - ownerHref: `/entities/${item.entity.id}/edit`, - metricHref: `/ingestion/otlp/metrics?entityId=${item.entity.id}`, - logHref: `/log/manage?entityId=${item.entity.id}`, - traceHref: `/trace/manage?entityId=${item.entity.id}` - })) - ); - mockState.renderData = { - list: { - content: currentPageContent, - totalElements: 5000, - pageIndex: 2, - pageSize: 50 - } - }; - - const html = renderToStaticMarkup( - - ); - const renderedItems = vi.mocked(buildEntityTableRows).mock.calls.at(-1)?.[0] as unknown[] | undefined; - - expect(renderedItems).toHaveLength(50); - expect(renderedItems?.[0]).toEqual(expect.objectContaining({ entity: expect.objectContaining({ name: 'hb-5k-0' }) })); - expect(renderedItems?.at(-1)).toEqual(expect.objectContaining({ entity: expect.objectContaining({ name: 'hb-5k-49' }) })); - expect(html).toContain(expectedT('entities.list.table.range', { from: 101, to: 150, total: 5000 })); - expect(html).toContain(expectedT('entities.list.pagination.summary', { page: 3, totalPages: 100, from: 101, to: 150, total: 5000 })); - expect(html).toContain('data-entity-list-pagination="hertzbeat-ui-dense-pagination"'); - expect(html).toContain('hb-5k-49'); - expect(html).not.toContain('hb-5k-50'); - expect(html).not.toContain('data-entity-list-payload-trimmed="page-size-guard"'); - }); - - it('uses the clamped out-of-range page for visible pagination even when the response omits page metadata', () => { - const expectedT = createTranslatorMock({ locale: 'zh-CN' }); - mockState.renderData = { - list: { - content: [ - { - entity: { - id: 1993, - name: 'hb-mix-last-page', - type: 'service', - environment: 'prod', - status: 'unknown' - }, - identityCount: 1, - monitorCount: 0, - activeAlertCount: 0, - relationCount: 0 - } - ], - totalElements: 1993, - pageSize: 50 - }, - pageOutOfRange: { - requestedPage: 1000, - displayedPage: 40, - totalPages: 40 - } - } as typeof mockState.renderData; - - const html = renderToStaticMarkup( - - ); - - expect(html).toContain(expectedT('entities.list.table.range', { from: 1951, to: 1951, total: 1993 })); - expect(html).toContain(expectedT('entities.list.pagination.summary', { page: 40, totalPages: 40, from: 1951, to: 1951, total: 1993 })); - expect(html).not.toContain(expectedT('entities.list.table.range', { from: 49951, to: 1993, total: 1993 })); - expect(html).not.toContain(expectedT('entities.list.pagination.summary', { page: 1000, totalPages: 40, from: 49951, to: 1993, total: 1993 })); - }); - - it('submits the current Entity search input value through the shared SearchRow form', async () => { - mockState.renderData = { - list: { - content: [ - { - id: 646566130001992, - name: 'hb-mix-1780329856-svc-11-164', - type: 'service', - environment: 'prod', - status: 'warning', - gmtUpdate: 1713200000000 - } - ], - pageIndex: 2, - pageSize: 50, - totalElements: 1993 - } - }; - interactionContainer = document.createElement('div'); - document.body.appendChild(interactionContainer); - interactionRoot = createRoot(interactionContainer); - - await act(async () => { - interactionRoot?.render( - - ); - await Promise.resolve(); - }); - - const input = interactionContainer.querySelector('input[data-hz-search-input="fixed-width-direct"]') as HTMLInputElement | null; - const valueSetter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set; - expect(input).not.toBeNull(); - expect(input?.getAttribute('data-hz-search-enter-submit')).toBe('direct-input'); - - await act(async () => { - valueSetter?.call(input, 'hb-mix-1780329856-svc-11-164'); - input?.dispatchEvent(new Event('input', { bubbles: true })); - input?.dispatchEvent(new KeyboardEvent('keydown', { bubbles: true, cancelable: true, key: 'Enter' })); - await Promise.resolve(); - }); - - expect(mockState.replace).toHaveBeenCalledWith( - '/entities?search=hb-mix-1780329856-svc-11-164&type=service&pageIndex=0&source=product-design-1388&pageSize=50', - { scroll: false } - ); - }); -}); diff --git a/web-next/app/entities/page.tsx b/web-next/app/entities/page.tsx deleted file mode 100644 index ef02b8644c..0000000000 --- a/web-next/app/entities/page.tsx +++ /dev/null @@ -1,14 +0,0 @@ -import React from 'react'; - -import EntityListPage from './entity-list-page'; -import { readEntityListQueryState, type EntityListSearchParams } from '../../lib/entity-manage/query-state'; - -export default async function EntitiesRoutePage({ - searchParams -}: { - searchParams?: Promise; -}) { - const resolvedSearchParams = await searchParams; - const initialQuery = readEntityListQueryState(resolvedSearchParams); - return ; -} diff --git a/web-next/app/entity-detail-family.chrome.test.ts b/web-next/app/entity-detail-family.chrome.test.ts deleted file mode 100644 index 09638d94ed..0000000000 --- a/web-next/app/entity-detail-family.chrome.test.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { readFileSync } from 'node:fs'; -import { resolve } from 'node:path'; -import { describe, expect, it } from 'vitest'; - -describe('entity detail family cold-workbench chrome', () => { - it('removes the remaining legacy white-on-black chrome from the current entity slice', () => { - const detailSource = readFileSync(resolve(process.cwd(), 'app/entities/[entityId]/entity-detail-page.tsx'), 'utf8'); - const definitionSource = readFileSync(resolve(process.cwd(), 'components/pages/entity-definition-workspace-surface.tsx'), 'utf8'); - - expect(detailSource).not.toContain('text-white/55'); - - expect(definitionSource).not.toContain('border-white/10'); - expect(definitionSource).not.toContain('bg-white/[0.03]'); - expect(definitionSource).not.toContain('text-white'); - expect(definitionSource).not.toContain('text-white/62'); - expect(definitionSource).not.toContain('text-white/45'); - expect(definitionSource).not.toContain('text-white/40'); - expect(definitionSource).not.toContain('text-white/30'); - expect(definitionSource).not.toContain('rounded-[20px]'); - expect(definitionSource).not.toContain('rounded-[22px]'); - }); - - it('adopts shared ops tokens across the current entity slice', () => { - const detailSource = readFileSync(resolve(process.cwd(), 'app/entities/[entityId]/entity-detail-page.tsx'), 'utf8'); - const detailSurfaceSource = readFileSync(resolve(process.cwd(), 'components/pages/entity-detail-surface.tsx'), 'utf8'); - const definitionSource = readFileSync(resolve(process.cwd(), 'components/pages/entity-definition-workspace-surface.tsx'), 'utf8'); - - expect(detailSource).toContain("from '@/components/pages/entity-detail-surface'"); - expect(detailSource).not.toContain('StageSection'); - expect(detailSource).not.toContain('DrawerSection'); - expect(detailSource).not.toContain('ObservabilityStatusState'); - expect(detailSource).not.toContain('WorkbenchPage'); - expect(detailSource).not.toContain('components/workbench/primitives'); - expect(detailSurfaceSource).toContain('hzOpsCatalogVisual'); - expect(detailSurfaceSource).toContain('data-entity-detail-surface="otlp-hertzbeat-ui-entity-detail"'); - expect(detailSurfaceSource).toContain('data-entity-detail-layout="full-width-workbench"'); - expect(detailSurfaceSource).not.toContain('StageSection'); - expect(detailSurfaceSource).not.toContain('DrawerSection'); - expect(detailSurfaceSource).not.toContain('WorkbenchPage'); - - expect(definitionSource).toContain('hzOpsCatalogVisual'); - expect(definitionSource).toContain('data-entity-definition-layout="full-width-workbench"'); - expect(definitionSource).toContain('data-entity-definition-editor-shell="otlp-hertzbeat-ui-definition-workbench"'); - expect(definitionSource).toContain('WorkbenchInsetPanel'); - expect(definitionSource).not.toContain( - 'rounded-[6px] border border-[var(--ops-border-color)] bg-[var(--ops-surface-panel)] p-3.5' - ); - }); -}); diff --git a/web-next/app/entity-editor-family.chrome.test.ts b/web-next/app/entity-editor-family.chrome.test.ts deleted file mode 100644 index 2633707765..0000000000 --- a/web-next/app/entity-editor-family.chrome.test.ts +++ /dev/null @@ -1,116 +0,0 @@ -import { readFileSync } from 'node:fs'; -import { resolve } from 'node:path'; -import { describe, expect, it } from 'vitest'; - -describe('entity editor family cold-workbench chrome', () => { - it('removes the remaining legacy white-on-black chrome from the shared editor slice', () => { - const editorRowsSource = readFileSync(resolve(process.cwd(), 'components/observability/editor-rows.tsx'), 'utf8'); - const monitorEditorSource = readFileSync(resolve(process.cwd(), 'components/pages/monitor-editor-surface.tsx'), 'utf8'); - const entityEditorSource = readFileSync(resolve(process.cwd(), 'components/pages/entity-editor-surface.tsx'), 'utf8'); - const entitiesSource = readFileSync(resolve(process.cwd(), 'app/entities/entity-list-page.tsx'), 'utf8'); - - expect(editorRowsSource).not.toContain('border-white/10'); - expect(editorRowsSource).not.toContain('bg-black/20'); - expect(editorRowsSource).not.toContain('text-white/70'); - expect(editorRowsSource).not.toContain('border-white/8'); - expect(editorRowsSource).not.toContain('bg-white/[0.025]'); - - expect(monitorEditorSource).not.toContain('border-white/10'); - expect(monitorEditorSource).not.toContain('bg-black/20'); - expect(monitorEditorSource).not.toContain('text-white/80'); - expect(monitorEditorSource).not.toContain('text-white/60'); - expect(monitorEditorSource).not.toContain('text-white/30'); - expect(monitorEditorSource).not.toContain('focus:border-sky-400/35'); - - expect(entityEditorSource).not.toContain('border-white/10'); - expect(entityEditorSource).not.toContain('border-white/8'); - expect(entityEditorSource).not.toContain('bg-black/20'); - expect(entityEditorSource).not.toContain('bg-white/[0.025]'); - expect(entityEditorSource).not.toContain('text-white/72'); - expect(entityEditorSource).not.toContain('text-white/62'); - expect(entityEditorSource).not.toContain('text-white/55'); - expect(entityEditorSource).not.toContain('text-white'); - expect(entityEditorSource).not.toContain('bg-white'); - expect(entityEditorSource).not.toContain('rounded-[2px]'); - expect(entityEditorSource).not.toContain('useAngularVisual'); - expect(entityEditorSource).not.toContain('angular-'); - expect(entityEditorSource).not.toContain('WorkbenchPage'); - expect(entityEditorSource).not.toContain('SurfaceSection'); - expect(entityEditorSource).not.toContain('RailSection'); - expect(entityEditorSource).not.toContain('WorkbenchPillButton'); - expect(entityEditorSource).not.toContain('WorkbenchSelectableCard'); - expect(entityEditorSource).not.toContain('WorkbenchStack'); - expect(entityEditorSource).not.toContain('RowList'); - expect(entityEditorSource).not.toContain('buildEntityEditorFacts'); - expect(entityEditorSource).not.toContain('buildEntityEditorCatalogRows'); - expect(entityEditorSource).not.toContain('buildEntityEditorNextStepRows'); - expect(entityEditorSource).not.toContain('buildEntityEditorWorkspaceTabs'); - expect(entityEditorSource).not.toContain("'Workflow'"); - expect(entityEditorSource).not.toContain("'Basics'"); - expect(entityEditorSource).not.toContain('focus:border-sky-400/35'); - expect(entityEditorSource).not.toContain('border-sky-400/40'); - expect(entityEditorSource).not.toContain('bg-sky-400/12'); - expect(entityEditorSource).not.toContain('lightInputClassName'); - expect(entityEditorSource).not.toContain('nameInputClassName'); - }); - - it('adopts shared ops tokens across the shared editor slice', () => { - const editorRowsSource = readFileSync(resolve(process.cwd(), 'components/observability/editor-rows.tsx'), 'utf8'); - const workbenchPrimitivesSource = readFileSync(resolve(process.cwd(), 'components/workbench/primitives.tsx'), 'utf8'); - const workbenchPageSource = readFileSync(resolve(process.cwd(), 'components/workbench/workbench-page.tsx'), 'utf8'); - const monitorEditorSource = readFileSync(resolve(process.cwd(), 'components/pages/monitor-editor-surface.tsx'), 'utf8'); - const entityEditorSource = readFileSync(resolve(process.cwd(), 'components/pages/entity-editor-surface.tsx'), 'utf8'); - const entitiesSource = readFileSync(resolve(process.cwd(), 'app/entities/entity-list-page.tsx'), 'utf8'); - - expect(editorRowsSource).toContain('border-[var(--ops-border-color)]'); - expect(editorRowsSource).toContain('bg-[var(--ops-surface-panel)]'); - expect(editorRowsSource).toContain('text-[var(--ops-text-primary)]'); - expect(editorRowsSource).toContain('text-[var(--ops-text-secondary)]'); - expect(editorRowsSource).toContain('hover:bg-[var(--ops-surface-hover)]'); - - expect(monitorEditorSource).toContain('HzMonitorEditorForm'); - expect(monitorEditorSource).toContain('HzMonitorEditorHeader'); - expect(monitorEditorSource).toContain('HzMonitorEditorSection'); - expect(monitorEditorSource).toContain('data-monitor-editor-form-owner="hertzbeat-ui-monitor-editor-form"'); - expect(monitorEditorSource).not.toContain('WorkbenchPage'); - expect(monitorEditorSource).not.toContain('SurfaceSection'); - expect(workbenchPrimitivesSource).toContain('border-[var(--ops-border-color)]'); - expect(workbenchPrimitivesSource).toContain('bg-[var(--ops-surface-panel)]'); - expect(workbenchPrimitivesSource).toContain('text-[var(--ops-text-primary)]'); - expect(workbenchPrimitivesSource).toContain('text-[var(--ops-text-secondary)]'); - expect(workbenchPageSource).toContain('divide-[var(--ops-border-color)]'); - expect(workbenchPageSource).toContain('text-[var(--ops-text-primary)]'); - expect(workbenchPageSource).toContain('text-[var(--ops-text-secondary)]'); - - expect(entityEditorSource).toContain('data-entity-editor-shell="otlp-hertzbeat-ui-entity-composer"'); - expect(entityEditorSource).toContain('data-entity-editor-style-baseline="hertzbeat-ui-matte"'); - expect(entityEditorSource).toContain('data-entity-editor-frame="hertzbeat-ui-unframed-editor-band"'); - expect(entityEditorSource).toContain('data-entity-editor-nested-card-policy="no-card-inside-card"'); - expect(entityEditorSource).toContain('data-entity-editor-summary-card="hertzbeat-ui-unframed-editor-section"'); - expect(entityEditorSource).toContain('data-entity-editor-definition-footer="hertzbeat-ui-definition-footer"'); - expect(entityEditorSource).toContain('data-entity-editor-definition-tabs="hertzbeat-ui-bottom-tabs"'); - - expect(entitiesSource).toContain('EntityListSurface'); - expect(entitiesSource).not.toContain('SummaryMetricGrid'); - expect(entitiesSource).not.toContain('StageSection'); - expect(entitiesSource).not.toContain('DrawerSection'); - expect(entitiesSource).not.toContain('DrawerCodePreview'); - expect(entitiesSource).not.toContain('ObservabilityStatusState'); - }); - - it('keeps the active monitor/entity edit routes composed from the shared editor owners', () => { - const monitorEditRouteSource = readFileSync(resolve(process.cwd(), 'app/monitors/[monitorId]/edit/page.tsx'), 'utf8'); - const monitorEditSource = readFileSync(resolve(process.cwd(), 'app/monitors/[monitorId]/edit/monitor-edit-page.tsx'), 'utf8'); - const entityNewRouteSource = readFileSync(resolve(process.cwd(), 'app/entities/new/page.tsx'), 'utf8'); - const entityNewSource = readFileSync(resolve(process.cwd(), 'app/entities/new/entity-new-page.tsx'), 'utf8'); - const entityEditRouteSource = readFileSync(resolve(process.cwd(), 'app/entities/[entityId]/edit/page.tsx'), 'utf8'); - const entityEditSource = readFileSync(resolve(process.cwd(), 'app/entities/[entityId]/edit/entity-edit-page.tsx'), 'utf8'); - - expect(monitorEditRouteSource).toContain("import MonitorEditPage from './monitor-edit-page'"); - expect(monitorEditSource).toContain('MonitorEditorSurface'); - expect(entityNewRouteSource).toContain("import EntityNewPage from './entity-new-page'"); - expect(entityNewSource).toContain('EntityEditorSurface'); - expect(entityEditRouteSource).toContain("import EntityEditPage from './entity-edit-page'"); - expect(entityEditSource).toContain('EntityEditorSurface'); - }); -}); diff --git a/web-next/app/events/page.test.ts b/web-next/app/events/page.test.ts deleted file mode 100644 index 7c698e1129..0000000000 --- a/web-next/app/events/page.test.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest'; - -const redirect = vi.fn(); - -vi.mock('next/navigation', () => ({ - redirect -})); - -describe('events alias route', () => { - beforeEach(() => { - redirect.mockReset(); - redirect.mockImplementation((target: string) => { - throw new Error(`redirect:${target}`); - }); - }); - - it('redirects events compatibility traffic to the canonical log explorer view', async () => { - const { default: EventsAliasPage } = await import('./page'); - - await expect(EventsAliasPage({ searchParams: Promise.resolve({}) })).rejects.toThrow('redirect:/log/manage?view=list'); - expect(redirect).toHaveBeenCalledWith('/log/manage?view=list'); - }, 20000); - - it('preserves log filters and machine context while stripping display-only labels', async () => { - const { default: EventsAliasPage } = await import('./page'); - - await expect( - EventsAliasPage({ - searchParams: Promise.resolve({ - content: ' checkout timeout ', - traceId: ' trace-123 ', - severityNumber: '17', - start: '10', - end: '20', - entityId: '7', - entityName: 'Checkout API', - returnTo: '/overview?returnLabel=Overview', - returnLabel: 'Overview', - serviceName: 'checkout', - environment: ['prod', 'staging'] - }) - }) - ).rejects.toThrow( - 'redirect:/log/manage?search=checkout+timeout&traceId=trace-123&severityNumber=17&view=list&start=10&end=20&entityId=7&entityName=Checkout+API&returnTo=%2Foverview&serviceName=checkout&environment=prod' - ); - expect(redirect).toHaveBeenLastCalledWith( - '/log/manage?search=checkout+timeout&traceId=trace-123&severityNumber=17&view=list&start=10&end=20&entityId=7&entityName=Checkout+API&returnTo=%2Foverview&serviceName=checkout&environment=prod' - ); - }, 20000); -}); diff --git a/web-next/app/events/page.tsx b/web-next/app/events/page.tsx deleted file mode 100644 index aa06b973bf..0000000000 --- a/web-next/app/events/page.tsx +++ /dev/null @@ -1,11 +0,0 @@ -import { redirect } from 'next/navigation'; -import { buildLogCompatRouteUrlFromSearchParams, type SearchParamsRecord } from '../../lib/log-manage/query-state'; - -export default async function EventsAliasPage({ - searchParams -}: { - searchParams?: Promise; -}) { - const resolvedSearchParams = await searchParams; - redirect(buildLogCompatRouteUrlFromSearchParams(resolvedSearchParams, { view: 'list' })); -} diff --git a/web-next/app/exception/[type]/page.test.tsx b/web-next/app/exception/[type]/page.test.tsx deleted file mode 100644 index 2ebdd3f81b..0000000000 --- a/web-next/app/exception/[type]/page.test.tsx +++ /dev/null @@ -1,33 +0,0 @@ -import React from 'react'; -import { renderToStaticMarkup } from 'react-dom/server'; -import { describe, expect, it, vi } from 'vitest'; -import ExceptionPage from './page'; - -const mockSurfaceProps = vi.hoisted(() => ({ - type: '' -})); - -vi.mock('../../../components/pages/exception-center-surface', () => ({ - ExceptionCenterSurface: ({ type }: { type: string }) => { - mockSurfaceProps.type = type; - return
; - } -})); - -describe('ExceptionPage', () => { - it.each(['403', '404', '500'])('renders the shared exception center surface for exception type %s', async type => { - const html = renderToStaticMarkup(await ExceptionPage({ params: Promise.resolve({ type }) })); - - expect(html).toContain('data-exception-center-surface="hertzbeat-ui-exceptions"'); - expect(html).toContain(`data-exception-type="${type}"`); - expect(mockSurfaceProps.type).toBe(type); - }); - - it('normalizes unsupported exception route params to the 404 surface type', async () => { - const html = renderToStaticMarkup(await ExceptionPage({ params: Promise.resolve({ type: '999' }) })); - - expect(html).toContain('data-exception-center-surface="hertzbeat-ui-exceptions"'); - expect(html).toContain('data-exception-type="404"'); - expect(mockSurfaceProps.type).toBe('404'); - }); -}); diff --git a/web-next/app/exception/[type]/page.tsx b/web-next/app/exception/[type]/page.tsx deleted file mode 100644 index 403f6a1a81..0000000000 --- a/web-next/app/exception/[type]/page.tsx +++ /dev/null @@ -1,10 +0,0 @@ -import React from 'react'; -import { ExceptionCenterSurface } from '../../../components/pages/exception-center-surface'; -import { normalizeExceptionRouteType } from '../../../lib/exception-center/view-model'; - -export default async function ExceptionPage({ params }: { params: Promise<{ type: string }> }) { - const { type } = await params; - const normalizedType = normalizeExceptionRouteType(type); - - return ; -} diff --git a/web-next/app/explorer/explorer-page.tsx b/web-next/app/explorer/explorer-page.tsx deleted file mode 100644 index b24cadb421..0000000000 --- a/web-next/app/explorer/explorer-page.tsx +++ /dev/null @@ -1,345 +0,0 @@ -'use client'; - -import React, { useCallback, useEffect, useMemo, useState } from 'react'; -import { useRouter, useSearchParams } from 'next/navigation'; -import { BellPlus, BarChart3, LayoutDashboard, Play, Save } from 'lucide-react'; -import { - HzButton, - HzButtonLink, - HzDataCellText, - HzDataMetaText, - HzDataTable, - HzExplorerFrame, - HzQueryBar, - HzSelect, - type HzDataColumn -} from '@hertzbeat/ui'; -import { ClientWorkbench } from '../../components/workbench/client-workbench'; -import { useI18n } from '../../components/providers/i18n-provider'; -import { - buildExplorerRouteUrl, - loadExplorerReadData, - readExplorerQueryState, - type ExplorerQueryState, - type ExplorerReadData, - type ExplorerSignalFilter -} from '../../lib/explorer-surface/controller'; -import { buildExplorerFilters, type ExplorerResultRow, type ExplorerSignalTone } from '../../lib/explorer-surface/view-model'; - -type Translator = ReturnType['t']; - -const EXPLORER_WORKBENCH_LOAD_TIMEOUT_MS = 15_000; - -function signalToneClass(signalTone: ExplorerSignalTone) { - if (signalTone === 'trace') return 'border-[#7a3f55] bg-[#241119] text-[#f18aa6]'; - if (signalTone === 'log') return 'border-[#3a5674] bg-[#101b29] text-[#9bc5ee]'; - if (signalTone === 'metric') return 'border-[#394a78] bg-[#121a2a] text-[#c7d7ff]'; - return 'border-[#303642] bg-[#151821] text-[#d7dce6]'; -} - -export default function ExplorerPage() { - const { t } = useI18n(); - const searchParams = useSearchParams(); - const queryState = useMemo(() => readExplorerQueryState(searchParams), [searchParams]); - const load = useCallback(() => loadExplorerReadData(t, undefined, queryState), [queryState, t]); - const cacheKey = `explorer:trace-log-read:${queryState.signal}:${queryState.q}`; - - return ( - - {data => } - - ); -} - -function ExplorerWorksurface({ data, t }: { data: ExplorerReadData; t: Translator }) { - const router = useRouter(); - const filters = buildExplorerFilters(t); - const rows = data.rows; - const [activeRowKey, setActiveRowKey] = useState(rows[0]?.key ?? null); - const activeRow = rows.find(row => row.key === activeRowKey) || rows[0] || null; - const [draft, setDraft] = useState(data.query); - useEffect(() => { - setDraft(data.query); - }, [data.query]); - useEffect(() => { - setActiveRowKey(rows[0]?.key ?? null); - }, [rows]); - const applyQuery = useCallback(() => { - router.replace(buildExplorerRouteUrl(draft)); - }, [draft, router]); - const columns: HzDataColumn[] = [ - { - key: 'signal', - header: t('explorer.table.signal-type'), - width: '112px', - render: row => ( - - {row.signal} - - ) - }, - { - key: 'service', - header: t('explorer.table.service'), - width: '180px', - render: row => {row.service} - }, - { - key: 'operation', - header: t('explorer.table.operation'), - render: row => ( - - {row.operation} - - ) - }, - { - key: 'status', - header: t('explorer.table.status'), - width: '96px', - render: row => {row.status} - }, - { - key: 'duration', - header: t('explorer.table.duration'), - width: '96px', - render: row => {row.duration} - }, - { - key: 'time', - header: t('explorer.table.time'), - width: '176px', - render: row => {row.timestamp} - } - ]; - - return ( -
- - - - {t('explorer.actions.save-view')} - - - - {t('explorer.actions.create-alert')} - - - - {t('explorer.actions.add-dashboard')} - - - } - queryBar={ -
- setDraft(current => ({ ...current, q }))} - inputProps={ - { - 'aria-label': t('explorer.query.aria'), - placeholder: t('explorer.query.placeholder'), - 'data-explorer-query-input': 'url-owned' - } as React.InputHTMLAttributes - } - actions={ - <> - setDraft(current => ({ ...current, signal: event.target.value as ExplorerSignalFilter }))} - data-explorer-signal-select="url-owned" - options={[ - { value: 'all', label: t('explorer.signal.all') }, - { value: 'trace', label: t('explorer.rows.trace.signal') }, - { value: 'log', label: t('explorer.rows.log.signal') }, - { value: 'metric', label: t('explorer.rows.metric.signal') } - ]} - /> - - - - {t('explorer.query.run')} - - - } - /> -
- } - filterRail={ - - } - > -
-
-
-

{t('explorer.chart.title')}

-

- {t('explorer.chart.subtitle')} -

-
-
-
-
-
- {[28, 36, 22, 54, 45, 68, 42, 58, 33, 74, 49, 62].map((height, index) => ( -
- ))} -
-
- -
-
-
- {t('explorer.results.title')} - {t('explorer.results.count', { count: rows.length })} -
- row.key} - variant="embedded" - selectedRowKey={activeRow?.key} - onRowClick={row => setActiveRowKey(row.key)} - getRowProps={row => ({ - 'aria-label': t('explorer.result.row.aria', { signal: row.signal, service: row.service, operation: row.operation }), - 'data-explorer-result-row': row.key, - 'data-explorer-result-row-selected': activeRow?.key === row.key ? 'true' : 'false' - })} - emptyLabel={t('explorer.empty.table-label')} - data-explorer-result-table-owner="hertzbeat-ui-data-table" - /> - {rows.length === 0 ? ( -
-

- {t('explorer.empty.title')} -

-

{t('explorer.empty.copy')}

-
- {t('explorer.empty.step.query')} - {t('explorer.empty.step.signal')} - {t('explorer.empty.step.ingest')} -
-
- ) : null} -
- - -
-
-
- ); -} diff --git a/web-next/app/explorer/page.test.tsx b/web-next/app/explorer/page.test.tsx deleted file mode 100644 index fc07bf4260..0000000000 --- a/web-next/app/explorer/page.test.tsx +++ /dev/null @@ -1,198 +0,0 @@ -import { readFileSync } from 'node:fs'; -import { resolve } from 'node:path'; -import React from 'react'; -import { renderToStaticMarkup } from 'react-dom/server'; -import { describe, expect, it, vi } from 'vitest'; -import { createTranslatorMock } from '../../test/i18n-test-helper'; - -vi.mock('next/link', () => ({ - default: ({ href, children, ...props }: any) => ( - - {children} - - ) -})); - -const replaceMock = vi.fn(); - -vi.mock('next/navigation', () => ({ - useRouter: () => ({ - replace: replaceMock - }), - useSearchParams: () => new URLSearchParams('q=checkout&signal=trace') -})); - -vi.mock('../../components/providers/i18n-provider', () => ({ - useI18n: () => ({ - t: createTranslatorMock({ locale: 'zh-CN' }) - }) -})); - -vi.mock('../../components/workbench/client-workbench', () => ({ - ClientWorkbench: ({ children }: { children: (data: any) => React.ReactNode }) => - children({ - rows: [ - { - key: 'trace:trace-1', - signalKey: 'trace', - signalTone: 'trace', - href: '/trace/manage?traceId=trace-1', - signal: 'Trace', - service: 'checkout', - operation: 'POST /checkout', - status: 'Error', - duration: '1.25s', - timestamp: '2026/03/30 11:50:57' - }, - { - key: 'log:1:trace-1:none:0', - signalKey: 'log', - signalTone: 'log', - href: '/log/manage?traceId=trace-1', - signal: 'Logs', - service: 'payment', - operation: 'payment failed', - status: 'ERROR', - duration: '-', - timestamp: '2026/03/30 11:50:58' - }, - { - key: 'metric:frontend:http.server.duration', - signalKey: 'metric', - signalTone: 'metric', - href: '/ingestion/otlp/metrics?query=http.server.duration&serviceName=frontend', - signal: 'Metrics', - service: 'frontend', - operation: 'http.server.duration', - status: 'Normal', - duration: '0.91', - timestamp: '2026/03/30 11:50:59' - } - ], - apiState: 'ready', - apiOwner: 'trace-log-bff-query-api', - query: { - q: 'checkout', - signal: 'trace' - }, - sourceUrls: { - traces: '/traces/list?pageIndex=0&pageSize=8&serviceName=checkout', - logs: null, - metrics: '/ingestion/otlp/metrics/console?query=checkout&aggregation=avg&groupBy=service_name&timeRange=last-30m' - }, - traceTotal: 1, - logTotal: 1, - metricTotal: 1 - }) -})); - -describe('explorer page', () => { - it('renders the OTLP cold Workbench explorer baseline in Chinese', async () => { - const t = createTranslatorMock({ locale: 'zh-CN' }); - const { default: ExplorerPage } = await import('./page'); - const html = renderToStaticMarkup(); - - expect(html).toContain('data-explorer-route="otlp-hertzbeat-ui-workbench"'); - expect(html).toContain('data-explorer-style-baseline="hertzbeat-ui-matte"'); - expect(html).toContain('data-explorer-api-owner="trace-log-bff-query-api"'); - expect(html).toContain('data-explorer-api-state="ready"'); - expect(html).toContain('data-explorer-api-total="3"'); - expect(html).toContain('data-explorer-api-metric-total="1"'); - expect(html).toContain('data-explorer-query-state="checkout"'); - expect(html).toContain('data-explorer-signal-filter="trace"'); - expect(html).toContain( - 'data-explorer-api-source="/traces/list?pageIndex=0&pageSize=8&serviceName=checkout|null|/ingestion/otlp/metrics/console?query=checkout&aggregation=avg&groupBy=service_name&timeRange=last-30m"' - ); - expect(html).toContain('data-explorer-shared-frame="hertzbeat-ui"'); - expect(html).toContain('data-hz-ui="explorer-frame"'); - expect(html).toContain('data-hz-density="operator-compact"'); - expect(html).toContain('data-explorer-query-bar="hertzbeat-ui-query-row"'); - expect(html).toContain('data-explorer-chart-band="hertzbeat-ui-chart-band"'); - expect(html).toContain('data-explorer-result-table="hertzbeat-ui-dense-table"'); - expect(html).toContain('data-explorer-detail-panel="hertzbeat-ui-detail-panel"'); - expect(html).toContain('data-explorer-detail-active-row="trace:trace-1"'); - expect(html).toContain('data-explorer-result-table-owner="hertzbeat-ui-data-table"'); - expect(html).toContain('data-hz-row-clickable="true"'); - expect(html).toContain('data-hz-row-selected="true"'); - expect(html).toContain('data-explorer-result-row="trace:trace-1"'); - expect(html).toContain('data-explorer-result-row-selected="true"'); - expect(html).toContain('data-explorer-result-row="log:1:trace-1:none:0"'); - expect(html).toContain('data-explorer-result-row-selected="false"'); - expect(html).toContain('data-explorer-result-row="metric:frontend:http.server.duration"'); - expect(html).toContain('data-explorer-signal-tone="trace"'); - expect(html).toContain('data-explorer-signal-tone="log"'); - expect(html).toContain('data-explorer-signal-tone="metric"'); - expect(html).toContain('border-[#394a78] bg-[#121a2a] text-[#c7d7ff]'); - expect(html).not.toContain('border-[#315b49] bg-[#0f211b] text-[#8bd8ad]'); - expect(html).toContain('href="/trace/manage?traceId=trace-1"'); - expect(html).toContain('href="/log/manage?traceId=trace-1"'); - expect(html).toContain('href="/ingestion/otlp/metrics?query=http.server.duration&serviceName=frontend"'); - expect(html).toContain(t('explorer.title')); - expect(html).toContain(t('explorer.query.label')); - expect(html).toContain(t('explorer.query.run')); - expect(html).toContain(t('explorer.signal.aria')); - expect(html).toContain(t('explorer.table.service')); - expect(html).toContain(t('explorer.table.operation')); - expect(html).toContain('checkout'); - expect(html).toContain(t('explorer.actions.save-view')); - expect(html).toContain(t('explorer.actions.create-alert')); - expect(html).toContain(t('explorer.actions.add-dashboard')); - expect(html).toContain('data-explorer-query-input="url-owned"'); - expect(html).toContain('data-explorer-signal-select="url-owned"'); - expect(html).toContain('data-explorer-query-url="/explorer?q=checkout&signal=trace"'); - - expect(html).not.toContain('signoz-'); - expect(html).not.toContain('Explorer'); - expect(html).not.toContain('Funnels'); - expect(html).not.toContain('Views'); - expect(html).not.toContain('Search and Filter based on resource attributes.'); - expect(html).not.toContain('Run Query'); - expect(html).not.toContain('Save this view'); - expect(html).not.toContain('Create an Alert'); - expect(html).not.toContain('Add to Dashboard'); - }); - - it('keeps cross-signal handoffs without reverting to old cards', async () => { - const { default: ExplorerPage } = await import('./page'); - const html = renderToStaticMarkup(); - - expect(html).toContain('href="/log/manage"'); - expect(html).toContain('href="/trace/manage"'); - expect(html).toContain('href="/ingestion/otlp/metrics"'); - expect(html).toContain('href="/entities"'); - }); - - it('does not keep the old placeholder surface owner', () => { - const routeSource = readFileSync(resolve(process.cwd(), 'app/explorer/page.tsx'), 'utf8'); - const source = readFileSync(resolve(process.cwd(), 'app/explorer/explorer-page.tsx'), 'utf8'); - - expect(routeSource).not.toMatch(/^['"]use client['"]/); - expect(routeSource).toContain("import ExplorerPage from './explorer-page'"); - expect(source).not.toContain('OpsSurfacePage'); - expect(source).not.toContain('buildExplorerSurfaceConfig'); - expect(source).not.toContain('buildExplorerResultRows'); - expect(source).not.toContain('data-explorer-floating-actions'); - expect(source).toContain('data-explorer-route="otlp-hertzbeat-ui-workbench"'); - expect(source).toContain('readExplorerQueryState'); - expect(source).toContain('buildExplorerRouteUrl'); - expect(source).toContain('loadExplorerReadData'); - expect(source).toContain('ClientWorkbench'); - expect(source).toContain('const EXPLORER_WORKBENCH_LOAD_TIMEOUT_MS = 15_000'); - expect(source).toContain('loadTimeoutMs={EXPLORER_WORKBENCH_LOAD_TIMEOUT_MS}'); - expect(source).toContain('HzExplorerFrame'); - expect(source).toContain("skipLinkLabel={t('app.frame.skip-to-workbench')}"); - expect(source).toContain('HzDataTable'); - }); - - it('keeps the empty explorer state actionable and localized for first-time users', () => { - const source = readFileSync(resolve(process.cwd(), 'app/explorer/explorer-page.tsx'), 'utf8'); - - expect(source).toContain("queryLabel={t('explorer.query.label')}"); - expect(source).toContain("emptyLabel={t('explorer.empty.table-label')}"); - expect(source).toContain('data-explorer-empty-title="query-no-results"'); - expect(source).toContain('data-explorer-empty-next-steps="query-signal-ingest"'); - expect(source).toContain('data-explorer-detail-empty-copy="select-or-broaden-query"'); - expect(source).toContain("t('explorer.detail.empty-title')"); - expect(source).not.toContain("t('common.no-data')"); - }); -}); diff --git a/web-next/app/explorer/page.tsx b/web-next/app/explorer/page.tsx deleted file mode 100644 index c052038d50..0000000000 --- a/web-next/app/explorer/page.tsx +++ /dev/null @@ -1,6 +0,0 @@ -import React from 'react'; -import ExplorerPage from './explorer-page'; - -export default function ExplorerRoutePage() { - return ; -} diff --git a/web-next/app/globals.css b/web-next/app/globals.css deleted file mode 100644 index 942d2fd1e5..0000000000 --- a/web-next/app/globals.css +++ /dev/null @@ -1,571 +0,0 @@ -@import '../packages/design-tokens/src/tokens.css'; -@import '../packages/design-tokens/src/themes.css'; - -@tailwind base; -@tailwind components; -@tailwind utilities; - -:root { - --ops-background: #0b0c0e; - --ops-surface-panel: #121317; - --ops-surface-raised: #16181d; - --ops-surface-elevated: #1a1c21; - --ops-border-color: #1f232b; - --ops-text-primary: #eef2f6; - --ops-text-secondary: #99a1b3; - --ops-text-tertiary: #727b8c; - --ops-primary: #4e74f8; - --ops-success: #4cb782; - --ops-warning: #c59857; - --ops-critical: #d86f5b; - --ops-radius-panel: 10px; - --ops-radius-compact: 6px; - --ops-panel-shadow: 0 2px 8px rgba(0, 0, 0, 0.12); - --ops-panel-shadow-strong: 0 8px 18px rgba(0, 0, 0, 0.16); - - --background: 220 11% 5%; - --foreground: 210 25% 95%; - --card: 225 9% 7%; - --card-foreground: 210 25% 95%; - --popover: 220 11% 5%; - --popover-foreground: 210 25% 95%; - --primary: 226 92% 64%; - --primary-foreground: 0 0% 100%; - --secondary: 225 8% 10%; - --secondary-foreground: 210 25% 95%; - --muted: 223 11% 12%; - --muted-foreground: 218 13% 62%; - --accent: 225 8% 10%; - --accent-foreground: 210 25% 95%; - --destructive: 10 60% 60%; - --destructive-foreground: 0 0% 98%; - --border: 220 12% 14%; - --input: 220 12% 14%; - --ring: 226 92% 64%; - --radius: 10px; - --success: 148 43% 51%; - --warning: 36 46% 56%; - - --hb-bg: hsl(var(--background)); - --hb-canvas: hsl(var(--popover)); - --hb-surface: hsl(var(--card)); - --hb-surface-2: hsl(var(--secondary)); - --hb-surface-3: hsl(var(--accent)); - --hb-border: var(--ops-border-color); - --hb-border-strong: rgba(78, 116, 248, 0.32); - --hb-text: var(--ops-text-primary); - --hb-text-2: var(--ops-text-secondary); - --hb-text-3: hsl(var(--muted-foreground) / 0.68); - --hb-accent: var(--ops-primary); - --hb-accent-soft: rgba(78, 116, 248, 0.12); - --hb-success: var(--ops-success); - --hb-warning: var(--ops-warning); - --hb-danger: var(--ops-critical); - --hb-shadow: var(--ops-panel-shadow); - --hb-font: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; - --hb-mono: 'SFMono-Regular', ui-monospace, Menlo, Monaco, Consolas, monospace; - - --hz-ui-canvas: #0b0d10; - --hz-ui-surface: #0d1014; - --hz-ui-surface-soft: #10141a; - --hz-ui-surface-raised: #131922; - --hz-ui-surface-graphite: #0c0e12; - --hz-ui-surface-muted: rgba(255, 255, 255, 0.018); - --hz-ui-control: #0e1218; - --hz-ui-code: #090b0e; - --hz-ui-line-strong: rgba(166, 178, 195, 0.135); - --hz-ui-line: rgba(166, 178, 195, 0.085); - --hz-ui-line-soft: rgba(166, 178, 195, 0.052); - --hz-ui-line-faint: rgba(166, 178, 195, 0.032); - --hz-ui-active: rgba(76, 92, 148, 0.22); - --hz-ui-active-soft: rgba(76, 92, 148, 0.095); - --hz-ui-accent: #7c93db; - --hz-ui-accent-muted: rgba(124, 147, 219, 0.32); - --hz-ui-action-primary: #4f6bdc; - --hz-ui-action-primary-hover: #5d78ee; - --hz-ui-action-danger: #7f232f; - --hz-ui-action-danger-hover: #9b2a39; - --hz-ui-scrollbar-size: 7px; - --hz-ui-scrollbar-thumb: rgba(166, 178, 195, 0.18); - --hz-ui-scrollbar-thumb-hover: rgba(166, 178, 195, 0.3); -} - -html[data-theme='light-ops'], -body[data-theme='light-ops'] { - --background: 210 20% 98%; - --foreground: 222 47% 11%; - --card: 0 0% 100%; - --card-foreground: 222 47% 11%; - --popover: 210 20% 99%; - --popover-foreground: 222 47% 11%; - --primary: 221 70% 52%; - --primary-foreground: 0 0% 100%; - --secondary: 210 30% 96%; - --secondary-foreground: 222 47% 12%; - --muted: 210 24% 95%; - --muted-foreground: 215 16% 40%; - --accent: 214 32% 92%; - --accent-foreground: 222 47% 11%; - --destructive: 0 72% 54%; - --destructive-foreground: 0 0% 100%; - --border: 214 21% 87%; - --input: 214 21% 87%; - --ring: 221 70% 52%; - --success: 154 60% 36%; - --warning: 35 88% 46%; - --hb-shadow: 0 18px 40px rgba(15, 23, 42, 0.08); -} - -html[data-theme='compact'], -body[data-theme='compact'] { - --radius: 0.625rem; -} - -@layer base { - * { - box-sizing: border-box; - border-color: hsl(var(--border)); - } - - html, - body { - min-height: 100%; - margin: 0; - padding: 0; - background: var(--ops-background); - color: var(--ops-text-primary); - font-family: var(--hb-font); - } - - body { - @apply antialiased; - } - - a { - color: inherit; - text-decoration: none; - } - - button, - input, - select, - textarea { - font: inherit; - } - - textarea { - resize: none; - } - - select:not([multiple]) { - -webkit-appearance: none; - appearance: none; - color-scheme: dark; - padding-right: 2rem; - background-image: - linear-gradient(45deg, transparent 50%, #8f99ab 50%), - linear-gradient(135deg, #8f99ab 50%, transparent 50%); - background-position: - calc(100% - 14px) calc(50% - 2px), - calc(100% - 9px) calc(50% - 2px); - background-size: - 5px 5px, - 5px 5px; - background-repeat: no-repeat; - } - - select option { - background: #101217; - color: #dbe4f0; - } - - ::selection { - background: hsl(var(--primary) / 0.3); - color: white; - } -} - -@layer components { - .hb-shell { - @apply min-h-screen bg-transparent; - } - - .hb-main { - @apply min-h-screen; - } - - .hb-page { - @apply px-3 pb-3 pt-2.5; - } - - .hb-layout { - @apply grid gap-3; - } - - .hb-layout.two-col { - grid-template-columns: minmax(0, 1fr) minmax(288px, 320px); - } - - .hb-side { - @apply grid content-start gap-3; - } - - .hb-toolbar { - @apply flex flex-wrap items-end gap-2 border px-3 py-2; - border-radius: var(--ops-radius-compact); - background: var(--ops-surface-panel); - border-color: var(--ops-border-color); - box-shadow: none; - } - - .hb-inline-actions { - @apply flex flex-wrap items-center gap-2; - } - - .hb-kicker { - @apply text-[10px] font-medium uppercase tracking-[0.18em]; - color: hsl(var(--muted-foreground) / 0.8); - } - - .hb-select, - .hb-input { - @apply flex h-8 w-full border px-3 py-1.5 text-[12px] outline-none transition-colors; - border-radius: 3px; - border-color: #2b3039; - background-color: #101217; - color: #dbe4f0; - box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.03); - } - - .hb-select::placeholder, - .hb-input::placeholder { - color: var(--ops-text-tertiary); - } - - .hb-select:focus, - .hb-input:focus { - background: var(--ops-surface-panel); - border-color: var(--ops-primary); - box-shadow: 0 0 0 2px rgba(78, 116, 248, 0.14); - } - - .hb-btn { - @apply inline-flex h-8 items-center justify-center gap-1.5 border px-3 py-1.5 text-[12px] font-semibold transition; - border-radius: 2px; - border-color: var(--ops-border-color); - background: var(--ops-surface-raised); - color: var(--ops-text-primary); - } - - .hb-btn:hover { - background: var(--ops-surface-elevated); - } - - .hb-btn-primary { - border-color: var(--ops-primary); - background: var(--ops-primary); - color: white; - box-shadow: none; - } - - .hb-btn-primary:hover { - filter: brightness(1.04); - } - - .hertzbeat-date-picker-wrapper, - .hertzbeat-date-picker-wrapper .react-datepicker__input-container { - display: contents; - } - - #hertzbeat-date-picker-portal, - :where(.hertzbeat-date-picker-popper, .hertzbeat-date-picker-panel) { - z-index: 80; - } - - .hertzbeat-date-picker-panel *, - .hertzbeat-date-picker-panel *::before, - .hertzbeat-date-picker-panel *::after { - box-sizing: border-box; - } - - :where(.hertzbeat-date-picker-popper, .hertzbeat-date-picker-panel) .react-datepicker { - overflow: hidden; - border: 0; - border-radius: 0; - background: #101217; - color: #dbe4f0; - font-family: var(--hb-font); - box-shadow: none; - } - - :where(.hertzbeat-date-picker-popper, .hertzbeat-date-picker-panel) .react-datepicker, - :where(.hertzbeat-date-picker-popper, .hertzbeat-date-picker-panel) .react-datepicker__month-container { - width: 258px; - } - - :where(.hertzbeat-date-picker-popper, .hertzbeat-date-picker-panel) .react-datepicker__triangle { - display: none; - } - - :where(.hertzbeat-date-picker-popper, .hertzbeat-date-picker-panel) .react-datepicker__header { - border-color: #252b34; - background: #101217; - } - - .hertzbeat-date-picker-panel .react-datepicker__month { - margin: 0; - padding: 4px 8px 8px; - overflow-x: hidden; - } - - .hertzbeat-date-picker-panel .react-datepicker__day-names, - .hertzbeat-date-picker-panel .react-datepicker__week { - display: grid; - grid-template-columns: repeat(7, 1fr); - } - - :where(.hertzbeat-date-picker-popper, .hertzbeat-date-picker-panel) .react-datepicker__current-month, - :where(.hertzbeat-date-picker-popper, .hertzbeat-date-picker-panel) .react-datepicker-time__header, - :where(.hertzbeat-date-picker-popper, .hertzbeat-date-picker-panel) .react-datepicker-year-header { - color: #dbe4f0; - font-size: 12px; - font-weight: 700; - } - - :where(.hertzbeat-date-picker-popper, .hertzbeat-date-picker-panel) .react-datepicker__day-name, - :where(.hertzbeat-date-picker-popper, .hertzbeat-date-picker-panel) .react-datepicker__day, - :where(.hertzbeat-date-picker-popper, .hertzbeat-date-picker-panel) .react-datepicker__time-name { - color: #a9b0bb; - font-size: 11px; - } - - :where(.hertzbeat-date-picker-popper, .hertzbeat-date-picker-panel) .react-datepicker__day { - border-radius: 3px; - } - - .hertzbeat-date-picker-panel .react-datepicker__day-name, - .hertzbeat-date-picker-panel .react-datepicker__day { - display: grid; - width: 100%; - height: 28px; - margin: 0; - place-items: center; - line-height: 1; - } - - :where(.hertzbeat-date-picker-popper, .hertzbeat-date-picker-panel) .react-datepicker__day:hover, - :where(.hertzbeat-date-picker-popper, .hertzbeat-date-picker-panel) .react-datepicker__month-text:hover, - :where(.hertzbeat-date-picker-popper, .hertzbeat-date-picker-panel) .react-datepicker__quarter-text:hover, - :where(.hertzbeat-date-picker-popper, .hertzbeat-date-picker-panel) .react-datepicker__year-text:hover { - background: #151b28; - color: #eef3ff; - } - - :where(.hertzbeat-date-picker-popper, .hertzbeat-date-picker-panel) .react-datepicker__day--selected, - :where(.hertzbeat-date-picker-popper, .hertzbeat-date-picker-panel) .react-datepicker__day--keyboard-selected, - :where(.hertzbeat-date-picker-popper, .hertzbeat-date-picker-panel) .react-datepicker__time-container .react-datepicker__time .react-datepicker__time-box ul.react-datepicker__time-list li.react-datepicker__time-list-item--selected { - background: #182238; - color: #eef3ff; - outline: 1px solid #4e74f8; - } - - :where(.hertzbeat-date-picker-popper, .hertzbeat-date-picker-panel) .react-datepicker__day--outside-month { - color: #555f70; - } - - :where(.hertzbeat-date-picker-popper, .hertzbeat-date-picker-panel) .react-datepicker__navigation-icon::before { - border-color: #8f99ab; - } - - :where(.hertzbeat-date-picker-popper, .hertzbeat-date-picker-panel) .react-datepicker__time-container { - border-color: #252b34; - background: #101217; - } - - :where(.hertzbeat-date-picker-popper, .hertzbeat-date-picker-panel) .react-datepicker__time-container .react-datepicker__time { - background: #101217; - } - - :where(.hertzbeat-date-picker-popper, .hertzbeat-date-picker-panel) .react-datepicker__time-container .react-datepicker__time .react-datepicker__time-box ul.react-datepicker__time-list { - scrollbar-color: #2b3039 #0d1015; - } - - :where(.hertzbeat-date-picker-popper, .hertzbeat-date-picker-panel) .react-datepicker__time-container .react-datepicker__time .react-datepicker__time-box ul.react-datepicker__time-list li.react-datepicker__time-list-item { - height: 28px; - color: #a9b0bb; - font-size: 11px; - font-weight: 600; - } - - :where(.hertzbeat-date-picker-popper, .hertzbeat-date-picker-panel) .react-datepicker__time-container .react-datepicker__time .react-datepicker__time-box ul.react-datepicker__time-list li.react-datepicker__time-list-item:hover { - background: #151b28; - color: #eef3ff; - } - - :where(.hertzbeat-date-picker-popper, .hertzbeat-date-picker-panel) .react-datepicker__children-container { - width: auto; - margin: 0; - padding: 0; - } - - .hertzbeat-time-column-scroll { - scrollbar-width: none; - } - - .hertzbeat-time-column-scroll::-webkit-scrollbar { - width: 0; - height: 0; - } - - .hb-code { - @apply w-full rounded-2xl border p-4 font-mono text-xs leading-6 shadow-[inset_0_1px_0_rgba(255,255,255,.02)]; - background: hsl(var(--popover) / 0.92); - border-color: hsl(var(--border)); - color: hsl(var(--muted-foreground)); - white-space: pre-wrap; - word-break: break-word; - } - - .hb-row-title { - @apply text-sm font-medium text-white; - } - - .hb-row-copy { - @apply text-sm leading-6; - color: hsl(var(--muted-foreground)); - } - - .hb-row-meta { - @apply text-xs leading-5; - color: hsl(var(--muted-foreground) / 0.76); - } - - .hb-empty { - @apply rounded-[22px] border border-dashed px-5 py-8; - background: hsl(var(--card) / 0.52); - border-color: hsl(var(--border)); - } - - .hb-empty-title { - @apply text-base font-semibold text-white; - } - - .hb-empty-copy { - @apply mt-2 max-w-2xl text-sm leading-6; - color: hsl(var(--muted-foreground)); - } - - .hb-waterfall { - @apply overflow-hidden rounded-[22px] border; - background: hsl(var(--card) / 0.9); - border-color: hsl(var(--border)); - } - - .hb-waterfall-head, - .hb-waterfall-row { - display: grid; - grid-template-columns: minmax(220px, 1.2fr) 90px minmax(0, 2fr); - gap: 14px; - align-items: center; - } - - .hb-waterfall-head { - @apply border-b px-4 py-3 text-[10px] uppercase tracking-[0.16em]; - border-color: hsl(var(--border)); - color: hsl(var(--muted-foreground) / 0.76); - } - - .hb-waterfall-ruler { - @apply relative h-9 border-b; - background: hsl(var(--card) / 0.6); - border-color: hsl(var(--border)); - } - - .hb-waterfall-ruler::before { - content: ''; - position: absolute; - inset: 0; - background-image: repeating-linear-gradient(to right, transparent 0, transparent 52px, rgba(255, 255, 255, 0.06) 52px, rgba(255, 255, 255, 0.06) 53px); - } - - .hb-waterfall-row { - @apply border-b px-4 py-3 transition; - border-color: hsl(var(--border)); - } - - .hb-waterfall-row:hover { - background: hsl(var(--accent) / 0.48); - } - - .hb-waterfall-row:last-child { - @apply border-b-0; - } - - .hb-waterfall-bar { - @apply relative h-3.5 overflow-hidden rounded-full; - background: hsl(var(--secondary)); - } - - .hb-waterfall-bar > span { - position: absolute; - top: 1px; - bottom: 1px; - border-radius: 999px; - background: linear-gradient(90deg, hsl(var(--primary) / 0.84), hsl(var(--primary))); - } - - .hb-waterfall-bar.tone-danger > span { - background: linear-gradient(90deg, hsl(var(--destructive) / 0.78), hsl(var(--destructive))); - } -} - -@layer utilities { - html, - body, - .hb-scrollbar { - scrollbar-width: thin; - scrollbar-color: var(--hz-ui-scrollbar-thumb) transparent; - scrollbar-gutter: stable; - } - - html::-webkit-scrollbar, - body::-webkit-scrollbar, - .hb-scrollbar::-webkit-scrollbar { - width: var(--hz-ui-scrollbar-size); - height: var(--hz-ui-scrollbar-size); - } - - html::-webkit-scrollbar-track, - body::-webkit-scrollbar-track, - .hb-scrollbar::-webkit-scrollbar-track { - background: transparent; - } - - html::-webkit-scrollbar-thumb, - body::-webkit-scrollbar-thumb, - .hb-scrollbar::-webkit-scrollbar-thumb { - border: 1px solid transparent; - border-radius: 999px; - background: var(--hz-ui-scrollbar-thumb); - background-clip: padding-box; - } - - html::-webkit-scrollbar-thumb:hover, - body::-webkit-scrollbar-thumb:hover, - .hb-scrollbar::-webkit-scrollbar-thumb:hover { - background: var(--hz-ui-scrollbar-thumb-hover); - background-clip: padding-box; - } - - html::-webkit-scrollbar-corner, - body::-webkit-scrollbar-corner, - .hb-scrollbar::-webkit-scrollbar-corner { - background: transparent; - } -} - -@media (max-width: 1279px) { - .hb-layout.two-col { - grid-template-columns: 1fr; - } -} diff --git a/web-next/app/globals.test.ts b/web-next/app/globals.test.ts deleted file mode 100644 index 7edc120e06..0000000000 --- a/web-next/app/globals.test.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { readFileSync } from 'node:fs'; -import { resolve } from 'node:path'; -import { describe, expect, it } from 'vitest'; - -describe('global cold-workbench styles', () => { - it('locks the Angular shell tokens and flat canvas baseline', () => { - const css = readFileSync(resolve(process.cwd(), 'app/globals.css'), 'utf8'); - - expect(css).toContain('--ops-background: #0b0c0e;'); - expect(css).toContain('--ops-surface-panel: #121317;'); - expect(css).toContain('--ops-surface-raised: #16181d;'); - expect(css).toContain('--ops-border-color: #1f232b;'); - expect(css).toContain('--ops-radius-panel: 10px;'); - expect(css).toContain('--ops-radius-compact: 6px;'); - expect(css).toContain('--ops-panel-shadow: 0 2px 8px rgba(0, 0, 0, 0.12);'); - expect(css).toContain("font-family: var(--hb-font);"); - expect(css).not.toContain('radial-gradient('); - }); - - it('removes native textarea resize grips from cold editors', () => { - const css = readFileSync(resolve(process.cwd(), 'app/globals.css'), 'utf8'); - - expect(css).toMatch(/textarea\s*\{[^}]*resize:\s*none;/s); - expect(css).not.toContain('resize: vertical'); - expect(css).not.toContain('resize: both'); - }); -}); diff --git a/web-next/app/hb-i18n/[lang]/route.test.ts b/web-next/app/hb-i18n/[lang]/route.test.ts deleted file mode 100644 index dcac0a4f40..0000000000 --- a/web-next/app/hb-i18n/[lang]/route.test.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { SUPPLEMENTAL_MESSAGES } from '../../../lib/i18n-runtime-messages'; - -const zhMessages = SUPPLEMENTAL_MESSAGES['zh-CN'] ?? {}; -const enMessages = SUPPLEMENTAL_MESSAGES['en-US'] ?? {}; - -describe('hb-i18n route', () => { - beforeEach(() => { - vi.resetModules(); - }); - - it('serves the normalized Next runtime locale bundle', async () => { - const { GET } = await import('./route'); - const response = await GET(new Request('http://localhost/hb-i18n/zh_CN'), { - params: Promise.resolve({ lang: 'zh_CN' }) - }); - - await expect(response.json()).resolves.toMatchObject({ - 'common.save': zhMessages['common.save'], - 'layout.setup.progress.headline': zhMessages['layout.setup.progress.headline'], - 'alert.notice.title': zhMessages['alert.notice.title'] - }); - }); - - it('serves web-next-owned alert workbench copy without a legacy locale bundle', async () => { - const { GET } = await import('./route'); - const response = await GET(new Request('http://localhost/hb-i18n/zh-CN'), { - params: Promise.resolve({ lang: 'zh-CN' }) - }); - - await expect(response.json()).resolves.toMatchObject({ - 'alert.workbench.empty.copy': zhMessages['alert.workbench.empty.copy'], - 'alert.workbench.empty.copy.filtered': zhMessages['alert.workbench.empty.copy.filtered'] - }); - }); - - it('falls back to English runtime copy when a normalized locale has no local catalog yet', async () => { - const { GET } = await import('./route'); - const response = await GET(new Request('http://localhost/hb-i18n/ja-JP'), { - params: Promise.resolve({ lang: 'ja-JP' }) - }); - - await expect(response.json()).resolves.toMatchObject({ - 'common.save': enMessages['common.save'], - 'layout.setup.progress.headline': enMessages['layout.setup.progress.headline'] - }); - }); -}); diff --git a/web-next/app/hb-i18n/[lang]/route.ts b/web-next/app/hb-i18n/[lang]/route.ts deleted file mode 100644 index b21ecf29fd..0000000000 --- a/web-next/app/hb-i18n/[lang]/route.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { NextResponse } from 'next/server'; -import { normalizeLocale } from '../../../lib/i18n'; -import { SUPPLEMENTAL_MESSAGES } from '../../../lib/i18n-runtime-messages'; - -export async function GET(_: Request, context: { params: Promise<{ lang: string }> }) { - const { lang } = await context.params; - const locale = normalizeLocale(lang); - const fallbackMessages = locale.startsWith('zh') ? SUPPLEMENTAL_MESSAGES['zh-CN'] : SUPPLEMENTAL_MESSAGES['en-US']; - const supplementalMessages = SUPPLEMENTAL_MESSAGES[locale] || fallbackMessages || {}; - - return NextResponse.json(supplementalMessages); -} diff --git a/web-next/app/incidents/incidents-page.tsx b/web-next/app/incidents/incidents-page.tsx deleted file mode 100644 index 7efc814f49..0000000000 --- a/web-next/app/incidents/incidents-page.tsx +++ /dev/null @@ -1,164 +0,0 @@ -'use client'; - -import React, { useCallback, useMemo, useState } from 'react'; -import { HzIncidentWorkbench } from '@hertzbeat/ui'; -import { ClientWorkbench } from '../../components/workbench/client-workbench'; -import { useI18n } from '../../components/providers/i18n-provider'; -import { apiMessageGet, apiMessagePut } from '../../lib/api-client'; -import { hzOpsCatalogVisual } from '../../lib/hz-ops-visual'; -import { formatTime } from '../../lib/format'; -import { - INCIDENT_WORKBENCH_DEFAULT_QUERY, - type IncidentTransitionState, - loadIncidentWorkbenchData, - transitionIncidentStatus, - type IncidentWorkbenchData -} from '../../lib/incidents-surface/controller'; -import type { StatusIncidentListQuery } from '../../lib/setting-status/controller'; - -const INCIDENT_WORKBENCH_SETTLED_CACHE_TTL_MS = 10_000; -const INCIDENT_TRANSITION_STATES: IncidentTransitionState[] = [1, 2, 3]; - -function IncidentWorkbenchSurface({ - state, - onTransition -}: { - state: IncidentWorkbenchData; - onTransition: (incident: NonNullable, nextState: IncidentTransitionState) => Promise; -}) { - const coldOpsVisual = hzOpsCatalogVisual; - const { t } = useI18n(); - const [transitionBusy, setTransitionBusy] = useState(null); - const transitionDisabled = state.transitionState !== 'ready' || !state.selectedIncident || transitionBusy != null; - - async function handleTransition(nextState: IncidentTransitionState) { - if (!state.selectedIncident || transitionBusy != null) { - return; - } - setTransitionBusy(nextState); - try { - await onTransition(state.selectedIncident, nextState); - } finally { - setTransitionBusy(null); - } - } - - return ( -
-
- ({ - id: item.id || `incident-timeline-${index + 1}`, - title: item.title, - copy: item.copy, - meta: item.meta, - tone: item.tone || (index === 0 ? 'warning' : 'info') - }))} - ownership={state.ownershipRows.map((item, index) => ({ - id: item.id || `incident-owner-${index + 1}`, - owner: item.owner, - queue: item.queue, - copy: item.copy, - meta: item.meta, - tone: item.tone || (index === 0 ? 'info' : 'neutral') - }))} - selectedIncidentId={state.selectedIncidentId} - emptyLabel={t('incidents.table.empty')} - actions={state.nextHops.map(action => ({ - label: action.label, - href: action.href, - variant: action.variant === 'default' ? 'default' : action.variant - }))} - transitionLabel={t('common.status')} - labels={{ - incident: t('incidents.table.incident'), - severity: t('incidents.table.severity'), - stage: t('incidents.table.stage'), - owner: t('incidents.table.owner'), - impact: t('incidents.table.impact'), - timeline: t('incidents.table.timeline'), - ownership: t('incidents.table.ownership') - }} - transitionActions={INCIDENT_TRANSITION_STATES.map(nextState => ({ - id: `state-${nextState}`, - label: t(`status.incident.state.${nextState}`), - state: nextState, - variant: nextState === 3 ? 'primary' : 'default', - disabled: transitionDisabled, - pending: transitionBusy === nextState, - onClick: () => { - void handleTransition(nextState); - } - }))} - /> -
-
- ); -} - -export default function IncidentsPage({ initialQuery = INCIDENT_WORKBENCH_DEFAULT_QUERY }: { initialQuery?: StatusIncidentListQuery } = {}) { - const { t } = useI18n(); - const [refreshTick, setRefreshTick] = useState(0); - const query = useMemo( - () => ({ - search: initialQuery.search || '', - pageIndex: initialQuery.pageIndex ?? INCIDENT_WORKBENCH_DEFAULT_QUERY.pageIndex, - pageSize: initialQuery.pageSize ?? INCIDENT_WORKBENCH_DEFAULT_QUERY.pageSize - }), - [initialQuery.pageIndex, initialQuery.pageSize, initialQuery.search] - ); - const incidentWorkbenchCacheKey = useMemo( - () => ['incident-workbench', query.search, query.pageIndex, query.pageSize, refreshTick].join(':'), - [query, refreshTick] - ); - const load = useCallback(() => { - void refreshTick; - return loadIncidentWorkbenchData(apiMessageGet, t, formatTime, query); - }, [query, refreshTick, t]); - const handleTransition = useCallback(async (incident: NonNullable, nextState: IncidentTransitionState) => { - await transitionIncidentStatus( - apiMessagePut, - incident, - nextState, - `${t(`status.incident.state.${nextState}`)} · /incidents` - ); - setRefreshTick(value => value + 1); - }, [t]); - - return ( - - {state => } - - ); -} diff --git a/web-next/app/incidents/page.test.tsx b/web-next/app/incidents/page.test.tsx deleted file mode 100644 index 48f8542515..0000000000 --- a/web-next/app/incidents/page.test.tsx +++ /dev/null @@ -1,265 +0,0 @@ -import { readFileSync } from 'node:fs'; -import { resolve } from 'node:path'; -import React from 'react'; -import { renderToStaticMarkup } from 'react-dom/server'; -import { describe, expect, it, vi } from 'vitest'; -import { createTranslatorMock } from '../../test/i18n-test-helper'; - -const t = createTranslatorMock({ locale: 'zh-CN' }); -const loadState = vi.hoisted(() => ({ - lastLoad: null as null | (() => Promise) -})); -const apiMessageGet = vi.hoisted(() => vi.fn()); -const apiMessagePut = vi.hoisted(() => vi.fn()); - -vi.mock('../../components/providers/i18n-provider', () => ({ - useI18n: () => ({ t }) -})); - -vi.mock('../../components/workbench/client-workbench', () => ({ - ClientWorkbench: ({ children, load, loadingCopy, cacheKey, cacheSettledTtlMs }: any) => { - loadState.lastLoad = load; - return ( -
- {children({ - apiState: 'ready', - apiSource: 'status-page-incident-list', - detailState: 'ready', - detailSource: 'status-page-incident-detail', - detailId: '42', - queryLabel: '/status/page/incident?pageIndex=0&pageSize=8', - title: 'Incidents', - subtitle: 'Align response timeline, owners, and evidence entry points with the OTLP cold baseline.', - kicker: 'Incident response desk', - metrics: [ - { label: 'Open incidents', value: '1', tone: 'warning' }, - { label: 'Critical incidents', value: '1', tone: 'critical' }, - { label: 'Mitigating', value: '1', tone: 'info' }, - { label: 'Ownership queues', value: '1', tone: 'info' } - ], - incidents: [ - { - id: '42', - title: 'API latency incident', - severity: 'critical', - severityLabel: 'Critical', - stage: 'Investigating', - service: 'api-gateway', - owner: 'platform-oncall', - openedAt: '2026-04-10 18:00:00', - blastRadius: '1 component' - } - ], - timelineRows: [ - { - id: 'incident-timeline-42-8', - title: '2026-04-10 18:05:00 · Identified', - copy: 'Rollback started', - meta: 'API latency incident', - tone: 'warning' - } - ], - ownershipRows: [ - { - id: 'incident-owner-42', - owner: 'platform-oncall', - queue: 'Investigating', - copy: 'API latency incident', - meta: '1 component · 2026-04-10 18:00:00', - tone: 'critical' - } - ], - nextHops: [ - { label: 'Open overview', href: '/overview', variant: 'subtle' }, - { label: 'View logs', href: '/log/manage', variant: 'default' } - ], - selectedIncidentId: '42', - selectedIncident: { - id: 42, - name: 'API latency incident', - state: 1, - contents: [] - }, - transitionState: 'ready', - totalElements: 1 - })} -
- ); - } -})); - -vi.mock('../../lib/api-client', () => ({ - apiMessageGet, - apiMessagePut -})); - -describe('incidents page', () => { - it('renders the API-backed UI Lab shared incident workbench instead of the placeholder entry shell', async () => { - loadState.lastLoad = null; - apiMessageGet.mockReset(); - apiMessagePut.mockReset(); - apiMessageGet - .mockResolvedValueOnce({ - content: [ - { - id: 42, - title: 'API latency incident', - state: 0, - startTime: 1712730000000, - creator: 'platform-oncall', - components: [{ id: 7, name: 'api-gateway' }], - contents: [] - } - ], - totalElements: 1, - pageIndex: 0, - pageSize: 8 - }) - .mockResolvedValueOnce({ - id: 42, - name: 'API latency incident details', - state: 1, - startTime: 1712730000000, - modifier: 'platform-oncall', - components: [{ id: 7, name: 'api-gateway' }], - contents: [{ id: 8, state: 1, message: 'Detail loaded', timestamp: 1712730300000 }] - }); - const routeSource = readFileSync(resolve(process.cwd(), 'app/incidents/page.tsx'), 'utf8'); - const source = readFileSync(resolve(process.cwd(), 'app/incidents/incidents-page.tsx'), 'utf8'); - const { default: IncidentsPage } = await import('./page'); - const html = renderToStaticMarkup(await IncidentsPage({})); - const lastLoad = loadState.lastLoad as (() => Promise) | null; - await lastLoad?.(); - - expect(routeSource).toContain("import IncidentsPage from './incidents-page'"); - expect(routeSource).toContain('readIncidentWorkbenchQuery'); - expect(routeSource).toContain('const resolvedSearchParams = await searchParams'); - expect(routeSource).toContain('return '); - expect(html).toContain('data-client-workbench="true"'); - expect(html).toContain(`data-loading-copy="${t('common.workbench.loading.copy')}"`); - expect(html).toContain('data-cache-key="incident-workbench::0:8:0"'); - expect(html).toContain('data-cache-ttl="10000"'); - expect(html).toContain('data-incidents-route="incident-workbench-api-ui-lab-shared"'); - expect(html).toContain('data-incidents-style-baseline="hertzbeat-ui-matte"'); - expect(html).toContain('data-incidents-workbench="hertzbeat-ui"'); - expect(html).toContain('data-incidents-api-owner="status-page-incident-api"'); - expect(html).toContain('data-incidents-api-source="status-page-incident-list"'); - expect(html).toContain('data-incidents-api-state="ready"'); - expect(html).toContain('data-incidents-api-total="1"'); - expect(html).toContain('data-incidents-detail-owner="status-page-incident-detail-api"'); - expect(html).toContain('data-incidents-detail-source="status-page-incident-detail"'); - expect(html).toContain('data-incidents-detail-state="ready"'); - expect(html).toContain('data-incidents-detail-id="42"'); - expect(html).toContain('data-incidents-transition-owner="status-page-incident-put-api"'); - expect(html).toContain('data-incidents-transition-source="/status/page/incident"'); - expect(html).toContain('data-incidents-transition-state="ready"'); - expect(html).toContain('data-incidents-transition-pending="none"'); - expect(html).toContain('data-incidents-query-contract="angular-search-pagination"'); - expect(html).toContain('data-incidents-query-label="/status/page/incident?pageIndex=0&pageSize=8"'); - expect(html).toContain('data-incidents-shared-workbench="hertzbeat-ui"'); - expect(html).toContain('data-hz-ui="incident-workbench"'); - expect(html).toContain('data-hz-incident-workbench-owner="hertzbeat-ui-incident-workbench"'); - expect(html).toContain('data-hz-incident-workbench-density="operator-compact"'); - expect(html).toContain('data-hz-incident-workbench-style="hertzbeat-ui-matte-hard-edge"'); - expect(html).toContain('data-hz-incident-workbench-table="shared"'); - expect(html).toContain('data-hz-incident-workbench-query="/status/page/incident?pageIndex=0&pageSize=8"'); - expect(html).toContain('data-hz-incident-transition-actions="shared"'); - expect(html).toContain('data-hz-incident-transition-owner="hertzbeat-ui-incident-transition-actions"'); - expect(html).toContain('data-hz-incident-transition-action="state-1"'); - expect(html).toContain('data-hz-incident-transition-action="state-2"'); - expect(html).toContain('data-hz-incident-transition-action="state-3"'); - expect(html).toContain('data-hz-incident-transition-disabled="false"'); - expect(html).toContain('data-hz-ui="data-table"'); - expect(html).toContain('data-hz-incident-timeline-item="incident-timeline-42-8"'); - expect(html).toContain('data-hz-incident-owner-item="incident-owner-42"'); - expect(html).toContain('Incidents'); - expect(html).toContain('API latency incident'); - expect(html).toContain('data-hz-row-selected="true"'); - expect(apiMessageGet).toHaveBeenNthCalledWith(1, '/status/page/incident?pageIndex=0&pageSize=8'); - expect(apiMessageGet).toHaveBeenNthCalledWith(2, '/status/page/incident/42'); - expect(html).not.toContain('angular-dark-ops-placeholder'); - expect(html).not.toContain('DARK OPS'); - expect(html).not.toContain('V1 SHELL IS LIVE'); - expect(html).not.toContain('Domain adapter comes next'); - expect(html).not.toContain('data-incidents-shell-panel="cold-ops-shell-panel"'); - expect(html).not.toContain('data-incidents-launch-checklist="cold-ops-static-rail"'); - expect(html).not.toContain('data-incidents-empty-state="cold-ops-domain-adapter"'); - expect(source).toContain('hzOpsCatalogVisual'); - expect(source).toContain('HzIncidentWorkbench'); - expect(source).toContain('ClientWorkbench'); - expect(source).toContain('loadIncidentWorkbenchData'); - expect(source).toContain('apiMessageGet'); - expect(source).toContain('apiMessagePut'); - expect(source).toContain('transitionIncidentStatus'); - expect(source).toContain("incident: t('incidents.table.incident')"); - expect(source).toContain("severity: t('incidents.table.severity')"); - expect(source).toContain("timeline: t('incidents.table.timeline')"); - expect(source).toContain("emptyLabel={t('incidents.table.empty')}"); - expect(source).toContain('data-incidents-detail-source'); - expect(source).toContain('data-incidents-transition-source'); - expect(source).toContain('data-incidents-query-contract="angular-search-pagination"'); - expect(source).not.toContain('rounded-[16px]'); - expect(source).not.toContain('rounded-[14px]'); - expect(source).not.toContain('#4f6cff'); - expect(source).not.toContain('#101c31'); - expect(html).not.toContain('incidents.subtitle'); - expect(html).not.toContain('data-summary-metric-grid'); - expect(source).not.toContain('WorkbenchPage'); - expect(source).not.toContain('StageSection'); - expect(source).not.toContain('SummaryMetricGrid'); - expect(source).not.toContain('DrawerSection'); - }); - - it('preserves the Angular incident search and server-side pagination read contract from route query params', async () => { - loadState.lastLoad = null; - apiMessageGet.mockReset(); - apiMessagePut.mockReset(); - apiMessageGet - .mockResolvedValueOnce({ - content: [ - { - id: 77, - name: 'API latency from query', - state: 2, - startTime: 1712730000000, - creator: 'platform-oncall', - components: [{ id: 9, name: 'checkout-api' }], - contents: [] - } - ], - totalElements: 1, - pageIndex: 2, - pageSize: 15 - }) - .mockResolvedValueOnce({ - id: 77, - name: 'API latency from query', - state: 2, - startTime: 1712730000000, - modifier: 'platform-oncall', - components: [{ id: 9, name: 'checkout-api' }], - contents: [{ id: 10, state: 2, message: 'query detail loaded', timestamp: 1712730300000 }] - }); - - const { default: IncidentsPage } = await import('./page'); - const html = renderToStaticMarkup(await IncidentsPage({ - searchParams: Promise.resolve({ - search: 'api latency', - pageIndex: '2', - pageSize: '15' - }) - })); - const lastLoad = loadState.lastLoad as (() => Promise) | null; - await lastLoad?.(); - - expect(html).toContain('data-cache-key="incident-workbench:api latency:2:15:0"'); - expect(html).toContain('data-incidents-query-contract="angular-search-pagination"'); - expect(apiMessageGet).toHaveBeenNthCalledWith(1, '/status/page/incident?pageIndex=2&pageSize=15&search=api+latency'); - expect(apiMessageGet).toHaveBeenNthCalledWith(2, '/status/page/incident/77'); - }); -}); diff --git a/web-next/app/incidents/page.tsx b/web-next/app/incidents/page.tsx deleted file mode 100644 index ff1a81d1d1..0000000000 --- a/web-next/app/incidents/page.tsx +++ /dev/null @@ -1,13 +0,0 @@ -import React from 'react'; -import IncidentsPage from './incidents-page'; -import { readIncidentWorkbenchQuery, type IncidentWorkbenchSearchParams } from '../../lib/incidents-surface/controller'; - -export default async function IncidentsRoutePage({ - searchParams -}: { - searchParams?: Promise; -}) { - const resolvedSearchParams = await searchParams; - const initialQuery = readIncidentWorkbenchQuery(resolvedSearchParams); - return ; -} diff --git a/web-next/app/ingestion/otlp/metrics/otlp-metrics-page.tsx b/web-next/app/ingestion/otlp/metrics/otlp-metrics-page.tsx deleted file mode 100644 index 3d2d99087c..0000000000 --- a/web-next/app/ingestion/otlp/metrics/otlp-metrics-page.tsx +++ /dev/null @@ -1,2917 +0,0 @@ -'use client'; - -import React, { useCallback, useEffect, useMemo, useState } from 'react'; -import Link from 'next/link'; -import { Ban, BarChart3, Check, Copy, Download, Filter, ListChecks, Pencil, Play, Replace, RotateCcw, Save, Search, Table2, Trash2, Workflow, X } from 'lucide-react'; -import { useRouter, useSearchParams } from 'next/navigation'; -import { HzActionGroup, HzAssistiveMarker, HzAttributeDiagnostics, HzButton, HzButtonIcon, HzButtonLink, HzChipGroup, HzCollapsibleSection, HzContextHandoff, HzControlStack, HzDataCellStack, HzDataCellText, HzDataMetaText, HzDataTable, HzDetailRows, HzDisabledActionShell, HzEmptyState, HzInput, HzPaginationBar, HzPanelHeader, HzPanelSection, HzPanelSurface, HzPanelTitleLabel, HzQueryActionGroup, HzSearchFieldFrame, HzSearchFieldIcon, HzSelect, HzSignalSummaryStrip, HzSignalWorkbenchShell, HzStateNotice, HzStatusBadge, HzTrendBar, HzTrendFrame, HzWorkbenchHeaderCopy, HzWorkbenchLayout } from '@hertzbeat/ui'; -import { EChartsPanel, type EChartsDataZoomRange } from '@/components/observability/echarts-panel'; -import { buildTimeRangeControlLabels, buildTimeRangePresetLabels, formatEpochMillisDraft, TimeRangeControl } from '@/components/observability/time-range-control'; -import { ClientWorkbench } from '@/components/workbench/client-workbench'; -import { useI18n } from '@/components/providers/i18n-provider'; -import { apiMessageGet } from '@/lib/api-client'; -import { copyTextToClipboard } from '@/lib/browser-clipboard'; -import { formatTime } from '@/lib/format'; -import { buildOtlpMetricsConsoleUrl, buildOtlpMetricsInventoryUrl, loadOtlpMetricsConsole, loadOtlpMetricsInventory, queryStateFromParams, type OtlpMetricsQueryState } from '@/lib/otlp-metrics/controller'; -import { buildOtlpMetricsCsv, buildOtlpMetricsExportFilename, buildOtlpMetricsJsonl, type OtlpMetricsExportFormat, type OtlpMetricsExportScope } from '@/lib/otlp-metrics/export'; -import { - createSignalDashboardPanelDraft, - applySignalDashboardPanelEditContext, - saveSignalDashboardPanelDraft, - type SignalDashboardPanelVisualization -} from '@/lib/signal-dashboard-panel-drafts'; -import { saveSignalDashboardPanelEditContext } from '@/lib/signal-dashboards'; -import { - buildSignalSavedViewKey, - deleteSignalSavedQueryView, - loadSignalSavedQueryViews, - saveSignalSavedQueryView, - type SignalSavedQueryView, - type SignalSavedQueryViewPersistenceMode -} from '@/lib/signal-saved-views'; -import { buildSignalEntityContextRows, isDashboardReturnContext, readEntityIdRouteParam, readEpochMillisRouteParam, readSignalPanelEditContext, readSignalRouteContext, type SignalPanelEditContext, type SignalRouteContext } from '@/lib/signal-route-context'; -import { resolveTimeContextBounds, sanitizeTimeContext, TIME_CONTEXT_PRESETS, type TimeContext } from '@/lib/time-context'; -import { - buildConsoleFacts, - buildConsoleMetrics, - buildContextRows, - buildMetricSeriesAttributionDiagnostics, - buildMetricSeriesContextRows, - buildMetricSeriesEvidenceRows, - buildMetricSeriesAttributeRows, - buildMetricSeriesLinkedRecordRows, - buildMetricSeriesRows, - buildMetricSeriesSampleRows, - buildMetricSeriesViews, - buildMetricInventorySourceRows, - buildMetricInventoryRows, - applyMetricsFormula, - buildMetricsChartOption, - buildMetricsDataZoomTimeContext, - buildMetricExpectedRangeConfig, - buildMetricTrendBars, - buildMetricThresholdConfig, - buildMetricsExplorerState, - buildMetricsHandoffLinks, - type OtlpMetricInventorySort, - type OtlpMetricInventoryRow, - type OtlpMetricSeriesView -} from '@/lib/otlp-metrics/view-model'; -import type { OtlpMetricsConsole, OtlpMetricsInventory } from '@/lib/types'; -import { buildOtlpMetricsRoute, hasMetricsDisplayReturnLabel } from './route-state'; - -type OtlpMetricsTranslate = ReturnType['t']; - -type MetricsSavedQueryView = SignalSavedQueryView; -type MetricsDashboardPanelDraftState = 'idle' | 'saving' | 'saved' | 'failed'; -type MetricAttributeOperator = 'filter' | 'contains' | 'not-contains' | 'in' | 'not-in' | 'exclude' | 'exists' | 'not-exists' | 'replace' | 'group'; -type OtlpMetricsWorkbenchData = OtlpMetricsConsole & { - inventory?: OtlpMetricsInventory | null; -}; -type MetricInventoryTableRow = OtlpMetricInventoryRow & { - rowKey: string; - series?: OtlpMetricSeriesView | null; -}; - -const METRICS_SAVED_QUERY_VIEW_STORAGE_KEY = 'hertzbeat.otlp-metrics.saved-query-views'; -const METRICS_SAVED_QUERY_VIEW_LIMIT = 5; -const METRICS_SAVED_QUERY_VIEW_PERSISTENCE_OWNER: Record = { - 'server-first': 'hertzbeat-api', - 'local-fallback': 'browser-local-storage' -}; -const DEFAULT_METRIC_INVENTORY_PAGE_SIZE = '10'; -const DEFAULT_METRIC_INVENTORY_PAGE_INDEX = '0'; -const METRIC_INVENTORY_PAGE_SIZE_OPTIONS = ['5', '10', '20', '50'] as const; -const METRICS_EXPORT_SCOPES: OtlpMetricsExportScope[] = ['all', 'selected']; -const METRIC_ATTRIBUTE_OPERATORS: MetricAttributeOperator[] = ['filter', 'contains', 'not-contains', 'in', 'not-in', 'exclude', 'exists', 'not-exists', 'replace', 'group']; - -function buildMetricAttributeOperatorOptions(t: OtlpMetricsTranslate) { - return METRIC_ATTRIBUTE_OPERATORS.map(operator => ({ - value: operator, - label: t(`otlp.metrics.attributes.operator.${operator}` as const) - })); -} - -function metricAttributeOperatorDataAttributes(operator: MetricAttributeOperator, name: string) { - const base = { - 'data-otlp-metrics-attribute-operator-option': operator, - 'data-otlp-metrics-attribute-operator-name': name - }; - switch (operator) { - case 'filter': - return { ...base, 'data-otlp-metrics-attribute-filter-action': name, 'data-otlp-metrics-attribute-filter-action-owner': 'hertzbeat-ui-select-menu-option' }; - case 'contains': - return { ...base, 'data-otlp-metrics-attribute-contains-action': name, 'data-otlp-metrics-attribute-contains-action-owner': 'hertzbeat-ui-select-menu-option' }; - case 'not-contains': - return { ...base, 'data-otlp-metrics-attribute-not-contains-action': name, 'data-otlp-metrics-attribute-not-contains-action-owner': 'hertzbeat-ui-select-menu-option' }; - case 'in': - return { ...base, 'data-otlp-metrics-attribute-in-action': name, 'data-otlp-metrics-attribute-in-action-owner': 'hertzbeat-ui-select-menu-option' }; - case 'not-in': - return { ...base, 'data-otlp-metrics-attribute-not-in-action': name, 'data-otlp-metrics-attribute-not-in-action-owner': 'hertzbeat-ui-select-menu-option' }; - case 'exclude': - return { ...base, 'data-otlp-metrics-attribute-filter-out-action': name, 'data-otlp-metrics-attribute-filter-out-action-owner': 'hertzbeat-ui-select-menu-option' }; - case 'exists': - return { ...base, 'data-otlp-metrics-attribute-exists-action': name, 'data-otlp-metrics-attribute-exists-action-owner': 'hertzbeat-ui-select-menu-option' }; - case 'not-exists': - return { ...base, 'data-otlp-metrics-attribute-not-exists-action': name, 'data-otlp-metrics-attribute-not-exists-action-owner': 'hertzbeat-ui-select-menu-option' }; - case 'replace': - return { ...base, 'data-otlp-metrics-attribute-replace-action': name, 'data-otlp-metrics-attribute-replace-action-owner': 'hertzbeat-ui-select-menu-option' }; - case 'group': - return { ...base, 'data-otlp-metrics-attribute-group-action': name, 'data-otlp-metrics-attribute-group-action-owner': 'hertzbeat-ui-select-menu-option' }; - } -} - -function resolveMetricInventoryPageSize(value?: string) { - return METRIC_INVENTORY_PAGE_SIZE_OPTIONS.find(option => option === value) || DEFAULT_METRIC_INVENTORY_PAGE_SIZE; -} - -function resolveMetricInventoryPageIndex(value?: string) { - const trimmed = value?.trim(); - if (!trimmed || !/^\d+$/.test(trimmed)) return DEFAULT_METRIC_INVENTORY_PAGE_INDEX; - return String(Math.max(0, Number(trimmed))); -} - -function latestSeriesTimestamp(data: OtlpMetricsConsole) { - const timestamps = (data.results?.frames || []) - .flatMap(frame => frame.data || []) - .map(row => Number(row?.[0])) - .filter(Number.isFinite); - return timestamps.length ? Math.max(...timestamps) : data.stats?.latestObservedAt ?? null; -} - -function routeEpochMillisValue(value?: string) { - const epochMillis = readEpochMillisRouteParam(value); - return epochMillis ? Number(epochMillis) : undefined; -} - -function routeEntityIdValue(value?: string) { - const entityId = readEntityIdRouteParam(value); - return entityId ? Number(entityId) : undefined; -} - -function firstRouteText(...values: Array) { - return values.find((value): value is string => value != null && value.trim() !== '')?.trim(); -} - -function escapeMetricFilterValue(value: string) { - return value.replace(/\\/g, '\\\\').replace(/"/g, '\\"'); -} - -function buildMetricAttributeFilterExpression(name: string, value: string) { - const trimmedName = name.trim(); - const trimmedValue = value.trim(); - if (!trimmedName || !trimmedValue) return null; - return `${trimmedName}="${escapeMetricFilterValue(trimmedValue)}"`; -} - -function buildMetricAttributeExcludeFilterExpression(name: string, value: string) { - const trimmedName = name.trim(); - const trimmedValue = value.trim(); - if (!trimmedName || !trimmedValue) return null; - return `${trimmedName}!="${escapeMetricFilterValue(trimmedValue)}"`; -} - -function buildMetricAttributeContainsFilterExpression(name: string, value: string) { - const trimmedName = name.trim(); - const trimmedValue = value.trim(); - if (!trimmedName || !trimmedValue) return null; - return `${trimmedName} CONTAINS ${escapeMetricFilterValue(trimmedValue)}`; -} - -function buildMetricAttributeNotContainsFilterExpression(name: string, value: string) { - const trimmedName = name.trim(); - const trimmedValue = value.trim(); - if (!trimmedName || !trimmedValue) return null; - return `${trimmedName} NOT CONTAINS ${escapeMetricFilterValue(trimmedValue)}`; -} - -function buildMetricAttributeInFilterExpression(name: string, value: string) { - const trimmedName = name.trim(); - const trimmedValue = value.trim(); - if (!trimmedName || !trimmedValue) return null; - return `${trimmedName} IN ("${escapeMetricFilterValue(trimmedValue)}")`; -} - -function buildMetricAttributeNotInFilterExpression(name: string, value: string) { - const trimmedName = name.trim(); - const trimmedValue = value.trim(); - if (!trimmedName || !trimmedValue) return null; - return `${trimmedName} NOT IN ("${escapeMetricFilterValue(trimmedValue)}")`; -} - -function buildMetricAttributeExistsFilterExpression(name: string) { - const trimmedName = name.trim(); - if (!trimmedName) return null; - return `${trimmedName} EXISTS`; -} - -function buildMetricAttributeNotExistsFilterExpression(name: string) { - const trimmedName = name.trim(); - if (!trimmedName) return null; - return `${trimmedName} NOT EXISTS`; -} - -function mergeMetricFilterExpression(currentFilter: string, expression: string) { - const trimmedFilter = currentFilter.trim(); - if (!trimmedFilter) return expression; - const compactFilter = trimmedFilter.replace(/\s+/g, ''); - const compactExpression = expression.replace(/\s+/g, ''); - if (compactFilter.includes(compactExpression)) { - return trimmedFilter; - } - return `${trimmedFilter} and ${expression}`; -} - -function normalizeMetricGroupByLabel(label: string) { - const normalized = label - .trim() - .replace(/[^A-Za-z0-9_:]/g, '_') - .replace(/_+/g, '_'); - if (!normalized) return null; - return /^\d/.test(normalized) ? `_${normalized}` : normalized; -} - -function metricGroupByLabelCandidates(groupBy: string) { - const normalized = groupBy.trim(); - if (!normalized) return []; - const candidates = [normalized]; - if (normalized === 'service_name') { - candidates.push('service.name'); - } else if (normalized === 'service_namespace') { - candidates.push('service.namespace'); - } else if (normalized === 'deployment_environment_name') { - candidates.push('deployment.environment.name'); - } - return candidates; -} - -function buildMetricGroupValueFilter(groupBy: string, series?: OtlpMetricSeriesView | null) { - const normalizedGroupBy = groupBy.trim(); - if (!normalizedGroupBy || !series) return null; - const value = firstRouteText(...metricGroupByLabelCandidates(normalizedGroupBy).map(candidate => series.labels[candidate])); - if (!value) return null; - const expression = buildMetricAttributeFilterExpression(normalizedGroupBy, value); - if (!expression) return null; - return { groupBy: normalizedGroupBy, value, expression }; -} - -function buildMetricSeriesServiceFilter(series?: OtlpMetricSeriesView | null) { - if (!series) return null; - const value = firstRouteText(series.labels['service.name'], series.labels.service_name, series.labels.serviceName); - return value ? { value } : null; -} - -function readMetricSeriesLabel(series: OtlpMetricSeriesView, ...keys: string[]) { - return firstRouteText(...keys.map(key => series.labels[key])); -} - -function findMetricSeriesForRouteTrace( - seriesList: OtlpMetricSeriesView[], - query: OtlpMetricsQueryState, - routeContext: SignalRouteContext -) { - const traceId = firstRouteText(query.traceId, routeContext.traceId); - const spanId = firstRouteText(query.spanId, routeContext.spanId); - if (!traceId) return null; - return seriesList.find(series => { - const seriesTraceId = readMetricSeriesLabel(series, 'traceId', 'trace_id', 'trace.id', 'trace_id_hex'); - const seriesSpanId = readMetricSeriesLabel(series, 'spanId', 'span_id', 'span.id', 'span_id_hex'); - if (seriesTraceId !== traceId) return false; - return spanId ? seriesSpanId === spanId : true; - }) || null; -} - -function buildMetricSeriesRouteContext(series: OtlpMetricSeriesView): Partial { - return { - entityId: readEntityIdRouteParam(readMetricSeriesLabel(series, 'hertzbeat.entity_id', 'hertzbeat_entity_id', 'entity.id', 'entity_id')), - entityName: readMetricSeriesLabel(series, 'hertzbeat.entity_name', 'hertzbeat_entity_name', 'entity.name', 'entity_name'), - serviceName: readMetricSeriesLabel(series, 'service.name', 'service_name', 'serviceName'), - serviceNamespace: readMetricSeriesLabel(series, 'service.namespace', 'service_namespace', 'serviceNamespace'), - environment: readMetricSeriesLabel(series, 'deployment.environment.name', 'deployment_environment_name', 'deployment_environment', 'environment'), - traceId: readMetricSeriesLabel(series, 'traceId', 'trace_id', 'trace.id', 'trace_id_hex'), - spanId: readMetricSeriesLabel(series, 'spanId', 'span_id', 'span.id', 'span_id_hex'), - collector: readMetricSeriesLabel(series, 'hertzbeat.collector', 'hertzbeat_collector', 'collector'), - template: readMetricSeriesLabel(series, 'hertzbeat.template', 'hertzbeat_template', 'hertzbeat.monitor_template', 'hertzbeat_monitor_template', 'template') - }; -} - -function isMetricsSavedQueryView(value: unknown): value is MetricsSavedQueryView { - if (!value || typeof value !== 'object') return false; - const candidate = value as Partial; - return ( - typeof candidate.id === 'string' && - typeof candidate.label === 'string' && - typeof candidate.description === 'string' && - typeof candidate.route === 'string' && - candidate.route.startsWith('/ingestion/otlp/metrics') && - typeof candidate.createdAt === 'number' - ); -} - -function readMetricsSavedQueryViews(): MetricsSavedQueryView[] { - if (typeof window === 'undefined') return []; - try { - const raw = window.localStorage.getItem(METRICS_SAVED_QUERY_VIEW_STORAGE_KEY); - const parsed = raw ? JSON.parse(raw) : []; - return Array.isArray(parsed) ? parsed.filter(isMetricsSavedQueryView).slice(0, METRICS_SAVED_QUERY_VIEW_LIMIT) : []; - } catch { - return []; - } -} - -function writeMetricsSavedQueryViews(views: MetricsSavedQueryView[]) { - if (typeof window === 'undefined') return; - try { - window.localStorage.setItem(METRICS_SAVED_QUERY_VIEW_STORAGE_KEY, JSON.stringify(views.slice(0, METRICS_SAVED_QUERY_VIEW_LIMIT))); - } catch { - // Ignore quota or privacy-mode failures; the current route remains shareable. - } -} - -function compactMetricsSavedViewValue(value: string | undefined, limit = 32) { - const trimmed = value?.trim(); - if (!trimmed) return ''; - return trimmed.length > limit ? `${trimmed.slice(0, limit - 1)}...` : trimmed; -} - -function buildMetricsSavedViewDescription(query: OtlpMetricsQueryState, routeContext: SignalRouteContext, t: OtlpMetricsTranslate) { - const serviceName = firstRouteText(query.serviceName, routeContext.serviceName); - const environment = firstRouteText(query.environment, routeContext.environment); - const parts = [ - query.query?.trim() ? `${t('otlp.metrics.saved-view.field.query')}: ${compactMetricsSavedViewValue(query.query)}` : '', - query.filter?.trim() ? `${t('otlp.metrics.saved-view.field.filter')}: ${compactMetricsSavedViewValue(query.filter)}` : '', - query.aggregation?.trim() ? `${t('otlp.metrics.saved-view.field.aggregation')}: ${compactMetricsSavedViewValue(query.aggregation)}` : '', - query.temporalAggregation?.trim() ? `${t('otlp.metrics.saved-view.field.temporal')}: ${compactMetricsSavedViewValue(query.temporalAggregation)}` : '', - query.groupBy?.trim() ? `${t('otlp.metrics.saved-view.field.group-by')}: ${compactMetricsSavedViewValue(query.groupBy)}` : '', - query.legendFormat?.trim() ? `${t('otlp.metrics.saved-view.field.legend')}: ${compactMetricsSavedViewValue(query.legendFormat)}` : '', - query.formula?.trim() ? `${t('otlp.metrics.saved-view.field.formula')}: ${compactMetricsSavedViewValue(query.formula)}` : '', - query.step?.trim() ? `${t('otlp.metrics.saved-view.field.step')}: ${compactMetricsSavedViewValue(query.step)}` : '', - query.limit?.trim() ? `${t('otlp.metrics.saved-view.field.limit')}: ${compactMetricsSavedViewValue(query.limit)}` : '', - query.series?.trim() ? `${t('otlp.metrics.saved-view.field.series')}: ${compactMetricsSavedViewValue(query.series)}` : '', - query.inspector === 'table' ? `${t('otlp.metrics.saved-view.field.inspector')}: ${query.inspector}` : '', - serviceName ? `${t('otlp.metrics.saved-view.field.service')}: ${compactMetricsSavedViewValue(serviceName)}` : '', - query.entityId?.trim() ? `${t('otlp.metrics.saved-view.field.entity')}: ${compactMetricsSavedViewValue(query.entityId)}` : '', - environment ? `${t('otlp.metrics.saved-view.field.environment')}: ${compactMetricsSavedViewValue(environment)}` : '', - query.warningThreshold?.trim() ? `${t('otlp.metrics.saved-view.field.warning')}: ${compactMetricsSavedViewValue(query.warningThreshold)}` : '', - query.criticalThreshold?.trim() ? `${t('otlp.metrics.saved-view.field.critical')}: ${compactMetricsSavedViewValue(query.criticalThreshold)}` : '', - query.expectedRange === 'on' ? `${t('otlp.metrics.saved-view.field.expected-range')}: on` : '', - query.inventorySearch?.trim() ? `${t('otlp.metrics.saved-view.field.inventory-search')}: ${compactMetricsSavedViewValue(query.inventorySearch)}` : '', - query.inventorySort?.trim() ? `${t('otlp.metrics.saved-view.field.inventory-sort')}: ${compactMetricsSavedViewValue(query.inventorySort)}` : '', - query.inventoryPageSize?.trim() ? `${t('otlp.metrics.saved-view.field.inventory-page-size')}: ${compactMetricsSavedViewValue(query.inventoryPageSize)}` : '', - query.inventoryPageIndex?.trim() ? `${t('otlp.metrics.saved-view.field.inventory-page-index')}: ${compactMetricsSavedViewValue(query.inventoryPageIndex)}` : '', - query.seriesAttributeSearch?.trim() ? `${t('otlp.metrics.saved-view.field.series-attribute-search')}: ${compactMetricsSavedViewValue(query.seriesAttributeSearch)}` : '' - ].filter(Boolean); - return parts.join(' | ') || t('otlp.metrics.saved-view.description.empty'); -} - -function buildMetricsSavedViewLabel(query: OtlpMetricsQueryState, routeContext: SignalRouteContext, t: OtlpMetricsTranslate) { - return ( - compactMetricsSavedViewValue(query.query, 42) - || compactMetricsSavedViewValue(query.series, 42) - || compactMetricsSavedViewValue(query.serviceName || routeContext.serviceName, 42) - || compactMetricsSavedViewValue(query.groupBy, 42) - || t('otlp.metrics.saved-view.current-label') - ); -} - -function createMetricsSavedQueryView( - query: OtlpMetricsQueryState, - routeContext: SignalRouteContext, - route: string, - t: OtlpMetricsTranslate -): MetricsSavedQueryView { - const now = Date.now(); - return { - id: buildSignalSavedViewKey('metrics', route), - label: buildMetricsSavedViewLabel(query, routeContext, t), - description: buildMetricsSavedViewDescription(query, routeContext, t), - route, - createdAt: now - }; -} - -function parseRelatedMetricResourceMatch(value: string | undefined) { - if (!value?.trim()) return []; - try { - const parsed = JSON.parse(value); - if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return []; - return Object.entries(parsed as Record) - .map(([name, rawValue]) => ({ name: name.trim(), value: typeof rawValue === 'string' ? rawValue.trim() : '' })) - .filter(row => row.name && row.value) - .sort((left, right) => left.name.localeCompare(right.name)); - } catch { - return []; - } -} - -function buildRelatedMetricCandidateRows(query: OtlpMetricsQueryState, t: OtlpMetricsTranslate) { - const matchedLabels = (query.relatedMetricMatchedLabels || '') - .split(',') - .map(label => label.trim()) - .filter(Boolean); - const resourceMatchRows = parseRelatedMetricResourceMatch(query.relatedMetricResourceMatch); - const rows = [ - query.relatedMetricSource || query.relatedMetricFamily - ? { - key: 'candidate-source', - title: t('otlp.metrics.related-candidate.source'), - copy: [query.relatedMetricSource, query.relatedMetricFamily].filter(Boolean).join(' / ') || '-', - meta: query.relatedMetricReason || t('otlp.metrics.related-candidate.meta') - } - : null, - matchedLabels.length - ? { - key: 'candidate-labels', - title: t('otlp.metrics.related-candidate.matched-labels'), - copy: matchedLabels.join(', '), - meta: t('otlp.metrics.related-candidate.labels-meta') - } - : null, - ...resourceMatchRows.map(row => ({ - key: `candidate-resource-${row.name}`, - title: row.name, - copy: row.value, - meta: t('otlp.metrics.related-candidate.resource-meta') - })) - ].filter((row): row is { key: string; title: string; copy: string; meta: string } => row !== null); - return rows; -} - -function resolveMetricsDashboardPanelVisualization( - inspector: NonNullable -): SignalDashboardPanelVisualization { - return inspector === 'table' ? 'table' : 'graph'; -} - -function appendMetricsPanelEditContext(route: string, panelEditContext: SignalPanelEditContext | null) { - if (!panelEditContext) return route; - const url = new URL(route || '/ingestion/otlp/metrics', 'http://localhost'); - url.searchParams.set('intent', panelEditContext.intent); - if (panelEditContext.dashboardKey) url.searchParams.set('dashboardKey', panelEditContext.dashboardKey); - if (panelEditContext.panelId) url.searchParams.set('panelId', panelEditContext.panelId); - if (panelEditContext.draftKey) url.searchParams.set('draftKey', panelEditContext.draftKey); - if (panelEditContext.returnTo) url.searchParams.set('returnTo', panelEditContext.returnTo); - if (panelEditContext.returnLabel) url.searchParams.set('returnLabel', panelEditContext.returnLabel); - return `${url.pathname}${url.search}${url.hash}`; -} - -export default function OtlpMetricsPage() { - const { t } = useI18n(); - const router = useRouter(); - const searchParams = useSearchParams(); - const metricsTimeRangeLabels = useMemo(() => buildTimeRangePresetLabels(t), [t]); - const metricsTimeRangePresets = useMemo( - () => TIME_CONTEXT_PRESETS.map(preset => ({ value: preset.value, label: metricsTimeRangeLabels[preset.value] || preset.value })), - [metricsTimeRangeLabels] - ); - const query = useMemo(() => queryStateFromParams(searchParams), [searchParams]); - const metricsInspectorView = query.inspector || 'graph'; - const routeContext = useMemo(() => readSignalRouteContext(searchParams), [searchParams]); - const panelEditContext = useMemo(() => readSignalPanelEditContext(searchParams), [searchParams]); - const replaceMetricsHref = useCallback((route: string) => { - router.replace(appendMetricsPanelEditContext(route, panelEditContext)); - }, [panelEditContext, router]); - const currentMetricsRoute = useMemo(() => buildOtlpMetricsRoute(query), [query]); - const workbenchCacheKey = useMemo( - () => `${buildOtlpMetricsConsoleUrl(query)}|${buildOtlpMetricsInventoryUrl(query)}`, - [query] - ); - const load = useCallback(async (): Promise => { - const [consoleData, inventory] = await Promise.all([ - loadOtlpMetricsConsole(apiMessageGet, query), - loadOtlpMetricsInventory(apiMessageGet, query).catch(() => null) - ]); - return { ...consoleData, inventory }; - }, [query]); - const initialDraft = useMemo(() => ({ - query: query.query || '', - filter: query.filter || '', - aggregation: query.aggregation || 'avg', - temporalAggregation: query.temporalAggregation || 'raw', - groupBy: query.groupBy || '', - legendFormat: query.legendFormat || '', - formula: query.formula || '', - step: query.step || '', - limit: query.limit || '', - warningThreshold: query.warningThreshold || '', - criticalThreshold: query.criticalThreshold || '', - timeRange: query.timeRange || 'last-30m', - from: query.from || '', - to: query.to || '', - start: query.start || '', - end: query.end || '', - refresh: query.refresh || '', - live: query.live || '', - tz: query.tz || '', - timezone: query.timezone || '', - serviceName: query.serviceName || '', - serviceNamespace: query.serviceNamespace || '', - environment: query.environment || '', - traceId: query.traceId || '', - spanId: query.spanId || '' - }), [ - query.aggregation, - query.environment, - query.filter, - query.groupBy, - query.legendFormat, - query.formula, - query.limit, - query.query, - query.end, - query.from, - query.live, - query.refresh, - query.serviceName, - query.serviceNamespace, - query.spanId, - query.start, - query.timeRange, - query.temporalAggregation, - query.timezone, - query.to, - query.traceId, - query.tz, - query.step, - query.warningThreshold, - query.criticalThreshold - ]); - const [draft, setDraft] = useState(initialDraft); - const [selectedSeriesKey, setSelectedSeriesKey] = useState(null); - const metricInventorySearch = query.inventorySearch || ''; - const metricInventorySort: OtlpMetricInventorySort = query.inventorySort || 'name'; - const metricInventoryPageSize = resolveMetricInventoryPageSize(query.inventoryPageSize); - const metricInventoryPageIndex = resolveMetricInventoryPageIndex(query.inventoryPageIndex); - const metricAttributeSearch = query.seriesAttributeSearch || ''; - const [savedQueryViews, setSavedQueryViews] = useState(readMetricsSavedQueryViews); - const [savedQueryViewPersistenceMode, setSavedQueryViewPersistenceMode] = useState('local-fallback'); - const [editingSavedQueryViewId, setEditingSavedQueryViewId] = useState(null); - const [savedQueryViewLabelDraft, setSavedQueryViewLabelDraft] = useState(''); - const [metricsChartZoomRange, setMetricsChartZoomRange] = useState(null); - const [metricsExportFormat, setMetricsExportFormat] = useState('csv'); - const [metricsExportScope, setMetricsExportScope] = useState('all'); - const [metricsExportFeedback, setMetricsExportFeedback] = useState(null); - const [dashboardPanelDraftState, setDashboardPanelDraftState] = useState('idle'); - - useEffect(() => { - let cancelled = false; - void loadSignalSavedQueryViews('metrics') - .then(views => { - if (cancelled) return; - const nextViews = views.filter(isMetricsSavedQueryView).slice(0, METRICS_SAVED_QUERY_VIEW_LIMIT); - setSavedQueryViews(nextViews); - writeMetricsSavedQueryViews(nextViews); - setSavedQueryViewPersistenceMode('server-first'); - }) - .catch(() => { - if (!cancelled) { - setSavedQueryViewPersistenceMode('local-fallback'); - } - }); - return () => { - cancelled = true; - }; - }, []); - - useEffect(() => { - if (!panelEditContext && hasMetricsDisplayReturnLabel(searchParams)) { - router.replace(buildOtlpMetricsRoute(query)); - } - }, [panelEditContext, query, router, searchParams]); - - useEffect(() => { - setDraft(initialDraft); - }, [initialDraft]); - - const updateDraftField = useCallback((field: keyof typeof draft, value: string) => { - setDraft(previous => ({ ...previous, [field]: value })); - }, []); - - const replaceMetricsInventoryRoute = useCallback((nextSearch: string, nextSort: OtlpMetricInventorySort) => { - replaceMetricsHref(buildOtlpMetricsRoute({ - ...query, - inventorySearch: nextSearch.trim() || undefined, - inventorySort: nextSort === 'name' ? undefined : nextSort, - inventoryPageIndex: undefined - })); - }, [query, replaceMetricsHref]); - - const replaceMetricsInventoryPageRoute = useCallback((nextPageSize: string, nextPageIndex: string) => { - const pageSize = resolveMetricInventoryPageSize(nextPageSize); - const pageIndex = resolveMetricInventoryPageIndex(nextPageIndex); - replaceMetricsHref(buildOtlpMetricsRoute({ - ...query, - inventoryPageSize: pageSize === DEFAULT_METRIC_INVENTORY_PAGE_SIZE ? undefined : pageSize, - inventoryPageIndex: pageIndex === DEFAULT_METRIC_INVENTORY_PAGE_INDEX ? undefined : pageIndex - })); - }, [query, replaceMetricsHref]); - - const replaceMetricsAttributeSearchRoute = useCallback((nextSearch: string) => { - replaceMetricsHref(buildOtlpMetricsRoute({ - ...query, - seriesAttributeSearch: nextSearch.trim() || undefined - })); - }, [query, replaceMetricsHref]); - - const applyMetricsInspectorView = useCallback((inspector: NonNullable) => { - replaceMetricsHref(buildOtlpMetricsRoute({ - ...query, - inspector - })); - }, [query, replaceMetricsHref]); - - const applySelectedMetricSeries = useCallback((series: OtlpMetricSeriesView) => { - setSelectedSeriesKey(series.key); - replaceMetricsHref(buildOtlpMetricsRoute({ - ...query, - ...buildMetricSeriesRouteContext(series), - series: series.key - })); - }, [query, replaceMetricsHref]); - - const toggleMetricsExpectedRange = useCallback(() => { - replaceMetricsHref(buildOtlpMetricsRoute({ - ...query, - expectedRange: query.expectedRange === 'on' ? undefined : 'on' - })); - }, [query, replaceMetricsHref]); - - const saveCurrentMetricsQueryView = useCallback(() => { - const nextView = createMetricsSavedQueryView(query, routeContext, currentMetricsRoute, t); - setSavedQueryViews(previous => { - const nextViews = [nextView, ...previous.filter(view => view.route !== nextView.route)].slice(0, METRICS_SAVED_QUERY_VIEW_LIMIT); - writeMetricsSavedQueryViews(nextViews); - return nextViews; - }); - void saveSignalSavedQueryView('metrics', nextView) - .then(savedView => { - setSavedQueryViewPersistenceMode('server-first'); - setSavedQueryViews(previous => { - const nextViews = [savedView, ...previous.filter(view => view.id !== nextView.id && view.route !== savedView.route)].slice(0, METRICS_SAVED_QUERY_VIEW_LIMIT); - writeMetricsSavedQueryViews(nextViews); - return nextViews; - }); - }) - .catch(() => { - setSavedQueryViewPersistenceMode('local-fallback'); - }); - }, [currentMetricsRoute, query, routeContext, t]); - - const copyCurrentMetricsQueryView = useCallback(() => { - void copyTextToClipboard(currentMetricsRoute); - }, [currentMetricsRoute]); - - const addCurrentMetricsQueryToDashboard = useCallback(() => { - const snapshot = createMetricsSavedQueryView(query, routeContext, currentMetricsRoute, t); - const panelDraft = applySignalDashboardPanelEditContext(createSignalDashboardPanelDraft({ - signal: 'metrics', - title: snapshot.label, - description: snapshot.description, - visualization: resolveMetricsDashboardPanelVisualization(metricsInspectorView), - route: currentMetricsRoute, - payload: { - source: 'metrics-explorer', - view: metricsInspectorView - } - }), panelEditContext); - setDashboardPanelDraftState('saving'); - void saveSignalDashboardPanelDraft(panelDraft) - .then(() => saveSignalDashboardPanelEditContext(panelEditContext, panelDraft)) - .then(() => setDashboardPanelDraftState('saved')) - .catch(() => setDashboardPanelDraftState('failed')); - }, [currentMetricsRoute, metricsInspectorView, panelEditContext, query, routeContext, t]); - - const downloadMetricsSeries = useCallback(( - seriesList: OtlpMetricSeriesView[], - selectedSeries: OtlpMetricSeriesView | null | undefined - ) => { - if (typeof window === 'undefined') return; - const exportSeries = metricsExportScope === 'selected' && selectedSeries ? [selectedSeries] : seriesList; - if (exportSeries.length === 0) return; - const content = metricsExportFormat === 'jsonl' - ? buildOtlpMetricsJsonl(exportSeries) - : buildOtlpMetricsCsv(exportSeries); - const type = metricsExportFormat === 'jsonl' ? 'application/x-ndjson;charset=utf-8' : 'text/csv;charset=utf-8'; - const blob = new Blob([content], { type }); - const href = window.URL.createObjectURL(blob); - const anchor = document.createElement('a'); - anchor.href = href; - anchor.download = buildOtlpMetricsExportFilename(metricsExportFormat); - anchor.rel = 'noopener'; - document.body.appendChild(anchor); - anchor.click(); - anchor.remove(); - window.URL.revokeObjectURL(href); - setMetricsExportFeedback(metricsExportFormat); - }, [metricsExportFormat, metricsExportScope]); - - const deleteMetricsSavedQueryView = useCallback((viewId: string) => { - setSavedQueryViews(previous => { - const nextViews = previous.filter(view => view.id !== viewId); - writeMetricsSavedQueryViews(nextViews); - return nextViews; - }); - void deleteSignalSavedQueryView('metrics', viewId) - .then(() => setSavedQueryViewPersistenceMode('server-first')) - .catch(() => { - setSavedQueryViewPersistenceMode('local-fallback'); - }); - }, []); - - const updateMetricsSavedQueryView = useCallback((viewId: string) => { - const nextSnapshot = createMetricsSavedQueryView(query, routeContext, currentMetricsRoute, t); - setSavedQueryViews(previous => { - const nextViews = previous.map(view => ( - view.id === viewId - ? { ...nextSnapshot, id: view.id, label: view.label, createdAt: view.createdAt } - : view - )); - writeMetricsSavedQueryViews(nextViews); - const updatedView = nextViews.find(view => view.id === viewId); - if (updatedView) { - void saveSignalSavedQueryView('metrics', updatedView) - .then(savedView => { - setSavedQueryViewPersistenceMode('server-first'); - setSavedQueryViews(currentViews => { - const syncedViews = currentViews.map(view => (view.id === viewId ? savedView : view)); - writeMetricsSavedQueryViews(syncedViews); - return syncedViews; - }); - }) - .catch(() => { - setSavedQueryViewPersistenceMode('local-fallback'); - }); - } - return nextViews; - }); - }, [currentMetricsRoute, query, routeContext, t]); - - const startRenameMetricsSavedQueryView = useCallback((view: MetricsSavedQueryView) => { - setEditingSavedQueryViewId(view.id); - setSavedQueryViewLabelDraft(view.label); - }, []); - - const cancelRenameMetricsSavedQueryView = useCallback(() => { - setEditingSavedQueryViewId(null); - setSavedQueryViewLabelDraft(''); - }, []); - - const saveRenameMetricsSavedQueryView = useCallback((viewId: string) => { - const nextLabel = savedQueryViewLabelDraft.trim(); - if (!nextLabel) { - cancelRenameMetricsSavedQueryView(); - return; - } - setSavedQueryViews(previous => { - const nextViews = previous.map(view => (view.id === viewId ? { ...view, label: nextLabel } : view)); - writeMetricsSavedQueryViews(nextViews); - const renamedView = nextViews.find(view => view.id === viewId); - if (renamedView) { - void saveSignalSavedQueryView('metrics', renamedView) - .then(savedView => { - setSavedQueryViewPersistenceMode('server-first'); - setSavedQueryViews(currentViews => { - const syncedViews = currentViews.map(view => (view.id === viewId ? savedView : view)); - writeMetricsSavedQueryViews(syncedViews); - return syncedViews; - }); - }) - .catch(() => { - setSavedQueryViewPersistenceMode('local-fallback'); - }); - } - return nextViews; - }); - cancelRenameMetricsSavedQueryView(); - }, [cancelRenameMetricsSavedQueryView, savedQueryViewLabelDraft]); - - const handleMetricsChartZoomChange = useCallback((nextZoom: EChartsDataZoomRange) => { - setMetricsChartZoomRange(previous => { - if ( - previous?.start === nextZoom.start && - previous?.end === nextZoom.end && - previous?.startValue === nextZoom.startValue && - previous?.endValue === nextZoom.endValue - ) { - return previous; - } - return nextZoom; - }); - }, []); - - const draftTimeContext = useMemo(() => sanitizeTimeContext({ - timeRange: draft.timeRange || query.timeRange || 'last-30m', - from: draft.from || query.from, - to: draft.to || query.to, - start: draft.start || query.start, - end: draft.end || query.end, - refresh: draft.refresh || query.refresh, - live: draft.live || query.live, - tz: draft.tz || query.tz, - timezone: draft.timezone || query.timezone - }), [ - draft.end, - draft.from, - draft.live, - draft.refresh, - draft.start, - draft.timeRange, - draft.timezone, - draft.to, - draft.tz, - query.end, - query.from, - query.live, - query.refresh, - query.start, - query.timeRange, - query.timezone, - query.to, - query.tz - ]); - - const replaceMetricsRoute = useCallback((nextDraft: typeof draft, nextTimeContext?: TimeContext, nextSeriesKey?: string, nextSeriesContext: Partial = {}) => { - const timeContext = sanitizeTimeContext({ - timeRange: nextTimeContext?.timeRange || nextDraft.timeRange || query.timeRange || 'last-30m', - from: nextTimeContext?.from || nextDraft.from || query.from, - to: nextTimeContext?.to || nextDraft.to || query.to, - start: nextTimeContext?.start || nextDraft.start || query.start, - end: nextTimeContext?.end || nextDraft.end || query.end, - refresh: nextTimeContext?.refresh || nextDraft.refresh || query.refresh, - live: nextTimeContext?.live || nextDraft.live || query.live, - tz: nextTimeContext?.tz || nextDraft.tz || query.tz, - timezone: nextTimeContext?.timezone || nextDraft.timezone || query.timezone - }); - const bounds = resolveTimeContextBounds(timeContext); - const hasExpressionDraft = Boolean(timeContext.from && timeContext.to); - const hasAbsoluteDraft = Boolean(timeContext.start && timeContext.end); - replaceMetricsHref(buildOtlpMetricsRoute({ - ...query, - query: nextDraft.query.trim() || undefined, - series: nextSeriesKey ?? query.series, - filter: nextDraft.filter.trim() || undefined, - aggregation: nextDraft.aggregation, - temporalAggregation: nextDraft.temporalAggregation === 'raw' ? undefined : nextDraft.temporalAggregation, - groupBy: nextDraft.groupBy, - legendFormat: nextDraft.legendFormat.trim() || undefined, - formula: nextDraft.formula.trim() || undefined, - step: nextDraft.step.trim() || undefined, - limit: nextDraft.limit.trim() || undefined, - warningThreshold: nextDraft.warningThreshold.trim() || undefined, - criticalThreshold: nextDraft.criticalThreshold.trim() || undefined, - timeRange: timeContext.timeRange || 'last-30m', - from: hasExpressionDraft ? timeContext.from : undefined, - to: hasExpressionDraft ? timeContext.to : undefined, - serviceName: nextDraft.serviceName.trim() || undefined, - serviceNamespace: nextDraft.serviceNamespace.trim() || undefined, - environment: nextDraft.environment.trim() || undefined, - traceId: nextDraft.traceId.trim() || undefined, - spanId: nextDraft.spanId.trim() || undefined, - ...nextSeriesContext, - start: hasExpressionDraft ? undefined : bounds?.start || (!hasAbsoluteDraft ? query.start : undefined), - end: hasExpressionDraft ? undefined : bounds?.end || (!hasAbsoluteDraft ? query.end : undefined), - refresh: timeContext.refresh, - live: timeContext.live, - tz: hasExpressionDraft ? undefined : timeContext.tz, - timezone: hasExpressionDraft ? timeContext.timezone || timeContext.tz : timeContext.timezone - })); - }, [query, replaceMetricsHref]); - - const applyMetricsQuery = useCallback((nextTimeContext?: TimeContext) => { - replaceMetricsRoute(draft, nextTimeContext); - }, [draft, replaceMetricsRoute]); - - const applyMetricInventoryQuery = useCallback((metricName: string, series?: OtlpMetricSeriesView | null) => { - const nextMetricName = metricName.trim(); - if (!nextMetricName) return; - const nextDraft = { - ...draft, - query: nextMetricName - }; - setDraft(nextDraft); - if (series) setSelectedSeriesKey(series.key); - replaceMetricsRoute(nextDraft, undefined, series?.key || query.series, series ? buildMetricSeriesRouteContext(series) : {}); - }, [draft, query.series, replaceMetricsRoute]); - - const applyMetricAttributeFilter = useCallback((name: string, value: string, series?: OtlpMetricSeriesView | null) => { - const expression = buildMetricAttributeFilterExpression(name, value); - if (!expression) return; - const nextDraft = { - ...draft, - filter: mergeMetricFilterExpression(draft.filter, expression) - }; - setDraft(nextDraft); - replaceMetricsRoute(nextDraft, undefined, series?.key || query.series, series ? buildMetricSeriesRouteContext(series) : {}); - }, [draft, query.series, replaceMetricsRoute]); - - const applyMetricAttributeExcludeFilter = useCallback((name: string, value: string, series?: OtlpMetricSeriesView | null) => { - const expression = buildMetricAttributeExcludeFilterExpression(name, value); - if (!expression) return; - const nextDraft = { - ...draft, - filter: mergeMetricFilterExpression(draft.filter, expression) - }; - setDraft(nextDraft); - replaceMetricsRoute(nextDraft, undefined, series?.key || query.series, series ? buildMetricSeriesRouteContext(series) : {}); - }, [draft, query.series, replaceMetricsRoute]); - - const applyMetricAttributeContainsFilter = useCallback((name: string, value: string, series?: OtlpMetricSeriesView | null) => { - const expression = buildMetricAttributeContainsFilterExpression(name, value); - if (!expression) return; - const nextDraft = { - ...draft, - filter: mergeMetricFilterExpression(draft.filter, expression) - }; - setDraft(nextDraft); - replaceMetricsRoute(nextDraft, undefined, series?.key || query.series, series ? buildMetricSeriesRouteContext(series) : {}); - }, [draft, query.series, replaceMetricsRoute]); - - const applyMetricAttributeNotContainsFilter = useCallback((name: string, value: string, series?: OtlpMetricSeriesView | null) => { - const expression = buildMetricAttributeNotContainsFilterExpression(name, value); - if (!expression) return; - const nextDraft = { - ...draft, - filter: mergeMetricFilterExpression(draft.filter, expression) - }; - setDraft(nextDraft); - replaceMetricsRoute(nextDraft, undefined, series?.key || query.series, series ? buildMetricSeriesRouteContext(series) : {}); - }, [draft, query.series, replaceMetricsRoute]); - - const applyMetricAttributeInFilter = useCallback((name: string, value: string, series?: OtlpMetricSeriesView | null) => { - const expression = buildMetricAttributeInFilterExpression(name, value); - if (!expression) return; - const nextDraft = { - ...draft, - filter: mergeMetricFilterExpression(draft.filter, expression) - }; - setDraft(nextDraft); - replaceMetricsRoute(nextDraft, undefined, series?.key || query.series, series ? buildMetricSeriesRouteContext(series) : {}); - }, [draft, query.series, replaceMetricsRoute]); - - const applyMetricAttributeNotInFilter = useCallback((name: string, value: string, series?: OtlpMetricSeriesView | null) => { - const expression = buildMetricAttributeNotInFilterExpression(name, value); - if (!expression) return; - const nextDraft = { - ...draft, - filter: mergeMetricFilterExpression(draft.filter, expression) - }; - setDraft(nextDraft); - replaceMetricsRoute(nextDraft, undefined, series?.key || query.series, series ? buildMetricSeriesRouteContext(series) : {}); - }, [draft, query.series, replaceMetricsRoute]); - - const applyMetricAttributeExistsFilter = useCallback((name: string, series?: OtlpMetricSeriesView | null) => { - const expression = buildMetricAttributeExistsFilterExpression(name); - if (!expression) return; - const nextDraft = { - ...draft, - filter: mergeMetricFilterExpression(draft.filter, expression) - }; - setDraft(nextDraft); - replaceMetricsRoute(nextDraft, undefined, series?.key || query.series, series ? buildMetricSeriesRouteContext(series) : {}); - }, [draft, query.series, replaceMetricsRoute]); - - const applyMetricAttributeNotExistsFilter = useCallback((name: string, series?: OtlpMetricSeriesView | null) => { - const expression = buildMetricAttributeNotExistsFilterExpression(name); - if (!expression) return; - const nextDraft = { - ...draft, - filter: mergeMetricFilterExpression(draft.filter, expression) - }; - setDraft(nextDraft); - replaceMetricsRoute(nextDraft, undefined, series?.key || query.series, series ? buildMetricSeriesRouteContext(series) : {}); - }, [draft, query.series, replaceMetricsRoute]); - - const applyMetricAttributeReplaceFilter = useCallback((name: string, value: string, series?: OtlpMetricSeriesView | null) => { - const expression = buildMetricAttributeFilterExpression(name, value); - if (!expression) return; - const nextDraft = { - ...draft, - filter: expression - }; - setDraft(nextDraft); - replaceMetricsRoute(nextDraft, undefined, series?.key || query.series, series ? buildMetricSeriesRouteContext(series) : {}); - }, [draft, query.series, replaceMetricsRoute]); - - const applyMetricGroupValueFilter = useCallback((groupBy: string, value: string) => { - const expression = buildMetricAttributeFilterExpression(groupBy, value); - if (!expression) return; - const nextDraft = { - ...draft, - filter: mergeMetricFilterExpression(draft.filter, expression), - groupBy - }; - setDraft(nextDraft); - replaceMetricsRoute(nextDraft); - }, [draft, replaceMetricsRoute]); - - const applyMetricSeriesServiceFilter = useCallback((value: string, series?: OtlpMetricSeriesView | null) => { - const serviceName = value.trim(); - if (!serviceName) return; - const nextDraft = { - ...draft, - serviceName - }; - setDraft(nextDraft); - if (series) setSelectedSeriesKey(series.key); - replaceMetricsRoute(nextDraft, undefined, series?.key || query.series); - }, [draft, query.series, replaceMetricsRoute]); - - const applyMetricAttributeGroupBy = useCallback((name: string, series?: OtlpMetricSeriesView | null) => { - const groupBy = normalizeMetricGroupByLabel(name); - if (!groupBy) return; - const nextDraft = { - ...draft, - groupBy - }; - setDraft(nextDraft); - replaceMetricsRoute(nextDraft, undefined, series?.key || query.series, series ? buildMetricSeriesRouteContext(series) : {}); - }, [draft, query.series, replaceMetricsRoute]); - - const applyMetricAttributeOperator = useCallback((operator: MetricAttributeOperator, name: string, value: string, series?: OtlpMetricSeriesView | null) => { - switch (operator) { - case 'filter': - applyMetricAttributeFilter(name, value, series); - break; - case 'contains': - applyMetricAttributeContainsFilter(name, value, series); - break; - case 'not-contains': - applyMetricAttributeNotContainsFilter(name, value, series); - break; - case 'in': - applyMetricAttributeInFilter(name, value, series); - break; - case 'not-in': - applyMetricAttributeNotInFilter(name, value, series); - break; - case 'exclude': - applyMetricAttributeExcludeFilter(name, value, series); - break; - case 'exists': - applyMetricAttributeExistsFilter(name, series); - break; - case 'not-exists': - applyMetricAttributeNotExistsFilter(name, series); - break; - case 'replace': - applyMetricAttributeReplaceFilter(name, value, series); - break; - case 'group': - applyMetricAttributeGroupBy(name, series); - break; - } - }, [ - applyMetricAttributeContainsFilter, - applyMetricAttributeExcludeFilter, - applyMetricAttributeExistsFilter, - applyMetricAttributeFilter, - applyMetricAttributeGroupBy, - applyMetricAttributeInFilter, - applyMetricAttributeNotContainsFilter, - applyMetricAttributeNotExistsFilter, - applyMetricAttributeNotInFilter, - applyMetricAttributeReplaceFilter - ]); - - const applyMetricsTimeContext = useCallback((timeContext: TimeContext) => { - const sanitized = sanitizeTimeContext(timeContext); - setDraft(previous => ({ - ...previous, - timeRange: sanitized.timeRange || previous.timeRange || 'last-30m', - from: sanitized.from || '', - to: sanitized.to || '', - start: sanitized.start || '', - end: sanitized.end || '', - refresh: sanitized.refresh || '', - live: sanitized.live || '', - tz: sanitized.tz || '', - timezone: sanitized.timezone || '' - })); - applyMetricsQuery(sanitized); - }, [applyMetricsQuery]); - - return ( - - {data => { - const queryEntityId = routeEntityIdValue(query.entityId); - const queryEntityIdText = readEntityIdRouteParam(query.entityId); - const queryStart = routeEpochMillisValue(query.start); - const queryEnd = routeEpochMillisValue(query.end); - const queryStartText = readEpochMillisRouteParam(query.start); - const queryEndText = readEpochMillisRouteParam(query.end); - const mergedData: OtlpMetricsWorkbenchData = { - ...data, - context: { - ...data.context, - entityId: data.context?.entityId ?? queryEntityId, - entityType: data.context?.entityType || query.entityType, - entityName: data.context?.entityName || query.entityName, - serviceName: data.context?.serviceName || query.serviceName, - serviceNamespace: data.context?.serviceNamespace || query.serviceNamespace, - environment: data.context?.environment || query.environment, - start: data.context?.start ?? queryStart, - end: data.context?.end ?? queryEnd - } - }; - const workbenchState = buildMetricsExplorerState(mergedData, t); - const metricSeries = applyMetricsFormula(buildMetricSeriesViews(mergedData, t), query.formula); - const hasMetricSeries = metricSeries.length > 0; - const seriesRows = buildMetricSeriesRows(metricSeries, t); - const selectedMetricSeries = - metricSeries.find(series => series.key === query.series) || - metricSeries.find(series => series.key === selectedSeriesKey) || - findMetricSeriesForRouteTrace(metricSeries, query, routeContext) || - metricSeries[0] || - null; - const selectedMetricSeriesIndex = selectedMetricSeries ? metricSeries.findIndex(series => series.key === selectedMetricSeries.key) : -1; - const selectedMetricSeriesRouteContext: Partial = selectedMetricSeries - ? buildMetricSeriesRouteContext(selectedMetricSeries) - : {}; - const requestedMetricTraceId = firstRouteText(query.traceId, routeContext.traceId) || ''; - const requestedMetricSpanId = firstRouteText(query.spanId, routeContext.spanId) || ''; - const requestedMetricServiceName = firstRouteText(query.serviceName, routeContext.serviceName) || ''; - const selectedMetricTraceId = selectedMetricSeriesRouteContext.traceId || ''; - const selectedMetricSpanId = selectedMetricSeriesRouteContext.spanId || ''; - const selectedMetricServiceName = selectedMetricSeriesRouteContext.serviceName || ''; - const selectedMetricSourceMatch = - requestedMetricTraceId && selectedMetricTraceId === requestedMetricTraceId && (!requestedMetricSpanId || selectedMetricSpanId === requestedMetricSpanId) - ? 'trace-span' - : requestedMetricServiceName && selectedMetricServiceName === requestedMetricServiceName - ? 'service' - : 'fallback'; - const selectedSeriesContextRows = buildMetricSeriesContextRows(selectedMetricSeries, t); - const selectedSeriesEvidenceRows = buildMetricSeriesEvidenceRows(selectedMetricSeries, formatTime, t); - const selectedSeriesSampleRows = buildMetricSeriesSampleRows(selectedMetricSeries, formatTime, t); - const selectedSeriesAttributeRows = buildMetricSeriesAttributeRows(selectedMetricSeries, metricAttributeSearch); - const metricAttributeOperatorOptions = buildMetricAttributeOperatorOptions(t); - const selectedSeriesContextDetailRows = selectedSeriesContextRows.map(row => ({ - key: row.label, - title: row.label, - copy: row.value, - meta: row.meta - })); - const selectedSeriesEvidenceDetailRows = selectedSeriesEvidenceRows.map(row => ({ - key: row.label, - title: row.label, - copy: row.value, - meta: row.meta - })); - const attributionDiagnostics = buildMetricSeriesAttributionDiagnostics(selectedMetricSeries, t); - const attributionDiagnosticRows = attributionDiagnostics.map(row => ({ - key: row.key, - label: row.label, - value: row.value, - meta: row.meta, - state: row.state, - stateLabel: row.state === 'present' ? t('otlp.metrics.attribution.state.present') : t('otlp.metrics.attribution.state.missing'), - tone: row.state === 'present' ? 'success' as const : 'critical' as const, - rowProps: { - 'data-otlp-metrics-attribution-diagnostic-state': row.state - } as React.HTMLAttributes - })); - const trendSourceSeries = selectedMetricSeries - ? [selectedMetricSeries, ...metricSeries.filter(series => series.key !== selectedMetricSeries.key)] - : metricSeries; - const trendBars = buildMetricTrendBars(trendSourceSeries, formatTime); - const metricThresholdConfig = buildMetricThresholdConfig(query.warningThreshold, query.criticalThreshold, t); - const metricExpectedRangeConfig = query.expectedRange === 'on' - ? buildMetricExpectedRangeConfig(selectedMetricSeries || metricSeries[0] || null, t) - : null; - const metricsChartOption = buildMetricsChartOption(metricSeries, metricThresholdConfig, metricExpectedRangeConfig, query.legendFormat); - const metricsChartZoomContext = buildMetricsDataZoomTimeContext( - metricSeries, - metricsChartZoomRange, - draftTimeContext.timeRange || query.timeRange || 'last-30m' - ); - const metricsChartZoomBounds = metricsChartZoomContext ? resolveTimeContextBounds(metricsChartZoomContext) : null; - const metricsChartZoomDraftLabel = metricsChartZoomBounds?.start && metricsChartZoomBounds?.end - ? `${formatEpochMillisDraft(metricsChartZoomBounds.start)} → ${formatEpochMillisDraft(metricsChartZoomBounds.end)}` - : ''; - const canApplyMetricsChartZoom = Boolean(metricsChartZoomContext && metricsChartZoomBounds && metricsChartZoomDraftLabel); - const facts = buildConsoleFacts(mergedData, t, formatTime); - const metrics = buildConsoleMetrics(mergedData, t); - const contextRows = buildContextRows(mergedData, t, formatTime); - const metricsDetailContextRows = contextRows.map(row => ({ - key: row.title, - title: row.title, - copy: row.copy, - meta: row.meta - })); - const relatedMetricCandidateRows = buildRelatedMetricCandidateRows(query, t); - const handoffLinks = buildMetricsHandoffLinks(mergedData, query, routeContext, selectedMetricSeries); - const linkedRecordRows = buildMetricSeriesLinkedRecordRows(selectedMetricSeries, handoffLinks, t); - const linkedRecordHandoffTargets = linkedRecordRows.map(row => ({ - id: row.key, - label: {row.label}, - description: row.meta, - meta: row.value, - href: row.href, - tone: row.key === 'alerts' ? ('critical' as const) : row.key === 'traces' ? ('info' as const) : ('neutral' as const) - })); - const missingEntityHandoffTitle = t('otlp.metrics.handoff.entity-disabled'); - const entityDiscoveryHandoffTitle = t('otlp.metrics.handoff.entity-discovery'); - const canOpenEntity = handoffLinks.entityHref.startsWith('/entities/'); - const entityContextRows = buildSignalEntityContextRows(routeContext, { - entityId: queryEntityIdText || (mergedData.context?.entityId != null ? String(mergedData.context.entityId) : undefined), - entityType: query.entityType || mergedData.context?.entityType || undefined, - entityName: query.entityName || mergedData.context?.entityName || undefined, - serviceName: mergedData.context?.serviceName || query.serviceName || undefined, - serviceNamespace: mergedData.context?.serviceNamespace || query.serviceNamespace || undefined, - environment: mergedData.context?.environment || query.environment || undefined, - start: queryStartText || (mergedData.context?.start != null ? String(mergedData.context.start) : undefined), - end: queryEndText || (mergedData.context?.end != null ? String(mergedData.context.end) : undefined), - source: routeContext.source || 'OTLP' - }); - const entityContextDetailRows = entityContextRows.map(row => ({ - key: row.label, - title: row.label, - copy: row.value, - meta: row.meta - })); - const serviceGroupLabel = t('otlp.metrics.group.service'); - const namespaceGroupLabel = t('otlp.metrics.group.namespace'); - const environmentGroupLabel = t('otlp.metrics.group.environment'); - const noGroupLabel = t('otlp.metrics.group.none'); - const metricsGroupOptions = [ - { value: '', label: noGroupLabel }, - { value: 'service_name', label: serviceGroupLabel }, - { value: 'service_namespace', label: namespaceGroupLabel }, - { value: 'deployment_environment_name', label: environmentGroupLabel } - ]; - const hasCustomGroupBy = Boolean(draft.groupBy && !metricsGroupOptions.some(option => option.value === draft.groupBy)); - const visibleMetricsGroupOptions = hasCustomGroupBy - ? [...metricsGroupOptions, { value: draft.groupBy, label: draft.groupBy }] - : metricsGroupOptions; - const currentGroupLabel = visibleMetricsGroupOptions.find(option => option.value === draft.groupBy)?.label || noGroupLabel; - const currentTemporalAggregationLabel = draft.temporalAggregation === 'rate' - ? t('otlp.metrics.temporal.rate') - : draft.temporalAggregation === 'increase' - ? t('otlp.metrics.temporal.increase') - : draft.temporalAggregation === 'delta' - ? t('otlp.metrics.temporal.delta') - : t('otlp.metrics.temporal.raw'); - const headerContextPills = [ - { label: t('otlp.metrics.field.service'), value: firstRouteText(mergedData.context?.serviceName, query.serviceName, draft.serviceName) }, - { label: t('otlp.metrics.field.namespace'), value: firstRouteText(mergedData.context?.serviceNamespace, query.serviceNamespace, draft.serviceNamespace) }, - { label: t('otlp.metrics.field.environment'), value: firstRouteText(mergedData.context?.environment, query.environment, draft.environment) }, - { label: t('otlp.metrics.filter.short'), value: firstRouteText(query.filter, draft.filter) }, - { - label: t('otlp.metrics.temporal.aria'), - value: currentTemporalAggregationLabel - }, - { - label: t('otlp.metrics.field.group-by'), - value: currentGroupLabel - } - ].filter((pill): pill is { label: string; value: string } => Boolean(pill.value)); - const seriesSetScopeRows = [ - { label: t('otlp.metrics.scope.service'), value: firstRouteText(mergedData.context?.serviceName, query.serviceName, draft.serviceName) || t('otlp.metrics.scope.all-services') }, - { label: t('otlp.metrics.field.namespace'), value: firstRouteText(mergedData.context?.serviceNamespace, query.serviceNamespace, draft.serviceNamespace) || t('otlp.metrics.scope.all-namespaces') }, - { label: t('otlp.metrics.field.environment'), value: firstRouteText(mergedData.context?.environment, query.environment, draft.environment) || t('otlp.metrics.scope.all-environments') }, - { - label: t('otlp.metrics.field.group-by'), - value: currentGroupLabel - }, - { label: t('otlp.metrics.scope.series'), value: t('otlp.metrics.scope.series-count', { count: seriesRows.length }) } - ]; - const latestObservedAt = latestSeriesTimestamp(mergedData); - const metricSeriesTableRows: MetricInventoryTableRow[] = seriesRows.map((row, index) => ({ - ...row, - rowKey: metricSeries[index]?.key || `${row.title}-${index}`, - pointCount: metricSeries[index]?.points.length ?? 0, - series: metricSeries[index] || null - })); - const sourceMetricInventoryRows: MetricInventoryTableRow[] = buildMetricInventorySourceRows(mergedData.inventory, t).map((row, index) => { - const matchingSeriesRow = metricSeriesTableRows.find(seriesRow => - seriesRow.title === row.title || seriesRow.series?.name === row.title - ); - return { - ...(matchingSeriesRow || {}), - ...row, - rowKey: matchingSeriesRow?.rowKey || `inventory-${row.title}-${index}`, - series: matchingSeriesRow?.series || row.series || null, - meta: matchingSeriesRow?.meta || row.meta, - pointCount: matchingSeriesRow?.pointCount ?? row.pointCount, - sampleCount: matchingSeriesRow?.sampleCount ?? row.sampleCount, - timeSeriesCount: row.timeSeriesCount ?? matchingSeriesRow?.timeSeriesCount - }; - }); - const metricInventoryBaseRows: MetricInventoryTableRow[] = sourceMetricInventoryRows.length > 0 ? sourceMetricInventoryRows : metricSeriesTableRows; - const metricInventoryRows = buildMetricInventoryRows(metricInventoryBaseRows, metricInventorySearch, metricInventorySort); - const metricInventoryPageSizeNumber = Number(metricInventoryPageSize); - const metricInventoryTotalPages = Math.max(1, Math.ceil(metricInventoryRows.length / metricInventoryPageSizeNumber)); - const clampedMetricInventoryPageIndex = Math.min(Number(metricInventoryPageIndex), metricInventoryTotalPages - 1); - const metricInventoryPageStartIndex = clampedMetricInventoryPageIndex * metricInventoryPageSizeNumber; - const metricInventoryPageRows = metricInventoryRows.slice(metricInventoryPageStartIndex, metricInventoryPageStartIndex + metricInventoryPageSizeNumber); - const metricInventoryPageFrom = metricInventoryRows.length === 0 ? 0 : metricInventoryPageStartIndex + 1; - const metricInventoryPageTo = metricInventoryRows.length === 0 ? 0 : Math.min(metricInventoryRows.length, metricInventoryPageStartIndex + metricInventoryPageRows.length); - const metricInventoryPaginationSummary = t('common.pagination.summary', { - page: clampedMetricInventoryPageIndex + 1, - totalPages: metricInventoryTotalPages, - from: metricInventoryPageFrom, - to: metricInventoryPageTo, - total: metricInventoryRows.length - }); - const metricInventorySummary = metricInventorySearch.trim() - ? t('otlp.metrics.inventory.filtered-count', { filtered: metricInventoryRows.length, total: metricInventoryBaseRows.length }) - : t('otlp.metrics.scope.series-count', { count: metricInventoryBaseRows.length }); - const firstSeries = (selectedMetricSeriesIndex >= 0 ? seriesRows[selectedMetricSeriesIndex] : undefined) ?? seriesRows[0] ?? { - title: mergedData.query || t('otlp.metrics.query.unselected'), - copy: mergedData.context?.serviceName || routeContext.serviceName || '-', - meta: '-' - }; - const sourceContextKind = panelEditContext - ? 'dashboard-panel-edit' - : isDashboardReturnContext(query.returnTo || routeContext.returnTo) - ? 'dashboard-evidence' - : query.returnTo || routeContext.returnTo - ? 'return-source' - : 'direct'; - - return ( - - - - - {headerContextPills.length ? ( - - {headerContextPills.map(pill => ( - - ))} - - ) : null} - - - - {routeContext.returnTo ? ( - - - - {t('otlp.metrics.route.action.return-source')} - - - ) : null} - - applyMetricsQuery(draftTimeContext)} - onReset={() => applyMetricsTimeContext({ timeRange: 'last-30m' })} - presets={metricsTimeRangePresets} - showAbsoluteFields - variant="narrow-rail" - data-otlp-metrics-time-range-control-owner="hertzbeat-shared-time-range-control" - presetSelectProps={{ 'data-otlp-metrics-time-range-select': 'true' }} - presetOptionDataAttribute="data-otlp-metrics-time-range-preset" - refreshActionProps={{ 'data-otlp-metrics-time-refresh-action': 'true' }} - /> - - - - - - - - - - )} - > - updateDraftField('query', event.target.value)} - onInput={event => updateDraftField('query', event.currentTarget.value)} - placeholder="http.server.duration" - inset="search-icon" - width="metrics-query-expression" - /> - - updateDraftField('aggregation', event.target.value)} - width="metrics-aggregation" - triggerClassName="text-[#d5dce8]" - options={[ - { value: 'avg', label: t('otlp.metrics.aggregation.avg') }, - { value: 'sum', label: t('otlp.metrics.aggregation.sum') }, - { value: 'max', label: t('otlp.metrics.aggregation.max') }, - { value: 'min', label: t('otlp.metrics.aggregation.min') } - ]} - optionDataAttributes={option => ({ - 'data-otlp-metrics-aggregation-option': option.value - })} - /> - updateDraftField('groupBy', event.target.value)} - width="metrics-group-by" - triggerClassName="text-[#d5dce8]" - options={visibleMetricsGroupOptions} - optionDataAttributes={option => ({ - 'data-otlp-metrics-group-by-option': option.value - })} - /> - - applyMetricsQuery()}> - - {t('otlp.metrics.query.run')} - - - - {t('common.reset')} - - - - - - )} - > - updateDraftField('filter', event.target.value)} - onInput={event => updateDraftField('filter', event.currentTarget.value)} - placeholder={t('otlp.metrics.filter.placeholder')} - inset="search-icon" - width="metrics-filter-expression" - /> - - updateDraftField('temporalAggregation', event.target.value)} - width="metrics-temporal-aggregation" - triggerClassName="text-[#d5dce8]" - options={[ - { value: 'raw', label: t('otlp.metrics.temporal.raw') }, - { value: 'rate', label: t('otlp.metrics.temporal.rate') }, - { value: 'increase', label: t('otlp.metrics.temporal.increase') }, - { value: 'delta', label: t('otlp.metrics.temporal.delta') } - ]} - optionDataAttributes={option => ({ - 'data-otlp-metrics-temporal-aggregation-option': option.value - })} - /> - updateDraftField('step', event.target.value)} - onInput={event => updateDraftField('step', event.currentTarget.value)} - placeholder={t('otlp.metrics.step.placeholder')} - inputMode="numeric" - width="metrics-query-step" - /> - updateDraftField('limit', event.target.value)} - onInput={event => updateDraftField('limit', event.currentTarget.value)} - placeholder={t('otlp.metrics.limit.placeholder')} - inputMode="numeric" - width="metrics-query-limit" - /> - updateDraftField('legendFormat', event.target.value)} - onInput={event => updateDraftField('legendFormat', event.currentTarget.value)} - placeholder={t('otlp.metrics.legend-format.placeholder')} - width="metrics-filter-expression" - /> - updateDraftField('formula', event.target.value)} - onInput={event => updateDraftField('formula', event.currentTarget.value)} - placeholder={t('otlp.metrics.formula.placeholder')} - width="metrics-filter-expression" - /> - updateDraftField('warningThreshold', event.target.value)} - onInput={event => updateDraftField('warningThreshold', event.currentTarget.value)} - placeholder={t('otlp.metrics.threshold.warning.placeholder')} - inputMode="decimal" - width="metrics-query-step" - /> - updateDraftField('criticalThreshold', event.target.value)} - onInput={event => updateDraftField('criticalThreshold', event.currentTarget.value)} - placeholder={t('otlp.metrics.threshold.critical.placeholder')} - inputMode="decimal" - width="metrics-query-step" - /> - - - {t('otlp.metrics.expected-range.label')} - - - - updateDraftField('serviceName', event.target.value)} - onInput={event => updateDraftField('serviceName', event.currentTarget.value)} - placeholder={t('otlp.metrics.field.service-name')} - width="metrics-context" - /> - updateDraftField('serviceNamespace', event.target.value)} - onInput={event => updateDraftField('serviceNamespace', event.currentTarget.value)} - placeholder={t('otlp.metrics.field.namespace')} - width="metrics-context" - /> - updateDraftField('environment', event.target.value)} - onInput={event => updateDraftField('environment', event.currentTarget.value)} - placeholder={t('otlp.metrics.field.environment')} - width="metrics-context-compact" - /> - updateDraftField('traceId', event.target.value)} - onInput={event => updateDraftField('traceId', event.currentTarget.value)} - placeholder={t('otlp.metrics.field.trace-id')} - width="metrics-trace-id" - /> - updateDraftField('spanId', event.target.value)} - onInput={event => updateDraftField('spanId', event.currentTarget.value)} - placeholder={t('otlp.metrics.field.span-id')} - width="metrics-trace-id" - /> - - - -
-
{t('otlp.metrics.saved-view.title')}
-
- {t(savedQueryViewPersistenceMode === 'server-first' - ? 'otlp.metrics.saved-view.persistence.server' - : 'otlp.metrics.saved-view.persistence.local')} -
- {dashboardPanelDraftState !== 'idle' ? ( -
- {t(panelEditContext - ? `otlp.metrics.dashboard-panel-draft.update-${dashboardPanelDraftState}` - : `otlp.metrics.dashboard-panel-draft.${dashboardPanelDraftState}`)} -
- ) : null} -
- - - - - - - - - {panelEditContext?.returnTo ? ( - - - {t('otlp.metrics.dashboard-panel-draft.return-dashboard')} - - ) : null} - {savedQueryViews.length ? ( - savedQueryViews.map(view => { - const active = view.route === currentMetricsRoute; - const editing = editingSavedQueryViewId === view.id; - return ( - - {editing ? ( - <> - setSavedQueryViewLabelDraft(event.target.value)} - onInput={event => setSavedQueryViewLabelDraft(event.currentTarget.value)} - aria-label={t('otlp.metrics.saved-view.rename-label')} - data-otlp-metrics-saved-view-rename-input={view.id} - data-otlp-metrics-saved-view-rename-input-owner="hertzbeat-ui-input" - /> - saveRenameMetricsSavedQueryView(view.id)} - > - - - - - ) : ( - <> - replaceMetricsHref(view.route)} - > - - startRenameMetricsSavedQueryView(view)} - > - - updateMetricsSavedQueryView(view.id)} - > - - - )} - deleteMetricsSavedQueryView(view.id)} - > - - - ); - }) - ) : ( - - {t('otlp.metrics.saved-view.empty')} - - )} - -
-
-
- - {relatedMetricCandidateRows.length ? ( - - - - ) : null} - - - - - - - {t('otlp.metrics.trend.title')} - - - {hasMetricSeries ? ( - ({ - id: fact.label, - label: fact.label, - value: fact.value - }))} - /> - ) : null} - {metricsChartZoomDraftLabel ? ( - - ) : null} - {hasMetricSeries ? ( - { - if (!metricsChartZoomContext) return; - setMetricsChartZoomRange(null); - applyMetricsTimeContext(metricsChartZoomContext); - }} - > - {t('time.context.zoom.apply')} - - ) : null} - - {workbenchState.seriesCountLabel} - - - - {hasMetricSeries ? ( - { - handleMetricsChartZoomChange(nextZoom); - const nextZoomContext = buildMetricsDataZoomTimeContext( - metricSeries, - nextZoom, - draftTimeContext.timeRange || query.timeRange || 'last-30m' - ); - if (!nextZoomContext) return; - setDraft(previous => ({ - ...previous, - timeRange: nextZoomContext.timeRange || previous.timeRange || 'last-30m', - from: nextZoomContext.from || '', - to: nextZoomContext.to || '', - start: nextZoomContext.start || '', - end: nextZoomContext.end || '', - refresh: nextZoomContext.refresh || previous.refresh || draftTimeContext.refresh || '', - live: nextZoomContext.live || previous.live || draftTimeContext.live || '', - tz: nextZoomContext.tz || previous.tz || draftTimeContext.tz || '', - timezone: nextZoomContext.timezone || previous.timezone || draftTimeContext.timezone || '' - })); - }} - /> - ) : ( - - {trendBars.length ? ( - trendBars.map(series => ( - - )) - ) : ( - - )} - - )} - {!hasMetricSeries ? null : ( - - {trendBars.length ? t('otlp.metrics.trend.sample-count', { count: trendBars.length }) : '-'} - - )} - - - - - - - - {metricInventorySummary} - - } - /> - - - - )} - > - replaceMetricsInventoryRoute(event.target.value, metricInventorySort)} - onInput={event => replaceMetricsInventoryRoute(event.currentTarget.value, metricInventorySort)} - placeholder={t('otlp.metrics.inventory.search.placeholder')} - inset="search-icon" - width="metrics-inventory-search" - /> - - replaceMetricsInventoryRoute(metricInventorySearch, event.target.value as OtlpMetricInventorySort)} - width="metrics-inventory-sort" - triggerClassName="text-[#d5dce8]" - options={[ - { value: 'name', label: t('otlp.metrics.inventory.sort.name') }, - { value: 'latest', label: t('otlp.metrics.inventory.sort.latest') }, - { value: 'samples', label: t('otlp.metrics.inventory.sort.samples') }, - { value: 'time-series', label: t('otlp.metrics.inventory.sort.time-series') } - ]} - optionDataAttributes={option => ({ - 'data-otlp-metrics-inventory-sort-option': option.value - })} - /> - setMetricsExportFormat(event.target.value === 'jsonl' ? 'jsonl' : 'csv')} - width="metrics-inventory-sort" - triggerClassName="text-[#d5dce8]" - options={[ - { value: 'csv' satisfies OtlpMetricsExportFormat, label: t('otlp.metrics.export.format.csv') }, - { value: 'jsonl' satisfies OtlpMetricsExportFormat, label: t('otlp.metrics.export.format.jsonl') } - ]} - optionDataAttributes={option => ({ - 'data-otlp-metrics-export-format-option': option.value - })} - /> - setMetricsExportScope(METRICS_EXPORT_SCOPES.includes(event.target.value as OtlpMetricsExportScope) ? event.target.value as OtlpMetricsExportScope : 'all')} - width="metrics-inventory-sort" - triggerClassName="text-[#d5dce8]" - options={[ - { value: 'all' satisfies OtlpMetricsExportScope, label: t('otlp.metrics.export.scope.all') }, - { value: 'selected' satisfies OtlpMetricsExportScope, label: t('otlp.metrics.export.scope.selected') } - ]} - optionDataAttributes={option => ({ - 'data-otlp-metrics-export-scope-option': option.value - })} - /> - downloadMetricsSeries(metricSeries, selectedMetricSeries)} - data-otlp-metrics-download-action="current-query" - data-otlp-metrics-download-owner="hertzbeat-ui-button" - data-otlp-metrics-download-format={metricsExportFormat} - data-otlp-metrics-download-scope={metricsExportScope} - data-otlp-metrics-download-series-count={metricSeries.length} - aria-label={t('otlp.metrics.export.download.aria', { format: metricsExportFormat.toUpperCase() })} - > - - {t('otlp.metrics.export.download')} - - - {metricsExportFeedback ? ( - - ) : null} - - - ({ - id: row.label, - label: row.label, - value: row.value - }))} - /> - - row.rowKey} - selectedRowKey={selectedMetricSeries?.key} - onRowClick={row => { - if (row.series) { - applySelectedMetricSeries(row.series); - return; - } - applyMetricInventoryQuery(row.title, null); - }} - getRowProps={row => ({ - 'data-otlp-metrics-series-row': 'selectable-series', - 'data-otlp-metrics-series-row-selected': selectedMetricSeries?.key === row.series?.key ? 'true' : 'false' - })} - emptyLabel={ - - } - columns={[ - { - key: 'name', - header: t('otlp.metrics.series.context.metric-name'), - render: row => ( - { - event.stopPropagation(); - applyMetricInventoryQuery(row.title, row.series || null); - }} - className="min-w-0 justify-start truncate font-mono" - > - {row.title} - - ) - }, - { - key: 'description', - header: t('otlp.metrics.inventory.column.description'), - render: row => ( - - {row.description} - - ) - }, - { - key: 'type', - header: t('otlp.metrics.inventory.column.type'), - render: row => ( - - {row.metricType} - - ) - }, - { - key: 'unit', - header: t('otlp.metrics.inventory.column.unit'), - render: row => ( - - {row.unit} - - ) - }, - { - key: 'service', - header: t('otlp.metrics.field.service'), - render: row => { - const groupFilter = buildMetricGroupValueFilter(draft.groupBy, row.series); - const serviceFilter = buildMetricSeriesServiceFilter(row.series); - return groupFilter ? ( - { - event.stopPropagation(); - applyMetricGroupValueFilter(groupFilter.groupBy, groupFilter.value); - }} - className="min-w-0 justify-start truncate font-mono" - > - {groupFilter.value} - - ) : serviceFilter ? ( - { - event.stopPropagation(); - applyMetricSeriesServiceFilter(serviceFilter.value, row.series); - }} - className="min-w-0 justify-start truncate font-mono" - > - {serviceFilter.value} - - ) : ( - {row.copy} - ); - } - }, - { - key: 'entity', - header: t('otlp.metrics.series.context.entity'), - render: row => ( - - - {row.entityLabel} - - - {row.entityMeta} - - - ) - }, - { - key: 'latest', - header: t('otlp.metrics.evidence.latest-value'), - render: row => {row.meta} - }, - { - key: 'points', - header: t('otlp.metrics.evidence.samples'), - render: row => {row.sampleCount ?? row.pointCount} - }, - { - key: 'time-series', - header: t('otlp.metrics.inventory.column.time-series'), - render: row => ( - - {row.timeSeriesCount} - - ) - }, - { - key: 'time', - header: t('otlp.metrics.table.recent-time'), - render: () => {formatTime(latestObservedAt)} - } - ]} - /> - ({ - value, - label: value - }))} - onPageSizeChange={value => replaceMetricsInventoryPageRoute(value, DEFAULT_METRIC_INVENTORY_PAGE_INDEX)} - pageJumpLabel={t('common.page')} - pageJumpValue={String(clampedMetricInventoryPageIndex + 1)} - pageJumpMax={metricInventoryTotalPages} - onPageJumpChange={value => { - const nextPage = Number(value); - if (!Number.isInteger(nextPage)) return; - replaceMetricsInventoryPageRoute(metricInventoryPageSize, String(Math.max(0, nextPage - 1))); - }} - previousLabel={t('common.previous-page')} - nextLabel={t('common.next-page')} - previousDisabled={clampedMetricInventoryPageIndex <= 0} - nextDisabled={clampedMetricInventoryPageIndex >= metricInventoryTotalPages - 1} - onPrevious={() => replaceMetricsInventoryPageRoute(metricInventoryPageSize, String(Math.max(0, clampedMetricInventoryPageIndex - 1)))} - onNext={() => replaceMetricsInventoryPageRoute(metricInventoryPageSize, String(clampedMetricInventoryPageIndex + 1))} - pageSizeSelectProps={{ - 'data-otlp-metrics-inventory-pagination-page-size': 'true', - optionDataAttributes: option => ({ - 'data-otlp-metrics-inventory-page-size-option': option.value - }) - }} - pageJumpInputProps={{ - 'data-otlp-metrics-inventory-pagination-page-jump': 'true' - }} - previousButtonProps={{ - 'data-otlp-metrics-inventory-pagination-previous': 'true' - }} - nextButtonProps={{ - 'data-otlp-metrics-inventory-pagination-next': 'true' - }} - /> - - - {hasMetricSeries && selectedMetricSeries ? ( - - - - ({ - id: metric.label, - label: metric.label, - value: metric.value - }))} - /> - - applyMetricsInspectorView('graph')} - > - - {t('otlp.metrics.inspector.graph')} - - applyMetricsInspectorView('table')} - > - - {t('otlp.metrics.inspector.table')} - - - - {selectedSeriesContextRows.length > 0 ? ( - - ) : null} - {metricsInspectorView === 'table' ? ( - row.key} - emptyLabel={t('otlp.metrics.inspector.empty')} - columns={[ - { - key: 'index', - header: t('otlp.metrics.inspector.column.index'), - render: row => {row.index} - }, - { - key: 'timestamp', - header: t('otlp.metrics.inspector.column.timestamp'), - render: row => {row.timestamp} - }, - { - key: 'rawTimestamp', - header: t('otlp.metrics.inspector.column.raw-timestamp'), - render: row => {row.rawTimestamp} - }, - { - key: 'value', - header: t('otlp.metrics.inspector.column.value'), - render: row => {row.value} - }, - { - key: 'state', - header: t('otlp.metrics.inspector.column.state'), - render: row => {row.state} - } - ]} - /> - ) : selectedSeriesEvidenceRows.length > 0 ? ( - - ) : null} - - - - {canOpenEntity ? ( - - {t('topology.context-link.entity')} - - ) : ( - - {t('otlp.metrics.handoff.entity-discovery')} - - )} - - {t('otlp.metrics.handoff.alerts')} - - - {t('explorer.actions.create-alert')} - - - {t('explorer.actions.add-dashboard')} - - - {t('otlp.metrics.handoff.logs.action')} - - - {t('otlp.metrics.handoff.traces.action')} - - - {t('overview.lane.entities.title')} - - - - - - - - )} - > - replaceMetricsAttributeSearchRoute(event.target.value)} - onInput={event => replaceMetricsAttributeSearchRoute(event.currentTarget.value)} - placeholder={t('otlp.metrics.attributes.search.placeholder')} - inset="search-icon" - width="metrics-inventory-search" - /> - - - row.key} - emptyLabel={t('otlp.metrics.attributes.empty')} - columns={[ - { - key: 'name', - header: t('otlp.metrics.attributes.column.name'), - render: row => {row.name} - }, - { - key: 'value', - header: t('otlp.metrics.attributes.column.value'), - render: row => {row.value} - }, - { - key: 'operator', - header: t('otlp.metrics.attributes.column.operator'), - render: row => ( - metricAttributeOperatorDataAttributes(option.value as MetricAttributeOperator, row.name)} - onChange={event => applyMetricAttributeOperator(event.target.value as MetricAttributeOperator, row.name, row.value, selectedMetricSeries)} - /> - ) - } - ]} - /> - - {linkedRecordRows.length > 0 ? ( - -
- -
-
- ) : null} - {attributionDiagnostics.length > 0 ? ( - - - - ) : null} - - - -
-
- ) : ( - - )} -
-
- ); - }} -
- ); -} diff --git a/web-next/app/ingestion/otlp/metrics/page.test.tsx b/web-next/app/ingestion/otlp/metrics/page.test.tsx deleted file mode 100644 index 5574fbd3a1..0000000000 --- a/web-next/app/ingestion/otlp/metrics/page.test.tsx +++ /dev/null @@ -1,3830 +0,0 @@ -// @vitest-environment jsdom - -import { readFileSync } from 'node:fs'; -import { resolve } from 'node:path'; -import React, { act } from 'react'; -import { createRoot, type Root } from 'react-dom/client'; -import { renderToStaticMarkup } from 'react-dom/server'; -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { createTranslatorMock } from '../../../../test/i18n-test-helper'; -import type { TranslationParams } from '../../../../lib/i18n'; - -(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; - -const mockState = vi.hoisted(() => ({ - searchParams: new URLSearchParams(), - replace: vi.fn(), - lastLoad: null as null | (() => Promise), - metricSeries: [] as any[], - renderData: { - datasource: 'prometheus', - queryMode: 'builder', - query: '', - stats: { totalSeries: 0, nonEmptySeries: 0, latestObservedAt: null }, - context: { serviceName: 'checkout', serviceNamespace: 'payments', start: 1712730000000, end: 1712733600000 }, - results: { msg: 'ok', frames: [] }, - emptyStateReason: 'no_context', - errorMessage: null - } -})); - -const apiMessageGet = vi.fn(); -const loadOtlpMetricsConsole = vi.fn(); -const loadOtlpMetricsInventory = vi.fn(); -const zhT = createTranslatorMock({ locale: 'zh-CN' }); -const originalFetch = globalThis.fetch; - -function tZh(key: string, params?: TranslationParams) { - return zhT(key, params); -} - -async function flushDashboardEditPromises() { - for (let index = 0; index < 8; index += 1) { - await Promise.resolve(); - } -} - -vi.mock('next/link', () => ({ - default: ({ href, children, ...props }: any) => {children} -})); - -vi.mock('next/navigation', () => ({ - useRouter: () => ({ - replace: mockState.replace - }), - useSearchParams: () => - ({ - get: (key: string) => mockState.searchParams.get(key) - }) as { get: (key: string) => string | null } -})); - -vi.mock('@/components/providers/i18n-provider', () => ({ - useI18n: () => ({ - t: zhT - }) -})); - -vi.mock('@/components/workbench/client-workbench', () => ({ - ClientWorkbench: ({ children, load }: { children: (data: any) => React.ReactNode; load: () => Promise }) => { - mockState.lastLoad = load; - return
{children(mockState.renderData)}
; - } -})); - -vi.mock('@/components/ui/button', () => ({ - Button: ({ children, ...props }: any) => -})); - -vi.mock('@/components/ui/input', () => ({ - Input: (props: any) => -})); - -vi.mock('@/components/ui/select', () => ({ - Select: ({ children, containerClassName: _containerClassName, ...props }: any) => -})); - -vi.mock('@/components/observability/echarts-panel', () => ({ - EChartsPanel: ({ edge, onDataZoomChange, preserveDataZoom, ...props }: any) => ( - - ) -})); - -vi.mock('@/lib/api-client', () => ({ - apiMessageGet -})); - -vi.mock('@/lib/format', () => ({ - formatTime: () => '2026-04-12 20:00:00' -})); - -vi.mock('@/lib/otlp-metrics/controller', () => ({ - loadOtlpMetricsConsole, - loadOtlpMetricsInventory, - buildOtlpMetricsConsoleUrl: (query: Record = {}) => { - const params = new URLSearchParams(); - Object.entries(query).forEach(([key, value]) => { - if (value !== undefined && value !== null && value !== '') { - if (key === 'inspector') return; - if (key === 'series') return; - if (key === 'legendFormat') return; - if (key === 'formula') return; - if (key === 'inventorySearch' || key === 'inventorySort' || key === 'inventoryPageSize' || key === 'inventoryPageIndex') return; - if (key === 'seriesAttributeSearch') return; - if (key === 'warningThreshold' || key === 'criticalThreshold') return; - if (key === 'expectedRange') return; - params.set(key, String(value)); - } - }); - const search = params.toString(); - return `/api/otlp/v1/metrics${search ? `?${search}` : ''}`; - }, - buildOtlpMetricsInventoryUrl: (query: Record = {}) => { - const params = new URLSearchParams(); - ['entityId', 'entityType', 'serviceName', 'serviceNamespace', 'environment', 'start', 'end', 'limit'].forEach(key => { - const value = query[key]; - if (value !== undefined && value !== null && value !== '') { - params.set(key, String(value)); - } - }); - const search = params.toString(); - return `/api/otlp/v1/metrics/inventory${search ? `?${search}` : ''}`; - }, - queryStateFromParams: (params: { get(key: string): string | null }) => ({ - query: params.get('query') || undefined, - series: params.get('series') || undefined, - filter: params.get('filter') || undefined, - aggregation: params.get('aggregation') || undefined, - temporalAggregation: params.get('temporalAggregation') || undefined, - groupBy: params.get('groupBy') || undefined, - legendFormat: params.get('legendFormat') || undefined, - formula: params.get('formula') || undefined, - inventorySearch: params.get('inventorySearch') || undefined, - inventorySort: params.get('inventorySort') || undefined, - inventoryPageSize: params.get('inventoryPageSize') || undefined, - inventoryPageIndex: params.get('inventoryPageIndex') || undefined, - seriesAttributeSearch: params.get('seriesAttributeSearch') || undefined, - relatedMetricSource: params.get('relatedMetricSource') || undefined, - relatedMetricFamily: params.get('relatedMetricFamily') || undefined, - relatedMetricReason: params.get('relatedMetricReason') || undefined, - relatedMetricMatchedLabels: params.get('relatedMetricMatchedLabels') || undefined, - relatedMetricResourceMatch: params.get('relatedMetricResourceMatch') || undefined, - step: params.get('step') || undefined, - limit: params.get('limit') || undefined, - timeRange: params.get('timeRange') || undefined, - collector: params.get('collector') || undefined, - template: params.get('template') || undefined, - entityId: params.get('entityId') || undefined, - entityType: params.get('entityType') || undefined, - entityName: params.get('entityName') || undefined, - returnTo: params.get('returnTo') || undefined, - traceId: params.get('traceId') || undefined, - spanId: params.get('spanId') || undefined, - inspector: params.get('inspector') === 'table' ? 'table' : 'graph', - warningThreshold: params.get('warningThreshold') || undefined, - criticalThreshold: params.get('criticalThreshold') || undefined, - expectedRange: params.get('expectedRange') === 'on' ? 'on' : undefined, - serviceName: params.get('serviceName') || undefined, - serviceNamespace: params.get('serviceNamespace') || undefined, - environment: params.get('environment') || undefined, - from: params.get('from') || undefined, - to: params.get('to') || undefined, - start: params.get('start') || undefined, - end: params.get('end') || undefined, - refresh: params.get('refresh') || undefined, - live: params.get('live') || undefined, - tz: params.get('tz') || undefined, - timezone: params.get('timezone') || undefined - }) -})); - -vi.mock('@/lib/signal-route-context', () => ({ - isDashboardReturnContext: (href?: string | null) => href?.startsWith('/dashboard') || false, - readSignalPanelEditContext: () => { - if (mockState.searchParams.get('intent') !== 'edit-panel') return null; - const dashboardKey = mockState.searchParams.get('dashboardKey') || undefined; - const panelId = mockState.searchParams.get('panelId') || undefined; - if (!dashboardKey || !panelId) return null; - return { - intent: 'edit-panel', - dashboardKey, - panelId, - draftKey: mockState.searchParams.get('draftKey') || undefined, - returnTo: mockState.searchParams.get('returnTo') || undefined, - returnLabel: mockState.searchParams.get('returnLabel') || undefined - }; - }, - buildSignalEntityContextRows: () => [ - { label: tZh('signal.context.entity.label'), value: 'checkout', meta: 'entityId 7' }, - { label: tZh('signal.context.service.label'), value: 'checkout', meta: 'payments' }, - { label: tZh('signal.context.environment.label'), value: 'prod', meta: tZh('signal.context.environment.meta') }, - { label: tZh('signal.context.time.label'), value: 'last-1h', meta: tZh('signal.context.time.meta.default') }, - { label: tZh('signal.context.source.label'), value: 'OTLP', meta: tZh('signal.context.source.otlp.meta') } - ], - readEpochMillisRouteParam: (value: string | null | undefined) => { - const trimmed = value?.trim(); - return trimmed && /^\d+$/.test(trimmed) ? trimmed : undefined; - }, - readEntityIdRouteParam: (value: string | null | undefined) => { - const trimmed = value?.trim(); - return trimmed && /^\d+$/.test(trimmed) ? trimmed : undefined; - }, - stripReturnLabelFromHref: (href?: string | null) => { - if (!href) return undefined; - const [path, query = ''] = href.split('?'); - const params = new URLSearchParams(query); - params.delete('returnLabel'); - const search = params.toString(); - return search ? `${path}?${search}` : path; - }, - readSignalRouteContext: () => ({ - entityId: mockState.searchParams.get('entityId') || undefined, - entityName: mockState.searchParams.get('entityName') || undefined, - returnTo: mockState.searchParams.get('returnTo') || undefined, - serviceName: mockState.searchParams.get('serviceName') || undefined, - serviceNamespace: mockState.searchParams.get('serviceNamespace') || undefined, - environment: mockState.searchParams.get('environment') || undefined, - from: mockState.searchParams.get('from') || undefined, - to: mockState.searchParams.get('to') || undefined, - timeRange: mockState.searchParams.get('timeRange') || undefined, - timezone: mockState.searchParams.get('timezone') || undefined, - source: mockState.searchParams.get('source') || undefined, - collector: mockState.searchParams.get('collector') || undefined, - template: mockState.searchParams.get('template') || undefined, - traceId: mockState.searchParams.get('traceId') || undefined, - spanId: mockState.searchParams.get('spanId') || undefined - }) -})); - -vi.mock('@/lib/otlp-metrics/view-model', () => ({ - buildMetricsExplorerState: () => ({ - chartLabel: tZh('otlp.metrics.explorer.chart-label', { count: mockState.metricSeries.length }), - hasSeries: mockState.metricSeries.length > 0, - emptyTitle: tZh('otlp.metrics.explorer.empty-title'), - noMetricsTitle: tZh('otlp.metrics.explorer.no-metrics-title'), - sendMetricsLabel: tZh('otlp.metrics.explorer.waiting-ingest'), - seriesCountLabel: tZh('otlp.metrics.explorer.series-count', { count: mockState.metricSeries.length }) - }), - buildConsoleFacts: () => [ - { label: tZh('otlp.metrics.stats.total-series'), value: '0' }, - { label: tZh('otlp.metrics.stats.non-empty-series'), value: '0' }, - { label: tZh('otlp.metrics.stats.datasource'), value: 'prometheus' }, - { label: tZh('otlp.metrics.stats.latest-observed'), value: '-' } - ], - buildConsoleMetrics: () => [ - { label: tZh('otlp.metrics.stats.non-empty-series'), value: '0' }, - { label: tZh('otlp.metrics.stats.series-total'), value: '0' }, - { label: tZh('otlp.metrics.stats.intake-state'), value: tZh('common.empty') } - ], - buildContextRows: () => [ - { title: tZh('otlp.metrics.context.current-service'), copy: 'checkout', meta: 'payments' }, - { title: tZh('otlp.metrics.context.time-range'), copy: '2026-04-12 20:00:00 → 2026-04-12 20:00:00', meta: 'ok' } - ], - buildMetricSeriesContextRows: (series: any) => series - ? [ - { label: tZh('otlp.metrics.series.context.metric-name'), value: series.name, meta: tZh('otlp.metrics.series.context.selected-series') }, - { label: tZh('otlp.metrics.series.context.entity'), value: series.labels['hertzbeat.entity_name'] || '-', meta: tZh('otlp.metrics.series.entity-id', { entityId: series.labels['hertzbeat.entity_id'] || '-' }) }, - { label: tZh('otlp.metrics.series.context.service'), value: series.labels['service.name'] || series.labels.service_name || '-', meta: series.labels['service.namespace'] || series.labels.service_namespace || '-' }, - { label: tZh('otlp.metrics.series.context.template'), value: series.labels['hertzbeat.template'] || '-', meta: tZh('otlp.metrics.series.context.collector', { collector: series.labels['hertzbeat.collector'] || '-' }) }, - { label: tZh('otlp.metrics.series.context.environment'), value: series.labels['deployment.environment.name'] || series.labels.deployment_environment_name || '-', meta: tZh('otlp.metrics.series.context.deployment-environment') } - ] - : [], - buildMetricSeriesEvidenceRows: (series: any) => series - ? [ - { label: tZh('otlp.metrics.evidence.samples'), value: String((series.points || []).length), meta: tZh('otlp.metrics.evidence.real-samples') }, - { label: tZh('otlp.metrics.evidence.latest-value'), value: String(series.latestValue ?? '-'), meta: 'Recent sample' }, - { label: tZh('otlp.metrics.evidence.value-range'), value: '12 - 20', meta: tZh('otlp.metrics.evidence.average', { average: 16 }) }, - { label: tZh('otlp.metrics.evidence.sample-window'), value: 'T1000 → T2000', meta: tZh('otlp.metrics.evidence.real-sample-time') }, - { label: tZh('otlp.metrics.evidence.linked-trace'), value: series.labels.trace_id || '-', meta: series.labels.span_id || '-' } - ] - : [], - buildMetricSeriesSampleRows: (series: any) => series - ? (series.points || []).map(([timestamp, value]: [number, number | null], index: number) => ({ - key: `${series.key}:${timestamp}:${index}`, - index: String(index + 1), - timestamp: `T${timestamp}`, - rawTimestamp: String(timestamp), - value: value == null ? '-' : String(value), - state: value == null ? tZh('otlp.metrics.inspector.sample-state.empty') : tZh('otlp.metrics.inspector.sample-state.present') - })) - : [], - buildMetricSeriesAttributeRows: (series: any, search: string) => { - if (!series) return []; - const normalizedSearch = search.trim().toLowerCase(); - return Object.entries(series.labels || {}) - .map(([name, value]) => ({ key: name, name, value: String(value).trim() })) - .filter(row => row.value) - .filter(row => !normalizedSearch || `${row.name} ${row.value}`.toLowerCase().includes(normalizedSearch)) - .sort((left, right) => left.name.localeCompare(right.name)); - }, - buildMetricSeriesLinkedRecordRows: (series: any, handoffLinks: any) => series - ? [ - { - key: 'logs', - label: tZh('otlp.metrics.handoff.logs'), - value: series.labels.trace_id ? tZh('otlp.metrics.handoff.logs-by-trace') : tZh('otlp.metrics.handoff.logs-by-service'), - meta: series.labels.span_id ? tZh('otlp.metrics.handoff.logs-current-span') : tZh('otlp.metrics.handoff.logs-service-filter'), - href: handoffLinks.logsHref - }, - { - key: 'traces', - label: tZh('otlp.metrics.handoff.traces'), - value: series.labels.trace_id ? tZh('otlp.metrics.handoff.trace-open') : tZh('otlp.metrics.handoff.trace-waiting-id'), - meta: series.labels.span_id ? tZh('otlp.metrics.handoff.trace-full-current-span') : tZh('otlp.metrics.handoff.trace-missing-id'), - href: handoffLinks.tracesHref - }, - { - key: 'alerts', - label: tZh('otlp.metrics.handoff.alerts'), - value: series.labels['hertzbeat.entity_id'] ? tZh('otlp.metrics.handoff.alerts-by-entity') : tZh('otlp.metrics.handoff.alerts-by-service'), - meta: series.labels['hertzbeat.entity_id'] ? tZh('otlp.metrics.handoff.alerts-by-entity-meta') : tZh('otlp.metrics.handoff.alerts-by-service-meta'), - href: handoffLinks.alertHandlingHref - } - ] - : [], - buildMetricSeriesAttributionDiagnostics: (series: any) => { - if (!series) return []; - const row = (key: string, value: string | undefined, meta: string) => ({ - key, - label: key, - value: value || '-', - state: value ? 'present' : 'missing', - meta - }); - return [ - row('hertzbeat.entity_id', series.labels['hertzbeat.entity_id'], series.labels['hertzbeat.entity_id'] ? tZh('otlp.metrics.attribution.entity-id.present') : tZh('otlp.metrics.attribution.entity-id.missing')), - row('hertzbeat.entity_name', series.labels['hertzbeat.entity_name'], tZh('otlp.metrics.attribution.entity-name')), - row('hertzbeat.workspace_id', series.labels['hertzbeat.workspace_id'], tZh('otlp.metrics.attribution.workspace-id')), - row('hertzbeat.collector', series.labels['hertzbeat.collector'], tZh('otlp.metrics.attribution.collector')), - row('hertzbeat.template', series.labels['hertzbeat.template'], tZh('otlp.metrics.attribution.template')) - ]; - }, - buildMetricSeriesViews: () => mockState.metricSeries, - applyMetricsFormula: (seriesList: any[], formula?: string | null) => ( - formula?.trim() === 'A * 1000' - ? seriesList.map(series => ({ - ...series, - points: (series.points || []).map(([timestamp, value]: [number, number | null]) => [ - timestamp, - value == null ? null : value * 1000 - ]), - latestValue: series.latestValue == null ? null : series.latestValue * 1000 - })) - : seriesList - ), - buildMetricExpectedRangeConfig: (series: any) => series - ? { - label: tZh('otlp.metrics.expected-range.label'), - lowerLabel: tZh('otlp.metrics.expected-range.lower'), - upperLabel: tZh('otlp.metrics.expected-range.upper'), - lowerData: [[1000, 9]], - upperGapData: [[1000, 2]], - sampleCount: 1 - } - : null, - buildMetricsChartOption: (_seriesList: any[], thresholds?: any, expectedRange?: any, legendFormat?: any) => ({ - series: [], - dataZoom: [{ type: 'slider', start: 0, end: 100 }], - thresholdProof: thresholds || null, - expectedRangeProof: expectedRange || null, - legendFormatProof: legendFormat || null - }), - buildMetricThresholdConfig: (warningThreshold?: string, criticalThreshold?: string) => { - const warning = warningThreshold && Number.isFinite(Number(warningThreshold)) ? Number(warningThreshold) : undefined; - const critical = criticalThreshold && Number.isFinite(Number(criticalThreshold)) ? Number(criticalThreshold) : undefined; - return warning == null && critical == null - ? null - : { - warning, - critical, - warningLabel: tZh('otlp.metrics.threshold.warning'), - criticalLabel: tZh('otlp.metrics.threshold.critical') - }; - }, - buildMetricsDataZoomTimeContext: (_seriesList: any[], zoomRange: any, fallbackTimeRange?: string) => - zoomRange - ? { - timeRange: fallbackTimeRange || 'last-30m', - from: '1970-01-01 08:00:01', - to: '1970-01-01 08:00:02' - } - : null, - buildMetricSeriesRows: () => mockState.metricSeries.map(series => ({ - title: series.name, - copy: series.labels['service.name'] || series.labels.service_name || '-', - meta: series.latestValue == null ? '-' : String(series.latestValue), - description: series.description || '-', - metricType: series.metricType || '-', - unit: series.unit || '-', - sampleCount: (series.points || []).length, - pointCount: (series.points || []).length, - timeSeriesCount: 1, - entityLabel: series.labels['hertzbeat.entity_name'] || series.labels.hertzbeat_entity_name || '-', - entityMeta: series.labels['hertzbeat.entity_id'] || series.labels.hertzbeat_entity_id - ? tZh('otlp.metrics.series.entity-id', { entityId: series.labels['hertzbeat.entity_id'] || series.labels.hertzbeat_entity_id }) - : tZh('otlp.metrics.series.entity-missing'), - entityState: series.labels['hertzbeat.entity_id'] || series.labels.hertzbeat_entity_id ? 'present' : 'missing' - })), - buildMetricInventorySourceRows: (inventory: any) => (inventory?.items || []).map((item: any) => ({ - title: item.metricName, - copy: item.labels?.service_name || inventory.context?.serviceName || '-', - meta: '-', - description: '-', - metricType: item.family || '-', - unit: '-', - pointCount: 0, - sampleCount: 0, - timeSeriesCount: item.timeSeriesCount ?? 0, - latestObservedAt: item.latestObservedAt ?? null, - entityLabel: inventory.context?.entityName || '-', - entityMeta: inventory.context?.entityId - ? tZh('otlp.metrics.series.entity-id', { entityId: inventory.context.entityId }) - : tZh('otlp.metrics.series.entity-missing'), - entityState: inventory.context?.entityId ? 'present' : 'missing', - inventorySource: inventory.source, - inventoryLabels: item.labels || {}, - series: null - })), - buildMetricInventoryRows: (rows: any[], search: string, sort: string) => { - const normalizedSearch = search.trim().toLowerCase(); - const filteredRows = normalizedSearch - ? rows.filter(row => [ - row.title, - row.copy, - row.meta, - row.entityLabel, - row.entityMeta, - row.inventorySource, - row.series?.name, - ...Object.values(row.series?.labels || {}), - ...Object.values(row.inventoryLabels || {}) - ].join(' ').toLowerCase().includes(normalizedSearch)) - : [...rows]; - return filteredRows.sort((left, right) => { - if (sort === 'latest') return (right.series?.latestValue ?? Number.NEGATIVE_INFINITY) - (left.series?.latestValue ?? Number.NEGATIVE_INFINITY); - if (sort === 'samples') return (right.pointCount ?? 0) - (left.pointCount ?? 0); - if (sort === 'time-series') return (right.timeSeriesCount ?? 0) - (left.timeSeriesCount ?? 0); - return String(left.title).localeCompare(String(right.title)); - }); - }, - buildMetricTrendBars: () => [], - buildMetricsHandoffLinks: (_data: any, _query: any, _routeContext: any, selectedSeries?: any) => { - const serviceName = selectedSeries?.labels['service.name'] || selectedSeries?.labels.service_name || 'checkout'; - const serviceNamespace = selectedSeries?.labels['service.namespace'] || selectedSeries?.labels.service_namespace || 'payments'; - const environment = selectedSeries?.labels['deployment.environment.name'] || selectedSeries?.labels.deployment_environment_name || 'prod'; - const entityId = selectedSeries ? selectedSeries.labels['hertzbeat.entity_id'] : mockState.searchParams.get('entityId') || '7'; - const entityName = selectedSeries?.labels['hertzbeat.entity_name'] || 'Checkout API'; - const traceId = selectedSeries?.labels.trace_id || mockState.searchParams.get('traceId') || undefined; - const spanId = selectedSeries?.labels.span_id || mockState.searchParams.get('spanId') || undefined; - const collector = selectedSeries?.labels['hertzbeat.collector'] || undefined; - const template = selectedSeries?.labels['hertzbeat.template'] || undefined; - const params = new URLSearchParams({ - entityName, - serviceName, - serviceNamespace, - environment, - source: 'otlp' - }); - if (entityId) params.set('entityId', entityId); - if (traceId) params.set('traceId', traceId); - if (spanId) params.set('spanId', spanId); - if (collector) params.set('collector', collector); - if (template) params.set('template', template); - const logParams = new URLSearchParams(params); - if (traceId) { - logParams.set('view', 'list'); - } else { - logParams.set('search', `service.name = "${serviceName}"`); - } - const alertParams = new URLSearchParams(params); - alertParams.set('status', 'firing'); - alertParams.set('signal', 'metrics'); - alertParams.set('search', serviceName); - return { - intakeHref: '/ingestion/otlp?signal=metrics&returnTo=%2Fingestion%2Fotlp%2Fmetrics', - logsHref: `/log/manage?${logParams.toString()}`, - tracesHref: `/trace/manage?${params.toString()}`, - entitiesHref: `/entities?search=${encodeURIComponent(serviceName)}`, - entityHref: entityId ? `/entities/${entityId}?${params.toString()}` : `/entities?search=${encodeURIComponent(serviceName)}&${params.toString()}`, - alertRulesHref: `/alert/setting?signal=metrics&${params.toString()}`, - alertHandlingHref: `/alert?${alertParams.toString()}`, - dashboardHref: `/dashboard?intent=add-panel&signal=metrics&panelTitle=${encodeURIComponent(serviceName)}&${params.toString()}` - }; - } -})); - -beforeEach(() => { - mockState.searchParams = new URLSearchParams(); - mockState.replace.mockReset(); - mockState.lastLoad = null; - mockState.metricSeries = []; - (mockState.renderData as any).inventory = undefined; - apiMessageGet.mockReset(); - loadOtlpMetricsConsole.mockReset(); - loadOtlpMetricsInventory.mockReset(); - loadOtlpMetricsConsole.mockResolvedValue(mockState.renderData); - loadOtlpMetricsInventory.mockResolvedValue(null); -}); - -let interactionRoot: Root | null = null; -let interactionContainer: HTMLDivElement | null = null; - -afterEach(() => { - if (interactionRoot) { - act(() => { - interactionRoot?.unmount(); - }); - } - interactionRoot = null; - interactionContainer?.remove(); - interactionContainer = null; - globalThis.fetch = originalFetch; -}); - -describe('otlp metrics page', () => { - it('keeps the metrics header and query toolbar copy behind i18n keys', () => { - const source = readFileSync(resolve(process.cwd(), 'app/ingestion/otlp/metrics/otlp-metrics-page.tsx'), 'utf8'); - const slice = source.slice( - source.indexOf('const serviceGroupLabel'), - source.indexOf('data-otlp-metrics-chart-band="hertzbeat-ui-chart-band"') - ); - - expect(slice).not.toMatch(/[\u4e00-\u9fff]/); - expect(slice).toContain("t('otlp.metrics.header.kicker')"); - expect(slice).toContain("t('otlp.metrics.query.run')"); - expect(slice).toContain("t('otlp.metrics.group.service')"); - expect(slice).toContain("t('otlp.metrics.scope.all-services')"); - }); - - it('keeps metrics on the OTLP cold Workbench owner instead of the old external-product explorer stack', () => { - const source = readFileSync(resolve(process.cwd(), 'app/ingestion/otlp/metrics/otlp-metrics-page.tsx'), 'utf8'); - const messagesSource = readFileSync(resolve(process.cwd(), 'lib/i18n-runtime-messages.ts'), 'utf8'); - - expect(source).toContain('data-otlp-metrics-route="otlp-hertzbeat-ui-metrics-workbench"'); - expect(source).toContain('HzSignalWorkbenchShell'); - expect(source).toContain('data-otlp-metrics-shell-owner="hertzbeat-ui-signal-workbench-shell"'); - expect(source).toContain('layout="topology-workbench"'); - expect(source).not.toContain('className="flex min-h-[calc(100vh-56px)] flex-col gap-3 bg-[#07090b] px-3 pb-3 pt-0 text-[#e8edf5]"'); - expect(source).toContain('data-otlp-metrics-style-baseline="hertzbeat-ui-matte"'); - expect(source).toContain('data-otlp-metrics-query-bar="hertzbeat-ui-query-row"'); - expect(source).toContain('data-otlp-metrics-query-bar-owner="hertzbeat-ui-panel-surface"'); - expect(source).toContain('padding="query"'); - expect(source).toContain('data-otlp-metrics-query-control-stack="shared-inline-controls"'); - expect(source).toContain('data-otlp-metrics-query-control-stack-owner="hertzbeat-ui-control-stack"'); - expect(source).toContain('data-otlp-metrics-builder-control-stack="shared-query-builder-controls"'); - expect(source).toContain('data-otlp-metrics-builder-control-stack-owner="hertzbeat-ui-control-stack"'); - expect(source).toContain('data-otlp-metrics-context-control-stack="shared-inline-controls"'); - expect(source).toContain('data-otlp-metrics-context-control-stack-owner="hertzbeat-ui-control-stack"'); - expect(source).toContain('layout="inline-wrap"'); - expect(source).toContain('spacing="top-2"'); - expect(source).not.toContain('className="flex flex-wrap items-center gap-2"'); - expect(source).not.toContain('className="mt-2 flex flex-wrap items-center gap-2"'); - expect(source).not.toContain('data-otlp-metrics-context-control-stack-owner="hertzbeat-ui-control-stack"\n className="mt-2"'); - expect(source).toContain('HzSearchFieldFrame'); - expect(source).toContain('HzSearchFieldIcon'); - expect(source).toContain('HzInput'); - expect(source).toContain('data-otlp-metrics-query-input="true"'); - expect(source).toContain('data-otlp-metrics-query-search-frame="shared-search-field-frame"'); - expect(source).toContain('data-otlp-metrics-query-search-frame-owner="hertzbeat-ui-search-field-frame"'); - expect(source).toContain('width="metrics-query"'); - expect(source).toContain('data-otlp-metrics-query-search-icon-owner="hertzbeat-ui-search-field-icon"'); - expect(source).toContain('data-otlp-metrics-query-input-owner="hertzbeat-ui-input"'); - expect(source).toContain('inset="search-icon"'); - expect(source).toContain('width="metrics-query-expression"'); - expect(source).toContain('data-otlp-metrics-filter-input="true"'); - expect(source).toContain('data-otlp-metrics-filter-input-owner="hertzbeat-ui-input"'); - expect(source).toContain("aria-label={t('otlp.metrics.filter.aria')}"); - expect(source).toContain("placeholder={t('otlp.metrics.filter.placeholder')}"); - expect(messagesSource).toContain("'otlp.metrics.filter.placeholder': 'service.name = \"checkout\", http.route CONTAINS checkout, k8s.pod.name EXISTS'"); - expect(source).toContain('width="metrics-filter-expression"'); - expect(source).toContain('data-otlp-metrics-temporal-aggregation-select="true"'); - expect(source).toContain('data-otlp-metrics-temporal-aggregation-select-owner="hertzbeat-ui-select"'); - expect(source).toContain("aria-label={t('otlp.metrics.temporal.aria')}"); - expect(source).toContain('width="metrics-temporal-aggregation"'); - expect(source).toContain('data-otlp-metrics-temporal-aggregation-option'); - expect(source).toContain('data-otlp-metrics-step-input="true"'); - expect(source).toContain('data-otlp-metrics-step-input-owner="hertzbeat-ui-input"'); - expect(source).toContain('width="metrics-query-step"'); - expect(source).toContain('data-otlp-metrics-limit-input="true"'); - expect(source).toContain('data-otlp-metrics-limit-input-owner="hertzbeat-ui-input"'); - expect(source).toContain('width="metrics-query-limit"'); - expect(source).toContain('data-otlp-metrics-legend-format-input="true"'); - expect(source).toContain('data-otlp-metrics-legend-format-input-owner="hertzbeat-ui-input"'); - expect(source).toContain("aria-label={t('otlp.metrics.legend-format.aria')}"); - expect(source).toContain('data-otlp-metrics-formula-input="true"'); - expect(source).toContain('data-otlp-metrics-formula-input-owner="hertzbeat-ui-input"'); - expect(source).toContain("aria-label={t('otlp.metrics.formula.aria')}"); - expect(source).toContain('data-otlp-metrics-warning-threshold-input="true"'); - expect(source).toContain('data-otlp-metrics-warning-threshold-input-owner="hertzbeat-ui-input"'); - expect(source).toContain('data-otlp-metrics-critical-threshold-input="true"'); - expect(source).toContain('data-otlp-metrics-critical-threshold-input-owner="hertzbeat-ui-input"'); - expect(source).toContain('data-otlp-metrics-expected-range-toggle="true"'); - expect(source).toContain('data-otlp-metrics-expected-range-toggle-owner="hertzbeat-ui-button"'); - expect(source).toContain('buildMetricThresholdConfig(query.warningThreshold, query.criticalThreshold, t)'); - expect(source).toContain("query.expectedRange === 'on'"); - expect(source).toContain('buildMetricExpectedRangeConfig(selectedMetricSeries || metricSeries[0] || null, t)'); - expect(source).toContain('applyMetricsFormula(buildMetricSeriesViews(mergedData, t), query.formula)'); - expect(source).toContain('applyMetricsFormula(buildMetricSeriesViews(mergedData, t), query.formula)'); - expect(source).toContain('buildMetricsChartOption(metricSeries, metricThresholdConfig, metricExpectedRangeConfig, query.legendFormat)'); - expect(source).not.toContain('