From 724c602bf96dd4ddb1635365137d83df1873b157 Mon Sep 17 00:00:00 2001 From: chenguo Date: Fri, 14 Aug 2026 16:10:39 +0800 Subject: [PATCH 1/7] =?UTF-8?q?feat:=20=E4=BC=98=E5=8C=96=E6=96=B0?= =?UTF-8?q?=E7=89=88=E4=B8=BB=E6=9C=BA=E9=A1=B5=E7=BA=A7=E6=8C=87=E6=A0=87?= =?UTF-8?q?=E6=9F=A5=E8=AF=A2=E4=B8=8E=E5=93=8D=E5=BA=94=E4=BC=A0=E8=BE=93?= =?UTF-8?q?=20--story=3D137152595=20(#11983)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit close #1010158081137152595 --- bkmonitor/api/cmdb/default.py | 6 ++ .../packages/monitor_web/cc/resources/cmdb.py | 15 +++-- .../monitor_web/performance/resources.py | 21 ++++++- .../packages/monitor_web/performance/views.py | 3 +- .../performance/test_search_host_metric.py | 62 ++++++++++++++++++- .../packages/monitor_web/tests/test_cc.py | 32 ++++++++++ 6 files changed, 132 insertions(+), 7 deletions(-) diff --git a/bkmonitor/api/cmdb/default.py b/bkmonitor/api/cmdb/default.py index 5d1fc4f957..95cee3e8db 100644 --- a/bkmonitor/api/cmdb/default.py +++ b/bkmonitor/api/cmdb/default.py @@ -673,6 +673,7 @@ class GetProcess(Resource): class RequestSerializer(serializers.Serializer): bk_biz_id = serializers.IntegerField(label="业务ID") bk_host_id = serializers.IntegerField(label="主机ID", required=False, allow_null=True) + bk_host_ids = serializers.ListField(label="主机ID列表", child=serializers.IntegerField(), required=False) include_multiple_bind_info = serializers.BooleanField( required=False, label="是否返回多个绑定信息", default=False ) @@ -685,6 +686,11 @@ def perform_request(self, validated_request_data): if validated_request_data.get("bk_host_id"): params["bk_host_id"] = validated_request_data["bk_host_id"] response_data = batch_request(client.list_service_instance_detail, params, limit=500) + elif validated_request_data.get("bk_host_ids") is not None: + if not validated_request_data["bk_host_ids"]: + return [] + params["bk_host_list"] = validated_request_data["bk_host_ids"] + response_data = batch_request(client.list_service_instance_detail, params, limit=500) else: response_data = get_service_instance_by_biz(validated_request_data["bk_biz_id"]) diff --git a/bkmonitor/packages/monitor_web/cc/resources/cmdb.py b/bkmonitor/packages/monitor_web/cc/resources/cmdb.py index f536d5c720..c5d21d620b 100644 --- a/bkmonitor/packages/monitor_web/cc/resources/cmdb.py +++ b/bkmonitor/packages/monitor_web/cc/resources/cmdb.py @@ -202,6 +202,7 @@ def get_process_info( start_time: int = None, end_time: int = None, fail_on_incomplete: bool = False, + filter_by_hosts: bool = False, ) -> dict[int, list[dict]]: """ :summary 通过主机ID列表获取主机进程信息 @@ -211,6 +212,7 @@ def get_process_info( :param start_time: 查询起始时间(秒级 Unix 时间戳,可选),用于限定进程存活状态的判定窗口 :param end_time: 查询结束时间(秒级 Unix 时间戳,可选)。不传时退化为默认"最近三分钟"。 :param fail_on_incomplete: UQ 返回部分结果时是否抛出异常。默认保持历史降级行为。 + :param filter_by_hosts: 多主机查询时是否将主机列表下推至 CMDB。默认保持全业务缓存查询行为。 :return: 以 bk_host_id 为 key 的进程信息字典,value 为该主机下的进程实例列表 e.g.: { @@ -233,13 +235,18 @@ def get_process_info( """ pp_info = defaultdict(list) - # 如果只有一台机器,可以直接使用bk_host_id参数进行检索 - bk_host_id = None + if not hosts: + return pp_info + + # 单主机沿用旧参数;页级多主机查询显式下推,其他调用保持全业务缓存查询行为。 + process_query = {"bk_biz_id": bk_biz_id} if len(hosts) == 1: - bk_host_id = hosts[0].bk_host_id + process_query["bk_host_id"] = hosts[0].bk_host_id + elif filter_by_hosts: + process_query["bk_host_ids"] = [host.bk_host_id for host in hosts] # 查询进程信息 - result = api.cmdb.get_process(bk_biz_id=bk_biz_id, bk_host_id=bk_host_id) + result = api.cmdb.get_process(**process_query) # 查询进程状态数据 statuses: dict[int, dict[str, int]] = get_process_status( diff --git a/bkmonitor/packages/monitor_web/performance/resources.py b/bkmonitor/packages/monitor_web/performance/resources.py index 1fa07a94e6..8ba430fa7e 100644 --- a/bkmonitor/packages/monitor_web/performance/resources.py +++ b/bkmonitor/packages/monitor_web/performance/resources.py @@ -391,9 +391,13 @@ class SearchHostMetricResource(ApiAuthResource): 查询指定主机的agent及指标信息 """ + PAGE_QUERY_MODE = "page" + PAGE_HOST_LIMIT = 100 + class RequestSerializer(serializers.Serializer): bk_host_ids = serializers.ListField(label="主机ID", child=serializers.IntegerField()) bk_biz_id = serializers.IntegerField(label="业务ID") + query_mode = serializers.ChoiceField(choices=("full", "page"), default="full") bk_host_id = serializers.IntegerField(required=False, label="分享主机ID") bk_obj_id = serializers.CharField(required=False, label="分享拓扑对象ID") bk_inst_id = serializers.IntegerField(required=False, label="分享拓扑实例ID") @@ -406,6 +410,16 @@ class RequestSerializer(serializers.Serializer): def validate_bk_biz_id(self, value): return validate_bk_biz_id(value) + def validate(self, attrs): + if ( + attrs["query_mode"] == SearchHostMetricResource.PAGE_QUERY_MODE + and len(attrs["bk_host_ids"]) > SearchHostMetricResource.PAGE_HOST_LIMIT + ): + raise serializers.ValidationError( + {"bk_host_ids": f"page query supports at most {SearchHostMetricResource.PAGE_HOST_LIMIT} hosts"} + ) + return attrs + @staticmethod def validate_scope_host_ids(params): requested_host_ids = set(params["bk_host_ids"]) @@ -489,6 +503,7 @@ def get_process_status( start_time: int = None, end_time: int = None, fail_on_incomplete: bool = False, + filter_by_hosts: bool = False, ): """ 获取进程信息 @@ -503,6 +518,7 @@ def get_process_status( start_time=start_time, end_time=end_time, fail_on_incomplete=fail_on_incomplete, + filter_by_hosts=filter_by_hosts, ) for bk_host_id in result: if bk_host_id not in data: @@ -564,7 +580,10 @@ def perform_request(self, params): futures = { "agent_status": pool.apply_async(self.get_agent_status, args=(*task_args, True)), "performance_data": pool.apply_async(self.get_performance_data, args=(*task_args, True)), - "process_status": pool.apply_async(self.get_process_status, args=(*task_args, True)), + "process_status": pool.apply_async( + self.get_process_status, + args=(*task_args, True, params.get("query_mode") == self.PAGE_QUERY_MODE), + ), "alarm_count": pool.apply_async(self.get_alarm_count, args=task_args), } pool.close() diff --git a/bkmonitor/packages/monitor_web/performance/views.py b/bkmonitor/packages/monitor_web/performance/views.py index 82d0291510..bfd0f8f2a9 100644 --- a/bkmonitor/packages/monitor_web/performance/views.py +++ b/bkmonitor/packages/monitor_web/performance/views.py @@ -8,6 +8,7 @@ an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. """ + from bkmonitor.iam import ActionEnum from bkmonitor.iam.drf import BusinessActionPermission from core.drf_resource import resource @@ -70,5 +71,5 @@ class SearchHostMetricViewSet(PermissionMixin, ResourceViewSet): """ resource_routes = [ - ResourceRoute("POST", resource.performance.search_host_metric), + ResourceRoute("POST", resource.performance.search_host_metric, content_encoding="gzip"), ] diff --git a/bkmonitor/packages/monitor_web/tests/performance/test_search_host_metric.py b/bkmonitor/packages/monitor_web/tests/performance/test_search_host_metric.py index 79cae349f4..4165cae966 100644 --- a/bkmonitor/packages/monitor_web/tests/performance/test_search_host_metric.py +++ b/bkmonitor/packages/monitor_web/tests/performance/test_search_host_metric.py @@ -3,9 +3,10 @@ import pytest from api.cmdb.mock import HOSTS -from core.drf_resource import resource +from core.drf_resource import api, resource from core.drf_resource.exceptions import CustomException from monitor_web.performance.resources import SearchHostMetricResource +from monitor_web.performance.views import SearchHostMetricViewSet def mock_other_sections(mocker, failed_section: str | None = None): @@ -15,6 +16,65 @@ def mock_other_sections(mocker, failed_section: str | None = None): method.side_effect = RuntimeError(f"{section} failed") +def test_search_host_metric_response_uses_gzip(): + assert SearchHostMetricViewSet.resource_routes[0].content_encoding == "gzip" + + +def test_get_process_multiple_hosts_use_cmdb_host_list(mocker): + batch_request = mocker.patch("api.cmdb.default.batch_request", return_value=[]) + + result = api.cmdb.get_process(bk_biz_id=2, bk_host_ids=[1, 2]) + + assert result == [] + assert batch_request.call_args.args[1] == {"bk_biz_id": 2, "bk_host_list": [1, 2]} + + +def test_get_process_empty_host_list_does_not_query_all_hosts(mocker): + batch_request = mocker.patch("api.cmdb.default.batch_request") + + result = api.cmdb.get_process(bk_biz_id=2, bk_host_ids=[]) + + assert result == [] + batch_request.assert_not_called() + + +def test_get_process_without_host_filter_keeps_business_cache(mocker): + batch_request = mocker.patch("api.cmdb.default.batch_request") + get_service_instance_by_biz = mocker.patch("api.cmdb.default.get_service_instance_by_biz", return_value=[]) + + result = api.cmdb.get_process(bk_biz_id=2) + + assert result == [] + get_service_instance_by_biz.assert_called_once_with(2) + batch_request.assert_not_called() + + +@pytest.mark.parametrize( + ("query_mode", "host_count", "expected_valid"), + [("page", 100, True), ("page", 101, False), ("full", 101, True)], +) +def test_search_host_metric_page_mode_limits_one_page(query_mode, host_count, expected_valid): + serializer = SearchHostMetricResource.RequestSerializer( + data={"bk_biz_id": 2, "bk_host_ids": list(range(host_count)), "query_mode": query_mode} + ) + + assert serializer.is_valid() is expected_valid + assert ("bk_host_ids" in serializer.errors) is (not expected_valid) + + +@pytest.mark.parametrize(("query_params", "expected_filter"), [({}, False), ({"query_mode": "page"}, True)]) +def test_search_host_metric_mode_controls_process_cmdb_host_filter(mocker, query_params, expected_filter): + mocker.patch("monitor_web.performance.resources.api.cmdb.get_host_by_id", return_value=HOSTS[:2]) + mock_other_sections(mocker) + + SearchHostMetricResource().perform_request( + {"bk_biz_id": 2, "bk_host_ids": [host.bk_host_id for host in HOSTS[:2]], **query_params} + ) + + process_status = SearchHostMetricResource.get_process_status + assert process_status.call_args.args[-1] is expected_filter + + @pytest.mark.parametrize("failed_section", ["agent_status", "performance_data", "process_status", "alarm_count"]) def test_search_host_metric_surfaces_thread_failure(mocker, failed_section): mocker.patch("monitor_web.performance.resources.api.cmdb.get_host_by_id", return_value=HOSTS[:1]) diff --git a/bkmonitor/packages/monitor_web/tests/test_cc.py b/bkmonitor/packages/monitor_web/tests/test_cc.py index acff664b4e..bed032b214 100644 --- a/bkmonitor/packages/monitor_web/tests/test_cc.py +++ b/bkmonitor/packages/monitor_web/tests/test_cc.py @@ -470,6 +470,38 @@ def test_query_filters_are_compiled_for_requested_hosts( } +class TestGetProcessInfo: + def test_multiple_hosts_query_cmdb_by_host_list(self, mocker): + get_process = mocker.patch("monitor_web.cc.resources.cmdb.api.cmdb.get_process", return_value=[]) + mocker.patch("monitor_web.cc.resources.cmdb.get_process_status", return_value={}) + + result = resource.cc.get_process_info(bk_biz_id=2, hosts=HOSTS[:2], filter_by_hosts=True) + + assert result == {} + get_process.assert_called_once_with( + bk_biz_id=2, + bk_host_ids=[HOSTS[0].bk_host_id, HOSTS[1].bk_host_id], + ) + + def test_multiple_hosts_keep_full_business_query_by_default(self, mocker): + get_process = mocker.patch("monitor_web.cc.resources.cmdb.api.cmdb.get_process", return_value=[]) + mocker.patch("monitor_web.cc.resources.cmdb.get_process_status", return_value={}) + + resource.cc.get_process_info(bk_biz_id=2, hosts=HOSTS[:2]) + + get_process.assert_called_once_with(bk_biz_id=2) + + def test_empty_hosts_do_not_query_cmdb(self, mocker): + get_process = mocker.patch("monitor_web.cc.resources.cmdb.api.cmdb.get_process") + get_process_status = mocker.patch("monitor_web.cc.resources.cmdb.get_process_status") + + result = resource.cc.get_process_info(bk_biz_id=2, hosts=[]) + + assert result == {} + get_process.assert_not_called() + get_process_status.assert_not_called() + + class TestGetProcessStatus: """ 测试 resource.cc.get_process_status From 297e748b882f2e81116464e417d0636486d8aa28 Mon Sep 17 00:00:00 2001 From: chenguo Date: Fri, 14 Aug 2026 17:25:40 +0800 Subject: [PATCH 2/7] =?UTF-8?q?feat:=20=E4=BC=98=E5=8C=96=E4=B8=BB?= =?UTF-8?q?=E6=9C=BA=E5=A4=A7=E4=B8=9A=E5=8A=A1=E5=85=A8=E9=87=8F=E6=8C=87?= =?UTF-8?q?=E6=A0=87=E5=BF=AB=E7=85=A7=20--story=3D137159376=20(#11988)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit close #1010158081137159376 ## 改造范围 - 保留现有 search_host_metric 接口,新增受 scope 约束的主机指标快照创建与轮询接口。 - 使用显式共享 Redis、singleflight、按业务并发租约、不可变压缩 section blob 和专用 Celery task。 - 普通业务快照不携带全量 host_id target;告警段使用 host_id、IPv4、IPv6 三路互斥 composite 聚合和 CMDB 白名单映射。 - 每次返回数据前重新校验权限、scope、时间锚点和主机集合;分享态仅允许 create/retrieve。 ## 默认状态与启用前门禁 ENABLE_HOST_METRIC_PROGRESSIVE 默认关闭。启用前必须完成以下真实环境压测并记录结论: 1. 真实 ES composite:验证 20,000 主机业务下的分页总量、总耗时、超时和索引兼容性。 2. 真实 Redis:验证 Web 与 Celery worker 使用同一 Redis alias,以及 singleflight、容量租约、任务死亡和 Redis 异常恢复。 3. 20,000 主机内存:验证 section blob 大小、压缩耗时和 Web/worker Pod RSS 硬上限。 4. 50 客户端轮询:验证 data-bearing/READY 轮询中的 CMDB scope 解析、host hash 和 gzip 开销。 任何门禁未通过均保持开关关闭,并继续使用旧的分页指标链路。 ## 关联 PR PR-C #11984 负责前端渐进式接线,依赖本 PR 的快照协议。两者均以 acceptance/host-metric-progressive-20260814 为 base,需在验收分支组合回归后再统一面向 master 交付;本 PR 不移除旧接口。 --- bkmonitor/bkmonitor/models/token.py | 1 + bkmonitor/config/default.py | 2 + .../packages/common/context_processors.py | 2 + .../common/tests/test_context_processors.py | 11 + .../packages/monitor_web/cc/resources/cmdb.py | 281 +++- .../monitor_web/performance/resources.py | 228 ++- .../monitor_web/performance/snapshot.py | 574 +++++++ .../packages/monitor_web/performance/tasks.py | 189 +++ .../packages/monitor_web/performance/views.py | 31 +- bkmonitor/packages/monitor_web/tasks.py | 4 + .../performance/test_host_metric_snapshot.py | 1390 +++++++++++++++++ .../performance/test_search_host_info.py | 30 + .../performance/test_search_host_metric.py | 270 ++++ .../tests/share/test_share_security.py | 5 + 14 files changed, 2968 insertions(+), 50 deletions(-) create mode 100644 bkmonitor/packages/monitor_web/performance/snapshot.py create mode 100644 bkmonitor/packages/monitor_web/performance/tasks.py create mode 100644 bkmonitor/packages/monitor_web/tests/performance/test_host_metric_snapshot.py diff --git a/bkmonitor/bkmonitor/models/token.py b/bkmonitor/bkmonitor/models/token.py index 0f8479dffe..24a6ca4954 100644 --- a/bkmonitor/bkmonitor/models/token.py +++ b/bkmonitor/bkmonitor/models/token.py @@ -87,6 +87,7 @@ class AuthType: "monitor_web.grafana.views.GrafanaViewSet": {"time_series/unify_query"}, "monitor_web.performance.views.SearchHostInfoViewSet": {"create"}, "monitor_web.performance.views.SearchHostMetricViewSet": {"create"}, + "monitor_web.performance.views.HostMetricSnapshotViewSet": {"create", "retrieve"}, "monitor_web.scene_view.views.SceneViewViewSet": { "get_host_metric_group_panel_order", "get_host_or_topo_node_detail", diff --git a/bkmonitor/config/default.py b/bkmonitor/config/default.py index 0746d0cc2b..7f455dd33a 100644 --- a/bkmonitor/config/default.py +++ b/bkmonitor/config/default.py @@ -989,6 +989,8 @@ # IS_ACCESS_BK_DATA = os.getenv("BKAPP_IS_ACCESS_BK_DATA", "") == "true" # 是否接入计算平台 IS_ENABLE_VIEW_CMDB_LEVEL = False # 是否开启前端视图部分的CMDB预聚合 +ENABLE_HOST_METRIC_PROGRESSIVE = os.getenv("ENABLE_HOST_METRIC_PROGRESSIVE", "false").lower() == "true" +HOST_METRIC_SNAPSHOT_MAX_CONCURRENT_PER_BIZ = max(1, int(os.getenv("HOST_METRIC_SNAPSHOT_MAX_CONCURRENT_PER_BIZ", 1))) IS_MIGRATE_AIOPS_STRATEGY = False # 数据接入相关 diff --git a/bkmonitor/packages/common/context_processors.py b/bkmonitor/packages/common/context_processors.py index 6c992c6b64..40fe806a34 100644 --- a/bkmonitor/packages/common/context_processors.py +++ b/bkmonitor/packages/common/context_processors.py @@ -198,6 +198,8 @@ def get_basic_context(request, space_list: list[dict[str, Any]], bk_biz_id: int) "GRAPH_WATERMARK": settings.GRAPH_WATERMARK, # 是否开启前端视图部分,按拓扑聚合的能力。(不包含对监控策略部分的功能) "ENABLE_CMDB_LEVEL": settings.IS_ACCESS_BK_DATA and settings.IS_ENABLE_VIEW_CMDB_LEVEL, + # 是否开启主机列表指标渐进式加载 + "ENABLE_HOST_METRIC_PROGRESSIVE": settings.ENABLE_HOST_METRIC_PROGRESSIVE, # 事件中心一键拉取功能展示 "ENABLE_CREATE_CHAT_GROUP": settings.ENABLE_CREATE_CHAT_GROUP, # 用于全局设置蓝鲸监控机器人发送图片是否开启 diff --git a/bkmonitor/packages/common/tests/test_context_processors.py b/bkmonitor/packages/common/tests/test_context_processors.py index 7930c777d1..6b50d9b976 100644 --- a/bkmonitor/packages/common/tests/test_context_processors.py +++ b/bkmonitor/packages/common/tests/test_context_processors.py @@ -99,3 +99,14 @@ def test_get_basic_context_disables_ai_assistant_by_environment_variable(): context = get_basic_context(make_request(), [{"bk_biz_id": 2}], 2) assert context["ENABLE_AI_ASSISTANT"] == "false" + + +@override_settings(ENABLE_HOST_METRIC_PROGRESSIVE=True) +def test_get_basic_context_exposes_host_metric_progressive_switch(): + with ( + mock.patch("common.context_processors.get_core_context", return_value={}), + mock.patch("common.context_processors.is_ipv6_biz", return_value=False), + ): + context = get_basic_context(make_request(), [{"bk_biz_id": 2}], 2) + + assert context["ENABLE_HOST_METRIC_PROGRESSIVE"] is True diff --git a/bkmonitor/packages/monitor_web/cc/resources/cmdb.py b/bkmonitor/packages/monitor_web/cc/resources/cmdb.py index c5d21d620b..9fd4d16ac2 100644 --- a/bkmonitor/packages/monitor_web/cc/resources/cmdb.py +++ b/bkmonitor/packages/monitor_web/cc/resources/cmdb.py @@ -24,10 +24,14 @@ from constants.data_source import DataSourceLabel, DataTypeLabel from constants.strategy import HOST_SCENARIO, TargetFieldType from core.drf_resource import api +from elasticsearch_dsl import Q from monitor.constants import AGENT_STATUS logger = logging.getLogger(__name__) +HOST_ALARM_COMPOSITE_PAGE_SIZE = 1000 +HOST_ALARM_COMPOSITE_AGGREGATION = "host_alarm_identity" + def topo_tree(bk_biz_id): # api.cmdb.get_topo_tree 已开启 API 缓存并由 alarm_backends.core.api_cache.library.cmdb_api_list 每分钟刷新 @@ -65,6 +69,7 @@ def get_agent_status( start_time: int = None, end_time: int = None, fail_on_incomplete: bool = False, + target_filter: dict | None = None, ) -> dict[int, int]: """ :summary 获取主机Agent状态及数据状态 @@ -74,6 +79,7 @@ def get_agent_status( 是否有数据上报来判定 Agent 状态,跳过 node_man 实时查询(历史场景无意义)。 :param end_time: 查询结束时间(秒级 Unix 时间戳,可选)。不传或仅传一个时退化为默认"最近三分钟"实时查询。 :param fail_on_incomplete: UQ 返回部分结果时是否抛出异常。默认保持历史降级行为。 + :param target_filter: UQ 目标过滤条件。None 沿用按 hosts 构造的默认条件;{} 仅供服务端可信的全业务查询。 :return {bk_host_id: AGENT_STATUS} """ if not hosts: @@ -95,7 +101,7 @@ def get_agent_status( metrics=[{"field": "usage", "method": "AVG", "alias": "A"}], table="system.cpu_summary", group_by=["bk_host_id", "bk_target_ip", "bk_target_cloud_id"], - filter_dict=_build_host_target_filter(bk_biz_id, hosts), + filter_dict=_build_host_target_filter(bk_biz_id, hosts) if target_filter is None else target_filter, ) query = UnifyQuery(data_sources=[data_source], bk_biz_id=bk_biz_id, expression="a") if is_historical: @@ -158,11 +164,16 @@ def get_agent_status( pool.close() pool.join() result = [] + node_man_failed = False for future in futures: try: result.extend(future.get()) except Exception as e: logger.error("get_agent_status error: %s", e) + node_man_failed = True + + if fail_on_incomplete and node_man_failed: + raise RuntimeError("node manager returned incomplete agent status") for info in result: host_id = info["host_id"] @@ -203,6 +214,7 @@ def get_process_info( end_time: int = None, fail_on_incomplete: bool = False, filter_by_hosts: bool = False, + target_filter: dict | None = None, ) -> dict[int, list[dict]]: """ :summary 通过主机ID列表获取主机进程信息 @@ -213,6 +225,7 @@ def get_process_info( :param end_time: 查询结束时间(秒级 Unix 时间戳,可选)。不传时退化为默认"最近三分钟"。 :param fail_on_incomplete: UQ 返回部分结果时是否抛出异常。默认保持历史降级行为。 :param filter_by_hosts: 多主机查询时是否将主机列表下推至 CMDB。默认保持全业务缓存查询行为。 + :param target_filter: 进程状态 UQ 目标过滤条件。None 沿用默认条件;{} 仅供服务端可信的全业务查询。 :return: 以 bk_host_id 为 key 的进程信息字典,value 为该主机下的进程实例列表 e.g.: { @@ -250,7 +263,12 @@ def get_process_info( # 查询进程状态数据 statuses: dict[int, dict[str, int]] = get_process_status( - bk_biz_id, hosts, start_time, end_time, fail_on_incomplete=fail_on_incomplete + bk_biz_id, + hosts, + start_time, + end_time, + fail_on_incomplete=fail_on_incomplete, + target_filter=target_filter, ) bk_host_ids = {host.bk_host_id for host in hosts} @@ -293,6 +311,7 @@ def get_process_status( start_time: int = None, end_time: int = None, fail_on_incomplete: bool = False, + target_filter: dict | None = None, ) -> dict[int, dict[str, int]]: """ 查询进程状态,1为存活 @@ -302,6 +321,7 @@ def get_process_status( :param start_time: 查询起始时间(秒级 Unix 时间戳,可选) :param end_time: 查询结束时间(秒级 Unix 时间戳,可选)。不传时退化为默认"最近三分钟"。 :param fail_on_incomplete: UQ 返回部分结果时是否抛出异常。默认保持历史降级行为。 + :param target_filter: UQ 目标过滤条件。None 沿用按 hosts 构造的默认条件;{} 仅供服务端可信的全业务查询。 """ result = defaultdict(dict) for bk_host_id, display_name, value in _query_proc_metrics( @@ -313,6 +333,7 @@ def get_process_status( start_time, end_time, fail_on_incomplete=fail_on_incomplete, + target_filter=target_filter, ): result[bk_host_id][display_name] = AGENT_STATUS.ON if value else AGENT_STATUS.OFF return result @@ -327,6 +348,7 @@ def _query_proc_metrics( start_time: int = None, end_time: int = None, fail_on_incomplete: bool = False, + target_filter: dict | None = None, ): """ 查询 system.proc / system.proc_port 指标的公共生成器。 @@ -342,6 +364,7 @@ def _query_proc_metrics( :param start_time: 查询起始时间(秒级 Unix 时间戳,可选) :param end_time: 查询结束时间(秒级 Unix 时间戳,可选) :param fail_on_incomplete: UQ 返回部分结果时是否抛出异常 + :param target_filter: UQ 目标过滤条件。None 沿用按 hosts 构造的默认条件;{} 表示不附加目标条件。 :return: 生成 (bk_host_id, display_name, value) 元组,仅包含成功匹配的记录 """ ip_to_host_id = {(host.bk_host_innerip, int(host.bk_cloud_id or 0)): host.bk_host_id for host in hosts} @@ -354,7 +377,7 @@ def _query_proc_metrics( metrics=[{"field": field, "method": method, "alias": "A"}], table=table, group_by=["bk_host_id", "bk_target_ip", "bk_target_cloud_id", "display_name"], - filter_dict=_build_host_target_filter(bk_biz_id, hosts), + filter_dict=_build_host_target_filter(bk_biz_id, hosts) if target_filter is None else target_filter, ) query = UnifyQuery(data_sources=[data_source], bk_biz_id=bk_biz_id, expression="a") if start_time is not None and end_time is not None: @@ -569,6 +592,7 @@ def get_host_performance_data( start_time: int = None, end_time: int = None, fail_on_incomplete: bool = False, + target_filter: dict | None = None, ) -> dict[int, dict] | dict[tuple, dict]: """ :summary 按主机查询主机性能信息(五分钟负载/CPU使用率/磁盘空间使用率/磁盘IO使用率/应用内存使用率) @@ -578,6 +602,7 @@ def get_host_performance_data( :param start_time: 查询起始时间(秒级 Unix 时间戳,可选)。与 end_time 同时传入时约束查询区间。 :param end_time: 查询结束时间(秒级 Unix 时间戳,可选)。不传或仅传一个时退化为默认"最近三分钟"。 :param fail_on_incomplete: 查询异常或 UQ 返回部分结果时是否抛出异常。默认保持历史降级行为。 + :param target_filter: UQ 目标过滤条件。None 沿用按 hosts 构造的默认条件;{} 仅供服务端可信的全业务查询。 """ if not hosts: return {} @@ -599,7 +624,7 @@ def get_host_performance_data( # 与主机图表保持相同的目标维度:IPv4 使用 IP+云区域,IPv6 使用主机 ID。 # IPv4 身份不完整时保留全量查询,避免过滤掉只能通过 bk_host_id 回填的兼容数据。 - target_filter = _build_host_target_filter(bk_biz_id, hosts) + target_filter = _build_host_target_filter(bk_biz_id, hosts) if target_filter is None else target_filter def get_metric_data(metric): # 每个线程写入独立的临时 dict,避免多线程并发写同一 data 的竞态 @@ -831,18 +856,146 @@ def get_host_strategy_count(bk_biz_id: int, host: Host = None) -> tuple[int, int return enabled, disabled +def _build_host_alarm_search( + bk_biz_id: int, + *, + days: int, + start_time: int | None, + end_time: int | None, +): + is_historical = start_time is not None and end_time is not None + search_object = ( + AlertDocument.search( + start_time=start_time if is_historical else None, + end_time=end_time if is_historical else None, + days=None if is_historical else days, + ) + .filter("term", status=EventStatus.ABNORMAL) + .filter("term", **{"event.bk_biz_id": bk_biz_id}) + ) + if is_historical: + search_object = search_object.filter("range", begin_time={"gte": start_time, "lte": end_time}) + return search_object + + +def _non_empty_host_alarm_identity(field: str): + return Q( + "bool", + filter=[Q("exists", field=field)], + must_not=[Q("term", **{field: ""})], + ) + + +def _iter_business_host_alarm_buckets( + bk_biz_id: int, + *, + days: int, + start_time: int | None, + end_time: int | None, + identity: str, +): + source_fields = { + "bk_host_id": [{"bk_host_id": {"terms": {"field": "event.bk_host_id"}}}], + "ip": [ + {"bk_cloud_id": {"terms": {"field": "event.bk_cloud_id"}}}, + {"ip": {"terms": {"field": "event.ip"}}}, + ], + "ipv6": [ + {"bk_cloud_id": {"terms": {"field": "event.bk_cloud_id"}}}, + {"ipv6": {"terms": {"field": "event.ipv6"}}}, + ], + } + after_key = None + while True: + search_object = _build_host_alarm_search( + bk_biz_id, + days=days, + start_time=start_time, + end_time=end_time, + ) + if identity == "bk_host_id": + search_object = search_object.filter(_non_empty_host_alarm_identity("event.bk_host_id")) + elif identity == "ip": + search_object = search_object.exclude(_non_empty_host_alarm_identity("event.bk_host_id")) + search_object = search_object.filter(_non_empty_host_alarm_identity("event.ip")) + else: + search_object = search_object.exclude(_non_empty_host_alarm_identity("event.bk_host_id")) + search_object = search_object.exclude(_non_empty_host_alarm_identity("event.ip")) + search_object = search_object.filter(_non_empty_host_alarm_identity("event.ipv6")) + + composite_params = { + "size": HOST_ALARM_COMPOSITE_PAGE_SIZE, + "sources": [*source_fields[identity], {"severity": {"terms": {"field": "severity"}}}], + } + if after_key: + composite_params["after"] = after_key + search_object = search_object.extra(size=0) + search_object.aggs.bucket(HOST_ALARM_COMPOSITE_AGGREGATION, "composite", **composite_params) + aggregation = getattr(search_object.execute().aggregations, HOST_ALARM_COMPOSITE_AGGREGATION) + buckets = list(aggregation.buckets) + yield from buckets + + after_key = getattr(aggregation, "after_key", None) + if hasattr(after_key, "to_dict"): + after_key = after_key.to_dict() + if not after_key or not buckets: + break + + +def _get_business_host_alarm_count( + bk_biz_id: int, + hosts: list[Host], + ip_to_host_id: dict[tuple, int], + *, + days: int, + start_time: int | None, + end_time: int | None, +) -> dict[int, dict[int, int]]: + known_host_ids = {host.bk_host_id for host in hosts} + alarm_count_info = {host.bk_host_id: {1: 0, 2: 0, 3: 0} for host in hosts} + for identity in ("bk_host_id", "ip", "ipv6"): + for bucket in _iter_business_host_alarm_buckets( + bk_biz_id, + days=days, + start_time=start_time, + end_time=end_time, + identity=identity, + ): + key = bucket.key.to_dict() if hasattr(bucket.key, "to_dict") else bucket.key + try: + severity = int(key["severity"]) + if identity == "bk_host_id": + host_id = int(key["bk_host_id"]) + if host_id not in known_host_ids: + continue + else: + host_id = ip_to_host_id.get((key[identity], int(key["bk_cloud_id"]))) + if host_id is None: + continue + alarm_count_info[host_id][severity] += int(bucket.doc_count) + except (KeyError, TypeError, ValueError): + continue + return alarm_count_info + + # 获取主机告警事件 def get_host_alarm_count( - bk_biz_id: int, hosts: list[Host], days: int = 7, start_time: int = None, end_time: int = None + bk_biz_id: int, + hosts: list[Host], + days: int = 7, + start_time: int = None, + end_time: int = None, + filter_by_host_ip: bool = True, ) -> dict[int, dict[int, int]]: """ - 获取主机关联告警数量,当不传主机时,统计所有主机数据 - todo: 在ipv6改造后,alert需要添加bk_host_id,该函数需要额外适配 - 支持两种匹配方式(按优先级): - 1. event.ip + event.bk_cloud_id 匹配(传统主机告警) - 2. dimensions 中提取 ip + bk_cloud_id 匹配(K8s告警) - 优化:传入主机时按 event.ip 做 terms 过滤,避免全索引扫描; - 无 event.ip 的 K8s 告警(仅 dimensions 匹配)可能被遗漏,属已知权衡。 + 获取主机关联告警数量,结果只包含传入的 CMDB 主机白名单。 + 页级查询支持三种匹配方式(按优先级): + 1. event.bk_host_id + 2. event.ip / event.ipv6 + event.bk_cloud_id + 3. dimensions 中的 bk_host_id,或 IPv4 / IPv6 + bk_cloud_id + 页级查询按 event.bk_host_id / event.ip / event.ipv6 做 terms 过滤;完整业务快照使用有界 composite + 聚合,依次统计 host_id、缺 host_id 的 IPv4、再缺 IPv4 的 IPv6,避免线性 terms 和文档 scan。 + 两种模式均可能遗漏仅存在于 dimensions 的告警,属已知权衡。 :param bk_biz_id: 业务ID :param hosts: 主机列表 :param days: 查询范围(天),仅在未传 start_time/end_time 时生效 @@ -854,57 +1007,76 @@ def get_host_alarm_count( if not hosts: return {} - # 收集主机IP用于ES端过滤,避免全索引扫描 - host_ips = set() + # 收集主机IP用于ES端过滤,完整业务快照由调用方显式关闭线性 terms。 + host_ipv4s = set() + host_ipv6s = set() + ip_to_host_id = {} for host in hosts: - inner_ip = host.bk_host_innerip - if inner_ip: - host_ips.update(ip.strip() for ip in inner_ip.split(",") if ip.strip()) - - is_historical = start_time is not None and end_time is not None - search_object = ( - AlertDocument.search( - start_time=start_time if is_historical else None, - end_time=end_time if is_historical else None, - days=None if is_historical else days, + for address_field in ("bk_host_innerip", "bk_host_innerip_v6"): + addresses = getattr(host, address_field, "") or "" + for ip in (value.strip() for value in addresses.split(",")): + if not ip: + continue + (host_ipv6s if address_field == "bk_host_innerip_v6" else host_ipv4s).add(ip) + ip_to_host_id[(ip, int(host.bk_cloud_id or 0))] = host.bk_host_id + + if not filter_by_host_ip: + return _get_business_host_alarm_count( + bk_biz_id, + hosts, + ip_to_host_id, + days=days, + start_time=start_time, + end_time=end_time, ) - .filter("term", status=EventStatus.ABNORMAL) - .filter("term", **{"event.bk_biz_id": bk_biz_id}) - .source(["event.ip", "event.bk_cloud_id", "severity", "dimensions"]) - ) - if is_historical: - # 补充 ES range 过滤,按告警开始时间精确约束,避免索引按天选择带来的边界数据 - search_object = search_object.filter("range", begin_time={"gte": start_time, "lte": end_time}) - - if host_ips: - search_object = search_object.filter("terms", **{"event.ip": list(host_ips)}) - - ip_to_host_id = {(host.bk_host_innerip, int(host.bk_cloud_id or 0)): host.bk_host_id for host in hosts} + search_object = _build_host_alarm_search( + bk_biz_id, + days=days, + start_time=start_time, + end_time=end_time, + ).source(["event.bk_host_id", "event.ip", "event.ipv6", "event.bk_cloud_id", "severity", "dimensions"]) + + identity_filters = [Q("terms", **{"event.bk_host_id": sorted(str(host.bk_host_id) for host in hosts)})] + if host_ipv4s: + identity_filters.append(Q("terms", **{"event.ip": sorted(host_ipv4s)})) + if host_ipv6s: + identity_filters.append(Q("terms", **{"event.ipv6": sorted(host_ipv6s)})) + search_object = search_object.filter(Q("bool", should=identity_filters, minimum_should_match=1)) + + known_host_ids = {host.bk_host_id for host in hosts} alarm_count_info = {host.bk_host_id: {1: 0, 2: 0, 3: 0} for host in hosts} for alert in search_object.scan(): - host_id = _resolve_host_id_from_alert(alert, ip_to_host_id) + host_id = _resolve_host_id_from_alert(alert, known_host_ids, ip_to_host_id) if host_id is None: continue alarm_count_info[host_id][int(alert.severity)] += 1 return alarm_count_info -def _resolve_host_id_from_alert(alert, ip_to_host_id: dict[tuple, int]) -> int | None: +def _resolve_host_id_from_alert(alert, known_host_ids: set[int], ip_to_host_id: dict[tuple, int]) -> int | None: """ 从告警中解析出 bk_host_id,支持多种匹配方式。 :return: bk_host_id 或 None(无法匹配) """ - # 优先级1:event.ip + event.bk_cloud_id 匹配(传统主机告警) + # 优先级1:event.bk_host_id 与服务端 CMDB 白名单匹配。 + try: + host_id = int(alert.event.bk_host_id) + if host_id in known_host_ids: + return host_id + except (ValueError, TypeError, AttributeError): + pass + + # 优先级2:event.ip / event.ipv6 + event.bk_cloud_id 匹配。 try: - ip = alert.event.ip bk_cloud_id = int(alert.event.bk_cloud_id) - if ip and (ip, bk_cloud_id) in ip_to_host_id: - return ip_to_host_id[(ip, bk_cloud_id)] + for ip in (getattr(alert.event, "ip", ""), getattr(alert.event, "ipv6", "")): + if ip and (ip, bk_cloud_id) in ip_to_host_id: + return ip_to_host_id[(ip, bk_cloud_id)] except (ValueError, TypeError, AttributeError): pass - # 优先级2:从 dimensions 中提取(K8s 告警通过 KubernetesCMDBEnricher 写入) + # 优先级3:从 dimensions 中提取(K8s 告警通过 KubernetesCMDBEnricher 写入)。 try: dimensions = alert.dimensions or [] dim_map = {} @@ -914,10 +1086,25 @@ def _resolve_host_id_from_alert(alert, ip_to_host_id: dict[tuple, int]) -> int | if key and value is not None: dim_map[key] = value - # dimensions 中的 ip + bk_cloud_id 匹配 - if "ip" in dim_map and "bk_cloud_id" in dim_map: - ip = dim_map["ip"] - bk_cloud_id = int(dim_map["bk_cloud_id"]) + if "bk_host_id" in dim_map: + host_id = int(dim_map["bk_host_id"]) + if host_id in known_host_ids: + return host_id + + ip = next( + ( + dim_map[key] + for key in ("ip", "ipv6", "bk_target_ip", "bk_host_innerip", "bk_host_innerip_v6") + if key in dim_map + ), + None, + ) + cloud_id = next( + (dim_map[key] for key in ("bk_cloud_id", "bk_target_cloud_id") if key in dim_map), + None, + ) + if ip is not None and cloud_id is not None: + bk_cloud_id = int(cloud_id) if (ip, bk_cloud_id) in ip_to_host_id: return ip_to_host_id[(ip, bk_cloud_id)] except (ValueError, TypeError, AttributeError): diff --git a/bkmonitor/packages/monitor_web/performance/resources.py b/bkmonitor/packages/monitor_web/performance/resources.py index 8ba430fa7e..dc265d94b0 100644 --- a/bkmonitor/packages/monitor_web/performance/resources.py +++ b/bkmonitor/packages/monitor_web/performance/resources.py @@ -9,13 +9,17 @@ """ import logging +import time from api.cmdb.define import Host, TopoNode from bkm_space.validate import validate_bk_biz_id +from django.conf import settings from bkmonitor.share.api_auth_resource import ApiAuthResource from bkmonitor.utils import time_tools from bkmonitor.utils.cache import CacheType +from bkmonitor.utils.request import get_request, get_request_tenant_id from bkmonitor.utils.thread_backend import ThreadPool +from bkmonitor.utils.user import get_request_username from bkmonitor.views import serializers from core.drf_resource import api, resource from core.drf_resource.base import Resource @@ -23,6 +27,16 @@ from core.drf_resource.exceptions import CustomException from core.errors.share import InvalidParamsError, ParamsPermissionDeniedError from monitor_web.constants import AGENT_STATUS +from monitor_web.performance.snapshot import ( + SNAPSHOT_DEADLINE, + SNAPSHOT_SECTIONS, + HostMetricSnapshotStore, + SnapshotState, + SnapshotUnavailable, + build_host_ids_hash, + build_snapshot_fingerprint, + canonicalize_snapshot_time, +) logger = logging.getLogger(__name__) @@ -372,7 +386,9 @@ def get_hosts() -> list[Host]: "bk_cloud_id": host.bk_cloud_id, "bk_cloud_name": host.bk_cloud_name, "bk_host_innerip": host.bk_host_innerip, + "bk_host_innerip_v6": host.bk_host_innerip_v6, "bk_host_outerip": host.bk_host_outerip, + "bk_host_outerip_v6": host.bk_host_outerip_v6, "bk_os_type": host.bk_os_type, "bk_os_name": host.bk_os_name, "region": host.bk_province_name, @@ -455,6 +471,7 @@ def get_agent_status( start_time: int = None, end_time: int = None, fail_on_incomplete: bool = False, + target_filter: dict | None = None, ): """ 获取Agent状态 @@ -465,6 +482,7 @@ def get_agent_status( start_time=start_time, end_time=end_time, fail_on_incomplete=fail_on_incomplete, + target_filter=target_filter, ) for bk_host_id, status in agent_statuses.items(): if bk_host_id not in data: @@ -479,6 +497,7 @@ def get_performance_data( start_time: int = None, end_time: int = None, fail_on_incomplete: bool = False, + target_filter: dict | None = None, ): """ 获取指标信息 @@ -489,6 +508,7 @@ def get_performance_data( start_time=start_time, end_time=end_time, fail_on_incomplete=fail_on_incomplete, + target_filter=target_filter, ) for bk_host_id, metrics in result.items(): if bk_host_id not in data: @@ -504,6 +524,7 @@ def get_process_status( end_time: int = None, fail_on_incomplete: bool = False, filter_by_hosts: bool = False, + target_filter: dict | None = None, ): """ 获取进程信息 @@ -519,6 +540,7 @@ def get_process_status( end_time=end_time, fail_on_incomplete=fail_on_incomplete, filter_by_hosts=filter_by_hosts, + target_filter=target_filter, ) for bk_host_id in result: if bk_host_id not in data: @@ -539,13 +561,22 @@ def get_process_status( @staticmethod def get_alarm_count( - bk_biz_id: int, hosts: list[Host], data: dict[int, dict], start_time: int = None, end_time: int = None + bk_biz_id: int, + hosts: list[Host], + data: dict[int, dict], + start_time: int = None, + end_time: int = None, + filter_by_host_ip: bool = True, ): """ 获取告警信息 """ result = resource.cc.get_host_alarm_count( - bk_biz_id=bk_biz_id, hosts=hosts, start_time=start_time, end_time=end_time + bk_biz_id=bk_biz_id, + hosts=hosts, + start_time=start_time, + end_time=end_time, + filter_by_host_ip=filter_by_host_ip, ) for bk_host_id in result: if bk_host_id not in data: @@ -598,3 +629,196 @@ def perform_request(self, params): if failed_sections: raise CustomException("get host metric data failed", data={"failed_sections": failed_sections}) return data + + +class HostMetricSnapshotRequestSerializer(serializers.Serializer): + bk_biz_id = serializers.IntegerField(label="业务ID") + start_time = serializers.IntegerField(label="开始时间(秒级时间戳)") + end_time = serializers.IntegerField(label="结束时间(秒级时间戳)") + bk_host_id = serializers.IntegerField(required=False, label="分享主机ID") + bk_obj_id = serializers.CharField(required=False, label="分享拓扑对象ID") + bk_inst_id = serializers.IntegerField(required=False, label="分享拓扑实例ID") + + def to_internal_value(self, data): + unknown_fields = set(data) - set(self.fields) + if unknown_fields: + raise serializers.ValidationError({field: ["unexpected field"] for field in sorted(unknown_fields)}) + return super().to_internal_value(data) + + def validate_bk_biz_id(self, value): + return validate_bk_biz_id(value) + + def validate(self, attrs): + if attrs["end_time"] <= attrs["start_time"]: + raise serializers.ValidationError({"end_time": ["must be greater than start_time"]}) + has_host = attrs.get("bk_host_id") is not None + has_topo = attrs.get("bk_obj_id") is not None or attrs.get("bk_inst_id") is not None + if has_host and has_topo: + raise serializers.ValidationError({"bk_host_id": ["host and topology scopes are mutually exclusive"]}) + if bool(attrs.get("bk_obj_id")) != (attrs.get("bk_inst_id") is not None): + raise serializers.ValidationError({"bk_obj_id": ["bk_obj_id and bk_inst_id must be provided together"]}) + return attrs + + +def build_host_metric_snapshot_scope(params: dict) -> dict: + if params.get("bk_host_id") is not None: + return {"bk_host_id": params["bk_host_id"], "type": "host"} + if params.get("bk_obj_id") and params.get("bk_inst_id") is not None: + return { + "bk_inst_id": params["bk_inst_id"], + "bk_obj_id": params["bk_obj_id"], + "type": "topology", + } + return {"type": "business"} + + +def resolve_host_metric_snapshot_scope(params: dict) -> tuple[dict, list[Host]]: + bk_biz_id = params["bk_biz_id"] + scope = build_host_metric_snapshot_scope(params) + if scope["type"] == "host": + hosts = api.cmdb.get_host_by_id(bk_biz_id=bk_biz_id, bk_host_ids=[params["bk_host_id"]]) + elif scope["type"] == "topology": + hosts = api.cmdb.get_host_by_topo_node( + bk_biz_id=bk_biz_id, + topo_nodes={params["bk_obj_id"]: [params["bk_inst_id"]]}, + ) + else: + hosts = api.cmdb.get_host_by_topo_node(bk_biz_id=bk_biz_id) + return scope, hosts + + +def _unavailable_snapshot_response(): + return { + "data": {}, + "expired": False, + "failed_sections": [], + "retry_after": 5, + "revision": 0, + "sections": {}, + "snapshot_id": "", + "state": SnapshotState.UNAVAILABLE, + } + + +def _expired_snapshot_response(snapshot_id: str): + return { + "data": {}, + "expired": True, + "failed_sections": [], + "retry_after": 5, + "revision": 0, + "sections": {}, + "snapshot_id": snapshot_id, + "state": SnapshotState.EXPIRED, + } + + +class CreateHostMetricSnapshotResource(ApiAuthResource): + RequestSerializer = HostMetricSnapshotRequestSerializer + + def perform_request(self, params): + if not settings.ENABLE_HOST_METRIC_PROGRESSIVE: + return _unavailable_snapshot_response() + scope = build_host_metric_snapshot_scope(params) + request = get_request(peaceful=True) + canonical_time = canonicalize_snapshot_time( + params["start_time"], + params["end_time"], + is_share=bool(request and getattr(request, "token", None)), + ) + bk_tenant_id = get_request_tenant_id() + fingerprint = build_snapshot_fingerprint( + bk_tenant_id=bk_tenant_id, + bk_biz_id=params["bk_biz_id"], + scope=scope, + time_key=canonical_time.time_key, + ) + now = int(time.time()) + payload = { + "bk_biz_id": params["bk_biz_id"], + "bk_tenant_id": bk_tenant_id, + "canonical_end_time": canonical_time.end_time, + "canonical_start_time": canonical_time.start_time, + "deadline_at": now + SNAPSHOT_DEADLINE, + "host_count": 0, + "host_ids_hash": "", + "scope": scope, + "sections": {section: {"state": "PENDING"} for section in SNAPSHOT_SECTIONS}, + "username": get_request_username(), + } + try: + store = HostMetricSnapshotStore() + manifest, created = store.create_or_get(fingerprint, payload) + if created: + from monitor_web.performance.tasks import build_host_metric_snapshot + + try: + build_host_metric_snapshot.delay(manifest["snapshot_id"]) + except Exception: + logger.exception("enqueue host metric snapshot failed, bk_biz_id=%s", params["bk_biz_id"]) + store.fail(manifest["snapshot_id"], "enqueue_failed") + else: + latest = store.get_manifest(manifest["snapshot_id"]) + if latest and latest["state"] == SnapshotState.RUNNING and not store.renew_capacity(latest): + store.expire(manifest["snapshot_id"], allow_ready=False) + response = store.build_response(manifest["snapshot_id"], now=now, include_data=False) + response["revision"] = 0 + return response + except SnapshotUnavailable: + logger.warning("host metric snapshot unavailable, bk_biz_id=%s", params["bk_biz_id"]) + return _unavailable_snapshot_response() + + +class GetHostMetricSnapshotResource(ApiAuthResource): + class RequestSerializer(HostMetricSnapshotRequestSerializer): + snapshot_id = serializers.CharField(label="快照ID") + since_revision = serializers.IntegerField(required=False, min_value=0, label="已加载版本") + + def perform_request(self, params): + if not settings.ENABLE_HOST_METRIC_PROGRESSIVE: + return _unavailable_snapshot_response() + snapshot_id = params["snapshot_id"] + try: + store = HostMetricSnapshotStore() + manifest = store.get_manifest(snapshot_id) + if not manifest: + return _expired_snapshot_response(snapshot_id) + + scope = build_host_metric_snapshot_scope(params) + is_bound = ( + manifest.get("bk_tenant_id") == get_request_tenant_id() + and manifest.get("bk_biz_id") == params["bk_biz_id"] + and manifest.get("scope") == scope + and manifest.get("canonical_start_time") == params["start_time"] + and manifest.get("canonical_end_time") == params["end_time"] + ) + current = store.get_current(manifest["fingerprint"]) + if not is_bound or not current or current["snapshot_id"] != snapshot_id: + return _expired_snapshot_response(snapshot_id) + + response = store.build_response( + snapshot_id, + since_revision=params.get("since_revision", 0), + ) + if response.get("data") or response.get("state") == SnapshotState.READY: + try: + _, hosts = resolve_host_metric_snapshot_scope(params) + current_host_hash = build_host_ids_hash(host.bk_host_id for host in hosts) + except Exception: + logger.exception( + "resolve host metric snapshot poll scope failed, bk_biz_id=%s", params["bk_biz_id"] + ) + store.expire(snapshot_id) + return _expired_snapshot_response(snapshot_id) + current = store.get_current(manifest["fingerprint"]) + if ( + response.get("host_ids_hash") != current_host_hash + or not current + or current["snapshot_id"] != snapshot_id + ): + store.expire(snapshot_id) + return _expired_snapshot_response(snapshot_id) + return response + except SnapshotUnavailable: + logger.warning("host metric snapshot poll unavailable, bk_biz_id=%s", params["bk_biz_id"]) + return _unavailable_snapshot_response() diff --git a/bkmonitor/packages/monitor_web/performance/snapshot.py b/bkmonitor/packages/monitor_web/performance/snapshot.py new file mode 100644 index 0000000000..73cbd18bc2 --- /dev/null +++ b/bkmonitor/packages/monitor_web/performance/snapshot.py @@ -0,0 +1,574 @@ +"""Shared Redis state for progressive host metric snapshots.""" + +import hashlib +import json +import time +import zlib +from dataclasses import dataclass +from uuid import uuid4 + +from django.conf import settings +from django.core.cache import caches + + +CACHE_ALIAS = "redis" +LIVE_END_TOLERANCE = 300 +LIVE_TIME_BUCKET = 60 +RESERVATION_TTL = 15 +RUNNING_TTL = 120 +READY_TTL = 120 +SECTION_TTL = 180 +FAILED_TTL = 15 +SNAPSHOT_DEADLINE = 60 +SNAPSHOT_SECTIONS = ("agent_status", "performance_data", "process_status", "alarm_count") + +CLAIM_SNAPSHOT_SCRIPT = """ +-- host-metric-snapshot-claim +local current = redis.call('get', KEYS[1]) +if current then + return {current, 0} +end +redis.call('zremrangebyscore', KEYS[2], '-inf', ARGV[1]) +if redis.call('zcard', KEYS[2]) >= tonumber(ARGV[3]) then + return {'', -1} +end +redis.call('set', KEYS[1], ARGV[4], 'EX', ARGV[5]) +redis.call('zadd', KEYS[2], ARGV[2], ARGV[4]) +redis.call('expire', KEYS[2], ARGV[6]) +return {ARGV[4], 1} +""" +RENEW_CAPACITY_SCRIPT = """ +-- host-metric-snapshot-renew +if redis.call('get', KEYS[1]) ~= ARGV[1] then + return 0 +end +local score = redis.call('zscore', KEYS[2], ARGV[1]) +if not score or tonumber(score) <= tonumber(ARGV[2]) then + return 0 +end +redis.call('expire', KEYS[1], ARGV[4]) +redis.call('zadd', KEYS[2], ARGV[3], ARGV[1]) +redis.call('expire', KEYS[2], ARGV[4]) +return 1 +""" +RELEASE_CAPACITY_SCRIPT = """ +-- host-metric-snapshot-terminal-claim +local score = redis.call('zscore', KEYS[1], ARGV[1]) +if not score then + return 0 +end +if tonumber(score) <= tonumber(ARGV[2]) or tonumber(ARGV[3]) < tonumber(ARGV[2]) then + redis.call('zrem', KEYS[1], ARGV[1]) + return -1 +end +return redis.call('zrem', KEYS[1], ARGV[1]) +""" +DELETE_POINTER_SCRIPT = """ +if redis.call('get', KEYS[1]) == ARGV[1] then + return redis.call('del', KEYS[1]) +end +return 0 +""" +TOUCH_POINTER_SCRIPT = """ +if redis.call('get', KEYS[1]) == ARGV[1] then + return redis.call('expire', KEYS[1], ARGV[2]) +end +return 0 +""" + + +class SnapshotState: + RUNNING = "RUNNING" + READY = "READY" + FAILED = "FAILED" + EXPIRED = "EXPIRED" + UNAVAILABLE = "UNAVAILABLE" + + +class SnapshotUnavailable(RuntimeError): + pass + + +class SnapshotCapacityExceeded(SnapshotUnavailable): + pass + + +@dataclass(frozen=True) +class CanonicalSnapshotTime: + start_time: int + end_time: int + time_key: dict + + +def _sha256(value: bytes) -> str: + return f"sha256:{hashlib.sha256(value).hexdigest()}" + + +def build_host_ids_hash(host_ids) -> str: + normalized = ",".join(str(host_id) for host_id in sorted({int(host_id) for host_id in host_ids})) + return hashlib.sha256(normalized.encode()).hexdigest() + + +def canonicalize_snapshot_time( + start_time: int, + end_time: int, + *, + now: int | None = None, + is_share: bool, +) -> CanonicalSnapshotTime: + start_time = int(start_time) + end_time = int(end_time) + if end_time <= start_time: + raise ValueError("end_time must be greater than start_time") + + now = int(time.time()) if now is None else int(now) + if not is_share and abs(now - end_time) <= LIVE_END_TOLERANCE: + duration = end_time - start_time + end_time = end_time // LIVE_TIME_BUCKET * LIVE_TIME_BUCKET + start_time = end_time - duration + time_key = {"end_time": end_time, "mode": "live", "start_time": start_time} + else: + time_key = {"end_time": end_time, "mode": "historical", "start_time": start_time} + return CanonicalSnapshotTime(start_time=start_time, end_time=end_time, time_key=time_key) + + +def build_snapshot_fingerprint( + *, + bk_tenant_id: str, + bk_biz_id: int, + scope: dict, + time_key: dict, +) -> str: + payload = { + "bk_biz_id": int(bk_biz_id), + "bk_tenant_id": bk_tenant_id, + "scope": scope, + "time": time_key, + } + return _sha256(json.dumps(payload, separators=(",", ":"), sort_keys=True).encode()) + + +class HostMetricSnapshotStore: + key_prefix = "host_metric_snapshot:v1" + + def __init__(self, cache=None): + if cache is None: + try: + cache = caches[CACHE_ALIAS] + except Exception as error: + raise SnapshotUnavailable("shared Redis cache is unavailable") from error + self.cache = cache + try: + self.redis = cache.client.get_client(write=True) + except Exception as error: + raise SnapshotUnavailable("shared Redis client is unavailable") from error + + @classmethod + def pointer_key(cls, fingerprint: str) -> str: + return f"{cls.key_prefix}:pointer:{fingerprint}" + + @classmethod + def manifest_key(cls, snapshot_id: str) -> str: + return f"{cls.key_prefix}:manifest:{snapshot_id}" + + @classmethod + def section_key(cls, snapshot_id: str, section: str) -> str: + return f"{cls.key_prefix}:section:{snapshot_id}:{section}" + + @classmethod + def repair_lock_key(cls, fingerprint: str) -> str: + return f"{cls.key_prefix}:repair:{fingerprint}" + + @classmethod + def capacity_lease_key(cls, *, bk_tenant_id: str, bk_biz_id: int) -> str: + identity = json.dumps( + {"bk_biz_id": int(bk_biz_id), "bk_tenant_id": bk_tenant_id}, + separators=(",", ":"), + sort_keys=True, + ) + return f"{cls.key_prefix}:capacity:{hashlib.sha256(identity.encode()).hexdigest()}" + + def _get(self, key, default=None): + try: + return self.cache.get(key, default) + except Exception as error: + raise SnapshotUnavailable("shared Redis cache read failed") from error + + def _set(self, key, value, timeout): + try: + self.cache.set(key, value, timeout=timeout) + except Exception as error: + raise SnapshotUnavailable("shared Redis cache write failed") from error + + def _raw_key(self, key: str) -> str: + try: + return self.cache.make_key(key) + except Exception as error: + raise SnapshotUnavailable("shared Redis key generation failed") from error + + @staticmethod + def _decode_redis_value(value): + return value.decode() if isinstance(value, bytes) else value + + def _get_pointer(self, fingerprint: str) -> str | None: + try: + value = self.redis.get(self._raw_key(self.pointer_key(fingerprint))) + except Exception as error: + raise SnapshotUnavailable("shared Redis pointer read failed") from error + return self._decode_redis_value(value) if value else None + + def _claim_snapshot(self, fingerprint: str, payload: dict, snapshot_id: str) -> tuple[str, int]: + lease_key = self._raw_key( + self.capacity_lease_key( + bk_tenant_id=payload["bk_tenant_id"], + bk_biz_id=payload["bk_biz_id"], + ) + ) + pointer_key = self._raw_key(self.pointer_key(fingerprint)) + now = int(time.time()) + try: + claimed_snapshot_id, claim_status = self.redis.eval( + CLAIM_SNAPSHOT_SCRIPT, + 2, + pointer_key, + lease_key, + now, + now + RESERVATION_TTL, + settings.HOST_METRIC_SNAPSHOT_MAX_CONCURRENT_PER_BIZ, + snapshot_id, + RESERVATION_TTL, + RUNNING_TTL, + ) + except Exception as error: + raise SnapshotUnavailable("shared Redis snapshot claim failed") from error + return self._decode_redis_value(claimed_snapshot_id), int(claim_status) + + def renew_capacity(self, manifest: dict, *, now: int | None = None) -> bool: + now = time.time() if now is None else now + try: + return bool( + self.redis.eval( + RENEW_CAPACITY_SCRIPT, + 2, + self._raw_key(self.pointer_key(manifest["fingerprint"])), + manifest["capacity_lease_key"], + manifest["snapshot_id"], + now, + now + RUNNING_TTL, + RUNNING_TTL, + ) + ) + except Exception as error: + raise SnapshotUnavailable("shared Redis capacity renewal failed") from error + + def _delete_pointer(self, fingerprint: str, snapshot_id: str): + try: + self.redis.eval( + DELETE_POINTER_SCRIPT, + 1, + self._raw_key(self.pointer_key(fingerprint)), + snapshot_id, + ) + except Exception as error: + raise SnapshotUnavailable("shared Redis pointer delete failed") from error + + def _touch_pointer(self, manifest: dict, timeout: int): + try: + self.redis.eval( + TOUCH_POINTER_SCRIPT, + 1, + self._raw_key(self.pointer_key(manifest["fingerprint"])), + manifest["snapshot_id"], + timeout, + ) + except Exception as error: + raise SnapshotUnavailable("shared Redis pointer touch failed") from error + + def _capacity_lease_key(self, payload: dict) -> str: + return self._raw_key( + self.capacity_lease_key( + bk_tenant_id=payload["bk_tenant_id"], + bk_biz_id=payload["bk_biz_id"], + ) + ) + + def owns_capacity(self, manifest: dict, *, now: int | None = None) -> bool: + lease_key = manifest.get("capacity_lease_key") + if not lease_key: + return False + try: + expires_at = self.redis.zscore(lease_key, manifest["snapshot_id"]) + except Exception as error: + raise SnapshotUnavailable("shared Redis capacity lease read failed") from error + return expires_at is not None and float(expires_at) > (time.time() if now is None else now) + + def _claim_terminal(self, manifest: dict) -> int: + lease_key = manifest.get("capacity_lease_key") + if not lease_key: + return 0 + try: + return int( + self.redis.eval( + RELEASE_CAPACITY_SCRIPT, + 1, + lease_key, + manifest["snapshot_id"], + time.time(), + manifest["deadline_at"], + ) + ) + except Exception as error: + raise SnapshotUnavailable("shared Redis capacity release failed") from error + + def _force_expired_if_running(self, snapshot_id: str) -> dict | None: + manifest = self.get_manifest(snapshot_id) + if not manifest or manifest["state"] != SnapshotState.RUNNING: + return manifest + manifest["state"] = SnapshotState.EXPIRED + self._set(self.manifest_key(snapshot_id), manifest, FAILED_TTL) + self._touch_pointer(manifest, FAILED_TTL) + return manifest + + def create_or_get(self, fingerprint: str, payload: dict) -> tuple[dict, bool]: + current_snapshot_id = self._get_pointer(fingerprint) + if current_snapshot_id: + current = self.get_manifest(current_snapshot_id) + if current: + return current, False + + repair_lock_key = self.repair_lock_key(fingerprint) + try: + has_repair_lock = self.cache.add(repair_lock_key, True, timeout=5) + except Exception as error: + raise SnapshotUnavailable("shared Redis singleflight repair failed") from error + if has_repair_lock: + try: + if self._get_pointer(fingerprint) == current_snapshot_id and not self.get_manifest( + current_snapshot_id + ): + self._delete_pointer(fingerprint, current_snapshot_id) + except Exception as error: + raise SnapshotUnavailable("shared Redis stale pointer repair failed") from error + finally: + try: + self.cache.delete(repair_lock_key) + except Exception: + pass + + snapshot_id = uuid4().hex + manifest = { + **payload, + "capacity_lease_key": self._capacity_lease_key(payload), + "fingerprint": fingerprint, + "revision": 0, + "snapshot_id": snapshot_id, + "state": SnapshotState.RUNNING, + } + self._set(self.manifest_key(snapshot_id), manifest, RUNNING_TTL) + try: + claimed_snapshot_id, claim_status = self._claim_snapshot(fingerprint, payload, snapshot_id) + except Exception: + self.cache.delete(self.manifest_key(snapshot_id)) + raise + if claim_status == 1: + return manifest, True + + self.cache.delete(self.manifest_key(snapshot_id)) + if claim_status == -1: + raise SnapshotCapacityExceeded("host metric snapshot capacity exceeded") + current = self.get_manifest(claimed_snapshot_id) + if not current: + raise SnapshotUnavailable("shared Redis singleflight manifest is unavailable") + return current, False + + def get_current(self, fingerprint: str) -> dict | None: + snapshot_id = self._get_pointer(fingerprint) + return self.get_manifest(snapshot_id) if snapshot_id else None + + def get_manifest(self, snapshot_id: str) -> dict | None: + return self._get(self.manifest_key(snapshot_id)) + + def update_manifest(self, snapshot_id: str, **changes) -> dict | None: + manifest = self.get_manifest(snapshot_id) + if not manifest: + return None + if "state" in changes and changes["state"] != manifest["state"]: + raise ValueError("snapshot state transitions require a terminal capacity claim") + if manifest["state"] != SnapshotState.RUNNING or not self.owns_capacity(manifest): + return manifest + manifest.update(changes) + self._set(self.manifest_key(snapshot_id), manifest, RUNNING_TTL) + if not self.owns_capacity(manifest): + return self._force_expired_if_running(snapshot_id) + return manifest + + def write_section(self, snapshot_id: str, section: str, data: dict): + encoded = zlib.compress(json.dumps(data, separators=(",", ":"), sort_keys=True).encode()) + self._set(self.section_key(snapshot_id, section), encoded, SECTION_TTL) + + def mark_section_ready(self, snapshot_id: str, section: str) -> dict | None: + manifest = self.get_manifest(snapshot_id) + if not manifest: + return None + revision = int(manifest.get("revision", 0)) + 1 + sections = dict(manifest.get("sections", {})) + sections[section] = {"revision": revision, "state": SnapshotState.READY} + return self.update_manifest(snapshot_id, revision=revision, sections=sections) + + def mark_ready(self, snapshot_id: str, *, expected_sections: set[str]) -> dict | None: + manifest = self.get_manifest(snapshot_id) + if not manifest: + return None + ready_sections = { + section + for section, section_state in manifest.get("sections", {}).items() + if section_state.get("state") == SnapshotState.READY + } + if ready_sections != expected_sections: + raise ValueError("incomplete sections") + terminal_claim = self._claim_terminal(manifest) if manifest["state"] == SnapshotState.RUNNING else 0 + if terminal_claim != 1: + if terminal_claim == -1: + return self._force_expired_if_running(snapshot_id) + return self.get_manifest(snapshot_id) + manifest = self.get_manifest(snapshot_id) + if not manifest or manifest["state"] != SnapshotState.RUNNING: + return manifest + manifest["state"] = SnapshotState.READY + self._set(self.manifest_key(snapshot_id), manifest, READY_TTL) + self._touch_pointer(manifest, READY_TTL) + return manifest + + def read_section(self, snapshot_id: str, section: str) -> dict | None: + encoded = self._get(self.section_key(snapshot_id, section)) + if encoded is None: + return None + data = json.loads(zlib.decompress(encoded)) + return {int(key) if key.isdigit() else key: value for key, value in data.items()} + + def fail( + self, + snapshot_id: str, + error_code: str, + *, + failed_sections: list[str] | None = None, + allow_ready: bool = False, + ): + manifest = self.get_manifest(snapshot_id) + if not manifest: + return + if manifest["state"] == SnapshotState.RUNNING: + terminal_claim = self._claim_terminal(manifest) + if terminal_claim != 1: + if terminal_claim == -1: + self._force_expired_if_running(snapshot_id) + return + elif manifest["state"] != SnapshotState.READY or not allow_ready: + return + manifest.update( + { + "error_code": error_code, + "failed_sections": failed_sections or [], + "state": SnapshotState.FAILED, + } + ) + self._set(self.manifest_key(snapshot_id), manifest, FAILED_TTL) + self._touch_pointer(manifest, FAILED_TTL) + + def expire(self, snapshot_id: str, *, allow_ready: bool = True): + manifest = self.get_manifest(snapshot_id) + if not manifest: + return + if manifest["state"] == SnapshotState.RUNNING: + terminal_claim = self._claim_terminal(manifest) + if terminal_claim != 1: + if terminal_claim == -1: + self._force_expired_if_running(snapshot_id) + return + elif manifest["state"] != SnapshotState.READY or not allow_ready: + return + manifest["state"] = SnapshotState.EXPIRED + self._set(self.manifest_key(snapshot_id), manifest, FAILED_TTL) + self._touch_pointer(manifest, FAILED_TTL) + + def build_response( + self, + snapshot_id: str, + *, + since_revision: int = 0, + now: int | None = None, + include_data: bool = True, + ) -> dict: + manifest = self.get_manifest(snapshot_id) + if not manifest: + return { + "data": {}, + "expired": True, + "failed_sections": [], + "retry_after": 0, + "snapshot_id": snapshot_id, + "state": SnapshotState.EXPIRED, + } + + now = int(time.time()) if now is None else int(now) + if manifest["state"] == SnapshotState.RUNNING and now > int(manifest["deadline_at"]): + self.expire(snapshot_id) + manifest = self.get_manifest(snapshot_id) + + response = { + key: manifest[key] + for key in ( + "canonical_end_time", + "canonical_start_time", + "host_count", + "host_ids_hash", + "revision", + "sections", + "snapshot_id", + "state", + ) + if key in manifest + } + response["data"] = {} + response["expired"] = manifest["state"] == SnapshotState.EXPIRED + response["failed_sections"] = manifest.get("failed_sections", []) + if manifest["state"] == SnapshotState.RUNNING: + response["retry_after"] = 1 + elif manifest["state"] in {SnapshotState.FAILED, SnapshotState.EXPIRED}: + response["retry_after"] = 5 + else: + response["retry_after"] = 0 + if manifest["state"] not in {SnapshotState.RUNNING, SnapshotState.READY}: + return response + if not include_data: + return response + + for section, section_state in manifest.get("sections", {}).items(): + if section_state.get("state") != SnapshotState.READY: + continue + if int(section_state.get("revision", 0)) <= since_revision: + continue + try: + data = self.read_section(snapshot_id, section) + except SnapshotUnavailable: + raise + except Exception: + self.fail(snapshot_id, "section_corrupt", failed_sections=[section], allow_ready=True) + response.update( + data={}, + failed_sections=[section], + retry_after=5, + state=SnapshotState.FAILED, + ) + return response + if data is None: + self.fail(snapshot_id, "section_missing", failed_sections=[section], allow_ready=True) + response.update( + data={}, + failed_sections=[section], + retry_after=5, + state=SnapshotState.FAILED, + ) + return response + response["data"][section] = data + return response diff --git a/bkmonitor/packages/monitor_web/performance/tasks.py b/bkmonitor/packages/monitor_web/performance/tasks.py new file mode 100644 index 0000000000..1e92ef4a48 --- /dev/null +++ b/bkmonitor/packages/monitor_web/performance/tasks.py @@ -0,0 +1,189 @@ +import logging +import time +from concurrent.futures import ThreadPoolExecutor, as_completed + +from celery import shared_task +from django.conf import settings + +from bkmonitor.utils.tenant import set_local_tenant_id +from bkmonitor.utils.user import set_local_username +from monitor_web.constants import AGENT_STATUS +from monitor_web.performance.resources import SearchHostMetricResource, resolve_host_metric_snapshot_scope +from monitor_web.performance.snapshot import ( + SNAPSHOT_SECTIONS, + HostMetricSnapshotStore, + SnapshotState, + SnapshotUnavailable, + build_host_ids_hash, +) + +logger = logging.getLogger(__name__) + + +def _build_snapshot_section( + section, + bk_biz_id, + hosts, + start_time, + end_time, + scope, + bk_tenant_id=None, + username=None, +): + if bk_tenant_id is not None: + set_local_tenant_id(bk_tenant_id) + if username is not None: + set_local_username(username) + host_ids = {host.bk_host_id for host in hosts} + target_filter = {} if scope["type"] == "business" else None + if section == "agent_status": + data = {host_id: {"status": AGENT_STATUS.UNKNOWN} for host_id in host_ids} + SearchHostMetricResource.get_agent_status( + bk_biz_id, + hosts, + data, + start_time, + end_time, + fail_on_incomplete=True, + target_filter=target_filter, + ) + elif section == "performance_data": + data = { + host_id: { + "cpu_load": None, + "cpu_usage": None, + "disk_in_use": None, + "io_util": None, + "mem_usage": None, + "psc_mem_usage": None, + } + for host_id in host_ids + } + SearchHostMetricResource.get_performance_data( + bk_biz_id, + hosts, + data, + start_time, + end_time, + fail_on_incomplete=True, + target_filter=target_filter, + ) + elif section == "process_status": + data = {host_id: {"component": []} for host_id in host_ids} + SearchHostMetricResource.get_process_status( + bk_biz_id, + hosts, + data, + start_time, + end_time, + fail_on_incomplete=True, + target_filter=target_filter, + ) + elif section == "alarm_count": + data = {host_id: {"alarm_count": []} for host_id in host_ids} + SearchHostMetricResource.get_alarm_count( + bk_biz_id, + hosts, + data, + start_time, + end_time, + filter_by_host_ip=scope["type"] != "business", + ) + else: + raise ValueError(f"unknown host metric snapshot section: {section}") + return data + + +@shared_task(ignore_result=True, queue="celery_resource", soft_time_limit=55, time_limit=60) +def build_host_metric_snapshot(snapshot_id: str): + if not settings.ENABLE_HOST_METRIC_PROGRESSIVE: + return + try: + store = HostMetricSnapshotStore() + manifest = store.get_manifest(snapshot_id) + if not manifest or manifest["state"] != SnapshotState.RUNNING: + return + current = store.get_current(manifest["fingerprint"]) + if not current or current["snapshot_id"] != snapshot_id: + return + if not store.renew_capacity(manifest): + store.expire(snapshot_id) + return + if time.time() > manifest["deadline_at"]: + store.expire(snapshot_id) + return + + set_local_tenant_id(manifest["bk_tenant_id"]) + set_local_username(manifest["username"]) + scope, hosts = resolve_host_metric_snapshot_scope( + { + "bk_biz_id": manifest["bk_biz_id"], + **{key: value for key, value in manifest["scope"].items() if key != "type"}, + } + ) + host_ids_hash = build_host_ids_hash(host.bk_host_id for host in hosts) + if scope != manifest["scope"] or (manifest.get("host_ids_hash") and host_ids_hash != manifest["host_ids_hash"]): + store.expire(snapshot_id) + return + current = store.get_current(manifest["fingerprint"]) + if not current or current["snapshot_id"] != snapshot_id: + return + if not store.owns_capacity(manifest): + store.expire(snapshot_id) + return + store.update_manifest( + snapshot_id, host_count=len({host.bk_host_id for host in hosts}), host_ids_hash=host_ids_hash + ) + + failed_sections = [] + with ThreadPoolExecutor(max_workers=len(SNAPSHOT_SECTIONS)) as executor: + futures = { + executor.submit( + _build_snapshot_section, + section, + manifest["bk_biz_id"], + hosts, + manifest["canonical_start_time"], + manifest["canonical_end_time"], + scope, + manifest["bk_tenant_id"], + manifest["username"], + ): section + for section in SNAPSHOT_SECTIONS + } + for future in as_completed(futures): + section = futures.pop(future) + try: + data = future.result() + except Exception: + logger.exception( + "build host metric snapshot section failed, bk_biz_id=%s, section=%s", + manifest["bk_biz_id"], + section, + ) + failed_sections.append(section) + continue + current = store.get_current(manifest["fingerprint"]) + if not current or current["snapshot_id"] != snapshot_id: + return + if not store.owns_capacity(manifest): + store.expire(snapshot_id) + return + if time.time() > manifest["deadline_at"]: + store.expire(snapshot_id) + return + store.write_section(snapshot_id, section, data) + store.mark_section_ready(snapshot_id, section) + + if failed_sections: + store.fail(snapshot_id, "section_failed", failed_sections=sorted(failed_sections)) + return + store.mark_ready(snapshot_id, expected_sections=set(SNAPSHOT_SECTIONS)) + except SnapshotUnavailable: + logger.warning("host metric snapshot Redis unavailable, snapshot_id=%s", snapshot_id) + except Exception: + logger.exception("build host metric snapshot failed, snapshot_id=%s", snapshot_id) + try: + HostMetricSnapshotStore().fail(snapshot_id, "task_failed") + except SnapshotUnavailable: + pass diff --git a/bkmonitor/packages/monitor_web/performance/views.py b/bkmonitor/packages/monitor_web/performance/views.py index bfd0f8f2a9..368de9239c 100644 --- a/bkmonitor/packages/monitor_web/performance/views.py +++ b/bkmonitor/packages/monitor_web/performance/views.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- """ Tencent is pleased to support the open source community by making 蓝鲸智云 - 监控平台 (BlueKing - Monitor) available. Copyright (C) 2017-2025 Tencent. All rights reserved. @@ -13,6 +12,16 @@ from bkmonitor.iam.drf import BusinessActionPermission from core.drf_resource import resource from core.drf_resource.viewsets import ResourceRoute, ResourceViewSet +from rest_framework.exceptions import ValidationError + + +def reject_generic_async_task(view_func): + def wrapped(view, request, *args, **kwargs): + if "HTTP_X_ASYNC_TASK" in request.META: + raise ValidationError("generic async task is not supported for host metric snapshots") + return view_func(view, request, *args, **kwargs) + + return wrapped class PermissionMixin: @@ -73,3 +82,23 @@ class SearchHostMetricViewSet(PermissionMixin, ResourceViewSet): resource_routes = [ ResourceRoute("POST", resource.performance.search_host_metric, content_encoding="gzip"), ] + + +class HostMetricSnapshotViewSet(PermissionMixin, ResourceViewSet): + """创建或轮询主机指标共享快照。""" + + resource_routes = [ + ResourceRoute( + "POST", + resource.performance.create_host_metric_snapshot, + content_encoding="gzip", + decorators=[reject_generic_async_task], + ), + ResourceRoute( + "GET", + resource.performance.get_host_metric_snapshot, + pk_field="snapshot_id", + content_encoding="gzip", + decorators=[reject_generic_async_task], + ), + ] diff --git a/bkmonitor/packages/monitor_web/tasks.py b/bkmonitor/packages/monitor_web/tasks.py index 4bf817071f..d6ee9ee7d3 100644 --- a/bkmonitor/packages/monitor_web/tasks.py +++ b/bkmonitor/packages/monitor_web/tasks.py @@ -1815,3 +1815,7 @@ def migrate_all_panels_task(bk_biz_id, org_id): ApplicationConfig.objects.update_or_create( cc_biz_id=bk_biz_id, key=f"{bk_biz_id}_migrate_all_panels", defaults={"value": result} ) + + +# Celery only autodiscovers the app-level tasks module. Import the dedicated implementation so workers register it. +from monitor_web.performance.tasks import build_host_metric_snapshot as build_host_metric_snapshot # noqa: E402,F401 diff --git a/bkmonitor/packages/monitor_web/tests/performance/test_host_metric_snapshot.py b/bkmonitor/packages/monitor_web/tests/performance/test_host_metric_snapshot.py new file mode 100644 index 0000000000..be6d9db8fc --- /dev/null +++ b/bkmonitor/packages/monitor_web/tests/performance/test_host_metric_snapshot.py @@ -0,0 +1,1390 @@ +from copy import deepcopy +from concurrent.futures import ThreadPoolExecutor +from importlib import import_module +import json +import os +from threading import Barrier, Event, Lock +import zlib + +import pytest +from api.cmdb.mock import HOSTS +from django.urls import resolve +from rest_framework import serializers +from rest_framework.test import APIRequestFactory + +from bkmonitor.iam import ActionEnum +from bkmonitor.iam.drf import BusinessActionPermission +from bkmonitor.share.api_auth_resource import ApiAuthResource +from monitor_web.performance import snapshot + + +@pytest.fixture(autouse=True) +def enable_host_metric_progressive(settings): + settings.ENABLE_HOST_METRIC_PROGRESSIVE = True + + +class FakeCache: + def __init__(self): + self.data = {} + self.timeouts = {} + self.zsets = {} + self.client = self + self.eval_lock = Lock() + + def get_client(self, write=False): + return self + + @staticmethod + def make_key(key): + return key + + def add(self, key, value, timeout=None): + if key in self.data: + return False + self.set(key, value, timeout) + return True + + def delete(self, key): + return self.data.pop(key, None) is not None + + def get(self, key, default=None): + return deepcopy(self.data.get(key, default)) + + def set(self, key, value, timeout=None): + self.data[key] = deepcopy(value) + self.timeouts[key] = timeout + + def touch(self, key, timeout=None): + if key not in self.data: + return False + self.timeouts[key] = timeout + return True + + def eval(self, script, numkeys, *values): + keys = values[:numkeys] + args = values[numkeys:] + with self.eval_lock: + if "host-metric-snapshot-claim" in script: + pointer_key, lease_key = keys + now, expires_at, limit, member, pointer_timeout, lease_timeout = args + current = self.data.get(pointer_key) + if current: + return [current, 0] + leases = self.zsets.setdefault(lease_key, {}) + self.zsets[lease_key] = leases = { + existing_member: score for existing_member, score in leases.items() if score > float(now) + } + if len(leases) >= int(limit): + return ["", -1] + self.data[pointer_key] = member + self.timeouts[pointer_key] = int(pointer_timeout) + leases[member] = float(expires_at) + self.timeouts[lease_key] = int(lease_timeout) + return [member, 1] + if "host-metric-snapshot-renew" in script: + pointer_key, lease_key = keys + member, now, expires_at, timeout = args + score = self.zsets.get(lease_key, {}).get(member) + if self.data.get(pointer_key) != member or score is None or score <= float(now): + return 0 + self.timeouts[pointer_key] = int(timeout) + self.zsets[lease_key][member] = float(expires_at) + self.timeouts[lease_key] = int(timeout) + return 1 + if "host-metric-snapshot-terminal-claim" in script: + key = keys[0] + leases = self.zsets.get(key, {}) + score = leases.get(args[0]) + if score is None: + return 0 + leases.pop(args[0]) + return 1 if score > float(args[1]) and float(args[2]) >= float(args[1]) else -1 + if "del" in script: + key = keys[0] + if self.data.get(key) == args[0]: + return int(self.delete(key)) + return 0 + if "expire" in script: + key = keys[0] + if self.data.get(key) == args[0]: + self.timeouts[key] = int(args[1]) + return 1 + return 0 + raise AssertionError("unexpected Redis script") + + def zscore(self, key, member): + return self.zsets.get(key, {}).get(member) + + +class ConcurrentPointerCache(FakeCache): + def __init__(self): + super().__init__() + self.pointer_barrier = Barrier(2) + self.pointer_reads = 0 + self.pointer_reads_lock = Lock() + + def get(self, key, default=None): + should_wait = False + if ":pointer:" in key and key not in self.data: + with self.pointer_reads_lock: + if self.pointer_reads < 2: + self.pointer_reads += 1 + should_wait = True + if should_wait: + self.pointer_barrier.wait(timeout=2) + return super().get(key, default) + + +class PrefixedFakeCache(FakeCache): + @staticmethod + def make_key(key): + return f"deployment-a:1:{key}" + + +def make_payload(host_ids_hash="sha256:hosts"): + return { + "bk_biz_id": 2, + "bk_tenant_id": "tenant-a", + "canonical_start_time": 100, + "canonical_end_time": 200, + "deadline_at": int(snapshot.time.time()) + snapshot.SNAPSHOT_DEADLINE, + "host_count": 2, + "host_ids_hash": host_ids_hash, + "scope": {"type": "business"}, + "sections": {}, + } + + +def test_host_ids_hash_is_order_independent_and_set_sensitive(): + assert snapshot.build_host_ids_hash([3, 1, 2]) == snapshot.build_host_ids_hash([1, 2, 3]) + assert snapshot.build_host_ids_hash([1, 2, 3]) != snapshot.build_host_ids_hash([1, 2, 4]) + + +@pytest.mark.parametrize( + ("host_ids", "expected"), + [ + ([2, 10, 2], "3b5140aab9f8b8240b81687ea6a802d4bb00fc5da32c97b4b2bff91263b3a545"), + ([], "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"), + ], +) +def test_host_ids_hash_has_cross_language_vectors(host_ids, expected): + assert snapshot.build_host_ids_hash(host_ids) == expected + + +def test_live_time_fingerprint_reuses_same_minute_bucket_and_anchor(): + first = snapshot.canonicalize_snapshot_time(100, 200, now=201, is_share=False) + second = snapshot.canonicalize_snapshot_time(110, 210, now=211, is_share=False) + + assert ( + first.time_key + == second.time_key + == { + "end_time": 180, + "mode": "live", + "start_time": 80, + } + ) + assert (first.start_time, first.end_time) == (80, 180) + assert (second.start_time, second.end_time) == (80, 180) + + +def test_live_time_fingerprint_does_not_reuse_across_minute_buckets(): + first = snapshot.canonicalize_snapshot_time(100, 200, now=201, is_share=False) + second = snapshot.canonicalize_snapshot_time(160, 260, now=261, is_share=False) + + assert first.time_key != second.time_key + assert (first.start_time, first.end_time) == (80, 180) + assert (second.start_time, second.end_time) == (140, 240) + + +def test_historical_time_fingerprint_keeps_exact_range(): + first = snapshot.canonicalize_snapshot_time(100, 200, now=1000, is_share=False) + second = snapshot.canonicalize_snapshot_time(110, 210, now=1000, is_share=False) + + assert first.time_key != second.time_key + assert first.time_key == {"end_time": 200, "mode": "historical", "start_time": 100} + + +def test_share_time_fingerprint_is_always_exact(): + result = snapshot.canonicalize_snapshot_time(100, 200, now=201, is_share=True) + + assert result.time_key == {"end_time": 200, "mode": "historical", "start_time": 100} + + +@pytest.mark.parametrize("start_time,end_time", [(100, 100), (101, 100)]) +def test_time_range_must_be_positive(start_time, end_time): + with pytest.raises(ValueError, match="end_time must be greater"): + snapshot.canonicalize_snapshot_time(start_time, end_time, now=200, is_share=False) + + +def test_fingerprint_binds_tenant_scope_and_canonical_time(): + base = { + "bk_tenant_id": "tenant-a", + "bk_biz_id": 2, + "scope": {"type": "business"}, + "time_key": {"end_time": 180, "mode": "live", "start_time": 80}, + } + fingerprint = snapshot.build_snapshot_fingerprint(**base) + + for key, replacement in ( + ("bk_tenant_id", "tenant-b"), + ("bk_biz_id", 3), + ("scope", {"type": "host", "bk_host_id": 1}), + ("time_key", {"end_time": 240, "mode": "live", "start_time": 140}), + ): + changed = {**base, key: replacement} + assert snapshot.build_snapshot_fingerprint(**changed) != fingerprint + + +def test_cache_add_singleflight_returns_one_snapshot_for_same_fingerprint(monkeypatch): + monkeypatch.setattr(snapshot, "uuid4", lambda: type("UUID", (), {"hex": "a" * 32})()) + store = snapshot.HostMetricSnapshotStore(cache=FakeCache()) + + first, first_created = store.create_or_get("sha256:fingerprint", make_payload()) + second, second_created = store.create_or_get("sha256:fingerprint", make_payload()) + + assert first_created is True + assert second_created is False + assert first["snapshot_id"] == second["snapshot_id"] == "a" * 32 + + +def test_capacity_limit_counts_distinct_running_snapshots_per_tenant_and_business(monkeypatch): + ids = iter(("a" * 32, "b" * 32, "c" * 32)) + monkeypatch.setattr(snapshot, "uuid4", lambda: type("UUID", (), {"hex": next(ids)})()) + monkeypatch.setattr(snapshot.settings, "HOST_METRIC_SNAPSHOT_MAX_CONCURRENT_PER_BIZ", 2, raising=False) + store = snapshot.HostMetricSnapshotStore(cache=FakeCache()) + + first, first_created = store.create_or_get("sha256:first", make_payload()) + second, second_created = store.create_or_get("sha256:second", make_payload()) + + assert first_created is second_created is True + with pytest.raises(snapshot.SnapshotCapacityExceeded): + store.create_or_get("sha256:third", make_payload()) + assert store.get_manifest("c" * 32) is None + assert first["capacity_lease_key"] == second["capacity_lease_key"] + + +def test_capacity_limit_defaults_to_one(settings): + if "HOST_METRIC_SNAPSHOT_MAX_CONCURRENT_PER_BIZ" in os.environ: + pytest.skip("deployment overrides snapshot capacity") + assert settings.HOST_METRIC_SNAPSHOT_MAX_CONCURRENT_PER_BIZ == 1 + + +def test_capacity_key_isolated_by_tenant_and_business(): + store = snapshot.HostMetricSnapshotStore(cache=FakeCache()) + + assert store.capacity_lease_key(bk_tenant_id="tenant-a", bk_biz_id=2) != store.capacity_lease_key( + bk_tenant_id="tenant-b", bk_biz_id=2 + ) + assert store.capacity_lease_key(bk_tenant_id="tenant-a", bk_biz_id=2) != store.capacity_lease_key( + bk_tenant_id="tenant-a", bk_biz_id=3 + ) + + +def test_raw_pointer_and_capacity_keys_use_django_cache_namespace(monkeypatch): + monkeypatch.setattr(snapshot, "uuid4", lambda: type("UUID", (), {"hex": "a" * 32})()) + cache = PrefixedFakeCache() + store = snapshot.HostMetricSnapshotStore(cache=cache) + + manifest, _ = store.create_or_get("sha256:first", make_payload()) + + assert manifest["capacity_lease_key"].startswith("deployment-a:1:") + assert cache.get("deployment-a:1:" + store.pointer_key("sha256:first")) == manifest["snapshot_id"] + + +def test_compare_delete_does_not_remove_replaced_pointer(monkeypatch): + monkeypatch.setattr(snapshot, "uuid4", lambda: type("UUID", (), {"hex": "a" * 32})()) + cache = FakeCache() + store = snapshot.HostMetricSnapshotStore(cache=cache) + old, _ = store.create_or_get("sha256:first", make_payload()) + cache.set(store.pointer_key("sha256:first"), "b" * 32, snapshot.RUNNING_TTL) + + store._delete_pointer("sha256:first", old["snapshot_id"]) + + assert cache.get(store.pointer_key("sha256:first")) == "b" * 32 + + +def test_same_fingerprint_reuse_does_not_consume_another_capacity_slot(monkeypatch): + monkeypatch.setattr(snapshot, "uuid4", lambda: type("UUID", (), {"hex": "a" * 32})()) + monkeypatch.setattr(snapshot.settings, "HOST_METRIC_SNAPSHOT_MAX_CONCURRENT_PER_BIZ", 1, raising=False) + cache = FakeCache() + store = snapshot.HostMetricSnapshotStore(cache=cache) + + first, first_created = store.create_or_get("sha256:same", make_payload()) + reused, reused_created = store.create_or_get("sha256:same", make_payload()) + + assert first_created is True + assert reused_created is False + assert reused["snapshot_id"] == first["snapshot_id"] + lease_key = first["capacity_lease_key"] + assert set(cache.zsets[lease_key]) == {first["snapshot_id"]} + + +def test_concurrent_same_fingerprint_claims_one_capacity_member(monkeypatch): + monkeypatch.setattr(snapshot.settings, "HOST_METRIC_SNAPSHOT_MAX_CONCURRENT_PER_BIZ", 1, raising=False) + cache = ConcurrentPointerCache() + + def create_snapshot(): + return snapshot.HostMetricSnapshotStore(cache=cache).create_or_get("sha256:same", make_payload()) + + with ThreadPoolExecutor(max_workers=2) as executor: + results = list(executor.map(lambda _: create_snapshot(), range(2))) + + manifests = [result[0] for result in results] + assert {manifest["snapshot_id"] for manifest in manifests} == {manifests[0]["snapshot_id"]} + assert sorted(result[1] for result in results) == [False, True] + assert set(cache.zsets[manifests[0]["capacity_lease_key"]]) == {manifests[0]["snapshot_id"]} + + +@pytest.mark.parametrize("terminal_state", ["ready", "failed", "expired"]) +def test_terminal_snapshot_releases_capacity_slot(monkeypatch, terminal_state): + ids = iter(("a" * 32, "b" * 32)) + monkeypatch.setattr(snapshot, "uuid4", lambda: type("UUID", (), {"hex": next(ids)})()) + monkeypatch.setattr(snapshot.settings, "HOST_METRIC_SNAPSHOT_MAX_CONCURRENT_PER_BIZ", 1, raising=False) + cache = FakeCache() + store = snapshot.HostMetricSnapshotStore(cache=cache) + first, _ = store.create_or_get("sha256:first", make_payload()) + + if terminal_state == "ready": + store.mark_ready(first["snapshot_id"], expected_sections=set()) + elif terminal_state == "failed": + store.fail(first["snapshot_id"], "task_failed") + else: + store.expire(first["snapshot_id"]) + + second, created = store.create_or_get("sha256:second", make_payload()) + assert created is True + assert second["snapshot_id"] == "b" * 32 + + +def test_expire_winning_terminal_lease_race_cannot_be_revived_ready(monkeypatch): + monkeypatch.setattr(snapshot, "uuid4", lambda: type("UUID", (), {"hex": "a" * 32})()) + store = snapshot.HostMetricSnapshotStore(cache=FakeCache()) + manifest, _ = store.create_or_get("sha256:first", make_payload()) + store.write_section(manifest["snapshot_id"], "agent_status", {}) + store.mark_section_ready(manifest["snapshot_id"], "agent_status") + ready_waiting = Event() + continue_ready = Event() + original_claim_terminal = store._claim_terminal + + def pause_ready_claim(current_manifest): + ready_waiting.set() + assert continue_ready.wait(timeout=2) + return original_claim_terminal(current_manifest) + + monkeypatch.setattr(store, "_claim_terminal", pause_ready_claim) + with ThreadPoolExecutor(max_workers=1) as executor: + ready_future = executor.submit( + store.mark_ready, + manifest["snapshot_id"], + expected_sections={"agent_status"}, + ) + assert ready_waiting.wait(timeout=2) + monkeypatch.setattr(store, "_claim_terminal", original_claim_terminal) + store.expire(manifest["snapshot_id"]) + continue_ready.set() + ready_future.result(timeout=2) + + assert store.get_manifest(manifest["snapshot_id"])["state"] == snapshot.SnapshotState.EXPIRED + assert store.cache.timeouts[store.pointer_key("sha256:first")] == snapshot.FAILED_TTL + + +def test_ready_terminal_winner_cannot_be_overwritten_by_losing_expire(monkeypatch): + monkeypatch.setattr(snapshot, "uuid4", lambda: type("UUID", (), {"hex": "a" * 32})()) + store = snapshot.HostMetricSnapshotStore(cache=FakeCache()) + manifest, _ = store.create_or_get("sha256:first", make_payload()) + ready_waiting = Event() + continue_ready = Event() + original_set = store._set + + def pause_ready_write(key, value, timeout): + if key == store.manifest_key(manifest["snapshot_id"]) and value.get("state") == snapshot.SnapshotState.READY: + ready_waiting.set() + assert continue_ready.wait(timeout=2) + return original_set(key, value, timeout) + + monkeypatch.setattr(store, "_set", pause_ready_write) + with ThreadPoolExecutor(max_workers=1) as executor: + ready_future = executor.submit(store.mark_ready, manifest["snapshot_id"], expected_sections=set()) + assert ready_waiting.wait(timeout=2) + store.expire(manifest["snapshot_id"]) + continue_ready.set() + ready_future.result(timeout=2) + + assert store.get_manifest(manifest["snapshot_id"])["state"] == snapshot.SnapshotState.READY + + +def test_running_manifest_update_cannot_overwrite_concurrent_expire(monkeypatch): + monkeypatch.setattr(snapshot, "uuid4", lambda: type("UUID", (), {"hex": "a" * 32})()) + store = snapshot.HostMetricSnapshotStore(cache=FakeCache()) + manifest, _ = store.create_or_get("sha256:first", make_payload()) + update_waiting = Event() + continue_update = Event() + original_owns_capacity = store.owns_capacity + owns_calls = 0 + + def pause_after_initial_capacity_check(current_manifest, **kwargs): + nonlocal owns_calls + owns_calls += 1 + result = original_owns_capacity(current_manifest, **kwargs) + if owns_calls == 1: + update_waiting.set() + assert continue_update.wait(timeout=2) + return result + + monkeypatch.setattr(store, "owns_capacity", pause_after_initial_capacity_check) + with ThreadPoolExecutor(max_workers=1) as executor: + update_future = executor.submit(store.update_manifest, manifest["snapshot_id"], host_count=1) + assert update_waiting.wait(timeout=2) + store.expire(manifest["snapshot_id"]) + continue_update.set() + update_future.result(timeout=2) + + assert store.get_manifest(manifest["snapshot_id"])["state"] == snapshot.SnapshotState.EXPIRED + + +def test_deadline_expired_snapshot_cannot_publish_ready_with_live_capacity_lease(monkeypatch): + monkeypatch.setattr(snapshot, "uuid4", lambda: type("UUID", (), {"hex": "a" * 32})()) + monkeypatch.setattr(snapshot.time, "time", lambda: 300) + store = snapshot.HostMetricSnapshotStore(cache=FakeCache()) + manifest, _ = store.create_or_get("sha256:first", {**make_payload(), "deadline_at": 299}) + + store.mark_ready(manifest["snapshot_id"], expected_sections=set()) + + assert store.get_manifest(manifest["snapshot_id"])["state"] == snapshot.SnapshotState.EXPIRED + + +def test_capacity_reservation_has_short_ttl_and_old_worker_cannot_release_new_owner(monkeypatch): + monkeypatch.setattr(snapshot, "uuid4", lambda: type("UUID", (), {"hex": "a" * 32})()) + monkeypatch.setattr(snapshot.settings, "HOST_METRIC_SNAPSHOT_MAX_CONCURRENT_PER_BIZ", 1, raising=False) + cache = FakeCache() + store = snapshot.HostMetricSnapshotStore(cache=cache) + manifest, _ = store.create_or_get("sha256:first", make_payload()) + slot_key = manifest["capacity_lease_key"] + + assert cache.timeouts[slot_key] == snapshot.RUNNING_TTL + assert cache.timeouts[store.pointer_key("sha256:first")] == snapshot.RESERVATION_TTL + cache.zsets[slot_key] = {"b" * 32: 400} + store.fail(manifest["snapshot_id"], "old_task_failed") + + assert cache.zscore(slot_key, "b" * 32) == 400 + + +def test_capacity_reservation_can_be_renewed_for_running_task(monkeypatch): + monkeypatch.setattr(snapshot, "uuid4", lambda: type("UUID", (), {"hex": "a" * 32})()) + cache = FakeCache() + store = snapshot.HostMetricSnapshotStore(cache=cache) + manifest, _ = store.create_or_get("sha256:first", make_payload()) + + assert store.renew_capacity(manifest) is True + assert cache.timeouts[manifest["capacity_lease_key"]] == snapshot.RUNNING_TTL + assert cache.timeouts[store.pointer_key("sha256:first")] == snapshot.RUNNING_TTL + + +def test_new_reservation_does_not_shorten_existing_running_capacity_key_ttl(monkeypatch): + ids = iter(("a" * 32, "b" * 32)) + monkeypatch.setattr(snapshot, "uuid4", lambda: type("UUID", (), {"hex": next(ids)})()) + monkeypatch.setattr(snapshot.settings, "HOST_METRIC_SNAPSHOT_MAX_CONCURRENT_PER_BIZ", 2, raising=False) + cache = FakeCache() + store = snapshot.HostMetricSnapshotStore(cache=cache) + first, _ = store.create_or_get("sha256:first", make_payload()) + store.renew_capacity(first) + + second, _ = store.create_or_get("sha256:second", make_payload()) + + assert cache.timeouts[first["capacity_lease_key"]] == snapshot.RUNNING_TTL + assert cache.zscore(first["capacity_lease_key"], first["snapshot_id"]) is not None + assert cache.zscore(second["capacity_lease_key"], second["snapshot_id"]) is not None + + +def test_expired_capacity_lease_allows_another_fingerprint(monkeypatch): + ids = iter(("a" * 32, "b" * 32)) + now = [100] + monkeypatch.setattr(snapshot, "uuid4", lambda: type("UUID", (), {"hex": next(ids)})()) + monkeypatch.setattr(snapshot.time, "time", lambda: now[0]) + monkeypatch.setattr(snapshot.settings, "HOST_METRIC_SNAPSHOT_MAX_CONCURRENT_PER_BIZ", 1, raising=False) + cache = FakeCache() + store = snapshot.HostMetricSnapshotStore(cache=cache) + first, _ = store.create_or_get("sha256:first", make_payload()) + + now[0] += snapshot.RESERVATION_TTL + 1 + second, created = store.create_or_get("sha256:second", make_payload()) + + assert created is True + assert cache.zscore(first["capacity_lease_key"], first["snapshot_id"]) is None + assert cache.zscore(second["capacity_lease_key"], second["snapshot_id"]) is not None + + +def test_expired_old_capacity_member_cannot_publish_ready_or_remove_new_member(monkeypatch): + ids = iter(("a" * 32, "b" * 32)) + monkeypatch.setattr(snapshot, "uuid4", lambda: type("UUID", (), {"hex": next(ids)})()) + monkeypatch.setattr(snapshot.time, "time", lambda: 200) + monkeypatch.setattr(snapshot.settings, "HOST_METRIC_SNAPSHOT_MAX_CONCURRENT_PER_BIZ", 2, raising=False) + cache = FakeCache() + store = snapshot.HostMetricSnapshotStore(cache=cache) + old, _ = store.create_or_get("sha256:old", make_payload()) + new, _ = store.create_or_get("sha256:new", make_payload()) + lease_key = old["capacity_lease_key"] + cache.zsets[lease_key][old["snapshot_id"]] = 199 + + store.mark_ready(old["snapshot_id"], expected_sections=set()) + + assert store.get_manifest(old["snapshot_id"])["state"] == snapshot.SnapshotState.EXPIRED + assert cache.zscore(lease_key, old["snapshot_id"]) is None + assert cache.zscore(lease_key, new["snapshot_id"]) > 200 + + +def test_dangling_pointer_without_manifest_can_be_reclaimed(monkeypatch): + monkeypatch.setattr(snapshot, "uuid4", lambda: type("UUID", (), {"hex": "b" * 32})()) + cache = FakeCache() + store = snapshot.HostMetricSnapshotStore(cache=cache) + cache.set(store.pointer_key("sha256:fingerprint"), "a" * 32, snapshot.RUNNING_TTL) + + manifest, created = store.create_or_get("sha256:fingerprint", make_payload()) + + assert created is True + assert manifest["snapshot_id"] == "b" * 32 + assert store.get_current("sha256:fingerprint")["snapshot_id"] == "b" * 32 + + +def test_manifest_and_section_keys_are_isolated_by_snapshot_id(monkeypatch): + monkeypatch.setattr(snapshot.settings, "HOST_METRIC_SNAPSHOT_MAX_CONCURRENT_PER_BIZ", 2, raising=False) + ids = iter(("a" * 32, "b" * 32)) + monkeypatch.setattr(snapshot, "uuid4", lambda: type("UUID", (), {"hex": next(ids)})()) + cache = FakeCache() + store = snapshot.HostMetricSnapshotStore(cache=cache) + + first, _ = store.create_or_get("sha256:first", make_payload()) + second, _ = store.create_or_get("sha256:second", make_payload("sha256:other")) + store.write_section(first["snapshot_id"], "agent_status", {1: {"status": 1}}) + store.write_section(second["snapshot_id"], "agent_status", {2: {"status": 2}}) + + assert store.read_section(first["snapshot_id"], "agent_status") == {1: {"status": 1}} + assert store.read_section(second["snapshot_id"], "agent_status") == {2: {"status": 2}} + + +def test_section_blob_compression_gate_for_twenty_thousand_hosts(monkeypatch): + monkeypatch.setattr(snapshot, "uuid4", lambda: type("UUID", (), {"hex": "a" * 32})()) + cache = FakeCache() + store = snapshot.HostMetricSnapshotStore(cache=cache) + manifest, _ = store.create_or_get("sha256:first", make_payload()) + data = { + host_id: { + "cpu_load": host_id % 100, + "cpu_usage": host_id % 100, + "disk_in_use": host_id % 100, + "io_util": host_id % 100, + "mem_usage": host_id % 100, + "psc_mem_usage": host_id % 100, + } + for host_id in range(1, 20_001) + } + + store.write_section(manifest["snapshot_id"], "performance_data", data) + + blob = cache.get(store.section_key(manifest["snapshot_id"], "performance_data")) + raw_size = len(json.dumps(data, separators=(",", ":"), sort_keys=True).encode()) + assert len(blob) < raw_size * 0.35 + assert len(store.read_section(manifest["snapshot_id"], "performance_data")) == 20_000 + + +def test_old_task_cannot_replace_new_pointer(monkeypatch): + monkeypatch.setattr(snapshot.settings, "HOST_METRIC_SNAPSHOT_MAX_CONCURRENT_PER_BIZ", 2, raising=False) + ids = iter(("a" * 32, "b" * 32)) + monkeypatch.setattr(snapshot, "uuid4", lambda: type("UUID", (), {"hex": next(ids)})()) + cache = FakeCache() + store = snapshot.HostMetricSnapshotStore(cache=cache) + + old, _ = store.create_or_get("sha256:same", make_payload()) + cache.delete(store.pointer_key("sha256:same")) + new, _ = store.create_or_get("sha256:same", make_payload()) + store.mark_ready(old["snapshot_id"], expected_sections=set()) + + assert store.get_current("sha256:same")["snapshot_id"] == new["snapshot_id"] + assert store.get_manifest(old["snapshot_id"])["state"] == snapshot.SnapshotState.READY + + +def test_missing_ready_section_blob_fails_closed(monkeypatch): + monkeypatch.setattr(snapshot, "uuid4", lambda: type("UUID", (), {"hex": "a" * 32})()) + cache = FakeCache() + store = snapshot.HostMetricSnapshotStore(cache=cache) + manifest, _ = store.create_or_get("sha256:fingerprint", make_payload()) + store.mark_section_ready(manifest["snapshot_id"], "agent_status") + store.mark_ready(manifest["snapshot_id"], expected_sections={"agent_status"}) + + response = store.build_response(manifest["snapshot_id"]) + + assert response["state"] == snapshot.SnapshotState.FAILED + assert response["data"] == {} + assert response["failed_sections"] == ["agent_status"] + assert response["retry_after"] == 5 + assert store.get_manifest(manifest["snapshot_id"])["error_code"] == "section_missing" + assert cache.timeouts[store.pointer_key("sha256:fingerprint")] == snapshot.FAILED_TTL + + +@pytest.mark.parametrize("corrupt_blob", [b"not-zlib", zlib.compress(b"not-json")]) +def test_corrupt_ready_section_blob_persists_failed_state_and_short_retry(monkeypatch, corrupt_blob): + monkeypatch.setattr(snapshot, "uuid4", lambda: type("UUID", (), {"hex": "a" * 32})()) + cache = FakeCache() + store = snapshot.HostMetricSnapshotStore(cache=cache) + manifest, _ = store.create_or_get("sha256:fingerprint", make_payload()) + cache.set(store.section_key(manifest["snapshot_id"], "agent_status"), corrupt_blob, snapshot.SECTION_TTL) + store.mark_section_ready(manifest["snapshot_id"], "agent_status") + store.mark_ready(manifest["snapshot_id"], expected_sections={"agent_status"}) + + response = store.build_response(manifest["snapshot_id"]) + + assert response["state"] == snapshot.SnapshotState.FAILED + assert response["data"] == {} + assert response["failed_sections"] == ["agent_status"] + assert response["retry_after"] == 5 + assert store.get_manifest(manifest["snapshot_id"])["error_code"] == "section_corrupt" + assert cache.timeouts[store.pointer_key("sha256:fingerprint")] == snapshot.FAILED_TTL + + +def test_enqueue_failure_is_visible_and_pointer_has_short_retry_ttl(monkeypatch): + monkeypatch.setattr(snapshot, "uuid4", lambda: type("UUID", (), {"hex": "a" * 32})()) + cache = FakeCache() + store = snapshot.HostMetricSnapshotStore(cache=cache) + manifest, _ = store.create_or_get("sha256:fingerprint", make_payload()) + + store.fail(manifest["snapshot_id"], "enqueue_failed") + + assert store.build_response(manifest["snapshot_id"])["state"] == snapshot.SnapshotState.FAILED + assert cache.timeouts[store.pointer_key("sha256:fingerprint")] == snapshot.FAILED_TTL + + +def test_missing_redis_alias_fails_closed(monkeypatch): + monkeypatch.setattr(snapshot, "caches", {}) + + with pytest.raises(snapshot.SnapshotUnavailable): + snapshot.HostMetricSnapshotStore() + + +def test_poll_since_revision_only_returns_new_section_blobs(monkeypatch): + monkeypatch.setattr(snapshot, "uuid4", lambda: type("UUID", (), {"hex": "a" * 32})()) + store = snapshot.HostMetricSnapshotStore(cache=FakeCache()) + manifest, _ = store.create_or_get("sha256:fingerprint", make_payload()) + snapshot_id = manifest["snapshot_id"] + store.write_section(snapshot_id, "agent_status", {1: {"status": 1}}) + store.mark_section_ready(snapshot_id, "agent_status") + store.write_section(snapshot_id, "performance_data", {1: {"cpu_usage": 10}}) + store.mark_section_ready(snapshot_id, "performance_data") + + response = store.build_response(snapshot_id, since_revision=1, now=200) + + assert response["revision"] == 2 + assert set(response["sections"]) == {"agent_status", "performance_data"} + assert response["data"] == {"performance_data": {1: {"cpu_usage": 10}}} + + +def test_poll_does_not_read_already_delivered_section_blobs(monkeypatch): + monkeypatch.setattr(snapshot, "uuid4", lambda: type("UUID", (), {"hex": "a" * 32})()) + store = snapshot.HostMetricSnapshotStore(cache=FakeCache()) + manifest, _ = store.create_or_get("sha256:fingerprint", make_payload()) + snapshot_id = manifest["snapshot_id"] + store.write_section(snapshot_id, "agent_status", {1: {"status": 1}}) + store.mark_section_ready(snapshot_id, "agent_status") + read_section = monkeypatch.setattr + + original_read_section = store.read_section + calls = [] + + def track_read(section_snapshot_id, section): + calls.append((section_snapshot_id, section)) + return original_read_section(section_snapshot_id, section) + + read_section(store, "read_section", track_read) + + response = store.build_response(snapshot_id, since_revision=1, now=200) + + assert response["data"] == {} + assert calls == [] + + +def test_ready_requires_every_expected_section(monkeypatch): + monkeypatch.setattr(snapshot, "uuid4", lambda: type("UUID", (), {"hex": "a" * 32})()) + store = snapshot.HostMetricSnapshotStore(cache=FakeCache()) + manifest, _ = store.create_or_get("sha256:fingerprint", make_payload()) + + with pytest.raises(ValueError, match="incomplete sections"): + store.mark_ready(manifest["snapshot_id"], expected_sections={"agent_status", "performance_data"}) + + +def test_running_snapshot_past_deadline_is_expired(monkeypatch): + monkeypatch.setattr(snapshot, "uuid4", lambda: type("UUID", (), {"hex": "a" * 32})()) + store = snapshot.HostMetricSnapshotStore(cache=FakeCache()) + manifest, _ = store.create_or_get("sha256:fingerprint", {**make_payload(), "deadline_at": 260}) + + response = store.build_response(manifest["snapshot_id"], now=261) + + assert response["state"] == snapshot.SnapshotState.EXPIRED + assert response["expired"] is True + assert store.get_manifest(manifest["snapshot_id"])["state"] == snapshot.SnapshotState.EXPIRED + assert store.cache.timeouts[store.pointer_key("sha256:fingerprint")] == snapshot.FAILED_TTL + assert response["retry_after"] == 5 + + +def test_expired_snapshot_can_be_rebuilt_after_short_pointer_ttl(monkeypatch): + ids = iter(("a" * 32, "b" * 32)) + monkeypatch.setattr(snapshot, "uuid4", lambda: type("UUID", (), {"hex": next(ids)})()) + cache = FakeCache() + store = snapshot.HostMetricSnapshotStore(cache=cache) + old, _ = store.create_or_get("sha256:fingerprint", {**make_payload(), "deadline_at": 260}) + + response = store.build_response(old["snapshot_id"], now=261) + assert response["retry_after"] == 5 + assert cache.timeouts[store.pointer_key("sha256:fingerprint")] == snapshot.FAILED_TTL + + cache.delete(store.pointer_key("sha256:fingerprint")) + cache.delete(store.manifest_key(old["snapshot_id"])) + rebuilt, created = store.create_or_get("sha256:fingerprint", make_payload()) + + assert created is True + assert rebuilt["snapshot_id"] == "b" * 32 + + +def test_snapshot_serializers_require_exact_time_and_reject_unknown_fields(): + resources = import_module("monitor_web.performance.resources") + create_serializer = resources.CreateHostMetricSnapshotResource.RequestSerializer + poll_serializer = resources.GetHostMetricSnapshotResource.RequestSerializer + + for serializer_class, base in ( + (create_serializer, {"bk_biz_id": 2}), + (poll_serializer, {"bk_biz_id": 2, "snapshot_id": "snapshot-1"}), + ): + for invalid in ( + base, + {**base, "start_time": 100}, + {**base, "end_time": 200}, + {**base, "start_time": 100, "end_time": 200, "full_business": True}, + {**base, "start_time": 100, "end_time": 200, "target_filter": {}}, + {**base, "start_time": 100, "end_time": 200, "capacity_lease_key": "forged"}, + {**base, "start_time": 100, "end_time": 200, "capacity_slot": 1}, + {**base, "start_time": 100, "end_time": 200, "release": True}, + ): + with pytest.raises(serializers.ValidationError): + serializer_class(data=invalid).is_valid(raise_exception=True) + + poll = poll_serializer( + data={"bk_biz_id": 2, "snapshot_id": "snapshot-1", "start_time": 100, "end_time": 200, "since_revision": -1} + ) + with pytest.raises(serializers.ValidationError): + poll.is_valid(raise_exception=True) + + +def test_snapshot_routes_resolve_to_dedicated_gzip_create_and_retrieve_actions(): + views = import_module("monitor_web.performance.views") + create_match = resolve("/rest/v2/performance/host_metric_snapshot/") + poll_match = resolve("/rest/v2/performance/host_metric_snapshot/snapshot-1/") + + assert create_match.func.cls is views.HostMetricSnapshotViewSet + assert create_match.func.actions == {"post": "create"} + assert poll_match.func.cls is views.HostMetricSnapshotViewSet + assert poll_match.func.actions == {"get": "retrieve"} + assert all(route.content_encoding == "gzip" for route in views.HostMetricSnapshotViewSet.resource_routes) + + +def test_snapshot_routes_require_view_host_and_api_auth_resources(): + resources = import_module("monitor_web.performance.resources") + views = import_module("monitor_web.performance.views") + + permissions = views.HostMetricSnapshotViewSet().get_permissions() + + assert len(permissions) == 1 + assert isinstance(permissions[0], BusinessActionPermission) + assert permissions[0].actions == [ActionEnum.VIEW_HOST] + assert issubclass(resources.CreateHostMetricSnapshotResource, ApiAuthResource) + assert issubclass(resources.GetHostMetricSnapshotResource, ApiAuthResource) + + +def test_disabled_feature_flag_blocks_create_and_poll_before_store_access(mocker, settings): + resources = import_module("monitor_web.performance.resources") + tasks = import_module("monitor_web.performance.tasks") + settings.ENABLE_HOST_METRIC_PROGRESSIVE = False + store = mocker.patch.object( + resources, + "HostMetricSnapshotStore", + side_effect=AssertionError("disabled snapshot touched Redis"), + ) + enqueue = mocker.patch("monitor_web.performance.tasks.build_host_metric_snapshot.delay") + task_store = mocker.patch.object( + tasks, + "HostMetricSnapshotStore", + side_effect=AssertionError("disabled task touched Redis"), + ) + + created = resources.CreateHostMetricSnapshotResource().perform_request( + {"bk_biz_id": 2, "start_time": 100, "end_time": 200} + ) + polled = resources.GetHostMetricSnapshotResource().perform_request( + {"bk_biz_id": 2, "snapshot_id": "snapshot-1", "start_time": 100, "end_time": 200} + ) + tasks.build_host_metric_snapshot.run("snapshot-1") + + assert created["state"] == polled["state"] == snapshot.SnapshotState.UNAVAILABLE + assert created["data"] == polled["data"] == {} + store.assert_not_called() + task_store.assert_not_called() + enqueue.assert_not_called() + + +def test_disabling_feature_after_create_blocks_existing_snapshot_data(mocker, settings): + resources = import_module("monitor_web.performance.resources") + cache = FakeCache() + store = snapshot.HostMetricSnapshotStore(cache=cache) + mocker.patch.object(resources, "HostMetricSnapshotStore", return_value=store) + mocker.patch.object(resources, "get_request_tenant_id", return_value="system") + mocker.patch.object(resources, "get_request_username", return_value="admin") + mocker.patch.object(resources, "get_request", return_value=None) + mocker.patch.object(resources.time, "time", return_value=201) + mocker.patch("monitor_web.performance.tasks.build_host_metric_snapshot.delay") + created = resources.CreateHostMetricSnapshotResource().perform_request( + {"bk_biz_id": 2, "start_time": 100, "end_time": 200} + ) + get_manifest = mocker.patch.object(store, "get_manifest", wraps=store.get_manifest) + settings.ENABLE_HOST_METRIC_PROGRESSIVE = False + + result = resources.GetHostMetricSnapshotResource().perform_request( + { + "bk_biz_id": 2, + "snapshot_id": created["snapshot_id"], + "start_time": created["canonical_start_time"], + "end_time": created["canonical_end_time"], + } + ) + + assert result["state"] == snapshot.SnapshotState.UNAVAILABLE + assert result["data"] == {} + get_manifest.assert_not_called() + + +@pytest.mark.parametrize( + ("path", "method"), + [ + ("/rest/v2/performance/host_metric_snapshot/", "POST"), + ("/rest/v2/performance/host_metric_snapshot/snapshot-1/", "GET"), + ], +) +def test_snapshot_routes_reject_generic_async_header_before_resource_delay(mocker, path, method): + resources = import_module("monitor_web.performance.resources") + match = resolve(path) + resource_class = ( + resources.CreateHostMetricSnapshotResource if method == "POST" else resources.GetHostMetricSnapshotResource + ) + mocker.patch.object(match.func.cls, "get_permissions", return_value=[]) + delay = mocker.patch.object(resource_class, "delay", side_effect=AssertionError("generic async path used")) + factory = APIRequestFactory() + + if method == "POST": + response = match.func(factory.post(path, {}, format="json", HTTP_X_ASYNC_TASK="1")) + else: + response = match.func(factory.get(path, {}, HTTP_X_ASYNC_TASK="1"), pk="snapshot-1") + + assert response.status_code == 400 + delay.assert_not_called() + + +def test_snapshot_create_reuses_singleflight_and_returns_canonical_anchor(mocker): + resources = import_module("monitor_web.performance.resources") + cache = FakeCache() + store = snapshot.HostMetricSnapshotStore(cache=cache) + mocker.patch.object(resources, "HostMetricSnapshotStore", return_value=store) + get_hosts = mocker.patch.object(resources.api.cmdb, "get_host_by_topo_node", return_value=HOSTS[:2]) + mocker.patch.object(resources, "get_request_tenant_id", return_value="system") + mocker.patch.object(resources, "get_request_username", return_value="admin") + mocker.patch.object(resources, "get_request", return_value=None) + mocker.patch.object(resources.time, "time", return_value=201) + delay = mocker.patch("monitor_web.performance.tasks.build_host_metric_snapshot.delay") + params = {"bk_biz_id": 2, "start_time": 100, "end_time": 200} + + first = resources.CreateHostMetricSnapshotResource().perform_request(params) + second = resources.CreateHostMetricSnapshotResource().perform_request(params) + + assert first["snapshot_id"] == second["snapshot_id"] + assert first["canonical_start_time"] == 80 + assert first["canonical_end_time"] == 180 + assert first["host_count"] == 0 + assert first["host_ids_hash"] == "" + assert first["state"] == snapshot.SnapshotState.RUNNING + assert first["retry_after"] == 1 + get_hosts.assert_not_called() + delay.assert_called_once_with(first["snapshot_id"]) + + +def test_snapshot_create_capacity_exceeded_returns_unavailable_without_enqueue(mocker, monkeypatch): + resources = import_module("monitor_web.performance.resources") + monkeypatch.setattr(snapshot.settings, "HOST_METRIC_SNAPSHOT_MAX_CONCURRENT_PER_BIZ", 1, raising=False) + cache = FakeCache() + store = snapshot.HostMetricSnapshotStore(cache=cache) + mocker.patch.object(resources, "HostMetricSnapshotStore", return_value=store) + mocker.patch.object(resources, "get_request_tenant_id", return_value="system") + mocker.patch.object(resources, "get_request_username", return_value="admin") + mocker.patch.object(resources, "get_request", return_value=None) + mocker.patch.object(resources.time, "time", return_value=201) + delay = mocker.patch("monitor_web.performance.tasks.build_host_metric_snapshot.delay") + + first = resources.CreateHostMetricSnapshotResource().perform_request( + {"bk_biz_id": 2, "start_time": 100, "end_time": 200} + ) + second = resources.CreateHostMetricSnapshotResource().perform_request( + {"bk_biz_id": 2, "start_time": 40, "end_time": 140} + ) + + assert first["state"] == snapshot.SnapshotState.RUNNING + assert second["state"] == snapshot.SnapshotState.UNAVAILABLE + assert second["retry_after"] == 5 + delay.assert_called_once_with(first["snapshot_id"]) + + +def test_snapshot_create_enqueue_failure_returns_failed_with_short_retry(mocker): + resources = import_module("monitor_web.performance.resources") + cache = FakeCache() + store = snapshot.HostMetricSnapshotStore(cache=cache) + mocker.patch.object(resources, "HostMetricSnapshotStore", return_value=store) + mocker.patch.object(resources, "get_request_tenant_id", return_value="system") + mocker.patch.object(resources, "get_request_username", return_value="admin") + mocker.patch.object(resources, "get_request", return_value=None) + mocker.patch.object(resources.time, "time", return_value=201) + mocker.patch( + "monitor_web.performance.tasks.build_host_metric_snapshot.delay", + side_effect=RuntimeError("broker unavailable"), + ) + + response = resources.CreateHostMetricSnapshotResource().perform_request( + {"bk_biz_id": 2, "start_time": 100, "end_time": 200} + ) + + assert response["state"] == snapshot.SnapshotState.FAILED + assert response["retry_after"] == 5 + manifest = store.get_manifest(response["snapshot_id"]) + assert manifest["error_code"] == "enqueue_failed" + assert cache.timeouts[store.pointer_key(manifest["fingerprint"])] == snapshot.FAILED_TTL + assert cache.zscore(manifest["capacity_lease_key"], manifest["snapshot_id"]) is None + + +def test_create_renewal_loser_does_not_expire_worker_ready_snapshot(mocker): + resources = import_module("monitor_web.performance.resources") + cache = FakeCache() + store = snapshot.HostMetricSnapshotStore(cache=cache) + mocker.patch.object(resources, "HostMetricSnapshotStore", return_value=store) + mocker.patch.object(resources, "get_request_tenant_id", return_value="system") + mocker.patch.object(resources, "get_request_username", return_value="admin") + mocker.patch.object(resources, "get_request", return_value=None) + mocker.patch.object(resources.time, "time", return_value=201) + delay = mocker.patch("monitor_web.performance.tasks.build_host_metric_snapshot.delay") + + def worker_finishes_before_create_renewal(manifest): + store.mark_ready(manifest["snapshot_id"], expected_sections=set()) + return False + + mocker.patch.object(store, "renew_capacity", side_effect=worker_finishes_before_create_renewal) + + response = resources.CreateHostMetricSnapshotResource().perform_request( + {"bk_biz_id": 2, "start_time": 100, "end_time": 200} + ) + + assert response["state"] == snapshot.SnapshotState.READY + assert store.get_manifest(response["snapshot_id"])["state"] == snapshot.SnapshotState.READY + delay.assert_called_once_with(response["snapshot_id"]) + + +def test_snapshot_create_reusing_ready_manifest_does_not_return_unvalidated_section_data(mocker): + resources = import_module("monitor_web.performance.resources") + cache = FakeCache() + store = snapshot.HostMetricSnapshotStore(cache=cache) + mocker.patch.object(resources, "HostMetricSnapshotStore", return_value=store) + get_hosts = mocker.patch.object(resources.api.cmdb, "get_host_by_topo_node", return_value=HOSTS[:2]) + mocker.patch.object(resources, "get_request_tenant_id", return_value="system") + mocker.patch.object(resources, "get_request_username", return_value="admin") + mocker.patch.object(resources, "get_request", return_value=None) + mocker.patch.object(resources.time, "time", return_value=201) + delay = mocker.patch("monitor_web.performance.tasks.build_host_metric_snapshot.delay") + params = {"bk_biz_id": 2, "start_time": 100, "end_time": 200} + created = resources.CreateHostMetricSnapshotResource().perform_request(params) + snapshot_id = created["snapshot_id"] + store.update_manifest( + snapshot_id, + host_count=2, + host_ids_hash=snapshot.build_host_ids_hash(host.bk_host_id for host in HOSTS[:2]), + ) + for section in snapshot.SNAPSHOT_SECTIONS: + store.write_section(snapshot_id, section, {HOSTS[0].bk_host_id: {"section": section}}) + store.mark_section_ready(snapshot_id, section) + store.mark_ready(snapshot_id, expected_sections=set(snapshot.SNAPSHOT_SECTIONS)) + + reused = resources.CreateHostMetricSnapshotResource().perform_request(params) + + assert reused["state"] == snapshot.SnapshotState.READY + assert reused["revision"] == 0 + assert reused["data"] == {} + get_hosts.assert_not_called() + delay.assert_called_once_with(snapshot_id) + + get_hosts.return_value = HOSTS[:1] + polled = resources.GetHostMetricSnapshotResource().perform_request( + { + **params, + "start_time": reused["canonical_start_time"], + "end_time": reused["canonical_end_time"], + "snapshot_id": snapshot_id, + "since_revision": reused["revision"], + } + ) + assert polled["state"] == snapshot.SnapshotState.EXPIRED + assert polled["data"] == {} + + +def test_snapshot_running_poll_without_new_data_does_not_resolve_full_business_hosts(mocker): + resources = import_module("monitor_web.performance.resources") + cache = FakeCache() + store = snapshot.HostMetricSnapshotStore(cache=cache) + mocker.patch.object(resources, "HostMetricSnapshotStore", return_value=store) + mocker.patch.object(resources, "get_request_tenant_id", return_value="system") + mocker.patch.object(resources, "get_request", return_value=None) + mocker.patch.object(resources.time, "time", return_value=201) + get_hosts = mocker.patch.object(resources.api.cmdb, "get_host_by_topo_node", return_value=HOSTS[:2]) + mocker.patch("monitor_web.performance.tasks.build_host_metric_snapshot.delay") + created = resources.CreateHostMetricSnapshotResource().perform_request( + {"bk_biz_id": 2, "start_time": 100, "end_time": 200} + ) + get_hosts.reset_mock() + + result = resources.GetHostMetricSnapshotResource().perform_request( + { + "bk_biz_id": 2, + "snapshot_id": created["snapshot_id"], + "start_time": created["canonical_start_time"], + "end_time": created["canonical_end_time"], + "since_revision": 0, + } + ) + + assert result["state"] == snapshot.SnapshotState.RUNNING + get_hosts.assert_not_called() + + +@pytest.mark.parametrize("mismatch", ["tenant", "business", "scope", "time"]) +def test_snapshot_poll_binding_mismatch_is_indistinguishable_from_unknown_and_does_not_touch_snapshot(mocker, mismatch): + resources = import_module("monitor_web.performance.resources") + store = snapshot.HostMetricSnapshotStore(cache=FakeCache()) + mocker.patch.object(resources, "HostMetricSnapshotStore", return_value=store) + tenant = mocker.patch.object(resources, "get_request_tenant_id", return_value="system") + mocker.patch.object(resources, "get_request", return_value=None) + mocker.patch.object(resources, "get_request_username", return_value="admin") + mocker.patch.object(resources.time, "time", return_value=201) + mocker.patch("monitor_web.performance.tasks.build_host_metric_snapshot.delay") + created = resources.CreateHostMetricSnapshotResource().perform_request( + {"bk_biz_id": 2, "start_time": 100, "end_time": 200} + ) + params = { + "bk_biz_id": 2, + "snapshot_id": created["snapshot_id"], + "start_time": created["canonical_start_time"], + "end_time": created["canonical_end_time"], + } + if mismatch == "tenant": + tenant.return_value = "other-tenant" + elif mismatch == "business": + params["bk_biz_id"] = 3 + elif mismatch == "scope": + params["bk_host_id"] = HOSTS[0].bk_host_id + else: + params["start_time"] += 1 + + build_response = mocker.patch.object(store, "build_response") + expire = mocker.patch.object(store, "expire") + expected = resources.GetHostMetricSnapshotResource().perform_request({**params, "snapshot_id": "unknown-snapshot"}) + actual = resources.GetHostMetricSnapshotResource().perform_request(params) + + assert {key: value for key, value in actual.items() if key != "snapshot_id"} == { + key: value for key, value in expected.items() if key != "snapshot_id" + } + assert actual["state"] == snapshot.SnapshotState.EXPIRED + assert actual["data"] == {} + build_response.assert_not_called() + expire.assert_not_called() + + +def test_snapshot_data_bearing_poll_expires_when_resolved_host_set_changes(mocker): + resources = import_module("monitor_web.performance.resources") + cache = FakeCache() + store = snapshot.HostMetricSnapshotStore(cache=cache) + mocker.patch.object(resources, "HostMetricSnapshotStore", return_value=store) + mocker.patch.object(resources, "get_request_tenant_id", return_value="system") + mocker.patch.object(resources, "get_request", return_value=None) + mocker.patch.object(resources.time, "time", return_value=201) + get_hosts = mocker.patch.object(resources.api.cmdb, "get_host_by_topo_node", return_value=HOSTS[:2]) + mocker.patch("monitor_web.performance.tasks.build_host_metric_snapshot.delay") + created = resources.CreateHostMetricSnapshotResource().perform_request( + {"bk_biz_id": 2, "start_time": 100, "end_time": 200} + ) + store.update_manifest( + created["snapshot_id"], + host_count=2, + host_ids_hash=snapshot.build_host_ids_hash(host.bk_host_id for host in HOSTS[:2]), + ) + store.write_section(created["snapshot_id"], "agent_status", {HOSTS[0].bk_host_id: {"status": 1}}) + store.mark_section_ready(created["snapshot_id"], "agent_status") + get_hosts.reset_mock() + get_hosts.return_value = HOSTS[:1] + + result = resources.GetHostMetricSnapshotResource().perform_request( + { + "bk_biz_id": 2, + "snapshot_id": created["snapshot_id"], + "start_time": created["canonical_start_time"], + "end_time": created["canonical_end_time"], + "since_revision": 0, + } + ) + + assert result["state"] == snapshot.SnapshotState.EXPIRED + assert result["expired"] is True + get_hosts.assert_called_once_with(bk_biz_id=2) + + +def test_snapshot_poll_revalidates_hash_when_section_completes_during_manifest_read(mocker): + resources = import_module("monitor_web.performance.resources") + cache = FakeCache() + store = snapshot.HostMetricSnapshotStore(cache=cache) + mocker.patch.object(resources, "HostMetricSnapshotStore", return_value=store) + mocker.patch.object(resources, "get_request_tenant_id", return_value="system") + mocker.patch.object(resources, "get_request", return_value=None) + mocker.patch.object(resources.time, "time", return_value=201) + get_hosts = mocker.patch.object(resources.api.cmdb, "get_host_by_topo_node", return_value=HOSTS[:2]) + mocker.patch("monitor_web.performance.tasks.build_host_metric_snapshot.delay") + created = resources.CreateHostMetricSnapshotResource().perform_request( + {"bk_biz_id": 2, "start_time": 100, "end_time": 200} + ) + store.update_manifest( + created["snapshot_id"], + host_count=2, + host_ids_hash=snapshot.build_host_ids_hash(host.bk_host_id for host in HOSTS[:2]), + ) + get_hosts.reset_mock() + get_hosts.return_value = HOSTS[:1] + + def publish_then_build(snapshot_id, **kwargs): + store.write_section(snapshot_id, "agent_status", {HOSTS[0].bk_host_id: {"status": 1}}) + store.mark_section_ready(snapshot_id, "agent_status") + return snapshot.HostMetricSnapshotStore.build_response(store, snapshot_id, **kwargs) + + mocker.patch.object(store, "build_response", side_effect=publish_then_build) + + result = resources.GetHostMetricSnapshotResource().perform_request( + { + "bk_biz_id": 2, + "snapshot_id": created["snapshot_id"], + "start_time": created["canonical_start_time"], + "end_time": created["canonical_end_time"], + "since_revision": 0, + } + ) + + assert result["state"] == snapshot.SnapshotState.EXPIRED + assert result["data"] == {} + get_hosts.assert_called_once_with(bk_biz_id=2) + + +def test_snapshot_task_only_marks_ready_after_all_sections_succeed(mocker): + tasks = import_module("monitor_web.performance.tasks") + cache = FakeCache() + store = snapshot.HostMetricSnapshotStore(cache=cache) + manifest, _ = store.create_or_get( + "sha256:fingerprint", + { + **make_payload(snapshot.build_host_ids_hash([host.bk_host_id for host in HOSTS[:2]])), + "bk_tenant_id": "system", + "username": "admin", + }, + ) + mocker.patch.object(tasks, "HostMetricSnapshotStore", return_value=store) + mocker.patch.object(tasks, "resolve_host_metric_snapshot_scope", return_value=({"type": "business"}, HOSTS[:2])) + mocker.patch.object(tasks.time, "time", return_value=200) + mocker.patch.object(tasks, "_build_snapshot_section", side_effect=lambda section, *_: {1: {"section": section}}) + + tasks.build_host_metric_snapshot.run(manifest["snapshot_id"]) + + response = store.build_response(manifest["snapshot_id"], since_revision=0, now=200) + assert response["state"] == snapshot.SnapshotState.READY + assert set(response["data"]) == set(tasks.SNAPSHOT_SECTIONS) + assert response["revision"] == len(tasks.SNAPSHOT_SECTIONS) + + +def test_snapshot_task_does_not_compute_after_capacity_lease_is_lost(mocker): + tasks = import_module("monitor_web.performance.tasks") + cache = FakeCache() + store = snapshot.HostMetricSnapshotStore(cache=cache) + manifest, _ = store.create_or_get( + "sha256:fingerprint", + { + **make_payload(), + "username": "admin", + }, + ) + cache.zsets[manifest["capacity_lease_key"]] = {} + mocker.patch.object(tasks, "HostMetricSnapshotStore", return_value=store) + resolve_scope = mocker.patch.object(tasks, "resolve_host_metric_snapshot_scope") + build_section = mocker.patch.object(tasks, "_build_snapshot_section") + mocker.patch.object(tasks.time, "time", return_value=200) + + tasks.build_host_metric_snapshot.run(manifest["snapshot_id"]) + + assert store.get_manifest(manifest["snapshot_id"])["state"] == snapshot.SnapshotState.RUNNING + resolve_scope.assert_not_called() + build_section.assert_not_called() + + +def test_snapshot_task_fails_when_any_section_fails(mocker): + tasks = import_module("monitor_web.performance.tasks") + cache = FakeCache() + store = snapshot.HostMetricSnapshotStore(cache=cache) + manifest, _ = store.create_or_get( + "sha256:fingerprint", + { + **make_payload(snapshot.build_host_ids_hash([host.bk_host_id for host in HOSTS[:2]])), + "bk_tenant_id": "system", + "username": "admin", + }, + ) + mocker.patch.object(tasks, "HostMetricSnapshotStore", return_value=store) + mocker.patch.object(tasks, "resolve_host_metric_snapshot_scope", return_value=({"type": "business"}, HOSTS[:2])) + mocker.patch.object(tasks.time, "time", return_value=200) + + def build(section, *_): + if section == "performance_data": + raise RuntimeError("partial") + return {1: {"section": section}} + + mocker.patch.object(tasks, "_build_snapshot_section", side_effect=build) + + tasks.build_host_metric_snapshot.run(manifest["snapshot_id"]) + + response = store.build_response(manifest["snapshot_id"], now=200) + assert response["state"] == snapshot.SnapshotState.FAILED + assert response["failed_sections"] == ["performance_data"] + + +def test_snapshot_task_publishes_ready_empty_sections_for_legitimate_empty_scope(mocker): + tasks = import_module("monitor_web.performance.tasks") + store = snapshot.HostMetricSnapshotStore(cache=FakeCache()) + manifest, _ = store.create_or_get( + "sha256:fingerprint", + { + **make_payload(""), + "host_count": 0, + "bk_tenant_id": "system", + "username": "admin", + }, + ) + mocker.patch.object(tasks, "HostMetricSnapshotStore", return_value=store) + resolve_scope = mocker.patch.object( + tasks, + "resolve_host_metric_snapshot_scope", + return_value=({"type": "business"}, []), + ) + build_section = mocker.patch.object(tasks, "_build_snapshot_section", return_value={}) + mocker.patch.object(tasks.time, "time", return_value=200) + + tasks.build_host_metric_snapshot.run(manifest["snapshot_id"]) + + response = store.build_response(manifest["snapshot_id"], now=200) + assert response["state"] == snapshot.SnapshotState.READY + assert response["host_count"] == 0 + assert response["host_ids_hash"] == snapshot.build_host_ids_hash([]) + resolve_scope.assert_called_once() + assert build_section.call_count == len(tasks.SNAPSHOT_SECTIONS) + + +def test_snapshot_task_fails_when_scope_resolution_raises(mocker): + tasks = import_module("monitor_web.performance.tasks") + store = snapshot.HostMetricSnapshotStore(cache=FakeCache()) + manifest, _ = store.create_or_get( + "sha256:fingerprint", + { + **make_payload(""), + "host_count": 0, + "bk_tenant_id": "system", + "username": "admin", + }, + ) + mocker.patch.object(tasks, "HostMetricSnapshotStore", return_value=store) + mocker.patch.object(tasks, "resolve_host_metric_snapshot_scope", side_effect=RuntimeError("cmdb unavailable")) + build_section = mocker.patch.object(tasks, "_build_snapshot_section") + mocker.patch.object(tasks.time, "time", return_value=200) + + tasks.build_host_metric_snapshot.run(manifest["snapshot_id"]) + + response = store.build_response(manifest["snapshot_id"], now=200) + assert response["state"] == snapshot.SnapshotState.FAILED + assert store.get_manifest(manifest["snapshot_id"])["error_code"] == "task_failed" + build_section.assert_not_called() + + +def test_full_business_snapshot_section_uses_explicit_empty_target_filter(mocker): + tasks = import_module("monitor_web.performance.tasks") + get_agent_status = mocker.patch.object(tasks.SearchHostMetricResource, "get_agent_status") + + tasks._build_snapshot_section( + "agent_status", + 2, + HOSTS[:1], + 100, + 200, + {"type": "business"}, + ) + + assert get_agent_status.call_args.kwargs["target_filter"] == {} + assert get_agent_status.call_args.kwargs["fail_on_incomplete"] is True + + +def test_full_business_snapshot_alarm_section_omits_linear_host_ip_terms(mocker): + tasks = import_module("monitor_web.performance.tasks") + get_alarm_count = mocker.patch.object(tasks.SearchHostMetricResource, "get_alarm_count") + + tasks._build_snapshot_section( + "alarm_count", + 2, + HOSTS[:1], + 100, + 200, + {"type": "business"}, + ) + + assert get_alarm_count.call_args.kwargs["filter_by_host_ip"] is False + + +def test_snapshot_section_sets_tenant_and_user_context_inside_worker_thread(mocker): + tasks = import_module("monitor_web.performance.tasks") + set_tenant = mocker.patch.object(tasks, "set_local_tenant_id") + set_user = mocker.patch.object(tasks, "set_local_username") + mocker.patch.object(tasks.SearchHostMetricResource, "get_agent_status") + + tasks._build_snapshot_section( + "agent_status", + 2, + HOSTS[:1], + 100, + 200, + {"type": "business"}, + bk_tenant_id="tenant-a", + username="admin", + ) + + set_tenant.assert_called_once_with("tenant-a") + set_user.assert_called_once_with("admin") + + +def test_snapshot_task_is_dedicated_and_ignores_celery_result(): + tasks = import_module("monitor_web.performance.tasks") + + assert tasks.build_host_metric_snapshot.ignore_result is True + assert tasks.build_host_metric_snapshot.queue == "celery_resource" + + +def test_snapshot_task_is_registered_from_monitor_web_task_module(): + root_tasks = import_module("monitor_web.tasks") + snapshot_tasks = import_module("monitor_web.performance.tasks") + + assert root_tasks.build_host_metric_snapshot is snapshot_tasks.build_host_metric_snapshot diff --git a/bkmonitor/packages/monitor_web/tests/performance/test_search_host_info.py b/bkmonitor/packages/monitor_web/tests/performance/test_search_host_info.py index 4c4209aea5..b317be99b4 100644 --- a/bkmonitor/packages/monitor_web/tests/performance/test_search_host_info.py +++ b/bkmonitor/packages/monitor_web/tests/performance/test_search_host_info.py @@ -50,3 +50,33 @@ def test_search_host_info_propagates_topology_query_failure(mocker): with pytest.raises(RuntimeError, match="topology query failed"): SearchHostInfoResource().perform_request({"bk_biz_id": 2}) + + +def test_search_host_info_returns_ipv6_as_base_host_identity(mocker): + host = mocker.Mock( + display_name="host-1", + bk_host_id=1, + bk_biz_id=2, + bk_cloud_id=0, + bk_cloud_name="default", + bk_host_innerip="10.0.0.1", + bk_host_innerip_v6="2001:db8::1", + bk_host_outerip="1.1.1.1", + bk_host_outerip_v6="2001:db8::2", + bk_os_type="1", + bk_os_name="Linux", + bk_province_name="", + bk_host_name="host-1", + ignore_monitoring=False, + is_shielding=False, + bk_module_ids=[], + ) + mocker.patch("monitor_web.performance.resources.api.cmdb.get_host_by_topo_node", return_value=[host]) + topo_tree = mocker.Mock() + topo_tree.convert_to_topo_link.return_value = {} + mocker.patch("monitor_web.performance.resources.api.cmdb.get_topo_tree", return_value=topo_tree) + + result = SearchHostInfoResource().perform_request({"bk_biz_id": 2}) + + assert result[0]["bk_host_innerip_v6"] == "2001:db8::1" + assert result[0]["bk_host_outerip_v6"] == "2001:db8::2" diff --git a/bkmonitor/packages/monitor_web/tests/performance/test_search_host_metric.py b/bkmonitor/packages/monitor_web/tests/performance/test_search_host_metric.py index 4165cae966..bfd40cbba3 100644 --- a/bkmonitor/packages/monitor_web/tests/performance/test_search_host_metric.py +++ b/bkmonitor/packages/monitor_web/tests/performance/test_search_host_metric.py @@ -1,4 +1,6 @@ +from types import SimpleNamespace from unittest.mock import Mock +from importlib import import_module import pytest @@ -183,3 +185,271 @@ def test_reused_agent_and_process_helpers_keep_partial_degradation_by_default(mo assert agent_status assert process_status == {} + + +@pytest.mark.parametrize( + "helper_name", + ["get_agent_status", "get_host_performance_data", "get_process_status"], +) +def test_snapshot_can_explicitly_omit_uq_host_target_without_losing_host_whitelist(mocker, helper_name): + data_source_class = Mock(return_value=Mock()) + mocker.patch("monitor_web.cc.resources.cmdb.load_data_source", return_value=data_source_class) + query = Mock(is_partial=False) + query.query_data.return_value = [] + mocker.patch("monitor_web.cc.resources.cmdb.UnifyQuery", return_value=query) + mocker.patch("monitor_web.cc.resources.cmdb.api.node_man.ipchooser_host_detail", return_value=[]) + + helper = getattr(resource.cc, helper_name) + result = helper(bk_biz_id=2, hosts=HOSTS[:1], target_filter={}) + + assert data_source_class.call_args.kwargs["filter_dict"] == {} + assert set(result).issubset({HOSTS[0].bk_host_id}) + + +def test_existing_host_metric_helpers_keep_builder_when_target_filter_is_unspecified(mocker): + expected_filter = {"targets": [{"bk_host_id": [str(HOSTS[0].bk_host_id)]}]} + build_filter = mocker.patch( + "monitor_web.cc.resources.cmdb._build_host_target_filter", + return_value=expected_filter, + ) + data_source_class = Mock(return_value=Mock()) + mocker.patch("monitor_web.cc.resources.cmdb.load_data_source", return_value=data_source_class) + query = Mock(is_partial=False) + query.query_data.return_value = [] + mocker.patch("monitor_web.cc.resources.cmdb.UnifyQuery", return_value=query) + + resource.cc.get_host_performance_data(bk_biz_id=2, hosts=HOSTS[:1]) + + build_filter.assert_called_once_with(2, HOSTS[:1]) + assert data_source_class.call_args.kwargs["filter_dict"] == expected_filter + + +def test_strict_agent_status_rejects_nodeman_chunk_failure(mocker): + mocker.patch("monitor_web.cc.resources.cmdb.load_data_source", return_value=Mock(return_value=Mock())) + query = Mock(is_partial=False) + query.query_data.return_value = [] + mocker.patch("monitor_web.cc.resources.cmdb.UnifyQuery", return_value=query) + failed_future = Mock() + failed_future.get.side_effect = RuntimeError("nodeman failed") + pool = Mock() + pool.apply_async.return_value = failed_future + mocker.patch("monitor_web.cc.resources.cmdb.ThreadPool", return_value=pool) + + with pytest.raises(RuntimeError, match="node manager returned incomplete"): + resource.cc.get_agent_status(bk_biz_id=2, hosts=HOSTS[:1], fail_on_incomplete=True) + + +def test_default_agent_status_keeps_nodeman_chunk_failure_degradation(mocker): + mocker.patch("monitor_web.cc.resources.cmdb.load_data_source", return_value=Mock(return_value=Mock())) + query = Mock(is_partial=False) + query.query_data.return_value = [] + mocker.patch("monitor_web.cc.resources.cmdb.UnifyQuery", return_value=query) + failed_future = Mock() + failed_future.get.side_effect = RuntimeError("nodeman failed") + pool = Mock() + pool.apply_async.return_value = failed_future + mocker.patch("monitor_web.cc.resources.cmdb.ThreadPool", return_value=pool) + + result = resource.cc.get_agent_status(bk_biz_id=2, hosts=HOSTS[:1]) + + assert result == {HOSTS[0].bk_host_id: 2} + + +def test_business_alarm_query_uses_paginated_composite_aggregation_and_cmdb_whitelist(mocker): + ipv4_host = HOSTS[0] + ipv6_host = HOSTS[2] + + def aggregation_response(buckets, after_key=None): + aggregation = SimpleNamespace(buckets=buckets) + if after_key is not None: + aggregation.after_key = after_key + return SimpleNamespace(aggregations=SimpleNamespace(host_alarm_identity=aggregation)) + + responses = [ + aggregation_response( + [ + SimpleNamespace(key={"bk_host_id": str(ipv4_host.bk_host_id), "severity": 1}, doc_count=3), + SimpleNamespace(key={"bk_host_id": "999999", "severity": 1}, doc_count=99), + ], + after_key={"bk_host_id": str(ipv4_host.bk_host_id), "severity": 1}, + ), + aggregation_response( + [SimpleNamespace(key={"bk_host_id": str(ipv6_host.bk_host_id), "severity": 2}, doc_count=4)] + ), + aggregation_response( + [ + SimpleNamespace( + key={"bk_cloud_id": str(ipv4_host.bk_cloud_id), "ip": ipv4_host.bk_host_innerip, "severity": 1}, + doc_count=2, + ), + SimpleNamespace(key={"bk_cloud_id": "0", "ip": "203.0.113.1", "severity": 1}, doc_count=88), + ] + ), + aggregation_response( + [ + SimpleNamespace( + key={ + "bk_cloud_id": str(ipv6_host.bk_cloud_id), + "ipv6": ipv6_host.bk_host_innerip_v6, + "severity": 3, + }, + doc_count=5, + ) + ] + ), + ] + searches = [] + for response in responses: + search = Mock() + search.filter.return_value = search + search.exclude.return_value = search + search.extra.return_value = search + search.execute.return_value = response + search.scan.side_effect = AssertionError("business snapshot must not scan alert documents") + searches.append(search) + search_api = mocker.patch("monitor_web.cc.resources.cmdb.AlertDocument.search", side_effect=searches) + + result = resource.cc.get_host_alarm_count( + bk_biz_id=2, + hosts=[ipv4_host, ipv6_host], + start_time=100, + end_time=200, + filter_by_host_ip=False, + ) + + assert result[ipv4_host.bk_host_id] == {1: 5, 2: 0, 3: 0} + assert result[ipv6_host.bk_host_id] == {1: 0, 2: 4, 3: 5} + assert search_api.call_count == 4 + first_composite = searches[0].aggs.bucket.call_args.kwargs + second_composite = searches[1].aggs.bucket.call_args.kwargs + assert first_composite["size"] == 1000 + assert "after" not in first_composite + assert second_composite["after"] == {"bk_host_id": str(ipv4_host.bk_host_id), "severity": 1} + + def identity_queries(search, method): + return [ + call.args[0].to_dict() + for call in getattr(search, method).call_args_list + if call.args and hasattr(call.args[0], "to_dict") + ] + + non_empty_host_id = { + "bool": { + "filter": [{"exists": {"field": "event.bk_host_id"}}], + "must_not": [{"term": {"event.bk_host_id": ""}}], + } + } + non_empty_ipv4 = { + "bool": { + "filter": [{"exists": {"field": "event.ip"}}], + "must_not": [{"term": {"event.ip": ""}}], + } + } + non_empty_ipv6 = { + "bool": { + "filter": [{"exists": {"field": "event.ipv6"}}], + "must_not": [{"term": {"event.ipv6": ""}}], + } + } + assert non_empty_host_id in identity_queries(searches[0], "filter") + assert non_empty_host_id in identity_queries(searches[2], "exclude") + assert non_empty_ipv4 in identity_queries(searches[2], "filter") + assert non_empty_host_id in identity_queries(searches[3], "exclude") + assert non_empty_ipv4 in identity_queries(searches[3], "exclude") + assert non_empty_ipv6 in identity_queries(searches[3], "filter") + + +def test_business_alarm_identity_priority_treats_empty_values_as_missing(): + cmdb = import_module("monitor_web.cc.resources.cmdb") + host_id = cmdb._non_empty_host_alarm_identity("event.bk_host_id") + ipv4 = cmdb._non_empty_host_alarm_identity("event.ip") + ipv6 = cmdb._non_empty_host_alarm_identity("event.ipv6") + + ipv4_query = cmdb.AlertDocument.search().exclude(host_id).filter(ipv4).to_dict()["query"] + ipv6_query = cmdb.AlertDocument.search().exclude(host_id).exclude(ipv4).filter(ipv6).to_dict()["query"] + + assert { + "bool": { + "should": [ + {"bool": {"must_not": [{"exists": {"field": "event.bk_host_id"}}]}}, + {"term": {"event.bk_host_id": ""}}, + ] + } + } in ipv4_query["bool"]["filter"] + assert { + "bool": {"filter": [{"exists": {"field": "event.ip"}}], "must_not": [{"term": {"event.ip": ""}}]} + } in ipv4_query["bool"]["filter"] + assert { + "bool": {"should": [{"bool": {"must_not": [{"exists": {"field": "event.ip"}}]}}, {"term": {"event.ip": ""}}]} + } in ipv6_query["bool"]["filter"] + assert { + "bool": {"filter": [{"exists": {"field": "event.ipv6"}}], "must_not": [{"term": {"event.ipv6": ""}}]} + } in ipv6_query["bool"]["filter"] + + +def test_scoped_alarm_query_filters_event_host_id_ipv4_and_ipv6(mocker): + known_host = HOSTS[3] + search = Mock() + search.filter.return_value = search + search.source.return_value = search + search.scan.return_value = [] + mocker.patch("monitor_web.cc.resources.cmdb.AlertDocument.search", return_value=search) + + resource.cc.get_host_alarm_count( + bk_biz_id=2, + hosts=[known_host], + start_time=100, + end_time=200, + ) + + identity_filter = next( + call.args[0] for call in search.filter.call_args_list if call.args and not isinstance(call.args[0], str) + ) + query = identity_filter.to_dict()["bool"] + assert query["minimum_should_match"] == 1 + assert {next(iter(clause["terms"])) for clause in query["should"]} == { + "event.bk_host_id", + "event.ip", + "event.ipv6", + } + assert {"terms": {"event.bk_host_id": [str(known_host.bk_host_id)]}} in query["should"] + + +def test_alarm_host_mapping_supports_event_and_dimension_host_id_and_ipv6(): + known_host = HOSTS[2] + known_host_ids = {known_host.bk_host_id} + ip_to_host_id = {(known_host.bk_host_innerip_v6, int(known_host.bk_cloud_id or 0)): known_host.bk_host_id} + + event_host = SimpleNamespace( + event=SimpleNamespace(bk_host_id=str(known_host.bk_host_id), ip="", ipv6="", bk_cloud_id=0), dimensions=[] + ) + event_ipv6 = SimpleNamespace( + event=SimpleNamespace( + bk_host_id=None, + ip="", + ipv6=known_host.bk_host_innerip_v6, + bk_cloud_id=known_host.bk_cloud_id, + ), + dimensions=[], + ) + dimension_host = SimpleNamespace( + event=SimpleNamespace(bk_host_id=None, ip="", ipv6="", bk_cloud_id=0), + dimensions=[SimpleNamespace(key="bk_host_id", value=str(known_host.bk_host_id))], + ) + dimension_ipv6 = SimpleNamespace( + event=SimpleNamespace(bk_host_id=None, ip="", ipv6="", bk_cloud_id=0), + dimensions=[ + SimpleNamespace(key="ipv6", value=known_host.bk_host_innerip_v6), + SimpleNamespace(key="bk_cloud_id", value=str(known_host.bk_cloud_id)), + ], + ) + unknown = SimpleNamespace( + event=SimpleNamespace(bk_host_id=999999, ip="203.0.113.1", ipv6="", bk_cloud_id=0), dimensions=[] + ) + + resolver = import_module("monitor_web.cc.resources.cmdb")._resolve_host_id_from_alert + assert resolver(event_host, known_host_ids, ip_to_host_id) == known_host.bk_host_id + assert resolver(event_ipv6, known_host_ids, ip_to_host_id) == known_host.bk_host_id + assert resolver(dimension_host, known_host_ids, ip_to_host_id) == known_host.bk_host_id + assert resolver(dimension_ipv6, known_host_ids, ip_to_host_id) == known_host.bk_host_id + assert resolver(unknown, known_host_ids, ip_to_host_id) is None diff --git a/bkmonitor/packages/monitor_web/tests/share/test_share_security.py b/bkmonitor/packages/monitor_web/tests/share/test_share_security.py index 8ff4ded57f..3f772fa3e3 100644 --- a/bkmonitor/packages/monitor_web/tests/share/test_share_security.py +++ b/bkmonitor/packages/monitor_web/tests/share/test_share_security.py @@ -48,6 +48,7 @@ def make_host(host_id=100, ip="10.0.0.1", cloud_id=0): bk_host_id=host_id, bk_host_innerip=ip, bk_host_innerip_v6="", + bk_host_outerip_v6="", bk_cloud_id=cloud_id, ip=ip, ) @@ -187,6 +188,8 @@ def test_scene_share_token_can_call_standard_read_action(): ("monitor_web.commons.cc.views", "GetTopoTree", "create"), ("monitor_web.performance.views", "SearchHostInfoViewSet", "create"), ("monitor_web.performance.views", "SearchHostMetricViewSet", "create"), + ("monitor_web.performance.views", "HostMetricSnapshotViewSet", "create"), + ("monitor_web.performance.views", "HostMetricSnapshotViewSet", "retrieve"), ("monitor_web.scene_view.views", "SceneViewViewSet", "get_host_or_topo_node_detail"), ("monitor_web.scene_view.views", "SceneViewViewSet", "get_host_process_port_status"), ("monitor_web.scene_view.views", "SceneViewViewSet", "get_host_process_list"), @@ -210,6 +213,8 @@ def test_scoped_host_share_token_only_allows_new_host_read_routes(module, name, ("monitor_web.scene_view.views", "SceneViewViewSet", "get_host_info"), ("monitor_web.scene_view.views", "SceneViewViewSet", "get_strategy_and_event_count"), ("monitor_web.performance.views", "HostListViewSet", "list"), + ("monitor_web.performance.views", "HostMetricSnapshotViewSet", "list"), + ("monitor_web.performance.views", "HostMetricSnapshotViewSet", "update"), ("monitor_web.commons.cc.views", "GetHostInstanceByIpViewSet", "create"), ("monitor_web.grafana.views", "GrafanaViewSet", "time_series/unify_query_raw"), ], From 3b57c2118cca80312bf30f66176535f0405ce272 Mon Sep 17 00:00:00 2001 From: chenguo Date: Fri, 14 Aug 2026 17:29:16 +0800 Subject: [PATCH 3/7] =?UTF-8?q?feat:=20=E4=B8=BB=E6=9C=BA=E5=88=97?= =?UTF-8?q?=E8=A1=A8=E6=8C=87=E6=A0=87=E6=B8=90=E8=BF=9B=E5=BC=8F=E5=8A=A0?= =?UTF-8?q?=E8=BD=BD=20--story=3D137159498=20(#11984)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit close #1010158081137159498 ## 改造内容 - 主机基础列表与全量指标快照并发加载,统一采用双路渐进模式,不按业务规模分支。 - 快照运行期间仅补齐当前页指标;页级结果只作为 overlay,不参与全局排序、筛选和统计。 - 四段快照全部成功且主机集合哈希一致后原子替换全量指标,并清理页级 overlay。 - 快照未就绪前禁用指标快捷卡、指标排序和指标筛选,基础字段检索与排序保持可用。 - 补齐 epoch、快照重试、页面卸载、翻页交错和旧响应回写等竞态保护。 ## 合入顺序与开关 - 依赖后端快照 API PR-B;请先将后端路由合入验收分支,本 PR 暂不先行合入。 - 功能开关默认关闭;部署侧需由后端 context 将 `ENABLE_HOST_METRIC_PROGRESSIVE` 注入为 `window.enable_host_metric_progressive` 后才会启用新链路。 - 开关仅用于整条渐进链灰度和回退,不引入大小业务分支。 ## 验证 - `node --test tests/host-*.test.cjs`:120/120 通过。 - 目标文件 ESLint、Biome、Stylelint、Prettier 与 `git diff --check` 通过。 - 全量 TypeScript 检查仍受仓库既有 `@vitejs/plugin-vue-jsx` moduleResolution 配置错误阻断;报错仅位于三个既有 build 脚本。 --- .../components/host-list/host-list-table.tsx | 32 +- .../host/components/host-list/host-list.scss | 13 + .../host/components/host-list/host-list.tsx | 27 +- .../components/host-list/host-stat-cards.scss | 29 +- .../components/host-list/host-stat-cards.tsx | 27 +- .../host/composables/use-host-list-worker.ts | 69 +- .../pages/host/composables/use-host-list.ts | 419 ++++++++- .../trace/pages/host/constants/host-list.ts | 13 + .../trace/pages/host/services/host-service.ts | 99 +++ .../host/types/host-metric-progressive.ts | 63 ++ .../src/trace/pages/host/types/host.ts | 4 +- .../host/workers/host-list.worker.raw.js | 112 ++- bkmonitor/webpack/src/trace/shim.d.ts | 1 + .../tests/host-list-consistency.test.cjs | 800 +++++++++++++++++- .../host-list-progressive-metrics.test.cjs | 191 +++++ .../tests/host-service-error-state.test.cjs | 175 +++- .../tests/host-topo-load-consistency.test.cjs | 3 + 17 files changed, 2002 insertions(+), 75 deletions(-) create mode 100644 bkmonitor/webpack/src/trace/pages/host/types/host-metric-progressive.ts create mode 100644 bkmonitor/webpack/tests/host-list-progressive-metrics.test.cjs diff --git a/bkmonitor/webpack/src/trace/pages/host/components/host-list/host-list-table.tsx b/bkmonitor/webpack/src/trace/pages/host/components/host-list/host-list-table.tsx index 5a8fafe92b..08ccca01b6 100644 --- a/bkmonitor/webpack/src/trace/pages/host/components/host-list/host-list-table.tsx +++ b/bkmonitor/webpack/src/trace/pages/host/components/host-list/host-list-table.tsx @@ -55,6 +55,7 @@ import { HOST_LIST_ELLIPSIS_CELL_CLASS, HOST_LIST_PAGE_SIZE_LIST, HOST_METRIC_HEADER_ICON_MAP, + HOST_PROGRESSIVE_METRIC_FIELD_IDS, HOST_STATUS_MAP, HOST_STATUS_TIPS_MAP, PROCESS_STATUS_TIPS_MAP, @@ -171,6 +172,11 @@ export default defineComponent({ type: Boolean, default: false, }, + /** 全量快照未就绪时禁用所有依赖指标的全局排序。 */ + metricSemanticsReady: { + type: Boolean, + default: true, + }, /** 置顶配置 */ markValue: { type: Object as PropType>, @@ -363,7 +369,11 @@ export default defineComponent({ const tableSort = computed(() => { if (!props.sort) return []; const descending = props.sort.startsWith('-'); - return [{ sortBy: descending ? props.sort.slice(1) : props.sort, descending }]; + const sortBy = descending ? props.sort.slice(1) : props.sort; + if (!props.metricSemanticsReady && HOST_PROGRESSIVE_METRIC_FIELD_IDS.has(sortBy)) { + return []; + } + return [{ sortBy, descending }]; }); /** 字段设置:全部字段 + 当前展示字段 */ @@ -570,7 +580,10 @@ export default defineComponent({ const renderMetricHeader = (column: IHostColumnConfig) => { const iconClass = HOST_METRIC_HEADER_ICON_MAP[column.id]; return ( -
+
{iconClass && } {t(column.name)}
@@ -601,7 +614,15 @@ export default defineComponent({ /** 构建某一列的 tdesign 配置 */ const buildColumn = (config: IHostColumnConfig) => { - let title = () => {t(config.name)}; + const metricSemanticsDisabled = !props.metricSemanticsReady && HOST_PROGRESSIVE_METRIC_FIELD_IDS.has(config.id); + let title = () => ( + + {t(config.name)} + + ); if (config.type === 'checkbox') { title = () => renderCheckboxHeader(); } else if (config.type === 'metric') { @@ -612,7 +633,7 @@ export default defineComponent({ title, minWidth: config.minWidth, width: config.width, - sorter: config.sortable, + sorter: config.sortable && !metricSemanticsDisabled, ellipsis: false, fixed: config.fixed, }; @@ -661,6 +682,9 @@ export default defineComponent({ const handleSortChange = (sortEvent: TableSort) => { const target = Array.isArray(sortEvent) ? sortEvent[0] : sortEvent; + if (!props.metricSemanticsReady && target?.sortBy && HOST_PROGRESSIVE_METRIC_FIELD_IDS.has(target.sortBy)) { + return; + } emit('sortChange', target?.sortBy ? `${target.descending ? '-' : ''}${target.sortBy}` : ''); }; diff --git a/bkmonitor/webpack/src/trace/pages/host/components/host-list/host-list.scss b/bkmonitor/webpack/src/trace/pages/host/components/host-list/host-list.scss index a6bbce741c..d95ee69289 100644 --- a/bkmonitor/webpack/src/trace/pages/host/components/host-list/host-list.scss +++ b/bkmonitor/webpack/src/trace/pages/host/components/host-list/host-list.scss @@ -11,6 +11,19 @@ gap: 8px; margin: 16px 0; } + + &__metric-progress { + display: flex; + align-items: center; + justify-content: space-between; + padding: 8px 12px; + margin-bottom: 8px; + font-size: 12px; + line-height: 20px; + color: #63656e; + background: #f0f5ff; + border-radius: 2px; + } } /** 真实内容容器 */ diff --git a/bkmonitor/webpack/src/trace/pages/host/components/host-list/host-list.tsx b/bkmonitor/webpack/src/trace/pages/host/components/host-list/host-list.tsx index b3f873d4dc..c4846f70f8 100644 --- a/bkmonitor/webpack/src/trace/pages/host/components/host-list/host-list.tsx +++ b/bkmonitor/webpack/src/trace/pages/host/components/host-list/host-list.tsx @@ -26,6 +26,7 @@ import { type PropType, computed, defineComponent, toRef } from 'vue'; +import { Button } from 'bkui-vue'; import { storeToRefs } from 'pinia'; import { useHostStore } from 'trace/store/modules/host'; @@ -112,7 +113,8 @@ export default defineComponent({ class='host-list-content' > ctx.handleCategoryClick(key)} /> @@ -128,7 +130,7 @@ export default defineComponent({ /> {ctx.filterExpanded.value && ( )}
+ {!ctx.metricSemanticsReady.value && ( +
+ + {['EXPIRED', 'FAILED', 'UNAVAILABLE'].includes(ctx.metricProgressiveState.value) + ? window.i18n.t('全量指标暂不可用,当前按页加载指标') + : window.i18n.t('全量指标准备中,当前按页加载指标')} + + {['EXPIRED', 'FAILED', 'UNAVAILABLE'].includes(ctx.metricProgressiveState.value) && ( + + )} +
+ )} 0 && ctx.total.value === 0 ? 'search-empty' : 'empty'} markValue={ctx.stickyValue.value} metricLoadError={ctx.metricLoadError.value} metricLoading={ctx.metricLoading.value} + metricSemanticsReady={ctx.metricSemanticsReady.value} page={ctx.page.value} pageSize={ctx.pageSize.value} readonly={props.readonly} selectedRowKeys={ctx.selectedRowKeys.value} selectType={ctx.selectType.value} - sort={ctx.sortInfo.value} + sort={ctx.availableSortInfo.value} total={ctx.total.value} visibleColumns={ctx.visibleColumns.value} onClearFilter={ctx.handleClearFilter} diff --git a/bkmonitor/webpack/src/trace/pages/host/components/host-list/host-stat-cards.scss b/bkmonitor/webpack/src/trace/pages/host/components/host-list/host-stat-cards.scss index 795eee1202..fe88fa3b5b 100644 --- a/bkmonitor/webpack/src/trace/pages/host/components/host-list/host-stat-cards.scss +++ b/bkmonitor/webpack/src/trace/pages/host/components/host-list/host-stat-cards.scss @@ -1,3 +1,13 @@ +.host-stat-cards-active { + position: absolute; + top: 0; + left: 0; + display: none; + width: 100%; + height: 4px; + background: #3a84ff; +} + .host-stat-cards { display: flex; gap: 16px; @@ -24,20 +34,19 @@ &.is-active { background: #e1ecff; - .host-stat-cards__active-bar { + .host-stat-cards-active { display: block; } } - } - &__active-bar { - position: absolute; - top: 0; - left: 0; - display: none; - width: 100%; - height: 4px; - background: #3a84ff; + &.is-disabled { + cursor: not-allowed; + opacity: 0.6; + + &:hover { + background: #f5f7fa; + } + } } &__icon { diff --git a/bkmonitor/webpack/src/trace/pages/host/components/host-list/host-stat-cards.tsx b/bkmonitor/webpack/src/trace/pages/host/components/host-list/host-stat-cards.tsx index 4e8d8f3ede..d844fac2f1 100644 --- a/bkmonitor/webpack/src/trace/pages/host/components/host-list/host-stat-cards.tsx +++ b/bkmonitor/webpack/src/trace/pages/host/components/host-list/host-stat-cards.tsx @@ -28,13 +28,13 @@ import { type PropType, defineComponent } from 'vue'; import { useI18n } from 'vue-i18n'; -import { HOST_QUICK_CARD_LIST } from '../../constants/host-list'; - -import type { EHostQuickCategory, IHostQuickCardStats } from '../../types/host-list'; import AlarmHostIcon from '../../../../static/img/alarm-host.png'; import CpuUsageIcon from '../../../../static/img/cpu-usage.png'; import DiskUsageIcon from '../../../../static/img/disk-usage.png'; import MemoryUsageIcon from '../../../../static/img/memory-usage.png'; +import { HOST_QUICK_CARD_LIST } from '../../constants/host-list'; + +import type { EHostQuickCategory, IHostQuickCardStats } from '../../types/host-list'; import './host-stat-cards.scss'; @@ -48,9 +48,14 @@ export default defineComponent({ }, /** 当前激活的分类(空为未激活) */ activeKey: { - type: String as PropType, + type: String as PropType<'' | EHostQuickCategory>, default: '', }, + /** 全量指标未就绪时禁止快捷全局过滤。 */ + disabled: { + type: Boolean, + default: false, + }, }, emits: { /** 点击卡片快速过滤(再次点击取消) */ @@ -79,14 +84,18 @@ export default defineComponent({ {HOST_QUICK_CARD_LIST.map(card => (
emit('cardClick', card.key)} + class={[ + 'host-stat-cards__item', + { 'is-active': props.activeKey === card.key, 'is-disabled': props.disabled }, + ]} + title={props.disabled ? t('全量指标准备中') : ''} + onClick={() => !props.disabled && emit('cardClick', card.key)} > -
+
{card.name}
{t(card.name)} diff --git a/bkmonitor/webpack/src/trace/pages/host/composables/use-host-list-worker.ts b/bkmonitor/webpack/src/trace/pages/host/composables/use-host-list-worker.ts index b134ec7f59..5598447d37 100644 --- a/bkmonitor/webpack/src/trace/pages/host/composables/use-host-list-worker.ts +++ b/bkmonitor/webpack/src/trace/pages/host/composables/use-host-list-worker.ts @@ -48,6 +48,36 @@ export interface IHostListComputeParams { } type WorkerResponse = + | { + applied: boolean; + epoch: number; + filterOptionsMap: Record; + rawRowCount: number; + requestId: number; + type: 'INIT_BASE_DONE'; + } + | { + applied: boolean; + epoch: number; + filterOptionsMap: Record; + requestId: number; + type: 'MERGE_METRICS_DONE'; + } + | { + applied: boolean; + epoch: number; + filterOptionsMap: Record; + requestId: number; + type: 'REPLACE_METRICS_DONE'; + } + | { + applied: boolean; + epoch: number; + filterOptionsMap: Record; + requestId: number; + type: 'RESET_METRICS_DONE'; + } + | { applied: boolean; epoch: number; requestId: number; type: 'PATCH_METRICS_DONE' } | { categoryStats: IHostQuickCardStats; pagedRows: IHostListRow[]; @@ -55,8 +85,6 @@ type WorkerResponse = total: number; type: 'COMPUTE_DONE'; } - | { filterOptionsMap: Record; rawRowCount: number; requestId: number; type: 'INIT_BASE_DONE' } - | { filterOptionsMap: Record; requestId: number; type: 'MERGE_METRICS_DONE' } | { ips: string[]; requestId: number; type: 'GET_SELECTED_IPS_DONE' } | { requestId: number; result: { count: number; list: IValue[] }; type: 'GET_FILTER_OPTIONS_DONE' } | { requestId: number; rowKeys: string[]; type: 'GET_FILTERED_ROW_KEYS_DONE' } @@ -108,9 +136,13 @@ export const useHostListWorker = () => { const worker = shallowRef(null); let requestSeq = 0; let latestComputeId = 0; + let disposed = false; const pendingRequests = new Map void; resolve: (value: unknown) => void }>(); const ensureWorker = () => { + if (disposed) { + throw new Error('host list worker has been disposed'); + } if (worker.value) { return worker.value; } @@ -160,18 +192,40 @@ export const useHostListWorker = () => { onComputeDone = handler; }; - const initBaseData = (baseList: IHostBaseInfo[]) => + const initBaseData = (baseList: IHostBaseInfo[], epoch?: number) => postRequest>({ baseList, + epoch, type: 'INIT_BASE', }); - const mergeMetrics = (metricListMap: Record) => + const mergeMetrics = (metricListMap: Record>) => postRequest>({ metricListMap, type: 'MERGE_METRICS', }); + const resetMetrics = (epoch: number) => + postRequest>({ + epoch, + type: 'RESET_METRICS', + }); + + const patchMetrics = (epoch: number, hostIds: number[], metricListMap: Record>) => + postRequest>({ + epoch, + hostIds, + metricListMap, + type: 'PATCH_METRICS', + }); + + const replaceMetrics = (epoch: number, metricListMap: Record>) => + postRequest>({ + epoch, + metricListMap, + type: 'REPLACE_METRICS', + }); + const computeNow = (params: IHostListComputeParams) => { latestComputeId = ++requestSeq; ensureWorker().postMessage({ @@ -218,8 +272,12 @@ export const useHostListWorker = () => { }); onScopeDispose(() => { + disposed = true; worker.value?.terminate(); worker.value = null; + for (const { reject } of pendingRequests.values()) { + reject(new Error('host list worker has been disposed')); + } pendingRequests.clear(); }); @@ -230,6 +288,9 @@ export const useHostListWorker = () => { getSelectedIps, initBaseData, mergeMetrics, + patchMetrics, + replaceMetrics, + resetMetrics, scheduleCompute, setComputeHandler, getFilterOptionsMap, diff --git a/bkmonitor/webpack/src/trace/pages/host/composables/use-host-list.ts b/bkmonitor/webpack/src/trace/pages/host/composables/use-host-list.ts index 3817413d03..5f2163125e 100644 --- a/bkmonitor/webpack/src/trace/pages/host/composables/use-host-list.ts +++ b/bkmonitor/webpack/src/trace/pages/host/composables/use-host-list.ts @@ -24,7 +24,7 @@ * IN THE SOFTWARE. */ -import { type Ref, type ShallowRef, computed, onMounted, shallowRef, watch } from 'vue'; +import { type Ref, type ShallowRef, computed, onMounted, onScopeDispose, shallowRef, watch } from 'vue'; import { useDebounceFn } from '@vueuse/core'; import { Message } from 'bkui-vue'; @@ -39,8 +39,14 @@ import { handleTransformToTimestamp } from '../../../components/time-range/utils import useUserConfig from '../../../hooks/useUserConfig'; import { useHostStore } from '../../../store/modules/host'; import { HostSelectAllModeEnum } from '../constants/enum'; -import { HOST_FILTER_FIELDS, HOST_LIST_COLUMNS, HOST_LIST_DEFAULT_PAGE_SIZE } from '../constants/host-list'; -import { getHostInfoList, getHostMetricInfoList } from '../services/host-service'; +import { + HOST_FILTER_FIELDS, + HOST_LIST_COLUMNS, + HOST_LIST_DEFAULT_PAGE_SIZE, + HOST_PROGRESSIVE_METRIC_FIELD_IDS, +} from '../constants/host-list'; +import { getHostInfoList, getHostMetricInfoList, hostMetricSnapshotService } from '../services/host-service'; +import { HOST_METRIC_SNAPSHOT_SECTIONS } from '../types/host-metric-progressive'; import { resolveHostRequestScope } from '../utils/share-scope'; import { useHostListWorker } from './use-host-list-worker'; import { useHostUrlParams } from './use-host-url-params'; @@ -57,12 +63,22 @@ import type { IHostQuickCardStats, TCopyIpField, } from '../types'; +import type { IHostMetricInfo } from '../types/host'; +import type { + HostMetricProgressiveState, + IHostMetricSnapshotQuery, + IHostMetricSnapshotResult, + IHostMetricSnapshotSection, + IHostMetricSnapshotService, +} from '../types/host-metric-progressive'; import type { IHostTopoTreeNode } from '../types/topo'; interface IUseHostListOptions { activeCategory: ShallowRef<'' | EHostQuickCategory>; filterExpanded: ShallowRef; keyword: ShallowRef; + /** 快照接口的领域适配器;具体 HTTP 契约只在 service 层实现 */ + progressiveMetricService?: IHostMetricSnapshotService; readonly: boolean; /** 当前选中的拓扑节点(页面层注入),用于联动过滤主机列表 */ selectedNode: Ref; @@ -84,6 +100,8 @@ export const useHostList = (options: IUseHostListOptions) => { const hostListWorker = useHostListWorker(); const { timeRange, timezone, refreshGeneration, refreshInterval } = storeToRefs(useHostStore()); const { handleGetUserConfig, handleSetUserConfig } = useUserConfig(); + const progressiveMetricService = options.progressiveMetricService ?? hostMetricSnapshotService; + const progressiveEnabled = !!window.enable_host_metric_progressive; /** 基础数据加载中(第一屏) */ const loading = shallowRef(false); @@ -93,6 +111,9 @@ export const useHostList = (options: IUseHostListOptions) => { const metricLoading = shallowRef(false); /** 指标数据加载失败(保留基础行,仅指标列展示错误态) */ const metricLoadError = shallowRef(false); + /** 渐进指标状态;旧链路始终视为 READY,保持现有交互 */ + const metricProgressiveState = shallowRef(progressiveEnabled ? 'RUNNING' : 'READY'); + const metricSemanticsReady = computed(() => !progressiveEnabled || metricProgressiveState.value === 'READY'); /** 全量主机行数(主线程不持有全量行对象) */ const rawRowCount = shallowRef(0); /** retrieval-filter 语句模式 */ @@ -102,6 +123,11 @@ export const useHostList = (options: IUseHostListOptions) => { /** 排序(tdesign 字符串格式:`-key` 倒序 / `key` 正序) */ const sortInfo = shallowRef(''); + const availableSortInfo = computed(() => + metricSemanticsReady.value || !HOST_PROGRESSIVE_METRIC_FIELD_IDS.has(sortInfo.value.replace(/^-/, '')) + ? sortInfo.value + : '' + ); /** 当前页码 */ const page = shallowRef(1); /** 每页条数(初始值取全局统一页码配置,未配置时回退到默认 50) */ @@ -124,8 +150,12 @@ export const useHostList = (options: IUseHostListOptions) => { /** 当前页数据(Worker 仅回传一页,避免主线程持有全量) */ const pagedRows = shallowRef([]); - /** retrieval-filter 字段列表(静态定义) */ - const filterFields = HOST_FILTER_FIELDS; + /** 快照 READY 前只开放基础主机字段,避免局部页指标产生全局假结果 */ + const availableFilterFields = computed(() => + metricSemanticsReady.value + ? HOST_FILTER_FIELDS + : HOST_FILTER_FIELDS.filter(field => !HOST_PROGRESSIVE_METRIC_FIELD_IDS.has(field.name)) + ); /** 集群模块等字段的完整选项映射(字段 -> 选项树),用于已选条件 tag 的名称还原 */ const filterOptionsMap = shallowRef>({}); @@ -134,12 +164,37 @@ export const useHostList = (options: IUseHostListOptions) => { let dataRequestGeneration = 0; let metricRequestGeneration = 0; let selectionRequestGeneration = 0; + let snapshotAttemptGeneration = 0; + let snapshotHostSetMismatch = false; + let snapshotHostSetRealignmentUsed = false; + const loadedPageHostIds = new Set(); + const pendingPageHostIds = new Set(); + let currentPageRequestKey = ''; + let disposed = false; + let currentCanonicalTime: + | undefined + | { + endTime: number; + epoch: number; + startTime: number; + }; + + onScopeDispose(() => { + disposed = true; + dataRequestGeneration += 1; + metricRequestGeneration += 1; + snapshotAttemptGeneration += 1; + loadedPageHostIds.clear(); + pendingPageHostIds.clear(); + currentPageRequestKey = ''; + currentCanonicalTime = undefined; + }); const getRequestScope = () => resolveHostRequestScope(options.readonly, route.query, selectedNode.value); watch([timeRange, timezone], () => { setUrlParams(); - loadMetricData(); + progressiveEnabled ? loadData() : loadMetricData(); }); watch(refreshGeneration, () => { @@ -197,14 +252,16 @@ export const useHostList = (options: IUseHostListOptions) => { /** 获取计算参数 */ const getComputeParams = () => ({ - activeCategory: activeCategory.value, + activeCategory: metricSemanticsReady.value ? activeCategory.value : '', keyword: keyword.value, page: page.value, pageSize: pageSize.value, selectedNode: selectedNode.value, - sortInfo: sortInfo.value, + sortInfo: availableSortInfo.value, stickyValue: stickyValue.value, - where: where.value, + where: metricSemanticsReady.value + ? where.value + : where.value.filter(item => !HOST_PROGRESSIVE_METRIC_FIELD_IDS.has(item.key)), }); const refreshList = (immediate = false) => { @@ -220,6 +277,9 @@ export const useHostList = (options: IUseHostListOptions) => { categoryStats.value = data.categoryStats; total.value = data.total; pagedRows.value = data.pagedRows; + if (progressiveEnabled && metricProgressiveState.value !== 'READY') { + void loadPageMetricData(data.pagedRows, dataRequestGeneration); + } }); /** 检索候选项获取函数(Worker 内基于全量数据构建的候选项映射) */ @@ -253,15 +313,285 @@ export const useHostList = (options: IUseHostListOptions) => { refreshList(true); }; + const waitForSnapshotPoll = (delay: number) => + delay > 0 ? new Promise(resolve => setTimeout(resolve, delay)) : Promise.resolve(); + + const mergeSnapshotSections = (sections: Map) => { + const metricListMap: Record> = {}; + for (const sectionName of HOST_METRIC_SNAPSHOT_SECTIONS) { + const section = sections.get(sectionName); + if (!section) { + return null; + } + for (const [hostId, data] of Object.entries(section.data)) { + metricListMap[hostId] = { ...metricListMap[hostId], ...data }; + } + } + return metricListMap; + }; + + const toSnapshotQuery = (startTime: number, endTime: number): IHostMetricSnapshotQuery => { + const scope = getRequestScope(); + return { + bkBizId: window.cc_biz_id, + ...(scope.bk_host_id === undefined ? {} : { bkHostId: scope.bk_host_id }), + ...(scope.bk_inst_id === undefined ? {} : { bkInstId: scope.bk_inst_id }), + ...(scope.bk_obj_id === undefined ? {} : { bkObjId: scope.bk_obj_id }), + endTime, + startTime, + }; + }; + + const isTerminalSnapshotState = (status: HostMetricProgressiveState) => + status === 'EXPIRED' || status === 'FAILED' || status === 'UNAVAILABLE'; + + const isCurrentSnapshotAttempt = (epoch: number, attempt: number) => + !disposed && epoch === dataRequestGeneration && attempt === snapshotAttemptGeneration; + + const mergeSnapshotResultSections = ( + sections: Map, + result: IHostMetricSnapshotResult + ) => { + for (const section of result.sections) { + sections.set(section.name, section); + } + }; + + const collectMetricSnapshot = async ( + epoch: number, + attempt: number, + query: IHostMetricSnapshotQuery, + onCanonicalTime: (query: IHostMetricSnapshotQuery) => void + ) => { + const createResult = await progressiveMetricService.create(query); + if (!isCurrentSnapshotAttempt(epoch, attempt)) { + return null; + } + const canonicalQuery = { + ...query, + endTime: Number.isFinite(createResult.canonicalEndTime) ? createResult.canonicalEndTime : query.endTime, + startTime: Number.isFinite(createResult.canonicalStartTime) ? createResult.canonicalStartTime : query.startTime, + }; + onCanonicalTime(canonicalQuery); + let result = createResult; + let sinceRevision = 0; + let hasPolled = false; + const sections = new Map(); + while (isCurrentSnapshotAttempt(epoch, attempt)) { + if (isTerminalSnapshotState(result.status)) { + return { query: canonicalQuery, result }; + } + if (result.status === 'READY') { + const metricListMap = mergeSnapshotSections(sections); + if (metricListMap) { + return { + query: canonicalQuery, + result, + metricListMap, + }; + } + if (hasPolled) { + throw new Error('host metric snapshot is incomplete'); + } + sinceRevision = 0; + } + if (!result.snapshotId) { + return { + query: canonicalQuery, + result: { ...result, status: 'UNAVAILABLE' as const }, + }; + } + await waitForSnapshotPoll(result.status === 'READY' ? 0 : (result.retryAfterMs ?? 1000)); + if (!isCurrentSnapshotAttempt(epoch, attempt)) { + return null; + } + result = await progressiveMetricService.poll({ + ...canonicalQuery, + sinceRevision, + snapshotId: result.snapshotId, + }); + hasPolled = true; + if (!isCurrentSnapshotAttempt(epoch, attempt)) { + return null; + } + sinceRevision = result.revision; + mergeSnapshotResultSections(sections, result); + } + return null; + }; + + const runProgressiveSnapshot = async ( + epoch: number, + attempt: number, + query: IHostMetricSnapshotQuery, + baseReady: Promise> | null>, + updateCanonicalTime: (value: { endTime: number; startTime: number }) => void + ) => { + let retryQuery = query; + try { + while (isCurrentSnapshotAttempt(epoch, attempt)) { + metricProgressiveState.value = 'RUNNING'; + const snapshotPromise = collectMetricSnapshot(epoch, attempt, retryQuery, canonicalQuery => { + retryQuery = canonicalQuery; + updateCanonicalTime({ endTime: canonicalQuery.endTime, startTime: canonicalQuery.startTime }); + }); + const [requestBaseList, snapshot] = await Promise.all([baseReady, snapshotPromise]); + if (!isCurrentSnapshotAttempt(epoch, attempt) || !requestBaseList || !snapshot) { + return; + } + retryQuery = snapshot.query; + if (isTerminalSnapshotState(snapshot.result.status)) { + snapshotHostSetMismatch = false; + metricProgressiveState.value = snapshot.result.status; + await waitForSnapshotPoll(snapshot.result.retryAfterMs ?? 1000); + continue; + } + const hostIds = [...new Set(requestBaseList.map(row => row.bk_host_id))]; + const hostIdsHash = await progressiveMetricService.hashHostIds(hostIds); + if (!isCurrentSnapshotAttempt(epoch, attempt)) { + return; + } + if (snapshot.result.hostCount !== hostIds.length || snapshot.result.hostIdsHash !== hostIdsHash) { + snapshotHostSetMismatch = true; + if (!snapshotHostSetRealignmentUsed) { + snapshotHostSetRealignmentUsed = true; + void loadDataInternal(); + return; + } + metricProgressiveState.value = 'FAILED'; + return; + } + const result = await hostListWorker.replaceMetrics(epoch, snapshot.metricListMap); + if (!result.applied || !isCurrentSnapshotAttempt(epoch, attempt)) { + return; + } + filterOptionsMap.value = result.filterOptionsMap; + snapshotHostSetMismatch = false; + snapshotHostSetRealignmentUsed = false; + metricProgressiveState.value = 'READY'; + metricLoading.value = false; + metricLoadError.value = false; + refreshList(true); + return; + } + } catch { + if (isCurrentSnapshotAttempt(epoch, attempt)) { + snapshotHostSetMismatch = false; + metricProgressiveState.value = 'FAILED'; + await waitForSnapshotPoll(1000); + if (isCurrentSnapshotAttempt(epoch, attempt)) { + void runProgressiveSnapshot(epoch, attempt, retryQuery, baseReady, updateCanonicalTime); + } + } + } + }; + + const startProgressiveSnapshot = ( + epoch: number, + baseReady: Promise> | null> + ) => { + const time = currentCanonicalTime; + if (!progressiveEnabled || disposed || time?.epoch !== epoch) { + return; + } + const attempt = ++snapshotAttemptGeneration; + metricProgressiveState.value = 'RUNNING'; + void runProgressiveSnapshot( + epoch, + attempt, + toSnapshotQuery(time.startTime, time.endTime), + baseReady, + canonicalTime => { + if (isCurrentSnapshotAttempt(epoch, attempt)) { + currentCanonicalTime = { ...canonicalTime, epoch }; + } + } + ); + }; + + const loadPageMetricData = async (rows: IHostListRow[], epoch: number) => { + if (!progressiveEnabled || epoch !== dataRequestGeneration || metricProgressiveState.value === 'READY') { + return; + } + const canonicalTime = currentCanonicalTime; + if (canonicalTime?.epoch !== epoch) { + return; + } + const hostIds = [...new Set(rows.map(row => row.bk_host_id))].slice(0, pageSize.value); + currentPageRequestKey = `${epoch}:${hostIds.join(',')}`; + const requestKey = currentPageRequestKey; + const missingHostIds = hostIds.filter(id => !loadedPageHostIds.has(id) && !pendingPageHostIds.has(id)); + if (!missingHostIds.length) { + metricLoading.value = false; + return; + } + for (const hostId of missingHostIds) { + pendingPageHostIds.add(hostId); + } + metricLoading.value = true; + metricLoadError.value = false; + try { + const metricListMap = await getHostMetricInfoList({ + ...getRequestScope(), + bk_host_ids: missingHostIds, + end_time: canonicalTime.endTime, + query_mode: 'page', + start_time: canonicalTime.startTime, + }); + if (epoch !== dataRequestGeneration || metricProgressiveState.value === 'READY') { + return; + } + const result = await hostListWorker.patchMetrics(epoch, missingHostIds, metricListMap); + if (!result.applied || epoch !== dataRequestGeneration) { + return; + } + for (const hostId of missingHostIds) { + loadedPageHostIds.add(hostId); + } + refreshList(true); + } catch { + if (epoch === dataRequestGeneration && requestKey === currentPageRequestKey) { + metricLoadError.value = true; + } + } finally { + if (epoch === dataRequestGeneration) { + for (const hostId of missingHostIds) { + pendingPageHostIds.delete(hostId); + } + if (requestKey === currentPageRequestKey) { + metricLoading.value = false; + } else { + void loadPageMetricData(pagedRows.value, epoch); + } + } + } + }; + /** 加载数据:基础数据先渲染,指标数据后补充 */ - const loadData = async () => { + const loadDataInternal = async () => { + if (disposed) { + return; + } const requestGeneration = ++dataRequestGeneration; metricRequestGeneration += 1; let requestBaseList: Awaited> = []; + let resolveBaseReady: (baseList: Awaited> | null) => void = () => {}; + const baseReady = new Promise> | null>(resolve => { + resolveBaseReady = resolve; + }); loading.value = true; metricLoading.value = true; loadError.value = false; metricLoadError.value = false; + loadedPageHostIds.clear(); + pendingPageHostIds.clear(); + currentPageRequestKey = ''; + if (progressiveEnabled) { + metricProgressiveState.value = 'RUNNING'; + const [startTime, endTime] = handleTransformToTimestamp(timeRange.value); + currentCanonicalTime = { endTime, epoch: requestGeneration, startTime }; + startProgressiveSnapshot(requestGeneration, baseReady); + } // 手动/定时刷新时重置选择(对标旧版 handleResetCheck) selectAllMode.value = HostSelectAllModeEnum.NONE; selectedRowKeys.value = new Set(); @@ -269,13 +599,19 @@ export const useHostList = (options: IUseHostListOptions) => { try { requestBaseList = await getHostInfoList(getRequestScope()); if (requestGeneration !== dataRequestGeneration) { + resolveBaseReady(null); return; } baseList = requestBaseList; - const initResult = await hostListWorker.initBaseData(requestBaseList); + const initResult = await hostListWorker.initBaseData( + requestBaseList, + progressiveEnabled ? requestGeneration : undefined + ); if (requestGeneration !== dataRequestGeneration) { + resolveBaseReady(null); return; } + resolveBaseReady(requestBaseList); rawRowCount.value = initResult.rawRowCount; await loadStickyConfig(); if (requestGeneration !== dataRequestGeneration) { @@ -302,6 +638,7 @@ export const useHostList = (options: IUseHostListOptions) => { pagedRows.value = []; filterOptionsMap.value = {}; metricRequestGeneration += 1; + resolveBaseReady(null); loadError.value = true; metricLoading.value = false; return; @@ -317,15 +654,18 @@ export const useHostList = (options: IUseHostListOptions) => { } return; } + if (progressiveEnabled) { + return; + } const metricGeneration = ++metricRequestGeneration; try { - const bk_host_ids = requestBaseList.map(row => row.bk_host_id); - const [start_time, end_time] = handleTransformToTimestamp(timeRange.value); + const bkHostIds = requestBaseList.map(row => row.bk_host_id); + const [startTime, endTime] = handleTransformToTimestamp(timeRange.value); const metricListMap = await getHostMetricInfoList({ ...getRequestScope(), - bk_host_ids, - start_time, - end_time, + bk_host_ids: bkHostIds, + start_time: startTime, + end_time: endTime, }); if (requestGeneration !== dataRequestGeneration || metricGeneration !== metricRequestGeneration) { return; @@ -347,23 +687,34 @@ export const useHostList = (options: IUseHostListOptions) => { } }; + const loadData = () => { + snapshotHostSetMismatch = false; + snapshotHostSetRealignmentUsed = false; + return loadDataInternal(); + }; + const loadMetricData = async () => { if (!baseList.length) { return; } + if (progressiveEnabled) { + await loadPageMetricData(pagedRows.value, dataRequestGeneration); + return; + } + const requestGeneration = ++metricRequestGeneration; metricLoading.value = true; metricLoadError.value = false; try { const requestBaseList = baseList; - const bk_host_ids = requestBaseList.map(row => row.bk_host_id); - const [start_time, end_time] = handleTransformToTimestamp(timeRange.value); + const bkHostIds = requestBaseList.map(row => row.bk_host_id); + const [startTime, endTime] = handleTransformToTimestamp(timeRange.value); const metricListMap = await getHostMetricInfoList({ ...getRequestScope(), - bk_host_ids, - start_time, - end_time, + bk_host_ids: bkHostIds, + start_time: startTime, + end_time: endTime, }); if (requestGeneration !== metricRequestGeneration) { return; @@ -385,6 +736,17 @@ export const useHostList = (options: IUseHostListOptions) => { } }; + const retryMetricSnapshot = () => { + if (!isTerminalSnapshotState(metricProgressiveState.value)) { + return; + } + if (snapshotHostSetMismatch) { + void loadData(); + return; + } + startProgressiveSnapshot(dataRequestGeneration, Promise.resolve(baseList)); + }; + /** 过滤条件变化后统一回到第一页 */ const resetPage = () => { page.value = 1; @@ -411,11 +773,18 @@ export const useHostList = (options: IUseHostListOptions) => { filterExpanded.value = !filterExpanded.value; }; const handleCategoryClick = (key: EHostQuickCategory) => { + if (!metricSemanticsReady.value) { + return; + } activeCategory.value = activeCategory.value === key ? '' : key; resetPage(); }; const handleSortChange = (sort: string | string[]) => { - sortInfo.value = Array.isArray(sort) ? sort[0] || '' : sort; + const nextSort = Array.isArray(sort) ? sort[0] || '' : sort; + if (!metricSemanticsReady.value && HOST_PROGRESSIVE_METRIC_FIELD_IDS.has(nextSort.replace(/^-/, ''))) { + return; + } + sortInfo.value = nextSort; // 排序后 page / none 模式清空选择(对齐旧版 current 模式行为) // across 模式保持选择(跨页全选语义不受排序影响) if (selectAllMode.value !== HostSelectAllModeEnum.ACROSS) { @@ -577,6 +946,9 @@ export const useHostList = (options: IUseHostListOptions) => { loadError, metricLoading, metricLoadError, + metricProgressiveState, + metricSemanticsReady, + availableSortInfo, rawRowCount, keyword, where, @@ -593,13 +965,14 @@ export const useHostList = (options: IUseHostListOptions) => { categoryStats, pagedRows, total, - filterFields, + availableFilterFields, filterOptionsMap, stickyValue, // 方法 getValueFn, loadData, loadMetricData, + retryMetricSnapshot, handleKeywordChange, handleWhereChange, handleQueryStringChange, diff --git a/bkmonitor/webpack/src/trace/pages/host/constants/host-list.ts b/bkmonitor/webpack/src/trace/pages/host/constants/host-list.ts index 2018b20375..f7c9337524 100644 --- a/bkmonitor/webpack/src/trace/pages/host/constants/host-list.ts +++ b/bkmonitor/webpack/src/trace/pages/host/constants/host-list.ts @@ -61,6 +61,19 @@ export const HOST_QUICK_CARD_LIST: IHostQuickCard[] = [ export const HOST_METRIC_COLUMN_KEYS = ['cpu_usage', 'mem_usage', 'disk_in_use', 'io_util', 'psc_mem_usage'] as const; export type HostMetricColumnKey = (typeof HOST_METRIC_COLUMN_KEYS)[number]; +/** 快照 READY 前不可用于全局筛选、排序、快捷统计或关键字匹配的补充字段 */ +export const HOST_PROGRESSIVE_METRIC_FIELD_IDS = new Set([ + 'status', + 'alarm_count', + 'cpu_usage', + 'mem_usage', + 'disk_in_use', + 'io_util', + 'psc_mem_usage', + 'cpu_load', + 'display_name', +]); + /** 表格列定义 */ export interface IHostColumnConfig { /** 是否默认展示 */ diff --git a/bkmonitor/webpack/src/trace/pages/host/services/host-service.ts b/bkmonitor/webpack/src/trace/pages/host/services/host-service.ts index fde27eca63..0f9669edad 100644 --- a/bkmonitor/webpack/src/trace/pages/host/services/host-service.ts +++ b/bkmonitor/webpack/src/trace/pages/host/services/host-service.ts @@ -24,13 +24,111 @@ * IN THE SOFTWARE. */ +import { request } from 'monitor-api/base'; import { getTopoTree } from 'monitor-api/modules/commons'; import { searchHostInfo, searchHostMetric } from 'monitor-api/modules/performance'; +import { HOST_METRIC_SNAPSHOT_SECTIONS } from '../types/host-metric-progressive'; + import type { IHostTopoTree } from '../types'; import type { IHostBaseInfo, IHostMetricInfo } from '../types/host'; +import type { + HostMetricProgressiveState, + HostMetricSnapshotSectionName, + IHostMetricSnapshotPollQuery, + IHostMetricSnapshotQuery, + IHostMetricSnapshotResult, + IHostMetricSnapshotService, +} from '../types/host-metric-progressive'; import type { HostScopeParams } from '../utils/share-scope'; +interface IHostMetricSnapshotResponse { + canonical_end_time?: number; + canonical_start_time?: number; + data?: Partial>>>; + expired?: boolean; + failed_sections?: HostMetricSnapshotSectionName[]; + host_count?: number; + host_ids_hash?: string; + retry_after?: number; + revision?: number; + snapshot_id?: string; + state: HostMetricProgressiveState; + sections?: Partial< + Record + >; +} + +const createHostMetricSnapshot = request('post', 'rest/v2/performance/host_metric_snapshot/'); +const retrieveHostMetricSnapshot = request('get', 'rest/v2/performance/host_metric_snapshot/{pk}/'); + +const toSnapshotRequestParams = (query: IHostMetricSnapshotQuery) => ({ + bk_biz_id: query.bkBizId, + ...(query.bkHostId === undefined ? {} : { bk_host_id: query.bkHostId }), + ...(query.bkInstId === undefined ? {} : { bk_inst_id: query.bkInstId }), + ...(query.bkObjId === undefined ? {} : { bk_obj_id: query.bkObjId }), + end_time: query.endTime, + start_time: query.startTime, +}); + +const toSnapshotResult = ( + response: IHostMetricSnapshotResponse, + fallbackQuery: IHostMetricSnapshotQuery, + includeData = true +): IHostMetricSnapshotResult => ({ + canonicalEndTime: + typeof response.canonical_end_time === 'number' && Number.isFinite(response.canonical_end_time) + ? response.canonical_end_time + : fallbackQuery.endTime, + canonicalStartTime: + typeof response.canonical_start_time === 'number' && Number.isFinite(response.canonical_start_time) + ? response.canonical_start_time + : fallbackQuery.startTime, + expired: !!response.expired, + failedSections: response.failed_sections || [], + hostCount: response.host_count || 0, + hostIdsHash: response.host_ids_hash || '', + retryAfterMs: response.retry_after === undefined ? undefined : response.retry_after * 1000, + revision: response.revision || 0, + sections: includeData + ? HOST_METRIC_SNAPSHOT_SECTIONS.flatMap(name => + response.data?.[name] ? [{ data: response.data[name], name }] : [] + ) + : [], + snapshotId: response.snapshot_id, + status: response.state, +}); + +const hashHostIds = async (hostIds: number[]) => { + const canonical = [...new Set(hostIds)].sort((left, right) => left - right).join(','); + const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(canonical)); + return [...new Uint8Array(digest)].map(value => value.toString(16).padStart(2, '0')).join(''); +}; + +export const hostMetricSnapshotService: IHostMetricSnapshotService = { + create: async query => { + const params = toSnapshotRequestParams(query); + const response = await createHostMetricSnapshot(params, { + isAsync: false, + needMessage: false, + }); + return toSnapshotResult(response, query, false); + }, + hashHostIds, + poll: async (query: IHostMetricSnapshotPollQuery) => { + const params = { + ...toSnapshotRequestParams(query), + since_revision: query.sinceRevision, + }; + const response = await retrieveHostMetricSnapshot( + query.snapshotId, + params, + { isAsync: false, needMessage: false } + ); + return toSnapshotResult(response, query); + }, +}; + /** * @description: 获取基础主机列表, 这个 API 要更快,但是不包含指标数据, 用于主机列表第一屏渲染 * @returns {Promise} 基础主机列表 @@ -48,6 +146,7 @@ export const getHostMetricInfoList = async ( params: HostScopeParams & { bk_host_ids: number[]; end_time: number; + query_mode?: 'full' | 'page'; start_time: number; } ) => { diff --git a/bkmonitor/webpack/src/trace/pages/host/types/host-metric-progressive.ts b/bkmonitor/webpack/src/trace/pages/host/types/host-metric-progressive.ts new file mode 100644 index 0000000000..dec07fdc82 --- /dev/null +++ b/bkmonitor/webpack/src/trace/pages/host/types/host-metric-progressive.ts @@ -0,0 +1,63 @@ +/* + * Tencent is pleased to support the open source community by making + * 蓝鲸智云PaaS平台 (BlueKing PaaS) available. + * + * Copyright (C) 2017-2025 Tencent. All rights reserved. + * + * 蓝鲸智云PaaS平台 (BlueKing PaaS) is licensed under the MIT License. + */ + +import type { IHostMetricInfo } from './host'; + +export const HOST_METRIC_SNAPSHOT_SECTIONS = [ + 'agent_status', + 'performance_data', + 'process_status', + 'alarm_count', +] as const; + +export type HostMetricProgressiveState = 'EXPIRED' | 'FAILED' | 'READY' | 'RUNNING' | 'UNAVAILABLE'; +export type HostMetricSnapshotSectionName = (typeof HOST_METRIC_SNAPSHOT_SECTIONS)[number]; + +export interface IHostMetricSnapshotPollQuery extends IHostMetricSnapshotQuery { + sinceRevision: number; + snapshotId: string; +} + +export interface IHostMetricSnapshotQuery { + bkBizId: number | string; + bkHostId?: number; + bkInstId?: number; + bkObjId?: string; + endTime: number; + startTime: number; +} + +export interface IHostMetricSnapshotResult { + canonicalEndTime: number; + canonicalStartTime: number; + expired: boolean; + failedSections: HostMetricSnapshotSectionName[]; + hostCount: number; + hostIdsHash: string; + retryAfterMs?: number; + revision: number; + sections: IHostMetricSnapshotSection[]; + snapshotId?: string; + status: HostMetricProgressiveState; +} + +export interface IHostMetricSnapshotSection { + data: Record>; + name: HostMetricSnapshotSectionName; +} + +/** + * 主机指标快照的前端领域契约。具体 HTTP 路由和响应字段只允许在 service adapter 中转换, + * Controller 与组件不依赖后端传输格式。 + */ +export interface IHostMetricSnapshotService { + create: (params: IHostMetricSnapshotQuery) => Promise; + hashHostIds: (hostIds: number[]) => Promise; + poll: (params: IHostMetricSnapshotPollQuery) => Promise; +} diff --git a/bkmonitor/webpack/src/trace/pages/host/types/host.ts b/bkmonitor/webpack/src/trace/pages/host/types/host.ts index 300c6d625b..33f81fd5df 100644 --- a/bkmonitor/webpack/src/trace/pages/host/types/host.ts +++ b/bkmonitor/webpack/src/trace/pages/host/types/host.ts @@ -40,8 +40,10 @@ export type IHostBaseInfo = { bk_cloud_name: string; bk_host_id: number; bk_host_innerip: string; + bk_host_innerip_v6: string; bk_host_name: string; bk_host_outerip: string; + bk_host_outerip_v6: string; bk_os_name: string; bk_os_type: string; display_name: string; @@ -65,8 +67,6 @@ export type IHostComponent = { /** 带指标数据的主机列表项 */ export interface IHostMetricInfo extends IHostBaseInfo { alarm_count: IHostAlarmCount[]; - bk_host_innerip_v6: string; - bk_host_outerip_v6: string; bk_state: string; component: IHostComponent[]; cpu_load: number; diff --git a/bkmonitor/webpack/src/trace/pages/host/workers/host-list.worker.raw.js b/bkmonitor/webpack/src/trace/pages/host/workers/host-list.worker.raw.js index e1c70dfbf7..2bb1614d45 100644 --- a/bkmonitor/webpack/src/trace/pages/host/workers/host-list.worker.raw.js +++ b/bkmonitor/webpack/src/trace/pages/host/workers/host-list.worker.raw.js @@ -129,7 +129,7 @@ const matchQuickCategory = (row, category) => { } }; -const matchKeyword = (row, keyword) => { +const matchKeyword = (row, keyword, includeMetricFields = true) => { const kw = keyword.trim().toLowerCase(); if (!kw) { return true; @@ -138,14 +138,17 @@ const matchKeyword = (row, keyword) => { row.bk_host_innerip, row.bk_host_innerip_v6, row.bk_host_outerip, + row.bk_host_outerip_v6, row.bk_host_name, row.display_name, row.bk_os_name, row.bk_cloud_name, row.clusterNames, row.moduleNames, - row.processNames, ]; + if (includeMetricFields) { + fields.push(row.processNames); + } return fields.some(field => !!field && String(field).toLowerCase().includes(kw)); }; @@ -390,8 +393,11 @@ const buildFilterOptionsMap = rows => { }; let baseRows = []; -let rawRows = []; +let committedRows = []; +let pageMetricMap = {}; let filterOptionsMap = new Map(); +let currentEpoch = 0; +let metricsReady = false; const optionsMapToRecord = map => { const record = {}; @@ -407,52 +413,128 @@ const filterByConditions = (rows, params) => row => matchQuickCategory(row, params.activeCategory) && matchWhere(row, params.where) && - matchKeyword(row, params.keyword) + matchKeyword(row, params.keyword, metricsReady) ); /** 拓扑 + 条件过滤后的全量行(不含分页) */ const getFilteredRows = params => { - const nodeScopedRows = rawRows.filter(row => matchTopoNode(row, params.selectedNode)); + const nodeScopedRows = committedRows.filter(row => matchTopoNode(row, params.selectedNode)); return filterByConditions(nodeScopedRows, params); }; const runCompute = params => { - const nodeScopedRows = rawRows.filter(row => matchTopoNode(row, params.selectedNode)); + const nodeScopedRows = committedRows.filter(row => matchTopoNode(row, params.selectedNode)); const categoryStats = computeCategoryStats(nodeScopedRows); const filteredRows = filterByConditions(nodeScopedRows, params); const sortedRows = sortRows(filteredRows, params.sortInfo, params.stickyValue); const total = sortedRows.length; const start = (params.page - 1) * params.pageSize; - const pagedRows = sortedRows.slice(start, start + params.pageSize); + const slicedRows = sortedRows.slice(start, start + params.pageSize); + const pagedRows = metricsReady + ? slicedRows + : slicedRows.map(row => createHostListRow(row, pageMetricMap[String(row.bk_host_id)])); return { categoryStats, pagedRows, total }; }; +const isCurrentEpoch = message => Number(message.epoch) === currentEpoch; + +const replaceCommittedMetrics = metricListMap => { + committedRows = baseRows.map(row => createHostListRow(row, metricListMap[row.bk_host_id])); + pageMetricMap = {}; + metricsReady = true; + filterOptionsMap = buildFilterOptionsMap(committedRows); +}; + self.onmessage = event => { const message = event.data; switch (message.type) { case 'INIT_BASE': { + const nextEpoch = message.epoch === undefined ? currentEpoch + 1 : Number(message.epoch); + if (nextEpoch < currentEpoch) { + self.postMessage({ + applied: false, + epoch: currentEpoch, + filterOptionsMap: optionsMapToRecord(filterOptionsMap), + rawRowCount: committedRows.length, + requestId: message.requestId, + type: 'INIT_BASE_DONE', + }); + break; + } + currentEpoch = nextEpoch; baseRows = message.baseList; - rawRows = baseRows.map(row => createHostListRow(row)); - filterOptionsMap = buildFilterOptionsMap(rawRows); + committedRows = baseRows.map(row => createHostListRow(row)); + pageMetricMap = {}; + metricsReady = false; + filterOptionsMap = buildFilterOptionsMap(committedRows); self.postMessage({ + applied: true, + epoch: currentEpoch, filterOptionsMap: optionsMapToRecord(filterOptionsMap), - rawRowCount: rawRows.length, + rawRowCount: committedRows.length, requestId: message.requestId, type: 'INIT_BASE_DONE', }); break; } case 'MERGE_METRICS': { - const metricListMap = message.metricListMap; - rawRows = baseRows.map(row => createHostListRow(row, metricListMap[row.bk_host_id])); - filterOptionsMap = buildFilterOptionsMap(rawRows); + replaceCommittedMetrics(message.metricListMap); self.postMessage({ + applied: true, + epoch: currentEpoch, filterOptionsMap: optionsMapToRecord(filterOptionsMap), requestId: message.requestId, type: 'MERGE_METRICS_DONE', }); break; } + case 'RESET_METRICS': { + const applied = isCurrentEpoch(message); + if (applied) { + committedRows = baseRows.map(row => createHostListRow(row)); + pageMetricMap = {}; + metricsReady = false; + filterOptionsMap = buildFilterOptionsMap(committedRows); + } + self.postMessage({ + applied, + epoch: currentEpoch, + filterOptionsMap: optionsMapToRecord(filterOptionsMap), + requestId: message.requestId, + type: 'RESET_METRICS_DONE', + }); + break; + } + case 'PATCH_METRICS': { + const applied = isCurrentEpoch(message); + if (applied && !metricsReady) { + for (const hostId of message.hostIds || []) { + delete pageMetricMap[String(hostId)]; + } + pageMetricMap = { ...pageMetricMap, ...message.metricListMap }; + } + self.postMessage({ + applied: applied && !metricsReady, + epoch: currentEpoch, + requestId: message.requestId, + type: 'PATCH_METRICS_DONE', + }); + break; + } + case 'REPLACE_METRICS': { + const applied = isCurrentEpoch(message); + if (applied) { + replaceCommittedMetrics(message.metricListMap); + } + self.postMessage({ + applied, + epoch: currentEpoch, + filterOptionsMap: optionsMapToRecord(filterOptionsMap), + requestId: message.requestId, + type: 'REPLACE_METRICS_DONE', + }); + break; + } case 'COMPUTE': { const result = runCompute(message.params); self.postMessage({ @@ -481,7 +563,7 @@ self.onmessage = event => { case 'GET_SELECTED_IPS': { const keySet = new Set(message.rowKeys.map(String)); // 表格 rowKey 为 id,同时兼容 rowId - const ips = rawRows + const ips = committedRows .filter(row => keySet.has(String(row.id)) || keySet.has(String(row.rowId))) .map(row => row.bk_host_innerip) .filter(Boolean); @@ -495,7 +577,7 @@ self.onmessage = event => { case 'GET_SELECTED_ROWS': { const keySet = new Set(message.rowKeys.map(String)); // 表格 rowKey 为 id,同时兼容 rowId - const rows = rawRows.filter(row => keySet.has(String(row.id)) || keySet.has(String(row.rowId))); + const rows = committedRows.filter(row => keySet.has(String(row.id)) || keySet.has(String(row.rowId))); self.postMessage({ rows, requestId: message.requestId, diff --git a/bkmonitor/webpack/src/trace/shim.d.ts b/bkmonitor/webpack/src/trace/shim.d.ts index 32648bd338..576954e3e4 100644 --- a/bkmonitor/webpack/src/trace/shim.d.ts +++ b/bkmonitor/webpack/src/trace/shim.d.ts @@ -59,6 +59,7 @@ declare global { enable_apm_profiling: boolean; enable_cmdb_level?: boolean; enable_create_chat_group?: boolean; + enable_host_metric_progressive?: boolean; // 多租户用户中心是否开启 enable_multi_tenant_mode?: boolean; FEATURE_TOGGLE?: Record; diff --git a/bkmonitor/webpack/tests/host-list-consistency.test.cjs b/bkmonitor/webpack/tests/host-list-consistency.test.cjs index 6b13a44822..29e89c0fa3 100644 --- a/bkmonitor/webpack/tests/host-list-consistency.test.cjs +++ b/bkmonitor/webpack/tests/host-list-consistency.test.cjs @@ -27,6 +27,10 @@ const vm = require('node:vm'); const Module = require('node:module'); const vue = require('vue'); +global.window = global.window || {}; +global.window.enable_host_metric_progressive = false; +global.window.cc_biz_id = 7; + const originalLoad = Module._load; Module._load = function mockCoreDependencies(request, parent, isMain) { if (request === 'monitor-common/utils') { @@ -278,6 +282,7 @@ const hostStore = { }; let getHostInfo; let getHostMetricInfo; +let defaultProgressiveMetricService; let hostListWorker; let mountedCallbacks = []; @@ -345,12 +350,18 @@ Module._load = function mockHostListDependencies(request, parent, isMain) { HOST_FILTER_FIELDS: [], HOST_LIST_COLUMNS: [{ checked: true, id: 'bk_host_innerip' }], HOST_LIST_DEFAULT_PAGE_SIZE: 50, + HOST_PROGRESSIVE_METRIC_FIELD_IDS: new Set(['cpu_usage', 'display_name', 'status']), }; } if (isHostList && request === '../services/host-service') { return { getHostInfoList: (...args) => getHostInfo(...args), getHostMetricInfoList: (...args) => getHostMetricInfo(...args), + hostMetricSnapshotService: { + create: (...args) => defaultProgressiveMetricService.create(...args), + hashHostIds: (...args) => defaultProgressiveMetricService.hashHostIds(...args), + poll: (...args) => defaultProgressiveMetricService.poll(...args), + }, }; } if (isHostList && request === './use-host-list-worker') { @@ -368,9 +379,14 @@ const createControllerWorker = () => { const calls = { computeNow: [], initBaseData: [], + initBaseEpochs: [], mergeMetrics: [], + patchMetrics: [], + replaceMetrics: [], + resetMetrics: [], }; let getFilteredRowKeys = async () => ({ rowKeys: [] }); + let computeHandler = () => {}; return { calls, computeNow: params => calls.computeNow.push(params), @@ -379,24 +395,42 @@ const createControllerWorker = () => { getSelectedIps: async () => ({ ips: [] }), getSelectedRows: async () => ({ rows: [] }), getFilterOptionsMap: async () => ({ filterOptionsMap: {} }), - initBaseData: async baseList => { + initBaseData: async (baseList, epoch) => { calls.initBaseData.push(baseList); - return { rawRowCount: baseList.length }; + calls.initBaseEpochs.push(epoch); + return { applied: true, epoch, rawRowCount: baseList.length }; }, mergeMetrics: async metricListMap => { calls.mergeMetrics.push(metricListMap); return { filterOptionsMap: {} }; }, + patchMetrics: async (epoch, hostIds, metricListMap) => { + calls.patchMetrics.push({ epoch, hostIds, metricListMap }); + return { applied: true, epoch }; + }, + replaceMetrics: async (epoch, metricListMap) => { + calls.replaceMetrics.push({ epoch, metricListMap }); + return { applied: true, epoch, filterOptionsMap: { display_name: [{ id: 'redis', name: 'redis' }] } }; + }, + resetMetrics: async epoch => { + calls.resetMetrics.push(epoch); + return { applied: true, epoch }; + }, scheduleCompute: () => {}, - setComputeHandler: () => {}, + setComputeHandler: callback => { + computeHandler = callback; + }, setFilteredRowKeysHandler: callback => { getFilteredRowKeys = callback; }, + emitComputeDone: pagedRows => + computeHandler({ categoryStats: { alarm: 0, cpu: 0, disk: 0, mem: 0 }, pagedRows, total: pagedRows.length }), }; }; -const createHostListController = () => { +const createHostListController = ({ progressive = false, progressiveMetricService } = {}) => { mountedCallbacks = []; + global.window.enable_host_metric_progressive = progressive; const scope = vue.effectScope(); let context; scope.run(() => { @@ -407,11 +441,756 @@ const createHostListController = () => { readonly: false, selectedNode: vue.shallowRef(null), where: vue.shallowRef([]), + progressiveMetricService, }); }); return { context, mountedCallbacks: [...mountedCallbacks], scope }; }; +const createSnapshotResult = overrides => ({ + canonicalEndTime: 900, + canonicalStartTime: 800, + expired: false, + failedSections: [], + hostCount: 0, + hostIdsHash: '', + retryAfterMs: 0, + revision: 0, + sections: [], + snapshotId: 'snapshot-1', + status: 'RUNNING', + ...overrides, +}); + +test('progressive mode starts the snapshot with the base list and requests only the visible page while running', async () => { + const baseRequest = deferred(); + const snapshotCreateRequest = deferred(); + const hosts = [ + createHost({ bkCloudId: 0, bkHostId: 101, ip: '10.0.0.1' }), + createHost({ bkCloudId: 0, bkHostId: 102, ip: '10.0.0.2' }), + ]; + const metricRequests = []; + const pollRequests = []; + getHostInfo = () => baseRequest.promise; + getHostMetricInfo = params => { + metricRequests.push(params); + return Promise.resolve(Object.fromEntries(params.bk_host_ids.map(id => [id, { cpu_usage: 25 }]))); + }; + hostListWorker = createControllerWorker(); + const progressiveMetricService = { + create: () => snapshotCreateRequest.promise, + hashHostIds: async hostIds => hostIds.map(String).sort().join(','), + poll: params => { + pollRequests.push(params); + return new Promise(() => {}); + }, + }; + const { context, scope } = createHostListController({ progressive: true, progressiveMetricService }); + + const loading = context.loadData(); + assert.equal(context.metricProgressiveState.value, 'RUNNING'); + baseRequest.resolve(hosts); + await loading; + hostListWorker.emitComputeDone([hosts[0]]); + await flushPromises(); + + assert.equal(metricRequests.length, 1); + assert.equal(metricRequests[0].start_time, 0); + assert.equal(metricRequests[0].end_time, 1); + snapshotCreateRequest.resolve( + createSnapshotResult({ canonicalEndTime: 190, canonicalStartTime: 90, snapshotId: 'snapshot-1' }) + ); + await flushPromises(); + hostListWorker.emitComputeDone([hosts[1]]); + await flushPromises(); + + assert.deepEqual(metricRequests[0].bk_host_ids, [101]); + assert.equal(metricRequests[0].query_mode, 'page'); + assert.deepEqual(metricRequests[1].bk_host_ids, [102]); + assert.equal(metricRequests[1].start_time, 90); + assert.equal(metricRequests[1].end_time, 190); + assert.deepEqual(hostListWorker.calls.patchMetrics, [ + { epoch: 1, hostIds: [101], metricListMap: { 101: { cpu_usage: 25 } } }, + { epoch: 1, hostIds: [102], metricListMap: { 102: { cpu_usage: 25 } } }, + ]); + + context.keyword.value = '10.0.0.1'; + await context.handleIpMark({ rowId: '101' }); + assert.equal(hostListWorker.calls.computeNow.at(-1).keyword, '10.0.0.1'); + + assert.deepEqual(pollRequests[0], { + bkBizId: 7, + endTime: 190, + sinceRevision: 0, + snapshotId: 'snapshot-1', + startTime: 90, + }); + scope.stop(); +}); + +test('an obsolete page metric response cannot patch a newer dataset epoch', async () => { + const oldPageRequest = deferred(); + let baseRequestCount = 0; + getHostInfo = async () => [ + createHost({ bkCloudId: 0, bkHostId: ++baseRequestCount, ip: `10.0.0.${baseRequestCount}` }), + ]; + getHostMetricInfo = () => oldPageRequest.promise; + hostListWorker = createControllerWorker(); + const neverReadyService = { + create: async () => createSnapshotResult({ snapshotId: 'snapshot-running' }), + hashHostIds: async hostIds => hostIds.map(String).sort().join(','), + poll: () => new Promise(() => {}), + }; + const { context, scope } = createHostListController({ + progressive: true, + progressiveMetricService: neverReadyService, + }); + + await context.loadData(); + hostListWorker.emitComputeDone([createHost({ bkCloudId: 0, bkHostId: 1, ip: '10.0.0.1' })]); + await flushPromises(); + await context.loadData(); + oldPageRequest.resolve({ 1: { cpu_usage: 99 } }); + await flushPromises(); + + assert.deepEqual(hostListWorker.calls.patchMetrics, []); + assert.deepEqual(hostListWorker.calls.initBaseEpochs, [1, 2]); + scope.stop(); +}); + +test('a failed old-page request releases shared pending hosts and refills the current page once', async () => { + const hosts = [ + createHost({ bkCloudId: 0, bkHostId: 1, ip: '10.0.0.1' }), + createHost({ bkCloudId: 0, bkHostId: 2, ip: '10.0.0.2' }), + createHost({ bkCloudId: 0, bkHostId: 3, ip: '10.0.0.3' }), + ]; + const metricRequests = []; + getHostInfo = async () => hosts; + getHostMetricInfo = params => { + const request = deferred(); + metricRequests.push({ params, request }); + return request.promise; + }; + hostListWorker = createControllerWorker(); + const progressiveMetricService = { + create: () => new Promise(() => {}), + hashHostIds: async () => '', + poll: () => new Promise(() => {}), + }; + const { context, scope } = createHostListController({ progressive: true, progressiveMetricService }); + + await context.loadData(); + hostListWorker.emitComputeDone([hosts[0], hosts[1]]); + await flushPromises(); + hostListWorker.emitComputeDone([hosts[1], hosts[2]]); + await flushPromises(); + + assert.deepEqual( + metricRequests.map(item => item.params.bk_host_ids), + [[1, 2], [3]] + ); + metricRequests[1].request.resolve({ 3: { cpu_usage: 30 } }); + await flushPromises(); + hostListWorker.emitComputeDone([hosts[1], hosts[2]]); + await flushPromises(); + metricRequests[0].request.reject(new Error('old page failed')); + await flushPromises(); + + assert.deepEqual( + metricRequests.map(item => item.params.bk_host_ids), + [[1, 2], [3], [2]] + ); + assert.equal(context.metricLoadError.value, false); + scope.stop(); + metricRequests[2].request.resolve({ 2: { cpu_usage: 20 } }); +}); + +test('a failed current-page request exposes the error without immediate retry spinning', async () => { + const host = createHost({ bkCloudId: 0, bkHostId: 1, ip: '10.0.0.1' }); + let metricRequestCount = 0; + getHostInfo = async () => [host]; + getHostMetricInfo = async () => { + metricRequestCount += 1; + throw new Error('current page failed'); + }; + hostListWorker = createControllerWorker(); + const progressiveMetricService = { + create: () => new Promise(() => {}), + hashHostIds: async () => '', + poll: () => new Promise(() => {}), + }; + const { context, scope } = createHostListController({ progressive: true, progressiveMetricService }); + + await context.loadData(); + hostListWorker.emitComputeDone([host]); + await flushPromises(); + + assert.equal(metricRequestCount, 1); + assert.equal(context.metricLoadError.value, true); + scope.stop(); +}); + +test('snapshot sections remain isolated until all four sections and the host hash are ready', async () => { + const hosts = [createHost({ bkCloudId: 0, bkHostId: 101, ip: '10.0.0.1' })]; + getHostInfo = async () => hosts; + getHostMetricInfo = async () => ({}); + hostListWorker = createControllerWorker(); + const pollResponses = [ + { + hostCount: 1, + hostIdsHash: '101', + revision: 1, + sections: [{ data: { 101: { status: 0 } }, name: 'agent_status' }], + status: 'RUNNING', + retryAfterMs: 0, + }, + { + hostCount: 1, + hostIdsHash: '101', + revision: 2, + sections: [ + { data: { 101: { cpu_usage: 88 } }, name: 'performance_data' }, + { data: { 101: { component: [] } }, name: 'process_status' }, + { data: { 101: { alarm_count: [] } }, name: 'alarm_count' }, + ], + status: 'READY', + }, + ]; + const progressiveMetricService = { + create: async () => createSnapshotResult({ snapshotId: 'snapshot-1' }), + hashHostIds: async hostIds => hostIds.map(String).sort().join(','), + poll: async () => createSnapshotResult(pollResponses.shift()), + }; + const { context, scope } = createHostListController({ progressive: true, progressiveMetricService }); + + await context.loadData(); + await flushPromises(); + assert.deepEqual(hostListWorker.calls.replaceMetrics, [ + { + epoch: 1, + metricListMap: { 101: { alarm_count: [], component: [], cpu_usage: 88, status: 0 } }, + }, + ]); + assert.equal(context.metricProgressiveState.value, 'READY'); + assert.deepEqual(context.filterOptionsMap.value, { display_name: [{ id: 'redis', name: 'redis' }] }); + scope.stop(); +}); + +test('a reused ready snapshot with a mismatched host set realigns the base list once without spinning', async () => { + let hostInfoCount = 0; + getHostInfo = async () => { + hostInfoCount += 1; + return [createHost({ bkCloudId: 0, bkHostId: 101, ip: '10.0.0.1' })]; + }; + getHostMetricInfo = async () => ({}); + hostListWorker = createControllerWorker(); + let createCount = 0; + let pollCount = 0; + const progressiveMetricService = { + create: async () => { + createCount += 1; + if (createCount > 2) { + return new Promise(() => {}); + } + return createSnapshotResult({ snapshotId: 'snapshot-reused' }); + }, + hashHostIds: async hostIds => hostIds.map(String).sort().join(','), + poll: async () => { + pollCount += 1; + return createSnapshotResult({ + hostCount: 2, + hostIdsHash: '101,999', + revision: 1, + retryAfterMs: 0, + sections: [ + { data: {}, name: 'agent_status' }, + { data: {}, name: 'performance_data' }, + { data: {}, name: 'process_status' }, + { data: {}, name: 'alarm_count' }, + ], + status: 'READY', + }); + }, + }; + const { context, scope } = createHostListController({ progressive: true, progressiveMetricService }); + + await context.loadData(); + await flushPromises(); + + assert.equal(hostInfoCount, 2); + assert.equal(createCount, 2); + assert.equal(pollCount, 2); + assert.deepEqual(hostListWorker.calls.replaceMetrics, []); + assert.equal(context.metricProgressiveState.value, 'FAILED'); + + context.retryMetricSnapshot(); + await flushPromises(); + assert.equal(hostInfoCount, 3); + assert.equal(createCount, 3); + assert.equal(context.metricProgressiveState.value, 'RUNNING'); + scope.stop(); +}); + +test('a reused READY manifest ignores create data and polls from revision zero before committing sections', async () => { + const host = createHost({ bkCloudId: 0, bkHostId: 101, ip: '10.0.0.1' }); + const pollRequests = []; + getHostInfo = async () => [host]; + getHostMetricInfo = async () => ({}); + hostListWorker = createControllerWorker(); + const progressiveMetricService = { + create: async () => + createSnapshotResult({ + hostCount: 1, + hostIdsHash: '101', + revision: 9, + sections: [ + { data: { 101: { cpu_usage: 1 } }, name: 'performance_data' }, + { data: { 101: { status: 0 } }, name: 'agent_status' }, + { data: { 101: { component: [] } }, name: 'process_status' }, + { data: { 101: { alarm_count: [] } }, name: 'alarm_count' }, + ], + status: 'READY', + }), + hashHostIds: async () => '101', + poll: async params => { + pollRequests.push(params); + return createSnapshotResult({ + hostCount: 1, + hostIdsHash: '101', + revision: 9, + sections: [ + { data: { 101: { status: 0 } }, name: 'agent_status' }, + { data: { 101: { cpu_usage: 88 } }, name: 'performance_data' }, + { data: { 101: { component: [] } }, name: 'process_status' }, + { data: { 101: { alarm_count: [] } }, name: 'alarm_count' }, + ], + status: 'READY', + }); + }, + }; + const { context, scope } = createHostListController({ progressive: true, progressiveMetricService }); + + await context.loadData(); + await flushPromises(); + + assert.equal(pollRequests.length, 1); + assert.equal(pollRequests[0].sinceRevision, 0); + assert.equal(context.metricProgressiveState.value, 'READY'); + assert.equal(context.metricLoading.value, false); + assert.equal(hostListWorker.calls.replaceMetrics.length, 1); + scope.stop(); +}); + +test('a page response arriving after snapshot replacement cannot patch READY metrics', async () => { + const snapshotCreate = deferred(); + const pageMetric = deferred(); + const host = createHost({ bkCloudId: 0, bkHostId: 101, ip: '10.0.0.1' }); + getHostInfo = async () => [host]; + getHostMetricInfo = () => pageMetric.promise; + hostListWorker = createControllerWorker(); + const progressiveMetricService = { + create: () => snapshotCreate.promise, + hashHostIds: async () => '101', + poll: async () => + createSnapshotResult({ + hostCount: 1, + hostIdsHash: '101', + sections: [ + { data: { 101: { status: 0 } }, name: 'agent_status' }, + { data: { 101: { cpu_usage: 88 } }, name: 'performance_data' }, + { data: { 101: { component: [] } }, name: 'process_status' }, + { data: { 101: { alarm_count: [] } }, name: 'alarm_count' }, + ], + status: 'READY', + }), + }; + const { context, scope } = createHostListController({ progressive: true, progressiveMetricService }); + + await context.loadData(); + hostListWorker.emitComputeDone([host]); + await flushPromises(); + snapshotCreate.resolve( + createSnapshotResult({ + hostCount: 1, + hostIdsHash: '101', + sections: [], + status: 'READY', + }) + ); + await flushPromises(); + assert.equal(context.metricProgressiveState.value, 'READY'); + + pageMetric.resolve({ 101: { cpu_usage: 25 } }); + await flushPromises(); + + assert.equal(hostListWorker.calls.replaceMetrics.length, 1); + assert.deepEqual(hostListWorker.calls.patchMetrics, []); + scope.stop(); +}); + +test('terminal snapshot states remain page-only and retry the snapshot without reloading the base list', async () => { + for (const status of ['FAILED', 'EXPIRED', 'UNAVAILABLE']) { + let baseRequestCount = 0; + let createCount = 0; + const retryCreate = deferred(); + getHostInfo = async () => { + baseRequestCount += 1; + return [createHost({ bkCloudId: 0, bkHostId: 101, ip: '10.0.0.1' })]; + }; + getHostMetricInfo = async () => ({}); + hostListWorker = createControllerWorker(); + const progressiveMetricService = { + create: async () => { + createCount += 1; + if (createCount === 1) { + return createSnapshotResult({ snapshotId: status === 'UNAVAILABLE' ? undefined : 'snapshot-1', status }); + } + return retryCreate.promise; + }, + hashHostIds: async () => '', + poll: async () => { + throw new Error('terminal create result must not be polled'); + }, + }; + const { context, scope } = createHostListController({ progressive: true, progressiveMetricService }); + + await context.loadData(); + await flushPromises(); + + assert.equal(baseRequestCount, 1, status); + assert.equal(createCount, 2, status); + assert.deepEqual(hostListWorker.calls.replaceMetrics, [], status); + scope.stop(); + retryCreate.resolve(createSnapshotResult()); + } +}); + +test('manual full-snapshot retry invalidates the old retry loop without reloading base or clearing page data', async () => { + let baseRequestCount = 0; + let createCount = 0; + const manualCreate = deferred(); + getHostInfo = async () => { + baseRequestCount += 1; + return [createHost({ bkCloudId: 0, bkHostId: 101, ip: '10.0.0.1' })]; + }; + getHostMetricInfo = async () => ({ 101: { cpu_usage: 25 } }); + hostListWorker = createControllerWorker(); + const progressiveMetricService = { + create: async () => { + createCount += 1; + return createCount === 1 + ? createSnapshotResult({ retryAfterMs: 50, status: 'UNAVAILABLE', snapshotId: undefined }) + : manualCreate.promise; + }, + hashHostIds: async () => '101', + poll: async () => new Promise(() => {}), + }; + const { context, scope } = createHostListController({ progressive: true, progressiveMetricService }); + + await context.loadData(); + hostListWorker.emitComputeDone([createHost({ bkCloudId: 0, bkHostId: 101, ip: '10.0.0.1' })]); + await flushPromises(); + assert.equal(context.metricProgressiveState.value, 'UNAVAILABLE'); + assert.equal(hostListWorker.calls.patchMetrics.length, 1); + + context.retryMetricSnapshot(); + context.retryMetricSnapshot(); + await new Promise(resolve => setTimeout(resolve, 60)); + + assert.equal(baseRequestCount, 1); + assert.equal(createCount, 2); + assert.equal(hostListWorker.calls.patchMetrics.length, 1); + scope.stop(); + manualCreate.resolve(createSnapshotResult()); +}); + +test('snapshot host-count validation uses the same deduplicated host id set as the hash', async () => { + const duplicateHosts = [ + createHost({ bkCloudId: 0, bkHostId: 101, ip: '10.0.0.1' }), + createHost({ bkCloudId: 0, bkHostId: 101, ip: '10.0.0.1' }), + ]; + getHostInfo = async () => duplicateHosts; + getHostMetricInfo = async () => ({}); + hostListWorker = createControllerWorker(); + let createCount = 0; + const progressiveMetricService = { + create: async () => { + createCount += 1; + return createSnapshotResult({ + hostCount: 1, + hostIdsHash: '101', + sections: [], + status: 'READY', + }); + }, + hashHostIds: async hostIds => hostIds.join(','), + poll: async () => + createSnapshotResult({ + hostCount: 1, + hostIdsHash: '101', + sections: [ + { data: {}, name: 'agent_status' }, + { data: {}, name: 'performance_data' }, + { data: {}, name: 'process_status' }, + { data: {}, name: 'alarm_count' }, + ], + status: 'READY', + }), + }; + const { context, scope } = createHostListController({ progressive: true, progressiveMetricService }); + + await context.loadData(); + await flushPromises(); + + assert.equal(createCount, 1); + assert.equal(context.metricProgressiveState.value, 'READY'); + assert.equal(hostListWorker.calls.replaceMetrics.length, 1); + scope.stop(); +}); + +test('a snapshot create failure falls back to the requested time for page metrics while retrying in background', async () => { + const retryCreate = deferred(); + let createCount = 0; + const metricRequests = []; + getHostInfo = async () => [createHost({ bkCloudId: 0, bkHostId: 101, ip: '10.0.0.1' })]; + getHostMetricInfo = async params => { + metricRequests.push(params); + return {}; + }; + hostListWorker = createControllerWorker(); + const progressiveMetricService = { + create: async () => { + createCount += 1; + if (createCount === 1) { + throw new Error('snapshot unavailable'); + } + return retryCreate.promise; + }, + hashHostIds: async () => '', + poll: async () => new Promise(() => {}), + }; + const { context, scope } = createHostListController({ progressive: true, progressiveMetricService }); + + await context.loadData(); + hostListWorker.emitComputeDone([createHost({ bkCloudId: 0, bkHostId: 101, ip: '10.0.0.1' })]); + await new Promise(resolve => setTimeout(resolve, 1050)); + + assert.equal(createCount, 2); + assert.equal(metricRequests[0].start_time, 0); + assert.equal(metricRequests[0].end_time, 1); + scope.stop(); + retryCreate.resolve(createSnapshotResult()); +}); + +test('an unavailable snapshot without canonical fields keeps the requested page time anchor', async () => { + const metricRequests = []; + getHostInfo = async () => [createHost({ bkCloudId: 0, bkHostId: 101, ip: '10.0.0.1' })]; + getHostMetricInfo = async params => { + metricRequests.push(params); + return {}; + }; + hostListWorker = createControllerWorker(); + const progressiveMetricService = { + create: async () => + createSnapshotResult({ + canonicalEndTime: undefined, + canonicalStartTime: undefined, + retryAfterMs: 20, + snapshotId: undefined, + status: 'UNAVAILABLE', + }), + hashHostIds: async () => '', + poll: async () => new Promise(() => {}), + }; + const { context, scope } = createHostListController({ progressive: true, progressiveMetricService }); + + await context.loadData(); + hostListWorker.emitComputeDone([createHost({ bkCloudId: 0, bkHostId: 101, ip: '10.0.0.1' })]); + await flushPromises(); + + assert.equal(metricRequests[0].start_time, 0); + assert.equal(metricRequests[0].end_time, 1); + scope.stop(); +}); + +test('production progressive mode uses the default snapshot adapter without component injection', async () => { + const createRequest = deferred(); + defaultProgressiveMetricService = { + create: () => createRequest.promise, + hashHostIds: async () => '', + poll: () => new Promise(() => {}), + }; + getHostInfo = async () => []; + getHostMetricInfo = async () => ({}); + hostListWorker = createControllerWorker(); + const { context, scope } = createHostListController({ progressive: true }); + + await context.loadData(); + + assert.equal(context.metricProgressiveState.value, 'RUNNING'); + scope.stop(); + createRequest.resolve(createSnapshotResult()); +}); + +test('metric quick cards and metric sorting are inert until the full snapshot is ready', () => { + const progressiveMetricService = { + create: () => new Promise(() => {}), + hashHostIds: async () => '', + poll: () => new Promise(() => {}), + }; + getHostInfo = async () => []; + getHostMetricInfo = async () => ({}); + hostListWorker = createControllerWorker(); + const { context, scope } = createHostListController({ progressive: true, progressiveMetricService }); + + context.handleCategoryClick('cpu'); + context.handleSortChange('-cpu_usage'); + assert.equal(context.activeCategory.value, ''); + assert.equal(context.sortInfo.value, ''); + + context.handleSortChange('bk_host_innerip'); + assert.equal(context.sortInfo.value, 'bk_host_innerip'); + scope.stop(); +}); + +test('disposing the controller rejects late snapshot creation and page responses', async () => { + const snapshotCreate = deferred(); + const pageMetric = deferred(); + let pollCount = 0; + getHostInfo = async () => [createHost({ bkCloudId: 0, bkHostId: 101, ip: '10.0.0.1' })]; + getHostMetricInfo = () => pageMetric.promise; + hostListWorker = createControllerWorker(); + const progressiveMetricService = { + create: () => snapshotCreate.promise, + hashHostIds: async () => '101', + poll: async () => { + pollCount += 1; + return new Promise(() => {}); + }, + }; + const { context, scope } = createHostListController({ progressive: true, progressiveMetricService }); + + await context.loadData(); + hostListWorker.emitComputeDone([createHost({ bkCloudId: 0, bkHostId: 101, ip: '10.0.0.1' })]); + await flushPromises(); + scope.stop(); + pageMetric.resolve({ 101: { cpu_usage: 25 } }); + snapshotCreate.resolve( + createSnapshotResult({ + hostCount: 1, + hostIdsHash: '101', + sections: [ + { data: {}, name: 'agent_status' }, + { data: {}, name: 'performance_data' }, + { data: {}, name: 'process_status' }, + { data: {}, name: 'alarm_count' }, + ], + status: 'READY', + }) + ); + await flushPromises(); + + assert.equal(pollCount, 0); + assert.deepEqual(hostListWorker.calls.patchMetrics, []); + assert.deepEqual(hostListWorker.calls.replaceMetrics, []); +}); + +test('disposing the controller rejects a late poll result without recreating metric state', async () => { + const pollRequest = deferred(); + getHostInfo = async () => [createHost({ bkCloudId: 0, bkHostId: 101, ip: '10.0.0.1' })]; + getHostMetricInfo = async () => ({}); + hostListWorker = createControllerWorker(); + const progressiveMetricService = { + create: async () => createSnapshotResult({ snapshotId: 'snapshot-1' }), + hashHostIds: async () => '101', + poll: () => pollRequest.promise, + }; + const { context, scope } = createHostListController({ progressive: true, progressiveMetricService }); + + await context.loadData(); + await flushPromises(); + scope.stop(); + pollRequest.resolve( + createSnapshotResult({ + hostCount: 1, + hostIdsHash: '101', + revision: 1, + sections: [ + { data: {}, name: 'agent_status' }, + { data: {}, name: 'performance_data' }, + { data: {}, name: 'process_status' }, + { data: {}, name: 'alarm_count' }, + ], + status: 'READY', + }) + ); + await flushPromises(); + + assert.deepEqual(hostListWorker.calls.replaceMetrics, []); +}); + +test('disposing during a snapshot retry delay prevents the obsolete poll request', async () => { + let pollCount = 0; + getHostInfo = async () => [createHost({ bkCloudId: 0, bkHostId: 101, ip: '10.0.0.1' })]; + getHostMetricInfo = async () => ({}); + hostListWorker = createControllerWorker(); + const progressiveMetricService = { + create: async () => createSnapshotResult({ retryAfterMs: 20, snapshotId: 'snapshot-1' }), + hashHostIds: async () => '101', + poll: async () => { + pollCount += 1; + return new Promise(() => {}); + }, + }; + const { context, scope } = createHostListController({ progressive: true, progressiveMetricService }); + + await context.loadData(); + await flushPromises(); + scope.stop(); + await new Promise(resolve => setTimeout(resolve, 30)); + + assert.equal(pollCount, 0); + assert.deepEqual(hostListWorker.calls.replaceMetrics, []); +}); + +test('disposing while host-id hashing is pending prevents a worker replacement', async () => { + const hashRequest = deferred(); + getHostInfo = async () => [createHost({ bkCloudId: 0, bkHostId: 101, ip: '10.0.0.1' })]; + getHostMetricInfo = async () => ({}); + hostListWorker = createControllerWorker(); + const progressiveMetricService = { + create: async () => + createSnapshotResult({ + hostCount: 1, + hostIdsHash: '101', + sections: [], + status: 'READY', + }), + hashHostIds: () => hashRequest.promise, + poll: async () => + createSnapshotResult({ + hostCount: 1, + hostIdsHash: '101', + sections: [ + { data: {}, name: 'agent_status' }, + { data: {}, name: 'performance_data' }, + { data: {}, name: 'process_status' }, + { data: {}, name: 'alarm_count' }, + ], + status: 'READY', + }), + }; + const { context, scope } = createHostListController({ progressive: true, progressiveMetricService }); + + await context.loadData(); + await flushPromises(); + scope.stop(); + hashRequest.resolve('101'); + await flushPromises(); + + assert.deepEqual(hostListWorker.calls.replaceMetrics, []); +}); + test('a slower old base-list request cannot replace a newer refresh', async () => { const first = deferred(); const second = deferred(); @@ -684,6 +1463,10 @@ test('host list views expose separate retry paths for base and metric failures', path.resolve(__dirname, '../src/trace/pages/host/components/host-list/host-list-table.tsx'), 'utf8' ); + const cardsSource = fs.readFileSync( + path.resolve(__dirname, '../src/trace/pages/host/components/host-list/host-stat-cards.tsx'), + 'utf8' + ); assert.match(hostListSource, / { diff --git a/bkmonitor/webpack/tests/host-list-progressive-metrics.test.cjs b/bkmonitor/webpack/tests/host-list-progressive-metrics.test.cjs new file mode 100644 index 0000000000..1ce23291fb --- /dev/null +++ b/bkmonitor/webpack/tests/host-list-progressive-metrics.test.cjs @@ -0,0 +1,191 @@ +/** + * Tencent is pleased to support the open source community by making + * 蓝鲸智云PaaS平台 (BlueKing PaaS) available. + * + * Copyright (C) 2017-2025 Tencent. All rights reserved. + * + * BlueKing PaaS is licensed under the MIT License. + */ + +'use strict'; + +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const test = require('node:test'); +const vm = require('node:vm'); + +const createHost = (bkHostId, ip) => ({ + bk_cloud_id: 0, + bk_host_id: bkHostId, + bk_host_innerip: ip, + module: [], +}); + +const createWorkerHarness = () => { + const source = fs.readFileSync( + path.resolve(__dirname, '../src/trace/pages/host/workers/host-list.worker.raw.js'), + 'utf8' + ); + const messages = []; + const workerSelf = { postMessage: message => messages.push(message) }; + vm.runInNewContext(source, { Map, Number, Object, Set, String, self: workerSelf }); + let requestId = 0; + return { + send(message) { + messages.length = 0; + requestId += 1; + workerSelf.onmessage({ data: { ...message, requestId } }); + assert.equal(messages.length, 1); + return messages[0]; + }, + }; +}; + +const defaultComputeParams = { + activeCategory: '', + keyword: '', + page: 1, + pageSize: 50, + selectedNode: null, + sortInfo: '', + stickyValue: {}, + where: [], +}; + +test('page metrics overlay the visible page without changing global sort or statistics', () => { + const worker = createWorkerHarness(); + worker.send({ + baseList: [createHost(101, '10.0.0.1'), createHost(102, '10.0.0.2')], + epoch: 1, + type: 'INIT_BASE', + }); + worker.send({ + epoch: 1, + metricListMap: { 102: { alarm_count: [], component: [], cpu_usage: 99 } }, + type: 'PATCH_METRICS', + }); + + const firstPage = worker.send({ + params: { ...defaultComputeParams, pageSize: 1, sortInfo: '-cpu_usage' }, + type: 'COMPUTE', + }); + assert.equal(firstPage.pagedRows[0].bk_host_id, 101); + assert.equal(firstPage.categoryStats.cpu, 0); + + const secondPage = worker.send({ + params: { ...defaultComputeParams, page: 2, pageSize: 1 }, + type: 'COMPUTE', + }); + assert.equal(secondPage.pagedRows[0].bk_host_id, 102); + assert.equal(secondPage.pagedRows[0].cpu_usage, 99); +}); + +test('running snapshot keeps base keyword search including IPv6 but excludes page-only process fields', () => { + const worker = createWorkerHarness(); + worker.send({ + baseList: [ + { + ...createHost(101, '10.0.0.1'), + bk_host_innerip_v6: '2001:db8::1', + bk_host_name: 'base-host', + }, + ], + epoch: 4, + type: 'INIT_BASE', + }); + const pageMetric = { + 101: { + component: [{ display_name: 'redis', status: 0 }], + }, + }; + worker.send({ epoch: 4, metricListMap: pageMetric, type: 'PATCH_METRICS' }); + + const baseMatch = worker.send({ + params: { ...defaultComputeParams, keyword: 'base-host' }, + type: 'COMPUTE', + }); + const processMatch = worker.send({ + params: { ...defaultComputeParams, keyword: 'redis' }, + type: 'COMPUTE', + }); + const ipv6Match = worker.send({ + params: { ...defaultComputeParams, keyword: '2001:db8' }, + type: 'COMPUTE', + }); + assert.equal(baseMatch.total, 1); + assert.equal(processMatch.total, 0); + assert.equal(ipv6Match.total, 1); + + worker.send({ epoch: 4, metricListMap: pageMetric, type: 'REPLACE_METRICS' }); + const readyProcessMatch = worker.send({ + params: { ...defaultComputeParams, keyword: 'redis' }, + type: 'COMPUTE', + }); + const readyIpv6Match = worker.send({ + params: { ...defaultComputeParams, keyword: '2001:db8' }, + type: 'COMPUTE', + }); + assert.equal(readyProcessMatch.total, 1); + assert.equal(readyIpv6Match.total, 1); +}); + +test('snapshot replacement atomically enables global metric semantics and clears page overlays', () => { + const worker = createWorkerHarness(); + worker.send({ + baseList: [createHost(101, '10.0.0.1'), createHost(102, '10.0.0.2')], + epoch: 7, + type: 'INIT_BASE', + }); + worker.send({ epoch: 7, metricListMap: { 101: { cpu_usage: 99 } }, type: 'PATCH_METRICS' }); + worker.send({ + epoch: 7, + metricListMap: { + 101: { alarm_count: [], component: [], cpu_usage: 1 }, + 102: { alarm_count: [], component: [], cpu_usage: 88 }, + }, + type: 'REPLACE_METRICS', + }); + + const result = worker.send({ + params: { ...defaultComputeParams, pageSize: 1, sortInfo: '-cpu_usage' }, + type: 'COMPUTE', + }); + assert.equal(result.pagedRows[0].bk_host_id, 102); + assert.equal(result.pagedRows[0].cpu_usage, 88); + assert.equal(result.categoryStats.cpu, 1); + + const host101 = worker.send({ + params: { ...defaultComputeParams, pageSize: 1 }, + type: 'COMPUTE', + }); + assert.equal(host101.pagedRows[0].cpu_usage, 1); +}); + +test('worker rejects page and snapshot results from an obsolete dataset epoch', () => { + const worker = createWorkerHarness(); + worker.send({ baseList: [createHost(202, '10.0.0.2')], epoch: 2, type: 'INIT_BASE' }); + + const pageResult = worker.send({ epoch: 1, metricListMap: { 202: { cpu_usage: 99 } }, type: 'PATCH_METRICS' }); + const snapshotResult = worker.send({ + epoch: 1, + metricListMap: { 202: { cpu_usage: 88 } }, + type: 'REPLACE_METRICS', + }); + const result = worker.send({ params: defaultComputeParams, type: 'COMPUTE' }); + + assert.equal(pageResult.applied, false); + assert.equal(snapshotResult.applied, false); + assert.equal(result.pagedRows[0].cpu_usage, undefined); +}); + +test('reset metrics clears both committed snapshot data and page overlays', () => { + const worker = createWorkerHarness(); + worker.send({ baseList: [createHost(101, '10.0.0.1')], epoch: 3, type: 'INIT_BASE' }); + worker.send({ epoch: 3, metricListMap: { 101: { cpu_usage: 99 } }, type: 'PATCH_METRICS' }); + worker.send({ epoch: 3, type: 'RESET_METRICS' }); + + const result = worker.send({ params: defaultComputeParams, type: 'COMPUTE' }); + assert.equal(result.pagedRows[0].cpu_usage, undefined); + assert.equal(result.categoryStats.cpu, 0); +}); diff --git a/bkmonitor/webpack/tests/host-service-error-state.test.cjs b/bkmonitor/webpack/tests/host-service-error-state.test.cjs index 86560b1c31..7da75f843d 100644 --- a/bkmonitor/webpack/tests/host-service-error-state.test.cjs +++ b/bkmonitor/webpack/tests/host-service-error-state.test.cjs @@ -25,6 +25,10 @@ const Module = require('node:module'); let searchHostInfo; let searchHostMetric; +let snapshotCreateResponse; +let snapshotPollResponse; +const snapshotCreateRequests = []; +const snapshotPollRequests = []; const originalLoad = Module._load; Module._load = function mockHostServiceDependencies(request, parent, isMain) { if (request === 'monitor-api/modules/commons') { @@ -36,11 +40,34 @@ Module._load = function mockHostServiceDependencies(request, parent, isMain) { searchHostMetric: (...args) => searchHostMetric(...args), }; } + if (request === 'monitor-api/base') { + return { + request: (method, url) => { + if (method.toLowerCase() === 'post') { + return async (params, config) => { + snapshotCreateRequests.push({ config, method, params, url }); + return snapshotCreateResponse; + }; + } + return async (id, params, config) => { + snapshotPollRequests.push({ config, id, method, params, url }); + return snapshotPollResponse; + }; + }, + }; + } return originalLoad.call(this, request, parent, isMain); }; -const { getHostInfoList, getHostMetricInfoList } = require('../src/trace/pages/host/services/host-service.ts'); +const { + getHostInfoList, + getHostMetricInfoList, + hostMetricSnapshotService, +} = require('../src/trace/pages/host/services/host-service.ts'); Module._load = originalLoad; +global.window = global.window || {}; +global.window.cc_biz_id = 7; + test('host service propagates a base-list request failure', async () => { const error = new Error('base request failed'); searchHostInfo = async () => { @@ -58,3 +85,149 @@ test('host service propagates a metric request failure', async () => { await assert.rejects(getHostMetricInfoList({ bk_host_ids: [101], end_time: 2, start_time: 1 }), error); }); + +test('snapshot adapter maps create manifest and sends an explicit scoped synchronous request', async () => { + snapshotCreateRequests.length = 0; + snapshotCreateResponse = { + canonical_end_time: 190, + canonical_start_time: 90, + data: { performance_data: { 101: { cpu_usage: 1 } } }, + expired: false, + failed_sections: [], + host_count: 2, + host_ids_hash: 'hash-1', + retry_after: 2, + revision: 3, + sections: {}, + snapshot_id: 'snapshot-1', + state: 'RUNNING', + }; + + const result = await hostMetricSnapshotService.create({ + bkBizId: 7, + bkInstId: 42, + bkObjId: 'module', + endTime: 200, + startTime: 100, + }); + + assert.deepEqual(snapshotCreateRequests, [ + { + config: { isAsync: false, needMessage: false }, + method: 'post', + params: { + bk_biz_id: 7, + bk_inst_id: 42, + bk_obj_id: 'module', + end_time: 200, + start_time: 100, + }, + url: 'rest/v2/performance/host_metric_snapshot/', + }, + ]); + assert.deepEqual(result, { + canonicalEndTime: 190, + canonicalStartTime: 90, + expired: false, + failedSections: [], + hostCount: 2, + hostIdsHash: 'hash-1', + retryAfterMs: 2000, + revision: 3, + sections: [], + snapshotId: 'snapshot-1', + status: 'RUNNING', + }); +}); + +test('snapshot adapter poll repeats business scope and canonical time and maps completed sections', async () => { + snapshotPollRequests.length = 0; + snapshotPollResponse = { + canonical_end_time: 190, + canonical_start_time: 90, + data: { + agent_status: { 101: { status: 0 } }, + performance_data: { 101: { cpu_usage: 12 } }, + }, + expired: false, + failed_sections: [], + host_count: 1, + host_ids_hash: 'hash-2', + retry_after: 0.5, + revision: 5, + sections: { agent_status: 'READY', performance_data: 'READY' }, + snapshot_id: 'snapshot-1', + state: 'RUNNING', + }; + + const result = await hostMetricSnapshotService.poll({ + bkBizId: 7, + bkHostId: 101, + endTime: 190, + sinceRevision: 3, + snapshotId: 'snapshot-1', + startTime: 90, + }); + + assert.deepEqual(snapshotPollRequests, [ + { + config: { isAsync: false, needMessage: false }, + id: 'snapshot-1', + method: 'get', + params: { + bk_biz_id: 7, + bk_host_id: 101, + end_time: 190, + since_revision: 3, + start_time: 90, + }, + url: 'rest/v2/performance/host_metric_snapshot/{pk}/', + }, + ]); + assert.equal(result.retryAfterMs, 500); + assert.deepEqual(result.sections, [ + { data: { 101: { status: 0 } }, name: 'agent_status' }, + { data: { 101: { cpu_usage: 12 } }, name: 'performance_data' }, + ]); +}); + +test('snapshot adapter preserves UNAVAILABLE and EXPIRED as terminal domain states', async () => { + snapshotCreateResponse = { + data: {}, + expired: false, + failed_sections: [], + host_count: 0, + host_ids_hash: '', + retry_after: 10, + revision: 0, + sections: {}, + state: 'UNAVAILABLE', + }; + const unavailable = await hostMetricSnapshotService.create({ bkBizId: 7, endTime: 200, startTime: 100 }); + assert.equal(unavailable.snapshotId, undefined); + assert.equal(unavailable.status, 'UNAVAILABLE'); + assert.equal(unavailable.canonicalStartTime, 100); + assert.equal(unavailable.canonicalEndTime, 200); + + snapshotPollResponse = { ...snapshotCreateResponse, expired: true, snapshot_id: 'snapshot-1', state: 'EXPIRED' }; + const expired = await hostMetricSnapshotService.poll({ + bkBizId: 7, + endTime: 190, + sinceRevision: 0, + snapshotId: 'snapshot-1', + startTime: 90, + }); + assert.equal(expired.status, 'EXPIRED'); + assert.equal(expired.expired, true); +}); + +test('host id hash uses canonical numeric ordering, deduplication, and SHA-256', async () => { + assert.equal( + await hostMetricSnapshotService.hashHostIds([2, 10, 2]), + '3b5140aab9f8b8240b81687ea6a802d4bb00fc5da32c97b4b2bff91263b3a545' + ); + assert.equal( + await hostMetricSnapshotService.hashHostIds([]), + 'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855' + ); +}); diff --git a/bkmonitor/webpack/tests/host-topo-load-consistency.test.cjs b/bkmonitor/webpack/tests/host-topo-load-consistency.test.cjs index 8e32a8152e..be4414059d 100644 --- a/bkmonitor/webpack/tests/host-topo-load-consistency.test.cjs +++ b/bkmonitor/webpack/tests/host-topo-load-consistency.test.cjs @@ -28,6 +28,9 @@ const loadHostService = getTopoTree => { const originalLoad = Module._load; Module._load = function loadWithHostServiceStubs(request, parent, isMain) { + if (request === 'monitor-api/base') { + return { request: () => async () => ({}) }; + } if (request === 'monitor-api/modules/commons') { return { getTopoTree }; } From cccc5c15baa38430e0acebc174a83ec08d5cde77 Mon Sep 17 00:00:00 2001 From: chenguo Date: Fri, 14 Aug 2026 17:43:12 +0800 Subject: [PATCH 4/7] =?UTF-8?q?fix:=20=E9=81=BF=E5=85=8D=E4=B8=BB=E6=9C=BA?= =?UTF-8?q?=E6=8C=87=E6=A0=87=E7=BC=93=E5=AD=98=E5=8A=A8=E6=80=81=E5=B1=9E?= =?UTF-8?q?=E6=80=A7=E6=B3=A8=E5=85=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../pages/host/workers/host-list.worker.raw.js | 16 +++++++++------- .../tests/host-list-progressive-metrics.test.cjs | 9 +++++++++ 2 files changed, 18 insertions(+), 7 deletions(-) diff --git a/bkmonitor/webpack/src/trace/pages/host/workers/host-list.worker.raw.js b/bkmonitor/webpack/src/trace/pages/host/workers/host-list.worker.raw.js index 2bb1614d45..6dff39da8d 100644 --- a/bkmonitor/webpack/src/trace/pages/host/workers/host-list.worker.raw.js +++ b/bkmonitor/webpack/src/trace/pages/host/workers/host-list.worker.raw.js @@ -394,7 +394,7 @@ const buildFilterOptionsMap = rows => { let baseRows = []; let committedRows = []; -let pageMetricMap = {}; +let pageMetricMap = new Map(); let filterOptionsMap = new Map(); let currentEpoch = 0; let metricsReady = false; @@ -432,7 +432,7 @@ const runCompute = params => { const slicedRows = sortedRows.slice(start, start + params.pageSize); const pagedRows = metricsReady ? slicedRows - : slicedRows.map(row => createHostListRow(row, pageMetricMap[String(row.bk_host_id)])); + : slicedRows.map(row => createHostListRow(row, pageMetricMap.get(String(row.bk_host_id)))); return { categoryStats, pagedRows, total }; }; @@ -440,7 +440,7 @@ const isCurrentEpoch = message => Number(message.epoch) === currentEpoch; const replaceCommittedMetrics = metricListMap => { committedRows = baseRows.map(row => createHostListRow(row, metricListMap[row.bk_host_id])); - pageMetricMap = {}; + pageMetricMap = new Map(); metricsReady = true; filterOptionsMap = buildFilterOptionsMap(committedRows); }; @@ -464,7 +464,7 @@ self.onmessage = event => { currentEpoch = nextEpoch; baseRows = message.baseList; committedRows = baseRows.map(row => createHostListRow(row)); - pageMetricMap = {}; + pageMetricMap = new Map(); metricsReady = false; filterOptionsMap = buildFilterOptionsMap(committedRows); self.postMessage({ @@ -492,7 +492,7 @@ self.onmessage = event => { const applied = isCurrentEpoch(message); if (applied) { committedRows = baseRows.map(row => createHostListRow(row)); - pageMetricMap = {}; + pageMetricMap = new Map(); metricsReady = false; filterOptionsMap = buildFilterOptionsMap(committedRows); } @@ -509,9 +509,11 @@ self.onmessage = event => { const applied = isCurrentEpoch(message); if (applied && !metricsReady) { for (const hostId of message.hostIds || []) { - delete pageMetricMap[String(hostId)]; + pageMetricMap.delete(String(hostId)); + } + for (const [hostId, metric] of Object.entries(message.metricListMap || {})) { + pageMetricMap.set(hostId, metric); } - pageMetricMap = { ...pageMetricMap, ...message.metricListMap }; } self.postMessage({ applied: applied && !metricsReady, diff --git a/bkmonitor/webpack/tests/host-list-progressive-metrics.test.cjs b/bkmonitor/webpack/tests/host-list-progressive-metrics.test.cjs index 1ce23291fb..5ea72410d0 100644 --- a/bkmonitor/webpack/tests/host-list-progressive-metrics.test.cjs +++ b/bkmonitor/webpack/tests/host-list-progressive-metrics.test.cjs @@ -53,6 +53,15 @@ const defaultComputeParams = { where: [], }; +test('page metric cache does not mutate object properties from worker messages', () => { + const source = fs.readFileSync( + path.resolve(__dirname, '../src/trace/pages/host/workers/host-list.worker.raw.js'), + 'utf8' + ); + assert.match(source, /let pageMetricMap = new Map\(\)/); + assert.doesNotMatch(source, /delete pageMetricMap\[/); +}); + test('page metrics overlay the visible page without changing global sort or statistics', () => { const worker = createWorkerHarness(); worker.send({ From 1ea7014e022bface5f9490e251b9c924fe0d70e4 Mon Sep 17 00:00:00 2001 From: chenguo Date: Wed, 19 Aug 2026 11:35:19 +0800 Subject: [PATCH 5/7] =?UTF-8?q?fix:=20=E6=8C=89=E4=B8=BB=E6=9C=BA=E8=A7=84?= =?UTF-8?q?=E6=A8=A1=E5=90=AF=E7=94=A8=E6=8C=87=E6=A0=87=E6=B8=90=E8=BF=9B?= =?UTF-8?q?=E5=8A=A0=E8=BD=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- bkmonitor/config/default.py | 1 + .../packages/common/context_processors.py | 2 + .../common/tests/test_context_processors.py | 3 +- .../pages/host/composables/use-host-list.ts | 43 +++++---- bkmonitor/webpack/src/trace/shim.d.ts | 1 + .../tests/host-list-consistency.test.cjs | 94 ++++++++++++++++++- 6 files changed, 122 insertions(+), 22 deletions(-) diff --git a/bkmonitor/config/default.py b/bkmonitor/config/default.py index 7f455dd33a..6d7c6cc2b3 100644 --- a/bkmonitor/config/default.py +++ b/bkmonitor/config/default.py @@ -990,6 +990,7 @@ IS_ACCESS_BK_DATA = os.getenv("BKAPP_IS_ACCESS_BK_DATA", "") == "true" # 是否接入计算平台 IS_ENABLE_VIEW_CMDB_LEVEL = False # 是否开启前端视图部分的CMDB预聚合 ENABLE_HOST_METRIC_PROGRESSIVE = os.getenv("ENABLE_HOST_METRIC_PROGRESSIVE", "false").lower() == "true" +HOST_METRIC_PROGRESSIVE_MIN_HOST_COUNT = int(os.getenv("HOST_METRIC_PROGRESSIVE_MIN_HOST_COUNT", "2000")) HOST_METRIC_SNAPSHOT_MAX_CONCURRENT_PER_BIZ = max(1, int(os.getenv("HOST_METRIC_SNAPSHOT_MAX_CONCURRENT_PER_BIZ", 1))) IS_MIGRATE_AIOPS_STRATEGY = False diff --git a/bkmonitor/packages/common/context_processors.py b/bkmonitor/packages/common/context_processors.py index 40fe806a34..7808e0c76d 100644 --- a/bkmonitor/packages/common/context_processors.py +++ b/bkmonitor/packages/common/context_processors.py @@ -200,6 +200,8 @@ def get_basic_context(request, space_list: list[dict[str, Any]], bk_biz_id: int) "ENABLE_CMDB_LEVEL": settings.IS_ACCESS_BK_DATA and settings.IS_ENABLE_VIEW_CMDB_LEVEL, # 是否开启主机列表指标渐进式加载 "ENABLE_HOST_METRIC_PROGRESSIVE": settings.ENABLE_HOST_METRIC_PROGRESSIVE, + # 主机列表启用指标渐进式加载的最小主机数 + "HOST_METRIC_PROGRESSIVE_MIN_HOST_COUNT": settings.HOST_METRIC_PROGRESSIVE_MIN_HOST_COUNT, # 事件中心一键拉取功能展示 "ENABLE_CREATE_CHAT_GROUP": settings.ENABLE_CREATE_CHAT_GROUP, # 用于全局设置蓝鲸监控机器人发送图片是否开启 diff --git a/bkmonitor/packages/common/tests/test_context_processors.py b/bkmonitor/packages/common/tests/test_context_processors.py index 6b50d9b976..0af2b51643 100644 --- a/bkmonitor/packages/common/tests/test_context_processors.py +++ b/bkmonitor/packages/common/tests/test_context_processors.py @@ -101,7 +101,7 @@ def test_get_basic_context_disables_ai_assistant_by_environment_variable(): assert context["ENABLE_AI_ASSISTANT"] == "false" -@override_settings(ENABLE_HOST_METRIC_PROGRESSIVE=True) +@override_settings(ENABLE_HOST_METRIC_PROGRESSIVE=True, HOST_METRIC_PROGRESSIVE_MIN_HOST_COUNT=2000) def test_get_basic_context_exposes_host_metric_progressive_switch(): with ( mock.patch("common.context_processors.get_core_context", return_value={}), @@ -110,3 +110,4 @@ def test_get_basic_context_exposes_host_metric_progressive_switch(): context = get_basic_context(make_request(), [{"bk_biz_id": 2}], 2) assert context["ENABLE_HOST_METRIC_PROGRESSIVE"] is True + assert context["HOST_METRIC_PROGRESSIVE_MIN_HOST_COUNT"] == 2000 diff --git a/bkmonitor/webpack/src/trace/pages/host/composables/use-host-list.ts b/bkmonitor/webpack/src/trace/pages/host/composables/use-host-list.ts index 5f2163125e..0345f3ee4e 100644 --- a/bkmonitor/webpack/src/trace/pages/host/composables/use-host-list.ts +++ b/bkmonitor/webpack/src/trace/pages/host/composables/use-host-list.ts @@ -101,7 +101,13 @@ export const useHostList = (options: IUseHostListOptions) => { const { timeRange, timezone, refreshGeneration, refreshInterval } = storeToRefs(useHostStore()); const { handleGetUserConfig, handleSetUserConfig } = useUserConfig(); const progressiveMetricService = options.progressiveMetricService ?? hostMetricSnapshotService; - const progressiveEnabled = !!window.enable_host_metric_progressive; + const progressiveFeatureEnabled = !!window.enable_host_metric_progressive; + const configuredProgressiveMinHostCount = Number(window.host_metric_progressive_min_host_count); + const progressiveMinHostCount = + Number.isInteger(configuredProgressiveMinHostCount) && configuredProgressiveMinHostCount > 0 + ? configuredProgressiveMinHostCount + : 2000; + const progressiveEnabled = shallowRef(false); /** 基础数据加载中(第一屏) */ const loading = shallowRef(false); @@ -112,8 +118,8 @@ export const useHostList = (options: IUseHostListOptions) => { /** 指标数据加载失败(保留基础行,仅指标列展示错误态) */ const metricLoadError = shallowRef(false); /** 渐进指标状态;旧链路始终视为 READY,保持现有交互 */ - const metricProgressiveState = shallowRef(progressiveEnabled ? 'RUNNING' : 'READY'); - const metricSemanticsReady = computed(() => !progressiveEnabled || metricProgressiveState.value === 'READY'); + const metricProgressiveState = shallowRef('READY'); + const metricSemanticsReady = computed(() => !progressiveEnabled.value || metricProgressiveState.value === 'READY'); /** 全量主机行数(主线程不持有全量行对象) */ const rawRowCount = shallowRef(0); /** retrieval-filter 语句模式 */ @@ -194,7 +200,7 @@ export const useHostList = (options: IUseHostListOptions) => { watch([timeRange, timezone], () => { setUrlParams(); - progressiveEnabled ? loadData() : loadMetricData(); + progressiveEnabled.value ? loadData() : loadMetricData(); }); watch(refreshGeneration, () => { @@ -277,7 +283,7 @@ export const useHostList = (options: IUseHostListOptions) => { categoryStats.value = data.categoryStats; total.value = data.total; pagedRows.value = data.pagedRows; - if (progressiveEnabled && metricProgressiveState.value !== 'READY') { + if (progressiveEnabled.value && metricProgressiveState.value !== 'READY') { void loadPageMetricData(data.pagedRows, dataRequestGeneration); } }); @@ -491,7 +497,7 @@ export const useHostList = (options: IUseHostListOptions) => { baseReady: Promise> | null> ) => { const time = currentCanonicalTime; - if (!progressiveEnabled || disposed || time?.epoch !== epoch) { + if (!progressiveEnabled.value || disposed || time?.epoch !== epoch) { return; } const attempt = ++snapshotAttemptGeneration; @@ -510,7 +516,7 @@ export const useHostList = (options: IUseHostListOptions) => { }; const loadPageMetricData = async (rows: IHostListRow[], epoch: number) => { - if (!progressiveEnabled || epoch !== dataRequestGeneration || metricProgressiveState.value === 'READY') { + if (!progressiveEnabled.value || epoch !== dataRequestGeneration || metricProgressiveState.value === 'READY') { return; } const canonicalTime = currentCanonicalTime; @@ -586,12 +592,9 @@ export const useHostList = (options: IUseHostListOptions) => { loadedPageHostIds.clear(); pendingPageHostIds.clear(); currentPageRequestKey = ''; - if (progressiveEnabled) { - metricProgressiveState.value = 'RUNNING'; - const [startTime, endTime] = handleTransformToTimestamp(timeRange.value); - currentCanonicalTime = { endTime, epoch: requestGeneration, startTime }; - startProgressiveSnapshot(requestGeneration, baseReady); - } + progressiveEnabled.value = false; + metricProgressiveState.value = 'READY'; + currentCanonicalTime = undefined; // 手动/定时刷新时重置选择(对标旧版 handleResetCheck) selectAllMode.value = HostSelectAllModeEnum.NONE; selectedRowKeys.value = new Set(); @@ -603,9 +606,17 @@ export const useHostList = (options: IUseHostListOptions) => { return; } baseList = requestBaseList; + const uniqueHostCount = new Set(requestBaseList.map(row => row.bk_host_id)).size; + progressiveEnabled.value = progressiveFeatureEnabled && uniqueHostCount >= progressiveMinHostCount; + if (progressiveEnabled.value) { + metricProgressiveState.value = 'RUNNING'; + const [startTime, endTime] = handleTransformToTimestamp(timeRange.value); + currentCanonicalTime = { endTime, epoch: requestGeneration, startTime }; + startProgressiveSnapshot(requestGeneration, baseReady); + } const initResult = await hostListWorker.initBaseData( requestBaseList, - progressiveEnabled ? requestGeneration : undefined + progressiveEnabled.value ? requestGeneration : undefined ); if (requestGeneration !== dataRequestGeneration) { resolveBaseReady(null); @@ -654,7 +665,7 @@ export const useHostList = (options: IUseHostListOptions) => { } return; } - if (progressiveEnabled) { + if (progressiveEnabled.value) { return; } const metricGeneration = ++metricRequestGeneration; @@ -698,7 +709,7 @@ export const useHostList = (options: IUseHostListOptions) => { return; } - if (progressiveEnabled) { + if (progressiveEnabled.value) { await loadPageMetricData(pagedRows.value, dataRequestGeneration); return; } diff --git a/bkmonitor/webpack/src/trace/shim.d.ts b/bkmonitor/webpack/src/trace/shim.d.ts index 576954e3e4..39a9d3b8fb 100644 --- a/bkmonitor/webpack/src/trace/shim.d.ts +++ b/bkmonitor/webpack/src/trace/shim.d.ts @@ -64,6 +64,7 @@ declare global { enable_multi_tenant_mode?: boolean; FEATURE_TOGGLE?: Record; graph_watermark: boolean; + host_metric_progressive_min_host_count?: number; i18n: typeof i18n.global; // 以下为日志全局变量配置 mainComponent: any; diff --git a/bkmonitor/webpack/tests/host-list-consistency.test.cjs b/bkmonitor/webpack/tests/host-list-consistency.test.cjs index 29e89c0fa3..7054be14a0 100644 --- a/bkmonitor/webpack/tests/host-list-consistency.test.cjs +++ b/bkmonitor/webpack/tests/host-list-consistency.test.cjs @@ -428,9 +428,14 @@ const createControllerWorker = () => { }; }; -const createHostListController = ({ progressive = false, progressiveMetricService } = {}) => { +const createHostListController = ({ + progressive = false, + progressiveMetricService, + progressiveMinHostCount = progressive ? 1 : 2000, +} = {}) => { mountedCallbacks = []; global.window.enable_host_metric_progressive = progressive; + global.window.host_metric_progressive_min_host_count = progressiveMinHostCount; const scope = vue.effectScope(); let context; scope.run(() => { @@ -462,6 +467,82 @@ const createSnapshotResult = overrides => ({ ...overrides, }); +test('progressive mode keeps the legacy full metric request below the 2000-host boundary', async () => { + const hosts = Array.from({ length: 1999 }, (_, index) => + createHost({ bkCloudId: 0, bkHostId: index + 1, ip: `10.0.${Math.floor(index / 255)}.${index % 255}` }) + ); + const metricRequests = []; + let snapshotCreateCount = 0; + getHostInfo = async () => hosts; + getHostMetricInfo = async params => { + metricRequests.push(params); + return {}; + }; + hostListWorker = createControllerWorker(); + const progressiveMetricService = { + create: async () => { + snapshotCreateCount += 1; + return createSnapshotResult(); + }, + hashHostIds: async hostIds => hostIds.map(String).sort().join(','), + poll: () => new Promise(() => {}), + }; + const { context, scope } = createHostListController({ + progressive: true, + progressiveMetricService, + progressiveMinHostCount: 2000, + }); + + await context.loadData(); + + assert.equal(snapshotCreateCount, 0); + assert.equal(metricRequests.length, 1); + assert.equal(metricRequests[0].query_mode, undefined); + assert.equal(metricRequests[0].bk_host_ids.length, 1999); + assert.equal(context.metricProgressiveState.value, 'READY'); + assert.equal(hostListWorker.calls.initBaseEpochs[0], undefined); + scope.stop(); +}); + +test('progressive mode starts at 2000 hosts and does not create a snapshot before the base count is known', async () => { + const baseRequest = deferred(); + const hosts = Array.from({ length: 2000 }, (_, index) => + createHost({ bkCloudId: 0, bkHostId: index + 1, ip: `10.1.${Math.floor(index / 255)}.${index % 255}` }) + ); + const metricRequests = []; + let snapshotCreateCount = 0; + getHostInfo = () => baseRequest.promise; + getHostMetricInfo = async params => { + metricRequests.push(params); + return {}; + }; + hostListWorker = createControllerWorker(); + const progressiveMetricService = { + create: () => { + snapshotCreateCount += 1; + return new Promise(() => {}); + }, + hashHostIds: async hostIds => hostIds.map(String).sort().join(','), + poll: () => new Promise(() => {}), + }; + const { context, scope } = createHostListController({ + progressive: true, + progressiveMetricService, + progressiveMinHostCount: 2000, + }); + + const loading = context.loadData(); + assert.equal(snapshotCreateCount, 0); + baseRequest.resolve(hosts); + await loading; + + assert.equal(snapshotCreateCount, 1); + assert.equal(metricRequests.length, 0); + assert.equal(context.metricProgressiveState.value, 'RUNNING'); + assert.equal(hostListWorker.calls.initBaseEpochs[0], 1); + scope.stop(); +}); + test('progressive mode starts the snapshot with the base list and requests only the visible page while running', async () => { const baseRequest = deferred(); const snapshotCreateRequest = deferred(); @@ -488,9 +569,10 @@ test('progressive mode starts the snapshot with the base list and requests only const { context, scope } = createHostListController({ progressive: true, progressiveMetricService }); const loading = context.loadData(); - assert.equal(context.metricProgressiveState.value, 'RUNNING'); + assert.equal(context.metricProgressiveState.value, 'READY'); baseRequest.resolve(hosts); await loading; + assert.equal(context.metricProgressiveState.value, 'RUNNING'); hostListWorker.emitComputeDone([hosts[0]]); await flushPromises(); @@ -1020,7 +1102,7 @@ test('production progressive mode uses the default snapshot adapter without comp hashHostIds: async () => '', poll: () => new Promise(() => {}), }; - getHostInfo = async () => []; + getHostInfo = async () => [createHost({ bkCloudId: 0, bkHostId: 101, ip: '10.0.0.1' })]; getHostMetricInfo = async () => ({}); hostListWorker = createControllerWorker(); const { context, scope } = createHostListController({ progressive: true }); @@ -1032,17 +1114,19 @@ test('production progressive mode uses the default snapshot adapter without comp createRequest.resolve(createSnapshotResult()); }); -test('metric quick cards and metric sorting are inert until the full snapshot is ready', () => { +test('metric quick cards and metric sorting are inert until the full snapshot is ready', async () => { const progressiveMetricService = { create: () => new Promise(() => {}), hashHostIds: async () => '', poll: () => new Promise(() => {}), }; - getHostInfo = async () => []; + getHostInfo = async () => [createHost({ bkCloudId: 0, bkHostId: 101, ip: '10.0.0.1' })]; getHostMetricInfo = async () => ({}); hostListWorker = createControllerWorker(); const { context, scope } = createHostListController({ progressive: true, progressiveMetricService }); + await context.loadData(); + context.handleCategoryClick('cpu'); context.handleSortChange('-cpu_usage'); assert.equal(context.activeCategory.value, ''); From 28a69584b0a2ad2b6264197bec84757b25fb35f7 Mon Sep 17 00:00:00 2001 From: chenguo Date: Wed, 19 Aug 2026 15:09:47 +0800 Subject: [PATCH 6/7] =?UTF-8?q?fix:=20=E4=BF=9D=E7=95=99=E4=B8=BB=E6=9C=BA?= =?UTF-8?q?=E6=8C=87=E6=A0=87=E9=83=A8=E5=88=86=E6=95=B0=E6=8D=AE=E5=8F=AF?= =?UTF-8?q?=E7=94=A8=E6=80=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../packages/monitor_web/cc/resources/cmdb.py | 75 +++++++--- .../monitor_web/performance/resources.py | 93 ++++++++++-- .../monitor_web/performance/snapshot.py | 98 ++++++++++-- .../packages/monitor_web/performance/tasks.py | 71 ++++++--- .../performance/test_host_metric_snapshot.py | 83 ++++++++++- .../performance/test_search_host_metric.py | 40 +++++ .../packages/monitor_web/tests/test_cc.py | 22 +++ .../components/host-list/host-list-table.tsx | 51 ++----- .../host/components/host-list/host-list.tsx | 18 ++- .../components/host-list/host-stat-cards.scss | 15 +- .../components/host-list/host-stat-cards.tsx | 29 ++-- .../host/composables/use-host-list-worker.ts | 2 + .../pages/host/composables/use-host-list.ts | 141 ++++++++++++------ .../trace/pages/host/services/host-service.ts | 28 ++++ .../src/trace/pages/host/types/host-list.ts | 13 +- .../host/types/host-metric-progressive.ts | 3 +- .../trace/pages/host/utils/host-list-core.ts | 51 +++++-- .../host/workers/host-list.worker.raw.js | 93 +++++++----- .../tests/host-list-consistency.test.cjs | 134 +++++++++++++++-- .../host-list-progressive-metrics.test.cjs | 98 ++++++++++-- .../tests/host-service-error-state.test.cjs | 1 + 21 files changed, 889 insertions(+), 270 deletions(-) diff --git a/bkmonitor/packages/monitor_web/cc/resources/cmdb.py b/bkmonitor/packages/monitor_web/cc/resources/cmdb.py index 9fd4d16ac2..6706d45604 100644 --- a/bkmonitor/packages/monitor_web/cc/resources/cmdb.py +++ b/bkmonitor/packages/monitor_web/cc/resources/cmdb.py @@ -11,6 +11,7 @@ import logging import time from collections import defaultdict +from collections.abc import Callable from api.cmdb.define import Host, ServiceInstance, TopoTree from bkm_ipchooser import constants @@ -70,6 +71,7 @@ def get_agent_status( end_time: int = None, fail_on_incomplete: bool = False, target_filter: dict | None = None, + incomplete_callback: Callable[[], None] | None = None, ) -> dict[int, int]: """ :summary 获取主机Agent状态及数据状态 @@ -80,6 +82,7 @@ def get_agent_status( :param end_time: 查询结束时间(秒级 Unix 时间戳,可选)。不传或仅传一个时退化为默认"最近三分钟"实时查询。 :param fail_on_incomplete: UQ 返回部分结果时是否抛出异常。默认保持历史降级行为。 :param target_filter: UQ 目标过滤条件。None 沿用按 hosts 构造的默认条件;{} 仅供服务端可信的全业务查询。 + :param incomplete_callback: UQ 或 NodeMan 返回不完整时回调;启用后仅返回能够确认的状态。 :return {bk_host_id: AGENT_STATUS} """ if not hosts: @@ -112,8 +115,11 @@ def get_agent_status( query_start = query_end - 180000 # 使用 instant 查询取窗口聚合的单点,避免拉回区间序列 records = query.query_data(start_time=query_start, end_time=query_end, instant=True) - if fail_on_incomplete and query.is_partial: - raise RuntimeError("unify query returned partial data for agent status") + if query.is_partial: + if incomplete_callback: + incomplete_callback() + elif fail_on_incomplete: + raise RuntimeError("unify query returned partial data for agent status") # 统计已经存在数据的主机并设置状态为正常 ip_to_host_id: dict[tuple, int] = { @@ -136,6 +142,11 @@ def get_agent_status( if bk_host_id: status[bk_host_id] = AGENT_STATUS.ON + # UQ 已明确声明结果不完整时,无法用“结果中不存在”推导无数据或未安装 Agent。 + # 保留已查到的 ON,其他主机保持未知,由调用方展示为未获取。 + if query.is_partial and incomplete_callback: + return status + if is_historical: # 历史查询:node_man 只提供实时 Agent 存活状态,对历史时间段无意义; # 改为依赖 TSDB 数据上报推断:历史窗口内有数据 → ON;无数据 → NO_DATA @@ -172,17 +183,21 @@ def get_agent_status( logger.error("get_agent_status error: %s", e) node_man_failed = True - if fail_on_incomplete and node_man_failed: - raise RuntimeError("node manager returned incomplete agent status") + if node_man_failed: + if incomplete_callback: + incomplete_callback() + elif fail_on_incomplete: + raise RuntimeError("node manager returned incomplete agent status") for info in result: host_id = info["host_id"] if info["alive"] == 1: status[host_id] = AGENT_STATUS.NO_DATA - for host in hosts: - if host.bk_host_id not in status: - status[host.bk_host_id] = AGENT_STATUS.NOT_EXIST + if not node_man_failed or incomplete_callback is None: + for host in hosts: + if host.bk_host_id not in status: + status[host.bk_host_id] = AGENT_STATUS.NOT_EXIST return status @@ -215,6 +230,7 @@ def get_process_info( fail_on_incomplete: bool = False, filter_by_hosts: bool = False, target_filter: dict | None = None, + incomplete_callback: Callable[[], None] | None = None, ) -> dict[int, list[dict]]: """ :summary 通过主机ID列表获取主机进程信息 @@ -269,6 +285,7 @@ def get_process_info( end_time, fail_on_incomplete=fail_on_incomplete, target_filter=target_filter, + incomplete_callback=incomplete_callback, ) bk_host_ids = {host.bk_host_id for host in hosts} @@ -312,6 +329,7 @@ def get_process_status( end_time: int = None, fail_on_incomplete: bool = False, target_filter: dict | None = None, + incomplete_callback: Callable[[], None] | None = None, ) -> dict[int, dict[str, int]]: """ 查询进程状态,1为存活 @@ -334,6 +352,7 @@ def get_process_status( end_time, fail_on_incomplete=fail_on_incomplete, target_filter=target_filter, + incomplete_callback=incomplete_callback, ): result[bk_host_id][display_name] = AGENT_STATUS.ON if value else AGENT_STATUS.OFF return result @@ -349,6 +368,7 @@ def _query_proc_metrics( end_time: int = None, fail_on_incomplete: bool = False, target_filter: dict | None = None, + incomplete_callback: Callable[[], None] | None = None, ): """ 查询 system.proc / system.proc_port 指标的公共生成器。 @@ -387,8 +407,11 @@ def _query_proc_metrics( # instant 查询仅返回 end_time 单点,路由窗口收紧为 180 秒,避免大范围分片扫描 query_start = query_end - 180000 records = query.query_data(start_time=query_start, end_time=query_end, instant=True) - if fail_on_incomplete and query.is_partial: - raise RuntimeError(f"unify query returned partial data for {table}.{field}") + if query.is_partial: + if incomplete_callback: + incomplete_callback() + elif fail_on_incomplete: + raise RuntimeError(f"unify query returned partial data for {table}.{field}") for record in records: if record.get("_result_") is None: continue @@ -593,6 +616,7 @@ def get_host_performance_data( end_time: int = None, fail_on_incomplete: bool = False, target_filter: dict | None = None, + incomplete_callback: Callable[[], None] | None = None, ) -> dict[int, dict] | dict[tuple, dict]: """ :summary 按主机查询主机性能信息(五分钟负载/CPU使用率/磁盘空间使用率/磁盘IO使用率/应用内存使用率) @@ -610,17 +634,15 @@ def get_host_performance_data( ip_to_host_id = {(host.bk_host_innerip, int(host.bk_cloud_id or 0)): host.bk_host_id for host in hosts} bk_host_ids = {host.bk_host_id for host in hosts} - data = { - host.bk_host_id: { - "cpu_load": None, - "cpu_usage": None, - "disk_in_use": None, - "io_util": None, - "mem_usage": None, - "psc_mem_usage": None, - } - for host in hosts + default_metrics = { + "cpu_load": None, + "cpu_usage": None, + "disk_in_use": None, + "io_util": None, + "mem_usage": None, + "psc_mem_usage": None, } + data = {host.bk_host_id: ({**default_metrics} if incomplete_callback is None else {}) for host in hosts} # 与主机图表保持相同的目标维度:IPv4 使用 IP+云区域,IPv6 使用主机 ID。 # IPv4 身份不完整时保留全量查询,避免过滤掉只能通过 bk_host_id 回填的兼容数据。 @@ -646,7 +668,8 @@ def get_metric_data(metric): # instant 查询仅返回 end_time 单点,路由窗口收紧为 180 秒,避免大范围分片扫描 query_start = query_end - 180000 records = query.query_data(start_time=query_start, end_time=query_end, instant=True) - if fail_on_incomplete and query.is_partial: + is_partial = query.is_partial + if is_partial and fail_on_incomplete and not incomplete_callback: raise RuntimeError(f"unify query returned partial data for metric {metric['field']}") for record in records: if record["_result_"] is None: @@ -661,7 +684,7 @@ def get_metric_data(metric): if bk_host_id in bk_host_ids: local[bk_host_id] = round(record["_result_"] * metric.get("ratio", 1), 2) - return local + return local, is_partial metrics = [ {"field": "cpu_load", "result_table_id": "system.load", "metric_field": "load5"}, @@ -683,12 +706,20 @@ def get_metric_data(metric): try: for metric, future in zip(metrics, futures): try: - metric_data = future.get() + metric_data, is_partial = future.get() except Exception as e: if fail_on_incomplete: raise + if incomplete_callback: + incomplete_callback() logger.warning("get_host_performance_data metric %s failed, skip: %s", metric["field"], e) continue + if is_partial and incomplete_callback: + incomplete_callback() + if incomplete_callback and not is_partial: + for host_id in bk_host_ids: + data[host_id][metric["field"]] = metric_data.get(host_id) + continue for host_id, value in metric_data.items(): data[host_id][metric["field"]] = value finally: diff --git a/bkmonitor/packages/monitor_web/performance/resources.py b/bkmonitor/packages/monitor_web/performance/resources.py index dc265d94b0..b917714cfb 100644 --- a/bkmonitor/packages/monitor_web/performance/resources.py +++ b/bkmonitor/packages/monitor_web/performance/resources.py @@ -472,6 +472,7 @@ def get_agent_status( end_time: int = None, fail_on_incomplete: bool = False, target_filter: dict | None = None, + incomplete_callback=None, ): """ 获取Agent状态 @@ -483,6 +484,7 @@ def get_agent_status( end_time=end_time, fail_on_incomplete=fail_on_incomplete, target_filter=target_filter, + incomplete_callback=incomplete_callback, ) for bk_host_id, status in agent_statuses.items(): if bk_host_id not in data: @@ -498,6 +500,7 @@ def get_performance_data( end_time: int = None, fail_on_incomplete: bool = False, target_filter: dict | None = None, + incomplete_callback=None, ): """ 获取指标信息 @@ -509,6 +512,7 @@ def get_performance_data( end_time=end_time, fail_on_incomplete=fail_on_incomplete, target_filter=target_filter, + incomplete_callback=incomplete_callback, ) for bk_host_id, metrics in result.items(): if bk_host_id not in data: @@ -525,6 +529,7 @@ def get_process_status( fail_on_incomplete: bool = False, filter_by_hosts: bool = False, target_filter: dict | None = None, + incomplete_callback=None, ): """ 获取进程信息 @@ -541,6 +546,7 @@ def get_process_status( fail_on_incomplete=fail_on_incomplete, filter_by_hosts=filter_by_hosts, target_filter=target_filter, + incomplete_callback=incomplete_callback, ) for bk_host_id in result: if bk_host_id not in data: @@ -589,18 +595,34 @@ def get_alarm_count( def perform_request(self, params): self.validate_scope_host_ids(params) bk_biz_id = params["bk_biz_id"] + is_page_query = params.get("query_mode") == self.PAGE_QUERY_MODE + defaults = { + "status": AGENT_STATUS.UNKNOWN, + "cpu_load": None, + "cpu_usage": None, + "disk_in_use": None, + "io_util": None, + "mem_usage": None, + "psc_mem_usage": None, + "component": [], + "alarm_count": [], + } data = { - bk_host_id: { - "status": AGENT_STATUS.UNKNOWN, - "cpu_load": None, - "cpu_usage": None, - "disk_in_use": None, - "io_util": None, - "mem_usage": None, - "psc_mem_usage": None, - "component": [], - "alarm_count": [], - } + bk_host_id: ( + {} + if is_page_query + else { + "status": AGENT_STATUS.UNKNOWN, + "cpu_load": None, + "cpu_usage": None, + "disk_in_use": None, + "io_util": None, + "mem_usage": None, + "psc_mem_usage": None, + "component": [], + "alarm_count": [], + } + ) for bk_host_id in params["bk_host_ids"] } @@ -608,12 +630,26 @@ def perform_request(self, params): pool = ThreadPool() task_args = (bk_biz_id, hosts, data, params.get("start_time"), params.get("end_time")) + section_states = {section: {"state": "RUNNING"} for section in SNAPSHOT_SECTIONS} + + def mark_partial(section): + section_states[section] = {"state": "PARTIAL"} + futures = { - "agent_status": pool.apply_async(self.get_agent_status, args=(*task_args, True)), - "performance_data": pool.apply_async(self.get_performance_data, args=(*task_args, True)), + "agent_status": pool.apply_async( + self.get_agent_status, + args=(*task_args, not is_page_query), + kwds={"incomplete_callback": (lambda: mark_partial("agent_status")) if is_page_query else None}, + ), + "performance_data": pool.apply_async( + self.get_performance_data, + args=(*task_args, not is_page_query), + kwds={"incomplete_callback": (lambda: mark_partial("performance_data")) if is_page_query else None}, + ), "process_status": pool.apply_async( self.get_process_status, - args=(*task_args, True, params.get("query_mode") == self.PAGE_QUERY_MODE), + args=(*task_args, not is_page_query, is_page_query), + kwds={"incomplete_callback": (lambda: mark_partial("process_status")) if is_page_query else None}, ), "alarm_count": pool.apply_async(self.get_alarm_count, args=task_args), } @@ -625,9 +661,36 @@ def perform_request(self, params): except Exception: logger.exception("get host metric section %s failed, bk_biz_id=%s", section, bk_biz_id) failed_sections.append(section) + section_states[section] = {"state": "FAILED"} + continue + if section_states[section]["state"] == "RUNNING": + section_states[section] = {"state": "READY"} + if is_page_query and section_states[section]["state"] == "READY": + section_defaults = { + "agent_status": {"status": defaults["status"]}, + "performance_data": { + key: defaults[key] + for key in defaults + if key.endswith("usage") or key in {"cpu_load", "disk_in_use", "io_util"} + }, + "process_status": {"component": []}, + "alarm_count": {"alarm_count": []}, + }[section] + for host_data in data.values(): + for field, value in section_defaults.items(): + host_data.setdefault(field, value) pool.join() - if failed_sections: + if failed_sections and not is_page_query: raise CustomException("get host metric data failed", data={"failed_sections": failed_sections}) + if is_page_query: + return { + "data": data, + "failed_sections": sorted(failed_sections), + "partial_sections": sorted( + section for section, state in section_states.items() if state["state"] == "PARTIAL" + ), + "sections": section_states, + } return data diff --git a/bkmonitor/packages/monitor_web/performance/snapshot.py b/bkmonitor/packages/monitor_web/performance/snapshot.py index 73cbd18bc2..e204d1dd76 100644 --- a/bkmonitor/packages/monitor_web/performance/snapshot.py +++ b/bkmonitor/packages/monitor_web/performance/snapshot.py @@ -80,6 +80,8 @@ class SnapshotState: RUNNING = "RUNNING" READY = "READY" + DEGRADED = "DEGRADED" + PARTIAL = "PARTIAL" FAILED = "FAILED" EXPIRED = "EXPIRED" UNAVAILABLE = "UNAVAILABLE" @@ -406,13 +408,15 @@ def write_section(self, snapshot_id: str, section: str, data: dict): encoded = zlib.compress(json.dumps(data, separators=(",", ":"), sort_keys=True).encode()) self._set(self.section_key(snapshot_id, section), encoded, SECTION_TTL) - def mark_section_ready(self, snapshot_id: str, section: str) -> dict | None: + def mark_section_ready(self, snapshot_id: str, section: str, *, state: str = SnapshotState.READY) -> dict | None: manifest = self.get_manifest(snapshot_id) if not manifest: return None revision = int(manifest.get("revision", 0)) + 1 sections = dict(manifest.get("sections", {})) - sections[section] = {"revision": revision, "state": SnapshotState.READY} + if state not in {SnapshotState.READY, SnapshotState.PARTIAL}: + raise ValueError("invalid available section state") + sections[section] = {"revision": revision, "state": state} return self.update_manifest(snapshot_id, revision=revision, sections=sections) def mark_ready(self, snapshot_id: str, *, expected_sections: set[str]) -> dict | None: @@ -439,6 +443,53 @@ def mark_ready(self, snapshot_id: str, *, expected_sections: set[str]) -> dict | self._touch_pointer(manifest, READY_TTL) return manifest + def mark_degraded( + self, + snapshot_id: str, + *, + failed_sections: list[str], + partial_sections: list[str] | None = None, + ) -> dict | None: + """结束不完整快照,同时保留已经成功发布的分区。""" + manifest = self.get_manifest(snapshot_id) + if not manifest: + return None + ready_sections = { + section + for section, section_state in manifest.get("sections", {}).items() + if section_state.get("state") in {SnapshotState.READY, SnapshotState.PARTIAL} + } + if not ready_sections: + self.fail(snapshot_id, "section_failed", failed_sections=sorted(set(failed_sections))) + return self.get_manifest(snapshot_id) + terminal_claim = self._claim_terminal(manifest) if manifest["state"] == SnapshotState.RUNNING else 0 + if manifest["state"] == SnapshotState.RUNNING and terminal_claim != 1: + if terminal_claim == -1: + return self._force_expired_if_running(snapshot_id) + return self.get_manifest(snapshot_id) + manifest = self.get_manifest(snapshot_id) + if not manifest or manifest["state"] not in { + SnapshotState.RUNNING, + SnapshotState.READY, + SnapshotState.DEGRADED, + }: + return manifest + sections = dict(manifest.get("sections", {})) + for section in failed_sections: + sections[section] = {"state": SnapshotState.FAILED} + manifest.update( + { + "error_code": "section_failed", + "failed_sections": sorted(set(manifest.get("failed_sections", [])) | set(failed_sections)), + "partial_sections": sorted(set(manifest.get("partial_sections", [])) | set(partial_sections or [])), + "sections": sections, + "state": SnapshotState.DEGRADED, + } + ) + self._set(self.manifest_key(snapshot_id), manifest, READY_TTL) + self._touch_pointer(manifest, READY_TTL) + return manifest + def read_section(self, snapshot_id: str, section: str) -> dict | None: encoded = self._get(self.section_key(snapshot_id, section)) if encoded is None: @@ -532,19 +583,21 @@ def build_response( response["data"] = {} response["expired"] = manifest["state"] == SnapshotState.EXPIRED response["failed_sections"] = manifest.get("failed_sections", []) + response["partial_sections"] = manifest.get("partial_sections", []) if manifest["state"] == SnapshotState.RUNNING: response["retry_after"] = 1 - elif manifest["state"] in {SnapshotState.FAILED, SnapshotState.EXPIRED}: + elif manifest["state"] in {SnapshotState.DEGRADED, SnapshotState.FAILED, SnapshotState.EXPIRED}: response["retry_after"] = 5 else: response["retry_after"] = 0 - if manifest["state"] not in {SnapshotState.RUNNING, SnapshotState.READY}: + if manifest["state"] not in {SnapshotState.RUNNING, SnapshotState.READY, SnapshotState.DEGRADED}: return response if not include_data: return response + broken_sections = [] for section, section_state in manifest.get("sections", {}).items(): - if section_state.get("state") != SnapshotState.READY: + if section_state.get("state") not in {SnapshotState.READY, SnapshotState.PARTIAL}: continue if int(section_state.get("revision", 0)) <= since_revision: continue @@ -553,22 +606,37 @@ def build_response( except SnapshotUnavailable: raise except Exception: - self.fail(snapshot_id, "section_corrupt", failed_sections=[section], allow_ready=True) + broken_sections.append(section) + continue + if data is None: + broken_sections.append(section) + continue + response["data"][section] = data + if broken_sections: + available_sections = { + section + for section, section_state in manifest.get("sections", {}).items() + if section_state.get("state") in {SnapshotState.READY, SnapshotState.PARTIAL} + } + if response["data"] or available_sections.difference(broken_sections): + manifest = self.mark_degraded(snapshot_id, failed_sections=broken_sections) response.update( - data={}, - failed_sections=[section], + failed_sections=manifest.get("failed_sections", broken_sections), retry_after=5, - state=SnapshotState.FAILED, + sections=manifest.get("sections", {}), + state=SnapshotState.DEGRADED, ) - return response - if data is None: - self.fail(snapshot_id, "section_missing", failed_sections=[section], allow_ready=True) + else: + error_code = "section_corrupt" + for section in broken_sections: + if self._get(self.section_key(snapshot_id, section)) is None: + error_code = "section_missing" + break + self.fail(snapshot_id, error_code, failed_sections=broken_sections, allow_ready=True) response.update( data={}, - failed_sections=[section], + failed_sections=broken_sections, retry_after=5, state=SnapshotState.FAILED, ) - return response - response["data"][section] = data return response diff --git a/bkmonitor/packages/monitor_web/performance/tasks.py b/bkmonitor/packages/monitor_web/performance/tasks.py index 1e92ef4a48..e59544ffe3 100644 --- a/bkmonitor/packages/monitor_web/performance/tasks.py +++ b/bkmonitor/packages/monitor_web/performance/tasks.py @@ -35,49 +35,48 @@ def _build_snapshot_section( if username is not None: set_local_username(username) host_ids = {host.bk_host_id for host in hosts} + is_partial = False + + def mark_partial(): + nonlocal is_partial + is_partial = True + target_filter = {} if scope["type"] == "business" else None if section == "agent_status": - data = {host_id: {"status": AGENT_STATUS.UNKNOWN} for host_id in host_ids} + data = {host_id: {} for host_id in host_ids} SearchHostMetricResource.get_agent_status( bk_biz_id, hosts, data, start_time, end_time, - fail_on_incomplete=True, + fail_on_incomplete=False, target_filter=target_filter, + incomplete_callback=mark_partial, ) elif section == "performance_data": - data = { - host_id: { - "cpu_load": None, - "cpu_usage": None, - "disk_in_use": None, - "io_util": None, - "mem_usage": None, - "psc_mem_usage": None, - } - for host_id in host_ids - } + data = {host_id: {} for host_id in host_ids} SearchHostMetricResource.get_performance_data( bk_biz_id, hosts, data, start_time, end_time, - fail_on_incomplete=True, + fail_on_incomplete=False, target_filter=target_filter, + incomplete_callback=mark_partial, ) elif section == "process_status": - data = {host_id: {"component": []} for host_id in host_ids} + data = {host_id: {} for host_id in host_ids} SearchHostMetricResource.get_process_status( bk_biz_id, hosts, data, start_time, end_time, - fail_on_incomplete=True, + fail_on_incomplete=False, target_filter=target_filter, + incomplete_callback=mark_partial, ) elif section == "alarm_count": data = {host_id: {"alarm_count": []} for host_id in host_ids} @@ -91,7 +90,24 @@ def _build_snapshot_section( ) else: raise ValueError(f"unknown host metric snapshot section: {section}") - return data + if not is_partial: + defaults = { + "agent_status": {"status": AGENT_STATUS.UNKNOWN}, + "performance_data": { + "cpu_load": None, + "cpu_usage": None, + "disk_in_use": None, + "io_util": None, + "mem_usage": None, + "psc_mem_usage": None, + }, + "process_status": {"component": []}, + "alarm_count": {"alarm_count": []}, + }[section] + for host_data in data.values(): + for field, value in defaults.items(): + host_data.setdefault(field, value) + return {"data": data, "state": SnapshotState.PARTIAL if is_partial else SnapshotState.READY} @shared_task(ignore_result=True, queue="celery_resource", soft_time_limit=55, time_limit=60) @@ -136,6 +152,7 @@ def build_host_metric_snapshot(snapshot_id: str): ) failed_sections = [] + partial_sections = [] with ThreadPoolExecutor(max_workers=len(SNAPSHOT_SECTIONS)) as executor: futures = { executor.submit( @@ -154,7 +171,7 @@ def build_host_metric_snapshot(snapshot_id: str): for future in as_completed(futures): section = futures.pop(future) try: - data = future.result() + section_result = future.result() except Exception: logger.exception( "build host metric snapshot section failed, bk_biz_id=%s, section=%s", @@ -163,6 +180,14 @@ def build_host_metric_snapshot(snapshot_id: str): ) failed_sections.append(section) continue + if isinstance(section_result, dict) and "data" in section_result and "state" in section_result: + data = section_result["data"] + section_state = section_result["state"] + else: + data = section_result + section_state = SnapshotState.READY + if section_state == SnapshotState.PARTIAL: + partial_sections.append(section) current = store.get_current(manifest["fingerprint"]) if not current or current["snapshot_id"] != snapshot_id: return @@ -173,10 +198,14 @@ def build_host_metric_snapshot(snapshot_id: str): store.expire(snapshot_id) return store.write_section(snapshot_id, section, data) - store.mark_section_ready(snapshot_id, section) + store.mark_section_ready(snapshot_id, section, state=section_state) - if failed_sections: - store.fail(snapshot_id, "section_failed", failed_sections=sorted(failed_sections)) + if failed_sections or partial_sections: + store.mark_degraded( + snapshot_id, + failed_sections=sorted(failed_sections), + partial_sections=sorted(partial_sections), + ) return store.mark_ready(snapshot_id, expected_sections=set(SNAPSHOT_SECTIONS)) except SnapshotUnavailable: diff --git a/bkmonitor/packages/monitor_web/tests/performance/test_host_metric_snapshot.py b/bkmonitor/packages/monitor_web/tests/performance/test_host_metric_snapshot.py index be6d9db8fc..58612a8db4 100644 --- a/bkmonitor/packages/monitor_web/tests/performance/test_host_metric_snapshot.py +++ b/bkmonitor/packages/monitor_web/tests/performance/test_host_metric_snapshot.py @@ -1236,7 +1236,7 @@ def test_snapshot_task_does_not_compute_after_capacity_lease_is_lost(mocker): build_section.assert_not_called() -def test_snapshot_task_fails_when_any_section_fails(mocker): +def test_snapshot_task_degrades_and_keeps_successful_sections_when_one_section_fails(mocker): tasks = import_module("monitor_web.performance.tasks") cache = FakeCache() store = snapshot.HostMetricSnapshotStore(cache=cache) @@ -1262,8 +1262,76 @@ def build(section, *_): tasks.build_host_metric_snapshot.run(manifest["snapshot_id"]) response = store.build_response(manifest["snapshot_id"], now=200) - assert response["state"] == snapshot.SnapshotState.FAILED + assert response["state"] == snapshot.SnapshotState.DEGRADED assert response["failed_sections"] == ["performance_data"] + assert set(response["data"]) == {"agent_status", "alarm_count", "process_status"} + assert response["data"]["agent_status"] == {1: {"section": "agent_status"}} + + +def test_snapshot_task_degrades_and_keeps_partial_section_records(mocker): + tasks = import_module("monitor_web.performance.tasks") + store = snapshot.HostMetricSnapshotStore(cache=FakeCache()) + manifest, _ = store.create_or_get( + "sha256:fingerprint", + { + **make_payload(snapshot.build_host_ids_hash([host.bk_host_id for host in HOSTS[:2]])), + "bk_tenant_id": "system", + "username": "admin", + }, + ) + mocker.patch.object(tasks, "HostMetricSnapshotStore", return_value=store) + mocker.patch.object(tasks, "resolve_host_metric_snapshot_scope", return_value=({"type": "business"}, HOSTS[:2])) + mocker.patch.object(tasks.time, "time", return_value=200) + + def build(section, *_): + if section == "performance_data": + return { + "data": {HOSTS[0].bk_host_id: {"cpu_usage": 81}}, + "state": snapshot.SnapshotState.PARTIAL, + } + return {HOSTS[0].bk_host_id: {"section": section}} + + mocker.patch.object(tasks, "_build_snapshot_section", side_effect=build) + + tasks.build_host_metric_snapshot.run(manifest["snapshot_id"]) + + response = store.build_response(manifest["snapshot_id"], now=200) + assert response["state"] == snapshot.SnapshotState.DEGRADED + assert response["failed_sections"] == [] + assert response["partial_sections"] == ["performance_data"] + assert response["data"]["performance_data"] == {HOSTS[0].bk_host_id: {"cpu_usage": 81}} + + +def test_missing_section_blob_degrades_only_that_section_and_keeps_other_data(monkeypatch): + monkeypatch.setattr(snapshot, "uuid4", lambda: type("UUID", (), {"hex": "a" * 32})()) + store = snapshot.HostMetricSnapshotStore(cache=FakeCache()) + manifest, _ = store.create_or_get("sha256:fingerprint", make_payload()) + store.write_section(manifest["snapshot_id"], "agent_status", {1: {"status": 0}}) + store.mark_section_ready(manifest["snapshot_id"], "agent_status") + store.mark_section_ready(manifest["snapshot_id"], "performance_data") + store.mark_ready(manifest["snapshot_id"], expected_sections={"agent_status", "performance_data"}) + + response = store.build_response(manifest["snapshot_id"]) + + assert response["state"] == snapshot.SnapshotState.DEGRADED + assert response["failed_sections"] == ["performance_data"] + assert response["data"] == {"agent_status": {1: {"status": 0}}} + + +def test_missing_new_section_keeps_already_consumed_section_usable(monkeypatch): + monkeypatch.setattr(snapshot, "uuid4", lambda: type("UUID", (), {"hex": "b" * 32})()) + store = snapshot.HostMetricSnapshotStore(cache=FakeCache()) + manifest, _ = store.create_or_get("sha256:fingerprint", make_payload()) + store.write_section(manifest["snapshot_id"], "agent_status", {1: {"status": 0}}) + store.mark_section_ready(manifest["snapshot_id"], "agent_status") + store.mark_section_ready(manifest["snapshot_id"], "performance_data") + store.mark_ready(manifest["snapshot_id"], expected_sections={"agent_status", "performance_data"}) + + response = store.build_response(manifest["snapshot_id"], since_revision=1) + + assert response["state"] == snapshot.SnapshotState.DEGRADED + assert response["failed_sections"] == ["performance_data"] + assert response["data"] == {} def test_snapshot_task_publishes_ready_empty_sections_for_legitimate_empty_scope(mocker): @@ -1324,9 +1392,13 @@ def test_snapshot_task_fails_when_scope_resolution_raises(mocker): def test_full_business_snapshot_section_uses_explicit_empty_target_filter(mocker): tasks = import_module("monitor_web.performance.tasks") - get_agent_status = mocker.patch.object(tasks.SearchHostMetricResource, "get_agent_status") + get_agent_status = mocker.patch.object( + tasks.SearchHostMetricResource, + "get_agent_status", + side_effect=lambda *_args, incomplete_callback, **_kwargs: incomplete_callback(), + ) - tasks._build_snapshot_section( + result = tasks._build_snapshot_section( "agent_status", 2, HOSTS[:1], @@ -1336,7 +1408,8 @@ def test_full_business_snapshot_section_uses_explicit_empty_target_filter(mocker ) assert get_agent_status.call_args.kwargs["target_filter"] == {} - assert get_agent_status.call_args.kwargs["fail_on_incomplete"] is True + assert get_agent_status.call_args.kwargs["fail_on_incomplete"] is False + assert result == {"data": {HOSTS[0].bk_host_id: {}}, "state": snapshot.SnapshotState.PARTIAL} def test_full_business_snapshot_alarm_section_omits_linear_host_ip_terms(mocker): diff --git a/bkmonitor/packages/monitor_web/tests/performance/test_search_host_metric.py b/bkmonitor/packages/monitor_web/tests/performance/test_search_host_metric.py index bfd40cbba3..604d4cc6a0 100644 --- a/bkmonitor/packages/monitor_web/tests/performance/test_search_host_metric.py +++ b/bkmonitor/packages/monitor_web/tests/performance/test_search_host_metric.py @@ -88,6 +88,46 @@ def test_search_host_metric_surfaces_thread_failure(mocker, failed_section): assert exc_info.value.data == {"failed_sections": [failed_section]} +def test_page_query_returns_usable_sections_when_other_sections_are_partial_or_failed(mocker): + host_id = HOSTS[0].bk_host_id + mocker.patch("monitor_web.performance.resources.api.cmdb.get_host_by_id", return_value=HOSTS[:1]) + + def agent_status(_bk_biz_id, _hosts, data, *_args, incomplete_callback=None, **_kwargs): + data[host_id]["status"] = 0 + incomplete_callback() + + def performance_data(_bk_biz_id, _hosts, data, *_args, **_kwargs): + data[host_id]["cpu_usage"] = 81 + + mocker.patch.object(SearchHostMetricResource, "get_agent_status", side_effect=agent_status) + mocker.patch.object(SearchHostMetricResource, "get_performance_data", side_effect=performance_data) + mocker.patch.object(SearchHostMetricResource, "get_process_status", side_effect=RuntimeError("process failed")) + mocker.patch.object(SearchHostMetricResource, "get_alarm_count") + + response = SearchHostMetricResource().perform_request( + {"bk_biz_id": 2, "bk_host_ids": [host_id], "query_mode": "page"} + ) + + assert response["data"][host_id] == { + "alarm_count": [], + "cpu_load": None, + "cpu_usage": 81, + "disk_in_use": None, + "io_util": None, + "mem_usage": None, + "psc_mem_usage": None, + "status": 0, + } + assert response["sections"] == { + "agent_status": {"state": "PARTIAL"}, + "performance_data": {"state": "READY"}, + "process_status": {"state": "FAILED"}, + "alarm_count": {"state": "READY"}, + } + assert response["failed_sections"] == ["process_status"] + assert response["partial_sections"] == ["agent_status"] + + def test_search_host_metric_does_not_degrade_alarm_failure_to_empty(mocker): mocker.patch("monitor_web.performance.resources.api.cmdb.get_host_by_id", return_value=HOSTS[:1]) mocker.patch.object(SearchHostMetricResource, "get_agent_status") diff --git a/bkmonitor/packages/monitor_web/tests/test_cc.py b/bkmonitor/packages/monitor_web/tests/test_cc.py index bed032b214..f002dc73cf 100644 --- a/bkmonitor/packages/monitor_web/tests/test_cc.py +++ b/bkmonitor/packages/monitor_web/tests/test_cc.py @@ -299,6 +299,28 @@ def test_recent_query_uses_current_end_and_queries_nodeman(self, mocker): assert query_data.call_args.kwargs == {"start_time": 820_000, "end_time": 1_000_000, "instant": True} node_man.assert_called_once() + def test_partial_query_keeps_confirmed_status_and_does_not_infer_missing_hosts(self, mocker): + unify_query = mocker.patch("monitor_web.cc.resources.cmdb.UnifyQuery") + unify_query.return_value.is_partial = True + unify_query.return_value.query_data.return_value = [ + { + "_result_": 1, + "bk_host_id": str(HOSTS[0].bk_host_id), + } + ] + node_man = mocker.patch("monitor_web.cc.resources.cmdb.api.node_man.ipchooser_host_detail") + incomplete_callback = mocker.Mock() + + result = resource.cc.get_agent_status( + bk_biz_id=2, + hosts=HOSTS[:2], + incomplete_callback=incomplete_callback, + ) + + assert result == {HOSTS[0].bk_host_id: AGENT_STATUS.ON} + incomplete_callback.assert_called_once_with() + node_man.assert_not_called() + @mock.patch( "core.drf_resource.api.gse.get_agent_status", return_value={ diff --git a/bkmonitor/webpack/src/trace/pages/host/components/host-list/host-list-table.tsx b/bkmonitor/webpack/src/trace/pages/host/components/host-list/host-list-table.tsx index 08ccca01b6..351e60a741 100644 --- a/bkmonitor/webpack/src/trace/pages/host/components/host-list/host-list-table.tsx +++ b/bkmonitor/webpack/src/trace/pages/host/components/host-list/host-list-table.tsx @@ -55,7 +55,6 @@ import { HOST_LIST_ELLIPSIS_CELL_CLASS, HOST_LIST_PAGE_SIZE_LIST, HOST_METRIC_HEADER_ICON_MAP, - HOST_PROGRESSIVE_METRIC_FIELD_IDS, HOST_STATUS_MAP, HOST_STATUS_TIPS_MAP, PROCESS_STATUS_TIPS_MAP, @@ -92,6 +91,11 @@ const HOST_METRIC_DATA_COLUMN_IDS = new Set([ 'cpu_load', 'display_name', ]); +const getMetricDataField = (columnId: string) => { + if (columnId === 'alarm_count') return 'alarm_count'; + if (columnId === 'display_name') return 'component'; + return columnId; +}; /** 指标进度条颜色阈值 */ const getProgressColor = (value: number) => { @@ -172,11 +176,6 @@ export default defineComponent({ type: Boolean, default: false, }, - /** 全量快照未就绪时禁用所有依赖指标的全局排序。 */ - metricSemanticsReady: { - type: Boolean, - default: true, - }, /** 置顶配置 */ markValue: { type: Object as PropType>, @@ -370,9 +369,6 @@ export default defineComponent({ if (!props.sort) return []; const descending = props.sort.startsWith('-'); const sortBy = descending ? props.sort.slice(1) : props.sort; - if (!props.metricSemanticsReady && HOST_PROGRESSIVE_METRIC_FIELD_IDS.has(sortBy)) { - return []; - } return [{ sortBy, descending }]; }); @@ -513,17 +509,14 @@ export default defineComponent({ onMouseenter={props.readonly ? undefined : e => hasAlarm && handleUnresolveEnter(row, e)} onMouseleave={props.readonly ? undefined : () => hasAlarm && handleUnresolveLeave()} > - {row.totalAlarmCount >= 0 ? row.totalAlarmCount : '--'} + {row.totalAlarmCount !== undefined && row.totalAlarmCount >= 0 ? row.totalAlarmCount : '--'} ); }; const renderMetricCell = (row: IHostListRow, key: string) => { - if (props.metricLoading) { - return
; - } - const value = Number(row[key as keyof IHostListRow] ?? 0); - if (!(value > 0)) { + const value = Number(row[key as keyof IHostListRow]); + if (!Number.isFinite(value) || value < 0) { return --; } return ( @@ -580,10 +573,7 @@ export default defineComponent({ const renderMetricHeader = (column: IHostColumnConfig) => { const iconClass = HOST_METRIC_HEADER_ICON_MAP[column.id]; return ( -
+
{iconClass && } {t(column.name)}
@@ -614,15 +604,7 @@ export default defineComponent({ /** 构建某一列的 tdesign 配置 */ const buildColumn = (config: IHostColumnConfig) => { - const metricSemanticsDisabled = !props.metricSemanticsReady && HOST_PROGRESSIVE_METRIC_FIELD_IDS.has(config.id); - let title = () => ( - - {t(config.name)} - - ); + let title = () => {t(config.name)}; if (config.type === 'checkbox') { title = () => renderCheckboxHeader(); } else if (config.type === 'metric') { @@ -633,18 +615,20 @@ export default defineComponent({ title, minWidth: config.minWidth, width: config.width, - sorter: config.sortable && !metricSemanticsDisabled, + sorter: config.sortable, ellipsis: false, fixed: config.fixed, }; base.cell = (_: unknown, { row }: { row: IHostListRow }) => { if (HOST_METRIC_DATA_COLUMN_IDS.has(config.id)) { - if (props.metricLoading) { + const hasMetricData = Object.hasOwn(row, getMetricDataField(config.id)); + if (!hasMetricData && props.metricLoading) { return
; } - if (props.metricLoadError) { + if (!hasMetricData && props.metricLoadError) { return {t('加载失败')}; } + if (!hasMetricData) return '--'; } switch (config.type) { case 'ip': @@ -682,9 +666,6 @@ export default defineComponent({ const handleSortChange = (sortEvent: TableSort) => { const target = Array.isArray(sortEvent) ? sortEvent[0] : sortEvent; - if (!props.metricSemanticsReady && target?.sortBy && HOST_PROGRESSIVE_METRIC_FIELD_IDS.has(target.sortBy)) { - return; - } emit('sortChange', target?.sortBy ? `${target.descending ? '-' : ''}${target.sortBy}` : ''); }; @@ -695,7 +676,7 @@ export default defineComponent({ > {props.metricLoadError && (
- {t('指标数据加载失败,当前仅展示主机基础信息')} + {t('部分指标数据加载失败,已获取数据仍可使用')}
- {!ctx.metricSemanticsReady.value && ( + {ctx.metricProgressiveState.value !== 'READY' && (
- {['EXPIRED', 'FAILED', 'UNAVAILABLE'].includes(ctx.metricProgressiveState.value) - ? window.i18n.t('全量指标暂不可用,当前按页加载指标') - : window.i18n.t('全量指标准备中,当前按页加载指标')} + {ctx.metricProgressiveState.value === 'DEGRADED' + ? window.i18n.t('部分指标未获取,当前数据仍可排序、筛选和统计') + : ['EXPIRED', 'FAILED', 'UNAVAILABLE'].includes(ctx.metricProgressiveState.value) + ? window.i18n.t('全量指标暂不可用,继续使用已获取数据并按页补充') + : window.i18n.t('全量指标准备中,已获取数据可立即使用')} - {['EXPIRED', 'FAILED', 'UNAVAILABLE'].includes(ctx.metricProgressiveState.value) && ( + {['DEGRADED', 'EXPIRED', 'FAILED', 'UNAVAILABLE'].includes(ctx.metricProgressiveState.value) && (