Compare commits
24 Commits
v1.28.2
...
fix_gw_age
Author | SHA1 | Date |
---|---|---|
|
5cc4ea9ef1 | |
|
a7ff1c6220 | |
|
9a9896c2f2 | |
|
785f2ede57 | |
|
9d5734b61e | |
|
a0d28fada9 | |
|
d851be6a38 | |
|
aa67bf7c80 | |
|
6c9e8d28c7 | |
|
ca52a5b00d | |
|
1e2f8c2520 | |
|
df33fa006b | |
|
183ebf037c | |
|
6ff2d72ff1 | |
|
1ecdbb3e59 | |
|
78cdd44e9c | |
|
932a2a46e1 | |
|
1cd1f98af4 | |
|
8452d8ef3e | |
|
1e601f259b | |
|
06f7d3bc77 | |
|
a092dd7cb1 | |
|
3f7c32d4de | |
|
f679a57fb2 |
|
@ -1,22 +1,22 @@
|
|||
path.data: data
|
||||
path.logs: log
|
||||
|
||||
allow_multi_instance: true
|
||||
configs.auto_reload: false
|
||||
allow_multi_instance: false
|
||||
configs.auto_reload: true
|
||||
|
||||
entry:
|
||||
- name: my_es_entry
|
||||
- name: agent_es_entry
|
||||
enabled: true
|
||||
router: my_router
|
||||
router: agent_metrics_router
|
||||
max_concurrency: 200000
|
||||
network:
|
||||
binding: 0.0.0.0:8081
|
||||
# tls: #for mTLS connection with config servers
|
||||
# enabled: true
|
||||
binding: 0.0.0.0:8765
|
||||
tls: #for mTLS connection with config servers
|
||||
enabled: true
|
||||
# ca_file: /xxx/ca.crt
|
||||
# cert_file: /xxx/server.crt
|
||||
# key_file: /xxx/server.key
|
||||
# skip_insecure_verify: false
|
||||
skip_insecure_verify: false
|
||||
|
||||
flow:
|
||||
- name: deny_flow
|
||||
|
@ -28,7 +28,7 @@ flow:
|
|||
filter:
|
||||
- basic_auth:
|
||||
valid_users:
|
||||
ingest: n
|
||||
$[[SETUP_AGENT_USERNAME]]: $[[SETUP_AGENT_PASSWORD]]
|
||||
- rewrite_to_bulk:
|
||||
type_removed: false
|
||||
- bulk_request_mutate:
|
||||
|
@ -50,7 +50,7 @@ flow:
|
|||
fix_null_id: true
|
||||
|
||||
router:
|
||||
- name: my_router
|
||||
- name: agent_metrics_router
|
||||
default_flow: deny_flow
|
||||
rules:
|
||||
- method:
|
||||
|
@ -65,8 +65,8 @@ elasticsearch:
|
|||
- name: prod
|
||||
enabled: true
|
||||
basic_auth:
|
||||
username: ingest
|
||||
password: password
|
||||
username: $[[SETUP_AGENT_USERNAME]]
|
||||
password: $[[SETUP_AGENT_PASSWORD]]
|
||||
endpoints: $[[SETUP_ENDPOINTS]]
|
||||
|
||||
pipeline:
|
||||
|
|
|
@ -34,6 +34,7 @@ pipeline:
|
|||
labels:
|
||||
cluster_id: $[[CLUSTER_ID]]
|
||||
cluster_uuid: $[[CLUSTER_UUID]]
|
||||
cluster_name: $[[CLUSTER_NAME]]
|
||||
when:
|
||||
cluster_available: ["$[[TASK_ID]]"]
|
||||
|
||||
|
@ -49,6 +50,7 @@ pipeline:
|
|||
labels:
|
||||
cluster_id: $[[CLUSTER_ID]]
|
||||
cluster_uuid: $[[CLUSTER_UUID]]
|
||||
cluster_name: $[[CLUSTER_NAME]]
|
||||
logs_path: $[[NODE_LOGS_PATH]]
|
||||
queue_name: logs
|
||||
when:
|
||||
|
|
|
@ -28,6 +28,19 @@ PUT _template/$[[SETUP_TEMPLATE_NAME]]
|
|||
}
|
||||
},
|
||||
"mappings": {
|
||||
"properties": {
|
||||
"metadata": {
|
||||
"properties": {
|
||||
"labels": {
|
||||
"properties": {
|
||||
"cluster_id": {
|
||||
"type": "keyword"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"dynamic_templates": [
|
||||
{
|
||||
"strings": {
|
||||
|
@ -368,6 +381,12 @@ PUT _template/$[[SETUP_INDEX_PREFIX]]alert-history-rollover
|
|||
}
|
||||
},
|
||||
"mappings" : {
|
||||
"properties" : {
|
||||
"condition_result" : {
|
||||
"type" : "object",
|
||||
"enabled" : false
|
||||
}
|
||||
},
|
||||
"dynamic_templates" : [
|
||||
{
|
||||
"strings" : {
|
||||
|
|
|
@ -27,6 +27,19 @@ PUT _template/$[[SETUP_TEMPLATE_NAME]]
|
|||
}
|
||||
},
|
||||
"mappings": {
|
||||
"properties": {
|
||||
"metadata": {
|
||||
"properties": {
|
||||
"labels": {
|
||||
"properties": {
|
||||
"cluster_id": {
|
||||
"type": "keyword"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"dynamic_templates": [
|
||||
{
|
||||
"strings": {
|
||||
|
|
|
@ -27,6 +27,19 @@ PUT _template/$[[SETUP_TEMPLATE_NAME]]
|
|||
}
|
||||
},
|
||||
"mappings": {
|
||||
"properties": {
|
||||
"metadata": {
|
||||
"properties": {
|
||||
"labels": {
|
||||
"properties": {
|
||||
"cluster_id": {
|
||||
"type": "keyword"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"dynamic_templates": [
|
||||
{
|
||||
"strings": {
|
||||
|
|
|
@ -104,6 +104,7 @@ security:
|
|||
# group_attribute: "ou"
|
||||
# bypass_api_key: true
|
||||
# cache_ttl: "10s"
|
||||
# default_roles: ["ReadonlyUI","DATA"] #default for all ldap users if no specify roles was defined
|
||||
# role_mapping:
|
||||
# group:
|
||||
# superheros: [ "Administrator" ]
|
||||
|
@ -118,6 +119,7 @@ security:
|
|||
# base_dn: "dc=example,dc=com"
|
||||
# user_filter: "(uid=%s)"
|
||||
# cache_ttl: "10s"
|
||||
# default_roles: ["ReadonlyUI","DATA"] #default for all ldap users if no specify roles was defined
|
||||
# role_mapping:
|
||||
# uid:
|
||||
# tesla: [ "readonly","data" ]
|
||||
|
|
|
@ -7,14 +7,43 @@ title: "Release Notes"
|
|||
|
||||
Information about release notes of INFINI Console is provided here.
|
||||
|
||||
## Latest (In development)
|
||||
|
||||
### Breaking changes
|
||||
|
||||
### Features
|
||||
|
||||
- Add Logs to Monitor (cluster, node)
|
||||
|
||||
### Bug fix
|
||||
- Fixed the error when querying empty metric data (#144)
|
||||
- Fixed empty host when setup step finishes (#147)
|
||||
- Fixed the error of obtaining suggestions of field's value in discover (#151)
|
||||
- Fixed the wrong display of heatmap's data in alerting message (#157)
|
||||
- Fixed Devtools `_sql` support for elasticsearch 6.x (#158)
|
||||
- Fixed audit log default sorting across pagination (#161)
|
||||
- Fixed mapping type conflict error (#164)
|
||||
- Fixed `Gateway` template config for mTLS(#166)
|
||||
|
||||
### Improvements
|
||||
- Update agent config with cluster name (#148)
|
||||
- Optimize UI of histogram and datepicker in discover (#151)
|
||||
- Support viewing logs for cluster, node, index health change events (#150)
|
||||
- Enhance LDAP authentication logging (#156)
|
||||
- Optimize UI for copying metric requests (#155)
|
||||
- Enhance deletion tips by adding cluster info for indices
|
||||
- Support clearing offline agent instances (#165)
|
||||
|
||||
## 1.28.2 (2025-02-15)
|
||||
|
||||
### Features
|
||||
- Support alerts based on bucket diff state (#119)
|
||||
- Add rollup ilm when use Easysearch (#128)
|
||||
- Log activity for cluster metric collection mode changes (#152)
|
||||
|
||||
### Bug fix
|
||||
- Fixed missing data when processing multiple time series in a group with insight data API (#127)
|
||||
- Fixed incorrect node health change activity logging (#154)
|
||||
|
||||
### Improvements
|
||||
|
||||
|
|
|
@ -7,14 +7,43 @@ title: "版本历史"
|
|||
|
||||
这里是 INFINI Console 历史版本发布的相关说明。
|
||||
|
||||
## Latest (In development)
|
||||
|
||||
### Breaking changes
|
||||
|
||||
### Features
|
||||
|
||||
- 监控(集群、节点)新增日志查询
|
||||
|
||||
### Bug fix
|
||||
- 修复指标数据为空时的查询错误 (#144)
|
||||
- 修复初始化结束步骤中主机显示为错误的问题 (#147)
|
||||
- 修复数据探索中获取字段值建议的错误 (#151)
|
||||
- 修复告警消息热图数据显示错误的问题 (#157)
|
||||
- 修复开发工具 `_sql` 查询支撑 Elasticsearch 6.x 版本 (#158)
|
||||
- 修复审计日志默认排序翻页之后丢失的问题 (#161)
|
||||
- 修复 `Mapping` 冲突问题 (#161)
|
||||
- 修复配置文件模板中 `Gateway` mTLS 配置(#166)
|
||||
|
||||
### Improvements
|
||||
- 优化下发给 Agent 的配置,增加集群名称 (#148)
|
||||
- 优化柱状图和时间选择器的 UI (#151)
|
||||
- 集群,节点,索引健康状态变更支持查看日志 (#150)
|
||||
- 增强 LDAP 身份验证的日志记录 (#156)
|
||||
- 优化监控报表里拷贝指标请求的 UI (#155)
|
||||
- 删除索引提示增加集群信息 (#162)
|
||||
|
||||
## 1.28.2 (2025-02-15)
|
||||
|
||||
### Features
|
||||
- 告警功能支持根据桶之间文档数差值和内容差异告警 (#119)
|
||||
- 当使用 Easysearch 存储指标时,增加 Rollup 索引生命周期 (#128)
|
||||
- 增加集群指标采集模式变更事件 (#152)
|
||||
- 支持清理离线 Agent 实例(#165)
|
||||
|
||||
### Bug fix
|
||||
- 修复 Insight API 处理多时间序列数据时数据丢失的问题 (#127)
|
||||
- 修复错误的节点健康状态变更事件 (#154)
|
||||
|
||||
### Improvements
|
||||
|
||||
|
@ -22,8 +51,8 @@ title: "版本历史"
|
|||
- 在注册 Agent 中新增 Agent 凭据设置
|
||||
- 在集群编辑中新增采集模式
|
||||
- 当使用 Easysearch 存储指标时,自动为系统集群创建 Agent 指标写入最小权限用户 (#120)
|
||||
- 修复 LDAP 用户映射增加默认权限组 (#114) (#130)
|
||||
- Agent 连接 Easysearch 的配置信息中增加 `version` 和 `distribution` 来解决启动时退出问题 (#131)
|
||||
- 优化 LDAP 用户映射增加默认权限组 (#114) (#130)
|
||||
- 优化 Agent 连接 Easysearch 的配置信息中增加 `version` 和 `distribution` 来解决启动时退出问题 (#131)
|
||||
|
||||
## 1.28.1 (2025-01-24)
|
||||
|
||||
|
|
|
@ -199,11 +199,13 @@ func getAgentIngestConfigs(instance string, items map[string]BindingItem) (strin
|
|||
var password = ""
|
||||
var version = ""
|
||||
var distribution = ""
|
||||
var clusterName = ""
|
||||
|
||||
if metadata.Config != nil {
|
||||
|
||||
version = metadata.Config.Version
|
||||
distribution = metadata.Config.Distribution
|
||||
clusterName = metadata.Config.Name
|
||||
|
||||
if metadata.Config.AgentCredentialID != "" {
|
||||
credential, err := common2.GetCredential(metadata.Config.AgentCredentialID)
|
||||
|
@ -250,6 +252,7 @@ func getAgentIngestConfigs(instance string, items map[string]BindingItem) (strin
|
|||
"variable:\n "+
|
||||
"TASK_ID: %v\n "+
|
||||
"CLUSTER_ID: %v\n "+
|
||||
"CLUSTER_NAME: %v\n "+
|
||||
"CLUSTER_UUID: %v\n "+
|
||||
"NODE_UUID: %v\n "+
|
||||
"CLUSTER_VERSION: %v\n "+
|
||||
|
@ -260,7 +263,7 @@ func getAgentIngestConfigs(instance string, items map[string]BindingItem) (strin
|
|||
"CLUSTER_LEVEL_TASKS_ENABLED: %v\n "+
|
||||
"NODE_LEVEL_TASKS_ENABLED: %v\n "+
|
||||
"NODE_LOGS_PATH: \"%v\"\n\n\n", taskID, taskID,
|
||||
v.ClusterID, v.ClusterUUID, v.NodeUUID, version, distribution, nodeEndPoint, username, password, clusterLevelEnabled, nodeLevelEnabled, pathLogs)))
|
||||
v.ClusterID, clusterName, v.ClusterUUID, v.NodeUUID, version, distribution, nodeEndPoint, username, password, clusterLevelEnabled, nodeLevelEnabled, pathLogs)))
|
||||
}
|
||||
|
||||
hash := util.MD5digest(buffer.String())
|
||||
|
|
|
@ -27,6 +27,7 @@ import (
|
|||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"infini.sh/framework/core/queue"
|
||||
"math"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
@ -107,6 +108,9 @@ func (h *APIHandler) HandleCreateClusterAction(w http.ResponseWriter, req *http.
|
|||
if conf.Distribution == "" {
|
||||
conf.Distribution = elastic.Elasticsearch
|
||||
}
|
||||
if conf.MetricCollectionMode == "" {
|
||||
conf.MetricCollectionMode = elastic.ModeAgentless
|
||||
}
|
||||
err = orm.Create(ctx, conf)
|
||||
if err != nil {
|
||||
log.Error(err)
|
||||
|
@ -183,6 +187,7 @@ func (h *APIHandler) HandleUpdateClusterAction(w http.ResponseWriter, req *http.
|
|||
h.Error404(w)
|
||||
return
|
||||
}
|
||||
var oldCollectionMode = originConf.MetricCollectionMode
|
||||
buf := util.MustToJSONBytes(originConf)
|
||||
source := map[string]interface{}{}
|
||||
util.MustFromJSONBytes(buf, &source)
|
||||
|
@ -255,7 +260,10 @@ func (h *APIHandler) HandleUpdateClusterAction(w http.ResponseWriter, req *http.
|
|||
h.WriteError(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// record cluster metric collection mode change activity
|
||||
if oldCollectionMode != newConf.MetricCollectionMode {
|
||||
recordCollectionModeChangeActivity(newConf.ID, newConf.Name, oldCollectionMode, newConf.MetricCollectionMode)
|
||||
}
|
||||
basicAuth, err := common.GetBasicAuth(newConf)
|
||||
if err != nil {
|
||||
h.WriteError(w, err.Error(), http.StatusInternalServerError)
|
||||
|
@ -273,6 +281,47 @@ func (h *APIHandler) HandleUpdateClusterAction(w http.ResponseWriter, req *http.
|
|||
h.WriteUpdatedOKJSON(w, id)
|
||||
}
|
||||
|
||||
func recordCollectionModeChangeActivity(clusterID, clusterName, oldMode, newMode string) {
|
||||
activityInfo := &event.Activity{
|
||||
ID: util.GetUUID(),
|
||||
Timestamp: time.Now(),
|
||||
Metadata: event.ActivityMetadata{
|
||||
Category: "elasticsearch",
|
||||
Group: "platform",
|
||||
Name: "metric_collection_mode_change",
|
||||
Type: "update",
|
||||
Labels: util.MapStr{
|
||||
"cluster_id": clusterID,
|
||||
"cluster_name": clusterName,
|
||||
"from": oldMode,
|
||||
"to": newMode,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
queueConfig := queue.GetOrInitConfig("platform##activities")
|
||||
if queueConfig.Labels == nil {
|
||||
queueConfig.ReplaceLabels(util.MapStr{
|
||||
"type": "platform",
|
||||
"name": "activity",
|
||||
"category": "elasticsearch",
|
||||
"activity": true,
|
||||
})
|
||||
}
|
||||
err := queue.Push(queueConfig, util.MustToJSONBytes(event.Event{
|
||||
Timestamp: time.Now(),
|
||||
Metadata: event.EventMetadata{
|
||||
Category: "elasticsearch",
|
||||
Name: "activity",
|
||||
},
|
||||
Fields: util.MapStr{
|
||||
"activity": activityInfo,
|
||||
}}))
|
||||
if err != nil {
|
||||
log.Error(err)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *APIHandler) HandleDeleteClusterAction(w http.ResponseWriter, req *http.Request, ps httprouter.Params) {
|
||||
resBody := map[string]interface{}{}
|
||||
id := ps.MustGetParameter("id")
|
||||
|
|
|
@ -80,6 +80,7 @@ func (h *APIHandler) HandleProxyAction(w http.ResponseWriter, req *http.Request,
|
|||
}
|
||||
if strings.Trim(newURL.Path, "/") == "_sql" {
|
||||
distribution := esClient.GetVersion().Distribution
|
||||
version := esClient.GetVersion().Number
|
||||
indexName, err := rewriteTableNamesOfSqlRequest(req, distribution)
|
||||
if err != nil {
|
||||
h.WriteError(w, err.Error(), http.StatusInternalServerError)
|
||||
|
@ -92,6 +93,15 @@ func (h *APIHandler) HandleProxyAction(w http.ResponseWriter, req *http.Request,
|
|||
q, _ := url.ParseQuery(newURL.RawQuery)
|
||||
hasFormat := q.Has("format")
|
||||
switch distribution {
|
||||
case elastic.Elasticsearch:
|
||||
if !hasFormat {
|
||||
q.Add("format", "txt")
|
||||
}
|
||||
if large, _ := util.VersionCompare(version, "7.0.0"); large > 0 {
|
||||
path = "_sql?" + q.Encode()
|
||||
} else {
|
||||
path = "_xpack/_sql?" + q.Encode()
|
||||
}
|
||||
case elastic.Opensearch:
|
||||
path = "_plugins/_sql?format=raw"
|
||||
case elastic.Easysearch:
|
||||
|
|
|
@ -82,6 +82,9 @@ func (r *LDAPRealm) mapLDAPRoles(authInfo auth.Info) []string {
|
|||
}
|
||||
|
||||
//map group
|
||||
if len(authInfo.GetGroups()) == 0 {
|
||||
log.Debugf("LDAP uid: %v, user: %v, group: %v", uid, authInfo, authInfo.GetGroups())
|
||||
}
|
||||
for _, roleName := range authInfo.GetGroups() {
|
||||
newRoles, ok := r.config.RoleMapping.Group[roleName]
|
||||
if ok {
|
||||
|
|
|
@ -77,9 +77,9 @@ func Init(config *config.Config) {
|
|||
|
||||
func Authenticate(username, password string) (bool, *rbac.User, error) {
|
||||
|
||||
for i, realm := range realms {
|
||||
for _, realm := range realms {
|
||||
ok, user, err := realm.Authenticate(username, password)
|
||||
log.Debugf("authenticate result: %v, user: %v, err: %v, realm: %v", ok, user, err, i)
|
||||
log.Debugf("authenticate result: %v, user: %v, err: %v, realm: %v", ok, user, err, realm.GetType())
|
||||
if ok && user != nil && err == nil {
|
||||
return true, user, nil
|
||||
}
|
||||
|
@ -92,14 +92,14 @@ func Authenticate(username, password string) (bool, *rbac.User, error) {
|
|||
|
||||
func Authorize(user *rbac.User) (bool, error) {
|
||||
|
||||
for i, realm := range realms {
|
||||
for _, realm := range realms {
|
||||
//skip if not the same auth provider, TODO: support cross-provider authorization
|
||||
if user.AuthProvider != realm.GetType() {
|
||||
continue
|
||||
}
|
||||
|
||||
ok, err := realm.Authorize(user)
|
||||
log.Debugf("authorize result: %v, user: %v, err: %v, realm: %v", ok, user, err, i)
|
||||
log.Debugf("authorize result: %v, user: %v, err: %v, realm: %v", ok, user, err, realm.GetType())
|
||||
if ok && err == nil {
|
||||
//return on any success, TODO, maybe merge all roles and privileges from all realms
|
||||
return true, nil
|
||||
|
|
|
@ -132,7 +132,7 @@ func (processor *MetadataProcessor) HandleUnknownNodeStatus(ev []byte) error {
|
|||
}
|
||||
esClient := elastic.GetClient(processor.config.Elasticsearch)
|
||||
queryDslTpl := `{"script": {
|
||||
"source": "ctx._source.metadata.labels.status='unavailable'",
|
||||
"source": "ctx._source.metadata.labels.status='unknown'",
|
||||
"lang": "painless"
|
||||
},
|
||||
"query": {
|
||||
|
|
|
@ -30,6 +30,9 @@ package server
|
|||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"infini.sh/framework/core/event"
|
||||
"infini.sh/framework/core/global"
|
||||
"infini.sh/framework/core/task"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
@ -76,6 +79,8 @@ func init() {
|
|||
|
||||
//try to connect to instance
|
||||
api.HandleAPIMethod(api.POST, "/instance/try_connect", handler.RequireLogin(handler.tryConnect))
|
||||
//clear instance that is not alive in 7 days
|
||||
api.HandleAPIMethod(api.POST, "/instance/_clear", handler.RequirePermission(handler.clearInstance, enum.PermissionGatewayInstanceWrite))
|
||||
|
||||
}
|
||||
|
||||
|
@ -85,18 +90,20 @@ func (h APIHandler) registerInstance(w http.ResponseWriter, req *http.Request, p
|
|||
err := h.DecodeJSON(req, obj)
|
||||
if err != nil {
|
||||
h.WriteError(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if obj.Endpoint == "" {
|
||||
h.WriteError(w, "empty endpoint", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
oldInst := &model.Instance{}
|
||||
oldInst.ID = obj.ID
|
||||
exists, err := orm.Get(oldInst)
|
||||
if exists {
|
||||
errMsg := fmt.Sprintf("agent [%s] already exists", obj.ID)
|
||||
h.WriteError(w, errMsg, http.StatusInternalServerError)
|
||||
return
|
||||
obj.Created = oldInst.Created
|
||||
}
|
||||
|
||||
err = orm.Create(nil, obj)
|
||||
err = orm.Save(nil, obj)
|
||||
if err != nil {
|
||||
h.WriteError(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
|
@ -369,6 +376,168 @@ func (h *APIHandler) getInstanceStatus(w http.ResponseWriter, req *http.Request,
|
|||
}
|
||||
h.WriteJSON(w, result, http.StatusOK)
|
||||
}
|
||||
func (h *APIHandler) clearInstance(w http.ResponseWriter, req *http.Request, ps httprouter.Params) {
|
||||
appName := h.GetParameterOrDefault(req, "app_name", "")
|
||||
task.RunWithinGroup("clear_instance", func(ctx context.Context) error {
|
||||
err := h.clearInstanceByAppName(appName)
|
||||
if err != nil {
|
||||
log.Error(err)
|
||||
}
|
||||
return err
|
||||
})
|
||||
h.WriteAckOKJSON(w)
|
||||
}
|
||||
|
||||
func (h *APIHandler) clearInstanceByAppName(appName string) error {
|
||||
var (
|
||||
size = 100
|
||||
from = 0
|
||||
)
|
||||
// Paginated query for all running instances
|
||||
q := orm.Query{
|
||||
Size: size,
|
||||
From: from,
|
||||
}
|
||||
if appName != "" {
|
||||
q.Conds = orm.And(
|
||||
orm.Eq("application.name", appName),
|
||||
)
|
||||
}
|
||||
q.AddSort("created", orm.ASC)
|
||||
insts := []model.Instance{}
|
||||
var (
|
||||
instanceIDs []string
|
||||
toRemoveIDs []string
|
||||
instsCache = map[string]*model.Instance{}
|
||||
)
|
||||
client := elastic2.GetClient(global.MustLookupString(elastic2.GlobalSystemElasticsearchID))
|
||||
for {
|
||||
err, _ := orm.SearchWithJSONMapper(&insts, &q)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, inst := range insts {
|
||||
instanceIDs = append(instanceIDs, inst.ID)
|
||||
instsCache[inst.ID] = &inst
|
||||
}
|
||||
if len(instanceIDs) == 0 {
|
||||
break
|
||||
}
|
||||
aliveInstanceIDs, err := getAliveInstanceIDs(client, instanceIDs)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, instanceID := range instanceIDs {
|
||||
if _, ok := aliveInstanceIDs[instanceID]; !ok {
|
||||
toRemoveIDs = append(toRemoveIDs, instanceID)
|
||||
}
|
||||
}
|
||||
if len(toRemoveIDs) > 0 {
|
||||
// Use the same slice to avoid extra allocation
|
||||
filteredIDs := toRemoveIDs[:0]
|
||||
// check whether the instance is still online
|
||||
for _, instanceID := range toRemoveIDs {
|
||||
if inst, ok := instsCache[instanceID]; ok {
|
||||
_, err = h.getInstanceInfo(inst.Endpoint, inst.BasicAuth)
|
||||
if err == nil {
|
||||
// Skip online instance, do not append to filtered list
|
||||
continue
|
||||
}
|
||||
}
|
||||
// Keep only offline instances
|
||||
filteredIDs = append(filteredIDs, instanceID)
|
||||
}
|
||||
|
||||
// Assign back after filtering
|
||||
toRemoveIDs = filteredIDs
|
||||
query := util.MapStr{
|
||||
"query": util.MapStr{
|
||||
"terms": util.MapStr{
|
||||
"id": toRemoveIDs,
|
||||
},
|
||||
},
|
||||
}
|
||||
// remove instances
|
||||
err = orm.DeleteBy(model.Instance{}, util.MustToJSONBytes(query))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to delete instance: %w", err)
|
||||
}
|
||||
// remove instance related data
|
||||
query = util.MapStr{
|
||||
"query": util.MapStr{
|
||||
"terms": util.MapStr{
|
||||
"metadata.labels.agent_id": toRemoveIDs,
|
||||
},
|
||||
},
|
||||
}
|
||||
err = orm.DeleteBy(model.Setting{}, util.MustToJSONBytes(query))
|
||||
}
|
||||
|
||||
// Exit loop when the number of returned records is less than the page size
|
||||
if len(insts) <= size {
|
||||
break
|
||||
}
|
||||
// Reset instance state for the next iteration
|
||||
insts = []model.Instance{}
|
||||
toRemoveIDs = nil
|
||||
instsCache = make(map[string]*model.Instance)
|
||||
q.From += size
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func getAliveInstanceIDs(client elastic2.API, instanceIDs []string) (map[string]struct{}, error) {
|
||||
query := util.MapStr{
|
||||
"size": 0,
|
||||
"query": util.MapStr{
|
||||
"bool": util.MapStr{
|
||||
"must": []util.MapStr{
|
||||
{
|
||||
"terms": util.MapStr{
|
||||
"agent.id": instanceIDs,
|
||||
},
|
||||
},
|
||||
{
|
||||
"range": util.MapStr{
|
||||
"timestamp": util.MapStr{
|
||||
"gt": "now-7d",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"aggs": util.MapStr{
|
||||
"grp_agent_id": util.MapStr{
|
||||
"terms": util.MapStr{
|
||||
"field": "agent.id",
|
||||
},
|
||||
"aggs": util.MapStr{
|
||||
"count": util.MapStr{
|
||||
"value_count": util.MapStr{
|
||||
"field": "agent.id",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
queryDSL := util.MustToJSONBytes(query)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Second*10)
|
||||
defer cancel()
|
||||
response, err := client.QueryDSL(ctx, orm.GetWildcardIndexName(event.Event{}), nil, queryDSL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ret := map[string]struct{}{}
|
||||
for _, bk := range response.Aggregations["grp_agent_id"].Buckets {
|
||||
key := bk["key"].(string)
|
||||
if bk["doc_count"].(float64) > 0 {
|
||||
ret[key] = struct{}{}
|
||||
}
|
||||
}
|
||||
return ret, nil
|
||||
}
|
||||
|
||||
func (h *APIHandler) proxy(w http.ResponseWriter, req *http.Request, ps httprouter.Params) {
|
||||
var (
|
||||
|
@ -417,7 +586,7 @@ func (h *APIHandler) getInstanceInfo(endpoint string, basicAuth *model.BasicAuth
|
|||
obj := &model.Instance{}
|
||||
_, err := ProxyAgentRequest("runtime", endpoint, req1, obj)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
return nil, err
|
||||
}
|
||||
return obj, err
|
||||
|
||||
|
|
|
@ -215,7 +215,7 @@ const DatePicker = (props) => {
|
|||
isMinimum ? styles.minimum : ""
|
||||
} ${className}`}
|
||||
>
|
||||
<Button.Group className={styles.RangeBox}>
|
||||
<Button.Group className={styles.RangeBox} style={{ width: onRefresh ? 'calc(100% - 64px)' : 'calc(100% - 32px)'}}>
|
||||
{!isMinimum && (
|
||||
<Button
|
||||
className={`${styles.iconBtn} common-ui-datepicker-backward`}
|
||||
|
|
|
@ -42,8 +42,8 @@
|
|||
align-items: center;
|
||||
margin-left: 4px !important;
|
||||
.play {
|
||||
min-width: 30px;
|
||||
max-width: 30px;
|
||||
min-width: 32px;
|
||||
max-width: 32px;
|
||||
padding: 0;
|
||||
font-size: 14px;
|
||||
color: #1890ff;
|
||||
|
|
|
@ -8,7 +8,6 @@ import { formatMessage } from "umi/locale";
|
|||
import { getDocPathByLang, getWebsitePathByLang } from "@/utils/utils";
|
||||
|
||||
export default ({autoInit = false}) => {
|
||||
const { loading, value } = useFetch(`/instance/_search`);
|
||||
|
||||
const [tokenLoading, setTokenLoading] = useState(false);
|
||||
|
||||
|
@ -18,7 +17,6 @@ export default ({autoInit = false}) => {
|
|||
|
||||
const fetchTokenInfo = async () => {
|
||||
setTokenInfo()
|
||||
// if (seletedGateways.length === 0) return;
|
||||
setTokenLoading(true)
|
||||
const res = await request('/instance/_generate_install_script', {
|
||||
method: "POST",
|
||||
|
@ -35,32 +33,10 @@ export default ({autoInit = false}) => {
|
|||
}
|
||||
}, [])
|
||||
|
||||
const gateways = value?.hits?.hits || []
|
||||
|
||||
return (
|
||||
<Spin spinning={loading || tokenLoading}>
|
||||
<Spin spinning={tokenLoading}>
|
||||
<div className={styles.installAgent}>
|
||||
{/* <Form className={styles.gateway} layout="vertical">
|
||||
<Form.Item label="选择接入网关" required>
|
||||
<Select
|
||||
mode="multiple"
|
||||
style={{ width: '100%' }}
|
||||
onChange={(value) => setSeletedGateways(value)}
|
||||
onBlur={() => fetchTokenInfo()}
|
||||
>
|
||||
{
|
||||
gateways.map((item) => (
|
||||
<Select.Option key={item._source.endpoint}>
|
||||
<span>
|
||||
<span style={{marginRight: 4}}>{item._source.name}</span>
|
||||
<span>[{item._source.endpoint}]</span>
|
||||
</span>
|
||||
</Select.Option>
|
||||
))
|
||||
}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
</Form> */}
|
||||
{!autoInit && <Button className={styles.gateway} type="primary" onClick={() => fetchTokenInfo()}>
|
||||
{formatMessage({
|
||||
id:"agent.install.label.get_cmd"
|
||||
|
|
|
@ -119,9 +119,10 @@ const Monitor = (props) => {
|
|||
setParam({ ...param, timeRange: state.timeRange, timeInterval: state.timeInterval, timeout: state.timeout });
|
||||
}, [state.timeRange, state.timeInterval, state.timeout]);
|
||||
|
||||
const handleTimeChange = useCallback(({ start, end, timeInterval, timeout, refresh }) => {
|
||||
const handleTimeChange = ({ start, end, timeInterval, timeout, refresh }) => {
|
||||
setState(initState({
|
||||
...state,
|
||||
param,
|
||||
timeRange: {
|
||||
min: start,
|
||||
max: end,
|
||||
|
@ -130,11 +131,12 @@ const Monitor = (props) => {
|
|||
timeout: timeout || state.timeout,
|
||||
refresh
|
||||
}));
|
||||
}, [state])
|
||||
}
|
||||
|
||||
const onInfoChange = (info) => {
|
||||
setState({
|
||||
...state,
|
||||
param,
|
||||
info,
|
||||
});
|
||||
};
|
||||
|
@ -156,7 +158,7 @@ const Monitor = (props) => {
|
|||
return monitor_configs?.node_stats?.enabled === false && monitor_configs?.index_stats?.enabled === false
|
||||
}
|
||||
return metric_collection_mode === 'agent'
|
||||
}, [JSON.stringify(selectedCluster?.monitor_configs)])
|
||||
}, [JSON.stringify(selectedCluster)])
|
||||
|
||||
return (
|
||||
<div>
|
||||
|
@ -168,45 +170,48 @@ const Monitor = (props) => {
|
|||
<>
|
||||
<div style={{ marginBottom: 5 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
|
||||
<DatePicker
|
||||
locale={getLocale()}
|
||||
start={state.timeRange.min}
|
||||
end={state.timeRange.max}
|
||||
onRangeChange={({ start, end }) => {
|
||||
handleTimeChange({ start, end })
|
||||
}}
|
||||
{...refresh}
|
||||
onRefreshChange={(newRefresh) => {
|
||||
onTimeSettingsChange(newRefresh)
|
||||
setRefresh(newRefresh)
|
||||
}}
|
||||
onRefresh={handleTimeChange}
|
||||
showTimeSetting={true}
|
||||
showTimeInterval={true}
|
||||
timeInterval={state.timeInterval}
|
||||
timeIntervalDisabled={state.timeIntervalDisabled}
|
||||
showTimeout={true}
|
||||
timeout={state.timeout}
|
||||
onTimeSettingChange={(timeSetting) => {
|
||||
onTimeSettingsChange({
|
||||
timeInterval: timeSetting.timeInterval,
|
||||
timeout: timeSetting.timeout
|
||||
})
|
||||
setState({
|
||||
...state,
|
||||
timeInterval: timeSetting.timeInterval,
|
||||
timeout: timeSetting.timeout
|
||||
});
|
||||
}}
|
||||
timeZone={timeZone}
|
||||
onTimeZoneChange={(timeZone) => {
|
||||
onTimeSettingsChange({
|
||||
timeZone,
|
||||
})
|
||||
setTimeZone(timeZone)
|
||||
}}
|
||||
recentlyUsedRangesKey={'monitor'}
|
||||
/>
|
||||
<div style={{ maxWidth: 600 }}>
|
||||
<DatePicker
|
||||
locale={getLocale()}
|
||||
start={state.timeRange.min}
|
||||
end={state.timeRange.max}
|
||||
onRangeChange={({ start, end }) => {
|
||||
handleTimeChange({ start, end })
|
||||
}}
|
||||
{...refresh}
|
||||
onRefreshChange={(newRefresh) => {
|
||||
onTimeSettingsChange(newRefresh)
|
||||
setRefresh(newRefresh)
|
||||
}}
|
||||
onRefresh={(value) => handleTimeChange({ ...(value || {}), refresh: new Date().valueOf()})}
|
||||
showTimeSetting={true}
|
||||
showTimeInterval={true}
|
||||
timeInterval={state.timeInterval}
|
||||
timeIntervalDisabled={state.timeIntervalDisabled}
|
||||
showTimeout={true}
|
||||
timeout={state.timeout}
|
||||
onTimeSettingChange={(timeSetting) => {
|
||||
onTimeSettingsChange({
|
||||
timeInterval: timeSetting.timeInterval,
|
||||
timeout: timeSetting.timeout
|
||||
})
|
||||
setState({
|
||||
...state,
|
||||
param,
|
||||
timeInterval: timeSetting.timeInterval,
|
||||
timeout: timeSetting.timeout
|
||||
});
|
||||
}}
|
||||
timeZone={timeZone}
|
||||
onTimeZoneChange={(timeZone) => {
|
||||
onTimeSettingsChange({
|
||||
timeZone,
|
||||
})
|
||||
setTimeZone(timeZone)
|
||||
}}
|
||||
recentlyUsedRangesKey={'monitor'}
|
||||
/>
|
||||
</div>
|
||||
<CollectStatus fetchUrl={`${ESPrefix}/${selectedCluster?.id}/_collection_stats`}/>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -221,7 +226,7 @@ const Monitor = (props) => {
|
|||
animated={false}
|
||||
>
|
||||
{panes.map((pane) => (
|
||||
<TabPane tab={pane.title} key={pane.key}>
|
||||
<TabPane tab={formatMessage({id: `cluster.monitor.tabs.${pane.key}`})} key={pane.key}>
|
||||
<Spin spinning={spinning && !!state.refresh}>
|
||||
<StatisticBar
|
||||
setSpinning={setSpinning}
|
||||
|
@ -249,6 +254,7 @@ const Monitor = (props) => {
|
|||
})
|
||||
setState({
|
||||
...state,
|
||||
param,
|
||||
timeInterval,
|
||||
});
|
||||
}}
|
||||
|
|
|
@ -121,6 +121,9 @@ function FilterBarUI(props: Props) {
|
|||
onCancel={() => setIsAddFilterPopoverOpen(false)}
|
||||
key={JSON.stringify(newFilter)}
|
||||
services={props.services}
|
||||
dateRangeFrom={props.dateRangeFrom}
|
||||
dateRangeTo={props.dateRangeTo}
|
||||
timeField={props.timeField}
|
||||
/>
|
||||
</div>
|
||||
</EuiFlexItem>
|
||||
|
|
|
@ -313,6 +313,9 @@ class FilterEditorUI extends Component<Props, State> {
|
|||
onChange={this.onParamsChange}
|
||||
data-test-subj="phraseValueInput"
|
||||
services={this.props.services}
|
||||
dateRangeFrom={this.props.dateRangeFrom}
|
||||
dateRangeTo={this.props.dateRangeTo}
|
||||
timeField={this.props.timeField}
|
||||
/>
|
||||
);
|
||||
case 'phrases':
|
||||
|
@ -323,6 +326,9 @@ class FilterEditorUI extends Component<Props, State> {
|
|||
values={this.state.params}
|
||||
onChange={this.onParamsChange}
|
||||
services={this.props.services}
|
||||
dateRangeFrom={this.props.dateRangeFrom}
|
||||
dateRangeTo={this.props.dateRangeTo}
|
||||
timeField={this.props.timeField}
|
||||
/>
|
||||
);
|
||||
case 'range':
|
||||
|
|
|
@ -82,17 +82,30 @@ export class PhraseSuggestorUI<
|
|||
protected updateSuggestions = debounce(async (query: string = "") => {
|
||||
if (this.abortController) this.abortController.abort();
|
||||
this.abortController = new AbortController();
|
||||
const { indexPattern, field } = this.props as PhraseSuggestorProps;
|
||||
const { indexPattern, field, dateRangeFrom, dateRangeTo, timeField } = this.props as PhraseSuggestorProps;
|
||||
if (!field || !this.isSuggestingValues()) {
|
||||
return;
|
||||
}
|
||||
this.setState({ isLoading: true });
|
||||
|
||||
const boolFilter = []
|
||||
if (dateRangeFrom && dateRangeTo && timeField) {
|
||||
boolFilter.push({
|
||||
"range": {
|
||||
[timeField]: {
|
||||
"gte": dateRangeFrom,
|
||||
"lte": dateRangeTo
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const suggestions = await this.props.services.data.autocomplete.getValueSuggestions(
|
||||
{
|
||||
indexPattern,
|
||||
field,
|
||||
query,
|
||||
boolFilter,
|
||||
signal: this.abortController.signal,
|
||||
}
|
||||
);
|
||||
|
|
|
@ -50,8 +50,7 @@ class PhraseValueInputUI extends PhraseSuggestorUI<Props> {
|
|||
}
|
||||
|
||||
private renderWithSuggestions() {
|
||||
let { suggestions } = this.state;
|
||||
suggestions = suggestions || [];
|
||||
const suggestions = Array.isArray(this.state.suggestions) ? this.state.suggestions : []
|
||||
const { value, intl, onChange } = this.props;
|
||||
// there are cases when the value is a number, this would cause an exception
|
||||
const valueAsStr = String(value);
|
||||
|
|
|
@ -59,7 +59,7 @@
|
|||
@include euiBreakpoint("m", "l", "xl") {
|
||||
.kbnQueryBar__datePickerWrapper {
|
||||
// sass-lint:disable-block no-important
|
||||
max-width: 340px;
|
||||
max-width: 400px;
|
||||
flex-grow: 0 !important;
|
||||
flex-basis: auto !important;
|
||||
margin-right: -$euiSizeXS;
|
||||
|
|
|
@ -264,7 +264,7 @@ export default function QueryBarTopRow(props: QueryBarTopRowProps) {
|
|||
return (
|
||||
<NoDataPopover storage={storage} showNoDataPopover={props.indicateNoData}>
|
||||
<EuiFlexGroup responsive={false} gutterSize="s">
|
||||
{renderHistogram()}
|
||||
{/* {renderHistogram()} */}
|
||||
{renderDatePicker()}
|
||||
<EuiFlexItem grow={false}>{button}</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
|
|
|
@ -484,6 +484,9 @@ class SearchBarUI extends Component<SearchBarProps, State> {
|
|||
filters={this.props.filters!}
|
||||
onFiltersUpdated={this.props.onFiltersUpdated}
|
||||
indexPatterns={this.props.indexPatterns!}
|
||||
dateRangeFrom={this.state.dateRangeFrom}
|
||||
dateRangeTo={this.state.dateRangeTo}
|
||||
timeField={this.props.timeSetting?.timeField}
|
||||
services={this.props.services}
|
||||
/>
|
||||
</div>
|
||||
|
|
|
@ -88,7 +88,7 @@ export class DiscoverHistogram extends Component {
|
|||
|
||||
render() {
|
||||
const timeZone = getTimezone();
|
||||
const { chartData } = this.props;
|
||||
const { chartData, height = 100 } = this.props;
|
||||
|
||||
const { chartsTheme, chartsBaseTheme } = this.state;
|
||||
|
||||
|
@ -149,7 +149,7 @@ export class DiscoverHistogram extends Component {
|
|||
//console.log(data)
|
||||
|
||||
return (
|
||||
<Chart size={{ height: 40 }}>
|
||||
<Chart size={{ height }}>
|
||||
<Settings
|
||||
xDomain={xDomain}
|
||||
onBrushEnd={this.onBrushEnd}
|
||||
|
|
|
@ -121,6 +121,7 @@ export default {
|
|||
"form.button.restart": "Restart",
|
||||
"form.button.verify": "Verify",
|
||||
"form.button.clean": "Clean",
|
||||
"form.button.view_logs": "View Logs",
|
||||
"form.button.clean.confim.desc": "Are you sure to clean data that is {status}?",
|
||||
"form.button.clean.unavailable.nodes": "Clean unavailable nodes",
|
||||
"form.button.clean.unavailable.nodes.desc": "Are you sure to clean nodes that are unavailable within seven days?",
|
||||
|
|
|
@ -43,4 +43,7 @@ export default {
|
|||
|
||||
"agent.label.agent_credential": "Agent Credential",
|
||||
"agent.credential.tip": "No credential required",
|
||||
"agent.instance.clear.title": "Clear Offline Instances",
|
||||
"agent.instance.clear.modal.title": "Are you sure you want to clear offline instances?",
|
||||
"agent.instance.clear.modal.desc": "This operation will delete offline instances that have not reported metrics for 7 days."
|
||||
};
|
||||
|
|
|
@ -124,6 +124,23 @@ export default {
|
|||
"cluster.monitor.topn.color": "Color Metric",
|
||||
"cluster.monitor.topn.theme": "Theme",
|
||||
|
||||
"cluster.monitor.logs.timestamp": "Timestamp",
|
||||
"cluster.monitor.logs.type": "Type",
|
||||
"cluster.monitor.logs.level": "Level",
|
||||
"cluster.monitor.logs.node": "Node",
|
||||
"cluster.monitor.logs.message": "Message",
|
||||
"cluster.monitor.logs.search.placeholder": "Search message",
|
||||
"cluster.monitor.logs.empty.agent": "No data, please change the time range or check if the Agent is working properly.",
|
||||
"cluster.monitor.logs.empty.agentless": "No data, please install the Agent and change the cluster collection mode to Agent.",
|
||||
|
||||
"cluster.monitor.tabs.overview": "Overview",
|
||||
"cluster.monitor.tabs.advanced": "Advanced",
|
||||
"cluster.monitor.tabs.topn": "TopN",
|
||||
"cluster.monitor.tabs.logs": "Logs",
|
||||
"cluster.monitor.tabs.nodes": "Nodes",
|
||||
"cluster.monitor.tabs.indices": "Indices",
|
||||
"cluster.monitor.tabs.shards": "Shards",
|
||||
|
||||
"cluster.metrics.axis.index_throughput.title": "Indexing Rate",
|
||||
"cluster.metrics.axis.search_throughput.title": "Search Rate",
|
||||
"cluster.metrics.axis.index_latency.title": "Indexing Latency",
|
||||
|
@ -371,4 +388,6 @@ export default {
|
|||
|
||||
"cluster.collect.last_active_at": "Last Active At",
|
||||
|
||||
|
||||
|
||||
};
|
||||
|
|
|
@ -126,6 +126,7 @@ export default {
|
|||
"form.button.restart": "重启",
|
||||
"form.button.verify": "校验",
|
||||
"form.button.clean": "清除",
|
||||
"form.button.view_logs": "View Logs",
|
||||
"form.button.clean.confim.desc": "确定删除状态为 {status} 的数据吗?",
|
||||
"form.button.clean.unavailable.nodes": "清除不可用节点",
|
||||
"form.button.clean.unavailable.nodes.desc": "确定清除7天内不可用的节点吗?",
|
||||
|
|
|
@ -40,4 +40,7 @@ export default {
|
|||
|
||||
"agent.label.agent_credential": "代理凭据",
|
||||
"agent.credential.tip": "不需要凭据",
|
||||
"agent.instance.clear.title": "清理离线实例",
|
||||
"agent.instance.clear.modal.title": "您确定要清理离线实例?",
|
||||
"agent.instance.clear.modal.desc": "该操作将会删除离线并且 7 天没有上报指标的实例"
|
||||
};
|
||||
|
|
|
@ -115,6 +115,23 @@ export default {
|
|||
"cluster.monitor.topn.color": "颜色指标",
|
||||
"cluster.monitor.topn.theme": "主题",
|
||||
|
||||
"cluster.monitor.logs.timestamp": "时间戳",
|
||||
"cluster.monitor.logs.type": "类型",
|
||||
"cluster.monitor.logs.level": "等级",
|
||||
"cluster.monitor.logs.node": "节点",
|
||||
"cluster.monitor.logs.message": "日志",
|
||||
"cluster.monitor.logs.search.placeholder": "搜索日志",
|
||||
"cluster.monitor.logs.empty.agent": "没有数据,请更改时间范围或检查 Agent 是否正常工作。",
|
||||
"cluster.monitor.logs.empty.agentless": "没有数据,请安装 Agent 并更改集群采集模式为 Agent 。",
|
||||
|
||||
"cluster.monitor.tabs.overview": "概览",
|
||||
"cluster.monitor.tabs.advanced": "高级",
|
||||
"cluster.monitor.tabs.topn": "TopN",
|
||||
"cluster.monitor.tabs.logs": "日志",
|
||||
"cluster.monitor.tabs.nodes": "节点",
|
||||
"cluster.monitor.tabs.indices": "索引",
|
||||
"cluster.monitor.tabs.shards": "分片",
|
||||
|
||||
"cluster.metrics.axis.index_throughput.title": "索引吞吐",
|
||||
"cluster.metrics.axis.search_throughput.title": "查询吞吐",
|
||||
"cluster.metrics.axis.index_latency.title": "索引延迟",
|
||||
|
|
|
@ -355,7 +355,10 @@ export default {
|
|||
let idx = state.clusterList.findIndex((item) => item.id === payload.id);
|
||||
idx > -1 && (state.clusterList[idx].name = payload.name);
|
||||
if (state.selectedCluster?.id === payload.id) {
|
||||
state.selectedCluster.monitor_configs = payload.monitor_configs
|
||||
state.selectedCluster = {
|
||||
...(state.selectedCluster || {}),
|
||||
...(payload || {})
|
||||
}
|
||||
}
|
||||
state.clusterStatus[payload.id].config.monitored = payload.monitored;
|
||||
return state;
|
||||
|
|
|
@ -379,6 +379,37 @@ const AgentList = (props) => {
|
|||
}
|
||||
};
|
||||
|
||||
const [clearLoading, setClearLoading] = useState(false)
|
||||
const onClearClick = async ()=>{
|
||||
setClearLoading(true);
|
||||
const statusRes = await request(`/instance/_clear`, {
|
||||
method: "POST",
|
||||
queryParams: {
|
||||
"app_name": "agent",
|
||||
},
|
||||
});
|
||||
if(statusRes && statusRes.acknowledged){
|
||||
message.success("submit successfully");
|
||||
}
|
||||
setClearLoading(false);
|
||||
}
|
||||
const showClearConfirm = useCallback(() => {
|
||||
Modal.confirm({
|
||||
title: formatMessage({ id: "agent.instance.clear.modal.title" }),
|
||||
content: (
|
||||
<>
|
||||
<div>{formatMessage({ id: "agent.instance.clear.modal.desc" })}</div>
|
||||
</>
|
||||
),
|
||||
okText: "Yes",
|
||||
okType: "danger",
|
||||
cancelText: "No",
|
||||
onOk() {
|
||||
onClearClick();
|
||||
},
|
||||
});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<PageHeaderWrapper>
|
||||
<Card>
|
||||
|
@ -390,7 +421,7 @@ const AgentList = (props) => {
|
|||
marginBottom: 15,
|
||||
}}
|
||||
>
|
||||
<div style={{ maxWidth: 500, flex: "1 1 auto" }}>
|
||||
<div style={{ maxWidth: 450, flex: "1 1 auto" }}>
|
||||
<Search
|
||||
allowClear
|
||||
placeholder="Type keyword to search"
|
||||
|
@ -413,6 +444,9 @@ const AgentList = (props) => {
|
|||
{
|
||||
hasAuthority("agent.instance:all") && (
|
||||
<>
|
||||
<Button loading={clearLoading} onClick={showClearConfirm}>
|
||||
{formatMessage({ id: "agent.instance.clear.title" })}
|
||||
</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={() => {
|
||||
|
|
|
@ -666,7 +666,7 @@ const Index = (props) => {
|
|||
onRangeChange={onTimeChange}
|
||||
{...refresh}
|
||||
onRefreshChange={setRefresh}
|
||||
onRefresh={({ start, end}) => onTimeChange({ start, end, refresh: new Date().valueOf()})}
|
||||
onRefresh={(value) => onTimeChange({ ...(value || {}), refresh: new Date().valueOf()})}
|
||||
timeZone={timeZone}
|
||||
onTimeZoneChange={setTimeZone}
|
||||
recentlyUsedRangesKey={'alerting-message'}
|
||||
|
@ -682,14 +682,6 @@ const Index = (props) => {
|
|||
gap: 10,
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
icon="redo"
|
||||
onClick={() => {
|
||||
onRefreshClick();
|
||||
}}
|
||||
>
|
||||
{formatMessage({ id: "form.button.refresh" })}
|
||||
</Button>
|
||||
{hasAuthority("alerting.message:all") ? (
|
||||
<Dropdown overlay={batchMenu}>
|
||||
<Button type="primary">
|
||||
|
|
|
@ -123,18 +123,6 @@ const MessageDetail = (props) => {
|
|||
recentlyUsedRangesKey={"rule-detail"}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => {
|
||||
handleTimeChange({
|
||||
start: timeRange.min,
|
||||
end: timeRange.max,
|
||||
});
|
||||
}}
|
||||
icon={"reload"}
|
||||
type="primary"
|
||||
>
|
||||
{formatMessage({ id: "form.button.refresh" })}
|
||||
</Button>
|
||||
</div>
|
||||
<div style={{marginTop: 15,display:"flex", gap: 15, marginBottom:10}}>
|
||||
<div style={{flex: "1 1 50%"}}>
|
||||
|
|
|
@ -333,7 +333,7 @@ const RuleDetail = (props) => {
|
|||
onRangeChange={handleTimeChange}
|
||||
{...refresh}
|
||||
onRefreshChange={setRefresh}
|
||||
onRefresh={({ start, end}) => handleTimeChange({ start, end, refresh: new Date().valueOf()})}
|
||||
onRefresh={(value) => handleTimeChange({ ...(value || {}), refresh: new Date().valueOf()})}
|
||||
timeZone={timeZone}
|
||||
onTimeZoneChange={setTimeZone}
|
||||
recentlyUsedRangesKey={"rule-detail"}
|
||||
|
|
|
@ -178,6 +178,9 @@ const Discover = (props) => {
|
|||
field: "",
|
||||
enabled: false,
|
||||
};
|
||||
|
||||
const [histogramVisible, setHistogramVisible] = useState(false)
|
||||
|
||||
const [distinctParams, setDistinctParams] = React.useState(
|
||||
distinctParamsDefault
|
||||
);
|
||||
|
@ -1122,7 +1125,7 @@ const Discover = (props) => {
|
|||
getVisualizations={() => visRef?.current?.getVisualizations()}
|
||||
searchInfo={{
|
||||
took,
|
||||
hits,
|
||||
total: hits,
|
||||
...timeChartProps,
|
||||
}}
|
||||
selectedQueriesId={selectedQueriesId}
|
||||
|
@ -1160,6 +1163,12 @@ const Discover = (props) => {
|
|||
}
|
||||
}}
|
||||
showLayoutListIcon={false}
|
||||
histogramProps={{
|
||||
histogramData,
|
||||
onHistogramToggle: () => {
|
||||
setHistogramVisible(!histogramVisible)
|
||||
},
|
||||
}}
|
||||
// viewLayout={viewLayout}
|
||||
// onViewLayoutChange={(layout) => {
|
||||
// if (layout) {
|
||||
|
@ -1306,6 +1315,31 @@ const Discover = (props) => {
|
|||
responsive={false}
|
||||
style={{ position: "relative" }}
|
||||
>
|
||||
{histogramVisible && opts.timefield && (
|
||||
<EuiFlexItem>
|
||||
<section
|
||||
aria-label={"Histogram of found documents"}
|
||||
className="dscTimechart"
|
||||
>
|
||||
{opts.chartAggConfigs &&
|
||||
histogramData &&
|
||||
records.length !== 0 && (
|
||||
<div
|
||||
className="dscHistogramGrid"
|
||||
data-test-subj="discoverChart"
|
||||
>
|
||||
<DiscoverHistogram
|
||||
chartData={histogramData}
|
||||
timefilterUpdateHandler={
|
||||
timefilterUpdateHandler
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
<EuiSpacer size="s" />
|
||||
</EuiFlexItem>
|
||||
)}
|
||||
<EuiFlexItem className="eui-yScroll">
|
||||
<section
|
||||
className="dscTable eui-yScroll"
|
||||
|
|
|
@ -744,6 +744,7 @@ class Index extends PureComponent {
|
|||
onChangeDeleteIndexConfirmState={this.onChangeDeleteIndexConfirmState}
|
||||
deleteIndexConfirm={this.state.deleteIndexConfirm}
|
||||
items={this.state.deleteIndexItems}
|
||||
selectedCluster={this.props.selectedCluster}
|
||||
/>
|
||||
</PageHeaderWrapper>
|
||||
);
|
||||
|
|
|
@ -0,0 +1,15 @@
|
|||
import { DiscoverHistogram } from "@/components/vendor/discover/public/application/components/histogram/histogram";
|
||||
import { Icon, Popover } from "antd"
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import styles from "./index.less";
|
||||
|
||||
export default (props) => {
|
||||
|
||||
const { onHistogramToggle } = props
|
||||
|
||||
return (
|
||||
<Icon type="bar-chart" title="show histogram" style={{color: '#006BB4', cursor: 'pointer'}} onClick={() => {
|
||||
onHistogramToggle()
|
||||
}}/>
|
||||
)
|
||||
}
|
|
@ -0,0 +1,9 @@
|
|||
.histogram {
|
||||
z-index: 1;
|
||||
:global {
|
||||
.ant-popover-inner-content {
|
||||
width: 400px;
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -5,9 +5,9 @@ import InsightConfig, { ISearchConfig } from "../InsightConfig";
|
|||
import styles from './index.less';
|
||||
import { create, list, remove, update } from "../services/queries";
|
||||
import FullScreen from "../FullScreen";
|
||||
import ModeHandler from "../ModeHandler";
|
||||
import { Icon, message } from "antd";
|
||||
import SearchInfo from "../SearchInfo";
|
||||
import Histogram from "../Histogram";
|
||||
import ViewLayout from "../ViewLayout";
|
||||
|
||||
export interface IQueries {
|
||||
|
@ -72,7 +72,8 @@ export default forwardRef((props: IProps, ref: any) => {
|
|||
onSearchConfigChange,
|
||||
showLayoutListIcon,
|
||||
viewLayout,
|
||||
onViewLayoutChange
|
||||
onViewLayoutChange,
|
||||
histogramProps = {}
|
||||
} = props;
|
||||
|
||||
const {
|
||||
|
@ -183,6 +184,7 @@ export default forwardRef((props: IProps, ref: any) => {
|
|||
return (
|
||||
<div className={styles.bar}>
|
||||
<SearchInfo {...searchInfo} loading={searchLoading}/>
|
||||
{ histogramProps?.histogramData && <Histogram {...histogramProps}/>}
|
||||
<SaveQueries
|
||||
tags={tags}
|
||||
onTagsChange={setTags}
|
||||
|
|
|
@ -30,7 +30,7 @@ export interface IProps {
|
|||
* selected interval
|
||||
*/
|
||||
stateInterval: string;
|
||||
hits: number;
|
||||
total: number;
|
||||
took?: number;
|
||||
}
|
||||
|
||||
|
@ -39,7 +39,7 @@ export default ({
|
|||
dateFormat,
|
||||
timeRange,
|
||||
stateInterval,
|
||||
hits,
|
||||
total,
|
||||
took,
|
||||
}: IProps) => {
|
||||
const [interval, setInterval] = useState(stateInterval);
|
||||
|
@ -69,7 +69,7 @@ export default ({
|
|||
>
|
||||
<EuiFlexItem grow={false}>
|
||||
<div style={{ fontSize: 12}}>
|
||||
Found <span style={{fontWeight: "bold" }}>{hits}</span>{" "}
|
||||
Found <span style={{fontWeight: "bold" }}>{total}</span>{" "}
|
||||
records {took && (
|
||||
<span style={{marginLeft: 5 }}>
|
||||
({took} milliscond)
|
||||
|
|
|
@ -1,17 +1,40 @@
|
|||
import { Icon, Popover } from "antd"
|
||||
import { useState } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import Info, { IProps } from "./Info";
|
||||
import styles from './index.scss';
|
||||
|
||||
export default (props: IProps & { loading: boolean }) => {
|
||||
|
||||
const [showResultCount, setShowResultCount] = useState(true);
|
||||
const { loading, total } = props
|
||||
|
||||
if (typeof props.hits !== 'number' || props.hits <= 0) return null;
|
||||
const [showResultCount, setShowResultCount] = useState(true);
|
||||
const timerRef = useRef(null)
|
||||
const autoHiddenRef = useRef(true)
|
||||
|
||||
useEffect(() => {
|
||||
if (timerRef.current) {
|
||||
clearTimeout(timerRef.current)
|
||||
}
|
||||
if (showResultCount) {
|
||||
timerRef.current = setTimeout(() => {
|
||||
if (autoHiddenRef.current) {
|
||||
setShowResultCount(false)
|
||||
}
|
||||
}, 3000);
|
||||
}
|
||||
}, [showResultCount])
|
||||
|
||||
useEffect(() => {
|
||||
if (loading) {
|
||||
autoHiddenRef.current = true
|
||||
}
|
||||
}, [loading])
|
||||
|
||||
if (typeof total !== 'number' || total <= 0) return null;
|
||||
|
||||
return (
|
||||
<Popover
|
||||
visible={!props.loading && showResultCount}
|
||||
visible={!loading && showResultCount}
|
||||
placement="left"
|
||||
title={null}
|
||||
overlayClassName={styles.searchInfo}
|
||||
|
@ -21,7 +44,14 @@ export default (props: IProps & { loading: boolean }) => {
|
|||
dateFormat={"YYYY-MM-DD H:mm"}
|
||||
/>
|
||||
)}>
|
||||
<Icon type="info-circle" style={{color: '#006BB4', cursor: 'pointer'}} onClick={() => setShowResultCount(!showResultCount)}/>
|
||||
<Icon type="info-circle" style={{color: '#006BB4', cursor: 'pointer'}} onClick={() => {
|
||||
if (showResultCount) {
|
||||
autoHiddenRef.current = true
|
||||
} else {
|
||||
autoHiddenRef.current = false
|
||||
}
|
||||
setShowResultCount(!showResultCount)
|
||||
}}/>
|
||||
</Popover>
|
||||
)
|
||||
}
|
|
@ -41,7 +41,7 @@ export default (props) => {
|
|||
to = bounds.max;
|
||||
}
|
||||
if (!from || !to) return data
|
||||
const newData = cloneDeep(data)
|
||||
const newData = cloneDeep(data.sort((a, b) => a.timestamp - b.timestamp))
|
||||
const fromTimestamp = moment(from).valueOf();
|
||||
const toTimestamp = moment(to).valueOf();
|
||||
let start = newData[0].timestamp;
|
||||
|
|
|
@ -14,7 +14,7 @@ export default (props) => {
|
|||
|
||||
const { record, result, options, isGroup, isLock, onReady, bucketSize, isTimeSeries, highlightRange, currentQueries = {}, handleContextMenu, onChartElementClick } = props;
|
||||
|
||||
const { id, is_stack, is_percent, drilling = {}, series, legend } = record;
|
||||
const { id, is_stack, is_percent, drilling = {}, series, legend, colors } = record;
|
||||
|
||||
const { metric = {} } = series[0]
|
||||
|
||||
|
@ -83,6 +83,13 @@ export default (props) => {
|
|||
config.yAxis.label.formatter = (value) => `${value * 100}%`
|
||||
}
|
||||
}
|
||||
|
||||
if (colors) {
|
||||
config.color = Array.isArray(colors) ? colors : (value) => {
|
||||
const { name } = value;
|
||||
return colors[name];
|
||||
}
|
||||
}
|
||||
|
||||
const dataDrillingMenuItem = {
|
||||
type: TYPE_DATA_DRILLING,
|
||||
|
|
|
@ -84,6 +84,7 @@ export const WidgetRender = (props) => {
|
|||
onResultChange={(res) => {
|
||||
setRequests(Array.isArray(res) ? res.filter((item) => !!item.request).map((item) => item.request) : [])
|
||||
}}
|
||||
refresh={refresh}
|
||||
/>
|
||||
</div>
|
||||
) : <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} />
|
||||
|
|
|
@ -20,7 +20,7 @@
|
|||
position: absolute;
|
||||
display: none;
|
||||
right: 0px;
|
||||
top: 0px;
|
||||
bottom: 0px;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 4px;
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { Modal, Checkbox, Tag, Badge, Alert, Icon } from "antd";
|
||||
import { Modal, Checkbox, Tag, Badge, Alert, Icon, Tooltip } from "antd";
|
||||
import { useCallback, useState, forwardRef, useMemo } from "react";
|
||||
import useFetch from "@/lib/hooks/use_fetch";
|
||||
import request from "@/utils/request";
|
||||
|
@ -28,7 +28,7 @@ export default (props) => {
|
|||
onOk={props.onOk}
|
||||
okButtonProps={{ disabled: !props.deleteIndexConfirm }}
|
||||
>
|
||||
<p>You are about to delete these indices:</p>
|
||||
<p>You are about to delete these indices in cluster <Tooltip title={props.selectedCluster.id}><b>{props.selectedCluster.name}</b></Tooltip>:</p>
|
||||
<ul style={{ maxHeight: 240, overflow: "scroll" }}>
|
||||
{props.items.map((item) => {
|
||||
return (
|
||||
|
|
|
@ -13,14 +13,19 @@ export default ({ formData }) => {
|
|||
username,
|
||||
password,
|
||||
isInit,
|
||||
hosts = [],
|
||||
bootstrap_username,
|
||||
bootstrap_password,
|
||||
credential_secret,
|
||||
} = formData;
|
||||
|
||||
const onDownload = () => {
|
||||
let hostV = host
|
||||
if (!hostV && hosts.length > 0) {
|
||||
hostV = hosts[0]
|
||||
}
|
||||
const data = {
|
||||
"Cluster": isTLS ? `https://${host}` : `http://${host}`,
|
||||
"Cluster": isTLS ? `https://${hostV}` : `http://${hostV}`,
|
||||
"Cluster Username": username,
|
||||
"Cluster Password": password,
|
||||
"Cluster Username": username,
|
||||
|
|
|
@ -24,6 +24,10 @@ export default (props) => {
|
|||
if (indexName && indexName.includes("%")) {
|
||||
indexNameEncode = encodeURIComponent(indexName);
|
||||
}
|
||||
const logStartTime = moment(timestamp).add(-3, "m");
|
||||
const logEndTime = moment(timestamp).add(3, "m");
|
||||
const logTimeRangeStr = encodeURIComponent(JSON.stringify({min:logStartTime.toISOString(),max:logEndTime.toISOString()}))
|
||||
|
||||
|
||||
switch (name) {
|
||||
case "index_state_change":
|
||||
|
@ -90,10 +94,20 @@ export default (props) => {
|
|||
</Link>{" "}
|
||||
<b>{opers[type]}</b> from <b>{hit._source.metadata.labels.from}</b> to{" "}
|
||||
<b>{hit._source.metadata.labels.to}</b>
|
||||
<a
|
||||
size="small"
|
||||
style={{ marginLeft: 12, cursor: 'pointer' }}
|
||||
>
|
||||
<Icon type="file-text" style={{marginRight:2}}/>
|
||||
<Link
|
||||
to={`/cluster/monitor/elasticsearch/${hit._source.metadata.labels.cluster_id}?_g={"tab":"logs","timeRange":${logTimeRangeStr}}`}
|
||||
>{formatMessage({ id: "form.button.view_logs" })}
|
||||
</Link>
|
||||
</a>
|
||||
</>
|
||||
);
|
||||
case "cluster_health_change":
|
||||
return <ClusterHealthChange hit={hit} type={opers[type]} timeRangeStr={timeRangeStr}/>
|
||||
return <ClusterHealthChange hit={hit} type={opers[type]} timeRangeStr={timeRangeStr} logTimeRangeStr={logTimeRangeStr}/>
|
||||
case "node_health_change":
|
||||
return (
|
||||
<>
|
||||
|
@ -110,6 +124,15 @@ export default (props) => {
|
|||
{hit._source.metadata.labels.cluster_name}
|
||||
</Link>{" "}
|
||||
<b>{opers[type]}</b> to <b>{hit._source.metadata.labels.to}</b>
|
||||
<a
|
||||
size="small"
|
||||
style={{ marginLeft: 12, cursor: 'pointer' }}
|
||||
>
|
||||
<Icon type="file-text" style={{marginRight:2}}/>
|
||||
<Link
|
||||
to={`/cluster/monitor/${hit._source.metadata.labels.cluster_id}/nodes/${hit._source.metadata.labels.node_id}?_g={"tab":"logs","timeRange":${logTimeRangeStr}}`}
|
||||
>{formatMessage({ id: "form.button.view_logs" })}</Link>
|
||||
</a>
|
||||
</>
|
||||
);
|
||||
case "node_state_change":
|
||||
|
@ -211,17 +234,30 @@ export default (props) => {
|
|||
</>
|
||||
);
|
||||
}
|
||||
case "metric_collection_mode_change":
|
||||
if (type == "update") {
|
||||
return (
|
||||
<>
|
||||
metric collection mode of cluster{" "}
|
||||
<Link
|
||||
to={`/resource/cluster/${hit._source.metadata.labels.cluster_id}/edit`}
|
||||
>
|
||||
{hit._source.metadata.labels.cluster_name}
|
||||
</Link>{" "}
|
||||
was <b>changed from {hit._source.metadata.labels.from} to {hit._source.metadata.labels.to}</b>
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
return <></>;
|
||||
};
|
||||
|
||||
const ClusterHealthChange = (props) => {
|
||||
const { hit, type, timeRangeStr } = props;
|
||||
const { hit, type, timeRangeStr, logTimeRangeStr } = props;
|
||||
const status = hit._source.metadata.labels.to
|
||||
const hasAllocationExplain = status === 'red'
|
||||
|
||||
const [active, setActive] = useState(false)
|
||||
|
||||
const content = (
|
||||
<span
|
||||
style={{ cursor: hasAllocationExplain ? 'pointer' : 'default'}}
|
||||
|
@ -251,6 +287,15 @@ const ClusterHealthChange = (props) => {
|
|||
<Icon type={active ? "up-square" : "down-square"}/> {formatMessage({ id: "form.button.detail" })}
|
||||
</a>
|
||||
) : null}
|
||||
<a
|
||||
size="small"
|
||||
style={{ marginLeft: 12, cursor: 'pointer' }}
|
||||
>
|
||||
<Icon type="file-text" style={{marginRight:2}}/>
|
||||
<Link
|
||||
to={`/cluster/monitor/elasticsearch/${hit._source.metadata.labels.cluster_id}?_g={"tab":"logs","timeRange":${logTimeRangeStr}}`}
|
||||
>{formatMessage({ id: "form.button.view_logs" })}</Link>
|
||||
</a>
|
||||
</span>
|
||||
)
|
||||
|
||||
|
|
|
@ -0,0 +1,45 @@
|
|||
import { Empty, Input, Spin, Table } from "antd";
|
||||
import { formatMessage } from "umi/locale";
|
||||
import { Link } from "umi";
|
||||
import Logs from "../../components/Logs";
|
||||
|
||||
const AGGS = {
|
||||
"Node":{"terms":{"field":"metadata.labels.node_name", "size": 1000 }},
|
||||
"Type":{"terms":{"field":"metadata.name", "size": 1000 }},
|
||||
"Level":{"terms":{"field":"payload.level", "size": 1000 }},
|
||||
}
|
||||
|
||||
export default (props) => {
|
||||
const { clusterID, param = {} } = props;
|
||||
|
||||
return (
|
||||
<Logs
|
||||
{...props}
|
||||
aggs={AGGS}
|
||||
queryFilters={[
|
||||
{
|
||||
"term": {
|
||||
"metadata.labels.cluster_id": clusterID
|
||||
}
|
||||
}
|
||||
]}
|
||||
extraColumns={[
|
||||
{
|
||||
title: formatMessage({ id: "cluster.monitor.logs.node" }),
|
||||
key: "metadata.labels.node_name",
|
||||
dataIndex: "metadata.labels.node_name",
|
||||
width: 88,
|
||||
ellipsis: true,
|
||||
render: (value, record) => {
|
||||
if (!record.metadata?.labels?.node_uuid) return value
|
||||
return (
|
||||
<Link to={`/cluster/monitor/${clusterID}/nodes/${record.metadata.labels.node_uuid}?_g=${encodeURIComponent(JSON.stringify(param))}`}>
|
||||
{value}
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
},
|
||||
]}
|
||||
/>
|
||||
);
|
||||
}
|
|
@ -9,11 +9,13 @@ import Monitor from "@/components/Overview/Monitor";
|
|||
import StatisticBar from "./statistic_bar";
|
||||
import { Empty } from "antd";
|
||||
import TopN from "./TopN";
|
||||
import Logs from "./Logs";
|
||||
|
||||
const panes = [
|
||||
{ title: "Overview", component: Overview, key: "overview" },
|
||||
{ title: "Advanced", component: Advanced, key: "advanced" },
|
||||
{ title: "TopN", component: TopN, key: "topn" },
|
||||
{ title: "Logs", component: Logs, key: "logs" },
|
||||
{ title: "Nodes", component: Nodes, key: "nodes" },
|
||||
{ title: "Indices", component: Indices, key: "indices" },
|
||||
];
|
||||
|
|
|
@ -0,0 +1,26 @@
|
|||
import { Empty, Input, Spin, Table } from "antd";
|
||||
import { formatMessage } from "umi/locale";
|
||||
import Logs from "../../components/Logs";
|
||||
|
||||
const AGGS = {
|
||||
"Type":{"terms":{"field":"metadata.name", "size": 1000 }},
|
||||
"Level":{"terms":{"field":"payload.level", "size": 1000 }},
|
||||
}
|
||||
|
||||
export default (props) => {
|
||||
const { nodeID } = props;
|
||||
|
||||
return (
|
||||
<Logs
|
||||
{...props}
|
||||
aggs={AGGS}
|
||||
queryFilters={[
|
||||
{
|
||||
"term": {
|
||||
"metadata.labels.node_uuid": nodeID
|
||||
}
|
||||
}
|
||||
]}
|
||||
/>
|
||||
);
|
||||
}
|
|
@ -6,10 +6,12 @@ import { formatMessage } from "umi/locale";
|
|||
import Monitor from "@/components/Overview/Monitor";
|
||||
import StatisticBar from "./statistic_bar";
|
||||
import { connect } from "dva";
|
||||
import Logs from "./Logs";
|
||||
|
||||
const panes = [
|
||||
{ title: "Overview", component: Overview, key: "overview" },
|
||||
{ title: "Advanced", component: Advanced, key: "advanced" },
|
||||
{ title: "Logs", component: Logs, key: "logs" },
|
||||
{ title: "Shards", component: Shards, key: "shards" },
|
||||
];
|
||||
const Page = (props) => {
|
||||
|
|
|
@ -0,0 +1,329 @@
|
|||
import { Empty, Input, Spin, Table } from "antd";
|
||||
import styles from "./index.less"
|
||||
import DatePicker from "@/common/src/DatePicker";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { formatESSearchResult, formatTimeRange } from "@/lib/elasticsearch/util";
|
||||
import { formatMessage } from "umi/locale";
|
||||
import request from "@/utils/request";
|
||||
import moment from "moment";
|
||||
import { getTimezone } from "@/utils/utils";
|
||||
import ListView from "@/components/ListView";
|
||||
import { ESPrefix } from "@/services/common";
|
||||
import Side from "../Side";
|
||||
import { WidgetRender } from "@/pages/DataManagement/View/WidgetLoader";
|
||||
import { cloneDeep } from "lodash";
|
||||
import { Link } from "umi";
|
||||
import InstallAgent from "@/components/InstallAgent";
|
||||
|
||||
const COLORS = {
|
||||
'INFO': '#e8eef2',
|
||||
'WARN': '#e99d43',
|
||||
'ERROR': '#ff3f3f'
|
||||
}
|
||||
|
||||
export default (props) => {
|
||||
const { timeRange, isAgent, refresh, aggs, queryFilters = [], extraColumns = [] } = props;
|
||||
|
||||
const ref = useRef(null);
|
||||
|
||||
const timeField = "timestamp";
|
||||
const indexName = ".infini_logs";
|
||||
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [result, setResult] = useState(false)
|
||||
|
||||
const [queryParams, setQueryParams] = useState({
|
||||
size: 20,
|
||||
from: 0,
|
||||
sort: {
|
||||
[timeField]: 'desc'
|
||||
},
|
||||
keyword: ''
|
||||
})
|
||||
|
||||
const fetchData = async (queryParams, timeRange, aggs, queryFilters) => {
|
||||
if (!timeRange) return;
|
||||
setLoading(true)
|
||||
const newTimeRange = formatTimeRange(timeRange);
|
||||
const filters = [...queryFilters]
|
||||
if (queryParams?.filters) {
|
||||
Object.keys(queryParams.filters).map((field) => {
|
||||
const values = queryParams.filters[field];
|
||||
if (Array.isArray(values)) {
|
||||
values.forEach((item) => {
|
||||
filters.push({
|
||||
'term': { [field]: item }
|
||||
})
|
||||
});
|
||||
} else {
|
||||
if (values) {
|
||||
filters.push({
|
||||
'term': { [field]: values }
|
||||
})
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
if (queryParams?.keyword) {
|
||||
filters.push({
|
||||
query_string: {
|
||||
query: `*${queryParams.keyword}*`,
|
||||
fields: ['payload.message'],
|
||||
}
|
||||
});
|
||||
}
|
||||
const res = await request(`${ESPrefix}/infini_default_system_cluster/search/ese?timeout=60m`, {
|
||||
method: 'POST',
|
||||
body: {
|
||||
index: indexName,
|
||||
body: {
|
||||
aggs,
|
||||
query: {
|
||||
"bool": {
|
||||
"filter": [
|
||||
{
|
||||
"range": {
|
||||
"timestamp": {
|
||||
"gte": newTimeRange.min,
|
||||
"lte": newTimeRange.max
|
||||
}
|
||||
}
|
||||
},
|
||||
...filters
|
||||
],
|
||||
"should": [],
|
||||
"must_not": []
|
||||
}
|
||||
},
|
||||
size: queryParams.size,
|
||||
from: queryParams.from,
|
||||
sort: queryParams.sort ? Object.keys(queryParams.sort).filter((key) => !!queryParams.sort[key]).map((key) => ({
|
||||
[key]: {
|
||||
"order": queryParams.sort[key]
|
||||
}
|
||||
})) : []
|
||||
}
|
||||
}
|
||||
})
|
||||
if (res && !res.error) {
|
||||
const rs = formatESSearchResult(res);
|
||||
setResult(rs)
|
||||
}
|
||||
setLoading(false)
|
||||
}
|
||||
|
||||
const onTableChange = (pagination, filters, sorter, extra) => {
|
||||
const sortOrder = sorter.order ? sorter.order.replace("end", "") : null;
|
||||
if (queryParams?.sort[sorter.field] !== sortOrder) {
|
||||
setQueryParams((st) => ({ ...st, sort: {
|
||||
...(queryParams.sort || {}),
|
||||
[sorter.field]: sortOrder
|
||||
}}));
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchData(queryParams, timeRange, aggs, queryFilters)
|
||||
}, [JSON.stringify(queryParams), timeRange, JSON.stringify(aggs), JSON.stringify(queryFilters)])
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: formatMessage({ id: "cluster.monitor.logs.timestamp" }),
|
||||
key: timeField,
|
||||
dataIndex: timeField,
|
||||
width: 170,
|
||||
render: (value) =>
|
||||
moment(value)
|
||||
.tz(getTimezone())
|
||||
.format("YYYY-MM-DD HH:mm:ss"),
|
||||
sorter: true,
|
||||
sortOrder: queryParams.sort?.[timeField] ? `${queryParams.sort?.[timeField]}end` : undefined
|
||||
},
|
||||
{
|
||||
title: formatMessage({ id: "cluster.monitor.logs.type" }),
|
||||
key: "metadata.name",
|
||||
dataIndex: "metadata.name",
|
||||
width: 100,
|
||||
},
|
||||
{
|
||||
title: formatMessage({ id: "cluster.monitor.logs.level" }),
|
||||
key: "payload.level",
|
||||
dataIndex: "payload.level",
|
||||
width: 88,
|
||||
render: (value, record) => {
|
||||
if (!value) return '-'
|
||||
const colors = {
|
||||
INFO: {
|
||||
background: COLORS.INFO,
|
||||
color: "#959ea0",
|
||||
},
|
||||
WARN: {
|
||||
background: COLORS.WARN,
|
||||
color: "#fff",
|
||||
},
|
||||
ERROR: {
|
||||
background: COLORS.ERROR,
|
||||
color: "#fff",
|
||||
},
|
||||
};
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
width: 54,
|
||||
height: 18,
|
||||
fontWeight: 600,
|
||||
lineHeight: "18px",
|
||||
textAlign: "center",
|
||||
...(colors[value] || colors[value]),
|
||||
}}
|
||||
>
|
||||
{value}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
...extraColumns,
|
||||
{
|
||||
title: formatMessage({ id: "cluster.monitor.logs.message" }),
|
||||
key: "payload.message",
|
||||
dataIndex: "payload.message",
|
||||
},
|
||||
];
|
||||
|
||||
const histogram = {
|
||||
bucket_size: "auto",
|
||||
is_stack: true,
|
||||
format: {
|
||||
type: "number",
|
||||
pattern: "0.00a",
|
||||
},
|
||||
legend: false,
|
||||
colors: COLORS,
|
||||
series: [
|
||||
{
|
||||
metric: {
|
||||
formula: "a",
|
||||
groups: [
|
||||
{
|
||||
field: "payload.level",
|
||||
limit: 10,
|
||||
},
|
||||
],
|
||||
items: [
|
||||
{
|
||||
field: "*",
|
||||
name: "a",
|
||||
statistic: "count",
|
||||
},
|
||||
],
|
||||
sort: [
|
||||
{
|
||||
direction: "desc",
|
||||
key: "_count",
|
||||
},
|
||||
],
|
||||
},
|
||||
queries: {
|
||||
cluster_id: "infini_default_system_cluster",
|
||||
indices: [indexName],
|
||||
time_field: timeField,
|
||||
},
|
||||
type: "date-histogram",
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
const isNotEmpty = useMemo(() => {
|
||||
return result?.data?.length > 0
|
||||
}, [result?.data?.length])
|
||||
|
||||
return (
|
||||
<Spin spinning={loading}>
|
||||
<div className={styles.logs} style={isNotEmpty ? {} : { justifyContent: 'center', alignItems: 'center'}}>
|
||||
{
|
||||
isNotEmpty ? (
|
||||
<>
|
||||
<div className={styles.side}>
|
||||
<Side
|
||||
aggs={aggs}
|
||||
data={result?.aggregations || {}}
|
||||
filters={queryParams?.filters || {}}
|
||||
onFacetChange={(v) => {
|
||||
const newFilters = cloneDeep(queryParams.filters) || {}
|
||||
if (!v.value || v.value.length === 0) {
|
||||
delete newFilters[v.field];
|
||||
} else {
|
||||
newFilters[v.field] = v.value;
|
||||
}
|
||||
setQueryParams((st) => ({ ...st, from: 0, filters: newFilters }));
|
||||
}}
|
||||
onReset={() => {
|
||||
setQueryParams((st) => ({ ...st, from: 0, filters: {} }));
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.result}>
|
||||
<div className={styles.header}>
|
||||
<Input.Search
|
||||
style={{ maxWidth: 600 }}
|
||||
placeholder={formatMessage({ id: "cluster.monitor.logs.search.placeholder" })}
|
||||
onSearch={value => {
|
||||
setQueryParams((st) => ({ ...st, from: 0, keyword: value }));
|
||||
}}
|
||||
enterButton
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.histogram}>
|
||||
<WidgetRender
|
||||
widget={histogram}
|
||||
range={{
|
||||
from: timeRange.min,
|
||||
to: timeRange.max,
|
||||
timeField: timeField,
|
||||
}}
|
||||
queryParams={queryParams?.filters || {}}
|
||||
refresh={refresh}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.table}>
|
||||
<Table
|
||||
size={"small"}
|
||||
rowKey={"id"}
|
||||
columns={columns}
|
||||
dataSource={result?.data || []}
|
||||
onChange={onTableChange}
|
||||
pagination={{
|
||||
size: "small",
|
||||
pageSize: queryParams.size,
|
||||
current: Math.ceil(queryParams.from / queryParams.size) + 1,
|
||||
total: result?.total?.value || result?.total || 0,
|
||||
onChange: (page, pageSize) => {
|
||||
setQueryParams((st) => ({
|
||||
...st,
|
||||
from: (page - 1) * st.size,
|
||||
}));
|
||||
},
|
||||
showSizeChanger: true,
|
||||
onShowSizeChange: (_, size) => {
|
||||
setQueryParams((st) => ({ ...st, from: 0, size }));
|
||||
},
|
||||
showTotal: (total, range) =>
|
||||
`${range[0]}-${range[1]} of ${total} items`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<Empty
|
||||
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||||
description={formatMessage({ id: `cluster.monitor.logs.empty.${isAgent ? 'agent' : 'agentless'}` })}
|
||||
>
|
||||
<div style={{width: 644}}>{!isAgent && <InstallAgent autoInit={false}/>}</div>
|
||||
</Empty>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
</Spin>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,26 @@
|
|||
.logs {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
min-height: calc(100vh - 444px);
|
||||
|
||||
.side {
|
||||
width: 220px;
|
||||
}
|
||||
|
||||
.result {
|
||||
width: calc(100% - 220px - 12px);
|
||||
|
||||
.header {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.histogram {
|
||||
width: 100%;
|
||||
height: 140px;
|
||||
border: 1px solid rgb(232, 232, 232);
|
||||
border-radius: 2px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -324,6 +324,19 @@ export default (props) => {
|
|||
|
||||
return (
|
||||
<div key={metricKey} ref={containerRef} className={className} style={style}>
|
||||
{
|
||||
metric?.request && (
|
||||
<CopyToClipboard text={`GET .infini_metrics/_search\n${metric.request}`}>
|
||||
<Tooltip title={formatMessage({id: "cluster.metrics.request.copy"})}>
|
||||
<Icon
|
||||
className="copyReq"
|
||||
type="copy"
|
||||
onClick={() => message.success(formatMessage({id: "cluster.metrics.request.copy.success"}))}
|
||||
/>
|
||||
</Tooltip>
|
||||
</CopyToClipboard>
|
||||
)
|
||||
}
|
||||
<Spin spinning={loading}>
|
||||
<div className={styles.vizChartItemTitle}>
|
||||
<span>
|
||||
|
@ -339,20 +352,6 @@ export default (props) => {
|
|||
</Tooltip>
|
||||
)
|
||||
}
|
||||
{
|
||||
metric?.request && (
|
||||
<CopyToClipboard text={`GET .infini_metrics/_search\n${metric.request}`}>
|
||||
<Tooltip title={formatMessage({id: "cluster.metrics.request.copy"})}>
|
||||
<Icon
|
||||
className={styles.copy}
|
||||
style={{ marginRight: 12 }}
|
||||
type="copy"
|
||||
onClick={() => message.success(formatMessage({id: "cluster.metrics.request.copy.success"}))}
|
||||
/>
|
||||
</Tooltip>
|
||||
</CopyToClipboard>
|
||||
)
|
||||
}
|
||||
<Tooltip title={formatMessage({id: "form.button.refresh"})}>
|
||||
<Icon className={styles.copy} type="sync" onClick={() => fetchData(...observerRef.current.deps, true)}/>
|
||||
</Tooltip>
|
||||
|
|
|
@ -25,6 +25,28 @@
|
|||
}
|
||||
}
|
||||
|
||||
.vizChartContainer{
|
||||
position: relative;
|
||||
.copyReq {
|
||||
cursor: pointer;
|
||||
position: absolute;
|
||||
display: none;
|
||||
right: 3px;
|
||||
bottom: 0px;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
z-index: 11;
|
||||
&:hover {
|
||||
color: #1890ff;
|
||||
}
|
||||
}
|
||||
&:hover {
|
||||
.copyReq {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.vizChartItem {
|
||||
background: white !important;
|
||||
|
||||
|
|
|
@ -0,0 +1,86 @@
|
|||
import { Checkbox, Icon, Input } from "antd";
|
||||
import { useEffect, useState } from "react";
|
||||
import { formatMessage } from "umi/locale";
|
||||
import styles from "./SearchFacet.less";
|
||||
|
||||
export default ({ field, label, data = [], onChange, selectedKeys }) => {
|
||||
const [filterData, setFilterData] = useState([]);
|
||||
useEffect(() => {
|
||||
setFilterData(data);
|
||||
}, [data]);
|
||||
const onInputChange = (ev) => {
|
||||
let lowerStr = ev.target.value.toLowerCase();
|
||||
setFilterData(
|
||||
data.filter((item) => {
|
||||
let lowerKey = item.key.toLowerCase();
|
||||
return lowerKey.includes(lowerStr);
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
const onSelectChange = (item, ev) => {
|
||||
if (ev.target.checked) {
|
||||
if (selectedKeys.indexOf(item.key) > -1) {
|
||||
return;
|
||||
}
|
||||
const newKeys = [...selectedKeys, item.key];
|
||||
if (typeof onChange == "function") {
|
||||
onChange({
|
||||
field,
|
||||
value: newKeys,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
const newKeys = selectedKeys.filter((key) => key != item.key);
|
||||
if (typeof onChange == "function") {
|
||||
onChange({
|
||||
field,
|
||||
value: newKeys,
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
const [showMore, setShowMore] = useState(false);
|
||||
return (
|
||||
<div className={styles.searchFacet}>
|
||||
<div className={`${styles.line} ${styles.label}`}>{label}</div>
|
||||
<div className={styles.line}>
|
||||
<Input
|
||||
placeholder={`${formatMessage({
|
||||
id: "listview.side.filter",
|
||||
})} ${label}`}
|
||||
onChange={onInputChange}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.line}>
|
||||
{(showMore ? filterData : filterData.slice(0, 5)).map((item) => {
|
||||
return (
|
||||
<div key={item.key} className={styles.value} title={item.key}>
|
||||
<Checkbox
|
||||
onChange={(v) => {
|
||||
onSelectChange(item, v);
|
||||
}}
|
||||
checked={selectedKeys && selectedKeys.indexOf(item.key) > -1}
|
||||
>
|
||||
{item?.key_as_string ?? item.key}
|
||||
</Checkbox>
|
||||
<div className={styles.count}>{item.doc_count}</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{!showMore && filterData.length > 5 ? (
|
||||
<div>
|
||||
<a
|
||||
onClick={() => {
|
||||
setShowMore(true);
|
||||
}}
|
||||
>
|
||||
<Icon type="plus" />
|
||||
more
|
||||
</a>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,30 @@
|
|||
.searchFacet {
|
||||
.line {
|
||||
padding: 4px 0;
|
||||
&.label {
|
||||
text-transform: capitalize;
|
||||
font-size: 12px;
|
||||
color: #8b9bad;
|
||||
letter-spacing: 1px;
|
||||
padding: 0;
|
||||
}
|
||||
.value {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
:global {
|
||||
label.ant-checkbox-wrapper {
|
||||
width: 152px;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
}
|
||||
.count {
|
||||
margin-left: auto;
|
||||
color: #888;
|
||||
font-size: 0.85em;
|
||||
text-align: right;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,52 @@
|
|||
import { Icon } from "antd";
|
||||
import { useMemo, useEffect, useState, useCallback } from "react";
|
||||
import { formatMessage } from "umi/locale";
|
||||
import SearchFacet from "./SearchFacet";
|
||||
import styles from "./index.less";
|
||||
|
||||
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 || [],
|
||||
};
|
||||
});
|
||||
return fts;
|
||||
}, [data]);
|
||||
|
||||
return (
|
||||
<div className={styles.searchFilter}>
|
||||
<div className={styles.title}>
|
||||
<span>{formatMessage({ id: "listview.side.filter" })}</span>
|
||||
<span
|
||||
className={styles.reload}
|
||||
onClick={onReset}
|
||||
title={formatMessage({ id: "listview.side.filter.reset" })}
|
||||
>
|
||||
<Icon type="reload" style={{ color: "rgba(0, 127, 255, 1)" }} />
|
||||
</span>
|
||||
</div>
|
||||
<div className={styles.facetCnt}>
|
||||
{facets.map((ft) => {
|
||||
if (ft.buckets.length == 0) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<SearchFacet
|
||||
key={ft.field}
|
||||
label={ft.label}
|
||||
field={ft.field}
|
||||
data={ft.buckets}
|
||||
onChange={onFacetChange}
|
||||
selectedKeys={filters[ft.field] || []}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,18 @@
|
|||
.searchFilter {
|
||||
height: 100%;
|
||||
.title {
|
||||
font-weight: bold;
|
||||
margin-bottom: 15px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
.reload {
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
.facetCnt {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1em;
|
||||
}
|
||||
}
|
|
@ -69,6 +69,7 @@ export default (props) => {
|
|||
}),
|
||||
key: "timestamp",
|
||||
sortable: true,
|
||||
defaultSortOrder: 'descend',
|
||||
render: (text, record) => {
|
||||
return formatUtcTimeToLocal(record.timestamp);
|
||||
},
|
||||
|
|
Loading…
Reference in New Issue