Search: Implement basic improved UI (#46758)

This commit is contained in:
Nathan Marrs 2022-03-30 09:50:32 -07:00 committed by GitHub
parent 851c54b3b1
commit 4449439a41
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
24 changed files with 1334 additions and 351 deletions

View File

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

View File

@ -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<T = any> extends FunctionalVector<T> {
private index = 0;
private obj: T;
readonly fields: {
readonly [Property in keyof T]: Field<T[Property]>;
};
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<T = any> extends FunctionalVector<T> {
}
this.obj = obj;
this.fields = fields;
}
get dataFrame() {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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<string[]>([]);
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 <div className={styles.unsupported}>Unsupported</div>;
}
const getTagOptions = (): Promise<TermCount[]> => {
const tags = results.value?.body.fields.find((f) => f.name === 'tags');
if (tags) {
return Promise.resolve(getTermCounts(tags));
}
return Promise.resolve([]);
};
return (
<Page navModel={{ node: node, main: node }}>
<Page.Contents>
<Input value={query} onChange={(e) => setQuery(e.currentTarget.value)} autoFocus spellCheck={false} />
<br /> <br />
<br />
{results.loading && <Spinner />}
{results.value?.body && (
<div>
<AutoSizer style={{ width: '100%', height: '1000px' }}>
<TagFilter isClearable tags={tags} tagOptions={getTagOptions} onChange={setTags} /> <br />
<AutoSizer style={{ width: '100%', height: '2000px' }}>
{({ width }) => {
return (
<div>
<SearchPageDashboards dashboards={results.value!.body} width={width} />
</div>
<>
<Table data={results.value!.body} width={width} />
</>
);
}}
</AutoSizer>

View File

@ -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 (
<>
<h1>Dashboards ({dashboards.length})</h1>
<PanelRenderer
pluginId="table"
title="Dashboards"
data={{ series: [dashboards], state: LoadingState.Done } as any}
options={{}}
width={width}
height={300}
fieldConfig={{ defaults: {}, overrides: [] }}
timeZone="browser"
/>
<br />
</>
);
};

View File

@ -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<DashboardData> {
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<any, number>();
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,
};
}

View File

@ -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<FieldAccess>(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 (
<div {...row.getRowProps({ style })} className={styles.rowContainer}>
{row.cells.map((cell: Cell, index: number) => {
if (cell.column.id === 'column-checkbox') {
return (
<div key={index} className={styles.cellWrapper}>
<TableCell
key={index}
tableStyles={tableStyles}
cell={cell}
columnIndex={index}
columnCount={row.cells.length}
/>
</div>
);
}
return (
<a href={url} key={index}>
<div className={styles.cellWrapper}>
<TableCell
key={index}
tableStyles={tableStyles}
cell={cell}
columnIndex={index}
columnCount={row.cells.length}
/>
</div>
</a>
);
})}
</div>
);
},
[rows, prepareRow, access.fields.url?.values, styles.rowContainer, styles.cellWrapper, tableStyles]
);
return (
<div {...getTableProps()} style={{ width }} aria-label={'Search result table'} role="table">
<div>
{headerGroups.map((headerGroup) => {
const { key, ...headerGroupProps } = headerGroup.getHeaderGroupProps();
return (
<div key={key} {...headerGroupProps} className={styles.headerRow}>
{headerGroup.headers.map((column) => {
const { key, ...headerProps } = column.getHeaderProps();
return (
<div key={key} {...headerProps} role="columnheader" className={styles.headerCell}>
{column.render('Header')}
</div>
);
})}
</div>
);
})}
</div>
<div {...getTableBodyProps()}>
{rows.length > 0 ? (
<FixedSizeList
height={500}
itemCount={rows.length}
itemSize={tableStyles.rowHeight}
width={'100%'}
className={styles.tableBody}
>
{RenderRow}
</FixedSizeList>
) : (
<div className={styles.noData}>No data</div>
)}
</div>
</div>
);
};
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;
`,
};
};

View File

@ -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<FieldAccess>,
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: () => (
<div className={styles.checkboxHeader}>
<Checkbox onChange={() => {}} />
</div>
),
Cell: () => (
<div className={styles.checkbox}>
<Checkbox onChange={() => {}} />
</div>
),
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 (
<div className={styles.typeText}>
<Icon name={'apps'} className={styles.typeIcon} />
Dashboard
</div>
);
},
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 <div className={styles.infoWrap}>{panelCount != null && <span>Panels: {panelCount}</span>}</div>;
},
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 (
<div>
{location.map((v, id) => (
<span
key={id}
className={styles.locationItem}
onClick={(e) => {
e.preventDefault();
alert('CLICK: ' + v.name);
}}
>
<Icon name={getIconForKind(v.kind)} /> {v.name}
</span>
))}
</div>
);
}
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<DataSourceRef[]>, 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 (
<div>
{dslist.map((v, i) => {
const settings = srv.getInstanceSettings(v);
const icon = settings?.meta?.info?.logos?.small;
if (icon) {
return (
<span key={i}>
<SVG src={icon} width={14} height={14} title={settings.type} className={iconClass} />
{settings.name}
</span>
);
}
return <span key={i}>{v.type}</span>;
})}
</div>
);
}
return null;
},
width,
};
}
function makeTypeColumn(
kindField: Field<string>,
typeField: Field<string>,
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 (
<div className={typeTextClass}>
<SVG src={icon} width={14} height={14} title={txt} className={iconClass} />
{txt}
</div>
);
},
width,
};
}
function makeTagsColumn(field: Field<string[]>, 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 <TagList className={tagListClass} tags={tags} onClick={(v) => alert('CLICKED TAG: ' + v)} />;
}
return null;
},
width,
};
}

View File

@ -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> = [S, Dispatch<SearchPageAction>];

View File

@ -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<RawIndexData> {
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<RawIndexData> {
}
return data;
}
export function buildStatsTable(field?: Field): DataFrame {
if (!field) {
return { length: 0, fields: [] };
}
const counts = new Map<any, number>();
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<any, number>();
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<QueryResult>(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<any>): Vector<any> {
const values = new Array(keep.length);
for (let i = 0; i < keep.length; i++) {
values[i] = raw.get(keep[i]);
}
return new ArrayVector(values);
}

View File

@ -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<string>;
uid?: Vector<string>;
name?: Vector<string>;
folder?: Vector<number>;
description?: Vector<string>;
dashboardID?: Vector<number>;
location?: Vector<LocationInfo[]>;
datasource?: Vector<DataSourceRef[]>;
type?: Vector<string>;
tags?: Vector<string>; // JSON strings?
tags?: Vector<string[]>; // JSON strings?
}
interface CompositeKey {
@ -42,7 +47,7 @@ export class MiniSearcher implements GrafanaSearcher {
const searcher = new MiniSearch<InputDoc>({
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<number, number>();
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<number, number>();
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;

View File

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

View File

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