Control Ports

The control port (_control) drives dashboard widgets: buttons, status displays, and runtime control affordances (Start/Stop and the like). Widget interactions are delivered to your component through the module.ControlHandler capability interface — not through Handle.

Control Port Overview

+-----------------------------------------------------------------------------+
|                         CONTROL PORT FLOW                                    |
+-----------------------------------------------------------------------------+

    +---------------+        +---------------+        +-------------------+
    |  Dashboard /  |        |  TinySignal   |        |     Component     |
    |  Visual UI    |------->|               |------->|                   |
    | User clicks   | Create |  CR created   | Decode |  OnControl(ctx,   |
    | widget button |        |  in K8s       |  into  |     control)      |
    +---------------+        +---------------+  type  +-------------------+

The widget the UI renders comes from the _control port's Configuration — a Go value whose reflected schema describes the form, and whose current field values show the component's state.

Defining a Control Port

The control port is a source port (Source: true) whose Configuration is the current control value:

// ControlStopped is the _control shape when the ticker is not running
type ControlStopped struct {
    Context Context `json:"context" required:"true" title:"Context"`
    Status  string  `json:"status" title:"Status" readonly:"true"`
    Start   bool    `json:"start" format:"button" title:"Start" required:"true"`
}

// ControlRunning is the _control shape when the ticker is running
type ControlRunning struct {
    Context Context `json:"context" required:"true" title:"Context" readonly:"true"`
    Status  string  `json:"status" title:"Status" readonly:"true"`
    Stop    bool    `json:"stop" format:"button" title:"Stop" required:"true"`
}

func (t *Component) Ports() []module.Port {
    return []module.Port{
        {
            Name:          v1alpha1.ControlPort, // "_control"
            Label:         "Control",
            Source:        true,
            Configuration: t.getControl(),
        },
        // Other ports...
    }
}

func (t *Component) getControl() interface{} {
    if t.isRunning() {
        return ControlRunning{Status: "Running", Context: t.settings.Context, Stop: true}
    }
    return ControlStopped{Status: "Not running", Context: t.settings.Context, Start: true}
}

Returning a different Go type per state (ControlRunning vs ControlStopped) is the canonical way to change which buttons the widget shows.

Button Format

The format:"button" tag renders a field as a clickable button:

type Control struct {
    // Shows as a button
    Send bool `json:"send" format:"button" title:"Send" required:"true"`

    // Display-only status text
    Status string `json:"status" title:"Status" readonly:"true"`
}

When the user clicks, the submitted control value arrives with that button's field set to true.

Handling Control Messages

Implement module.ControlHandler:

type ControlHandler interface {
    OnControl(ctx context.Context, control any) error
}

Basic Pattern

func (t *Component) OnControl(ctx context.Context, msg any) error {
    if msg == nil {
        return nil
    }
    // Only the leader acts on control clicks in multi-replica modules
    if !utils.IsLeader(ctx) {
        return nil
    }

    switch ctrl := msg.(type) {
    case ControlRunning:
        if ctrl.Stop {
            return t.stop()
        }
    case ControlStopped:
        if t.isRunning() {
            return nil
        }
        t.settings.Context = ctrl.Context
        go t.run(context.Background())
    }
    return nil
}

The message is decoded into whichever type the _control port's Configuration currently declares — a type switch handles the per-state shapes.

Updating the Widget

After a state change, emit the fresh control value on the control port. Components embed module.Base to get a long-lived Emit handler usable from any goroutine:

type Component struct {
    module.Base
    // ...
}

// after starting/stopping:
t.Emit(context.Background(), v1alpha1.ControlPort, t.getControl())

The runner invalidates its port cache and patches the node status, so the UI re-renders the widget with the new shape and values (e.g. the Stop button replaces Start).

Complete Example: Ticker

Condensed from the real ticker component in common-module:

package ticker

import (
    "context"
    "fmt"
    "sync"
    "time"

    "github.com/tiny-systems/module/api/v1alpha1"
    "github.com/tiny-systems/module/module"
    "github.com/tiny-systems/module/pkg/utils"
    "github.com/tiny-systems/module/registry"
)

const OutPort = "out"

type Context any

type Settings struct {
    Context Context `json:"context" configurable:"true" title:"Context" description:"Arbitrary message to send each period"`
    Delay   int     `json:"delay" required:"true" title:"Delay (ms)" minimum:"0" default:"1000"`
}

type ControlStopped struct {
    Context Context `json:"context" required:"true" title:"Context"`
    Status  string  `json:"status" title:"Status" readonly:"true"`
    Start   bool    `json:"start" format:"button" title:"Start" required:"true"`
}

type ControlRunning struct {
    Context Context `json:"context" required:"true" title:"Context" readonly:"true"`
    Status  string  `json:"status" title:"Status" readonly:"true"`
    Stop    bool    `json:"stop" format:"button" title:"Stop" required:"true"`
}

type Component struct {
    module.Base

    settings       Settings
    cancelFunc     context.CancelFunc
    cancelFuncLock *sync.Mutex
}

func (t *Component) Instance() module.Component {
    return &Component{
        cancelFuncLock: &sync.Mutex{},
        settings:       Settings{Delay: 1000},
    }
}

func (t *Component) GetInfo() module.ComponentInfo {
    return module.ComponentInfo{
        Name:        "ticker",
        Description: "Ticker",
        Info:        "Periodic emitter. Click Start to begin emitting context on Out.",
        Tags:        []string{"SDK"},
    }
}

func (t *Component) OnSettings(_ context.Context, msg any) error {
    in, ok := msg.(Settings)
    if !ok {
        return fmt.Errorf("invalid settings")
    }
    t.settings = in
    return nil
}

