You are viewing a plain text version of this content. The canonical link for it is here.
Posted to notifications@hertzbeat.apache.org by zh...@apache.org on 2026/07/10 06:55:02 UTC
(hertzbeat) branch 2.0.0 updated: feat: complete HertzBeat 2.0 novice workflows
This is an automated email from the ASF dual-hosted git repository.
zqr10159 pushed a commit to branch 2.0.0
in repository https://gitbox.apache.org/repos/asf/hertzbeat.git
The following commit(s) were added to refs/heads/2.0.0 by this push:
new 2de48d2caa feat: complete HertzBeat 2.0 novice workflows
2de48d2caa is described below
commit 2de48d2caa43fa58fd93e71b0e22e7d4f431c9d5
Author: Logic <zq...@dromara.org>
AuthorDate: Fri Jul 10 14:54:43 2026 +0800
feat: complete HertzBeat 2.0 novice workflows
Refine entity, monitor, alert, settings, observability, and shared operator workflows; add scale guards, route compatibility, release validation, and Product Design coverage.
AI assistance: used for draft implementation and test iteration.\nHuman validation: Node 22 production build; 426 frontend files and 3,398 tests; 59-route matrix; release budget 7.84 MiB / 8.00 MiB; focused backend tests and Browser QA.\nRisk notes: local scale proof covers 2,005 entities; sustained concurrent-ingestion SLA remains deployment-specific.
---
docs/product-design-user-validation-loop.md | 214 ++
.../alert/service/impl/AlertDefineServiceImpl.java | 2 +-
.../dto/entity/EntityOpsSummaryInfo.java | 30 +-
.../manager/controller/EntityController.java | 18 +
.../apache/hertzbeat/manager/dao/BulletinDao.java | 5 +
.../hertzbeat/manager/dao/EntityIdentityDao.java | 4 +
.../manager/dao/EntityMonitorBindDao.java | 6 +
.../hertzbeat/manager/dao/EntityRelationDao.java | 16 +
.../hertzbeat/manager/dao/ObserveEntityDao.java | 4 +
.../manager/service/ObserveEntityService.java | 3 +
.../service/entity/EntityCatalogQueryService.java | 88 +-
.../EntityDefinitionDocumentParserService.java | 130 +-
.../entity/EntityDefinitionDraftService.java | 129 +-
...tityDefinitionRelationNormalizationService.java | 7 +-
.../EntityDefinitionTypeResolverService.java | 20 +-
.../EntityDetailObservabilityReadModelService.java | 24 +-
.../entity/EntityDetailReadModelService.java | 26 +-
.../service/entity/EntityIdentityQueryService.java | 15 +
.../entity/EntityIdentityReadModelService.java | 5 +
.../entity/EntityIdentityWriteModelService.java | 56 +-
.../entity/EntityIntegrationHintService.java | 71 +-
.../service/entity/EntityListReadModelService.java | 50 +-
.../entity/EntityMonitorBindQueryService.java | 40 +
.../service/entity/EntityMonitorBindService.java | 60 +
.../entity/EntityMutationWorkflowService.java | 200 +-
.../entity/EntityNoiseControlRuleQueryService.java | 6 +-
.../service/entity/EntityRelationQueryService.java | 34 +
.../service/entity/EntityRelationService.java | 9 +
.../service/entity/EntityStatusRefreshService.java | 61 +-
.../entity/EntitySummaryReadModelService.java | 67 +-
.../entity/EntityWorkspaceAccessService.java | 10 +
.../entity/EntityWorkspaceQueryService.java | 7 +
.../entity/LocalTopologyDemoRelationSeeder.java | 96 +-
.../service/impl/AbstractImExportServiceImpl.java | 21 +
.../manager/service/impl/BulletinServiceImpl.java | 5 +-
.../manager/service/impl/MonitorServiceImpl.java | 14 +-
.../service/impl/ObserveEntityServiceImpl.java | 7 +
.../manager/service/impl/PluginServiceImpl.java | 22 +-
.../manager/service/BulletinServiceTest.java | 44 +
.../manager/service/JsonImExportServiceTest.java | 72 +
.../manager/service/MonitorServiceTest.java | 24 +
.../manager/service/ObserveEntityServiceTest.java | 57 +-
.../manager/service/PluginServiceTest.java | 52 +-
.../EntityDefinitionDocumentParserServiceTest.java | 42 +
.../entity/EntityDefinitionDraftServiceTest.java | 113 +
.../EntityDefinitionNormalizationServiceTest.java | 14 +
...DefinitionRelationNormalizationServiceTest.java | 23 +
.../EntityDefinitionTypeResolverServiceTest.java | 12 +
...tityDetailChildEvidenceSourceOwnershipTest.java | 5 +
...tityDetailFacadeHandoffSourceOwnershipTest.java | 9 +-
...ityDetailObservabilityReadModelServiceTest.java | 93 +-
.../entity/EntityDetailReadModelServiceTest.java | 33 +
.../entity/EntityIdentityQueryServiceTest.java | 11 +
.../EntityIdentityWriteModelServiceTest.java | 81 +-
.../entity/EntityIntegrationHintServiceTest.java | 81 +-
.../EntityListCatalogQuerySourceOwnershipTest.java | 15 +
.../entity/EntityListReadModelServiceTest.java | 60 +-
...tityListRuntimeEvidenceSourceOwnershipTest.java | 3 +-
.../entity/EntityMonitorBindQueryServiceTest.java | 35 +
.../entity/EntityMonitorBindServiceTest.java | 49 +
.../entity/EntityMutationWorkflowServiceTest.java | 136 +
.../EntityNoiseControlRuleQueryServiceTest.java | 8 +-
...tyNoiseControlRuleQuerySourceOwnershipTest.java | 6 +
.../entity/EntityRelationQueryServiceTest.java | 39 +
.../service/entity/EntityRelationServiceTest.java | 15 +
.../entity/EntityStatusRefreshServiceTest.java | 46 +
.../EntityStatusRefreshSourceOwnershipTest.java | 3 +-
.../entity/EntitySummaryReadModelServiceTest.java | 59 +
.../entity/EntityWorkspaceAccessServiceTest.java | 19 +
.../EntityWorkspaceQuerySourceOwnershipTest.java | 4 +
.../LocalTopologyDemoRelationSeederTest.java | 22 +
.../impl/OtlpIngestionWorkspaceServiceImpl.java | 6 +-
.../impl/EntityObservabilityGatewayImpl.java | 3 +-
.../service/impl/TelemetryIntakeServiceImpl.java | 71 +-
.../traces/controller/TraceQueryController.java | 8 -
.../service/impl/EntityTraceQueryServiceImpl.java | 31 +-
.../OtlpIngestionWorkspaceServiceImplTest.java | 85 +
.../impl/EntityObservabilityGatewayImplTest.java | 2 +
.../impl/TelemetryIntakeServiceImplTest.java | 103 +
.../controller/TraceQueryControllerTest.java | 12 +-
.../impl/EntityTraceQueryServiceImplTest.java | 43 +
web-next/app/actions/actions-page.tsx | 22 +-
web-next/app/actions/page.test.tsx | 20 +
web-next/app/alert-family.chrome.test.ts | 27 +-
web-next/app/alert/alert-center-page.tsx | 18 +-
web-next/app/alert/group/alert-group-page.tsx | 207 +-
web-next/app/alert/group/page.test.tsx | 242 +-
web-next/app/alert/inhibit/alert-inhibit-page.tsx | 260 +-
web-next/app/alert/inhibit/page.test.tsx | 280 +-
.../[source]/alert-integration-source-redirect.tsx | 17 +
.../app/alert/integration/[source]/page.test.tsx | 31 +-
web-next/app/alert/integration/[source]/page.tsx | 7 +-
web-next/app/alert/notice/alert-notice-page.tsx | 1405 +++++++--
web-next/app/alert/notice/page.test.tsx | 488 +++-
web-next/app/alert/page.test.tsx | 29 +
web-next/app/alert/setting/alert-setting-page.tsx | 282 +-
web-next/app/alert/setting/page.test.tsx | 399 ++-
web-next/app/alert/silence/alert-silence-page.tsx | 270 +-
web-next/app/alert/silence/page.test.tsx | 276 +-
web-next/app/api/account/auth/form/route.ts | 41 +-
web-next/app/api/account/auth/refresh/route.ts | 6 +-
web-next/app/api/account/session/route.ts | 4 +-
.../app/compatibility-entrypoints.chrome.test.ts | 4 +-
.../app/dashboard/dashboard-draft-workspace.tsx | 225 +-
web-next/app/dashboard/page.test.ts | 39 +-
.../definition/entity-definition-page.tsx | 10 +-
.../entities/[entityId]/definition/page.test.tsx | 27 +-
.../app/entities/[entityId]/definition/page.tsx | 13 +-
.../entities/[entityId]/edit/entity-edit-page.tsx | 78 +-
.../app/entities/[entityId]/edit/page.test.tsx | 87 +-
web-next/app/entities/[entityId]/edit/page.tsx | 13 +-
.../app/entities/[entityId]/entity-detail-page.tsx | 51 +-
web-next/app/entities/[entityId]/page.test.tsx | 180 +-
web-next/app/entities/[entityId]/page.tsx | 11 +-
.../entities/discovery/entity-discovery-page.tsx | 13 +
web-next/app/entities/discovery/page.test.tsx | 17 +-
web-next/app/entities/entity-list-page.tsx | 201 +-
.../app/entities/import/entity-import-page.tsx | 25 +-
web-next/app/entities/import/page.test.tsx | 60 +-
web-next/app/entities/import/page.tsx | 19 +-
web-next/app/entities/legacy/page.test.ts | 31 +
web-next/app/entities/legacy/page.tsx | 12 +
web-next/app/entities/new/entity-new-page.tsx | 48 +-
web-next/app/entities/new/page.test.tsx | 61 +-
web-next/app/entities/page.test.tsx | 539 +++-
web-next/app/entity-editor-family.chrome.test.ts | 4 +-
web-next/app/explorer/explorer-page.tsx | 82 +-
web-next/app/explorer/page.test.tsx | 52 +-
web-next/app/globals.css | 4 +-
web-next/app/incidents/incidents-page.tsx | 10 +
web-next/app/incidents/page.test.tsx | 17 +-
.../ingestion/otlp/metrics/otlp-metrics-page.tsx | 55 +-
web-next/app/ingestion/otlp/metrics/page.test.tsx | 44 +-
web-next/app/ingestion/otlp/otlp-page.tsx | 123 +-
web-next/app/ingestion/otlp/page.test.tsx | 36 +-
web-next/app/loading.test.tsx | 9 +-
web-next/app/loading.tsx | 24 +-
web-next/app/log/manage/log-manage-page.tsx | 279 +-
web-next/app/log/manage/page.test.tsx | 216 +-
web-next/app/log/stream/page.test.tsx | 78 +-
web-next/app/log/stream/page.tsx | 12 +-
web-next/app/milestone5-final-sweep.chrome.test.ts | 15 +-
.../milestone5-shared-route-owners.chrome.test.ts | 12 +-
.../[monitorId]/edit/monitor-edit-page.tsx | 16 +-
.../app/monitors/[monitorId]/edit/page.test.tsx | 12 +
.../monitors/[monitorId]/monitor-detail-page.tsx | 90 +-
web-next/app/monitors/[monitorId]/page.test.tsx | 38 +
web-next/app/monitors/legacy/page.test.ts | 31 +
web-next/app/monitors/legacy/page.tsx | 12 +
web-next/app/monitors/monitor-manage-page.tsx | 519 +++-
web-next/app/monitors/new/page.test.tsx | 5 +-
web-next/app/monitors/page.test.tsx | 200 +-
web-next/app/operator-family.chrome.test.ts | 3 +-
web-next/app/overview/overview-page.tsx | 39 +-
web-next/app/overview/page.test.tsx | 3 +
web-next/app/passport/lock/page.test.tsx | 5 +-
web-next/app/passport/login/page.tsx | 5 +-
web-next/app/setting/collector/page.test.tsx | 242 +-
.../setting/collector/setting-collector-page.tsx | 78 +-
web-next/app/setting/define/page.test.tsx | 270 +-
.../app/setting/define/setting-define-page.tsx | 76 +-
web-next/app/setting/labels/page.test.tsx | 284 +-
.../app/setting/labels/setting-labels-page.tsx | 122 +-
web-next/app/setting/page.test.ts | 6 +-
web-next/app/setting/plugins/page.test.tsx | 212 +-
.../app/setting/plugins/setting-plugins-page.tsx | 76 +-
web-next/app/setting/settings/config/page.test.tsx | 263 +-
.../settings/config/setting-config-page.tsx | 178 +-
.../app/setting/settings/mcp-server/page.test.ts | 4 +-
web-next/app/setting/settings/mcp-server/page.tsx | 9 +-
.../setting/settings/object-store/page.test.tsx | 249 +-
.../object-store/setting-object-store-page.tsx | 221 +-
web-next/app/setting/settings/page.test.ts | 6 +-
web-next/app/setting/settings/server/page.test.tsx | 534 +++-
.../settings/server/setting-server-page.tsx | 451 ++-
web-next/app/setting/settings/token/page.test.tsx | 384 ++-
.../setting/settings/token/setting-token-page.tsx | 380 ++-
web-next/app/setting/status/page.test.tsx | 196 +-
web-next/app/setting/status/page.tsx | 10 +-
.../app/setting/status/setting-status-page.tsx | 56 +-
web-next/app/settings-family.chrome.test.ts | 13 +-
web-next/app/three-signal-family.chrome.test.ts | 10 +-
web-next/app/topology/page.test.tsx | 159 +-
web-next/app/topology/topology-page.tsx | 274 +-
.../app/topology/topology-route-client.test.tsx | 120 +
web-next/app/trace/manage/page.test.tsx | 96 +-
web-next/app/trace/manage/route-state.test.ts | 17 +
web-next/app/trace/manage/route-state.ts | 3 +-
.../app/trace/manage/trace-manage-client.test.tsx | 177 ++
web-next/app/trace/manage/trace-manage-page.tsx | 249 +-
web-next/app/ui-lab/page.tsx | 2 +-
.../log-manage/log-stream-detail-dialog.test.tsx | 1 +
.../log-manage/log-stream-detail-dialog.tsx | 1 +
.../monitor-detail/monitor-detail-console.test.tsx | 19 +-
.../monitor-detail/monitor-detail-console.tsx | 60 +-
.../monitor-detail-sections.test.tsx | 152 +
.../monitor-detail/monitor-detail-sections.tsx | 78 +-
.../monitor-history-chart-grid.test.tsx | 8 +-
.../monitor-detail/monitor-history-chart-grid.tsx | 6 +-
.../monitor-detail/monitor-history-panel.test.tsx | 13 +-
.../observability/observability.chrome.test.ts | 7 +-
.../observability/time-range-control.test.tsx | 10 +-
.../observability/time-range-control.tsx | 29 +-
web-next/components/observability/toolbar.test.tsx | 6 +-
.../components/overview/overview-console.test.tsx | 19 +
web-next/components/overview/overview-console.tsx | 6 +-
.../pages/alert-authoring-primitives.test.tsx | 43 +
.../pages/alert-authoring-primitives.tsx | 76 +-
.../pages/alert-authoring.chrome.test.ts | 4 +-
.../components/pages/alert-center-surface.test.tsx | 128 +-
web-next/components/pages/alert-center-surface.tsx | 141 +-
.../pages/alert-group-authoring-fields.test.tsx | 23 +
.../pages/alert-group-authoring-fields.tsx | 75 +-
.../components/pages/alert-group-surface.test.tsx | 89 +
web-next/components/pages/alert-group-surface.tsx | 145 +-
.../pages/alert-inhibit-authoring-fields.test.tsx | 25 +
.../pages/alert-inhibit-authoring-fields.tsx | 92 +-
.../pages/alert-inhibit-surface.test.tsx | 115 +
.../components/pages/alert-inhibit-surface.tsx | 187 +-
.../pages/alert-integration-markdown.test.tsx | 32 +-
.../components/pages/alert-integration-mermaid.tsx | 160 +-
.../pages/alert-notice-receiver-fields.test.tsx | 122 +-
.../pages/alert-notice-receiver-fields.tsx | 228 +-
.../pages/alert-notice-rule-fields.test.tsx | 112 +-
.../components/pages/alert-notice-rule-fields.tsx | 237 +-
.../pages/alert-notice-template-fields.test.tsx | 67 +-
.../pages/alert-notice-template-fields.tsx | 146 +-
.../components/pages/alert-notice.chrome.test.ts | 5 +-
.../pages/alert-rule-quick-dialog.test.tsx | 37 +
.../components/pages/alert-rule-quick-dialog.tsx | 16 +-
.../pages/alert-setting-create-dialog.test.tsx | 885 +++++-
.../pages/alert-setting-create-dialog.tsx | 1030 ++++++-
.../pages/alert-setting-surface.test.tsx | 387 +++
.../components/pages/alert-setting-surface.tsx | 521 +++-
.../pages/alert-silence-authoring-fields.test.tsx | 49 +
.../pages/alert-silence-authoring-fields.tsx | 138 +-
.../pages/alert-silence-surface.test.tsx | 119 +
.../components/pages/alert-silence-surface.tsx | 189 +-
.../pages/bulletin-center-surface.test.tsx | 14 +-
.../components/pages/bulletin-center-surface.tsx | 4 +-
.../pages/bulletin-manage-dialog.test.tsx | 9 +-
.../components/pages/bulletin-manage-dialog.tsx | 4 +
.../pages/collector-manage-surface.test.tsx | 174 +-
.../components/pages/collector-manage-surface.tsx | 389 ++-
.../entity-definition-workspace-surface.test.tsx | 858 +++++-
.../pages/entity-definition-workspace-surface.tsx | 1118 +++++++-
.../pages/entity-detail-surface.test.tsx | 953 ++++++-
.../components/pages/entity-detail-surface.tsx | 1262 ++++++--
.../pages/entity-discovery-surface.test.tsx | 198 +-
.../components/pages/entity-discovery-surface.tsx | 682 ++++-
.../pages/entity-editor-surface.test.tsx | 1655 ++++++++++-
.../components/pages/entity-editor-surface.tsx | 2763 ++++++++++++++++--
.../pages/entity-import-surface.test.tsx | 14 +-
.../components/pages/entity-import-surface.tsx | 23 +-
.../components/pages/entity-list-surface.test.tsx | 928 +++++-
web-next/components/pages/entity-list-surface.tsx | 873 +++++-
.../pages/exception-center-surface.test.tsx | 2 +
.../components/pages/exception-center-surface.tsx | 1 +
.../components/pages/label-manage-surface.test.tsx | 201 +-
web-next/components/pages/label-manage-surface.tsx | 443 ++-
.../pages/login-form-submit-flow.test.tsx | 58 +-
web-next/components/pages/login-form.test.tsx | 45 +-
web-next/components/pages/login-form.tsx | 53 +-
.../pages/monitor-editor-surface.test.tsx | 594 +++-
.../components/pages/monitor-editor-surface.tsx | 573 +++-
.../components/pages/ops-surface-page.test.tsx | 138 -
web-next/components/pages/ops-surface-page.tsx | 116 -
web-next/components/pages/passport-shell.test.tsx | 22 +-
web-next/components/pages/passport-shell.tsx | 17 +-
.../pages/plugin-manage-surface.test.tsx | 156 +-
.../components/pages/plugin-manage-surface.tsx | 478 +++-
.../components/pages/public-status-shell.test.tsx | 8 +
web-next/components/pages/public-status-shell.tsx | 52 +-
.../pages/setting-define-surface.test.tsx | 264 +-
.../components/pages/setting-define-surface.tsx | 119 +-
.../pages/settings-surface-page.test.tsx | 23 +-
.../components/pages/settings-surface-page.tsx | 83 +-
.../pages/status-setting-surface.test.tsx | 709 ++++-
.../components/pages/status-setting-surface.tsx | 1150 +++++++-
.../settings/settings-console-shell.test.tsx | 31 +-
.../components/settings/settings-console-shell.tsx | 31 +-
.../settings/settings-dialog-form.test.tsx | 93 +-
.../components/settings/settings-dialog-form.tsx | 156 +-
.../components/settings/settings-form.test.tsx | 99 +-
web-next/components/settings/settings-form.tsx | 181 +-
.../settings/settings-summary-list.test.tsx | 18 +
.../components/settings/settings-summary-list.tsx | 40 +-
web-next/components/shared-owner.chrome.test.ts | 4 +-
.../components/shell/app-frame.chrome.test.tsx | 70 +-
web-next/components/shell/app-frame.tsx | 101 +-
web-next/components/shell/app-sidebar.test.tsx | 9 +-
web-next/components/shell/app-sidebar.tsx | 15 +-
web-next/components/ui/hz-code-editor.test.tsx | 5 +
web-next/components/ui/hz-code-editor.tsx | 19 +-
web-next/components/ui/hz-confirm-dialog.test.tsx | 24 +
web-next/components/ui/hz-confirm-dialog.tsx | 4 +-
web-next/components/ui/search-row.test.tsx | 64 +-
web-next/components/ui/search-row.tsx | 34 +-
.../components/workbench/client-workbench.test.tsx | 232 +-
web-next/components/workbench/client-workbench.tsx | 53 +-
.../components/workbench/overlay-dialog.test.tsx | 32 +
web-next/components/workbench/overlay-dialog.tsx | 30 +-
web-next/components/workbench/toolbar.test.tsx | 3 +-
web-next/lib/actions-surface/model.test.ts | 12 +-
web-next/lib/actions-surface/model.ts | 11 +-
web-next/lib/actions-surface/view-model.test.ts | 13 +
web-next/lib/alert-group/query-state.test.ts | 15 +
web-next/lib/alert-group/query-state.ts | 9 +-
web-next/lib/alert-group/view-model.test.ts | 24 +-
web-next/lib/alert-group/view-model.ts | 45 +-
web-next/lib/alert-inhibit/query-state.test.ts | 36 +
web-next/lib/alert-inhibit/query-state.ts | 9 +-
web-next/lib/alert-inhibit/view-model.test.ts | 80 +-
web-next/lib/alert-inhibit/view-model.ts | 52 +-
web-next/lib/alert-manage/controller.test.ts | 98 +-
web-next/lib/alert-manage/controller.ts | 23 +-
web-next/lib/alert-manage/query-state.test.ts | 54 +
web-next/lib/alert-manage/query-state.ts | 69 +-
web-next/lib/alert-manage/view-model.test.ts | 100 +
web-next/lib/alert-manage/view-model.ts | 67 +-
web-next/lib/alert-notice/controller.test.ts | 20 +-
web-next/lib/alert-notice/controller.ts | 26 +-
web-next/lib/alert-notice/query-state.test.ts | 15 +
web-next/lib/alert-notice/query-state.ts | 9 +-
web-next/lib/alert-notice/view-model.test.ts | 26 +-
web-next/lib/alert-notice/view-model.ts | 214 +-
web-next/lib/alert-setting/view-model.test.ts | 1 +
web-next/lib/alert-setting/view-model.ts | 16 +
web-next/lib/alert-silence/query-state.test.ts | 36 +
web-next/lib/alert-silence/query-state.ts | 9 +-
web-next/lib/alert-silence/view-model.test.ts | 78 +-
web-next/lib/alert-silence/view-model.ts | 52 +
web-next/lib/api-client.test.ts | 23 +
web-next/lib/api-client.ts | 50 +-
web-next/lib/api-facade.test.ts | 4 +-
web-next/lib/app-frame-state.test.ts | 5 +-
web-next/lib/app-frame-state.ts | 13 +-
web-next/lib/bulletin-center/view-model.test.ts | 6 +-
web-next/lib/bulletin-center/view-model.ts | 15 +-
web-next/lib/client-workbench-state.test.ts | 15 +-
web-next/lib/client-workbench-state.ts | 11 +-
web-next/lib/collector-health-evidence.test.ts | 17 +
web-next/lib/collector-health-evidence.ts | 13 +-
web-next/lib/collector-manage/view-model.test.ts | 12 +-
web-next/lib/entity-definition/controller.test.ts | 26 +-
web-next/lib/entity-definition/controller.ts | 17 +-
web-next/lib/entity-detail/controller.test.ts | 18 +-
web-next/lib/entity-detail/controller.ts | 27 +-
web-next/lib/entity-detail/query-state.test.ts | 22 +-
web-next/lib/entity-detail/query-state.ts | 12 +
web-next/lib/entity-detail/view-model.test.ts | 740 ++++-
web-next/lib/entity-detail/view-model.ts | 481 +++-
web-next/lib/entity-discovery/controller.test.ts | 157 +-
web-next/lib/entity-discovery/controller.ts | 88 +-
web-next/lib/entity-discovery/search-state.test.ts | 45 +-
web-next/lib/entity-discovery/search-state.ts | 104 +
web-next/lib/entity-discovery/view-model.test.ts | 210 +-
web-next/lib/entity-discovery/view-model.ts | 184 +-
web-next/lib/entity-editor/controller.test.ts | 230 +-
web-next/lib/entity-editor/controller.ts | 144 +-
web-next/lib/entity-editor/initial-state.test.ts | 60 +-
web-next/lib/entity-editor/initial-state.ts | 64 +-
web-next/lib/entity-editor/query-state.test.ts | 20 +
web-next/lib/entity-editor/query-state.ts | 14 +-
web-next/lib/entity-editor/view-model.test.ts | 27 +
web-next/lib/entity-editor/view-model.ts | 56 +-
web-next/lib/entity-health-affordance.test.ts | 30 +-
web-next/lib/entity-health-affordance.ts | 15 +-
web-next/lib/entity-import/controller.test.ts | 4 +-
web-next/lib/entity-import/controller.ts | 6 +-
web-next/lib/entity-import/view-model.test.ts | 130 +-
web-next/lib/entity-import/view-model.ts | 43 +-
web-next/lib/entity-manage/controller.test.ts | 44 +
web-next/lib/entity-manage/controller.ts | 40 +-
web-next/lib/entity-manage/query-state.test.ts | 101 +-
web-next/lib/entity-manage/query-state.ts | 80 +-
web-next/lib/entity-manage/view-model.test.ts | 227 +-
web-next/lib/entity-manage/view-model.ts | 93 +-
web-next/lib/hz-ops-visual.test.ts | 12 +-
web-next/lib/hz-ops-visual.ts | 8 +-
web-next/lib/i18n-runtime-messages.ts | 3006 +++++++++++++++++++-
web-next/lib/incidents-surface/controller.test.ts | 51 +
web-next/lib/incidents-surface/controller.ts | 73 +-
web-next/lib/incidents-surface/model.ts | 3 +-
web-next/lib/incidents-surface/view-model.test.ts | 5 +
web-next/lib/label-manage/query-state.test.ts | 8 +-
web-next/lib/label-manage/query-state.ts | 9 +-
web-next/lib/log-manage/view-model.test.ts | 52 +
web-next/lib/log-manage/view-model.ts | 11 +-
web-next/lib/monitor-api-facade.ts | 9 +-
web-next/lib/monitor-detail/history-chart.ts | 3 +-
web-next/lib/monitor-editor/controller.test.ts | 70 +-
web-next/lib/monitor-editor/controller.ts | 151 +-
web-next/lib/monitor-manage/controller.test.ts | 64 +
web-next/lib/monitor-manage/controller.ts | 31 +-
web-next/lib/monitor-manage/query-state.test.ts | 125 +
web-next/lib/monitor-manage/query-state.ts | 88 +-
web-next/lib/monitor-manage/view-model.test.ts | 2 +-
web-next/lib/monitor-manage/view-model.ts | 2 +-
web-next/lib/nav.test.ts | 6 +-
web-next/lib/object-store/view-model.test.ts | 65 +
web-next/lib/object-store/view-model.ts | 40 +-
web-next/lib/otlp-metrics/view-model.test.ts | 7 +
web-next/lib/otlp-metrics/view-model.ts | 15 +-
web-next/lib/parity/route-manifest.json | 94 +-
web-next/lib/parity/route-manifest.test.ts | 94 +-
web-next/lib/passport-login/view-model.test.ts | 33 +-
web-next/lib/passport-login/view-model.ts | 45 +
web-next/lib/session-bff.test.ts | 67 +-
web-next/lib/session-bff.ts | 112 +-
web-next/lib/setting-config/view-model.test.ts | 79 +-
web-next/lib/setting-config/view-model.ts | 64 +-
web-next/lib/setting-define/controller.test.ts | 7 +
web-next/lib/setting-define/controller.ts | 5 +
web-next/lib/setting-server/view-model.test.ts | 100 +
web-next/lib/setting-server/view-model.ts | 143 +
.../lib/setting-settings-layout/navigation.test.ts | 36 +
web-next/lib/setting-settings-layout/navigation.ts | 33 +-
web-next/lib/setting-status/query-state.ts | 12 +
web-next/lib/setting-status/view-model.test.ts | 15 +-
web-next/lib/setting-status/view-model.ts | 6 +-
web-next/lib/setting-token/controller.test.ts | 22 +-
web-next/lib/setting-token/controller.ts | 30 +-
web-next/lib/shell/sidebar.test.ts | 46 +
web-next/lib/signal-dashboards.ts | 7 +-
web-next/lib/signal-route-context.test.ts | 41 +
web-next/lib/signal-route-context.ts | 27 +-
web-next/lib/status-center/controller.test.ts | 44 +
web-next/lib/status-center/controller.ts | 20 +-
web-next/lib/status-center/view-model.test.ts | 22 +
web-next/lib/status-center/view-model.ts | 4 +-
web-next/lib/time-context.test.ts | 18 +
web-next/lib/time-context.ts | 9 +-
web-next/lib/topology-surface/controller.test.ts | 24 +-
web-next/lib/topology-surface/controller.ts | 12 +-
web-next/lib/topology-surface/query-state.test.ts | 31 +
web-next/lib/topology-surface/query-state.ts | 4 +-
web-next/lib/topology-surface/view-model.test.ts | 62 +
web-next/lib/topology-surface/view-model.ts | 58 +-
web-next/lib/trace-manage/query-state.test.ts | 21 +-
web-next/lib/trace-manage/query-state.ts | 22 +-
web-next/lib/trace-manage/view-model.test.ts | 7 +
web-next/lib/trace-manage/view-model.ts | 6 +-
web-next/lib/types.ts | 39 +
web-next/lib/workbench-load-cache.test.ts | 13 +-
web-next/lib/workbench-load-cache.ts | 4 +
web-next/lucide-react-icon-node.d.ts | 3 +
web-next/package.json | 5 +-
web-next/packages/hertzbeat-ui/src/index.test.tsx | 275 +-
web-next/packages/hertzbeat-ui/src/index.tsx | 413 ++-
.../packages/hertzbeat-ui/src/topology-g6.test.tsx | 171 +-
web-next/packages/hertzbeat-ui/src/topology-g6.tsx | 72 +-
.../src/topology-metric-table.interaction.test.tsx | 63 +
web-next/packages/hertzbeat-ui/src/topology.tsx | 6 +-
.../scripts/frontend-convergence-contract.test.ts | 19 +-
web-next/scripts/parity/harness-plan.test.ts | 10 +-
web-next/scripts/parity/owner-contracts.test.ts | 15 +-
.../scripts/product-design-validation-matrix.mjs | 451 +++
.../product-design-validation-matrix.test.ts | 231 ++
.../product-design-visual-governance.test.ts | 200 ++
.../scripts/release-route-browser-smoke.spec.ts | 5 +
web-next/scripts/release-shell-smoke.test.ts | 3 +-
web-next/scripts/route-matrix.mjs | 6 +
web-next/scripts/standalone-server.mjs | 141 +
web-next/scripts/standalone-server.test.ts | 54 +
web-next/test/i18n-test-helper.ts | 712 ++++-
466 files changed, 49264 insertions(+), 5422 deletions(-)
diff --git a/docs/product-design-user-validation-loop.md b/docs/product-design-user-validation-loop.md
new file mode 100644
index 0000000000..d42c949247
--- /dev/null
+++ b/docs/product-design-user-validation-loop.md
@@ -0,0 +1,214 @@
+# Product Design User Validation Loop
+
+This runbook makes Product Design part of the HertzBeat 2.0 user-layer
+validation loop. It is not a screenshot-only audit pass and it is not a visual
+polish layer. Use it to close product experience loops around real operator
+workflows, honest state feedback, data lifecycle clarity, and repeatable
+actions.
+
+## Product Context
+
+Start every Product Design workflow from saved context:
+
+- Product Design state: `$CODEX_HOME/state/plugins/product-design/user-context.md`
+- Local screenshot/reference assets:
+ `$CODEX_HOME/state/plugins/product-design/assets/`
+- Repo guidance: `AGENTS.md` and the latest active slice in `progress.md`
+- Frontend implementation: `web-next/app`, `web-next/components`,
+ `web-next/lib`, and `web-next/packages/hertzbeat-ui`
+- Theme and UI constraints: `web-next/app/globals.css`, shared
+ `@hertzbeat/ui` primitives, `lucide-react` icons, and runtime i18n catalogs
+
+Saved context should include the HertzBeat product position, operator audience,
+competitive references, design preferences, code paths, screenshots, design
+system constraints, and mainstream observability console expectations. Future
+audits, ideation, prototypes, and implementation work must prefer that context
+unless the user explicitly asks for a different direction.
+
+## Required Workflow Selection
+
+Use the smallest workflow that matches the problem.
+
+Small issue:
+
+```text
+audit -> small fix -> focused test -> Browser verification -> progress.md
+```
+
+Use this for a concrete user-visible problem in one flow, such as confusing
+error copy, an unclear button, an empty state that hides the data lifecycle, a
+broken cancel path, or a refresh/status mismatch.
+
+Systemic issue:
+
+```text
+user-context/get-context -> research -> audit -> ideate -> prototype -> design-qa -> small implementation slice -> progress.md
+```
+
+Use this when the issue exposes a broader product pattern, such as monitor
+authoring confusion, entity lifecycle ambiguity, alert rule authoring friction,
+or settings forms that repeatedly fail to explain required state and side
+effects.
+
+Do not skip `audit` or `research` before broad UI changes. Do not use Product
+Design to decorate an existing screen while leaving the real workflow,
+state feedback, error explanation, or data lifecycle unresolved.
+
+## Design Brief Gate
+
+Before any design judgment, experience redesign, prototype exploration, or UI
+implementation, confirm a brief through Product Design `get-context`.
+
+The brief must identify:
+
+- Target user and operating context.
+- Workflow problem being solved.
+- Existing route, screenshot, design system, or component reference.
+- Required interaction fidelity.
+- Backend/data lifecycle states that must stay honest.
+- Acceptance evidence expected before implementation is considered done.
+
+If the brief is missing or under-specified, stop and ask for only the missing
+context. Do not edit UI from an unconfirmed brief.
+
+## Research Gate
+
+For systemic problems, perform product and user research before designing a
+direction. Cover:
+
+- Operator mental model and repeated-use pressure.
+- Comparable observability platform patterns, especially Grafana, Datadog,
+ SigNoz, and OpenTelemetry ecosystem consoles where relevant.
+- State, error, loading, empty, and recovery language.
+- Help affordance density and progressive disclosure.
+- Safe authoring, save/cancel, detect/test, refresh, and status convergence.
+
+Prioritize monitor, entity, alert, and settings workflows because these are
+high-frequency operator surfaces.
+
+## Real Flow Audit
+
+Audits must use current product evidence:
+
+- Production frontend build.
+- Real HertzBeat backend, using H2/Greptime/warehouse data honestly.
+- In-app Browser screenshots for the current route state.
+- Step notes tied to every screenshot and finding.
+- Theme preflight before accepting screenshots: record `html[data-theme]`,
+ `body[data-theme]`, `--background`, `--card`, and `--ops-background`.
+ HertzBeat's default audit baseline is `dark-ops` (`--background: 220 11%
+ 5%`, `--ops-background: #0b0c0e`). If an existing Browser origin has
+ persisted `light-ops`, do not use those screenshots as dark-baseline
+ evidence; use an isolated localhost port/origin or explicitly document that
+ the slice is auditing light theme.
+
+Audit at least the relevant parts of:
+
+- Login and guarded deep-link return.
+- Navigation and retained query/context parameters.
+- Create, edit, save, cancel, detect/test, refresh, and route revisit.
+- Status convergence without browser reload when the UI promises refresh.
+- Empty, loading, unavailable, failed, and recovered states.
+- Help question marks, button labels, destructive confirmations, and action
+ meaning.
+- Card hierarchy, table density, form grouping, text overflow, responsive
+ behavior, keyboard/focus semantics, and visible accessibility risks.
+
+Findings must cite screenshot names and step numbers. Do not evaluate from
+memory.
+
+## Ideation Gate
+
+When audit and research show a systemic UX problem, do not immediately rewrite
+product code. Generate two or three different directions first. Useful default
+directions for HertzBeat include:
+
+- Dense operations console: fastest scanning and repeat actions.
+- Diagnosis-first evidence chain: status, raw backend reason, metric evidence,
+ history availability, and recovery steps.
+- Guided authoring: lower misoperation risk for complex create/edit/test flows.
+
+Choose a direction before prototyping or implementing.
+
+## Prototype Gate
+
+For high-impact flows, build a local interactive prototype before production
+implementation. Prototype only the smallest slice needed to validate interaction
+and information architecture. Good candidates include:
+
+- Monitor create, detect, detail refresh, and history chart availability.
+- Alert rule authoring, preview/test, save/cancel, and template selection.
+- Entity create/edit/detail and definition import/edit handoff.
+- Settings forms with required fields, disabled save, cancel, and no-write
+ guarantees.
+
+The prototype is evidence for product decisions. It does not replace
+production code, tests, or Browser verification.
+
+## Implementation Gate
+
+Use Product Design `image-to-code` or production UI implementation only after
+there is a confirmed brief plus a concrete visual or interaction target:
+screenshot, selected visual direction, prototype, Figma frame, or mockup.
+
+Implementation must preserve:
+
+- Existing HertzBeat route/controller/view-model patterns.
+- Shared `@hertzbeat/ui` primitives and source-backed icons.
+- Dense operational layouts and stable dimensions.
+- Clear data lifecycle and backend-state honesty.
+- Runtime i18n rules and no hardcoded localized UI copy outside approved
+ locale resources.
+
+Do not introduce marketing-page composition, decorative chrome, fake zero
+states, fake healthy states, broad refactors, or untested UI rewrites.
+
+## Design QA Gate
+
+After implementation, run design QA against the target evidence:
+
+- Original audit screenshot(s).
+- Selected direction or prototype.
+- Actual production page rendered through the in-app Browser.
+- Focused automated tests and the smallest relevant Browser proof.
+
+Check layout, hierarchy, density, text overflow, responsive behavior, control
+states, focus/keyboard semantics, empty/error/loading states, and visible
+accessibility risks. P0, P1, and P2 design issues must be fixed before the
+slice is treated as passing.
+
+## Artifact And Progress Policy
+
+Persist Product Design evidence locally. Use clear names that can be understood
+without opening the file.
+
+Recommended local artifact layout:
+
+```text
+.tmp/product-design/
+ <slice-id>/
+ screenshots/
+ audit.md
+ research.md
+ directions.md
+ prototype-notes.md
+ design-qa.md
+```
+
+Do not commit `.tmp/`, screenshots, reports, runtime logs, local proof data, or
+generated artifacts unless maintainers explicitly ask. The active slice in
+`progress.md` must record:
+
+- Saved Product Design context used.
+- Research sources or research scope.
+- Audit route, backend, screenshots, and steps.
+- Theme preflight evidence and whether the screenshot baseline is `dark-ops`
+ or an intentional alternate theme.
+- Findings by severity and user impact.
+- Selected direction and prototype target, when applicable.
+- Implementation files changed.
+- Focused tests and Browser verification.
+- Remaining risks and next slice.
+
+The release gate is not complete until Product Design evidence supports the
+actual operator workflow, not just a single screenshot or isolated component.
diff --git a/hertzbeat-alerter/src/main/java/org/apache/hertzbeat/alert/service/impl/AlertDefineServiceImpl.java b/hertzbeat-alerter/src/main/java/org/apache/hertzbeat/alert/service/impl/AlertDefineServiceImpl.java
index a01983b1d6..6c056841e7 100644
--- a/hertzbeat-alerter/src/main/java/org/apache/hertzbeat/alert/service/impl/AlertDefineServiceImpl.java
+++ b/hertzbeat-alerter/src/main/java/org/apache/hertzbeat/alert/service/impl/AlertDefineServiceImpl.java
@@ -87,7 +87,7 @@ public class AlertDefineServiceImpl implements AlertDefineService {
@Resource
private LabelDao labelDao;
- @Resource
+ @Autowired(required = false)
private List<HistoryDataReader> historyDataReaders = Collections.emptyList();
private final DataSourceService dataSourceService;
diff --git a/hertzbeat-common-spring/src/main/java/org/apache/hertzbeat/common/observability/dto/entity/EntityOpsSummaryInfo.java b/hertzbeat-common-spring/src/main/java/org/apache/hertzbeat/common/observability/dto/entity/EntityOpsSummaryInfo.java
index 2b4b2b314d..ce38625364 100644
--- a/hertzbeat-common-spring/src/main/java/org/apache/hertzbeat/common/observability/dto/entity/EntityOpsSummaryInfo.java
+++ b/hertzbeat-common-spring/src/main/java/org/apache/hertzbeat/common/observability/dto/entity/EntityOpsSummaryInfo.java
@@ -17,7 +17,6 @@
package org.apache.hertzbeat.common.observability.dto.entity;
-import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
@@ -25,7 +24,6 @@ import lombok.NoArgsConstructor;
* Operational readiness summary for entity workspace.
*/
@Data
-@AllArgsConstructor
@NoArgsConstructor
public class EntityOpsSummaryInfo {
@@ -40,4 +38,32 @@ public class EntityOpsSummaryInfo {
private boolean statusReady;
private int readinessScore;
+
+ private long relationCount;
+
+ public EntityOpsSummaryInfo(boolean ownerReady,
+ boolean runbookReady,
+ boolean relationReady,
+ boolean telemetryReady,
+ boolean statusReady,
+ int readinessScore,
+ long relationCount) {
+ this.ownerReady = ownerReady;
+ this.runbookReady = runbookReady;
+ this.relationReady = relationReady;
+ this.telemetryReady = telemetryReady;
+ this.statusReady = statusReady;
+ this.readinessScore = readinessScore;
+ this.relationCount = relationCount;
+ }
+
+ public EntityOpsSummaryInfo(boolean ownerReady,
+ boolean runbookReady,
+ boolean relationReady,
+ boolean telemetryReady,
+ boolean statusReady,
+ int readinessScore) {
+ this(ownerReady, runbookReady, relationReady, telemetryReady, statusReady,
+ readinessScore, relationReady ? 1L : 0L);
+ }
}
diff --git a/hertzbeat-manager/src/main/java/org/apache/hertzbeat/manager/controller/EntityController.java b/hertzbeat-manager/src/main/java/org/apache/hertzbeat/manager/controller/EntityController.java
index 01b10a04fb..938bf76b34 100644
--- a/hertzbeat-manager/src/main/java/org/apache/hertzbeat/manager/controller/EntityController.java
+++ b/hertzbeat-manager/src/main/java/org/apache/hertzbeat/manager/controller/EntityController.java
@@ -25,6 +25,7 @@ import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import java.util.List;
+import java.util.Map;
import org.apache.hertzbeat.manager.pojo.dto.EntityDefinitionRequest;
import org.apache.hertzbeat.manager.pojo.dto.EntityDefinitionActivityInfo;
import org.apache.hertzbeat.manager.pojo.dto.EntityCatalogSuggestionsInfo;
@@ -90,6 +91,15 @@ public class EntityController {
return ResponseEntity.ok(Message.success(entityDto));
}
+ @PostMapping("/{id:\\d+}/definition/parse")
+ @Operation(summary = "Parse entity definition for an entity", description = "Parse entity definition content while allowing the current entity identity")
+ public ResponseEntity<Message<EntityDto>> parseEntityDefinitionForEntity(
+ @Parameter(description = "Entity ID", example = "87584674384") @PathVariable("id") long id,
+ @Valid @RequestBody EntityDefinitionRequest definitionRequest) {
+ EntityDto entityDto = observeEntityService.parseEntityDefinition(definitionRequest, id);
+ return ResponseEntity.ok(Message.success(entityDto));
+ }
+
@PostMapping("/definition/bundle/parse")
@Operation(summary = "Parse entity definition bundle", description = "Parse entity definition bundle content to entity dto list")
public ResponseEntity<Message<List<EntityDto>>> parseEntityDefinitionBundle(@Valid @RequestBody EntityDefinitionRequest definitionRequest) {
@@ -332,4 +342,12 @@ public class EntityController {
List<EntityMonitorBindingCandidate> candidates = observeEntityService.getMonitorBindingCandidates(monitorId);
return ResponseEntity.ok(Message.success(candidates));
}
+
+ @GetMapping("/monitor/candidates")
+ @Operation(summary = "Recommend entities for monitors", description = "Recommend entities for multiple monitors")
+ public ResponseEntity<Message<Map<Long, List<EntityMonitorBindingCandidate>>>> getMonitorBindingCandidates(
+ @Parameter(description = "Monitor IDs", example = "87584674384") @RequestParam List<Long> ids) {
+ Map<Long, List<EntityMonitorBindingCandidate>> candidates = observeEntityService.getMonitorBindingCandidates(ids);
+ return ResponseEntity.ok(Message.success(candidates));
+ }
}
diff --git a/hertzbeat-manager/src/main/java/org/apache/hertzbeat/manager/dao/BulletinDao.java b/hertzbeat-manager/src/main/java/org/apache/hertzbeat/manager/dao/BulletinDao.java
index 7ad5486948..c014c4d0bf 100644
--- a/hertzbeat-manager/src/main/java/org/apache/hertzbeat/manager/dao/BulletinDao.java
+++ b/hertzbeat-manager/src/main/java/org/apache/hertzbeat/manager/dao/BulletinDao.java
@@ -42,5 +42,10 @@ public interface BulletinDao extends JpaRepository<Bulletin, Long>, JpaSpecifica
*/
int countByName(String name);
+ /**
+ * Count other Bulletins by name
+ */
+ int countByNameAndIdNot(String name, Long id);
+
}
diff --git a/hertzbeat-manager/src/main/java/org/apache/hertzbeat/manager/dao/EntityIdentityDao.java b/hertzbeat-manager/src/main/java/org/apache/hertzbeat/manager/dao/EntityIdentityDao.java
index 5216948ca4..96f0f228f0 100644
--- a/hertzbeat-manager/src/main/java/org/apache/hertzbeat/manager/dao/EntityIdentityDao.java
+++ b/hertzbeat-manager/src/main/java/org/apache/hertzbeat/manager/dao/EntityIdentityDao.java
@@ -17,6 +17,7 @@
package org.apache.hertzbeat.manager.dao;
+import java.util.Collection;
import java.util.List;
import java.util.Set;
import org.apache.hertzbeat.common.entity.manager.EntityIdentity;
@@ -41,6 +42,9 @@ public interface EntityIdentityDao extends JpaRepository<EntityIdentity, Long>,
@Query("SELECT COUNT(DISTINCT identity.entityId) FROM EntityIdentity identity WHERE identity.identityKey IN :identityKeys")
long countDistinctEntityIdsByIdentityKeyIn(@Param("identityKeys") Set<String> identityKeys);
+ @Query("SELECT identity.entityId, COUNT(identity) FROM EntityIdentity identity WHERE identity.entityId IN :entityIds GROUP BY identity.entityId")
+ List<Object[]> countByEntityIdInGroupByEntityId(@Param("entityIds") Collection<Long> entityIds);
+
@Modifying(clearAutomatically = true, flushAutomatically = true)
@Query("DELETE FROM EntityIdentity identity WHERE identity.entityId = :entityId")
void deleteAllByEntityId(@Param("entityId") Long entityId);
diff --git a/hertzbeat-manager/src/main/java/org/apache/hertzbeat/manager/dao/EntityMonitorBindDao.java b/hertzbeat-manager/src/main/java/org/apache/hertzbeat/manager/dao/EntityMonitorBindDao.java
index cd8f8b8ba9..bd548e8540 100644
--- a/hertzbeat-manager/src/main/java/org/apache/hertzbeat/manager/dao/EntityMonitorBindDao.java
+++ b/hertzbeat-manager/src/main/java/org/apache/hertzbeat/manager/dao/EntityMonitorBindDao.java
@@ -17,6 +17,7 @@
package org.apache.hertzbeat.manager.dao;
+import java.util.Collection;
import java.util.List;
import java.util.Set;
import org.apache.hertzbeat.common.entity.manager.EntityMonitorBind;
@@ -33,6 +34,8 @@ public interface EntityMonitorBindDao extends JpaRepository<EntityMonitorBind, L
List<EntityMonitorBind> findAllByEntityIdOrderByIdAsc(Long entityId);
+ List<EntityMonitorBind> findAllByEntityIdInOrderByEntityIdAscIdAsc(Collection<Long> entityIds);
+
List<EntityMonitorBind> findAllByMonitorId(Long monitorId);
@Modifying(clearAutomatically = true, flushAutomatically = true)
@@ -46,4 +49,7 @@ public interface EntityMonitorBindDao extends JpaRepository<EntityMonitorBind, L
void deleteAllByMonitorIdIn(Set<Long> monitorIds);
long countByEntityId(Long entityId);
+
+ @Query("SELECT bind.entityId, COUNT(bind) FROM EntityMonitorBind bind WHERE bind.entityId IN :entityIds GROUP BY bind.entityId")
+ List<Object[]> countByEntityIdInGroupByEntityId(@Param("entityIds") Collection<Long> entityIds);
}
diff --git a/hertzbeat-manager/src/main/java/org/apache/hertzbeat/manager/dao/EntityRelationDao.java b/hertzbeat-manager/src/main/java/org/apache/hertzbeat/manager/dao/EntityRelationDao.java
index 4edf781aa9..8d5c15a033 100644
--- a/hertzbeat-manager/src/main/java/org/apache/hertzbeat/manager/dao/EntityRelationDao.java
+++ b/hertzbeat-manager/src/main/java/org/apache/hertzbeat/manager/dao/EntityRelationDao.java
@@ -17,8 +17,10 @@
package org.apache.hertzbeat.manager.dao;
+import java.util.Collection;
import java.util.List;
import org.apache.hertzbeat.common.entity.manager.EntityRelation;
+import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
import org.springframework.data.jpa.repository.Modifying;
@@ -32,6 +34,8 @@ public interface EntityRelationDao extends JpaRepository<EntityRelation, Long>,
List<EntityRelation> findBySourceEntityIdOrTargetEntityId(Long sourceEntityId, Long targetEntityId);
+ List<EntityRelation> findBySourceEntityIdOrTargetEntityId(Long sourceEntityId, Long targetEntityId, Pageable pageable);
+
@Modifying(clearAutomatically = true, flushAutomatically = true)
@Query("DELETE FROM EntityRelation relation WHERE relation.sourceEntityId = :sourceEntityId")
void deleteAllBySourceEntityId(@Param("sourceEntityId") Long sourceEntityId);
@@ -42,4 +46,16 @@ public interface EntityRelationDao extends JpaRepository<EntityRelation, Long>,
@Param("targetEntityId") Long targetEntityId);
long countBySourceEntityIdOrTargetEntityId(Long sourceEntityId, Long targetEntityId);
+
+ @Query("SELECT relation.sourceEntityId, COUNT(relation) FROM EntityRelation relation WHERE relation.sourceEntityId IN :entityIds GROUP BY relation.sourceEntityId")
+ List<Object[]> countBySourceEntityIdInGroupBySourceEntityId(@Param("entityIds") Collection<Long> entityIds);
+
+ @Query("""
+ SELECT relation.targetEntityId, COUNT(relation)
+ FROM EntityRelation relation
+ WHERE relation.targetEntityId IN :entityIds
+ AND (relation.sourceEntityId IS NULL OR relation.sourceEntityId <> relation.targetEntityId)
+ GROUP BY relation.targetEntityId
+ """)
+ List<Object[]> countByTargetEntityIdInGroupByTargetEntityId(@Param("entityIds") Collection<Long> entityIds);
}
diff --git a/hertzbeat-manager/src/main/java/org/apache/hertzbeat/manager/dao/ObserveEntityDao.java b/hertzbeat-manager/src/main/java/org/apache/hertzbeat/manager/dao/ObserveEntityDao.java
index 9bd509760b..33c0856c2b 100644
--- a/hertzbeat-manager/src/main/java/org/apache/hertzbeat/manager/dao/ObserveEntityDao.java
+++ b/hertzbeat-manager/src/main/java/org/apache/hertzbeat/manager/dao/ObserveEntityDao.java
@@ -34,11 +34,15 @@ public interface ObserveEntityDao extends JpaRepository<ObserveEntity, Long>, Jp
Optional<ObserveEntity> findFirstByTypeAndName(String type, String name);
+ Optional<ObserveEntity> findFirstByName(String name);
+
Optional<ObserveEntity> findFirstByWorkspaceIdAndTypeAndNamespaceAndName(
String workspaceId, String type, String namespace, String name);
Optional<ObserveEntity> findFirstByWorkspaceIdAndTypeAndName(String workspaceId, String type, String name);
+ Optional<ObserveEntity> findFirstByWorkspaceIdAndName(String workspaceId, String name);
+
List<ObserveEntity> findAllByWorkspaceId(String workspaceId, Sort sort);
List<ObserveEntity> findAllByWorkspaceId(String workspaceId, Pageable pageable);
diff --git a/hertzbeat-manager/src/main/java/org/apache/hertzbeat/manager/service/ObserveEntityService.java b/hertzbeat-manager/src/main/java/org/apache/hertzbeat/manager/service/ObserveEntityService.java
index b5b32c89f1..79ddb88715 100644
--- a/hertzbeat-manager/src/main/java/org/apache/hertzbeat/manager/service/ObserveEntityService.java
+++ b/hertzbeat-manager/src/main/java/org/apache/hertzbeat/manager/service/ObserveEntityService.java
@@ -18,6 +18,7 @@
package org.apache.hertzbeat.manager.service;
import java.util.List;
+import java.util.Map;
import org.apache.hertzbeat.common.entity.alerter.SingleAlert;
import org.apache.hertzbeat.common.observability.dto.entity.MonitorInfo;
import org.apache.hertzbeat.manager.pojo.dto.EntityCatalogSuggestionsInfo;
@@ -102,4 +103,6 @@ public interface ObserveEntityService {
String sort, String order, int pageIndex, int pageSize);
List<EntityMonitorBindingCandidate> getMonitorBindingCandidates(long monitorId);
+
+ Map<Long, List<EntityMonitorBindingCandidate>> getMonitorBindingCandidates(List<Long> monitorIds);
}
diff --git a/hertzbeat-manager/src/main/java/org/apache/hertzbeat/manager/service/entity/EntityCatalogQueryService.java b/hertzbeat-manager/src/main/java/org/apache/hertzbeat/manager/service/entity/EntityCatalogQueryService.java
index b513097539..0e65ce14c2 100644
--- a/hertzbeat-manager/src/main/java/org/apache/hertzbeat/manager/service/entity/EntityCatalogQueryService.java
+++ b/hertzbeat-manager/src/main/java/org/apache/hertzbeat/manager/service/entity/EntityCatalogQueryService.java
@@ -20,8 +20,12 @@ package org.apache.hertzbeat.manager.service.entity;
import com.google.common.primitives.Longs;
import jakarta.persistence.criteria.CriteriaBuilder;
import jakarta.persistence.criteria.Predicate;
+import java.net.URLDecoder;
+import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
+import java.util.LinkedHashSet;
import java.util.List;
+import java.util.Set;
import org.apache.hertzbeat.common.entity.manager.ObserveEntity;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageImpl;
@@ -171,33 +175,71 @@ public class EntityCatalogQueryService {
if (!StringUtils.hasText(search)) {
return orList;
}
- String lower = "%" + search.toLowerCase() + "%";
- orList.add(criteriaBuilder.like(criteriaBuilder.lower(root.get("name")), lower));
- orList.add(criteriaBuilder.like(criteriaBuilder.lower(root.get("displayName")), lower));
- orList.add(criteriaBuilder.like(criteriaBuilder.lower(root.get("subtype")), lower));
- orList.add(criteriaBuilder.like(criteriaBuilder.lower(root.get("namespace")), lower));
- orList.add(criteriaBuilder.like(criteriaBuilder.lower(root.get("environment")), lower));
- orList.add(criteriaBuilder.like(criteriaBuilder.lower(root.get("description")), lower));
- orList.add(criteriaBuilder.like(criteriaBuilder.lower(root.get("owner")), lower));
- orList.add(criteriaBuilder.like(criteriaBuilder.lower(root.get("lifecycle")), lower));
- orList.add(criteriaBuilder.like(criteriaBuilder.lower(root.get("tier")), lower));
- orList.add(criteriaBuilder.like(criteriaBuilder.lower(root.get("system")), lower));
- orList.add(criteriaBuilder.like(criteriaBuilder.lower(root.get("runbook")), lower));
- orList.add(criteriaBuilder.like(criteriaBuilder.lower(root.get("inheritFrom")), lower));
- orList.add(criteriaBuilder.like(criteriaBuilder.lower(root.get("labels").as(String.class)), lower));
- orList.add(criteriaBuilder.like(criteriaBuilder.lower(root.get("tags").as(String.class)), lower));
- orList.add(criteriaBuilder.like(criteriaBuilder.lower(root.get("additionalOwners").as(String.class)), lower));
- orList.add(criteriaBuilder.like(criteriaBuilder.lower(root.get("componentOf").as(String.class)), lower));
- orList.add(criteriaBuilder.like(criteriaBuilder.lower(root.get("components").as(String.class)), lower));
- orList.add(criteriaBuilder.like(criteriaBuilder.lower(root.get("implementedBy").as(String.class)), lower));
- orList.add(criteriaBuilder.like(criteriaBuilder.lower(root.get("apiInterface").as(String.class)), lower));
- orList.add(criteriaBuilder.like(criteriaBuilder.lower(root.get("languages").as(String.class)), lower));
- orList.add(criteriaBuilder.like(criteriaBuilder.lower(root.get("links").as(String.class)), lower));
- orList.add(criteriaBuilder.like(criteriaBuilder.lower(root.get("contacts").as(String.class)), lower));
+ for (String normalizedSearch : normalizeSearchTextVariants(search)) {
+ String lower = "%" + normalizedSearch + "%";
+ orList.add(criteriaBuilder.equal(criteriaBuilder.lower(root.get("name")), normalizedSearch));
+ orList.add(criteriaBuilder.equal(criteriaBuilder.lower(root.get("displayName")), normalizedSearch));
+ orList.add(criteriaBuilder.like(criteriaBuilder.lower(root.get("name")), lower));
+ orList.add(criteriaBuilder.like(criteriaBuilder.lower(root.get("displayName")), lower));
+ List<Predicate> nameTokenPredicates = new ArrayList<>();
+ List<Predicate> displayNameTokenPredicates = new ArrayList<>();
+ for (String token : normalizedSearch.split("[^a-z0-9]+")) {
+ if (token.length() < 2 || token.equals(normalizedSearch)) {
+ continue;
+ }
+ String tokenLike = "%" + token + "%";
+ nameTokenPredicates.add(criteriaBuilder.like(criteriaBuilder.lower(root.get("name")), tokenLike));
+ displayNameTokenPredicates.add(criteriaBuilder.like(criteriaBuilder.lower(root.get("displayName")), tokenLike));
+ }
+ if (!nameTokenPredicates.isEmpty()) {
+ orList.add(criteriaBuilder.or(
+ criteriaBuilder.and(nameTokenPredicates.toArray(new Predicate[0])),
+ criteriaBuilder.and(displayNameTokenPredicates.toArray(new Predicate[0]))));
+ }
+ orList.add(criteriaBuilder.like(criteriaBuilder.lower(root.get("subtype")), lower));
+ orList.add(criteriaBuilder.like(criteriaBuilder.lower(root.get("namespace")), lower));
+ orList.add(criteriaBuilder.like(criteriaBuilder.lower(root.get("environment")), lower));
+ orList.add(criteriaBuilder.like(criteriaBuilder.lower(root.get("description")), lower));
+ orList.add(criteriaBuilder.like(criteriaBuilder.lower(root.get("owner")), lower));
+ orList.add(criteriaBuilder.like(criteriaBuilder.lower(root.get("lifecycle")), lower));
+ orList.add(criteriaBuilder.like(criteriaBuilder.lower(root.get("tier")), lower));
+ orList.add(criteriaBuilder.like(criteriaBuilder.lower(root.get("system")), lower));
+ orList.add(criteriaBuilder.like(criteriaBuilder.lower(root.get("runbook")), lower));
+ orList.add(criteriaBuilder.like(criteriaBuilder.lower(root.get("inheritFrom")), lower));
+ orList.add(criteriaBuilder.like(criteriaBuilder.lower(root.get("labels").as(String.class)), lower));
+ orList.add(criteriaBuilder.like(criteriaBuilder.lower(root.get("tags").as(String.class)), lower));
+ orList.add(criteriaBuilder.like(criteriaBuilder.lower(root.get("additionalOwners").as(String.class)), lower));
+ orList.add(criteriaBuilder.like(criteriaBuilder.lower(root.get("componentOf").as(String.class)), lower));
+ orList.add(criteriaBuilder.like(criteriaBuilder.lower(root.get("components").as(String.class)), lower));
+ orList.add(criteriaBuilder.like(criteriaBuilder.lower(root.get("implementedBy").as(String.class)), lower));
+ orList.add(criteriaBuilder.like(criteriaBuilder.lower(root.get("apiInterface").as(String.class)), lower));
+ orList.add(criteriaBuilder.like(criteriaBuilder.lower(root.get("languages").as(String.class)), lower));
+ orList.add(criteriaBuilder.like(criteriaBuilder.lower(root.get("links").as(String.class)), lower));
+ orList.add(criteriaBuilder.like(criteriaBuilder.lower(root.get("contacts").as(String.class)), lower));
+ }
Long id = Longs.tryParse(search);
if (id != null) {
orList.add(criteriaBuilder.equal(root.get("id"), id));
}
return orList;
}
+
+ private List<String> normalizeSearchTextVariants(String search) {
+ String trimmedSearch = search.trim();
+ String normalizedSearch;
+ try {
+ normalizedSearch = URLDecoder.decode(trimmedSearch, StandardCharsets.UTF_8).toLowerCase();
+ } catch (IllegalArgumentException ignored) {
+ normalizedSearch = trimmedSearch.toLowerCase();
+ }
+ Set<String> variants = new LinkedHashSet<>();
+ variants.add(normalizedSearch);
+ if (normalizedSearch.contains("localhost")) {
+ variants.add(normalizedSearch.replace("localhost", "127.0.0.1"));
+ }
+ if (normalizedSearch.contains("127.0.0.1")) {
+ variants.add(normalizedSearch.replace("127.0.0.1", "localhost"));
+ }
+ return new ArrayList<>(variants);
+ }
}
diff --git a/hertzbeat-manager/src/main/java/org/apache/hertzbeat/manager/service/entity/EntityDefinitionDocumentParserService.java b/hertzbeat-manager/src/main/java/org/apache/hertzbeat/manager/service/entity/EntityDefinitionDocumentParserService.java
index 378c349829..7f355efbbf 100644
--- a/hertzbeat-manager/src/main/java/org/apache/hertzbeat/manager/service/entity/EntityDefinitionDocumentParserService.java
+++ b/hertzbeat-manager/src/main/java/org/apache/hertzbeat/manager/service/entity/EntityDefinitionDocumentParserService.java
@@ -38,9 +38,9 @@ public class EntityDefinitionDocumentParserService {
private static final String FORMAT_CURL = "curl";
public List<Map<String, Object>> parseDefinitionRecords(EntityDefinitionRequest definitionRequest) {
- String payload = extractDefinitionPayload(definitionRequest.getContent(), definitionRequest.getFormat());
- String format = normalizeDefinitionFormat(definitionRequest.getFormat(), payload);
- return parseDefinitionDocuments(payload, format);
+ DefinitionPayload payload = extractDefinitionPayload(definitionRequest.getContent(), definitionRequest.getFormat());
+ String format = normalizeDefinitionFormat(payload.format(), payload.content());
+ return parseDefinitionDocuments(payload.content(), format);
}
private List<Map<String, Object>> parseDefinitionDocuments(String payload, String format) {
@@ -74,35 +74,116 @@ public class EntityDefinitionDocumentParserService {
return List.of(toDefinitionRecord(value));
}
- private String extractDefinitionPayload(String content, String format) {
+ private DefinitionPayload extractDefinitionPayload(String content, String format) {
String trimmed = content == null ? null : content.trim();
if (!StringUtils.hasText(trimmed)) {
throw new IllegalArgumentException("Entity definition content can not be blank.");
}
if (!FORMAT_CURL.equalsIgnoreCase(defaultText(format, ""))) {
- return trimmed;
- }
- int singleQuoteIndex = trimmed.indexOf("-d '");
- if (singleQuoteIndex >= 0) {
- int payloadStart = singleQuoteIndex + 4;
- int payloadEnd = trimmed.lastIndexOf('\'');
- if (payloadEnd > payloadStart) {
- return trimmed.substring(payloadStart, payloadEnd)
- .replace("'\\\\''", "'")
- .replace("\\\\", "\\");
+ return new DefinitionPayload(trimmed, format);
+ }
+ String payload = extractCurlDataPayload(trimmed);
+ if (payload != null) {
+ return unwrapCurlRequestEnvelope(payload, format);
+ }
+ return unwrapCurlRequestEnvelope(trimmed, format);
+ }
+
+ private String extractCurlDataPayload(String curlCommand) {
+ for (String flag : List.of("--data-urlencode", "--data-binary", "--data-raw", "--data", "-d")) {
+ int searchFrom = 0;
+ int flagIndex;
+ while ((flagIndex = curlCommand.indexOf(flag, searchFrom)) >= 0) {
+ int valueStart = flagIndex + flag.length();
+ if (valueStart < curlCommand.length() && curlCommand.charAt(valueStart) == '=') {
+ valueStart++;
+ } else if (valueStart < curlCommand.length() && !Character.isWhitespace(curlCommand.charAt(valueStart))) {
+ searchFrom = valueStart;
+ continue;
+ }
+ while (valueStart < curlCommand.length() && Character.isWhitespace(curlCommand.charAt(valueStart))) {
+ valueStart++;
+ }
+ if (valueStart >= curlCommand.length()) {
+ return null;
+ }
+ return readCurlDataValue(curlCommand, valueStart);
}
}
- int doubleQuoteIndex = trimmed.indexOf("-d \"");
- if (doubleQuoteIndex >= 0) {
- int payloadStart = doubleQuoteIndex + 4;
- int payloadEnd = trimmed.lastIndexOf('"');
- if (payloadEnd > payloadStart) {
- return trimmed.substring(payloadStart, payloadEnd)
- .replace("\\\"", "\"")
- .replace("\\\\", "\\");
+ return null;
+ }
+
+ private String readCurlDataValue(String curlCommand, int valueStart) {
+ char first = curlCommand.charAt(valueStart);
+ if (first == '\'') {
+ return readSingleQuotedCurlValue(curlCommand, valueStart + 1)
+ .replace("'\\\\''", "'")
+ .replace("\\\\", "\\");
+ }
+ if (first == '"') {
+ return readDoubleQuotedCurlValue(curlCommand, valueStart + 1)
+ .replace("\\\"", "\"")
+ .replace("\\\\", "\\");
+ }
+ int valueEnd = valueStart;
+ while (valueEnd < curlCommand.length() && !Character.isWhitespace(curlCommand.charAt(valueEnd))) {
+ valueEnd++;
+ }
+ return curlCommand.substring(valueStart, valueEnd);
+ }
+
+ private String readSingleQuotedCurlValue(String curlCommand, int payloadStart) {
+ int index = payloadStart;
+ while (index < curlCommand.length()) {
+ if (curlCommand.charAt(index) == '\'') {
+ if (index + 3 < curlCommand.length()
+ && curlCommand.charAt(index + 1) == '\\'
+ && curlCommand.charAt(index + 2) == '\''
+ && curlCommand.charAt(index + 3) == '\'') {
+ index += 4;
+ continue;
+ }
+ return curlCommand.substring(payloadStart, index);
+ }
+ index++;
+ }
+ return curlCommand.substring(payloadStart);
+ }
+
+ private String readDoubleQuotedCurlValue(String curlCommand, int payloadStart) {
+ int index = payloadStart;
+ boolean escaped = false;
+ while (index < curlCommand.length()) {
+ char current = curlCommand.charAt(index);
+ if (current == '"' && !escaped) {
+ return curlCommand.substring(payloadStart, index);
+ }
+ escaped = current == '\\' && !escaped;
+ if (current != '\\') {
+ escaped = false;
}
+ index++;
}
- return trimmed;
+ return curlCommand.substring(payloadStart);
+ }
+
+ private DefinitionPayload unwrapCurlRequestEnvelope(String payload, String format) {
+ if (!JsonUtil.isJsonStr(payload)) {
+ return new DefinitionPayload(payload, format);
+ }
+ Object parsed = JsonUtil.fromJson(payload, Object.class);
+ if (!(parsed instanceof Map<?, ?> rawMap)) {
+ return new DefinitionPayload(payload, format);
+ }
+ Object content = rawMap.get("content");
+ if (!(content instanceof String contentText) || !StringUtils.hasText(contentText)) {
+ return new DefinitionPayload(payload, format);
+ }
+ Object envelopeFormat = rawMap.get("format");
+ return new DefinitionPayload(
+ contentText.trim(),
+ defaultText(envelopeFormat == null ? null : String.valueOf(envelopeFormat), format)
+ );
}
private String normalizeDefinitionFormat(String format, String payload) {
@@ -141,4 +222,7 @@ public class EntityDefinitionDocumentParserService {
}
return "";
}
+
+ private record DefinitionPayload(String content, String format) {
+ }
}
diff --git a/hertzbeat-manager/src/main/java/org/apache/hertzbeat/manager/service/entity/EntityDefinitionDraftService.java b/hertzbeat-manager/src/main/java/org/apache/hertzbeat/manager/service/entity/EntityDefinitionDraftService.java
index 1dfeb23811..5d17fb9d02 100644
--- a/hertzbeat-manager/src/main/java/org/apache/hertzbeat/manager/service/entity/EntityDefinitionDraftService.java
+++ b/hertzbeat-manager/src/main/java/org/apache/hertzbeat/manager/service/entity/EntityDefinitionDraftService.java
@@ -17,11 +17,18 @@
package org.apache.hertzbeat.manager.service.entity;
+import java.util.ArrayList;
+import java.util.HashSet;
import java.util.List;
+import java.util.Set;
+import org.apache.hertzbeat.common.entity.manager.EntityIdentity;
+import org.apache.hertzbeat.common.entity.manager.ObserveEntity;
+import org.apache.hertzbeat.manager.dao.EntityIdentityDao;
import org.apache.hertzbeat.manager.pojo.dto.EntityDefinitionRequest;
import org.apache.hertzbeat.manager.pojo.dto.EntityDto;
import org.springframework.stereotype.Service;
import org.springframework.util.CollectionUtils;
+import org.springframework.util.StringUtils;
/**
* Builds entity DTO drafts from submitted definition documents.
@@ -32,13 +39,25 @@ public class EntityDefinitionDraftService {
private final EntityDefinitionDocumentParserService entityDefinitionDocumentParserService;
private final EntityDefinitionNormalizationService entityDefinitionNormalizationService;
private final EntityDefinitionMappingService entityDefinitionMappingService;
+ private final EntityIdentityDao entityIdentityDao;
+ private final EntityIdentityResolutionService entityIdentityResolutionService;
+ private final EntityWorkspaceAccessService entityWorkspaceAccessService;
+ private final EntityMonitorBindQueryService entityMonitorBindQueryService;
public EntityDefinitionDraftService(EntityDefinitionDocumentParserService entityDefinitionDocumentParserService,
EntityDefinitionNormalizationService entityDefinitionNormalizationService,
- EntityDefinitionMappingService entityDefinitionMappingService) {
+ EntityDefinitionMappingService entityDefinitionMappingService,
+ EntityIdentityDao entityIdentityDao,
+ EntityIdentityResolutionService entityIdentityResolutionService,
+ EntityWorkspaceAccessService entityWorkspaceAccessService,
+ EntityMonitorBindQueryService entityMonitorBindQueryService) {
this.entityDefinitionDocumentParserService = entityDefinitionDocumentParserService;
this.entityDefinitionNormalizationService = entityDefinitionNormalizationService;
this.entityDefinitionMappingService = entityDefinitionMappingService;
+ this.entityIdentityDao = entityIdentityDao;
+ this.entityIdentityResolutionService = entityIdentityResolutionService;
+ this.entityWorkspaceAccessService = entityWorkspaceAccessService;
+ this.entityMonitorBindQueryService = entityMonitorBindQueryService;
}
public EntityDto parseEntityDefinition(EntityDefinitionRequest definitionRequest, Long entityId) {
@@ -79,11 +98,117 @@ public class EntityDefinitionDraftService {
Long entityId,
String requestWorkspaceId,
boolean resolveRequestWorkspaceAtMappingBoundary) {
- return entityDefinitionDocumentParserService.parseDefinitionRecords(definitionRequest).stream()
+ List<EntityDto> entityDtos = entityDefinitionDocumentParserService.parseDefinitionRecords(definitionRequest).stream()
.map(entityDefinitionNormalizationService::normalizeDefinition)
.map(definition -> resolveRequestWorkspaceAtMappingBoundary
? entityDefinitionMappingService.toEntityDto(definition, entityId)
: entityDefinitionMappingService.toEntityDto(definition, entityId, requestWorkspaceId))
.toList();
+ rejectPreviewPrimaryIdentityCollisions(entityDtos, entityId);
+ rejectPreviewMonitorBindCollisions(entityDtos, entityId);
+ return entityDtos;
+ }
+
+ private void rejectPreviewMonitorBindCollisions(List<EntityDto> entityDtos, Long currentEntityId) {
+ for (EntityDto entityDto : entityDtos) {
+ if (entityDto == null || CollectionUtils.isEmpty(entityDto.getMonitorBinds())) {
+ continue;
+ }
+ for (org.apache.hertzbeat.common.entity.manager.EntityMonitorBind monitorBind : entityDto.getMonitorBinds()) {
+ if (monitorBind == null || monitorBind.getMonitorId() == null) {
+ continue;
+ }
+ for (org.apache.hertzbeat.common.entity.manager.EntityMonitorBind existingBind
+ : entityMonitorBindQueryService.findMonitorBindsByMonitorId(monitorBind.getMonitorId())) {
+ if (existingBind == null || existingBind.getEntityId() == null
+ || existingBind.getEntityId().equals(currentEntityId)) {
+ continue;
+ }
+ throw new IllegalArgumentException("Monitor already bound to another entity: "
+ + monitorBind.getMonitorId() + ".");
+ }
+ }
+ }
+ }
+
+ private void rejectPreviewPrimaryIdentityCollisions(List<EntityDto> entityDtos, Long currentEntityId) {
+ List<PreviewPrimaryIdentity> primaryIdentities = collectPreviewPrimaryIdentities(entityDtos);
+ if (primaryIdentities.isEmpty()) {
+ return;
+ }
+
+ Set<String> identityKeys = new HashSet<>();
+ Set<String> normalizedValues = new HashSet<>();
+ for (PreviewPrimaryIdentity identity : primaryIdentities) {
+ identityKeys.add(identity.identityKey());
+ normalizedValues.add(identity.normalizedValue());
+ }
+
+ List<EntityIdentity> matchingIdentities =
+ entityIdentityDao.findAllByIdentityKeyInAndNormalizedValueIn(identityKeys, normalizedValues);
+ if (CollectionUtils.isEmpty(matchingIdentities)) {
+ return;
+ }
+
+ Set<Long> matchingEntityIds = new HashSet<>();
+ for (EntityIdentity matchingIdentity : matchingIdentities) {
+ Long matchingEntityId = matchingIdentity.getEntityId();
+ if (matchingEntityId != null && !matchingEntityId.equals(currentEntityId)) {
+ matchingEntityIds.add(matchingEntityId);
+ }
+ }
+ if (matchingEntityIds.isEmpty()) {
+ return;
+ }
+
+ Set<Long> accessibleEntityIds = new HashSet<>(entityWorkspaceAccessService
+ .findAccessibleEntitiesByIdsForRequestWorkspace(matchingEntityIds)
+ .stream()
+ .map(ObserveEntity::getId)
+ .toList());
+ if (accessibleEntityIds.isEmpty()) {
+ return;
+ }
+
+ for (PreviewPrimaryIdentity primaryIdentity : primaryIdentities) {
+ for (EntityIdentity matchingIdentity : matchingIdentities) {
+ if (!accessibleEntityIds.contains(matchingIdentity.getEntityId())) {
+ continue;
+ }
+ if (matchingIdentity.isPrimaryIdentity()
+ && primaryIdentity.identityKey().equals(matchingIdentity.getIdentityKey())
+ && primaryIdentity.normalizedValue().equals(matchingIdentity.getNormalizedValue())) {
+ throw new IllegalArgumentException("Entity primary identity already exists: "
+ + primaryIdentity.identityKey() + "=" + primaryIdentity.identityValue() + ".");
+ }
+ }
+ }
+ }
+
+ private List<PreviewPrimaryIdentity> collectPreviewPrimaryIdentities(List<EntityDto> entityDtos) {
+ List<PreviewPrimaryIdentity> identities = new ArrayList<>();
+ for (EntityDto entityDto : entityDtos) {
+ if (entityDto == null || CollectionUtils.isEmpty(entityDto.getIdentities())) {
+ continue;
+ }
+ for (EntityIdentity identity : entityDto.getIdentities()) {
+ if (identity == null
+ || !identity.isPrimaryIdentity()
+ || !StringUtils.hasText(identity.getIdentityKey())
+ || !StringUtils.hasText(identity.getIdentityValue())) {
+ continue;
+ }
+ String identityKey = identity.getIdentityKey().trim();
+ String identityValue = identity.getIdentityValue().trim();
+ String normalizedValue = entityIdentityResolutionService.normalizeIdentityValue(identityKey, identityValue);
+ if (StringUtils.hasText(normalizedValue)) {
+ identities.add(new PreviewPrimaryIdentity(identityKey, identityValue, normalizedValue));
+ }
+ }
+ }
+ return identities;
+ }
+
+ private record PreviewPrimaryIdentity(String identityKey, String identityValue, String normalizedValue) {
}
}
diff --git a/hertzbeat-manager/src/main/java/org/apache/hertzbeat/manager/service/entity/EntityDefinitionRelationNormalizationService.java b/hertzbeat-manager/src/main/java/org/apache/hertzbeat/manager/service/entity/EntityDefinitionRelationNormalizationService.java
index 5b4a8aefe0..3b51ed0975 100644
--- a/hertzbeat-manager/src/main/java/org/apache/hertzbeat/manager/service/entity/EntityDefinitionRelationNormalizationService.java
+++ b/hertzbeat-manager/src/main/java/org/apache/hertzbeat/manager/service/entity/EntityDefinitionRelationNormalizationService.java
@@ -151,10 +151,13 @@ public class EntityDefinitionRelationNormalizationService {
}
private String relationSignature(EntityDefinition.Relation relation) {
+ String targetReference = defaultText(
+ relation.getTargetRef(),
+ buildEntityReference(relation.getTargetEntityId()),
+ relation.getTargetEntityId() == null ? "" : String.valueOf(relation.getTargetEntityId()));
return String.join("|",
defaultText(relation.getRelationType(), "depends_on"),
- relation.getTargetEntityId() == null ? "" : String.valueOf(relation.getTargetEntityId()),
- relation.getTargetRef() == null ? "" : relation.getTargetRef());
+ targetReference.trim().toLowerCase(java.util.Locale.ROOT));
}
private String buildEntityReference(Long entityId) {
diff --git a/hertzbeat-manager/src/main/java/org/apache/hertzbeat/manager/service/entity/EntityDefinitionTypeResolverService.java b/hertzbeat-manager/src/main/java/org/apache/hertzbeat/manager/service/entity/EntityDefinitionTypeResolverService.java
index a86d6859e5..41d8604c8b 100644
--- a/hertzbeat-manager/src/main/java/org/apache/hertzbeat/manager/service/entity/EntityDefinitionTypeResolverService.java
+++ b/hertzbeat-manager/src/main/java/org/apache/hertzbeat/manager/service/entity/EntityDefinitionTypeResolverService.java
@@ -47,19 +47,29 @@ public class EntityDefinitionTypeResolverService {
}
public String resolveDefinitionEntityType(Map<String, Object> root, Map<String, Object> specMap) {
- String normalizedKind = normalizeSupportedEntityType(asText(root.get("kind")));
+ String rawKind = asText(root.get("kind"));
+ String normalizedKind = normalizeSupportedEntityType(rawKind);
if (StringUtils.hasText(normalizedKind)) {
return normalizedKind;
}
- String normalizedEntityType = normalizeSupportedEntityType(defaultText(
+ if (isUnsupportedExplicitKind(rawKind)) {
+ throw new IllegalArgumentException("Unsupported entity definition kind: " + rawKind + ".");
+ }
+
+ String rawEntityType = defaultText(
asText(specMap.get("entityType")),
asText(specMap.get("entity_type")),
asText(root.get("entityType")),
asText(root.get("entity_type"))
- ));
+ );
+ String normalizedEntityType = normalizeSupportedEntityType(rawEntityType);
if (StringUtils.hasText(normalizedEntityType)) {
return normalizedEntityType;
}
+ if (StringUtils.hasText(rawEntityType)) {
+ throw new IllegalArgumentException("Unsupported entity definition entity type: " + rawEntityType + ".");
+ }
+
return defaultText(
normalizeSupportedEntityType(asText(specMap.get("type"))),
normalizeSupportedEntityType(asText(root.get("type"))),
@@ -68,6 +78,10 @@ public class EntityDefinitionTypeResolverService {
);
}
+ private boolean isUnsupportedExplicitKind(String rawKind) {
+ return StringUtils.hasText(rawKind) && !LEGACY_ENTITY_DEFINITION_KIND.equalsIgnoreCase(rawKind.trim());
+ }
+
public String resolveDefinitionSubtype(Map<String, Object> root, Map<String, Object> specMap, String entityType) {
String explicitSubtype = defaultText(
asText(specMap.get("subtype")),
diff --git a/hertzbeat-manager/src/main/java/org/apache/hertzbeat/manager/service/entity/EntityDetailObservabilityReadModelService.java b/hertzbeat-manager/src/main/java/org/apache/hertzbeat/manager/service/entity/EntityDetailObservabilityReadModelService.java
index c790424de4..f198c1121a 100644
--- a/hertzbeat-manager/src/main/java/org/apache/hertzbeat/manager/service/entity/EntityDetailObservabilityReadModelService.java
+++ b/hertzbeat-manager/src/main/java/org/apache/hertzbeat/manager/service/entity/EntityDetailObservabilityReadModelService.java
@@ -61,6 +61,9 @@ import org.springframework.util.CollectionUtils;
@Service
public class EntityDetailObservabilityReadModelService {
+ static final int ENTITY_DETAIL_RELATION_PREVIEW_LIMIT = 50;
+ static final int ENTITY_DETAIL_BOUND_MONITOR_PREVIEW_LIMIT = 50;
+
private final EntityDetailReadModelService entityDetailReadModelService;
private final EntityStatusRefreshService entityStatusRefreshService;
private final EntityObservabilityGateway entityObservabilityGateway;
@@ -86,24 +89,26 @@ public class EntityDetailObservabilityReadModelService {
}
public EntityDetailDto buildEntityDetail(long entityId) {
- EntityDto entityDto = entityDetailReadModelService.loadEntityDto(entityId);
+ EntityDto entityDto = entityDetailReadModelService.loadEntityDto(
+ entityId, ENTITY_DETAIL_RELATION_PREVIEW_LIMIT);
if (entityDto == null) {
return null;
}
- return buildEntityDetail(entityDto);
+ return buildEntityDetail(entityDto, null, true, entityDetailReadModelService.countEntityRelations(entityId));
}
public EntityDetailDto buildEntityDetail(EntityDto entityDto) {
- return buildEntityDetail(entityDto, null, true);
+ return buildEntityDetail(entityDto, null, true, null);
}
public EntityDetailDto buildEntityDetail(EntityDto entityDto, String requestWorkspaceId) {
- return buildEntityDetail(entityDto, requestWorkspaceId, false);
+ return buildEntityDetail(entityDto, requestWorkspaceId, false, null);
}
private EntityDetailDto buildEntityDetail(EntityDto entityDto,
String requestWorkspaceId,
- boolean resolveRequestWorkspaceAtEvidenceBoundaries) {
+ boolean resolveRequestWorkspaceAtEvidenceBoundaries,
+ Long relationCountOverride) {
if (entityDto == null || entityDto.getEntity() == null) {
return null;
}
@@ -124,7 +129,10 @@ public class EntityDetailObservabilityReadModelService {
entityDto.getEntity(), entityDto.getIdentities(),
entityDto.getEntityInfo() == null ? null : entityDto.getEntityInfo().getHertzbeat()
);
- List<MonitorInfo> boundMonitors = monitors.stream().map(MonitorInfo::fromEntity).toList();
+ List<MonitorInfo> boundMonitors = monitors.stream()
+ .limit(ENTITY_DETAIL_BOUND_MONITOR_PREVIEW_LIMIT)
+ .map(MonitorInfo::fromEntity)
+ .toList();
List<EntityLogQueryHint> logQueryHints =
entityObservabilityGateway.buildEntityLogQueryHints(entityDto.getIdentities(), monitors);
EntityEvidenceSummaryInfo evidenceSummary = entityObservabilityGateway.buildEntityEvidenceSummary(
@@ -150,7 +158,9 @@ public class EntityDetailObservabilityReadModelService {
List<TraceEvidence> traceEvidence = observabilityDetail.getTraceEvidence();
EntityUnifiedEvidenceSummary unifiedEvidenceSummary = observabilityDetail.getUnifiedEvidenceSummary();
EntityTriageRecommendation triageRecommendation = observabilityDetail.getTriageRecommendation();
- long relationCount = CollectionUtils.isEmpty(entityDto.getRelations()) ? 0 : entityDto.getRelations().size();
+ long relationCount = relationCountOverride == null
+ ? CollectionUtils.isEmpty(entityDto.getRelations()) ? 0 : entityDto.getRelations().size()
+ : relationCountOverride;
EntityOpsSummaryInfo opsSummary = entityObservabilityGateway.buildEntityOpsSummary(
entityDto.getEntity(), relationCount, evidenceSummary);
List<EntityNextActionInfo> nextActions =
diff --git a/hertzbeat-manager/src/main/java/org/apache/hertzbeat/manager/service/entity/EntityDetailReadModelService.java b/hertzbeat-manager/src/main/java/org/apache/hertzbeat/manager/service/entity/EntityDetailReadModelService.java
index 8796bbdc28..df7de3dd75 100644
--- a/hertzbeat-manager/src/main/java/org/apache/hertzbeat/manager/service/entity/EntityDetailReadModelService.java
+++ b/hertzbeat-manager/src/main/java/org/apache/hertzbeat/manager/service/entity/EntityDetailReadModelService.java
@@ -47,12 +47,34 @@ public class EntityDetailReadModelService {
return buildEntityDto(entityId, entityWorkspaceAccessService.findAccessibleEntityForRequestWorkspace(entityId));
}
+ public EntityDto loadEntityDto(long entityId, int relationPreviewLimit) {
+ return buildEntityDto(
+ entityId,
+ entityWorkspaceAccessService.findAccessibleEntityForRequestWorkspace(entityId),
+ relationPreviewLimit);
+ }
+
public EntityDto loadEntityDto(long entityId, String requestWorkspaceId) {
return buildEntityDto(entityId, entityWorkspaceAccessService.findAccessibleEntityById(
entityId, requestWorkspaceId));
}
+ public EntityDto loadEntityDto(long entityId, String requestWorkspaceId, int relationPreviewLimit) {
+ return buildEntityDto(
+ entityId,
+ entityWorkspaceAccessService.findAccessibleEntityById(entityId, requestWorkspaceId),
+ relationPreviewLimit);
+ }
+
+ public long countEntityRelations(long entityId) {
+ return entityRelationService.countEntityRelations(entityId);
+ }
+
private EntityDto buildEntityDto(long entityId, Optional<ObserveEntity> optional) {
+ return buildEntityDto(entityId, optional, null);
+ }
+
+ private EntityDto buildEntityDto(long entityId, Optional<ObserveEntity> optional, Integer relationPreviewLimit) {
if (optional.isEmpty()) {
return null;
}
@@ -61,7 +83,9 @@ public class EntityDetailReadModelService {
dto.setEntity(entity);
dto.setIdentities(entityIdentityReadModelService.findIdentities(entityId));
dto.setMonitorBinds(entityMonitorBindService.findMonitorBinds(entityId));
- dto.setRelations(entityRelationService.findEntityRelations(entityId));
+ dto.setRelations(relationPreviewLimit == null
+ ? entityRelationService.findEntityRelations(entityId)
+ : entityRelationService.findEntityRelations(entityId, relationPreviewLimit));
return dto;
}
}
diff --git a/hertzbeat-manager/src/main/java/org/apache/hertzbeat/manager/service/entity/EntityIdentityQueryService.java b/hertzbeat-manager/src/main/java/org/apache/hertzbeat/manager/service/entity/EntityIdentityQueryService.java
index ef5cc05750..195dc52cbb 100644
--- a/hertzbeat-manager/src/main/java/org/apache/hertzbeat/manager/service/entity/EntityIdentityQueryService.java
+++ b/hertzbeat-manager/src/main/java/org/apache/hertzbeat/manager/service/entity/EntityIdentityQueryService.java
@@ -18,10 +18,13 @@
package org.apache.hertzbeat.manager.service.entity;
import java.util.List;
+import java.util.Map;
import java.util.Set;
+import java.util.stream.Collectors;
import org.apache.hertzbeat.common.entity.manager.EntityIdentity;
import org.apache.hertzbeat.manager.dao.EntityIdentityDao;
import org.springframework.stereotype.Service;
+import org.springframework.util.CollectionUtils;
/**
* Query boundary for raw persisted entity identity rows, counts, and match lookups.
@@ -50,4 +53,16 @@ public class EntityIdentityQueryService {
public long countIdentities(long entityId) {
return entityIdentityDao.countByEntityId(entityId);
}
+
+ public Map<Long, Long> countIdentitiesByEntityIds(List<Long> entityIds) {
+ if (CollectionUtils.isEmpty(entityIds)) {
+ return Map.of();
+ }
+ return entityIdentityDao.countByEntityIdInGroupByEntityId(entityIds)
+ .stream()
+ .collect(Collectors.toMap(
+ row -> (Long) row[0],
+ row -> ((Number) row[1]).longValue()
+ ));
+ }
}
diff --git a/hertzbeat-manager/src/main/java/org/apache/hertzbeat/manager/service/entity/EntityIdentityReadModelService.java b/hertzbeat-manager/src/main/java/org/apache/hertzbeat/manager/service/entity/EntityIdentityReadModelService.java
index 93f9b6dd21..29c93831b6 100644
--- a/hertzbeat-manager/src/main/java/org/apache/hertzbeat/manager/service/entity/EntityIdentityReadModelService.java
+++ b/hertzbeat-manager/src/main/java/org/apache/hertzbeat/manager/service/entity/EntityIdentityReadModelService.java
@@ -18,6 +18,7 @@
package org.apache.hertzbeat.manager.service.entity;
import java.util.List;
+import java.util.Map;
import java.util.Set;
import org.apache.hertzbeat.common.entity.manager.EntityIdentity;
import org.springframework.stereotype.Service;
@@ -45,4 +46,8 @@ public class EntityIdentityReadModelService {
public long countIdentities(long entityId) {
return entityIdentityQueryService.countIdentities(entityId);
}
+
+ public Map<Long, Long> countIdentitiesByEntityIds(List<Long> entityIds) {
+ return entityIdentityQueryService.countIdentitiesByEntityIds(entityIds);
+ }
}
diff --git a/hertzbeat-manager/src/main/java/org/apache/hertzbeat/manager/service/entity/EntityIdentityWriteModelService.java b/hertzbeat-manager/src/main/java/org/apache/hertzbeat/manager/service/entity/EntityIdentityWriteModelService.java
index 93182b7d75..aa2266b890 100644
--- a/hertzbeat-manager/src/main/java/org/apache/hertzbeat/manager/service/entity/EntityIdentityWriteModelService.java
+++ b/hertzbeat-manager/src/main/java/org/apache/hertzbeat/manager/service/entity/EntityIdentityWriteModelService.java
@@ -41,11 +41,14 @@ public class EntityIdentityWriteModelService {
private final EntityIdentityDao entityIdentityDao;
private final EntityIdentityResolutionService entityIdentityResolutionService;
+ private final EntityWorkspaceAccessService entityWorkspaceAccessService;
public EntityIdentityWriteModelService(EntityIdentityDao entityIdentityDao,
- EntityIdentityResolutionService entityIdentityResolutionService) {
+ EntityIdentityResolutionService entityIdentityResolutionService,
+ EntityWorkspaceAccessService entityWorkspaceAccessService) {
this.entityIdentityDao = entityIdentityDao;
this.entityIdentityResolutionService = entityIdentityResolutionService;
+ this.entityWorkspaceAccessService = entityWorkspaceAccessService;
}
public void replaceIdentities(ObserveEntity entity, List<EntityIdentity> identities) {
@@ -86,6 +89,7 @@ public class EntityIdentityWriteModelService {
if (rows.isEmpty()) {
rows.addAll(buildDefaultIdentities(entity));
}
+ rejectPrimaryIdentityCollisions(entity, rows);
entityIdentityDao.saveAll(rows);
}
@@ -173,6 +177,56 @@ public class EntityIdentityWriteModelService {
};
}
+ private void rejectPrimaryIdentityCollisions(ObserveEntity entity, List<EntityIdentity> rows) {
+ List<EntityIdentity> primaryRows = rows.stream()
+ .filter(EntityIdentity::isPrimaryIdentity)
+ .filter(identity -> StringUtils.hasText(identity.getIdentityKey())
+ && StringUtils.hasText(identity.getNormalizedValue()))
+ .toList();
+ if (primaryRows.isEmpty()) {
+ return;
+ }
+ Set<String> identityKeys = primaryRows.stream()
+ .map(EntityIdentity::getIdentityKey)
+ .collect(java.util.stream.Collectors.toSet());
+ Set<String> normalizedValues = primaryRows.stream()
+ .map(EntityIdentity::getNormalizedValue)
+ .collect(java.util.stream.Collectors.toSet());
+ List<EntityIdentity> matchingIdentities =
+ entityIdentityDao.findAllByIdentityKeyInAndNormalizedValueIn(identityKeys, normalizedValues);
+ if (CollectionUtils.isEmpty(matchingIdentities)) {
+ return;
+ }
+ Set<Long> matchingEntityIds = matchingIdentities.stream()
+ .map(EntityIdentity::getEntityId)
+ .filter(id -> id != null && !id.equals(entity.getId()))
+ .collect(java.util.stream.Collectors.toSet());
+ if (matchingEntityIds.isEmpty()) {
+ return;
+ }
+ Set<Long> accessibleEntityIds = entityWorkspaceAccessService
+ .findAccessibleEntitiesByIdsForRequestWorkspace(matchingEntityIds)
+ .stream()
+ .map(ObserveEntity::getId)
+ .collect(java.util.stream.Collectors.toSet());
+ if (accessibleEntityIds.isEmpty()) {
+ return;
+ }
+ for (EntityIdentity primaryRow : primaryRows) {
+ for (EntityIdentity matchingIdentity : matchingIdentities) {
+ if (!accessibleEntityIds.contains(matchingIdentity.getEntityId())) {
+ continue;
+ }
+ if (primaryRow.getIdentityKey().equals(matchingIdentity.getIdentityKey())
+ && primaryRow.getNormalizedValue().equals(matchingIdentity.getNormalizedValue())
+ && matchingIdentity.isPrimaryIdentity()) {
+ throw new IllegalArgumentException("Entity primary identity already exists: "
+ + primaryRow.getIdentityKey() + "=" + primaryRow.getIdentityValue() + ".");
+ }
+ }
+ }
+ }
+
private String defaultText(String... values) {
for (String value : values) {
if (StringUtils.hasText(value)) {
diff --git a/hertzbeat-manager/src/main/java/org/apache/hertzbeat/manager/service/entity/EntityIntegrationHintService.java b/hertzbeat-manager/src/main/java/org/apache/hertzbeat/manager/service/entity/EntityIntegrationHintService.java
index 56ca09f10b..52d62b3a8d 100644
--- a/hertzbeat-manager/src/main/java/org/apache/hertzbeat/manager/service/entity/EntityIntegrationHintService.java
+++ b/hertzbeat-manager/src/main/java/org/apache/hertzbeat/manager/service/entity/EntityIntegrationHintService.java
@@ -18,10 +18,18 @@
package org.apache.hertzbeat.manager.service.entity;
import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.LinkedHashSet;
import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.stream.Collectors;
+import org.apache.hertzbeat.common.entity.manager.EntityMonitorBind;
import org.apache.hertzbeat.common.entity.manager.Monitor;
+import org.apache.hertzbeat.common.entity.manager.ObserveEntity;
import org.apache.hertzbeat.manager.pojo.dto.EntityMonitorBindingCandidate;
import org.springframework.stereotype.Service;
+import org.springframework.util.CollectionUtils;
/**
* Builds integration hints that connect traditional monitors to entity catalog candidates.
@@ -29,13 +37,21 @@ import org.springframework.stereotype.Service;
@Service
public class EntityIntegrationHintService {
+ private static final String RECOMMEND_ALREADY_BOUND = "already_bound";
+
private final EntityMonitorQueryService entityMonitorQueryService;
private final EntityIdentityResolutionService entityIdentityResolutionService;
+ private final EntityMonitorBindService entityMonitorBindService;
+ private final EntityWorkspaceAccessService entityWorkspaceAccessService;
public EntityIntegrationHintService(EntityMonitorQueryService entityMonitorQueryService,
- EntityIdentityResolutionService entityIdentityResolutionService) {
+ EntityIdentityResolutionService entityIdentityResolutionService,
+ EntityMonitorBindService entityMonitorBindService,
+ EntityWorkspaceAccessService entityWorkspaceAccessService) {
this.entityMonitorQueryService = entityMonitorQueryService;
this.entityIdentityResolutionService = entityIdentityResolutionService;
+ this.entityMonitorBindService = entityMonitorBindService;
+ this.entityWorkspaceAccessService = entityWorkspaceAccessService;
}
public List<EntityMonitorBindingCandidate> getMonitorBindingCandidates(long monitorId) {
@@ -43,6 +59,57 @@ public class EntityIntegrationHintService {
if (monitor == null) {
return Collections.emptyList();
}
- return entityIdentityResolutionService.resolveMonitorBindingCandidates(monitor);
+ return includeExistingBoundEntities(monitor.getId(),
+ entityIdentityResolutionService.resolveMonitorBindingCandidates(monitor));
+ }
+
+ public Map<Long, List<EntityMonitorBindingCandidate>> getMonitorBindingCandidates(List<Long> monitorIds) {
+ if (CollectionUtils.isEmpty(monitorIds)) {
+ return Collections.emptyMap();
+ }
+ Map<Long, List<EntityMonitorBindingCandidate>> result = new LinkedHashMap<>();
+ monitorIds.stream()
+ .filter(java.util.Objects::nonNull)
+ .filter(monitorId -> monitorId > 0)
+ .distinct()
+ .limit(100)
+ .forEach(monitorId -> result.put(monitorId, getMonitorBindingCandidates(monitorId)));
+ return result;
+ }
+
+ private List<EntityMonitorBindingCandidate> includeExistingBoundEntities(
+ long monitorId, List<EntityMonitorBindingCandidate> identityCandidates) {
+ List<EntityMonitorBindingCandidate> candidates = new java.util.ArrayList<>(
+ identityCandidates == null ? Collections.emptyList() : identityCandidates);
+ Set<Long> candidateEntityIds = candidates.stream()
+ .map(EntityMonitorBindingCandidate::getEntityId)
+ .collect(Collectors.toCollection(LinkedHashSet::new));
+ Set<Long> boundEntityIds = entityMonitorBindService.findMonitorBindsByMonitorId(monitorId).stream()
+ .map(EntityMonitorBind::getEntityId)
+ .filter(java.util.Objects::nonNull)
+ .collect(Collectors.toCollection(LinkedHashSet::new));
+ boundEntityIds.removeAll(candidateEntityIds);
+ if (CollectionUtils.isEmpty(boundEntityIds)) {
+ return candidates;
+ }
+ Map<Long, ObserveEntity> entities = entityWorkspaceAccessService
+ .findAccessibleEntitiesByIdsForRequestWorkspace(boundEntityIds)
+ .stream()
+ .collect(Collectors.toMap(ObserveEntity::getId, item -> item, (left, right) -> left));
+ for (Long entityId : boundEntityIds) {
+ ObserveEntity entity = entities.get(entityId);
+ if (entity == null) {
+ continue;
+ }
+ candidates.add(new EntityMonitorBindingCandidate(
+ entity.getId(),
+ entity.getDisplayName() == null ? entity.getName() : entity.getDisplayName(),
+ entity.getType(),
+ 0,
+ RECOMMEND_ALREADY_BOUND,
+ true,
+ Collections.emptyMap()));
+ }
+ return candidates;
}
}
diff --git a/hertzbeat-manager/src/main/java/org/apache/hertzbeat/manager/service/entity/EntityListReadModelService.java b/hertzbeat-manager/src/main/java/org/apache/hertzbeat/manager/service/entity/EntityListReadModelService.java
index ef6d77623c..8948c10fa4 100644
--- a/hertzbeat-manager/src/main/java/org/apache/hertzbeat/manager/service/entity/EntityListReadModelService.java
+++ b/hertzbeat-manager/src/main/java/org/apache/hertzbeat/manager/service/entity/EntityListReadModelService.java
@@ -34,6 +34,9 @@ import org.springframework.stereotype.Service;
@Service
public class EntityListReadModelService {
+ static final int DEFAULT_ENTITY_LIST_PAGE_SIZE = 8;
+ static final int MAX_ENTITY_LIST_PAGE_SIZE = 50;
+
private final EntityCatalogQueryService entityCatalogQueryService;
private final EntityStatusRefreshService entityStatusRefreshService;
private final EntitySummaryReadModelService entitySummaryReadModelService;
@@ -50,9 +53,11 @@ public class EntityListReadModelService {
String owner, String source, String environment, String lifecycle,
String tier, String system, String sort, String order,
int pageIndex, int pageSize) {
+ int safePageIndex = normalizePageIndex(pageIndex);
+ int safePageSize = normalizePageSize(pageSize);
Page<ObserveEntity> entityPage = entityCatalogQueryService.findEntityPage(
entityIds, type, status, search, owner, source, environment, lifecycle, tier, system,
- sort, order, pageIndex, pageSize);
+ sort, order, safePageIndex, safePageSize);
return buildEntitySummaryPage(entityPage, null, false);
}
@@ -60,42 +65,51 @@ public class EntityListReadModelService {
String owner, String source, String environment, String lifecycle,
String tier, String system, String sort, String order,
int pageIndex, int pageSize, String requestWorkspaceId) {
+ int safePageIndex = normalizePageIndex(pageIndex);
+ int safePageSize = normalizePageSize(pageSize);
Page<ObserveEntity> entityPage = entityCatalogQueryService.findEntityPage(
entityIds, type, status, search, owner, source, environment, lifecycle, tier, system,
- sort, order, pageIndex, pageSize, requestWorkspaceId);
+ sort, order, safePageIndex, safePageSize, requestWorkspaceId);
return buildEntitySummaryPage(entityPage, requestWorkspaceId, true);
}
+ private int normalizePageIndex(int pageIndex) {
+ return Math.max(0, pageIndex);
+ }
+
+ private int normalizePageSize(int pageSize) {
+ if (pageSize <= 0) {
+ return DEFAULT_ENTITY_LIST_PAGE_SIZE;
+ }
+ return Math.min(pageSize, MAX_ENTITY_LIST_PAGE_SIZE);
+ }
+
private Page<EntitySummaryInfo> buildEntitySummaryPage(Page<ObserveEntity> entityPage,
String requestWorkspaceId,
boolean explicitWorkspace) {
List<ObserveEntity> scopedEntities = entityPage.getContent();
Map<Long, EntityDefinitionActivity> latestActivityMap =
entitySummaryReadModelService.loadLatestDefinitionActivities(scopedEntities);
+ Map<Long, EntitySummaryReadModelService.EntitySummaryCounts> summaryCountsMap =
+ entitySummaryReadModelService.loadSummaryCounts(scopedEntities);
+ Map<Long, EntityStatusRefreshService.EntityRuntimeStatusEvidence> runtimeEvidenceMap =
+ explicitWorkspace
+ ? entityStatusRefreshService.refreshEntityStatusesWithEvidence(scopedEntities, requestWorkspaceId)
+ : entityStatusRefreshService.refreshEntityStatusesWithEvidence(scopedEntities);
List<EntitySummaryInfo> summaries = scopedEntities.stream()
- .map(entity -> explicitWorkspace
- ? buildSummary(entity, latestActivityMap.get(entity.getId()), requestWorkspaceId)
- : buildSummary(entity, latestActivityMap.get(entity.getId())))
+ .map(entity -> buildSummary(entity, latestActivityMap.get(entity.getId()),
+ summaryCountsMap.get(entity.getId()), runtimeEvidenceMap.get(entity.getId())))
.toList();
return new PageImpl<>(summaries, entityPage.getPageable(), entityPage.getTotalElements());
}
- private EntitySummaryInfo buildSummary(ObserveEntity entity,
- EntityDefinitionActivity latestActivity) {
- EntityStatusRefreshService.EntityRuntimeStatusEvidence runtimeEvidence =
- entityStatusRefreshService.refreshEntityStatusWithEvidence(entity);
- List<Monitor> monitors = runtimeEvidence.monitors();
- EntityStatusInfo statusInfo = runtimeEvidence.statusInfo();
- return entitySummaryReadModelService.buildEntitySummary(entity, latestActivity, statusInfo, monitors);
- }
-
private EntitySummaryInfo buildSummary(ObserveEntity entity,
EntityDefinitionActivity latestActivity,
- String requestWorkspaceId) {
- EntityStatusRefreshService.EntityRuntimeStatusEvidence runtimeEvidence =
- entityStatusRefreshService.refreshEntityStatusWithEvidence(entity, requestWorkspaceId);
+ EntitySummaryReadModelService.EntitySummaryCounts summaryCounts,
+ EntityStatusRefreshService.EntityRuntimeStatusEvidence runtimeEvidence) {
List<Monitor> monitors = runtimeEvidence.monitors();
EntityStatusInfo statusInfo = runtimeEvidence.statusInfo();
- return entitySummaryReadModelService.buildEntitySummary(entity, latestActivity, statusInfo, monitors);
+ return entitySummaryReadModelService.buildEntitySummary(entity, latestActivity, statusInfo, monitors,
+ summaryCounts);
}
}
diff --git a/hertzbeat-manager/src/main/java/org/apache/hertzbeat/manager/service/entity/EntityMonitorBindQueryService.java b/hertzbeat-manager/src/main/java/org/apache/hertzbeat/manager/service/entity/EntityMonitorBindQueryService.java
index 824e467683..b1ebd016c0 100644
--- a/hertzbeat-manager/src/main/java/org/apache/hertzbeat/manager/service/entity/EntityMonitorBindQueryService.java
+++ b/hertzbeat-manager/src/main/java/org/apache/hertzbeat/manager/service/entity/EntityMonitorBindQueryService.java
@@ -18,10 +18,15 @@
package org.apache.hertzbeat.manager.service.entity;
import java.util.Collections;
+import java.util.LinkedHashMap;
import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.stream.Collectors;
import org.apache.hertzbeat.common.entity.manager.EntityMonitorBind;
import org.apache.hertzbeat.manager.dao.EntityMonitorBindDao;
import org.springframework.stereotype.Service;
+import org.springframework.util.CollectionUtils;
/**
* Owns persisted monitor-bind evidence lookup for entity read-model boundaries.
@@ -39,6 +44,29 @@ public class EntityMonitorBindQueryService {
return entityMonitorBindDao.findAllByEntityIdOrderByIdAsc(entityId);
}
+ public Map<Long, List<EntityMonitorBind>> findMonitorBindsByEntityIds(List<Long> entityIds) {
+ if (CollectionUtils.isEmpty(entityIds)) {
+ return Map.of();
+ }
+ List<Long> acceptedEntityIds = entityIds.stream()
+ .filter(Objects::nonNull)
+ .distinct()
+ .toList();
+ if (CollectionUtils.isEmpty(acceptedEntityIds)) {
+ return Map.of();
+ }
+ Map<Long, List<EntityMonitorBind>> bindsByEntityId = new LinkedHashMap<>();
+ entityMonitorBindDao.findAllByEntityIdInOrderByEntityIdAscIdAsc(acceptedEntityIds)
+ .forEach(bind -> {
+ if (bind == null || bind.getEntityId() == null) {
+ return;
+ }
+ bindsByEntityId.computeIfAbsent(bind.getEntityId(), ignored -> new java.util.ArrayList<>())
+ .add(bind);
+ });
+ return bindsByEntityId;
+ }
+
public List<EntityMonitorBind> findMonitorBindsByMonitorId(Long monitorId) {
if (monitorId == null) {
return Collections.emptyList();
@@ -49,4 +77,16 @@ public class EntityMonitorBindQueryService {
public long countMonitorBinds(Long entityId) {
return entityMonitorBindDao.countByEntityId(entityId);
}
+
+ public Map<Long, Long> countMonitorBindsByEntityIds(List<Long> entityIds) {
+ if (CollectionUtils.isEmpty(entityIds)) {
+ return Map.of();
+ }
+ return entityMonitorBindDao.countByEntityIdInGroupByEntityId(entityIds)
+ .stream()
+ .collect(Collectors.toMap(
+ row -> (Long) row[0],
+ row -> ((Number) row[1]).longValue()
+ ));
+ }
}
diff --git a/hertzbeat-manager/src/main/java/org/apache/hertzbeat/manager/service/entity/EntityMonitorBindService.java b/hertzbeat-manager/src/main/java/org/apache/hertzbeat/manager/service/entity/EntityMonitorBindService.java
index 6358af3429..51843e5532 100644
--- a/hertzbeat-manager/src/main/java/org/apache/hertzbeat/manager/service/entity/EntityMonitorBindService.java
+++ b/hertzbeat-manager/src/main/java/org/apache/hertzbeat/manager/service/entity/EntityMonitorBindService.java
@@ -75,13 +75,22 @@ public class EntityMonitorBindService {
return entityMonitorBindQueryService.countMonitorBinds(entityId);
}
+ public Map<Long, Long> countMonitorBindsByEntityIds(List<Long> entityIds) {
+ return entityMonitorBindQueryService.countMonitorBindsByEntityIds(entityIds);
+ }
+
public void replaceMonitorBinds(Long entityId, List<EntityMonitorBind> monitorBinds) {
List<EntityMonitorBind> rows = new ArrayList<>();
+ Set<Long> acceptedMonitorIds = new LinkedHashSet<>();
if (!CollectionUtils.isEmpty(monitorBinds)) {
for (EntityMonitorBind bind : monitorBinds) {
if (bind == null || !entityMonitorQueryService.monitorExists(bind.getMonitorId())) {
continue;
}
+ if (!acceptedMonitorIds.add(bind.getMonitorId())) {
+ continue;
+ }
+ requireMonitorAvailableForEntity(entityId, bind.getMonitorId());
rows.add(EntityMonitorBind.builder()
.entityId(entityId)
.monitorId(bind.getMonitorId())
@@ -96,6 +105,16 @@ public class EntityMonitorBindService {
entityMonitorBindWriteModelService.replaceMonitorBinds(entityId, rows);
}
+ private void requireMonitorAvailableForEntity(Long entityId, Long monitorId) {
+ List<EntityMonitorBind> existingBinds = entityMonitorBindQueryService.findMonitorBindsByMonitorId(monitorId);
+ for (EntityMonitorBind existingBind : existingBinds) {
+ if (existingBind == null || Objects.equals(entityId, existingBind.getEntityId())) {
+ continue;
+ }
+ throw new IllegalArgumentException("Monitor already bound to another entity: " + monitorId + ".");
+ }
+ }
+
public void replaceAutoMonitorBinds(Long monitorId, List<EntityMonitorBindingCandidate> candidates) {
if (monitorId == null) {
return;
@@ -129,6 +148,47 @@ public class EntityMonitorBindService {
public List<Monitor> findEntityMonitors(Long entityId) {
List<EntityMonitorBind> binds = entityMonitorBindQueryService.findMonitorBinds(entityId);
+ return resolveMonitorsInBindOrder(binds);
+ }
+
+ public Map<Long, List<Monitor>> findEntityMonitorsByEntityIds(List<Long> entityIds) {
+ if (CollectionUtils.isEmpty(entityIds)) {
+ return Collections.emptyMap();
+ }
+ Map<Long, List<EntityMonitorBind>> bindsByEntityId =
+ entityMonitorBindQueryService.findMonitorBindsByEntityIds(entityIds);
+ Set<Long> monitorIds = bindsByEntityId.values()
+ .stream()
+ .flatMap(List::stream)
+ .map(EntityMonitorBind::getMonitorId)
+ .filter(Objects::nonNull)
+ .collect(Collectors.toCollection(LinkedHashSet::new));
+ if (monitorIds.isEmpty()) {
+ return Collections.emptyMap();
+ }
+ Map<Long, Monitor> monitorMap = entityMonitorQueryService.findMonitorsByIds(monitorIds).stream()
+ .collect(Collectors.toMap(Monitor::getId, item -> item, (left, right) -> left));
+ Map<Long, List<Monitor>> monitorsByEntityId = new java.util.LinkedHashMap<>();
+ for (Long entityId : entityIds) {
+ List<EntityMonitorBind> binds = bindsByEntityId.get(entityId);
+ if (CollectionUtils.isEmpty(binds)) {
+ continue;
+ }
+ List<Monitor> monitors = new ArrayList<>();
+ for (EntityMonitorBind bind : binds) {
+ Monitor monitor = monitorMap.get(bind.getMonitorId());
+ if (monitor != null) {
+ monitors.add(monitor);
+ }
+ }
+ if (!monitors.isEmpty()) {
+ monitorsByEntityId.put(entityId, monitors);
+ }
+ }
+ return monitorsByEntityId;
+ }
+
+ private List<Monitor> resolveMonitorsInBindOrder(List<EntityMonitorBind> binds) {
if (CollectionUtils.isEmpty(binds)) {
return Collections.emptyList();
}
diff --git a/hertzbeat-manager/src/main/java/org/apache/hertzbeat/manager/service/entity/EntityMutationWorkflowService.java b/hertzbeat-manager/src/main/java/org/apache/hertzbeat/manager/service/entity/EntityMutationWorkflowService.java
index c0015577f4..5c1e25aa79 100644
--- a/hertzbeat-manager/src/main/java/org/apache/hertzbeat/manager/service/entity/EntityMutationWorkflowService.java
+++ b/hertzbeat-manager/src/main/java/org/apache/hertzbeat/manager/service/entity/EntityMutationWorkflowService.java
@@ -18,12 +18,23 @@
package org.apache.hertzbeat.manager.service.entity;
import java.util.Collections;
+import java.util.HashSet;
import java.util.List;
+import java.util.Locale;
+import java.util.Set;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.ConcurrentMap;
+import java.util.concurrent.locks.ReentrantLock;
+import java.util.function.Supplier;
+import org.apache.hertzbeat.common.entity.manager.EntityIdentity;
import org.apache.hertzbeat.common.entity.manager.ObserveEntity;
import org.apache.hertzbeat.manager.pojo.dto.EntityDefinitionRequest;
import org.apache.hertzbeat.manager.pojo.dto.EntityDto;
import org.springframework.stereotype.Service;
+import org.springframework.transaction.support.TransactionSynchronization;
+import org.springframework.transaction.support.TransactionSynchronizationManager;
import org.springframework.util.CollectionUtils;
+import org.springframework.util.StringUtils;
/**
* Coordinates entity write workflows while dedicated components own each write stage.
@@ -35,6 +46,8 @@ public class EntityMutationWorkflowService {
private static final String ACTIVITY_TYPE_DEFINITION_IMPORT = "definition_import";
private static final String ACTIVITY_TYPE_DEFINITION_UPDATE = "definition_update";
+ private final ConcurrentMap<String, ReentrantLock> mutationReferenceLocks = new ConcurrentHashMap<>();
+
private final EntityCoreWriteModelService entityCoreWriteModelService;
private final EntityIdentityWriteModelService entityIdentityWriteModelService;
private final EntityMonitorBindService entityMonitorBindService;
@@ -66,7 +79,10 @@ public class EntityMutationWorkflowService {
}
public long addEntity(EntityDto entityDto) {
- return addEntity(entityDto, true);
+ return withMutationReferenceLocks(List.of(entityDto), () -> {
+ requireNewEntityReferences(List.of(entityDto));
+ return addEntity(entityDto, true);
+ });
}
public void modifyEntity(EntityDto entityDto) {
@@ -76,13 +92,16 @@ public class EntityMutationWorkflowService {
public long addEntityByDefinition(EntityDefinitionRequest definitionRequest) {
EntityDto entityDto = entityDefinitionDraftService.parseEntityDefinition(definitionRequest, null);
entityValidationService.validate(entityDto, false);
- long entityId = addEntity(entityDto, false);
- entityActivityWriteModelService.recordDefinitionActivity(
- entityId,
- ACTIVITY_TYPE_DEFINITION_IMPORT,
- definitionRequest == null ? null : definitionRequest.getFormat(),
- entityDto.getEntity());
- return entityId;
+ return withMutationReferenceLocks(List.of(entityDto), () -> {
+ requireNewEntityReferences(List.of(entityDto));
+ long entityId = addEntity(entityDto, false);
+ entityActivityWriteModelService.recordDefinitionActivity(
+ entityId,
+ ACTIVITY_TYPE_DEFINITION_IMPORT,
+ definitionRequest == null ? null : definitionRequest.getFormat(),
+ entityDto.getEntity());
+ return entityId;
+ });
}
public List<Long> addEntitiesByDefinitionBundle(EntityDefinitionRequest definitionRequest) {
@@ -93,31 +112,158 @@ public class EntityMutationWorkflowService {
if (entityDtos.size() == 1) {
EntityDto entityDto = entityDtos.getFirst();
entityValidationService.validate(entityDto, false);
- return List.of(addEntity(entityDto));
+ return withMutationReferenceLocks(List.of(entityDto), () -> {
+ requireNewEntityReferences(List.of(entityDto));
+ return List.of(addEntity(entityDto));
+ });
}
for (EntityDto entityDto : entityDtos) {
entityValidationService.validate(entityDto, false);
}
- List<ObserveEntity> entities = entityCoreWriteModelService.createEntities(
- entityDtos.stream().map(EntityDto::getEntity).toList(), SOURCE_MANUAL);
- for (int index = 0; index < entities.size(); index++) {
- ObserveEntity entity = entities.get(index);
- EntityDto entityDto = entityDtos.get(index);
- entityIdentityWriteModelService.replaceIdentities(entity, entityDto.getIdentities());
- entityMonitorBindService.replaceMonitorBinds(entity.getId(), entityDto.getMonitorBinds());
+ return withMutationReferenceLocks(entityDtos, () -> {
+ requireNewEntityReferences(entityDtos);
+ List<ObserveEntity> entities = entityCoreWriteModelService.createEntities(
+ entityDtos.stream().map(EntityDto::getEntity).toList(), SOURCE_MANUAL);
+ for (int index = 0; index < entities.size(); index++) {
+ ObserveEntity entity = entities.get(index);
+ EntityDto entityDto = entityDtos.get(index);
+ entityIdentityWriteModelService.replaceIdentities(entity, entityDto.getIdentities());
+ entityMonitorBindService.replaceMonitorBinds(entity.getId(), entityDto.getMonitorBinds());
+ }
+ for (int index = 0; index < entities.size(); index++) {
+ ObserveEntity entity = entities.get(index);
+ EntityDto entityDto = entityDtos.get(index);
+ entityRelationService.replaceRelations(entity.getId(), entityDto.getRelations());
+ entityStatusRefreshService.refreshEntityStatus(entity);
+ entityActivityWriteModelService.recordDefinitionActivity(
+ entity.getId(),
+ ACTIVITY_TYPE_DEFINITION_IMPORT,
+ definitionRequest == null ? null : definitionRequest.getFormat(),
+ entity);
+ }
+ return entities.stream().map(ObserveEntity::getId).toList();
+ });
+ }
+
+ private <T> T withMutationReferenceLocks(List<EntityDto> entityDtos, Supplier<T> supplier) {
+ List<String> lockKeys = mutationLockKeys(entityDtos);
+ if (lockKeys.isEmpty()) {
+ return supplier.get();
+ }
+ List<ReentrantLock> locks = lockKeys.stream()
+ .map(key -> mutationReferenceLocks.computeIfAbsent(key, ignored -> new ReentrantLock()))
+ .toList();
+ locks.forEach(ReentrantLock::lock);
+ boolean releaseAfterTransaction = TransactionSynchronizationManager.isSynchronizationActive();
+ if (releaseAfterTransaction) {
+ TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
+ @Override
+ public void afterCompletion(int status) {
+ releaseMutationLocks(lockKeys, locks);
+ }
+ });
}
- for (int index = 0; index < entities.size(); index++) {
- ObserveEntity entity = entities.get(index);
- EntityDto entityDto = entityDtos.get(index);
- entityRelationService.replaceRelations(entity.getId(), entityDto.getRelations());
- entityStatusRefreshService.refreshEntityStatus(entity);
- entityActivityWriteModelService.recordDefinitionActivity(
- entity.getId(),
- ACTIVITY_TYPE_DEFINITION_IMPORT,
- definitionRequest == null ? null : definitionRequest.getFormat(),
- entity);
+ try {
+ return supplier.get();
+ } finally {
+ if (!releaseAfterTransaction) {
+ releaseMutationLocks(lockKeys, locks);
+ }
+ }
+ }
+
+ private void releaseMutationLocks(List<String> lockKeys, List<ReentrantLock> locks) {
+ for (int index = locks.size() - 1; index >= 0; index--) {
+ ReentrantLock lock = locks.get(index);
+ String lockKey = lockKeys.get(index);
+ try {
+ lock.unlock();
+ } finally {
+ if (!lock.isLocked() && !lock.hasQueuedThreads()) {
+ mutationReferenceLocks.remove(lockKey, lock);
+ }
+ }
+ }
+ }
+
+ private List<String> mutationLockKeys(List<EntityDto> entityDtos) {
+ Set<String> lockKeys = new HashSet<>();
+ for (EntityDto entityDto : entityDtos) {
+ if (entityDto == null) {
+ continue;
+ }
+ ObserveEntity entity = entityDto.getEntity();
+ if (entity != null && StringUtils.hasText(entity.getType()) && StringUtils.hasText(entity.getName())) {
+ lockKeys.add("entity:" + referenceKey(entity));
+ }
+ if (entity != null && StringUtils.hasText(entity.getName())) {
+ lockKeys.add("entity-name:" + normalizeReferencePart(entity.getName()));
+ }
+ if (!CollectionUtils.isEmpty(entityDto.getIdentities())) {
+ for (EntityIdentity identity : entityDto.getIdentities()) {
+ if (identity == null
+ || !identity.isPrimaryIdentity()
+ || !StringUtils.hasText(identity.getIdentityKey())) {
+ continue;
+ }
+ String identityValue = StringUtils.hasText(identity.getNormalizedValue())
+ ? identity.getNormalizedValue()
+ : identity.getIdentityValue();
+ if (StringUtils.hasText(identityValue)) {
+ lockKeys.add("primary-identity:"
+ + normalizeReferencePart(identity.getIdentityKey())
+ + '\n' + normalizeReferencePart(identityValue));
+ }
+ }
+ }
+ }
+ return lockKeys.stream().sorted().toList();
+ }
+
+ private void requireNewEntityReferences(List<EntityDto> entityDtos) {
+ Set<String> submittedReferences = new HashSet<>();
+ Set<String> submittedNames = new HashSet<>();
+ for (EntityDto entityDto : entityDtos) {
+ ObserveEntity entity = entityDto.getEntity();
+ if (entity == null || !StringUtils.hasText(entity.getType()) || !StringUtils.hasText(entity.getName())) {
+ continue;
+ }
+ String entityName = entity.getName().trim();
+ String nameKey = normalizeReferencePart(entityName);
+ if (!submittedNames.add(nameKey)) {
+ throw new IllegalArgumentException("Entity already exists in definition bundle: " + entityName + ".");
+ }
+ if (entityWorkspaceAccessService.findAccessibleEntityByNameForRequestWorkspace(entityName).isPresent()) {
+ throw new IllegalArgumentException("Entity already exists: " + entityName + ".");
+ }
+ String referenceKey = referenceKey(entity);
+ if (!submittedReferences.add(referenceKey)) {
+ throw new IllegalArgumentException("Entity already exists in definition bundle: " + displayReference(entity) + ".");
+ }
+ boolean exists = StringUtils.hasText(entity.getNamespace())
+ ? entityWorkspaceAccessService.findAccessibleEntityByReferenceForRequestWorkspace(
+ entity.getType(), entity.getNamespace(), entity.getName()).isPresent()
+ : entityWorkspaceAccessService.findAccessibleEntityByReferenceForRequestWorkspace(
+ entity.getType(), entity.getName()).isPresent();
+ if (exists) {
+ throw new IllegalArgumentException("Entity already exists: " + displayReference(entity) + ".");
+ }
}
- return entities.stream().map(ObserveEntity::getId).toList();
+ }
+
+ private String referenceKey(ObserveEntity entity) {
+ return normalizeReferencePart(entity.getType())
+ + '\n' + normalizeReferencePart(entity.getNamespace())
+ + '\n' + normalizeReferencePart(entity.getName());
+ }
+
+ private String displayReference(ObserveEntity entity) {
+ String namespacePrefix = StringUtils.hasText(entity.getNamespace()) ? entity.getNamespace().trim() + "/" : "";
+ return entity.getType().trim() + " " + namespacePrefix + entity.getName().trim();
+ }
+
+ private String normalizeReferencePart(String value) {
+ return StringUtils.hasText(value) ? value.trim().toLowerCase(Locale.ROOT) : "";
}
public void modifyEntityByDefinition(long entityId, EntityDefinitionRequest definitionRequest) {
diff --git a/hertzbeat-manager/src/main/java/org/apache/hertzbeat/manager/service/entity/EntityNoiseControlRuleQueryService.java b/hertzbeat-manager/src/main/java/org/apache/hertzbeat/manager/service/entity/EntityNoiseControlRuleQueryService.java
index eb1059d710..9d2046c63b 100644
--- a/hertzbeat-manager/src/main/java/org/apache/hertzbeat/manager/service/entity/EntityNoiseControlRuleQueryService.java
+++ b/hertzbeat-manager/src/main/java/org/apache/hertzbeat/manager/service/entity/EntityNoiseControlRuleQueryService.java
@@ -51,15 +51,13 @@ public class EntityNoiseControlRuleQueryService {
}
public List<AlertSilence> findEnabledSilences(String requestWorkspaceId) {
- return alertSilenceDao.findAll().stream()
- .filter(rule -> Boolean.TRUE.equals(rule.isEnable()))
+ return alertSilenceDao.findAlertSilencesByEnableTrue().stream()
.filter(rule -> matchesSilenceRuleRequestWorkspace(rule, requestWorkspaceId))
.toList();
}
public List<AlertInhibit> findEnabledInhibits(String requestWorkspaceId) {
- return alertInhibitDao.findAll().stream()
- .filter(rule -> Boolean.TRUE.equals(rule.getEnable()))
+ return alertInhibitDao.findAlertInhibitsByEnableIsTrue().stream()
.filter(rule -> matchesInhibitRuleRequestWorkspace(rule, requestWorkspaceId))
.toList();
}
diff --git a/hertzbeat-manager/src/main/java/org/apache/hertzbeat/manager/service/entity/EntityRelationQueryService.java b/hertzbeat-manager/src/main/java/org/apache/hertzbeat/manager/service/entity/EntityRelationQueryService.java
index 1ff5f42eac..e03227591a 100644
--- a/hertzbeat-manager/src/main/java/org/apache/hertzbeat/manager/service/entity/EntityRelationQueryService.java
+++ b/hertzbeat-manager/src/main/java/org/apache/hertzbeat/manager/service/entity/EntityRelationQueryService.java
@@ -18,9 +18,14 @@
package org.apache.hertzbeat.manager.service.entity;
import java.util.List;
+import java.util.LinkedHashMap;
+import java.util.Map;
import org.apache.hertzbeat.common.entity.manager.EntityRelation;
import org.apache.hertzbeat.manager.dao.EntityRelationDao;
+import org.springframework.data.domain.PageRequest;
+import org.springframework.data.domain.Sort;
import org.springframework.stereotype.Service;
+import org.springframework.util.CollectionUtils;
/**
* Owns persisted relation evidence lookup for entity read-model boundaries.
@@ -38,7 +43,36 @@ public class EntityRelationQueryService {
return entityRelationDao.findBySourceEntityIdOrTargetEntityId(entityId, entityId);
}
+ public List<EntityRelation> findEntityRelations(Long entityId, int limit) {
+ if (limit <= 0) {
+ return List.of();
+ }
+ return entityRelationDao.findBySourceEntityIdOrTargetEntityId(
+ entityId, entityId, PageRequest.of(0, limit, Sort.by(Sort.Direction.DESC, "id")));
+ }
+
public long countEntityRelations(Long entityId) {
return entityRelationDao.countBySourceEntityIdOrTargetEntityId(entityId, entityId);
}
+
+ public Map<Long, Long> countEntityRelationsByEntityIds(List<Long> entityIds) {
+ if (CollectionUtils.isEmpty(entityIds)) {
+ return Map.of();
+ }
+ Map<Long, Long> counts = new LinkedHashMap<>();
+ mergeCounts(counts, entityRelationDao.countBySourceEntityIdInGroupBySourceEntityId(entityIds));
+ mergeCounts(counts, entityRelationDao.countByTargetEntityIdInGroupByTargetEntityId(entityIds));
+ return counts;
+ }
+
+ private void mergeCounts(Map<Long, Long> counts, List<Object[]> rows) {
+ if (CollectionUtils.isEmpty(rows)) {
+ return;
+ }
+ for (Object[] row : rows) {
+ Long entityId = (Long) row[0];
+ long count = ((Number) row[1]).longValue();
+ counts.merge(entityId, count, Long::sum);
+ }
+ }
}
diff --git a/hertzbeat-manager/src/main/java/org/apache/hertzbeat/manager/service/entity/EntityRelationService.java b/hertzbeat-manager/src/main/java/org/apache/hertzbeat/manager/service/entity/EntityRelationService.java
index 1df37194df..b970f2defa 100644
--- a/hertzbeat-manager/src/main/java/org/apache/hertzbeat/manager/service/entity/EntityRelationService.java
+++ b/hertzbeat-manager/src/main/java/org/apache/hertzbeat/manager/service/entity/EntityRelationService.java
@@ -20,6 +20,7 @@ package org.apache.hertzbeat.manager.service.entity;
import java.util.ArrayList;
import java.util.LinkedHashSet;
import java.util.List;
+import java.util.Map;
import java.util.Objects;
import java.util.Set;
import org.apache.hertzbeat.common.entity.manager.EntityRelation;
@@ -71,10 +72,18 @@ public class EntityRelationService {
return entityRelationQueryService.findEntityRelations(entityId);
}
+ public List<EntityRelation> findEntityRelations(Long entityId, int limit) {
+ return entityRelationQueryService.findEntityRelations(entityId, limit);
+ }
+
public long countEntityRelations(Long entityId) {
return entityRelationQueryService.countEntityRelations(entityId);
}
+ public Map<Long, Long> countEntityRelationsByEntityIds(List<Long> entityIds) {
+ return entityRelationQueryService.countEntityRelationsByEntityIds(entityIds);
+ }
+
public void replaceRelations(Long entityId, List<EntityRelation> relations, String requestWorkspaceId) {
replaceRelationsInternal(entityId, relations, requestWorkspaceId, false);
}
diff --git a/hertzbeat-manager/src/main/java/org/apache/hertzbeat/manager/service/entity/EntityStatusRefreshService.java b/hertzbeat-manager/src/main/java/org/apache/hertzbeat/manager/service/entity/EntityStatusRefreshService.java
index e48c19451a..ffd027a0bc 100644
--- a/hertzbeat-manager/src/main/java/org/apache/hertzbeat/manager/service/entity/EntityStatusRefreshService.java
+++ b/hertzbeat-manager/src/main/java/org/apache/hertzbeat/manager/service/entity/EntityStatusRefreshService.java
@@ -17,12 +17,17 @@
package org.apache.hertzbeat.manager.service.entity;
+import java.util.Collections;
+import java.util.LinkedHashMap;
import java.util.List;
+import java.util.Map;
+import java.util.Objects;
import org.apache.hertzbeat.common.entity.alerter.SingleAlert;
import org.apache.hertzbeat.common.entity.manager.Monitor;
import org.apache.hertzbeat.common.entity.manager.ObserveEntity;
import org.apache.hertzbeat.common.observability.dto.entity.EntityStatusInfo;
import org.springframework.stereotype.Service;
+import org.springframework.util.CollectionUtils;
/**
* Refreshes entity runtime status from current monitor bindings and alert evidence.
@@ -54,20 +59,64 @@ public class EntityStatusRefreshService {
public EntityRuntimeStatusEvidence refreshEntityStatusWithEvidence(ObserveEntity entity) {
List<Monitor> monitors = entityMonitorBindService.findEntityMonitors(entity.getId());
- List<SingleAlert> activeAlerts = entityAlertEvidenceReadModelService.queryActiveAlerts(
- monitors, ACTIVE_ALERT_LIMIT);
- EntityStatusInfo statusInfo = entityRuntimeHealthService.refreshEntityStatus(entity, monitors, activeAlerts);
- return new EntityRuntimeStatusEvidence(monitors, activeAlerts, statusInfo);
+ return refreshEntityStatusWithEvidence(entity, monitors, null, false);
}
public EntityRuntimeStatusEvidence refreshEntityStatusWithEvidence(ObserveEntity entity, String requestWorkspaceId) {
List<Monitor> monitors = entityMonitorBindService.findEntityMonitors(entity.getId());
- List<SingleAlert> activeAlerts = entityAlertEvidenceReadModelService.queryActiveAlerts(
- monitors, ACTIVE_ALERT_LIMIT, requestWorkspaceId);
+ return refreshEntityStatusWithEvidence(entity, monitors, requestWorkspaceId, true);
+ }
+
+ public Map<Long, EntityRuntimeStatusEvidence> refreshEntityStatusesWithEvidence(List<ObserveEntity> entities) {
+ return refreshEntityStatusesWithEvidence(entities, null, false);
+ }
+
+ public Map<Long, EntityRuntimeStatusEvidence> refreshEntityStatusesWithEvidence(List<ObserveEntity> entities,
+ String requestWorkspaceId) {
+ return refreshEntityStatusesWithEvidence(entities, requestWorkspaceId, true);
+ }
+
+ private Map<Long, EntityRuntimeStatusEvidence> refreshEntityStatusesWithEvidence(List<ObserveEntity> entities,
+ String requestWorkspaceId,
+ boolean explicitWorkspace) {
+ if (CollectionUtils.isEmpty(entities)) {
+ return Collections.emptyMap();
+ }
+ List<Long> entityIds = entities.stream()
+ .map(ObserveEntity::getId)
+ .filter(Objects::nonNull)
+ .toList();
+ Map<Long, List<Monitor>> monitorsByEntityId = entityMonitorBindService.findEntityMonitorsByEntityIds(entityIds);
+ Map<Long, EntityRuntimeStatusEvidence> evidenceByEntityId = new LinkedHashMap<>();
+ for (ObserveEntity entity : entities) {
+ if (entity == null || entity.getId() == null) {
+ continue;
+ }
+ List<Monitor> monitors = monitorsByEntityId.getOrDefault(entity.getId(), Collections.emptyList());
+ evidenceByEntityId.put(entity.getId(),
+ refreshEntityStatusWithEvidence(entity, monitors, requestWorkspaceId, explicitWorkspace));
+ }
+ return evidenceByEntityId;
+ }
+
+ private EntityRuntimeStatusEvidence refreshEntityStatusWithEvidence(ObserveEntity entity,
+ List<Monitor> monitors,
+ String requestWorkspaceId,
+ boolean explicitWorkspace) {
+ List<SingleAlert> activeAlerts = CollectionUtils.isEmpty(monitors)
+ ? Collections.emptyList()
+ : queryActiveAlerts(monitors, requestWorkspaceId, explicitWorkspace);
EntityStatusInfo statusInfo = entityRuntimeHealthService.refreshEntityStatus(entity, monitors, activeAlerts);
return new EntityRuntimeStatusEvidence(monitors, activeAlerts, statusInfo);
}
+ private List<SingleAlert> queryActiveAlerts(List<Monitor> monitors, String requestWorkspaceId,
+ boolean explicitWorkspace) {
+ return explicitWorkspace
+ ? entityAlertEvidenceReadModelService.queryActiveAlerts(monitors, ACTIVE_ALERT_LIMIT, requestWorkspaceId)
+ : entityAlertEvidenceReadModelService.queryActiveAlerts(monitors, ACTIVE_ALERT_LIMIT);
+ }
+
/**
* Runtime status evidence collected during a refresh operation.
*/
diff --git a/hertzbeat-manager/src/main/java/org/apache/hertzbeat/manager/service/entity/EntitySummaryReadModelService.java b/hertzbeat-manager/src/main/java/org/apache/hertzbeat/manager/service/entity/EntitySummaryReadModelService.java
index e4825a616d..71d5ff1ba8 100644
--- a/hertzbeat-manager/src/main/java/org/apache/hertzbeat/manager/service/entity/EntitySummaryReadModelService.java
+++ b/hertzbeat-manager/src/main/java/org/apache/hertzbeat/manager/service/entity/EntitySummaryReadModelService.java
@@ -62,29 +62,45 @@ public class EntitySummaryReadModelService {
EntityDefinitionActivity latestDefinitionActivity,
EntityStatusInfo statusInfo,
List<Monitor> monitors) {
+ return buildEntitySummary(
+ entity,
+ latestDefinitionActivity,
+ statusInfo,
+ monitors,
+ new EntitySummaryCounts(
+ entityIdentityReadModelService.countIdentities(entity.getId()),
+ entityMonitorBindService.countMonitorBinds(entity.getId()),
+ entityRelationService.countEntityRelations(entity.getId())
+ )
+ );
+ }
+
+ public EntitySummaryInfo buildEntitySummary(ObserveEntity entity,
+ EntityDefinitionActivity latestDefinitionActivity,
+ EntityStatusInfo statusInfo,
+ List<Monitor> monitors,
+ EntitySummaryCounts counts) {
List<Monitor> safeMonitors = CollectionUtils.isEmpty(monitors) ? Collections.emptyList() : monitors;
- long monitorBindCount = entityMonitorBindService.countMonitorBinds(entity.getId());
- long identityCount = entityIdentityReadModelService.countIdentities(entity.getId());
- long relationCount = entityRelationService.countEntityRelations(entity.getId());
+ EntitySummaryCounts safeCounts = counts == null ? EntitySummaryCounts.empty() : counts;
EntityEvidenceSummaryInfo evidenceSummary = entityObservabilityGateway.buildEntityEvidenceSummary(
entity,
statusInfo,
- identityCount,
+ safeCounts.identityCount(),
0,
safeMonitors,
Collections.emptyList()
);
EntityOpsSummaryInfo opsSummary = entityObservabilityGateway.buildEntityOpsSummary(
- entity, relationCount, evidenceSummary
+ entity, safeCounts.relationCount(), evidenceSummary
);
EntityNextActionInfo nextAction = entityObservabilityGateway.buildEntityNextActions(
entity, evidenceSummary, null, opsSummary
).stream().findFirst().orElse(null);
return new EntitySummaryInfo(
EntityInfo.fromEntity(entity),
- identityCount,
- monitorBindCount,
- relationCount,
+ safeCounts.identityCount(),
+ safeCounts.monitorBindCount(),
+ safeCounts.relationCount(),
statusInfo.getActiveAlertCount(),
statusInfo,
opsSummary,
@@ -98,6 +114,31 @@ public class EntitySummaryReadModelService {
);
}
+ public Map<Long, EntitySummaryCounts> loadSummaryCounts(List<ObserveEntity> entities) {
+ if (CollectionUtils.isEmpty(entities)) {
+ return Collections.emptyMap();
+ }
+ List<Long> entityIds = entities.stream()
+ .map(ObserveEntity::getId)
+ .filter(Objects::nonNull)
+ .toList();
+ if (CollectionUtils.isEmpty(entityIds)) {
+ return Collections.emptyMap();
+ }
+ Map<Long, Long> identityCounts = entityIdentityReadModelService.countIdentitiesByEntityIds(entityIds);
+ Map<Long, Long> monitorBindCounts = entityMonitorBindService.countMonitorBindsByEntityIds(entityIds);
+ Map<Long, Long> relationCounts = entityRelationService.countEntityRelationsByEntityIds(entityIds);
+ return entityIds.stream()
+ .collect(java.util.stream.Collectors.toMap(
+ entityId -> entityId,
+ entityId -> new EntitySummaryCounts(
+ identityCounts.getOrDefault(entityId, 0L),
+ monitorBindCounts.getOrDefault(entityId, 0L),
+ relationCounts.getOrDefault(entityId, 0L)
+ )
+ ));
+ }
+
public Map<Long, EntityDefinitionActivity> loadLatestDefinitionActivities(List<ObserveEntity> entities) {
if (CollectionUtils.isEmpty(entities)) {
return Collections.emptyMap();
@@ -111,4 +152,14 @@ public class EntitySummaryReadModelService {
}
return entityActivityReadModelService.findLatestDefinitionActivities(entityIds);
}
+
+ /**
+ * Current-page count snapshot used to avoid per-row summary count queries.
+ */
+ public record EntitySummaryCounts(long identityCount, long monitorBindCount, long relationCount) {
+
+ public static EntitySummaryCounts empty() {
+ return new EntitySummaryCounts(0, 0, 0);
+ }
+ }
}
diff --git a/hertzbeat-manager/src/main/java/org/apache/hertzbeat/manager/service/entity/EntityWorkspaceAccessService.java b/hertzbeat-manager/src/main/java/org/apache/hertzbeat/manager/service/entity/EntityWorkspaceAccessService.java
index a71d0bf0cd..40ebb05d8b 100644
--- a/hertzbeat-manager/src/main/java/org/apache/hertzbeat/manager/service/entity/EntityWorkspaceAccessService.java
+++ b/hertzbeat-manager/src/main/java/org/apache/hertzbeat/manager/service/entity/EntityWorkspaceAccessService.java
@@ -130,6 +130,16 @@ public class EntityWorkspaceAccessService {
return findAccessibleEntityByReference(currentRequestWorkspaceId(), type, name);
}
+ public Optional<ObserveEntity> findAccessibleEntityByName(String requestWorkspaceId, String name) {
+ String normalizedWorkspaceId = normalizeWorkspaceId(requestWorkspaceId);
+ Optional<ObserveEntity> entity = entityWorkspaceQueryService.findEntityByName(normalizedWorkspaceId, name);
+ return entity.filter(candidate -> matchesRequestWorkspace(candidate, normalizedWorkspaceId));
+ }
+
+ public Optional<ObserveEntity> findAccessibleEntityByNameForRequestWorkspace(String name) {
+ return findAccessibleEntityByName(currentRequestWorkspaceId(), name);
+ }
+
public boolean matchesRequestWorkspace(ObserveEntity entity, String requestWorkspaceId) {
if (!StringUtils.hasText(requestWorkspaceId)) {
return true;
diff --git a/hertzbeat-manager/src/main/java/org/apache/hertzbeat/manager/service/entity/EntityWorkspaceQueryService.java b/hertzbeat-manager/src/main/java/org/apache/hertzbeat/manager/service/entity/EntityWorkspaceQueryService.java
index 25fa541198..e3d2a52ba5 100644
--- a/hertzbeat-manager/src/main/java/org/apache/hertzbeat/manager/service/entity/EntityWorkspaceQueryService.java
+++ b/hertzbeat-manager/src/main/java/org/apache/hertzbeat/manager/service/entity/EntityWorkspaceQueryService.java
@@ -84,4 +84,11 @@ public class EntityWorkspaceQueryService {
}
return observeEntityDao.findFirstByTypeAndName(type, name);
}
+
+ public Optional<ObserveEntity> findEntityByName(String workspaceId, String name) {
+ if (StringUtils.hasText(workspaceId)) {
+ return observeEntityDao.findFirstByWorkspaceIdAndName(workspaceId, name);
+ }
+ return observeEntityDao.findFirstByName(name);
+ }
}
diff --git a/hertzbeat-manager/src/main/java/org/apache/hertzbeat/manager/service/entity/LocalTopologyDemoRelationSeeder.java b/hertzbeat-manager/src/main/java/org/apache/hertzbeat/manager/service/entity/LocalTopologyDemoRelationSeeder.java
index 1b2e965a4b..04fe9f0c9c 100644
--- a/hertzbeat-manager/src/main/java/org/apache/hertzbeat/manager/service/entity/LocalTopologyDemoRelationSeeder.java
+++ b/hertzbeat-manager/src/main/java/org/apache/hertzbeat/manager/service/entity/LocalTopologyDemoRelationSeeder.java
@@ -129,18 +129,20 @@ public class LocalTopologyDemoRelationSeeder implements ApplicationRunner {
List<ObserveEntity> missingEntities = new ArrayList<>();
List<EntityIdentity> missingIdentities = new ArrayList<>();
List<String> serviceNames = mixedScaleProofServiceNames();
+ Map<String, ObserveEntity> entitiesByName = new java.util.LinkedHashMap<>();
for (int index = 0; index < serviceNames.size(); index += 1) {
String serviceName = serviceNames.get(index);
Optional<ObserveEntity> existingEntity = findMixedScaleProofEntity(serviceName);
if (existingEntity.isPresent()) {
+ entitiesByName.put(serviceName, existingEntity.get());
if (!hasServiceNameIdentity(existingEntity.get().getId(), serviceName)) {
missingIdentities.add(serviceNameIdentity(existingEntity.get().getId(), serviceName));
}
continue;
}
long entityId = MIXED_PROOF_ENTITY_ID_BASE + index;
- missingEntities.add(ObserveEntity.builder()
+ ObserveEntity entity = ObserveEntity.builder()
.id(entityId)
.workspaceId(WORKSPACE_ID)
.type("service")
@@ -148,11 +150,14 @@ public class LocalTopologyDemoRelationSeeder implements ApplicationRunner {
.name(serviceName)
.displayName(displayName(serviceName))
.environment(ENVIRONMENT)
+ .source("local-scale-proof")
.status("unknown")
.criticality("medium")
.owner("local-scale-proof")
.lifecycle("local")
- .build());
+ .build();
+ missingEntities.add(entity);
+ entitiesByName.put(serviceName, entity);
missingIdentities.add(serviceNameIdentity(entityId, serviceName));
}
@@ -165,6 +170,69 @@ public class LocalTopologyDemoRelationSeeder implements ApplicationRunner {
log.info("Seeded {} local mixed topology scale proof service.name identity row(s).",
missingIdentities.size());
}
+ seedMixedScaleProofRelations(entitiesByName);
+ }
+
+ void seedMixedScaleProofRelations(Map<String, ObserveEntity> entitiesByName) {
+ if (entitiesByName == null || entitiesByName.isEmpty()) {
+ return;
+ }
+ List<EntityRelation> missingRelations = new ArrayList<>();
+ String gatewayName = MIXED_PROOF_BATCH + "-edge-gateway";
+ ObserveEntity gateway = entitiesByName.get(gatewayName);
+ if (gateway == null || gateway.getId() == null) {
+ return;
+ }
+ Map<Long, List<EntityRelation>> relationCacheBySource = new java.util.LinkedHashMap<>();
+
+ for (int domainIndex = 0; domainIndex < 12; domainIndex += 1) {
+ String domainName = mixedScaleProofDomainName(domainIndex);
+ ObserveEntity domain = entitiesByName.get(domainName);
+ if (domain == null || domain.getId() == null) {
+ continue;
+ }
+ addMissingMixedScaleProofRelation(
+ relationCacheBySource,
+ missingRelations, gateway, domain, "trace-call", "otlp-trace-call",
+ "local mixed topology scale proof gateway to domain call");
+ for (int serviceIndex = 0; serviceIndex < 165; serviceIndex += 1) {
+ ObserveEntity service = entitiesByName.get(mixedScaleProofServiceName(domainIndex, serviceIndex));
+ if (service == null || service.getId() == null) {
+ continue;
+ }
+ addMissingMixedScaleProofRelation(
+ relationCacheBySource,
+ missingRelations, domain, service, "trace-call", "otlp-trace-call",
+ "local mixed topology scale proof domain to service call");
+ }
+ }
+
+ if (!missingRelations.isEmpty()) {
+ entityRelationDao.saveAll(missingRelations);
+ log.info("Seeded {} local mixed topology scale proof relation row(s).", missingRelations.size());
+ }
+ }
+
+ private void addMissingMixedScaleProofRelation(Map<Long, List<EntityRelation>> relationCacheBySource,
+ List<EntityRelation> missingRelations,
+ ObserveEntity source,
+ ObserveEntity target,
+ String relationType,
+ String relationSource,
+ String description) {
+ if (!relationExists(relationCacheBySource, source.getId(), target.getId(), relationType)) {
+ missingRelations.add(EntityRelation.builder()
+ .sourceEntityId(source.getId())
+ .targetEntityId(target.getId())
+ .targetRef("%s:%s/%s".formatted(target.getType(), target.getNamespace(), target.getName()))
+ .relationType(relationType)
+ .relationSource(relationSource)
+ .status("confirmed")
+ .score(92)
+ .description(description)
+ .attributes(seedAttributes("outbound"))
+ .build());
+ }
}
private boolean hasServiceNameIdentity(Long entityId, String serviceName) {
@@ -201,16 +269,24 @@ public class LocalTopologyDemoRelationSeeder implements ApplicationRunner {
List<String> serviceNames = new ArrayList<>();
serviceNames.add(MIXED_PROOF_BATCH + "-edge-gateway");
for (int domainIndex = 0; domainIndex < 12; domainIndex += 1) {
- serviceNames.add("%s-domain-%02d".formatted(MIXED_PROOF_BATCH, domainIndex));
+ serviceNames.add(mixedScaleProofDomainName(domainIndex));
}
for (int domainIndex = 0; domainIndex < 12; domainIndex += 1) {
for (int serviceIndex = 0; serviceIndex < 165; serviceIndex += 1) {
- serviceNames.add("%s-svc-%02d-%03d".formatted(MIXED_PROOF_BATCH, domainIndex, serviceIndex));
+ serviceNames.add(mixedScaleProofServiceName(domainIndex, serviceIndex));
}
}
return serviceNames;
}
+ private String mixedScaleProofDomainName(int domainIndex) {
+ return "%s-domain-%02d".formatted(MIXED_PROOF_BATCH, domainIndex);
+ }
+
+ private String mixedScaleProofServiceName(int domainIndex, int serviceIndex) {
+ return "%s-svc-%02d-%03d".formatted(MIXED_PROOF_BATCH, domainIndex, serviceIndex);
+ }
+
private String displayName(String serviceName) {
if (serviceName.equals(MIXED_PROOF_BATCH + "-edge-gateway")) {
return "Mixed Edge Gateway";
@@ -229,6 +305,18 @@ public class LocalTopologyDemoRelationSeeder implements ApplicationRunner {
&& relationType.equals(relation.getRelationType()));
}
+ private boolean relationExists(Map<Long, List<EntityRelation>> relationCacheBySource,
+ Long sourceEntityId,
+ Long targetEntityId,
+ String relationType) {
+ List<EntityRelation> sourceRelations = relationCacheBySource.computeIfAbsent(sourceEntityId,
+ source -> entityRelationDao.findBySourceEntityIdOrTargetEntityId(source, source));
+ return sourceRelations.stream()
+ .anyMatch(relation -> sourceEntityId.equals(relation.getSourceEntityId())
+ && targetEntityId.equals(relation.getTargetEntityId())
+ && relationType.equals(relation.getRelationType()));
+ }
+
private Map<String, String> seedAttributes(String direction) {
return Map.of(
"seed", SEED_NAME,
diff --git a/hertzbeat-manager/src/main/java/org/apache/hertzbeat/manager/service/impl/AbstractImExportServiceImpl.java b/hertzbeat-manager/src/main/java/org/apache/hertzbeat/manager/service/impl/AbstractImExportServiceImpl.java
index 7bf85944de..b058954b50 100644
--- a/hertzbeat-manager/src/main/java/org/apache/hertzbeat/manager/service/impl/AbstractImExportServiceImpl.java
+++ b/hertzbeat-manager/src/main/java/org/apache/hertzbeat/manager/service/impl/AbstractImExportServiceImpl.java
@@ -35,6 +35,7 @@ import org.apache.hertzbeat.base.service.LabelService;
import org.springframework.beans.BeanUtils;
import org.springframework.context.annotation.Lazy;
import org.springframework.util.CollectionUtils;
+import org.springframework.util.StringUtils;
import java.io.InputStream;
import java.io.OutputStream;
@@ -104,6 +105,7 @@ public abstract class AbstractImExportServiceImpl implements ImExportService {
var exportMonitor = new ExportMonitorDTO();
var monitor = new MonitorDTO();
BeanUtils.copyProperties(dto.getMonitor(), monitor);
+ monitor.setHost(dto.getMonitor().getInstance());
exportMonitor.setMonitor(monitor);
exportMonitor.setParams(dto.getParams().stream().map(it -> {
var param = new ParamDTO();
@@ -128,6 +130,9 @@ public abstract class AbstractImExportServiceImpl implements ImExportService {
// Add one more null check
BeanUtils.copyProperties(exportMonitor.monitor, monitor);
}
+ if (!StringUtils.hasText(monitor.getInstance())) {
+ monitor.setInstance(resolveImportedInstance(exportMonitor));
+ }
monitorDto.setMonitor(monitor);
if (exportMonitor.getMonitor() != null) {
monitorDto.setCollector(exportMonitor.getMonitor().getCollector());
@@ -146,6 +151,22 @@ public abstract class AbstractImExportServiceImpl implements ImExportService {
return monitorDto;
}
+ private static String resolveImportedInstance(ExportMonitorDTO exportMonitor) {
+ if (exportMonitor.getMonitor() != null && StringUtils.hasText(exportMonitor.getMonitor().getHost())) {
+ return exportMonitor.getMonitor().getHost().trim();
+ }
+ if (exportMonitor.getParams() == null) {
+ return null;
+ }
+ return exportMonitor.getParams().stream()
+ .filter(param -> "host".equals(param.getField()))
+ .map(ParamDTO::getValue)
+ .filter(StringUtils::hasText)
+ .map(String::trim)
+ .findFirst()
+ .orElse(null);
+ }
+
protected String fileNamePrefix() {
return "hertzbeat_monitor_" + LocalDate.now();
}
diff --git a/hertzbeat-manager/src/main/java/org/apache/hertzbeat/manager/service/impl/BulletinServiceImpl.java b/hertzbeat-manager/src/main/java/org/apache/hertzbeat/manager/service/impl/BulletinServiceImpl.java
index e5cc79dd94..ff60ac8821 100644
--- a/hertzbeat-manager/src/main/java/org/apache/hertzbeat/manager/service/impl/BulletinServiceImpl.java
+++ b/hertzbeat-manager/src/main/java/org/apache/hertzbeat/manager/service/impl/BulletinServiceImpl.java
@@ -78,7 +78,10 @@ public class BulletinServiceImpl implements BulletinService {
if (bulletin.getMonitorIds() == null || bulletin.getMonitorIds().isEmpty()) {
throw new IllegalArgumentException("Bulletin monitorIds cannot be null or empty");
}
- if (bulletinDao.countByName(bulletin.getName()) > 0) {
+ int duplicateCount = bulletin.getId() == null
+ ? bulletinDao.countByName(bulletin.getName())
+ : bulletinDao.countByNameAndIdNot(bulletin.getName(), bulletin.getId());
+ if (duplicateCount > 0) {
throw new IllegalArgumentException("Bulletin name duplicated");
}
}
diff --git a/hertzbeat-manager/src/main/java/org/apache/hertzbeat/manager/service/impl/MonitorServiceImpl.java b/hertzbeat-manager/src/main/java/org/apache/hertzbeat/manager/service/impl/MonitorServiceImpl.java
index 9d6e3a138f..6957756519 100644
--- a/hertzbeat-manager/src/main/java/org/apache/hertzbeat/manager/service/impl/MonitorServiceImpl.java
+++ b/hertzbeat-manager/src/main/java/org/apache/hertzbeat/manager/service/impl/MonitorServiceImpl.java
@@ -99,6 +99,7 @@ public class MonitorServiceImpl implements MonitorService {
public static final String PATTERN_HTTPS = "(?i)https://";
private static final Long MONITOR_ID_TMP = 1000000000L;
public static final String PARAM_FIELD_PORT = "port";
+ private static final String MONITOR_COPY_SUFFIX = "_copy";
@Autowired
private ParamValidatorManager paramValidatorManager;
@@ -760,7 +761,7 @@ public class MonitorServiceImpl implements MonitorService {
// Copy basic properties, exclude ID, jobId and status
BeanUtils.copyProperties(sourceMonitor, newMonitor, "id", "jobId", "status");
// Set new name
- newMonitor.setName(sourceMonitor.getName() + "_copy");
+ newMonitor.setName(buildCopyMonitorName(sourceMonitor.getName()));
// Set initial status
newMonitor.setStatus(CommonConstants.MONITOR_UP_CODE);
// Set create and update time
@@ -779,6 +780,17 @@ public class MonitorServiceImpl implements MonitorService {
addMonitor(newMonitor, newParams, null, null);
}
+ private String buildCopyMonitorName(String sourceName) {
+ String baseName = StringUtils.hasText(sourceName) ? sourceName.trim() : "monitor";
+ String candidate = baseName + MONITOR_COPY_SUFFIX;
+ int index = 2;
+ while (oldMonitorCatalogQueryService.findMonitorByName(candidate).isPresent()) {
+ candidate = baseName + MONITOR_COPY_SUFFIX + "_" + index;
+ index++;
+ }
+ return candidate;
+ }
+
private void detectSdMonitor(Monitor monitor, List<Param> params, String collector) {
Long monitorId = monitor.getId();
if (monitorId == null || monitorId == 0) {
diff --git a/hertzbeat-manager/src/main/java/org/apache/hertzbeat/manager/service/impl/ObserveEntityServiceImpl.java b/hertzbeat-manager/src/main/java/org/apache/hertzbeat/manager/service/impl/ObserveEntityServiceImpl.java
index 500f3f9a39..431307bdd5 100644
--- a/hertzbeat-manager/src/main/java/org/apache/hertzbeat/manager/service/impl/ObserveEntityServiceImpl.java
+++ b/hertzbeat-manager/src/main/java/org/apache/hertzbeat/manager/service/impl/ObserveEntityServiceImpl.java
@@ -18,6 +18,7 @@
package org.apache.hertzbeat.manager.service.impl;
import java.util.List;
+import java.util.Map;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.hertzbeat.common.entity.alerter.SingleAlert;
@@ -244,4 +245,10 @@ public class ObserveEntityServiceImpl implements ObserveEntityService {
return entityIntegrationHintService.getMonitorBindingCandidates(monitorId);
}
+ @Override
+ @Transactional(readOnly = true)
+ public Map<Long, List<EntityMonitorBindingCandidate>> getMonitorBindingCandidates(List<Long> monitorIds) {
+ return entityIntegrationHintService.getMonitorBindingCandidates(monitorIds);
+ }
+
}
diff --git a/hertzbeat-manager/src/main/java/org/apache/hertzbeat/manager/service/impl/PluginServiceImpl.java b/hertzbeat-manager/src/main/java/org/apache/hertzbeat/manager/service/impl/PluginServiceImpl.java
index a8206b35bf..0a4d424283 100644
--- a/hertzbeat-manager/src/main/java/org/apache/hertzbeat/manager/service/impl/PluginServiceImpl.java
+++ b/hertzbeat-manager/src/main/java/org/apache/hertzbeat/manager/service/impl/PluginServiceImpl.java
@@ -27,8 +27,6 @@ import java.io.OutputStream;
import java.net.MalformedURLException;
import java.net.URL;
import java.net.URLClassLoader;
-import java.nio.file.Path;
-import java.nio.file.Paths;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.Enumeration;
@@ -85,6 +83,8 @@ import org.yaml.snakeyaml.error.YAMLException;
@RequiredArgsConstructor
public class PluginServiceImpl implements PluginService {
+ private static final String PLUGIN_LIB_DIR_PROPERTY = "hertzbeat.plugin.lib.dir";
+
private final PluginMetadataDao metadataDao;
private final PluginItemDao itemDao;
@@ -196,6 +196,7 @@ public class PluginServiceImpl implements PluginService {
return;
}
pluginParamDao.deletePluginParamsByPluginMetadataId(params.get(0).getPluginMetadataId());
+ pluginParamDao.flush();
pluginParamDao.saveAll(params);
syncPluginParamMap(params.get(0).getPluginMetadataId(), params, false);
}
@@ -284,8 +285,8 @@ public class PluginServiceImpl implements PluginService {
private void validateFilePath(File file) {
try {
String canonicalPath = file.getCanonicalPath();
- String expectedDir = new File("plugin-lib").getCanonicalPath();
- if (!canonicalPath.startsWith(expectedDir)) {
+ String expectedDir = resolvePluginLibDir().getCanonicalPath();
+ if (!canonicalPath.equals(expectedDir) && !canonicalPath.startsWith(expectedDir + File.separator)) {
throw new CommonException("File is outside the allowed directory: " + canonicalPath);
}
} catch (IOException e) {
@@ -315,9 +316,7 @@ public class PluginServiceImpl implements PluginService {
@SneakyThrows
@Transactional
public void savePlugin(PluginUpload pluginUpload) {
- String jarPath = new File(this.getClass().getProtectionDomain().getCodeSource().getLocation().getPath()).getAbsolutePath();
- Path extLibPath = Paths.get(new File(jarPath).getParent(), "plugin-lib");
- File extLibDir = extLibPath.toFile();
+ File extLibDir = resolvePluginLibDir();
String fileName = pluginUpload.getJarFile().getOriginalFilename();
validateFileName(fileName);
fileName = UUID.randomUUID().toString().replace("-", "") + "_" + fileName;
@@ -365,6 +364,15 @@ public class PluginServiceImpl implements PluginService {
}
}
+ private File resolvePluginLibDir() {
+ try {
+ return new File(System.getProperty(PLUGIN_LIB_DIR_PROPERTY, "plugin-lib")).getCanonicalFile();
+ } catch (IOException e) {
+ log.error("Error resolving plugin lib dir", e);
+ throw new CommonException("Error resolving plugin lib dir: " + e.getMessage());
+ }
+ }
+
@Override
public boolean pluginIsEnable(Class<?> clazz) {
return Boolean.TRUE.equals(PLUGIN_ENABLE_STATUS.get(clazz.getName()));
diff --git a/hertzbeat-manager/src/test/java/org/apache/hertzbeat/manager/service/BulletinServiceTest.java b/hertzbeat-manager/src/test/java/org/apache/hertzbeat/manager/service/BulletinServiceTest.java
index dd9768347b..d1e594d951 100644
--- a/hertzbeat-manager/src/test/java/org/apache/hertzbeat/manager/service/BulletinServiceTest.java
+++ b/hertzbeat-manager/src/test/java/org/apache/hertzbeat/manager/service/BulletinServiceTest.java
@@ -105,6 +105,50 @@ public class BulletinServiceTest {
});
}
+ @Test
+ public void testValidateAllowsEditingSameBulletinName() throws Exception {
+ Map<String, List<String>> fields = new HashMap<String, List<String>>();
+ fields.put("summary", List.of("responseTime"));
+
+ List<Long> ids = new ArrayList<Long>();
+ ids.add((long) 1);
+
+ Bulletin obj = new Bulletin();
+ obj.setId((long) 7);
+ obj.setName("Ops board");
+ obj.setApp("website");
+ obj.setFields(fields);
+ obj.setMonitorIds(ids);
+
+ when(bulletinDao.countByNameAndIdNot("Ops board", 7L)).thenReturn(0);
+
+ assertDoesNotThrow(() -> {
+ bulletinService.validate(obj);
+ });
+ }
+
+ @Test
+ public void testValidateRejectsEditingToAnotherBulletinName() throws Exception {
+ Map<String, List<String>> fields = new HashMap<String, List<String>>();
+ fields.put("summary", List.of("responseTime"));
+
+ List<Long> ids = new ArrayList<Long>();
+ ids.add((long) 1);
+
+ Bulletin obj = new Bulletin();
+ obj.setId((long) 7);
+ obj.setName("Ops board");
+ obj.setApp("website");
+ obj.setFields(fields);
+ obj.setMonitorIds(ids);
+
+ when(bulletinDao.countByNameAndIdNot("Ops board", 7L)).thenReturn(1);
+
+ assertThrows(IllegalArgumentException.class, () -> {
+ bulletinService.validate(obj);
+ });
+ }
+
@Test
public void testAddBulletin() throws Exception {
Map<String, List<String>> fields = new HashMap<String, List<String>>();
diff --git a/hertzbeat-manager/src/test/java/org/apache/hertzbeat/manager/service/JsonImExportServiceTest.java b/hertzbeat-manager/src/test/java/org/apache/hertzbeat/manager/service/JsonImExportServiceTest.java
index 3c36a18c33..14b5581824 100644
--- a/hertzbeat-manager/src/test/java/org/apache/hertzbeat/manager/service/JsonImExportServiceTest.java
+++ b/hertzbeat-manager/src/test/java/org/apache/hertzbeat/manager/service/JsonImExportServiceTest.java
@@ -21,14 +21,23 @@ import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.nio.charset.StandardCharsets;
import java.util.List;
+import org.apache.hertzbeat.common.entity.manager.Monitor;
+import org.apache.hertzbeat.common.entity.manager.Param;
+import org.apache.hertzbeat.manager.config.ManagerSseManager;
+import org.apache.hertzbeat.manager.pojo.dto.MonitorDto;
import org.apache.hertzbeat.manager.service.impl.AbstractImExportServiceImpl;
import org.apache.hertzbeat.manager.service.impl.JsonImExportServiceImpl;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
+import org.springframework.test.util.ReflectionTestUtils;
/**
* Test case for {@link JsonImExportServiceImpl}
@@ -89,4 +98,67 @@ class JsonImExportServiceTest {
assertEquals("JSON", jsonImExportService.type());
}
+ @Test
+ void importConfigRestoresInstanceFromLegacyHostParam() {
+ MonitorService monitorService = org.mockito.Mockito.mock(MonitorService.class);
+ ManagerSseManager managerSseManager = org.mockito.Mockito.mock(ManagerSseManager.class);
+ ReflectionTestUtils.setField(jsonImExportService, "monitorService", monitorService);
+ ReflectionTestUtils.setField(jsonImExportService, "managerSseManager", managerSseManager);
+ String json = """
+ [{
+ "monitor": {
+ "name": "Codex import monitor",
+ "app": "website",
+ "intervals": 60,
+ "status": 1
+ },
+ "params": [
+ {"field": "host", "type": 1, "value": "127.0.0.1"},
+ {"field": "port", "type": 0, "value": "4223"}
+ ]
+ }]
+ """;
+
+ jsonImExportService.importConfig("legacy-export.json", new ByteArrayInputStream(json.getBytes(StandardCharsets.UTF_8)));
+
+ ArgumentCaptor<MonitorDto> validateCaptor = ArgumentCaptor.forClass(MonitorDto.class);
+ ArgumentCaptor<Monitor> monitorCaptor = ArgumentCaptor.forClass(Monitor.class);
+ @SuppressWarnings("unchecked")
+ ArgumentCaptor<List<Param>> paramsCaptor = ArgumentCaptor.forClass(List.class);
+ verify(monitorService).validate(validateCaptor.capture(), org.mockito.Mockito.eq(false));
+ verify(monitorService).addMonitor(monitorCaptor.capture(), paramsCaptor.capture(), any(), any());
+ assertEquals("127.0.0.1", validateCaptor.getValue().getMonitor().getInstance());
+ assertEquals("127.0.0.1", monitorCaptor.getValue().getInstance());
+ assertEquals("4223", paramsCaptor.getValue().stream()
+ .filter(param -> "port".equals(param.getField()))
+ .findFirst()
+ .orElseThrow()
+ .getParamValue());
+ }
+
+ @Test
+ void exportConfigPreservesMonitorInstanceAsHost() {
+ MonitorService monitorService = org.mockito.Mockito.mock(MonitorService.class);
+ ReflectionTestUtils.setField(jsonImExportService, "monitorService", monitorService);
+ Monitor monitor = Monitor.builder()
+ .id(42L)
+ .name("Codex export monitor")
+ .app("website")
+ .instance("127.0.0.1:4223")
+ .intervals(60)
+ .status((byte) 1)
+ .build();
+ MonitorDto monitorDto = new MonitorDto();
+ monitorDto.setMonitor(monitor);
+ monitorDto.setParams(List.of(Param.builder().field("host").type((byte) 1).paramValue("127.0.0.1").build()));
+ when(monitorService.getMonitorDto(42L)).thenReturn(monitorDto);
+ ByteArrayOutputStream bos = new ByteArrayOutputStream();
+
+ jsonImExportService.exportConfig(bos, List.of(42L));
+
+ String result = bos.toString(StandardCharsets.UTF_8);
+ assertTrue(result.contains("\"host\":\"127.0.0.1:4223\""));
+ assertTrue(result.contains("\"name\":\"Codex export monitor\""));
+ }
+
}
diff --git a/hertzbeat-manager/src/test/java/org/apache/hertzbeat/manager/service/MonitorServiceTest.java b/hertzbeat-manager/src/test/java/org/apache/hertzbeat/manager/service/MonitorServiceTest.java
index bc65a9a0ef..c6819b84ee 100644
--- a/hertzbeat-manager/src/test/java/org/apache/hertzbeat/manager/service/MonitorServiceTest.java
+++ b/hertzbeat-manager/src/test/java/org/apache/hertzbeat/manager/service/MonitorServiceTest.java
@@ -76,6 +76,7 @@ import org.apache.hertzbeat.manager.service.helper.MonitorImExportHelper;
import org.apache.hertzbeat.manager.service.impl.MonitorServiceImpl;
import org.apache.hertzbeat.manager.support.exception.MonitorDatabaseException;
import org.apache.hertzbeat.manager.support.exception.MonitorDetectException;
+import static org.mockito.Mockito.argThat;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.verify;
import org.junit.jupiter.api.Assertions;
@@ -1091,6 +1092,29 @@ class MonitorServiceTest {
assertDoesNotThrow(() -> monitorService.copyMonitor(1L));
}
+ @Test
+ void copyMonitorUsesNextAvailableCopyName() {
+ Monitor monitor = Monitor.builder()
+ .intervals(1)
+ .name("memory")
+ .app("demoApp")
+ .instance("localhost")
+ .build();
+ Job job = new Job();
+ when(appService.getAppDefine(monitor.getApp())).thenReturn(job);
+ when(monitorDao.findById(1L)).thenReturn(Optional.of(monitor));
+ when(paramDao.findParamsByMonitorId(1L)).thenReturn(Collections.singletonList(new Param()));
+ when(monitorDao.findMonitorByNameEquals("memory_copy"))
+ .thenReturn(Optional.of(Monitor.builder().id(2L).name("memory_copy").build()));
+ when(monitorDao.findMonitorByNameEquals("memory_copy_2"))
+ .thenReturn(Optional.of(Monitor.builder().id(3L).name("memory_copy_2").build()));
+ when(monitorDao.findMonitorByNameEquals("memory_copy_3")).thenReturn(Optional.empty());
+
+ assertDoesNotThrow(() -> monitorService.copyMonitor(1L));
+
+ verify(monitorDao).save(argThat(saved -> "memory_copy_3".equals(saved.getName())));
+ }
+
@Test
void exportAll() throws Exception {
// Create some test monitors
diff --git a/hertzbeat-manager/src/test/java/org/apache/hertzbeat/manager/service/ObserveEntityServiceTest.java b/hertzbeat-manager/src/test/java/org/apache/hertzbeat/manager/service/ObserveEntityServiceTest.java
index 8ba96aba86..b082bf5bfc 100644
--- a/hertzbeat-manager/src/test/java/org/apache/hertzbeat/manager/service/ObserveEntityServiceTest.java
+++ b/hertzbeat-manager/src/test/java/org/apache/hertzbeat/manager/service/ObserveEntityServiceTest.java
@@ -309,9 +309,12 @@ class ObserveEntityServiceTest {
entityIdentityResolutionService = new EntityIdentityResolutionService(
entityIdentityReadModelService, entityMonitorBindService, entityWorkspaceAccessService);
entityIdentityWriteModelService = new EntityIdentityWriteModelService(
- entityIdentityDao, entityIdentityResolutionService);
+ entityIdentityDao, entityIdentityResolutionService, entityWorkspaceAccessService);
entityIntegrationHintService = new EntityIntegrationHintService(
- entityMonitorQueryService, entityIdentityResolutionService);
+ entityMonitorQueryService,
+ entityIdentityResolutionService,
+ entityMonitorBindService,
+ entityWorkspaceAccessService);
ReflectionTestUtils.setField(
observeEntityService, "entityIntegrationHintService", entityIntegrationHintService);
entityDeletionWriteModelService = new EntityDeletionWriteModelService(
@@ -352,7 +355,11 @@ class ObserveEntityServiceTest {
entityDefinitionDraftService = new EntityDefinitionDraftService(
entityDefinitionDocumentParserService,
entityDefinitionNormalizationService,
- entityDefinitionMappingService);
+ entityDefinitionMappingService,
+ entityIdentityDao,
+ entityIdentityResolutionService,
+ entityWorkspaceAccessService,
+ entityMonitorBindQueryService);
ReflectionTestUtils.setField(
observeEntityService, "entityDefinitionDraftService", entityDefinitionDraftService);
entityResponseHandoffReadModelService = new EntityResponseHandoffReadModelService(entityObservabilityGateway);
@@ -396,8 +403,8 @@ class ObserveEntityServiceTest {
entityStatusRefreshService,
entitySummaryReadModelService);
ReflectionTestUtils.setField(observeEntityService, "entityListReadModelService", entityListReadModelService);
- lenient().when(alertSilenceDao.findAll()).thenReturn(Collections.emptyList());
- lenient().when(alertInhibitDao.findAll()).thenReturn(Collections.emptyList());
+ lenient().when(alertSilenceDao.findAlertSilencesByEnableTrue()).thenReturn(Collections.emptyList());
+ lenient().when(alertInhibitDao.findAlertInhibitsByEnableIsTrue()).thenReturn(Collections.emptyList());
lenient().when(entityTraceQueryService.buildEntityTraceSummary(any()))
.thenReturn(new EntityTraceSummaryDto(0, 0, null, false, null));
lenient().when(entityTraceQueryService.buildEntityTraceQueryHints(any()))
@@ -1330,9 +1337,9 @@ class ObserveEntityServiceTest {
assertEquals(1, detail.getMonitorSummary().getAbnormalMonitors().size());
assertEquals("otel-resource", detail.getLogSummary().getPreferredQueryType());
assertNotNull(detail.getUnifiedEvidenceSummary());
- assertEquals(2, detail.getUnifiedEvidenceSummary().getActiveSignalCount());
+ assertEquals(1, detail.getUnifiedEvidenceSummary().getActiveSignalCount());
assertTrue(detail.getUnifiedEvidenceSummary().isMetricsActive());
- assertTrue(detail.getUnifiedEvidenceSummary().isLogsActive());
+ assertFalse(detail.getUnifiedEvidenceSummary().isLogsActive());
assertFalse(detail.getUnifiedEvidenceSummary().isTracesActive());
assertNotNull(detail.getTriageRecommendation());
assertEquals("metrics", detail.getTriageRecommendation().getRecommendedFocus());
@@ -1426,8 +1433,8 @@ class ObserveEntityServiceTest {
.equalLabels(List.of(CommonConstants.LABEL_INSTANCE))
.gmtUpdate(LocalDateTime.of(2026, 3, 20, 10, 5))
.build();
- when(alertSilenceDao.findAll()).thenReturn(List.of(silence));
- when(alertInhibitDao.findAll()).thenReturn(List.of(inhibit));
+ when(alertSilenceDao.findAlertSilencesByEnableTrue()).thenReturn(List.of(silence));
+ when(alertInhibitDao.findAlertInhibitsByEnableIsTrue()).thenReturn(List.of(inhibit));
EntityDetailDto detail = observeEntityService.getEntityDetail(14L);
@@ -1540,8 +1547,8 @@ class ObserveEntityServiceTest {
.equalLabels(List.of(CommonConstants.LABEL_INSTANCE))
.gmtUpdate(LocalDateTime.of(2026, 4, 1, 10, 25))
.build();
- when(alertSilenceDao.findAll()).thenReturn(List.of(globalMatchAll, teamBetaSilence, teamAlphaSilence));
- when(alertInhibitDao.findAll()).thenReturn(List.of(genericInhibit, teamBetaInhibit, teamAlphaInhibit));
+ when(alertSilenceDao.findAlertSilencesByEnableTrue()).thenReturn(List.of(globalMatchAll, teamBetaSilence, teamAlphaSilence));
+ when(alertInhibitDao.findAlertInhibitsByEnableIsTrue()).thenReturn(List.of(genericInhibit, teamBetaInhibit, teamAlphaInhibit));
EntityDetailDto detail = observeEntityService.getEntityDetail(141L);
@@ -2898,9 +2905,10 @@ class ObserveEntityServiceTest {
.build();
when(observeEntityDao.findAll(any(Specification.class), any(Pageable.class)))
.thenReturn(new PageImpl<>(List.of(entity)));
- when(entityIdentityDao.countByEntityId(21L)).thenReturn(0L);
- when(entityRelationDao.countBySourceEntityIdOrTargetEntityId(21L, 21L)).thenReturn(0L);
- when(entityMonitorBindDao.findAllByEntityIdOrderByIdAsc(21L)).thenReturn(Collections.emptyList());
+ when(entityIdentityDao.countByEntityIdInGroupByEntityId(List.of(21L))).thenReturn(Collections.emptyList());
+ when(entityMonitorBindDao.countByEntityIdInGroupByEntityId(List.of(21L))).thenReturn(Collections.emptyList());
+ when(entityRelationDao.countBySourceEntityIdInGroupBySourceEntityId(List.of(21L))).thenReturn(Collections.emptyList());
+ when(entityRelationDao.countByTargetEntityIdInGroupByTargetEntityId(List.of(21L))).thenReturn(Collections.emptyList());
EntityDefinitionActivity latestActivity = EntityDefinitionActivity.builder()
.id(101L)
.entityId(21L)
@@ -2933,9 +2941,10 @@ class ObserveEntityServiceTest {
.build();
when(observeEntityDao.findAll(any(Specification.class), any(Pageable.class)))
.thenReturn(new PageImpl<>(List.of(entity)));
- when(entityIdentityDao.countByEntityId(23L)).thenReturn(0L);
- when(entityRelationDao.countBySourceEntityIdOrTargetEntityId(23L, 23L)).thenReturn(0L);
- when(entityMonitorBindDao.findAllByEntityIdOrderByIdAsc(23L)).thenReturn(Collections.emptyList());
+ when(entityIdentityDao.countByEntityIdInGroupByEntityId(List.of(23L))).thenReturn(Collections.emptyList());
+ when(entityMonitorBindDao.countByEntityIdInGroupByEntityId(List.of(23L))).thenReturn(Collections.emptyList());
+ when(entityRelationDao.countBySourceEntityIdInGroupBySourceEntityId(List.of(23L))).thenReturn(Collections.emptyList());
+ when(entityRelationDao.countByTargetEntityIdInGroupByTargetEntityId(List.of(23L))).thenReturn(Collections.emptyList());
when(entityDefinitionActivityDao.findAllByEntityIdIn(any(), any(Sort.class))).thenReturn(Collections.emptyList());
var page = observeEntityService.getEntities(null, null, null, null, null, null,
@@ -2970,9 +2979,10 @@ class ObserveEntityServiceTest {
.build();
when(observeEntityDao.findAll(any(Specification.class), any(Pageable.class)))
.thenReturn(new PageImpl<>(List.of(teamAlpha, teamBeta)));
- when(entityIdentityDao.countByEntityId(anyLong())).thenReturn(0L);
- when(entityRelationDao.countBySourceEntityIdOrTargetEntityId(anyLong(), anyLong())).thenReturn(0L);
- when(entityMonitorBindDao.findAllByEntityIdOrderByIdAsc(anyLong())).thenReturn(Collections.emptyList());
+ when(entityIdentityDao.countByEntityIdInGroupByEntityId(List.of(31L))).thenReturn(Collections.emptyList());
+ when(entityMonitorBindDao.countByEntityIdInGroupByEntityId(List.of(31L))).thenReturn(Collections.emptyList());
+ when(entityRelationDao.countBySourceEntityIdInGroupBySourceEntityId(List.of(31L))).thenReturn(Collections.emptyList());
+ when(entityRelationDao.countByTargetEntityIdInGroupByTargetEntityId(List.of(31L))).thenReturn(Collections.emptyList());
when(entityDefinitionActivityDao.findAllByEntityIdIn(any(), any(Sort.class))).thenReturn(Collections.emptyList());
AuthTokenRequestContext.bindWorkspaceId("team-a");
@@ -2996,9 +3006,10 @@ class ObserveEntityServiceTest {
.build();
when(observeEntityDao.findAll(any(Specification.class), any(Pageable.class)))
.thenReturn(new PageImpl<>(List.of(entity)));
- when(entityIdentityDao.countByEntityId(22L)).thenReturn(0L);
- when(entityRelationDao.countBySourceEntityIdOrTargetEntityId(22L, 22L)).thenReturn(0L);
- when(entityMonitorBindDao.findAllByEntityIdOrderByIdAsc(22L)).thenReturn(Collections.emptyList());
+ when(entityIdentityDao.countByEntityIdInGroupByEntityId(List.of(22L))).thenReturn(Collections.emptyList());
+ when(entityMonitorBindDao.countByEntityIdInGroupByEntityId(List.of(22L))).thenReturn(Collections.emptyList());
+ when(entityRelationDao.countBySourceEntityIdInGroupBySourceEntityId(List.of(22L))).thenReturn(Collections.emptyList());
+ when(entityRelationDao.countByTargetEntityIdInGroupByTargetEntityId(List.of(22L))).thenReturn(Collections.emptyList());
EntityDefinitionActivity discoveryActivity = EntityDefinitionActivity.builder()
.id(202L)
.entityId(22L)
diff --git a/hertzbeat-manager/src/test/java/org/apache/hertzbeat/manager/service/PluginServiceTest.java b/hertzbeat-manager/src/test/java/org/apache/hertzbeat/manager/service/PluginServiceTest.java
index 5c3181c914..78806cabbd 100644
--- a/hertzbeat-manager/src/test/java/org/apache/hertzbeat/manager/service/PluginServiceTest.java
+++ b/hertzbeat-manager/src/test/java/org/apache/hertzbeat/manager/service/PluginServiceTest.java
@@ -20,6 +20,7 @@ package org.apache.hertzbeat.manager.service;
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertInstanceOf;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
@@ -27,6 +28,7 @@ import static org.mockito.ArgumentMatchers.anyList;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.Mockito.doNothing;
import static org.mockito.Mockito.doReturn;
+import static org.mockito.Mockito.inOrder;
import static org.mockito.Mockito.spy;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
@@ -57,6 +59,8 @@ import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.junit.jupiter.api.io.TempDir;
+import org.mockito.ArgumentCaptor;
+import org.mockito.InOrder;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
@@ -91,7 +95,7 @@ class PluginServiceTest {
}
@Test
- void testSavePlugin() {
+ void testSavePlugin(@TempDir File tempDir) throws IOException {
List<PluginItem> pluginItems = Collections.singletonList(new PluginItem("org.apache.hertzbeat.PluginTest", PluginType.POST_ALERT));
PluginMetadata metadata = new PluginMetadata();
@@ -99,6 +103,9 @@ class PluginServiceTest {
metadata.setParamCount(0);
PluginServiceImpl service = spy(pluginService);
doReturn(metadata).when(service).validateJarFile(any());
+ File pluginLibDir = new File(tempDir, "plugin-lib");
+ String previousPluginLib = System.getProperty("hertzbeat.plugin.lib.dir");
+ System.setProperty("hertzbeat.plugin.lib.dir", pluginLibDir.getAbsolutePath());
MockMultipartFile mockFile = new MockMultipartFile(
"file", "test-plugin.jar", "application/java-archive",
@@ -108,9 +115,23 @@ class PluginServiceTest {
when(metadataDao.save(any(PluginMetadata.class))).thenReturn(new PluginMetadata());
when(itemDao.saveAll(anyList())).thenReturn(Collections.emptyList());
- service.savePlugin(pluginUpload);
- verify(metadataDao, times(1)).save(any(PluginMetadata.class));
+ try {
+ service.savePlugin(pluginUpload);
+ } finally {
+ if (previousPluginLib == null) {
+ System.clearProperty("hertzbeat.plugin.lib.dir");
+ } else {
+ System.setProperty("hertzbeat.plugin.lib.dir", previousPluginLib);
+ }
+ }
+ ArgumentCaptor<File> uploadedJarCaptor = ArgumentCaptor.forClass(File.class);
+ ArgumentCaptor<PluginMetadata> metadataCaptor = ArgumentCaptor.forClass(PluginMetadata.class);
+ verify(service).validateJarFile(uploadedJarCaptor.capture());
+ verify(metadataDao, times(1)).save(metadataCaptor.capture());
verify(itemDao, times(1)).saveAll(anyList());
+ assertTrue(uploadedJarCaptor.getValue().getCanonicalPath().startsWith(pluginLibDir.getCanonicalPath() + File.separator));
+ assertNotNull(metadataCaptor.getValue().getJarFilePath());
+ assertTrue(new File(metadataCaptor.getValue().getJarFilePath()).getCanonicalPath().startsWith(pluginLibDir.getCanonicalPath() + File.separator));
}
@@ -168,6 +189,31 @@ class PluginServiceTest {
verify(metadataDao, times(1)).findAll(any(Specification.class), any(PageRequest.class));
}
+ @Test
+ void testSavePluginParamFlushesDeleteBeforeInsert() {
+ List<org.apache.hertzbeat.manager.pojo.dto.PluginParam> params = List.of(
+ org.apache.hertzbeat.manager.pojo.dto.PluginParam.builder()
+ .pluginMetadataId(1L)
+ .field("endpoint")
+ .paramValue("https://example.invalid/hertzbeat-plugin-audit")
+ .type((byte) 1)
+ .build(),
+ org.apache.hertzbeat.manager.pojo.dto.PluginParam.builder()
+ .pluginMetadataId(1L)
+ .field("mode")
+ .paramValue("audit-only")
+ .type((byte) 1)
+ .build()
+ );
+
+ pluginService.savePluginParam(params);
+
+ InOrder inOrder = inOrder(pluginParamDao);
+ inOrder.verify(pluginParamDao).deletePluginParamsByPluginMetadataId(1L);
+ inOrder.verify(pluginParamDao).flush();
+ inOrder.verify(pluginParamDao).saveAll(params);
+ }
+
@Test
void testZipSlipDetected(@TempDir File tempDir) throws Exception {
File maliciousJar = new File(tempDir, "malicious.jar");
diff --git a/hertzbeat-manager/src/test/java/org/apache/hertzbeat/manager/service/entity/EntityDefinitionDocumentParserServiceTest.java b/hertzbeat-manager/src/test/java/org/apache/hertzbeat/manager/service/entity/EntityDefinitionDocumentParserServiceTest.java
index 6576c1ea8f..19d336104d 100644
--- a/hertzbeat-manager/src/test/java/org/apache/hertzbeat/manager/service/entity/EntityDefinitionDocumentParserServiceTest.java
+++ b/hertzbeat-manager/src/test/java/org/apache/hertzbeat/manager/service/entity/EntityDefinitionDocumentParserServiceTest.java
@@ -105,6 +105,48 @@ class EntityDefinitionDocumentParserServiceTest {
assertEquals("curl-checkout", metadataName(records.getFirst()));
}
+ @Test
+ void parseDefinitionRecordsUnwrapsCurlEntityDefinitionRequestEnvelope() {
+ EntityDefinitionRequest request = new EntityDefinitionRequest();
+ request.setFormat("curl");
+ request.setContent("""
+ curl -X POST http://127.0.0.1:1157/api/entities/import -H 'Content-Type: application/json' -d '{"format":"yaml","content":"apiVersion: hertzbeat/v1\\nkind: service\\nmetadata:\\n name: curl-envelope-checkout\\n namespace: commerce"}'
+ """);
+
+ List<Map<String, Object>> records = documentParserService.parseDefinitionRecords(request);
+
+ assertEquals(1, records.size());
+ assertEquals("curl-envelope-checkout", metadataName(records.getFirst()));
+ }
+
+ @Test
+ void parseDefinitionRecordsUnwrapsCurlDataRawEnvelope() {
+ EntityDefinitionRequest request = new EntityDefinitionRequest();
+ request.setFormat("curl");
+ request.setContent("""
+ curl -X POST http://127.0.0.1:1157/api/entities/import -H 'Content-Type: application/json' --data-raw '{"format":"yaml","content":"apiVersion: hertzbeat/v1\\nkind: service\\nmetadata:\\n name: curl-data-raw-checkout\\n namespace: commerce"}'
+ """);
+
+ List<Map<String, Object>> records = documentParserService.parseDefinitionRecords(request);
+
+ assertEquals(1, records.size());
+ assertEquals("curl-data-raw-checkout", metadataName(records.getFirst()));
+ }
+
+ @Test
+ void parseDefinitionRecordsUnwrapsCurlDataRawEnvelopeWithEquals() {
+ EntityDefinitionRequest request = new EntityDefinitionRequest();
+ request.setFormat("curl");
+ request.setContent("""
+ curl -X POST http://127.0.0.1:1157/api/entities/import -H 'Content-Type: application/json' --data-raw='{"format":"yaml","content":"apiVersion: hertzbeat/v1\\nkind: service\\nmetadata:\\n name: curl-data-raw-equals-checkout\\n namespace: commerce"}'
+ """);
+
+ List<Map<String, Object>> records = documentParserService.parseDefinitionRecords(request);
+
+ assertEquals(1, records.size());
+ assertEquals("curl-data-raw-equals-checkout", metadataName(records.getFirst()));
+ }
+
@Test
void parseDefinitionRecordsRejectsBlankContent() {
EntityDefinitionRequest request = new EntityDefinitionRequest();
diff --git a/hertzbeat-manager/src/test/java/org/apache/hertzbeat/manager/service/entity/EntityDefinitionDraftServiceTest.java b/hertzbeat-manager/src/test/java/org/apache/hertzbeat/manager/service/entity/EntityDefinitionDraftServiceTest.java
index 90a6b0a94d..4fc1de75e7 100644
--- a/hertzbeat-manager/src/test/java/org/apache/hertzbeat/manager/service/entity/EntityDefinitionDraftServiceTest.java
+++ b/hertzbeat-manager/src/test/java/org/apache/hertzbeat/manager/service/entity/EntityDefinitionDraftServiceTest.java
@@ -26,6 +26,11 @@ import static org.mockito.Mockito.when;
import java.util.Collections;
import java.util.List;
import java.util.Map;
+import java.util.Set;
+import org.apache.hertzbeat.common.entity.manager.EntityIdentity;
+import org.apache.hertzbeat.common.entity.manager.EntityMonitorBind;
+import org.apache.hertzbeat.common.entity.manager.ObserveEntity;
+import org.apache.hertzbeat.manager.dao.EntityIdentityDao;
import org.apache.hertzbeat.manager.pojo.dto.EntityDefinition;
import org.apache.hertzbeat.manager.pojo.dto.EntityDefinitionRequest;
import org.apache.hertzbeat.manager.pojo.dto.EntityDto;
@@ -54,6 +59,18 @@ class EntityDefinitionDraftServiceTest {
@Mock
private EntityDefinitionMappingService entityDefinitionMappingService;
+ @Mock
+ private EntityIdentityDao entityIdentityDao;
+
+ @Mock
+ private EntityIdentityResolutionService entityIdentityResolutionService;
+
+ @Mock
+ private EntityWorkspaceAccessService entityWorkspaceAccessService;
+
+ @Mock
+ private EntityMonitorBindQueryService entityMonitorBindQueryService;
+
@Test
void parseEntityDefinitionMapsSingleNormalizedDraftWithEntityAndWorkspaceContext() {
EntityDefinitionRequest request = new EntityDefinitionRequest();
@@ -131,4 +148,100 @@ class EntityDefinitionDraftServiceTest {
assertEquals(List.of(firstDto, secondDto), entityDtos);
}
+
+ @Test
+ void parseEntityDefinitionBundleRejectsAccessiblePrimaryIdentityCollisionsBeforeWrite() {
+ EntityDefinitionRequest request = new EntityDefinitionRequest();
+ request.setContent("kind: service");
+ Map<String, Object> rawRecord = Map.of("kind", "service");
+ EntityDefinition normalizedDefinition = new EntityDefinition();
+ EntityDto dto = new EntityDto();
+ dto.setIdentities(List.of(EntityIdentity.builder()
+ .identityKey("service.name")
+ .identityValue("checkout-api")
+ .primaryIdentity(true)
+ .build()));
+ EntityIdentity persistedIdentity = EntityIdentity.builder()
+ .entityId(42L)
+ .identityKey("service.name")
+ .identityValue("checkout-api")
+ .normalizedValue("checkout-api")
+ .primaryIdentity(true)
+ .build();
+ ObserveEntity visibleEntity = ObserveEntity.builder()
+ .id(42L)
+ .name("checkout-api")
+ .type("service")
+ .build();
+ when(entityDefinitionDocumentParserService.parseDefinitionRecords(request)).thenReturn(List.of(rawRecord));
+ when(entityDefinitionNormalizationService.normalizeDefinition(rawRecord)).thenReturn(normalizedDefinition);
+ when(entityDefinitionMappingService.toEntityDto(normalizedDefinition, null, "team-a")).thenReturn(dto);
+ when(entityIdentityResolutionService.normalizeIdentityValue("service.name", "checkout-api"))
+ .thenReturn("checkout-api");
+ when(entityIdentityDao.findAllByIdentityKeyInAndNormalizedValueIn(
+ Set.of("service.name"), Set.of("checkout-api"))).thenReturn(List.of(persistedIdentity));
+ when(entityWorkspaceAccessService.findAccessibleEntitiesByIdsForRequestWorkspace(Set.of(42L)))
+ .thenReturn(List.of(visibleEntity));
+
+ IllegalArgumentException exception = assertThrows(IllegalArgumentException.class,
+ () -> definitionDraftService.parseEntityDefinitionBundle(request, "team-a"));
+
+ assertEquals("Entity primary identity already exists: service.name=checkout-api.", exception.getMessage());
+ }
+
+ @Test
+ void parseEntityDefinitionAllowsCurrentEntityPrimaryIdentityWhenEditingDefinition() {
+ EntityDefinitionRequest request = new EntityDefinitionRequest();
+ request.setContent("kind: service");
+ Map<String, Object> rawRecord = Map.of("kind", "service");
+ EntityDefinition normalizedDefinition = new EntityDefinition();
+ EntityDto dto = new EntityDto();
+ dto.setIdentities(List.of(EntityIdentity.builder()
+ .identityKey("service.name")
+ .identityValue("checkout-api")
+ .primaryIdentity(true)
+ .build()));
+ EntityIdentity currentIdentity = EntityIdentity.builder()
+ .entityId(42L)
+ .identityKey("service.name")
+ .identityValue("checkout-api")
+ .normalizedValue("checkout-api")
+ .primaryIdentity(true)
+ .build();
+ when(entityDefinitionDocumentParserService.parseDefinitionRecords(request)).thenReturn(List.of(rawRecord));
+ when(entityDefinitionNormalizationService.normalizeDefinition(rawRecord)).thenReturn(normalizedDefinition);
+ when(entityDefinitionMappingService.toEntityDto(normalizedDefinition, 42L, "team-a")).thenReturn(dto);
+ when(entityIdentityResolutionService.normalizeIdentityValue("service.name", "checkout-api"))
+ .thenReturn("checkout-api");
+ when(entityIdentityDao.findAllByIdentityKeyInAndNormalizedValueIn(
+ Set.of("service.name"), Set.of("checkout-api"))).thenReturn(List.of(currentIdentity));
+
+ EntityDto actualDto = definitionDraftService.parseEntityDefinition(request, 42L, "team-a");
+
+ assertSame(dto, actualDto);
+ }
+
+ @Test
+ void parseEntityDefinitionBundleRejectsMonitorBindAlreadyAssignedBeforeWrite() {
+ EntityDefinitionRequest request = new EntityDefinitionRequest();
+ request.setContent("kind: service");
+ Map<String, Object> rawRecord = Map.of("kind", "service");
+ EntityDefinition normalizedDefinition = new EntityDefinition();
+ EntityDto dto = new EntityDto();
+ dto.setMonitorBinds(List.of(EntityMonitorBind.builder()
+ .monitorId(501L)
+ .build()));
+ when(entityDefinitionDocumentParserService.parseDefinitionRecords(request)).thenReturn(List.of(rawRecord));
+ when(entityDefinitionNormalizationService.normalizeDefinition(rawRecord)).thenReturn(normalizedDefinition);
+ when(entityDefinitionMappingService.toEntityDto(normalizedDefinition, null, "team-a")).thenReturn(dto);
+ when(entityMonitorBindQueryService.findMonitorBindsByMonitorId(501L)).thenReturn(List.of(EntityMonitorBind.builder()
+ .entityId(302L)
+ .monitorId(501L)
+ .build()));
+
+ IllegalArgumentException exception = assertThrows(IllegalArgumentException.class,
+ () -> definitionDraftService.parseEntityDefinitionBundle(request, "team-a"));
+
+ assertEquals("Monitor already bound to another entity: 501.", exception.getMessage());
+ }
}
diff --git a/hertzbeat-manager/src/test/java/org/apache/hertzbeat/manager/service/entity/EntityDefinitionNormalizationServiceTest.java b/hertzbeat-manager/src/test/java/org/apache/hertzbeat/manager/service/entity/EntityDefinitionNormalizationServiceTest.java
index b2c2f4dea8..3618d1cbbf 100644
--- a/hertzbeat-manager/src/test/java/org/apache/hertzbeat/manager/service/entity/EntityDefinitionNormalizationServiceTest.java
+++ b/hertzbeat-manager/src/test/java/org/apache/hertzbeat/manager/service/entity/EntityDefinitionNormalizationServiceTest.java
@@ -18,6 +18,7 @@
package org.apache.hertzbeat.manager.service.entity;
import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.Mockito.when;
import java.util.List;
@@ -141,4 +142,17 @@ class EntityDefinitionNormalizationServiceTest {
assertEquals("level:error", definition.getHertzbeat().getLogs().getFirst().getQuery());
assertEquals(List.of("checkout", "api"), definition.getHertzbeat().getPerformanceData().getTags());
}
+
+ @Test
+ void normalizeDefinitionRejectsUnsupportedExplicitKindInsteadOfDefaultingToService() {
+ Map<String, Object> record = Map.of(
+ "apiVersion", "hertzbeat/v1",
+ "kind", "cache",
+ "metadata", Map.of("name", "redis-cache"));
+
+ IllegalArgumentException exception = assertThrows(IllegalArgumentException.class,
+ () -> normalizationService.normalizeDefinition(record));
+
+ assertEquals("Unsupported entity definition kind: cache.", exception.getMessage());
+ }
}
diff --git a/hertzbeat-manager/src/test/java/org/apache/hertzbeat/manager/service/entity/EntityDefinitionRelationNormalizationServiceTest.java b/hertzbeat-manager/src/test/java/org/apache/hertzbeat/manager/service/entity/EntityDefinitionRelationNormalizationServiceTest.java
index f48e8b6d7a..0d6e408167 100644
--- a/hertzbeat-manager/src/test/java/org/apache/hertzbeat/manager/service/entity/EntityDefinitionRelationNormalizationServiceTest.java
+++ b/hertzbeat-manager/src/test/java/org/apache/hertzbeat/manager/service/entity/EntityDefinitionRelationNormalizationServiceTest.java
@@ -104,6 +104,29 @@ class EntityDefinitionRelationNormalizationServiceTest {
assertEquals(List.of("service:commerce/orders-worker", "database:commerce/orders-db"), references);
}
+ @Test
+ void dedupesDependsOnWhenExplicitRelationAlreadyNamesSameTargetReference() {
+ EntityDefinitionRelationNormalizationService normalizationService =
+ new EntityDefinitionRelationNormalizationService(entityRelationService);
+ Map<String, Object> specMap = Map.of(
+ "relations", List.of(Map.of(
+ "targetEntityId", "42",
+ "targetRef", "service:commerce/payments-api",
+ "relationType", "depends_on",
+ "relationSource", "definition")),
+ "dependsOn", List.of("service:commerce/payments-api")
+ );
+
+ List<EntityDefinition.Relation> relations = normalizationService.mergeDefinitionRelations(
+ normalizationService.extractDefinitionRelations("relations", specMap),
+ normalizationService.extractDefinitionDependsOn(specMap.get("dependsOn")));
+
+ assertEquals(1, relations.size());
+ assertEquals(42L, relations.getFirst().getTargetEntityId());
+ assertEquals("service:commerce/payments-api", relations.getFirst().getTargetRef());
+ assertEquals("definition", relations.getFirst().getRelationSource());
+ }
+
@Test
void attachesRelationEnvelopeFromCanonicalAndLegacyFields() {
EntityDefinitionRelationNormalizationService normalizationService =
diff --git a/hertzbeat-manager/src/test/java/org/apache/hertzbeat/manager/service/entity/EntityDefinitionTypeResolverServiceTest.java b/hertzbeat-manager/src/test/java/org/apache/hertzbeat/manager/service/entity/EntityDefinitionTypeResolverServiceTest.java
index a55bcf7bbb..a1212ab120 100644
--- a/hertzbeat-manager/src/test/java/org/apache/hertzbeat/manager/service/entity/EntityDefinitionTypeResolverServiceTest.java
+++ b/hertzbeat-manager/src/test/java/org/apache/hertzbeat/manager/service/entity/EntityDefinitionTypeResolverServiceTest.java
@@ -19,6 +19,7 @@ package org.apache.hertzbeat.manager.service.entity;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
import java.util.Map;
import org.junit.jupiter.api.Test;
@@ -57,4 +58,15 @@ class EntityDefinitionTypeResolverServiceTest {
assertEquals("mysql", resolverService.resolveDefinitionSubtype(
Map.of("resourceType", "mysql"), Map.of("type", "database"), "database"));
}
+
+ @Test
+ void rejectsUnsupportedExplicitKindOrEntityTypeInsteadOfDefaultingToService() {
+ IllegalArgumentException kind = assertThrows(IllegalArgumentException.class,
+ () -> resolverService.resolveDefinitionEntityType(Map.of("kind", "cache"), Map.of()));
+ assertEquals("Unsupported entity definition kind: cache.", kind.getMessage());
+
+ IllegalArgumentException entityType = assertThrows(IllegalArgumentException.class,
+ () -> resolverService.resolveDefinitionEntityType(Map.of(), Map.of("entity_type", "cache")));
+ assertEquals("Unsupported entity definition entity type: cache.", entityType.getMessage());
+ }
}
diff --git a/hertzbeat-manager/src/test/java/org/apache/hertzbeat/manager/service/entity/EntityDetailChildEvidenceSourceOwnershipTest.java b/hertzbeat-manager/src/test/java/org/apache/hertzbeat/manager/service/entity/EntityDetailChildEvidenceSourceOwnershipTest.java
index 0c66f0ad55..1a079d5c62 100644
--- a/hertzbeat-manager/src/test/java/org/apache/hertzbeat/manager/service/entity/EntityDetailChildEvidenceSourceOwnershipTest.java
+++ b/hertzbeat-manager/src/test/java/org/apache/hertzbeat/manager/service/entity/EntityDetailChildEvidenceSourceOwnershipTest.java
@@ -56,13 +56,18 @@ class EntityDetailChildEvidenceSourceOwnershipTest {
assertTrue(detailSource.contains("private final EntityRelationService entityRelationService"));
assertTrue(detailSource.contains("entityMonitorBindService.findMonitorBinds(entityId)"));
assertTrue(detailSource.contains("entityRelationService.findEntityRelations(entityId)"));
+ assertTrue(detailSource.contains("entityRelationService.findEntityRelations(entityId, relationPreviewLimit)"));
+ assertTrue(detailSource.contains("entityRelationService.countEntityRelations(entityId)"));
assertTrue(monitorBindSource.contains("public List<EntityMonitorBind> findMonitorBinds(Long entityId)"));
assertTrue(monitorBindSource.contains("entityMonitorBindQueryService.findMonitorBinds(entityId)"));
assertTrue(monitorBindQuerySource.contains("entityMonitorBindDao.findAllByEntityIdOrderByIdAsc(entityId)"));
assertTrue(relationSource.contains("public List<EntityRelation> findEntityRelations(Long entityId)"));
+ assertTrue(relationSource.contains("public List<EntityRelation> findEntityRelations(Long entityId, int limit)"));
assertTrue(relationSource.contains("entityRelationQueryService.findEntityRelations(entityId)"));
+ assertTrue(relationSource.contains("entityRelationQueryService.findEntityRelations(entityId, limit)"));
assertTrue(relationQuerySource.contains(
"entityRelationDao.findBySourceEntityIdOrTargetEntityId(entityId, entityId)"));
+ assertTrue(relationQuerySource.contains("PageRequest.of(0, limit"));
}
}
diff --git a/hertzbeat-manager/src/test/java/org/apache/hertzbeat/manager/service/entity/EntityDetailFacadeHandoffSourceOwnershipTest.java b/hertzbeat-manager/src/test/java/org/apache/hertzbeat/manager/service/entity/EntityDetailFacadeHandoffSourceOwnershipTest.java
index 9c5de8ac9f..63fcb076b9 100644
--- a/hertzbeat-manager/src/test/java/org/apache/hertzbeat/manager/service/entity/EntityDetailFacadeHandoffSourceOwnershipTest.java
+++ b/hertzbeat-manager/src/test/java/org/apache/hertzbeat/manager/service/entity/EntityDetailFacadeHandoffSourceOwnershipTest.java
@@ -49,7 +49,12 @@ class EntityDetailFacadeHandoffSourceOwnershipTest {
"Detail observability read model should own the entity DTO loading handoff");
assertTrue(detailObservabilitySource.contains("public EntityDetailDto buildEntityDetail(long entityId)"),
"Detail observability read model should expose an id-based detail assembly handoff");
- assertTrue(detailObservabilitySource.contains("entityDetailReadModelService.loadEntityDto(entityId)"),
- "Detail observability read model should load the entity DTO before runtime evidence assembly");
+ assertTrue(detailObservabilitySource.contains("ENTITY_DETAIL_RELATION_PREVIEW_LIMIT"),
+ "Detail observability read model should cap relation preview evidence for large graph payloads");
+ assertTrue(detailObservabilitySource.contains(
+ "entityDetailReadModelService.loadEntityDto(\n entityId, ENTITY_DETAIL_RELATION_PREVIEW_LIMIT)"),
+ "Detail observability read model should load a relation preview before runtime evidence assembly");
+ assertTrue(detailObservabilitySource.contains("entityDetailReadModelService.countEntityRelations(entityId)"),
+ "Detail observability read model should count full relations separately from preview rows");
}
}
diff --git a/hertzbeat-manager/src/test/java/org/apache/hertzbeat/manager/service/entity/EntityDetailObservabilityReadModelServiceTest.java b/hertzbeat-manager/src/test/java/org/apache/hertzbeat/manager/service/entity/EntityDetailObservabilityReadModelServiceTest.java
index 9553cbc5e6..1135ec19c0 100644
--- a/hertzbeat-manager/src/test/java/org/apache/hertzbeat/manager/service/entity/EntityDetailObservabilityReadModelServiceTest.java
+++ b/hertzbeat-manager/src/test/java/org/apache/hertzbeat/manager/service/entity/EntityDetailObservabilityReadModelServiceTest.java
@@ -32,6 +32,7 @@ import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.Set;
+import java.util.stream.IntStream;
import org.apache.hertzbeat.common.entity.alerter.SingleAlert;
import org.apache.hertzbeat.common.entity.manager.EntityIdentity;
import org.apache.hertzbeat.common.entity.manager.EntityRelation;
@@ -277,13 +278,101 @@ class EntityDetailObservabilityReadModelServiceTest {
same(opsSummary));
}
+ @Test
+ void buildEntityDetailCapsBoundMonitorPreviewWithoutTruncatingEvidenceInputs() {
+ ObserveEntity entity = ObserveEntity.builder()
+ .id(502L)
+ .name("checkout-api")
+ .type("service")
+ .workspaceId("team-a")
+ .build();
+ EntityDto entityDto = new EntityDto();
+ entityDto.setEntity(entity);
+ entityDto.setIdentities(Collections.emptyList());
+ List<Monitor> monitors = IntStream.rangeClosed(1, 60)
+ .mapToObj(index -> Monitor.builder()
+ .id(700L + index)
+ .app("springboot3")
+ .name("checkout monitor " + index)
+ .status((byte) 2)
+ .build())
+ .toList();
+ List<SingleAlert> activeAlerts = Collections.emptyList();
+ EntityStatusInfo statusInfo = new EntityStatusInfo(
+ "available", "all monitors are up", 60, 60, 0, 0, 0, LocalDateTime.now());
+ EntityEvidenceSummaryInfo evidenceSummary = new EntityEvidenceSummaryInfo(60, 0, 0, 0, 0, 123L);
+ EntityAlertSummaryInfo alertSummary = new EntityAlertSummaryInfo(
+ 0, Collections.emptyList(), Collections.emptyMap(), 123L);
+ EntityMonitorSummaryInfo monitorSummary = new EntityMonitorSummaryInfo(
+ 60, Map.of("springboot3", 60L), Map.of("up", 60L), Collections.emptyList(), 123L);
+ EntityLogSummaryInfo logSummary = new EntityLogSummaryInfo(
+ 0, "none", null, Collections.emptyMap(), Collections.emptyList(), null);
+ EntityTraceSummaryDto traceSummary = new EntityTraceSummaryDto(0, 0, null, false, null);
+ EntityUnifiedEvidenceSummary unifiedSummary = new EntityUnifiedEvidenceSummary(
+ 60, true, false, false, 60, 0, 0, 123L, Collections.emptyList());
+ EntityTriageRecommendation triage = new EntityTriageRecommendation(
+ "stable", "metrics", "Review monitors", "No active alert", null, "Open monitors", 123L);
+ EntityObservabilityDetailBundle detailBundle = new EntityObservabilityDetailBundle(
+ traceSummary, Collections.emptyList(), logSummary, Collections.emptyList(),
+ Collections.emptyList(), Collections.emptyList(), Collections.emptyList(), unifiedSummary, triage);
+ EntityOpsSummaryInfo opsSummary = new EntityOpsSummaryInfo(true, true, false, false, true, 80);
+ EntityStatusPageSummaryInfo statusPageSummary = new EntityStatusPageSummaryInfo(true, 0, 123L, false);
+ EntityResponseHandoffsInfo handoffs = new EntityResponseHandoffsInfo();
+ EntityDetailDto.EntityNoiseControlSummaryInfo noiseSummary =
+ new EntityDetailDto.EntityNoiseControlSummaryInfo(0, 0, Collections.emptyList(),
+ Collections.emptyList(), false);
+
+ EntityStatusRefreshService.EntityRuntimeStatusEvidence runtimeEvidence =
+ new EntityStatusRefreshService.EntityRuntimeStatusEvidence(monitors, activeAlerts, statusInfo);
+ when(entityStatusRefreshService.refreshEntityStatusWithEvidence(entity, "team-a")).thenReturn(runtimeEvidence);
+ when(entityObservabilityGateway.buildEntityLogQueryHints(Collections.emptyList(), monitors))
+ .thenReturn(Collections.emptyList());
+ when(entityObservabilityGateway.buildEntityEvidenceSummary(
+ entity, statusInfo, 0L, 0, monitors, activeAlerts)).thenReturn(evidenceSummary);
+ when(entityObservabilityGateway.buildEntityAlertSummary(activeAlerts)).thenReturn(alertSummary);
+ when(entityObservabilityGateway.buildEntityMonitorSummary(monitors)).thenReturn(monitorSummary);
+ when(entityObservabilityGateway.buildEntityLogSummary(Collections.emptyList())).thenReturn(logSummary);
+ when(entityObservabilityGateway.resolveEntityDetailBundle(
+ any(ObservedEntityContext.class), same(statusInfo), same(evidenceSummary), same(monitorSummary),
+ same(logSummary), same(monitors), eq(Collections.emptyList()))).thenReturn(detailBundle);
+ when(entityObservabilityGateway.buildEntityOpsSummary(entity, 0L, evidenceSummary)).thenReturn(opsSummary);
+ when(entityObservabilityGateway.buildEntityNextActions(entity, evidenceSummary, logSummary, opsSummary))
+ .thenReturn(Collections.emptyList());
+ when(entityObservabilityGateway.buildEntityStatusPageSummary(entity, opsSummary)).thenReturn(statusPageSummary);
+ when(entityResponseHandoffReadModelService.buildResponseHandoffs(
+ eq(502L), any(ObservedEntityContext.class), same(activeAlerts), same(monitors),
+ any(EntitySignalEvidenceBundle.class), same(opsSummary))).thenReturn(handoffs);
+ when(entityNoiseControlReadModelService.buildNoiseControlSummary(
+ entityDto, monitors, activeAlerts, "team-a")).thenReturn(noiseSummary);
+ when(entityActivityReadModelService.getDefinitionActivities(502L, 12, "team-a"))
+ .thenReturn(Collections.emptyList());
+
+ EntityDetailDto detail = entityDetailObservabilityReadModelService.buildEntityDetail(entityDto, "team-a");
+
+ assertEquals(EntityDetailObservabilityReadModelService.ENTITY_DETAIL_BOUND_MONITOR_PREVIEW_LIMIT,
+ detail.getBoundMonitors().size());
+ assertEquals(701L, detail.getBoundMonitors().getFirst().getId());
+ assertEquals(750L, detail.getBoundMonitors().getLast().getId());
+ assertSame(monitorSummary, detail.getMonitorSummary());
+ verify(entityObservabilityGateway).buildEntityMonitorSummary(same(monitors));
+ verify(entityObservabilityGateway).resolveEntityDetailBundle(
+ any(ObservedEntityContext.class), same(statusInfo), same(evidenceSummary), same(monitorSummary),
+ same(logSummary), same(monitors), eq(Collections.emptyList()));
+ verify(entityResponseHandoffReadModelService).buildResponseHandoffs(
+ eq(502L), any(ObservedEntityContext.class), same(activeAlerts), same(monitors),
+ any(EntitySignalEvidenceBundle.class), same(opsSummary));
+ }
+
@Test
void buildEntityDetailByIdReturnsNullWhenEntityReadModelHasNoAccessibleEntity() {
- when(entityDetailReadModelService.loadEntityDto(501L)).thenReturn(null);
+ when(entityDetailReadModelService.loadEntityDto(
+ 501L, EntityDetailObservabilityReadModelService.ENTITY_DETAIL_RELATION_PREVIEW_LIMIT))
+ .thenReturn(null);
assertNull(entityDetailObservabilityReadModelService.buildEntityDetail(501L));
- verify(entityDetailReadModelService).loadEntityDto(501L);
+ verify(entityDetailReadModelService).loadEntityDto(
+ 501L, EntityDetailObservabilityReadModelService.ENTITY_DETAIL_RELATION_PREVIEW_LIMIT);
verifyNoInteractions(entityStatusRefreshService, entityObservabilityGateway,
entityResponseHandoffReadModelService, entityNoiseControlReadModelService,
entityActivityReadModelService, entityWorkspaceAccessService);
diff --git a/hertzbeat-manager/src/test/java/org/apache/hertzbeat/manager/service/entity/EntityDetailReadModelServiceTest.java b/hertzbeat-manager/src/test/java/org/apache/hertzbeat/manager/service/entity/EntityDetailReadModelServiceTest.java
index 5d34ca11b2..c4dc750a1b 100644
--- a/hertzbeat-manager/src/test/java/org/apache/hertzbeat/manager/service/entity/EntityDetailReadModelServiceTest.java
+++ b/hertzbeat-manager/src/test/java/org/apache/hertzbeat/manager/service/entity/EntityDetailReadModelServiceTest.java
@@ -19,6 +19,7 @@ package org.apache.hertzbeat.manager.service.entity;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
@@ -123,6 +124,38 @@ class EntityDetailReadModelServiceTest {
assertEquals(List.of(), dto.getRelations());
}
+ @Test
+ void loadEntityDtoWithRelationPreviewLimitKeepsDefinitionLoadsSeparateFromDetailPreviewLoads() {
+ ObserveEntity entity = ObserveEntity.builder()
+ .id(807L)
+ .name("checkout-graph")
+ .workspaceId("team-a")
+ .build();
+ EntityRelation relation = EntityRelation.builder()
+ .id(907L)
+ .sourceEntityId(807L)
+ .targetEntityId(808L)
+ .relationType("calls")
+ .build();
+ when(entityWorkspaceAccessService.findAccessibleEntityForRequestWorkspace(807L))
+ .thenReturn(Optional.of(entity));
+ when(entityIdentityReadModelService.findIdentities(807L)).thenReturn(List.of());
+ when(entityMonitorBindService.findMonitorBinds(807L)).thenReturn(List.of());
+ when(entityRelationService.findEntityRelations(807L, 50)).thenReturn(List.of(relation));
+
+ EntityDto dto = entityDetailReadModelService.loadEntityDto(807L, 50);
+
+ assertEquals(List.of(relation), dto.getRelations());
+ verify(entityRelationService).findEntityRelations(807L, 50);
+ }
+
+ @Test
+ void countEntityRelationsDelegatesToRelationBoundary() {
+ when(entityRelationService.countEntityRelations(807L)).thenReturn(99L);
+
+ assertEquals(99L, entityDetailReadModelService.countEntityRelations(807L));
+ }
+
@Test
void loadEntityDtoReturnsNullBeforeChildLookupsWhenWorkspaceRejectsEntity() {
when(entityWorkspaceAccessService.findAccessibleEntityById(811L, "team-a"))
diff --git a/hertzbeat-manager/src/test/java/org/apache/hertzbeat/manager/service/entity/EntityIdentityQueryServiceTest.java b/hertzbeat-manager/src/test/java/org/apache/hertzbeat/manager/service/entity/EntityIdentityQueryServiceTest.java
index 5c77f17bd6..4ebf72f774 100644
--- a/hertzbeat-manager/src/test/java/org/apache/hertzbeat/manager/service/entity/EntityIdentityQueryServiceTest.java
+++ b/hertzbeat-manager/src/test/java/org/apache/hertzbeat/manager/service/entity/EntityIdentityQueryServiceTest.java
@@ -22,6 +22,7 @@ import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import java.util.List;
+import java.util.Map;
import java.util.Set;
import org.apache.hertzbeat.common.entity.manager.EntityIdentity;
import org.apache.hertzbeat.manager.dao.EntityIdentityDao;
@@ -71,6 +72,16 @@ class EntityIdentityQueryServiceTest {
verify(entityIdentityDao).countByEntityId(801L);
}
+ @Test
+ void countIdentitiesByEntityIdsBatchesCurrentPageRows() {
+ List<Long> entityIds = List.of(801L, 802L);
+ when(entityIdentityDao.countByEntityIdInGroupByEntityId(entityIds))
+ .thenReturn(List.<Object[]>of(new Object[] {801L, 2L}, new Object[] {802L, 1L}));
+
+ assertEquals(Map.of(801L, 2L, 802L, 1L), entityIdentityQueryService.countIdentitiesByEntityIds(entityIds));
+ verify(entityIdentityDao).countByEntityIdInGroupByEntityId(entityIds);
+ }
+
@Test
void findMatchingIdentitiesUsesPersistedKeysAndNormalizedValues() {
EntityIdentity serviceIdentity = EntityIdentity.builder()
diff --git a/hertzbeat-manager/src/test/java/org/apache/hertzbeat/manager/service/entity/EntityIdentityWriteModelServiceTest.java b/hertzbeat-manager/src/test/java/org/apache/hertzbeat/manager/service/entity/EntityIdentityWriteModelServiceTest.java
index 159f49f536..2a815da537 100644
--- a/hertzbeat-manager/src/test/java/org/apache/hertzbeat/manager/service/entity/EntityIdentityWriteModelServiceTest.java
+++ b/hertzbeat-manager/src/test/java/org/apache/hertzbeat/manager/service/entity/EntityIdentityWriteModelServiceTest.java
@@ -18,8 +18,10 @@
package org.apache.hertzbeat.manager.service.entity;
import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.ArgumentMatchers.anySet;
import static org.mockito.Mockito.inOrder;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.verify;
@@ -28,6 +30,7 @@ import static org.mockito.Mockito.when;
import java.util.Collections;
import java.util.List;
import java.util.Locale;
+import java.util.Set;
import org.apache.hertzbeat.common.entity.manager.EntityIdentity;
import org.apache.hertzbeat.common.entity.manager.ObserveEntity;
import org.apache.hertzbeat.manager.dao.EntityIdentityDao;
@@ -53,10 +56,14 @@ class EntityIdentityWriteModelServiceTest {
@Mock
private EntityIdentityResolutionService entityIdentityResolutionService;
+ @Mock
+ private EntityWorkspaceAccessService entityWorkspaceAccessService;
+
@BeforeEach
void setUp() {
identityWriteModelService =
- new EntityIdentityWriteModelService(entityIdentityDao, entityIdentityResolutionService);
+ new EntityIdentityWriteModelService(entityIdentityDao, entityIdentityResolutionService,
+ entityWorkspaceAccessService);
when(entityIdentityResolutionService.normalizeIdentityValue(anyString(), anyString()))
.thenAnswer(invocation -> invocation.getArgument(1, String.class).trim().toLowerCase(Locale.ROOT));
when(entityIdentityResolutionService.defaultIdentityPriority(anyString())).thenReturn(40);
@@ -64,6 +71,8 @@ class EntityIdentityWriteModelServiceTest {
lenient().when(entityIdentityResolutionService.defaultIdentityPriority("service.namespace")).thenReturn(30);
lenient().when(entityIdentityResolutionService.defaultIdentityPriority("deployment.environment.name")).thenReturn(20);
lenient().when(entityIdentityResolutionService.defaultIdentityPriority("messaging.destination.name")).thenReturn(120);
+ lenient().when(entityIdentityDao.findAllByIdentityKeyInAndNormalizedValueIn(anySet(), anySet()))
+ .thenReturn(Collections.emptyList());
}
@Test
@@ -103,6 +112,8 @@ class EntityIdentityWriteModelServiceTest {
InOrder inOrder = inOrder(entityIdentityDao);
inOrder.verify(entityIdentityDao).deleteAllByEntityId(7L);
inOrder.verify(entityIdentityDao).flush();
+ inOrder.verify(entityIdentityDao).findAllByIdentityKeyInAndNormalizedValueIn(
+ Set.of("service.name"), Set.of("checkout-api"));
@SuppressWarnings("unchecked")
ArgumentCaptor<List<EntityIdentity>> identitiesCaptor = ArgumentCaptor.forClass(List.class);
@@ -199,4 +210,72 @@ class EntityIdentityWriteModelServiceTest {
assertEquals("prod", environmentIdentity.getIdentityValue());
assertEquals(20, environmentIdentity.getPriority());
}
+
+ @Test
+ void replaceIdentitiesRejectsAccessiblePrimaryIdentityCollision() {
+ ObserveEntity entity = ObserveEntity.builder()
+ .id(9L)
+ .type("service")
+ .name("catalog-b")
+ .workspaceId("team-a")
+ .build();
+ EntityIdentity sharedPrimary = EntityIdentity.builder()
+ .identityType("manual")
+ .identityKey("service.name")
+ .identityValue("shared-checkout")
+ .primaryIdentity(true)
+ .build();
+ EntityIdentity existing = EntityIdentity.builder()
+ .entityId(8L)
+ .identityKey("service.name")
+ .identityValue("shared-checkout")
+ .normalizedValue("shared-checkout")
+ .primaryIdentity(true)
+ .build();
+ when(entityIdentityDao.findAllByIdentityKeyInAndNormalizedValueIn(
+ Set.of("service.name"), Set.of("shared-checkout")))
+ .thenReturn(List.of(existing));
+ when(entityWorkspaceAccessService.findAccessibleEntitiesByIdsForRequestWorkspace(Set.of(8L)))
+ .thenReturn(List.of(ObserveEntity.builder().id(8L).name("catalog-a").build()));
+
+ IllegalArgumentException thrown = assertThrows(IllegalArgumentException.class,
+ () -> identityWriteModelService.replaceIdentities(entity, List.of(sharedPrimary)));
+
+ assertEquals("Entity primary identity already exists: service.name=shared-checkout.", thrown.getMessage());
+ }
+
+ @Test
+ void replaceIdentitiesAllowsSharedNonPrimaryContextIdentities() {
+ ObserveEntity entity = ObserveEntity.builder()
+ .id(10L)
+ .type("service")
+ .name("catalog-c")
+ .workspaceId("team-a")
+ .build();
+ EntityIdentity primary = EntityIdentity.builder()
+ .identityType("manual")
+ .identityKey("service.name")
+ .identityValue("catalog-c")
+ .primaryIdentity(true)
+ .build();
+ EntityIdentity sharedNamespace = EntityIdentity.builder()
+ .identityType("manual")
+ .identityKey("service.namespace")
+ .identityValue("shared-namespace")
+ .primaryIdentity(false)
+ .build();
+ when(entityIdentityDao.findAllByIdentityKeyInAndNormalizedValueIn(
+ Set.of("service.name"), Set.of("catalog-c")))
+ .thenReturn(Collections.emptyList());
+
+ identityWriteModelService.replaceIdentities(entity, List.of(primary, sharedNamespace));
+
+ @SuppressWarnings("unchecked")
+ ArgumentCaptor<List<EntityIdentity>> identitiesCaptor = ArgumentCaptor.forClass(List.class);
+ verify(entityIdentityDao).saveAll(identitiesCaptor.capture());
+ List<EntityIdentity> rows = identitiesCaptor.getValue();
+ assertEquals(2, rows.size());
+ assertEquals("service.namespace", rows.get(1).getIdentityKey());
+ assertEquals("shared-namespace", rows.get(1).getIdentityValue());
+ }
}
diff --git a/hertzbeat-manager/src/test/java/org/apache/hertzbeat/manager/service/entity/EntityIntegrationHintServiceTest.java b/hertzbeat-manager/src/test/java/org/apache/hertzbeat/manager/service/entity/EntityIntegrationHintServiceTest.java
index 841552b2c4..ac83eb6f74 100644
--- a/hertzbeat-manager/src/test/java/org/apache/hertzbeat/manager/service/entity/EntityIntegrationHintServiceTest.java
+++ b/hertzbeat-manager/src/test/java/org/apache/hertzbeat/manager/service/entity/EntityIntegrationHintServiceTest.java
@@ -23,10 +23,13 @@ import static org.mockito.Mockito.inOrder;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
+import java.util.Arrays;
import java.util.List;
import java.util.Map;
import java.util.Optional;
+import org.apache.hertzbeat.common.entity.manager.EntityMonitorBind;
import org.apache.hertzbeat.common.entity.manager.Monitor;
+import org.apache.hertzbeat.common.entity.manager.ObserveEntity;
import org.apache.hertzbeat.manager.pojo.dto.EntityMonitorBindingCandidate;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
@@ -49,10 +52,19 @@ class EntityIntegrationHintServiceTest {
@Mock
private EntityIdentityResolutionService entityIdentityResolutionService;
+ @Mock
+ private EntityMonitorBindService entityMonitorBindService;
+
+ @Mock
+ private EntityWorkspaceAccessService entityWorkspaceAccessService;
+
@BeforeEach
void setUp() {
integrationHintService = new EntityIntegrationHintService(
- entityMonitorQueryService, entityIdentityResolutionService);
+ entityMonitorQueryService,
+ entityIdentityResolutionService,
+ entityMonitorBindService,
+ entityWorkspaceAccessService);
}
@Test
@@ -64,6 +76,8 @@ class EntityIntegrationHintServiceTest {
assertTrue(candidates.isEmpty());
verifyNoInteractions(entityIdentityResolutionService);
+ verifyNoInteractions(entityMonitorBindService);
+ verifyNoInteractions(entityWorkspaceAccessService);
}
@Test
@@ -84,13 +98,76 @@ class EntityIntegrationHintServiceTest {
when(entityMonitorQueryService.findMonitor(502L)).thenReturn(Optional.of(monitor));
when(entityIdentityResolutionService.resolveMonitorBindingCandidates(monitor))
.thenReturn(List.of(candidate));
+ when(entityMonitorBindService.findMonitorBindsByMonitorId(502L)).thenReturn(List.of());
List<EntityMonitorBindingCandidate> candidates =
integrationHintService.getMonitorBindingCandidates(502L);
assertEquals(List.of(candidate), candidates);
- InOrder inOrder = inOrder(entityMonitorQueryService, entityIdentityResolutionService);
+ InOrder inOrder = inOrder(entityMonitorQueryService, entityIdentityResolutionService, entityMonitorBindService);
inOrder.verify(entityMonitorQueryService).findMonitor(502L);
inOrder.verify(entityIdentityResolutionService).resolveMonitorBindingCandidates(monitor);
+ inOrder.verify(entityMonitorBindService).findMonitorBindsByMonitorId(502L);
+ }
+
+ @Test
+ void getMonitorBindingCandidatesIncludesExistingBoundEntityWithoutIdentityMatch() {
+ Monitor monitor = new Monitor();
+ monitor.setId(503L);
+ monitor.setApp("website");
+ monitor.setName("checkout-probe");
+ when(entityMonitorQueryService.findMonitor(503L)).thenReturn(Optional.of(monitor));
+ when(entityIdentityResolutionService.resolveMonitorBindingCandidates(monitor)).thenReturn(List.of());
+ when(entityMonitorBindService.findMonitorBindsByMonitorId(503L)).thenReturn(List.of(
+ EntityMonitorBind.builder().entityId(91L).monitorId(503L).bindType("manual").build()
+ ));
+ when(entityWorkspaceAccessService.findAccessibleEntitiesByIdsForRequestWorkspace(java.util.Set.of(91L)))
+ .thenReturn(List.of(ObserveEntity.builder()
+ .id(91L)
+ .type("service")
+ .name("checkout-api")
+ .displayName("Checkout API")
+ .build()));
+
+ List<EntityMonitorBindingCandidate> candidates =
+ integrationHintService.getMonitorBindingCandidates(503L);
+
+ assertEquals(1, candidates.size());
+ EntityMonitorBindingCandidate candidate = candidates.getFirst();
+ assertEquals(91L, candidate.getEntityId());
+ assertEquals("Checkout API", candidate.getEntityName());
+ assertEquals("service", candidate.getEntityType());
+ assertEquals(0, candidate.getScore());
+ assertEquals("already_bound", candidate.getRecommendation());
+ assertTrue(candidate.isAlreadyBound());
+ assertEquals(Map.of(), candidate.getMatchedIdentities());
+ }
+
+ @Test
+ void getMonitorBindingCandidatesBatchesDistinctMonitorIdsInOrder() {
+ Monitor monitor502 = new Monitor();
+ monitor502.setId(502L);
+ monitor502.setApp("springboot3");
+ monitor502.setName("checkout-api");
+ EntityMonitorBindingCandidate candidate = new EntityMonitorBindingCandidate(
+ 90L,
+ "Checkout API",
+ "service",
+ 120,
+ "direct",
+ false,
+ Map.of("service.name", List.of("checkout-api")));
+ when(entityMonitorQueryService.findMonitor(502L)).thenReturn(Optional.of(monitor502));
+ when(entityIdentityResolutionService.resolveMonitorBindingCandidates(monitor502))
+ .thenReturn(List.of(candidate));
+ when(entityMonitorBindService.findMonitorBindsByMonitorId(502L)).thenReturn(List.of());
+ when(entityMonitorQueryService.findMonitor(503L)).thenReturn(Optional.empty());
+
+ Map<Long, List<EntityMonitorBindingCandidate>> candidates =
+ integrationHintService.getMonitorBindingCandidates(Arrays.asList(502L, 503L, 502L, null, -1L));
+
+ assertEquals(List.of(502L, 503L), List.copyOf(candidates.keySet()));
+ assertEquals(List.of(candidate), candidates.get(502L));
+ assertTrue(candidates.get(503L).isEmpty());
}
}
diff --git a/hertzbeat-manager/src/test/java/org/apache/hertzbeat/manager/service/entity/EntityListCatalogQuerySourceOwnershipTest.java b/hertzbeat-manager/src/test/java/org/apache/hertzbeat/manager/service/entity/EntityListCatalogQuerySourceOwnershipTest.java
index fcaad79ea2..6b72ed0cbe 100644
--- a/hertzbeat-manager/src/test/java/org/apache/hertzbeat/manager/service/entity/EntityListCatalogQuerySourceOwnershipTest.java
+++ b/hertzbeat-manager/src/test/java/org/apache/hertzbeat/manager/service/entity/EntityListCatalogQuerySourceOwnershipTest.java
@@ -69,6 +69,21 @@ class EntityListCatalogQuerySourceOwnershipTest {
assertTrue(querySource.contains("entityWorkspaceQueryService.findEntityPage("));
assertTrue(querySource.contains("buildEntitySpecification"));
assertTrue(querySource.contains("buildSearchPredicates"));
+ assertTrue(querySource.contains("for (String normalizedSearch : normalizeSearchTextVariants(search))"),
+ "Catalog search should normalize exact copied endpoint names before predicate construction");
+ assertTrue(querySource.contains("URLDecoder.decode(trimmedSearch, StandardCharsets.UTF_8)"),
+ "Catalog search should tolerate URL-encoded copied endpoint names from the BFF boundary");
+ assertTrue(querySource.contains("normalizedSearch.replace(\"localhost\", \"127.0.0.1\")"),
+ "Catalog search should treat localhost and loopback endpoint names as equivalent");
+ assertTrue(querySource.contains("criteriaBuilder.equal(criteriaBuilder.lower(root.get(\"name\")), normalizedSearch)"),
+ "Catalog search should exact-match entity names such as copied endpoint IP:port values");
+ assertTrue(querySource.contains(
+ "criteriaBuilder.equal(criteriaBuilder.lower(root.get(\"displayName\")), normalizedSearch)"),
+ "Catalog search should exact-match display names before falling back to fuzzy LIKE");
+ assertTrue(querySource.contains("normalizedSearch.split(\"[^a-z0-9]+\")"),
+ "Catalog search should tokenize copied endpoint names such as 127.0.0.1:18608");
+ assertTrue(querySource.contains("criteriaBuilder.and(nameTokenPredicates.toArray(new Predicate[0]))"),
+ "Catalog search should require all copied endpoint name tokens to match the same field");
assertTrue(querySource.contains("TYPE_API"));
assertTrue(querySource.contains("TYPE_ENDPOINT"));
assertTrue(querySource.contains("Longs.tryParse(search)"));
diff --git a/hertzbeat-manager/src/test/java/org/apache/hertzbeat/manager/service/entity/EntityListReadModelServiceTest.java b/hertzbeat-manager/src/test/java/org/apache/hertzbeat/manager/service/entity/EntityListReadModelServiceTest.java
index 5b6346fb79..5c55233b0c 100644
--- a/hertzbeat-manager/src/test/java/org/apache/hertzbeat/manager/service/entity/EntityListReadModelServiceTest.java
+++ b/hertzbeat-manager/src/test/java/org/apache/hertzbeat/manager/service/entity/EntityListReadModelServiceTest.java
@@ -91,6 +91,10 @@ class EntityListReadModelServiceTest {
.build();
when(entitySummaryReadModelService.loadLatestDefinitionActivities(List.of(teamAlpha)))
.thenReturn(Map.of(31L, definitionActivity));
+ EntitySummaryReadModelService.EntitySummaryCounts summaryCounts =
+ new EntitySummaryReadModelService.EntitySummaryCounts(2, 1, 3);
+ when(entitySummaryReadModelService.loadSummaryCounts(List.of(teamAlpha)))
+ .thenReturn(Map.of(31L, summaryCounts));
List<Monitor> monitors = List.of(
Monitor.builder().id(701L).status(CommonConstants.MONITOR_UP_CODE).build()
);
@@ -99,13 +103,15 @@ class EntityListReadModelServiceTest {
);
EntityStatusRefreshService.EntityRuntimeStatusEvidence runtimeEvidence =
new EntityStatusRefreshService.EntityRuntimeStatusEvidence(monitors, List.of(), statusInfo);
- when(entityStatusRefreshService.refreshEntityStatusWithEvidence(teamAlpha, "team-a")).thenReturn(runtimeEvidence);
+ when(entityStatusRefreshService.refreshEntityStatusesWithEvidence(List.of(teamAlpha), "team-a"))
+ .thenReturn(Map.of(31L, runtimeEvidence));
EntitySummaryInfo summaryInfo = new EntitySummaryInfo();
EntityInfo entityInfo = new EntityInfo();
entityInfo.setId(31L);
entityInfo.setWorkspaceId("team-a");
summaryInfo.setEntity(entityInfo);
- when(entitySummaryReadModelService.buildEntitySummary(teamAlpha, definitionActivity, statusInfo, monitors))
+ when(entitySummaryReadModelService.buildEntitySummary(teamAlpha, definitionActivity, statusInfo, monitors,
+ summaryCounts))
.thenReturn(summaryInfo);
Page<EntitySummaryInfo> page = entityListReadModelService.getEntities(
@@ -120,7 +126,8 @@ class EntityListReadModelServiceTest {
eq(null), eq("service"), eq(null), eq("checkout"), eq(null), eq(null), eq(null), eq(null), eq(null),
eq(null), eq("gmtUpdate"), eq("desc"), eq(0), eq(8), eq("team-a"));
verify(entitySummaryReadModelService).loadLatestDefinitionActivities(List.of(teamAlpha));
- verify(entityStatusRefreshService).refreshEntityStatusWithEvidence(teamAlpha, "team-a");
+ verify(entitySummaryReadModelService).loadSummaryCounts(List.of(teamAlpha));
+ verify(entityStatusRefreshService).refreshEntityStatusesWithEvidence(List.of(teamAlpha), "team-a");
}
@Test
@@ -137,15 +144,19 @@ class EntityListReadModelServiceTest {
any(), any(), anyInt(), anyInt(), any()))
.thenReturn(new PageImpl<>(List.of(entity), PageRequest.of(0, 10), 42));
when(entitySummaryReadModelService.loadLatestDefinitionActivities(List.of(entity))).thenReturn(Map.of());
+ EntitySummaryReadModelService.EntitySummaryCounts summaryCounts =
+ new EntitySummaryReadModelService.EntitySummaryCounts(1, 0, 0);
+ when(entitySummaryReadModelService.loadSummaryCounts(List.of(entity))).thenReturn(Map.of(41L, summaryCounts));
EntityStatusInfo statusInfo = new EntityStatusInfo(
"unknown", "no live evidence bound yet", 0, 0, 0, 0, 0, LocalDateTime.now()
);
EntityStatusRefreshService.EntityRuntimeStatusEvidence runtimeEvidence =
new EntityStatusRefreshService.EntityRuntimeStatusEvidence(List.of(), List.of(), statusInfo);
- when(entityStatusRefreshService.refreshEntityStatusWithEvidence(entity, "team-a")).thenReturn(runtimeEvidence);
+ when(entityStatusRefreshService.refreshEntityStatusesWithEvidence(List.of(entity), "team-a"))
+ .thenReturn(Map.of(41L, runtimeEvidence));
EntitySummaryInfo summaryInfo = new EntitySummaryInfo();
summaryInfo.setEntity(EntityInfo.fromEntity(entity));
- when(entitySummaryReadModelService.buildEntitySummary(entity, null, statusInfo, List.of()))
+ when(entitySummaryReadModelService.buildEntitySummary(entity, null, statusInfo, List.of(), summaryCounts))
.thenReturn(summaryInfo);
Page<EntitySummaryInfo> page = entityListReadModelService.getEntities(
@@ -155,4 +166,43 @@ class EntityListReadModelServiceTest {
assertEquals(42, page.getTotalElements());
assertEquals(41L, page.getContent().getFirst().getEntity().getId());
}
+
+ @Test
+ void getEntitiesCapsOversizedPagesBeforeRuntimeEvidenceFanout() {
+ ObserveEntity entity = ObserveEntity.builder()
+ .id(51L)
+ .type("service")
+ .name("scale-proof")
+ .status("unknown")
+ .build();
+ when(entityCatalogQueryService.findEntityPage(
+ any(), any(), any(), any(), any(), any(), any(), any(), any(), any(),
+ any(), any(), anyInt(), anyInt()))
+ .thenReturn(new PageImpl<>(List.of(entity), PageRequest.of(0, 50), 1993));
+ when(entitySummaryReadModelService.loadLatestDefinitionActivities(List.of(entity))).thenReturn(Map.of());
+ EntitySummaryReadModelService.EntitySummaryCounts summaryCounts =
+ new EntitySummaryReadModelService.EntitySummaryCounts(1, 0, 0);
+ when(entitySummaryReadModelService.loadSummaryCounts(List.of(entity))).thenReturn(Map.of(51L, summaryCounts));
+ EntityStatusInfo statusInfo = new EntityStatusInfo(
+ "unknown", "no live evidence bound yet", 0, 0, 0, 0, 0, LocalDateTime.now()
+ );
+ EntityStatusRefreshService.EntityRuntimeStatusEvidence runtimeEvidence =
+ new EntityStatusRefreshService.EntityRuntimeStatusEvidence(List.of(), List.of(), statusInfo);
+ when(entityStatusRefreshService.refreshEntityStatusesWithEvidence(List.of(entity)))
+ .thenReturn(Map.of(51L, runtimeEvidence));
+ EntitySummaryInfo summaryInfo = new EntitySummaryInfo();
+ summaryInfo.setEntity(EntityInfo.fromEntity(entity));
+ when(entitySummaryReadModelService.buildEntitySummary(entity, null, statusInfo, List.of(), summaryCounts))
+ .thenReturn(summaryInfo);
+
+ Page<EntitySummaryInfo> page = entityListReadModelService.getEntities(
+ null, null, null, null, null, null, null, null, null, null,
+ "gmtUpdate", "desc", -4, 1000);
+
+ assertEquals(1993, page.getTotalElements());
+ assertEquals(51L, page.getContent().getFirst().getEntity().getId());
+ verify(entityCatalogQueryService).findEntityPage(
+ eq(null), eq(null), eq(null), eq(null), eq(null), eq(null), eq(null), eq(null), eq(null), eq(null),
+ eq("gmtUpdate"), eq("desc"), eq(0), eq(50));
+ }
}
diff --git a/hertzbeat-manager/src/test/java/org/apache/hertzbeat/manager/service/entity/EntityListRuntimeEvidenceSourceOwnershipTest.java b/hertzbeat-manager/src/test/java/org/apache/hertzbeat/manager/service/entity/EntityListRuntimeEvidenceSourceOwnershipTest.java
index fb0ac33dc7..06268bc645 100644
--- a/hertzbeat-manager/src/test/java/org/apache/hertzbeat/manager/service/entity/EntityListRuntimeEvidenceSourceOwnershipTest.java
+++ b/hertzbeat-manager/src/test/java/org/apache/hertzbeat/manager/service/entity/EntityListRuntimeEvidenceSourceOwnershipTest.java
@@ -51,11 +51,12 @@ class EntityListRuntimeEvidenceSourceOwnershipTest {
assertTrue(listSource.contains("private final EntityStatusRefreshService entityStatusRefreshService"));
assertTrue(listSource.contains("EntityStatusRefreshService.EntityRuntimeStatusEvidence runtimeEvidence"));
- assertTrue(listSource.contains("entityStatusRefreshService.refreshEntityStatusWithEvidence(entity, requestWorkspaceId)"));
+ assertTrue(listSource.contains("entityStatusRefreshService.refreshEntityStatusesWithEvidence(scopedEntities, requestWorkspaceId)"));
assertTrue(listSource.contains("runtimeEvidence.monitors()"));
assertTrue(listSource.contains("runtimeEvidence.statusInfo()"));
assertTrue(statusRefreshSource.contains("public EntityRuntimeStatusEvidence refreshEntityStatusWithEvidence("));
+ assertTrue(statusRefreshSource.contains("public Map<Long, EntityRuntimeStatusEvidence> refreshEntityStatusesWithEvidence("));
assertTrue(statusRefreshSource.contains("public record EntityRuntimeStatusEvidence("));
assertTrue(statusRefreshSource.contains("List<Monitor> monitors"));
assertTrue(statusRefreshSource.contains("List<SingleAlert> activeAlerts"));
diff --git a/hertzbeat-manager/src/test/java/org/apache/hertzbeat/manager/service/entity/EntityMonitorBindQueryServiceTest.java b/hertzbeat-manager/src/test/java/org/apache/hertzbeat/manager/service/entity/EntityMonitorBindQueryServiceTest.java
index 66d8d0ac8a..98b8cdf724 100644
--- a/hertzbeat-manager/src/test/java/org/apache/hertzbeat/manager/service/entity/EntityMonitorBindQueryServiceTest.java
+++ b/hertzbeat-manager/src/test/java/org/apache/hertzbeat/manager/service/entity/EntityMonitorBindQueryServiceTest.java
@@ -23,6 +23,7 @@ import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
import java.util.List;
+import java.util.Map;
import org.apache.hertzbeat.common.entity.manager.EntityMonitorBind;
import org.apache.hertzbeat.manager.dao.EntityMonitorBindDao;
import org.junit.jupiter.api.Test;
@@ -57,6 +58,30 @@ class EntityMonitorBindQueryServiceTest {
assertEquals(List.of(monitorBind), binds);
}
+ @Test
+ void findMonitorBindsByEntityIdsBatchesCurrentPageRows() {
+ List<Long> entityIds = List.of(301L, 302L);
+ EntityMonitorBind firstBind = EntityMonitorBind.builder()
+ .id(401L)
+ .entityId(301L)
+ .monitorId(501L)
+ .build();
+ EntityMonitorBind secondBind = EntityMonitorBind.builder()
+ .id(402L)
+ .entityId(302L)
+ .monitorId(502L)
+ .build();
+ when(entityMonitorBindDao.findAllByEntityIdInOrderByEntityIdAscIdAsc(entityIds))
+ .thenReturn(List.of(firstBind, secondBind));
+
+ Map<Long, List<EntityMonitorBind>> bindsByEntityId =
+ entityMonitorBindQueryService.findMonitorBindsByEntityIds(entityIds);
+
+ assertEquals(List.of(firstBind), bindsByEntityId.get(301L));
+ assertEquals(List.of(secondBind), bindsByEntityId.get(302L));
+ verify(entityMonitorBindDao).findAllByEntityIdInOrderByEntityIdAscIdAsc(entityIds);
+ }
+
@Test
void findMonitorBindsByMonitorIdReturnsPersistedBinds() {
EntityMonitorBind monitorBind = EntityMonitorBind.builder()
@@ -84,4 +109,14 @@ class EntityMonitorBindQueryServiceTest {
assertEquals(2L, entityMonitorBindQueryService.countMonitorBinds(301L));
verify(entityMonitorBindDao).countByEntityId(301L);
}
+
+ @Test
+ void countMonitorBindsByEntityIdsBatchesCurrentPageRows() {
+ List<Long> entityIds = List.of(301L, 302L);
+ when(entityMonitorBindDao.countByEntityIdInGroupByEntityId(entityIds))
+ .thenReturn(List.<Object[]>of(new Object[] {301L, 2L}, new Object[] {302L, 1L}));
+
+ assertEquals(Map.of(301L, 2L, 302L, 1L), entityMonitorBindQueryService.countMonitorBindsByEntityIds(entityIds));
+ verify(entityMonitorBindDao).countByEntityIdInGroupByEntityId(entityIds);
+ }
}
diff --git a/hertzbeat-manager/src/test/java/org/apache/hertzbeat/manager/service/entity/EntityMonitorBindServiceTest.java b/hertzbeat-manager/src/test/java/org/apache/hertzbeat/manager/service/entity/EntityMonitorBindServiceTest.java
index 9f23ec3cab..9591ab6b46 100644
--- a/hertzbeat-manager/src/test/java/org/apache/hertzbeat/manager/service/entity/EntityMonitorBindServiceTest.java
+++ b/hertzbeat-manager/src/test/java/org/apache/hertzbeat/manager/service/entity/EntityMonitorBindServiceTest.java
@@ -18,6 +18,8 @@
package org.apache.hertzbeat.manager.service.entity;
import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
@@ -106,6 +108,7 @@ class EntityMonitorBindServiceTest {
EntityMonitorBind missingMonitorId = EntityMonitorBind.builder().build();
when(entityMonitorQueryService.monitorExists(501L)).thenReturn(true);
when(entityMonitorQueryService.monitorExists(502L)).thenReturn(false);
+ when(entityMonitorBindQueryService.findMonitorBindsByMonitorId(501L)).thenReturn(List.of());
monitorBindService.replaceMonitorBinds(301L, List.of(validBind, missingMonitorBind, missingMonitorId));
@@ -123,6 +126,25 @@ class EntityMonitorBindServiceTest {
assertEquals(Map.of("service.name", List.of("checkout-api")), saved.getMatchContext());
}
+ @Test
+ void replaceMonitorBindsRejectsMonitorAlreadyBoundToAnotherEntity() {
+ EntityMonitorBind input = EntityMonitorBind.builder()
+ .monitorId(501L)
+ .build();
+ EntityMonitorBind existing = EntityMonitorBind.builder()
+ .entityId(302L)
+ .monitorId(501L)
+ .build();
+ when(entityMonitorQueryService.monitorExists(501L)).thenReturn(true);
+ when(entityMonitorBindQueryService.findMonitorBindsByMonitorId(501L)).thenReturn(List.of(existing));
+
+ IllegalArgumentException error = assertThrows(IllegalArgumentException.class,
+ () -> monitorBindService.replaceMonitorBinds(301L, List.of(input)));
+
+ assertEquals("Monitor already bound to another entity: 501.", error.getMessage());
+ verify(entityMonitorBindWriteModelService, never()).replaceMonitorBinds(eq(301L), org.mockito.ArgumentMatchers.anyList());
+ }
+
@Test
@SuppressWarnings("unchecked")
void replaceAutoMonitorBindsRefreshesAutoRowsAndKeepsManualBindsProtected() {
@@ -189,6 +211,33 @@ class EntityMonitorBindServiceTest {
assertEquals(List.of(702L, 701L), monitors.stream().map(Monitor::getId).toList());
}
+ @Test
+ void findEntityMonitorsByEntityIdsBatchesBindAndMonitorLookupsForCurrentPage() {
+ EntityMonitorBind firstBind = EntityMonitorBind.builder()
+ .entityId(301L)
+ .monitorId(702L)
+ .build();
+ EntityMonitorBind secondBind = EntityMonitorBind.builder()
+ .entityId(302L)
+ .monitorId(701L)
+ .build();
+ when(entityMonitorBindQueryService.findMonitorBindsByEntityIds(List.of(301L, 302L, 303L)))
+ .thenReturn(Map.of(301L, List.of(firstBind), 302L, List.of(secondBind)));
+ Monitor firstMonitor = Monitor.builder().id(701L).name("first-returned").build();
+ Monitor secondMonitor = Monitor.builder().id(702L).name("second-returned").build();
+ when(entityMonitorQueryService.findMonitorsByIds(java.util.Set.of(701L, 702L)))
+ .thenReturn(List.of(firstMonitor, secondMonitor));
+
+ Map<Long, List<Monitor>> monitorsByEntityId =
+ monitorBindService.findEntityMonitorsByEntityIds(List.of(301L, 302L, 303L));
+
+ assertEquals(List.of(702L), monitorsByEntityId.get(301L).stream().map(Monitor::getId).toList());
+ assertEquals(List.of(701L), monitorsByEntityId.get(302L).stream().map(Monitor::getId).toList());
+ assertNull(monitorsByEntityId.get(303L));
+ verify(entityMonitorBindQueryService).findMonitorBindsByEntityIds(List.of(301L, 302L, 303L));
+ verify(entityMonitorQueryService).findMonitorsByIds(java.util.Set.of(701L, 702L));
+ }
+
@Test
void findEntityMonitorsReturnsEmptyWithoutMonitorDaoLookupWhenNoBindsExist() {
when(entityMonitorBindQueryService.findMonitorBinds(302L)).thenReturn(List.of());
diff --git a/hertzbeat-manager/src/test/java/org/apache/hertzbeat/manager/service/entity/EntityMutationWorkflowServiceTest.java b/hertzbeat-manager/src/test/java/org/apache/hertzbeat/manager/service/entity/EntityMutationWorkflowServiceTest.java
index 53ab31691e..315d1a520b 100644
--- a/hertzbeat-manager/src/test/java/org/apache/hertzbeat/manager/service/entity/EntityMutationWorkflowServiceTest.java
+++ b/hertzbeat-manager/src/test/java/org/apache/hertzbeat/manager/service/entity/EntityMutationWorkflowServiceTest.java
@@ -18,12 +18,20 @@
package org.apache.hertzbeat.manager.service.entity;
import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertSame;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.Mockito.inOrder;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import java.util.List;
+import java.util.Optional;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.Future;
+import java.util.concurrent.TimeUnit;
import org.apache.hertzbeat.common.entity.manager.EntityIdentity;
import org.apache.hertzbeat.common.entity.manager.EntityMonitorBind;
import org.apache.hertzbeat.common.entity.manager.EntityRelation;
@@ -36,6 +44,8 @@ import org.mockito.InOrder;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
+import org.springframework.transaction.support.TransactionSynchronization;
+import org.springframework.transaction.support.TransactionSynchronizationManager;
/**
* Contract for entity mutation workflow ordering across create, edit, and definition import paths.
@@ -195,6 +205,132 @@ class EntityMutationWorkflowServiceTest {
.recordDefinitionActivity(102L, "definition_import", "yaml", secondSaved);
}
+ @Test
+ void addEntityByDefinitionRejectsExistingEntityReferenceBeforeWrite() {
+ EntityDefinitionRequest request = new EntityDefinitionRequest();
+ EntityDto entityDto = new EntityDto();
+ ObserveEntity input = ObserveEntity.builder()
+ .type("service")
+ .namespace("product-design")
+ .name("checkout")
+ .build();
+ entityDto.setEntity(input);
+ when(entityDefinitionDraftService.parseEntityDefinition(request, null)).thenReturn(entityDto);
+ when(entityWorkspaceAccessService.findAccessibleEntityByReferenceForRequestWorkspace(
+ "service", "product-design", "checkout"))
+ .thenReturn(Optional.of(ObserveEntity.builder().id(44L).build()));
+
+ IllegalArgumentException thrown = assertThrows(IllegalArgumentException.class,
+ () -> entityMutationWorkflowService.addEntityByDefinition(request));
+
+ assertEquals("Entity already exists: service product-design/checkout.", thrown.getMessage());
+ verify(entityCoreWriteModelService, never()).createEntity(input, "manual");
+ }
+
+ @Test
+ void addEntityRejectsExistingEntityReferenceBeforeWrite() {
+ EntityDto entityDto = new EntityDto();
+ ObserveEntity input = ObserveEntity.builder()
+ .type("service")
+ .namespace("product-design")
+ .name("checkout")
+ .build();
+ entityDto.setEntity(input);
+ when(entityWorkspaceAccessService.findAccessibleEntityByNameForRequestWorkspace("checkout"))
+ .thenReturn(Optional.of(ObserveEntity.builder()
+ .id(44L)
+ .type("endpoint")
+ .name("checkout")
+ .build()));
+
+ IllegalArgumentException thrown = assertThrows(IllegalArgumentException.class,
+ () -> entityMutationWorkflowService.addEntity(entityDto));
+
+ assertEquals("Entity already exists: checkout.", thrown.getMessage());
+ verify(entityCoreWriteModelService, never()).createEntity(input, "manual");
+ }
+
+ @Test
+ void addEntitiesByDefinitionBundleRejectsDuplicateSubmittedReferenceBeforeWrite() {
+ EntityDefinitionRequest request = new EntityDefinitionRequest();
+ EntityDto firstDto = new EntityDto();
+ ObserveEntity firstInput = ObserveEntity.builder()
+ .type("service")
+ .namespace("product-design")
+ .name("checkout")
+ .build();
+ firstDto.setEntity(firstInput);
+ EntityDto secondDto = new EntityDto();
+ ObserveEntity secondInput = ObserveEntity.builder()
+ .type("service")
+ .namespace("product-design")
+ .name("checkout")
+ .build();
+ secondDto.setEntity(secondInput);
+ when(entityDefinitionDraftService.parseEntityDefinitionBundle(request))
+ .thenReturn(List.of(firstDto, secondDto));
+
+ IllegalArgumentException thrown = assertThrows(IllegalArgumentException.class,
+ () -> entityMutationWorkflowService.addEntitiesByDefinitionBundle(request));
+
+ assertEquals("Entity already exists in definition bundle: checkout.", thrown.getMessage());
+ verify(entityCoreWriteModelService, never()).createEntities(List.of(firstInput, secondInput), "manual");
+ }
+
+ @Test
+ void addEntitiesByDefinitionBundleHoldsSameReferenceLockUntilTransactionCompletion() throws Exception {
+ EntityDefinitionRequest request = new EntityDefinitionRequest();
+ request.setFormat("yaml");
+ EntityDto firstDto = new EntityDto();
+ ObserveEntity firstInput = ObserveEntity.builder()
+ .type("service")
+ .namespace("product-design")
+ .name("checkout")
+ .build();
+ firstDto.setEntity(firstInput);
+ EntityDto secondDto = new EntityDto();
+ ObserveEntity secondInput = ObserveEntity.builder()
+ .type("service")
+ .namespace("product-design")
+ .name("payments")
+ .build();
+ secondDto.setEntity(secondInput);
+ ObserveEntity firstSaved = ObserveEntity.builder().id(101L).name("checkout").build();
+ ObserveEntity secondSaved = ObserveEntity.builder().id(102L).name("payments").build();
+ when(entityDefinitionDraftService.parseEntityDefinitionBundle(request))
+ .thenReturn(List.of(firstDto, secondDto));
+ when(entityWorkspaceAccessService.findAccessibleEntityByReferenceForRequestWorkspace(
+ "service", "product-design", "checkout"))
+ .thenReturn(Optional.empty());
+ when(entityWorkspaceAccessService.findAccessibleEntityByReferenceForRequestWorkspace(
+ "service", "product-design", "payments"))
+ .thenReturn(Optional.empty());
+ when(entityCoreWriteModelService.createEntities(List.of(firstInput, secondInput), "manual"))
+ .thenReturn(List.of(firstSaved, secondSaved));
+ ExecutorService executor = Executors.newSingleThreadExecutor();
+
+ TransactionSynchronizationManager.initSynchronization();
+ try {
+ assertEquals(List.of(101L, 102L), entityMutationWorkflowService.addEntitiesByDefinitionBundle(request));
+ Future<List<Long>> blockedImport = executor.submit(
+ () -> entityMutationWorkflowService.addEntitiesByDefinitionBundle(request));
+
+ TimeUnit.MILLISECONDS.sleep(150);
+ assertFalse(blockedImport.isDone());
+
+ TransactionSynchronizationManager.getSynchronizations()
+ .forEach(synchronization -> synchronization.afterCompletion(TransactionSynchronization.STATUS_COMMITTED));
+ TransactionSynchronizationManager.clearSynchronization();
+
+ assertEquals(List.of(101L, 102L), blockedImport.get(2, TimeUnit.SECONDS));
+ } finally {
+ if (TransactionSynchronizationManager.isSynchronizationActive()) {
+ TransactionSynchronizationManager.clearSynchronization();
+ }
+ executor.shutdownNow();
+ }
+ }
+
@Test
void modifyEntityByDefinitionRecordsFailureActivityBeforeRethrow() {
EntityDefinitionRequest request = new EntityDefinitionRequest();
diff --git a/hertzbeat-manager/src/test/java/org/apache/hertzbeat/manager/service/entity/EntityNoiseControlRuleQueryServiceTest.java b/hertzbeat-manager/src/test/java/org/apache/hertzbeat/manager/service/entity/EntityNoiseControlRuleQueryServiceTest.java
index bddc6c7fe0..2755ce8d2c 100644
--- a/hertzbeat-manager/src/test/java/org/apache/hertzbeat/manager/service/entity/EntityNoiseControlRuleQueryServiceTest.java
+++ b/hertzbeat-manager/src/test/java/org/apache/hertzbeat/manager/service/entity/EntityNoiseControlRuleQueryServiceTest.java
@@ -18,6 +18,8 @@
package org.apache.hertzbeat.manager.service.entity;
import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import java.util.List;
@@ -82,7 +84,7 @@ class EntityNoiseControlRuleQueryServiceTest {
CommonConstants.LABEL_INSTANCE, "checkout-api.default.svc.cluster.local"
))
.build();
- when(alertSilenceDao.findAll()).thenReturn(List.of(disabled, globalMatchAll, teamBeta, teamAlpha));
+ when(alertSilenceDao.findAlertSilencesByEnableTrue()).thenReturn(List.of(globalMatchAll, teamBeta, teamAlpha));
List<AlertSilence> scopedRules = entityNoiseControlRuleQueryService.findEnabledSilences("team-a");
List<AlertSilence> defaultRules = entityNoiseControlRuleQueryService.findEnabledSilences(
@@ -90,6 +92,7 @@ class EntityNoiseControlRuleQueryServiceTest {
assertEquals(List.of(teamAlpha), scopedRules);
assertEquals(List.of(globalMatchAll), defaultRules);
+ verify(alertSilenceDao, never()).findAll();
}
@Test
@@ -122,7 +125,7 @@ class EntityNoiseControlRuleQueryServiceTest {
CommonConstants.LABEL_INSTANCE, "checkout-api.default.svc.cluster.local"
))
.build();
- when(alertInhibitDao.findAll()).thenReturn(List.of(disabled, generic, teamBeta, teamAlpha));
+ when(alertInhibitDao.findAlertInhibitsByEnableIsTrue()).thenReturn(List.of(generic, teamBeta, teamAlpha));
List<AlertInhibit> scopedRules = entityNoiseControlRuleQueryService.findEnabledInhibits("team-a");
List<AlertInhibit> defaultRules = entityNoiseControlRuleQueryService.findEnabledInhibits(
@@ -130,5 +133,6 @@ class EntityNoiseControlRuleQueryServiceTest {
assertEquals(List.of(teamAlpha), scopedRules);
assertEquals(List.of(generic), defaultRules);
+ verify(alertInhibitDao, never()).findAll();
}
}
diff --git a/hertzbeat-manager/src/test/java/org/apache/hertzbeat/manager/service/entity/EntityNoiseControlRuleQuerySourceOwnershipTest.java b/hertzbeat-manager/src/test/java/org/apache/hertzbeat/manager/service/entity/EntityNoiseControlRuleQuerySourceOwnershipTest.java
index c83f26e5c3..63fc3dfe88 100644
--- a/hertzbeat-manager/src/test/java/org/apache/hertzbeat/manager/service/entity/EntityNoiseControlRuleQuerySourceOwnershipTest.java
+++ b/hertzbeat-manager/src/test/java/org/apache/hertzbeat/manager/service/entity/EntityNoiseControlRuleQuerySourceOwnershipTest.java
@@ -61,6 +61,12 @@ class EntityNoiseControlRuleQuerySourceOwnershipTest {
assertTrue(ruleQuerySource.contains("import org.apache.hertzbeat.alert.dao.AlertInhibitDao"));
assertTrue(ruleQuerySource.contains("public List<AlertSilence> findEnabledSilences(String requestWorkspaceId)"));
assertTrue(ruleQuerySource.contains("public List<AlertInhibit> findEnabledInhibits(String requestWorkspaceId)"));
+ assertFalse(ruleQuerySource.contains("alertSilenceDao.findAll()"),
+ "Entity detail should not scan every silence rule before workspace and enable filtering");
+ assertFalse(ruleQuerySource.contains("alertInhibitDao.findAll()"),
+ "Entity detail should not scan every inhibit rule before workspace and enable filtering");
+ assertTrue(ruleQuerySource.contains("alertSilenceDao.findAlertSilencesByEnableTrue()"));
+ assertTrue(ruleQuerySource.contains("alertInhibitDao.findAlertInhibitsByEnableIsTrue()"));
assertTrue(ruleQuerySource.contains("matchesNoiseControlRuleRequestWorkspace("));
assertTrue(ruleQuerySource.contains("AuthTokenScopes.DEFAULT_WORKSPACE_ID.equals(normalizedRequestWorkspaceId)"));
}
diff --git a/hertzbeat-manager/src/test/java/org/apache/hertzbeat/manager/service/entity/EntityRelationQueryServiceTest.java b/hertzbeat-manager/src/test/java/org/apache/hertzbeat/manager/service/entity/EntityRelationQueryServiceTest.java
index 99eb7b6e2a..1ba2ab020d 100644
--- a/hertzbeat-manager/src/test/java/org/apache/hertzbeat/manager/service/entity/EntityRelationQueryServiceTest.java
+++ b/hertzbeat-manager/src/test/java/org/apache/hertzbeat/manager/service/entity/EntityRelationQueryServiceTest.java
@@ -18,12 +18,16 @@
package org.apache.hertzbeat.manager.service.entity;
import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import java.util.List;
+import java.util.Map;
import org.apache.hertzbeat.common.entity.manager.EntityRelation;
import org.apache.hertzbeat.manager.dao.EntityRelationDao;
+import org.springframework.data.domain.PageRequest;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
@@ -57,6 +61,28 @@ class EntityRelationQueryServiceTest {
assertEquals(List.of(relation), relations);
}
+ @Test
+ void findEntityRelationsWithLimitReturnsStablePreviewRows() {
+ EntityRelation relation = EntityRelation.builder()
+ .id(31L)
+ .sourceEntityId(10L)
+ .targetEntityId(30L)
+ .relationType("calls")
+ .build();
+ when(entityRelationDao.findBySourceEntityIdOrTargetEntityId(eq(10L), eq(10L), any(PageRequest.class)))
+ .thenReturn(List.of(relation));
+
+ List<EntityRelation> relations = entityRelationQueryService.findEntityRelations(10L, 50);
+
+ assertEquals(List.of(relation), relations);
+ verify(entityRelationDao).findBySourceEntityIdOrTargetEntityId(eq(10L), eq(10L), any(PageRequest.class));
+ }
+
+ @Test
+ void findEntityRelationsWithNonPositiveLimitSkipsStorage() {
+ assertEquals(List.of(), entityRelationQueryService.findEntityRelations(10L, 0));
+ }
+
@Test
void countEntityRelationsReturnsIncomingAndOutgoingCount() {
when(entityRelationDao.countBySourceEntityIdOrTargetEntityId(10L, 10L)).thenReturn(3L);
@@ -64,4 +90,17 @@ class EntityRelationQueryServiceTest {
assertEquals(3L, entityRelationQueryService.countEntityRelations(10L));
verify(entityRelationDao).countBySourceEntityIdOrTargetEntityId(10L, 10L);
}
+
+ @Test
+ void countEntityRelationsByEntityIdsMergesIncomingAndOutgoingCounts() {
+ List<Long> entityIds = List.of(10L, 20L);
+ when(entityRelationDao.countBySourceEntityIdInGroupBySourceEntityId(entityIds))
+ .thenReturn(List.<Object[]>of(new Object[] {10L, 2L}));
+ when(entityRelationDao.countByTargetEntityIdInGroupByTargetEntityId(entityIds))
+ .thenReturn(List.<Object[]>of(new Object[] {10L, 1L}, new Object[] {20L, 4L}));
+
+ assertEquals(Map.of(10L, 3L, 20L, 4L), entityRelationQueryService.countEntityRelationsByEntityIds(entityIds));
+ verify(entityRelationDao).countBySourceEntityIdInGroupBySourceEntityId(entityIds);
+ verify(entityRelationDao).countByTargetEntityIdInGroupByTargetEntityId(entityIds);
+ }
}
diff --git a/hertzbeat-manager/src/test/java/org/apache/hertzbeat/manager/service/entity/EntityRelationServiceTest.java b/hertzbeat-manager/src/test/java/org/apache/hertzbeat/manager/service/entity/EntityRelationServiceTest.java
index 1ca437e492..cc5279dff9 100644
--- a/hertzbeat-manager/src/test/java/org/apache/hertzbeat/manager/service/entity/EntityRelationServiceTest.java
+++ b/hertzbeat-manager/src/test/java/org/apache/hertzbeat/manager/service/entity/EntityRelationServiceTest.java
@@ -76,6 +76,21 @@ class EntityRelationServiceTest {
assertEquals(List.of(relation), relations);
}
+ @Test
+ void findEntityRelationsWithLimitDelegatesToPreviewQuery() {
+ EntityRelation relation = EntityRelation.builder()
+ .id(31L)
+ .sourceEntityId(10L)
+ .targetEntityId(30L)
+ .relationType("calls")
+ .build();
+ when(entityRelationQueryService.findEntityRelations(10L, 50)).thenReturn(List.of(relation));
+
+ List<EntityRelation> relations = entityRelationService.findEntityRelations(10L, 50);
+
+ assertEquals(List.of(relation), relations);
+ }
+
@Test
void countEntityRelationsReturnsIncomingAndOutgoingCount() {
when(entityRelationQueryService.countEntityRelations(10L)).thenReturn(3L);
diff --git a/hertzbeat-manager/src/test/java/org/apache/hertzbeat/manager/service/entity/EntityStatusRefreshServiceTest.java b/hertzbeat-manager/src/test/java/org/apache/hertzbeat/manager/service/entity/EntityStatusRefreshServiceTest.java
index f526f97962..34fc702113 100644
--- a/hertzbeat-manager/src/test/java/org/apache/hertzbeat/manager/service/entity/EntityStatusRefreshServiceTest.java
+++ b/hertzbeat-manager/src/test/java/org/apache/hertzbeat/manager/service/entity/EntityStatusRefreshServiceTest.java
@@ -17,11 +17,15 @@
package org.apache.hertzbeat.manager.service.entity;
+import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertSame;
import static org.mockito.Mockito.inOrder;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import java.util.List;
+import java.util.Map;
import org.apache.hertzbeat.common.entity.alerter.SingleAlert;
import org.apache.hertzbeat.common.entity.manager.Monitor;
import org.apache.hertzbeat.common.entity.manager.ObserveEntity;
@@ -113,4 +117,46 @@ class EntityStatusRefreshServiceTest {
inOrder.verify(entityAlertEvidenceReadModelService).queryActiveAlerts(monitors, 20);
inOrder.verify(entityRuntimeHealthService).refreshEntityStatus(entity, monitors, activeAlerts);
}
+
+ @Test
+ void refreshEntityStatusesWithEvidenceBatchesCurrentPageMonitorLookup() {
+ ObserveEntity checkout = ObserveEntity.builder()
+ .id(42L)
+ .type("service")
+ .name("checkout-api")
+ .workspaceId("team-a")
+ .build();
+ ObserveEntity payments = ObserveEntity.builder()
+ .id(43L)
+ .type("service")
+ .name("payments-api")
+ .workspaceId("team-a")
+ .build();
+ Monitor monitor = Monitor.builder().id(101L).name("checkout-instance").build();
+ List<SingleAlert> activeAlerts = List.of(SingleAlert.builder().id(201L).status("firing").build());
+ EntityStatusInfo checkoutStatus = new EntityStatusInfo(
+ "critical", "1 firing alerts", 1, 0, 1, 0, 1, null);
+ EntityStatusInfo paymentsStatus = new EntityStatusInfo(
+ "unknown", "no live evidence bound yet", 0, 0, 0, 0, 0, null);
+ when(entityMonitorBindService.findEntityMonitorsByEntityIds(List.of(42L, 43L)))
+ .thenReturn(Map.of(42L, List.of(monitor)));
+ when(entityAlertEvidenceReadModelService.queryActiveAlerts(List.of(monitor), 20, "team-a"))
+ .thenReturn(activeAlerts);
+ when(entityRuntimeHealthService.refreshEntityStatus(checkout, List.of(monitor), activeAlerts))
+ .thenReturn(checkoutStatus);
+ when(entityRuntimeHealthService.refreshEntityStatus(payments, List.of(), List.of()))
+ .thenReturn(paymentsStatus);
+
+ Map<Long, EntityStatusRefreshService.EntityRuntimeStatusEvidence> evidenceByEntity =
+ statusRefreshService.refreshEntityStatusesWithEvidence(List.of(checkout, payments), "team-a");
+
+ assertSame(checkoutStatus, evidenceByEntity.get(42L).statusInfo());
+ assertSame(paymentsStatus, evidenceByEntity.get(43L).statusInfo());
+ assertEquals(List.of(monitor), evidenceByEntity.get(42L).monitors());
+ assertEquals(List.of(), evidenceByEntity.get(43L).monitors());
+ verify(entityMonitorBindService).findEntityMonitorsByEntityIds(List.of(42L, 43L));
+ verify(entityMonitorBindService, never()).findEntityMonitors(42L);
+ verify(entityMonitorBindService, never()).findEntityMonitors(43L);
+ verify(entityAlertEvidenceReadModelService, never()).queryActiveAlerts(List.of(), 20, "team-a");
+ }
}
diff --git a/hertzbeat-manager/src/test/java/org/apache/hertzbeat/manager/service/entity/EntityStatusRefreshSourceOwnershipTest.java b/hertzbeat-manager/src/test/java/org/apache/hertzbeat/manager/service/entity/EntityStatusRefreshSourceOwnershipTest.java
index d152e36c99..8aea9d8253 100644
--- a/hertzbeat-manager/src/test/java/org/apache/hertzbeat/manager/service/entity/EntityStatusRefreshSourceOwnershipTest.java
+++ b/hertzbeat-manager/src/test/java/org/apache/hertzbeat/manager/service/entity/EntityStatusRefreshSourceOwnershipTest.java
@@ -58,8 +58,7 @@ class EntityStatusRefreshSourceOwnershipTest {
assertFalse(statusRefreshSource.contains("currentRequestWorkspaceId()"),
"Status refresh should delegate default active-alert workspace lookup to the alert query boundary");
assertTrue(statusRefreshSource.contains("return refreshEntityStatusWithEvidence(entity).statusInfo()"));
- assertTrue(statusRefreshSource.contains("entityAlertEvidenceReadModelService.queryActiveAlerts(\n"
- + " monitors, ACTIVE_ALERT_LIMIT)"));
+ assertTrue(statusRefreshSource.contains("entityAlertEvidenceReadModelService.queryActiveAlerts(monitors, ACTIVE_ALERT_LIMIT)"));
assertTrue(alertEvidenceSource.contains("public List<SingleAlert> queryActiveAlerts(List<Monitor> monitors, "
+ "int limit)"));
assertTrue(alertQuerySource.contains("public List<SingleAlert> findActiveAlerts(List<Monitor> monitors, "
diff --git a/hertzbeat-manager/src/test/java/org/apache/hertzbeat/manager/service/entity/EntitySummaryReadModelServiceTest.java b/hertzbeat-manager/src/test/java/org/apache/hertzbeat/manager/service/entity/EntitySummaryReadModelServiceTest.java
index ecb81dc562..5c1ef3a5e5 100644
--- a/hertzbeat-manager/src/test/java/org/apache/hertzbeat/manager/service/entity/EntitySummaryReadModelServiceTest.java
+++ b/hertzbeat-manager/src/test/java/org/apache/hertzbeat/manager/service/entity/EntitySummaryReadModelServiceTest.java
@@ -164,4 +164,63 @@ class EntitySummaryReadModelServiceTest {
assertEquals(Map.of(), latestActivities);
verify(entityActivityReadModelService, never()).findLatestDefinitionActivities(any());
}
+
+ @Test
+ void loadSummaryCountsBatchesCurrentPageCountEvidence() {
+ ObserveEntity firstEntity = ObserveEntity.builder().id(501L).name("checkout-api").build();
+ ObserveEntity secondEntity = ObserveEntity.builder().id(502L).name("billing-api").build();
+ List<Long> entityIds = List.of(501L, 502L);
+ when(entityIdentityReadModelService.countIdentitiesByEntityIds(entityIds))
+ .thenReturn(Map.of(501L, 2L, 502L, 1L));
+ when(entityMonitorBindService.countMonitorBindsByEntityIds(entityIds))
+ .thenReturn(Map.of(501L, 3L));
+ when(entityRelationService.countEntityRelationsByEntityIds(entityIds))
+ .thenReturn(Map.of(502L, 4L));
+
+ Map<Long, EntitySummaryReadModelService.EntitySummaryCounts> counts =
+ summaryReadModelService.loadSummaryCounts(List.of(firstEntity, secondEntity));
+
+ assertEquals(2, counts.size());
+ assertEquals(new EntitySummaryReadModelService.EntitySummaryCounts(2, 3, 0), counts.get(501L));
+ assertEquals(new EntitySummaryReadModelService.EntitySummaryCounts(1, 0, 4), counts.get(502L));
+ verify(entityIdentityReadModelService).countIdentitiesByEntityIds(entityIds);
+ verify(entityMonitorBindService).countMonitorBindsByEntityIds(entityIds);
+ verify(entityRelationService).countEntityRelationsByEntityIds(entityIds);
+ }
+
+ @Test
+ void buildEntitySummaryAcceptsPreloadedCountsForListPages() {
+ ObserveEntity entity = ObserveEntity.builder()
+ .id(601L)
+ .type("service")
+ .name("checkout-api")
+ .status("unknown")
+ .build();
+ EntityStatusInfo statusInfo = new EntityStatusInfo(
+ "unknown", "no live evidence", 0, 0, 0, 0, 0, LocalDateTime.now()
+ );
+ EntityEvidenceSummaryInfo evidenceSummary = new EntityEvidenceSummaryInfo(0, 0, 0, 5, 0, 1778340000000L);
+ when(entityObservabilityGateway.buildEntityEvidenceSummary(
+ eq(entity), eq(statusInfo), eq(5L), eq(0), eq(Collections.emptyList()), eq(Collections.emptyList())
+ )).thenReturn(evidenceSummary);
+ EntityOpsSummaryInfo opsSummary = new EntityOpsSummaryInfo(true, false, true, true, true, 80);
+ when(entityObservabilityGateway.buildEntityOpsSummary(entity, 7L, evidenceSummary)).thenReturn(opsSummary);
+ when(entityObservabilityGateway.buildEntityNextActions(entity, evidenceSummary, null, opsSummary))
+ .thenReturn(List.of());
+
+ EntitySummaryInfo summary = summaryReadModelService.buildEntitySummary(
+ entity,
+ null,
+ statusInfo,
+ List.of(),
+ new EntitySummaryReadModelService.EntitySummaryCounts(5, 6, 7)
+ );
+
+ assertEquals(5, summary.getIdentityCount());
+ assertEquals(6, summary.getMonitorCount());
+ assertEquals(7, summary.getRelationCount());
+ verify(entityIdentityReadModelService, never()).countIdentities(601L);
+ verify(entityMonitorBindService, never()).countMonitorBinds(601L);
+ verify(entityRelationService, never()).countEntityRelations(601L);
+ }
}
diff --git a/hertzbeat-manager/src/test/java/org/apache/hertzbeat/manager/service/entity/EntityWorkspaceAccessServiceTest.java b/hertzbeat-manager/src/test/java/org/apache/hertzbeat/manager/service/entity/EntityWorkspaceAccessServiceTest.java
index 1161ee54b7..c48596c194 100644
--- a/hertzbeat-manager/src/test/java/org/apache/hertzbeat/manager/service/entity/EntityWorkspaceAccessServiceTest.java
+++ b/hertzbeat-manager/src/test/java/org/apache/hertzbeat/manager/service/entity/EntityWorkspaceAccessServiceTest.java
@@ -300,6 +300,25 @@ class EntityWorkspaceAccessServiceTest {
assertSame(teamAlphaEntity, accessibleEntity.get());
}
+ @Test
+ void findAccessibleEntityByNameForRequestWorkspaceUsesCurrentRequestScope() {
+ ObserveEntity teamAlphaEntity = ObserveEntity.builder()
+ .id(433L)
+ .type("endpoint")
+ .name("checkout")
+ .workspaceId("team-a")
+ .build();
+ when(entityWorkspaceQueryService.findEntityByName("team-a", "checkout"))
+ .thenReturn(Optional.of(teamAlphaEntity));
+ AuthTokenRequestContext.bindWorkspaceId(" team-a ");
+
+ Optional<ObserveEntity> accessibleEntity =
+ workspaceAccessService.findAccessibleEntityByNameForRequestWorkspace("checkout");
+
+ assertTrue(accessibleEntity.isPresent());
+ assertSame(teamAlphaEntity, accessibleEntity.get());
+ }
+
@Test
void resolveWriteWorkspaceIdPrefersRequestThenSourceThenCurrentThenDefault() {
AuthTokenRequestContext.bindWorkspaceId(" team-a ");
diff --git a/hertzbeat-manager/src/test/java/org/apache/hertzbeat/manager/service/entity/EntityWorkspaceQuerySourceOwnershipTest.java b/hertzbeat-manager/src/test/java/org/apache/hertzbeat/manager/service/entity/EntityWorkspaceQuerySourceOwnershipTest.java
index 280b321837..8840939949 100644
--- a/hertzbeat-manager/src/test/java/org/apache/hertzbeat/manager/service/entity/EntityWorkspaceQuerySourceOwnershipTest.java
+++ b/hertzbeat-manager/src/test/java/org/apache/hertzbeat/manager/service/entity/EntityWorkspaceQuerySourceOwnershipTest.java
@@ -54,6 +54,7 @@ class EntityWorkspaceQuerySourceOwnershipTest {
assertTrue(accessSource.contains("entityWorkspaceQueryService.findEntities(normalizedWorkspaceId, sort)"));
assertTrue(accessSource.contains("entityWorkspaceQueryService.findEntityById(entityId)"));
assertTrue(accessSource.contains("entityWorkspaceQueryService.findEntityByReference("));
+ assertTrue(accessSource.contains("entityWorkspaceQueryService.findEntityByName(normalizedWorkspaceId, name)"));
assertTrue(accessSource.contains("normalizedWorkspaceId, type, namespace, name"));
assertTrue(accessSource.contains("normalizedWorkspaceId, type, name"));
@@ -76,5 +77,8 @@ class EntityWorkspaceQuerySourceOwnershipTest {
assertTrue(querySource.contains(
"observeEntityDao.findFirstByWorkspaceIdAndTypeAndName(workspaceId, type, name)"));
assertTrue(querySource.contains("observeEntityDao.findFirstByTypeAndName(type, name)"));
+ assertTrue(querySource.contains("public Optional<ObserveEntity> findEntityByName(String workspaceId, String name)"));
+ assertTrue(querySource.contains("observeEntityDao.findFirstByWorkspaceIdAndName(workspaceId, name)"));
+ assertTrue(querySource.contains("observeEntityDao.findFirstByName(name)"));
}
}
diff --git a/hertzbeat-manager/src/test/java/org/apache/hertzbeat/manager/service/entity/LocalTopologyDemoRelationSeederTest.java b/hertzbeat-manager/src/test/java/org/apache/hertzbeat/manager/service/entity/LocalTopologyDemoRelationSeederTest.java
index dc38ac840d..5b11cf75d7 100644
--- a/hertzbeat-manager/src/test/java/org/apache/hertzbeat/manager/service/entity/LocalTopologyDemoRelationSeederTest.java
+++ b/hertzbeat-manager/src/test/java/org/apache/hertzbeat/manager/service/entity/LocalTopologyDemoRelationSeederTest.java
@@ -142,6 +142,9 @@ class LocalTopologyDemoRelationSeederTest {
ArgumentCaptor<List<ObserveEntity>> entitiesCaptor = ArgumentCaptor.forClass(List.class);
verify(observeEntityDao).saveAll(entitiesCaptor.capture());
assertEquals(1993, entitiesCaptor.getValue().size());
+ ArgumentCaptor<List<EntityRelation>> relationsCaptor = ArgumentCaptor.forClass(List.class);
+ verify(entityRelationDao).saveAll(relationsCaptor.capture());
+ assertEquals(1992, relationsCaptor.getValue().size());
}
@Test
@@ -156,21 +159,37 @@ class LocalTopologyDemoRelationSeederTest {
ArgumentCaptor<List<ObserveEntity>> entitiesCaptor = ArgumentCaptor.forClass(List.class);
ArgumentCaptor<List<EntityIdentity>> identitiesCaptor = ArgumentCaptor.forClass(List.class);
+ ArgumentCaptor<List<EntityRelation>> relationsCaptor = ArgumentCaptor.forClass(List.class);
verify(observeEntityDao).saveAll(entitiesCaptor.capture());
verify(entityIdentityDao).saveAll(identitiesCaptor.capture());
+ verify(entityRelationDao).saveAll(relationsCaptor.capture());
List<ObserveEntity> entities = entitiesCaptor.getValue();
List<EntityIdentity> identities = identitiesCaptor.getValue();
+ List<EntityRelation> relations = relationsCaptor.getValue();
assertEquals(1993, entities.size());
assertEquals(1993, identities.size());
+ assertEquals(1992, relations.size());
assertEquals("hb-mix-1780329856-edge-gateway", entities.getFirst().getName());
assertEquals("scale-mix", entities.getFirst().getNamespace());
assertEquals("prod", entities.getFirst().getEnvironment());
+ assertEquals("local-scale-proof", entities.getFirst().getSource());
assertEquals("service", entities.getFirst().getType());
assertEquals("hb-mix-1780329856-edge-gateway", identities.getFirst().getIdentityValue());
assertEquals("service.name", identities.getFirst().getIdentityKey());
assertEquals("otel_resource", identities.getFirst().getIdentityType());
assertEquals("hb-mix-1780329856-svc-11-164", entities.getLast().getName());
+ assertEquals("local-scale-proof", entities.getLast().getSource());
assertEquals("hb-mix-1780329856-svc-11-164", identities.getLast().getIdentityValue());
+ EntityRelation gatewayToDomain = relations.getFirst();
+ assertEquals(646566130000000L, gatewayToDomain.getSourceEntityId());
+ assertEquals(646566130000001L, gatewayToDomain.getTargetEntityId());
+ assertEquals("trace-call", gatewayToDomain.getRelationType());
+ assertEquals("otlp-trace-call", gatewayToDomain.getRelationSource());
+ assertEquals("local-topology-demo-seed", gatewayToDomain.getAttributes().get("seed"));
+ EntityRelation lastDomainToService = relations.getLast();
+ assertEquals(646566130000012L, lastDomainToService.getSourceEntityId());
+ assertEquals(646566130001992L, lastDomainToService.getTargetEntityId());
+ assertEquals("trace-call", lastDomainToService.getRelationType());
}
@Test
@@ -207,6 +226,9 @@ class LocalTopologyDemoRelationSeederTest {
assertEquals("hb-mix-1780329856-edge-gateway", gatewayIdentity.getIdentityValue());
assertEquals("hb-mix-1780329856-edge-gateway", gatewayIdentity.getNormalizedValue());
assertEquals(1993, identities.size());
+ ArgumentCaptor<List<EntityRelation>> relationsCaptor = ArgumentCaptor.forClass(List.class);
+ verify(entityRelationDao).saveAll(relationsCaptor.capture());
+ assertEquals(1992, relationsCaptor.getValue().size());
}
private ObserveEntity entity(Long id, String type, String name) {
diff --git a/hertzbeat-observability/src/main/java/org/apache/hertzbeat/observability/ingestion/service/impl/OtlpIngestionWorkspaceServiceImpl.java b/hertzbeat-observability/src/main/java/org/apache/hertzbeat/observability/ingestion/service/impl/OtlpIngestionWorkspaceServiceImpl.java
index ca1080546a..b6025abd5d 100644
--- a/hertzbeat-observability/src/main/java/org/apache/hertzbeat/observability/ingestion/service/impl/OtlpIngestionWorkspaceServiceImpl.java
+++ b/hertzbeat-observability/src/main/java/org/apache/hertzbeat/observability/ingestion/service/impl/OtlpIngestionWorkspaceServiceImpl.java
@@ -1169,9 +1169,13 @@ public class OtlpIngestionWorkspaceServiceImpl implements OtlpIngestionWorkspace
operationNameMatch.remove("http_route");
LinkedHashMap<String, String> httpRouteMatch = new LinkedHashMap<>(resourceMatch);
httpRouteMatch.remove("operation_name");
+ LinkedHashMap<String, String> serviceMatch = new LinkedHashMap<>(resourceMatch);
+ serviceMatch.remove("operation_name");
+ serviceMatch.remove("http_route");
return List.of(
buildRelatedMetricAvailabilityProbe(metricName, candidate, operationNameMatch),
- buildRelatedMetricAvailabilityProbe(metricName, candidate, httpRouteMatch)
+ buildRelatedMetricAvailabilityProbe(metricName, candidate, httpRouteMatch),
+ buildRelatedMetricAvailabilityProbe(metricName, candidate, serviceMatch)
);
}
return List.of(buildRelatedMetricAvailabilityProbe(metricName, candidate, resourceMatch));
diff --git a/hertzbeat-observability/src/main/java/org/apache/hertzbeat/observability/shared/service/impl/EntityObservabilityGatewayImpl.java b/hertzbeat-observability/src/main/java/org/apache/hertzbeat/observability/shared/service/impl/EntityObservabilityGatewayImpl.java
index 5ac0bdc5f4..88c3edeb88 100644
--- a/hertzbeat-observability/src/main/java/org/apache/hertzbeat/observability/shared/service/impl/EntityObservabilityGatewayImpl.java
+++ b/hertzbeat-observability/src/main/java/org/apache/hertzbeat/observability/shared/service/impl/EntityObservabilityGatewayImpl.java
@@ -250,7 +250,8 @@ public class EntityObservabilityGatewayImpl implements EntityObservabilityGatewa
readinessScore += relationReady ? 20 : 0;
readinessScore += telemetryReady ? 20 : 0;
readinessScore += statusReady ? 20 : 0;
- return new EntityOpsSummaryInfo(ownerReady, runbookReady, relationReady, telemetryReady, statusReady, readinessScore);
+ return new EntityOpsSummaryInfo(ownerReady, runbookReady, relationReady, telemetryReady,
+ statusReady, readinessScore, Math.max(0L, relationCount));
}
@Override
diff --git a/hertzbeat-observability/src/main/java/org/apache/hertzbeat/observability/shared/service/impl/TelemetryIntakeServiceImpl.java b/hertzbeat-observability/src/main/java/org/apache/hertzbeat/observability/shared/service/impl/TelemetryIntakeServiceImpl.java
index c8f8a4e706..727fecb890 100644
--- a/hertzbeat-observability/src/main/java/org/apache/hertzbeat/observability/shared/service/impl/TelemetryIntakeServiceImpl.java
+++ b/hertzbeat-observability/src/main/java/org/apache/hertzbeat/observability/shared/service/impl/TelemetryIntakeServiceImpl.java
@@ -666,54 +666,7 @@ public class TelemetryIntakeServiceImpl implements TelemetryEvidenceGateway {
if (results.isEmpty()) {
results.addAll(buildStoredLogEvidence(entityContext, entityId, entityIdentityKeys, preferredHint, logSummary, bindingResult));
}
- if (!results.isEmpty()) {
- return results;
- }
- if (preferredHint == null && !hasLogSummaryQuerySignal(logSummary)) {
- return Collections.emptyList();
- }
- Map<String, String> resource = preferredHint == null || preferredHint.getResourceFilters() == null
- ? Collections.emptyMap()
- : preferredHint.getResourceFilters();
- List<String> searchTerms = preferredHint == null || CollectionUtils.isEmpty(preferredHint.getSearchTerms())
- ? buildFallbackSearchTerms(null, null,
- defaultText(logSummary == null ? null : logSummary.getPreferredQueryTitle(),
- logSummary == null ? null : logSummary.getFallbackSearchTerm()))
- : preferredHint.getSearchTerms();
- TelemetryIdentitySnapshot identitySnapshot =
- buildEntityIdentitySnapshot(entityContext, SOURCE_OTLP, SIGNAL_LOGS, System.currentTimeMillis());
- String queryHint = firstNonBlank(
- preferredHint == null ? null : preferredHint.getTraceId(),
- preferredHint == null ? null : preferredHint.getTitle(),
- logSummary == null ? null : logSummary.getPreferredQueryTitle(),
- logSummary == null ? null : logSummary.getFallbackSearchTerm()
- );
- return List.of(new LogEvidence(
- SOURCE_OTLP,
- SIGNAL_LOGS,
- entityId == 0L ? null : entityId,
- identitySnapshot,
- System.currentTimeMillis(),
- "attention",
- queryHint,
- bindingResult,
- buildCodeNavigationHint(entityContext, resource, Collections.emptyMap(), searchTerms, queryHint),
- defaultText(logSummary == null ? null : logSummary.getPreferredQueryTitle(), queryHint),
- null,
- preferredHint == null ? null : preferredHint.getTraceId(),
- preferredHint == null ? null : preferredHint.getSpanId(),
- resource,
- searchTerms
- ));
- }
-
- private boolean hasLogSummaryQuerySignal(EntityLogSummaryInfo logSummary) {
- return logSummary != null
- && (logSummary.getHintCount() > 0
- || StringUtils.hasText(logSummary.getPreferredQueryTitle())
- || StringUtils.hasText(logSummary.getFallbackSearchTerm())
- || !CollectionUtils.isEmpty(logSummary.getPreferredResourceFilters())
- || !CollectionUtils.isEmpty(logSummary.getPreferredSearchTerms()));
+ return results;
}
private List<LogEvidence> buildStoredLogEvidence(ObservedEntityContext entityContext,
@@ -801,9 +754,7 @@ public class TelemetryIntakeServiceImpl implements TelemetryEvidenceGateway {
continue;
}
recentTraceCount++;
- if (StringUtils.hasText(signal.errorState())
- && !"ok".equalsIgnoreCase(signal.errorState())
- && !"false".equalsIgnoreCase(signal.errorState())) {
+ if (isTraceErrorState(signal.errorState())) {
recentErrorTraceCount++;
}
if (latestObservedAt == null || (signal.observedAt() != null && signal.observedAt() > latestObservedAt)) {
@@ -820,6 +771,19 @@ public class TelemetryIntakeServiceImpl implements TelemetryEvidenceGateway {
);
}
+ private boolean isTraceErrorState(String errorState) {
+ String normalized = trimToNull(errorState);
+ if (!StringUtils.hasText(normalized)) {
+ return false;
+ }
+ String lower = normalized.toLowerCase(Locale.ROOT);
+ return "error".equals(lower)
+ || "status_code_error".equals(lower)
+ || "true".equals(lower)
+ || "failed".equals(lower)
+ || "failure".equals(lower);
+ }
+
@Override
public List<EntityTraceQueryHintDto> buildTraceQueryHints(ObservedEntityContext entityContext) {
long entityId = entityId(entityContext);
@@ -945,8 +909,7 @@ public class TelemetryIntakeServiceImpl implements TelemetryEvidenceGateway {
logsActive,
tracesActive,
metricEvidence == null ? 0 : metricEvidence.size(),
- Math.max(logSummary == null ? 0 : logSummary.getHintCount(),
- logEvidence == null ? 0 : logEvidence.size()),
+ logEvidence == null ? 0 : logEvidence.size(),
traceSummary == null ? 0 : traceSummary.getRecentTraceCount(),
latestObservedAt,
activeSignals
@@ -996,7 +959,7 @@ public class TelemetryIntakeServiceImpl implements TelemetryEvidenceGateway {
now
);
}
- if (logSummary != null && logSummary.getHintCount() > 0 || !CollectionUtils.isEmpty(logEvidence)) {
+ if (!CollectionUtils.isEmpty(logEvidence)) {
return new EntityTriageRecommendation(
"rule",
FOCUS_LOGS,
diff --git a/hertzbeat-observability/src/main/java/org/apache/hertzbeat/observability/traces/controller/TraceQueryController.java b/hertzbeat-observability/src/main/java/org/apache/hertzbeat/observability/traces/controller/TraceQueryController.java
index 51e15e4ee5..08d0061df3 100644
--- a/hertzbeat-observability/src/main/java/org/apache/hertzbeat/observability/traces/controller/TraceQueryController.java
+++ b/hertzbeat-observability/src/main/java/org/apache/hertzbeat/observability/traces/controller/TraceQueryController.java
@@ -151,14 +151,6 @@ public class TraceQueryController {
private String mergeEntityContextResourceFilter(Long entityId, String entityType, String resourceFilter) {
String normalizedResourceFilter = StringUtils.trimWhitespace(resourceFilter);
String scopedResourceFilter = normalizedResourceFilter;
- if (entityId != null && entityId > 0 && (StringUtils.hasText(scopedResourceFilter)
- ? !scopedResourceFilter.contains(OtlpResourceSemanticAttributes.HERTZBEAT_ENTITY_ID)
- : true)) {
- String entityIdFilter = OtlpResourceSemanticAttributes.HERTZBEAT_ENTITY_ID + "=\"" + entityId + "\"";
- scopedResourceFilter = StringUtils.hasText(scopedResourceFilter)
- ? scopedResourceFilter + " and " + entityIdFilter
- : entityIdFilter;
- }
String normalizedEntityType = StringUtils.trimWhitespace(entityType);
if (!StringUtils.hasText(normalizedEntityType) || !normalizedEntityType.matches("[A-Za-z0-9_.:-]+")) {
return scopedResourceFilter;
diff --git a/hertzbeat-observability/src/main/java/org/apache/hertzbeat/observability/traces/service/impl/EntityTraceQueryServiceImpl.java b/hertzbeat-observability/src/main/java/org/apache/hertzbeat/observability/traces/service/impl/EntityTraceQueryServiceImpl.java
index b05a42fb9f..7d5f22dea7 100644
--- a/hertzbeat-observability/src/main/java/org/apache/hertzbeat/observability/traces/service/impl/EntityTraceQueryServiceImpl.java
+++ b/hertzbeat-observability/src/main/java/org/apache/hertzbeat/observability/traces/service/impl/EntityTraceQueryServiceImpl.java
@@ -127,7 +127,7 @@ public class EntityTraceQueryServiceImpl implements EntityTraceQueryService {
@Override
public EntityTraceSummaryDto buildEntityTraceSummary(ObservedEntityContext entityContext) {
- Map<String, Set<String>> identityValues = canonicalIdentityValues(entityContext);
+ Map<String, Set<String>> identityValues = traceQueryIdentityValues(entityContext);
if (identityValues.isEmpty()) {
return new EntityTraceSummaryDto(0, 0, null, false, null);
}
@@ -171,7 +171,7 @@ public class EntityTraceQueryServiceImpl implements EntityTraceQueryService {
@Override
public List<EntityTraceQueryHintDto> buildEntityTraceQueryHints(ObservedEntityContext entityContext) {
- Map<String, Set<String>> identityValues = canonicalIdentityValues(entityContext);
+ Map<String, Set<String>> identityValues = traceQueryIdentityValues(entityContext);
if (identityValues.isEmpty()) {
return Collections.emptyList();
}
@@ -254,7 +254,7 @@ public class EntityTraceQueryServiceImpl implements EntityTraceQueryService {
Long maxDurationMs, int pageIndex, int pageSize,
Boolean hideInternal, String spanScope, String attributeFilter) {
ObservedEntityContext entityContext = entityId == null ? null : loadEntityContext(entityId);
- Map<String, Set<String>> identityValues = canonicalIdentityValues(entityContext);
+ Map<String, Set<String>> identityValues = traceQueryIdentityValues(entityContext);
TraceQueryScope queryScope = resolveTraceQueryScope(entityContext, identityValues, serviceName, serviceNamespace, environment);
ResourceFilterSet resourceFilters = removeEntityScopeResourceFilters(
identityValues, parseResourceFilters(resourceFilter));
@@ -399,7 +399,7 @@ public class EntityTraceQueryServiceImpl implements EntityTraceQueryService {
String resourceFilter, String operationName, Long minDurationMs, Long maxDurationMs,
Boolean hideInternal, String spanScope, String attributeFilter) {
ObservedEntityContext entityContext = entityId == null ? null : loadEntityContext(entityId);
- Map<String, Set<String>> identityValues = canonicalIdentityValues(entityContext);
+ Map<String, Set<String>> identityValues = traceQueryIdentityValues(entityContext);
TraceQueryScope queryScope = resolveTraceQueryScope(entityContext, identityValues, serviceName, serviceNamespace, environment);
ResourceFilterSet resourceFilters = removeEntityScopeResourceFilters(
identityValues, parseResourceFilters(resourceFilter));
@@ -541,7 +541,7 @@ public class EntityTraceQueryServiceImpl implements EntityTraceQueryService {
return result;
}
ObservedEntityContext entityContext = entityId == null ? null : loadEntityContext(entityId);
- Map<String, Set<String>> identityValues = canonicalIdentityValues(entityContext);
+ Map<String, Set<String>> identityValues = traceQueryIdentityValues(entityContext);
TraceQueryScope queryScope = resolveTraceQueryScope(entityContext, identityValues, serviceName, serviceNamespace, environment);
ResourceFilterSet resourceFilters = removeEntityScopeResourceFilters(
identityValues, parseResourceFilters(resourceFilter));
@@ -1674,6 +1674,27 @@ public class EntityTraceQueryServiceImpl implements EntityTraceQueryService {
return values.isEmpty() ? Collections.emptyMap() : values;
}
+ private Map<String, Set<String>> traceQueryIdentityValues(ObservedEntityContext entityContext) {
+ Map<String, Set<String>> values = canonicalIdentityValues(entityContext);
+ if (values.size() <= 1
+ || !values.containsKey(OtlpResourceSemanticAttributes.HERTZBEAT_ENTITY_ID)
+ || hasExplicitEntityIdIdentity(entityContext)) {
+ return values;
+ }
+ Map<String, Set<String>> relaxedValues = new LinkedHashMap<>(values);
+ relaxedValues.remove(OtlpResourceSemanticAttributes.HERTZBEAT_ENTITY_ID);
+ return relaxedValues.isEmpty() ? values : relaxedValues;
+ }
+
+ private boolean hasExplicitEntityIdIdentity(ObservedEntityContext entityContext) {
+ if (entityContext == null || CollectionUtils.isEmpty(entityContext.getIdentities())) {
+ return false;
+ }
+ return entityContext.getIdentities().stream()
+ .anyMatch(identity -> OtlpResourceSemanticAttributes.HERTZBEAT_ENTITY_ID.equals(
+ trimText(identity.getIdentityKey())));
+ }
+
private List<String> preferredSearchTerms(Map<String, Set<String>> identityValues) {
List<String> preferredKeys = List.of("service.name", "service.instance.id", "host.name", "k8s.deployment.name", "cloud.resource_id");
List<String> terms = new ArrayList<>();
diff --git a/hertzbeat-observability/src/test/java/org/apache/hertzbeat/observability/ingestion/service/impl/OtlpIngestionWorkspaceServiceImplTest.java b/hertzbeat-observability/src/test/java/org/apache/hertzbeat/observability/ingestion/service/impl/OtlpIngestionWorkspaceServiceImplTest.java
index 71b1772da3..b77e6ce852 100644
--- a/hertzbeat-observability/src/test/java/org/apache/hertzbeat/observability/ingestion/service/impl/OtlpIngestionWorkspaceServiceImplTest.java
+++ b/hertzbeat-observability/src/test/java/org/apache/hertzbeat/observability/ingestion/service/impl/OtlpIngestionWorkspaceServiceImplTest.java
@@ -1332,6 +1332,91 @@ class OtlpIngestionWorkspaceServiceImplTest {
assertFalse(candidate.getResourceMatch().containsKey("operation_name"));
}
+ @Test
+ void relatedMetricsFallsBackToServiceAvailabilityWhenOperationLabelsAreAbsent() {
+ observabilitySignalIntakeGateway.recordOtlpMetricIntake(
+ Map.of(
+ "service.name", "checkout",
+ "service.namespace", "commerce",
+ "deployment.environment.name", "prod"
+ ),
+ 2_000L,
+ "http.server.duration",
+ "histogram",
+ "ms",
+ 14.0,
+ Map.of()
+ );
+ when(workspaceQueryGateway.findEntityById(42L)).thenReturn(java.util.Optional.empty());
+ when(workspaceQueryGateway.findIdentitiesByEntityId(42L)).thenReturn(List.of());
+ when(metricQueryRepository.hasPromqlExecutor()).thenReturn(true);
+ when(metricQueryRepository.queryPromqlRange(
+ anyString(),
+ anyString(),
+ anyLong(),
+ anyLong(),
+ anyString()
+ )).thenAnswer(invocation -> {
+ String refId = invocation.getArgument(0);
+ String query = invocation.getArgument(1);
+ if ("otlp-related-metrics-inventory".equals(refId)) {
+ return promqlSuccess(new DatasourceQueryData("otlp-related-metrics-inventory", 200, null, List.of()));
+ }
+ if (query.contains("__name__=\"http_server_duration\"")
+ && query.contains("service_name=\"checkout\"")
+ && query.contains("service_namespace=\"commerce\"")
+ && query.contains("deployment_environment_name=\"prod\"")
+ && !query.contains("operation_name=")
+ && !query.contains("http_route=")) {
+ return promqlSuccess(new DatasourceQueryData(
+ "otlp-related-metrics",
+ 200,
+ null,
+ List.of(new DatasourceQueryData.SchemaData(
+ new DatasourceQueryData.MetricSchema(
+ List.of(
+ new DatasourceQueryData.MetricField("__ts__", "time", null),
+ new DatasourceQueryData.MetricField("__value__", "number", null)
+ ),
+ Map.of("__name__", "http_server_duration"),
+ Map.of()
+ ),
+ Collections.singletonList(new Object[] {2_000L, 14.0})
+ ))
+ ));
+ }
+ return promqlSuccess(new DatasourceQueryData("otlp-related-metrics", 200, null, List.of()));
+ });
+
+ OtlpRelatedMetricsDto related = otlpIngestionWorkspaceService.getRelatedMetrics(
+ 42L,
+ "service",
+ 1_000L,
+ 2_000L,
+ "checkout",
+ "commerce",
+ "prod",
+ null,
+ "POST /checkout",
+ "8"
+ );
+
+ assertFalse(related.getCandidates().isEmpty());
+ OtlpRelatedMetricsDto.Candidate candidate = related.getCandidates().getFirst();
+ assertEquals("http_server_duration", candidate.getQuery());
+ assertEquals("promql-series", candidate.getReason());
+ assertTrue(candidate.getMatchedLabels().contains("service_name"));
+ assertTrue(candidate.getMatchedLabels().contains("service_namespace"));
+ assertTrue(candidate.getMatchedLabels().contains("deployment_environment_name"));
+ assertFalse(candidate.getMatchedLabels().contains("operation_name"));
+ assertFalse(candidate.getMatchedLabels().contains("http_route"));
+ assertEquals("checkout", candidate.getResourceMatch().get("service_name"));
+ assertEquals("commerce", candidate.getResourceMatch().get("service_namespace"));
+ assertEquals("prod", candidate.getResourceMatch().get("deployment_environment_name"));
+ assertFalse(candidate.getResourceMatch().containsKey("operation_name"));
+ assertFalse(candidate.getResourceMatch().containsKey("http_route"));
+ }
+
@Test
void relatedMetricsPrefersPromqlAvailableCandidatesWhenExecutorExists() {
observabilitySignalIntakeGateway.recordOtlpMetricIntake(
diff --git a/hertzbeat-observability/src/test/java/org/apache/hertzbeat/observability/shared/service/impl/EntityObservabilityGatewayImplTest.java b/hertzbeat-observability/src/test/java/org/apache/hertzbeat/observability/shared/service/impl/EntityObservabilityGatewayImplTest.java
index 875abc8fe6..1d1263aa9a 100644
--- a/hertzbeat-observability/src/test/java/org/apache/hertzbeat/observability/shared/service/impl/EntityObservabilityGatewayImplTest.java
+++ b/hertzbeat-observability/src/test/java/org/apache/hertzbeat/observability/shared/service/impl/EntityObservabilityGatewayImplTest.java
@@ -405,6 +405,7 @@ class EntityObservabilityGatewayImplTest {
assertTrue(summary.isTelemetryReady());
assertTrue(summary.isStatusReady());
assertEquals(100, summary.getReadinessScore());
+ assertEquals(2L, summary.getRelationCount());
EntityOpsSummaryInfo emptySummary = gateway.buildEntityOpsSummary(
ObserveEntity.builder().name("empty").build(),
@@ -417,6 +418,7 @@ class EntityObservabilityGatewayImplTest {
assertFalse(emptySummary.isTelemetryReady());
assertFalse(emptySummary.isStatusReady());
assertEquals(0, emptySummary.getReadinessScore());
+ assertEquals(0L, emptySummary.getRelationCount());
}
@Test
diff --git a/hertzbeat-observability/src/test/java/org/apache/hertzbeat/observability/shared/service/impl/TelemetryIntakeServiceImplTest.java b/hertzbeat-observability/src/test/java/org/apache/hertzbeat/observability/shared/service/impl/TelemetryIntakeServiceImplTest.java
index 331fee5a02..007adce2de 100644
--- a/hertzbeat-observability/src/test/java/org/apache/hertzbeat/observability/shared/service/impl/TelemetryIntakeServiceImplTest.java
+++ b/hertzbeat-observability/src/test/java/org/apache/hertzbeat/observability/shared/service/impl/TelemetryIntakeServiceImplTest.java
@@ -36,6 +36,7 @@ import org.apache.hertzbeat.common.entity.log.LogEntry;
import org.apache.hertzbeat.common.entity.manager.EntityIdentity;
import org.apache.hertzbeat.common.entity.manager.ObserveEntity;
import org.apache.hertzbeat.common.observability.dto.entity.EntityEvidenceSummaryInfo;
+import org.apache.hertzbeat.common.observability.dto.entity.EntityTriageRecommendation;
import org.apache.hertzbeat.common.observability.dto.entity.EntityUnifiedEvidenceSummary;
import org.apache.hertzbeat.common.observability.dto.binding.TelemetryIdentitySnapshot;
import org.apache.hertzbeat.common.observability.dto.evidence.LogEvidence;
@@ -377,6 +378,57 @@ class TelemetryIntakeServiceImplTest {
assertFalse(summary.isLogsActive());
assertEquals(0, summary.getActiveSignalCount());
+ assertEquals(0, summary.getLogEvidenceCount());
+ }
+
+ @Test
+ void buildLogEvidenceDoesNotConvertQueryHintsIntoFakeEvidence() {
+ ObservedEntityContext entityContext = ObservedEntityContext.from(
+ ObserveEntity.builder().id(88L).type("service").name("checkout").build(),
+ List.of(
+ EntityIdentity.builder().entityId(88L).identityKey("service.name").identityValue("checkout").build(),
+ EntityIdentity.builder().entityId(88L).identityKey("service.namespace").identityValue("commerce").build()
+ )
+ );
+ EntityLogQueryHint queryHint = new EntityLogQueryHint(
+ "otel-resource",
+ Map.of("service.name", "checkout", "service.namespace", "commerce"),
+ Collections.emptyList(),
+ null,
+ null,
+ "checkout",
+ "commerce",
+ null,
+ 1000L,
+ 2000L
+ );
+ EntityLogSummaryInfo summaryInfo = new EntityLogSummaryInfo(
+ 1,
+ "otel-resource",
+ "otel-resource",
+ queryHint.getResourceFilters(),
+ Collections.emptyList(),
+ "otel-resource"
+ );
+ when(logQueryRepository.queryLogs(1000L, 2000L, null, null, 20)).thenReturn(Collections.emptyList());
+
+ List<LogEvidence> evidence = telemetryIntakeService.buildLogEvidence(
+ entityContext,
+ summaryInfo,
+ List.of(queryHint)
+ );
+ EntityTriageRecommendation recommendation = telemetryIntakeService.buildTriageRecommendation(
+ new EntityEvidenceSummaryInfo(0, 0, 0, 0L, 1, System.currentTimeMillis()),
+ null,
+ summaryInfo,
+ null,
+ Collections.emptyList(),
+ evidence,
+ Collections.emptyList()
+ );
+
+ assertTrue(evidence.isEmpty());
+ assertEquals("evidence", recommendation.getRecommendedFocus());
}
@Test
@@ -571,6 +623,57 @@ class TelemetryIntakeServiceImplTest {
assertEquals(1, summary.getTraceEvidenceCount());
}
+ @Test
+ void buildTraceSummaryTreatsOtelOkAndUnsetStatusCodesAsNonErrorEvidence() {
+ ObservedEntityContext entityContext = ObservedEntityContext.from(
+ ObserveEntity.builder().id(90L).type("service").name("checkout").build(),
+ List.of(
+ EntityIdentity.builder().entityId(90L).identityKey("service.name").identityValue("checkout").build(),
+ EntityIdentity.builder().entityId(90L).identityKey("service.namespace").identityValue("commerce").build(),
+ EntityIdentity.builder().entityId(90L).identityKey("deployment.environment.name").identityValue("prod").build()
+ )
+ );
+ telemetryIntakeService.recordOtlpTraceIntake(
+ Map.of(
+ "service.name", "checkout",
+ "service.namespace", "commerce",
+ "deployment.environment.name", "prod"
+ ),
+ 1_710_000_001_000L,
+ "trace-ok",
+ "span-ok",
+ "GET /checkout",
+ "status_code_ok",
+ Map.of("http.status_code", "200")
+ );
+ telemetryIntakeService.recordOtlpTraceIntake(
+ Map.of(
+ "service.name", "checkout",
+ "service.namespace", "commerce",
+ "deployment.environment.name", "prod"
+ ),
+ 1_710_000_002_000L,
+ "trace-unset",
+ "span-unset",
+ "GET /checkout/cache",
+ "status_code_unset",
+ Map.of()
+ );
+
+ EntityTraceSummaryDto traceSummary = telemetryIntakeService.buildTraceSummary(entityContext);
+ List<TraceEvidence> traceEvidence = telemetryIntakeService.buildTraceEvidence(
+ entityContext,
+ traceSummary,
+ telemetryIntakeService.buildTraceQueryHints(entityContext)
+ );
+
+ assertEquals(2, traceSummary.getRecentTraceCount());
+ assertEquals(0, traceSummary.getRecentErrorTraceCount());
+ assertEquals(1, traceEvidence.size());
+ assertEquals("ok", traceEvidence.getFirst().getErrorState());
+ assertEquals("active", traceEvidence.getFirst().getSeverityOrHealth());
+ }
+
@Test
void buildMetricCorrelationHintUsesCanonicalIdentityAndTimeWindow() {
ObserveEntity entity = ObserveEntity.builder()
diff --git a/hertzbeat-observability/src/test/java/org/apache/hertzbeat/observability/traces/controller/TraceQueryControllerTest.java b/hertzbeat-observability/src/test/java/org/apache/hertzbeat/observability/traces/controller/TraceQueryControllerTest.java
index 4b8f92fbea..484fb99a0b 100644
--- a/hertzbeat-observability/src/test/java/org/apache/hertzbeat/observability/traces/controller/TraceQueryControllerTest.java
+++ b/hertzbeat-observability/src/test/java/org/apache/hertzbeat/observability/traces/controller/TraceQueryControllerTest.java
@@ -68,7 +68,7 @@ class TraceQueryControllerTest {
);
when(entityTraceQueryService.queryTraceList(
1L, 100L, 200L, "trace-1", true, "checkout", "commerce", "prod",
- "service.version=1.2.3 and hertzbeat.entity_id=\"1\" and hertzbeat.entity_type=\"service\"", "GET /checkout",
+ "service.version=1.2.3 and hertzbeat.entity_type=\"service\"", "GET /checkout",
100L, 500L, 2, 50, true, null, "http.route CONTAINS checkout"))
.thenReturn(new PageImpl<>(List.of(item), PageRequest.of(2, 50), 1));
@@ -97,7 +97,7 @@ class TraceQueryControllerTest {
verify(entityTraceQueryService).queryTraceList(
1L, 100L, 200L, "trace-1", true, "checkout", "commerce", "prod",
- "service.version=1.2.3 and hertzbeat.entity_id=\"1\" and hertzbeat.entity_type=\"service\"", "GET /checkout",
+ "service.version=1.2.3 and hertzbeat.entity_type=\"service\"", "GET /checkout",
100L, 500L, 2, 50, true, null, "http.route CONTAINS checkout");
}
@@ -144,7 +144,7 @@ class TraceQueryControllerTest {
TraceOverviewDto overview = new TraceOverviewDto(2, 1, 1_710_000_000_000L, true);
when(entityTraceQueryService.getTraceOverview(
9L, 100L, 200L, "trace-9", false, "payments", "core", "stage",
- "service.version=2.0.0 and hertzbeat.entity_id=\"9\"", "POST /pay", 200L, 900L, true, null, null))
+ "service.version=2.0.0", "POST /pay", 200L, 900L, true, null, null))
.thenReturn(overview);
mockMvc.perform(get("/api/traces/stats/overview")
@@ -169,7 +169,7 @@ class TraceQueryControllerTest {
verify(entityTraceQueryService).getTraceOverview(
9L, 100L, 200L, "trace-9", false, "payments", "core", "stage",
- "service.version=2.0.0 and hertzbeat.entity_id=\"9\"", "POST /pay", 200L, 900L, true, null, null);
+ "service.version=2.0.0", "POST /pay", 200L, 900L, true, null, null);
}
@Test
@@ -213,7 +213,7 @@ class TraceQueryControllerTest {
);
when(entityTraceQueryService.getTraceGroupByStats(
3L, 100L, 200L, "trace-3", true, "checkout", "commerce", "prod",
- "host.name=checkout-1 and hertzbeat.entity_id=\"3\"", "GET /checkout", 100L, 500L,
+ "host.name=checkout-1", "GET /checkout", 100L, 500L,
"resource:service.version", 7, "latency-p95-desc", 5, true, "entrypoint", null))
.thenReturn(result);
@@ -247,7 +247,7 @@ class TraceQueryControllerTest {
verify(entityTraceQueryService).getTraceGroupByStats(
3L, 100L, 200L, "trace-3", true, "checkout", "commerce", "prod",
- "host.name=checkout-1 and hertzbeat.entity_id=\"3\"", "GET /checkout", 100L, 500L,
+ "host.name=checkout-1", "GET /checkout", 100L, 500L,
"resource:service.version", 7, "latency-p95-desc", 5, true, "entrypoint", null);
}
}
diff --git a/hertzbeat-observability/src/test/java/org/apache/hertzbeat/observability/traces/service/impl/EntityTraceQueryServiceImplTest.java b/hertzbeat-observability/src/test/java/org/apache/hertzbeat/observability/traces/service/impl/EntityTraceQueryServiceImplTest.java
index 5bc8e48704..6ba71b6977 100644
--- a/hertzbeat-observability/src/test/java/org/apache/hertzbeat/observability/traces/service/impl/EntityTraceQueryServiceImplTest.java
+++ b/hertzbeat-observability/src/test/java/org/apache/hertzbeat/observability/traces/service/impl/EntityTraceQueryServiceImplTest.java
@@ -161,6 +161,49 @@ class EntityTraceQueryServiceImplTest {
org.mockito.ArgumentMatchers.<Map<String, Set<String>>>any(), org.mockito.ArgumentMatchers.any());
}
+ @Test
+ void buildEntityTraceSummaryUsesResourceIdentityWhenEntityWasCreatedAfterOtlpTrace() {
+ long now = System.currentTimeMillis();
+ ObservedEntityContext entityContext = ObservedEntityContext.from(
+ ObserveEntity.builder().id(658273243069696L).type("service")
+ .name("codex-pd-1369-novice-checkout").build(),
+ List.of(
+ identity(658273243069696L, "service.name", "codex-pd-1369-novice-checkout", 90, true),
+ identity(658273243069696L, "service.namespace", "product-design-1369", 30, false),
+ identity(658273243069696L, "deployment.environment.name", "prod", 20, false)
+ ));
+ when(traceQueryRepository.supportsTraceSummaryRows()).thenReturn(true);
+ when(traceQueryRepository.queryTraceSummaryRows(
+ org.mockito.ArgumentMatchers.anyLong(), org.mockito.ArgumentMatchers.anyLong(),
+ eq("codex-pd-1369-novice-checkout"), eq("product-design-1369"), eq("prod"),
+ org.mockito.ArgumentMatchers.isNull(), org.mockito.ArgumentMatchers.<Map<String, Set<String>>>any(),
+ eq(false)))
+ .thenReturn(Map.of(
+ "total_trace_count", 1L,
+ "error_trace_count", 0L,
+ "latest_observed_at", now - 20_000,
+ "latest_trace_id", "13691369136913691369136913691369"
+ ));
+
+ EntityTraceSummaryDto summary = entityTraceQueryService.buildEntityTraceSummary(entityContext);
+
+ assertEquals(1, summary.getRecentTraceCount());
+ assertEquals(0, summary.getRecentErrorTraceCount());
+ assertEquals("13691369136913691369136913691369", summary.getLatestTraceId());
+ ArgumentCaptor<Map<String, Set<String>>> identityFilterCaptor = ArgumentCaptor.forClass(Map.class);
+ verify(traceQueryRepository).queryTraceSummaryRows(
+ org.mockito.ArgumentMatchers.anyLong(), org.mockito.ArgumentMatchers.anyLong(),
+ eq("codex-pd-1369-novice-checkout"), eq("product-design-1369"), eq("prod"),
+ org.mockito.ArgumentMatchers.isNull(), identityFilterCaptor.capture(), eq(false));
+ assertNull(identityFilterCaptor.getValue().get("hertzbeat.entity_id"));
+ assertEquals(Set.of("codex-pd-1369-novice-checkout"),
+ identityFilterCaptor.getValue().get("service.name"));
+ assertEquals(Set.of("product-design-1369"),
+ identityFilterCaptor.getValue().get("service.namespace"));
+ assertEquals(Set.of("prod"),
+ identityFilterCaptor.getValue().get("deployment.environment.name"));
+ }
+
@Test
void getTraceGroupByStatsUsesStorageRowsAndMapsLatencyMetrics() {
AuthTokenRequestContext.bindWorkspaceId("team-a");
diff --git a/web-next/app/actions/actions-page.tsx b/web-next/app/actions/actions-page.tsx
index ebd38aec93..eff0744bc0 100644
--- a/web-next/app/actions/actions-page.tsx
+++ b/web-next/app/actions/actions-page.tsx
@@ -47,6 +47,14 @@ type ApprovalDraftQueueResult = {
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);
@@ -209,6 +217,10 @@ export default function ActionsPage({ suggestionContext }: { suggestionContext?:
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
@@ -273,14 +285,10 @@ export default function ActionsPage({ suggestionContext }: { suggestionContext?:
endpoint: approvalDecisionEndpoint,
managerBacked: Boolean(approvalDecisionResult?.managerBacked),
requestPreview: approvalDraftResult?.draftId
- ? JSON.stringify({
- draftId: approvalDraftResult.draftId,
- decision: 'approved',
- executionAllowed: false
- })
+ ? approvalDecisionRequestPreview
: state.approvalDecision.requestPreview,
- onApprove: approvalDraftResult?.draftId ? () => void decideApprovalDraft('approved') : undefined,
- onReject: approvalDraftResult?.draftId ? () => void decideApprovalDraft('rejected') : undefined,
+ onApprove: canDecideApprovalDraft ? () => void decideApprovalDraft('approved') : undefined,
+ onReject: canDecideApprovalDraft ? () => void decideApprovalDraft('rejected') : undefined,
result: approvalDecisionResult,
error: approvalDecisionError
}}
diff --git a/web-next/app/actions/page.test.tsx b/web-next/app/actions/page.test.tsx
index 6a41ccce09..7cb7c161ab 100644
--- a/web-next/app/actions/page.test.tsx
+++ b/web-next/app/actions/page.test.tsx
@@ -94,6 +94,9 @@ describe('actions page', () => {
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');
@@ -159,6 +162,23 @@ describe('actions page', () => {
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(
diff --git a/web-next/app/alert-family.chrome.test.ts b/web-next/app/alert-family.chrome.test.ts
index e9176daff7..f73530dada 100644
--- a/web-next/app/alert-family.chrome.test.ts
+++ b/web-next/app/alert-family.chrome.test.ts
@@ -5,13 +5,14 @@ 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('hzOpsCatalogVisual');
- expect(integrationSource).toContain('data-alert-integration-surface="otlp-cold-source-doc"');
- expect(integrationSource).toContain('data-alert-integration-container="cold-source-doc-shell"');
- expect(integrationSource).toContain('data-alert-integration-source-rail="cold-source-list"');
- expect(integrationSource).toContain('data-alert-integration-doc-panel="cold-markdown-doc"');
+ 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');
@@ -19,15 +20,17 @@ describe('alert family cold-matte routes', () => {
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('AlertNoticeConsoleShell');
- expect(noticeSource).toContain('hzOpsCatalogVisual');
- expect(noticeSource).toContain('data-alert-notice-surface="otlp-cold-notice-console"');
- expect(noticeSource).toContain('data-alert-notice-header="cold-compact-header"');
- expect(noticeSource).toContain('HzConfirmDialog');
- expect(noticeShellSource).toContain('data-alert-notice-workbench-panel="cold-tabbed-table-panel"');
- expect(noticeShellSource).toContain('data-alert-notice-global-panel="cold-matte-tabbed-table"');
+ expect(noticeSource).toContain('AlertNoticePage');
+ expect(noticePageSource).toContain('AlertNoticeConsoleShell');
+ expect(noticePageSource).toContain('hzOpsCatalogVisual');
+ expect(noticePageSource).toContain('data-alert-notice-surface="otlp-hertzbeat-ui-notice-console"');
+ expect(noticePageSource).toContain('data-alert-notice-header="hertzbeat-ui-compact-header"');
+ expect(noticePageSource).toContain('HzConfirmDialog');
+ expect(noticeShellSource).toContain('data-alert-notice-workbench-panel="hertzbeat-ui-tabbed-table-panel"');
+ expect(noticeShellSource).toContain('data-alert-notice-global-panel="hertzbeat-ui-matte-tabbed-table"');
expect(noticeSource).not.toContain('components/observability');
expect(noticeSource).not.toContain('components/workbench/primitives');
});
diff --git a/web-next/app/alert/alert-center-page.tsx b/web-next/app/alert/alert-center-page.tsx
index f666601e3c..59ecb3fe0d 100644
--- a/web-next/app/alert/alert-center-page.tsx
+++ b/web-next/app/alert/alert-center-page.tsx
@@ -17,6 +17,7 @@ import {
} from '../../lib/alert-manage/controller';
import {
ALERT_CENTER_PAGE_SIZE_OPTIONS,
+ buildAlertCenterRouteUrl,
buildAlertListUrl,
hasAlertEntityContext,
type AlertCenterRouteState,
@@ -149,19 +150,23 @@ export default function AlertCenterPage({ initialRouteState }: { initialRouteSta
const handlePageIndexChange = useCallback((nextPageIndex: number) => {
const normalizedPageIndex = Math.max(0, Math.floor(nextPageIndex));
+ const nextQuery = { ...query, pageIndex: normalizedPageIndex };
setDraft(current => ({ ...current, pageIndex: normalizedPageIndex }));
- setQuery(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(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);
@@ -178,8 +183,10 @@ export default function AlertCenterPage({ initialRouteState }: { initialRouteSta
? 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) {
@@ -188,7 +195,7 @@ export default function AlertCenterPage({ initialRouteState }: { initialRouteSta
copy: buildAlertClosureOperationFailureFeedback(action, t)
});
}
- }, [query, t]);
+ }, [query, router, t]);
const handleRuleQuickCreate = useCallback(async (
mode: AlertRuleDialogMode,
@@ -244,6 +251,7 @@ export default function AlertCenterPage({ initialRouteState }: { initialRouteSta
setQuery(cleared);
setSelectedGroupIds([]);
setEntityResponseResult(null);
+ router.replace(buildAlertCenterRouteUrl(cleared));
}}
operationFeedback={operationFeedback}
entityResponseResult={entityResponseResult}
diff --git a/web-next/app/alert/group/alert-group-page.tsx b/web-next/app/alert/group/alert-group-page.tsx
index 0f44286e02..9273e5d47c 100644
--- a/web-next/app/alert/group/alert-group-page.tsx
+++ b/web-next/app/alert/group/alert-group-page.tsx
@@ -1,6 +1,7 @@
'use client';
-import React, { useCallback, useMemo, useState } from 'react';
+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';
@@ -17,7 +18,13 @@ import {
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, validateAlertGroupForm } from '../../../lib/alert-group/view-model';
+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';
@@ -34,19 +41,75 @@ 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('');
- const [query, setQuery] = useState('');
- const [pageIndex, setPageIndex] = useState(0);
- const [pageSize, setPageSize] = useState<number>(ALERT_GROUP_PAGE_SIZE_OPTIONS[0]);
+ 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);
@@ -56,6 +119,10 @@ export default function AlertGroupPage({ initialRouteState }: { initialRouteStat
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);
@@ -65,6 +132,46 @@ export default function AlertGroupPage({ initialRouteState }: { initialRouteStat
() => ['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(
@@ -89,7 +196,10 @@ export default function AlertGroupPage({ initialRouteState }: { initialRouteStat
const selected = data.list.content.find(item => item.id === selectedId) ?? data.list.content[0] ?? null;
async function handleNew() {
- setDraft(buildAlertGroupFormDraft(null, groupEvidenceContext?.draftPatch));
+ const nextDraft = buildAlertGroupFormDraft(null, groupEvidenceContext?.draftPatch);
+ setDraft(nextDraft);
+ setEditorInitialFingerprint(serializeAlertGroupDraft(nextDraft));
+ setEditorDiscardDialogOpen(false);
setEditorError(null);
setEditorErrorDetail(null);
setEditorErrorContract(null);
@@ -107,7 +217,10 @@ export default function AlertGroupPage({ initialRouteState }: { initialRouteStat
setEditorMessage(null);
try {
const detail = await loadAlertGroupDetailFromFacade(api.alertGroups.detail, targetId);
- setDraft(buildAlertGroupFormDraft(detail));
+ 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'));
@@ -121,9 +234,13 @@ export default function AlertGroupPage({ initialRouteState }: { initialRouteStat
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);
@@ -138,8 +255,10 @@ export default function AlertGroupPage({ initialRouteState }: { initialRouteStat
} 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'));
@@ -223,33 +342,77 @@ export default function AlertGroupPage({ initialRouteState }: { initialRouteStat
}
function handleApplyFilter() {
- setQuery(search);
+ 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
@@ -258,6 +421,7 @@ export default function AlertGroupPage({ initialRouteState }: { initialRouteStat
search={search}
selectedId={selectedId}
checkedIds={checkedIds}
+ requestedPageSize={pageSize}
editorOpen={editorOpen}
editorLoading={editorLoading}
editorSaving={editorSaving}
@@ -284,19 +448,30 @@ export default function AlertGroupPage({ initialRouteState }: { initialRouteStat
onEdit={groupId => void handleEdit(groupId)}
onDelete={groupId => void handleDelete(groupId)}
onDeleteSelected={() => void handleDeleteSelected()}
- onCloseEditor={() => setEditorOpen(false)}
+ 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={
- deleteRequest?.kind === 'batch'
- ? t('alert.group.delete.confirm.batch', { count: deleteRequest.ids.length })
- : t('alert.group.delete.confirm.single')
- }
- confirmLabel={t('common.button.ok')}
+ copy={deleteConfirmCopy}
+ confirmLabel={t('alert.group.delete.confirm.action')}
cancelLabel={t('common.button.cancel')}
pending={deletePending}
onCancel={() => setDeleteRequest(null)}
diff --git a/web-next/app/alert/group/page.test.tsx b/web-next/app/alert/group/page.test.tsx
index ba0c09dc7a..c419ecad65 100644
--- a/web-next/app/alert/group/page.test.tsx
+++ b/web-next/app/alert/group/page.test.tsx
@@ -1,13 +1,20 @@
+// @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 { beforeEach, describe, expect, it, vi } from 'vitest';
+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,
@@ -37,6 +44,16 @@ const mockState = vi.hoisted(() => ({
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()
@@ -63,31 +80,32 @@ vi.mock('../../../components/workbench/client-workbench', () => ({
}));
vi.mock('../../../components/pages/alert-group-surface', () => ({
- AlertGroupSurface: ({
- data,
- labelOptions,
- evidenceContext,
- draft,
- pageSizeOptions
- }: {
- data: { list: { totalElements: number } };
- labelOptions?: { keys: string[] };
- evidenceContext?: { signal: string; groupLabelsText: string; returnHref?: string } | null;
- draft?: { groupLabelsText?: string };
- pageSizeOptions?: number[];
- }) => (
- <div
- data-alert-group-surface="true"
- data-total={data.list.totalElements}
- data-label-options={labelOptions?.keys?.join('|')}
- data-page-size-options={pageSizeOptions?.join('|')}
- 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 ?? ''}
- />
- )
+ 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', () => ({
@@ -108,8 +126,25 @@ async function renderAlertGroupPage(initialRouteState: AlertGroupRouteState = EM
}
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);
});
@@ -119,6 +154,7 @@ describe('alert group page', () => {
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?.();
@@ -157,7 +193,7 @@ describe('alert group page', () => {
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).not.toContain('useSearchParams');
+ 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');
});
@@ -167,8 +203,8 @@ describe('alert group page', () => {
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(0)');
- expect(source).toContain('const [pageSize, setPageSize] = useState<number>(ALERT_GROUP_PAGE_SIZE_OPTIONS[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]');
@@ -187,6 +223,73 @@ describe('alert group page', () => {
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');
@@ -220,6 +323,55 @@ describe('alert group page', () => {
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');
@@ -234,11 +386,21 @@ describe('alert group page', () => {
const source = readFileSync(resolve(process.cwd(), 'lib/alert-group/view-model.ts'), 'utf8');
expect(source).toContain('if (!draft.groupWait.trim())');
- expect(source).toContain("return t('alert.group.validation.group-wait')");
+ expect(source).toContain("t('alert.group.validation.group-wait')");
expect(source).toContain('if (!draft.groupInterval.trim())');
- expect(source).toContain("return t('alert.group.validation.group-interval')");
+ expect(source).toContain("t('alert.group.validation.group-interval')");
expect(source).toContain('if (!draft.repeatInterval.trim())');
- expect(source).toContain("return t('alert.group.validation.repeat-interval')");
+ 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', () => {
@@ -247,9 +409,9 @@ describe('alert group page', () => {
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("return t('alert.group.validation.group-wait-non-negative')");
- expect(source).toContain("return t('alert.group.validation.group-interval-non-negative')");
- expect(source).toContain("return t('alert.group.validation.repeat-interval-non-negative')");
+ 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', () => {
@@ -264,4 +426,14 @@ describe('alert group page', () => {
expect(source).not.toContain("setEditorMessage(t('common.delete-success'))");
expect(source).not.toContain("t('common.delete-failed')");
});
+
+ it('names the target and destructive action in group delete confirmation', () => {
+ const source = readFileSync(resolve(process.cwd(), 'app/alert/group/alert-group-page.tsx'), 'utf8');
+
+ expect(source).toContain('const deleteTargetNames = deleteRequest?.ids');
+ expect(source).toContain("t('alert.group.delete.confirm.targets', { names: deleteTargetNames.join(', ') })");
+ expect(source).toContain("t('alert.group.delete.confirm.targets-more', { count: missingDeleteTargetCount })");
+ expect(source).toContain("confirmLabel={t('alert.group.delete.confirm.action')}");
+ expect(source).not.toContain("confirmLabel={t('common.button.ok')}");
+ });
});
diff --git a/web-next/app/alert/inhibit/alert-inhibit-page.tsx b/web-next/app/alert/inhibit/alert-inhibit-page.tsx
index d1b6c5c6de..7b21acec93 100644
--- a/web-next/app/alert/inhibit/alert-inhibit-page.tsx
+++ b/web-next/app/alert/inhibit/alert-inhibit-page.tsx
@@ -1,7 +1,8 @@
'use client';
import React from 'react';
-import { useCallback, useMemo, useState } 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';
@@ -21,12 +22,18 @@ import {
type AlertInhibitFormDraft
} from '../../../lib/alert-inhibit/controller';
import { ALERT_INHIBIT_PAGE_SIZE_OPTIONS, buildAlertInhibitUrl, type AlertInhibitRouteState } from '../../../lib/alert-inhibit/query-state';
-import { buildAlertInhibitEvidenceContext, validateAlertInhibitForm } from '../../../lib/alert-inhibit/view-model';
+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';
@@ -35,6 +42,21 @@ type InhibitDeleteRequest = {
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: '',
@@ -58,6 +80,18 @@ const EMPTY_ALERT_INHIBIT_ROUTE_STATE: AlertInhibitRouteState = {
}
};
+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;
@@ -105,18 +139,75 @@ function withTimeoutFallback<T>(promise: Promise<T>, fallback: T, timeoutMs: num
});
}
+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, signalContext, t),
- [signal, signalContext, t]
+ () => buildAlertInhibitEvidenceContext(signal, inhibitEvidenceRouteContext, t),
+ [signal, inhibitEvidenceRouteContext, t]
);
- const [search, setSearch] = useState('');
- const [query, setQuery] = useState('');
- const [pageIndex, setPageIndex] = useState(0);
- const [pageSize, setPageSize] = useState<number>(ALERT_INHIBIT_PAGE_SIZE_OPTIONS[0]);
+ 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);
@@ -126,6 +217,10 @@ export default function AlertInhibitPage({ initialRouteState }: { initialRouteSt
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);
@@ -141,6 +236,46 @@ export default function AlertInhibitPage({ initialRouteState }: { initialRouteSt
() => ['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(
@@ -202,13 +337,18 @@ export default function AlertInhibitPage({ initialRouteState }: { initialRouteSt
);
setEntityPrefillSource(prefill.source);
setEntityPrefillWarning(prefill.warning);
- setDraft(buildAlertInhibitFormDraft(null, { ...baseDraftPatch, ...prefill.draftPatch }));
+ const nextDraft = buildAlertInhibitFormDraft(null, { ...baseDraftPatch, ...prefill.draftPatch });
+ setDraft(nextDraft);
+ setEditorInitialFingerprint(serializeAlertInhibitDraft(nextDraft));
} finally {
setEditorLoading(false);
}
} else {
- setDraft(buildAlertInhibitFormDraft(null, baseDraftPatch));
+ const nextDraft = buildAlertInhibitFormDraft(null, baseDraftPatch);
+ setDraft(nextDraft);
+ setEditorInitialFingerprint(serializeAlertInhibitDraft(nextDraft));
}
+ setEditorDiscardDialogOpen(false);
setEditorError(null);
setEditorErrorDetail(null);
setEditorErrorContract(null);
@@ -227,7 +367,10 @@ export default function AlertInhibitPage({ initialRouteState }: { initialRouteSt
setEditorMessage(null);
try {
const detail = await loadAlertInhibitDetailFromFacade(api.alertInhibits.detail, targetId);
- setDraft(buildAlertInhibitFormDraft(detail));
+ 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'));
@@ -241,9 +384,13 @@ export default function AlertInhibitPage({ initialRouteState }: { initialRouteSt
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);
@@ -258,8 +405,10 @@ export default function AlertInhibitPage({ initialRouteState }: { initialRouteSt
} 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) {
@@ -271,6 +420,22 @@ export default function AlertInhibitPage({ initialRouteState }: { initialRouteSt
}
}
+ 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;
@@ -344,35 +509,78 @@ export default function AlertInhibitPage({ initialRouteState }: { initialRouteSt
}
function handleApplyFilter() {
- setQuery(search);
+ 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}
@@ -404,6 +612,7 @@ export default function AlertInhibitPage({ initialRouteState }: { initialRouteSt
onSelect={setSelectedId}
onCheckedIdsChange={setCheckedIds}
pageSizeOptions={[...ALERT_INHIBIT_PAGE_SIZE_OPTIONS]}
+ requestedPageSize={pageSize}
onPageIndexChange={handlePageIndexChange}
onPageSizeChange={handlePageSizeChange}
onViewAllRules={() => {
@@ -425,27 +634,26 @@ export default function AlertInhibitPage({ initialRouteState }: { initialRouteSt
onToggleEnabled={inhibit => void handleToggleEnabled(inhibit)}
onDelete={inhibitId => void handleDelete(inhibitId)}
onDeleteSelected={() => void handleDeleteSelected()}
- onCloseEditor={() => setEditorOpen(false)}
+ 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-delete-confirm={deleteRequest ? 'open' : 'closed'}>
+ <div
+ data-alert-inhibit-unsaved-cancel="hertzbeat-ui-confirm-dialog"
+ data-alert-inhibit-unsaved-cancel-state={editorDiscardDialogOpen ? 'open' : 'closed'}
+ >
<HzConfirmDialog
- open={Boolean(deleteRequest)}
- title={deleteRequest?.kind === 'batch' ? t('common.confirm.delete-batch') : t('common.confirm.delete')}
- copy={
- deleteRequest?.kind === 'batch'
- ? t('alert.inhibit.delete.confirm.batch', { count: deleteRequest.ids.length })
- : t('alert.inhibit.delete.confirm.single')
- }
- confirmLabel={t('common.button.ok')}
- cancelLabel={t('common.button.cancel')}
- pending={deletePending}
- onCancel={() => setDeleteRequest(null)}
- onConfirm={() => void handleConfirmedDelete()}
+ 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>
</>
diff --git a/web-next/app/alert/inhibit/page.test.tsx b/web-next/app/alert/inhibit/page.test.tsx
index 7c50eabd2a..d0c9bbc594 100644
--- a/web-next/app/alert/inhibit/page.test.tsx
+++ b/web-next/app/alert/inhibit/page.test.tsx
@@ -1,13 +1,20 @@
+// @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 { beforeEach, describe, expect, it, vi } from 'vitest';
+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,
@@ -30,6 +37,16 @@ const mockState = vi.hoisted(() => ({
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()
@@ -56,51 +73,46 @@ vi.mock('../../../components/workbench/client-workbench', () => ({
}));
vi.mock('../../../components/pages/alert-inhibit-surface', () => ({
- AlertInhibitSurface: ({
- data,
- returnContext,
- managementContext,
- matchedViewEnabled,
- missingMatchedRuleCount,
- createdOutsideMatchedViewNotice,
- entityPrefillSource,
- entityPrefillWarning,
- evidenceContext,
- draft,
- pageSizeOptions
- }: {
- data: { list: { totalElements: number } };
- returnContext?: Record<string, string>;
- managementContext?: { matchMode?: string; matchingRuleIds?: number[] };
- matchedViewEnabled?: boolean;
- missingMatchedRuleCount?: number;
- createdOutsideMatchedViewNotice?: boolean;
- entityPrefillSource?: string;
- entityPrefillWarning?: string | null;
- evidenceContext?: { signal: string; sourceLabelsText: string; returnHref?: string } | null;
- draft?: { sourceLabelsText?: string; targetLabelsText?: string; equalLabelsText?: string };
- pageSizeOptions?: number[];
- }) => (
- <div
- data-alert-inhibit-surface="true"
- data-total={data.list.totalElements}
- data-page-size-options={pageSizeOptions?.join('|')}
- 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 ?? ''}
- />
- )
+ 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', () => ({
@@ -139,8 +151,25 @@ async function renderAlertInhibitPage(initialRouteState: AlertInhibitRouteState
}
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);
});
@@ -149,6 +178,7 @@ describe('alert inhibit page', () => {
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?.();
@@ -269,7 +299,7 @@ describe('alert inhibit page', () => {
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).not.toContain('useSearchParams');
+ 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');
@@ -282,8 +312,8 @@ describe('alert inhibit page', () => {
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(0)');
- expect(source).toContain('const [pageSize, setPageSize] = useState<number>(ALERT_INHIBIT_PAGE_SIZE_OPTIONS[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]');
@@ -303,6 +333,148 @@ describe('alert inhibit page', () => {
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: {
@@ -413,6 +585,16 @@ describe('alert inhibit page', () => {
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(
diff --git a/web-next/app/alert/integration/[source]/alert-integration-source-redirect.tsx b/web-next/app/alert/integration/[source]/alert-integration-source-redirect.tsx
new file mode 100644
index 0000000000..5c8b3b17b5
--- /dev/null
+++ b/web-next/app/alert/integration/[source]/alert-integration-source-redirect.tsx
@@ -0,0 +1,17 @@
+'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]"
+ />
+ );
+}
diff --git a/web-next/app/alert/integration/[source]/page.test.tsx b/web-next/app/alert/integration/[source]/page.test.tsx
index 6ee3908fdf..cbfb55ae97 100644
--- a/web-next/app/alert/integration/[source]/page.test.tsx
+++ b/web-next/app/alert/integration/[source]/page.test.tsx
@@ -4,7 +4,6 @@ import React from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { describe, expect, it, vi } from 'vitest';
-const redirect = vi.fn();
const headers = vi.fn();
const mockLoadIntegrationDoc = vi.hoisted(() =>
@@ -38,10 +37,6 @@ vi.mock('next/link', () => ({
)
}));
-vi.mock('next/navigation', () => ({
- redirect
-}));
-
vi.mock('next/headers', () => ({
headers
}));
@@ -151,6 +146,7 @@ describe('alert integration page', () => {
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"');
@@ -160,7 +156,8 @@ describe('alert integration page', () => {
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="pending"');
+ 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');
@@ -194,6 +191,7 @@ describe('alert integration page', () => {
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');
@@ -217,19 +215,24 @@ describe('alert integration page', () => {
expect(source).not.toContain("from '@/components/observability/code-pane'");
});
- it('redirects unknown source params to the canonical default source route', async () => {
+ 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' }));
- redirect.mockImplementation((target: string) => {
- throw new Error(`redirect:${target}`);
- });
+ 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');
- await expect(
- AlertIntegrationPage({
+ const html = renderToStaticMarkup(
+ await AlertIntegrationPage({
params: Promise.resolve({ source: 'unknown-provider' })
})
- ).rejects.toThrow('redirect:/alert/integration/webhook');
- expect(redirect).toHaveBeenLastCalledWith('/alert/integration/webhook');
+ );
+
+ 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"');
});
});
diff --git a/web-next/app/alert/integration/[source]/page.tsx b/web-next/app/alert/integration/[source]/page.tsx
index 2fdf0d1f78..8965f7f46c 100644
--- a/web-next/app/alert/integration/[source]/page.tsx
+++ b/web-next/app/alert/integration/[source]/page.tsx
@@ -1,7 +1,6 @@
import path from 'node:path';
import Link from 'next/link';
import { headers } from 'next/headers';
-import { redirect } from 'next/navigation';
import React from 'react';
import { Settings } from 'lucide-react';
import { HzSourceDocShell } from '@hertzbeat/ui/source-doc-shell';
@@ -15,12 +14,13 @@ import {
} 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) {
- redirect(buildAlertIntegrationSourceHref(selectedSource));
+ return <AlertIntegrationSourceRedirect href={buildAlertIntegrationSourceHref(selectedSource)} />;
}
const requestHeaders = await headers();
const locale = normalizeLocale(requestHeaders.get('accept-language'));
@@ -41,7 +41,8 @@ export default async function AlertIntegrationPage({ params }: { params: Promise
selected: item.id === selectedSource.id,
itemProps: {
'data-alert-integration-source-item': item.id,
- 'data-alert-integration-source-selected': item.id === selectedSource.id ? 'true' : undefined
+ '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
diff --git a/web-next/app/alert/notice/alert-notice-page.tsx b/web-next/app/alert/notice/alert-notice-page.tsx
index 16a5056066..565a754c37 100644
--- a/web-next/app/alert/notice/alert-notice-page.tsx
+++ b/web-next/app/alert/notice/alert-notice-page.tsx
@@ -1,13 +1,14 @@
'use client';
-import React, { useCallback, useMemo, useState } from 'react';
-import { ArrowLeft, Eye, Inbox, MoreHorizontal, Pencil, Plus, RefreshCw, Trash2 } from 'lucide-react';
+import React, { useCallback, useEffect, useMemo, useState } from 'react';
+import { useRouter, useSearchParams } from 'next/navigation';
+import { ArrowLeft, CircleHelp, Eye, Inbox, MoreHorizontal, Pencil, Plus, RefreshCw, Trash2 } from 'lucide-react';
import { HzConfirmDialog, HzPaginationBar } from '@hertzbeat/ui';
import { AlertNoticeConsoleShell, type AlertNoticeConsoleTabKey } from '../../../components/pages/alert-notice-console-shell';
import { ClientWorkbench } from '../../../components/workbench/client-workbench';
import { useI18n } from '../../../components/providers/i18n-provider';
import { AlertNoticeReceiverFields } from '../../../components/pages/alert-notice-receiver-fields';
-import { AlertNoticeRuleFields } from '../../../components/pages/alert-notice-rule-fields';
+import { AlertNoticeRuleFields, type NoticeRuleValidationIssue } from '../../../components/pages/alert-notice-rule-fields';
import { AlertNoticeTemplateFields } from '../../../components/pages/alert-notice-template-fields';
import { Button } from '../../../components/ui/button';
import { SearchRow } from '../../../components/ui/search-row';
@@ -22,7 +23,7 @@ import {
import { api } from '../../../lib/alert-api-facade';
import { buildNoticeListUrl, buildNoticeRuleDisplayNames, buildNoticeRuleDraft, buildNoticeTemplateListUrl, loadAlertNoticeDataFromFacade, type NoticeReceiverDraft, type NoticeRuleDraft, type NoticeTemplateDraft } from '../../../lib/alert-notice/controller';
import type { AlertNoticeRouteState } from '../../../lib/alert-notice/query-state';
-import { buildAlertNoticeEvidenceContext, buildNoticeReceiverDraft, buildNoticeTemplateDraft, getAlertNoticeProductCopy, validateNoticeReceiverDraft, validateNoticeRuleDraft, validateNoticeTemplateDraft } from '../../../lib/alert-notice/view-model';
+import { buildAlertNoticeEvidenceContext, buildNoticeReceiverDraft, buildNoticeReceiverValidationIssues, buildNoticeTemplateDraft, buildNoticeTemplateValidationIssues, getAlertNoticeProductCopy, validateNoticeRuleDraft, type NoticeReceiverValidationIssue, type NoticeTemplateValidationIssue } from '../../../lib/alert-notice/view-model';
import { DEFAULT_ALERT_LABEL_OPTIONS, loadAlertLabelOptionsFromFacade, type AlertLabelOptions } from '../../../lib/alert-label-options';
import { hzOpsCatalogVisual } from '../../../lib/hz-ops-visual';
import { formatTime } from '../../../lib/format';
@@ -40,12 +41,49 @@ type NoticePageData = {
type NoticeDeleteRequest = {
kind: 'receiver' | 'rule' | 'template';
id: number;
+ name?: string;
+};
+
+type AlertNoticeActionHelpCopy = {
+ label: string;
+ body: string;
+ impact?: string;
+};
+export type AlertNoticeRouteTab = 'receiver' | 'rule' | 'template';
+export type AlertNoticeListRouteState = {
+ selectedTab: AlertNoticeRouteTab;
+ receiverSearch: string;
+ receiverPageIndex: number;
+ receiverPageSize: number;
+ ruleSearch: string;
+ rulePageIndex: number;
+ rulePageSize: number;
+ templateSearch: string;
+ templatePresetFilter: boolean;
+ templatePageIndex: number;
+ templatePageSize: number;
};
const NOTICE_PAGE_SIZE_OPTIONS = [8, 15, 25];
type Translator = ReturnType<typeof useI18n>['t'];
const coldNoticeVisual = hzOpsCatalogVisual;
const coldToolbarClass = 'flex flex-wrap items-center gap-2 border-b border-[#252b34] bg-[#0b0c0e] px-3 py-2';
+
+function resolveNoticeRuleValidationIssues(
+ validationError: string | null,
+ t: Translator
+): NoticeRuleValidationIssue[] {
+ if (!validationError) return [];
+ const validationMap: Array<{ field: NoticeRuleValidationIssue['field']; key: string }> = [
+ { field: 'name', key: 'alert.notice.rule.validation.name' },
+ { field: 'receiver', key: 'alert.notice.rule.validation.receivers' },
+ { field: 'labels', key: 'alert.notice.rule.validation.labels' },
+ { field: 'days', key: 'alert.notice.rule.validation.days' },
+ { field: 'time', key: 'alert.notice.rule.validation.period' }
+ ];
+ const matchedIssue = validationMap.find(issue => t(issue.key) === validationError);
+ return matchedIssue ? [{ field: matchedIssue.field, message: validationError }] : [];
+}
const coldSelectClass =
'h-8 w-[132px] rounded-[3px] border border-[#2b3039] bg-[#101217] px-2 text-[12px] font-semibold text-[#eef2f7] outline-none focus:border-[#4e74f8]';
const coldTableShellClass = 'rounded-[4px] border border-[#252b34] bg-[#0b0c0e]';
@@ -58,12 +96,218 @@ const coldCommandButtonClass = `${coldButtonClassName} w-[124px] min-w-[124px]`;
const coldPrimaryCommandButtonClass = `${coldPrimaryButtonClassName} w-[124px] min-w-[124px]`;
const coldIconButtonClass =
'h-8 w-8 min-w-0 rounded-[3px] border-[#2b3039] bg-[#101217] text-[#dbe4f0] hover:border-[#4e74f8] hover:bg-[#151b28] hover:text-white';
+const coldStickyActionHeaderClass =
+ 'sticky right-0 z-10 w-[168px] border-l border-[#252b34] bg-[#101217] px-3 py-3 text-center shadow-[-12px_0_18px_rgba(0,0,0,0.28)]';
+const coldStickyActionCellClass =
+ 'sticky right-0 z-10 border-l border-[#252b34] bg-[#0b0c0e] px-3 py-3 text-center shadow-[-12px_0_18px_rgba(0,0,0,0.28)]';
+const coldNoticeStatusMessageClass = 'px-3 py-2 text-sm text-[#b9c6d8]';
+const coldNoticeTestStatusMessageClass =
+ 'rounded-[3px] border border-[#2f3a4a] bg-[#111722] px-3 py-2 text-[12px] font-semibold leading-5 text-[#c6d4e6]';
const ALERT_NOTICE_SETTLED_CACHE_TTL_MS = 10_000;
+const RECEIVER_TEST_SEND_TIMEOUT_MS = 15_000;
+const NOTICE_RECEIVER_DRAFT_FINGERPRINT_FIELDS: Array<keyof NoticeReceiverDraft> = [
+ 'id',
+ 'name',
+ 'type',
+ 'email',
+ 'phone',
+ 'hookUrl',
+ 'hookAuthType',
+ 'hookAuthToken',
+ 'wechatId',
+ 'accessToken',
+ 'tgBotToken',
+ 'tgUserId',
+ 'tgMessageThreadId',
+ 'larkReceiveType',
+ 'userId',
+ 'chatId',
+ 'slackWebHookUrl',
+ 'corpId',
+ 'agentId',
+ 'appSecret',
+ 'partyId',
+ 'tagId',
+ 'discordChannelId',
+ 'discordBotToken',
+ 'smnAk',
+ 'smnSk',
+ 'smnProjectId',
+ 'smnRegion',
+ 'smnTopicUrn',
+ 'serverChanToken',
+ 'gotifyToken',
+ 'appId'
+];
+const NOTICE_RULE_DRAFT_FINGERPRINT_FIELDS: Array<keyof NoticeRuleDraft> = [
+ 'id',
+ 'name',
+ 'receiverIdsText',
+ 'templateId',
+ 'enable',
+ 'filterAll',
+ 'labelsText',
+ 'daysText',
+ 'periodLimit',
+ 'periodStart',
+ 'periodEnd'
+];
+const NOTICE_TEMPLATE_DRAFT_FINGERPRINT_FIELDS: Array<keyof NoticeTemplateDraft> = [
+ 'id',
+ 'name',
+ 'type',
+ 'preset',
+ 'content'
+];
const EMPTY_ALERT_NOTICE_ROUTE_STATE: AlertNoticeRouteState = {
signal: null,
signalContext: {}
};
+function serializeNoticeReceiverDraft(draft: NoticeReceiverDraft) {
+ return JSON.stringify(
+ NOTICE_RECEIVER_DRAFT_FINGERPRINT_FIELDS.map(field => [field, draft[field] == null ? '' : String(draft[field]).trim()])
+ );
+}
+
+function serializeNoticeRuleDraft(draft: NoticeRuleDraft) {
+ return JSON.stringify(
+ NOTICE_RULE_DRAFT_FINGERPRINT_FIELDS.map(field => [field, draft[field] == null ? '' : String(draft[field]).trim()])
+ );
+}
+
+function serializeNoticeTemplateDraft(draft: NoticeTemplateDraft) {
+ return JSON.stringify(
+ NOTICE_TEMPLATE_DRAFT_FINGERPRINT_FIELDS.map(field => [field, draft[field] == null ? '' : String(draft[field]).trim()])
+ );
+}
+
+async function withNoticeReceiverTestTimeout<T>(task: Promise<T>, error: Error) {
+ let timeoutId: ReturnType<typeof setTimeout> | null = null;
+ try {
+ return await Promise.race([
+ task,
+ new Promise<T>((_, reject) => {
+ timeoutId = setTimeout(() => reject(error), RECEIVER_TEST_SEND_TIMEOUT_MS);
+ })
+ ]);
+ } finally {
+ if (timeoutId) clearTimeout(timeoutId);
+ }
+}
+
+function parseAlertNoticeRouteInteger(value: string | null, fallback: number, minimum = 0) {
+ if (!value) return fallback;
+ const parsed = Number.parseInt(value, 10);
+ return Number.isFinite(parsed) && parsed >= minimum ? parsed : fallback;
+}
+
+function normalizeAlertNoticeTab(value: string | null, fallback: AlertNoticeRouteTab): AlertNoticeRouteTab {
+ return value === 'receiver' || value === 'rule' || value === 'template' ? value : fallback;
+}
+
+function writeAlertNoticeRouteParam(params: URLSearchParams, key: string, value: string) {
+ const cleanValue = value.trim();
+ if (cleanValue) {
+ params.set(key, cleanValue);
+ } else {
+ params.delete(key);
+ }
+}
+
+function writeAlertNoticeRouteIntegerParam(
+ params: URLSearchParams,
+ currentParams: URLSearchParams,
+ key: string,
+ value: number,
+ defaultValue: number
+) {
+ const nextValue = String(value);
+ if (value !== defaultValue || currentParams.get(key) === nextValue) {
+ params.set(key, nextValue);
+ } else {
+ params.delete(key);
+ }
+}
+
+export function buildAlertNoticeListRouteUrl(
+ routeSearchParamString: string,
+ defaultSelectedTab: AlertNoticeRouteTab,
+ nextState: AlertNoticeListRouteState
+) {
+ const currentParams = new URLSearchParams(routeSearchParamString);
+ const nextParams = new URLSearchParams(routeSearchParamString);
+ if (nextState.selectedTab !== defaultSelectedTab || currentParams.get('tab') === nextState.selectedTab) {
+ nextParams.set('tab', nextState.selectedTab);
+ } else {
+ nextParams.delete('tab');
+ }
+ writeAlertNoticeRouteParam(nextParams, 'receiverSearch', nextState.receiverSearch);
+ writeAlertNoticeRouteParam(nextParams, 'ruleSearch', nextState.ruleSearch);
+ writeAlertNoticeRouteParam(nextParams, 'templateSearch', nextState.templateSearch);
+
+ writeAlertNoticeRouteIntegerParam(nextParams, currentParams, 'receiverPageIndex', nextState.receiverPageIndex, 0);
+ writeAlertNoticeRouteIntegerParam(nextParams, currentParams, 'receiverPageSize', nextState.receiverPageSize, 8);
+ writeAlertNoticeRouteIntegerParam(nextParams, currentParams, 'rulePageIndex', nextState.rulePageIndex, 0);
+ writeAlertNoticeRouteIntegerParam(nextParams, currentParams, 'rulePageSize', nextState.rulePageSize, 8);
+ writeAlertNoticeRouteIntegerParam(nextParams, currentParams, 'templatePageIndex', nextState.templatePageIndex, 0);
+ writeAlertNoticeRouteIntegerParam(nextParams, currentParams, 'templatePageSize', nextState.templatePageSize, 8);
+ if (!nextState.templatePresetFilter || currentParams.get('templatePreset') === 'true') nextParams.set('templatePreset', String(nextState.templatePresetFilter));
+ else nextParams.delete('templatePreset');
+
+ const nextParamString = nextParams.toString();
+ return nextParamString ? `/alert/notice?${nextParamString}` : '/alert/notice';
+}
+
+function alertNoticeActionHelp(t: Translator, id: string): AlertNoticeActionHelpCopy {
+ const impactKey = `alert.notice.action.${id}.impact`;
+ const impact = t(impactKey);
+ return {
+ label: t('alert.notice.action.help-aria', { action: t(`alert.notice.action.${id}.label`) }),
+ body: t(`alert.notice.action.${id}.help`),
+ impact: impact === impactKey ? undefined : impact
+ };
+}
+
+function AlertNoticeActionHelp({
+ id,
+ label,
+ body,
+ impact
+}: AlertNoticeActionHelpCopy & {
+ id: string;
+}) {
+ return (
+ <span
+ data-alert-notice-action-help={id}
+ className="group/help relative inline-flex h-5 w-5 shrink-0 items-center justify-center"
+ >
+ <button
+ type="button"
+ aria-label={label}
+ data-alert-notice-action-help-trigger="hertzbeat-ui-action-help"
+ data-alert-notice-action-help-style="icon-after-action"
+ data-alert-notice-action-help-visual="circle-help-icon"
+ className="inline-flex h-5 w-5 items-center justify-center rounded-none border-0 bg-transparent p-0 text-[#d8e4ff] transition hover:text-[#f5f7fb] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#4e74f8]"
+ onClick={event => {
+ event.preventDefault();
+ event.stopPropagation();
+ }}
+ >
+ <CircleHelp size={13} strokeWidth={2} aria-hidden="true" data-alert-notice-action-help-icon="lucide-circle-help" />
+ </button>
+ <span
+ role="tooltip"
+ data-alert-notice-action-help-tooltip={id}
+ className="pointer-events-none absolute left-0 top-6 z-30 hidden w-[300px] rounded-[3px] border border-[#2b3039] bg-[#101217] p-3 text-left shadow-[0_16px_40px_rgba(0,0,0,0.42)] group-hover/help:block group-focus-within/help:block"
+ >
+ <span className="block text-[11px] leading-4 text-[#dbe4f0]">{body}</span>
+ {impact ? <span className="mt-2 block border-t border-[#2b3039] pt-2 text-[11px] leading-4 text-[#98a2b3]">{impact}</span> : null}
+ </span>
+ </span>
+ );
+}
+
function isApiMessageBusinessError(error: unknown) {
return typeof error === 'object' && error !== null && typeof (error as { code?: unknown }).code === 'number';
}
@@ -248,19 +492,24 @@ function NoticePagination({
function NoticeTableEmptyRow({
t,
colSpan,
- prefix
+ prefix,
+ action
}: {
t: Translator;
colSpan: number;
prefix: 'receiver' | 'rule' | 'template';
+ action?: {
+ label: string;
+ onClick: () => void;
+ };
}) {
return (
<tr data-alert-notice-empty-row="true" data-alert-notice-receiver-empty-state={prefix === 'receiver' ? 'hertzbeat-ui-empty-state' : undefined} className="bg-[#0b0c0e]">
<td
colSpan={colSpan}
- className="h-[360px] px-3 pt-[54px] text-center align-top text-[#8f99ab]"
+ className="h-[360px] px-3 pt-[54px] text-left align-top text-[#8f99ab]"
>
- <div className="inline-flex flex-col items-center gap-2.5">
+ <div className="flex w-[min(560px,70vw)] min-w-[220px] flex-col items-center gap-2.5">
<span
data-alert-notice-receiver-empty-icon={prefix === 'receiver' ? 'hertzbeat-ui-empty-icon' : undefined}
className="inline-flex h-10 w-12 items-center justify-center rounded-[4px] border border-[#2b3039] bg-[#101217] text-[#7e8494]"
@@ -268,6 +517,21 @@ function NoticeTableEmptyRow({
<Inbox className="h-7 w-7" aria-hidden="true" />
</span>
<div className="text-[14px]">{t('common.no-data')}</div>
+ {action ? (
+ <Button
+ type="button"
+ size="sm"
+ variant="default"
+ data-alert-notice-receiver-empty-action={prefix === 'receiver' ? 'new' : undefined}
+ data-alert-notice-rule-empty-action={prefix === 'rule' ? 'new' : undefined}
+ data-alert-notice-template-empty-action={prefix === 'template' ? 'new' : undefined}
+ className={coldPrimaryCommandButtonClass}
+ onClick={action.onClick}
+ >
+ <Plus className="h-4 w-4" aria-hidden="true" />
+ {action.label}
+ </Button>
+ ) : null}
</div>
</td>
</tr>
@@ -279,12 +543,14 @@ function NoticeTableSwitch({
field,
label,
pending,
+ commandAction,
onChange
}: {
checked: boolean;
field: 'filter-all' | 'enable';
label: string;
pending?: boolean;
+ commandAction?: string;
onChange: (checked: boolean) => void;
}) {
return (
@@ -296,6 +562,7 @@ function NoticeTableSwitch({
data-alert-notice-rule-table-switch={field}
data-alert-notice-rule-table-switch-update="angular-edit-notify"
data-alert-notice-rule-table-switch-update-owner="route-action-feedback-contract"
+ data-alert-notice-command-action={commandAction}
data-state={checked ? 'checked' : 'unchecked'}
disabled={pending}
onClick={() => onChange(!checked)}
@@ -311,6 +578,9 @@ function NoticeTableSwitch({
export default function AlertNoticePage({ initialRouteState }: { initialRouteState?: AlertNoticeRouteState } = {}) {
const { t, locale } = useI18n();
+ const router = useRouter();
+ const searchParams = useSearchParams();
+ const routeSearchParamString = searchParams.toString();
const alertNoticeRouteState = initialRouteState ?? EMPTY_ALERT_NOTICE_ROUTE_STATE;
const { signal, signalContext } = alertNoticeRouteState;
const noticeEvidenceContext = useMemo(
@@ -319,37 +589,62 @@ export default function AlertNoticePage({ initialRouteState }: { initialRouteSta
);
const [editingReceiver, setEditingReceiver] = useState(false);
const [receiverDraft, setReceiverDraft] = useState<NoticeReceiverDraft>(() => buildNoticeReceiverDraft(null));
+ const [receiverInitialFingerprint, setReceiverInitialFingerprint] = useState(() => serializeNoticeReceiverDraft(buildNoticeReceiverDraft(null)));
+ const [receiverDiscardDialogOpen, setReceiverDiscardDialogOpen] = useState(false);
const [selectedReceiverId, setSelectedReceiverId] = useState<number | null>(null);
- const [receiverSearchDraft, setReceiverSearchDraft] = useState('');
- const [receiverSearch, setReceiverSearch] = useState('');
- const [receiverPageIndex, setReceiverPageIndex] = useState(0);
- const [receiverPageSize, setReceiverPageSize] = useState(8);
+ const defaultSelectedTab: AlertNoticeRouteTab = noticeEvidenceContext ? 'rule' : 'receiver';
+ const routeListState = useMemo<AlertNoticeListRouteState>(() => {
+ const routeParams = new URLSearchParams(routeSearchParamString);
+ return {
+ selectedTab: normalizeAlertNoticeTab(routeParams.get('tab'), defaultSelectedTab),
+ receiverSearch: routeParams.get('receiverSearch') ?? '',
+ receiverPageIndex: parseAlertNoticeRouteInteger(routeParams.get('receiverPageIndex'), 0),
+ receiverPageSize: parseAlertNoticeRouteInteger(routeParams.get('receiverPageSize'), 8, 1),
+ ruleSearch: routeParams.get('ruleSearch') ?? '',
+ rulePageIndex: parseAlertNoticeRouteInteger(routeParams.get('rulePageIndex'), 0),
+ rulePageSize: parseAlertNoticeRouteInteger(routeParams.get('rulePageSize'), 8, 1),
+ templateSearch: routeParams.get('templateSearch') ?? '',
+ templatePresetFilter: routeParams.get('templatePreset') === 'false' ? false : true,
+ templatePageIndex: parseAlertNoticeRouteInteger(routeParams.get('templatePageIndex'), 0),
+ templatePageSize: parseAlertNoticeRouteInteger(routeParams.get('templatePageSize'), 8, 1)
+ };
+ }, [defaultSelectedTab, routeSearchParamString]);
+ const [receiverSearchDraft, setReceiverSearchDraft] = useState(routeListState.receiverSearch);
+ const [receiverSearch, setReceiverSearch] = useState(routeListState.receiverSearch);
+ const [receiverPageIndex, setReceiverPageIndex] = useState(routeListState.receiverPageIndex);
+ const [receiverPageSize, setReceiverPageSize] = useState(routeListState.receiverPageSize);
const [savingReceiver, setSavingReceiver] = useState(false);
const [testingReceiver, setTestingReceiver] = useState(false);
const [receiverMessage, setReceiverMessage] = useState<string | null>(null);
const [receiverError, setReceiverError] = useState<string | null>(null);
const [receiverErrorDetail, setReceiverErrorDetail] = useState<string | null>(null);
+ const [receiverValidationIssues, setReceiverValidationIssues] = useState<NoticeReceiverValidationIssue[]>([]);
const [editingTemplate, setEditingTemplate] = useState(false);
const [templateDraft, setTemplateDraft] = useState<NoticeTemplateDraft>(() => buildNoticeTemplateDraft(null));
+ const [templateInitialFingerprint, setTemplateInitialFingerprint] = useState(() => serializeNoticeTemplateDraft(buildNoticeTemplateDraft(null)));
+ const [templateDiscardDialogOpen, setTemplateDiscardDialogOpen] = useState(false);
const [templateReadOnly, setTemplateReadOnly] = useState(false);
const [selectedTemplateId, setSelectedTemplateId] = useState<number | null>(null);
const [savingTemplate, setSavingTemplate] = useState(false);
const [templateMessage, setTemplateMessage] = useState<string | null>(null);
const [templateError, setTemplateError] = useState<string | null>(null);
const [templateErrorDetail, setTemplateErrorDetail] = useState<string | null>(null);
- const [templateSearchDraft, setTemplateSearchDraft] = useState('');
- const [templateSearch, setTemplateSearch] = useState('');
- const [templatePresetFilter, setTemplatePresetFilter] = useState(true);
- const [templatePageIndex, setTemplatePageIndex] = useState(0);
- const [templatePageSize, setTemplatePageSize] = useState(8);
- const [selectedTab, setSelectedTab] = useState<AlertNoticeConsoleTabKey>(() => noticeEvidenceContext ? 'rule' : 'receiver');
+ const [templateValidationIssues, setTemplateValidationIssues] = useState<NoticeTemplateValidationIssue[]>([]);
+ const [templateSearchDraft, setTemplateSearchDraft] = useState(routeListState.templateSearch);
+ const [templateSearch, setTemplateSearch] = useState(routeListState.templateSearch);
+ const [templatePresetFilter, setTemplatePresetFilter] = useState(routeListState.templatePresetFilter);
+ const [templatePageIndex, setTemplatePageIndex] = useState(routeListState.templatePageIndex);
+ const [templatePageSize, setTemplatePageSize] = useState(routeListState.templatePageSize);
+ const [selectedTab, setSelectedTab] = useState<AlertNoticeConsoleTabKey>(() => routeListState.selectedTab);
const [editingRule, setEditingRule] = useState(false);
const [ruleDraft, setRuleDraft] = useState<NoticeRuleDraft>(() => buildNoticeRuleDraft(null));
+ const [ruleInitialFingerprint, setRuleInitialFingerprint] = useState(() => serializeNoticeRuleDraft(buildNoticeRuleDraft(null)));
+ const [ruleDiscardDialogOpen, setRuleDiscardDialogOpen] = useState(false);
const [selectedRuleId, setSelectedRuleId] = useState<number | null>(null);
- const [ruleSearchDraft, setRuleSearchDraft] = useState('');
- const [ruleSearch, setRuleSearch] = useState('');
- const [rulePageIndex, setRulePageIndex] = useState(0);
- const [rulePageSize, setRulePageSize] = useState(8);
+ const [ruleSearchDraft, setRuleSearchDraft] = useState(routeListState.ruleSearch);
+ const [ruleSearch, setRuleSearch] = useState(routeListState.ruleSearch);
+ const [rulePageIndex, setRulePageIndex] = useState(routeListState.rulePageIndex);
+ const [rulePageSize, setRulePageSize] = useState(routeListState.rulePageSize);
const [savingRule, setSavingRule] = useState(false);
const [ruleMessage, setRuleMessage] = useState<string | null>(null);
const [ruleError, setRuleError] = useState<string | null>(null);
@@ -364,6 +659,14 @@ export default function AlertNoticePage({ initialRouteState }: { initialRouteSta
() => buildNoticeTemplateListUrl({ search: templateSearch, preset: templatePresetFilter, pageIndex: templatePageIndex, pageSize: templatePageSize }),
[templatePageIndex, templatePageSize, templatePresetFilter, templateSearch]
);
+ const receiverDraftFingerprint = useMemo(() => serializeNoticeReceiverDraft(receiverDraft), [receiverDraft]);
+ const shouldConfirmReceiverDiscard = Boolean(editingReceiver && receiverDraftFingerprint !== receiverInitialFingerprint && !savingReceiver);
+ const ruleDraftFingerprint = useMemo(() => serializeNoticeRuleDraft(ruleDraft), [ruleDraft]);
+ const shouldConfirmRuleDiscard = Boolean(editingRule && ruleDraftFingerprint !== ruleInitialFingerprint && !savingRule);
+ const templateDraftFingerprint = useMemo(() => serializeNoticeTemplateDraft(templateDraft), [templateDraft]);
+ const shouldConfirmTemplateDiscard = Boolean(
+ editingTemplate && !templateReadOnly && templateDraftFingerprint !== templateInitialFingerprint && !savingTemplate
+ );
const alertNoticeLoadQuery = useMemo(
() => ({
receivers: {
@@ -389,6 +692,44 @@ export default function AlertNoticePage({ initialRouteState }: { initialRouteSta
() => ['alert-notice', alertNoticeReceiverListUrl, alertNoticeRuleListUrl, alertNoticeTemplateListUrl, refreshTick].join('|'),
[alertNoticeReceiverListUrl, alertNoticeRuleListUrl, alertNoticeTemplateListUrl, refreshTick]
);
+ const currentListRouteState = useMemo<AlertNoticeListRouteState>(() => ({
+ selectedTab: selectedTab as AlertNoticeRouteTab,
+ receiverSearch,
+ receiverPageIndex,
+ receiverPageSize,
+ ruleSearch,
+ rulePageIndex,
+ rulePageSize,
+ templateSearch,
+ templatePresetFilter,
+ templatePageIndex,
+ templatePageSize
+ }), [receiverPageIndex, receiverPageSize, receiverSearch, rulePageIndex, rulePageSize, ruleSearch, selectedTab, templatePageIndex, templatePageSize, templatePresetFilter, templateSearch]);
+
+ useEffect(() => {
+ setSelectedTab(routeListState.selectedTab);
+ setReceiverSearchDraft(routeListState.receiverSearch);
+ setReceiverSearch(routeListState.receiverSearch);
+ setReceiverPageIndex(routeListState.receiverPageIndex);
+ setReceiverPageSize(routeListState.receiverPageSize);
+ setRuleSearchDraft(routeListState.ruleSearch);
+ setRuleSearch(routeListState.ruleSearch);
+ setRulePageIndex(routeListState.rulePageIndex);
+ setRulePageSize(routeListState.rulePageSize);
+ setTemplateSearchDraft(routeListState.templateSearch);
+ setTemplateSearch(routeListState.templateSearch);
+ setTemplatePresetFilter(routeListState.templatePresetFilter);
+ setTemplatePageIndex(routeListState.templatePageIndex);
+ setTemplatePageSize(routeListState.templatePageSize);
+ }, [routeListState]);
+
+ const replaceListRouteState = useCallback((nextState: AlertNoticeListRouteState) => {
+ const nextUrl = buildAlertNoticeListRouteUrl(routeSearchParamString, defaultSelectedTab, nextState);
+ const currentUrl = routeSearchParamString ? `/alert/notice?${routeSearchParamString}` : '/alert/notice';
+ if (nextUrl !== currentUrl) {
+ router.replace(nextUrl, { scroll: false });
+ }
+ }, [defaultSelectedTab, routeSearchParamString, router]);
const load = useCallback(async (): Promise<NoticePageData> => {
void refreshTick;
@@ -451,12 +792,66 @@ export default function AlertNoticePage({ initialRouteState }: { initialRouteSta
const ruleTotal = data.rules.totalElements ?? data.rules.content.length;
const templateTotal = data.templates.totalElements ?? data.templates.content.length;
+ function closeReceiverEditor() {
+ setEditingReceiver(false);
+ setReceiverDiscardDialogOpen(false);
+ setReceiverMessage(null);
+ setReceiverError(null);
+ setReceiverErrorDetail(null);
+ setReceiverValidationIssues([]);
+ }
+
+ function requestCloseReceiverEditor() {
+ if (shouldConfirmReceiverDiscard) {
+ setReceiverDiscardDialogOpen(true);
+ return;
+ }
+ closeReceiverEditor();
+ }
+
+ function closeTemplateEditor() {
+ setEditingTemplate(false);
+ setTemplateReadOnly(false);
+ setTemplateDiscardDialogOpen(false);
+ setTemplateMessage(null);
+ setTemplateError(null);
+ setTemplateErrorDetail(null);
+ setTemplateValidationIssues([]);
+ }
+
+ function requestCloseTemplateEditor() {
+ if (shouldConfirmTemplateDiscard) {
+ setTemplateDiscardDialogOpen(true);
+ return;
+ }
+ closeTemplateEditor();
+ }
+
+ function closeRuleEditor() {
+ setEditingRule(false);
+ setRuleDiscardDialogOpen(false);
+ setRuleMessage(null);
+ setRuleError(null);
+ setRuleErrorDetail(null);
+ }
+
+ function requestCloseRuleEditor() {
+ if (shouldConfirmRuleDiscard) {
+ setRuleDiscardDialogOpen(true);
+ return;
+ }
+ closeRuleEditor();
+ }
+
async function handleNewReceiver() {
setSelectedTab('receiver');
- setReceiverDraft(buildNoticeReceiverDraft(null));
+ const nextDraft = buildNoticeReceiverDraft(null);
+ setReceiverDraft(nextDraft);
+ setReceiverInitialFingerprint(serializeNoticeReceiverDraft(nextDraft));
setReceiverMessage(null);
setReceiverError(null);
setReceiverErrorDetail(null);
+ setReceiverValidationIssues([]);
setEditingReceiver(true);
}
@@ -466,10 +861,13 @@ export default function AlertNoticePage({ initialRouteState }: { initialRouteSta
setSelectedTab('receiver');
setSelectedReceiverId(receiver.id);
const detail = await api.alertNotice.receivers.detail(receiver.id);
- setReceiverDraft(buildNoticeReceiverDraft(detail));
+ const nextDraft = buildNoticeReceiverDraft(detail);
+ setReceiverDraft(nextDraft);
+ setReceiverInitialFingerprint(serializeNoticeReceiverDraft(nextDraft));
setReceiverMessage(null);
setReceiverError(null);
setReceiverErrorDetail(null);
+ setReceiverValidationIssues([]);
setEditingReceiver(true);
} catch (error) {
setReceiverError(error instanceof Error ? error.message : t('common.notify.edit-fail'));
@@ -477,15 +875,31 @@ export default function AlertNoticePage({ initialRouteState }: { initialRouteSta
}
}
+ function focusReceiverValidationField(field: NoticeReceiverValidationIssue['field']) {
+ window.setTimeout(() => {
+ document.querySelector<HTMLElement>(`[data-testid="notice-receiver-field-${String(field)}"]`)?.focus();
+ }, 0);
+ }
+
+ function handleReceiverDraftChange(nextDraft: React.SetStateAction<NoticeReceiverDraft>) {
+ setReceiverDraft(nextDraft);
+ setReceiverValidationIssues([]);
+ setReceiverError(null);
+ setReceiverErrorDetail(null);
+ }
+
async function handleSaveReceiver() {
const isEdit = Boolean(receiverDraft.id);
- const validationError = validateNoticeReceiverDraft(receiverDraft, t);
- if (validationError) {
+ const validationIssues = buildNoticeReceiverValidationIssues(receiverDraft, t);
+ if (validationIssues.length > 0) {
+ setReceiverValidationIssues(validationIssues);
setReceiverMessage(null);
- setReceiverError(validationError);
+ setReceiverError(validationIssues.map(issue => issue.message).join(', '));
setReceiverErrorDetail(null);
+ focusReceiverValidationField(validationIssues[0].field);
return;
}
+ setReceiverValidationIssues([]);
setSavingReceiver(true);
setReceiverMessage(null);
setReceiverError(null);
@@ -496,6 +910,7 @@ export default function AlertNoticePage({ initialRouteState }: { initialRouteSta
} else {
await api.alertNotice.receivers.create(receiverDraft);
}
+ setReceiverInitialFingerprint(serializeNoticeReceiverDraft(receiverDraft));
setReceiverMessage([t(isEdit ? 'common.notify.edit-success' : 'common.notify.new-success'), t('alert.notice.receiver.next')].join(' '));
setEditingReceiver(false);
setRefreshTick(value => value + 1);
@@ -512,11 +927,11 @@ export default function AlertNoticePage({ initialRouteState }: { initialRouteSta
async function handleDeleteReceiver() {
if (!selectedReceiver?.id) return;
- await handleDeleteReceiverById(selectedReceiver.id);
+ await handleDeleteReceiverById(selectedReceiver.id, selectedReceiver.name);
}
- async function handleDeleteReceiverById(receiverId: number) {
- setDeleteRequest({ kind: 'receiver', id: receiverId });
+ async function handleDeleteReceiverById(receiverId: number, receiverName?: string | null) {
+ setDeleteRequest({ kind: 'receiver', id: receiverId, name: receiverName?.trim() || undefined });
}
async function handleConfirmedDelete() {
@@ -575,14 +990,17 @@ export default function AlertNoticePage({ initialRouteState }: { initialRouteSta
setReceiverError(null);
setReceiverErrorDetail(null);
try {
- await api.alertNotice.receivers.sendTest(receiverDraft);
+ await withNoticeReceiverTestTimeout(
+ api.alertNotice.receivers.sendTest(receiverDraft),
+ new Error(t('alert.notice.send-test.timeout.detail'))
+ );
setReceiverMessage(t('alert.notice.send-test.notify.success'));
setReceiverError(null);
setReceiverErrorDetail(null);
} catch (error) {
setReceiverMessage(null);
- setReceiverError(error instanceof Error ? error.message : t('alert.notice.send-test.notify.failed'));
- setReceiverErrorDetail(null);
+ setReceiverError(t('alert.notice.send-test.notify.failed'));
+ setReceiverErrorDetail(error instanceof Error ? error.message : null);
} finally {
setTestingReceiver(false);
}
@@ -590,11 +1008,14 @@ export default function AlertNoticePage({ initialRouteState }: { initialRouteSta
async function handleNewTemplate() {
setSelectedTab('template');
- setTemplateDraft(buildNoticeTemplateDraft(null));
+ const nextDraft = buildNoticeTemplateDraft(null);
+ setTemplateDraft(nextDraft);
+ setTemplateInitialFingerprint(serializeNoticeTemplateDraft(nextDraft));
setTemplateReadOnly(false);
setTemplateMessage(null);
setTemplateError(null);
setTemplateErrorDetail(null);
+ setTemplateValidationIssues([]);
setEditingTemplate(true);
}
@@ -605,10 +1026,13 @@ export default function AlertNoticePage({ initialRouteState }: { initialRouteSta
setSelectedTemplateId(template.id);
setTemplateReadOnly(false);
const detail = await api.alertNotice.templates.detail(template.id);
- setTemplateDraft(buildNoticeTemplateDraft(detail));
+ const nextDraft = buildNoticeTemplateDraft(detail);
+ setTemplateDraft(nextDraft);
+ setTemplateInitialFingerprint(serializeNoticeTemplateDraft(nextDraft));
setTemplateMessage(null);
setTemplateError(null);
setTemplateErrorDetail(null);
+ setTemplateValidationIssues([]);
setEditingTemplate(true);
} catch (error) {
setTemplateError(error instanceof Error ? error.message : t('common.notify.edit-fail'));
@@ -623,31 +1047,51 @@ export default function AlertNoticePage({ initialRouteState }: { initialRouteSta
setSelectedTemplateId(templateId);
setTemplateReadOnly(true);
setTemplateDraft(rowDraft);
+ setTemplateInitialFingerprint(serializeNoticeTemplateDraft(rowDraft));
setTemplateMessage(null);
setTemplateError(null);
setTemplateErrorDetail(null);
+ setTemplateValidationIssues([]);
setEditingTemplate(true);
if (templateId == null) return;
try {
const detail = await api.alertNotice.templates.detail(templateId);
- setTemplateDraft(buildNoticeTemplateDraft(detail));
+ const nextDraft = buildNoticeTemplateDraft(detail);
+ setTemplateDraft(nextDraft);
+ setTemplateInitialFingerprint(serializeNoticeTemplateDraft(nextDraft));
} catch (error) {
setTemplateError(null);
setTemplateErrorDetail(null);
}
}
+ function focusTemplateValidationField(field: NoticeTemplateValidationIssue['field']) {
+ window.setTimeout(() => {
+ document.querySelector<HTMLElement>(`[data-testid="notice-template-field-${String(field)}"]`)?.focus();
+ }, 0);
+ }
+
+ function handleTemplateDraftChange(nextDraft: React.SetStateAction<NoticeTemplateDraft>) {
+ setTemplateDraft(nextDraft);
+ setTemplateValidationIssues([]);
+ setTemplateError(null);
+ setTemplateErrorDetail(null);
+ }
+
async function handleSaveTemplate() {
const isEdit = Boolean(templateDraft.id);
- const validationError = validateNoticeTemplateDraft(templateDraft, t);
- if (validationError) {
+ const validationIssues = buildNoticeTemplateValidationIssues(templateDraft, t);
+ if (validationIssues.length > 0) {
+ setTemplateValidationIssues(validationIssues);
setTemplateMessage(null);
- setTemplateError(validationError);
+ setTemplateError(validationIssues.map(issue => issue.message).join(', '));
setTemplateErrorDetail(null);
+ focusTemplateValidationField(validationIssues[0].field);
return;
}
+ setTemplateValidationIssues([]);
setSavingTemplate(true);
setTemplateMessage(null);
setTemplateError(null);
@@ -658,8 +1102,23 @@ export default function AlertNoticePage({ initialRouteState }: { initialRouteSta
} else {
await api.alertNotice.templates.create(templateDraft);
}
+ setTemplateInitialFingerprint(serializeNoticeTemplateDraft(templateDraft));
setTemplateMessage(t(isEdit ? 'common.notify.edit-success' : 'common.notify.new-success'));
setEditingTemplate(false);
+ if (!isEdit) {
+ const nextTemplateSearch = templateDraft.name.trim();
+ setTemplateSearchDraft(nextTemplateSearch);
+ setTemplateSearch(nextTemplateSearch);
+ setTemplatePresetFilter(false);
+ setTemplatePageIndex(0);
+ replaceListRouteState({
+ ...currentListRouteState,
+ selectedTab: 'template',
+ templateSearch: nextTemplateSearch,
+ templatePresetFilter: false,
+ templatePageIndex: 0
+ });
+ }
setRefreshTick(value => value + 1);
} catch (error) {
setTemplateError(t(isEdit ? 'common.notify.edit-fail' : 'common.notify.new-fail'));
@@ -671,27 +1130,40 @@ export default function AlertNoticePage({ initialRouteState }: { initialRouteSta
async function handleDeleteTemplate() {
if (!selectedTemplate?.id || selectedTemplate.preset) return;
- await handleDeleteTemplateById(selectedTemplate.id);
+ await handleDeleteTemplateById(selectedTemplate.id, selectedTemplate.name);
}
- async function handleDeleteTemplateById(templateId: number) {
- setDeleteRequest({ kind: 'template', id: templateId });
+ async function handleDeleteTemplateById(templateId: number, templateName?: string | null) {
+ setDeleteRequest({ kind: 'template', id: templateId, name: templateName?.trim() || undefined });
}
function commitTemplateSearch() {
- setTemplateSearch(templateSearchDraft);
+ const nextSearch = templateSearchDraft.trim();
+ setTemplateSearch(nextSearch);
setTemplatePageIndex(0);
+ replaceListRouteState({
+ ...currentListRouteState,
+ templateSearch: nextSearch,
+ templatePageIndex: 0
+ });
}
function resetTemplateSearch() {
setTemplateSearchDraft('');
setTemplateSearch('');
setTemplatePageIndex(0);
+ replaceListRouteState({
+ ...currentListRouteState,
+ templateSearch: '',
+ templatePageIndex: 0
+ });
}
async function handleNewRule() {
setSelectedTab('rule');
- setRuleDraft(buildNoticeRuleDraft(null, noticeEvidenceContext?.ruleDraftPatch));
+ const nextDraft = buildNoticeRuleDraft(null, noticeEvidenceContext?.ruleDraftPatch);
+ setRuleDraft(nextDraft);
+ setRuleInitialFingerprint(serializeNoticeRuleDraft(nextDraft));
setRuleMessage(null);
setRuleError(null);
setRuleErrorDetail(null);
@@ -705,7 +1177,9 @@ export default function AlertNoticePage({ initialRouteState }: { initialRouteSta
setSelectedTab('rule');
setSelectedRuleId(ruleId);
const detail = await api.alertNotice.rules.detail(ruleId);
- setRuleDraft(buildNoticeRuleDraft(detail));
+ const nextDraft = buildNoticeRuleDraft(detail);
+ setRuleDraft(nextDraft);
+ setRuleInitialFingerprint(serializeNoticeRuleDraft(nextDraft));
setRuleMessage(null);
setRuleError(null);
setRuleErrorDetail(null);
@@ -716,6 +1190,13 @@ export default function AlertNoticePage({ initialRouteState }: { initialRouteSta
}
}
+ function handleRuleDraftChange(nextDraft: React.SetStateAction<NoticeRuleDraft>) {
+ setRuleDraft(nextDraft);
+ setRuleMessage(null);
+ setRuleError(null);
+ setRuleErrorDetail(null);
+ }
+
async function handleSaveRule() {
const isEdit = Boolean(ruleDraft.id);
const validationError = validateNoticeRuleDraft(ruleDraft, t);
@@ -736,6 +1217,7 @@ export default function AlertNoticePage({ initialRouteState }: { initialRouteSta
} else {
await api.alertNotice.rules.create(ruleDraft, displayNames);
}
+ setRuleInitialFingerprint(serializeNoticeRuleDraft(ruleDraft));
setRuleMessage(t(isEdit ? 'common.notify.edit-success' : 'common.notify.new-success'));
setEditingRule(false);
setRefreshTick(value => value + 1);
@@ -749,11 +1231,11 @@ export default function AlertNoticePage({ initialRouteState }: { initialRouteSta
async function handleDeleteRule() {
if (!selectedRule?.id) return;
- await handleDeleteRuleById(selectedRule.id);
+ await handleDeleteRuleById(selectedRule.id, selectedRule.name);
}
- async function handleDeleteRuleById(ruleId: number) {
- setDeleteRequest({ kind: 'rule', id: ruleId });
+ async function handleDeleteRuleById(ruleId: number, ruleName?: string | null) {
+ setDeleteRequest({ kind: 'rule', id: ruleId, name: ruleName?.trim() || undefined });
}
async function handleToggleRuleSwitch(rule: NoticeRule, field: 'filterAll' | 'enable', checked: boolean) {
@@ -782,25 +1264,47 @@ export default function AlertNoticePage({ initialRouteState }: { initialRouteSta
}
function commitReceiverSearch() {
- setReceiverSearch(receiverSearchDraft.trim());
+ const nextSearch = receiverSearchDraft.trim();
+ setReceiverSearch(nextSearch);
setReceiverPageIndex(0);
+ replaceListRouteState({
+ ...currentListRouteState,
+ receiverSearch: nextSearch,
+ receiverPageIndex: 0
+ });
}
function resetReceiverSearch() {
setReceiverSearchDraft('');
setReceiverSearch('');
setReceiverPageIndex(0);
+ replaceListRouteState({
+ ...currentListRouteState,
+ receiverSearch: '',
+ receiverPageIndex: 0
+ });
}
function commitRuleSearch() {
- setRuleSearch(ruleSearchDraft.trim());
+ const nextSearch = ruleSearchDraft.trim();
+ setRuleSearch(nextSearch);
setRulePageIndex(0);
+ replaceListRouteState({
+ ...currentListRouteState,
+ ruleSearch: nextSearch,
+ rulePageIndex: 0
+ });
}
function resetRuleSearch() {
setRuleSearchDraft('');
setRuleSearch('');
setRulePageIndex(0);
+ replaceListRouteState({
+ ...currentListRouteState,
+ ruleSearch: '',
+ rulePageIndex: 0
+ });
}
const receiverPanel = (
@@ -811,35 +1315,46 @@ export default function AlertNoticePage({ initialRouteState }: { initialRouteSta
className={coldToolbarClass}
>
<div className="flex flex-wrap items-center gap-2">
- <Button
- data-alert-notice-receiver-sync="angular-load-table"
- data-alert-notice-receiver-sync-owner="route-refresh-contract"
- className={coldIconButtonClass}
- size="icon"
- variant="default"
- onClick={() => setRefreshTick(value => value + 1)}
- title={t('common.refresh')}
- >
- <RefreshCw className="h-4 w-4" aria-hidden="true" />
- <span className="sr-only">{t('common.refresh')}</span>
- </Button>
- <Button data-testid="notice-receiver-new" className={coldPrimaryCommandButtonClass} size="sm" variant="default" onClick={() => void handleNewReceiver()}>
- <Plus className="h-4 w-4" aria-hidden="true" />
- {t('alert.notice.receiver.new')}
- </Button>
- <Button
- data-alert-notice-receiver-bulk-menu="hertzbeat-ui-more"
- data-testid="notice-receiver-delete"
- className={coldIconButtonClass}
- size="icon"
- variant="default"
- onClick={() => void handleDeleteReceiver()}
- disabled={!selectedReceiver}
- title={t('common.button.delete')}
- >
- <MoreHorizontal className="h-4 w-4" aria-hidden="true" />
- <span className="sr-only">{t('common.button.delete')}</span>
- </Button>
+ <span className="inline-flex items-center gap-1">
+ <Button
+ data-alert-notice-receiver-sync="angular-load-table"
+ data-alert-notice-receiver-sync-owner="route-refresh-contract"
+ data-alert-notice-command-action="receiver-refresh"
+ className={coldIconButtonClass}
+ size="icon"
+ variant="default"
+ onClick={() => setRefreshTick(value => value + 1)}
+ title={t('common.refresh')}
+ >
+ <RefreshCw className="h-4 w-4" aria-hidden="true" />
+ <span className="sr-only">{t('common.refresh')}</span>
+ </Button>
+ <AlertNoticeActionHelp id="receiver-refresh" {...alertNoticeActionHelp(t, 'receiver-refresh')} />
+ </span>
+ <span className="inline-flex items-center gap-1">
+ <Button data-testid="notice-receiver-new" data-alert-notice-command-action="receiver-new" className={coldPrimaryCommandButtonClass} size="sm" variant="default" onClick={() => void handleNewReceiver()}>
+ <Plus className="h-4 w-4" aria-hidden="true" />
+ {t('alert.notice.receiver.new')}
+ </Button>
+ <AlertNoticeActionHelp id="receiver-new" {...alertNoticeActionHelp(t, 'receiver-new')} />
+ </span>
+ <span className="inline-flex items-center gap-1">
+ <Button
+ data-alert-notice-receiver-bulk-menu="hertzbeat-ui-more"
+ data-alert-notice-command-action="receiver-delete"
+ data-testid="notice-receiver-delete"
+ className={coldIconButtonClass}
+ size="icon"
+ variant="default"
+ onClick={() => void handleDeleteReceiver()}
+ disabled={!selectedReceiver}
+ title={t('common.button.delete')}
+ >
+ <MoreHorizontal className="h-4 w-4" aria-hidden="true" />
+ <span className="sr-only">{t('common.button.delete')}</span>
+ </Button>
+ <AlertNoticeActionHelp id="receiver-delete" {...alertNoticeActionHelp(t, 'receiver-delete')} />
+ </span>
</div>
<SearchRow
data-alert-notice-receiver-search="shared-compact"
@@ -856,14 +1371,14 @@ export default function AlertNoticePage({ initialRouteState }: { initialRouteSta
/>
</div>
<AlertSurfaceTableShell data-alert-notice-receiver-table-shell="hertzbeat-ui-dense-table" className={coldTableShellClass}>
- <AlertSurfaceTable className="min-w-[1240px] text-center">
+ <AlertSurfaceTable data-alert-notice-receiver-table-layout="viewport-contained-visible-actions" className="w-full table-fixed text-center">
<AlertSurfaceTableHead className="text-center text-[13px] normal-case tracking-normal">
<tr>
- <th className="w-[15%] px-3 py-3 text-center">{t('alert.notice.receiver.people')}</th>
- <th className="w-[15%] px-3 py-3 text-center">{t('alert.notice.receiver.type')}</th>
- <th className="w-[25%] px-3 py-3 text-center">{t('alert.notice.receiver.setting')}</th>
- <th className="w-[15%] px-3 py-3 text-center">{t('common.edit-time')}</th>
- <th className="w-[15%] px-3 py-3 text-center">{t('common.edit')}</th>
+ <th className="w-[26%] px-3 py-3 text-center">{t('alert.notice.receiver.people')}</th>
+ <th className="w-[112px] px-3 py-3 text-center">{t('alert.notice.receiver.type')}</th>
+ <th className="w-[26%] px-3 py-3 text-center">{t('alert.notice.receiver.setting')}</th>
+ <th className="w-[148px] px-3 py-3 text-center">{t('common.edit-time')}</th>
+ <th className="w-[132px] px-3 py-3 text-center">{t('common.edit')}</th>
</tr>
</AlertSurfaceTableHead>
<tbody>
@@ -874,43 +1389,61 @@ export default function AlertNoticePage({ initialRouteState }: { initialRouteSta
className="border-t border-[#252b34] hover:bg-[#101217]"
onClick={() => setSelectedReceiverId(receiver.id)}
>
- <td className={`px-3 py-3 text-center font-medium ${coldTextPrimaryClass}`}>{receiver.name || t('alert.notice.receiver.people')}</td>
+ <td className={`truncate px-3 py-3 text-center font-medium ${coldTextPrimaryClass}`} title={receiver.name || t('alert.notice.receiver.people')}>
+ {receiver.name || t('alert.notice.receiver.people')}
+ </td>
<td className="px-3 py-3 text-center">
<div className="flex justify-center">
<AlertSurfaceValuePill>{getNoticeTypeLabel(receiver.type, t, emptyValue)}</AlertSurfaceValuePill>
</div>
</td>
- <td className="max-w-[340px] truncate px-3 py-3 text-center" title={getReceiverSetting(receiver, emptyValue)}>{getReceiverSetting(receiver, emptyValue)}</td>
- <td className="px-3 py-3 text-center">{formatTime(receiver.gmtUpdate || receiver.gmtCreate || null)}</td>
+ <td className="truncate px-3 py-3 text-center" title={getReceiverSetting(receiver, emptyValue)}>{getReceiverSetting(receiver, emptyValue)}</td>
+ <td className="whitespace-nowrap px-3 py-3 text-center">{formatTime(receiver.gmtUpdate || receiver.gmtCreate || null)}</td>
<td className="px-3 py-3 text-center" onClick={event => event.stopPropagation()}>
- <div className="flex justify-center gap-2">
- <Button
- data-alert-notice-receiver-edit-detail="angular-detail-fetch"
- data-alert-notice-receiver-edit-detail-owner="route-detail-fetch-contract"
- className={coldIconButtonClass}
- size="icon"
- variant="default"
- onClick={() => void handleEditReceiver(receiver)}
- title={t('alert.notice.receiver.edit')}
- >
- <Pencil className="h-4 w-4" aria-hidden="true" />
- <span className="sr-only">{t('alert.notice.receiver.edit')}</span>
- </Button>
- <Button
- className={coldIconButtonClass}
- size="icon"
- variant="default"
- onClick={() => void handleDeleteReceiverById(receiver.id)}
- title={t('alert.notice.receiver.delete')}
- >
- <Trash2 className="h-4 w-4" aria-hidden="true" />
- <span className="sr-only">{t('alert.notice.receiver.delete')}</span>
- </Button>
+ <div className="flex justify-center gap-1.5">
+ <span className="inline-flex items-center gap-1">
+ <Button
+ data-alert-notice-receiver-edit-detail="angular-detail-fetch"
+ data-alert-notice-receiver-edit-detail-owner="route-detail-fetch-contract"
+ data-alert-notice-command-action="receiver-row-edit"
+ className={coldIconButtonClass}
+ size="icon"
+ variant="default"
+ onClick={() => void handleEditReceiver(receiver)}
+ title={t('alert.notice.receiver.edit')}
+ >
+ <Pencil className="h-4 w-4" aria-hidden="true" />
+ <span className="sr-only">{t('alert.notice.receiver.edit')}</span>
+ </Button>
+ <AlertNoticeActionHelp id="receiver-row-edit" {...alertNoticeActionHelp(t, 'receiver-row-edit')} />
+ </span>
+ <span className="inline-flex items-center gap-1">
+ <Button
+ data-alert-notice-command-action="receiver-row-delete"
+ className={coldIconButtonClass}
+ size="icon"
+ variant="default"
+ onClick={() => void handleDeleteReceiverById(receiver.id, receiver.name)}
+ title={t('alert.notice.receiver.delete')}
+ >
+ <Trash2 className="h-4 w-4" aria-hidden="true" />
+ <span className="sr-only">{t('alert.notice.receiver.delete')}</span>
+ </Button>
+ <AlertNoticeActionHelp id="receiver-row-delete" {...alertNoticeActionHelp(t, 'receiver-row-delete')} />
+ </span>
</div>
</td>
</tr>
)) : (
- <NoticeTableEmptyRow t={t} colSpan={5} prefix="receiver" />
+ <NoticeTableEmptyRow
+ t={t}
+ colSpan={5}
+ prefix="receiver"
+ action={{
+ label: t('alert.notice.receiver.new'),
+ onClick: () => void handleNewReceiver()
+ }}
+ />
)}
</tbody>
</AlertSurfaceTable>
@@ -923,14 +1456,25 @@ export default function AlertNoticePage({ initialRouteState }: { initialRouteSta
totalElements={data.receivers.totalElements || 0}
visibleCount={data.receivers.content.length}
testIdPrefix="notice-receiver"
- onPageIndexChange={setReceiverPageIndex}
+ onPageIndexChange={nextPageIndex => {
+ setReceiverPageIndex(nextPageIndex);
+ replaceListRouteState({
+ ...currentListRouteState,
+ receiverPageIndex: nextPageIndex
+ });
+ }}
onPageSizeChange={nextPageSize => {
setReceiverPageSize(nextPageSize);
setReceiverPageIndex(0);
+ replaceListRouteState({
+ ...currentListRouteState,
+ receiverPageIndex: 0,
+ receiverPageSize: nextPageSize
+ });
}}
/>
) : null}
- {!editingReceiver && receiverMessage ? <div className="px-3 py-2 text-sm text-emerald-300">{receiverMessage}</div> : null}
+ {!editingReceiver && receiverMessage ? <div role="status" className={coldNoticeStatusMessageClass}>{receiverMessage}</div> : null}
{!editingReceiver && receiverError ? (
<div
role="alert"
@@ -961,26 +1505,36 @@ export default function AlertNoticePage({ initialRouteState }: { initialRouteSta
className={coldToolbarClass}
>
<div className="flex flex-wrap items-center gap-2">
- <Button
- data-alert-notice-rule-sync="angular-load-table"
- data-alert-notice-rule-sync-owner="route-refresh-contract"
- className={coldIconButtonClass}
- size="icon"
- variant="default"
- onClick={() => setRefreshTick(value => value + 1)}
- title={t('common.refresh')}
- >
- <RefreshCw className="h-4 w-4" aria-hidden="true" />
- <span className="sr-only">{t('common.refresh')}</span>
- </Button>
- <Button className={coldPrimaryCommandButtonClass} variant="default" size="sm" onClick={() => void handleNewRule()}>
- <Plus className="h-4 w-4" aria-hidden="true" />
- {t('alert.notice.rule.new')}
- </Button>
- <Button className={coldIconButtonClass} variant="default" size="icon" onClick={() => void handleDeleteRule()} disabled={!selectedRule} title={t('common.button.delete')}>
- <MoreHorizontal className="h-4 w-4" aria-hidden="true" />
- <span className="sr-only">{t('common.button.delete')}</span>
- </Button>
+ <span className="inline-flex items-center gap-1">
+ <Button
+ data-alert-notice-rule-sync="angular-load-table"
+ data-alert-notice-rule-sync-owner="route-refresh-contract"
+ data-alert-notice-command-action="rule-refresh"
+ className={coldIconButtonClass}
+ size="icon"
+ variant="default"
+ onClick={() => setRefreshTick(value => value + 1)}
+ title={t('common.refresh')}
+ >
+ <RefreshCw className="h-4 w-4" aria-hidden="true" />
+ <span className="sr-only">{t('common.refresh')}</span>
+ </Button>
+ <AlertNoticeActionHelp id="rule-refresh" {...alertNoticeActionHelp(t, 'rule-refresh')} />
+ </span>
+ <span className="inline-flex items-center gap-1">
+ <Button data-alert-notice-command-action="rule-new" className={coldPrimaryCommandButtonClass} variant="default" size="sm" onClick={() => void handleNewRule()}>
+ <Plus className="h-4 w-4" aria-hidden="true" />
+ {t('alert.notice.rule.new')}
+ </Button>
+ <AlertNoticeActionHelp id="rule-new" {...alertNoticeActionHelp(t, 'rule-new')} />
+ </span>
+ <span className="inline-flex items-center gap-1">
+ <Button data-alert-notice-command-action="rule-delete" className={coldIconButtonClass} variant="default" size="icon" onClick={() => void handleDeleteRule()} disabled={!selectedRule} title={t('common.button.delete')}>
+ <MoreHorizontal className="h-4 w-4" aria-hidden="true" />
+ <span className="sr-only">{t('common.button.delete')}</span>
+ </Button>
+ <AlertNoticeActionHelp id="rule-delete" {...alertNoticeActionHelp(t, 'rule-delete')} />
+ </span>
</div>
<SearchRow
data-alert-notice-rule-search="shared-compact"
@@ -997,7 +1551,7 @@ export default function AlertNoticePage({ initialRouteState }: { initialRouteSta
/>
</div>
<AlertSurfaceTableShell data-alert-notice-rule-table-shell="hertzbeat-ui-dense-table" className={coldTableShellClass}>
- <AlertSurfaceTable className="min-w-[1240px] text-center">
+ <AlertSurfaceTable data-alert-notice-rule-table-actions="sticky-visible-actions" className="min-w-[1240px] text-center">
<AlertSurfaceTableHead className="text-center text-[13px] normal-case tracking-normal">
<tr>
<th className="px-3 py-3 text-center">{t('alert.notice.rule.name')}</th>
@@ -1006,7 +1560,7 @@ export default function AlertNoticePage({ initialRouteState }: { initialRouteSta
<th className="px-3 py-3 text-center">{t('alert.notice.rule.all')}</th>
<th className="px-3 py-3 text-center">{t('common.enable')}</th>
<th className="px-3 py-3 text-center">{t('common.edit-time')}</th>
- <th className="px-3 py-3 text-center">{t('common.edit')}</th>
+ <th className={coldStickyActionHeaderClass}>{t('common.edit')}</th>
</tr>
</AlertSurfaceTableHead>
<tbody>
@@ -1021,47 +1575,70 @@ export default function AlertNoticePage({ initialRouteState }: { initialRouteSta
<td className="px-3 py-3 text-center">{formatNoticeRuleReceivers(rule.receiverName, productCopy.ruleNoReceiver)}</td>
<td className="px-3 py-3 text-center">{rule.templateId ? rule.templateName || t('alert.notice.template.preset.true') : t('alert.notice.template.preset.true')}</td>
<td className="px-3 py-3 text-center" onClick={event => event.stopPropagation()}>
- <NoticeTableSwitch
- field="filter-all"
- checked={Boolean(rule.filterAll)}
- pending={ruleSwitchPending === `${rule.id}:filterAll`}
- label={t('alert.notice.rule.all')}
- onChange={checked => void handleToggleRuleSwitch(rule, 'filterAll', checked)}
- />
+ <span className="inline-flex items-center gap-1">
+ <NoticeTableSwitch
+ field="filter-all"
+ commandAction="rule-row-filter-all"
+ checked={Boolean(rule.filterAll)}
+ pending={ruleSwitchPending === `${rule.id}:filterAll`}
+ label={t('alert.notice.rule.all')}
+ onChange={checked => void handleToggleRuleSwitch(rule, 'filterAll', checked)}
+ />
+ <AlertNoticeActionHelp id="rule-row-filter-all" {...alertNoticeActionHelp(t, 'rule-row-filter-all')} />
+ </span>
</td>
<td className="px-3 py-3 text-center" onClick={event => event.stopPropagation()}>
- <NoticeTableSwitch
- field="enable"
- checked={Boolean(rule.enable)}
- pending={ruleSwitchPending === `${rule.id}:enable`}
- label={t('common.enable')}
- onChange={checked => void handleToggleRuleSwitch(rule, 'enable', checked)}
- />
+ <span className="inline-flex items-center gap-1">
+ <NoticeTableSwitch
+ field="enable"
+ commandAction="rule-row-enable"
+ checked={Boolean(rule.enable)}
+ pending={ruleSwitchPending === `${rule.id}:enable`}
+ label={t('common.enable')}
+ onChange={checked => void handleToggleRuleSwitch(rule, 'enable', checked)}
+ />
+ <AlertNoticeActionHelp id="rule-row-enable" {...alertNoticeActionHelp(t, 'rule-row-enable')} />
+ </span>
</td>
<td className="px-3 py-3 text-center">{formatTime(rule.gmtUpdate || rule.gmtCreate || null)}</td>
- <td className="px-3 py-3 text-center" onClick={event => event.stopPropagation()}>
- <div className="flex justify-center gap-2">
- <Button
- className={coldIconButtonClass}
- size="icon"
- variant="default"
- data-alert-notice-rule-edit-detail="angular-detail-fetch"
- data-alert-notice-rule-edit-detail-owner="route-detail-fetch-contract"
- onClick={() => void handleEditRule(rule)}
- title={t('alert.notice.rule.edit')}
- >
- <Pencil className="h-4 w-4" aria-hidden="true" />
- <span className="sr-only">{t('alert.notice.rule.edit')}</span>
- </Button>
- <Button className={coldIconButtonClass} size="icon" variant="default" onClick={() => void handleDeleteRuleById(rule.id)} title={t('alert.notice.rule.delete')}>
- <Trash2 className="h-4 w-4" aria-hidden="true" />
- <span className="sr-only">{t('alert.notice.rule.delete')}</span>
- </Button>
+ <td className={coldStickyActionCellClass} onClick={event => event.stopPropagation()}>
+ <div className="flex justify-center gap-1.5">
+ <span className="inline-flex items-center gap-1">
+ <Button
+ className={coldIconButtonClass}
+ size="icon"
+ variant="default"
+ data-alert-notice-rule-edit-detail="angular-detail-fetch"
+ data-alert-notice-rule-edit-detail-owner="route-detail-fetch-contract"
+ data-alert-notice-command-action="rule-row-edit"
+ onClick={() => void handleEditRule(rule)}
+ title={t('alert.notice.rule.edit')}
+ >
+ <Pencil className="h-4 w-4" aria-hidden="true" />
+ <span className="sr-only">{t('alert.notice.rule.edit')}</span>
+ </Button>
+ <AlertNoticeActionHelp id="rule-row-edit" {...alertNoticeActionHelp(t, 'rule-row-edit')} />
+ </span>
+ <span className="inline-flex items-center gap-1">
+ <Button data-alert-notice-command-action="rule-row-delete" className={coldIconButtonClass} size="icon" variant="default" onClick={() => void handleDeleteRuleById(rule.id, rule.name)} title={t('alert.notice.rule.delete')}>
+ <Trash2 className="h-4 w-4" aria-hidden="true" />
+ <span className="sr-only">{t('alert.notice.rule.delete')}</span>
+ </Button>
+ <AlertNoticeActionHelp id="rule-row-delete" {...alertNoticeActionHelp(t, 'rule-row-delete')} />
+ </span>
</div>
</td>
</tr>
)) : (
- <NoticeTableEmptyRow t={t} colSpan={7} prefix="rule" />
+ <NoticeTableEmptyRow
+ t={t}
+ colSpan={7}
+ prefix="rule"
+ action={{
+ label: t('alert.notice.rule.new'),
+ onClick: () => void handleNewRule()
+ }}
+ />
)}
</tbody>
</AlertSurfaceTable>
@@ -1074,14 +1651,25 @@ export default function AlertNoticePage({ initialRouteState }: { initialRouteSta
totalElements={data.rules.totalElements || 0}
visibleCount={data.rules.content.length}
testIdPrefix="notice-rule"
- onPageIndexChange={setRulePageIndex}
+ onPageIndexChange={nextPageIndex => {
+ setRulePageIndex(nextPageIndex);
+ replaceListRouteState({
+ ...currentListRouteState,
+ rulePageIndex: nextPageIndex
+ });
+ }}
onPageSizeChange={nextPageSize => {
setRulePageSize(nextPageSize);
setRulePageIndex(0);
+ replaceListRouteState({
+ ...currentListRouteState,
+ rulePageIndex: 0,
+ rulePageSize: nextPageSize
+ });
}}
/>
) : null}
- {ruleMessage ? <div className="px-3 py-2 text-sm text-emerald-300">{ruleMessage}</div> : null}
+ {ruleMessage ? <div role="status" className={coldNoticeStatusMessageClass}>{ruleMessage}</div> : null}
{!editingRule && ruleError ? (
<div role="alert" className="px-3 py-2 text-sm text-rose-300">{ruleError}</div>
) : null}
@@ -1102,36 +1690,53 @@ export default function AlertNoticePage({ initialRouteState }: { initialRouteSta
className={coldToolbarClass}
>
<div className="flex flex-wrap items-center gap-2">
- <Button
- data-alert-notice-template-sync="angular-load-table"
- data-alert-notice-template-sync-owner="route-refresh-contract"
- className={coldIconButtonClass}
- size="icon"
- variant="default"
- onClick={() => setRefreshTick(value => value + 1)}
- title={t('common.refresh')}
- >
- <RefreshCw className="h-4 w-4" aria-hidden="true" />
- <span className="sr-only">{t('common.refresh')}</span>
- </Button>
- <Button className={coldPrimaryCommandButtonClass} variant="default" size="sm" onClick={() => void handleNewTemplate()}>
- <Plus className="h-4 w-4" aria-hidden="true" />
- {t('alert.notice.template.new')}
- </Button>
- <Button className={coldIconButtonClass} variant="default" size="icon" onClick={() => void handleDeleteTemplate()} disabled={!selectedTemplateIsCustom} title={t('common.button.delete')}>
- <MoreHorizontal className="h-4 w-4" aria-hidden="true" />
- <span className="sr-only">{t('common.button.delete')}</span>
- </Button>
+ <span className="inline-flex items-center gap-1">
+ <Button
+ data-alert-notice-template-sync="angular-load-table"
+ data-alert-notice-template-sync-owner="route-refresh-contract"
+ data-alert-notice-command-action="template-refresh"
+ className={coldIconButtonClass}
+ size="icon"
+ variant="default"
+ onClick={() => setRefreshTick(value => value + 1)}
+ title={t('common.refresh')}
+ >
+ <RefreshCw className="h-4 w-4" aria-hidden="true" />
+ <span className="sr-only">{t('common.refresh')}</span>
+ </Button>
+ <AlertNoticeActionHelp id="template-refresh" {...alertNoticeActionHelp(t, 'template-refresh')} />
+ </span>
+ <span className="inline-flex items-center gap-1">
+ <Button data-alert-notice-command-action="template-new" className={coldPrimaryCommandButtonClass} variant="default" size="sm" onClick={() => void handleNewTemplate()}>
+ <Plus className="h-4 w-4" aria-hidden="true" />
+ {t('alert.notice.template.new')}
+ </Button>
+ <AlertNoticeActionHelp id="template-new" {...alertNoticeActionHelp(t, 'template-new')} />
+ </span>
+ <span className="inline-flex items-center gap-1">
+ <Button data-alert-notice-command-action="template-delete" className={coldIconButtonClass} variant="default" size="icon" onClick={() => void handleDeleteTemplate()} disabled={!selectedTemplateIsCustom} title={t('common.button.delete')}>
+ <MoreHorizontal className="h-4 w-4" aria-hidden="true" />
+ <span className="sr-only">{t('common.button.delete')}</span>
+ </Button>
+ <AlertNoticeActionHelp id="template-delete" {...alertNoticeActionHelp(t, 'template-delete')} />
+ </span>
</div>
<div className="flex min-w-0 flex-wrap items-center gap-2">
<Select
data-alert-notice-template-preset-filter="hertzbeat-ui-select"
data-alert-notice-template-preset-query="server-param"
+ data-alert-notice-command-action="template-filter-preset"
aria-label={t('monitor.status')}
value={templatePresetFilter ? 'true' : 'false'}
onChange={event => {
- setTemplatePresetFilter(event.target.value === 'true');
+ const nextPresetFilter = event.target.value === 'true';
+ setTemplatePresetFilter(nextPresetFilter);
setTemplatePageIndex(0);
+ replaceListRouteState({
+ ...currentListRouteState,
+ templatePresetFilter: nextPresetFilter,
+ templatePageIndex: 0
+ });
}}
containerClassName="w-[132px]"
className={coldSelectClass}
@@ -1155,14 +1760,14 @@ export default function AlertNoticePage({ initialRouteState }: { initialRouteSta
</div>
</div>
<AlertSurfaceTableShell data-alert-notice-template-table-shell="hertzbeat-ui-dense-table" className={coldTableShellClass}>
- <AlertSurfaceTable className="min-w-[1240px] text-center">
+ <AlertSurfaceTable data-alert-notice-template-table-actions="sticky-visible-actions" className="min-w-[1240px] text-center">
<AlertSurfaceTableHead className="text-center text-[13px] normal-case tracking-normal">
<tr>
<th className="px-3 py-3 text-center">{t('alert.notice.template.name')}</th>
<th className="px-3 py-3 text-center">{t('alert.notice.template.type')}</th>
<th className="px-3 py-3 text-center">{t('alert.notice.template.preset')}</th>
<th className="px-3 py-3 text-center">{t('common.edit-time')}</th>
- <th className="px-3 py-3 text-center">{t('common.edit')}</th>
+ <th className={coldStickyActionHeaderClass}>{t('common.edit')}</th>
</tr>
</AlertSurfaceTableHead>
<tbody>
@@ -1185,45 +1790,64 @@ export default function AlertNoticePage({ initialRouteState }: { initialRouteSta
</div>
</td>
<td className="px-3 py-3 text-center">{formatTime(template.gmtUpdate || template.gmtCreate || null)}</td>
- <td className="px-3 py-3 text-center" onClick={event => event.stopPropagation()}>
- <div className="flex justify-center gap-2">
+ <td className={coldStickyActionCellClass} onClick={event => event.stopPropagation()}>
+ <div className="flex justify-center gap-1.5">
{template.preset ? (
- <Button
- data-alert-notice-template-view-trigger="hertzbeat-ui-modal-viewer-trigger"
- className={coldIconButtonClass}
- size="icon"
- variant="default"
- onClick={() => void handleViewTemplate(template)}
- title={t('alert.notice.template.show')}
- >
- <Eye className="h-4 w-4" aria-hidden="true" />
- <span className="sr-only">{t('alert.notice.template.show')}</span>
- </Button>
- ) : (
- <>
+ <span className="inline-flex items-center gap-1">
<Button
- data-alert-notice-template-edit-detail="angular-detail-fetch"
- data-alert-notice-template-edit-detail-owner="route-detail-fetch-contract"
+ data-alert-notice-template-view-trigger="hertzbeat-ui-modal-viewer-trigger"
+ data-alert-notice-command-action="template-row-view"
className={coldIconButtonClass}
size="icon"
variant="default"
- onClick={() => void handleEditTemplate(template)}
- title={t('alert.notice.template.edit')}
+ onClick={() => void handleViewTemplate(template)}
+ title={t('alert.notice.template.show')}
>
- <Pencil className="h-4 w-4" aria-hidden="true" />
- <span className="sr-only">{t('alert.notice.template.edit')}</span>
- </Button>
- <Button className={coldIconButtonClass} size="icon" variant="default" onClick={() => void handleDeleteTemplateById(template.id)} title={t('alert.notice.template.delete')}>
- <Trash2 className="h-4 w-4" aria-hidden="true" />
- <span className="sr-only">{t('alert.notice.template.delete')}</span>
+ <Eye className="h-4 w-4" aria-hidden="true" />
+ <span className="sr-only">{t('alert.notice.template.show')}</span>
</Button>
+ <AlertNoticeActionHelp id="template-row-view" {...alertNoticeActionHelp(t, 'template-row-view')} />
+ </span>
+ ) : (
+ <>
+ <span className="inline-flex items-center gap-1">
+ <Button
+ data-alert-notice-template-edit-detail="angular-detail-fetch"
+ data-alert-notice-template-edit-detail-owner="route-detail-fetch-contract"
+ data-alert-notice-command-action="template-row-edit"
+ className={coldIconButtonClass}
+ size="icon"
+ variant="default"
+ onClick={() => void handleEditTemplate(template)}
+ title={t('alert.notice.template.edit')}
+ >
+ <Pencil className="h-4 w-4" aria-hidden="true" />
+ <span className="sr-only">{t('alert.notice.template.edit')}</span>
+ </Button>
+ <AlertNoticeActionHelp id="template-row-edit" {...alertNoticeActionHelp(t, 'template-row-edit')} />
+ </span>
+ <span className="inline-flex items-center gap-1">
+ <Button data-alert-notice-command-action="template-row-delete" className={coldIconButtonClass} size="icon" variant="default" onClick={() => void handleDeleteTemplateById(template.id, template.name)} title={t('alert.notice.template.delete')}>
+ <Trash2 className="h-4 w-4" aria-hidden="true" />
+ <span className="sr-only">{t('alert.notice.template.delete')}</span>
+ </Button>
+ <AlertNoticeActionHelp id="template-row-delete" {...alertNoticeActionHelp(t, 'template-row-delete')} />
+ </span>
</>
)}
</div>
</td>
</tr>
)) : (
- <NoticeTableEmptyRow t={t} colSpan={5} prefix="template" />
+ <NoticeTableEmptyRow
+ t={t}
+ colSpan={5}
+ prefix="template"
+ action={{
+ label: t('alert.notice.template.new'),
+ onClick: () => void handleNewTemplate()
+ }}
+ />
)}
</tbody>
</AlertSurfaceTable>
@@ -1236,14 +1860,25 @@ export default function AlertNoticePage({ initialRouteState }: { initialRouteSta
totalElements={templateTotalElements}
visibleCount={templateVisibleRows.length}
testIdPrefix="notice-template"
- onPageIndexChange={setTemplatePageIndex}
+ onPageIndexChange={nextPageIndex => {
+ setTemplatePageIndex(nextPageIndex);
+ replaceListRouteState({
+ ...currentListRouteState,
+ templatePageIndex: nextPageIndex
+ });
+ }}
onPageSizeChange={nextPageSize => {
setTemplatePageSize(nextPageSize);
setTemplatePageIndex(0);
+ replaceListRouteState({
+ ...currentListRouteState,
+ templatePageIndex: 0,
+ templatePageSize: nextPageSize
+ });
}}
/>
) : null}
- {templateMessage ? <div className="px-3 py-2 text-sm text-emerald-300">{templateMessage}</div> : null}
+ {templateMessage ? <div role="status" className={coldNoticeStatusMessageClass}>{templateMessage}</div> : null}
{!editingTemplate && templateError ? (
<div role="alert" className="px-3 py-2 text-sm text-rose-300">{templateError}</div>
) : null}
@@ -1255,30 +1890,57 @@ export default function AlertNoticePage({ initialRouteState }: { initialRouteSta
open={editingReceiver}
title={receiverDraft.id ? t('alert.notice.receiver.edit') : t('alert.notice.receiver.new')}
kicker={t('menu.alert.dispatch')}
- onClose={() => setEditingReceiver(false)}
+ onClose={requestCloseReceiverEditor}
maxWidthClassName="max-w-4xl"
footer={
<div className="flex flex-wrap justify-end gap-2">
- <Button data-testid="notice-receiver-cancel" className={coldButtonClassName} size="sm" variant="default" onClick={() => setEditingReceiver(false)}>
- {t('common.cancel')}
- </Button>
- <Button
- data-testid="notice-receiver-test"
- data-alert-notice-receiver-test-loading={testingReceiver ? 'true' : 'false'}
- data-alert-notice-receiver-test-validation="angular-backend-owned"
- data-alert-notice-receiver-test-validation-owner="route-mutation-contract"
- aria-busy={testingReceiver}
- className={coldButtonClassName}
- size="sm"
- variant="default"
- onClick={() => void handleTestSend()}
- disabled={testingReceiver || savingReceiver}
- >
- {t('alert.notice.send-test')}
- </Button>
- <Button data-testid="notice-receiver-save" className={coldPrimaryButtonClassName} size="sm" variant="default" onClick={() => void handleSaveReceiver()} disabled={savingReceiver}>
- {savingReceiver ? t('common.saving') : t('common.save')}
- </Button>
+ <span className="inline-flex items-center gap-1">
+ <Button
+ data-testid="notice-receiver-cancel"
+ data-alert-notice-receiver-unsaved-cancel-trigger={shouldConfirmReceiverDiscard ? 'dirty' : 'clean'}
+ data-alert-notice-command-action="receiver-cancel"
+ className={coldButtonClassName}
+ size="sm"
+ variant="default"
+ onClick={requestCloseReceiverEditor}
+ >
+ {t('common.cancel')}
+ </Button>
+ <AlertNoticeActionHelp id="receiver-cancel" {...alertNoticeActionHelp(t, 'receiver-cancel')} />
+ </span>
+ <span className="inline-flex items-center gap-1">
+ <Button
+ data-testid="notice-receiver-test"
+ data-alert-notice-receiver-test-loading={testingReceiver ? 'true' : 'false'}
+ data-alert-notice-receiver-test-validation="angular-backend-owned"
+ data-alert-notice-receiver-test-validation-owner="route-mutation-contract"
+ data-alert-notice-command-action="receiver-test"
+ aria-busy={testingReceiver}
+ className={coldButtonClassName}
+ size="sm"
+ variant="default"
+ onClick={() => void handleTestSend()}
+ disabled={testingReceiver || savingReceiver}
+ >
+ {t('alert.notice.send-test')}
+ </Button>
+ <AlertNoticeActionHelp id="receiver-test" {...alertNoticeActionHelp(t, 'receiver-test')} />
+ </span>
+ <span className="inline-flex items-center gap-1">
+ <Button
+ data-testid="notice-receiver-save"
+ data-alert-notice-receiver-save-blocked-by-test={testingReceiver ? 'true' : undefined}
+ data-alert-notice-command-action="receiver-save"
+ className={coldPrimaryButtonClassName}
+ size="sm"
+ variant="default"
+ onClick={() => void handleSaveReceiver()}
+ disabled={savingReceiver || testingReceiver}
+ >
+ {savingReceiver ? t('common.saving') : t('common.save')}
+ </Button>
+ <AlertNoticeActionHelp id="receiver-save" {...alertNoticeActionHelp(t, 'receiver-save')} />
+ </span>
</div>
}
>
@@ -1301,6 +1963,7 @@ export default function AlertNoticePage({ initialRouteState }: { initialRouteSta
data-alert-notice-receiver-save-failure-owner={receiverErrorDetail ? 'route-action-feedback-contract' : undefined}
data-alert-notice-receiver-save-failure-title={receiverErrorDetail ? receiverError : undefined}
data-alert-notice-receiver-save-failure-detail={receiverErrorDetail ?? undefined}
+ data-alert-notice-receiver-validation-count={receiverValidationIssues.length > 0 ? String(receiverValidationIssues.length) : undefined}
className="rounded-[3px] border border-[#6f3141] bg-[#1b1014] px-3 py-2 text-[12px] font-semibold leading-5 text-[#ffb4c1]"
>
<span>{receiverError}</span>
@@ -1311,7 +1974,7 @@ export default function AlertNoticePage({ initialRouteState }: { initialRouteSta
<div
role="status"
data-alert-notice-receiver-test-feedback="hertzbeat-ui-test-feedback"
- className="rounded-[3px] border border-[#24563d] bg-[#0d1a14] px-3 py-2 text-[12px] font-semibold leading-5 text-[#9be8bd]"
+ className={coldNoticeTestStatusMessageClass}
>
{receiverMessage}
</div>
@@ -1322,6 +1985,10 @@ export default function AlertNoticePage({ initialRouteState }: { initialRouteSta
data-alert-notice-receiver-test-preview-owner="signal-alert-handoff"
data-alert-notice-receiver-test-preview-signal={noticeEvidenceContext.signal}
data-alert-notice-receiver-test-preview-labels="provided-labels"
+ data-alert-notice-receiver-test-preview-labels-total={noticeEvidenceContext.receiverTestPreview.labelsTotal}
+ data-alert-notice-receiver-test-preview-labels-rendered={noticeEvidenceContext.receiverTestPreview.labelsRendered}
+ data-alert-notice-receiver-test-preview-labels-limit={noticeEvidenceContext.receiverTestPreview.labelsLimit}
+ data-alert-notice-receiver-test-preview-labels-overflow={noticeEvidenceContext.receiverTestPreview.labelsOverflow}
className="rounded-[3px] border border-[#26303d] bg-[#080a0e] px-3 py-2"
>
<p className="text-[12px] font-semibold text-[#eef2f7]">{noticeEvidenceContext.receiverTestPreview.title}</p>
@@ -1330,7 +1997,7 @@ export default function AlertNoticePage({ initialRouteState }: { initialRouteSta
data-alert-notice-receiver-test-preview-labels-text="signal-route"
className="mt-2 block whitespace-pre-wrap break-words font-mono text-[11px] leading-5 text-[#aab4c3]"
>
- {noticeEvidenceContext.receiverTestPreview.labelsText}
+ {noticeEvidenceContext.receiverTestPreview.labelsPreviewText}
</code>
<div
data-alert-notice-receiver-test-preview-payload="sample-alert"
@@ -1368,7 +2035,8 @@ export default function AlertNoticePage({ initialRouteState }: { initialRouteSta
t={t}
draft={receiverDraft}
productCopy={productCopy}
- onDraftChange={setReceiverDraft}
+ onDraftChange={handleReceiverDraftChange}
+ validationIssues={receiverValidationIssues}
/>
</div>
</OverlayDialog>
@@ -1379,13 +2047,14 @@ export default function AlertNoticePage({ initialRouteState }: { initialRouteSta
open={editingRule}
title={ruleDraft.id ? t('alert.notice.rule.edit') : t('alert.notice.rule.new')}
kicker={t('menu.alert.dispatch')}
- onClose={() => setEditingRule(false)}
+ onClose={requestCloseRuleEditor}
maxWidthClassName="max-w-4xl"
footer={
<div className="flex flex-wrap justify-end gap-2">
{noticeEvidenceContext?.returnHref ? (
<a
data-alert-notice-rule-editor-return="evidence-context"
+ data-alert-notice-command-action="rule-return-to-evidence"
href={noticeEvidenceContext.returnHref}
className="inline-flex h-8 items-center gap-1 rounded-[3px] border border-[#2b3039] bg-[#101217] px-3 text-[12px] font-semibold text-[#dbe4f0] hover:border-[#4e74f8] hover:bg-[#151b28] hover:text-white"
>
@@ -1393,22 +2062,36 @@ export default function AlertNoticePage({ initialRouteState }: { initialRouteSta
{t('alert.rule.evidence.return')}
</a>
) : null}
- <Button className={coldButtonClassName} size="sm" variant="default" onClick={() => setEditingRule(false)}>
- {t('common.cancel')}
- </Button>
- <Button
- className={coldPrimaryButtonClassName}
- size="sm"
- variant="default"
- data-alert-notice-rule-save-loading="angular-nz-ok-loading"
- data-alert-notice-rule-save-loading-owner="route-modal-ok-contract"
- data-alert-notice-rule-save-loading-state={savingRule ? 'true' : 'false'}
- aria-busy={savingRule}
- onClick={() => void handleSaveRule()}
- disabled={savingRule}
- >
- {savingRule ? t('common.saving') : t('common.save')}
- </Button>
+ <span className="inline-flex items-center gap-1">
+ <Button
+ className={coldButtonClassName}
+ size="sm"
+ variant="default"
+ data-alert-notice-rule-unsaved-cancel-trigger={shouldConfirmRuleDiscard ? 'dirty' : 'clean'}
+ data-alert-notice-command-action="rule-cancel"
+ onClick={requestCloseRuleEditor}
+ >
+ {t('common.cancel')}
+ </Button>
+ <AlertNoticeActionHelp id="rule-cancel" {...alertNoticeActionHelp(t, 'rule-cancel')} />
+ </span>
+ <span className="inline-flex items-center gap-1">
+ <Button
+ className={coldPrimaryButtonClassName}
+ size="sm"
+ variant="default"
+ data-alert-notice-rule-save-loading="angular-nz-ok-loading"
+ data-alert-notice-rule-save-loading-owner="route-modal-ok-contract"
+ data-alert-notice-rule-save-loading-state={savingRule ? 'true' : 'false'}
+ data-alert-notice-command-action="rule-save"
+ aria-busy={savingRule}
+ onClick={() => void handleSaveRule()}
+ disabled={savingRule}
+ >
+ {savingRule ? t('common.saving') : t('common.save')}
+ </Button>
+ <AlertNoticeActionHelp id="rule-save" {...alertNoticeActionHelp(t, 'rule-save')} />
+ </span>
</div>
}
>
@@ -1448,7 +2131,8 @@ export default function AlertNoticePage({ initialRouteState }: { initialRouteSta
labelOptions={labelOptions}
sourceLabelsText={noticeEvidenceContext?.labelsText}
sourceSignal={noticeEvidenceContext?.signal}
- onDraftChange={setRuleDraft}
+ validationIssues={resolveNoticeRuleValidationIssues(ruleErrorDetail ? null : ruleError, t)}
+ onDraftChange={handleRuleDraftChange}
/>
</div>
</OverlayDialog>
@@ -1459,30 +2143,35 @@ export default function AlertNoticePage({ initialRouteState }: { initialRouteSta
open={editingTemplate}
title={templateReadOnly ? t('alert.notice.template.content') : templateDraft.id ? t('alert.notice.template.edit') : t('alert.notice.template.new')}
kicker={t('menu.alert.dispatch')}
- onClose={() => {
- setEditingTemplate(false);
- setTemplateReadOnly(false);
- }}
+ onClose={requestCloseTemplateEditor}
maxWidthClassName="max-w-4xl"
footer={
<div className="flex flex-wrap justify-end gap-2">
- <Button
- className={coldButtonClassName}
- size="sm"
- variant="default"
- data-alert-notice-template-viewer-return={templateReadOnly ? 'angular-cancel-return' : undefined}
- data-alert-notice-template-viewer-return-owner={templateReadOnly ? 'route-modal-footer-contract' : undefined}
- onClick={() => {
- setEditingTemplate(false);
- setTemplateReadOnly(false);
- }}
- >
- {templateReadOnly ? t('common.button.return') : t('common.cancel')}
- </Button>
- {!templateReadOnly ? (
- <Button className={coldPrimaryButtonClassName} size="sm" variant="default" onClick={() => void handleSaveTemplate()} disabled={savingTemplate}>
- {savingTemplate ? t('common.saving') : t('common.save')}
+ <span className="inline-flex items-center gap-1">
+ <Button
+ className={coldButtonClassName}
+ size="sm"
+ variant="default"
+ data-alert-notice-template-viewer-return={templateReadOnly ? 'angular-cancel-return' : undefined}
+ data-alert-notice-template-viewer-return-owner={templateReadOnly ? 'route-modal-footer-contract' : undefined}
+ data-alert-notice-template-unsaved-cancel-trigger={!templateReadOnly ? shouldConfirmTemplateDiscard ? 'dirty' : 'clean' : undefined}
+ data-alert-notice-command-action={templateReadOnly ? 'template-return' : 'template-cancel'}
+ onClick={requestCloseTemplateEditor}
+ >
+ {templateReadOnly ? t('common.button.return') : t('common.cancel')}
</Button>
+ <AlertNoticeActionHelp
+ id={templateReadOnly ? 'template-return' : 'template-cancel'}
+ {...alertNoticeActionHelp(t, templateReadOnly ? 'template-return' : 'template-cancel')}
+ />
+ </span>
+ {!templateReadOnly ? (
+ <span className="inline-flex items-center gap-1">
+ <Button data-alert-notice-command-action="template-save" className={coldPrimaryButtonClassName} size="sm" variant="default" onClick={() => void handleSaveTemplate()} disabled={savingTemplate}>
+ {savingTemplate ? t('common.saving') : t('common.save')}
+ </Button>
+ <AlertNoticeActionHelp id="template-save" {...alertNoticeActionHelp(t, 'template-save')} />
+ </span>
) : null}
</div>
}
@@ -1511,26 +2200,48 @@ export default function AlertNoticePage({ initialRouteState }: { initialRouteSta
data-alert-notice-template-save-failure-owner={templateErrorDetail ? 'route-action-feedback-contract' : undefined}
data-alert-notice-template-save-failure-title={templateErrorDetail ? templateError : undefined}
data-alert-notice-template-save-failure-detail={templateErrorDetail ?? undefined}
+ data-alert-notice-template-validation-count={templateValidationIssues.length > 0 ? String(templateValidationIssues.length) : undefined}
className="rounded-[3px] border border-[#6f3141] bg-[#1b1014] px-3 py-2 text-[12px] font-semibold leading-5 text-[#ffb4c1]"
>
<span>{templateError}</span>
{templateErrorDetail ? <span className="mt-1 block font-medium text-[#ffccd5]">{templateErrorDetail}</span> : null}
</div>
) : null}
- <AlertNoticeTemplateFields t={t} draft={templateDraft} readOnly={templateReadOnly} onDraftChange={setTemplateDraft} />
+ <AlertNoticeTemplateFields
+ t={t}
+ draft={templateDraft}
+ readOnly={templateReadOnly}
+ onDraftChange={handleTemplateDraftChange}
+ validationIssues={templateValidationIssues}
+ />
</div>
)}
</OverlayDialog>
);
+ const deleteConfirmCopy = [
+ deleteRequest?.kind === 'receiver'
+ ? t('alert.notice.delete.confirm.receiver')
+ : deleteRequest?.kind === 'template'
+ ? t('alert.notice.delete.confirm.template')
+ : t('alert.notice.delete.confirm.rule'),
+ deleteRequest?.name ? t('alert.notice.delete.confirm.target', { name: deleteRequest.name }) : null
+ ].filter(Boolean).join(' ');
+ const deleteConfirmActionLabel =
+ deleteRequest?.kind === 'receiver'
+ ? t('alert.notice.delete.confirm.receiver-action')
+ : deleteRequest?.kind === 'template'
+ ? t('alert.notice.delete.confirm.template-action')
+ : t('alert.notice.delete.confirm.rule-action');
return (
<>
<div
data-alert-notice-surface="otlp-hertzbeat-ui-notice-console"
data-alert-notice-style-baseline={coldNoticeVisual.canvasName}
+ data-alert-notice-page-overflow="route-contained-horizontal"
data-alert-notice-edit-load-feedback="angular-edit-fail"
data-alert-notice-edit-load-feedback-owner="route-action-feedback-contract"
- className={coldNoticeVisual.canvas.root}
+ className={`${coldNoticeVisual.canvas.root} overflow-x-hidden`}
style={coldNoticeVisual.canvas.backgroundStyle}
>
<section className={coldNoticeVisual.layout.pageSection}>
@@ -1538,8 +2249,9 @@ export default function AlertNoticePage({ initialRouteState }: { initialRouteSta
<div className="mb-5">
<section
data-alert-notice-header="hertzbeat-ui-compact-header"
+ data-alert-notice-header-nesting-contract="flat-page-introduction"
data-alert-notice-admin-layout="full-width-admin-list"
- className={coldNoticeVisual.panel.hero}
+ className="p-0"
>
<div className="flex flex-wrap items-start justify-between gap-5">
<div className="min-w-[260px] flex-1">
@@ -1547,7 +2259,11 @@ export default function AlertNoticePage({ initialRouteState }: { initialRouteSta
<p className="mt-4 max-w-[780px] text-[13px] leading-6 text-[#a9b0bb]">
{t('alert.notice.copy')}
</p>
- <div data-alert-notice-command-bar="standard-equal-buttons" className={coldNoticeVisual.button.row}>
+ <div
+ data-alert-notice-command-bar="standard-equal-buttons"
+ data-alert-notice-command-bar-mobile="two-column-wrap"
+ className="mt-6 grid grid-cols-2 items-center gap-2 sm:flex sm:flex-wrap"
+ >
<Button size="sm" className={coldCommandButtonClass} variant="default" onClick={() => setRefreshTick(value => value + 1)}>
{t('common.refresh')}
</Button>
@@ -1583,7 +2299,13 @@ export default function AlertNoticePage({ initialRouteState }: { initialRouteSta
<AlertNoticeConsoleShell
t={t}
selectedTab={selectedTab}
- onSelectTab={setSelectedTab}
+ onSelectTab={nextTab => {
+ setSelectedTab(nextTab);
+ replaceListRouteState({
+ ...currentListRouteState,
+ selectedTab: nextTab
+ });
+ }}
receiverContent={receiverPanel}
ruleContent={rulePanel}
templateContent={templatePanel}
@@ -1591,9 +2313,10 @@ export default function AlertNoticePage({ initialRouteState }: { initialRouteSta
{noticeEvidenceContext ? (
<section
data-alert-notice-evidence-context="signal-route"
+ data-alert-notice-evidence-layering="flat-context-band"
data-alert-notice-evidence-signal={noticeEvidenceContext.signal}
data-alert-notice-prefill-labels={noticeEvidenceContext.labelsText}
- className="mt-5 rounded-[4px] border border-[#27303c] bg-[#0b0f15] px-4 py-3 shadow-[0_18px_48px_rgba(0,0,0,0.24)]"
+ className="mt-5 border-y border-[#27303c] bg-[#0b0f15]/80 px-3 py-3"
>
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="min-w-0">
@@ -1633,6 +2356,102 @@ export default function AlertNoticePage({ initialRouteState }: { initialRouteSta
{receiverEditorDialog}
{ruleEditorDialog}
{templateEditorDialog}
+ <div
+ data-alert-notice-template-unsaved-cancel="hertzbeat-ui-confirm-dialog"
+ data-alert-notice-template-unsaved-cancel-state={templateDiscardDialogOpen ? 'open' : 'closed'}
+ >
+ <HzConfirmDialog
+ open={templateDiscardDialogOpen}
+ tone="warning"
+ title={t('alert.notice.template.unsaved-cancel.title')}
+ kicker={t('alert.notice.template.unsaved-cancel.kicker')}
+ cancelLabel={t('alert.notice.template.unsaved-cancel.keep-editing')}
+ confirmLabel={t('alert.notice.template.unsaved-cancel.discard')}
+ onClose={() => setTemplateDiscardDialogOpen(false)}
+ onConfirm={closeTemplateEditor}
+ data-alert-notice-template-unsaved-cancel-dialog="hertzbeat-ui-confirm-dialog"
+ cancelButtonProps={
+ {
+ type: 'button',
+ 'data-alert-notice-template-unsaved-cancel-keep-editing': 'true'
+ } as React.ComponentProps<typeof HzConfirmDialog>['cancelButtonProps']
+ }
+ confirmButtonProps={
+ {
+ type: 'button',
+ 'data-alert-notice-template-unsaved-cancel-confirm': 'true'
+ } as React.ComponentProps<typeof HzConfirmDialog>['confirmButtonProps']
+ }
+ >
+ <p data-alert-notice-template-unsaved-cancel-copy="true">
+ {t('alert.notice.template.unsaved-cancel.copy')}
+ </p>
+ </HzConfirmDialog>
+ </div>
+ <div
+ data-alert-notice-rule-unsaved-cancel="hertzbeat-ui-confirm-dialog"
+ data-alert-notice-rule-unsaved-cancel-state={ruleDiscardDialogOpen ? 'open' : 'closed'}
+ >
+ <HzConfirmDialog
+ open={ruleDiscardDialogOpen}
+ tone="warning"
+ title={t('alert.notice.rule.unsaved-cancel.title')}
+ kicker={t('alert.notice.rule.unsaved-cancel.kicker')}
+ cancelLabel={t('alert.notice.rule.unsaved-cancel.keep-editing')}
+ confirmLabel={t('alert.notice.rule.unsaved-cancel.discard')}
+ onClose={() => setRuleDiscardDialogOpen(false)}
+ onConfirm={closeRuleEditor}
+ data-alert-notice-rule-unsaved-cancel-dialog="hertzbeat-ui-confirm-dialog"
+ cancelButtonProps={
+ {
+ type: 'button',
+ 'data-alert-notice-rule-unsaved-cancel-keep-editing': 'true'
+ } as React.ComponentProps<typeof HzConfirmDialog>['cancelButtonProps']
+ }
+ confirmButtonProps={
+ {
+ type: 'button',
+ 'data-alert-notice-rule-unsaved-cancel-confirm': 'true'
+ } as React.ComponentProps<typeof HzConfirmDialog>['confirmButtonProps']
+ }
+ >
+ <p data-alert-notice-rule-unsaved-cancel-copy="true">
+ {t('alert.notice.rule.unsaved-cancel.copy')}
+ </p>
+ </HzConfirmDialog>
+ </div>
+ <div
+ data-alert-notice-receiver-unsaved-cancel="hertzbeat-ui-confirm-dialog"
+ data-alert-notice-receiver-unsaved-cancel-state={receiverDiscardDialogOpen ? 'open' : 'closed'}
+ >
+ <HzConfirmDialog
+ open={receiverDiscardDialogOpen}
+ tone="warning"
+ title={t('alert.notice.receiver.unsaved-cancel.title')}
+ kicker={t('alert.notice.receiver.unsaved-cancel.kicker')}
+ cancelLabel={t('alert.notice.receiver.unsaved-cancel.keep-editing')}
+ confirmLabel={t('alert.notice.receiver.unsaved-cancel.discard')}
+ onClose={() => setReceiverDiscardDialogOpen(false)}
+ onConfirm={closeReceiverEditor}
+ data-alert-notice-receiver-unsaved-cancel-dialog="hertzbeat-ui-confirm-dialog"
+ cancelButtonProps={
+ {
+ type: 'button',
+ 'data-alert-notice-receiver-unsaved-cancel-keep-editing': 'true'
+ } as React.ComponentProps<typeof HzConfirmDialog>['cancelButtonProps']
+ }
+ confirmButtonProps={
+ {
+ type: 'button',
+ 'data-alert-notice-receiver-unsaved-cancel-confirm': 'true'
+ } as React.ComponentProps<typeof HzConfirmDialog>['confirmButtonProps']
+ }
+ >
+ <p data-alert-notice-receiver-unsaved-cancel-copy="true">
+ {t('alert.notice.receiver.unsaved-cancel.copy')}
+ </p>
+ </HzConfirmDialog>
+ </div>
<div
data-alert-delete-confirm={deleteRequest ? 'open' : 'closed'}
data-alert-notice-delete-confirm="angular-modal-confirm"
@@ -1647,7 +2466,7 @@ export default function AlertNoticePage({ initialRouteState }: { initialRouteSta
tone="critical"
kicker={t('common.confirm.operation')}
title={t('common.confirm.delete')}
- confirmLabel={t('common.button.ok')}
+ confirmLabel={deleteConfirmActionLabel}
cancelLabel={t('common.button.cancel')}
confirmDisabled={deletePending}
onClose={deletePending ? undefined : () => setDeleteRequest(null)}
@@ -1666,11 +2485,7 @@ export default function AlertNoticePage({ initialRouteState }: { initialRouteSta
}
>
<p data-alert-notice-delete-confirm-copy="angular-modal-confirm">
- {deleteRequest?.kind === 'receiver'
- ? t('alert.notice.delete.confirm.receiver')
- : deleteRequest?.kind === 'template'
- ? t('alert.notice.delete.confirm.template')
- : t('alert.notice.delete.confirm.rule')}
+ {deleteConfirmCopy}
</p>
</HzConfirmDialog>
</div>
diff --git a/web-next/app/alert/notice/page.test.tsx b/web-next/app/alert/notice/page.test.tsx
index eb00cc9971..6f82607b67 100644
--- a/web-next/app/alert/notice/page.test.tsx
+++ b/web-next/app/alert/notice/page.test.tsx
@@ -9,6 +9,8 @@ import { createTranslatorMock } from '../../../test/i18n-test-helper';
const mockState = vi.hoisted(() => ({
lastLoad: null as null | (() => Promise<unknown>),
+ currentSearchParams: '',
+ routerReplace: vi.fn(),
renderData: {
receivers: {
content: [
@@ -118,6 +120,13 @@ vi.mock('next/link', () => ({
)
}));
+vi.mock('next/navigation', () => ({
+ useRouter: () => ({
+ replace: mockState.routerReplace
+ }),
+ useSearchParams: () => new URLSearchParams(mockState.currentSearchParams)
+}));
+
vi.mock('../../../components/providers/i18n-provider', () => ({
useI18n: () => ({
t: createTranslatorMock({
@@ -346,12 +355,15 @@ async function renderAlertNoticePage(initialRouteState: AlertNoticeRouteState =
describe('alert notice page', () => {
beforeEach(() => {
mockState.lastLoad = null;
+ mockState.currentSearchParams = '';
+ mockState.routerReplace.mockReset();
mockLoadAlertNoticeData.mockClear().mockResolvedValue(mockState.renderData);
});
it('renders the OTLP cold notice tab shell with the receiver console selected by default', async () => {
const t = createTranslatorMock({ locale: 'zh-CN' });
const source = readFileSync(resolve(process.cwd(), 'app/alert/notice/alert-notice-page.tsx'), 'utf8');
+ const receiverPanelSource = source.slice(source.indexOf('const receiverPanel = ('), source.indexOf('const rulePanel = ('));
const html = await renderAlertNoticePage();
expect(html).toContain(t('alert.notice.title'));
@@ -363,8 +375,12 @@ describe('alert notice page', () => {
expect(html).toContain('data-tab="template"');
expect(html).toContain('data-alert-notice-surface="otlp-hertzbeat-ui-notice-console"');
expect(html).toContain('data-alert-notice-style-baseline="hertzbeat-ui-matte"');
+ expect(html).toContain('data-alert-notice-page-overflow="route-contained-horizontal"');
+ expect(html).toContain('overflow-x-hidden');
expect(html).toContain('data-alert-notice-header="hertzbeat-ui-compact-header"');
+ expect(html).toContain('data-alert-notice-header-nesting-contract="flat-page-introduction"');
expect(html).toContain('data-alert-notice-admin-layout="full-width-admin-list"');
+ expect(html).toContain('class="p-0"');
expect(html).toContain('data-alert-notice-inline-metrics="hertzbeat-ui-inline-counts"');
expect(html).toContain('data-alert-notice-command-bar="standard-equal-buttons"');
expect(html).toContain('data-alert-notice-workbench-panel="cold-tabbed-table-panel"');
@@ -384,6 +400,10 @@ describe('alert notice page', () => {
expect(html).not.toContain('data-hz-search-input-shell');
expect(html).toContain('data-hz-search-action="submit"');
expect(html).toContain('data-alert-notice-receiver-table-shell="hertzbeat-ui-dense-table"');
+ expect(html).toContain('data-alert-notice-receiver-table-layout="viewport-contained-visible-actions"');
+ expect(html).toContain('class="min-w-full border-collapse text-sm text-[var(--ops-text-secondary)] w-full table-fixed text-center"');
+ expect(receiverPanelSource).toContain('data-alert-notice-receiver-table-layout="viewport-contained-visible-actions"');
+ expect(receiverPanelSource).not.toContain('className="min-w-[1240px] text-center"');
expect(html).toContain('data-alert-notice-pagination="hertzbeat-ui-dense-pagination"');
expect(html).toContain('data-alert-notice-pagination-owner="hertzbeat-ui-pagination-bar"');
expect(html).toContain('data-hz-ui="pagination-bar"');
@@ -418,11 +438,15 @@ describe('alert notice page', () => {
expect(source).toContain('hzOpsCatalogVisual');
expect(source).toContain('data-alert-notice-style-baseline={coldNoticeVisual.canvasName}');
- expect(source).toContain('className={coldNoticeVisual.canvas.root}');
+ expect(source).toContain('data-alert-notice-page-overflow="route-contained-horizontal"');
+ expect(source).toContain('className={`${coldNoticeVisual.canvas.root} overflow-x-hidden`}');
expect(source).toContain('style={coldNoticeVisual.canvas.backgroundStyle}');
expect(source).toContain('<section className={coldNoticeVisual.layout.pageSection}>');
- expect(source).toContain('className={coldNoticeVisual.panel.hero}');
- expect(source).toContain('className={coldNoticeVisual.button.row}');
+ expect(source).toContain('data-alert-notice-header-nesting-contract="flat-page-introduction"');
+ expect(source).toContain('className="p-0"');
+ expect(source).not.toContain('className={coldNoticeVisual.panel.hero}');
+ expect(source).toContain('data-alert-notice-command-bar-mobile="two-column-wrap"');
+ expect(source).toContain('className="mt-6 grid grid-cols-2 items-center gap-2 sm:flex sm:flex-wrap"');
expect(source).toContain('coldButtonClassName');
expect(source).toContain('coldPrimaryButtonClassName');
expect(source).toContain('coldCommandButtonClass');
@@ -433,6 +457,17 @@ describe('alert notice page', () => {
expect(source).toContain("from '../../../lib/alert-label-options'");
expect(source).toContain('loadAlertLabelOptionsFromFacade(api.alertLabels.list)');
expect(source).toContain('loadAlertNoticeDataFromFacade');
+ expect(source).toContain("import { useRouter, useSearchParams } from 'next/navigation';");
+ expect(source).toContain("receiverSearch: routeParams.get('receiverSearch') ?? ''");
+ expect(source).toContain("ruleSearch: routeParams.get('ruleSearch') ?? ''");
+ expect(source).toContain("templateSearch: routeParams.get('templateSearch') ?? ''");
+ expect(source).toContain("if (!nextState.templatePresetFilter || currentParams.get('templatePreset') === 'true') nextParams.set('templatePreset', String(nextState.templatePresetFilter));");
+ expect(source).toContain('const nextTemplateSearch = templateDraft.name.trim();');
+ expect(source).toContain('setTemplatePresetFilter(false);');
+ expect(source).toContain("templatePresetFilter: false");
+ expect(source).toContain("templateSearch: nextTemplateSearch");
+ expect(source).toContain('return nextParamString ? `/alert/notice?${nextParamString}` : \'/alert/notice\';');
+ expect(source).toContain('router.replace(nextUrl, { scroll: false });');
expect(source).toContain('receivers: api.alertNotice.receivers.list');
expect(source).toContain('rules: api.alertNotice.rules.list');
expect(source).toContain('receiverOptions: api.alertNotice.receivers.options');
@@ -483,6 +518,249 @@ describe('alert notice page', () => {
expect(source).not.toContain('NoticeListToolbar');
expect(source).not.toContain('buildNoticeFacts');
expect(source).not.toContain("from '../../../components/workbench/primitives'");
+ }, 60_000);
+
+ it('initializes notice tab and list queries from URL state', async () => {
+ mockState.currentSearchParams = [
+ 'tab=template',
+ 'receiverSearch=ops',
+ 'receiverPageIndex=2',
+ 'receiverPageSize=15',
+ 'ruleSearch=severity',
+ 'rulePageIndex=1',
+ 'rulePageSize=25',
+ 'templateSearch=webhook',
+ 'templatePreset=false',
+ 'templatePageIndex=3',
+ 'templatePageSize=15'
+ ].join('&');
+
+ const html = await renderAlertNoticePage();
+
+ expect(html).toContain('data-selected-tab="template"');
+ expect(html).toContain('value="webhook"');
+ expect(html).toContain('value="false" selected=""');
+
+ await mockState.lastLoad?.();
+
+ expect(mockLoadAlertNoticeData).toHaveBeenLastCalledWith(expect.anything(), {
+ receivers: {
+ search: 'ops',
+ pageIndex: 2,
+ pageSize: 15
+ },
+ rules: {
+ search: 'severity',
+ pageIndex: 1,
+ pageSize: 25
+ },
+ templates: {
+ search: 'webhook',
+ preset: false,
+ pageIndex: 3,
+ pageSize: 15
+ }
+ });
+ }, 30_000);
+
+ it('keeps explicitly supplied default notice list URL params during route sync', async () => {
+ const { buildAlertNoticeListRouteUrl } = await import('./alert-notice-page');
+ const nextUrl = buildAlertNoticeListRouteUrl(
+ [
+ 'tab=receiver',
+ 'receiverSearch=uv_notice_receiver_webhook_token_help_1082',
+ 'receiverPageIndex=0',
+ 'receiverPageSize=8',
+ 'source=notice-receiver-webhook-token-help-1082',
+ 'returnTo=%2Falert%2Fsetting%3Fsource%3Dnotice-receiver-webhook-token-help-return-1082%26probe%3Dnotice-receiver-webhook-token-help-1082',
+ 'timeRange=last-30m',
+ 'live=false',
+ 'probe=notice-receiver-webhook-token-help-1082',
+ 'templatePreset=true'
+ ].join('&'),
+ 'receiver',
+ {
+ selectedTab: 'receiver',
+ receiverSearch: 'uv_notice_receiver_webhook_token_help_1082',
+ receiverPageIndex: 0,
+ receiverPageSize: 8,
+ ruleSearch: '',
+ rulePageIndex: 0,
+ rulePageSize: 8,
+ templateSearch: '',
+ templatePresetFilter: true,
+ templatePageIndex: 0,
+ templatePageSize: 8
+ }
+ );
+
+ expect(nextUrl).toContain('tab=receiver');
+ expect(nextUrl).toContain('receiverSearch=uv_notice_receiver_webhook_token_help_1082');
+ expect(nextUrl).toContain('receiverPageIndex=0');
+ expect(nextUrl).toContain('receiverPageSize=8');
+ expect(nextUrl).toContain('templatePreset=true');
+ expect(nextUrl).toContain('source=notice-receiver-webhook-token-help-1082');
+ expect(nextUrl).toContain('timeRange=last-30m');
+ expect(nextUrl).toContain('live=false');
+ expect(nextUrl).toContain('probe=notice-receiver-webhook-token-help-1082');
+ expect(decodeURIComponent(nextUrl)).toContain('/alert/setting?source=notice-receiver-webhook-token-help-return-1082&probe=notice-receiver-webhook-token-help-1082');
+ }, 30_000);
+
+ it('drops stale non-default notice list URL params when operators switch back to defaults', async () => {
+ const { buildAlertNoticeListRouteUrl } = await import('./alert-notice-page');
+ const nextUrl = buildAlertNoticeListRouteUrl(
+ 'tab=template&receiverPageSize=15&templatePreset=false&source=manual',
+ 'receiver',
+ {
+ selectedTab: 'receiver',
+ receiverSearch: '',
+ receiverPageIndex: 0,
+ receiverPageSize: 8,
+ ruleSearch: '',
+ rulePageIndex: 0,
+ rulePageSize: 8,
+ templateSearch: '',
+ templatePresetFilter: true,
+ templatePageIndex: 0,
+ templatePageSize: 8
+ }
+ );
+
+ expect(nextUrl).toBe('/alert/notice?source=manual');
+ }, 30_000);
+
+ it('explains notification lane actions before operators create or delete delivery paths', async () => {
+ const t = createTranslatorMock({ locale: 'zh-CN' });
+ const source = readFileSync(resolve(process.cwd(), 'app/alert/notice/alert-notice-page.tsx'), 'utf8');
+ const receiverHtml = await renderAlertNoticePage();
+ const ruleHtml = await renderAlertNoticePage({
+ signal: 'logs',
+ signalContext: {
+ serviceName: 'checkout',
+ environment: 'prod'
+ }
+ });
+ mockState.currentSearchParams = 'tab=template';
+ const templateHtml = await renderAlertNoticePage();
+
+ expect(source).toContain('function alertNoticeActionHelp');
+ expect(source).toContain('function AlertNoticeActionHelp');
+ for (const id of [
+ 'receiver-refresh',
+ 'receiver-new',
+ 'receiver-delete',
+ 'receiver-row-edit',
+ 'receiver-row-delete',
+ 'receiver-cancel',
+ 'receiver-test',
+ 'receiver-save',
+ 'rule-refresh',
+ 'rule-new',
+ 'rule-delete',
+ 'rule-row-filter-all',
+ 'rule-row-enable',
+ 'rule-row-edit',
+ 'rule-row-delete',
+ 'rule-cancel',
+ 'rule-save',
+ 'template-refresh',
+ 'template-new',
+ 'template-delete',
+ 'template-row-view',
+ 'template-row-edit',
+ 'template-row-delete',
+ 'template-save'
+ ]) {
+ expect(source).toContain(`id="${id}"`);
+ expect(source).toContain(`alertNoticeActionHelp(t, '${id}')`);
+ }
+
+ expect(receiverHtml).toContain('data-alert-notice-action-help="receiver-refresh"');
+ expect(receiverHtml).toContain('data-alert-notice-command-action="receiver-refresh"');
+ expect(receiverHtml).toContain('data-alert-notice-action-help="receiver-new"');
+ expect(receiverHtml).toContain('data-alert-notice-command-action="receiver-new"');
+ expect(receiverHtml).toContain('data-alert-notice-action-help="receiver-delete"');
+ expect(receiverHtml).toContain('data-alert-notice-command-action="receiver-delete"');
+ expect(receiverHtml).toContain('data-alert-notice-action-help="receiver-row-edit"');
+ expect(receiverHtml).toContain('data-alert-notice-command-action="receiver-row-edit"');
+ expect(receiverHtml).toContain('data-alert-notice-action-help="receiver-row-delete"');
+ expect(receiverHtml).toContain('data-alert-notice-command-action="receiver-row-delete"');
+ expect(receiverHtml).toContain('data-alert-notice-action-help-trigger="hertzbeat-ui-action-help"');
+ expect(receiverHtml).toContain('data-alert-notice-action-help-style="icon-after-action"');
+ expect(receiverHtml).toContain('data-alert-notice-action-help-visual="circle-help-icon"');
+ expect(receiverHtml).toContain('data-alert-notice-action-help-icon="lucide-circle-help"');
+ expect(receiverHtml).toContain('lucide-circle-help');
+ expect(receiverHtml).toContain('data-alert-notice-action-help-tooltip="receiver-delete"');
+ expect(receiverHtml).not.toContain('<span aria-hidden="true">?</span>');
+ expect(receiverHtml).toContain(t('alert.notice.action.receiver-new.help'));
+ expect(receiverHtml).toContain(t('alert.notice.action.receiver-delete.impact'));
+ expect(receiverHtml).toContain(t('alert.notice.action.receiver-row-edit.help'));
+ expect(receiverHtml).toContain(t('alert.notice.action.receiver-row-delete.impact'));
+
+ expect(ruleHtml).toContain('data-selected-tab="rule"');
+ expect(ruleHtml).toContain('data-alert-notice-rule-table-actions="sticky-visible-actions"');
+ expect(ruleHtml).toContain('data-alert-notice-action-help="rule-refresh"');
+ expect(ruleHtml).toContain('data-alert-notice-command-action="rule-refresh"');
+ expect(ruleHtml).toContain('data-alert-notice-action-help="rule-new"');
+ expect(ruleHtml).toContain('data-alert-notice-command-action="rule-new"');
+ expect(ruleHtml).toContain('data-alert-notice-action-help="rule-delete"');
+ expect(ruleHtml).toContain('data-alert-notice-command-action="rule-delete"');
+ expect(ruleHtml).toContain('data-alert-notice-action-help="rule-row-filter-all"');
+ expect(ruleHtml).toContain('data-alert-notice-command-action="rule-row-filter-all"');
+ expect(ruleHtml).toContain('data-alert-notice-action-help="rule-row-enable"');
+ expect(ruleHtml).toContain('data-alert-notice-command-action="rule-row-enable"');
+ expect(ruleHtml).toContain('data-alert-notice-action-help="rule-row-edit"');
+ expect(ruleHtml).toContain('data-alert-notice-command-action="rule-row-edit"');
+ expect(ruleHtml).toContain('data-alert-notice-action-help="rule-row-delete"');
+ expect(ruleHtml).toContain('data-alert-notice-command-action="rule-row-delete"');
+ expect(ruleHtml).toContain(t('alert.notice.action.rule-new.impact'));
+ expect(ruleHtml).toContain(t('alert.notice.action.rule-row-enable.help'));
+
+ expect(templateHtml).toContain('data-selected-tab="template"');
+ expect(templateHtml).toContain('data-alert-notice-template-table-actions="sticky-visible-actions"');
+ expect(templateHtml).toContain('data-alert-notice-action-help="template-refresh"');
+ expect(templateHtml).toContain('data-alert-notice-command-action="template-refresh"');
+ expect(templateHtml).toContain('data-alert-notice-action-help="template-new"');
+ expect(templateHtml).toContain('data-alert-notice-command-action="template-new"');
+ expect(templateHtml).toContain('data-alert-notice-action-help="template-delete"');
+ expect(templateHtml).toContain('data-alert-notice-command-action="template-delete"');
+ expect(templateHtml).toContain('data-alert-notice-command-action="template-filter-preset"');
+ expect(templateHtml).toContain('data-alert-notice-command-action="template-row-view"');
+ expect(templateHtml).toContain('data-alert-notice-command-action="template-row-edit"');
+ expect(templateHtml).toContain('data-alert-notice-command-action="template-row-delete"');
+
+ expect(source).toContain('data-alert-notice-action-help={id}');
+ expect(source).toContain('data-alert-notice-command-action={commandAction}');
+ expect(source).toContain('CircleHelp');
+ expect(source).toContain('data-alert-notice-action-help-style="icon-after-action"');
+ expect(source).toContain('data-alert-notice-action-help-visual="circle-help-icon"');
+ expect(source).toContain('data-alert-notice-action-help-icon="lucide-circle-help"');
+ expect(source).toContain('data-alert-notice-action-help-tooltip={id}');
+ expect(source).toContain("alertNoticeActionHelp(t, 'receiver-cancel')");
+ expect(source).toContain('data-alert-notice-command-action="receiver-cancel"');
+ expect(source).toContain("alertNoticeActionHelp(t, 'receiver-test')");
+ expect(source).toContain('data-alert-notice-command-action="receiver-test"');
+ expect(source).toContain("alertNoticeActionHelp(t, 'receiver-save')");
+ expect(source).toContain('data-alert-notice-command-action="receiver-save"');
+ expect(source).toContain("alertNoticeActionHelp(t, 'rule-cancel')");
+ expect(source).toContain('data-alert-notice-command-action="rule-cancel"');
+ expect(source).toContain("alertNoticeActionHelp(t, 'rule-save')");
+ expect(source).toContain('data-alert-notice-command-action="rule-save"');
+ expect(source).toContain('data-alert-notice-command-action="rule-return-to-evidence"');
+ expect(source).toContain("templateReadOnly ? 'template-return' : 'template-cancel'");
+ expect(source).toContain("data-alert-notice-command-action={templateReadOnly ? 'template-return' : 'template-cancel'}");
+ expect(source).toContain("alertNoticeActionHelp(t, templateReadOnly ? 'template-return' : 'template-cancel')");
+ expect(source).toContain("alertNoticeActionHelp(t, 'template-save')");
+ expect(source).toContain('data-alert-notice-command-action="template-save"');
+ expect(source).not.toContain('data-alert-notice-action-help-style="literal-question-after-action"');
+ expect(source).not.toContain('data-alert-notice-action-help-visual="borderless-question"');
+ expect(source).not.toContain('<span aria-hidden="true">?</span>');
+ expect(source).toContain('id="template-refresh"');
+ expect(source).toContain('id="template-new"');
+ expect(source).toContain('id="template-delete"');
+ expect(source).toContain('id="template-row-view"');
+ expect(source).toContain('id="template-row-edit"');
+ expect(source).toContain('id="template-row-delete"');
}, 30_000);
it('renders missing receiver table settings with the localized empty fallback', async () => {
@@ -613,10 +891,19 @@ describe('alert notice page', () => {
expect(source).toContain('data-alert-notice-rule-toolbar-layout="compact-inline-actions-query"');
expect(source).toContain('data-alert-notice-rule-search-submit="angular-enter-and-clear"');
expect(source).toContain('data-alert-notice-rule-search-submit-owner="hertzbeat-ui-search-row"');
+ expect(source).toContain('data-alert-notice-rule-empty-action={prefix === \'rule\' ? \'new\' : undefined}');
+ expect(source).toContain('w-[min(560px,70vw)]');
+ expect(source).toContain("label: t('alert.notice.rule.new')");
+ expect(source).toContain('onClick: () => void handleNewRule()');
expect(source).toContain('onSearch={commitRuleSearch}');
expect(source).toContain('onClear={ruleSearchDraft || ruleSearch ? resetRuleSearch : undefined}');
expect(source).toContain('setRulePageIndex(0);');
expect(source).toContain('data-alert-notice-rule-table-shell="hertzbeat-ui-dense-table"');
+ expect(source).toContain('data-alert-notice-rule-table-actions="sticky-visible-actions"');
+ expect(source).toContain('const coldStickyActionHeaderClass =');
+ expect(source).toContain('const coldStickyActionCellClass =');
+ expect(source).toContain("<th className={coldStickyActionHeaderClass}>{t('common.edit')}</th>");
+ expect(source).toContain('<td className={coldStickyActionCellClass} onClick={event => event.stopPropagation()}>');
expect(source).toContain('function NoticeTableSwitch');
expect(source).toContain('role="switch"');
expect(source).toContain('data-alert-notice-rule-table-switch={field}');
@@ -633,7 +920,8 @@ describe('alert notice page', () => {
expect(source).toContain('const ruleId = rule?.id;');
expect(source).toContain('const detail = await api.alertNotice.rules.detail(ruleId);');
expect(source).toContain('setSelectedRuleId(ruleId);');
- expect(source).toContain('setRuleDraft(buildNoticeRuleDraft(detail));');
+ expect(source).toContain('const nextDraft = buildNoticeRuleDraft(detail);');
+ expect(source).toContain('setRuleDraft(nextDraft);');
expect(source).toContain('onClick={() => void handleEditRule(rule)}');
expect(source).toContain('field="filter-all"');
expect(source).toContain('field="enable"');
@@ -774,6 +1062,9 @@ describe('alert notice page', () => {
expect(source).toContain('data-alert-notice-template-toolbar="hertzbeat-ui-query-toolbar"');
expect(source).toContain('data-alert-notice-template-toolbar-layout="compact-inline-actions-query"');
expect(source).toContain('data-alert-notice-template-preset-filter="hertzbeat-ui-select"');
+ expect(source).toContain('data-alert-notice-template-empty-action={prefix === \'template\' ? \'new\' : undefined}');
+ expect(source).toContain("label: t('alert.notice.template.new')");
+ expect(source).toContain('onClick: () => void handleNewTemplate()');
expect(source).toContain('onSearch={commitTemplateSearch}');
expect(source).toContain('onClear={templateSearchDraft || templateSearch ? resetTemplateSearch : undefined}');
expect(source).toContain('setTemplatePageIndex(0);');
@@ -869,7 +1160,8 @@ describe('alert notice page', () => {
expect(source).toContain('data-alert-notice-rule-edit-detail-owner="route-detail-fetch-contract"');
expect(source).toContain('onClick={() => void handleEditRule(rule)}');
expect(handleEditRuleSource).toContain('const detail = await api.alertNotice.rules.detail(ruleId)');
- expect(handleEditRuleSource).toContain('setRuleDraft(buildNoticeRuleDraft(detail))');
+ expect(handleEditRuleSource).toContain('const nextDraft = buildNoticeRuleDraft(detail)');
+ expect(handleEditRuleSource).toContain('setRuleDraft(nextDraft)');
expect(handleEditRuleSource).not.toContain('buildNoticeRuleDraft(rule)');
});
@@ -901,6 +1193,18 @@ describe('alert notice page', () => {
expect(handleSaveRuleSource).not.toContain("setRuleError(error instanceof Error ? error.message : t(isEdit ? 'common.notify.edit-fail' : 'common.notify.new-fail'))");
});
+ it('clears notice-rule authoring feedback when the draft changes', () => {
+ const source = readFileSync(resolve(process.cwd(), 'app/alert/notice/alert-notice-page.tsx'), 'utf8');
+ const handleRuleDraftChangeSource = source.slice(source.indexOf('function handleRuleDraftChange('), source.indexOf('async function handleSaveRule()'));
+
+ expect(handleRuleDraftChangeSource).toContain('setRuleDraft(nextDraft);');
+ expect(handleRuleDraftChangeSource).toContain('setRuleMessage(null);');
+ expect(handleRuleDraftChangeSource).toContain('setRuleError(null);');
+ expect(handleRuleDraftChangeSource).toContain('setRuleErrorDetail(null);');
+ expect(source).toContain('onDraftChange={handleRuleDraftChange}');
+ expect(source).not.toContain('onDraftChange={setRuleDraft}');
+ });
+
it('keeps Angular notice-template save failure title separate from backend detail', () => {
const source = readFileSync(resolve(process.cwd(), 'app/alert/notice/alert-notice-page.tsx'), 'utf8');
... 72101 lines suppressed ...
---------------------------------------------------------------------
To unsubscribe, e-mail: notifications-unsubscribe@hertzbeat.apache.org
For additional commands, e-mail: notifications-help@hertzbeat.apache.org