Compare commits

...

24 Commits

Author SHA1 Message Date
hardy 5cc4ea9ef1
docs: update release notes 2025-02-25 20:27:56 +08:00
hardy a7ff1c6220
fix: typo config 2025-02-25 20:25:06 +08:00
hardy 9a9896c2f2
fix: gateway config for agent mTLS 2025-02-25 16:22:24 +08:00
Hardy 785f2ede57
fix: mapping type conflict error (#164)
* fix: mapping type conflict error

* docs: update release notes

---------

Co-authored-by: hardy <luohf@infinilabs.com>
2025-02-24 18:02:12 +08:00
silenceqi 9d5734b61e
feat: support clearing offline agent instances (#165)
* feat: support clearing offline agent instances

* chore: update release notes
2025-02-24 18:01:30 +08:00
silenceqi a0d28fada9
improvement: retain a single instance when registering duplicate endp… (#163)
* improvement: retain a single instance when registering duplicate endpoints

* chore: update release notes
2025-02-24 11:20:45 +08:00
silenceqi d851be6a38
chore: enhance deletion tips by adding cluster info for indices (#162)
* chore: enhance deletion tips by adding cluster info for indices

* chore: update release notes
2025-02-21 17:24:15 +08:00
silenceqi aa67bf7c80
fix: preserve audit log default sorting across pagination (#161)
* fix: preserve audit log default sorting across pagination

* chore: update release notes
2025-02-21 16:40:07 +08:00
silenceqi 6c9e8d28c7
chore: remove unused code and adjust UI (#159) 2025-02-21 15:18:06 +08:00
Hardy ca52a5b00d
fix: sql query with elasticsearch version 6.x (#158)
* fix: sql query with version 6.x

* docs: update release notes

* docs: typo release notes

---------

Co-authored-by: hardy <luohf@infinilabs.com>
2025-02-21 15:06:10 +08:00
yaojp123 1e2f8c2520
fix: wrong display of heatmap's data in alerting message (#157)
* fix: wrong display of heatmap's data in alerting message

* chore: update release notes

---------

Co-authored-by: yaojiping <yaojiping@infini.ltd>
2025-02-21 11:20:37 +08:00
Hardy df33fa006b
chore: Enhance LDAP authentication logging (#156)
* chore: improve logs for ldap auth

* docs: update release notes

---------

Co-authored-by: hardy <luohf@infinilabs.com>
Co-authored-by: silenceqi <silenceqi@hotmail.com>
2025-02-20 20:08:48 +08:00
silenceqi 183ebf037c
chore: optimize UI for copying metric requests (#155)
* chore: optimize UI for copying metric requests

* chore: update release notes
2025-02-20 20:07:24 +08:00
silenceqi 6ff2d72ff1
fix: correct node health change activity logging (#154)
* fix: correct node health change activity logging

* chore: update release notes
2025-02-20 16:42:17 +08:00
yaojp123 1ecdbb3e59
chore: adjust the description when no data in monitor logs (#153)
* chore: adjust the description when no data in monitor logs

* chore: adjust discover's histogram

---------

Co-authored-by: yaojiping <yaojiping@infini.ltd>
2025-02-20 16:03:30 +08:00
silenceqi 78cdd44e9c
feat: log activity for cluster metric collection mode changes (#152)
* feat: log activity for cluster metric collection mode changes

* chore: update release notes
2025-02-20 16:01:46 +08:00
yaojp123 932a2a46e1
chore: adjust discover (#151)
* fix: add time range to suggestions's request

* fix: adjust DatePicker's display

* chore: hide search info automatically in discover

* chore: adjust discover's histogram

* chore: update release notes

---------

Co-authored-by: yaojiping <yaojiping@infini.ltd>
Co-authored-by: Hardy <luohoufu@infinilabs.com>
2025-02-19 22:09:34 +08:00
silenceqi 1cd1f98af4
feat: support viewing logs for cluster, node health change events (#150)
* feat: support viewing logs for cluster, node, and index health change events

* chore: update release notes
2025-02-19 21:32:10 +08:00
yaojp123 8452d8ef3e
feat: add logs to monitor (#149)
* feat: add logs to cluster's monitor

* feat: add logs to node's monitor

* chore: update release notes

---------

Co-authored-by: yaojiping <yaojiping@infini.ltd>
2025-02-19 14:48:49 +08:00
Hardy 1e601f259b
chore: update agent config with cluster name (#148)
* chore: update agent config with cluster name

* docs: update release-notes

---------

Co-authored-by: hardy <luohf@infinilabs.com>
2025-02-18 20:29:41 +08:00
Hardy 06f7d3bc77
chore: update default console.yml with default_roles (#146)
Co-authored-by: hardy <luohf@infinilabs.com>
2025-02-18 20:26:42 +08:00
silenceqi a092dd7cb1
fix: handle empty host when setup step finishes (#147)
* fix: handle empty host when setup step finishes

* chore: update release notes
2025-02-18 14:40:01 +08:00
Hardy 3f7c32d4de
chore: typo to release note (#145)
Co-authored-by: hardy <luohf@infinilabs.com>
2025-02-17 18:08:02 +08:00
silenceqi f679a57fb2
fix: the error when querying empty metric data (#144)
* fix: the error when querying empty metric data

* chore: update release notes
2025-02-17 15:31:12 +08:00
65 changed files with 1358 additions and 163 deletions

View File

@ -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:

View File

@ -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:

View File

@ -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" : {

View File

@ -27,6 +27,19 @@ PUT _template/$[[SETUP_TEMPLATE_NAME]]
}
},
"mappings": {
"properties": {
"metadata": {
"properties": {
"labels": {
"properties": {
"cluster_id": {
"type": "keyword"
}
}
}
}
}
},
"dynamic_templates": [
{
"strings": {

View File

@ -27,6 +27,19 @@ PUT _template/$[[SETUP_TEMPLATE_NAME]]
}
},
"mappings": {
"properties": {
"metadata": {
"properties": {
"labels": {
"properties": {
"cluster_id": {
"type": "keyword"
}
}
}
}
}
},
"dynamic_templates": [
{
"strings": {

View File

@ -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" ]

View File

@ -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

View File

@ -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)

View File

@ -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())

View File

@ -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")

View File

@ -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:

View File

@ -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 {

View File

@ -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

View File

@ -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": {

View File

@ -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

View File

@ -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`}

View File

@ -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;

View File

@ -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"

View File

@ -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,
});
}}

View File

@ -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>

View File

@ -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':

View File

@ -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,
}
);

View File

@ -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);

View File

@ -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;

View File

@ -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>

View File

@ -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>

View File

@ -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}

View File

@ -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?",

View File

@ -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."
};

View File

@ -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",
};

View File

@ -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天内不可用的节点吗",

View File

@ -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 天没有上报指标的实例"
};

View File

@ -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": "索引延迟",

View File

@ -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;

View File

@ -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={() => {

View File

@ -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">

View File

@ -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%"}}>

View File

@ -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"}

View File

@ -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"

View File

@ -744,6 +744,7 @@ class Index extends PureComponent {
onChangeDeleteIndexConfirmState={this.onChangeDeleteIndexConfirmState}
deleteIndexConfirm={this.state.deleteIndexConfirm}
items={this.state.deleteIndexItems}
selectedCluster={this.props.selectedCluster}
/>
</PageHeaderWrapper>
);

View File

@ -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()
}}/>
)
}

View File

@ -0,0 +1,9 @@
.histogram {
z-index: 1;
:global {
.ant-popover-inner-content {
width: 400px;
padding: 0;
}
}
}

View File

@ -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}

View File

@ -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)

View File

@ -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>
)
}

View File

@ -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;

View File

@ -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,

View File

@ -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} />

View File

@ -20,7 +20,7 @@
position: absolute;
display: none;
right: 0px;
top: 0px;
bottom: 0px;
width: 24px;
height: 24px;
border-radius: 4px;

View File

@ -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 (

View File

@ -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,

View File

@ -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>
)

View File

@ -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>
)
}
},
]}
/>
);
}

View File

@ -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" },
];

View File

@ -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
}
}
]}
/>
);
}

View File

@ -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) => {

View File

@ -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>
);
}

View File

@ -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;
}
}
}

View File

@ -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>

View File

@ -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;

View File

@ -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>
);
};

View File

@ -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;
}
}
}
}

View File

@ -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>
);
};

View File

@ -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;
}
}

View File

@ -69,6 +69,7 @@ export default (props) => {
}),
key: "timestamp",
sortable: true,
defaultSortOrder: 'descend',
render: (text, record) => {
return formatUtcTimeToLocal(record.timestamp);
},