diff --git a/internal/docs/dashboards.go b/internal/docs/dashboards.go new file mode 100644 index 0000000000..c30a8c150f --- /dev/null +++ b/internal/docs/dashboards.go @@ -0,0 +1,91 @@ +// Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one +// or more contributor license agreements. Licensed under the Elastic License; +// you may not use this file except in compliance with the Elastic License. + +package docs + +import ( + "encoding/json" + "fmt" + "io/fs" + "os" + "path/filepath" + "strings" +) + +type dashboard struct { + Attributes struct { + Title string + Description string + } +} + +func renderDashboards(packageRoot string) (string, error) { + dashboardsDir := filepath.Join(packageRoot, "kibana", "dashboard") + + if _, err := os.Stat(dashboardsDir); os.IsNotExist(err) { + return "", nil + } + + var dashboards []dashboard + + err := filepath.WalkDir(dashboardsDir, func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + + if path == dashboardsDir { + return nil + } + + if d.IsDir() { + return filepath.SkipDir + } + + if filepath.Ext(d.Name()) != ".json" { + return nil + } + + rawDashboard, err := os.ReadFile(path) + if err != nil { + return fmt.Errorf("failed to read dashboard file: %w", err) + } + + var dash dashboard + if err := json.Unmarshal(rawDashboard, &dash); err != nil { + return fmt.Errorf("failed to unmarshal dashboard JSON: %w", err) + } + + dashboards = append(dashboards, dash) + return nil + }) + + if err != nil { + return "", fmt.Errorf("parsing dashboards failed: %w", err) + } + + var builder strings.Builder + + if len(dashboards) != 0 { + builder.WriteString("**The following dashboards are available:**\n\n") + renderDashboardsCollapsibleTable(&builder, dashboards) + builder.WriteString("\n") + } + + return builder.String(), nil +} + +func renderDashboardsCollapsibleTable(builder *strings.Builder, dashboards []dashboard) { + builder.WriteString("
\n") + builder.WriteString("View the dashboards\n\n") + builder.WriteString("| Dashboard | Description |\n") + builder.WriteString("|---|---|\n") + for _, d := range dashboards { + title := strings.TrimSpace(d.Attributes.Title) + description := strings.TrimSpace(strings.ReplaceAll(d.Attributes.Description, "\n", " ")) + fmt.Fprintf(builder, "| **%s** | %s |\n", + escaper.Replace(title), + escaper.Replace(description)) + } + builder.WriteString("\n
\n") +} diff --git a/internal/docs/dashboards_test.go b/internal/docs/dashboards_test.go new file mode 100644 index 0000000000..b94b80a77c --- /dev/null +++ b/internal/docs/dashboards_test.go @@ -0,0 +1,263 @@ +// Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one +// or more contributor license agreements. Licensed under the Elastic License; +// you may not use this file except in compliance with the Elastic License. + +package docs + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestRenderDashboards(t *testing.T) { + cases := []struct { + name string + setupFunc func(t *testing.T) string + expectError bool + expectEmpty bool + validateFunc func(t *testing.T, result string) + }{ + { + name: "no dashboards directory", + setupFunc: func(t *testing.T) string { + return t.TempDir() + }, + expectError: false, + expectEmpty: true, + }, + { + name: "empty dashboards directory", + setupFunc: func(t *testing.T) string { + tmpDir := t.TempDir() + dashboardsDir := filepath.Join(tmpDir, "kibana", "dashboard") + require.NoError(t, os.MkdirAll(dashboardsDir, 0o755)) + return tmpDir + }, + expectError: false, + expectEmpty: true, + }, + { + name: "single valid dashboard", + setupFunc: func(t *testing.T) string { + tmpDir := t.TempDir() + dashboardsDir := filepath.Join(tmpDir, "kibana", "dashboard") + require.NoError(t, os.MkdirAll(dashboardsDir, 0o755)) + + dash := `{ + "attributes": { + "title": "[PostgreSQL OTel Copy] Overview", + "description": "Overview of PostgreSQL health and golden signals." + } + }` + dashFile := filepath.Join(dashboardsDir, "overview.json") + require.NoError(t, os.WriteFile(dashFile, []byte(dash), 0o644)) + return tmpDir + }, + expectError: false, + expectEmpty: false, + validateFunc: func(t *testing.T, result string) { + assert.Contains(t, result, "**The following dashboards are available:**") + assert.Contains(t, result, "
") + assert.Contains(t, result, "View the dashboards") + assert.Contains(t, result, "
") + assert.Contains(t, result, "| Dashboard | Description |") + assert.Contains(t, result, "|---|---|") + assert.Contains(t, result, "| **[PostgreSQL OTel Copy] Overview** | Overview of PostgreSQL health and golden signals. |") + }, + }, + { + name: "multiple valid dashboards", + setupFunc: func(t *testing.T) string { + tmpDir := t.TempDir() + dashboardsDir := filepath.Join(tmpDir, "kibana", "dashboard") + require.NoError(t, os.MkdirAll(dashboardsDir, 0o755)) + + dash1 := `{ + "attributes": { + "title": "First Dashboard", + "description": "First description" + } + }` + dash2 := `{ + "attributes": { + "title": "Second Dashboard", + "description": "Second description" + } + }` + require.NoError(t, os.WriteFile(filepath.Join(dashboardsDir, "d1.json"), []byte(dash1), 0o644)) + require.NoError(t, os.WriteFile(filepath.Join(dashboardsDir, "d2.json"), []byte(dash2), 0o644)) + return tmpDir + }, + expectError: false, + expectEmpty: false, + validateFunc: func(t *testing.T, result string) { + assert.Contains(t, result, "
") + assert.Contains(t, result, "View the dashboards") + assert.Contains(t, result, "
") + assert.Contains(t, result, "| Dashboard | Description |") + assert.Contains(t, result, "|---|---|") + assert.Contains(t, result, "| **First Dashboard** | First description |") + assert.Contains(t, result, "| **Second Dashboard** | Second description |") + }, + }, + { + name: "skip non-json files", + setupFunc: func(t *testing.T) string { + tmpDir := t.TempDir() + dashboardsDir := filepath.Join(tmpDir, "kibana", "dashboard") + require.NoError(t, os.MkdirAll(dashboardsDir, 0o755)) + + dash := `{ + "attributes": { + "title": "Valid Dashboard", + "description": "Valid description" + } + }` + require.NoError(t, os.WriteFile(filepath.Join(dashboardsDir, "valid.json"), []byte(dash), 0o644)) + require.NoError(t, os.WriteFile(filepath.Join(dashboardsDir, "ignore.txt"), []byte("ignored"), 0o644)) + require.NoError(t, os.WriteFile(filepath.Join(dashboardsDir, "README.md"), []byte("# readme"), 0o644)) + return tmpDir + }, + expectError: false, + expectEmpty: false, + validateFunc: func(t *testing.T, result string) { + assert.Contains(t, result, "| **Valid Dashboard** | Valid description |") + assert.NotContains(t, result, "ignored") + assert.NotContains(t, result, "readme") + }, + }, + { + name: "skip subdirectories", + setupFunc: func(t *testing.T) string { + tmpDir := t.TempDir() + dashboardsDir := filepath.Join(tmpDir, "kibana", "dashboard") + require.NoError(t, os.MkdirAll(dashboardsDir, 0o755)) + + dash := `{ + "attributes": { + "title": "Root Dashboard", + "description": "Root description" + } + }` + require.NoError(t, os.WriteFile(filepath.Join(dashboardsDir, "root.json"), []byte(dash), 0o644)) + + subDir := filepath.Join(dashboardsDir, "subdir") + require.NoError(t, os.MkdirAll(subDir, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(subDir, "nested.json"), []byte(dash), 0o644)) + return tmpDir + }, + expectError: false, + expectEmpty: false, + validateFunc: func(t *testing.T, result string) { + assert.Equal(t, 1, strings.Count(result, "Root Dashboard")) + }, + }, + { + name: "unreadable file", + setupFunc: func(t *testing.T) string { + tmpDir := t.TempDir() + dashboardsDir := filepath.Join(tmpDir, "kibana", "dashboard") + require.NoError(t, os.MkdirAll(dashboardsDir, 0o755)) + + unreadableFile := filepath.Join(dashboardsDir, "unreadable.json") + require.NoError(t, os.WriteFile(unreadableFile, []byte("content"), 0o000)) + return tmpDir + }, + expectError: true, + expectEmpty: false, + }, + { + name: "invalid json file", + setupFunc: func(t *testing.T) string { + tmpDir := t.TempDir() + dashboardsDir := filepath.Join(tmpDir, "kibana", "dashboard") + require.NoError(t, os.MkdirAll(dashboardsDir, 0o755)) + + invalidJSON := `{ "attributes": { "title": "Invalid" }` + require.NoError(t, os.WriteFile(filepath.Join(dashboardsDir, "invalid.json"), []byte(invalidJSON), 0o644)) + return tmpDir + }, + expectError: true, + expectEmpty: false, + }, + { + name: "special characters are escaped", + setupFunc: func(t *testing.T) string { + tmpDir := t.TempDir() + dashboardsDir := filepath.Join(tmpDir, "kibana", "dashboard") + require.NoError(t, os.MkdirAll(dashboardsDir, 0o755)) + + dash := `{ + "attributes": { + "title": "Dashboard with *bold* and {braces}", + "description": "Description with and {curly} brackets" + } + }` + require.NoError(t, os.WriteFile(filepath.Join(dashboardsDir, "special.json"), []byte(dash), 0o644)) + return tmpDir + }, + expectError: false, + expectEmpty: false, + validateFunc: func(t *testing.T, result string) { + assert.Contains(t, result, `\*bold\*`) + assert.Contains(t, result, `\{braces\}`) + assert.Contains(t, result, `\`) + assert.Contains(t, result, `\{curly\}`) + assert.NotContains(t, result, "*bold*") + assert.NotContains(t, result, "{braces}") + assert.NotContains(t, result, "") + }, + }, + { + name: "newlines in description are flattened", + setupFunc: func(t *testing.T) string { + tmpDir := t.TempDir() + dashboardsDir := filepath.Join(tmpDir, "kibana", "dashboard") + require.NoError(t, os.MkdirAll(dashboardsDir, 0o755)) + + dash := `{ + "attributes": { + "title": "Multiline", + "description": "Line one.\nLine two." + } + }` + require.NoError(t, os.WriteFile(filepath.Join(dashboardsDir, "ml.json"), []byte(dash), 0o644)) + return tmpDir + }, + expectError: false, + expectEmpty: false, + validateFunc: func(t *testing.T, result string) { + assert.Contains(t, result, "| **Multiline** | Line one. Line two. |") + }, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + packageRoot := tc.setupFunc(t) + + result, err := renderDashboards(packageRoot) + + if tc.expectError { + assert.Error(t, err) + return + } + + require.NoError(t, err) + + if tc.expectEmpty { + assert.Empty(t, result) + } else { + assert.NotEmpty(t, result) + if tc.validateFunc != nil { + tc.validateFunc(t, result) + } + } + }) + } +} diff --git a/internal/docs/readme.go b/internal/docs/readme.go index 3140d42d7c..6d2b307422 100644 --- a/internal/docs/readme.go +++ b/internal/docs/readme.go @@ -240,6 +240,9 @@ func renderReadme(repositoryRoot *os.Root, fileName, packageRoot, templatePath s "sloTemplates": func() (string, error) { return renderSloTemplates(packageRoot, linksMap) }, + "dashboards": func() (string, error) { + return renderDashboards(packageRoot) + }, }).ParseFiles(templatePath) if err != nil { return nil, fmt.Errorf("parsing README template failed (path: %s): %w", templatePath, err) diff --git a/test/packages/other/dashboards/_dev/build/build.yml b/test/packages/other/dashboards/_dev/build/build.yml new file mode 100644 index 0000000000..002aa15659 --- /dev/null +++ b/test/packages/other/dashboards/_dev/build/build.yml @@ -0,0 +1,3 @@ +dependencies: + ecs: + reference: git@1.10 diff --git a/test/packages/other/dashboards/_dev/build/docs/README.md b/test/packages/other/dashboards/_dev/build/docs/README.md new file mode 100644 index 0000000000..f49379c6e8 --- /dev/null +++ b/test/packages/other/dashboards/_dev/build/docs/README.md @@ -0,0 +1,5 @@ +# Readme + +## Dashboards + +{{ dashboards }} \ No newline at end of file diff --git a/test/packages/other/dashboards/changelog.yml b/test/packages/other/dashboards/changelog.yml new file mode 100644 index 0000000000..e00f881335 --- /dev/null +++ b/test/packages/other/dashboards/changelog.yml @@ -0,0 +1,6 @@ +# newer versions go on top +- version: "0.0.1" + changes: + - description: Initial draft of the package + type: enhancement + link: https://github.com/elastic/integrations/pull/1 diff --git a/test/packages/other/dashboards/docs/README.md b/test/packages/other/dashboards/docs/README.md new file mode 100644 index 0000000000..b7dcdfb8af --- /dev/null +++ b/test/packages/other/dashboards/docs/README.md @@ -0,0 +1,15 @@ +# Readme + +## Dashboards + +**The following dashboards are available:** + +
+View the dashboards + +| Dashboard | Description | +|---|---| +| **[Example] Dashboard** | An example dashboard for testing the rendering of dashboards in the package README. | + +
+ diff --git a/test/packages/other/dashboards/kibana/dashboard/dashboards-example.json b/test/packages/other/dashboards/kibana/dashboard/dashboards-example.json new file mode 100644 index 0000000000..8d0d6f0821 --- /dev/null +++ b/test/packages/other/dashboards/kibana/dashboard/dashboards-example.json @@ -0,0 +1,17 @@ +{ + "attributes": { + "description": "An example dashboard for testing the rendering of dashboards in the package README.", + "kibanaSavedObjectMeta": { + "searchSourceJSON": "{\"filter\":[],\"query\":{\"query\":\"\",\"language\":\"kuery\"}}" + }, + "optionsJSON": "{\"useMargins\":true,\"hidePanelTitles\":false}", + "panelsJSON": "[]", + "timeRestore": false, + "title": "[Example] Dashboard" + }, + "coreMigrationVersion": "8.8.0", + "id": "dashboards-example", + "references": [], + "type": "dashboard", + "typeMigrationVersion": "10.3.0" +} diff --git a/test/packages/other/dashboards/manifest.yml b/test/packages/other/dashboards/manifest.yml new file mode 100644 index 0000000000..49033afa2d --- /dev/null +++ b/test/packages/other/dashboards/manifest.yml @@ -0,0 +1,14 @@ +format_version: 3.4.0 +name: dashboards +title: "Dashboards" +version: 0.0.1 +description: "This is a test of rendering dashboards in README." +type: integration +categories: + - custom +conditions: + kibana: + version: "^9.0.0" +owner: + github: elastic/integrations + type: elastic