From 0c3d39d009fdfedc508d752ef1fd7af8fbe2a700 Mon Sep 17 00:00:00 2001 From: hardy Date: Mon, 27 Apr 2026 17:03:42 +0800 Subject: [PATCH 001/426] fix: panic elasticserch client is nil --- Makefile | 2 +- common/info.go | 55 + common/info_test.go | 37 + common/probe.go | 199 +++ common/probe_test.go | 68 + config/setup/common/alerting.tpl | 8 +- config/setup/easysearch/template_ilm.tpl | 24 +- .../setup/easysearch/template_ilm_1_12_1.tpl | 24 +- config/setup/easysearch/template_rollup.tpl | 17 +- config/setup/elasticsearch/template_ilm.tpl | 24 +- .../setup/elasticsearch/v5/template_ilm.tpl | 24 +- .../setup/elasticsearch/v6/template_ilm.tpl | 24 +- config/setup/opensearch/template_ilm.tpl | 24 +- config/system_config.tpl | 2 +- console.yml | 8 +- core/auth.go | 77 ++ core/auth_test.go | 78 ++ core/replay.go | 161 +++ core/replay_test.go | 79 ++ core/security/access_token.go | 13 +- core/security/password_challenge.go | 154 +++ core/security/password_challenge_test.go | 34 + core/security/user.go | 16 +- core/security/validate.go | 55 +- core/security/validate_test.go | 163 +++ info_api.go | 52 + modules/agent/api/elasticsearch.go | 4 +- modules/elastic/api/cluster_overview.go | 2 +- modules/elastic/api/init.go | 4 +- modules/elastic/api/manage.go | 37 +- modules/elastic/api/test_connection.go | 221 ++- modules/elastic/api/test_connection_test.go | 98 ++ modules/elastic/api/v1/cluster_overview.go | 2 +- modules/elastic/api/v1/manage.go | 26 +- modules/security/api/account.go | 136 +- modules/security/api/init.go | 10 +- modules/security/api/replay.go | 55 + modules/security/api/user.go | 17 +- modules/security/credential/api/init.go | 4 +- .../security/realm/authc/native/load_test.go | 30 + .../realm/authc/native/permission.json | 5 +- modules/security/realm/authc/native/role.go | 2 +- modules/security/realm/authc/native/user.go | 2 +- plugin/api/alerting/channel.go | 54 + plugin/api/alerting/channel_test.go | 59 + plugin/api/email/api.go | 6 +- plugin/api/email/common/pipeline.go | 2 +- plugin/api/email/common/pipeline_test.go | 54 + plugin/api/email/processor.go | 400 ++++++ plugin/api/email/processor_test.go | 115 ++ plugin/api/email/server.go | 11 +- plugin/api/email/server_test.go | 13 + plugin/api/index_management/elasticsearch.go | 2 +- plugin/api/init.go | 2 + plugin/api/insight/api.go | 27 +- plugin/api/settings/api.go | 1218 +++++++++++++++++ plugin/api/settings/api_test.go | 483 +++++++ plugin/managed/server/instance.go | 30 +- plugin/setup/setup.go | 91 +- plugin/setup/setup_test.go | 74 + service/alerting/action/email.go | 3 + service/alerting/common/helper.go | 76 + service/alerting/common/helper_test.go | 73 + ui.go | 29 +- web/.gitignore | 2 + web/config/config.js | 263 +--- web/config/router.config.js | 41 +- web/docs/test-cases.md | 328 +++++ web/mock/api.js | 16 - web/mock/rbac/admin.js | 8 +- web/package.json | 12 +- web/scripts/ensure-cnpm.js | 18 + web/scripts/run-umi.js | 120 ++ web/src/app.js | 201 ++- web/src/components/Anchor/index.js | 119 +- web/src/components/Anchor/index.scss | 27 +- web/src/components/ClusterSelect/index.jsx | 3 + web/src/components/CollectStatus/index.js | 15 +- .../components/GlobalHeader/RightContent.js | 50 +- web/src/components/HealthProvider/index.js | 40 +- web/src/components/Icons/Dingding.jsx | 2 +- web/src/components/Icons/DirectionArrow.jsx | 2 +- web/src/components/Icons/ExternalLink.jsx | 2 +- web/src/components/Icons/HornVibration.jsx | 2 +- web/src/components/Icons/LightningToggle.jsx | 2 +- web/src/components/Icons/LowerVolume.jsx | 2 +- web/src/components/Icons/Mute.jsx | 2 +- web/src/components/Icons/NoPermission.jsx | 2 +- web/src/components/Icons/Notification.jsx | 2 +- web/src/components/Icons/Slack.jsx | 4 +- web/src/components/Licence/index.js | 2 +- .../ListView/components/Search/index.jsx | 3 +- .../ListView/components/Side/index.jsx | 23 +- web/src/components/ListView/index.jsx | 91 +- web/src/components/ListView/index.less | 13 +- web/src/components/Login/map.js | 15 +- web/src/components/Markdown/index.jsx | 64 +- .../components/Overview/Detail/Content.tsx | 6 +- .../Overview/Detail/Metrics/MetricIndices.jsx | 8 +- .../Overview/Detail/Metrics/MetricNodes.jsx | 8 +- .../Overview/Detail/Metrics/index.js | 9 +- web/src/components/Overview/Header/index.tsx | 7 +- web/src/components/Overview/Monitor/index.jsx | 14 +- .../components/Overview/Monitor/index.less | 10 +- web/src/components/Overview/Side/index.scss | 13 +- web/src/components/Overview/Side/index.tsx | 20 +- web/src/components/Overview/index.scss | 58 +- web/src/components/Overview/index.tsx | 255 ++-- web/src/components/RollupStats/index.js | 8 +- web/src/components/StandardTable/index.js | 2 +- .../quick_select_popover.tsx | 2 +- .../components/infini/SearchInput/index.jsx | 58 + web/src/components/infini/TagEditor.jsx | 2 +- .../infini/search/FilterSearchGroup.jsx | 8 +- web/src/components/monaco-editor/index.jsx | 5 +- .../console/components/CommonCommandModal.tsx | 2 +- .../console/components/ConsoleInput.tsx | 42 +- .../vendor/console/components/ConsoleMenu.tsx | 105 +- .../console/components/ConsoleOutput.tsx | 2 +- .../components/apply_editor_settings.ts | 9 + .../console/entities/console_menu_actions.ts | 4 +- .../vendor/console/modules/es/index.ts | 2 +- .../vendor/console/services/settings.ts | 2 +- .../saved_objects/saved_objects_client.ts | 2 +- .../filter_editor/lib/filter_label.tsx | 16 +- .../query_string_input/query_bar_top_row.tsx | 23 +- .../saved_query_list_item.tsx | 2 +- .../saved_query_management_component.tsx | 8 +- .../angular/directives/no_results.js | 12 +- .../components/discover_table/table.tsx | 2 +- .../create_button/create_button.tsx | 2 +- .../edit_index_pattern/edit_index_pattern.tsx | 4 +- .../index_header/index_header.tsx | 8 +- .../edit_index_pattern/tabs/tabs.tsx | 8 +- .../field_editor/complex_field_editor.tsx | 2 +- .../editors/color/color.tsx | 2 +- .../field_format_editor/editors/date/date.tsx | 2 +- .../editors/date_nanos/date_nanos.tsx | 2 +- .../editors/duration/duration.tsx | 2 +- .../editors/number/number.tsx | 2 +- .../editors/static_lookup/static_lookup.tsx | 2 +- .../editors/string/string.tsx | 2 +- .../editors/truncate/truncate.tsx | 2 +- .../field_format_editor/editors/url/url.tsx | 4 +- .../scripting_call_outs/disabled_call_out.tsx | 2 +- .../scripting_call_outs/warning_call_out.tsx | 2 +- .../scripting_help/scripting_syntax.tsx | 2 +- .../components/scripting_help/test_script.tsx | 6 +- .../components/field_editor/field_editor.tsx | 8 +- .../index_pattern_table.tsx | 5 +- web/src/layouts/BasicLayout.js | 8 +- web/src/layouts/Footer.js | 2 +- web/src/layouts/Header.js | 58 +- web/src/layouts/UserLayout.js | 2 +- web/src/locales/en-US.js | 101 +- web/src/locales/en-US/agent.js | 14 + web/src/locales/en-US/alert.js | 7 + web/src/locales/en-US/cluster.js | 26 + web/src/locales/en-US/command.js | 2 +- web/src/locales/en-US/error.js | 3 + web/src/locales/en-US/explore.js | 2 + web/src/locales/en-US/gateway.js | 32 +- web/src/locales/en-US/guide.js | 31 +- web/src/locales/en-US/indices.js | 13 + web/src/locales/en-US/listview.js | 1 + web/src/locales/en-US/migration.js | 156 +++ web/src/locales/en-US/overview.js | 58 + web/src/locales/en-US/settings.js | 45 +- web/src/locales/zh-CN.js | 93 ++ web/src/locales/zh-CN/agent.js | 11 + web/src/locales/zh-CN/alert.js | 7 + web/src/locales/zh-CN/cluster.js | 42 +- web/src/locales/zh-CN/command.js | 2 +- web/src/locales/zh-CN/error.js | 3 + web/src/locales/zh-CN/explore.js | 2 + web/src/locales/zh-CN/gateway.js | 32 +- web/src/locales/zh-CN/guide.js | 30 +- web/src/locales/zh-CN/indices.js | 9 + web/src/locales/zh-CN/listview.js | 1 + web/src/locales/zh-CN/migration.js | 154 +++ web/src/locales/zh-CN/overview.js | 58 + web/src/locales/zh-CN/settings.js | 40 +- web/src/models/global.js | 219 +-- web/src/models/login.js | 94 +- web/src/pages/Account/Settings/BaseView.js | 2 +- web/src/pages/Account/Settings/BindingView.js | 2 +- .../Account/Settings/NotificationView.js | 2 +- web/src/pages/Account/Settings/PhoneView.js | 2 +- .../pages/Account/Settings/SecurityView.js | 4 +- .../pages/Agent/Instance/Step/extra_step.jsx | 26 +- .../Agent/Instance/Step/initial_step.jsx | 44 +- .../pages/Agent/Instance/Step/result_step.jsx | 20 +- .../Agent/Instance/components/RowDetail.jsx | 126 +- .../Agent/Instance/components/RowDetail.less | 37 + .../components/SetAgentCredential.jsx | 3 + .../Instance/components/UnknownProcess.jsx | 35 +- web/src/pages/Agent/Instance/form.jsx | 42 +- web/src/pages/Agent/Instance/index.jsx | 51 +- web/src/pages/Agent/Instance/new.jsx | 46 +- web/src/pages/Alerting/Channel/FormEmail.jsx | 2 +- web/src/pages/Alerting/Channel/Index.jsx | 78 +- web/src/pages/Alerting/Message/Index.jsx | 12 +- .../Message/components/AlertChartCard.jsx | 15 +- .../Message/components/EventDetail.jsx | 19 +- .../Message/components/MessageDetail.jsx | 13 +- .../Message/components/NotificationCard.jsx | 14 +- web/src/pages/Alerting/Rule/Index.jsx | 22 +- .../Rule/components/NotificationCard.jsx | 14 +- .../Alerting/Rule/components/RuleDetail.jsx | 47 +- web/src/pages/Backup/BakAndRestore.js | 10 +- web/src/pages/Backup/BakCycle.js | 4 +- web/src/pages/Cluster/Activities.jsx | 2 +- web/src/pages/Cluster/NewOverview.js | 19 +- web/src/pages/Cluster/Settings/Repository.js | 6 +- web/src/pages/Cluster/components/clusters.js | 4 +- .../Cluster/components/detail/metric_topn.jsx | 3 +- .../Cluster/components/host/detail/metrics.js | 5 +- .../pages/Cluster/components/host/hosts.jsx | 4 +- .../Cluster/components/index/indices.jsx | 4 +- .../components/index/overview/shards.jsx | 4 +- .../pages/Cluster/components/node/nodes.jsx | 4 +- .../components/node/overview/shards.jsx | 4 +- .../node/overview/statistic_bar.jsx | 37 +- web/src/pages/DataManagement/Alias.js | 9 +- web/src/pages/DataManagement/Discover.jsx | 217 ++- web/src/pages/DataManagement/Document.js | 23 +- web/src/pages/DataManagement/Index.js | 47 +- .../pages/DataManagement/IndexLifeCycle.js | 4 +- .../pages/DataManagement/IndexPatterns.jsx | 8 +- web/src/pages/DataManagement/IndexSummary.js | 2 +- web/src/pages/DataManagement/IndexTemplate.js | 4 +- .../Insight/InsightBar/index.less | 7 +- .../Insight/InsightBar/index.tsx | 173 ++- .../Insight/InsightConfig/SearchConfig.tsx | 31 +- .../Insight/InsightConfig/index.tsx | 11 +- .../Insight/SearchInfo/index.tsx | 82 +- .../pages/DataManagement/SearchFlow/Form.jsx | 19 +- .../DataManagement/SearchFlow/SearchFlow.jsx | 33 +- .../DataManagement/SearchFlow/TraceSearch.jsx | 17 +- .../pages/DataManagement/View/LayoutList.jsx | 9 +- .../WidgetConfig/WidgetConfigDrawer.jsx | 2 +- .../DataManagement/View/WidgetLoader.jsx | 44 +- .../components/DeleteIndexModal.jsx | 52 +- web/src/pages/DevTool/Console.tsx | 32 +- web/src/pages/Endpoints/Execute.js | 4 +- web/src/pages/Forms/StepForm/Step1.js | 2 +- web/src/pages/Forms/StepForm/Step3.js | 2 +- web/src/pages/Forms/StepForm/index.js | 2 +- web/src/pages/Forms/TableForm.js | 2 +- web/src/pages/Gateway/Entry/index.jsx | 4 +- web/src/pages/Gateway/Flow/index.jsx | 4 +- .../Gateway/Instance/Step/extra_step.jsx | 26 +- .../Gateway/Instance/Step/initial_step.jsx | 40 +- .../Gateway/Instance/Step/result_step.jsx | 12 +- web/src/pages/Gateway/Instance/form.jsx | 39 +- web/src/pages/Gateway/Instance/index.jsx | 233 +++- web/src/pages/Gateway/Instance/new.jsx | 39 +- web/src/pages/Gateway/Queue/Persistent.jsx | 4 +- web/src/pages/Gateway/Queue/Transient.jsx | 4 +- web/src/pages/Gateway/Router/index.jsx | 41 +- .../components/Configuration/index.js | 54 +- .../components/Initialization/index.js | 225 ++- web/src/pages/List/Articles.js | 2 +- web/src/pages/List/TableList.js | 2 +- .../Overview/components/Activities/index.less | 20 +- .../Overview/components/Message/index.jsx | 2 +- .../Overview/components/Product/index.less | 11 +- .../components/Quick/icons/ClusterIcon.jsx | 18 +- .../components/Quick/icons/DevToolIcon.jsx | 36 +- .../components/Quick/icons/DiscoverIcon.jsx | 34 +- .../components/Quick/icons/MessageIcon.jsx | 42 +- .../components/Quick/icons/MigrationIcon.jsx | 20 +- .../components/Quick/icons/MonitorIcon.jsx | 16 +- .../components/Quick/icons/SecurityIcon.jsx | 18 +- .../pages/Overview/components/Quick/index.jsx | 117 +- .../Overview/components/Quick/index.less | 5 +- .../Overview/components/Status/index.jsx | 8 +- web/src/pages/Overview/index.jsx | 38 +- web/src/pages/Overview/index.less | 21 +- web/src/pages/Platform/Notification/index.tsx | 4 +- .../Platform/Overview/Cluster/Detail/Infos.js | 3 +- .../Overview/Cluster/Detail/MetricTopN.jsx | 3 +- .../Overview/Cluster/Detail/Metrics.js | 29 +- .../Overview/Cluster/Monitor/indices.jsx | 22 +- .../Overview/Cluster/Monitor/nodes.jsx | 26 +- .../Platform/Overview/Cluster/Table/index.jsx | 18 +- .../pages/Platform/Overview/Cluster/index.tsx | 21 +- .../Platform/Overview/Host/Detail/Metrics.js | 24 +- .../Overview/Host/Monitor/process.jsx | 14 +- .../Platform/Overview/Host/Table/index.jsx | 15 +- .../pages/Platform/Overview/Host/index.tsx | 21 +- .../Platform/Overview/Indices/Detail/Infos.js | 3 +- .../Overview/Indices/Detail/Metrics.js | 14 +- .../Indices/Monitor/shard_statistic_bar.jsx | 15 +- .../Overview/Indices/Monitor/shards.jsx | 22 +- .../Indices/Monitor/statistic_bar.jsx | 40 +- .../Platform/Overview/Indices/Table/index.jsx | 12 +- .../pages/Platform/Overview/Indices/index.tsx | 14 +- .../Platform/Overview/Node/Detail/Infos.js | 3 +- .../Platform/Overview/Node/Detail/Logs.js | 1 - .../Platform/Overview/Node/Detail/Metrics.js | 2 +- .../Platform/Overview/Node/Monitor/shards.jsx | 20 +- .../Overview/Node/Monitor/statistic_bar.jsx | 37 +- .../Platform/Overview/Node/Table/index.jsx | 15 +- .../pages/Platform/Overview/Node/index.tsx | 21 +- .../Overview/components/CommonTable/index.jsx | 1 + .../Overview/components/Logs/index.jsx | 4 +- .../Overview/components/MetricChart.jsx | 45 +- .../Platform/Overview/components/Metrics.scss | 67 +- .../Overview/components/TopN/index.jsx | 18 +- .../Overview/components/TopN/index.less | 30 +- web/src/pages/Platform/Overview/index.js | 10 +- web/src/pages/Profile/AdvancedProfile.js | 6 +- web/src/pages/Result/Error.js | 2 +- web/src/pages/Result/Success.js | 4 +- .../pages/SearchManage/alias/AliasManage.js | 4 +- web/src/pages/SearchManage/alias/Param.js | 4 +- web/src/pages/SearchManage/alias/Rule.js | 4 +- .../SearchManage/analyzer/AnalyzerTest.js | 2 +- web/src/pages/SearchManage/analyzer/Manage.js | 4 +- web/src/pages/SearchManage/dict/Common.js | 20 +- web/src/pages/SearchManage/nlp/Intention.js | 4 +- web/src/pages/SearchManage/nlp/Knowledge.js | 4 +- web/src/pages/SearchManage/nlp/Query.js | 4 +- web/src/pages/SearchManage/nlp/Text.js | 4 +- .../pages/SearchManage/template/History.js | 4 +- web/src/pages/SearchManage/template/Param.js | 4 +- .../SearchManage/template/SearchTemplate.js | 4 +- .../pages/SearchManage/template/Summary.js | 22 +- web/src/pages/Synchronize/IngestPipeline.js | 4 +- web/src/pages/Synchronize/LogstashConfig.js | 2 +- web/src/pages/System/Audit/index.jsx | 2 +- web/src/pages/System/Cluster/CollectMode.jsx | 26 +- web/src/pages/System/Cluster/Form.js | 71 +- web/src/pages/System/Cluster/Step.js | 33 +- web/src/pages/System/Cluster/index.jsx | 297 ++-- .../pages/System/Cluster/models/cluster.js | 4 +- .../pages/System/Cluster/steps/extra_step.js | 11 +- .../System/Cluster/steps/initial_step.js | 57 +- .../pages/System/Cluster/steps/result_step.js | 2 +- web/src/pages/System/Cluster/utils.js | 138 ++ web/src/pages/System/Command/Index.js | 16 +- .../System/Credential/CredentialForm.jsx | 118 +- web/src/pages/System/Credential/Index.js | 10 +- .../Email/Components/CredentialForm.jsx | 16 +- .../System/Email/Components/ServerConfig.jsx | 90 +- web/src/pages/System/Email/Server.jsx | 37 +- .../System/Role/Data/api_privilege_field.jsx | 11 +- web/src/pages/System/Role/Data/form.jsx | 198 ++- .../Role/Data/index_privilege_field.jsx | 11 +- web/src/pages/System/Role/Data/new.jsx | 17 +- .../System/Role/Platform/api_permission.jsx | 25 +- web/src/pages/System/Role/Platform/form.jsx | 147 +- web/src/pages/System/Role/Platform/menu.js | 26 +- web/src/pages/System/Role/Platform/new.jsx | 17 +- .../pages/System/Role/Platform/permission.jsx | 30 +- web/src/pages/System/Role/index.jsx | 39 +- web/src/pages/System/Security/index.jsx | 10 +- web/src/pages/System/Settings/index.jsx | 352 +++++ web/src/pages/System/User/form.jsx | 61 +- web/src/pages/System/User/index.jsx | 40 +- web/src/pages/System/User/new.jsx | 1 + web/src/pages/System/User/resetPassword.jsx | 12 +- web/src/pages/User/Login.js | 24 + web/src/services/api.js | 24 + web/src/utils/authority.js | 56 +- web/src/utils/password.js | 68 + web/src/utils/request.js | 252 +++- web/src/utils/treemap_title.js | 23 + 369 files changed, 11916 insertions(+), 2465 deletions(-) create mode 100644 common/info.go create mode 100644 common/info_test.go create mode 100644 common/probe.go create mode 100644 common/probe_test.go create mode 100644 core/auth_test.go create mode 100644 core/replay.go create mode 100644 core/replay_test.go create mode 100644 core/security/password_challenge.go create mode 100644 core/security/password_challenge_test.go create mode 100644 core/security/validate_test.go create mode 100644 info_api.go create mode 100644 modules/elastic/api/test_connection_test.go create mode 100644 modules/security/api/replay.go create mode 100644 modules/security/realm/authc/native/load_test.go create mode 100644 plugin/api/alerting/channel_test.go create mode 100644 plugin/api/email/common/pipeline_test.go create mode 100644 plugin/api/email/processor.go create mode 100644 plugin/api/email/processor_test.go create mode 100644 plugin/api/settings/api.go create mode 100644 plugin/api/settings/api_test.go create mode 100644 plugin/setup/setup_test.go create mode 100644 service/alerting/common/helper_test.go create mode 100644 web/docs/test-cases.md create mode 100644 web/scripts/ensure-cnpm.js create mode 100644 web/scripts/run-umi.js create mode 100644 web/src/components/infini/SearchInput/index.jsx create mode 100644 web/src/locales/en-US/migration.js create mode 100644 web/src/locales/zh-CN/migration.js create mode 100644 web/src/pages/Agent/Instance/components/RowDetail.less create mode 100644 web/src/pages/System/Settings/index.jsx create mode 100644 web/src/utils/password.js create mode 100644 web/src/utils/treemap_title.js diff --git a/Makefile b/Makefile index 04522ac4..9db0809f 100755 --- a/Makefile +++ b/Makefile @@ -4,7 +4,7 @@ SHELL=/bin/bash APP_NAME := console APP_VERSION := 1.0.0_SNAPSHOT APP_CONFIG := $(APP_NAME).yml -APP_EOLDate ?= "2026-12-31T10:10:10Z" +APP_EOLDate ?= "2027-12-31T10:10:10Z" APP_STATIC_FOLDER := .public APP_STATIC_PACKAGE := public APP_UI_FOLDER := ui diff --git a/common/info.go b/common/info.go new file mode 100644 index 00000000..197671e6 --- /dev/null +++ b/common/info.go @@ -0,0 +1,55 @@ +// Copyright (C) INFINI Labs & INFINI LIMITED. +// +// The INFINI Console is offered under the GNU Affero General Public License v3.0 +// and as commercial software. +// +// For commercial licensing, contact us at: +// - Website: infinilabs.com +// - Email: hello@infini.ltd +// +// Open Source licensed under AGPL V3: +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package common + +import "infini.sh/framework/core/util" + +var allowedInstanceInfoKeys = []string{ + "id", + "name", + "application", + "labels", + "tags", + "description", + "status", +} + +func SanitizeInstanceInfoMap(payload util.MapStr) util.MapStr { + sanitized := util.MapStr{} + for _, key := range allowedInstanceInfoKeys { + if value, ok := payload[key]; ok { + sanitized[key] = value + } + } + return sanitized +} + +func SanitizeInstanceInfoBytes(body []byte) ([]byte, error) { + payload := util.MapStr{} + err := util.FromJSONBytes(body, &payload) + if err != nil { + return nil, err + } + return util.MustToJSONBytes(SanitizeInstanceInfoMap(payload)), nil +} diff --git a/common/info_test.go b/common/info_test.go new file mode 100644 index 00000000..7a3afafa --- /dev/null +++ b/common/info_test.go @@ -0,0 +1,37 @@ +package common + +import ( + "testing" + + "infini.sh/framework/core/util" +) + +func TestSanitizeInstanceInfoMap(t *testing.T) { + payload := util.MapStr{ + "id": "node-1", + "name": "console-a", + "application": util.MapStr{"name": "console"}, + "endpoint": "http://127.0.0.1:2900", + "host": util.MapStr{"name": "host-a"}, + "network": util.MapStr{"ip": []string{"10.0.0.1"}}, + "basic_auth": util.MapStr{"username": "admin"}, + } + + sanitized := SanitizeInstanceInfoMap(payload) + + if sanitized["id"] != "node-1" { + t.Fatalf("expected id to be preserved, got %v", sanitized["id"]) + } + if _, ok := sanitized["endpoint"]; ok { + t.Fatalf("expected endpoint to be removed") + } + if _, ok := sanitized["host"]; ok { + t.Fatalf("expected host to be removed") + } + if _, ok := sanitized["network"]; ok { + t.Fatalf("expected network to be removed") + } + if _, ok := sanitized["basic_auth"]; ok { + t.Fatalf("expected basic_auth to be removed") + } +} diff --git a/common/probe.go b/common/probe.go new file mode 100644 index 00000000..3e1e4064 --- /dev/null +++ b/common/probe.go @@ -0,0 +1,199 @@ +// Copyright (C) INFINI Labs & INFINI LIMITED. +// +// The INFINI Console is offered under the GNU Affero General Public License v3.0 +// and as commercial software. +// +// For commercial licensing, contact us at: +// - Website: infinilabs.com +// - Email: hello@infini.ltd +// +// Open Source licensed under AGPL V3: +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package common + +import ( + "context" + "fmt" + "net/http" + "net/url" + "strings" + "time" + + "github.com/segmentio/encoding/json" + "infini.sh/framework/core/elastic" + "infini.sh/framework/core/errors" + "infini.sh/framework/core/util" + "infini.sh/framework/modules/elastic/adapter" +) + +const ClusterProbePathLabel = "console_probe_path" + +func NormalizeProbePath(path string) string { + path = strings.TrimSpace(path) + if path == "" || path == "/" { + return "" + } + if !strings.HasPrefix(path, "/") { + path = "/" + path + } + return path +} + +func GetProbePath(config *elastic.ElasticsearchConfig) string { + if config == nil || config.Labels == nil { + return "" + } + return NormalizeProbePath(util.ToString(config.Labels[ClusterProbePathLabel])) +} + +func SetProbePath(config *elastic.ElasticsearchConfig, path string) { + if config == nil { + return + } + path = NormalizeProbePath(path) + if config.Labels == nil { + config.Labels = util.MapStr{} + } + if path == "" { + delete(config.Labels, ClusterProbePathLabel) + return + } + config.Labels[ClusterProbePathLabel] = path +} + +func BuildEndpointWithPath(endpoint, path string) string { + path = NormalizeProbePath(path) + if path == "" { + return endpoint + } + + baseURL, err := url.Parse(endpoint) + if err != nil { + return strings.TrimRight(endpoint, "/") + path + } + + refURL, err := url.Parse(path) + if err != nil { + return strings.TrimRight(endpoint, "/") + path + } + + return baseURL.ResolveReference(refURL).String() +} + +func ClusterVersion(metadata *elastic.ElasticsearchMetadata) (*elastic.ClusterInformation, error) { + if metadata == nil || metadata.Config == nil { + return nil, errors.New("elasticsearch metadata is nil") + } + + probePath := GetProbePath(metadata.Config) + if probePath == "" { + return adapter.ClusterVersion(metadata) + } + + if metadata.Config.RequestTimeout <= 0 { + metadata.Config.RequestTimeout = 5 + } + + endpoint := fmt.Sprintf("%v://%v", metadata.GetSchema(), metadata.GetActiveHost()) + if err := probeClusterEndpoint(metadata.Config, endpoint, probePath); err != nil { + return nil, err + } + return loadClusterInformationWithoutRoot(metadata.Config, endpoint) +} + +func ClusterVersionWithConfig(config *elastic.ElasticsearchConfig) (*elastic.ClusterInformation, error) { + if config == nil { + return nil, errors.New("elasticsearch config is nil") + } + return ClusterVersion(&elastic.ElasticsearchMetadata{Config: config}) +} + +func probeClusterEndpoint(config *elastic.ElasticsearchConfig, endpoint, probePath string) error { + res, err := executeRequest(config, BuildEndpointWithPath(endpoint, probePath)) + if err != nil { + return err + } + if res.StatusCode != http.StatusOK { + return errors.New(string(res.Body)) + } + return nil +} + +func loadClusterInformationWithoutRoot(config *elastic.ElasticsearchConfig, endpoint string) (*elastic.ClusterInformation, error) { + stats := &elastic.ClusterStats{} + res, err := executeRequest(config, BuildEndpointWithPath(endpoint, "/_cluster/stats")) + if err != nil { + return nil, err + } + if res.StatusCode != http.StatusOK { + return nil, errors.New(string(res.Body)) + } + if err = json.Unmarshal(res.Body, stats); err != nil { + return nil, err + } + + nodes := &elastic.NodesResponse{} + res, err = executeRequest(config, BuildEndpointWithPath(endpoint, "/_nodes/_all/http")) + if err != nil { + return nil, err + } + if res.StatusCode != http.StatusOK { + return nil, errors.New(string(res.Body)) + } + if err = json.Unmarshal(res.Body, nodes); err != nil { + return nil, err + } + + info := &elastic.ClusterInformation{ + ClusterName: stats.ClusterName, + ClusterUUID: stats.ClusterUUID, + } + if info.ClusterName == "" { + info.ClusterName = nodes.ClusterName + } + for _, node := range nodes.Nodes { + if node.Version != "" { + info.Version.Number = node.Version + break + } + } + info.Version.Distribution = config.Distribution + if info.Version.Distribution == "" { + info.Version.Distribution = elastic.Elasticsearch + } + return info, nil +} + +func executeRequest(config *elastic.ElasticsearchConfig, requestURL string) (*util.Result, error) { + req := util.Request{ + Method: http.MethodGet, + Url: requestURL, + } + if config.BasicAuth != nil && strings.TrimSpace(config.BasicAuth.Username) != "" { + req.SetBasicAuth(config.BasicAuth.Username, config.BasicAuth.Password.Get()) + } + ctx, cancel := context.WithTimeout(context.Background(), time.Duration(getRequestTimeout(config))*time.Second) + req.Context = ctx + defer cancel() + + return util.ExecuteRequestWithCatchFlag(nil, &req, true) +} + +func getRequestTimeout(config *elastic.ElasticsearchConfig) int { + if config == nil || config.RequestTimeout <= 0 { + return 5 + } + return config.RequestTimeout +} diff --git a/common/probe_test.go b/common/probe_test.go new file mode 100644 index 00000000..c7373d6d --- /dev/null +++ b/common/probe_test.go @@ -0,0 +1,68 @@ +// Copyright (C) INFINI Labs & INFINI LIMITED. +// +// The INFINI Console is offered under the GNU Affero General Public License v3.0 +// and as commercial software. +// +// For commercial licensing, contact us at: +// - Website: infinilabs.com +// - Email: hello@infini.ltd +// +// Open Source licensed under AGPL V3: +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package common + +import ( + "testing" + + "infini.sh/framework/core/elastic" +) + +func TestNormalizeProbePath(t *testing.T) { + tests := map[string]string{ + "": "", + " ": "", + "/": "", + "_cluster/health": "/_cluster/health", + " /_cat/nodes ": "/_cat/nodes", + } + + for input, expected := range tests { + if got := NormalizeProbePath(input); got != expected { + t.Fatalf("NormalizeProbePath(%q) = %q, want %q", input, got, expected) + } + } +} + +func TestProbePathStoredInLabels(t *testing.T) { + cfg := &elastic.ElasticsearchConfig{} + SetProbePath(cfg, "_cluster/health") + + if got := GetProbePath(cfg); got != "/_cluster/health" { + t.Fatalf("GetProbePath() = %q, want %q", got, "/_cluster/health") + } + + SetProbePath(cfg, "") + if got := GetProbePath(cfg); got != "" { + t.Fatalf("GetProbePath() = %q, want empty", got) + } +} + +func TestBuildEndpointWithPath(t *testing.T) { + got := BuildEndpointWithPath("https://example.com:9200", "/_cluster/health") + want := "https://example.com:9200/_cluster/health" + if got != want { + t.Fatalf("BuildEndpointWithPath() = %q, want %q", got, want) + } +} diff --git a/config/setup/common/alerting.tpl b/config/setup/common/alerting.tpl index 1ae2cbcc..c81903b5 100644 --- a/config/setup/common/alerting.tpl +++ b/config/setup/common/alerting.tpl @@ -395,7 +395,7 @@ POST $[[SETUP_INDEX_PREFIX]]channel/$[[SETUP_DOC_TYPE]]/cgiospt3q95q49k3u00g }, "method": "POST", "url": "{{$.env.DINGTALK_WEBHOOK_ENDPOINT}}", - "body": "{\n \"msgtype\": \"markdown\",\n \"markdown\": {\n \"title\": \"{{.title}}\",\n \"text\": \"![INFINI Platform Alerting](https://infinilabs.com/img/email/alert-header.png)\\n\\n🔥 Incident [{{.event_id}}]({{$.env.INFINI_CONSOLE_ENDPOINT}}/#/alerting/message/{{.event_id}}) is ongoing\\n\\n**{{.title}}**\\n\\nPriority: {{.priority}}\\n\\nEventID: {{.event_id}}\\n\\nTarget: {{.resource_name}}-{{.objects}}\\n\\nTriggerAt: {{.trigger_at | datetime}}\\n\\n---\\n\\n{{.message}}\"\n }\n}" + "body": "{\n \"msgtype\": \"markdown\",\n \"markdown\": {\n \"title\": \"{{.title}}\",\n \"text\": \"![INFINI Platform Alerting](https://infinilabs.cn/img/email/alert-header.png)\\n\\n🔥 Incident [{{.event_id}}]({{$.env.INFINI_CONSOLE_ENDPOINT}}/#/alerting/message/{{.event_id}}) is ongoing\\n\\n**{{.title}}**\\n\\nPriority: {{.priority}}\\n\\nEventID: {{.event_id}}\\n\\nTarget: {{.resource_name}}-{{.objects}}\\n\\nTriggerAt: {{.trigger_at | datetime}}\\n\\n---\\n\\n{{.message}}\"\n }\n}" }, "sub_type": "dingtalk", "enabled": false @@ -413,7 +413,7 @@ POST $[[SETUP_INDEX_PREFIX]]channel/$[[SETUP_DOC_TYPE]]/cj8ctat3q95l9ebbntlg }, "method": "POST", "url": "{{$.env.DINGTALK_WEBHOOK_ENDPOINT}}", - "body": "{\n \"msgtype\": \"markdown\",\n \"markdown\": {\n \"title\": \"{{.title}}\",\n \"text\": \"![INFINI Platform Alerting](https://infinilabs.com/img/email/recovery-header.png)\\n\\n**{{.title}}**\\n\\n{{.message}}\\n\\n> [View Incident]({{$.env.INFINI_CONSOLE_ENDPOINT}}/#/alerting/message/{{.event_id}})\"\n }\n}\n" + "body": "{\n \"msgtype\": \"markdown\",\n \"markdown\": {\n \"title\": \"{{.title}}\",\n \"text\": \"![INFINI Platform Alerting](https://infinilabs.cn/img/email/recovery-header.png)\\n\\n**{{.title}}**\\n\\n{{.message}}\\n\\n> [View Incident]({{$.env.INFINI_CONSOLE_ENDPOINT}}/#/alerting/message/{{.event_id}})\"\n }\n}\n" }, "sub_type": "dingtalk", "enabled": false @@ -451,7 +451,7 @@ POST $[[SETUP_INDEX_PREFIX]]channel/$[[SETUP_DOC_TYPE]]/cgnb2r53q95nmusjl6vg "cc": [] }, "subject": "[INFINI Platform Alerting] 🔥 {{.title}}", - "body": "\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n
\n \n \n \n \n \n \n
\n
\n
\n \n
\n \n \n \n \n
\n
\n \n \n \n \n \n \n
\n \"email-header\"\n
\n
\n \n \n \n \n \n \n
\n
\n
\n {{.title}}\n

\n \n

Priority: {{.priority}}

\n

EventID: {{.event_id}}

\n

Target: {{.resource_name}}-{{.objects}}

\n

TriggerAt: {{.trigger_at | datetime}}

\n {{.message | md_to_html}}\n
\n

\n \n \n View Detail\n \n

\n \n \n \n

\n \n

\n \"INFINI\n
\n
\n \n
\n

\n \n \n
\n
\n \n \n \n
\n
\n
\n \n
\n \n \n \n \n
\n
\n
\n
\n \n \n \n
\n
\n \n
\n \n \n
\n
\n \n ", + "body": "\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n
\n \n \n \n \n \n \n
\n
\n
\n \n
\n \n \n \n \n
\n
\n \n \n \n \n \n \n
\n \"email-header\"\n
\n
\n \n \n \n \n \n \n
\n
\n
\n {{.title}}\n

\n \n

Priority: {{.priority}}

\n

EventID: {{.event_id}}

\n

Target: {{.resource_name}}-{{.objects}}

\n

TriggerAt: {{.trigger_at | datetime}}

\n {{.message | md_to_html}}\n
\n

\n \n \n View Detail\n \n

\n \n \n \n

\n \n

\n \"INFINI\n
\n
\n \n
\n

\n \n \n
\n
\n \n \n \n
\n
\n
\n \n
\n \n \n \n \n
\n
\n
\n
\n \n \n \n
\n
\n \n
\n \n \n
\n
\n \n ", "content_type": "text/html" }, "enabled": false @@ -471,7 +471,7 @@ POST $[[SETUP_INDEX_PREFIX]]channel/$[[SETUP_DOC_TYPE]]/cj8atf53q95lhahebg8g "cc": [] }, "subject": "[INFINI Platform Alerting] {{.title}}", - "body": "\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n
\n \n \n \n \n \n \n
\n
\n
\n \n
\n \n \n \n \n
\n
\n \n \n \n \n \n \n
\n \"email-header\"\n
\n
\n \n \n \n \n \n \n
\n
\n
\n {{.title}}\n

\n {{.message | md_to_html}}\n
\n

\n \n \n View Detail\n \n

\n \n \n \n

\n \n

\n \"INFINI\n
\n
\n \n
\n

\n \n \n
\n
\n \n \n \n
\n
\n
\n \n
\n \n \n \n \n
\n
\n
\n
\n \n \n \n
\n
\n \n
\n \n \n
\n
\n \n ", + "body": "\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n
\n \n \n \n \n \n \n
\n
\n
\n \n
\n \n \n \n \n
\n
\n \n \n \n \n \n \n
\n \"email-header\"\n
\n
\n \n \n \n \n \n \n
\n
\n
\n {{.title}}\n

\n {{.message | md_to_html}}\n
\n

\n \n \n View Detail\n \n

\n \n \n \n

\n \n

\n \"INFINI\n
\n
\n \n
\n

\n \n \n
\n
\n \n \n \n
\n
\n
\n \n
\n \n \n \n \n
\n
\n
\n
\n \n \n \n
\n
\n \n
\n \n \n
\n
\n \n ", "content_type": "text/html" }, "enabled": false diff --git a/config/setup/easysearch/template_ilm.tpl b/config/setup/easysearch/template_ilm.tpl index d6a45954..45316558 100644 --- a/config/setup/easysearch/template_ilm.tpl +++ b/config/setup/easysearch/template_ilm.tpl @@ -24,7 +24,8 @@ PUT _template/$[[SETUP_TEMPLATE_NAME]] }, "codec": "ZSTD", "source_reuse": false, - "number_of_shards": "1" + "number_of_shards": "$[[SETUP_PRIMARY_SHARDS]]", + "auto_expand_replicas": "$[[SETUP_AUTO_EXPAND_REPLICAS]]" } }, "mappings": { @@ -171,7 +172,8 @@ PUT _template/$[[SETUP_INDEX_PREFIX]]metrics-rollover }, "codec" : "ZSTD", "source_reuse": true, - "number_of_shards" : "1", + "number_of_shards": "$[[SETUP_PRIMARY_SHARDS]]", + "auto_expand_replicas": "$[[SETUP_AUTO_EXPAND_REPLICAS]]", "translog.durability":"async", "mapping.coerce": false, "mapping.ignore_malformed": true @@ -240,7 +242,8 @@ PUT _template/$[[SETUP_INDEX_PREFIX]]logs-rollover }, "codec": "ZSTD", "source_reuse": false, - "number_of_shards": "1", + "number_of_shards": "$[[SETUP_PRIMARY_SHARDS]]", + "auto_expand_replicas": "$[[SETUP_AUTO_EXPAND_REPLICAS]]", "translog": { "durability": "async" } @@ -299,7 +302,8 @@ PUT _template/$[[SETUP_INDEX_PREFIX]]requests_logging-rollover }, "codec": "ZSTD", "source_reuse": true, - "number_of_shards": "1", + "number_of_shards": "$[[SETUP_PRIMARY_SHARDS]]", + "auto_expand_replicas": "$[[SETUP_AUTO_EXPAND_REPLICAS]]", "translog": { "durability": "async" } @@ -369,7 +373,8 @@ PUT _template/$[[SETUP_INDEX_PREFIX]]async_bulk_results-rollover }, "codec": "ZSTD", "source_reuse": false, - "number_of_shards": "1", + "number_of_shards": "$[[SETUP_PRIMARY_SHARDS]]", + "auto_expand_replicas": "$[[SETUP_AUTO_EXPAND_REPLICAS]]", "translog": { "durability": "async" } @@ -439,7 +444,8 @@ PUT _template/$[[SETUP_INDEX_PREFIX]]alert-history-rollover }, "codec" : "ZSTD", "source_reuse": false, - "number_of_shards" : "1", + "number_of_shards": "$[[SETUP_PRIMARY_SHARDS]]", + "auto_expand_replicas": "$[[SETUP_AUTO_EXPAND_REPLICAS]]", "translog.durability":"async", "analysis": { "analyzer": { @@ -606,7 +612,8 @@ PUT _template/$[[SETUP_INDEX_PREFIX]]activities-rollover }, "codec" : "ZSTD", "source_reuse": false, - "number_of_shards" : "1", + "number_of_shards": "$[[SETUP_PRIMARY_SHARDS]]", + "auto_expand_replicas": "$[[SETUP_AUTO_EXPAND_REPLICAS]]", "translog.durability":"async", "analysis": { "analyzer": { @@ -711,7 +718,8 @@ PUT _template/$[[SETUP_INDEX_PREFIX]]audit-logs-rollover }, "codec" : "ZSTD", "source_reuse": false, - "number_of_shards" : "1", + "number_of_shards": "$[[SETUP_PRIMARY_SHARDS]]", + "auto_expand_replicas": "$[[SETUP_AUTO_EXPAND_REPLICAS]]", "translog.durability":"async", "analysis": { "analyzer": { diff --git a/config/setup/easysearch/template_ilm_1_12_1.tpl b/config/setup/easysearch/template_ilm_1_12_1.tpl index 37d40a36..9001532e 100644 --- a/config/setup/easysearch/template_ilm_1_12_1.tpl +++ b/config/setup/easysearch/template_ilm_1_12_1.tpl @@ -24,7 +24,8 @@ PUT _template/$[[SETUP_TEMPLATE_NAME]] }, "codec": "ZSTD", "source_reuse": false, - "number_of_shards": "1" + "number_of_shards": "$[[SETUP_PRIMARY_SHARDS]]", + "auto_expand_replicas": "$[[SETUP_AUTO_EXPAND_REPLICAS]]" } }, "mappings": { @@ -110,7 +111,8 @@ PUT _template/$[[SETUP_INDEX_PREFIX]]metrics-rollover }, "codec" : "ZSTD", "source_reuse": false, - "number_of_shards" : "1", + "number_of_shards": "$[[SETUP_PRIMARY_SHARDS]]", + "auto_expand_replicas": "$[[SETUP_AUTO_EXPAND_REPLICAS]]", "translog.durability":"async", "mapping.coerce": false, "mapping.ignore_malformed": true @@ -197,7 +199,8 @@ PUT _template/$[[SETUP_INDEX_PREFIX]]logs-rollover }, "codec": "ZSTD", "source_reuse": false, - "number_of_shards": "1", + "number_of_shards": "$[[SETUP_PRIMARY_SHARDS]]", + "auto_expand_replicas": "$[[SETUP_AUTO_EXPAND_REPLICAS]]", "translog": { "durability": "async" } @@ -256,7 +259,8 @@ PUT _template/$[[SETUP_INDEX_PREFIX]]requests_logging-rollover }, "codec": "ZSTD", "source_reuse": true, - "number_of_shards": "1", + "number_of_shards": "$[[SETUP_PRIMARY_SHARDS]]", + "auto_expand_replicas": "$[[SETUP_AUTO_EXPAND_REPLICAS]]", "translog": { "durability": "async" } @@ -326,7 +330,8 @@ PUT _template/$[[SETUP_INDEX_PREFIX]]async_bulk_results-rollover }, "codec": "ZSTD", "source_reuse": false, - "number_of_shards": "1", + "number_of_shards": "$[[SETUP_PRIMARY_SHARDS]]", + "auto_expand_replicas": "$[[SETUP_AUTO_EXPAND_REPLICAS]]", "translog": { "durability": "async" } @@ -396,7 +401,8 @@ PUT _template/$[[SETUP_INDEX_PREFIX]]alert-history-rollover }, "codec" : "ZSTD", "source_reuse": false, - "number_of_shards" : "1", + "number_of_shards": "$[[SETUP_PRIMARY_SHARDS]]", + "auto_expand_replicas": "$[[SETUP_AUTO_EXPAND_REPLICAS]]", "translog.durability":"async", "analysis": { "analyzer": { @@ -563,7 +569,8 @@ PUT _template/$[[SETUP_INDEX_PREFIX]]activities-rollover }, "codec" : "ZSTD", "source_reuse": false, - "number_of_shards" : "1", + "number_of_shards": "$[[SETUP_PRIMARY_SHARDS]]", + "auto_expand_replicas": "$[[SETUP_AUTO_EXPAND_REPLICAS]]", "translog.durability":"async", "analysis": { "analyzer": { @@ -668,7 +675,8 @@ PUT _template/$[[SETUP_INDEX_PREFIX]]audit-logs-rollover }, "codec" : "ZSTD", "source_reuse": false, - "number_of_shards" : "1", + "number_of_shards": "$[[SETUP_PRIMARY_SHARDS]]", + "auto_expand_replicas": "$[[SETUP_AUTO_EXPAND_REPLICAS]]", "translog.durability":"async", "analysis": { "analyzer": { diff --git a/config/setup/easysearch/template_rollup.tpl b/config/setup/easysearch/template_rollup.tpl index 06133f73..6d6d99d9 100644 --- a/config/setup/easysearch/template_rollup.tpl +++ b/config/setup/easysearch/template_rollup.tpl @@ -6,7 +6,7 @@ PUT /_rollup/jobs/rollup_index_stats "target_index": "rollup_index_stats_{{ctx.source_index}}", "timestamp": "timestamp", "continuous": true, - "page_size": 600, + "page_size": 100, "cron": "*/5 0-23 * * *", "timezone": "UTC", "stats": [ @@ -51,7 +51,7 @@ PUT /_rollup/jobs/rollup_index_health "target_index": "rollup_index_health_{{ctx.source_index}}", "timestamp": "timestamp", "continuous": true, - "page_size": 600, + "page_size": 100, "cron": "*/5 0-23 * * *", "timezone": "UTC", "stats": [ @@ -90,7 +90,7 @@ PUT /_rollup/jobs/rollup_cluster_stats "rollup": { "source_index": "$[[SETUP_INDEX_PREFIX]]metrics", "target_index": "rollup_cluster_stats_{{ctx.source_index}}", - "page_size": 600, + "page_size": 100, "continuous": true, "cron": "*/5 0-23 * * *", "timezone": "UTC", @@ -132,7 +132,7 @@ PUT /_rollup/jobs/rollup_cluster_health "source_index": "$[[SETUP_INDEX_PREFIX]]metrics", "target_index": "rollup_cluster_health_{{ctx.source_index}}", "continuous": true, - "page_size": 600, + "page_size": 100, "cron": "*/5 0-23 * * *", "timezone": "UTC", "stats": [ @@ -173,7 +173,7 @@ PUT /_rollup/jobs/rollup_node_stats "target_index": "rollup_node_stats_{{ctx.source_index}}", "timestamp": "timestamp", "continuous": true, - "page_size": 600, + "page_size": 100, "cron": "*/5 0-23 * * *", "timezone": "UTC", "stats": [ @@ -256,7 +256,7 @@ PUT /_rollup/jobs/rollup_shard_stats_metrics "target_index": "rollup_shard_stats_metrics_{{ctx.source_index}}", "timestamp": "timestamp", "continuous": true, - "page_size": 600, + "page_size": 100, "cron": "*/5 0-23 * * *", "timezone": "UTC", "stats": [ @@ -302,7 +302,7 @@ PUT /_rollup/jobs/rollup_shard_stats_state "target_index": "rollup_shard_stats_state_{{ctx.source_index}}", "timestamp": "timestamp", "continuous": true, - "page_size": 600, + "page_size": 100, "cron": "*/5 0-23 * * *", "timezone": "UTC", "stats": [ @@ -375,7 +375,8 @@ PUT _template/rollup_policy_template "order": 1, "index_patterns": ["rollup*"], "settings": { - "index.lifecycle.name": "ilm_$[[SETUP_INDEX_PREFIX]]rollup-30days-retention" + "index.lifecycle.name": "ilm_$[[SETUP_INDEX_PREFIX]]rollup-30days-retention", + "index.auto_expand_replicas": "$[[SETUP_AUTO_EXPAND_REPLICAS]]" } } diff --git a/config/setup/elasticsearch/template_ilm.tpl b/config/setup/elasticsearch/template_ilm.tpl index ea7069a8..87ffbe84 100644 --- a/config/setup/elasticsearch/template_ilm.tpl +++ b/config/setup/elasticsearch/template_ilm.tpl @@ -23,7 +23,8 @@ PUT _template/$[[SETUP_TEMPLATE_NAME]] } } }, - "number_of_shards": "1" + "number_of_shards": "$[[SETUP_PRIMARY_SHARDS]]", + "auto_expand_replicas": "$[[SETUP_AUTO_EXPAND_REPLICAS]]" } }, "mappings": { @@ -105,7 +106,8 @@ PUT _template/$[[SETUP_INDEX_PREFIX]]metrics-rollover "rollover_alias" : "$[[SETUP_INDEX_PREFIX]]metrics" }, "codec" : "best_compression", - "number_of_shards" : "1", + "number_of_shards": "$[[SETUP_PRIMARY_SHARDS]]", + "auto_expand_replicas": "$[[SETUP_AUTO_EXPAND_REPLICAS]]", "translog.durability":"async", "mapping.coerce": false, "mapping.ignore_malformed": true @@ -173,7 +175,8 @@ PUT _template/$[[SETUP_INDEX_PREFIX]]logs-rollover "rollover_alias" : "$[[SETUP_INDEX_PREFIX]]logs" }, "codec": "best_compression", - "number_of_shards": "1", + "number_of_shards": "$[[SETUP_PRIMARY_SHARDS]]", + "auto_expand_replicas": "$[[SETUP_AUTO_EXPAND_REPLICAS]]", "translog": { "durability": "async" } @@ -231,7 +234,8 @@ PUT _template/$[[SETUP_INDEX_PREFIX]]requests_logging-rollover "rollover_alias" : "$[[SETUP_INDEX_PREFIX]]requests_logging" }, "codec": "best_compression", - "number_of_shards": "1", + "number_of_shards": "$[[SETUP_PRIMARY_SHARDS]]", + "auto_expand_replicas": "$[[SETUP_AUTO_EXPAND_REPLICAS]]", "translog": { "durability": "async" } @@ -300,7 +304,8 @@ PUT _template/$[[SETUP_INDEX_PREFIX]]async_bulk_results-rollover "rollover_alias" : "$[[SETUP_INDEX_PREFIX]]async_bulk_results" }, "codec": "best_compression", - "number_of_shards": "1", + "number_of_shards": "$[[SETUP_PRIMARY_SHARDS]]", + "auto_expand_replicas": "$[[SETUP_AUTO_EXPAND_REPLICAS]]", "translog": { "durability": "async" } @@ -369,7 +374,8 @@ PUT _template/$[[SETUP_INDEX_PREFIX]]alert-history-rollover "rollover_alias" : "$[[SETUP_INDEX_PREFIX]]alert-history" }, "codec" : "best_compression", - "number_of_shards" : "1", + "number_of_shards": "$[[SETUP_PRIMARY_SHARDS]]", + "auto_expand_replicas": "$[[SETUP_AUTO_EXPAND_REPLICAS]]", "translog.durability":"async", "analysis": { "analyzer": { @@ -534,7 +540,8 @@ PUT _template/$[[SETUP_INDEX_PREFIX]]activities-rollover "rollover_alias" : "$[[SETUP_INDEX_PREFIX]]activities" }, "codec" : "best_compression", - "number_of_shards" : "1", + "number_of_shards": "$[[SETUP_PRIMARY_SHARDS]]", + "auto_expand_replicas": "$[[SETUP_AUTO_EXPAND_REPLICAS]]", "translog.durability":"async", "analysis": { "analyzer": { @@ -638,7 +645,8 @@ PUT _template/$[[SETUP_INDEX_PREFIX]]audit-logs-rollover "rollover_alias" : "$[[SETUP_INDEX_PREFIX]]audit-logs" }, "codec" : "best_compression", - "number_of_shards" : "1", + "number_of_shards": "$[[SETUP_PRIMARY_SHARDS]]", + "auto_expand_replicas": "$[[SETUP_AUTO_EXPAND_REPLICAS]]", "translog.durability":"async" } }, diff --git a/config/setup/elasticsearch/v5/template_ilm.tpl b/config/setup/elasticsearch/v5/template_ilm.tpl index 8e2377b7..0f1d335b 100644 --- a/config/setup/elasticsearch/v5/template_ilm.tpl +++ b/config/setup/elasticsearch/v5/template_ilm.tpl @@ -21,7 +21,8 @@ PUT _template/$[[SETUP_TEMPLATE_NAME]] } } }, - "number_of_shards": "1" + "number_of_shards": "$[[SETUP_PRIMARY_SHARDS]]", + "auto_expand_replicas": "$[[SETUP_AUTO_EXPAND_REPLICAS]]" } }, "mappings": { @@ -59,7 +60,8 @@ PUT _template/$[[SETUP_INDEX_PREFIX]]metrics-rollover "index" : { "format" : "7", "codec" : "best_compression", - "number_of_shards" : "1", + "number_of_shards": "$[[SETUP_PRIMARY_SHARDS]]", + "auto_expand_replicas": "$[[SETUP_AUTO_EXPAND_REPLICAS]]", "translog.durability":"async" } }, @@ -121,7 +123,8 @@ PUT _template/$[[SETUP_INDEX_PREFIX]]logs-rollover "index": { "format": "7", "codec": "best_compression", - "number_of_shards": "1", + "number_of_shards": "$[[SETUP_PRIMARY_SHARDS]]", + "auto_expand_replicas": "$[[SETUP_AUTO_EXPAND_REPLICAS]]", "translog": { "durability": "async" } @@ -174,7 +177,8 @@ PUT _template/$[[SETUP_INDEX_PREFIX]]requests_logging-rollover "index": { "format": "7", "codec": "best_compression", - "number_of_shards": "1", + "number_of_shards": "$[[SETUP_PRIMARY_SHARDS]]", + "auto_expand_replicas": "$[[SETUP_AUTO_EXPAND_REPLICAS]]", "translog": { "durability": "async" } @@ -238,7 +242,8 @@ PUT _template/$[[SETUP_INDEX_PREFIX]]async_bulk_results-rollover "index": { "format": "7", "codec": "best_compression", - "number_of_shards": "1", + "number_of_shards": "$[[SETUP_PRIMARY_SHARDS]]", + "auto_expand_replicas": "$[[SETUP_AUTO_EXPAND_REPLICAS]]", "translog": { "durability": "async" } @@ -302,7 +307,8 @@ PUT _template/$[[SETUP_INDEX_PREFIX]]alert-history-rollover "index" : { "format" : "7", "codec" : "best_compression", - "number_of_shards" : "1", + "number_of_shards": "$[[SETUP_PRIMARY_SHARDS]]", + "auto_expand_replicas": "$[[SETUP_AUTO_EXPAND_REPLICAS]]", "translog.durability":"async", "analysis": { "analyzer": { @@ -457,7 +463,8 @@ PUT _template/$[[SETUP_INDEX_PREFIX]]activities-rollover "index" : { "format" : "7", "codec" : "best_compression", - "number_of_shards" : "1", + "number_of_shards": "$[[SETUP_PRIMARY_SHARDS]]", + "auto_expand_replicas": "$[[SETUP_AUTO_EXPAND_REPLICAS]]", "translog.durability":"async", "analysis": { "analyzer": { @@ -556,7 +563,8 @@ PUT _template/$[[SETUP_INDEX_PREFIX]]audit-logs-rollover "index" : { "format" : "7", "codec" : "best_compression", - "number_of_shards" : "1", + "number_of_shards": "$[[SETUP_PRIMARY_SHARDS]]", + "auto_expand_replicas": "$[[SETUP_AUTO_EXPAND_REPLICAS]]", "translog.durability":"async", "analysis": { "analyzer": { diff --git a/config/setup/elasticsearch/v6/template_ilm.tpl b/config/setup/elasticsearch/v6/template_ilm.tpl index 80e78f1e..d2327632 100644 --- a/config/setup/elasticsearch/v6/template_ilm.tpl +++ b/config/setup/elasticsearch/v6/template_ilm.tpl @@ -23,7 +23,8 @@ PUT _template/$[[SETUP_TEMPLATE_NAME]] } } }, - "number_of_shards": "1" + "number_of_shards": "$[[SETUP_PRIMARY_SHARDS]]", + "auto_expand_replicas": "$[[SETUP_AUTO_EXPAND_REPLICAS]]" } }, "mappings": { @@ -94,7 +95,8 @@ PUT _template/$[[SETUP_INDEX_PREFIX]]metrics-rollover "rollover_alias" : "$[[SETUP_INDEX_PREFIX]]metrics" }, "codec" : "best_compression", - "number_of_shards" : "1", + "number_of_shards": "$[[SETUP_PRIMARY_SHARDS]]", + "auto_expand_replicas": "$[[SETUP_AUTO_EXPAND_REPLICAS]]", "translog.durability":"async" } }, @@ -164,7 +166,8 @@ PUT _template/$[[SETUP_INDEX_PREFIX]]logs-rollover "rollover_alias" : "$[[SETUP_INDEX_PREFIX]]logs" }, "codec": "best_compression", - "number_of_shards": "1", + "number_of_shards": "$[[SETUP_PRIMARY_SHARDS]]", + "auto_expand_replicas": "$[[SETUP_AUTO_EXPAND_REPLICAS]]", "translog": { "durability": "async" } @@ -224,7 +227,8 @@ PUT _template/$[[SETUP_INDEX_PREFIX]]requests_logging-rollover "rollover_alias" : "$[[SETUP_INDEX_PREFIX]]requests_logging" }, "codec": "best_compression", - "number_of_shards": "1", + "number_of_shards": "$[[SETUP_PRIMARY_SHARDS]]", + "auto_expand_replicas": "$[[SETUP_AUTO_EXPAND_REPLICAS]]", "translog": { "durability": "async" } @@ -295,7 +299,8 @@ PUT _template/$[[SETUP_INDEX_PREFIX]]async_bulk_results-rollover "rollover_alias" : "$[[SETUP_INDEX_PREFIX]]async_bulk_results" }, "codec": "best_compression", - "number_of_shards": "1", + "number_of_shards": "$[[SETUP_PRIMARY_SHARDS]]", + "auto_expand_replicas": "$[[SETUP_AUTO_EXPAND_REPLICAS]]", "translog": { "durability": "async" } @@ -366,7 +371,8 @@ PUT _template/$[[SETUP_INDEX_PREFIX]]alert-history-rollover "rollover_alias" : "$[[SETUP_INDEX_PREFIX]]alert-history" }, "codec" : "best_compression", - "number_of_shards" : "1", + "number_of_shards": "$[[SETUP_PRIMARY_SHARDS]]", + "auto_expand_replicas": "$[[SETUP_AUTO_EXPAND_REPLICAS]]", "translog.durability":"async", "analysis": { "analyzer": { @@ -533,7 +539,8 @@ PUT _template/$[[SETUP_INDEX_PREFIX]]activities-rollover "rollover_alias" : "$[[SETUP_INDEX_PREFIX]]activities" }, "codec" : "best_compression", - "number_of_shards" : "1", + "number_of_shards": "$[[SETUP_PRIMARY_SHARDS]]", + "auto_expand_replicas": "$[[SETUP_AUTO_EXPAND_REPLICAS]]", "translog.durability":"async", "analysis": { "analyzer": { @@ -639,7 +646,8 @@ PUT _template/$[[SETUP_INDEX_PREFIX]]audit-logs-rollover "rollover_alias" : "$[[SETUP_INDEX_PREFIX]]audit-logs" }, "codec" : "best_compression", - "number_of_shards" : "1", + "number_of_shards": "$[[SETUP_PRIMARY_SHARDS]]", + "auto_expand_replicas": "$[[SETUP_AUTO_EXPAND_REPLICAS]]", "translog.durability":"async", "analysis": { "analyzer": { diff --git a/config/setup/opensearch/template_ilm.tpl b/config/setup/opensearch/template_ilm.tpl index 9c6fa964..16974fa0 100644 --- a/config/setup/opensearch/template_ilm.tpl +++ b/config/setup/opensearch/template_ilm.tpl @@ -23,7 +23,8 @@ PUT _template/$[[SETUP_TEMPLATE_NAME]] } } }, - "number_of_shards": "1" + "number_of_shards": "$[[SETUP_PRIMARY_SHARDS]]", + "auto_expand_replicas": "$[[SETUP_AUTO_EXPAND_REPLICAS]]" } }, "mappings": { @@ -117,7 +118,8 @@ PUT _template/$[[SETUP_INDEX_PREFIX]]metrics-rollover "settings": { "index":{ "codec" : "best_compression", - "number_of_shards" : "1", + "number_of_shards": "$[[SETUP_PRIMARY_SHARDS]]", + "auto_expand_replicas": "$[[SETUP_AUTO_EXPAND_REPLICAS]]", "translog.durability":"async" }, "plugins.index_state_management.rollover_alias": "$[[SETUP_INDEX_PREFIX]]metrics" @@ -167,7 +169,8 @@ PUT /_template/$[[SETUP_INDEX_PREFIX]]logs-rollover "settings": { "plugins.index_state_management.rollover_alias": "$[[SETUP_INDEX_PREFIX]]logs", "codec": "best_compression", - "number_of_shards": "1", + "number_of_shards": "$[[SETUP_PRIMARY_SHARDS]]", + "auto_expand_replicas": "$[[SETUP_AUTO_EXPAND_REPLICAS]]", "translog": { "durability": "async" } @@ -222,7 +225,8 @@ PUT _template/$[[SETUP_INDEX_PREFIX]]requests_logging-rollover "settings": { "plugins.index_state_management.rollover_alias": "$[[SETUP_INDEX_PREFIX]]requests_logging", "codec": "best_compression", - "number_of_shards": "1", + "number_of_shards": "$[[SETUP_PRIMARY_SHARDS]]", + "auto_expand_replicas": "$[[SETUP_AUTO_EXPAND_REPLICAS]]", "translog": { "durability": "async" } @@ -288,7 +292,8 @@ PUT /_template/$[[SETUP_INDEX_PREFIX]]async_bulk_results-rollover "settings": { "plugins.index_state_management.rollover_alias": "$[[SETUP_INDEX_PREFIX]]async_bulk_results", "codec": "best_compression", - "number_of_shards": "1", + "number_of_shards": "$[[SETUP_PRIMARY_SHARDS]]", + "auto_expand_replicas": "$[[SETUP_AUTO_EXPAND_REPLICAS]]", "translog": { "durability": "async" } @@ -353,7 +358,8 @@ PUT /_template/$[[SETUP_INDEX_PREFIX]]alert-history-rollover "settings": { "plugins.index_state_management.rollover_alias": "$[[SETUP_INDEX_PREFIX]]alert-history", "codec": "best_compression", - "number_of_shards": "1", + "number_of_shards": "$[[SETUP_PRIMARY_SHARDS]]", + "auto_expand_replicas": "$[[SETUP_AUTO_EXPAND_REPLICAS]]", "translog": { "durability": "async" }, @@ -516,7 +522,8 @@ PUT /_template/$[[SETUP_INDEX_PREFIX]]activities-rollover "settings": { "plugins.index_state_management.rollover_alias": "$[[SETUP_INDEX_PREFIX]]activities", "codec": "best_compression", - "number_of_shards": "1", + "number_of_shards": "$[[SETUP_PRIMARY_SHARDS]]", + "auto_expand_replicas": "$[[SETUP_AUTO_EXPAND_REPLICAS]]", "translog": { "durability": "async" }, @@ -620,7 +627,8 @@ PUT /_template/$[[SETUP_INDEX_PREFIX]]audit-logs-rollover "settings": { "plugins.index_state_management.rollover_alias": "$[[SETUP_INDEX_PREFIX]]audit-logs", "codec": "best_compression", - "number_of_shards": "1", + "number_of_shards": "$[[SETUP_PRIMARY_SHARDS]]", + "auto_expand_replicas": "$[[SETUP_AUTO_EXPAND_REPLICAS]]", "translog": { "durability": "async" }, diff --git a/config/system_config.tpl b/config/system_config.tpl index 6b69cb08..ad45161c 100644 --- a/config/system_config.tpl +++ b/config/system_config.tpl @@ -113,4 +113,4 @@ pipeline: queues: type: indexing_merge when: - cluster_available: ["$[[CLUSTER_ID]]"] \ No newline at end of file + cluster_available: ["$[[CLUSTER_ID]]"] diff --git a/console.yml b/console.yml index 1b700f01..bf8014fa 100644 --- a/console.yml +++ b/console.yml @@ -1,4 +1,4 @@ -#env: +# env: # INFINI_CONSOLE_ENDPOINT: "http://127.0.0.1:9000" # INGEST_CLUSTER_ENDPOINT: "https://127.0.0.1:9200" # INGEST_CLUSTER_CREDENTIAL_ID: chjkp9dath21f1ae9tq0 @@ -6,7 +6,7 @@ # DISCORD_WEBHOOK_ENDPOINT: # DINGTALK_WEBHOOK_ENDPOINT: # WECOM_WEBHOOK_ENDPOINT: -# FEISHU_WEBHOOK_ENDPOINT: +# FEISHU_WEBHOOK_ENDPOINT: https://open.feishu.cn/open-apis/bot/v2/hook/ # must in major config file @@ -24,6 +24,10 @@ configs: skip_insecure_verify: false web: enabled: true + tls: + enabled: true + skip_insecure_verify: true + default_domain: "localhost:9000" embedding_api: true # base_path: /console security: diff --git a/core/auth.go b/core/auth.go index e88d89fe..647439e9 100644 --- a/core/auth.go +++ b/core/auth.go @@ -34,6 +34,7 @@ import ( "infini.sh/framework/core/global" "infini.sh/framework/core/util" "net/http" + "strings" ) // Handler is the object of http handler @@ -94,6 +95,82 @@ func (handler Handler) RequirePermission(h httprouter.Handle, permissions ...str } } +func RequestUsesSecureTransport(req *http.Request) bool { + if req == nil { + return false + } + if req.TLS != nil { + return true + } + + for _, header := range []string{"X-Forwarded-Proto", "X-Forwarded-Protocol", "X-Url-Scheme"} { + if headerIndicatesHTTPS(req.Header.Get(header)) { + return true + } + } + + if strings.EqualFold(strings.TrimSpace(req.Header.Get("X-Forwarded-Ssl")), "on") { + return true + } + + return forwardedHeaderIndicatesHTTPS(req.Header.Get("Forwarded")) +} + +func (handler Handler) RequireSecureTransport(h httprouter.Handle) httprouter.Handle { + return func(w http.ResponseWriter, r *http.Request, ps httprouter.Params) { + if !RequestUsesSecureTransport(r) { + handler.WriteError(w, "sensitive endpoints require HTTPS or a trusted HTTPS reverse proxy", http.StatusUpgradeRequired) + return + } + h(w, r, ps) + } +} + +func RequireSecureTransport(h httprouter.Handle) httprouter.Handle { + return Handler{}.RequireSecureTransport(h) +} + +func (handler Handler) RequireReplayProtection(h httprouter.Handle) httprouter.Handle { + return func(w http.ResponseWriter, r *http.Request, ps httprouter.Params) { + if err := ValidateAndConsumeReplayNonce(r); err != nil { + handler.WriteError(w, err.Error(), http.StatusUnauthorized) + return + } + h(w, r, ps) + } +} + +func RequireReplayProtection(h httprouter.Handle) httprouter.Handle { + return Handler{}.RequireReplayProtection(h) +} + +func headerIndicatesHTTPS(value string) bool { + if value == "" { + return false + } + first := strings.TrimSpace(strings.Split(value, ",")[0]) + return strings.EqualFold(first, "https") +} + +func forwardedHeaderIndicatesHTTPS(value string) bool { + if value == "" { + return false + } + + for _, forwardedValue := range strings.Split(value, ",") { + for _, token := range strings.Split(forwardedValue, ";") { + parts := strings.SplitN(strings.TrimSpace(token), "=", 2) + if len(parts) != 2 || !strings.EqualFold(parts[0], "proto") { + continue + } + proto := strings.Trim(parts[1], "\"") + return strings.EqualFold(proto, "https") + } + } + + return false +} + func (handler Handler) RequireClusterPermission(h httprouter.Handle, permissions ...string) httprouter.Handle { return func(w http.ResponseWriter, r *http.Request, ps httprouter.Params) { diff --git a/core/auth_test.go b/core/auth_test.go new file mode 100644 index 00000000..5fca2a71 --- /dev/null +++ b/core/auth_test.go @@ -0,0 +1,78 @@ +package core + +import ( + "crypto/tls" + "net/http" + "net/http/httptest" + "testing" + + httprouter "infini.sh/framework/core/api/router" +) + +func TestRequestUsesSecureTransport(t *testing.T) { + tests := []struct { + name string + setup func(req *http.Request) + secure bool + }{ + { + name: "tls request", + setup: func(req *http.Request) { + req.TLS = &tls.ConnectionState{} + }, + secure: true, + }, + { + name: "forwarded proto", + setup: func(req *http.Request) { + req.Header.Set("X-Forwarded-Proto", "https") + }, + secure: true, + }, + { + name: "forwarded header", + setup: func(req *http.Request) { + req.Header.Set("Forwarded", `for=127.0.0.1;proto=https;host=console.local`) + }, + secure: true, + }, + { + name: "plain http", + setup: func(req *http.Request) { + }, + secure: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + req := httptest.NewRequest(http.MethodPost, "http://console.local/account/login", nil) + tt.setup(req) + + if RequestUsesSecureTransport(req) != tt.secure { + t.Fatalf("expected secure=%v", tt.secure) + } + }) + } +} + +func TestRequireSecureTransport(t *testing.T) { + handler := Handler{} + called := false + protected := handler.RequireSecureTransport(func(w http.ResponseWriter, r *http.Request, ps httprouter.Params) { + called = true + w.WriteHeader(http.StatusOK) + }) + + req := httptest.NewRequest(http.MethodPost, "http://console.local/account/login", nil) + resp := httptest.NewRecorder() + + protected(resp, req, nil) + + if called { + t.Fatal("expected insecure request to be blocked") + } + if resp.Code != http.StatusUpgradeRequired { + t.Fatalf("expected status %d, got %d", http.StatusUpgradeRequired, resp.Code) + } +} diff --git a/core/replay.go b/core/replay.go new file mode 100644 index 00000000..b6131a9a --- /dev/null +++ b/core/replay.go @@ -0,0 +1,161 @@ +// Copyright (C) INFINI Labs & INFINI LIMITED. +// +// The INFINI Console is offered under the GNU Affero General Public License v3.0 +// and as commercial software. +// +// For commercial licensing, contact us at: +// - Website: infinilabs.com +// - Email: hello@infini.ltd +// +// Open Source licensed under AGPL V3: +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package core + +import ( + "crypto/sha256" + "encoding/hex" + "fmt" + "net/http" + pathutil "path" + "strings" + "sync" + "time" + + "infini.sh/framework/core/util" +) + +const replayNonceHeader = "X-Request-Nonce" +const replayNonceTTL = 30 * time.Second + +type replayNonceRecord struct { + Subject string + Method string + Path string + ExpiresAt time.Time +} + +type replayNonceStore struct { + mu sync.Mutex + records map[string]replayNonceRecord +} + +var sensitiveRequestNonceStore = replayNonceStore{ + records: map[string]replayNonceRecord{}, +} + +func IssueReplayNonce(r *http.Request, method, requestPath string) (string, time.Duration, error) { + normalizedMethod, normalizedPath, err := normalizeReplayScope(method, requestPath) + if err != nil { + return "", 0, err + } + + nonce := util.GenerateSecureString(32) + if nonce == "" { + return "", 0, fmt.Errorf("failed to generate replay nonce") + } + + subject := replayRequestSubject(r) + expiresAt := time.Now().Add(replayNonceTTL) + + sensitiveRequestNonceStore.mu.Lock() + defer sensitiveRequestNonceStore.mu.Unlock() + sensitiveRequestNonceStore.cleanupExpiredLocked(time.Now()) + sensitiveRequestNonceStore.records[nonce] = replayNonceRecord{ + Subject: subject, + Method: normalizedMethod, + Path: normalizedPath, + ExpiresAt: expiresAt, + } + return nonce, replayNonceTTL, nil +} + +func ValidateAndConsumeReplayNonce(r *http.Request) error { + if r == nil { + return fmt.Errorf("request can not be nil") + } + + nonce := strings.TrimSpace(r.Header.Get(replayNonceHeader)) + if nonce == "" { + return fmt.Errorf("missing replay nonce") + } + + subject := replayRequestSubject(r) + method, requestPath, err := normalizeReplayScope(r.Method, r.URL.Path) + if err != nil { + return err + } + + now := time.Now() + sensitiveRequestNonceStore.mu.Lock() + defer sensitiveRequestNonceStore.mu.Unlock() + sensitiveRequestNonceStore.cleanupExpiredLocked(now) + + record, ok := sensitiveRequestNonceStore.records[nonce] + if !ok { + return fmt.Errorf("replay nonce is invalid or expired") + } + delete(sensitiveRequestNonceStore.records, nonce) + + if record.Subject != subject || record.Method != method || record.Path != requestPath { + return fmt.Errorf("replay nonce does not match request context") + } + + return nil +} + +func (store *replayNonceStore) cleanupExpiredLocked(now time.Time) { + for nonce, record := range store.records { + if now.After(record.ExpiresAt) { + delete(store.records, nonce) + } + } +} + +func normalizeReplayScope(method, requestPath string) (string, string, error) { + normalizedMethod := strings.ToUpper(strings.TrimSpace(method)) + switch normalizedMethod { + case http.MethodPost, http.MethodPut, http.MethodDelete: + default: + return "", "", fmt.Errorf("unsupported replay-protected method [%s]", method) + } + + normalizedPath := strings.TrimSpace(requestPath) + if normalizedPath == "" { + return "", "", fmt.Errorf("request path can not be empty") + } + if !strings.HasPrefix(normalizedPath, "/") { + normalizedPath = "/" + normalizedPath + } + normalizedPath = pathutil.Clean(normalizedPath) + if normalizedPath == "." { + normalizedPath = "/" + } + + return normalizedMethod, normalizedPath, nil +} + +func replayRequestSubject(r *http.Request) string { + if r == nil { + return "anonymous" + } + + authorizationHeader := strings.TrimSpace(r.Header.Get("Authorization")) + if authorizationHeader == "" { + return "anonymous" + } + + sum := sha256.Sum256([]byte(authorizationHeader)) + return hex.EncodeToString(sum[:]) +} diff --git a/core/replay_test.go b/core/replay_test.go new file mode 100644 index 00000000..30a523f0 --- /dev/null +++ b/core/replay_test.go @@ -0,0 +1,79 @@ +// Copyright (C) INFINI Labs & INFINI LIMITED. +// +// The INFINI Console is offered under the GNU Affero General Public License v3.0 +// and as commercial software. +// +// For commercial licensing, contact us at: +// - Website: infinilabs.com +// - Email: hello@infini.ltd +// +// Open Source licensed under AGPL V3: +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package core + +import ( + "net/http" + "net/http/httptest" + "testing" +) + +func TestReplayNonceCanOnlyBeUsedOnce(t *testing.T) { + req := httptest.NewRequest(http.MethodPost, "https://console.local/account/login", nil) + + nonce, _, err := IssueReplayNonce(req, http.MethodPost, "/account/login") + if err != nil { + t.Fatalf("issue replay nonce failed: %v", err) + } + + req.Header.Set(replayNonceHeader, nonce) + if err := ValidateAndConsumeReplayNonce(req); err != nil { + t.Fatalf("expected first nonce use to succeed: %v", err) + } + if err := ValidateAndConsumeReplayNonce(req); err == nil { + t.Fatal("expected second nonce use to be rejected") + } +} + +func TestReplayNonceBindsToAuthorizationHeader(t *testing.T) { + issueReq := httptest.NewRequest(http.MethodPut, "https://console.local/credential/test", nil) + issueReq.Header.Set("Authorization", "Bearer token-a") + + nonce, _, err := IssueReplayNonce(issueReq, http.MethodPut, "/credential/test") + if err != nil { + t.Fatalf("issue replay nonce failed: %v", err) + } + + useReq := httptest.NewRequest(http.MethodPut, "https://console.local/credential/test", nil) + useReq.Header.Set(replayNonceHeader, nonce) + useReq.Header.Set("Authorization", "Bearer token-b") + if err := ValidateAndConsumeReplayNonce(useReq); err == nil { + t.Fatal("expected nonce bound to a different authorization header to fail") + } +} + +func TestReplayNonceBindsToPathAndMethod(t *testing.T) { + issueReq := httptest.NewRequest(http.MethodPost, "https://console.local/setup/_initialize", nil) + + nonce, _, err := IssueReplayNonce(issueReq, http.MethodPost, "/setup/_initialize") + if err != nil { + t.Fatalf("issue replay nonce failed: %v", err) + } + + useReq := httptest.NewRequest(http.MethodPut, "https://console.local/setup/_initialize", nil) + useReq.Header.Set(replayNonceHeader, nonce) + if err := ValidateAndConsumeReplayNonce(useReq); err == nil { + t.Fatal("expected nonce with mismatched method to fail") + } +} diff --git a/core/security/access_token.go b/core/security/access_token.go index bad08095..47ed6bb7 100644 --- a/core/security/access_token.go +++ b/core/security/access_token.go @@ -35,10 +35,13 @@ import ( "infini.sh/framework/core/util" ) +const accessTokenTTL = 2 * time.Hour + func GenerateAccessToken(user *User) (map[string]interface{}, error) { var data map[string]interface{} roles, privilege := user.GetPermissions() + expireAt := time.Now().Add(accessTokenTTL) token1 := jwt.NewWithClaims(jwt.SigningMethodHS256, UserClaims{ ShortUser: &ShortUser{ @@ -48,7 +51,7 @@ func GenerateAccessToken(user *User) (map[string]interface{}, error) { Roles: roles, }, RegisteredClaims: &jwt.RegisteredClaims{ - ExpiresAt: jwt.NewNumericDate(time.Now().Add(24 * time.Hour)), + ExpiresAt: jwt.NewNumericDate(expireAt), }, }) @@ -57,14 +60,18 @@ func GenerateAccessToken(user *User) (map[string]interface{}, error) { return nil, errors.Errorf("failed to generate access_token for user: %v", user.Username) } - token := Token{ExpireIn: time.Now().Unix() + 86400} + token := Token{ + JwtStr: tokenString, + Value: tokenString, + ExpireIn: expireAt.Unix(), + } SetUserToken(user.ID, token) data = util.MapStr{ "access_token": tokenString, "username": user.Username, "id": user.ID, - "expire_in": 86400, + "expire_in": int64(accessTokenTTL / time.Second), "roles": roles, "privilege": privilege, } diff --git a/core/security/password_challenge.go b/core/security/password_challenge.go new file mode 100644 index 00000000..1f0f2d94 --- /dev/null +++ b/core/security/password_challenge.go @@ -0,0 +1,154 @@ +package security + +import ( + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "errors" + "strings" + "sync" + "time" + + "golang.org/x/crypto/bcrypt" + "golang.org/x/crypto/pbkdf2" + "infini.sh/framework/core/util" +) + +const ( + PasswordChallengeMethod = "challenge" + PasswordChallengeAlgorithm = "PBKDF2-SHA256" + PasswordChallengeIterations = 120000 + passwordChallengeKeyLength = 32 + loginChallengeTTL = 5 * time.Minute +) + +type LoginChallenge struct { + ID string + Username string + Nonce string + ExpireAt time.Time +} + +var loginChallengeLocker sync.Mutex +var loginChallengeMap = map[string]LoginChallenge{} + +func CanUsePasswordChallenge(user *User) bool { + return user != nil && user.PasswordSalt != "" && user.PasswordVerifier != "" +} + +func SetPassword(user *User, password string) error { + if user == nil { + return errors.New("user is nil") + } + hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) + if err != nil { + return err + } + salt := util.GenerateSecureString(32) + verifier, err := DerivePasswordVerifier(password, salt) + if err != nil { + return err + } + user.Password = string(hash) + user.PasswordSalt = salt + user.PasswordVerifier = verifier + return nil +} + +func EnsurePasswordChallenge(user *User, password string) error { + if user == nil { + return errors.New("user is nil") + } + if CanUsePasswordChallenge(user) { + return nil + } + salt := util.GenerateSecureString(32) + verifier, err := DerivePasswordVerifier(password, salt) + if err != nil { + return err + } + user.PasswordSalt = salt + user.PasswordVerifier = verifier + return nil +} + +func DerivePasswordVerifier(password, salt string) (string, error) { + if password == "" { + return "", errors.New("password is empty") + } + if salt == "" { + return "", errors.New("password salt is empty") + } + key := pbkdf2.Key([]byte(password), []byte(salt), PasswordChallengeIterations, passwordChallengeKeyLength, sha256.New) + return hex.EncodeToString(key), nil +} + +func BuildPasswordProof(verifier, username, challengeID, nonce string) (string, error) { + key, err := hex.DecodeString(verifier) + if err != nil { + return "", err + } + mac := hmac.New(sha256.New, key) + mac.Write([]byte(username)) + mac.Write([]byte(":")) + mac.Write([]byte(challengeID)) + mac.Write([]byte(":")) + mac.Write([]byte(nonce)) + return hex.EncodeToString(mac.Sum(nil)), nil +} + +func VerifyPasswordProof(verifier, username, challengeID, nonce, proof string) bool { + expected, err := BuildPasswordProof(verifier, username, challengeID, nonce) + if err != nil { + return false + } + expectedBytes, err := hex.DecodeString(expected) + if err != nil { + return false + } + proofBytes, err := hex.DecodeString(strings.ToLower(proof)) + if err != nil { + return false + } + return hmac.Equal(expectedBytes, proofBytes) +} + +func NewLoginChallenge(username string) LoginChallenge { + now := time.Now() + loginChallengeLocker.Lock() + defer loginChallengeLocker.Unlock() + + for id, challenge := range loginChallengeMap { + if challenge.ExpireAt.Before(now) { + delete(loginChallengeMap, id) + } + } + + challenge := LoginChallenge{ + ID: util.GenerateSecureString(32), + Username: username, + Nonce: util.GenerateSecureString(32), + ExpireAt: now.Add(loginChallengeTTL), + } + loginChallengeMap[challenge.ID] = challenge + return challenge +} + +func ConsumeLoginChallenge(challengeID, username string) (LoginChallenge, error) { + loginChallengeLocker.Lock() + defer loginChallengeLocker.Unlock() + + challenge, ok := loginChallengeMap[challengeID] + if !ok { + return LoginChallenge{}, errors.New("login challenge is invalid") + } + delete(loginChallengeMap, challengeID) + + if challenge.ExpireAt.Before(time.Now()) { + return LoginChallenge{}, errors.New("login challenge expired") + } + if challenge.Username != username { + return LoginChallenge{}, errors.New("login challenge does not match user") + } + return challenge, nil +} diff --git a/core/security/password_challenge_test.go b/core/security/password_challenge_test.go new file mode 100644 index 00000000..ebe4ef07 --- /dev/null +++ b/core/security/password_challenge_test.go @@ -0,0 +1,34 @@ +package security + +import "testing" + +func TestPasswordChallengeProofRoundTrip(t *testing.T) { + verifier, err := DerivePasswordVerifier("admin", "salt-123") + if err != nil { + t.Fatalf("derive verifier: %v", err) + } + + challenge := NewLoginChallenge("admin") + proof, err := BuildPasswordProof(verifier, "admin", challenge.ID, challenge.Nonce) + if err != nil { + t.Fatalf("build proof: %v", err) + } + + if !VerifyPasswordProof(verifier, "admin", challenge.ID, challenge.Nonce, proof) { + t.Fatal("expected password proof to validate") + } +} + +func TestSetPasswordEnablesChallengeLogin(t *testing.T) { + user := &User{} + if err := SetPassword(user, "admin"); err != nil { + t.Fatalf("set password: %v", err) + } + + if user.Password == "" || user.PasswordSalt == "" || user.PasswordVerifier == "" { + t.Fatal("expected password, salt and verifier to be populated") + } + if !CanUsePasswordChallenge(user) { + t.Fatal("expected challenge login to be enabled") + } +} diff --git a/core/security/user.go b/core/security/user.go index 0d4177e1..113a14d3 100644 --- a/core/security/user.go +++ b/core/security/user.go @@ -34,13 +34,15 @@ import ( type User struct { orm.ORMObjectBase - AuthProvider string `json:"auth_provider" elastic_mapping:"auth_provider: { type: keyword }"` - Username string `json:"name" elastic_mapping:"name: { type: keyword }"` - Nickname string `json:"nick_name" elastic_mapping:"nick_name: { type: keyword }"` - Password string `json:"password" elastic_mapping:"password: { type: keyword }"` - Email string `json:"email" elastic_mapping:"email: { type: keyword }"` - Phone string `json:"phone" elastic_mapping:"phone: { type: keyword }"` - Tags []string `json:"tags" elastic_mapping:"mobile: { type: keyword }"` + AuthProvider string `json:"auth_provider" elastic_mapping:"auth_provider: { type: keyword }"` + Username string `json:"name" elastic_mapping:"name: { type: keyword }"` + Nickname string `json:"nick_name" elastic_mapping:"nick_name: { type: keyword }"` + Password string `json:"password" elastic_mapping:"password: { type: keyword }"` + PasswordSalt string `json:"password_salt,omitempty" elastic_mapping:"password_salt: { type: keyword }"` + PasswordVerifier string `json:"password_verifier,omitempty" elastic_mapping:"password_verifier: { type: keyword }"` + Email string `json:"email" elastic_mapping:"email: { type: keyword }"` + Phone string `json:"phone" elastic_mapping:"phone: { type: keyword }"` + Tags []string `json:"tags" elastic_mapping:"mobile: { type: keyword }"` AvatarUrl string `json:"avatar_url" elastic_mapping:"avatar_url: { type: keyword }"` Roles []UserRole `json:"roles" elastic_mapping:"roles: { type: object }"` diff --git a/core/security/validate.go b/core/security/validate.go index 44f94fba..72177232 100644 --- a/core/security/validate.go +++ b/core/security/validate.go @@ -60,6 +60,13 @@ type IndexRequest struct { type ElasticsearchAPIPrivilege map[string]map[string]struct{} +var legacyPermissionAliases = map[string][]string{ + "template.delete": {"indices.delete_template"}, + "template.exists": {"indices.exists_template"}, + "template.get": {"indices.get_template"}, + "template.put": {"indices.put_template"}, +} + func (ep ElasticsearchAPIPrivilege) Merge(epa ElasticsearchAPIPrivilege) { for k, permissions := range epa { if _, ok := ep[k]; ok { @@ -98,6 +105,18 @@ func NewClusterRequest(ps httprouter.Params, privilege []string) ClusterRequest } } +func hasPermissionOrAlias(permissions map[string]struct{}, permission string) bool { + if _, ok := permissions[permission]; ok { + return true + } + for _, alias := range legacyPermissionAliases[permission] { + if _, ok := permissions[alias]; ok { + return true + } + } + return false +} + func validateApiPermission(apiPrivileges map[string]struct{}, permissions map[string]struct{}) { if _, ok := permissions["*"]; ok { for privilege := range apiPrivileges { @@ -105,8 +124,8 @@ func validateApiPermission(apiPrivileges map[string]struct{}, permissions map[st } return } - for permission := range permissions { - if _, ok := apiPrivileges[permission]; ok { + for permission := range apiPrivileges { + if hasPermissionOrAlias(permissions, permission) { delete(apiPrivileges, permission) } } @@ -117,7 +136,7 @@ func validateApiPermission(apiPrivileges map[string]struct{}, permissions map[st } prefix := privilege[:position] - if _, ok := permissions[prefix+".*"]; ok { + if hasPermissionOrAlias(permissions, prefix+".*") { delete(apiPrivileges, privilege) } } @@ -313,18 +332,22 @@ func GetRoleIndex(roles []string, clusterID string) (bool, []string) { return false, realIndex } -func ValidateLogin(authorizationHeader string) (clams *UserClaims, err error) { - - if authorizationHeader == "" { - err = errors.New("authorization header is empty") - return +func ParseBearerToken(authorizationHeader string) (string, error) { + if strings.TrimSpace(authorizationHeader) == "" { + return "", errors.New("authorization header is empty") } fields := strings.Fields(authorizationHeader) - if fields[0] != "Bearer" || len(fields) != 2 { - err = errors.New("authorization header is invalid") - return + if len(fields) != 2 || !strings.EqualFold(fields[0], "Bearer") { + return "", errors.New("authorization header is invalid") + } + return fields[1], nil +} + +func ValidateLogin(authorizationHeader string) (clams *UserClaims, err error) { + tokenString, err := ParseBearerToken(authorizationHeader) + if err != nil { + return nil, err } - tokenString := fields[1] token, err := jwt.ParseWithClaims(tokenString, &UserClaims{}, func(token *jwt.Token) (interface{}, error) { if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { @@ -352,6 +375,14 @@ func ValidateLogin(authorizationHeader string) (clams *UserClaims, err error) { DeleteUserToken(clams.UserId) return } + activeToken := tokenVal.Value + if activeToken == "" { + activeToken = tokenVal.JwtStr + } + if activeToken != "" && activeToken != tokenString { + err = errors.New("token is invalid") + return + } if ok && token.Valid { return clams, nil } diff --git a/core/security/validate_test.go b/core/security/validate_test.go new file mode 100644 index 00000000..38b69621 --- /dev/null +++ b/core/security/validate_test.go @@ -0,0 +1,163 @@ +// Copyright (C) INFINI Labs & INFINI LIMITED. +// +// The INFINI Console is offered under the GNU Affero General Public License v3.0 +// and as commercial software. +// +// For commercial licensing, contact us at: +// - Website: infinilabs.com +// - Email: hello@infini.ltd +// +// Open Source licensed under AGPL V3: +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package security + +import ( + "github.com/golang-jwt/jwt" + "testing" + "time" +) + +func TestValidateAPIPermissionSupportsLegacyTemplateAliases(t *testing.T) { + tests := []struct { + name string + privilege string + permission string + }{ + { + name: "template delete", + privilege: "template.delete", + permission: "indices.delete_template", + }, + { + name: "template exists", + privilege: "template.exists", + permission: "indices.exists_template", + }, + { + name: "template get", + privilege: "template.get", + permission: "indices.get_template", + }, + { + name: "template put", + privilege: "template.put", + permission: "indices.put_template", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + apiPrivileges := map[string]struct{}{ + tt.privilege: {}, + } + permissions := map[string]struct{}{ + tt.permission: {}, + } + + validateApiPermission(apiPrivileges, permissions) + + if len(apiPrivileges) != 0 { + t.Fatalf("expected legacy permission %q to satisfy %q", tt.permission, tt.privilege) + } + }) + } +} + +func issueTestToken(t *testing.T, userID string) string { + t.Helper() + + expireAt := time.Now().Add(time.Hour) + token := jwt.NewWithClaims(jwt.SigningMethodHS256, UserClaims{ + ShortUser: &ShortUser{ + Provider: "native", + Username: "tester", + UserId: userID, + }, + RegisteredClaims: &jwt.RegisteredClaims{ + ExpiresAt: jwt.NewNumericDate(expireAt), + }, + }) + + tokenString, err := token.SignedString([]byte(Secret)) + if err != nil { + t.Fatalf("sign token: %v", err) + } + + userTokenLocker.Lock() + tokenMap[userID] = Token{ + Value: tokenString, + ExpireIn: expireAt.Unix(), + } + userTokenLocker.Unlock() + + t.Cleanup(func() { + userTokenLocker.Lock() + delete(tokenMap, userID) + userTokenLocker.Unlock() + }) + + return tokenString +} + +func TestValidateLoginRejectsReplacedActiveToken(t *testing.T) { + userID := "user-revoked" + authorizationHeader := "Bearer " + issueTestToken(t, userID) + if _, err := ValidateLogin(authorizationHeader); err != nil { + t.Fatalf("expected token to validate before revoke, got %v", err) + } + + userTokenLocker.Lock() + tokenMap[userID] = Token{ + Value: "replaced-token", + ExpireIn: time.Now().Add(time.Hour).Unix(), + } + userTokenLocker.Unlock() + + if _, err := ValidateLogin(authorizationHeader); err == nil { + t.Fatal("expected replaced token to be rejected") + } +} + +func TestValidateLoginRejectsStaleTokenValue(t *testing.T) { + userID := "user-stale" + authorizationHeader := "Bearer " + issueTestToken(t, userID) + + userTokenLocker.Lock() + tokenMap[userID] = Token{ + Value: "replacement-token", + ExpireIn: time.Now().Add(time.Hour).Unix(), + } + userTokenLocker.Unlock() + + if _, err := ValidateLogin(authorizationHeader); err == nil { + t.Fatal("expected stale token to be rejected") + } +} + +func TestValidateLoginSupportsLegacyJwtField(t *testing.T) { + userID := "user-legacy" + tokenString := issueTestToken(t, userID) + + userTokenLocker.Lock() + tokenMap[userID] = Token{ + JwtStr: tokenString, + ExpireIn: time.Now().Add(time.Hour).Unix(), + } + userTokenLocker.Unlock() + + if _, err := ValidateLogin("Bearer " + tokenString); err != nil { + t.Fatalf("expected legacy jwt field to remain valid, got %v", err) + } +} diff --git a/info_api.go b/info_api.go new file mode 100644 index 00000000..5c1e611e --- /dev/null +++ b/info_api.go @@ -0,0 +1,52 @@ +// Copyright (C) INFINI Labs & INFINI LIMITED. +// +// The INFINI Console is offered under the GNU Affero General Public License v3.0 +// and as commercial software. +// +// For commercial licensing, contact us at: +// - Website: infinilabs.com +// - Email: hello@infini.ltd +// +// Open Source licensed under AGPL V3: +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package main + +import ( + console_common "infini.sh/console/common" + "infini.sh/framework/core/api" + httprouter "infini.sh/framework/core/api/router" + framework_model "infini.sh/framework/core/model" + "infini.sh/framework/core/util" + "net/http" +) + +func init() { + api.HandleAPIMethod(api.GET, "/_info", sanitizedInfoAPIHandler) + api.HandleUIMethod(api.GET, "/_info", sanitizedInfoAPIHandler, api.RequireLogin()) +} + +func sanitizedInfoAPIHandler(w http.ResponseWriter, req *http.Request, ps httprouter.Params) { + info := framework_model.GetInstanceInfo() + payload := console_common.SanitizeInstanceInfoMap(util.MapStr{ + "id": info.ID, + "name": info.Name, + "application": info.Application, + "labels": info.Labels, + "tags": info.Tags, + "description": info.Description, + "status": info.Status, + }) + api.DefaultAPI.WriteJSON(w, payload, http.StatusOK) +} diff --git a/modules/agent/api/elasticsearch.go b/modules/agent/api/elasticsearch.go index e6c32c74..606bcfd3 100644 --- a/modules/agent/api/elasticsearch.go +++ b/modules/agent/api/elasticsearch.go @@ -38,6 +38,7 @@ import ( "github.com/buger/jsonparser" log "github.com/cihub/seelog" + console_common "infini.sh/console/common" "infini.sh/console/plugin/managed/server" httprouter "infini.sh/framework/core/api/router" "infini.sh/framework/core/elastic" @@ -45,7 +46,6 @@ import ( "infini.sh/framework/core/model" "infini.sh/framework/core/orm" "infini.sh/framework/core/util" - "infini.sh/framework/modules/elastic/adapter" "infini.sh/framework/modules/elastic/common" "infini.sh/framework/modules/elastic/metadata" ) @@ -170,7 +170,7 @@ func refreshNodesInfo(instanceID, instanceEndpoint string) (*elastic.DiscoveryRe continue } } else { - clusterInfo, err = adapter.ClusterVersion(elastic.GetMetadata(v.ClusterID)) + clusterInfo, err = console_common.ClusterVersion(elastic.GetMetadata(v.ClusterID)) if err != nil || clusterInfo == nil { log.Error(err) continue diff --git a/modules/elastic/api/cluster_overview.go b/modules/elastic/api/cluster_overview.go index 0519646a..1cd14781 100644 --- a/modules/elastic/api/cluster_overview.go +++ b/modules/elastic/api/cluster_overview.go @@ -1326,7 +1326,7 @@ func (h *APIHandler) SearchClusterMetadata(w http.ResponseWriter, req *http.Requ } } - clusterFilter, hasAllPrivilege := h.GetClusterFilter(req, "_id") + clusterFilter, hasAllPrivilege := h.GetClusterFilter(req, "id") if !hasAllPrivilege && clusterFilter == nil { h.WriteJSON(w, elastic.SearchResponse{}, http.StatusOK) return diff --git a/modules/elastic/api/init.go b/modules/elastic/api/init.go index 59cc41e1..1c0cbc0a 100644 --- a/modules/elastic/api/init.go +++ b/modules/elastic/api/init.go @@ -43,11 +43,11 @@ func init() { api.HandleAPIMethod(api.GET, "/elasticsearch/:id/queue_metrics", clusterAPI.RequireClusterPermission(clusterAPI.HandleQueueMetricsAction)) api.HandleAPIMethod(api.GET, "/elasticsearch/:id/storage_metrics", clusterAPI.RequireClusterPermission(clusterAPI.HandleGetStorageMetricAction)) - api.HandleAPIMethod(api.POST, "/elasticsearch/", clusterAPI.RequirePermission(clusterAPI.HandleCreateClusterAction, enum.PermissionElasticsearchClusterWrite)) + api.HandleAPIMethod(api.POST, "/elasticsearch/", clusterAPI.RequireSecureTransport(clusterAPI.RequireReplayProtection(clusterAPI.RequirePermission(clusterAPI.HandleCreateClusterAction, enum.PermissionElasticsearchClusterWrite)))) api.HandleAPIMethod(api.GET, "/elasticsearch/indices", clusterAPI.RequireLogin(clusterAPI.ListIndex)) api.HandleAPIMethod(api.GET, "/elasticsearch/status", clusterAPI.RequireLogin(clusterAPI.GetClusterStatusAction)) api.HandleAPIMethod(api.GET, "/elasticsearch/:id", clusterAPI.RequireClusterPermission(clusterAPI.RequirePermission(clusterAPI.HandleGetClusterAction, enum.PermissionElasticsearchClusterRead))) - api.HandleAPIMethod(api.PUT, "/elasticsearch/:id", clusterAPI.RequireClusterPermission(clusterAPI.RequirePermission(clusterAPI.HandleUpdateClusterAction, enum.PermissionElasticsearchClusterWrite))) + api.HandleAPIMethod(api.PUT, "/elasticsearch/:id", clusterAPI.RequireSecureTransport(clusterAPI.RequireReplayProtection(clusterAPI.RequireClusterPermission(clusterAPI.RequirePermission(clusterAPI.HandleUpdateClusterAction, enum.PermissionElasticsearchClusterWrite))))) api.HandleAPIMethod(api.DELETE, "/elasticsearch/:id", clusterAPI.RequireClusterPermission(clusterAPI.RequirePermission(clusterAPI.HandleDeleteClusterAction, enum.PermissionElasticsearchClusterWrite))) api.HandleAPIMethod(api.GET, "/elasticsearch/_search", clusterAPI.RequirePermission(clusterAPI.HandleSearchClusterAction, enum.PermissionElasticsearchClusterRead)) api.HandleAPIMethod(api.POST, "/elasticsearch/_search", clusterAPI.RequirePermission(clusterAPI.HandleSearchClusterAction, enum.PermissionElasticsearchClusterRead)) diff --git a/modules/elastic/api/manage.go b/modules/elastic/api/manage.go index 0928e00e..cb3b257c 100644 --- a/modules/elastic/api/manage.go +++ b/modules/elastic/api/manage.go @@ -37,6 +37,7 @@ import ( "infini.sh/framework/core/queue" log "github.com/cihub/seelog" + console_common "infini.sh/console/common" "infini.sh/console/core" v1 "infini.sh/console/modules/elastic/api/v1" httprouter "infini.sh/framework/core/api/router" @@ -60,13 +61,15 @@ func (h *APIHandler) Client() elastic.API { } func (h *APIHandler) HandleCreateClusterAction(w http.ResponseWriter, req *http.Request, ps httprouter.Params) { - var conf = &elastic.ElasticsearchConfig{} - err := h.DecodeJSON(req, conf) + payload := &elasticConfigPayload{} + err := h.DecodeJSON(req, payload) if err != nil { log.Error(err) h.WriteError(w, err.Error(), http.StatusInternalServerError) return } + conf := &payload.ElasticsearchConfig + console_common.SetProbePath(conf, payload.ProbePath) conf.Enabled = true if len(conf.Hosts) > 0 && conf.Host == "" { conf.Host = conf.Hosts[0] @@ -225,6 +228,10 @@ func (h *APIHandler) HandleUpdateClusterAction(w http.ResponseWriter, req *http. newConf := &elastic.ElasticsearchConfig{} json.Unmarshal(confBytes, newConf) newConf.ID = id + if probePath, ok := conf["probe_path"]; ok { + console_common.SetProbePath(newConf, util.ToString(probePath)) + delete(conf, "probe_path") + } if conf["credential_id"] == nil { if newConf.BasicAuth != nil && newConf.BasicAuth.Username != "" { @@ -380,7 +387,7 @@ func (h *APIHandler) HandleSearchClusterAction(w http.ResponseWriter, req *http. name = h.GetParameterOrDefault(req, "name", "") sortField = h.GetParameterOrDefault(req, "sort_field", "") sortOrder = h.GetParameterOrDefault(req, "sort_order", "") - queryDSL = `{"query":{"bool":{"must":[%s]}}, "size": %d, "from": %d%s}` + queryDSL = `{"query":{"bool":{"must":[%s]}}, "size": %d, "from": %d%s%s}` strSize = h.GetParameterOrDefault(req, "size", "20") strFrom = h.GetParameterOrDefault(req, "from", "0") mustBuilder = &strings.Builder{} @@ -388,7 +395,7 @@ func (h *APIHandler) HandleSearchClusterAction(w http.ResponseWriter, req *http. if name != "" { mustBuilder.WriteString(fmt.Sprintf(`{"prefix":{"name.text": "%s"}}`, name)) } - clusterFilter, hasAllPrivilege := h.GetClusterFilter(req, "_id") + clusterFilter, hasAllPrivilege := h.GetClusterFilter(req, "id") if !hasAllPrivilege && clusterFilter == nil { h.WriteJSON(w, elastic.SearchResponse{}, http.StatusOK) return @@ -399,6 +406,9 @@ func (h *APIHandler) HandleSearchClusterAction(w http.ResponseWriter, req *http. } mustBuilder.Write(util.MustToJSONBytes(clusterFilter)) } + if mustBuilder.Len() == 0 { + mustBuilder.WriteString(`{"match_all":{}}`) + } size, _ := strconv.Atoi(strSize) if size <= 0 { @@ -408,23 +418,38 @@ func (h *APIHandler) HandleSearchClusterAction(w http.ResponseWriter, req *http. if from < 0 { from = 0 } - var sort = "" + var ( + functions = "" + sort = "" + trackScore = "" + ) if sortField != "" && sortOrder != "" { sort = fmt.Sprintf(`,"sort":[{"%s":{"order":"%s"}}]`, sortField, sortOrder) + } else { + functions = `,"functions":[{"filter":{"term":{"labels.health_status":"red"}},"weight":300},{"filter":{"term":{"labels.health_status":"yellow"}},"weight":200},{"filter":{"term":{"labels.health_status":"unavailable"}},"weight":100},{"filter":{"term":{"labels.health_status":"green"}},"weight":1}]` + sort = `,"sort":[{"_score":{"order":"desc"}},{"name.keyword":{"order":"asc","unmapped_type":"keyword"}}]` + trackScore = `,"track_scores":true` + queryDSL = `{"query":{"function_score":{"query":{"bool":{"must":[%s]}}%s,"score_mode":"sum","boost_mode":"replace"}}, "size": %d, "from": %d%s%s}` } - queryDSL = fmt.Sprintf(queryDSL, mustBuilder.String(), size, from, sort) + queryDSL = fmt.Sprintf(queryDSL, mustBuilder.String(), functions, size, from, sort, trackScore) q := orm.Query{ RawQuery: []byte(queryDSL), } err, result := orm.Search(elastic.ElasticsearchConfig{}, &q) if err != nil { + if global.Env().IsDebug { + log.Errorf("cluster search failed, name=%q, from=%d, size=%d, dsl=%s, err=%v", name, from, size, queryDSL, err) + } log.Error(err) h.WriteError(w, err.Error(), http.StatusInternalServerError) return } searchRes := elastic.SearchResponse{} util.MustFromJSONBytes(result.Raw, &searchRes) + if global.Env().IsDebug && len(searchRes.Hits.Hits) == 0 { + log.Debugf("cluster search returned zero hits, name=%q, from=%d, size=%d, dsl=%s", name, from, size, queryDSL) + } if len(searchRes.Hits.Hits) > 0 { for _, hit := range searchRes.Hits.Hits { if basicAuth, ok := hit.Source["basic_auth"]; ok { diff --git a/modules/elastic/api/test_connection.go b/modules/elastic/api/test_connection.go index d37bc02c..258d8d0c 100644 --- a/modules/elastic/api/test_connection.go +++ b/modules/elastic/api/test_connection.go @@ -28,8 +28,10 @@ package api import ( + "context" "fmt" "github.com/segmentio/encoding/json" + console_common "infini.sh/console/common" "infini.sh/console/core" "infini.sh/framework/core/api" httprouter "infini.sh/framework/core/api/router" @@ -47,33 +49,42 @@ type TestAPI struct { core.Handler } +const ( + tryConnectErrorKeyHealthRed = "cluster.connect.error.health_red" + tryConnectErrorKeyNonESEndpoint = "cluster.connect.error.non_es_endpoint" + tryConnectErrorKeyTLSMismatch = "cluster.connect.error.tls_mismatch" + tryConnectErrorKeyAuthRequired = "cluster.connect.error.auth_required" + tryConnectErrorKeyEndpointUnreachable = "cluster.connect.error.endpoint_unreachable" + tryConnectErrorKeyUnexpectedStatus = "cluster.connect.error.unexpected_status" + tryConnectErrorKeyDefault = "cluster.regist.try_connect.failed" +) + +type elasticConfigPayload struct { + elastic.ElasticsearchConfig + ProbePath string `json:"probe_path,omitempty"` +} + var testAPI = TestAPI{} var testInited bool func InitTestAPI() { if !testInited { - api.HandleAPIMethod(api.POST, "/elasticsearch/try_connect", testAPI.HandleTestConnectionAction) + api.HandleAPIMethod(api.POST, "/elasticsearch/try_connect", testAPI.RequireSecureTransport(testAPI.RequireReplayProtection(testAPI.HandleTestConnectionAction))) testInited = true } } func (h TestAPI) HandleTestConnectionAction(w http.ResponseWriter, req *http.Request, ps httprouter.Params) { - var ( - freq = httpPool.AcquireRequest() - fres = httpPool.AcquireResponse() - resBody = map[string]interface{}{} - ) - defer func() { - httpPool.ReleaseRequest(freq) - httpPool.ReleaseResponse(fres) - }() - var config = &elastic.ElasticsearchConfig{} - err := h.DecodeJSON(req, &config) + var resBody = map[string]interface{}{} + payload := &elasticConfigPayload{} + err := h.DecodeJSON(req, payload) if err != nil { panic(err) } defer req.Body.Close() + config := &payload.ElasticsearchConfig + console_common.SetProbePath(config, payload.ProbePath) var url string if config.Endpoint != "" { url = config.Endpoint @@ -123,38 +134,20 @@ func (h TestAPI) HandleTestConnectionAction(w http.ResponseWriter, req *http.Req clusterUUID string ) for i, url = range config.Endpoints { - if !util.SuffixStr(url, "/") { - url = fmt.Sprintf("%s/", url) - } - - freq.SetRequestURI(url) - freq.Header.SetMethod("GET") - - if config.BasicAuth != nil && strings.TrimSpace(config.BasicAuth.Username) != "" { - freq.SetBasicAuth(config.BasicAuth.Username, config.BasicAuth.Password.Get()) - } - - const testClientName = "elasticsearch_test_connection" - err = api.GetFastHttpClient(testClientName).DoTimeout(freq, fres, 10*time.Second) - + clusterInfo, err := console_common.ClusterVersionWithConfig(&elastic.ElasticsearchConfig{ + Schema: config.Schema, + Endpoint: url, + Endpoints: []string{url}, + Distribution: config.Distribution, + BasicAuth: config.BasicAuth, + RequestTimeout: 10, + Labels: config.Labels, + }) if err != nil { - panic(err) - } - - var statusCode = fres.StatusCode() - if statusCode > 300 || statusCode == 0 { - resBody["error"] = fmt.Sprintf("invalid status code: %d", statusCode) - h.WriteJSON(w, resBody, 500) + writeTryConnectError(h, w, err) return } - b := fres.Body() - clusterInfo := &elastic.ClusterInformation{} - err = json.Unmarshal(b, clusterInfo) - if err != nil { - panic(err) - } - resBody["version"] = clusterInfo.Version.Number resBody["cluster_uuid"] = clusterInfo.ClusterUUID resBody["cluster_name"] = clusterInfo.ClusterName @@ -173,19 +166,9 @@ func (h TestAPI) HandleTestConnectionAction(w http.ResponseWriter, req *http.Req break } //fetch cluster health info - freq.SetRequestURI(fmt.Sprintf("%s/_cluster/health", url)) - fres.Reset() - err = api.GetFastHttpClient(testClientName).Do(freq, fres) - if err != nil { - resBody["error"] = fmt.Sprintf("error on get cluster health: %v", err) - h.WriteJSON(w, resBody, http.StatusInternalServerError) - return - } - - healthInfo := &elastic.ClusterHealth{} - err = json.Unmarshal(fres.Body(), &healthInfo) + healthInfo, err := fetchClusterHealth(url, config) if err != nil { - resBody["error"] = fmt.Sprintf("error on decode cluster health info : %v", err) + resBody["error"] = buildTryConnectErrorPayload(err) h.WriteJSON(w, resBody, http.StatusInternalServerError) return } @@ -195,15 +178,141 @@ func (h TestAPI) HandleTestConnectionAction(w http.ResponseWriter, req *http.Req resBody["active_shards"] = healthInfo.ActiveShards if healthInfo.Status == "red" { - resBody["error"] = "cluster health status is red, please fix the cluster before connecting" + resBody["error"] = buildTryConnectErrorPayload(errors.New("cluster health status is red, please fix the cluster before connecting")) h.WriteJSON(w, resBody, http.StatusInternalServerError) return } - - freq.Reset() - fres.Reset() } h.WriteJSON(w, resBody, http.StatusOK) } + +func writeTryConnectError(h TestAPI, w http.ResponseWriter, err error) { + h.WriteJSON(w, map[string]interface{}{ + "error": buildTryConnectErrorPayload(err), + }, http.StatusInternalServerError) +} + +func buildTryConnectErrorPayload(err error) map[string]interface{} { + reason, key := resolveTryConnectError(err) + return map[string]interface{}{ + "reason": reason, + "key": key, + } +} + +func sanitizeTryConnectError(err error) string { + reason, _ := resolveTryConnectError(err) + return reason +} + +func sanitizeTryConnectErrorKey(err error) string { + _, key := resolveTryConnectError(err) + return key +} + +func resolveTryConnectError(err error) (string, string) { + raw := extractTryConnectReason(err) + lowerRaw := strings.ToLower(raw) + + switch { + case strings.Contains(lowerRaw, "cluster health status is red"): + return "cluster health status is red, please fix the cluster before connecting", tryConnectErrorKeyHealthRed + case strings.Contains(lowerRaw, "invalid character '<' looking for beginning of value"), + strings.Contains(lowerRaw, ""), + strings.Contains(lowerRaw, " 300 || res.StatusCode == 0 { + return nil, errors.New(fmt.Sprintf("invalid status code: %d", res.StatusCode)) + } + + healthInfo := &elastic.ClusterHealth{} + err = json.Unmarshal(res.Body, healthInfo) + if err != nil { + return nil, err + } + return healthInfo, nil +} diff --git a/modules/elastic/api/test_connection_test.go b/modules/elastic/api/test_connection_test.go new file mode 100644 index 00000000..5316c0b9 --- /dev/null +++ b/modules/elastic/api/test_connection_test.go @@ -0,0 +1,98 @@ +// Copyright (C) INFINI Labs & INFINI LIMITED. +// +// The INFINI Console is offered under the GNU Affero General Public License v3.0 +// and as commercial software. +// +// For commercial licensing, contact us at: +// - Website: infinilabs.com +// - Email: hello@infini.ltd +// +// Open Source licensed under AGPL V3: +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package api + +import ( + "errors" + "testing" +) + +func TestSanitizeTryConnectError(t *testing.T) { + tests := []struct { + name string + err error + want string + }{ + { + name: "connection refused", + err: errors.New(`Get "https://127.0.0.1:9200": dial tcp 127.0.0.1:9200: connect: connection refused`), + want: "unable to connect to the cluster endpoint, please check the address, network accessibility, and TLS setting", + }, + { + name: "tls mismatch", + err: errors.New(`Get "https://127.0.0.1:9200": http: server gave HTTP response to HTTPS client`), + want: "TLS setting does not match the cluster endpoint, please check whether HTTPS is enabled", + }, + { + name: "security exception json", + err: errors.New(`{"error":{"root_cause":[{"type":"security_exception","reason":"Missing authentication information for REST request [/]"}],"type":"security_exception","reason":"Missing authentication information for REST request [/]"},"status":401}`), + want: "authentication is required or invalid, please check the credential", + }, + { + name: "html response", + err: errors.New(`json: invalid character '<' looking for beginning of value: `), + want: "the endpoint did not return an Elasticsearch-compatible API response, please check the address and port", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := sanitizeTryConnectError(tt.err); got != tt.want { + t.Fatalf("expected %q, got %q", tt.want, got) + } + }) + } +} + +func TestSanitizeTryConnectErrorKey(t *testing.T) { + tests := []struct { + name string + err error + want string + }{ + { + name: "connection refused", + err: errors.New(`Get "https://127.0.0.1:9200": dial tcp 127.0.0.1:9200: connect: connection refused`), + want: tryConnectErrorKeyEndpointUnreachable, + }, + { + name: "tls mismatch", + err: errors.New(`Get "https://127.0.0.1:9200": http: server gave HTTP response to HTTPS client`), + want: tryConnectErrorKeyTLSMismatch, + }, + { + name: "html response", + err: errors.New(`json: invalid character '<' looking for beginning of value: `), + want: tryConnectErrorKeyNonESEndpoint, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := sanitizeTryConnectErrorKey(tt.err); got != tt.want { + t.Fatalf("expected %q, got %q", tt.want, got) + } + }) + } +} diff --git a/modules/elastic/api/v1/cluster_overview.go b/modules/elastic/api/v1/cluster_overview.go index 53955259..ce35ce23 100644 --- a/modules/elastic/api/v1/cluster_overview.go +++ b/modules/elastic/api/v1/cluster_overview.go @@ -973,7 +973,7 @@ func (h *APIHandler) SearchClusterMetadata(w http.ResponseWriter, req *http.Requ } } - clusterFilter, hasAllPrivilege := h.GetClusterFilter(req, "_id") + clusterFilter, hasAllPrivilege := h.GetClusterFilter(req, "id") if !hasAllPrivilege && clusterFilter == nil { h.WriteJSON(w, elastic.SearchResponse{}, http.StatusOK) return diff --git a/modules/elastic/api/v1/manage.go b/modules/elastic/api/v1/manage.go index ce2eb654..c0cf1b10 100644 --- a/modules/elastic/api/v1/manage.go +++ b/modules/elastic/api/v1/manage.go @@ -292,7 +292,7 @@ func (h *APIHandler) HandleSearchClusterAction(w http.ResponseWriter, req *http. name = h.GetParameterOrDefault(req, "name", "") sortField = h.GetParameterOrDefault(req, "sort_field", "") sortOrder = h.GetParameterOrDefault(req, "sort_order", "") - queryDSL = `{"query":{"bool":{"must":[%s]}}, "size": %d, "from": %d%s}` + queryDSL = `{"query":{"bool":{"must":[%s]}}, "size": %d, "from": %d%s%s}` strSize = h.GetParameterOrDefault(req, "size", "20") strFrom = h.GetParameterOrDefault(req, "from", "0") mustBuilder = &strings.Builder{} @@ -300,7 +300,7 @@ func (h *APIHandler) HandleSearchClusterAction(w http.ResponseWriter, req *http. if name != "" { mustBuilder.WriteString(fmt.Sprintf(`{"prefix":{"name.text": "%s"}}`, name)) } - clusterFilter, hasAllPrivilege := h.GetClusterFilter(req, "_id") + clusterFilter, hasAllPrivilege := h.GetClusterFilter(req, "id") if !hasAllPrivilege && clusterFilter == nil { h.WriteJSON(w, elastic.SearchResponse{}, http.StatusOK) return @@ -311,6 +311,9 @@ func (h *APIHandler) HandleSearchClusterAction(w http.ResponseWriter, req *http. } mustBuilder.Write(util.MustToJSONBytes(clusterFilter)) } + if mustBuilder.Len() == 0 { + mustBuilder.WriteString(`{"match_all":{}}`) + } size, _ := strconv.Atoi(strSize) if size <= 0 { @@ -320,23 +323,38 @@ func (h *APIHandler) HandleSearchClusterAction(w http.ResponseWriter, req *http. if from < 0 { from = 0 } - var sort = "" + var ( + functions = "" + sort = "" + trackScore = "" + ) if sortField != "" && sortOrder != "" { sort = fmt.Sprintf(`,"sort":[{"%s":{"order":"%s"}}]`, sortField, sortOrder) + } else { + functions = `,"functions":[{"filter":{"term":{"labels.health_status":"red"}},"weight":300},{"filter":{"term":{"labels.health_status":"yellow"}},"weight":200},{"filter":{"term":{"labels.health_status":"unavailable"}},"weight":100},{"filter":{"term":{"labels.health_status":"green"}},"weight":1}]` + sort = `,"sort":[{"_score":{"order":"desc"}},{"name.keyword":{"order":"asc","unmapped_type":"keyword"}}]` + trackScore = `,"track_scores":true` + queryDSL = `{"query":{"function_score":{"query":{"bool":{"must":[%s]}}%s,"score_mode":"sum","boost_mode":"replace"}}, "size": %d, "from": %d%s%s}` } - queryDSL = fmt.Sprintf(queryDSL, mustBuilder.String(), size, from, sort) + queryDSL = fmt.Sprintf(queryDSL, mustBuilder.String(), functions, size, from, sort, trackScore) q := orm.Query{ RawQuery: []byte(queryDSL), } err, result := orm.Search(elastic.ElasticsearchConfig{}, &q) if err != nil { + if global.Env().IsDebug { + log.Errorf("cluster search failed, name=%q, from=%d, size=%d, dsl=%s, err=%v", name, from, size, queryDSL, err) + } log.Error(err) h.WriteError(w, err.Error(), http.StatusInternalServerError) return } searchRes := elastic.SearchResponse{} util.MustFromJSONBytes(result.Raw, &searchRes) + if global.Env().IsDebug && len(searchRes.Hits.Hits) == 0 { + log.Debugf("cluster search returned zero hits, name=%q, from=%d, size=%d, dsl=%s", name, from, size, queryDSL) + } if len(searchRes.Hits.Hits) > 0 { for _, hit := range searchRes.Hits.Hits { if basicAuth, ok := hit.Source["basic_auth"]; ok { diff --git a/modules/security/api/account.go b/modules/security/api/account.go index f6a9522e..182378f4 100644 --- a/modules/security/api/account.go +++ b/modules/security/api/account.go @@ -37,6 +37,7 @@ import ( httprouter "infini.sh/framework/core/api/router" "infini.sh/framework/core/util" "net/http" + "strings" ) const userInSession = "user_session:" @@ -48,14 +49,65 @@ const NativeProvider = "native" func (h APIHandler) Logout(w http.ResponseWriter, r *http.Request, ps httprouter.Params) { reqUser, err := rbac.FromUserContext(r.Context()) + if err != nil { + if api.IsAuthEnable() { + claims, validateErr := rbac.ValidateLogin(r.Header.Get("Authorization")) + if validateErr != nil { + h.WriteError(w, validateErr.Error(), http.StatusUnauthorized) + return + } + reqUser = claims.ShortUser + } + } + + if reqUser != nil && reqUser.UserId != "" { + rbac.DeleteUserToken(reqUser.UserId) + } + h.WriteOKJSON(w, util.MapStr{ + "status": "ok", + }) +} + +func (h APIHandler) LoginChallenge(w http.ResponseWriter, r *http.Request, ps httprouter.Params) { + var req struct { + Username string `json:"username"` + UserName string `json:"userName"` + } + err := h.DecodeJSON(r, &req) + if err != nil { + h.Error400(w, err.Error()) + return + } + + username := normalizedUsername(req.Username, req.UserName) + if username == "" { + h.Error400(w, "username is required") + return + } + + user, err := h.User.GetBy("name", username) if err != nil { h.ErrorInternalServer(w, err.Error()) return } - rbac.DeleteUserToken(reqUser.UserId) + if user == nil || !rbac.CanUsePasswordChallenge(user) { + h.WriteOKJSON(w, util.MapStr{ + "status": "ok", + "method": "plain", + }) + return + } + + challenge := rbac.NewLoginChallenge(username) h.WriteOKJSON(w, util.MapStr{ - "status": "ok", + "status": "ok", + "method": rbac.PasswordChallengeMethod, + "algorithm": rbac.PasswordChallengeAlgorithm, + "iterations": rbac.PasswordChallengeIterations, + "challenge_id": challenge.ID, + "nonce": challenge.Nonce, + "salt": user.PasswordSalt, }) } @@ -128,12 +180,11 @@ func (h APIHandler) UpdatePassword(w http.ResponseWriter, r *http.Request, ps ht h.ErrorInternalServer(w, "old password is not correct") return } - hash, err := bcrypt.GenerateFromPassword([]byte(req.NewPassword), bcrypt.DefaultCost) + err = rbac.SetPassword(&user, req.NewPassword) if err != nil { h.ErrorInternalServer(w, err.Error()) return } - user.Password = string(hash) err = h.User.Update(&user) if err != nil { h.ErrorInternalServer(w, err.Error()) @@ -178,8 +229,11 @@ func (h APIHandler) UpdateProfile(w http.ResponseWriter, r *http.Request, ps htt func (h APIHandler) Login(w http.ResponseWriter, r *http.Request, ps httprouter.Params) { var req struct { - Username string `json:"username"` - Password string `json:"password"` + Username string `json:"username"` + UserName string `json:"userName"` + Password string `json:"password"` + ChallengeID string `json:"challenge_id"` + Proof string `json:"proof"` } err := h.DecodeJSON(r, &req) if err != nil { @@ -188,9 +242,19 @@ func (h APIHandler) Login(w http.ResponseWriter, r *http.Request, ps httprouter. } var user *rbac.User + username := normalizedUsername(req.Username, req.UserName) + if username == "" { + h.Error400(w, "username is required") + return + } //check user validation - ok, user, err := realm.Authenticate(req.Username, req.Password) + var ok bool + if req.ChallengeID != "" || req.Proof != "" { + ok, user, err = h.authenticateChallengeLogin(username, req.ChallengeID, req.Proof) + } else { + ok, user, err = realm.Authenticate(username, req.Password) + } if err != nil { h.WriteError(w, err.Error(), 500) return @@ -202,14 +266,20 @@ func (h APIHandler) Login(w http.ResponseWriter, r *http.Request, ps httprouter. } if user == nil { - h.ErrorInternalServer(w, fmt.Sprintf("failed to authenticate user: %v", req.Username)) + h.ErrorInternalServer(w, fmt.Sprintf("failed to authenticate user: %v", username)) return } + if user.AuthProvider == NativeProvider && req.Password != "" { + if err := h.ensurePasswordChallenge(user, req.Password); err != nil { + log.Warnf("failed to migrate password verifier for user [%s]: %v", username, err) + } + } + //check permissions ok, err = realm.Authorize(user) if err != nil || !ok { - h.ErrorInternalServer(w, fmt.Sprintf("failed to authorize user: %v", req.Username)) + h.ErrorInternalServer(w, fmt.Sprintf("failed to authorize user: %v", username)) return } @@ -222,10 +292,56 @@ func (h APIHandler) Login(w http.ResponseWriter, r *http.Request, ps httprouter. //generate access token token, err := rbac.GenerateAccessToken(user) if err != nil { - h.ErrorInternalServer(w, fmt.Sprintf("failed to authorize user: %v", req.Username)) + h.ErrorInternalServer(w, fmt.Sprintf("failed to authorize user: %v", username)) return } //api.SetSession(w, r, userInSession+req.Username, req.Username) h.WriteOKJSON(w, token) } + +func normalizedUsername(username, userName string) string { + username = strings.TrimSpace(username) + if username != "" { + return username + } + return strings.TrimSpace(userName) +} + +func (h APIHandler) authenticateChallengeLogin(username, challengeID, proof string) (bool, *rbac.User, error) { + if challengeID == "" || proof == "" { + return false, nil, fmt.Errorf("challenge response is incomplete") + } + + challenge, err := rbac.ConsumeLoginChallenge(challengeID, username) + if err != nil { + return false, nil, err + } + + user, err := h.User.GetBy("name", username) + if err != nil { + return false, nil, err + } + if user == nil { + return false, nil, fmt.Errorf("invalid username or password") + } + if !rbac.CanUsePasswordChallenge(user) { + return false, nil, fmt.Errorf("password challenge is not available") + } + if !rbac.VerifyPasswordProof(user.PasswordVerifier, username, challenge.ID, challenge.Nonce, proof) { + return false, nil, fmt.Errorf("incorrect password") + } + + user.AuthProvider = NativeProvider + return true, user, nil +} + +func (h APIHandler) ensurePasswordChallenge(user *rbac.User, password string) error { + if user == nil || password == "" || rbac.CanUsePasswordChallenge(user) { + return nil + } + if err := rbac.EnsurePasswordChallenge(user, password); err != nil { + return err + } + return h.User.Update(user) +} diff --git a/modules/security/api/init.go b/modules/security/api/init.go index 47ceaa3c..3ec0eaa2 100644 --- a/modules/security/api/init.go +++ b/modules/security/api/init.go @@ -54,18 +54,20 @@ func Init() { api.HandleAPIMethod(api.PUT, "/role/:id", apiHandler.RequirePermission(apiHandler.UpdateRole, enum.RoleAllPermission...)) api.HandleAPIMethod(api.GET, "/role/_search", apiHandler.RequirePermission(apiHandler.SearchRole, enum.RoleReadPermission...)) - api.HandleAPIMethod(api.POST, "/user", apiHandler.RequirePermission(apiHandler.CreateUser, enum.UserAllPermission...)) + api.HandleAPIMethod(api.POST, "/user", apiHandler.RequireSecureTransport(apiHandler.RequireReplayProtection(apiHandler.RequirePermission(apiHandler.CreateUser, enum.UserAllPermission...)))) api.HandleAPIMethod(api.GET, "/user/:id", apiHandler.RequirePermission(apiHandler.GetUser, enum.UserReadPermission...)) api.HandleAPIMethod(api.DELETE, "/user/:id", apiHandler.RequirePermission(apiHandler.DeleteUser, enum.UserAllPermission...)) api.HandleAPIMethod(api.PUT, "/user/:id", apiHandler.RequirePermission(apiHandler.UpdateUser, enum.UserAllPermission...)) api.HandleAPIMethod(api.GET, "/user/_search", apiHandler.RequirePermission(apiHandler.SearchUser, enum.UserReadPermission...)) - api.HandleAPIMethod(api.PUT, "/user/:id/password", apiHandler.RequirePermission(apiHandler.UpdateUserPassword, enum.UserAllPermission...)) + api.HandleAPIMethod(api.PUT, "/user/:id/password", apiHandler.RequireSecureTransport(apiHandler.RequireReplayProtection(apiHandler.RequirePermission(apiHandler.UpdateUserPassword, enum.UserAllPermission...)))) - api.HandleAPIMethod(api.POST, "/account/login", apiHandler.Login) + api.HandleAPIMethod(api.POST, "/account/replay_nonce", apiHandler.RequireSecureTransport(apiHandler.IssueReplayNonce)) + api.HandleAPIMethod(api.POST, "/account/login/challenge", apiHandler.RequireSecureTransport(apiHandler.LoginChallenge)) + api.HandleAPIMethod(api.POST, "/account/login", apiHandler.RequireSecureTransport(apiHandler.RequireReplayProtection(apiHandler.Login))) api.HandleAPIMethod(api.POST, "/account/logout", apiHandler.Logout) api.HandleAPIMethod(api.DELETE, "/account/logout", apiHandler.Logout) api.HandleAPIMethod(api.GET, "/account/profile", apiHandler.RequireLogin(apiHandler.Profile)) - api.HandleAPIMethod(api.PUT, "/account/password", apiHandler.RequireLogin(apiHandler.UpdatePassword)) + api.HandleAPIMethod(api.PUT, "/account/password", apiHandler.RequireSecureTransport(apiHandler.RequireReplayProtection(apiHandler.RequireLogin(apiHandler.UpdatePassword)))) } diff --git a/modules/security/api/replay.go b/modules/security/api/replay.go new file mode 100644 index 00000000..c16d5966 --- /dev/null +++ b/modules/security/api/replay.go @@ -0,0 +1,55 @@ +// Copyright (C) INFINI Labs & INFINI LIMITED. +// +// The INFINI Console is offered under the GNU Affero General Public License v3.0 +// and as commercial software. +// +// For commercial licensing, contact us at: +// - Website: infinilabs.com +// - Email: hello@infini.ltd +// +// Open Source licensed under AGPL V3: +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package api + +import ( + core2 "infini.sh/console/core" + httprouter "infini.sh/framework/core/api/router" + "net/http" + "time" +) + +func (h APIHandler) IssueReplayNonce(w http.ResponseWriter, r *http.Request, ps httprouter.Params) { + var req struct { + Method string `json:"method"` + Path string `json:"path"` + } + + if err := h.DecodeJSON(r, &req); err != nil { + h.WriteError(w, err.Error(), http.StatusBadRequest) + return + } + + nonce, ttl, err := core2.IssueReplayNonce(r, req.Method, req.Path) + if err != nil { + h.WriteError(w, err.Error(), http.StatusBadRequest) + return + } + + h.WriteOKJSON(w, map[string]interface{}{ + "status": "ok", + "nonce": nonce, + "expire_in_seconds": int(ttl / time.Second), + }) +} diff --git a/modules/security/api/user.go b/modules/security/api/user.go index 5ce04202..3235964e 100644 --- a/modules/security/api/user.go +++ b/modules/security/api/user.go @@ -32,7 +32,6 @@ import ( "errors" "github.com/buger/jsonparser" log "github.com/cihub/seelog" - "golang.org/x/crypto/bcrypt" rbac "infini.sh/console/core/security" "infini.sh/framework/core/api" httprouter "infini.sh/framework/core/api/router" @@ -64,11 +63,11 @@ func (h APIHandler) CreateUser(w http.ResponseWriter, r *http.Request, ps httpro return } randStr := util.GenerateSecureString(16) - hash, err := bcrypt.GenerateFromPassword([]byte(randStr), bcrypt.DefaultCost) + err = rbac.SetPassword(&user, randStr) if err != nil { + h.ErrorInternalServer(w, err.Error()) return } - user.Password = string(hash) now := time.Now() user.Created = &now @@ -117,6 +116,9 @@ func (h APIHandler) GetUser(w http.ResponseWriter, r *http.Request, ps httproute h.ErrorInternalServer(w, err.Error()) return } + user.Password = "" + user.PasswordSalt = "" + user.PasswordVerifier = "" h.WriteOKJSON(w, api.FoundResponse(id, user)) return } @@ -150,6 +152,9 @@ func (h APIHandler) UpdateUser(w http.ResponseWriter, r *http.Request, ps httpro user.Updated = &now user.Created = oldUser.Created user.ID = id + user.Password = oldUser.Password + user.PasswordSalt = oldUser.PasswordSalt + user.PasswordVerifier = oldUser.PasswordVerifier err = h.User.Update(&user) if err != nil { @@ -217,6 +222,8 @@ func (h APIHandler) SearchUser(w http.ResponseWriter, r *http.Request, ps httpro hitsBuf.Write([]byte("[")) jsonparser.ArrayEach(res.Raw, func(value []byte, dataType jsonparser.ValueType, offset int, err error) { value = jsonparser.Delete(value, "_source", "password") + value = jsonparser.Delete(value, "_source", "password_salt") + value = jsonparser.Delete(value, "_source", "password_verifier") hitsBuf.Write(value) hitsBuf.Write([]byte(",")) }, "hits", "hits") @@ -261,11 +268,11 @@ func (h APIHandler) UpdateUserPassword(w http.ResponseWriter, r *http.Request, p h.ErrorInternalServer(w, err.Error()) return } - hash, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost) + err = rbac.SetPassword(&user, req.Password) if err != nil { + h.ErrorInternalServer(w, err.Error()) return } - user.Password = string(hash) //t:=time.Now() //user.Updated =&t err = h.User.Update(&user) diff --git a/modules/security/credential/api/init.go b/modules/security/credential/api/init.go index bc611277..5b3dfb9a 100644 --- a/modules/security/credential/api/init.go +++ b/modules/security/credential/api/init.go @@ -40,8 +40,8 @@ import ( func Init() { handler := APIHandler{} - api.HandleAPIMethod(api.POST, "/credential", handler.RequirePermission(handler.createCredential, enum.PermissionCredentialWrite)) - api.HandleAPIMethod(api.PUT, "/credential/:id", handler.RequirePermission(handler.updateCredential, enum.PermissionCredentialWrite)) + api.HandleAPIMethod(api.POST, "/credential", handler.RequireSecureTransport(handler.RequireReplayProtection(handler.RequirePermission(handler.createCredential, enum.PermissionCredentialWrite)))) + api.HandleAPIMethod(api.PUT, "/credential/:id", handler.RequireSecureTransport(handler.RequireReplayProtection(handler.RequirePermission(handler.updateCredential, enum.PermissionCredentialWrite)))) api.HandleAPIMethod(api.DELETE, "/credential/:id", handler.RequirePermission(handler.deleteCredential, enum.PermissionCredentialWrite)) api.HandleAPIMethod(api.GET, "/credential/_search", handler.RequirePermission(handler.searchCredential, enum.PermissionCredentialRead)) api.HandleAPIMethod(api.GET, "/credential/:id", handler.RequirePermission(handler.getCredential, enum.PermissionCredentialRead)) diff --git a/modules/security/realm/authc/native/load_test.go b/modules/security/realm/authc/native/load_test.go new file mode 100644 index 00000000..4f7a07e1 --- /dev/null +++ b/modules/security/realm/authc/native/load_test.go @@ -0,0 +1,30 @@ +package native + +import ( + "fmt" + "strings" + "testing" + + "github.com/segmentio/encoding/json" +) + +func TestPermissionFileHasNoDuplicateMethodPathPairs(t *testing.T) { + apis := map[string]ElasticsearchAPIMetadataList{} + if err := json.Unmarshal(permissionFile, &apis); err != nil { + t.Fatalf("failed to unmarshal permission file: %v", err) + } + + seen := map[string]string{} + for category, list := range apis { + for _, md := range list { + owner := fmt.Sprintf("%s/%s", category, md.Name) + for _, method := range md.Methods { + key := strings.ToUpper(method) + " " + md.Path + if previous, ok := seen[key]; ok { + t.Fatalf("duplicate permission route %s in %s and %s", key, previous, owner) + } + seen[key] = owner + } + } + } +} diff --git a/modules/security/realm/authc/native/permission.json b/modules/security/realm/authc/native/permission.json index a74e3087..33af9ac6 100644 --- a/modules/security/realm/authc/native/permission.json +++ b/modules/security/realm/authc/native/permission.json @@ -397,9 +397,6 @@ {"name": "indices.field_caps", "methods":["get", "post"], "path": "/:index_name/_field_caps" }, - {"name": "indices.exists_template", "methods":["head"], - "path": "/_template/:template_name" - }, {"name": "indices.field_usage_stats", "methods":["get"], "path": "/:index_name/_field_usage_stats" }, @@ -1022,4 +1019,4 @@ "path": "/_license" } ] -} \ No newline at end of file +} diff --git a/modules/security/realm/authc/native/role.go b/modules/security/realm/authc/native/role.go index cd652fae..5be1c32d 100644 --- a/modules/security/realm/authc/native/role.go +++ b/modules/security/realm/authc/native/role.go @@ -79,7 +79,7 @@ func (dal *Role) Create(role *rbac.Role) (string, error) { func (dal *Role) Delete(id string) error { role := rbac.Role{} role.ID = id - return orm.Delete(nil, role) + return orm.Delete(nil, &role) } func (dal *Role) Search(keyword string, from, size int) (orm.Result, error) { diff --git a/modules/security/realm/authc/native/user.go b/modules/security/realm/authc/native/user.go index 94047e87..227d630e 100644 --- a/modules/security/realm/authc/native/user.go +++ b/modules/security/realm/authc/native/user.go @@ -75,7 +75,7 @@ func (dal *User) Create(user *rbac.User) (string, error) { func (dal *User) Delete(id string) error { user := rbac.User{} user.ID = id - return orm.Delete(nil, user) + return orm.Delete(nil, &user) } func (dal *User) Search(keyword string, from, size int) (orm.Result, error) { diff --git a/plugin/api/alerting/channel.go b/plugin/api/alerting/channel.go index f7b52e00..c656cf29 100644 --- a/plugin/api/alerting/channel.go +++ b/plugin/api/alerting/channel.go @@ -353,6 +353,12 @@ func (alertAPI *AlertAPI) batchEnableChannel(w http.ResponseWriter, req *http.Re return } if len(channelIDs) > 0 { + err = validateChannelsBeforeEnable(channelIDs) + if err != nil { + log.Error(err) + alertAPI.WriteError(w, err.Error(), http.StatusBadRequest) + return + } err = setChannelEnabled(true, channelIDs) if err != nil { log.Error(err) @@ -400,3 +406,51 @@ func setChannelEnabled(enabled bool, channelIDs []string) error { err := orm.UpdateBy(alerting.Channel{}, util.MustToJSONBytes(q)) return err } + +func validateChannelsBeforeEnable(channelIDs []string) error { + for _, id := range channelIDs { + channel := alerting.Channel{} + channel.ID = id + exists, err := orm.Get(&channel) + if err != nil { + return err + } + if !exists { + return fmt.Errorf("channel [%s] not found", id) + } + if err = validateChannelBeforeEnable(&channel); err != nil { + return err + } + } + return nil +} + +func validateChannelBeforeEnable(channel *alerting.Channel) error { + if channel == nil { + return fmt.Errorf("empty channel") + } + + channelType := channel.SubType + if channelType == "" { + channelType = channel.Type + } + if channelType != alerting.ChannelEmail { + return nil + } + + if channel.Email == nil { + return fmt.Errorf("email channel [%s] is incomplete, please configure the smtp server and recipients first", channel.Name) + } + + if channel.Email.ServerID == "" && len(channel.Email.Recipients.To) == 0 { + return fmt.Errorf("email channel [%s] is incomplete, please configure the smtp server and recipients first", channel.Name) + } + if channel.Email.ServerID == "" { + return fmt.Errorf("email channel [%s] is incomplete, please configure the smtp server first", channel.Name) + } + if len(channel.Email.Recipients.To) == 0 { + return fmt.Errorf("email channel [%s] is incomplete, please configure at least one recipient first", channel.Name) + } + + return nil +} diff --git a/plugin/api/alerting/channel_test.go b/plugin/api/alerting/channel_test.go new file mode 100644 index 00000000..46a580b2 --- /dev/null +++ b/plugin/api/alerting/channel_test.go @@ -0,0 +1,59 @@ +package alerting + +import ( + "strings" + "testing" + + modelalerting "infini.sh/console/model/alerting" +) + +func TestValidateChannelBeforeEnableRequiresEmailConfig(t *testing.T) { + channel := &modelalerting.Channel{ + Name: "Email Channel", + Type: modelalerting.ChannelEmail, + SubType: modelalerting.ChannelEmail, + } + + err := validateChannelBeforeEnable(channel) + if err == nil { + t.Fatal("expected incomplete email channel to be rejected") + } + if !strings.Contains(err.Error(), "smtp server and recipients") { + t.Fatalf("expected combined config hint, got %v", err) + } +} + +func TestValidateChannelBeforeEnableRequiresRecipients(t *testing.T) { + channel := &modelalerting.Channel{ + Name: "Email Channel", + Type: modelalerting.ChannelEmail, + SubType: modelalerting.ChannelEmail, + Email: &modelalerting.Email{ + ServerID: "smtp-1", + }, + } + + err := validateChannelBeforeEnable(channel) + if err == nil { + t.Fatal("expected missing recipients to be rejected") + } + if !strings.Contains(err.Error(), "at least one recipient") { + t.Fatalf("expected recipient hint, got %v", err) + } +} + +func TestValidateChannelBeforeEnableAcceptsCompleteEmailChannel(t *testing.T) { + channel := &modelalerting.Channel{ + Name: "Email Channel", + Type: modelalerting.ChannelEmail, + SubType: modelalerting.ChannelEmail, + Email: &modelalerting.Email{ + ServerID: "smtp-1", + }, + } + channel.Email.Recipients.To = []string{"ops@example.com"} + + if err := validateChannelBeforeEnable(channel); err != nil { + t.Fatalf("expected complete email channel to pass validation, got %v", err) + } +} diff --git a/plugin/api/email/api.go b/plugin/api/email/api.go index 47629d97..4b009215 100644 --- a/plugin/api/email/api.go +++ b/plugin/api/email/api.go @@ -45,10 +45,10 @@ type EmailAPI struct { func InitAPI() { email := EmailAPI{} - api.HandleAPIMethod(api.POST, "/email/server/_test", email.RequirePermission(email.testEmailServer, enum.PermissionSmtpServerRead)) + api.HandleAPIMethod(api.POST, "/email/server/_test", email.RequireSecureTransport(email.RequireReplayProtection(email.RequirePermission(email.testEmailServer, enum.PermissionSmtpServerRead)))) api.HandleAPIMethod(api.GET, "/email/server/:email_server_id", email.RequirePermission(email.getEmailServer, enum.PermissionAlertRuleRead)) - api.HandleAPIMethod(api.POST, "/email/server", email.RequirePermission(email.createEmailServer, enum.PermissionSmtpServerWrite)) - api.HandleAPIMethod(api.PUT, "/email/server/:email_server_id", email.RequirePermission(email.updateEmailServer, enum.PermissionSmtpServerWrite)) + api.HandleAPIMethod(api.POST, "/email/server", email.RequireSecureTransport(email.RequireReplayProtection(email.RequirePermission(email.createEmailServer, enum.PermissionSmtpServerWrite)))) + api.HandleAPIMethod(api.PUT, "/email/server/:email_server_id", email.RequireSecureTransport(email.RequireReplayProtection(email.RequirePermission(email.updateEmailServer, enum.PermissionSmtpServerWrite)))) api.HandleAPIMethod(api.DELETE, "/email/server/:email_server_id", email.RequirePermission(email.deleteEmailServer, enum.PermissionSmtpServerWrite)) api.HandleAPIMethod(api.GET, "/email/server/_search", email.RequirePermission(email.searchEmailServer, enum.PermissionSmtpServerRead)) diff --git a/plugin/api/email/common/pipeline.go b/plugin/api/email/common/pipeline.go index b029f1df..3277d3f3 100644 --- a/plugin/api/email/common/pipeline.go +++ b/plugin/api/email/common/pipeline.go @@ -147,7 +147,7 @@ func GeneratePipelineConfig(servers []model.EmailServer) (string, error) { }, "processor": []util.MapStr{ { - "smtp": util.MapStr{ + "console_smtp": util.MapStr{ "idle_timeout_in_seconds": 1, "servers": smtpServers, "templates": util.MapStr{ diff --git a/plugin/api/email/common/pipeline_test.go b/plugin/api/email/common/pipeline_test.go new file mode 100644 index 00000000..aa42c3db --- /dev/null +++ b/plugin/api/email/common/pipeline_test.go @@ -0,0 +1,54 @@ +// Copyright (C) INFINI Labs & INFINI LIMITED. +// +// The INFINI Console is offered under the GNU Affero General Public License v3.0 +// and as commercial software. +// +// For commercial licensing, contact us at: +// - Website: infinilabs.com +// - Email: hello@infini.ltd +// +// Open Source licensed under AGPL V3: +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package common + +import ( + "strings" + "testing" + + "infini.sh/console/model" + framework_model "infini.sh/framework/core/model" + "infini.sh/framework/core/orm" + ucfg "infini.sh/framework/lib/go-ucfg" +) + +func TestGeneratePipelineConfigUsesConsoleSMTP(t *testing.T) { + cfg, err := GeneratePipelineConfig([]model.EmailServer{ + { + ORMObjectBase: orm.ORMObjectBase{ID: "srv-1"}, + Host: "smtp.example.com", + Port: 465, + Auth: &framework_model.BasicAuth{ + Username: "user", + Password: ucfg.SecretString("secret"), + }, + }, + }) + if err != nil { + t.Fatalf("GeneratePipelineConfig() error = %v", err) + } + if !strings.Contains(cfg, "console_smtp:") { + t.Fatalf("expected pipeline config to use console_smtp, got %s", cfg) + } +} diff --git a/plugin/api/email/processor.go b/plugin/api/email/processor.go new file mode 100644 index 00000000..647f7991 --- /dev/null +++ b/plugin/api/email/processor.go @@ -0,0 +1,400 @@ +// Copyright (C) INFINI Labs & INFINI LIMITED. +// +// The INFINI Console is offered under the GNU Affero General Public License v3.0 +// and as commercial software. +// +// For commercial licensing, contact us at: +// - Website: infinilabs.com +// - Email: hello@infini.ltd +// +// Open Source licensed under AGPL V3: +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package email + +import ( + "fmt" + "io" + "time" + + log "github.com/cihub/seelog" + "github.com/gopkg.in/gomail.v2" + "infini.sh/console/model" + emailcommon "infini.sh/console/plugin/api/email/common" + "infini.sh/framework/core/config" + "infini.sh/framework/core/errors" + "infini.sh/framework/core/orm" + "infini.sh/framework/core/param" + "infini.sh/framework/core/pipeline" + "infini.sh/framework/core/queue" + "infini.sh/framework/core/util" + "infini.sh/framework/lib/fasttemplate" +) + +type EmailProcessor struct { + config *EmailProcessorConfig +} + +type EmailServerConfig struct { + Server struct { + Host string `config:"host"` + Port int `config:"port"` + TLS bool `config:"tls"` + } `config:"server"` + Auth struct { + Username string `config:"username"` + Password string `config:"password"` + } `config:"auth"` + MinTLSVersion string `config:"min_tls_version"` + SendFrom string `config:"sender"` + Recipients struct { + To []string `config:"to"` + CC []string `config:"cc"` + BCC []string `config:"bcc"` + } `config:"recipients"` +} + +type EmailTemplate struct { + ContentType string `config:"content_type"` + Subject string `config:"subject"` + Body string `config:"body"` + variableInSubject bool + variableInBody bool + bodyTemplate *fasttemplate.Template + subjectTemplate *fasttemplate.Template +} + +type EmailProcessorConfig struct { + DialTimeoutInSeconds int `config:"dial_timeout_in_seconds"` + MessageField param.ParaKey `config:"message_field"` + VariableStartTag string `config:"variable_start_tag"` + VariableEndTag string `config:"variable_end_tag"` + Variables map[string]interface{} `config:"variables"` + Servers map[string]*EmailServerConfig `config:"servers"` + Templates map[string]*EmailTemplate `config:"templates"` +} + +var loadEmailServerForProcessor = queryEnabledEmailServerConfigByID +var loadEmailServersForProcessor = queryEnabledEmailServerConfigs + +func (processor *EmailProcessor) Name() string { + return "console_smtp" +} + +func init() { + pipeline.RegisterProcessorPlugin("console_smtp", newEmailProcessor) +} + +func newEmailProcessor(c *config.Config) (pipeline.Processor, error) { + cfg := EmailProcessorConfig{ + DialTimeoutInSeconds: 30, + VariableStartTag: "$[[", + VariableEndTag: "]]", + MessageField: "messages", + } + if err := c.Unpack(&cfg); err != nil { + return nil, fmt.Errorf("failed to unpack console_smtp processor config: %s", err) + } + + processor := &EmailProcessor{config: &cfg} + for _, server := range processor.config.Servers { + normalizeEmailServerConfig(server) + } + + for _, tpl := range processor.config.Templates { + if util.ContainStr(tpl.Body, processor.config.VariableStartTag) { + tpl.variableInBody = true + template, err := fasttemplate.NewTemplate(tpl.Body, processor.config.VariableStartTag, processor.config.VariableEndTag) + if err != nil { + return nil, err + } + tpl.bodyTemplate = template + } + if util.ContainStr(tpl.Subject, processor.config.VariableStartTag) { + tpl.variableInSubject = true + template, err := fasttemplate.NewTemplate(tpl.Subject, processor.config.VariableStartTag, processor.config.VariableEndTag) + if err != nil { + return nil, err + } + tpl.subjectTemplate = template + } + } + + return processor, nil +} + +func (processor *EmailProcessor) Process(ctx *pipeline.Context) error { + obj := ctx.Get(processor.config.MessageField) + if obj == nil { + return nil + } + + messages, ok := obj.([]queue.Message) + if !ok || len(messages) == 0 { + return nil + } + + for _, message := range messages { + payload := util.MapStr{} + if err := util.FromJSONBytes(message.Data, &payload); err != nil { + return err + } + + vars := toMapStr(payload["variables"]) + serverID := util.ToString(payload["server_id"]) + if serverID == "" { + serverID = util.ToString(processor.config.Variables["server_id"]) + } + if serverID == "" { + return errors.New("server_id is empty") + } + + resolvedServerID, server, err := processor.resolveServer(serverID) + if err != nil { + return err + } + + templateName := util.ToString(payload["template"]) + if templateName == "" { + templateName = "raw" + } + tpl, ok := processor.config.Templates[templateName] + if !ok || tpl == nil { + return errors.Errorf("template [%v] not found", templateName) + } + + sendTo := append(readStringList(payload["email"]), server.Recipients.To...) + if len(sendTo) == 0 { + sendTo = append(sendTo, readStringList(vars["email"])...) + } + if len(sendTo) == 0 { + log.Errorf("skip email message without recipients, server_id: %v", resolvedServerID) + continue + } + + cc := append([]string{}, server.Recipients.CC...) + cc = append(cc, readStringList(vars["cc"])...) + + mergedVars := util.MapStr{} + mergedVars.Merge(processor.config.Variables) + mergedVars.Merge(vars) + + subject := processor.renderTemplate(tpl.subjectTemplate, tpl.variableInSubject, tpl.Subject, mergedVars) + body := processor.renderTemplate(tpl.bodyTemplate, tpl.variableInBody, tpl.Body, mergedVars) + contentType := tpl.ContentType + if override := util.ToString(vars["content_type"]); override != "" { + contentType = override + } + + log.Debugf("start to send mail to: %v, subject: %v", sendTo, subject) + if err := processor.send(server, sendTo, cc, subject, contentType, body); err != nil { + return err + } + } + + return nil +} + +func normalizeEmailServerConfig(server *EmailServerConfig) { + if server == nil { + return + } + if server.Auth.Username == "" && server.SendFrom != "" { + server.Auth.Username = server.SendFrom + } + if server.SendFrom == "" && server.Auth.Username != "" { + server.SendFrom = server.Auth.Username + } +} + +func buildEmailServerConfig(server model.EmailServer) (*EmailServerConfig, error) { + if err := server.Validate(false); err != nil { + return nil, err + } + auth, err := emailcommon.GetBasicAuth(&server) + if err != nil { + return nil, err + } + cfg := &EmailServerConfig{ + MinTLSVersion: server.TLSMinVersion, + } + cfg.Server.Host = server.Host + cfg.Server.Port = server.Port + cfg.Server.TLS = server.TLS + cfg.Auth.Username = auth.Username + cfg.Auth.Password = auth.Password.Get() + normalizeEmailServerConfig(cfg) + return cfg, nil +} + +func queryEnabledEmailServerConfigByID(serverID string) (*EmailServerConfig, error) { + if serverID == "" { + return nil, nil + } + server := model.EmailServer{} + server.ID = serverID + exists, err := orm.Get(&server) + if err != nil { + return nil, err + } + if !exists || !server.Enabled { + return nil, nil + } + return buildEmailServerConfig(server) +} + +func queryEnabledEmailServerConfigs() (map[string]*EmailServerConfig, error) { + q := &orm.Query{Size: 100} + q.Conds = orm.And(orm.Eq("enabled", true)) + err, result := orm.Search(model.EmailServer{}, q) + if err != nil { + return nil, err + } + servers := map[string]*EmailServerConfig{} + for _, row := range result.Result { + server := model.EmailServer{} + buf := util.MustToJSONBytes(row) + util.MustFromJSONBytes(buf, &server) + cfg, err := buildEmailServerConfig(server) + if err != nil { + return nil, err + } + servers[server.ID] = cfg + } + return servers, nil +} + +func (processor *EmailProcessor) resolveServer(serverID string) (string, *EmailServerConfig, error) { + if server, ok := processor.config.Servers[serverID]; ok && server != nil { + return serverID, server, nil + } + server, err := loadEmailServerForProcessor(serverID) + if err != nil { + return "", nil, err + } + if server != nil { + return serverID, server, nil + } + servers, err := loadEmailServersForProcessor() + if err != nil { + return "", nil, err + } + switch len(servers) { + case 0: + return "", nil, errors.Errorf("server_id [%v] not found and no enabled smtp server is available", serverID) + case 1: + for id, fallback := range servers { + return id, fallback, nil + } + default: + return "", nil, errors.Errorf("server_id [%v] not found and multiple enabled smtp servers were found", serverID) + } + return "", nil, errors.Errorf("server_id [%v] not found", serverID) +} + +func (processor *EmailProcessor) renderTemplate(tpl *fasttemplate.Template, enabled bool, raw string, vars util.MapStr) string { + if !enabled || tpl == nil { + return raw + } + + return tpl.ExecuteFuncString(func(w io.Writer, tag string) (int, error) { + value, err := vars.GetValue(tag) + if err != nil { + return -1, err + } + text := util.ToString(value) + if text == "" { + return 0, nil + } + return w.Write([]byte(text)) + }) +} + +func (processor *EmailProcessor) send(server *EmailServerConfig, to []string, cc []string, subject, contentType, body string) error { + if len(to) == 0 { + return errors.New("no recipient found") + } + + message := gomail.NewMessage() + message.SetHeader("From", server.SendFrom) + message.SetHeader("To", to...) + if len(cc) > 0 { + message.SetHeader("Cc", cc...) + } + if len(server.Recipients.BCC) > 0 { + message.SetHeader("Bcc", server.Recipients.BCC...) + } + message.SetHeader("Subject", subject) + message.SetBody(contentType, body) + + minVersion := model.TLSVersion12 + if server.MinTLSVersion != "" { + minVersion = server.MinTLSVersion + } + tlsMinVersion, err := model.GetTLSVersion(minVersion) + if err != nil { + return err + } + + d := gomail.NewDialerWithTimeout( + server.Server.Host, + server.Server.Port, + server.Auth.Username, + server.Auth.Password, + time.Duration(processor.config.DialTimeoutInSeconds)*time.Second, + ) + d.TLSConfig = newEmailTLSConfig(server.Server.Host, tlsMinVersion) + d.SSL = server.Server.TLS + return d.DialAndSend(message) +} + +func readStringList(value interface{}) []string { + switch v := value.(type) { + case string: + if v == "" { + return nil + } + return []string{v} + case []string: + list := make([]string, 0, len(v)) + for _, item := range v { + if item != "" { + list = append(list, item) + } + } + return list + case []interface{}: + list := make([]string, 0, len(v)) + for _, item := range v { + text := util.ToString(item) + if text != "" { + list = append(list, text) + } + } + return list + default: + return nil + } +} + +func toMapStr(value interface{}) util.MapStr { + switch v := value.(type) { + case util.MapStr: + return v + case map[string]interface{}: + return util.MapStr(v) + default: + return util.MapStr{} + } +} diff --git a/plugin/api/email/processor_test.go b/plugin/api/email/processor_test.go new file mode 100644 index 00000000..41400353 --- /dev/null +++ b/plugin/api/email/processor_test.go @@ -0,0 +1,115 @@ +package email + +import ( + "strings" + "testing" +) + +func TestResolveServerUsesLiveEnabledServer(t *testing.T) { + previousByID := loadEmailServerForProcessor + previousAll := loadEmailServersForProcessor + defer func() { + loadEmailServerForProcessor = previousByID + loadEmailServersForProcessor = previousAll + }() + + loadEmailServerForProcessor = func(serverID string) (*EmailServerConfig, error) { + cfg := &EmailServerConfig{} + cfg.Server.Host = "smtp.example.com" + cfg.Server.Port = 465 + cfg.Server.TLS = true + cfg.Auth.Username = "ops@example.com" + normalizeEmailServerConfig(cfg) + return cfg, nil + } + loadEmailServersForProcessor = func() (map[string]*EmailServerConfig, error) { + t.Fatal("unexpected fallback server query") + return nil, nil + } + + processor := &EmailProcessor{ + config: &EmailProcessorConfig{ + Servers: map[string]*EmailServerConfig{}, + }, + } + resolvedID, server, err := processor.resolveServer("smtp-live") + if err != nil { + t.Fatalf("expected live server lookup to succeed, got %v", err) + } + if resolvedID != "smtp-live" { + t.Fatalf("expected smtp-live, got %s", resolvedID) + } + if server.Server.Host != "smtp.example.com" { + t.Fatalf("expected resolved server host, got %s", server.Server.Host) + } + if server.SendFrom != "ops@example.com" { + t.Fatalf("expected send-from normalized from username, got %s", server.SendFrom) + } +} + +func TestResolveServerFallsBackToOnlyEnabledServer(t *testing.T) { + previousByID := loadEmailServerForProcessor + previousAll := loadEmailServersForProcessor + defer func() { + loadEmailServerForProcessor = previousByID + loadEmailServersForProcessor = previousAll + }() + + loadEmailServerForProcessor = func(serverID string) (*EmailServerConfig, error) { + return nil, nil + } + loadEmailServersForProcessor = func() (map[string]*EmailServerConfig, error) { + cfg := &EmailServerConfig{} + cfg.Server.Host = "smtp.example.com" + cfg.Auth.Username = "ops@example.com" + normalizeEmailServerConfig(cfg) + return map[string]*EmailServerConfig{ + "smtp-fallback": cfg, + }, nil + } + + processor := &EmailProcessor{ + config: &EmailProcessorConfig{ + Servers: map[string]*EmailServerConfig{}, + }, + } + resolvedID, _, err := processor.resolveServer("smtp-stale") + if err != nil { + t.Fatalf("expected single fallback server to be selected, got %v", err) + } + if resolvedID != "smtp-fallback" { + t.Fatalf("expected smtp-fallback, got %s", resolvedID) + } +} + +func TestResolveServerFailsWithMultipleEnabledServers(t *testing.T) { + previousByID := loadEmailServerForProcessor + previousAll := loadEmailServersForProcessor + defer func() { + loadEmailServerForProcessor = previousByID + loadEmailServersForProcessor = previousAll + }() + + loadEmailServerForProcessor = func(serverID string) (*EmailServerConfig, error) { + return nil, nil + } + loadEmailServersForProcessor = func() (map[string]*EmailServerConfig, error) { + return map[string]*EmailServerConfig{ + "smtp-1": {}, + "smtp-2": {}, + }, nil + } + + processor := &EmailProcessor{ + config: &EmailProcessorConfig{ + Servers: map[string]*EmailServerConfig{}, + }, + } + _, _, err := processor.resolveServer("smtp-stale") + if err == nil { + t.Fatal("expected multiple enabled smtp servers to fail") + } + if !strings.Contains(err.Error(), "multiple enabled smtp servers") { + t.Fatalf("expected multi-server hint, got %v", err) + } +} diff --git a/plugin/api/email/server.go b/plugin/api/email/server.go index 02b942cd..451b2a0e 100644 --- a/plugin/api/email/server.go +++ b/plugin/api/email/server.go @@ -46,6 +46,14 @@ import ( "infini.sh/framework/core/util" ) +func newEmailTLSConfig(serverName string, minVersion uint16) *tls.Config { + return &tls.Config{ + InsecureSkipVerify: true, + MinVersion: minVersion, + ServerName: serverName, + } +} + func (h *EmailAPI) createEmailServer(w http.ResponseWriter, req *http.Request, ps httprouter.Params) { var obj = &model.EmailServer{} err := h.DecodeJSON(req, obj) @@ -384,7 +392,6 @@ func (h *EmailAPI) testEmailServer(w http.ResponseWriter, req *http.Request, ps h.WriteError(w, err.Error(), http.StatusInternalServerError) return } - err = d.DialAndSend(message) if err != nil { log.Error(err) @@ -411,7 +418,7 @@ func newEmailTestDialer(server *model.EmailServer) (*gomail.Dialer, error) { } dialer := gomail.NewDialer(server.Host, server.Port, server.Auth.Username, server.Auth.Password.Get()) - dialer.TLSConfig = &tls.Config{InsecureSkipVerify: true, MinVersion: tlsMinVersion} + dialer.TLSConfig = newEmailTLSConfig(server.Host, tlsMinVersion) dialer.SSL = server.TLS return dialer, nil diff --git a/plugin/api/email/server_test.go b/plugin/api/email/server_test.go index c5f71d72..99732ddf 100644 --- a/plugin/api/email/server_test.go +++ b/plugin/api/email/server_test.go @@ -106,5 +106,18 @@ func TestNewEmailTestDialerRejectsUnsupportedTLSVersion(t *testing.T) { _, err := newEmailTestDialer(server) if err == nil { t.Fatal("expected unsupported TLS version to fail") +} + +func TestNewEmailTLSConfigSetsServerName(t *testing.T) { + cfg := newEmailTLSConfig("smtp.example.com", tls.VersionTLS13) + + if cfg.ServerName != "smtp.example.com" { + t.Fatalf("expected server name to be preserved, got %q", cfg.ServerName) + } + if cfg.MinVersion != tls.VersionTLS13 { + t.Fatalf("expected min TLS version %d, got %d", tls.VersionTLS13, cfg.MinVersion) + } + if !cfg.InsecureSkipVerify { + t.Fatal("expected insecure skip verify to remain enabled") } } diff --git a/plugin/api/index_management/elasticsearch.go b/plugin/api/index_management/elasticsearch.go index 74864334..23d3997e 100644 --- a/plugin/api/index_management/elasticsearch.go +++ b/plugin/api/index_management/elasticsearch.go @@ -60,7 +60,7 @@ func (handler APIHandler) ElasticsearchOverviewAction(w http.ResponseWriter, req queryDsl := util.MapStr{ "size": 100, } - clusterFilter, hasAllPrivilege := handler.GetClusterFilter(req, "_id") + clusterFilter, hasAllPrivilege := handler.GetClusterFilter(req, "id") if !hasAllPrivilege && clusterFilter == nil { handler.WriteJSON(w, util.MapStr{ "nodes_count": 0, diff --git a/plugin/api/init.go b/plugin/api/init.go index e525a710..c2705d4b 100644 --- a/plugin/api/init.go +++ b/plugin/api/init.go @@ -36,6 +36,7 @@ import ( "infini.sh/console/plugin/api/layout" "infini.sh/console/plugin/api/notification" "infini.sh/console/plugin/api/platform" + "infini.sh/console/plugin/api/settings" "infini.sh/framework/core/api" ) @@ -80,4 +81,5 @@ func Init(cfg *config.AppConfig) { email.InitAPI() data.InitAPI() platform.InitAPI() + settings.InitAPI() } diff --git a/plugin/api/insight/api.go b/plugin/api/insight/api.go index bd9b8dfb..c0d979f6 100644 --- a/plugin/api/insight/api.go +++ b/plugin/api/insight/api.go @@ -29,6 +29,7 @@ package insight import ( "infini.sh/console/core" + "infini.sh/console/core/security/enum" "infini.sh/framework/core/api" ) @@ -42,18 +43,18 @@ func InitAPI() { api.HandleAPIMethod(api.POST, "/elasticsearch/:id/visualization/data", insight.RequireLogin(insight.HandleGetMetricData)) api.HandleAPIMethod(api.POST, "/elasticsearch/:id/visualization/preview", insight.RequireLogin(insight.HandleGetPreview)) - api.HandleAPIMethod(api.GET, "/insight/visualization/:visualization_id", insight.getVisualization) - api.HandleAPIMethod(api.POST, "/insight/visualization", insight.createVisualization) - api.HandleAPIMethod(api.PUT, "/insight/visualization/:visualization_id", insight.updateVisualization) - api.HandleAPIMethod(api.DELETE, "/insight/visualization/:visualization_id", insight.deleteVisualization) - api.HandleAPIMethod(api.GET, "/insight/visualization/_search", insight.searchVisualization) + api.HandleAPIMethod(api.GET, "/insight/visualization/:visualization_id", insight.RequirePermission(insight.getVisualization, enum.PermissionLayoutRead)) + api.HandleAPIMethod(api.POST, "/insight/visualization", insight.RequirePermission(insight.createVisualization, enum.PermissionLayoutWrite)) + api.HandleAPIMethod(api.PUT, "/insight/visualization/:visualization_id", insight.RequirePermission(insight.updateVisualization, enum.PermissionLayoutWrite)) + api.HandleAPIMethod(api.DELETE, "/insight/visualization/:visualization_id", insight.RequirePermission(insight.deleteVisualization, enum.PermissionLayoutWrite)) + api.HandleAPIMethod(api.GET, "/insight/visualization/_search", insight.RequirePermission(insight.searchVisualization, enum.PermissionLayoutRead)) - api.HandleAPIMethod(api.GET, "/insight/dashboard/:dashboard_id", insight.getDashboard) - api.HandleAPIMethod(api.POST, "/insight/dashboard", insight.createDashboard) - api.HandleAPIMethod(api.PUT, "/insight/dashboard/:dashboard_id", insight.updateDashboard) - api.HandleAPIMethod(api.DELETE, "/insight/dashboard/:dashboard_id", insight.deleteDashboard) - api.HandleAPIMethod(api.GET, "/insight/dashboard/_search", insight.searchDashboard) - api.HandleAPIMethod(api.POST, "/elasticsearch/:id/map_label/_render", insight.renderMapLabelTemplate) - api.HandleAPIMethod(api.GET, "/insight/widget/:widget_id", insight.getWidget) - api.HandleAPIMethod(api.POST, "/insight/widget", insight.RequireLogin(insight.createWidget)) + api.HandleAPIMethod(api.GET, "/insight/dashboard/:dashboard_id", insight.RequirePermission(insight.getDashboard, enum.DashboardReadPermission...)) + api.HandleAPIMethod(api.POST, "/insight/dashboard", insight.RequirePermission(insight.createDashboard, enum.DashboardAllPermission...)) + api.HandleAPIMethod(api.PUT, "/insight/dashboard/:dashboard_id", insight.RequirePermission(insight.updateDashboard, enum.DashboardAllPermission...)) + api.HandleAPIMethod(api.DELETE, "/insight/dashboard/:dashboard_id", insight.RequirePermission(insight.deleteDashboard, enum.DashboardAllPermission...)) + api.HandleAPIMethod(api.GET, "/insight/dashboard/_search", insight.RequirePermission(insight.searchDashboard, enum.DashboardReadPermission...)) + api.HandleAPIMethod(api.POST, "/elasticsearch/:id/map_label/_render", insight.RequireClusterPermission(insight.renderMapLabelTemplate, enum.PermissionElasticsearchClusterRead)) + api.HandleAPIMethod(api.GET, "/insight/widget/:widget_id", insight.RequirePermission(insight.getWidget, enum.PermissionLayoutRead)) + api.HandleAPIMethod(api.POST, "/insight/widget", insight.RequirePermission(insight.createWidget, enum.PermissionLayoutWrite)) } diff --git a/plugin/api/settings/api.go b/plugin/api/settings/api.go new file mode 100644 index 00000000..3227e747 --- /dev/null +++ b/plugin/api/settings/api.go @@ -0,0 +1,1218 @@ +// Copyright (C) INFINI Labs & INFINI LIMITED. +// +// The INFINI Console is offered under the GNU Affero General Public License v3.0 +// and as commercial software. +// +// For commercial licensing, contact us at: +// - Website: infinilabs.com +// - Email: hello@infini.ltd +// +// Open Source licensed under AGPL V3: +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package settings + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/url" + "regexp" + "strconv" + "strings" + + log "github.com/cihub/seelog" + "infini.sh/console/core" + "infini.sh/console/core/security/enum" + "infini.sh/framework/core/api" + httprouter "infini.sh/framework/core/api/router" + "infini.sh/framework/core/elastic" + "infini.sh/framework/core/env" + "infini.sh/framework/core/global" + "infini.sh/framework/core/util" + elasticCommon "infini.sh/framework/modules/elastic/common" +) + +type SettingsAPI struct { + core.Handler +} + +type rawRequester interface { + Request(ctx context.Context, method, url string, body []byte) (*util.Result, error) +} + +type rollupSettingsRequest struct { + Enabled bool `json:"enabled"` +} + +type retentionSettingsRequest struct { + Days int `json:"days"` + MaxSize string `json:"max_size"` +} + +const defaultRetentionDays = 30 +const defaultRetentionMaxSize = "50gb" + +var retentionPolicyPattern = regexp.MustCompile(`-(\d+)days-retention$`) +var retentionSizePattern = regexp.MustCompile(`(?i)^(\d+)([kmgt]b?|b)$`) + +func InitAPI() { + handler := SettingsAPI{} + api.HandleAPIMethod(api.GET, "/setting/system/retention", handler.RequirePermission(handler.getRetentionSetting, enum.PermissionElasticsearchClusterRead)) + api.HandleAPIMethod(api.PUT, "/setting/system/retention", handler.RequireSecureTransport(handler.RequireReplayProtection(handler.RequirePermission(handler.updateRetentionSetting, enum.PermissionElasticsearchClusterWrite)))) + api.HandleAPIMethod(api.GET, "/setting/system/rollup", handler.RequirePermission(handler.getRollupSetting, enum.PermissionElasticsearchClusterRead)) + api.HandleAPIMethod(api.PUT, "/setting/system/rollup", handler.RequireSecureTransport(handler.RequireReplayProtection(handler.RequirePermission(handler.updateRollupSetting, enum.PermissionElasticsearchClusterWrite)))) +} + +func resolveSystemIndexPrefix() string { + ormConfig := elasticCommon.ORMConfig{} + _, err := env.ParseConfig("elastic.orm", &ormConfig) + if err != nil { + log.Warn(err) + } + indexPrefix := strings.TrimSpace(ormConfig.IndexPrefix) + if indexPrefix == "" { + indexPrefix = ".infini_" + } + return indexPrefix +} + +func getMetricsRetentionPolicyID(indexPrefix string, days int) string { + return fmt.Sprintf("ilm_%smetrics-%ddays-retention", indexPrefix, days) +} + +func getRollupRetentionPolicyID(indexPrefix string, days int) string { + return fmt.Sprintf("ilm_%srollup-%ddays-retention", indexPrefix, days) +} + +func getManagedRetentionTemplateNames(indexPrefix string) []string { + return []string{ + indexPrefix + "metrics-rollover", + indexPrefix + "logs-rollover", + indexPrefix + "requests_logging-rollover", + indexPrefix + "async_bulk_results-rollover", + indexPrefix + "alert-history-rollover", + indexPrefix + "activities-rollover", + } +} + +func getManagedRetentionIndexPatterns(indexPrefix string) []string { + return []string{ + indexPrefix + "metrics*", + indexPrefix + "logs*", + indexPrefix + "requests_logging*", + indexPrefix + "async_bulk_results*", + indexPrefix + "alert-history*", + indexPrefix + "activities*", + } +} + +func castMap(value interface{}) (map[string]interface{}, bool) { + switch v := value.(type) { + case map[string]interface{}: + return v, true + case util.MapStr: + return v, true + default: + return nil, false + } +} + +func castSlice(value interface{}) ([]interface{}, bool) { + items, ok := value.([]interface{}) + return items, ok +} + +func parseRetentionDays(value interface{}) (int, error) { + if value == nil { + return 0, fmt.Errorf("retention value is empty") + } + rawValue := strings.TrimSpace(fmt.Sprint(value)) + if rawValue == "" || rawValue == "" { + return 0, fmt.Errorf("retention value is empty") + } + rawValue = strings.TrimSuffix(rawValue, "d") + days, err := strconv.Atoi(rawValue) + if err != nil || days <= 0 { + return 0, fmt.Errorf("invalid retention value: %v", value) + } + return days, nil +} + +func parseRetentionDaysFromPolicyID(policyID string) (int, error) { + matches := retentionPolicyPattern.FindStringSubmatch(strings.TrimSpace(policyID)) + if len(matches) != 2 { + return 0, fmt.Errorf("retention days not found in policy id: %s", policyID) + } + return parseRetentionDays(matches[1]) +} + +func normalizeRetentionSize(value interface{}) (string, error) { + rawValue := strings.ToLower(strings.ReplaceAll(strings.TrimSpace(fmt.Sprint(value)), " ", "")) + if rawValue == "" || rawValue == "" { + return "", fmt.Errorf("retention size is empty") + } + if _, err := util.ToBytes(rawValue); err != nil { + return "", err + } + matches := retentionSizePattern.FindStringSubmatch(rawValue) + if len(matches) != 3 { + return "", fmt.Errorf("invalid retention size: %v", value) + } + unit := strings.ToLower(matches[2]) + switch unit { + case "k": + unit = "kb" + case "m": + unit = "mb" + case "g": + unit = "gb" + case "t": + unit = "tb" + } + return matches[1] + unit, nil +} + +func rawJSONRequest(requester rawRequester, cfg *elastic.ElasticsearchConfig, method, path string, payload interface{}) (map[string]interface{}, int, error) { + var body []byte + if payload != nil { + body = util.MustToJSONBytes(payload) + } + requestURL := fmt.Sprintf("%s%s", strings.TrimRight(cfg.GetAnyEndpoint(), "/"), path) + resp, err := requester.Request(context.Background(), method, requestURL, body) + if err != nil { + return nil, 0, err + } + if resp.StatusCode == http.StatusNotFound { + return nil, resp.StatusCode, nil + } + if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated { + return nil, resp.StatusCode, fmt.Errorf("%s", resp.Body) + } + if len(resp.Body) == 0 { + return map[string]interface{}{}, resp.StatusCode, nil + } + result := map[string]interface{}{} + if err := util.FromJSONBytes(resp.Body, &result); err != nil { + return nil, resp.StatusCode, err + } + return result, resp.StatusCode, nil +} + +func getLegacyTemplate(requester rawRequester, cfg *elastic.ElasticsearchConfig, templateName string) (map[string]interface{}, int, error) { + response, statusCode, err := rawJSONRequest(requester, cfg, util.Verb_GET, "/_template/"+templateName, nil) + if err != nil || statusCode == http.StatusNotFound { + return nil, statusCode, err + } + template, ok := castMap(response[templateName]) + if !ok { + return nil, statusCode, fmt.Errorf("template %s payload not found", templateName) + } + return template, statusCode, nil +} + +func putLegacyTemplate(requester rawRequester, cfg *elastic.ElasticsearchConfig, templateName string, template map[string]interface{}) error { + _, _, err := rawJSONRequest(requester, cfg, util.Verb_PUT, "/_template/"+templateName, template) + return err +} + +func readTemplateLifecyclePolicyID(template map[string]interface{}) (string, error) { + settings, ok := castMap(template["settings"]) + if !ok { + return "", fmt.Errorf("template settings not found") + } + if policyID, ok := settings["index.lifecycle.name"]; ok { + value := strings.TrimSpace(fmt.Sprint(policyID)) + if value != "" && value != "" { + return value, nil + } + } + if indexSettings, ok := castMap(settings["index"]); ok { + if lifecycle, ok := castMap(indexSettings["lifecycle"]); ok { + value := strings.TrimSpace(fmt.Sprint(lifecycle["name"])) + if value != "" && value != "" { + return value, nil + } + } + } + return "", fmt.Errorf("template lifecycle policy not found") +} + +func setTemplateLifecyclePolicyID(template map[string]interface{}, policyID string) error { + settings, ok := castMap(template["settings"]) + if !ok { + return fmt.Errorf("template settings not found") + } + updated := false + if _, exists := settings["index.lifecycle.name"]; exists { + settings["index.lifecycle.name"] = policyID + updated = true + } + if indexSettings, ok := castMap(settings["index"]); ok { + if lifecycle, ok := castMap(indexSettings["lifecycle"]); ok { + lifecycle["name"] = policyID + updated = true + } + } + if !updated { + return fmt.Errorf("template lifecycle policy not found") + } + return nil +} + +func getILMPolicy(requester rawRequester, cfg *elastic.ElasticsearchConfig, policyID string) (map[string]interface{}, int64, int64, int, error) { + response, statusCode, err := rawJSONRequest( + requester, + cfg, + util.Verb_GET, + "/_ilm/policy/"+url.PathEscape(policyID), + nil, + ) + if err != nil || statusCode == http.StatusNotFound { + return nil, 0, 0, statusCode, err + } + seqNo, _ := response["_seq_no"].(json.Number) + primaryTerm, _ := response["_primary_term"].(json.Number) + seqNoVal, _ := seqNo.Int64() + primaryTermVal, _ := primaryTerm.Int64() + + policy, err := extractILMPolicy(response, policyID) + return policy, seqNoVal, primaryTermVal, statusCode, err +} + +func extractILMPolicy(response map[string]interface{}, policyID string) (map[string]interface{}, error) { + if policyWrapper, ok := castMap(response[policyID]); ok { + if policy, ok := castMap(policyWrapper["policy"]); ok { + return policy, nil + } + return nil, fmt.Errorf("ilm policy body not found") + } + + if policy, ok := castMap(response["policy"]); ok { + return policy, nil + } + + if len(response) == 1 { + for _, value := range response { + if policyWrapper, ok := castMap(value); ok { + if policy, ok := castMap(policyWrapper["policy"]); ok { + return policy, nil + } + } + } + } + + return nil, fmt.Errorf("ilm policy payload not found") +} + +func normalizeILMActionForPut(actionName string, rawConfig interface{}) (string, interface{}, bool) { + switch actionName { + case "retry", "timeout": + return "", nil, false + } + + config, ok := castMap(rawConfig) + if !ok { + if rawConfig == nil { + return actionName, util.MapStr{}, true + } + return actionName, rawConfig, true + } + + normalized := util.MapStr{} + for key, value := range config { + normalized[key] = value + } + + switch actionName { + case "rollover": + if value, exists := normalized["min_index_age"]; exists { + if _, hasMaxAge := normalized["max_age"]; !hasMaxAge { + normalized["max_age"] = value + } + delete(normalized, "min_index_age") + } + if value, exists := normalized["min_size"]; exists { + if _, hasMaxSize := normalized["max_size"]; !hasMaxSize { + normalized["max_size"] = value + } + delete(normalized, "min_size") + } + if value, exists := normalized["min_doc_count"]; exists { + if _, hasMaxDocs := normalized["max_docs"]; !hasMaxDocs { + normalized["max_docs"] = value + } + delete(normalized, "min_doc_count") + } + case "index_priority": + actionName = "set_priority" + case "allocation": + actionName = "allocate" + delete(normalized, "wait_for") + case "force_merge": + actionName = "forcemerge" + case "read_only": + actionName = "readonly" + } + + return actionName, normalized, true +} + +func normalizeILMPolicyForPut(policy map[string]interface{}) (map[string]interface{}, error) { + if phases, ok := castMap(policy["phases"]); ok { + return util.MapStr{ + "phases": phases, + }, nil + } + + states, ok := castSlice(policy["states"]) + if !ok { + return nil, fmt.Errorf("ilm phases not found") + } + + phaseMinAge := map[string]interface{}{ + "hot": "0ms", + } + phaseBodies := util.MapStr{} + + for _, stateValue := range states { + state, ok := castMap(stateValue) + if !ok { + continue + } + + stateName := strings.TrimSpace(fmt.Sprint(state["name"])) + if stateName == "" || stateName == "" { + continue + } + + if transitions, ok := castSlice(state["transitions"]); ok { + for _, transitionValue := range transitions { + transition, ok := castMap(transitionValue) + if !ok { + continue + } + targetState := strings.TrimSpace(fmt.Sprint(transition["state_name"])) + if targetState == "" || targetState == "" { + continue + } + if conditions, ok := castMap(transition["conditions"]); ok { + if minIndexAge, exists := conditions["min_index_age"]; exists { + phaseMinAge[targetState] = minIndexAge + } + } + } + } + + phase := util.MapStr{} + if minAge, exists := phaseMinAge[stateName]; exists { + phase["min_age"] = minAge + } + + phaseActions := util.MapStr{} + if actions, ok := castSlice(state["actions"]); ok { + for _, actionValue := range actions { + action, ok := castMap(actionValue) + if !ok { + continue + } + for actionName, actionConfig := range action { + normalizedName, normalizedConfig, shouldInclude := normalizeILMActionForPut(actionName, actionConfig) + if !shouldInclude { + continue + } + phaseActions[normalizedName] = normalizedConfig + } + } + } + if len(phaseActions) > 0 { + phase["actions"] = phaseActions + } + + phaseBodies[stateName] = phase + } + + if len(phaseBodies) == 0 { + return nil, fmt.Errorf("ilm phases not found") + } + + for phaseName, minAge := range phaseMinAge { + if phase, ok := castMap(phaseBodies[phaseName]); ok { + if _, exists := phase["min_age"]; !exists { + phase["min_age"] = minAge + } + } + } + + return util.MapStr{ + "phases": phaseBodies, + }, nil +} + +func putILMPolicy(requester rawRequester, cfg *elastic.ElasticsearchConfig, policyID string, policy map[string]interface{}, seqNo, primaryTerm int64) error { + normalizedPolicy, err := normalizeILMPolicyForPut(policy) + if err != nil { + return err + } + path := "/_ilm/policy/" + url.PathEscape(policyID) + if seqNo >= 0 { + path += fmt.Sprintf("?if_seq_no=%d&if_primary_term=%d", seqNo, primaryTerm) + } + _, _, err = rawJSONRequest( + requester, + cfg, + util.Verb_PUT, + path, + util.MapStr{"policy": normalizedPolicy}, + ) + return err +} + +func deleteILMPolicy(requester rawRequester, cfg *elastic.ElasticsearchConfig, policyID string) error { + _, _, err := rawJSONRequest( + requester, + cfg, + util.Verb_DELETE, + "/_ilm/policy/"+url.PathEscape(policyID), + nil, + ) + return err +} + +func getILMRetentionDays(policy map[string]interface{}) (int, error) { + phases, ok := castMap(policy["phases"]) + if ok { + if deletePhase, ok := castMap(phases["delete"]); ok { + if days, err := parseRetentionDays(deletePhase["min_age"]); err == nil { + return days, nil + } + } + if hotPhase, ok := castMap(phases["hot"]); ok { + if actions, ok := castMap(hotPhase["actions"]); ok { + if rollover, ok := castMap(actions["rollover"]); ok { + return parseRetentionDays(rollover["max_age"]) + } + } + } + } + if _, ok := castSlice(policy["states"]); ok { + return getISMRetentionDays(policy) + } + return 0, fmt.Errorf("ilm phases not found") +} + +func getILMRetentionMaxSize(policy map[string]interface{}) (string, error) { + phases, ok := castMap(policy["phases"]) + if ok { + if hotPhase, ok := castMap(phases["hot"]); ok { + if actions, ok := castMap(hotPhase["actions"]); ok { + if rollover, ok := castMap(actions["rollover"]); ok { + return normalizeRetentionSize(rollover["max_size"]) + } + } + } + } + if _, ok := castSlice(policy["states"]); ok { + return getISMRetentionMaxSize(policy) + } + return "", fmt.Errorf("ilm rollover max_size not found") +} + +func setILMRetentionDays(policy map[string]interface{}, days int) error { + phases, ok := castMap(policy["phases"]) + if ok { + retention := fmt.Sprintf("%dd", days) + updated := false + if hotPhase, ok := castMap(phases["hot"]); ok { + if actions, ok := castMap(hotPhase["actions"]); ok { + if rollover, ok := castMap(actions["rollover"]); ok { + rollover["max_age"] = retention + updated = true + } + } + } + if deletePhase, ok := castMap(phases["delete"]); ok { + deletePhase["min_age"] = retention + updated = true + } + if !updated { + return fmt.Errorf("ilm retention settings not found") + } + return nil + } + if _, ok := castSlice(policy["states"]); ok { + return setISMRetentionDays(policy, days) + } + return fmt.Errorf("ilm phases not found") +} + +func setILMRetentionMaxSize(policy map[string]interface{}, size string) error { + normalizedSize, err := normalizeRetentionSize(size) + if err != nil { + return err + } + phases, ok := castMap(policy["phases"]) + if ok { + if hotPhase, ok := castMap(phases["hot"]); ok { + if actions, ok := castMap(hotPhase["actions"]); ok { + if rollover, ok := castMap(actions["rollover"]); ok { + rollover["max_size"] = normalizedSize + return nil + } + } + } + return fmt.Errorf("ilm rollover settings not found") + } + if _, ok := castSlice(policy["states"]); ok { + return setISMRetentionMaxSize(policy, normalizedSize) + } + return fmt.Errorf("ilm phases not found") +} + +func setRollupILMRetentionDays(policy map[string]interface{}, days int) error { + phases, ok := castMap(policy["phases"]) + if ok { + deletePhase, ok := castMap(phases["delete"]) + if !ok { + return fmt.Errorf("ilm delete phase not found") + } + retention := fmt.Sprintf("%dd", days) + deletePhase["min_age"] = retention + if actions, ok := castMap(deletePhase["actions"]); ok { + if deleteAction, ok := castMap(actions["delete"]); ok { + if _, exists := deleteAction["min_data_age"]; exists { + deleteAction["min_data_age"] = retention + } + } + } + return nil + } + states, ok := castSlice(policy["states"]) + if !ok { + return fmt.Errorf("ilm phases not found") + } + retention := fmt.Sprintf("%dd", days) + updated := false + for _, stateValue := range states { + state, ok := castMap(stateValue) + if !ok { + continue + } + if transitions, ok := castSlice(state["transitions"]); ok { + for _, transitionValue := range transitions { + transition, ok := castMap(transitionValue) + if !ok { + continue + } + if conditions, ok := castMap(transition["conditions"]); ok { + if _, exists := conditions["min_index_age"]; exists { + conditions["min_index_age"] = retention + updated = true + } + } + } + } + if actions, ok := castSlice(state["actions"]); ok { + for _, actionValue := range actions { + action, ok := castMap(actionValue) + if !ok { + continue + } + if deleteAction, ok := castMap(action["delete"]); ok { + if _, exists := deleteAction["min_data_age"]; exists { + deleteAction["min_data_age"] = retention + updated = true + } + } + } + } + } + if !updated { + return fmt.Errorf("ilm retention settings not found") + } + return nil +} + +func getISMRetentionDays(policy map[string]interface{}) (int, error) { + states, ok := castSlice(policy["states"]) + if !ok { + return 0, fmt.Errorf("ism states not found") + } + for _, stateValue := range states { + state, ok := castMap(stateValue) + if !ok { + continue + } + if transitions, ok := castSlice(state["transitions"]); ok { + for _, transitionValue := range transitions { + transition, ok := castMap(transitionValue) + if !ok { + continue + } + if conditions, ok := castMap(transition["conditions"]); ok { + if days, err := parseRetentionDays(conditions["min_index_age"]); err == nil { + return days, nil + } + } + } + } + if actions, ok := castSlice(state["actions"]); ok { + for _, actionValue := range actions { + action, ok := castMap(actionValue) + if !ok { + continue + } + if rollover, ok := castMap(action["rollover"]); ok { + if days, err := parseRetentionDays(rollover["min_index_age"]); err == nil { + return days, nil + } + } + } + } + } + return 0, fmt.Errorf("ism retention days not found") +} + +func getISMRetentionMaxSize(policy map[string]interface{}) (string, error) { + states, ok := castSlice(policy["states"]) + if !ok { + return "", fmt.Errorf("ism states not found") + } + for _, stateValue := range states { + state, ok := castMap(stateValue) + if !ok { + continue + } + if actions, ok := castSlice(state["actions"]); ok { + for _, actionValue := range actions { + action, ok := castMap(actionValue) + if !ok { + continue + } + if rollover, ok := castMap(action["rollover"]); ok { + if size, err := normalizeRetentionSize(rollover["min_size"]); err == nil { + return size, nil + } + } + } + } + } + return "", fmt.Errorf("ism retention max size not found") +} + +func setISMRetentionDays(policy map[string]interface{}, days int) error { + states, ok := castSlice(policy["states"]) + if !ok { + return fmt.Errorf("ism states not found") + } + retention := fmt.Sprintf("%dd", days) + updated := false + for _, stateValue := range states { + state, ok := castMap(stateValue) + if !ok { + continue + } + if actions, ok := castSlice(state["actions"]); ok { + for _, actionValue := range actions { + action, ok := castMap(actionValue) + if !ok { + continue + } + if rollover, ok := castMap(action["rollover"]); ok { + rollover["min_index_age"] = retention + updated = true + } + } + } + if transitions, ok := castSlice(state["transitions"]); ok { + for _, transitionValue := range transitions { + transition, ok := castMap(transitionValue) + if !ok { + continue + } + if conditions, ok := castMap(transition["conditions"]); ok { + if _, exists := conditions["min_index_age"]; exists { + conditions["min_index_age"] = retention + updated = true + } + } + } + } + } + if !updated { + return fmt.Errorf("ism retention settings not found") + } + return nil +} + +func setISMRetentionMaxSize(policy map[string]interface{}, size string) error { + normalizedSize, err := normalizeRetentionSize(size) + if err != nil { + return err + } + states, ok := castSlice(policy["states"]) + if !ok { + return fmt.Errorf("ism states not found") + } + updated := false + for _, stateValue := range states { + state, ok := castMap(stateValue) + if !ok { + continue + } + if actions, ok := castSlice(state["actions"]); ok { + for _, actionValue := range actions { + action, ok := castMap(actionValue) + if !ok { + continue + } + if rollover, ok := castMap(action["rollover"]); ok { + rollover["min_size"] = normalizedSize + updated = true + } + } + } + } + if !updated { + return fmt.Errorf("ism rollover settings not found") + } + return nil +} + +func getRetentionSettings(client elastic.API, cfg *elastic.ElasticsearchConfig) (int, string, error) { + requester, ok := client.(rawRequester) + if !ok { + return 0, "", fmt.Errorf("cluster client does not support raw requests") + } + indexPrefix := resolveSystemIndexPrefix() + if strings.EqualFold(cfg.Distribution, elastic.Opensearch) { + policyID := getMetricsRetentionPolicyID(indexPrefix, defaultRetentionDays) + response, statusCode, err := rawJSONRequest( + requester, + cfg, + util.Verb_GET, + "/_plugins/_ism/policies/"+url.PathEscape(policyID), + nil, + ) + if err != nil { + return 0, "", err + } + if statusCode == http.StatusNotFound { + return defaultRetentionDays, defaultRetentionMaxSize, nil + } + policy, ok := castMap(response["policy"]) + if !ok { + return 0, "", fmt.Errorf("ism policy payload not found") + } + days, err := getISMRetentionDays(policy) + if err != nil { + return 0, "", err + } + maxSize, err := getISMRetentionMaxSize(policy) + if err != nil { + maxSize = defaultRetentionMaxSize + } + return days, maxSize, nil + } + + currentMetricsPolicyID := getMetricsRetentionPolicyID(indexPrefix, defaultRetentionDays) + template, statusCode, err := getLegacyTemplate(requester, cfg, indexPrefix+"metrics-rollover") + if err != nil { + return 0, "", err + } + currentDays := defaultRetentionDays + if statusCode == http.StatusNotFound { + return currentDays, defaultRetentionMaxSize, nil + } + if policyID, err := readTemplateLifecyclePolicyID(template); err == nil { + currentMetricsPolicyID = policyID + if days, parseErr := parseRetentionDaysFromPolicyID(policyID); parseErr == nil { + currentDays = days + } + } + + policy, _, _, statusCode, err := getILMPolicy(requester, cfg, currentMetricsPolicyID) + if err != nil { + return 0, "", err + } + if statusCode == http.StatusNotFound { + return currentDays, defaultRetentionMaxSize, nil + } + days, err := getILMRetentionDays(policy) + if err != nil { + return 0, "", err + } + maxSize, err := getILMRetentionMaxSize(policy) + if err != nil { + maxSize = defaultRetentionMaxSize + } + return days, maxSize, nil +} + +func updateRetentionSettings(client elastic.API, cfg *elastic.ElasticsearchConfig, days int, maxSize string) error { + requester, ok := client.(rawRequester) + if !ok { + return fmt.Errorf("cluster client does not support raw requests") + } + normalizedMaxSize, err := normalizeRetentionSize(maxSize) + if err != nil { + return err + } + indexPrefix := resolveSystemIndexPrefix() + if strings.EqualFold(cfg.Distribution, elastic.Opensearch) { + policyID := getMetricsRetentionPolicyID(indexPrefix, defaultRetentionDays) + response, statusCode, err := rawJSONRequest( + requester, + cfg, + util.Verb_GET, + "/_plugins/_ism/policies/"+url.PathEscape(policyID), + nil, + ) + if err != nil { + return err + } + if statusCode == http.StatusNotFound { + return fmt.Errorf("ism policy %s not found", policyID) + } + policy, ok := castMap(response["policy"]) + if !ok { + return fmt.Errorf("ism policy payload not found") + } + if err := setISMRetentionDays(policy, days); err != nil { + return err + } + if err := setISMRetentionMaxSize(policy, normalizedMaxSize); err != nil { + return err + } + _, _, err = rawJSONRequest( + requester, + cfg, + util.Verb_PUT, + "/_plugins/_ism/policies/"+url.PathEscape(policyID), + util.MapStr{"policy": policy}, + ) + return err + } + + currentMetricsPolicyID := getMetricsRetentionPolicyID(indexPrefix, defaultRetentionDays) + if template, statusCode, err := getLegacyTemplate(requester, cfg, indexPrefix+"metrics-rollover"); err != nil { + return err + } else if statusCode != http.StatusNotFound { + if policyID, err := readTemplateLifecyclePolicyID(template); err == nil { + currentMetricsPolicyID = policyID + } + } + newMetricsPolicyID := getMetricsRetentionPolicyID(indexPrefix, days) + metricsPolicy, seqNo, primaryTerm, statusCode, err := getILMPolicy(requester, cfg, currentMetricsPolicyID) + if err != nil { + return err + } + if statusCode == http.StatusNotFound { + return fmt.Errorf("ilm policy %s not found", currentMetricsPolicyID) + } + if err := setILMRetentionDays(metricsPolicy, days); err != nil { + return err + } + if err := setILMRetentionMaxSize(metricsPolicy, normalizedMaxSize); err != nil { + return err + } + if currentMetricsPolicyID != newMetricsPolicyID { + if err := deleteILMPolicy(requester, cfg, newMetricsPolicyID); err != nil { + return err + } + seqNo, primaryTerm = -1, -1 + } + if err := putILMPolicy(requester, cfg, newMetricsPolicyID, metricsPolicy, seqNo, primaryTerm); err != nil { + return err + } + + for _, templateName := range getManagedRetentionTemplateNames(indexPrefix) { + template, statusCode, err := getLegacyTemplate(requester, cfg, templateName) + if err != nil { + return err + } + if statusCode == http.StatusNotFound { + continue + } + if err := setTemplateLifecyclePolicyID(template, newMetricsPolicyID); err != nil { + return err + } + if err := putLegacyTemplate(requester, cfg, templateName, template); err != nil { + return err + } + } + + _, _, err = rawJSONRequest( + requester, + cfg, + util.Verb_PUT, + "/"+strings.Join(getManagedRetentionIndexPatterns(indexPrefix), ",")+"/_settings?allow_no_indices=true&ignore_unavailable=true", + util.MapStr{ + "index": util.MapStr{ + "lifecycle": util.MapStr{ + "name": newMetricsPolicyID, + }, + }, + }, + ) + if err != nil { + return err + } + + rollupTemplate, statusCode, err := getLegacyTemplate(requester, cfg, "rollup_policy_template") + if err != nil { + return err + } + if statusCode == http.StatusNotFound { + return nil + } + currentRollupPolicyID, err := readTemplateLifecyclePolicyID(rollupTemplate) + if err != nil { + return err + } + newRollupPolicyID := getRollupRetentionPolicyID(indexPrefix, days) + rollupPolicy, rollupSeqNo, rollupPrimaryTerm, statusCode, err := getILMPolicy(requester, cfg, currentRollupPolicyID) + if err != nil { + return err + } + if statusCode == http.StatusNotFound { + return fmt.Errorf("ilm policy %s not found", currentRollupPolicyID) + } + if err := setRollupILMRetentionDays(rollupPolicy, days); err != nil { + return err + } + if currentRollupPolicyID != newRollupPolicyID { + if err := deleteILMPolicy(requester, cfg, newRollupPolicyID); err != nil { + return err + } + rollupSeqNo, rollupPrimaryTerm = -1, -1 + } + if err := putILMPolicy(requester, cfg, newRollupPolicyID, rollupPolicy, rollupSeqNo, rollupPrimaryTerm); err != nil { + return err + } + if err := setTemplateLifecyclePolicyID(rollupTemplate, newRollupPolicyID); err != nil { + return err + } + if err := putLegacyTemplate(requester, cfg, "rollup_policy_template", rollupTemplate); err != nil { + return err + } + _, _, err = rawJSONRequest( + requester, + cfg, + util.Verb_PUT, + "/rollup*/_settings?allow_no_indices=true&ignore_unavailable=true", + util.MapStr{ + "index.lifecycle.name": newRollupPolicyID, + }, + ) + return err +} + +func getRollupEnabled(client elastic.API) (bool, error) { + settings, err := client.GetClusterSettings(nil) + if err != nil { + return false, err + } + rollupEnabled, _ := util.GetMapValueByKeys([]string{"persistent", "rollup", "search", "enabled"}, settings) + switch value := rollupEnabled.(type) { + case string: + return strings.EqualFold(value, "true"), nil + case bool: + return value, nil + default: + return false, nil + } +} + +func getSystemClusterClient() (elastic.API, *elastic.ElasticsearchConfig, error) { + systemClusterID := global.MustLookupString(elastic.GlobalSystemElasticsearchID) + cfg := elastic.GetConfigNoPanic(systemClusterID) + if cfg == nil { + return nil, nil, fmt.Errorf("system cluster config not found") + } + client := elastic.GetClient(systemClusterID) + if client == nil { + return nil, nil, fmt.Errorf("system cluster client not found") + } + return client, cfg, nil +} + +func (h *SettingsAPI) getRetentionSetting(w http.ResponseWriter, req *http.Request, ps httprouter.Params) { + client, cfg, err := getSystemClusterClient() + if err != nil { + log.Error(err) + h.WriteError(w, err.Error(), http.StatusInternalServerError) + return + } + days, maxSize, err := getRetentionSettings(client, cfg) + if err != nil { + log.Error(err) + h.WriteError(w, err.Error(), http.StatusInternalServerError) + return + } + h.WriteJSON(w, util.MapStr{ + "days": days, + "max_size": maxSize, + }, http.StatusOK) +} + +func (h *SettingsAPI) updateRetentionSetting(w http.ResponseWriter, req *http.Request, ps httprouter.Params) { + payload := retentionSettingsRequest{} + body, err := h.GetRawBody(req) + if err != nil { + log.Error(err) + h.WriteError(w, err.Error(), http.StatusBadRequest) + return + } + if err := util.FromJSONBytes(body, &payload); err != nil { + log.Error(err) + h.WriteError(w, err.Error(), http.StatusBadRequest) + return + } + if payload.Days <= 0 { + h.WriteError(w, "retention days must be greater than 0", http.StatusBadRequest) + return + } + + client, cfg, err := getSystemClusterClient() + if err != nil { + log.Error(err) + h.WriteError(w, err.Error(), http.StatusInternalServerError) + return + } + currentMaxSize := defaultRetentionMaxSize + if payload.MaxSize == "" { + _, currentSize, err := getRetentionSettings(client, cfg) + if err != nil { + log.Error(err) + h.WriteError(w, err.Error(), http.StatusInternalServerError) + return + } + currentMaxSize = currentSize + } else { + currentMaxSize = payload.MaxSize + } + normalizedMaxSize, err := normalizeRetentionSize(currentMaxSize) + if err != nil { + log.Error(err) + h.WriteError(w, err.Error(), http.StatusBadRequest) + return + } + if err := updateRetentionSettings(client, cfg, payload.Days, normalizedMaxSize); err != nil { + log.Error(err) + h.WriteError(w, err.Error(), http.StatusInternalServerError) + return + } + h.WriteJSON(w, util.MapStr{ + "days": payload.Days, + "max_size": normalizedMaxSize, + }, http.StatusOK) +} + +func updateRollupJobs(client elastic.API, cfg *elastic.ElasticsearchConfig, action string) error { + requester, ok := client.(rawRequester) + if !ok { + return fmt.Errorf("cluster client does not support raw requests") + } + url := fmt.Sprintf("%s/_rollup/jobs/rollup*/_%s", cfg.GetAnyEndpoint(), action) + resp, err := requester.Request(context.Background(), util.Verb_POST, url, nil) + if err != nil { + return err + } + if resp.StatusCode == http.StatusNotFound { + return nil + } + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("%s", resp.Body) + } + return nil +} + +func (h *SettingsAPI) getRollupSetting(w http.ResponseWriter, req *http.Request, ps httprouter.Params) { + client, _, err := getSystemClusterClient() + if err != nil { + log.Error(err) + h.WriteError(w, err.Error(), http.StatusInternalServerError) + return + } + enabled, err := getRollupEnabled(client) + if err != nil { + log.Error(err) + h.WriteError(w, err.Error(), http.StatusInternalServerError) + return + } + h.WriteJSON(w, util.MapStr{ + "enabled": enabled, + }, http.StatusOK) +} + +func (h *SettingsAPI) updateRollupSetting(w http.ResponseWriter, req *http.Request, ps httprouter.Params) { + payload := rollupSettingsRequest{} + body, err := h.GetRawBody(req) + if err != nil { + log.Error(err) + h.WriteError(w, err.Error(), http.StatusBadRequest) + return + } + if err := util.FromJSONBytes(body, &payload); err != nil { + log.Error(err) + h.WriteError(w, err.Error(), http.StatusBadRequest) + return + } + + client, cfg, err := getSystemClusterClient() + if err != nil { + log.Error(err) + h.WriteError(w, err.Error(), http.StatusInternalServerError) + return + } + + if !payload.Enabled { + err = updateRollupJobs(client, cfg, "stop") + if err != nil { + log.Error(err) + h.WriteError(w, err.Error(), http.StatusInternalServerError) + return + } + } + + err = client.UpdateClusterSettings(util.MustToJSONBytes(util.MapStr{ + "persistent": util.MapStr{ + "rollup": util.MapStr{ + "search": util.MapStr{ + "enabled": fmt.Sprintf("%t", payload.Enabled), + }, + }, + }, + })) + if err != nil { + log.Error(err) + h.WriteError(w, err.Error(), http.StatusInternalServerError) + return + } + + if payload.Enabled { + err = updateRollupJobs(client, cfg, "start") + if err != nil { + log.Error(err) + h.WriteError(w, err.Error(), http.StatusInternalServerError) + return + } + } + + h.WriteJSON(w, util.MapStr{ + "enabled": payload.Enabled, + }, http.StatusOK) +} diff --git a/plugin/api/settings/api_test.go b/plugin/api/settings/api_test.go new file mode 100644 index 00000000..ac9a2340 --- /dev/null +++ b/plugin/api/settings/api_test.go @@ -0,0 +1,483 @@ +package settings + +import "testing" + +func TestSetILMRetentionDays(t *testing.T) { + policy := map[string]interface{}{ + "phases": map[string]interface{}{ + "hot": map[string]interface{}{ + "actions": map[string]interface{}{ + "rollover": map[string]interface{}{ + "max_age": "30d", + "max_size": "50gb", + }, + }, + }, + "delete": map[string]interface{}{ + "min_age": "30d", + }, + }, + } + + if err := setILMRetentionDays(policy, 90); err != nil { + t.Fatalf("setILMRetentionDays returned error: %v", err) + } + + days, err := getILMRetentionDays(policy) + if err != nil { + t.Fatalf("getILMRetentionDays returned error: %v", err) + } + if days != 90 { + t.Fatalf("expected 90 retention days, got %d", days) + } +} + +func TestNormalizeRetentionSize(t *testing.T) { + size, err := normalizeRetentionSize("50 GB") + if err != nil { + t.Fatalf("normalizeRetentionSize returned error: %v", err) + } + if size != "50gb" { + t.Fatalf("expected normalized size 50gb, got %s", size) + } +} + +func TestSetILMRetentionDaysFromStates(t *testing.T) { + policy := map[string]interface{}{ + "states": []interface{}{ + map[string]interface{}{ + "name": "hot", + "actions": []interface{}{ + map[string]interface{}{ + "rollover": map[string]interface{}{ + "min_index_age": "30d", + }, + }, + }, + "transitions": []interface{}{ + map[string]interface{}{ + "state_name": "delete", + "conditions": map[string]interface{}{ + "min_index_age": "30d", + }, + }, + }, + }, + }, + } + + if err := setILMRetentionDays(policy, 7); err != nil { + t.Fatalf("setILMRetentionDays returned error for states payload: %v", err) + } + + days, err := getILMRetentionDays(policy) + if err != nil { + t.Fatalf("getILMRetentionDays returned error for states payload: %v", err) + } + if days != 7 { + t.Fatalf("expected 7 retention days, got %d", days) + } +} + +func TestSetILMRetentionMaxSize(t *testing.T) { + policy := map[string]interface{}{ + "phases": map[string]interface{}{ + "hot": map[string]interface{}{ + "actions": map[string]interface{}{ + "rollover": map[string]interface{}{ + "max_age": "30d", + "max_size": "50gb", + }, + }, + }, + "delete": map[string]interface{}{ + "min_age": "30d", + }, + }, + } + + if err := setILMRetentionMaxSize(policy, "80 GB"); err != nil { + t.Fatalf("setILMRetentionMaxSize returned error: %v", err) + } + + size, err := getILMRetentionMaxSize(policy) + if err != nil { + t.Fatalf("getILMRetentionMaxSize returned error: %v", err) + } + if size != "80gb" { + t.Fatalf("expected 80gb retention size, got %s", size) + } +} + +func TestNormalizeILMPolicyForPutFromStates(t *testing.T) { + policy := map[string]interface{}{ + "states": []interface{}{ + map[string]interface{}{ + "name": "hot", + "actions": []interface{}{ + map[string]interface{}{ + "retry": map[string]interface{}{ + "count": 3, + "backoff": "exponential", + "delay": "1m", + }, + "rollover": map[string]interface{}{ + "min_index_age": "30d", + "min_size": "50gb", + "min_doc_count": 100000000, + }, + }, + map[string]interface{}{ + "retry": map[string]interface{}{ + "count": 3, + "backoff": "exponential", + "delay": "1m", + }, + "index_priority": map[string]interface{}{ + "priority": 100, + }, + }, + }, + "transitions": []interface{}{ + map[string]interface{}{ + "state_name": "delete", + "conditions": map[string]interface{}{ + "min_index_age": "30d", + }, + }, + }, + }, + map[string]interface{}{ + "name": "delete", + "actions": []interface{}{ + map[string]interface{}{ + "delete": nil, + }, + }, + }, + }, + } + + if err := setILMRetentionDays(policy, 7); err != nil { + t.Fatalf("setILMRetentionDays returned error for states payload: %v", err) + } + + normalized, err := normalizeILMPolicyForPut(policy) + if err != nil { + t.Fatalf("normalizeILMPolicyForPut returned error: %v", err) + } + + phases := normalized["phases"].(map[string]interface{}) + hotPhase := phases["hot"].(map[string]interface{}) + if got := hotPhase["min_age"]; got != "0ms" { + t.Fatalf("expected hot min_age 0ms, got %v", got) + } + hotActions := hotPhase["actions"].(map[string]interface{}) + rollover := hotActions["rollover"].(map[string]interface{}) + if got := rollover["max_age"]; got != "7d" { + t.Fatalf("expected rollover max_age 7d, got %v", got) + } + if got := rollover["max_size"]; got != "50gb" { + t.Fatalf("expected rollover max_size 50gb, got %v", got) + } + if got := rollover["max_docs"]; got != 100000000 { + t.Fatalf("expected rollover max_docs 100000000, got %v", got) + } + if _, exists := rollover["min_index_age"]; exists { + t.Fatalf("expected rollover min_index_age to be removed, got %#v", rollover) + } + if _, exists := rollover["min_size"]; exists { + t.Fatalf("expected rollover min_size to be removed, got %#v", rollover) + } + if _, exists := rollover["min_doc_count"]; exists { + t.Fatalf("expected rollover min_doc_count to be removed, got %#v", rollover) + } + if _, exists := hotActions["retry"]; exists { + t.Fatalf("expected retry metadata to be stripped from ILM actions, got %#v", hotActions) + } + + setPriority := hotActions["set_priority"].(map[string]interface{}) + if got := setPriority["priority"]; got != 100 { + t.Fatalf("expected set_priority priority 100, got %v", got) + } + + deletePhase := phases["delete"].(map[string]interface{}) + if got := deletePhase["min_age"]; got != "7d" { + t.Fatalf("expected delete min_age 7d, got %v", got) + } + deleteActions := deletePhase["actions"].(map[string]interface{}) + if got := deleteActions["delete"]; got == nil { + t.Fatalf("expected delete action payload to be an object, got nil") + } + if deletePayload, ok := deleteActions["delete"].(map[string]interface{}); !ok || len(deletePayload) != 0 { + t.Fatalf("expected empty delete action payload, got %#v", deleteActions["delete"]) + } +} + +func TestSetISMRetentionDays(t *testing.T) { + policy := map[string]interface{}{ + "states": []interface{}{ + map[string]interface{}{ + "name": "hot", + "actions": []interface{}{ + map[string]interface{}{ + "rollover": map[string]interface{}{ + "min_index_age": "30d", + "min_size": "50gb", + }, + }, + }, + "transitions": []interface{}{ + map[string]interface{}{ + "state_name": "delete", + "conditions": map[string]interface{}{ + "min_index_age": "30d", + }, + }, + }, + }, + }, + } + + if err := setISMRetentionDays(policy, 60); err != nil { + t.Fatalf("setISMRetentionDays returned error: %v", err) + } + + days, err := getISMRetentionDays(policy) + if err != nil { + t.Fatalf("getISMRetentionDays returned error: %v", err) + } + if days != 60 { + t.Fatalf("expected 60 retention days, got %d", days) + } +} + +func TestSetISMRetentionMaxSize(t *testing.T) { + policy := map[string]interface{}{ + "states": []interface{}{ + map[string]interface{}{ + "name": "hot", + "actions": []interface{}{ + map[string]interface{}{ + "rollover": map[string]interface{}{ + "min_index_age": "30d", + "min_size": "50gb", + }, + }, + }, + }, + }, + } + + if err := setISMRetentionMaxSize(policy, "120g"); err != nil { + t.Fatalf("setISMRetentionMaxSize returned error: %v", err) + } + + size, err := getISMRetentionMaxSize(policy) + if err != nil { + t.Fatalf("getISMRetentionMaxSize returned error: %v", err) + } + if size != "120gb" { + t.Fatalf("expected 120gb retention size, got %s", size) + } +} + +func TestSetRollupILMRetentionDaysFromStates(t *testing.T) { + policy := map[string]interface{}{ + "states": []interface{}{ + map[string]interface{}{ + "name": "hot", + "transitions": []interface{}{ + map[string]interface{}{ + "state_name": "delete", + "conditions": map[string]interface{}{ + "min_index_age": "30d", + }, + }, + }, + }, + map[string]interface{}{ + "name": "delete", + "actions": []interface{}{ + map[string]interface{}{ + "delete": map[string]interface{}{ + "min_data_age": "30d", + }, + }, + }, + }, + }, + } + + if err := setRollupILMRetentionDays(policy, 7); err != nil { + t.Fatalf("setRollupILMRetentionDays returned error for states payload: %v", err) + } + + states := policy["states"].([]interface{}) + hotState := states[0].(map[string]interface{}) + transition := hotState["transitions"].([]interface{})[0].(map[string]interface{}) + if got := transition["conditions"].(map[string]interface{})["min_index_age"]; got != "7d" { + t.Fatalf("expected transition min_index_age 7d, got %v", got) + } + + deleteState := states[1].(map[string]interface{}) + deleteAction := deleteState["actions"].([]interface{})[0].(map[string]interface{})["delete"].(map[string]interface{}) + if got := deleteAction["min_data_age"]; got != "7d" { + t.Fatalf("expected delete min_data_age 7d, got %v", got) + } +} + +func TestNormalizeRollupILMPolicyForPutFromStates(t *testing.T) { + policy := map[string]interface{}{ + "states": []interface{}{ + map[string]interface{}{ + "name": "hot", + "transitions": []interface{}{ + map[string]interface{}{ + "state_name": "delete", + "conditions": map[string]interface{}{ + "min_index_age": "30d", + }, + }, + }, + }, + map[string]interface{}{ + "name": "delete", + "actions": []interface{}{ + map[string]interface{}{ + "delete": map[string]interface{}{ + "timestamp_field": "timestamp.date_histogram", + "min_data_age": "30d", + }, + }, + }, + }, + }, + } + + if err := setRollupILMRetentionDays(policy, 7); err != nil { + t.Fatalf("setRollupILMRetentionDays returned error for states payload: %v", err) + } + + normalized, err := normalizeILMPolicyForPut(policy) + if err != nil { + t.Fatalf("normalizeILMPolicyForPut returned error: %v", err) + } + + phases := normalized["phases"].(map[string]interface{}) + deletePhase := phases["delete"].(map[string]interface{}) + if got := deletePhase["min_age"]; got != "7d" { + t.Fatalf("expected delete min_age 7d, got %v", got) + } + + deleteAction := deletePhase["actions"].(map[string]interface{})["delete"].(map[string]interface{}) + if got := deleteAction["min_data_age"]; got != "7d" { + t.Fatalf("expected delete min_data_age 7d, got %v", got) + } + if got := deleteAction["timestamp_field"]; got != "timestamp.date_histogram" { + t.Fatalf("expected timestamp_field to be preserved, got %v", got) + } +} + +func TestParseRetentionDays(t *testing.T) { + days, err := parseRetentionDays("30d") + if err != nil { + t.Fatalf("parseRetentionDays returned error: %v", err) + } + if days != 30 { + t.Fatalf("expected 30 retention days, got %d", days) + } +} + +func TestParseRetentionDaysFromPolicyID(t *testing.T) { + days, err := parseRetentionDaysFromPolicyID("ilm_.infini_metrics-7days-retention") + if err != nil { + t.Fatalf("parseRetentionDaysFromPolicyID returned error: %v", err) + } + if days != 7 { + t.Fatalf("expected 7 retention days, got %d", days) + } +} + +func TestSetRollupILMRetentionDays(t *testing.T) { + policy := map[string]interface{}{ + "phases": map[string]interface{}{ + "hot": map[string]interface{}{ + "min_age": "0ms", + }, + "delete": map[string]interface{}{ + "min_age": "30d", + "actions": map[string]interface{}{ + "delete": map[string]interface{}{ + "min_data_age": "30d", + }, + }, + }, + }, + } + + if err := setRollupILMRetentionDays(policy, 7); err != nil { + t.Fatalf("setRollupILMRetentionDays returned error: %v", err) + } + + deletePhase := policy["phases"].(map[string]interface{})["delete"].(map[string]interface{}) + if got := deletePhase["min_age"]; got != "7d" { + t.Fatalf("expected delete min_age 7d, got %v", got) + } + deleteAction := deletePhase["actions"].(map[string]interface{})["delete"].(map[string]interface{}) + if got := deleteAction["min_data_age"]; got != "7d" { + t.Fatalf("expected delete min_data_age 7d, got %v", got) + } +} + +func TestExtractILMPolicyByExactPolicyID(t *testing.T) { + response := map[string]interface{}{ + "ilm_.infini_metrics-30days-retention": map[string]interface{}{ + "policy": map[string]interface{}{ + "phases": map[string]interface{}{}, + }, + }, + } + + policy, err := extractILMPolicy(response, "ilm_.infini_metrics-30days-retention") + if err != nil { + t.Fatalf("extractILMPolicy returned error: %v", err) + } + if _, ok := policy["phases"]; !ok { + t.Fatalf("expected extracted policy phases, got %#v", policy) + } +} + +func TestExtractILMPolicyFromSingleEntryFallback(t *testing.T) { + response := map[string]interface{}{ + "ilm_.infini_metrics-7days-retention": map[string]interface{}{ + "policy": map[string]interface{}{ + "phases": map[string]interface{}{}, + }, + }, + } + + policy, err := extractILMPolicy(response, "ilm_.infini_metrics-30days-retention") + if err != nil { + t.Fatalf("extractILMPolicy returned error: %v", err) + } + if _, ok := policy["phases"]; !ok { + t.Fatalf("expected extracted policy phases, got %#v", policy) + } +} + +func TestExtractILMPolicyFromDirectBody(t *testing.T) { + response := map[string]interface{}{ + "policy": map[string]interface{}{ + "phases": map[string]interface{}{}, + }, + } + + policy, err := extractILMPolicy(response, "ilm_.infini_metrics-30days-retention") + if err != nil { + t.Fatalf("extractILMPolicy returned error: %v", err) + } + if _, ok := policy["phases"]; !ok { + t.Fatalf("expected extracted policy phases, got %#v", policy) + } +} diff --git a/plugin/managed/server/instance.go b/plugin/managed/server/instance.go index 0fe130b3..9173d38f 100644 --- a/plugin/managed/server/instance.go +++ b/plugin/managed/server/instance.go @@ -30,10 +30,12 @@ package server import ( "context" "fmt" + console_common "infini.sh/console/common" "infini.sh/framework/core/event" "infini.sh/framework/core/global" "infini.sh/framework/core/task" "net/http" + "net/url" "strconv" "strings" "time" @@ -568,6 +570,13 @@ func (h *APIHandler) proxy(w http.ResponseWriter, req *http.Request, ps httprout panic(err) } + if isSensitiveInfoPath(path) && len(res.Body) > 0 { + res.Body, err = console_common.SanitizeInstanceInfoBytes(res.Body) + if err != nil { + panic(err) + } + } + h.WriteHeader(w, res.StatusCode) h.Write(w, res.Body) } @@ -607,7 +616,15 @@ func (h *APIHandler) tryConnect(w http.ResponseWriter, req *http.Request, ps htt h.WriteError(w, err.Error(), http.StatusInternalServerError) return } - h.WriteJSON(w, connectRes, http.StatusOK) + h.WriteJSON(w, console_common.SanitizeInstanceInfoMap(util.MapStr{ + "id": connectRes.ID, + "name": connectRes.Name, + "application": connectRes.Application, + "labels": connectRes.Labels, + "tags": connectRes.Tags, + "description": connectRes.Description, + "status": connectRes.Status, + }), http.StatusOK) } func (h *APIHandler) tryESConnect(w http.ResponseWriter, req *http.Request, ps httprouter.Params) { @@ -674,6 +691,17 @@ func (h *APIHandler) tryESConnect(w http.ResponseWriter, req *http.Request, ps h h.Write(w, res.Body) } +func isSensitiveInfoPath(rawPath string) bool { + if rawPath == "" { + return false + } + parsed, err := url.Parse(rawPath) + if err != nil { + return rawPath == "/_info" + } + return parsed.Path == "/_info" +} + // TODO check permission by user func GetRuntimeInstanceByID(instanceID string) (bool, *model.Instance, error) { obj := model.Instance{} diff --git a/plugin/setup/setup.go b/plugin/setup/setup.go index cfc60b6d..46c127cb 100644 --- a/plugin/setup/setup.go +++ b/plugin/setup/setup.go @@ -35,16 +35,21 @@ import ( "net/http" uri2 "net/url" "path" + "regexp" "runtime" + "strconv" "strings" + "sync" + "sync/atomic" "time" + console_common "infini.sh/console/common" + core2 "infini.sh/console/core" "infini.sh/console/core/security" "infini.sh/framework/lib/go-ucfg" elastic2 "infini.sh/framework/modules/elastic" log "github.com/cihub/seelog" - "golang.org/x/crypto/bcrypt" elastic3 "infini.sh/console/modules/elastic/api" security2 "infini.sh/console/modules/security" "infini.sh/framework/core/api" @@ -64,7 +69,6 @@ import ( "infini.sh/framework/lib/fasthttp" "infini.sh/framework/lib/fasttemplate" keystore2 "infini.sh/framework/lib/keystore" - "infini.sh/framework/modules/elastic/adapter" elastic1 "infini.sh/framework/modules/elastic/common" "infini.sh/framework/plugins/replay" ) @@ -92,23 +96,35 @@ func (module *Module) Setup() { return } - api.HandleAPIMethod(api.POST, "/setup/_validate", module.validate) - api.HandleAPIMethod(api.POST, "/setup/_initialize", module.initialize) - api.HandleAPIMethod(api.POST, "/setup/_validate_secret", module.validateSecret) + api.HandleAPIMethod(api.POST, "/setup/_validate", core2.RequireSecureTransport(core2.RequireReplayProtection(module.validate))) + api.HandleAPIMethod(api.POST, "/setup/_initialize", core2.RequireSecureTransport(core2.RequireReplayProtection(module.initialize))) + api.HandleAPIMethod(api.POST, "/setup/_validate_secret", core2.RequireSecureTransport(core2.RequireReplayProtection(module.validateSecret))) api.HandleAPIMethod(api.POST, "/setup/_initialize_template", module.initializeTemplate) elastic3.InitTestAPI() } var setupFinishedCallback = []func(){} +var setupCallbackOnce sync.Once +var setupInitializeRunning uint32 func RegisterSetupCallback(f func()) { setupFinishedCallback = append(setupFinishedCallback, f) } func InvokeSetupCallback() { - for _, v := range setupFinishedCallback { - v() - } + setupCallbackOnce.Do(func() { + for _, v := range setupFinishedCallback { + v() + } + }) +} + +func acquireSetupInitialization() bool { + return atomic.CompareAndSwapUint32(&setupInitializeRunning, 0, 1) +} + +func releaseSetupInitialization() { + atomic.StoreUint32(&setupInitializeRunning, 0) } // Start initializes the module and registers a change event for credentials. @@ -158,6 +174,8 @@ type SetupRequest struct { BootstrapPassword string `json:"bootstrap_password"` CredentialSecret string `json:"credential_secret"` InitializeTemplate string `json:"initialize_template"` + PrimaryShards int `json:"primary_shards"` + AutoExpandReplicas string `json:"auto_expand_replicas"` } var GlobalSystemElasticsearchID = "infini_default_system_cluster" @@ -169,6 +187,40 @@ const VersionNotSupport = "unknown_cluster_version" var cfg1 elastic1.ORMConfig +const defaultSetupAutoExpandReplicas = "0-1" + +var setupAutoExpandReplicasPattern = regexp.MustCompile(`^(false|all|\d+-\d+)$`) + +func resolveSetupTemplateSettings(client elastic.API, request *SetupRequest) (int, string, error) { + primaryShards := request.PrimaryShards + if primaryShards <= 0 { + health, err := client.ClusterHealth(context.Background()) + if err != nil { + return 0, "", err + } + if health != nil { + if health.NumberOf_data_nodes > 0 { + primaryShards = health.NumberOf_data_nodes + } else if health.NumberOfNodes > 0 { + primaryShards = health.NumberOfNodes + } + } + } + if primaryShards <= 0 { + primaryShards = 1 + } + + autoExpandReplicas := strings.TrimSpace(request.AutoExpandReplicas) + if autoExpandReplicas == "" { + autoExpandReplicas = defaultSetupAutoExpandReplicas + } + if !setupAutoExpandReplicasPattern.MatchString(autoExpandReplicas) { + return 0, "", errors.Errorf("invalid auto expand replicas, expected false, all, or a range like 0-1") + } + + return primaryShards, autoExpandReplicas, nil +} + // validate checks the Elasticsearch cluster configuration and validates the setup. func (module *Module) validate(w http.ResponseWriter, r *http.Request, ps httprouter.Params) { @@ -230,7 +282,7 @@ func (module *Module) validate(w http.ResponseWriter, r *http.Request, ps httpro } //validate version - verInfo, err := adapter.ClusterVersion(elastic.GetMetadata(cfg.ID)) + verInfo, err := console_common.ClusterVersion(elastic.GetMetadata(cfg.ID)) if err != nil { panic(err) } @@ -338,7 +390,7 @@ func (module *Module) initTempClient(request *SetupRequest) (error, elastic.API) cfg.ID = GlobalSystemElasticsearchID cfg.Name = "INFINI_SYSTEM (" + util.PickRandomName() + ")" elastic.InitMetadata(&cfg, true) - verInfo, err := adapter.ClusterVersion(elastic.GetMetadata(cfg.ID)) + verInfo, err := console_common.ClusterVersion(elastic.GetMetadata(cfg.ID)) if err != nil { panic(err) } @@ -402,6 +454,11 @@ func (module *Module) initialize(w http.ResponseWriter, r *http.Request, ps http module.WriteError(w, "setup not permitted", http.StatusInternalServerError) return } + if !acquireSetupInitialization() { + module.WriteError(w, "setup is already running", http.StatusConflict) + return + } + defer releaseSetupInitialization() request := &SetupRequest{} err := module.DecodeJSON(r, request) if err != nil { @@ -610,13 +667,10 @@ func (module *Module) initialize(w http.ResponseWriter, r *http.Request, ps http user.ID = "default_user_" + request.BootstrapUsername user.Username = request.BootstrapUsername user.Nickname = request.BootstrapUsername - var hash []byte - hash, err = bcrypt.GenerateFromPassword([]byte(request.BootstrapPassword), bcrypt.DefaultCost) + err = security.SetPassword(&user, request.BootstrapPassword) if err != nil { panic(err) } - - user.Password = string(hash) role := []security.UserRole{} role = append(role, security.UserRole{ ID: security.RoleAdminName, @@ -896,6 +950,11 @@ func (module *Module) initializeTemplate(w http.ResponseWriter, r *http.Request, break } + primaryShards, autoExpandReplicas, err := resolveSetupTemplateSettings(elastic.GetClient(GlobalSystemElasticsearchID), request) + if err != nil { + panic(err) + } + dslTplFile := path.Join(baseDir, dslTplFileName) if !util.FileExists(dslTplFile) { panic(errors.Errorf("template file %v for setup was missing", dslTplFile)) @@ -982,6 +1041,10 @@ func (module *Module) initializeTemplate(w http.ResponseWriter, r *http.Request, return w.Write([]byte(request.BootstrapUsername)) case "SETUP_DOC_TYPE": return w.Write([]byte(docType)) + case "SETUP_PRIMARY_SHARDS": + return w.Write([]byte(strconv.Itoa(primaryShards))) + case "SETUP_AUTO_EXPAND_REPLICAS": + return w.Write([]byte(autoExpandReplicas)) } //ignore unresolved variable return w.Write([]byte("$[[" + tag + "]]")) diff --git a/plugin/setup/setup_test.go b/plugin/setup/setup_test.go new file mode 100644 index 00000000..1a7e99dd --- /dev/null +++ b/plugin/setup/setup_test.go @@ -0,0 +1,74 @@ +// Copyright (C) INFINI Labs & INFINI LIMITED. +// +// The INFINI Console is offered under the GNU Affero General Public License v3.0 +// and as commercial software. +// +// For commercial licensing, contact us at: +// - Website: infinilabs.com +// - Email: hello@infini.ltd +// +// Open Source licensed under AGPL V3: +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package task + +import ( + "sync" + "testing" +) + +func TestInvokeSetupCallbackRunsOnlyOnce(t *testing.T) { + previousCallbacks := setupFinishedCallback + previousOnce := setupCallbackOnce + defer func() { + setupFinishedCallback = previousCallbacks + setupCallbackOnce = previousOnce + }() + + setupFinishedCallback = nil + setupCallbackOnce = sync.Once{} + + count := 0 + RegisterSetupCallback(func() { + count++ + }) + RegisterSetupCallback(func() { + count++ + }) + + InvokeSetupCallback() + InvokeSetupCallback() + + if count != 2 { + t.Fatalf("expected callbacks to run once each, got %d invocations", count) + } +} + +func TestAcquireSetupInitialization(t *testing.T) { + releaseSetupInitialization() + defer releaseSetupInitialization() + + if !acquireSetupInitialization() { + t.Fatal("expected first acquire to succeed") + } + if acquireSetupInitialization() { + t.Fatal("expected second acquire to fail while initialization is running") + } + + releaseSetupInitialization() + + if !acquireSetupInitialization() { + t.Fatal("expected acquire to succeed after release") + } +} diff --git a/service/alerting/action/email.go b/service/alerting/action/email.go index e63d7839..3f2632c5 100644 --- a/service/alerting/action/email.go +++ b/service/alerting/action/email.go @@ -47,6 +47,9 @@ func (act *EmailAction) Execute() ([]byte, error) { if act.Data.ServerID == "" { return nil, fmt.Errorf("parameter server_id must not be empty") } + if len(act.Data.Recipients.To) == 0 { + return nil, fmt.Errorf("parameter recipients.to must not be empty") + } emailMsg := util.MapStr{ "server_id": act.Data.ServerID, "email": act.Data.Recipients.To, diff --git a/service/alerting/common/helper.go b/service/alerting/common/helper.go index 6acff481..6340e648 100644 --- a/service/alerting/common/helper.go +++ b/service/alerting/common/helper.go @@ -30,10 +30,12 @@ package common import ( "bytes" "fmt" + "infini.sh/console/model" "infini.sh/console/model/alerting" "infini.sh/console/service/alerting/action" "infini.sh/console/service/alerting/funcs" "infini.sh/framework/core/orm" + "infini.sh/framework/core/util" "text/template" ) @@ -64,6 +66,9 @@ func PerformChannel(channel *alerting.Channel, ctx map[string]interface{}) ([]by Message: string(message), } case alerting.ChannelEmail: + if channel.Email == nil { + return nil, fmt.Errorf("empty email channel config"), nil + } message, err = ResolveMessage(channel.Email.Body, ctx) if err != nil { return nil, err, message @@ -72,6 +77,14 @@ func PerformChannel(channel *alerting.Channel, ctx map[string]interface{}) ([]by if err != nil { return nil, err, nil } + err = ensureEmailServerID(channel.Email) + if err != nil { + return nil, err, message + } + err = ensureEmailRecipients(channel.Email) + if err != nil { + return nil, err, message + } act = &action.EmailAction{ Data: channel.Email, Subject: string(subjectBytes), @@ -134,3 +147,66 @@ func RetrieveChannel(ch *alerting.Channel, raiseChannelEnabledErr bool) (*alerti } return ch, nil } + +func ensureEmailServerID(email *alerting.Email) error { + if email == nil { + return fmt.Errorf("empty email channel config") + } + if email.ServerID != "" { + return nil + } + serverID, err := getFallbackEmailServerID() + if err != nil { + return err + } + email.ServerID = serverID + return nil +} + +func ensureEmailRecipients(email *alerting.Email) error { + if email == nil { + return fmt.Errorf("empty email channel config") + } + if len(email.Recipients.To) == 0 { + return fmt.Errorf("email channel recipients are empty, please configure at least one recipient") + } + return nil +} + +func getFallbackEmailServerID() (string, error) { + servers, err := getEnabledEmailServers() + if err != nil { + return "", err + } + return selectFallbackEmailServerID(servers) +} + +func selectFallbackEmailServerID(servers []model.EmailServer) (string, error) { + switch len(servers) { + case 0: + return "", fmt.Errorf("parameter server_id must not be empty and no enabled smtp server is available") + case 1: + return servers[0].ID, nil + default: + return "", fmt.Errorf("parameter server_id must not be empty and multiple enabled smtp servers were found") + } +} + +func getEnabledEmailServers() ([]model.EmailServer, error) { + q := &orm.Query{ + Size: 100, + } + q.Conds = orm.And(orm.Eq("enabled", true)) + err, result := orm.Search(model.EmailServer{}, q) + if err != nil { + return nil, err + } + servers := make([]model.EmailServer, 0, len(result.Result)) + for _, row := range result.Result { + server := model.EmailServer{} + buf := util.MustToJSONBytes(row) + util.MustFromJSONBytes(buf, &server) + servers = append(servers, server) + } + return servers, nil +} diff --git a/service/alerting/common/helper_test.go b/service/alerting/common/helper_test.go new file mode 100644 index 00000000..d0418329 --- /dev/null +++ b/service/alerting/common/helper_test.go @@ -0,0 +1,73 @@ +package common + +import ( + "strings" + "testing" + + "infini.sh/console/model" + modelalerting "infini.sh/console/model/alerting" +) + +func TestSelectFallbackEmailServerID(t *testing.T) { + server := model.EmailServer{} + server.ID = "smtp-1" + + serverID, err := selectFallbackEmailServerID([]model.EmailServer{ + server, + }) + if err != nil { + t.Fatalf("expected single enabled server to be selected, got error: %v", err) + } + if serverID != "smtp-1" { + t.Fatalf("expected smtp-1, got %q", serverID) + } +} + +func TestSelectFallbackEmailServerIDWithoutEnabledServer(t *testing.T) { + _, err := selectFallbackEmailServerID(nil) + if err == nil { + t.Fatal("expected error when no enabled smtp server exists") + } + if !strings.Contains(err.Error(), "no enabled smtp server") { + t.Fatalf("expected missing-server hint, got %v", err) + } +} + +func TestSelectFallbackEmailServerIDWithMultipleEnabledServers(t *testing.T) { + server1 := model.EmailServer{} + server1.ID = "smtp-1" + server2 := model.EmailServer{} + server2.ID = "smtp-2" + + _, err := selectFallbackEmailServerID([]model.EmailServer{ + server1, + server2, + }) + if err == nil { + t.Fatal("expected error when multiple enabled smtp servers exist") + } + if !strings.Contains(err.Error(), "multiple enabled smtp servers") { + t.Fatalf("expected multi-server hint, got %v", err) + } +} + +func TestEnsureEmailRecipients(t *testing.T) { + err := ensureEmailRecipients(nil) + if err == nil { + t.Fatal("expected nil email config to fail") + } + + email := &modelalerting.Email{} + err = ensureEmailRecipients(email) + if err == nil { + t.Fatal("expected missing recipients to fail") + } + if !strings.Contains(err.Error(), "at least one recipient") { + t.Fatalf("expected recipient hint, got %v", err) + } + + email.Recipients.To = []string{"ops@example.com"} + if err = ensureEmailRecipients(email); err != nil { + t.Fatalf("expected configured recipients to pass, got %v", err) + } +} diff --git a/ui.go b/ui.go index c19794ff..35a32481 100644 --- a/ui.go +++ b/ui.go @@ -57,11 +57,15 @@ func (h UI) InitUI() { // b) Create the final handler that acts as a dispatcher for the sub-path. finalHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { // Check if the request is for the sub-path's entrypoint. - if r.Method == "GET" && (r.URL.Path == "/" || r.URL.Path == basePath+"/" || r.URL.Path == basePath) { + if r.Method == "GET" && isIndexRequest(r.URL.Path, basePath) { serveDynamicIndex(w, r, basePath) return } + if r.Method == "GET" && shouldDisableCache(r.URL.Path, basePath) { + setNoCacheHeaders(w) + } + // For all other requests, delegate to the static file handler. staticFilesHandler.ServeHTTP(w, r) }) @@ -89,6 +93,25 @@ func (h UI) InitUI() { }) } +func isIndexRequest(path, basePath string) bool { + return path == "/" || + path == "/index.html" || + path == basePath || + path == basePath+"/" || + path == basePath+"/index.html" +} + +func shouldDisableCache(path, basePath string) bool { + return path == "/manifest.json" || + path == basePath+"/manifest.json" +} + +func setNoCacheHeaders(w http.ResponseWriter) { + w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate") + w.Header().Set("Pragma", "no-cache") + w.Header().Set("Expires", "0") +} + // Helper function for serving the DYNAMIC index.html with replacements. func serveDynamicIndex(w http.ResponseWriter, r *http.Request, basePath string) { file, err := vfs.VFS().Open("/index.html") @@ -113,9 +136,7 @@ func serveDynamicIndex(w http.ResponseWriter, r *http.Request, basePath string) content = bytes.ReplaceAll(content, []byte(`window.routerBase = "/";`), []byte(`window.routerBase = "`+jsBasePath+`";`)) content = bytes.ReplaceAll(content, []byte(`window.publicPath = "/";`), []byte(`window.publicPath = "`+jsBasePath+`";`)) - w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate") - w.Header().Set("Pragma", "no-cache") - w.Header().Set("Expires", "0") + setNoCacheHeaders(w) w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Write(content) } diff --git a/web/.gitignore b/web/.gitignore index a40ed7fa..49f9f376 100644 --- a/web/.gitignore +++ b/web/.gitignore @@ -36,3 +36,5 @@ log/ /web/ .github src/common*/ +/public +plugin/enterprise/ diff --git a/web/config/config.js b/web/config/config.js index 22a7be05..230a77fb 100644 --- a/web/config/config.js +++ b/web/config/config.js @@ -5,25 +5,69 @@ import webpackPlugin from "./plugin.config"; import defaultSettings from "../src/defaultSettings"; import packageJson from "../package.json"; -const ProxyTarget = "http://localhost:9001" +const ProxyTarget = "https://localhost:9000"; + +// 统一代理路径列表 +const proxyPaths = [ + "/elasticsearch/", + "/_search-center/", + "/gateway/", + "/_info", + "/config/", + "/environments/", + "/pipeline/", + "/queue/", + "/task/", + "/tasks/", + "/debug/", + "/alerting/", + "/health", + "/stats", + "/keystore", + "/user", + "/role/", + "/permission/", + "/account/", + "/notification/", + "/agent/", + "/insight/", + "/host/", + "/_platform/", + "/migration/", + "/comparison/", + "/_license/", + "/setup/", + "/layout", + "/credential", + "/setting/", + "/email/", + "/data/", + "/instance", + "/collection/", +]; + +// 生成代理配置,统一加上 secure: false +const proxy = proxyPaths.reduce(function(acc, path) { + acc[path] = { + target: ProxyTarget, + changeOrigin: true, + secure: false, // 忽略自签名证书 + }; + return acc; +}, {}); export default { - // add for transfer to umi plugins: [ [ "umi-plugin-react", { antd: true, - dva: { - hmr: true, - }, - targets: { - ie: 11, - }, + dva: { hmr: true }, + targets: { ie: 11 }, locale: { - enable: true, // default false - default: "en-US", // default zh-CN - baseNavigator: true, // default true, when it is true, will use `navigator.language` overwrite default + enable: true, + default: "en-US", + baseNavigator: true, }, dynamicImport: { loadingComponent: "./components/PageLoading/index", @@ -39,17 +83,8 @@ export default { : {}), }, ], - // [ - // 'umi-plugin-ga', - // { - // code: 'UA-12123-6', - // judge: () => process.env.APP_TYPE === 'site', - // }, - // ], ], - targets: { - ie: 11, - }, + targets: { ie: 11 }, define: { APP_TYPE: process.env.APP_TYPE || "", ENV: process.env.NODE_ENV, @@ -59,170 +94,16 @@ export default { APP_AUTHOR: packageJson.author, APP_OFFICIAL_WEBSITE: packageJson.official_website || "", }, - // 路由配置 routes: pageRoutes, - // Theme for antd - // https://ant.design/docs/react/customize-theme-cn - theme: { - "primary-color": defaultSettings.primaryColor, - }, - externals: { - // '@antv/data-set': 'DataSet', - }, - proxy: { - "/elasticsearch/": { - target: ProxyTarget, - changeOrigin: true, - }, - "/_search-center/": { - target: ProxyTarget, - changeOrigin: true, - }, - "/static/": { - target: ProxyTarget, - changeOrigin: true, - }, - "/gateway/": { - target: ProxyTarget, - changeOrigin: true, - }, - "/_info": { - target: ProxyTarget, - changeOrigin: true, - }, - "/config/": { - target: ProxyTarget, - changeOrigin: true, - }, - "/environments/": { - target: ProxyTarget, - changeOrigin: true, - }, - "/pipeline/": { - target: ProxyTarget, - changeOrigin: true, - }, - "/queue/": { - target: ProxyTarget, - changeOrigin: true, - }, - "/task/": { - target: ProxyTarget, - changeOrigin: true, - }, - "/tasks/": { - target: ProxyTarget, - changeOrigin: true, - }, - "/debug/": { - target: ProxyTarget, - changeOrigin: true, - }, - "/alerting/": { - target: ProxyTarget, - changeOrigin: true, - }, - "/health": { - target: ProxyTarget, - changeOrigin: true, - }, - "/stats": { - target: ProxyTarget, - changeOrigin: true, - }, - "/keystore": { - target: ProxyTarget, - changeOrigin: true, - }, - "/user": { - target: ProxyTarget, - changeOrigin: true, - }, - "/role/": { - target: ProxyTarget, - changeOrigin: true, - }, - "/permission/": { - target: ProxyTarget, - changeOrigin: true, - }, - "/account/": { - target: ProxyTarget, - changeOrigin: true, - }, - "/notification/": { - target: ProxyTarget, - changeOrigin: true, - }, - "/agent/": { - target: ProxyTarget, - changeOrigin: true, - }, - "/insight/": { - target: ProxyTarget, - changeOrigin: true, - }, - "/host/": { - target: ProxyTarget, - changeOrigin: true, - }, - "/_platform/": { - target: ProxyTarget, - changeOrigin: true, - }, - "/migration/": { - target: ProxyTarget, - changeOrigin: true, - }, - "/comparison/": { - target: ProxyTarget, - changeOrigin: true, - }, - "/_license/": { - target: ProxyTarget, - changeOrigin: true, - }, - "/setup/": { - target: ProxyTarget, - changeOrigin: true, - }, - "/layout": { - target: ProxyTarget, - changeOrigin: true, - }, - "/credential": { - target: ProxyTarget, - changeOrigin: true, - }, - "/setting": { - target: ProxyTarget, - changeOrigin: true, - }, - "/email": { - target: ProxyTarget, - changeOrigin: true, - }, - "/data": { - target: ProxyTarget, - changeOrigin: true, - }, - "/instance": { - target: ProxyTarget, - changeOrigin: true, - }, - "/collection": { - target: ProxyTarget, - changeOrigin: true, - }, - }, + theme: { "primary-color": defaultSettings.primaryColor }, + proxy: proxy, ignoreMomentLocale: true, - lessLoaderOptions: { - javascriptEnabled: true, - }, + ...(process.env.NODE_ENV === "production" ? { devtool: false } : {}), + lessLoaderOptions: { javascriptEnabled: true }, disableRedirectHoist: true, cssLoaderOptions: { modules: true, - getLocalIdent: (context, localIdentName, localName) => { + getLocalIdent: function(context, localIdentName, localName) { if ( context.resourcePath.includes("node_modules") || context.resourcePath.includes("ant.design.pro.less") || @@ -236,33 +117,19 @@ export default { const antdProPath = match[1].replace(".less", ""); const arr = antdProPath .split("/") - .map((a) => a.replace(/([A-Z])/g, "-$1")) - .map((a) => a.toLowerCase()); + .map(function(a) { return a.replace(/([A-Z])/g, "-$1"); }) + .map(function(a) { return a.toLowerCase(); }); return `antd-pro${arr.join("-")}-${localName}`.replace(/--/g, "-"); } return localName; }, }, - - // chainWebpack: webpackPlugin, - cssnano: { - mergeRules: false, - }, - - // extra configuration for egg + cssnano: { mergeRules: false }, runtimePublicPath: true, hash: true, outputPath: "../.public", - manifest: { - fileName: "../.public/manifest.json", - publicPath: "", - }, - + manifest: { fileName: "../.public/manifest.json", publicPath: "" }, copy: ["./src/assets/favicon.ico"], history: "hash", - // exportStatic: { - // // htmlSuffix: true, - // dynamicRoot: true, - // }, sass: {}, }; diff --git a/web/config/router.config.js b/web/config/router.config.js index b65fc989..65f8994e 100644 --- a/web/config/router.config.js +++ b/web/config/router.config.js @@ -1,3 +1,13 @@ +import fs from 'fs'; +import path from 'path'; + +const pluginRoutes = []; + +const pluginRoutePath = path.resolve('config','router.enterprise.js'); +if (fs.existsSync(pluginRoutePath)) { + pluginRoutes.push(...require(pluginRoutePath).default); +} + export default [ // user { @@ -155,7 +165,7 @@ export default [ }, ], }, - + ...pluginRoutes, // alerting { path: "/alerting", @@ -277,6 +287,11 @@ export default [ "agent.instance:read", ], routes: [ + { + path: "/resource/runtime", + hideInMenu: true, + hideInBreadcrumb: true, + }, { path: "/resource/runtime/instance/new", name: "runtime.new_instance", @@ -381,6 +396,8 @@ export default [ name: "system", icon: "setting", authority: [ + "system.cluster:all", + "system.cluster:read", "system.credential:all", "system.credential:read", "system.security:all", @@ -391,11 +408,27 @@ export default [ "system.smtp_server:read" ], routes: [ + { + path: "/system/settings", + name: "settings", + component: "./System/Settings/index", + authority: [ + "system.cluster:all", + "system.cluster:read", + "system.smtp_server:all", + "system.smtp_server:read", + ], + }, { path: "/system/email_server", - name: "smtp_server", - component: "./System/Email/Server", - authority: ["system.smtp_server:all", "system.smtp_server:read"], + component: "./System/Settings/index", + hideInMenu: true, + authority: [ + "system.cluster:all", + "system.cluster:read", + "system.smtp_server:all", + "system.smtp_server:read", + ], }, { path: "/system/credential", diff --git a/web/docs/test-cases.md b/web/docs/test-cases.md new file mode 100644 index 00000000..d9c66e7d --- /dev/null +++ b/web/docs/test-cases.md @@ -0,0 +1,328 @@ +# Console Web 测试用例基线 + +> 适用范围:当前 `web/` 前端已接入路由与主功能页面。 +> 目标:为后续手工测试、回归测试、自动化测试提供统一覆盖基线。 +> 整理依据:`web/config/router.config.js`、当前菜单与现有页面能力。 + +## 1. 测试目标 + +本用例文档用于覆盖以下风险: + +1. **核心流程不可用**:登录、导航、查询、创建、编辑、删除、详情页跳转失败。 +2. **权限与可见性错误**:无权限菜单可见、按钮显示错误、接口越权。 +3. **配置类功能保存失败**:系统设置、安全设置、凭据、告警配置等保存不生效。 +4. **复杂页面交互异常**:Discover、Alerting、Migration、Comparison、Agent 详情等富交互页面出现状态同步、滚动、表格渲染问题。 +5. **国际化与页面基础质量问题**:缺失文案、breadcrumb 异常、React warning、布局溢出、重复 key、重复路由跳转。 + +## 2. 建议测试分层 + +| 层级 | 目标 | 执行时机 | 建议范围 | +| --- | --- | --- | --- | +| Smoke | 验证主链路可用 | 每次提测 / 每个 PR | 登录、菜单、首页、核心列表页、核心 CRUD | +| Core Regression | 验证业务主流程稳定 | 每日 / 每个迭代 | 用户、角色、告警、Discover、数据工具、系统设置 | +| Full Regression | 验证跨模块与异常路径 | 发版前 | 全模块 + 权限 + 国际化 + 异常场景 | + +## 3. 通用测试维度 + +所有模块都应尽量覆盖以下维度: + +1. **页面进入**:路由可访问、首屏无白屏、无明显报错。 +2. **数据加载**:列表/详情接口成功、空态正确、异常态可提示。 +3. **查询与筛选**:关键字、分页、排序、筛选条件生效。 +4. **新增/编辑/删除**:表单校验、提交成功、提交失败、刷新后结果一致。 +5. **权限控制**:只读用户不可编辑、无权限菜单不可见、按钮不可操作。 +6. **国际化**:中文场景无缺失文案、标题/breadcrumb/按钮/表单提示完整。 +7. **布局稳定性**:无横向顶层滚动、表格内滚动合理、按钮不被挤压。 +8. **状态同步**:URL 参数、筛选条件、详情状态切换后行为一致。 +9. **浏览器交互**:刷新、返回、复制、弹窗关闭、确认框取消。 +10. **异常处理**:接口 4xx/5xx 时提示明确,不出现死循环或重复请求。 + +## 4. 测试环境建议 + +| 维度 | 建议值 | +| --- | --- | +| 浏览器 | Chrome 最新版、Edge 最新版 | +| 语言 | `zh-CN` 必测,`en-US` 抽检 | +| 账号 | 超级管理员、只读账号、受限角色账号 | +| 数据准备 | 至少 1 个平台角色、1 个数据角色、2 个用户、2 个集群、1 个 Agent、1 条告警规则、1 个告警渠道、若干索引/视图/别名 | +| 网络场景 | 正常网络、慢接口、接口 401/403/500 | + +## 5. 模块覆盖矩阵 + +| 模块 | 核心页面/能力 | 最低覆盖要求 | +| --- | --- | --- | +| 用户与登录 | 登录、登出、密码修改、个人设置 | Smoke + Regression | +| 工作台/全局布局 | 首页、菜单、面包屑、语言、全局通知 | Smoke | +| 平台管理 | 概览、监控、活动 | Regression | +| 数据管理 | 索引、别名、视图 | Regression | +| 数据探索 | Discover、分享、导出、空态 | Regression | +| 数据工具 | Migration、Comparison | Regression | +| 告警管理 | 规则、消息、告警详情、渠道 | Regression | +| 开发工具 | Console、常用命令 | Smoke | +| 资源管理 | Runtime、Cluster、Agent | Regression | +| 系统管理 | 设置、凭据、安全、审计 | Regression | +| 账户中心 | 个人信息、密码、通知 | Smoke | + +## 6. 详细测试用例 + +> 优先级说明:P0 = 发版阻断,P1 = 重要回归,P2 = 常规覆盖。 + +### 6.1 认证与全局框架 + +| 编号 | 优先级 | 场景 | 前置条件 | 步骤 | 预期结果 | +| --- | --- | --- | --- | --- | --- | +| AUTH-001 | P0 | 用户名密码登录成功 | 存在有效账号 | 输入正确账号密码登录 | 登录成功,跳转首页,用户态建立 | +| AUTH-002 | P0 | 错误密码登录失败 | 存在有效账号 | 输入错误密码登录 | 页面提示失败,不进入系统 | +| AUTH-003 | P1 | 未登录访问受保护路由 | 未登录 | 直接打开受保护地址 | 被重定向到登录页 | +| AUTH-004 | P0 | 退出登录 | 已登录 | 点击右上角退出 | 会话失效,返回登录页,无重复 401 循环 | +| AUTH-005 | P1 | 登录页 SSO 成功回调 | 已配置 SSO | 触发 SSO 成功回跳 | 页面进入系统,用户信息正确 | +| AUTH-006 | P1 | 登录页 SSO 失败回调 | 已配置 SSO | 触发失败回跳 | 页面显示失败状态,可重新登录 | +| GLB-001 | P0 | 主框架菜单渲染 | 已登录 | 查看左侧菜单 | 仅展示有权限菜单,无空菜单项 | +| GLB-002 | P1 | 面包屑渲染正确 | 已登录 | 进入新增/编辑详情页 | breadcrumb 无原始路径片段,名称国际化正常 | +| GLB-003 | P1 | 中文国际化完整 | `zh-CN` | 遍历主要页面 | 无 missing message、无英文漏出 | +| GLB-004 | P1 | 英文国际化回退 | `en-US` | 抽检主要页面 | 标题、按钮、表单提示正常 | +| GLB-005 | P1 | 全局错误提示 | 构造接口 500 | 进入任一请求页面 | 页面有错误提示,不死循环 | +| GLB-006 | P1 | 无权限按钮隐藏 | 只读账号 | 访问列表页 | 新建/编辑/删除按钮不可见 | + +### 6.2 工作台 / 首页 + +| 编号 | 优先级 | 场景 | 前置条件 | 步骤 | 预期结果 | +| --- | --- | --- | --- | --- | --- | +| OVR-001 | P0 | 首页可访问 | 有首页权限 | 打开 `/overview` | 页面正常加载,无白屏 | +| OVR-002 | P1 | 首页卡片数据正常 | 存在监控数据 | 查看各统计卡片 | 数值、图表、趋势正常显示 | +| OVR-003 | P1 | 首页空数据兜底 | 无监控数据 | 打开首页 | 空态提示清晰,无控制台报错 | +| OVR-004 | P1 | 顶部组件布局稳定 | 浏览器宽度缩放 | 缩放窗口 | 无异常横向滚动,不挤压操作区 | + +### 6.3 平台管理 + +| 编号 | 优先级 | 场景 | 前置条件 | 步骤 | 预期结果 | +| --- | --- | --- | --- | --- | --- | +| CLU-001 | P0 | 平台概览进入 | 已注册集群 | 打开 `/cluster/overview` | 页面加载成功,卡片与图表正常 | +| CLU-002 | P1 | 监控总览进入 | 已注册集群 | 打开 `/cluster/monitor` | 监控列表和图表正常 | +| CLU-003 | P1 | 集群详情跳转 | 列表有数据 | 从监控列表进入某集群详情 | 路由正确,详情数据显示正常 | +| CLU-004 | P1 | 节点详情跳转 | 存在节点数据 | 进入节点监控页面 | 节点图表与指标可用 | +| CLU-005 | P1 | 索引详情跳转 | 存在索引数据 | 进入索引监控页面 | 指标图表与索引信息正常 | +| CLU-006 | P1 | Metric collection mode 展示 | 有 agent / agentless 数据 | 查看列表、详情状态 | 文案国际化正确,无硬编码英文 | +| CLU-007 | P1 | 活动页查询 | 有活动记录 | 打开活动页并筛选 | 列表刷新正确,分页正常 | +| CLU-008 | P2 | 监控空桶场景 | 时间粒度过小 | 切换时间范围/粒度 | 显示建议文案,不报错 | + +### 6.4 数据管理 + +| 编号 | 优先级 | 场景 | 前置条件 | 步骤 | 预期结果 | +| --- | --- | --- | --- | --- | --- | +| DATA-001 | P0 | 索引列表加载 | 有索引数据 | 打开 `/data/index` | 列表正常加载 | +| DATA-002 | P1 | 索引搜索 | 有多条索引 | 按关键字搜索 | 返回符合关键字的数据 | +| DATA-003 | P1 | 索引分页 | 有超过一页数据 | 切换分页/页大小 | 数据与页码对应正确 | +| DATA-004 | P1 | 别名列表加载 | 有别名 | 打开 `/data/alias` | 列表正常显示 | +| DATA-005 | P1 | 视图列表加载 | 有视图 | 打开 `/data/views` | 列表、空态、操作按钮正常 | +| DATA-006 | P2 | 空数据场景 | 无相关数据 | 分别访问索引/别名/视图页 | 空态友好,无控制台 warning | + +### 6.5 数据探索 Discover + +| 编号 | 优先级 | 场景 | 前置条件 | 步骤 | 预期结果 | +| --- | --- | --- | --- | --- | --- | +| DISC-001 | P0 | Discover 页面加载 | 有 Discover 权限 | 打开 `/insight/discover` | 页面正常进入,无白屏 | +| DISC-002 | P0 | 查询条件生效 | 存在可查询数据 | 输入查询条件并执行 | 表格、统计、图表同步刷新 | +| DISC-003 | P1 | 时间范围切换 | 有时间字段 | 切换时间范围 | 数据刷新正确,URL 参数同步 | +| DISC-004 | P1 | URL 恢复状态 | 已设置 query/time | 刷新页面 | 状态恢复,无重复 push warning | +| DISC-005 | P1 | 表格行 key 稳定 | 有重复 `_id` 场景 | 查询结果中翻页/刷新 | 无 duplicate key warning | +| DISC-006 | P1 | 分享链接复制 | 页面有查询状态 | 点击分享 | 复制成功,打开链接可复现当前状态 | +| DISC-007 | P1 | 导出 CSV/Excel | 有结果数据 | 点击导出 | 导出成功,文件内容正确 | +| DISC-008 | P1 | 无索引或无视图空态 | 无数据源 | 打开页面 | 显示国际化空态与创建入口 | +| DISC-009 | P2 | 无结果场景 | 查询无命中 | 执行无结果查询 | 空态正确,无 DOM nesting warning | + +### 6.6 数据工具:Migration + +| 编号 | 优先级 | 场景 | 前置条件 | 步骤 | 预期结果 | +| --- | --- | --- | --- | --- | --- | +| MIG-001 | P0 | 迁移列表加载 | 有迁移任务 | 打开 `/data_tools/migration` | 列表正常显示 | +| MIG-002 | P0 | 新建迁移任务 | 有源目标集群 | 创建迁移任务 | 校验通过,创建成功 | +| MIG-003 | P1 | 迁移详情进入 | 存在任务 | 进入详情页 | 分段进度、状态、步骤显示正常 | +| MIG-004 | P1 | Legacy 迁移跳转 | 存在 legacy 链接 | 访问 legacy 路由 | 正确重定向到新路由 | +| MIG-005 | P1 | 迁移表单组件加载 | 打开新建页 | 检查执行节点等字段 | 组件引用正常,无构建缺失 | +| MIG-006 | P2 | 删除迁移任务 | 有可删除任务 | 执行删除 | 删除成功,列表刷新 | + +### 6.7 数据工具:Comparison + +| 编号 | 优先级 | 场景 | 前置条件 | 步骤 | 预期结果 | +| --- | --- | --- | --- | --- | --- | +| CMP-001 | P0 | 比对列表加载 | 有比对任务 | 打开 `/data_tools/comparison` | 列表正常显示 | +| CMP-002 | P0 | 新建比对任务 | 有源目标集群 | 创建比对任务 | 提交成功,列表可见 | +| CMP-003 | P1 | 比对详情查看 | 存在任务 | 打开详情页 | 差异结果、状态信息正确 | +| CMP-004 | P1 | Legacy 比对跳转 | 存在 legacy 链接 | 访问 legacy 路由 | 正确重定向 | +| CMP-005 | P2 | 删除比对任务 | 有可删除任务 | 删除任务 | 删除成功,列表同步更新 | + +### 6.8 告警管理 + +| 编号 | 优先级 | 场景 | 前置条件 | 步骤 | 预期结果 | +| --- | --- | --- | --- | --- | --- | +| ALT-001 | P0 | 告警规则列表加载 | 有规则数据 | 打开 `/alerting/rule` | 列表正常显示 | +| ALT-002 | P0 | 新建规则 | 已配置数据源/渠道 | 创建规则 | 表单校验正确,创建成功 | +| ALT-003 | P0 | 编辑规则 | 已存在规则 | 编辑并保存 | 保存成功,详情更新 | +| ALT-004 | P1 | 启停规则 | 存在启用/禁用入口 | 切换状态 | 状态变更成功 | +| ALT-005 | P1 | 删除规则 | 有可删除规则 | 删除规则 | 删除成功,列表更新 | +| ALT-006 | P1 | 规则详情 URL 状态同步 | 已打开详情页 | 调整时间条件并刷新 | 状态可恢复,无 hash push warning | +| ALT-007 | P1 | 规则详情通知卡片渲染 | 规则有多个渠道 | 查看通知卡片 | 渲染正常,无 key warning | +| ALT-008 | P1 | 消息中心列表 | 有消息记录 | 打开 `/alerting/message` | 列表正常显示 | +| ALT-009 | P1 | 消息详情查看 | 存在消息记录 | 打开消息详情 | 详情页、Tab、时间条件正常 | +| ALT-010 | P1 | 渠道列表/新建/编辑 | 有渠道权限 | 执行列表、新建、编辑 | 全流程正常 | +| ALT-011 | P2 | Priority 缺省值展示 | 存在 priority 为空消息 | 打开消息详情 | 文案国际化正常,无 missing message | + +### 6.9 开发工具 + +| 编号 | 优先级 | 场景 | 前置条件 | 步骤 | 预期结果 | +| --- | --- | --- | --- | --- | --- | +| DEV-001 | P1 | Console 页面进入 | 有权限 | 打开 `/devtool/console` | 页面加载成功 | +| DEV-002 | P1 | 常用命令页进入 | 有权限 | 打开 `/devtool/command` | 列表正常显示 | +| DEV-003 | P2 | ToolTip 与按钮布局 | 页面已打开 | 鼠标悬停/缩放页面 | 提示文案可见,无布局抖动 | + +### 6.10 资源管理:Runtime / Gateway + +| 编号 | 优先级 | 场景 | 前置条件 | 步骤 | 预期结果 | +| --- | --- | --- | --- | --- | --- | +| RT-001 | P0 | Runtime 实例列表 | 有实例 | 打开 `/resource/runtime/instance` | 列表正常显示 | +| RT-002 | P0 | 新建 Runtime 实例 | 有权限 | 新建实例 | 创建成功 | +| RT-003 | P1 | 编辑 Runtime 实例 | 存在实例 | 编辑并保存 | 保存成功 | +| RT-004 | P1 | Runtime 任务页 | 存在实例 | 进入任务页 | 列表/状态正常 | +| RT-005 | P1 | Runtime 队列页 | 存在实例 | 进入队列页 | 队列信息正常 | +| RT-006 | P1 | Runtime 磁盘页 | 存在实例 | 进入磁盘页 | 指标正常 | +| RT-007 | P1 | Runtime 日志页 | 存在实例 | 进入日志页 | 日志展示正常 | +| RT-008 | P1 | Runtime 配置页 | 存在实例 | 进入配置页 | 配置内容正常加载 | + +### 6.11 资源管理:Cluster / Agent + +| 编号 | 优先级 | 场景 | 前置条件 | 步骤 | 预期结果 | +| --- | --- | --- | --- | --- | --- | +| RES-001 | P0 | Cluster 列表加载 | 有已注册集群 | 打开 `/resource/cluster` | 列表正常 | +| RES-002 | P0 | 注册集群 | 有权限 | 进入注册流程并提交 | 注册成功 | +| RES-003 | P1 | 编辑集群 | 存在集群 | 编辑并保存 | 保存成功 | +| RES-004 | P0 | Agent 列表加载 | 有 Agent 数据 | 打开 `/resource/agent` | 列表正常 | +| RES-005 | P0 | 注册 Agent | 有权限 | 新建 Agent | 创建成功 | +| RES-006 | P1 | 编辑 Agent | 存在 Agent | 编辑并保存 | 保存成功 | +| RES-007 | P1 | Agent 详情页 Tabs 切换 | Agent 有进程/指标数据 | 展开详情并切换 Tabs | 内容切换正常 | +| RES-008 | P1 | 未知进程列表横向滚动 | Agent 有长进程 ID/命令行 | 点击“未知进程”标签 | 仅表格内部滚动,不挤压顶部操作按钮 | +| RES-009 | P2 | 进程详情表格 key 稳定 | 有多进程数据 | 展开详情、多次刷新 | 无 key warning | + +### 6.12 系统管理:系统设置 / 凭据 / 邮件 + +| 编号 | 优先级 | 场景 | 前置条件 | 步骤 | 预期结果 | +| --- | --- | --- | --- | --- | --- | +| SYS-001 | P0 | 系统设置页加载 | 有权限 | 打开 `/system/settings` | 页面正常加载 | +| SYS-002 | P0 | 滚动存储大小保存 | 可编辑 retention | 输入数字并保存 | 保存成功,单位默认 GB,刷新后保留 | +| SYS-003 | P1 | retention 旧值回显 | 后端存在 `mb/tb/gb` 等历史值 | 打开设置页 | 前端正确换算并显示数值 | +| SYS-004 | P1 | 邮件服务器配置保存 | 有 SMTP 权限 | 配置并保存 | 保存成功 | +| SYS-005 | P1 | 邮件服务器测试连接 | 已填写配置 | 发送测试 | 返回成功/失败提示 | +| SYS-006 | P0 | 凭据列表加载 | 有凭据 | 打开 `/system/credential` | 列表正常 | +| SYS-007 | P1 | 新建/编辑/删除凭据 | 有权限 | 执行 CRUD | 各操作成功,错误提示明确 | + +### 6.13 系统管理:安全设置 - 用户 + +| 编号 | 优先级 | 场景 | 前置条件 | 步骤 | 预期结果 | +| --- | --- | --- | --- | --- | --- | +| SEC-U-001 | P0 | 用户列表加载 | 有用户数据 | 打开安全设置用户页 | 列表正常 | +| SEC-U-002 | P0 | 新建用户 | 有可选角色 | 新建用户 | 创建成功,返回初始密码 | +| SEC-U-003 | P1 | 新建用户表单校验 | 打开新建页 | 留空用户名/角色、输入非法邮箱 | 表单阻止提交并显示国际化提示 | +| SEC-U-004 | P0 | 编辑用户 | 存在用户 | 编辑昵称/电话/邮箱/标签并保存 | 保存成功 | +| SEC-U-005 | P0 | 重置用户密码 | 存在用户 | 执行重置密码 | 保存成功,校验规则生效 | +| SEC-U-006 | P0 | 删除用户 | 有可删除用户 | 删除用户 | 删除成功,无 500 错误 | +| SEC-U-007 | P1 | 用户页 breadcrumb 与标题 | 打开新增/编辑/重置页 | 查看面包屑 | 显示国际化标题,无原始 `/user` | + +### 6.14 系统管理:安全设置 - 角色 + +| 编号 | 优先级 | 场景 | 前置条件 | 步骤 | 预期结果 | +| --- | --- | --- | --- | --- | --- | +| SEC-R-001 | P0 | 角色列表加载 | 有角色数据 | 打开安全设置角色页 | 列表正常 | +| SEC-R-002 | P0 | 新建平台角色 | 有权限 | 新建平台角色并选择功能权限 | 创建成功 | +| SEC-R-003 | P0 | 编辑平台角色 | 存在平台角色 | 编辑并保存 | 保存成功,权限数据正确 | +| SEC-R-004 | P0 | 新建数据角色 | 有集群和权限数据 | 新建数据角色 | 创建成功 | +| SEC-R-005 | P0 | 编辑数据角色 | 存在数据角色 | 编辑并保存 | 保存成功 | +| SEC-R-006 | P0 | 删除角色 | 有可删除角色 | 删除角色 | 删除成功,无 `only non-nil pointer to object is allowed` | +| SEC-R-007 | P1 | 平台角色权限必填校验 | 打开平台角色表单 | 不选择权限直接保存 | 阻止提交并提示 | +| SEC-R-008 | P1 | 数据角色集群权限校验 | 打开数据角色表单 | 缺少集群/集群权限/索引权限 | 阻止提交并提示国际化文案 | +| SEC-R-009 | P1 | 角色页 breadcrumb 与标题 | 打开新增/编辑数据角色页 | 查看面包屑和标题 | 显示国际化标题,无原始 `/role/data` | +| SEC-R-010 | P1 | 角色列表按钮权限控制 | 只读账号 | 进入角色页 | 新建/编辑/删除按钮不可见 | + +### 6.15 系统管理:审计 + +| 编号 | 优先级 | 场景 | 前置条件 | 步骤 | 预期结果 | +| --- | --- | --- | --- | --- | --- | +| AUD-001 | P1 | 审计日志列表加载 | 有审计数据 | 打开 `/system/audit` | 列表正常 | +| AUD-002 | P1 | 审计日志查询 | 有多条日志 | 按关键字/时间查询 | 返回结果正确 | +| AUD-003 | P2 | 空审计场景 | 无数据 | 打开页面 | 空态正确,无报错 | + +### 6.16 账户中心 + +| 编号 | 优先级 | 场景 | 前置条件 | 步骤 | 预期结果 | +| --- | --- | --- | --- | --- | --- | +| ACC-001 | P1 | 个人信息页加载 | 已登录 | 打开 `/account/settings/base` | 页面正常 | +| ACC-002 | P1 | 修改个人资料 | 已登录 | 编辑资料并保存 | 保存成功 | +| ACC-003 | P0 | 修改个人密码 | 已登录 | 打开 `/account/password` 并修改 | 修改成功,规则校验正确 | +| ACC-004 | P1 | 通知页加载 | 存在通知 | 打开 `/account/notification` | 列表正常 | + +## 7. 专项回归清单 + +以下问题建议进入长期专项回归: + +1. **国际化专项** + - 中文页面无 missing message + - 新增/编辑/详情页 breadcrumb 正确 + - 空态、提示语、按钮、校验文案统一国际化 + +2. **URL 状态同步专项** + - Discover 查询参数 + - Alerting 时间范围参数 + - 列表页筛选、分页、返回后状态恢复 + +3. **表格稳定性专项** + - rowKey 唯一 + - 展开行、Tabs 内嵌表格、长字段换行 + - 表格内部滚动不影响整页布局 + +4. **权限专项** + - 菜单权限 + - 按钮权限 + - 直接访问 URL 的越权处理 + +5. **接口异常专项** + - 401 会话失效 + - 403 无权限 + - 500 服务异常 + - 保存/删除失败后的前端提示与状态回退 + +## 8. 自动化落地建议 + +建议后续按以下顺序补自动化: + +1. **P0 主链路先自动化** + - 登录/退出 + - 用户 CRUD + - 角色 CRUD + - 告警规则 CRUD + - Discover 基础查询 + - 系统设置保存 + +2. **P1 页面稳定性自动化** + - breadcrumb/i18n 快照 + - 列表页搜索/分页 + - Tabs / Drawer / Modal 交互 + - 表格内滚动与长文本场景 + +3. **接口契约与异常场景自动化** + - 401/403/500 + - 空数据响应 + - 字段缺省值 + - 删除/保存失败回滚 + +## 9. 维护建议 + +每次新增功能或修复线上问题时,至少同步更新: + +1. 模块覆盖矩阵 +2. 对应详细测试用例 +3. 专项回归清单 + +建议规则: + +- 新增页面:至少补 1 条进入用例 + 1 条成功流用例 + 1 条失败流用例 +- 新增表单:至少补 1 条校验用例 + 1 条保存成功用例 +- 修复线上 bug:必须补 1 条对应回归用例 diff --git a/web/mock/api.js b/web/mock/api.js index b20bb393..a45f60f4 100644 --- a/web/mock/api.js +++ b/web/mock/api.js @@ -374,22 +374,6 @@ export default { "eol_date": "2023-12-31T10:10:10Z" }, "tagline": "A light-weight but powerful agent." - }, - "basic_auth": {}, - "endpoint": "http://192.168.3.25:2900", - "host": { - "name": "INFINI-4.local", - "os": { - "name": "darwin", - "architecture": "arm64", - "version": "22.6.0" - } - }, - "network": { - "ip": [ - "192.168.3.25" - ], - "major_ip": "192.168.3.25" } }); }, diff --git a/web/mock/rbac/admin.js b/web/mock/rbac/admin.js index 4194be7d..398b96fc 100644 --- a/web/mock/rbac/admin.js +++ b/web/mock/rbac/admin.js @@ -101,18 +101,18 @@ export default { "indices.get_mapping", "indices.upgrade", "indices.validate_query", - "indices.exists_template", + "template.exists", "indices.get_upgrade", "indices.update_aliases", "indices.analyze", "indices.exists", "indices.close", - "indices.delete_template", + "template.delete", "indices.get_field_mapping", "indices.delete_alias", "indices.exists_type", - "indices.get_template", - "indices.put_template", + "template.get", + "template.put", "indices.refresh", "indices.segments", "indices.termvectors", diff --git a/web/package.json b/web/package.json index 16152d19..652e44db 100644 --- a/web/package.json +++ b/web/package.json @@ -118,6 +118,7 @@ "babel-plugin-dva-hmr": "^0.4.1", "babel-plugin-import": "^1.6.3", "babel-plugin-transform-decorators-legacy": "^1.3.4", + "chokidar": "^5.0.0", "cross-env": "^7.0.3", "enzyme": "^3.9.0", "eslint": "^4.18.2", @@ -137,10 +138,10 @@ "sass": "1.69.5", "sass-loader": "8.0.2", "ts-loader": "8.4.0", + "typescript": "4.9.5", "umi": "^2.1.2", "umi-plugin-ga": "^1.1.3", - "umi-plugin-react": "^1.1.1", - "typescript": "4.9.5" + "umi-plugin-react": "^1.1.1" }, "engines": { "node": ">=8.9.0" @@ -155,9 +156,10 @@ "@antv/l7-component": "2.23.0" }, "scripts": { - "dev": "cross-env MOCK=none UMI_UI=none NODE_OPTIONS=\"--max_old_space_size=4096\" umi dev", - "mock": "cross-env UMI_UI=none NODE_OPTIONS=\"--max_old_space_size=4096\" umi dev", - "build": "NODE_OPTIONS=\"--max_old_space_size=4096 --trace-deprecation\" umi build && cp -R static/* ../.public/static/", + "preinstall": "node ./scripts/ensure-cnpm.js", + "dev": "cross-env HTTPS=true MOCK=none node ./scripts/run-umi.js dev", + "mock": "node ./scripts/run-umi.js dev", + "build": "node ./scripts/run-umi.js build && cp -R static/* ../.public/static/", "autod": "autod", "docker:dev": "docker-compose -f ./docker/docker-compose.dev.yml up --remove-orphans -d", "docker:stop-dev": "docker-compose -f ./docker/docker-compose.dev.yml down", diff --git a/web/scripts/ensure-cnpm.js b/web/scripts/ensure-cnpm.js new file mode 100644 index 00000000..73955385 --- /dev/null +++ b/web/scripts/ensure-cnpm.js @@ -0,0 +1,18 @@ +"use strict"; + +const execPath = `${process.env.npm_execpath || ""}`.toLowerCase(); +const userAgent = `${process.env.npm_config_user_agent || ""}`.toLowerCase(); + +const isCnpm = + execPath.includes("cnpm") || + execPath.includes("npminstall") || + userAgent.includes("cnpm/") || + userAgent.includes("npminstall/"); + +if (!isCnpm) { + console.error(""); + console.error("This frontend only supports dependency installation via cnpm."); + console.error("Please use: cnpm install"); + console.error(""); + process.exit(1); +} diff --git a/web/scripts/run-umi.js b/web/scripts/run-umi.js new file mode 100644 index 00000000..b240cd5b --- /dev/null +++ b/web/scripts/run-umi.js @@ -0,0 +1,120 @@ +"use strict"; + +const { spawnSync } = require("child_process"); +const fs = require("fs"); +const path = require("path"); + +const mode = process.argv[2]; + +if (!mode || !["dev", "build"].includes(mode)) { + console.error("usage: node scripts/run-umi.js "); + process.exit(1); +} + +const syncStaticAssets = () => { + const staticDir = path.resolve(__dirname, "../static"); + const publicStaticDir = path.resolve(__dirname, "../public/static"); + + if (!fs.existsSync(staticDir)) { + return; + } + + fs.mkdirSync(path.dirname(publicStaticDir), { recursive: true }); + fs.rmSync(publicStaticDir, { recursive: true, force: true }); + fs.cpSync(staticDir, publicStaticDir, { recursive: true }); +}; + +const syncPluginDirectory = async () => { + const pluginDir = path.resolve(__dirname, "../../plugin/enterprise/web"); + const pagesDir = path.resolve(__dirname, "../"); + + if (fs.existsSync(pluginDir)) { + console.log("Plugin directory found. Syncing with the main project..."); + + try { + fs.cpSync(pluginDir, pagesDir, { recursive: true, force: true }); + console.log(`Plugin synced directly to ${pagesDir}`); + } catch (err) { + console.error(`Failed to sync plugin to ${pagesDir}:`, err); + } + } else { + console.log("No plugin directory found, skipping sync."); + } +}; + +const watchPluginChanges = async () => { + const chokidar = await import("chokidar"); + + const pluginDir = path.resolve(__dirname, "../../plugin/enterprise/web"); + + if (fs.existsSync(pluginDir)) { + const watcher = chokidar.watch(pluginDir, { persistent: true }); + + watcher.on("change", (filePath) => { + console.log(`File changed: ${filePath}`); + syncPluginDirectory(); + }); + + watcher.on("error", (err) => { + console.error("Error watching plugin directory:", err); + }); + + console.log(`Watching plugin directory for changes: ${pluginDir}`); + } else { + console.log("Plugin directory not found, skipping file watching."); + } +}; + +const run = async () => { + syncStaticAssets(); + + await syncPluginDirectory(); + + if (mode === "dev") { + await watchPluginChanges(); + } + + const defaultOldSpaceSize = + process.env.UMI_MAX_OLD_SPACE_SIZE || (mode === "build" ? "8192" : "4096"); + + const nodeMajorVersion = Number(process.versions.node.split(".")[0] || "0"); + const existingNodeOptions = (process.env.NODE_OPTIONS || "").trim(); + const optionSet = new Set(existingNodeOptions.split(/\s+/).filter(Boolean)); + + if (![...optionSet].some((item) => item.startsWith("--max_old_space_size="))) { + optionSet.add(`--max_old_space_size=${defaultOldSpaceSize}`); + } + + if ( + nodeMajorVersion >= 17 && + !optionSet.has("--openssl-legacy-provider") + ) { + optionSet.add("--openssl-legacy-provider"); + } + + optionSet.add("--trace-deprecation"); + + const env = { + ...process.env, + NODE_OPTIONS: [...optionSet].join(" "), + }; + + if (mode === "dev") { + env.UMI_UI = env.UMI_UI || "none"; + } + + const result = spawnSync("./node_modules/.bin/umi", [mode], { + stdio: "inherit", + shell: true, + env, + }); + + if (result.error) { + console.error(result.error); + process.exit(1); + } + + process.exit(result.status || 0); +}; + +run(); \ No newline at end of file diff --git a/web/src/app.js b/web/src/app.js index 79a04614..7ebb7cfa 100644 --- a/web/src/app.js +++ b/web/src/app.js @@ -21,12 +21,29 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -import { getAuthEnabled } from "./utils/authority"; +import { + getAuthEnabled, + getEnterpriseTaskManagerEnabled, + refreshApplicationSettings, +} from "./utils/authority"; import request from "./utils/request"; import { setSetupRequired } from "@/utils/setup"; import { getHealth } from "@/services/system" import PlatformContainer from "./components/PlatformContainer"; import React from "react"; +import { message, notification } from "antd"; + +message.config({ + maxCount: 3, +}); + +notification.config({ + placement: "topRight", +}); + +const CHUNK_RELOAD_KEY = "console.chunk-reload"; +const CHUNK_RELOAD_WINDOW_MS = 15000; +const CHUNK_RELOAD_QUERY_KEY = "_reload_ts"; if (!String.prototype.replaceAll) { String.prototype.replaceAll = function(search, replacement) { @@ -35,13 +52,171 @@ if (!String.prototype.replaceAll) { }; } +const isChunkLoadError = (error) => { + const reason = + error?.message || + error?.reason?.message || + error?.reason || + ""; + return /ChunkLoadError|Loading chunk [\d]+ failed|CSS_CHUNK_LOAD_FAILED/i.test( + `${reason}` + ); +}; + +const isInjectedScriptConnectionError = (error) => { + const reason = + error?.message || + error?.reason?.message || + error?.reason || + error || + ""; + const stack = `${error?.stack || error?.reason?.stack || ""}`; + return ( + `${reason}`.includes("Could not establish connection. Receiving end does not exist.") && + (stack.includes("single-file-bootstrap.bundle.js") || stack === "") + ); +}; + +const suppressUnhandledRejection = (event) => { + event.preventDefault?.(); + event.stopImmediatePropagation?.(); + event.stopPropagation?.(); +}; + +const getCanonicalLocation = () => { + const url = new URL(window.location.href); + url.searchParams.delete(CHUNK_RELOAD_QUERY_KEY); + return `${url.pathname}${url.search}${url.hash}`; +}; + +const buildReloadLocation = () => { + const url = new URL(window.location.href); + url.searchParams.set(CHUNK_RELOAD_QUERY_KEY, `${Date.now()}`); + return `${url.pathname}${url.search}${url.hash}`; +}; + +const shouldReloadForChunkError = () => { + const now = Date.now(); + const currentLocation = getCanonicalLocation(); + + try { + const raw = sessionStorage.getItem(CHUNK_RELOAD_KEY); + const previous = raw ? JSON.parse(raw) : null; + if ( + previous?.location === currentLocation && + now - Number(previous?.timestamp || 0) < CHUNK_RELOAD_WINDOW_MS + ) { + return false; + } + sessionStorage.setItem( + CHUNK_RELOAD_KEY, + JSON.stringify({ + location: currentLocation, + timestamp: now, + }) + ); + } catch (e) {} + + return true; +}; + +const installChunkReloadRecovery = () => { + if (typeof window === "undefined" || window.__consoleChunkReloadRecoveryInstalled) { + return; + } + + window.__consoleChunkReloadRecoveryInstalled = true; + + const handleChunkFailure = (error) => { + if (!isChunkLoadError(error) || !shouldReloadForChunkError()) { + return false; + } + window.location.replace(buildReloadLocation()); + return true; + }; + + window.addEventListener("error", (event) => { + handleChunkFailure(event?.error || new Error(event?.message || "")); + }); + + window.addEventListener("unhandledrejection", (event) => { + if (handleChunkFailure(event?.reason)) { + suppressUnhandledRejection(event); + return; + } + if (isInjectedScriptConnectionError(event?.reason || event)) { + suppressUnhandledRejection(event); + } + }, true); +}; + +const serializeConsoleArgs = (args = []) => { + return args + .map((arg) => { + if (typeof arg === "string") { + return arg; + } + if (arg instanceof Error) { + return arg.stack || arg.message; + } + try { + return JSON.stringify(arg); + } catch (e) { + return String(arg); + } + }) + .join(" "); +}; + +const isUmiDllWarning = (args = []) => { + const message = serializeConsoleArgs(args); + if (!/warning:/i.test(message)) { + return false; + } + const stack = new Error().stack || ""; + return stack.includes("/umi.dll.js"); +}; + +const installConsoleWarningFilter = () => { + if ( + typeof window === "undefined" || + process.env.NODE_ENV === "development" || + window.__consoleWarningFilterInstalled + ) { + return; + } + + window.__consoleWarningFilterInstalled = true; + + ["warn", "error"].forEach((method) => { + const original = console[method]; + if (typeof original !== "function") { + return; + } + console[method] = function(...args) { + if (isUmiDllWarning(args)) { + return; + } + return original.apply(this, args); + }; + }); +}; + +installChunkReloadRecovery(); +installConsoleWarningFilter(); + export async function patchRoutes(routes) { + await refreshApplicationSettings(); const healthRes = await getHealth(); setSetupRequired(`${healthRes?.setup_required}`); + if (getEnterpriseTaskManagerEnabled() !== "true") { + routes = hideRoutesInMenu(routes, ["data_tools"], ""); + } if (getAuthEnabled() === "false") { routes = filterRoutes(routes, ["system.security"], ""); // routes = disableAuth(routes); } + return routes; } @@ -64,7 +239,7 @@ export function render(oldRoutes) { function filterRoutes(routes, names, prefix) { return routes.filter((route) => { if (route.name) { - const pn = `${prefix}.${route.name}`; + const pn = prefix ? `${prefix}.${route.name}` : route.name; if (names.includes(pn)) { return false; } @@ -80,7 +255,27 @@ function filterRoutes(routes, names, prefix) { }); } +function hideRoutesInMenu(routes, names, prefix) { + return (routes || []).map((route) => { + const nextRoute = { ...route }; + if (nextRoute.name) { + const pn = prefix ? `${prefix}.${nextRoute.name}` : nextRoute.name; + if (names.includes(pn)) { + nextRoute.hideInMenu = true; + } + } + if (nextRoute.routes) { + let pn = ""; + if (nextRoute.name) { + pn = prefix ? `${prefix}.${nextRoute.name}` : nextRoute.name; + } + nextRoute.routes = hideRoutesInMenu(nextRoute.routes, names, pn); + } + return nextRoute; + }); +} + export function rootContainer(container) { return React.createElement(PlatformContainer, null, container); -} \ No newline at end of file +} diff --git a/web/src/components/Anchor/index.js b/web/src/components/Anchor/index.js index 57caba70..b3a67add 100644 --- a/web/src/components/Anchor/index.js +++ b/web/src/components/Anchor/index.js @@ -1,88 +1,95 @@ -import React, { useState, useRef, useEffect } from 'react'; +import React, { useEffect, useState } from "react"; import { Tooltip, Timeline } from "antd"; import { formatMessage } from "umi/locale"; -import { throttle } from 'lodash'; +import { throttle } from "lodash"; import "./index.scss"; -function Anchor({ links }) { +function Anchor({ links = [] }) { const [activeID, setActiveID] = useState(links[0]); - const [isFixed, setIsFixed] = useState(false); - const anchorRef = useRef(null); useEffect(() => { - const handleScroll = () => { + setActiveID(links[0]); + }, [links]); - setTimeout(() => { - const anchorElements = links.map(link => document.getElementById(link)); - const scrollPosition = window.scrollY || document.documentElement.scrollTop; + useEffect(() => { + if (!links.length) { + return undefined; + } - for (let i = anchorElements.length - 1; i >= 0; i--) { - const anchorElement = anchorElements[i]; - if (!anchorElement) { - continue - } + const handleScroll = () => { + const scrollPosition = window.scrollY || document.documentElement.scrollTop; + let nextActiveID = links[0]; - const offsetTop = anchorElement.offsetTop - if (offsetTop <= scrollPosition - 200) { - setActiveID(anchorElement.id); - break; - } + for (let i = links.length - 1; i >= 0; i--) { + const anchorElement = document.getElementById(links[i]); + if (!anchorElement) { + continue; } - }, 1000) - const offsetLeft = anchorRef.current.getBoundingClientRect().left; - const offsetTop = anchorRef.current.getBoundingClientRect().top; - const children = anchorRef.current.children[0]; - - if (offsetTop <= 0) { - setIsFixed(true); - children.style.top = 0; - children.style.left = offsetLeft + 'px'; - } else { - setIsFixed(false); + const offsetTop = + anchorElement.getBoundingClientRect().top + window.pageYOffset; + if (offsetTop <= scrollPosition + 120) { + nextActiveID = anchorElement.id; + break; + } } + + setActiveID((prevActiveID) => + prevActiveID === nextActiveID ? prevActiveID : nextActiveID + ); }; - const throttledScroll = throttle(handleScroll, 1000); + const throttledScroll = throttle(handleScroll, 100, { + leading: true, + trailing: true, + }); - window.addEventListener('scroll', throttledScroll); + handleScroll(); + window.addEventListener("scroll", throttledScroll, { passive: true }); + window.addEventListener("resize", throttledScroll); return () => { - window.removeEventListener('scroll', throttledScroll); + window.removeEventListener("scroll", throttledScroll); + window.removeEventListener("resize", throttledScroll); + throttledScroll.cancel(); }; - }, []); + }, [links]); const handleClick = (targetId) => { const targetElement = document.getElementById(targetId); if (targetElement) { - setActiveID(targetId) + const targetTop = + targetElement.getBoundingClientRect().top + window.pageYOffset - 120; + setActiveID(targetId); window.scrollTo({ - behavior: 'smooth', - top: targetElement.offsetTop + 300, + behavior: "smooth", + top: Math.max(targetTop, 0), }); } }; + if (!links.length) { + return null; + } + return ( -
-
- - {links.map((link, index) => { - const linkElement = document.getElementById(link) - return linkElement ? ( - - handleClick(link)} - > - 1 - - - ) : null - })} +
+
+ + {links.map((link) => ( + + handleClick(link)} + > + 1 + + + ))}
diff --git a/web/src/components/Anchor/index.scss b/web/src/components/Anchor/index.scss index 1dcf37cf..1a6c9db0 100644 --- a/web/src/components/Anchor/index.scss +++ b/web/src/components/Anchor/index.scss @@ -1,38 +1,43 @@ -.p-anchor{ +.p-anchor { width: 30px; + flex: 0 0 30px; box-sizing: border-box; + align-self: flex-start; } -.c-anchor{ + +.c-anchor { + position: sticky; + top: 12px; width: 30px; box-sizing: border-box; padding-left: 10px; padding-top: 10px; cursor: pointer; + .ant-timeline-item-head { width: 12px; height: 12px; border: 1px solid; } + .ant-timeline-item-tail { left: 6px; } + .ant-timeline-item-head-blue { position: relative; } - .ant-timeline-item-head-blue::before{ - content: ""; + + .ant-timeline-item-head-blue::before { + content: ""; position: absolute; top: 50%; left: 50%; - width: 6px; - height: 6px; + width: 6px; + height: 6px; margin-top: -3px; - margin-left: -3px; + margin-left: -3px; background-color: #1890ff; border-radius: 50%; } } -.fixed-top { - position: fixed; - z-index: 10; -} \ No newline at end of file diff --git a/web/src/components/ClusterSelect/index.jsx b/web/src/components/ClusterSelect/index.jsx index 7e6cd44c..0836e79a 100644 --- a/web/src/components/ClusterSelect/index.jsx +++ b/web/src/components/ClusterSelect/index.jsx @@ -177,6 +177,9 @@ export default connect(({ global, loading }) => ({ }); dispatch({ type: "global/fetchClusterStatus", + payload: { + force: true, + }, }) }} loading={clusterLoading} diff --git a/web/src/components/CollectStatus/index.js b/web/src/components/CollectStatus/index.js index fbd868c9..7c554d4f 100644 --- a/web/src/components/CollectStatus/index.js +++ b/web/src/components/CollectStatus/index.js @@ -1,5 +1,5 @@ import request from "@/utils/request" -import { firstUpperCase, formatToUniversalTime } from "@/utils/utils"; +import { formatToUniversalTime } from "@/utils/utils"; import { Descriptions, Icon, Spin, Tooltip } from "antd"; import moment from "moment"; import { useEffect, useMemo, useRef, useState } from "react"; @@ -39,6 +39,15 @@ const STATUS_ICONS = { ) } +const getMetricCollectionModeLabel = (mode) => { + if (mode === "agent" || mode === "agentless") { + return formatMessage({ + id: `cluster.manage.metric_collection_mode.option.${mode}`, + }); + } + return "Unknown"; +} + export default (props) => { const { fetchUrl, filter={} } = props; @@ -131,9 +140,9 @@ export default (props) => {
{renderIcon()} - {firstUpperCase(data?.metric_collection_mode) || "Unknown"} + {getMetricCollectionModeLabel(data?.metric_collection_mode)}
); -} \ No newline at end of file +} diff --git a/web/src/components/GlobalHeader/RightContent.js b/web/src/components/GlobalHeader/RightContent.js index 61a8171c..ea1acbfe 100644 --- a/web/src/components/GlobalHeader/RightContent.js +++ b/web/src/components/GlobalHeader/RightContent.js @@ -20,8 +20,18 @@ import EmptyNoticeSvg from "@/assets/emptyNotice.svg"; import EmptyTodoSvg from "@/assets/emptyTodo.svg"; import router from "umi/router"; +const isMacPlatform = () => + typeof window !== "undefined" && + /(Mac|iPhone|iPad|iPod)/i.test(window.navigator.platform); + export default class GlobalHeaderRight extends PureComponent { state = { consoleVisible: false, notificationPopupVisible: false }; + + isConsoleToggleShortcut = (event) => { + const key = (event.key || "").toLowerCase(); + return (event.ctrlKey || event.metaKey) && event.shiftKey && (key === "o" || event.keyCode === 79); + }; + getNoticeData() { const { notices = [] } = this.props; if (notices.length === 0) { @@ -49,32 +59,26 @@ export default class GlobalHeaderRight extends PureComponent { } }; onKeyDown = (e) => { - const { keyCode } = e; - if (this.keysPressed["17"] && this.keysPressed["16"] && keyCode == 79) { + const hasDevtoolPrivilege = + hasAuthority("devtool.console:all") || + hasAuthority("devtool.console:read"); + if (hasDevtoolPrivilege && this.isConsoleToggleShortcut(e)) { + e.preventDefault(); if (this.state.consoleVisible) document.body.style.overflow = ""; this.setConsoleVisible(!this.state.consoleVisible); return true; } - this.keysPressed[keyCode] = e.type == "keydown"; return false; }; - onKeyUp = (e) => { - const { keyCode } = e; - delete this.keysPressed[keyCode]; - }; constructor(props) { super(props); this.onKeyDown = this.onKeyDown.bind(this); - this.onKeyUp = this.onKeyUp.bind(this); } componentDidMount() { - this.keysPressed = {}; document.addEventListener("keydown", this.onKeyDown, false); - document.addEventListener("keyup", this.onKeyUp, false); } componentWillUnmount() { document.removeEventListener("keydown", this.onKeyDown); - document.removeEventListener("keyup", this.onKeyUp); } render() { @@ -132,6 +136,9 @@ export default class GlobalHeaderRight extends PureComponent { const hasDevtoolPrivilege = hasAuthority("devtool.console:all") || hasAuthority("devtool.console:read"); + const consoleShortcutLabel = isMacPlatform() + ? "Cmd+Shift+O" + : "Ctrl+Shift+O"; return (
@@ -167,16 +174,17 @@ export default class GlobalHeaderRight extends PureComponent { {hasDevtoolPrivilege ? ( - { - const { history, selectedCluster } = this.props; - this.setConsoleVisible(!this.state.consoleVisible); - }} - > - {" "} - - + + { + this.setConsoleVisible(!this.state.consoleVisible); + }} + > + + + ) : null} {APP_OFFICIAL_WEBSITE ? ( { const [health, setHealth] = useState() const [visible, setVisible] = useState(false) const intervalRef = useRef(null); + const isMountedRef = useRef(false); + const fetchSeqRef = useRef(0); const fetchHealth = async () => { + const fetchSeq = ++fetchSeqRef.current; try { + if (!isMountedRef.current) { + return; + } setModalLoading(true) const res = await getHealth(); + if (!isMountedRef.current || fetchSeq !== fetchSeqRef.current) { + return; + } if(res instanceof Error && res.name === "ERR_CONNECTION_REFUSED"){ setHealth({}) setModalLoading(false) @@ -28,6 +37,9 @@ export default ({ children, location }) => { router.push("/guide/initialization"); } } catch (error) { + if (!isMountedRef.current || fetchSeq !== fetchSeqRef.current) { + return; + } setModalLoading(false) console.log(error); message.error('Check servies health failed!') @@ -44,29 +56,37 @@ export default ({ children, location }) => { } useEffect(() => { + fetchSeqRef.current += 1; if (!location?.pathname || location?.pathname.includes('/guide/initialization')) { if (intervalRef.current) { clearInterval(intervalRef.current) + intervalRef.current = null; } setHealth() + setVisible(false) return; } - if (!health) { + fetchHealth() + if (intervalRef.current) { + clearInterval(intervalRef.current) + } + intervalRef.current = setInterval(() => { fetchHealth() + }, 5 * 60 * 1000) + return () => { + fetchSeqRef.current += 1; if (intervalRef.current) { clearInterval(intervalRef.current) + intervalRef.current = null; } - intervalRef.current = setInterval(() => { - fetchHealth(true) - }, [5*60*1000]) } - }, [location?.pathname, JSON.stringify(health)]) + }, [location?.pathname]) useEffect(() => { if (health) { checkStatus(health) } - }, [JSON.stringify(health)]) + }, [health]) useEffect(() => { if (health?.setup_required) { @@ -75,12 +95,16 @@ export default ({ children, location }) => { }, [health?.setup_required]) useEffect(() => { + isMountedRef.current = true; window.setGlobalHealth = setHealth; return () => { + isMountedRef.current = false; + fetchSeqRef.current += 1; if (intervalRef.current) { clearInterval(intervalRef.current) - window.setGlobalHealth = null; + intervalRef.current = null; } + window.setGlobalHealth = null; } }, []) @@ -100,7 +124,7 @@ export default ({ children, location }) => { {children} diff --git a/web/src/components/Icons/Dingding.jsx b/web/src/components/Icons/Dingding.jsx index af08c44f..16d84716 100644 --- a/web/src/components/Icons/Dingding.jsx +++ b/web/src/components/Icons/Dingding.jsx @@ -1,6 +1,6 @@ const Dingding = ({color}) => { return ( - + ) } export default Dingding; diff --git a/web/src/components/Icons/DirectionArrow.jsx b/web/src/components/Icons/DirectionArrow.jsx index 291cc6ab..3bb351d9 100644 --- a/web/src/components/Icons/DirectionArrow.jsx +++ b/web/src/components/Icons/DirectionArrow.jsx @@ -1,3 +1,3 @@ export default ()=>{ - return + return } \ No newline at end of file diff --git a/web/src/components/Icons/ExternalLink.jsx b/web/src/components/Icons/ExternalLink.jsx index 9286284c..79129897 100644 --- a/web/src/components/Icons/ExternalLink.jsx +++ b/web/src/components/Icons/ExternalLink.jsx @@ -1,3 +1,3 @@ export default ()=>{ - return + return } \ No newline at end of file diff --git a/web/src/components/Icons/HornVibration.jsx b/web/src/components/Icons/HornVibration.jsx index 9be24daa..e6cc294d 100644 --- a/web/src/components/Icons/HornVibration.jsx +++ b/web/src/components/Icons/HornVibration.jsx @@ -1,3 +1,3 @@ export default ()=>{ - return + return } \ No newline at end of file diff --git a/web/src/components/Icons/LightningToggle.jsx b/web/src/components/Icons/LightningToggle.jsx index f0f2d009..d7694f63 100644 --- a/web/src/components/Icons/LightningToggle.jsx +++ b/web/src/components/Icons/LightningToggle.jsx @@ -2,7 +2,7 @@ export default () => { return ( { - return + return } \ No newline at end of file diff --git a/web/src/components/Icons/Mute.jsx b/web/src/components/Icons/Mute.jsx index fe01544d..a99f664a 100644 --- a/web/src/components/Icons/Mute.jsx +++ b/web/src/components/Icons/Mute.jsx @@ -1,5 +1,5 @@ export default () => { return ( - + ); }; diff --git a/web/src/components/Icons/NoPermission.jsx b/web/src/components/Icons/NoPermission.jsx index 5c1deda7..3094e5e1 100644 --- a/web/src/components/Icons/NoPermission.jsx +++ b/web/src/components/Icons/NoPermission.jsx @@ -1,6 +1,6 @@ export default () => { return ( - + ); }; \ No newline at end of file diff --git a/web/src/components/Icons/Notification.jsx b/web/src/components/Icons/Notification.jsx index de1b230d..754f3c7b 100644 --- a/web/src/components/Icons/Notification.jsx +++ b/web/src/components/Icons/Notification.jsx @@ -1,6 +1,6 @@ export default () => { return ( - + ); }; diff --git a/web/src/components/Icons/Slack.jsx b/web/src/components/Icons/Slack.jsx index d230a64c..1c59728f 100644 --- a/web/src/components/Icons/Slack.jsx +++ b/web/src/components/Icons/Slack.jsx @@ -1,11 +1,11 @@ export default () => { return ( - + ) } export const SlackWithColor = ({color}) => { return ()=>{ - return + return } } \ No newline at end of file diff --git a/web/src/components/Licence/index.js b/web/src/components/Licence/index.js index a2f9066d..f11371dd 100644 --- a/web/src/components/Licence/index.js +++ b/web/src/components/Licence/index.js @@ -51,7 +51,7 @@ export default forwardRef((props, ref) => { return ( { allowClear placeholder={placeholder} value={value} - enterButton + enterButton={formatMessage({ id: "form.button.search" })} onSearch={onSearch} onChange={(e) => { onSearch(e.currentTarget.value); diff --git a/web/src/components/ListView/components/Side/index.jsx b/web/src/components/ListView/components/Side/index.jsx index e20ecddd..13e8ce89 100644 --- a/web/src/components/ListView/components/Side/index.jsx +++ b/web/src/components/ListView/components/Side/index.jsx @@ -8,15 +8,22 @@ export default (props) => { const { aggs, data, filters, onFacetChange, onReset } = props; const facets = useMemo(() => { - const fts = Object.keys(data).map((item) => { - return { - label: item, - field: aggs[item].terms.field, - buckets: data[item]?.buckets || [], - }; - }); + const safeData = data && typeof data === "object" ? data : {}; + const fts = Object.keys(safeData).reduce((items, item) => { + const agg = aggs?.[item]; + const field = agg?.field || agg?.terms?.field; + if (!field) { + return items; + } + items.push({ + label: agg?.label || item, + field, + buckets: safeData[item]?.buckets || [], + }); + return items; + }, []); return fts; - }, [data]); + }, [data, aggs]); return (
diff --git a/web/src/components/ListView/index.jsx b/web/src/components/ListView/index.jsx index 9a7fe09e..cd390fd3 100644 --- a/web/src/components/ListView/index.jsx +++ b/web/src/components/ListView/index.jsx @@ -10,6 +10,7 @@ import React, { Fragment, } from "react"; import { Card, Table, Button, Input, Icon, Switch, Empty, Spin } from "antd"; +import isEqual from "lodash/isEqual"; import styles from "./index.less"; import { formatMessage, getLocale } from "umi/locale"; import { getTimezone } from "@/utils/utils"; @@ -66,6 +67,22 @@ const Index = forwardRef((props, ref) => { ? rowSelectionExtra.getExtra(props) : []; + const getHeaderExtraKey = (item, index) => { + let baseKey; + if (React.isValidElement(item) && item.key != null) { + baseKey = item.key; + } else if (item?.key != null) { + baseKey = item.key; + } else if (item?.id != null) { + baseKey = item.id; + } else if (item?.props?.id != null) { + baseKey = item.props.id; + } + return baseKey != null + ? `header-extra-${String(baseKey)}-${index}` + : `header-extra-${index}`; + }; + const [param, setParam] = useQueryParam("_g", JsonParam); const [queryParams, setQueryParams] = useState({ ...defaultQueryParams, @@ -137,18 +154,23 @@ const Index = forwardRef((props, ref) => { return [columnsNew, sortOptions, aggOptions, searchFields]; }, [columns]); - const formatAggs = useMemo(() => { + const [formatAggs, aggMetas] = useMemo(() => { let aggsJson = {}; + let metas = {}; let aggsArr = sideEnable ? aggOptions : []; aggsArr.map((item) => { - aggsJson[item.label] = { + aggsJson[item.field] = { terms: { field: item.field, size: item.size, }, }; + metas[item.field] = { + label: item.label, + field: item.field, + }; }); - return aggsJson; + return [aggsJson, metas]; }, [sideEnable, aggOptions]); const formatQueryBody = (queryParams) => { @@ -242,6 +264,35 @@ const Index = forwardRef((props, ref) => { return res?.hits?.total?.value || 0; }; const [dataSource, setDataSource] = useState({}); + const tableDataSource = useMemo(() => { + const rows = Array.isArray(dataSource?.data) ? dataSource.data : []; + const rowKeyCounts = new Map(); + + rows.forEach((item, index) => { + const baseKey = + item?.id ?? item?._id ?? item?.key ?? item?._key ?? item?.name ?? null; + const normalizedKey = + baseKey == null || baseKey === "" ? `row-${index}` : String(baseKey); + rowKeyCounts.set(normalizedKey, (rowKeyCounts.get(normalizedKey) || 0) + 1); + }); + + return rows.map((item, index) => { + const baseKey = + item?.id ?? item?._id ?? item?.key ?? item?._key ?? item?.name ?? null; + const normalizedKey = + baseKey == null || baseKey === "" ? `row-${index}` : String(baseKey); + const uniqueKey = + rowKeyCounts.get(normalizedKey) > 1 + ? `${normalizedKey}-${index}` + : normalizedKey; + + return { + ...item, + __listview_row_key: uniqueKey, + }; + }); + }, [dataSource]); + useMemo(async () => { let ds = value; // console.log("dataSource:", value); @@ -384,9 +435,30 @@ const Index = forwardRef((props, ref) => { } }; + const onHistogramQueriesChange = (nextQueries = {}) => { + const nextRange = nextQueries?.range; + if (!nextRange?.from || !nextRange?.to) { + return; + } + const timeField = + histogramState.widget?.series?.[0]?.queries?.time_field || + queryParams?.timeRange?.timeField || + defaultQueryParams?.timeRange?.timeField || + ""; + onTimeRangeChange({ + start: nextRange.from, + end: nextRange.to, + timeField, + }); + }; + useEffect(() => { - setParam((st) => ({ ...st, ...queryParams })); - }, [queryParams]); + const nextParam = { ...(param || {}), ...queryParams }; + if (isEqual(param || {}, nextParam)) { + return; + } + setParam(nextParam); + }, [param, queryParams, setParam]); //用 useImperativeHandle 暴露一些外部 ref 能访问的属性 useImperativeHandle(ref, () => ({ @@ -418,7 +490,7 @@ const Index = forwardRef((props, ref) => { {sideEnable && aggOptions.length > 0 ? (
{ {formatMessage({ id: "form.button.refresh" })} {headerExtra.map((item, i) => ( - {item} + {item} ))}
@@ -533,6 +605,7 @@ const Index = forwardRef((props, ref) => { widget={histogramState.widget} range={histogramState.range} queryParams={queryParams?.filters || {}} + onGlobalQueriesChange={onHistogramQueriesChange} />
) : null} @@ -543,8 +616,8 @@ const Index = forwardRef((props, ref) => { size={"small"} loading={loading} columns={columnsNew} - dataSource={dataSource?.data || []} - rowKey={"id"} + dataSource={tableDataSource} + rowKey={"__listview_row_key"} onChange={onTableChange} pagination={{ size: "small", diff --git a/web/src/components/ListView/index.less b/web/src/components/ListView/index.less index 55cf25cc..d1a1fd6c 100644 --- a/web/src/components/ListView/index.less +++ b/web/src/components/ListView/index.less @@ -39,12 +39,12 @@ cursor: pointer; z-index: 10; position: absolute; - top: 50%; - margin-top: -15px; - width: 12px; - height: 30px; - line-height: 30px; - border-radius: 2px; + top: 24px; + margin-top: 0; + width: 14px; + height: 32px; + line-height: 32px; + border-radius: 0 4px 4px 0; background-color: rgba(234, 244, 255, 1); text-align: center; } @@ -62,6 +62,7 @@ .contentWrap { .expandAndCollapse { right: 0; + border-radius: 4px 0 0 4px; } } } diff --git a/web/src/components/Login/map.js b/web/src/components/Login/map.js index 72e547c2..1709bbb5 100644 --- a/web/src/components/Login/map.js +++ b/web/src/components/Login/map.js @@ -1,5 +1,6 @@ import React from 'react'; import { Icon } from 'antd'; +import { formatMessage } from 'umi/locale'; import styles from './index.less'; export default { @@ -12,7 +13,7 @@ export default { rules: [ { required: true, - message: 'Please enter username!', + message: formatMessage({ id: 'app.login.username.required' }), }, ], }, @@ -26,7 +27,7 @@ export default { rules: [ { required: true, - message: 'Please enter password!', + message: formatMessage({ id: 'app.login.password.required' }), }, ], }, @@ -34,16 +35,16 @@ export default { props: { size: 'large', prefix: , - placeholder: 'mobile number', + placeholder: formatMessage({ id: 'app.login.mobile.placeholder' }), }, rules: [ { required: true, - message: 'Please enter mobile number!', + message: formatMessage({ id: 'app.login.mobile.required' }), }, { pattern: /^1\d{10}$/, - message: 'Wrong mobile number format!', + message: formatMessage({ id: 'app.login.mobile.invalid' }), }, ], }, @@ -51,12 +52,12 @@ export default { props: { size: 'large', prefix: , - placeholder: 'captcha', + placeholder: formatMessage({ id: 'app.login.captcha.placeholder' }), }, rules: [ { required: true, - message: 'Please enter Captcha!', + message: formatMessage({ id: 'app.login.captcha.required' }), }, ], }, diff --git a/web/src/components/Markdown/index.jsx b/web/src/components/Markdown/index.jsx index dad22272..cab82ae2 100644 --- a/web/src/components/Markdown/index.jsx +++ b/web/src/components/Markdown/index.jsx @@ -37,9 +37,71 @@ export const IFrame = ({ children, styleSelector, ...props }) => { const linkEls = win.parent.document.querySelectorAll(styleSelector); if (linkEls.length) { linkEls.forEach((el) => { - win.document.head.appendChild(el); + win.document.head.appendChild(el.cloneNode(true)); }); } + + const parentWin = win.parent; + const parentDoc = parentWin.document; + const parentEl = contentRef.parentElement || parentDoc.body; + const parentStyles = parentWin.getComputedStyle(parentEl); + const linkProbe = parentDoc.createElement("a"); + linkProbe.href = "#"; + linkProbe.style.position = "absolute"; + linkProbe.style.visibility = "hidden"; + linkProbe.style.pointerEvents = "none"; + parentDoc.body.appendChild(linkProbe); + const linkColor = parentWin.getComputedStyle(linkProbe).color; + linkProbe.remove(); + + const themeStyleId = "markdown-frame-theme"; + const existingThemeStyle = win.document.getElementById(themeStyleId); + if (existingThemeStyle) { + existingThemeStyle.remove(); + } + + const themeStyle = win.document.createElement("style"); + themeStyle.id = themeStyleId; + themeStyle.textContent = ` + html, body { + margin: 0; + padding: 0; + background: transparent; + color: ${parentStyles.color}; + font-size: ${parentStyles.fontSize}; + line-height: ${parentStyles.lineHeight}; + font-family: ${parentStyles.fontFamily}; + } + + .markdown-body { + color: ${parentStyles.color}; + background: transparent; + font-size: ${parentStyles.fontSize}; + line-height: ${parentStyles.lineHeight}; + font-family: ${parentStyles.fontFamily}; + } + + .markdown-body p, + .markdown-body li, + .markdown-body blockquote, + .markdown-body table, + .markdown-body td, + .markdown-body th, + .markdown-body span, + .markdown-body strong, + .markdown-body em { + color: inherit; + } + + .markdown-body a, + .markdown-body a:hover, + .markdown-body a:focus, + .markdown-body a:active, + .markdown-body a:visited { + color: ${linkColor}; + } + `; + win.document.head.appendChild(themeStyle); }, [contentRef, styleSelector]); let docHeight = "auto"; if (contentRef) { diff --git a/web/src/components/Overview/Detail/Content.tsx b/web/src/components/Overview/Detail/Content.tsx index c55f007c..ebaeab7f 100644 --- a/web/src/components/Overview/Detail/Content.tsx +++ b/web/src/components/Overview/Detail/Content.tsx @@ -1,4 +1,5 @@ import { Tabs } from "antd"; +import { formatMessage } from "umi/locale"; interface IProps { data: any; @@ -24,7 +25,10 @@ export default (props: IProps) => { tabPosition="right" > {details.map((pane) => ( - + {typeof pane.component == "string" ? ( pane.component ) : ( diff --git a/web/src/components/Overview/Detail/Metrics/MetricIndices.jsx b/web/src/components/Overview/Detail/Metrics/MetricIndices.jsx index d03f7da0..ef5ea24f 100644 --- a/web/src/components/Overview/Detail/Metrics/MetricIndices.jsx +++ b/web/src/components/Overview/Detail/Metrics/MetricIndices.jsx @@ -99,7 +99,7 @@ export default ({ clusterID, clusterName, timeRange, action }) => { const columns = [ { - title: "Name", + title: formatMessage({ id: "overview.column.name" }), dataIndex: "index", render: (text, record) => ( { sorter: (a, b) => sorter.string(a, b, "index"), }, { - title: "Health", + title: formatMessage({ id: "overview.column.health" }), dataIndex: "health", render: (text, record) => , sorter: (a, b) => sorter.string(a, b, "health"), @@ -178,14 +178,14 @@ export default ({ clusterID, clusterName, timeRange, action }) => { >
{ setSearchValue(value); }} onChange={(e) => { setSearchValue(e.currentTarget.value); }} - enterButton + enterButton={formatMessage({ id: "form.button.search" })} />
diff --git a/web/src/components/Overview/Detail/Metrics/MetricNodes.jsx b/web/src/components/Overview/Detail/Metrics/MetricNodes.jsx index 845cdc0b..78f0574c 100644 --- a/web/src/components/Overview/Detail/Metrics/MetricNodes.jsx +++ b/web/src/components/Overview/Detail/Metrics/MetricNodes.jsx @@ -73,7 +73,7 @@ export default ({ clusterID, clusterName, timeRange, action }) => { const columns = [ { - title: "Name", + title: formatMessage({ id: "overview.column.name" }), dataIndex: "name", render: (text, record) => ( { sorter: (a, b) => sorter.string(a, b, "name"), }, { - title: "Status", + title: formatMessage({ id: "overview.column.status" }), dataIndex: "status", render: (text, record) => , sorter: (a, b) => sorter.string(a, b, "status"), @@ -119,14 +119,14 @@ export default ({ clusterID, clusterName, timeRange, action }) => { >
{ setSearchValue(value); }} onChange={(e) => { setSearchValue(e.currentTarget.value); }} - enterButton + enterButton={formatMessage({ id: "form.button.search" })} />
diff --git a/web/src/components/Overview/Detail/Metrics/index.js b/web/src/components/Overview/Detail/Metrics/index.js index 8e886581..9994dc75 100644 --- a/web/src/components/Overview/Detail/Metrics/index.js +++ b/web/src/components/Overview/Detail/Metrics/index.js @@ -155,7 +155,14 @@ export default (props) => {
{overviews.map((item) => ( - + { allowClear={true} style={{ width: filterWidth }} dropdownMatchSelectWidth={false} - placeholder="Filters" + placeholder={formatMessage({ id: "listview.filters.placeholder" })} onChange={onSearchFieldChange} value={searchField} > @@ -218,8 +219,8 @@ export default (props: IProps) => { {...autoCompleteProps} > { onSearchChange(value); setSearchOpen(false); diff --git a/web/src/components/Overview/Monitor/index.jsx b/web/src/components/Overview/Monitor/index.jsx index edfded75..a693a0cc 100644 --- a/web/src/components/Overview/Monitor/index.jsx +++ b/web/src/components/Overview/Monitor/index.jsx @@ -119,7 +119,15 @@ const Monitor = (props) => { const [timeZone, setTimeZone] = useState(() => allTimeSettingsCache.timeZone || getTimezone()); useEffect(() => { - setParam({ ...param, timeRange: state.timeRange, timeInterval: state.timeInterval, timeout: state.timeout }); + const newParam = { + ...param, + timeRange: state.timeRange, + timeInterval: state.timeInterval, + timeout: state.timeout + }; + if (JSON.stringify(newParam) !== JSON.stringify(param)) { + setParam(newParam); + } }, [state.timeRange, state.timeInterval, state.timeout]); const handleTimeChange = ({ start, end, timeInterval, timeout, refresh }) => { @@ -236,10 +244,10 @@ const Monitor = (props) => { recentlyUsedRangesKey={'monitor'} />
-
+
{isSystemCluster(selectedCluster?.id) && getRollupEnabled() === "true" && } + />}
diff --git a/web/src/components/Overview/Monitor/index.less b/web/src/components/Overview/Monitor/index.less index 8d95fe64..2828e357 100644 --- a/web/src/components/Overview/Monitor/index.less +++ b/web/src/components/Overview/Monitor/index.less @@ -4,4 +4,12 @@ padding-right: 16px !important; } } -} \ No newline at end of file +} + +.statusActions { + display: flex; + align-items: center; + justify-content: flex-end; + flex-wrap: wrap; + gap: 16px; +} diff --git a/web/src/components/Overview/Side/index.scss b/web/src/components/Overview/Side/index.scss index 68929184..12dff982 100644 --- a/web/src/components/Overview/Side/index.scss +++ b/web/src/components/Overview/Side/index.scss @@ -1,6 +1,15 @@ .search-filter { - margin-top: 56px; - padding: 0 10px; + height: 100%; + .search-filter-title { + font-weight: bold; + margin-bottom: 16px; + display: flex; + justify-content: space-between; + align-items: center; + } + .search-filter-reset { + cursor: pointer; + } .facet-cnt { display: flex; flex-direction: column; diff --git a/web/src/components/Overview/Side/index.tsx b/web/src/components/Overview/Side/index.tsx index 0bfeae90..61a4e1e7 100644 --- a/web/src/components/Overview/Side/index.tsx +++ b/web/src/components/Overview/Side/index.tsx @@ -2,6 +2,8 @@ import Sorter from "@/components/infini/search/sort/sort"; import { getSearchFacets } from "@/lib/elasticsearch/search"; import request from "@/utils/request"; import { useEffect, useState } from "react"; +import { Icon } from "antd"; +import { formatMessage } from "umi/locale"; import SearchFacet from "./SearchFacet"; import './index.scss'; @@ -16,6 +18,7 @@ interface IProps { } filters: {[key: string]: string}; onFacetChange: (facet: any) => void; + onReset?: () => void; } interface IFacet { @@ -32,7 +35,8 @@ export default (props: IProps) => { facetLabels, aggsConfig : { action, params }, filters, - onFacetChange + onFacetChange, + onReset } = props; const [facets, setFacets] = useState([]); @@ -59,7 +63,17 @@ export default (props: IProps) => { return (
-
+
+ {formatMessage({ id: "listview.side.filter" })} + + + +
+
{
) -} \ No newline at end of file +} diff --git a/web/src/components/Overview/index.scss b/web/src/components/Overview/index.scss index 0456e3fb..e2bc133b 100644 --- a/web/src/components/Overview/index.scss +++ b/web/src/components/Overview/index.scss @@ -1,17 +1,49 @@ .overview-wrapper { - .content { - display: flex; - gap: 1em; - margin-top: 10px; - .ant-list-item { - border-bottom: none !important; - } - .left { - flex: 1 1 auto; + display: flex; + gap: 10px; + margin-top: 10px; + + .overview-side-wrap { + min-height: calc(100vh - 220px); + padding: 24px 15px; + background-color: #ffffff; + flex: 0 0 220px; + width: 220px; + } + + .overview-content-wrap { + position: relative; + flex: 1 1 auto; + min-width: 0; + } + + .overview-expand-and-collapse { + cursor: pointer; + z-index: 10; + position: absolute; + top: 24px; + left: 0; + width: 14px; + height: 32px; + line-height: 32px; + border-radius: 0 4px 4px 0; + background-color: rgba(234, 244, 255, 1); + text-align: center; + } + + .overview-content-card { + min-height: calc(100vh - 220px); + .content { + .ant-list-item { + border-bottom: none !important; + } .search-result { - min-width: 900px; + width: 100%; + min-width: 0; + overflow-x: auto; margin-top: 15px; .table-wrap { + min-width: 0; margin-top: 24px; .ant-progress-inner { border-radius: 0; @@ -23,4 +55,10 @@ } } } + + &.collapse { + .overview-side-wrap { + display: none; + } + } } diff --git a/web/src/components/Overview/index.tsx b/web/src/components/Overview/index.tsx index 7cb54a5f..5b2696ac 100644 --- a/web/src/components/Overview/index.tsx +++ b/web/src/components/Overview/index.tsx @@ -9,6 +9,8 @@ import React, { import useFetch from "@/lib/hooks/use_fetch"; import "./index.scss"; import request from "@/utils/request"; +import { Button, Card, Icon } from "antd"; +import { formatMessage } from "umi/locale"; import { JsonParam, useQueryParam } from "use-query-params"; import Header from "./Header"; import List from "./List"; @@ -17,6 +19,7 @@ import Drawer, { IDrawerRef } from "./Drawer"; import Title from "./Detail/Title"; import Content from "./Detail/Content"; import { useLocalStorage } from "@/lib/hooks/storage"; +import isEqual from "lodash/isEqual"; interface IQueryParams { from: number; @@ -27,6 +30,7 @@ interface IQueryParams { interface IDetail { key: string; title: string; + titleId?: string; component: any; } @@ -101,6 +105,7 @@ export default forwardRef((props: IProps, ref: any) => { } = props; const drawRef = useRef(null); + const [sideVisible, setSideVisible] = useState(false); const [searchField, setSearchField] = useState(); const [selectedItem, setSelectedItem] = useState({}); @@ -177,6 +182,7 @@ export default forwardRef((props: IProps, ref: any) => { const result = (value as any)?.hits || {}; const { hits = [] } = result; + const hasSide = sideSorterOptions.length > 0 || aggsParams.length > 0; const initQueryParams = () => { extraQueryFields.forEach((item) => { @@ -204,6 +210,16 @@ export default forwardRef((props: IProps, ref: any) => { dispatch({ type: "pagination", value: 1 }) }; + const onSideReset = () => { + const { filters, ...restParams } = param || {}; + setParam(restParams); + dispatch({ type: "pagination", value: 1 }); + }; + + const onRefresh = () => { + run(); + }; + useEffect(() => { if (hits.length === 0) { return; @@ -213,8 +229,12 @@ export default forwardRef((props: IProps, ref: any) => { }, [value]); useEffect(() => { - setParam({ ...param, ...queryParams }); - }, [JSON.stringify(queryParams)]); + const nextParam = { ...(param || {}), ...queryParams }; + if (isEqual(param || {}, nextParam)) { + return; + } + setParam(nextParam); + }, [param, queryParams, setParam]); useEffect(() => { initQueryParams(); @@ -226,106 +246,139 @@ export default forwardRef((props: IProps, ref: any) => { return ( <> -
-
-
-
{ - setSearchField(value); - setParam({ ...param, search_field: value }); - }} - defaultSearchValue={queryParams?.keyword} - onSearchChange={(value) => { - dispatch({ type: "search", value }) - }} - onFacetChange={onFacetChange} - dispalyType={dispalyTypeObj[currentTab]} - onDisplayTypeChange={onDisplayTypeChange} - autoCompleteConfig={{ - action: searchAction, - highlightFields: searchHighlightFields, - ...searchAutoCompleteConfig, - }} - {...headerConfig} - onCleanSuccess={() => { - dispatch({ type: "pagination", value: 1 }) - }} - /> -
- {dispalyTypeObj[currentTab] == "card" ? ( - - dispatch({ type: "pagination", value: page }) - } - onPageSizeChange={(size) => - dispatch({ type: "pageSizeChange", value: size }) - } - renderItem={(item) => { - const infoField = listItemConfig.getId(item); - return ( - { - setSelectedItem(item); - drawRef.current?.open(); - }} - onChangeFacet={onFacetChange} - infoAction={infoAction} - parentLoading={loading} - /> - ); - }} - /> - ) : ( - ({...item, id: listItemConfig.getId(item)}))} - total={result?.total?.value || 0} - from={queryParams.from} - pageSize={queryParams.size} - loading={loading} - onPageChange={(page) => - dispatch({ type: "pagination", value: page }) - } - onPageSizeChange={(size) => - dispatch({ type: "pageSizeChange", value: size }) - } - onRowClick={(item) => { - setSelectedItem(item); - drawRef.current?.open(); - }} - parentLoading={loading} - /> - )} -
+
+ {hasSide ? ( +
+ {sideVisible ? ( + { + setParam({ + ...param, + sort, + }); + }} + facetLabels={facetLabels} + aggsConfig={{ + action: searchAction, + params: aggsParams, + }} + filters={param?.filters || {}} + onFacetChange={onFacetChange} + onReset={onSideReset} + /> + ) : null}
- { - setParam({ - ...param, - sort, - }); - }} - facetLabels={facetLabels} - aggsConfig={{ - action: searchAction, - params: aggsParams, - }} - filters={param?.filters || {}} - onFacetChange={onFacetChange} - /> + ) : null} +
+ {hasSide ? ( + setSideVisible((visible) => !visible)} + title={ + sideVisible + ? formatMessage({ id: "listview.side.button.collapse" }) + : formatMessage({ id: "listview.side.button.expand" }) + } + > + + + ) : null} + +
+
{ + setSearchField(value); + setParam({ ...param, search_field: value }); + }} + defaultSearchValue={queryParams?.keyword} + onSearchChange={(value) => { + dispatch({ type: "search", value }) + }} + onFacetChange={onFacetChange} + dispalyType={dispalyTypeObj[currentTab]} + onDisplayTypeChange={onDisplayTypeChange} + autoCompleteConfig={{ + action: searchAction, + highlightFields: searchHighlightFields, + ...searchAutoCompleteConfig, + }} + {...headerConfig} + getExtra={(headerProps) => { + const extras = headerConfig.getExtra + ? headerConfig.getExtra(headerProps) + : []; + return [ + , + ...extras, + ]; + }} + onCleanSuccess={() => { + dispatch({ type: "pagination", value: 1 }) + }} + /> +
+ {dispalyTypeObj[currentTab] == "card" ? ( + + dispatch({ type: "pagination", value: page }) + } + onPageSizeChange={(size) => + dispatch({ type: "pageSizeChange", value: size }) + } + renderItem={(item) => { + const infoField = listItemConfig.getId(item); + return ( + { + setSelectedItem(item); + drawRef.current?.open(); + }} + onChangeFacet={onFacetChange} + infoAction={infoAction} + parentLoading={loading} + /> + ); + }} + /> + ) : ( + ({...item, id: listItemConfig.getId(item)}))} + total={result?.total?.value || 0} + from={queryParams.from} + pageSize={queryParams.size} + loading={loading} + onPageChange={(page) => + dispatch({ type: "pagination", value: page }) + } + onPageSizeChange={(size) => + dispatch({ type: "pageSizeChange", value: size }) + } + onRowClick={(item) => { + setSelectedItem(item); + drawRef.current?.open(); + }} + parentLoading={loading} + /> + )} +
+
+
{ return (
- Rollup Gap + {formatMessage({ id: "cluster.monitor.rollup.gap" })} !loading && fetchData(fetchUrl)} >
@@ -137,9 +137,9 @@ export default (props) => { >
- {renderIcon()} Rollup + {renderIcon()} {formatMessage({ id: "cluster.monitor.tabs.rollup" })}
); -} \ No newline at end of file +} diff --git a/web/src/components/StandardTable/index.js b/web/src/components/StandardTable/index.js index fad3c788..87d2d97c 100644 --- a/web/src/components/StandardTable/index.js +++ b/web/src/components/StandardTable/index.js @@ -91,7 +91,7 @@ class StandardTable extends PureComponent {
+ 已选择 {selectedRowKeys.length} 项   {needTotalList.map(item => ( diff --git a/web/src/components/SuperDatePicker/quick_select_popover/quick_select_popover.tsx b/web/src/components/SuperDatePicker/quick_select_popover/quick_select_popover.tsx index 33ed4aff..a473c757 100644 --- a/web/src/components/SuperDatePicker/quick_select_popover/quick_select_popover.tsx +++ b/web/src/components/SuperDatePicker/quick_select_popover/quick_select_popover.tsx @@ -117,7 +117,7 @@ export class EuiQuickSelectPopover extends Component< const showTimeSelect = !!timeField return ( - + { showTimeSelect && ( <> diff --git a/web/src/components/infini/SearchInput/index.jsx b/web/src/components/infini/SearchInput/index.jsx new file mode 100644 index 00000000..2420d7a5 --- /dev/null +++ b/web/src/components/infini/SearchInput/index.jsx @@ -0,0 +1,58 @@ +import React from "react"; +import { Input } from "antd"; +import { formatMessage } from "umi/locale"; + +const { Search } = Input; + +const normalizePlaceholder = (placeholder) => { + if (typeof placeholder === "undefined") { + return formatMessage({ id: "listview.search.placeholder" }); + } + + if (typeof placeholder !== "string") { + return placeholder; + } + + const normalizedPlaceholder = placeholder.trim().toLowerCase(); + if ( + normalizedPlaceholder === "type keyword to search" || + normalizedPlaceholder === "search" || + normalizedPlaceholder === "keyword" + ) { + return formatMessage({ id: "listview.search.placeholder" }); + } + + return placeholder; +}; + +const normalizeEnterButton = (enterButton) => { + if (typeof enterButton === "undefined") { + return formatMessage({ id: "form.button.search" }); + } + + if (typeof enterButton !== "string") { + return enterButton; + } + + const normalizedEnterButton = enterButton.trim(); + if ( + normalizedEnterButton.toLowerCase() === "search" || + normalizedEnterButton === "搜索" + ) { + return formatMessage({ id: "form.button.search" }); + } + + return enterButton; +}; + +const SearchInput = ({ placeholder, enterButton, ...props }) => { + return ( + + ); +}; + +export default SearchInput; diff --git a/web/src/components/infini/TagEditor.jsx b/web/src/components/infini/TagEditor.jsx index 5cebd9ae..946d0e1d 100644 --- a/web/src/components/infini/TagEditor.jsx +++ b/web/src/components/infini/TagEditor.jsx @@ -41,7 +41,7 @@ export default ({ value = [], onChange }) => {
{value.map((tag, index) => ( handleRemove(index)} diff --git a/web/src/components/infini/search/FilterSearchGroup.jsx b/web/src/components/infini/search/FilterSearchGroup.jsx index 65b7ddf8..8da2a0ea 100644 --- a/web/src/components/infini/search/FilterSearchGroup.jsx +++ b/web/src/components/infini/search/FilterSearchGroup.jsx @@ -1,10 +1,12 @@ import { Input, Select } from "antd"; +import { formatMessage } from "umi/locale"; + const { Search } = Input; const InputGroup = Input.Group; const Option = Select.Option; const FilterSearchGroup = ({ - enterButton = true, + enterButton = formatMessage({ id: "form.button.search" }), filterWidth = 120, filterFields, onFilterChange, @@ -17,7 +19,7 @@ const FilterSearchGroup = ({ allowClear={true} style={{ width: filterWidth }} dropdownMatchSelectWidth={false} - placeholder="Filters" + placeholder={formatMessage({ id: "listview.filters.placeholder" })} onChange={(value) => { if (typeof onFilterChange == "function") { onFilterChange(value); @@ -35,7 +37,7 @@ const FilterSearchGroup = ({ { if (typeof onSearch == "function") { diff --git a/web/src/components/monaco-editor/index.jsx b/web/src/components/monaco-editor/index.jsx index 72b55bd7..1443df83 100644 --- a/web/src/components/monaco-editor/index.jsx +++ b/web/src/components/monaco-editor/index.jsx @@ -1,5 +1,6 @@ -import { loader } from "@monaco-editor/react"; +import Editor, { loader, monaco } from "@monaco-editor/react"; loader.config({ paths: { vs: "/static/monaco-editor/min/vs" } }); -export {default as Editor} from "@monaco-editor/react"; \ No newline at end of file +export { Editor, monaco }; +export default Editor; diff --git a/web/src/components/vendor/console/components/CommonCommandModal.tsx b/web/src/components/vendor/console/components/CommonCommandModal.tsx index 08b31542..3d731235 100644 --- a/web/src/components/vendor/console/components/CommonCommandModal.tsx +++ b/web/src/components/vendor/console/components/CommonCommandModal.tsx @@ -48,7 +48,7 @@ export const TagGenerator = ({ value = [], onChange }: ITagGeneratorProps) => {
{value.map((tag, index) => ( handleRemove(index)} diff --git a/web/src/components/vendor/console/components/ConsoleInput.tsx b/web/src/components/vendor/console/components/ConsoleInput.tsx index 53ed5913..34b39c2d 100644 --- a/web/src/components/vendor/console/components/ConsoleInput.tsx +++ b/web/src/components/vendor/console/components/ConsoleInput.tsx @@ -10,7 +10,7 @@ import { } from "@elastic/eui"; import { SenseEditor } from "../entities/sense_editor"; import { LegacyCoreEditor } from "../modules/legacy_core_editor"; -import ConsoleMenu from "./ConsoleMenu"; +import ConsoleMenu, { CONSOLE_MENU_SHORTCUTS } from "./ConsoleMenu"; // import { RequestContextProvider } from '../contexts/request_context'; import { getDocumentation, autoIndent } from "../entities/console_menu_actions"; import "./ConsoleInput.scss"; @@ -27,6 +27,11 @@ import { useServicesContext } from "../contexts"; import { applyCurrentSettings } from "./apply_editor_settings"; import { subscribeResizeChecker } from "./subscribe_console_resize_checker"; import { formatMessage } from "umi/locale"; +import { hasAuthority } from "@/utils/authority"; + +const isMacPlatform = () => + typeof window !== "undefined" && + /(Mac|iPhone|iPad|iPod)/i.test(window.navigator.platform); const abs: CSSProperties = { position: "absolute", @@ -47,6 +52,10 @@ const abs: CSSProperties = { const SendRequestButton = (props: any) => { const sendCurrentRequestToES = useSendCurrentRequestToES(); const saveCurrentTextObject = useSaveCurrentTextObject(); + const sendShortcutLabel = isMacPlatform() ? "Cmd+Enter" : "Ctrl+Enter"; + const tooltipContent = `${formatMessage({ + id: "console.SendRequestButton.ToolTip", + })} (${sendShortcutLabel})`; const { saveCurrentTextObjectRef } = props; useEffect(() => { @@ -54,12 +63,10 @@ const SendRequestButton = (props: any) => { }, [saveCurrentTextObjectRef]); return ( - + ); diff --git a/web/src/pages/Agent/Instance/components/RowDetail.jsx b/web/src/pages/Agent/Instance/components/RowDetail.jsx index 1ea8347e..f788c4c3 100644 --- a/web/src/pages/Agent/Instance/components/RowDetail.jsx +++ b/web/src/pages/Agent/Instance/components/RowDetail.jsx @@ -27,6 +27,7 @@ import { SearchEngineIcon } from "@/lib/search_engines"; import { hasAuthority } from "@/utils/authority"; import AutoTextEllipsis from "@/components/AutoTextEllipsis"; import commonStyles from "@/common.less" +import styles from "./RowDetail.less"; const { TabPane } = Tabs; @@ -129,21 +130,25 @@ export const AgentRowDetail = ({ agentID, t }) => { const columns = useMemo( () => [ { - title: "PID", + title: formatMessage({ id: "overview.column.pid" }), + width: 100, dataIndex: "node_info.process.id", + render: (text) =>
{text}
, }, { - title: "Port", + title: formatMessage({ id: "alert.email.manage.field.port" }), + width: 100, dataIndex: "node_info.http.publish_address", render: (text, record) => { - return text?.split(":")?.[1]; + return
{text?.split(":")?.[1]}
; }, }, { - title: "Cluster", + title: formatMessage({ id: "overview.column.cluster" }), + width: 100, dataIndex: "cluster_info.cluster_name", render: (text, record) => { - return <> + return
{ {text} - ) : ( - text - )} - + ) : ( + text + )} +
}, }, { - title: "Node", + title: formatMessage({ id: "overview.column.node" }), + width: 100, dataIndex: "node_info.name", render: (text, record) => { return record.cluster_id ? ( - } - text={ - - {text} - - } - /> +
+ } + text={ + + {text} + + } + /> +
) : ( - text +
{text}
); }, }, { - title: "Home", + title: formatMessage({ id: "overview.column.homepath" }), dataIndex: "node_info.settings.path.home", - render: (text) => {text}, + render: (text) => ( +
+ {text} +
+ ), className: commonStyles.maxColumnWidth }, { - title: "Status", + title: formatMessage({ id: "overview.column.status" }), dataIndex: "status", render: (text, record) => { const status = text == "online" ? "online" : "gray"; - return ; + const label = + text === "online" || text === "Online" + ? formatMessage({ id: "gateway.instance.status.online" }) + : text; + return ; }, width: 100, }, { title: formatMessage({ id: "table.field.actions" }), - width: 160, + width: 100, render: (text, record) => ( -
+
{/* onDeleteClick(record.id, agentID)} @@ -219,7 +235,9 @@ export const AgentRowDetail = ({ agentID, t }) => { hasAuthority("agent.instance:all") && ( <> onRevoke({ cluster_id: record.cluster_id, @@ -229,7 +247,7 @@ export const AgentRowDetail = ({ agentID, t }) => { } > @@ -358,14 +376,15 @@ export const AgentRowDetail = ({ agentID, t }) => { }; return ( -
+
{ setState({ ...state, processesTab: tabKey }); }} tabBarExtraContent={ -
+
{hasAuthority("agent.instance:all") && state.processesTab === "unknown" ? (
-
diff --git a/web/src/pages/DataManagement/SearchFlow/TraceSearch.jsx b/web/src/pages/DataManagement/SearchFlow/TraceSearch.jsx index 15beff75..706ba645 100644 --- a/web/src/pages/DataManagement/SearchFlow/TraceSearch.jsx +++ b/web/src/pages/DataManagement/SearchFlow/TraceSearch.jsx @@ -1,20 +1,23 @@ import React, { useRef } from "react"; -import { Input, Select, Button } from "antd"; +import { Input, Select } from "antd"; import useFetch from "@/lib/hooks/use_fetch"; import { ESPrefix } from "@/services/common"; import { useGlobal } from "@/layouts/GlobalContext"; import { formatESSearchResult } from "@/lib/elasticsearch/util"; import { on } from "@svgdotjs/svg.js"; +import SearchInput from "@/components/infini/SearchInput"; export default ({ onTraceIDSearch }) => { const { selectedCluster } = useGlobal(); + const clusterId = selectedCluster?.id; const [queryParams, setQueryParams] = React.useState({ size: 1000 }); const { loading, error, value } = useFetch( - `${ESPrefix}/${selectedCluster.id}/trace_template`, + `${ESPrefix}/${clusterId}/trace_template`, { queryParams: queryParams, }, - [selectedCluster, queryParams] + [clusterId, queryParams], + !!clusterId ); const [selectedTemplate, setSelectedTemplate] = React.useState(); const { data: templates, total } = React.useMemo(() => { @@ -59,15 +62,11 @@ export default ({ onTraceIDSearch }) => { ))} - - 搜索 - - } + disabled={!clusterId} onSearch={onTraceSearch} /> diff --git a/web/src/pages/DataManagement/View/LayoutList.jsx b/web/src/pages/DataManagement/View/LayoutList.jsx index ea035be7..73f9b3f4 100644 --- a/web/src/pages/DataManagement/View/LayoutList.jsx +++ b/web/src/pages/DataManagement/View/LayoutList.jsx @@ -8,6 +8,7 @@ import { formatMessage } from "umi/locale"; import request from "@/utils/request"; import styles from './LayoutList.less'; import { withRouter } from "react-router-dom"; +import SearchInput from "@/components/infini/SearchInput"; export default withRouter((props) => { const { layout, indexPattern, clusterId, isView, onRowSelect } = props; @@ -141,7 +142,7 @@ export default withRouter((props) => { return ( isView ? ( - + { selectedRow?.id !== record.id && ( { @@ -153,7 +154,7 @@ export default withRouter((props) => { { defaultAction } ) : ( - + Edit @@ -194,7 +195,7 @@ export default withRouter((props) => { }} >
- { />
) -}) \ No newline at end of file +}) diff --git a/web/src/pages/DataManagement/View/Widget/WidgetConfig/WidgetConfigDrawer.jsx b/web/src/pages/DataManagement/View/Widget/WidgetConfig/WidgetConfigDrawer.jsx index 85c2e602..b268f0d8 100644 --- a/web/src/pages/DataManagement/View/Widget/WidgetConfig/WidgetConfigDrawer.jsx +++ b/web/src/pages/DataManagement/View/Widget/WidgetConfig/WidgetConfigDrawer.jsx @@ -42,7 +42,7 @@ export default (props) => { padding: 0, height: "calc(100vh - 55px)", }} - wrapClassName={styles.widgetConfig} + className={styles.widgetConfig} onClose={() => onVisibleChange(false)} destroyOnClose > diff --git a/web/src/pages/DataManagement/View/WidgetLoader.jsx b/web/src/pages/DataManagement/View/WidgetLoader.jsx index dc8b31d5..22d2c0fe 100644 --- a/web/src/pages/DataManagement/View/WidgetLoader.jsx +++ b/web/src/pages/DataManagement/View/WidgetLoader.jsx @@ -11,8 +11,18 @@ import { CopyToClipboard } from "react-copy-to-clipboard"; import { formatMessage } from "umi/locale"; export const WidgetRender = (props) => { - - const { widget, range, query, queryParams = {}, highlightRange = {}, refresh, showCopy = true } = props; + + const { + widget, + range, + query, + queryParams = {}, + highlightRange = {}, + refresh, + showCopy = true, + onGlobalQueriesChange = () => {}, + onHighlightRangeChange = () => {}, + } = props; const [globalRangeCache, setGlobalRangeCache] = useState() const [requests, setRequests] = useState([]) @@ -71,20 +81,20 @@ export const WidgetRender = (props) => { hideHeader: true, hideBorder: true, }} - globalRangeCache={globalRangeCache} - onGlobalRangeCacheChange={setGlobalRangeCache} - onGlobalQueriesChange={() => {}} - onClone={() => {}} - onRemove={() => {}} - onSave={() => {}} - onFullElement={() => {}} - clusterList={[]} - highlightRange={highlightRange} - onHighlightRangeChange={() => {}} - onResultChange={(res) => { - setRequests(Array.isArray(res) ? res.filter((item) => !!item.request).map((item) => item.request) : []) - }} - refresh={refresh} + globalRangeCache={globalRangeCache} + onGlobalRangeCacheChange={setGlobalRangeCache} + onGlobalQueriesChange={onGlobalQueriesChange} + onClone={() => {}} + onRemove={() => {}} + onSave={() => {}} + onFullElement={() => {}} + clusterList={[]} + highlightRange={highlightRange} + onHighlightRangeChange={onHighlightRangeChange} + onResultChange={(res) => { + setRequests(Array.isArray(res) ? res.filter((item) => !!item.request).map((item) => item.request) : []) + }} + refresh={refresh} />
) : @@ -123,4 +133,4 @@ export default (props) => {
) -} \ No newline at end of file +} diff --git a/web/src/pages/DataManagement/components/DeleteIndexModal.jsx b/web/src/pages/DataManagement/components/DeleteIndexModal.jsx index 2252e199..3f2de84e 100644 --- a/web/src/pages/DataManagement/components/DeleteIndexModal.jsx +++ b/web/src/pages/DataManagement/components/DeleteIndexModal.jsx @@ -6,6 +6,13 @@ import { router } from "umi"; import { formatMessage } from "umi/locale"; export default (props) => { + const selectedCluster = props.selectedCluster || {}; + const selectedClusterId = selectedCluster?.id || ""; + const selectedClusterName = + (typeof selectedCluster?.name === "string" && selectedCluster?.name) || + (typeof selectedCluster?.label === "string" && selectedCluster?.label) || + (typeof selectedCluster?.title === "string" && selectedCluster?.title) || + selectedClusterId; const [len, hasSpecialIndex] = useMemo(() => { let items = props.items; let len = items.length; @@ -23,12 +30,35 @@ export default (props) => { 1 ? `Delete ${len} indices` : "Delete index"} + title={formatMessage( + { + id: + len > 1 + ? "indices.delete.modal.title.batch" + : "indices.delete.modal.title.single", + }, + { count: len } + )} onCancel={props.onCancel} onOk={props.onOk} okButtonProps={{ disabled: !props.deleteIndexConfirm }} > -

You are about to delete these indices in cluster {props.selectedCluster.name}:

+

+ {formatMessage( + { id: "indices.delete.modal.cluster" }, + { + cluster: selectedClusterName || selectedClusterId || "-", + } + )} + {selectedClusterId && selectedClusterName !== selectedClusterId ? ( + <> + {" "} + + + + + ) : null} +

\n \n ", + "content_type": "text/html" + }, + "enabled": false +} +POST $[[SETUP_INDEX_PREFIX]]channel/$[[SETUP_DOC_TYPE]]/cj8atf53q95lhahebg8g +{ + "id": "cj8atf53q95lhahebg8g", + "created": "2023-08-07T16:43:40.062389175+08:00", + "updated": "2023-08-10T12:04:42.842628127+08:00", + "name": "[Recovery] Email Notification", + "type": "email", + "sub_type": "email", + "email": { + "server_id": "", + "recipients": { + "to": [], + "cc": [] + }, + "subject": "[INFINI Platform Alerting] {{.title}}", + "body": "\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n
\n \n \n \n \n \n \n
\n
\n
\n \n
\n \n \n \n \n
\n
\n \n \n \n \n \n \n
\n \"email-header\"\n
\n
\n \n \n \n \n \n \n
\n
\n
\n {{.title}}\n

\n {{.message | md_to_html}}\n
\n

\n \n \n View Detail\n \n

\n \n \n \n

\n \n

\n \"INFINI\n
\n
\n \n
\n

\n \n \n
\n
\n \n \n \n
\n
\n
\n \n
\n \n \n \n \n
\n
\n
\n
\n \n \n \n
\n
\n \n
\n \n \n
\n
\n \n ", + "content_type": "text/html" + }, + "enabled": false +} +POST $[[SETUP_INDEX_PREFIX]]channel/$[[SETUP_DOC_TYPE]]/ch1os6t3q95lk6lepkq0 +{ + "id": "ch1os6t3q95lk6lepkq0", + "created": "2023-04-22T07:34:51.848540351Z", + "updated": "2023-08-10T17:18:38.592432088+08:00", + "name": "[Alerting] Feishu Notification", + "type": "webhook", + "webhook": { + "header_params": { + "Content-Type": "application/json" + }, + "method": "POST", + "url": "{{$.env.FEISHU_WEBHOOK_ENDPOINT}}", + "body": "{\n \"msg_type\": \"interactive\",\n \"card\": {\n \"header\": {\n \"title\": {\n \"content\": \"[ INFINI Platform Alerting ]\",\n \"tag\": \"plain_text\"\n },\n \"template\":\"{{if eq .priority \"critical\"}}red{{else if eq .priority \"high\"}}orange{{else if eq .priority \"medium\"}}yellow{{else if eq .priority \"low\"}}grey{{else}}blue{{end}}\"\n },\n \"elements\": [{\n \"tag\": \"markdown\",\n \"content\": \"🔥 Incident [#{{.event_id}}]({{$.env.INFINI_CONSOLE_ENDPOINT}}/#/alerting/message/{{.event_id}}) is ongoing\\n **{{.title}}**\\nPriority: {{.priority}}\\nEventID: {{.event_id}}\\nTarget: {{.resource_name}}-{{.objects}}\\nTriggerAt: {{.trigger_at | datetime}}\"\n },{\n \"tag\": \"hr\"\n },\n {\n \"tag\": \"markdown\",\n \"content\": \"{{ .message | str_replace \"\\n\" \"\\\\n\" }}\"\n }\n ]\n}\n}" + }, + "sub_type": "feishu", + "enabled": false + } +POST $[[SETUP_INDEX_PREFIX]]channel/$[[SETUP_DOC_TYPE]]/cj8e9s53q95gsdbb054g +{ + "id": "cj8e9s53q95gsdbb054g", + "created": "2023-08-07T20:34:56.334695598+08:00", + "updated": "2023-08-10T17:18:36.035896482+08:00", + "name": "[Recovery] Feishu Notification", + "type": "webhook", + "webhook": { + "header_params": { + "Content-Type": "application/json" + }, + "method": "POST", + "url": "{{$.env.FEISHU_WEBHOOK_ENDPOINT}}", + "body": "{\n \"msg_type\": \"interactive\",\n \"card\": {\n \"header\": {\n \"title\": {\n \"content\": \"[ INFINI Platform Alerting ]\",\n \"tag\": \"plain_text\"\n },\n \"template\":\"green\"\n },\n \"elements\": [\n {\n \"tag\": \"markdown\",\n \"content\": \"**{{.title}}**\"\n },\n {\n \"tag\": \"hr\"\n },\n {\n \"tag\": \"markdown\",\n \"content\": \"{{ .message | str_replace \"\\n\" \"\\\\n\" }}\"\n },\n {\n \"tag\": \"hr\"\n },\n {\n \"tag\": \"markdown\",\n \"content\": \"[View Incident]({{$.env.INFINI_CONSOLE_ENDPOINT}}/#/alerting/message/{{.event_id}})\"\n }\n ]\n }\n}" + }, + "sub_type": "feishu", + "enabled": false +} +POST $[[SETUP_INDEX_PREFIX]]channel/$[[SETUP_DOC_TYPE]]/cj865st3q95rega919ig +{ + "id": "cj865st3q95rega919ig", + "created": "2023-08-07T11:20:19.223545026+08:00", + "updated": "2023-08-10T17:18:41.92016786+08:00", + "name": "[Alerting] Discord Notification", + "type": "webhook", + "webhook": { + "header_params": { + "Content-Type": "application/json" + }, + "method": "POST", + "url": "{{$.env.DISCORD_WEBHOOK_ENDPOINT}}", + "body": "{\"content\": \"**[ INFINI Platform Alerting ]**\\n🔥 Incident [#{{.event_id}}]({{$.env.INFINI_CONSOLE_ENDPOINT}}/#/alerting/message/{{.event_id}}) is ongoing\\n**{{.title}}**\\n\\nPriority: {{.priority}}\\nEventID: {{.event_id}}\\nTarget: {{.resource_name}}-{{.objects}}\\nTriggerAt: {{.trigger_at | datetime}}\\n{{ .message | str_replace \"\\n\" \"\\\\n\" }}\"}" + }, + "sub_type": "discord", + "enabled": false +} +POST $[[SETUP_INDEX_PREFIX]]channel/$[[SETUP_DOC_TYPE]]/cj86l0l3q95rrpfea6ug +{ + "id": "cj86l0l3q95rrpfea6ug", + "created": "2023-08-07T11:52:34.192522006+08:00", + "updated": "2023-08-10T17:18:44.422687739+08:00", + "name": "[Recovery] Discord Notification", + "type": "webhook", + "webhook": { + "header_params": { + "Content-Type": "application/json" + }, + "method": "POST", + "url": "{{$.env.DISCORD_WEBHOOK_ENDPOINT}}", + "body": "{\n \"content\": \"**[ INFINI Platform Alerting ]**\\n🌈 **{{.title}}**\\n\\n{{.message | str_replace \"\\n\" \"\\\\n\" }}\\n> [View Incident]({{$.env.INFINI_CONSOLE_ENDPOINT}}/#/alerting/message/{{.event_id}})\"\n}" + }, + "sub_type": "discord", + "enabled": false +} + +#alerting +#The `id` value is consistent with the `_id` value +POST $[[SETUP_INDEX_PREFIX]]alert-rule/$[[SETUP_DOC_TYPE]]/builtin-cal8n7p7h710dpnoaps0 +{ + "id": "builtin-cal8n7p7h710dpnoaps0", + "created": "2022-06-16T01:47:11.326727124Z", + "updated": "2023-08-09T22:39:43.98598502+08:00", + "name": "Cluster Health Change to Red", + "enabled": true, + "resource": { + "resource_id": "$[[SETUP_RESOURCE_ID]]", + "resource_name": "$[[SETUP_RESOURCE_NAME]]", + "type": "elasticsearch", + "objects": [ + ".infini_metrics*" + ], + "filter": {}, + "raw_filter": { + "bool": { + "must": [ + { + "term": { + "payload.elasticsearch.cluster_health.status": "red" + } + }, + { + "term": { + "metadata.name": { + "value": "cluster_health" + } + } + } + ] + } + }, + "time_field": "timestamp", + "context": { + "fields": null + } + }, + "metrics": { + "bucket_size": "1m", + "groups": [ + { + "field": "metadata.labels.cluster_id", + "limit": 5 + } + ], + "formula": "a", + "items": [ + { + "name": "a", + "field": "payload.elasticsearch.cluster_health.status", + "statistic": "count" + } + ], + "format_type": "num", + "bucket_label": { + "enabled": false + }, + "expression": "count(payload.elasticsearch.cluster_health.status)" + }, + "conditions": { + "operator": "any", + "items": [ + { + "minimum_period_match": 1, + "operator": "gte", + "values": [ + "1" + ], + "priority": "critical" + } + ] + }, + "notification_config": { + "enabled": true, + "title": "🔥 Health of Clusters ({{len .results}} clusters in total) Changed to Red", + "message": "{{range .results}}\n{{$cn := lookup \"category=metadata, object=cluster, property=name, default=N/A\" (index .group_values 0) }}\n{{$cu := printf \"%s/#/cluster/monitor/elasticsearch/%s\" $.env.INFINI_CONSOLE_ENDPOINT (index .group_values 0)}}\nCluster: [{{$cn}}]({{$cu}}?_g=%7B%22timeRange%22:%7B%22min%22:%22{{$.min}}%22%2C%22max%22:%22{{$.max}}%22%7D%2C%22cluster_name%22:%22{{$cn | urlquery}}%22%7D) is Red now\n{{end}}", + "normal": [ + { + "id": "cgnb2nt3q95nmusjl65g", + "created": "2023-04-06T11:47:43.104108279Z", + "updated": "2023-08-07T15:02:17.165625799+08:00", + "name": "[Alerting] Slack Notification", + "type": "webhook", + "webhook": { + "header_params": { + "Content-Type": "application/json" + }, + "method": "POST", + "body": "{\n \"blocks\": [\n {\n \"type\": \"section\",\n \"text\": {\n \"type\": \"mrkdwn\",\n \"text\": \"*{{if eq .priority \"critical\"}} :fire: {{else if eq .priority \"error\"}} :rotating_light: {{else}} :warning: {{end}} Incident <{{$.env.INFINI_CONSOLE_ENDPOINT}}/#/alerting/message/{{.event_id}}|#{{.event_id}}> is ongoing*\\n :point_right: *{{.rule_name}} - {{.title}}*\"\n }\n },\n {\n \"type\": \"section\",\n \"text\": {\n \"type\": \"mrkdwn\",\n \"text\": \"*TriggerAt:* {{.timestamp | datetime}}\"\n }\n },\n {\n \"type\": \"section\",\n \"text\": {\n \"type\": \"mrkdwn\",\n \"text\": \"*Priority:* {{.priority}}\"\n }\n },\n {\n \"type\": \"divider\"\n }\n ]\n {{if gt (len .results) 0}}\n ,\"attachments\": [\n {{range .results}}\n {\n \"color\": {{if eq .priority \"critical\"}} \"#C91010\" {{else if eq .priority \"error\"}} \"#EB4C21\" {{else}} \"#FFB449\" {{end}},\n \"blocks\": [\n {\n \"type\": \"section\",\n \"text\": {\n \"type\": \"mrkdwn\",\n \"text\": \"*Cluster:* <{{$.env.INFINI_CONSOLE_ENDPOINT}}/#/cluster/monitor/elasticsearch/{{ index .group_values 0}}?_g={%22timeRange%22:%7B%22min%22:%22{{$.min}}%22%2C%22max%22:%22{{$.max}}%22%7D%2C%22cluster_name%22:%22{{lookup \"category=metadata, object=cluster, property=name, default=N/A\" (index .group_values 0) }}%22} | {{lookup \"category=metadata, object=cluster, property=name, default=N/A\" (index .group_values 0) }}> is Red now\"\n }\n }\n ]\n },\n {{end}}\n {\n \"blocks\": [\n {\n \"type\": \"divider\"\n },\n {\n \"type\": \"actions\",\n \"elements\": [\n {\n \"type\": \"button\",\n \"text\": {\n \"type\": \"plain_text\",\n \"text\": \"View Incident\" \n },\n \"url\": \"{{$.env.INFINI_CONSOLE_ENDPOINT}}/#/alerting/message/{{.event_id}}\"\n }\n ]\n },\n ]\n }\n ]\n {{end}}\n}" + }, + "sub_type": "slack", + "enabled": true + }, + { + "id": "cgiospt3q95q49k3u00g", + "enabled": true + }, + { + "id": "cj865st3q95rega919ig", + "enabled": true + }, + { + "id": "cgnb2r53q95nmusjl6vg", + "enabled": true + }, + { + "id": "ch1os6t3q95lk6lepkq0", + "enabled": true + }, + { + "id": "cgnb2kt3q95nmusjl64g", + "enabled": true + } + ], + "throttle_period": "1h", + "accept_time_range": { + "start": "00:00", + "end": "23:59" + } + }, + "category": "Platform", + "recovery_notification_config": { + "enabled": true, + "title": "🌈 {{.rule_name}} Resolved", + "message": "EventID: {{.event_id}} \nTarget: {{.resource_name}}-{{.objects}} \nTriggerAt: {{.trigger_at | datetime}} \nResolveAt: {{.timestamp | datetime}} \nDuration: {{.duration}}{{if .recovery_context}} \n{{.recovery_context}}{{end}}", + "normal": [ + { + "id": "cj8atf53q95lhahebg8g", + "enabled": true + }, + { + "id": "cj8bq8d3q95ogankugqg", + "enabled": true + }, + { + "id": "cj8ctat3q95l9ebbntlg", + "enabled": true + }, + { + "id": "cj8e9s53q95gsdbb054g", + "enabled": true + }, + { + "id": "cj8e9gt3q95gsdbb0170", + "enabled": true + }, + { + "id": "cj86l0l3q95rrpfea6ug", + "enabled": true + } + ], + "event_enabled": true + }, + "schedule": { + "interval": "1m" + }, + "creator": { + "name": "$[[SETUP_USERNAME]]", + "id": "$[[SETUP_USER_ID]]" + } +} +POST $[[SETUP_INDEX_PREFIX]]alert-rule/$[[SETUP_DOC_TYPE]]/builtin-calavvp7h710dpnp32r3 +{ + "id": "builtin-calavvp7h710dpnp32r3", + "created": "2022-06-16T04:22:23.001354546Z", + "updated": "2023-08-09T22:20:17.864619426+08:00", + "name": "Index Health Change to Red", + "enabled": true, + "resource": { + "resource_id": "$[[SETUP_RESOURCE_ID]]", + "resource_name": "$[[SETUP_RESOURCE_NAME]]", + "type": "elasticsearch", + "objects": [ + ".infini_metrics" + ], + "filter": {}, + "raw_filter": {"bool":{"must":[{"term":{"payload.elasticsearch.index_health.status":"red"}},{"term":{"metadata.name":{"value":"index_health"}}}]}}, + "time_field": "timestamp", + "context": { + "fields": null + } + }, + "metrics": { + "bucket_size": "1m", + "groups": [ + { + "field": "metadata.cluster_id", + "limit": 50 + }, + { + "field": "metadata.index_name", + "limit": 1000 + } + ], + "formula": "a", + "items": [ + { + "name": "a", + "field": "metadata.index_name", + "statistic": "count" + } + ], + "format_type": "num", + "bucket_label": { + "enabled": false + }, + "expression": "count(metadata.index_name)" + }, + "conditions": { + "operator": "any", + "items": [ + { + "minimum_period_match": 1, + "operator": "gte", + "values": [ + "1" + ], + "priority": "high" + } + ] + }, + "notification_config": { + "enabled": true, + "title": "🔥 Health of Indices ({{len .results}} indices in total) Changed to Red", + "message": "{{range .results}}\n{{$cn := lookup \"category=metadata, object=cluster, property=name, default=N/A\" (index .group_values 0) }}\n{{$iu := printf \"%s/#/cluster/monitor/%s/indices/%s\" $.env.INFINI_CONSOLE_ENDPOINT (index .group_values 0) (index .group_values 1)}}\n{{$cu := printf \"%s/#/cluster/monitor/elasticsearch/%s\" $.env.INFINI_CONSOLE_ENDPOINT (index .group_values 0)}}\nIndex: [{{index .group_values 1}}]({{$iu}}?_g=%7B%22timeRange%22:%7B%22min%22:%22{{$.min}}%22%2C%22max%22:%22{{$.max}}%22%7D%2C%22cluster_name%22:%22{{$cn | urlquery}}%22%7D) of Cluster: [{{$cn}}]({{$cu}}?_g=%7B%22timeRange%22:%7B%22min%22:%22{{$.min}}%22%2C%22max%22:%22{{$.max}}%22%7D) is Red now\n{{end}}", + "normal": [ + { + "id": "cgnb2nt3q95nmusjl65g", + "created": "2023-04-06T11:47:43.104108279Z", + "updated": "2023-08-07T15:17:26.18861218+08:00", + "name": "[Alerting] Slack Notification", + "type": "webhook", + "webhook": { + "header_params": { + "Content-type": "application/json" + }, + "method": "POST", + "body": "{\n \"blocks\": [\n {\n \"type\": \"section\",\n \"text\": {\n \"type\": \"mrkdwn\",\n \"text\": \"*{{if eq .priority \"critical\"}} :fire: {{else if eq .priority \"error\"}} :rotating_light: {{else}} :warning: {{end}} Incident <{{$.env.INFINI_CONSOLE_ENDPOINT}}/#/alerting/message/{{.event_id}}|#{{.event_id}}> is ongoing*\\n :point_right: *{{.rule_name}} - {{.title}}*\"\n }\n },\n {\n \"type\": \"section\",\n \"text\": {\n \"type\": \"mrkdwn\",\n \"text\": \"*TriggerAt:* {{.timestamp | datetime}}\"\n }\n },\n {\n \"type\": \"section\",\n \"text\": {\n \"type\": \"mrkdwn\",\n \"text\": \"*Priority:* {{.priority}}\"\n }\n },\n {\n \"type\": \"divider\"\n }\n ]\n {{if gt (len .results) 0}}\n ,\"attachments\": [\n {{range .results}}\n {\n \"color\": {{if eq .priority \"critical\"}} \"#C91010\" {{else if eq .priority \"error\"}} \"#EB4C21\" {{else}} \"#FFB449\" {{end}},\n \"blocks\": [\n {\n \"type\": \"section\",\n \"text\": {\n \"type\": \"mrkdwn\",\n \"text\": \"Index: <{{$.env.INFINI_CONSOLE_ENDPOINT}}#/cluster/monitor/{{ index .group_values 0}}/indices/{{ index .group_values 1}}?_g={%22timeRange%22:%7B%22min%22:%22{{$.min}}%22%2C%22max%22:%22{{$.max}}%22%7D%2C%22cluster_name%22:%22{{ lookup \"category=metadata, object=cluster, property=name, default=N/A\" (index .group_values 0)}}%22} | {{index .group_values 1}}> of Cluster: <{{$.env.INFINI_CONSOLE_ENDPOINT}}/#/cluster/monitor/elasticsearch/{{index .group_values 0}}?_g={%22timeRange%22:%7B%22min%22:%22{{$.min}}%22%2C%22max%22:%22{{$.max}}%22%7D%2C%22cluster_name%22:%22{{lookup \"category=metadata, object=cluster, property=name, default=N/A\" (index .group_values 0) }}%22} | {{lookup \"category=metadata, object=cluster, property=name, default=N/A\" (index .group_values 0) }}> is Red now\"\n }\n }\n ]\n },\n {{end}}\n {\n \"blocks\": [\n {\n \"type\": \"divider\"\n },\n {\n \"type\": \"actions\",\n \"elements\": [\n {\n \"type\": \"button\",\n \"text\": {\n \"type\": \"plain_text\",\n \"text\": \"View Incident\" \n },\n \"url\": \"{{$.env.INFINI_CONSOLE_ENDPOINT}}/#/alerting/message/{{.event_id}}\"\n }\n ]\n },\n ]\n }\n ]\n {{end}}\n}" + }, + "sub_type": "slack", + "enabled": true + }, + { + "id": "cgiospt3q95q49k3u00g", + "enabled": true + }, + { + "id": "cj865st3q95rega919ig", + "enabled": true + }, + { + "id": "cgnb2r53q95nmusjl6vg", + "enabled": true + }, + { + "id": "ch1os6t3q95lk6lepkq0", + "enabled": true + }, + { + "id": "cgnb2kt3q95nmusjl64g", + "enabled": true + } + ], + "throttle_period": "1h", + "accept_time_range": { + "start": "00:00", + "end": "23:59" + } + }, + "category": "Platform", + "recovery_notification_config": { + "enabled": true, + "title": "🌈{{.rule_name}} Resolved", + "message": "EventID: {{.event_id}} \nTarget: {{.resource_name}}-{{.objects}} \nTriggerAt: {{.trigger_at | datetime}} \nResolveAt: {{.timestamp | datetime}} \nDuration: {{.duration}}{{if .recovery_context}} \n{{.recovery_context}}{{end}}", + "normal": [ + { + "id": "cj8bq8d3q95ogankugqg", + "enabled": true + }, + { + "id": "cj8ctat3q95l9ebbntlg", + "enabled": true + }, + { + "id": "cj8atf53q95lhahebg8g", + "enabled": true + }, + { + "id": "cj8e9s53q95gsdbb054g", + "enabled": true + }, + { + "id": "cj8e9gt3q95gsdbb0170", + "enabled": true + }, + { + "id": "cj86l0l3q95rrpfea6ug", + "enabled": true + } + ], + "event_enabled": true + }, + "schedule": { + "interval": "1m" + }, + "creator": { + "name": "$[[SETUP_USERNAME]]", + "id": "$[[SETUP_USER_ID]]" + } +} +POST $[[SETUP_INDEX_PREFIX]]alert-rule/$[[SETUP_DOC_TYPE]]/builtin-cbp20n2anisjmu4gehc5 +{ + "id": "builtin-cbp20n2anisjmu4gehc5", + "created": "2022-08-09T08:52:44.63345561Z", + "updated": "2023-08-09T22:11:45.679048697+08:00", + "name": "Elasticsearch node left cluster", + "enabled": true, + "resource": { + "resource_id": "$[[SETUP_RESOURCE_ID]]", + "resource_name": "$[[SETUP_RESOURCE_NAME]]", + "type": "elasticsearch", + "objects": [ + ".infini_node" + ], + "filter": {}, + "raw_filter": { + "match_phrase": { + "metadata.labels.status": "unavailable" + } + }, + "ignore_time_filter": true, + "time_field": "timestamp", + "context": { + "fields": null + } + }, + "metrics": { + "bucket_size": "1m", + "groups": [ + { + "field": "metadata.cluster_id", + "limit": 5 + }, + { + "field": "metadata.node_id", + "limit": 50 + } + ], + "formula": "a", + "items": [ + { + "name": "a", + "field": "metadata.labels.status", + "statistic": "count" + } + ], + "format_type": "num", + "bucket_label": { + "enabled": false + }, + "expression": "count(metadata.labels.status)" + }, + "conditions": { + "operator": "any", + "items": [ + { + "minimum_period_match": 1, + "operator": "gte", + "values": [ + "1" + ], + "priority": "critical" + } + ] + }, + "notification_config": { + "enabled": true, + "title": "🔥 Elasticsearch node left cluster", + "message": "{{range .results}}\n{{$cn := lookup \"category=metadata, object=cluster, property=name, default=N/A\" (index .group_values 0) }}\n{{$nn := lookup \"category=metadata, object=node, property=metadata.node_name, default=N/A\" (index .group_values 1) }}\n{{$nu := printf \"%s/#/cluster/monitor/%s/nodes/%s\" $.env.INFINI_CONSOLE_ENDPOINT (index .group_values 0) (index .group_values 1)}}\n{{$cu := printf \"%s/#/cluster/monitor/elasticsearch/%s\" $.env.INFINI_CONSOLE_ENDPOINT (index .group_values 0)}}\nNode: [{{$nn}}]({{$nu}}?_g=%7B%22timeRange%22:%7B%22min%22:%22{{$.min}}%22%2C%22max%22:%22{{$.max}}%22%7D%2C%22cluster_name%22:%22{{$cn | urlquery}}%22%2C%22node_name%22:%22{{$nn}}%22%7D) of Cluster: [{{$cn}}]({{$cu}}?_g=%7B%22timeRange%22:%7B%22min%22:%22{{$.min}}%22%2C%22max%22:%22{{$.max}}%22%7D%7D), Left: {{.result_value}}\n{{end}}", + "normal": [ + { + "id": "cgnb2nt3q95nmusjl65g", + "created": "2023-04-06T11:47:43.104108279Z", + "updated": "2023-08-07T10:42:17.686776304+08:00", + "name": "Slack Notification", + "type": "webhook", + "webhook": { + "header_params": { + "Content-type": "application/json" + }, + "method": "POST", + "body": "{\n \"blocks\": [\n {\n \"type\": \"section\",\n \"text\": {\n \"type\": \"mrkdwn\",\n \"text\": \"*{{if eq .priority \"critical\"}} :fire: {{else if eq .priority \"error\"}} :rotating_light: {{else}} :warning: {{end}} Incident <{{$.env.INFINI_CONSOLE_ENDPOINT}}/#/alerting/message/{{.event_id}}|#{{.event_id}}> is ongoing*\\n :point_right: *{{.rule_name}} - {{.title}}*\"\n }\n },\n {\n \"type\": \"section\",\n \"text\": {\n \"type\": \"mrkdwn\",\n \"text\": \"*TriggerAt:* {{.timestamp | datetime}}\"\n }\n },\n {\n \"type\": \"section\",\n \"text\": {\n \"type\": \"mrkdwn\",\n \"text\": \"*Priority:* {{.priority}}\"\n }\n },\n {\n \"type\": \"divider\"\n }\n ]\n {{if gt (len .results) 0}}\n ,\"attachments\": [\n {{range .results}}\n {\n \"color\": {{if eq .priority \"critical\"}} \"#C91010\" {{else if eq .priority \"error\"}} \"#EB4C21\" {{else}} \"#FFB449\" {{end}},\n \"blocks\": [\n {\n \"type\": \"section\",\n \"text\": {\n \"type\": \"mrkdwn\",\n \"text\": \"Node: <{{$.env.INFINI_CONSOLE_ENDPOINT}}/#/cluster/monitor/{{index .group_values 0}}/nodes/{{index .group_values 1}}?_g={%22timeRange%22:%7B%22min%22:%22{{$.min}}%22%2C%22max%22:%22{{$.max}}%22%7D%2C%22cluster_name%22:%22{{lookup \"category=metadata, object=cluster, property=name, default=N/A\" (index .group_values 0) }}%22%2C%22node_name%22:%22{{lookup \"category=metadata, object=node, property=metadata.node_name, default=N/A\" (index .group_values 1) }}%22} | {{lookup \"category=metadata, object=node, property=metadata.node_name, default=N/A\" (index .group_values 1) }}> of Cluster: <{{$.env.INFINI_CONSOLE_ENDPOINT}}/#/cluster/monitor/elasticsearch/{{index .group_values 0}}?_g={%22timeRange%22:%7B%22min%22:%22{{$.min}}%22%2C%22max%22:%22{{$.max}}%22%7D%2C%22cluster_name%22:%22{{lookup \"category=metadata, object=cluster, property=name, default=N/A\" (index .group_values 0) }}%22} | {{lookup \"category=metadata, object=cluster, property=name, default=N/A\" (index .group_values 0) }}>, Left: {{.result_value}}\"\n }\n }\n ]\n },\n {{end}}\n {\n \"blocks\": [\n {\n \"type\": \"divider\"\n },\n {\n \"type\": \"actions\",\n \"elements\": [\n {\n \"type\": \"button\",\n \"text\": {\n \"type\": \"plain_text\",\n \"text\": \"View Incident\" \n },\n \"url\": \"{{$.env.INFINI_CONSOLE_ENDPOINT}}/#/alerting/message/{{.event_id}}\"\n }\n ]\n },\n ]\n }\n ]\n {{end}}\n}" + }, + "sub_type": "slack", + "enabled": true + }, + { + "id": "cgnb2r53q95nmusjl6vg", + "enabled": true + }, + { + "id": "ch1os6t3q95lk6lepkq0", + "enabled": true + }, + { + "id": "cgnb2kt3q95nmusjl64g", + "enabled": true + }, + { + "id": "cj865st3q95rega919ig", + "enabled": true + }, + { + "id": "cgiospt3q95q49k3u00g", + "enabled": true + } + ], + "throttle_period": "1h", + "accept_time_range": { + "start": "00:00", + "end": "23:59" + } + }, + "category": "Platform", + "recovery_notification_config": { + "enabled": true, + "title": "🌈 {{.rule_name}} Resolved", + "message": "EventID: {{.event_id}} \nTarget: {{.resource_name}}-{{.objects}} \nTriggerAt: {{.trigger_at | datetime}} \nResolveAt: {{.timestamp | datetime}} \nDuration: {{.duration}}{{if .recovery_context}} \n{{.recovery_context}}{{end}}", + "normal": [ + { + "id": "cj8bq8d3q95ogankugqg", + "enabled": true + }, + { + "id": "cj8ctat3q95l9ebbntlg", + "enabled": true + }, + { + "id": "cj8e9s53q95gsdbb054g", + "enabled": true + }, + { + "id": "cj8e9gt3q95gsdbb0170", + "enabled": true + }, + { + "id": "cj86l0l3q95rrpfea6ug", + "enabled": true + }, + { + "id": "cj8atf53q95lhahebg8g", + "enabled": true + } + ], + "event_enabled": true + }, + "schedule": { + "interval": "1m" + }, + "creator": { + "name": "$[[SETUP_USERNAME]]", + "id": "$[[SETUP_USER_ID]]" + } +} +POST $[[SETUP_INDEX_PREFIX]]alert-rule/$[[SETUP_DOC_TYPE]]/builtin-cb34sfl6psfiqtovhpt4 +{ + "id": "builtin-cb34sfl6psfiqtovhpt4", + "created": "2022-07-07T03:08:46.297166036Z", + "updated": "2023-08-09T22:38:41.764325087+08:00", + "name": "Too Many Deleted Documents", + "enabled": true, + "resource": { + "resource_id": "$[[SETUP_RESOURCE_ID]]", + "resource_name": "$[[SETUP_RESOURCE_NAME]]", + "type": "elasticsearch", + "objects": [ + ".infini_metrics*" + ], + "filter": {}, + "raw_filter": { + "range": { + "payload.elasticsearch.cluster_stats.indices.store.size_in_bytes": { + "gte": 32212254720 + } + } + }, + "time_field": "timestamp", + "context": { + "fields": null + } + }, + "metrics": { + "bucket_size": "1m", + "groups": [ + { + "field": "metadata.labels.cluster_id", + "limit": 20 + }, + { + "field": "metadata.labels.index_name", + "limit": 10 + } + ], + "formula": "(a/(a+b))*100", + "items": [ + { + "name": "a", + "field": "payload.elasticsearch.index_stats.primaries.docs.deleted", + "statistic": "max" + }, + { + "name": "b", + "field": "payload.elasticsearch.index_stats.primaries.docs.count", + "statistic": "max" + } + ], + "format_type": "ratio", + "bucket_label": { + "enabled": false + }, + "expression": "(max(payload.elasticsearch.index_stats.primaries.docs.deleted)/(max(payload.elasticsearch.index_stats.primaries.docs.deleted)+max(payload.elasticsearch.index_stats.primaries.docs.count)))*100" + }, + "conditions": { + "operator": "any", + "items": [ + { + "minimum_period_match": 1, + "operator": "gte", + "values": [ + "30" + ], + "priority": "medium" + }, + { + "minimum_period_match": 1, + "operator": "gte", + "values": [ + "40" + ], + "priority": "high" + }, + { + "minimum_period_match": 1, + "operator": "gte", + "values": [ + "55" + ], + "priority": "low" + } + ] + }, + "notification_config": { + "enabled": true, + "title": "🔥 Too Many Deleted Documents (>30%)", + "message": "{{range .results}}\n{{$cn := lookup \"category=metadata, object=cluster, property=name, default=N/A\" (index .group_values 0) }}\n{{$iu := printf \"%s/#/cluster/monitor/%s/indices/%s\" $.env.INFINI_CONSOLE_ENDPOINT (index .group_values 0) (index .group_values 1)}}\n{{$cu := printf \"%s/#/cluster/monitor/elasticsearch/%s\" $.env.INFINI_CONSOLE_ENDPOINT (index .group_values 0)}}\nIndex: [{{index .group_values 1}}]({{$iu}}?_g=%7B%22timeRange%22:%7B%22min%22:%22{{$.min}}%22%2C%22max%22:%22{{$.max}}%22%7D%2C%22cluster_name%22:%22{{$cn | urlquery}}%22%7D) of Cluster: [{{$cn}}]({{$cu}}?_g=%7B%22timeRange%22:%7B%22min%22:%22{{$.min}}%22%2C%22max%22:%22{{$.max}}%22%7D%7D), Deleted: {{.result_value | to_fixed 2}}%\n{{end}}", + "normal": [ + { + "id": "cgnb2nt3q95nmusjl65g", + "name": "", + "type": "webhook", + "webhook": { + "header_params": { + "Content-type": "application/json" + }, + "method": "POST", + "body": "{\n \"blocks\": [\n {\n \"type\": \"section\",\n \"text\": {\n \"type\": \"mrkdwn\",\n \"text\": \"*{{if eq .priority \"critical\"}} :fire: {{else if eq .priority \"error\"}} :rotating_light: {{else}} :warning: {{end}} Incident <{{$.env.INFINI_CONSOLE_ENDPOINT}}/#/alerting/message/{{.event_id}}|#{{.event_id}}> is ongoing*\\n :point_right: *{{.rule_name}} - {{.title}}*\"\n }\n },\n {\n \"type\": \"section\",\n \"text\": {\n \"type\": \"mrkdwn\",\n \"text\": \"*TriggerAt:* {{.timestamp | datetime}}\"\n }\n },\n {\n \"type\": \"section\",\n \"text\": {\n \"type\": \"mrkdwn\",\n \"text\": \"*Priority:* {{.priority}}\"\n }\n },\n {\n \"type\": \"divider\"\n }\n ]\n {{if gt (len .results) 0}}\n ,\"attachments\": [\n {{range .results}}\n {\n \"color\": {{if eq .priority \"critical\"}} \"#C91010\" {{else if eq .priority \"error\"}} \"#EB4C21\" {{else}} \"#FFB449\" {{end}},\n \"blocks\": [\n {\n \"type\": \"section\",\n \"text\": {\n \"type\": \"mrkdwn\",\n \"text\": \"Index: <{{$.env.INFINI_CONSOLE_ENDPOINT}}#/cluster/monitor/{{ index .group_values 0}}/indices/{{ index .group_values 1}}?_g={%22timeRange%22:%7B%22min%22:%22{{$.min}}%22%2C%22max%22:%22{{$.max}}%22%7D%2C%22cluster_name%22:%22{{ lookup \"category=metadata, object=cluster, property=name, default=N/A\" (index .group_values 0)}}%22} | {{index .group_values 1}}> of Cluster: <{{$.env.INFINI_CONSOLE_ENDPOINT}}/#/cluster/monitor/elasticsearch/{{index .group_values 0}}?_g={%22timeRange%22:%7B%22min%22:%22{{$.min}}%22%2C%22max%22:%22{{$.max}}%22%7D%2C%22cluster_name%22:%22{{lookup \"category=metadata, object=cluster, property=name, default=N/A\" (index .group_values 0) }}%22} | {{lookup \"category=metadata, object=cluster, property=name, default=N/A\" (index .group_values 0) }}>, Deleted ratio: {{.result_value | to_fixed 2}}%\"\n }\n }\n ]\n },\n {{end}}\n {\n \"blocks\": [\n {\n \"type\": \"divider\"\n },\n {\n \"type\": \"actions\",\n \"elements\": [\n {\n \"type\": \"button\",\n \"text\": {\n \"type\": \"plain_text\",\n \"text\": \"View Incident\" \n },\n \"url\": \"{{$.env.INFINI_CONSOLE_ENDPOINT}}/#/alerting/message/{{.event_id}}\"\n }\n ]\n },\n ]\n }\n ]\n {{end}}\n}" + }, + "sub_type": "", + "enabled": true + }, + { + "id": "cj865st3q95rega919ig", + "enabled": true + }, + { + "id": "cgiospt3q95q49k3u00g", + "enabled": true + }, + { + "id": "cgnb2r53q95nmusjl6vg", + "enabled": true + }, + { + "id": "ch1os6t3q95lk6lepkq0", + "enabled": true + }, + { + "id": "cgnb2kt3q95nmusjl64g", + "enabled": true + } + ], + "throttle_period": "24h", + "accept_time_range": { + "start": "00:00", + "end": "23:59" + } + }, + "category": "Platform", + "recovery_notification_config": { + "enabled": true, + "title": "🌈 {{.rule_name}} Resolved", + "message": "EventID: {{.event_id}} \nTarget: {{.resource_name}}-{{.objects}} \nTriggerAt: {{.trigger_at | datetime}} \nResolveAt: {{.timestamp | datetime}} \nDuration: {{.duration}}{{if .recovery_context}} \n{{.recovery_context}}{{end}}", + "normal": [ + { + "id": "cj8bq8d3q95ogankugqg", + "enabled": true + }, + { + "id": "cj8ctat3q95l9ebbntlg", + "enabled": true + }, + { + "id": "cj8atf53q95lhahebg8g", + "enabled": true + }, + { + "id": "cj8e9s53q95gsdbb054g", + "enabled": true + }, + { + "id": "cj8e9gt3q95gsdbb0170", + "enabled": true + }, + { + "id": "cj86l0l3q95rrpfea6ug", + "enabled": true + } + ], + "event_enabled": true + }, + "schedule": { + "interval": "1m" + }, + "creator": { + "name": "$[[SETUP_USERNAME]]", + "id": "$[[SETUP_USER_ID]]" + } +} +POST $[[SETUP_INDEX_PREFIX]]alert-rule/$[[SETUP_DOC_TYPE]]/builtin-cbp2e4ianisjmu4giqs7 +{ + "id": "builtin-cbp2e4ianisjmu4giqs7", + "created": "2022-06-16T04:11:10.242061032Z", + "updated": "2023-08-09T22:39:15.339913317+08:00", + "name": "Search latency is great than 500ms", + "enabled": true, + "resource": { + "resource_id": "$[[SETUP_RESOURCE_ID]]", + "resource_name": "$[[SETUP_RESOURCE_NAME]]", + "type": "elasticsearch", + "objects": [ + ".infini_metrics*" + ], + "filter": {}, + "raw_filter": { + "bool": { + "must": [ + { + "term": { + "metadata.name": { + "value": "index_stats" + } + } + }, + { + "term": { + "metadata.category": { + "value": "elasticsearch" + } + } + } + ], + "must_not": [ + { + "term": { + "metadata.labels.index_name": { + "value": "_all" + } + } + } + ] + } + }, + "time_field": "timestamp", + "context": { + "fields": null + } + }, + "metrics": { + "bucket_size": "1m", + "groups": [ + { + "field": "metadata.labels.cluster_id", + "limit": 50 + }, + { + "field": "metadata.labels.index_name", + "limit": 10 + } + ], + "formula": "a/b", + "items": [ + { + "name": "a", + "field": "payload.elasticsearch.index_stats.total.search.query_time_in_millis", + "statistic": "rate" + }, + { + "name": "b", + "field": "payload.elasticsearch.index_stats.primaries.search.query_total", + "statistic": "rate" + } + ], + "format_type": "num", + "bucket_label": { + "enabled": false + }, + "expression": "rate(payload.elasticsearch.index_stats.total.search.query_time_in_millis)/rate(payload.elasticsearch.index_stats.primaries.search.query_total)" + }, + "conditions": { + "operator": "any", + "items": [ + { + "minimum_period_match": 1, + "operator": "gte", + "values": [ + "500" + ], + "priority": "medium" + }, + { + "minimum_period_match": 1, + "operator": "gte", + "values": [ + "300" + ], + "priority": "low" + } + ] + }, + "notification_config": { + "enabled": true, + "title": "🔥 Search latency is great than 500ms", + "message": "{{range .results}}\n{{$cn := lookup \"category=metadata, object=cluster, property=name, default=N/A\" (index .group_values 0) }}\n{{$iu := printf \"%s/#/cluster/monitor/%s/indices/%s\" $.env.INFINI_CONSOLE_ENDPOINT (index .group_values 0) (index .group_values 1)}}\n{{$cu := printf \"%s/#/cluster/monitor/elasticsearch/%s\" $.env.INFINI_CONSOLE_ENDPOINT (index .group_values 0)}}\nIndex: [{{index .group_values 1}}]({{$iu}}?_g=%7B%22timeRange%22:%7B%22min%22:%22{{$.min}}%22%2C%22max%22:%22{{$.max}}%22%7D%2C%22cluster_name%22:%22{{$cn | urlquery}}%22%7D) of Cluster: [{{$cn}}]({{$cu}}?_g=%7B%22timeRange%22:%7B%22min%22:%22{{$.min}}%22%2C%22max%22:%22{{$.max}}%22%7D%7D), Latency: {{.result_value | to_fixed 2}}ms\n{{end}}", + "normal": [ + { + "id": "cgnb2nt3q95nmusjl65g", + "created": "2023-04-06T11:47:43.104108279Z", + "updated": "2023-08-06T15:46:34.404507399+08:00", + "name": "Slack Notification", + "type": "webhook", + "webhook": { + "header_params": { + "Content-type": "application/json" + }, + "method": "POST", + "body": "\n{\n \"blocks\": [\n {\n \"type\": \"section\",\n \"text\": {\n \"type\": \"mrkdwn\",\n \"text\": \"*{{if eq .priority \"critical\"}} :fire: {{else if eq .priority \"error\"}} :rotating_light: {{else}} :warning: {{end}} Incident <{{$.env.INFINI_CONSOLE_ENDPOINT}}/#/alerting/message/{{.event_id}}|#{{.event_id}}> is ongoing*\\n :point_right: *{{.rule_name}} - {{.title}}*\"\n }\n },\n {\n \"type\": \"section\",\n \"text\": {\n \"type\": \"mrkdwn\",\n \"text\": \"*TriggerAt:* {{.timestamp | datetime}}\"\n }\n },\n {\n \"type\": \"section\",\n \"text\": {\n \"type\": \"mrkdwn\",\n \"text\": \"*Priority:* {{.priority}}\"\n }\n },\n {\n \"type\": \"divider\"\n }\n ]\n {{if gt (len .results) 0}}\n ,\"attachments\": [\n {{range .results}}\n {\n \"color\": {{if eq .priority \"critical\"}} \"#C91010\" {{else if eq .priority \"error\"}} \"#EB4C21\" {{else}} \"#FFB449\" {{end}},\n \"blocks\": [\n {\n \"type\": \"section\",\n \"text\": {\n \"type\": \"mrkdwn\",\n \"text\": \"Index: <{{$.env.INFINI_CONSOLE_ENDPOINT}}#/cluster/monitor/{{ index .group_values 0}}/indices/{{ index .group_values 1}}?_g={%22timeRange%22:%7B%22min%22:%22{{$.min}}%22%2C%22max%22:%22{{$.max}}%22%7D%2C%22cluster_name%22:%22{{ lookup \"category=metadata, object=cluster, property=name, default=N/A\" (index .group_values 0)}}%22} | {{index .group_values 1}}> of Cluster: <{{$.env.INFINI_CONSOLE_ENDPOINT}}/#/cluster/monitor/elasticsearch/{{index .group_values 0}}?_g={%22timeRange%22:%7B%22min%22:%22{{$.min}}%22%2C%22max%22:%22{{$.max}}%22%7D%2C%22cluster_name%22:%22{{lookup \"category=metadata, object=cluster, property=name, default=N/A\" (index .group_values 0) }}%22} | {{lookup \"category=metadata, object=cluster, property=name, default=N/A\" (index .group_values 0) }}>, Search latency: {{.result_value | to_fixed 2}}ms\"\n }\n }\n ]\n },\n {{end}}\n {\n \"blocks\": [\n {\n \"type\": \"divider\"\n },\n {\n \"type\": \"actions\",\n \"elements\": [\n {\n \"type\": \"button\",\n \"text\": {\n \"type\": \"plain_text\",\n \"text\": \"View Incident\" \n },\n \"url\": \"{{$.env.INFINI_CONSOLE_ENDPOINT}}/#/alerting/message/{{.event_id}}\"\n }\n ]\n },\n ]\n }\n ]\n {{end}}\n}" + }, + "sub_type": "slack", + "enabled": true + }, + { + "id": "cgiospt3q95q49k3u00g", + "enabled": true + }, + { + "id": "cj865st3q95rega919ig", + "enabled": true + }, + { + "id": "cgnb2r53q95nmusjl6vg", + "enabled": true + }, + { + "id": "ch1os6t3q95lk6lepkq0", + "enabled": true + }, + { + "id": "cgnb2kt3q95nmusjl64g", + "enabled": true + } + ], + "throttle_period": "1h", + "accept_time_range": { + "start": "00:00", + "end": "23:59" + } + }, + "category": "Platform", + "recovery_notification_config": { + "enabled": true, + "title": "🌈 {{.rule_name}} Resolved", + "message": "EventID: {{.event_id}} \nTarget: {{.resource_name}}-{{.objects}} \nTriggerAt: {{.trigger_at | datetime}} \nResolveAt: {{.timestamp | datetime}} \nDuration: {{.duration}}{{if .recovery_context}} \n{{.recovery_context}}{{end}}", + "normal": [ + { + "id": "cj8bq8d3q95ogankugqg", + "enabled": true + }, + { + "id": "cj8ctat3q95l9ebbntlg", + "enabled": true + }, + { + "id": "cj8atf53q95lhahebg8g", + "enabled": true + }, + { + "id": "cj8e9s53q95gsdbb054g", + "enabled": true + }, + { + "id": "cj8e9gt3q95gsdbb0170", + "enabled": true + }, + { + "id": "cj86l0l3q95rrpfea6ug", + "enabled": true + } + ], + "event_enabled": true + }, + "schedule": { + "interval": "1m" + }, + "creator": { + "name": "$[[SETUP_USERNAME]]", + "id": "$[[SETUP_USER_ID]]" + } +} +POST $[[SETUP_INDEX_PREFIX]]alert-rule/$[[SETUP_DOC_TYPE]]/builtin-calaqnh7h710dpnp2bm8 +{ + "id": "builtin-calaqnh7h710dpnp2bm8", + "created": "2022-06-16T04:11:10.242061032Z", + "updated": "2023-08-09T22:38:55.677122718+08:00", + "name": "JVM utilization is Too High", + "enabled": true, + "resource": { + "resource_id": "$[[SETUP_RESOURCE_ID]]", + "resource_name": "$[[SETUP_RESOURCE_NAME]]", + "type": "elasticsearch", + "objects": [ + ".infini_metrics*" + ], + "filter": {}, + "raw_filter": { + "bool": { + "must": [ + { + "term": { + "metadata.name": { + "value": "node_stats" + } + } + }, + { + "term": { + "metadata.category": { + "value": "elasticsearch" + } + } + } + ] + } + }, + "time_field": "timestamp", + "context": { + "fields": null + } + }, + "metrics": { + "bucket_size": "1m", + "groups": [ + { + "field": "metadata.labels.cluster_id", + "limit": 5 + }, + { + "field": "metadata.labels.node_id", + "limit": 300 + } + ], + "formula": "a", + "items": [ + { + "name": "a", + "field": "payload.elasticsearch.node_stats.jvm.mem.heap_used_percent", + "statistic": "p90" + } + ], + "format_type": "ratio", + "bucket_label": { + "enabled": false + }, + "expression": "p90(payload.elasticsearch.node_stats.jvm.mem.heap_used_percent)" + }, + "conditions": { + "operator": "any", + "items": [ + { + "minimum_period_match": 1, + "operator": "gte", + "values": [ + "80" + ], + "priority": "low" + }, + { + "minimum_period_match": 1, + "operator": "gte", + "values": [ + "90" + ], + "priority": "medium" + }, + { + "minimum_period_match": 1, + "operator": "gte", + "values": [ + "95" + ], + "priority": "high" + } + ] + }, + "notification_config": { + "enabled": true, + "title": "🔥 JVM Usage of Nodes ({{len .results}} nodes in total) >= {{.first_threshold}}%", + "message": "{{range .results}}\n{{$cn := lookup \"category=metadata, object=cluster, property=name, default=N/A\" (index .group_values 0) }}\n{{$nn := lookup \"category=metadata, object=node, property=metadata.node_name, default=N/A\" (index .group_values 1) }}\n{{$nu := printf \"%s/#/cluster/monitor/%s/nodes/%s\" $.env.INFINI_CONSOLE_ENDPOINT (index .group_values 0) (index .group_values 1)}}\n{{$cu := printf \"%s/#/cluster/monitor/elasticsearch/%s\" $.env.INFINI_CONSOLE_ENDPOINT (index .group_values 0)}}\nNode: [{{$nn}}]({{$nu}}?_g=%7B%22timeRange%22:%7B%22min%22:%22{{$.min}}%22%2C%22max%22:%22{{$.max}}%22%7D%2C%22cluster_name%22:%22{{$cn | urlquery}}%22%2C%22node_name%22:%22{{$nn}}%22%7D) of Cluster: [{{$cn}}]({{$cu}}?_g=%7B%22timeRange%22:%7B%22min%22:%22{{$.min}}%22%2C%22max%22:%22{{$.max}}%22%7D%7D), JVM Usage: {{.result_value | to_fixed 2}}%\n{{end}}", + "normal": [ + { + "id": "cgnb2nt3q95nmusjl65g", + "created": "2023-04-06T11:47:43.104108279Z", + "updated": "2023-08-06T15:46:34.404507399+08:00", + "name": "Slack Notification", + "type": "webhook", + "webhook": { + "header_params": { + "Content-type": "application/json" + }, + "method": "POST", + "body": "{\n \"blocks\": [\n {\n \"type\": \"section\",\n \"text\": {\n \"type\": \"mrkdwn\",\n \"text\": \"*{{if eq .priority \"critical\"}} :fire: {{else if eq .priority \"error\"}} :rotating_light: {{else}} :warning: {{end}} Incident <{{$.env.INFINI_CONSOLE_ENDPOINT}}/#/alerting/message/{{.event_id}}|#{{.event_id}}> is ongoing*\\n :point_right: *{{.rule_name}} - {{.title}}*\"\n }\n },\n {\n \"type\": \"section\",\n \"text\": {\n \"type\": \"mrkdwn\",\n \"text\": \"*TriggerAt:* {{.timestamp | datetime}}\"\n }\n },\n {\n \"type\": \"section\",\n \"text\": {\n \"type\": \"mrkdwn\",\n \"text\": \"*Priority:* {{.priority}}\"\n }\n },\n {\n \"type\": \"divider\"\n }\n ]\n {{if gt (len .results) 0}}\n ,\"attachments\": [\n {{range .results}}\n {\n \"color\": {{if eq .priority \"critical\"}} \"#C91010\" {{else if eq .priority \"error\"}} \"#EB4C21\" {{else}} \"#FFB449\" {{end}},\n \"blocks\": [\n {\n \"type\": \"section\",\n \"text\": {\n \"type\": \"mrkdwn\",\n \"text\": \"Node: <{{$.env.INFINI_CONSOLE_ENDPOINT}}/#/cluster/monitor/{{index .group_values 0}}/nodes/{{index .group_values 1}}?_g={%22timeRange%22:%7B%22min%22:%22{{$.min}}%22%2C%22max%22:%22{{$.max}}%22%7D%2C%22cluster_name%22:%22{{lookup \"category=metadata, object=cluster, property=name, default=N/A\" (index .group_values 0) }}%22%2C%22node_name%22:%22{{lookup \"category=metadata, object=node, property=metadata.node_name, default=N/A\" (index .group_values 1) }}%22} | {{lookup \"category=metadata, object=node, property=metadata.node_name, default=N/A\" (index .group_values 1) }}> of Cluster: <{{$.env.INFINI_CONSOLE_ENDPOINT}}/#/cluster/monitor/elasticsearch/{{index .group_values 0}}?_g={%22timeRange%22:%7B%22min%22:%22{{$.min}}%22%2C%22max%22:%22{{$.max}}%22%7D%2C%22cluster_name%22:%22{{lookup \"category=metadata, object=cluster, property=name, default=N/A\" (index .group_values 0) }}%22} | {{lookup \"category=metadata, object=cluster, property=name, default=N/A\" (index .group_values 0) }}>, JVM Usage: {{.result_value | to_fixed 2}}%\"\n }\n }\n ]\n },\n {{end}}\n {\n \"blocks\": [\n {\n \"type\": \"divider\"\n },\n {\n \"type\": \"actions\",\n \"elements\": [\n {\n \"type\": \"button\",\n \"text\": {\n \"type\": \"plain_text\",\n \"text\": \"View Incident\" \n },\n \"url\": \"{{$.env.INFINI_CONSOLE_ENDPOINT}}/#/alerting/message/{{.event_id}}\"\n }\n ]\n },\n ]\n }\n ]\n {{end}}\n}" + }, + "sub_type": "slack", + "enabled": true + }, + { + "id": "cgiospt3q95q49k3u00g", + "enabled": true + }, + { + "id": "cj865st3q95rega919ig", + "enabled": true + }, + { + "id": "cgnb2r53q95nmusjl6vg", + "enabled": true + }, + { + "id": "ch1os6t3q95lk6lepkq0", + "enabled": true + }, + { + "id": "cgnb2kt3q95nmusjl64g", + "enabled": true + } + ], + "throttle_period": "1h", + "accept_time_range": { + "start": "00:00", + "end": "23:59" + } + }, + "category": "Platform", + "recovery_notification_config": { + "enabled": true, + "title": "🌈 {{.rule_name}} Resolved", + "message": "EventID: {{.event_id}} \nTarget: {{.resource_name}}-{{.objects}} \nTriggerAt: {{.trigger_at | datetime}} \nResolveAt: {{.timestamp | datetime}} \nDuration: {{.duration}}{{if .recovery_context}} \n{{.recovery_context}}{{end}}", + "normal": [ + { + "id": "cj8bq8d3q95ogankugqg", + "enabled": true + }, + { + "id": "cj8ctat3q95l9ebbntlg", + "enabled": true + }, + { + "id": "cj8atf53q95lhahebg8g", + "enabled": true + }, + { + "id": "cj8e9s53q95gsdbb054g", + "enabled": true + }, + { + "id": "cj8e9gt3q95gsdbb0170", + "enabled": true + }, + { + "id": "cj86l0l3q95rrpfea6ug", + "enabled": true + } + ], + "event_enabled": true + }, + "schedule": { + "interval": "1m" + }, + "creator": { + "name": "$[[SETUP_USERNAME]]", + "id": "$[[SETUP_USER_ID]]" + } +} +POST $[[SETUP_INDEX_PREFIX]]alert-rule/$[[SETUP_DOC_TYPE]]/builtin-calakp97h710dpnp1fa2 +{ + "id": "builtin-calakp97h710dpnp1fa2", + "created": "2022-06-16T03:58:29.437447113Z", + "updated": "2023-08-09T22:33:25.692835454+08:00", + "name": "CPU utilization is Too High", + "enabled": true, + "resource": { + "resource_id": "$[[SETUP_RESOURCE_ID]]", + "resource_name": "$[[SETUP_RESOURCE_NAME]]", + "type": "elasticsearch", + "objects": [ + ".infini_metrics*" + ], + "filter": {}, + "raw_filter": { + "bool": { + "must": [ + { + "term": { + "metadata.name": { + "value": "node_stats" + } + } + }, + { + "term": { + "metadata.category": { + "value": "elasticsearch" + } + } + } + ] + } + }, + "time_field": "timestamp", + "context": { + "fields": null + } + }, + "metrics": { + "bucket_size": "1m", + "groups": [ + { + "field": "metadata.labels.cluster_id", + "limit": 5 + }, + { + "field": "metadata.labels.node_id", + "limit": 300 + } + ], + "formula": "a", + "items": [ + { + "name": "a", + "field": "payload.elasticsearch.node_stats.process.cpu.percent", + "statistic": "avg" + } + ], + "format_type": "ratio", + "bucket_label": { + "enabled": false + }, + "expression": "avg(payload.elasticsearch.node_stats.process.cpu.percent)" + }, + "conditions": { + "operator": "any", + "items": [ + { + "minimum_period_match": 1, + "operator": "gte", + "values": [ + "80" + ], + "priority": "low" + }, + { + "minimum_period_match": 1, + "operator": "gte", + "values": [ + "90" + ], + "priority": "medium" + }, + { + "minimum_period_match": 1, + "operator": "gte", + "values": [ + "95" + ], + "priority": "high" + } + ] + }, + "notification_config": { + "enabled": true, + "title": "🔥 CPU Usage of Nodes ({{len .results}} nodes in total) >= {{.first_threshold}}%", + "message": "{{range .results}}\n{{$cn := lookup \"category=metadata, object=cluster, property=name, default=N/A\" (index .group_values 0) }}\n{{$nn := lookup \"category=metadata, object=node, property=metadata.node_name, default=N/A\" (index .group_values 1) }}\n{{$nu := printf \"%s/#/cluster/monitor/%s/nodes/%s\" $.env.INFINI_CONSOLE_ENDPOINT (index .group_values 0) (index .group_values 1)}}\n{{$cu := printf \"%s/#/cluster/monitor/elasticsearch/%s\" $.env.INFINI_CONSOLE_ENDPOINT (index .group_values 0)}}\nNode: [{{$nn}}]({{$nu}}?_g=%7B%22timeRange%22:%7B%22min%22:%22{{$.min}}%22%2C%22max%22:%22{{$.max}}%22%7D%2C%22cluster_name%22:%22{{$cn | urlquery}}%22%2C%22node_name%22:%22{{$nn}}%22%7D) of Cluster: [{{$cn}}]({{$cu}}?_g=%7B%22timeRange%22:%7B%22min%22:%22{{$.min}}%22%2C%22max%22:%22{{$.max}}%22%7D%7D), CPU Usage: {{.result_value | to_fixed 2}}%\n{{end}}", + "normal": [ + { + "id": "cgnb2nt3q95nmusjl65g", + "created": "2023-04-06T11:47:43.104108279Z", + "updated": "2023-08-07T15:17:26.18861218+08:00", + "name": "[Alerting] Slack Notification", + "type": "webhook", + "webhook": { + "header_params": { + "Content-type": "application/json" + }, + "method": "POST", + "body": "{\n \"blocks\": [\n {\n \"type\": \"section\",\n \"text\": {\n \"type\": \"mrkdwn\",\n \"text\": \"*{{if eq .priority \"critical\"}} :fire: {{else if eq .priority \"error\"}} :rotating_light: {{else}} :warning: {{end}} Incident <{{$.env.INFINI_CONSOLE_ENDPOINT}}/#/alerting/message/{{.event_id}}|#{{.event_id}}> is ongoing*\\n :point_right: *{{.rule_name}} - {{.title}}*\"\n }\n },\n {\n \"type\": \"section\",\n \"text\": {\n \"type\": \"mrkdwn\",\n \"text\": \"*TriggerAt:* {{.timestamp | datetime}}\"\n }\n },\n {\n \"type\": \"section\",\n \"text\": {\n \"type\": \"mrkdwn\",\n \"text\": \"*Priority:* {{.priority}}\"\n }\n },\n {\n \"type\": \"divider\"\n }\n ]\n {{if gt (len .results) 0}}\n ,\"attachments\": [\n {{range .results}}\n {\n \"color\": {{if eq .priority \"critical\"}} \"#C91010\" {{else if eq .priority \"error\"}} \"#EB4C21\" {{else}} \"#FFB449\" {{end}},\n \"blocks\": [\n {\n \"type\": \"section\",\n \"text\": {\n \"type\": \"mrkdwn\",\n \"text\": \"Node: <{{$.env.INFINI_CONSOLE_ENDPOINT}}/#/cluster/monitor/{{index .group_values 0}}/nodes/{{index .group_values 1}}?_g={%22timeRange%22:%7B%22min%22:%22{{$.min}}%22%2C%22max%22:%22{{$.max}}%22%7D%2C%22cluster_name%22:%22{{lookup \"category=metadata, object=cluster, property=name, default=N/A\" (index .group_values 0) }}%22%2C%22node_name%22:%22{{lookup \"category=metadata, object=node, property=metadata.node_name, default=N/A\" (index .group_values 1) }}%22} | {{lookup \"category=metadata, object=node, property=metadata.node_name, default=N/A\" (index .group_values 1) }}> of Cluster: <{{$.env.INFINI_CONSOLE_ENDPOINT}}/#/cluster/monitor/elasticsearch/{{index .group_values 0}}?_g={%22timeRange%22:%7B%22min%22:%22{{$.min}}%22%2C%22max%22:%22{{$.max}}%22%7D%2C%22cluster_name%22:%22{{lookup \"category=metadata, object=cluster, property=name, default=N/A\" (index .group_values 0) }}%22} | {{lookup \"category=metadata, object=cluster, property=name, default=N/A\" (index .group_values 0) }}>, CPU Usage: {{.result_value | to_fixed 2}}%\"\n }\n }\n ]\n },\n {{end}}\n {\n \"blocks\": [\n {\n \"type\": \"divider\"\n },\n {\n \"type\": \"actions\",\n \"elements\": [\n {\n \"type\": \"button\",\n \"text\": {\n \"type\": \"plain_text\",\n \"text\": \"View Incident\" \n },\n \"url\": \"{{$.env.INFINI_CONSOLE_ENDPOINT}}/#/alerting/message/{{.event_id}}\"\n }\n ]\n },\n ]\n }\n ]\n {{end}}\n}" + }, + "sub_type": "slack", + "enabled": true + }, + { + "id": "cgiospt3q95q49k3u00g", + "enabled": true + }, + { + "id": "cj865st3q95rega919ig", + "enabled": true + }, + { + "id": "cgnb2r53q95nmusjl6vg", + "enabled": true + }, + { + "id": "ch1os6t3q95lk6lepkq0", + "enabled": true + }, + { + "id": "cgnb2kt3q95nmusjl64g", + "enabled": true + } + ], + "throttle_period": "6h", + "accept_time_range": { + "start": "00:00", + "end": "23:59" + } + }, + "category": "Platform", + "recovery_notification_config": { + "enabled": true, + "title": "🌈 {{.rule_name}} Resolved", + "message": "EventID: {{.event_id}} \nTarget: {{.resource_name}}-{{.objects}} \nTriggerAt: {{.trigger_at | datetime}} \nResolveAt: {{.timestamp | datetime}} \nDuration: {{.duration}}{{if .recovery_context}} \n{{.recovery_context}}{{end}}", + "normal": [ + { + "id": "cj8bq8d3q95ogankugqg", + "enabled": true + }, + { + "id": "cj8ctat3q95l9ebbntlg", + "enabled": true + }, + { + "id": "cj8atf53q95lhahebg8g", + "enabled": true + }, + { + "id": "cj8e9s53q95gsdbb054g", + "enabled": true + }, + { + "id": "cj8e9gt3q95gsdbb0170", + "enabled": true + }, + { + "id": "cj86l0l3q95rrpfea6ug", + "enabled": true + } + ], + "event_enabled": true + }, + "schedule": { + "interval": "1m" + }, + "creator": { + "name": "$[[SETUP_USERNAME]]", + "id": "$[[SETUP_USER_ID]]" + } +} +POST $[[SETUP_INDEX_PREFIX]]alert-rule/$[[SETUP_DOC_TYPE]]/builtin-calgapp7h710dpnpbeb6 +{ + "id": "builtin-calgapp7h710dpnpbeb6", + "created": "2022-06-16T10:26:47.360988761Z", + "updated": "2023-08-09T22:37:44.038127695+08:00", + "name": "Shard Storage >= 55G", + "enabled": true, + "resource": { + "resource_id": "$[[SETUP_RESOURCE_ID]]", + "resource_name": "$[[SETUP_RESOURCE_NAME]]", + "type": "elasticsearch", + "objects": [ + ".infini_metrics*" + ], + "filter": {}, + "raw_filter": { + "range": { + "payload.elasticsearch.index_stats.shard_info.store_in_bytes": { + "gte": 59055800320 + } + } + }, + "time_field": "timestamp", + "context": { + "fields": null + } + }, + "metrics": { + "bucket_size": "1m", + "groups": [ + { + "field": "metadata.labels.cluster_id", + "limit": 5 + }, + { + "field": "metadata.labels.index_name", + "limit": 500 + } + ], + "formula": "a", + "items": [ + { + "name": "a", + "field": "payload.elasticsearch.index_stats.shard_info.store_in_bytes", + "statistic": "max" + } + ], + "format_type": "bytes", + "bucket_label": { + "enabled": false + }, + "expression": "max(payload.elasticsearch.index_stats.shard_info.store_in_bytes)" + }, + "conditions": { + "operator": "any", + "items": [ + { + "minimum_period_match": 1, + "operator": "gte", + "values": [ + "59055800320" + ], + "priority": "high" + } + ] + }, + "notification_config": { + "enabled": true, + "title": "🔥 Shard Storage >55GB in ({{len .results}} indices in total)", + "message": "{{range .results}}\n{{$cn := lookup \"category=metadata, object=cluster, property=name, default=N/A\" (index .group_values 0) }}\n{{$iu := printf \"%s/#/cluster/monitor/%s/indices/%s\" $.env.INFINI_CONSOLE_ENDPOINT (index .group_values 0) (index .group_values 1)}}\n{{$cu := printf \"%s/#/cluster/monitor/elasticsearch/%s\" $.env.INFINI_CONSOLE_ENDPOINT (index .group_values 0)}}\nIndex: [{{index .group_values 1}}]({{$iu}}?_g=%7B%22timeRange%22:%7B%22min%22:%22{{$.min}}%22%2C%22max%22:%22{{$.max}}%22%7D%2C%22cluster_name%22:%22{{$cn | urlquery}}%22%7D) of Cluster: [{{$cn}}]({{$cu}}?_g=%7B%22timeRange%22:%7B%22min%22:%22{{$.min}}%22%2C%22max%22:%22{{$.max}}%22%7D%7D), Max Shard Storage: {{.result_value | format_bytes 2}}\n{{end}}", + "normal": [ + { + "id": "cgnb2nt3q95nmusjl65g", + "created": "2023-04-06T11:47:43.104108279Z", + "updated": "2023-08-07T14:02:53.734855705+08:00", + "name": "[Alerting] Slack Notification", + "type": "webhook", + "webhook": { + "header_params": { + "Content-type": "application/json" + }, + "method": "POST", + "body": "{\n \"blocks\": [\n {\n \"type\": \"section\",\n \"text\": {\n \"type\": \"mrkdwn\",\n \"text\": \"*{{if eq .priority \"critical\"}} :fire: {{else if eq .priority \"error\"}} :rotating_light: {{else}} :warning: {{end}} Incident <{{$.env.INFINI_CONSOLE_ENDPOINT}}/#/alerting/message/{{.event_id}}|#{{.event_id}}> is ongoing*\\n :point_right: *{{.rule_name}} - {{.title}}*\"\n }\n },\n {\n \"type\": \"section\",\n \"text\": {\n \"type\": \"mrkdwn\",\n \"text\": \"*TriggerAt:* {{.timestamp | datetime}}\"\n }\n },\n {\n \"type\": \"section\",\n \"text\": {\n \"type\": \"mrkdwn\",\n \"text\": \"*Priority:* {{.priority}}\"\n }\n },\n {\n \"type\": \"divider\"\n }\n ]\n {{if gt (len .results) 0}}\n ,\"attachments\": [\n {{range .results}}\n {\n \"color\": {{if eq .priority \"critical\"}} \"#C91010\" {{else if eq .priority \"error\"}} \"#EB4C21\" {{else}} \"#FFB449\" {{end}},\n \"blocks\": [\n {\n \"type\": \"section\",\n \"text\": {\n \"type\": \"mrkdwn\",\n \"text\": \"Index: <{{$.env.INFINI_CONSOLE_ENDPOINT}}#/cluster/monitor/{{ index .group_values 0}}/indices/{{ index .group_values 1}}?_g={%22timeRange%22:%7B%22min%22:%22{{$.min}}%22%2C%22max%22:%22{{$.max}}%22%7D%2C%22cluster_name%22:%22{{ lookup \"category=metadata, object=cluster, property=name, default=N/A\" (index .group_values 0)}}%22} | {{index .group_values 1}}> of Cluster: <{{$.env.INFINI_CONSOLE_ENDPOINT}}/#/cluster/monitor/elasticsearch/{{index .group_values 0}}?_g={%22timeRange%22:%7B%22min%22:%22{{$.min}}%22%2C%22max%22:%22{{$.max}}%22%7D%2C%22cluster_name%22:%22{{lookup \"category=metadata, object=cluster, property=name, default=N/A\" (index .group_values 0) }}%22} | {{lookup \"category=metadata, object=cluster, property=name, default=N/A\" (index .group_values 0) }}>, Max shard storage: {{.result_value | format_bytes 2}}\"\n }\n }\n ]\n },\n {{end}}\n {\n \"blocks\": [\n {\n \"type\": \"divider\"\n },\n {\n \"type\": \"actions\",\n \"elements\": [\n {\n \"type\": \"button\",\n \"text\": {\n \"type\": \"plain_text\",\n \"text\": \"View Incident\" \n },\n \"url\": \"{{$.env.INFINI_CONSOLE_ENDPOINT}}/#/alerting/message/{{.event_id}}\"\n }\n ]\n },\n ]\n }\n ]\n {{end}}\n}" + }, + "sub_type": "slack", + "enabled": true + }, + { + "id": "cj865st3q95rega919ig", + "enabled": true + }, + { + "id": "cgiospt3q95q49k3u00g", + "enabled": true + }, + { + "id": "cgnb2r53q95nmusjl6vg", + "enabled": true + }, + { + "id": "ch1os6t3q95lk6lepkq0", + "enabled": true + }, + { + "id": "cgnb2kt3q95nmusjl64g", + "enabled": true + } + ], + "throttle_period": "1h", + "accept_time_range": { + "start": "00:00", + "end": "23:59" + } + }, + "category": "Platform", + "recovery_notification_config": { + "enabled": true, + "title": "🌈 {{.rule_name}} Resolved", + "message": "- EventID: {{.event_id}}\n- Target: {{.resource_name}}-{{.objects}}\n- TriggerAt: {{.trigger_at | datetime}}\n- ResolveAt: {{.timestamp | datetime}}\n- Duration: {{.duration}}{{if .recovery_context}}\n{{.recovery_context}}{{end}}", + "normal": [ + { + "id": "cj8bq8d3q95ogankugqg", + "enabled": true + }, + { + "id": "cj8ctat3q95l9ebbntlg", + "enabled": true + }, + { + "id": "cj8atf53q95lhahebg8g", + "enabled": true + }, + { + "id": "cj8e9s53q95gsdbb054g", + "enabled": true + }, + { + "id": "cj8e9gt3q95gsdbb0170", + "enabled": true + }, + { + "id": "cj86l0l3q95rrpfea6ug", + "enabled": true + } + ], + "event_enabled": true + }, + "schedule": { + "interval": "1m" + }, + "creator": { + "name": "$[[SETUP_USERNAME]]", + "id": "$[[SETUP_USER_ID]]" + } +} +POST $[[SETUP_INDEX_PREFIX]]alert-rule/$[[SETUP_DOC_TYPE]]/builtin-cal8n7p7h710dpnogps1 +{ + "id": "builtin-cal8n7p7h710dpnogps1", + "created": "2022-06-16T03:11:01.445958361Z", + "updated": "2023-08-10T17:16:34.900352415+08:00", + "name": "Disk utilization is Too High", + "enabled": true, + "resource": { + "resource_id": "$[[SETUP_RESOURCE_ID]]", + "resource_name": "$[[SETUP_RESOURCE_NAME]]", + "type": "elasticsearch", + "objects": [ + ".infini_metrics*" + ], + "filter": {}, + "raw_filter": { + "bool": { + "must": [ + { + "term": { + "metadata.name": { + "value": "node_stats" + } + } + }, + { + "term": { + "metadata.category": { + "value": "elasticsearch" + } + } + } + ] + } + }, + "time_field": "timestamp", + "context": { + "fields": null + } + }, + "metrics": { + "bucket_size": "1m", + "groups": [ + { + "field": "metadata.labels.cluster_id", + "limit": 5 + }, + { + "field": "metadata.labels.node_id", + "limit": 200 + } + ], + "formula": "((a-b)/a)*100", + "items": [ + { + "name": "a", + "field": "payload.elasticsearch.node_stats.fs.data.total_in_bytes", + "statistic": "max" + }, + { + "name": "b", + "field": "payload.elasticsearch.node_stats.fs.data.free_in_bytes", + "statistic": "max" + } + ], + "format_type": "ratio", + "bucket_label": { + "enabled": false + }, + "expression": "((max(payload.elasticsearch.node_stats.fs.data.total_in_bytes)-max(payload.elasticsearch.node_stats.fs.data.free_in_bytes))/max(payload.elasticsearch.node_stats.fs.data.total_in_bytes))*100" + }, + "conditions": { + "operator": "any", + "items": [ + { + "minimum_period_match": 5, + "operator": "gte", + "values": [ + "80" + ], + "priority": "low" + }, + { + "minimum_period_match": 5, + "operator": "gte", + "values": [ + "90" + ], + "priority": "medium" + }, + { + "minimum_period_match": 5, + "operator": "gte", + "values": [ + "95" + ], + "priority": "high" + } + ] + }, + "notification_config": { + "enabled": true, + "title": "🔥 Disk Usage of Nodes ({{len .results}} nodes in total) >= {{.first_threshold}}%", + "message": "{{range .results}}\n{{$cn := lookup \"category=metadata, object=cluster, property=name, default=N/A\" (index .group_values 0) }}\n{{$nn := lookup \"category=metadata, object=node, property=metadata.node_name, default=N/A\" (index .group_values 1) }}\n{{$nu := printf \"%s/#/cluster/monitor/%s/nodes/%s\" $.env.INFINI_CONSOLE_ENDPOINT (index .group_values 0) (index .group_values 1)}}\n{{$cu := printf \"%s/#/cluster/monitor/elasticsearch/%s\" $.env.INFINI_CONSOLE_ENDPOINT (index .group_values 0)}}\nNode: [{{$nn}}]({{$nu}}?_g=%7B%22timeRange%22:%7B%22min%22:%22{{$.min}}%22%2C%22max%22:%22{{$.max}}%22%7D%2C%22cluster_name%22:%22{{$cn | urlquery}}%22%2C%22node_name%22:%22{{$nn}}%22%7D) of Cluster: [{{$cn}}]({{$cu}}?_g=%7B%22timeRange%22:%7B%22min%22:%22{{$.min}}%22%2C%22max%22:%22{{$.max}}%22%7D%7D), Usage: {{.result_value | to_fixed 2}}% / Free: {{.relation_values.b | format_bytes 2}}\n{{end}}", + "normal": [ + { + "id": "cgnb2nt3q95nmusjl65g", + "name": "", + "type": "webhook", + "webhook": { + "header_params": { + "Content-type": "application/json" + }, + "method": "POST", + "body": "{\n \"blocks\": [\n {\n \"type\": \"section\",\n \"text\": {\n \"type\": \"mrkdwn\",\n \"text\": \"*{{if eq .priority \"critical\"}} :fire: {{else if eq .priority \"error\"}} :rotating_light: {{else}} :warning: {{end}} Incident <{{$.env.INFINI_CONSOLE_ENDPOINT}}/#/alerting/message/{{.event_id}}|#{{.event_id}}> is ongoing*\\n :point_right: *{{.rule_name}} - {{.title}}*\"\n }\n },\n {\n \"type\": \"section\",\n \"text\": {\n \"type\": \"mrkdwn\",\n \"text\": \"*TriggerAt:* {{.timestamp | datetime}}\"\n }\n },\n {\n \"type\": \"section\",\n \"text\": {\n \"type\": \"mrkdwn\",\n \"text\": \"*Priority:* {{.priority}}\"\n }\n },\n {\n \"type\": \"divider\"\n }\n ]\n {{if gt (len .results) 0}}\n ,\"attachments\": [\n {{range .results}}\n {\n \"color\": {{if eq .priority \"critical\"}} \"#C91010\" {{else if eq .priority \"error\"}} \"#EB4C21\" {{else}} \"#FFB449\" {{end}},\n \"blocks\": [\n {\n \"type\": \"section\",\n \"text\": {\n \"type\": \"mrkdwn\",\n \"text\": \"Node: <{{$.env.INFINI_CONSOLE_ENDPOINT}}/#/cluster/overview/{{index .group_values 0}}/nodes/{{index .group_values 1}}?_g={%22cluster_name%22:%22{{lookup \"category=metadata, object=cluster, property=name, default=N/A\" (index .group_values 0) }}%22%2C%22node_name%22:%22{{lookup \"category=metadata, object=node, property=metadata.node_name, default=N/A\" (index .group_values 1) }}%22} | {{lookup \"category=metadata, object=node, property=metadata.node_name, default=N/A\" (index .group_values 1) }}> of Cluster: <{{$.env.INFINI_CONSOLE_ENDPOINT}}/#/cluster/monitor/elasticsearch/{{index .group_values 0}} | {{lookup \"category=metadata, object=cluster, property=name, default=N/A\" (index .group_values 0) }}>, Disk Usage: {{.result_value | to_fixed 2}}%, Free: {{.relation_values.b | format_bytes 2}}\"\n }\n }\n ]\n },\n {{end}}\n {\n \"blocks\": [\n {\n \"type\": \"divider\"\n },\n {\n \"type\": \"actions\",\n \"elements\": [\n {\n \"type\": \"button\",\n \"text\": {\n \"type\": \"plain_text\",\n \"text\": \"View Incident\" \n },\n \"url\": \"{{$.env.INFINI_CONSOLE_ENDPOINT}}/#/alerting/message/{{.event_id}}\"\n },\n {\n \"type\": \"button\",\n \"text\": {\n \"type\": \"plain_text\",\n \"text\": \"View Document\" \n },\n \"style\": \"primary\",\n \"url\": \"https://www.elastic.co/guide/en/elasticsearch/reference/current/modules-cluster.html#disk-based-shard-allocation\"\n }\n ]\n },\n ]\n }\n ]\n {{end}}\n}" + }, + "sub_type": "", + "enabled": true + }, + { + "id": "cgiospt3q95q49k3u00g", + "enabled": true + }, + { + "id": "cj865st3q95rega919ig", + "enabled": true + }, + { + "id": "cgnb2r53q95nmusjl6vg", + "enabled": true + }, + { + "id": "ch1os6t3q95lk6lepkq0", + "enabled": true + }, + { + "id": "cgnb2kt3q95nmusjl64g", + "enabled": true + } + ], + "throttle_period": "6h", + "accept_time_range": { + "start": "00:00", + "end": "23:59" + } + }, + "category": "Platform", + "recovery_notification_config": { + "enabled": true, + "title": "🌈 [{{.rule_name}}] Resolved", + "message": "EventID: {{.event_id}} \nTarget: {{.resource_name}}-{{.objects}} \nTriggerAt: {{.trigger_at | datetime}} \nResolveAt: {{.timestamp | datetime}} \nDuration: {{.duration}}{{if .recovery_context}} \n{{.recovery_context}}{{end}}", + "normal": [ + { + "id": "cj8bq8d3q95ogankugqg", + "enabled": true + }, + { + "id": "cj8ctat3q95l9ebbntlg", + "enabled": true + }, + { + "id": "cj8atf53q95lhahebg8g", + "enabled": true + }, + { + "id": "cj8e9s53q95gsdbb054g", + "enabled": true + }, + { + "id": "cj8e9gt3q95gsdbb0170", + "enabled": true + }, + { + "id": "cj86l0l3q95rrpfea6ug", + "enabled": true + } + ], + "event_enabled": true + }, + "schedule": { + "interval": "1m" + }, + "creator": { + "name": "$[[SETUP_USERNAME]]", + "id": "$[[SETUP_USER_ID]]" + } +} +POST $[[SETUP_INDEX_PREFIX]]alert-rule/$[[SETUP_DOC_TYPE]]/builtin-cujivv5ath26drn6bcl0 +{ + "id": "builtin-cujivv5ath26drn6bcl0", + "created": "2025-02-08T18:20:44.273334+08:00", + "updated": "2025-02-12T16:31:05.672771+08:00", + "name": "Cluster Metrics Collection Anomaly", + "enabled": true, + "resource": { + "resource_id": "$[[SETUP_RESOURCE_ID]]", + "resource_name": "$[[SETUP_RESOURCE_NAME]]", + "type": "elasticsearch", + "objects": [ + ".infini_metrics*" + ], + "filter": {}, + "raw_filter": { + "bool": { + "must": [ + { + "terms": { + "metadata.name": [ + "cluster_health", + "cluster_stats", + "index_stats", + "node_stats", + "shard_stats" + ] + } + } + ] + } + }, + "time_field": "timestamp", + "context": { + "fields": null + } + }, + "metrics": { + "bucket_size": "1m", + "groups": [ + { + "field": "metadata.labels.cluster_id", + "limit": 5 + }, + { + "field": "metadata.name", + "limit": 5 + } + ], + "formula": "a", + "items": [ + { + "name": "a", + "field": "agent.id", + "statistic": "count" + } + ], + "bucket_label": { + "enabled": false + }, + "expression": "count(agent.id)" + }, + "bucket_conditions": { + "operator": "any", + "items": [ + { + "minimum_period_match": 1, + "operator": "lt", + "values": [ + "0" + ], + "priority": "critical", + "type": "content", + "bucket_count": 10 + } + ] + }, + "notification_config": { + "enabled": true, + "title": "🔥 {{.rule_name}} Alerting", + "message": "{{range .results}}\n{{$cn := lookup \"category=metadata, object=cluster, property=name, default=N/A\" (index .group_values 0) }}\n{{$cu := printf \"%s/#/cluster/monitor/elasticsearch/%s\" $.env.INFINI_CONSOLE_ENDPOINT (index .group_values 0)}}\nCluster [[{{$cn}}]({{$cu}}?_g=%7B%22timeRange%22:%7B%22min%22:%22{{$.min}}%22%2C%22max%22:%22{{$.max}}%22%7D%7D)] ({{index .group_values 1}}) metrics has dropped at {{.issue_timestamp | datetime}};\n{{end}}", + "normal": [ + { + "id": "cgnb2nt3q95nmusjl65g", + "enabled": true + }, + { + "id": "cgiospt3q95q49k3u00g", + "enabled": true + }, + { + "id": "cj865st3q95rega919ig", + "enabled": true + }, + { + "id": "cgnb2r53q95nmusjl6vg", + "enabled": true + }, + { + "id": "ch1os6t3q95lk6lepkq0", + "enabled": true + }, + { + "id": "cgnb2kt3q95nmusjl64g", + "enabled": true + } + ], + "throttle_period": "6h", + "accept_time_range": { + "start": "00:00", + "end": "23:59" + } + }, + "category": "Platform", + "recovery_notification_config": { + "enabled": true, + "title": "🌈 {{.rule_name}} Resolved", + "message": "EventID: {{.event_id}} \nTarget: {{.resource_name}}-{{.objects}} \nTriggerAt: {{.trigger_at | datetime}} \nResolveAt: {{.timestamp | datetime}} \nDuration: {{.duration}}{{if .recovery_context}} \n{{.recovery_context}}{{end}}", + "normal": [ + { + "id": "cj8bq8d3q95ogankugqg", + "enabled": true + }, + { + "id": "cj8ctat3q95l9ebbntlg", + "enabled": true + }, + { + "id": "cj8atf53q95lhahebg8g", + "enabled": true + }, + { + "id": "cj8e9s53q95gsdbb054g", + "enabled": true + }, + { + "id": "cj8e9gt3q95gsdbb0170", + "enabled": true + }, + { + "id": "cj86l0l3q95rrpfea6ug", + "enabled": true + } + ], + "event_enabled": true + }, + "schedule": { + "interval": "1m" + }, + "creator": { + "name": "$[[SETUP_USERNAME]]", + "id": "$[[SETUP_USER_ID]]" + } +} \ No newline at end of file diff --git a/config/setup/common/alerting.zh-CN.tpl b/config/setup/common/alerting.zh-CN.tpl new file mode 100644 index 00000000..354f8607 --- /dev/null +++ b/config/setup/common/alerting.zh-CN.tpl @@ -0,0 +1,2249 @@ +PUT $[[SETUP_INDEX_PREFIX]]alert-rule +{ + "mappings": { + "properties": { + "bucket_conditions": { + "properties": { + "items": { + "properties": { + "bucket_count": { + "type": "long" + }, + "minimum_period_match": { + "type": "long" + }, + "operator": { + "type": "keyword", + "ignore_above": 256 + }, + "priority": { + "type": "keyword", + "ignore_above": 256 + }, + "type": { + "type": "keyword", + "ignore_above": 256 + }, + "values": { + "type": "keyword", + "ignore_above": 256 + } + } + }, + "operator": { + "type": "keyword", + "ignore_above": 256 + } + } + }, + "category": { + "type": "keyword", + "ignore_above": 256 + }, + "conditions": { + "properties": { + "items": { + "properties": { + "minimum_period_match": { + "type": "long" + }, + "operator": { + "type": "keyword", + "ignore_above": 256 + }, + "priority": { + "type": "keyword", + "ignore_above": 256 + }, + "values": { + "type": "keyword", + "ignore_above": 256 + } + } + }, + "operator": { + "type": "keyword", + "ignore_above": 256 + } + } + }, + "created": { + "type": "date" + }, + "creator": { + "properties": { + "id": { + "type": "keyword", + "ignore_above": 256 + }, + "name": { + "type": "keyword", + "ignore_above": 256 + } + } + }, + "enabled": { + "type": "boolean" + }, + "id": { + "type": "keyword", + "ignore_above": 256 + }, + "metrics": { + "properties": { + "bucket_label": { + "properties": { + "enabled": { + "type": "boolean" + } + } + }, + "bucket_size": { + "type": "keyword", + "ignore_above": 256 + }, + "expression": { + "type": "keyword", + "ignore_above": 256 + }, + "format_type": { + "type": "keyword", + "ignore_above": 256 + }, + "formula": { + "type": "keyword", + "ignore_above": 256 + }, + "groups": { + "properties": { + "field": { + "type": "keyword", + "ignore_above": 256 + }, + "limit": { + "type": "long" + } + } + }, + "items": { + "properties": { + "field": { + "type": "keyword", + "ignore_above": 256 + }, + "name": { + "type": "keyword", + "ignore_above": 256 + }, + "statistic": { + "type": "keyword", + "ignore_above": 256 + } + } + } + } + }, + "name": { + "type": "keyword", + "ignore_above": 256 + }, + "notification_config": { + "properties": { + "accept_time_range": { + "properties": { + "end": { + "type": "keyword", + "ignore_above": 256 + }, + "start": { + "type": "keyword", + "ignore_above": 256 + } + } + }, + "enabled": { + "type": "boolean" + }, + "escalation_throttle_period": { + "type": "keyword", + "ignore_above": 256 + }, + "message": { + "type": "keyword", + "ignore_above": 256 + }, + "normal": { + "properties": { + "created": { + "type": "date" + }, + "enabled": { + "type": "boolean" + }, + "id": { + "type": "keyword", + "ignore_above": 256 + }, + "name": { + "type": "keyword", + "ignore_above": 256 + }, + "sub_type": { + "type": "keyword", + "ignore_above": 256 + }, + "type": { + "type": "keyword", + "ignore_above": 256 + }, + "updated": { + "type": "date" + }, + "webhook": { + "properties": { + "body": { + "type": "keyword", + "ignore_above": 256 + }, + "header_params": { + "properties": { + "Content-Type": { + "type": "keyword", + "ignore_above": 256 + }, + "Content-type": { + "type": "keyword", + "ignore_above": 256 + } + } + }, + "method": { + "type": "keyword", + "ignore_above": 256 + } + } + } + } + }, + "throttle_period": { + "type": "keyword", + "ignore_above": 256 + }, + "title": { + "type": "keyword", + "ignore_above": 256 + } + } + }, + "recovery_notification_config": { + "properties": { + "accept_time_range": { + "properties": { + "end": { + "type": "keyword", + "ignore_above": 256 + }, + "start": { + "type": "keyword", + "ignore_above": 256 + } + } + }, + "enabled": { + "type": "boolean" + }, + "event_enabled": { + "type": "boolean" + }, + "incremental_recovery_enabled": { + "type": "boolean" + }, + "message": { + "type": "keyword", + "ignore_above": 256 + }, + "normal": { + "properties": { + "enabled": { + "type": "boolean" + }, + "id": { + "type": "keyword", + "ignore_above": 256 + } + } + }, + "title": { + "type": "keyword", + "ignore_above": 256 + } + } + }, + "resource": { + "properties": { + "context": { + "type": "object" + }, + "filter": { + "type": "object", + "enabled": false + }, + "objects": { + "type": "keyword", + "ignore_above": 256 + }, + "raw_filter": { + "type": "object", + "enabled": false + }, + "resource_id": { + "type": "keyword", + "ignore_above": 256 + }, + "resource_name": { + "type": "keyword", + "ignore_above": 256 + }, + "time_field": { + "type": "keyword", + "ignore_above": 256 + }, + "type": { + "type": "keyword", + "ignore_above": 256 + } + } + }, + "schedule": { + "properties": { + "interval": { + "type": "keyword", + "ignore_above": 256 + } + } + }, + "updated": { + "type": "date" + } + } + } +} + +#alerting channel +#The `id` value is consistent with the `_id` value +POST $[[SETUP_INDEX_PREFIX]]channel/$[[SETUP_DOC_TYPE]]/cgnb2nt3q95nmusjl65g +{ + "id": "cgnb2nt3q95nmusjl65g", + "created": "2023-04-06T11:47:43.104108279Z", + "updated": "2023-08-09T22:39:50.494915568+08:00", + "name": "[告警] Slack 消息通知", + "type": "webhook", + "webhook": { + "header_params": { + "Content-type": "application/json" + }, + "method": "POST", + "url": "{{$.env.SLACK_WEBHOOK_ENDPOINT}}", + "body": "{\n \"blocks\": [\n {\n \"type\": \"section\",\n \"text\": {\n \"type\": \"mrkdwn\",\n \"text\": \"*{{if eq .priority \"critical\"}} :fire: {{else if eq .priority \"error\"}} :rotating_light: {{else}} :warning: {{end}} 告警事件 <{{$.env.INFINI_CONSOLE_ENDPOINT}}/#/alerting/message/{{.event_id}}|#{{.event_id}}> 正在发生!*\\n :point_right: *{{.rule_name}} - {{.title}}*\"\n }\n },\n {\n \"type\": \"section\",\n \"text\": {\n \"type\": \"mrkdwn\",\n \"text\": \"*触发时间:* {{.trigger_at | datetime}}\"\n }\n },\n {\n \"type\": \"section\",\n \"text\": {\n \"type\": \"mrkdwn\",\n \"text\": \"*优先级:* {{.priority}}\"\n }\n },\n {\n \"type\": \"divider\"\n }\n ]\n {{if gt (len .results) 0}}\n ,\"attachments\": [\n {{range .results}}\n {\n \"color\": {{if eq .priority \"critical\"}} \"#C91010\" {{else if eq .priority \"error\"}} \"#EB4C21\" {{else}} \"#FFB449\" {{end}},\n \"blocks\": [\n {\n \"type\": \"section\",\n \"text\": {\n \"type\": \"mrkdwn\",\n \"text\": \"*集群:* <{{$.env.INFINI_CONSOLE_ENDPOINT}}/#/cluster/monitor/elasticsearch/{{ index .group_values 0}}|{{lookup \"category=metadata, object=cluster, property=name, default=未知\" (index .group_values 0) }}>\"\n }\n }\n ]\n },\n {{end}}\n {\n \"blocks\": [\n {\n \"type\": \"divider\"\n },\n {\n \"type\": \"actions\",\n \"elements\": [\n {\n \"type\": \"button\",\n \"text\": {\n \"type\": \"plain_text\",\n \"text\": \"查看告警\" \n },\n \"url\": \"{{$.env.INFINI_CONSOLE_ENDPOINT}}/#/alerting/message/{{.event_id}}\"\n }\n ]\n },\n ]\n }\n ]\n {{end}}\n}" + }, + "sub_type": "slack", + "enabled": false +} +POST $[[SETUP_INDEX_PREFIX]]channel/$[[SETUP_DOC_TYPE]]/cj8bq8d3q95ogankugqg +{ + "id": "cj8bq8d3q95ogankugqg", + "created": "2023-08-07T17:45:05.534408059+08:00", + "updated": "2023-08-09T22:39:56.489567891+08:00", + "name": "[恢复] Slack 消息通知", + "type": "webhook", + "webhook": { + "header_params": { + "Content-Type": "application/json" + }, + "method": "POST", + "url": "{{$.env.SLACK_WEBHOOK_ENDPOINT}}", + "body": "{\n \"blocks\": [\n {\n \"type\": \"section\",\n \"text\": {\n \"type\": \"mrkdwn\",\n \"text\": \"*{{.title}}*\"\n }\n },\n {\n \"type\": \"section\",\n \"text\": {\n \"type\": \"mrkdwn\",\n \"text\": \"*恢复时间:* {{.timestamp | datetime}}\"\n }\n },\n {\n \"type\": \"section\",\n \"text\": {\n \"type\": \"mrkdwn\",\n \"text\": \"*目标:* {{.resource_name}}-{{.objects}}\"\n }\n },\n {\n \"type\": \"section\",\n \"text\": {\n \"type\": \"mrkdwn\",\n \"text\": \"*触发时间:* {{.trigger_at | datetime}}\"\n }\n },\n {\n \"type\": \"section\",\n \"text\": {\n \"type\": \"mrkdwn\",\n \"text\": \"*持续时长:* {{.duration}}\"\n }\n },\n {\n \"type\": \"divider\"\n },\n {\n \"type\": \"actions\",\n \"elements\": [\n {\n \"type\": \"button\",\n \"text\": {\n \"type\": \"plain_text\",\n \"text\": \"查看告警\" \n },\n \"url\": \"{{$.env.INFINI_CONSOLE_ENDPOINT}}/#/alerting/message/{{.event_id}}\"\n }\n ]\n }\n ]\n}" + }, + "sub_type": "slack", + "enabled": false +} +POST $[[SETUP_INDEX_PREFIX]]channel/$[[SETUP_DOC_TYPE]]/cgnb2kt3q95nmusjl64g +{ + "id": "cgnb2kt3q95nmusjl64g", + "created": "2023-04-06T11:47:31.161587662Z", + "updated": "2023-08-09T22:39:51.540172306+08:00", + "name": "[告警] 企业微信通知", + "type": "webhook", + "webhook": { + "header_params": { + "Content-Type": "application/json" + }, + "method": "POST", + "url": "{{$.env.WECOM_WEBHOOK_ENDPOINT}}", + "body": "{\n \"msgtype\": \"markdown\",\n \"markdown\": {\n \"content\": \"**[ INFINI 平台告警 ]**\\n🔥 告警事件 [#{{.event_id}}]({{$.env.INFINI_CONSOLE_ENDPOINT}}/#/alerting/message/{{.event_id}}) 正在发生\\n**{{.title}}**\\n优先级: {{.priority}}\\n\\n事件编号: {{.event_id}}\\n\\n目标: {{.resource_name}}-{{.objects}}\\n\\n触发时间: {{.trigger_at | datetime}}\\n{{.message}}\"\n }\n}" + }, + "sub_type": "wechat", + "enabled": false +} +POST $[[SETUP_INDEX_PREFIX]]channel/$[[SETUP_DOC_TYPE]]/cgiospt3q95q49k3u00g +{ + "id": "cgiospt3q95q49k3u00g", + "created": "2023-03-30T13:28:07.531263747Z", + "updated": "2023-08-09T22:39:52.356059486+08:00", + "name": "[告警] 钉钉通知", + "type": "webhook", + "webhook": { + "header_params": { + "Content-Type": "application/json" + }, + "method": "POST", + "url": "{{$.env.DINGTALK_WEBHOOK_ENDPOINT}}", + "body": "{\n \"msgtype\": \"markdown\",\n \"markdown\": {\n \"title\": \"{{.title}}\",\n \"text\": \"![INFINI 平台告警](https://infinilabs.cn/img/email/alert-header.png)\\n\\n🔥 告警事件 [{{.event_id}}]({{$.env.INFINI_CONSOLE_ENDPOINT}}/#/alerting/message/{{.event_id}}) 正在发生\\n\\n**{{.title}}**\\n\\n优先级: {{.priority}}\\n\\n事件编号: {{.event_id}}\\n\\n目标: {{.resource_name}}-{{.objects}}\\n\\n触发时间: {{.trigger_at | datetime}}\\n\\n---\\n\\n{{.message}}\"\n }\n}" + }, + "sub_type": "dingtalk", + "enabled": false +} +POST $[[SETUP_INDEX_PREFIX]]channel/$[[SETUP_DOC_TYPE]]/cj8ctat3q95l9ebbntlg +{ + "id": "cj8ctat3q95l9ebbntlg", + "created": "2023-08-07T18:59:55.28732241+08:00", + "updated": "2023-08-09T22:39:58.967970184+08:00", + "name": "[恢复] 钉钉通知", + "type": "webhook", + "webhook": { + "header_params": { + "Content-Type": "application/json" + }, + "method": "POST", + "url": "{{$.env.DINGTALK_WEBHOOK_ENDPOINT}}", + "body": "{\n \"msgtype\": \"markdown\",\n \"markdown\": {\n \"title\": \"{{.title}}\",\n \"text\": \"![INFINI 平台告警](https://infinilabs.cn/img/email/recovery-header.png)\\n\\n**{{.title}}**\\n\\n{{.message}}\\n\\n> [查看告警]({{$.env.INFINI_CONSOLE_ENDPOINT}}/#/alerting/message/{{.event_id}})\"\n }\n}\n" + }, + "sub_type": "dingtalk", + "enabled": false +} +POST $[[SETUP_INDEX_PREFIX]]channel/$[[SETUP_DOC_TYPE]]/cj8e9gt3q95gsdbb0170 +{ + "id": "cj8e9gt3q95gsdbb0170", + "created": "2023-08-07T20:34:11.998953512+08:00", + "updated": "2023-08-09T22:40:04.665871275+08:00", + "name": "[恢复] 企业微信通知", + "type": "webhook", + "webhook": { + "header_params": { + "Content-Type": "application/json" + }, + "method": "POST", + "url": "{{$.env.WECOM_WEBHOOK_ENDPOINT}}", + "body": "{\n \"msgtype\": \"markdown\",\n \"markdown\": {\n \"content\": \"**[ INFINI 平台告警 ]**\\n**{{.title}}**\\n\\n{{.message}}\\n\\n> [查看告警]({{$.env.INFINI_CONSOLE_ENDPOINT}}/#/alerting/message/{{.event_id}})\"\n }\n}\n" + }, + "sub_type": "wechat", + "enabled": false +} +POST $[[SETUP_INDEX_PREFIX]]channel/$[[SETUP_DOC_TYPE]]/cgnb2r53q95nmusjl6vg +{ + "id": "cgnb2r53q95nmusjl6vg", + "created": "2023-04-06T11:47:56.652637309Z", + "updated": "2023-08-10T12:04:08.046781556+08:00", + "name": "[告警] 邮件通知", + "type": "email", + "sub_type": "email", + "email": { + "server_id": "", + "recipients": { + "to": [], + "cc": [] + }, + "subject": "[INFINI 平台告警] 🔥 {{.title}}", + "body": "\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n
\n \n \n \n \n \n \n
\n
\n
\n \n
\n \n \n \n \n
\n
\n \n \n \n \n \n \n
\n \"email-header\"\n
\n
\n \n \n \n \n \n \n
\n
\n
\n {{.title}}\n

\n \n

优先级: {{.priority}}

\n

事件编号: {{.event_id}}

\n

目标: {{.resource_name}}-{{.objects}}

\n

触发时间: {{.trigger_at | datetime}}

\n {{.message | md_to_html}}\n
\n

\n \n \n View Detail\n \n

\n \n \n \n

\n \n

\n \"INFINI\n
\n
\n \n
\n

\n \n \n
\n
\n \n \n \n
\n
\n
\n \n
\n \n \n \n \n
\n
\n
\n
\n \n \n \n
\n
\n \n
\n \n \n
\n
\n \n ", + "content_type": "text/html" + }, + "enabled": false +} +POST $[[SETUP_INDEX_PREFIX]]channel/$[[SETUP_DOC_TYPE]]/cj8atf53q95lhahebg8g +{ + "id": "cj8atf53q95lhahebg8g", + "created": "2023-08-07T16:43:40.062389175+08:00", + "updated": "2023-08-10T12:04:42.842628127+08:00", + "name": "[恢复] 邮件通知", + "type": "email", + "sub_type": "email", + "email": { + "server_id": "", + "recipients": { + "to": [], + "cc": [] + }, + "subject": "[INFINI 平台告警恢复] {{.title}}", + "body": "\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n
\n \n \n \n \n \n \n
\n
\n
\n \n
\n \n \n \n \n
\n
\n \n \n \n \n \n \n
\n \"email-header\"\n
\n
\n \n \n \n \n \n \n
\n
\n
\n {{.title}}\n

\n {{.message | md_to_html}}\n
\n

\n \n \n View Detail\n \n

\n \n \n \n

\n \n

\n \"INFINI\n
\n
\n \n
\n

\n \n \n
\n
\n \n \n \n
\n
\n
\n \n
\n \n \n \n \n
\n
\n
\n
\n \n \n \n
\n
\n \n
\n \n \n
\n
\n \n ", + "content_type": "text/html" + }, + "enabled": false +} +POST $[[SETUP_INDEX_PREFIX]]channel/$[[SETUP_DOC_TYPE]]/ch1os6t3q95lk6lepkq0 +{ + "id": "ch1os6t3q95lk6lepkq0", + "created": "2023-04-22T07:34:51.848540351Z", + "updated": "2023-08-10T17:18:38.592432088+08:00", + "name": "[告警] 飞书通知", + "type": "webhook", + "webhook": { + "header_params": { + "Content-Type": "application/json" + }, + "method": "POST", + "url": "{{$.env.FEISHU_WEBHOOK_ENDPOINT}}", + "body": "{\n \"msg_type\": \"interactive\",\n \"card\": {\n \"header\": {\n \"title\": {\n \"content\": \"[ INFINI 平台告警 ]\",\n \"tag\": \"plain_text\"\n },\n \"template\":\"{{if eq .priority \"critical\"}}red{{else if eq .priority \"high\"}}orange{{else if eq .priority \"medium\"}}yellow{{else if eq .priority \"low\"}}grey{{else}}blue{{end}}\"\n },\n \"elements\": [{\n \"tag\": \"markdown\",\n \"content\": \"🔥 告警事件 [#{{.event_id}}]({{$.env.INFINI_CONSOLE_ENDPOINT}}/#/alerting/message/{{.event_id}}) 正在发生\\n **{{.title}}**\\n优先级: {{.priority}}\\n事件编号: {{.event_id}}\\n目标: {{.resource_name}}-{{.objects}}\\n触发时间: {{.trigger_at | datetime}}\"\n },{\n \"tag\": \"hr\"\n },\n {\n \"tag\": \"markdown\",\n \"content\": \"{{ .message | str_replace \"\\n\" \"\\\\n\" }}\"\n }\n ]\n}\n}" + }, + "sub_type": "feishu", + "enabled": false + } +POST $[[SETUP_INDEX_PREFIX]]channel/$[[SETUP_DOC_TYPE]]/cj8e9s53q95gsdbb054g +{ + "id": "cj8e9s53q95gsdbb054g", + "created": "2023-08-07T20:34:56.334695598+08:00", + "updated": "2023-08-10T17:18:36.035896482+08:00", + "name": "[恢复] 飞书通知", + "type": "webhook", + "webhook": { + "header_params": { + "Content-Type": "application/json" + }, + "method": "POST", + "url": "{{$.env.FEISHU_WEBHOOK_ENDPOINT}}", + "body": "{\n \"msg_type\": \"interactive\",\n \"card\": {\n \"header\": {\n \"title\": {\n \"content\": \"[ INFINI 平台告警 ]\",\n \"tag\": \"plain_text\"\n },\n \"template\":\"green\"\n },\n \"elements\": [\n {\n \"tag\": \"markdown\",\n \"content\": \"**{{.title}}**\"\n },\n {\n \"tag\": \"hr\"\n },\n {\n \"tag\": \"markdown\",\n \"content\": \"{{ .message | str_replace \"\\n\" \"\\\\n\" }}\"\n },\n {\n \"tag\": \"hr\"\n },\n {\n \"tag\": \"markdown\",\n \"content\": \"[查看告警]({{$.env.INFINI_CONSOLE_ENDPOINT}}/#/alerting/message/{{.event_id}})\"\n }\n ]\n }\n}" + }, + "sub_type": "feishu", + "enabled": false +} +POST $[[SETUP_INDEX_PREFIX]]channel/$[[SETUP_DOC_TYPE]]/cj865st3q95rega919ig +{ + "id": "cj865st3q95rega919ig", + "created": "2023-08-07T11:20:19.223545026+08:00", + "updated": "2023-08-10T17:18:41.92016786+08:00", + "name": "[告警] Discord 消息通知", + "type": "webhook", + "webhook": { + "header_params": { + "Content-Type": "application/json" + }, + "method": "POST", + "url": "{{$.env.DISCORD_WEBHOOK_ENDPOINT}}", + "body": "{\"content\": \"**[ INFINI 平台告警 ]**\\n🔥 告警事件 [#{{.event_id}}]({{$.env.INFINI_CONSOLE_ENDPOINT}}/#/alerting/message/{{.event_id}}) 正在发生\\n**{{.title}}**\\n\\n优先级: {{.priority}}\\n事件编号: {{.event_id}}\\n目标: {{.resource_name}}-{{.objects}}\\n触发时间: {{.trigger_at | datetime}}\\n{{ .message | str_replace \"\\n\" \"\\\\n\" }}\"}" + }, + "sub_type": "discord", + "enabled": false +} +POST $[[SETUP_INDEX_PREFIX]]channel/$[[SETUP_DOC_TYPE]]/cj86l0l3q95rrpfea6ug +{ + "id": "cj86l0l3q95rrpfea6ug", + "created": "2023-08-07T11:52:34.192522006+08:00", + "updated": "2023-08-10T17:18:44.422687739+08:00", + "name": "[恢复] Discord 消息通知", + "type": "webhook", + "webhook": { + "header_params": { + "Content-Type": "application/json" + }, + "method": "POST", + "url": "{{$.env.DISCORD_WEBHOOK_ENDPOINT}}", + "body": "{\n \"content\": \"**[ INFINI 平台告警 ]**\\n🌈 **{{.title}}**\\n\\n{{.message | str_replace \"\\n\" \"\\\\n\" }}\\n> [查看告警]({{$.env.INFINI_CONSOLE_ENDPOINT}}/#/alerting/message/{{.event_id}})\"\n}" + }, + "sub_type": "discord", + "enabled": false +} + +#alerting +#The `id` value is consistent with the `_id` value +POST $[[SETUP_INDEX_PREFIX]]alert-rule/$[[SETUP_DOC_TYPE]]/builtin-cal8n7p7h710dpnoaps0 +{ + "id": "builtin-cal8n7p7h710dpnoaps0", + "created": "2022-06-16T01:47:11.326727124Z", + "updated": "2023-08-09T22:39:43.98598502+08:00", + "name": "集群健康状态变为红色", + "enabled": true, + "resource": { + "resource_id": "$[[SETUP_RESOURCE_ID]]", + "resource_name": "$[[SETUP_RESOURCE_NAME]]", + "type": "elasticsearch", + "objects": [ + ".infini_metrics*" + ], + "filter": {}, + "raw_filter": { + "bool": { + "must": [ + { + "term": { + "payload.elasticsearch.cluster_health.status": "red" + } + }, + { + "term": { + "metadata.name": { + "value": "cluster_health" + } + } + } + ] + } + }, + "time_field": "timestamp", + "context": { + "fields": null + } + }, + "metrics": { + "bucket_size": "1m", + "groups": [ + { + "field": "metadata.labels.cluster_id", + "limit": 5 + } + ], + "formula": "a", + "items": [ + { + "name": "a", + "field": "payload.elasticsearch.cluster_health.status", + "statistic": "count" + } + ], + "format_type": "num", + "bucket_label": { + "enabled": false + }, + "expression": "count(payload.elasticsearch.cluster_health.status)" + }, + "conditions": { + "operator": "any", + "items": [ + { + "minimum_period_match": 1, + "operator": "gte", + "values": [ + "1" + ], + "priority": "critical" + } + ] + }, + "notification_config": { + "enabled": true, + "title": "🔥 集群健康状态变为红色(共 {{len .results}} 个集群)", + "message": "{{range .results}}\n{{$cn := lookup \"category=metadata, object=cluster, property=name, default=未知\" (index .group_values 0) }}\n{{$cu := printf \"%s/#/cluster/monitor/elasticsearch/%s\" $.env.INFINI_CONSOLE_ENDPOINT (index .group_values 0)}}\n集群: [{{$cn}}]({{$cu}}?_g=%7B%22timeRange%22:%7B%22min%22:%22{{$.min}}%22%2C%22max%22:%22{{$.max}}%22%7D%2C%22cluster_name%22:%22{{$cn | urlquery}}%22%7D) 当前为红色\n{{end}}", + "normal": [ + { + "id": "cgnb2nt3q95nmusjl65g", + "created": "2023-04-06T11:47:43.104108279Z", + "updated": "2023-08-07T15:02:17.165625799+08:00", + "name": "[告警] Slack 消息通知", + "type": "webhook", + "webhook": { + "header_params": { + "Content-Type": "application/json" + }, + "method": "POST", + "body": "{\n \"blocks\": [\n {\n \"type\": \"section\",\n \"text\": {\n \"type\": \"mrkdwn\",\n \"text\": \"*{{if eq .priority \"critical\"}} :fire: {{else if eq .priority \"error\"}} :rotating_light: {{else}} :warning: {{end}} 告警事件 <{{$.env.INFINI_CONSOLE_ENDPOINT}}/#/alerting/message/{{.event_id}}|#{{.event_id}}> 正在发生*\\n :point_right: *{{.rule_name}} - {{.title}}*\"\n }\n },\n {\n \"type\": \"section\",\n \"text\": {\n \"type\": \"mrkdwn\",\n \"text\": \"*触发时间:* {{.timestamp | datetime}}\"\n }\n },\n {\n \"type\": \"section\",\n \"text\": {\n \"type\": \"mrkdwn\",\n \"text\": \"*优先级:* {{.priority}}\"\n }\n },\n {\n \"type\": \"divider\"\n }\n ]\n {{if gt (len .results) 0}}\n ,\"attachments\": [\n {{range .results}}\n {\n \"color\": {{if eq .priority \"critical\"}} \"#C91010\" {{else if eq .priority \"error\"}} \"#EB4C21\" {{else}} \"#FFB449\" {{end}},\n \"blocks\": [\n {\n \"type\": \"section\",\n \"text\": {\n \"type\": \"mrkdwn\",\n \"text\": \"*Cluster:* <{{$.env.INFINI_CONSOLE_ENDPOINT}}/#/cluster/monitor/elasticsearch/{{ index .group_values 0}}?_g={%22timeRange%22:%7B%22min%22:%22{{$.min}}%22%2C%22max%22:%22{{$.max}}%22%7D%2C%22cluster_name%22:%22{{lookup \"category=metadata, object=cluster, property=name, default=未知\" (index .group_values 0) }}%22} | {{lookup \"category=metadata, object=cluster, property=name, default=未知\" (index .group_values 0) }}> 当前为红色\"\n }\n }\n ]\n },\n {{end}}\n {\n \"blocks\": [\n {\n \"type\": \"divider\"\n },\n {\n \"type\": \"actions\",\n \"elements\": [\n {\n \"type\": \"button\",\n \"text\": {\n \"type\": \"plain_text\",\n \"text\": \"查看告警\" \n },\n \"url\": \"{{$.env.INFINI_CONSOLE_ENDPOINT}}/#/alerting/message/{{.event_id}}\"\n }\n ]\n },\n ]\n }\n ]\n {{end}}\n}" + }, + "sub_type": "slack", + "enabled": true + }, + { + "id": "cgiospt3q95q49k3u00g", + "enabled": true + }, + { + "id": "cj865st3q95rega919ig", + "enabled": true + }, + { + "id": "cgnb2r53q95nmusjl6vg", + "enabled": true + }, + { + "id": "ch1os6t3q95lk6lepkq0", + "enabled": true + }, + { + "id": "cgnb2kt3q95nmusjl64g", + "enabled": true + } + ], + "throttle_period": "1h", + "accept_time_range": { + "start": "00:00", + "end": "23:59" + } + }, + "category": "Platform", + "recovery_notification_config": { + "enabled": true, + "title": "🌈 {{.rule_name}} 已恢复", + "message": "事件编号: {{.event_id}} \n目标: {{.resource_name}}-{{.objects}} \n触发时间: {{.trigger_at | datetime}} \n恢复时间: {{.timestamp | datetime}} \n持续时长: {{.duration}}{{if .recovery_context}} \n{{.recovery_context}}{{end}}", + "normal": [ + { + "id": "cj8atf53q95lhahebg8g", + "enabled": true + }, + { + "id": "cj8bq8d3q95ogankugqg", + "enabled": true + }, + { + "id": "cj8ctat3q95l9ebbntlg", + "enabled": true + }, + { + "id": "cj8e9s53q95gsdbb054g", + "enabled": true + }, + { + "id": "cj8e9gt3q95gsdbb0170", + "enabled": true + }, + { + "id": "cj86l0l3q95rrpfea6ug", + "enabled": true + } + ], + "event_enabled": true + }, + "schedule": { + "interval": "1m" + }, + "creator": { + "name": "$[[SETUP_USERNAME]]", + "id": "$[[SETUP_USER_ID]]" + } +} +POST $[[SETUP_INDEX_PREFIX]]alert-rule/$[[SETUP_DOC_TYPE]]/builtin-calavvp7h710dpnp32r3 +{ + "id": "builtin-calavvp7h710dpnp32r3", + "created": "2022-06-16T04:22:23.001354546Z", + "updated": "2023-08-09T22:20:17.864619426+08:00", + "name": "索引健康状态变为红色", + "enabled": true, + "resource": { + "resource_id": "$[[SETUP_RESOURCE_ID]]", + "resource_name": "$[[SETUP_RESOURCE_NAME]]", + "type": "elasticsearch", + "objects": [ + ".infini_metrics" + ], + "filter": {}, + "raw_filter": {"bool":{"must":[{"term":{"payload.elasticsearch.index_health.status":"red"}},{"term":{"metadata.name":{"value":"index_health"}}}]}}, + "time_field": "timestamp", + "context": { + "fields": null + } + }, + "metrics": { + "bucket_size": "1m", + "groups": [ + { + "field": "metadata.cluster_id", + "limit": 50 + }, + { + "field": "metadata.index_name", + "limit": 1000 + } + ], + "formula": "a", + "items": [ + { + "name": "a", + "field": "metadata.index_name", + "statistic": "count" + } + ], + "format_type": "num", + "bucket_label": { + "enabled": false + }, + "expression": "count(metadata.index_name)" + }, + "conditions": { + "operator": "any", + "items": [ + { + "minimum_period_match": 1, + "operator": "gte", + "values": [ + "1" + ], + "priority": "high" + } + ] + }, + "notification_config": { + "enabled": true, + "title": "🔥 索引健康状态变为红色(共 {{len .results}} 个索引)", + "message": "{{range .results}}\n{{$cn := lookup \"category=metadata, object=cluster, property=name, default=未知\" (index .group_values 0) }}\n{{$iu := printf \"%s/#/cluster/monitor/%s/indices/%s\" $.env.INFINI_CONSOLE_ENDPOINT (index .group_values 0) (index .group_values 1)}}\n{{$cu := printf \"%s/#/cluster/monitor/elasticsearch/%s\" $.env.INFINI_CONSOLE_ENDPOINT (index .group_values 0)}}\n索引: [{{index .group_values 1}}]({{$iu}}?_g=%7B%22timeRange%22:%7B%22min%22:%22{{$.min}}%22%2C%22max%22:%22{{$.max}}%22%7D%2C%22cluster_name%22:%22{{$cn | urlquery}}%22%7D) 所属集群: [{{$cn}}]({{$cu}}?_g=%7B%22timeRange%22:%7B%22min%22:%22{{$.min}}%22%2C%22max%22:%22{{$.max}}%22%7D) 当前为红色\n{{end}}", + "normal": [ + { + "id": "cgnb2nt3q95nmusjl65g", + "created": "2023-04-06T11:47:43.104108279Z", + "updated": "2023-08-07T15:17:26.18861218+08:00", + "name": "[告警] Slack 消息通知", + "type": "webhook", + "webhook": { + "header_params": { + "Content-type": "application/json" + }, + "method": "POST", + "body": "{\n \"blocks\": [\n {\n \"type\": \"section\",\n \"text\": {\n \"type\": \"mrkdwn\",\n \"text\": \"*{{if eq .priority \"critical\"}} :fire: {{else if eq .priority \"error\"}} :rotating_light: {{else}} :warning: {{end}} 告警事件 <{{$.env.INFINI_CONSOLE_ENDPOINT}}/#/alerting/message/{{.event_id}}|#{{.event_id}}> 正在发生*\\n :point_right: *{{.rule_name}} - {{.title}}*\"\n }\n },\n {\n \"type\": \"section\",\n \"text\": {\n \"type\": \"mrkdwn\",\n \"text\": \"*触发时间:* {{.timestamp | datetime}}\"\n }\n },\n {\n \"type\": \"section\",\n \"text\": {\n \"type\": \"mrkdwn\",\n \"text\": \"*优先级:* {{.priority}}\"\n }\n },\n {\n \"type\": \"divider\"\n }\n ]\n {{if gt (len .results) 0}}\n ,\"attachments\": [\n {{range .results}}\n {\n \"color\": {{if eq .priority \"critical\"}} \"#C91010\" {{else if eq .priority \"error\"}} \"#EB4C21\" {{else}} \"#FFB449\" {{end}},\n \"blocks\": [\n {\n \"type\": \"section\",\n \"text\": {\n \"type\": \"mrkdwn\",\n \"text\": \"索引: <{{$.env.INFINI_CONSOLE_ENDPOINT}}#/cluster/monitor/{{ index .group_values 0}}/indices/{{ index .group_values 1}}?_g={%22timeRange%22:%7B%22min%22:%22{{$.min}}%22%2C%22max%22:%22{{$.max}}%22%7D%2C%22cluster_name%22:%22{{ lookup \"category=metadata, object=cluster, property=name, default=未知\" (index .group_values 0)}}%22} | {{index .group_values 1}}> 所属集群: <{{$.env.INFINI_CONSOLE_ENDPOINT}}/#/cluster/monitor/elasticsearch/{{index .group_values 0}}?_g={%22timeRange%22:%7B%22min%22:%22{{$.min}}%22%2C%22max%22:%22{{$.max}}%22%7D%2C%22cluster_name%22:%22{{lookup \"category=metadata, object=cluster, property=name, default=未知\" (index .group_values 0) }}%22} | {{lookup \"category=metadata, object=cluster, property=name, default=未知\" (index .group_values 0) }}> 当前为红色\"\n }\n }\n ]\n },\n {{end}}\n {\n \"blocks\": [\n {\n \"type\": \"divider\"\n },\n {\n \"type\": \"actions\",\n \"elements\": [\n {\n \"type\": \"button\",\n \"text\": {\n \"type\": \"plain_text\",\n \"text\": \"查看告警\" \n },\n \"url\": \"{{$.env.INFINI_CONSOLE_ENDPOINT}}/#/alerting/message/{{.event_id}}\"\n }\n ]\n },\n ]\n }\n ]\n {{end}}\n}" + }, + "sub_type": "slack", + "enabled": true + }, + { + "id": "cgiospt3q95q49k3u00g", + "enabled": true + }, + { + "id": "cj865st3q95rega919ig", + "enabled": true + }, + { + "id": "cgnb2r53q95nmusjl6vg", + "enabled": true + }, + { + "id": "ch1os6t3q95lk6lepkq0", + "enabled": true + }, + { + "id": "cgnb2kt3q95nmusjl64g", + "enabled": true + } + ], + "throttle_period": "1h", + "accept_time_range": { + "start": "00:00", + "end": "23:59" + } + }, + "category": "Platform", + "recovery_notification_config": { + "enabled": true, + "title": "🌈 {{.rule_name}} 已恢复", + "message": "事件编号: {{.event_id}} \n目标: {{.resource_name}}-{{.objects}} \n触发时间: {{.trigger_at | datetime}} \n恢复时间: {{.timestamp | datetime}} \n持续时长: {{.duration}}{{if .recovery_context}} \n{{.recovery_context}}{{end}}", + "normal": [ + { + "id": "cj8bq8d3q95ogankugqg", + "enabled": true + }, + { + "id": "cj8ctat3q95l9ebbntlg", + "enabled": true + }, + { + "id": "cj8atf53q95lhahebg8g", + "enabled": true + }, + { + "id": "cj8e9s53q95gsdbb054g", + "enabled": true + }, + { + "id": "cj8e9gt3q95gsdbb0170", + "enabled": true + }, + { + "id": "cj86l0l3q95rrpfea6ug", + "enabled": true + } + ], + "event_enabled": true + }, + "schedule": { + "interval": "1m" + }, + "creator": { + "name": "$[[SETUP_USERNAME]]", + "id": "$[[SETUP_USER_ID]]" + } +} +POST $[[SETUP_INDEX_PREFIX]]alert-rule/$[[SETUP_DOC_TYPE]]/builtin-cbp20n2anisjmu4gehc5 +{ + "id": "builtin-cbp20n2anisjmu4gehc5", + "created": "2022-08-09T08:52:44.63345561Z", + "updated": "2023-08-09T22:11:45.679048697+08:00", + "name": "节点离开集群(Elasticsearch)", + "enabled": true, + "resource": { + "resource_id": "$[[SETUP_RESOURCE_ID]]", + "resource_name": "$[[SETUP_RESOURCE_NAME]]", + "type": "elasticsearch", + "objects": [ + ".infini_node" + ], + "filter": {}, + "raw_filter": { + "match_phrase": { + "metadata.labels.status": "unavailable" + } + }, + "ignore_time_filter": true, + "time_field": "timestamp", + "context": { + "fields": null + } + }, + "metrics": { + "bucket_size": "1m", + "groups": [ + { + "field": "metadata.cluster_id", + "limit": 5 + }, + { + "field": "metadata.node_id", + "limit": 50 + } + ], + "formula": "a", + "items": [ + { + "name": "a", + "field": "metadata.labels.status", + "statistic": "count" + } + ], + "format_type": "num", + "bucket_label": { + "enabled": false + }, + "expression": "count(metadata.labels.status)" + }, + "conditions": { + "operator": "any", + "items": [ + { + "minimum_period_match": 1, + "operator": "gte", + "values": [ + "1" + ], + "priority": "critical" + } + ] + }, + "notification_config": { + "enabled": true, + "title": "🔥 节点离开集群(Elasticsearch)", + "message": "{{range .results}}\n{{$cn := lookup \"category=metadata, object=cluster, property=name, default=未知\" (index .group_values 0) }}\n{{$nn := lookup \"category=metadata, object=node, property=metadata.node_name, default=未知\" (index .group_values 1) }}\n{{$nu := printf \"%s/#/cluster/monitor/%s/nodes/%s\" $.env.INFINI_CONSOLE_ENDPOINT (index .group_values 0) (index .group_values 1)}}\n{{$cu := printf \"%s/#/cluster/monitor/elasticsearch/%s\" $.env.INFINI_CONSOLE_ENDPOINT (index .group_values 0)}}\n节点: [{{$nn}}]({{$nu}}?_g=%7B%22timeRange%22:%7B%22min%22:%22{{$.min}}%22%2C%22max%22:%22{{$.max}}%22%7D%2C%22cluster_name%22:%22{{$cn | urlquery}}%22%2C%22node_name%22:%22{{$nn}}%22%7D) 所属集群: [{{$cn}}]({{$cu}}?_g=%7B%22timeRange%22:%7B%22min%22:%22{{$.min}}%22%2C%22max%22:%22{{$.max}}%22%7D%7D), 离线数: {{.result_value}}\n{{end}}", + "normal": [ + { + "id": "cgnb2nt3q95nmusjl65g", + "created": "2023-04-06T11:47:43.104108279Z", + "updated": "2023-08-07T10:42:17.686776304+08:00", + "name": "Slack 消息通知", + "type": "webhook", + "webhook": { + "header_params": { + "Content-type": "application/json" + }, + "method": "POST", + "body": "{\n \"blocks\": [\n {\n \"type\": \"section\",\n \"text\": {\n \"type\": \"mrkdwn\",\n \"text\": \"*{{if eq .priority \"critical\"}} :fire: {{else if eq .priority \"error\"}} :rotating_light: {{else}} :warning: {{end}} 告警事件 <{{$.env.INFINI_CONSOLE_ENDPOINT}}/#/alerting/message/{{.event_id}}|#{{.event_id}}> 正在发生*\\n :point_right: *{{.rule_name}} - {{.title}}*\"\n }\n },\n {\n \"type\": \"section\",\n \"text\": {\n \"type\": \"mrkdwn\",\n \"text\": \"*触发时间:* {{.timestamp | datetime}}\"\n }\n },\n {\n \"type\": \"section\",\n \"text\": {\n \"type\": \"mrkdwn\",\n \"text\": \"*优先级:* {{.priority}}\"\n }\n },\n {\n \"type\": \"divider\"\n }\n ]\n {{if gt (len .results) 0}}\n ,\"attachments\": [\n {{range .results}}\n {\n \"color\": {{if eq .priority \"critical\"}} \"#C91010\" {{else if eq .priority \"error\"}} \"#EB4C21\" {{else}} \"#FFB449\" {{end}},\n \"blocks\": [\n {\n \"type\": \"section\",\n \"text\": {\n \"type\": \"mrkdwn\",\n \"text\": \"节点: <{{$.env.INFINI_CONSOLE_ENDPOINT}}/#/cluster/monitor/{{index .group_values 0}}/nodes/{{index .group_values 1}}?_g={%22timeRange%22:%7B%22min%22:%22{{$.min}}%22%2C%22max%22:%22{{$.max}}%22%7D%2C%22cluster_name%22:%22{{lookup \"category=metadata, object=cluster, property=name, default=未知\" (index .group_values 0) }}%22%2C%22node_name%22:%22{{lookup \"category=metadata, object=node, property=metadata.node_name, default=未知\" (index .group_values 1) }}%22} | {{lookup \"category=metadata, object=node, property=metadata.node_name, default=未知\" (index .group_values 1) }}> 所属集群: <{{$.env.INFINI_CONSOLE_ENDPOINT}}/#/cluster/monitor/elasticsearch/{{index .group_values 0}}?_g={%22timeRange%22:%7B%22min%22:%22{{$.min}}%22%2C%22max%22:%22{{$.max}}%22%7D%2C%22cluster_name%22:%22{{lookup \"category=metadata, object=cluster, property=name, default=未知\" (index .group_values 0) }}%22} | {{lookup \"category=metadata, object=cluster, property=name, default=未知\" (index .group_values 0) }}>, 离线数: {{.result_value}}\"\n }\n }\n ]\n },\n {{end}}\n {\n \"blocks\": [\n {\n \"type\": \"divider\"\n },\n {\n \"type\": \"actions\",\n \"elements\": [\n {\n \"type\": \"button\",\n \"text\": {\n \"type\": \"plain_text\",\n \"text\": \"查看告警\" \n },\n \"url\": \"{{$.env.INFINI_CONSOLE_ENDPOINT}}/#/alerting/message/{{.event_id}}\"\n }\n ]\n },\n ]\n }\n ]\n {{end}}\n}" + }, + "sub_type": "slack", + "enabled": true + }, + { + "id": "cgnb2r53q95nmusjl6vg", + "enabled": true + }, + { + "id": "ch1os6t3q95lk6lepkq0", + "enabled": true + }, + { + "id": "cgnb2kt3q95nmusjl64g", + "enabled": true + }, + { + "id": "cj865st3q95rega919ig", + "enabled": true + }, + { + "id": "cgiospt3q95q49k3u00g", + "enabled": true + } + ], + "throttle_period": "1h", + "accept_time_range": { + "start": "00:00", + "end": "23:59" + } + }, + "category": "Platform", + "recovery_notification_config": { + "enabled": true, + "title": "🌈 {{.rule_name}} 已恢复", + "message": "事件编号: {{.event_id}} \n目标: {{.resource_name}}-{{.objects}} \n触发时间: {{.trigger_at | datetime}} \n恢复时间: {{.timestamp | datetime}} \n持续时长: {{.duration}}{{if .recovery_context}} \n{{.recovery_context}}{{end}}", + "normal": [ + { + "id": "cj8bq8d3q95ogankugqg", + "enabled": true + }, + { + "id": "cj8ctat3q95l9ebbntlg", + "enabled": true + }, + { + "id": "cj8e9s53q95gsdbb054g", + "enabled": true + }, + { + "id": "cj8e9gt3q95gsdbb0170", + "enabled": true + }, + { + "id": "cj86l0l3q95rrpfea6ug", + "enabled": true + }, + { + "id": "cj8atf53q95lhahebg8g", + "enabled": true + } + ], + "event_enabled": true + }, + "schedule": { + "interval": "1m" + }, + "creator": { + "name": "$[[SETUP_USERNAME]]", + "id": "$[[SETUP_USER_ID]]" + } +} +POST $[[SETUP_INDEX_PREFIX]]alert-rule/$[[SETUP_DOC_TYPE]]/builtin-cb34sfl6psfiqtovhpt4 +{ + "id": "builtin-cb34sfl6psfiqtovhpt4", + "created": "2022-07-07T03:08:46.297166036Z", + "updated": "2023-08-09T22:38:41.764325087+08:00", + "name": "已删除文档占比过高", + "enabled": true, + "resource": { + "resource_id": "$[[SETUP_RESOURCE_ID]]", + "resource_name": "$[[SETUP_RESOURCE_NAME]]", + "type": "elasticsearch", + "objects": [ + ".infini_metrics*" + ], + "filter": {}, + "raw_filter": { + "range": { + "payload.elasticsearch.cluster_stats.indices.store.size_in_bytes": { + "gte": 32212254720 + } + } + }, + "time_field": "timestamp", + "context": { + "fields": null + } + }, + "metrics": { + "bucket_size": "1m", + "groups": [ + { + "field": "metadata.labels.cluster_id", + "limit": 20 + }, + { + "field": "metadata.labels.index_name", + "limit": 10 + } + ], + "formula": "(a/(a+b))*100", + "items": [ + { + "name": "a", + "field": "payload.elasticsearch.index_stats.primaries.docs.deleted", + "statistic": "max" + }, + { + "name": "b", + "field": "payload.elasticsearch.index_stats.primaries.docs.count", + "statistic": "max" + } + ], + "format_type": "ratio", + "bucket_label": { + "enabled": false + }, + "expression": "(max(payload.elasticsearch.index_stats.primaries.docs.deleted)/(max(payload.elasticsearch.index_stats.primaries.docs.deleted)+max(payload.elasticsearch.index_stats.primaries.docs.count)))*100" + }, + "conditions": { + "operator": "any", + "items": [ + { + "minimum_period_match": 1, + "operator": "gte", + "values": [ + "30" + ], + "priority": "medium" + }, + { + "minimum_period_match": 1, + "operator": "gte", + "values": [ + "40" + ], + "priority": "high" + }, + { + "minimum_period_match": 1, + "operator": "gte", + "values": [ + "55" + ], + "priority": "low" + } + ] + }, + "notification_config": { + "enabled": true, + "title": "🔥 已删除文档占比过高 (>30%)", + "message": "{{range .results}}\n{{$cn := lookup \"category=metadata, object=cluster, property=name, default=未知\" (index .group_values 0) }}\n{{$iu := printf \"%s/#/cluster/monitor/%s/indices/%s\" $.env.INFINI_CONSOLE_ENDPOINT (index .group_values 0) (index .group_values 1)}}\n{{$cu := printf \"%s/#/cluster/monitor/elasticsearch/%s\" $.env.INFINI_CONSOLE_ENDPOINT (index .group_values 0)}}\n索引: [{{index .group_values 1}}]({{$iu}}?_g=%7B%22timeRange%22:%7B%22min%22:%22{{$.min}}%22%2C%22max%22:%22{{$.max}}%22%7D%2C%22cluster_name%22:%22{{$cn | urlquery}}%22%7D) 所属集群: [{{$cn}}]({{$cu}}?_g=%7B%22timeRange%22:%7B%22min%22:%22{{$.min}}%22%2C%22max%22:%22{{$.max}}%22%7D%7D), 已删除占比: {{.result_value | to_fixed 2}}%\n{{end}}", + "normal": [ + { + "id": "cgnb2nt3q95nmusjl65g", + "name": "", + "type": "webhook", + "webhook": { + "header_params": { + "Content-type": "application/json" + }, + "method": "POST", + "body": "{\n \"blocks\": [\n {\n \"type\": \"section\",\n \"text\": {\n \"type\": \"mrkdwn\",\n \"text\": \"*{{if eq .priority \"critical\"}} :fire: {{else if eq .priority \"error\"}} :rotating_light: {{else}} :warning: {{end}} 告警事件 <{{$.env.INFINI_CONSOLE_ENDPOINT}}/#/alerting/message/{{.event_id}}|#{{.event_id}}> 正在发生*\\n :point_right: *{{.rule_name}} - {{.title}}*\"\n }\n },\n {\n \"type\": \"section\",\n \"text\": {\n \"type\": \"mrkdwn\",\n \"text\": \"*触发时间:* {{.timestamp | datetime}}\"\n }\n },\n {\n \"type\": \"section\",\n \"text\": {\n \"type\": \"mrkdwn\",\n \"text\": \"*优先级:* {{.priority}}\"\n }\n },\n {\n \"type\": \"divider\"\n }\n ]\n {{if gt (len .results) 0}}\n ,\"attachments\": [\n {{range .results}}\n {\n \"color\": {{if eq .priority \"critical\"}} \"#C91010\" {{else if eq .priority \"error\"}} \"#EB4C21\" {{else}} \"#FFB449\" {{end}},\n \"blocks\": [\n {\n \"type\": \"section\",\n \"text\": {\n \"type\": \"mrkdwn\",\n \"text\": \"索引: <{{$.env.INFINI_CONSOLE_ENDPOINT}}#/cluster/monitor/{{ index .group_values 0}}/indices/{{ index .group_values 1}}?_g={%22timeRange%22:%7B%22min%22:%22{{$.min}}%22%2C%22max%22:%22{{$.max}}%22%7D%2C%22cluster_name%22:%22{{ lookup \"category=metadata, object=cluster, property=name, default=未知\" (index .group_values 0)}}%22} | {{index .group_values 1}}> 所属集群: <{{$.env.INFINI_CONSOLE_ENDPOINT}}/#/cluster/monitor/elasticsearch/{{index .group_values 0}}?_g={%22timeRange%22:%7B%22min%22:%22{{$.min}}%22%2C%22max%22:%22{{$.max}}%22%7D%2C%22cluster_name%22:%22{{lookup \"category=metadata, object=cluster, property=name, default=未知\" (index .group_values 0) }}%22} | {{lookup \"category=metadata, object=cluster, property=name, default=未知\" (index .group_values 0) }}>, 删除占比: {{.result_value | to_fixed 2}}%\"\n }\n }\n ]\n },\n {{end}}\n {\n \"blocks\": [\n {\n \"type\": \"divider\"\n },\n {\n \"type\": \"actions\",\n \"elements\": [\n {\n \"type\": \"button\",\n \"text\": {\n \"type\": \"plain_text\",\n \"text\": \"查看告警\" \n },\n \"url\": \"{{$.env.INFINI_CONSOLE_ENDPOINT}}/#/alerting/message/{{.event_id}}\"\n }\n ]\n },\n ]\n }\n ]\n {{end}}\n}" + }, + "sub_type": "", + "enabled": true + }, + { + "id": "cj865st3q95rega919ig", + "enabled": true + }, + { + "id": "cgiospt3q95q49k3u00g", + "enabled": true + }, + { + "id": "cgnb2r53q95nmusjl6vg", + "enabled": true + }, + { + "id": "ch1os6t3q95lk6lepkq0", + "enabled": true + }, + { + "id": "cgnb2kt3q95nmusjl64g", + "enabled": true + } + ], + "throttle_period": "24h", + "accept_time_range": { + "start": "00:00", + "end": "23:59" + } + }, + "category": "Platform", + "recovery_notification_config": { + "enabled": true, + "title": "🌈 {{.rule_name}} 已恢复", + "message": "事件编号: {{.event_id}} \n目标: {{.resource_name}}-{{.objects}} \n触发时间: {{.trigger_at | datetime}} \n恢复时间: {{.timestamp | datetime}} \n持续时长: {{.duration}}{{if .recovery_context}} \n{{.recovery_context}}{{end}}", + "normal": [ + { + "id": "cj8bq8d3q95ogankugqg", + "enabled": true + }, + { + "id": "cj8ctat3q95l9ebbntlg", + "enabled": true + }, + { + "id": "cj8atf53q95lhahebg8g", + "enabled": true + }, + { + "id": "cj8e9s53q95gsdbb054g", + "enabled": true + }, + { + "id": "cj8e9gt3q95gsdbb0170", + "enabled": true + }, + { + "id": "cj86l0l3q95rrpfea6ug", + "enabled": true + } + ], + "event_enabled": true + }, + "schedule": { + "interval": "1m" + }, + "creator": { + "name": "$[[SETUP_USERNAME]]", + "id": "$[[SETUP_USER_ID]]" + } +} +POST $[[SETUP_INDEX_PREFIX]]alert-rule/$[[SETUP_DOC_TYPE]]/builtin-cbp2e4ianisjmu4giqs7 +{ + "id": "builtin-cbp2e4ianisjmu4giqs7", + "created": "2022-06-16T04:11:10.242061032Z", + "updated": "2023-08-09T22:39:15.339913317+08:00", + "name": "搜索延迟高于 500ms", + "enabled": true, + "resource": { + "resource_id": "$[[SETUP_RESOURCE_ID]]", + "resource_name": "$[[SETUP_RESOURCE_NAME]]", + "type": "elasticsearch", + "objects": [ + ".infini_metrics*" + ], + "filter": {}, + "raw_filter": { + "bool": { + "must": [ + { + "term": { + "metadata.name": { + "value": "index_stats" + } + } + }, + { + "term": { + "metadata.category": { + "value": "elasticsearch" + } + } + } + ], + "must_not": [ + { + "term": { + "metadata.labels.index_name": { + "value": "_all" + } + } + } + ] + } + }, + "time_field": "timestamp", + "context": { + "fields": null + } + }, + "metrics": { + "bucket_size": "1m", + "groups": [ + { + "field": "metadata.labels.cluster_id", + "limit": 50 + }, + { + "field": "metadata.labels.index_name", + "limit": 10 + } + ], + "formula": "a/b", + "items": [ + { + "name": "a", + "field": "payload.elasticsearch.index_stats.total.search.query_time_in_millis", + "statistic": "rate" + }, + { + "name": "b", + "field": "payload.elasticsearch.index_stats.primaries.search.query_total", + "statistic": "rate" + } + ], + "format_type": "num", + "bucket_label": { + "enabled": false + }, + "expression": "rate(payload.elasticsearch.index_stats.total.search.query_time_in_millis)/rate(payload.elasticsearch.index_stats.primaries.search.query_total)" + }, + "conditions": { + "operator": "any", + "items": [ + { + "minimum_period_match": 1, + "operator": "gte", + "values": [ + "500" + ], + "priority": "medium" + }, + { + "minimum_period_match": 1, + "operator": "gte", + "values": [ + "300" + ], + "priority": "low" + } + ] + }, + "notification_config": { + "enabled": true, + "title": "🔥 搜索延迟高于 500ms", + "message": "{{range .results}}\n{{$cn := lookup \"category=metadata, object=cluster, property=name, default=未知\" (index .group_values 0) }}\n{{$iu := printf \"%s/#/cluster/monitor/%s/indices/%s\" $.env.INFINI_CONSOLE_ENDPOINT (index .group_values 0) (index .group_values 1)}}\n{{$cu := printf \"%s/#/cluster/monitor/elasticsearch/%s\" $.env.INFINI_CONSOLE_ENDPOINT (index .group_values 0)}}\n索引: [{{index .group_values 1}}]({{$iu}}?_g=%7B%22timeRange%22:%7B%22min%22:%22{{$.min}}%22%2C%22max%22:%22{{$.max}}%22%7D%2C%22cluster_name%22:%22{{$cn | urlquery}}%22%7D) 所属集群: [{{$cn}}]({{$cu}}?_g=%7B%22timeRange%22:%7B%22min%22:%22{{$.min}}%22%2C%22max%22:%22{{$.max}}%22%7D%7D), 延迟: {{.result_value | to_fixed 2}}ms\n{{end}}", + "normal": [ + { + "id": "cgnb2nt3q95nmusjl65g", + "created": "2023-04-06T11:47:43.104108279Z", + "updated": "2023-08-06T15:46:34.404507399+08:00", + "name": "Slack 消息通知", + "type": "webhook", + "webhook": { + "header_params": { + "Content-type": "application/json" + }, + "method": "POST", + "body": "\n{\n \"blocks\": [\n {\n \"type\": \"section\",\n \"text\": {\n \"type\": \"mrkdwn\",\n \"text\": \"*{{if eq .priority \"critical\"}} :fire: {{else if eq .priority \"error\"}} :rotating_light: {{else}} :warning: {{end}} 告警事件 <{{$.env.INFINI_CONSOLE_ENDPOINT}}/#/alerting/message/{{.event_id}}|#{{.event_id}}> 正在发生*\\n :point_right: *{{.rule_name}} - {{.title}}*\"\n }\n },\n {\n \"type\": \"section\",\n \"text\": {\n \"type\": \"mrkdwn\",\n \"text\": \"*触发时间:* {{.timestamp | datetime}}\"\n }\n },\n {\n \"type\": \"section\",\n \"text\": {\n \"type\": \"mrkdwn\",\n \"text\": \"*优先级:* {{.priority}}\"\n }\n },\n {\n \"type\": \"divider\"\n }\n ]\n {{if gt (len .results) 0}}\n ,\"attachments\": [\n {{range .results}}\n {\n \"color\": {{if eq .priority \"critical\"}} \"#C91010\" {{else if eq .priority \"error\"}} \"#EB4C21\" {{else}} \"#FFB449\" {{end}},\n \"blocks\": [\n {\n \"type\": \"section\",\n \"text\": {\n \"type\": \"mrkdwn\",\n \"text\": \"索引: <{{$.env.INFINI_CONSOLE_ENDPOINT}}#/cluster/monitor/{{ index .group_values 0}}/indices/{{ index .group_values 1}}?_g={%22timeRange%22:%7B%22min%22:%22{{$.min}}%22%2C%22max%22:%22{{$.max}}%22%7D%2C%22cluster_name%22:%22{{ lookup \"category=metadata, object=cluster, property=name, default=未知\" (index .group_values 0)}}%22} | {{index .group_values 1}}> 所属集群: <{{$.env.INFINI_CONSOLE_ENDPOINT}}/#/cluster/monitor/elasticsearch/{{index .group_values 0}}?_g={%22timeRange%22:%7B%22min%22:%22{{$.min}}%22%2C%22max%22:%22{{$.max}}%22%7D%2C%22cluster_name%22:%22{{lookup \"category=metadata, object=cluster, property=name, default=未知\" (index .group_values 0) }}%22} | {{lookup \"category=metadata, object=cluster, property=name, default=未知\" (index .group_values 0) }}>, 搜索延迟: {{.result_value | to_fixed 2}}ms\"\n }\n }\n ]\n },\n {{end}}\n {\n \"blocks\": [\n {\n \"type\": \"divider\"\n },\n {\n \"type\": \"actions\",\n \"elements\": [\n {\n \"type\": \"button\",\n \"text\": {\n \"type\": \"plain_text\",\n \"text\": \"查看告警\" \n },\n \"url\": \"{{$.env.INFINI_CONSOLE_ENDPOINT}}/#/alerting/message/{{.event_id}}\"\n }\n ]\n },\n ]\n }\n ]\n {{end}}\n}" + }, + "sub_type": "slack", + "enabled": true + }, + { + "id": "cgiospt3q95q49k3u00g", + "enabled": true + }, + { + "id": "cj865st3q95rega919ig", + "enabled": true + }, + { + "id": "cgnb2r53q95nmusjl6vg", + "enabled": true + }, + { + "id": "ch1os6t3q95lk6lepkq0", + "enabled": true + }, + { + "id": "cgnb2kt3q95nmusjl64g", + "enabled": true + } + ], + "throttle_period": "1h", + "accept_time_range": { + "start": "00:00", + "end": "23:59" + } + }, + "category": "Platform", + "recovery_notification_config": { + "enabled": true, + "title": "🌈 {{.rule_name}} 已恢复", + "message": "事件编号: {{.event_id}} \n目标: {{.resource_name}}-{{.objects}} \n触发时间: {{.trigger_at | datetime}} \n恢复时间: {{.timestamp | datetime}} \n持续时长: {{.duration}}{{if .recovery_context}} \n{{.recovery_context}}{{end}}", + "normal": [ + { + "id": "cj8bq8d3q95ogankugqg", + "enabled": true + }, + { + "id": "cj8ctat3q95l9ebbntlg", + "enabled": true + }, + { + "id": "cj8atf53q95lhahebg8g", + "enabled": true + }, + { + "id": "cj8e9s53q95gsdbb054g", + "enabled": true + }, + { + "id": "cj8e9gt3q95gsdbb0170", + "enabled": true + }, + { + "id": "cj86l0l3q95rrpfea6ug", + "enabled": true + } + ], + "event_enabled": true + }, + "schedule": { + "interval": "1m" + }, + "creator": { + "name": "$[[SETUP_USERNAME]]", + "id": "$[[SETUP_USER_ID]]" + } +} +POST $[[SETUP_INDEX_PREFIX]]alert-rule/$[[SETUP_DOC_TYPE]]/builtin-calaqnh7h710dpnp2bm8 +{ + "id": "builtin-calaqnh7h710dpnp2bm8", + "created": "2022-06-16T04:11:10.242061032Z", + "updated": "2023-08-09T22:38:55.677122718+08:00", + "name": "JVM 使用率过高", + "enabled": true, + "resource": { + "resource_id": "$[[SETUP_RESOURCE_ID]]", + "resource_name": "$[[SETUP_RESOURCE_NAME]]", + "type": "elasticsearch", + "objects": [ + ".infini_metrics*" + ], + "filter": {}, + "raw_filter": { + "bool": { + "must": [ + { + "term": { + "metadata.name": { + "value": "node_stats" + } + } + }, + { + "term": { + "metadata.category": { + "value": "elasticsearch" + } + } + } + ] + } + }, + "time_field": "timestamp", + "context": { + "fields": null + } + }, + "metrics": { + "bucket_size": "1m", + "groups": [ + { + "field": "metadata.labels.cluster_id", + "limit": 5 + }, + { + "field": "metadata.labels.node_id", + "limit": 300 + } + ], + "formula": "a", + "items": [ + { + "name": "a", + "field": "payload.elasticsearch.node_stats.jvm.mem.heap_used_percent", + "statistic": "p90" + } + ], + "format_type": "ratio", + "bucket_label": { + "enabled": false + }, + "expression": "p90(payload.elasticsearch.node_stats.jvm.mem.heap_used_percent)" + }, + "conditions": { + "operator": "any", + "items": [ + { + "minimum_period_match": 1, + "operator": "gte", + "values": [ + "80" + ], + "priority": "low" + }, + { + "minimum_period_match": 1, + "operator": "gte", + "values": [ + "90" + ], + "priority": "medium" + }, + { + "minimum_period_match": 1, + "operator": "gte", + "values": [ + "95" + ], + "priority": "high" + } + ] + }, + "notification_config": { + "enabled": true, + "title": "🔥 节点 JVM 使用率过高(共 {{len .results}} 个节点)>= {{.first_threshold}}%", + "message": "{{range .results}}\n{{$cn := lookup \"category=metadata, object=cluster, property=name, default=未知\" (index .group_values 0) }}\n{{$nn := lookup \"category=metadata, object=node, property=metadata.node_name, default=未知\" (index .group_values 1) }}\n{{$nu := printf \"%s/#/cluster/monitor/%s/nodes/%s\" $.env.INFINI_CONSOLE_ENDPOINT (index .group_values 0) (index .group_values 1)}}\n{{$cu := printf \"%s/#/cluster/monitor/elasticsearch/%s\" $.env.INFINI_CONSOLE_ENDPOINT (index .group_values 0)}}\n节点: [{{$nn}}]({{$nu}}?_g=%7B%22timeRange%22:%7B%22min%22:%22{{$.min}}%22%2C%22max%22:%22{{$.max}}%22%7D%2C%22cluster_name%22:%22{{$cn | urlquery}}%22%2C%22node_name%22:%22{{$nn}}%22%7D) 所属集群: [{{$cn}}]({{$cu}}?_g=%7B%22timeRange%22:%7B%22min%22:%22{{$.min}}%22%2C%22max%22:%22{{$.max}}%22%7D%7D), JVM 使用率: {{.result_value | to_fixed 2}}%\n{{end}}", + "normal": [ + { + "id": "cgnb2nt3q95nmusjl65g", + "created": "2023-04-06T11:47:43.104108279Z", + "updated": "2023-08-06T15:46:34.404507399+08:00", + "name": "Slack 消息通知", + "type": "webhook", + "webhook": { + "header_params": { + "Content-type": "application/json" + }, + "method": "POST", + "body": "{\n \"blocks\": [\n {\n \"type\": \"section\",\n \"text\": {\n \"type\": \"mrkdwn\",\n \"text\": \"*{{if eq .priority \"critical\"}} :fire: {{else if eq .priority \"error\"}} :rotating_light: {{else}} :warning: {{end}} 告警事件 <{{$.env.INFINI_CONSOLE_ENDPOINT}}/#/alerting/message/{{.event_id}}|#{{.event_id}}> 正在发生*\\n :point_right: *{{.rule_name}} - {{.title}}*\"\n }\n },\n {\n \"type\": \"section\",\n \"text\": {\n \"type\": \"mrkdwn\",\n \"text\": \"*触发时间:* {{.timestamp | datetime}}\"\n }\n },\n {\n \"type\": \"section\",\n \"text\": {\n \"type\": \"mrkdwn\",\n \"text\": \"*优先级:* {{.priority}}\"\n }\n },\n {\n \"type\": \"divider\"\n }\n ]\n {{if gt (len .results) 0}}\n ,\"attachments\": [\n {{range .results}}\n {\n \"color\": {{if eq .priority \"critical\"}} \"#C91010\" {{else if eq .priority \"error\"}} \"#EB4C21\" {{else}} \"#FFB449\" {{end}},\n \"blocks\": [\n {\n \"type\": \"section\",\n \"text\": {\n \"type\": \"mrkdwn\",\n \"text\": \"节点: <{{$.env.INFINI_CONSOLE_ENDPOINT}}/#/cluster/monitor/{{index .group_values 0}}/nodes/{{index .group_values 1}}?_g={%22timeRange%22:%7B%22min%22:%22{{$.min}}%22%2C%22max%22:%22{{$.max}}%22%7D%2C%22cluster_name%22:%22{{lookup \"category=metadata, object=cluster, property=name, default=未知\" (index .group_values 0) }}%22%2C%22node_name%22:%22{{lookup \"category=metadata, object=node, property=metadata.node_name, default=未知\" (index .group_values 1) }}%22} | {{lookup \"category=metadata, object=node, property=metadata.node_name, default=未知\" (index .group_values 1) }}> 所属集群: <{{$.env.INFINI_CONSOLE_ENDPOINT}}/#/cluster/monitor/elasticsearch/{{index .group_values 0}}?_g={%22timeRange%22:%7B%22min%22:%22{{$.min}}%22%2C%22max%22:%22{{$.max}}%22%7D%2C%22cluster_name%22:%22{{lookup \"category=metadata, object=cluster, property=name, default=未知\" (index .group_values 0) }}%22} | {{lookup \"category=metadata, object=cluster, property=name, default=未知\" (index .group_values 0) }}>, JVM 使用率: {{.result_value | to_fixed 2}}%\"\n }\n }\n ]\n },\n {{end}}\n {\n \"blocks\": [\n {\n \"type\": \"divider\"\n },\n {\n \"type\": \"actions\",\n \"elements\": [\n {\n \"type\": \"button\",\n \"text\": {\n \"type\": \"plain_text\",\n \"text\": \"查看告警\" \n },\n \"url\": \"{{$.env.INFINI_CONSOLE_ENDPOINT}}/#/alerting/message/{{.event_id}}\"\n }\n ]\n },\n ]\n }\n ]\n {{end}}\n}" + }, + "sub_type": "slack", + "enabled": true + }, + { + "id": "cgiospt3q95q49k3u00g", + "enabled": true + }, + { + "id": "cj865st3q95rega919ig", + "enabled": true + }, + { + "id": "cgnb2r53q95nmusjl6vg", + "enabled": true + }, + { + "id": "ch1os6t3q95lk6lepkq0", + "enabled": true + }, + { + "id": "cgnb2kt3q95nmusjl64g", + "enabled": true + } + ], + "throttle_period": "1h", + "accept_time_range": { + "start": "00:00", + "end": "23:59" + } + }, + "category": "Platform", + "recovery_notification_config": { + "enabled": true, + "title": "🌈 {{.rule_name}} 已恢复", + "message": "事件编号: {{.event_id}} \n目标: {{.resource_name}}-{{.objects}} \n触发时间: {{.trigger_at | datetime}} \n恢复时间: {{.timestamp | datetime}} \n持续时长: {{.duration}}{{if .recovery_context}} \n{{.recovery_context}}{{end}}", + "normal": [ + { + "id": "cj8bq8d3q95ogankugqg", + "enabled": true + }, + { + "id": "cj8ctat3q95l9ebbntlg", + "enabled": true + }, + { + "id": "cj8atf53q95lhahebg8g", + "enabled": true + }, + { + "id": "cj8e9s53q95gsdbb054g", + "enabled": true + }, + { + "id": "cj8e9gt3q95gsdbb0170", + "enabled": true + }, + { + "id": "cj86l0l3q95rrpfea6ug", + "enabled": true + } + ], + "event_enabled": true + }, + "schedule": { + "interval": "1m" + }, + "creator": { + "name": "$[[SETUP_USERNAME]]", + "id": "$[[SETUP_USER_ID]]" + } +} +POST $[[SETUP_INDEX_PREFIX]]alert-rule/$[[SETUP_DOC_TYPE]]/builtin-calakp97h710dpnp1fa2 +{ + "id": "builtin-calakp97h710dpnp1fa2", + "created": "2022-06-16T03:58:29.437447113Z", + "updated": "2023-08-09T22:33:25.692835454+08:00", + "name": "CPU 使用率过高", + "enabled": true, + "resource": { + "resource_id": "$[[SETUP_RESOURCE_ID]]", + "resource_name": "$[[SETUP_RESOURCE_NAME]]", + "type": "elasticsearch", + "objects": [ + ".infini_metrics*" + ], + "filter": {}, + "raw_filter": { + "bool": { + "must": [ + { + "term": { + "metadata.name": { + "value": "node_stats" + } + } + }, + { + "term": { + "metadata.category": { + "value": "elasticsearch" + } + } + } + ] + } + }, + "time_field": "timestamp", + "context": { + "fields": null + } + }, + "metrics": { + "bucket_size": "1m", + "groups": [ + { + "field": "metadata.labels.cluster_id", + "limit": 5 + }, + { + "field": "metadata.labels.node_id", + "limit": 300 + } + ], + "formula": "a", + "items": [ + { + "name": "a", + "field": "payload.elasticsearch.node_stats.process.cpu.percent", + "statistic": "avg" + } + ], + "format_type": "ratio", + "bucket_label": { + "enabled": false + }, + "expression": "avg(payload.elasticsearch.node_stats.process.cpu.percent)" + }, + "conditions": { + "operator": "any", + "items": [ + { + "minimum_period_match": 1, + "operator": "gte", + "values": [ + "80" + ], + "priority": "low" + }, + { + "minimum_period_match": 1, + "operator": "gte", + "values": [ + "90" + ], + "priority": "medium" + }, + { + "minimum_period_match": 1, + "operator": "gte", + "values": [ + "95" + ], + "priority": "high" + } + ] + }, + "notification_config": { + "enabled": true, + "title": "🔥 节点 CPU 使用率过高(共 {{len .results}} 个节点)>= {{.first_threshold}}%", + "message": "{{range .results}}\n{{$cn := lookup \"category=metadata, object=cluster, property=name, default=未知\" (index .group_values 0) }}\n{{$nn := lookup \"category=metadata, object=node, property=metadata.node_name, default=未知\" (index .group_values 1) }}\n{{$nu := printf \"%s/#/cluster/monitor/%s/nodes/%s\" $.env.INFINI_CONSOLE_ENDPOINT (index .group_values 0) (index .group_values 1)}}\n{{$cu := printf \"%s/#/cluster/monitor/elasticsearch/%s\" $.env.INFINI_CONSOLE_ENDPOINT (index .group_values 0)}}\n节点: [{{$nn}}]({{$nu}}?_g=%7B%22timeRange%22:%7B%22min%22:%22{{$.min}}%22%2C%22max%22:%22{{$.max}}%22%7D%2C%22cluster_name%22:%22{{$cn | urlquery}}%22%2C%22node_name%22:%22{{$nn}}%22%7D) 所属集群: [{{$cn}}]({{$cu}}?_g=%7B%22timeRange%22:%7B%22min%22:%22{{$.min}}%22%2C%22max%22:%22{{$.max}}%22%7D%7D), CPU 使用率: {{.result_value | to_fixed 2}}%\n{{end}}", + "normal": [ + { + "id": "cgnb2nt3q95nmusjl65g", + "created": "2023-04-06T11:47:43.104108279Z", + "updated": "2023-08-07T15:17:26.18861218+08:00", + "name": "[告警] Slack 消息通知", + "type": "webhook", + "webhook": { + "header_params": { + "Content-type": "application/json" + }, + "method": "POST", + "body": "{\n \"blocks\": [\n {\n \"type\": \"section\",\n \"text\": {\n \"type\": \"mrkdwn\",\n \"text\": \"*{{if eq .priority \"critical\"}} :fire: {{else if eq .priority \"error\"}} :rotating_light: {{else}} :warning: {{end}} 告警事件 <{{$.env.INFINI_CONSOLE_ENDPOINT}}/#/alerting/message/{{.event_id}}|#{{.event_id}}> 正在发生*\\n :point_right: *{{.rule_name}} - {{.title}}*\"\n }\n },\n {\n \"type\": \"section\",\n \"text\": {\n \"type\": \"mrkdwn\",\n \"text\": \"*触发时间:* {{.timestamp | datetime}}\"\n }\n },\n {\n \"type\": \"section\",\n \"text\": {\n \"type\": \"mrkdwn\",\n \"text\": \"*优先级:* {{.priority}}\"\n }\n },\n {\n \"type\": \"divider\"\n }\n ]\n {{if gt (len .results) 0}}\n ,\"attachments\": [\n {{range .results}}\n {\n \"color\": {{if eq .priority \"critical\"}} \"#C91010\" {{else if eq .priority \"error\"}} \"#EB4C21\" {{else}} \"#FFB449\" {{end}},\n \"blocks\": [\n {\n \"type\": \"section\",\n \"text\": {\n \"type\": \"mrkdwn\",\n \"text\": \"节点: <{{$.env.INFINI_CONSOLE_ENDPOINT}}/#/cluster/monitor/{{index .group_values 0}}/nodes/{{index .group_values 1}}?_g={%22timeRange%22:%7B%22min%22:%22{{$.min}}%22%2C%22max%22:%22{{$.max}}%22%7D%2C%22cluster_name%22:%22{{lookup \"category=metadata, object=cluster, property=name, default=未知\" (index .group_values 0) }}%22%2C%22node_name%22:%22{{lookup \"category=metadata, object=node, property=metadata.node_name, default=未知\" (index .group_values 1) }}%22} | {{lookup \"category=metadata, object=node, property=metadata.node_name, default=未知\" (index .group_values 1) }}> 所属集群: <{{$.env.INFINI_CONSOLE_ENDPOINT}}/#/cluster/monitor/elasticsearch/{{index .group_values 0}}?_g={%22timeRange%22:%7B%22min%22:%22{{$.min}}%22%2C%22max%22:%22{{$.max}}%22%7D%2C%22cluster_name%22:%22{{lookup \"category=metadata, object=cluster, property=name, default=未知\" (index .group_values 0) }}%22} | {{lookup \"category=metadata, object=cluster, property=name, default=未知\" (index .group_values 0) }}>, CPU 使用率: {{.result_value | to_fixed 2}}%\"\n }\n }\n ]\n },\n {{end}}\n {\n \"blocks\": [\n {\n \"type\": \"divider\"\n },\n {\n \"type\": \"actions\",\n \"elements\": [\n {\n \"type\": \"button\",\n \"text\": {\n \"type\": \"plain_text\",\n \"text\": \"查看告警\" \n },\n \"url\": \"{{$.env.INFINI_CONSOLE_ENDPOINT}}/#/alerting/message/{{.event_id}}\"\n }\n ]\n },\n ]\n }\n ]\n {{end}}\n}" + }, + "sub_type": "slack", + "enabled": true + }, + { + "id": "cgiospt3q95q49k3u00g", + "enabled": true + }, + { + "id": "cj865st3q95rega919ig", + "enabled": true + }, + { + "id": "cgnb2r53q95nmusjl6vg", + "enabled": true + }, + { + "id": "ch1os6t3q95lk6lepkq0", + "enabled": true + }, + { + "id": "cgnb2kt3q95nmusjl64g", + "enabled": true + } + ], + "throttle_period": "6h", + "accept_time_range": { + "start": "00:00", + "end": "23:59" + } + }, + "category": "Platform", + "recovery_notification_config": { + "enabled": true, + "title": "🌈 {{.rule_name}} 已恢复", + "message": "事件编号: {{.event_id}} \n目标: {{.resource_name}}-{{.objects}} \n触发时间: {{.trigger_at | datetime}} \n恢复时间: {{.timestamp | datetime}} \n持续时长: {{.duration}}{{if .recovery_context}} \n{{.recovery_context}}{{end}}", + "normal": [ + { + "id": "cj8bq8d3q95ogankugqg", + "enabled": true + }, + { + "id": "cj8ctat3q95l9ebbntlg", + "enabled": true + }, + { + "id": "cj8atf53q95lhahebg8g", + "enabled": true + }, + { + "id": "cj8e9s53q95gsdbb054g", + "enabled": true + }, + { + "id": "cj8e9gt3q95gsdbb0170", + "enabled": true + }, + { + "id": "cj86l0l3q95rrpfea6ug", + "enabled": true + } + ], + "event_enabled": true + }, + "schedule": { + "interval": "1m" + }, + "creator": { + "name": "$[[SETUP_USERNAME]]", + "id": "$[[SETUP_USER_ID]]" + } +} +POST $[[SETUP_INDEX_PREFIX]]alert-rule/$[[SETUP_DOC_TYPE]]/builtin-calgapp7h710dpnpbeb6 +{ + "id": "builtin-calgapp7h710dpnpbeb6", + "created": "2022-06-16T10:26:47.360988761Z", + "updated": "2023-08-09T22:37:44.038127695+08:00", + "name": "分片存储 >= 55G", + "enabled": true, + "resource": { + "resource_id": "$[[SETUP_RESOURCE_ID]]", + "resource_name": "$[[SETUP_RESOURCE_NAME]]", + "type": "elasticsearch", + "objects": [ + ".infini_metrics*" + ], + "filter": {}, + "raw_filter": { + "range": { + "payload.elasticsearch.index_stats.shard_info.store_in_bytes": { + "gte": 59055800320 + } + } + }, + "time_field": "timestamp", + "context": { + "fields": null + } + }, + "metrics": { + "bucket_size": "1m", + "groups": [ + { + "field": "metadata.labels.cluster_id", + "limit": 5 + }, + { + "field": "metadata.labels.index_name", + "limit": 500 + } + ], + "formula": "a", + "items": [ + { + "name": "a", + "field": "payload.elasticsearch.index_stats.shard_info.store_in_bytes", + "statistic": "max" + } + ], + "format_type": "bytes", + "bucket_label": { + "enabled": false + }, + "expression": "max(payload.elasticsearch.index_stats.shard_info.store_in_bytes)" + }, + "conditions": { + "operator": "any", + "items": [ + { + "minimum_period_match": 1, + "operator": "gte", + "values": [ + "59055800320" + ], + "priority": "high" + } + ] + }, + "notification_config": { + "enabled": true, + "title": "🔥 分片存储超过 55GB(共 {{len .results}} 个索引)", + "message": "{{range .results}}\n{{$cn := lookup \"category=metadata, object=cluster, property=name, default=未知\" (index .group_values 0) }}\n{{$iu := printf \"%s/#/cluster/monitor/%s/indices/%s\" $.env.INFINI_CONSOLE_ENDPOINT (index .group_values 0) (index .group_values 1)}}\n{{$cu := printf \"%s/#/cluster/monitor/elasticsearch/%s\" $.env.INFINI_CONSOLE_ENDPOINT (index .group_values 0)}}\n索引: [{{index .group_values 1}}]({{$iu}}?_g=%7B%22timeRange%22:%7B%22min%22:%22{{$.min}}%22%2C%22max%22:%22{{$.max}}%22%7D%2C%22cluster_name%22:%22{{$cn | urlquery}}%22%7D) 所属集群: [{{$cn}}]({{$cu}}?_g=%7B%22timeRange%22:%7B%22min%22:%22{{$.min}}%22%2C%22max%22:%22{{$.max}}%22%7D%7D), 最大分片存储: {{.result_value | format_bytes 2}}\n{{end}}", + "normal": [ + { + "id": "cgnb2nt3q95nmusjl65g", + "created": "2023-04-06T11:47:43.104108279Z", + "updated": "2023-08-07T14:02:53.734855705+08:00", + "name": "[告警] Slack 消息通知", + "type": "webhook", + "webhook": { + "header_params": { + "Content-type": "application/json" + }, + "method": "POST", + "body": "{\n \"blocks\": [\n {\n \"type\": \"section\",\n \"text\": {\n \"type\": \"mrkdwn\",\n \"text\": \"*{{if eq .priority \"critical\"}} :fire: {{else if eq .priority \"error\"}} :rotating_light: {{else}} :warning: {{end}} 告警事件 <{{$.env.INFINI_CONSOLE_ENDPOINT}}/#/alerting/message/{{.event_id}}|#{{.event_id}}> 正在发生*\\n :point_right: *{{.rule_name}} - {{.title}}*\"\n }\n },\n {\n \"type\": \"section\",\n \"text\": {\n \"type\": \"mrkdwn\",\n \"text\": \"*触发时间:* {{.timestamp | datetime}}\"\n }\n },\n {\n \"type\": \"section\",\n \"text\": {\n \"type\": \"mrkdwn\",\n \"text\": \"*优先级:* {{.priority}}\"\n }\n },\n {\n \"type\": \"divider\"\n }\n ]\n {{if gt (len .results) 0}}\n ,\"attachments\": [\n {{range .results}}\n {\n \"color\": {{if eq .priority \"critical\"}} \"#C91010\" {{else if eq .priority \"error\"}} \"#EB4C21\" {{else}} \"#FFB449\" {{end}},\n \"blocks\": [\n {\n \"type\": \"section\",\n \"text\": {\n \"type\": \"mrkdwn\",\n \"text\": \"索引: <{{$.env.INFINI_CONSOLE_ENDPOINT}}#/cluster/monitor/{{ index .group_values 0}}/indices/{{ index .group_values 1}}?_g={%22timeRange%22:%7B%22min%22:%22{{$.min}}%22%2C%22max%22:%22{{$.max}}%22%7D%2C%22cluster_name%22:%22{{ lookup \"category=metadata, object=cluster, property=name, default=未知\" (index .group_values 0)}}%22} | {{index .group_values 1}}> 所属集群: <{{$.env.INFINI_CONSOLE_ENDPOINT}}/#/cluster/monitor/elasticsearch/{{index .group_values 0}}?_g={%22timeRange%22:%7B%22min%22:%22{{$.min}}%22%2C%22max%22:%22{{$.max}}%22%7D%2C%22cluster_name%22:%22{{lookup \"category=metadata, object=cluster, property=name, default=未知\" (index .group_values 0) }}%22} | {{lookup \"category=metadata, object=cluster, property=name, default=未知\" (index .group_values 0) }}>, 最大分片存储: {{.result_value | format_bytes 2}}\"\n }\n }\n ]\n },\n {{end}}\n {\n \"blocks\": [\n {\n \"type\": \"divider\"\n },\n {\n \"type\": \"actions\",\n \"elements\": [\n {\n \"type\": \"button\",\n \"text\": {\n \"type\": \"plain_text\",\n \"text\": \"查看告警\" \n },\n \"url\": \"{{$.env.INFINI_CONSOLE_ENDPOINT}}/#/alerting/message/{{.event_id}}\"\n }\n ]\n },\n ]\n }\n ]\n {{end}}\n}" + }, + "sub_type": "slack", + "enabled": true + }, + { + "id": "cj865st3q95rega919ig", + "enabled": true + }, + { + "id": "cgiospt3q95q49k3u00g", + "enabled": true + }, + { + "id": "cgnb2r53q95nmusjl6vg", + "enabled": true + }, + { + "id": "ch1os6t3q95lk6lepkq0", + "enabled": true + }, + { + "id": "cgnb2kt3q95nmusjl64g", + "enabled": true + } + ], + "throttle_period": "1h", + "accept_time_range": { + "start": "00:00", + "end": "23:59" + } + }, + "category": "Platform", + "recovery_notification_config": { + "enabled": true, + "title": "🌈 {{.rule_name}} 已恢复", + "message": "- 事件编号: {{.event_id}}\n- 目标: {{.resource_name}}-{{.objects}}\n- 触发时间: {{.trigger_at | datetime}}\n- 恢复时间: {{.timestamp | datetime}}\n- 持续时长: {{.duration}}{{if .recovery_context}}\n{{.recovery_context}}{{end}}", + "normal": [ + { + "id": "cj8bq8d3q95ogankugqg", + "enabled": true + }, + { + "id": "cj8ctat3q95l9ebbntlg", + "enabled": true + }, + { + "id": "cj8atf53q95lhahebg8g", + "enabled": true + }, + { + "id": "cj8e9s53q95gsdbb054g", + "enabled": true + }, + { + "id": "cj8e9gt3q95gsdbb0170", + "enabled": true + }, + { + "id": "cj86l0l3q95rrpfea6ug", + "enabled": true + } + ], + "event_enabled": true + }, + "schedule": { + "interval": "1m" + }, + "creator": { + "name": "$[[SETUP_USERNAME]]", + "id": "$[[SETUP_USER_ID]]" + } +} +POST $[[SETUP_INDEX_PREFIX]]alert-rule/$[[SETUP_DOC_TYPE]]/builtin-cal8n7p7h710dpnogps1 +{ + "id": "builtin-cal8n7p7h710dpnogps1", + "created": "2022-06-16T03:11:01.445958361Z", + "updated": "2023-08-10T17:16:34.900352415+08:00", + "name": "磁盘使用率过高", + "enabled": true, + "resource": { + "resource_id": "$[[SETUP_RESOURCE_ID]]", + "resource_name": "$[[SETUP_RESOURCE_NAME]]", + "type": "elasticsearch", + "objects": [ + ".infini_metrics*" + ], + "filter": {}, + "raw_filter": { + "bool": { + "must": [ + { + "term": { + "metadata.name": { + "value": "node_stats" + } + } + }, + { + "term": { + "metadata.category": { + "value": "elasticsearch" + } + } + } + ] + } + }, + "time_field": "timestamp", + "context": { + "fields": null + } + }, + "metrics": { + "bucket_size": "1m", + "groups": [ + { + "field": "metadata.labels.cluster_id", + "limit": 5 + }, + { + "field": "metadata.labels.node_id", + "limit": 200 + } + ], + "formula": "((a-b)/a)*100", + "items": [ + { + "name": "a", + "field": "payload.elasticsearch.node_stats.fs.data.total_in_bytes", + "statistic": "max" + }, + { + "name": "b", + "field": "payload.elasticsearch.node_stats.fs.data.free_in_bytes", + "statistic": "max" + } + ], + "format_type": "ratio", + "bucket_label": { + "enabled": false + }, + "expression": "((max(payload.elasticsearch.node_stats.fs.data.total_in_bytes)-max(payload.elasticsearch.node_stats.fs.data.free_in_bytes))/max(payload.elasticsearch.node_stats.fs.data.total_in_bytes))*100" + }, + "conditions": { + "operator": "any", + "items": [ + { + "minimum_period_match": 5, + "operator": "gte", + "values": [ + "80" + ], + "priority": "low" + }, + { + "minimum_period_match": 5, + "operator": "gte", + "values": [ + "90" + ], + "priority": "medium" + }, + { + "minimum_period_match": 5, + "operator": "gte", + "values": [ + "95" + ], + "priority": "high" + } + ] + }, + "notification_config": { + "enabled": true, + "title": "🔥 节点磁盘使用率过高(共 {{len .results}} 个节点)>= {{.first_threshold}}%", + "message": "{{range .results}}\n{{$cn := lookup \"category=metadata, object=cluster, property=name, default=未知\" (index .group_values 0) }}\n{{$nn := lookup \"category=metadata, object=node, property=metadata.node_name, default=未知\" (index .group_values 1) }}\n{{$nu := printf \"%s/#/cluster/monitor/%s/nodes/%s\" $.env.INFINI_CONSOLE_ENDPOINT (index .group_values 0) (index .group_values 1)}}\n{{$cu := printf \"%s/#/cluster/monitor/elasticsearch/%s\" $.env.INFINI_CONSOLE_ENDPOINT (index .group_values 0)}}\n节点: [{{$nn}}]({{$nu}}?_g=%7B%22timeRange%22:%7B%22min%22:%22{{$.min}}%22%2C%22max%22:%22{{$.max}}%22%7D%2C%22cluster_name%22:%22{{$cn | urlquery}}%22%2C%22node_name%22:%22{{$nn}}%22%7D) 所属集群: [{{$cn}}]({{$cu}}?_g=%7B%22timeRange%22:%7B%22min%22:%22{{$.min}}%22%2C%22max%22:%22{{$.max}}%22%7D%7D), 使用率: {{.result_value | to_fixed 2}}% / 可用: {{.relation_values.b | format_bytes 2}}\n{{end}}", + "normal": [ + { + "id": "cgnb2nt3q95nmusjl65g", + "name": "", + "type": "webhook", + "webhook": { + "header_params": { + "Content-type": "application/json" + }, + "method": "POST", + "body": "{\n \"blocks\": [\n {\n \"type\": \"section\",\n \"text\": {\n \"type\": \"mrkdwn\",\n \"text\": \"*{{if eq .priority \"critical\"}} :fire: {{else if eq .priority \"error\"}} :rotating_light: {{else}} :warning: {{end}} 告警事件 <{{$.env.INFINI_CONSOLE_ENDPOINT}}/#/alerting/message/{{.event_id}}|#{{.event_id}}> 正在发生*\\n :point_right: *{{.rule_name}} - {{.title}}*\"\n }\n },\n {\n \"type\": \"section\",\n \"text\": {\n \"type\": \"mrkdwn\",\n \"text\": \"*触发时间:* {{.timestamp | datetime}}\"\n }\n },\n {\n \"type\": \"section\",\n \"text\": {\n \"type\": \"mrkdwn\",\n \"text\": \"*优先级:* {{.priority}}\"\n }\n },\n {\n \"type\": \"divider\"\n }\n ]\n {{if gt (len .results) 0}}\n ,\"attachments\": [\n {{range .results}}\n {\n \"color\": {{if eq .priority \"critical\"}} \"#C91010\" {{else if eq .priority \"error\"}} \"#EB4C21\" {{else}} \"#FFB449\" {{end}},\n \"blocks\": [\n {\n \"type\": \"section\",\n \"text\": {\n \"type\": \"mrkdwn\",\n \"text\": \"节点: <{{$.env.INFINI_CONSOLE_ENDPOINT}}/#/cluster/overview/{{index .group_values 0}}/nodes/{{index .group_values 1}}?_g={%22cluster_name%22:%22{{lookup \"category=metadata, object=cluster, property=name, default=未知\" (index .group_values 0) }}%22%2C%22node_name%22:%22{{lookup \"category=metadata, object=node, property=metadata.node_name, default=未知\" (index .group_values 1) }}%22} | {{lookup \"category=metadata, object=node, property=metadata.node_name, default=未知\" (index .group_values 1) }}> 所属集群: <{{$.env.INFINI_CONSOLE_ENDPOINT}}/#/cluster/monitor/elasticsearch/{{index .group_values 0}} | {{lookup \"category=metadata, object=cluster, property=name, default=未知\" (index .group_values 0) }}>, 磁盘使用率: {{.result_value | to_fixed 2}}%, 可用: {{.relation_values.b | format_bytes 2}}\"\n }\n }\n ]\n },\n {{end}}\n {\n \"blocks\": [\n {\n \"type\": \"divider\"\n },\n {\n \"type\": \"actions\",\n \"elements\": [\n {\n \"type\": \"button\",\n \"text\": {\n \"type\": \"plain_text\",\n \"text\": \"查看告警\" \n },\n \"url\": \"{{$.env.INFINI_CONSOLE_ENDPOINT}}/#/alerting/message/{{.event_id}}\"\n },\n {\n \"type\": \"button\",\n \"text\": {\n \"type\": \"plain_text\",\n \"text\": \"查看文档\" \n },\n \"style\": \"primary\",\n \"url\": \"https://www.elastic.co/guide/en/elasticsearch/reference/current/modules-cluster.html#disk-based-shard-allocation\"\n }\n ]\n },\n ]\n }\n ]\n {{end}}\n}" + }, + "sub_type": "", + "enabled": true + }, + { + "id": "cgiospt3q95q49k3u00g", + "enabled": true + }, + { + "id": "cj865st3q95rega919ig", + "enabled": true + }, + { + "id": "cgnb2r53q95nmusjl6vg", + "enabled": true + }, + { + "id": "ch1os6t3q95lk6lepkq0", + "enabled": true + }, + { + "id": "cgnb2kt3q95nmusjl64g", + "enabled": true + } + ], + "throttle_period": "6h", + "accept_time_range": { + "start": "00:00", + "end": "23:59" + } + }, + "category": "Platform", + "recovery_notification_config": { + "enabled": true, + "title": "🌈 [{{.rule_name}}] 已恢复", + "message": "事件编号: {{.event_id}} \n目标: {{.resource_name}}-{{.objects}} \n触发时间: {{.trigger_at | datetime}} \n恢复时间: {{.timestamp | datetime}} \n持续时长: {{.duration}}{{if .recovery_context}} \n{{.recovery_context}}{{end}}", + "normal": [ + { + "id": "cj8bq8d3q95ogankugqg", + "enabled": true + }, + { + "id": "cj8ctat3q95l9ebbntlg", + "enabled": true + }, + { + "id": "cj8atf53q95lhahebg8g", + "enabled": true + }, + { + "id": "cj8e9s53q95gsdbb054g", + "enabled": true + }, + { + "id": "cj8e9gt3q95gsdbb0170", + "enabled": true + }, + { + "id": "cj86l0l3q95rrpfea6ug", + "enabled": true + } + ], + "event_enabled": true + }, + "schedule": { + "interval": "1m" + }, + "creator": { + "name": "$[[SETUP_USERNAME]]", + "id": "$[[SETUP_USER_ID]]" + } +} +POST $[[SETUP_INDEX_PREFIX]]alert-rule/$[[SETUP_DOC_TYPE]]/builtin-cujivv5ath26drn6bcl0 +{ + "id": "builtin-cujivv5ath26drn6bcl0", + "created": "2025-02-08T18:20:44.273334+08:00", + "updated": "2025-02-12T16:31:05.672771+08:00", + "name": "集群指标采集异常", + "enabled": true, + "resource": { + "resource_id": "$[[SETUP_RESOURCE_ID]]", + "resource_name": "$[[SETUP_RESOURCE_NAME]]", + "type": "elasticsearch", + "objects": [ + ".infini_metrics*" + ], + "filter": {}, + "raw_filter": { + "bool": { + "must": [ + { + "terms": { + "metadata.name": [ + "cluster_health", + "cluster_stats", + "index_stats", + "node_stats", + "shard_stats" + ] + } + } + ] + } + }, + "time_field": "timestamp", + "context": { + "fields": null + } + }, + "metrics": { + "bucket_size": "1m", + "groups": [ + { + "field": "metadata.labels.cluster_id", + "limit": 5 + }, + { + "field": "metadata.name", + "limit": 5 + } + ], + "formula": "a", + "items": [ + { + "name": "a", + "field": "agent.id", + "statistic": "count" + } + ], + "bucket_label": { + "enabled": false + }, + "expression": "count(agent.id)" + }, + "bucket_conditions": { + "operator": "any", + "items": [ + { + "minimum_period_match": 1, + "operator": "lt", + "values": [ + "0" + ], + "priority": "critical", + "type": "content", + "bucket_count": 10 + } + ] + }, + "notification_config": { + "enabled": true, + "title": "🔥 {{.rule_name}} 告警", + "message": "{{range .results}}\n{{$cn := lookup \"category=metadata, object=cluster, property=name, default=未知\" (index .group_values 0) }}\n{{$cu := printf \"%s/#/cluster/monitor/elasticsearch/%s\" $.env.INFINI_CONSOLE_ENDPOINT (index .group_values 0)}}\n集群 [{{$cn}}]({{$cu}}?_g=%7B%22timeRange%22:%7B%22min%22:%22{{$.min}}%22%2C%22max%22:%22{{$.max}}%22%7D%7D) ({{index .group_values 1}}) 指标在 {{.issue_timestamp | datetime}} 发生下降;\n{{end}}", + "normal": [ + { + "id": "cgnb2nt3q95nmusjl65g", + "enabled": true + }, + { + "id": "cgiospt3q95q49k3u00g", + "enabled": true + }, + { + "id": "cj865st3q95rega919ig", + "enabled": true + }, + { + "id": "cgnb2r53q95nmusjl6vg", + "enabled": true + }, + { + "id": "ch1os6t3q95lk6lepkq0", + "enabled": true + }, + { + "id": "cgnb2kt3q95nmusjl64g", + "enabled": true + } + ], + "throttle_period": "6h", + "accept_time_range": { + "start": "00:00", + "end": "23:59" + } + }, + "category": "Platform", + "recovery_notification_config": { + "enabled": true, + "title": "🌈 {{.rule_name}} 已恢复", + "message": "事件编号: {{.event_id}} \n目标: {{.resource_name}}-{{.objects}} \n触发时间: {{.trigger_at | datetime}} \n恢复时间: {{.timestamp | datetime}} \n持续时长: {{.duration}}{{if .recovery_context}} \n{{.recovery_context}}{{end}}", + "normal": [ + { + "id": "cj8bq8d3q95ogankugqg", + "enabled": true + }, + { + "id": "cj8ctat3q95l9ebbntlg", + "enabled": true + }, + { + "id": "cj8atf53q95lhahebg8g", + "enabled": true + }, + { + "id": "cj8e9s53q95gsdbb054g", + "enabled": true + }, + { + "id": "cj8e9gt3q95gsdbb0170", + "enabled": true + }, + { + "id": "cj86l0l3q95rrpfea6ug", + "enabled": true + } + ], + "event_enabled": true + }, + "schedule": { + "interval": "1m" + }, + "creator": { + "name": "$[[SETUP_USERNAME]]", + "id": "$[[SETUP_USER_ID]]" + } +} \ No newline at end of file diff --git a/modules/elastic/api/metrics_util.go b/modules/elastic/api/metrics_util.go index 6d9fbbe2..558017c2 100644 --- a/modules/elastic/api/metrics_util.go +++ b/modules/elastic/api/metrics_util.go @@ -311,6 +311,91 @@ func GetMetricRangeAndBucketSize(minStr string, maxStr string, bucketSize int, m return bucketSize, min, max, nil } +func buildDateHistogramParams(query map[string]interface{}, intervalField, bucketSizeStr string) util.MapStr { + params := util.MapStr{ + "field": "timestamp", + intervalField: bucketSizeStr, + "min_doc_count": 0, + } + if bounds, ok := extractDateHistogramBounds(query); ok { + params["extended_bounds"] = bounds + } + return params +} + +func extractDateHistogramBounds(query map[string]interface{}) (util.MapStr, bool) { + queryMap, ok := asMap(query["query"]) + if !ok { + return nil, false + } + boolMap, ok := asMap(queryMap["bool"]) + if !ok { + return nil, false + } + filters, ok := asSlice(boolMap["filter"]) + if !ok { + return nil, false + } + for _, filterItem := range filters { + filterMap, ok := asMap(filterItem) + if !ok { + continue + } + rangeMap, ok := asMap(filterMap["range"]) + if !ok { + continue + } + timestampRange, ok := asMap(rangeMap["timestamp"]) + if !ok { + continue + } + min := firstNotNil(timestampRange["gte"], timestampRange["from"], timestampRange["gt"]) + max := firstNotNil(timestampRange["lte"], timestampRange["to"], timestampRange["lt"]) + if min != nil && max != nil { + return util.MapStr{ + "min": min, + "max": max, + }, true + } + } + return nil, false +} + +func asMap(value interface{}) (map[string]interface{}, bool) { + switch v := value.(type) { + case map[string]interface{}: + return v, true + case util.MapStr: + return map[string]interface{}(v), true + default: + return nil, false + } +} + +func asSlice(value interface{}) ([]interface{}, bool) { + switch v := value.(type) { + case []interface{}: + return v, true + case []util.MapStr: + result := make([]interface{}, 0, len(v)) + for _, item := range v { + result = append(result, item) + } + return result, true + default: + return nil, false + } +} + +func firstNotNil(values ...interface{}) interface{} { + for _, value := range values { + if value != nil { + return value + } + } + return nil +} + // 获取单个指标,可以包含多条曲线 func (h *APIHandler) getSingleMetrics(ctx context.Context, metricItems []*common.MetricItem, query map[string]interface{}, bucketSize int) (map[string]*common.MetricItem, error) { metricData := map[string][][]interface{}{} @@ -363,11 +448,8 @@ func (h *APIHandler) getSingleMetrics(ctx context.Context, metricItems []*common query["size"] = 0 query["aggs"] = util.MapStr{ "dates": util.MapStr{ - "date_histogram": util.MapStr{ - "field": "timestamp", - intervalField: bucketSizeStr, - }, - "aggs": aggs, + "date_histogram": buildDateHistogramParams(query, intervalField, bucketSizeStr), + "aggs": aggs, }, } queryDSL := util.MustToJSONBytes(query) @@ -1021,11 +1103,8 @@ func (h *APIHandler) getSingleIndexMetricsByNodeStats(ctx context.Context, metri query["size"] = 0 query["aggs"] = util.MapStr{ "dates": util.MapStr{ - "date_histogram": util.MapStr{ - "field": "timestamp", - intervalField: bucketSizeStr, - }, - "aggs": sumAggs, + "date_histogram": buildDateHistogramParams(query, intervalField, bucketSizeStr), + "aggs": sumAggs, }, } return parseSingleIndexMetrics(ctx, term_level, clusterID, metricItems, query, bucketSize, metricData, metricItemsMap) @@ -1116,11 +1195,8 @@ func (h *APIHandler) getSingleIndexMetrics(ctx context.Context, metricItems []*c query["size"] = 0 query["aggs"] = util.MapStr{ "dates": util.MapStr{ - "date_histogram": util.MapStr{ - "field": "timestamp", - intervalField: bucketSizeStr, - }, - "aggs": sumAggs, + "date_histogram": buildDateHistogramParams(query, intervalField, bucketSizeStr), + "aggs": sumAggs, }, } return parseSingleIndexMetrics(ctx, term_level, clusterID, metricItems, query, bucketSize, metricData, metricItemsMap) diff --git a/modules/elastic/api/v1/metrics_util.go b/modules/elastic/api/v1/metrics_util.go index d615d9d0..9abc5bda 100644 --- a/modules/elastic/api/v1/metrics_util.go +++ b/modules/elastic/api/v1/metrics_util.go @@ -371,6 +371,91 @@ func GetMetricRangeAndBucketSize(minStr string, maxStr string, bucketSize int, m return bucketSize, min, max, nil } +func buildDateHistogramParams(query map[string]interface{}, intervalField, bucketSizeStr string) util.MapStr { + params := util.MapStr{ + "field": "timestamp", + intervalField: bucketSizeStr, + "min_doc_count": 0, + } + if bounds, ok := extractDateHistogramBounds(query); ok { + params["extended_bounds"] = bounds + } + return params +} + +func extractDateHistogramBounds(query map[string]interface{}) (util.MapStr, bool) { + queryMap, ok := asMap(query["query"]) + if !ok { + return nil, false + } + boolMap, ok := asMap(queryMap["bool"]) + if !ok { + return nil, false + } + filters, ok := asSlice(boolMap["filter"]) + if !ok { + return nil, false + } + for _, filterItem := range filters { + filterMap, ok := asMap(filterItem) + if !ok { + continue + } + rangeMap, ok := asMap(filterMap["range"]) + if !ok { + continue + } + timestampRange, ok := asMap(rangeMap["timestamp"]) + if !ok { + continue + } + min := firstNotNil(timestampRange["gte"], timestampRange["from"], timestampRange["gt"]) + max := firstNotNil(timestampRange["lte"], timestampRange["to"], timestampRange["lt"]) + if min != nil && max != nil { + return util.MapStr{ + "min": min, + "max": max, + }, true + } + } + return nil, false +} + +func asMap(value interface{}) (map[string]interface{}, bool) { + switch v := value.(type) { + case map[string]interface{}: + return v, true + case util.MapStr: + return map[string]interface{}(v), true + default: + return nil, false + } +} + +func asSlice(value interface{}) ([]interface{}, bool) { + switch v := value.(type) { + case []interface{}: + return v, true + case []util.MapStr: + result := make([]interface{}, 0, len(v)) + for _, item := range v { + result = append(result, item) + } + return result, true + default: + return nil, false + } +} + +func firstNotNil(values ...interface{}) interface{} { + for _, value := range values { + if value != nil { + return value + } + } + return nil +} + // findClosestNiceInterval finds the interval in sortedNiceIntervals that is closest to targetSize. func findClosestNiceInterval(targetSize float64, sortedNiceIntervals []float64) float64 { if len(sortedNiceIntervals) == 0 { @@ -513,11 +598,8 @@ func (h *APIHandler) getSingleMetrics(ctx context.Context, metricItems []*common query["size"] = 0 query["aggs"] = util.MapStr{ "dates": util.MapStr{ - "date_histogram": util.MapStr{ - "field": "timestamp", - intervalField: bucketSizeStr, - }, - "aggs": aggs, + "date_histogram": buildDateHistogramParams(query, intervalField, bucketSizeStr), + "aggs": aggs, }, } queryDSL := util.MustToJSONBytes(query) diff --git a/plugin/setup/setup.go b/plugin/setup/setup.go index be56b662..07fae757 100644 --- a/plugin/setup/setup.go +++ b/plugin/setup/setup.go @@ -315,6 +315,7 @@ type SetupRequest struct { BootstrapPassword string `json:"bootstrap_password"` CredentialSecret string `json:"credential_secret"` InitializeTemplate string `json:"initialize_template"` + Language string `json:"language,omitempty"` PrimaryShards int `json:"primary_shards"` AutoExpandReplicas string `json:"auto_expand_replicas"` } @@ -1136,6 +1137,9 @@ func (module *Module) initializeTemplate(w http.ResponseWriter, r *http.Request, } case "alerting": dslTplFileName = "alerting.tpl" + if localizedTemplate := resolveAlertingTemplateByLanguage(strings.TrimSpace(request.Language)); localizedTemplate != "" { + dslTplFileName = localizedTemplate + } case "insight": dslTplFileName = "insight.tpl" case "view": @@ -1198,6 +1202,10 @@ func (module *Module) initializeTemplate(w http.ResponseWriter, r *http.Request, relayPartitionSize := ResolveRelayPartitionSize(systemClient) dslTplFile := path.Join(baseDir, dslTplFileName) + if request.InitializeTemplate == "alerting" && !util.FileExists(dslTplFile) { + dslTplFileName = "alerting.tpl" + dslTplFile = path.Join(baseDir, dslTplFileName) + } if !util.FileExists(dslTplFile) { panic(errors.Errorf("template file %v for setup was missing", dslTplFile)) } @@ -1207,6 +1215,7 @@ func (module *Module) initializeTemplate(w http.ResponseWriter, r *http.Request, if err != nil { panic(err) } + if len(dsl) == 0 { panic(fmt.Sprintf("got empty template [%s]", dslTplFile)) } @@ -1380,3 +1389,14 @@ func buildIngestRoleBody(indexPrefix string) util.MapStr { }, } } + +func resolveAlertingTemplateByLanguage(language string) string { + switch { + case strings.HasPrefix(strings.ToLower(language), "zh"): + return "alerting.zh-CN.tpl" + case strings.HasPrefix(strings.ToLower(language), "en"): + return "alerting.en-US.tpl" + default: + return "" + } +} diff --git a/service/alerting/elasticsearch/engine.go b/service/alerting/elasticsearch/engine.go index 186df1ce..016ca7cc 100644 --- a/service/alerting/elasticsearch/engine.go +++ b/service/alerting/elasticsearch/engine.go @@ -1150,7 +1150,7 @@ func (engine *Engine) Do(rule *alerting.Rule) error { Status: model.NotificationStatusNew, Title: alertItem.Title, Body: alertItem.Message, - Link: "/alerting/message", + Link: fmt.Sprintf("/alerting/message/%s", alertMessage.ID), } err = orm.Create(orm.NewContext(), notification) if err != nil { diff --git a/web/src/common/src/DatePicker/Range.jsx b/web/src/common/src/DatePicker/Range.jsx index ccfaeabd..4b51d06f 100644 --- a/web/src/common/src/DatePicker/Range.jsx +++ b/web/src/common/src/DatePicker/Range.jsx @@ -229,6 +229,7 @@ const Range = (props) => { const fullRangeText = useMemo(() => { if (isMinimum || !start || !end) return ""; + if (start === 'auto') return currentLocales[`datepicker.quick_select.${start}`]; if (showPrettyDuration(start, end, commonlyUsedRanges)) { return prettyDuration( start, diff --git a/web/src/common/src/DatePicker/Range.less b/web/src/common/src/DatePicker/Range.less index 5b104a38..f15aa2ed 100644 --- a/web/src/common/src/DatePicker/Range.less +++ b/web/src/common/src/DatePicker/Range.less @@ -23,6 +23,7 @@ overflow: hidden; white-space: nowrap; text-overflow: ellipsis; + letter-spacing: 0; } .down { diff --git a/web/src/common/src/DatePicker/TimeSetting.jsx b/web/src/common/src/DatePicker/TimeSetting.jsx index 630092b0..4cbaf878 100644 --- a/web/src/common/src/DatePicker/TimeSetting.jsx +++ b/web/src/common/src/DatePicker/TimeSetting.jsx @@ -55,7 +55,7 @@ const TimeSetting = props => { unit: timeout.replace(`${value}`, ''), } }, [timeout]) - + return (
{currentLocales[`datepicker.time_setting`]}
@@ -155,7 +155,7 @@ const TimeSetting = props => { ) - } + }
)} @@ -166,4 +166,4 @@ const TimeSetting = props => { ); }; -export default TimeSetting; \ No newline at end of file +export default TimeSetting; diff --git a/web/src/common/src/DatePicker/index.jsx b/web/src/common/src/DatePicker/index.jsx index b4292d08..4df44d36 100644 --- a/web/src/common/src/DatePicker/index.jsx +++ b/web/src/common/src/DatePicker/index.jsx @@ -11,6 +11,12 @@ import { toMilliseconds, fromMilliseconds } from "./utils/utils"; import styles from "./index.less"; const DEFAULT_COMMONLY_USED_RANGES = [ + { + start: "auto", + end: "auto", + label: "Auto", + key: "auto", + }, { start: "now/d", end: "now/d", @@ -96,6 +102,7 @@ const DatePicker = (props) => { recentlyUsedRangesKey, onRefreshChange, onRefresh, + showAutoTimeRange = false, } = props; const [prevQuickSelect, setPrevQuickSelect] = useState(); @@ -237,11 +244,12 @@ const DatePicker = (props) => { timeFields={timeFields} showTimeInterval={showTimeInterval} timeInterval={timeInterval} + timeIntervalDisabled={timeIntervalDisabled} showTimeout={showTimeout} timeout={timeout} autoFitLoading={autoFitLoading} timeZone={timeZone} - commonlyUsedRanges={commonlyUsedRanges} + commonlyUsedRanges={commonlyUsedRanges.filter((item) => item.key !== 'auto' || showAutoTimeRange)} recentlyUsedRanges={recentlyUsedRanges} isMinimum={isMinimum} prevQuickSelect={prevQuickSelect} diff --git a/web/src/common/src/DatePicker/index.less b/web/src/common/src/DatePicker/index.less index 9cd96bcc..bbf070cf 100644 --- a/web/src/common/src/DatePicker/index.less +++ b/web/src/common/src/DatePicker/index.less @@ -42,8 +42,8 @@ align-items: center; margin-left: 4px !important; .play { - min-width: 32px; - max-width: 32px; + min-width: 30px; + max-width: 30px; padding: 0; font-size: 14px; color: #1890ff; diff --git a/web/src/common/src/DatePicker/index.md b/web/src/common/src/DatePicker/index.md index 2868cb8d..b4ec9e70 100644 --- a/web/src/common/src/DatePicker/index.md +++ b/web/src/common/src/DatePicker/index.md @@ -32,4 +32,5 @@ | timeZone | 时区 | string | 'Asia/Shanghai' | 1.0.0 | | onTimeZoneChange | 时区变更的回调 | (timeZone: string) => void | - | 1.0.0 | | commonlyUsedRanges | 快速选择列表 | {start: string, end: string, label: string}[] | [] | 1.0.0 | -| recentlyUsedRangesKey | 时间范围历史字段 | string | - | 1.0.0 | \ No newline at end of file +| recentlyUsedRangesKey | 时间范围历史字段 | string | - | 1.0.0 | +| showAutoTimeRange | 是否显示自动 | boolean | false | 1.0.0 | \ No newline at end of file diff --git a/web/src/common/src/DatePicker/locales/en-US.js b/web/src/common/src/DatePicker/locales/en-US.js index 24a57dfc..b846a03a 100644 --- a/web/src/common/src/DatePicker/locales/en-US.js +++ b/web/src/common/src/DatePicker/locales/en-US.js @@ -1,4 +1,5 @@ export default { + "datepicker.quick_select.auto": "Auto", "datepicker.quick_select.auto_fit": "Auto fit", "datepicker.quick_select.today": "Today", "datepicker.quick_select.this_week": "This week", diff --git a/web/src/common/src/DatePicker/locales/zh-CN.js b/web/src/common/src/DatePicker/locales/zh-CN.js index accc0a49..d292d9fe 100644 --- a/web/src/common/src/DatePicker/locales/zh-CN.js +++ b/web/src/common/src/DatePicker/locales/zh-CN.js @@ -1,4 +1,5 @@ export default { + "datepicker.quick_select.auto": "自动", "datepicker.quick_select.auto_fit": "自动适配", "datepicker.quick_select.today": "今天", "datepicker.quick_select.this_week": "这个星期", diff --git a/web/src/components/ListView/index.jsx b/web/src/components/ListView/index.jsx index 724cb344..02250d44 100644 --- a/web/src/components/ListView/index.jsx +++ b/web/src/components/ListView/index.jsx @@ -220,7 +220,12 @@ const Index = forwardRef((props, ref) => { let filter = []; //time range - if (queryParams?.timeRange && queryParams?.timeRange?.timeField) { + if ( + queryParams?.timeRange && + queryParams?.timeRange?.timeField && + queryParams?.timeRange?.from !== "auto" && + queryParams?.timeRange?.to !== "auto" + ) { let range = {}; range[queryParams.timeRange.timeField] = { gte: queryParams.timeRange.from, diff --git a/web/src/pages/Alerting/Message/Index.jsx b/web/src/pages/Alerting/Message/Index.jsx index ecf730d1..5a542d5b 100644 --- a/web/src/pages/Alerting/Message/Index.jsx +++ b/web/src/pages/Alerting/Message/Index.jsx @@ -87,8 +87,8 @@ const Index = (props) => { from: 0, size: 10, // status: "alerting", - start_time: "now-7d", - end_time: "now", + start_time: "auto", + end_time: "auto", ...param, }; @@ -383,7 +383,12 @@ const Index = (props) => { const fetchMessages = (queryParams) => { setLoading(true); let params = queryParams; - if (queryParams?.start_time && queryParams.end_time) { + if ( + queryParams?.start_time && + queryParams.end_time && + queryParams.start_time !== "auto" && + queryParams.end_time !== "auto" + ) { const bounds = calculateBounds({ from: queryParams?.start_time, to: queryParams.end_time, @@ -701,8 +706,8 @@ const Index = (props) => {
{ defaultQueryParams={{ from: 0, size: 20, - timeRange: { from: "now-7d", to: "now", timeField: timeField }, + timeRange: { from: "auto", to: "auto", timeField: timeField }, sort: [[timeField, "desc"]], }} dateTimeEnable={true} diff --git a/web/src/pages/Guide/Initialization/components/Initialization/index.js b/web/src/pages/Guide/Initialization/components/Initialization/index.js index 12d5195b..0f2f6113 100644 --- a/web/src/pages/Guide/Initialization/components/Initialization/index.js +++ b/web/src/pages/Guide/Initialization/components/Initialization/index.js @@ -3,7 +3,7 @@ import { Alert, Button, Divider, Form, Icon, Input, InputNumber, message, Result import styles from './index.less' import request from '@/utils/request'; import { CopyToClipboard } from 'react-copy-to-clipboard'; -import { formatMessage } from "umi/locale"; +import { formatMessage, getLocale } from "umi/locale"; const defaultAutoExpandReplicas = "0-1"; @@ -220,6 +220,7 @@ export default ({ onPrev, onNext, form, formData, onFormDataChange }) => { const body = { cluster, initialize_template: currentTask.name, + language: getLocale(), primary_shards: formData.primary_shards, auto_expand_replicas: formData.auto_expand_replicas, } diff --git a/web/src/pages/System/Audit/index.jsx b/web/src/pages/System/Audit/index.jsx index a9659f50..bde8970c 100644 --- a/web/src/pages/System/Audit/index.jsx +++ b/web/src/pages/System/Audit/index.jsx @@ -153,7 +153,7 @@ export default (props) => { defaultQueryParams={{ from: 0, size: 10, - timeRange: { from: "now-7d", to: "now", timeField: timeField }, + timeRange: { from: "auto", to: "auto", timeField: timeField }, sort: [[timeField, "desc"]], }} dateTimeEnable={true} From 6c878a5d6608e2b1fca194b48a2feace9b17eb93 Mon Sep 17 00:00:00 2001 From: hardy Date: Sat, 13 Jun 2026 10:46:20 +0800 Subject: [PATCH 327/426] fix: datepicker auto object error --- .../ListView/components/DatePicker/index.jsx | 26 +++++- web/src/components/ListView/index.jsx | 71 +++++++++++++--- web/src/components/Overview/Monitor/index.jsx | 57 +++++++++---- web/src/pages/Alerting/Message/Index.jsx | 84 +++++++++++++++---- web/src/pages/DataManagement/Index.js | 19 +++-- .../pages/Platform/Notification/index.scss | 8 +- .../Overview/components/MetricChart.jsx | 12 ++- 7 files changed, 220 insertions(+), 57 deletions(-) diff --git a/web/src/components/ListView/components/DatePicker/index.jsx b/web/src/components/ListView/components/DatePicker/index.jsx index cd6cf2d4..33a75b3c 100644 --- a/web/src/components/ListView/components/DatePicker/index.jsx +++ b/web/src/components/ListView/components/DatePicker/index.jsx @@ -4,6 +4,25 @@ import { message } from "antd"; import request from "@/utils/request"; import DatePicker from "@/common/src/DatePicker"; +const normalizeTimeValue = (value, fallback) => { + if (typeof value === "string" || typeof value === "number") { + return `${value}`; + } + if (value && typeof value === "object") { + const keys = ["from", "to", "min", "max", "gte", "lte", "start", "end"]; + for (const key of keys) { + if ( + Object.prototype.hasOwnProperty.call(value, key) && + value[key] !== undefined && + value[key] !== null + ) { + return `${value[key]}`; + } + } + } + return fallback; +}; + export default (props) => { const { locale = "en-US", @@ -23,8 +42,8 @@ export default (props) => { const [range] = useMemo(() => { let range = { - start: timeRange.from || "now-15m", - end: timeRange.to || "now", + start: normalizeTimeValue(timeRange.from, "now-15m"), + end: normalizeTimeValue(timeRange.to, "now"), timeField: timeRange.timeField || "", }; return [range]; @@ -91,7 +110,7 @@ export default (props) => { // }; return ( -
+
{ timeZone={currentTimeZone} onTimeZoneChange={setCurrentTimeZone} recentlyUsedRangesKey={recentlyUsedRangesKey} + showAutoTimeRange={true} />
); diff --git a/web/src/components/ListView/index.jsx b/web/src/components/ListView/index.jsx index 02250d44..cf4aad61 100644 --- a/web/src/components/ListView/index.jsx +++ b/web/src/components/ListView/index.jsx @@ -31,6 +31,41 @@ import { WidgetRender } from "@/pages/DataManagement/View/WidgetLoader"; import { buildContainsQueryString } from "@/lib/elasticsearch/util"; import moment from "moment"; +const normalizeTimeRangeValue = (value, fallback = "") => { + if (typeof value === "string" || typeof value === "number") { + return `${value}`; + } + if (value && typeof value === "object") { + const candidates = ["from", "to", "min", "max", "gte", "lte", "start", "end"]; + for (const key of candidates) { + if ( + Object.prototype.hasOwnProperty.call(value, key) && + value[key] !== undefined && + value[key] !== null + ) { + return `${value[key]}`; + } + } + } + return fallback; +}; + +const normalizeTimeRange = (timeRange = {}, fallback = {}) => { + const merged = { + ...(fallback || {}), + ...(timeRange || {}), + }; + return { + ...merged, + from: normalizeTimeRangeValue(merged.from, fallback?.from || "now-7d"), + to: normalizeTimeRangeValue(merged.to, fallback?.to || "now"), + timeField: + typeof merged.timeField === "string" + ? merged.timeField + : fallback?.timeField || "", + }; +}; + const buildTimestampKeywordFilter = (keyword, timeField) => { const value = `${keyword ?? ""}`.trim(); if (!value || !timeField) { @@ -112,10 +147,22 @@ const Index = forwardRef((props, ref) => { }; const [param, setParam] = useQueryParam("_g", JsonParam); - const [queryParams, setQueryParams] = useState({ - ...defaultQueryParams, - ...param, - from: viewLayout === 'timeline' ? 0 : (defaultQueryParams.from ?? param.from) + const [queryParams, setQueryParams] = useState(() => { + const merged = { + ...defaultQueryParams, + ...(param || {}), + from: + viewLayout === "timeline" + ? 0 + : defaultQueryParams.from ?? param?.from, + }; + return { + ...merged, + timeRange: normalizeTimeRange( + merged?.timeRange, + defaultQueryParams?.timeRange + ), + }; }); const [histogramState, setHistogramState] = useState({ visible: histogramVisible, @@ -466,22 +513,22 @@ const Index = forwardRef((props, ref) => { }; const onTimeRangeChange = ({ start, end, timeField }) => { + const normalizedTimeRange = normalizeTimeRange( + { from: start, to: end, timeField }, + defaultQueryParams?.timeRange + ); setQueryParams((st) => ({ ...st, from: 0, - timeRange: { - from: start, - to: end, - timeField: timeField, - }, + timeRange: normalizedTimeRange, })); if (histogramEnable) { let range = { - from: start, - to: end, + from: normalizedTimeRange.from, + to: normalizedTimeRange.to, }; let series = histogramState.widget?.series?.map((item) => { - item.queries.time_field = timeField; + item.queries.time_field = normalizedTimeRange.timeField; return item; }); let widget = { ...histogramState.widget, series }; diff --git a/web/src/components/Overview/Monitor/index.jsx b/web/src/components/Overview/Monitor/index.jsx index 4e3951f7..0d06a00f 100644 --- a/web/src/components/Overview/Monitor/index.jsx +++ b/web/src/components/Overview/Monitor/index.jsx @@ -59,21 +59,53 @@ export const getAllTimeSettingsCache = () => { const getDuration = (from, to) => { if (!from || !to) return; - const bounds = calculateBounds({ - from, - to, - }); - return bounds.max.valueOf() - bounds.min.valueOf() + try { + const bounds = calculateBounds({ + from, + to, + }); + return bounds.max.valueOf() - bounds.min.valueOf() + } catch (e) { + return undefined; + } } +const normalizeTimeValue = (value, fallback = "now-15m", keys = []) => { + if (typeof value === "string" || typeof value === "number") { + return `${value}`; + } + if (value && typeof value === "object") { + for (const key of keys) { + if ( + Object.prototype.hasOwnProperty.call(value, key) && + value[key] !== undefined && + value[key] !== null + ) { + return `${value[key]}`; + } + } + } + return fallback; +}; + export const initState = (state = {}) => { const { timeRange, timeInterval, timeout } = state || {} - const from = timeRange?.min || "now-15m" - const to = timeRange?.max || "now" + const from = normalizeTimeValue( + timeRange?.min ?? timeRange?.from, + "now-15m", + ["min", "from", "gte", "start"] + ); + const to = normalizeTimeValue( + timeRange?.max ?? timeRange?.to, + "now", + ["max", "to", "lte", "end"] + ); const duration = getDuration(from, to); - const gtOneHour = moment.duration(duration).asHours() > 1 - const day = moment.duration(duration).asDays(); - const intDay = parseInt(day) + 1; + const durationMs = + Number.isFinite(duration) && duration > 0 ? duration : 15 * 60 * 1000; + const gtOneHour = moment.duration(durationMs).asHours() > 1 + const day = moment.duration(durationMs).asDays(); + const intDay = Math.max(parseInt(day, 10) + 1, 1); return { ...state, timeRange: { @@ -105,10 +137,7 @@ const Monitor = (props) => { const [spinning, setSpinning] = useState(false); const [state, setState] = useState(formatState(initState({ - timeRange: { - min: param?.timeRange?.min || "now-15m", - max: param?.timeRange?.max || "now", - }, + timeRange: param?.timeRange || {}, timeInterval: formatTimeInterval(param?.timeInterval) || allTimeSettingsCache.timeInterval, timeout: formatTimeout(param?.timeout) || allTimeSettingsCache.timeout || '10s', param: param, diff --git a/web/src/pages/Alerting/Message/Index.jsx b/web/src/pages/Alerting/Message/Index.jsx index 5a542d5b..303cda32 100644 --- a/web/src/pages/Alerting/Message/Index.jsx +++ b/web/src/pages/Alerting/Message/Index.jsx @@ -68,6 +68,24 @@ const isValidAlertTime = (value) => { const getAlertDisplayStartTime = (record = {}) => isValidAlertTime(record?.trigger_at) ? record.trigger_at : record?.created; +const normalizeQueryTimeValue = (value, fallback = "auto", keys = []) => { + if (typeof value === "string" || typeof value === "number") { + return `${value}`; + } + if (value && typeof value === "object") { + for (const key of keys) { + if ( + Object.prototype.hasOwnProperty.call(value, key) && + value[key] !== undefined && + value[key] !== null + ) { + return `${value[key]}`; + } + } + } + return fallback; +}; + const Index = (props) => { const [param, setParam] = useQueryParam("_g", JsonParam); const [searchValue, setSearchValue] = React.useState(""); @@ -83,13 +101,24 @@ const Index = (props) => { const [refresh, setRefresh] = useState({ isRefreshPaused: false }); const [timeZone, setTimeZone] = useState(() => getTimezone()); + const initialStartTime = normalizeQueryTimeValue( + param?.start_time, + "auto", + ["from", "min", "gte", "start"] + ); + const initialEndTime = normalizeQueryTimeValue( + param?.end_time, + "auto", + ["to", "max", "lte", "end"] + ); + const initialQueryParams = { from: 0, size: 10, // status: "alerting", - start_time: "auto", - end_time: "auto", - ...param, + ...(param || {}), + start_time: initialStartTime, + end_time: initialEndTime, }; const alertReducer = (queryParams, action) => { @@ -382,19 +411,33 @@ const Index = (props) => { const fetchMessages = (queryParams) => { setLoading(true); - let params = queryParams; + const normalizedStartTime = normalizeQueryTimeValue( + queryParams?.start_time, + "auto", + ["from", "min", "gte", "start"] + ); + const normalizedEndTime = normalizeQueryTimeValue( + queryParams?.end_time, + "auto", + ["to", "max", "lte", "end"] + ); + let params = { + ...queryParams, + start_time: normalizedStartTime, + end_time: normalizedEndTime, + }; if ( - queryParams?.start_time && - queryParams.end_time && - queryParams.start_time !== "auto" && - queryParams.end_time !== "auto" + normalizedStartTime && + normalizedEndTime && + normalizedStartTime !== "auto" && + normalizedEndTime !== "auto" ) { const bounds = calculateBounds({ - from: queryParams?.start_time, - to: queryParams.end_time, + from: normalizedStartTime, + to: normalizedEndTime, }); params = { - ...queryParams, + ...params, min: bounds.min.valueOf(), max: bounds.max.valueOf(), }; @@ -558,10 +601,20 @@ const Index = (props) => { }, [dataSource.aggregations]); const widgetRange = useMemo(() => { - if (queryParams?.start_time && queryParams?.end_time) { + const startTime = normalizeQueryTimeValue( + queryParams?.start_time, + "auto", + ["from", "min", "gte", "start"] + ); + const endTime = normalizeQueryTimeValue( + queryParams?.end_time, + "auto", + ["to", "max", "lte", "end"] + ); + if (startTime && endTime) { return { - from: queryParams.start_time, - to: queryParams.end_time, + from: startTime, + to: endTime, }; } return { @@ -703,11 +756,12 @@ const Index = (props) => { -
+
+ {hasAuthority("data.index:all") ? ( + + ) : null} ) : null} - {hasAuthority("data.index:all") ? ( - - ) : null}
tr.read > td { + background-color: #f5f7fb !important; + color: rgba(0, 0, 0, 0.5); + } + .ant-table-tbody > tr.read > td a { + color: rgba(0, 0, 0, 0.65); } } diff --git a/web/src/pages/Platform/Overview/components/MetricChart.jsx b/web/src/pages/Platform/Overview/components/MetricChart.jsx index d2dd2dca..90ca508a 100644 --- a/web/src/pages/Platform/Overview/components/MetricChart.jsx +++ b/web/src/pages/Platform/Overview/components/MetricChart.jsx @@ -150,6 +150,14 @@ export default (props) => { } }; + const chartTimeFormatter = + typeof timeRange?.timeFormatter === "function" + ? (value) => { + const formatted = timeRange.timeFormatter(value); + return formatted == null ? "" : formatted; + } + : formatter.dates(1); + const renderChart = () => { if (error) { return ( @@ -216,8 +224,8 @@ export default (props) => { {metricKey == "cluster_health" ? ( Date: Sat, 13 Jun 2026 11:28:25 +0800 Subject: [PATCH 328/426] fix: datepicker auto object error and style change --- web/src/components/ListView/index.jsx | 67 ++++++++++++++-- web/src/global.less | 11 +++ web/src/pages/Alerting/Message/Index.jsx | 21 ++++- web/src/pages/Alerting/Rule/Index.jsx | 15 +++- .../Rule/components/RuleMetricChart.jsx | 5 +- .../Rule/components/RuleRecordChart.jsx | 5 +- .../DataManagement/View/WidgetLoader.jsx | 38 +++++++-- .../pages/Platform/Notification/index.scss | 7 ++ web/src/pages/Platform/Notification/index.tsx | 78 +++++++++++++++++-- web/src/pages/System/Role/index.jsx | 10 ++- web/src/pages/System/User/index.jsx | 16 +++- 11 files changed, 243 insertions(+), 30 deletions(-) diff --git a/web/src/components/ListView/index.jsx b/web/src/components/ListView/index.jsx index cf4aad61..d1a3830f 100644 --- a/web/src/components/ListView/index.jsx +++ b/web/src/components/ListView/index.jsx @@ -66,6 +66,45 @@ const normalizeTimeRange = (timeRange = {}, fallback = {}) => { }; }; +const normalizeHistogramRange = (timeRange = {}) => { + const from = normalizeTimeRangeValue(timeRange?.from, "now-15m"); + const to = normalizeTimeRangeValue(timeRange?.to, "now"); + if (from === "auto" || to === "auto") { + return { + from: "now-15m", + to: "now", + }; + } + return { from, to }; +}; + +const resolveAutoHistogramRange = (timeRange = {}, aggregations = {}) => { + const normalized = normalizeHistogramRange(timeRange); + if (normalized.from !== "now-15m" || normalized.to !== "now") { + return normalized; + } + const isAuto = + normalizeTimeRangeValue(timeRange?.from, "") === "auto" || + normalizeTimeRangeValue(timeRange?.to, "") === "auto"; + if (!isAuto) { + return normalized; + } + const minValue = aggregations?.__listview_min_time?.value; + const maxValue = aggregations?.__listview_max_time?.value; + if ( + Number.isFinite(minValue) && + Number.isFinite(maxValue) && + minValue > 0 && + maxValue > 0 + ) { + return { + from: moment(minValue).toISOString(), + to: moment(maxValue).toISOString(), + }; + } + return normalized; +}; + const buildTimestampKeywordFilter = (keyword, timeField) => { const value = `${keyword ?? ""}`.trim(); if (!value || !timeField) { @@ -167,7 +206,7 @@ const Index = forwardRef((props, ref) => { const [histogramState, setHistogramState] = useState({ visible: histogramVisible, widget: histogramWidget, - range: defaultQueryParams.timeRange, + range: normalizeHistogramRange(defaultQueryParams.timeRange), }); useEffect(() => { if (histogramEnable) { @@ -323,6 +362,14 @@ const Index = forwardRef((props, ref) => { return sortJson; }); } + const aggs = { + ...formatAggs, + }; + const timeField = queryParams?.timeRange?.timeField; + if (timeField) { + aggs.__listview_min_time = { min: { field: timeField } }; + aggs.__listview_max_time = { max: { field: timeField } }; + } return { query: { bool: { @@ -342,7 +389,7 @@ const Index = forwardRef((props, ref) => { }, }, sort: sort, - aggs: formatAggs, + aggs, }; }; @@ -523,10 +570,7 @@ const Index = forwardRef((props, ref) => { timeRange: normalizedTimeRange, })); if (histogramEnable) { - let range = { - from: normalizedTimeRange.from, - to: normalizedTimeRange.to, - }; + let range = normalizeHistogramRange(normalizedTimeRange); let series = histogramState.widget?.series?.map((item) => { item.queries.time_field = normalizedTimeRange.timeField; return item; @@ -558,6 +602,15 @@ const Index = forwardRef((props, ref) => { return query ? JSON.stringify(query) : undefined; }, [JSON.stringify(queryParams)]); + const histogramRange = useMemo( + () => + resolveAutoHistogramRange( + queryParams?.timeRange || {}, + dataSource?.aggregations || {} + ), + [queryParams?.timeRange, dataSource?.aggregations] + ); + useEffect(() => { const nextParam = { ...(param || {}), ...queryParams }; if (isEqual(param || {}, nextParam)) { @@ -709,7 +762,7 @@ const Index = forwardRef((props, ref) => { > { "auto", ["to", "max", "lte", "end"] ); - if (startTime && endTime) { + if ( + startTime && + endTime && + startTime !== "auto" && + endTime !== "auto" + ) { return { from: startTime, to: endTime, }; } + if (minUpdated && maxUpdated) { + const minMoment = moment(minUpdated); + const maxMoment = moment(maxUpdated); + if (minMoment.isValid() && maxMoment.isValid()) { + return { + from: minUpdated, + to: maxUpdated, + }; + } + } return { - from: minUpdated, - to: maxUpdated, + from: "now-15m", + to: "now", }; }, [queryParams?.start_time, queryParams?.end_time, minUpdated, maxUpdated]); diff --git a/web/src/pages/Alerting/Rule/Index.jsx b/web/src/pages/Alerting/Rule/Index.jsx index 752e7f65..047b0534 100644 --- a/web/src/pages/Alerting/Rule/Index.jsx +++ b/web/src/pages/Alerting/Rule/Index.jsx @@ -36,6 +36,12 @@ import NoData from "./components/NoData"; import { HealthStatusCircle } from "@/components/infini/health_status_circle"; import { getSystemClusterID } from "@/utils/setup"; +const displayOrDash = (value) => { + if (value === null || value === undefined) return "-"; + const text = `${value}`.trim(); + return text ? text : "-"; +}; + export default (props) => { const ref = useRef(null); const [isLoading, setIsLoading] = React.useState(); @@ -326,7 +332,7 @@ export default (props) => { return text ? ( {text} ) : ( - {text} + - ); }, }, @@ -343,7 +349,7 @@ export default (props) => { style={{ display: "flex", alignItems: "center", gap: 5 }} > {renderRuleStatus(record)} - {text} + {displayOrDash(text)} ); }, @@ -353,6 +359,9 @@ export default (props) => { key: "tags", searchable: true, render: (text, record) => { + if (!record.tags || record.tags.length === 0) { + return "-"; + } return record.tags?.map((item) => { return ( @@ -367,7 +376,7 @@ export default (props) => { id: "alert.rule.table.columnns.last_notification_time", }), key: "info.last_notification_time", - render: (text, record) => (text ? moment(text).fromNow() : text), + render: (text, record) => (text ? moment(text).fromNow() : "-"), }, { title: formatMessage({ id: "alert.rule.table.columnns.updated" }), diff --git a/web/src/pages/Alerting/Rule/components/RuleMetricChart.jsx b/web/src/pages/Alerting/Rule/components/RuleMetricChart.jsx index 3ef3fa53..aadaca57 100644 --- a/web/src/pages/Alerting/Rule/components/RuleMetricChart.jsx +++ b/web/src/pages/Alerting/Rule/components/RuleMetricChart.jsx @@ -121,7 +121,10 @@ const RuleMetricChart = ({ conditions, values }) => { let headerUnit = ""; return ( -
+
{ }; return ( -
+
{ return mergeFilters([], newFilters) }, [JSON.stringify(queryParams)]) + const normalizeRangeValue = (value, fallback) => { + if (typeof value === "string" || typeof value === "number") { + return `${value}`; + } + if (value && typeof value === "object") { + for (const key of ["from", "to", "min", "max", "gte", "lte", "start", "end"]) { + if ( + Object.prototype.hasOwnProperty.call(value, key) && + value[key] !== undefined && + value[key] !== null + ) { + return `${value[key]}`; + } + } + } + return fallback; + }; + const formatTimeRange = useMemo(() => { - if (!range.from || !range.to) return { from: 'now-15m', to: 'now'} - const bounds = calculateBounds(range); - return { - from: moment(bounds.min.valueOf()).tz(getTimezone()).utc().format(), - to: moment(bounds.max.valueOf()).tz(getTimezone()).utc().format(), - }; + const from = normalizeRangeValue(range?.from, "now-15m"); + const to = normalizeRangeValue(range?.to, "now"); + if (!from || !to || from === "auto" || to === "auto") { + return { from: "now-15m", to: "now" }; + } + try { + const bounds = calculateBounds({ from, to }); + return { + from: moment(bounds.min.valueOf()).tz(getTimezone()).utc().format(), + to: moment(bounds.max.valueOf()).tz(getTimezone()).utc().format(), + }; + } catch (e) { + return { from: "now-15m", to: "now" }; + } }, [JSON.stringify(range), refresh]); const renderWidget = useMemo(() => { diff --git a/web/src/pages/Platform/Notification/index.scss b/web/src/pages/Platform/Notification/index.scss index a79e35ec..5da98155 100644 --- a/web/src/pages/Platform/Notification/index.scss +++ b/web/src/pages/Platform/Notification/index.scss @@ -11,4 +11,11 @@ .ant-table-tbody > tr.read > td a { color: rgba(0, 0, 0, 0.65); } + :global { + .notice-status-read { + color: rgba(0, 0, 0, 0.65); + background: #eef2f7; + border-color: #d6dee9; + } + } } diff --git a/web/src/pages/Platform/Notification/index.tsx b/web/src/pages/Platform/Notification/index.tsx index bf2308ff..aa6d27de 100644 --- a/web/src/pages/Platform/Notification/index.tsx +++ b/web/src/pages/Platform/Notification/index.tsx @@ -41,6 +41,74 @@ import SearchInput from "@/components/infini/SearchInput"; const { TabPane } = Tabs; const { Option } = Select; +const normalizeTimeValue = (value, fallback = "auto", keys = []) => { + if (typeof value === "string" || typeof value === "number") { + return `${value}`; + } + if (value && typeof value === "object") { + for (const key of keys) { + if ( + Object.prototype.hasOwnProperty.call(value, key) && + value[key] !== undefined && + value[key] !== null + ) { + return `${value[key]}`; + } + } + } + return fallback; +}; + +const tryParseJSON = (value) => { + if (!value || typeof value !== "string") return undefined; + try { + return JSON.parse(value); + } catch (e) { + return undefined; + } +}; + +const normalizeAlertingMessageLink = (rawLink = "") => { + let link = rawLink || ""; + if (link.indexOf("/#") === 0) { + link = link.substr(2); + } + if (!link || !link.includes("/alerting/message")) { + return link; + } + const [pathname, queryString = ""] = link.split("?"); + if (!queryString) { + return pathname; + } + const params = new URLSearchParams(queryString); + const rawG = params.get("_g"); + if (!rawG) { + return link; + } + const parsedG = tryParseJSON(rawG) || tryParseJSON(decodeURIComponent(rawG)); + if (!parsedG || typeof parsedG !== "object") { + return pathname; + } + const normalizedG = { ...parsedG }; + const range = normalizedG?.timeRange; + if (typeof normalizedG.start_time !== "string") { + normalizedG.start_time = normalizeTimeValue( + normalizedG.start_time ?? range?.min ?? range?.from, + "auto", + ["from", "min", "gte", "start"] + ); + } + if (typeof normalizedG.end_time !== "string") { + normalizedG.end_time = normalizeTimeValue( + normalizedG.end_time ?? range?.max ?? range?.to, + "auto", + ["to", "max", "lte", "end"] + ); + } + params.set("_g", JSON.stringify(normalizedG)); + return `${pathname}?${params.toString()}`; +}; + const Index = (props) => { const [param, setParam] = useQueryParam("_g", JsonParam); const [searchValue, setSearchValue] = React.useState(""); @@ -125,7 +193,10 @@ const Index = (props) => { title: formatMessage({ id: "platform.notification.table.status" }), dataIndex: "status", render: (text, record) => ( - + {text == "new" ? formatMessage({ id: "platform.notification.status.new" }) : formatMessage({ id: "platform.notification.status.read" })} @@ -135,10 +206,7 @@ const Index = (props) => { { title: formatMessage({ id: "table.field.actions" }), render: (text, record) => { - let link = record.link || ""; - if (link.indexOf("/#") === 0) { - link = link.substr(2); - } + const link = normalizeAlertingMessageLink(record.link || ""); return (
diff --git a/web/src/pages/System/Role/index.jsx b/web/src/pages/System/Role/index.jsx index a2fd4149..e8d2a3e8 100644 --- a/web/src/pages/System/Role/index.jsx +++ b/web/src/pages/System/Role/index.jsx @@ -36,6 +36,12 @@ const firstColumnIconStyle = { fontSize: 12, }; +const displayOrDash = (value) => { + if (value === null || value === undefined) return "-"; + const text = `${value}`.trim(); + return text ? text : "-"; +}; + const RoleList = (props) => { const [queryParams, setQueryParams] = React.useState({}); const [searchValue, setSearchValue] = React.useState(""); @@ -84,13 +90,14 @@ const RoleList = (props) => { render: (text) => (
- {text} + {displayOrDash(text)}
), }, { title: formatMessage({ id: "system.security.role.table.type" }), dataIndex: "type", + render: (val) => displayOrDash(val), }, { title: formatMessage({ id: "system.security.role.table.builtin" }), @@ -102,6 +109,7 @@ const RoleList = (props) => { { title: formatMessage({ id: "system.security.role.table.description" }), dataIndex: "description", + render: (val) => displayOrDash(val), }, { title: formatMessage({ id: "table.field.actions" }), diff --git a/web/src/pages/System/User/index.jsx b/web/src/pages/System/User/index.jsx index 20262c18..6f0c3f5c 100644 --- a/web/src/pages/System/User/index.jsx +++ b/web/src/pages/System/User/index.jsx @@ -34,6 +34,12 @@ const firstColumnIconStyle = { fontSize: 12, }; +const displayOrDash = (value) => { + if (value === null || value === undefined) return "-"; + const text = `${value}`.trim(); + return text ? text : "-"; +}; + const UserList = (props) => { const [queryParams, setQueryParams] = React.useState({}); const [searchValue, setSearchValue] = React.useState(""); @@ -73,34 +79,38 @@ const UserList = (props) => { render: (text) => (
- {text} + {displayOrDash(text)}
), }, { title: formatMessage({ id: "system.security.user.table.nickname" }), dataIndex: "nick_name", + render: (val) => displayOrDash(val), }, { title: formatMessage({ id: "system.security.user.table.roles" }), dataIndex: "roles", render: (val) => { - return (val || []).map((role) => role.name).join(","); + const text = (val || []).map((role) => role.name).join(","); + return displayOrDash(text); }, }, { title: formatMessage({ id: "system.security.user.table.phone" }), dataIndex: "phone", + render: (val) => displayOrDash(val), }, { title: formatMessage({ id: "system.security.user.table.email" }), dataIndex: "email", + render: (val) => displayOrDash(val), }, { title: formatMessage({ id: "system.security.user.table.tags" }), dataIndex: "tags", render: (text) => { - return text; + return displayOrDash(text); }, }, { From a064b20f6e980c1033e0e5d8f42f71b1dbf6dda0 Mon Sep 17 00:00:00 2001 From: hardy Date: Sat, 13 Jun 2026 11:52:34 +0800 Subject: [PATCH 329/426] fix: datepicker show width --- .../ListView/components/DatePicker/index.jsx | 10 +++- web/src/components/ListView/index.jsx | 2 + .../components/infini/health_status_view.tsx | 2 +- web/src/pages/Cluster/Activities.jsx | 26 ++++++++- web/src/pages/Gateway/Instance/index.jsx | 13 ++++- web/src/pages/System/Audit/index.jsx | 1 + web/src/pages/System/Role/index.jsx | 55 ++++++++++--------- 7 files changed, 77 insertions(+), 32 deletions(-) diff --git a/web/src/components/ListView/components/DatePicker/index.jsx b/web/src/components/ListView/components/DatePicker/index.jsx index 33a75b3c..0d37815a 100644 --- a/web/src/components/ListView/components/DatePicker/index.jsx +++ b/web/src/components/ListView/components/DatePicker/index.jsx @@ -34,6 +34,7 @@ export default (props) => { isRefreshPaused = true, onRefresh, recentlyUsedRangesKey = "listview-recently-used-ranges", + wrapperStyle = {}, } = props; if (timeFields.length == 0) { @@ -109,8 +110,15 @@ export default (props) => { // setAutoFitLoading(false); // }; + const containerStyle = { + width: "460px", + maxWidth: "55vw", + minWidth: 320, + ...wrapperStyle, + }; + return ( -
+
{ showEmptyUI = false, setShowEmptyUI = null, scroll, + datePickerContainerStyle = {}, } = props; const headerExtra = headerToobarExtra.getExtra @@ -708,6 +709,7 @@ const Index = forwardRef((props, ref) => { onTimeRangeChange={onTimeRangeChange} isRefreshPaused={isRefreshPaused} recentlyUsedRangesKey={collectionName} + wrapperStyle={datePickerContainerStyle} /> ) : null} - +
+
+
+ {body} + +
+
+ + +
diff --git a/web/src/pages/Alerting/components/ExportAndImport/index.less b/web/src/pages/Alerting/components/ExportAndImport/index.less index 1c17b946..99214fd6 100644 --- a/web/src/pages/Alerting/components/ExportAndImport/index.less +++ b/web/src/pages/Alerting/components/ExportAndImport/index.less @@ -1,7 +1,5 @@ .actions { - position: absolute; - right: 0; - bottom: 0; + flex: none; width: 100%; border-top: 1px solid #e9e9e9; padding: 11px 16px; diff --git a/web/src/pages/Alerting/components/ExportAndImportDrawer.jsx b/web/src/pages/Alerting/components/ExportAndImportDrawer.jsx index 57b1472b..388eea2c 100644 --- a/web/src/pages/Alerting/components/ExportAndImportDrawer.jsx +++ b/web/src/pages/Alerting/components/ExportAndImportDrawer.jsx @@ -21,29 +21,37 @@ export default (props) => { bodyStyle={{ padding: 0, height: "calc(100vh - 110px)", - overflow: "auto", + overflow: "hidden", }} destroyOnClose > -
{children}
- - +
{children}
+
+ + +
); diff --git a/web/src/pages/Alerting/components/Import.jsx b/web/src/pages/Alerting/components/Import.jsx index 8985a801..e7dfa5bc 100644 --- a/web/src/pages/Alerting/components/Import.jsx +++ b/web/src/pages/Alerting/components/Import.jsx @@ -184,7 +184,7 @@ export default Form.create()((props) => {
{ enabled: false, }, readOnly: true, + scrollBeyondLastLine: false, + scrollbar: { + vertical: "hidden", + horizontal: "hidden", + }, }} />
From 80f6203e3ee612a2c620bb1ecb6a442a8d9b984c Mon Sep 17 00:00:00 2001 From: hardy Date: Sat, 13 Jun 2026 16:22:56 +0800 Subject: [PATCH 337/426] fix: table page size to 20 --- .../components/GlobalHeader/DropdownSelect.js | 2 +- web/src/components/Overview/index.tsx | 31 +++++++--- .../Overview/components/CommonTable/index.jsx | 60 ++++++++++++------- 3 files changed, 61 insertions(+), 32 deletions(-) diff --git a/web/src/components/GlobalHeader/DropdownSelect.js b/web/src/components/GlobalHeader/DropdownSelect.js index d3f1ea3d..661953a0 100644 --- a/web/src/components/GlobalHeader/DropdownSelect.js +++ b/web/src/components/GlobalHeader/DropdownSelect.js @@ -188,7 +188,7 @@ class DropdownSelect extends React.Component { > { decode: JSON.parse, } ); + const pageSizeByDisplayRef = useRef>({ + card: initialQueryParams.size, + table: tableDefaultPageSize, + }); + const appliedDisplayTypeRef = useRef(); const onDisplayTypeChange = (value: string) => { - let obj = {}; - obj[currentTab] = value; - setDispalyTypeObj({ ...dispalyTypeObj, ...obj }); - if ( - value === "table" && - queryParams.size === initialQueryParams.size - ) { - dispatch({ type: "setPageSizeAndReset", value: tableDefaultPageSize }); - } + const currentDisplayType = dispalyTypeObj[currentTab] || "card"; + pageSizeByDisplayRef.current[currentDisplayType] = queryParams.size; + setDispalyTypeObj({ ...dispalyTypeObj, [currentTab]: value }); }; + useEffect(() => { + const displayType = dispalyTypeObj[currentTab] || "card"; + if (appliedDisplayTypeRef.current === displayType) { + return; + } + appliedDisplayTypeRef.current = displayType; + const nextPageSize = + displayType === "table" + ? pageSizeByDisplayRef.current.table || tableDefaultPageSize + : pageSizeByDisplayRef.current.card || initialQueryParams.size; + if (queryParams.size !== nextPageSize || queryParams.from !== 0) { + dispatch({ type: "setPageSizeAndReset", value: nextPageSize }); + } + }, [currentTab, dispalyTypeObj, queryParams.size, queryParams.from]); function reducer( queryParams: IQueryParams, action: { type: string; value: any } diff --git a/web/src/pages/Platform/Overview/components/CommonTable/index.jsx b/web/src/pages/Platform/Overview/components/CommonTable/index.jsx index c7b69442..0d58ca6c 100644 --- a/web/src/pages/Platform/Overview/components/CommonTable/index.jsx +++ b/web/src/pages/Platform/Overview/components/CommonTable/index.jsx @@ -30,28 +30,44 @@ export default (props) => { const [loadings, setLoadings] = useState({}); const fetchListInfo = async (data) => { - data?.forEach((item) => { - setLoadings((loadings) => ({ - ...loadings, - [item.id]: true - })) - request(infoAction, { - method: "POST", - body: [item.id], - }, false, false).then((res) => { - if (res && !res.error) { - setInfos((infos) => ({ - ...infos, - ...res - })); - } - }).finally(() => { - setLoadings((loadings) => ({ - ...loadings, - [item.id]: false - })) - }) - }) + const ids = (data || []) + .map((item) => item?.id) + .filter((id) => !!id && !infos[id]); + if (ids.length === 0) { + return; + } + setLoadings((current) => { + const next = { ...current }; + ids.forEach((id) => { + next[id] = true; + }); + return next; + }); + try { + const res = await request( + infoAction, + { + method: "POST", + body: ids, + }, + false, + false + ); + if (res && !res.error) { + setInfos((current) => ({ + ...current, + ...res, + })); + } + } finally { + setLoadings((current) => { + const next = { ...current }; + ids.forEach((id) => { + next[id] = false; + }); + return next; + }); + } }; useEffect(() => { From b82cc6eae26055b3a31148c65f181b30a33e96cc Mon Sep 17 00:00:00 2001 From: hardy Date: Sat, 13 Jun 2026 16:36:01 +0800 Subject: [PATCH 338/426] fix: table page size to 20 --- web/src/pages/DataManagement/Alias.js | 4 ++-- web/src/pages/DataManagement/Index.js | 2 +- web/src/pages/Gateway/Instance/index.jsx | 2 +- web/src/pages/System/Audit/index.jsx | 2 +- web/src/pages/System/Credential/Index.js | 2 +- 5 files changed, 6 insertions(+), 6 deletions(-) diff --git a/web/src/pages/DataManagement/Alias.js b/web/src/pages/DataManagement/Alias.js index eb31d55d..2b059433 100644 --- a/web/src/pages/DataManagement/Alias.js +++ b/web/src/pages/DataManagement/Alias.js @@ -152,7 +152,7 @@ class AliasManage extends PureComponent { selectedRows: [], formValues: {}, updateFormValues: {}, - pageSize: 10, + pageSize: 20, editMode: "UPDATE", }; @@ -581,7 +581,7 @@ class AliasIndexTable extends React.Component { size={"small"} pagination={{ size: "small", - pageSize: 10, + pageSize: 20, showSizeChanger: true, showTotal: (total, range) => `${range[0]}-${range[1]} of ${total} items`, diff --git a/web/src/pages/DataManagement/Index.js b/web/src/pages/DataManagement/Index.js index d682c760..7ade0d11 100644 --- a/web/src/pages/DataManagement/Index.js +++ b/web/src/pages/DataManagement/Index.js @@ -203,7 +203,7 @@ class Index extends PureComponent { editingIndex: {}, indexActiveKey: "overview", showSystemIndices: false, - pageSize: 10, + pageSize: 20, deleteIndexItems: [], deleteIndexVisible: false, deleteIndexConfirm: false, diff --git a/web/src/pages/Gateway/Instance/index.jsx b/web/src/pages/Gateway/Instance/index.jsx index 0e7f28ce..23010edf 100644 --- a/web/src/pages/Gateway/Instance/index.jsx +++ b/web/src/pages/Gateway/Instance/index.jsx @@ -535,7 +535,7 @@ export default (props) => { }} defaultQueryParams={{ from: 0, - size: 10, + size: 20, }} sortEnable={true} sideEnable={true} diff --git a/web/src/pages/System/Audit/index.jsx b/web/src/pages/System/Audit/index.jsx index b58a251b..2b6859ee 100644 --- a/web/src/pages/System/Audit/index.jsx +++ b/web/src/pages/System/Audit/index.jsx @@ -152,7 +152,7 @@ export default (props) => { }} defaultQueryParams={{ from: 0, - size: 10, + size: 20, timeRange: { from: "auto", to: "auto", timeField: timeField }, sort: [[timeField, "desc"]], }} diff --git a/web/src/pages/System/Credential/Index.js b/web/src/pages/System/Credential/Index.js index 5bba52b5..b95c4dfd 100644 --- a/web/src/pages/System/Credential/Index.js +++ b/web/src/pages/System/Credential/Index.js @@ -33,7 +33,7 @@ const firstColumnIconStyle = { export default () => { const initialQueryParams = { from: 0, - size: 10, + size: 20, }; function reducer(queryParams, action) { From 5162932ccca62659aa3df55d52a59b74564349dc Mon Sep 17 00:00:00 2001 From: hardy Date: Sat, 13 Jun 2026 16:43:29 +0800 Subject: [PATCH 339/426] fix: auto param for metrics query --- web/src/components/Overview/Monitor/index.jsx | 3 +++ web/src/lib/elasticsearch/util.js | 12 ++++++++++-- web/src/services/cluster.js | 4 +++- 3 files changed, 16 insertions(+), 3 deletions(-) diff --git a/web/src/components/Overview/Monitor/index.jsx b/web/src/components/Overview/Monitor/index.jsx index ba122cb5..d4fe2734 100644 --- a/web/src/components/Overview/Monitor/index.jsx +++ b/web/src/components/Overview/Monitor/index.jsx @@ -72,6 +72,9 @@ const getDuration = (from, to) => { const normalizeTimeValue = (value, fallback = "now-15m", keys = []) => { if (typeof value === "string" || typeof value === "number") { + if (typeof value === "number" && !Number.isFinite(value)) { + return fallback; + } return `${value}`; } if (value && typeof value === "object") { diff --git a/web/src/lib/elasticsearch/util.js b/web/src/lib/elasticsearch/util.js index d5c31fe3..b9978dac 100644 --- a/web/src/lib/elasticsearch/util.js +++ b/web/src/lib/elasticsearch/util.js @@ -162,9 +162,17 @@ export function extractClusterIDFromURL() { export function formatTimeRange(timeRange) { const bounds = calculateBounds({ - from: timeRange.min, - to: timeRange.max, + from: timeRange?.min, + to: timeRange?.max, }); + if (!bounds || !Number.isFinite(bounds.min?.valueOf?.()) || !Number.isFinite(bounds.max?.valueOf?.())) { + const min = timeRange?.min; + const max = timeRange?.max; + return { + min: Number.isFinite(min) ? min : Date.now() - 15 * 60 * 1000, + max: Number.isFinite(max) ? max : Date.now(), + }; + } return { min: bounds.min.valueOf(), max: bounds.max.valueOf(), diff --git a/web/src/services/cluster.js b/web/src/services/cluster.js index 943526ee..c4bcb835 100644 --- a/web/src/services/cluster.js +++ b/web/src/services/cluster.js @@ -10,8 +10,10 @@ export async function getClusterVersion(params) { export async function getClusterMetrics(params) { let id = params.cluster_id; delete params["cluster_id"]; + const min = Number.isFinite(params?.timeRange?.min) ? params.timeRange.min : Date.now() - 15 * 60 * 1000; + const max = Number.isFinite(params?.timeRange?.max) ? params.timeRange.max : Date.now(); return request( - `${ESPrefix}/${id}/metrics?min=${params.timeRange.min}&max=${params.timeRange.max}`, + `${ESPrefix}/${id}/metrics?min=${min}&max=${max}`, { method: "GET", } From 7d20d702e05d07b60e0236765834ce59930d9f8b Mon Sep 17 00:00:00 2001 From: hardy Date: Sat, 13 Jun 2026 17:15:14 +0800 Subject: [PATCH 340/426] fix: pagesize with search --- modules/agent/api/elasticsearch.go | 8 ++-- modules/agent/api/remote_config.go | 2 +- plugin/managed/server/instance.go | 6 +-- web/src/common/src/DropdownList/index.jsx | 2 +- web/src/components/Overview/index.tsx | 45 ++++++++++++----------- web/src/pages/Alerting/Rule/Index.jsx | 2 +- 6 files changed, 33 insertions(+), 32 deletions(-) diff --git a/modules/agent/api/elasticsearch.go b/modules/agent/api/elasticsearch.go index d01b555a..44c25cdf 100644 --- a/modules/agent/api/elasticsearch.go +++ b/modules/agent/api/elasticsearch.go @@ -683,14 +683,14 @@ func runAutoEnroll(clusterInfo ClusterInfo) { } nodes, err := refreshNodesInfo(instanceID) if err != nil { - log.Errorf("failed to refresh nodes for agent instance [%s] at [%s]: %s", console_common.MaskLogToken(instanceID), console_common.MaskLogEndpoint(instanceEndpoint), console_common.MaskLogError(err)) + log.Errorf("failed to refresh nodes for agent instance [%s] at [%s]: %s", instanceID, console_common.MaskLogEndpoint(instanceEndpoint), console_common.MaskLogError(err)) continue } - log.Tracef("instance:%v,%v, has: %v nodes, %v unknown nodes", console_common.MaskLogToken(instanceID), console_common.MaskLogEndpoint(instanceEndpoint), len(nodes.Nodes), len(nodes.UnknownProcess)) + log.Tracef("instance:%v,%v, has: %v nodes, %v unknown nodes", instanceID, console_common.MaskLogEndpoint(instanceEndpoint), len(nodes.Nodes), len(nodes.UnknownProcess)) if len(nodes.UnknownProcess) > 0 { pids := bindInstanceToCluster(clusterInfo, nodes, instanceID, instanceEndpoint) if len(pids) > 0 { - log.Infof("instance:%v,%v, success enroll %v nodes", console_common.MaskLogToken(instanceID), console_common.MaskLogEndpoint(instanceEndpoint), len(pids)) + log.Infof("instance:%v,%v, success enroll %v nodes", instanceID, console_common.MaskLogEndpoint(instanceEndpoint), len(pids)) } } @@ -700,7 +700,7 @@ func runAutoEnroll(clusterInfo ClusterInfo) { if !v.Enrolled { pids := bindInstanceToCluster(clusterInfo, nodes, instanceID, instanceEndpoint) if len(pids) > 0 { - log.Infof("instance:%v,%v, success enroll %v nodes", console_common.MaskLogToken(instanceID), console_common.MaskLogEndpoint(instanceEndpoint), len(pids)) + log.Infof("instance:%v,%v, success enroll %v nodes", instanceID, console_common.MaskLogEndpoint(instanceEndpoint), len(pids)) } break } diff --git a/modules/agent/api/remote_config.go b/modules/agent/api/remote_config.go index 11f34769..fd3bc30b 100644 --- a/modules/agent/api/remote_config.go +++ b/modules/agent/api/remote_config.go @@ -397,7 +397,7 @@ func dynamicAgentConfigProvider(instance model.Instance) []*common.ConfigFile { panic(err) } latestTimestamp = time.Now().Unix() - log.Tracef("agent config hash changed for [%s]: %s -> %s, version=%v", console_common.MaskLogToken(instance.ID), console_common.MaskLogToken(string(v)), console_common.MaskLogToken(hash), latestTimestamp) + log.Tracef("agent config hash changed for [%s]: %s -> %s, version=%v", instance.ID, console_common.MaskLogToken(string(v)), console_common.MaskLogToken(hash), latestTimestamp) } cfg.Size = int64(len(cfg.Content)) diff --git a/plugin/managed/server/instance.go b/plugin/managed/server/instance.go index 2c5d2f51..a76e477a 100644 --- a/plugin/managed/server/instance.go +++ b/plugin/managed/server/instance.go @@ -92,7 +92,7 @@ func logManagedRegistration(stage string, instance *model.Instance, req *http.Re "managed agent registration %s: %v[%v], version=%v, endpoint=%v", stage, instance.Name, - console_common.MaskLogToken(instance.ID), + instance.ID, instance.Application.Version.VersionNumber, console_common.MaskLogEndpoint(instance.Endpoint), ) @@ -124,7 +124,7 @@ func logLegacyManagedRegistration(stage string, instance *model.Instance, req *h "legacy managed agent registration %s: %v[%v], version=%v, endpoint=%v", stage, instance.Name, - console_common.MaskLogToken(instance.ID), + instance.ID, instance.Application.Version.VersionNumber, console_common.MaskLogEndpoint(instance.Endpoint), ) @@ -332,7 +332,7 @@ func (h APIHandler) registerInstance(w http.ResponseWriter, req *http.Request, p } } - log.Infof("register instance: %v[%v], %v", obj.Name, console_common.MaskLogToken(obj.ID), console_common.MaskLogEndpoint(obj.Endpoint)) + log.Infof("register instance: %v[%v], %v", obj.Name, obj.ID, console_common.MaskLogEndpoint(obj.Endpoint)) detail := "registered successfully" if exists { detail = "updated existing registration successfully" diff --git a/web/src/common/src/DropdownList/index.jsx b/web/src/common/src/DropdownList/index.jsx index 7bc44abf..ee8a963d 100644 --- a/web/src/common/src/DropdownList/index.jsx +++ b/web/src/common/src/DropdownList/index.jsx @@ -18,7 +18,7 @@ const DropdownList = (props) => { popoverClassName = "", popoverPlacement = "bottomLeft", children, - width = 300, + width = 400, dropdownWidth, locale = "en-US", allowClear = false, diff --git a/web/src/components/Overview/index.tsx b/web/src/components/Overview/index.tsx index f189a38b..5043b271 100644 --- a/web/src/components/Overview/index.tsx +++ b/web/src/components/Overview/index.tsx @@ -129,26 +129,7 @@ export default forwardRef((props: IProps, ref: any) => { table: tableDefaultPageSize, }); const appliedDisplayTypeRef = useRef(); - const onDisplayTypeChange = (value: string) => { - const currentDisplayType = dispalyTypeObj[currentTab] || "card"; - pageSizeByDisplayRef.current[currentDisplayType] = queryParams.size; - setDispalyTypeObj({ ...dispalyTypeObj, [currentTab]: value }); - }; - useEffect(() => { - const displayType = dispalyTypeObj[currentTab] || "card"; - if (appliedDisplayTypeRef.current === displayType) { - return; - } - appliedDisplayTypeRef.current = displayType; - const nextPageSize = - displayType === "table" - ? pageSizeByDisplayRef.current.table || tableDefaultPageSize - : pageSizeByDisplayRef.current.card || initialQueryParams.size; - if (queryParams.size !== nextPageSize || queryParams.from !== 0) { - dispatch({ type: "setPageSizeAndReset", value: nextPageSize }); - } - }, [currentTab, dispalyTypeObj, queryParams.size, queryParams.from]); function reducer( queryParams: IQueryParams, action: { type: string; value: any } @@ -180,12 +161,32 @@ export default forwardRef((props: IProps, ref: any) => { throw new Error(); } } - const [queryParams, dispatch] = useReducer(reducer, { + const [queryParams, dispatch] = useReducer(reducer, { from: param?.from || initialQueryParams.from, - size: param?.size || initialQueryParams.size , - keyword: param?.keyword || initialQueryParams.keyword + size: param?.size || initialQueryParams.size, + keyword: param?.keyword || initialQueryParams.keyword, }); + const onDisplayTypeChange = (value: string) => { + const currentDisplayType = dispalyTypeObj[currentTab] || "card"; + pageSizeByDisplayRef.current[currentDisplayType] = queryParams.size; + setDispalyTypeObj({ ...dispalyTypeObj, [currentTab]: value }); + }; + + useEffect(() => { + const displayType = dispalyTypeObj[currentTab] || "card"; + if (appliedDisplayTypeRef.current === displayType) { + return; + } + appliedDisplayTypeRef.current = displayType; + const nextPageSize = + displayType === "table" + ? pageSizeByDisplayRef.current.table || tableDefaultPageSize + : pageSizeByDisplayRef.current.card || initialQueryParams.size; + if (queryParams.size !== nextPageSize || queryParams.from !== 0) { + dispatch({ type: "setPageSizeAndReset", value: nextPageSize }); + } + }, [currentTab, dispalyTypeObj, queryParams.size, queryParams.from]); const { run, loading, value } = useFetch( searchAction, { diff --git a/web/src/pages/Alerting/Rule/Index.jsx b/web/src/pages/Alerting/Rule/Index.jsx index 047b0534..13b7006e 100644 --- a/web/src/pages/Alerting/Rule/Index.jsx +++ b/web/src/pages/Alerting/Rule/Index.jsx @@ -492,7 +492,7 @@ export default (props) => { }} defaultQueryParams={{ from: 0, - size: 10, + size: 20, sort: [[timeField, "desc"]], }} sortEnable={true} From 2aa1a6f45971e19504ffe4a7261bc0dff9cc27e3 Mon Sep 17 00:00:00 2001 From: hardy Date: Sat, 13 Jun 2026 17:43:50 +0800 Subject: [PATCH 341/426] fix: shard state with agent --- modules/elastic/api/cluster_overview.go | 10 +++- modules/elastic/api/node_overview.go | 57 +++++++++++++++--- web/src/components/Overview/index.tsx | 60 +++++++++++++++++-- .../Platform/Overview/Cluster/Card/index.js | 12 ++-- .../Platform/Overview/Indices/Card/index.js | 12 ++-- .../Platform/Overview/Node/Card/index.js | 12 ++-- 6 files changed, 135 insertions(+), 28 deletions(-) diff --git a/modules/elastic/api/cluster_overview.go b/modules/elastic/api/cluster_overview.go index 44986b82..d2b8ce09 100644 --- a/modules/elastic/api/cluster_overview.go +++ b/modules/elastic/api/cluster_overview.go @@ -1048,9 +1048,13 @@ func (h *APIHandler) getShardQPS(clusterID string, nodeUUID string, indexName st } if nodeUUID != "" { must = append(must, util.MapStr{ - "term": util.MapStr{ - "metadata.labels.node_id": util.MapStr{ - "value": nodeUUID, + "bool": util.MapStr{ + "minimum_should_match": 1, + "should": []util.MapStr{ + {"term": util.MapStr{"metadata.labels.node_id": util.MapStr{"value": nodeUUID}}}, + {"term": util.MapStr{"metadata.labels.node_uuid": util.MapStr{"value": nodeUUID}}}, + {"term": util.MapStr{"payload.elasticsearch.shard_stats.routing.node": util.MapStr{"value": nodeUUID}}}, + {"term": util.MapStr{"payload.elasticsearch.shard_stats.routing.current_node": util.MapStr{"value": nodeUUID}}}, }, }, }) diff --git a/modules/elastic/api/node_overview.go b/modules/elastic/api/node_overview.go index 76fecb37..08ddcc36 100644 --- a/modules/elastic/api/node_overview.go +++ b/modules/elastic/api/node_overview.go @@ -677,6 +677,41 @@ func (h *APIHandler) GetSingleNodeMetrics(w http.ResponseWriter, req *http.Reque } metrics["node_health"] = healthMetric } else if metricKey == ShardStateMetricKey { + nodeIdentityFilter := util.MapStr{ + "bool": util.MapStr{ + "minimum_should_match": 1, + "should": []util.MapStr{ + { + "term": util.MapStr{ + "metadata.labels.node_id": util.MapStr{ + "value": nodeID, + }, + }, + }, + { + "term": util.MapStr{ + "metadata.labels.node_uuid": util.MapStr{ + "value": nodeID, + }, + }, + }, + { + "term": util.MapStr{ + "payload.elasticsearch.shard_stats.routing.node": util.MapStr{ + "value": nodeID, + }, + }, + }, + { + "term": util.MapStr{ + "payload.elasticsearch.shard_stats.routing.current_node": util.MapStr{ + "value": nodeID, + }, + }, + }, + }, + }, + } query = util.MapStr{ "size": 0, "query": util.MapStr{ @@ -698,13 +733,7 @@ func (h *APIHandler) GetSingleNodeMetrics(w http.ResponseWriter, req *http.Reque }, }, }, - { - "term": util.MapStr{ - "metadata.labels.node_id": util.MapStr{ - "value": nodeID, - }, - }, - }, + nodeIdentityFilter, }, "filter": []util.MapStr{ { @@ -1115,6 +1144,18 @@ func (h *APIHandler) getNodeLatestIndicesAgent(req *http.Request, min, max, clus return nil, err } + nodeIdentityFilter := util.MapStr{ + "bool": util.MapStr{ + "minimum_should_match": 1, + "should": []util.MapStr{ + {"term": util.MapStr{"metadata.labels.node_id": util.MapStr{"value": nodeUUID}}}, + {"term": util.MapStr{"metadata.labels.node_uuid": util.MapStr{"value": nodeUUID}}}, + {"term": util.MapStr{"payload.elasticsearch.shard_stats.routing.node": util.MapStr{"value": nodeUUID}}}, + {"term": util.MapStr{"payload.elasticsearch.shard_stats.routing.current_node": util.MapStr{"value": nodeUUID}}}, + }, + }, + } + // Query shard_stats for this node in the time range. query := util.MapStr{ "size": 10000, @@ -1139,7 +1180,7 @@ func (h *APIHandler) getNodeLatestIndicesAgent(req *http.Request, min, max, clus {"term": util.MapStr{"metadata.category": util.MapStr{"value": "elasticsearch"}}}, {"term": util.MapStr{"metadata.labels.cluster_uuid": util.MapStr{"value": clusterUUID}}}, {"term": util.MapStr{"metadata.name": util.MapStr{"value": "shard_stats"}}}, - {"term": util.MapStr{"metadata.labels.node_id": util.MapStr{"value": nodeUUID}}}, + nodeIdentityFilter, }, }, }, diff --git a/web/src/components/Overview/index.tsx b/web/src/components/Overview/index.tsx index 5043b271..a824932f 100644 --- a/web/src/components/Overview/index.tsx +++ b/web/src/components/Overview/index.tsx @@ -110,6 +110,8 @@ export default forwardRef((props: IProps, ref: any) => { const [searchField, setSearchField] = useState(); const [selectedItem, setSelectedItem] = useState({}); + const [cardInfos, setCardInfos] = useState>({}); + const [cardInfoLoadings, setCardInfoLoadings] = useState>({}); const [dispalyTypeObj, setDispalyTypeObj] = useLocalStorage( "console:overview:displayType", @@ -210,6 +212,7 @@ export default forwardRef((props: IProps, ref: any) => { const result = (value as any)?.hits || {}; const { hits = [] } = result; const hasSide = sideSorterOptions.length > 0 || aggsParams.length > 0; + const displayType = dispalyTypeObj[currentTab] || "card"; const initQueryParams = () => { extraQueryFields.forEach((item) => { @@ -248,12 +251,57 @@ export default forwardRef((props: IProps, ref: any) => { }; useEffect(() => { - if (hits.length === 0) { + if (displayType !== "card" || loading || hits.length === 0 || !infoAction) { return; } - - // fetchListInfo(); - }, [value]); + const ids = hits + .map((item) => listItemConfig.getId(item)) + .filter((id): id is string => !!id && !cardInfos[id]); + if (ids.length === 0) { + return; + } + let cancelled = false; + const fetchListInfo = async () => { + setCardInfoLoadings((current) => { + const next = { ...current }; + ids.forEach((id) => { + next[id] = true; + }); + return next; + }); + try { + const res = await request( + infoAction, + { + method: "POST", + body: ids, + }, + false, + false + ); + if (!cancelled && res && !res.error) { + setCardInfos((current) => ({ + ...current, + ...res, + })); + } + } finally { + if (!cancelled) { + setCardInfoLoadings((current) => { + const next = { ...current }; + ids.forEach((id) => { + next[id] = false; + }); + return next; + }); + } + } + }; + fetchListInfo(); + return () => { + cancelled = true; + }; + }, [displayType, loading, hits, infoAction, listItemConfig, cardInfos]); useEffect(() => { const nextParam = { ...(param || {}), ...queryParams }; @@ -351,7 +399,7 @@ export default forwardRef((props: IProps, ref: any) => { }} />
- {dispalyTypeObj[currentTab] == "card" ? ( + {displayType == "card" ? ( { { setSelectedItem(item); diff --git a/web/src/pages/Platform/Overview/Cluster/Card/index.js b/web/src/pages/Platform/Overview/Cluster/Card/index.js index 3e1aef8e..1f1595ae 100644 --- a/web/src/pages/Platform/Overview/Cluster/Card/index.js +++ b/web/src/pages/Platform/Overview/Cluster/Card/index.js @@ -13,11 +13,11 @@ import { SearchEngineIcon } from "@/lib/search_engines"; import request from "@/utils/request"; export default (props) => { - const { infoAction, id, parentLoading } = props; + const { infoAction, id, parentLoading, info: prefetchedInfo, infoLoading } = props; const clusterID = props.data?._id; const metadata = props.data._source || {}; - const [info, setInfo] = useState({}); + const [info, setInfo] = useState(prefetchedInfo || {}); const [loading, setLoading] = useState(false) const fetchListInfo = async (id) => { @@ -35,10 +35,14 @@ export default (props) => { }; useEffect(() => { + if (prefetchedInfo) { + setInfo(prefetchedInfo); + return; + } if (!parentLoading) { fetchListInfo(id) } - }, [id, parentLoading]) + }, [id, parentLoading, prefetchedInfo, infoAction]) const summary = info?.summary || {}; const metrics = info?.metrics || {}; @@ -111,7 +115,7 @@ export default (props) => { const healthStatus = metadata.labels?.health_status; return ( - +
{ - const { infoAction, id, parentLoading } = props; + const { infoAction, id, parentLoading, info: prefetchedInfo, infoLoading } = props; const metadata = props.data._source?.metadata || {}; const timestamp = props.data._source?.timestamp ? formatUtcTimeToLocal(props.data._source?.timestamp) @@ -27,7 +27,7 @@ export default (props) => { return items; }; - const [info, setInfo] = useState({}); + const [info, setInfo] = useState(prefetchedInfo || {}); const [loading, setLoading] = useState(false) const fetchListInfo = async (id) => { @@ -45,10 +45,14 @@ export default (props) => { }; useEffect(() => { + if (prefetchedInfo) { + setInfo(prefetchedInfo); + return; + } if (!parentLoading) { fetchListInfo(id) } - }, [id, parentLoading]) + }, [id, parentLoading, prefetchedInfo, infoAction]) const summary = info?.summary || {}; const metrics = info?.metrics || {}; @@ -107,7 +111,7 @@ export default (props) => { const unassignedShards = (numReplicas+1)*numShards-summary?.shards - summary?.replicas || 0 return ( - +
{ - const { infoAction, id, parentLoading } = props; + const { infoAction, id, parentLoading, info: prefetchedInfo, infoLoading } = props; const metadata = props.data._source?.metadata || {}; - const [info, setInfo] = useState({}); + const [info, setInfo] = useState(prefetchedInfo || {}); const [loading, setLoading] = useState(false) const fetchListInfo = async (id) => { @@ -31,10 +31,14 @@ export default (props) => { }; useEffect(() => { + if (prefetchedInfo) { + setInfo(prefetchedInfo); + return; + } if (!parentLoading) { fetchListInfo(id) } - }, [id, parentLoading]) + }, [id, parentLoading, prefetchedInfo, infoAction]) const summary = info?.summary || {}; const metrics = info?.metrics || {}; @@ -106,7 +110,7 @@ export default (props) => { const healthStatus = metadata?.labels?.status; return ( - +
Date: Sat, 13 Jun 2026 18:28:24 +0800 Subject: [PATCH 342/426] fix: shard state with not data node --- .../pages/Platform/Overview/Node/Monitor/overview.jsx | 9 ++++++++- .../Platform/Overview/Node/Monitor/statistic_bar.jsx | 8 +++++++- 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/web/src/pages/Platform/Overview/Node/Monitor/overview.jsx b/web/src/pages/Platform/Overview/Node/Monitor/overview.jsx index 38edd230..a291c48b 100644 --- a/web/src/pages/Platform/Overview/Node/Monitor/overview.jsx +++ b/web/src/pages/Platform/Overview/Node/Monitor/overview.jsx @@ -9,8 +9,15 @@ export default (props) => { isAgent, clusterID, nodeID, + info, } = props + const nodeRoles = Array.isArray(info?.roles) ? info.roles : []; + const hasDataRole = + nodeRoles.length === 0 + ? true + : nodeRoles.some((role) => role === "data" || role?.startsWith?.("data_")); + return ( { "index_latency", "search_latency", "parent_breaker", - isAgent ? "shard_state" : undefined, + isAgent && hasDataRole ? "shard_state" : undefined, ].filter((item) => !!item)} /> ); diff --git a/web/src/pages/Platform/Overview/Node/Monitor/statistic_bar.jsx b/web/src/pages/Platform/Overview/Node/Monitor/statistic_bar.jsx index a6550485..16e72d54 100644 --- a/web/src/pages/Platform/Overview/Node/Monitor/statistic_bar.jsx +++ b/web/src/pages/Platform/Overview/Node/Monitor/statistic_bar.jsx @@ -14,7 +14,7 @@ const vstyle = { fontWeight: "bold", }; -const StatisticBar = ({ clusterID, nodeID, timeRange, setSpinning }) => { +const StatisticBar = ({ clusterID, nodeID, timeRange, setSpinning, onInfoChange }) => { if (!clusterID || !nodeID) { return null; } @@ -32,6 +32,12 @@ const StatisticBar = ({ clusterID, nodeID, timeRange, setSpinning }) => { setSpinning(loading); }, [loading]); + React.useEffect(() => { + if (onInfoChange) { + onInfoChange(nodeValue); + } + }, [JSON.stringify(nodeValue)]); + const isAvailable = loading || (nodeValue?.status && From 3c025a8c6924f0dacbd52fef12d2965e9c955f4e Mon Sep 17 00:00:00 2001 From: hardy Date: Sat, 13 Jun 2026 19:19:37 +0800 Subject: [PATCH 343/426] fix: reverse channel agent connect directory --- modules/agent/api/elasticsearch.go | 19 ++++---- modules/agent/api/reverse_channel.go | 27 +++++++++++ modules/agent/api/reverse_channel_test.go | 48 +++++++++++++++++++ modules/elastic/api/index_overview.go | 7 ++- modules/elastic/api/v1/index_overview.go | 4 +- .../components/DeleteIndexModal.jsx | 10 +--- 6 files changed, 90 insertions(+), 25 deletions(-) diff --git a/modules/agent/api/elasticsearch.go b/modules/agent/api/elasticsearch.go index 44c25cdf..184170bf 100644 --- a/modules/agent/api/elasticsearch.go +++ b/modules/agent/api/elasticsearch.go @@ -251,20 +251,17 @@ func GetElasticsearchNodesViaAgent(ctx context.Context, instance *model.Instance } obj := elastic.DiscoveryResult{} - _, err := proxyAgentRequestDirect(instance, req, &obj) - if err == nil { + if shouldUseReverseChannelOnlyForInstance(instance) { + _, err := ProxyAgentRequestViaChannel(instance.ID, req, &obj) + if err != nil { + return nil, err + } return &obj, nil } - if !IsAgentReverseChannelConnected(instance.ID) { - return nil, err - } - _, reverseErr := ProxyAgentRequestViaChannel(instance.ID, req, &obj) - if reverseErr != nil { - if shouldFallbackToDirectAgentDiscovery(reverseErr) { - return nil, err - } - return nil, reverseErr + _, err := proxyAgentRequest(instance, req, &obj) + if err != nil { + return nil, err } return &obj, nil diff --git a/modules/agent/api/reverse_channel.go b/modules/agent/api/reverse_channel.go index 510699bf..1387c487 100644 --- a/modules/agent/api/reverse_channel.go +++ b/modules/agent/api/reverse_channel.go @@ -21,6 +21,9 @@ import ( var errAgentReverseChannelDisconnected = framework_reverse.ErrDisconnected var errAgentReverseChannelNotConnected = framework_reverse.ErrNotConnected +const reverseChannelRegistrationLabelKey = "registered_via_reverse_channel" +const reverseChannelRegistrationLabelValue = "true" + var agentReverseChannel = framework_reverse.NewSessionManager(framework_reverse.ManagerOptions{}) var agentReverseChannelRegisterOnce sync.Once @@ -58,6 +61,9 @@ func onAgentReverseConnect(sessionID string, w http.ResponseWriter, r *http.Requ if err := agent_common.ValidateManagerRequestAuth(r, &instance, (*model.BasicAuth)(&global.Env().SystemConfig.Configs.ManagerConfig.BasicAuth)); err != nil { return err } + if err := ensureReverseChannelRegistrationMarker(&instance); err != nil { + log.Warnf("failed to persist reverse channel registration marker for agent [%s]: %v", instanceID, err) + } agentReverseChannel.RegisterPendingSession(sessionID, instanceID) return nil } @@ -114,6 +120,27 @@ func IsAgentReverseChannelConnected(instanceID string) bool { return agentReverseChannel.IsConnected(instanceID) } +func shouldUseReverseChannelOnlyForInstance(instance *model.Instance) bool { + if instance == nil || instance.Labels == nil { + return false + } + return strings.EqualFold(strings.TrimSpace(instance.Labels[reverseChannelRegistrationLabelKey]), reverseChannelRegistrationLabelValue) +} + +func ensureReverseChannelRegistrationMarker(instance *model.Instance) error { + if instance == nil { + return fmt.Errorf("instance is nil") + } + if shouldUseReverseChannelOnlyForInstance(instance) { + return nil + } + if instance.Labels == nil { + instance.Labels = map[string]string{} + } + instance.Labels[reverseChannelRegistrationLabelKey] = reverseChannelRegistrationLabelValue + return orm.Save(&orm.Context{Refresh: orm.WaitForRefresh}, instance) +} + func loadReverseAccessToken(instanceID string) (string, error) { instance := model.Instance{} instance.ID = instanceID diff --git a/modules/agent/api/reverse_channel_test.go b/modules/agent/api/reverse_channel_test.go index 5607d5ff..e6f77bcc 100644 --- a/modules/agent/api/reverse_channel_test.go +++ b/modules/agent/api/reverse_channel_test.go @@ -3,6 +3,8 @@ package api import ( "net/http/httptest" "testing" + + "infini.sh/framework/core/model" ) func TestAgentReverseChannelOnConnectAllowsGenericWebsocket(t *testing.T) { @@ -12,3 +14,49 @@ func TestAgentReverseChannelOnConnectAllowsGenericWebsocket(t *testing.T) { t.Fatalf("expected generic websocket connection to pass, got %v", err) } } + +func TestShouldUseReverseChannelOnlyForInstance(t *testing.T) { + cases := []struct { + name string + instance *model.Instance + expect bool + }{ + { + name: "nil instance", + expect: false, + }, + { + name: "empty labels", + instance: &model.Instance{ + Labels: map[string]string{}, + }, + expect: false, + }, + { + name: "marker enabled", + instance: &model.Instance{ + Labels: map[string]string{ + reverseChannelRegistrationLabelKey: reverseChannelRegistrationLabelValue, + }, + }, + expect: true, + }, + { + name: "marker mixed case", + instance: &model.Instance{ + Labels: map[string]string{ + reverseChannelRegistrationLabelKey: "TRUE", + }, + }, + expect: true, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := shouldUseReverseChannelOnlyForInstance(tc.instance); got != tc.expect { + t.Fatalf("unexpected reverse-only result, got=%v want=%v", got, tc.expect) + } + }) + } +} diff --git a/modules/elastic/api/index_overview.go b/modules/elastic/api/index_overview.go index aa6b2c19..c78318ae 100644 --- a/modules/elastic/api/index_overview.go +++ b/modules/elastic/api/index_overview.go @@ -284,7 +284,6 @@ func (h *APIHandler) FetchIndexInfo(w http.ResponseWriter, req *http.Request, ps h.WriteJSON(w, util.MapStr{}, http.StatusOK) return } - indexIDs = indexIDs[0:1] // map indexIDs(cluster_id:index_name => cluster_uuid:indexName) var ( indexIDM = map[string]string{} @@ -333,11 +332,15 @@ func (h *APIHandler) FetchIndexInfo(w http.ResponseWriter, req *http.Request, ps indexIDM[fmt.Sprintf("%s:%s", clusterID, indexName)] = newIndexID } } + if len(newIndexIDs) == 0 { + h.WriteJSON(w, util.MapStr{}, http.StatusOK) + return + } q1 := orm.Query{WildcardIndex: true} q1.Conds = orm.And( orm.Eq("metadata.category", "elasticsearch"), orm.Eq("metadata.name", "shard_stats"), - orm.Eq("metadata.labels.index_id", newIndexIDs[0]), + orm.In("metadata.labels.index_id", newIndexIDs), ) q1.Collapse("metadata.labels.shard_id") q1.AddSort("timestamp", orm.DESC) diff --git a/modules/elastic/api/v1/index_overview.go b/modules/elastic/api/v1/index_overview.go index e0d8ddb8..495a8644 100644 --- a/modules/elastic/api/v1/index_overview.go +++ b/modules/elastic/api/v1/index_overview.go @@ -52,13 +52,11 @@ func (h *APIHandler) FetchIndexInfo(w http.ResponseWriter, ctx context.Context, h.WriteJSON(w, util.MapStr{}, http.StatusOK) return } - //only query first index - indexIDs = indexIDs[0:1] q1 := orm.Query{WildcardIndex: true} q1.Conds = orm.And( orm.Eq("metadata.category", "elasticsearch"), orm.Eq("metadata.name", "index_stats"), - orm.Eq("metadata.labels.index_id", indexIDs[0]), + orm.In("metadata.labels.index_id", indexIDs), ) //q1.Collapse("metadata.labels.index_id") q1.AddSort("timestamp", orm.DESC) diff --git a/web/src/pages/DataManagement/components/DeleteIndexModal.jsx b/web/src/pages/DataManagement/components/DeleteIndexModal.jsx index b8f5ef69..49fdf15f 100644 --- a/web/src/pages/DataManagement/components/DeleteIndexModal.jsx +++ b/web/src/pages/DataManagement/components/DeleteIndexModal.jsx @@ -1,4 +1,4 @@ -import { Modal, Checkbox, Tag, Badge, Alert, Icon, Tooltip } from "antd"; +import { Modal, Checkbox, Tag, Badge, Alert, Icon } from "antd"; import { useCallback, useState, forwardRef, useMemo } from "react"; import { FormattedMessage } from 'react-intl'; import useFetch from "@/lib/hooks/use_fetch"; @@ -51,14 +51,6 @@ export default (props) => { cluster: {selectedClusterName || selectedClusterId || "-"} }} /> - {selectedClusterId && selectedClusterName !== selectedClusterId ? ( - <> - {" "} - - - - - ) : null}

    {props.items.map((item) => { From ec5533acb302ba0ee15afd8a88e1be710f85328e Mon Sep 17 00:00:00 2001 From: hardy Date: Sat, 13 Jun 2026 20:52:34 +0800 Subject: [PATCH 344/426] fix: some optimize with buckets and auto --- modules/elastic/api/index_overview.go | 27 +++++++- modules/elastic/api/v1/index_overview.go | 61 ++++++++++++++++++- plugin/api/alerting/rule.go | 46 ++++++++------ plugin/setup/setup.go | 38 ++++++------ web/src/components/ListView/index.jsx | 25 +++++--- .../Overview/Detail/Metrics/index.js | 4 +- .../Agent/Instance/components/Associate.jsx | 1 + .../Agent/Instance/components/AutoEnroll.jsx | 1 + .../Instance/components/UnknownAssociate.jsx | 1 + web/src/pages/Alerting/Message/Index.jsx | 4 +- .../Message/components/EventDetail.jsx | 8 +-- .../Message/components/RuleRecordChart.jsx | 4 +- .../Message/components/RuleRecords.jsx | 4 +- .../Alerting/Rule/components/RuleDetail.jsx | 4 +- .../Rule/components/RuleMetricChart.jsx | 4 +- .../Rule/components/RuleRecordChart.jsx | 30 ++++++++- web/src/pages/Cluster/Metrics.js | 4 +- .../Cluster/components/detail/metrics.js | 4 +- .../components/index/detail/metrics.js | 4 +- .../components/index/overview/index.jsx | 4 +- .../Cluster/components/node/detail/metrics.js | 4 +- .../components/node/overview/index.jsx | 4 +- .../Cluster/components/overview/index.jsx | 4 +- .../DataManagement/View/WidgetLoader.jsx | 5 +- 24 files changed, 214 insertions(+), 81 deletions(-) diff --git a/modules/elastic/api/index_overview.go b/modules/elastic/api/index_overview.go index c78318ae..470458db 100644 --- a/modules/elastic/api/index_overview.go +++ b/modules/elastic/api/index_overview.go @@ -46,6 +46,28 @@ import ( "infini.sh/framework/modules/elastic/common" ) +func normalizeRollupMetricIndexName(indexName string) string { + normalized := strings.TrimSpace(indexName) + if normalized == "" { + return normalized + } + prefixes := []string{ + v1.RollupClusterHealthKey + "_", + v1.RollupIndexHealthKey + "_", + v1.RollupClusterStataKey + "_", + v1.RollupIndexStatsKey + "_", + v1.RollupNodeStatsKey + "_", + v1.RollupShardStatsMetricsKey + "_", + v1.RollupShardStatsStateKey + "_", + } + for _, prefix := range prefixes { + if strings.HasPrefix(normalized, prefix) { + return strings.TrimPrefix(normalized, prefix) + } + } + return normalized +} + func (h *APIHandler) SearchIndexMetadata(w http.ResponseWriter, req *http.Request, ps httprouter.Params) { resBody := util.MapStr{} reqBody := struct { @@ -831,13 +853,14 @@ func (h *APIHandler) GetSingleIndexMetrics(w http.ResponseWriter, req *http.Requ h.APIHandler.GetSingleIndexMetrics(w, req, ps) return } - indexName := ps.MustGetParameter("index") - if !h.IsIndexAllowed(req, clusterID, indexName) { + rawIndexName := ps.MustGetParameter("index") + if !h.IsIndexAllowed(req, clusterID, rawIndexName) { h.WriteJSON(w, util.MapStr{ "error": http.StatusText(http.StatusForbidden), }, http.StatusForbidden) return } + indexName := normalizeRollupMetricIndexName(rawIndexName) clusterUUID, err := h.getClusterUUID(clusterID) if err != nil { log.Errorf("GetSingleIndexMetrics failed: %v", err) diff --git a/modules/elastic/api/v1/index_overview.go b/modules/elastic/api/v1/index_overview.go index 495a8644..ff2e7e4c 100644 --- a/modules/elastic/api/v1/index_overview.go +++ b/modules/elastic/api/v1/index_overview.go @@ -429,15 +429,67 @@ func (h *APIHandler) GetIndexShards(w http.ResponseWriter, req *http.Request, ps const IndexHealthMetricKey = "index_health" +func normalizeRollupMetricIndexName(indexName string) string { + normalized := strings.TrimSpace(indexName) + if normalized == "" { + return normalized + } + prefixes := []string{ + RollupClusterHealthKey + "_", + RollupIndexHealthKey + "_", + RollupClusterStataKey + "_", + RollupIndexStatsKey + "_", + RollupNodeStatsKey + "_", + RollupShardStatsMetricsKey + "_", + RollupShardStatsStateKey + "_", + } + for _, prefix := range prefixes { + if strings.HasPrefix(normalized, prefix) { + return strings.TrimPrefix(normalized, prefix) + } + } + return normalized +} + +func normalizeIndexHealthBucketSize(bucketSize int, min, max int64) int { + if bucketSize <= 0 { + bucketSize = 60 + } + durationSeconds := (max - min) / 1000 + if durationSeconds <= 0 { + return bucketSize + } + const ( + maxBuckets = int64(65535) + minBuckets = int64(24) + ) + minIntervalForMaxBuckets := int((durationSeconds + maxBuckets - 1) / maxBuckets) + if minIntervalForMaxBuckets < 1 { + minIntervalForMaxBuckets = 1 + } + maxIntervalForMinBuckets := int(durationSeconds / minBuckets) + if bucketSize < minIntervalForMaxBuckets { + bucketSize = minIntervalForMaxBuckets + } + if maxIntervalForMinBuckets >= 1 && bucketSize > maxIntervalForMinBuckets { + bucketSize = maxIntervalForMinBuckets + } + if bucketSize < 1 { + bucketSize = 1 + } + return bucketSize +} + func (h *APIHandler) GetSingleIndexMetrics(w http.ResponseWriter, req *http.Request, ps httprouter.Params) { clusterID := ps.MustGetParameter("id") - indexName := ps.MustGetParameter("index") - if !h.IsIndexAllowed(req, clusterID, indexName) { + rawIndexName := ps.MustGetParameter("index") + if !h.IsIndexAllowed(req, clusterID, rawIndexName) { h.WriteJSON(w, util.MapStr{ "error": http.StatusText(http.StatusForbidden), }, http.StatusForbidden) return } + indexName := normalizeRollupMetricIndexName(rawIndexName) var must = []util.MapStr{ { "term": util.MapStr{ @@ -593,7 +645,9 @@ func (h *APIHandler) GetSingleIndexMetrics(w http.ResponseWriter, req *http.Requ } func (h *APIHandler) GetIndexHealthMetric(ctx context.Context, id, indexName string, min, max int64, bucketSize int) (*common.MetricItem, error) { - bucketSizeStr := fmt.Sprintf("%vs", bucketSize) + indexName = normalizeRollupMetricIndexName(indexName) + healthBucketSize := normalizeIndexHealthBucketSize(bucketSize, min, max) + bucketSizeStr := fmt.Sprintf("%vs", healthBucketSize) intervalField, err := getDateHistogramIntervalField(global.MustLookupString(elastic.GlobalSystemElasticsearchID), bucketSizeStr) if err != nil { return nil, err @@ -688,6 +742,7 @@ func (h *APIHandler) GetIndexHealthMetric(ctx context.Context, id, indexName str } func (h *APIHandler) GetIndexStatusOfRecentDay(clusterID, indexName string) (map[string][]interface{}, error) { + indexName = normalizeRollupMetricIndexName(indexName) q := orm.Query{ WildcardIndex: true, } diff --git a/plugin/api/alerting/rule.go b/plugin/api/alerting/rule.go index 76b95947..3cb1b28e 100644 --- a/plugin/api/alerting/rule.go +++ b/plugin/api/alerting/rule.go @@ -1222,9 +1222,14 @@ func (alertAPI *AlertAPI) getHistoryMetricData(w http.ResponseWriter, req *http. alertAPI.WriteError(w, err.Error(), http.StatusInternalServerError) return } + indexName, queryDsl := buildAlertHistoryQuery(rule.ID, min, max) alertAPI.WriteJSON(w, util.MapStr{ "metric": metricItem, "bucket_label": rule.Metrics.BucketLabel, + "request": util.MapStr{ + "index": indexName, + "query": queryDsl, + }, }, http.StatusOK) } @@ -1232,25 +1237,9 @@ func (alertAPI *AlertAPI) getHistoryMetricData(w http.ResponseWriter, req *http. // a MetricItem whose lines are [timestamp_ms, value] pairs taken from condition_result. func buildMetricFromAlertHistory(rule *alerting.Rule, minMs, maxMs int64) (*alerting.AlertMetricItem, error) { esClient := elastic.GetClient(global.MustLookupString(elastic.GlobalSystemElasticsearchID)) - queryDsl := util.MapStr{ - "size": 1000, - "_source": []string{"created", "condition_result", "state"}, - "sort": []util.MapStr{{"created": util.MapStr{"order": "asc"}}}, - "query": util.MapStr{ - "bool": util.MapStr{ - "must": []util.MapStr{ - {"term": util.MapStr{"rule_id": util.MapStr{"value": rule.ID}}}, - {"range": util.MapStr{"created": util.MapStr{ - "gte": minMs, - "lte": maxMs, - "format": "epoch_millis", - }}}, - }, - }, - }, - } + indexName, queryDsl := buildAlertHistoryQuery(rule.ID, minMs, maxMs) searchRes, err := esClient.SearchWithRawQueryDSL( - orm.GetWildcardIndexName(alerting.Alert{}), + indexName, util.MustToJSONBytes(queryDsl), ) if err != nil { @@ -1336,6 +1325,27 @@ func buildMetricFromAlertHistory(rule *alerting.Rule, minMs, maxMs int64) (*aler return metricItem, nil } +func buildAlertHistoryQuery(ruleID string, minMs, maxMs int64) (string, util.MapStr) { + queryDsl := util.MapStr{ + "size": 1000, + "_source": []string{"created", "condition_result", "state"}, + "sort": []util.MapStr{{"created": util.MapStr{"order": "asc"}}}, + "query": util.MapStr{ + "bool": util.MapStr{ + "must": []util.MapStr{ + {"term": util.MapStr{"rule_id": util.MapStr{"value": ruleID}}}, + {"range": util.MapStr{"created": util.MapStr{ + "gte": minMs, + "lte": maxMs, + "format": "epoch_millis", + }}}, + }, + }, + }, + } + return orm.GetWildcardIndexName(alerting.Alert{}), queryDsl +} + func (alertAPI *AlertAPI) getMetricData(w http.ResponseWriter, req *http.Request, ps httprouter.Params) { rule := &alerting.Rule{ ID: ps.ByName("rule_id"), diff --git a/plugin/setup/setup.go b/plugin/setup/setup.go index 07fae757..6b9f6efe 100644 --- a/plugin/setup/setup.go +++ b/plugin/setup/setup.go @@ -936,23 +936,8 @@ func (module *Module) initialize(w http.ResponseWriter, r *http.Request, ps http //callback InvokeSetupCallback() - //update credential state - q := util.MapStr{ - "query": util.MapStr{ - "range": util.MapStr{ - "created": util.MapStr{ - "lte": "now-30s", - }, - }, - }, - "script": util.MapStr{ - "source": fmt.Sprintf("ctx._source['invalid'] = %v", secretMismatch), - }, - } - err = orm.UpdateBy(credential.Credential{}, util.MustToJSONBytes(q)) - if err != nil { - log.Error(err) - } + //update credential state in background to avoid blocking setup response + go updateCredentialState(secretMismatch) success = true } @@ -1060,6 +1045,25 @@ func newSetupSaveContext() *orm.Context { return ctx } +func updateCredentialState(secretMismatch bool) { + q := util.MapStr{ + "query": util.MapStr{ + "range": util.MapStr{ + "created": util.MapStr{ + "lte": "now-30s", + }, + }, + }, + "script": util.MapStr{ + "source": fmt.Sprintf("ctx._source['invalid'] = %v", secretMismatch), + }, + } + err := orm.UpdateBy(credential.Credential{}, util.MustToJSONBytes(q)) + if err != nil { + log.Error(err) + } +} + // getYamlData reads a YAML file from the setup directory and returns its content as a byte slice. func getYamlData(filename string) []byte { baseDir := path.Join(global.Env().GetConfigDir(), "setup") diff --git a/web/src/components/ListView/index.jsx b/web/src/components/ListView/index.jsx index b0945bee..4a3016c2 100644 --- a/web/src/components/ListView/index.jsx +++ b/web/src/components/ListView/index.jsx @@ -71,8 +71,8 @@ const normalizeHistogramRange = (timeRange = {}) => { const to = normalizeTimeRangeValue(timeRange?.to, "now"); if (from === "auto" || to === "auto") { return { - from: "now-15m", - to: "now", + from: "auto", + to: "auto", }; } return { from, to }; @@ -80,13 +80,13 @@ const normalizeHistogramRange = (timeRange = {}) => { const resolveAutoHistogramRange = (timeRange = {}, aggregations = {}) => { const normalized = normalizeHistogramRange(timeRange); - if (normalized.from !== "now-15m" || normalized.to !== "now") { - return normalized; - } const isAuto = normalizeTimeRangeValue(timeRange?.from, "") === "auto" || normalizeTimeRangeValue(timeRange?.to, "") === "auto"; - if (!isAuto) { + if (isAuto) { + return normalized; + } + if (normalized.from !== "now-15m" || normalized.to !== "now") { return normalized; } const minValue = aggregations?.__listview_min_time?.value; @@ -97,9 +97,18 @@ const resolveAutoHistogramRange = (timeRange = {}, aggregations = {}) => { minValue > 0 && maxValue > 0 ) { + const minTs = Number(minValue); + const maxTs = Number(maxValue); + if (maxTs <= minTs) { + const center = maxTs > 0 ? maxTs : Date.now(); + return { + from: moment(center).subtract(15, "minutes").toISOString(), + to: moment(center).toISOString(), + }; + } return { - from: moment(minValue).toISOString(), - to: moment(maxValue).toISOString(), + from: moment(minTs).toISOString(), + to: moment(maxTs).toISOString(), }; } return normalized; diff --git a/web/src/components/Overview/Detail/Metrics/index.js b/web/src/components/Overview/Detail/Metrics/index.js index 9994dc75..73a5ed02 100644 --- a/web/src/components/Overview/Detail/Metrics/index.js +++ b/web/src/components/Overview/Detail/Metrics/index.js @@ -35,8 +35,8 @@ export default (props) => { const [spinning, setSpinning] = useState(false); const [state, setState] = useState(initState({ timeRange: { - min: "now-15m", - max: "now", + min: "auto", + max: "auto", }, timeInterval: allTimeSettingsCache.timeInterval, timeout: allTimeSettingsCache.timeout || '10s', diff --git a/web/src/pages/Agent/Instance/components/Associate.jsx b/web/src/pages/Agent/Instance/components/Associate.jsx index 53bef472..1416e457 100644 --- a/web/src/pages/Agent/Instance/components/Associate.jsx +++ b/web/src/pages/Agent/Instance/components/Associate.jsx @@ -358,6 +358,7 @@ const Result = ({ data, onComplete, loading = false }) => {
{ { + const index = requestPayload?.index; + const query = requestPayload?.query; + if (!index || !query) { + return ""; + } + return `GET ${index}/_search\n${JSON.stringify(query, null, 2)}`; +}; const RuleMetricChart = ({ conditions, values }) => { const [timeRange, setTimeRange] = React.useState( @@ -46,6 +56,7 @@ const RuleMetricChart = ({ conditions, values }) => { }); const [metricData, setMetricData] = useState({}); + const [latestRequest, setLatestRequest] = useState(""); const hasMetricData = useMemo(() => { if (!Array.isArray(metricData?.lines)) { return false; @@ -96,6 +107,7 @@ const RuleMetricChart = ({ conditions, values }) => { }); if (res && !res.error) { setMetricData(res.metric); + setLatestRequest(buildCopyRequestText(res.request)); } console.log("preview_metric res:", res); }; @@ -130,12 +142,20 @@ const RuleMetricChart = ({ conditions, values }) => { return (
); @@ -144,7 +164,7 @@ const RuleMetricChart = ({ conditions, values }) => { return (
{ ); })} + {latestRequest ? ( +
+ + message.success(formatMessage({ id: "cluster.metrics.request.copy.success" })) + } + > + + + + +
+ ) : null}
); }; diff --git a/web/src/pages/Cluster/Activities.jsx b/web/src/pages/Cluster/Activities.jsx index 92988401..59e1f1b3 100644 --- a/web/src/pages/Cluster/Activities.jsx +++ b/web/src/pages/Cluster/Activities.jsx @@ -327,7 +327,7 @@ export default (props) => { defaultQueryParams={{ from: 0, size: 20, - timeRange: { from: "now-7d", to: "now", timeField: timeField }, + timeRange: { from: "auto", to: "auto", timeField: timeField }, sort: [[timeField, "desc"]], }} dateTimeEnable={true} diff --git a/web/src/pages/Cluster/Metrics.js b/web/src/pages/Cluster/Metrics.js index 5c5d9a0c..10772505 100644 --- a/web/src/pages/Cluster/Metrics.js +++ b/web/src/pages/Cluster/Metrics.js @@ -143,8 +143,8 @@ class ClusterMonitor extends PureComponent { clusterID: null, activeTab: props.param?.tab || "cluster", timeRange: { - min: "now-1h", - max: "now", + min: "auto", + max: "auto", timeFormatter: formatter.dates(1), }, }; @@ -168,18 +168,26 @@ class ClusterMonitor extends PureComponent { fetchDataCount++; const { dispatch } = this.props; const { timeRange } = this.state; - const bounds = calculateBounds({ - from: timeRange.min, - to: timeRange.max, - }); + const useAutoRange = + `${timeRange?.min || ""}`.toLowerCase() === "auto" || + `${timeRange?.max || ""}`.toLowerCase() === "auto"; + const resolvedRange = useAutoRange + ? { min: "auto", max: "auto" } + : (() => { + const bounds = calculateBounds({ + from: timeRange.min, + to: timeRange.max, + }); + return { + min: bounds.min.valueOf(), + max: bounds.max.valueOf(), + }; + })(); Promise.resolve( dispatch({ type: "clusterMonitor/fetchClusterMetrics", payload: { - timeRange: { - min: bounds.min.valueOf(), - max: bounds.max.valueOf(), - }, + timeRange: resolvedRange, cluster_id: this.state.clusterID, }, }) @@ -288,14 +296,17 @@ class ClusterMonitor extends PureComponent { } handleTimeChange = ({ start, end }) => { - const bounds = calculateBounds({ - from: start, - to: end, - }); - const day = moment - .duration(bounds.max.valueOf() - bounds.min.valueOf()) - .asDays(); - const intDay = parseInt(day) + 1; + let intDay = 1; + if (`${start || ""}`.toLowerCase() !== "auto" && `${end || ""}`.toLowerCase() !== "auto") { + const bounds = calculateBounds({ + from: start, + to: end, + }); + const day = moment + .duration(bounds.max.valueOf() - bounds.min.valueOf()) + .asDays(); + intDay = parseInt(day) + 1; + } this.setState( { timeRange: { diff --git a/web/src/pages/Cluster/components/cluster_metric.jsx b/web/src/pages/Cluster/components/cluster_metric.jsx index bd018d0f..2ba3abe8 100644 --- a/web/src/pages/Cluster/components/cluster_metric.jsx +++ b/web/src/pages/Cluster/components/cluster_metric.jsx @@ -17,10 +17,10 @@ import styles from "../Metrics.scss"; import { Spin, Radio, Select, Skeleton } from "antd"; import { formatter, getFormatter, getNumFormatter } from "@/utils/format"; import "./node_metric.scss"; -import { calculateBounds } from "@/components/vendor/data/common/query/timefilter"; import moment from "moment"; import { formatMessage } from "umi/locale"; import _ from "lodash"; +import { formatTimeRange } from "@/lib/elasticsearch/util"; export default ({ clusterID, @@ -34,14 +34,7 @@ export default ({ return null; } const queryParams = React.useMemo(() => { - const bounds = calculateBounds({ - from: timeRange.min, - to: timeRange.max, - }); - let params = { - min: bounds.min.valueOf(), - max: bounds.max.valueOf(), - }; + const params = formatTimeRange(timeRange); if (overview) { params.overview = overview; } diff --git a/web/src/pages/Cluster/components/index_metric.jsx b/web/src/pages/Cluster/components/index_metric.jsx index e104ca3f..58b6f674 100644 --- a/web/src/pages/Cluster/components/index_metric.jsx +++ b/web/src/pages/Cluster/components/index_metric.jsx @@ -17,11 +17,11 @@ import styles from "../Metrics.scss"; import { Spin, Radio, Select, Skeleton } from "antd"; import { formatter, getFormatter, getNumFormatter } from "@/utils/format"; import "./node_metric.scss"; -import { calculateBounds } from "@/components/vendor/data/common/query/timefilter"; import moment from "moment"; import { formatMessage } from "umi/locale"; import MetricContainer from "./metric_container"; import _ from "lodash"; +import { formatTimeRange } from "@/lib/elasticsearch/util"; const gorupOrder = [ "operations", @@ -64,14 +64,7 @@ export default ({ }); }; const queryParams = React.useMemo(() => { - const bounds = calculateBounds({ - from: timeRange.min, - to: timeRange.max, - }); - let newParams = { - min: bounds.min.valueOf(), - max: bounds.max.valueOf(), - }; + const newParams = formatTimeRange(timeRange); if (param.top) { newParams.top = param.top; } diff --git a/web/src/pages/Cluster/components/queue_metric.jsx b/web/src/pages/Cluster/components/queue_metric.jsx index 9c76367d..f2c98ba9 100644 --- a/web/src/pages/Cluster/components/queue_metric.jsx +++ b/web/src/pages/Cluster/components/queue_metric.jsx @@ -16,11 +16,11 @@ import styles from "../Metrics.scss"; import { Spin, Radio, Select, Skeleton, Row, Col } from "antd"; import { formatter, getFormatter, getNumFormatter } from "@/utils/format"; import "./node_metric.scss"; -import { calculateBounds } from "@/components/vendor/data/common/query/timefilter"; import moment from "moment"; import { formatMessage } from "umi/locale"; import MetricContainer from "./metric_container"; import _ from "lodash"; +import { formatTimeRange } from "@/lib/elasticsearch/util"; const gorupOrder = [ "thread_pool_write", @@ -98,14 +98,7 @@ export default ({ [param] ); const queryParams = React.useMemo(() => { - const bounds = calculateBounds({ - from: timeRange.min, - to: timeRange.max, - }); - let newParams = { - min: bounds.min.valueOf(), - max: bounds.max.valueOf(), - }; + const newParams = formatTimeRange(timeRange); if (param.top) { newParams.top = param.top; } diff --git a/web/src/services/cluster.js b/web/src/services/cluster.js index c4bcb835..ef59161c 100644 --- a/web/src/services/cluster.js +++ b/web/src/services/cluster.js @@ -10,8 +10,20 @@ export async function getClusterVersion(params) { export async function getClusterMetrics(params) { let id = params.cluster_id; delete params["cluster_id"]; - const min = Number.isFinite(params?.timeRange?.min) ? params.timeRange.min : Date.now() - 15 * 60 * 1000; - const max = Number.isFinite(params?.timeRange?.max) ? params.timeRange.max : Date.now(); + const rawMin = params?.timeRange?.min; + const rawMax = params?.timeRange?.max; + const min = + `${rawMin ?? ""}`.toLowerCase() === "auto" + ? "auto" + : Number.isFinite(rawMin) + ? rawMin + : Date.now() - 15 * 60 * 1000; + const max = + `${rawMax ?? ""}`.toLowerCase() === "auto" + ? "auto" + : Number.isFinite(rawMax) + ? rawMax + : Date.now(); return request( `${ESPrefix}/${id}/metrics?min=${min}&max=${max}`, { From ad45f949b6c1120bb08625b7efeff82c9db2827f Mon Sep 17 00:00:00 2001 From: hardy Date: Sun, 14 Jun 2026 11:39:40 +0800 Subject: [PATCH 355/426] improve: email tpl with zh cn --- config/setup/common/alerting.zh-CN.tpl | 4 +-- .../Agent/Instance/components/RowDetail.jsx | 3 +- web/src/pages/Alerting/Rule/Edit.jsx | 29 +------------------ 3 files changed, 5 insertions(+), 31 deletions(-) diff --git a/config/setup/common/alerting.zh-CN.tpl b/config/setup/common/alerting.zh-CN.tpl index e5a1ca35..25e66503 100644 --- a/config/setup/common/alerting.zh-CN.tpl +++ b/config/setup/common/alerting.zh-CN.tpl @@ -454,7 +454,7 @@ POST $[[SETUP_INDEX_PREFIX]]channel/$[[SETUP_DOC_TYPE]]/cgnb2r53q95nmusjl6vg "cc": [] }, "subject": "[INFINI 平台告警] 🔥 {{.title}}", - "body": "\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n
\n
\n \n \n \n \n \n
\n
\n
\n \n
\n \n \n \n \n
\n
\n \n \n \n \n \n \n
\n \"email-header\"\n
\n
\n \n \n \n \n \n \n
\n
\n
\n {{.title}}\n

\n \n

优先级: {{.priority}}

\n

事件编号: {{.event_id}}

\n

目标: {{.resource_name}}-{{.objects}}

\n

触发时间: {{.trigger_at | datetime}}

\n {{.message | md_to_html}}\n
\n

\n \n \n View Detail\n \n

\n \n \n \n

\n \n

\n \"INFINI\n
\n
\n \n
\n

\n \n \n
\n
\n \n \n \n
\n
\n
\n \n
\n \n \n \n \n
\n
\n
\n
\n \n \n \n
\n
\n \n
\n \n \n
\n
\n \n ", + "body": "\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n
\n \n \n \n \n \n \n
\n
\n
\n \n
\n \n \n \n \n
\n
\n \n \n \n \n \n \n
\n \"email-header\"\n
\n
\n \n \n \n \n \n \n
\n
\n
\n {{.title}}\n

\n \n

优先级: {{.priority}}

\n

事件编号: {{.event_id}}

\n

目标: {{.resource_name}}-{{.objects}}

\n

触发时间: {{.trigger_at | datetime}}

\n {{.message | md_to_html}}\n
\n

\n \n \n 查看明细\n \n

\n \n \n \n

\n \n

\n \"INFINI\n
\n
\n \n
\n

\n \n \n
\n
\n \n \n \n
\n
\n
\n \n
\n \n \n \n \n
\n
\n
\n
\n \n \n \n
\n
\n \n
\n \n \n
\n
\n \n ", "content_type": "text/html" }, "enabled": false @@ -474,7 +474,7 @@ POST $[[SETUP_INDEX_PREFIX]]channel/$[[SETUP_DOC_TYPE]]/cj8atf53q95lhahebg8g "cc": [] }, "subject": "[INFINI 平台告警恢复] {{.title}}", - "body": "\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n
\n \n \n \n \n \n \n
\n
\n
\n \n
\n \n \n \n \n
\n
\n \n \n \n \n \n \n
\n \"email-header\"\n
\n
\n \n \n \n \n \n \n
\n
\n
\n {{.title}}\n

\n {{.message | md_to_html}}\n
\n

\n \n \n View Detail\n \n

\n \n \n \n

\n \n

\n \"INFINI\n
\n
\n \n
\n

\n \n \n
\n
\n \n \n \n
\n
\n
\n \n
\n \n \n \n \n
\n
\n
\n
\n \n \n \n
\n
\n \n
\n \n \n
\n
\n \n ", + "body": "\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n
\n \n \n \n \n \n \n
\n
\n
\n \n
\n \n \n \n \n
\n
\n \n \n \n \n \n \n
\n \"email-header\"\n
\n
\n \n \n \n \n \n \n
\n
\n
\n {{.title}}\n

\n {{.message | md_to_html}}\n
\n

\n \n \n 查看明细\n \n

\n \n \n \n

\n \n

\n \"INFINI\n
\n
\n \n
\n

\n \n \n
\n
\n \n \n \n
\n
\n
\n \n
\n \n \n \n \n
\n
\n
\n
\n \n \n \n
\n
\n \n
\n \n \n
\n
\n \n ", "content_type": "text/html" }, "enabled": false diff --git a/web/src/pages/Agent/Instance/components/RowDetail.jsx b/web/src/pages/Agent/Instance/components/RowDetail.jsx index b6b8f0ed..c4f8464b 100644 --- a/web/src/pages/Agent/Instance/components/RowDetail.jsx +++ b/web/src/pages/Agent/Instance/components/RowDetail.jsx @@ -279,6 +279,7 @@ export const AgentRowDetail = ({ agentID, t }) => { width: 100, }, { + key: "collection_interval", title: ( {formatMessage({ id: "agent.instance.collection_interval.label" })} @@ -597,7 +598,7 @@ export const AgentRowDetail = ({ agentID, t }) => { { start: range[0], end: range[1], total } ), }} - columns={columns} + columns={columns.filter((item) => item.key !== "collection_interval")} />
diff --git a/web/src/pages/Alerting/Rule/Edit.jsx b/web/src/pages/Alerting/Rule/Edit.jsx index 3c493969..15445e1c 100644 --- a/web/src/pages/Alerting/Rule/Edit.jsx +++ b/web/src/pages/Alerting/Rule/Edit.jsx @@ -10,8 +10,7 @@ export default Form.create({ name: "rule_form_edit" })((props) => { const { match } = props; const ruleID = match.params.rule_id; const [submitLoading, setSubmitLoading] = useState(false) - const [syncLoading, setSyncLoading] = useState(false); - const { loading, error, value, run: reloadRule } = useFetch( + const { loading, error, value } = useFetch( `/alerting/rule/${ruleID}`, null, [ruleID] @@ -49,30 +48,6 @@ export default Form.create({ name: "rule_form_edit" })((props) => { [value] ); - const onSyncTemplateClick = useCallback(async () => { - setSyncLoading(true); - const res = await request(`/alerting/rule/${ruleID}/_sync_template`, { - method: "POST", - }); - if (res && res.result == "updated") { - message.success( - formatMessage({ - id: "alert.rule.form.template.sync.success", - }) - ); - reloadRule(); - } else { - message.error( - res?.message || - res?.error || - formatMessage({ - id: "alert.rule.form.template.sync.failed", - }) - ); - } - setSyncLoading(false); - }, [reloadRule, ruleID]); - const formatChannelItems = (items) => { return items?.map((item) => { const channelItem = item[item.type]; @@ -124,8 +99,6 @@ export default Form.create({ name: "rule_form_edit" })((props) => { {...props} title={formatMessage({ id: "alert.rule.form.title.edit" })} onSaveClick={onSaveClick} - onSyncTemplateClick={onSyncTemplateClick} - syncLoading={syncLoading} value={editValue} submitLoading={submitLoading} /> From 253cd3290f5e610b1af89a5675fd92270baf9d02 Mon Sep 17 00:00:00 2001 From: hardy Date: Sun, 14 Jun 2026 12:08:13 +0800 Subject: [PATCH 356/426] fix: auto time range for search --- modules/elastic/api/discover.go | 91 +++++++++++++++++++++++++++++++ plugin/api/alerting/alert.go | 18 +++++- plugin/api/alerting/message.go | 8 +-- plugin/api/alerting/time_range.go | 14 +++++ 4 files changed, 126 insertions(+), 5 deletions(-) create mode 100644 plugin/api/alerting/time_range.go diff --git a/modules/elastic/api/discover.go b/modules/elastic/api/discover.go index 6193a68e..70978d39 100644 --- a/modules/elastic/api/discover.go +++ b/modules/elastic/api/discover.go @@ -34,6 +34,7 @@ import ( "infini.sh/framework/core/orm" "infini.sh/framework/core/util" "net/http" + "strings" "time" ) @@ -66,6 +67,7 @@ func (h *APIHandler) HandleEseSearchAction(w http.ResponseWriter, req *http.Requ h.WriteError(w, err.Error(), http.StatusInternalServerError) return } + sanitizeAutoRangeInMap(reqParams.Body) //validate index search api permission reqUser, err := security.FromUserContext(req.Context()) if err != nil { @@ -207,6 +209,95 @@ func (h *APIHandler) HandleEseSearchAction(w http.ResponseWriter, req *http.Requ h.Write(w, searchRes.RawResult.Body) } +func sanitizeAutoRangeInMap(data map[string]interface{}) bool { + if data == nil { + return false + } + changed := false + for key, val := range data { + switch typed := val.(type) { + case map[string]interface{}: + if key == "range" { + if sanitizeAutoRangeNode(typed) { + changed = true + } + if len(typed) == 0 { + delete(data, key) + changed = true + } + continue + } + if sanitizeAutoRangeInMap(typed) { + changed = true + } + if len(typed) == 0 { + delete(data, key) + changed = true + } + case []interface{}: + newList := make([]interface{}, 0, len(typed)) + listChanged := false + for _, item := range typed { + if itemMap, ok := item.(map[string]interface{}); ok { + if sanitizeAutoRangeInMap(itemMap) { + listChanged = true + } + if len(itemMap) == 0 { + listChanged = true + continue + } + newList = append(newList, itemMap) + continue + } + newList = append(newList, item) + } + if listChanged { + data[key] = newList + changed = true + } + } + } + return changed +} + +func sanitizeAutoRangeNode(rangeNode map[string]interface{}) bool { + changed := false + for field, condVal := range rangeNode { + cond, ok := condVal.(map[string]interface{}) + if !ok { + continue + } + for _, boundKey := range []string{"gte", "lte", "gt", "lt", "from", "to"} { + if isAutoRangeValue(cond[boundKey]) { + delete(cond, boundKey) + changed = true + } + } + if !hasRangeBounds(cond) { + delete(cond, "format") + } + if len(cond) == 0 || !hasRangeBounds(cond) { + delete(rangeNode, field) + changed = true + } + } + return changed +} + +func hasRangeBounds(rangeCond map[string]interface{}) bool { + for _, key := range []string{"gte", "lte", "gt", "lt", "from", "to"} { + if _, ok := rangeCond[key]; ok { + return true + } + } + return false +} + +func isAutoRangeValue(value interface{}) bool { + v, ok := value.(string) + return ok && strings.EqualFold(strings.TrimSpace(v), "auto") +} + func (h *APIHandler) HandleValueSuggestionAction(w http.ResponseWriter, req *http.Request, ps httprouter.Params) { resBody := map[string]interface{}{} targetClusterID := ps.ByName("id") diff --git a/plugin/api/alerting/alert.go b/plugin/api/alerting/alert.go index 7194bcb6..885bc5f6 100644 --- a/plugin/api/alerting/alert.go +++ b/plugin/api/alerting/alert.go @@ -102,7 +102,23 @@ func (h *AlertAPI) searchAlert(w http.ResponseWriter, req *http.Request, ps http mustBuilder = &strings.Builder{} sortBuilder = strings.Builder{} ) - mustBuilder.WriteString(fmt.Sprintf(`{"range":{"created":{"gte":"%s", "lte": "%s"}}}`, min, max)) + timeRange := util.MapStr{} + if minValue, ok := normalizeTimeBound(min); ok { + timeRange["gte"] = minValue + } + if maxValue, ok := normalizeTimeBound(max); ok { + timeRange["lte"] = maxValue + } + if len(timeRange) > 0 { + timeFilter := util.MapStr{ + "range": util.MapStr{ + "created": timeRange, + }, + } + mustBuilder.Write(util.MustToJSONBytes(timeFilter)) + } else { + mustBuilder.WriteString(`{"match_all":{}}`) + } if ruleID != "" { mustBuilder.WriteString(fmt.Sprintf(`,{"term":{"rule_id":{"value":"%s"}}}`, ruleID)) } diff --git a/plugin/api/alerting/message.go b/plugin/api/alerting/message.go index e809e277..113f3170 100644 --- a/plugin/api/alerting/message.go +++ b/plugin/api/alerting/message.go @@ -277,11 +277,11 @@ func (h *AlertAPI) searchAlertMessage(w http.ResponseWriter, req *http.Request, tags = h.GetParameterOrDefault(req, "tags", "") ) timeRange := util.MapStr{} - if min != "" { - timeRange["gte"] = min + if minValue, ok := normalizeTimeBound(min); ok { + timeRange["gte"] = minValue } - if max != "" { - timeRange["lte"] = max + if maxValue, ok := normalizeTimeBound(max); ok { + timeRange["lte"] = maxValue } if len(timeRange) > 0 { timeFilter := util.MapStr{ diff --git a/plugin/api/alerting/time_range.go b/plugin/api/alerting/time_range.go new file mode 100644 index 00000000..aae6e0b7 --- /dev/null +++ b/plugin/api/alerting/time_range.go @@ -0,0 +1,14 @@ +package alerting + +import "strings" + +func normalizeTimeBound(value string) (string, bool) { + v := strings.TrimSpace(value) + if v == "" { + return "", false + } + if strings.EqualFold(v, "auto") { + return "", false + } + return v, true +} From 407e5768552a0fc809a67bee2be8b6fc44d7a12f Mon Sep 17 00:00:00 2001 From: hardy Date: Sun, 14 Jun 2026 12:40:53 +0800 Subject: [PATCH 357/426] fix: cluster uuid as uniqe for update --- modules/agent/api/elasticsearch.go | 76 ++++++++++++++++++- modules/elastic/api/manage.go | 8 ++ web/src/models/global.js | 36 ++++++++- .../Agent/Instance/components/Associate.jsx | 32 +++++--- 4 files changed, 134 insertions(+), 18 deletions(-) diff --git a/modules/agent/api/elasticsearch.go b/modules/agent/api/elasticsearch.go index 184170bf..a45659da 100644 --- a/modules/agent/api/elasticsearch.go +++ b/modules/agent/api/elasticsearch.go @@ -273,7 +273,8 @@ func shouldFallbackToDirectAgentDiscovery(err error) bool { type BindingItem struct { //infini system assigned id - ClusterID string `json:"cluster_id"` + ClusterID string `json:"cluster_id"` + ClusterName string `json:"cluster_name,omitempty"` ClusterUUID string `json:"cluster_uuid"` NodeUUID string `json:"node_uuid"` @@ -873,7 +874,7 @@ func bindInstanceToCluster(clusterInfo ClusterInfo, nodes *elastic.DiscoveryResu } clusterUUID := states.ClusterUUID - auth, err := common.GetAgentBasicAuth(preparedConf) + auth, err := resolveEnrollAgentAuth(preparedConf) if err != nil { log.Errorf("failed to get agent credential for cluster [%s]: %v", console_common.MaskLogToken(clusterID), err) continue @@ -1269,6 +1270,67 @@ func hasUsableAgentBasicAuth(auth *model.BasicAuth) bool { return auth == nil || auth.Username != "" } +func resolveEnrollClusterID(clusterID, clusterUUID, clusterName string) (string, error) { + clusterID = strings.TrimSpace(clusterID) + clusterUUID = strings.TrimSpace(clusterUUID) + clusterName = strings.TrimSpace(clusterName) + + // Prefer the explicit cluster id from UI when it still exists and is enabled. + if clusterID != "" { + conf := &elastic.ElasticsearchConfig{} + conf.ID = clusterID + exists, err := orm.GetV2(orm.NewContext(), conf) + if err != nil { + return "", err + } + if exists && conf.Enabled { + if clusterUUID == "" || strings.TrimSpace(conf.ClusterUUID) == "" || strings.TrimSpace(conf.ClusterUUID) == clusterUUID { + return clusterID, nil + } + } + } + + if clusterUUID == "" { + return clusterID, nil + } + + q := orm.Query{ + Size: 100, + Conds: orm.And( + orm.Eq("cluster_uuid", clusterUUID), + orm.Eq("enabled", true), + ), + } + clusters := []elastic.ElasticsearchConfig{} + if err, _ := orm.SearchWithJSONMapper(&clusters, &q); err != nil { + return "", err + } + if len(clusters) == 0 { + return clusterID, nil + } + if clusterName != "" { + for _, item := range clusters { + if strings.TrimSpace(item.Name) == clusterName || strings.TrimSpace(item.RawName) == clusterName { + return item.ID, nil + } + } + } + return clusters[0].ID, nil +} + +func resolveEnrollAgentAuth(conf *elastic.ElasticsearchConfig) (*model.BasicAuth, error) { + auth, err := common.GetAgentBasicAuth(conf) + if err == nil { + return auth, nil + } + if strings.Contains(strings.ToLower(err.Error()), "record not found") { + // Missing credential record should not block no-auth clusters from enrollment. + // Continue with anonymous access and let node probe decide reachability. + return nil, nil + } + return nil, err +} + func normalizeSchema(schema string) string { switch strings.ToLower(strings.TrimSpace(schema)) { case "https": @@ -1391,6 +1453,14 @@ func (h *APIHandler) enrollESNode(w http.ResponseWriter, req *http.Request, ps h h.WriteError(w, err.Error(), http.StatusInternalServerError) return } + resolvedClusterID, err := resolveEnrollClusterID(item.ClusterID, item.ClusterUUID, item.ClusterName) + if err != nil { + h.WriteError(w, err.Error(), http.StatusInternalServerError) + return + } + if resolvedClusterID != "" { + item.ClusterID = resolvedClusterID + } if strings.TrimSpace(item.PublishAddress) == "" && item.ClusterID != "" && item.NodeUUID != "" { if nodeCfg, nodeErr := metadata.GetNodeConfig(item.ClusterID, item.NodeUUID); nodeErr == nil && nodeCfg != nil && nodeCfg.Payload.NodeInfo != nil { @@ -1406,7 +1476,7 @@ func (h *APIHandler) enrollESNode(w http.ResponseWriter, req *http.Request, ps h } //use agent credential to access the node - preparedConf.BasicAuth, err = common.GetAgentBasicAuth(preparedConf) + preparedConf.BasicAuth, err = resolveEnrollAgentAuth(preparedConf) if err != nil { h.WriteError(w, err.Error(), http.StatusInternalServerError) return diff --git a/modules/elastic/api/manage.go b/modules/elastic/api/manage.go index 49526cf7..c7a56ad0 100644 --- a/modules/elastic/api/manage.go +++ b/modules/elastic/api/manage.go @@ -455,6 +455,14 @@ func getManagedAgentCredential(conf *elastic.ElasticsearchConfig, previousCluste cred, err := common.GetCredential(conf.AgentCredentialID) if err != nil { + // Agent credential can be deleted manually while cluster config still keeps + // the old credential id. Treat it as absent and let follow-up logic + // re-provision or fall back based on current auth settings. + if strings.Contains(strings.ToLower(err.Error()), "record not found") { + conf.AgentCredentialID = "" + conf.AgentBasicAuth = nil + return nil, nil, true, nil + } return nil, nil, false, err } auth, err := cred.DecodeBasicAuth() diff --git a/web/src/models/global.js b/web/src/models/global.js index 27d2cdad..10a7ccb2 100644 --- a/web/src/models/global.js +++ b/web/src/models/global.js @@ -25,6 +25,35 @@ const CLUSTER_STATUS_CACHE_TTL = 60 * 1000; const CONSOLE_WELCOME_BANNER_STYLE = "color:#1677ff;font-size:14px;font-weight:700;font-family:Inter,-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;"; +const mergeUniqueClusters = (existing = [], incoming = []) => { + const seenIDs = new Set(); + const seenUUIDs = new Set(); + const result = []; + + const append = (item) => { + if (!item?.id) { + return; + } + const uuid = `${item.cluster_uuid || ""}`.trim(); + if (seenIDs.has(item.id)) { + return; + } + if (uuid && seenUUIDs.has(uuid)) { + return; + } + seenIDs.add(item.id); + if (uuid) { + seenUUIDs.add(uuid); + } + result.push(item); + }; + + // Prefer fresh data from latest fetch, then backfill old entries that don't conflict. + incoming.forEach(append); + existing.forEach(append); + return result; +}; + const formatBuildDate = (value) => { if (!value) { return value; @@ -194,9 +223,10 @@ export default { }); } - let newClusterList = search.name !== payload.name - ? data - : clusterList.concat(data); + let newClusterList = + search.name !== payload.name + ? data + : mergeUniqueClusters(clusterList, data); yield put({ type: "saveData", diff --git a/web/src/pages/Agent/Instance/components/Associate.jsx b/web/src/pages/Agent/Instance/components/Associate.jsx index 950814c6..cc38d7da 100644 --- a/web/src/pages/Agent/Instance/components/Associate.jsx +++ b/web/src/pages/Agent/Instance/components/Associate.jsx @@ -229,19 +229,27 @@ const Result = ({ data, onComplete, loading = false }) => { const { clusterList = [] } = useGlobal(); const detectedLogsPaths = useMemo(() => getDetectedLogsPaths(data), [data]); const detectedLogsPath = detectedLogsPaths.join(", "); - const clusters = useMemo( - () => { - if (!data.cluster_info.cluster_uuid) { - return []; - } - return clusterList.filter((item) => { - return item.cluster_uuid == data.cluster_info.cluster_uuid; + const clusters = useMemo(() => { + const clusterUUID = `${data?.cluster_info?.cluster_uuid || ""}`.trim(); + if (!clusterUUID) { + return []; + } + const clusterName = `${data?.cluster_info?.cluster_name || ""}`.trim(); + const toTs = (item) => new Date(item?.updated || item?.created || 0).getTime() || 0; + return (clusterList || []) + .filter((item) => `${item?.cluster_uuid || ""}`.trim() === clusterUUID) + .sort((a, b) => { + const aName = `${a?.name || a?.raw_name || ""}`.trim(); + const bName = `${b?.name || b?.raw_name || ""}`.trim(); + const aNameMatched = clusterName && aName === clusterName ? 1 : 0; + const bNameMatched = clusterName && bName === clusterName ? 1 : 0; + if (aNameMatched !== bNameMatched) { + return bNameMatched - aNameMatched; + } + return toTs(b) - toTs(a); }); - }, - clusterList, - data.cluster_info.cluster_uuid - ); - const selectedID = clusters[0]?.id || ""; + }, [clusterList, data?.cluster_info?.cluster_uuid, data?.cluster_info?.cluster_name]); + const selectedID = useMemo(() => clusters[0]?.id || "", [clusters]); const clusterRef = useRef(); const onAssociateClick = () => { if (typeof onComplete === "function") { From c072eb4d35d89312a16a5182b7fad0d9a946f754 Mon Sep 17 00:00:00 2001 From: hardy Date: Sun, 14 Jun 2026 14:53:02 +0800 Subject: [PATCH 358/426] fix: use auto date histogram --- core/insight/metric_util.go | 28 +++-- modules/elastic/api/index_overview.go | 46 ++++---- modules/elastic/api/node_overview.go | 22 ++-- modules/elastic/api/value_parse.go | 101 ++++++++++++++++ plugin/api/insight/metadata.go | 2 + plugin/api/insight/time_range.go | 109 ++++++++++++++++++ web/src/components/Overview/Monitor/index.jsx | 13 ++- web/src/global.less | 7 +- 8 files changed, 283 insertions(+), 45 deletions(-) create mode 100644 modules/elastic/api/value_parse.go create mode 100644 plugin/api/insight/time_range.go diff --git a/core/insight/metric_util.go b/core/insight/metric_util.go index 623b7bf5..29ac5b55 100644 --- a/core/insight/metric_util.go +++ b/core/insight/metric_util.go @@ -195,15 +195,27 @@ func GenerateQuery(metric *Metric) (interface{}, error) { ) if metric.BucketSize != "" && metric.TimeField != "" { useDateHistogram = true - // Default to auto for better handling of varying data densities - dateHistogramAggName = "auto_date_histogram" - buckets := metric.Buckets - if buckets == 0 { - buckets = 2 + bucketSize := strings.TrimSpace(metric.BucketSize) + if bucketSize == "" { + bucketSize = "auto" } - dateHistogramAgg = util.MapStr{ - "field": metric.TimeField, - "buckets": buckets, + if strings.EqualFold(bucketSize, "auto") { + dateHistogramAggName = "auto_date_histogram" + buckets := metric.Buckets + if buckets == 0 { + buckets = 30 + } + dateHistogramAgg = util.MapStr{ + "field": metric.TimeField, + "buckets": buckets, + } + } else { + dateHistogramAggName = "date_histogram" + dateHistogramAgg = util.MapStr{ + "field": metric.TimeField, + "interval": bucketSize, + "min_doc_count": 0, + } } } diff --git a/modules/elastic/api/index_overview.go b/modules/elastic/api/index_overview.go index 1317e028..90ae4ba6 100644 --- a/modules/elastic/api/index_overview.go +++ b/modules/elastic/api/index_overview.go @@ -388,6 +388,7 @@ func (h *APIHandler) FetchIndexInfo(w http.ResponseWriter, req *http.Request, ps indexID, _ := util.GetMapValueByKeys([]string{"metadata", "labels", "index_id"}, hitM) indexName, _ := util.GetMapValueByKeys([]string{"metadata", "labels", "index_name"}, hitM) primary, _ := util.GetMapValueByKeys([]string{"payload", "elasticsearch", "shard_stats", "routing", "primary"}, hitM) + isPrimary, _ := parseBoolValue(primary) if v, ok := indexID.(string); ok { if _, ok = summaryMap[v]; !ok { summaryMap[v] = &ShardsSummary{} @@ -396,16 +397,16 @@ func (h *APIHandler) FetchIndexInfo(w http.ResponseWriter, req *http.Request, ps if iv, ok := indexName.(string); ok { indexInfo.Index = iv } - if count, ok := shardDocCount.(float64); ok && primary == true { - indexInfo.DocsCount += int64(count) + if count, ok := parseInt64Value(shardDocCount); ok && isPrimary { + indexInfo.DocsCount += count } - if deleted, ok := shardDocDeleted.(float64); ok && primary == true { - indexInfo.DocsDeleted += int64(deleted) + if deleted, ok := parseInt64Value(shardDocDeleted); ok && isPrimary { + indexInfo.DocsDeleted += deleted } - if storeSize, ok := storeInBytes.(float64); ok { - indexInfo.StoreInBytes += int64(storeSize) - if primary == true { - indexInfo.PriStoreInBytes += int64(storeSize) + if storeSize, ok := parseInt64Value(storeInBytes); ok { + indexInfo.StoreInBytes += storeSize + if isPrimary { + indexInfo.PriStoreInBytes += storeSize } } missingIndexIDs := make([]interface{}, 0) @@ -452,11 +453,11 @@ func (h *APIHandler) FetchIndexInfo(w http.ResponseWriter, req *http.Request, ps summary := summaryMap[indexIDStr] if docs, ok := util.GetMapValueByKeys([]string{"payload", "elasticsearch", "index_stats", "total", "docs"}, result); ok { if docsM, ok := docs.(map[string]interface{}); ok { - if count, ok := docsM["count"].(float64); ok { - summary.DocsCount = int64(count) + if count, ok := parseInt64Value(docsM["count"]); ok { + summary.DocsCount = count } - if deleted, ok := docsM["deleted"].(float64); ok { - summary.DocsDeleted = int64(deleted) + if deleted, ok := parseInt64Value(docsM["deleted"]); ok { + summary.DocsDeleted = deleted } } } @@ -465,10 +466,10 @@ func (h *APIHandler) FetchIndexInfo(w http.ResponseWriter, req *http.Request, ps if idxName, ok := infoM["index"].(string); ok { summary.Index = idxName } - if shards, ok := infoM["shards"].(float64); ok { + if shards, ok := parseInt64Value(infoM["shards"]); ok { summary.Shards = int(shards) } - if replicas, ok := infoM["replicas"].(float64); ok { + if replicas, ok := parseInt64Value(infoM["replicas"]); ok { summary.Replicas = int(replicas) } if storeSize, ok := infoM["store_size"].(string); ok { @@ -486,7 +487,7 @@ func (h *APIHandler) FetchIndexInfo(w http.ResponseWriter, req *http.Request, ps summary.Timestamp = result["timestamp"] } } - if primary == true { + if isPrimary { indexInfo.Shards++ } else { indexInfo.Replicas++ @@ -787,20 +788,21 @@ func (h *APIHandler) GetIndexInfo(w http.ResponseWriter, req *http.Request, ps h resultM, ok := row.(map[string]interface{}) if ok { primary, _ := util.GetMapValueByKeys([]string{"payload", "elasticsearch", "shard_stats", "routing", "primary"}, resultM) + isPrimary, _ := parseBoolValue(primary) storeInBytes, _ := util.GetMapValueByKeys([]string{"payload", "elasticsearch", "shard_stats", "store", "size_in_bytes"}, resultM) if docs, ok := util.GetMapValueByKeys([]string{"payload", "elasticsearch", "shard_stats", "docs", "count"}, resultM); ok { //summary["docs"] = docs - if v, ok := docs.(float64); ok && primary == true { - shardSum.DocsCount += int64(v) + if v, ok := parseInt64Value(docs); ok && isPrimary { + shardSum.DocsCount += v } } - if storeSize, ok := storeInBytes.(float64); ok { - shardSum.StoreInBytes += int64(storeSize) - if primary == true { - shardSum.PriStoreInBytes += int64(storeSize) + if storeSize, ok := parseInt64Value(storeInBytes); ok { + shardSum.StoreInBytes += storeSize + if isPrimary { + shardSum.PriStoreInBytes += storeSize } } - if primary == true { + if isPrimary { shardSum.Shards++ } else { shardSum.Replicas++ diff --git a/modules/elastic/api/node_overview.go b/modules/elastic/api/node_overview.go index 8cc34fee..f78c651a 100644 --- a/modules/elastic/api/node_overview.go +++ b/modules/elastic/api/node_overview.go @@ -1195,6 +1195,7 @@ func (h *APIHandler) getNodeLatestIndicesAgent(req *http.Request, min, max, clus shardID, _ := util.GetMapValueByKeys([]string{"metadata", "labels", "shard_id"}, hitM) shardNum, _ := util.GetMapValueByKeys([]string{"metadata", "labels", "shard"}, hitM) primary, _ := util.GetMapValueByKeys([]string{"payload", "elasticsearch", "shard_stats", "routing", "primary"}, hitM) + isPrimary, _ := parseBoolValue(primary) if v, ok := indexName.(string); ok { dedupeKey := util.ToString(shardID) if dedupeKey == "" { @@ -1208,13 +1209,13 @@ func (h *APIHandler) getNodeLatestIndicesAgent(req *http.Request, min, max, clus indexInfos[v] = &ShardsSummary{Index: v} } info := indexInfos[v] - if count, ok := shardDocCount.(float64); ok && primary == true { - info.DocsCount += int64(count) + if count, ok := parseInt64Value(shardDocCount); ok && isPrimary { + info.DocsCount += count } - if storeSize, ok := storeInBytes.(float64); ok { - info.StoreInBytes += int64(storeSize) + if storeSize, ok := parseInt64Value(storeInBytes); ok { + info.StoreInBytes += storeSize } - if primary == true { + if isPrimary { info.Shards++ } else { info.Replicas++ @@ -1396,6 +1397,7 @@ func (h *APIHandler) getLatestIndices(req *http.Request, min string, max string, shardID, _ := util.GetMapValueByKeys([]string{"metadata", "labels", "shard_id"}, hitM) shardNum, _ := util.GetMapValueByKeys([]string{"metadata", "labels", "shard"}, hitM) primary, _ := util.GetMapValueByKeys([]string{"payload", "elasticsearch", "shard_stats", "routing", "primary"}, hitM) + isPrimary, _ := parseBoolValue(primary) if v, ok := indexName.(string); ok { dedupeKey := util.ToString(shardID) if dedupeKey == "" { @@ -1410,13 +1412,13 @@ func (h *APIHandler) getLatestIndices(req *http.Request, min string, max string, } indexInfo := indexInfos[v] indexInfo.Index = v - if count, ok := shardDocCount.(float64); ok && primary == true { - indexInfo.DocsCount += int64(count) + if count, ok := parseInt64Value(shardDocCount); ok && isPrimary { + indexInfo.DocsCount += count } - if storeSize, ok := storeInBytes.(float64); ok { - indexInfo.StoreInBytes += int64(storeSize) + if storeSize, ok := parseInt64Value(storeInBytes); ok { + indexInfo.StoreInBytes += storeSize } - if primary == true { + if isPrimary { indexInfo.Shards++ } else { indexInfo.Replicas++ diff --git a/modules/elastic/api/value_parse.go b/modules/elastic/api/value_parse.go new file mode 100644 index 00000000..8042cb40 --- /dev/null +++ b/modules/elastic/api/value_parse.go @@ -0,0 +1,101 @@ +package api + +import ( + "encoding/json" + "strings" + + "infini.sh/framework/core/util" +) + +func parseInt64Value(value interface{}) (int64, bool) { + if value == nil { + return 0, false + } + switch v := value.(type) { + case int: + return int64(v), true + case int8: + return int64(v), true + case int16: + return int64(v), true + case int32: + return int64(v), true + case int64: + return v, true + case uint: + return int64(v), true + case uint8: + return int64(v), true + case uint16: + return int64(v), true + case uint32: + return int64(v), true + case uint64: + return int64(v), true + case float32: + return int64(v), true + case float64: + return int64(v), true + case json.Number: + fv, err := v.Float64() + if err != nil { + return 0, false + } + return int64(fv), true + case string: + trimmed := strings.TrimSpace(v) + if trimmed == "" { + return 0, false + } + iv, err := util.ToInt64(trimmed) + if err != nil { + return 0, false + } + return iv, true + default: + iv, err := util.ToInt64(strings.TrimSpace(util.ToString(v))) + if err != nil { + return 0, false + } + return iv, true + } +} + +func parseBoolValue(value interface{}) (bool, bool) { + if value == nil { + return false, false + } + switch v := value.(type) { + case bool: + return v, true + case string: + switch strings.ToLower(strings.TrimSpace(v)) { + case "true", "1", "yes", "y": + return true, true + case "false", "0", "no", "n": + return false, true + default: + return false, false + } + case int, int8, int16, int32, int64: + iv, ok := parseInt64Value(v) + if !ok { + return false, false + } + return iv != 0, true + case uint, uint8, uint16, uint32, uint64: + iv, ok := parseInt64Value(v) + if !ok { + return false, false + } + return iv != 0, true + case float32, float64, json.Number: + iv, ok := parseInt64Value(v) + if !ok { + return false, false + } + return iv != 0, true + default: + return false, false + } +} diff --git a/plugin/api/insight/metadata.go b/plugin/api/insight/metadata.go index 5eb6bf74..a1a41b16 100644 --- a/plugin/api/insight/metadata.go +++ b/plugin/api/insight/metadata.go @@ -265,6 +265,8 @@ func (h *InsightAPI) HandleGetMetricData(w http.ResponseWriter, req *http.Reques return } } + sanitizeAutoRangeInValue(reqBody.Filter) + sanitizeAutoRangeInValue(reqBody.TimeFilter) reqBody.ClusterId = clusterID metricData, err := getMetricData(&reqBody) if err != nil { diff --git a/plugin/api/insight/time_range.go b/plugin/api/insight/time_range.go new file mode 100644 index 00000000..1ca60324 --- /dev/null +++ b/plugin/api/insight/time_range.go @@ -0,0 +1,109 @@ +package insight + +import "strings" + +func sanitizeAutoRangeInValue(val interface{}) bool { + switch typed := val.(type) { + case map[string]interface{}: + return sanitizeAutoRangeInMap(typed) + case []interface{}: + changed := false + for _, item := range typed { + if sanitizeAutoRangeInValue(item) { + changed = true + } + } + return changed + default: + return false + } +} + +func sanitizeAutoRangeInMap(data map[string]interface{}) bool { + if data == nil { + return false + } + changed := false + for key, val := range data { + switch typed := val.(type) { + case map[string]interface{}: + if key == "range" { + if sanitizeAutoRangeNode(typed) { + changed = true + } + if len(typed) == 0 { + delete(data, key) + changed = true + } + continue + } + if sanitizeAutoRangeInMap(typed) { + changed = true + } + if len(typed) == 0 { + delete(data, key) + changed = true + } + case []interface{}: + newList := make([]interface{}, 0, len(typed)) + listChanged := false + for _, item := range typed { + if itemMap, ok := item.(map[string]interface{}); ok { + if sanitizeAutoRangeInMap(itemMap) { + listChanged = true + } + if len(itemMap) == 0 { + listChanged = true + continue + } + newList = append(newList, itemMap) + continue + } + newList = append(newList, item) + } + if listChanged { + data[key] = newList + changed = true + } + } + } + return changed +} + +func sanitizeAutoRangeNode(rangeNode map[string]interface{}) bool { + changed := false + for field, condVal := range rangeNode { + cond, ok := condVal.(map[string]interface{}) + if !ok { + continue + } + for _, boundKey := range []string{"gte", "lte", "gt", "lt", "from", "to"} { + if isAutoRangeValue(cond[boundKey]) { + delete(cond, boundKey) + changed = true + } + } + if !hasRangeBounds(cond) { + delete(cond, "format") + } + if len(cond) == 0 || !hasRangeBounds(cond) { + delete(rangeNode, field) + changed = true + } + } + return changed +} + +func hasRangeBounds(rangeCond map[string]interface{}) bool { + for _, key := range []string{"gte", "lte", "gt", "lt", "from", "to"} { + if _, ok := rangeCond[key]; ok { + return true + } + } + return false +} + +func isAutoRangeValue(value interface{}) bool { + v, ok := value.(string) + return ok && strings.EqualFold(strings.TrimSpace(v), "auto") +} diff --git a/web/src/components/Overview/Monitor/index.jsx b/web/src/components/Overview/Monitor/index.jsx index 8d7be3d5..35f2c2cc 100644 --- a/web/src/components/Overview/Monitor/index.jsx +++ b/web/src/components/Overview/Monitor/index.jsx @@ -71,11 +71,18 @@ const getDuration = (from, to) => { } const normalizeTimeValue = (value, fallback = "now-15m", keys = []) => { + const normalizeCandidate = (candidate) => { + const normalized = `${candidate}`.trim(); + if (!normalized || normalized.toLowerCase() === "auto") { + return fallback; + } + return normalized; + }; if (typeof value === "string" || typeof value === "number") { if (typeof value === "number" && !Number.isFinite(value)) { return fallback; } - return `${value}`; + return normalizeCandidate(value); } if (value && typeof value === "object") { for (const key of keys) { @@ -86,7 +93,7 @@ const normalizeTimeValue = (value, fallback = "now-15m", keys = []) => { ) { const candidate = value[key]; if (typeof candidate === "string" || typeof candidate === "number") { - return `${candidate}`; + return normalizeCandidate(candidate); } } } @@ -277,7 +284,7 @@ const Monitor = (props) => { setTimeZone(timeZone) }} recentlyUsedRangesKey={'monitor'} - showAutoTimeRange={true} + showAutoTimeRange={false} />
diff --git a/web/src/global.less b/web/src/global.less index 029c2c8a..0b8e9f21 100644 --- a/web/src/global.less +++ b/web/src/global.less @@ -42,8 +42,11 @@ ol { :global { .ant-drawer.ant-drawer-right { .ant-drawer-content-wrapper { - top: @layout-header-height; - height: calc(100% - @layout-header-height); + top: 0; + right: 0; + height: 100%; + width: 100vw !important; + max-width: 100vw; } } } From f3fcabbb4a2701df1d37083d685d1bdb7de1f1d6 Mon Sep 17 00:00:00 2001 From: hardy Date: Sun, 14 Jun 2026 15:15:31 +0800 Subject: [PATCH 359/426] fix: use auto date histogram and reufse connection for migration --- service/alerting/elasticsearch/engine.go | 29 ++++++++- service/alerting/elasticsearch/engine_test.go | 9 +++ web/src/pages/System/Audit/index.jsx | 63 +++++++++++++++++++ 3 files changed, 100 insertions(+), 1 deletion(-) diff --git a/service/alerting/elasticsearch/engine.go b/service/alerting/elasticsearch/engine.go index 016ca7cc..91e0f79d 100644 --- a/service/alerting/elasticsearch/engine.go +++ b/service/alerting/elasticsearch/engine.go @@ -1291,11 +1291,38 @@ func normalizeAlertTemplateText(text string) string { continue } line = collapseDuplicatedLeadingAlertEmoji(line) - result = append(result, line) + result = append(result, splitRepeatedMetricEntryLine(line)...) } return strings.Join(result, "\n") } +func splitRepeatedMetricEntryLine(line string) []string { + for _, prefix := range []string{"节点:", "Node:"} { + if strings.Count(line, prefix) <= 1 { + continue + } + parts := strings.Split(line, prefix) + entries := make([]string, 0, len(parts)) + for i, part := range parts { + part = strings.TrimSpace(part) + if i == 0 { + if part != "" { + entries = append(entries, part) + } + continue + } + if part == "" { + continue + } + entries = append(entries, prefix+" "+part) + } + if len(entries) > 1 { + return entries + } + } + return []string{line} +} + func collapseDuplicatedLeadingAlertEmoji(line string) string { trimmed := strings.TrimLeft(line, " \t") for _, emoji := range []string{"🌈", "🔥"} { diff --git a/service/alerting/elasticsearch/engine_test.go b/service/alerting/elasticsearch/engine_test.go index b6fc8472..667c6031 100644 --- a/service/alerting/elasticsearch/engine_test.go +++ b/service/alerting/elasticsearch/engine_test.go @@ -320,6 +320,15 @@ func TestNormalizeAlertTemplateTextPreservesMarkdownHardBreakSpacing(t *testing. } } +func TestNormalizeAlertTemplateTextSplitsRepeatedNodeEntries(t *testing.T) { + input := "节点: es817 所属集群: migrator-es817, JVM 使用率: 65% 节点: es717 所属集群: migrator-es717, JVM 使用率: 56% 节点: node-03 所属集群: es-cluster-3node, JVM 使用率: 42%" + got := normalizeAlertTemplateText(input) + want := "节点: es817 所属集群: migrator-es817, JVM 使用率: 65%\n节点: es717 所属集群: migrator-es717, JVM 使用率: 56%\n节点: node-03 所属集群: es-cluster-3node, JVM 使用率: 42%" + if got != want { + t.Fatalf("expected repeated node entries to be split by lines, got %q", got) + } +} + func TestFormatAlertDuration(t *testing.T) { cases := []struct { name string diff --git a/web/src/pages/System/Audit/index.jsx b/web/src/pages/System/Audit/index.jsx index fcabac8a..76d525cb 100644 --- a/web/src/pages/System/Audit/index.jsx +++ b/web/src/pages/System/Audit/index.jsx @@ -17,6 +17,11 @@ export default (props) => { const clusterID = getSystemClusterID(); const collectionName = "audit_log"; const timeField = "timestamp"; //timestamp + const [histogramState, setHistogramState] = useState({ + enable: true, + visible: false, + widget: {}, + }); const [visible, setVisible] = useState(false); const [selectedItem, setSelectedItem] = useState(); @@ -138,6 +143,61 @@ export default (props) => { const selectedRows = listViewRef.current?.selectedRows.rows || [] downloadFile(selectedRows, 'text/plain', 'audit_log.json') } + + const initHistogramWidget = async () => { + let res = await request(`/collection/${collectionName}/metadata`); + let indexName = res?.metadata?.index_name || ""; + if (indexName) { + let widget = { + bucket_size: "auto", + is_stack: true, + format: { + type: "number", + pattern: "0.00a", + }, + legend: false, + series: [ + { + metric: { + formula: "a", + groups: [ + { + field: "metadata.log_type", + limit: 10, + }, + ], + items: [ + { + field: "*", + name: "a", + statistic: "count", + }, + ], + sort: [ + { + direction: "desc", + key: "_count", + }, + ], + }, + queries: { + cluster_id: clusterID, + indices: [indexName], + time_field: timeField, + }, + type: "date-histogram", + }, + ], + }; + setHistogramState((st) => ({ ...st, widget })); + } + }; + + useEffect(() => { + if (histogramState.enable) { + initHistogramWidget(); + } + }, []); return ( @@ -163,6 +223,9 @@ export default (props) => { sideVisible={false} sidePlacement="left" datePickerContainerStyle={{ width: 320, maxWidth: "45vw", minWidth: 270 }} + histogramEnable={histogramState.enable} + histogramVisible={histogramState.visible} + histogramWidget={histogramState.widget} rowSelectionExtra={{ getExtra: (props) => [ + +
+ + ); +}); + +const Token = () => { + const initialQueryParams = { + from: 0, + size: 20, + keyword: "", + }; + + function reducer(queryParams, action) { + switch (action.type) { + case "search": + return { + ...queryParams, + from: 0, + keyword: action.value, + }; + case "pagination": + return { + ...queryParams, + from: (action.current - 1) * action.pageSize, + size: action.pageSize, + }; + case "refresh": + return { + ...queryParams, + _t: Date.now(), + }; + default: + return queryParams; + } + } + + const [queryParams, dispatch] = useReducer(reducer, initialQueryParams); + const [visible, setVisible] = useState(false); + const [submitLoading, setSubmitLoading] = useState(false); + const [selectedItem, setSelectedItem] = useState(); + + const canSearch = hasAuthority("security:auth:api-token:search"); + const canCreate = hasAuthority("security:auth:api-token:create"); + const canUpdate = hasAuthority("security:auth:api-token:update"); + const canDelete = hasAuthority("security:auth:api-token:delete"); + + const { loading, value, run } = useFetch( + "/auth/access_token/_search", + { + queryParams, + noticeable: false, + }, + [queryParams], + canSearch + ); + + const openCreateTokenResult = (token) => { + Modal.success({ + title: formatMessage({ id: "system.security.token.create.result.title" }), + content: ( +
+

{formatMessage({ id: "system.security.token.create.result.tip" })}

+ +
+ ), + okText: formatMessage({ id: "form.button.ok" }), + width: 640, + }); + }; + + const onDelete = async (tokenID) => { + const res = await request(`/auth/access_token/${tokenID}`, { + method: "DELETE", + }); + if (res?.result === "deleted") { + message.success(formatMessage({ id: "app.message.delete.success" })); + run(); + return; + } + message.error(res?.error?.reason || formatMessage({ id: "app.message.delete.failed" })); + }; + + const onSubmit = async (formValue) => { + if (formValue == null) { + setVisible(false); + setSelectedItem(undefined); + return; + } + if (submitLoading) { + return; + } + + setSubmitLoading(true); + try { + if (selectedItem?.id) { + const res = await request(`/auth/access_token/${selectedItem.id}`, { + method: "PUT", + body: { + name: formValue.name, + description: formValue.description, + }, + }); + if (res?.result === "updated") { + message.success(formatMessage({ id: "app.message.update.success" })); + setVisible(false); + setSelectedItem(undefined); + run(); + return; + } + message.error(res?.error?.reason || formatMessage({ id: "app.message.update.failed" })); + return; + } + + const res = await request(`/auth/access_token`, { + method: "POST", + body: { + name: formValue.name, + description: formValue.description, + }, + }); + if (res?._id && res?.access_token) { + message.success(formatMessage({ id: "app.message.create.success" })); + setVisible(false); + setSelectedItem(undefined); + run(); + openCreateTokenResult(res.access_token); + return; + } + message.error(res?.error?.reason || formatMessage({ id: "app.message.create.failed" })); + } finally { + setSubmitLoading(false); + } + }; + + const { data, total } = useMemo(() => formatESSearchResult(value), [value]); + + const columns = [ + { + title: formatMessage({ id: "table.field.id" }), + dataIndex: "id", + render: (text) => ( +
+ + {text || "-"} +
+ ), + }, + { + title: formatMessage({ id: "system.security.token.table.name" }), + dataIndex: "name", + render: (text) => text || "-", + }, + { + title: formatMessage({ id: "system.security.token.table.description" }), + dataIndex: "description", + render: (text) => text || "-", + }, + { + title: formatMessage({ id: "system.security.token.table.permissions" }), + dataIndex: "permissions", + render: (permissions) => (permissions || []).join(", ") || "-", + }, + { + title: formatMessage({ id: "system.security.token.table.expire" }), + dataIndex: "expire_in", + width: 180, + render: (expireIn) => + Number(expireIn) > 0 + ? moment.unix(expireIn).format("YYYY.MM.DD HH:mm:ss") + : formatMessage({ id: "system.security.token.never_expire" }), + }, + { + title: formatMessage({ id: "table.field.actions" }), + width: 150, + render: (_, record) => ( + <> + {canUpdate ? ( + { + setSelectedItem(record); + setVisible(true); + }} + > + {formatMessage({ id: "form.button.edit" })} + + ) : null} + {canUpdate && canDelete ? : null} + {canDelete ? ( + onDelete(record.id)} + > + {formatMessage({ id: "form.button.delete" })} + + ) : null} + + ), + }, + ]; + + if (!canUpdate && !canDelete) { + columns.splice(columns.length - 1, 1); + } + + return ( + +
+
+ dispatch({ type: "search", value: keyword })} + /> +
+
+ + {canCreate ? ( + + ) : null} +
+
+ + formatMessage( + { id: "system.security.pagination.total" }, + { start: range[0], end: range[1], total: t } + ), + }} + onChange={(pagination) => + dispatch({ + type: "pagination", + current: pagination.current, + pageSize: pagination.pageSize, + }) + } + /> + { + if (!submitLoading) { + setVisible(false); + setSelectedItem(undefined); + } + }} + visible={visible} + title={formatMessage({ + id: selectedItem?.id + ? "system.security.token.drawer.edit.title" + : "system.security.token.drawer.create.title", + })} + destroyOnClose + > + + + + ); +}; + +export default Token; diff --git a/web/src/pages/System/Security/index.jsx b/web/src/pages/System/Security/index.jsx index 8e9a45f4..401348cb 100644 --- a/web/src/pages/System/Security/index.jsx +++ b/web/src/pages/System/Security/index.jsx @@ -25,6 +25,8 @@ import "@/assets/headercontent.scss"; const { TabPane } = Tabs; import User from "../User/index"; import Role from "../Role/index"; +import Token from "./Token"; +import { hasAuthority } from "@/utils/authority"; const Security = (props) => { const [param, setParam] = useQueryParam("_g", JsonParam); @@ -49,6 +51,15 @@ const Security = (props) => { > + //TODO: with CRUD for system.security:all + {hasAuthority("security:auth:api-token:search") ? ( + {formatMessage({ id: "system.security.tab.token" })}} + key="token" + > + + + ) : null} diff --git a/web/src/pages/System/User/index.jsx b/web/src/pages/System/User/index.jsx index 6f0c3f5c..a261364b 100644 --- a/web/src/pages/System/User/index.jsx +++ b/web/src/pages/System/User/index.jsx @@ -10,6 +10,7 @@ import { Input, message, Icon, + Switch, } from "antd"; import { formatMessage, getLocale } from "umi/locale"; import useFetch from "@/lib/hooks/use_fetch"; @@ -40,6 +41,11 @@ const displayOrDash = (value) => { return text ? text : "-"; }; +const isAdministratorUser = (record) => + (record?.roles || []).some( + (role) => role?.id === "Administrator" || role?.name === "Administrator" + ); + const UserList = (props) => { const [queryParams, setQueryParams] = React.useState({}); const [searchValue, setSearchValue] = React.useState(""); @@ -71,6 +77,25 @@ const UserList = (props) => { }, [setQueryParams] ); + const onToggleEnabledClick = useCallback( + async (userID, enabled) => { + const actionUrl = enabled ? "/user/_enable" : "/user/_disable"; + const res = await request(actionUrl, { + method: "POST", + body: [userID], + }); + if (res?.acknowledged) { + message.success(formatMessage({ id: "app.message.operate.success" })); + onRefreshClick(); + return; + } + message.error(formatMessage({ id: "app.message.operate.failed" })); + }, + [setQueryParams] + ); + + const normalizeEnabled = (value) => value !== false; + const columns = useMemo( () => [ { @@ -113,9 +138,29 @@ const UserList = (props) => { return displayOrDash(text); }, }, + { + title: formatMessage({ id: "system.security.user.table.status" }), + dataIndex: "enabled", + render: (enabled, record) => { + if (isAdministratorUser(record)) { + return "-"; + } + const checked = normalizeEnabled(enabled); + return ( + + onToggleEnabledClick(record.id, nextChecked) + } + /> + ); + }, + }, { title: formatMessage({ id: "table.field.actions" }), - width: getLocale() === "zh-CN" ? 180 : 220, + width: getLocale() === "zh-CN" ? 180 : 240, render: (text, record) => (
{hasAuthority("system.security:all") ? ( @@ -151,7 +196,7 @@ const UserList = (props) => { }, ], - [value] + [value, onToggleEnabledClick] ); const { data: users, total } = React.useMemo(() => { setIsLoading(loading); From 0ef94c76c3c5a1ce88864bf2fe5c0d09ab3ffe55 Mon Sep 17 00:00:00 2001 From: hardy Date: Tue, 28 Jul 2026 21:12:04 +0800 Subject: [PATCH 419/426] fix: setup with use enabled and remove token --- plugin/setup/setup.go | 2 ++ web/src/pages/System/Security/index.jsx | 11 ----------- 2 files changed, 2 insertions(+), 11 deletions(-) diff --git a/plugin/setup/setup.go b/plugin/setup/setup.go index 692ec027..fdec9c83 100644 --- a/plugin/setup/setup.go +++ b/plugin/setup/setup.go @@ -838,6 +838,7 @@ func (module *Module) initialize(w http.ResponseWriter, r *http.Request, ps http if request.shouldResetBootstrapUser() { //Save bootstrap user + enabled := true user := security.User{} user.ID = "default_user_" + request.BootstrapUsername user.Username = request.BootstrapUsername @@ -847,6 +848,7 @@ func (module *Module) initialize(w http.ResponseWriter, r *http.Request, ps http panic(err) } user.Password = material.Hash + user.Enabled = &enabled user.PasswordSalt = material.Salt user.PasswordVerifier = material.Verifier role := []security.UserRole{} diff --git a/web/src/pages/System/Security/index.jsx b/web/src/pages/System/Security/index.jsx index 401348cb..8e9a45f4 100644 --- a/web/src/pages/System/Security/index.jsx +++ b/web/src/pages/System/Security/index.jsx @@ -25,8 +25,6 @@ import "@/assets/headercontent.scss"; const { TabPane } = Tabs; import User from "../User/index"; import Role from "../Role/index"; -import Token from "./Token"; -import { hasAuthority } from "@/utils/authority"; const Security = (props) => { const [param, setParam] = useQueryParam("_g", JsonParam); @@ -51,15 +49,6 @@ const Security = (props) => { > - //TODO: with CRUD for system.security:all - {hasAuthority("security:auth:api-token:search") ? ( - {formatMessage({ id: "system.security.tab.token" })}} - key="token" - > - - - ) : null} From b3f7e37b114517ab69d1b509e50109ba3c911859 Mon Sep 17 00:00:00 2001 From: hardy Date: Wed, 29 Jul 2026 14:44:25 +0800 Subject: [PATCH 420/426] chore: code format and audit with user and role --- modules/security/api/account_password.go | 16 +++- modules/security/api/role.go | 73 +++++++++++----- modules/security/api/user.go | 101 +++++++++++++++++++---- 3 files changed, 151 insertions(+), 39 deletions(-) diff --git a/modules/security/api/account_password.go b/modules/security/api/account_password.go index a21fe13c..3b18cd9f 100644 --- a/modules/security/api/account_password.go +++ b/modules/security/api/account_password.go @@ -24,12 +24,16 @@ package api import ( + "net/http" + "golang.org/x/crypto/bcrypt" + "infini.sh/console/common" rbac "infini.sh/console/core/security" + "infini.sh/console/model" + "infini.sh/console/service" "infini.sh/framework/core/api" httprouter "infini.sh/framework/core/api/router" frameworksecurity "infini.sh/framework/core/security" - "net/http" ) func (h APIHandler) UpdatePassword(w http.ResponseWriter, r *http.Request, ps httprouter.Params) { @@ -73,5 +77,15 @@ func (h APIHandler) UpdatePassword(w http.ResponseWriter, r *http.Request, ps ht h.ErrorInternalServer(w, err.Error()) return } + + if r.Header.Get("Referer") != "" { + auditLog, _ := model.NewAuditLogBuilderWithDefault().WithOperator(reqUser.Username). + WithLogTypeOperation().WithResourceTypeAccountCenter(). + WithEventName("update password").WithEventSourceIP(common.GetClientIP(r)). + WithResourceName(reqUser.Username).WithOperationTypeModification(). + WithEventRecord("user updated password").Build() + _ = service.LogAuditLog(auditLog) + } + h.WriteOKJSON(w, api.UpdateResponse(reqUser.UserId)) } diff --git a/modules/security/api/role.go b/modules/security/api/role.go index 211c568f..a9888e4d 100644 --- a/modules/security/api/role.go +++ b/modules/security/api/role.go @@ -30,7 +30,10 @@ package api import ( "encoding/json" log "github.com/cihub/seelog" + "infini.sh/console/common" rbac "infini.sh/console/core/security" + "infini.sh/console/model" + "infini.sh/console/service" "infini.sh/framework/core/api" httprouter "infini.sh/framework/core/api/router" "infini.sh/framework/core/elastic" @@ -45,13 +48,13 @@ const errRoleAssignedToUsers = "role is still assigned to users" func (h APIHandler) CreateRole(w http.ResponseWriter, r *http.Request, ps httprouter.Params) { roleType := ps.MustGetParameter("type") - //localUser, err := rbac.FromUserContext(r.Context()) - //if err != nil { - // log.Error(err.Error()) - // h.ErrorInternalServer(w, err.Error()) - // return - //} - err := rbac.IsAllowRoleType(roleType) + localUser, err := rbac.FromUserContext(r.Context()) + if err != nil { + log.Error(err.Error()) + h.ErrorInternalServer(w, err.Error()) + return + } + err = rbac.IsAllowRoleType(roleType) if err != nil { h.ErrorInternalServer(w, err.Error()) return @@ -81,6 +84,16 @@ func (h APIHandler) CreateRole(w http.ResponseWriter, r *http.Request, ps httpro return } rbac.RoleMap[role.Name] = *role + + if r.Header.Get("Referer") != "" { + auditLog, _ := model.NewAuditLogBuilderWithDefault().WithOperator(localUser.Username). + WithLogTypeOperation().WithResourceTypeAccountCenter(). + WithEventName("create role").WithEventSourceIP(common.GetClientIP(r)). + WithResourceName(role.Name).WithOperationTypeCreate(). + WithEventRecord(util.MustToJSON(role)).Build() + _ = service.LogAuditLog(auditLog) + } + h.WriteOKJSON(w, api.CreateResponse(id)) return @@ -147,12 +160,12 @@ func (h APIHandler) GetRole(w http.ResponseWriter, r *http.Request, ps httproute func (h APIHandler) DeleteRole(w http.ResponseWriter, r *http.Request, ps httprouter.Params) { id := ps.MustGetParameter("id") - //localUser, err := biz.FromUserContext(r.Context()) - //if err != nil { - // log.Error(err.Error()) - // h.ErrorInternalServer(w, err.Error()) - // return - //} + localUser, err := rbac.FromUserContext(r.Context()) + if err != nil { + log.Error(err.Error()) + h.ErrorInternalServer(w, err.Error()) + return + } oldRole, err := h.Role.Get(id) if err != nil { h.ErrorInternalServer(w, err.Error()) @@ -176,20 +189,30 @@ func (h APIHandler) DeleteRole(w http.ResponseWriter, r *http.Request, ps httpro return } delete(rbac.RoleMap, oldRole.Name) + + if r.Header.Get("Referer") != "" { + auditLog, _ := model.NewAuditLogBuilderWithDefault().WithOperator(localUser.Username). + WithLogTypeOperation().WithResourceTypeAccountCenter(). + WithEventName("delete role").WithEventSourceIP(common.GetClientIP(r)). + WithResourceName(oldRole.Name).WithOperationTypeDelete(). + WithEventRecord(util.MustToJSON(oldRole)).Build() + _ = service.LogAuditLog(auditLog) + } + h.WriteOKJSON(w, api.DeleteResponse(id)) return } func (h APIHandler) UpdateRole(w http.ResponseWriter, r *http.Request, ps httprouter.Params) { id := ps.MustGetParameter("id") - //localUser, err := biz.FromUserContext(r.Context()) - //if err != nil { - // log.Error(err.Error()) - // h.ErrorInternalServer(w, err.Error()) - // return - //} + localUser, err := rbac.FromUserContext(r.Context()) + if err != nil { + log.Error(err.Error()) + h.ErrorInternalServer(w, err.Error()) + return + } role := &rbac.Role{} - err := h.DecodeJSON(r, role) + err = h.DecodeJSON(r, role) if err != nil { h.Error400(w, err.Error()) return @@ -219,6 +242,16 @@ func (h APIHandler) UpdateRole(w http.ResponseWriter, r *http.Request, ps httpro } delete(rbac.RoleMap, oldRole.Name) rbac.RoleMap[role.Name] = *role + + if r.Header.Get("Referer") != "" { + auditLog, _ := model.NewAuditLogBuilderWithDefault().WithOperator(localUser.Username). + WithLogTypeOperation().WithResourceTypeAccountCenter(). + WithEventName("update role").WithEventSourceIP(common.GetClientIP(r)). + WithResourceName(role.Name).WithOperationTypeModification(). + WithEventRecord(util.MustToJSON(role)).Build() + _ = service.LogAuditLog(auditLog) + } + h.WriteOKJSON(w, api.UpdateResponse(id)) return } diff --git a/modules/security/api/user.go b/modules/security/api/user.go index cc2c39d6..c015ca78 100644 --- a/modules/security/api/user.go +++ b/modules/security/api/user.go @@ -32,7 +32,10 @@ import ( "errors" "github.com/buger/jsonparser" log "github.com/cihub/seelog" + "infini.sh/console/common" rbac "infini.sh/console/core/security" + "infini.sh/console/model" + "infini.sh/console/service" "infini.sh/framework/core/api" httprouter "infini.sh/framework/core/api/router" frameworksecurity "infini.sh/framework/core/security" @@ -40,6 +43,7 @@ import ( "infini.sh/framework/modules/elastic" "net/http" "sort" + "strings" "time" ) @@ -54,12 +58,12 @@ func (h APIHandler) CreateUser(w http.ResponseWriter, r *http.Request, ps httpro h.Error400(w, "username is required") return } - //localUser, err := biz.FromUserContext(r.Context()) - //if err != nil { - // log.Error(err.Error()) - // h.ErrorInternalServer(w, err.Error()) - // return - //} + localUser, err := rbac.FromUserContext(r.Context()) + if err != nil { + log.Error(err.Error()) + h.ErrorInternalServer(w, err.Error()) + return + } if h.userNameExists(w, user.Username) { return } @@ -85,6 +89,16 @@ func (h APIHandler) CreateUser(w http.ResponseWriter, r *http.Request, ps httpro h.ErrorInternalServer(w, err.Error()) return } + + if r.Header.Get("Referer") != "" { + auditLog, _ := model.NewAuditLogBuilderWithDefault().WithOperator(localUser.Username). + WithLogTypeOperation().WithResourceTypeAccountCenter(). + WithEventName("create user").WithEventSourceIP(common.GetClientIP(r)). + WithResourceName(user.Username).WithOperationTypeCreate(). + WithEventRecord(util.MustToJSON(user)).Build() + _ = service.LogAuditLog(auditLog) + } + h.WriteOKJSON(w, util.MapStr{ "_id": id, "password": randStr, @@ -137,12 +151,12 @@ func (h APIHandler) UpdateUser(w http.ResponseWriter, r *http.Request, ps httpro h.Error400(w, err.Error()) return } - //localUser, err := biz.FromUserContext(r.Context()) - //if err != nil { - // log.Error(err.Error()) - // h.ErrorInternalServer(w, err.Error()) - // return - //} + localUser, err := rbac.FromUserContext(r.Context()) + if err != nil { + log.Error(err.Error()) + h.ErrorInternalServer(w, err.Error()) + return + } oldUser, err := h.User.Get(id) if err != nil { _ = log.Error(err.Error()) @@ -181,6 +195,16 @@ func (h APIHandler) UpdateUser(w http.ResponseWriter, r *http.Request, ps httpro if len(changeLog) > 0 { rbac.DeleteUserToken(id) } + + if r.Header.Get("Referer") != "" { + auditLog, _ := model.NewAuditLogBuilderWithDefault().WithOperator(localUser.Username). + WithLogTypeOperation().WithResourceTypeAccountCenter(). + WithEventName("update user").WithEventSourceIP(common.GetClientIP(r)). + WithResourceName(user.Username).WithOperationTypeModification(). + WithEventRecord(util.MustToJSON(user)).Build() + _ = service.LogAuditLog(auditLog) + } + h.WriteOKJSON(w, api.UpdateResponse(id)) return } @@ -211,6 +235,7 @@ func (h APIHandler) batchSetUserEnabled(w http.ResponseWriter, r *http.Request, return } + var updatedUsernames []string for _, userID := range userIDs { user, err := h.User.Get(userID) if errors.Is(err, elastic.ErrNotFound) { @@ -242,6 +267,20 @@ func (h APIHandler) batchSetUserEnabled(w http.ResponseWriter, r *http.Request, if !enabled { rbac.DeleteUserToken(userID) } + updatedUsernames = append(updatedUsernames, user.Username) + } + + if len(updatedUsernames) > 0 && r.Header.Get("Referer") != "" { + eventName := "enable user" + if !enabled { + eventName = "disable user" + } + auditLog, _ := model.NewAuditLogBuilderWithDefault().WithOperator(reqUser.Username). + WithLogTypeOperation().WithResourceTypeAccountCenter(). + WithEventName(eventName).WithEventSourceIP(common.GetClientIP(r)). + WithResourceName(strings.Join(updatedUsernames, ",")).WithOperationTypeModification(). + WithEventRecord(util.MustToJSON(updatedUsernames)).Build() + _ = service.LogAuditLog(auditLog) } h.WriteAckOKJSON(w) @@ -268,6 +307,9 @@ func (h APIHandler) DeleteUser(w http.ResponseWriter, r *http.Request, ps httpro h.WriteError(w, "can not delete yourself", http.StatusInternalServerError) return } + + oldUser, getErr := h.User.Get(id) + err = h.User.Delete(id) if errors.Is(err, elastic.ErrNotFound) { h.WriteJSON(w, api.NotFoundResponse(id), http.StatusNotFound) @@ -279,6 +321,20 @@ func (h APIHandler) DeleteUser(w http.ResponseWriter, r *http.Request, ps httpro return } rbac.DeleteUserToken(id) + + if r.Header.Get("Referer") != "" { + resourceName := id + if getErr == nil { + resourceName = oldUser.Username + } + auditLog, _ := model.NewAuditLogBuilderWithDefault().WithOperator(user.Username). + WithLogTypeOperation().WithResourceTypeAccountCenter(). + WithEventName("delete user").WithEventSourceIP(common.GetClientIP(r)). + WithResourceName(resourceName).WithOperationTypeDelete(). + WithEventRecord(resourceName).Build() + _ = service.LogAuditLog(auditLog) + } + h.WriteOKJSON(w, api.DeleteResponse(id)) return } @@ -335,12 +391,12 @@ func (h APIHandler) UpdateUserPassword(w http.ResponseWriter, r *http.Request, p h.Error400(w, err.Error()) return } - //localUser, err := biz.FromUserContext(r.Context()) - //if err != nil { - // log.Error(err.Error()) - // h.ErrorInternalServer(w, err.Error()) - // return - //} + localUser, err := rbac.FromUserContext(r.Context()) + if err != nil { + log.Error(err.Error()) + h.ErrorInternalServer(w, err.Error()) + return + } user, err := h.User.Get(id) if err != nil { _ = log.Error(err.Error()) @@ -366,6 +422,15 @@ func (h APIHandler) UpdateUserPassword(w http.ResponseWriter, r *http.Request, p //disable old token to let user login rbac.DeleteUserToken(id) + if r.Header.Get("Referer") != "" { + auditLog, _ := model.NewAuditLogBuilderWithDefault().WithOperator(localUser.Username). + WithLogTypeOperation().WithResourceTypeAccountCenter(). + WithEventName("reset user password").WithEventSourceIP(common.GetClientIP(r)). + WithResourceName(user.Username).WithOperationTypeModification(). + WithEventRecord("password reset for user: " + user.Username).Build() + _ = service.LogAuditLog(auditLog) + } + h.WriteOKJSON(w, api.UpdateResponse(id)) return From 67d957a8038d2dcd99a24ce79e8b2bd97b61bc05 Mon Sep 17 00:00:00 2001 From: hardy Date: Wed, 29 Jul 2026 15:03:36 +0800 Subject: [PATCH 421/426] fix: audit role and user build error --- modules/security/api/role.go | 4 ++-- modules/security/api/user.go | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/modules/security/api/role.go b/modules/security/api/role.go index a9888e4d..060a330b 100644 --- a/modules/security/api/role.go +++ b/modules/security/api/role.go @@ -89,7 +89,7 @@ func (h APIHandler) CreateRole(w http.ResponseWriter, r *http.Request, ps httpro auditLog, _ := model.NewAuditLogBuilderWithDefault().WithOperator(localUser.Username). WithLogTypeOperation().WithResourceTypeAccountCenter(). WithEventName("create role").WithEventSourceIP(common.GetClientIP(r)). - WithResourceName(role.Name).WithOperationTypeCreate(). + WithResourceName(role.Name).WithOperationTypeNew(). WithEventRecord(util.MustToJSON(role)).Build() _ = service.LogAuditLog(auditLog) } @@ -194,7 +194,7 @@ func (h APIHandler) DeleteRole(w http.ResponseWriter, r *http.Request, ps httpro auditLog, _ := model.NewAuditLogBuilderWithDefault().WithOperator(localUser.Username). WithLogTypeOperation().WithResourceTypeAccountCenter(). WithEventName("delete role").WithEventSourceIP(common.GetClientIP(r)). - WithResourceName(oldRole.Name).WithOperationTypeDelete(). + WithResourceName(oldRole.Name).WithOperationTypeDeletion(). WithEventRecord(util.MustToJSON(oldRole)).Build() _ = service.LogAuditLog(auditLog) } diff --git a/modules/security/api/user.go b/modules/security/api/user.go index c015ca78..7835ad32 100644 --- a/modules/security/api/user.go +++ b/modules/security/api/user.go @@ -94,7 +94,7 @@ func (h APIHandler) CreateUser(w http.ResponseWriter, r *http.Request, ps httpro auditLog, _ := model.NewAuditLogBuilderWithDefault().WithOperator(localUser.Username). WithLogTypeOperation().WithResourceTypeAccountCenter(). WithEventName("create user").WithEventSourceIP(common.GetClientIP(r)). - WithResourceName(user.Username).WithOperationTypeCreate(). + WithResourceName(user.Username).WithOperationTypeNew(). WithEventRecord(util.MustToJSON(user)).Build() _ = service.LogAuditLog(auditLog) } @@ -330,7 +330,7 @@ func (h APIHandler) DeleteUser(w http.ResponseWriter, r *http.Request, ps httpro auditLog, _ := model.NewAuditLogBuilderWithDefault().WithOperator(user.Username). WithLogTypeOperation().WithResourceTypeAccountCenter(). WithEventName("delete user").WithEventSourceIP(common.GetClientIP(r)). - WithResourceName(resourceName).WithOperationTypeDelete(). + WithResourceName(resourceName).WithOperationTypeDeletion(). WithEventRecord(resourceName).Build() _ = service.LogAuditLog(auditLog) } From 6bb79cc0de9d8e7faf1248f8a0f9bcec1aa2c990 Mon Sep 17 00:00:00 2001 From: hardy Date: Fri, 7 Aug 2026 19:12:39 +0800 Subject: [PATCH 422/426] fix: security api with token --- core/security/enum/const.go | 4 +- core/security/enum/const_test.go | 28 ++++++++++ plugin/api/self_api.go | 2 + plugin/api/settings/api.go | 37 +++++++++++++ plugin/api/settings/api_test.go | 74 +++++++++++++++++++++++++ web/src/pages/System/Security/index.jsx | 7 +++ 6 files changed, 150 insertions(+), 2 deletions(-) create mode 100644 core/security/enum/const_test.go diff --git a/core/security/enum/const.go b/core/security/enum/const.go index 9f9968f2..2c34eecc 100644 --- a/core/security/enum/const.go +++ b/core/security/enum/const.go @@ -181,8 +181,8 @@ var ( AliasReadPermission = []string{"alias:read", "alias:write"} ViewsAllPermission = []string{PermissionViewRead, PermissionViewWrite, PermissionLayoutRead, PermissionLayoutWrite} ViewsReadPermission = []string{PermissionViewRead, PermissionLayoutRead} - DiscoverReadPermission = []string{PermissionViewRead} - DiscoverAllPermission = []string{PermissionViewRead} + DiscoverReadPermission = []string{PermissionViewRead, PermissionLayoutRead} + DiscoverAllPermission = []string{PermissionViewRead, PermissionLayoutRead, PermissionLayoutWrite} RuleReadPermission = []string{PermissionAlertRuleRead, PermissionAlertHistoryRead} RuleAllPermission = []string{PermissionAlertRuleRead, PermissionAlertRuleWrite, PermissionAlertHistoryRead, PermissionElasticsearchClusterRead} diff --git a/core/security/enum/const_test.go b/core/security/enum/const_test.go new file mode 100644 index 00000000..0d3c2180 --- /dev/null +++ b/core/security/enum/const_test.go @@ -0,0 +1,28 @@ +package enum + +import ( + "testing" +) + +func TestDiscoverPermissionsIncludeLayoutAccess(t *testing.T) { + has := func(permissions []string, target string) bool { + for _, permission := range permissions { + if permission == target { + return true + } + } + return false + } + + if !has(DiscoverReadPermission, PermissionLayoutRead) { + t.Fatalf("expected discover read permissions to include %q", PermissionLayoutRead) + } + + if !has(DiscoverAllPermission, PermissionLayoutRead) { + t.Fatalf("expected discover all permissions to include %q", PermissionLayoutRead) + } + + if !has(DiscoverAllPermission, PermissionLayoutWrite) { + t.Fatalf("expected discover all permissions to include %q", PermissionLayoutWrite) + } +} diff --git a/plugin/api/self_api.go b/plugin/api/self_api.go index c7406461..2e060b15 100644 --- a/plugin/api/self_api.go +++ b/plugin/api/self_api.go @@ -55,6 +55,8 @@ var consolePublicProxyUIRoutes = []frameworkapi.ProtectedAPIRoute{ {Method: frameworkapi.GET, Path: "/setting/application"}, {Method: frameworkapi.GET, Path: "/instance/_get_install_script"}, {Method: frameworkapi.GET, Path: "/instance/_get_gateway_install_script"}, + {Method: frameworkapi.POST, Path: "/configs/_sync"}, + {Method: frameworkapi.POST, Path: "/configs/_reload"}, } type consoleSelfAPIHandler struct { diff --git a/plugin/api/settings/api.go b/plugin/api/settings/api.go index 039a2903..ddd6a358 100644 --- a/plugin/api/settings/api.go +++ b/plugin/api/settings/api.go @@ -159,6 +159,17 @@ func getManagedRetentionTemplateNames(indexPrefix string) []string { } } +func getManagedRetentionAliases(indexPrefix string) []string { + return []string{ + indexPrefix + "metrics", + indexPrefix + "logs", + indexPrefix + "requests_logging", + indexPrefix + "async_bulk_results", + indexPrefix + "alert-history", + indexPrefix + "activities", + } +} + func getManagedRetentionIndexPatterns(indexPrefix string) []string { return []string{ indexPrefix + "metrics*", @@ -1101,6 +1112,29 @@ func getRetentionSettings(client elastic.API, cfg *elastic.ElasticsearchConfig) return days, maxSize, nil } +func rolloverManagedRetentionWriteIndices(requester rawRequester, cfg *elastic.ElasticsearchConfig, indexPrefix string) error { + for _, aliasName := range getManagedRetentionAliases(indexPrefix) { + _, statusCode, err := rawJSONRequest( + requester, + cfg, + util.Verb_POST, + "/"+url.PathEscape(aliasName)+"/_rollover", + util.MapStr{ + "conditions": util.MapStr{ + "max_docs": 0, + }, + }, + ) + if statusCode == http.StatusNotFound { + continue + } + if err != nil { + return err + } + } + return nil +} + func updateRetentionSettings(client elastic.API, cfg *elastic.ElasticsearchConfig, days int, maxSize string) error { requester, ok := client.(rawRequester) if !ok { @@ -1210,6 +1244,9 @@ func updateRetentionSettings(client elastic.API, cfg *elastic.ElasticsearchConfi if err != nil { return err } + if err := rolloverManagedRetentionWriteIndices(requester, cfg, indexPrefix); err != nil { + return err + } rollupTemplate, statusCode, err := getLegacyTemplate(requester, cfg, "rollup_policy_template") if err != nil { diff --git a/plugin/api/settings/api_test.go b/plugin/api/settings/api_test.go index d2bcb78f..c63747f3 100644 --- a/plugin/api/settings/api_test.go +++ b/plugin/api/settings/api_test.go @@ -391,6 +391,80 @@ func TestUpdateRollupJobsStopsManagedJobsIndividually(t *testing.T) { } } +func TestRolloverManagedRetentionWriteIndices(t *testing.T) { + requester := &mockRawRequester{ + responses: map[string]*util.Result{ + "POST http://example.com/.infini_metrics/_rollover": { + StatusCode: http.StatusOK, + Body: []byte(`{"rolled_over":true}`), + }, + "POST http://example.com/.infini_logs/_rollover": { + StatusCode: http.StatusOK, + Body: []byte(`{"rolled_over":true}`), + }, + "POST http://example.com/.infini_requests_logging/_rollover": { + StatusCode: http.StatusOK, + Body: []byte(`{"rolled_over":true}`), + }, + "POST http://example.com/.infini_async_bulk_results/_rollover": { + StatusCode: http.StatusOK, + Body: []byte(`{"rolled_over":true}`), + }, + "POST http://example.com/.infini_alert-history/_rollover": { + StatusCode: http.StatusOK, + Body: []byte(`{"rolled_over":true}`), + }, + "POST http://example.com/.infini_activities/_rollover": { + StatusCode: http.StatusOK, + Body: []byte(`{"rolled_over":true}`), + }, + }, + } + + if err := rolloverManagedRetentionWriteIndices(requester, &elastic.ElasticsearchConfig{Endpoint: "http://example.com"}, ".infini_"); err != nil { + t.Fatalf("rolloverManagedRetentionWriteIndices returned error: %v", err) + } + + if len(requester.calls) != 6 { + t.Fatalf("expected 6 rollover requests, got %#v", requester.calls) + } +} + +func TestRolloverManagedRetentionWriteIndicesSkipsMissingAlias(t *testing.T) { + requester := &mockRawRequester{ + responses: map[string]*util.Result{ + "POST http://example.com/.infini_metrics/_rollover": { + StatusCode: http.StatusNotFound, + Body: []byte(`{"error":"alias [.infini_metrics] missing"}`), + }, + "POST http://example.com/.infini_logs/_rollover": { + StatusCode: http.StatusOK, + Body: []byte(`{"rolled_over":true}`), + }, + "POST http://example.com/.infini_requests_logging/_rollover": { + StatusCode: http.StatusOK, + Body: []byte(`{"rolled_over":true}`), + }, + "POST http://example.com/.infini_async_bulk_results/_rollover": { + StatusCode: http.StatusOK, + Body: []byte(`{"rolled_over":true}`), + }, + "POST http://example.com/.infini_alert-history/_rollover": { + StatusCode: http.StatusOK, + Body: []byte(`{"rolled_over":true}`), + }, + "POST http://example.com/.infini_activities/_rollover": { + StatusCode: http.StatusOK, + Body: []byte(`{"rolled_over":true}`), + }, + }, + } + + if err := rolloverManagedRetentionWriteIndices(requester, &elastic.ElasticsearchConfig{Endpoint: "http://example.com"}, ".infini_"); err != nil { + t.Fatalf("rolloverManagedRetentionWriteIndices should ignore 404 aliases, got %v", err) + } +} + type mockRawRequester struct { responses map[string]*util.Result calls []string diff --git a/web/src/pages/System/Security/index.jsx b/web/src/pages/System/Security/index.jsx index 8e9a45f4..130a811c 100644 --- a/web/src/pages/System/Security/index.jsx +++ b/web/src/pages/System/Security/index.jsx @@ -25,6 +25,7 @@ import "@/assets/headercontent.scss"; const { TabPane } = Tabs; import User from "../User/index"; import Role from "../Role/index"; +import Token from "./Token"; const Security = (props) => { const [param, setParam] = useQueryParam("_g", JsonParam); @@ -49,6 +50,12 @@ const Security = (props) => { > + {formatMessage({ id: "system.security.tab.token" })}} + key="token" + > + + From 3d6851a6b6b536a2e206405ff64d3ea09b378599 Mon Sep 17 00:00:00 2001 From: hardy Date: Fri, 7 Aug 2026 23:33:04 +0800 Subject: [PATCH 423/426] fix: api token with api --- modules/security/api/account_profile.go | 5 +- modules/security/api/init_test.go | 15 ++- modules/security/framework_account_bridge.go | 5 + modules/security/module.go | 1 + service/alerting/elasticsearch/engine.go | 12 +- web/src/locales/en-US.js | 12 +- web/src/locales/zh-CN.js | 12 +- web/src/pages/System/Security/Token.jsx | 112 +++++++++++++++++-- 8 files changed, 149 insertions(+), 25 deletions(-) diff --git a/modules/security/api/account_profile.go b/modules/security/api/account_profile.go index 2c50be1d..a97d1964 100644 --- a/modules/security/api/account_profile.go +++ b/modules/security/api/account_profile.go @@ -97,10 +97,7 @@ func collectPlatformPrivileges(roles []string) []string { } func privilegesToPermissionKeys(privileges []string) []frameworksecurity.PermissionKey { - permissions := make([]frameworksecurity.PermissionKey, 0, len(privileges)+1) - for _, privilege := range normalizeStringList(privileges) { - permissions = append(permissions, frameworksecurity.PermissionKey(privilege)) - } + permissions := rbac.ExpandFrameworkPermissionKeysForPlatformPrivileges(normalizeStringList(privileges)) sessionUser := &frameworksecurity.UserSessionInfo{ UserAssignedPermission: frameworksecurity.NewUserAssignedPermission(permissions, nil), } diff --git a/modules/security/api/init_test.go b/modules/security/api/init_test.go index d3373804..5ddf838c 100644 --- a/modules/security/api/init_test.go +++ b/modules/security/api/init_test.go @@ -2,6 +2,7 @@ package api import ( "bytes" + "net" "net/http" "net/http/httptest" "testing" @@ -13,6 +14,18 @@ import ( "infini.sh/framework/core/global" ) +func newTestBinding(t *testing.T) string { + t.Helper() + + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("listen on random port: %v", err) + } + defer listener.Close() + + return listener.Addr().String() +} + func TestInitRegistersPublicLoginUIRoutes(t *testing.T) { oldEnv := global.Env() testEnv := env.EmptyEnv() @@ -23,7 +36,7 @@ func TestInitRegistersPublicLoginUIRoutes(t *testing.T) { Init() webCfg := config2.WebAppConfig{} - webCfg.NetworkConfig.Binding = "127.0.0.1:0" + webCfg.NetworkConfig.Binding = newTestBinding(t) api2.StartWeb(webCfg) defer api2.StopWeb(webCfg) diff --git a/modules/security/framework_account_bridge.go b/modules/security/framework_account_bridge.go index b1c7c785..a71d1c2e 100644 --- a/modules/security/framework_account_bridge.go +++ b/modules/security/framework_account_bridge.go @@ -112,6 +112,11 @@ func (frameworkRealmPasswordLoginProvider) AuthenticateByPassword(login, passwor Roles: roleNames(user.Roles), } sessionUser.SetUserID(user.ID) + _, privilege := user.GetPermissions() + sessionUser.UserAssignedPermission = frameworksecurity.NewUserAssignedPermission( + rbac.ExpandFrameworkPermissionKeysForPlatformPrivileges(privilege), + nil, + ) return rbac.EnsureFrameworkDefaultPermissions(sessionUser), nil } diff --git a/modules/security/module.go b/modules/security/module.go index b77ec46d..04a3dfb3 100644 --- a/modules/security/module.go +++ b/modules/security/module.go @@ -39,6 +39,7 @@ import ( "infini.sh/framework/core/env" "infini.sh/framework/core/global" "infini.sh/framework/core/orm" + _ "infini.sh/framework/modules/security/access_token" ) type Module struct { diff --git a/service/alerting/elasticsearch/engine.go b/service/alerting/elasticsearch/engine.go index 68a60c01..3cc15add 100644 --- a/service/alerting/elasticsearch/engine.go +++ b/service/alerting/elasticsearch/engine.go @@ -982,7 +982,7 @@ func (engine *Engine) Do(rule *alerting.Rule) error { } } - err = orm.Save(&orm.Context{Refresh: orm.WaitForRefresh}, alertItem) + err = saveAlertItemToES(alertItem) if err != nil { log.Errorf("save alert item failed, rule_id=%s, alert_id=%s, state=%s: %v", rule.ID, alertItem.ID, alertItem.State, err) } @@ -1687,12 +1687,20 @@ func getLastAlertMessage(ruleID string, duration time.Duration) (*alerting.Alert func saveAlertMessageToES(message *alerting.AlertMessage) error { message.Updated = time.Now() - ctx := &orm.Context{Refresh: orm.WaitForRefresh} + ctx := orm.NewContext().DirectAccess() + ctx.Refresh = orm.WaitForRefresh ctx.Set(orm.CheckExistsBeforeUpdate, false) ctx.Set(orm.MergePartialFieldsBeforeUpdate, false) return orm.Save(ctx, message) } +func saveAlertItemToES(alertItem *alerting.Alert) error { + alertItem.Updated = time.Now() + ctx := orm.NewContext().DirectAccess() + ctx.Refresh = orm.WaitForRefresh + return orm.Create(ctx, alertItem) +} + func saveAlertMessage(message *alerting.AlertMessage) error { //todo diff message if not change , then skip save to es ? err := saveAlertMessageToES(message) diff --git a/web/src/locales/en-US.js b/web/src/locales/en-US.js index 2d61bbbd..5fb262ba 100644 --- a/web/src/locales/en-US.js +++ b/web/src/locales/en-US.js @@ -144,7 +144,8 @@ export default { "table.field.pid": "PID", "system.security.tab.user": "User", "system.security.tab.role": "Role", - "system.security.tab.token": "API Token", + "system.security.tab.token": "Access Credential", + "system.security.token.table.token": "Access Credential", "system.security.token.search.placeholder": "Type keyword to search", "system.security.token.table.name": "Name", "system.security.token.table.description": "Description", @@ -154,11 +155,14 @@ export default { "system.security.token.form.name": "Name", "system.security.token.form.name.required": "Please input token name!", "system.security.token.form.description": "Description", - "system.security.token.drawer.create.title": "Create API Token", - "system.security.token.drawer.edit.title": "Edit API Token", - "system.security.token.create.result.title": "API Token Created", + "system.security.token.drawer.create.title": "Create Access Credential", + "system.security.token.drawer.edit.title": "Edit Access Credential", + "system.security.token.create.result.title": "Access Credential Created", "system.security.token.create.result.tip": "Please copy and store this token now. It will only be shown once.", + "system.security.token.copy.tooltip": "Copy", + "system.security.token.copy.success": "Access credential copied to clipboard", + "system.security.token.copy.failed": "Failed to copy access credential", "system.security.search.placeholder": "Type keyword to search", "system.security.pagination.total": "{start}-{end} of {total} items", "system.security.confirm.delete": "Are you sure you want to delete this item?", diff --git a/web/src/locales/zh-CN.js b/web/src/locales/zh-CN.js index 2393572c..170b5c72 100644 --- a/web/src/locales/zh-CN.js +++ b/web/src/locales/zh-CN.js @@ -149,7 +149,8 @@ export default { "table.field.pid": "进程ID", "system.security.tab.user": "用户", "system.security.tab.role": "角色", - "system.security.tab.token": "API Token", + "system.security.tab.token": "访问凭据", + "system.security.token.table.token": "访问凭据", "system.security.token.search.placeholder": "输入关键字搜索", "system.security.token.table.name": "名称", "system.security.token.table.description": "描述", @@ -159,11 +160,14 @@ export default { "system.security.token.form.name": "名称", "system.security.token.form.name.required": "请输入 Token 名称!", "system.security.token.form.description": "描述", - "system.security.token.drawer.create.title": "创建 API Token", - "system.security.token.drawer.edit.title": "编辑 API Token", - "system.security.token.create.result.title": "API Token 已创建", + "system.security.token.drawer.create.title": "创建访问凭据", + "system.security.token.drawer.edit.title": "编辑访问凭据", + "system.security.token.create.result.title": "访问凭据已创建", "system.security.token.create.result.tip": "请立即复制并妥善保存该 Token。该值只会展示一次。", + "system.security.token.copy.tooltip": "复制", + "system.security.token.copy.success": "访问凭据已复制到剪贴板", + "system.security.token.copy.failed": "访问凭据复制失败", "system.security.search.placeholder": "输入关键字搜索", "system.security.pagination.total": "{start}-{end} / 共 {total} 条", "system.security.confirm.delete": "确认删除?", diff --git a/web/src/pages/System/Security/Token.jsx b/web/src/pages/System/Security/Token.jsx index de9bd12e..81c72be5 100644 --- a/web/src/pages/System/Security/Token.jsx +++ b/web/src/pages/System/Security/Token.jsx @@ -10,6 +10,7 @@ import { Modal, Popconfirm, Table, + Tooltip, message, } from "antd"; import { formatMessage } from "umi/locale"; @@ -26,6 +27,33 @@ const firstColumnIconStyle = { fontSize: 12, }; +const ellipsisTextStyle = { + overflow: "hidden", + textOverflow: "ellipsis", + whiteSpace: "nowrap", +}; + +const copyText = async (text) => { + if (!text) { + return false; + } + if (navigator?.clipboard?.writeText) { + await navigator.clipboard.writeText(text); + return true; + } + + const input = document.createElement("textarea"); + input.value = text; + input.setAttribute("readonly", "readonly"); + input.style.position = "fixed"; + input.style.top = "-9999px"; + document.body.appendChild(input); + input.select(); + const copied = document.execCommand("copy"); + document.body.removeChild(input); + return copied; +}; + const TokenForm = Form.create()(({ form, record, onSubmit, submitLoading }) => { const { getFieldDecorator, validateFields } = form; const isEdit = !!record; @@ -106,10 +134,11 @@ const Token = () => { const [submitLoading, setSubmitLoading] = useState(false); const [selectedItem, setSelectedItem] = useState(); - const canSearch = hasAuthority("security:auth:api-token:search"); - const canCreate = hasAuthority("security:auth:api-token:create"); - const canUpdate = hasAuthority("security:auth:api-token:update"); - const canDelete = hasAuthority("security:auth:api-token:delete"); + const canSearch = + hasAuthority("system.security:all") || hasAuthority("system.security:read"); + const canCreate = hasAuthority("system.security:all"); + const canUpdate = hasAuthority("system.security:all"); + const canDelete = hasAuthority("system.security:all"); const { loading, value, run } = useFetch( "/auth/access_token/_search", @@ -135,6 +164,18 @@ const Token = () => { }); }; + const onCopy = async (token) => { + try { + const copied = await copyText(token); + if (!copied) { + throw new Error("copy failed"); + } + message.success(formatMessage({ id: "system.security.token.copy.success" })); + } catch (e) { + message.error(formatMessage({ id: "system.security.token.copy.failed" })); + } + }; + const onDelete = async (tokenID) => { const res = await request(`/auth/access_token/${tokenID}`, { method: "DELETE", @@ -199,33 +240,84 @@ const Token = () => { } }; - const { data, total } = useMemo(() => formatESSearchResult(value), [value]); + const { data, total } = useMemo(() => { + if (!value || value.error) { + return { + data: [], + total: 0, + }; + } + return formatESSearchResult(value); + }, [value]); const columns = [ { title: formatMessage({ id: "table.field.id" }), dataIndex: "id", + width: 240, render: (text) => (
- {text || "-"} + + {text || "-"} +
), }, + { + title: formatMessage({ id: "system.security.token.table.token" }), + dataIndex: "access_token", + width: 220, + render: (text) => { + if (!text) { + return "-"; + } + return ( +
+ + {text} + + + onCopy(text)} + /> + +
+ ); + }, + }, { title: formatMessage({ id: "system.security.token.table.name" }), dataIndex: "name", - render: (text) => text || "-", + render: (text) => ( +
+ {text || "-"} +
+ ), }, { title: formatMessage({ id: "system.security.token.table.description" }), dataIndex: "description", - render: (text) => text || "-", + render: (text) => ( +
+ {text || "-"} +
+ ), }, { title: formatMessage({ id: "system.security.token.table.permissions" }), dataIndex: "permissions", - render: (permissions) => (permissions || []).join(", ") || "-", + width: 260, + render: (permissions) => { + const text = (permissions || []).join(", ") || "-"; + return ( + +
{text}
+
+ ); + }, }, { title: formatMessage({ id: "system.security.token.table.expire" }), @@ -307,7 +399,7 @@ const Token = () => {
Date: Sat, 8 Aug 2026 09:21:52 +0800 Subject: [PATCH 424/426] chore: add missing file with permission --- core/security/framework_permissions.go | 50 +++++++++++++++++ core/security/framework_permissions_test.go | 33 +++++++++++ modules/security/access_token_route_test.go | 61 +++++++++++++++++++++ 3 files changed, 144 insertions(+) create mode 100644 core/security/framework_permissions.go create mode 100644 core/security/framework_permissions_test.go create mode 100644 modules/security/access_token_route_test.go diff --git a/core/security/framework_permissions.go b/core/security/framework_permissions.go new file mode 100644 index 00000000..6b70dbbb --- /dev/null +++ b/core/security/framework_permissions.go @@ -0,0 +1,50 @@ +package security + +import ( + "sort" + + "infini.sh/console/core/security/enum" + frameworksecurity "infini.sh/framework/core/security" +) + +func ExpandFrameworkPermissionKeysForPlatformPrivileges(privileges []string) []frameworksecurity.PermissionKey { + permissionSet := map[frameworksecurity.PermissionKey]struct{}{} + add := func(keys ...frameworksecurity.PermissionKey) { + for _, key := range keys { + if key == "" { + continue + } + permissionSet[key] = struct{}{} + } + } + + for _, privilege := range privileges { + add(frameworksecurity.PermissionKey(privilege)) + switch privilege { + case enum.SecurityAll: + add( + frameworksecurity.GetOrInitPermission("generic", "security:auth:api-token", frameworksecurity.Create), + frameworksecurity.GetOrInitPermission("generic", "security:auth:api-token", frameworksecurity.Update), + frameworksecurity.GetOrInitPermission("generic", "security:auth:api-token", frameworksecurity.Delete), + frameworksecurity.GetOrInitPermission("generic", "security:auth:api-token", frameworksecurity.Search), + ) + case enum.SecurityRead: + add( + frameworksecurity.GetOrInitPermission("generic", "security:auth:api-token", frameworksecurity.Search), + ) + } + } + + if len(permissionSet) == 0 { + return nil + } + + keys := make([]frameworksecurity.PermissionKey, 0, len(permissionSet)) + for key := range permissionSet { + keys = append(keys, key) + } + sort.Slice(keys, func(i, j int) bool { + return keys[i] < keys[j] + }) + return keys +} diff --git a/core/security/framework_permissions_test.go b/core/security/framework_permissions_test.go new file mode 100644 index 00000000..feed1c2c --- /dev/null +++ b/core/security/framework_permissions_test.go @@ -0,0 +1,33 @@ +package security + +import ( + "testing" + + "infini.sh/console/core/security/enum" + frameworksecurity "infini.sh/framework/core/security" +) + +func TestExpandFrameworkPermissionKeysForPlatformPrivileges(t *testing.T) { + keys := ExpandFrameworkPermissionKeysForPlatformPrivileges([]string{enum.SecurityAll}) + + has := func(target frameworksecurity.PermissionKey) bool { + for _, key := range keys { + if key == target { + return true + } + } + return false + } + + if !has(frameworksecurity.PermissionKey(enum.SecurityAll)) { + t.Fatalf("expected platform privilege %q to be preserved", enum.SecurityAll) + } + + if !has(frameworksecurity.GetOrInitPermission("generic", "security:auth:api-token", frameworksecurity.Create)) { + t.Fatal("expected create access token permission to be included for system.security:all") + } + + if !has(frameworksecurity.GetOrInitPermission("generic", "security:auth:api-token", frameworksecurity.Search)) { + t.Fatal("expected search access token permission to be included for system.security:all") + } +} diff --git a/modules/security/access_token_route_test.go b/modules/security/access_token_route_test.go new file mode 100644 index 00000000..1cdbda58 --- /dev/null +++ b/modules/security/access_token_route_test.go @@ -0,0 +1,61 @@ +package security + +import ( + "bytes" + "net" + "net/http" + "net/http/httptest" + "testing" + + api2 "infini.sh/framework/core/api" + "infini.sh/framework/core/config" + "infini.sh/framework/core/env" + "infini.sh/framework/core/global" +) + +func newSecurityTestBinding(t *testing.T) string { + t.Helper() + + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("listen on random port: %v", err) + } + defer listener.Close() + + return listener.Addr().String() +} + +func TestAccessTokenUIRoutesAreRegistered(t *testing.T) { + oldEnv := global.Env() + testEnv := env.EmptyEnv() + testEnv.SystemConfig.PathConfig.Data = t.TempDir() + testEnv.SystemConfig.WebAppConfig.Security.Enabled = false + global.RegisterEnv(testEnv) + defer global.RegisterEnv(oldEnv) + + for _, callback := range global.GetFuncBeforeSetup() { + callback() + } + + webCfg := config.WebAppConfig{} + webCfg.NetworkConfig.Binding = newSecurityTestBinding(t) + api2.StartWeb(webCfg) + defer api2.StopWeb(webCfg) + + req := httptest.NewRequest(http.MethodPost, "http://console.local/auth/access_token", bytes.NewBufferString(`{}`)) + req.Header.Set("Content-Type", "application/json") + resp := httptest.NewRecorder() + + func() { + defer func() { + _ = recover() + }() + if err := api2.ServeRegisteredUIRequest(resp, req); err != nil { + t.Fatalf("serve ui request: %v", err) + } + }() + + if resp.Code == http.StatusNotFound { + t.Fatal("expected /auth/access_token to be registered on the UI router, got 404") + } +} From 0affbc15f0d9e35eb86c18e2fde7530bc05f1017 Mon Sep 17 00:00:00 2001 From: hardy Date: Sat, 8 Aug 2026 10:48:56 +0800 Subject: [PATCH 425/426] fix: token with copy --- web/src/pages/System/Security/Token.jsx | 56 +++++++++++++------------ 1 file changed, 30 insertions(+), 26 deletions(-) diff --git a/web/src/pages/System/Security/Token.jsx b/web/src/pages/System/Security/Token.jsx index 81c72be5..ad7a0f69 100644 --- a/web/src/pages/System/Security/Token.jsx +++ b/web/src/pages/System/Security/Token.jsx @@ -10,7 +10,6 @@ import { Modal, Popconfirm, Table, - Tooltip, message, } from "antd"; import { formatMessage } from "umi/locale"; @@ -33,6 +32,25 @@ const ellipsisTextStyle = { whiteSpace: "nowrap", }; +const tokenPreviewStyle = { + position: "relative", + marginTop: 12, + padding: "16px 52px 16px 16px", + borderRadius: 6, + background: "rgb(241, 242, 245)", + color: "rgba(0, 0, 0, 0.85)", + fontFamily: + '"SFMono-Regular", Monaco, Menlo, Consolas, "Liberation Mono", "Ubuntu Mono", monospace', + lineHeight: 1.6, + wordBreak: "break-all", +}; + +const tokenPreviewCopyButtonStyle = { + position: "absolute", + top: 12, + right: 12, +}; + const copyText = async (text) => { if (!text) { return false; @@ -156,7 +174,17 @@ const Token = () => { content: (

{formatMessage({ id: "system.security.token.create.result.tip" })}

- +
+ +
{token}
+
), okText: formatMessage({ id: "form.button.ok" }), @@ -264,30 +292,6 @@ const Token = () => { ), }, - { - title: formatMessage({ id: "system.security.token.table.token" }), - dataIndex: "access_token", - width: 220, - render: (text) => { - if (!text) { - return "-"; - } - return ( -
- - {text} - - - onCopy(text)} - /> - -
- ); - }, - }, { title: formatMessage({ id: "system.security.token.table.name" }), dataIndex: "name", From d397d00361298ddeff202193ded9c01abae339f2 Mon Sep 17 00:00:00 2001 From: hardy Date: Sat, 8 Aug 2026 11:46:09 +0800 Subject: [PATCH 426/426] fix: token with api expire date --- .../docs/reference/system/security/token.md | 54 ++++++++++++++ .../docs/reference/system/security/token.md | 54 ++++++++++++++ web/src/locales/en-US.js | 4 + web/src/locales/zh-CN.js | 4 + web/src/pages/System/Security/Token.jsx | 73 ++++++++++++++++++- 5 files changed, 188 insertions(+), 1 deletion(-) create mode 100644 docs/content.en/docs/reference/system/security/token.md create mode 100644 docs/content.zh/docs/reference/system/security/token.md diff --git a/docs/content.en/docs/reference/system/security/token.md b/docs/content.en/docs/reference/system/security/token.md new file mode 100644 index 00000000..87398a2c --- /dev/null +++ b/docs/content.en/docs/reference/system/security/token.md @@ -0,0 +1,54 @@ +--- +weight: 36 +title: Token +--- + +# Token Management + +## Introduction + +The token created on this page is a **managed API token** for accessing protected INFINI Console HTTP APIs. It is intended for scripts, automation jobs, and external system integrations. + +After creation, the full token value is shown only once, so copy and store it securely right away. + +> This token is different from the user session token issued after web login. Session tokens are for Console login state, while this managed token is for long-running third-party or automation API calls. + +## How to use + +Include the following header when calling Console APIs: + +```bash +X-API-TOKEN: +``` + +Example: + +```bash +curl -X GET "http://localhost:9000/account/profile" \ + -H "X-API-TOKEN: " +``` + +If the request succeeds, Console returns the account profile for the user associated with the token. + +## Common use cases + +- Calling protected Console APIs from scripts +- Accessing Console APIs in CI/CD or operations jobs +- Integrating external systems with Console over HTTP + +## Permission model + +A token is bound to the user who created it and uses that user's permissions. The APIs it can access depend on the creator's platform and data privileges. + +## Expiration and revocation + +- You can configure the expiration time when creating the token +- Tokens can expire at a specific time or be set to **never expire** +- If no expiration is specified in the create request, the default is **1 year** +- Deleting a token revokes it immediately + +## Notes + +- The full token value is displayed only once after creation +- Do not commit tokens into source control or expose them in logs, screenshots, or chats +- Use tokens only where needed and rotate them regularly diff --git a/docs/content.zh/docs/reference/system/security/token.md b/docs/content.zh/docs/reference/system/security/token.md new file mode 100644 index 00000000..b971cd8e --- /dev/null +++ b/docs/content.zh/docs/reference/system/security/token.md @@ -0,0 +1,54 @@ +--- +weight: 36 +title: Token 管理 +--- + +# Token 管理 + +## 简介 + +这里创建的 Token 是 **管理型 API Token**,用于访问受保护的 INFINI Console HTTP API,适合脚本、自动化任务或外部系统集成场景。 + +创建成功后,系统只会展示一次完整 Token,请立即复制并妥善保存。 + +> 这类 Token 与用户登录后产生的会话 Token 不同。会话 Token 主要用于 Console Web 登录态;这里创建的 Token 主要用于第三方系统或自动化程序长期调用 API。 + +## 使用方式 + +调用 Console API 时,在请求头中带上 `X-API-TOKEN`: + +```bash +X-API-TOKEN: +``` + +示例: + +```bash +curl -X GET "http://localhost:9000/account/profile" \ + -H "X-API-TOKEN: " +``` + +如果请求成功,将返回当前 Token 对应用户的账户信息。 + +## 使用场景 + +- 通过脚本调用 Console 的受保护接口 +- 在 CI/CD 或运维任务中访问 Console API +- 供外部系统以 HTTP 方式集成 Console 能力 + +## 权限说明 + +Token 绑定到创建它的用户身份,使用时继承该用户的访问权限。也就是说,Token 能访问哪些接口,取决于该用户本身拥有哪些平台权限和数据权限。 + +## 有效期和失效规则 + +- 创建时可以自定义有效期 +- 可以设置为指定到期时间,或设置为 **永不过期** +- 如果创建请求里未显式指定有效期,默认有效期为 **1 年** +- 删除对应 Token 后,将无法继续使用 + +## 注意事项 + +- Token 只在创建成功时完整展示一次,关闭弹窗后无法再次查看原文 +- 不要将 Token 提交到代码仓库、日志、截图或聊天记录中 +- 建议仅在必要的自动化场景中使用,并按需定期轮换 diff --git a/web/src/locales/en-US.js b/web/src/locales/en-US.js index 5fb262ba..74792e47 100644 --- a/web/src/locales/en-US.js +++ b/web/src/locales/en-US.js @@ -155,6 +155,10 @@ export default { "system.security.token.form.name": "Name", "system.security.token.form.name.required": "Please input token name!", "system.security.token.form.description": "Description", + "system.security.token.form.expire": "Expire At", + "system.security.token.form.expire.required": "Please select an expiration time!", + "system.security.token.form.expire.future": "Expiration time must be in the future!", + "system.security.token.form.never_expire": "Never expire", "system.security.token.drawer.create.title": "Create Access Credential", "system.security.token.drawer.edit.title": "Edit Access Credential", "system.security.token.create.result.title": "Access Credential Created", diff --git a/web/src/locales/zh-CN.js b/web/src/locales/zh-CN.js index 170b5c72..eec8ec7f 100644 --- a/web/src/locales/zh-CN.js +++ b/web/src/locales/zh-CN.js @@ -160,6 +160,10 @@ export default { "system.security.token.form.name": "名称", "system.security.token.form.name.required": "请输入 Token 名称!", "system.security.token.form.description": "描述", + "system.security.token.form.expire": "有效期至", + "system.security.token.form.expire.required": "请选择有效期!", + "system.security.token.form.expire.future": "有效期必须晚于当前时间!", + "system.security.token.form.never_expire": "永不过期", "system.security.token.drawer.create.title": "创建访问凭据", "system.security.token.drawer.edit.title": "编辑访问凭据", "system.security.token.create.result.title": "访问凭据已创建", diff --git a/web/src/pages/System/Security/Token.jsx b/web/src/pages/System/Security/Token.jsx index ad7a0f69..8c67fbf0 100644 --- a/web/src/pages/System/Security/Token.jsx +++ b/web/src/pages/System/Security/Token.jsx @@ -2,6 +2,7 @@ import React, { useMemo, useReducer, useState } from "react"; import { Button, Card, + DatePicker, Divider, Drawer, Form, @@ -9,6 +10,7 @@ import { Input, Modal, Popconfirm, + Switch, Table, message, } from "antd"; @@ -51,6 +53,8 @@ const tokenPreviewCopyButtonStyle = { right: 12, }; +const getDefaultTokenExpireTime = () => moment().add(1, "year"); + const copyText = async (text) => { if (!text) { return false; @@ -75,11 +79,17 @@ const copyText = async (text) => { const TokenForm = Form.create()(({ form, record, onSubmit, submitLoading }) => { const { getFieldDecorator, validateFields } = form; const isEdit = !!record; + const [neverExpire, setNeverExpire] = useState(!(Number(record?.expire_in) > 0)); + const submit = (e) => { e.preventDefault(); validateFields((err, values) => { if (!err) { - onSubmit(values); + onSubmit({ + name: values.name, + description: values.description, + expire_in: values.never_expire ? 0 : values.expire_in.unix(), + }); } }); }; @@ -102,6 +112,65 @@ const TokenForm = Form.create()(({ form, record, onSubmit, submitLoading }) => { initialValue: record?.description || "", })()} + + {getFieldDecorator("never_expire", { + initialValue: !(Number(record?.expire_in) > 0), + valuePropName: "checked", + })( + { + setNeverExpire(checked); + if (!checked && !form.getFieldValue("expire_in")) { + form.setFieldsValue({ + expire_in: getDefaultTokenExpireTime(), + }); + } + }} + /> + )} + + + {getFieldDecorator("expire_in", { + initialValue: + Number(record?.expire_in) > 0 + ? moment.unix(record.expire_in) + : getDefaultTokenExpireTime(), + rules: neverExpire + ? [] + : [ + { + validator: (_, value, callback) => { + if (!value) { + callback( + formatMessage({ + id: "system.security.token.form.expire.required", + }) + ); + return; + } + if (!value.isAfter(moment())) { + callback( + formatMessage({ + id: "system.security.token.form.expire.future", + }) + ); + return; + } + callback(); + }, + }, + ], + })( + + current && current < moment().startOf("day") + } + /> + )} +