maintenance(frontend): remove deprecated web-next

This commit is contained in:
Logic
2026-07-27 14:03:38 +08:00
parent b47aee0bce
commit a93ca81d4c
995 changed files with 0 additions and 351594 deletions
-5
View File
@@ -1,5 +0,0 @@
.next
.next-dev
node_modules
npm-debug.log
Dockerfile
-3
View File
@@ -1,3 +0,0 @@
{
"extends": ["next/core-web-vitals"]
}
-11
View File
@@ -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
-31
View File
@@ -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"]
-308
View File
@@ -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<ApprovalDraftResult | undefined>();
const [approvalDraftError, setApprovalDraftError] = React.useState<string | undefined>();
const [approvalDecisionStatus, setApprovalDecisionStatus] = React.useState<'blocked' | 'ready' | 'submitting' | 'decided' | 'failed'>('blocked');
const [approvalDecisionResult, setApprovalDecisionResult] = React.useState<ApprovalDecisionResult | undefined>();
const [approvalDecisionError, setApprovalDecisionError] = React.useState<string | undefined>();
const [catalogResult, setCatalogResult] = React.useState<ActionCatalogResult>({
state: state.catalogAdapter.state,
adapterOwner: state.catalogAdapter.adapterOwner,
managerBacked: state.catalogAdapter.managerBacked,
items: []
});
const [approvalDraftQueueResult, setApprovalDraftQueueResult] = React.useState<ApprovalDraftQueueResult>({
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 (
<main
className={coldOpsVisual.entry.main}
data-actions-route="otlp-hertzbeat-ui-ops-entry"
data-actions-style-baseline={coldOpsVisual.canvasName}
data-actions-placeholder-replacement="api-backed-workbench"
data-actions-legacy-open-context="adapter-boundary-panel"
data-actions-legacy-entity-handoff="/entities"
>
<div className={coldOpsVisual.entry.container}>
<HzActionWorkbench
data-actions-shared-workbench="hertzbeat-ui"
data-actions-placeholder-replacement="api-backed-workbench"
data-actions-legacy-open-context="adapter-boundary-panel"
data-actions-legacy-entity-handoff="/entities"
title={state.title}
subtitle={state.subtitle}
sourceLabel={state.kicker}
actions={state.actions}
shell={state.shell}
adapterBoundary={state.adapterBoundary}
catalogAdapter={{
...state.catalogAdapter,
state: catalogResult.state,
adapterOwner: catalogResult.adapterOwner,
managerBacked: catalogResult.managerBacked,
items: catalogResult.items.map(item => ({
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}
/>
</div>
</main>
);
}
-201
View File
@@ -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(<ActionsPage />);
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 <ActionsPage suggestionContext={suggestionContext} />');
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(
<ActionsPage
suggestionContext={{
source: 'alert',
signal: 'traces',
severity: 'critical',
entityId: 'service:commerce/checkout',
entityName: 'checkout-api',
serviceName: 'checkout-api',
serviceNamespace: 'commerce',
environment: 'prod',
timeRange: 'last-1h',
traceId: 'trace-123',
spanId: 'span-456',
collector: 'edge-collector-a',
template: 'java-service',
returnTo: `/alert?status=firing&returnLabel=${encodeURIComponent(t('alert.center.default-title'))}`
}}
/>
);
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('&quot;actionId&quot;:&quot;suggest-restart-checkout&quot;');
expect(html).toContain('&quot;executionAllowed&quot;: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(
<ActionsPage
suggestionContext={{
source: 'product-design-1590-default'
}}
/>
);
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('&quot;actionId&quot;:&quot;suggest-restart-checkout&quot;');
});
it('renders entity-id-only suggested remediation targets with localized fallback copy', async () => {
const { default: ActionsPage } = await import('./actions-page');
const html = renderToStaticMarkup(
<ActionsPage
suggestionContext={{
entityId: 'service:commerce/checkout',
source: 'entity',
returnTo: `/entities/service%3Acommerce%2Fcheckout?returnLabel=${encodeURIComponent(t('actions.suggestion.source.entity'))}`
}}
/>
);
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=');
});
});
-13
View File
@@ -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<ActionsSearchParams>;
}) {
const resolvedSearchParams = await searchParams;
const suggestionContext = readActionsSuggestionContext(resolvedSearchParams);
return <ActionsPage suggestionContext={suggestionContext} />;
}
-37
View File
@@ -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');
});
});
-271
View File
@@ -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<AlertEntityResponseResult>['action'] {
return action === 'recover' ? 'resolve' : action;
}
export function resolveRealtimeGroupId(alert: Pick<GroupAlert, 'id' | 'groupKey'> | 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<AlertQueryState>(initialQuery);
const [query, setQuery] = useState<AlertQueryState>(initialQuery);
const [refreshNonce, setRefreshNonce] = useState(0);
const [operationFeedback, setOperationFeedback] = useState<{ tone: 'success' | 'danger'; copy: string } | null>(null);
const [selectedGroupIds, setSelectedGroupIds] = useState<number[]>([]);
const [entityResponseResult, setEntityResponseResult] = useState<AlertEntityResponseResult>(null);
const [realtimeEventCount, setRealtimeEventCount] = useState(0);
const [realtimeGroupIds, setRealtimeGroupIds] = useState<number[]>([]);
const realtimeHighlightTimers = useRef<number[]>([]);
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<string>) => {
const alert = parseHeaderSseJson<GroupAlert>(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<AlertPageData> => {
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 (
<ClientWorkbench
load={load}
loadingCopy={t('alert.center.loading')}
cacheKey={alertCenterCacheKey}
cacheSettledTtlMs={ALERT_CENTER_SETTLED_CACHE_TTL_MS}
>
{data => (
<AlertCenterSurface
t={t}
data={data}
draft={draft}
onDraftChange={setDraft}
onRefresh={refreshQuery}
onClearFilters={() => {
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}
/>
)}
</ClientWorkbench>
);
}
@@ -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');
}
});
});
@@ -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');
}
});
});
-67
View File
@@ -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'
);
});
});
-9
View File
@@ -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<SearchParamsRecord>;
}) {
const resolvedSearchParams = await props?.searchParams;
redirect(buildAlertCompatRouteUrlFromSearchParams(resolvedSearchParams));
}
@@ -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<AlertGroupValidationField, string> = {
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<keyof AlertGroupFormDraft> = [
'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<HTMLInputElement>(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<AlertGroupListRouteState>(() => ({
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<number>(routeListState.pageSize);
const [selectedId, setSelectedId] = useState<number | null>(null);
const [editorOpen, setEditorOpen] = useState(false);
const [editorLoading, setEditorLoading] = useState(false);
const [editorSaving, setEditorSaving] = useState(false);
const [editorMessage, setEditorMessage] = useState<string | null>(null);
const [editorError, setEditorError] = useState<string | null>(null);
const [editorErrorDetail, setEditorErrorDetail] = useState<string | null>(null);
const [editorErrorContract, setEditorErrorContract] = useState<AlertGroupErrorContract | null>(null);
const [draft, setDraft] = useState<AlertGroupFormDraft>(() => 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<number[]>([]);
const [deleteRequest, setDeleteRequest] = useState<GroupDeleteRequest | null>(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 (
<ClientWorkbench
load={load}
loadingCopy={t('alert.group.loading')}
cacheKey={alertGroupCacheKey}
cacheSettledTtlMs={ALERT_GROUP_SETTLED_CACHE_TTL_MS}
>
{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 (
<>
<AlertGroupSurface
t={t}
data={data}
search={search}
selectedId={selectedId}
checkedIds={checkedIds}
requestedPageSize={pageSize}
editorOpen={editorOpen}
editorLoading={editorLoading}
editorSaving={editorSaving}
editorMessage={editorMessage}
editorError={editorError}
editorErrorDetail={editorErrorDetail}
editorErrorContract={editorErrorContract}
evidenceContext={groupEvidenceContext}
draft={draft}
formatTime={formatTime}
labelOptions={labelOptions}
onSearchChange={setSearch}
onApplyFilter={handleApplyFilter}
onClearFilter={handleClearFilter}
onRefresh={handleRefresh}
onSelect={setSelectedId}
onCheckedIdsChange={setCheckedIds}
pageSizeOptions={[...ALERT_GROUP_PAGE_SIZE_OPTIONS]}
onPageIndexChange={handlePageIndexChange}
onPageSizeChange={handlePageSizeChange}
onNew={() => 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}
/>
<div
data-alert-group-unsaved-cancel="hertzbeat-ui-confirm-dialog"
data-alert-group-unsaved-cancel-state={editorDiscardDialogOpen ? 'open' : 'closed'}
>
<HzConfirmDialog
open={editorDiscardDialogOpen}
title={t('alert.group.unsaved-cancel.title')}
kicker={t('alert.group.unsaved-cancel.kicker')}
copy={t('alert.group.unsaved-cancel.copy')}
confirmLabel={t('alert.group.unsaved-cancel.discard')}
cancelLabel={t('alert.group.unsaved-cancel.keep-editing')}
onCancel={() => setEditorDiscardDialogOpen(false)}
onConfirm={handleCloseEditor}
/>
</div>
<div data-alert-delete-confirm={deleteRequest ? 'open' : 'closed'}>
<HzConfirmDialog
open={Boolean(deleteRequest)}
title={deleteRequest?.kind === 'batch' ? t('common.confirm.delete-batch') : t('common.confirm.delete')}
copy={deleteConfirmCopy}
confirmLabel={t('alert.group.delete.confirm.action')}
cancelLabel={t('common.button.cancel')}
pending={deletePending}
onCancel={() => setDeleteRequest(null)}
onConfirm={() => void handleConfirmedDelete()}
/>
</div>
</>
);
}}
</ClientWorkbench>
);
}
-439
View File
@@ -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<unknown>),
lastSurfaceProps: null as null | Record<string, any>,
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<unknown>;
loadingCopy?: string;
}) => {
mockState.lastLoad = load;
return (
<div data-client-workbench="true" data-loading-copy={loadingCopy}>
{children(mockState.renderData)}
</div>
);
}
}));
vi.mock('../../../components/pages/alert-group-surface', () => ({
AlertGroupSurface: (props: any) => {
const {
data,
labelOptions,
evidenceContext,
draft,
pageSizeOptions,
search
} = props;
mockState.lastSurfaceProps = props;
return (
<div
data-alert-group-surface="true"
data-total={data.list.totalElements}
data-label-options={labelOptions?.keys?.join('|')}
data-page-size-options={pageSizeOptions?.join('|')}
data-requested-page-size={props.requestedPageSize}
data-search={search}
data-alert-group-evidence-context={evidenceContext ? 'signal-route' : 'none'}
data-alert-group-evidence-signal={evidenceContext?.signal ?? ''}
data-alert-group-evidence-return={evidenceContext?.returnHref ?? ''}
data-alert-group-prefill-labels={evidenceContext?.groupLabelsText ?? ''}
data-alert-group-draft-labels={draft?.groupLabelsText ?? ''}
/>
);
}
}));
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(<AlertGroupPage initialRouteState={initialRouteState} />);
}
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<number>(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<PageResult<AlertGroupConverge>>(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(<AlertGroupPage />);
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(<AlertGroupPage />);
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(<AlertGroupPage />);
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(<AlertGroupPage />);
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(<AlertGroupPage />);
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')}");
});
});
-14
View File
@@ -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<AlertGroupSearchParams>;
}) {
const resolvedSearchParams = await searchParams;
const routeState = readAlertGroupRouteState(resolvedSearchParams);
return <AlertGroupPage initialRouteState={routeState} />;
}
@@ -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<AlertInhibitValidationField, string> = {
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<keyof AlertInhibitFormDraft> = [
'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<AlertInhibit> {
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<T>(promise: Promise<T>, fallback: T, timeoutMs: number): Promise<T> {
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<HTMLInputElement>(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<AlertInhibitListRouteState>(() => ({
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<number>(routeListState.pageSize);
const [selectedId, setSelectedId] = useState<number | null>(null);
const [editorOpen, setEditorOpen] = useState(false);
const [editorLoading, setEditorLoading] = useState(false);
const [editorSaving, setEditorSaving] = useState(false);
const [editorMessage, setEditorMessage] = useState<string | null>(null);
const [editorError, setEditorError] = useState<string | null>(null);
const [editorErrorDetail, setEditorErrorDetail] = useState<string | null>(null);
const [editorErrorContract, setEditorErrorContract] = useState<'save' | 'enable' | 'delete' | null>(null);
const [draft, setDraft] = useState<AlertInhibitFormDraft>(() => 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<number[]>([]);
const [deleteRequest, setDeleteRequest] = useState<InhibitDeleteRequest | null>(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<string | null>(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 (
<ClientWorkbench
load={load}
loadingCopy={t('alert.inhibit.loading')}
cacheKey={alertInhibitCacheKey}
cacheSettledTtlMs={ALERT_INHIBIT_SETTLED_CACHE_TTL_MS}
>
{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 (
<div data-alert-delete-confirm={deleteRequest ? 'open' : 'closed'}>
<HzConfirmDialog
open={Boolean(deleteRequest)}
title={deleteRequest?.kind === 'batch' ? t('common.confirm.delete-batch') : t('common.confirm.delete')}
copy={deleteConfirmCopy}
confirmLabel={t('alert.inhibit.delete.confirm.action')}
cancelLabel={t('common.button.cancel')}
pending={deletePending}
onCancel={() => setDeleteRequest(null)}
onConfirm={() => void handleConfirmedDelete()}
/>
</div>
);
})()}
<AlertInhibitSurface
t={t}
data={data}
search={search}
selectedId={selectedId}
checkedIds={checkedIds}
editorOpen={editorOpen}
editorLoading={editorLoading}
editorSaving={editorSaving}
editorMessage={editorMessage}
editorError={editorError}
editorErrorDetail={editorErrorDetail}
editorErrorContract={editorErrorContract}
returnContext={returnContext}
managementContext={managementContext}
matchedViewEnabled={useMatchedView}
missingMatchedRuleCount={data.missingMatchedRuleCount ?? 0}
createdOutsideMatchedViewNotice={createdOutsideMatchedViewNotice}
entityPrefillSource={entityPrefillSource}
entityPrefillWarning={entityPrefillWarning}
evidenceContext={inhibitEvidenceContext}
draft={draft}
labelOptions={labelOptions}
formatTime={formatTime}
onSearchChange={setSearch}
onApplyFilter={handleApplyFilter}
onClearFilter={handleClearFilter}
onRefresh={handleRefresh}
onSelect={setSelectedId}
onCheckedIdsChange={setCheckedIds}
pageSizeOptions={[...ALERT_INHIBIT_PAGE_SIZE_OPTIONS]}
requestedPageSize={pageSize}
onPageIndexChange={handlePageIndexChange}
onPageSizeChange={handlePageSizeChange}
onViewAllRules={() => {
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))}
/>
<div
data-alert-inhibit-unsaved-cancel="hertzbeat-ui-confirm-dialog"
data-alert-inhibit-unsaved-cancel-state={editorDiscardDialogOpen ? 'open' : 'closed'}
>
<HzConfirmDialog
open={editorDiscardDialogOpen}
title={t('alert.inhibit.unsaved-cancel.title')}
kicker={t('alert.inhibit.unsaved-cancel.kicker')}
copy={t('alert.inhibit.unsaved-cancel.copy')}
confirmLabel={t('alert.inhibit.unsaved-cancel.discard')}
cancelLabel={t('alert.inhibit.unsaved-cancel.keep-editing')}
onCancel={() => setEditorDiscardDialogOpen(false)}
onConfirm={handleCloseEditor}
/>
</div>
</>
);
}}
</ClientWorkbench>
);
}
-650
View File
@@ -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<unknown>),
lastSurfaceProps: null as null | Record<string, any>,
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<unknown>;
loadingCopy?: string;
}) => {
mockState.lastLoad = load;
return (
<div data-client-workbench="true" data-loading-copy={loadingCopy}>
{children(mockState.renderData)}
</div>
);
}
}));
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 (
<div
data-alert-inhibit-surface="true"
data-total={data.list.totalElements}
data-page-size-options={pageSizeOptions?.join('|')}
data-requested-page-size={props.requestedPageSize}
data-search={search}
data-return-context={JSON.stringify(returnContext ?? {})}
data-management-context={JSON.stringify(managementContext ?? {})}
data-alert-inhibit-match-view={matchedViewEnabled ? 'matched' : 'all'}
data-alert-inhibit-missing-rule-count={missingMatchedRuleCount ?? 0}
data-alert-inhibit-created-outside-matched={createdOutsideMatchedViewNotice ? 'true' : 'false'}
data-alert-inhibit-entity-prefill-source={entityPrefillSource ?? 'none'}
data-alert-inhibit-entity-prefill-warning={entityPrefillWarning ?? ''}
data-alert-inhibit-evidence-context={evidenceContext ? 'signal-route' : 'none'}
data-alert-inhibit-evidence-signal={evidenceContext?.signal ?? ''}
data-alert-inhibit-evidence-return={evidenceContext?.returnHref ?? ''}
data-alert-inhibit-prefill-source-labels={evidenceContext?.sourceLabelsText ?? ''}
data-alert-inhibit-draft-source-labels={draft?.sourceLabelsText ?? ''}
data-alert-inhibit-draft-target-labels={draft?.targetLabelsText ?? ''}
data-alert-inhibit-draft-equal-labels={draft?.equalLabelsText ?? ''}
/>
);
}
}));
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(<AlertInhibitPage initialRouteState={initialRouteState} />);
}
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('&quot;edgeId&quot;:&quot;svc-checkout--res-orders-db&quot;');
expect(html).toContain('&quot;viewMode&quot;:&quot;resource-dependency&quot;');
expect(html).toContain('&quot;sourceKind&quot;:&quot;database-middleware-connection&quot;');
expect(html).toContain('&quot;returnTo&quot;:&quot;/topology?viewMode=resource-dependency&amp;sourceKind=database-middleware-connection&amp;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<number>(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<PageResult<AlertInhibit>>(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(<AlertInhibitPage />);
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(<AlertInhibitPage />);
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(<AlertInhibitPage />);
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(<AlertInhibitPage />);
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(<AlertInhibitPage />);
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(<AlertInhibitPage />);
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('&quot;matchMode&quot;:&quot;entity-noise-controls&quot;');
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')");
});
});
-14
View File
@@ -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<AlertInhibitSearchParams>;
}) {
const resolvedSearchParams = await searchParams;
const routeState = readAlertInhibitRouteState(resolvedSearchParams);
return <AlertInhibitPage initialRouteState={routeState} />;
}
@@ -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 (
<section
data-alert-integration-canonical-redirect="pending"
className="min-h-[260px]"
/>
);
}
@@ -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) => (
<a href={href} {...props}>
{children}
</a>
)
}));
vi.mock('next/headers', () => ({
headers
}));
vi.mock('@/components/workbench/workbench-page', () => ({
WorkbenchPage: ({ title, subtitle, facts, actions, main, side, tone }: any) => (
<main data-workbench-page="true" data-tone={tone}>
<h1>{title}</h1>
<p>{subtitle}</p>
<div data-facts="true">{facts.map((fact: any) => `${fact.label}:${fact.value}`).join('|')}</div>
<div data-actions="true">{actions}</div>
<div data-main="true">{main}</div>
<div data-side="true">{side}</div>
</main>
),
RowList: ({ rows }: any) => <div data-row-list="true">{rows.map((row: any) => `${row.title}||${row.copy}||${row.meta}`).join('|')}</div>
}));
vi.mock('@/components/observability', () => ({
DrawerCodePreview: ({ children }: any) => <pre data-drawer-code-preview="true">{children}</pre>,
DrawerSection: ({ title, children }: any) => (
<aside data-drawer-section={title}>
<h3>{title}</h3>
{children}
</aside>
),
StageSection: ({ title, description, children }: any) => (
<section data-stage-section={title}>
<h2>{title}</h2>
{description ? <p>{description}</p> : null}
{children}
</section>
)
}));
vi.mock('@/components/ui/button', () => ({
Button: ({ children, ...props }: any) => <button {...props}>{children}</button>
}));
vi.mock('@/lib/utils', () => ({
cn: (...inputs: Array<string | false | null | undefined>) => 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 <AlertIntegrationSourceRedirect href={buildAlertIntegrationSourceHref(selectedSource)} />');
expect(source).not.toContain("from 'next/navigation'");
expect(redirectSource).toContain('window.location.replace(href)');
expect(redirectSource).toContain('data-alert-integration-canonical-redirect="pending"');
});
});
@@ -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 <AlertIntegrationSourceRedirect href={buildAlertIntegrationSourceHref(selectedSource)} />;
}
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 (
<HzSourceDocShell
data-alert-integration-surface="hertzbeat-ui-source-doc"
data-alert-integration-shell-owner="hertzbeat-ui-source-doc-shell"
eyebrow={t('alert.integration.kicker')}
title={selectedSourceName}
docTitle={selectedSourceName}
sourceRailLabel={sourceRailLabel}
sourceItems={sourceItems}
sourceLinkComponent={Link}
actions={
<Link
href="/setting/settings/token"
className="inline-flex h-7 items-center justify-center rounded-[3px] border border-[#303743] bg-[#151922] px-3 text-[12px] font-semibold leading-none text-[#dfe6f3] transition-colors hover:border-[#415072] hover:bg-[#1a2130]"
data-alert-integration-token-action-owner="hertzbeat-ui-button-link"
>
<Settings className="mr-1.5 h-3.5 w-3.5" aria-hidden="true" />
{t('alert.integration.token.manage')}
</Link>
}
>
{doc === fallbackDocCopy ? (
<p data-alert-integration-markdown="rendered" className="text-[13px] leading-7 text-[#a9b0bb]">
{doc}
</p>
) : (
<AlertIntegrationMarkdown content={doc} />
)}
</HzSourceDocShell>
);
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
-14
View File
@@ -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<AlertNoticeSearchParams>;
}) {
const resolvedSearchParams = await searchParams;
const routeState = readAlertNoticeRouteState(resolvedSearchParams);
return <AlertNoticePage initialRouteState={routeState} />;
}
-464
View File
@@ -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<unknown>),
lastSurfaceProps: null as null | Record<string, any>,
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<unknown>;
loadingCopy?: string;
}) => {
mockState.lastLoad = load;
return <div data-client-workbench="true" data-loading-copy={loadingCopy}>{children(mockState.renderData)}</div>;
}
}));
vi.mock('../../components/pages/alert-center-surface', () => ({
AlertCenterSurface: (props: any) => {
mockState.lastSurfaceProps = props;
return (
<div
data-alert-center-surface="otlp-cold-center-console"
data-alert-center-style-baseline="hertzbeat-ui-matte"
data-draft={JSON.stringify(props.draft)}
data-rule-create={typeof props.onRuleQuickCreate}
data-realtime-event-count={props.realtimeEventCount}
data-realtime-group-ids={(props.realtimeGroupIds || []).join(',')}
/>
);
}
}));
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(<AlertCenterPage initialRouteState={initialRouteState} />);
}
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<GroupAlert>(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<AlertEntityResponseResult>(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)');
});
});
-14
View File
@@ -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<AlertCenterSearchParams>;
}) {
const resolvedSearchParams = await searchParams;
const routeState = readAlertCenterRouteState(resolvedSearchParams);
return <AlertCenterPage initialRouteState={routeState} />;
}
@@ -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, string | number>) => 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<AlertSettingCreateDraft> {
const seed: Partial<AlertSettingCreateDraft> = { 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<AlertSettingCreateDraft>
): 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<AlertSettingListRouteState>(() => ({
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<number[]>([]);
const [createMode, setCreateMode] = useState<AlertSettingCreateMode | 'closed'>(
() => 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<AlertSettingCreatePreviewFeedback | null>(null);
const [deleteRequest, setDeleteRequest] = useState<SettingDeleteRequest | null>(null);
const [deletePending, setDeletePending] = useState(false);
const [exportDialogOpen, setExportDialogOpen] = useState(false);
const [pendingExportType, setPendingExportType] = useState<HzExportTypeDialogType | null>(null);
const [pendingActionId, setPendingActionId] = useState<string | null>(null);
const [actionFeedback, setActionFeedback] = useState<AlertSettingActionFeedback | null>(null);
const [saveFeedback, setSaveFeedback] = useState<AlertSettingSaveFeedback | null>(null);
const importInputRef = useRef<HTMLInputElement | null>(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<string>,
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<HTMLInputElement>) {
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 (
<ClientWorkbench
load={load}
loadingCopy={t('alert.setting.loading')}
cacheKey={alertSettingCacheKey}
cacheSettledTtlMs={ALERT_SETTING_SETTLED_CACHE_TTL_MS}
>
{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 (
<>
<AlertSettingSurface
t={t}
data={data}
search={search}
checkedIds={checkedIds}
evidenceContext={evidenceContext}
formatTime={formatTime}
onSearchChange={setSearch}
onApplyFilter={() => {
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}
/>
<HzFileInput
ref={importInputRef}
accept={ALERT_DEFINE_IMPORT_FILE_ACCEPT}
aria-label={t('alert.setting.import.input')}
multiple={false}
data-alert-setting-import-input-owner="hertzbeat-ui-file-input"
data-alert-setting-import-file-input="true"
data-alert-setting-import-upload-contract="angular-nz-upload-limit-one-no-list"
data-alert-setting-import-show-list="false"
data-alert-setting-import-refresh-contract="angular-success-refresh"
data-alert-setting-import-failure-refresh-contract="angular-failure-no-refresh"
onChange={event => void handleImportChange(event)}
/>
<AlertSettingCreateDialog
t={t}
open={createMode !== 'closed'}
mode={createMode === 'closed' ? 'type' : createMode}
intent={typeof createDraft.id === 'number' ? 'edit' : 'create'}
datasourceStatus={data.datasourceStatus}
draft={createDraft}
submitting={creating}
saveFeedback={saveFeedback}
previewing={previewing}
previewFeedback={previewFeedback}
evidenceReturnHref={evidenceContext?.returnHref}
onClose={closeCreateFlow}
onSelectType={selectCreateType}
onDraftChange={updateCreateDraft}
onBackToType={() => {
setPreviewFeedback(null);
setCreateMode('type');
}}
onSubmit={submitCreate}
onPreview={previewCreate}
/>
<div data-alert-delete-confirm={deleteRequest ? 'open' : 'closed'}>
<HzConfirmDialog
open={Boolean(deleteRequest)}
kicker={t('common.confirm.operation')}
title={deleteRequest?.kind === 'batch' ? t('common.confirm.delete-batch') : t('common.confirm.delete')}
copy={deleteConfirmCopy}
confirmLabel={t('alert.setting.delete.confirm.action')}
cancelLabel={t('common.button.cancel')}
pending={deletePending}
onCancel={() => setDeleteRequest(null)}
onConfirm={() => void confirmDelete()}
/>
</div>
<HzExportTypeDialog
open={exportDialogOpen}
title={t('alert.export.switch-type')}
description={t('alert.setting.export.selected', { count: checkedIds.length })}
scope="selected"
selectedCount={checkedIds.length}
closeLabel={t('common.button.cancel')}
onClose={() => 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<typeof HzExportTypeDialog>['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<typeof HzExportTypeDialog>['excelButtonProps']
}
/>
</>
);
}}
</ClientWorkbench>
);
}
File diff suppressed because it is too large Load Diff
-14
View File
@@ -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<AlertSettingSearchParams>;
}) {
const resolvedSearchParams = await searchParams;
const routeState = readAlertSettingRouteState(resolvedSearchParams);
return <AlertSettingPage initialRouteState={routeState} />;
}
@@ -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<AlertSilenceValidationField, string> = {
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<keyof AlertSilenceFormDraft> = [
'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<AlertSilence> {
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<T>(promise: Promise<T>, fallback: T, timeoutMs: number): Promise<T> {
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<HTMLInputElement>(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<AlertSilenceListRouteState>(() => ({
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<number>(routeListState.pageSize);
const [selectedId, setSelectedId] = useState<number | null>(null);
const [editorOpen, setEditorOpen] = useState(false);
const [editorLoading, setEditorLoading] = useState(false);
const [editorSaving, setEditorSaving] = useState(false);
const [editorMessage, setEditorMessage] = useState<string | null>(null);
const [editorError, setEditorError] = useState<string | null>(null);
const [editorErrorDetail, setEditorErrorDetail] = useState<string | null>(null);
const [editorErrorContract, setEditorErrorContract] = useState<'save' | 'enable' | 'delete' | null>(null);
const [draft, setDraft] = useState<AlertSilenceFormDraft>(() => 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<number[]>([]);
const [deleteRequest, setDeleteRequest] = useState<SilenceDeleteRequest | null>(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<string | null>(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 (
<ClientWorkbench
load={load}
loadingCopy={t('alert.silence.loading')}
cacheKey={alertSilenceCacheKey}
cacheSettledTtlMs={ALERT_SILENCE_SETTLED_CACHE_TTL_MS}
>
{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 (
<div data-alert-delete-confirm={deleteRequest ? 'open' : 'closed'}>
<HzConfirmDialog
open={Boolean(deleteRequest)}
title={deleteRequest?.kind === 'batch' ? t('common.confirm.delete-batch') : t('common.confirm.delete')}
copy={deleteConfirmCopy}
confirmLabel={t('alert.silence.delete.confirm.action')}
cancelLabel={t('common.button.cancel')}
pending={deletePending}
onCancel={() => setDeleteRequest(null)}
onConfirm={() => void handleConfirmedDelete()}
/>
</div>
);
})()}
<AlertSilenceSurface
t={t}
data={data}
search={search}
selectedId={selectedId}
checkedIds={checkedIds}
editorOpen={editorOpen}
editorLoading={editorLoading}
editorSaving={editorSaving}
editorMessage={editorMessage}
editorError={editorError}
editorErrorDetail={editorErrorDetail}
editorErrorContract={editorErrorContract}
returnContext={returnContext}
managementContext={managementContext}
matchedViewEnabled={useMatchedView}
missingMatchedRuleCount={data.missingMatchedRuleCount ?? 0}
createdOutsideMatchedViewNotice={createdOutsideMatchedViewNotice}
entityPrefillSource={entityPrefillSource}
entityPrefillWarning={entityPrefillWarning}
evidenceContext={silenceEvidenceContext}
draft={draft}
labelOptions={labelOptions}
formatTime={formatTime}
onSearchChange={setSearch}
onApplyFilter={handleApplyFilter}
onClearFilter={handleClearFilter}
onRefresh={handleRefresh}
onSelect={setSelectedId}
onCheckedIdsChange={setCheckedIds}
pageSizeOptions={[...ALERT_SILENCE_PAGE_SIZE_OPTIONS]}
requestedPageSize={pageSize}
onPageIndexChange={handlePageIndexChange}
onPageSizeChange={handlePageSizeChange}
onViewAllRules={() => {
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}
/>
<div
data-alert-silence-unsaved-cancel="hertzbeat-ui-confirm-dialog"
data-alert-silence-unsaved-cancel-state={editorDiscardDialogOpen ? 'open' : 'closed'}
>
<HzConfirmDialog
open={editorDiscardDialogOpen}
title={t('alert.silence.unsaved-cancel.title')}
kicker={t('alert.silence.unsaved-cancel.kicker')}
copy={t('alert.silence.unsaved-cancel.copy')}
confirmLabel={t('alert.silence.unsaved-cancel.discard')}
cancelLabel={t('alert.silence.unsaved-cancel.keep-editing')}
onCancel={() => setEditorDiscardDialogOpen(false)}
onConfirm={handleCloseEditor}
/>
</div>
</>
);
}}
</ClientWorkbench>
);
}
-644
View File
@@ -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<unknown>),
lastSurfaceProps: null as null | Record<string, any>,
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<unknown>;
loadingCopy?: string;
}) => {
mockState.lastLoad = load;
return (
<div data-client-workbench="true" data-loading-copy={loadingCopy}>
{children(mockState.renderData)}
</div>
);
}
}));
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 (
<div
data-alert-silence-surface="true"
data-total={data.list.totalElements}
data-page-size-options={pageSizeOptions?.join('|')}
data-requested-page-size={props.requestedPageSize}
data-search={search}
data-return-context={JSON.stringify(returnContext ?? {})}
data-alert-silence-match-mode={managementContext?.matchMode ?? ''}
data-alert-silence-match-view={matchedViewEnabled ? 'matched' : 'all'}
data-alert-silence-created-outside-matched={createdOutsideMatchedViewNotice ? 'true' : 'false'}
data-alert-silence-matching-rule-ids={managementContext?.matchingRuleIds?.join(',') ?? ''}
data-alert-silence-missing-rule-count={missingMatchedRuleCount ?? 0}
data-alert-silence-entity-prefill-source={entityPrefillSource ?? 'none'}
data-alert-silence-entity-prefill-warning={entityPrefillWarning ?? ''}
data-alert-silence-evidence-context={evidenceContext ? 'signal-route' : 'none'}
data-alert-silence-evidence-signal={evidenceContext?.signal ?? ''}
data-alert-silence-evidence-return={evidenceContext?.returnHref ?? ''}
data-alert-silence-prefill-labels={evidenceContext?.labelsText ?? ''}
data-alert-silence-draft-labels={draft?.labelsText ?? ''}
/>
);
}
}));
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(<AlertSilencePage initialRouteState={initialRouteState} />);
}
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('&quot;edgeId&quot;:&quot;svc-checkout--res-orders-db&quot;');
expect(html).toContain('&quot;viewMode&quot;:&quot;resource-dependency&quot;');
expect(html).toContain('&quot;sourceKind&quot;:&quot;database-middleware-connection&quot;');
expect(html).toContain('&quot;returnTo&quot;:&quot;/topology?viewMode=resource-dependency&amp;sourceKind=database-middleware-connection&amp;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&amp;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<number>(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<PageResult<AlertSilence>>(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(<AlertSilencePage />);
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(<AlertSilencePage />);
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(<AlertSilencePage />);
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(<AlertSilencePage />);
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(<AlertSilencePage />);
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(<AlertSilencePage />);
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')");
});
});
-14
View File
@@ -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<AlertSilenceSearchParams>;
}) {
const resolvedSearchParams = await searchParams;
const routeState = readAlertSilenceRouteState(resolvedSearchParams);
return <AlertSilencePage initialRouteState={routeState} />;
}
-48
View File
@@ -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);
});
-11
View File
@@ -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<SearchParamsRecord>;
}) {
const resolvedSearchParams = await searchParams;
redirect(buildAlertCompatRouteUrlFromSearchParams(resolvedSearchParams));
}
-44
View File
@@ -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');
});
});
-31
View File
@@ -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;
@@ -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;
}
@@ -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;
}
-24
View File
@@ -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;
}
@@ -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'
});
});
});
@@ -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<string, unknown> | 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<string, unknown>) {
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 }
);
}
@@ -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' })
);
});
});
@@ -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<string, unknown> | Record<string, unknown>[] | 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<string, unknown>) {
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
});
}
@@ -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' })
);
});
});
-165
View File
@@ -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<string, unknown> | Record<string, unknown>[] | 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<string, unknown>) {
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 || []
});
}
-37
View File
@@ -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<BulletinCenterData> => {
return loadBulletinData(apiMessageGet, bulletinListSearch);
}, [bulletinListSearch]);
return (
<ClientWorkbench
load={load}
loadingCopy={t('bulletin.loading')}
cacheKey={bulletinCenterCacheKey}
cacheSettledTtlMs={BULLETIN_CENTER_SETTLED_CACHE_TTL_MS}
>
{data => <BulletinCenterSurface data={data} refreshTick={refreshTick} onReload={() => setRefreshTick(value => value + 1)} />}
</ClientWorkbench>
);
}
-105
View File
@@ -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<unknown>)
}));
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<unknown>;
loadingCopy?: string;
}) => {
loadState.lastLoad = load;
return (
<div data-client-workbench="true" data-loading-copy={loadingCopy}>
{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' }
]
}
})}
</div>
);
}
}));
vi.mock('@/components/pages/bulletin-center-surface', () => ({
BulletinCenterSurface: ({ refreshTick }: { refreshTick: number }) => (
<div data-bulletin-center-surface="true">
<span>{refreshTick}</span>
<span>Ops board</span>
</div>
)
}));
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(<BulletinPage />);
const lastLoad = loadState.lastLoad as (() => Promise<unknown>) | 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}');
});
});
-7
View File
@@ -1,7 +0,0 @@
import React from 'react';
import BulletinPage from './bulletin-page';
export default function BulletinRoutePage() {
return <BulletinPage />;
}
@@ -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(');
});
});
File diff suppressed because it is too large Load Diff
-486
View File
@@ -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"');
});
});
-12
View File
@@ -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<SearchParamsRecord>;
}) {
const resolvedSearchParams = await searchParams;
return <DashboardDraftWorkspace initialContext={resolvedSearchParams || {}} />;
}
@@ -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'
);
});
});
-9
View File
@@ -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<SearchParamsRecord>;
}) {
const resolvedSearchParams = await props?.searchParams;
redirect(buildEntityListCompatRouteUrl(resolvedSearchParams));
}
@@ -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<ReturnType<typeof loadEntityDefinitionPageDataFromFacade>>;
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<DefinitionPageData> => {
return loadEntityDefinitionPageDataFromFacade(
{
definition: api.entities.definition,
activities: api.entities.definitionActivities,
templates: api.entities.definitionTemplates
},
entityId,
'yaml'
);
}, [entityId]);
return (
<ClientWorkbench
load={load}
loadingCopy={t('entities.definition.loading')}
cacheKey={entityDefinitionCacheKey}
cacheSettledTtlMs={ENTITY_DEFINITION_SETTLED_CACHE_TTL_MS}
>
{data => (
<EntityDefinitionWorkspaceSurface
mode="definition"
entityId={data.entityId}
initialContent={data.definition}
initialFormat="yaml"
initialMessage={data.loadMessage}
routeContext={routeContext}
templates={data.templates}
activities={data.activities}
/>
)}
</ClientWorkbench>
);
}
@@ -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<unknown>),
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<unknown>;
loadingCopy?: string;
cacheKey?: string;
cacheSettledTtlMs?: number;
}) => {
mockState.lastLoad = load;
return (
<div
data-client-workbench="true"
data-loading-copy={loadingCopy}
data-cache-key={cacheKey}
data-cache-settled-ttl={cacheSettledTtlMs}
>
{children(mockState.renderData)}
</div>
);
}
}));
vi.mock('@/components/pages/entity-definition-workspace-surface', () => ({
EntityDefinitionWorkspaceSurface: ({ mode, entityId, initialContent, initialMessage, routeContext, templates, activities }: any) => (
<div
data-entity-definition-workspace={mode}
data-entity-id={entityId}
data-definition-route-monitor-id={routeContext?.monitorId || ''}
data-definition-route-time-range={routeContext?.timeRange || ''}
data-definition-route-return-to={routeContext?.returnTo || ''}
>
{initialContent} / {templates.length} templates / {activities.length} activities
{initialMessage ? ` / ${initialMessage}` : ''}
</div>
)
}));
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(<EntityDefinitionPage entityId="42" />);
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&amp;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(<EntityDefinitionPage entityId="1" />);
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(
<EntityDefinitionPage
entityId="42"
routeContext={{
timeRange: 'last-45m',
monitorId: '632051474676992',
returnTo: '/entities/42'
}}
/>
);
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"');
});
});
@@ -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<EntityDetailSearchParams>;
}) {
const { entityId } = await params;
const resolvedSearchParams = await searchParams;
const routeContext = readEntityDetailRouteContext(resolvedSearchParams);
return <EntityDefinitionPage entityId={entityId} routeContext={routeContext} />;
}
@@ -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 (
<ClientWorkbench
load={load}
loadingCopy={t('entities.edit.loading')}
cacheKey={entityEditCacheKey}
cacheSettledTtlMs={ENTITY_EDIT_SETTLED_CACHE_TTL_MS}
renderError={(message, retry) => (
<section
className="mx-auto w-full max-w-[1320px] px-3 py-6"
data-entity-editor-route-state="error"
data-entity-editor-route-state-owner="hertzbeat-ui-inline-feedback"
data-entity-editor-edit-error-state="missing-entity"
>
<HzInlineFeedback
tone="critical"
title={t('common.load-failed')}
description={
<>
{message}
<span className="sr-only">{t('entities.editor.action.all-entities.help')}</span>
</>
}
variant="embedded"
data-entity-editor-route-state-feedback="error"
/>
<div className="mt-3 flex flex-wrap justify-center gap-2">
<HzButton
size="sm"
intent="primary"
onClick={retry}
data-entity-editor-route-state-retry="true"
data-entity-editor-route-state-retry-owner="hertzbeat-ui-button"
>
{t('common.button.retry')}
</HzButton>
<HzButton
size="sm"
intent="secondary"
onClick={() => 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')}
</HzButton>
</div>
</section>
)}
>
{data => (
<EntityEditorSurface
initial={data.dto}
mode="edit"
entityId={data.entityId}
catalogSuggestions={data.catalogSuggestions}
routeContext={routeContext}
/>
)}
</ClientWorkbench>
);
}
@@ -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<unknown>),
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) => <button {...props}>{children}</button>,
HzInlineFeedback: ({ title, description, ...props }: any) => (
<section data-hz-ui="inline-feedback" {...props}>
<h2>{title}</h2>
<p>{description}</p>
</section>
)
}));
vi.mock('@/components/workbench/client-workbench', () => ({
ClientWorkbench: ({
children,
load,
cacheKey,
cacheSettledTtlMs,
loadingCopy,
renderError
}: {
children: (data: any) => React.ReactNode;
load: () => Promise<unknown>;
cacheKey?: string;
cacheSettledTtlMs?: number;
loadingCopy?: string;
renderError?: (message: string, retry: () => void) => React.ReactNode;
}) => {
mockState.lastLoad = load;
if (mockState.renderError) {
return <div data-client-workbench="error">{renderError?.('Entity not exist.', () => undefined)}</div>;
}
return (
<div
data-client-workbench="true"
data-cache-key={cacheKey}
data-cache-settled-ttl={cacheSettledTtlMs}
data-loading-copy={loadingCopy}
>
{children(mockState.renderData)}
</div>
);
}
}));
vi.mock('@/components/pages/entity-editor-surface', () => ({
EntityEditorSurface: ({ mode, entityId, initial, routeContext }: any) => (
<div data-entity-editor-surface={mode} data-entity-id={entityId} data-route-monitor-id={routeContext?.monitorId ?? ''}>
{initial.entity.name}
</div>
)
}));
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(<EntityEditPage entityId="42" />);
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(
<EntityEditPage
entityId="42"
routeContext={{
monitorId: '658094606003456',
monitorName: 'Checkout API',
monitorApp: 'website',
monitorInstance: '127.0.0.1:4223',
source: 'discovery-candidate'
}}
/>
);
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(<EntityEditPage entityId="42" />);
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(<EntityEditPage entityId="42" />);
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(
<EntityEditPage
entityId="1"
routeContext={{
returnTo: '/entities?search=checkout&source=pd-1218&pageSize=8'
}}
/>
);
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&amp;source=pd-1218&amp;pageSize=8"');
expect(html).toContain('Entity not exist.');
});
});
@@ -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<EntityDetailSearchParams>;
}) {
const { entityId } = await params;
const resolvedSearchParams = await searchParams;
const routeContext = readEntityDetailRouteContext(resolvedSearchParams);
return <EntityEditPage entityId={entityId} routeContext={routeContext} />;
}
@@ -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<string | null>(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<EntityDetailDto> => {
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<void>(`/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 (
<ClientWorkbench
key={entityDetailCacheKey}
load={load}
loadingTitle={t('entities.detail.loading.title')}
loadingCopy={t('entities.detail.loading.copy')}
loadTimeoutMs={ENTITY_DETAIL_LOAD_TIMEOUT_MS}
loadingDelayMs={150}
cacheKey={entityDetailCacheKey}
cacheSettledTtlMs={ENTITY_DETAIL_SETTLED_CACHE_TTL_MS}
>
{detail => (
<EntityDetailSurface
detail={detail}
routeContext={routeContext}
createdResult={createdResult}
updatedResult={updatedResult}
actionError={actionError}
isPending={isPending}
onDelete={handleDelete}
onRefresh={handleRefresh}
/>
)}
</ClientWorkbench>
);
}
@@ -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<unknown>),
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) => (
<a href={href} {...props}>
{children}
</a>
)
}));
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<unknown>;
loadingTitle?: string;
loadingCopy?: string;
loadTimeoutMs?: number;
loadingDelayMs?: number;
}) => {
mockState.lastLoad = load;
return (
<div
data-client-workbench="true"
data-loading-title={loadingTitle}
data-loading-copy={loadingCopy}
data-load-timeout-ms={loadTimeoutMs}
data-loading-delay-ms={loadingDelayMs}
>
{children(mockState.renderData)}
</div>
);
}
}));
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 (
<main
data-entity-detail-surface="otlp-hertzbeat-ui-entity-detail"
data-entity-detail-style-baseline="hertzbeat-ui-matte"
data-entity-detail-layout="full-width-workbench"
data-entity-detail-route-monitor-id={routeContext?.monitorId || ''}
data-entity-detail-route-time-range={routeContext?.timeRange || ''}
data-entity-detail-route-return-to={routeContext?.returnTo || ''}
data-entity-detail-created-result={createdResult ? 'true' : 'false'}
>
<header
data-entity-detail-header="hertzbeat-ui-compact-header"
data-entity-detail-header-nesting-contract="flat-page-introduction"
>
{surfaceT('entities.detail.header.badge')}
{surfaceT('entities.detail.header.kicker')}
<div data-entity-detail-command-row="standard-equal-buttons">
<span data-href="/entities">{surfaceT('entities.detail.action.all-entities')}</span>
<button>{surfaceT('common.refresh')}</button>
<span data-href={`/entities/${entity.id}/definition`}>{surfaceT('entities.detail.action.edit-definition')}</span>
<button
data-entity-detail-delete-confirm-action="route-mock"
disabled={isPending}
onClick={() => onDelete?.(entity.id)}
>
{surfaceT('entities.detail.action.delete')}
</button>
<span data-href={`/entities/${entity.id}/edit`}>{surfaceT('entities.detail.action.edit')}</span>
</div>
</header>
{actionError ? <div data-entity-detail-error="hertzbeat-ui-inline-error">{actionError}</div> : null}
<div data-entity-detail-count-strip="hertzbeat-ui-inline-counts" />
<div data-entity-detail-signal-grid="hertzbeat-ui-detail-grid">
<section data-entity-detail-overview-panel="hertzbeat-ui-overview-panel">{surfaceT('entities.detail.panel.overview.title')}</section>
<section data-entity-detail-related-panel="hertzbeat-ui-related-panel">{surfaceT('entities.detail.panel.related.title')}</section>
<section data-entity-detail-next-panel="hertzbeat-ui-next-panel">{surfaceT('entities.detail.panel.next.title')}</section>
<section data-entity-detail-drilldown-panel="hertzbeat-ui-drilldown-panel">{surfaceT('entities.detail.panel.drilldown.title')}</section>
</div>
</main>
);
}
}));
vi.mock('@/components/ui/button', () => ({
Button: ({ children, ...props }: any) => <button {...props}>{children}</button>,
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<typeof import('@/lib/entity-detail/controller')>();
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(<EntityDetailPage entityId="42" />);
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(<EntityDetailPage entityId="42" routeContext={routeContext} />);
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(<EntityDetailPage entityId="42" createdResult />);
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(
<EntityDetailPage
entityId="42"
createdResult
routeContext={{
returnTo: '/entities?search=checkout&pageSize=50&source=product-design-1719',
source: 'product-design-1719'
}}
/>
);
expect(html).toContain('data-entity-detail-created-result="true"');
expect(html).toContain(
'data-entity-detail-route-return-to="/entities?search=checkout&amp;pageSize=50&amp;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(
<EntityDetailPage
entityId="42"
routeContext={{
returnTo: '/entities?search=checkout&pageSize=50&source=product-design-delete-failure',
source: 'product-design-delete-failure'
}}
/>
);
});
const deleteButton = container.querySelector<HTMLButtonElement>('[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&amp;pageSize=50&amp;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}');
});
});
-24
View File
@@ -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<EntityDetailSearchParams>;
}) {
const { entityId } = await params;
const resolvedSearchParams = await searchParams;
const routeContext = readEntityDetailRouteContext(resolvedSearchParams);
const createdResult = readEntityDetailCreatedResult(resolvedSearchParams);
const updatedResult = readEntityDetailUpdatedResult(resolvedSearchParams);
return <EntityDetailPage entityId={entityId} routeContext={routeContext} createdResult={createdResult} updatedResult={updatedResult} />;
}
@@ -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<ReturnType<typeof loadDiscoveryDataFromFacade>>;
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<DiscoveryData> =>
loadDiscoveryDataFromFacade({
presets: api.entities.discoveryGovernancePresets,
activities: api.entities.discoveryGovernanceActivities,
catalogSuggestions: api.entities.catalogSuggestions
}),
[]
);
return (
<ClientWorkbench
load={load}
loadingCopy={t('entities.discovery.loading')}
cacheKey={entityDiscoveryCacheKey}
cacheSettledTtlMs={ENTITY_DISCOVERY_SETTLED_CACHE_TTL_MS}
>
{data => (
<EntityDiscoverySurface
presets={data.presets}
activities={data.activities}
catalog={data.catalog}
candidateContext={candidateContext}
initialSearch={initialSearch}
initialSource={initialSource}
initialPageIndex={initialPageIndex}
deleteSuccess={deleteSuccess}
deletedEntity={deletedEntity}
/>
)}
</ClientWorkbench>
);
}
@@ -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<unknown>),
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<unknown>;
loadingCopy?: string;
cacheKey?: string;
cacheSettledTtlMs?: number;
}) => {
mockState.lastLoad = load;
return (
<div
data-client-workbench="true"
data-loading-copy={loadingCopy}
data-cache-key={cacheKey}
data-cache-settled-ttl={cacheSettledTtlMs}
>
{children(mockState.renderData)}
</div>
);
}
}));
vi.mock('@/components/pages/entity-discovery-surface', () => ({
EntityDiscoverySurface: ({ presets, activities, catalog, candidateContext, initialSearch, initialSource, initialPageIndex, deleteSuccess, deletedEntity }: any) => (
<div
data-entity-discovery-surface="otlp-hertzbeat-ui-discovery-console"
data-page-initial-search={initialSearch}
data-page-initial-source={initialSource}
data-page-initial-page-index={initialPageIndex}
data-page-delete-success={deleteSuccess ? 'true' : 'false'}
data-page-deleted-entity={deletedEntity ?? ''}
>
{candidateContext ? (
<span
data-page-candidate-source={candidateContext.source}
data-page-candidate-identity={`${candidateContext.identityKey}:${candidateContext.identityValue}`}
data-page-candidate-service={candidateContext.serviceName}
data-page-candidate-namespace={candidateContext.serviceNamespace}
data-page-candidate-environment={candidateContext.environment}
/>
) : null}
{presets.length} presets / {activities.length} activities / {catalog.owners.length} owners
</div>
)
}));
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(<EntityDiscoveryPage />);
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
});
});
});
-7
View File
@@ -1,7 +0,0 @@
import React from 'react';
import EntityDiscoveryPage from './entity-discovery-page';
export default function EntityDiscoveryRoutePage() {
return <EntityDiscoveryPage />;
}
-253
View File
@@ -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<EntitySummaryInfo>;
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<EntitySummaryInfo>): 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<EntityQueryState>(() => normalizeEntityListQueryForRuntime(initialEntityQuery));
const [query, setQuery] = useState<EntityQueryState>(() => normalizeEntityListQueryForRuntime(initialEntityQuery));
const [pageSizeAdjustment, setPageSizeAdjustment] = useState<EntityListPageSizeAdjustment | undefined>(() =>
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<EntityListLoadResult> => {
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 (
<ClientWorkbench
key={entityListCacheKey}
load={load}
loadingTitle={t('entities.list.loading.title')}
loadingCopy={t('entities.list.loading.copy')}
loadingDelayMs={150}
cacheKey={entityListCacheKey}
cacheSettledTtlMs={ENTITY_LIST_SETTLED_CACHE_TTL_MS}
>
{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<EntitySummaryInfo> & { 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 (
<EntityListSurface
t={t}
rows={rows}
draft={draft}
total={total}
rangeFrom={rangeFrom}
rangeTo={rangeTo}
pageIndex={pageIndex}
pageSize={pageSize}
abnormalCount={abnormalCount}
healthPendingCount={healthPendingCount}
alertingCount={alertingCount}
linkedCount={linkedCount}
pageOutOfRange={data.pageOutOfRange}
pageSizeAdjustment={pageSizeAdjustment}
payloadTrim={payloadTrim}
deleteSuccess={query.deleteResult === 'success'}
deletedEntity={query.deletedEntity}
onDraftChange={patch => setDraft(prev => ({ ...prev, ...patch }))}
onSearch={applyQuery}
onRefresh={refreshQuery}
onReset={resetQuery}
onPageIndexChange={changePageIndex}
onPageSizeChange={changePageSize}
/>
);
}}
</ClientWorkbench>
);
}
@@ -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<ReturnType<typeof loadImportDataFromFacade>>;
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<ImportData> =>
loadImportDataFromFacade({
templates: api.entities.importTemplates,
activities: api.entities.importActivities
}),
[]
);
return (
<ClientWorkbench
load={load}
loadingCopy={t('entities.import.loading')}
cacheKey={entityImportCacheKey}
cacheSettledTtlMs={ENTITY_IMPORT_SETTLED_CACHE_TTL_MS}
>
{data => (
<EntityImportSurface
templates={data.templates}
activities={data.activities}
initialMessage={
deleteResult === 'success' && deletedEntity
? t('entities.import.delete-success', { id: deletedEntity })
: null
}
initialMessageTone={deleteResult === 'success' && deletedEntity ? 'success' : 'error'}
routeContext={routeContext}
/>
)}
</ClientWorkbench>
);
}
-161
View File
@@ -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<unknown>),
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<unknown>;
loadingCopy: string;
cacheKey?: string;
cacheSettledTtlMs?: number;
}) => {
mockState.lastLoad = load;
return (
<div
data-client-workbench="true"
data-loading-copy={loadingCopy}
data-cache-key={cacheKey}
data-cache-settled-ttl={cacheSettledTtlMs}
>
{children(mockState.renderData)}
</div>
);
}
}));
vi.mock('@/components/pages/entity-import-surface', () => ({
EntityImportSurface: ({ initialMessage, initialMessageTone, routeContext, templates, activities }: any) => (
<div
data-entity-import-surface="true"
data-initial-message={initialMessage ?? ''}
data-initial-message-tone={initialMessageTone ?? ''}
data-route-source={routeContext?.source}
data-route-return-to={routeContext?.returnTo}
data-route-time-range={routeContext?.timeRange}
>
{templates.length} templates / {activities.length} activities
</div>
)
}));
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(<EntityImportPage />);
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(
<EntityImportPage
routeContext={{
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&amp;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&amp;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' }));
});
});
-22
View File
@@ -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<EntityDetailSearchParams>;
}) {
const resolvedSearchParams = await searchParams;
const routeContext = readEntityDetailRouteContext(resolvedSearchParams);
const reader = createCompatSearchParamReader(resolvedSearchParams);
return (
<EntityImportPage
deletedEntity={reader.get('deletedEntity')}
deleteResult={reader.get('deleteResult') === 'success' ? 'success' : null}
routeContext={routeContext}
/>
);
}
-31
View File
@@ -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'
);
});
});
-12
View File
@@ -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<SearchParamsRecord>;
}) {
const resolvedSearchParams = await props.searchParams;
redirect(buildEntityListCompatRouteUrl(resolvedSearchParams));
}
@@ -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<SignalRouteContext | undefined>(() => {
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 (
<ClientWorkbench
load={load}
loadingCopy={t('entities.new.loading')}
cacheKey={entityNewCacheKey}
cacheSettledTtlMs={ENTITY_NEW_SETTLED_CACHE_TTL_MS}
>
{data => (
<EntityEditorSurface
initial={data.initial}
mode="new"
catalogSuggestions={data.catalogSuggestions}
routeContext={entityNewRouteContext}
/>
)}
</ClientWorkbench>
);
}
-247
View File
@@ -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<unknown>),
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<unknown>) => 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<unknown>;
cacheKey?: string;
cacheSettledTtlMs?: number;
loadingCopy?: string;
}) => {
mockState.lastLoad = load;
return (
<div
data-client-workbench="true"
data-cache-key={cacheKey}
data-cache-settled-ttl={cacheSettledTtlMs}
data-loading-copy={loadingCopy}
>
{children(mockState.renderData)}
</div>
);
}
}));
vi.mock('@/components/pages/entity-editor-surface', () => ({
EntityEditorSurface: ({ mode, entityId, initial, routeContext }: any) => (
<div
data-entity-editor-surface={mode}
data-entity-id={entityId ?? 'new'}
data-route-source={routeContext?.source ?? ''}
data-route-monitor-id={routeContext?.monitorId ?? ''}
data-route-monitor-name={routeContext?.monitorName ?? ''}
data-route-monitor-app={routeContext?.monitorApp ?? ''}
data-route-monitor-instance={routeContext?.monitorInstance ?? ''}
data-route-return-to={routeContext?.returnTo ?? ''}
data-route-service-name={routeContext?.serviceName ?? ''}
data-route-service-namespace={routeContext?.serviceNamespace ?? ''}
data-route-environment={routeContext?.environment ?? ''}
>
{initial.entity.name}
</div>
)
}));
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(<EntityNewPage />);
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(<EntityNewPage />);
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(<EntityNewPage initialSeed={initialSeed} />);
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(<EntityNewPage initialSeed={initialSeed} />);
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(<EntityNewPage initialSeed={initialSeed as any} />);
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'
});
});
});
-14
View File
@@ -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<EntityNewSearchParams>;
}) {
const resolvedSearchParams = await searchParams;
const initialSeed = readEntityNewDraftSeed(resolvedSearchParams);
return <EntityNewPage initialSeed={initialSeed} />;
}
-20
View File
@@ -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');
});
});
-6
View File
@@ -1,6 +0,0 @@
import { redirect } from 'next/navigation';
import { buildEntityListCompatRouteUrl } from '../../lib/entity-manage/query-state';
export default function EntitiesNotFound() {
redirect(buildEntityListCompatRouteUrl());
}
-782
View File
@@ -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<unknown>),
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) => (
<a href={href} {...props}>
{children}
</a>
)
}));
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<unknown>;
loadingTitle?: string;
loadingCopy?: string;
loadingDelayMs?: number;
}) => {
mockState.lastLoad = load;
return (
<div
data-client-workbench="true"
data-loading-title={loadingTitle}
data-loading-copy={loadingCopy}
data-loading-delay-ms={loadingDelayMs}
>
{children(mockState.renderData)}
</div>
);
}
}));
vi.mock('@/components/observability', () => ({
DrawerCodePreview: ({ children }: any) => <pre data-drawer-code-preview="true">{children}</pre>,
DrawerSection: ({ title, children }: any) => (
<section data-drawer-section={title}>
<h3>{title}</h3>
{children}
</section>
),
ObservabilityStatusState: ({ title, copy }: any) => (
<div>
{title}
{copy}
</div>
),
StageSection: ({ title, children }: any) => (
<section data-stage-section={title}>
<h2>{title}</h2>
{children}
</section>
),
SelectableEvidenceList: ({ rows }: any) => <div>{rows.map((row: any) => row.title).join('|')}</div>,
SummaryMetricGrid: ({ items }: any) => <div data-summary-metric-grid="true">{items.map((item: any) => item.label).join('|')}</div>,
ToolbarField: ({ label, children }: any) => (
<label>
{label}
{children}
</label>
),
ToolbarRow: ({ children }: any) => <div data-toolbar-row="true">{children}</div>
}));
vi.mock('@/components/workbench/primitives', () => ({
WorkbenchStack: ({ children }: any) => <div>{children}</div>
}));
vi.mock('@/components/workbench/workbench-page', () => ({
RowList: ({ rows }: any) => <div>{rows.map((row: any) => row.title).join('|')}</div>,
WorkbenchPage: ({ title, subtitle, actions, main, side }: any) => (
<main>
<h1>{title}</h1>
<p>{subtitle}</p>
<div>{actions}</div>
<div>{main}</div>
<aside>{side}</aside>
</main>
)
}));
vi.mock('@/components/ui/button', () => ({
Button: ({ children, ...props }: any) => <button {...props}>{children}</button>
}));
vi.mock('@/components/ui/input', () => ({
Input: (props: any) => <input {...props} />
}));
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<typeof import('@/lib/entity-manage/controller')>();
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<string, string>) => {
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<string, string>) =>
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(<EntityListPage initialQuery={initialQuery} />);
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('<span aria-hidden="true" class="text-[11px] font-semibold leading-none">?</span>');
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(
<EntityListPage
initialQuery={{
search: 'hb-mix',
type: '',
status: '',
pageIndex: '999',
source: 'product-design-1475',
pageSize: '50'
}}
/>
);
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(
<EntityListPage
initialQuery={{
search: 'hb-mix',
type: '',
status: '',
pageIndex: '0',
source: 'product-design-1493',
pageSize: '100'
}}
/>
);
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(
<EntityListPage
initialQuery={{
search: 'hb-scale',
type: '',
status: '',
pageIndex: '0',
source: 'product-design-1679',
pageSize: '50'
}}
/>
);
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(
<EntityListPage
initialQuery={{
search: 'hb-5k',
type: '',
status: '',
pageIndex: '2',
source: 'product-design-1696',
pageSize: '50'
}}
/>
);
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(
<EntityListPage
initialQuery={{
search: 'hb-mix',
type: '',
status: '',
pageIndex: '999',
source: 'product-design-1475',
pageSize: '50'
}}
/>
);
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(
<EntityListPage
initialQuery={{
search: '',
type: 'service',
status: '',
pageIndex: '2',
source: 'product-design-1388',
pageSize: '50'
}}
/>
);
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 }
);
});
});
-14
View File
@@ -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<EntityListSearchParams>;
}) {
const resolvedSearchParams = await searchParams;
const initialQuery = readEntityListQueryState(resolvedSearchParams);
return <EntityListPage initialQuery={initialQuery} />;
}
@@ -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'
);
});
});
@@ -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');
});
});
-50
View File
@@ -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);
});
-11
View File
@@ -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<SearchParamsRecord>;
}) {
const resolvedSearchParams = await searchParams;
redirect(buildLogCompatRouteUrlFromSearchParams(resolvedSearchParams, { view: 'list' }));
}
@@ -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 <div data-exception-center-surface="hertzbeat-ui-exceptions" data-exception-type={type} />;
}
}));
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');
});
});
-10
View File
@@ -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 <ExceptionCenterSurface type={normalizedType} />;
}
-345
View File
@@ -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<typeof useI18n>['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 (
<ClientWorkbench
load={load}
loadingCopy={t('common.workbench.loading.copy')}
cacheKey={cacheKey}
loadTimeoutMs={EXPLORER_WORKBENCH_LOAD_TIMEOUT_MS}
>
{data => <ExplorerWorksurface data={data} t={t} />}
</ClientWorkbench>
);
}
function ExplorerWorksurface({ data, t }: { data: ExplorerReadData; t: Translator }) {
const router = useRouter();
const filters = buildExplorerFilters(t);
const rows = data.rows;
const [activeRowKey, setActiveRowKey] = useState<string | null>(rows[0]?.key ?? null);
const activeRow = rows.find(row => row.key === activeRowKey) || rows[0] || null;
const [draft, setDraft] = useState<ExplorerQueryState>(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<ExplorerResultRow>[] = [
{
key: 'signal',
header: t('explorer.table.signal-type'),
width: '112px',
render: row => (
<span
className={`rounded-[3px] border px-2 py-0.5 text-[11px] font-semibold ${signalToneClass(row.signalTone)}`}
data-explorer-signal-tone={row.signalTone}
>
{row.signal}
</span>
)
},
{
key: 'service',
header: t('explorer.table.service'),
width: '180px',
render: row => <HzDataCellText>{row.service}</HzDataCellText>
},
{
key: 'operation',
header: t('explorer.table.operation'),
render: row => (
<a href={row.href} className="font-medium text-[#9fb4e7] hover:text-[#d7e2ff]">
{row.operation}
</a>
)
},
{
key: 'status',
header: t('explorer.table.status'),
width: '96px',
render: row => <HzDataCellText>{row.status}</HzDataCellText>
},
{
key: 'duration',
header: t('explorer.table.duration'),
width: '96px',
render: row => <HzDataMetaText casing="plain">{row.duration}</HzDataMetaText>
},
{
key: 'time',
header: t('explorer.table.time'),
width: '176px',
render: row => <HzDataMetaText casing="plain">{row.timestamp}</HzDataMetaText>
}
];
return (
<main
data-explorer-route="otlp-hertzbeat-ui-workbench"
data-explorer-style-baseline="hertzbeat-ui-matte"
data-explorer-api-owner={data.apiOwner}
data-explorer-api-state={data.apiState}
data-explorer-api-total={rows.length}
data-explorer-api-trace-total={data.traceTotal}
data-explorer-api-log-total={data.logTotal}
data-explorer-api-metric-total={data.metricTotal}
data-explorer-query-state={data.query.q || 'none'}
data-explorer-signal-filter={data.query.signal}
className="min-h-[calc(100vh-56px)] bg-[#07090b] text-[#e8edf5]"
>
<HzExplorerFrame
data-explorer-shared-frame="hertzbeat-ui"
className="mx-0 mb-0 mt-0 min-h-[calc(100vh-56px)] sm:mx-0"
eyebrow={t('explorer.kicker')}
title={t('explorer.title')}
description={t('explorer.subtitle')}
mainId="explorer-shared-main"
mainLabel={t('explorer.title')}
skipLinkLabel={t('app.frame.skip-to-workbench')}
filterRailLabel={t('explorer.filters.title')}
actions={
<>
<HzButtonLink href="/explorer?view=saved" intent="secondary" data-explorer-action="save-view">
<Save size={14} />
{t('explorer.actions.save-view')}
</HzButtonLink>
<HzButtonLink href="/alert/setting?source=explorer" intent="secondary" data-explorer-action="create-alert">
<BellPlus size={14} />
{t('explorer.actions.create-alert')}
</HzButtonLink>
<HzButtonLink href="/dashboard?source=explorer" intent="secondary" data-explorer-action="add-dashboard">
<LayoutDashboard size={14} />
{t('explorer.actions.add-dashboard')}
</HzButtonLink>
</>
}
queryBar={
<div data-explorer-query-bar="hertzbeat-ui-query-row">
<HzQueryBar
query={draft.q}
queryLabel={t('explorer.query.label')}
onQueryChange={q => 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<HTMLInputElement>
}
actions={
<>
<HzSelect
aria-label={t('explorer.signal.aria')}
className="w-[136px]"
value={draft.signal}
onChange={event => 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') }
]}
/>
<HzSelect
aria-label={t('explorer.sort.aria')}
className="w-[148px]"
defaultValue="time-desc"
options={[
{ value: 'time-desc', label: t('explorer.sort.time-desc') },
{ value: 'duration-desc', label: t('explorer.sort.duration-desc') }
]}
/>
<HzButton
type="button"
intent="primary"
onClick={applyQuery}
data-explorer-query-action="run"
data-explorer-query-url={buildExplorerRouteUrl(draft)}
>
<Play size={14} />
{t('explorer.query.run')}
</HzButton>
</>
}
/>
</div>
}
filterRail={
<aside data-explorer-filter-rail="hertzbeat-ui-static-rail" className="bg-[var(--hz-ui-surface)] px-3 py-3">
<div className="mb-4 flex items-center justify-between">
<div className="text-[13px] font-semibold text-[#e4ebf5]">{t('explorer.filters.title')}</div>
<button type="button" className="text-[12px] font-semibold text-[#9aa6b8] hover:text-[#dbe4f0]">
{t('explorer.filters.clear')}
</button>
</div>
<div className="space-y-4">
{filters.map(filter => (
<section key={filter.title} className="space-y-2">
<div className="text-[12px] font-semibold text-[#8d98aa]">{filter.title}</div>
<div className="space-y-1">
{filter.values.map(value => (
<label key={value} className="flex h-7 items-center gap-2 rounded-[3px] px-1 text-[12px] text-[#c7d0df] hover:bg-[#151a22]">
<span className="grid h-4 w-4 place-items-center rounded-[3px] border border-[#4d65c8] bg-[#17213a] text-[10px] font-semibold text-[#b9c8ff]">
</span>
<span className="min-w-0 truncate">{value}</span>
</label>
))}
</div>
</section>
))}
</div>
</aside>
}
>
<section data-explorer-chart-band="hertzbeat-ui-chart-band" className="border-b border-[var(--hz-ui-line-soft)] bg-[var(--hz-ui-surface)] px-4 py-4">
<div className="mb-3 flex items-center justify-between">
<div>
<p className="text-[12px] font-semibold text-[#8e99ab]">{t('explorer.chart.title')}</p>
<p className="mt-1 text-[13px] text-[#d5dde9]" data-explorer-api-source={`${data.sourceUrls.traces}|${data.sourceUrls.logs}|${data.sourceUrls.metrics}`}>
{t('explorer.chart.subtitle')}
</p>
</div>
<div className="flex items-center gap-2 text-[12px] text-[#8d98aa]">
<BarChart3 className="h-4 w-4" aria-hidden="true" />
{t('explorer.chart.result-count', { count: rows.length })}
</div>
</div>
<div className="flex h-[86px] items-end gap-2 border-t border-[#232a34] pt-3">
{[28, 36, 22, 54, 45, 68, 42, 58, 33, 74, 49, 62].map((height, index) => (
<div
key={`${height}-${index}`}
className="min-w-0 flex-1 rounded-[3px] border border-[#2f3b4d] bg-[#182232]"
style={{ height: `${height}px` }}
/>
))}
</div>
</section>
<div className="grid min-h-[420px] gap-0 2xl:grid-cols-[minmax(0,1fr)_320px]">
<section data-explorer-result-table="hertzbeat-ui-dense-table" className="min-w-0 overflow-hidden border-r border-[var(--hz-ui-line-soft)]">
<div className="flex h-11 items-center justify-between border-b border-[var(--hz-ui-line-soft)] px-4 text-[12px] text-[#8e99ab]">
<span>{t('explorer.results.title')}</span>
<span>{t('explorer.results.count', { count: rows.length })}</span>
</div>
<HzDataTable
columns={columns}
rows={rows}
getRowKey={row => 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 ? (
<div
data-explorer-api-empty-state="trace-log-bff-query-api"
className="border-t border-[var(--hz-ui-line-soft)] px-4 py-5 text-[13px] text-[#8f9bad]"
>
<p data-explorer-empty-title="query-no-results" className="font-semibold text-[#dbe5f3]">
{t('explorer.empty.title')}
</p>
<p className="mt-1 max-w-[720px] leading-5">{t('explorer.empty.copy')}</p>
<div data-explorer-empty-next-steps="query-signal-ingest" className="mt-3 flex flex-wrap gap-2 text-[12px]">
<span className="rounded-[3px] border border-[#2d3646] bg-[#121721] px-2 py-1 text-[#b9c4d5]">{t('explorer.empty.step.query')}</span>
<span className="rounded-[3px] border border-[#2d3646] bg-[#121721] px-2 py-1 text-[#b9c4d5]">{t('explorer.empty.step.signal')}</span>
<span className="rounded-[3px] border border-[#2d3646] bg-[#121721] px-2 py-1 text-[#b9c4d5]">{t('explorer.empty.step.ingest')}</span>
</div>
</div>
) : null}
</section>
<aside
data-explorer-detail-panel="hertzbeat-ui-detail-panel"
data-explorer-detail-active-row={activeRow?.key || 'none'}
className="h-fit bg-[var(--hz-ui-surface)] px-4 py-4"
>
<p className="text-[12px] font-semibold text-[#8d98aa]">{t('explorer.detail.title')}</p>
<h2 className="mt-2 text-[18px] font-semibold text-[#f0f4fa]">{activeRow?.service || t('explorer.detail.empty-title')}</h2>
{activeRow ? (
<div className="mt-4 space-y-3 text-[12px] text-[#9aa6b8]">
<div className="flex items-center justify-between border-b border-dashed border-[#2c3441] pb-2">
<span>{t('explorer.detail.signal')}</span>
<span className="font-semibold text-[#dbe5f3]">{activeRow.signal}</span>
</div>
<div className="flex items-center justify-between border-b border-dashed border-[#2c3441] pb-2">
<span>{t('explorer.detail.status')}</span>
<span className="font-semibold text-[#dbe5f3]">{activeRow.status}</span>
</div>
<div className="flex items-center justify-between border-b border-dashed border-[#2c3441] pb-2">
<span>{t('explorer.detail.duration')}</span>
<span className="font-semibold text-[#dbe5f3]">{activeRow.duration}</span>
</div>
</div>
) : (
<p data-explorer-detail-empty-copy="select-or-broaden-query" className="mt-3 text-[12px] leading-5 text-[#9aa6b8]">
{t('explorer.detail.empty-copy')}
</p>
)}
<div className="mt-4 flex flex-col gap-2">
<HzButtonLink href="/trace/manage" intent="secondary">{t('explorer.handoff.trace')}</HzButtonLink>
<HzButtonLink href="/log/manage" intent="secondary">{t('explorer.handoff.log')}</HzButtonLink>
<HzButtonLink href="/ingestion/otlp/metrics" intent="secondary">{t('explorer.handoff.metrics')}</HzButtonLink>
<HzButtonLink href="/entities" intent="secondary">{t('explorer.handoff.entities')}</HzButtonLink>
</div>
</aside>
</div>
</HzExplorerFrame>
</main>
);
}
-198
View File
@@ -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) => (
<a href={href} {...props}>
{children}
</a>
)
}));
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(<ExplorerPage />);
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&amp;pageSize=8&amp;serviceName=checkout|null|/ingestion/otlp/metrics/console?query=checkout&amp;aggregation=avg&amp;groupBy=service_name&amp;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&amp;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&amp;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(<ExplorerPage />);
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')");
});
});
-6
View File
@@ -1,6 +0,0 @@
import React from 'react';
import ExplorerPage from './explorer-page';
export default function ExplorerRoutePage() {
return <ExplorerPage />;
}
-571
View File
@@ -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;
}
}
-27
View File
@@ -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');
});
});
-48
View File
@@ -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']
});
});
});
-12
View File
@@ -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);
}
-164
View File
@@ -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<IncidentWorkbenchData['selectedIncident']>, nextState: IncidentTransitionState) => Promise<void>;
}) {
const coldOpsVisual = hzOpsCatalogVisual;
const { t } = useI18n();
const [transitionBusy, setTransitionBusy] = useState<IncidentTransitionState | null>(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 (
<main
className={coldOpsVisual.entry.main}
data-incidents-route="incident-workbench-api-ui-lab-shared"
data-incidents-style-baseline={coldOpsVisual.canvasName}
data-incidents-workbench="hertzbeat-ui"
data-incidents-api-owner="status-page-incident-api"
data-incidents-api-source={state.apiSource}
data-incidents-api-state={state.apiState}
data-incidents-api-total={state.totalElements}
data-incidents-detail-owner="status-page-incident-detail-api"
data-incidents-detail-source={state.detailSource}
data-incidents-detail-state={state.detailState}
data-incidents-detail-id={state.detailId || ''}
data-incidents-transition-owner="status-page-incident-put-api"
data-incidents-transition-source="/status/page/incident"
data-incidents-transition-state={state.transitionState}
data-incidents-transition-pending={transitionBusy == null ? 'none' : String(transitionBusy)}
data-incidents-query-contract="angular-search-pagination"
data-incidents-query-label={state.queryLabel}
>
<div className={coldOpsVisual.entry.container}>
<HzIncidentWorkbench
data-incidents-shared-workbench="hertzbeat-ui"
title={state.title}
subtitle={state.subtitle}
sourceLabel={state.kicker}
queryLabel={state.queryLabel}
metrics={state.metrics}
incidents={state.incidents}
timeline={state.timelineRows.map((item, index) => ({
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);
}
}))}
/>
</div>
</main>
);
}
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<IncidentWorkbenchData['selectedIncident']>, nextState: IncidentTransitionState) => {
await transitionIncidentStatus(
apiMessagePut,
incident,
nextState,
`${t(`status.incident.state.${nextState}`)} · /incidents`
);
setRefreshTick(value => value + 1);
}, [t]);
return (
<ClientWorkbench
load={load}
loadingCopy={t('common.workbench.loading.copy')}
cacheKey={incidentWorkbenchCacheKey}
cacheSettledTtlMs={INCIDENT_WORKBENCH_SETTLED_CACHE_TTL_MS}
>
{state => <IncidentWorkbenchSurface state={state} onTransition={handleTransition} />}
</ClientWorkbench>
);
}
-265
View File
@@ -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<unknown>)
}));
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 (
<div
data-client-workbench="true"
data-loading-copy={loadingCopy}
data-cache-key={cacheKey}
data-cache-ttl={cacheSettledTtlMs}
>
{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
})}
</div>
);
}
}));
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<unknown>) | 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 <IncidentsPage initialQuery={initialQuery} />');
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&amp;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&amp;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<unknown>) | 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');
});
});
-13
View File
@@ -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<IncidentWorkbenchSearchParams>;
}) {
const resolvedSearchParams = await searchParams;
const initialQuery = readIncidentWorkbenchQuery(resolvedSearchParams);
return <IncidentsPage initialQuery={initialQuery} />;
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -1,6 +0,0 @@
import React from 'react';
import OtlpMetricsPage from './otlp-metrics-page';
export default function OtlpMetricsRoutePage() {
return <OtlpMetricsPage />;
}
@@ -1,221 +0,0 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { describe, expect, it } from 'vitest';
import { buildOtlpMetricsRoute, hasMetricsDisplayReturnLabel } from './route-state';
import { createTranslatorMock } from '../../../../test/i18n-test-helper';
describe('otlp metrics route state', () => {
it('drops localized display labels from metrics workbench URLs', () => {
const t = createTranslatorMock({ locale: 'zh-CN' });
const localizedReturnLabel = t('log.manage.route.title');
const route = buildOtlpMetricsRoute({
entityId: '7',
entityType: 'service',
entityName: 'checkout',
returnTo: `/log/manage?returnLabel=${localizedReturnLabel}`,
traceId: 'trace-123',
spanId: 'span-456',
operationName: 'POST /checkout',
query: 'http_server_duration_milliseconds_count',
filter: 'service.name="checkout"',
aggregation: 'sum',
temporalAggregation: 'rate',
groupBy: 'service_name',
legendFormat: '{{service.name}} - p95',
formula: 'A * 1000',
step: '60',
limit: '25',
timeRange: 'last-1h',
collector: 'collector-a',
template: 'spring-boot',
serviceName: 'checkout',
serviceNamespace: 'payments',
environment: 'prod',
start: '1712730000000',
end: '1712733600000'
});
expect(route).toBe(
'/ingestion/otlp/metrics?entityId=7&entityType=service&entityName=checkout&returnTo=%2Flog%2Fmanage&traceId=trace-123&spanId=span-456&operationName=POST+%2Fcheckout&query=http_server_duration_milliseconds_count&filter=service.name%3D%22checkout%22&aggregation=sum&temporalAggregation=rate&groupBy=service_name&legendFormat=%7B%7Bservice.name%7D%7D+-+p95&formula=A+*+1000&step=60&limit=25&timeRange=last-1h&serviceName=checkout&serviceNamespace=payments&environment=prod&collector=collector-a&template=spring-boot&start=1712730000000&end=1712733600000'
);
expect(route).not.toContain('returnLabel=');
expect(route).not.toContain(encodeURIComponent(localizedReturnLabel));
});
it('detects legacy display labels in direct and nested return URLs', () => {
expect(hasMetricsDisplayReturnLabel(new URLSearchParams('returnLabel=Metrics'))).toBe(true);
expect(hasMetricsDisplayReturnLabel(new URLSearchParams('returnTo=%2Flog%2Fmanage%3FreturnLabel%3DLogs'))).toBe(true);
expect(hasMetricsDisplayReturnLabel(new URLSearchParams('returnTo=%2Flog%2Fmanage'))).toBe(false);
});
it('rejects decimal time bounds in metrics workbench URLs instead of rounding them', () => {
expect(
buildOtlpMetricsRoute({
traceId: 'trace-123',
serviceName: 'checkout',
start: '1777484896189.989',
end: '1777485856189.989'
})
).toBe('/ingestion/otlp/metrics?traceId=trace-123&serviceName=checkout');
});
it('rejects decimal entity ids in metrics workbench URLs instead of forwarding them', () => {
expect(
buildOtlpMetricsRoute({
entityId: '7.5',
entityName: 'checkout',
serviceName: 'checkout'
})
).toBe('/ingestion/otlp/metrics?entityName=checkout&serviceName=checkout');
});
it('rejects invalid metrics builder numeric controls instead of forwarding them', () => {
expect(
buildOtlpMetricsRoute({
query: 'http_server_duration_milliseconds_count',
filter: 'service.name="checkout"',
step: '0',
limit: '12.5'
})
).toBe('/ingestion/otlp/metrics?query=http_server_duration_milliseconds_count&filter=service.name%3D%22checkout%22');
});
it('keeps table inspector mode in the metrics workbench URL', () => {
expect(
buildOtlpMetricsRoute({
query: 'http.server.duration',
inspector: 'table'
})
).toBe('/ingestion/otlp/metrics?query=http.server.duration&inspector=table');
expect(
buildOtlpMetricsRoute({
query: 'http.server.duration',
inspector: 'heatmap' as 'table'
})
).toBe('/ingestion/otlp/metrics?query=http.server.duration');
});
it('keeps selected metric series in the workbench URL for repeatable inspection', () => {
expect(
buildOtlpMetricsRoute({
query: 'http.server.duration',
inspector: 'table',
series: 'http.server.duration-1'
})
).toBe('/ingestion/otlp/metrics?query=http.server.duration&series=http.server.duration-1&inspector=table');
});
it('keeps related metric candidate metadata in the workbench URL', () => {
const route = buildOtlpMetricsRoute({
query: 'container.cpu.usage',
filter: 'k8s.pod.name="checkout-7d9"',
relatedMetricSource: 'pod',
relatedMetricFamily: 'cpu',
relatedMetricReason: 'resource-filter',
relatedMetricMatchedLabels: 'k8s_pod_name',
relatedMetricResourceMatch: '{"k8s_pod_name":"checkout-7d9"}'
});
const params = new URL(route, 'http://localhost').searchParams;
expect(params.get('query')).toBe('container.cpu.usage');
expect(params.get('filter')).toBe('k8s.pod.name="checkout-7d9"');
expect(params.get('relatedMetricSource')).toBe('pod');
expect(params.get('relatedMetricFamily')).toBe('cpu');
expect(params.get('relatedMetricReason')).toBe('resource-filter');
expect(params.get('relatedMetricMatchedLabels')).toBe('k8s_pod_name');
expect(params.get('relatedMetricResourceMatch')).toBe('{"k8s_pod_name":"checkout-7d9"}');
});
it('keeps chart display settings in the metrics workbench URL', () => {
expect(
buildOtlpMetricsRoute({
query: 'http.server.duration',
warningThreshold: '75.5',
criticalThreshold: '90',
expectedRange: 'on'
})
).toBe('/ingestion/otlp/metrics?query=http.server.duration&warningThreshold=75.5&criticalThreshold=90&expectedRange=on');
expect(
buildOtlpMetricsRoute({
query: 'http.server.duration',
warningThreshold: 'abc',
criticalThreshold: 'Infinity',
expectedRange: 'yes' as 'on'
})
).toBe('/ingestion/otlp/metrics?query=http.server.duration');
});
it('keeps metrics legend format in the workbench URL', () => {
expect(
buildOtlpMetricsRoute({
query: 'http.server.duration',
groupBy: 'service.name',
legendFormat: '{{service.name}} - p95',
formula: 'A / 1024'
})
).toBe('/ingestion/otlp/metrics?query=http.server.duration&groupBy=service.name&legendFormat=%7B%7Bservice.name%7D%7D+-+p95&formula=A+%2F+1024');
});
it('keeps metrics inventory search and sort in the workbench URL', () => {
expect(
buildOtlpMetricsRoute({
query: 'http.server.duration',
inventorySearch: 'checkout',
inventorySort: 'time-series',
inventoryPageSize: '20',
inventoryPageIndex: '2',
seriesAttributeSearch: 'deployment'
})
).toBe('/ingestion/otlp/metrics?query=http.server.duration&inventorySearch=checkout&inventorySort=time-series&inventoryPageSize=20&inventoryPageIndex=2&seriesAttributeSearch=deployment');
});
it('keeps the shared time context keys when metrics opens another signal window', () => {
const route = buildOtlpMetricsRoute({
serviceName: 'checkout',
timeRange: 'last-6h',
start: '1712730000000',
end: '1712751600000',
refresh: '30',
live: 'false',
tz: 'Asia/Shanghai'
});
expect(route).toBe(
'/ingestion/otlp/metrics?timeRange=last-6h&serviceName=checkout&start=1712730000000&end=1712751600000&refresh=30&live=false&tz=Asia%2FShanghai'
);
});
it('keeps readable expression time windows in metrics workbench URLs', () => {
const route = buildOtlpMetricsRoute({
serviceName: 'checkout',
timeRange: 'last-1h',
from: '2026-05-17 15:30:00',
to: '2026-05-17 16:30:00',
timezone: 'Asia/Shanghai',
start: '1779003000000',
end: '1779006600000'
});
expect(route).toBe(
'/ingestion/otlp/metrics?timeRange=last-1h&from=2026-05-17+15%3A30%3A00&to=2026-05-17+16%3A30%3A00&serviceName=checkout&timezone=Asia%2FShanghai'
);
expect(route).not.toContain('start=');
expect(route).not.toContain('end=');
});
});

Some files were not shown because too many files have changed in this diff Show More