mirror of
https://github.com/dromara/hertzbeat.git
synced 2026-09-17 18:19:02 +00:00
maintenance(frontend): remove deprecated web-next
This commit is contained in:
@@ -1,5 +0,0 @@
|
||||
.next
|
||||
.next-dev
|
||||
node_modules
|
||||
npm-debug.log
|
||||
Dockerfile
|
||||
@@ -1,3 +0,0 @@
|
||||
{
|
||||
"extends": ["next/core-web-vitals"]
|
||||
}
|
||||
@@ -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
|
||||
@@ -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"]
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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('"actionId":"suggest-restart-checkout"');
|
||||
expect(html).toContain('"executionAllowed":false');
|
||||
expect(html).toContain(t('actions.entry.suggested.confirm'));
|
||||
expect(html).toContain('data-actions-suggested-action-evidence="suggest-restart-checkout"');
|
||||
expect(html).toContain('/alert?status=firing');
|
||||
expect(html).toContain('traceId=trace-123');
|
||||
expect(html).not.toContain('data-actions-auto-execute');
|
||||
expect(html).not.toContain('/actions/run');
|
||||
});
|
||||
|
||||
it('does not enable suggested actions from route tracking source params alone', async () => {
|
||||
const { default: ActionsPage } = await import('./actions-page');
|
||||
const html = renderToStaticMarkup(
|
||||
<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('"actionId":"suggest-restart-checkout"');
|
||||
});
|
||||
|
||||
it('renders entity-id-only suggested remediation targets with localized fallback copy', async () => {
|
||||
const { default: ActionsPage } = await import('./actions-page');
|
||||
const html = renderToStaticMarkup(
|
||||
<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=');
|
||||
});
|
||||
});
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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'
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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')}");
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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('"edgeId":"svc-checkout--res-orders-db"');
|
||||
expect(html).toContain('"viewMode":"resource-dependency"');
|
||||
expect(html).toContain('"sourceKind":"database-middleware-connection"');
|
||||
expect(html).toContain('"returnTo":"/topology?viewMode=resource-dependency&sourceKind=database-middleware-connection&edgeId=svc-checkout--res-orders-db');
|
||||
expect(html).not.toContain('returnLabel=');
|
||||
expect(html).not.toContain('HertzBeat operations topology');
|
||||
});
|
||||
|
||||
it('preserves three-signal evidence context into new inhibit authoring', async () => {
|
||||
const initialRouteState: AlertInhibitRouteState = {
|
||||
signal: 'traces',
|
||||
returnContext: {
|
||||
search: 'checkout',
|
||||
status: 'firing',
|
||||
severity: '',
|
||||
entityId: 'service:commerce/checkout',
|
||||
entityName: 'checkout-api',
|
||||
returnTo: '/trace/manage?traceId=trace-123',
|
||||
serviceName: 'checkout',
|
||||
serviceNamespace: 'commerce',
|
||||
environment: 'prod',
|
||||
source: 'otlp',
|
||||
signal: 'traces',
|
||||
traceId: 'trace-123',
|
||||
spanId: 'span-456',
|
||||
collector: 'edge-collector-a',
|
||||
template: 'java-service'
|
||||
},
|
||||
managementContext: {
|
||||
entityId: '',
|
||||
entityName: '',
|
||||
returnTo: '',
|
||||
returnLabel: '',
|
||||
matchMode: '',
|
||||
matchingRuleType: '',
|
||||
matchingRuleIds: [],
|
||||
matchedViewEnabled: false
|
||||
},
|
||||
signalContext: {
|
||||
entityId: 'service:commerce/checkout',
|
||||
entityName: 'checkout-api',
|
||||
serviceName: 'checkout',
|
||||
serviceNamespace: 'commerce',
|
||||
environment: 'prod',
|
||||
source: 'otlp',
|
||||
traceId: 'trace-123',
|
||||
spanId: 'span-456',
|
||||
collector: 'edge-collector-a',
|
||||
template: 'java-service',
|
||||
returnTo: '/trace/manage?traceId=trace-123'
|
||||
}
|
||||
};
|
||||
|
||||
const html = await renderAlertInhibitPage(initialRouteState);
|
||||
|
||||
expect(html).toContain('data-alert-inhibit-evidence-context="signal-route"');
|
||||
expect(html).toContain('data-alert-inhibit-evidence-signal="traces"');
|
||||
expect(html).toContain('data-alert-inhibit-evidence-return="/trace/manage?traceId=trace-123"');
|
||||
expect(html).toContain('hertzbeat.signal:traces');
|
||||
expect(html).toContain('service.name:checkout');
|
||||
expect(html).toContain('trace_id:trace-123');
|
||||
expect(html).toContain('span_id:span-456');
|
||||
expect(html).toContain('hertzbeat.collector:edge-collector-a');
|
||||
expect(html).toContain('data-alert-inhibit-draft-source-labels="hertzbeat.signal:traces');
|
||||
expect(html).toContain('data-alert-inhibit-draft-target-labels="hertzbeat.signal:traces');
|
||||
expect(html).toContain('data-alert-inhibit-draft-equal-labels="hertzbeat.entity.id, service.name, service.namespace, deployment.environment"');
|
||||
expect(html).not.toContain('returnLabel=');
|
||||
const source = readFileSync(resolve(process.cwd(), 'app/alert/inhibit/alert-inhibit-page.tsx'), 'utf8');
|
||||
expect(source).toContain("import { useRouter, useSearchParams } from 'next/navigation';");
|
||||
expect(source).not.toContain('queryStateFromParams(searchParams)');
|
||||
expect(source).not.toContain('readSignalRouteContext(searchParams)');
|
||||
expect(source).toContain('const alertInhibitRouteState = initialRouteState ?? EMPTY_ALERT_INHIBIT_ROUTE_STATE');
|
||||
});
|
||||
|
||||
it('keeps alert inhibit remounts on a short settled cache window with refresh-tick invalidation', () => {
|
||||
const source = readFileSync(resolve(process.cwd(), 'app/alert/inhibit/alert-inhibit-page.tsx'), 'utf8');
|
||||
|
||||
expect(source).toContain('ALERT_INHIBIT_SETTLED_CACHE_TTL_MS = 10_000');
|
||||
expect(source).toContain('ALERT_INHIBIT_LABEL_OPTIONS_TIMEOUT_MS = 2_500');
|
||||
expect(source).toContain('withTimeoutFallback(');
|
||||
expect(source).toContain('const [refreshTick, setRefreshTick] = useState(0)');
|
||||
expect(source).toContain('const [pageIndex, setPageIndex] = useState(routeListState.pageIndex)');
|
||||
expect(source).toContain('const [pageSize, setPageSize] = useState<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('"matchMode":"entity-noise-controls"');
|
||||
expect(apiMessageGet).toHaveBeenCalledWith('/alert/inhibit/11');
|
||||
expect(apiMessageGet).toHaveBeenCalledWith('/alert/inhibit/12');
|
||||
expect(apiMessageGet).not.toHaveBeenCalledWith('/alert/inhibits?pageIndex=0&pageSize=8&sort=id&order=desc');
|
||||
expect(result.list.content.map((item: any) => item.id)).toEqual([11]);
|
||||
expect(result.missingMatchedRuleCount).toBe(1);
|
||||
});
|
||||
|
||||
it('keeps Angular no-selection batch delete warning before opening confirm', () => {
|
||||
const source = readFileSync(resolve(process.cwd(), 'app/alert/inhibit/alert-inhibit-page.tsx'), 'utf8');
|
||||
const surfaceSource = readFileSync(resolve(process.cwd(), 'components/pages/alert-inhibit-surface.tsx'), 'utf8');
|
||||
|
||||
expect(source).toContain('if (checkedIds.length === 0)');
|
||||
expect(source).toContain("setEditorError(t('common.notify.no-select-delete'))");
|
||||
expect(source).toContain('setEditorMessage(null)');
|
||||
expect(source).toContain("setDeleteRequest({ kind: 'batch', ids: checkedIds })");
|
||||
expect(surfaceSource).toContain('data-alert-inhibit-delete-selected="toolbar"');
|
||||
expect(surfaceSource).toContain('data-alert-inhibit-action-feedback-owner="hertzbeat-ui-inline-feedback"');
|
||||
expect(surfaceSource).not.toContain('disabled={selectedCount === 0}');
|
||||
});
|
||||
|
||||
it('keeps Angular created-outside-matched notice state for matched-view authoring', () => {
|
||||
const source = readFileSync(resolve(process.cwd(), 'app/alert/inhibit/alert-inhibit-page.tsx'), 'utf8');
|
||||
const surfaceSource = readFileSync(resolve(process.cwd(), 'components/pages/alert-inhibit-surface.tsx'), 'utf8');
|
||||
|
||||
expect(source).toContain('const [createdOutsideMatchedViewNotice, setCreatedOutsideMatchedViewNotice] = useState(false)');
|
||||
expect(source).toContain('setCreatedOutsideMatchedViewNotice(false)');
|
||||
expect(source).toContain('createdOutsideMatchedViewNotice={createdOutsideMatchedViewNotice}');
|
||||
expect(surfaceSource).toContain('data-alert-inhibit-created-outside-matched="angular-authoring-notice"');
|
||||
expect(surfaceSource).toContain('data-alert-inhibit-created-outside-matched-owner="hertzbeat-ui-inline-feedback"');
|
||||
expect(surfaceSource).toContain('data-alert-inhibit-created-outside-matched-action="view-all"');
|
||||
});
|
||||
|
||||
it('keeps Angular entity-alert authoring prefill for entity noise-control inhibits', () => {
|
||||
const source = readFileSync(resolve(process.cwd(), 'app/alert/inhibit/alert-inhibit-page.tsx'), 'utf8');
|
||||
const surfaceSource = readFileSync(resolve(process.cwd(), 'components/pages/alert-inhibit-surface.tsx'), 'utf8');
|
||||
|
||||
expect(source).toContain('buildAlertInhibitEntityPrefillFromFacade(');
|
||||
expect(source).toContain("entityId => api.entities.alerts(entityId, { pageIndex: 0, pageSize: 20, status: 'firing' })");
|
||||
expect(source).not.toContain("from '../../../lib/api-client'");
|
||||
expect(source).toContain("t('entity.noise-controls.authoring.inhibit.prefill-warning')");
|
||||
expect(source).toContain("t('entity.noise-controls.authoring.prefill-warning.no-entity-id')");
|
||||
expect(source).toContain('entityPrefillSource={entityPrefillSource}');
|
||||
expect(source).toContain('entityPrefillWarning={entityPrefillWarning}');
|
||||
expect(source).toContain("name: `${displayName} inhibit`");
|
||||
expect(surfaceSource).toContain('data-alert-inhibit-entity-prefill={entityPrefillSource ===');
|
||||
expect(surfaceSource).toContain("t('entity.noise-controls.authoring.inhibit.title')");
|
||||
expect(surfaceSource).toContain("t('entity.noise-controls.authoring.inhibit.prefill-success')");
|
||||
expect(surfaceSource).toContain('prefillWarning={entityPrefillWarning}');
|
||||
});
|
||||
|
||||
it('uses Angular delete notifications for confirmed delete feedback', () => {
|
||||
const source = readFileSync(resolve(process.cwd(), 'app/alert/inhibit/alert-inhibit-page.tsx'), 'utf8');
|
||||
|
||||
expect(source).toContain('async function handleConfirmedDelete()');
|
||||
expect(source).toContain('deleteAlertInhibitsFromFacade(api.alertInhibits.delete, request.ids)');
|
||||
expect(source).toContain('deleteAlertInhibitFromFacade(api.alertInhibits.delete, request.ids[0])');
|
||||
expect(source).toContain("setEditorMessage(t('common.notify.delete-success'))");
|
||||
expect(source).toContain("setEditorError(t('common.notify.delete-fail'))");
|
||||
expect(source).toContain('setEditorErrorDetail(error instanceof Error ? error.message : null)');
|
||||
expect(source).toContain("setEditorErrorContract('delete')");
|
||||
expect(source).not.toContain('apiMessageDelete');
|
||||
expect(source).not.toContain("setEditorMessage(t('common.delete-success'))");
|
||||
expect(source).not.toContain("t('common.delete-failed')");
|
||||
});
|
||||
|
||||
it('names the target and destructive action in inhibit delete confirmation', () => {
|
||||
const source = readFileSync(resolve(process.cwd(), 'app/alert/inhibit/alert-inhibit-page.tsx'), 'utf8');
|
||||
|
||||
expect(source).toContain('const deleteTargetNames = deleteRequest?.ids');
|
||||
expect(source).toContain("t('alert.inhibit.delete.confirm.targets', { names: deleteTargetNames.join(', ') })");
|
||||
expect(source).toContain("t('alert.inhibit.delete.confirm.targets-more', { count: missingDeleteTargetCount })");
|
||||
expect(source).toContain("confirmLabel={t('alert.inhibit.delete.confirm.action')}");
|
||||
expect(source).not.toContain("confirmLabel={t('common.button.ok')}");
|
||||
});
|
||||
|
||||
it('uses Angular edit notifications and facade persistence for enable-toggle feedback', () => {
|
||||
const source = readFileSync(resolve(process.cwd(), 'app/alert/inhibit/alert-inhibit-page.tsx'), 'utf8');
|
||||
const toggleSource = source.slice(
|
||||
source.indexOf('async function handleToggleEnabled(inhibit?: AlertInhibit)'),
|
||||
source.indexOf('async function handleDelete(inhibitId?: number)')
|
||||
);
|
||||
|
||||
expect(toggleSource).toContain('async function handleToggleEnabled(inhibit?: AlertInhibit)');
|
||||
expect(toggleSource).toContain('updateAlertInhibitEnabledFromFacade(api.alertInhibits.update, target, !(target.enable ?? true))');
|
||||
expect(toggleSource).toContain("setEditorMessage(t('common.notify.edit-success'))");
|
||||
expect(toggleSource).toContain("setEditorError(t('common.notify.edit-fail'))");
|
||||
expect(toggleSource).toContain('setEditorErrorDetail(error instanceof Error ? error.message : null)');
|
||||
expect(toggleSource).toContain("setEditorErrorContract('enable')");
|
||||
expect(toggleSource).not.toContain('apiMessagePut');
|
||||
expect(toggleSource).not.toContain("setEditorMessage(t('common.save-success'))");
|
||||
expect(toggleSource).not.toContain("t('common.save-failed')");
|
||||
});
|
||||
|
||||
it('uses Angular create/edit notifications and facade persistence for editor save feedback', () => {
|
||||
const source = readFileSync(resolve(process.cwd(), 'app/alert/inhibit/alert-inhibit-page.tsx'), 'utf8');
|
||||
const saveSource = source.slice(
|
||||
source.indexOf('async function handleSave()'),
|
||||
source.indexOf('async function handleToggleEnabled(inhibit?: AlertInhibit)')
|
||||
);
|
||||
|
||||
expect(saveSource).toContain('const isEdit = Boolean(draft.id)');
|
||||
expect(saveSource).toContain('updateAlertInhibitFromFacade(api.alertInhibits.update, draft)');
|
||||
expect(saveSource).toContain('createAlertInhibitFromFacade(api.alertInhibits.create, draft)');
|
||||
expect(saveSource).toContain("setEditorMessage(t(isEdit ? 'common.notify.edit-success' : 'common.notify.new-success'))");
|
||||
expect(saveSource).toContain('setCreatedOutsideMatchedViewNotice(!isEdit && useMatchedView)');
|
||||
expect(saveSource).toContain("setEditorError(t(isEdit ? 'common.notify.edit-fail' : 'common.notify.new-fail'))");
|
||||
expect(saveSource).toContain('setEditorErrorDetail(error instanceof Error ? error.message : null)');
|
||||
expect(saveSource).toContain("setEditorErrorContract('save')");
|
||||
expect(saveSource).not.toContain('apiMessagePost');
|
||||
expect(saveSource).not.toContain('apiMessagePut');
|
||||
expect(saveSource).not.toContain("setEditorMessage(t('common.save-success'))");
|
||||
expect(saveSource).not.toContain("t('common.save-failed')");
|
||||
});
|
||||
|
||||
it('uses Angular edit failure notification and facade detail loading for edit detail fallback', () => {
|
||||
const source = readFileSync(resolve(process.cwd(), 'app/alert/inhibit/alert-inhibit-page.tsx'), 'utf8');
|
||||
const editSource = source.slice(
|
||||
source.indexOf('async function handleEdit(inhibitId?: number)'),
|
||||
source.indexOf('async function handleSave()')
|
||||
);
|
||||
|
||||
expect(editSource).toContain('async function handleEdit(inhibitId?: number)');
|
||||
expect(editSource).toContain('loadAlertInhibitDetailFromFacade(api.alertInhibits.detail, targetId)');
|
||||
expect(editSource).toContain("setEditorError(error instanceof Error ? error.message : t('common.notify.edit-fail'))");
|
||||
expect(editSource).not.toContain('apiMessageGet');
|
||||
expect(editSource).not.toContain("t('common.load-failed')");
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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} />;
|
||||
}
|
||||
@@ -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)');
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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('"edgeId":"svc-checkout--res-orders-db"');
|
||||
expect(html).toContain('"viewMode":"resource-dependency"');
|
||||
expect(html).toContain('"sourceKind":"database-middleware-connection"');
|
||||
expect(html).toContain('"returnTo":"/topology?viewMode=resource-dependency&sourceKind=database-middleware-connection&edgeId=svc-checkout--res-orders-db');
|
||||
expect(html).not.toContain('returnLabel=');
|
||||
expect(html).not.toContain('HertzBeat operations topology');
|
||||
}, 30_000);
|
||||
|
||||
it('preserves three-signal evidence context into new silence authoring', async () => {
|
||||
const initialRouteState: AlertSilenceRouteState = {
|
||||
signal: 'logs',
|
||||
returnContext: {
|
||||
search: 'checkout',
|
||||
status: 'firing',
|
||||
severity: '',
|
||||
entityId: 'service:commerce/checkout',
|
||||
entityName: 'checkout-api',
|
||||
returnTo: '/log/manage?view=list&traceId=trace-123',
|
||||
serviceName: 'checkout',
|
||||
serviceNamespace: 'commerce',
|
||||
environment: 'prod',
|
||||
source: 'otlp',
|
||||
signal: 'logs',
|
||||
traceId: 'trace-123',
|
||||
spanId: 'span-456',
|
||||
collector: 'edge-collector-a',
|
||||
template: 'java-service'
|
||||
},
|
||||
signalContext: {
|
||||
entityId: 'service:commerce/checkout',
|
||||
entityName: 'checkout-api',
|
||||
serviceName: 'checkout',
|
||||
serviceNamespace: 'commerce',
|
||||
environment: 'prod',
|
||||
source: 'otlp',
|
||||
traceId: 'trace-123',
|
||||
spanId: 'span-456',
|
||||
collector: 'edge-collector-a',
|
||||
template: 'java-service',
|
||||
returnTo: '/log/manage?view=list&traceId=trace-123'
|
||||
},
|
||||
managementContext: {
|
||||
entityId: 'service:commerce/checkout',
|
||||
entityName: 'checkout-api',
|
||||
returnTo: '/log/manage?view=list&traceId=trace-123',
|
||||
returnLabel: '',
|
||||
matchMode: '',
|
||||
matchingRuleType: '',
|
||||
matchingRuleIds: [],
|
||||
matchedViewEnabled: false
|
||||
}
|
||||
};
|
||||
|
||||
const html = await renderAlertSilencePage(initialRouteState);
|
||||
|
||||
expect(html).toContain('data-alert-silence-evidence-context="signal-route"');
|
||||
expect(html).toContain('data-alert-silence-evidence-signal="logs"');
|
||||
expect(html).toContain('data-alert-silence-evidence-return="/log/manage?view=list&traceId=trace-123"');
|
||||
expect(html).toContain('hertzbeat.signal:logs');
|
||||
expect(html).toContain('service.name:checkout');
|
||||
expect(html).toContain('trace_id:trace-123');
|
||||
expect(html).toContain('span_id:span-456');
|
||||
expect(html).toContain('hertzbeat.collector:edge-collector-a');
|
||||
expect(html).toContain('data-alert-silence-draft-labels="hertzbeat.signal:logs');
|
||||
expect(html).not.toContain('returnLabel=');
|
||||
const source = readFileSync(resolve(process.cwd(), 'app/alert/silence/alert-silence-page.tsx'), 'utf8');
|
||||
expect(source).toContain("import { useRouter, useSearchParams } from 'next/navigation';");
|
||||
expect(source).not.toContain('queryStateFromParams(searchParams)');
|
||||
expect(source).not.toContain('readSignalRouteContext(searchParams)');
|
||||
expect(source).toContain('const alertSilenceRouteState = initialRouteState ?? EMPTY_ALERT_SILENCE_ROUTE_STATE');
|
||||
}, 30_000);
|
||||
|
||||
it('loads Angular entity-noise-control matched silence rules by id', async () => {
|
||||
const returnTo = '/entities/42?tab=alerts';
|
||||
const initialRouteState: AlertSilenceRouteState = {
|
||||
returnContext: {
|
||||
search: 'checkout',
|
||||
status: 'firing',
|
||||
severity: '',
|
||||
entityId: '42',
|
||||
entityName: 'checkout-api',
|
||||
returnTo
|
||||
},
|
||||
signal: null,
|
||||
signalContext: {
|
||||
entityId: '42',
|
||||
entityName: 'checkout-api',
|
||||
returnTo
|
||||
},
|
||||
managementContext: {
|
||||
entityId: '42',
|
||||
entityName: 'checkout-api',
|
||||
returnTo,
|
||||
returnLabel: 'checkout-api',
|
||||
matchMode: 'entity-noise-controls',
|
||||
matchingRuleType: 'silence',
|
||||
matchingRuleIds: [11, 12],
|
||||
matchedViewEnabled: true
|
||||
}
|
||||
};
|
||||
apiMessageGet.mockImplementation(async (url: string) => {
|
||||
if (url === '/alert/silence/11') {
|
||||
return { id: 11, name: 'checkout silence', enable: true, matchAll: false, labels: { service: 'checkout' }, type: 0 };
|
||||
}
|
||||
if (url === '/alert/silence/12') {
|
||||
throw new Error('missing');
|
||||
}
|
||||
return mockState.renderData.list;
|
||||
});
|
||||
|
||||
const html = await renderAlertSilencePage(initialRouteState);
|
||||
const loaded = await mockState.lastLoad?.() as any;
|
||||
|
||||
expect(html).toContain('data-alert-silence-match-mode="entity-noise-controls"');
|
||||
expect(html).toContain('data-alert-silence-match-view="matched"');
|
||||
expect(html).toContain('data-alert-silence-matching-rule-ids="11,12"');
|
||||
expect(apiMessageGet).toHaveBeenCalledWith('/alert/silence/11');
|
||||
expect(apiMessageGet).toHaveBeenCalledWith('/alert/silence/12');
|
||||
expect(apiMessageGet).not.toHaveBeenCalledWith('/alert/silences?pageIndex=0&pageSize=8&sort=id&order=desc');
|
||||
expect(loaded.list.content.map((item: any) => item.id)).toEqual([11]);
|
||||
expect(loaded.missingMatchedRuleCount).toBe(1);
|
||||
}, 30_000);
|
||||
|
||||
it('keeps alert silence remounts on a short settled cache window with refresh-tick invalidation', () => {
|
||||
const source = readFileSync(resolve(process.cwd(), 'app/alert/silence/alert-silence-page.tsx'), 'utf8');
|
||||
|
||||
expect(source).toContain('ALERT_SILENCE_SETTLED_CACHE_TTL_MS = 10_000');
|
||||
expect(source).toContain('ALERT_SILENCE_LABEL_OPTIONS_TIMEOUT_MS = 2_500');
|
||||
expect(source).toContain('withTimeoutFallback(');
|
||||
expect(source).toContain('const [refreshTick, setRefreshTick] = useState(0)');
|
||||
expect(source).toContain('const [pageIndex, setPageIndex] = useState(routeListState.pageIndex)');
|
||||
expect(source).toContain('const [pageSize, setPageSize] = useState<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')");
|
||||
});
|
||||
});
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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' })
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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 || []
|
||||
});
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}');
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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"');
|
||||
});
|
||||
});
|
||||
@@ -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'
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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&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&source=pd-1218&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&pageSize=50&source=product-design-1719"'
|
||||
);
|
||||
expect(html).toContain('data-entity-detail-surface="otlp-hertzbeat-ui-entity-detail"');
|
||||
});
|
||||
|
||||
it('returns to the safe entity list context after delete instead of dropping query parameters', () => {
|
||||
expect(
|
||||
buildEntityDetailDeleteReturnHref({
|
||||
returnTo: '/entities?search=checkout&source=entity-create-return&pageSize=8',
|
||||
source: 'entity-create-return'
|
||||
}, '42')
|
||||
).toBe('/entities?search=checkout&source=entity-create-return&pageSize=8');
|
||||
|
||||
expect(
|
||||
buildEntityDetailDeleteReturnHref({
|
||||
returnTo: 'https://example.invalid/entities',
|
||||
timeRange: 'last-30m',
|
||||
source: 'monitor'
|
||||
}, '42')
|
||||
).toBe('/entities?timeRange=last-30m&source=monitor');
|
||||
|
||||
expect(
|
||||
buildEntityDetailDeleteReturnHref({
|
||||
returnTo: '/entities/42?source=entity-edit-return',
|
||||
timeRange: 'last-30m',
|
||||
source: 'entity-edit-return'
|
||||
}, '42')
|
||||
).toBe('/entities?timeRange=last-30m&source=entity-edit-return');
|
||||
|
||||
expect(
|
||||
buildEntityDetailDeleteReturnHref({
|
||||
returnTo: '/entities/42/edit?source=entity-edit-return',
|
||||
timeRange: 'last-30m',
|
||||
source: 'entity-edit-return'
|
||||
}, 42)
|
||||
).toBe('/entities?timeRange=last-30m&source=entity-edit-return');
|
||||
});
|
||||
|
||||
it('adds a list-visible success result to the post-delete return URL', () => {
|
||||
expect(buildEntityDetailDeleteSuccessHref('/entities?source=entity-delete-return&pageSize=8', '42')).toBe(
|
||||
'/entities?source=entity-delete-return&pageSize=8&deleteResult=success&deletedEntity=42'
|
||||
);
|
||||
expect(buildEntityDetailDeleteSuccessHref('/entities', null)).toBe('/entities?deleteResult=success');
|
||||
});
|
||||
|
||||
it('keeps novice entity detail recoverable when delete fails', async () => {
|
||||
vi.mocked(apiMessageDelete).mockRejectedValueOnce(new Error('delete denied by relation guard'));
|
||||
container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
|
||||
await act(async () => {
|
||||
root?.render(
|
||||
<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&pageSize=50&source=product-design-delete-failure"');
|
||||
expect(mockState.push).not.toHaveBeenCalled();
|
||||
expect(mockState.refresh).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('keeps entity detail remounts on a short settled cache window with refresh invalidation', () => {
|
||||
const source = readFileSync(resolve(process.cwd(), 'app/entities/[entityId]/entity-detail-page.tsx'), 'utf8');
|
||||
|
||||
expect(source).toContain('ENTITY_DETAIL_SETTLED_CACHE_TTL_MS = 10_000');
|
||||
expect(source).toContain('ENTITY_DETAIL_LOAD_TIMEOUT_MS = 15_000');
|
||||
expect(source).toContain('key={entityDetailCacheKey}');
|
||||
expect(source).toContain("loadingTitle={t('entities.detail.loading.title')}");
|
||||
expect(source).toContain("loadingCopy={t('entities.detail.loading.copy')}");
|
||||
expect(source).toContain('loadTimeoutMs={ENTITY_DETAIL_LOAD_TIMEOUT_MS}');
|
||||
expect(source).toContain('loadingDelayMs={150}');
|
||||
expect(source).toContain('const [reloadNonce, setReloadNonce] = useState(0)');
|
||||
expect(source).toContain("['entity-detail', entityDetailUrl, reloadNonce].join(':')");
|
||||
expect(source).toContain('[entityDetailUrl, reloadNonce]');
|
||||
expect(source).toContain('void reloadNonce');
|
||||
expect(source).toContain("import { api } from '@/lib/api-facade';");
|
||||
expect(source).toContain('return loadEntityDetailFromFacade(api.entities.detail, entityId, t);');
|
||||
expect(source).toContain("import { resetWorkbenchLoadCache } from '@/lib/workbench-load-cache';");
|
||||
expect(source).toContain('resetWorkbenchLoadCache();');
|
||||
expect(source).toContain('router.push(buildEntityDetailDeleteSuccessHref(deleteReturnHref, entityId));');
|
||||
expect(source).not.toContain('return loadEntityDetail(apiMessageGet, entityId, t);');
|
||||
expect(source).toContain('setReloadNonce(current => current + 1)');
|
||||
expect(source).toContain('router.refresh();');
|
||||
expect(source).toContain('cacheKey={entityDetailCacheKey}');
|
||||
expect(source).toContain('cacheSettledTtlMs={ENTITY_DETAIL_SETTLED_CACHE_TTL_MS}');
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,7 +0,0 @@
|
||||
import React from 'react';
|
||||
|
||||
import EntityDiscoveryPage from './entity-discovery-page';
|
||||
|
||||
export default function EntityDiscoveryRoutePage() {
|
||||
return <EntityDiscoveryPage />;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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&pageSize=50"');
|
||||
expect(html).toContain('data-route-time-range="last-30m"');
|
||||
});
|
||||
|
||||
it('reads route context from import route search params', async () => {
|
||||
const html = renderToStaticMarkup(
|
||||
await EntityImportRoutePage({
|
||||
searchParams: Promise.resolve({
|
||||
source: 'product-design-1330',
|
||||
returnTo: '/entities?source=product-design-1330&pageSize=50',
|
||||
timeRange: 'last-30m'
|
||||
})
|
||||
})
|
||||
);
|
||||
|
||||
expect(html).toContain('data-route-source="product-design-1330"');
|
||||
expect(html).toContain('data-route-return-to="/entities?source=product-design-1330&pageSize=50"');
|
||||
expect(html).toContain('data-route-time-range="last-30m"');
|
||||
});
|
||||
|
||||
it('shows delete success feedback when entity detail returns to import', async () => {
|
||||
const expectedT = createTranslatorMock({ locale: 'zh-CN' });
|
||||
const html = renderToStaticMarkup(
|
||||
await EntityImportRoutePage({
|
||||
searchParams: Promise.resolve({
|
||||
deleteResult: 'success',
|
||||
deletedEntity: '658679066385664',
|
||||
source: 'product-design-1499'
|
||||
})
|
||||
})
|
||||
);
|
||||
|
||||
expect(html).toContain('data-initial-message-tone="success"');
|
||||
expect(html).toContain(expectedT('entities.import.delete-success', { id: '658679066385664' }));
|
||||
});
|
||||
});
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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'
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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'
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -1,6 +0,0 @@
|
||||
import { redirect } from 'next/navigation';
|
||||
import { buildEntityListCompatRouteUrl } from '../../lib/entity-manage/query-state';
|
||||
|
||||
export default function EntitiesNotFound() {
|
||||
redirect(buildEntityListCompatRouteUrl());
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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&pageSize=8&serviceName=checkout|null|/ingestion/otlp/metrics/console?query=checkout&aggregation=avg&groupBy=service_name&timeRange=last-30m"'
|
||||
);
|
||||
expect(html).toContain('data-explorer-shared-frame="hertzbeat-ui"');
|
||||
expect(html).toContain('data-hz-ui="explorer-frame"');
|
||||
expect(html).toContain('data-hz-density="operator-compact"');
|
||||
expect(html).toContain('data-explorer-query-bar="hertzbeat-ui-query-row"');
|
||||
expect(html).toContain('data-explorer-chart-band="hertzbeat-ui-chart-band"');
|
||||
expect(html).toContain('data-explorer-result-table="hertzbeat-ui-dense-table"');
|
||||
expect(html).toContain('data-explorer-detail-panel="hertzbeat-ui-detail-panel"');
|
||||
expect(html).toContain('data-explorer-detail-active-row="trace:trace-1"');
|
||||
expect(html).toContain('data-explorer-result-table-owner="hertzbeat-ui-data-table"');
|
||||
expect(html).toContain('data-hz-row-clickable="true"');
|
||||
expect(html).toContain('data-hz-row-selected="true"');
|
||||
expect(html).toContain('data-explorer-result-row="trace:trace-1"');
|
||||
expect(html).toContain('data-explorer-result-row-selected="true"');
|
||||
expect(html).toContain('data-explorer-result-row="log:1:trace-1:none:0"');
|
||||
expect(html).toContain('data-explorer-result-row-selected="false"');
|
||||
expect(html).toContain('data-explorer-result-row="metric:frontend:http.server.duration"');
|
||||
expect(html).toContain('data-explorer-signal-tone="trace"');
|
||||
expect(html).toContain('data-explorer-signal-tone="log"');
|
||||
expect(html).toContain('data-explorer-signal-tone="metric"');
|
||||
expect(html).toContain('border-[#394a78] bg-[#121a2a] text-[#c7d7ff]');
|
||||
expect(html).not.toContain('border-[#315b49] bg-[#0f211b] text-[#8bd8ad]');
|
||||
expect(html).toContain('href="/trace/manage?traceId=trace-1"');
|
||||
expect(html).toContain('href="/log/manage?traceId=trace-1"');
|
||||
expect(html).toContain('href="/ingestion/otlp/metrics?query=http.server.duration&serviceName=frontend"');
|
||||
expect(html).toContain(t('explorer.title'));
|
||||
expect(html).toContain(t('explorer.query.label'));
|
||||
expect(html).toContain(t('explorer.query.run'));
|
||||
expect(html).toContain(t('explorer.signal.aria'));
|
||||
expect(html).toContain(t('explorer.table.service'));
|
||||
expect(html).toContain(t('explorer.table.operation'));
|
||||
expect(html).toContain('checkout');
|
||||
expect(html).toContain(t('explorer.actions.save-view'));
|
||||
expect(html).toContain(t('explorer.actions.create-alert'));
|
||||
expect(html).toContain(t('explorer.actions.add-dashboard'));
|
||||
expect(html).toContain('data-explorer-query-input="url-owned"');
|
||||
expect(html).toContain('data-explorer-signal-select="url-owned"');
|
||||
expect(html).toContain('data-explorer-query-url="/explorer?q=checkout&signal=trace"');
|
||||
|
||||
expect(html).not.toContain('signoz-');
|
||||
expect(html).not.toContain('Explorer');
|
||||
expect(html).not.toContain('Funnels');
|
||||
expect(html).not.toContain('Views');
|
||||
expect(html).not.toContain('Search and Filter based on resource attributes.');
|
||||
expect(html).not.toContain('Run Query');
|
||||
expect(html).not.toContain('Save this view');
|
||||
expect(html).not.toContain('Create an Alert');
|
||||
expect(html).not.toContain('Add to Dashboard');
|
||||
});
|
||||
|
||||
it('keeps cross-signal handoffs without reverting to old cards', async () => {
|
||||
const { default: ExplorerPage } = await import('./page');
|
||||
const html = renderToStaticMarkup(<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')");
|
||||
});
|
||||
});
|
||||
@@ -1,6 +0,0 @@
|
||||
import React from 'react';
|
||||
import ExplorerPage from './explorer-page';
|
||||
|
||||
export default function ExplorerRoutePage() {
|
||||
return <ExplorerPage />;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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']
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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&pageSize=8"');
|
||||
expect(html).toContain('data-incidents-shared-workbench="hertzbeat-ui"');
|
||||
expect(html).toContain('data-hz-ui="incident-workbench"');
|
||||
expect(html).toContain('data-hz-incident-workbench-owner="hertzbeat-ui-incident-workbench"');
|
||||
expect(html).toContain('data-hz-incident-workbench-density="operator-compact"');
|
||||
expect(html).toContain('data-hz-incident-workbench-style="hertzbeat-ui-matte-hard-edge"');
|
||||
expect(html).toContain('data-hz-incident-workbench-table="shared"');
|
||||
expect(html).toContain('data-hz-incident-workbench-query="/status/page/incident?pageIndex=0&pageSize=8"');
|
||||
expect(html).toContain('data-hz-incident-transition-actions="shared"');
|
||||
expect(html).toContain('data-hz-incident-transition-owner="hertzbeat-ui-incident-transition-actions"');
|
||||
expect(html).toContain('data-hz-incident-transition-action="state-1"');
|
||||
expect(html).toContain('data-hz-incident-transition-action="state-2"');
|
||||
expect(html).toContain('data-hz-incident-transition-action="state-3"');
|
||||
expect(html).toContain('data-hz-incident-transition-disabled="false"');
|
||||
expect(html).toContain('data-hz-ui="data-table"');
|
||||
expect(html).toContain('data-hz-incident-timeline-item="incident-timeline-42-8"');
|
||||
expect(html).toContain('data-hz-incident-owner-item="incident-owner-42"');
|
||||
expect(html).toContain('Incidents');
|
||||
expect(html).toContain('API latency incident');
|
||||
expect(html).toContain('data-hz-row-selected="true"');
|
||||
expect(apiMessageGet).toHaveBeenNthCalledWith(1, '/status/page/incident?pageIndex=0&pageSize=8');
|
||||
expect(apiMessageGet).toHaveBeenNthCalledWith(2, '/status/page/incident/42');
|
||||
expect(html).not.toContain('angular-dark-ops-placeholder');
|
||||
expect(html).not.toContain('DARK OPS');
|
||||
expect(html).not.toContain('V1 SHELL IS LIVE');
|
||||
expect(html).not.toContain('Domain adapter comes next');
|
||||
expect(html).not.toContain('data-incidents-shell-panel="cold-ops-shell-panel"');
|
||||
expect(html).not.toContain('data-incidents-launch-checklist="cold-ops-static-rail"');
|
||||
expect(html).not.toContain('data-incidents-empty-state="cold-ops-domain-adapter"');
|
||||
expect(source).toContain('hzOpsCatalogVisual');
|
||||
expect(source).toContain('HzIncidentWorkbench');
|
||||
expect(source).toContain('ClientWorkbench');
|
||||
expect(source).toContain('loadIncidentWorkbenchData');
|
||||
expect(source).toContain('apiMessageGet');
|
||||
expect(source).toContain('apiMessagePut');
|
||||
expect(source).toContain('transitionIncidentStatus');
|
||||
expect(source).toContain("incident: t('incidents.table.incident')");
|
||||
expect(source).toContain("severity: t('incidents.table.severity')");
|
||||
expect(source).toContain("timeline: t('incidents.table.timeline')");
|
||||
expect(source).toContain("emptyLabel={t('incidents.table.empty')}");
|
||||
expect(source).toContain('data-incidents-detail-source');
|
||||
expect(source).toContain('data-incidents-transition-source');
|
||||
expect(source).toContain('data-incidents-query-contract="angular-search-pagination"');
|
||||
expect(source).not.toContain('rounded-[16px]');
|
||||
expect(source).not.toContain('rounded-[14px]');
|
||||
expect(source).not.toContain('#4f6cff');
|
||||
expect(source).not.toContain('#101c31');
|
||||
expect(html).not.toContain('incidents.subtitle');
|
||||
expect(html).not.toContain('data-summary-metric-grid');
|
||||
expect(source).not.toContain('WorkbenchPage');
|
||||
expect(source).not.toContain('StageSection');
|
||||
expect(source).not.toContain('SummaryMetricGrid');
|
||||
expect(source).not.toContain('DrawerSection');
|
||||
});
|
||||
|
||||
it('preserves the Angular incident search and server-side pagination read contract from route query params', async () => {
|
||||
loadState.lastLoad = null;
|
||||
apiMessageGet.mockReset();
|
||||
apiMessagePut.mockReset();
|
||||
apiMessageGet
|
||||
.mockResolvedValueOnce({
|
||||
content: [
|
||||
{
|
||||
id: 77,
|
||||
name: 'API latency from query',
|
||||
state: 2,
|
||||
startTime: 1712730000000,
|
||||
creator: 'platform-oncall',
|
||||
components: [{ id: 9, name: 'checkout-api' }],
|
||||
contents: []
|
||||
}
|
||||
],
|
||||
totalElements: 1,
|
||||
pageIndex: 2,
|
||||
pageSize: 15
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
id: 77,
|
||||
name: 'API latency from query',
|
||||
state: 2,
|
||||
startTime: 1712730000000,
|
||||
modifier: 'platform-oncall',
|
||||
components: [{ id: 9, name: 'checkout-api' }],
|
||||
contents: [{ id: 10, state: 2, message: 'query detail loaded', timestamp: 1712730300000 }]
|
||||
});
|
||||
|
||||
const { default: IncidentsPage } = await import('./page');
|
||||
const html = renderToStaticMarkup(await IncidentsPage({
|
||||
searchParams: Promise.resolve({
|
||||
search: 'api latency',
|
||||
pageIndex: '2',
|
||||
pageSize: '15'
|
||||
})
|
||||
}));
|
||||
const lastLoad = loadState.lastLoad as (() => Promise<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');
|
||||
});
|
||||
});
|
||||
@@ -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
Reference in New Issue
Block a user