Skip to content

Commit af50a47

Browse files
authored
feat(tool): Add append functionality to dashboard panels (#260)
1 parent d0cbad8 commit af50a47

File tree

2 files changed

+135
-10
lines changed

2 files changed

+135
-10
lines changed

tools/dashboard.go

Lines changed: 44 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ func getDashboardByUID(ctx context.Context, args GetDashboardByUIDParams) (*mode
3232
// PatchOperation represents a single patch operation
3333
type PatchOperation struct {
3434
Op string `json:"op" jsonschema:"required,description=Operation type: 'replace'\\, 'add'\\, 'remove'"`
35-
Path string `json:"path" jsonschema:"required,description=JSONPath to the property to modify. Supports: '$.title'\\, '$.panels[0].title'\\, '$.panels[0].targets[0].expr'\\, '$.panels[1].targets[0].datasource'\\, etc."`
35+
Path string `json:"path" jsonschema:"required,description=JSONPath to the property to modify. Supports: '$.title'\\, '$.panels[0].title'\\, '$.panels[0].targets[0].expr'\\, '$.panels[1].targets[0].datasource'\\, etc. For appending to arrays\\, use '/- ' syntax: '$.panels/- ' (append to panels array) or '$.panels[2]/- ' (append to nested array at index 2)."`
3636
Value interface{} `json:"value,omitempty" jsonschema:"description=New value for replace/add operations"`
3737
}
3838

@@ -140,7 +140,7 @@ var GetDashboardByUID = mcpgrafana.MustTool(
140140

141141
var UpdateDashboard = mcpgrafana.MustTool(
142142
"update_dashboard",
143-
"Create or update a dashboard using either full JSON or efficient patch operations. For new dashboards\\, provide the 'dashboard' field. For updating existing dashboards\\, use 'uid' + 'operations' for better context window efficiency. Patch operations support complex JSONPaths like '$.panels[0].targets[0].expr'\\, '$.panels[1].title'\\, '$.panels[2].targets[0].datasource'\\, etc.",
143+
"Create or update a dashboard using either full JSON or efficient patch operations. For new dashboards\\, provide the 'dashboard' field. For updating existing dashboards\\, use 'uid' + 'operations' for better context window efficiency. Patch operations support complex JSONPaths like '$.panels[0].targets[0].expr'\\, '$.panels[1].title'\\, '$.panels[2].targets[0].datasource'\\, etc. Supports appending to arrays using '/- ' syntax: '$.panels/- ' appends to panels array\\, '$.panels[2]/- ' appends to nested array at index 2.",
144144
updateDashboard,
145145
mcp.WithTitleAnnotation("Create or update dashboard"),
146146
mcp.WithDestructiveHintAnnotation(true),
@@ -405,12 +405,16 @@ func applyJSONPath(data map[string]interface{}, path string, value interface{},
405405

406406
// JSONPathSegment represents a segment of a JSONPath
407407
type JSONPathSegment struct {
408-
Key string
409-
Index int
410-
IsArray bool
408+
Key string
409+
Index int
410+
IsArray bool
411+
IsAppend bool // true when using /- syntax to append to array
411412
}
412413

413414
func (s JSONPathSegment) String() string {
415+
if s.IsAppend {
416+
return fmt.Sprintf("%s/-", s.Key)
417+
}
414418
if s.IsArray {
415419
return fmt.Sprintf("%s[%d]", s.Key, s.Index)
416420
}
@@ -419,6 +423,7 @@ func (s JSONPathSegment) String() string {
419423

420424
// parseJSONPath parses a JSONPath string into segments
421425
// Supports paths like "panels[0].targets[1].expr", "title", "templating.list[0].name"
426+
// Also supports append syntax: "panels/-" or "panels[2]/-"
422427
func parseJSONPath(path string) []JSONPathSegment {
423428
var segments []JSONPathSegment
424429

@@ -427,18 +432,20 @@ func parseJSONPath(path string) []JSONPathSegment {
427432
return segments
428433
}
429434

430-
// Use regex for more robust parsing
431-
re := regexp.MustCompile(`([^.\[\]]+)(?:\[(\d+)\])?`)
435+
// Enhanced regex to handle /- append syntax
436+
// Matches: key, key[index], key/-, key[index]/-
437+
re := regexp.MustCompile(`([^.\[\]\/]+)(?:\[(\d+)\])?(?:(\/-))?`)
432438
matches := re.FindAllStringSubmatch(path, -1)
433439

434440
for _, match := range matches {
435441
if len(match) >= 2 && match[1] != "" {
436442
segment := JSONPathSegment{
437-
Key: match[1],
438-
IsArray: len(match) >= 3 && match[2] != "",
443+
Key: match[1],
444+
IsArray: len(match) >= 3 && match[2] != "",
445+
IsAppend: len(match) >= 4 && match[3] == "/-",
439446
}
440447

441-
if segment.IsArray {
448+
if segment.IsArray && !segment.IsAppend {
442449
if index, err := strconv.Atoi(match[2]); err == nil {
443450
segment.Index = index
444451
}
@@ -458,6 +465,11 @@ func validateArrayAccess(current map[string]interface{}, segment JSONPathSegment
458465
return nil, fmt.Errorf("field '%s' is not an array", segment.Key)
459466
}
460467

468+
// For append operations, we don't need to validate index bounds
469+
if segment.IsAppend {
470+
return arr, nil
471+
}
472+
461473
if segment.Index < 0 || segment.Index >= len(arr) {
462474
return nil, fmt.Errorf("index %d out of bounds for array '%s' (length %d)", segment.Index, segment.Key, len(arr))
463475
}
@@ -467,6 +479,11 @@ func validateArrayAccess(current map[string]interface{}, segment JSONPathSegment
467479

468480
// navigateSegment navigates to the next level in the JSON structure
469481
func navigateSegment(current map[string]interface{}, segment JSONPathSegment) (map[string]interface{}, error) {
482+
// Append operations can only be at the final segment
483+
if segment.IsAppend {
484+
return nil, fmt.Errorf("append operation (/- ) can only be used at the final path segment")
485+
}
486+
470487
if segment.IsArray {
471488
arr, err := validateArrayAccess(current, segment)
472489
if err != nil {
@@ -493,6 +510,19 @@ func navigateSegment(current map[string]interface{}, segment JSONPathSegment) (m
493510

494511
// setAtSegment sets a value at the final segment
495512
func setAtSegment(current map[string]interface{}, segment JSONPathSegment, value interface{}) error {
513+
if segment.IsAppend {
514+
// Handle append operation: add to the end of the array
515+
arr, err := validateArrayAccess(current, segment)
516+
if err != nil {
517+
return err
518+
}
519+
520+
// Append the value to the array
521+
arr = append(arr, value)
522+
current[segment.Key] = arr
523+
return nil
524+
}
525+
496526
if segment.IsArray {
497527
arr, err := validateArrayAccess(current, segment)
498528
if err != nil {
@@ -511,6 +541,10 @@ func setAtSegment(current map[string]interface{}, segment JSONPathSegment, value
511541

512542
// removeAtSegment removes a value at the final segment
513543
func removeAtSegment(current map[string]interface{}, segment JSONPathSegment) error {
544+
if segment.IsAppend {
545+
return fmt.Errorf("cannot use remove operation with append syntax (/- ) at %s", segment.Key)
546+
}
547+
514548
if segment.IsArray {
515549
return fmt.Errorf("cannot remove array element %s[%d] (not supported)", segment.Key, segment.Index)
516550
}

tools/dashboard_test.go

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -349,4 +349,95 @@ func TestDashboardTools(t *testing.T) {
349349
})
350350
require.Error(t, err, "Should fail when no valid parameters provided")
351351
})
352+
353+
t.Run("update dashboard - append to panels array", func(t *testing.T) {
354+
ctx := newTestContext()
355+
356+
// Get our test dashboard
357+
dashboard := getExistingTestDashboard(t, ctx, newTestDashboardName)
358+
359+
// Create a new panel to append
360+
newPanel := map[string]interface{}{
361+
"id": 999,
362+
"title": "New Appended Panel",
363+
"type": "stat",
364+
"targets": []interface{}{
365+
map[string]interface{}{
366+
"expr": "up",
367+
},
368+
},
369+
"gridPos": map[string]interface{}{
370+
"h": 8,
371+
"w": 12,
372+
"x": 0,
373+
"y": 8,
374+
},
375+
}
376+
377+
_, err := updateDashboard(ctx, UpdateDashboardParams{
378+
UID: dashboard.UID,
379+
Operations: []PatchOperation{
380+
{
381+
Op: "add",
382+
Path: "$.panels/-",
383+
Value: newPanel,
384+
},
385+
},
386+
Message: "Appended new panel via /- syntax",
387+
})
388+
require.NoError(t, err)
389+
390+
// Verify the panel was appended
391+
updatedDashboard, err := getDashboardByUID(ctx, GetDashboardByUIDParams{
392+
UID: dashboard.UID,
393+
})
394+
require.NoError(t, err)
395+
396+
dashboardMap, ok := updatedDashboard.Dashboard.(map[string]interface{})
397+
require.True(t, ok, "Dashboard should be a map")
398+
399+
panels, ok := dashboardMap["panels"].([]interface{})
400+
require.True(t, ok, "Panels should be an array")
401+
402+
// Check that the new panel was appended (should be the last panel)
403+
lastPanel, ok := panels[len(panels)-1].(map[string]interface{})
404+
require.True(t, ok, "Last panel should be an object")
405+
assert.Equal(t, "New Appended Panel", lastPanel["title"])
406+
assert.Equal(t, float64(999), lastPanel["id"]) // JSON unmarshaling converts to float64
407+
})
408+
409+
t.Run("update dashboard - remove with append syntax should fail", func(t *testing.T) {
410+
ctx := newTestContext()
411+
412+
dashboard := getExistingTestDashboard(t, ctx, newTestDashboardName)
413+
414+
_, err := updateDashboard(ctx, UpdateDashboardParams{
415+
UID: dashboard.UID,
416+
Operations: []PatchOperation{
417+
{
418+
Op: "remove",
419+
Path: "$.panels/-", // Invalid: remove with append syntax
420+
},
421+
},
422+
})
423+
require.Error(t, err, "Should fail when using remove operation with append syntax")
424+
})
425+
426+
t.Run("update dashboard - append to non-array should fail", func(t *testing.T) {
427+
ctx := newTestContext()
428+
429+
dashboard := getExistingTestDashboard(t, ctx, newTestDashboardName)
430+
431+
_, err := updateDashboard(ctx, UpdateDashboardParams{
432+
UID: dashboard.UID,
433+
Operations: []PatchOperation{
434+
{
435+
Op: "add",
436+
Path: "$.title/-", // Invalid: title is not an array
437+
Value: "Invalid",
438+
},
439+
},
440+
})
441+
require.Error(t, err, "Should fail when trying to append to non-array field")
442+
})
352443
}

0 commit comments

Comments
 (0)