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
- System Ports - All system ports
- Handling Messages - Message patterns
- Leader-Reader Pattern - Multi-pod coordination