Files
tiny-forge/internal/events/bus.go
T
alexei.dolgolyov 410a131cec feat(apps): stepped creation wizard, branch previews, and app-creation fixes
This session (frontend focus):
- Rebuild /apps/new as a 4-step wizard (Basics → Configure → Trigger → Review):
  WizardRail, SourceKindPicker card grid, AppManifest review, per-step validation,
  ConfirmDialog-based unsaved-changes guard.
- Extract lib/workload/sourceForms.ts (single source of truth for source_config)
  + {Image,Compose,Static,Dockerfile}SourceForm + StaticDiscoveryWizard; fold the
  /apps/[id] edit form onto the same components (removes the duplication). Add
  vitest + sourceForms unit tests.
- Branch preview environments UI: /chain is_preview/preview_branch + a Preview
  environments panel on /apps/[id] (per-branch URLs, ConfirmDialog teardown, armed
  state); RegistryImagePicker on the registry trigger and the image source.
- Fixes: image-inspect 404 -> admin-gated POST /api/discovery/image/inspect;
  conflict-panel blur flicker; friendly localized discovery errors; CPU/Memory
  label hints; dashboard + /apps "Total workloads" count only source_kind workloads
  (drop stale trigger_kind gate); NPM cert/access-list name cache; EntityPicker
  empty-list guard.
- Update CLAUDE.md frontend conventions + add a Build & Test section.

Also captures pre-existing in-progress platform work (not from this session):
workload notifications, Prometheus metrics export, store lockfile, health probes,
backup hardening, and related store/webhook/scheduler changes.
2026-05-29 02:09:54 +03:00

224 lines
6.4 KiB
Go

