-
Notifications
You must be signed in to change notification settings - Fork 321
feat: add canvas dashboard CLI commands #4930
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Closed
Closed
Changes from all commits
Commits
Show all changes
5 commits
Select commit
Hold shift + click to select a range
471c271
feat: add canvas dashboard CLI commands
forestileao 39cfc0b
test: cover dashboard resource parsing
forestileao afadf69
Merge branch 'main' into add-dashboard-skill
forestileao 37e3789
Merge branch 'main' into add-dashboard-skill
forestileao c6753de
fix: normalize dashboard panels/layout in exports
cursoragent File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,130 @@ | ||
| package canvases | ||
|
|
||
| import ( | ||
| "fmt" | ||
| "io" | ||
| "os" | ||
|
|
||
| "github.com/superplanehq/superplane/pkg/cli/commands/canvases/models" | ||
| "github.com/superplanehq/superplane/pkg/cli/core" | ||
| "github.com/superplanehq/superplane/pkg/openapi_client" | ||
| ) | ||
|
|
||
| type dashboardGetCommand struct{} | ||
|
|
||
| type dashboardUpdateCommand struct { | ||
| file *string | ||
| } | ||
|
|
||
| func (c *dashboardGetCommand) Execute(ctx core.CommandContext) error { | ||
| canvasID, err := findCanvasID(ctx, ctx.API, ctx.Args[0]) | ||
| if err != nil { | ||
| return err | ||
| } | ||
|
|
||
| canvas, err := describeCanvas(ctx, canvasID) | ||
| if err != nil { | ||
| return err | ||
| } | ||
|
|
||
| response, _, err := ctx.API.CanvasAPI.CanvasesGetCanvasDashboard(ctx.Context, canvasID).Execute() | ||
| if err != nil { | ||
| return err | ||
| } | ||
| if response == nil || response.Dashboard == nil { | ||
| return fmt.Errorf("dashboard for canvas %q not found", canvasID) | ||
| } | ||
|
|
||
| canvasName := "" | ||
| if canvas.Metadata != nil { | ||
| canvasName = canvas.Metadata.GetName() | ||
| } | ||
|
|
||
| resource := models.DashboardResourceFromDashboard(*response.Dashboard, canvasName) | ||
| if !ctx.Renderer.IsText() { | ||
| return ctx.Renderer.Render(resource) | ||
| } | ||
|
|
||
| return ctx.Renderer.RenderText(func(stdout io.Writer) error { | ||
| _, _ = fmt.Fprintf(stdout, "Canvas ID: %s\n", resource.Metadata.CanvasID) | ||
| if resource.Metadata.Name != "" { | ||
| _, _ = fmt.Fprintf(stdout, "Canvas Name: %s\n", resource.Metadata.Name) | ||
| } | ||
| _, _ = fmt.Fprintf(stdout, "Panels: %d\n", len(resource.Spec.Panels)) | ||
| _, err := fmt.Fprintf(stdout, "Layout Items: %d\n", len(resource.Spec.Layout)) | ||
| return err | ||
| }) | ||
| } | ||
|
|
||
| func (c *dashboardUpdateCommand) Execute(ctx core.CommandContext) error { | ||
| canvasID, err := findCanvasID(ctx, ctx.API, ctx.Args[0]) | ||
| if err != nil { | ||
| return err | ||
| } | ||
|
|
||
| filePath := "" | ||
| if c.file != nil { | ||
| filePath = *c.file | ||
| } | ||
| resource, err := parseDashboardResourceFromFile(filePath) | ||
| if err != nil { | ||
| return err | ||
| } | ||
|
|
||
| body := models.UpdateDashboardRequestFromDashboard(*resource) | ||
| response, _, err := ctx.API.CanvasAPI. | ||
| CanvasesUpdateCanvasDashboard(ctx.Context, canvasID). | ||
| Body(body). | ||
| Execute() | ||
| if err != nil { | ||
| return err | ||
| } | ||
| if response == nil || response.Dashboard == nil { | ||
| return fmt.Errorf("failed to update dashboard: the server returned an empty response") | ||
| } | ||
|
|
||
| rendered := models.DashboardResourceFromDashboard(*response.Dashboard, resource.Metadata.Name) | ||
| if !ctx.Renderer.IsText() { | ||
| return ctx.Renderer.Render(rendered) | ||
| } | ||
|
|
||
| return ctx.Renderer.RenderText(func(stdout io.Writer) error { | ||
| _, _ = fmt.Fprintf(stdout, "Dashboard updated for canvas %s\n", rendered.Metadata.CanvasID) | ||
| _, _ = fmt.Fprintf(stdout, "Panels: %d\n", len(rendered.Spec.Panels)) | ||
| _, err := fmt.Fprintf(stdout, "Layout Items: %d\n", len(rendered.Spec.Layout)) | ||
| return err | ||
| }) | ||
| } | ||
|
|
||
| func parseDashboardResourceFromFile(filePath string) (*models.Dashboard, error) { | ||
| if filePath == "" { | ||
| return nil, fmt.Errorf("dashboard file is required") | ||
| } | ||
|
|
||
| data, err := os.ReadFile(filePath) | ||
| if err != nil { | ||
| return nil, fmt.Errorf("failed to read dashboard file: %w", err) | ||
| } | ||
|
|
||
| _, kind, err := core.ParseYamlResourceHeaders(data) | ||
| if err != nil { | ||
| return nil, err | ||
| } | ||
| if kind != models.DashboardKind { | ||
| return nil, fmt.Errorf("unsupported resource kind %q for dashboard update", kind) | ||
| } | ||
|
|
||
| return models.ParseDashboard(data) | ||
| } | ||
|
|
||
| func describeCanvas(ctx core.CommandContext, canvasID string) (openapi_client.CanvasesCanvas, error) { | ||
| response, _, err := ctx.API.CanvasAPI.CanvasesDescribeCanvas(ctx.Context, canvasID).Execute() | ||
| if err != nil { | ||
| return openapi_client.CanvasesCanvas{}, err | ||
| } | ||
| if response == nil || response.Canvas == nil { | ||
| return openapi_client.CanvasesCanvas{}, fmt.Errorf("canvas %q not found", canvasID) | ||
| } | ||
|
|
||
| return *response.Canvas, nil | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,113 @@ | ||
| package canvases | ||
|
|
||
| import ( | ||
| "encoding/json" | ||
| "net/http" | ||
| "os" | ||
| "path/filepath" | ||
| "testing" | ||
|
|
||
| "github.com/stretchr/testify/require" | ||
| ) | ||
|
|
||
| func TestDashboardGetCommandReturnsYAML(t *testing.T) { | ||
| canvasID := "4e9ae08d-0363-40d2-ba2c-5f6389a418d8" | ||
| server := newAPITestServer( | ||
| t, | ||
| requestExpectation{ | ||
| method: http.MethodGet, | ||
| path: "/api/v1/canvases/" + canvasID, | ||
| handle: func(t *testing.T, w http.ResponseWriter, _ *http.Request) { | ||
| w.Header().Set("Content-Type", "application/json") | ||
| _, _ = w.Write([]byte(`{"canvas":{"metadata":{"id":"` + canvasID + `","name":"deploy"}}}`)) | ||
| }, | ||
| }, | ||
| requestExpectation{ | ||
| method: http.MethodGet, | ||
| path: "/api/v1/canvases/" + canvasID + "/dashboard", | ||
| handle: func(t *testing.T, w http.ResponseWriter, _ *http.Request) { | ||
| w.Header().Set("Content-Type", "application/json") | ||
| _, _ = w.Write([]byte(`{"dashboard":{"canvasId":"` + canvasID + `","panels":[{"id":"p1","type":"markdown","content":{"body":"ok"}}],"layout":[{"i":"p1","x":0,"y":0,"w":4,"h":3}]}}`)) | ||
| }, | ||
| }, | ||
| ) | ||
|
|
||
| ctx, stdout := newCreateCommandContextForTest(t, server.server, "yaml") | ||
| ctx.Args = []string{canvasID} | ||
|
|
||
| err := (&dashboardGetCommand{}).Execute(ctx) | ||
| require.NoError(t, err) | ||
| require.Contains(t, stdout.String(), "kind: Dashboard") | ||
| require.Contains(t, stdout.String(), "canvasId: "+canvasID) | ||
| require.Contains(t, stdout.String(), "name: deploy") | ||
| require.Contains(t, stdout.String(), "type: markdown") | ||
| } | ||
|
|
||
| func TestDashboardUpdateCommandSendsPanelsAndLayout(t *testing.T) { | ||
| canvasID := "4e9ae08d-0363-40d2-ba2c-5f6389a418d8" | ||
| server := newAPITestServer( | ||
| t, | ||
| requestExpectation{ | ||
| method: http.MethodPut, | ||
| path: "/api/v1/canvases/" + canvasID + "/dashboard", | ||
| handle: func(t *testing.T, w http.ResponseWriter, r *http.Request) { | ||
| var body map[string]any | ||
| require.NoError(t, json.NewDecoder(r.Body).Decode(&body)) | ||
| require.Len(t, body["panels"], 1) | ||
| require.Len(t, body["layout"], 1) | ||
|
|
||
| w.Header().Set("Content-Type", "application/json") | ||
| _, _ = w.Write([]byte(`{"dashboard":{"canvasId":"` + canvasID + `","panels":[{"id":"p1","type":"markdown","content":{"title":"Deploy"}}],"layout":[{"i":"p1","x":0,"y":0,"w":4,"h":3}]}}`)) | ||
| }, | ||
| }, | ||
| ) | ||
|
|
||
| filePath := writeTestDashboardFile(t) | ||
| ctx, stdout := newCreateCommandContextForTest(t, server.server, "text") | ||
| ctx.Args = []string{canvasID} | ||
|
|
||
| err := (&dashboardUpdateCommand{file: &filePath}).Execute(ctx) | ||
| require.NoError(t, err) | ||
| require.Contains(t, stdout.String(), "Dashboard updated for canvas "+canvasID) | ||
| require.Contains(t, stdout.String(), "Panels: 1") | ||
| } | ||
|
|
||
| func TestDashboardUpdateCommandRequiresDashboardKind(t *testing.T) { | ||
| dir := t.TempDir() | ||
| filePath := filepath.Join(dir, "canvas.yaml") | ||
| require.NoError(t, os.WriteFile(filePath, []byte("apiVersion: v1\nkind: Canvas\nmetadata: {}\n"), 0o644)) | ||
|
|
||
| ctx, _ := newCreateCommandContextForTest(t, nil, "text") | ||
| ctx.Args = []string{"4e9ae08d-0363-40d2-ba2c-5f6389a418d8"} | ||
|
|
||
| err := (&dashboardUpdateCommand{file: &filePath}).Execute(ctx) | ||
| require.Error(t, err) | ||
| require.Contains(t, err.Error(), `unsupported resource kind "Canvas"`) | ||
| } | ||
|
|
||
| func writeTestDashboardFile(t *testing.T) string { | ||
| t.Helper() | ||
|
|
||
| dir := t.TempDir() | ||
| filePath := filepath.Join(dir, "dashboard.yaml") | ||
| content := []byte(` | ||
| apiVersion: v1 | ||
| kind: Dashboard | ||
| metadata: | ||
| name: Deploy | ||
| spec: | ||
| panels: | ||
| - id: p1 | ||
| type: markdown | ||
| content: | ||
| title: Deploy | ||
| layout: | ||
| - i: p1 | ||
| x: 0 | ||
| y: 0 | ||
| w: 4 | ||
| h: 3 | ||
| `) | ||
| require.NoError(t, os.WriteFile(filePath, content, 0o644)) | ||
| return filePath | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,88 @@ | ||
| package models | ||
|
|
||
| import ( | ||
| "fmt" | ||
|
|
||
| "github.com/superplanehq/superplane/pkg/cli/core" | ||
| "github.com/superplanehq/superplane/pkg/openapi_client" | ||
| ) | ||
|
|
||
| const ( | ||
| DashboardKind = "Dashboard" | ||
| DashboardAPIVersion = "v1" | ||
| ) | ||
|
|
||
| type DashboardMetadata struct { | ||
| CanvasID string `json:"canvasId,omitempty" yaml:"canvasId,omitempty"` | ||
| Name string `json:"name,omitempty" yaml:"name,omitempty"` | ||
| } | ||
|
|
||
| type DashboardSpec struct { | ||
| Panels []openapi_client.CanvasesDashboardPanel `json:"panels" yaml:"panels"` | ||
| Layout []openapi_client.CanvasesDashboardLayoutItem `json:"layout" yaml:"layout"` | ||
| } | ||
|
|
||
| type Dashboard struct { | ||
| APIVersion string `json:"apiVersion" yaml:"apiVersion"` | ||
| Kind string `json:"kind" yaml:"kind"` | ||
| Metadata DashboardMetadata `json:"metadata" yaml:"metadata"` | ||
| Spec DashboardSpec `json:"spec" yaml:"spec"` | ||
| } | ||
|
|
||
| func ParseDashboard(raw []byte) (*Dashboard, error) { | ||
| var resource Dashboard | ||
| if err := core.NewDecoder(raw).DecodeYAML(&resource); err != nil { | ||
| return nil, fmt.Errorf("failed to parse dashboard yaml: %w", err) | ||
| } | ||
|
|
||
| if resource.APIVersion == "" { | ||
| return nil, fmt.Errorf("dashboard apiVersion is required") | ||
| } | ||
| if resource.APIVersion != DashboardAPIVersion { | ||
| return nil, fmt.Errorf("unsupported dashboard apiVersion %q", resource.APIVersion) | ||
| } | ||
| if resource.Kind != DashboardKind { | ||
| return nil, fmt.Errorf("unsupported resource kind %q", resource.Kind) | ||
| } | ||
|
|
||
| if resource.Spec.Panels == nil { | ||
| resource.Spec.Panels = []openapi_client.CanvasesDashboardPanel{} | ||
| } | ||
| if resource.Spec.Layout == nil { | ||
| resource.Spec.Layout = []openapi_client.CanvasesDashboardLayoutItem{} | ||
| } | ||
|
|
||
| return &resource, nil | ||
| } | ||
|
|
||
| func DashboardResourceFromDashboard(dashboard openapi_client.CanvasesCanvasDashboard, canvasName string) Dashboard { | ||
| panels := dashboard.GetPanels() | ||
| if panels == nil { | ||
| panels = []openapi_client.CanvasesDashboardPanel{} | ||
| } | ||
|
|
||
| layout := dashboard.GetLayout() | ||
| if layout == nil { | ||
| layout = []openapi_client.CanvasesDashboardLayoutItem{} | ||
| } | ||
|
|
||
| return Dashboard{ | ||
| APIVersion: DashboardAPIVersion, | ||
| Kind: DashboardKind, | ||
| Metadata: DashboardMetadata{ | ||
| CanvasID: dashboard.GetCanvasId(), | ||
| Name: canvasName, | ||
| }, | ||
| Spec: DashboardSpec{ | ||
| Panels: panels, | ||
| Layout: layout, | ||
| }, | ||
| } | ||
| } | ||
|
|
||
| func UpdateDashboardRequestFromDashboard(resource Dashboard) openapi_client.CanvasesUpdateCanvasDashboardBody { | ||
| body := openapi_client.CanvasesUpdateCanvasDashboardBody{} | ||
| body.SetPanels(resource.Spec.Panels) | ||
| body.SetLayout(resource.Spec.Layout) | ||
| return body | ||
| } | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.