Skip to content

Commit ee2d398

Browse files
vanugrahxnyo
andauthored
feat: Adds support for -server mode and concurrently downloading dashboards (#21)
* adds error logger to logger package * sets up flag to run detection on a periodic interval * Working now. Added flag for -server mode. Added a web server with an /output route which returns a JSON payload of dashboard objects that are still using angular. Updates the in memory representation of that list every (default) 10m interval. This can be hooked up directly to grafana via the infinity data source plugin * moves flag handling to flags.go package. cleans up http handler * comment * setup flag for server port * add comment * add readiness probe since full detections can take 5+ minutes. refactors for loop on channel to be after http server is setup * update readiness check to use sync.Once. Creates struct with mutex for output data * add support for concurrently download dashboards to speed up detection runs * update test * refactor * Fix CLI mode not working * refactor * more refactoring * moved run channel inside loop goroutine * removed context with cancel * incorporate PR feedback * do not set feault server args * add log when running dector * add a log statement when interval ticker is setup for periodic runs * sets up runServer method with graceful termintion * Update flags/flags.go Co-authored-by: Giuseppe Guerra <giuseppe@guerra.in> * Update flags/flags.go Co-authored-by: Giuseppe Guerra <giuseppe@guerra.in> * Update main.go Co-authored-by: Giuseppe Guerra <giuseppe@guerra.in> * rename handleOutputRequest to handleDetectionsRequest * Update flags/flags.go Co-authored-by: Giuseppe Guerra <giuseppe@guerra.in> --------- Co-authored-by: Giuseppe Guerra <giuseppe@guerra.in>
1 parent 9a6c364 commit ee2d398

File tree

6 files changed

+327
-85
lines changed

6 files changed

+327
-85
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
.idea
22
detect-angular-dashboards
33
dist
4+
.vscode

detector/detector.go

Lines changed: 59 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import (
55
"fmt"
66
"net/url"
77
"strings"
8+
"sync"
89

910
"github.com/grafana/detect-angular-dashboards/api/gcom"
1011
"github.com/grafana/detect-angular-dashboards/api/grafana"
@@ -38,14 +39,17 @@ type Detector struct {
3839

3940
angularDetected map[string]bool
4041
datasourcePluginIDs map[string]string
42+
maxConcurrency int
4143
}
4244

4345
// NewDetector returns a new Detector.
44-
func NewDetector(log *logger.LeveledLogger, grafanaClient GrafanaDetectorAPIClient, gcomClient gcom.APIClient) *Detector {
46+
func NewDetector(log *logger.LeveledLogger, grafanaClient GrafanaDetectorAPIClient, gcomClient gcom.APIClient, maxConcurrency int) *Detector {
4547
return &Detector{
46-
log: log,
47-
grafanaClient: grafanaClient,
48-
gcomClient: gcomClient,
48+
log: log,
49+
grafanaClient: grafanaClient,
50+
gcomClient: gcomClient,
51+
angularDetected: map[string]bool{},
52+
maxConcurrency: maxConcurrency,
4953
}
5054
}
5155

@@ -60,7 +64,6 @@ func (d *Detector) Run(ctx context.Context) ([]output.Dashboard, error) {
6064
// Determine if plugins are angular.
6165
// This can be done from frontendsettings (faster and works with private plugins, but only works with >= 10.1.0)
6266
// or from GCOM (slower, but always available, but public plugins only)
63-
d.angularDetected = map[string]bool{}
6467
frontendSettings, err := d.grafanaClient.GetFrontendSettings(ctx)
6568
if err != nil {
6669
return []output.Dashboard{}, fmt.Errorf("get frontend settings: %w", err)
@@ -154,33 +157,59 @@ func (d *Detector) Run(ctx context.Context) ([]output.Dashboard, error) {
154157
return []output.Dashboard{}, fmt.Errorf("get dashboards: %w", err)
155158
}
156159

160+
// Create a semaphore to limit concurrency
161+
semaphore := make(chan struct{}, d.maxConcurrency)
162+
var wg sync.WaitGroup
163+
var mu sync.Mutex
164+
var downloadErrors []error
165+
157166
for _, dash := range dashboards {
158-
// Determine absolute dashboard URL for output
159-
dashboardAbsURL, err := url.JoinPath(strings.TrimSuffix(d.grafanaClient.BaseURL(), "/api"), dash.URL)
160-
if err != nil {
161-
// Silently ignore errors
162-
dashboardAbsURL = ""
163-
}
164-
dashboardDefinition, err := d.grafanaClient.GetDashboard(ctx, dash.UID)
165-
if err != nil {
166-
return []output.Dashboard{}, fmt.Errorf("get dashboard %q: %w", dash.UID, err)
167-
}
168-
dashboardOutput := output.Dashboard{
169-
Detections: []output.Detection{},
170-
URL: dashboardAbsURL,
171-
Title: dash.Title,
172-
Folder: dashboardDefinition.Meta.FolderTitle,
173-
CreatedBy: dashboardDefinition.Meta.CreatedBy,
174-
UpdatedBy: dashboardDefinition.Meta.UpdatedBy,
175-
Created: dashboardDefinition.Meta.Created,
176-
Updated: dashboardDefinition.Meta.Updated,
177-
}
178-
dashboardOutput.Detections, err = d.checkPanels(dashboardDefinition, dashboardDefinition.Dashboard.Panels)
179-
if err != nil {
180-
return []output.Dashboard{}, fmt.Errorf("check panels: %w", err)
181-
}
182-
finalOutput = append(finalOutput, dashboardOutput)
167+
wg.Add(1)
168+
go func(dash grafana.ListedDashboard) {
169+
defer wg.Done()
170+
semaphore <- struct{}{} // Acquire semaphore
171+
defer func() { <-semaphore }() // Release semaphore
172+
173+
dashboardAbsURL, err := url.JoinPath(strings.TrimSuffix(d.grafanaClient.BaseURL(), "/api"), dash.URL)
174+
if err != nil {
175+
dashboardAbsURL = ""
176+
}
177+
dashboardDefinition, err := d.grafanaClient.GetDashboard(ctx, dash.UID)
178+
if err != nil {
179+
mu.Lock()
180+
downloadErrors = append(downloadErrors, fmt.Errorf("get dashboard %q: %w", dash.UID, err))
181+
mu.Unlock()
182+
return
183+
}
184+
dashboardOutput := output.Dashboard{
185+
Detections: []output.Detection{},
186+
URL: dashboardAbsURL,
187+
Title: dash.Title,
188+
Folder: dashboardDefinition.Meta.FolderTitle,
189+
CreatedBy: dashboardDefinition.Meta.CreatedBy,
190+
UpdatedBy: dashboardDefinition.Meta.UpdatedBy,
191+
Created: dashboardDefinition.Meta.Created,
192+
Updated: dashboardDefinition.Meta.Updated,
193+
}
194+
dashboardOutput.Detections, err = d.checkPanels(dashboardDefinition, dashboardDefinition.Dashboard.Panels)
195+
if err != nil {
196+
mu.Lock()
197+
downloadErrors = append(downloadErrors, fmt.Errorf("check panels: %w", err))
198+
mu.Unlock()
199+
return
200+
}
201+
mu.Lock()
202+
finalOutput = append(finalOutput, dashboardOutput)
203+
mu.Unlock()
204+
}(dash)
183205
}
206+
207+
wg.Wait()
208+
209+
if len(downloadErrors) > 0 {
210+
return finalOutput, fmt.Errorf("errors occurred during dashboard download: %v", downloadErrors)
211+
}
212+
184213
return finalOutput, nil
185214
}
186215

detector/detector_test.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ import (
1919
func TestDetector(t *testing.T) {
2020
t.Run("meta", func(t *testing.T) {
2121
cl := NewTestAPIClient(filepath.Join("testdata", "dashboards", "graph-old.json"))
22-
d := NewDetector(logger.NewLeveledLogger(false), cl, gcom.NewAPIClient())
22+
d := NewDetector(logger.NewLeveledLogger(false), cl, gcom.NewAPIClient(), 5)
2323
out, err := d.Run(context.Background())
2424
require.NoError(t, err)
2525
require.Len(t, out, 1)
@@ -31,7 +31,7 @@ func TestDetector(t *testing.T) {
3131
require.Equal(t, "2023-11-07T11:13:24+01:00", out[0].Created)
3232
require.Equal(t, "2024-02-21T13:09:27+01:00", out[0].Updated)
3333
})
34-
34+
3535
type expDetection struct {
3636
pluginID string
3737
detectionType output.DetectionType
@@ -112,7 +112,7 @@ func TestDetector(t *testing.T) {
112112
} {
113113
t.Run(tc.name, func(t *testing.T) {
114114
cl := NewTestAPIClient(filepath.Join("testdata", "dashboards", tc.file))
115-
d := NewDetector(logger.NewLeveledLogger(false), cl, gcom.NewAPIClient())
115+
d := NewDetector(logger.NewLeveledLogger(false), cl, gcom.NewAPIClient(), 5)
116116
out, err := d.Run(context.Background())
117117
require.NoError(t, err)
118118
require.Len(t, out, 1, "should have result for one dashboard")

flags/flags.go

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
package flags
2+
3+
import (
4+
"flag"
5+
"time"
6+
)
7+
8+
// Flags holds the command-line flags.
9+
type Flags struct {
10+
Version bool
11+
Verbose bool
12+
JSONOutput bool
13+
SkipTLS bool
14+
Server string
15+
Interval time.Duration
16+
MaxConcurrency int
17+
}
18+
19+
// Parse parses the command-line flags.
20+
func Parse() Flags {
21+
var flags Flags
22+
flag.BoolVar(&flags.Version, "version", false, "print version number")
23+
flag.BoolVar(&flags.Verbose, "v", false, "verbose output")
24+
flag.BoolVar(&flags.JSONOutput, "j", false, "json output")
25+
flag.BoolVar(&flags.SkipTLS, "insecure", false, "skip TLS verification")
26+
flag.DurationVar(&flags.Interval, "interval", 5*time.Minute, "detection refresh interval when running in HTTP server mode")
27+
flag.StringVar(&flags.Server, "server", "", "Run as HTTP server instead of CLI. Value must be a listen address (e.g.: 0.0.0.0:5000. Output is exposed as JSON at /detections.")
28+
flag.IntVar(&flags.MaxConcurrency, "max-concurrency", 10, "maximum number of concurrent dashboard downloads")
29+
flag.Parse()
30+
31+
return flags
32+
}

logger/logger.go

Lines changed: 22 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,16 @@ import (
88
type Logger interface {
99
Log(format string, v ...any)
1010
Warn(format string, v ...any)
11+
Error(format string, v ...any)
12+
Errorf(format string, v ...any)
1113
}
1214

1315
type nopLogger struct{}
1416

15-
func (nopLogger) Log(string, ...any) {}
16-
func (nopLogger) Warn(string, ...any) {}
17+
func (nopLogger) Log(string, ...any) {}
18+
func (nopLogger) Warn(string, ...any) {}
19+
func (nopLogger) Error(string, ...any) {}
20+
func (nopLogger) Errorf(string, ...any) {}
1721

1822
// NewNopLogger returns a new logger whose methods are no-ops and don't log anything.
1923
func NewNopLogger() Logger {
@@ -23,26 +27,36 @@ func NewNopLogger() Logger {
2327
type LeveledLogger struct {
2428
isVerbose bool
2529

26-
Logger *log.Logger
27-
WarnLogger *log.Logger
30+
Logger *log.Logger
31+
WarnLogger *log.Logger
32+
ErrorLogger *log.Logger
2833
}
2934

3035
func NewLeveledLogger(verbose bool) *LeveledLogger {
3136
return &LeveledLogger{
32-
isVerbose: verbose,
33-
Logger: log.New(os.Stdout, "", log.LstdFlags),
34-
WarnLogger: log.New(os.Stderr, "", log.LstdFlags),
37+
isVerbose: verbose,
38+
Logger: log.New(os.Stdout, "INFO: ", log.LstdFlags),
39+
WarnLogger: log.New(os.Stderr, "WARN: ", log.LstdFlags),
40+
ErrorLogger: log.New(os.Stderr, "ERROR: ", log.LstdFlags),
3541
}
3642
}
3743

3844
func (l *LeveledLogger) Log(format string, v ...any) {
39-
log.Printf(format, v...)
45+
l.Logger.Printf(format, v...)
4046
}
4147

4248
func (l *LeveledLogger) Warn(format string, v ...any) {
4349
l.WarnLogger.Printf(format, v...)
4450
}
4551

52+
func (l *LeveledLogger) Error(format string, v ...any) {
53+
l.ErrorLogger.Printf(format, v...)
54+
}
55+
56+
func (l *LeveledLogger) Errorf(format string, v ...any) {
57+
l.ErrorLogger.Printf(format, v...)
58+
}
59+
4660
func (l *LeveledLogger) Verbose() Logger {
4761
if !l.isVerbose {
4862
return NewNopLogger()

0 commit comments

Comments
 (0)