package events
import (
"encoding/json"
"log/slog"
"sync"
)
// EventType identifies the kind of event being published.
type EventType string
const (
// EventDeployLog is emitted when a new deploy log line is appended.
EventDeployLog EventType = "deploy_log"
// EventInstanceStatus is emitted when an instance status changes.
EventInstanceStatus EventType = "instance_status"
// EventDeployStatus is emitted when a deploy status changes.
EventDeployStatus EventType = "deploy_status"
// EventLog is emitted for audit trail and operational log entries.
EventLog EventType = "event_log"
// EventStaticSiteStatus is emitted when a static site status changes.
EventStaticSiteStatus EventType = "static_site_status"
// EventStackStatus is emitted when a compose stack status changes.
EventStackStatus EventType = "stack_status"
// EventBuildLog is emitted for each line of a streaming image build.
// Per-line events are ephemeral (not persisted to the event_log) — they
// exist to drive a live tail UI during the slow "building" phase of a
// dockerfile-source deploy. Subscribers should filter by WorkloadID
// because every dockerfile deploy on the box publishes on the same bus.
EventBuildLog EventType = "build_log"
)
// Event is a single event published on the bus.
type Event struct {
Type EventType `json:"type"`
Payload any `json:"payload"`
}
// DeployLogPayload is the payload for EventDeployLog events.
type DeployLogPayload struct {
DeployID string `json:"deploy_id"`
Message string `json:"message"`
Level string `json:"level"`
}
// InstanceStatusPayload is the payload for EventInstanceStatus events.
type InstanceStatusPayload struct {
InstanceID string `json:"instance_id"`
ProjectID string `json:"project_id"`
StageID string `json:"stage_id"`
Status string `json:"status"`
}
// DeployStatusPayload is the payload for EventDeployStatus events.
type DeployStatusPayload struct {
DeployID string `json:"deploy_id"`
ProjectID string `json:"project_id"`
StageID string `json:"stage_id"`
ImageTag string `json:"image_tag"`
Status string `json:"status"`
Error string `json:"error,omitempty"`
}
// EventLogPayload is the payload for EventLog events (audit trail).
type EventLogPayload struct {
ID int64 `json:"id"`
Source string `json:"source"`
Severity string `json:"severity"`
Message string `json:"message"`
Metadata string `json:"metadata"`
CreatedAt string `json:"created_at"`
}
// StaticSiteStatusPayload is the payload for EventStaticSiteStatus events.
type StaticSiteStatusPayload struct {
SiteID string `json:"site_id"`
Name string `json:"name"`
Status string `json:"status"`
}
// BuildLogPayload is the payload for EventBuildLog events. One event
// per non-empty line read off the daemon's NDJSON build stream.
type BuildLogPayload struct {
WorkloadID string `json:"workload_id"`
Line string `json:"line"`
Stream string `json:"stream,omitempty"`
}
// StackStatusPayload is the payload for EventStackStatus events.
type StackStatusPayload struct {
StackID string `json:"stack_id"`
Name string `json:"name"`
Status string `json:"status"`
Error string `json:"error,omitempty"`
}
// Subscriber is a channel that receives events.
type Subscriber chan Event
// Bus is a simple in-process pub/sub event bus.
// It supports topic-based filtering and per-subscriber buffering.
type Bus struct {
mu sync.RWMutex
subscribers map[Subscriber]subscriberInfo
}
type subscriberInfo struct {
filter func(Event) bool
}
// New creates a new event bus.
func New() *Bus {
return &Bus{
subscribers: make(map[Subscriber]subscriberInfo),
}
}
// Subscribe registers a new subscriber with an optional filter.
// If filter is nil, the subscriber receives all events.
// The returned channel is buffered to avoid blocking publishers.
func (b *Bus) Subscribe(filter func(Event) bool) Subscriber {
ch := make(Subscriber, 64)
b.mu.Lock()
b.subscribers[ch] = subscriberInfo{filter: filter}
b.mu.Unlock()
return ch
}
// Unsubscribe removes a subscriber and closes its channel.
func (b *Bus) Unsubscribe(ch Subscriber) {
b.mu.Lock()
if _, ok := b.subscribers[ch]; ok {
delete(b.subscribers, ch)
close(ch)
}
b.mu.Unlock()
}
// Publish sends an event to all matching subscribers.
// If a subscriber's buffer is full, the event is dropped for that subscriber
// to avoid blocking the publisher.
func (b *Bus) Publish(evt Event) {
b.mu.RLock()
defer b.mu.RUnlock()
for ch, info := range b.subscribers {
if info.filter != nil && !info.filter(evt) {
continue
}
// Non-blocking send — drop if subscriber is slow.
select {
case ch <- evt:
default:
}
}
}
// PersistFunc is a callback that persists an event log entry.
// It receives source, severity, message, and metadata (JSON string).
// It returns the persisted entry's ID and created_at timestamp.
type PersistFunc func(source, severity, message, metadata string) (int64, string, error)
// RegisterPersistentLogger subscribes to the bus and auto-persists warn/error
// events by calling the provided persist function. It also re-publishes the
// persisted event as an EventLog so SSE clients receive it in real-time.
// Call the returned function to unsubscribe.
func (b *Bus) RegisterPersistentLogger(persist PersistFunc) func() {
sub := b.Subscribe(func(evt Event) bool {
if evt.Type != EventDeployLog {
return false
}
p, ok := evt.Payload.(DeployLogPayload)
if !ok {
return false
}
return p.Level == "warn" || p.Level == "error"
})
go func() {
for evt := range sub {
p, ok := evt.Payload.(DeployLogPayload)
if !ok {
continue
}
metaBytes, _ := json.Marshal(map[string]string{"deploy_id": p.DeployID})
metadata := string(metaBytes)
id, createdAt, err := persist("deploy", p.Level, p.Message, metadata)
if err != nil {
slog.Error("failed to persist event log", "source", "deploy", "level", p.Level, "error", err)
continue
}
b.Publish(Event{
Type: EventLog,
Payload: EventLogPayload{
ID: id,
Source: "deploy",
Severity: p.Level,
Message: p.Message,
Metadata: metadata,
CreatedAt: createdAt,
},
})
}
}()
return func() { b.Unsubscribe(sub) }
}
// MarshalEvent serializes an event to a JSON string suitable for SSE data lines.
func MarshalEvent(evt Event) (string, error) {
data, err := json.Marshal(evt)
if err != nil {
return "", err
}
return string(data), nil
}