From 660757097bb6a9f765776cd73581ffb59816220f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Calil?= Date: Mon, 18 May 2026 19:16:20 -0300 Subject: [PATCH 1/3] feat: Dashboard and Widgets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: André Calil --- .../canvases/dashboard_serialization.go | 17 +- .../canvases/update_canvas_dashboard.go | 48 +- pkg/models/canvas_dashboard_yml.go | 414 ++++++++++++++++++ pkg/models/canvas_dashboard_yml_test.go | 270 ++++++++++++ web_src/package-lock.json | 128 +++++- web_src/package.json | 3 + .../workflowv2/dashboard/ChartPanelCard.tsx | 192 ++++++++ .../workflowv2/dashboard/DashboardContext.tsx | 87 ++++ .../workflowv2/dashboard/DashboardOverlay.tsx | 142 +++++- .../workflowv2/dashboard/DashboardView.tsx | 262 +++++++++-- .../dashboard/DashboardYamlModal.tsx | 257 +++++++++++ .../workflowv2/dashboard/DataSourceForm.tsx | 134 ++++++ .../dashboard/MarkdownPanelCard.tsx | 275 +++++++++--- .../workflowv2/dashboard/NodePanelCard.tsx | 214 +++++++++ .../workflowv2/dashboard/NumberPanelCard.tsx | 191 ++++++++ .../dashboard/PanelEditorDialog.tsx | 184 ++++++++ .../workflowv2/dashboard/TablePanelCard.tsx | 193 ++++++++ .../workflowv2/dashboard/TypedPanelShell.tsx | 138 ++++++ .../workflowv2/dashboard/dashboard-grid.css | 58 +++ .../workflowv2/dashboard/dashboardEvents.ts | 8 + .../dashboardTriggerParameters.spec.ts | 86 ++++ .../dashboard/dashboardTriggerParameters.ts | 40 ++ .../dashboard/dashboardYaml.spec.ts | 249 +++++++++++ .../workflowv2/dashboard/dashboardYaml.ts | 349 +++++++++++++++ .../dashboard/deriveNodeStatuses.ts | 39 ++ .../workflowv2/dashboard/panelTypes.spec.ts | 109 +++++ .../pages/workflowv2/dashboard/panelTypes.ts | 348 +++++++++++++++ .../dashboard/useDashboardPanelState.ts | 57 ++- .../dashboard/widget/WidgetChart.tsx | 260 +++++++++++ .../dashboard/widget/WidgetNumber.tsx | 74 ++++ .../dashboard/widget/WidgetTable.spec.tsx | 98 +++++ .../dashboard/widget/WidgetTable.tsx | 216 +++++++++ .../dashboard/widget/fieldPath.spec.ts | 29 ++ .../workflowv2/dashboard/widget/fieldPath.ts | 86 ++++ .../dashboard/widget/showExpression.spec.ts | 30 ++ .../dashboard/widget/showExpression.ts | 216 +++++++++ .../workflowv2/dashboard/widget/types.ts | 153 +++++++ .../dashboard/widget/useWidgetData.ts | 251 +++++++++++ .../workflowv2/dashboard/widget/widgetData.ts | 71 +++ .../dashboard/widget/widgetFormat.ts | 68 +++ web_src/src/pages/workflowv2/index.tsx | 87 +++- .../workflowv2/useWorkflowViewSearchParams.ts | 5 + web_src/src/ui/CanvasPage/Header.tsx | 4 + .../ui/CanvasPage/HeaderSecondaryActions.tsx | 28 +- web_src/src/ui/CanvasPage/index.tsx | 12 + 45 files changed, 5974 insertions(+), 206 deletions(-) create mode 100644 pkg/models/canvas_dashboard_yml.go create mode 100644 pkg/models/canvas_dashboard_yml_test.go create mode 100644 web_src/src/pages/workflowv2/dashboard/ChartPanelCard.tsx create mode 100644 web_src/src/pages/workflowv2/dashboard/DashboardContext.tsx create mode 100644 web_src/src/pages/workflowv2/dashboard/DashboardYamlModal.tsx create mode 100644 web_src/src/pages/workflowv2/dashboard/DataSourceForm.tsx create mode 100644 web_src/src/pages/workflowv2/dashboard/NodePanelCard.tsx create mode 100644 web_src/src/pages/workflowv2/dashboard/NumberPanelCard.tsx create mode 100644 web_src/src/pages/workflowv2/dashboard/PanelEditorDialog.tsx create mode 100644 web_src/src/pages/workflowv2/dashboard/TablePanelCard.tsx create mode 100644 web_src/src/pages/workflowv2/dashboard/TypedPanelShell.tsx create mode 100644 web_src/src/pages/workflowv2/dashboard/dashboard-grid.css create mode 100644 web_src/src/pages/workflowv2/dashboard/dashboardEvents.ts create mode 100644 web_src/src/pages/workflowv2/dashboard/dashboardTriggerParameters.spec.ts create mode 100644 web_src/src/pages/workflowv2/dashboard/dashboardTriggerParameters.ts create mode 100644 web_src/src/pages/workflowv2/dashboard/dashboardYaml.spec.ts create mode 100644 web_src/src/pages/workflowv2/dashboard/dashboardYaml.ts create mode 100644 web_src/src/pages/workflowv2/dashboard/deriveNodeStatuses.ts create mode 100644 web_src/src/pages/workflowv2/dashboard/panelTypes.spec.ts create mode 100644 web_src/src/pages/workflowv2/dashboard/panelTypes.ts create mode 100644 web_src/src/pages/workflowv2/dashboard/widget/WidgetChart.tsx create mode 100644 web_src/src/pages/workflowv2/dashboard/widget/WidgetNumber.tsx create mode 100644 web_src/src/pages/workflowv2/dashboard/widget/WidgetTable.spec.tsx create mode 100644 web_src/src/pages/workflowv2/dashboard/widget/WidgetTable.tsx create mode 100644 web_src/src/pages/workflowv2/dashboard/widget/fieldPath.spec.ts create mode 100644 web_src/src/pages/workflowv2/dashboard/widget/fieldPath.ts create mode 100644 web_src/src/pages/workflowv2/dashboard/widget/showExpression.spec.ts create mode 100644 web_src/src/pages/workflowv2/dashboard/widget/showExpression.ts create mode 100644 web_src/src/pages/workflowv2/dashboard/widget/types.ts create mode 100644 web_src/src/pages/workflowv2/dashboard/widget/useWidgetData.ts create mode 100644 web_src/src/pages/workflowv2/dashboard/widget/widgetData.ts create mode 100644 web_src/src/pages/workflowv2/dashboard/widget/widgetFormat.ts diff --git a/pkg/grpc/actions/canvases/dashboard_serialization.go b/pkg/grpc/actions/canvases/dashboard_serialization.go index 7e09c3e7f6..c9d19e6a39 100644 --- a/pkg/grpc/actions/canvases/dashboard_serialization.go +++ b/pkg/grpc/actions/canvases/dashboard_serialization.go @@ -1,7 +1,6 @@ package canvases import ( - "encoding/json" "fmt" "github.com/superplanehq/superplane/pkg/models" @@ -10,8 +9,12 @@ import ( "google.golang.org/protobuf/types/known/timestamppb" ) -const MaxDashboardPanels = 50 -const MaxDashboardPayloadBytes = 1024 * 1024 +// MaxDashboardPanels and MaxDashboardPayloadBytes are re-exported from the +// models package so existing gRPC tests/callers keep working unchanged. +const ( + MaxDashboardPanels = models.MaxDashboardPanels + MaxDashboardPayloadBytes = models.MaxDashboardPayloadBytes +) func serializeCanvasDashboard(dashboard *models.CanvasDashboard) (*pb.CanvasDashboard, error) { panels := dashboard.Panels.Data() @@ -132,11 +135,3 @@ func toStructpbCompatible(v any) any { return v } } - -func encodedDashboardPanelsSize(panels []models.DashboardPanel) (int, error) { - encoded, err := json.Marshal(panels) - if err != nil { - return 0, err - } - return len(encoded), nil -} diff --git a/pkg/grpc/actions/canvases/update_canvas_dashboard.go b/pkg/grpc/actions/canvases/update_canvas_dashboard.go index 6405575ca6..55ca901fa3 100644 --- a/pkg/grpc/actions/canvases/update_canvas_dashboard.go +++ b/pkg/grpc/actions/canvases/update_canvas_dashboard.go @@ -70,52 +70,8 @@ func UpdateCanvasDashboard(ctx context.Context, organizationID, canvasID string, } func validateDashboardInput(panels []models.DashboardPanel, layout []models.DashboardLayoutItem) error { - if len(panels) > MaxDashboardPanels { - return status.Errorf(codes.InvalidArgument, "too many panels (max %d)", MaxDashboardPanels) + if err := models.ValidateDashboardContent(panels, layout); err != nil { + return status.Errorf(codes.InvalidArgument, "%v", err) } - - panelIDs := make(map[string]struct{}, len(panels)) - for _, panel := range panels { - if panel.ID == "" { - return status.Error(codes.InvalidArgument, "panel id is required") - } - if panel.Type == "" { - return status.Errorf(codes.InvalidArgument, "panel %q type is required", panel.ID) - } - if _, exists := panelIDs[panel.ID]; exists { - return status.Errorf(codes.InvalidArgument, "duplicate panel id %q", panel.ID) - } - panelIDs[panel.ID] = struct{}{} - } - - size, err := encodedDashboardPanelsSize(panels) - if err != nil { - return status.Error(codes.Internal, "failed to validate panel size") - } - if size > MaxDashboardPayloadBytes { - return status.Errorf(codes.InvalidArgument, "panels payload exceeds %d bytes", MaxDashboardPayloadBytes) - } - - layoutIDs := make(map[string]struct{}, len(layout)) - for _, item := range layout { - if item.I == "" { - return status.Error(codes.InvalidArgument, "layout item i is required") - } - if _, exists := layoutIDs[item.I]; exists { - return status.Errorf(codes.InvalidArgument, "duplicate layout id %q", item.I) - } - layoutIDs[item.I] = struct{}{} - - if _, ok := panelIDs[item.I]; !ok { - return status.Errorf(codes.InvalidArgument, "layout item %q does not reference any panel", item.I) - } - if item.W <= 0 || item.H <= 0 { - return status.Errorf(codes.InvalidArgument, "layout item %q must have positive width and height", item.I) - } - if item.X < 0 || item.Y < 0 { - return status.Errorf(codes.InvalidArgument, "layout item %q must have non-negative x and y", item.I) - } - } - return nil } diff --git a/pkg/models/canvas_dashboard_yml.go b/pkg/models/canvas_dashboard_yml.go new file mode 100644 index 0000000000..08709f119e --- /dev/null +++ b/pkg/models/canvas_dashboard_yml.go @@ -0,0 +1,414 @@ +package models + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + + "gopkg.in/yaml.v3" +) + +const ( + DashboardKind = "Dashboard" + DashboardAPIVersion = "v1" + + DashboardPanelTypeMarkdown = "markdown" + DashboardPanelTypeNode = "node" + DashboardPanelTypeTable = "table" + DashboardPanelTypeChart = "chart" + DashboardPanelTypeNumber = "number" + + MaxDashboardPanels = 50 + MaxDashboardPayloadBytes = 1024 * 1024 +) + +// AllowedDashboardPanelTypes lists the panel `type` values accepted on import. +// Keep this list in lockstep with `web_src/src/pages/workflowv2/dashboard/panelTypes.ts` +// — the frontend validators and per-type form editors rely on the same set. +var AllowedDashboardPanelTypes = []string{ + DashboardPanelTypeMarkdown, + DashboardPanelTypeNode, + DashboardPanelTypeTable, + DashboardPanelTypeChart, + DashboardPanelTypeNumber, +} + +// DashboardYAMLMetadata is informational only. `canvasId` is ignored on +// import; `name` is used solely for display/filename purposes. +type DashboardYAMLMetadata struct { + CanvasID string `json:"canvasId,omitempty" yaml:"canvasId,omitempty"` + Name string `json:"name,omitempty" yaml:"name,omitempty"` +} + +// DashboardYAMLSpec carries the persisted dashboard shape (panels + layout) +// while keeping a stable, deterministic field ordering on export. +type DashboardYAMLSpec struct { + Panels []DashboardPanel `json:"panels" yaml:"panels"` + Layout []DashboardLayoutItem `json:"layout" yaml:"layout"` +} + +// DashboardYAML is the canonical YAML representation of a canvas dashboard. +// +// Import is replace-all: it overwrites every panel and layout entry for the +// canvas. Export is deterministic: identical dashboards produce identical +// YAML bytes regardless of how the underlying maps were ordered in memory. +type DashboardYAML struct { + APIVersion string `json:"apiVersion" yaml:"apiVersion"` + Kind string `json:"kind" yaml:"kind"` + Metadata DashboardYAMLMetadata `json:"metadata" yaml:"metadata"` + Spec DashboardYAMLSpec `json:"spec" yaml:"spec"` +} + +// DashboardFromYML parses raw YAML bytes into a validated DashboardYAML. The +// parser is strict: unknown top-level fields are rejected, panel content must +// be an object, and the configured limits apply. +func DashboardFromYML(raw []byte) (*DashboardYAML, error) { + if len(bytes.TrimSpace(raw)) == 0 { + return nil, errors.New("dashboard yaml is empty") + } + + var asAny any + if err := yaml.Unmarshal(raw, &asAny); err != nil { + return nil, fmt.Errorf("invalid yaml: %w", err) + } + if _, ok := asAny.(map[string]any); !ok { + return nil, errors.New("dashboard yaml must be an object") + } + + jsonBytes, err := json.Marshal(asAny) + if err != nil { + return nil, fmt.Errorf("invalid yaml: %w", err) + } + + decoder := json.NewDecoder(bytes.NewReader(jsonBytes)) + decoder.DisallowUnknownFields() + + var resource DashboardYAML + if err := decoder.Decode(&resource); err != nil { + return nil, fmt.Errorf("invalid dashboard yaml: %w", err) + } + + if err := resource.Validate(); err != nil { + return nil, err + } + + return &resource, nil +} + +// DashboardToYML serializes a stored dashboard into the canonical YAML +// representation with stable field ordering. Empty dashboards produce a +// valid empty spec. +func DashboardToYML(dashboard *CanvasDashboard, canvasName string) ([]byte, error) { + if dashboard == nil { + return nil, errors.New("dashboard is required") + } + + resource := DashboardYAML{ + APIVersion: DashboardAPIVersion, + Kind: DashboardKind, + Metadata: DashboardYAMLMetadata{ + CanvasID: dashboard.CanvasID.String(), + Name: canvasName, + }, + Spec: DashboardYAMLSpec{ + Panels: normalizeDashboardPanelsForExport(dashboard.Panels.Data()), + Layout: normalizeDashboardLayoutForExport(dashboard.Layout.Data()), + }, + } + + jsonBytes, err := json.Marshal(resource) + if err != nil { + return nil, fmt.Errorf("failed to serialize dashboard: %w", err) + } + + var generic any + if err := json.Unmarshal(jsonBytes, &generic); err != nil { + return nil, fmt.Errorf("failed to serialize dashboard: %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 dashboard yaml: %w", err) + } + if err := encoder.Close(); err != nil { + return nil, fmt.Errorf("failed to encode dashboard yaml: %w", err) + } + return buf.Bytes(), nil +} + +// Validate enforces the structural and size invariants of a dashboard import. +func (d *DashboardYAML) Validate() error { + if d.APIVersion == "" { + return errors.New("apiVersion is required") + } + if d.APIVersion != DashboardAPIVersion { + return fmt.Errorf("unsupported apiVersion %q (expected %q)", d.APIVersion, DashboardAPIVersion) + } + if d.Kind == "" { + return errors.New("kind is required") + } + if d.Kind != DashboardKind { + return fmt.Errorf("unsupported kind %q (expected %q)", d.Kind, DashboardKind) + } + + return ValidateDashboardContent(d.Spec.Panels, d.Spec.Layout) +} + +// ValidateDashboardContent enforces the shared validation rules used by both +// YAML import and the gRPC update endpoint. Keeping this in models means the +// rules live next to the persisted shape and stay consistent across surfaces. +func ValidateDashboardContent(panels []DashboardPanel, layout []DashboardLayoutItem) error { + if len(panels) > MaxDashboardPanels { + return fmt.Errorf("too many panels (max %d)", MaxDashboardPanels) + } + + panelIDs := make(map[string]struct{}, len(panels)) + for _, panel := range 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 !isAllowedDashboardPanelType(panel.Type) { + return fmt.Errorf("panel %q has unsupported type %q", panel.ID, panel.Type) + } + if _, exists := panelIDs[panel.ID]; exists { + return fmt.Errorf("duplicate panel id %q", panel.ID) + } + if err := validatePanelContent(panel); err != nil { + return err + } + panelIDs[panel.ID] = struct{}{} + } + + size, err := encodedDashboardPanelsSize(panels) + if err != nil { + return fmt.Errorf("failed to validate panel size: %w", err) + } + if size > MaxDashboardPayloadBytes { + return fmt.Errorf("panels payload exceeds %d bytes", MaxDashboardPayloadBytes) + } + + layoutIDs := make(map[string]struct{}, len(layout)) + for _, item := range 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 +} + +func isAllowedDashboardPanelType(panelType string) bool { + for _, allowed := range AllowedDashboardPanelTypes { + if panelType == allowed { + return true + } + } + return false +} + +func validatePanelContent(panel DashboardPanel) error { + switch panel.Type { + case DashboardPanelTypeMarkdown: + return validateMarkdownContent(panel) + case DashboardPanelTypeNode: + return validateNodePanelContent(panel) + case DashboardPanelTypeTable: + return validateTablePanelContent(panel) + case DashboardPanelTypeChart: + return validateChartPanelContent(panel) + case DashboardPanelTypeNumber: + return validateNumberPanelContent(panel) + } + return nil +} + +func validateMarkdownContent(panel DashboardPanel) error { + if panel.Content == nil { + return nil + } + if rawTitle, ok := panel.Content["title"]; ok && rawTitle != nil { + if _, ok := rawTitle.(string); !ok { + return fmt.Errorf("panel %q content.title must be a string", panel.ID) + } + } + if rawBody, ok := panel.Content["body"]; ok && rawBody != nil { + if _, ok := rawBody.(string); !ok { + return fmt.Errorf("panel %q content.body must be a string", panel.ID) + } + } + return nil +} + +func validateNodePanelContent(panel DashboardPanel) error { + if panel.Content == nil { + return fmt.Errorf("panel %q content is required", panel.ID) + } + // `node` must be present as a string but may be empty: newly added + // panels start unconfigured and the UI renders a "configure me" hint + // until the user picks one. The card body never executes a trigger / + // status lookup against an empty reference. + rawNode, ok := panel.Content["node"] + if !ok { + return fmt.Errorf("panel %q content.node is required", panel.ID) + } + if _, ok := rawNode.(string); !ok { + return fmt.Errorf("panel %q content.node must be a string", panel.ID) + } + if rawShowRun, ok := panel.Content["showRun"]; ok && rawShowRun != nil { + if _, ok := rawShowRun.(bool); !ok { + return fmt.Errorf("panel %q content.showRun must be a boolean", panel.ID) + } + } + return nil +} + +func validateDataSource(panelID string, raw any) error { + ds, ok := raw.(map[string]any) + if !ok || ds == nil { + return fmt.Errorf("panel %q dataSource must be an object", panelID) + } + kind, _ := ds["kind"].(string) + switch kind { + case "memory": + ns, ok := ds["namespace"].(string) + if !ok || ns == "" { + return fmt.Errorf("panel %q dataSource.namespace must be a non-empty string for memory sources", panelID) + } + case "executions", "runs": + // node (executions only) and limit are optional + default: + return fmt.Errorf("panel %q dataSource.kind must be \"memory\", \"executions\", or \"runs\"", panelID) + } + return nil +} + +func validateRender(panelID string, raw any, expectedKind string) (map[string]any, error) { + render, ok := raw.(map[string]any) + if !ok || render == nil { + return nil, fmt.Errorf("panel %q render must be an object", panelID) + } + kind, _ := render["kind"].(string) + if kind != expectedKind { + return nil, fmt.Errorf("panel %q render.kind must be %q", panelID, expectedKind) + } + return render, nil +} + +func validateTablePanelContent(panel DashboardPanel) error { + if panel.Content == nil { + return fmt.Errorf("panel %q content is required", panel.ID) + } + if err := validateDataSource(panel.ID, panel.Content["dataSource"]); err != nil { + return err + } + render, err := validateRender(panel.ID, panel.Content["render"], "table") + if err != nil { + return err + } + cols, ok := render["columns"].([]any) + if !ok || len(cols) == 0 { + return fmt.Errorf("panel %q render.columns must be a non-empty array", panel.ID) + } + return nil +} + +func validateChartPanelContent(panel DashboardPanel) error { + if panel.Content == nil { + return fmt.Errorf("panel %q content is required", panel.ID) + } + if err := validateDataSource(panel.ID, panel.Content["dataSource"]); err != nil { + return err + } + render, err := validateRender(panel.ID, panel.Content["render"], "chart") + if err != nil { + return err + } + if xField, ok := render["xField"].(string); !ok || xField == "" { + return fmt.Errorf("panel %q render.xField must be a non-empty string", panel.ID) + } + if series, ok := render["series"].([]any); !ok || len(series) == 0 { + return fmt.Errorf("panel %q render.series must be a non-empty array", panel.ID) + } + return nil +} + +func validateNumberPanelContent(panel DashboardPanel) error { + if panel.Content == nil { + return fmt.Errorf("panel %q content is required", panel.ID) + } + if err := validateDataSource(panel.ID, panel.Content["dataSource"]); err != nil { + return err + } + render, err := validateRender(panel.ID, panel.Content["render"], "number") + if err != nil { + return err + } + aggregation, _ := render["aggregation"].(string) + switch aggregation { + case "count", "sum", "avg", "min", "max", "first", "last": + default: + return fmt.Errorf("panel %q render.aggregation must be one of count/sum/avg/min/max/first/last", panel.ID) + } + if aggregation != "count" { + if field, ok := render["field"].(string); !ok || field == "" { + return fmt.Errorf("panel %q render.field is required when aggregation is %q", panel.ID, aggregation) + } + } + return nil +} + +// normalizeDashboardPanelsForExport ensures stable field order in panel +// content maps so YAML output is deterministic across runs. +func normalizeDashboardPanelsForExport(panels []DashboardPanel) []DashboardPanel { + if panels == nil { + return []DashboardPanel{} + } + + out := make([]DashboardPanel, len(panels)) + for i, panel := range panels { + out[i] = DashboardPanel{ + ID: panel.ID, + Type: panel.Type, + Content: panel.Content, + } + } + return out +} + +func normalizeDashboardLayoutForExport(layout []DashboardLayoutItem) []DashboardLayoutItem { + if layout == nil { + return []DashboardLayoutItem{} + } + + out := make([]DashboardLayoutItem, len(layout)) + copy(out, layout) + return out +} + +func encodedDashboardPanelsSize(panels []DashboardPanel) (int, error) { + encoded, err := json.Marshal(panels) + if err != nil { + return 0, err + } + return len(encoded), nil +} diff --git a/pkg/models/canvas_dashboard_yml_test.go b/pkg/models/canvas_dashboard_yml_test.go new file mode 100644 index 0000000000..0fe2c55540 --- /dev/null +++ b/pkg/models/canvas_dashboard_yml_test.go @@ -0,0 +1,270 @@ +package models + +import ( + "strings" + "testing" + + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gorm.io/datatypes" +) + +func TestDashboardFromYML_ParsesValidDashboard(t *testing.T) { + yaml := `apiVersion: v1 +kind: Dashboard +metadata: + name: My dashboard +spec: + panels: + - id: intro + type: markdown + content: + body: "# Hello" + layout: + - i: intro + x: 0 + y: 0 + w: 12 + h: 6 + minW: 2 + minH: 2 +` + + resource, err := DashboardFromYML([]byte(yaml)) + require.NoError(t, err) + require.Equal(t, "v1", resource.APIVersion) + require.Equal(t, "Dashboard", resource.Kind) + require.Equal(t, "My dashboard", resource.Metadata.Name) + require.Len(t, resource.Spec.Panels, 1) + require.Equal(t, "intro", resource.Spec.Panels[0].ID) + require.Equal(t, "markdown", resource.Spec.Panels[0].Type) + require.Equal(t, "# Hello", resource.Spec.Panels[0].Content["body"]) + require.Len(t, resource.Spec.Layout, 1) + require.Equal(t, 12, resource.Spec.Layout[0].W) + require.NotNil(t, resource.Spec.Layout[0].MinW) + assert.Equal(t, 2, *resource.Spec.Layout[0].MinW) +} + +func TestDashboardFromYML_RejectsEmptyInput(t *testing.T) { + _, err := DashboardFromYML([]byte("")) + require.Error(t, err) + _, err = DashboardFromYML([]byte(" \n\n ")) + require.Error(t, err) +} + +func TestDashboardFromYML_RejectsUnknownFields(t *testing.T) { + yaml := `apiVersion: v1 +kind: Dashboard +metadata: + name: ok +spec: + panels: [] + layout: [] + extraField: nope +` + _, err := DashboardFromYML([]byte(yaml)) + require.Error(t, err) +} + +func TestDashboardFromYML_RejectsWrongKind(t *testing.T) { + yaml := `apiVersion: v1 +kind: Canvas +metadata: {} +spec: + panels: [] + layout: [] +` + _, err := DashboardFromYML([]byte(yaml)) + require.Error(t, err) +} + +func TestDashboardFromYML_RejectsWrongAPIVersion(t *testing.T) { + yaml := `apiVersion: v2 +kind: Dashboard +metadata: {} +spec: + panels: [] + layout: [] +` + _, err := DashboardFromYML([]byte(yaml)) + require.Error(t, err) +} + +func TestDashboardFromYML_RejectsNonObjectRoot(t *testing.T) { + _, err := DashboardFromYML([]byte("- 1\n- 2\n")) + require.Error(t, err) +} + +func TestDashboardFromYML_RejectsUnsupportedPanelType(t *testing.T) { + yaml := `apiVersion: v1 +kind: Dashboard +metadata: {} +spec: + panels: + - id: p1 + type: timeline + content: {} + layout: [] +` + _, err := DashboardFromYML([]byte(yaml)) + require.Error(t, err) + assert.Contains(t, err.Error(), "unsupported type") +} + +func TestDashboardFromYML_RejectsDuplicatePanelIDs(t *testing.T) { + yaml := `apiVersion: v1 +kind: Dashboard +metadata: {} +spec: + panels: + - id: dup + type: markdown + content: {} + - id: dup + type: markdown + content: {} + layout: [] +` + _, err := DashboardFromYML([]byte(yaml)) + require.Error(t, err) + assert.Contains(t, err.Error(), "duplicate") +} + +func TestDashboardFromYML_RejectsLayoutWithUnknownPanel(t *testing.T) { + yaml := `apiVersion: v1 +kind: Dashboard +metadata: {} +spec: + panels: + - id: p1 + type: markdown + content: {} + layout: + - i: other + x: 0 + y: 0 + w: 1 + h: 1 +` + _, err := DashboardFromYML([]byte(yaml)) + require.Error(t, err) +} + +func TestDashboardFromYML_RejectsNonStringBody(t *testing.T) { + yaml := `apiVersion: v1 +kind: Dashboard +metadata: {} +spec: + panels: + - id: p1 + type: markdown + content: + body: 42 + layout: [] +` + _, err := DashboardFromYML([]byte(yaml)) + require.Error(t, err) + assert.Contains(t, err.Error(), "body") +} + +func TestDashboardFromYML_RejectsTooManyPanels(t *testing.T) { + var b strings.Builder + b.WriteString("apiVersion: v1\nkind: Dashboard\nmetadata: {}\nspec:\n panels:\n") + for i := 0; i < MaxDashboardPanels+1; i++ { + b.WriteString(" - id: p") + b.WriteString(strings.Repeat("a", 1)) + b.WriteString(strings.Repeat("b", i+1)) + b.WriteString("\n type: markdown\n content: {}\n") + } + b.WriteString(" layout: []\n") + + _, err := DashboardFromYML([]byte(b.String())) + require.Error(t, err) + assert.Contains(t, err.Error(), "too many panels") +} + +func TestDashboardToYML_RoundTripsEmptyDashboard(t *testing.T) { + canvasID := uuid.New() + dashboard := &CanvasDashboard{ + CanvasID: canvasID, + Panels: datatypes.NewJSONType([]DashboardPanel{}), + Layout: datatypes.NewJSONType([]DashboardLayoutItem{}), + } + + out, err := DashboardToYML(dashboard, "Canvas Name") + require.NoError(t, err) + assert.Contains(t, string(out), "apiVersion: v1") + assert.Contains(t, string(out), "kind: Dashboard") + assert.Contains(t, string(out), canvasID.String()) + assert.Contains(t, string(out), "name: Canvas Name") + + parsed, err := DashboardFromYML(out) + require.NoError(t, err) + assert.Empty(t, parsed.Spec.Panels) + assert.Empty(t, parsed.Spec.Layout) +} + +func TestDashboardToYML_RoundTripsPanelsAndLayout(t *testing.T) { + canvasID := uuid.New() + minW, minH := 2, 1 + dashboard := &CanvasDashboard{ + CanvasID: canvasID, + Panels: datatypes.NewJSONType([]DashboardPanel{ + {ID: "p1", Type: "markdown", Content: map[string]any{"body": "hello"}}, + }), + Layout: datatypes.NewJSONType([]DashboardLayoutItem{ + {I: "p1", X: 0, Y: 0, W: 4, H: 2, MinW: &minW, MinH: &minH}, + }), + } + + out, err := DashboardToYML(dashboard, "") + require.NoError(t, err) + + parsed, err := DashboardFromYML(out) + require.NoError(t, err) + require.Len(t, parsed.Spec.Panels, 1) + require.Equal(t, "p1", parsed.Spec.Panels[0].ID) + require.Equal(t, "markdown", parsed.Spec.Panels[0].Type) + require.Equal(t, "hello", parsed.Spec.Panels[0].Content["body"]) + require.Len(t, parsed.Spec.Layout, 1) + require.Equal(t, 4, parsed.Spec.Layout[0].W) + require.NotNil(t, parsed.Spec.Layout[0].MinW) + assert.Equal(t, 2, *parsed.Spec.Layout[0].MinW) +} + +func TestDashboardToYML_IsDeterministic(t *testing.T) { + canvasID := uuid.New() + dashboard := &CanvasDashboard{ + CanvasID: canvasID, + Panels: datatypes.NewJSONType([]DashboardPanel{ + {ID: "a", Type: "markdown", Content: map[string]any{"body": "hi"}}, + {ID: "b", Type: "markdown", Content: map[string]any{"body": "hey"}}, + }), + Layout: datatypes.NewJSONType([]DashboardLayoutItem{ + {I: "a", X: 0, Y: 0, W: 1, H: 1}, + {I: "b", X: 1, Y: 0, W: 1, H: 1}, + }), + } + + first, err := DashboardToYML(dashboard, "name") + require.NoError(t, err) + second, err := DashboardToYML(dashboard, "name") + require.NoError(t, err) + assert.Equal(t, string(first), string(second)) +} + +func TestValidateDashboardContent_RejectsInvalidLayout(t *testing.T) { + panels := []DashboardPanel{{ID: "p", Type: "markdown", Content: map[string]any{}}} + err := ValidateDashboardContent(panels, []DashboardLayoutItem{ + {I: "p", X: -1, Y: 0, W: 1, H: 1}, + }) + require.Error(t, err) + assert.Contains(t, err.Error(), "non-negative") + + err = ValidateDashboardContent(panels, []DashboardLayoutItem{ + {I: "p", X: 0, Y: 0, W: 0, H: 1}, + }) + require.Error(t, err) + assert.Contains(t, err.Error(), "positive") +} diff --git a/web_src/package-lock.json b/web_src/package-lock.json index 47ddb49cab..5bea11a05b 100644 --- a/web_src/package-lock.json +++ b/web_src/package-lock.json @@ -45,6 +45,7 @@ "@tippyjs/react": "^4.2.6", "@types/js-yaml": "^4.0.9", "@types/node": "^22.15.18", + "@types/react-grid-layout": "^1.3.6", "@uiw/react-json-view": "^2.0.0-alpha.37", "@xyflow/react": "^12.10.0", "class-variance-authority": "^0.7.1", @@ -63,12 +64,14 @@ "react-day-picker": "^9.11.0", "react-diff-view": "^3.3.2", "react-dom": "^19.1.0", + "react-grid-layout": "^1.5.3", "react-markdown": "^10.1.0", "react-resizable-panels": "^3.0.6", "react-router-dom": "^7.13.0", "react-use-websocket": "^4.0.0", "recharts": "^3.8.1", "remark-breaks": "^4.0.0", + "remark-directive": "^4.0.0", "remark-gfm": "^4.0.1", "sonner": "^2.0.7", "tailwind-merge": "^3.3.1", @@ -5958,6 +5961,15 @@ "@types/react": "^19.0.0" } }, + "node_modules/@types/react-grid-layout": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/@types/react-grid-layout/-/react-grid-layout-1.3.6.tgz", + "integrity": "sha512-Cw7+sb3yyjtmxwwJiXtEXcu5h4cgs+sCGkHwHXsFmPyV30bf14LeD/fa2LwQovuD2HWxCcjIdNhDlcYGj95qGA==", + "license": "MIT", + "dependencies": { + "@types/react": "*" + } + }, "node_modules/@types/resolve": { "version": "1.20.6", "resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.20.6.tgz", @@ -9197,6 +9209,12 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-equals": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-4.0.3.tgz", + "integrity": "sha512-G3BSX9cfKttjr+2o1O22tYMLq0DPluZnYtq1rXumE1SpL/F/SLIfHx08WYQoWSIpeMYf8sRbJ8++71+v6Pnxfg==", + "license": "MIT" + }, "node_modules/fast-glob": { "version": "3.3.3", "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", @@ -11348,6 +11366,27 @@ "node": ">= 0.4" } }, + "node_modules/mdast-util-directive": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-directive/-/mdast-util-directive-3.1.0.tgz", + "integrity": "sha512-I3fNFt+DHmpWCYAT7quoM6lHf9wuqtI+oCOfvILnoicNIqjh5E3dEJWiXuYME2gNe8vl1iMQwyUHa7bgFmak6Q==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "ccount": "^2.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0", + "parse-entities": "^4.0.0", + "stringify-entities": "^4.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/mdast-util-find-and-replace": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/mdast-util-find-and-replace/-/mdast-util-find-and-replace-3.0.2.tgz", @@ -11769,6 +11808,25 @@ "micromark-util-types": "^2.0.0" } }, + "node_modules/micromark-extension-directive": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/micromark-extension-directive/-/micromark-extension-directive-4.0.0.tgz", + "integrity": "sha512-/C2nqVmXXmiseSSuCdItCMho7ybwwop6RrrRPk0KbOHW21JKoCldC+8rFOaundDoRBUWBnJJcxeA/Kvi34WQXg==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-factory-whitespace": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0", + "parse-entities": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/micromark-extension-gfm": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/micromark-extension-gfm/-/micromark-extension-gfm-3.0.0.tgz", @@ -12443,7 +12501,6 @@ "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", - "dev": true, "engines": { "node": ">=0.10.0" } @@ -13052,7 +13109,6 @@ "version": "15.8.1", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", - "dev": true, "license": "MIT", "dependencies": { "loose-envify": "^1.4.0", @@ -13335,6 +13391,38 @@ "react": "^19.1.0" } }, + "node_modules/react-draggable": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/react-draggable/-/react-draggable-4.5.0.tgz", + "integrity": "sha512-VC+HBLEZ0XJxnOxVAZsdRi8rD04Iz3SiiKOoYzamjylUcju/hP9np/aZdLHf/7WOD268WMoNJMvYfB5yAK45cw==", + "license": "MIT", + "dependencies": { + "clsx": "^2.1.1", + "prop-types": "^15.8.1" + }, + "peerDependencies": { + "react": ">= 16.3.0", + "react-dom": ">= 16.3.0" + } + }, + "node_modules/react-grid-layout": { + "version": "1.5.3", + "resolved": "https://registry.npmjs.org/react-grid-layout/-/react-grid-layout-1.5.3.tgz", + "integrity": "sha512-KaG6IbjD6fYhagUtIvOzhftXG+ViKZjCjADe86X1KHl7C/dsBN2z0mi14nbvZKTkp0RKiil9RPcJBgq3LnoA8g==", + "license": "MIT", + "dependencies": { + "clsx": "^2.1.1", + "fast-equals": "^4.0.3", + "prop-types": "^15.8.1", + "react-draggable": "^4.4.6", + "react-resizable": "^3.0.5", + "resize-observer-polyfill": "^1.5.1" + }, + "peerDependencies": { + "react": ">= 16.3.0", + "react-dom": ">= 16.3.0" + } + }, "node_modules/react-is": { "version": "16.13.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", @@ -13447,6 +13535,20 @@ } } }, + "node_modules/react-resizable": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/react-resizable/-/react-resizable-3.2.0.tgz", + "integrity": "sha512-3NKQ0SLZV7rs3LQHeXlOzDSRQfFrkX6TVet77/Qk03zqiZyee37b7N8/gwDJAA8UUjRz7PdWCCy49hcso45SMQ==", + "license": "MIT", + "dependencies": { + "prop-types": "15.x", + "react-draggable": "^4.5.0" + }, + "peerDependencies": { + "react": ">= 16.3", + "react-dom": ">= 16.3" + } + }, "node_modules/react-resizable-panels": { "version": "3.0.6", "resolved": "https://registry.npmjs.org/react-resizable-panels/-/react-resizable-panels-3.0.6.tgz", @@ -13684,6 +13786,22 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/remark-directive": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/remark-directive/-/remark-directive-4.0.0.tgz", + "integrity": "sha512-7sxn4RfF1o3izevPV1DheyGDD6X4c9hrGpfdUpm7uC++dqrnJxIZVkk7CoKqcLm0VUMAuOol7Mno3m6g8cfMuA==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-directive": "^3.0.0", + "micromark-extension-directive": "^4.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/remark-gfm": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/remark-gfm/-/remark-gfm-4.0.1.tgz", @@ -13756,6 +13874,12 @@ "integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==", "license": "MIT" }, + "node_modules/resize-observer-polyfill": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/resize-observer-polyfill/-/resize-observer-polyfill-1.5.1.tgz", + "integrity": "sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg==", + "license": "MIT" + }, "node_modules/resolve": { "version": "1.22.10", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz", diff --git a/web_src/package.json b/web_src/package.json index a95b67df38..34ca17be45 100644 --- a/web_src/package.json +++ b/web_src/package.json @@ -60,6 +60,7 @@ "@tippyjs/react": "^4.2.6", "@types/js-yaml": "^4.0.9", "@types/node": "^22.15.18", + "@types/react-grid-layout": "^1.3.6", "@uiw/react-json-view": "^2.0.0-alpha.37", "@xyflow/react": "^12.10.0", "class-variance-authority": "^0.7.1", @@ -78,12 +79,14 @@ "react-day-picker": "^9.11.0", "react-diff-view": "^3.3.2", "react-dom": "^19.1.0", + "react-grid-layout": "^1.5.3", "react-markdown": "^10.1.0", "react-resizable-panels": "^3.0.6", "react-router-dom": "^7.13.0", "react-use-websocket": "^4.0.0", "recharts": "^3.8.1", "remark-breaks": "^4.0.0", + "remark-directive": "^4.0.0", "remark-gfm": "^4.0.1", "sonner": "^2.0.7", "tailwind-merge": "^3.3.1", diff --git a/web_src/src/pages/workflowv2/dashboard/ChartPanelCard.tsx b/web_src/src/pages/workflowv2/dashboard/ChartPanelCard.tsx new file mode 100644 index 0000000000..b9490ccc80 --- /dev/null +++ b/web_src/src/pages/workflowv2/dashboard/ChartPanelCard.tsx @@ -0,0 +1,192 @@ +import { useState } from "react"; +import { AlertTriangle, Trash2 } from "lucide-react"; + +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import type { DashboardPanel } from "@/hooks/useCanvasData"; + +import { DataSourceForm } from "./DataSourceForm"; +import { PanelEditorDialog } from "./PanelEditorDialog"; +import { TypedPanelShell } from "./TypedPanelShell"; +import { useDashboardContext } from "./DashboardContext"; +import type { ChartPanelContent } from "./panelTypes"; +import { useWidgetData } from "./widget/useWidgetData"; +import { WidgetChart } from "./widget/WidgetChart"; +import type { WidgetChartKind, WidgetChartSeries } from "./widget/types"; + +const CHART_KINDS: WidgetChartKind[] = ["bar", "stacked-bar", "line", "area", "donut"]; + +interface ChartPanelCardProps { + panel: DashboardPanel; + readOnly: boolean; + onDelete: () => void; + onChange: (content: Record) => void; +} + +export function ChartPanelCard({ panel, readOnly, onDelete, onChange }: ChartPanelCardProps) { + const [editing, setEditing] = useState(false); + const content = normalizeContent(panel.content); + + return ( + <> + setEditing(true)} + onDelete={onDelete} + > + + + + open={editing} + onOpenChange={setEditing} + panelId={panel.id} + panelType="chart" + initialContent={content} + onSave={(next) => onChange(next as unknown as Record)} + renderForm={({ value, onChange: setDraft }) => } + /> + + ); +} + +function ChartPanelBody({ content }: { content: ChartPanelContent }) { + const ctx = useDashboardContext(); + if (!ctx?.canvasId) return ; + return ; +} + +function ChartPanelDataBound({ content, canvasId }: { content: ChartPanelContent; canvasId: string }) { + const { rows, isLoading, error } = useWidgetData(canvasId, content.dataSource); + if (error) return ; + return ; +} + +function ChartPanelForm({ + value, + onChange, +}: { + value: ChartPanelContent; + onChange: (next: ChartPanelContent) => void; +}) { + const updateSeries = (idx: number, patch: Partial) => { + const series = value.render.series.map((s, i) => (i === idx ? { ...s, ...patch } : s)); + onChange({ ...value, render: { ...value.render, series } }); + }; + const addSeries = () => { + onChange({ + ...value, + render: { ...value.render, series: [...value.render.series, { field: "", label: "" }] }, + }); + }; + const removeSeries = (idx: number) => { + onChange({ ...value, render: { ...value.render, series: value.render.series.filter((_, i) => i !== idx) } }); + }; + + return ( +
+
+ + onChange({ ...value, title: e.target.value })} + placeholder="Defaults to panel id" + /> +
+ onChange({ ...value, dataSource: ds })} /> +
+
+ + +
+
+ + onChange({ ...value, render: { ...value.render, xField: e.target.value } })} + placeholder="e.g. status" + /> +
+
+
+
+ + +
+
+ {value.render.series.map((s, idx) => ( +
+ updateSeries(idx, { field: e.target.value || undefined })} + placeholder="field (blank = count)" + aria-label={`Series ${idx + 1} field`} + /> + updateSeries(idx, { label: e.target.value })} + placeholder="label" + aria-label={`Series ${idx + 1} label`} + /> + +
+ ))} +
+
+
+ ); +} + +function PanelError({ message }: { message: string }) { + return ( +
+ + {message} +
+ ); +} + +function normalizeContent(raw: Record | undefined): ChartPanelContent { + const r = raw ?? {}; + return { + title: typeof r.title === "string" ? r.title : "", + dataSource: (r.dataSource as ChartPanelContent["dataSource"]) ?? { kind: "executions", limit: 100 }, + render: (r.render as ChartPanelContent["render"]) ?? { + kind: "chart", + type: "bar", + xField: "status", + series: [{ field: "count", label: "Count" }], + }, + }; +} diff --git a/web_src/src/pages/workflowv2/dashboard/DashboardContext.tsx b/web_src/src/pages/workflowv2/dashboard/DashboardContext.tsx new file mode 100644 index 0000000000..83dde226df --- /dev/null +++ b/web_src/src/pages/workflowv2/dashboard/DashboardContext.tsx @@ -0,0 +1,87 @@ +import { createContext, useContext, useMemo, type ReactNode } from "react"; + +import type { SuperplaneComponentsNode } from "@/api-client"; + +/** + * Public status shape used by status chips. Mirrors the categories already + * surfaced by the canvas UI (passed / failed / running / etc.) but normalized + * to a small enum so the dashboard does not have to know about every API + * variant. + */ +export type DashboardNodeStatus = "passed" | "failed" | "running" | "pending" | "cancelled" | "skipped" | "unknown"; + +export interface DashboardContextValue { + canvasId: string; + organizationId: string; + /** All canvas nodes available for chip resolution. */ + nodes: SuperplaneComponentsNode[]; + /** Optional latest-status map keyed by node id. */ + nodeStatuses?: Record; + /** + * Runtime authorization flag — true when the viewer is allowed to invoke + * manual triggers, approvals, cancellations, and push-through actions on + * the underlying canvas. Maps to the same `canvases:update` permission the + * gRPC interceptor enforces on `InvokeNodeTriggerHook` / + * `InvokeNodeExecutionHook`; the UI mirrors that so users without the + * permission see disabled controls instead of clicks that silently fail. + */ + canRunNodes: boolean; + /** + * Open the manual-trigger flow for the given node. Resolution is by node id; + * if undefined the chip falls back to dispatching the + * `dashboard:trigger-node` window event so a host can react when wired. + */ + onTriggerNode?: (nodeId: string, options?: { templateName?: string; triggerName?: string }) => void; + /** + * Optional callback when the user opens a node chip (e.g. to focus / scroll + * the corresponding canvas node into view). Falls back to navigation. + */ + onOpenNode?: (nodeId: string) => void; +} + +const DashboardContext = createContext(undefined); + +export interface DashboardContextProviderProps extends DashboardContextValue { + children: ReactNode; +} + +export function DashboardContextProvider({ + children, + canvasId, + organizationId, + nodes, + nodeStatuses, + canRunNodes, + onTriggerNode, + onOpenNode, +}: DashboardContextProviderProps) { + const value = useMemo( + () => ({ canvasId, organizationId, nodes, nodeStatuses, canRunNodes, onTriggerNode, onOpenNode }), + [canvasId, organizationId, nodes, nodeStatuses, canRunNodes, onTriggerNode, onOpenNode], + ); + return {children}; +} + +export function useDashboardContext(): DashboardContextValue | undefined { + return useContext(DashboardContext); +} + +/** + * Resolve a textual node reference to its concrete node id and friendly label. + * Accepts either the canvas node id (UUID) or the node name (e.g. `deploy-prod`). + * + * Returns `undefined` when the reference doesn't match any current node. + */ +export function resolveDashboardNode( + ctx: Pick | undefined, + reference: string, +): { node: SuperplaneComponentsNode; label: string } | undefined { + if (!ctx) return undefined; + const trimmed = reference.trim(); + if (!trimmed) return undefined; + const byId = ctx.nodes.find((n) => n.id === trimmed); + if (byId) return { node: byId, label: byId.name || byId.id || trimmed }; + const byName = ctx.nodes.find((n) => n.name === trimmed); + if (byName) return { node: byName, label: byName.name || byName.id || trimmed }; + return undefined; +} diff --git a/web_src/src/pages/workflowv2/dashboard/DashboardOverlay.tsx b/web_src/src/pages/workflowv2/dashboard/DashboardOverlay.tsx index 2259ab6b99..fc71177691 100644 --- a/web_src/src/pages/workflowv2/dashboard/DashboardOverlay.tsx +++ b/web_src/src/pages/workflowv2/dashboard/DashboardOverlay.tsx @@ -1,5 +1,6 @@ -import { useCallback, useRef } from "react"; +import { useCallback, useMemo, useRef } from "react"; +import type { SuperplaneComponentsNode } from "@/api-client"; import type { CanvasDashboardQueryResult, DashboardLayoutItem, @@ -8,59 +9,152 @@ import type { } from "@/hooks/useCanvasData"; import { DashboardView } from "./DashboardView"; +import { DashboardYamlModal } from "./DashboardYamlModal"; +import { DashboardContextProvider, type DashboardNodeStatus } from "./DashboardContext"; export type DashboardOverlayProps = { + /** + * Aggregated read-only flag: true when the viewer cannot edit the dashboard + * structure (panels, layout, markdown body) for any reason — missing the + * `canvases:update` permission, viewing a template, or the canvas being + * deleted remotely. Mirrors the server-side guard in + * `UpdateCanvasDashboard`; the server remains the source of truth. + */ readOnly: boolean; + /** + * True when the viewer can import/replace the dashboard with YAML. Falls + * back to false when `readOnly` is true. Server-side authorization remains + * the source of truth; this is for UX only. + */ + canImportYaml: boolean; + /** + * True when the viewer can invoke runtime actions on the underlying canvas: + * trigger chips, run-trigger row actions, approve/cancel/push-through + * execution hooks. This maps 1:1 to the `canvases:update` permission used + * by the `InvokeNodeTriggerHook` / `InvokeNodeExecutionHook` interceptor + * rules; the same backend rules apply even if the UI is bypassed. + */ + canRunNodes: boolean; dashboardQuery: CanvasDashboardQueryResult; updateDashboardMutation: UpdateCanvasDashboardMutationResult; addPanelDialogOpen: boolean; onAddPanelDialogOpenChange: (open: boolean) => void; + /** Controlled state for the YAML modal — owned by the canvas page header. */ + yamlModalOpen: boolean; + onYamlModalOpenChange: (open: boolean) => void; + canvasId?: string; + canvasName?: string; + /** Organization id for chip navigation. Required when chips should be live. */ + organizationId?: string; + /** Canvas nodes used to resolve chip references by id or name. */ + canvasNodes?: SuperplaneComponentsNode[]; + /** Latest known node status per node id; powers the status chip. */ + nodeStatuses?: Record; + /** Callback invoked when a manual-run chip is clicked. */ + onTriggerNode?: (nodeId: string, options?: { templateName?: string; triggerName?: string }) => void; }; export function DashboardOverlay({ readOnly, + canImportYaml, + canRunNodes, dashboardQuery, updateDashboardMutation, addPanelDialogOpen, onAddPanelDialogOpenChange, + yamlModalOpen, + onYamlModalOpenChange, + canvasId, + canvasName, + organizationId, + canvasNodes, + nodeStatuses, + onTriggerNode, }: DashboardOverlayProps) { const updateDashboardMutationRef = useRef(updateDashboardMutation); updateDashboardMutationRef.current = updateDashboardMutation; - const panels: DashboardPanel[] = (dashboardQuery.data?.panels || []).map((p) => ({ - id: p.id || "", - type: p.type || "markdown", - content: (p.content as Record) || {}, - })); - const layout: DashboardLayoutItem[] = (dashboardQuery.data?.layout || []).map((l) => ({ - i: l.i || "", - x: l.x || 0, - y: l.y || 0, - w: l.w || 12, - h: l.h || 6, - ...(l.minW !== undefined ? { minW: l.minW } : {}), - ...(l.minH !== undefined ? { minH: l.minH } : {}), - })); + const panels: DashboardPanel[] = useMemo( + () => + (dashboardQuery.data?.panels || []).map((p) => ({ + id: p.id || "", + type: p.type || "markdown", + content: (p.content as Record) || {}, + })), + [dashboardQuery.data?.panels], + ); + const layout: DashboardLayoutItem[] = useMemo( + () => + (dashboardQuery.data?.layout || []).map((l) => ({ + i: l.i || "", + x: l.x || 0, + y: l.y || 0, + w: l.w || 12, + h: l.h || 6, + ...(l.minW !== undefined ? { minW: l.minW } : {}), + ...(l.minH !== undefined ? { minH: l.minH } : {}), + })), + [dashboardQuery.data?.layout], + ); const handleChange = useCallback((next: { panels: DashboardPanel[]; layout: DashboardLayoutItem[] }) => { updateDashboardMutationRef.current.mutate(next); }, []); - return ( + const handleImportYaml = useCallback(async (next: { panels: DashboardPanel[]; layout: DashboardLayoutItem[] }) => { + // Use mutateAsync so the modal can await before closing/showing toasts. + await updateDashboardMutationRef.current.mutateAsync(next); + }, []); + + const contextNodes = canvasNodes ?? []; + // The YAML modal is opened from the canvas page header (next to "Add panel"). + // The dashboard overlay no longer renders its own toolbar so the white + // strip is gone and the grid fills the available area. + const overlayContent = (
- + +
+ + ); + + if (!canvasId || !organizationId) { + return overlayContent; + } + + return ( + + {overlayContent} + + ); } diff --git a/web_src/src/pages/workflowv2/dashboard/DashboardView.tsx b/web_src/src/pages/workflowv2/dashboard/DashboardView.tsx index 8d5d65b1d2..e383c59525 100644 --- a/web_src/src/pages/workflowv2/dashboard/DashboardView.tsx +++ b/web_src/src/pages/workflowv2/dashboard/DashboardView.tsx @@ -1,5 +1,8 @@ -import { useCallback, useState } from "react"; -import { Plus, Loader2, LayoutGrid, AtSign, BarChart3, Play } from "lucide-react"; +import { useCallback, useMemo, useState } from "react"; +import GridLayout, { type Layout, WidthProvider } from "react-grid-layout"; +import { Plus, Loader2, LayoutGrid, FileText, Hash, LineChart, Table2, Workflow } from "lucide-react"; + +import { cn } from "@/lib/utils"; import { Button } from "@/components/ui/button"; import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"; import { Input } from "@/components/ui/input"; @@ -7,7 +10,22 @@ import { Label } from "@/components/ui/label"; import type { DashboardPanel, DashboardLayoutItem } from "@/hooks/useCanvasData"; import { MarkdownPanelCard } from "./MarkdownPanelCard"; +import { NodePanelCard } from "./NodePanelCard"; +import { TablePanelCard } from "./TablePanelCard"; +import { ChartPanelCard } from "./ChartPanelCard"; +import { NumberPanelCard } from "./NumberPanelCard"; import { useDashboardPanelState } from "./useDashboardPanelState"; +import { PANEL_TYPE_META, PANEL_TYPES, type PanelType } from "./panelTypes"; + +import "react-grid-layout/css/styles.css"; +import "./dashboard-grid.css"; + +const ResponsiveGridLayout = WidthProvider(GridLayout); + +const DASHBOARD_GRID_COLS = 12; +const DASHBOARD_ROW_HEIGHT = 40; +const DASHBOARD_DEFAULT_MIN_W = 2; +const DASHBOARD_DEFAULT_MIN_H = 2; export interface DashboardViewProps { panels: DashboardPanel[]; @@ -42,16 +60,18 @@ export function DashboardView({ [isAddPanelControlled, onAddPanelDialogOpenChange], ); - const { localPanels, handleAddPanel, handleDeletePanel, handlePanelContentChange } = useDashboardPanelState( - panels, - layout, - onChange, + const { localPanels, localLayout, handleAddPanel, handleDeletePanel, handlePanelContentChange, handleLayoutChange } = + useDashboardPanelState(panels, layout, onChange); + + const confirmAddPanel = useCallback( + (name: string, type: PanelType) => { + handleAddPanel(name, type); + setAddPanelOpen(false); + }, + [handleAddPanel, setAddPanelOpen], ); - const confirmAddPanel = (name: string) => { - handleAddPanel(name); - setAddPanelOpen(false); - }; + const layoutItems = useMemo(() => buildRGLLayout(localPanels, localLayout), [localPanels, localLayout]); if (errorMessage) { return ( @@ -81,22 +101,101 @@ export function DashboardView({ return (
-
- {localPanels.map((panel) => ( - handleDeletePanel(panel.id)} - onChange={(content) => handlePanelContentChange(panel.id, content)} - /> - ))} +
+ { + if (readOnly) return; + handleLayoutChange(toDashboardLayout(next)); + }} + useCSSTransforms + compactType="vertical" + preventCollision={false} + > + {localPanels.map((panel) => ( +
+ handleDeletePanel(panel.id)} + onChange={(content) => handlePanelContentChange(panel.id, content)} + /> +
+ ))} +
setAddPanelOpen(false)} />
); } +/** + * Build the layout array consumed by react-grid-layout. Panels with no layout + * entry get a sensible default position appended to the bottom of the grid, so + * legacy or YAML-imported dashboards that omit `layout` still render. + */ +function buildRGLLayout(panels: DashboardPanel[], layout: DashboardLayoutItem[]): Layout[] { + const byId = new Map(); + for (const item of layout) byId.set(item.i, item); + + let nextY = layout.reduce((acc, item) => Math.max(acc, item.y + item.h), 0); + const result: Layout[] = []; + for (const panel of panels) { + const existing = byId.get(panel.id); + if (existing) { + result.push({ + i: existing.i, + x: existing.x, + y: existing.y, + w: existing.w, + h: existing.h, + minW: existing.minW ?? DASHBOARD_DEFAULT_MIN_W, + minH: existing.minH ?? DASHBOARD_DEFAULT_MIN_H, + }); + continue; + } + result.push({ + i: panel.id, + x: 0, + y: nextY, + w: 12, + h: 6, + minW: DASHBOARD_DEFAULT_MIN_W, + minH: DASHBOARD_DEFAULT_MIN_H, + }); + nextY += 6; + } + return result; +} + +function toDashboardLayout(next: Layout[]): DashboardLayoutItem[] { + return next.map((item) => { + const result: DashboardLayoutItem = { + i: item.i, + x: item.x, + y: item.y, + w: item.w, + h: item.h, + }; + if (typeof item.minW === "number") result.minW = item.minW; + if (typeof item.minH === "number") result.minH = item.minH; + return result; + }); +} + function EmptyState({ readOnly, onAdd }: { readOnly: boolean; onAdd: () => void }) { return (
@@ -114,22 +213,22 @@ function EmptyState({ readOnly, onAdd }: { readOnly: boolean; onAdd: () => void
- -

Reference nodes

-

Drop in @my-node chips that show live status.

+ +

Document with markdown

+

Write runbooks, links, and notes in markdown.

- +

Show live data

- Embed canvas memory or executions with widget blocks. + Tables, charts, and KPIs over executions or memory.

- -

Trigger runs

+ +

Surface key nodes

- Add Run buttons for manual triggers right in markdown. + Pin a node with its live status and an optional Run button.

@@ -145,16 +244,56 @@ function EmptyState({ readOnly, onAdd }: { readOnly: boolean; onAdd: () => void ); } +const PANEL_TYPE_ICONS: Record = { + markdown: FileText, + node: Workflow, + table: Table2, + chart: LineChart, + number: Hash, +}; + +function PanelCardRouter({ + panel, + readOnly, + onDelete, + onChange, +}: { + panel: DashboardPanel; + readOnly: boolean; + onDelete: () => void; + onChange: (content: Record) => void; +}) { + switch (panel.type) { + case "node": + return ; + case "table": + return ; + case "chart": + return ; + case "number": + return ; + case "markdown": + default: + return ; + } +} + +/** + * Add Panel dialog with a type picker. The user picks one of the five panel + * kinds, names the panel, and confirms — the resulting panel is seeded with a + * sensible per-type template so the editor opens straight into a working form. + */ function AddPanelDialog({ open, onConfirm, onCancel, }: { open: boolean; - onConfirm: (name: string) => void; + onConfirm: (name: string, type: PanelType) => void; onCancel: () => void; }) { const [name, setName] = useState(""); + const [type, setType] = useState("markdown"); const slug = name .toLowerCase() .trim() @@ -164,21 +303,63 @@ function AddPanelDialog({ .replace(/^-|-$/g, ""); const isValid = slug.length > 0; + const reset = () => { + setName(""); + setType("markdown"); + }; + + const submit = () => { + if (!isValid) return; + onConfirm(name.trim(), type); + reset(); + }; + return ( { if (!isOpen) { - setName(""); + reset(); onCancel(); } }} > - + Add panel -
+
+
+ +
+ {PANEL_TYPES.map((t) => { + const meta = PANEL_TYPE_META[t]; + const Icon = PANEL_TYPE_ICONS[t]; + const selected = type === t; + return ( + + ); + })} +
+
setName(e.target.value)} onKeyDown={(e: React.KeyboardEvent) => { - if (e.key === "Enter" && isValid) { - onConfirm(name.trim()); - setName(""); + if (e.key === "Enter") { + e.preventDefault(); + submit(); } }} autoFocus @@ -201,16 +382,7 @@ function AddPanelDialog({ - diff --git a/web_src/src/pages/workflowv2/dashboard/DashboardYamlModal.tsx b/web_src/src/pages/workflowv2/dashboard/DashboardYamlModal.tsx new file mode 100644 index 0000000000..546f855f64 --- /dev/null +++ b/web_src/src/pages/workflowv2/dashboard/DashboardYamlModal.tsx @@ -0,0 +1,257 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { Editor } from "@monaco-editor/react"; +import { AlertCircle, Copy, Download, Upload } from "lucide-react"; + +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { showErrorToast, showSuccessToast } from "@/lib/toast"; +import type { DashboardLayoutItem, DashboardPanel } from "@/hooks/useCanvasData"; + +import { dashboardToYaml, dashboardYamlFilename, parseDashboardYaml } from "./dashboardYaml"; + +export type DashboardYamlModalProps = { + open: boolean; + onOpenChange: (open: boolean) => void; + panels: DashboardPanel[]; + layout: DashboardLayoutItem[]; + canvasId?: string; + canvasName?: string; + /** When provided, the modal allows importing YAML. When omitted, it is view-only. */ + onImport?: (next: { panels: DashboardPanel[]; layout: DashboardLayoutItem[] }) => Promise; + isImporting?: boolean; +}; + +export function DashboardYamlModal({ + open, + onOpenChange, + panels, + layout, + canvasId, + canvasName, + onImport, + isImporting, +}: DashboardYamlModalProps) { + const exportedYaml = useMemo( + () => dashboardToYaml({ panels, layout, canvasId, canvasName }), + [panels, layout, canvasId, canvasName], + ); + const filename = useMemo(() => dashboardYamlFilename(canvasName), [canvasName]); + + const [editorText, setEditorText] = useState(exportedYaml); + const [parseError, setParseError] = useState(null); + const [confirmingReplace, setConfirmingReplace] = useState(false); + const fileInputRef = useRef(null); + + // Keep editor in sync with the latest exported YAML when the modal opens + // or the underlying dashboard changes from outside. + useEffect(() => { + if (open) { + setEditorText(exportedYaml); + setParseError(null); + } + }, [open, exportedYaml]); + + const handleCopy = useCallback(async () => { + try { + await navigator.clipboard.writeText(editorText); + showSuccessToast("Dashboard YAML copied to clipboard"); + } catch { + showErrorToast("Failed to copy YAML to clipboard"); + } + }, [editorText]); + + const handleDownload = useCallback(() => { + const blob = new Blob([editorText], { type: "text/yaml;charset=utf-8" }); + const url = URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = url; + link.download = filename; + document.body.appendChild(link); + link.click(); + link.remove(); + URL.revokeObjectURL(url); + showSuccessToast("Dashboard exported as YAML"); + }, [editorText, filename]); + + const handleFileUpload = useCallback((event: React.ChangeEvent) => { + const file = event.target.files?.[0]; + if (!file) return; + if (!file.name.endsWith(".yaml") && !file.name.endsWith(".yml")) { + setParseError("Please select a .yaml or .yml file."); + return; + } + const reader = new FileReader(); + reader.onload = (e) => { + const text = e.target?.result; + if (typeof text === "string") { + setEditorText(text); + setParseError(null); + } + }; + reader.onerror = () => setParseError("Failed to read file."); + reader.readAsText(file); + + if (fileInputRef.current) fileInputRef.current.value = ""; + }, []); + + const handleApplyClick = useCallback(() => { + setParseError(null); + const result = parseDashboardYaml(editorText); + if (!result.ok) { + setParseError(result.error); + return; + } + setConfirmingReplace(true); + }, [editorText]); + + const handleConfirmReplace = useCallback(async () => { + if (!onImport) return; + const result = parseDashboardYaml(editorText); + if (!result.ok) { + setParseError(result.error); + setConfirmingReplace(false); + return; + } + try { + await onImport({ panels: result.data.spec.panels, layout: result.data.spec.layout }); + setConfirmingReplace(false); + onOpenChange(false); + showSuccessToast("Dashboard imported from YAML"); + } catch (e) { + setConfirmingReplace(false); + setParseError(e instanceof Error ? e.message : "Failed to import dashboard."); + } + }, [editorText, onImport, onOpenChange]); + + const isDirty = editorText !== exportedYaml; + + return ( + + + + {filename} + + View, copy, download, or import the dashboard as a YAML file. Imports replace every panel and layout entry. + + + +
+ + {isDirty ? "Editor has unsaved YAML edits" : "Showing live dashboard YAML"} + +
+ {onImport ? ( + <> + + + + ) : null} + + +
+
+ +
+ { + setEditorText(value ?? ""); + if (parseError) setParseError(null); + }} + theme="vs" + options={{ + readOnly: !onImport, + domReadOnly: !onImport, + minimap: { enabled: false }, + fontSize: 13, + lineNumbers: "on", + wordWrap: "on", + folding: true, + scrollBeyondLastLine: false, + renderWhitespace: "boundary", + smoothScrolling: true, + tabSize: 2, + renderLineHighlight: "line", + }} + /> +
+ + {parseError ? ( +
+ + {parseError} +
+ ) : null} + + + + {onImport ? ( + + ) : null} + +
+ + + + + Replace dashboard? + + Applying this YAML replaces every panel and layout entry for the current dashboard. This action cannot be + undone. + + + + + + + + +
+ ); +} diff --git a/web_src/src/pages/workflowv2/dashboard/DataSourceForm.tsx b/web_src/src/pages/workflowv2/dashboard/DataSourceForm.tsx new file mode 100644 index 0000000000..ca4d23b438 --- /dev/null +++ b/web_src/src/pages/workflowv2/dashboard/DataSourceForm.tsx @@ -0,0 +1,134 @@ +import { useDashboardContext } from "./DashboardContext"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; + +import type { ChartPanelDataSource } from "./panelTypes"; + +interface DataSourceFormProps { + value: ChartPanelDataSource; + onChange: (next: ChartPanelDataSource) => void; + /** Hide the limit input (e.g. number panels that aggregate everything). */ + hideLimit?: boolean; +} + +/** + * Shared editor for `panel.content.dataSource`. Used by the table, chart, and + * number form editors. Switching `kind` resets the kind-specific fields to + * sensible defaults so the resulting object always matches the validator. + */ +export function DataSourceForm({ value, onChange, hideLimit }: DataSourceFormProps) { + const ctx = useDashboardContext(); + const nodes = ctx?.nodes ?? []; + + const setKind = (kind: "memory" | "executions" | "runs") => { + if (kind === "memory") { + onChange({ kind: "memory", namespace: "" }); + } else if (kind === "runs") { + onChange({ kind: "runs", limit: 100 }); + } else { + onChange({ kind: "executions", limit: 50 }); + } + }; + + return ( +
+
+ + +
+ + {value.kind === "runs" ? ( + hideLimit ? null : ( +
+ + + onChange({ + ...value, + limit: e.target.value === "" ? undefined : Number(e.target.value), + }) + } + placeholder="100" + /> +
+ ) + ) : value.kind === "executions" ? ( + <> +
+ + +
+ {hideLimit ? null : ( +
+ + + onChange({ + ...value, + limit: e.target.value === "" ? undefined : Number(e.target.value), + }) + } + placeholder="50" + /> +
+ )} + + ) : ( + <> +
+ + onChange({ ...value, namespace: e.target.value })} + placeholder="e.g. deployments" + /> +
+
+ + onChange({ ...value, fieldPath: e.target.value || undefined })} + placeholder="e.g. items" + /> +
+ + )} +
+ ); +} diff --git a/web_src/src/pages/workflowv2/dashboard/MarkdownPanelCard.tsx b/web_src/src/pages/workflowv2/dashboard/MarkdownPanelCard.tsx index fb99005932..4d6037c68a 100644 --- a/web_src/src/pages/workflowv2/dashboard/MarkdownPanelCard.tsx +++ b/web_src/src/pages/workflowv2/dashboard/MarkdownPanelCard.tsx @@ -1,7 +1,11 @@ -import { useEffect, useRef, useState, type RefObject } from "react"; +import { useEffect, useMemo, useRef, useState, type RefObject } from "react"; import { Pencil, Trash2 } from "lucide-react"; +import ReactMarkdown from "react-markdown"; +import remarkBreaks from "remark-breaks"; +import remarkGfm from "remark-gfm"; import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; import { Textarea } from "@/components/ui/textarea"; import { Dialog, @@ -11,8 +15,38 @@ import { DialogHeader, DialogTitle, } from "@/components/ui/dialog"; +import { cn } from "@/lib/utils"; import type { DashboardPanel } from "@/hooks/useCanvasData"; -import { WorkflowMarkdownPreview } from "../WorkflowMarkdownPreview"; + +/** + * Tailwind class string used to style the rendered markdown body. We don't use + * the official `prose` plugin so panels stay visually consistent with the rest + * of the canvas chrome at small panel sizes. + */ +const MARKDOWN_CLASSES = + "max-w-none text-sm text-slate-800 " + + "[&_h1]:mb-1.5 [&_h1]:mt-1 [&_h1]:text-lg [&_h1]:font-semibold [&_h1]:leading-tight [&_h1:first-child]:mt-0 " + + "[&_h2]:mb-1 [&_h2]:mt-1 [&_h2]:text-base [&_h2]:font-semibold [&_h2]:leading-tight [&_h2:first-child]:mt-0 " + + "[&_h3]:mb-0.5 [&_h3]:mt-1 [&_h3]:text-sm [&_h3]:font-semibold [&_h3]:leading-tight [&_h3:first-child]:mt-0 " + + "[&_h4]:mb-0.5 [&_h4]:mt-1 [&_h4]:text-sm [&_h4]:font-medium [&_h4]:leading-tight [&_h4:first-child]:mt-0 " + + "[&_p]:mb-2 [&_p]:leading-relaxed " + + "[&_ol]:mb-2 [&_ol]:ml-5 [&_ol]:list-decimal " + + "[&_ul]:mb-2 [&_ul]:ml-5 [&_ul]:list-disc [&_li]:mb-1 " + + "[&_blockquote]:my-2 [&_blockquote]:border-l-2 [&_blockquote]:border-slate-300 [&_blockquote]:pl-3 " + + "[&_code]:rounded [&_code]:bg-slate-100 [&_code]:px-1 [&_code]:py-0.5 [&_code]:text-xs " + + "[&_pre]:my-2 [&_pre]:overflow-auto [&_pre]:rounded [&_pre]:bg-slate-100 [&_pre]:p-2 " + + "[&_pre_code]:bg-transparent [&_pre_code]:p-0 " + + "[&_a]:underline [&_a]:underline-offset-2 [&_a]:decoration-current " + + "[&_table]:my-2 [&_table]:text-xs [&_table]:border-collapse [&_th]:border [&_th]:border-slate-200 [&_th]:px-2 [&_th]:py-1 " + + "[&_td]:border [&_td]:border-slate-100 [&_td]:px-2 [&_td]:py-1"; + +/** + * Which field auto-focuses when the user enters edit mode. Driven by which + * affordance the user activated: + * - pencil icon → title input + * - double-click on the body / "click to edit" empty state → body textarea + */ +type EditFocus = "title" | "body" | null; export function MarkdownPanelCard({ panel, @@ -25,37 +59,69 @@ export function MarkdownPanelCard({ onDelete: () => void; onChange: (content: Record) => void; }) { - const [isEditing, setIsEditing] = useState(false); const body = typeof panel.content?.body === "string" ? panel.content.body : ""; - const [draft, setDraft] = useState(body); + const persistedTitle = typeof panel.content?.title === "string" ? panel.content.title : ""; + const displayTitle = persistedTitle.trim() || panel.id; + + const [editFocus, setEditFocus] = useState(null); + const isEditing = editFocus !== null; + const [draftBody, setDraftBody] = useState(body); + const [draftTitle, setDraftTitle] = useState(persistedTitle); + const titleInputRef = useRef(null); const textareaRef = useRef(null); const [confirmingDelete, setConfirmingDelete] = useState(false); + // Sync drafts from props when we're not editing, so external updates + // (YAML import, websocket invalidation, etc.) flow into the rendered view. useEffect(() => { - if (!isEditing) setDraft(body); - }, [body, isEditing]); + if (!isEditing) { + setDraftBody(body); + setDraftTitle(persistedTitle); + } + }, [body, persistedTitle, isEditing]); useEffect(() => { - if (isEditing) textareaRef.current?.focus(); - }, [isEditing]); + if (editFocus === "title") { + titleInputRef.current?.focus(); + titleInputRef.current?.select(); + } else if (editFocus === "body") { + textareaRef.current?.focus(); + } + }, [editFocus]); const commit = () => { if (!isEditing) return; - setIsEditing(false); - if (draft !== body) onChange({ body: draft }); + setEditFocus(null); + const trimmedTitle = draftTitle.trim(); + const bodyChanged = draftBody !== body; + const titleChanged = trimmedTitle !== persistedTitle; + if (!bodyChanged && !titleChanged) return; + const nextContent: Record = { ...(panel.content ?? {}), body: draftBody }; + if (trimmedTitle) nextContent.title = trimmedTitle; + else delete nextContent.title; + onChange(nextContent); }; const cancel = () => { - setIsEditing(false); - setDraft(body); + setEditFocus(null); + setDraftBody(body); + setDraftTitle(persistedTitle); + }; + + const startEditing = (focus: EditFocus) => { + if (readOnly || focus === null) return; + setEditFocus(focus); }; if (isEditing && !readOnly) { return ( -
-
- {panel.id} - {!readOnly ? ( -
- - -
- ) : null} -
+
+ startEditing("title")} + onRequestDelete={() => setConfirmingDelete(true)} + /> {body.trim() ? (
setIsEditing(true)} + className="min-h-0 flex-1 overflow-auto px-4 py-3" + onDoubleClick={readOnly ? undefined : () => startEditing("body")} data-testid="dashboard-markdown-view" > - +
) : ( + +
+ ) : null} +
+ ); +} + function MarkdownPanelEditor({ panelId, - draft, - setDraft, + draftTitle, + setDraftTitle, + draftBody, + setDraftBody, + titleInputRef, textareaRef, onCancel, onCommit, }: { panelId: string; - draft: string; - setDraft: (value: string) => void; + draftTitle: string; + setDraftTitle: (value: string) => void; + draftBody: string; + setDraftBody: (value: string) => void; + titleInputRef: RefObject; textareaRef: RefObject; onCancel: () => void; onCommit: () => void; }) { + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Escape") { + e.preventDefault(); + onCancel(); + return; + } + if ((e.metaKey || e.ctrlKey) && e.key === "Enter") { + e.preventDefault(); + onCommit(); + } + }; + return ( -
-
- {panelId} +
+
+ setDraftTitle(e.target.value)} + onKeyDown={handleKeyDown} + placeholder={panelId} + aria-label="Panel title" + className="h-7 border-0 bg-transparent px-2 text-xs font-medium text-slate-800 shadow-none focus-visible:ring-0 focus-visible:ring-offset-0" + data-testid="dashboard-markdown-title-editor" + />