func (t *Component) OnControl(ctx context.Context, msg any) error {
    if msg == nil || !utils.IsLeader(ctx) {
        return nil
    }
    switch ctrl := msg.(type) {
    case ControlRunning:
        if ctrl.Stop {
            return t.stop()
        }
    case ControlStopped:
        t.settings.Context = ctrl.Context
        if t.isRunning() {
            return nil
        }
        go t.run(context.Background())
    }
    return nil
}

func (t *Component) run(ctx context.Context) {
    runCtx, cancel := context.WithCancel(ctx)
    defer cancel()

    t.setCancelFunc(cancel)
    t.Emit(context.Background(), v1alpha1.ControlPort, t.getControl()) // widget -> Running

    defer func() {
        t.setCancelFunc(nil)
        t.Emit(context.Background(), v1alpha1.ControlPort, t.getControl()) // widget -> Stopped
    }()

    timer := time.NewTimer(0)
    defer timer.Stop()

    for {
        select {
        case <-timer.C:
            if err := t.Emit(runCtx, OutPort, t.settings.Context).Err(); err != nil {
                // downstream error — log and keep ticking
            }
            timer.Reset(time.Duration(t.settings.Delay) * time.Millisecond)
        case <-runCtx.Done():
            return
        }
    }
}

func (t *Component) getControl() interface{} {
    if t.isRunning() {
        return ControlRunning{Status: "Running", Context: t.settings.Context, Stop: true}
    }
    return ControlStopped{Status: "Not running", Context: t.settings.Context, Start: true}
}

func (t *Component) setCancelFunc(f context.CancelFunc) {
    t.cancelFuncLock.Lock()
    defer t.cancelFuncLock.Unlock()
    t.cancelFunc = f
}

func (t *Component) isRunning() bool {
    t.cancelFuncLock.Lock()
    defer t.cancelFuncLock.Unlock()
    return t.cancelFunc != nil
}

func (t *Component) stop() error {
    t.cancelFuncLock.Lock()
    defer t.cancelFuncLock.Unlock()
    if t.cancelFunc != nil {
        t.cancelFunc()
    }
    return nil
}

func (t *Component) Ports() []module.Port {
    return []module.Port{
        {Name: v1alpha1.SettingsPort, Label: "Settings", Configuration: t.settings},
        {Name: OutPort, Label: "Out", Source: true, Position: module.Right, Configuration: new(Context)},
        {Name: v1alpha1.ControlPort, Label: "Control", Source: true, Configuration: t.getControl()},
    }
}

// Handle is unreachable — every declared port is either system or source-only.
func (t *Component) Handle(_ context.Context, _ module.Handler, port string, _ any) module.Result {
    return module.Fail(fmt.Errorf("ticker has no business-port input: got %q", port))
}

var (
    _ module.Component       = (*Component)(nil)
    _ module.SettingsHandler = (*Component)(nil)
    _ module.ControlHandler  = (*Component)(nil)
)

func init() {
    registry.Register((&Component{}).Instance())
}

Signal Component Example

A fire-and-forget trigger — one Send button, no running state:

type Control struct {
    Context Context `json:"context" required:"true" title:"Context"`
    Send    bool    `json:"send" format:"button" title:"Send" required:"true"`
}

func (t *Component) OnControl(ctx context.Context, msg any) error {
    if !utils.IsLeader(ctx) {
        return nil
    }
    ctrl, ok := msg.(Control)
    if !ok {
        return fmt.Errorf("invalid control msg: expected Control, got %T", msg)
    }
    if !ctrl.Send {
        return nil
    }

    // The Send dialog carries the user's values; fall back to settings
    // when Send arrives without a context.
    sendCtx := ctrl.Context
    if sendCtx == nil {
        sendCtx = t.settings.Context
    }

    // Fire-and-forget: detach from the control call's context
    go t.Emit(context.Background(), OutPort, sendCtx)
    return nil
}

Control with Parameters

Widget fields other than buttons carry data alongside the click:

type Control struct {
    Message string `json:"message" title:"Message" required:"true"`
    Send    bool   `json:"send" format:"button" title:"Send Custom" required:"true"`
}

func (c *Component) OnControl(ctx context.Context, msg any) error {
    ctrl, ok := msg.(Control)
    if !ok || !ctrl.Send {
        return nil
    }
    return c.Emit(ctx, "output", ctrl.Message).Err()
}

Runtime-Authored Forms

When the form's shape is only known at runtime (no compile-time Go type — e.g. the ask component renders a JSON Schema it received as data), publish the schema verbatim through Port.Schema and keep a map as the decode target:

{
    Name:          v1alpha1.ControlPort,
    Label:         "Control",
    Source:        true,
    Position:      module.Top,
    Configuration: c.control(), // map[string]interface{} — decode target
    Schema:        c.form(),    // json.RawMessage — authored form, verbatim
}

See Defining Ports for the Schema field's rules.

Best Practices

1. Leader-Only for State Changes

func (c *Component) OnControl(ctx context.Context, msg any) error {
    if !utils.IsLeader(ctx) {
        return nil // skip on non-leader pods
    }
    // process control
}

2. Update the Widget After State Changes

c.Emit(context.Background(), v1alpha1.ControlPort, c.getControl())

3. Thread-Safe State Behind the Widget

func (c *Component) getControl() interface{} {
    c.mu.Lock()
    defer c.mu.Unlock()
    if c.isRunning {
        return ControlRunning{ /* ... */ }
    }
    return ControlStopped{ /* ... */ }
}

4. Clear Button Labels

// Good: clear action
Start bool `json:"start" format:"button" title:"Start Processing" required:"true"`

// Bad: unclear
Go bool `json:"go" format:"button" title:"Go" required:"true"`

Next Steps