diff --git a/package.json b/package.json index 16a45ef226b..88a1c885987 100644 --- a/package.json +++ b/package.json @@ -146,6 +146,7 @@ "@types/react-loadable": "5.5.6", "@types/react-redux": "7.1.23", "@types/react-router-dom": "5.3.3", + "@types/react-table": "^7", "@types/react-test-renderer": "17.0.1", "@types/react-transition-group": "4.4.4", "@types/react-virtualized-auto-sizer": "1.0.1", @@ -353,6 +354,7 @@ "react-router-dom": "^5.2.0", "react-select": "5.2.2", "react-split-pane": "0.1.92", + "react-table": "^7.7.0", "react-transition-group": "4.4.2", "react-use": "17.3.2", "react-virtualized-auto-sizer": "1.0.6", diff --git a/packages/grafana-data/src/dataframe/DataFrameView.ts b/packages/grafana-data/src/dataframe/DataFrameView.ts index 59c12bd6dfd..9f8880d1aa5 100644 --- a/packages/grafana-data/src/dataframe/DataFrameView.ts +++ b/packages/grafana-data/src/dataframe/DataFrameView.ts @@ -1,4 +1,4 @@ -import { DataFrame } from '../types/dataFrame'; +import { DataFrame, Field } from '../types/dataFrame'; import { DisplayProcessor } from '../types'; import { FunctionalVector } from '../vector/FunctionalVector'; @@ -16,13 +16,22 @@ import { FunctionalVector } from '../vector/FunctionalVector'; export class DataFrameView extends FunctionalVector { private index = 0; private obj: T; + readonly fields: { + readonly [Property in keyof T]: Field; + }; constructor(private data: DataFrame) { super(); const obj = {} as unknown as T; + const fields = {} as any; for (let i = 0; i < data.fields.length; i++) { const field = data.fields[i]; + if (!field.name) { + continue; // unsupported + } + + fields[field.name] = field; const getter = () => field.values.get(this.index); if (!(obj as any).hasOwnProperty(field.name)) { @@ -41,6 +50,7 @@ export class DataFrameView extends FunctionalVector { } this.obj = obj; + this.fields = fields; } get dataFrame() { diff --git a/packages/grafana-data/src/types/dataFrameTypes.ts b/packages/grafana-data/src/types/dataFrameTypes.ts index f4faa9fda88..d7c5c8cead2 100644 --- a/packages/grafana-data/src/types/dataFrameTypes.ts +++ b/packages/grafana-data/src/types/dataFrameTypes.ts @@ -21,4 +21,7 @@ export enum DataFrameType { * All values in the grid exist and have regular spacing */ HeatmapScanlines = 'heatmap-scanlines', + + /** Directory listing */ + DirectoryListing = 'directory-listing', } diff --git a/packages/grafana-ui/src/components/Table/utils.ts b/packages/grafana-ui/src/components/Table/utils.ts index 872904219e6..6691efef841 100644 --- a/packages/grafana-ui/src/components/Table/utils.ts +++ b/packages/grafana-ui/src/components/Table/utils.ts @@ -118,7 +118,7 @@ export function getColumns( return columns; } -function getCellComponent(displayMode: TableCellDisplayMode, field: Field): CellComponent { +export function getCellComponent(displayMode: TableCellDisplayMode, field: Field): CellComponent { switch (displayMode) { case TableCellDisplayMode.ColorText: case TableCellDisplayMode.ColorBackground: diff --git a/pkg/services/searchV2/extract/dashboard.go b/pkg/services/searchV2/extract/dashboard.go index 38e174e5b47..fdfa5da231a 100644 --- a/pkg/services/searchV2/extract/dashboard.go +++ b/pkg/services/searchV2/extract/dashboard.go @@ -12,7 +12,7 @@ func logf(format string, a ...interface{}) { // nolint:gocyclo // ReadDashboard will take a byte stream and return dashboard info -func ReadDashboard(stream io.Reader, datasource DatasourceLookup) *DashboardInfo { +func ReadDashboard(stream io.Reader, lookup DatasourceLookup) *DashboardInfo { iter := jsoniter.Parse(jsoniter.ConfigDefault, stream, 1024) dash := &DashboardInfo{} @@ -73,7 +73,7 @@ func ReadDashboard(stream io.Reader, datasource DatasourceLookup) *DashboardInfo case "panels": for iter.ReadArray() { - dash.Panels = append(dash.Panels, readPanelInfo(iter)) + dash.Panels = append(dash.Panels, readPanelInfo(iter, lookup)) } case "rows": @@ -129,16 +129,29 @@ func ReadDashboard(stream io.Reader, datasource DatasourceLookup) *DashboardInfo logf("All dashbaords should have a UID defined") } + targets := newTargetInfo(lookup) + for _, panel := range dash.Panels { + targets.addPanel(panel) + } + dash.Datasource = targets.GetDatasourceInfo() + return dash } // will always return strings for now -func readPanelInfo(iter *jsoniter.Iterator) PanelInfo { +func readPanelInfo(iter *jsoniter.Iterator, lookup DatasourceLookup) PanelInfo { panel := PanelInfo{} + targets := newTargetInfo(lookup) + for l1Field := iter.ReadObject(); l1Field != ""; l1Field = iter.ReadObject() { - // Skip null values so we don't need special int handling if iter.WhatIsNext() == jsoniter.NilValue { + if l1Field == "datasource" { + targets.addDatasource(iter) + continue + } + + // Skip null values so we don't need special int handling iter.Skip() continue } @@ -160,13 +173,11 @@ func readPanelInfo(iter *jsoniter.Iterator) PanelInfo { panel.PluginVersion = iter.ReadString() // since 7x (the saved version for the plugin model) case "datasource": - v := iter.Read() - logf(">>Panel.datasource = %v\n", v) // string or object!!! + targets.addDatasource(iter) case "targets": for iter.ReadArray() { - v := iter.Read() - logf("[Panel.TARGET] %v\n", v) + targets.addTarget(iter) } case "transformations": @@ -183,7 +194,7 @@ func readPanelInfo(iter *jsoniter.Iterator) PanelInfo { // Rows have nested panels case "panels": for iter.ReadArray() { - panel.Collapsed = append(panel.Collapsed, readPanelInfo(iter)) + panel.Collapsed = append(panel.Collapsed, readPanelInfo(iter, lookup)) } case "options": @@ -201,5 +212,7 @@ func readPanelInfo(iter *jsoniter.Iterator) PanelInfo { } } + panel.Datasource = targets.GetDatasourceInfo() + return panel } diff --git a/pkg/services/searchV2/extract/dashboard_test.go b/pkg/services/searchV2/extract/dashboard_test.go index 73c2d8394f6..34270bec827 100644 --- a/pkg/services/searchV2/extract/dashboard_test.go +++ b/pkg/services/searchV2/extract/dashboard_test.go @@ -12,19 +12,36 @@ import ( func TestReadDashboard(t *testing.T) { inputs := []string{ - "all-panels.json", - "panel-graph/graph-shared-tooltips.json", + "check-string-datasource-id", + "all-panels", + "panel-graph/graph-shared-tooltips", } // key will allow name or uid - ds := func(key string) *DatasourceInfo { - return nil // TODO! + ds := func(ref *DataSourceRef) *DataSourceRef { + if ref == nil || ref.UID == "" { + return &DataSourceRef{ + UID: "default.uid", + Type: "default.type", + } + } + return ref } + devdash := "../../../../devenv/dev-dashboards/" + for _, input := range inputs { // nolint:gosec // We can ignore the gosec G304 warning because this is a test with hardcoded input values - f, err := os.Open("../../../../devenv/dev-dashboards/" + input) + f, err := os.Open(filepath.Join(devdash, input) + ".json") + if err == nil { + input = "devdash-" + filepath.Base(input) + } + if err != nil { + // nolint:gosec + // We can ignore the gosec G304 warning because this is a test with hardcoded input values + f, err = os.Open(filepath.Join("testdata", input) + ".json") + } require.NoError(t, err) dash := ReadDashboard(f, ds) @@ -32,7 +49,7 @@ func TestReadDashboard(t *testing.T) { require.NoError(t, err) update := false - savedPath := "testdata/" + filepath.Base(input) + savedPath := "testdata/" + input + "-info.json" saved, err := os.ReadFile(savedPath) if err != nil { update = true diff --git a/pkg/services/searchV2/extract/targets.go b/pkg/services/searchV2/extract/targets.go new file mode 100644 index 00000000000..6fa02622ecd --- /dev/null +++ b/pkg/services/searchV2/extract/targets.go @@ -0,0 +1,81 @@ +package extract + +import ( + jsoniter "github.com/json-iterator/go" +) + +type targetInfo struct { + lookup DatasourceLookup + uids map[string]*DataSourceRef +} + +func newTargetInfo(lookup DatasourceLookup) targetInfo { + return targetInfo{ + lookup: lookup, + uids: make(map[string]*DataSourceRef), + } +} + +func (s *targetInfo) GetDatasourceInfo() []DataSourceRef { + keys := make([]DataSourceRef, len(s.uids)) + i := 0 + for _, v := range s.uids { + keys[i] = *v + i++ + } + return keys +} + +// the node will either be string (name|uid) OR ref +func (s *targetInfo) addDatasource(iter *jsoniter.Iterator) { + switch iter.WhatIsNext() { + case jsoniter.StringValue: + key := iter.ReadString() + ds := s.lookup(&DataSourceRef{UID: key}) + s.addRef(ds) + + case jsoniter.NilValue: + s.addRef(s.lookup(nil)) + iter.Skip() + + case jsoniter.ObjectValue: + ref := &DataSourceRef{} + iter.ReadVal(ref) + ds := s.lookup(ref) + s.addRef(ds) + + default: + v := iter.Read() + logf("[Panel.datasource.unknown] %v\n", v) + } +} + +func (s *targetInfo) addRef(ref *DataSourceRef) { + if ref != nil && ref.UID != "" { + s.uids[ref.UID] = ref + } +} + +func (s *targetInfo) addTarget(iter *jsoniter.Iterator) { + for l1Field := iter.ReadObject(); l1Field != ""; l1Field = iter.ReadObject() { + switch l1Field { + case "datasource": + s.addDatasource(iter) + + case "refId": + iter.Skip() + + default: + v := iter.Read() + logf("[Panel.TARGET] %s=%v\n", l1Field, v) + } + } +} + +func (s *targetInfo) addPanel(panel PanelInfo) { + for idx, v := range panel.Datasource { + if v.UID != "" { + s.uids[v.UID] = &panel.Datasource[idx] + } + } +} diff --git a/pkg/services/searchV2/extract/testdata/check-string-datasource-id-info.json b/pkg/services/searchV2/extract/testdata/check-string-datasource-id-info.json new file mode 100644 index 00000000000..ec9dd91d010 --- /dev/null +++ b/pkg/services/searchV2/extract/testdata/check-string-datasource-id-info.json @@ -0,0 +1,29 @@ +{ + "id": 250, + "uid": "K2X7hzwGk", + "title": "fast streaming", + "tags": null, + "datasource": [ + { + "uid": "-- Grafana --" + } + ], + "panels": [ + { + "id": 3, + "title": "Panel Title", + "type": "timeseries", + "pluginVersion": "7.5.0-pre", + "datasource": [ + { + "uid": "-- Grafana --" + } + ] + } + ], + "schemaVersion": 27, + "linkCount": 0, + "timeFrom": "now-30s", + "timeTo": "now", + "timezone": "" +} \ No newline at end of file diff --git a/pkg/services/searchV2/extract/testdata/check-string-datasource-id.json b/pkg/services/searchV2/extract/testdata/check-string-datasource-id.json new file mode 100644 index 00000000000..7e499d3dc60 --- /dev/null +++ b/pkg/services/searchV2/extract/testdata/check-string-datasource-id.json @@ -0,0 +1,67 @@ +{ + "annotations": { + "list": [ + { + "builtIn": 1, + "datasource": "-- Grafana --", + "enable": true, + "hide": true, + "iconColor": "rgba(0, 211, 255, 1)", + "name": "Annotations \u0026 Alerts", + "type": "dashboard" + } + ] + }, + "editable": true, + "gnetId": null, + "graphTooltip": 0, + "id": 250, + "links": [], + "panels": [ + { + "datasource": "-- Grafana --", + "fieldConfig": {}, + "gridPos": {}, + "id": 3, + "options": { + "graph": {}, + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom" + }, + "tooltipOptions": { + "mode": "single" + } + }, + "pluginVersion": "7.5.0-pre", + "targets": [ + { + "channel": "stream/telegraf/cpu", + "filter": { + "fields": [] + }, + "queryType": "measurements", + "refId": "A" + } + ], + "title": "Panel Title", + "type": "timeseries" + } + ], + "schemaVersion": 27, + "style": "dark", + "tags": [], + "templating": { + "list": [] + }, + "time": { + "from": "now-30s", + "to": "now" + }, + "timepicker": {}, + "timezone": "", + "title": "fast streaming", + "uid": "K2X7hzwGk", + "version": 8 +} \ No newline at end of file diff --git a/pkg/services/searchV2/extract/testdata/all-panels.json b/pkg/services/searchV2/extract/testdata/devdash-all-panels-info.json similarity index 100% rename from pkg/services/searchV2/extract/testdata/all-panels.json rename to pkg/services/searchV2/extract/testdata/devdash-all-panels-info.json diff --git a/pkg/services/searchV2/extract/testdata/devdash-graph-shared-tooltips-info.json b/pkg/services/searchV2/extract/testdata/devdash-graph-shared-tooltips-info.json new file mode 100644 index 00000000000..a9d9a9eccd8 --- /dev/null +++ b/pkg/services/searchV2/extract/testdata/devdash-graph-shared-tooltips-info.json @@ -0,0 +1,122 @@ +{ + "uid": "TX2VU59MZ", + "title": "Panel Tests - shared tooltips", + "tags": [ + "gdev", + "panel-tests", + "graph-ng" + ], + "datasource": [ + { + "uid": "default.uid", + "type": "default.type" + } + ], + "panels": [ + { + "id": 4, + "title": "two units", + "type": "timeseries", + "pluginVersion": "7.5.0-pre", + "datasource": [ + { + "uid": "default.uid", + "type": "default.type" + } + ] + }, + { + "id": 13, + "title": "Speed vs Temperature (XY)", + "type": "xychart", + "pluginVersion": "7.5.0-pre", + "datasource": [ + { + "uid": "default.uid", + "type": "default.type" + } + ], + "transformations": [ + "seriesToColumns", + "organize" + ] + }, + { + "id": 2, + "title": "Cursor info", + "type": "debug", + "pluginVersion": "7.5.0-pre", + "datasource": [ + { + "uid": "default.uid", + "type": "default.type" + } + ] + }, + { + "id": 5, + "title": "Only temperature", + "type": "timeseries", + "pluginVersion": "7.5.0-pre", + "datasource": [ + { + "uid": "default.uid", + "type": "default.type" + } + ] + }, + { + "id": 9, + "title": "Only Speed", + "type": "timeseries", + "pluginVersion": "7.5.0-pre", + "datasource": [ + { + "uid": "default.uid", + "type": "default.type" + } + ] + }, + { + "id": 11, + "title": "Panel Title", + "type": "timeseries", + "pluginVersion": "7.5.0-pre", + "datasource": [ + { + "uid": "default.uid", + "type": "default.type" + } + ] + }, + { + "id": 8, + "title": "flot panel (temperature)", + "type": "graph", + "pluginVersion": "7.5.0-pre", + "datasource": [ + { + "uid": "default.uid", + "type": "default.type" + } + ] + }, + { + "id": 10, + "title": "flot panel (no units)", + "type": "graph", + "pluginVersion": "7.5.0-pre", + "datasource": [ + { + "uid": "default.uid", + "type": "default.type" + } + ] + } + ], + "schemaVersion": 28, + "linkCount": 0, + "timeFrom": "2020-09-14T16:13:20.000Z", + "timeTo": "2020-09-15T20:00:00.000Z", + "timezone": "" +} \ No newline at end of file diff --git a/pkg/services/searchV2/extract/testdata/graph-shared-tooltips.json b/pkg/services/searchV2/extract/testdata/graph-shared-tooltips.json deleted file mode 100644 index 54580e8135b..00000000000 --- a/pkg/services/searchV2/extract/testdata/graph-shared-tooltips.json +++ /dev/null @@ -1,68 +0,0 @@ -{ - "uid": "TX2VU59MZ", - "title": "Panel Tests - shared tooltips", - "tags": [ - "gdev", - "panel-tests", - "graph-ng" - ], - "panels": [ - { - "id": 4, - "title": "two units", - "type": "timeseries", - "pluginVersion": "7.5.0-pre" - }, - { - "id": 13, - "title": "Speed vs Temperature (XY)", - "type": "xychart", - "pluginVersion": "7.5.0-pre", - "transformations": [ - "seriesToColumns", - "organize" - ] - }, - { - "id": 2, - "title": "Cursor info", - "type": "debug", - "pluginVersion": "7.5.0-pre" - }, - { - "id": 5, - "title": "Only temperature", - "type": "timeseries", - "pluginVersion": "7.5.0-pre" - }, - { - "id": 9, - "title": "Only Speed", - "type": "timeseries", - "pluginVersion": "7.5.0-pre" - }, - { - "id": 11, - "title": "Panel Title", - "type": "timeseries", - "pluginVersion": "7.5.0-pre" - }, - { - "id": 8, - "title": "flot panel (temperature)", - "type": "graph", - "pluginVersion": "7.5.0-pre" - }, - { - "id": 10, - "title": "flot panel (no units)", - "type": "graph", - "pluginVersion": "7.5.0-pre" - } - ], - "schemaVersion": 28, - "linkCount": 0, - "timeFrom": "2020-09-14T16:13:20.000Z", - "timeTo": "2020-09-15T20:00:00.000Z", - "timezone": "" -} \ No newline at end of file diff --git a/pkg/services/searchV2/extract/types.go b/pkg/services/searchV2/extract/types.go index 246c7cebb8a..7cbd72adf16 100644 --- a/pkg/services/searchV2/extract/types.go +++ b/pkg/services/searchV2/extract/types.go @@ -1,45 +1,40 @@ package extract -type DatasourceLookup = func(key string) *DatasourceInfo +// empty everything will return the default +type DatasourceLookup = func(ref *DataSourceRef) *DataSourceRef -type DatasourceInfo struct { - UID string `json:"uid"` - Name string `json:"name"` - Type string `json:"type"` // plugin name - Version string `json:"version"` - Access string `json:"access,omitempty"` // proxy, direct, or empty +type DataSourceRef struct { + UID string `json:"uid,omitempty"` + Type string `json:"type,omitempty"` } type PanelInfo struct { - ID int64 `json:"id"` - Title string `json:"title"` - Description string `json:"description,omitempty"` - Type string `json:"type,omitempty"` // PluginID - PluginVersion string `json:"pluginVersion,omitempty"` - Datasource []string `json:"datasource,omitempty"` // UIDs - DatasourceType []string `json:"datasourceType,omitempty"` // PluginIDs - Transformations []string `json:"transformations,omitempty"` // ids of the transformation steps + ID int64 `json:"id"` + Title string `json:"title"` + Description string `json:"description,omitempty"` + Type string `json:"type,omitempty"` // PluginID + PluginVersion string `json:"pluginVersion,omitempty"` + Datasource []DataSourceRef `json:"datasource,omitempty"` // UIDs + Transformations []string `json:"transformations,omitempty"` // ids of the transformation steps // Rows define panels as sub objects Collapsed []PanelInfo `json:"collapsed,omitempty"` } type DashboardInfo struct { - ID int64 `json:"id,omitempty"` - UID string `json:"uid,omitempty"` - Path string `json:"path,omitempty"` - Title string `json:"title"` - Description string `json:"description,omitempty"` - Tags []string `json:"tags"` // UIDs - Datasource []string `json:"datasource,omitempty"` // UIDs - DatasourceType []string `json:"datasourceType,omitempty"` // PluginIDs - TemplateVars []string `json:"templateVars,omitempty"` // the keys used - Panels []PanelInfo `json:"panels"` // nesed documents - SchemaVersion int64 `json:"schemaVersion"` - LinkCount int64 `json:"linkCount"` - TimeFrom string `json:"timeFrom"` - TimeTo string `json:"timeTo"` - TimeZone string `json:"timezone"` - Refresh string `json:"refresh,omitempty"` - ReadOnly bool `json:"readOnly,omitempty"` // editable = false + ID int64 `json:"id,omitempty"` // internal ID + UID string `json:"uid,omitempty"` + Title string `json:"title"` + Description string `json:"description,omitempty"` + Tags []string `json:"tags"` + TemplateVars []string `json:"templateVars,omitempty"` // the keys used + Datasource []DataSourceRef `json:"datasource,omitempty"` // UIDs + Panels []PanelInfo `json:"panels"` // nesed documents + SchemaVersion int64 `json:"schemaVersion"` + LinkCount int64 `json:"linkCount"` + TimeFrom string `json:"timeFrom"` + TimeTo string `json:"timeTo"` + TimeZone string `json:"timezone"` + Refresh string `json:"refresh,omitempty"` + ReadOnly bool `json:"readOnly,omitempty"` // editable = false } diff --git a/pkg/services/searchV2/service.go b/pkg/services/searchV2/service.go index c2ea11f6923..3998b0ac224 100644 --- a/pkg/services/searchV2/service.go +++ b/pkg/services/searchV2/service.go @@ -89,7 +89,7 @@ func (s *StandardSearchService) applyAuthFilter(user *models.SignedInUser, dash // create a list of all viewable dashboards for this user res := make([]dashMeta, 0, len(dash)) for _, dash := range dash { - if filter(dash.dash.UID) { + if filter(dash.dash.UID) || (dash.is_folder && dash.dash.UID == "") { // include the "General" folder res = append(res, dash) } } @@ -106,15 +106,38 @@ type dashDataQueryResult struct { Updated time.Time } +type dsQueryResult struct { + UID string `xorm:"uid"` + Type string `xorm:"type"` + Name string `xorm:"name"` + IsDefault bool `xorm:"is_default"` +} + func loadDashboards(ctx context.Context, orgID int64, sql *sqlstore.SQLStore) ([]dashMeta, error) { meta := make([]dashMeta, 0, 200) + // Add the root folder ID (does not exist in SQL) + meta = append(meta, dashMeta{ + id: 0, + is_folder: true, + folder_id: 0, + slug: "", + created: time.Now(), + updated: time.Now(), + dash: &extract.DashboardInfo{ + ID: 0, + UID: "", + Title: "General", + }, + }) + // key will allow name or uid - lookup := func(key string) *extract.DatasourceInfo { - return nil // TODO! + lookup, err := loadDatasoureLookup(ctx, orgID, sql) + if err != nil { + return meta, err } - err := sql.WithDbSession(ctx, func(sess *sqlstore.DBSession) error { + err = sql.WithDbSession(ctx, func(sess *sqlstore.DBSession) error { rows := make([]*dashDataQueryResult, 0) sess.Table("dashboard"). @@ -146,6 +169,64 @@ func loadDashboards(ctx context.Context, orgID int64, sql *sqlstore.SQLStore) ([ return meta, err } +func loadDatasoureLookup(ctx context.Context, orgID int64, sql *sqlstore.SQLStore) (extract.DatasourceLookup, error) { + byUID := make(map[string]*extract.DataSourceRef, 50) + byName := make(map[string]*extract.DataSourceRef, 50) + var defaultDS *extract.DataSourceRef + + err := sql.WithDbSession(ctx, func(sess *sqlstore.DBSession) error { + rows := make([]*dsQueryResult, 0) + sess.Table("data_source"). + Where("org_id = ?", orgID). + Cols("uid", "name", "type", "is_default") + + err := sess.Find(&rows) + if err != nil { + return err + } + + for _, row := range rows { + ds := &extract.DataSourceRef{ + UID: row.UID, + Type: row.Type, + } + byUID[row.UID] = ds + byName[row.Name] = ds + if row.IsDefault { + defaultDS = ds + } + } + + return nil + }) + if err != nil { + return nil, err + } + + // Lookup by UID or name + return func(ref *extract.DataSourceRef) *extract.DataSourceRef { + if ref == nil { + return defaultDS + } + key := "" + if ref.UID != "" { + ds, ok := byUID[ref.UID] + if ok { + return ds + } + key = ref.UID + } + if key == "" { + return defaultDS + } + ds, ok := byUID[key] + if ok { + return ds + } + return byName[key] + }, err +} + type simpleCounter struct { values map[string]int64 } @@ -173,10 +254,12 @@ func metaToFrame(meta []dashMeta) data.Frames { folderID := data.NewFieldFromFieldType(data.FieldTypeInt64, 0) folderUID := data.NewFieldFromFieldType(data.FieldTypeString, 0) folderName := data.NewFieldFromFieldType(data.FieldTypeString, 0) + folderDashCount := data.NewFieldFromFieldType(data.FieldTypeInt64, 0) - folderID.Name = "ID" - folderUID.Name = "UID" - folderName.Name = "Name" + folderID.Name = "id" + folderUID.Name = "uid" + folderName.Name = "name" + folderDashCount.Name = "DashCount" dashID := data.NewFieldFromFieldType(data.FieldTypeInt64, 0) dashUID := data.NewFieldFromFieldType(data.FieldTypeString, 0) @@ -188,22 +271,28 @@ func metaToFrame(meta []dashMeta) data.Frames { dashUpdated := data.NewFieldFromFieldType(data.FieldTypeTime, 0) dashSchemaVersion := data.NewFieldFromFieldType(data.FieldTypeInt64, 0) dashTags := data.NewFieldFromFieldType(data.FieldTypeNullableString, 0) + dashPanelCount := data.NewFieldFromFieldType(data.FieldTypeInt64, 0) + dashVarCount := data.NewFieldFromFieldType(data.FieldTypeInt64, 0) + dashDSList := data.NewFieldFromFieldType(data.FieldTypeNullableString, 0) - dashID.Name = "ID" - dashUID.Name = "UID" - dashFolderID.Name = "FolderID" - dashName.Name = "Name" - dashDescr.Name = "Description" - dashTags.Name = "Tags" + dashID.Name = "id" + dashUID.Name = "uid" + dashFolderID.Name = "folderID" + dashName.Name = "name" + dashDescr.Name = "description" + dashTags.Name = "tags" dashSchemaVersion.Name = "SchemaVersion" dashCreated.Name = "Created" dashUpdated.Name = "Updated" - dashURL.Name = "URL" + dashURL.Name = "url" dashURL.Config = &data.FieldConfig{ Links: []data.DataLink{ {Title: "link", URL: "${__value.text}"}, }, } + dashPanelCount.Name = "panelCount" + dashVarCount.Name = "varCount" + dashDSList.Name = "datasource" dashTags.Config = &data.FieldConfig{ Custom: map[string]interface{}{ @@ -218,11 +307,11 @@ func metaToFrame(meta []dashMeta) data.Frames { panelDescr := data.NewFieldFromFieldType(data.FieldTypeString, 0) panelType := data.NewFieldFromFieldType(data.FieldTypeString, 0) - panelDashID.Name = "DashboardID" - panelID.Name = "ID" - panelName.Name = "Name" - panelDescr.Name = "Description" - panelType.Name = "Type" + panelDashID.Name = "dashboardID" + panelID.Name = "id" + panelName.Name = "name" + panelDescr.Name = "description" + panelType.Name = "type" panelTypeCounter := simpleCounter{ values: make(map[string]int64, 30), @@ -232,12 +321,14 @@ func metaToFrame(meta []dashMeta) data.Frames { values: make(map[string]int64, 30), } - var tags *string + folderCounter := make(map[int64]int64, 20) + for _, row := range meta { if row.is_folder { folderID.Append(row.id) folderUID.Append(row.dash.UID) folderName.Append(row.dash.Title) + folderDashCount.Append(int64(0)) // filled in later continue } @@ -250,22 +341,23 @@ func metaToFrame(meta []dashMeta) data.Frames { dashCreated.Append(row.created) dashUpdated.Append(row.updated) + // Increment the folder counter + fcount, ok := folderCounter[row.folder_id] + if !ok { + fcount = 0 + } + folderCounter[row.folder_id] = fcount + 1 + url := fmt.Sprintf("/d/%s/%s", row.dash.UID, row.slug) dashURL.Append(url) // stats schemaVersionCounter.add(strconv.FormatInt(row.dash.SchemaVersion, 10)) - // Send tags as JSON array - tags = nil - if len(row.dash.Tags) > 0 { - b, err := json.Marshal(row.dash.Tags) - if err == nil { - s := string(b) - tags = &s - } - } - dashTags.Append(tags) + dashTags.Append(toJSONString(row.dash.Tags)) + dashPanelCount.Append(int64(len(row.dash.Panels))) + dashVarCount.Append(int64(len(row.dash.TemplateVars))) + dashDSList.Append(dsAsJSONString(row.dash.Datasource)) // Row for each panel for _, panel := range row.dash.Panels { @@ -278,11 +370,47 @@ func metaToFrame(meta []dashMeta) data.Frames { } } + // Update the folder counts + for i := 0; i < folderID.Len(); i++ { + id, ok := folderID.At(i).(int64) + if ok { + folderDashCount.Set(i, folderCounter[id]) + } + } + return data.Frames{ - data.NewFrame("folders", folderID, folderUID, folderName), - data.NewFrame("dashboards", dashID, dashUID, dashURL, dashFolderID, dashName, dashDescr, dashTags, dashSchemaVersion, dashCreated, dashUpdated), + data.NewFrame("folders", folderID, folderUID, folderName, folderDashCount), + data.NewFrame("dashboards", dashID, dashUID, dashURL, dashFolderID, + dashName, dashDescr, dashTags, + dashSchemaVersion, + dashPanelCount, dashVarCount, dashDSList, + dashCreated, dashUpdated), data.NewFrame("panels", panelDashID, panelID, panelName, panelDescr, panelType), panelTypeCounter.toFrame("panel-type-counts"), schemaVersionCounter.toFrame("schema-version-counts"), } } + +func toJSONString(vals []string) *string { + if len(vals) < 1 { + return nil + } + b, err := json.Marshal(vals) + if err == nil { + s := string(b) + return &s + } + return nil +} + +func dsAsJSONString(vals []extract.DataSourceRef) *string { + if len(vals) < 1 { + return nil + } + b, err := json.Marshal(vals) + if err == nil { + s := string(b) + return &s + } + return nil +} diff --git a/public/app/features/search/page/SearchPage.tsx b/public/app/features/search/page/SearchPage.tsx index d5ccf0d676c..c55edaab958 100644 --- a/public/app/features/search/page/SearchPage.tsx +++ b/public/app/features/search/page/SearchPage.tsx @@ -6,9 +6,11 @@ import AutoSizer from 'react-virtualized-auto-sizer'; import { css } from '@emotion/css'; import Page from 'app/core/components/Page/Page'; -import { SearchPageDashboards } from './SearchPageDashboards'; import { useAsync } from 'react-use'; -import { getGrafanaSearcher } from '../service'; +import { getGrafanaSearcher, QueryFilters } from '../service'; +import { Table } from './table/Table'; +import { TagFilter, TermCount } from 'app/core/components/TagFilter/TagFilter'; +import { getTermCounts } from '../service/backend'; const node: NavModelItem = { id: 'search', @@ -20,29 +22,43 @@ const node: NavModelItem = { export default function SearchPage() { const styles = useStyles2(getStyles); const [query, setQuery] = useState(''); + const [tags, setTags] = useState([]); const results = useAsync(() => { - return getGrafanaSearcher().search(query); - }, [query]); + const filters: QueryFilters = { + tags, + }; + return getGrafanaSearcher().search(query, tags.length ? filters : undefined); + }, [query, tags]); if (!config.featureToggles.panelTitleSearch) { return
Unsupported
; } + const getTagOptions = (): Promise => { + const tags = results.value?.body.fields.find((f) => f.name === 'tags'); + + if (tags) { + return Promise.resolve(getTermCounts(tags)); + } + return Promise.resolve([]); + }; + return ( setQuery(e.currentTarget.value)} autoFocus spellCheck={false} /> -

+
{results.loading && } {results.value?.body && (
- +
+ {({ width }) => { return ( -
- -
+ <> + + ); }} diff --git a/public/app/features/search/page/SearchPageDashboards.tsx b/public/app/features/search/page/SearchPageDashboards.tsx deleted file mode 100644 index ed4b1cf7927..00000000000 --- a/public/app/features/search/page/SearchPageDashboards.tsx +++ /dev/null @@ -1,27 +0,0 @@ -import React from 'react'; -import { DataFrame, LoadingState } from '@grafana/data'; -import { PanelRenderer } from '@grafana/runtime'; - -type Props = { - dashboards: DataFrame; - width: number; -}; - -export const SearchPageDashboards = ({ dashboards, width }: Props) => { - return ( - <> -

Dashboards ({dashboards.length})

- -
- - ); -}; diff --git a/public/app/features/search/page/data.ts b/public/app/features/search/page/data.ts deleted file mode 100644 index 1d4c1db2833..00000000000 --- a/public/app/features/search/page/data.ts +++ /dev/null @@ -1,114 +0,0 @@ -import { ArrayVector, DataFrame, Field, FieldType } from '@grafana/data'; -import { getDataSourceSrv } from '@grafana/runtime'; -import { GrafanaDatasource } from 'app/plugins/datasource/grafana/datasource'; -import { GrafanaQueryType } from 'app/plugins/datasource/grafana/types'; -import { lastValueFrom } from 'rxjs'; - -export interface DashboardData { - dashboards: DataFrame; - panels: DataFrame; - panelTypes: DataFrame; - schemaVersions: DataFrame; -} - -export async function getDashboardData(): Promise { - const ds = (await getDataSourceSrv().get('-- Grafana --')) as GrafanaDatasource; - const rsp = await lastValueFrom( - ds.query({ - targets: [ - { refId: 'A', queryType: GrafanaQueryType.Search }, // gets all data - ], - } as any) - ); - - const data: DashboardData = {} as any; - for (const f of rsp.data) { - switch (f.name) { - case 'dashboards': - data.dashboards = f; - break; - case 'panels': - data.panels = f; - break; - } - } - - data.panelTypes = buildStatsTable(data.panels.fields.find((f) => f.name === 'Type')); - data.schemaVersions = buildStatsTable(data.dashboards.fields.find((f) => f.name === 'SchemaVersion')); - - return data; -} - -export function filterDataFrame(query: string, frame: DataFrame, ...fields: string[]): DataFrame { - if (!frame || !query?.length) { - return frame; - } - query = query.toLowerCase(); - - const checkIndex: number[] = []; - const buffer: any[][] = []; - const copy = frame.fields.map((f, idx) => { - if (f.type === FieldType.string && fields.includes(f.name)) { - checkIndex.push(idx); - } - const v: any[] = []; - buffer.push(v); - return { ...f, values: new ArrayVector(v) }; - }); - - for (let i = 0; i < frame.length; i++) { - let match = false; - for (const idx of checkIndex) { - const v = frame.fields[idx].values.get(i) as string; - if (v && v.toLowerCase().indexOf(query) >= 0) { - match = true; - break; - } - } - - if (match) { - for (let idx = 0; idx < buffer.length; idx++) { - buffer[idx].push(frame.fields[idx].values.get(i)); - } - } - } - - return { - fields: copy, - length: buffer[0].length, - }; -} - -export function buildStatsTable(field?: Field): DataFrame { - if (!field) { - return { length: 0, fields: [] }; - } - - const counts = new Map(); - for (let i = 0; i < field.values.length; i++) { - const k = field.values.get(i); - const v = counts.get(k) ?? 0; - counts.set(k, v + 1); - } - - // Sort largest first - counts[Symbol.iterator] = function* () { - yield* [...this.entries()].sort((a, b) => b[1] - a[1]); - }; - - const keys: any[] = []; - const vals: number[] = []; - - for (let [k, v] of counts) { - keys.push(k); - vals.push(v); - } - - return { - fields: [ - { ...field, values: new ArrayVector(keys) }, - { name: 'Count', type: FieldType.number, values: new ArrayVector(vals), config: {} }, - ], - length: keys.length, - }; -} diff --git a/public/app/features/search/page/table/Table.tsx b/public/app/features/search/page/table/Table.tsx new file mode 100644 index 00000000000..e97e8669642 --- /dev/null +++ b/public/app/features/search/page/table/Table.tsx @@ -0,0 +1,223 @@ +import React, { useMemo } from 'react'; +import { useTable, useBlockLayout, Column, TableOptions, Cell } from 'react-table'; +import { DataFrame, DataFrameType, DataFrameView, DataSourceRef, Field, GrafanaTheme2 } from '@grafana/data'; +import { css } from '@emotion/css'; +import { useStyles2 } from '@grafana/ui'; +import { FixedSizeList } from 'react-window'; +import { TableCell } from '@grafana/ui/src/components/Table/TableCell'; +import { getTableStyles } from '@grafana/ui/src/components/Table/styles'; + +import { LocationInfo } from '../../service'; +import { generateColumns } from './columns'; + +type Props = { + data: DataFrame; + width: number; +}; + +export type TableColumn = Column & { + field?: Field; +}; + +export interface FieldAccess { + kind: string; // panel, dashboard, folder + name: string; + description: string; + url: string; // link to value (unique) + type: string; // graph + tags: string[]; + location: LocationInfo[]; // the folder name + score: number; + + // Count info + panelCount: number; + datasource: DataSourceRef[]; +} + +export const Table = ({ data, width }: Props) => { + const styles = useStyles2(getStyles); + const tableStyles = useStyles2(getTableStyles); + + const memoizedData = useMemo(() => { + if (!data.fields.length) { + return []; + } + // as we only use this to fake the length of our data set for react-table we need to make sure we always return an array + // filled with values at each index otherwise we'll end up trying to call accessRow for null|undefined value in + // https://github.com/tannerlinsley/react-table/blob/7be2fc9d8b5e223fc998af88865ae86a88792fdb/src/hooks/useTable.js#L585 + return Array(data.length).fill(0); + }, [data]); + + // React-table column definitions + const access = useMemo(() => new DataFrameView(data), [data]); + const memoizedColumns = useMemo(() => { + const isDashboardList = data.meta?.type === DataFrameType.DirectoryListing; + return generateColumns(access, isDashboardList, width, styles); + }, [data.meta?.type, access, width, styles]); + + const options: TableOptions<{}> = useMemo( + () => ({ + columns: memoizedColumns, + data: memoizedData, + }), + [memoizedColumns, memoizedData] + ); + + const { getTableProps, getTableBodyProps, headerGroups, rows, prepareRow } = useTable(options, useBlockLayout); + + const RenderRow = React.useCallback( + ({ index: rowIndex, style }) => { + const row = rows[rowIndex]; + prepareRow(row); + + const url = access.fields.url?.values.get(rowIndex); + + return ( +
+ {row.cells.map((cell: Cell, index: number) => { + if (cell.column.id === 'column-checkbox') { + return ( +
+ +
+ ); + } + + return ( + +
+ +
+
+ ); + })} +
+ ); + }, + [rows, prepareRow, access.fields.url?.values, styles.rowContainer, styles.cellWrapper, tableStyles] + ); + + return ( +
+
+ {headerGroups.map((headerGroup) => { + const { key, ...headerGroupProps } = headerGroup.getHeaderGroupProps(); + + return ( +
+ {headerGroup.headers.map((column) => { + const { key, ...headerProps } = column.getHeaderProps(); + return ( +
+ {column.render('Header')} +
+ ); + })} +
+ ); + })} +
+ +
+ {rows.length > 0 ? ( + + {RenderRow} + + ) : ( +
No data
+ )} +
+
+ ); +}; + +const getStyles = (theme: GrafanaTheme2) => { + const rowHoverBg = theme.colors.emphasize(theme.colors.background.primary, 0.03); + + return { + noData: css` + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + height: 100%; + `, + table: css` + width: 100%; + `, + tableBody: css` + overflow: 'hidden auto'; + `, + cellIcon: css` + display: flex; + align-items: center; + `, + cellWrapper: css` + display: flex; + pointer-events: none; + `, + headerCell: css` + padding-top: 2px; + padding-left: 10px; + `, + headerRow: css` + background-color: ${theme.colors.background.secondary}; + height: 36px; + align-items: center; + `, + rowContainer: css` + &:hover { + background-color: ${rowHoverBg}; + } + `, + typeIcon: css` + margin-right: 9.5px; + vertical-align: middle; + display: inline-block; + margin-bottom: ${theme.v1.spacing.xxs}; + fill: ${theme.colors.text.secondary}; + `, + typeText: css` + color: ${theme.colors.text.secondary}; + `, + locationItem: css` + color: ${theme.colors.text.secondary}; + margin-right: 12px; + `, + checkboxHeader: css` + // display: flex; + // justify-content: flex-start; + `, + checkbox: css` + margin-left: 10px; + margin-right: 10px; + margin-top: 5px; + `, + infoWrap: css` + span { + margin-right: 10px; + } + `, + tagList: css` + justify-content: flex-start; + `, + }; +}; diff --git a/public/app/features/search/page/table/columns.tsx b/public/app/features/search/page/table/columns.tsx new file mode 100644 index 00000000000..e42780d0b28 --- /dev/null +++ b/public/app/features/search/page/table/columns.tsx @@ -0,0 +1,278 @@ +import React from 'react'; +import { DataFrameView, DataSourceRef, Field } from '@grafana/data'; +import { config, getDataSourceSrv } from '@grafana/runtime'; +import SVG from 'react-inlinesvg'; +import { Checkbox, Icon, IconName, TagList } from '@grafana/ui'; +import { DefaultCell } from '@grafana/ui/src/components/Table/DefaultCell'; + +import { FieldAccess, TableColumn } from './Table'; +import { LocationInfo } from '../../service'; + +export const generateColumns = ( + data: DataFrameView, + isDashboardList: boolean, + availableWidth: number, + styles: { [key: string]: string } +): TableColumn[] => { + const columns: TableColumn[] = []; + const urlField = data.fields.url!; + const access = data.fields; + + availableWidth -= 8; // ??? + let width = 50; + + // TODO: Add optional checkbox support + if (false) { + // checkbox column + columns.push({ + id: `column-checkbox`, + Header: () => ( +
+ {}} /> +
+ ), + Cell: () => ( +
+ {}} /> +
+ ), + accessor: 'check', + field: urlField, + width: 30, + }); + availableWidth -= width; + } + + // Name column + width = Math.max(availableWidth * 0.2, 200); + columns.push({ + Cell: DefaultCell, + id: `column-name`, + field: access.name!, + Header: 'Name', + accessor: (row: any, i: number) => { + const name = access.name!.values.get(i); + return name; + }, + width, + }); + availableWidth -= width; + + const TYPE_COLUMN_WIDTH = 130; + const DATASOURCE_COLUMN_WIDTH = 200; + const INFO_COLUMN_WIDTH = 100; + const LOCATION_COLUMN_WIDTH = 200; + + width = TYPE_COLUMN_WIDTH; + if (isDashboardList) { + columns.push({ + Cell: DefaultCell, + id: `column-type`, + field: access.name!, + Header: 'Type', + accessor: (row: any, i: number) => { + return ( +
+ + Dashboard +
+ ); + }, + width, + }); + availableWidth -= width; + } else { + columns.push(makeTypeColumn(access.kind, access.type, width, styles.typeText, styles.typeIcon)); + availableWidth -= width; + } + + // Show datasources if we have any + if (access.datasource && hasFieldValue(access.datasource)) { + width = DATASOURCE_COLUMN_WIDTH; + columns.push(makeDataSourceColumn(access.datasource, width, styles.typeIcon)); + availableWidth -= width; + } + + if (isDashboardList) { + width = INFO_COLUMN_WIDTH; + columns.push({ + Cell: DefaultCell, + id: `column-info`, + field: access.url!, + Header: 'Info', + accessor: (row: any, i: number) => { + const panelCount = access.panelCount?.values.get(i); + return
{panelCount != null && Panels: {panelCount}}
; + }, + width: width, + }); + availableWidth -= width; + } else { + columns.push({ + Cell: DefaultCell, + id: `column-location`, + field: access.location ?? access.url, + Header: 'Location', + accessor: (row: any, i: number) => { + const location = access.location?.values.get(i) as LocationInfo[]; + if (location) { + return ( +
+ {location.map((v, id) => ( + { + e.preventDefault(); + alert('CLICK: ' + v.name); + }} + > + {v.name} + + ))} +
+ ); + } + return null; + }, + width: LOCATION_COLUMN_WIDTH, + }); + availableWidth -= width; + } + + // Show tags if we have any + if (access.tags && hasFieldValue(access.tags)) { + width = Math.max(availableWidth, 250); + columns.push(makeTagsColumn(access.tags, width, styles.tagList)); + } + + return columns; +}; + +function hasFieldValue(field: Field): boolean { + for (let i = 0; i < field.values.length; i++) { + const v = field.values.get(i); + if (v && v.length) { + return true; + } + } + return false; +} + +function getIconForKind(v: string): IconName { + if (v === 'dashboard') { + return 'apps'; + } + if (v === 'folder') { + return 'folder'; + } + return 'question-circle'; +} + +function makeDataSourceColumn(field: Field, width: number, iconClass: string): TableColumn { + return { + Cell: DefaultCell, + id: `column-datasource`, + field, + Header: 'Data source', + accessor: (row: any, i: number) => { + const dslist = field.values.get(i); + if (dslist?.length) { + const srv = getDataSourceSrv(); + return ( +
+ {dslist.map((v, i) => { + const settings = srv.getInstanceSettings(v); + const icon = settings?.meta?.info?.logos?.small; + if (icon) { + return ( + + + {settings.name} + + ); + } + return {v.type}; + })} +
+ ); + } + return null; + }, + width, + }; +} + +function makeTypeColumn( + kindField: Field, + typeField: Field, + width: number, + typeTextClass: string, + iconClass: string +): TableColumn { + return { + Cell: DefaultCell, + id: `column-type`, + field: kindField, + Header: 'Type', + accessor: (row: any, i: number) => { + const kind = kindField.values.get(i); + let icon = 'public/img/icons/unicons/apps.svg'; + let txt = 'Dashboard'; + if (kind) { + txt = kind; + switch (txt) { + case 'dashboard': + txt = 'Dashboard'; + break; + + case 'folder': + icon = 'public/img/icons/unicons/folder.svg'; + txt = 'Folder'; + break; + + case 'panel': + icon = 'public/img/icons/unicons/graph-bar.svg'; + const type = typeField.values.get(i); + if (type) { + txt = type; + const info = config.panels[txt]; + if (info?.name) { + const v = info.info?.logos.small; + if (v && v.endsWith('.svg')) { + icon = v; + } + txt = info.name; + } + } + break; + } + } + + return ( +
+ + {txt} +
+ ); + }, + width, + }; +} + +function makeTagsColumn(field: Field, width: number, tagListClass: string): TableColumn { + return { + Cell: DefaultCell, + id: `column-tags`, + field: field, + Header: 'Tags', + accessor: (row: any, i: number) => { + const tags = field.values.get(i); + if (tags) { + return alert('CLICKED TAG: ' + v)} />; + } + return null; + }, + width, + }; +} diff --git a/public/app/features/search/page/types.ts b/public/app/features/search/page/types.ts deleted file mode 100644 index 44a67e21857..00000000000 --- a/public/app/features/search/page/types.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { Dispatch } from 'react'; -import { Action } from 'redux'; - -export interface DashboardResult { - UID: string; - URL: string; - Name: string; - Description: string; - Created: number; - Updated: number; -} - -export interface SearchPageAction extends Action { - payload?: any; -} - -export type SearchPageReducer = [S, Dispatch]; diff --git a/public/app/features/search/service/backend.ts b/public/app/features/search/service/backend.ts index 80f073e83e3..e6137f3de3f 100644 --- a/public/app/features/search/service/backend.ts +++ b/public/app/features/search/service/backend.ts @@ -1,8 +1,20 @@ -import { DataFrame, getDisplayProcessor } from '@grafana/data'; +import { + ArrayVector, + DataFrame, + DataFrameType, + DataFrameView, + Field, + FieldType, + getDisplayProcessor, + Vector, +} from '@grafana/data'; import { config, getDataSourceSrv } from '@grafana/runtime'; import { GrafanaDatasource } from 'app/plugins/datasource/grafana/datasource'; import { lastValueFrom } from 'rxjs'; import { GrafanaQueryType } from 'app/plugins/datasource/grafana/types'; +import { TermCount } from 'app/core/components/TagFilter/TagFilter'; +import { QueryFilters } from './types'; +import { QueryResult } from '.'; // The raw restuls from query server export interface RawIndexData { @@ -27,8 +39,26 @@ export async function getRawIndexData(): Promise { for (const f of rsp.data) { const frame = f as DataFrame; for (const field of frame.fields) { + // Parse tags/ds from JSON string + if (field.name === 'tags' || field.name === 'datasource') { + const values = field.values.toArray().map((v) => { + if (v?.length) { + try { + const arr = JSON.parse(v); + return arr.length ? arr : undefined; + } catch {} + } + return undefined; + }); + field.type = FieldType.other; // []string + field.values = new ArrayVector(values); + } + field.display = getDisplayProcessor({ field, theme: config.theme2 }); } + frame.meta = { + type: DataFrameType.DirectoryListing, + }; switch (frame.name) { case 'dashboards': @@ -44,3 +74,120 @@ export async function getRawIndexData(): Promise { } return data; } + +export function buildStatsTable(field?: Field): DataFrame { + if (!field) { + return { length: 0, fields: [] }; + } + + const counts = new Map(); + for (let i = 0; i < field.values.length; i++) { + const k = field.values.get(i); + const v = counts.get(k) ?? 0; + counts.set(k, v + 1); + } + + // Sort largest first + counts[Symbol.iterator] = function* () { + yield* [...this.entries()].sort((a, b) => b[1] - a[1]); + }; + + const keys: any[] = []; + const vals: number[] = []; + + for (let [k, v] of counts) { + keys.push(k); + vals.push(v); + } + + return { + fields: [ + { ...field, values: new ArrayVector(keys) }, + { name: 'Count', type: FieldType.number, values: new ArrayVector(vals), config: {} }, + ], + length: keys.length, + }; +} + +export function getTermCounts(field?: Field): TermCount[] { + if (!field) { + return []; + } + + const counts = new Map(); + for (let i = 0; i < field.values.length; i++) { + const k = field.values.get(i); + if (k == null || !k.length) { + continue; + } + if (Array.isArray(k)) { + for (const sub of k) { + const v = counts.get(sub) ?? 0; + counts.set(sub, v + 1); + } + } else { + const v = counts.get(k) ?? 0; + counts.set(k, v + 1); + } + } + + // Sort largest first + counts[Symbol.iterator] = function* () { + yield* [...this.entries()].sort((a, b) => b[1] - a[1]); + }; + + const terms: TermCount[] = []; + for (let [term, count] of counts) { + terms.push({ + term, + count, + }); + } + + return terms; +} + +export function filterFrame(frame: DataFrame, filter?: QueryFilters): DataFrame { + if (!filter) { + return frame; + } + const view = new DataFrameView(frame); + const keep: number[] = []; + + let ok = true; + for (let i = 0; i < view.length; i++) { + ok = true; + const row = view.get(i); + if (filter.tags) { + const tags = row.tags; + if (!tags) { + ok = false; + continue; + } + for (const t of filter.tags) { + if (!tags.includes(t)) { + ok = false; + break; + } + } + } + if (ok) { + keep.push(i); + } + } + + return { + meta: frame.meta, + name: frame.name, + fields: frame.fields.map((f) => ({ ...f, values: filterValues(keep, f.values) })), + length: keep.length, + }; +} + +function filterValues(keep: number[], raw: Vector): Vector { + const values = new Array(keep.length); + for (let i = 0; i < keep.length; i++) { + values[i] = raw.get(keep[i]); + } + return new ArrayVector(values); +} diff --git a/public/app/features/search/service/minisearcher.ts b/public/app/features/search/service/minisearcher.ts index 6478b3350f4..432bf702e58 100644 --- a/public/app/features/search/service/minisearcher.ts +++ b/public/app/features/search/service/minisearcher.ts @@ -1,9 +1,11 @@ import MiniSearch from 'minisearch'; -import { ArrayVector, DataFrame, Field, FieldType, getDisplayProcessor, Vector } from '@grafana/data'; +import { ArrayVector, DataFrame, DataSourceRef, Field, FieldType, getDisplayProcessor, Vector } from '@grafana/data'; import { config } from '@grafana/runtime'; import { GrafanaSearcher, QueryFilters, QueryResponse } from './types'; -import { getRawIndexData, RawIndexData, rawIndexSupplier } from './backend'; +import { filterFrame, getRawIndexData, RawIndexData, rawIndexSupplier } from './backend'; +import { LocationInfo } from '.'; +import { isArray, isString } from 'lodash'; export type SearchResultKind = keyof RawIndexData; @@ -16,10 +18,13 @@ interface InputDoc { url?: Vector; uid?: Vector; name?: Vector; + folder?: Vector; description?: Vector; dashboardID?: Vector; + location?: Vector; + datasource?: Vector; type?: Vector; - tags?: Vector; // JSON strings? + tags?: Vector; // JSON strings? } interface CompositeKey { @@ -42,7 +47,7 @@ export class MiniSearcher implements GrafanaSearcher { const searcher = new MiniSearch({ idField: '__id', - fields: ['name', 'description', 'tags'], // fields to index for full-text search + fields: ['name', 'description', 'tags', 'type', 'tags'], // fields to index for full-text search searchOptions: { boost: { name: 3, @@ -68,13 +73,20 @@ export class MiniSearcher implements GrafanaSearcher { return { kind: doc.kind, index: doc.index, - }; + } as any; } const values = (doc as any)[name] as Vector; if (!values) { - return undefined; + return ''; } - return values.get(doc.index); + const value = values.get(doc.index); + if (isString(value)) { + return value as string; + } + if (isArray(value)) { + return value.join(' '); + } + return JSON.stringify(value); }, }); @@ -90,23 +102,53 @@ export class MiniSearcher implements GrafanaSearcher { } // Construct the URL field for each panel + const folderIDToIndex = new Map(); + const folder = lookup.get('folder'); const dashboard = lookup.get('dashboard'); const panel = lookup.get('panel'); + if (folder?.id) { + for (let i = 0; i < folder.id?.length; i++) { + folderIDToIndex.set(folder.id.get(i), i); + } + } + if (dashboard?.id && panel?.dashboardID && dashboard.url) { + let location: LocationInfo[][] = new Array(dashboard.id.length); const dashIDToIndex = new Map(); for (let i = 0; i < dashboard.id?.length; i++) { dashIDToIndex.set(dashboard.id.get(i), i); + const folderId = dashboard.folder?.get(i); + if (folderId != null) { + const index = folderIDToIndex.get(folderId); + const name = folder?.name?.get(index!); + if (name) { + location[i] = [ + { + kind: 'folder', + name, + }, + ]; + } + } } + dashboard.location = new ArrayVector(location); // folder name - const urls: string[] = new Array(panel.dashboardID.length); + location = new Array(panel.dashboardID.length); + const urls: string[] = new Array(location.length); for (let i = 0; i < panel.dashboardID.length; i++) { const dashboardID = panel.dashboardID.get(i); const index = dashIDToIndex.get(dashboardID); if (index != null) { - urls[i] = dashboard.url.get(index) + '?viewPanel=' + panel.id?.get(i); + const idx = panel.id?.get(i); + urls[i] = dashboard.url.get(index) + '?viewPanel=' + idx; + + const parent = dashboard.location.get(index) ?? []; + const name = dashboard.name?.get(index) ?? '?'; + location[i] = [...parent, { kind: 'dashboard', name }]; } } panel.url = new ArrayVector(urls); + panel.location = new ArrayVector(location); } this.index = searcher; @@ -122,7 +164,7 @@ export class MiniSearcher implements GrafanaSearcher { // empty query can return everything if (!query && this.data.dashboard) { return { - body: this.data.dashboard, + body: filterFrame(this.data.dashboard, filter), }; } @@ -133,6 +175,9 @@ export class MiniSearcher implements GrafanaSearcher { const kind: string[] = []; const type: string[] = []; const name: string[] = []; + const tags: string[][] = []; + const location: LocationInfo[][] = []; + const datasource: DataSourceRef[][] = []; const info: any[] = []; const score: number[] = []; @@ -144,38 +189,34 @@ export class MiniSearcher implements GrafanaSearcher { continue; } + if (filter && !shouldKeep(filter, input, index)) { + continue; + } + url.push(input.url?.get(index) ?? '?'); + location.push(input.location?.get(index) as any); + datasource.push(input.datasource?.get(index) as any); + tags.push(input.tags?.get(index) as any); kind.push(key.kind); name.push(input.name?.get(index) ?? '?'); - type.push(input.type?.get(index) as any); + type.push(input.type?.get(index)!); info.push(res.match); // ??? score.push(res.score); } const fields: Field[] = [ - { name: 'Kind', config: {}, type: FieldType.string, values: new ArrayVector(kind) }, - { name: 'Name', config: {}, type: FieldType.string, values: new ArrayVector(name) }, + { name: 'kind', config: {}, type: FieldType.string, values: new ArrayVector(kind) }, + { name: 'name', config: {}, type: FieldType.string, values: new ArrayVector(name) }, { - name: 'URL', - config: { - links: [ - { - title: 'view', - url: '?', - onClick: (evt) => { - const { field, rowIndex } = evt.origin; - if (field && rowIndex != null) { - const url = field.values.get(rowIndex) as string; - window.location.href = url; // HACK! - } - }, - }, - ], - }, + name: 'url', + config: {}, type: FieldType.string, values: new ArrayVector(url), }, - { name: 'type', config: {}, type: FieldType.other, values: new ArrayVector(type) }, + { name: 'type', config: {}, type: FieldType.string, values: new ArrayVector(type) }, { name: 'info', config: {}, type: FieldType.other, values: new ArrayVector(info) }, + { name: 'tags', config: {}, type: FieldType.other, values: new ArrayVector(tags) }, + { name: 'location', config: {}, type: FieldType.other, values: new ArrayVector(location) }, + { name: 'datasource', config: {}, type: FieldType.other, values: new ArrayVector(datasource) }, { name: 'score', config: {}, type: FieldType.number, values: new ArrayVector(score) }, ]; for (const field of fields) { @@ -190,6 +231,21 @@ export class MiniSearcher implements GrafanaSearcher { } } +function shouldKeep(filter: QueryFilters, doc: InputDoc, index: number): boolean { + if (filter.tags) { + const tags = doc.tags?.get(index); + if (!tags?.length) { + return false; + } + for (const t of filter.tags) { + if (!tags.includes(t)) { + return false; + } + } + } + return true; +} + function getInputDoc(kind: SearchResultKind, frame: DataFrame): InputDoc { const input: InputDoc = { kind, @@ -217,6 +273,10 @@ function getInputDoc(kind: SearchResultKind, frame: DataFrame): InputDoc { case 'ID': input.id = field.values; break; + case 'Tags': + case 'tags': + input.tags = field.values; + break; case 'DashboardID': case 'dashboardID': input.dashboardID = field.values; @@ -225,6 +285,15 @@ function getInputDoc(kind: SearchResultKind, frame: DataFrame): InputDoc { case 'type': input.type = field.values; break; + case 'folderID': + case 'FolderID': + input.folder = field.values; + break; + case 'datasource': + case 'dsList': + case 'DSList': + input.datasource = field.values; + break; } } return input; diff --git a/public/app/features/search/service/types.ts b/public/app/features/search/service/types.ts index 25a0ed7999f..8d13a2a076a 100644 --- a/public/app/features/search/service/types.ts +++ b/public/app/features/search/service/types.ts @@ -1,4 +1,4 @@ -import { DataFrame } from '@grafana/data'; +import { DataFrame, DataSourceRef } from '@grafana/data'; export interface QueryResult { kind: string; // panel, dashboard, folder @@ -6,13 +6,20 @@ export interface QueryResult { description?: string; url: string; // link to value (unique) tags?: string[]; - location?: string; // the folder name + location?: LocationInfo[]; // the folder name + datasource?: DataSourceRef[]; score?: number; } +export interface LocationInfo { + kind: 'folder' | 'dashboard'; + name: string; +} + export interface QueryFilters { kind?: string; // limit to a single type tags?: string[]; // match all tags + datasource?: string; // limit to a single datasource } export interface QueryResponse { diff --git a/yarn.lock b/yarn.lock index 7c830145ca5..39b30c0addc 100644 --- a/yarn.lock +++ b/yarn.lock @@ -10536,7 +10536,7 @@ __metadata: languageName: node linkType: hard -"@types/react-table@npm:7.7.10": +"@types/react-table@npm:7.7.10, @types/react-table@npm:^7": version: 7.7.10 resolution: "@types/react-table@npm:7.7.10" dependencies: @@ -20502,6 +20502,7 @@ __metadata: "@types/react-loadable": 5.5.6 "@types/react-redux": 7.1.23 "@types/react-router-dom": 5.3.3 + "@types/react-table": ^7 "@types/react-test-renderer": 17.0.1 "@types/react-transition-group": 4.4.4 "@types/react-virtualized-auto-sizer": 1.0.1 @@ -20650,6 +20651,7 @@ __metadata: react-select: 5.2.2 react-select-event: ^5.1.0 react-split-pane: 0.1.92 + react-table: ^7.7.0 react-test-renderer: 17.0.2 react-transition-group: 4.4.2 react-use: 17.3.2 @@ -31187,7 +31189,7 @@ __metadata: languageName: node linkType: hard -"react-table@npm:7.7.0": +"react-table@npm:7.7.0, react-table@npm:^7.7.0": version: 7.7.0 resolution: "react-table@npm:7.7.0" peerDependencies: