410a131cec
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.
224 lines
6.4 KiB
Go
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
|
|
}
|