Files
7838d00f57 feat: connector workbench (#641)
* wip: connector workbench

* wip: improve workbench

* feat(workbench): improve UI with sidebar, routing, and syntax highlighting

- Replace connector dropdown with Slack-style sidebar for connector selection
- Add URL-based routing with react-router-dom for browser navigation support
- Add JSON syntax highlighting with react-syntax-highlighter
- Rework State tab layout to list/detail view with filtering
- Update terminology from "create" to "instantiate" for connectors
- Fix duplicate selection bug in lists (ID generation collision)
- Unify data table row heights across tabs
- Increase code block font sizes for readability
- Hide navigation tabs on homepage when no connector selected
- Make CW logo clickable to return to home view

Co-authored-by: Cursor <[email protected]>

* fix(workbench): use textarea for PEM/certificate config fields

Fields containing 'privateKey', 'certificate', or 'pem' in their name
now use a textarea instead of single-line input, preserving newlines
in PEM content that would otherwise be stripped.

Co-authored-by: Cursor <[email protected]>

* fix(workbench): address PR review issues

- Fix data race on idCounter using atomic.Int64
- Fix path traversal vulnerability in introspect.go
- Fix pointer comparison for DefaultAsset in baseline.go

Co-authored-by: Cursor <[email protected]>

* feat(workbench): add generic connector server for integration testing

Expose the generic connector REST API from the workbench so staging
services can install a "generic connector" pointing to the local
workbench, enabling end-to-end integration testing.

- Add /generic/* routes (accounts, balances, beneficiaries, transactions)
- Return cached data from storage instead of making fresh API calls
- Add connector selection and optional API key authentication
- Add UI panel in Debug tab for configuration

Co-authored-by: Cursor <[email protected]>

* fix(workbench): use internal registry to avoid pkg dependency

Change imports from pkg/registry to internal/connectors/plugins/registry
so the workbench can be merged before the pkg refactoring. The internal
registry will automatically delegate to pkg/registry once that refactor
lands.

Also adds light/dark mode toggle to the UI with system preference detection.

Co-authored-by: Cursor <[email protected]>

* fix(workbench): address PR review feedback

- Add ctx.Done() checks in fetch loops to exit early on client disconnect
- Refactor generic server to get connector directly instead of via context,
  providing clearer data flow as suggested in review

Co-authored-by: Cursor <[email protected]>

* fix(workbench): include built UI files for embed directive

The //go:embed ui/dist/* directive requires files to exist at build time.
Add exception to .gitignore and commit the built UI distribution files.

Co-authored-by: Cursor <[email protected]>

* fix(workbench): address golangci-lint errors

- Remove unused globalDebugTransport variable
- Remove unused parseIntParam function
- Fix printf format for models.TaskType (use %v instead of %s)
- Add error return value handling for various function calls

Co-authored-by: Cursor <[email protected]>

* fix: remove .vite cache folder from git

Co-authored-by: Cursor <[email protected]>

* fix(workbench): fix remaining errcheck issue

Co-authored-by: Cursor <[email protected]>

* fix(workbench): fix all remaining lint issues

- Add error handling for Body.Close() calls
- Remove unnecessary fmt.Sprintf wrapper
- Simplify hex character check condition

Co-authored-by: Cursor <[email protected]>

* fix(workbench): fix errcheck in cmd/workbench.go

Co-authored-by: Cursor <[email protected]>

* chore: replace ui dist build artifacts with placeholder

The //go:embed directive needs at least one file to exist.
Instead of committing the entire built UI, commit only a
.gitkeep placeholder. The server has fallback code that
displays instructions when the full UI isn't available.

Co-authored-by: Cursor <[email protected]>

---------

Co-authored-by: Cursor <[email protected]>
2026-02-12 18:29:17 +00:00

485 lines
13 KiB
Go

package workbench
import (
"encoding/json"
"fmt"
"sync"
"time"
"github.com/formancehq/payments/internal/models"
"github.com/google/uuid"
)
// taskTypeNames maps TaskType to human-readable strings.
var taskTypeNames = map[models.TaskType]string{
models.TASK_FETCH_OTHERS: "FETCH_OTHERS",
models.TASK_FETCH_ACCOUNTS: "FETCH_ACCOUNTS",
models.TASK_FETCH_BALANCES: "FETCH_BALANCES",
models.TASK_FETCH_EXTERNAL_ACCOUNTS: "FETCH_EXTERNAL_ACCOUNTS",
models.TASK_FETCH_PAYMENTS: "FETCH_PAYMENTS",
models.TASK_CREATE_WEBHOOKS: "CREATE_WEBHOOKS",
}
// taskTypeName returns the string name for a TaskType.
func taskTypeName(t models.TaskType) string {
if name, ok := taskTypeNames[t]; ok {
return name
}
return fmt.Sprintf("UNKNOWN_%d", int(t))
}
// TaskStatus represents the status of a task.
type TaskStatus string
const (
TaskStatusPending TaskStatus = "pending"
TaskStatusRunning TaskStatus = "running"
TaskStatusCompleted TaskStatus = "completed"
TaskStatusFailed TaskStatus = "failed"
TaskStatusSkipped TaskStatus = "skipped"
)
// TaskNode represents a task in the execution tree.
type TaskNode struct {
ID string `json:"id"`
Type string `json:"type"`
Name string `json:"name,omitempty"`
Status TaskStatus `json:"status"`
StartedAt *time.Time `json:"started_at,omitempty"`
CompletedAt *time.Time `json:"completed_at,omitempty"`
Duration time.Duration `json:"duration,omitempty"`
Error string `json:"error,omitempty"`
ItemsCount int `json:"items_count,omitempty"`
FromPayload json.RawMessage `json:"from_payload,omitempty"`
Children []*TaskNode `json:"children,omitempty"`
Executions []TaskExecution `json:"executions,omitempty"`
}
// TaskExecution represents a single execution of a task.
type TaskExecution struct {
ID string `json:"id"`
StartedAt time.Time `json:"started_at"`
CompletedAt *time.Time `json:"completed_at,omitempty"`
Duration time.Duration `json:"duration,omitempty"`
Status TaskStatus `json:"status"`
Error string `json:"error,omitempty"`
ItemsCount int `json:"items_count"`
PageNumber int `json:"page_number"`
HasMore bool `json:"has_more"`
}
// TaskTracker tracks task execution for the workbench.
type TaskTracker struct {
mu sync.RWMutex
// The root task tree (from Install)
rootTree []*TaskNode
// Flat list of all executions for history
executions []TaskExecution
// Currently running task
currentTask *TaskNode
// Execution mode
stepMode bool // If true, pause after each task
// Channels for step control
stepChan chan struct{}
stopChan chan struct{}
// Stats
stats TaskStats
}
// TaskStats holds execution statistics.
type TaskStats struct {
TotalExecutions int `json:"total_executions"`
SuccessCount int `json:"success_count"`
FailureCount int `json:"failure_count"`
TotalItemsFetched int `json:"total_items_fetched"`
TotalDuration time.Duration `json:"total_duration"`
LastExecutionAt *time.Time `json:"last_execution_at,omitempty"`
}
// NewTaskTracker creates a new task tracker.
func NewTaskTracker() *TaskTracker {
return &TaskTracker{
executions: make([]TaskExecution, 0),
stepChan: make(chan struct{}, 1),
stopChan: make(chan struct{}),
}
}
// SetTaskTree sets the task tree from the connector's Install response.
func (t *TaskTracker) SetTaskTree(tree models.ConnectorTasksTree) {
t.mu.Lock()
defer t.mu.Unlock()
t.rootTree = t.convertTree(tree, nil)
}
func (t *TaskTracker) convertTree(tree models.ConnectorTasksTree, fromPayload json.RawMessage) []*TaskNode {
var nodes []*TaskNode
for _, task := range tree {
node := &TaskNode{
ID: uuid.New().String(),
Type: taskTypeName(task.TaskType),
Name: task.Name,
Status: TaskStatusPending,
FromPayload: fromPayload,
Executions: make([]TaskExecution, 0),
}
if len(task.NextTasks) > 0 {
// Children will be created dynamically based on fetched items
// For now, just note that there are child tasks
node.Children = make([]*TaskNode, 0)
}
nodes = append(nodes, node)
}
return nodes
}
// GetTaskTree returns the current task tree.
func (t *TaskTracker) GetTaskTree() []*TaskNode {
t.mu.RLock()
defer t.mu.RUnlock()
return t.rootTree
}
// GetExecutions returns recent executions.
func (t *TaskTracker) GetExecutions(limit int) []TaskExecution {
t.mu.RLock()
defer t.mu.RUnlock()
if limit <= 0 || limit > len(t.executions) {
limit = len(t.executions)
}
// Return most recent first
result := make([]TaskExecution, limit)
for i := 0; i < limit; i++ {
result[i] = t.executions[len(t.executions)-1-i]
}
return result
}
// GetCurrentTask returns the currently running task.
func (t *TaskTracker) GetCurrentTask() *TaskNode {
t.mu.RLock()
defer t.mu.RUnlock()
return t.currentTask
}
// GetStats returns execution statistics.
func (t *TaskTracker) GetStats() TaskStats {
t.mu.RLock()
defer t.mu.RUnlock()
return t.stats
}
// StartTask marks a task as started.
func (t *TaskTracker) StartTask(taskType models.TaskType, name string, fromPayload json.RawMessage) *TaskExecution {
t.mu.Lock()
defer t.mu.Unlock()
now := time.Now()
exec := TaskExecution{
ID: uuid.New().String(),
StartedAt: now,
Status: TaskStatusRunning,
}
// Find or create the task node
taskTypStr := taskTypeName(taskType)
node := t.findOrCreateNode(taskTypStr, name, fromPayload)
if node != nil {
node.Status = TaskStatusRunning
node.StartedAt = &now
t.currentTask = node
}
return &exec
}
// CompleteTask marks a task as completed.
func (t *TaskTracker) CompleteTask(exec *TaskExecution, itemsCount int, hasMore bool, err error) {
t.mu.Lock()
defer t.mu.Unlock()
now := time.Now()
exec.CompletedAt = &now
exec.Duration = now.Sub(exec.StartedAt)
exec.ItemsCount = itemsCount
exec.HasMore = hasMore
if err != nil {
exec.Status = TaskStatusFailed
exec.Error = err.Error()
t.stats.FailureCount++
} else {
exec.Status = TaskStatusCompleted
t.stats.SuccessCount++
}
t.stats.TotalExecutions++
t.stats.TotalItemsFetched += itemsCount
t.stats.TotalDuration += exec.Duration
t.stats.LastExecutionAt = &now
// Add to history
t.executions = append(t.executions, *exec)
if len(t.executions) > 1000 {
t.executions = t.executions[1:]
}
// Update node
if t.currentTask != nil {
t.currentTask.Status = exec.Status
t.currentTask.CompletedAt = &now
t.currentTask.Duration = exec.Duration
t.currentTask.ItemsCount += itemsCount
if err != nil {
t.currentTask.Error = err.Error()
}
t.currentTask.Executions = append(t.currentTask.Executions, *exec)
t.currentTask = nil
}
}
func (t *TaskTracker) findOrCreateNode(taskType string, name string, fromPayload json.RawMessage) *TaskNode {
payloadKey := string(fromPayload)
// Recursive search function
var findInNodes func([]*TaskNode) *TaskNode
findInNodes = func(nodes []*TaskNode) *TaskNode {
for _, node := range nodes {
// Match by type and payload (payload is the key for child tasks)
if node.Type == taskType {
// For child tasks, match by fromPayload
if payloadKey != "" && string(node.FromPayload) == payloadKey {
return node
}
// For root tasks with no payload, match by name or just type
if payloadKey == "" && (name == "" || node.Name == name) {
return node
}
}
// Search children recursively
if len(node.Children) > 0 {
if found := findInNodes(node.Children); found != nil {
return found
}
}
}
return nil
}
// Search existing tree
if found := findInNodes(t.rootTree); found != nil {
return found
}
// Create new node at root if not found (shouldn't happen for child tasks)
node := &TaskNode{
ID: uuid.New().String(),
Type: taskType,
Name: name,
Status: TaskStatusPending,
FromPayload: fromPayload,
Executions: make([]TaskExecution, 0),
}
t.rootTree = append(t.rootTree, node)
return node
}
// AddChildTask adds a child task node (for tasks spawned by fetched items).
func (t *TaskTracker) AddChildTask(parentType models.TaskType, childType models.TaskType, childName string, fromPayload json.RawMessage) *TaskNode {
t.mu.Lock()
defer t.mu.Unlock()
parentTypStr := taskTypeName(parentType)
childTypStr := taskTypeName(childType)
// Find parent
var parent *TaskNode
for _, node := range t.rootTree {
if node.Type == parentTypStr {
parent = node
break
}
}
if parent == nil {
return nil
}
// Check if child already exists
payloadKey := string(fromPayload)
for _, child := range parent.Children {
if child.Type == childTypStr && string(child.FromPayload) == payloadKey {
return child
}
}
// Create child node
child := &TaskNode{
ID: uuid.New().String(),
Type: childTypStr,
Name: childName,
Status: TaskStatusPending,
FromPayload: fromPayload,
Executions: make([]TaskExecution, 0),
Children: make([]*TaskNode, 0),
}
parent.Children = append(parent.Children, child)
return child
}
// SetStepMode enables or disables step-by-step execution.
func (t *TaskTracker) SetStepMode(enabled bool) {
t.mu.Lock()
defer t.mu.Unlock()
t.stepMode = enabled
}
// IsStepMode returns whether step mode is enabled.
func (t *TaskTracker) IsStepMode() bool {
t.mu.RLock()
defer t.mu.RUnlock()
return t.stepMode
}
// Step signals to continue to the next task (when in step mode).
func (t *TaskTracker) Step() {
select {
case t.stepChan <- struct{}{}:
default:
}
}
// WaitForStep waits for a step signal (when in step mode).
func (t *TaskTracker) WaitForStep() bool {
if !t.IsStepMode() {
return true
}
select {
case <-t.stepChan:
return true
case <-t.stopChan:
return false
}
}
// Reset resets the task tracker.
func (t *TaskTracker) Reset() {
t.mu.Lock()
defer t.mu.Unlock()
// Reset all node statuses
var resetNodes func([]*TaskNode)
resetNodes = func(nodes []*TaskNode) {
for _, node := range nodes {
node.Status = TaskStatusPending
node.StartedAt = nil
node.CompletedAt = nil
node.Duration = 0
node.Error = ""
node.ItemsCount = 0
node.Executions = make([]TaskExecution, 0)
if len(node.Children) > 0 {
resetNodes(node.Children)
}
}
}
resetNodes(t.rootTree)
t.executions = make([]TaskExecution, 0)
t.currentTask = nil
t.stats = TaskStats{}
}
// TaskTreeSummary provides a summary view of the task tree.
type TaskTreeSummary struct {
Tree []*TaskNodeSummary `json:"tree"`
CurrentTask *TaskNodeSummary `json:"current_task,omitempty"`
Stats TaskStats `json:"stats"`
StepMode bool `json:"step_mode"`
IsRunning bool `json:"is_running"`
}
// TaskNodeSummary is a simplified task node for the UI.
type TaskNodeSummary struct {
ID string `json:"id"`
Type string `json:"type"`
Name string `json:"name,omitempty"`
Status TaskStatus `json:"status"`
Duration string `json:"duration,omitempty"`
ItemsCount int `json:"items_count"`
Error string `json:"error,omitempty"`
Children []*TaskNodeSummary `json:"children,omitempty"`
ChildCount int `json:"child_count"`
LastExecTime string `json:"last_exec_time,omitempty"`
FromPayload json.RawMessage `json:"from_payload,omitempty"`
}
// GetSummary returns a summary of the task tree.
func (t *TaskTracker) GetSummary() TaskTreeSummary {
t.mu.RLock()
defer t.mu.RUnlock()
summary := TaskTreeSummary{
Tree: t.summarizeNodes(t.rootTree),
Stats: t.stats,
StepMode: t.stepMode,
IsRunning: t.currentTask != nil,
}
if t.currentTask != nil {
summary.CurrentTask = t.summarizeNode(t.currentTask)
}
return summary
}
func (t *TaskTracker) summarizeNodes(nodes []*TaskNode) []*TaskNodeSummary {
var result []*TaskNodeSummary
for _, node := range nodes {
result = append(result, t.summarizeNode(node))
}
return result
}
func (t *TaskTracker) summarizeNode(node *TaskNode) *TaskNodeSummary {
s := &TaskNodeSummary{
ID: node.ID,
Type: node.Type,
Name: node.Name,
Status: node.Status,
ItemsCount: node.ItemsCount,
Error: node.Error,
ChildCount: len(node.Children),
FromPayload: node.FromPayload,
}
if node.Duration > 0 {
s.Duration = fmt.Sprintf("%.2fs", node.Duration.Seconds())
}
if node.CompletedAt != nil {
s.LastExecTime = node.CompletedAt.Format("15:04:05")
}
// Include children (limit depth to avoid huge payloads)
if len(node.Children) > 0 && len(node.Children) <= 50 {
s.Children = t.summarizeNodes(node.Children)
}
return s
}