From 4dd8e38e2ed62a8bb18b420dc3f438da58fc493d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Calil?= Date: Mon, 25 May 2026 18:52:28 -0300 Subject: [PATCH] feat: Console at the CLI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: André Calil --- AGENTS.md | 1 + docs/contributing/cli-quickstart.md | 109 ++++ pkg/cli/commands/console/clear.go | 47 ++ pkg/cli/commands/console/clear_test.go | 34 ++ pkg/cli/commands/console/common.go | 315 +++++++++++ pkg/cli/commands/console/data.go | 31 ++ pkg/cli/commands/console/data_runtime.go | 526 ++++++++++++++++++ pkg/cli/commands/console/data_test.go | 128 +++++ pkg/cli/commands/console/export.go | 55 ++ pkg/cli/commands/console/file_io.go | 79 +++ pkg/cli/commands/console/get.go | 116 ++++ pkg/cli/commands/console/get_test.go | 82 +++ pkg/cli/commands/console/helpers_test.go | 137 +++++ pkg/cli/commands/console/import.go | 72 +++ pkg/cli/commands/console/import_test.go | 114 ++++ pkg/cli/commands/console/panels.go | 372 +++++++++++++ pkg/cli/commands/console/panels_test.go | 144 +++++ pkg/cli/commands/console/root.go | 148 +++++ pkg/cli/commands/console/runtime.go | 14 + pkg/cli/commands/console/trigger.go | 40 ++ pkg/cli/commands/console/trigger_runtime.go | 123 ++++ pkg/cli/commands/console/trigger_test.go | 81 +++ pkg/cli/commands/index/dump.go | 12 + pkg/cli/commands/index/dump_test.go | 14 +- pkg/cli/commands/index/index_test.go | 20 + pkg/cli/commands/index/widgets.go | 62 ++- pkg/cli/commands/index/widgets_test.go | 15 +- pkg/cli/commands/widgets/add.go | 148 +++++ pkg/cli/commands/widgets/add_test.go | 132 +++++ pkg/cli/commands/widgets/change_management.go | 134 +++++ pkg/cli/commands/widgets/common.go | 186 +++++++ pkg/cli/commands/widgets/delete.go | 109 ++++ pkg/cli/commands/widgets/delete_test.go | 82 +++ pkg/cli/commands/widgets/get.go | 36 ++ pkg/cli/commands/widgets/get_test.go | 82 +++ pkg/cli/commands/widgets/helpers_test.go | 107 ++++ pkg/cli/commands/widgets/list.go | 73 +++ pkg/cli/commands/widgets/list_test.go | 57 ++ pkg/cli/commands/widgets/root.go | 171 ++++++ pkg/cli/commands/widgets/update.go | 134 +++++ pkg/cli/root.go | 4 + 41 files changed, 4333 insertions(+), 13 deletions(-) create mode 100644 docs/contributing/cli-quickstart.md create mode 100644 pkg/cli/commands/console/clear.go create mode 100644 pkg/cli/commands/console/clear_test.go create mode 100644 pkg/cli/commands/console/common.go create mode 100644 pkg/cli/commands/console/data.go create mode 100644 pkg/cli/commands/console/data_runtime.go create mode 100644 pkg/cli/commands/console/data_test.go create mode 100644 pkg/cli/commands/console/export.go create mode 100644 pkg/cli/commands/console/file_io.go create mode 100644 pkg/cli/commands/console/get.go create mode 100644 pkg/cli/commands/console/get_test.go create mode 100644 pkg/cli/commands/console/helpers_test.go create mode 100644 pkg/cli/commands/console/import.go create mode 100644 pkg/cli/commands/console/import_test.go create mode 100644 pkg/cli/commands/console/panels.go create mode 100644 pkg/cli/commands/console/panels_test.go create mode 100644 pkg/cli/commands/console/root.go create mode 100644 pkg/cli/commands/console/runtime.go create mode 100644 pkg/cli/commands/console/trigger.go create mode 100644 pkg/cli/commands/console/trigger_runtime.go create mode 100644 pkg/cli/commands/console/trigger_test.go create mode 100644 pkg/cli/commands/widgets/add.go create mode 100644 pkg/cli/commands/widgets/add_test.go create mode 100644 pkg/cli/commands/widgets/change_management.go create mode 100644 pkg/cli/commands/widgets/common.go create mode 100644 pkg/cli/commands/widgets/delete.go create mode 100644 pkg/cli/commands/widgets/delete_test.go create mode 100644 pkg/cli/commands/widgets/get.go create mode 100644 pkg/cli/commands/widgets/get_test.go create mode 100644 pkg/cli/commands/widgets/helpers_test.go create mode 100644 pkg/cli/commands/widgets/list.go create mode 100644 pkg/cli/commands/widgets/list_test.go create mode 100644 pkg/cli/commands/widgets/root.go create mode 100644 pkg/cli/commands/widgets/update.go diff --git a/AGENTS.md b/AGENTS.md index eab5b88535..2ad66f3efe 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -34,6 +34,7 @@ - After adding new API endpoints, ensure the new endpoints have their authorization covered in `pkg/authorization/interceptor.go` - For UI component workflow, see [web_src/AGENTS.md](web_src/AGENTS.md) - For new components or triggers, see [docs/contributing/component-implementations.md](docs/contributing/component-implementations.md) +- For the first-time CLI workflow (build, connect, exercise console/widgets), see [docs/contributing/cli-quickstart.md](docs/contributing/cli-quickstart.md) - For component design guidelines and quality standards, see [docs/contributing/component-design.md](docs/contributing/component-design.md) - After updating the proto definitions in protos/, always regenerate them, the OpenAPI spec for the API, and SDKs for the CLI and the UI (requires a running `app` container from `make dev.up`): - `make pb.gen` to regenerate protobuf files diff --git a/docs/contributing/cli-quickstart.md b/docs/contributing/cli-quickstart.md new file mode 100644 index 0000000000..86aa822f89 --- /dev/null +++ b/docs/contributing/cli-quickstart.md @@ -0,0 +1,109 @@ +# CLI Quickstart + +This guide walks through building the SuperPlane CLI locally, authenticating against a development environment, and exercising the new `console` and `widgets` commands end-to-end. Use it as your first-time checklist when verifying CLI changes before opening a PR. + +## 1. Bring up the dev stack + +From the repository root: + +```bash +make dev.up # build dev-base image and start app/db/rabbitmq containers +make dev.setup # install deps, run codegen, migrate superplane_dev +make dev.server # in another terminal: starts air (Go) + Vite (UI) at :8000 +``` + +Verify the API is up: `curl http://localhost:8000/health`. + +## 2. Create an owner account and an API token + +1. Open `http://localhost:8000` and complete the owner setup flow (`OWNER_SETUP_ENABLED=yes` is the default). +2. Once signed in, open **Organization Settings -> Profile** and generate an API token, or create a service account from **Members -> Service Accounts** and copy its one-time token. + +Keep the token handy — it is the only artifact the CLI needs to talk to your local server. + +## 3. Build the CLI + +On Apple Silicon: + +```bash +make cli.build.m1 +``` + +This produces `./build/cli`. On other platforms, run `go build -o build/cli ./cmd/cli` from inside the `app` container, or use `go run ./cmd/cli/main.go ...` for ad hoc invocations. + +## 4. Connect and select a canvas + +```bash +./build/cli connect http://localhost:8000 +./build/cli whoami +./build/cli canvases list +./build/cli canvases active +``` + +After `canvases active`, every command below works without `--canvas-id`. Pass `--canvas-id` explicitly to target a different canvas. + +## 5. Console workflow + +```bash +# Inspect what the canvas Console looks like today. +./build/cli console get +./build/cli console get -o yaml + +# Round-trip the Console as YAML. +./build/cli console export --file console.yaml +./build/cli console import --file console.yaml --yes + +# Manage individual panels without rewriting the whole document. +./build/cli console panels list +./build/cli console panels upsert --file panel.yaml +./build/cli console panels delete --yes + +# Pull live data from a runtime panel and inspect it without leaving the shell. +./build/cli console data + +# Re-run a node from a node panel or row action. +./build/cli console trigger --node --hook run --parameters '{"environment":"prod"}' +``` + +`console import` and `console clear` always replace all panels and layout because the underlying API is replace-all. Use `--yes` to skip the confirmation prompt in scripts. + +## 6. Widgets workflow + +The `widgets` commands operate on `TYPE_WIDGET` canvas nodes (annotations are the first supported widget). They reuse the canvas change-management flow, so a draft is created when one does not exist, updated, then published unless `--draft` is set. + +```bash +# Discover available widget types from the registry. +./build/cli index widgets +./build/cli index widgets --full -o yaml + +# Manage widget instances on the active canvas. +./build/cli widgets list +./build/cli widgets get +./build/cli widgets add --component annotation --name release-notes \ + --text "Deploys after Friday lunch" --color amber --width 320 --height 160 \ + --position-x 200 --position-y 80 +./build/cli widgets update --text "Updated copy" --color blue +./build/cli widgets delete --yes +``` + +`--draft` keeps the change in an unpublished draft (useful when the canvas requires reviewer approval). Without `--draft`, the CLI publishes the draft for you so the change appears immediately in the UI. + +## 7. Confirming the round-trip + +Open the canvas in the browser at `http://localhost:8000/canvases/` (or click through from the canvas list) and confirm: + +- Console panels appear in the same layout you imported. +- Widget annotations show the text, color, and dimensions you set. +- `./build/cli widgets list` and the canvas inspector agree on every TYPE_WIDGET node. + +If the UI does not match, re-run the matching CLI command with `-o yaml` to see exactly what the API returned, and check `make dev.server` logs for backend errors. + +## 8. Run the test suite before opening a PR + +```bash +make format.go +make test PKG_TEST_PACKAGES=./pkg/cli/... +make lint && make check.build.app +``` + +The CLI unit tests for `console` and `widgets` rely on `httptest.Server` mocks, so they do not need a running `app` container — but they do need the codegen step from `make dev.setup` to have completed at least once. diff --git a/pkg/cli/commands/console/clear.go b/pkg/cli/commands/console/clear.go new file mode 100644 index 0000000000..2f970306e7 --- /dev/null +++ b/pkg/cli/commands/console/clear.go @@ -0,0 +1,47 @@ +package console + +import ( + "fmt" + "io" + + "github.com/superplanehq/superplane/pkg/cli/core" + "github.com/superplanehq/superplane/pkg/openapi_client" +) + +type clearCommand struct { + canvasID *string + yes *bool +} + +func (c *clearCommand) Execute(ctx core.CommandContext) error { + canvasID, err := core.ResolveCanvasID(ctx, valueOf(c.canvasID)) + if err != nil { + return err + } + + if !confirmReplace(ctx, c.yes, 0, 0) { + _, err := fmt.Fprintln(ctx.Cmd.OutOrStdout(), "Aborted.") + return err + } + + body := openapi_client.CanvasesUpdateCanvasDashboardBody{} + body.SetPanels([]openapi_client.CanvasesDashboardPanel{}) + body.SetLayout([]openapi_client.CanvasesDashboardLayoutItem{}) + + response, _, err := ctx.API.CanvasAPI. + CanvasesUpdateCanvasDashboard(ctx.Context, canvasID). + Body(body). + Execute() + if err != nil { + return err + } + + if !ctx.Renderer.IsText() { + return ctx.Renderer.Render(response.GetDashboard()) + } + + return ctx.Renderer.RenderText(func(stdout io.Writer) error { + _, err := fmt.Fprintf(stdout, "Console cleared for canvas %s\n", canvasID) + return err + }) +} diff --git a/pkg/cli/commands/console/clear_test.go b/pkg/cli/commands/console/clear_test.go new file mode 100644 index 0000000000..7194974a4f --- /dev/null +++ b/pkg/cli/commands/console/clear_test.go @@ -0,0 +1,34 @@ +package console + +import ( + "encoding/json" + "io" + "net/http" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestClearReplacesPanelsAndLayoutWithEmptyArrays(t *testing.T) { + var captured map[string]any + server := newAPITestServer(t, requestExpectation{ + method: http.MethodPut, + path: "/api/v1/canvases/canvas-123/dashboard", + handle: func(t *testing.T, w http.ResponseWriter, r *http.Request) { + body, err := io.ReadAll(r.Body) + require.NoError(t, err) + require.NoError(t, json.Unmarshal(body, &captured)) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"dashboard": {"panels": [], "layout": []}}`)) + }, + }) + + ctx, _ := newCommandContext(t, server.server, "text") + cmd := &clearCommand{canvasID: stringPtr("canvas-123"), yes: boolPtr(true)} + + require.NoError(t, cmd.Execute(ctx)) + panels, _ := captured["panels"].([]any) + layout, _ := captured["layout"].([]any) + require.Len(t, panels, 0) + require.Len(t, layout, 0) +} diff --git a/pkg/cli/commands/console/common.go b/pkg/cli/commands/console/common.go new file mode 100644 index 0000000000..64134eda56 --- /dev/null +++ b/pkg/cli/commands/console/common.go @@ -0,0 +1,315 @@ +package console + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "io" + "os" + + "github.com/superplanehq/superplane/pkg/cli/core" + "github.com/superplanehq/superplane/pkg/openapi_client" + "gopkg.in/yaml.v3" +) + +// ConsoleKind is the canonical YAML kind for the SuperPlane Console resource. +// +// Console is the user-facing name; the backend still calls this resource +// "Dashboard" internally and on the gRPC/REST API. The CLI keeps the +// user-facing name aligned with the UI and product documentation. +const ConsoleKind = "Console" + +// supportedPanelTypes mirrors `pkg/models/canvas_dashboard_yml.go` and the +// frontend `web_src/src/pages/workflowv2/dashboard/panelTypes.ts`. Updating +// this list requires updating those callers in lockstep. +var supportedPanelTypes = []string{ + "markdown", + "node", + "table", + "chart", + "number", +} + +// ConsoleResourceMetadata is informational only on import. `canvasId` is +// resolved from the active CLI context or `--canvas-id`; the field on the +// resource is preserved on export so files round-trip cleanly. +type ConsoleResourceMetadata struct { + CanvasID string `json:"canvasId,omitempty" yaml:"canvasId,omitempty"` + Name string `json:"name,omitempty" yaml:"name,omitempty"` +} + +// ConsoleResourceSpec carries the panel and layout payload exactly as it is +// stored server-side. Map values are kept as `map[string]any` so panel +// content stays type-agnostic and forward-compatible. +type ConsoleResourceSpec struct { + Panels []consoleResourcePanel `json:"panels" yaml:"panels"` + Layout []consoleResourceLayout `json:"layout" yaml:"layout"` +} + +// ConsoleResource is the canonical YAML representation of a Console. +type ConsoleResource struct { + APIVersion string `json:"apiVersion" yaml:"apiVersion"` + Kind string `json:"kind" yaml:"kind"` + Metadata ConsoleResourceMetadata `json:"metadata,omitempty" yaml:"metadata,omitempty"` + Spec ConsoleResourceSpec `json:"spec" yaml:"spec"` +} + +type consoleResourcePanel struct { + ID string `json:"id" yaml:"id"` + Type string `json:"type" yaml:"type"` + Content map[string]any `json:"content,omitempty" yaml:"content,omitempty"` +} + +type consoleResourceLayout struct { + I string `json:"i" yaml:"i"` + X int32 `json:"x" yaml:"x"` + Y int32 `json:"y" yaml:"y"` + W int32 `json:"w" yaml:"w"` + H int32 `json:"h" yaml:"h"` + MinW *int32 `json:"minW,omitempty" yaml:"minW,omitempty"` + MinH *int32 `json:"minH,omitempty" yaml:"minH,omitempty"` +} + +// dashboardToResource converts the API Console (Dashboard) shape into the +// CLI YAML resource. Panels and layout are preserved as-is so that what is +// written matches what the API returns. +func dashboardToResource(dashboard openapi_client.CanvasesCanvasDashboard, canvasName string) ConsoleResource { + resource := ConsoleResource{ + APIVersion: core.APIVersion, + Kind: ConsoleKind, + Metadata: ConsoleResourceMetadata{ + CanvasID: dashboard.GetCanvasId(), + Name: canvasName, + }, + Spec: ConsoleResourceSpec{ + Panels: make([]consoleResourcePanel, 0, len(dashboard.GetPanels())), + Layout: make([]consoleResourceLayout, 0, len(dashboard.GetLayout())), + }, + } + + for _, panel := range dashboard.GetPanels() { + resource.Spec.Panels = append(resource.Spec.Panels, consoleResourcePanel{ + ID: panel.GetId(), + Type: panel.GetType(), + Content: panel.GetContent(), + }) + } + + for _, item := range dashboard.GetLayout() { + layout := consoleResourceLayout{ + I: item.GetI(), + X: item.GetX(), + Y: item.GetY(), + W: item.GetW(), + H: item.GetH(), + } + if item.HasMinW() { + minW := item.GetMinW() + layout.MinW = &minW + } + if item.HasMinH() { + minH := item.GetMinH() + layout.MinH = &minH + } + resource.Spec.Layout = append(resource.Spec.Layout, layout) + } + + return resource +} + +// resourceToUpdateBody converts the CLI resource into the API update body. +// Empty slices are sent so the API replace-all semantics produce a clean +// state when callers explicitly clear panels or layout. +func resourceToUpdateBody(resource ConsoleResource) openapi_client.CanvasesUpdateCanvasDashboardBody { + body := openapi_client.CanvasesUpdateCanvasDashboardBody{} + body.SetPanels(resourcePanelsToAPI(resource.Spec.Panels)) + body.SetLayout(resourceLayoutToAPI(resource.Spec.Layout)) + return body +} + +func resourcePanelsToAPI(panels []consoleResourcePanel) []openapi_client.CanvasesDashboardPanel { + out := make([]openapi_client.CanvasesDashboardPanel, 0, len(panels)) + for _, panel := range panels { + apiPanel := openapi_client.CanvasesDashboardPanel{} + apiPanel.SetId(panel.ID) + apiPanel.SetType(panel.Type) + if panel.Content != nil { + apiPanel.SetContent(panel.Content) + } + out = append(out, apiPanel) + } + return out +} + +func resourceLayoutToAPI(layout []consoleResourceLayout) []openapi_client.CanvasesDashboardLayoutItem { + out := make([]openapi_client.CanvasesDashboardLayoutItem, 0, len(layout)) + for _, item := range layout { + apiItem := openapi_client.CanvasesDashboardLayoutItem{} + apiItem.SetI(item.I) + apiItem.SetX(item.X) + apiItem.SetY(item.Y) + apiItem.SetW(item.W) + apiItem.SetH(item.H) + if item.MinW != nil { + apiItem.SetMinW(*item.MinW) + } + if item.MinH != nil { + apiItem.SetMinH(*item.MinH) + } + out = append(out, apiItem) + } + return out +} + +// resourceFromInput reads YAML from either a file path or stdin (`-`), +// validates it as a Console resource, and returns a parsed ConsoleResource. +func resourceFromInput(path string, stdin io.Reader) (*ConsoleResource, error) { + if path == "" { + return nil, errors.New("--file is required (use - to read from stdin)") + } + + var data []byte + var err error + if path == "-" { + data, err = io.ReadAll(stdin) + if err != nil { + return nil, fmt.Errorf("failed to read from stdin: %w", err) + } + } else { + // #nosec G304 - file path is supplied by the CLI user. + data, err = os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("failed to read console file: %w", err) + } + } + + apiVersion, kind, err := core.ParseYamlResourceHeaders(data) + if err != nil { + return nil, err + } + if apiVersion != core.APIVersion { + return nil, fmt.Errorf("unsupported apiVersion %q (expected %q)", apiVersion, core.APIVersion) + } + if kind != ConsoleKind { + return nil, fmt.Errorf("unsupported resource kind %q (expected %q)", kind, ConsoleKind) + } + + resource := ConsoleResource{} + if err := core.NewDecoder(data).DecodeYAML(&resource); err != nil { + return nil, fmt.Errorf("failed to parse console resource: %w", err) + } + + if err := validateConsoleResource(&resource); err != nil { + return nil, err + } + + return &resource, nil +} + +// validateConsoleResource enforces the same panel/layout invariants the +// backend uses on import (panel IDs unique, layout entries reference panels, +// types are in the supported set). Detailed per-type content validation is +// intentionally left to the API so the CLI does not drift from the server. +func validateConsoleResource(resource *ConsoleResource) error { + if resource == nil { + return errors.New("console resource is empty") + } + + panelIDs := make(map[string]struct{}, len(resource.Spec.Panels)) + for _, panel := range resource.Spec.Panels { + if panel.ID == "" { + return errors.New("panel id is required") + } + if panel.Type == "" { + return fmt.Errorf("panel %q type is required", panel.ID) + } + if !panelTypeIsSupported(panel.Type) { + return fmt.Errorf("panel %q has unsupported type %q (supported: %s)", panel.ID, panel.Type, joinStrings(supportedPanelTypes, ", ")) + } + if _, exists := panelIDs[panel.ID]; exists { + return fmt.Errorf("duplicate panel id %q", panel.ID) + } + panelIDs[panel.ID] = struct{}{} + } + + layoutIDs := make(map[string]struct{}, len(resource.Spec.Layout)) + for _, item := range resource.Spec.Layout { + if item.I == "" { + return errors.New("layout item i is required") + } + if _, exists := layoutIDs[item.I]; exists { + return fmt.Errorf("duplicate layout id %q", item.I) + } + layoutIDs[item.I] = struct{}{} + if _, ok := panelIDs[item.I]; !ok { + return fmt.Errorf("layout item %q does not reference any panel", item.I) + } + if item.W <= 0 || item.H <= 0 { + return fmt.Errorf("layout item %q must have positive width and height", item.I) + } + if item.X < 0 || item.Y < 0 { + return fmt.Errorf("layout item %q must have non-negative x and y", item.I) + } + } + + return nil +} + +// renderConsoleResourceYAML serializes a Console resource into stable YAML +// suitable for writing to disk. The output round-trips through json so map +// keys are sorted in insertion-friendly order, matching the canonical +// behavior used by the API. +func renderConsoleResourceYAML(resource ConsoleResource) ([]byte, error) { + jsonBytes, err := json.Marshal(resource) + if err != nil { + return nil, fmt.Errorf("failed to serialize console: %w", err) + } + + var generic any + if err := json.Unmarshal(jsonBytes, &generic); err != nil { + return nil, fmt.Errorf("failed to serialize console: %w", err) + } + + var buf bytes.Buffer + encoder := yaml.NewEncoder(&buf) + encoder.SetIndent(2) + if err := encoder.Encode(generic); err != nil { + return nil, fmt.Errorf("failed to encode console yaml: %w", err) + } + if err := encoder.Close(); err != nil { + return nil, fmt.Errorf("failed to encode console yaml: %w", err) + } + return buf.Bytes(), nil +} + +func panelTypeIsSupported(panelType string) bool { + for _, t := range supportedPanelTypes { + if t == panelType { + return true + } + } + return false +} + +func joinStrings(values []string, separator string) string { + out := "" + for i, v := range values { + if i > 0 { + out += separator + } + out += v + } + return out +} + +// findCanvasName looks up the canvas name for export metadata. The error is +// non-fatal — when name resolution fails the export still succeeds with an +// empty `metadata.name`, matching the UI behavior. +func findCanvasName(ctx core.CommandContext, canvasID string) string { + response, _, err := ctx.API.CanvasAPI.CanvasesDescribeCanvas(ctx.Context, canvasID).Execute() + if err != nil || response.Canvas == nil || response.Canvas.Metadata == nil { + return "" + } + return response.Canvas.Metadata.GetName() +} diff --git a/pkg/cli/commands/console/data.go b/pkg/cli/commands/console/data.go new file mode 100644 index 0000000000..282ab3614d --- /dev/null +++ b/pkg/cli/commands/console/data.go @@ -0,0 +1,31 @@ +package console + +import ( + "github.com/spf13/cobra" + "github.com/superplanehq/superplane/pkg/cli/core" +) + +// addDataCommand wires `superplane console data`. The implementation is in +// data_runtime.go to keep the data-source resolution logic separate from +// the Cobra wiring. +func addDataCommand(root *cobra.Command, options core.BindOptions) { + canvasID := "" + limit := int64(0) + cmd := &cobra.Command{ + Use: "data ", + Short: "Fetch the runtime data backing a Console panel", + Long: `Fetch the runtime data backing a Console panel. + +This command reads the panel's data source and returns the rows or +computed value the UI would render. Supported sources are memory, +executions, and runs (matching the panel types defined by the API). +Markdown and node panels do not have a data source and produce a +descriptive error instead.`, + Args: cobra.ExactArgs(1), + } + cmd.Flags().StringVar(&canvasID, "canvas-id", "", "canvas id (defaults to the active canvas)") + cmd.Flags().Int64Var(&limit, "limit", 0, "override the panel data source limit (0 keeps the panel's configured limit)") + core.Bind(cmd, &dataCommand{canvasID: &canvasID, limit: &limit}, options) + + root.AddCommand(cmd) +} diff --git a/pkg/cli/commands/console/data_runtime.go b/pkg/cli/commands/console/data_runtime.go new file mode 100644 index 0000000000..abcde3558d --- /dev/null +++ b/pkg/cli/commands/console/data_runtime.go @@ -0,0 +1,526 @@ +package console + +import ( + "encoding/json" + "fmt" + "io" + "strings" + "text/tabwriter" + "time" + + "github.com/superplanehq/superplane/pkg/cli/core" + "github.com/superplanehq/superplane/pkg/openapi_client" +) + +const ( + defaultDataSourceLimit = 50 + maxExecutionsPagesPerCommand = 20 + executionsPageSize = 25 +) + +type dataCommand struct { + canvasID *string + limit *int64 +} + +// Execute fetches the runtime data backing a Console panel. +// +// The supported panel types are `table`, `chart`, `number`. Each one stores +// a `dataSource` block on the panel content. We resolve that block server- +// side using the same APIs the UI uses (memory, runs, events with embedded +// executions) and emit the rows as JSON/YAML, or a text-friendly summary. +// +// Markdown and node panels do not have a data source; we surface a clear +// error rather than silently returning an empty list. +func (c *dataCommand) Execute(ctx core.CommandContext) error { + canvasID, err := core.ResolveCanvasID(ctx, valueOf(c.canvasID)) + if err != nil { + return err + } + + if len(ctx.Args) == 0 { + return fmt.Errorf("panel id is required") + } + panelID := ctx.Args[0] + + dashboard, err := fetchDashboard(ctx, canvasID) + if err != nil { + return err + } + + panel, ok := findPanel(dashboard, panelID) + if !ok { + return fmt.Errorf("panel %q not found", panelID) + } + + dataSource, err := dataSourceFromPanel(panel) + if err != nil { + return err + } + + overrideLimit := int64(0) + if c.limit != nil { + overrideLimit = *c.limit + } + + rows, summary, err := fetchPanelData(ctx, canvasID, dataSource, overrideLimit) + if err != nil { + return err + } + + if !ctx.Renderer.IsText() { + return ctx.Renderer.Render(map[string]any{ + "panelId": panel.GetId(), + "type": panel.GetType(), + "dataSource": dataSource, + "summary": summary, + "rows": rows, + }) + } + + return ctx.Renderer.RenderText(func(stdout io.Writer) error { + return renderPanelDataText(stdout, panel, dataSource, summary, rows) + }) +} + +// panelDataSummary captures totals reported by the API alongside the data. +// +// `totalCount` is populated only for sources that report it (currently +// `runs`); other sources leave it at zero so the renderer omits it. +type panelDataSummary struct { + Source string `json:"source"` + RowCount int `json:"rowCount"` + TotalCount int `json:"totalCount,omitempty"` +} + +func findPanel(dashboard openapi_client.CanvasesCanvasDashboard, panelID string) (openapi_client.CanvasesDashboardPanel, bool) { + for _, panel := range dashboard.GetPanels() { + if panel.GetId() == panelID { + return panel, true + } + } + return openapi_client.CanvasesDashboardPanel{}, false +} + +// dataSourceFromPanel pulls the `dataSource` block off a panel's content. +// Markdown/node panels return a clear error instead of an empty source so +// the user knows why the command refuses. +func dataSourceFromPanel(panel openapi_client.CanvasesDashboardPanel) (map[string]any, error) { + switch panel.GetType() { + case "markdown": + return nil, fmt.Errorf("panel %q is a markdown panel and has no data source", panel.GetId()) + case "node": + return nil, fmt.Errorf("panel %q is a node panel; use 'superplane console trigger --node ' to invoke its run hook", panel.GetId()) + } + + content := panel.GetContent() + if content == nil { + return nil, fmt.Errorf("panel %q has no content", panel.GetId()) + } + rawDataSource, ok := content["dataSource"] + if !ok { + return nil, fmt.Errorf("panel %q has no dataSource block", panel.GetId()) + } + ds, ok := rawDataSource.(map[string]any) + if !ok { + return nil, fmt.Errorf("panel %q dataSource must be an object", panel.GetId()) + } + return ds, nil +} + +func fetchPanelData( + ctx core.CommandContext, + canvasID string, + dataSource map[string]any, + overrideLimit int64, +) ([]map[string]any, panelDataSummary, error) { + kind, _ := dataSource["kind"].(string) + switch kind { + case "memory": + return fetchMemoryRows(ctx, canvasID, dataSource) + case "runs": + return fetchRunRows(ctx, canvasID, dataSource, overrideLimit) + case "executions": + return fetchExecutionRows(ctx, canvasID, dataSource, overrideLimit) + default: + return nil, panelDataSummary{}, fmt.Errorf("unsupported dataSource.kind %q", kind) + } +} + +// fetchMemoryRows returns the entries for a single memory namespace, then +// flattens `fieldPath` like the UI does (lists are spread, scalars become +// single-row entries) so the output matches what the user sees in the UI. +func fetchMemoryRows( + ctx core.CommandContext, + canvasID string, + dataSource map[string]any, +) ([]map[string]any, panelDataSummary, error) { + namespace, _ := dataSource["namespace"].(string) + if namespace == "" { + return nil, panelDataSummary{}, fmt.Errorf("dataSource.namespace is required for memory sources") + } + fieldPath, _ := dataSource["fieldPath"].(string) + + response, _, err := ctx.API.CanvasAPI.CanvasesListCanvasMemories(ctx.Context, canvasID).Execute() + if err != nil { + return nil, panelDataSummary{}, err + } + + rows := []map[string]any{} + for _, memory := range response.GetItems() { + if memory.GetNamespace() != namespace { + continue + } + expanded := flattenMemoryEntry(memory.GetValues(), fieldPath) + rows = append(rows, expanded...) + } + + return rows, panelDataSummary{Source: "memory", RowCount: len(rows)}, nil +} + +// flattenMemoryEntry mirrors the frontend `flattenMemoryEntries` helper. +// +// When `fieldPath` is empty, the memory record is returned verbatim. When +// `fieldPath` resolves to a list, each element becomes its own row. When it +// resolves to a scalar/object, a single-row list with the value at `value` +// is returned for downstream renderers. +func flattenMemoryEntry(values map[string]any, fieldPath string) []map[string]any { + if fieldPath == "" { + return []map[string]any{values} + } + + resolved := resolveFieldPath(values, fieldPath) + if resolved == nil { + return []map[string]any{} + } + + if list, ok := resolved.([]any); ok { + rows := make([]map[string]any, 0, len(list)) + for _, entry := range list { + if asMap, ok := entry.(map[string]any); ok { + rows = append(rows, asMap) + continue + } + rows = append(rows, map[string]any{"value": entry}) + } + return rows + } + + if asMap, ok := resolved.(map[string]any); ok { + return []map[string]any{asMap} + } + + return []map[string]any{{"value": resolved}} +} + +// resolveFieldPath walks a dotted path against a map. Numeric segments are +// treated as list indices so paths like "items.0.name" work without +// requiring users to learn a custom syntax. +func resolveFieldPath(value any, fieldPath string) any { + current := value + for _, segment := range strings.Split(fieldPath, ".") { + segment = strings.TrimSpace(segment) + if segment == "" { + continue + } + switch typed := current.(type) { + case map[string]any: + current = typed[segment] + case []any: + idx := -1 + fmt.Sscanf(segment, "%d", &idx) + if idx < 0 || idx >= len(typed) { + return nil + } + current = typed[idx] + default: + return nil + } + } + return current +} + +func fetchRunRows( + ctx core.CommandContext, + canvasID string, + dataSource map[string]any, + overrideLimit int64, +) ([]map[string]any, panelDataSummary, error) { + limit := resolveLimit(dataSource["limit"], overrideLimit) + + response, _, err := ctx.API.CanvasRunAPI. + CanvasesListRuns(ctx.Context, canvasID). + Limit(limit). + Execute() + if err != nil { + return nil, panelDataSummary{}, err + } + + rows := make([]map[string]any, 0, len(response.GetRuns())) + for _, run := range response.GetRuns() { + row, err := structToMap(run) + if err != nil { + return nil, panelDataSummary{}, err + } + rows = append(rows, row) + if int64(len(rows)) >= limit { + break + } + } + + totalCount := 0 + if response.HasTotalCount() { + totalCount = int(response.GetTotalCount()) + } + + return rows, panelDataSummary{Source: "runs", RowCount: len(rows), TotalCount: totalCount}, nil +} + +// fetchExecutionRows iterates through the events endpoint, collecting and +// filtering executions until the requested limit is satisfied or +// `maxExecutionsPagesPerCommand` is reached. The page-bound mirrors the +// UI's eager pagination cap (also 20 pages of 25 events each). +func fetchExecutionRows( + ctx core.CommandContext, + canvasID string, + dataSource map[string]any, + overrideLimit int64, +) ([]map[string]any, panelDataSummary, error) { + limit := resolveLimit(dataSource["limit"], overrideLimit) + targetNode, _ := dataSource["node"].(string) + targetNodeID := targetNode + + if targetNode != "" { + resolved, err := resolveNodeID(ctx, canvasID, targetNode) + if err == nil && resolved != "" { + targetNodeID = resolved + } + } + + rows := []map[string]any{} + var before *time.Time + for page := 0; page < maxExecutionsPagesPerCommand; page++ { + req := ctx.API.CanvasEventAPI. + CanvasesListCanvasEvents(ctx.Context, canvasID). + Limit(executionsPageSize) + if before != nil { + req = req.Before(*before) + } + + response, _, err := req.Execute() + if err != nil { + return nil, panelDataSummary{}, err + } + + for _, event := range response.GetEvents() { + for _, exec := range event.GetExecutions() { + if targetNodeID != "" && exec.GetNodeId() != targetNodeID { + continue + } + row, err := structToMap(exec) + if err != nil { + return nil, panelDataSummary{}, err + } + row["status"] = deriveExecutionStatus(string(exec.GetState()), string(exec.GetResult())) + if exec.HasUpdatedAt() && exec.HasCreatedAt() { + row["durationMs"] = exec.GetUpdatedAt().Sub(exec.GetCreatedAt()).Milliseconds() + } + rows = append(rows, row) + if int64(len(rows)) >= limit { + return rows, panelDataSummary{Source: "executions", RowCount: len(rows)}, nil + } + } + } + + if !response.GetHasNextPage() { + break + } + last, ok := response.GetLastTimestampOk() + if !ok || last == nil { + break + } + before = last + } + + return rows, panelDataSummary{Source: "executions", RowCount: len(rows)}, nil +} + +// deriveExecutionStatus mirrors the lowercase status vocabulary the UI uses +// (`passed`/`failed`/`cancelled`/`running`/`pending`/`unknown`) so CLI rows +// match what users see in panels. +func deriveExecutionStatus(state, result string) string { + switch state { + case "STATE_PENDING": + return "pending" + case "STATE_STARTED": + return "running" + case "STATE_FINISHED": + switch result { + case "RESULT_PASSED": + return "passed" + case "RESULT_FAILED": + return "failed" + case "RESULT_CANCELLED": + return "cancelled" + } + } + return "unknown" +} + +// resolveLimit honors the override flag, falling back to the panel-level +// limit, and finally to a sensible default (50) matching the UI when +// neither is set. +func resolveLimit(rawPanelLimit any, override int64) int64 { + if override > 0 { + return override + } + switch v := rawPanelLimit.(type) { + case float64: + if v > 0 { + return int64(v) + } + case int: + if v > 0 { + return int64(v) + } + case int64: + if v > 0 { + return v + } + } + return defaultDataSourceLimit +} + +// resolveNodeID accepts either a node id or a node name and returns the id. +// When the node cannot be resolved we return the original input so the +// caller can match by string equality (for canvases that use the user- +// facing name on event records). +func resolveNodeID(ctx core.CommandContext, canvasID, nameOrID string) (string, error) { + if strings.TrimSpace(nameOrID) == "" { + return "", nil + } + + response, _, err := ctx.API.CanvasAPI.CanvasesDescribeCanvas(ctx.Context, canvasID).Execute() + if err != nil { + return nameOrID, err + } + if response.Canvas == nil || response.Canvas.Spec == nil { + return nameOrID, nil + } + for _, node := range response.Canvas.Spec.GetNodes() { + if node.GetId() == nameOrID || node.GetName() == nameOrID { + return node.GetId(), nil + } + } + return nameOrID, nil +} + +// structToMap is a small helper that round-trips an OpenAPI model through +// JSON so the CLI can render it with predictable map-shaped output without +// pulling in reflection-heavy dependencies. +func structToMap(value any) (map[string]any, error) { + bytes, err := json.Marshal(value) + if err != nil { + return nil, err + } + out := map[string]any{} + if err := json.Unmarshal(bytes, &out); err != nil { + return nil, err + } + return out, nil +} + +func renderPanelDataText( + stdout io.Writer, + panel openapi_client.CanvasesDashboardPanel, + dataSource map[string]any, + summary panelDataSummary, + rows []map[string]any, +) error { + if _, err := fmt.Fprintf(stdout, "Panel: %s (%s)\n", panel.GetId(), panel.GetType()); err != nil { + return err + } + if _, err := fmt.Fprintf(stdout, "Source: %s\n", summary.Source); err != nil { + return err + } + if summary.TotalCount > 0 { + if _, err := fmt.Fprintf(stdout, "Total: %d\n", summary.TotalCount); err != nil { + return err + } + } + if _, err := fmt.Fprintf(stdout, "Rows: %d\n", summary.RowCount); err != nil { + return err + } + + if len(rows) == 0 { + _, err := fmt.Fprintln(stdout, "(no data)") + return err + } + + columns := pickPreviewColumns(rows[0]) + if _, err := fmt.Fprintln(stdout); err != nil { + return err + } + writer := tabwriter.NewWriter(stdout, 0, 8, 2, ' ', 0) + _, _ = fmt.Fprintln(writer, strings.Join(columns, "\t")) + for _, row := range rows { + values := make([]string, 0, len(columns)) + for _, column := range columns { + values = append(values, formatCellValue(row[column])) + } + _, _ = fmt.Fprintln(writer, strings.Join(values, "\t")) + } + return writer.Flush() +} + +// pickPreviewColumns chooses a stable, friendly set of columns for text +// preview output. We bias toward the keys most relevant for status (`id`, +// `state`, `status`, etc.) but fall back to the first few keys so unknown +// payloads still render usefully. +func pickPreviewColumns(row map[string]any) []string { + preferred := []string{"id", "nodeId", "nodeName", "name", "state", "result", "status", "createdAt", "value"} + chosen := make([]string, 0, 6) + seen := make(map[string]struct{}) + for _, key := range preferred { + if _, ok := row[key]; ok { + chosen = append(chosen, key) + seen[key] = struct{}{} + } + if len(chosen) >= 6 { + return chosen + } + } + for key := range row { + if _, alreadyChosen := seen[key]; alreadyChosen { + continue + } + chosen = append(chosen, key) + if len(chosen) >= 6 { + return chosen + } + } + return chosen +} + +func formatCellValue(value any) string { + if value == nil { + return "" + } + switch v := value.(type) { + case string: + return v + case bool: + return fmt.Sprintf("%t", v) + case float64: + if v == float64(int64(v)) { + return fmt.Sprintf("%d", int64(v)) + } + return fmt.Sprintf("%g", v) + default: + bytes, err := json.Marshal(v) + if err != nil { + return fmt.Sprintf("%v", v) + } + return string(bytes) + } +} diff --git a/pkg/cli/commands/console/data_test.go b/pkg/cli/commands/console/data_test.go new file mode 100644 index 0000000000..9ec3a6bf00 --- /dev/null +++ b/pkg/cli/commands/console/data_test.go @@ -0,0 +1,128 @@ +package console + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestDataReturnsMemoryRowsFromAPI(t *testing.T) { + server := newRouteAPITestServer(t, map[string]string{ + "/api/v1/canvases/canvas-123/dashboard": `{ + "dashboard": { + "panels": [{ + "id": "p1", + "type": "table", + "content": { + "dataSource": {"kind": "memory", "namespace": "users"}, + "render": {"kind": "table", "columns": [{"field": "id"}]} + } + }] + } + }`, + "/api/v1/canvases/canvas-123/memory": `{ + "items": [ + {"namespace": "users", "values": {"id": "u1", "name": "Alice"}}, + {"namespace": "other", "values": {"id": "x"}} + ] + }`, + }) + + ctx, stdout := newCommandContext(t, server, "json") + ctx.Args = []string{"p1"} + cmd := &dataCommand{canvasID: stringPtr("canvas-123"), limit: int64Ptr(0)} + + require.NoError(t, cmd.Execute(ctx)) + + out := stdout.String() + require.Contains(t, out, `"source": "memory"`) + require.Contains(t, out, `"id": "u1"`) + require.Contains(t, out, `"name": "Alice"`) + require.NotContains(t, out, `"id": "x"`) +} + +func TestDataReturnsRunsRowsFromAPI(t *testing.T) { + server := newRouteAPITestServer(t, map[string]string{ + "/api/v1/canvases/canvas-123/dashboard": `{ + "dashboard": { + "panels": [{ + "id": "p1", + "type": "table", + "content": { + "dataSource": {"kind": "runs", "limit": 5}, + "render": {"kind": "table", "columns": [{"field": "id"}]} + } + }] + } + }`, + "/api/v1/canvases/canvas-123/runs": `{ + "runs": [ + {"id": "r1", "state": "STATE_FINISHED"}, + {"id": "r2", "state": "STATE_STARTED"} + ], + "totalCount": 2, + "hasNextPage": false + }`, + }) + + ctx, stdout := newCommandContext(t, server, "json") + ctx.Args = []string{"p1"} + cmd := &dataCommand{canvasID: stringPtr("canvas-123"), limit: int64Ptr(0)} + + require.NoError(t, cmd.Execute(ctx)) + out := stdout.String() + require.Contains(t, out, `"source": "runs"`) + require.Contains(t, out, `"id": "r1"`) + require.Contains(t, out, `"id": "r2"`) + require.Contains(t, out, `"totalCount": 2`) +} + +func TestDataReportsMissingDataSourceForMarkdownPanel(t *testing.T) { + server := newRouteAPITestServer(t, map[string]string{ + "/api/v1/canvases/canvas-123/dashboard": `{ + "dashboard": { + "panels": [{"id": "md", "type": "markdown", "content": {"title": "Notes"}}] + } + }`, + }) + + ctx, _ := newCommandContext(t, server, "text") + ctx.Args = []string{"md"} + cmd := &dataCommand{canvasID: stringPtr("canvas-123"), limit: int64Ptr(0)} + + err := cmd.Execute(ctx) + require.Error(t, err) + require.Contains(t, err.Error(), "no data source") +} + +func TestDataFlattensMemoryFieldPath(t *testing.T) { + server := newRouteAPITestServer(t, map[string]string{ + "/api/v1/canvases/canvas-123/dashboard": `{ + "dashboard": { + "panels": [{ + "id": "p1", + "type": "table", + "content": { + "dataSource": {"kind": "memory", "namespace": "events", "fieldPath": "items"}, + "render": {"kind": "table", "columns": [{"field": "id"}]} + } + }] + } + }`, + "/api/v1/canvases/canvas-123/memory": `{ + "items": [ + {"namespace": "events", "values": {"items": [{"id": "1"}, {"id": "2"}]}} + ] + }`, + }) + + ctx, stdout := newCommandContext(t, server, "json") + ctx.Args = []string{"p1"} + cmd := &dataCommand{canvasID: stringPtr("canvas-123"), limit: int64Ptr(0)} + + require.NoError(t, cmd.Execute(ctx)) + + out := stdout.String() + require.Contains(t, out, `"id": "1"`) + require.Contains(t, out, `"id": "2"`) +} diff --git a/pkg/cli/commands/console/export.go b/pkg/cli/commands/console/export.go new file mode 100644 index 0000000000..02725b4fa1 --- /dev/null +++ b/pkg/cli/commands/console/export.go @@ -0,0 +1,55 @@ +package console + +import ( + "fmt" + "io" + "os" + + "github.com/superplanehq/superplane/pkg/cli/core" +) + +type exportCommand struct { + canvasID *string + file *string +} + +func (c *exportCommand) Execute(ctx core.CommandContext) error { + canvasID, err := core.ResolveCanvasID(ctx, valueOf(c.canvasID)) + if err != nil { + return err + } + + dashboard, err := fetchDashboard(ctx, canvasID) + if err != nil { + return err + } + + canvasName := findCanvasName(ctx, canvasID) + resource := dashboardToResource(dashboard, canvasName) + resource.Metadata.CanvasID = canvasID + + yamlBytes, err := renderConsoleResourceYAML(resource) + if err != nil { + return err + } + + target := valueOf(c.file) + if target == "" || target == "-" { + _, err := io.WriteString(ctx.Cmd.OutOrStdout(), string(yamlBytes)) + return err + } + + if err := os.WriteFile(target, yamlBytes, 0o600); err != nil { + return fmt.Errorf("failed to write console yaml to %s: %w", target, err) + } + + if ctx.Renderer.IsText() { + _, err := fmt.Fprintf(ctx.Cmd.OutOrStdout(), "Console exported to %s\n", target) + return err + } + + return ctx.Renderer.Render(map[string]string{ + "canvasId": canvasID, + "file": target, + }) +} diff --git a/pkg/cli/commands/console/file_io.go b/pkg/cli/commands/console/file_io.go new file mode 100644 index 0000000000..b250e32523 --- /dev/null +++ b/pkg/cli/commands/console/file_io.go @@ -0,0 +1,79 @@ +package console + +import ( + "encoding/json" + "fmt" + "io" + "os" + "sort" + "strings" + + "github.com/ghodss/yaml" +) + +func readAndCloseFile(path string) ([]byte, error) { + // #nosec G304 - path comes from CLI flags supplied by the user. + f, err := os.Open(path) + if err != nil { + return nil, fmt.Errorf("failed to read %s: %w", path, err) + } + defer f.Close() + return io.ReadAll(f) +} + +// renderMapAsYAMLBlock prints a labeled, indented YAML block to stdout. It +// is used by `console panels get` and similar text renderers to show the +// panel content without overwhelming the user with raw JSON. Keys are +// rendered in sorted order so successive runs are stable. +func renderMapAsYAMLBlock(stdout io.Writer, label string, value map[string]any) error { + if _, err := fmt.Fprintf(stdout, "%s:\n", label); err != nil { + return err + } + if len(value) == 0 { + _, err := fmt.Fprintln(stdout, " (empty)") + return err + } + + ordered := orderedMap(value) + jsonBytes, err := json.Marshal(ordered) + if err != nil { + return err + } + yamlBytes, err := yaml.JSONToYAML(jsonBytes) + if err != nil { + return err + } + + for _, line := range strings.Split(strings.TrimRight(string(yamlBytes), "\n"), "\n") { + if _, err := fmt.Fprintf(stdout, " %s\n", line); err != nil { + return err + } + } + return nil +} + +// orderedMap recursively produces a JSON-serializable structure with keys +// sorted alphabetically so the output is stable. +func orderedMap(value any) any { + switch v := value.(type) { + case map[string]any: + keys := make([]string, 0, len(v)) + for k := range v { + keys = append(keys, k) + } + sort.Strings(keys) + ordered := make(map[string]any, len(v)) + for _, k := range keys { + ordered[k] = orderedMap(v[k]) + } + return ordered + case []any: + out := make([]any, len(v)) + for i, item := range v { + out[i] = orderedMap(item) + } + return out + default: + return v + } +} diff --git a/pkg/cli/commands/console/get.go b/pkg/cli/commands/console/get.go new file mode 100644 index 0000000000..1b1b71c98e --- /dev/null +++ b/pkg/cli/commands/console/get.go @@ -0,0 +1,116 @@ +package console + +import ( + "fmt" + "io" + "text/tabwriter" + "time" + + "github.com/superplanehq/superplane/pkg/cli/core" + "github.com/superplanehq/superplane/pkg/openapi_client" +) + +type getCommand struct { + canvasID *string +} + +func (c *getCommand) Execute(ctx core.CommandContext) error { + canvasID, err := core.ResolveCanvasID(ctx, valueOf(c.canvasID)) + if err != nil { + return err + } + + dashboard, err := fetchDashboard(ctx, canvasID) + if err != nil { + return err + } + + if !ctx.Renderer.IsText() { + return ctx.Renderer.Render(dashboard) + } + + return ctx.Renderer.RenderText(func(stdout io.Writer) error { + return renderConsoleSummaryText(stdout, canvasID, dashboard) + }) +} + +func fetchDashboard(ctx core.CommandContext, canvasID string) (openapi_client.CanvasesCanvasDashboard, error) { + response, _, err := ctx.API.CanvasAPI.CanvasesGetCanvasDashboard(ctx.Context, canvasID).Execute() + if err != nil { + return openapi_client.CanvasesCanvasDashboard{}, err + } + if response.Dashboard == nil { + return openapi_client.CanvasesCanvasDashboard{}, nil + } + return *response.Dashboard, nil +} + +func renderConsoleSummaryText(stdout io.Writer, canvasID string, dashboard openapi_client.CanvasesCanvasDashboard) error { + if _, err := fmt.Fprintf(stdout, "Canvas ID: %s\n", canvasID); err != nil { + return err + } + if dashboard.HasUpdatedAt() { + if _, err := fmt.Fprintf(stdout, "Updated: %s\n", dashboard.GetUpdatedAt().Format(time.RFC3339)); err != nil { + return err + } + } + if _, err := fmt.Fprintf(stdout, "Panels: %d\n", len(dashboard.GetPanels())); err != nil { + return err + } + if _, err := fmt.Fprintf(stdout, "Layout: %d\n", len(dashboard.GetLayout())); err != nil { + return err + } + + if len(dashboard.GetPanels()) == 0 { + _, err := fmt.Fprintln(stdout, "\nNo panels defined.") + return err + } + + if _, err := fmt.Fprintln(stdout); err != nil { + return err + } + writer := tabwriter.NewWriter(stdout, 0, 8, 2, ' ', 0) + if _, err := fmt.Fprintln(writer, "ID\tTYPE\tTITLE\tPOSITION\tSIZE"); err != nil { + return err + } + layoutByID := make(map[string]openapi_client.CanvasesDashboardLayoutItem, len(dashboard.GetLayout())) + for _, item := range dashboard.GetLayout() { + layoutByID[item.GetI()] = item + } + for _, panel := range dashboard.GetPanels() { + title := panelTitle(panel) + positionStr, sizeStr := layoutPositionAndSize(layoutByID[panel.GetId()]) + if _, err := fmt.Fprintf(writer, "%s\t%s\t%s\t%s\t%s\n", panel.GetId(), panel.GetType(), title, positionStr, sizeStr); err != nil { + return err + } + } + return writer.Flush() +} + +func panelTitle(panel openapi_client.CanvasesDashboardPanel) string { + content := panel.GetContent() + if content == nil { + return "(no title)" + } + if title, ok := content["title"].(string); ok && title != "" { + return title + } + if node, ok := content["node"].(string); ok && node != "" { + return node + } + return "(no title)" +} + +func layoutPositionAndSize(item openapi_client.CanvasesDashboardLayoutItem) (string, string) { + if item.GetI() == "" { + return "-", "-" + } + return fmt.Sprintf("%d,%d", item.GetX(), item.GetY()), fmt.Sprintf("%dx%d", item.GetW(), item.GetH()) +} + +func valueOf(p *string) string { + if p == nil { + return "" + } + return *p +} diff --git a/pkg/cli/commands/console/get_test.go b/pkg/cli/commands/console/get_test.go new file mode 100644 index 0000000000..8b2c0833e9 --- /dev/null +++ b/pkg/cli/commands/console/get_test.go @@ -0,0 +1,82 @@ +package console + +import ( + "net/http" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestGetReportsPanelCountInTextOutput(t *testing.T) { + server := newAPITestServer(t, requestExpectation{ + method: http.MethodGet, + path: "/api/v1/canvases/canvas-123/dashboard", + handle: func(t *testing.T, w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{ + "dashboard": { + "canvasId": "canvas-123", + "panels": [ + {"id": "p1", "type": "markdown", "content": {"title": "Notes"}}, + {"id": "p2", "type": "table", "content": {}} + ], + "layout": [ + {"i": "p1", "x": 0, "y": 0, "w": 4, "h": 3} + ] + } + }`)) + }, + }) + + ctx, stdout := newCommandContext(t, server.server, "text") + cmd := &getCommand{canvasID: stringPtr("canvas-123")} + + require.NoError(t, cmd.Execute(ctx)) + out := stdout.String() + require.Contains(t, out, "Canvas ID: canvas-123") + require.Contains(t, out, "Panels: 2") + require.Contains(t, out, "Layout: 1") + require.Contains(t, out, "p1") + require.Contains(t, out, "Notes") +} + +func TestGetUsesActiveCanvasWhenFlagOmitted(t *testing.T) { + server := newAPITestServer(t, requestExpectation{ + method: http.MethodGet, + path: "/api/v1/canvases/active-456/dashboard", + handle: func(t *testing.T, w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"dashboard": {"canvasId": "active-456"}}`)) + }, + }) + + ctx, _ := newCommandContext(t, server.server, "text") + ctx.Config = &fakeConfig{activeCanvas: "active-456"} + + cmd := &getCommand{canvasID: stringPtr("")} + require.NoError(t, cmd.Execute(ctx)) + server.AssertCalls(t, []string{"GET /api/v1/canvases/active-456/dashboard"}) +} + +func TestGetEmitsJSONForJSONOutput(t *testing.T) { + server := newAPITestServer(t, requestExpectation{ + method: http.MethodGet, + path: "/api/v1/canvases/canvas-123/dashboard", + handle: func(t *testing.T, w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"dashboard": {"canvasId": "canvas-123", "panels": [{"id":"p1","type":"markdown"}]}}`)) + }, + }) + ctx, stdout := newCommandContext(t, server.server, "json") + + cmd := &getCommand{canvasID: stringPtr("canvas-123")} + require.NoError(t, cmd.Execute(ctx)) + require.Contains(t, stdout.String(), `"canvasId": "canvas-123"`) + require.Contains(t, stdout.String(), `"id": "p1"`) +} + +func (s *apiTestServer) AssertCalls(t *testing.T, calls []string) { + t.Helper() + require.Equal(t, calls, s.calls) + require.Len(t, s.expectations, 0, "unused request expectations") +} diff --git a/pkg/cli/commands/console/helpers_test.go b/pkg/cli/commands/console/helpers_test.go new file mode 100644 index 0000000000..a181c970b3 --- /dev/null +++ b/pkg/cli/commands/console/helpers_test.go @@ -0,0 +1,137 @@ +package console + +import ( + "bytes" + "context" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/spf13/cobra" + "github.com/stretchr/testify/require" + "github.com/superplanehq/superplane/pkg/cli/core" + "github.com/superplanehq/superplane/pkg/openapi_client" +) + +// fakeConfig provides the canvas-active accessor expected by +// core.ResolveCanvasID. CLI tests typically pass `--canvas-id` directly +// (so the active canvas is irrelevant), but a few exercises rely on the +// fallback behavior — set `activeCanvas` for those cases. +type fakeConfig struct { + activeCanvas string + url string +} + +func (f *fakeConfig) GetActiveCanvas() string { return f.activeCanvas } +func (f *fakeConfig) SetActiveCanvas(canvasID string) error { return nil } +func (f *fakeConfig) GetURL() string { return f.url } + +type requestExpectation struct { + method string + path string + handle func(t *testing.T, w http.ResponseWriter, r *http.Request) +} + +type apiTestServer struct { + t *testing.T + expectations []requestExpectation + calls []string + server *httptest.Server +} + +// newAPITestServer creates a strict mock server: each request must match +// the next expectation in order. Tests that don't care about call order +// should pass the routes via newRouteAPITestServer instead. +func newAPITestServer(t *testing.T, expectations ...requestExpectation) *apiTestServer { + t.Helper() + + s := &apiTestServer{t: t, expectations: expectations} + s.server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + s.calls = append(s.calls, r.Method+" "+r.URL.Path) + + if len(s.expectations) == 0 { + w.WriteHeader(http.StatusNotFound) + return + } + + next := s.expectations[0] + require.Equal(t, next.method, r.Method) + require.Equal(t, next.path, r.URL.Path) + + s.expectations = s.expectations[1:] + if next.handle != nil { + next.handle(t, w, r) + } else { + w.WriteHeader(http.StatusOK) + } + })) + t.Cleanup(s.server.Close) + return s +} + +// newRouteAPITestServer returns a server that responds based on the URL +// path, regardless of order. Useful when commands fan out (e.g. the data +// command may fetch the dashboard plus list memory/runs/events). +func newRouteAPITestServer(t *testing.T, routes map[string]string) *httptest.Server { + t.Helper() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + response, ok := routes[r.URL.Path] + if !ok { + w.WriteHeader(http.StatusNotFound) + return + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(response)) + })) + t.Cleanup(server.Close) + return server +} + +func newCommandContext( + t *testing.T, + server *httptest.Server, + outputFormat string, +) (core.CommandContext, *bytes.Buffer) { + t.Helper() + + stdout := bytes.NewBuffer(nil) + renderer, err := core.NewRenderer(outputFormat, stdout) + require.NoError(t, err) + + cobraCmd := &cobra.Command{} + cobraCmd.SetOut(stdout) + cobraCmd.SetIn(strings.NewReader("")) + + ctx := core.CommandContext{ + Context: context.Background(), + Cmd: cobraCmd, + Renderer: renderer, + Config: &fakeConfig{}, + } + + if server != nil { + config := openapi_client.NewConfiguration() + config.Servers = openapi_client.ServerConfigurations{{URL: server.URL}} + ctx.API = openapi_client.NewAPIClient(config) + } + + return ctx, stdout +} + +// stringPtr returns a pointer to the given string. Test helper that lets +// us pass cobra-style flag fields without declaring a local variable for +// every flag. +func stringPtr(s string) *string { + return &s +} + +// boolPtr returns a pointer to the given bool, mirroring stringPtr. +func boolPtr(b bool) *bool { + return &b +} + +// int64Ptr returns a pointer to the given int64. +func int64Ptr(i int64) *int64 { + return &i +} diff --git a/pkg/cli/commands/console/import.go b/pkg/cli/commands/console/import.go new file mode 100644 index 0000000000..b78e80ec31 --- /dev/null +++ b/pkg/cli/commands/console/import.go @@ -0,0 +1,72 @@ +package console + +import ( + "fmt" + "io" + + "github.com/superplanehq/superplane/pkg/cli/core" +) + +type importCommand struct { + canvasID *string + file *string + yes *bool +} + +func (c *importCommand) Execute(ctx core.CommandContext) error { + canvasID, err := core.ResolveCanvasID(ctx, valueOf(c.canvasID)) + if err != nil { + return err + } + + resource, err := resourceFromInput(valueOf(c.file), ctx.Cmd.InOrStdin()) + if err != nil { + return err + } + + if !confirmReplace(ctx, c.yes, len(resource.Spec.Panels), len(resource.Spec.Layout)) { + _, err := fmt.Fprintln(ctx.Cmd.OutOrStdout(), "Aborted.") + return err + } + + body := resourceToUpdateBody(*resource) + response, _, err := ctx.API.CanvasAPI. + CanvasesUpdateCanvasDashboard(ctx.Context, canvasID). + Body(body). + Execute() + if err != nil { + return err + } + + if !ctx.Renderer.IsText() { + return ctx.Renderer.Render(response.GetDashboard()) + } + + return ctx.Renderer.RenderText(func(stdout io.Writer) error { + dashboard := response.GetDashboard() + _, err := fmt.Fprintf(stdout, "Console imported (%d panels, %d layout items)\n", len(dashboard.GetPanels()), len(dashboard.GetLayout())) + return err + }) +} + +// confirmReplace returns true when the user accepts the replace-all +// semantics of import/clear (or has passed --yes / is non-interactive). +// +// The Console API is replace-all: importing or clearing wipes any panels and +// layout that are not in the request body. We surface a single confirmation +// prompt for interactive sessions to make that destructive behavior obvious. +func confirmReplace(ctx core.CommandContext, yes *bool, newPanelCount int, newLayoutCount int) bool { + if yes != nil && *yes { + return true + } + + if !ctx.IsInteractive() { + return true + } + + _, _ = fmt.Fprintf(ctx.Cmd.OutOrStdout(), "Importing replaces all existing panels and layout. New panels: %d, layout items: %d.\nProceed? [y/N]: ", newPanelCount, newLayoutCount) + + var answer string + _, _ = fmt.Fscanln(ctx.Cmd.InOrStdin(), &answer) + return answer == "y" || answer == "Y" || answer == "yes" || answer == "YES" +} diff --git a/pkg/cli/commands/console/import_test.go b/pkg/cli/commands/console/import_test.go new file mode 100644 index 0000000000..7be844f21d --- /dev/null +++ b/pkg/cli/commands/console/import_test.go @@ -0,0 +1,114 @@ +package console + +import ( + "encoding/json" + "io" + "net/http" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestImportSendsParsedYAMLToAPI(t *testing.T) { + dir := t.TempDir() + consoleFile := filepath.Join(dir, "console.yaml") + require.NoError(t, os.WriteFile(consoleFile, []byte(`apiVersion: v1 +kind: Console +metadata: + canvasId: canvas-123 + name: Demo +spec: + panels: + - id: m1 + type: markdown + content: + title: Notes + body: hello + layout: + - i: m1 + x: 0 + y: 0 + w: 4 + h: 3 +`), 0o600)) + + var captured map[string]any + server := newAPITestServer(t, requestExpectation{ + method: http.MethodPut, + path: "/api/v1/canvases/canvas-123/dashboard", + handle: func(t *testing.T, w http.ResponseWriter, r *http.Request) { + body, err := io.ReadAll(r.Body) + require.NoError(t, err) + require.NoError(t, json.Unmarshal(body, &captured)) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"dashboard": {"panels": [{"id":"m1"}], "layout": [{"i":"m1"}]}}`)) + }, + }) + + ctx, _ := newCommandContext(t, server.server, "text") + cmd := &importCommand{ + canvasID: stringPtr("canvas-123"), + file: stringPtr(consoleFile), + yes: boolPtr(true), + } + + require.NoError(t, cmd.Execute(ctx)) + + panels, _ := captured["panels"].([]any) + require.Len(t, panels, 1) + first, _ := panels[0].(map[string]any) + require.Equal(t, "m1", first["id"]) + require.Equal(t, "markdown", first["type"]) +} + +func TestImportRejectsUnsupportedKind(t *testing.T) { + dir := t.TempDir() + bad := filepath.Join(dir, "console.yaml") + require.NoError(t, os.WriteFile(bad, []byte("apiVersion: v1\nkind: Canvas\nspec:\n panels: []\n layout: []\n"), 0o600)) + + server := newAPITestServer(t) + ctx, _ := newCommandContext(t, server.server, "text") + + cmd := &importCommand{ + canvasID: stringPtr("canvas-123"), + file: stringPtr(bad), + yes: boolPtr(true), + } + + err := cmd.Execute(ctx) + require.Error(t, err) + require.Contains(t, err.Error(), "unsupported resource kind") +} + +func TestImportRejectsLayoutForUnknownPanel(t *testing.T) { + dir := t.TempDir() + bad := filepath.Join(dir, "console.yaml") + require.NoError(t, os.WriteFile(bad, []byte(`apiVersion: v1 +kind: Console +spec: + panels: + - id: m1 + type: markdown + layout: + - i: missing + x: 0 + y: 0 + w: 1 + h: 1 +`), 0o600)) + + server := newAPITestServer(t) + ctx, _ := newCommandContext(t, server.server, "text") + + cmd := &importCommand{ + canvasID: stringPtr("canvas-123"), + file: stringPtr(bad), + yes: boolPtr(true), + } + + err := cmd.Execute(ctx) + require.Error(t, err) + require.Contains(t, err.Error(), `layout item "missing"`) +} diff --git a/pkg/cli/commands/console/panels.go b/pkg/cli/commands/console/panels.go new file mode 100644 index 0000000000..15927a06d6 --- /dev/null +++ b/pkg/cli/commands/console/panels.go @@ -0,0 +1,372 @@ +package console + +import ( + "fmt" + "io" + "text/tabwriter" + + "github.com/superplanehq/superplane/pkg/cli/core" + "github.com/superplanehq/superplane/pkg/openapi_client" +) + +type panelsListCommand struct { + canvasID *string +} + +func (c *panelsListCommand) Execute(ctx core.CommandContext) error { + canvasID, err := core.ResolveCanvasID(ctx, valueOf(c.canvasID)) + if err != nil { + return err + } + + dashboard, err := fetchDashboard(ctx, canvasID) + if err != nil { + return err + } + + if !ctx.Renderer.IsText() { + return ctx.Renderer.Render(dashboard.GetPanels()) + } + + return ctx.Renderer.RenderText(func(stdout io.Writer) error { + panels := dashboard.GetPanels() + if len(panels) == 0 { + _, err := fmt.Fprintln(stdout, "No panels.") + return err + } + writer := tabwriter.NewWriter(stdout, 0, 8, 2, ' ', 0) + _, _ = fmt.Fprintln(writer, "ID\tTYPE\tTITLE") + for _, panel := range panels { + _, _ = fmt.Fprintf(writer, "%s\t%s\t%s\n", panel.GetId(), panel.GetType(), panelTitle(panel)) + } + return writer.Flush() + }) +} + +type panelsGetCommand struct { + canvasID *string +} + +func (c *panelsGetCommand) Execute(ctx core.CommandContext) error { + canvasID, err := core.ResolveCanvasID(ctx, valueOf(c.canvasID)) + if err != nil { + return err + } + + if len(ctx.Args) == 0 { + return fmt.Errorf("panel id is required") + } + panelID := ctx.Args[0] + + dashboard, err := fetchDashboard(ctx, canvasID) + if err != nil { + return err + } + + for _, panel := range dashboard.GetPanels() { + if panel.GetId() != panelID { + continue + } + if !ctx.Renderer.IsText() { + return ctx.Renderer.Render(panel) + } + return ctx.Renderer.RenderText(func(stdout io.Writer) error { + return renderPanelText(stdout, panel) + }) + } + + return fmt.Errorf("panel %q not found", panelID) +} + +func renderPanelText(stdout io.Writer, panel openapi_client.CanvasesDashboardPanel) error { + if _, err := fmt.Fprintf(stdout, "ID: %s\n", panel.GetId()); err != nil { + return err + } + if _, err := fmt.Fprintf(stdout, "Type: %s\n", panel.GetType()); err != nil { + return err + } + if _, err := fmt.Fprintf(stdout, "Title: %s\n", panelTitle(panel)); err != nil { + return err + } + return renderMapAsYAMLBlock(stdout, "Content", panel.GetContent()) +} + +type panelsDeleteCommand struct { + canvasID *string + yes *bool +} + +func (c *panelsDeleteCommand) Execute(ctx core.CommandContext) error { + canvasID, err := core.ResolveCanvasID(ctx, valueOf(c.canvasID)) + if err != nil { + return err + } + + if len(ctx.Args) == 0 { + return fmt.Errorf("panel id is required") + } + panelID := ctx.Args[0] + + dashboard, err := fetchDashboard(ctx, canvasID) + if err != nil { + return err + } + + panels := dashboard.GetPanels() + layout := dashboard.GetLayout() + filteredPanels := make([]openapi_client.CanvasesDashboardPanel, 0, len(panels)) + found := false + for _, panel := range panels { + if panel.GetId() == panelID { + found = true + continue + } + filteredPanels = append(filteredPanels, panel) + } + if !found { + return fmt.Errorf("panel %q not found", panelID) + } + + filteredLayout := make([]openapi_client.CanvasesDashboardLayoutItem, 0, len(layout)) + for _, item := range layout { + if item.GetI() == panelID { + continue + } + filteredLayout = append(filteredLayout, item) + } + + if !confirmDeletePanel(ctx, c.yes, panelID) { + _, err := fmt.Fprintln(ctx.Cmd.OutOrStdout(), "Aborted.") + return err + } + + body := openapi_client.CanvasesUpdateCanvasDashboardBody{} + body.SetPanels(filteredPanels) + body.SetLayout(filteredLayout) + + response, _, err := ctx.API.CanvasAPI. + CanvasesUpdateCanvasDashboard(ctx.Context, canvasID). + Body(body). + Execute() + if err != nil { + return err + } + + if !ctx.Renderer.IsText() { + return ctx.Renderer.Render(response.GetDashboard()) + } + + return ctx.Renderer.RenderText(func(stdout io.Writer) error { + _, err := fmt.Fprintf(stdout, "Panel %q deleted from canvas %s\n", panelID, canvasID) + return err + }) +} + +func confirmDeletePanel(ctx core.CommandContext, yes *bool, panelID string) bool { + if yes != nil && *yes { + return true + } + if !ctx.IsInteractive() { + return true + } + _, _ = fmt.Fprintf(ctx.Cmd.OutOrStdout(), "Delete panel %q? [y/N]: ", panelID) + var answer string + _, _ = fmt.Fscanln(ctx.Cmd.InOrStdin(), &answer) + return answer == "y" || answer == "Y" || answer == "yes" || answer == "YES" +} + +type panelsUpsertCommand struct { + canvasID *string + file *string + layout *string +} + +// panelDocument is the input shape for `console panels upsert`. It is a +// superset of the Console panel/layout JSON in the API to keep the file +// simple: a single document with the panel and an optional `layout` block. +type panelDocument struct { + APIVersion string `json:"apiVersion,omitempty" yaml:"apiVersion,omitempty"` + Kind string `json:"kind,omitempty" yaml:"kind,omitempty"` + ID string `json:"id,omitempty" yaml:"id,omitempty"` + Type string `json:"type,omitempty" yaml:"type,omitempty"` + Content map[string]any `json:"content,omitempty" yaml:"content,omitempty"` + Layout *consoleResourceLayout `json:"layout,omitempty" yaml:"layout,omitempty"` + Panel *panelDocumentEmbedded `json:"panel,omitempty" yaml:"panel,omitempty"` +} + +type panelDocumentEmbedded struct { + ID string `json:"id,omitempty" yaml:"id,omitempty"` + Type string `json:"type,omitempty" yaml:"type,omitempty"` + Content map[string]any `json:"content,omitempty" yaml:"content,omitempty"` +} + +func (c *panelsUpsertCommand) Execute(ctx core.CommandContext) error { + canvasID, err := core.ResolveCanvasID(ctx, valueOf(c.canvasID)) + if err != nil { + return err + } + + doc, err := parsePanelDocument(valueOf(c.file), ctx.Cmd.InOrStdin()) + if err != nil { + return err + } + + panel := openapi_client.CanvasesDashboardPanel{} + panel.SetId(doc.ID) + panel.SetType(doc.Type) + if doc.Content != nil { + panel.SetContent(doc.Content) + } + + dashboard, err := fetchDashboard(ctx, canvasID) + if err != nil { + return err + } + + updatedPanels := make([]openapi_client.CanvasesDashboardPanel, 0, len(dashboard.GetPanels())+1) + replaced := false + for _, existing := range dashboard.GetPanels() { + if existing.GetId() == doc.ID { + updatedPanels = append(updatedPanels, panel) + replaced = true + continue + } + updatedPanels = append(updatedPanels, existing) + } + if !replaced { + updatedPanels = append(updatedPanels, panel) + } + + updatedLayout := updatedLayoutForPanel(dashboard.GetLayout(), doc, valueOf(c.layout)) + + body := openapi_client.CanvasesUpdateCanvasDashboardBody{} + body.SetPanels(updatedPanels) + body.SetLayout(updatedLayout) + + response, _, err := ctx.API.CanvasAPI. + CanvasesUpdateCanvasDashboard(ctx.Context, canvasID). + Body(body). + Execute() + if err != nil { + return err + } + + if !ctx.Renderer.IsText() { + return ctx.Renderer.Render(response.GetDashboard()) + } + + action := "added" + if replaced { + action = "updated" + } + return ctx.Renderer.RenderText(func(stdout io.Writer) error { + _, err := fmt.Fprintf(stdout, "Panel %q %s on canvas %s\n", doc.ID, action, canvasID) + return err + }) +} + +func parsePanelDocument(path string, stdin io.Reader) (*panelDocument, error) { + if path == "" { + return nil, fmt.Errorf("--file is required (use - to read from stdin)") + } + + doc := panelDocument{} + data, err := readFileOrStdin(path, stdin) + if err != nil { + return nil, err + } + if err := core.NewDecoder(data).DecodeYAML(&doc); err != nil { + return nil, fmt.Errorf("failed to parse panel: %w", err) + } + + if doc.Panel != nil { + if doc.ID == "" { + doc.ID = doc.Panel.ID + } + if doc.Type == "" { + doc.Type = doc.Panel.Type + } + if doc.Content == nil { + doc.Content = doc.Panel.Content + } + } + + if doc.ID == "" { + return nil, fmt.Errorf("panel id is required") + } + if doc.Type == "" { + return nil, fmt.Errorf("panel type is required") + } + if !panelTypeIsSupported(doc.Type) { + return nil, fmt.Errorf("panel %q has unsupported type %q (supported: %s)", doc.ID, doc.Type, joinStrings(supportedPanelTypes, ", ")) + } + + return &doc, nil +} + +// updatedLayoutForPanel applies the provided layout (from --layout JSON or +// from the panel document) on top of the existing dashboard layout. If +// neither source is set and there is no existing layout entry for this +// panel, the layout list is left unchanged so the API can fill in defaults +// the same way the UI does on first display. +func updatedLayoutForPanel(existing []openapi_client.CanvasesDashboardLayoutItem, doc *panelDocument, layoutJSON string) []openapi_client.CanvasesDashboardLayoutItem { + out := make([]openapi_client.CanvasesDashboardLayoutItem, 0, len(existing)+1) + updated := false + + override := layoutFromDocOrJSON(doc, layoutJSON) + for _, item := range existing { + if item.GetI() == doc.ID && override != nil { + out = append(out, layoutItemForAPI(*override)) + updated = true + continue + } + out = append(out, item) + } + + if override != nil && !updated { + out = append(out, layoutItemForAPI(*override)) + } + + return out +} + +func layoutFromDocOrJSON(doc *panelDocument, layoutJSON string) *consoleResourceLayout { + if doc != nil && doc.Layout != nil { + layout := *doc.Layout + layout.I = doc.ID + return &layout + } + if layoutJSON == "" { + return nil + } + override := consoleResourceLayout{} + if err := core.NewDecoder([]byte(layoutJSON)).DecodeYAML(&override); err != nil { + return nil + } + override.I = doc.ID + return &override +} + +func layoutItemForAPI(layout consoleResourceLayout) openapi_client.CanvasesDashboardLayoutItem { + apiItem := openapi_client.CanvasesDashboardLayoutItem{} + apiItem.SetI(layout.I) + apiItem.SetX(layout.X) + apiItem.SetY(layout.Y) + apiItem.SetW(layout.W) + apiItem.SetH(layout.H) + if layout.MinW != nil { + apiItem.SetMinW(*layout.MinW) + } + if layout.MinH != nil { + apiItem.SetMinH(*layout.MinH) + } + return apiItem +} + +func readFileOrStdin(path string, stdin io.Reader) ([]byte, error) { + if path == "-" { + return io.ReadAll(stdin) + } + // #nosec G304 - file path is supplied by the CLI user. + return readAndCloseFile(path) +} diff --git a/pkg/cli/commands/console/panels_test.go b/pkg/cli/commands/console/panels_test.go new file mode 100644 index 0000000000..9bbf18e792 --- /dev/null +++ b/pkg/cli/commands/console/panels_test.go @@ -0,0 +1,144 @@ +package console + +import ( + "encoding/json" + "io" + "net/http" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestPanelsListPrintsTableInTextOutput(t *testing.T) { + server := newAPITestServer(t, requestExpectation{ + method: http.MethodGet, + path: "/api/v1/canvases/canvas-123/dashboard", + handle: func(t *testing.T, w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"dashboard": {"panels": [{"id":"p1","type":"markdown","content":{"title":"T1"}}]}}`)) + }, + }) + + ctx, stdout := newCommandContext(t, server.server, "text") + cmd := &panelsListCommand{canvasID: stringPtr("canvas-123")} + + require.NoError(t, cmd.Execute(ctx)) + require.Contains(t, stdout.String(), "ID") + require.Contains(t, stdout.String(), "p1") + require.Contains(t, stdout.String(), "T1") +} + +func TestPanelsGetReturns404WhenPanelMissing(t *testing.T) { + server := newAPITestServer(t, requestExpectation{ + method: http.MethodGet, + path: "/api/v1/canvases/canvas-123/dashboard", + handle: func(t *testing.T, w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"dashboard": {"panels": [{"id":"other","type":"markdown"}]}}`)) + }, + }) + + ctx, _ := newCommandContext(t, server.server, "text") + ctx.Args = []string{"missing"} + cmd := &panelsGetCommand{canvasID: stringPtr("canvas-123")} + + err := cmd.Execute(ctx) + require.Error(t, err) + require.Contains(t, err.Error(), "missing") +} + +func TestPanelsUpsertAppendsNewPanel(t *testing.T) { + dir := t.TempDir() + panelFile := filepath.Join(dir, "panel.yaml") + require.NoError(t, os.WriteFile(panelFile, []byte(`id: new-panel +type: markdown +content: + title: Hi +layout: + x: 0 + y: 0 + w: 4 + h: 3 +`), 0o600)) + + var captured map[string]any + server := newAPITestServer(t, + requestExpectation{ + method: http.MethodGet, + path: "/api/v1/canvases/canvas-123/dashboard", + handle: func(t *testing.T, w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"dashboard": {"panels": [{"id":"existing","type":"markdown"}], "layout": []}}`)) + }, + }, + requestExpectation{ + method: http.MethodPut, + path: "/api/v1/canvases/canvas-123/dashboard", + handle: func(t *testing.T, w http.ResponseWriter, r *http.Request) { + body, err := io.ReadAll(r.Body) + require.NoError(t, err) + require.NoError(t, json.Unmarshal(body, &captured)) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"dashboard": {}}`)) + }, + }, + ) + + ctx, _ := newCommandContext(t, server.server, "text") + cmd := &panelsUpsertCommand{ + canvasID: stringPtr("canvas-123"), + file: stringPtr(panelFile), + layout: stringPtr(""), + } + require.NoError(t, cmd.Execute(ctx)) + + panels, _ := captured["panels"].([]any) + require.Len(t, panels, 2) + last := panels[len(panels)-1].(map[string]any) + require.Equal(t, "new-panel", last["id"]) + + layout, _ := captured["layout"].([]any) + require.Len(t, layout, 1) + first, _ := layout[0].(map[string]any) + require.Equal(t, "new-panel", first["i"]) +} + +func TestPanelsDeleteFiltersTargetPanel(t *testing.T) { + var captured map[string]any + server := newAPITestServer(t, + requestExpectation{ + method: http.MethodGet, + path: "/api/v1/canvases/canvas-123/dashboard", + handle: func(t *testing.T, w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"dashboard": {"panels":[{"id":"keep","type":"markdown"},{"id":"drop","type":"markdown"}], "layout":[{"i":"drop","x":0,"y":0,"w":1,"h":1}]}}`)) + }, + }, + requestExpectation{ + method: http.MethodPut, + path: "/api/v1/canvases/canvas-123/dashboard", + handle: func(t *testing.T, w http.ResponseWriter, r *http.Request) { + body, err := io.ReadAll(r.Body) + require.NoError(t, err) + require.NoError(t, json.Unmarshal(body, &captured)) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"dashboard": {}}`)) + }, + }, + ) + + ctx, _ := newCommandContext(t, server.server, "text") + ctx.Args = []string{"drop"} + cmd := &panelsDeleteCommand{canvasID: stringPtr("canvas-123"), yes: boolPtr(true)} + + require.NoError(t, cmd.Execute(ctx)) + + panels, _ := captured["panels"].([]any) + require.Len(t, panels, 1) + require.Equal(t, "keep", panels[0].(map[string]any)["id"]) + + layout, _ := captured["layout"].([]any) + require.Len(t, layout, 0) +} diff --git a/pkg/cli/commands/console/root.go b/pkg/cli/commands/console/root.go new file mode 100644 index 0000000000..628d6a02b7 --- /dev/null +++ b/pkg/cli/commands/console/root.go @@ -0,0 +1,148 @@ +package console + +import ( + "github.com/spf13/cobra" + "github.com/superplanehq/superplane/pkg/cli/core" +) + +// NewCommand wires the `superplane console` command tree. +// +// User-facing terminology stays "Console" even though the underlying API +// still calls this resource "Dashboard" (see canvas_dashboard.proto). +// Updating the user-visible text here without updating the backend keeps +// the migration story consistent: API methods follow the legacy name, and +// only display strings change. +func NewCommand(options core.BindOptions) *cobra.Command { + root := &cobra.Command{ + Use: "console", + Short: "Manage canvas Console (panels, layouts, and runtime data)", + Long: `Manage the canvas Console. + +Every Console subcommand resolves the target canvas with --canvas-id (or +the active canvas configured via "superplane canvases active"). Imports +replace the entire Console for the canvas to match the API behavior.`, + Aliases: []string{"dashboard"}, + } + + addConsoleCommands(root, options) + addPanelCommands(root, options) + addRuntimeCommands(root, options) + + return root +} + +func addConsoleCommands(root *cobra.Command, options core.BindOptions) { + getCanvasID := "" + getCmd := &cobra.Command{ + Use: "get", + Short: "Show a summary of the canvas Console", + Args: cobra.NoArgs, + } + getCmd.Flags().StringVar(&getCanvasID, "canvas-id", "", "canvas id (defaults to the active canvas)") + core.Bind(getCmd, &getCommand{canvasID: &getCanvasID}, options) + + exportCanvasID := "" + exportFile := "" + exportCmd := &cobra.Command{ + Use: "export", + Short: "Export a canvas Console as YAML", + Args: cobra.NoArgs, + } + exportCmd.Flags().StringVar(&exportCanvasID, "canvas-id", "", "canvas id (defaults to the active canvas)") + exportCmd.Flags().StringVarP(&exportFile, "file", "f", "", "output file path (use - or omit to write to stdout)") + core.Bind(exportCmd, &exportCommand{canvasID: &exportCanvasID, file: &exportFile}, options) + + importCanvasID := "" + importFile := "" + importYes := false + importCmd := &cobra.Command{ + Use: "import", + Short: "Replace a canvas Console with the contents of a YAML file", + Args: cobra.NoArgs, + } + importCmd.Flags().StringVar(&importCanvasID, "canvas-id", "", "canvas id (defaults to the active canvas)") + importCmd.Flags().StringVarP(&importFile, "file", "f", "", "console YAML file (use - to read from stdin)") + importCmd.Flags().BoolVarP(&importYes, "yes", "y", false, "do not prompt for replace-all confirmation") + _ = importCmd.MarkFlagRequired("file") + core.Bind(importCmd, &importCommand{canvasID: &importCanvasID, file: &importFile, yes: &importYes}, options) + + clearCanvasID := "" + clearYes := false + clearCmd := &cobra.Command{ + Use: "clear", + Short: "Remove all panels and layout from a canvas Console", + Args: cobra.NoArgs, + } + clearCmd.Flags().StringVar(&clearCanvasID, "canvas-id", "", "canvas id (defaults to the active canvas)") + clearCmd.Flags().BoolVarP(&clearYes, "yes", "y", false, "do not prompt for confirmation") + core.Bind(clearCmd, &clearCommand{canvasID: &clearCanvasID, yes: &clearYes}, options) + + root.AddCommand(getCmd) + root.AddCommand(exportCmd) + root.AddCommand(importCmd) + root.AddCommand(clearCmd) +} + +func addPanelCommands(root *cobra.Command, options core.BindOptions) { + panels := &cobra.Command{ + Use: "panels", + Short: "Manage individual Console panels", + } + + listCanvasID := "" + listCmd := &cobra.Command{ + Use: "list", + Short: "List Console panels", + Args: cobra.NoArgs, + } + listCmd.Flags().StringVar(&listCanvasID, "canvas-id", "", "canvas id (defaults to the active canvas)") + core.Bind(listCmd, &panelsListCommand{canvasID: &listCanvasID}, options) + + getCanvasID := "" + getCmd := &cobra.Command{ + Use: "get ", + Short: "Show a Console panel", + Args: cobra.ExactArgs(1), + } + getCmd.Flags().StringVar(&getCanvasID, "canvas-id", "", "canvas id (defaults to the active canvas)") + core.Bind(getCmd, &panelsGetCommand{canvasID: &getCanvasID}, options) + + deleteCanvasID := "" + deleteYes := false + deleteCmd := &cobra.Command{ + Use: "delete ", + Short: "Delete a Console panel", + Args: cobra.ExactArgs(1), + } + deleteCmd.Flags().StringVar(&deleteCanvasID, "canvas-id", "", "canvas id (defaults to the active canvas)") + deleteCmd.Flags().BoolVarP(&deleteYes, "yes", "y", false, "do not prompt for confirmation") + core.Bind(deleteCmd, &panelsDeleteCommand{canvasID: &deleteCanvasID, yes: &deleteYes}, options) + + upsertCanvasID := "" + upsertFile := "" + upsertLayout := "" + upsertCmd := &cobra.Command{ + Use: "upsert", + Short: "Add or update a Console panel from a YAML/JSON file", + Long: `Add or update a Console panel. + +The file describes a single panel and may include a layout block. Use +` + "`--layout '{\"x\":0,\"y\":0,\"w\":4,\"h\":3}'`" + ` to override the layout +position from the command line. When the layout is not provided, the +existing layout entry for the panel is preserved (or left for the API to +default for new panels).`, + Args: cobra.NoArgs, + } + upsertCmd.Flags().StringVar(&upsertCanvasID, "canvas-id", "", "canvas id (defaults to the active canvas)") + upsertCmd.Flags().StringVarP(&upsertFile, "file", "f", "", "panel definition file (use - for stdin)") + upsertCmd.Flags().StringVar(&upsertLayout, "layout", "", "JSON object overriding the panel layout") + _ = upsertCmd.MarkFlagRequired("file") + core.Bind(upsertCmd, &panelsUpsertCommand{canvasID: &upsertCanvasID, file: &upsertFile, layout: &upsertLayout}, options) + + panels.AddCommand(listCmd) + panels.AddCommand(getCmd) + panels.AddCommand(deleteCmd) + panels.AddCommand(upsertCmd) + + root.AddCommand(panels) +} diff --git a/pkg/cli/commands/console/runtime.go b/pkg/cli/commands/console/runtime.go new file mode 100644 index 0000000000..13fae15959 --- /dev/null +++ b/pkg/cli/commands/console/runtime.go @@ -0,0 +1,14 @@ +package console + +import ( + "github.com/spf13/cobra" + "github.com/superplanehq/superplane/pkg/cli/core" +) + +// addRuntimeCommands wires the Console runtime subcommands (data, trigger). +// The actual command implementations live in data.go and trigger.go so that +// each runtime entry point is self-contained. +func addRuntimeCommands(root *cobra.Command, options core.BindOptions) { + addDataCommand(root, options) + addTriggerCommand(root, options) +} diff --git a/pkg/cli/commands/console/trigger.go b/pkg/cli/commands/console/trigger.go new file mode 100644 index 0000000000..0be958ff68 --- /dev/null +++ b/pkg/cli/commands/console/trigger.go @@ -0,0 +1,40 @@ +package console + +import ( + "github.com/spf13/cobra" + "github.com/superplanehq/superplane/pkg/cli/core" +) + +// addTriggerCommand wires `superplane console trigger`, exposing the same +// node trigger hook the UI invokes from node panels and table row actions. +func addTriggerCommand(root *cobra.Command, options core.BindOptions) { + canvasID := "" + node := "" + hook := "run" + parameters := "" + + cmd := &cobra.Command{ + Use: "trigger", + Short: "Invoke a trigger hook on a node from the Console", + Long: `Invoke a trigger hook on a node. + +The default hook is "run" so the command mirrors the run button on +Console node panels. Provide --parameters to pass the same JSON payload +the UI would build for table row actions or for node panels with input +fields.`, + Args: cobra.NoArgs, + } + cmd.Flags().StringVar(&canvasID, "canvas-id", "", "canvas id (defaults to the active canvas)") + cmd.Flags().StringVar(&node, "node", "", "node id or name to trigger (required)") + cmd.Flags().StringVar(&hook, "hook", "run", "trigger hook name (default: run)") + cmd.Flags().StringVar(¶meters, "parameters", "", "JSON parameters for the trigger (use @file.json or - for stdin)") + _ = cmd.MarkFlagRequired("node") + core.Bind(cmd, &triggerCommand{ + canvasID: &canvasID, + node: &node, + hook: &hook, + parameters: ¶meters, + }, options) + + root.AddCommand(cmd) +} diff --git a/pkg/cli/commands/console/trigger_runtime.go b/pkg/cli/commands/console/trigger_runtime.go new file mode 100644 index 0000000000..649e07e575 --- /dev/null +++ b/pkg/cli/commands/console/trigger_runtime.go @@ -0,0 +1,123 @@ +package console + +import ( + "encoding/json" + "fmt" + "io" + "os" + "strings" + + "github.com/superplanehq/superplane/pkg/cli/core" + "github.com/superplanehq/superplane/pkg/openapi_client" +) + +type triggerCommand struct { + canvasID *string + node *string + hook *string + parameters *string +} + +// Execute invokes a node trigger hook (typically `run`) so the CLI can +// kick off the same operation users start from a Console node panel. +// +// The CLI accepts node ids or node names, and parameters can be supplied +// inline, from a file (`@path`), or from stdin (`-`). Parameters are sent +// to the API as the `parameters` map matching the proto payload. +func (c *triggerCommand) Execute(ctx core.CommandContext) error { + canvasID, err := core.ResolveCanvasID(ctx, valueOf(c.canvasID)) + if err != nil { + return err + } + + nodeRef := strings.TrimSpace(valueOf(c.node)) + if nodeRef == "" { + return fmt.Errorf("--node is required") + } + + hook := strings.TrimSpace(valueOf(c.hook)) + if hook == "" { + hook = "run" + } + + // Parse parameters before any network calls so a malformed --parameters + // flag fails fast without hitting the API. + parameters, err := loadTriggerParameters(valueOf(c.parameters), ctx.Cmd.InOrStdin()) + if err != nil { + return err + } + + nodeID, err := resolveNodeID(ctx, canvasID, nodeRef) + if err != nil { + return err + } + if nodeID == "" { + return fmt.Errorf("node %q not found", nodeRef) + } + + body := openapi_client.CanvasesInvokeNodeTriggerHookBody{} + if parameters != nil { + body.SetParameters(parameters) + } + + response, _, err := ctx.API.CanvasNodeAPI. + CanvasesInvokeNodeTriggerHook(ctx.Context, canvasID, nodeID, hook). + Body(body). + Execute() + if err != nil { + return err + } + + if !ctx.Renderer.IsText() { + return ctx.Renderer.Render(response) + } + + return ctx.Renderer.RenderText(func(stdout io.Writer) error { + _, err := fmt.Fprintf(stdout, "Triggered hook %q on node %s (canvas %s).\n", hook, nodeID, canvasID) + return err + }) +} + +// loadTriggerParameters supports three sources for the JSON payload: +// - `-` read from stdin +// - `@path/to.json` read from file +// - `{...}` inline JSON +// +// Returns nil when no parameters are provided so the API call sends an +// empty object (matching the UI default). +func loadTriggerParameters(input string, stdin io.Reader) (map[string]any, error) { + trimmed := strings.TrimSpace(input) + if trimmed == "" { + return nil, nil + } + + var raw []byte + switch { + case trimmed == "-": + var err error + raw, err = io.ReadAll(stdin) + if err != nil { + return nil, fmt.Errorf("failed to read parameters from stdin: %w", err) + } + case strings.HasPrefix(trimmed, "@"): + path := strings.TrimPrefix(trimmed, "@") + // #nosec G304 - path is supplied by the CLI user. + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("failed to read parameters file %s: %w", path, err) + } + raw = data + default: + raw = []byte(trimmed) + } + + if len(raw) == 0 { + return nil, nil + } + + parameters := map[string]any{} + if err := json.Unmarshal(raw, ¶meters); err != nil { + return nil, fmt.Errorf("invalid JSON parameters: %w", err) + } + return parameters, nil +} diff --git a/pkg/cli/commands/console/trigger_test.go b/pkg/cli/commands/console/trigger_test.go new file mode 100644 index 0000000000..d9fbd1f806 --- /dev/null +++ b/pkg/cli/commands/console/trigger_test.go @@ -0,0 +1,81 @@ +package console + +import ( + "encoding/json" + "io" + "net/http" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestTriggerInvokesNodeHookWithParameters(t *testing.T) { + var captured map[string]any + server := newAPITestServer(t, + requestExpectation{ + method: http.MethodGet, + path: "/api/v1/canvases/canvas-123", + handle: func(t *testing.T, w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"canvas": {"spec": {"nodes": [{"id":"node-1","name":"deploy"}]}}}`)) + }, + }, + requestExpectation{ + method: http.MethodPost, + path: "/api/v1/canvases/canvas-123/triggers/node-1/hooks/run", + handle: func(t *testing.T, w http.ResponseWriter, r *http.Request) { + body, err := io.ReadAll(r.Body) + require.NoError(t, err) + require.NoError(t, json.Unmarshal(body, &captured)) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{}`)) + }, + }, + ) + + ctx, _ := newCommandContext(t, server.server, "text") + cmd := &triggerCommand{ + canvasID: stringPtr("canvas-123"), + node: stringPtr("deploy"), + hook: stringPtr("run"), + parameters: stringPtr(`{"environment": "prod"}`), + } + + require.NoError(t, cmd.Execute(ctx)) + + parameters, _ := captured["parameters"].(map[string]any) + require.NotNil(t, parameters) + require.Equal(t, "prod", parameters["environment"]) +} + +func TestTriggerRejectsInvalidJSON(t *testing.T) { + server := newAPITestServer(t) + ctx, _ := newCommandContext(t, server.server, "text") + + cmd := &triggerCommand{ + canvasID: stringPtr("canvas-123"), + node: stringPtr("node-1"), + hook: stringPtr("run"), + parameters: stringPtr("not-json"), + } + + err := cmd.Execute(ctx) + require.Error(t, err) + require.Contains(t, err.Error(), "invalid JSON parameters") +} + +func TestTriggerRequiresNode(t *testing.T) { + server := newAPITestServer(t) + ctx, _ := newCommandContext(t, server.server, "text") + + cmd := &triggerCommand{ + canvasID: stringPtr("canvas-123"), + node: stringPtr(""), + hook: stringPtr("run"), + parameters: stringPtr(""), + } + + err := cmd.Execute(ctx) + require.Error(t, err) + require.Contains(t, err.Error(), "--node is required") +} diff --git a/pkg/cli/commands/index/dump.go b/pkg/cli/commands/index/dump.go index 19398494dc..2286ae96bf 100644 --- a/pkg/cli/commands/index/dump.go +++ b/pkg/cli/commands/index/dump.go @@ -31,6 +31,7 @@ type indexDump struct { Integrations []openapi_client.IntegrationsIntegrationDefinition `json:"integrations"` Actions []openapi_client.SuperplaneActionsAction `json:"actions"` Triggers []openapi_client.TriggersTrigger `json:"triggers"` + Widgets []openapi_client.WidgetsWidget `json:"widgets"` } type dumpCommand struct { @@ -78,6 +79,17 @@ func (c *dumpCommand) Execute(ctx core.CommandContext) error { return nil }) + g.Go(func() error { + resp, _, err := ctx.API.WidgetAPI.WidgetsListWidgets(gctx).Execute() + if err != nil { + return fmt.Errorf("fetching widgets: %w", err) + } + mu.Lock() + dump.Widgets = resp.GetWidgets() + mu.Unlock() + return nil + }) + if err := g.Wait(); err != nil { return err } diff --git a/pkg/cli/commands/index/dump_test.go b/pkg/cli/commands/index/dump_test.go index 0fd31b88dd..d58faa8647 100644 --- a/pkg/cli/commands/index/dump_test.go +++ b/pkg/cli/commands/index/dump_test.go @@ -34,6 +34,7 @@ func TestDumpWritesFullRegistryToFile(t *testing.T) { "/api/v1/integrations": integrationsListResponse, "/api/v1/actions": actionsListResponse, "/api/v1/triggers": triggersListResponse, + "/api/v1/widgets": widgetsListResponse, }) outFile := filepath.Join(t.TempDir(), "index.json") @@ -47,7 +48,7 @@ func TestDumpWritesFullRegistryToFile(t *testing.T) { require.Contains(t, stdout.String(), outFile) require.Contains(t, stdout.String(), "Index downloaded to") - // The file must exist and be valid JSON with all three sections. + // The file must exist and be valid JSON with all four sections. raw, err := os.ReadFile(outFile) require.NoError(t, err) @@ -62,6 +63,9 @@ func TestDumpWritesFullRegistryToFile(t *testing.T) { require.Len(t, dump.Triggers, 1) require.Equal(t, "cron", dump.Triggers[0].GetName()) + + require.Len(t, dump.Widgets, 1) + require.Equal(t, "annotation", dump.Widgets[0].GetName()) } func TestDumpIntegrationContainsNestedFields(t *testing.T) { @@ -69,6 +73,7 @@ func TestDumpIntegrationContainsNestedFields(t *testing.T) { "/api/v1/integrations": integrationsListResponse, "/api/v1/actions": actionsListResponse, "/api/v1/triggers": triggersListResponse, + "/api/v1/widgets": widgetsListResponse, }) outFile := filepath.Join(t.TempDir(), "index.json") @@ -109,6 +114,7 @@ func TestDumpFullComponentFields(t *testing.T) { "/api/v1/integrations": integrationsListResponse, "/api/v1/actions": actionsListResponse, "/api/v1/triggers": triggersListResponse, + "/api/v1/widgets": widgetsListResponse, }) outFile := filepath.Join(t.TempDir(), "index.json") @@ -140,6 +146,7 @@ func TestDumpFullTriggerFields(t *testing.T) { "/api/v1/integrations": integrationsListResponse, "/api/v1/actions": actionsListResponse, "/api/v1/triggers": triggersListResponse, + "/api/v1/widgets": widgetsListResponse, }) outFile := filepath.Join(t.TempDir(), "index.json") @@ -196,6 +203,7 @@ func TestDumpErrorOnUnwritablePath(t *testing.T) { "/api/v1/integrations": integrationsListResponse, "/api/v1/actions": actionsListResponse, "/api/v1/triggers": triggersListResponse, + "/api/v1/widgets": widgetsListResponse, }) // A directory that doesn't exist cannot be written to. @@ -213,6 +221,7 @@ func TestDumpUsesProvidedOpenAPITypes(t *testing.T) { "/api/v1/integrations": integrationsListResponse, "/api/v1/actions": actionsListResponse, "/api/v1/triggers": triggersListResponse, + "/api/v1/widgets": widgetsListResponse, }) outFile := filepath.Join(t.TempDir(), "index.json") @@ -236,4 +245,7 @@ func TestDumpUsesProvidedOpenAPITypes(t *testing.T) { var trigger openapi_client.TriggersTrigger require.IsType(t, trigger, dump.Triggers[0]) + + var widget openapi_client.WidgetsWidget + require.IsType(t, widget, dump.Widgets[0]) } diff --git a/pkg/cli/commands/index/index_test.go b/pkg/cli/commands/index/index_test.go index ef3cd51536..79bf72aab5 100644 --- a/pkg/cli/commands/index/index_test.go +++ b/pkg/cli/commands/index/index_test.go @@ -61,6 +61,26 @@ const triggersListResponse = `{ ] }` +const widgetsListResponse = `{ + "widgets": [ + { + "name": "annotation", + "label": "Annotation", + "description": "Add text annotations and notes to your workflow", + "icon": "sticky-note", + "color": "yellow", + "configuration": [ + { + "name": "text", + "type": "text", + "required": true, + "description": "Text content for the annotation" + } + ] + } + ] +}` + // Integrations tests func TestIntegrationsListReturnsSummaryJSON(t *testing.T) { diff --git a/pkg/cli/commands/index/widgets.go b/pkg/cli/commands/index/widgets.go index 98926be87e..722873f905 100644 --- a/pkg/cli/commands/index/widgets.go +++ b/pkg/cli/commands/index/widgets.go @@ -13,20 +13,28 @@ import ( func newWidgetsCommand(options core.BindOptions) *cobra.Command { var name string + var full bool cmd := &cobra.Command{ Use: "widgets", Short: "List or describe available widgets", - Args: cobra.NoArgs, + Long: `List or describe available widgets. + +Use -o json or -o yaml with --name to inspect configuration fields, +defaults, and field-level constraints. Pass --full when listing widgets +to include configuration in the json/yaml payload.`, + Args: cobra.NoArgs, } cmd.Flags().StringVar(&name, "name", "", "widget name") - core.Bind(cmd, &widgetsCommand{name: &name}, options) + cmd.Flags().BoolVar(&full, "full", false, "show full output including all fields") + core.Bind(cmd, &widgetsCommand{name: &name, full: &full}, options) return cmd } type widgetsCommand struct { name *string + full *bool } func (c *widgetsCommand) Execute(ctx core.CommandContext) error { @@ -41,7 +49,19 @@ func (c *widgetsCommand) Execute(ctx core.CommandContext) error { } if !ctx.Renderer.IsText() { - return ctx.Renderer.Render(widgets) + if c.full != nil && *c.full { + return ctx.Renderer.Render(widgets) + } + + summary := make([]map[string]string, len(widgets)) + for i, widget := range widgets { + summary[i] = map[string]string{ + "name": widget.GetName(), + "label": widget.GetLabel(), + "description": widget.GetDescription(), + } + } + return ctx.Renderer.Render(summary) } return ctx.Renderer.RenderText(func(stdout io.Writer) error { @@ -74,10 +94,7 @@ func (c *widgetsCommand) getWidgetByName(ctx core.CommandContext, name string) e } return ctx.Renderer.RenderText(func(stdout io.Writer) error { - _, _ = fmt.Fprintf(stdout, "Name: %s\n", widget.GetName()) - _, _ = fmt.Fprintf(stdout, "Label: %s\n", widget.GetLabel()) - _, err := fmt.Fprintf(stdout, "Description: %s\n", widget.GetDescription()) - return err + return renderWidgetText(stdout, widget) }) } @@ -89,3 +106,34 @@ func (c *widgetsCommand) findWidgetByName(ctx core.CommandContext, name string) return response.GetWidget(), nil } + +// renderWidgetText prints a widget's metadata and its configuration fields +// in the same tabular layout used by other index entries (actions, +// triggers). Color and icon are surfaced because canvas widget instances +// inherit them and users typically want to see what they're getting. +func renderWidgetText(stdout io.Writer, widget openapi_client.WidgetsWidget) error { + if _, err := fmt.Fprintf(stdout, "Name: %s\n", widget.GetName()); err != nil { + return err + } + if _, err := fmt.Fprintf(stdout, "Label: %s\n", widget.GetLabel()); err != nil { + return err + } + if _, err := fmt.Fprintf(stdout, "Description: %s\n", widget.GetDescription()); err != nil { + return err + } + if icon := widget.GetIcon(); icon != "" { + if _, err := fmt.Fprintf(stdout, "Icon: %s\n", icon); err != nil { + return err + } + } + if color := widget.GetColor(); color != "" { + if _, err := fmt.Fprintf(stdout, "Color: %s\n", color); err != nil { + return err + } + } + if _, err := fmt.Fprintln(stdout); err != nil { + return err + } + + return renderConfigurationText(stdout, widget.GetConfiguration()) +} diff --git a/pkg/cli/commands/index/widgets_test.go b/pkg/cli/commands/index/widgets_test.go index d201dab00c..230484bb55 100644 --- a/pkg/cli/commands/index/widgets_test.go +++ b/pkg/cli/commands/index/widgets_test.go @@ -23,7 +23,8 @@ func TestWidgetsCommandExecuteListText(t *testing.T) { ctx, stdout := newWidgetsCommandContextForTest(t, server, "text") name := "" - command := widgetsCommand{name: &name} + full := false + command := widgetsCommand{name: &name, full: &full} err := command.Execute(ctx) require.NoError(t, err) @@ -43,7 +44,8 @@ func TestWidgetsCommandExecuteDescribeJSON(t *testing.T) { ctx, stdout := newWidgetsCommandContextForTest(t, server, "json") name := "annotation" - command := widgetsCommand{name: &name} + full := false + command := widgetsCommand{name: &name, full: &full} err := command.Execute(ctx) require.NoError(t, err) @@ -62,7 +64,8 @@ func TestWidgetsCommandExecuteDescribeText(t *testing.T) { ctx, stdout := newWidgetsCommandContextForTest(t, server, "text") name := "annotation" - command := widgetsCommand{name: &name} + full := false + command := widgetsCommand{name: &name, full: &full} err := command.Execute(ctx) require.NoError(t, err) @@ -83,7 +86,8 @@ func TestWidgetsCommandExecuteListYAML(t *testing.T) { ctx, stdout := newWidgetsCommandContextForTest(t, server, "yaml") name := "" - command := widgetsCommand{name: &name} + full := false + command := widgetsCommand{name: &name, full: &full} err := command.Execute(ctx) require.NoError(t, err) @@ -103,7 +107,8 @@ func TestWidgetsCommandExecuteReturnsAPIError(t *testing.T) { ctx, _ := newWidgetsCommandContextForTest(t, server, "text") name := "annotation" - command := widgetsCommand{name: &name} + full := false + command := widgetsCommand{name: &name, full: &full} err := command.Execute(ctx) require.Error(t, err) diff --git a/pkg/cli/commands/widgets/add.go b/pkg/cli/commands/widgets/add.go new file mode 100644 index 0000000000..f67ce14eec --- /dev/null +++ b/pkg/cli/commands/widgets/add.go @@ -0,0 +1,148 @@ +package widgets + +import ( + "fmt" + "io" + + "github.com/google/uuid" + "github.com/superplanehq/superplane/pkg/cli/core" + "github.com/superplanehq/superplane/pkg/openapi_client" +) + +type addCommand struct { + canvasID *string + component *string + name *string + configuration *string + positionX *int32 + positionY *int32 + width *int32 + height *int32 + color *string + text *string + draft *bool +} + +// Execute adds a new TYPE_WIDGET node to the canvas spec. +// +// The implementation reuses the canvases change-management flow: it +// resolves (or creates) the user's draft version, mutates the spec to add +// the widget, and publishes the draft unless --draft is specified or the +// canvas requires explicit change requests. +func (c *addCommand) Execute(ctx core.CommandContext) error { + canvasID, err := core.ResolveCanvasID(ctx, valueOf(c.canvasID)) + if err != nil { + return err + } + + component := valueOf(c.component) + if component == "" { + return fmt.Errorf("--component is required (e.g. annotation)") + } + + cmEnabled, err := changeManagementEnabled(ctx, canvasID) + if err != nil { + return err + } + draftMode := c.draft != nil && *c.draft + if cmEnabled && !draftMode { + return fmt.Errorf("change management is enabled for this canvas; pass --draft and publish via `superplane canvases change-requests`") + } + + versionID, err := ensureCurrentUserDraftVersionID(ctx, canvasID) + if err != nil { + return err + } + version, err := describeCanvasVersionByID(ctx, canvasID, versionID) + if err != nil { + return err + } + + cfg, err := configurationFromInput(valueOf(c.configuration), ctx.Cmd.InOrStdin()) + if err != nil { + return err + } + cfg = applyAnnotationShortcuts(cfg, valueOf(c.text), valueOf(c.color), int32Value(c.width), int32Value(c.height)) + + posX, hasX := flagInt32IfChanged(ctx, "position-x", c.positionX) + posY, hasY := flagInt32IfChanged(ctx, "position-y", c.positionY) + node := buildWidgetNode(component, valueOf(c.name), cfg, posX, posY, hasX || hasY) + + canvas := canvasFromVersion(version) + if canvas.Spec == nil { + canvas.SetSpec(openapi_client.CanvasesCanvasSpec{}) + } + spec := canvas.GetSpec() + nodes := spec.GetNodes() + nodes = append(nodes, node) + spec.SetNodes(nodes) + canvas.SetSpec(spec) + + updatedVersion, err := updateAndMaybePublish(ctx, canvasID, versionID, canvas, draftMode) + if err != nil { + return err + } + + if !ctx.Renderer.IsText() { + return ctx.Renderer.Render(node) + } + return ctx.Renderer.RenderText(func(stdout io.Writer) error { + _, _ = fmt.Fprintf(stdout, "Widget added: %s (component: %s)\n", node.GetId(), component) + metadata := updatedVersion.GetMetadata() + _, err := fmt.Fprintf(stdout, "Canvas version: %s\n", metadata.GetId()) + return err + }) +} + +// buildWidgetNode assembles the SuperplaneComponentsNode payload for a new +// widget. When --name is omitted we fall back to the component name so the +// node renders with a friendly label in the UI immediately after creation. +// +// Position is only attached when the caller explicitly provided a flag, +// signaled by hasPosition. This avoids stamping (0, 0) on every new node +// just because the position-x/y flags default to zero. +func buildWidgetNode( + component, name string, + configuration map[string]any, + x, y int32, + hasPosition bool, +) openapi_client.SuperplaneComponentsNode { + node := openapi_client.SuperplaneComponentsNode{} + node.SetId(uuid.NewString()) + if name == "" { + name = component + } + node.SetName(name) + widgetType := openapi_client.COMPONENTSNODETYPE_TYPE_WIDGET + node.Type = &widgetType + node.SetComponent(component) + if configuration != nil { + node.SetConfiguration(configuration) + } + if hasPosition { + position := openapi_client.ComponentsPosition{} + position.SetX(x) + position.SetY(y) + node.SetPosition(position) + } + return node +} + +func int32Value(p *int32) int32 { + if p == nil { + return 0 + } + return *p +} + +// flagInt32IfChanged returns the current flag value and a bool indicating +// whether the user explicitly passed it (cobra `Flags().Changed`). This +// keeps the "did the user set this?" check at execute time so commands +// don't apply default-zero positions or sizes by accident. +func flagInt32IfChanged(ctx core.CommandContext, flagName string, value *int32) (int32, bool) { + v := int32Value(value) + if ctx.Cmd == nil || ctx.Cmd.Flags() == nil { + return v, false + } + return v, ctx.Cmd.Flags().Changed(flagName) +} diff --git a/pkg/cli/commands/widgets/add_test.go b/pkg/cli/commands/widgets/add_test.go new file mode 100644 index 0000000000..a51d34e90f --- /dev/null +++ b/pkg/cli/commands/widgets/add_test.go @@ -0,0 +1,132 @@ +package widgets + +import ( + "encoding/json" + "io" + "net/http" + "testing" + + "github.com/stretchr/testify/require" +) + +// canvasResponseWith returns a canvas response that has change management +// disabled — used to seed the GET /canvases/ calls that mutation +// commands make to check change management state and to read the spec. +func canvasResponseWith(spec string) string { + return `{"canvas": {"spec": {` + spec + `, "changeManagement": {"enabled": false}}}}` +} + +func TestAddPublishesAnnotationWidgetNode(t *testing.T) { + var captured map[string]any + server := newAPITestServer(t, + // changeManagementEnabled lookup + requestExpectation{ + method: http.MethodGet, + path: "/api/v1/canvases/canvas-123", + handle: func(t *testing.T, w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(canvasResponseWith(`"nodes": [], "edges": []`))) + }, + }, + // list versions to find existing draft + requestExpectation{ + method: http.MethodGet, + path: "/api/v1/canvases/canvas-123/versions", + handle: func(t *testing.T, w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"versions": [{"metadata": {"id": "ver-1", "state": "STATE_DRAFT"}}]}`)) + }, + }, + // describe draft version (returns spec used as base) + requestExpectation{ + method: http.MethodGet, + path: "/api/v1/canvases/canvas-123/versions/ver-1", + handle: func(t *testing.T, w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"version": {"metadata":{"id":"ver-1"}, "spec": {"nodes": [], "edges": []}}}`)) + }, + }, + // update version + requestExpectation{ + method: http.MethodPut, + path: "/api/v1/canvases/canvas-123/versions", + handle: func(t *testing.T, w http.ResponseWriter, r *http.Request) { + body, err := io.ReadAll(r.Body) + require.NoError(t, err) + require.NoError(t, json.Unmarshal(body, &captured)) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"version": {"metadata":{"id":"ver-1"}, "spec":{"nodes": [], "edges": []}}}`)) + }, + }, + // publish the draft (default: not in --draft mode) + requestExpectation{ + method: http.MethodPatch, + path: "/api/v1/canvases/canvas-123/versions/ver-1/publish", + handle: func(t *testing.T, w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{}`)) + }, + }, + ) + + ctx, _ := newCommandContext(t, server.server, "text") + cmd := &addCommand{ + canvasID: stringPtr("canvas-123"), + component: stringPtr("annotation"), + name: stringPtr("note-1"), + configuration: stringPtr(""), + positionX: int32Ptr(0), + positionY: int32Ptr(0), + width: int32Ptr(0), + height: int32Ptr(0), + color: stringPtr(""), + text: stringPtr("Hello!"), + draft: boolPtr(false), + } + + require.NoError(t, cmd.Execute(ctx)) + + canvas, _ := captured["canvas"].(map[string]any) + require.NotNil(t, canvas) + spec, _ := canvas["spec"].(map[string]any) + require.NotNil(t, spec) + nodes, _ := spec["nodes"].([]any) + require.Len(t, nodes, 1) + + first, _ := nodes[0].(map[string]any) + require.Equal(t, "TYPE_WIDGET", first["type"]) + require.Equal(t, "annotation", first["component"]) + require.Equal(t, "note-1", first["name"]) + + cfg, _ := first["configuration"].(map[string]any) + require.Equal(t, "Hello!", cfg["text"]) +} + +func TestAddRejectsWhenChangeManagementEnabledWithoutDraft(t *testing.T) { + server := newAPITestServer(t, requestExpectation{ + method: http.MethodGet, + path: "/api/v1/canvases/canvas-123", + handle: func(t *testing.T, w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"canvas": {"spec": {"nodes": [], "changeManagement": {"enabled": true}}}}`)) + }, + }) + + ctx, _ := newCommandContext(t, server.server, "text") + cmd := &addCommand{ + canvasID: stringPtr("canvas-123"), + component: stringPtr("annotation"), + name: stringPtr(""), + configuration: stringPtr(""), + positionX: int32Ptr(0), + positionY: int32Ptr(0), + width: int32Ptr(0), + height: int32Ptr(0), + color: stringPtr(""), + text: stringPtr(""), + draft: boolPtr(false), + } + err := cmd.Execute(ctx) + require.Error(t, err) + require.Contains(t, err.Error(), "change management is enabled") +} diff --git a/pkg/cli/commands/widgets/change_management.go b/pkg/cli/commands/widgets/change_management.go new file mode 100644 index 0000000000..2fe6ceffa2 --- /dev/null +++ b/pkg/cli/commands/widgets/change_management.go @@ -0,0 +1,134 @@ +package widgets + +import ( + "fmt" + "strings" + + "github.com/superplanehq/superplane/pkg/cli/core" + "github.com/superplanehq/superplane/pkg/openapi_client" +) + +// findCurrentUserDraftVersionID returns the current user's draft version +// id, or "" if no draft exists yet. Mirrors the canvases package helper. +func findCurrentUserDraftVersionID(ctx core.CommandContext, canvasID string) (string, error) { + response, _, err := ctx.API.CanvasVersionAPI.CanvasesListCanvasVersions(ctx.Context, canvasID).Execute() + if err != nil { + return "", err + } + + for _, version := range response.GetVersions() { + metadata := version.GetMetadata() + if metadata.GetState() == openapi_client.CANVASESCANVASVERSIONSTATE_STATE_PUBLISHED { + continue + } + if id := strings.TrimSpace(metadata.GetId()); id != "" { + return id, nil + } + } + return "", nil +} + +func ensureCurrentUserDraftVersionID(ctx core.CommandContext, canvasID string) (string, error) { + versionID, err := findCurrentUserDraftVersionID(ctx, canvasID) + if err != nil { + return "", err + } + if versionID != "" { + return versionID, nil + } + + response, _, err := ctx.API.CanvasVersionAPI. + CanvasesCreateCanvasVersion(ctx.Context, canvasID). + Body(map[string]any{}). + Execute() + if err != nil { + return "", err + } + if response.Version == nil || response.Version.Metadata == nil { + return "", fmt.Errorf("draft version was not returned by the API") + } + + id := strings.TrimSpace(response.Version.Metadata.GetId()) + if id == "" { + return "", fmt.Errorf("draft version id was not returned by the API") + } + return id, nil +} + +// changeManagementEnabled tells callers whether the canvas requires going +// through the change-request flow rather than auto-publishing. +func changeManagementEnabled(ctx core.CommandContext, canvasID string) (bool, error) { + response, _, err := ctx.API.CanvasAPI.CanvasesDescribeCanvas(ctx.Context, canvasID).Execute() + if err != nil { + return false, err + } + if response.Canvas == nil { + return false, fmt.Errorf("canvas not found") + } + spec := response.Canvas.GetSpec() + cm := spec.GetChangeManagement() + return cm.GetEnabled(), nil +} + +// canvasFromVersion returns a canvas containing the given version's spec +// (the wire shape required by the update-version endpoint). +func canvasFromVersion(version openapi_client.CanvasesCanvasVersion) openapi_client.CanvasesCanvas { + canvas := openapi_client.CanvasesCanvas{} + if version.Spec != nil { + canvas.SetSpec(*version.Spec) + } + return canvas +} + +// describeCanvasVersionByID reads a specific canvas version, used so the +// CLI can mutate the same draft the API would publish on auto-publish. +func describeCanvasVersionByID( + ctx core.CommandContext, + canvasID, versionID string, +) (openapi_client.CanvasesCanvasVersion, error) { + response, _, err := ctx.API.CanvasVersionAPI.CanvasesDescribeCanvasVersion(ctx.Context, canvasID, versionID).Execute() + if err != nil { + return openapi_client.CanvasesCanvasVersion{}, err + } + if response.Version == nil { + return openapi_client.CanvasesCanvasVersion{}, fmt.Errorf("canvas version %q not found", versionID) + } + return *response.Version, nil +} + +// updateAndMaybePublish persists the new canvas spec into the user's draft +// version and (when not in --draft mode) publishes the draft so the changes +// land in the live canvas. Errors from the publish step include the prefix +// "draft was updated but publish failed" so users know they can re-run a +// `superplane canvases change-requests publish` instead of starting over. +func updateAndMaybePublish( + ctx core.CommandContext, + canvasID, versionID string, + canvas openapi_client.CanvasesCanvas, + draftMode bool, +) (openapi_client.CanvasesCanvasVersion, error) { + body := openapi_client.CanvasesUpdateCanvasVersionBody{} + body.SetCanvas(canvas) + body.SetVersionId(versionID) + + response, _, err := ctx.API.CanvasVersionAPI. + CanvasesUpdateCanvasVersion2(ctx.Context, canvasID). + Body(body). + Execute() + if err != nil { + return openapi_client.CanvasesCanvasVersion{}, err + } + version := response.GetVersion() + + if draftMode { + return version, nil + } + + if _, _, err := ctx.API.CanvasVersionAPI. + CanvasesPublishCanvasVersion(ctx.Context, canvasID, versionID). + Body(map[string]any{}). + Execute(); err != nil { + return version, fmt.Errorf("draft was updated but publish failed: %w", err) + } + return version, nil +} diff --git a/pkg/cli/commands/widgets/common.go b/pkg/cli/commands/widgets/common.go new file mode 100644 index 0000000000..a59eed0b6a --- /dev/null +++ b/pkg/cli/commands/widgets/common.go @@ -0,0 +1,186 @@ +package widgets + +import ( + "encoding/json" + "fmt" + "io" + "os" + "strings" + + "github.com/superplanehq/superplane/pkg/cli/core" + "github.com/superplanehq/superplane/pkg/openapi_client" +) + +// canvasNodeIsWidget returns true when the given node is a widget node +// (TYPE_WIDGET). Widget nodes are the canvas-side counterpart to the +// widgets registered through `pkg/widgets/` — for example annotation +// notes are TYPE_WIDGET nodes referencing the `annotation` component. +func canvasNodeIsWidget(node openapi_client.SuperplaneComponentsNode) bool { + if node.Type == nil { + return false + } + return *node.Type == openapi_client.COMPONENTSNODETYPE_TYPE_WIDGET +} + +// fetchCanvas loads the latest published canvas spec for read-only commands. +// Mutations should pull the user's draft via the change-management helpers +// instead so they don't observe a stale view. +func fetchCanvas(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.Canvas == nil { + return openapi_client.CanvasesCanvas{}, fmt.Errorf("canvas %q not found", canvasID) + } + return *response.Canvas, nil +} + +// findWidgetNode locates a TYPE_WIDGET node by id or name. Names are case +// sensitive but unambiguous: if more than one widget node shares a name the +// caller gets a clear error rather than silently picking one. +func findWidgetNode(canvas openapi_client.CanvasesCanvas, idOrName string) (openapi_client.SuperplaneComponentsNode, error) { + idOrName = strings.TrimSpace(idOrName) + if idOrName == "" { + return openapi_client.SuperplaneComponentsNode{}, fmt.Errorf("widget id or name is required") + } + if canvas.Spec == nil { + return openapi_client.SuperplaneComponentsNode{}, fmt.Errorf("canvas has no spec") + } + + spec := *canvas.Spec + matches := []openapi_client.SuperplaneComponentsNode{} + for _, node := range spec.GetNodes() { + if !canvasNodeIsWidget(node) { + continue + } + if node.GetId() == idOrName || node.GetName() == idOrName { + matches = append(matches, node) + } + } + + if len(matches) == 0 { + return openapi_client.SuperplaneComponentsNode{}, fmt.Errorf("widget %q not found", idOrName) + } + if len(matches) > 1 { + return openapi_client.SuperplaneComponentsNode{}, fmt.Errorf("multiple widgets match %q", idOrName) + } + return matches[0], nil +} + +// configurationFromInput resolves a widget configuration map from one of +// three sources: --configuration inline JSON, --configuration @file, or the +// stdin sentinel `-`. The empty string is returned as nil so callers know +// no override was provided. +func configurationFromInput(input string, stdin io.Reader) (map[string]any, error) { + trimmed := strings.TrimSpace(input) + if trimmed == "" { + return nil, nil + } + + var raw []byte + switch { + case trimmed == "-": + var err error + raw, err = io.ReadAll(stdin) + if err != nil { + return nil, fmt.Errorf("failed to read configuration from stdin: %w", err) + } + case strings.HasPrefix(trimmed, "@"): + path := strings.TrimPrefix(trimmed, "@") + // #nosec G304 - path is supplied by the CLI user. + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("failed to read configuration file %s: %w", path, err) + } + raw = data + default: + raw = []byte(trimmed) + } + + if len(raw) == 0 { + return nil, nil + } + + out := map[string]any{} + if err := json.Unmarshal(raw, &out); err != nil { + return nil, fmt.Errorf("invalid JSON configuration: %w", err) + } + return out, nil +} + +// applyAnnotationShortcuts merges the annotation-specific convenience flags +// (--text, --color, --width, --height) into the configuration map. Values +// from --configuration win over individual flags so callers can use both +// without surprises (the JSON is treated as the explicit source of truth). +func applyAnnotationShortcuts(cfg map[string]any, text, color string, width, height int32) map[string]any { + if cfg == nil { + cfg = map[string]any{} + } + if text != "" { + if _, ok := cfg["text"]; !ok { + cfg["text"] = text + } + } + if color != "" { + if _, ok := cfg["color"]; !ok { + cfg["color"] = color + } + } + if width > 0 { + if _, ok := cfg["width"]; !ok { + cfg["width"] = width + } + } + if height > 0 { + if _, ok := cfg["height"]; !ok { + cfg["height"] = height + } + } + return cfg +} + +// renderNodeText prints a single widget node in a friendly key/value form +// suitable for the default text renderer. The configuration block is +// pretty-printed as JSON because it varies per widget component. +func renderNodeText(stdout io.Writer, node openapi_client.SuperplaneComponentsNode) error { + if _, err := fmt.Fprintf(stdout, "ID: %s\n", node.GetId()); err != nil { + return err + } + if _, err := fmt.Fprintf(stdout, "Name: %s\n", node.GetName()); err != nil { + return err + } + if _, err := fmt.Fprintf(stdout, "Component: %s\n", node.GetComponent()); err != nil { + return err + } + if node.Position != nil { + if _, err := fmt.Fprintf(stdout, "Position: %d,%d\n", node.Position.GetX(), node.Position.GetY()); err != nil { + return err + } + } + if msg := node.GetErrorMessage(); msg != "" { + if _, err := fmt.Fprintf(stdout, "Error: %s\n", msg); err != nil { + return err + } + } + + cfg := node.GetConfiguration() + if len(cfg) == 0 { + _, err := fmt.Fprintln(stdout, "Configuration: (none)") + return err + } + + encoded, err := json.MarshalIndent(cfg, "", " ") + if err != nil { + return err + } + if _, err := fmt.Fprintln(stdout, "Configuration:"); err != nil { + return err + } + for _, line := range strings.Split(string(encoded), "\n") { + if _, err := fmt.Fprintf(stdout, " %s\n", line); err != nil { + return err + } + } + return nil +} diff --git a/pkg/cli/commands/widgets/delete.go b/pkg/cli/commands/widgets/delete.go new file mode 100644 index 0000000000..4643e041df --- /dev/null +++ b/pkg/cli/commands/widgets/delete.go @@ -0,0 +1,109 @@ +package widgets + +import ( + "fmt" + "io" + + "github.com/superplanehq/superplane/pkg/cli/core" + "github.com/superplanehq/superplane/pkg/openapi_client" +) + +type deleteCommand struct { + canvasID *string + yes *bool + draft *bool +} + +// Execute removes a widget node from the canvas spec, including any edges +// that point at it (none today since widget nodes are decorative, but the +// pruning is harmless and keeps the spec clean if that ever changes). +func (c *deleteCommand) Execute(ctx core.CommandContext) error { + canvasID, err := core.ResolveCanvasID(ctx, valueOf(c.canvasID)) + if err != nil { + return err + } + + cmEnabled, err := changeManagementEnabled(ctx, canvasID) + if err != nil { + return err + } + draftMode := c.draft != nil && *c.draft + if cmEnabled && !draftMode { + return fmt.Errorf("change management is enabled for this canvas; pass --draft and publish via `superplane canvases change-requests`") + } + + versionID, err := ensureCurrentUserDraftVersionID(ctx, canvasID) + if err != nil { + return err + } + version, err := describeCanvasVersionByID(ctx, canvasID, versionID) + if err != nil { + return err + } + canvas := canvasFromVersion(version) + if canvas.Spec == nil { + return fmt.Errorf("canvas has no spec to update") + } + + target, err := findWidgetNode(canvas, ctx.Args[0]) + if err != nil { + return err + } + + if !confirmDelete(ctx, c.yes, target.GetId()) { + _, err := fmt.Fprintln(ctx.Cmd.OutOrStdout(), "Aborted.") + return err + } + + spec := canvas.GetSpec() + updatedNodes := make([]openapi_client.SuperplaneComponentsNode, 0, len(spec.GetNodes())) + for _, node := range spec.GetNodes() { + if node.GetId() == target.GetId() { + continue + } + updatedNodes = append(updatedNodes, node) + } + + updatedEdges := make([]openapi_client.SuperplaneComponentsEdge, 0, len(spec.GetEdges())) + for _, edge := range spec.GetEdges() { + if edge.GetSourceId() == target.GetId() || edge.GetTargetId() == target.GetId() { + continue + } + updatedEdges = append(updatedEdges, edge) + } + + spec.SetNodes(updatedNodes) + spec.SetEdges(updatedEdges) + canvas.SetSpec(spec) + + updatedVersion, err := updateAndMaybePublish(ctx, canvasID, versionID, canvas, draftMode) + if err != nil { + return err + } + + if !ctx.Renderer.IsText() { + return ctx.Renderer.Render(map[string]any{ + "id": target.GetId(), + "deleted": true, + }) + } + return ctx.Renderer.RenderText(func(stdout io.Writer) error { + _, _ = fmt.Fprintf(stdout, "Widget deleted: %s\n", target.GetId()) + metadata := updatedVersion.GetMetadata() + _, err := fmt.Fprintf(stdout, "Canvas version: %s\n", metadata.GetId()) + return err + }) +} + +func confirmDelete(ctx core.CommandContext, yes *bool, id string) bool { + if yes != nil && *yes { + return true + } + if !ctx.IsInteractive() { + return true + } + _, _ = fmt.Fprintf(ctx.Cmd.OutOrStdout(), "Delete widget %q? [y/N]: ", id) + var answer string + _, _ = fmt.Fscanln(ctx.Cmd.InOrStdin(), &answer) + return answer == "y" || answer == "Y" || answer == "yes" || answer == "YES" +} diff --git a/pkg/cli/commands/widgets/delete_test.go b/pkg/cli/commands/widgets/delete_test.go new file mode 100644 index 0000000000..84d68a7ede --- /dev/null +++ b/pkg/cli/commands/widgets/delete_test.go @@ -0,0 +1,82 @@ +package widgets + +import ( + "encoding/json" + "io" + "net/http" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestDeleteRemovesWidgetAndEdges(t *testing.T) { + var captured map[string]any + server := newAPITestServer(t, + // changeManagementEnabled lookup + requestExpectation{ + method: http.MethodGet, + path: "/api/v1/canvases/canvas-123", + handle: func(t *testing.T, w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"canvas": {"spec": {"nodes": [{"id":"w1","name":"note","type":"TYPE_WIDGET"}], "edges": [{"sourceId":"w1","targetId":"x"},{"sourceId":"x","targetId":"y"}], "changeManagement": {"enabled": false}}}}`)) + }, + }, + requestExpectation{ + method: http.MethodGet, + path: "/api/v1/canvases/canvas-123/versions", + handle: func(t *testing.T, w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"versions": [{"metadata": {"id": "ver-1", "state": "STATE_DRAFT"}}]}`)) + }, + }, + requestExpectation{ + method: http.MethodGet, + path: "/api/v1/canvases/canvas-123/versions/ver-1", + handle: func(t *testing.T, w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"version": {"metadata":{"id":"ver-1"}, "spec": {"nodes": [{"id":"w1","name":"note","type":"TYPE_WIDGET"}], "edges": [{"sourceId":"w1","targetId":"x"},{"sourceId":"x","targetId":"y"}]}}}`)) + }, + }, + requestExpectation{ + method: http.MethodPut, + path: "/api/v1/canvases/canvas-123/versions", + handle: func(t *testing.T, w http.ResponseWriter, r *http.Request) { + body, err := io.ReadAll(r.Body) + require.NoError(t, err) + require.NoError(t, json.Unmarshal(body, &captured)) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"version": {"metadata":{"id":"ver-1"}, "spec":{"nodes": [], "edges": []}}}`)) + }, + }, + requestExpectation{ + method: http.MethodPatch, + path: "/api/v1/canvases/canvas-123/versions/ver-1/publish", + handle: func(t *testing.T, w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{}`)) + }, + }, + ) + + ctx, _ := newCommandContext(t, server.server, "text") + ctx.Args = []string{"w1"} + cmd := &deleteCommand{ + canvasID: stringPtr("canvas-123"), + yes: boolPtr(true), + draft: boolPtr(false), + } + require.NoError(t, cmd.Execute(ctx)) + + canvas, _ := captured["canvas"].(map[string]any) + require.NotNil(t, canvas) + spec, _ := canvas["spec"].(map[string]any) + + nodes, _ := spec["nodes"].([]any) + require.Len(t, nodes, 0) + + edges, _ := spec["edges"].([]any) + require.Len(t, edges, 1) + first, _ := edges[0].(map[string]any) + require.Equal(t, "x", first["sourceId"]) + require.Equal(t, "y", first["targetId"]) +} diff --git a/pkg/cli/commands/widgets/get.go b/pkg/cli/commands/widgets/get.go new file mode 100644 index 0000000000..24666fe940 --- /dev/null +++ b/pkg/cli/commands/widgets/get.go @@ -0,0 +1,36 @@ +package widgets + +import ( + "io" + + "github.com/superplanehq/superplane/pkg/cli/core" +) + +type getCommand struct { + canvasID *string +} + +func (c *getCommand) Execute(ctx core.CommandContext) error { + canvasID, err := core.ResolveCanvasID(ctx, valueOf(c.canvasID)) + if err != nil { + return err + } + + canvas, err := fetchCanvas(ctx, canvasID) + if err != nil { + return err + } + + node, err := findWidgetNode(canvas, ctx.Args[0]) + if err != nil { + return err + } + + if !ctx.Renderer.IsText() { + return ctx.Renderer.Render(node) + } + + return ctx.Renderer.RenderText(func(stdout io.Writer) error { + return renderNodeText(stdout, node) + }) +} diff --git a/pkg/cli/commands/widgets/get_test.go b/pkg/cli/commands/widgets/get_test.go new file mode 100644 index 0000000000..f03c1dcce5 --- /dev/null +++ b/pkg/cli/commands/widgets/get_test.go @@ -0,0 +1,82 @@ +package widgets + +import ( + "net/http" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestGetReturnsWidgetByID(t *testing.T) { + server := newAPITestServer(t, requestExpectation{ + method: http.MethodGet, + path: "/api/v1/canvases/canvas-123", + handle: func(t *testing.T, w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{ + "canvas": { + "spec": { + "nodes": [ + {"id":"w1","name":"my-note","type":"TYPE_WIDGET","component":"annotation","configuration":{"text":"hi"},"position":{"x":5,"y":7}} + ] + } + } + }`)) + }, + }) + + ctx, stdout := newCommandContext(t, server.server, "text") + ctx.Args = []string{"w1"} + cmd := &getCommand{canvasID: stringPtr("canvas-123")} + + require.NoError(t, cmd.Execute(ctx)) + out := stdout.String() + require.Contains(t, out, "my-note") + require.Contains(t, out, "annotation") + require.Contains(t, out, "hi") +} + +func TestGetReturnsWidgetByName(t *testing.T) { + server := newAPITestServer(t, requestExpectation{ + method: http.MethodGet, + path: "/api/v1/canvases/canvas-123", + handle: func(t *testing.T, w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{ + "canvas": { + "spec": { + "nodes": [ + {"id":"w1","name":"my-note","type":"TYPE_WIDGET","component":"annotation"} + ] + } + } + }`)) + }, + }) + + ctx, stdout := newCommandContext(t, server.server, "text") + ctx.Args = []string{"my-note"} + cmd := &getCommand{canvasID: stringPtr("canvas-123")} + + require.NoError(t, cmd.Execute(ctx)) + require.Contains(t, stdout.String(), "my-note") +} + +func TestGetErrorsWhenWidgetMissing(t *testing.T) { + server := newAPITestServer(t, requestExpectation{ + method: http.MethodGet, + path: "/api/v1/canvases/canvas-123", + handle: func(t *testing.T, w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"canvas": {"spec": {"nodes": []}}}`)) + }, + }) + + ctx, _ := newCommandContext(t, server.server, "text") + ctx.Args = []string{"missing"} + cmd := &getCommand{canvasID: stringPtr("canvas-123")} + + err := cmd.Execute(ctx) + require.Error(t, err) + require.Contains(t, err.Error(), "missing") +} diff --git a/pkg/cli/commands/widgets/helpers_test.go b/pkg/cli/commands/widgets/helpers_test.go new file mode 100644 index 0000000000..18953b4313 --- /dev/null +++ b/pkg/cli/commands/widgets/helpers_test.go @@ -0,0 +1,107 @@ +package widgets + +import ( + "bytes" + "context" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/spf13/cobra" + "github.com/stretchr/testify/require" + "github.com/superplanehq/superplane/pkg/cli/core" + "github.com/superplanehq/superplane/pkg/openapi_client" +) + +type fakeConfig struct { + activeCanvas string +} + +func (f *fakeConfig) GetActiveCanvas() string { return f.activeCanvas } +func (f *fakeConfig) SetActiveCanvas(canvasID string) error { return nil } +func (f *fakeConfig) GetURL() string { return "" } + +type requestExpectation struct { + method string + path string + handle func(t *testing.T, w http.ResponseWriter, r *http.Request) +} + +type apiTestServer struct { + t *testing.T + expectations []requestExpectation + calls []string + server *httptest.Server +} + +func newAPITestServer(t *testing.T, expectations ...requestExpectation) *apiTestServer { + t.Helper() + s := &apiTestServer{t: t, expectations: expectations} + s.server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + s.calls = append(s.calls, r.Method+" "+r.URL.Path) + if len(s.expectations) == 0 { + w.WriteHeader(http.StatusNotFound) + return + } + next := s.expectations[0] + require.Equal(t, next.method, r.Method) + require.Equal(t, next.path, r.URL.Path) + s.expectations = s.expectations[1:] + if next.handle != nil { + next.handle(t, w, r) + } else { + w.WriteHeader(http.StatusOK) + } + })) + t.Cleanup(s.server.Close) + return s +} + +func (s *apiTestServer) AssertCalls(t *testing.T, calls []string) { + t.Helper() + require.Equal(t, calls, s.calls) +} + +func newCommandContext( + t *testing.T, + server *httptest.Server, + outputFormat string, +) (core.CommandContext, *bytes.Buffer) { + t.Helper() + + stdout := bytes.NewBuffer(nil) + renderer, err := core.NewRenderer(outputFormat, stdout) + require.NoError(t, err) + + cobraCmd := &cobra.Command{} + cobraCmd.SetOut(stdout) + cobraCmd.SetIn(strings.NewReader("")) + + ctx := core.CommandContext{ + Context: context.Background(), + Cmd: cobraCmd, + Renderer: renderer, + Config: &fakeConfig{}, + } + + if server != nil { + config := openapi_client.NewConfiguration() + config.Servers = openapi_client.ServerConfigurations{{URL: server.URL}} + ctx.API = openapi_client.NewAPIClient(config) + } + + return ctx, stdout +} + +func stringPtr(s string) *string { + return &s +} + +func boolPtr(b bool) *bool { + return &b +} + +func int32Ptr(i int32) *int32 { + return &i +} diff --git a/pkg/cli/commands/widgets/list.go b/pkg/cli/commands/widgets/list.go new file mode 100644 index 0000000000..d062dffeb7 --- /dev/null +++ b/pkg/cli/commands/widgets/list.go @@ -0,0 +1,73 @@ +package widgets + +import ( + "fmt" + "io" + "text/tabwriter" + + "github.com/superplanehq/superplane/pkg/cli/core" +) + +type listCommand struct { + canvasID *string +} + +func (c *listCommand) Execute(ctx core.CommandContext) error { + canvasID, err := core.ResolveCanvasID(ctx, valueOf(c.canvasID)) + if err != nil { + return err + } + + canvas, err := fetchCanvas(ctx, canvasID) + if err != nil { + return err + } + + spec := canvas.GetSpec() + widgets := []map[string]any{} + for _, node := range spec.GetNodes() { + if !canvasNodeIsWidget(node) { + continue + } + row := map[string]any{ + "id": node.GetId(), + "name": node.GetName(), + "component": node.GetComponent(), + } + if node.Position != nil { + row["position"] = map[string]int32{ + "x": node.Position.GetX(), + "y": node.Position.GetY(), + } + } + widgets = append(widgets, row) + } + + if !ctx.Renderer.IsText() { + return ctx.Renderer.Render(widgets) + } + + return ctx.Renderer.RenderText(func(stdout io.Writer) error { + if len(widgets) == 0 { + _, err := fmt.Fprintln(stdout, "No widget nodes on this canvas.") + return err + } + writer := tabwriter.NewWriter(stdout, 0, 8, 2, ' ', 0) + _, _ = fmt.Fprintln(writer, "ID\tNAME\tCOMPONENT\tPOSITION") + for _, w := range widgets { + pos := "-" + if posMap, ok := w["position"].(map[string]int32); ok { + pos = fmt.Sprintf("%d,%d", posMap["x"], posMap["y"]) + } + _, _ = fmt.Fprintf(writer, "%s\t%s\t%s\t%s\n", w["id"], w["name"], w["component"], pos) + } + return writer.Flush() + }) +} + +func valueOf(p *string) string { + if p == nil { + return "" + } + return *p +} diff --git a/pkg/cli/commands/widgets/list_test.go b/pkg/cli/commands/widgets/list_test.go new file mode 100644 index 0000000000..8f61ec95fd --- /dev/null +++ b/pkg/cli/commands/widgets/list_test.go @@ -0,0 +1,57 @@ +package widgets + +import ( + "net/http" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestListShowsOnlyWidgetNodes(t *testing.T) { + server := newAPITestServer(t, requestExpectation{ + method: http.MethodGet, + path: "/api/v1/canvases/canvas-123", + handle: func(t *testing.T, w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{ + "canvas": { + "spec": { + "nodes": [ + {"id":"a","name":"trigger","type":"TYPE_TRIGGER"}, + {"id":"b","name":"my-note","type":"TYPE_WIDGET","component":"annotation","position":{"x":10,"y":20}}, + {"id":"c","name":"action","type":"TYPE_ACTION"} + ] + } + } + }`)) + }, + }) + + ctx, stdout := newCommandContext(t, server.server, "text") + cmd := &listCommand{canvasID: stringPtr("canvas-123")} + + require.NoError(t, cmd.Execute(ctx)) + out := stdout.String() + require.Contains(t, out, "my-note") + require.Contains(t, out, "annotation") + require.Contains(t, out, "10,20") + require.NotContains(t, out, "trigger") + require.NotContains(t, out, "action") +} + +func TestListReportsEmptyState(t *testing.T) { + server := newAPITestServer(t, requestExpectation{ + method: http.MethodGet, + path: "/api/v1/canvases/canvas-123", + handle: func(t *testing.T, w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"canvas": {"spec": {"nodes": []}}}`)) + }, + }) + + ctx, stdout := newCommandContext(t, server.server, "text") + cmd := &listCommand{canvasID: stringPtr("canvas-123")} + + require.NoError(t, cmd.Execute(ctx)) + require.Contains(t, stdout.String(), "No widget nodes") +} diff --git a/pkg/cli/commands/widgets/root.go b/pkg/cli/commands/widgets/root.go new file mode 100644 index 0000000000..eacfc92b56 --- /dev/null +++ b/pkg/cli/commands/widgets/root.go @@ -0,0 +1,171 @@ +package widgets + +import ( + "github.com/spf13/cobra" + "github.com/superplanehq/superplane/pkg/cli/core" +) + +// NewCommand wires the `superplane widgets` command tree. +// +// These commands manage canvas widget *instances* — TYPE_WIDGET nodes +// embedded in a canvas spec (annotations, etc.). To discover the widget +// definitions registered with the platform see `superplane index widgets`. +func NewCommand(options core.BindOptions) *cobra.Command { + root := &cobra.Command{ + Use: "widgets", + Short: "Manage widget nodes embedded in a canvas", + Long: `Manage widget nodes embedded in a canvas (TYPE_WIDGET). + +These commands operate on widget instances inside a canvas spec; for the +catalog of available widgets see "superplane index widgets". Mutations +go through the regular canvas draft flow, so --draft is required when +the canvas has change management enabled.`, + } + + addListCommand(root, options) + addGetCommand(root, options) + addAddCommand(root, options) + addUpdateCommand(root, options) + addDeleteCommand(root, options) + + return root +} + +func addListCommand(root *cobra.Command, options core.BindOptions) { + canvasID := "" + cmd := &cobra.Command{ + Use: "list", + Short: "List widget nodes on a canvas", + Args: cobra.NoArgs, + } + cmd.Flags().StringVar(&canvasID, "canvas-id", "", "canvas id (defaults to the active canvas)") + core.Bind(cmd, &listCommand{canvasID: &canvasID}, options) + root.AddCommand(cmd) +} + +func addGetCommand(root *cobra.Command, options core.BindOptions) { + canvasID := "" + cmd := &cobra.Command{ + Use: "get ", + Short: "Show a widget node", + Args: cobra.ExactArgs(1), + } + cmd.Flags().StringVar(&canvasID, "canvas-id", "", "canvas id (defaults to the active canvas)") + core.Bind(cmd, &getCommand{canvasID: &canvasID}, options) + root.AddCommand(cmd) +} + +func addAddCommand(root *cobra.Command, options core.BindOptions) { + canvasID := "" + component := "" + name := "" + configuration := "" + positionX := int32(0) + positionY := int32(0) + width := int32(0) + height := int32(0) + color := "" + text := "" + draft := false + + cmd := &cobra.Command{ + Use: "add", + Short: "Add a widget node to a canvas", + Long: `Add a widget node to a canvas. + +Use --component to pick the widget definition (see "superplane index +widgets"), and --configuration with inline JSON, @file.json, or - for +stdin. Annotation widgets accept --text, --color, --width, and --height +shortcuts that are merged into the configuration when not already set.`, + Args: cobra.NoArgs, + } + cmd.Flags().StringVar(&canvasID, "canvas-id", "", "canvas id (defaults to the active canvas)") + cmd.Flags().StringVar(&component, "component", "", "widget component name (e.g. annotation)") + cmd.Flags().StringVar(&name, "name", "", "node name (defaults to the component)") + cmd.Flags().StringVar(&configuration, "configuration", "", "JSON configuration (inline, @file, or -)") + cmd.Flags().Int32Var(&positionX, "position-x", 0, "node x position") + cmd.Flags().Int32Var(&positionY, "position-y", 0, "node y position") + cmd.Flags().Int32Var(&width, "width", 0, "annotation width (annotation-friendly shortcut)") + cmd.Flags().Int32Var(&height, "height", 0, "annotation height (annotation-friendly shortcut)") + cmd.Flags().StringVar(&color, "color", "", "annotation color (annotation-friendly shortcut)") + cmd.Flags().StringVar(&text, "text", "", "annotation text (annotation-friendly shortcut)") + cmd.Flags().BoolVar(&draft, "draft", false, "keep the change as a draft instead of auto-publishing") + _ = cmd.MarkFlagRequired("component") + + command := &addCommand{ + canvasID: &canvasID, + component: &component, + name: &name, + configuration: &configuration, + positionX: &positionX, + positionY: &positionY, + width: &width, + height: &height, + color: &color, + text: &text, + draft: &draft, + } + core.Bind(cmd, command, options) + root.AddCommand(cmd) +} + +func addUpdateCommand(root *cobra.Command, options core.BindOptions) { + canvasID := "" + configuration := "" + name := "" + positionX := int32(0) + positionY := int32(0) + width := int32(0) + height := int32(0) + color := "" + text := "" + draft := false + + cmd := &cobra.Command{ + Use: "update ", + Short: "Update a widget node", + Args: cobra.ExactArgs(1), + } + cmd.Flags().StringVar(&canvasID, "canvas-id", "", "canvas id (defaults to the active canvas)") + cmd.Flags().StringVar(&configuration, "configuration", "", "JSON configuration override (inline, @file, or -)") + cmd.Flags().StringVar(&name, "name", "", "rename the node") + cmd.Flags().Int32Var(&positionX, "position-x", 0, "set node x position") + cmd.Flags().Int32Var(&positionY, "position-y", 0, "set node y position") + cmd.Flags().Int32Var(&width, "width", 0, "annotation width (annotation-friendly shortcut)") + cmd.Flags().Int32Var(&height, "height", 0, "annotation height (annotation-friendly shortcut)") + cmd.Flags().StringVar(&color, "color", "", "annotation color (annotation-friendly shortcut)") + cmd.Flags().StringVar(&text, "text", "", "annotation text (annotation-friendly shortcut)") + cmd.Flags().BoolVar(&draft, "draft", false, "keep the change as a draft instead of auto-publishing") + + command := &updateCommand{ + canvasID: &canvasID, + configuration: &configuration, + name: &name, + positionX: &positionX, + positionY: &positionY, + width: &width, + height: &height, + color: &color, + text: &text, + draft: &draft, + } + core.Bind(cmd, command, options) + root.AddCommand(cmd) +} + +func addDeleteCommand(root *cobra.Command, options core.BindOptions) { + canvasID := "" + yes := false + draft := false + + cmd := &cobra.Command{ + Use: "delete ", + Short: "Delete a widget node", + Args: cobra.ExactArgs(1), + } + cmd.Flags().StringVar(&canvasID, "canvas-id", "", "canvas id (defaults to the active canvas)") + cmd.Flags().BoolVarP(&yes, "yes", "y", false, "do not prompt for confirmation") + cmd.Flags().BoolVar(&draft, "draft", false, "keep the change as a draft instead of auto-publishing") + core.Bind(cmd, &deleteCommand{canvasID: &canvasID, yes: &yes, draft: &draft}, options) + root.AddCommand(cmd) +} diff --git a/pkg/cli/commands/widgets/update.go b/pkg/cli/commands/widgets/update.go new file mode 100644 index 0000000000..a30feeb12e --- /dev/null +++ b/pkg/cli/commands/widgets/update.go @@ -0,0 +1,134 @@ +package widgets + +import ( + "fmt" + "io" + + "github.com/superplanehq/superplane/pkg/cli/core" + "github.com/superplanehq/superplane/pkg/openapi_client" +) + +type updateCommand struct { + canvasID *string + configuration *string + name *string + positionX *int32 + positionY *int32 + width *int32 + height *int32 + color *string + text *string + draft *bool +} + +// Execute updates an existing widget node's configuration, name, or +// position. Configuration changes are merge-on-top: the new keys override +// existing keys but keys absent from the input are preserved so users can +// tweak a single field (e.g. annotation text) without rewriting the rest. +func (c *updateCommand) Execute(ctx core.CommandContext) error { + canvasID, err := core.ResolveCanvasID(ctx, valueOf(c.canvasID)) + if err != nil { + return err + } + + cmEnabled, err := changeManagementEnabled(ctx, canvasID) + if err != nil { + return err + } + draftMode := c.draft != nil && *c.draft + if cmEnabled && !draftMode { + return fmt.Errorf("change management is enabled for this canvas; pass --draft and publish via `superplane canvases change-requests`") + } + + versionID, err := ensureCurrentUserDraftVersionID(ctx, canvasID) + if err != nil { + return err + } + version, err := describeCanvasVersionByID(ctx, canvasID, versionID) + if err != nil { + return err + } + canvas := canvasFromVersion(version) + if canvas.Spec == nil { + return fmt.Errorf("canvas has no spec to update") + } + + target, err := findWidgetNode(canvas, ctx.Args[0]) + if err != nil { + return err + } + + overrides, err := configurationFromInput(valueOf(c.configuration), ctx.Cmd.InOrStdin()) + if err != nil { + return err + } + + width, _ := flagInt32IfChanged(ctx, "width", c.width) + height, _ := flagInt32IfChanged(ctx, "height", c.height) + overrides = applyAnnotationShortcuts(overrides, valueOf(c.text), valueOf(c.color), width, height) + + mutated := mergeConfiguration(target.GetConfiguration(), overrides) + target.SetConfiguration(mutated) + + if name := valueOf(c.name); name != "" { + target.SetName(name) + } + posX, hasX := flagInt32IfChanged(ctx, "position-x", c.positionX) + posY, hasY := flagInt32IfChanged(ctx, "position-y", c.positionY) + if hasX || hasY { + current := openapi_client.ComponentsPosition{} + if target.Position != nil { + current = *target.Position + } + if hasX { + current.SetX(posX) + } + if hasY { + current.SetY(posY) + } + target.SetPosition(current) + } + + spec := canvas.GetSpec() + nodes := spec.GetNodes() + for i, node := range nodes { + if node.GetId() == target.GetId() { + nodes[i] = target + break + } + } + spec.SetNodes(nodes) + canvas.SetSpec(spec) + + updatedVersion, err := updateAndMaybePublish(ctx, canvasID, versionID, canvas, draftMode) + if err != nil { + return err + } + + if !ctx.Renderer.IsText() { + return ctx.Renderer.Render(target) + } + return ctx.Renderer.RenderText(func(stdout io.Writer) error { + _, _ = fmt.Fprintf(stdout, "Widget updated: %s\n", target.GetId()) + metadata := updatedVersion.GetMetadata() + _, err := fmt.Fprintf(stdout, "Canvas version: %s\n", metadata.GetId()) + return err + }) +} + +// mergeConfiguration overlays new values on top of the existing +// configuration. We prefer merge-over-replace because users typically want +// to change one field without restating the rest of the widget config. +func mergeConfiguration(existing, overrides map[string]any) map[string]any { + if len(existing) == 0 && len(overrides) == 0 { + return nil + } + out := map[string]any{} + for k, v := range existing { + out[k] = v + } + for k, v := range overrides { + out[k] = v + } + return out +} diff --git a/pkg/cli/root.go b/pkg/cli/root.go index b330b0a8d2..298115a958 100644 --- a/pkg/cli/root.go +++ b/pkg/cli/root.go @@ -11,6 +11,7 @@ import ( "github.com/spf13/cobra" "github.com/spf13/viper" canvases "github.com/superplanehq/superplane/pkg/cli/commands/canvases" + console "github.com/superplanehq/superplane/pkg/cli/commands/console" events "github.com/superplanehq/superplane/pkg/cli/commands/events" executions "github.com/superplanehq/superplane/pkg/cli/commands/executions" groups "github.com/superplanehq/superplane/pkg/cli/commands/groups" @@ -22,6 +23,7 @@ import ( roles "github.com/superplanehq/superplane/pkg/cli/commands/roles" secrets "github.com/superplanehq/superplane/pkg/cli/commands/secrets" usage "github.com/superplanehq/superplane/pkg/cli/commands/usage" + widgets "github.com/superplanehq/superplane/pkg/cli/commands/widgets" "github.com/superplanehq/superplane/pkg/cli/core" ) @@ -58,6 +60,7 @@ func init() { options := defaultBindOptions() RootCmd.AddCommand(canvases.NewCommand(options)) + RootCmd.AddCommand(console.NewCommand(options)) RootCmd.AddCommand(executions.NewCommand(options)) RootCmd.AddCommand(events.NewCommand(options)) RootCmd.AddCommand(groups.NewCommand(options)) @@ -69,6 +72,7 @@ func init() { RootCmd.AddCommand(roles.NewCommand(options)) RootCmd.AddCommand(secrets.NewCommand(options)) RootCmd.AddCommand(usage.NewCommand(options)) + RootCmd.AddCommand(widgets.NewCommand(options)) } func initConfig() {