feat(docker-watcher): phase 11 - frontend embed & SSE

Embed SvelteKit static build in Go binary via go:embed. Event bus
for pub/sub with deploy log, instance status, and deploy status events.
SSE endpoints for real-time streaming. Frontend SSE client with
exponential backoff reconnection. Makefile for build pipeline.
Update Phase 12 auth plan with OAuth2/OIDC support.
This commit is contained in:
2026-03-27 22:30:25 +03:00
parent d40cf10f88
commit 5558396bb7
16 changed files with 844 additions and 73 deletions
+60 -1
View File
@@ -9,6 +9,7 @@ import (
"github.com/alexei/docker-watcher/internal/crypto"
"github.com/alexei/docker-watcher/internal/docker"
"github.com/alexei/docker-watcher/internal/events"
"github.com/alexei/docker-watcher/internal/health"
"github.com/alexei/docker-watcher/internal/notify"
"github.com/alexei/docker-watcher/internal/npm"
@@ -25,9 +26,15 @@ type Deployer struct {
store *store.Store
health *health.Checker
notifier *notify.Notifier
eventBus EventPublisher
encKey [32]byte
}
// EventPublisher is the interface for publishing events to the event bus.
type EventPublisher interface {
Publish(evt events.Event)
}
// New creates a new Deployer with all required dependencies.
func New(
dockerClient *docker.Client,
@@ -35,6 +42,7 @@ func New(
st *store.Store,
checker *health.Checker,
notifier *notify.Notifier,
eventBus EventPublisher,
encKey [32]byte,
) *Deployer {
return &Deployer{
@@ -43,6 +51,7 @@ func New(
store: st,
health: checker,
notifier: notifier,
eventBus: eventBus,
encKey: encKey,
}
}
@@ -91,6 +100,7 @@ func (d *Deployer) TriggerDeploy(ctx context.Context, projectID, stageID, imageT
if deployErr != nil {
d.logDeploy(deploy.ID, fmt.Sprintf("Deploy failed: %v", deployErr), "error")
d.publishDeployStatus(deploy.ID, projectID, stageID, imageTag, "failed", deployErr.Error())
d.rollback(ctx, deploy.ID, containerID, npmProxyID, instanceID)
d.notifier.Send(settings.NotificationURL, notify.Event{
@@ -108,6 +118,7 @@ func (d *Deployer) TriggerDeploy(ctx context.Context, projectID, stageID, imageT
if err := d.store.UpdateDeployStatus(deploy.ID, "success", ""); err != nil {
log.Printf("deployer: update deploy status to success: %v", err)
}
d.publishDeployStatus(deploy.ID, projectID, stageID, imageTag, "success", "")
subdomain := d.buildSubdomain(project, stage, settings, imageTag)
fullURL := fmt.Sprintf("https://%s.%s", subdomain, settings.Domain)
@@ -144,6 +155,7 @@ func (d *Deployer) executeDeploy(
if err := d.store.UpdateDeployStatus(deployID, "pulling", ""); err != nil {
log.Printf("deployer: update deploy status: %v", err)
}
d.publishDeployStatus(deployID, project.ID, stage.ID, imageTag, "pulling", "")
d.logDeploy(deployID, fmt.Sprintf("Pulling image %s:%s", project.Image, imageTag), "info")
authConfig, err := d.buildRegistryAuth(project)
@@ -167,6 +179,7 @@ func (d *Deployer) executeDeploy(
if err := d.store.UpdateDeployStatus(deployID, "starting", ""); err != nil {
log.Printf("deployer: update deploy status: %v", err)
}
d.publishDeployStatus(deployID, project.ID, stage.ID, imageTag, "starting", "")
// Pre-generate instance ID so it can be set as a container label.
instanceID = uuid.New().String()
@@ -224,12 +237,14 @@ func (d *Deployer) executeDeploy(
if err := d.store.UpdateInstanceStatus(instanceID, "running"); err != nil {
log.Printf("deployer: update instance status to running: %v", err)
}
d.publishInstanceStatus(instanceID, project.ID, stage.ID, "running")
d.logDeploy(deployID, "Container started", "info")
// Step 4: Configure NPM proxy.
if err := d.store.UpdateDeployStatus(deployID, "configuring_proxy", ""); err != nil {
log.Printf("deployer: update deploy status: %v", err)
}
d.publishDeployStatus(deployID, project.ID, stage.ID, imageTag, "configuring_proxy", "")
npmProxyID, err = d.configureProxy(ctx, deployID, settings, containerName, project.Port, subdomain)
if err != nil {
@@ -248,6 +263,7 @@ func (d *Deployer) executeDeploy(
if err := d.store.UpdateDeployStatus(deployID, "health_checking", ""); err != nil {
log.Printf("deployer: update deploy status: %v", err)
}
d.publishDeployStatus(deployID, project.ID, stage.ID, imageTag, "health_checking", "")
healthURL := fmt.Sprintf("http://%s:%d%s", containerName, project.Port, project.Healthcheck)
d.logDeploy(deployID, fmt.Sprintf("Running health check: %s", healthURL), "info")
@@ -466,11 +482,54 @@ func (d *Deployer) parseEnvVars(envJSON string) []string {
return vars
}
// logDeploy appends a log entry for a deploy. Errors are logged to stderr but not propagated.
// logDeploy appends a log entry for a deploy and publishes it on the event bus.
// Errors are logged to stderr but not propagated.
func (d *Deployer) logDeploy(deployID, message, level string) {
if err := d.store.AppendDeployLog(deployID, message, level); err != nil {
log.Printf("deployer: append deploy log: %v", err)
}
if d.eventBus != nil {
d.eventBus.Publish(events.Event{
Type: events.EventDeployLog,
Payload: events.DeployLogPayload{
DeployID: deployID,
Message: message,
Level: level,
},
})
}
}
// publishDeployStatus publishes a deploy status change event on the bus.
func (d *Deployer) publishDeployStatus(deployID, projectID, stageID, imageTag, status, deployErr string) {
if d.eventBus != nil {
d.eventBus.Publish(events.Event{
Type: events.EventDeployStatus,
Payload: events.DeployStatusPayload{
DeployID: deployID,
ProjectID: projectID,
StageID: stageID,
ImageTag: imageTag,
Status: status,
Error: deployErr,
},
})
}
}
// publishInstanceStatus publishes an instance status change event on the bus.
func (d *Deployer) publishInstanceStatus(instanceID, projectID, stageID, status string) {
if d.eventBus != nil {
d.eventBus.Publish(events.Event{
Type: events.EventInstanceStatus,
Payload: events.InstanceStatusPayload{
InstanceID: instanceID,
ProjectID: projectID,
StageID: stageID,
Status: status,
},
})
}
}
// truncateID safely truncates a Docker ID to 12 characters for display.