Skip to content

Commit 4168ab9

Browse files
majorgreysclaude
andcommitted
feat(notebooks): add cells append command for cell-level operations
Add `notebooks cells append` subcommand that appends individual cells to an existing notebook using the internal /api/v1/notebooks/{id}/cells endpoint, matching the Datadog MCP server's cell-level operations. Supports three cell types via simplified JSON format: - markdown: text content with Markdown formatting - metric: timeseries graphs with Datadog metrics query language - logs: log stream widgets with Datadog logs query language Cells are validated before any API calls to prevent partial failures. Error messages distinguish between full and partial append failures when sending multiple cells. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 9357896 commit 4168ab9

3 files changed

Lines changed: 602 additions & 4 deletions

File tree

cmd/notebooks.go

Lines changed: 295 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,13 @@
66
package cmd
77

88
import (
9+
"bytes"
910
"encoding/json"
1011
"fmt"
1112
"io"
1213
"os"
1314
"strings"
15+
"time"
1416

1517
"github.com/DataDog/datadog-api-client-go/v2/api/datadogV1"
1618
"github.com/DataDog/pup/pkg/formatter"
@@ -89,6 +91,91 @@ var notebooksDeleteCmd = &cobra.Command{
8991
RunE: runNotebooksDelete,
9092
}
9193

94+
var notebooksCellsCmd = &cobra.Command{
95+
Use: "cells",
96+
Short: "Manage notebook cells",
97+
Long: `Manage individual cells within a Datadog notebook.
98+
99+
Cells are the building blocks of notebooks. Each cell can contain
100+
markdown text, metric graphs, or log streams.`,
101+
}
102+
103+
var notebooksCellsAppendCmd = &cobra.Command{
104+
Use: "append [notebook-id]",
105+
Short: "Append cells to a notebook",
106+
Long: `Append one or more cells to an existing notebook.
107+
108+
Uses a simplified cell format for easy creation. Each cell has a type
109+
(markdown, metric, or logs) and data content.
110+
111+
ARGUMENTS:
112+
notebook-id The numeric notebook ID
113+
114+
FLAGS:
115+
--body Cell definitions in simplified JSON (@filepath or - for stdin)
116+
117+
CELL TYPES:
118+
markdown Text content with Markdown formatting
119+
metric Timeseries graph using Datadog metrics query language
120+
logs Log stream widget using Datadog logs query language
121+
122+
CELL FORMAT:
123+
{
124+
"cells": [
125+
{"type": "markdown", "data": "# Heading\nSome text..."},
126+
{"type": "metric", "data": "avg:system.cpu.user{*}", "title": "CPU Usage"},
127+
{"type": "logs", "data": "service:api status:error", "title": "API Errors"}
128+
]
129+
}
130+
131+
For metric and logs cells, optional time windows can be specified:
132+
"start": "2025-01-01T00:00:00Z"
133+
"end": "2025-01-01T01:00:00Z"
134+
135+
If omitted, metric/logs cells default to the last hour.
136+
137+
EXAMPLES:
138+
# Append a markdown cell from file
139+
pup notebooks cells append 12345 --body @cells.json
140+
141+
# Append from stdin
142+
echo '{"cells":[{"type":"markdown","data":"# New Section"}]}' | \
143+
pup notebooks cells append 12345 --body -
144+
145+
# Append a metric graph
146+
echo '{"cells":[{"type":"metric","data":"avg:system.load.1{*}","title":"Load"}]}' | \
147+
pup notebooks cells append 12345 --body -
148+
149+
AUTHENTICATION:
150+
Requires API key authentication (DD_API_KEY + DD_APP_KEY).`,
151+
Args: cobra.ExactArgs(1),
152+
RunE: runNotebooksCellsAppend,
153+
}
154+
155+
// simpleCellType represents the type of a notebook cell.
156+
type simpleCellType string
157+
158+
const (
159+
simpleCellMarkdown simpleCellType = "markdown"
160+
simpleCellMetric simpleCellType = "metric"
161+
simpleCellLogs simpleCellType = "logs"
162+
)
163+
164+
// simpleCell is a simplified representation of a notebook cell,
165+
// matching the format used by the Datadog MCP server.
166+
type simpleCell struct {
167+
Type simpleCellType `json:"type"`
168+
Data string `json:"data"`
169+
Start string `json:"start,omitempty"`
170+
End string `json:"end,omitempty"`
171+
Title string `json:"title,omitempty"`
172+
}
173+
174+
// simpleCellsBody is the top-level JSON structure for cell input.
175+
type simpleCellsBody struct {
176+
Cells []simpleCell `json:"cells"`
177+
}
178+
92179
func init() {
93180
notebooksCreateCmd.Flags().String("body", "", "JSON body (@filepath or - for stdin) (required)")
94181
if err := notebooksCreateCmd.MarkFlagRequired("body"); err != nil {
@@ -100,7 +187,13 @@ func init() {
100187
panic(fmt.Errorf("failed to mark flag as required: %w", err))
101188
}
102189

103-
notebooksCmd.AddCommand(notebooksListCmd, notebooksGetCmd, notebooksCreateCmd, notebooksUpdateCmd, notebooksDeleteCmd)
190+
notebooksCellsAppendCmd.Flags().String("body", "", "Simplified cell JSON (@filepath or - for stdin) (required)")
191+
if err := notebooksCellsAppendCmd.MarkFlagRequired("body"); err != nil {
192+
panic(fmt.Errorf("failed to mark flag as required: %w", err))
193+
}
194+
195+
notebooksCellsCmd.AddCommand(notebooksCellsAppendCmd)
196+
notebooksCmd.AddCommand(notebooksListCmd, notebooksGetCmd, notebooksCreateCmd, notebooksUpdateCmd, notebooksDeleteCmd, notebooksCellsCmd)
104197
}
105198

106199
// readBody reads JSON body content from a file (@path) or stdin (-).
@@ -267,3 +360,204 @@ func runNotebooksDelete(cmd *cobra.Command, args []string) error {
267360
printOutput("Successfully deleted notebook %d\n", notebookID)
268361
return nil
269362
}
363+
364+
func runNotebooksCellsAppend(cmd *cobra.Command, args []string) error {
365+
notebookID := args[0]
366+
367+
bodyFlag, _ := cmd.Flags().GetString("body")
368+
data, err := readBody(bodyFlag)
369+
if err != nil {
370+
return err
371+
}
372+
373+
var cellsBody simpleCellsBody
374+
if err := json.Unmarshal(data, &cellsBody); err != nil {
375+
return fmt.Errorf("failed to parse cells: %w", err)
376+
}
377+
if len(cellsBody.Cells) == 0 {
378+
return fmt.Errorf("no cells provided")
379+
}
380+
381+
// Validate and build all cell requests before making any API calls
382+
cellReqs := make([]datadogV1.NotebookUpdateCell, len(cellsBody.Cells))
383+
for i, cell := range cellsBody.Cells {
384+
req, err := buildCellRequest(cell)
385+
if err != nil {
386+
return fmt.Errorf("cell %d: %w", i, err)
387+
}
388+
cellReqs[i] = req
389+
}
390+
391+
client, err := getClientForEndpoint("POST", "/api/v1/notebooks/")
392+
if err != nil {
393+
return err
394+
}
395+
396+
path := fmt.Sprintf("/api/v1/notebooks/%s/cells", notebookID)
397+
398+
for i, cellReq := range cellReqs {
399+
400+
reqBody := struct {
401+
Data datadogV1.NotebookUpdateCell `json:"data"`
402+
}{Data: cellReq}
403+
404+
bodyBytes, err := json.Marshal(reqBody)
405+
if err != nil {
406+
return fmt.Errorf("cell %d: failed to marshal request: %w", i, err)
407+
}
408+
409+
resp, err := client.RawRequest("POST", path, bytes.NewReader(bodyBytes))
410+
if err != nil {
411+
if i > 0 {
412+
return fmt.Errorf("partially appended %d of %d cells; cell %d failed: %w", i, len(cellsBody.Cells), i, err)
413+
}
414+
return fmt.Errorf("failed to append cell: %w", err)
415+
}
416+
417+
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
418+
respBody, _ := io.ReadAll(resp.Body)
419+
resp.Body.Close()
420+
if i > 0 {
421+
return fmt.Errorf("partially appended %d of %d cells; cell %d returned status %d: %s", i, len(cellsBody.Cells), i, resp.StatusCode, string(respBody))
422+
}
423+
return fmt.Errorf("failed to append cell (status %d): %s", resp.StatusCode, string(respBody))
424+
}
425+
resp.Body.Close()
426+
}
427+
428+
printOutput("Successfully appended %d cell(s) to notebook %s\n", len(cellsBody.Cells), notebookID)
429+
return nil
430+
}
431+
432+
// buildCellRequest converts a simpleCell into a NotebookUpdateCell for the append API.
433+
func buildCellRequest(cell simpleCell) (datadogV1.NotebookUpdateCell, error) {
434+
switch cell.Type {
435+
case simpleCellMarkdown:
436+
return buildMarkdownCell(cell), nil
437+
case simpleCellMetric:
438+
return buildMetricCell(cell), nil
439+
case simpleCellLogs:
440+
return buildLogsCell(cell), nil
441+
default:
442+
return datadogV1.NotebookUpdateCell{}, fmt.Errorf("unknown cell type %q (expected: markdown, metric, logs)", cell.Type)
443+
}
444+
}
445+
446+
func buildMarkdownCell(cell simpleCell) datadogV1.NotebookUpdateCell {
447+
createReq := datadogV1.NotebookCellCreateRequest{
448+
Attributes: datadogV1.NotebookCellCreateRequestAttributes{
449+
NotebookMarkdownCellAttributes: &datadogV1.NotebookMarkdownCellAttributes{
450+
Definition: datadogV1.NotebookMarkdownCellDefinition{
451+
Text: cell.Data,
452+
Type: datadogV1.NOTEBOOKMARKDOWNCELLDEFINITIONTYPE_MARKDOWN,
453+
},
454+
},
455+
},
456+
Type: datadogV1.NOTEBOOKCELLRESOURCETYPE_NOTEBOOK_CELLS,
457+
}
458+
return datadogV1.NotebookUpdateCell{NotebookCellCreateRequest: &createReq}
459+
}
460+
461+
func buildMetricCell(cell simpleCell) datadogV1.NotebookUpdateCell {
462+
graphSize := datadogV1.NOTEBOOKGRAPHSIZE_MEDIUM
463+
showLegend := true
464+
graphType := datadogV1.TIMESERIESWIDGETDEFINITIONTYPE_TIMESERIES
465+
linearScale := "linear"
466+
line := datadogV1.WIDGETDISPLAYTYPE_LINE
467+
lineType := datadogV1.WIDGETLINETYPE_SOLID
468+
lineWidth := datadogV1.WIDGETLINEWIDTH_NORMAL
469+
palette := "dog_classic"
470+
title := cell.Title
471+
472+
startTime, endTime := parseCellTimes(cell.Start, cell.End)
473+
createReq := datadogV1.NotebookCellCreateRequest{
474+
Attributes: datadogV1.NotebookCellCreateRequestAttributes{
475+
NotebookTimeseriesCellAttributes: &datadogV1.NotebookTimeseriesCellAttributes{
476+
Definition: datadogV1.TimeseriesWidgetDefinition{
477+
Requests: []datadogV1.TimeseriesWidgetRequest{{
478+
DisplayType: &line,
479+
Q: &cell.Data,
480+
Style: &datadogV1.WidgetRequestStyle{
481+
LineType: &lineType,
482+
LineWidth: &lineWidth,
483+
Palette: &palette,
484+
},
485+
}},
486+
ShowLegend: &showLegend,
487+
Type: graphType,
488+
Yaxis: &datadogV1.WidgetAxis{
489+
Scale: &linearScale,
490+
},
491+
Title: &title,
492+
},
493+
GraphSize: &graphSize,
494+
Time: *datadogV1.NewNullableNotebookCellTime(&datadogV1.NotebookCellTime{
495+
NotebookAbsoluteTime: datadogV1.NewNotebookAbsoluteTime(endTime, startTime),
496+
}),
497+
},
498+
},
499+
Type: datadogV1.NOTEBOOKCELLRESOURCETYPE_NOTEBOOK_CELLS,
500+
}
501+
return datadogV1.NotebookUpdateCell{NotebookCellCreateRequest: &createReq}
502+
}
503+
504+
func buildLogsCell(cell simpleCell) datadogV1.NotebookUpdateCell {
505+
graphSize := datadogV1.NOTEBOOKGRAPHSIZE_MEDIUM
506+
messageDisplay := datadogV1.WIDGETMESSAGEDISPLAY_INLINE
507+
showDate := true
508+
showMessage := true
509+
title := "logs"
510+
if cell.Title != "" {
511+
title = cell.Title
512+
}
513+
textAlign := datadogV1.WIDGETTEXTALIGN_LEFT
514+
515+
startTime, endTime := parseCellTimes(cell.Start, cell.End)
516+
createReq := datadogV1.NotebookCellCreateRequest{
517+
Attributes: datadogV1.NotebookCellCreateRequestAttributes{
518+
NotebookLogStreamCellAttributes: &datadogV1.NotebookLogStreamCellAttributes{
519+
Definition: datadogV1.LogStreamWidgetDefinition{
520+
Columns: []string{"timestamp", "host", "service", "message"},
521+
MessageDisplay: &messageDisplay,
522+
Query: &cell.Data,
523+
ShowDateColumn: &showDate,
524+
ShowMessageColumn: &showMessage,
525+
Sort: &datadogV1.WidgetFieldSort{
526+
Column: "timestamp",
527+
Order: datadogV1.WIDGETSORT_ASCENDING,
528+
},
529+
Title: &title,
530+
TitleAlign: &textAlign,
531+
Type: datadogV1.LOGSTREAMWIDGETDEFINITIONTYPE_LOG_STREAM,
532+
},
533+
GraphSize: &graphSize,
534+
Time: *datadogV1.NewNullableNotebookCellTime(&datadogV1.NotebookCellTime{
535+
NotebookAbsoluteTime: datadogV1.NewNotebookAbsoluteTime(endTime, startTime),
536+
}),
537+
},
538+
},
539+
Type: datadogV1.NOTEBOOKCELLRESOURCETYPE_NOTEBOOK_CELLS,
540+
}
541+
return datadogV1.NotebookUpdateCell{NotebookCellCreateRequest: &createReq}
542+
}
543+
544+
// parseCellTimes parses optional ISO 8601 start/end times for cells.
545+
// Defaults to (now - 1h, now) if not specified.
546+
func parseCellTimes(startStr, endStr string) (time.Time, time.Time) {
547+
now := time.Now()
548+
endTime := now
549+
startTime := now.Add(-time.Hour)
550+
551+
if endStr != "" {
552+
if t, err := time.Parse(time.RFC3339, endStr); err == nil {
553+
endTime = t
554+
}
555+
}
556+
if startStr != "" {
557+
if t, err := time.Parse(time.RFC3339, startStr); err == nil {
558+
startTime = t
559+
}
560+
}
561+
562+
return startTime, endTime
563+
}

0 commit comments

Comments
 (0)