66package cmd
77
88import (
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+
92179func 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